mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-14 08:09:16 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f61537a8c5 | ||
|
|
612e714479 | ||
|
|
a739185740 | ||
|
|
9859e02215 | ||
|
|
95160a304d |
@@ -173,7 +173,7 @@ jobs:
|
||||
|
||||
- name: Test WebUI
|
||||
working-directory: webui
|
||||
run: bun run test:coverage
|
||||
run: bun run test
|
||||
|
||||
- name: Build WebUI
|
||||
working-directory: webui
|
||||
@@ -189,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"
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ Prefer your own infrastructure? Follow the [deployment guide](./docs/deployment.
|
||||
|
||||
## 🌐 WebUI
|
||||
|
||||
The WebUI ships **inside the published wheel** with no separate frontend build. It is the browser workbench for persistent topics, temporary chats, visible agent activity, workspace controls, Apps, Skills, Automations, and settings.
|
||||
The WebUI ships **inside the published wheel** with no separate frontend build. It is the browser workbench for persistent topics, visible agent activity, workspace controls, Apps, Skills, Automations, and settings.
|
||||
|
||||
<p align="center">
|
||||
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
||||
@@ -250,10 +250,9 @@ The WebUI ships **inside the published wheel** with no separate frontend build.
|
||||
Use it to:
|
||||
|
||||
- keep separate topics for different tasks and projects;
|
||||
- use temporary chats when a conversation should not be saved to history or memory;
|
||||
- inspect reasoning, tool calls, file edits, diffs, command output, and generated artifacts;
|
||||
- switch models and workspaces without leaving the conversation;
|
||||
- configure providers and chat channels, connect Apps, discover Skills, and manage Automations from one place.
|
||||
- configure providers, chat channels, Apps, Skills, and Automations from one place.
|
||||
|
||||
See the [WebUI guide](./docs/webui.md) for LAN access, background operation, workspace controls, and the full feature tour. Working on the frontend itself? Use [`webui/README.md`](./webui/README.md).
|
||||
|
||||
|
||||
-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:
|
||||
|
||||
+1
-2
@@ -19,7 +19,7 @@ The recommended first-run path is:
|
||||
3. Configure a provider and model in **Settings → Models**.
|
||||
4. Send `Hello!` before configuring anything else.
|
||||
|
||||
Most people do not need to edit JSON for the first run. The WebUI handles the initial provider, model, and local browser settings. SSH, headless, existing-config, and older-release installs retain `nanobot onboard --wizard` as a terminal fallback. After the WebUI opens, use **Settings** for models and built-in capabilities, **Settings → Channels** for chat apps, and **Apps** for Agent Plugins, CLI Apps, and MCP integrations.
|
||||
Most people do not need to edit JSON for the first run. The WebUI handles the initial provider, model, and local browser settings. SSH, headless, existing-config, and older-release installs retain `nanobot onboard --wizard` as a terminal fallback. After the WebUI opens, use **Settings** for models and built-in capabilities, **Settings → Channels** for chat apps, and **Apps** for CLI App or MCP integrations.
|
||||
|
||||
## Add One Capability
|
||||
|
||||
@@ -32,7 +32,6 @@ Pick the row that matches what you want to accomplish next:
|
||||
| Choose a hosted, OAuth, company, or local model | [Provider Cookbook](./provider-cookbook.md) |
|
||||
| Add model fallbacks | [Configure Model Fallback](./guides/configure-model-fallback.md) |
|
||||
| Enable web search | [Configure Web Search](./guides/configure-web-search.md) |
|
||||
| Manage Agent Plugins, CLI Apps, or MCP integrations | [WebUI Apps](./webui.md#apps) |
|
||||
| Add an MCP tool server | [Configure MCP Tools](./guides/configure-mcp-tools.md) |
|
||||
| Generate images | [Image Generation](./image-generation.md) |
|
||||
| Schedule work or create a local trigger | [Automations](./automations.md) |
|
||||
|
||||
+7
-15
@@ -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
|
||||
@@ -132,6 +125,7 @@ Important files:
|
||||
| Shell execution | `nanobot/agent/tools/shell.py` |
|
||||
| Filesystem tools | `nanobot/agent/tools/filesystem.py` |
|
||||
| Web search/fetch | `nanobot/agent/tools/web.py` |
|
||||
| Browser and computer use | `nanobot/agent/tools/browser_tool.py`, `nanobot/agent/tools/computer_use.py` |
|
||||
| MCP tools | `nanobot/agent/tools/mcp.py` |
|
||||
| Cron | `nanobot/agent/tools/cron.py`, `nanobot/cron/` |
|
||||
| Image generation | `nanobot/agent/tools/image_generation.py` |
|
||||
@@ -149,7 +143,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 +158,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 +174,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/` |
|
||||
@@ -195,7 +189,7 @@ Security-sensitive code paths include:
|
||||
|---|---|
|
||||
| Workspace scope | `nanobot/security/workspace_access.py`, `nanobot/security/workspace_policy.py` |
|
||||
| Shell sandboxing | `nanobot/agent/tools/shell.py` |
|
||||
| SSRF/network checks | `nanobot/security/network.py`, `nanobot/agent/tools/web.py` |
|
||||
| SSRF/network checks | `nanobot/security/network.py`, `nanobot/agent/tools/web.py`, `nanobot/agent/tools/computer_use_backends/browser_playwright.py` |
|
||||
| PTH guard and CLI startup security | `nanobot/security/` and CLI entrypoints |
|
||||
| Channel access control | channel config in `nanobot/channels/*.py` |
|
||||
|
||||
@@ -208,10 +202,8 @@ When changing tools, channels, file access, WebUI workspace behavior, or network
|
||||
| Provider | Add `ProviderSpec` in `providers/registry.py`, add schema field in `config/schema.py`, implement provider only if the generic backend is not enough |
|
||||
| Channel | Export a `ChannelPlugin` descriptor, keep its runtime and optional setup surfaces in one package, and follow [`channel-package-guide.md`](./channel-package-guide.md) |
|
||||
| Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
|
||||
| Agent Plugin | Add a v1 package under `<workspace>/plugins/` and enable it from Apps |
|
||||
| MCP | Add `tools.mcpServers` config or bundle the server in an Agent Plugin |
|
||||
| Skill | Add workspace skills under `<workspace>/skills/`, bundle them in an Agent Plugin, or add built-in skills under `nanobot/skills/` |
|
||||
| CLI App | Add it to the CLI Apps catalog; the installer owns its executable lifecycle and writes a skills-only Agent Plugin |
|
||||
| MCP | Add `tools.mcpServers` config |
|
||||
| Skill | Add workspace skill files under `<workspace>/skills/` or built-in skills under `nanobot/skills/` |
|
||||
|
||||
Prefer existing registry/discovery patterns over ad hoc wiring.
|
||||
|
||||
|
||||
@@ -94,24 +94,6 @@ follow the printed WebUI **Settings → Models** or `nanobot onboard --wizard` r
|
||||
| `nanobot agent --no-markdown` | Print plain text instead of Rich-rendered Markdown |
|
||||
| `nanobot agent --logs` | Show runtime logs while chatting |
|
||||
|
||||
## 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.
|
||||
|
||||
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`.
|
||||
|
||||
+2
-29
@@ -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,39 +125,13 @@ 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
|
||||
|
||||
Agent Plugins are nanobot's common package and activation boundary for
|
||||
installable capabilities. They organize existing extension types instead of
|
||||
replacing them:
|
||||
|
||||
| Part | Role |
|
||||
|---|---|
|
||||
| Agent Plugin | Installable package that can bundle skills, MCP servers, or both |
|
||||
| Skill | Workflow guidance loaded progressively or invoked with `$skill-name` |
|
||||
| MCP server | Runtime tools exposed to the agent |
|
||||
| CLI App | Locally managed executable whose adapter is packaged and activated like a plugin |
|
||||
| Apps | WebUI surface for reviewing and managing these capabilities |
|
||||
|
||||
Native providers, channels, built-in tools, standalone workspace skills, and
|
||||
directly configured MCP servers keep their existing extension paths. See
|
||||
[`webui.md#apps`](./webui.md#apps) for the user-facing flow and
|
||||
[`configuration.md#agent-plugins-v1`](./configuration.md#agent-plugins-v1) for
|
||||
the package contract.
|
||||
|
||||
## Tools and Safety
|
||||
|
||||
Tools are discovered automatically from built-in modules and plugin entry points. Common tool groups include:
|
||||
|
||||
+68
-74
@@ -42,6 +42,7 @@ the focused guides first and come back here for exact fields and defaults.
|
||||
| Add fallback chains | [Model Fallbacks](#model-fallbacks) |
|
||||
| Configure voice transcription | [Transcription Settings](#transcription-settings) |
|
||||
| Tune channel defaults | [Channel Settings](#channel-settings) |
|
||||
| Enable browser or desktop control | [Browser and Computer Use](#browser-and-computer-use) |
|
||||
| Configure web search and fetch | [Web Tools](#web-tools) |
|
||||
| Enable image generation | [Image Generation](#image-generation) |
|
||||
| Add MCP servers | [MCP](#mcp-model-context-protocol) |
|
||||
@@ -330,11 +331,7 @@ By default, OpenAI uses `apiType: "auto"`: nanobot calls Chat Completions normal
|
||||
|
||||
Valid `apiType` values are exactly `auto`, `chat_completions`, and `responses`.
|
||||
|
||||
`extraBody` follows the selected OpenAI API surface. With Chat Completions, nanobot passes
|
||||
ordinary fields through as the SDK `extra_body` value; list-valued `extraBody.tools` is handled
|
||||
specially and appended after generated function tools. With Responses, configure it in Responses
|
||||
API body shape; nanobot merges ordinary top-level fields into the Responses request body, appends
|
||||
`extraBody.tools` after generated function tools, and merges `extraBody.include` without duplicates:
|
||||
`extraBody` follows the selected OpenAI API surface. With Chat Completions, nanobot passes it through as the SDK `extra_body` value. With Responses, configure it in Responses API body shape; nanobot merges ordinary top-level fields into the Responses request body, appends `extraBody.tools` after generated function tools, and merges `extraBody.include` without duplicates:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -360,7 +357,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 +374,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 +388,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.
|
||||
@@ -1674,6 +1671,62 @@ When a channel `send()` raises, nanobot retries at the channel-manager layer. By
|
||||
>
|
||||
> If a channel is completely unreachable, nanobot cannot notify the user through that same channel. Watch logs for `Failed to send to {channel} after N attempts` to spot persistent delivery failures.
|
||||
|
||||
## Browser and Computer Use
|
||||
|
||||
Browser and desktop control are optional and disabled by default. Install their runtime first:
|
||||
|
||||
```bash
|
||||
pip install 'nanobot-ai[computer-use]'
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
For normal web interaction, prefer the DOM-based `browser` tool. It gives the model numbered
|
||||
element references and works without vision. Use `computer_use` when the model must see and act
|
||||
on pixels; its `desktop` backend controls the real local machine, while its `browser` backend
|
||||
controls an isolated Playwright page.
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"browser": {
|
||||
"enable": true,
|
||||
"allowedDomains": ["example.com"]
|
||||
},
|
||||
"computerUse": {
|
||||
"enable": false,
|
||||
"backend": "desktop"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|---|---|---|
|
||||
| `tools.browser.enable` | `false` | Register the DOM-based `browser` tool |
|
||||
| `tools.browser.allowedDomains` | `[]` | Optional top-level navigation allowlist; entries include subdomains |
|
||||
| `tools.browser.includeScreenshot` | `false` | Attach a screenshot after browser actions |
|
||||
| `tools.browser.maxSessions` | `8` | Maximum retained browser sessions; least-recently-used state is closed first |
|
||||
| `tools.computerUse.enable` | `false` | Register pixel-based `computer_use` |
|
||||
| `tools.computerUse.backend` | `"desktop"` | `"desktop"` or `"browser"` |
|
||||
| `tools.computerUse.allowedDomains` | `[]` | Navigation allowlist for the browser backend |
|
||||
| `tools.computerUse.targetWidth` / `targetHeight` | `1280` / `800` | Maximum screenshot dimensions exposed to the model |
|
||||
| `tools.computerUse.maxSessions` | `8` | Maximum retained sessions for the browser backend |
|
||||
|
||||
Each nanobot session gets separate browser state. Browser HTTP and WebSocket traffic passes
|
||||
through the shared SSRF policy; local, private, link-local, and metadata targets are blocked
|
||||
unless explicitly permitted with `tools.ssrfWhitelist`. When `maxSessions` is reached, the
|
||||
least-recently-used browser state is closed. `file:` URLs are not accepted.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Browser URL checks are defense in depth, not an egress sandbox: Chromium performs its own DNS
|
||||
> resolution after validation. Use OS/container network isolation when browsing hostile pages.
|
||||
|
||||
> [!WARNING]
|
||||
> The desktop backend can click, type, and change state outside the workspace. Enabling it is an
|
||||
> explicit trust decision: use a trusted model and input source, and run nanobot in a disposable
|
||||
> OS account or VM when unattended. The workspace restriction is not an OS sandbox. Desktop text
|
||||
> input supports ASCII key events; use the browser backend when Unicode text input is required.
|
||||
|
||||
## Web Tools
|
||||
|
||||
nanobot incorporates basic tools for accessing the web. These include searching via APIs, and fetching arbitrary web pages in Markdown format. They are enabled by default, and can be configured in `~/.nanobot/config.json` under `tools.web`.
|
||||
@@ -1921,14 +1974,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
|
||||
@@ -1983,52 +2028,15 @@ Add MCP servers to your `config.json`:
|
||||
}
|
||||
```
|
||||
|
||||
MCP servers can run locally over stdio or connect remotely over HTTP:
|
||||
Two transport modes are supported:
|
||||
|
||||
| Connection | Config | Example |
|
||||
| Mode | Config | Example |
|
||||
|------|--------|---------|
|
||||
| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` |
|
||||
| **Streamable HTTP / SSE** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/mcp`) |
|
||||
|
||||
Remote HTTP servers may use browser OAuth instead of static headers. In the
|
||||
WebUI, open **Apps → MCP → Add MCP server**, choose **Custom**, select HTTP or
|
||||
SSE, and choose **OAuth** under **Authentication**. Save the server, then choose
|
||||
**Connect**. For manual configuration, add `auth: "oauth"` and open
|
||||
**Apps → MCP** to connect. Known presets such as Xmind, Notion, and Linear add
|
||||
the config automatically on first click.
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcpServers": {
|
||||
"notion": {
|
||||
"type": "streamableHttp",
|
||||
"url": "https://mcp.notion.com/mcp",
|
||||
"auth": "oauth"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
nanobot opens the server's authorization page and handles the callback through
|
||||
the gateway. The tools become available immediately when hot reload succeeds;
|
||||
otherwise the WebUI asks for a restart. OAuth tokens and dynamic client
|
||||
registration data are stored in the nanobot data directory under
|
||||
`auth/mcp.json`; they are not written to `config.json`. Removing the MCP server
|
||||
from Apps also removes its saved OAuth credentials. Normal gateway startup never
|
||||
opens a browser or registers a new OAuth client when credentials are
|
||||
missing—interactive authorization starts only after a user clicks **Connect**.
|
||||
|
||||
For a remotely accessed WebUI, HTTPS is recommended. Configure
|
||||
`channels.websocket.publicWsUrl` with the browser-facing `wss://` endpoint so
|
||||
nanobot can register the matching HTTPS callback and finish automatically. A
|
||||
loopback WebUI may use HTTP. When a remote WebUI is served over plain HTTP,
|
||||
nanobot instead registers a localhost callback and asks you to paste the complete
|
||||
callback URL from the browser address bar after authorization.
|
||||
| **HTTP** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/sse`) |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> HTTP/SSE MCP URLs are validated before probing or connecting, and every outgoing MCP HTTP request—including OAuth metadata, client registration, token exchange, and redirects—is validated again. `localhost`, `127.0.0.1`, RFC1918/private IPs, CGNAT/Tailscale ranges, link-local addresses, and cloud metadata endpoints are blocked by default. This can break previously working local or private HTTP MCP configs until the endpoint is explicitly allowed with `tools.ssrfWhitelist`, preferably with a single-host CIDR such as `127.0.0.1/32`, `::1/128`, or `192.168.1.50/32`. Stdio MCP servers are not affected.
|
||||
> HTTP/SSE MCP URLs are validated before probing or connecting, and every outgoing MCP HTTP request is validated again before redirects are followed. `localhost`, `127.0.0.1`, RFC1918/private IPs, CGNAT/Tailscale ranges, link-local addresses, and cloud metadata endpoints are blocked by default. This can break previously working local or private HTTP MCP configs until the endpoint is explicitly allowed with `tools.ssrfWhitelist`, preferably with a single-host CIDR such as `127.0.0.1/32`, `::1/128`, or `192.168.1.50/32`. Stdio MCP servers are not affected.
|
||||
|
||||
Use `toolTimeout` to override the default 30s per-call timeout for slow servers:
|
||||
|
||||
@@ -2103,7 +2111,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
|
||||
@@ -2355,20 +2363,6 @@ Disabled skills are excluded from the main agent's skill summary, from always-on
|
||||
|--------|---------|-------------|
|
||||
| `agents.defaults.disabledSkills` | `[]` | List of skill directory names to exclude from loading. Applies to both built-in skills and workspace skills. |
|
||||
|
||||
### Agent Plugins v1
|
||||
|
||||
nanobot discovers [Agent Plugins](https://agent-plugins.org/) under `<workspace>/plugins/`; a v1 package has `plugin.json` and may add `mcp.json`, `skills/<name>/SKILL.md`, or both. Agent Plugins are the common package and activation boundary for installable capabilities; they do not replace native providers, channels, tools, standalone workspace skills, or directly configured MCP servers.
|
||||
|
||||
Directory presence means installed; activation is explicit in **Apps**. Skills use progressive loading and `$skill-name` invocation, with workspace > plugin > built-in precedence.
|
||||
Enabled `stdio` servers receive contained `PLUGIN_ROOT` and isolated `PLUGIN_DATA` paths; explicit
|
||||
`tools.mcpServers` entries win collisions. Invalid or escaping components are ignored.
|
||||
An enabled package is treated as immutable: changing any packaged file disables it until the user
|
||||
reviews and enables it again. Runtime state belongs under `PLUGIN_DATA`, not the package root.
|
||||
|
||||
Enabled plugins run as the nanobot user; permissions are descriptive, not an OS sandbox. The optional `extensions.dev.nanobot.logo` accepts a contained PNG, JPEG, or WebP up to 256 KiB.
|
||||
|
||||
CLI Apps use the same skills-only package layout while their installer manages executables, updates, and removal. Future catalogs can place packages before using this activation path.
|
||||
|
||||
## Tool Hint Max Length
|
||||
|
||||
Tool hints are the short progress messages shown when the agent calls tools (e.g. `$ cd …/project && npm test`). By default, these are truncated at 40 characters, which can make long commands hard to read.
|
||||
|
||||
+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 \
|
||||
|
||||
@@ -30,15 +30,10 @@ remote HTTP endpoint.
|
||||
For local interactive setup:
|
||||
|
||||
1. Run `nanobot webui` and open **Apps**.
|
||||
2. Choose a known MCP server preset, or add a custom stdio, HTTP, or SSE server.
|
||||
For a custom OAuth server, choose **OAuth** under **Authentication**, save it,
|
||||
and click **Connect**. Presets such as Xmind, Notion, and Linear go straight to
|
||||
**Connect**. Approve access in the browser window. HTTPS and localhost WebUIs
|
||||
return automatically. From a remote plain-HTTP WebUI, copy the complete
|
||||
localhost callback URL from the browser address bar and paste it into nanobot.
|
||||
2. Choose a known integration preset, or add a custom stdio, HTTP, or SSE server.
|
||||
3. Limit the enabled tools when the server exposes more than the task needs.
|
||||
4. Save and restart when prompted.
|
||||
5. Mention the connected MCP server with `@` in the next message and ask for a small test action.
|
||||
5. Mention the integration with `@` in the next message and ask for a small test action.
|
||||
|
||||
For manual or deployment-managed config, add this to `~/.nanobot/config.json`:
|
||||
|
||||
@@ -63,16 +58,12 @@ Restart nanobot and ask a question that requires the MCP tool.
|
||||
- Prefer `enabledTools` over exposing every tool by default.
|
||||
- Use `toolTimeout` for slow MCP operations.
|
||||
- Use HTTP MCP only for endpoints you trust.
|
||||
- For deployment-managed OAuth servers, set `auth` to `oauth` and complete the
|
||||
browser connection from **Apps → MCP**.
|
||||
- Keep MCP server commands stable and versioned in deployment docs or scripts.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Stdio MCP starts a local process; review the command before enabling it.
|
||||
- HTTP/SSE MCP uses nanobot's SSRF guard, including OAuth discovery, registration,
|
||||
token exchange, and redirects.
|
||||
- OAuth credentials live in the nanobot data directory, not in `config.json`.
|
||||
- HTTP/SSE MCP uses nanobot's SSRF guard.
|
||||
- Allow private HTTP MCP hosts only with narrow `tools.ssrfWhitelist` CIDRs.
|
||||
- Do not place secrets in command arguments when environment variables or
|
||||
headers can be used.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -47,8 +47,8 @@ nanobot gateway logs
|
||||
- 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
|
||||
|
||||
@@ -58,7 +58,6 @@ nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test
|
||||
|-----------|---------------|---------|
|
||||
| **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
|
||||
|
||||
+1
-24
@@ -100,29 +100,6 @@ Gateway-style setup for model IDs served through OpenRouter.
|
||||
|
||||
Use the model ID exactly as OpenRouter lists it.
|
||||
|
||||
To opt into OpenRouter server-managed search and fetch, add:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"extraBody": {
|
||||
"tools": [
|
||||
{ "type": "openrouter:web_search" },
|
||||
{ "type": "openrouter:web_fetch" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Chat Completions-compatible OpenRouter
|
||||
[server tools](https://openrouter.ai/docs/guides/features/server-tools), such as those above, are
|
||||
appended to nanobot's generated functions. This keeps unrelated local tools such as `write_file`
|
||||
available in the same request. Responses-only server tools require an API surface that the
|
||||
OpenRouter provider does not currently enable.
|
||||
|
||||
### Eden AI Gateway
|
||||
|
||||
Eden AI exposes an OpenAI-compatible chat-completions endpoint at
|
||||
@@ -287,7 +264,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
|
||||
|
||||
|
||||
+1
-2
@@ -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:
|
||||
|
||||
|
||||
@@ -270,12 +270,6 @@ http://127.0.0.1:8765
|
||||
|
||||
If accessing from another device, bind the WebSocket channel to `0.0.0.0` and set `token` or `tokenIssueSecret`. The WebSocket channel refuses public binds without a token or token issue secret.
|
||||
|
||||
| Symptom | Check |
|
||||
|---|---|
|
||||
| A temporary chat disappeared after a reload or reconnect | This is expected. Temporary chats exist only for the current WebUI connection and are not saved to history or memory. Use a regular topic for anything you need to retain. |
|
||||
| A skills.sh install says that `npx` is required | Install Node.js with `npx` on the gateway machine, or choose a SkillHub skill that does not require `npx`. |
|
||||
| A remote browser says skill installation is disabled | Install from a same-machine WebUI. For a private deployment where every authenticated user is trusted to install third-party skill instructions or scripts, explicitly enable `tools.webuiAllowRemotePackageInstall`. |
|
||||
|
||||
See [`webui.md#lan-access`](./webui.md#lan-access) for LAN setup and [`../webui/README.md`](../webui/README.md) for frontend development.
|
||||
|
||||
## Chat App Problems
|
||||
@@ -319,8 +313,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
|
||||
|
||||
+28
-88
@@ -1,10 +1,10 @@
|
||||
# Nanobot WebUI: Browser Workbench for Self-Hosted AI Agents
|
||||
|
||||
<!-- Meta description: Run nanobot from a browser WebUI with persistent and temporary chats, visible tool activity, workspace controls, Apps, skill discovery, settings, and Automations. -->
|
||||
<!-- Meta description: Run nanobot from a browser WebUI with persistent topics, visible tool activity, workspace controls, Apps, MCP presets, Skills, settings, and Automations. -->
|
||||
|
||||
The WebUI is nanobot's browser workbench for persistent topics, temporary
|
||||
chats, visible agent activity, workspace controls, Apps, skill discovery,
|
||||
settings, and Automations in one place.
|
||||
The WebUI is nanobot's browser workbench for persistent topics, visible
|
||||
agent activity, workspace controls, Apps, Skills, settings, and Automations in
|
||||
one place.
|
||||
|
||||
The published `nanobot-ai` wheel already includes the WebUI bundle. You only need
|
||||
the `webui/` source directory when you are changing the frontend itself.
|
||||
@@ -72,14 +72,14 @@ This path avoids hand-editing `config.json` for normal setup. Use the reference
|
||||
|
||||
| Area | Use it for |
|
||||
|---|---|
|
||||
| Topics | Start persistent topics or temporary chats; switch, search, reorder, fork, or delete persistent topics |
|
||||
| Topics | Start, switch, search, fork, and delete browser topics |
|
||||
| Agent activity | See thinking, tool calls, file edits with diffs, command output, and generated artifacts in context |
|
||||
| Workspace | Pick the project workspace before asking for file or shell work |
|
||||
| Access | Choose the access mode for local capabilities allowed by your gateway configuration |
|
||||
| Composer | Send text, images, voice input, slash commands, and `@` mentions for topics, Apps, or MCP presets |
|
||||
| Channels | Connect and validate chat platforms, install their optional support, and manage saved channel setup |
|
||||
| Apps | Install, test, update, and use local CLI App adapters and MCP presets |
|
||||
| Skills | Inspect and manage installed skills, or discover skills from supported marketplaces |
|
||||
| Skills | Inspect available built-in and workspace skills before relying on them |
|
||||
| Automations | Review, search, run, pause, edit, and delete scheduled and local-trigger agent turns |
|
||||
| Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options |
|
||||
|
||||
@@ -90,10 +90,6 @@ workspace selection, and linked automations. Use a new topic when you want a
|
||||
separate context; use fork when you want to continue from an existing point
|
||||
without changing the original thread.
|
||||
|
||||
Drag a topic within its current sidebar group to keep frequently used work in
|
||||
your preferred order. Drag a topic from the sidebar into the composer when you
|
||||
want to reference it in the next message instead of switching to it.
|
||||
|
||||
The message timeline shows both user-visible replies and agent activity. Long
|
||||
tool or reasoning sections can be expanded when you need the details.
|
||||
|
||||
@@ -107,28 +103,6 @@ File previews follow the active session access mode. Restricted workspace access
|
||||
previews only files under the selected workspace. Full Access can preview files
|
||||
outside the workspace when that access mode is allowed by the gateway.
|
||||
|
||||
## Temporary Chats
|
||||
|
||||
Use a temporary chat for a conversation that should not be added to nanobot's
|
||||
topic history or long-term memory:
|
||||
|
||||
1. Select **New topic**.
|
||||
2. Select the **Temporary chat** control in the page header.
|
||||
3. Send the first message.
|
||||
|
||||
You can keep more than one temporary chat open and switch between them under
|
||||
**Temporary chats** in the sidebar while the current WebUI connection remains
|
||||
open. Reloading or closing the page, restarting the gateway, or losing the
|
||||
WebSocket connection ends all of them. They cannot be recovered afterward.
|
||||
|
||||
Temporary does not mean consequence-free. Requests still go to the configured
|
||||
model provider, and tools can still change files, run commands, or affect
|
||||
external services. Temporary chats always use the default workspace in
|
||||
Restricted mode; the project picker and Full Access are unavailable. Commands
|
||||
and tools that create durable goals, automations, or subagent work are also
|
||||
unavailable. Use a regular topic when you need reusable context, scheduled work,
|
||||
or a result you must retain.
|
||||
|
||||
## Workspace and Access
|
||||
|
||||
Use the workspace picker before starting project-specific work. This gives the
|
||||
@@ -171,8 +145,7 @@ clients.
|
||||
The composer supports plain messages, image attachments, voice input when
|
||||
transcription is configured, slash commands, and `@` mentions for installed Apps
|
||||
or MCP presets. Select another topic from the `@` menu to attach a stable
|
||||
reference, or drag that topic from the sidebar into the composer. Plain text
|
||||
that happens to start with `@` does not attach history.
|
||||
reference; plain text that happens to start with `@` does not attach history.
|
||||
Restricted chats offer topics from the same project, while Full Access chats can
|
||||
reference any WebUI topic. Nanobot reads a referenced topic only when its history
|
||||
is relevant and can link it in the response. The model badge shows the current
|
||||
@@ -198,23 +171,14 @@ Test a new channel with a private DM. When a supported channel sends a pairing c
|
||||
|
||||
## Apps
|
||||
|
||||
Open Apps from the sidebar to review and manage installable capabilities. The
|
||||
default **Ready** view shows only capabilities that can be used immediately:
|
||||
Open Apps from the sidebar to manage tools that nanobot can attach to a chat
|
||||
turn. The default **Ready** view shows only tools that can be used immediately:
|
||||
|
||||
- **Agent Plugins** are local packages that can bundle skills, MCP servers, or
|
||||
both. A package under `<workspace>/plugins/` is installed but remains inactive
|
||||
until you enable it in Apps.
|
||||
- **CLI Apps** are local command-line adapters that nanobot runs on your
|
||||
machine. Their installer manages the executable and exposes its adapter
|
||||
through the same plugin activation model. Installing an adapter does not
|
||||
modify the native desktop or web app it connects to.
|
||||
- **MCP** lists Model Context Protocol servers. Presets provide known
|
||||
configurations, and the **Add MCP server** panel accepts stdio, HTTP, and SSE
|
||||
servers. Custom HTTP/SSE servers can use no authentication, OAuth, or request
|
||||
headers. After saving an OAuth server, choose **Connect** to open its sign-in
|
||||
page. Presets such as Xmind, Notion, and Linear already use OAuth. HTTPS and
|
||||
localhost WebUIs return automatically; a remote plain-HTTP WebUI shows one
|
||||
field for pasting the complete localhost callback URL.
|
||||
- **Apps** are local command-line adapters that nanobot runs on your machine.
|
||||
Installing an adapter does not modify the native desktop or web app it
|
||||
connects to.
|
||||
- **Integrations** are MCP servers. Presets provide known configurations, and
|
||||
the custom integration panel accepts stdio, HTTP, and SSE servers.
|
||||
|
||||
Apps intentionally does not list nanobot runtime support packages such as
|
||||
`api` or `bedrock`. Those packages enable providers, servers, or channels; they
|
||||
@@ -223,7 +187,6 @@ are not tools that can be attached to a turn with `@`. Manage them from
|
||||
included in nanobot and activate automatically when a file is attached. The
|
||||
equivalent CLI for optional integrations remains `nanobot plugins`. See
|
||||
[`cli-reference.md`](./cli-reference.md#optional-features).
|
||||
That command manages nanobot runtime extras, not Agent Plugin packages.
|
||||
|
||||
Some MCP presets connect to hosted keyless endpoints. For example, the Firecrawl
|
||||
preset uses Firecrawl's hosted MCP endpoint for search, scrape, crawl, and
|
||||
@@ -236,26 +199,15 @@ endpoint and exposes `web_search` and `web_fetch` without requiring an API key.
|
||||
It is an optional integration and does not replace nanobot's built-in web search
|
||||
provider; mention `@parallel-search` when a turn should use it.
|
||||
|
||||
After a CLI App or MCP server is available, mention it from the composer with
|
||||
`@` to attach that tool to the next message. Plugin-provided skills participate
|
||||
in normal skill discovery and can be invoked with `$skill-name`.
|
||||
After an App or integration is available, mention it from the composer with
|
||||
`@` to attach that tool to the next message.
|
||||
|
||||
## Skills
|
||||
|
||||
Open **Skills → Installed** to review built-in and workspace-provided skills.
|
||||
You can search and filter them, inspect their instructions and setup
|
||||
requirements, enable or disable them, and delete workspace skills you no longer
|
||||
want.
|
||||
|
||||
Open **Skills → Discover** to browse or search skills from skills.sh and
|
||||
SkillHub. A marketplace skill is copied into the active agent workspace after
|
||||
you confirm the installation. skills.sh installation requires Node.js with
|
||||
`npx`; SkillHub installation does not.
|
||||
|
||||
Marketplace skills are third-party instructions and may include executable
|
||||
scripts. Review the source and instructions before installing one, and enable
|
||||
only skills you trust with the same files, tools, and credentials available to
|
||||
your agent.
|
||||
The Skills view shows the skill instructions available to the agent, including
|
||||
built-in skills and workspace-provided skills. Check this view when you want to
|
||||
know whether nanobot already has a focused workflow for a task before you ask it
|
||||
to perform that task.
|
||||
|
||||
## Automations
|
||||
|
||||
@@ -336,17 +288,10 @@ The gateway refuses to start with `host` set to `"0.0.0.0"` unless `token` or
|
||||
`http://<your-ip>:8765` from the other device and enter the secret in the login
|
||||
form.
|
||||
|
||||
Plain HTTP is enough for basic WebUI access, but browsers expose microphone
|
||||
capture only in secure contexts. Voice input works on same-machine localhost;
|
||||
from another device, serve the WebUI over HTTPS with a certificate that device
|
||||
trusts. Configure [`sslCertfile` and `sslKeyfile`](./websocket.md#tlsssl) on the
|
||||
WebSocket channel and open `https://<your-host>:8765`, or terminate HTTPS at a
|
||||
reverse proxy and use that proxy's HTTPS URL.
|
||||
|
||||
Remote WebUI clients with a valid token can view and use Apps and installed
|
||||
skills. Actions that install missing nanobot support packages or third-party
|
||||
marketplace skills are blocked by default. To let trusted remote administrators
|
||||
perform those installations through the WebUI, opt in explicitly:
|
||||
Remote WebUI clients with a valid token can view and use Apps. Actions that
|
||||
install missing nanobot support packages, such as adding a channel dependency,
|
||||
are blocked by default. To let trusted remote administrators change the Python
|
||||
environment through the WebUI, opt in explicitly:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -357,13 +302,12 @@ perform those installations through the WebUI, opt in explicitly:
|
||||
```
|
||||
|
||||
Use this only for a private deployment where every authenticated WebUI user is
|
||||
trusted to change nanobot's Python environment and install workspace skill
|
||||
instructions or scripts. If you publish the WebUI through Nginx, Caddy,
|
||||
Cloudflare Tunnel, or a similar service, treat it as remote access and leave
|
||||
package and skill installs disabled unless that is intentional.
|
||||
trusted to change the Python environment that nanobot runs in. If you publish
|
||||
the WebUI through Nginx, Caddy, Cloudflare Tunnel, or a similar service, treat it
|
||||
as remote access and leave package installs disabled unless that is intentional.
|
||||
|
||||
Optional feature installs use pip's configured package index, including
|
||||
`PIP_INDEX_URL`. skills.sh marketplace installs use `npx` instead.
|
||||
`PIP_INDEX_URL`.
|
||||
|
||||
Leave remote package installs disabled when the WebUI is exposed beyond a
|
||||
private, trusted network.
|
||||
@@ -378,10 +322,6 @@ If the page does not open, check these in order:
|
||||
4. You are opening port `8765`, not the gateway health port.
|
||||
5. LAN access uses `host: "0.0.0.0"` and a token or token issue secret.
|
||||
|
||||
If voice input asks for a secure connection, use HTTPS with a certificate the
|
||||
device trusts. Browsers do not expose microphone capture to
|
||||
`http://<your-ip>` origins.
|
||||
|
||||
For detailed diagnostics, see
|
||||
[`troubleshooting.md#webui-problems`](./troubleshooting.md#webui-problems).
|
||||
For frontend development, see [`../webui/README.md`](../webui/README.md).
|
||||
|
||||
@@ -42,11 +42,25 @@ 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)
|
||||
|
||||
|
||||
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:
|
||||
|
||||
@@ -33,6 +33,11 @@ COMPACTABLE_TOOLS = frozenset({
|
||||
"read_file", "exec", "grep", "find_files",
|
||||
"web_search", "web_fetch", "list_dir", "list_exec_sessions",
|
||||
})
|
||||
VISUAL_TOOLS = frozenset({"browser", "computer_use"})
|
||||
STALE_SCREENSHOT_PLACEHOLDER = {
|
||||
"type": "text",
|
||||
"text": "[Earlier screenshot omitted; use the latest screenshot from this tool.]",
|
||||
}
|
||||
# read_file is the recovery path for persisted results; exempting it prevents persist->read->persist loops.
|
||||
TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS = frozenset({"read_file"})
|
||||
BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
|
||||
@@ -41,6 +46,12 @@ PLACEHOLDER_TEXTS = frozenset({
|
||||
})
|
||||
|
||||
|
||||
def _is_image_block(value: object) -> bool:
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
return cast(dict[str, Any], value).get("type") in {"image_url", "input_image"}
|
||||
|
||||
|
||||
def _tool_call_name_is_valid(tool_call: Any) -> bool:
|
||||
"""Whether a persisted OpenAI-style tool_call carries a usable name.
|
||||
|
||||
@@ -84,6 +95,10 @@ class ContextGovernor:
|
||||
updated = self.drop_orphan_tool_results(updated)
|
||||
updated = self.backfill_missing_tool_results(updated)
|
||||
updated = self.apply_tool_result_budget(config, updated)
|
||||
updated = self.drop_stale_visual_tool_images(
|
||||
updated,
|
||||
start_index=config.inflight_start_index,
|
||||
)
|
||||
updated = self.compact_inflight_overflow(config, updated, compacted_tool_call_ids)
|
||||
updated = self.snip_history(config, updated)
|
||||
updated = self.drop_orphan_tool_results(updated)
|
||||
@@ -326,6 +341,35 @@ class ContextGovernor:
|
||||
updated[idx]["content"] = normalized
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def drop_stale_visual_tool_images(
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
start_index: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Keep only the latest in-flight screenshot from each visual tool."""
|
||||
seen: set[str] = set()
|
||||
updated = messages
|
||||
for idx in range(len(messages) - 1, start_index - 1, -1):
|
||||
message = messages[idx]
|
||||
name = str(message.get("name") or "")
|
||||
content = message.get("content")
|
||||
if message.get("role") != "tool" or name not in VISUAL_TOOLS:
|
||||
continue
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
content_blocks = cast(list[object], content)
|
||||
blocks = [block for block in content_blocks if not _is_image_block(block)]
|
||||
if len(blocks) == len(content_blocks):
|
||||
continue
|
||||
if name not in seen:
|
||||
seen.add(name)
|
||||
continue
|
||||
if updated is messages:
|
||||
updated = [dict(item) for item in messages]
|
||||
updated[idx]["content"] = [dict(STALE_SCREENSHOT_PLACEHOLDER), *blocks]
|
||||
return updated
|
||||
|
||||
def compact_inflight_overflow(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
|
||||
+30
-35
@@ -36,7 +36,6 @@ from nanobot.agent.tools.exec_session import ExecSessionManager
|
||||
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
|
||||
from nanobot.agent.tools.self import MyTool
|
||||
from nanobot.agent.turn_delivery import (
|
||||
TurnDelivery,
|
||||
@@ -95,9 +94,11 @@ from nanobot.utils.runtime import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.tools.mcp import MCPConnection
|
||||
from nanobot.config.schema import (
|
||||
ChannelsConfig,
|
||||
Config,
|
||||
MCPServerConfig,
|
||||
ProviderConfig,
|
||||
ToolsConfig,
|
||||
)
|
||||
@@ -196,11 +197,6 @@ class AgentLoop:
|
||||
def tool_names(self) -> list[str]:
|
||||
return self.tools.tool_names
|
||||
|
||||
@property
|
||||
def last_usage(self) -> Mapping[str, int]:
|
||||
"""Latest aggregate usage exposed through the runtime-control snapshot."""
|
||||
return self._last_usage
|
||||
|
||||
@property
|
||||
def provider(self) -> LLMProvider:
|
||||
"""Provider selected for future turn admissions."""
|
||||
@@ -269,7 +265,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,
|
||||
@@ -377,7 +373,7 @@ 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 = tool_registry if tool_registry is not None else ToolRegistry()
|
||||
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()
|
||||
@@ -397,11 +393,14 @@ class AgentLoop:
|
||||
)
|
||||
self._unified_session = unified_session
|
||||
self._running = False
|
||||
self._mcp_servers = mcp_servers or {}
|
||||
self._mcp_stacks: dict[str, MCPConnection] = {}
|
||||
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()
|
||||
)
|
||||
@@ -449,6 +448,7 @@ class AgentLoop:
|
||||
if model_preset:
|
||||
self.set_model_preset(model_preset, publish_update=False)
|
||||
self._register_default_tools(provider_snapshot_loader=provider_snapshot_loader)
|
||||
self._runtime_vars: dict[str, Any] = {}
|
||||
self._current_iteration: int = 0
|
||||
self.commands = CommandRouter()
|
||||
register_builtin_commands(self.commands)
|
||||
@@ -458,15 +458,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``).
|
||||
@@ -476,12 +471,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
|
||||
@@ -505,6 +494,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=config.tools.mcp_servers,
|
||||
channels_config=config.channels,
|
||||
timezone=defaults.timezone,
|
||||
unified_session=defaults.unified_session,
|
||||
@@ -519,7 +509,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,
|
||||
)
|
||||
|
||||
@@ -634,18 +623,19 @@ class AgentLoop:
|
||||
loader = ToolLoader()
|
||||
registered = loader.load(ctx, self.tools)
|
||||
|
||||
# MyTool receives only the explicit runtime-control capability.
|
||||
# MyTool needs runtime state reference — manual registration
|
||||
if self.tools_config.my.enable:
|
||||
self.tools.register(
|
||||
MyTool(
|
||||
runtime_control=AgentRuntimeControl(self),
|
||||
modify_allowed=self.tools_config.my.allow_set,
|
||||
)
|
||||
MyTool(runtime_state=self, modify_allowed=self.tools_config.my.allow_set)
|
||||
)
|
||||
registered.append("my")
|
||||
|
||||
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 register_runtime_context_provider(
|
||||
self,
|
||||
provider: RuntimeContextProvider,
|
||||
@@ -1157,6 +1147,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:
|
||||
@@ -1247,7 +1238,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."""
|
||||
@@ -1365,24 +1357,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})
|
||||
@@ -1405,6 +1397,8 @@ class AgentLoop:
|
||||
cleanup_steps = (
|
||||
self.subagents.close,
|
||||
self._exec_session_manager.close_all,
|
||||
*(() if not hasattr(self, "tools") else (self.tools.close,)),
|
||||
lambda: agent_context.close_mcp(self),
|
||||
)
|
||||
for cleanup in cleanup_steps:
|
||||
try:
|
||||
@@ -2293,6 +2287,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
|
||||
|
||||
@@ -1,435 +0,0 @@
|
||||
"""Load and activate locally installed Agent Plugin packages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, replace
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import ValidationError
|
||||
|
||||
from nanobot.agent.skills import parse_skill_metadata, valid_skill_metadata
|
||||
from nanobot.config.loader import get_config_path
|
||||
from nanobot.config.schema import MCPServerConfig
|
||||
|
||||
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
|
||||
AGENT_PLUGIN_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"
|
||||
|
||||
_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
|
||||
_SKILL_CACHE: dict[tuple[Path, Path], tuple[tuple[str, Path], ...]] = {}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentPlugin:
|
||||
"""A validated, locally installed Agent Plugins v1 package."""
|
||||
|
||||
name: str
|
||||
root: Path
|
||||
description: str
|
||||
repository: str
|
||||
display_name: str
|
||||
category: str
|
||||
accent_color: str | None
|
||||
logo: str | None
|
||||
permissions: tuple[str, ...]
|
||||
mcp_servers: tuple[str, ...] = ()
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
def _installed_plugins(workspace: Path) -> list[AgentPlugin]:
|
||||
"""Return installed packages found under ``<workspace>/plugins/*``."""
|
||||
workspace = workspace.expanduser().resolve()
|
||||
root = _contained(workspace / "plugins", workspace, directory=True)
|
||||
if root is None:
|
||||
return []
|
||||
plugins: dict[str, AgentPlugin | None] = {}
|
||||
for candidate in _children(root, "Agent Plugins directory"):
|
||||
plugin_root = _contained(candidate, root, directory=True)
|
||||
if plugin_root is None:
|
||||
continue
|
||||
plugin = _load_manifest(plugin_root)
|
||||
if plugin is not None:
|
||||
if plugin.name in plugins:
|
||||
logger.warning("Ignoring duplicate Agent Plugin identity '{}'", plugin.name)
|
||||
plugins[plugin.name] = None
|
||||
else:
|
||||
plugins[plugin.name] = plugin
|
||||
return [plugin for plugin in plugins.values() if plugin is not None]
|
||||
|
||||
|
||||
def enabled_agent_plugin_skills(workspace: Path) -> list[tuple[str, Path]]:
|
||||
"""Verify and return skills from plugins the user has explicitly enabled."""
|
||||
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) -> tuple[Path, ...]:
|
||||
"""Return the last verified skill roots, verifying once on a cache miss."""
|
||||
key = _skill_cache_key(workspace)
|
||||
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]:
|
||||
return (
|
||||
workspace.expanduser().resolve(),
|
||||
get_config_path().expanduser().resolve(),
|
||||
)
|
||||
|
||||
|
||||
def _invalidate_skill_cache(workspace: Path) -> None:
|
||||
_SKILL_CACHE.pop(_skill_cache_key(workspace), None)
|
||||
|
||||
|
||||
def _load_manifest(plugin_root: Path) -> AgentPlugin | None:
|
||||
payload = _read_object(plugin_root / "plugin.json", plugin_root)
|
||||
if payload is None:
|
||||
return None
|
||||
if payload.get("$schema") != AGENT_PLUGIN_SCHEMA:
|
||||
return None
|
||||
name = payload.get("name")
|
||||
if (
|
||||
not isinstance(name, str)
|
||||
or len(name) > 64
|
||||
or _PLUGIN_NAME.fullmatch(name) is None
|
||||
):
|
||||
logger.warning("Ignoring Agent Plugin manifest in '{}': invalid name", plugin_root)
|
||||
return None
|
||||
extension = payload.get("extensions")
|
||||
extension_payload = cast(dict[str, object], extension) if isinstance(extension, dict) else {}
|
||||
nanobot_value = extension_payload.get("dev.nanobot")
|
||||
nanobot = cast(dict[str, object], nanobot_value) if isinstance(nanobot_value, dict) else {}
|
||||
return AgentPlugin(
|
||||
name=name,
|
||||
root=plugin_root,
|
||||
description=_string(payload.get("description")),
|
||||
repository=_string(payload.get("repository")),
|
||||
display_name=_string(nanobot.get("displayName")) or name,
|
||||
category=_string(nanobot.get("category")) or "Plugin",
|
||||
accent_color=_accent_color(nanobot.get("accentColor")),
|
||||
logo=_plugin_logo(nanobot.get("logo"), plugin_root),
|
||||
permissions=_string_tuple(nanobot.get("permissions")),
|
||||
)
|
||||
|
||||
|
||||
def agent_plugin_mcp_servers(
|
||||
workspace: Path,
|
||||
configured: dict[str, MCPServerConfig] | None = None,
|
||||
) -> dict[str, MCPServerConfig]:
|
||||
"""Merge explicitly enabled plugin MCP servers with user configuration.
|
||||
|
||||
User configuration wins on the unlikely event of a namespaced collision.
|
||||
"""
|
||||
servers: dict[str, MCPServerConfig] = {}
|
||||
for plugin in _installed_plugins(workspace):
|
||||
if not _enabled(workspace, plugin):
|
||||
continue
|
||||
plugin_servers = _plugin_mcp_servers(workspace, plugin)
|
||||
for name, server in plugin_servers.items():
|
||||
# ``--`` cannot occur in a valid plugin identity, so multi-server
|
||||
# namespaces cannot collide with a single-server plugin name.
|
||||
host_name = plugin.name if len(plugin_servers) == 1 else f"{plugin.name}--{name}"
|
||||
servers[host_name] = server
|
||||
configured = configured or {}
|
||||
if collisions := servers.keys() & configured.keys():
|
||||
logger.warning("Configured MCP servers override Agent Plugins: {}", ", ".join(sorted(collisions)))
|
||||
return servers | configured
|
||||
|
||||
|
||||
def discover_agent_plugins(workspace: Path) -> list[AgentPlugin]:
|
||||
"""Return component and lifecycle state for discovered plugins."""
|
||||
return [
|
||||
replace(
|
||||
plugin,
|
||||
mcp_servers=tuple(sorted(_plugin_mcp_servers(workspace, plugin))),
|
||||
enabled=_enabled(workspace, plugin),
|
||||
)
|
||||
for plugin in _installed_plugins(workspace)
|
||||
]
|
||||
|
||||
|
||||
def set_agent_plugin_enabled(workspace: Path, name: str, enabled: bool) -> None:
|
||||
"""Enable or disable one installed plugin."""
|
||||
plugin = next((item for item in _installed_plugins(workspace) if item.name == name), None)
|
||||
if plugin is None:
|
||||
raise ValueError(f"unknown Agent Plugin '{name}'")
|
||||
data = _plugin_data_dir(workspace, plugin.name, create=True)
|
||||
marker = data / "enabled"
|
||||
if enabled:
|
||||
activation = _activation_marker(plugin)
|
||||
if activation is None:
|
||||
raise RuntimeError(f"Agent Plugin '{name}' changed while it was being enabled")
|
||||
marker.write_text(activation, encoding="utf-8")
|
||||
marker.chmod(0o600)
|
||||
else:
|
||||
marker.unlink(missing_ok=True)
|
||||
_invalidate_skill_cache(workspace)
|
||||
|
||||
|
||||
def _string(value: object) -> str:
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _string_tuple(value: object) -> tuple[str, ...]:
|
||||
items = cast(list[object], value) if isinstance(value, list) else []
|
||||
return tuple(item.strip() for item in items if isinstance(item, str) and item.strip())
|
||||
|
||||
|
||||
def _accent_color(value: object) -> str | None:
|
||||
return value if isinstance(value, str) and re.fullmatch(r"#[0-9a-fA-F]{6}", value) else None
|
||||
|
||||
|
||||
def _plugin_logo(value: object, plugin_root: Path) -> str | None:
|
||||
"""Resolve nanobot's optional packaged logo extension."""
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str) or not value.startswith("./"):
|
||||
logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root)
|
||||
return None
|
||||
logo = _contained(plugin_root / value[2:], plugin_root)
|
||||
try:
|
||||
data = logo.read_bytes() if logo is not None else b""
|
||||
suffix = logo.suffix.lower() if logo is not None else ""
|
||||
if len(data) <= _MAX_LOGO_BYTES and (
|
||||
suffix == ".png" and data.startswith(b"\x89PNG\r\n\x1a\n")
|
||||
or suffix in {".jpg", ".jpeg"} and data.startswith(b"\xff\xd8\xff")
|
||||
or suffix == ".webp" and data.startswith(b"RIFF") and data[8:12] == b"WEBP"
|
||||
):
|
||||
mime = "jpeg" if suffix in {".jpg", ".jpeg"} else suffix[1:]
|
||||
return f"data:image/{mime};base64,{base64.b64encode(data).decode('ascii')}"
|
||||
except OSError:
|
||||
pass
|
||||
logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root)
|
||||
return None
|
||||
|
||||
|
||||
def _plugin_mcp_servers(workspace: Path, plugin: AgentPlugin) -> dict[str, MCPServerConfig]:
|
||||
payload = _read_object(plugin.root / "mcp.json", plugin.root)
|
||||
if payload is None:
|
||||
return {}
|
||||
raw_servers = payload.get("mcpServers")
|
||||
if (
|
||||
payload.keys() != {"$schema", "mcpServers"}
|
||||
or payload.get("$schema") != AGENT_PLUGIN_MCP_SCHEMA
|
||||
or not isinstance(raw_servers, dict)
|
||||
):
|
||||
logger.warning("Ignoring invalid MCP component for Agent Plugin '{}'", plugin.name)
|
||||
return {}
|
||||
|
||||
data = _plugin_data_dir(workspace, plugin.name, create=True)
|
||||
servers: dict[str, MCPServerConfig] = {}
|
||||
for name, raw in cast(dict[str, object], raw_servers).items():
|
||||
if not name or len(name) > 128 or any(ord(char) < 32 for char in name):
|
||||
logger.warning("Ignoring invalid MCP server name in Agent Plugin '{}'", plugin.name)
|
||||
continue
|
||||
server = _plugin_mcp_server(raw, plugin.root, data)
|
||||
if server is None:
|
||||
logger.warning("Ignoring invalid MCP server '{}' in Agent Plugin '{}'", name, plugin.name)
|
||||
continue
|
||||
servers[name] = server
|
||||
return servers
|
||||
|
||||
|
||||
def _plugin_mcp_server(raw: object, root: Path, data: Path) -> MCPServerConfig | None:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
payload = cast(dict[str, object], raw)
|
||||
if payload.keys() - _MCP_SERVER_FIELDS:
|
||||
return None
|
||||
try:
|
||||
server = MCPServerConfig.model_validate(payload)
|
||||
except ValidationError:
|
||||
return None
|
||||
command = _stdio_command(server.command, root)
|
||||
cwd = _stdio_cwd(payload.get("cwd"), root, data)
|
||||
if server.type != "stdio" or command is None or cwd is None:
|
||||
return None
|
||||
if {"PLUGIN_ROOT", "PLUGIN_DATA"} & server.env.keys():
|
||||
return None
|
||||
return server.model_copy(
|
||||
update={
|
||||
"command": command,
|
||||
"args": [_expand(item, root, data) for item in server.args],
|
||||
"env": {
|
||||
**{key: _expand(value, root, data) for key, value in server.env.items()},
|
||||
"PYTHONDONTWRITEBYTECODE": "1",
|
||||
"PLUGIN_ROOT": str(root),
|
||||
"PLUGIN_DATA": str(data),
|
||||
},
|
||||
"cwd": str(cwd),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _stdio_command(value: object, root: Path) -> str | None:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
if value.startswith("./"):
|
||||
executable = _contained(root / value[2:], root)
|
||||
return str(executable) if executable is not None else None
|
||||
if any(char.isspace() for char in value) or "/" in value or "\\" in value:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _stdio_cwd(value: object, root: Path, data: Path) -> Path | None:
|
||||
if value is None:
|
||||
return root
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
if value.startswith("./"):
|
||||
return _contained(root / value[2:], root, directory=True)
|
||||
for placeholder, base in (("${PLUGIN_ROOT}", root), ("${PLUGIN_DATA}", data)):
|
||||
if value == placeholder or value.startswith(f"{placeholder}/"):
|
||||
relative = value[len(placeholder):].lstrip("/")
|
||||
candidate = (base / relative).resolve()
|
||||
if not candidate.is_relative_to(base):
|
||||
return None
|
||||
if base == data:
|
||||
candidate.mkdir(parents=True, exist_ok=True)
|
||||
candidate.chmod(0o700)
|
||||
return candidate if candidate.is_dir() else None
|
||||
return None
|
||||
|
||||
|
||||
def _expand(value: str, root: Path, data: Path) -> str:
|
||||
return value.replace("${PLUGIN_ROOT}", str(root)).replace("${PLUGIN_DATA}", str(data))
|
||||
|
||||
|
||||
def _plugin_data_dir(workspace: Path, name: str, *, create: bool) -> Path:
|
||||
workspace_id = sha256(str(workspace.expanduser().resolve()).encode()).hexdigest()[:12]
|
||||
current = get_config_path().expanduser().resolve().parent
|
||||
for segment in ("plugin-data", workspace_id, name):
|
||||
path = current / segment
|
||||
if create:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
resolved = path.resolve(strict=create)
|
||||
except OSError as exc:
|
||||
raise RuntimeError("Agent Plugin data directory is unavailable") from exc
|
||||
if not resolved.is_relative_to(current):
|
||||
raise RuntimeError("Agent Plugin data directory escapes its parent")
|
||||
if create:
|
||||
resolved.chmod(0o700)
|
||||
current = resolved
|
||||
return current
|
||||
|
||||
|
||||
def _enabled(workspace: Path, plugin: AgentPlugin) -> bool:
|
||||
marker = _plugin_data_dir(workspace, plugin.name, create=False) / "enabled"
|
||||
try:
|
||||
if not marker.is_file():
|
||||
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 False
|
||||
if current == activation:
|
||||
return True
|
||||
if current == str(plugin.root):
|
||||
marker.write_text(activation, encoding="utf-8")
|
||||
marker.chmod(0o600)
|
||||
return True
|
||||
marker.unlink(missing_ok=True)
|
||||
_invalidate_skill_cache(workspace)
|
||||
return False
|
||||
except OSError:
|
||||
_invalidate_skill_cache(workspace)
|
||||
return False
|
||||
|
||||
|
||||
def _activation_marker(plugin: AgentPlugin) -> str | None:
|
||||
"""Bind activation to one immutable package snapshot."""
|
||||
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": digest.hexdigest(), "root": str(plugin.root)},
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
|
||||
def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[tuple[str, Path]]:
|
||||
skills_root = _contained(plugin_root / "skills", plugin_root, directory=True)
|
||||
if skills_root is None:
|
||||
return []
|
||||
|
||||
skills: list[tuple[str, Path]] = []
|
||||
for candidate in _children(skills_root, f"Agent Plugin '{plugin_name}' skills"):
|
||||
skill_root = _contained(candidate, skills_root, directory=True)
|
||||
if skill_root is None:
|
||||
continue
|
||||
skill_file = _contained(skill_root / "SKILL.md", plugin_root)
|
||||
if skill_file is None:
|
||||
continue
|
||||
try:
|
||||
metadata = parse_skill_metadata(skill_file.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError):
|
||||
metadata = None
|
||||
if metadata is None or not valid_skill_metadata(metadata, candidate.name):
|
||||
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid metadata", plugin_name, candidate.name)
|
||||
continue
|
||||
skills.append((candidate.name, skill_file))
|
||||
return skills
|
||||
|
||||
|
||||
def _children(root: Path, label: str) -> list[Path]:
|
||||
try:
|
||||
return sorted(root.iterdir(), key=lambda path: path.name)
|
||||
except OSError as exc:
|
||||
logger.warning("Could not inspect {}: {}", label, exc)
|
||||
return []
|
||||
|
||||
|
||||
def _contained(path: Path, root: Path, *, directory: bool = False) -> Path | None:
|
||||
try:
|
||||
resolved = path.resolve(strict=True)
|
||||
except OSError:
|
||||
return None
|
||||
expected_kind = resolved.is_dir() if directory else resolved.is_file()
|
||||
return resolved if expected_kind and resolved.is_relative_to(root) else None
|
||||
|
||||
|
||||
def _read_object(path: Path, root: Path) -> dict[str, object] | None:
|
||||
contained = _contained(path, root)
|
||||
if contained is None:
|
||||
return None
|
||||
try:
|
||||
value = cast(object, json.loads(contained.read_text(encoding="utf-8")))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Ignoring invalid Agent Plugin component '{}': {}", contained, exc)
|
||||
return None
|
||||
return cast(dict[str, object], value) if isinstance(value, dict) else None
|
||||
+30
-66
@@ -17,35 +17,9 @@ _STRIP_SKILL_FRONTMATTER = re.compile(
|
||||
r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?",
|
||||
re.DOTALL,
|
||||
)
|
||||
_SKILL_NAME = re.compile(r"^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
||||
_SKILL_REFERENCE = re.compile(r"(?<![\w$])\$([A-Za-z0-9_-]+)")
|
||||
|
||||
|
||||
def parse_skill_metadata(content: str) -> dict[str, object] | None:
|
||||
"""Parse a skill document's YAML frontmatter."""
|
||||
if not (match := _STRIP_SKILL_FRONTMATTER.match(content)):
|
||||
return None
|
||||
try:
|
||||
parsed = yaml.safe_load(match.group(1))
|
||||
except yaml.YAMLError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
return {str(key): value for key, value in cast(dict[object, object], parsed).items()}
|
||||
|
||||
|
||||
def valid_skill_metadata(metadata: dict[str, object], name: str) -> bool:
|
||||
"""Return whether metadata satisfies the Agent Skills identity contract."""
|
||||
description = metadata.get("description")
|
||||
return (
|
||||
metadata.get("name") == name
|
||||
and len(name) <= 64
|
||||
and _SKILL_NAME.fullmatch(name) is not None
|
||||
and isinstance(description, str)
|
||||
and 1 <= len(description.strip()) <= 1024
|
||||
)
|
||||
|
||||
|
||||
class SkillsLoader:
|
||||
"""
|
||||
Loader for agent skills.
|
||||
@@ -60,15 +34,6 @@ class SkillsLoader:
|
||||
self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR
|
||||
self.disabled_skills = disabled_skills or set()
|
||||
|
||||
def _skill_aliases(self) -> dict[str, str]:
|
||||
"""Return compatibility aliases owned by installed CLI Apps."""
|
||||
from nanobot.apps.cli import CliAppManager
|
||||
|
||||
try:
|
||||
return CliAppManager(workspace=self.workspace).installed_skill_aliases()
|
||||
except OSError:
|
||||
return {}
|
||||
|
||||
def _skill_entries_from_dir(self, base: Path, source: str, *, skip_names: set[str] | None = None) -> list[dict[str, str]]:
|
||||
if not base.exists():
|
||||
return []
|
||||
@@ -95,33 +60,15 @@ class SkillsLoader:
|
||||
Returns:
|
||||
List of skill info dicts with 'name', 'path', 'source'.
|
||||
"""
|
||||
from nanobot.agent.plugins import enabled_agent_plugin_skills
|
||||
|
||||
plugin_skills = enabled_agent_plugin_skills(self.workspace)
|
||||
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
|
||||
seen_names = {entry["name"] for entry in skills}
|
||||
for name, path in plugin_skills:
|
||||
if name in seen_names:
|
||||
continue
|
||||
skills.append(
|
||||
{
|
||||
"name": name,
|
||||
"path": str(path),
|
||||
"source": "plugin",
|
||||
}
|
||||
)
|
||||
seen_names.add(name)
|
||||
workspace_names = {entry["name"] for entry in skills}
|
||||
if self.builtin_skills and self.builtin_skills.exists():
|
||||
skills.extend(
|
||||
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=seen_names)
|
||||
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=workspace_names)
|
||||
)
|
||||
|
||||
if self.disabled_skills:
|
||||
disabled = set(self.disabled_skills)
|
||||
for legacy, canonical in self._skill_aliases().items():
|
||||
if legacy in disabled or canonical in disabled:
|
||||
disabled.update((legacy, canonical))
|
||||
skills = [s for s in skills if s["name"] not in disabled]
|
||||
skills = [s for s in skills if s["name"] not in self.disabled_skills]
|
||||
|
||||
if filter_unavailable:
|
||||
return [skill for skill in skills if self._check_requirements(self._get_skill_meta(skill["name"]))]
|
||||
@@ -137,11 +84,14 @@ class SkillsLoader:
|
||||
Returns:
|
||||
Skill content or None if not found.
|
||||
"""
|
||||
skills = self.list_skills(filter_unavailable=False)
|
||||
available = {skill["name"] for skill in skills}
|
||||
resolved = name if name in available else self._skill_aliases().get(name, name)
|
||||
entry = next((skill for skill in skills if skill["name"] == resolved), None)
|
||||
return Path(entry["path"]).read_text(encoding="utf-8") if entry else None
|
||||
roots = [self.workspace_skills]
|
||||
if self.builtin_skills:
|
||||
roots.append(self.builtin_skills)
|
||||
for root in roots:
|
||||
path = root / name / "SKILL.md"
|
||||
if path.exists():
|
||||
return path.read_text(encoding="utf-8")
|
||||
return None
|
||||
|
||||
def load_skills_for_context(self, skill_names: list[str]) -> str:
|
||||
"""
|
||||
@@ -168,11 +118,9 @@ class SkillsLoader:
|
||||
entry["name"]
|
||||
for entry in self.list_skills(filter_unavailable=True)
|
||||
}
|
||||
aliases = self._skill_aliases()
|
||||
invoked: list[str] = []
|
||||
for match in _SKILL_REFERENCE.finditer(text):
|
||||
requested = match.group(1)
|
||||
name = requested if requested in available else aliases.get(requested, requested)
|
||||
name = match.group(1)
|
||||
if name in available and name not in invoked:
|
||||
invoked.append(name)
|
||||
return invoked
|
||||
@@ -197,7 +145,6 @@ class SkillsLoader:
|
||||
sections: list[str] = []
|
||||
groups = (
|
||||
("Workspace skills", "workspace", self.workspace_skills),
|
||||
("Agent Plugin skills", "plugin", self.workspace / "plugins"),
|
||||
("Built-in skills", "builtin", self.builtin_skills),
|
||||
)
|
||||
for label, source, root in groups:
|
||||
@@ -331,4 +278,21 @@ class SkillsLoader:
|
||||
Returns:
|
||||
Metadata dict or None.
|
||||
"""
|
||||
return parse_skill_metadata(self.load_skill(name) or "")
|
||||
content = self.load_skill(name)
|
||||
if not content or not content.startswith("---"):
|
||||
return None
|
||||
match = _STRIP_SKILL_FRONTMATTER.match(content)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
parsed = yaml.safe_load(match.group(1))
|
||||
except yaml.YAMLError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
# yaml.safe_load returns native types (int, bool, list, etc.);
|
||||
# keep values as-is so downstream consumers get correct types.
|
||||
metadata: dict[str, object] = {}
|
||||
for key, value in cast(dict[object, object], parsed).items():
|
||||
metadata[str(key)] = value
|
||||
return metadata
|
||||
|
||||
@@ -5,7 +5,6 @@ import json
|
||||
import time
|
||||
import uuid
|
||||
import warnings
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, TypedDict
|
||||
@@ -158,10 +157,6 @@ class SubagentManager:
|
||||
self._task_statuses: dict[str, SubagentStatus] = {}
|
||||
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
||||
|
||||
def runtime_statuses(self) -> Mapping[str, SubagentStatus]:
|
||||
"""Return the observable task statuses used by runtime-control snapshots."""
|
||||
return self._task_statuses
|
||||
|
||||
def set_provider(self, provider: LLMProvider, model: str) -> None:
|
||||
"""Update the deprecated runtime source used by legacy ``spawn`` calls."""
|
||||
warnings.warn(
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
"""Windows Job Object ownership for subprocess trees."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
_CREATE_SUSPENDED = 0x00000004
|
||||
_PROCESS_SET_QUOTA = 0x0100
|
||||
_PROCESS_TERMINATE = 0x0001
|
||||
_TH32CS_SNAPTHREAD = 0x00000004
|
||||
_THREAD_SUSPEND_RESUME = 0x0002
|
||||
_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
|
||||
_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9
|
||||
_INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
|
||||
|
||||
|
||||
class _IoCounters(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("ReadOperationCount", ctypes.c_ulonglong),
|
||||
("WriteOperationCount", ctypes.c_ulonglong),
|
||||
("OtherOperationCount", ctypes.c_ulonglong),
|
||||
("ReadTransferCount", ctypes.c_ulonglong),
|
||||
("WriteTransferCount", ctypes.c_ulonglong),
|
||||
("OtherTransferCount", ctypes.c_ulonglong),
|
||||
]
|
||||
|
||||
|
||||
class _BasicLimitInformation(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("PerProcessUserTimeLimit", ctypes.c_longlong),
|
||||
("PerJobUserTimeLimit", ctypes.c_longlong),
|
||||
("LimitFlags", wintypes.DWORD),
|
||||
("MinimumWorkingSetSize", ctypes.c_size_t),
|
||||
("MaximumWorkingSetSize", ctypes.c_size_t),
|
||||
("ActiveProcessLimit", wintypes.DWORD),
|
||||
("Affinity", ctypes.c_size_t),
|
||||
("PriorityClass", wintypes.DWORD),
|
||||
("SchedulingClass", wintypes.DWORD),
|
||||
]
|
||||
|
||||
|
||||
class _ExtendedLimitInformation(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("BasicLimitInformation", _BasicLimitInformation),
|
||||
("IoInfo", _IoCounters),
|
||||
("ProcessMemoryLimit", ctypes.c_size_t),
|
||||
("JobMemoryLimit", ctypes.c_size_t),
|
||||
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
||||
("PeakJobMemoryUsed", ctypes.c_size_t),
|
||||
]
|
||||
|
||||
|
||||
class _ThreadEntry32(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("dwSize", wintypes.DWORD),
|
||||
("cntUsage", wintypes.DWORD),
|
||||
("th32ThreadID", wintypes.DWORD),
|
||||
("th32OwnerProcessID", wintypes.DWORD),
|
||||
("tpBasePri", wintypes.LONG),
|
||||
("tpDeltaPri", wintypes.LONG),
|
||||
("dwFlags", wintypes.DWORD),
|
||||
]
|
||||
|
||||
|
||||
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
_kernel32.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR]
|
||||
_kernel32.CreateJobObjectW.restype = wintypes.HANDLE
|
||||
_kernel32.SetInformationJobObject.argtypes = [
|
||||
wintypes.HANDLE,
|
||||
ctypes.c_int,
|
||||
ctypes.c_void_p,
|
||||
wintypes.DWORD,
|
||||
]
|
||||
_kernel32.SetInformationJobObject.restype = wintypes.BOOL
|
||||
_kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
||||
_kernel32.OpenProcess.restype = wintypes.HANDLE
|
||||
_kernel32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
|
||||
_kernel32.AssignProcessToJobObject.restype = wintypes.BOOL
|
||||
_kernel32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT]
|
||||
_kernel32.TerminateProcess.restype = wintypes.BOOL
|
||||
_kernel32.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT]
|
||||
_kernel32.TerminateJobObject.restype = wintypes.BOOL
|
||||
_kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD]
|
||||
_kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE
|
||||
_kernel32.Thread32First.argtypes = [wintypes.HANDLE, ctypes.POINTER(_ThreadEntry32)]
|
||||
_kernel32.Thread32First.restype = wintypes.BOOL
|
||||
_kernel32.Thread32Next.argtypes = [wintypes.HANDLE, ctypes.POINTER(_ThreadEntry32)]
|
||||
_kernel32.Thread32Next.restype = wintypes.BOOL
|
||||
_kernel32.OpenThread.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
||||
_kernel32.OpenThread.restype = wintypes.HANDLE
|
||||
_kernel32.ResumeThread.argtypes = [wintypes.HANDLE]
|
||||
_kernel32.ResumeThread.restype = wintypes.DWORD
|
||||
_kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
_kernel32.CloseHandle.restype = wintypes.BOOL
|
||||
|
||||
|
||||
def _win_error(operation: str) -> OSError:
|
||||
code = ctypes.get_last_error()
|
||||
return OSError(code, f"{operation} failed (Windows error {code})")
|
||||
|
||||
|
||||
def _close_handle(handle: int | None) -> None:
|
||||
if handle:
|
||||
_kernel32.CloseHandle(handle)
|
||||
|
||||
|
||||
def _set_kill_on_close(handle: int, enabled: bool) -> None:
|
||||
info = _ExtendedLimitInformation()
|
||||
if enabled:
|
||||
info.BasicLimitInformation.LimitFlags = _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
||||
if not _kernel32.SetInformationJobObject(
|
||||
handle,
|
||||
_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION,
|
||||
ctypes.byref(info),
|
||||
ctypes.sizeof(info),
|
||||
):
|
||||
raise _win_error("SetInformationJobObject")
|
||||
|
||||
|
||||
def _resume_primary_thread(pid: int) -> None:
|
||||
snapshot = _kernel32.CreateToolhelp32Snapshot(_TH32CS_SNAPTHREAD, 0)
|
||||
if snapshot == _INVALID_HANDLE_VALUE:
|
||||
raise _win_error("CreateToolhelp32Snapshot")
|
||||
try:
|
||||
entry = _ThreadEntry32()
|
||||
entry.dwSize = ctypes.sizeof(entry)
|
||||
found = _kernel32.Thread32First(snapshot, ctypes.byref(entry))
|
||||
while found:
|
||||
if entry.th32OwnerProcessID == pid:
|
||||
thread = _kernel32.OpenThread(
|
||||
_THREAD_SUSPEND_RESUME,
|
||||
False,
|
||||
entry.th32ThreadID,
|
||||
)
|
||||
if not thread:
|
||||
raise _win_error("OpenThread")
|
||||
try:
|
||||
if _kernel32.ResumeThread(thread) == 0xFFFFFFFF:
|
||||
raise _win_error("ResumeThread")
|
||||
return
|
||||
finally:
|
||||
_close_handle(thread)
|
||||
found = _kernel32.Thread32Next(snapshot, ctypes.byref(entry))
|
||||
raise RuntimeError(f"suspended process {pid} has no resumable thread")
|
||||
finally:
|
||||
_close_handle(snapshot)
|
||||
|
||||
|
||||
class WindowsJob:
|
||||
"""Own a process tree even after its root process exits."""
|
||||
|
||||
creation_flags = _CREATE_SUSPENDED
|
||||
|
||||
def __init__(self, handle: int) -> None:
|
||||
self._handle: int | None = handle
|
||||
|
||||
@classmethod
|
||||
def create(cls) -> WindowsJob:
|
||||
handle = _kernel32.CreateJobObjectW(None, None)
|
||||
if not handle:
|
||||
raise _win_error("CreateJobObjectW")
|
||||
try:
|
||||
_set_kill_on_close(handle, True)
|
||||
except Exception:
|
||||
_close_handle(handle)
|
||||
raise
|
||||
return cls(handle)
|
||||
|
||||
def assign_and_resume(self, pid: int) -> None:
|
||||
"""Atomically establish tree ownership before the root can spawn."""
|
||||
if self._handle is None:
|
||||
raise RuntimeError("Windows job is already closed")
|
||||
process = _kernel32.OpenProcess(
|
||||
_PROCESS_SET_QUOTA | _PROCESS_TERMINATE,
|
||||
False,
|
||||
pid,
|
||||
)
|
||||
if not process:
|
||||
error = _win_error("OpenProcess")
|
||||
self.close()
|
||||
raise error
|
||||
|
||||
if not _kernel32.AssignProcessToJobObject(self._handle, process):
|
||||
error = _win_error("AssignProcessToJobObject")
|
||||
_kernel32.TerminateProcess(process, 1)
|
||||
_close_handle(process)
|
||||
self.close()
|
||||
raise error
|
||||
|
||||
try:
|
||||
_resume_primary_thread(pid)
|
||||
except Exception:
|
||||
self.terminate()
|
||||
raise
|
||||
finally:
|
||||
_close_handle(process)
|
||||
|
||||
def release(self) -> None:
|
||||
"""Release ownership after successful output collection."""
|
||||
if self._handle is None:
|
||||
return
|
||||
_set_kill_on_close(self._handle, False)
|
||||
self.close()
|
||||
|
||||
def terminate(self) -> None:
|
||||
"""Terminate every process in the job and close its handle."""
|
||||
if self._handle is None:
|
||||
return
|
||||
try:
|
||||
_kernel32.TerminateJobObject(self._handle, 1)
|
||||
finally:
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
handle = self._handle
|
||||
self._handle = None
|
||||
_close_handle(handle)
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Base class for agent tools."""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import typing
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
@@ -68,8 +67,6 @@ class Schema(ABC):
|
||||
return [f"{label} should be number"]
|
||||
if t in _JSON_TYPE_MAP and t not in ("integer", "number") and not isinstance(val, _JSON_TYPE_MAP[t]):
|
||||
return [f"{label} should be {t}"]
|
||||
if t == "number" and isinstance(val, float) and not math.isfinite(val):
|
||||
return [f"{label} must be finite"]
|
||||
|
||||
errors: list[str] = []
|
||||
if "enum" in schema and val not in schema["enum"]:
|
||||
@@ -223,6 +220,10 @@ class Tool(ABC):
|
||||
"""Return optional per-turn prompt context owned by this tool."""
|
||||
return None
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Release resources owned by the tool. Safe to call repeatedly."""
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, **kwargs: Any) -> Any:
|
||||
"""Run the tool; return content, or ``ToolResult.error(...)`` for failures."""
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""DOM-based browser automation by element reference."""
|
||||
|
||||
# pyright: reportIncompatibleMethodOverride=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||
from nanobot.agent.tools.computer_use_backends.base import SessionBackendPool
|
||||
from nanobot.agent.tools.context import ToolContext
|
||||
from nanobot.agent.tools.schema import (
|
||||
BooleanSchema,
|
||||
IntegerSchema,
|
||||
StringSchema,
|
||||
tool_parameters_schema,
|
||||
)
|
||||
from nanobot.config_base import Base
|
||||
from nanobot.utils.helpers import build_image_content_blocks
|
||||
|
||||
_ACTIONS = [
|
||||
"navigate",
|
||||
"snapshot",
|
||||
"click",
|
||||
"type",
|
||||
"select",
|
||||
"scroll",
|
||||
"key",
|
||||
"back",
|
||||
"read_text",
|
||||
]
|
||||
|
||||
|
||||
class BrowserToolConfig(Base):
|
||||
"""browser (DOM) tool configuration."""
|
||||
|
||||
enable: bool = False
|
||||
start_url: str = "about:blank"
|
||||
headless: bool = True
|
||||
width: int = Field(default=1280, ge=320, le=4096)
|
||||
height: int = Field(default=800, ge=240, le=4096)
|
||||
allowed_domains: list[str] = Field(default_factory=list)
|
||||
include_screenshot: bool = False
|
||||
max_elements: int = Field(default=200, ge=1, le=1000)
|
||||
max_sessions: int = Field(default=8, ge=1, le=64)
|
||||
|
||||
|
||||
def _format_elements(elements: list[dict[str, Any]]) -> str:
|
||||
if not elements:
|
||||
return "Interactive elements: (none found — try scrolling or read_text)"
|
||||
lines: list[str] = []
|
||||
for e in elements:
|
||||
tag = str(e.get("tag") or "")
|
||||
typ = str(e.get("type") or "")
|
||||
label = tag + (f"[{typ}]" if typ else "")
|
||||
line = f"[{e.get('ref')}] {label}"
|
||||
name = str(e.get("name") or "").strip()
|
||||
if name:
|
||||
line += f' "{name}"'
|
||||
href = str(e.get("href") or "")
|
||||
if href and tag == "a":
|
||||
line += f" -> {href[:60]}"
|
||||
lines.append(line)
|
||||
return "Interactive elements (act with the [ref] number):\n" + "\n".join(lines)
|
||||
|
||||
|
||||
@tool_parameters(
|
||||
tool_parameters_schema(
|
||||
action=StringSchema("The action to perform.", enum=_ACTIONS),
|
||||
ref=IntegerSchema(
|
||||
description="Element ref number from the latest snapshot (click/type/select).",
|
||||
minimum=1,
|
||||
nullable=True,
|
||||
),
|
||||
text=StringSchema(
|
||||
"Text to type (action=type) or key/combo like 'Enter'/'ctrl+a' (action=key).",
|
||||
nullable=True,
|
||||
),
|
||||
url=StringSchema("URL to open (action=navigate).", nullable=True),
|
||||
value=StringSchema("Option value/label to choose (action=select).", nullable=True),
|
||||
submit=BooleanSchema(description="Press Enter after typing (action=type).", nullable=True),
|
||||
scroll_direction=StringSchema(
|
||||
"Scroll direction (action=scroll).", enum=["up", "down", "left", "right"], nullable=True
|
||||
),
|
||||
scroll_amount=IntegerSchema(
|
||||
description="Scroll clicks (action=scroll).",
|
||||
minimum=1,
|
||||
maximum=100,
|
||||
nullable=True,
|
||||
),
|
||||
required=["action"],
|
||||
)
|
||||
)
|
||||
class BrowserTool(Tool):
|
||||
"""Browse and act on web pages by element ref (DOM-based, works with any model)."""
|
||||
|
||||
_scopes = {"core"}
|
||||
|
||||
name = "browser" # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
|
||||
description = ( # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
|
||||
"Control a web browser by acting on page elements by their [ref] number. "
|
||||
"Each call returns the current page URL plus a fresh numbered list of the page's "
|
||||
"interactive elements; pick a [ref] to click/type/select — no pixel coordinates "
|
||||
"needed. A page may already be open: call 'snapshot' FIRST to see it. Only use "
|
||||
"'navigate' for a specific URL you were explicitly given — never guess a URL. "
|
||||
"Move between pages by clicking links/buttons via their [ref]. Use 'read_text' to "
|
||||
"read page text. Re-read the element list after each action; refs are reassigned."
|
||||
)
|
||||
|
||||
config_key = "browser"
|
||||
|
||||
@classmethod
|
||||
def config_cls(cls) -> type[BrowserToolConfig]:
|
||||
return BrowserToolConfig
|
||||
|
||||
@classmethod
|
||||
def enabled(cls, ctx: ToolContext) -> bool:
|
||||
return bool(ctx.config.browser.enable)
|
||||
|
||||
@classmethod
|
||||
def create(cls, ctx: ToolContext) -> Tool:
|
||||
return cls(ctx.config.browser)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: BrowserToolConfig | None = None,
|
||||
*,
|
||||
backend_impl: Any = None,
|
||||
) -> None:
|
||||
self.config = config or BrowserToolConfig()
|
||||
runtime = None
|
||||
if backend_impl is None:
|
||||
from nanobot.agent.tools.computer_use_backends.browser_playwright import BrowserRuntime
|
||||
runtime = BrowserRuntime(headless=self.config.headless)
|
||||
self._runtime = runtime
|
||||
self._execution_lock = asyncio.Lock()
|
||||
self._backends = SessionBackendPool(
|
||||
self._make_backend,
|
||||
backend_impl,
|
||||
max_backends=self.config.max_sessions,
|
||||
finalizer=runtime.close if runtime is not None else None,
|
||||
)
|
||||
|
||||
@property
|
||||
def read_only(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def exclusive(self) -> bool:
|
||||
return True
|
||||
|
||||
def _make_backend(self) -> Any:
|
||||
from nanobot.agent.tools.computer_use_backends.browser_playwright import BrowserBackend
|
||||
return BrowserBackend(
|
||||
width=self.config.width,
|
||||
height=self.config.height,
|
||||
start_url=self.config.start_url,
|
||||
allowed_domains=self.config.allowed_domains,
|
||||
runtime=self._runtime,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _req_ref(params: dict[str, Any], action: str) -> Any:
|
||||
ref = params.get("ref")
|
||||
if ref is None:
|
||||
raise ValueError(f"action '{action}' requires an element 'ref' from the snapshot")
|
||||
return ref
|
||||
|
||||
async def _dispatch(self, backend: Any, action: str, p: dict[str, Any]) -> tuple[str, str | None]:
|
||||
"""Return (status, direct_text). If direct_text is set, it is returned as-is
|
||||
(no snapshot appended)."""
|
||||
if action == "navigate":
|
||||
url = p.get("url")
|
||||
if not url:
|
||||
raise ValueError("action 'navigate' requires 'url'")
|
||||
await backend.navigate(str(url))
|
||||
return f"Navigated to {url}", None
|
||||
|
||||
if action == "snapshot":
|
||||
return "Snapshot of the current page", None
|
||||
|
||||
if action == "click":
|
||||
ref = self._req_ref(p, action)
|
||||
await backend.click_ref(ref)
|
||||
return f"Clicked element [{ref}]", None
|
||||
|
||||
if action == "type":
|
||||
ref = self._req_ref(p, action)
|
||||
text = p.get("text")
|
||||
if text is None:
|
||||
raise ValueError("action 'type' requires 'text'")
|
||||
submit = bool(p.get("submit"))
|
||||
await backend.fill_ref(ref, str(text), submit=submit)
|
||||
return f"Typed into [{ref}]" + (" and pressed Enter" if submit else ""), None
|
||||
|
||||
if action == "select":
|
||||
ref = self._req_ref(p, action)
|
||||
value = p.get("value")
|
||||
if value is None:
|
||||
raise ValueError("action 'select' requires 'value'")
|
||||
await backend.select_ref(ref, str(value))
|
||||
return f"Selected '{value}' in [{ref}]", None
|
||||
|
||||
if action == "scroll":
|
||||
direction = str(p.get("scroll_direction") or "down").lower()
|
||||
if direction not in ("up", "down", "left", "right"):
|
||||
raise ValueError("'scroll_direction' must be up/down/left/right")
|
||||
await backend.scroll_page(direction, int(p.get("scroll_amount") or 3))
|
||||
return f"Scrolled {direction}", None
|
||||
|
||||
if action == "key":
|
||||
combo = p.get("text")
|
||||
if not combo:
|
||||
raise ValueError("action 'key' requires 'text' (e.g. 'Enter')")
|
||||
await backend.key(str(combo))
|
||||
return f"Pressed {combo}", None
|
||||
|
||||
if action == "back":
|
||||
await backend.go_back()
|
||||
return "Navigated back", None
|
||||
|
||||
if action == "read_text":
|
||||
txt = await backend.read_text()
|
||||
return "", f"Page text:\n{txt}"
|
||||
|
||||
raise ValueError(f"unknown action '{action}'")
|
||||
|
||||
async def execute(self, action: str | None = None, **kwargs: Any) -> Any:
|
||||
async with self._execution_lock:
|
||||
return await self._execute(action, **kwargs)
|
||||
|
||||
async def _execute(self, action: str | None = None, **kwargs: Any) -> Any:
|
||||
action = (action or "").strip()
|
||||
if action not in _ACTIONS:
|
||||
return ToolResult.error(
|
||||
f"Error: unknown action '{action}'. Valid actions: {', '.join(_ACTIONS)}"
|
||||
)
|
||||
|
||||
try:
|
||||
backend = await self._backends.get()
|
||||
except ImportError as exc:
|
||||
return ToolResult.error(f"Error: {exc}")
|
||||
except Exception as exc:
|
||||
return ToolResult.error(
|
||||
f"Error: could not initialize browser backend: {type(exc).__name__}: {exc}"
|
||||
)
|
||||
|
||||
try:
|
||||
status, direct = await self._dispatch(backend, action, kwargs)
|
||||
if blocked := getattr(backend, "pop_blocked_navigation", lambda: None)():
|
||||
raise ValueError(f"navigation was blocked: {blocked}")
|
||||
except ValueError as exc:
|
||||
return ToolResult.error(f"Error: {exc}")
|
||||
except Exception as exc:
|
||||
return ToolResult.error(
|
||||
f"Error executing browser '{action}': {type(exc).__name__}: {exc}"
|
||||
)
|
||||
|
||||
if direct is not None:
|
||||
return direct
|
||||
|
||||
try:
|
||||
elements = await backend.dom_snapshot(self.config.max_elements)
|
||||
snapshot = _format_elements(elements)
|
||||
except Exception as exc:
|
||||
snapshot = f"(could not read page elements: {type(exc).__name__}: {exc})"
|
||||
try:
|
||||
current = await backend.current_url()
|
||||
except Exception:
|
||||
current = ""
|
||||
header = f"{status}\nCurrent page: {current}" if current else status
|
||||
text_out = f"{header}\n\n{snapshot}"
|
||||
|
||||
if self.config.include_screenshot:
|
||||
try:
|
||||
png = await backend.screenshot()
|
||||
return build_image_content_blocks(png, "image/png", "", text_out)
|
||||
except Exception:
|
||||
return text_out
|
||||
return text_out
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._backends.close()
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Screenshot-based computer control."""
|
||||
|
||||
# pyright: reportIncompatibleMethodOverride=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||
from nanobot.agent.tools.computer_use_backends.base import SessionBackendPool
|
||||
from nanobot.agent.tools.context import ToolContext
|
||||
from nanobot.agent.tools.schema import (
|
||||
IntegerSchema,
|
||||
NumberSchema,
|
||||
StringSchema,
|
||||
tool_parameters_schema,
|
||||
)
|
||||
from nanobot.config_base import Base
|
||||
from nanobot.utils.helpers import build_image_content_blocks
|
||||
|
||||
_ACTIONS = [
|
||||
"screenshot",
|
||||
"left_click",
|
||||
"right_click",
|
||||
"middle_click",
|
||||
"double_click",
|
||||
"triple_click",
|
||||
"mouse_move",
|
||||
"left_click_drag",
|
||||
"scroll",
|
||||
"type",
|
||||
"key",
|
||||
"wait",
|
||||
"navigate",
|
||||
]
|
||||
|
||||
_CLICK_BUTTONS = {
|
||||
"left_click": "left",
|
||||
"double_click": "left",
|
||||
"triple_click": "left",
|
||||
"right_click": "right",
|
||||
"middle_click": "middle",
|
||||
}
|
||||
_CLICK_COUNTS = {"double_click": 2, "triple_click": 3}
|
||||
|
||||
_MAX_WAIT_S = 10.0
|
||||
|
||||
|
||||
class ComputerUseToolConfig(Base):
|
||||
"""computer_use tool configuration."""
|
||||
|
||||
enable: bool = False
|
||||
backend: Literal["desktop", "browser"] = "desktop"
|
||||
target_width: int = Field(default=1280, ge=320, le=4096)
|
||||
target_height: int = Field(default=800, ge=240, le=4096)
|
||||
allowed_domains: list[str] = Field(default_factory=list)
|
||||
start_url: str = "about:blank"
|
||||
headless: bool = True
|
||||
max_sessions: int = Field(default=8, ge=1, le=64)
|
||||
|
||||
|
||||
def _fit_size(width: int, height: int, max_width: int, max_height: int) -> tuple[int, int]:
|
||||
if width <= 0 or height <= 0:
|
||||
return max(1, max_width), max(1, max_height)
|
||||
scale = min(max_width / width, max_height / height, 1.0)
|
||||
return max(1, round(width * scale)), max(1, round(height * scale))
|
||||
|
||||
|
||||
def _scale_point(
|
||||
x: int,
|
||||
y: int,
|
||||
source: tuple[int, int],
|
||||
target: tuple[int, int],
|
||||
) -> tuple[int, int]:
|
||||
width, height = source
|
||||
target_width, target_height = target
|
||||
real_x = round(x * width / target_width) if target_width else x
|
||||
real_y = round(y * height / target_height) if target_height else y
|
||||
return (
|
||||
max(0, min(real_x, max(0, width - 1))),
|
||||
max(0, min(real_y, max(0, height - 1))),
|
||||
)
|
||||
|
||||
|
||||
@tool_parameters(
|
||||
tool_parameters_schema(
|
||||
action=StringSchema("The action to perform.", enum=_ACTIONS),
|
||||
x=IntegerSchema(
|
||||
description="X coordinate in the pixel space of the screenshot you were last shown.",
|
||||
nullable=True,
|
||||
),
|
||||
y=IntegerSchema(
|
||||
description="Y coordinate in the pixel space of the screenshot you were last shown.",
|
||||
nullable=True,
|
||||
),
|
||||
text=StringSchema(
|
||||
"Text to type (action=type; desktop supports ASCII), or a key/combo like "
|
||||
"'ctrl+s' or 'Enter' (action=key).",
|
||||
nullable=True,
|
||||
),
|
||||
scroll_direction=StringSchema(
|
||||
"Scroll direction (action=scroll).", enum=["up", "down", "left", "right"], nullable=True
|
||||
),
|
||||
scroll_amount=IntegerSchema(
|
||||
description="Number of scroll clicks (action=scroll).",
|
||||
minimum=1,
|
||||
maximum=100,
|
||||
nullable=True,
|
||||
),
|
||||
duration=NumberSchema(
|
||||
description="Seconds to wait (action=wait).",
|
||||
minimum=0,
|
||||
maximum=_MAX_WAIT_S,
|
||||
nullable=True,
|
||||
),
|
||||
url=StringSchema("URL to open (action=navigate, browser backend only).", nullable=True),
|
||||
required=["action"],
|
||||
)
|
||||
)
|
||||
class ComputerUseTool(Tool):
|
||||
"""Control a computer (desktop or browser) by looking at screenshots and acting."""
|
||||
|
||||
_scopes = {"core"} # never exposed to subagents — security-sensitive
|
||||
|
||||
name = "computer_use" # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
|
||||
description = ( # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
|
||||
"Control a computer via screenshots and mouse/keyboard. Each call performs ONE "
|
||||
"action and returns a fresh screenshot of the resulting screen. Coordinates (x, y) "
|
||||
"are in the pixel space of the screenshot you were last shown (top-left is 0,0). "
|
||||
"The 'browser' backend additionally supports the 'navigate' action. Always start "
|
||||
"with a 'screenshot' to see the screen, then act based on what you observe; after "
|
||||
"each action re-check the new screenshot before the next step."
|
||||
)
|
||||
|
||||
config_key = "computer_use"
|
||||
|
||||
@classmethod
|
||||
def config_cls(cls) -> type[ComputerUseToolConfig]:
|
||||
return ComputerUseToolConfig
|
||||
|
||||
@classmethod
|
||||
def enabled(cls, ctx: ToolContext) -> bool:
|
||||
return bool(ctx.config.computer_use.enable)
|
||||
|
||||
@classmethod
|
||||
def create(cls, ctx: ToolContext) -> Tool:
|
||||
return cls(ctx.config.computer_use)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: ComputerUseToolConfig | None = None,
|
||||
*,
|
||||
backend_impl: Any = None,
|
||||
) -> None:
|
||||
self.config = config or ComputerUseToolConfig()
|
||||
runtime = None
|
||||
if backend_impl is None and self.config.backend == "browser":
|
||||
from nanobot.agent.tools.computer_use_backends.browser_playwright import BrowserRuntime
|
||||
runtime = BrowserRuntime(headless=self.config.headless)
|
||||
self._runtime = runtime
|
||||
self._execution_lock = asyncio.Lock()
|
||||
self._backends = SessionBackendPool(
|
||||
self._make_backend,
|
||||
backend_impl,
|
||||
max_backends=1 if self.config.backend == "desktop" else self.config.max_sessions,
|
||||
finalizer=runtime.close if runtime is not None else None,
|
||||
)
|
||||
|
||||
@property
|
||||
def read_only(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def exclusive(self) -> bool:
|
||||
# Stateful single environment; must not run alongside other tools.
|
||||
return True
|
||||
|
||||
def _make_backend(self) -> Any:
|
||||
if self.config.backend == "browser":
|
||||
from nanobot.agent.tools.computer_use_backends.browser_playwright import BrowserBackend
|
||||
return BrowserBackend(
|
||||
width=self.config.target_width,
|
||||
height=self.config.target_height,
|
||||
start_url=self.config.start_url,
|
||||
allowed_domains=self.config.allowed_domains,
|
||||
runtime=self._runtime,
|
||||
)
|
||||
from nanobot.agent.tools.computer_use_backends.desktop_pyautogui import DesktopBackend
|
||||
return DesktopBackend()
|
||||
|
||||
@staticmethod
|
||||
def _downscale_png(png: bytes, target: tuple[int, int]) -> bytes:
|
||||
try:
|
||||
from PIL import Image # noqa: PLC0415
|
||||
except Exception as exc:
|
||||
raise ImportError(
|
||||
"Pillow is required for computer_use. Install: pip install 'nanobot-ai[computer-use]'"
|
||||
) from exc
|
||||
tw, th = target
|
||||
with Image.open(io.BytesIO(png)) as img:
|
||||
if (img.width, img.height) == (tw, th):
|
||||
return png
|
||||
resized = img.convert("RGB").resize((tw, th)) # pyright: ignore[reportUnknownMemberType]
|
||||
out = io.BytesIO()
|
||||
resized.save(out, format="PNG")
|
||||
return out.getvalue()
|
||||
|
||||
async def _dispatch(
|
||||
self,
|
||||
backend: Any,
|
||||
action: str,
|
||||
params: dict[str, Any],
|
||||
source: tuple[int, int],
|
||||
target: tuple[int, int],
|
||||
) -> str:
|
||||
def _xy() -> tuple[int, int]:
|
||||
x, y = params.get("x"), params.get("y")
|
||||
if x is None or y is None:
|
||||
raise ValueError(f"action '{action}' requires integer 'x' and 'y'")
|
||||
return _scale_point(int(x), int(y), source, target)
|
||||
|
||||
if action == "screenshot":
|
||||
return "Took a screenshot"
|
||||
|
||||
if action == "wait":
|
||||
duration = params.get("duration")
|
||||
secs = 1.0 if duration is None else float(duration)
|
||||
secs = max(0.0, min(secs, _MAX_WAIT_S))
|
||||
await asyncio.sleep(secs)
|
||||
return f"Waited {secs:g}s"
|
||||
|
||||
if action in _CLICK_BUTTONS:
|
||||
rx, ry = _xy()
|
||||
await backend.click(rx, ry, _CLICK_BUTTONS[action], _CLICK_COUNTS.get(action, 1))
|
||||
return f"{action} at ({rx}, {ry})"
|
||||
|
||||
if action == "mouse_move":
|
||||
rx, ry = _xy()
|
||||
await backend.move(rx, ry)
|
||||
return f"Moved to ({rx}, {ry})"
|
||||
|
||||
if action == "left_click_drag":
|
||||
rx, ry = _xy()
|
||||
await backend.drag(rx, ry)
|
||||
return f"Dragged to ({rx}, {ry})"
|
||||
|
||||
if action == "scroll":
|
||||
rx, ry = _xy()
|
||||
direction = str(params.get("scroll_direction") or "down").lower()
|
||||
if direction not in ("up", "down", "left", "right"):
|
||||
raise ValueError("'scroll_direction' must be up/down/left/right")
|
||||
amount = int(params.get("scroll_amount") or 3)
|
||||
await backend.scroll(rx, ry, direction, amount)
|
||||
return f"Scrolled {direction} by {amount} at ({rx}, {ry})"
|
||||
|
||||
if action == "type":
|
||||
text = params.get("text")
|
||||
if not text:
|
||||
raise ValueError("action 'type' requires 'text'")
|
||||
await backend.type_text(str(text))
|
||||
return f"Typed {len(str(text))} characters"
|
||||
|
||||
if action == "key":
|
||||
combo = params.get("text")
|
||||
if not combo:
|
||||
raise ValueError("action 'key' requires 'text' (e.g. 'ctrl+s')")
|
||||
await backend.key(str(combo))
|
||||
return f"Pressed {combo}"
|
||||
|
||||
if action == "navigate":
|
||||
url = params.get("url")
|
||||
if not url:
|
||||
raise ValueError("action 'navigate' requires 'url'")
|
||||
await backend.navigate(str(url))
|
||||
return f"Navigated to {url}"
|
||||
|
||||
raise ValueError(f"unknown action '{action}'")
|
||||
|
||||
async def execute(self, action: str | None = None, **kwargs: Any) -> Any:
|
||||
async with self._execution_lock:
|
||||
return await self._execute(action, **kwargs)
|
||||
|
||||
async def _execute(self, action: str | None = None, **kwargs: Any) -> Any:
|
||||
action = (action or "").strip()
|
||||
if action not in _ACTIONS:
|
||||
return ToolResult.error(
|
||||
f"Error: unknown action '{action}'. Valid actions: {', '.join(_ACTIONS)}"
|
||||
)
|
||||
|
||||
try:
|
||||
backend = await self._backends.get()
|
||||
real_w, real_h = await backend.dimensions()
|
||||
except ImportError as exc:
|
||||
return ToolResult.error(f"Error: {exc}")
|
||||
except Exception as exc:
|
||||
return ToolResult.error(
|
||||
f"Error: could not initialize computer_use backend: {type(exc).__name__}: {exc}"
|
||||
)
|
||||
|
||||
source = (real_w, real_h)
|
||||
target = _fit_size(real_w, real_h, self.config.target_width, self.config.target_height)
|
||||
|
||||
try:
|
||||
status = await self._dispatch(backend, action, kwargs, source, target)
|
||||
if blocked := getattr(backend, "pop_blocked_navigation", lambda: None)():
|
||||
raise ValueError(f"navigation was blocked: {blocked}")
|
||||
except ValueError as exc:
|
||||
return ToolResult.error(f"Error: {exc}")
|
||||
except NotImplementedError as exc:
|
||||
return ToolResult.error(f"Error: {exc}")
|
||||
except Exception as exc:
|
||||
return ToolResult.error(
|
||||
f"Error executing computer_use '{action}': {type(exc).__name__}: {exc}"
|
||||
)
|
||||
|
||||
# Return a fresh screenshot so the model sees the result of its action.
|
||||
try:
|
||||
png = await backend.screenshot()
|
||||
png = self._downscale_png(png, target)
|
||||
except ImportError as exc:
|
||||
return ToolResult.error(f"Error: {exc}")
|
||||
except Exception as exc:
|
||||
return f"{status}\n(Could not capture screenshot: {type(exc).__name__}: {exc})"
|
||||
|
||||
label = f"{status} | screen {target[0]}x{target[1]} ({backend.environment})"
|
||||
return build_image_content_blocks(png, "image/png", "", label)
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._backends.close()
|
||||
@@ -0,0 +1 @@
|
||||
"""Computer-use backend adapters."""
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Backend interface for the ``computer_use`` tool.
|
||||
|
||||
A backend is the *actuator* + *screenshot source* for one execution environment
|
||||
(the local desktop, a headless browser, a VM, ...). The tool layer owns the
|
||||
agent loop, coordinate scaling, screenshot downscaling and safety gating; a
|
||||
backend only has to perform primitive actions and grab a screenshot.
|
||||
|
||||
Coordinate contract: every ``x``/``y`` passed to a backend is already in **real
|
||||
device pixels** (the same pixel space as :meth:`screenshot`). The tool scales the
|
||||
model's target-space coordinates to real pixels before calling the backend, so
|
||||
backends never deal with the downscaled space.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.tools.context import current_request_session_key
|
||||
|
||||
|
||||
class ComputerBackend(ABC):
|
||||
"""Primitive GUI actions + screenshot for one execution environment."""
|
||||
|
||||
#: "desktop" or "browser" — surfaced to the model so it knows the context.
|
||||
environment: str = "desktop"
|
||||
|
||||
@abstractmethod
|
||||
async def dimensions(self) -> tuple[int, int]:
|
||||
"""Return the real screenshot pixel size as ``(width, height)``."""
|
||||
|
||||
@abstractmethod
|
||||
async def screenshot(self) -> bytes:
|
||||
"""Return a PNG screenshot of the current screen at real pixel size."""
|
||||
|
||||
@abstractmethod
|
||||
async def click(self, x: int, y: int, button: str = "left", count: int = 1) -> None:
|
||||
"""Click at ``(x, y)``. ``button`` in {left,right,middle}; ``count`` for double/triple."""
|
||||
|
||||
@abstractmethod
|
||||
async def move(self, x: int, y: int) -> None:
|
||||
"""Move the cursor to ``(x, y)`` without clicking."""
|
||||
|
||||
@abstractmethod
|
||||
async def drag(self, x: int, y: int) -> None:
|
||||
"""Press at the current cursor position and drag to ``(x, y)``, then release."""
|
||||
|
||||
@abstractmethod
|
||||
async def scroll(self, x: int, y: int, direction: str, amount: int) -> None:
|
||||
"""Scroll at ``(x, y)``. ``direction`` in {up,down,left,right}; ``amount`` in clicks."""
|
||||
|
||||
@abstractmethod
|
||||
async def type_text(self, text: str) -> None:
|
||||
"""Type ``text`` at the current focus."""
|
||||
|
||||
@abstractmethod
|
||||
async def key(self, combo: str) -> None:
|
||||
"""Press a key or combo, e.g. ``"ctrl+s"`` / ``"Enter"`` (backend-specific syntax)."""
|
||||
|
||||
async def navigate(self, url: str) -> None:
|
||||
"""Navigate to ``url`` (browser backends only)."""
|
||||
raise NotImplementedError(
|
||||
f"'navigate' is not supported by the {self.environment} backend"
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Release any resources (browser process, etc.). Safe to call repeatedly."""
|
||||
return None
|
||||
|
||||
|
||||
class SessionBackendPool:
|
||||
"""Keep stateful backends isolated by nanobot session."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
factory: Callable[[], Any],
|
||||
injected: Any = None,
|
||||
*,
|
||||
max_backends: int = 8,
|
||||
finalizer: Callable[[], Awaitable[None]] | None = None,
|
||||
) -> None:
|
||||
if max_backends < 1:
|
||||
raise ValueError("max_backends must be at least 1")
|
||||
self._factory = factory
|
||||
self._injected = injected
|
||||
self._max_backends = max_backends
|
||||
self._finalizer = finalizer
|
||||
self._backends: OrderedDict[str, Any] = OrderedDict()
|
||||
self._lock = asyncio.Lock()
|
||||
self._closed = False
|
||||
|
||||
async def get(self) -> Any:
|
||||
async with self._lock:
|
||||
if self._closed:
|
||||
raise RuntimeError("computer-use backend pool is closed")
|
||||
if self._injected is not None:
|
||||
return self._injected
|
||||
key = current_request_session_key() or "default"
|
||||
backend = self._backends.get(key)
|
||||
if backend is not None:
|
||||
self._backends.move_to_end(key)
|
||||
return backend
|
||||
if len(self._backends) >= self._max_backends:
|
||||
_, stale = self._backends.popitem(last=False)
|
||||
await stale.close()
|
||||
backend = self._factory()
|
||||
self._backends[key] = backend
|
||||
return backend
|
||||
|
||||
async def close(self) -> None:
|
||||
async with self._lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
backends = (
|
||||
[self._injected]
|
||||
if self._injected is not None
|
||||
else list(self._backends.values())
|
||||
)
|
||||
self._injected = None
|
||||
self._backends.clear()
|
||||
finalizer, self._finalizer = self._finalizer, None
|
||||
results = await asyncio.gather(
|
||||
*(backend.close() for backend in backends if backend is not None),
|
||||
return_exceptions=True,
|
||||
)
|
||||
errors = [result for result in results if isinstance(result, BaseException)]
|
||||
if finalizer is not None:
|
||||
try:
|
||||
await finalizer()
|
||||
except BaseException as exc:
|
||||
errors.append(exc)
|
||||
if len(errors) == 1:
|
||||
raise errors[0]
|
||||
if errors:
|
||||
raise BaseExceptionGroup("failed to close computer-use backends", errors)
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Playwright backend shared by browser and computer_use."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, cast
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.tools.computer_use_backends.base import ComputerBackend
|
||||
from nanobot.security.network import validate_url_target
|
||||
|
||||
_MISSING = (
|
||||
"Browser computer-use backend needs 'playwright'. Install with: "
|
||||
"pip install 'nanobot-ai[computer-use]' && playwright install chromium"
|
||||
)
|
||||
|
||||
_SCROLL_PIXELS = 100 # one "scroll click" ~= this many pixels
|
||||
|
||||
# Tags visible interactive elements with data-nanobot-ref and returns a compact
|
||||
# list. Refs are reassigned per call. Used by DOM/accessibility mode.
|
||||
_SNAPSHOT_JS = r"""
|
||||
(max) => {
|
||||
const SEL = 'a,button,input,textarea,select,[role=button],[role=link],[role=checkbox],[role=radio],[role=tab],[role=menuitem],[role=switch],[onclick],[contenteditable=""],[contenteditable=true]';
|
||||
const out = [];
|
||||
let ref = 0;
|
||||
for (const el of document.querySelectorAll(SEL)) {
|
||||
const r = el.getBoundingClientRect();
|
||||
const s = getComputedStyle(el);
|
||||
if (r.width <= 0 || r.height <= 0) continue;
|
||||
if (s.visibility === 'hidden' || s.display === 'none' || s.opacity === '0') continue;
|
||||
ref++;
|
||||
el.setAttribute('data-nanobot-ref', String(ref));
|
||||
let name = (el.getAttribute('aria-label') || el.innerText || el.value ||
|
||||
el.getAttribute('placeholder') || el.getAttribute('name') ||
|
||||
el.getAttribute('title') || '');
|
||||
name = name.replace(/\s+/g, ' ').trim().slice(0, 120);
|
||||
out.push({
|
||||
ref: ref,
|
||||
tag: el.tagName.toLowerCase(),
|
||||
role: el.getAttribute('role') || '',
|
||||
type: el.getAttribute('type') || '',
|
||||
name: name,
|
||||
href: el.getAttribute('href') || ''
|
||||
});
|
||||
if (out.length >= max) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
"""
|
||||
|
||||
# CUA/xdotool-ish modifier names -> Playwright modifiers.
|
||||
_MODIFIERS = {
|
||||
"ctrl": "Control", "control": "Control",
|
||||
"alt": "Alt", "option": "Alt",
|
||||
"shift": "Shift",
|
||||
"cmd": "Meta", "meta": "Meta", "super": "Meta", "win": "Meta",
|
||||
}
|
||||
# Common single-key names -> Playwright key names.
|
||||
_KEYS = {
|
||||
"return": "Enter", "enter": "Enter", "tab": "Tab", "esc": "Escape",
|
||||
"escape": "Escape", "backspace": "Backspace", "delete": "Delete",
|
||||
"space": "Space", "up": "ArrowUp", "down": "ArrowDown",
|
||||
"left": "ArrowLeft", "right": "ArrowRight",
|
||||
"page_down": "PageDown", "pagedown": "PageDown",
|
||||
"page_up": "PageUp", "pageup": "PageUp", "home": "Home", "end": "End",
|
||||
}
|
||||
|
||||
|
||||
def _validate_browser_url(
|
||||
url: str,
|
||||
allowed_domains: Sequence[str] = (),
|
||||
*,
|
||||
navigation: bool = True,
|
||||
) -> tuple[bool, str]:
|
||||
if url == "about:blank":
|
||||
return True, ""
|
||||
|
||||
parsed = urlparse(url)
|
||||
if not navigation and parsed.scheme in {"blob", "data"}:
|
||||
return True, ""
|
||||
|
||||
target = url
|
||||
if parsed.scheme in {"ws", "wss"}:
|
||||
target = urlunparse(parsed._replace(scheme="https" if parsed.scheme == "wss" else "http"))
|
||||
|
||||
if navigation and allowed_domains:
|
||||
host = (parsed.hostname or "").rstrip(".").lower()
|
||||
allowed = any(
|
||||
normalized and (host == normalized or host.endswith(f".{normalized}"))
|
||||
for domain in allowed_domains
|
||||
if (normalized := domain.strip().lstrip(".").rstrip(".").lower())
|
||||
)
|
||||
if not allowed:
|
||||
return False, f"host {host or '<missing>'} is not in allowed_domains"
|
||||
|
||||
return validate_url_target(target)
|
||||
|
||||
|
||||
def _playwright_key(combo: str) -> str:
|
||||
parts = [p.strip() for p in combo.split("+") if p.strip()]
|
||||
out: list[str] = []
|
||||
for part in parts:
|
||||
low = part.lower()
|
||||
if low in _MODIFIERS:
|
||||
out.append(_MODIFIERS[low])
|
||||
elif low in _KEYS:
|
||||
out.append(_KEYS[low])
|
||||
elif len(part) == 1:
|
||||
out.append(part)
|
||||
else:
|
||||
out.append(part.capitalize())
|
||||
return "+".join(out)
|
||||
|
||||
|
||||
class BrowserRuntime:
|
||||
"""One lazily started browser process shared by isolated session contexts."""
|
||||
|
||||
def __init__(self, *, headless: bool = True) -> None:
|
||||
self._headless = headless
|
||||
self._lock = asyncio.Lock()
|
||||
self._playwright: Any = None
|
||||
self._browser: Any = None
|
||||
|
||||
async def get(self) -> Any:
|
||||
if self._browser is not None:
|
||||
return self._browser
|
||||
async with self._lock:
|
||||
if self._browser is not None:
|
||||
return self._browser
|
||||
try:
|
||||
playwright = importlib.import_module("playwright.async_api")
|
||||
async_playwright = cast(Any, playwright).async_playwright
|
||||
except ImportError as exc:
|
||||
raise ImportError(_MISSING) from exc
|
||||
self._playwright = await async_playwright().start()
|
||||
try:
|
||||
self._browser = await self._playwright.chromium.launch(
|
||||
headless=self._headless
|
||||
)
|
||||
except BaseException:
|
||||
await self.close()
|
||||
raise
|
||||
return self._browser
|
||||
|
||||
async def close(self) -> None:
|
||||
browser, playwright = self._browser, self._playwright
|
||||
self._browser = self._playwright = None
|
||||
errors: list[BaseException] = []
|
||||
closers = (
|
||||
browser.close if browser is not None else None,
|
||||
playwright.stop if playwright is not None else None,
|
||||
)
|
||||
for close in closers:
|
||||
if close is None:
|
||||
continue
|
||||
try:
|
||||
await close()
|
||||
except BaseException as exc:
|
||||
errors.append(exc)
|
||||
if len(errors) == 1:
|
||||
raise errors[0]
|
||||
if errors:
|
||||
raise BaseExceptionGroup("failed to close browser runtime", errors)
|
||||
|
||||
|
||||
class BrowserBackend(ComputerBackend):
|
||||
environment = "browser"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
width: int = 1280,
|
||||
height: int = 800,
|
||||
headless: bool = True,
|
||||
start_url: str = "about:blank",
|
||||
allowed_domains: Sequence[str] = (),
|
||||
runtime: BrowserRuntime | None = None,
|
||||
) -> None:
|
||||
self._width = width
|
||||
self._height = height
|
||||
self._start_url = start_url
|
||||
self._allowed_domains = tuple(allowed_domains)
|
||||
self._runtime = runtime or BrowserRuntime(headless=headless)
|
||||
self._owns_runtime = runtime is None
|
||||
self._context: Any = None
|
||||
self._page: Any = None
|
||||
self._last_pos = (0, 0)
|
||||
self._blocked_navigation: str | None = None
|
||||
|
||||
async def _require_url(self, url: str, label: str) -> None:
|
||||
ok, error = await asyncio.to_thread(
|
||||
_validate_browser_url,
|
||||
url,
|
||||
self._allowed_domains,
|
||||
)
|
||||
if not ok:
|
||||
raise ValueError(f"{label} is blocked: {error}")
|
||||
|
||||
async def _route_request(self, route: Any) -> None:
|
||||
request = route.request
|
||||
navigation = bool(request.is_navigation_request())
|
||||
ok, error = await asyncio.to_thread(
|
||||
_validate_browser_url,
|
||||
request.url,
|
||||
self._allowed_domains,
|
||||
navigation=navigation,
|
||||
)
|
||||
if ok:
|
||||
await route.continue_()
|
||||
return
|
||||
if navigation:
|
||||
self._blocked_navigation = error
|
||||
logger.warning("Blocked browser request to {}: {}", request.url, error)
|
||||
await route.abort("blockedbyclient")
|
||||
|
||||
async def _route_web_socket(self, web_socket: Any) -> None:
|
||||
ok, error = await asyncio.to_thread(
|
||||
_validate_browser_url,
|
||||
web_socket.url,
|
||||
self._allowed_domains,
|
||||
navigation=False,
|
||||
)
|
||||
if not ok:
|
||||
logger.warning("Blocked browser WebSocket to {}: {}", web_socket.url, error)
|
||||
await web_socket.close(code=1008, reason="Blocked by nanobot network policy")
|
||||
return
|
||||
await web_socket.connect_to_server()
|
||||
|
||||
def pop_blocked_navigation(self) -> str | None:
|
||||
error = self._blocked_navigation
|
||||
self._blocked_navigation = None
|
||||
return error
|
||||
|
||||
async def _ensure(self) -> Any:
|
||||
if self._page is not None:
|
||||
return self._page
|
||||
await self._require_url(self._start_url, "start_url")
|
||||
try:
|
||||
browser = await self._runtime.get()
|
||||
self._context = await browser.new_context(
|
||||
viewport={"width": self._width, "height": self._height},
|
||||
device_scale_factor=1,
|
||||
service_workers="block",
|
||||
)
|
||||
await self._context.route("**/*", self._route_request)
|
||||
await self._context.route_web_socket("**/*", self._route_web_socket)
|
||||
self._page = await self._context.new_page()
|
||||
if self._start_url != "about:blank":
|
||||
await self._page.goto(self._start_url)
|
||||
return self._page
|
||||
except BaseException:
|
||||
await self.close()
|
||||
raise
|
||||
|
||||
async def dimensions(self) -> tuple[int, int]:
|
||||
await self._ensure()
|
||||
vp = self._page.viewport_size or {"width": self._width, "height": self._height}
|
||||
return vp["width"], vp["height"]
|
||||
|
||||
async def screenshot(self) -> bytes:
|
||||
page = await self._ensure()
|
||||
return await page.screenshot()
|
||||
|
||||
async def click(self, x: int, y: int, button: str = "left", count: int = 1) -> None:
|
||||
page = await self._ensure()
|
||||
await page.mouse.click(x, y, button=button, click_count=count)
|
||||
self._last_pos = (x, y)
|
||||
|
||||
async def move(self, x: int, y: int) -> None:
|
||||
page = await self._ensure()
|
||||
await page.mouse.move(x, y)
|
||||
self._last_pos = (x, y)
|
||||
|
||||
async def drag(self, x: int, y: int) -> None:
|
||||
page = await self._ensure()
|
||||
sx, sy = self._last_pos
|
||||
await page.mouse.move(sx, sy)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(x, y)
|
||||
await page.mouse.up()
|
||||
self._last_pos = (x, y)
|
||||
|
||||
async def scroll(self, x: int, y: int, direction: str, amount: int) -> None:
|
||||
page = await self._ensure()
|
||||
await page.mouse.move(x, y)
|
||||
pixels = max(1, amount) * _SCROLL_PIXELS
|
||||
dx = pixels if direction == "right" else -pixels if direction == "left" else 0
|
||||
dy = pixels if direction == "down" else -pixels if direction == "up" else 0
|
||||
await page.mouse.wheel(dx, dy)
|
||||
|
||||
async def type_text(self, text: str) -> None:
|
||||
page = await self._ensure()
|
||||
await page.keyboard.type(text)
|
||||
|
||||
async def key(self, combo: str) -> None:
|
||||
page = await self._ensure()
|
||||
key = _playwright_key(combo)
|
||||
if key:
|
||||
await page.keyboard.press(key)
|
||||
|
||||
async def navigate(self, url: str) -> None:
|
||||
await self._require_url(url, "navigation")
|
||||
page = await self._ensure()
|
||||
await page.goto(url)
|
||||
self._last_pos = (0, 0)
|
||||
|
||||
# --- DOM / accessibility mode (act by element ref, not pixels) ---
|
||||
|
||||
async def dom_snapshot(self, max_elements: int = 200) -> list[dict[str, Any]]:
|
||||
"""Tag visible interactive elements with ``data-nanobot-ref`` and return them.
|
||||
|
||||
Each entry: ``{ref, tag, role, type, name, href}``. Refs are reassigned on
|
||||
every snapshot, so callers should act on the latest snapshot.
|
||||
"""
|
||||
page = await self._ensure()
|
||||
return cast(list[dict[str, Any]], await page.evaluate(_SNAPSHOT_JS, max_elements))
|
||||
|
||||
def _ref_selector(self, ref: int) -> str:
|
||||
return f'[data-nanobot-ref="{int(ref)}"]'
|
||||
|
||||
async def click_ref(self, ref: int) -> None:
|
||||
page = await self._ensure()
|
||||
await page.click(self._ref_selector(ref), timeout=5000)
|
||||
|
||||
async def fill_ref(self, ref: int, text: str, submit: bool = False) -> None:
|
||||
page = await self._ensure()
|
||||
sel = self._ref_selector(ref)
|
||||
await page.fill(sel, text, timeout=5000)
|
||||
if submit:
|
||||
await page.press(sel, "Enter")
|
||||
|
||||
async def select_ref(self, ref: int, value: str) -> None:
|
||||
page = await self._ensure()
|
||||
sel = self._ref_selector(ref)
|
||||
try:
|
||||
await page.select_option(sel, value, timeout=3000)
|
||||
except Exception:
|
||||
# Models usually pass the visible label, not the option value.
|
||||
await page.select_option(sel, label=value, timeout=3000)
|
||||
|
||||
async def scroll_page(self, direction: str, amount: int) -> None:
|
||||
page = await self._ensure()
|
||||
pixels = max(1, amount) * _SCROLL_PIXELS
|
||||
dx = pixels if direction == "right" else -pixels if direction == "left" else 0
|
||||
dy = pixels if direction == "down" else -pixels if direction == "up" else 0
|
||||
await page.evaluate("([x, y]) => window.scrollBy(x, y)", [dx, dy])
|
||||
|
||||
async def go_back(self) -> None:
|
||||
page = await self._ensure()
|
||||
await page.go_back()
|
||||
|
||||
async def read_text(self, max_chars: int = 4000) -> str:
|
||||
page = await self._ensure()
|
||||
txt = await page.evaluate("() => document.body ? document.body.innerText : ''")
|
||||
return (txt or "")[:max_chars]
|
||||
|
||||
async def current_url(self) -> str:
|
||||
page = await self._ensure()
|
||||
return page.url
|
||||
|
||||
async def close(self) -> None:
|
||||
context = self._context
|
||||
self._context = self._page = None
|
||||
error: BaseException | None = None
|
||||
if context is not None:
|
||||
try:
|
||||
await context.close()
|
||||
except BaseException as exc:
|
||||
error = exc
|
||||
if self._owns_runtime:
|
||||
try:
|
||||
await self._runtime.close()
|
||||
except BaseException as exc:
|
||||
if error is not None:
|
||||
raise BaseExceptionGroup("failed to close browser backend", [error, exc])
|
||||
raise
|
||||
if error is not None:
|
||||
raise error
|
||||
@@ -0,0 +1,134 @@
|
||||
"""PyAutoGUI desktop backend with HiDPI coordinate correction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.tools.computer_use_backends.base import ComputerBackend
|
||||
|
||||
_MISSING = (
|
||||
"Desktop computer-use backend needs 'pyautogui' and 'pillow'. "
|
||||
"Install with: pip install 'nanobot-ai[computer-use]'"
|
||||
)
|
||||
|
||||
# xdotool/CUA-style key names -> PyAutoGUI key names.
|
||||
_KEY_ALIASES = {
|
||||
"return": "enter",
|
||||
"ctrl": "ctrl",
|
||||
"control": "ctrl",
|
||||
"cmd": "command",
|
||||
"super": "win",
|
||||
"win": "win",
|
||||
"page_down": "pagedown",
|
||||
"page_up": "pageup",
|
||||
"pagedown": "pagedown",
|
||||
"pageup": "pageup",
|
||||
"esc": "esc",
|
||||
"escape": "esc",
|
||||
}
|
||||
|
||||
|
||||
class DesktopBackend(ComputerBackend):
|
||||
environment = "desktop"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pg: Any = None
|
||||
self._ratio_x = 1.0
|
||||
self._ratio_y = 1.0
|
||||
self._dims: tuple[int, int] | None = None
|
||||
|
||||
def _ensure(self) -> Any:
|
||||
if self._pg is not None:
|
||||
return self._pg
|
||||
try:
|
||||
import pyautogui # noqa: PLC0415
|
||||
except Exception as exc: # ImportError, or platform display errors
|
||||
raise ImportError(_MISSING) from exc
|
||||
self._pg = pyautogui
|
||||
return pyautogui
|
||||
|
||||
def _grab_png_and_size(self) -> tuple[bytes, int, int]:
|
||||
pg = self._ensure()
|
||||
img = pg.screenshot()
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
width, height = img.size
|
||||
# Refresh logical<->physical ratio from the actual grab.
|
||||
try:
|
||||
logical_w, logical_h = pg.size()
|
||||
self._ratio_x = (logical_w / width) if width else 1.0
|
||||
self._ratio_y = (logical_h / height) if height else 1.0
|
||||
except Exception:
|
||||
self._ratio_x = self._ratio_y = 1.0
|
||||
self._dims = (width, height)
|
||||
return buf.getvalue(), width, height
|
||||
|
||||
def _to_logical(self, x: int, y: int) -> tuple[int, int]:
|
||||
return round(x * self._ratio_x), round(y * self._ratio_y)
|
||||
|
||||
async def dimensions(self) -> tuple[int, int]:
|
||||
if self._dims is not None:
|
||||
return self._dims
|
||||
_, w, h = await asyncio.to_thread(self._grab_png_and_size)
|
||||
return w, h
|
||||
|
||||
async def screenshot(self) -> bytes:
|
||||
png, _, _ = await asyncio.to_thread(self._grab_png_and_size)
|
||||
return png
|
||||
|
||||
async def click(self, x: int, y: int, button: str = "left", count: int = 1) -> None:
|
||||
pg = self._ensure()
|
||||
lx, ly = self._to_logical(x, y)
|
||||
await asyncio.to_thread(pg.click, lx, ly, clicks=count, button=button)
|
||||
|
||||
async def move(self, x: int, y: int) -> None:
|
||||
pg = self._ensure()
|
||||
lx, ly = self._to_logical(x, y)
|
||||
await asyncio.to_thread(pg.moveTo, lx, ly)
|
||||
|
||||
async def drag(self, x: int, y: int) -> None:
|
||||
pg = self._ensure()
|
||||
lx, ly = self._to_logical(x, y)
|
||||
await asyncio.to_thread(
|
||||
pg.dragTo,
|
||||
lx,
|
||||
ly,
|
||||
duration=0.3,
|
||||
tween=pg.easeInOutQuad,
|
||||
button="left",
|
||||
)
|
||||
|
||||
async def scroll(self, x: int, y: int, direction: str, amount: int) -> None:
|
||||
pg = self._ensure()
|
||||
lx, ly = self._to_logical(x, y)
|
||||
clicks = max(1, amount)
|
||||
await asyncio.to_thread(pg.moveTo, lx, ly)
|
||||
if direction in ("up", "down"):
|
||||
await asyncio.to_thread(pg.scroll, clicks if direction == "up" else -clicks)
|
||||
else:
|
||||
await asyncio.to_thread(pg.hscroll, clicks if direction == "right" else -clicks)
|
||||
|
||||
async def type_text(self, text: str) -> None:
|
||||
if not text.isascii():
|
||||
raise ValueError(
|
||||
"desktop text input supports ASCII key events only; "
|
||||
"use the browser backend for Unicode text"
|
||||
)
|
||||
pg = self._ensure()
|
||||
await asyncio.to_thread(pg.typewrite, text, 0.01)
|
||||
|
||||
async def key(self, combo: str) -> None:
|
||||
pg = self._ensure()
|
||||
keys = [
|
||||
_KEY_ALIASES.get(part.strip().lower(), part.strip().lower())
|
||||
for part in combo.split("+")
|
||||
if part.strip()
|
||||
]
|
||||
if not keys:
|
||||
return
|
||||
if len(keys) == 1:
|
||||
await asyncio.to_thread(pg.press, keys[0])
|
||||
else:
|
||||
await asyncio.to_thread(pg.hotkey, *keys)
|
||||
@@ -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()
|
||||
|
||||
@@ -148,19 +148,9 @@ class _FsTool(Tool):
|
||||
)
|
||||
|
||||
def _resolve_read(self, path: str) -> Path:
|
||||
plugin_skill_dirs: list[Path] = []
|
||||
if self._workspace is not None:
|
||||
from nanobot.agent.plugins import enabled_agent_plugin_skill_dirs
|
||||
|
||||
try:
|
||||
plugin_skill_dirs = list(
|
||||
enabled_agent_plugin_skill_dirs(Path(self._workspace))
|
||||
)
|
||||
except (OSError, RuntimeError):
|
||||
pass
|
||||
return self._resolve_with_extra(
|
||||
path,
|
||||
[*self._extra_read_allowed_dirs, *plugin_skill_dirs],
|
||||
self._extra_read_allowed_dirs,
|
||||
self._extra_read_allowed_files,
|
||||
include_media_dir=True,
|
||||
extra_files_require_allowed_root=True,
|
||||
@@ -837,8 +827,7 @@ class EditFileTool(_FsTool):
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Perform a small, exact replacement in one file by replacing "
|
||||
"old_text with new_text. When replacing text in an existing file, "
|
||||
"old_text and new_text must be different. Use this for narrow text substitutions "
|
||||
"old_text with new_text. Use this for narrow text substitutions "
|
||||
"with old_text copied from read_file. For multi-file, structural, "
|
||||
"or generated code edits, prefer apply_patch. If old_text matches "
|
||||
"multiple times, provide more context or set occurrence, line_hint, "
|
||||
@@ -873,12 +862,9 @@ class EditFileTool(_FsTool):
|
||||
return ToolResult.error("Error: expected_replacements must be >= 1.")
|
||||
|
||||
fp = self._resolve_write(path)
|
||||
file_exists = fp.exists()
|
||||
if file_exists and old_text == new_text:
|
||||
return ToolResult.error("Error: new_text must be different from old_text.")
|
||||
|
||||
# Create-file semantics: old_text='' + file doesn't exist → create
|
||||
if not file_exists:
|
||||
if not fp.exists():
|
||||
if old_text == "":
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp.write_text(new_text, encoding="utf-8")
|
||||
|
||||
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
|
||||
|
||||
_SKIP_MODULES = frozenset({
|
||||
"base", "schema", "registry", "context", "loader", "config",
|
||||
"file_state", "sandbox", "mcp", "__init__", "runtime_control",
|
||||
"file_state", "sandbox", "mcp", "__init__", "runtime_state",
|
||||
})
|
||||
|
||||
|
||||
@@ -187,5 +187,8 @@ class _LegacyErrorPrefixTool(Tool):
|
||||
return ToolResult.error(result)
|
||||
return result
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._wrapped.close()
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._wrapped, name)
|
||||
|
||||
+238
-372
@@ -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, 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,
|
||||
@@ -32,8 +38,7 @@ if TYPE_CHECKING:
|
||||
from mcp.types import Prompt, Resource
|
||||
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 +59,14 @@ _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"]
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
@@ -202,25 +184,6 @@ def _is_transient(exc: BaseException) -> bool:
|
||||
return type(exc).__name__ in _TRANSIENT_EXC_NAMES
|
||||
|
||||
|
||||
def _is_transient_connection_failure(exc: BaseException) -> bool:
|
||||
if isinstance(exc, BaseExceptionGroup):
|
||||
group = cast(BaseExceptionGroup[BaseException], exc)
|
||||
return bool(group.exceptions) and all(
|
||||
_is_transient_connection_failure(nested) for nested in group.exceptions
|
||||
)
|
||||
return isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)) or _is_transient(exc)
|
||||
|
||||
|
||||
def _log_mcp_connection_failure(name: str, exc: BaseException, hint: str = "") -> None:
|
||||
if _is_transient_connection_failure(exc):
|
||||
logger.warning("MCP server '{}': transient connection failure", name)
|
||||
logger.opt(exception=exc).debug(
|
||||
"MCP server '{}' transient connection failure details", name
|
||||
)
|
||||
return
|
||||
logger.opt(exception=exc).error("MCP server '{}': failed to connect: {}", name, hint)
|
||||
|
||||
|
||||
def _is_session_terminated(exc: BaseException) -> bool:
|
||||
"""Return True when the MCP SDK reports a dead client session."""
|
||||
if _is_transient(exc):
|
||||
@@ -505,11 +468,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 +562,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 +724,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 +828,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 +961,7 @@ class MCPPromptWrapper(_MCPWrapperBase):
|
||||
|
||||
|
||||
async def connect_mcp_servers(
|
||||
mcp_servers: dict[str, MCPServerConfig],
|
||||
registry: ToolRegistry,
|
||||
*,
|
||||
oauth_handlers: Mapping[str, MCPOAuthHandlers] | None = None,
|
||||
mcp_servers: "dict[str, MCPServerConfig]", registry: ToolRegistry
|
||||
) -> dict[str, MCPConnection]:
|
||||
"""Connect to configured MCP servers and register their tools, resources, prompts.
|
||||
|
||||
@@ -1015,8 +975,11 @@ 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
|
||||
) -> bool:
|
||||
name: str, cfg: "MCPServerConfig"
|
||||
) -> tuple[str, AsyncExitStack | None]:
|
||||
server_stack = AsyncExitStack()
|
||||
await server_stack.__aenter__()
|
||||
|
||||
try:
|
||||
transport_type = cfg.type
|
||||
if not transport_type:
|
||||
@@ -1028,7 +991,8 @@ async def connect_mcp_servers(
|
||||
)
|
||||
else:
|
||||
logger.warning("MCP server '{}': no command or url configured, skipping", name)
|
||||
return False
|
||||
await server_stack.aclose()
|
||||
return name, None
|
||||
|
||||
if transport_type in {"sse", "streamableHttp"}:
|
||||
ok, error = validate_url_target(cfg.url)
|
||||
@@ -1039,30 +1003,8 @@ async def connect_mcp_servers(
|
||||
_redact_url(cfg.url),
|
||||
error,
|
||||
)
|
||||
return False
|
||||
|
||||
oauth_auth: httpx.Auth | None = None
|
||||
if cfg.auth == "oauth":
|
||||
if transport_type not in {"sse", "streamableHttp"}:
|
||||
logger.warning(
|
||||
"MCP server '{}': OAuth requires an SSE or Streamable HTTP transport",
|
||||
name,
|
||||
)
|
||||
return False
|
||||
from nanobot.agent.tools.mcp_oauth import (
|
||||
MCPAuthorizationRequiredError,
|
||||
create_mcp_oauth_auth,
|
||||
)
|
||||
|
||||
try:
|
||||
oauth_auth = await create_mcp_oauth_auth(
|
||||
name,
|
||||
cfg.url,
|
||||
(oauth_handlers or {}).get(name),
|
||||
)
|
||||
except MCPAuthorizationRequiredError:
|
||||
logger.info("MCP server '{}': waiting for browser authorization", name)
|
||||
return False
|
||||
await server_stack.aclose()
|
||||
return name, None
|
||||
|
||||
if transport_type == "stdio":
|
||||
command, args, env = _normalize_windows_stdio_command(
|
||||
@@ -1080,7 +1022,8 @@ async def connect_mcp_servers(
|
||||
elif transport_type == "sse":
|
||||
if not await _probe_http_url(cfg.url):
|
||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
||||
return False
|
||||
await server_stack.aclose()
|
||||
return name, None
|
||||
|
||||
def httpx_client_factory(
|
||||
headers: dict[str, str] | None = None,
|
||||
@@ -1101,37 +1044,31 @@ async def connect_mcp_servers(
|
||||
**_pinned_transport_kwargs(),
|
||||
)
|
||||
|
||||
sse_kwargs: dict[str, Any] = {
|
||||
"httpx_client_factory": httpx_client_factory,
|
||||
}
|
||||
if oauth_auth is not None:
|
||||
sse_kwargs["auth"] = oauth_auth
|
||||
read, write = await server_stack.enter_async_context(
|
||||
sse_client(cfg.url, **sse_kwargs)
|
||||
sse_client(cfg.url, httpx_client_factory=httpx_client_factory)
|
||||
)
|
||||
elif transport_type == "streamableHttp":
|
||||
if not await _probe_http_url(cfg.url):
|
||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
||||
return False
|
||||
await server_stack.aclose()
|
||||
return name, None
|
||||
|
||||
http_client_kwargs: dict[str, Any] = {
|
||||
"headers": cfg.headers or None,
|
||||
"event_hooks": {"request": [_validate_mcp_request_url]},
|
||||
"follow_redirects": True,
|
||||
"timeout": httpx.Timeout(30.0, connect=10.0),
|
||||
**_pinned_transport_kwargs(),
|
||||
}
|
||||
if oauth_auth is not None:
|
||||
http_client_kwargs["auth"] = oauth_auth
|
||||
http_client = await server_stack.enter_async_context(
|
||||
httpx.AsyncClient(**http_client_kwargs)
|
||||
httpx.AsyncClient(
|
||||
headers=cfg.headers or None,
|
||||
event_hooks={"request": [_validate_mcp_request_url]},
|
||||
follow_redirects=True,
|
||||
timeout=httpx.Timeout(30.0, connect=10.0),
|
||||
**_pinned_transport_kwargs(),
|
||||
)
|
||||
)
|
||||
read, write, _ = await server_stack.enter_async_context(
|
||||
streamable_http_client(cfg.url, http_client=http_client)
|
||||
)
|
||||
else:
|
||||
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
|
||||
return False
|
||||
await server_stack.aclose()
|
||||
return name, None
|
||||
|
||||
read = _filter_malformed_mcp_progress_notifications(read, name)
|
||||
session = await server_stack.enter_async_context(ClientSession(read, write))
|
||||
@@ -1234,7 +1171,7 @@ async def connect_mcp_servers(
|
||||
logger.info(
|
||||
"MCP server '{}': connected, {} capabilities registered", name, registered_count
|
||||
)
|
||||
return True
|
||||
return name, server_stack
|
||||
|
||||
except Exception as e:
|
||||
hint = ""
|
||||
@@ -1253,41 +1190,43 @@ async def connect_mcp_servers(
|
||||
" Hint: this looks like stdio protocol pollution. Make sure the MCP server writes "
|
||||
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
|
||||
)
|
||||
_log_mcp_connection_failure(name, e, hint)
|
||||
return False
|
||||
logger.exception("MCP server '{}': failed to connect: {}", name, hint)
|
||||
with suppress(Exception):
|
||||
await server_stack.aclose()
|
||||
return name, None
|
||||
|
||||
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()
|
||||
close_requested = asyncio.Event()
|
||||
|
||||
async def own_connection() -> None:
|
||||
stack: AsyncExitStack | None = None
|
||||
try:
|
||||
async with AsyncExitStack() as stack:
|
||||
connected = await open_single_server(name, cfg, stack)
|
||||
_, stack = await open_single_server(name, cfg)
|
||||
if not ready.done():
|
||||
ready.set_result(connected)
|
||||
if connected:
|
||||
ready.set_result(stack is not None)
|
||||
if stack is not None:
|
||||
await close_requested.wait()
|
||||
except BaseException as exc:
|
||||
if not ready.done():
|
||||
ready.set_exception(exc)
|
||||
raise
|
||||
finally:
|
||||
if stack is not None:
|
||||
await stack.aclose()
|
||||
|
||||
owner = asyncio.create_task(own_connection(), name=f"mcp:{name}")
|
||||
connection = _OwnedMCPConnection(owner, close_requested)
|
||||
try:
|
||||
connected = await ready
|
||||
except BaseException as exc:
|
||||
except BaseException:
|
||||
close_requested.set()
|
||||
owner.cancel()
|
||||
with suppress(BaseException):
|
||||
await asyncio.shield(owner)
|
||||
if isinstance(exc, asyncio.CancelledError) and not task_is_cancelling():
|
||||
logger.warning("MCP server '{}': connection cancelled by server/SDK", name)
|
||||
return name, None
|
||||
raise
|
||||
if not connected:
|
||||
await connection.aclose()
|
||||
@@ -1295,29 +1234,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)
|
||||
logger.exception("MCP server '{}' connection failed: {}", 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)
|
||||
try:
|
||||
await _close_mcp_connections(server_stacks)
|
||||
except BaseException as cleanup_exc:
|
||||
logger.debug("MCP batch rollback cleanup error (can be ignored): {}", cleanup_exc)
|
||||
raise
|
||||
|
||||
return server_stacks
|
||||
|
||||
@@ -1328,158 +1253,53 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
return {"mcp_presets": mcp_presets} if isinstance(mcp_presets, list) and mcp_presets else {}
|
||||
|
||||
|
||||
def _configured_servers(config: Config) -> dict[str, MCPServerConfig]:
|
||||
from nanobot.agent.plugins import agent_plugin_mcp_servers
|
||||
|
||||
return agent_plugin_mcp_servers(
|
||||
config.workspace_path,
|
||||
config.tools.mcp_servers,
|
||||
)
|
||||
|
||||
|
||||
def _load_current_servers() -> dict[str, MCPServerConfig]:
|
||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||
|
||||
return _configured_servers(resolve_config_env_vars(load_config()))
|
||||
|
||||
|
||||
class MCPProvider:
|
||||
"""Own configured MCP connections and their dynamic tool registrations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
servers: Mapping[str, MCPServerConfig],
|
||||
registry: ToolRegistry,
|
||||
*,
|
||||
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,
|
||||
)
|
||||
|
||||
@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
|
||||
}
|
||||
|
||||
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:
|
||||
async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
|
||||
"""Connect configured MCP servers that are not currently live."""
|
||||
async with _reload_lock(state):
|
||||
if getattr(state, "_mcp_closing", False):
|
||||
return
|
||||
configured_missing = {
|
||||
name: cfg
|
||||
for name, cfg in self._servers.items()
|
||||
if name not in self._connections
|
||||
}
|
||||
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
|
||||
name: cfg for name, cfg in state._mcp_servers.items() if name not in state._mcp_stacks
|
||||
}
|
||||
if not missing_servers:
|
||||
if state._mcp_connecting or not missing_servers:
|
||||
return
|
||||
self._set_runtime_status(missing_servers, "connecting")
|
||||
state._mcp_connecting = True
|
||||
try:
|
||||
connected = await connect_mcp_servers(missing_servers, self._registry)
|
||||
if self._closing:
|
||||
await _close_mcp_connections(connected)
|
||||
connected = await connect_mcp_servers(missing_servers, registry)
|
||||
if getattr(state, "_mcp_closing", False):
|
||||
for connection in connected.values():
|
||||
await connection.aclose()
|
||||
return
|
||||
self._connections.update(connected)
|
||||
self._record_connection_result(missing_servers, connected)
|
||||
self._attach_reconnect_handlers(connected)
|
||||
state._mcp_stacks.update(connected)
|
||||
_attach_reconnect_handlers(state, registry, connected)
|
||||
if connected:
|
||||
logger.info("MCP connected servers: {}", sorted(connected))
|
||||
else:
|
||||
logger.warning(
|
||||
"No MCP servers connected successfully "
|
||||
"(will retry on the next readiness check)"
|
||||
)
|
||||
logger.warning("No MCP servers connected successfully (will retry next message)")
|
||||
except asyncio.CancelledError:
|
||||
self._set_runtime_status(missing_servers, "failed")
|
||||
if task_is_cancelling():
|
||||
raise
|
||||
logger.warning(
|
||||
"MCP connection cancelled (will retry on the next readiness check)"
|
||||
)
|
||||
except BaseException as exc:
|
||||
self._set_runtime_status(missing_servers, "failed")
|
||||
logger.warning(
|
||||
"Failed to connect MCP servers "
|
||||
"(will retry on the next readiness check): {}",
|
||||
exc,
|
||||
)
|
||||
logger.warning("MCP connection cancelled (will retry next message)")
|
||||
except BaseException as e:
|
||||
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
|
||||
finally:
|
||||
state._mcp_connecting = False
|
||||
|
||||
async def reload(self) -> dict[str, Any]:
|
||||
"""Reconcile live MCP connections with the current configuration."""
|
||||
async with self._lock:
|
||||
if self._closing:
|
||||
return self._closing_result()
|
||||
|
||||
async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
||||
"""Reconcile live MCP connections with the current config file."""
|
||||
async with _reload_lock(state):
|
||||
if getattr(state, "_mcp_closing", False):
|
||||
return {
|
||||
"ok": False,
|
||||
"message": "MCP connections are shutting down.",
|
||||
"requires_restart": True,
|
||||
}
|
||||
try:
|
||||
next_servers = dict(self._server_loader())
|
||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||
|
||||
config = resolve_config_env_vars(load_config())
|
||||
next_servers = dict(config.tools.mcp_servers)
|
||||
except Exception as exc:
|
||||
logger.warning("MCP hot reload could not read config: {}", exc)
|
||||
return {
|
||||
@@ -1489,69 +1309,49 @@ class MCPProvider:
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
current_servers = dict(self._servers)
|
||||
current_servers = dict(state._mcp_servers)
|
||||
current_names = set(current_servers)
|
||||
next_names = set(next_servers)
|
||||
from nanobot.agent.tools.mcp_oauth import mcp_oauth_has_credentials
|
||||
|
||||
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])
|
||||
if _server_signature(current_servers[name]) != _server_signature(next_servers[name])
|
||||
)
|
||||
|
||||
tools_removed = 0
|
||||
for name in [*removed, *changed]:
|
||||
tools_removed += _unregister_server_tools(self._registry, name)
|
||||
await self._close_server(name)
|
||||
tools_removed += _unregister_server_tools(registry, name)
|
||||
await _close_server(state, name)
|
||||
|
||||
for name in [*removed, *authorization_pending]:
|
||||
self._runtime_statuses.pop(name, None)
|
||||
|
||||
self._servers = next_servers
|
||||
state._mcp_servers = next_servers
|
||||
retry_missing = sorted(
|
||||
name
|
||||
for name in next_names
|
||||
if name not in self._connections
|
||||
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
|
||||
if name not in state._mcp_stacks and name not in set(added) | set(changed)
|
||||
)
|
||||
to_connect_names = sorted(set(added) | set(changed) | set(retry_missing))
|
||||
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)
|
||||
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)
|
||||
_attach_reconnect_handlers(state, registry, connected)
|
||||
|
||||
failed = sorted(set(to_connect) - set(connected))
|
||||
unchanged = not removed and not added and not changed and not retry_missing
|
||||
ok = not failed
|
||||
if failed:
|
||||
message = (
|
||||
"MCP config reloaded, but some servers did not connect: "
|
||||
+ ", ".join(failed)
|
||||
)
|
||||
message = "MCP config reloaded, but some servers did not connect: " + ", ".join(failed)
|
||||
elif unchanged:
|
||||
message = "MCP config is already live."
|
||||
elif retry_missing and not added and not changed and not removed:
|
||||
@@ -1560,8 +1360,7 @@ class MCPProvider:
|
||||
message = "MCP config reloaded without restarting nanobot."
|
||||
|
||||
logger.info(
|
||||
"MCP hot reload: added={} changed={} removed={} retried={} "
|
||||
"connected={} failed={} tools_removed={}",
|
||||
"MCP hot reload: added={} changed={} removed={} retried={} connected={} failed={} tools_removed={}",
|
||||
added,
|
||||
changed,
|
||||
removed,
|
||||
@@ -1577,51 +1376,114 @@ class MCPProvider:
|
||||
"changed": changed,
|
||||
"removed": removed,
|
||||
"retried": retry_missing,
|
||||
"connected": sorted(self._connections),
|
||||
"configured": sorted(self._servers),
|
||||
"connected": sorted(state._mcp_stacks),
|
||||
"configured": sorted(state._mcp_servers),
|
||||
"failed": failed,
|
||||
"tools_removed": tools_removed,
|
||||
"requires_restart": False,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _closing_result() -> dict[str, Any]:
|
||||
|
||||
async def request_mcp_reload(
|
||||
bus: MessageBus,
|
||||
*,
|
||||
timeout: float = 15.0,
|
||||
) -> dict[str, Any]:
|
||||
"""Ask the running agent loop to reconcile live MCP connections."""
|
||||
loop = asyncio.get_running_loop()
|
||||
ack: asyncio.Future[dict[str, Any]] = loop.create_future()
|
||||
await bus.publish_inbound(
|
||||
InboundMessage(
|
||||
channel="system",
|
||||
sender_id="webui-settings",
|
||||
chat_id="runtime",
|
||||
content=RUNTIME_CONTROL_MCP_RELOAD,
|
||||
metadata={
|
||||
INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_MCP_RELOAD,
|
||||
RUNTIME_CONTROL_ACK: ack,
|
||||
},
|
||||
)
|
||||
)
|
||||
try:
|
||||
result = await asyncio.wait_for(ack, timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
return {
|
||||
"ok": False,
|
||||
"message": "MCP connections are shutting down.",
|
||||
"message": "MCP hot reload timed out. Restart nanobot to pick up changes.",
|
||||
"requires_restart": True,
|
||||
}
|
||||
return result if isinstance(cast(object, result), dict) else {
|
||||
"ok": False,
|
||||
"message": "MCP hot reload returned an unexpected response.",
|
||||
"requires_restart": True,
|
||||
}
|
||||
|
||||
def _attach_reconnect_handlers(self, server_names: Iterable[str]) -> None:
|
||||
async def reconnect(
|
||||
server_name: str,
|
||||
tool_name: str,
|
||||
stale_tool: Tool,
|
||||
) -> Tool | None:
|
||||
return await self._refresh_terminated_server(
|
||||
|
||||
async def handle_runtime_control(state: Any, msg: InboundMessage, registry: ToolRegistry) -> bool:
|
||||
metadata = msg.metadata if isinstance(cast(object, msg.metadata), dict) else {}
|
||||
control = metadata.get(INBOUND_META_RUNTIME_CONTROL)
|
||||
if control != RUNTIME_CONTROL_MCP_RELOAD:
|
||||
return False
|
||||
|
||||
ack = metadata.get(RUNTIME_CONTROL_ACK)
|
||||
try:
|
||||
result = await reload_servers(state, registry)
|
||||
except Exception as exc:
|
||||
logger.exception("MCP hot reload failed")
|
||||
result = {
|
||||
"ok": False,
|
||||
"message": "MCP hot reload failed. Restart nanobot to pick up changes.",
|
||||
"requires_restart": True,
|
||||
"error": str(exc),
|
||||
}
|
||||
if isinstance(ack, asyncio.Future) and not ack.done():
|
||||
cast(asyncio.Future[dict[str, Any]], ack).set_result(result)
|
||||
return True
|
||||
|
||||
|
||||
def _reload_lock(state: Any) -> asyncio.Lock:
|
||||
try:
|
||||
return _RELOAD_LOCKS[state]
|
||||
except KeyError:
|
||||
lock = asyncio.Lock()
|
||||
_RELOAD_LOCKS[state] = lock
|
||||
return lock
|
||||
|
||||
|
||||
def _attach_reconnect_handlers(
|
||||
state: Any,
|
||||
registry: ToolRegistry,
|
||||
server_names: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...],
|
||||
) -> None:
|
||||
async def reconnect(server_name: str, tool_name: str, stale_tool: Tool) -> Tool | None:
|
||||
return await _refresh_terminated_server(
|
||||
state,
|
||||
registry,
|
||||
server_name,
|
||||
tool_name,
|
||||
stale_tool,
|
||||
)
|
||||
|
||||
for server_name in server_names:
|
||||
for tool_name in list(self._registry.tool_names):
|
||||
tool = self._registry.get(tool_name)
|
||||
for tool_name in list(registry.tool_names):
|
||||
tool = registry.get(tool_name)
|
||||
if not _tool_belongs_to_server(tool, tool_name, server_name):
|
||||
continue
|
||||
if isinstance(tool, _MCPWrapperBase):
|
||||
tool.set_reconnect_handler(reconnect)
|
||||
|
||||
|
||||
async def _refresh_terminated_server(
|
||||
self,
|
||||
state: Any,
|
||||
registry: ToolRegistry,
|
||||
server_name: str,
|
||||
tool_name: str,
|
||||
stale_tool: Tool,
|
||||
) -> Tool | None:
|
||||
async with self._lock:
|
||||
if self._closing:
|
||||
async with _reload_lock(state):
|
||||
if getattr(state, "_mcp_closing", False):
|
||||
return None
|
||||
cfg = self._servers.get(server_name)
|
||||
cfg = state._mcp_servers.get(server_name)
|
||||
if cfg is None:
|
||||
logger.warning(
|
||||
"MCP server '{}' session terminated but is no longer configured",
|
||||
@@ -1629,56 +1491,29 @@ class MCPProvider:
|
||||
)
|
||||
return None
|
||||
|
||||
current_tool = self._registry.get(tool_name)
|
||||
current_tool = registry.get(tool_name)
|
||||
if (
|
||||
current_tool is not None
|
||||
and current_tool is not stale_tool
|
||||
and server_name in self._connections
|
||||
and server_name in state._mcp_stacks
|
||||
):
|
||||
return current_tool
|
||||
|
||||
logger.warning(
|
||||
"MCP server '{}' session terminated; refreshing connection",
|
||||
server_name,
|
||||
)
|
||||
_unregister_server_tools(self._registry, server_name)
|
||||
await self._close_server(server_name)
|
||||
logger.warning("MCP server '{}' session terminated; refreshing connection", server_name)
|
||||
_unregister_server_tools(registry, server_name)
|
||||
await _close_server(state, server_name)
|
||||
|
||||
self._set_runtime_status({server_name}, "connecting")
|
||||
connected = await connect_mcp_servers(
|
||||
{server_name: cfg},
|
||||
self._registry,
|
||||
)
|
||||
if self._closing:
|
||||
await _close_mcp_connections(connected)
|
||||
connected = await connect_mcp_servers({server_name: cfg}, registry)
|
||||
if getattr(state, "_mcp_closing", False):
|
||||
for connection in connected.values():
|
||||
await connection.aclose()
|
||||
return None
|
||||
self._connections.update(connected)
|
||||
self._record_connection_result({server_name}, connected)
|
||||
self._attach_reconnect_handlers(connected)
|
||||
state._mcp_stacks.update(connected)
|
||||
_attach_reconnect_handlers(state, registry, connected)
|
||||
if server_name not in connected:
|
||||
logger.warning(
|
||||
"MCP server '{}' reconnect failed after session termination",
|
||||
server_name,
|
||||
)
|
||||
logger.warning("MCP server '{}' reconnect failed after session termination", server_name)
|
||||
return None
|
||||
return self._registry.get(tool_name)
|
||||
|
||||
async def _close_server(self, server_name: str) -> None:
|
||||
connection = self._connections.pop(server_name, None)
|
||||
if connection is None:
|
||||
return
|
||||
await _close_mcp_connection(server_name, connection)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Close every connection while excluding reconnect and hot reload."""
|
||||
self._closing = True
|
||||
async with self._lock:
|
||||
connections = dict(self._connections)
|
||||
self._connections.clear()
|
||||
self._runtime_statuses.clear()
|
||||
for name in self._servers:
|
||||
_unregister_server_tools(self._registry, name)
|
||||
await _close_mcp_connections(connections)
|
||||
return registry.get(tool_name)
|
||||
|
||||
|
||||
def _server_signature(cfg: Any) -> Any:
|
||||
@@ -1705,3 +1540,34 @@ 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()
|
||||
for name, connection in connections:
|
||||
try:
|
||||
await connection.aclose()
|
||||
except asyncio.CancelledError:
|
||||
if task_is_cancelling():
|
||||
raise
|
||||
logger.debug("MCP server '{}' cleanup error (can be ignored)", name)
|
||||
except (RuntimeError, BaseExceptionGroup):
|
||||
logger.debug("MCP server '{}' cleanup error (can be ignored)", name)
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
"""OAuth support for remote MCP servers.
|
||||
|
||||
This module intentionally owns MCP OAuth end to end. Provider OAuth has a
|
||||
different lifecycle and storage contract, so sharing a higher-level workflow
|
||||
would couple unrelated extension boundaries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
from filelock import FileLock
|
||||
from loguru import logger
|
||||
from mcp.client.auth import OAuthClientProvider
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
||||
from pydantic import AnyHttpUrl, AnyUrl
|
||||
|
||||
from nanobot.config.paths import get_data_dir
|
||||
from nanobot.utils.helpers import _write_text_atomic # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
MCP_OAUTH_CALLBACK_PATH = "/auth/mcp/callback"
|
||||
_STORE_VERSION = 1
|
||||
_STORE_LOCK_TIMEOUT_S = 15
|
||||
_DEFAULT_REDIRECT_URI = f"http://127.0.0.1{MCP_OAUTH_CALLBACK_PATH}"
|
||||
_CLIENT_URI = AnyHttpUrl("https://github.com/HKUDS/nanobot")
|
||||
_LOGO_URI = AnyHttpUrl(
|
||||
"https://raw.githubusercontent.com/HKUDS/nanobot/main/"
|
||||
"webui/public/brand/nanobot_apple_touch.png"
|
||||
)
|
||||
|
||||
|
||||
class _StoredServer(TypedDict, total=False):
|
||||
server_fingerprint: str
|
||||
write_lease: str
|
||||
tokens: dict[str, Any]
|
||||
client_info: dict[str, Any]
|
||||
redirect_uri: str
|
||||
|
||||
|
||||
class _CredentialStore(TypedDict):
|
||||
version: int
|
||||
servers: dict[str, _StoredServer]
|
||||
generations: dict[str, str]
|
||||
|
||||
|
||||
class MCPAuthorizationRequiredError(RuntimeError):
|
||||
"""Raised when a background MCP connection needs interactive authorization."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MCPOAuthHandlers:
|
||||
"""Browser callbacks supplied only for a user-initiated OAuth attempt."""
|
||||
|
||||
redirect_uri: str
|
||||
redirect_handler: Callable[[str], Awaitable[None]]
|
||||
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]]
|
||||
reset_credentials: bool = False
|
||||
|
||||
|
||||
def _store_path() -> Path:
|
||||
return get_data_dir() / "auth" / "mcp.json"
|
||||
|
||||
|
||||
def _server_fingerprint(server_url: str) -> str:
|
||||
return hashlib.sha256(server_url.strip().encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _empty_store() -> _CredentialStore:
|
||||
return {"version": _STORE_VERSION, "servers": {}, "generations": {}}
|
||||
|
||||
|
||||
def _stored_server(value: object) -> _StoredServer | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
raw = cast(dict[object, object], value)
|
||||
entry: _StoredServer = {}
|
||||
fingerprint = raw.get("server_fingerprint")
|
||||
if isinstance(fingerprint, str):
|
||||
entry["server_fingerprint"] = fingerprint
|
||||
write_lease = raw.get("write_lease")
|
||||
if isinstance(write_lease, str) and write_lease:
|
||||
entry["write_lease"] = write_lease
|
||||
redirect_uri = raw.get("redirect_uri")
|
||||
if isinstance(redirect_uri, str):
|
||||
entry["redirect_uri"] = redirect_uri
|
||||
tokens = raw.get("tokens")
|
||||
if isinstance(tokens, dict):
|
||||
token_values = cast(dict[object, object], tokens)
|
||||
if all(isinstance(key, str) for key in token_values):
|
||||
entry["tokens"] = cast(dict[str, Any], token_values)
|
||||
client_info = raw.get("client_info")
|
||||
if isinstance(client_info, dict):
|
||||
client_values = cast(dict[object, object], client_info)
|
||||
if all(isinstance(key, str) for key in client_values):
|
||||
entry["client_info"] = cast(dict[str, Any], client_values)
|
||||
return entry
|
||||
|
||||
|
||||
def _read_store_unlocked(path: Path) -> _CredentialStore:
|
||||
try:
|
||||
raw = cast(object, json.loads(path.read_text(encoding="utf-8")))
|
||||
except FileNotFoundError:
|
||||
return _empty_store()
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
logger.warning("Could not read MCP OAuth credentials: {}", type(exc).__name__)
|
||||
return _empty_store()
|
||||
if not isinstance(raw, dict):
|
||||
return _empty_store()
|
||||
payload = cast(dict[object, object], raw)
|
||||
raw_servers = payload.get("servers")
|
||||
if not isinstance(raw_servers, dict):
|
||||
return _empty_store()
|
||||
servers: dict[str, _StoredServer] = {}
|
||||
for name, value in cast(dict[object, object], raw_servers).items():
|
||||
entry = _stored_server(value)
|
||||
if isinstance(name, str) and entry is not None:
|
||||
servers[name] = entry
|
||||
generations: dict[str, str] = {}
|
||||
raw_generations = payload.get("generations")
|
||||
if isinstance(raw_generations, dict):
|
||||
for name, value in cast(dict[object, object], raw_generations).items():
|
||||
if isinstance(name, str) and isinstance(value, str) and value:
|
||||
generations[name] = value
|
||||
return {
|
||||
"version": _STORE_VERSION,
|
||||
"servers": servers,
|
||||
"generations": generations,
|
||||
}
|
||||
|
||||
|
||||
def _with_store_lock(path: Path) -> FileLock:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return FileLock(str(path.with_suffix(".lock")), timeout=_STORE_LOCK_TIMEOUT_S)
|
||||
|
||||
|
||||
def _write_store_unlocked(path: Path, payload: _CredentialStore) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with suppress(OSError):
|
||||
os.chmod(path.parent, 0o700)
|
||||
_write_text_atomic(path, json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
with suppress(OSError):
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
class MCPOAuthStorage:
|
||||
"""Persistent MCP SDK token storage, isolated by config name and server URL."""
|
||||
|
||||
def __init__(self, server_name: str, server_url: str) -> None:
|
||||
self.server_name = server_name
|
||||
self.server_fingerprint = _server_fingerprint(server_url)
|
||||
self._observed_generation = self._read_generation_sync()
|
||||
self._write_lease: str | None = None
|
||||
|
||||
def _read_generation_sync(self) -> str | None:
|
||||
path = _store_path()
|
||||
if not path.exists():
|
||||
return None
|
||||
# Writes replace the whole file atomically, so this observes either side
|
||||
# of a concurrent deletion without blocking the async connection path.
|
||||
return _read_store_unlocked(path)["generations"].get(self.server_name)
|
||||
|
||||
def _generation_is_current(self, payload: _CredentialStore) -> bool:
|
||||
return payload["generations"].get(self.server_name) == self._observed_generation
|
||||
|
||||
def _entry_unlocked(self, payload: _CredentialStore) -> _StoredServer | None:
|
||||
servers = payload["servers"]
|
||||
entry = servers.get(self.server_name)
|
||||
if entry is None or entry.get("server_fingerprint") != self.server_fingerprint:
|
||||
return None
|
||||
return entry
|
||||
|
||||
def _bind_entry_unlocked(
|
||||
self,
|
||||
payload: _CredentialStore,
|
||||
*,
|
||||
create: bool,
|
||||
) -> tuple[_StoredServer | None, bool]:
|
||||
if not self._generation_is_current(payload):
|
||||
return None, False
|
||||
entry = self._entry_unlocked(payload)
|
||||
if self._write_lease is not None:
|
||||
if entry is None or entry.get("write_lease") != self._write_lease:
|
||||
return None, False
|
||||
return entry, False
|
||||
if entry is None:
|
||||
if not create:
|
||||
return None, False
|
||||
self._write_lease = secrets.token_urlsafe(24)
|
||||
entry = _StoredServer(
|
||||
server_fingerprint=self.server_fingerprint,
|
||||
write_lease=self._write_lease,
|
||||
)
|
||||
payload["servers"][self.server_name] = entry
|
||||
return entry, True
|
||||
write_lease = entry.get("write_lease")
|
||||
changed = not isinstance(write_lease, str) or not write_lease
|
||||
if changed:
|
||||
write_lease = secrets.token_urlsafe(24)
|
||||
entry["write_lease"] = write_lease
|
||||
self._write_lease = write_lease
|
||||
return entry, changed
|
||||
|
||||
def _read_entry_sync(self) -> _StoredServer | None:
|
||||
path = _store_path()
|
||||
with _with_store_lock(path):
|
||||
payload = _read_store_unlocked(path)
|
||||
entry, changed = self._bind_entry_unlocked(payload, create=False)
|
||||
if changed:
|
||||
_write_store_unlocked(path, payload)
|
||||
return entry
|
||||
|
||||
def _update_entry_sync(
|
||||
self,
|
||||
update: Callable[[_StoredServer], None],
|
||||
*,
|
||||
create: bool = True,
|
||||
claim: bool = False,
|
||||
) -> bool:
|
||||
path = _store_path()
|
||||
with _with_store_lock(path):
|
||||
payload = _read_store_unlocked(path)
|
||||
if claim:
|
||||
# A browser flow owns subsequent SDK writes until another flow
|
||||
# claims the entry or the configured server is removed.
|
||||
if not self._generation_is_current(payload):
|
||||
logger.info(
|
||||
"Ignored stale MCP OAuth credential claim for '{}'",
|
||||
self.server_name,
|
||||
)
|
||||
return False
|
||||
entry = self._entry_unlocked(payload)
|
||||
if entry is None:
|
||||
entry = _StoredServer(server_fingerprint=self.server_fingerprint)
|
||||
payload["servers"][self.server_name] = entry
|
||||
self._write_lease = secrets.token_urlsafe(24)
|
||||
entry["write_lease"] = self._write_lease
|
||||
else:
|
||||
entry, _ = self._bind_entry_unlocked(payload, create=create)
|
||||
if entry is None:
|
||||
if self._write_lease is not None:
|
||||
logger.info(
|
||||
"Ignored stale MCP OAuth credential update for '{}'",
|
||||
self.server_name,
|
||||
)
|
||||
return False
|
||||
update(entry)
|
||||
payload["version"] = _STORE_VERSION
|
||||
_write_store_unlocked(path, payload)
|
||||
return True
|
||||
|
||||
async def get_tokens(self) -> OAuthToken | None:
|
||||
entry = await asyncio.to_thread(self._read_entry_sync)
|
||||
raw = entry.get("tokens") if entry is not None else None
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
try:
|
||||
return OAuthToken.model_validate(raw)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("Ignoring invalid MCP OAuth tokens for '{}'", self.server_name)
|
||||
return None
|
||||
|
||||
async def set_tokens(self, tokens: OAuthToken) -> None:
|
||||
raw = tokens.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
def update(entry: _StoredServer) -> None:
|
||||
entry["tokens"] = raw
|
||||
|
||||
await asyncio.to_thread(self._update_entry_sync, update)
|
||||
|
||||
async def clear_tokens(self) -> None:
|
||||
def update(entry: _StoredServer) -> None:
|
||||
entry.pop("tokens", None)
|
||||
|
||||
await asyncio.to_thread(self._update_entry_sync, update, create=False)
|
||||
|
||||
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
||||
entry = await asyncio.to_thread(self._read_entry_sync)
|
||||
raw = entry.get("client_info") if entry is not None else None
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
try:
|
||||
return OAuthClientInformationFull.model_validate(raw)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("Ignoring invalid MCP OAuth client info for '{}'", self.server_name)
|
||||
return None
|
||||
|
||||
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
||||
raw = client_info.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
def update(entry: _StoredServer) -> None:
|
||||
entry["client_info"] = raw
|
||||
|
||||
await asyncio.to_thread(self._update_entry_sync, update)
|
||||
|
||||
async def redirect_uri(self) -> str | None:
|
||||
entry = await asyncio.to_thread(self._read_entry_sync)
|
||||
value = entry.get("redirect_uri") if entry is not None else None
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
async def prepare_redirect_uri(self, redirect_uri: str, *, reset: bool = False) -> None:
|
||||
def update(entry: _StoredServer) -> None:
|
||||
changed = entry.get("redirect_uri") != redirect_uri
|
||||
if reset:
|
||||
entry.pop("tokens", None)
|
||||
entry.pop("client_info", None)
|
||||
elif changed:
|
||||
# Dynamic registrations bind a client to its redirect URI.
|
||||
entry.pop("client_info", None)
|
||||
entry["redirect_uri"] = redirect_uri
|
||||
|
||||
claimed = await asyncio.to_thread(self._update_entry_sync, update, claim=True)
|
||||
if not claimed:
|
||||
raise MCPAuthorizationRequiredError("MCP authorization was cancelled")
|
||||
|
||||
def has_credentials(self) -> bool:
|
||||
entry = self._read_entry_sync()
|
||||
raw_tokens = entry.get("tokens") if entry is not None else None
|
||||
if not isinstance(raw_tokens, dict):
|
||||
return False
|
||||
tokens = cast(dict[str, object], raw_tokens)
|
||||
access_token = tokens.get("access_token")
|
||||
return isinstance(access_token, str) and bool(access_token)
|
||||
|
||||
|
||||
async def _missing_callback() -> tuple[str, str | None]:
|
||||
raise MCPAuthorizationRequiredError("MCP server requires browser authorization")
|
||||
|
||||
|
||||
async def create_mcp_oauth_auth(
|
||||
server_name: str,
|
||||
server_url: str,
|
||||
handlers: MCPOAuthHandlers | None = None,
|
||||
) -> OAuthClientProvider:
|
||||
"""Build the official MCP SDK OAuth provider for one configured server."""
|
||||
storage = MCPOAuthStorage(server_name, server_url)
|
||||
if handlers is not None:
|
||||
await storage.prepare_redirect_uri(
|
||||
handlers.redirect_uri,
|
||||
reset=handlers.reset_credentials,
|
||||
)
|
||||
redirect_uri = handlers.redirect_uri
|
||||
redirect_handler = handlers.redirect_handler
|
||||
callback_handler = handlers.callback_handler
|
||||
else:
|
||||
if not await asyncio.to_thread(storage.has_credentials):
|
||||
# Do not perform discovery or dynamic registration from a background
|
||||
# startup. Interactive OAuth begins only after an explicit user action.
|
||||
raise MCPAuthorizationRequiredError("MCP server requires browser authorization")
|
||||
redirect_uri = await storage.redirect_uri() or _DEFAULT_REDIRECT_URI
|
||||
|
||||
async def authorization_required(_authorization_url: str) -> None:
|
||||
await storage.clear_tokens()
|
||||
raise MCPAuthorizationRequiredError("MCP server requires browser authorization")
|
||||
|
||||
redirect_handler = authorization_required
|
||||
callback_handler = _missing_callback
|
||||
|
||||
metadata = OAuthClientMetadata(
|
||||
redirect_uris=[AnyUrl(redirect_uri)],
|
||||
token_endpoint_auth_method="none",
|
||||
client_name="nanobot",
|
||||
client_uri=_CLIENT_URI,
|
||||
logo_uri=_LOGO_URI,
|
||||
software_id="https://github.com/HKUDS/nanobot",
|
||||
)
|
||||
return OAuthClientProvider(
|
||||
server_url,
|
||||
metadata,
|
||||
storage,
|
||||
redirect_handler=redirect_handler,
|
||||
callback_handler=callback_handler,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
|
||||
def mcp_oauth_has_credentials(server_name: str, server_url: str) -> bool:
|
||||
"""Return whether this exact configured MCP instance has an access token."""
|
||||
return MCPOAuthStorage(server_name, server_url).has_credentials()
|
||||
|
||||
|
||||
def delete_mcp_oauth_credentials(server_name: str) -> bool:
|
||||
"""Delete credentials for one config name without touching other MCP instances."""
|
||||
path = _store_path()
|
||||
with _with_store_lock(path):
|
||||
payload = _read_store_unlocked(path)
|
||||
servers = payload["servers"]
|
||||
removed = servers.pop(server_name, None) is not None
|
||||
# Rotate even when no entry exists so a flow created before removal cannot
|
||||
# claim the name later and resurrect credentials.
|
||||
payload["generations"][server_name] = secrets.token_urlsafe(24)
|
||||
_write_store_unlocked(path, payload)
|
||||
return removed
|
||||
@@ -200,6 +200,19 @@ class ToolRegistry:
|
||||
except Exception as e:
|
||||
return ToolResult.error(f"Error executing {name}: {str(e)}" + hint)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close every registered tool, attempting all cleanups."""
|
||||
errors: list[BaseException] = []
|
||||
for tool in self._tools.values():
|
||||
try:
|
||||
await tool.close()
|
||||
except BaseException as exc:
|
||||
errors.append(exc)
|
||||
if len(errors) == 1:
|
||||
raise errors[0]
|
||||
if errors:
|
||||
raise BaseExceptionGroup("failed to close tools", errors)
|
||||
|
||||
@property
|
||||
def tool_names(self) -> list[str]:
|
||||
"""Get list of registered tool names."""
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
"""Explicit runtime state boundary used by :class:`MyTool`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Protocol, TypeAlias, runtime_checkable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.subagent import SubagentManager, SubagentStatus
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
from nanobot.agent.tools.web import WebToolsConfig
|
||||
from nanobot.config.schema import ModelPresetConfig
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
|
||||
|
||||
JsonScalar: TypeAlias = str | int | float | bool | None
|
||||
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
|
||||
|
||||
|
||||
RUNTIME_SNAPSHOT_KEYS = frozenset({
|
||||
"model",
|
||||
"model_preset",
|
||||
"model_presets",
|
||||
"max_iterations",
|
||||
"context_window_tokens",
|
||||
"workspace",
|
||||
"provider_retry_mode",
|
||||
"max_tool_result_chars",
|
||||
"current_iteration",
|
||||
"_current_iteration",
|
||||
"tool_names",
|
||||
"web_config",
|
||||
"exec_config",
|
||||
"subagents",
|
||||
"_last_usage",
|
||||
})
|
||||
|
||||
RUNTIME_COMMAND_KEYS = frozenset({
|
||||
"model",
|
||||
"model_preset",
|
||||
"max_iterations",
|
||||
"context_window_tokens",
|
||||
"provider_retry_mode",
|
||||
"max_tool_result_chars",
|
||||
"workspace",
|
||||
})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuntimeSnapshot:
|
||||
"""Detached, allowlisted values available to self-inspection."""
|
||||
|
||||
model: str
|
||||
model_preset: str | None
|
||||
model_presets: dict[str, dict[str, object]]
|
||||
max_iterations: int
|
||||
context_window_tokens: int
|
||||
workspace: Path | str
|
||||
provider_retry_mode: str
|
||||
max_tool_result_chars: int
|
||||
current_iteration: int
|
||||
tool_names: list[str]
|
||||
web_config: dict[str, object]
|
||||
exec_config: dict[str, object]
|
||||
subagent_statuses: dict[str, dict[str, object]]
|
||||
last_usage: dict[str, int]
|
||||
scratchpad: dict[str, JsonValue]
|
||||
|
||||
def as_mapping(self) -> Mapping[str, object]:
|
||||
"""Return the fixed public names understood by ``MyTool``."""
|
||||
values: dict[str, object] = {
|
||||
"model": self.model,
|
||||
"model_preset": self.model_preset,
|
||||
"model_presets": self.model_presets,
|
||||
"max_iterations": self.max_iterations,
|
||||
"context_window_tokens": self.context_window_tokens,
|
||||
"workspace": self.workspace,
|
||||
"provider_retry_mode": self.provider_retry_mode,
|
||||
"max_tool_result_chars": self.max_tool_result_chars,
|
||||
"current_iteration": self.current_iteration,
|
||||
"_current_iteration": self.current_iteration,
|
||||
"tool_names": self.tool_names,
|
||||
"web_config": self.web_config,
|
||||
"exec_config": self.exec_config,
|
||||
"subagents": {"_task_statuses": self.subagent_statuses},
|
||||
"_last_usage": self.last_usage,
|
||||
}
|
||||
assert values.keys() == RUNTIME_SNAPSHOT_KEYS
|
||||
return values
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RuntimeControl(Protocol):
|
||||
"""The complete runtime capability exposed to ``MyTool``."""
|
||||
|
||||
def snapshot(self) -> RuntimeSnapshot: ...
|
||||
|
||||
def set_model(self, model: str) -> LLMRuntime: ...
|
||||
|
||||
def set_model_preset(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
session_key: str | None,
|
||||
) -> LLMRuntime: ...
|
||||
|
||||
def set_max_iterations(self, value: int) -> None: ...
|
||||
|
||||
def set_context_window_tokens(self, value: int) -> LLMRuntime: ...
|
||||
|
||||
def set_provider_retry_mode(self, value: str) -> None: ...
|
||||
|
||||
def set_max_tool_result_chars(self, value: int) -> None: ...
|
||||
|
||||
def set_workspace_display(self, value: str) -> None: ...
|
||||
|
||||
def set_scratchpad(self, key: str, value: JsonValue, *, max_keys: int) -> None: ...
|
||||
|
||||
|
||||
class _RuntimeControlTarget(Protocol):
|
||||
"""Narrow structural dependency required by ``AgentRuntimeControl``."""
|
||||
|
||||
max_iterations: int
|
||||
provider_retry_mode: str
|
||||
max_tool_result_chars: int
|
||||
web_config: WebToolsConfig
|
||||
exec_config: ExecToolConfig
|
||||
subagents: SubagentManager
|
||||
|
||||
@property
|
||||
def model(self) -> str: ...
|
||||
|
||||
@property
|
||||
def model_preset(self) -> str | None: ...
|
||||
|
||||
@property
|
||||
def model_presets(self) -> Mapping[str, ModelPresetConfig]: ...
|
||||
|
||||
@property
|
||||
def context_window_tokens(self) -> int: ...
|
||||
|
||||
@property
|
||||
def workspace(self) -> Path: ...
|
||||
|
||||
@property
|
||||
def current_iteration(self) -> int: ...
|
||||
|
||||
@property
|
||||
def tool_names(self) -> list[str]: ...
|
||||
|
||||
@property
|
||||
def last_usage(self) -> Mapping[str, int]: ...
|
||||
|
||||
def set_runtime_model(self, model: str) -> LLMRuntime: ...
|
||||
|
||||
def set_runtime_context_window(self, context_window_tokens: int) -> LLMRuntime: ...
|
||||
|
||||
def set_model_preset(self, name: str | None) -> LLMRuntime: ...
|
||||
|
||||
def set_session_model_preset(self, session_key: str, name: str) -> LLMRuntime: ...
|
||||
|
||||
|
||||
class AgentRuntimeControl:
|
||||
"""Allowlisted adapter from agent-loop state to ``RuntimeControl``."""
|
||||
|
||||
def __init__(self, target: _RuntimeControlTarget) -> None:
|
||||
self.__target = target
|
||||
self.__scratchpad: dict[str, JsonValue] = {}
|
||||
self.__workspace_display: str | None = None
|
||||
|
||||
def snapshot(self) -> RuntimeSnapshot:
|
||||
target = self.__target
|
||||
return RuntimeSnapshot(
|
||||
model=target.model,
|
||||
model_preset=target.model_preset,
|
||||
model_presets=_snapshot_model_presets(target.model_presets),
|
||||
max_iterations=target.max_iterations,
|
||||
context_window_tokens=target.context_window_tokens,
|
||||
workspace=(
|
||||
self.__workspace_display
|
||||
if self.__workspace_display is not None
|
||||
else target.workspace
|
||||
),
|
||||
provider_retry_mode=target.provider_retry_mode,
|
||||
max_tool_result_chars=target.max_tool_result_chars,
|
||||
current_iteration=target.current_iteration,
|
||||
tool_names=list(target.tool_names),
|
||||
web_config=_snapshot_web_config(target.web_config),
|
||||
exec_config=_snapshot_exec_config(target.exec_config),
|
||||
subagent_statuses=_snapshot_subagent_statuses(target.subagents),
|
||||
last_usage=dict(target.last_usage),
|
||||
scratchpad=_snapshot_json_mapping(self.__scratchpad),
|
||||
)
|
||||
|
||||
def set_model(self, model: str) -> LLMRuntime:
|
||||
return self.__target.set_runtime_model(model)
|
||||
|
||||
def set_model_preset(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
session_key: str | None,
|
||||
) -> LLMRuntime:
|
||||
if session_key is not None:
|
||||
return self.__target.set_session_model_preset(session_key, name)
|
||||
return self.__target.set_model_preset(name)
|
||||
|
||||
def set_max_iterations(self, value: int) -> None:
|
||||
self.__target.max_iterations = value
|
||||
self.__target.subagents.max_iterations = value
|
||||
|
||||
def set_context_window_tokens(self, value: int) -> LLMRuntime:
|
||||
return self.__target.set_runtime_context_window(value)
|
||||
|
||||
def set_provider_retry_mode(self, value: str) -> None:
|
||||
self.__target.provider_retry_mode = value
|
||||
|
||||
def set_max_tool_result_chars(self, value: int) -> None:
|
||||
self.__target.max_tool_result_chars = value
|
||||
|
||||
def set_workspace_display(self, value: str) -> None:
|
||||
"""Preserve MyTool display compatibility without changing path enforcement."""
|
||||
self.__workspace_display = value
|
||||
|
||||
def set_scratchpad(self, key: str, value: JsonValue, *, max_keys: int) -> None:
|
||||
if key not in self.__scratchpad and len(self.__scratchpad) >= max_keys:
|
||||
raise ValueError(f"scratchpad is full (max {max_keys} keys)")
|
||||
self.__scratchpad[key] = value
|
||||
|
||||
|
||||
def _snapshot_model_presets(
|
||||
presets: Mapping[str, ModelPresetConfig],
|
||||
) -> dict[str, dict[str, object]]:
|
||||
return {
|
||||
name: {
|
||||
"label": preset.label,
|
||||
"model": preset.model,
|
||||
"provider": preset.provider,
|
||||
"max_tokens": preset.max_tokens,
|
||||
"context_window_tokens": preset.context_window_tokens,
|
||||
"temperature": preset.temperature,
|
||||
"reasoning_effort": preset.reasoning_effort,
|
||||
}
|
||||
for name, preset in presets.items()
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_web_config(config: WebToolsConfig) -> dict[str, object]:
|
||||
return {
|
||||
"enable": config.enable,
|
||||
# Proxy URLs may embed credentials. Presence is enough for diagnosis.
|
||||
"proxy": "<configured>" if config.proxy else config.proxy,
|
||||
"user_agent": config.user_agent,
|
||||
"search": {
|
||||
"provider": config.search.provider,
|
||||
"base_url": config.search.base_url,
|
||||
"max_results": config.search.max_results,
|
||||
"timeout": config.search.timeout,
|
||||
},
|
||||
"fetch": {
|
||||
"use_jina_reader": config.fetch.use_jina_reader,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_exec_config(config: ExecToolConfig) -> dict[str, object]:
|
||||
return {
|
||||
"enable": config.enable,
|
||||
"timeout": config.timeout,
|
||||
"path_prepend": config.path_prepend,
|
||||
"path_append": config.path_append,
|
||||
"sandbox": config.sandbox,
|
||||
"sandbox_ro_binds": list(config.sandbox_ro_binds),
|
||||
"sandbox_rw_binds": list(config.sandbox_rw_binds),
|
||||
"allowed_env_keys": list(config.allowed_env_keys),
|
||||
"allow_patterns": list(config.allow_patterns),
|
||||
"deny_patterns": list(config.deny_patterns),
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_subagent_statuses(
|
||||
manager: SubagentManager,
|
||||
) -> dict[str, dict[str, object]]:
|
||||
return {
|
||||
task_id: _snapshot_subagent_status(status)
|
||||
for task_id, status in manager.runtime_statuses().items()
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_subagent_status(status: SubagentStatus) -> dict[str, object]:
|
||||
return {
|
||||
"task_id": status.task_id,
|
||||
"label": status.label,
|
||||
"task_description": status.task_description,
|
||||
"started_at": status.started_at,
|
||||
"phase": status.phase,
|
||||
"iteration": status.iteration,
|
||||
"tool_events": [dict(event) for event in status.tool_events],
|
||||
"usage": dict(status.usage),
|
||||
"stop_reason": status.stop_reason,
|
||||
"error": status.error,
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_json_mapping(values: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
|
||||
return {key: _snapshot_json_value(value) for key, value in values.items()}
|
||||
|
||||
|
||||
def _snapshot_json_value(value: JsonValue) -> JsonValue:
|
||||
if isinstance(value, list):
|
||||
return [_snapshot_json_value(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _snapshot_json_value(item)
|
||||
for key, item in value.items()
|
||||
}
|
||||
return value
|
||||
@@ -0,0 +1,76 @@
|
||||
"""RuntimeState protocol: agent loop state exposed to MyTool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
from nanobot.agent.tools.web import WebToolsConfig
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
|
||||
|
||||
class RuntimeState(Protocol):
|
||||
"""Minimum contract that MyTool requires from its runtime state provider.
|
||||
|
||||
In practice, this is always satisfied by ``AgentLoop``. MyTool also
|
||||
accesses arbitrary attributes dynamically (via ``getattr`` / ``setattr``)
|
||||
for dot-path inspection and modification; those paths are validated at
|
||||
runtime rather than by this protocol.
|
||||
"""
|
||||
|
||||
@property
|
||||
def model(self) -> str: ...
|
||||
|
||||
@property
|
||||
def max_iterations(self) -> int: ...
|
||||
|
||||
@property
|
||||
def current_iteration(self) -> int: ...
|
||||
|
||||
@property
|
||||
def tool_names(self) -> list[str]: ...
|
||||
|
||||
@property
|
||||
def workspace(self) -> Path: ...
|
||||
|
||||
@property
|
||||
def provider_retry_mode(self) -> str: ...
|
||||
|
||||
@property
|
||||
def max_tool_result_chars(self) -> int: ...
|
||||
|
||||
@property
|
||||
def context_window_tokens(self) -> int: ...
|
||||
|
||||
@property
|
||||
def web_config(self) -> WebToolsConfig: ...
|
||||
|
||||
@property
|
||||
def exec_config(self) -> ExecToolConfig: ...
|
||||
|
||||
@property
|
||||
def subagents(self) -> SubagentManager: ...
|
||||
|
||||
@property
|
||||
def _runtime_vars(self) -> dict[str, Any]: ...
|
||||
|
||||
@property
|
||||
def _last_usage(self) -> dict[str, int]: ...
|
||||
|
||||
def _sync_subagent_runtime_limits(self) -> None: ...
|
||||
|
||||
def set_runtime_model(self, model: str) -> LLMRuntime: ...
|
||||
|
||||
def set_runtime_context_window(self, context_window_tokens: int) -> LLMRuntime: ...
|
||||
|
||||
def set_session_model_preset(
|
||||
self,
|
||||
session_key: str,
|
||||
name: str,
|
||||
) -> LLMRuntime: ...
|
||||
|
||||
@property
|
||||
def model_preset(self) -> str | None: ...
|
||||
+166
-197
@@ -1,7 +1,8 @@
|
||||
"""MyTool: runtime state inspection and configuration for the agent loop."""
|
||||
|
||||
# Tool.execute accepts heterogeneous schemas.
|
||||
# pyright: reportIncompatibleMethodOverride=false
|
||||
# RuntimeState intentionally exposes a narrow set of AgentLoop internals to
|
||||
# this manually registered tool. Tool.execute accepts heterogeneous schemas.
|
||||
# pyright: reportPrivateUsage=false, reportIncompatibleMethodOverride=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,13 +14,7 @@ from loguru import logger
|
||||
|
||||
from nanobot.agent.tools.base import Tool, ToolResult
|
||||
from nanobot.agent.tools.context import current_request_context, current_request_session_key
|
||||
from nanobot.agent.tools.runtime_control import (
|
||||
RUNTIME_COMMAND_KEYS,
|
||||
RUNTIME_SNAPSHOT_KEYS,
|
||||
JsonValue,
|
||||
RuntimeControl,
|
||||
RuntimeSnapshot,
|
||||
)
|
||||
from nanobot.agent.tools.runtime_state import RuntimeState
|
||||
from nanobot.config_base import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -33,28 +28,25 @@ class MyToolConfig(Base):
|
||||
allow_set: bool = False
|
||||
|
||||
|
||||
def _has_real_attr(obj: Any, key: str) -> bool:
|
||||
"""Check if obj has a real (explicitly set) attribute, not auto-generated by mock."""
|
||||
if isinstance(obj, dict):
|
||||
return key in obj
|
||||
d = getattr(obj, "__dict__", None)
|
||||
if d is not None and key in d:
|
||||
return True
|
||||
for cls in type(obj).__mro__:
|
||||
if key in cls.__dict__:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_subagent_status(value: object) -> TypeGuard[SubagentStatus]:
|
||||
from nanobot.agent.subagent import SubagentStatus
|
||||
|
||||
return isinstance(value, SubagentStatus)
|
||||
|
||||
|
||||
def _is_subagent_status_snapshot(value: object) -> TypeGuard[Mapping[str, object]]:
|
||||
if not isinstance(value, Mapping):
|
||||
return False
|
||||
return all(
|
||||
field in value
|
||||
for field in ("task_id", "label", "task_description", "started_at", "phase")
|
||||
)
|
||||
|
||||
|
||||
def _is_string_mapping(value: object) -> TypeGuard[Mapping[str, object]]:
|
||||
if not isinstance(value, Mapping):
|
||||
return False
|
||||
mapping = cast(Mapping[object, object], value)
|
||||
return all(isinstance(key, str) for key in mapping)
|
||||
|
||||
|
||||
class MyTool(Tool):
|
||||
"""Check and set the agent loop's runtime configuration."""
|
||||
|
||||
@@ -78,7 +70,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",
|
||||
@@ -87,10 +79,7 @@ class MyTool(Tool):
|
||||
|
||||
READ_ONLY = frozenset({
|
||||
"subagents", # observable but replacing it would break the system
|
||||
"tool_names",
|
||||
"current_iteration",
|
||||
"_current_iteration", # updated by runner only
|
||||
"_last_usage",
|
||||
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked
|
||||
"web_config", # inspect allowed (e.g. check enable), modify blocked
|
||||
"model_presets", # config-derived catalog; changes require config reload
|
||||
@@ -114,6 +103,13 @@ class MyTool(Tool):
|
||||
"private_key", "access_token", "refresh_token", "auth",
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def _is_sensitive_field_name(cls, name: str) -> bool:
|
||||
lowered = name.lower()
|
||||
return lowered in cls._SENSITIVE_NAMES or any(
|
||||
part in cls._SENSITIVE_NAMES for part in lowered.split("_")
|
||||
)
|
||||
|
||||
RESTRICTED: dict[str, dict[str, Any]] = {
|
||||
"max_iterations": {"type": int, "min": 1, "max": 100},
|
||||
"context_window_tokens": {"type": int, "min": 4096, "max": 1_000_000},
|
||||
@@ -127,15 +123,15 @@ class MyTool(Tool):
|
||||
"context_window_tokens",
|
||||
})
|
||||
|
||||
def __init__(self, runtime_control: RuntimeControl, modify_allowed: bool = True) -> None:
|
||||
self._runtime_control = runtime_control
|
||||
def __init__(self, runtime_state: RuntimeState, modify_allowed: bool = True) -> None:
|
||||
self._runtime_state = runtime_state
|
||||
self._modify_allowed = modify_allowed
|
||||
|
||||
def __deepcopy__(self, memo: dict[int, Any]) -> MyTool:
|
||||
cls = self.__class__
|
||||
result = cls.__new__(cls)
|
||||
memo[id(self)] = result
|
||||
result._runtime_control = self._runtime_control
|
||||
result._runtime_state = self._runtime_state
|
||||
result._modify_allowed = self._modify_allowed
|
||||
return result
|
||||
|
||||
@@ -212,12 +208,9 @@ class MyTool(Tool):
|
||||
# Path resolution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_path(
|
||||
self,
|
||||
snapshot: RuntimeSnapshot,
|
||||
path: str,
|
||||
) -> tuple[object | None, str | None]:
|
||||
def _resolve_path(self, path: str) -> tuple[Any, str | None]:
|
||||
parts = path.split(".")
|
||||
obj: Any = self._runtime_state
|
||||
for part in parts:
|
||||
if part in self._DENIED_ATTRS or part.startswith("__"):
|
||||
return None, f"'{part}' is not accessible"
|
||||
@@ -225,13 +218,17 @@ class MyTool(Tool):
|
||||
return None, f"'{part}' is not accessible"
|
||||
if part.lower() in self._SENSITIVE_NAMES:
|
||||
return None, f"'{part}' is not accessible"
|
||||
obj: object = snapshot.as_mapping()
|
||||
for part in parts:
|
||||
if not _is_string_mapping(obj):
|
||||
return None, f"'{part}' not found"
|
||||
if part not in obj:
|
||||
try:
|
||||
if isinstance(obj, Mapping):
|
||||
mapping = cast(Mapping[str, Any], obj)
|
||||
if part in mapping:
|
||||
obj = mapping[part]
|
||||
else:
|
||||
return None, f"'{part}' not found in mapping"
|
||||
obj = obj[part]
|
||||
else:
|
||||
obj = getattr(obj, part)
|
||||
except (KeyError, AttributeError) as e:
|
||||
return None, f"'{part}' not found: {e}"
|
||||
return obj, None
|
||||
|
||||
@staticmethod
|
||||
@@ -245,48 +242,20 @@ class MyTool(Tool):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _format_status(
|
||||
st: "SubagentStatus | Mapping[str, object]",
|
||||
indent: str = " ",
|
||||
) -> str:
|
||||
if isinstance(st, Mapping):
|
||||
started_at = st.get("started_at", time.monotonic())
|
||||
raw_events = st.get("tool_events", [])
|
||||
phase = st.get("phase", "unknown")
|
||||
iteration = st.get("iteration", 0)
|
||||
usage = st.get("usage", {})
|
||||
error = st.get("error")
|
||||
stop_reason = st.get("stop_reason")
|
||||
else:
|
||||
started_at = st.started_at
|
||||
raw_events = st.tool_events
|
||||
phase = st.phase
|
||||
iteration = st.iteration
|
||||
usage = st.usage
|
||||
error = st.error
|
||||
stop_reason = st.stop_reason
|
||||
elapsed = time.monotonic() - (
|
||||
float(started_at) if isinstance(started_at, (int, float)) else time.monotonic()
|
||||
)
|
||||
tool_events = cast(list[object], raw_events) if isinstance(raw_events, list) else []
|
||||
tool_summaries: list[str] = []
|
||||
for raw_event in tool_events[-5:]:
|
||||
if not isinstance(raw_event, Mapping):
|
||||
continue
|
||||
event = cast(Mapping[str, object], raw_event)
|
||||
tool_summaries.append(
|
||||
f"{event.get('name', '?')}({event.get('status', '?')})"
|
||||
)
|
||||
tool_summary = ", ".join(tool_summaries) or "none"
|
||||
def _format_status(st: "SubagentStatus", indent: str = " ") -> str:
|
||||
elapsed = time.monotonic() - st.started_at
|
||||
tool_summary = ", ".join(
|
||||
f"{e.get('name', '?')}({e.get('status', '?')})" for e in st.tool_events[-5:]
|
||||
) or "none"
|
||||
lines = [
|
||||
f"{indent}phase: {phase}, iteration: {iteration}, elapsed: {elapsed:.1f}s",
|
||||
f"{indent}phase: {st.phase}, iteration: {st.iteration}, elapsed: {elapsed:.1f}s",
|
||||
f"{indent}tools: {tool_summary}",
|
||||
f"{indent}usage: {usage or 'n/a'}",
|
||||
f"{indent}usage: {st.usage or 'n/a'}",
|
||||
]
|
||||
if error:
|
||||
lines.append(f"{indent}error: {error}")
|
||||
if stop_reason:
|
||||
lines.append(f"{indent}stop_reason: {stop_reason}")
|
||||
if st.error:
|
||||
lines.append(f"{indent}error: {st.error}")
|
||||
if st.stop_reason:
|
||||
lines.append(f"{indent}stop_reason: {st.stop_reason}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
@@ -295,38 +264,29 @@ class MyTool(Tool):
|
||||
header = f"Subagent [{val.task_id}] '{val.label}'"
|
||||
detail = MyTool._format_status(val, " ")
|
||||
return f"{header}\n task: {val.task_description}\n{detail}"
|
||||
if _is_subagent_status_snapshot(val):
|
||||
header = f"Subagent [{val['task_id']}] '{val['label']}'"
|
||||
detail = MyTool._format_status(val, " ")
|
||||
return f"{header}\n task: {val['task_description']}\n{detail}"
|
||||
# SubagentManager: delegate to its _task_statuses dict
|
||||
task_statuses = getattr(val, "_task_statuses", None)
|
||||
if isinstance(task_statuses, dict):
|
||||
return MyTool._format_value(task_statuses, key)
|
||||
if isinstance(val, Mapping):
|
||||
mapping = cast(Mapping[object, object], val)
|
||||
else:
|
||||
mapping = None
|
||||
if mapping and set(mapping) == {"_task_statuses"}:
|
||||
task_statuses = mapping["_task_statuses"]
|
||||
if isinstance(task_statuses, Mapping):
|
||||
return MyTool._format_value(task_statuses, key)
|
||||
if (
|
||||
mapping
|
||||
and (
|
||||
_is_subagent_status(next(iter(mapping.values())))
|
||||
or _is_subagent_status_snapshot(next(iter(mapping.values())))
|
||||
)
|
||||
and _is_subagent_status(next(iter(mapping.values())))
|
||||
):
|
||||
status_mapping: Mapping[object, SubagentStatus] = cast(Any, mapping)
|
||||
prefix = f"{key}: " if key else ""
|
||||
lines = [f"{prefix}{len(mapping)} subagent(s):"]
|
||||
for tid, st in mapping.items():
|
||||
if _is_subagent_status(st):
|
||||
lines = [f"{prefix}{len(status_mapping)} subagent(s):"]
|
||||
for tid, st in status_mapping.items():
|
||||
detail = MyTool._format_status(st, " ")
|
||||
label = st.label
|
||||
elif _is_subagent_status_snapshot(st):
|
||||
detail = MyTool._format_status(st, " ")
|
||||
label = st.get("label", "?")
|
||||
else:
|
||||
continue
|
||||
lines.append(f" [{tid}] '{label}'\n{detail}")
|
||||
lines.append(f" [{tid}] '{st.label}'\n{detail}")
|
||||
return "\n".join(lines)
|
||||
dynamic_value = cast(Any, val)
|
||||
if hasattr(dynamic_value, "tool_names"):
|
||||
tool_names: Any = getattr(dynamic_value, "tool_names")
|
||||
return f"tools: {len(tool_names)} registered — {tool_names}"
|
||||
# Scalar types — repr is fine
|
||||
if isinstance(val, (str, int, float, bool, type(None))):
|
||||
r = repr(val)
|
||||
@@ -351,6 +311,32 @@ class MyTool(Tool):
|
||||
return f"{key}: [{len(sequence)} items]" if key else f"[{len(sequence)} items]"
|
||||
r = repr(sequence)
|
||||
return f"{key}: {r}" if key else r
|
||||
# Complex object — small Pydantic models: show values; others: show field names for navigation
|
||||
value_type = type(cast(object, val))
|
||||
cls_name = value_type.__name__
|
||||
model_fields = cast(object, getattr(value_type, "model_fields", None))
|
||||
if isinstance(model_fields, Mapping) and model_fields:
|
||||
fields = list(cast(Mapping[str, object], model_fields).keys())
|
||||
if len(fields) <= 8:
|
||||
# Small config objects: show field=value pairs
|
||||
pairs: list[str] = []
|
||||
for f in fields:
|
||||
fv = getattr(val, f, "?")
|
||||
if MyTool._is_sensitive_field_name(f):
|
||||
continue
|
||||
if isinstance(fv, (str, int, float, bool, type(None))):
|
||||
pairs.append(f"{f}={fv!r}")
|
||||
else:
|
||||
pairs.append(f"{f}=<{type(fv).__name__}>")
|
||||
preview = ", ".join(pairs)
|
||||
return f"{key}: {preview}" if key else preview
|
||||
else:
|
||||
attributes = cast(dict[str, Any], getattr(val, "__dict__", {}))
|
||||
fields = [name for name in attributes if not name.startswith("__")]
|
||||
if fields:
|
||||
preview = ", ".join(str(f) for f in fields[:20])
|
||||
suffix = ", ..." if len(fields) > 20 else ""
|
||||
return f"{key}: <{cls_name}> [{preview}{suffix}]" if key else f"<{cls_name}> [{preview}{suffix}]"
|
||||
r = repr(val)
|
||||
return f"{key}: {r}" if key else r
|
||||
|
||||
@@ -380,12 +366,7 @@ class MyTool(Tool):
|
||||
runtime = request_ctx.runtime if request_ctx is not None else None
|
||||
if runtime is None or key not in self._MODEL_RUNTIME_FIELDS:
|
||||
return False, None
|
||||
values: dict[str, object] = {
|
||||
"model": runtime.model,
|
||||
"model_preset": runtime.model_preset,
|
||||
"context_window_tokens": runtime.context_window_tokens,
|
||||
}
|
||||
return True, values[key]
|
||||
return True, getattr(runtime, key)
|
||||
|
||||
def _inspect(self, key: str | None) -> str:
|
||||
if not key:
|
||||
@@ -394,64 +375,62 @@ class MyTool(Tool):
|
||||
request_ctx = current_request_context()
|
||||
if request_ctx is None:
|
||||
return ToolResult.error("Error: current request context is unavailable")
|
||||
request_values: dict[str, str | None] = {
|
||||
"channel": request_ctx.channel,
|
||||
"chat_id": request_ctx.chat_id,
|
||||
"sender_id": request_ctx.sender_id,
|
||||
}
|
||||
if key == "request":
|
||||
return self._format_value(request_values, key)
|
||||
return self._format_value(
|
||||
{field: getattr(request_ctx, field) for field in self._REQUEST_FIELDS},
|
||||
key,
|
||||
)
|
||||
field = key.removeprefix("request.")
|
||||
if field not in self._REQUEST_FIELDS:
|
||||
return ToolResult.error(f"Error: '{key}' not found")
|
||||
return self._format_value(request_values[field], key)
|
||||
return self._format_value(getattr(request_ctx, field), key)
|
||||
if "." not in key:
|
||||
found, value = self._current_runtime_value(key)
|
||||
if found:
|
||||
return self._format_value(value, key)
|
||||
snapshot = self._runtime_control.snapshot()
|
||||
top = key.split(".")[0]
|
||||
if top in self._DENIED_ATTRS or top.startswith("__"):
|
||||
return ToolResult.error(f"Error: '{top}' is not accessible")
|
||||
obj, err = self._resolve_path(snapshot, key)
|
||||
obj, err = self._resolve_path(key)
|
||||
if err:
|
||||
# "scratchpad" alias for _runtime_vars
|
||||
if key == "scratchpad":
|
||||
return (
|
||||
self._format_value(snapshot.scratchpad, "scratchpad")
|
||||
if snapshot.scratchpad
|
||||
else "scratchpad is empty"
|
||||
)
|
||||
if "." not in key and key in snapshot.scratchpad:
|
||||
return self._format_value(snapshot.scratchpad[key], key)
|
||||
rv = self._runtime_state._runtime_vars
|
||||
return self._format_value(rv, "scratchpad") if rv else "scratchpad is empty"
|
||||
# Fallback: check _runtime_vars for simple keys stored by modify
|
||||
if "." not in key and key in self._runtime_state._runtime_vars:
|
||||
return self._format_value(self._runtime_state._runtime_vars[key], key)
|
||||
return ToolResult.error(f"Error: {err}")
|
||||
# Guard against mock auto-generated attributes
|
||||
if "." not in key and not _has_real_attr(self._runtime_state, key):
|
||||
if key in self._runtime_state._runtime_vars:
|
||||
return self._format_value(self._runtime_state._runtime_vars[key], key)
|
||||
return ToolResult.error(f"Error: '{key}' not found")
|
||||
return self._format_value(obj, key)
|
||||
|
||||
def _inspect_all(self) -> str:
|
||||
snapshot = self._runtime_control.snapshot()
|
||||
values = snapshot.as_mapping()
|
||||
state = self._runtime_state
|
||||
parts: list[str] = []
|
||||
# RESTRICTED keys
|
||||
for k in self.RESTRICTED:
|
||||
found, value = self._current_runtime_value(k)
|
||||
parts.append(self._format_value(value if found else values[k], k))
|
||||
parts.append(self._format_value(value if found else getattr(state, k, None), k))
|
||||
found, value = self._current_runtime_value("model_preset")
|
||||
parts.append(self._format_value(
|
||||
value if found else snapshot.model_preset,
|
||||
value if found else state.model_preset,
|
||||
"model_preset",
|
||||
))
|
||||
for k in (
|
||||
"workspace",
|
||||
"provider_retry_mode",
|
||||
"max_tool_result_chars",
|
||||
"_current_iteration",
|
||||
"web_config",
|
||||
"exec_config",
|
||||
"subagents",
|
||||
):
|
||||
parts.append(self._format_value(values[k], k))
|
||||
if snapshot.last_usage:
|
||||
parts.append(self._format_value(snapshot.last_usage, "_last_usage"))
|
||||
if snapshot.scratchpad:
|
||||
parts.append(self._format_value(snapshot.scratchpad, "scratchpad"))
|
||||
# Other useful top-level keys shown in description
|
||||
for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "workspace_sandbox", "subagents"):
|
||||
if _has_real_attr(state, k):
|
||||
parts.append(self._format_value(getattr(state, k, None), k))
|
||||
# Token usage
|
||||
usage = state._last_usage
|
||||
if usage:
|
||||
parts.append(self._format_value(usage, "_last_usage"))
|
||||
rv = state._runtime_vars
|
||||
if rv:
|
||||
parts.append(self._format_value(rv, "scratchpad"))
|
||||
return "\n".join(parts)
|
||||
|
||||
# -- modify --
|
||||
@@ -475,49 +454,48 @@ class MyTool(Tool):
|
||||
if leaf.lower() in self._SENSITIVE_NAMES:
|
||||
self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'")
|
||||
return ToolResult.error(f"Error: '{leaf}' is not accessible")
|
||||
snapshot = self._runtime_control.snapshot()
|
||||
_parent, err = self._resolve_path(snapshot, parent_path)
|
||||
parent, err = self._resolve_path(parent_path)
|
||||
if err:
|
||||
return ToolResult.error(f"Error: {err}")
|
||||
self._audit("modify", f"READ_ONLY {key}")
|
||||
return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
|
||||
if isinstance(parent, dict):
|
||||
parent[leaf] = value
|
||||
else:
|
||||
setattr(parent, leaf, value)
|
||||
self._audit("modify", f"{key} = {value!r}")
|
||||
return f"Set {key} = {value!r}"
|
||||
if key == "model_preset":
|
||||
return self._modify_model_preset(value)
|
||||
if key in self.RESTRICTED:
|
||||
return self._modify_restricted(key, value)
|
||||
if key in RUNTIME_COMMAND_KEYS:
|
||||
return self._modify_runtime_setting(key, value)
|
||||
if key in RUNTIME_SNAPSHOT_KEYS:
|
||||
self._audit("modify", f"READ_ONLY {key}")
|
||||
return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
|
||||
return self._modify_scratchpad(key, value)
|
||||
return self._modify_free(key, value)
|
||||
|
||||
def _modify_model_preset(self, value: Any) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return ToolResult.error("Error: 'model_preset' must be a non-empty string")
|
||||
name = value.strip()
|
||||
session_key = current_request_session_key()
|
||||
old = self._runtime_control.snapshot().model_preset
|
||||
if session_key:
|
||||
try:
|
||||
runtime = self._runtime_control.set_model_preset(
|
||||
runtime = self._runtime_state.set_session_model_preset(
|
||||
session_key,
|
||||
name,
|
||||
session_key=session_key,
|
||||
)
|
||||
except (KeyError, ValueError) as exc:
|
||||
message = str(exc.args[0]) if exc.args else str(exc)
|
||||
punctuation = "" if message.endswith((".", "!", "?")) else "."
|
||||
return ToolResult.error(f"Error: {message}{punctuation}")
|
||||
if session_key:
|
||||
self._audit("modify", f"model_preset = {name!r}")
|
||||
return (
|
||||
f"Set model_preset = {name!r} for the next turn; "
|
||||
f"model will be {runtime.model!r}; "
|
||||
f"context_window_tokens will be {runtime.context_window_tokens!r}"
|
||||
)
|
||||
self._audit("modify", f"model_preset: {old!r} -> {name!r}")
|
||||
result = self._modify_free("model_preset", name)
|
||||
if isinstance(result, ToolResult) and result.is_error:
|
||||
return result if result.endswith((".", "!", "?")) else ToolResult.error(f"{result}.")
|
||||
return (
|
||||
f"Set model_preset = {name!r} (was {old!r}); model is now {runtime.model!r}; "
|
||||
f"context_window_tokens is now {runtime.context_window_tokens!r}"
|
||||
f"{result}; model is now {self._runtime_state.model!r}; "
|
||||
f"context_window_tokens is now {self._runtime_state.context_window_tokens!r}"
|
||||
)
|
||||
|
||||
def _modify_restricted(self, key: str, value: Any) -> str:
|
||||
@@ -530,7 +508,7 @@ class MyTool(Tool):
|
||||
value = expected(value)
|
||||
except (ValueError, TypeError):
|
||||
return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}")
|
||||
old = self._runtime_control.snapshot().as_mapping()[key]
|
||||
old = getattr(self._runtime_state, key)
|
||||
if "min" in spec and value < spec["min"]:
|
||||
return ToolResult.error(f"Error: '{key}' must be >= {spec['min']}")
|
||||
if "max" in spec and value > spec["max"]:
|
||||
@@ -543,46 +521,41 @@ class MyTool(Tool):
|
||||
"during an active session; use a configured model_preset"
|
||||
)
|
||||
if key == "model":
|
||||
self._runtime_control.set_model(cast(str, value))
|
||||
self._runtime_state.set_runtime_model(cast(str, value))
|
||||
elif key == "context_window_tokens":
|
||||
self._runtime_control.set_context_window_tokens(cast(int, value))
|
||||
self._runtime_state.set_runtime_context_window(cast(int, value))
|
||||
else:
|
||||
self._runtime_control.set_max_iterations(cast(int, value))
|
||||
setattr(self._runtime_state, key, value)
|
||||
if key == "max_iterations" and hasattr(
|
||||
self._runtime_state,
|
||||
"_sync_subagent_runtime_limits",
|
||||
):
|
||||
self._runtime_state._sync_subagent_runtime_limits()
|
||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||
return f"Set {key} = {value!r} (was {old!r})"
|
||||
|
||||
def _modify_runtime_setting(self, key: str, value: Any) -> str:
|
||||
old = self._runtime_control.snapshot().as_mapping()[key]
|
||||
if key == "workspace":
|
||||
if not isinstance(value, str):
|
||||
return ToolResult.error(
|
||||
f"Error: 'workspace' expects str, got {type(value).__name__}"
|
||||
)
|
||||
self._runtime_control.set_workspace_display(value)
|
||||
self._audit("modify", f"workspace: {old!r} -> {value!r}")
|
||||
return f"Set workspace = {value!r} (was {old!r})"
|
||||
old_t = type(old)
|
||||
def _modify_free(self, key: str, value: Any) -> str:
|
||||
if _has_real_attr(self._runtime_state, key):
|
||||
old = getattr(self._runtime_state, key)
|
||||
if isinstance(old, (str, int, float, bool)):
|
||||
old_t: type[Any] = type(old)
|
||||
new_t = cast(type[Any], type(value))
|
||||
if old_t is float and new_t is int:
|
||||
pass
|
||||
pass # int → float coercion allowed
|
||||
elif old_t is not new_t:
|
||||
self._audit(
|
||||
"modify",
|
||||
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
|
||||
)
|
||||
return ToolResult.error(
|
||||
f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}"
|
||||
)
|
||||
if key == "provider_retry_mode":
|
||||
self._runtime_control.set_provider_retry_mode(cast(str, value))
|
||||
elif key == "max_tool_result_chars":
|
||||
self._runtime_control.set_max_tool_result_chars(cast(int, value))
|
||||
else:
|
||||
raise AssertionError(f"Unhandled runtime command: {key}")
|
||||
return ToolResult.error(f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}")
|
||||
try:
|
||||
setattr(self._runtime_state, key, value)
|
||||
except (ValueError, KeyError) as e:
|
||||
message = str(e.args[0] if isinstance(e, KeyError) and e.args else e).strip('"')
|
||||
self._audit("modify", f"REJECTED {key}: {message}")
|
||||
return ToolResult.error(f"Error: {message}")
|
||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||
return f"Set {key} = {value!r} (was {old!r})"
|
||||
|
||||
def _modify_scratchpad(self, key: str, value: Any) -> str:
|
||||
if callable(value):
|
||||
self._audit("modify", f"REJECTED callable {key}")
|
||||
return ToolResult.error("Error: cannot store callable values")
|
||||
@@ -590,16 +563,12 @@ class MyTool(Tool):
|
||||
if err:
|
||||
self._audit("modify", f"REJECTED {key}: {err}")
|
||||
return ToolResult.error(f"Error: {err}")
|
||||
try:
|
||||
self._runtime_control.set_scratchpad(
|
||||
key,
|
||||
cast(JsonValue, value),
|
||||
max_keys=self._MAX_RUNTIME_KEYS,
|
||||
)
|
||||
except ValueError as exc:
|
||||
if key not in self._runtime_state._runtime_vars and len(self._runtime_state._runtime_vars) >= self._MAX_RUNTIME_KEYS:
|
||||
self._audit("modify", f"REJECTED {key}: max keys ({self._MAX_RUNTIME_KEYS}) reached")
|
||||
return ToolResult.error(f"Error: {exc}. Remove unused keys first.")
|
||||
self._audit("modify", f"scratchpad.{key} = {value!r}")
|
||||
return ToolResult.error(f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first.")
|
||||
old = self._runtime_state._runtime_vars.get(key)
|
||||
self._runtime_state._runtime_vars[key] = value
|
||||
self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}")
|
||||
return f"Set scratchpad.{key} = {value!r}"
|
||||
|
||||
@classmethod
|
||||
|
||||
+15
-221
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
@@ -13,8 +12,7 @@ import sys
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PureWindowsPath
|
||||
from typing import Any, Protocol, cast
|
||||
from urllib.parse import unquote
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
@@ -44,17 +42,6 @@ from nanobot.security.workspace_access import current_scope_allows_loopback, cur
|
||||
from nanobot.security.workspace_policy import is_path_within
|
||||
|
||||
_IS_WINDOWS = sys.platform == "win32"
|
||||
_PROCESS_TREE_OWNER_ATTR = "_nanobot_process_tree_owner"
|
||||
|
||||
|
||||
class _ProcessTreeOwner(Protocol):
|
||||
creation_flags: int
|
||||
|
||||
def assign_and_resume(self, pid: int) -> None: ...
|
||||
|
||||
def release(self) -> None: ...
|
||||
|
||||
def terminate(self) -> None: ...
|
||||
|
||||
|
||||
def _reap_pid(pid: int) -> None:
|
||||
@@ -339,7 +326,6 @@ class ExecTool(Tool):
|
||||
prepared.env,
|
||||
prepared.shell_program,
|
||||
prepared.login,
|
||||
process_tree=True,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -348,10 +334,10 @@ class ExecTool(Tool):
|
||||
timeout=prepared.timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
await self._kill_process_tree(process)
|
||||
await self._kill_process(process)
|
||||
return ToolResult.error(f"Error: Command timed out after {prepared.timeout} seconds")
|
||||
except asyncio.CancelledError:
|
||||
await self._kill_process_tree(process)
|
||||
await self._kill_process(process)
|
||||
raise
|
||||
|
||||
# Safety-net reap: asyncio *should* have reaped the child via
|
||||
@@ -382,14 +368,13 @@ class ExecTool(Tool):
|
||||
+ result[-half:]
|
||||
)
|
||||
|
||||
self._release_process_tree(process)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# Kill and reap the child if it was spawned but an unexpected
|
||||
# error prevented communicate() from completing.
|
||||
if process is not None:
|
||||
await self._kill_process_tree(process)
|
||||
await self._kill_process(process)
|
||||
return ToolResult.error(f"Error executing command: {str(e)}")
|
||||
|
||||
async def _execute_session(
|
||||
@@ -552,31 +537,22 @@ class ExecTool(Tool):
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Launch *command* in a platform-appropriate shell."""
|
||||
if _IS_WINDOWS:
|
||||
windows_job = None
|
||||
process = None
|
||||
creation_flags = 0
|
||||
if process_tree and sys.platform == "win32":
|
||||
windows_job = ExecTool._create_windows_job()
|
||||
creation_flags = windows_job.creation_flags
|
||||
# Default to PowerShell so single-line and multi-line commands
|
||||
# share the same shell semantics. cmd.exe is reachable via the
|
||||
# explicit shell="cmd" parameter (see _resolve_shell).
|
||||
default_program = shutil.which("pwsh") or shutil.which("powershell") or "powershell"
|
||||
program = shell_program or default_program
|
||||
program_name = PureWindowsPath(program).name.lower()
|
||||
try:
|
||||
if program_name in ("cmd", "cmd.exe"):
|
||||
cmd_env = {**env, "COMSPEC": program}
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
return await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdin=stdin,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
env=cmd_env,
|
||||
creationflags=creation_flags,
|
||||
)
|
||||
else:
|
||||
command = ExecTool._normalize_powershell_command(command)
|
||||
command = (
|
||||
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)\n"
|
||||
@@ -585,25 +561,14 @@ class ExecTool(Tool):
|
||||
f"{command}\n"
|
||||
"if ($LASTEXITCODE -ne $null) { exit $LASTEXITCODE }"
|
||||
)
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
return await asyncio.create_subprocess_exec(
|
||||
program, "-NoProfile", "-NonInteractive", "-Command", command,
|
||||
stdin=stdin,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
creationflags=creation_flags,
|
||||
)
|
||||
if windows_job is not None:
|
||||
windows_job.assign_and_resume(process.pid)
|
||||
setattr(process, _PROCESS_TREE_OWNER_ATTR, windows_job)
|
||||
return process
|
||||
except BaseException:
|
||||
if windows_job is not None:
|
||||
windows_job.terminate()
|
||||
if process is not None:
|
||||
await ExecTool._kill_process(process)
|
||||
raise
|
||||
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
|
||||
args: list[str] = [shell_program]
|
||||
shell_name = Path(shell_program).name.lower()
|
||||
@@ -722,12 +687,11 @@ class ExecTool(Tool):
|
||||
@staticmethod
|
||||
async def _kill_process_tree(process: asyncio.subprocess.Process) -> None:
|
||||
"""Kill a session process and descendants, then reap the root process."""
|
||||
owner = ExecTool._process_tree_owner(process)
|
||||
if process.returncode is not None:
|
||||
_reap_pid(process.pid)
|
||||
return
|
||||
try:
|
||||
if owner is not None:
|
||||
owner.terminate()
|
||||
elif _IS_WINDOWS:
|
||||
if process.returncode is None:
|
||||
if _IS_WINDOWS:
|
||||
with suppress(OSError, asyncio.TimeoutError):
|
||||
await asyncio.wait_for(
|
||||
asyncio.to_thread(
|
||||
@@ -751,36 +715,8 @@ class ExecTool(Tool):
|
||||
with suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(process.wait(), timeout=5.0)
|
||||
finally:
|
||||
if owner is not None:
|
||||
ExecTool._drop_process_tree_owner(process)
|
||||
_reap_pid(process.pid)
|
||||
|
||||
@staticmethod
|
||||
def _process_tree_owner(
|
||||
process: asyncio.subprocess.Process,
|
||||
) -> _ProcessTreeOwner | None:
|
||||
# _spawn is the only writer for this private ownership marker.
|
||||
return cast(_ProcessTreeOwner | None, vars(process).get(_PROCESS_TREE_OWNER_ATTR))
|
||||
|
||||
@staticmethod
|
||||
def _create_windows_job() -> _ProcessTreeOwner:
|
||||
from nanobot.agent.tools._windows_job import WindowsJob
|
||||
|
||||
return WindowsJob.create()
|
||||
|
||||
@staticmethod
|
||||
def _drop_process_tree_owner(process: asyncio.subprocess.Process) -> None:
|
||||
with suppress(AttributeError):
|
||||
delattr(process, _PROCESS_TREE_OWNER_ATTR)
|
||||
|
||||
@staticmethod
|
||||
def _release_process_tree(process: asyncio.subprocess.Process) -> None:
|
||||
owner = ExecTool._process_tree_owner(process)
|
||||
if owner is None:
|
||||
return
|
||||
owner.release()
|
||||
ExecTool._drop_process_tree_owner(process)
|
||||
|
||||
def _build_env(self) -> dict[str, str]:
|
||||
"""Build a minimal environment for subprocess execution.
|
||||
|
||||
@@ -890,27 +826,12 @@ class ExecTool(Tool):
|
||||
for raw in self._extract_absolute_paths(cmd):
|
||||
try:
|
||||
expanded = os.path.expandvars(raw.strip())
|
||||
# Python's expanduser() intentionally does not implement
|
||||
# shell directory-stack forms. ``~+`` is the active cwd,
|
||||
# while ``~-`` and indexed forms can resolve outside it;
|
||||
# normalize the former and fail closed on the latter.
|
||||
if expanded == "~+":
|
||||
p = cwd_path
|
||||
elif expanded.startswith("~+/"):
|
||||
p = (cwd_path / expanded[3:]).resolve()
|
||||
elif re.match(r"^~(?:-|[+-]\d+)(?:/|$)", expanded):
|
||||
return ToolResult.error(
|
||||
"Error: Command blocked by safety guard "
|
||||
"(path outside working dir)"
|
||||
+ _WORKSPACE_BOUNDARY_NOTE
|
||||
)
|
||||
else:
|
||||
p = Path(expanded).expanduser().resolve()
|
||||
# Match against the un-resolved path first. On Linux,
|
||||
# /dev/stderr is a symlink to /proc/self/fd/2 and
|
||||
# ``Path.resolve()`` would mask the device-file intent.
|
||||
if self._is_benign_device_path(expanded):
|
||||
continue
|
||||
p = Path(expanded).expanduser().resolve()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
@@ -993,9 +914,7 @@ class ExecTool(Tool):
|
||||
):
|
||||
current.append(ch)
|
||||
operator_len = 1
|
||||
# A newline separates commands just like ";" does, so a payload
|
||||
# smuggled onto its own line must be checked on its own too.
|
||||
elif ch in {";", "|", "\n", "\r"}:
|
||||
elif ch in {";", "|"}:
|
||||
operator_len = 1
|
||||
|
||||
if operator_len:
|
||||
@@ -1029,134 +948,9 @@ class ExecTool(Tool):
|
||||
r"(?<![A-Za-z])(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
|
||||
command
|
||||
)
|
||||
try:
|
||||
lexer = shlex.shlex(command, posix=True, punctuation_chars="();<>|&")
|
||||
lexer.whitespace_split = True
|
||||
lexer.commenters = ""
|
||||
tokens = list(lexer)
|
||||
except ValueError:
|
||||
# Keep malformed quoting fail-closed. The shell will normally reject
|
||||
# it too, but a conservative raw scan must not turn it into a bypass.
|
||||
tokens = [command]
|
||||
|
||||
paths = [*win_paths]
|
||||
seen = set(win_paths)
|
||||
for index, token in enumerate(tokens):
|
||||
for path in ExecTool._extract_posix_paths_from_token(token):
|
||||
if path not in seen:
|
||||
paths.append(path)
|
||||
seen.add(path)
|
||||
if index > 0 and tokens[index - 1] in {"-c", "-lc", "--command"}:
|
||||
for path in ExecTool._extract_absolute_paths(token):
|
||||
if path not in seen:
|
||||
paths.append(path)
|
||||
seen.add(path)
|
||||
return paths
|
||||
|
||||
@staticmethod
|
||||
def _extract_posix_paths_from_token(token: str) -> list[str]:
|
||||
"""Extract local POSIX/home paths from one shell-decoded token.
|
||||
|
||||
``shlex`` separates real grouping/redirection operators while preserving
|
||||
parentheses and spaces that were quoted or escaped as part of a path.
|
||||
Embedded scripts (for example ``sh -c \"cat /tmp/x\"``) still need a
|
||||
small boundary scan. Colons are not general boundaries: treating them
|
||||
as such misclassifies URLs, ``host:/remote`` and ``C:/Windows``. They
|
||||
are considered only inside a syntactically valid assignment, where
|
||||
shells expand each colon-delimited tilde component.
|
||||
"""
|
||||
paths: list[str] = []
|
||||
for match in re.finditer(
|
||||
r"file://(?:[^/\s\"']+)?(/[^\s\"'<>|;&]*)",
|
||||
token,
|
||||
flags=re.IGNORECASE,
|
||||
):
|
||||
uri_prefix = token[: match.start()]
|
||||
raw_path = match.group(1)
|
||||
if uri_prefix.count("(") > uri_prefix.count(")"):
|
||||
raw_path = raw_path.split(")", 1)[0]
|
||||
if uri_prefix.count("{") > uri_prefix.count("}"):
|
||||
raw_path = raw_path.split(",", 1)[0].split("}", 1)[0]
|
||||
raw_path = raw_path.split("?", 1)[0].split("#", 1)[0]
|
||||
if raw_path:
|
||||
paths.append(unquote(raw_path))
|
||||
boundary_chars = frozenset(" \t\r\n=({,<>|;&\"'")
|
||||
i = 0
|
||||
while i < len(token):
|
||||
is_posix = token[i] == "/"
|
||||
home_match = re.match(
|
||||
r"~(?:[+-](?:\d+)?|[A-Za-z0-9_.@-]+)?(?=/|:|$)",
|
||||
token[i:],
|
||||
)
|
||||
is_home = home_match is not None
|
||||
if not is_posix and not is_home:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
prefix = token[:i]
|
||||
parameter_default = (
|
||||
i >= 2 and token[i - 2] == ":" and token[i - 1] in "-+?="
|
||||
)
|
||||
word_start = max(
|
||||
(prefix.rfind(char) for char in " \t\r\n<>|;&"),
|
||||
default=-1,
|
||||
) + 1
|
||||
word_prefix = prefix[word_start:]
|
||||
assignment_component = bool(
|
||||
re.fullmatch(
|
||||
r"(?:[A-Za-z_][A-Za-z0-9_]*|--?[A-Za-z0-9_.-]+)="
|
||||
r"(?:[^:=\s]*:)*",
|
||||
word_prefix,
|
||||
)
|
||||
)
|
||||
at_boundary = i == 0 or token[i - 1] in boundary_chars
|
||||
if is_home:
|
||||
# A shell word beginning with ``~`` is a separate shlex token.
|
||||
# Mid-token expansion is valid only after ``=`` or a colon in
|
||||
# an assignment. This avoids PromQL/Loki ``=~`` and ``|~``
|
||||
# match operators while covering PATH-like values.
|
||||
at_boundary = i == 0 or assignment_component
|
||||
if not at_boundary and not parameter_default:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if re.search(r"[A-Za-z][A-Za-z0-9+.-]*://", word_prefix) or re.match(
|
||||
r"(?:[^/:=\s]+@)?[^/:=\s]+:$",
|
||||
word_prefix,
|
||||
):
|
||||
# HTTP-style URL path/query fragments and scp-style remote paths
|
||||
# are not local filesystem references. ``file://`` paths were
|
||||
# decoded above. Windows drive paths are already captured by the
|
||||
# platform-specific expression above.
|
||||
i += 1
|
||||
continue
|
||||
|
||||
assignment_value = assignment_component
|
||||
if i == 0 or assignment_value:
|
||||
end = len(token)
|
||||
if assignment_value:
|
||||
separator = token.find(":", i)
|
||||
if separator >= 0:
|
||||
end = separator
|
||||
elif token[i - 1] in {"'", '"'}:
|
||||
quote = token[i - 1]
|
||||
closing = token.find(quote, i)
|
||||
end = len(token) if closing < 0 else closing
|
||||
else:
|
||||
end_chars = set(" \t\r\n\"'<>|;&")
|
||||
if prefix.count("(") > prefix.count(")"):
|
||||
end_chars.add(")")
|
||||
if prefix.count("{") > prefix.count("}"):
|
||||
end_chars.update({",", "}"})
|
||||
end = i
|
||||
while end < len(token) and token[end] not in end_chars:
|
||||
end += 1
|
||||
|
||||
candidate = token[i:end]
|
||||
if candidate:
|
||||
paths.append(candidate)
|
||||
i = max(end, i + 1)
|
||||
return paths
|
||||
posix_paths = re.findall(r"(?:^|[\s|>='\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
|
||||
home_paths = re.findall(r"(?:^|[\s>='\"])(~[/+][^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~/ or ~+
|
||||
return win_paths + posix_paths + home_paths
|
||||
|
||||
@staticmethod
|
||||
def _normalize_bind_roots(paths: list[str] | None) -> list[Path]:
|
||||
|
||||
+19
-122
@@ -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:
|
||||
@@ -520,9 +453,9 @@ class WebSearchTool(Tool):
|
||||
|
||||
async def _search_olostep(self, query: str, n: int) -> str:
|
||||
try:
|
||||
from olostep import ( # pyright: ignore[reportMissingImports, reportMissingTypeStubs]
|
||||
from olostep import ( # pyright: ignore[reportMissingImports]
|
||||
AsyncOlostep, # pyright: ignore[reportUnknownVariableType]
|
||||
Olostep_BaseError, # pyright: ignore[reportAttributeAccessIssue, reportUnknownVariableType]
|
||||
Olostep_BaseError, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
except ImportError:
|
||||
return ToolResult.error(
|
||||
@@ -1110,26 +1043,20 @@ class WebFetchTool(Tool):
|
||||
if not is_valid:
|
||||
return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False)
|
||||
|
||||
# Detect and fetch images directly to avoid Jina's textual image captioning.
|
||||
# This local preflight also proves that no credential-bearing URL occurs
|
||||
# in the redirect chain before the original URL may be sent to Jina.
|
||||
jina_remote_safe = False
|
||||
# Detect and fetch images directly to avoid Jina's textual image captioning
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
**_fetch_client_kwargs(self.proxy, 15.0),
|
||||
) as client:
|
||||
r, stream, redirect_error, chain_carries_credentials = (
|
||||
await _stream_with_safe_redirects(
|
||||
r, stream, redirect_error = await _stream_with_safe_redirects(
|
||||
client,
|
||||
url,
|
||||
headers={"User-Agent": self.user_agent},
|
||||
)
|
||||
)
|
||||
if redirect_error:
|
||||
return json.dumps({"error": redirect_error, "url": url}, ensure_ascii=False)
|
||||
if r is None:
|
||||
return json.dumps({"error": "Fetch failed", "url": url}, ensure_ascii=False)
|
||||
jina_remote_safe = not chain_carries_credentials
|
||||
|
||||
try:
|
||||
ctype = r.headers.get("content-type", "")
|
||||
@@ -1144,14 +1071,10 @@ class WebFetchTool(Tool):
|
||||
unsafe_error = _unsafe_url_request_error(e)
|
||||
if unsafe_error is not None:
|
||||
return json.dumps({"error": f"URL validation failed: {unsafe_error}", "url": url}, ensure_ascii=False)
|
||||
logger.debug(
|
||||
"Pre-fetch image detection failed for {} ({})",
|
||||
_redact_url_for_log(url),
|
||||
type(e).__name__,
|
||||
)
|
||||
logger.debug("Pre-fetch image detection failed for {}: {}", url, e)
|
||||
|
||||
result = None
|
||||
if self.config.use_jina_reader and jina_remote_safe:
|
||||
if self.config.use_jina_reader:
|
||||
result = await self._fetch_jina(url, max_chars)
|
||||
if result is None:
|
||||
result = await self._fetch_readability(url, extract_mode, max_chars)
|
||||
@@ -1159,23 +1082,13 @@ class WebFetchTool(Tool):
|
||||
|
||||
async def _fetch_jina(self, url: str, max_chars: int) -> str | None:
|
||||
"""Try fetching via Jina Reader API. Returns None on failure."""
|
||||
if _url_carries_credentials(url):
|
||||
logger.debug(
|
||||
"Skipping Jina Reader for {}: URL carries credential material",
|
||||
_redact_url_for_log(url),
|
||||
)
|
||||
return None
|
||||
# httpx already drops the fragment when building the request; strip it
|
||||
# explicitly so client-side-only data (OAuth implicit flows put tokens
|
||||
# there) stays out of this path even if the transport changes.
|
||||
forwarded_url = url.split("#", 1)[0]
|
||||
try:
|
||||
headers = {"Accept": "application/json", "User-Agent": self.user_agent}
|
||||
jina_key = os.environ.get("JINA_API_KEY", "")
|
||||
if jina_key:
|
||||
headers["Authorization"] = f"Bearer {jina_key}"
|
||||
async with httpx.AsyncClient(proxy=self.proxy, timeout=20.0) as client:
|
||||
r = await client.get(f"https://r.jina.ai/{forwarded_url}", headers=headers)
|
||||
r = await client.get(f"https://r.jina.ai/{url}", headers=headers)
|
||||
if r.status_code == 429:
|
||||
logger.debug("Jina Reader rate limited, falling back to readability")
|
||||
return None
|
||||
@@ -1200,11 +1113,7 @@ class WebFetchTool(Tool):
|
||||
"untrusted": True, "text": text,
|
||||
}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Jina Reader failed for {}, falling back to readability ({})",
|
||||
_redact_url_for_log(url),
|
||||
type(e).__name__,
|
||||
)
|
||||
logger.debug("Jina Reader failed for {}, falling back to readability: {}", url, e)
|
||||
return None
|
||||
|
||||
async def _fetch_readability(self, url: str, extract_mode: str, max_chars: int) -> Any:
|
||||
@@ -1235,11 +1144,7 @@ class WebFetchTool(Tool):
|
||||
text = self._extract_readable_html(r.text, extract_mode)
|
||||
extractor = "readability"
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Readability failed for {}, using raw HTML fallback ({})",
|
||||
_redact_url_for_log(url),
|
||||
type(e).__name__,
|
||||
)
|
||||
logger.warning("Readability failed for {}, using raw HTML fallback: {}", url, e)
|
||||
text, extractor = _normalize(_strip_tags(r.text)), "html"
|
||||
else:
|
||||
text, extractor = r.text, "raw"
|
||||
@@ -1255,18 +1160,10 @@ class WebFetchTool(Tool):
|
||||
"untrusted": True, "text": text,
|
||||
}, ensure_ascii=False)
|
||||
except httpx.ProxyError as e:
|
||||
logger.warning(
|
||||
"WebFetch proxy error for {} ({})",
|
||||
_redact_url_for_log(url),
|
||||
type(e).__name__,
|
||||
)
|
||||
logger.exception("WebFetch proxy error for {}", url)
|
||||
return json.dumps({"error": f"Proxy error: {e}", "url": url}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"WebFetch error for {} ({})",
|
||||
_redact_url_for_log(url),
|
||||
type(e).__name__,
|
||||
)
|
||||
logger.exception("WebFetch error for {}", url)
|
||||
return json.dumps({"error": str(e), "url": url}, ensure_ascii=False)
|
||||
|
||||
def _extract_readable_html(self, html_content: str, extract_mode: str) -> str:
|
||||
|
||||
+8
-21
@@ -48,7 +48,6 @@ _AGENT_LOOP_KEY = web.AppKey[Any]("agent_loop")
|
||||
_MODEL_NAME_KEY = web.AppKey[str]("model_name")
|
||||
_REQUEST_TIMEOUT_KEY = web.AppKey[float]("request_timeout")
|
||||
_SESSION_LOCKS_KEY = web.AppKey[dict[str, asyncio.Lock]]("session_locks")
|
||||
_PREPARE_AGENT_KEY = web.AppKey[Callable[[], Awaitable[None]] | None]("prepare_agent")
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
@@ -67,17 +66,6 @@ def _app_value(
|
||||
return app.get(legacy_key, default)
|
||||
|
||||
|
||||
async def _prepare_agent(app: Any) -> None:
|
||||
prepare: Callable[[], Awaitable[None]] | None = _app_value(
|
||||
app,
|
||||
_PREPARE_AGENT_KEY,
|
||||
"prepare_agent",
|
||||
None,
|
||||
)
|
||||
if prepare is not None:
|
||||
await prepare()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -358,9 +346,8 @@ async def handle_chat_completions(request: web.Request) -> web.Response | web.St
|
||||
nonlocal stream_failed
|
||||
try:
|
||||
async with session_lock:
|
||||
async with asyncio.timeout(timeout_s):
|
||||
await _prepare_agent(request.app)
|
||||
response = await agent_loop.process_direct(
|
||||
response = await asyncio.wait_for(
|
||||
agent_loop.process_direct(
|
||||
content=text,
|
||||
media=media_paths if media_paths else None,
|
||||
session_key=session_key,
|
||||
@@ -368,6 +355,8 @@ async def handle_chat_completions(request: web.Request) -> web.Response | web.St
|
||||
chat_id=API_CHAT_ID,
|
||||
on_stream=_on_stream,
|
||||
on_stream_end=_on_stream_end,
|
||||
),
|
||||
timeout=timeout_s,
|
||||
)
|
||||
if not emitted_content:
|
||||
response_text = _response_text(response)
|
||||
@@ -401,14 +390,15 @@ async def handle_chat_completions(request: web.Request) -> web.Response | web.St
|
||||
try:
|
||||
async with session_lock:
|
||||
try:
|
||||
async with asyncio.timeout(timeout_s):
|
||||
await _prepare_agent(request.app)
|
||||
response = await agent_loop.process_direct(
|
||||
response = await asyncio.wait_for(
|
||||
agent_loop.process_direct(
|
||||
content=text,
|
||||
media=media_paths if media_paths else None,
|
||||
session_key=session_key,
|
||||
channel="api",
|
||||
chat_id=API_CHAT_ID,
|
||||
),
|
||||
timeout=timeout_s,
|
||||
)
|
||||
response_text = _response_text(response)
|
||||
if not response_text or not response_text.strip():
|
||||
@@ -462,7 +452,6 @@ def create_app(
|
||||
model_name: str = "nanobot",
|
||||
request_timeout: float = 120.0,
|
||||
api_key: str = "",
|
||||
prepare_agent: Callable[[], Awaitable[None]] | None = None,
|
||||
) -> web.Application:
|
||||
"""Create the aiohttp application.
|
||||
|
||||
@@ -471,14 +460,12 @@ def create_app(
|
||||
model_name: Model name reported in responses.
|
||||
request_timeout: Per-request timeout in seconds.
|
||||
api_key: Optional API key for Bearer-token authentication on API routes.
|
||||
prepare_agent: Optional application-owned readiness callback run before each turn.
|
||||
"""
|
||||
app = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images
|
||||
app[_AGENT_LOOP_KEY] = agent_loop
|
||||
app[_MODEL_NAME_KEY] = model_name
|
||||
app[_REQUEST_TIMEOUT_KEY] = request_timeout
|
||||
app[_SESSION_LOCKS_KEY] = {} # per-user locks, keyed by session_key
|
||||
app[_PREPARE_AGENT_KEY] = prepare_agent
|
||||
|
||||
@web.middleware
|
||||
async def auth_middleware(
|
||||
|
||||
+19
-99
@@ -20,7 +20,6 @@ from urllib.parse import urlparse
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.skills import parse_skill_metadata, valid_skill_metadata
|
||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||
from nanobot.config.paths import get_runtime_subdir
|
||||
from nanobot.security.workspace_policy import is_path_within
|
||||
@@ -28,7 +27,6 @@ from nanobot.security.workspace_policy import is_path_within
|
||||
CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
|
||||
CLI_ANYTHING_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
|
||||
CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main"
|
||||
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
|
||||
NANOBOT_EXTENSION_REGISTRY_URL = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main/registry.json"
|
||||
NANOBOT_EXTENSION_RAW_BASE = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main"
|
||||
_CATALOG_SOURCES = (
|
||||
@@ -212,27 +210,11 @@ def _as_object_dict(value: object) -> dict[str, Any] | None:
|
||||
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _skill_name(name: str, *, legacy: bool = False) -> str:
|
||||
def _safe_skill_name(name: str) -> str:
|
||||
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
|
||||
if not legacy:
|
||||
clean = clean.replace("_", "-")
|
||||
return f"cli-app-{clean or 'app'}"
|
||||
|
||||
|
||||
def _plugin_skill_relative_path(name: str) -> str:
|
||||
skill_name = _skill_name(name)
|
||||
return f"plugins/{skill_name}/skills/{skill_name}/SKILL.md"
|
||||
|
||||
|
||||
def cli_app_skill_relative_path(workspace: Path, name: str) -> str:
|
||||
"""Return a CLI App's skill path, including the legacy location."""
|
||||
canonical = _plugin_skill_relative_path(name)
|
||||
legacy = f"skills/{_skill_name(name, legacy=True)}/SKILL.md"
|
||||
if not (workspace / canonical).is_file() and (workspace / legacy).is_file():
|
||||
return legacy
|
||||
return canonical
|
||||
|
||||
|
||||
def _has_shell_meta(command: str) -> bool:
|
||||
return any(char in command for char in _SHELL_META_CHARS)
|
||||
|
||||
@@ -460,16 +442,6 @@ class CliAppManager:
|
||||
"""Return registry names explicitly installed through CLI Apps."""
|
||||
return sorted(str(name) for name in self._load_installed())
|
||||
|
||||
def installed_skill_aliases(self) -> dict[str, str]:
|
||||
"""Map pre-plugin CLI App skill names to their portable identities."""
|
||||
aliases: dict[str, str] = {}
|
||||
for name in self.installed_names():
|
||||
legacy = _skill_name(name, legacy=True)
|
||||
canonical = _skill_name(name)
|
||||
if legacy != canonical:
|
||||
aliases[legacy] = canonical
|
||||
return aliases
|
||||
|
||||
def _fetch_registry(
|
||||
self,
|
||||
url: str,
|
||||
@@ -641,7 +613,7 @@ class CliAppManager:
|
||||
"name": installed_name,
|
||||
"entry_point": entry_point,
|
||||
"source": str(data.get("source") or ""),
|
||||
"skill": cli_app_skill_relative_path(self.workspace, installed_name),
|
||||
"skill": f"skills/{_safe_skill_name(installed_name)}/SKILL.md",
|
||||
"tool": "run_cli_app",
|
||||
}
|
||||
)
|
||||
@@ -667,6 +639,9 @@ class CliAppManager:
|
||||
install_cmd = str(app.get("install_cmd") or "")
|
||||
return not _has_shell_meta(install_cmd)
|
||||
|
||||
def _skill_path(self, name: str) -> Path:
|
||||
return self.workspace / "skills" / _safe_skill_name(name) / "SKILL.md"
|
||||
|
||||
def _app_payload(
|
||||
self,
|
||||
app: dict[str, Any],
|
||||
@@ -702,7 +677,7 @@ class CliAppManager:
|
||||
"status": status,
|
||||
"logo_url": logo_url,
|
||||
"brand_color": brand_color,
|
||||
"skill_installed": (self.workspace / cli_app_skill_relative_path(self.workspace, name)).is_file(),
|
||||
"skill_installed": self._skill_path(name).is_file(),
|
||||
"manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color),
|
||||
}
|
||||
|
||||
@@ -738,8 +713,7 @@ class CliAppManager:
|
||||
name = str(app["name"])
|
||||
entry_point = str(app.get("entry_point") or "")
|
||||
strategy = self._strategy(app)
|
||||
skill_path = _plugin_skill_relative_path(name)
|
||||
plugin_path = f"plugins/{_skill_name(name)}"
|
||||
skill_path = f"skills/{_safe_skill_name(name)}/SKILL.md"
|
||||
capabilities = [
|
||||
compact_dict({
|
||||
"type": "cli",
|
||||
@@ -752,13 +726,13 @@ class CliAppManager:
|
||||
install = compact_dict({
|
||||
"supported": install_supported,
|
||||
"strategy": strategy,
|
||||
"managed_paths": [plugin_path],
|
||||
"managed_paths": [skill_path],
|
||||
"verification": ["entry_point_available"] if entry_point else [],
|
||||
})
|
||||
remove = compact_dict({
|
||||
"supported": strategy != "unsupported",
|
||||
"strategy": strategy,
|
||||
"managed_paths": [plugin_path],
|
||||
"managed_paths": [skill_path],
|
||||
"verification": (
|
||||
["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
|
||||
if strategy not in {"bundled", "unsupported"}
|
||||
@@ -990,35 +964,6 @@ class CliAppManager:
|
||||
return None
|
||||
raise CliAppError("this CLI app uses an unsupported install strategy")
|
||||
|
||||
def _subprocess_env(self) -> dict[str, str]:
|
||||
"""Minimal env for CLI app subprocesses — no API keys or secrets.
|
||||
|
||||
Mirrors the shell tool's allowlist so installed apps cannot read
|
||||
provider credentials from the parent process environment.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
sr = os.environ.get("SYSTEMROOT", r"C:\Windows")
|
||||
env = {
|
||||
"SYSTEMROOT": sr,
|
||||
"COMSPEC": os.environ.get("COMSPEC", f"{sr}\\system32\\cmd.exe"),
|
||||
"USERPROFILE": os.environ.get("USERPROFILE", ""),
|
||||
"HOMEDRIVE": os.environ.get("HOMEDRIVE", "C:"),
|
||||
"HOMEPATH": os.environ.get("HOMEPATH", "\\"),
|
||||
"TEMP": os.environ.get("TEMP", f"{sr}\\Temp"),
|
||||
"TMP": os.environ.get("TMP", f"{sr}\\Temp"),
|
||||
"PATHEXT": os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD"),
|
||||
"PATH": os.environ.get("PATH", f"{sr}\\system32;{sr}"),
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
}
|
||||
return env
|
||||
return {
|
||||
"HOME": os.environ.get("HOME", "/tmp"),
|
||||
"LANG": os.environ.get("LANG", "C.UTF-8"),
|
||||
"TERM": os.environ.get("TERM", "dumb"),
|
||||
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
}
|
||||
|
||||
def _run_argv(self, argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
|
||||
command = subprocess.list2cmdline(argv)
|
||||
logger.info("CLI Apps: running {}", command)
|
||||
@@ -1029,7 +974,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()
|
||||
@@ -1088,10 +1032,11 @@ class CliAppManager:
|
||||
name = str(app.get("name") or "unknown")
|
||||
display = str(app.get("display_name") or name)
|
||||
entry = str(app.get("entry_point") or f"cli-anything-{name}")
|
||||
description = (_catalog_description(app) or f"Use {display} from nanobot.")[:1024]
|
||||
description = _catalog_description(app) or f"Use {display} from nanobot."
|
||||
return f"""---
|
||||
name: {_skill_name(name)}
|
||||
description: {json.dumps(description, ensure_ascii=False)}
|
||||
name: {_safe_skill_name(name)}
|
||||
description: >-
|
||||
{description}
|
||||
---
|
||||
|
||||
# {display}
|
||||
@@ -1111,17 +1056,10 @@ Prefer machine-readable output when the CLI supports `--json`.
|
||||
"""
|
||||
|
||||
def _with_nanobot_skill_note(self, content: str, app: dict[str, Any]) -> str:
|
||||
name = str(app.get("name") or "unknown")
|
||||
skill_name = _skill_name(name)
|
||||
metadata = parse_skill_metadata(content)
|
||||
if metadata is None or not valid_skill_metadata(metadata | {"name": skill_name}, skill_name):
|
||||
content = self._fallback_skill(app)
|
||||
content, replaced = re.subn(r"(?m)^name\s*:.*$", f"name: {skill_name}", content, count=1)
|
||||
if not replaced:
|
||||
content = content.replace("---\n", f"---\nname: {skill_name}\n", 1)
|
||||
marker = "<!-- nanobot-cli-app-note -->"
|
||||
if marker in content:
|
||||
return content
|
||||
name = str(app.get("name") or "unknown")
|
||||
note = f"""{marker}
|
||||
## Nanobot execution
|
||||
|
||||
@@ -1135,42 +1073,24 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
||||
return note + "\n" + content
|
||||
|
||||
def install_skill(self, app: dict[str, Any]) -> Path:
|
||||
name = str(app["name"])
|
||||
path = self.workspace / _plugin_skill_relative_path(name)
|
||||
path = self._skill_path(str(app["name"]))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = self._fetch_skill_content(app) or self._fallback_skill(app)
|
||||
content = self._with_nanobot_skill_note(content, app)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
plugin_root = path.parents[2]
|
||||
manifest = compact_dict({
|
||||
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||
"name": _skill_name(str(app["name"])),
|
||||
"version": str(app.get("version") or ""),
|
||||
"description": _catalog_description(app),
|
||||
})
|
||||
_write_json(plugin_root / "plugin.json", manifest)
|
||||
legacy_dir = self.workspace / "skills" / _skill_name(str(app["name"]), legacy=True)
|
||||
if legacy_dir.is_dir():
|
||||
shutil.rmtree(legacy_dir)
|
||||
return path
|
||||
|
||||
def remove_skill(self, name: str) -> None:
|
||||
plugin_root = (self.workspace / _plugin_skill_relative_path(name)).parents[2]
|
||||
if plugin_root.is_dir():
|
||||
shutil.rmtree(plugin_root)
|
||||
legacy_dir = self.workspace / "skills" / _skill_name(name, legacy=True)
|
||||
if legacy_dir.is_dir():
|
||||
shutil.rmtree(legacy_dir)
|
||||
skill_dir = self._skill_path(name).parent
|
||||
if skill_dir.is_dir():
|
||||
shutil.rmtree(skill_dir)
|
||||
|
||||
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
|
||||
from nanobot.agent.plugins import set_agent_plugin_enabled
|
||||
|
||||
installed = self._load_installed()
|
||||
entry = self._installed_entry(app)
|
||||
installed[str(app["name"])] = entry
|
||||
self._save_installed(installed)
|
||||
self.install_skill(app)
|
||||
set_agent_plugin_enabled(self.workspace, _skill_name(str(app["name"])), True)
|
||||
return entry
|
||||
|
||||
def install(self, name: str) -> dict[str, Any]:
|
||||
@@ -1461,7 +1381,7 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=effective_timeout,
|
||||
env=self._subprocess_env(),
|
||||
env=os.environ.copy(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"CLI app '{name}' timed out after {effective_timeout}s"
|
||||
|
||||
@@ -20,8 +20,6 @@ def runtime_lines_for_request(
|
||||
"""Return CLI App annotations from an immutable request snapshot."""
|
||||
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
||||
if isinstance(structured, list):
|
||||
from nanobot.apps.cli.service import cli_app_skill_relative_path
|
||||
|
||||
structured_items = cast(list[Any], structured)
|
||||
mentions = [
|
||||
cast(Mapping[str, Any], item) for item in structured_items
|
||||
@@ -34,7 +32,7 @@ def runtime_lines_for_request(
|
||||
f"@{str(item['name']).strip().lower()} "
|
||||
f"(installed; tool=run_cli_app; "
|
||||
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
|
||||
f"skill={cli_app_skill_relative_path(workspace, str(item['name']))}). "
|
||||
f"skill=skills/cli-app-{str(item['name']).strip().lower()}/SKILL.md). "
|
||||
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
|
||||
for item in mentions
|
||||
if str(item.get("name") or "").strip()
|
||||
|
||||
@@ -16,6 +16,7 @@ OUTBOUND_META_AGENT_UI = "_agent_ui"
|
||||
# loop to update runtime state without going through a user session.
|
||||
INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
|
||||
RUNTIME_CONTROL_ACK = "_ack"
|
||||
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
|
||||
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
|
||||
RUNTIME_CONTROL_SESSION_DISCARD = "session_discard"
|
||||
|
||||
|
||||
@@ -238,6 +238,20 @@ class TestStreamEndReactionCleanup:
|
||||
|
||||
ch._remove_reaction.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_removal_when_both_ids_missing(self):
|
||||
ch = _make_channel()
|
||||
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
||||
text="Done", card_id="card_1", sequence=3, last_edit=0.0,
|
||||
)
|
||||
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
|
||||
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
|
||||
ch._remove_reaction = AsyncMock()
|
||||
|
||||
await ch.send_delta("oc_chat1", "", stream_end=True)
|
||||
|
||||
ch._remove_reaction.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_removal_when_not_stream_end(self):
|
||||
ch = _make_channel()
|
||||
|
||||
@@ -15,7 +15,6 @@ import type {
|
||||
NanobotFeatureInfo,
|
||||
NanobotFeaturesPayload,
|
||||
} from "@/lib/types";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
import { FeishuConnectFlow } from "./FeishuConnectFlow";
|
||||
|
||||
@@ -34,6 +33,7 @@ export function FeishuAssistantsPanel({
|
||||
|
||||
return (
|
||||
<ChannelInstancesPanel
|
||||
token={token}
|
||||
feature={feature}
|
||||
showBrandLogos={showBrandLogos}
|
||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||
@@ -92,7 +92,6 @@ function FeishuInstanceAction({
|
||||
instance: NanobotChannelInstanceInfo;
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
}) {
|
||||
const { client } = useClient();
|
||||
const { t } = useTranslation();
|
||||
const tx = channelTranslator(t, "feishu");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -115,7 +114,7 @@ function FeishuInstanceAction({
|
||||
setError(null);
|
||||
try {
|
||||
onFeaturesUpdate(
|
||||
await enableNanobotFeature(client, "feishu", { instanceId: instance.id }),
|
||||
await enableNanobotFeature(token, "feishu", { instanceId: instance.id }),
|
||||
);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
|
||||
@@ -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
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
@@ -100,17 +100,9 @@ class ChannelManager:
|
||||
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,
|
||||
):
|
||||
if config_path is None:
|
||||
from nanobot.config.loader import get_config_path
|
||||
|
||||
config_path = get_config_path()
|
||||
self.config = config
|
||||
self._config_path = config_path.expanduser().resolve(strict=False)
|
||||
self.bus = bus
|
||||
self._session_manager = session_manager
|
||||
self._cron_service = cron_service
|
||||
@@ -121,8 +113,6 @@ class ChannelManager:
|
||||
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] = {}
|
||||
@@ -180,7 +170,6 @@ class ChannelManager:
|
||||
static_dist_path=static_path,
|
||||
workspace_path=workspace,
|
||||
default_restrict_to_workspace=self.config.tools.restrict_to_workspace,
|
||||
config_path=self._config_path,
|
||||
disabled_skills=set(self.config.agents.defaults.disabled_skills),
|
||||
runtime_model_name=self._webui_runtime_model_name,
|
||||
runtime_surface=self._webui_runtime_surface,
|
||||
@@ -191,8 +180,6 @@ class ChannelManager:
|
||||
local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -968,11 +968,6 @@ class MatrixChannel(BaseChannel):
|
||||
meta["thread_reply_to_event_id"] = reply_to
|
||||
return meta
|
||||
|
||||
def _thread_session_key(self, room_id: str, event: RoomMessage) -> str | None:
|
||||
if not (root_id := self._event_thread_root_id(event)):
|
||||
return None
|
||||
return f"{self.name}:{room_id}:thread:{root_id}"
|
||||
|
||||
@staticmethod
|
||||
def _build_thread_relates_to(metadata: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if not metadata:
|
||||
@@ -1176,7 +1171,6 @@ class MatrixChannel(BaseChannel):
|
||||
await self._handle_message(
|
||||
sender_id=event.sender, chat_id=room.room_id,
|
||||
content=event.body, metadata=self._base_metadata(room, event),
|
||||
session_key=self._thread_session_key(room.room_id, event),
|
||||
is_dm=self._is_direct_room(room),
|
||||
)
|
||||
except Exception:
|
||||
@@ -1215,7 +1209,6 @@ class MatrixChannel(BaseChannel):
|
||||
content="\n".join(parts),
|
||||
media=[attachment["path"]] if attachment else [],
|
||||
metadata=meta,
|
||||
session_key=self._thread_session_key(room.room_id, event),
|
||||
is_dm=self._is_direct_room(room),
|
||||
)
|
||||
except Exception:
|
||||
|
||||
@@ -971,81 +971,6 @@ async def test_on_message_sets_thread_metadata_when_threaded_event() -> None:
|
||||
assert metadata["thread_root_event_id"] == "$root1"
|
||||
assert metadata["thread_reply_to_event_id"] == "$reply1"
|
||||
assert metadata["event_id"] == "$reply1"
|
||||
assert handled[0]["session_key"] == "matrix:!room:matrix.org:thread:$root1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_keeps_matrix_thread_sessions_independent() -> None:
|
||||
channel = MatrixChannel(_make_config(), MessageBus())
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel.client = client
|
||||
|
||||
handled: list[dict[str, object]] = []
|
||||
|
||||
async def _fake_handle_message(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = _fake_handle_message # type: ignore[method-assign]
|
||||
|
||||
room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=3)
|
||||
|
||||
def _thread_event(body: str, event_id: str, root_id: str) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
sender="@alice:matrix.org",
|
||||
body=body,
|
||||
event_id=event_id,
|
||||
source={
|
||||
"content": {
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.thread",
|
||||
"event_id": root_id,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await channel._on_message(room, _thread_event("Plan the wedding", "$reply1", "$root1"))
|
||||
await channel._on_message(room, _thread_event("Pick a gift", "$reply2", "$root1"))
|
||||
await channel._on_message(room, _thread_event("/new", "$reply3", "$root2"))
|
||||
|
||||
assert [message["chat_id"] for message in handled] == [
|
||||
"!room:matrix.org",
|
||||
"!room:matrix.org",
|
||||
"!room:matrix.org",
|
||||
]
|
||||
assert [message["session_key"] for message in handled] == [
|
||||
"matrix:!room:matrix.org:thread:$root1",
|
||||
"matrix:!room:matrix.org:thread:$root1",
|
||||
"matrix:!room:matrix.org:thread:$root2",
|
||||
]
|
||||
assert handled[2]["content"] == "/new"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_keeps_non_threaded_room_session() -> None:
|
||||
channel = MatrixChannel(_make_config(), MessageBus())
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel.client = client
|
||||
|
||||
handled: list[dict[str, object]] = []
|
||||
|
||||
async def _fake_handle_message(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = _fake_handle_message # type: ignore[method-assign]
|
||||
|
||||
room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=3)
|
||||
event = SimpleNamespace(
|
||||
sender="@alice:matrix.org",
|
||||
body="Hello",
|
||||
event_id="$event1",
|
||||
source={"content": {}},
|
||||
)
|
||||
|
||||
await channel._on_message(room, event)
|
||||
|
||||
assert len(handled) == 1
|
||||
assert handled[0]["session_key"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1151,7 +1076,6 @@ async def test_on_media_message_sets_thread_metadata_when_threaded_event(
|
||||
assert metadata["thread_root_event_id"] == "$root1"
|
||||
assert metadata["thread_reply_to_event_id"] == "$event1"
|
||||
assert metadata["event_id"] == "$event1"
|
||||
assert handled[0]["session_key"] == "matrix:!room:matrix.org:thread:$root1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -373,13 +373,6 @@ 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()
|
||||
# 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._stop_event: asyncio.Event | None = None
|
||||
self._server_task: asyncio.Task[None] | None = None
|
||||
|
||||
@@ -530,10 +523,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)
|
||||
|
||||
@classmethod
|
||||
def default_config(cls) -> dict[str, Any]:
|
||||
return WebSocketConfig().model_dump(by_alias=True)
|
||||
@@ -769,9 +758,6 @@ class WebSocketChannel(BaseChannel):
|
||||
) -> None:
|
||||
"""Route one typed inbound envelope (``new_chat`` / ``attach`` / ``message``)."""
|
||||
t = envelope.get("type")
|
||||
if t == "webui_request":
|
||||
await self._start_webui_request(connection, envelope)
|
||||
return
|
||||
if t == "new_chat":
|
||||
new_id = str(uuid.uuid4())
|
||||
scope = await self._workspace_scope_or_error(
|
||||
@@ -852,7 +838,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),
|
||||
)
|
||||
@@ -863,11 +849,6 @@ class WebSocketChannel(BaseChannel):
|
||||
detail="invalid_sidebar_state",
|
||||
)
|
||||
return
|
||||
await self._broadcast_webui_event(
|
||||
"sidebar_state_updated",
|
||||
state=saved_state,
|
||||
)
|
||||
return
|
||||
if t == "set_workspace_scope":
|
||||
cid = envelope.get("chat_id")
|
||||
if not _is_valid_chat_id(cid):
|
||||
@@ -1124,157 +1105,6 @@ class WebSocketChannel(BaseChannel):
|
||||
return
|
||||
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
|
||||
|
||||
async def _start_webui_request(
|
||||
self,
|
||||
connection: ServerConnection,
|
||||
envelope: dict[str, Any],
|
||||
) -> None:
|
||||
request_id = envelope.get("request_id")
|
||||
if not isinstance(request_id, str) or re.fullmatch(
|
||||
r"[A-Za-z0-9._:-]{1,128}",
|
||||
request_id,
|
||||
) is None:
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
detail="invalid webui request_id",
|
||||
)
|
||||
return
|
||||
if connection not in self._webui_connections:
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
status=403,
|
||||
message="access_denied",
|
||||
)
|
||||
return
|
||||
|
||||
action = envelope.get("action")
|
||||
payload = envelope.get("payload")
|
||||
if not isinstance(action, str) or re.fullmatch(
|
||||
r"[a-z][a-z0-9_.]{0,127}",
|
||||
action,
|
||||
) is None:
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
status=400,
|
||||
message="invalid WebUI mutation action",
|
||||
)
|
||||
return
|
||||
if not isinstance(payload, dict):
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
status=400,
|
||||
message="WebUI mutation payload must be an object",
|
||||
)
|
||||
return
|
||||
|
||||
key = (connection, request_id)
|
||||
if key in self._webui_request_tasks:
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
status=409,
|
||||
message="duplicate WebUI request_id",
|
||||
)
|
||||
return
|
||||
task = asyncio.create_task(
|
||||
self._complete_webui_request(
|
||||
connection,
|
||||
request_id,
|
||||
action,
|
||||
cast(dict[str, Any], payload),
|
||||
)
|
||||
)
|
||||
self._webui_request_tasks[key] = task
|
||||
|
||||
async def _complete_webui_request(
|
||||
self,
|
||||
connection: ServerConnection,
|
||||
request_id: str,
|
||||
action: str,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
try:
|
||||
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,
|
||||
status=502,
|
||||
message="WebUI mutation returned an invalid response",
|
||||
)
|
||||
return
|
||||
if action == "sidebar.update" and isinstance(result, dict):
|
||||
await self._broadcast_webui_event(
|
||||
"sidebar_state_updated",
|
||||
state=result,
|
||||
)
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
result=result,
|
||||
)
|
||||
return
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
status=status,
|
||||
message=body or response.reason_phrase,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
self.logger.exception("WebUI mutation '{}' failed", action)
|
||||
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,
|
||||
connection: ServerConnection,
|
||||
request_id: str,
|
||||
*,
|
||||
result: Any = None,
|
||||
status: int | None = None,
|
||||
message: str | None = None,
|
||||
) -> None:
|
||||
if status is None:
|
||||
await self._send_event(
|
||||
connection,
|
||||
"webui_response",
|
||||
request_id=request_id,
|
||||
ok=True,
|
||||
result=result,
|
||||
)
|
||||
return
|
||||
await self._send_event(
|
||||
connection,
|
||||
"webui_response",
|
||||
request_id=request_id,
|
||||
ok=False,
|
||||
error={
|
||||
"status": status,
|
||||
"message": message or "WebUI mutation failed",
|
||||
},
|
||||
)
|
||||
|
||||
async def _workspace_scope_or_error(
|
||||
self,
|
||||
connection: ServerConnection,
|
||||
@@ -1315,12 +1145,6 @@ class WebSocketChannel(BaseChannel):
|
||||
except Exception as e:
|
||||
self.logger.warning("server task error during shutdown: {}", e)
|
||||
self._server_task = None
|
||||
mutation_tasks = tuple(self._webui_request_tasks.values())
|
||||
for task in mutation_tasks:
|
||||
task.cancel()
|
||||
if mutation_tasks:
|
||||
await asyncio.gather(*mutation_tasks, return_exceptions=True)
|
||||
self._webui_request_tasks.clear()
|
||||
self._subs.clear()
|
||||
self._conn_chats.clear()
|
||||
self._conn_default.clear()
|
||||
|
||||
@@ -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)
|
||||
@@ -3,16 +3,12 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import websockets
|
||||
from websockets.datastructures import Headers
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
from websockets.frames import Close
|
||||
|
||||
@@ -46,12 +42,6 @@ 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.webui.gateway_services import GatewayServices, build_gateway_services
|
||||
from nanobot.webui.http_utils import (
|
||||
http_error as _http_error,
|
||||
)
|
||||
from nanobot.webui.http_utils import (
|
||||
http_json_response as _http_json_response,
|
||||
)
|
||||
from nanobot.webui.http_utils import (
|
||||
issue_route_secret_matches as _issue_route_secret_matches,
|
||||
)
|
||||
@@ -129,46 +119,6 @@ def _basic_handler(bus: Any, **kw: Any) -> GatewayServices:
|
||||
)
|
||||
|
||||
|
||||
async def _connect_when_ready(url: str) -> Any:
|
||||
while True:
|
||||
try:
|
||||
return await websockets.connect(url)
|
||||
except OSError:
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
|
||||
async def _webui_mutate(
|
||||
client: Any,
|
||||
action: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> httpx.Response:
|
||||
request_id = f"test-{uuid.uuid4().hex}"
|
||||
await client.send(json.dumps({
|
||||
"type": "webui_request",
|
||||
"request_id": request_id,
|
||||
"action": action,
|
||||
"payload": payload or {},
|
||||
}))
|
||||
while True:
|
||||
envelope = json.loads(await asyncio.wait_for(client.recv(), timeout=5))
|
||||
if envelope.get("event") != "webui_response":
|
||||
continue
|
||||
if envelope.get("request_id") != request_id:
|
||||
continue
|
||||
if envelope.get("ok") is True:
|
||||
status = 200
|
||||
body = envelope.get("result")
|
||||
else:
|
||||
error = envelope.get("error") or {}
|
||||
status = int(error.get("status") or 500)
|
||||
body = {"error": str(error.get("message") or "WebUI mutation failed")}
|
||||
return httpx.Response(
|
||||
status,
|
||||
json=body,
|
||||
request=httpx.Request("WS", "http://nanobot.local/webui-mutation"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_treats_cancelled_server_task_as_shutdown() -> None:
|
||||
channel = _ch(MessageBus())
|
||||
@@ -717,7 +667,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,
|
||||
)
|
||||
|
||||
@@ -725,16 +675,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
|
||||
@@ -908,98 +857,6 @@ def test_only_bootstrap_tokens_mark_webui_connections(bus: MagicMock) -> None:
|
||||
assert client_connection not in channel._webui_connections
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticated_webui_request_returns_correlated_success(bus: MagicMock) -> None:
|
||||
channel = _ch(bus)
|
||||
conn = AsyncMock()
|
||||
channel._webui_connections.add(conn)
|
||||
channel.gateway.http.dispatch_webui_mutation = AsyncMock(
|
||||
return_value=_http_json_response({"saved": True})
|
||||
)
|
||||
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "webui_request",
|
||||
"request_id": "request-1",
|
||||
"action": "settings.provider.update",
|
||||
"payload": {"provider": "openrouter", "apiKey": "secret"},
|
||||
},
|
||||
)
|
||||
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
|
||||
|
||||
channel.gateway.http.dispatch_webui_mutation.assert_awaited_once_with(
|
||||
conn,
|
||||
"settings.provider.update",
|
||||
{"provider": "openrouter", "apiKey": "secret"},
|
||||
)
|
||||
assert json.loads(conn.send.await_args.args[0]) == {
|
||||
"event": "webui_response",
|
||||
"request_id": "request-1",
|
||||
"ok": True,
|
||||
"result": {"saved": True},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_request_returns_correlated_route_error(bus: MagicMock) -> None:
|
||||
channel = _ch(bus)
|
||||
conn = AsyncMock()
|
||||
channel._webui_connections.add(conn)
|
||||
channel.gateway.http.dispatch_webui_mutation = AsyncMock(
|
||||
return_value=_http_error(400, "invalid settings payload")
|
||||
)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "webui_request",
|
||||
"request_id": "request-2",
|
||||
"action": "settings.agent.update",
|
||||
"payload": {},
|
||||
},
|
||||
)
|
||||
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
|
||||
|
||||
assert json.loads(conn.send.await_args.args[0]) == {
|
||||
"event": "webui_response",
|
||||
"request_id": "request-2",
|
||||
"ok": False,
|
||||
"error": {"status": 400, "message": "invalid settings payload"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_request_requires_bootstrap_authenticated_connection(
|
||||
bus: MagicMock,
|
||||
) -> None:
|
||||
channel = _ch(bus)
|
||||
conn = AsyncMock()
|
||||
channel.gateway.http.dispatch_webui_mutation = AsyncMock()
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"static-token-client",
|
||||
{
|
||||
"type": "webui_request",
|
||||
"request_id": "request-3",
|
||||
"action": "settings.agent.update",
|
||||
"payload": {},
|
||||
},
|
||||
)
|
||||
|
||||
channel.gateway.http.dispatch_webui_mutation.assert_not_awaited()
|
||||
assert json.loads(conn.send.await_args.args[0]) == {
|
||||
"event": "webui_response",
|
||||
"request_id": "request-3",
|
||||
"ok": False,
|
||||
"error": {"status": 403, "message": "access_denied"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_persists_sidebar_state_larger_than_http_request_line(
|
||||
bus: MagicMock,
|
||||
@@ -1009,90 +866,23 @@ async def test_webui_persists_sidebar_state_larger_than_http_request_line(
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
channel = _ch(bus)
|
||||
conn = AsyncMock()
|
||||
conn.request = SimpleNamespace(headers=Headers())
|
||||
channel._webui_connections.add(conn)
|
||||
session_order = [f"websocket:{index:04d}-{'x' * 48}" for index in range(160)]
|
||||
request_id = "sidebar-large-state"
|
||||
envelope = {
|
||||
"type": "webui_request",
|
||||
"request_id": request_id,
|
||||
"action": "sidebar.update",
|
||||
"payload": {"state": {
|
||||
"type": "set_sidebar_state",
|
||||
"state": {
|
||||
"session_order": session_order,
|
||||
"view": {"sort": "manual"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
assert len(json.dumps(envelope).encode()) > 8_192
|
||||
|
||||
await channel._dispatch_envelope(conn, "webui-client", envelope)
|
||||
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
|
||||
|
||||
saved = json.loads((tmp_path / "webui" / "sidebar-state.json").read_text(encoding="utf-8"))
|
||||
assert saved["session_order"] == session_order
|
||||
assert saved["view"]["sort"] == "manual"
|
||||
assert json.loads(conn.send.await_args.args[0]) == {
|
||||
"event": "webui_response",
|
||||
"request_id": request_id,
|
||||
"ok": True,
|
||||
"result": saved,
|
||||
}
|
||||
|
||||
|
||||
@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
|
||||
]
|
||||
conn.send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -2931,13 +2721,10 @@ async def test_end_to_end_client_receives_ready_and_agent_sees_inbound(bus: Magi
|
||||
channel = _ch(bus, port=port)
|
||||
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
try:
|
||||
client = await asyncio.wait_for(
|
||||
_connect_when_ready(f"ws://127.0.0.1:{port}/ws?client_id=tester"),
|
||||
timeout=5,
|
||||
)
|
||||
async with client:
|
||||
async with websockets.connect(f"ws://127.0.0.1:{port}/ws?client_id=tester") as client:
|
||||
ready_raw = await client.recv()
|
||||
ready = json.loads(ready_raw)
|
||||
assert ready["event"] == "ready"
|
||||
@@ -3100,15 +2887,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
webui_client = None
|
||||
try:
|
||||
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
||||
webui_client = await websockets.connect(
|
||||
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=settings-test"
|
||||
)
|
||||
ready = json.loads(await asyncio.wait_for(webui_client.recv(), timeout=5))
|
||||
assert ready["event"] == "ready"
|
||||
|
||||
settings = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/settings",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
@@ -3192,14 +2971,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert unknown_api.status_code == 404
|
||||
assert "<!doctype html>" not in unknown_api.text.lower()
|
||||
|
||||
provider_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.provider.update",
|
||||
{
|
||||
"provider": "openrouter",
|
||||
"apiKey": "sk-or-test",
|
||||
"apiBase": "https://openrouter.ai/api/v1",
|
||||
},
|
||||
provider_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/provider/update?provider=openrouter"
|
||||
"&api_key=sk-or-test&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert provider_updated.status_code == 200
|
||||
provider_body = provider_updated.json()
|
||||
@@ -3209,9 +2985,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert provider_body["image_generation"]["provider_configured"] is True
|
||||
assert "sk-or-test" not in provider_updated.text
|
||||
|
||||
custom_provider_created = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.provider.create",
|
||||
custom_provider_created = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/settings/provider/create",
|
||||
headers={
|
||||
"Authorization": "Bearer tok",
|
||||
"X-Nanobot-Provider-Values": json.dumps(
|
||||
{
|
||||
"name": "Company Gateway",
|
||||
"apiBase": "https://gateway.example/v1",
|
||||
@@ -3221,6 +2999,8 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
|
||||
"proxy": "http://127.0.0.1:7890",
|
||||
"thinkingStyle": "enable_thinking",
|
||||
}
|
||||
),
|
||||
},
|
||||
)
|
||||
assert custom_provider_created.status_code == 200
|
||||
@@ -3235,10 +3015,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
}
|
||||
assert "sk-company" not in custom_provider_created.text
|
||||
|
||||
local_provider_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.provider.update",
|
||||
{"provider": "atomic_chat", "apiBase": "http://localhost:1337/v1"},
|
||||
local_provider_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/provider/update?provider=atomic_chat"
|
||||
"&api_base=http%3A%2F%2Flocalhost%3A1337%2Fv1",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert local_provider_updated.status_code == 200
|
||||
local_provider_body = local_provider_updated.json()
|
||||
@@ -3248,44 +3029,38 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert local_provider_rows["atomic_chat"]["configured"] is True
|
||||
assert "localhost:1337" in local_provider_updated.text
|
||||
|
||||
updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.agent.update",
|
||||
{
|
||||
"model": "atomic_chat/test",
|
||||
"provider": "atomic_chat",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"tool_hint_max_length": 120,
|
||||
},
|
||||
updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/update?model=atomic_chat/test"
|
||||
"&provider=atomic_chat&timezone=Asia%2FShanghai"
|
||||
"&bot_name=Nano&bot_icon=N&tool_hint_max_length=120",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
updated_body = updated.json()
|
||||
assert updated_body["requires_restart"] is True
|
||||
assert updated_body["restart_required_sections"] == ["runtime"]
|
||||
|
||||
preset_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.agent.update",
|
||||
{"model_preset": "deep"},
|
||||
preset_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/update?model_preset=deep",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert preset_updated.status_code == 200
|
||||
assert preset_updated.json()["agent"]["model"] == "anthropic/claude-opus-4-5"
|
||||
|
||||
bad_preset = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.agent.update",
|
||||
{"model_preset": "missing"},
|
||||
bad_preset = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/update?model_preset=missing",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert bad_preset.status_code == 400
|
||||
|
||||
created_preset = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.model_configuration.create",
|
||||
{
|
||||
"label": "Fast writing",
|
||||
"provider": "openai",
|
||||
"model": "openai/gpt-4.1-mini",
|
||||
},
|
||||
created_preset = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/model-configurations/create"
|
||||
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert created_preset.status_code == 200
|
||||
created_body = created_preset.json()
|
||||
@@ -3299,15 +3074,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert created_presets["fast-writing"]["label"] == "Fast writing"
|
||||
assert created_presets["fast-writing"]["provider"] == "openai"
|
||||
|
||||
updated_preset = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.model_configuration.update",
|
||||
{
|
||||
"name": "fast-writing",
|
||||
"label": "Codex",
|
||||
"provider": "openai",
|
||||
"model": "openai/gpt-5.5",
|
||||
},
|
||||
updated_preset = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/model-configurations/update"
|
||||
"?name=fast-writing&label=Codex&provider=openai&model=openai%2Fgpt-5.5",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert updated_preset.status_code == 200
|
||||
updated_preset_body = updated_preset.json()
|
||||
@@ -3318,10 +3089,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
}
|
||||
assert updated_presets["fast-writing"]["label"] == "Codex"
|
||||
|
||||
call_order_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.model_call_order.update",
|
||||
{"order": ["fast-writing", "deep"]},
|
||||
call_order_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/model-call-order/update"
|
||||
"?order=%5B%22fast-writing%22%2C%22deep%22%5D",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert call_order_updated.status_code == 200
|
||||
call_order_body = call_order_updated.json()
|
||||
@@ -3329,27 +3101,20 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert call_order_body["agent"]["model"] == "openai/gpt-5.5"
|
||||
assert call_order_body["model_call_order"] == ["fast-writing", "deep"]
|
||||
|
||||
duplicate_preset = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.model_configuration.create",
|
||||
{
|
||||
"label": "Fast writing",
|
||||
"provider": "openai",
|
||||
"model": "openai/gpt-4.1-mini",
|
||||
},
|
||||
duplicate_preset = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/model-configurations/create"
|
||||
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert duplicate_preset.status_code == 409
|
||||
|
||||
search_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.web_search.update",
|
||||
{
|
||||
"provider": "searxng",
|
||||
"base_url": "https://search.example.com",
|
||||
"max_results": 8,
|
||||
"timeout": 45,
|
||||
"use_jina_reader": False,
|
||||
},
|
||||
search_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/web-search/update?provider=searxng"
|
||||
"&base_url=https%3A%2F%2Fsearch.example.com"
|
||||
"&max_results=8&timeout=45&use_jina_reader=false",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert search_updated.status_code == 200
|
||||
search_body = search_updated.json()
|
||||
@@ -3361,13 +3126,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert search_body["web_search"]["max_results"] == 8
|
||||
assert search_body["web"]["fetch"]["use_jina_reader"] is False
|
||||
|
||||
network_safety_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.network_safety.update",
|
||||
{
|
||||
"webui_allow_local_service_access": False,
|
||||
"webui_default_access_mode": "full",
|
||||
},
|
||||
network_safety_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert network_safety_updated.status_code == 200
|
||||
network_safety_body = network_safety_updated.json()
|
||||
@@ -3377,17 +3139,13 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert network_safety_body["advanced"]["webui_default_access_mode"] == "full"
|
||||
assert network_safety_body["advanced"]["private_service_protection_enabled"] is True
|
||||
|
||||
image_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.image_generation.update",
|
||||
{
|
||||
"enabled": True,
|
||||
"provider": "openrouter",
|
||||
"model": "openai/gpt-image-1",
|
||||
"default_aspect_ratio": "16:9",
|
||||
"default_image_size": "2K",
|
||||
"max_images_per_turn": 3,
|
||||
},
|
||||
image_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/image-generation/update?enabled=true"
|
||||
"&provider=openrouter&model=openai%2Fgpt-image-1"
|
||||
"&default_aspect_ratio=16%3A9&default_image_size=2K"
|
||||
"&max_images_per_turn=3",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert image_updated.status_code == 200
|
||||
image_body = image_updated.json()
|
||||
@@ -3399,14 +3157,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert image_body["image_generation"]["default_image_size"] == "2K"
|
||||
assert image_body["image_generation"]["max_images_per_turn"] == 3
|
||||
|
||||
image_provider_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.provider.update",
|
||||
{
|
||||
"provider": "openrouter",
|
||||
"apiKey": "sk-or-next",
|
||||
"apiBase": "https://openrouter.ai/api/v1",
|
||||
},
|
||||
image_provider_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/provider/update?provider=openrouter"
|
||||
"&api_key=sk-or-next&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert image_provider_updated.status_code == 200
|
||||
assert image_provider_updated.json()["requires_restart"] is True
|
||||
@@ -3414,17 +3169,17 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert "sk-or-next" not in image_provider_updated.text
|
||||
assert image_reload.await_count == 2
|
||||
|
||||
bad_web = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.web_search.update",
|
||||
{"provider": "duckduckgo", "max_results": 99},
|
||||
bad_web = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/web-search/update?provider=duckduckgo&max_results=99",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert bad_web.status_code == 400
|
||||
|
||||
bad_image = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.image_generation.update",
|
||||
{"provider": "missing"},
|
||||
bad_image = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/image-generation/update?provider=missing",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert bad_image.status_code == 400
|
||||
|
||||
@@ -3461,8 +3216,6 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert saved.tools.image_generation.default_image_size == "2K"
|
||||
assert saved.tools.image_generation.max_images_per_turn == 3
|
||||
finally:
|
||||
if webui_client is not None:
|
||||
await webui_client.close()
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
@@ -3495,17 +3248,11 @@ async def test_image_settings_hot_reload_without_restart(
|
||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
webui_client = None
|
||||
try:
|
||||
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
||||
webui_client = await websockets.connect(
|
||||
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-reload-test"
|
||||
)
|
||||
assert json.loads(await webui_client.recv())["event"] == "ready"
|
||||
response = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.image_generation.update",
|
||||
{"enabled": True, "provider": "openrouter", "model": "openai/gpt-image-1"},
|
||||
response = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
|
||||
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -3513,8 +3260,6 @@ async def test_image_settings_hot_reload_without_restart(
|
||||
assert response.json()["restart_required_sections"] == []
|
||||
image_reload.assert_awaited_once_with(bus)
|
||||
finally:
|
||||
if webui_client is not None:
|
||||
await webui_client.close()
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
@@ -3546,25 +3291,17 @@ async def test_image_settings_fall_back_to_restart_when_hot_reload_fails(
|
||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
webui_client = None
|
||||
try:
|
||||
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
||||
webui_client = await websockets.connect(
|
||||
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-fallback-test"
|
||||
)
|
||||
assert json.loads(await webui_client.recv())["event"] == "ready"
|
||||
response = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.image_generation.update",
|
||||
{"enabled": True, "provider": "openrouter", "model": "openai/gpt-image-1"},
|
||||
response = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
|
||||
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["requires_restart"] is True
|
||||
assert response.json()["restart_required_sections"] == ["image"]
|
||||
finally:
|
||||
if webui_client is not None:
|
||||
await webui_client.close()
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
@@ -3804,7 +3541,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:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,6 @@ class WeixinConnectSession:
|
||||
channel: WeixinChannel
|
||||
current_poll_base_url: str
|
||||
refresh_count: int
|
||||
force: bool
|
||||
created_wall: float
|
||||
deadline: float
|
||||
last_error: str | None = None
|
||||
@@ -73,7 +72,7 @@ class WeixinConnectStore:
|
||||
|
||||
channel.connect_open_client()
|
||||
try:
|
||||
qrcode_id, qr_url = await channel.connect_fetch_qr_code(force=force)
|
||||
qrcode_id, qr_url = await channel.connect_fetch_qr_code()
|
||||
except Exception as exc:
|
||||
await self._close_channel(channel)
|
||||
raise ChannelConnectError(
|
||||
@@ -90,7 +89,6 @@ class WeixinConnectStore:
|
||||
channel=channel,
|
||||
current_poll_base_url=channel.connect_base_url,
|
||||
refresh_count=0,
|
||||
force=force,
|
||||
created_wall=now_wall,
|
||||
deadline=time.monotonic() + 600,
|
||||
)
|
||||
@@ -189,7 +187,7 @@ class WeixinConnectStore:
|
||||
}
|
||||
try:
|
||||
session.qrcode_id, session.qr_url = (
|
||||
await session.channel.connect_fetch_qr_code(force=session.force)
|
||||
await session.channel.connect_fetch_qr_code()
|
||||
)
|
||||
except Exception as exc:
|
||||
self._sessions.pop(session_id, None)
|
||||
@@ -206,17 +204,6 @@ class WeixinConnectStore:
|
||||
)
|
||||
|
||||
if status == "binded_redirect":
|
||||
if session.force:
|
||||
self._sessions.pop(session_id, None)
|
||||
await self._close_channel(session.channel)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"status": "failed",
|
||||
"message": (
|
||||
"Unable to complete a new WeChat login. "
|
||||
"Start again and scan with the account you want to connect."
|
||||
),
|
||||
}
|
||||
if not session.channel.connect_load_state():
|
||||
self._sessions.pop(session_id, None)
|
||||
await self._close_channel(session.channel)
|
||||
@@ -247,7 +234,7 @@ class WeixinConnectStore:
|
||||
}
|
||||
try:
|
||||
session.qrcode_id, session.qr_url = (
|
||||
await session.channel.connect_fetch_qr_code(force=session.force)
|
||||
await session.channel.connect_fetch_qr_code()
|
||||
)
|
||||
except Exception as exc:
|
||||
self._sessions.pop(session_id, None)
|
||||
|
||||
@@ -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)
|
||||
@@ -755,9 +726,9 @@ class WeixinChannel(BaseChannel):
|
||||
break
|
||||
return tokens
|
||||
|
||||
async def _fetch_qr_code(self, *, force: bool = False) -> tuple[str, str]:
|
||||
"""Fetch a QR code without existing credentials when forced."""
|
||||
local_tokens = [] if force else self._local_token_list()
|
||||
async def _fetch_qr_code(self) -> tuple[str, str]:
|
||||
"""Fetch a fresh QR code. Returns (qrcode_id, scan_url)."""
|
||||
local_tokens = self._local_token_list()
|
||||
data = await self._api_post(
|
||||
"ilink/bot/get_bot_qrcode?bot_type=3",
|
||||
{"local_token_list": local_tokens},
|
||||
@@ -784,11 +755,11 @@ class WeixinChannel(BaseChannel):
|
||||
raise RuntimeError(f"Failed to get QR code from WeChat API: {data}")
|
||||
return qrcode_id, (qrcode_img_content or qrcode_id)
|
||||
|
||||
async def _qr_login(self, *, force: bool = False) -> bool:
|
||||
"""Perform QR login; forced flows accept only newly confirmed credentials."""
|
||||
async def _qr_login(self) -> bool:
|
||||
"""Perform QR code login flow. Returns True on success."""
|
||||
try:
|
||||
refresh_count = 0
|
||||
qrcode_id, scan_url = await self._fetch_qr_code(force=force)
|
||||
qrcode_id, scan_url = await self._fetch_qr_code()
|
||||
self._print_qr_code(scan_url)
|
||||
current_poll_base_url = self.config.base_url
|
||||
verify_code = ""
|
||||
@@ -854,16 +825,11 @@ class WeixinChannel(BaseChannel):
|
||||
if refresh_count > MAX_QR_REFRESH_COUNT:
|
||||
self.logger.warning("WeChat verification failed too many times")
|
||||
return False
|
||||
qrcode_id, scan_url = await self._fetch_qr_code(force=force)
|
||||
qrcode_id, scan_url = await self._fetch_qr_code()
|
||||
current_poll_base_url = self.config.base_url
|
||||
self._print_qr_code(scan_url)
|
||||
continue
|
||||
elif status == "binded_redirect":
|
||||
if force:
|
||||
self.logger.error(
|
||||
"Forced WeChat login returned an existing binding without new credentials"
|
||||
)
|
||||
return False
|
||||
if self._token or self._load_state():
|
||||
self.logger.info("WeChat account is already connected")
|
||||
return True
|
||||
@@ -880,7 +846,7 @@ class WeixinChannel(BaseChannel):
|
||||
MAX_QR_REFRESH_COUNT,
|
||||
)
|
||||
return False
|
||||
qrcode_id, scan_url = await self._fetch_qr_code(force=force)
|
||||
qrcode_id, scan_url = await self._fetch_qr_code()
|
||||
current_poll_base_url = self.config.base_url
|
||||
verify_code = ""
|
||||
self._print_qr_code(scan_url)
|
||||
@@ -927,8 +893,8 @@ class WeixinChannel(BaseChannel):
|
||||
self._client = self._new_http_client(httpx.Timeout(60, connect=30))
|
||||
self._running = True
|
||||
|
||||
async def connect_fetch_qr_code(self, *, force: bool = False) -> tuple[str, str]:
|
||||
return await self._fetch_qr_code(force=force)
|
||||
async def connect_fetch_qr_code(self) -> tuple[str, str]:
|
||||
return await self._fetch_qr_code()
|
||||
|
||||
async def connect_poll_qr_code(
|
||||
self,
|
||||
@@ -981,14 +947,14 @@ class WeixinChannel(BaseChannel):
|
||||
if force:
|
||||
self._token = ""
|
||||
self._get_updates_buf = ""
|
||||
if self._token or (not force and self._load_state()):
|
||||
if self._token or self._load_state():
|
||||
return True
|
||||
|
||||
# Initialize HTTP client for the login flow
|
||||
self._client = self._new_http_client(httpx.Timeout(60, connect=30))
|
||||
self._running = True # Enable polling loop in _qr_login()
|
||||
try:
|
||||
return await self._qr_login(force=force)
|
||||
return await self._qr_login()
|
||||
finally:
|
||||
self._running = False
|
||||
if self._client:
|
||||
|
||||
@@ -25,9 +25,7 @@ async def test_weixin_connect_store_saves_confirmed_qr_login(
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
async def fake_fetch_qr_code(
|
||||
self: WeixinChannel, **_kwargs: Any
|
||||
) -> tuple[str, str]:
|
||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||
return "qr-1", "https://qr.example/1"
|
||||
|
||||
async def fake_api_get_with_base(
|
||||
@@ -66,63 +64,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(
|
||||
@@ -145,31 +86,14 @@ async def test_weixin_reconnect_keeps_existing_account_until_scan_succeeds(
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
observed_force: list[bool] = []
|
||||
|
||||
async def fake_fetch_qr_code(
|
||||
self: WeixinChannel,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> tuple[str, str]:
|
||||
observed_force.append(force)
|
||||
return f"qr-reconnect-{len(observed_force)}", "https://qr.example/reconnect"
|
||||
|
||||
async def fake_api_get_with_base(
|
||||
self: WeixinChannel,
|
||||
**_kwargs: Any,
|
||||
) -> dict[str, str]:
|
||||
return {"status": "expired"}
|
||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||
return "qr-reconnect", "https://qr.example/reconnect"
|
||||
|
||||
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(force=True)
|
||||
refreshed = await store.poll(started["session_id"])
|
||||
|
||||
assert refreshed["status"] == "pending"
|
||||
assert observed_force == [True, True]
|
||||
assert json.loads(state_file.read_text(encoding="utf-8")) == existing
|
||||
cancelled = await store.cancel(started["session_id"])
|
||||
assert cancelled["status"] == "cancelled"
|
||||
@@ -192,9 +116,7 @@ async def test_weixin_cancel_wins_over_inflight_confirmation(
|
||||
poll_started = asyncio.Event()
|
||||
release_poll = asyncio.Event()
|
||||
|
||||
async def fake_fetch_qr_code(
|
||||
self: WeixinChannel, **_kwargs: Any
|
||||
) -> tuple[str, str]:
|
||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||
return "qr-cancel", "https://qr.example/cancel"
|
||||
|
||||
async def fake_api_get_with_base(
|
||||
@@ -240,9 +162,7 @@ async def test_weixin_connect_store_handles_verification_code(
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
async def fake_fetch_qr_code(
|
||||
self: WeixinChannel, **_kwargs: Any
|
||||
) -> tuple[str, str]:
|
||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||
return "qr-verify", "https://qr.example/verify"
|
||||
|
||||
responses = [
|
||||
@@ -284,7 +204,7 @@ async def test_weixin_connect_store_handles_verification_code(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weixin_connect_store_rejects_existing_binding_during_forced_login(
|
||||
async def test_weixin_connect_store_treats_existing_binding_as_success(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -301,12 +221,7 @@ async def test_weixin_connect_store_rejects_existing_binding_during_forced_login
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
async def fake_fetch_qr_code(
|
||||
self: WeixinChannel,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> tuple[str, str]:
|
||||
assert force is True
|
||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||
return "qr-existing", "https://qr.example/existing"
|
||||
|
||||
async def fake_api_get_with_base(
|
||||
@@ -322,8 +237,8 @@ async def test_weixin_connect_store_rejects_existing_binding_during_forced_login
|
||||
started = await store.start(force=True)
|
||||
completed = await store.poll(started["session_id"])
|
||||
|
||||
assert completed["status"] == "failed"
|
||||
assert "new WeChat login" in completed["message"]
|
||||
assert completed["status"] == "succeeded"
|
||||
assert "already connected" in completed["message"]
|
||||
assert json.loads((state_dir / "account.json").read_text())["token"] == "working-token"
|
||||
|
||||
|
||||
@@ -340,9 +255,7 @@ async def test_weixin_connect_store_rejects_existing_binding_without_local_crede
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
async def fake_fetch_qr_code(
|
||||
self: WeixinChannel, **_kwargs: Any
|
||||
) -> tuple[str, str]:
|
||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||
return "qr-missing", "https://qr.example/missing"
|
||||
|
||||
async def fake_api_get_with_base(
|
||||
@@ -355,7 +268,7 @@ async def test_weixin_connect_store_rejects_existing_binding_without_local_crede
|
||||
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
|
||||
|
||||
store = WeixinConnectStore()
|
||||
started = await store.start(force=False)
|
||||
started = await store.start(force=True)
|
||||
completed = await store.poll(started["session_id"])
|
||||
|
||||
assert completed["status"] == "failed"
|
||||
|
||||
@@ -196,86 +196,6 @@ def test_save_state_with_empty_runtime_token_preserves_persisted_account(tmp_pat
|
||||
assert json.loads((tmp_path / "account.json").read_text()) == persisted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_force_ignores_persisted_account_through_qr_flow(tmp_path) -> None:
|
||||
persisted = {
|
||||
"token": "persisted-token",
|
||||
"get_updates_buf": "persisted-cursor",
|
||||
"context_tokens": {"wx-user": "ctx-persisted"},
|
||||
"typing_tickets": {"wx-user": {"ticket": "ticket-persisted"}},
|
||||
"base_url": "https://persisted.example",
|
||||
}
|
||||
channel = WeixinChannel(
|
||||
WeixinConfig(
|
||||
enabled=True,
|
||||
allow_from=["*"],
|
||||
token="configured-token",
|
||||
state_dir=str(tmp_path),
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
(tmp_path / "account.json").write_text(
|
||||
json.dumps(persisted),
|
||||
encoding="utf-8",
|
||||
)
|
||||
channel._print_qr_code = lambda _url: None
|
||||
channel._api_post = AsyncMock(
|
||||
side_effect=[
|
||||
{"qrcode": "qr-1", "qrcode_img_content": "url-1"},
|
||||
{"qrcode": "qr-2", "qrcode_img_content": "url-2"},
|
||||
]
|
||||
)
|
||||
channel._api_get_with_base = AsyncMock(
|
||||
side_effect=[
|
||||
{"status": "expired"},
|
||||
{"status": "binded_redirect"},
|
||||
]
|
||||
)
|
||||
|
||||
ok = await channel.login(force=True)
|
||||
|
||||
assert ok is False
|
||||
assert [call.args[1]["local_token_list"] for call in channel._api_post.await_args_list] == [
|
||||
[],
|
||||
[],
|
||||
]
|
||||
assert channel._token == ""
|
||||
assert channel._get_updates_buf == ""
|
||||
assert channel._context_tokens == {}
|
||||
assert channel._typing_tickets == {}
|
||||
assert channel.config.base_url == "https://ilinkai.weixin.qq.com"
|
||||
assert json.loads((tmp_path / "account.json").read_text()) == persisted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_without_force_reuses_persisted_account(tmp_path) -> None:
|
||||
channel = WeixinChannel(
|
||||
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
||||
MessageBus(),
|
||||
)
|
||||
(tmp_path / "account.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"token": "persisted-token",
|
||||
"get_updates_buf": "persisted-cursor",
|
||||
"context_tokens": {"wx-user": "ctx-persisted"},
|
||||
"base_url": "https://persisted.example",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
channel._qr_login = AsyncMock(return_value=False)
|
||||
|
||||
ok = await channel.login(force=False)
|
||||
|
||||
assert ok is True
|
||||
channel._qr_login.assert_not_awaited()
|
||||
assert channel._token == "persisted-token"
|
||||
assert channel._get_updates_buf == "persisted-cursor"
|
||||
assert channel._context_tokens == {"wx-user": "ctx-persisted"}
|
||||
assert channel.config.base_url == "https://persisted.example"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_deduplicates_inbound_ids() -> None:
|
||||
channel, bus = _make_channel()
|
||||
|
||||
@@ -27,16 +27,33 @@ import type {
|
||||
NanobotFeatureInfo,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
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,
|
||||
@@ -47,7 +64,6 @@ export function WeixinPanel({
|
||||
onAction,
|
||||
onFeaturesUpdate,
|
||||
}: ChannelPluginPanelProps) {
|
||||
const { client } = useClient();
|
||||
const { t, i18n } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const channelTx = channelTranslator(t, "weixin");
|
||||
@@ -134,7 +150,7 @@ export function WeixinPanel({
|
||||
setSaveState("idle");
|
||||
try {
|
||||
const payload = await configureChannel(
|
||||
client,
|
||||
context.token,
|
||||
"weixin",
|
||||
channelValuesForSave(editableFieldsRef.current, values),
|
||||
{ enable: context.enabled },
|
||||
@@ -152,7 +168,7 @@ export function WeixinPanel({
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [client]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
||||
@@ -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;
|
||||
+2
-17
@@ -13,8 +13,6 @@ from rich.console import Console
|
||||
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.outbound_events import (
|
||||
StreamDeltaEvent,
|
||||
StreamedResponseEvent,
|
||||
@@ -86,8 +84,6 @@ def agent(
|
||||
# Create cron service with workspace-scoped store
|
||||
cron_store_path = runtime_config.workspace_path / "cron" / "jobs.json"
|
||||
cron = CronService(cron_store_path)
|
||||
tools = ToolRegistry()
|
||||
mcp_provider = MCPProvider.from_config(runtime_config, tools)
|
||||
|
||||
_set_nanobot_logs(logs)
|
||||
|
||||
@@ -99,7 +95,6 @@ def agent(
|
||||
cron_service=cron,
|
||||
image_generation_provider_configs=image_gen_provider_configs(runtime_config),
|
||||
hook_factories=[create_file_edit_activity_hook],
|
||||
tool_registry=tools,
|
||||
)
|
||||
except ValueError as exc:
|
||||
_print_agent_start_error(exc)
|
||||
@@ -111,12 +106,6 @@ def agent(
|
||||
render_markdown=False,
|
||||
)
|
||||
|
||||
async def _close_runtime() -> None:
|
||||
try:
|
||||
await agent_loop.aclose()
|
||||
finally:
|
||||
await mcp_provider.aclose()
|
||||
|
||||
# Shared reference for progress callbacks
|
||||
_thinking: ThinkingSpinner | None = None
|
||||
|
||||
@@ -160,8 +149,6 @@ def agent(
|
||||
if message:
|
||||
# Single message mode — direct call, no bus needed
|
||||
async def run_once() -> None:
|
||||
try:
|
||||
await mcp_provider.connect()
|
||||
renderer = StreamRenderer(
|
||||
render_markdown=markdown,
|
||||
bot_name=runtime_config.agents.defaults.bot_name,
|
||||
@@ -185,8 +172,7 @@ def agent(
|
||||
metadata=response.metadata if response else None,
|
||||
**print_kwargs,
|
||||
)
|
||||
finally:
|
||||
await _close_runtime()
|
||||
await agent_loop.close_mcp()
|
||||
|
||||
asyncio.run(run_once())
|
||||
else:
|
||||
@@ -223,7 +209,6 @@ def agent(
|
||||
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
|
||||
|
||||
async def run_interactive() -> None:
|
||||
await mcp_provider.connect()
|
||||
bus_task = asyncio.create_task(agent_loop.run())
|
||||
turn_done = asyncio.Event()
|
||||
turn_done.set()
|
||||
@@ -362,6 +347,6 @@ def agent(
|
||||
agent_loop.stop()
|
||||
outbound_task.cancel()
|
||||
await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
|
||||
await _close_runtime()
|
||||
await agent_loop.close_mcp()
|
||||
|
||||
asyncio.run(run_interactive())
|
||||
|
||||
+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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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 (
|
||||
@@ -235,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,
|
||||
@@ -243,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.
|
||||
@@ -270,14 +272,10 @@ async def _close_gateway_runtime(
|
||||
task.cancel()
|
||||
if runtime_tasks is not None and not runtime_tasks.done():
|
||||
runtime_tasks.cancel()
|
||||
for label, close in (
|
||||
("agent", agent.aclose),
|
||||
("MCP provider", mcp_provider.aclose),
|
||||
):
|
||||
try:
|
||||
await asyncio.wait_for(close(), timeout=close_timeout)
|
||||
await asyncio.wait_for(agent.close_mcp(), timeout=close_timeout)
|
||||
except BaseException as exc: # noqa: BLE001 - shutdown must proceed
|
||||
logger.warning("Gateway shutdown: {} cleanup incomplete: {}", label, exc)
|
||||
logger.warning("Gateway shutdown: agent resource cleanup incomplete: {}", exc)
|
||||
# Retrieving an already-finished gather prevents noisy unhandled exceptions,
|
||||
# but never wait for it here: its children were bounded individually above.
|
||||
if runtime_tasks is not None and runtime_tasks.done():
|
||||
@@ -416,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,
|
||||
@@ -436,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))
|
||||
@@ -518,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,
|
||||
@@ -596,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",
|
||||
@@ -676,10 +668,7 @@ def _run_gateway(
|
||||
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_skill_state_action=_webui_skill_state_action,
|
||||
config_path=Path(config_path),
|
||||
)
|
||||
|
||||
def _pick_heartbeat_target() -> tuple[str, str]:
|
||||
@@ -853,13 +842,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()
|
||||
|
||||
tasks = [
|
||||
asyncio.create_task(
|
||||
watch_config_file(
|
||||
@@ -868,7 +850,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(
|
||||
@@ -926,13 +908,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.).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+17
-13
@@ -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
|
||||
@@ -12,7 +12,9 @@ from nanobot.config_base import Base
|
||||
from nanobot.cron.types import CronSchedule
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.tools.browser_tool import BrowserToolConfig
|
||||
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
|
||||
from nanobot.agent.tools.computer_use import ComputerUseToolConfig
|
||||
from nanobot.agent.tools.filesystem import FileToolsConfig
|
||||
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
|
||||
from nanobot.agent.tools.self import MyToolConfig
|
||||
@@ -373,7 +375,6 @@ class MCPServerConfig(Base):
|
||||
"""MCP server connection configuration (stdio or HTTP)."""
|
||||
|
||||
type: Literal["stdio", "sse", "streamableHttp"] | None = None # auto-detected if omitted
|
||||
auth: Literal["oauth"] | None = None # Remote MCP OAuth; tokens are stored outside config
|
||||
command: str = "" # Stdio: command to run (e.g. "npx")
|
||||
args: list[str] = Field(default_factory=list) # Stdio: command arguments
|
||||
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
|
||||
@@ -400,6 +401,16 @@ class ToolsConfig(Base):
|
||||
"""
|
||||
|
||||
web: WebToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.web", "WebToolsConfig"))
|
||||
browser: BrowserToolConfig = Field(
|
||||
default_factory=lambda: _lazy_default(
|
||||
"nanobot.agent.tools.browser_tool", "BrowserToolConfig"
|
||||
)
|
||||
)
|
||||
computer_use: ComputerUseToolConfig = Field(
|
||||
default_factory=lambda: _lazy_default(
|
||||
"nanobot.agent.tools.computer_use", "ComputerUseToolConfig"
|
||||
)
|
||||
)
|
||||
exec: ExecToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.shell", "ExecToolConfig"))
|
||||
file: FileToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.filesystem", "FileToolsConfig"))
|
||||
cli_apps: CliAppsToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.cli_apps", "CliAppsToolConfig"))
|
||||
@@ -431,8 +442,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,15 +460,6 @@ 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":
|
||||
if "default" in self.model_presets:
|
||||
@@ -682,7 +682,9 @@ def _resolve_tool_config_refs() -> None:
|
||||
"""
|
||||
import sys
|
||||
|
||||
from nanobot.agent.tools.browser_tool import BrowserToolConfig
|
||||
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
|
||||
from nanobot.agent.tools.computer_use import ComputerUseToolConfig
|
||||
from nanobot.agent.tools.filesystem import FileToolsConfig
|
||||
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
|
||||
from nanobot.agent.tools.self import MyToolConfig
|
||||
@@ -692,6 +694,8 @@ def _resolve_tool_config_refs() -> None:
|
||||
# Re-export into this module's namespace
|
||||
mod = sys.modules[__name__]
|
||||
mod.ExecToolConfig = ExecToolConfig # type: ignore[attr-defined]
|
||||
mod.BrowserToolConfig = BrowserToolConfig # type: ignore[attr-defined]
|
||||
mod.ComputerUseToolConfig = ComputerUseToolConfig # type: ignore[attr-defined]
|
||||
mod.FileToolsConfig = FileToolsConfig # type: ignore[attr-defined]
|
||||
mod.CliAppsToolConfig = CliAppsToolConfig # type: ignore[attr-defined]
|
||||
mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined]
|
||||
|
||||
+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
|
||||
|
||||
@@ -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 (
|
||||
@@ -449,28 +447,6 @@ def _merge_unique_list(base: object, override: object) -> object:
|
||||
return result
|
||||
|
||||
|
||||
def _merge_chat_extra_body(
|
||||
kwargs: dict[str, Any],
|
||||
extra_body: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Merge configured Chat Completions fields without clobbering tools."""
|
||||
regular_extra = {key: value for key, value in extra_body.items() if key != "tools"}
|
||||
merged = dict(kwargs)
|
||||
if regular_extra:
|
||||
existing = kwargs.get("extra_body", {})
|
||||
merged["extra_body"] = _deep_merge(existing, regular_extra)
|
||||
|
||||
if "tools" in extra_body:
|
||||
current_tools = kwargs.get("tools")
|
||||
configured_tools = extra_body["tools"]
|
||||
if isinstance(current_tools, list) and isinstance(configured_tools, list):
|
||||
merged["tools"] = [*current_tools, *configured_tools]
|
||||
else:
|
||||
merged["tools"] = configured_tools
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def _merge_responses_extra_body(
|
||||
body: dict[str, Any],
|
||||
extra_body: dict[str, Any],
|
||||
@@ -525,6 +501,9 @@ class OpenAICompatProvider(LLMProvider):
|
||||
self._proxy = proxy or None
|
||||
self._native_compaction_available = True
|
||||
|
||||
if api_key and spec and spec.env_key:
|
||||
self._setup_env(api_key, api_base)
|
||||
|
||||
effective_base = api_base or (spec.default_api_base if spec else None) or None
|
||||
self._effective_base = effective_base
|
||||
self._default_headers = {"x-session-affinity": uuid.uuid4().hex}
|
||||
@@ -617,6 +596,20 @@ class OpenAICompatProvider(LLMProvider):
|
||||
raise RuntimeError("OpenAI client initialization did not produce a client")
|
||||
return self._client
|
||||
|
||||
def _setup_env(self, api_key: str, api_base: str | None) -> None:
|
||||
"""Set environment variables based on provider spec."""
|
||||
spec = self._spec
|
||||
if not spec or not spec.env_key:
|
||||
return
|
||||
if spec.is_gateway:
|
||||
os.environ[spec.env_key] = api_key
|
||||
else:
|
||||
os.environ.setdefault(spec.env_key, api_key)
|
||||
effective_base = api_base or spec.default_api_base
|
||||
for env_name, env_val in spec.env_extras:
|
||||
resolved = env_val.replace("{api_key}", api_key).replace("{api_base}", effective_base)
|
||||
os.environ.setdefault(env_name, resolved)
|
||||
|
||||
@classmethod
|
||||
def _apply_cache_control(
|
||||
cls,
|
||||
@@ -678,6 +671,73 @@ class OpenAICompatProvider(LLMProvider):
|
||||
dumped = str(content)
|
||||
return dumped or "(empty)"
|
||||
|
||||
@classmethod
|
||||
def _move_tool_images_to_user(
|
||||
cls,
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Adapt multimodal tool results to Chat Completions' text-only tool role."""
|
||||
updated: list[dict[str, Any]] = []
|
||||
pending_images: list[dict[str, Any]] = []
|
||||
|
||||
def flush_images(next_message: dict[str, Any] | None = None) -> None:
|
||||
if not pending_images:
|
||||
if next_message is not None:
|
||||
updated.append(next_message)
|
||||
return
|
||||
content: list[dict[str, Any]] = [
|
||||
*pending_images,
|
||||
{"type": "text", "text": "Images returned by the preceding tool call(s)."},
|
||||
]
|
||||
pending_images.clear()
|
||||
if next_message is not None and next_message.get("role") == "user":
|
||||
existing = next_message.get("content")
|
||||
if isinstance(existing, str):
|
||||
content.append({"type": "text", "text": existing})
|
||||
elif isinstance(existing, list):
|
||||
content.extend(cast(list[dict[str, Any]], existing))
|
||||
updated.append({**next_message, "content": content})
|
||||
else:
|
||||
updated.append({"role": "user", "content": content})
|
||||
if next_message is not None:
|
||||
updated.append(next_message)
|
||||
|
||||
for message in messages:
|
||||
content = message.get("content")
|
||||
if message.get("role") == "tool" and isinstance(content, list):
|
||||
blocks = cast(list[object], content)
|
||||
images: list[dict[str, Any]] = []
|
||||
text_blocks: list[object] = []
|
||||
for block in blocks:
|
||||
if isinstance(block, dict):
|
||||
block_data = cast(dict[str, Any], block)
|
||||
image_url = block_data.get("image_url")
|
||||
if block_data.get("type") == "image_url" and isinstance(
|
||||
image_url, dict
|
||||
):
|
||||
images.append({"type": "image_url", "image_url": image_url})
|
||||
continue
|
||||
text_blocks.append(block_data)
|
||||
else:
|
||||
text_blocks.append(block)
|
||||
if images:
|
||||
updated.append({
|
||||
**message,
|
||||
"content": (
|
||||
cls._coerce_content_to_string(text_blocks)
|
||||
if text_blocks
|
||||
else "(image returned)"
|
||||
),
|
||||
})
|
||||
pending_images.extend(images)
|
||||
continue
|
||||
if message.get("role") != "tool":
|
||||
flush_images(message)
|
||||
else:
|
||||
updated.append(message)
|
||||
flush_images()
|
||||
return updated
|
||||
|
||||
def _sanitize_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Strip non-standard keys, normalize tool_call IDs."""
|
||||
sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS)
|
||||
@@ -692,8 +752,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 +829,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
|
||||
# ------------------------------------------------------------------
|
||||
@@ -908,9 +891,10 @@ class OpenAICompatProvider(LLMProvider):
|
||||
|
||||
model_name = self._request_model_name(model_name)
|
||||
|
||||
sanitized_messages = self._sanitize_messages(self._sanitize_empty_content(messages))
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model_name,
|
||||
"messages": self._sanitize_messages(self._sanitize_empty_content(messages)),
|
||||
"messages": self._move_tool_images_to_user(sanitized_messages),
|
||||
}
|
||||
|
||||
# GPT-5 and reasoning models (o1/o3/o4) reject temperature when
|
||||
@@ -1052,11 +1036,14 @@ class OpenAICompatProvider(LLMProvider):
|
||||
if msg.get("role") == "assistant" and "reasoning_content" not in msg:
|
||||
msg["reasoning_content"] = ""
|
||||
|
||||
# Merge user-configured extra_body last so ordinary fields can override
|
||||
# provider defaults. Keep configured tools at the top level: the SDK
|
||||
# otherwise lets extra_body.tools replace nanobot's generated functions.
|
||||
# Merge user-configured extra_body last so it can override or
|
||||
# extend provider-specific defaults (e.g. chat_template_kwargs,
|
||||
# guided_json, repetition_penalty). Uses recursive merge so
|
||||
# nested dicts like {"chat_template_kwargs": {"enable_thinking": false}}
|
||||
# do not clobber sibling keys already set by thinking-style logic.
|
||||
if self._extra_body:
|
||||
kwargs = _merge_chat_extra_body(kwargs, self._extra_body)
|
||||
existing = kwargs.get("extra_body", {})
|
||||
kwargs["extra_body"] = _deep_merge(existing, self._extra_body)
|
||||
|
||||
return kwargs
|
||||
|
||||
@@ -1237,8 +1224,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 +1260,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
|
||||
@@ -481,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
|
||||
|
||||
+5
-477
@@ -2,12 +2,9 @@
|
||||
|
||||
import base64
|
||||
import errno
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import stat
|
||||
from collections import OrderedDict
|
||||
from contextlib import suppress
|
||||
from copy import deepcopy
|
||||
@@ -17,10 +14,9 @@ from pathlib import Path
|
||||
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,
|
||||
@@ -61,11 +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
|
||||
_COPY_CHUNK_SIZE = 1024 * 1024
|
||||
|
||||
|
||||
def _json_object(value: object) -> dict[str, Any]:
|
||||
@@ -512,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: ...
|
||||
|
||||
@@ -546,445 +520,9 @@ class SessionStore(Protocol):
|
||||
class JsonlSessionStore:
|
||||
"""JSONL implementation of session persistence."""
|
||||
|
||||
def __init__(self, workspace: Path, *, sessions_root: Path | None = None):
|
||||
canonical_workspace = Path(workspace).expanduser().resolve(strict=False)
|
||||
ensure_dir(canonical_workspace)
|
||||
root = (
|
||||
Path(sessions_root).expanduser().resolve(strict=False)
|
||||
if sessions_root is not None
|
||||
else get_runtime_subdir("sessions").resolve(strict=False)
|
||||
)
|
||||
if root == canonical_workspace or root.is_relative_to(canonical_workspace):
|
||||
raise RuntimeError(
|
||||
"session storage must be outside the agent workspace; "
|
||||
"move --config outside --workspace or choose a nested workspace directory"
|
||||
)
|
||||
ensure_dir(root)
|
||||
with suppress(OSError):
|
||||
os.chmod(root, 0o700)
|
||||
self.workspace = canonical_workspace
|
||||
self._migration_lock = FileLock(
|
||||
str(root / ".workspace-migration.lock"),
|
||||
timeout=_SESSION_MIGRATION_LOCK_TIMEOUT_SECONDS,
|
||||
)
|
||||
with self._migration_lock:
|
||||
workspace_id = self._load_or_create_workspace_id(canonical_workspace, root)
|
||||
workspace_id = self._claim_workspace_namespace(
|
||||
root,
|
||||
canonical_workspace,
|
||||
workspace_id,
|
||||
)
|
||||
self.sessions_dir = ensure_dir(root / workspace_id)
|
||||
def __init__(self, workspace: Path):
|
||||
self.sessions_dir = ensure_dir(workspace / "sessions")
|
||||
self.legacy_sessions_dir = get_legacy_sessions_dir()
|
||||
self._migrate_from_workspace(canonical_workspace)
|
||||
|
||||
@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:
|
||||
for src in self.sessions_dir.glob("*.jsonl"):
|
||||
if self.session_key_from_path(src) is None:
|
||||
continue
|
||||
source_snapshot = self._session_file_snapshot(src)
|
||||
if source_snapshot is None:
|
||||
conflicts.append(src)
|
||||
continue
|
||||
dst = old_dir / src.name
|
||||
if dst.exists():
|
||||
destination_snapshot = self._session_file_snapshot(dst)
|
||||
if (
|
||||
destination_snapshot is not None
|
||||
and destination_snapshot.digest == source_snapshot.digest
|
||||
):
|
||||
unchanged += 1
|
||||
else:
|
||||
conflicts.append(dst)
|
||||
continue
|
||||
self._install_snapshot(src, dst, source_snapshot)
|
||||
restored += 1
|
||||
return SessionRestoreResult(
|
||||
restored=restored,
|
||||
unchanged=unchanged,
|
||||
conflicts=tuple(conflicts),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def safe_key(key: str) -> str:
|
||||
@@ -1453,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
|
||||
@@ -1627,10 +1159,6 @@ class SessionManager:
|
||||
self.invalidate(key)
|
||||
return self._store.delete(key)
|
||||
|
||||
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()
|
||||
|
||||
def fork_session_before_user_index(
|
||||
self,
|
||||
source_key: str,
|
||||
|
||||
@@ -11,39 +11,23 @@ Two free services, no API keys needed.
|
||||
|
||||
## wttr.in (primary)
|
||||
|
||||
Choose one request that matches the user's scope. Do not fetch current
|
||||
conditions separately when a today or forecast request already includes them.
|
||||
|
||||
Platform notes:
|
||||
- On Windows PowerShell, use `curl.exe`; bare `curl` may resolve to
|
||||
`Invoke-WebRequest`.
|
||||
- On macOS and Linux, use `curl`.
|
||||
|
||||
Current conditions only:
|
||||
Quick one-liner:
|
||||
```bash
|
||||
curl -s "https://wttr.in/London?format=3"
|
||||
curl -s "wttr.in/London?format=3"
|
||||
# Output: London: ⛅️ +8°C
|
||||
```
|
||||
|
||||
Custom current conditions format:
|
||||
Compact format:
|
||||
```bash
|
||||
curl -s "https://wttr.in/London?format=%l:+%c+%t+%h+%w"
|
||||
curl -s "wttr.in/London?format=%l:+%c+%t+%h+%w"
|
||||
# Output: London: ⛅️ +8°C 71% ↙5km/h
|
||||
```
|
||||
|
||||
Today's weather, including current conditions (use this single request for
|
||||
questions about today's weather):
|
||||
```bash
|
||||
curl -s "https://wttr.in/London?1&m"
|
||||
```
|
||||
|
||||
Full forecast:
|
||||
```bash
|
||||
curl -s "https://wttr.in/London?T&m"
|
||||
curl -s "wttr.in/London?T"
|
||||
```
|
||||
|
||||
On Windows PowerShell, replace `curl` with `curl.exe` in the commands above.
|
||||
|
||||
Format codes: `%c` condition · `%t` temp · `%h` humidity · `%w` wind · `%l` location · `%m` moon
|
||||
|
||||
Tips:
|
||||
@@ -51,8 +35,7 @@ Tips:
|
||||
- Airport codes: `wttr.in/JFK`
|
||||
- Units: `?m` (metric) `?u` (USCS)
|
||||
- Today only: `?1` · Current only: `?0`
|
||||
- PNG (macOS/Linux): `curl -s "https://wttr.in/Berlin.png" -o weather.png`
|
||||
- PNG (Windows PowerShell): `curl.exe -s "https://wttr.in/Berlin.png" -o weather.png`
|
||||
- PNG: `curl -s "wttr.in/Berlin.png" -o /tmp/weather.png`
|
||||
|
||||
## Open-Meteo (fallback, JSON)
|
||||
|
||||
|
||||
@@ -356,6 +356,7 @@ _TOOL_RESULT_PREVIEW_CHARS = 1200
|
||||
_TOOL_RESULTS_DIR = ".nanobot/tool-results"
|
||||
_TOOL_RESULT_RETENTION_SECS = 7 * 24 * 60 * 60
|
||||
_TOOL_RESULT_MAX_BUCKETS = 32
|
||||
_IMAGE_TOKEN_ESTIMATE = 2048
|
||||
_TRUNCATED_SUFFIX = "\n... (truncated)"
|
||||
|
||||
|
||||
@@ -676,6 +677,7 @@ def _estimate_prompt_tokens_with_source(
|
||||
reasoning_content, tool_call_id, name, plus per-message framing overhead.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
image_tokens = 0
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
@@ -687,6 +689,8 @@ def _estimate_prompt_tokens_with_source(
|
||||
text = part.get("text", "")
|
||||
if isinstance(text, str) and text:
|
||||
parts.append(text)
|
||||
elif part is not None and part.get("type") in {"image_url", "input_image"}:
|
||||
image_tokens += _IMAGE_TOKEN_ESTIMATE
|
||||
|
||||
tc = msg.get("tool_calls")
|
||||
if tc:
|
||||
@@ -709,7 +713,7 @@ def _estimate_prompt_tokens_with_source(
|
||||
_estimate_tools_tokens(enc, tools, leading_separator=bool(parts)) if tools else 0
|
||||
)
|
||||
message_tokens = len(enc.encode(message_payload)) if message_payload else 0
|
||||
return message_tokens + tool_tokens + per_message_overhead, "tiktoken"
|
||||
return message_tokens + image_tokens + tool_tokens + per_message_overhead, "tiktoken"
|
||||
except Exception:
|
||||
tool_payload = (
|
||||
("\n" if message_payload else "") + json.dumps(tools, ensure_ascii=False)
|
||||
@@ -718,7 +722,7 @@ def _estimate_prompt_tokens_with_source(
|
||||
)
|
||||
payload = message_payload + tool_payload
|
||||
estimated = len(payload.encode("utf-8"))
|
||||
return estimated + per_message_overhead, "heuristic"
|
||||
return estimated + image_tokens + per_message_overhead, "heuristic"
|
||||
|
||||
|
||||
def estimate_prompt_tokens(
|
||||
@@ -734,6 +738,7 @@ def estimate_message_tokens(message: dict[str, Any]) -> int:
|
||||
"""Estimate prompt tokens contributed by one persisted message."""
|
||||
content = message.get("content")
|
||||
parts: list[str] = []
|
||||
image_tokens = 0
|
||||
if isinstance(content, str):
|
||||
parts.append(content)
|
||||
elif isinstance(content, list):
|
||||
@@ -743,6 +748,8 @@ def estimate_message_tokens(message: dict[str, Any]) -> int:
|
||||
text = part.get("text", "")
|
||||
if isinstance(text, str) and text:
|
||||
parts.append(text)
|
||||
elif part is not None and part.get("type") in {"image_url", "input_image"}:
|
||||
image_tokens += _IMAGE_TOKEN_ESTIMATE
|
||||
else:
|
||||
parts.append(json.dumps(raw_part, ensure_ascii=False))
|
||||
elif content is not None:
|
||||
@@ -760,13 +767,13 @@ def estimate_message_tokens(message: dict[str, Any]) -> int:
|
||||
parts.append(rc)
|
||||
|
||||
payload = "\n".join(parts)
|
||||
if not payload:
|
||||
if not payload and not image_tokens:
|
||||
return 4
|
||||
try:
|
||||
enc = _get_token_encoding()
|
||||
return max(4, len(enc.encode(payload)) + 4)
|
||||
return max(4, len(enc.encode(payload)) + image_tokens + 4)
|
||||
except Exception:
|
||||
return max(4, len(payload.encode("utf-8")) + 4)
|
||||
return max(4, len(payload.encode("utf-8")) + image_tokens + 4)
|
||||
|
||||
|
||||
def estimate_prompt_tokens_chain(
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||
@@ -90,8 +89,8 @@ def _query_first(query: QueryParams, key: str) -> str | None:
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def _manager(config_path: Path | None = None) -> CliAppManager:
|
||||
config = load_config(config_path) if config_path is not None else load_config()
|
||||
def _manager() -> CliAppManager:
|
||||
config = load_config()
|
||||
cli_cfg = config.tools.cli_apps
|
||||
return CliAppManager(
|
||||
workspace=config.workspace_path,
|
||||
@@ -103,12 +102,8 @@ def _manager(config_path: Path | None = None) -> CliAppManager:
|
||||
)
|
||||
|
||||
|
||||
async def cli_apps_payload(
|
||||
*,
|
||||
installed_only: bool = False,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
manager = _manager(config_path) if config_path is not None else _manager()
|
||||
async def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]:
|
||||
manager = _manager()
|
||||
if installed_only:
|
||||
return manager.installed_payload()
|
||||
payload = manager.payload(cache_only=True)
|
||||
@@ -123,16 +118,11 @@ async def cli_apps_payload(
|
||||
return payload
|
||||
|
||||
|
||||
def cli_apps_action(
|
||||
action: str,
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
def cli_apps_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||
name = (_query_first(query, "name") or "").strip()
|
||||
if not name:
|
||||
raise CliAppError("missing CLI app name")
|
||||
manager = _manager(config_path) if config_path is not None else _manager()
|
||||
manager = _manager()
|
||||
if action == "install":
|
||||
return manager.install(name)
|
||||
if action == "update":
|
||||
|
||||
@@ -2,18 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from loguru import logger as default_logger
|
||||
|
||||
from nanobot.config.loader import get_config_path
|
||||
from nanobot.webui.gateway_tokens import GatewayTokenStore
|
||||
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
|
||||
from nanobot.webui.media_gateway import WebUIMediaGateway
|
||||
from nanobot.webui.settings_services import WebUISettingsServices
|
||||
from nanobot.webui.temporary_chats import WebUITemporaryChats
|
||||
from nanobot.webui.transcript import WebUITranscriptRecorder
|
||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||
@@ -32,7 +29,6 @@ class GatewayServices:
|
||||
"""Explicit dependencies shared by WebSocket transport and HTTP routes."""
|
||||
|
||||
http: GatewayHTTPHandler
|
||||
settings: WebUISettingsServices
|
||||
tokens: GatewayTokenStore
|
||||
media: WebUIMediaGateway
|
||||
ingress: WebUIIngressPolicy
|
||||
@@ -54,7 +50,6 @@ def build_gateway_services(
|
||||
static_dist_path: Path | None,
|
||||
workspace_path: Path,
|
||||
default_restrict_to_workspace: bool,
|
||||
config_path: Path | None = None,
|
||||
runtime_model_name: Callable[[], str | None] | None,
|
||||
runtime_surface: str,
|
||||
runtime_capabilities_overrides: dict[str, Any] | None,
|
||||
@@ -65,12 +60,9 @@ def build_gateway_services(
|
||||
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
||||
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())
|
||||
tokens = GatewayTokenStore()
|
||||
ingress = DEFAULT_WEBUI_INGRESS_POLICY
|
||||
minimum_frame_bytes = ingress.minimum_full_policy_frame_bytes()
|
||||
@@ -110,7 +102,6 @@ def build_gateway_services(
|
||||
media=media,
|
||||
ingress=ingress,
|
||||
workspaces=workspaces,
|
||||
settings=settings,
|
||||
skills_workspace_path=workspace_path,
|
||||
disabled_skills=disabled_skills,
|
||||
cron_service=cron_service,
|
||||
@@ -119,14 +110,11 @@ def build_gateway_services(
|
||||
local_trigger_pending_ids=local_trigger_pending_ids,
|
||||
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,
|
||||
)
|
||||
return GatewayServices(
|
||||
http=http,
|
||||
settings=settings,
|
||||
tokens=tokens,
|
||||
media=media,
|
||||
ingress=ingress,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,415 +0,0 @@
|
||||
"""Gateway-owned browser authorization flows for remote MCP servers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from urllib.parse import SplitResult, parse_qs, urlsplit, urlunsplit
|
||||
|
||||
from nanobot.agent.tools.mcp import MCPConnection, connect_mcp_servers
|
||||
from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH, MCPOAuthHandlers
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.config.schema import MCPServerConfig
|
||||
from nanobot.security.network import validate_url_target
|
||||
from nanobot.webui.http_utils import is_loopback_host
|
||||
|
||||
McpReload = Callable[[], Awaitable[dict[str, Any]]]
|
||||
_FLOW_TTL_S = 300
|
||||
_START_WAIT_S = 20
|
||||
_OAUTH_ERROR_RE = re.compile(r"^[a-zA-Z0-9_.-]{1,80}$")
|
||||
|
||||
|
||||
class McpOAuthError(Exception):
|
||||
"""Safe WebUI error for an MCP OAuth request."""
|
||||
|
||||
def __init__(self, message: str, status: int = 400) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
class _OAuthCallbackError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class _McpOAuthFlow:
|
||||
flow_id: str
|
||||
name: str
|
||||
cfg: MCPServerConfig
|
||||
redirect_uri: str
|
||||
manual_callback: bool
|
||||
expires_at: float
|
||||
authorization_ready: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
callback_result: asyncio.Future[tuple[str, str | None]] | None = None
|
||||
task: asyncio.Task[bool] | None = None
|
||||
authorization_url: str | None = None
|
||||
state: str | None = None
|
||||
callback_received: bool = False
|
||||
error: str | None = None
|
||||
reload_result: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _parse_mcp_oauth_redirect_uri(redirect_uri: str) -> tuple[str, SplitResult, int | None]:
|
||||
cleaned = redirect_uri.strip()
|
||||
parsed = urlsplit(cleaned)
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise McpOAuthError("Invalid MCP OAuth callback URL") from exc
|
||||
if (
|
||||
not parsed.netloc
|
||||
or not parsed.hostname
|
||||
or parsed.path != MCP_OAUTH_CALLBACK_PATH
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
):
|
||||
raise McpOAuthError("Invalid MCP OAuth callback URL")
|
||||
return cleaned, parsed, port
|
||||
|
||||
|
||||
def validate_mcp_oauth_redirect_uri(redirect_uri: str) -> str:
|
||||
"""Allow HTTPS callbacks, plus loopback HTTP for a local gateway."""
|
||||
cleaned, parsed, _port = _parse_mcp_oauth_redirect_uri(redirect_uri)
|
||||
if parsed.scheme == "https":
|
||||
return cleaned
|
||||
if parsed.scheme == "http" and is_loopback_host(parsed.netloc):
|
||||
return cleaned
|
||||
raise McpOAuthError("MCP OAuth callbacks must use HTTPS or localhost")
|
||||
|
||||
|
||||
def prepare_mcp_oauth_redirect_uri(redirect_uri: str) -> tuple[str, bool]:
|
||||
"""Use a pasteable loopback callback when a remote WebUI is served over HTTP."""
|
||||
cleaned, parsed, port = _parse_mcp_oauth_redirect_uri(redirect_uri)
|
||||
if parsed.scheme != "http" or is_loopback_host(parsed.netloc):
|
||||
return validate_mcp_oauth_redirect_uri(cleaned), False
|
||||
|
||||
loopback = "127.0.0.1" if port is None else f"127.0.0.1:{port}"
|
||||
manual_redirect_uri = urlunsplit(("http", loopback, parsed.path, "", ""))
|
||||
return validate_mcp_oauth_redirect_uri(manual_redirect_uri), True
|
||||
|
||||
|
||||
class McpOAuthManager:
|
||||
"""Own short-lived browser flows while the gateway process is running."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._flows: dict[str, _McpOAuthFlow] = {}
|
||||
self._states: dict[str, str] = {}
|
||||
|
||||
async def start(
|
||||
self,
|
||||
name: str,
|
||||
cfg: MCPServerConfig,
|
||||
redirect_uri: str,
|
||||
*,
|
||||
reload_mcp: McpReload,
|
||||
reset_credentials: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
self._prune()
|
||||
redirect_uri, manual_callback = prepare_mcp_oauth_redirect_uri(redirect_uri)
|
||||
await self._cancel_name(name)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
now = time.monotonic()
|
||||
flow = _McpOAuthFlow(
|
||||
flow_id=secrets.token_urlsafe(24),
|
||||
name=name,
|
||||
cfg=cfg,
|
||||
redirect_uri=redirect_uri,
|
||||
manual_callback=manual_callback,
|
||||
expires_at=now + _FLOW_TTL_S,
|
||||
callback_result=loop.create_future(),
|
||||
)
|
||||
self._flows[flow.flow_id] = flow
|
||||
handlers = MCPOAuthHandlers(
|
||||
redirect_uri=redirect_uri,
|
||||
redirect_handler=lambda url: self._receive_authorization_url(flow, url),
|
||||
callback_handler=lambda: self._wait_for_callback(flow),
|
||||
reset_credentials=reset_credentials,
|
||||
)
|
||||
flow.task = asyncio.create_task(
|
||||
self._connect_and_reload(flow, handlers, reload_mcp),
|
||||
name=f"mcp-oauth:{name}",
|
||||
)
|
||||
|
||||
ready_waiter = asyncio.create_task(flow.authorization_ready.wait())
|
||||
try:
|
||||
await asyncio.wait(
|
||||
{ready_waiter, flow.task},
|
||||
timeout=_START_WAIT_S,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
finally:
|
||||
ready_waiter.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await ready_waiter
|
||||
return self._payload(flow)
|
||||
|
||||
async def status(self, flow_id: str) -> dict[str, Any]:
|
||||
self._prune()
|
||||
flow = self._flow(flow_id)
|
||||
return self._payload(flow)
|
||||
|
||||
def submit_callback(
|
||||
self,
|
||||
*,
|
||||
state: str,
|
||||
code: str | None,
|
||||
error: str | None,
|
||||
) -> str:
|
||||
self._prune()
|
||||
flow_id = self._states.pop(state, None)
|
||||
if flow_id is None:
|
||||
raise McpOAuthError("This MCP authorization request has expired", status=410)
|
||||
flow = self._flow(flow_id)
|
||||
callback_result = flow.callback_result
|
||||
if callback_result is None or callback_result.done():
|
||||
raise McpOAuthError("This MCP authorization callback was already used", status=409)
|
||||
|
||||
flow.callback_received = True
|
||||
if error:
|
||||
safe_error = error if _OAUTH_ERROR_RE.fullmatch(error) else "authorization_failed"
|
||||
flow.error = f"Authorization was not completed ({safe_error})."
|
||||
callback_result.set_exception(_OAuthCallbackError(flow.error))
|
||||
raise McpOAuthError(flow.error)
|
||||
elif not code or len(code) > 8192:
|
||||
flow.error = "The MCP server did not return an authorization code."
|
||||
callback_result.set_exception(_OAuthCallbackError(flow.error))
|
||||
raise McpOAuthError(flow.error)
|
||||
else:
|
||||
callback_result.set_result((code, state))
|
||||
return flow.name
|
||||
|
||||
def submit_callback_url(self, *, flow_id: str, callback_url: str) -> dict[str, Any]:
|
||||
"""Complete a flow from a full browser callback URL pasted into the WebUI."""
|
||||
self._prune()
|
||||
flow = self._flow(flow_id)
|
||||
parsed = urlsplit(callback_url.strip())
|
||||
expected = urlsplit(flow.redirect_uri)
|
||||
if (
|
||||
not parsed.query
|
||||
or parsed.fragment
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.scheme != expected.scheme
|
||||
or parsed.netloc != expected.netloc
|
||||
or parsed.path != expected.path
|
||||
):
|
||||
raise McpOAuthError(
|
||||
"Paste the complete callback URL from the browser address bar."
|
||||
)
|
||||
try:
|
||||
query = parse_qs(parsed.query, keep_blank_values=True, max_num_fields=16)
|
||||
except ValueError as exc:
|
||||
raise McpOAuthError(
|
||||
"Paste the complete callback URL from the browser address bar."
|
||||
) from exc
|
||||
|
||||
states = query.get("state", [])
|
||||
state = states[0] if len(states) == 1 else ""
|
||||
if not state or state != flow.state:
|
||||
raise McpOAuthError(
|
||||
"This callback belongs to a different or expired authorization request. "
|
||||
"Start again.",
|
||||
status=410,
|
||||
)
|
||||
|
||||
codes = query.get("code", [])
|
||||
errors = query.get("error", [])
|
||||
if len(codes) > 1 or len(errors) > 1 or (codes and errors):
|
||||
raise McpOAuthError(
|
||||
"Paste the complete callback URL from the browser address bar."
|
||||
)
|
||||
code = codes[0] if len(codes) == 1 else None
|
||||
error = errors[0] if len(errors) == 1 else None
|
||||
if (not code and not error) or (code is not None and len(code) > 8192):
|
||||
raise McpOAuthError(
|
||||
"Paste the complete callback URL from the browser address bar."
|
||||
)
|
||||
|
||||
self.submit_callback(state=state, code=code, error=error)
|
||||
return self._payload(flow)
|
||||
|
||||
async def cancel(self, flow_id: str) -> dict[str, Any]:
|
||||
self._prune()
|
||||
flow = self._flow(flow_id)
|
||||
await self._cancel_flow(flow)
|
||||
return self._payload(flow)
|
||||
|
||||
async def _receive_authorization_url(
|
||||
self,
|
||||
flow: _McpOAuthFlow,
|
||||
authorization_url: str,
|
||||
) -> None:
|
||||
parsed = urlsplit(authorization_url)
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not parsed.netloc
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.fragment
|
||||
):
|
||||
flow.error = "The MCP server returned an unsafe authorization URL."
|
||||
raise McpOAuthError(flow.error)
|
||||
ok, _error = validate_url_target(authorization_url)
|
||||
if not ok:
|
||||
flow.error = "The MCP server returned an unsafe authorization URL."
|
||||
raise McpOAuthError(flow.error)
|
||||
states = parse_qs(parsed.query).get("state", [])
|
||||
state = states[0] if len(states) == 1 else ""
|
||||
if not state or len(state) > 512:
|
||||
flow.error = "The MCP server returned an invalid authorization URL."
|
||||
raise McpOAuthError(flow.error)
|
||||
if state in self._states:
|
||||
flow.error = "The MCP server reused an OAuth state value."
|
||||
raise McpOAuthError(flow.error)
|
||||
flow.authorization_url = authorization_url
|
||||
flow.state = state
|
||||
self._states[state] = flow.flow_id
|
||||
flow.authorization_ready.set()
|
||||
|
||||
async def _wait_for_callback(self, flow: _McpOAuthFlow) -> tuple[str, str | None]:
|
||||
callback_result = flow.callback_result
|
||||
if callback_result is None:
|
||||
raise _OAuthCallbackError("MCP OAuth callback is unavailable")
|
||||
remaining = max(0.1, flow.expires_at - time.monotonic())
|
||||
try:
|
||||
return await asyncio.wait_for(asyncio.shield(callback_result), timeout=remaining)
|
||||
except asyncio.TimeoutError as exc:
|
||||
flow.error = "MCP authorization timed out."
|
||||
raise _OAuthCallbackError(flow.error) from exc
|
||||
|
||||
async def _connect(self, flow: _McpOAuthFlow, handlers: MCPOAuthHandlers) -> bool:
|
||||
connections: dict[str, MCPConnection] = {}
|
||||
try:
|
||||
connections = await connect_mcp_servers(
|
||||
{flow.name: flow.cfg},
|
||||
ToolRegistry(),
|
||||
oauth_handlers={flow.name: handlers},
|
||||
)
|
||||
succeeded = flow.name in connections
|
||||
if not succeeded and flow.error is None:
|
||||
flow.error = "Could not complete the MCP OAuth connection."
|
||||
return succeeded
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
if flow.error is None:
|
||||
flow.error = "Could not complete the MCP OAuth connection."
|
||||
return False
|
||||
finally:
|
||||
for connection in connections.values():
|
||||
with suppress(Exception):
|
||||
await connection.aclose()
|
||||
|
||||
async def _connect_and_reload(
|
||||
self,
|
||||
flow: _McpOAuthFlow,
|
||||
handlers: MCPOAuthHandlers,
|
||||
reload_mcp: McpReload,
|
||||
) -> bool:
|
||||
succeeded = await self._connect(flow, handlers)
|
||||
if not succeeded:
|
||||
return False
|
||||
try:
|
||||
flow.reload_result = await reload_mcp()
|
||||
failed = flow.reload_result.get("failed")
|
||||
if (
|
||||
not flow.reload_result.get("ok")
|
||||
and not flow.reload_result.get("requires_restart")
|
||||
and isinstance(failed, list)
|
||||
and flow.name in failed
|
||||
):
|
||||
flow.reload_result = await reload_mcp()
|
||||
except Exception:
|
||||
flow.reload_result = {
|
||||
"ok": False,
|
||||
"message": "Signed in, but nanobot could not activate the MCP tools.",
|
||||
"requires_restart": True,
|
||||
}
|
||||
return True
|
||||
|
||||
def _flow(self, flow_id: str) -> _McpOAuthFlow:
|
||||
flow = self._flows.get(flow_id)
|
||||
if flow is None:
|
||||
raise McpOAuthError("Unknown or expired MCP OAuth flow", status=404)
|
||||
return flow
|
||||
|
||||
def _payload(self, flow: _McpOAuthFlow) -> dict[str, Any]:
|
||||
task = flow.task
|
||||
connected = flow.reload_result.get("connected") if flow.reload_result is not None else None
|
||||
if task is not None and task.cancelled():
|
||||
status = "cancelled"
|
||||
elif task is not None and task.done():
|
||||
try:
|
||||
succeeded = task.result()
|
||||
except Exception:
|
||||
succeeded = False
|
||||
if not succeeded:
|
||||
status = "failed"
|
||||
elif flow.reload_result is None:
|
||||
status = "authorized"
|
||||
elif flow.reload_result.get("ok") or (
|
||||
isinstance(connected, list) and flow.name in connected
|
||||
):
|
||||
status = "connected"
|
||||
else:
|
||||
status = "authorized"
|
||||
elif flow.callback_received:
|
||||
status = "connecting"
|
||||
elif flow.authorization_url:
|
||||
status = "authorization_required"
|
||||
else:
|
||||
status = "starting"
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"flow_id": flow.flow_id,
|
||||
"name": flow.name,
|
||||
"status": status,
|
||||
"expires_in": max(0, int(flow.expires_at - time.monotonic())),
|
||||
}
|
||||
if flow.manual_callback:
|
||||
payload["completion_input"] = "callback_url"
|
||||
if flow.authorization_url and status == "authorization_required":
|
||||
payload["authorization_url"] = flow.authorization_url
|
||||
if flow.error:
|
||||
payload["error"] = flow.error
|
||||
if flow.reload_result is not None:
|
||||
payload["hot_reload"] = flow.reload_result
|
||||
return payload
|
||||
|
||||
async def _cancel_name(self, name: str) -> None:
|
||||
for flow in list(self._flows.values()):
|
||||
if flow.name == name and flow.task is not None and not flow.task.done():
|
||||
await self._cancel_flow(flow)
|
||||
|
||||
async def _cancel_flow(self, flow: _McpOAuthFlow) -> None:
|
||||
if flow.state:
|
||||
self._states.pop(flow.state, None)
|
||||
task = flow.task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
with suppress(BaseException):
|
||||
await task
|
||||
|
||||
def _prune(self) -> None:
|
||||
now = time.monotonic()
|
||||
for flow_id, flow in list(self._flows.items()):
|
||||
if flow.expires_at > now:
|
||||
continue
|
||||
if flow.state:
|
||||
self._states.pop(flow.state, None)
|
||||
if flow.task is not None and not flow.task.done():
|
||||
flow.task.cancel()
|
||||
callback_result = flow.callback_result
|
||||
if callback_result is not None and not callback_result.done():
|
||||
callback_result.cancel()
|
||||
self._flows.pop(flow_id, None)
|
||||
@@ -14,17 +14,8 @@ from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal, Mapping, cast
|
||||
from typing import Any, Literal, Mapping, cast
|
||||
|
||||
from nanobot.agent.plugins import (
|
||||
AgentPlugin,
|
||||
discover_agent_plugins,
|
||||
set_agent_plugin_enabled,
|
||||
)
|
||||
from nanobot.agent.tools.mcp_oauth import (
|
||||
delete_mcp_oauth_credentials,
|
||||
mcp_oauth_has_credentials,
|
||||
)
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||
from nanobot.config.loader import load_config, resolve_config_env_vars, save_config
|
||||
@@ -34,9 +25,6 @@ from nanobot.utils.helpers import ensure_dir
|
||||
|
||||
QueryParams = dict[str, list[str]]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.webui.settings_services import WebUISettingsConfig
|
||||
|
||||
_MCP_PRESET_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$", re.IGNORECASE)
|
||||
_SECRET_QUERY_RE = re.compile(
|
||||
r"([?&](?:[^=&]*(?:api[_-]?key|token|secret|password|bearer)[^=&]*)=)[^&#\s]+",
|
||||
@@ -56,13 +44,12 @@ _MCP_ATTACHMENT_KEYS = (
|
||||
"status",
|
||||
"configured",
|
||||
)
|
||||
_MAX_TEST_TOOLS = 16
|
||||
_DEFAULT_TEST_TIMEOUT = 20
|
||||
_DEFAULT_CUSTOM_TIMEOUT = 30
|
||||
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
|
||||
_MCP_RUNTIME_STATUSES = {"connecting", "connected", "failed"}
|
||||
|
||||
McpReload = Callable[[], Awaitable[dict[str, Any]]]
|
||||
McpRuntimeStatus = Callable[[], Mapping[str, str]]
|
||||
|
||||
|
||||
class McpPresetError(Exception):
|
||||
@@ -347,63 +334,6 @@ MCP_PRESETS: tuple[McpPreset, ...] = (
|
||||
),
|
||||
note="Requires Figma Desktop Dev Mode MCP to be running locally.",
|
||||
),
|
||||
McpPreset(
|
||||
name="xmind",
|
||||
display_name="Xmind",
|
||||
category="productivity",
|
||||
description="Create, read, and edit cloud mind maps through Xmind.",
|
||||
docs_url="https://xmind.com/user-guide/xmind-mcp",
|
||||
transport="streamableHttp",
|
||||
install_supported=True,
|
||||
brand_domain="xmind.com",
|
||||
brand_color="#F4B41A",
|
||||
requires="Xmind account",
|
||||
server=MCPServerConfig(
|
||||
type="streamableHttp",
|
||||
auth="oauth",
|
||||
url="https://app.xmind.com/api/mcp",
|
||||
tool_timeout=60,
|
||||
),
|
||||
note="Connects securely in your browser with Xmind OAuth.",
|
||||
),
|
||||
McpPreset(
|
||||
name="notion",
|
||||
display_name="Notion",
|
||||
category="productivity",
|
||||
description="Read and update your Notion workspace through Notion MCP.",
|
||||
docs_url="https://developers.notion.com/guides/mcp/get-started-with-mcp",
|
||||
transport="streamableHttp",
|
||||
install_supported=True,
|
||||
brand_domain="notion.so",
|
||||
brand_color="#111111",
|
||||
requires="Notion account",
|
||||
server=MCPServerConfig(
|
||||
type="streamableHttp",
|
||||
auth="oauth",
|
||||
url="https://mcp.notion.com/mcp",
|
||||
tool_timeout=60,
|
||||
),
|
||||
note="Connects securely in your browser with Notion OAuth.",
|
||||
),
|
||||
McpPreset(
|
||||
name="linear",
|
||||
display_name="Linear",
|
||||
category="productivity",
|
||||
description="Find and manage Linear issues, projects, and comments.",
|
||||
docs_url="https://linear.app/docs/mcp",
|
||||
transport="streamableHttp",
|
||||
install_supported=True,
|
||||
brand_domain="linear.app",
|
||||
brand_color="#5E6AD2",
|
||||
requires="Linear account",
|
||||
server=MCPServerConfig(
|
||||
type="streamableHttp",
|
||||
auth="oauth",
|
||||
url="https://mcp.linear.app/mcp",
|
||||
tool_timeout=60,
|
||||
),
|
||||
note="Connects securely in your browser with Linear OAuth.",
|
||||
),
|
||||
McpPreset(
|
||||
name="github",
|
||||
display_name="GitHub",
|
||||
@@ -724,8 +654,6 @@ def _status_for(preset: McpPreset, cfg: MCPServerConfig | None) -> str:
|
||||
return "not_installed" if preset.install_supported else "coming_soon"
|
||||
if any(field.required and not _field_configured(field, cfg) for field in preset.fields):
|
||||
return "missing_credentials"
|
||||
if cfg.auth == "oauth" and not mcp_oauth_has_credentials(preset.name, cfg.url):
|
||||
return "authorization_required"
|
||||
if cfg.command and not _command_available(cfg.command):
|
||||
return "missing_dependency"
|
||||
return "configured"
|
||||
@@ -771,7 +699,6 @@ def _preset_manifest(preset: McpPreset, *, logo_url: str) -> dict[str, Any]:
|
||||
compact_dict({
|
||||
"type": "mcp",
|
||||
"transport": preset.transport,
|
||||
"auth": server.auth if server and server.auth else None,
|
||||
"command": server.command if server and server.command else None,
|
||||
"args": list(server.args) if server and server.command else None,
|
||||
"url": _connection_summary(server) if server and server.url else None,
|
||||
@@ -822,7 +749,6 @@ def _custom_manifest(name: str, cfg: MCPServerConfig) -> dict[str, Any]:
|
||||
compact_dict({
|
||||
"type": "mcp",
|
||||
"transport": transport,
|
||||
"auth": cfg.auth,
|
||||
"command": cfg.command or None,
|
||||
"url": _connection_summary(cfg) if cfg.url else None,
|
||||
})
|
||||
@@ -850,7 +776,7 @@ def _custom_manifest(name: str, cfg: MCPServerConfig) -> dict[str, Any]:
|
||||
def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerConfig]) -> dict[str, Any]:
|
||||
cfg = configured_servers.get(preset.name)
|
||||
status = _status_for(preset, cfg)
|
||||
configured = cfg is not None and status not in {"missing_credentials", "authorization_required"}
|
||||
configured = cfg is not None and status not in {"missing_credentials"}
|
||||
logo_url = _favicon_url(preset.brand_domain)
|
||||
return {
|
||||
"name": preset.name,
|
||||
@@ -859,7 +785,6 @@ def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerCo
|
||||
"description": preset.description,
|
||||
"docs_url": preset.docs_url,
|
||||
"transport": preset.transport,
|
||||
"auth": (cfg.auth if cfg is not None else (preset.server.auth if preset.server else None)),
|
||||
"requires": preset.requires,
|
||||
"note": preset.note,
|
||||
"install_supported": preset.install_supported,
|
||||
@@ -886,11 +811,7 @@ def _custom_payload(
|
||||
transport = cfg.type
|
||||
if not transport:
|
||||
transport = "stdio" if cfg.command else ("sse" if cfg.url.rstrip("/").endswith("/sse") else "streamableHttp")
|
||||
if cfg.auth == "oauth" and not mcp_oauth_has_credentials(name, cfg.url):
|
||||
status = "authorization_required"
|
||||
else:
|
||||
status = "missing_dependency" if cfg.command and not _command_available(cfg.command) else "configured"
|
||||
configured = status != "authorization_required"
|
||||
return {
|
||||
"name": name,
|
||||
"display_name": name,
|
||||
@@ -898,13 +819,12 @@ def _custom_payload(
|
||||
"description": "Custom MCP server from nanobot config.",
|
||||
"docs_url": "",
|
||||
"transport": transport,
|
||||
"auth": cfg.auth,
|
||||
"requires": "",
|
||||
"note": "",
|
||||
"install_supported": True,
|
||||
"installed": True,
|
||||
"configured": configured,
|
||||
"available": configured and _config_available(cfg),
|
||||
"configured": True,
|
||||
"available": _config_available(cfg),
|
||||
"status": status,
|
||||
"logo_url": None,
|
||||
"brand_color": "#64748B",
|
||||
@@ -917,38 +837,12 @@ def _custom_payload(
|
||||
}
|
||||
|
||||
|
||||
def _agent_plugin_payload(plugin: AgentPlugin) -> dict[str, Any]:
|
||||
return {
|
||||
"name": f"plugin-{plugin.name}",
|
||||
"display_name": plugin.display_name,
|
||||
"category": plugin.category,
|
||||
"description": plugin.description or "Agent Plugin",
|
||||
"docs_url": plugin.repository,
|
||||
"transport": "stdio",
|
||||
"requires": ", ".join(plugin.permissions),
|
||||
"note": "",
|
||||
"install_supported": False,
|
||||
"installed": True,
|
||||
"configured": True,
|
||||
"enabled": plugin.enabled,
|
||||
"available": plugin.enabled,
|
||||
"status": "enabled" if plugin.enabled else "disabled",
|
||||
"logo_url": plugin.logo,
|
||||
"brand_color": plugin.accent_color,
|
||||
"required_fields": [],
|
||||
"connection_summary": ", ".join(plugin.mcp_servers),
|
||||
"source": "agent-plugin",
|
||||
}
|
||||
|
||||
|
||||
def mcp_presets_payload(
|
||||
*,
|
||||
last_action: dict[str, Any] | None = None,
|
||||
tool_preview: Mapping[str, list[str]] | None = None,
|
||||
runtime_status: Mapping[str, str] | None = None,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
config = load_config(config_path) if config_path is not None else load_config()
|
||||
config = load_config()
|
||||
known = _known_preset_names()
|
||||
preset_rows = [
|
||||
_preset_payload(preset, config.tools.mcp_servers)
|
||||
@@ -960,51 +854,13 @@ def mcp_presets_payload(
|
||||
for name, cfg in sorted(config.tools.mcp_servers.items())
|
||||
if name not in known
|
||||
]
|
||||
existing_names = {str(row["name"]) for row in (*preset_rows, *custom_rows)}
|
||||
plugin_rows = [
|
||||
_agent_plugin_payload(plugin)
|
||||
for plugin in discover_agent_plugins(config.workspace_path)
|
||||
if f"plugin-{plugin.name}" not in existing_names
|
||||
]
|
||||
payload: dict[str, Any] = {
|
||||
"presets": [*preset_rows, *custom_rows, *plugin_rows],
|
||||
"installed_count": len(config.tools.mcp_servers)
|
||||
+ sum(int(row["enabled"]) for row in plugin_rows),
|
||||
"presets": [*preset_rows, *custom_rows],
|
||||
"installed_count": len(config.tools.mcp_servers),
|
||||
}
|
||||
if last_action is not None:
|
||||
payload["last_action"] = last_action
|
||||
return attach_mcp_runtime_status(payload, runtime_status)
|
||||
|
||||
|
||||
def attach_mcp_runtime_status(
|
||||
payload: dict[str, Any],
|
||||
runtime_status: Mapping[str, str] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Project safe, connection-attempt state onto configured MCP rows."""
|
||||
if runtime_status is None:
|
||||
return payload
|
||||
projected = dict(payload)
|
||||
raw_rows: object = payload.get("presets", [])
|
||||
preset_rows = cast(list[object], raw_rows) if isinstance(raw_rows, list) else []
|
||||
rows: list[Any] = []
|
||||
for raw_row in preset_rows:
|
||||
if not isinstance(raw_row, dict):
|
||||
rows.append(raw_row)
|
||||
continue
|
||||
row = dict(cast(dict[str, Any], raw_row))
|
||||
name = row.get("name")
|
||||
status = runtime_status.get(name) if isinstance(name, str) else None
|
||||
if (
|
||||
status in _MCP_RUNTIME_STATUSES
|
||||
and row.get("installed") is True
|
||||
and row.get("configured") is True
|
||||
):
|
||||
row["runtime_status"] = status
|
||||
else:
|
||||
row.pop("runtime_status", None)
|
||||
rows.append(row)
|
||||
projected["presets"] = rows
|
||||
return projected
|
||||
|
||||
|
||||
def _display_name_for(name: str, preset: McpPreset | None = None) -> str:
|
||||
@@ -1037,7 +893,6 @@ def _server_action_message(action: str, name: str, *, ok: bool = True) -> dict[s
|
||||
"import-cursor": "Imported",
|
||||
"tools": "Updated tools for",
|
||||
"remove": "Removed",
|
||||
"reconnect": "Retried connection for",
|
||||
}.get(action, "Updated")
|
||||
payload: dict[str, Any] = {
|
||||
"ok": ok,
|
||||
@@ -1052,24 +907,6 @@ def _server_action_message(action: str, name: str, *, ok: bool = True) -> dict[s
|
||||
return payload
|
||||
|
||||
|
||||
def mcp_reconnect_action(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate a configured server before asking the live runtime to retry it."""
|
||||
name = _validated_server_name((_query_first(query, "name") or "").strip())
|
||||
config = load_config(config_path) if config_path is not None else load_config()
|
||||
if name not in config.tools.mcp_servers:
|
||||
raise McpPresetError("unknown MCP server", status=404)
|
||||
payload = mcp_presets_payload(
|
||||
last_action=_server_action_message("reconnect", name),
|
||||
config_path=config_path,
|
||||
)
|
||||
payload["requires_restart"] = True
|
||||
return payload
|
||||
|
||||
|
||||
def _scrub_test_error(text: str) -> str:
|
||||
scrubbed = _SECRET_QUERY_RE.sub(r"\1<redacted>", text.strip())
|
||||
scrubbed = _SECRET_ASSIGNMENT_RE.sub(r"\1<redacted>", scrubbed)
|
||||
@@ -1091,12 +928,8 @@ async def _close_mcp_stacks(stacks: Mapping[str, Any]) -> None:
|
||||
await stack.aclose()
|
||||
|
||||
|
||||
async def mcp_presets_test_action(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Connect to an enabled MCP preset and report its complete tool surface."""
|
||||
async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
||||
"""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()
|
||||
@@ -1108,22 +941,16 @@ async def mcp_presets_test_action(
|
||||
display_name = _display_name_for(name, preset)
|
||||
|
||||
try:
|
||||
config = resolve_config_env_vars(
|
||||
load_config(config_path) if config_path is not None else load_config(),
|
||||
config_path=config_path,
|
||||
)
|
||||
config = resolve_config_env_vars(load_config())
|
||||
except ValueError as exc:
|
||||
return mcp_presets_payload(
|
||||
last_action={
|
||||
return mcp_presets_payload(last_action={
|
||||
"ok": False,
|
||||
"message": _scrub_test_error(str(exc)),
|
||||
"error": _scrub_test_error(str(exc)),
|
||||
"tool_count": 0,
|
||||
"tool_names": [],
|
||||
"checked_at": _checked_at(),
|
||||
},
|
||||
config_path=config_path,
|
||||
)
|
||||
})
|
||||
|
||||
cfg = config.tools.mcp_servers.get(name)
|
||||
if cfg is None:
|
||||
@@ -1141,7 +968,7 @@ async def mcp_presets_test_action(
|
||||
"tool_names": [],
|
||||
"checked_at": _checked_at(),
|
||||
}
|
||||
return mcp_presets_payload(last_action=last_action, config_path=config_path)
|
||||
return mcp_presets_payload(last_action=last_action)
|
||||
|
||||
if cfg.command and not _command_available(cfg.command):
|
||||
last_action = {
|
||||
@@ -1152,14 +979,13 @@ async def mcp_presets_test_action(
|
||||
"tool_names": [],
|
||||
"checked_at": _checked_at(),
|
||||
}
|
||||
return mcp_presets_payload(last_action=last_action, config_path=config_path)
|
||||
return mcp_presets_payload(last_action=last_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}_"
|
||||
@@ -1178,7 +1004,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:
|
||||
@@ -1214,11 +1040,7 @@ async def mcp_presets_test_action(
|
||||
|
||||
tool_names = last_action.get("tool_names", [])
|
||||
preview = {name: tool_names} if tool_names else None
|
||||
return mcp_presets_payload(
|
||||
last_action=last_action,
|
||||
tool_preview=preview,
|
||||
config_path=config_path,
|
||||
)
|
||||
return mcp_presets_payload(last_action=last_action, tool_preview=preview)
|
||||
|
||||
|
||||
def _parse_json_value(raw: str | None, *, fallback: Any) -> Any:
|
||||
@@ -1287,32 +1109,6 @@ def _normalize_transport(value: str | None, *, command: str = "", url: str = "")
|
||||
return normalized # type: ignore[return-value]
|
||||
|
||||
|
||||
def _normalize_auth(
|
||||
value: object,
|
||||
*,
|
||||
transport: Literal["stdio", "sse", "streamableHttp"],
|
||||
url: str,
|
||||
headers: Mapping[str, str],
|
||||
) -> Literal["oauth"] | None:
|
||||
raw = str(value or "").strip().lower()
|
||||
if not raw and url and not headers:
|
||||
normalized_url = url.rstrip("/")
|
||||
if any(
|
||||
preset.server is not None
|
||||
and preset.server.auth == "oauth"
|
||||
and preset.server.url.rstrip("/") == normalized_url
|
||||
for preset in MCP_PRESETS
|
||||
):
|
||||
raw = "oauth"
|
||||
if not raw:
|
||||
return None
|
||||
if raw != "oauth":
|
||||
raise McpPresetError("unsupported MCP auth type")
|
||||
if transport == "stdio":
|
||||
raise McpPresetError("MCP OAuth requires a remote HTTP transport")
|
||||
return "oauth"
|
||||
|
||||
|
||||
def _validated_server_name(name: str) -> str:
|
||||
if not name or _MCP_PRESET_NAME_RE.match(name) is None:
|
||||
raise McpPresetError("invalid MCP server name")
|
||||
@@ -1328,13 +1124,6 @@ def _custom_server_from_query(query: QueryParams) -> tuple[str, MCPServerConfig]
|
||||
raise McpPresetError("stdio MCP servers require a command")
|
||||
if transport in {"sse", "streamableHttp"} and not url:
|
||||
raise McpPresetError("remote MCP servers require a URL")
|
||||
headers = _parse_string_map(_query_first(query, "headers"))
|
||||
auth = _normalize_auth(
|
||||
_query_first(query, "auth"),
|
||||
transport=transport,
|
||||
url=url,
|
||||
headers=headers,
|
||||
)
|
||||
raw_timeout = (_query_first(query, "tool_timeout") or "").strip()
|
||||
tool_timeout = _DEFAULT_CUSTOM_TIMEOUT
|
||||
if raw_timeout:
|
||||
@@ -1344,13 +1133,12 @@ def _custom_server_from_query(query: QueryParams) -> tuple[str, MCPServerConfig]
|
||||
raise McpPresetError("tool_timeout must be an integer") from exc
|
||||
cfg = MCPServerConfig(
|
||||
type=transport,
|
||||
auth=auth,
|
||||
command=command if transport == "stdio" else "",
|
||||
args=_parse_string_list(_query_first(query, "args")),
|
||||
env=_parse_string_map(_query_first(query, "env")),
|
||||
cwd=(_query_first(query, "cwd") or "").strip() if transport == "stdio" else "",
|
||||
url=url if transport in {"sse", "streamableHttp"} else "",
|
||||
headers=headers,
|
||||
headers=_parse_string_map(_query_first(query, "headers")),
|
||||
tool_timeout=tool_timeout,
|
||||
enabled_tools=_parse_enabled_tools(_query_first(query, "enabled_tools")),
|
||||
)
|
||||
@@ -1395,13 +1183,6 @@ def _mcp_server_config(name: str, raw: Any) -> tuple[str, MCPServerConfig]:
|
||||
headers = cast(dict[object, object], headers_value)
|
||||
if not all(isinstance(k, str) and isinstance(v, str) for k, v in headers.items()):
|
||||
raise McpPresetError(f"MCP server '{server_name}' headers must be a string object")
|
||||
typed_headers = cast(dict[str, str], headers)
|
||||
auth = _normalize_auth(
|
||||
server.get("auth"),
|
||||
transport=transport,
|
||||
url=url,
|
||||
headers=typed_headers,
|
||||
)
|
||||
if not isinstance(enabled_tools_value, list):
|
||||
enabled_tools_value = ["*"]
|
||||
else:
|
||||
@@ -1410,13 +1191,12 @@ def _mcp_server_config(name: str, raw: Any) -> tuple[str, MCPServerConfig]:
|
||||
enabled_tools_value = ["*"]
|
||||
return server_name, MCPServerConfig(
|
||||
type=transport,
|
||||
auth=auth,
|
||||
command=command if transport == "stdio" else "",
|
||||
args=cast(list[str], args),
|
||||
env=cast(dict[str, str], env),
|
||||
cwd=cwd if transport == "stdio" else "",
|
||||
url=url if transport in {"sse", "streamableHttp"} else "",
|
||||
headers=typed_headers,
|
||||
headers=cast(dict[str, str], headers),
|
||||
tool_timeout=timeout_int,
|
||||
enabled_tools=cast(list[str], enabled_tools_value),
|
||||
)
|
||||
@@ -1441,54 +1221,24 @@ def _import_mcp_servers(raw_json: str | None) -> dict[str, MCPServerConfig]:
|
||||
return out
|
||||
|
||||
|
||||
def _oauth_credentials_replaced(
|
||||
previous: MCPServerConfig | None,
|
||||
replacement: MCPServerConfig,
|
||||
) -> bool:
|
||||
if previous is None or previous.auth != "oauth":
|
||||
return False
|
||||
return replacement.auth != "oauth" or replacement.url != previous.url
|
||||
|
||||
|
||||
def custom_mcp_action(
|
||||
action: str,
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
config = load_config(config_path) if config_path is not None else load_config()
|
||||
def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||
config = load_config()
|
||||
if action == "custom":
|
||||
name, cfg = _custom_server_from_query(query)
|
||||
delete_credentials = _oauth_credentials_replaced(config.tools.mcp_servers.get(name), cfg)
|
||||
config.tools.mcp_servers[name] = cfg
|
||||
save_config(config, config_path)
|
||||
if delete_credentials:
|
||||
delete_mcp_oauth_credentials(name)
|
||||
payload = mcp_presets_payload(
|
||||
last_action=_server_action_message(action, name),
|
||||
config_path=config_path,
|
||||
)
|
||||
save_config(config)
|
||||
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
|
||||
payload["requires_restart"] = True
|
||||
return payload
|
||||
|
||||
if action in {"import", "import-cursor"}:
|
||||
servers = _import_mcp_servers(_query_first(query, "config"))
|
||||
delete_credentials = [
|
||||
name
|
||||
for name, cfg in servers.items()
|
||||
if _oauth_credentials_replaced(config.tools.mcp_servers.get(name), cfg)
|
||||
]
|
||||
config.tools.mcp_servers.update(servers)
|
||||
save_config(config, config_path)
|
||||
for name in delete_credentials:
|
||||
delete_mcp_oauth_credentials(name)
|
||||
payload = mcp_presets_payload(
|
||||
last_action={
|
||||
save_config(config)
|
||||
payload = mcp_presets_payload(last_action={
|
||||
"ok": True,
|
||||
"message": f"Imported {len(servers)} MCP server(s).",
|
||||
},
|
||||
config_path=config_path,
|
||||
)
|
||||
})
|
||||
payload["requires_restart"] = True
|
||||
return payload
|
||||
|
||||
@@ -1499,61 +1249,29 @@ def custom_mcp_action(
|
||||
raise McpPresetError("unknown MCP server", status=404)
|
||||
cfg.enabled_tools = _parse_enabled_tools(_query_first(query, "enabled_tools"))
|
||||
config.tools.mcp_servers[name] = cfg
|
||||
save_config(config, config_path)
|
||||
payload = mcp_presets_payload(
|
||||
last_action=_server_action_message(action, name),
|
||||
config_path=config_path,
|
||||
)
|
||||
save_config(config)
|
||||
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
|
||||
payload["requires_restart"] = True
|
||||
return payload
|
||||
|
||||
raise McpPresetError(f"unknown MCP action '{action}'", status=404)
|
||||
|
||||
|
||||
def ensure_mcp_oauth_server(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> tuple[str, MCPServerConfig]:
|
||||
"""Materialize an OAuth preset on first click and return its saved config."""
|
||||
name = _validated_server_name((_query_first(query, "name") or "").strip())
|
||||
config = load_config(config_path) if config_path is not None else load_config()
|
||||
cfg = config.tools.mcp_servers.get(name)
|
||||
if cfg is None:
|
||||
preset = _preset_by_name(name)
|
||||
if preset.server is None or preset.server.auth != "oauth":
|
||||
raise McpPresetError("MCP server does not support browser authorization", status=409)
|
||||
cfg = _materialize_server(preset, query, None)
|
||||
config.tools.mcp_servers[name] = cfg
|
||||
save_config(config, config_path)
|
||||
if cfg.auth != "oauth" or cfg.type not in {"sse", "streamableHttp"} or not cfg.url:
|
||||
raise McpPresetError("MCP server is not configured for OAuth", status=409)
|
||||
return name, cfg
|
||||
|
||||
|
||||
def mcp_presets_action(
|
||||
action: str,
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||
name = (_query_first(query, "name") or "").strip()
|
||||
if not name:
|
||||
raise McpPresetError("missing MCP preset name")
|
||||
preset = _preset_by_name_optional(name)
|
||||
|
||||
config = load_config(config_path) if config_path is not None else load_config()
|
||||
config = load_config()
|
||||
existing = config.tools.mcp_servers.get(name)
|
||||
|
||||
if action == "enable":
|
||||
if preset is None:
|
||||
raise McpPresetError("unknown MCP preset", status=404)
|
||||
config.tools.mcp_servers[preset.name] = _materialize_server(preset, query, existing)
|
||||
save_config(config, config_path)
|
||||
payload = mcp_presets_payload(
|
||||
last_action=_action_message(action, preset),
|
||||
config_path=config_path,
|
||||
)
|
||||
save_config(config)
|
||||
payload = mcp_presets_payload(last_action=_action_message(action, preset))
|
||||
payload["requires_restart"] = True
|
||||
return payload
|
||||
|
||||
@@ -1569,8 +1287,7 @@ def mcp_presets_action(
|
||||
except OSError as exc:
|
||||
cleanup_error = str(exc)
|
||||
del config.tools.mcp_servers[name]
|
||||
save_config(config, config_path)
|
||||
delete_mcp_oauth_credentials(name)
|
||||
save_config(config)
|
||||
last_action = (
|
||||
_action_message(action, preset)
|
||||
if preset is not None
|
||||
@@ -1586,10 +1303,7 @@ def mcp_presets_action(
|
||||
f"{last_action['message']} Could not remove managed runtime files: {cleanup_error}"
|
||||
)
|
||||
last_action["verification_failed"] = ["managed_paths_absent"]
|
||||
payload = mcp_presets_payload(
|
||||
last_action=last_action,
|
||||
config_path=config_path,
|
||||
)
|
||||
payload = mcp_presets_payload(last_action=last_action)
|
||||
payload["requires_restart"] = True
|
||||
return payload
|
||||
|
||||
@@ -1625,64 +1339,16 @@ async def mcp_presets_settings_action(
|
||||
query: QueryParams,
|
||||
*,
|
||||
reload_mcp: McpReload | None = None,
|
||||
mcp_runtime_status: McpRuntimeStatus | None = None,
|
||||
config: WebUISettingsConfig | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run a WebUI MCP preset action and hot-reload the agent when config changes."""
|
||||
config_path = config.path if config is not None else None
|
||||
if action is None:
|
||||
return mcp_presets_payload(
|
||||
runtime_status=mcp_runtime_status() if mcp_runtime_status is not None else None,
|
||||
config_path=config_path,
|
||||
)
|
||||
name = (_query_first(query, "name") or "").strip()
|
||||
if name.startswith("plugin-"):
|
||||
plugin_config = load_config(config_path) if config_path is not None else load_config()
|
||||
plugin_name = name.removeprefix("plugin-")
|
||||
plugins = discover_agent_plugins(plugin_config.workspace_path)
|
||||
plugin = next((item for item in plugins if item.name == plugin_name), None)
|
||||
if name not in plugin_config.tools.mcp_servers and plugin is not None:
|
||||
if action not in {"enable", "disable"}:
|
||||
raise McpPresetError("Agent Plugins support enable and disable actions only")
|
||||
await asyncio.to_thread(
|
||||
set_agent_plugin_enabled,
|
||||
plugin_config.workspace_path,
|
||||
plugin_name,
|
||||
action == "enable",
|
||||
)
|
||||
verb = "enabled" if action == "enable" else "disabled"
|
||||
payload = mcp_presets_payload(
|
||||
last_action={"ok": True, "message": f"{plugin.display_name} {verb}."},
|
||||
config_path=config_path,
|
||||
)
|
||||
if reload_mcp is not None:
|
||||
payload = attach_mcp_hot_reload_result(payload, await reload_mcp())
|
||||
return payload
|
||||
return mcp_presets_payload()
|
||||
if action == "test":
|
||||
payload = await mcp_presets_test_action(query, config_path=config_path)
|
||||
return attach_mcp_runtime_status(
|
||||
payload,
|
||||
mcp_runtime_status() if mcp_runtime_status is not None else None,
|
||||
)
|
||||
if action == "reconnect":
|
||||
payload = await asyncio.to_thread(
|
||||
mcp_reconnect_action,
|
||||
query,
|
||||
config_path=config_path,
|
||||
)
|
||||
elif config is not None:
|
||||
operation = custom_mcp_action if action in _CUSTOM_ACTIONS else mcp_presets_action
|
||||
payload = await asyncio.to_thread(
|
||||
config.run_serialized,
|
||||
lambda path: operation(action, query, config_path=path),
|
||||
)
|
||||
elif action in _CUSTOM_ACTIONS:
|
||||
return await mcp_presets_test_action(query)
|
||||
if action in _CUSTOM_ACTIONS:
|
||||
payload = await asyncio.to_thread(custom_mcp_action, action, query)
|
||||
else:
|
||||
payload = await asyncio.to_thread(mcp_presets_action, action, query)
|
||||
if reload_mcp is not None:
|
||||
payload = attach_mcp_hot_reload_result(payload, await reload_mcp())
|
||||
return attach_mcp_runtime_status(
|
||||
payload,
|
||||
mcp_runtime_status() if mcp_runtime_status is not None else None,
|
||||
)
|
||||
return payload
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Nanobot optional feature helpers for WebUI Settings."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.channels.registry import load_channel_plugin
|
||||
@@ -16,14 +15,9 @@ from nanobot.webui.http_utils import query_first
|
||||
QueryParams = dict[str, list[str]]
|
||||
|
||||
|
||||
def nanobot_features_payload(*, config_path: Path | None = None) -> dict[str, Any]:
|
||||
if config_path is None:
|
||||
def nanobot_features_payload() -> dict[str, Any]:
|
||||
return optional_features_payload()
|
||||
|
||||
from nanobot.config.loader import load_config
|
||||
|
||||
return optional_features_payload(config=load_config(config_path))
|
||||
|
||||
|
||||
def nanobot_feature_instance_target(query: QueryParams) -> str | None:
|
||||
"""Preserve the difference between a global action and an explicit instance."""
|
||||
@@ -38,19 +32,13 @@ def nanobot_features_action(
|
||||
query: QueryParams,
|
||||
*,
|
||||
allow_install: bool = True,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
name = (query_first(query, "name") or "").strip()
|
||||
instance_id = nanobot_feature_instance_target(query)
|
||||
if not name:
|
||||
raise OptionalFeatureError("missing feature name")
|
||||
if action == "enable":
|
||||
return enable_optional_feature(
|
||||
name,
|
||||
config_path=config_path,
|
||||
allow_install=allow_install,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
return enable_optional_feature(name, allow_install=allow_install, instance_id=instance_id)
|
||||
if action == "disable":
|
||||
try:
|
||||
plugin = load_channel_plugin(name)
|
||||
@@ -62,9 +50,5 @@ def nanobot_features_action(
|
||||
f"Use `nanobot plugins disable {name}` from a terminal if you need to disable it.",
|
||||
status=400,
|
||||
)
|
||||
return disable_optional_feature(
|
||||
name,
|
||||
config_path=config_path,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
return disable_optional_feature(name, instance_id=instance_id)
|
||||
raise OptionalFeatureError(f"unknown feature action '{action}'", status=404)
|
||||
|
||||
@@ -1,15 +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
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
@@ -31,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(
|
||||
@@ -46,12 +42,7 @@ _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]]:
|
||||
@@ -62,79 +53,41 @@ def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]
|
||||
_write_index_rows(session_manager.sessions_dir, rows)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to write WebUI session list index: {}", e)
|
||||
sessions = [
|
||||
_public_row(session_manager.sessions_dir, get_webui_dir(), row)
|
||||
for row in rows
|
||||
]
|
||||
sessions = [_public_row(session_manager.sessions_dir, row) for row in rows]
|
||||
return sorted(sessions, key=lambda row: row.get("updated_at", ""), reverse=True)
|
||||
|
||||
|
||||
def _reconcile_index(session_manager: SessionManager) -> tuple[list[dict[str, Any]], bool]:
|
||||
existing_rows = _read_index_rows(session_manager.sessions_dir)
|
||||
existing_by_source = {
|
||||
(row.get(_ROW_SOURCE_FIELD), row.get("file")): row
|
||||
existing_by_file = {
|
||||
row.get("file"): row
|
||||
for row in existing_rows or []
|
||||
if isinstance(row.get(_ROW_SOURCE_FIELD), str)
|
||||
and isinstance(row.get("file"), str)
|
||||
if isinstance(row.get("file"), str)
|
||||
}
|
||||
webui_dir = get_webui_dir()
|
||||
session_paths: dict[str, Path] = {}
|
||||
for path in sorted(session_manager.sessions_dir.glob("*.jsonl")):
|
||||
key = SessionManager._session_key_from_path(path) # pyright: ignore[reportPrivateUsage]
|
||||
if key is not None:
|
||||
session_paths[key] = path
|
||||
paths = sorted(
|
||||
path
|
||||
for path in session_manager.sessions_dir.glob("*.jsonl")
|
||||
if SessionManager._session_key_from_path(path) is not None # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
if not paths:
|
||||
return [], existing_rows != []
|
||||
|
||||
session_keys_by_stem = {
|
||||
SessionManager.safe_key(key): key
|
||||
for key in session_paths
|
||||
if key.startswith("websocket:")
|
||||
}
|
||||
webui_dir = get_webui_dir()
|
||||
rows: list[dict[str, Any]] = []
|
||||
changed = existing_rows is None
|
||||
expected_sources: set[tuple[str, str]] = set()
|
||||
|
||||
for key, path in sorted(session_paths.items()):
|
||||
identity = (_SESSION_SOURCE, path.name)
|
||||
row = existing_by_source.get(identity)
|
||||
for path in paths:
|
||||
row = existing_by_file.get(path.name)
|
||||
if row is not None and _indexed_row_matches_file(row, path, webui_dir):
|
||||
rows.append(row)
|
||||
expected_sources.add(identity)
|
||||
continue
|
||||
|
||||
changed = True
|
||||
scanned = _scan_session_row(session_manager, path, webui_dir)
|
||||
if scanned is not None:
|
||||
rows.append(scanned)
|
||||
expected_sources.add(identity)
|
||||
|
||||
for stem, paths in _webui_transcript_sources(webui_dir).items():
|
||||
if stem in session_keys_by_stem:
|
||||
continue
|
||||
identity = (_TRANSCRIPT_SOURCE, stem)
|
||||
row = existing_by_source.get(identity)
|
||||
cached_key = row.get("key") if row is not None else None
|
||||
key = (
|
||||
cached_key
|
||||
if isinstance(cached_key, str) and _valid_transcript_session_key(cached_key, stem)
|
||||
else None
|
||||
)
|
||||
if key is not None and row is not None and _indexed_transcript_row_matches(
|
||||
row,
|
||||
key,
|
||||
webui_dir,
|
||||
):
|
||||
rows.append(row)
|
||||
expected_sources.add(identity)
|
||||
continue
|
||||
|
||||
changed = True
|
||||
scanned = _scan_transcript_row(key, stem, paths, webui_dir)
|
||||
scanned_key = scanned.get("key") if scanned is not None else None
|
||||
if scanned is not None and scanned_key not in session_paths:
|
||||
rows.append(scanned)
|
||||
expected_sources.add(identity)
|
||||
|
||||
if set(existing_by_source) != expected_sources:
|
||||
if set(existing_by_file) != {path.name for path in paths}:
|
||||
changed = True
|
||||
if existing_rows is not None and rows != existing_rows:
|
||||
changed = True
|
||||
@@ -191,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)
|
||||
@@ -203,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"),
|
||||
@@ -245,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", ""))),
|
||||
}
|
||||
|
||||
|
||||
@@ -318,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()
|
||||
@@ -409,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,
|
||||
}
|
||||
|
||||
|
||||
@@ -484,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"],
|
||||
@@ -492,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,
|
||||
@@ -686,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"],
|
||||
|
||||
+2174
-236
File diff suppressed because it is too large
Load Diff
@@ -1,804 +0,0 @@
|
||||
"""Capability settings domain logic for Web, media, network, and API features."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypedDict
|
||||
|
||||
from nanobot.agent.tools.web import SEARCH_PROVIDER_OPTIONS
|
||||
from nanobot.api.runtime import ApiRuntime, ApiStartOptions
|
||||
from nanobot.audio.transcription import resolve_transcription_config
|
||||
from nanobot.audio.transcription_registry import (
|
||||
resolve_transcription_provider,
|
||||
transcription_provider_names,
|
||||
)
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.optional_features import (
|
||||
OptionalFeatureError,
|
||||
extra_installed,
|
||||
optional_dependency_groups,
|
||||
)
|
||||
from nanobot.providers.image_generation import (
|
||||
get_image_gen_provider,
|
||||
image_gen_provider_names,
|
||||
)
|
||||
from nanobot.providers.registry import find_by_name
|
||||
from nanobot.security.network import is_loopback_host
|
||||
from nanobot.webui.settings_contracts import (
|
||||
QueryParams,
|
||||
SettingsRequest,
|
||||
SettingsRouteResult,
|
||||
WebUISettingsError,
|
||||
parse_bool,
|
||||
query_first,
|
||||
query_first_alias,
|
||||
)
|
||||
from nanobot.webui.settings_models import (
|
||||
OAuthStatusReader,
|
||||
mask_secret_hint,
|
||||
provider_configured_for_settings,
|
||||
)
|
||||
from nanobot.webui.workspaces import (
|
||||
read_webui_default_access_mode,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.webui.settings_services import WebUISettingsServices
|
||||
|
||||
SettingsOperation = Callable[..., dict[str, Any]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapabilitySettingsOperations:
|
||||
update_web_search: SettingsOperation
|
||||
update_api: SettingsOperation
|
||||
update_image: SettingsOperation
|
||||
update_transcription: SettingsOperation
|
||||
update_network: SettingsOperation
|
||||
nanobot_features_action: SettingsOperation
|
||||
api_runtime: Callable[[], ApiRuntime]
|
||||
reload_image: Callable[[], Awaitable[dict[str, Any]]]
|
||||
|
||||
|
||||
class CapabilitySettingsPayload(TypedDict):
|
||||
web_search: dict[str, Any]
|
||||
web: dict[str, Any]
|
||||
api: dict[str, Any]
|
||||
observability: dict[str, Any]
|
||||
image_generation: dict[str, Any]
|
||||
transcription: dict[str, Any]
|
||||
|
||||
|
||||
_WEB_SEARCH_PROVIDER_OPTIONS = SEARCH_PROVIDER_OPTIONS
|
||||
_WEB_SEARCH_PROVIDER_BY_NAME = {
|
||||
provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS
|
||||
}
|
||||
_IMAGE_GENERATION_ASPECT_RATIOS = {
|
||||
"1:1",
|
||||
"3:4",
|
||||
"9:16",
|
||||
"4:3",
|
||||
"16:9",
|
||||
"3:2",
|
||||
"2:3",
|
||||
"21:9",
|
||||
}
|
||||
|
||||
|
||||
def _image_generation_provider_rows(
|
||||
config: Config,
|
||||
*,
|
||||
oauth_status: OAuthStatusReader,
|
||||
) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for name in image_gen_provider_names():
|
||||
image_provider = get_image_gen_provider(name)
|
||||
spec = find_by_name(name)
|
||||
provider_config = getattr(config.providers, name, None)
|
||||
configured = (
|
||||
provider_configured_for_settings(spec, provider_config, oauth_status)
|
||||
if spec is not None and provider_config is not None
|
||||
else bool(getattr(provider_config, "api_key", None))
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"name": name,
|
||||
"label": spec.label if spec is not None else name,
|
||||
"configured": configured,
|
||||
"auth_type": "oauth" if spec is not None and spec.is_oauth else "api_key",
|
||||
"api_key_hint": mask_secret_hint(getattr(provider_config, "api_key", None)),
|
||||
"api_base": getattr(provider_config, "api_base", None),
|
||||
"default_api_base": (
|
||||
spec.default_api_base if spec and spec.default_api_base else None
|
||||
),
|
||||
"models": list(image_provider.model_options) if image_provider else [],
|
||||
"default_model": (
|
||||
image_provider.model_options[0]
|
||||
if image_provider and image_provider.model_options
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _transcription_provider_rows(config: Config) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for name in transcription_provider_names():
|
||||
spec = find_by_name(name)
|
||||
provider_config = getattr(config.providers, name, None)
|
||||
rows.append(
|
||||
{
|
||||
"name": name,
|
||||
"label": spec.label if spec is not None else name,
|
||||
"configured": bool(getattr(provider_config, "api_key", None)),
|
||||
"api_key_hint": mask_secret_hint(getattr(provider_config, "api_key", None)),
|
||||
"api_base": getattr(provider_config, "api_base", None),
|
||||
"default_api_base": (
|
||||
spec.default_api_base if spec and spec.default_api_base else None
|
||||
),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def capability_settings_payload(
|
||||
config: Config,
|
||||
*,
|
||||
oauth_status: OAuthStatusReader,
|
||||
) -> CapabilitySettingsPayload:
|
||||
search_config = config.tools.web.search
|
||||
image_config = config.tools.image_generation
|
||||
transcription = resolve_transcription_config(config)
|
||||
search_provider = (
|
||||
search_config.provider
|
||||
if search_config.provider in _WEB_SEARCH_PROVIDER_BY_NAME
|
||||
else "duckduckgo"
|
||||
)
|
||||
image_providers = _image_generation_provider_rows(config, oauth_status=oauth_status)
|
||||
selected_image_provider = next(
|
||||
(
|
||||
provider
|
||||
for provider in image_providers
|
||||
if provider["name"] == image_config.provider
|
||||
),
|
||||
None,
|
||||
)
|
||||
return {
|
||||
"web_search": {
|
||||
"provider": search_provider,
|
||||
"api_key_hint": mask_secret_hint(search_config.api_key),
|
||||
"base_url": search_config.base_url or None,
|
||||
"max_results": search_config.max_results,
|
||||
"timeout": search_config.timeout,
|
||||
"providers": list(_WEB_SEARCH_PROVIDER_OPTIONS),
|
||||
},
|
||||
"web": {
|
||||
"enable": config.tools.web.enable,
|
||||
"proxy": config.tools.web.proxy,
|
||||
"user_agent": config.tools.web.user_agent,
|
||||
"search": {
|
||||
"max_results": search_config.max_results,
|
||||
"timeout": search_config.timeout,
|
||||
},
|
||||
"fetch": {
|
||||
"use_jina_reader": config.tools.web.fetch.use_jina_reader,
|
||||
},
|
||||
},
|
||||
"api": {
|
||||
"host": config.api.host,
|
||||
"port": config.api.port,
|
||||
"timeout": config.api.timeout,
|
||||
"api_key_hint": mask_secret_hint(config.api.api_key),
|
||||
},
|
||||
"observability": {
|
||||
"provider": "langfuse",
|
||||
"configured": bool(
|
||||
os.environ.get("LANGFUSE_SECRET_KEY")
|
||||
and os.environ.get("LANGFUSE_PUBLIC_KEY")
|
||||
),
|
||||
"base_url": os.environ.get("LANGFUSE_BASE_URL")
|
||||
or "https://cloud.langfuse.com",
|
||||
},
|
||||
"image_generation": {
|
||||
"enabled": image_config.enabled,
|
||||
"provider": image_config.provider,
|
||||
"provider_configured": bool(
|
||||
selected_image_provider and selected_image_provider["configured"]
|
||||
),
|
||||
"model": image_config.model,
|
||||
"default_aspect_ratio": image_config.default_aspect_ratio,
|
||||
"default_image_size": image_config.default_image_size,
|
||||
"max_images_per_turn": image_config.max_images_per_turn,
|
||||
"save_dir": image_config.save_dir,
|
||||
"providers": image_providers,
|
||||
},
|
||||
"transcription": {
|
||||
"enabled": transcription.enabled,
|
||||
"provider": transcription.provider,
|
||||
"provider_configured": transcription.configured,
|
||||
"model": transcription.model,
|
||||
"language": transcription.language,
|
||||
"max_duration_sec": transcription.max_duration_sec,
|
||||
"max_upload_mb": transcription.max_upload_mb,
|
||||
"providers": _transcription_provider_rows(config),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def update_network_safety_settings(
|
||||
config: Config,
|
||||
query: QueryParams,
|
||||
) -> tuple[bool, str | None]:
|
||||
raw_allow = (
|
||||
query_first_alias(
|
||||
query,
|
||||
"webui_allow_local_service_access",
|
||||
"webuiAllowLocalServiceAccess",
|
||||
)
|
||||
or query_first_alias(
|
||||
query,
|
||||
"allow_local_preview_access",
|
||||
"allowLocalPreviewAccess",
|
||||
)
|
||||
)
|
||||
raw_default_access_mode = query_first_alias(
|
||||
query,
|
||||
"webui_default_access_mode",
|
||||
"webuiDefaultAccessMode",
|
||||
)
|
||||
if raw_allow is None and raw_default_access_mode is None:
|
||||
raise WebUISettingsError(
|
||||
"webui_allow_local_service_access or webui_default_access_mode is required"
|
||||
)
|
||||
|
||||
changed = False
|
||||
if raw_allow is not None:
|
||||
allow_local = parse_bool(raw_allow, "webui_allow_local_service_access")
|
||||
if config.tools.webui_allow_local_service_access != allow_local:
|
||||
config.tools.webui_allow_local_service_access = allow_local
|
||||
changed = True
|
||||
|
||||
default_access_mode: str | None = None
|
||||
if raw_default_access_mode is not None:
|
||||
default_access_mode = raw_default_access_mode.strip().lower()
|
||||
if default_access_mode == "restricted":
|
||||
default_access_mode = "default"
|
||||
if default_access_mode not in {"default", "full"}:
|
||||
raise WebUISettingsError(
|
||||
"webui_default_access_mode must be default or full"
|
||||
)
|
||||
return changed, default_access_mode
|
||||
|
||||
|
||||
def update_web_search_settings(config: Config, query: QueryParams) -> tuple[bool, bool]:
|
||||
provider_name = (query_first(query, "provider") or "").strip().lower()
|
||||
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
|
||||
if provider_option is None:
|
||||
raise WebUISettingsError("unknown web search provider")
|
||||
|
||||
search_config = config.tools.web.search
|
||||
web_config = config.tools.web
|
||||
previous_provider = search_config.provider
|
||||
changed = False
|
||||
restart_required = False
|
||||
|
||||
def set_search_value(attr: str, value: object) -> None:
|
||||
nonlocal changed
|
||||
if getattr(search_config, attr) != value:
|
||||
setattr(search_config, attr, value)
|
||||
changed = True
|
||||
|
||||
def set_fetch_value(attr: str, value: object) -> None:
|
||||
nonlocal changed
|
||||
if getattr(web_config.fetch, attr) != value:
|
||||
setattr(web_config.fetch, attr, value)
|
||||
changed = True
|
||||
|
||||
if search_config.provider != provider_name:
|
||||
search_config.provider = provider_name
|
||||
changed = True
|
||||
|
||||
credential = provider_option["credential"]
|
||||
if credential == "none":
|
||||
set_search_value("api_key", "")
|
||||
set_search_value("base_url", "")
|
||||
elif credential == "base_url":
|
||||
base_url = query_first_alias(query, "base_url", "baseUrl")
|
||||
base_url = base_url.strip() if base_url is not None else None
|
||||
if not base_url and previous_provider == provider_name and search_config.base_url:
|
||||
base_url = search_config.base_url
|
||||
if not base_url:
|
||||
raise WebUISettingsError("base_url is required")
|
||||
set_search_value("base_url", base_url)
|
||||
set_search_value("api_key", "")
|
||||
elif credential in {"api_key", "optional_api_key"}:
|
||||
raw_api_key = query_first_alias(query, "api_key", "apiKey")
|
||||
api_key = raw_api_key.strip() if raw_api_key is not None else None
|
||||
if api_key is None and previous_provider == provider_name and search_config.api_key:
|
||||
api_key = search_config.api_key
|
||||
if credential == "api_key" and not api_key:
|
||||
raise WebUISettingsError("api_key is required")
|
||||
set_search_value("api_key", api_key or "")
|
||||
set_search_value("base_url", "")
|
||||
else:
|
||||
raise WebUISettingsError("unknown web search credential type")
|
||||
|
||||
max_results = query_first_alias(query, "max_results", "maxResults")
|
||||
if max_results is not None:
|
||||
try:
|
||||
parsed = int(max_results)
|
||||
except ValueError:
|
||||
raise WebUISettingsError("max_results must be an integer") from None
|
||||
if parsed < 1 or parsed > 10:
|
||||
raise WebUISettingsError("max_results must be between 1 and 10")
|
||||
set_search_value("max_results", parsed)
|
||||
|
||||
timeout = query_first(query, "timeout")
|
||||
if timeout is not None:
|
||||
try:
|
||||
parsed_timeout = int(timeout)
|
||||
except ValueError:
|
||||
raise WebUISettingsError("timeout must be an integer") from None
|
||||
if parsed_timeout < 1 or parsed_timeout > 120:
|
||||
raise WebUISettingsError("timeout must be between 1 and 120")
|
||||
set_search_value("timeout", parsed_timeout)
|
||||
|
||||
use_jina_reader = query_first_alias(query, "use_jina_reader", "useJinaReader")
|
||||
if use_jina_reader is not None:
|
||||
previous_jina_reader = web_config.fetch.use_jina_reader
|
||||
set_fetch_value("use_jina_reader", parse_bool(use_jina_reader, "use_jina_reader"))
|
||||
if web_config.fetch.use_jina_reader != previous_jina_reader:
|
||||
restart_required = True
|
||||
return changed, restart_required
|
||||
|
||||
|
||||
def update_api_settings(config: Config, query: QueryParams) -> None:
|
||||
"""Update the managed OpenAI-compatible API configuration."""
|
||||
api = config.api
|
||||
host = query_first(query, "host")
|
||||
if host is not None:
|
||||
host = host.strip()
|
||||
if not host:
|
||||
raise WebUISettingsError("host is required")
|
||||
api.host = host
|
||||
|
||||
port = query_first(query, "port")
|
||||
if port is not None:
|
||||
try:
|
||||
parsed_port = int(port)
|
||||
except ValueError:
|
||||
raise WebUISettingsError("port must be an integer") from None
|
||||
if parsed_port < 1 or parsed_port > 65535:
|
||||
raise WebUISettingsError("port must be between 1 and 65535")
|
||||
api.port = parsed_port
|
||||
|
||||
timeout = query_first(query, "timeout")
|
||||
if timeout is not None:
|
||||
try:
|
||||
parsed_timeout = float(timeout)
|
||||
except ValueError:
|
||||
raise WebUISettingsError("timeout must be a number") from None
|
||||
if parsed_timeout < 1 or parsed_timeout > 3600:
|
||||
raise WebUISettingsError("timeout must be between 1 and 3600")
|
||||
api.timeout = parsed_timeout
|
||||
|
||||
api_key = query_first_alias(query, "api_key", "apiKey")
|
||||
if api_key is not None:
|
||||
api.api_key = api_key.strip()
|
||||
if not is_loopback_host(api.host) and not api.api_key.strip():
|
||||
raise WebUISettingsError(
|
||||
"an API key is required when the API is available on the network"
|
||||
)
|
||||
|
||||
|
||||
def update_image_generation_settings(
|
||||
config: Config,
|
||||
query: QueryParams,
|
||||
*,
|
||||
oauth_status: OAuthStatusReader,
|
||||
) -> bool:
|
||||
image_config = config.tools.image_generation
|
||||
changed = False
|
||||
|
||||
provider_name = query_first(query, "provider")
|
||||
if provider_name is not None:
|
||||
provider_name = provider_name.strip().lower()
|
||||
if not provider_name:
|
||||
raise WebUISettingsError("image generation provider is required")
|
||||
if get_image_gen_provider(provider_name) is None:
|
||||
raise WebUISettingsError("unknown image generation provider")
|
||||
if image_config.provider != provider_name:
|
||||
image_config.provider = provider_name
|
||||
changed = True
|
||||
|
||||
enabled = query_first(query, "enabled")
|
||||
if enabled is not None:
|
||||
parsed_enabled = parse_bool(enabled, "enabled")
|
||||
if image_config.enabled != parsed_enabled:
|
||||
image_config.enabled = parsed_enabled
|
||||
changed = True
|
||||
|
||||
model = query_first(query, "model")
|
||||
if model is not None:
|
||||
model = model.strip()
|
||||
if not model:
|
||||
raise WebUISettingsError("image generation model is required")
|
||||
if len(model) > 200:
|
||||
raise WebUISettingsError("image generation model is too long")
|
||||
if image_config.model != model:
|
||||
image_config.model = model
|
||||
changed = True
|
||||
|
||||
default_aspect_ratio = query_first_alias(
|
||||
query,
|
||||
"default_aspect_ratio",
|
||||
"defaultAspectRatio",
|
||||
)
|
||||
if default_aspect_ratio is not None:
|
||||
default_aspect_ratio = default_aspect_ratio.strip()
|
||||
if default_aspect_ratio not in _IMAGE_GENERATION_ASPECT_RATIOS:
|
||||
raise WebUISettingsError("unsupported image generation aspect ratio")
|
||||
if image_config.default_aspect_ratio != default_aspect_ratio:
|
||||
image_config.default_aspect_ratio = default_aspect_ratio
|
||||
changed = True
|
||||
|
||||
default_image_size = query_first_alias(
|
||||
query,
|
||||
"default_image_size",
|
||||
"defaultImageSize",
|
||||
)
|
||||
if default_image_size is not None:
|
||||
default_image_size = default_image_size.strip()
|
||||
if not default_image_size:
|
||||
raise WebUISettingsError("default image size is required")
|
||||
if len(default_image_size) > 32 or not all(
|
||||
char.isascii() and (char.isalnum() or char in {"x", "X", ":", "-", "_"})
|
||||
for char in default_image_size
|
||||
):
|
||||
raise WebUISettingsError("unsupported image generation size")
|
||||
if image_config.default_image_size != default_image_size:
|
||||
image_config.default_image_size = default_image_size
|
||||
changed = True
|
||||
|
||||
max_images_per_turn = query_first_alias(
|
||||
query,
|
||||
"max_images_per_turn",
|
||||
"maxImagesPerTurn",
|
||||
)
|
||||
if max_images_per_turn is not None:
|
||||
try:
|
||||
parsed_max = int(max_images_per_turn)
|
||||
except ValueError:
|
||||
raise WebUISettingsError("max_images_per_turn must be an integer") from None
|
||||
if parsed_max < 1 or parsed_max > 8:
|
||||
raise WebUISettingsError("max_images_per_turn must be between 1 and 8")
|
||||
if image_config.max_images_per_turn != parsed_max:
|
||||
image_config.max_images_per_turn = parsed_max
|
||||
changed = True
|
||||
|
||||
if image_config.enabled:
|
||||
selected_provider = next(
|
||||
(
|
||||
provider
|
||||
for provider in _image_generation_provider_rows(
|
||||
config,
|
||||
oauth_status=oauth_status,
|
||||
)
|
||||
if provider["name"] == image_config.provider
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not selected_provider or not selected_provider["configured"]:
|
||||
raise WebUISettingsError("image generation provider is not configured")
|
||||
return changed
|
||||
|
||||
|
||||
def update_transcription_settings(config: Config, query: QueryParams) -> bool:
|
||||
transcription = config.transcription
|
||||
changed = False
|
||||
|
||||
enabled = query_first(query, "enabled")
|
||||
if enabled is not None:
|
||||
parsed_enabled = parse_bool(enabled, "enabled")
|
||||
if transcription.enabled != parsed_enabled:
|
||||
transcription.enabled = parsed_enabled
|
||||
changed = True
|
||||
|
||||
provider = query_first(query, "provider")
|
||||
if provider is not None:
|
||||
provider = provider.strip().lower()
|
||||
provider_spec = resolve_transcription_provider(provider)
|
||||
if provider_spec is None:
|
||||
raise WebUISettingsError("unknown transcription provider")
|
||||
provider = provider_spec.name
|
||||
if transcription.provider != provider:
|
||||
transcription.provider = provider
|
||||
changed = True
|
||||
|
||||
model = query_first(query, "model")
|
||||
if model is not None:
|
||||
model = model.strip() or None
|
||||
if model is not None and len(model) > 200:
|
||||
raise WebUISettingsError("transcription model is too long")
|
||||
if transcription.model != model:
|
||||
transcription.model = model
|
||||
changed = True
|
||||
|
||||
language = query_first(query, "language")
|
||||
if language is not None:
|
||||
language = language.strip().lower() or None
|
||||
if language is not None and not re.fullmatch(r"[a-z]{2,3}", language):
|
||||
raise WebUISettingsError(
|
||||
"transcription language must be 2-3 lowercase letters"
|
||||
)
|
||||
if transcription.language != language:
|
||||
transcription.language = language
|
||||
changed = True
|
||||
|
||||
max_duration_sec = query_first_alias(query, "max_duration_sec", "maxDurationSec")
|
||||
if max_duration_sec is not None:
|
||||
try:
|
||||
parsed_duration = int(max_duration_sec)
|
||||
except ValueError:
|
||||
raise WebUISettingsError("max_duration_sec must be an integer") from None
|
||||
if parsed_duration < 1 or parsed_duration > 600:
|
||||
raise WebUISettingsError("max_duration_sec must be between 1 and 600")
|
||||
if transcription.max_duration_sec != parsed_duration:
|
||||
transcription.max_duration_sec = parsed_duration
|
||||
changed = True
|
||||
|
||||
max_upload_mb = query_first_alias(query, "max_upload_mb", "maxUploadMb")
|
||||
if max_upload_mb is not None:
|
||||
try:
|
||||
parsed_upload = int(max_upload_mb)
|
||||
except ValueError:
|
||||
raise WebUISettingsError("max_upload_mb must be an integer") from None
|
||||
if parsed_upload < 1 or parsed_upload > 100:
|
||||
raise WebUISettingsError("max_upload_mb must be between 1 and 100")
|
||||
if transcription.max_upload_mb != parsed_upload:
|
||||
transcription.max_upload_mb = parsed_upload
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def network_safety_payload(config: Config) -> dict[str, Any]:
|
||||
"""Return the network-related fields embedded in the advanced DTO."""
|
||||
return {
|
||||
"webui_allow_local_service_access": config.tools.webui_allow_local_service_access,
|
||||
"allow_local_preview_access": config.tools.webui_allow_local_service_access,
|
||||
"webui_default_access_mode": read_webui_default_access_mode(),
|
||||
"private_service_protection_enabled": True,
|
||||
"ssrf_whitelist_count": len(config.tools.ssrf_whitelist),
|
||||
}
|
||||
|
||||
|
||||
def masked_api_secret(value: str) -> str | None:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return None
|
||||
return f"{value[:3]}...{value[-4:]}" if len(value) > 8 else "configured"
|
||||
|
||||
|
||||
def api_runtime_message(message: str) -> str:
|
||||
known = {
|
||||
"api_exited_during_startup": "API server exited during startup. Check its log for details.",
|
||||
"api_stop_timeout": "API server did not stop in time.",
|
||||
"api_state_stale": "API server state was stale; try starting it again.",
|
||||
}
|
||||
if message in known:
|
||||
return known[message]
|
||||
if message.startswith("api_"):
|
||||
return f"API server {message.removeprefix('api_').replace('_', ' ')}"
|
||||
return message.replace("_", " ")
|
||||
|
||||
|
||||
def api_service_payload(
|
||||
settings: WebUISettingsServices,
|
||||
runtime: ApiRuntime,
|
||||
*,
|
||||
last_action: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
config = settings.config.load()
|
||||
status = runtime.status()
|
||||
extras = optional_dependency_groups()
|
||||
connect_host = (
|
||||
"127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host
|
||||
)
|
||||
payload = {
|
||||
"installed": extra_installed("api", extras.get("api")),
|
||||
"running": status.running,
|
||||
"managed": status.running,
|
||||
"host": config.api.host,
|
||||
"port": config.api.port,
|
||||
"timeout": config.api.timeout,
|
||||
"api_key_hint": masked_api_secret(config.api.api_key),
|
||||
"endpoint": f"http://{connect_host}:{config.api.port}/v1",
|
||||
"command": "nanobot serve",
|
||||
"log_path": str(status.log_path),
|
||||
}
|
||||
if last_action:
|
||||
payload["last_action"] = last_action
|
||||
return payload
|
||||
|
||||
|
||||
class CapabilitySettingsHandler:
|
||||
"""Handle capability commands after transport authentication and decoding."""
|
||||
|
||||
def __init__(self, settings: WebUISettingsServices, logger: Any) -> None:
|
||||
self.settings = settings
|
||||
self.logger = logger
|
||||
|
||||
async def handle(
|
||||
self,
|
||||
action: str,
|
||||
request: SettingsRequest,
|
||||
operations: CapabilitySettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
if action == "api-status":
|
||||
return SettingsRouteResult.success(
|
||||
api_service_payload(self.settings, operations.api_runtime())
|
||||
)
|
||||
if action == "api-start":
|
||||
return await self._start_api(request, operations)
|
||||
if action == "api-stop":
|
||||
return await self._stop_api(operations)
|
||||
|
||||
mutation = {
|
||||
"web-search-update": (
|
||||
operations.update_web_search,
|
||||
"browser",
|
||||
False,
|
||||
),
|
||||
"transcription-update": (
|
||||
operations.update_transcription,
|
||||
None,
|
||||
False,
|
||||
),
|
||||
"network-update": (
|
||||
operations.update_network,
|
||||
"runtime",
|
||||
False,
|
||||
),
|
||||
"image-update": (
|
||||
operations.update_image,
|
||||
"image",
|
||||
True,
|
||||
),
|
||||
}.get(action)
|
||||
if mutation is None:
|
||||
return SettingsRouteResult.failure(404, "unknown settings action")
|
||||
|
||||
operation, section, apply_image_reload = mutation
|
||||
try:
|
||||
payload = self.settings.mutate(operation, request.query)
|
||||
except WebUISettingsError as exc:
|
||||
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||
if apply_image_reload:
|
||||
payload, image_restart_cleared = await self.apply_image_runtime_change(
|
||||
payload,
|
||||
operations.reload_image,
|
||||
)
|
||||
else:
|
||||
image_restart_cleared = False
|
||||
return SettingsRouteResult.success(
|
||||
payload,
|
||||
decorate_restart=True,
|
||||
restart_section=section,
|
||||
clear_restart_section=("image" if image_restart_cleared else None),
|
||||
)
|
||||
|
||||
async def apply_image_runtime_change(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
reload_image: Callable[[], Awaitable[dict[str, Any]]],
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
"""Hot-apply image settings, preserving restart fallback on failure."""
|
||||
if not payload.get("requires_restart"):
|
||||
return payload, False
|
||||
try:
|
||||
result = await reload_image()
|
||||
except Exception:
|
||||
self.logger.exception("failed to hot-reload image generation settings")
|
||||
return payload, False
|
||||
|
||||
applied = bool(result.get("ok")) and not result.get("requires_restart")
|
||||
updated = dict(payload)
|
||||
updated["requires_restart"] = not applied
|
||||
if not applied:
|
||||
self.logger.warning(
|
||||
"image generation settings were saved but require restart: {}",
|
||||
result.get("message") or "hot reload failed",
|
||||
)
|
||||
return updated, applied
|
||||
|
||||
async def _start_api(
|
||||
self,
|
||||
request: SettingsRequest,
|
||||
operations: CapabilitySettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
api_key = (request.payload or {}).get("api_key")
|
||||
if api_key is not None and not isinstance(api_key, str):
|
||||
return SettingsRouteResult.failure(
|
||||
400,
|
||||
"API service API key must be a string",
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
self.settings.mutate,
|
||||
operations.nanobot_features_action,
|
||||
"enable",
|
||||
{"name": ["api"]},
|
||||
allow_install=self._allow_feature_package_install(request),
|
||||
)
|
||||
self.settings.mutate(operations.update_api, request.query)
|
||||
config = self.settings.config.load()
|
||||
runtime = operations.api_runtime()
|
||||
options = ApiStartOptions(
|
||||
host=config.api.host,
|
||||
port=config.api.port,
|
||||
workspace=str(config.workspace_path),
|
||||
config_path=str(self.settings.config.path),
|
||||
)
|
||||
current = runtime.status()
|
||||
result = await asyncio.to_thread(
|
||||
runtime.restart if current.running else runtime.start_background,
|
||||
options,
|
||||
)
|
||||
if not result.ok:
|
||||
return SettingsRouteResult.failure(
|
||||
500,
|
||||
api_runtime_message(result.message),
|
||||
)
|
||||
except (WebUISettingsError, OptionalFeatureError) as exc:
|
||||
return SettingsRouteResult.failure(
|
||||
getattr(exc, "status", 400),
|
||||
getattr(exc, "message", str(exc)),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.logger.exception("failed to start managed API service")
|
||||
return SettingsRouteResult.failure(500, str(exc))
|
||||
return SettingsRouteResult.success(
|
||||
api_service_payload(
|
||||
self.settings,
|
||||
operations.api_runtime(),
|
||||
last_action="started",
|
||||
)
|
||||
)
|
||||
|
||||
async def _stop_api(
|
||||
self,
|
||||
operations: CapabilitySettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
runtime = operations.api_runtime()
|
||||
try:
|
||||
result = await asyncio.to_thread(runtime.stop)
|
||||
except Exception as exc:
|
||||
self.logger.exception("failed to stop managed API service")
|
||||
return SettingsRouteResult.failure(500, str(exc))
|
||||
if not result.ok and result.message != "api_not_running":
|
||||
return SettingsRouteResult.failure(
|
||||
500,
|
||||
api_runtime_message(result.message),
|
||||
)
|
||||
return SettingsRouteResult.success(
|
||||
api_service_payload(
|
||||
self.settings,
|
||||
operations.api_runtime(),
|
||||
last_action="stopped",
|
||||
)
|
||||
)
|
||||
|
||||
def _allow_feature_package_install(self, request: SettingsRequest) -> bool:
|
||||
if request.local_browser:
|
||||
return True
|
||||
try:
|
||||
return bool(
|
||||
self.settings.config.load().tools.webui_allow_remote_package_install
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("failed to load remote package install policy")
|
||||
return False
|
||||
@@ -1,82 +0,0 @@
|
||||
"""Stable request and error contracts shared by WebUI settings domains."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
QueryParams = dict[str, list[str]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SettingsRequest:
|
||||
"""Transport-neutral input decoded by the settings route facade."""
|
||||
|
||||
query: QueryParams
|
||||
payload: dict[str, Any] | None = None
|
||||
local_browser: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SettingsRouteResult:
|
||||
"""Transport-neutral result returned by a settings domain handler."""
|
||||
|
||||
payload: dict[str, Any] | None = None
|
||||
status: int = 200
|
||||
error: str | None = None
|
||||
decorate_restart: bool = False
|
||||
restart_section: str | None = None
|
||||
clear_restart_section: str | None = None
|
||||
restart_payload_key: str | None = None
|
||||
|
||||
@classmethod
|
||||
def success(
|
||||
cls,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
decorate_restart: bool = False,
|
||||
restart_section: str | None = None,
|
||||
clear_restart_section: str | None = None,
|
||||
restart_payload_key: str | None = None,
|
||||
) -> SettingsRouteResult:
|
||||
return cls(
|
||||
payload=payload,
|
||||
decorate_restart=decorate_restart,
|
||||
restart_section=restart_section,
|
||||
clear_restart_section=clear_restart_section,
|
||||
restart_payload_key=restart_payload_key,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def failure(cls, status: int, error: str) -> SettingsRouteResult:
|
||||
return cls(status=status, error=error)
|
||||
|
||||
|
||||
class WebUISettingsError(ValueError):
|
||||
"""User-facing settings validation failure."""
|
||||
|
||||
def __init__(self, message: str, *, status: int = 400) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
def query_first(query: QueryParams, key: str) -> str | None:
|
||||
values = query.get(key)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def query_first_alias(query: QueryParams, snake: str, camel: str) -> str | None:
|
||||
value = query_first(query, snake)
|
||||
return query_first(query, camel) if value is None else value
|
||||
|
||||
|
||||
def query_has_alias(query: QueryParams, snake: str, camel: str) -> bool:
|
||||
return snake in query or camel in query
|
||||
|
||||
|
||||
def parse_bool(value: str, field: str) -> bool:
|
||||
normalized = value.strip().lower()
|
||||
if normalized not in {"1", "0", "true", "false", "yes", "no"}:
|
||||
raise WebUISettingsError(f"{field} must be boolean")
|
||||
return normalized in {"1", "true", "yes"}
|
||||
File diff suppressed because it is too large
Load Diff
+1026
-585
File diff suppressed because it is too large
Load Diff
@@ -1,148 +0,0 @@
|
||||
"""Gateway-owned state for the WebUI settings surface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
_T = TypeVar("_T")
|
||||
_WEBUI_OAUTH_MAX_FLOWS = 8
|
||||
|
||||
|
||||
class WebUISettingsConfig:
|
||||
"""Instance-scoped config access with serialized read-modify-write operations."""
|
||||
|
||||
def __init__(self, config_path: Path) -> None:
|
||||
self.path = config_path.expanduser().resolve(strict=False)
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def load(self) -> Config:
|
||||
"""Load this gateway's config without consulting the process-global path."""
|
||||
with self._lock:
|
||||
return load_config(self.path)
|
||||
|
||||
def update(self, mutation: Callable[[Config], _T]) -> _T:
|
||||
"""Apply and atomically persist one in-process read-modify-write operation."""
|
||||
with self._lock:
|
||||
config = load_config(self.path)
|
||||
result = mutation(config)
|
||||
save_config(config, self.path)
|
||||
return result
|
||||
|
||||
def run_serialized(self, operation: Callable[[Path], _T]) -> _T:
|
||||
"""Run a path-aware read-modify-write operation under the instance lock."""
|
||||
with self._lock:
|
||||
return operation(self.path)
|
||||
|
||||
|
||||
class WebUIOAuthFlowRegistry:
|
||||
"""Bounded, thread-safe OAuth flows owned by one gateway instance."""
|
||||
|
||||
def __init__(self, *, max_flows: int = _WEBUI_OAUTH_MAX_FLOWS) -> None:
|
||||
if max_flows < 1:
|
||||
raise ValueError("max_flows must be at least one")
|
||||
self._max_flows = max_flows
|
||||
self._flows: dict[str, tuple[str, Any]] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def register(self, provider_name: str, flow_id: str, flow: Any) -> None:
|
||||
discarded: list[Any] = []
|
||||
with self._lock:
|
||||
for existing_id, (_provider_name, existing) in list(self._flows.items()):
|
||||
if existing.expired:
|
||||
discarded.append(self._flows.pop(existing_id)[1])
|
||||
while len(self._flows) >= self._max_flows:
|
||||
oldest_id = next(iter(self._flows))
|
||||
discarded.append(self._flows.pop(oldest_id)[1])
|
||||
self._flows[flow_id] = (provider_name, flow)
|
||||
for existing in discarded:
|
||||
existing.cancel()
|
||||
|
||||
def get(self, provider_name: str, flow_id: str) -> Any | None:
|
||||
with self._lock:
|
||||
registered = self._flows.get(flow_id)
|
||||
if registered is None or registered[0] != provider_name:
|
||||
return None
|
||||
flow = registered[1]
|
||||
if not flow.expired:
|
||||
return flow
|
||||
self._flows.pop(flow_id, None)
|
||||
flow.cancel()
|
||||
return None
|
||||
|
||||
def remove(
|
||||
self,
|
||||
provider_name: str,
|
||||
flow_id: str,
|
||||
flow: Any,
|
||||
*,
|
||||
cancel: bool = True,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
registered = self._flows.get(flow_id)
|
||||
if (
|
||||
registered is not None
|
||||
and registered[0] == provider_name
|
||||
and registered[1] is flow
|
||||
):
|
||||
self._flows.pop(flow_id)
|
||||
if cancel:
|
||||
flow.cancel()
|
||||
|
||||
def clear(self, provider_name: str) -> None:
|
||||
with self._lock:
|
||||
flow_ids = [
|
||||
flow_id
|
||||
for flow_id, (registered_provider, _flow) in self._flows.items()
|
||||
if registered_provider == provider_name
|
||||
]
|
||||
flows = [self._flows.pop(flow_id)[1] for flow_id in flow_ids]
|
||||
for flow in flows:
|
||||
flow.cancel()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WebUISettingsServices:
|
||||
"""Settings dependencies composed once for a gateway instance."""
|
||||
|
||||
config: WebUISettingsConfig
|
||||
oauth_flows: WebUIOAuthFlowRegistry
|
||||
|
||||
@classmethod
|
||||
def create(cls, config_path: Path) -> WebUISettingsServices:
|
||||
return cls(
|
||||
config=WebUISettingsConfig(config_path),
|
||||
oauth_flows=WebUIOAuthFlowRegistry(),
|
||||
)
|
||||
|
||||
def read(
|
||||
self,
|
||||
operation: Callable[..., _T],
|
||||
/,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> _T:
|
||||
"""Run a settings read against this gateway's explicit config path."""
|
||||
return operation(*args, config_path=self.config.path, **kwargs)
|
||||
|
||||
def mutate(
|
||||
self,
|
||||
operation: Callable[..., _T],
|
||||
/,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> _T:
|
||||
"""Serialize a path-aware settings read-modify-write operation."""
|
||||
return self.config.run_serialized(
|
||||
lambda config_path: operation(
|
||||
*args,
|
||||
config_path=config_path,
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
@@ -1,959 +0,0 @@
|
||||
"""System and channel settings domain logic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, TypedDict, cast
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from nanobot.channels._setup import channel_setup_spec
|
||||
from nanobot.channels.connect import ChannelConnectError
|
||||
from nanobot.channels.contracts import (
|
||||
RouteFieldType,
|
||||
channel_instance_config,
|
||||
channel_update_instance_config,
|
||||
)
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.optional_features import OptionalFeatureError, with_channel_runtime_status
|
||||
from nanobot.security.workspace_access import workspace_sandbox_status
|
||||
from nanobot.webui.settings_capabilities import network_safety_payload
|
||||
from nanobot.webui.settings_contracts import (
|
||||
QueryParams,
|
||||
SettingsRequest,
|
||||
SettingsRouteResult,
|
||||
WebUISettingsError,
|
||||
query_first,
|
||||
query_first_alias,
|
||||
)
|
||||
from nanobot.webui.token_usage import token_usage_payload
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.webui.settings_services import WebUISettingsServices
|
||||
|
||||
LoadChannelPlugin = Callable[[str], Any]
|
||||
ListPendingPairings = Callable[[], Iterable[dict[str, Any]]]
|
||||
SettingsOperation = Callable[..., Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SystemSettingsOperations:
|
||||
cli_apps_payload: SettingsOperation
|
||||
cli_apps_action: SettingsOperation
|
||||
nanobot_features_payload: SettingsOperation
|
||||
nanobot_features_action: SettingsOperation
|
||||
nanobot_feature_instance_target: SettingsOperation
|
||||
validate_channel_config: SettingsOperation
|
||||
load_channel_plugin: LoadChannelPlugin
|
||||
list_pending: ListPendingPairings
|
||||
approve_code: SettingsOperation
|
||||
deny_code: SettingsOperation
|
||||
mcp_presets_action: SettingsOperation
|
||||
reload_mcp: SettingsOperation
|
||||
mcp_runtime_status: Callable[[], Mapping[str, str]] | None
|
||||
check_for_update: SettingsOperation
|
||||
channel_feature_action: SettingsOperation | None = None
|
||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None
|
||||
|
||||
|
||||
class SystemSettingsPayload(TypedDict):
|
||||
runtime: dict[str, Any]
|
||||
usage: dict[str, Any]
|
||||
advanced: dict[str, Any]
|
||||
version: dict[str, Any]
|
||||
docs: dict[str, Any]
|
||||
|
||||
|
||||
_DOCS_STABLE_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:\.post\d+)?$")
|
||||
_DOCS_LATEST_URL = "https://nanobot.wiki/docs/latest"
|
||||
_SKIP_FIELD = object()
|
||||
|
||||
|
||||
def docs_version(version: str) -> str:
|
||||
"""Map package versions to the matching public docs path."""
|
||||
normalized = version.strip()
|
||||
if _DOCS_STABLE_VERSION_RE.fullmatch(normalized):
|
||||
return normalized
|
||||
return "latest"
|
||||
|
||||
|
||||
def docs_payload(version: str) -> dict[str, Any]:
|
||||
selected_version = docs_version(version)
|
||||
base_url = f"https://nanobot.wiki/docs/{selected_version}"
|
||||
return {
|
||||
"version": selected_version,
|
||||
"base_url": base_url,
|
||||
"chat_apps_url": f"{base_url}/getting-started/chat-apps",
|
||||
"latest_url": _DOCS_LATEST_URL,
|
||||
}
|
||||
|
||||
|
||||
def system_settings_payload(
|
||||
config: Config,
|
||||
*,
|
||||
config_path: Path,
|
||||
version: str,
|
||||
) -> SystemSettingsPayload:
|
||||
defaults = config.agents.defaults
|
||||
exec_config = config.tools.exec
|
||||
sandbox_status = workspace_sandbox_status(
|
||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||
workspace=config.workspace_path,
|
||||
)
|
||||
return {
|
||||
"runtime": {
|
||||
"config_path": str(config_path.expanduser()),
|
||||
"workspace_path": str(config.workspace_path),
|
||||
"gateway_host": config.gateway.host,
|
||||
"gateway_port": config.gateway.port,
|
||||
"heartbeat": {
|
||||
"enabled": config.gateway.heartbeat.enabled,
|
||||
"interval_s": config.gateway.heartbeat.interval_s,
|
||||
"keep_recent_messages": config.gateway.heartbeat.keep_recent_messages,
|
||||
},
|
||||
"dream": {
|
||||
"schedule": defaults.dream.describe_schedule(),
|
||||
},
|
||||
"unified_session": defaults.unified_session,
|
||||
},
|
||||
"usage": token_usage_payload(timezone_name=defaults.timezone),
|
||||
"advanced": {
|
||||
"restrict_to_workspace": config.tools.restrict_to_workspace,
|
||||
"workspace_sandbox": sandbox_status.as_dict(),
|
||||
**network_safety_payload(config),
|
||||
"mcp_server_count": len(config.tools.mcp_servers),
|
||||
"exec_enabled": exec_config.enable,
|
||||
"exec_sandbox": exec_config.sandbox or None,
|
||||
"exec_path_prepend_set": bool(exec_config.path_prepend),
|
||||
"exec_path_append_set": bool(exec_config.path_append),
|
||||
},
|
||||
"version": {"current": version},
|
||||
"docs": docs_payload(version),
|
||||
}
|
||||
|
||||
|
||||
def settings_usage_payload(config: Config) -> dict[str, Any]:
|
||||
"""Return the lightweight token usage slice for Overview refreshes."""
|
||||
return token_usage_payload(timezone_name=config.agents.defaults.timezone)
|
||||
|
||||
|
||||
def update_agent_system_settings(config: Config, query: QueryParams) -> tuple[bool, bool]:
|
||||
defaults = config.agents.defaults
|
||||
changed = False
|
||||
restart_required = False
|
||||
|
||||
timezone = query_first(query, "timezone")
|
||||
if timezone is not None:
|
||||
timezone = timezone.strip()
|
||||
if not timezone:
|
||||
raise WebUISettingsError("timezone is required")
|
||||
try:
|
||||
ZoneInfo(timezone)
|
||||
except Exception:
|
||||
raise WebUISettingsError("invalid timezone") from None
|
||||
timezone_changed = defaults.timezone != timezone
|
||||
if timezone_changed or defaults.timezone_mode != "manual":
|
||||
defaults.timezone = timezone
|
||||
defaults.timezone_mode = "manual"
|
||||
changed = True
|
||||
restart_required = timezone_changed
|
||||
|
||||
tool_hint_max_length = query_first_alias(
|
||||
query,
|
||||
"tool_hint_max_length",
|
||||
"toolHintMaxLength",
|
||||
)
|
||||
if tool_hint_max_length is not None:
|
||||
try:
|
||||
parsed = int(tool_hint_max_length)
|
||||
except ValueError:
|
||||
raise WebUISettingsError(
|
||||
"tool_hint_max_length must be an integer"
|
||||
) from None
|
||||
if parsed < 20 or parsed > 500:
|
||||
raise WebUISettingsError(
|
||||
"tool_hint_max_length must be between 20 and 500"
|
||||
)
|
||||
if defaults.tool_hint_max_length != parsed:
|
||||
defaults.tool_hint_max_length = parsed
|
||||
changed = True
|
||||
restart_required = True
|
||||
return changed, restart_required
|
||||
|
||||
|
||||
def save_channel_config_values(
|
||||
config: Config,
|
||||
name: str,
|
||||
raw_values: dict[str, Any],
|
||||
instance_id: str = "default",
|
||||
*,
|
||||
load_channel_plugin: LoadChannelPlugin,
|
||||
) -> list[str]:
|
||||
if not name:
|
||||
raise WebUISettingsError("missing channel name")
|
||||
try:
|
||||
plugin = load_channel_plugin(name)
|
||||
except ImportError:
|
||||
raise WebUISettingsError(f"unknown channel '{name}'", status=404) from None
|
||||
setup_spec = channel_setup_spec(name, plugin=plugin)
|
||||
if setup_spec is None:
|
||||
raise WebUISettingsError(
|
||||
f"channel '{name}' cannot be configured from WebUI",
|
||||
status=404,
|
||||
)
|
||||
field_types = setup_spec.route_field_types
|
||||
if not raw_values:
|
||||
return []
|
||||
|
||||
section = getattr(config.channels, name, None)
|
||||
channel_config = channel_instance_config(
|
||||
plugin,
|
||||
section,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
saved: list[str] = []
|
||||
prefix = f"channels.{name}."
|
||||
for raw_key, raw_value in raw_values.items():
|
||||
if not raw_key:
|
||||
raise WebUISettingsError(
|
||||
"channel settings payload contains an invalid key"
|
||||
)
|
||||
field = raw_key[len(prefix) :] if raw_key.startswith(prefix) else raw_key
|
||||
value_type = field_types.get(field)
|
||||
if value_type is None:
|
||||
raise WebUISettingsError(f"'{raw_key}' cannot be configured from WebUI")
|
||||
value = coerce_channel_value(raw_key, raw_value, value_type)
|
||||
if value is _SKIP_FIELD:
|
||||
continue
|
||||
assign_channel_config_value(channel_config, field, value)
|
||||
saved.append(raw_key)
|
||||
|
||||
try:
|
||||
updated_section = channel_update_instance_config(
|
||||
plugin,
|
||||
section,
|
||||
channel_config,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise WebUISettingsError(
|
||||
f"Invalid {name} configuration: {exc}",
|
||||
status=400,
|
||||
) from exc
|
||||
setattr(config.channels, name, updated_section)
|
||||
return saved
|
||||
|
||||
|
||||
def coerce_channel_value(
|
||||
raw_key: str,
|
||||
raw_value: Any,
|
||||
value_type: RouteFieldType,
|
||||
) -> Any:
|
||||
if isinstance(value_type, tuple):
|
||||
kind = value_type[0]
|
||||
allowed = value_type[1]
|
||||
else:
|
||||
kind = value_type
|
||||
allowed = None
|
||||
|
||||
if kind in {"string", "secret"}:
|
||||
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value)
|
||||
if kind == "secret" and not value:
|
||||
return _SKIP_FIELD
|
||||
return value
|
||||
|
||||
if kind == "list":
|
||||
if raw_value is None:
|
||||
return []
|
||||
if isinstance(raw_value, str):
|
||||
return [item.strip() for item in raw_value.split(",") if item.strip()]
|
||||
if isinstance(raw_value, list):
|
||||
return [
|
||||
str(item).strip()
|
||||
for item in cast(list[Any], raw_value)
|
||||
if str(item).strip()
|
||||
]
|
||||
raise WebUISettingsError(f"'{raw_key}' must be a comma-separated list")
|
||||
|
||||
if kind == "int":
|
||||
if raw_value in (None, ""):
|
||||
return _SKIP_FIELD
|
||||
try:
|
||||
return int(raw_value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise WebUISettingsError(f"'{raw_key}' must be a number") from exc
|
||||
|
||||
if kind == "bool":
|
||||
if isinstance(raw_value, bool):
|
||||
return raw_value
|
||||
value = str(raw_value).strip().lower()
|
||||
if value in {"true", "1", "yes", "on"}:
|
||||
return True
|
||||
if value in {"false", "0", "no", "off"}:
|
||||
return False
|
||||
raise WebUISettingsError(f"'{raw_key}' must be true or false")
|
||||
|
||||
if kind == "enum":
|
||||
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value)
|
||||
if not value:
|
||||
return _SKIP_FIELD
|
||||
if allowed is None or value not in allowed:
|
||||
options = ", ".join(sorted(allowed or ()))
|
||||
raise WebUISettingsError(f"'{raw_key}' must be one of: {options}")
|
||||
return value
|
||||
|
||||
raise WebUISettingsError(f"'{raw_key}' has an unsupported field type")
|
||||
|
||||
|
||||
def assign_channel_config_value(
|
||||
channel_config: dict[str, Any],
|
||||
field: str,
|
||||
value: Any,
|
||||
) -> None:
|
||||
target = channel_config
|
||||
parts = field.split(".")
|
||||
for part in parts[:-1]:
|
||||
current: object = target.get(part)
|
||||
if not isinstance(current, dict):
|
||||
current = {}
|
||||
target[part] = current
|
||||
target = cast(dict[str, Any], current)
|
||||
target[parts[-1]] = value
|
||||
|
||||
|
||||
def pairing_payload(
|
||||
list_pending: ListPendingPairings,
|
||||
last_action: dict[str, Any] | None = None,
|
||||
*,
|
||||
now: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
current_time = time.time() if now is None else now
|
||||
requests: list[dict[str, Any]] = []
|
||||
for item in list_pending():
|
||||
expires_at = float(item.get("expires_at", 0) or 0)
|
||||
created_at = float(item.get("created_at", 0) or 0)
|
||||
requests.append(
|
||||
{
|
||||
"code": str(item.get("code", "")),
|
||||
"channel": str(item.get("channel", "")),
|
||||
"sender_id": str(item.get("sender_id", "")),
|
||||
"created_at_ms": int(created_at * 1000) if created_at else None,
|
||||
"expires_at_ms": int(expires_at * 1000) if expires_at else None,
|
||||
"expires_in_seconds": (
|
||||
max(0, int(expires_at - current_time)) if expires_at else None
|
||||
),
|
||||
}
|
||||
)
|
||||
payload: dict[str, Any] = {"requests": requests}
|
||||
if last_action is not None:
|
||||
payload["last_action"] = last_action
|
||||
return payload
|
||||
|
||||
|
||||
class SystemSettingsHandler:
|
||||
"""Handle channel and system commands behind a transport-neutral request DTO."""
|
||||
|
||||
def __init__(self, settings: WebUISettingsServices, logger: Any) -> None:
|
||||
self.settings = settings
|
||||
self.logger = logger
|
||||
self._channel_connectors: dict[str, Any] = {}
|
||||
|
||||
async def handle(
|
||||
self,
|
||||
action: str,
|
||||
request: SettingsRequest,
|
||||
operations: SystemSettingsOperations,
|
||||
*,
|
||||
channel_name: str | None = None,
|
||||
connect_action: str | None = None,
|
||||
) -> SettingsRouteResult:
|
||||
if action == "cli-list":
|
||||
return await self._cli_apps(request, operations)
|
||||
if action.startswith("cli-"):
|
||||
return await self._cli_apps_action(
|
||||
request,
|
||||
action.removeprefix("cli-"),
|
||||
operations,
|
||||
)
|
||||
if action == "features-list":
|
||||
return await self._features(operations)
|
||||
if action in {"features-enable", "features-disable"}:
|
||||
return await self._features_action(
|
||||
request,
|
||||
action.removeprefix("features-"),
|
||||
operations,
|
||||
)
|
||||
if action == "channel-validate":
|
||||
return await self._channel_validate(request, operations)
|
||||
if action == "channel-configure":
|
||||
return await self._channel_configure(request, operations)
|
||||
if action == "channel-connect" and channel_name and connect_action:
|
||||
return await self._channel_connect(
|
||||
request,
|
||||
channel_name,
|
||||
connect_action,
|
||||
operations,
|
||||
)
|
||||
if action == "pairing-list":
|
||||
return SettingsRouteResult.success(pairing_payload(operations.list_pending))
|
||||
if action in {"pairing-approve", "pairing-deny"}:
|
||||
return self._pairing_action(
|
||||
request,
|
||||
action.removeprefix("pairing-"),
|
||||
operations,
|
||||
)
|
||||
if action == "mcp-list":
|
||||
return await self._mcp_presets(request, None, operations)
|
||||
if action.startswith("mcp-"):
|
||||
return await self._mcp_presets(
|
||||
request,
|
||||
action.removeprefix("mcp-"),
|
||||
operations,
|
||||
)
|
||||
if action == "version-check":
|
||||
return await self._version_check(operations)
|
||||
return SettingsRouteResult.failure(404, "unknown settings action")
|
||||
|
||||
async def _cli_apps(
|
||||
self,
|
||||
request: SettingsRequest,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
installed_only = (query_first(request.query, "installed_only") or "").lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}
|
||||
try:
|
||||
payload = await operations.cli_apps_payload(
|
||||
installed_only=installed_only,
|
||||
config_path=self.settings.config.path,
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("failed to load CLI Apps payload")
|
||||
return SettingsRouteResult.failure(500, "failed to load CLI Apps")
|
||||
return SettingsRouteResult.success(payload)
|
||||
|
||||
async def _cli_apps_action(
|
||||
self,
|
||||
request: SettingsRequest,
|
||||
action: str,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
try:
|
||||
payload = await asyncio.to_thread(
|
||||
operations.cli_apps_action,
|
||||
action,
|
||||
request.query,
|
||||
config_path=self.settings.config.path,
|
||||
)
|
||||
except WebUISettingsError as exc:
|
||||
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||
except Exception as exc:
|
||||
status = getattr(exc, "status", 500)
|
||||
message = getattr(exc, "message", str(exc))
|
||||
if status >= 500:
|
||||
self.logger.exception("CLI Apps action '{}' failed", action)
|
||||
return SettingsRouteResult.failure(status, message)
|
||||
return SettingsRouteResult.success(payload)
|
||||
|
||||
async def _features(
|
||||
self,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
try:
|
||||
payload = await asyncio.to_thread(
|
||||
operations.nanobot_features_payload,
|
||||
config_path=self.settings.config.path,
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("failed to load nanobot features")
|
||||
return SettingsRouteResult.failure(500, "failed to load nanobot features")
|
||||
return SettingsRouteResult.success(
|
||||
self._with_channel_runtime_status(payload, operations)
|
||||
)
|
||||
|
||||
def _nanobot_features_payload(
|
||||
self,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> dict[str, Any]:
|
||||
return operations.nanobot_features_payload(config_path=self.settings.config.path)
|
||||
|
||||
def _nanobot_features_action(
|
||||
self,
|
||||
action: str,
|
||||
query: QueryParams,
|
||||
operations: SystemSettingsOperations,
|
||||
*,
|
||||
allow_install: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
return self.settings.mutate(
|
||||
operations.nanobot_features_action,
|
||||
action,
|
||||
query,
|
||||
allow_install=allow_install,
|
||||
)
|
||||
|
||||
async def _features_action(
|
||||
self,
|
||||
request: SettingsRequest,
|
||||
action: str,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
try:
|
||||
payload = await asyncio.to_thread(
|
||||
self._nanobot_features_action,
|
||||
action,
|
||||
request.query,
|
||||
operations,
|
||||
allow_install=(
|
||||
action != "enable"
|
||||
or self.allow_feature_package_install(request)
|
||||
),
|
||||
)
|
||||
except OptionalFeatureError as exc:
|
||||
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||
except Exception as exc:
|
||||
status = getattr(exc, "status", 500)
|
||||
message = getattr(exc, "message", str(exc))
|
||||
if status >= 500:
|
||||
self.logger.exception(
|
||||
"nanobot feature action '{}' failed",
|
||||
action,
|
||||
)
|
||||
return SettingsRouteResult.failure(status, message)
|
||||
payload = await self._apply_feature_runtime_change(
|
||||
action,
|
||||
request.query,
|
||||
payload,
|
||||
operations,
|
||||
)
|
||||
payload = self._with_channel_runtime_status(payload, operations)
|
||||
return SettingsRouteResult.success(
|
||||
payload,
|
||||
decorate_restart=True,
|
||||
restart_section="runtime",
|
||||
)
|
||||
|
||||
def _with_channel_runtime_status(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
operations: SystemSettingsOperations,
|
||||
) -> dict[str, Any]:
|
||||
if operations.channel_runtime_status is None:
|
||||
return payload
|
||||
try:
|
||||
return with_channel_runtime_status(
|
||||
payload,
|
||||
operations.channel_runtime_status(),
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("failed to load channel runtime status")
|
||||
return payload
|
||||
|
||||
async def _apply_feature_runtime_change(
|
||||
self,
|
||||
action: str,
|
||||
query: QueryParams,
|
||||
payload: dict[str, Any],
|
||||
operations: SystemSettingsOperations,
|
||||
) -> dict[str, Any]:
|
||||
if operations.channel_feature_action is None:
|
||||
return payload
|
||||
name = (query_first(query, "name") or "").strip()
|
||||
if not name:
|
||||
return payload
|
||||
try:
|
||||
instance_id = operations.nanobot_feature_instance_target(query)
|
||||
result = operations.channel_feature_action(action, name, instance_id)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
except Exception as exc:
|
||||
self.logger.exception("failed to apply channel '{}' without restart", name)
|
||||
return self.feature_runtime_fallback(
|
||||
payload,
|
||||
message=(
|
||||
f"{name} channel config was saved, but hot reload failed: {exc}"
|
||||
),
|
||||
)
|
||||
|
||||
if not isinstance(result, dict):
|
||||
return payload
|
||||
result = cast(dict[str, Any], result)
|
||||
if not result.get("handled"):
|
||||
return payload
|
||||
|
||||
updated = dict(payload)
|
||||
updated["requires_restart"] = bool(result.get("requires_restart"))
|
||||
message = result.get("message")
|
||||
if isinstance(message, str) and message:
|
||||
last_action = dict(updated.get("last_action") or {})
|
||||
previous = last_action.get("message")
|
||||
last_action["message"] = (
|
||||
f"{previous}. {message}"
|
||||
if isinstance(previous, str) and previous
|
||||
else message
|
||||
)
|
||||
last_action["hot_reload"] = not updated["requires_restart"]
|
||||
if "ok" in result:
|
||||
last_action["ok"] = bool(result["ok"])
|
||||
updated["last_action"] = last_action
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def feature_runtime_fallback(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
message: str,
|
||||
) -> dict[str, Any]:
|
||||
updated = dict(payload)
|
||||
updated["requires_restart"] = True
|
||||
last_action = dict(updated.get("last_action") or {})
|
||||
previous = last_action.get("message")
|
||||
last_action["message"] = (
|
||||
f"{previous}. {message}"
|
||||
if isinstance(previous, str) and previous
|
||||
else message
|
||||
)
|
||||
last_action["hot_reload"] = False
|
||||
updated["last_action"] = last_action
|
||||
return updated
|
||||
|
||||
async def _channel_configure(
|
||||
self,
|
||||
request: SettingsRequest,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
name = (query_first(request.query, "name") or "").strip()
|
||||
instance_id = (
|
||||
query_first(request.query, "instance_id") or "default"
|
||||
).strip()
|
||||
enable = (query_first(request.query, "enable") or "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}
|
||||
try:
|
||||
saved = await asyncio.to_thread(
|
||||
self._save_channel_config_values,
|
||||
name,
|
||||
self.parse_channel_values(request),
|
||||
instance_id,
|
||||
operations,
|
||||
)
|
||||
except WebUISettingsError as exc:
|
||||
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||
except Exception:
|
||||
self.logger.exception("failed to save channel '{}' settings", name)
|
||||
return SettingsRouteResult.failure(500, "failed to save channel settings")
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"name": name,
|
||||
"saved": True,
|
||||
"saved_keys": saved,
|
||||
}
|
||||
if not enable:
|
||||
features = await asyncio.to_thread(
|
||||
self._nanobot_features_payload,
|
||||
operations,
|
||||
)
|
||||
payload["nanobot_features"] = self._with_channel_runtime_status(
|
||||
features,
|
||||
operations,
|
||||
)
|
||||
return SettingsRouteResult.success(
|
||||
payload,
|
||||
decorate_restart=True,
|
||||
restart_section="runtime",
|
||||
restart_payload_key="nanobot_features",
|
||||
)
|
||||
|
||||
feature_query = {"name": [name]}
|
||||
if instance_id:
|
||||
feature_query["instance_id"] = [instance_id]
|
||||
try:
|
||||
features = await asyncio.to_thread(
|
||||
self._nanobot_features_action,
|
||||
"enable",
|
||||
feature_query,
|
||||
operations,
|
||||
allow_install=self.allow_feature_package_install(request),
|
||||
)
|
||||
except OptionalFeatureError as exc:
|
||||
return SettingsRouteResult.failure(
|
||||
exc.status,
|
||||
f"Settings saved, but {exc.message}",
|
||||
)
|
||||
except Exception as exc:
|
||||
self.logger.exception(
|
||||
"failed to enable channel '{}' after settings save",
|
||||
name,
|
||||
)
|
||||
return SettingsRouteResult.failure(
|
||||
500,
|
||||
f"Settings saved, but enabling {name} failed: {exc}",
|
||||
)
|
||||
|
||||
features = await self._apply_feature_runtime_change(
|
||||
"enable",
|
||||
feature_query,
|
||||
features,
|
||||
operations,
|
||||
)
|
||||
payload["nanobot_features"] = self._with_channel_runtime_status(
|
||||
features,
|
||||
operations,
|
||||
)
|
||||
return SettingsRouteResult.success(
|
||||
payload,
|
||||
decorate_restart=True,
|
||||
restart_section="runtime",
|
||||
restart_payload_key="nanobot_features",
|
||||
)
|
||||
|
||||
async def _channel_validate(
|
||||
self,
|
||||
request: SettingsRequest,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
name = (query_first(request.query, "name") or "").strip()
|
||||
instance_id = (
|
||||
query_first(request.query, "instance_id") or "default"
|
||||
).strip()
|
||||
try:
|
||||
payload = await asyncio.to_thread(
|
||||
operations.validate_channel_config,
|
||||
name,
|
||||
self.parse_channel_values(request),
|
||||
instance_id=instance_id,
|
||||
)
|
||||
except WebUISettingsError as exc:
|
||||
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||
except Exception:
|
||||
self.logger.exception("failed to validate channel '{}' settings", name)
|
||||
return SettingsRouteResult.failure(
|
||||
500,
|
||||
"failed to validate channel settings",
|
||||
)
|
||||
return SettingsRouteResult.success(payload)
|
||||
|
||||
@staticmethod
|
||||
def parse_channel_values(request: SettingsRequest) -> dict[str, Any]:
|
||||
if request.payload is None or "values" not in request.payload:
|
||||
return {}
|
||||
values = request.payload.get("values")
|
||||
if not isinstance(values, dict):
|
||||
raise WebUISettingsError(
|
||||
"channel settings payload must be a JSON object"
|
||||
)
|
||||
return cast(dict[str, Any], values)
|
||||
|
||||
def _save_channel_config_values(
|
||||
self,
|
||||
name: str,
|
||||
raw_values: dict[str, Any],
|
||||
instance_id: str,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> list[str]:
|
||||
return self.settings.config.update(
|
||||
lambda config: save_channel_config_values(
|
||||
config,
|
||||
name,
|
||||
raw_values,
|
||||
instance_id,
|
||||
load_channel_plugin=operations.load_channel_plugin,
|
||||
)
|
||||
)
|
||||
|
||||
async def _channel_connect(
|
||||
self,
|
||||
request: SettingsRequest,
|
||||
channel_name: str,
|
||||
action: str,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
try:
|
||||
connector = self._channel_connectors.get(channel_name)
|
||||
if connector is None:
|
||||
plugin = operations.load_channel_plugin(channel_name)
|
||||
connector = plugin.load_connector()
|
||||
self._channel_connectors[channel_name] = connector
|
||||
except ImportError:
|
||||
return SettingsRouteResult.failure(
|
||||
404,
|
||||
f"channel '{channel_name}' does not support connect",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = await connector.handle(action, request.query)
|
||||
except ChannelConnectError as exc:
|
||||
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||
except Exception:
|
||||
self.logger.exception(
|
||||
"failed to run {} WebUI connect action for {}",
|
||||
action,
|
||||
channel_name,
|
||||
)
|
||||
return SettingsRouteResult.failure(
|
||||
500,
|
||||
f"failed to {action} {channel_name} connection",
|
||||
)
|
||||
|
||||
if payload.get("status") != "succeeded":
|
||||
return SettingsRouteResult.success(payload)
|
||||
payload = await self._with_channel_connect_success(
|
||||
request,
|
||||
channel_name,
|
||||
payload,
|
||||
operations,
|
||||
)
|
||||
return SettingsRouteResult.success(
|
||||
payload,
|
||||
decorate_restart=True,
|
||||
restart_section="runtime",
|
||||
restart_payload_key="nanobot_features",
|
||||
)
|
||||
|
||||
async def _with_channel_connect_success(
|
||||
self,
|
||||
request: SettingsRequest,
|
||||
channel_name: str,
|
||||
payload: dict[str, Any],
|
||||
operations: SystemSettingsOperations,
|
||||
) -> dict[str, Any]:
|
||||
target = {"name": [channel_name]}
|
||||
if payload.get("instance_id"):
|
||||
target["instance_id"] = [str(payload["instance_id"])]
|
||||
try:
|
||||
features = await asyncio.to_thread(
|
||||
self._nanobot_features_action,
|
||||
"enable",
|
||||
target,
|
||||
operations,
|
||||
allow_install=self.allow_feature_package_install(request),
|
||||
)
|
||||
except OptionalFeatureError as exc:
|
||||
features = self.feature_runtime_fallback(
|
||||
self._nanobot_features_payload(operations),
|
||||
message=(
|
||||
f"{channel_name} connected, but enabling channel support failed: "
|
||||
f"{exc.message}"
|
||||
),
|
||||
)
|
||||
else:
|
||||
features = await self._apply_feature_runtime_change(
|
||||
"enable",
|
||||
target,
|
||||
features,
|
||||
operations,
|
||||
)
|
||||
updated = dict(payload)
|
||||
updated["nanobot_features"] = self._with_channel_runtime_status(
|
||||
features,
|
||||
operations,
|
||||
)
|
||||
return updated
|
||||
|
||||
def allow_feature_package_install(self, request: SettingsRequest) -> bool:
|
||||
if request.local_browser:
|
||||
return True
|
||||
try:
|
||||
return bool(
|
||||
self.settings.config.load().tools.webui_allow_remote_package_install
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("failed to load remote package install policy")
|
||||
return False
|
||||
|
||||
def _pairing_action(
|
||||
self,
|
||||
request: SettingsRequest,
|
||||
action: str,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
code = (query_first(request.query, "code") or "").strip()
|
||||
if not code:
|
||||
return SettingsRouteResult.failure(400, "Missing pairing code")
|
||||
if action == "approve":
|
||||
result = operations.approve_code(code)
|
||||
if result is None:
|
||||
return SettingsRouteResult.failure(
|
||||
404,
|
||||
"Pairing code not found or expired",
|
||||
)
|
||||
channel, sender_id = result
|
||||
return SettingsRouteResult.success(
|
||||
pairing_payload(
|
||||
operations.list_pending,
|
||||
{
|
||||
"ok": True,
|
||||
"action": "approve",
|
||||
"message": f"Approved {sender_id} for {channel}",
|
||||
"channel": channel,
|
||||
"sender_id": sender_id,
|
||||
"code": code,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
if not operations.deny_code(code):
|
||||
return SettingsRouteResult.failure(
|
||||
404,
|
||||
"Pairing code not found or expired",
|
||||
)
|
||||
return SettingsRouteResult.success(
|
||||
pairing_payload(
|
||||
operations.list_pending,
|
||||
{
|
||||
"ok": True,
|
||||
"action": "deny",
|
||||
"message": f"Denied pairing code {code}",
|
||||
"code": code,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def _mcp_presets(
|
||||
self,
|
||||
request: SettingsRequest,
|
||||
action: str | None,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
try:
|
||||
payload = await operations.mcp_presets_action(
|
||||
action,
|
||||
request.query,
|
||||
reload_mcp=operations.reload_mcp,
|
||||
mcp_runtime_status=operations.mcp_runtime_status,
|
||||
config=self.settings.config,
|
||||
)
|
||||
except Exception as exc:
|
||||
status = getattr(exc, "status", 500)
|
||||
message = getattr(exc, "message", str(exc))
|
||||
if status >= 500:
|
||||
self.logger.exception(
|
||||
"MCP preset action '{}' failed",
|
||||
action or "list",
|
||||
)
|
||||
return SettingsRouteResult.failure(status, message)
|
||||
return SettingsRouteResult.success(
|
||||
payload,
|
||||
decorate_restart=action is not None,
|
||||
restart_section="runtime" if action is not None else None,
|
||||
)
|
||||
|
||||
async def _version_check(
|
||||
self,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
try:
|
||||
update_info = await asyncio.to_thread(operations.check_for_update)
|
||||
except Exception:
|
||||
self.logger.exception("version check failed")
|
||||
return SettingsRouteResult.failure(500, "version check failed")
|
||||
return SettingsRouteResult.success({"updateAvailable": update_info})
|
||||
@@ -8,9 +8,7 @@ does not modify agent sessions.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
@@ -26,11 +24,8 @@ _MAX_MAP_ITEMS = 2_000
|
||||
_MAX_KEY_LEN = 512
|
||||
_MAX_TITLE_LEN = 160
|
||||
_MAX_TAG_LEN = 40
|
||||
_MAX_WORKBENCH_PANES = 4
|
||||
_ALLOWED_DENSITIES = {"comfortable", "compact"}
|
||||
_ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc", "manual"}
|
||||
_ALLOWED_WORKBENCH_LAYOUTS = {"columns", "rows", "grid", "bsp", "main-stack"}
|
||||
_SIDEBAR_STATE_WRITE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def webui_sidebar_state_path() -> Path:
|
||||
@@ -47,7 +42,6 @@ def default_webui_sidebar_state() -> dict[str, Any]:
|
||||
"project_name_overrides": {},
|
||||
"tags_by_key": {},
|
||||
"collapsed_groups": {},
|
||||
"workbench": {"version": 1, "tabs": {}},
|
||||
"view": {
|
||||
"density": "comfortable",
|
||||
"show_previews": False,
|
||||
@@ -82,20 +76,6 @@ def _clean_string_list(value: Any, *, max_len: int = _MAX_KEY_LEN) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def _clean_split_ratios(value: Any) -> list[float]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
ratios: list[float] = []
|
||||
for raw_ratio in cast(list[Any], value)[: _MAX_WORKBENCH_PANES - 1]:
|
||||
if isinstance(raw_ratio, bool) or not isinstance(raw_ratio, (int, float)):
|
||||
continue
|
||||
ratio = float(raw_ratio)
|
||||
if not math.isfinite(ratio):
|
||||
continue
|
||||
ratios.append(round(min(0.95, max(0.05, ratio)), 4))
|
||||
return ratios
|
||||
|
||||
|
||||
def _clean_bool_map(value: Any) -> dict[str, bool]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
@@ -151,56 +131,8 @@ def _clean_view(value: Any) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _clean_workbench(value: Any) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
return {"version": 1, "tabs": {}}
|
||||
workbench = cast(dict[str, Any], value)
|
||||
if workbench.get("version") != 1:
|
||||
return {"version": 1, "tabs": {}}
|
||||
raw_tabs = workbench.get("tabs")
|
||||
if not isinstance(raw_tabs, dict):
|
||||
return {"version": 1, "tabs": {}}
|
||||
|
||||
tabs: dict[str, dict[str, Any]] = {}
|
||||
claimed_panes: set[str] = set()
|
||||
for raw_tab_key, raw_tab in list(cast(dict[Any, Any], raw_tabs).items())[:_MAX_MAP_ITEMS]:
|
||||
tab_key = _clean_string(raw_tab_key)
|
||||
if tab_key is None or not isinstance(raw_tab, dict):
|
||||
continue
|
||||
tab = cast(dict[str, Any], raw_tab)
|
||||
pane_keys = [
|
||||
key
|
||||
for key in _clean_string_list(tab.get("paneKeys"))
|
||||
if key not in claimed_panes
|
||||
][:_MAX_WORKBENCH_PANES]
|
||||
if not pane_keys:
|
||||
continue
|
||||
explicit = tab.get("explicit") is True
|
||||
if not explicit and len(pane_keys) == 1:
|
||||
continue
|
||||
requested_layout_pane_keys = [
|
||||
key for key in _clean_string_list(tab.get("layoutPaneKeys")) if key in pane_keys
|
||||
]
|
||||
layout_pane_keys = requested_layout_pane_keys + [
|
||||
key for key in pane_keys if key not in requested_layout_pane_keys
|
||||
]
|
||||
claimed_panes.update(pane_keys)
|
||||
raw_layout = tab.get("layout")
|
||||
layout = raw_layout if raw_layout in _ALLOWED_WORKBENCH_LAYOUTS else "columns"
|
||||
title = _clean_string(tab.get("title"), max_len=_MAX_TITLE_LEN)
|
||||
tabs[tab_key] = {
|
||||
"explicit": explicit,
|
||||
"title": title,
|
||||
"paneKeys": pane_keys,
|
||||
"layoutPaneKeys": layout_pane_keys,
|
||||
"layout": layout,
|
||||
"splitRatios": _clean_split_ratios(tab.get("splitRatios")),
|
||||
}
|
||||
return {"version": 1, "tabs": tabs}
|
||||
|
||||
|
||||
def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
|
||||
"""Return a validated canonical sidebar state."""
|
||||
"""Return a schema-v1 sidebar state from any older/partial input."""
|
||||
if not isinstance(raw, dict):
|
||||
raw = {}
|
||||
raw = cast(dict[str, Any], raw)
|
||||
@@ -214,7 +146,6 @@ def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
|
||||
)
|
||||
state["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key"))
|
||||
state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups"))
|
||||
state["workbench"] = _clean_workbench(raw.get("workbench"))
|
||||
state["view"] = _clean_view(raw.get("view"))
|
||||
updated_at = raw.get("updated_at")
|
||||
state["updated_at"] = updated_at if isinstance(updated_at, str) else None
|
||||
@@ -238,11 +169,6 @@ def read_webui_sidebar_state() -> dict[str, Any]:
|
||||
|
||||
|
||||
def write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
with _SIDEBAR_STATE_WRITE_LOCK:
|
||||
return _write_webui_sidebar_state(raw)
|
||||
|
||||
|
||||
def _write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
state = normalize_webui_sidebar_state(raw)
|
||||
state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
encoded = json.dumps(
|
||||
|
||||
@@ -10,7 +10,6 @@ import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from nanobot import __version__
|
||||
|
||||
@@ -43,13 +42,7 @@ def check_for_update() -> dict[str, Any] | None:
|
||||
return None
|
||||
_cache = (now, latest)
|
||||
|
||||
if not isinstance(latest, str) or not latest:
|
||||
return None
|
||||
try:
|
||||
if Version(latest) <= Version(__version__):
|
||||
return None
|
||||
except InvalidVersion:
|
||||
logger.debug("PyPI returned an invalid nanobot version: %r", latest)
|
||||
if not latest or latest == __version__:
|
||||
return None
|
||||
return {
|
||||
"currentVersion": __version__,
|
||||
|
||||
+33
-210
@@ -14,13 +14,12 @@ import json
|
||||
import mimetypes
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from urllib.parse import quote, unquote, urlsplit, urlunsplit
|
||||
from urllib.parse import unquote
|
||||
|
||||
from loguru import logger
|
||||
from websockets.datastructures import Headers
|
||||
from websockets.http11 import Request as WsRequest
|
||||
from websockets.http11 import Response
|
||||
|
||||
@@ -119,66 +118,7 @@ from nanobot.webui.transcript import build_webui_thread_response
|
||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||
|
||||
_SLOW_WEBUI_HTTP_LOG_MS = 1_000
|
||||
_WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
|
||||
_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
|
||||
_NO_STORE_HEADERS = [("Cache-Control", "no-store")]
|
||||
|
||||
_WEBUI_MUTATION_PATHS = {
|
||||
"automation.enable": "/api/webui/automations/enable",
|
||||
"automation.disable": "/api/webui/automations/disable",
|
||||
"automation.delete": "/api/webui/automations/delete",
|
||||
"automation.run": "/api/webui/automations/run",
|
||||
"automation.update": "/api/webui/automations/update",
|
||||
"skill.install": "/api/webui/skills/install",
|
||||
"skill.update": "/api/webui/skills/update",
|
||||
"skill.delete": "/api/webui/skills/delete",
|
||||
"sidebar.update": "/api/webui/sidebar-state/update",
|
||||
"settings.agent.update": "/api/settings/update",
|
||||
"settings.model_configuration.create": "/api/settings/model-configurations/create",
|
||||
"settings.model_configuration.update": "/api/settings/model-configurations/update",
|
||||
"settings.model_configuration.delete": "/api/settings/model-configurations/delete",
|
||||
"settings.model_configuration.migrate": "/api/settings/model-configurations/migrate",
|
||||
"settings.model_call_order.update": "/api/settings/model-call-order/update",
|
||||
"settings.provider.update": "/api/settings/provider/update",
|
||||
"settings.provider.create": "/api/settings/provider/create",
|
||||
"settings.provider.oauth_login": "/api/settings/provider/oauth-login",
|
||||
"settings.provider.oauth_complete": "/api/settings/provider/oauth-login/complete",
|
||||
"settings.provider.oauth_logout": "/api/settings/provider/oauth-logout",
|
||||
"settings.web_search.update": "/api/settings/web-search/update",
|
||||
"settings.api_service.start": "/api/settings/api-service/start",
|
||||
"settings.api_service.stop": "/api/settings/api-service/stop",
|
||||
"settings.image_generation.update": "/api/settings/image-generation/update",
|
||||
"settings.transcription.update": "/api/settings/transcription/update",
|
||||
"settings.network_safety.update": "/api/settings/network-safety/update",
|
||||
"settings.cli_app.install": "/api/settings/cli-apps/install",
|
||||
"settings.cli_app.update": "/api/settings/cli-apps/update",
|
||||
"settings.cli_app.uninstall": "/api/settings/cli-apps/uninstall",
|
||||
"settings.cli_app.test": "/api/settings/cli-apps/test",
|
||||
"settings.feature.enable": "/api/settings/nanobot-features/enable",
|
||||
"settings.feature.disable": "/api/settings/nanobot-features/disable",
|
||||
"settings.channel.validate": "/api/settings/channels/validate",
|
||||
"settings.channel.configure": "/api/settings/channels/configure",
|
||||
"settings.pairing.approve": "/api/settings/pairing/approve",
|
||||
"settings.pairing.deny": "/api/settings/pairing/deny",
|
||||
"settings.mcp.enable": "/api/settings/mcp-presets/enable",
|
||||
"settings.mcp.disable": "/api/settings/mcp-presets/disable",
|
||||
"settings.mcp.remove": "/api/settings/mcp-presets/remove",
|
||||
"settings.mcp.test": "/api/settings/mcp-presets/test",
|
||||
"settings.mcp.reconnect": "/api/settings/mcp-presets/reconnect",
|
||||
"settings.mcp.custom": "/api/settings/mcp-presets/custom",
|
||||
"settings.mcp.import": "/api/settings/mcp-presets/import",
|
||||
"settings.mcp.import_cursor": "/api/settings/mcp-presets/import-cursor",
|
||||
"settings.mcp.tools": "/api/settings/mcp-presets/tools",
|
||||
"settings.mcp.oauth_start": "/api/settings/mcp-oauth/start",
|
||||
"settings.mcp.oauth_complete": "/api/settings/mcp-oauth/complete",
|
||||
"settings.mcp.oauth_cancel": "/api/settings/mcp-oauth/cancel",
|
||||
}
|
||||
|
||||
_WEBUI_CHANNEL_CONNECT_ACTIONS = {
|
||||
"settings.channel.connect.start": "start",
|
||||
"settings.channel.connect.poll": "poll",
|
||||
"settings.channel.connect.cancel": "cancel",
|
||||
}
|
||||
_AUTOMATION_VALUES_HEADER = "X-Nanobot-Automation-Values"
|
||||
|
||||
# Fix for #5190: On Windows, mimetypes.guess_type() reads the registry key
|
||||
# HKEY_CLASSES_ROOT\.js\Content Type, which is commonly set to 'text/plain'
|
||||
@@ -210,7 +150,6 @@ if TYPE_CHECKING:
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.triggers.local_store import LocalTriggerStore
|
||||
from nanobot.webui.settings_services import WebUISettingsServices
|
||||
|
||||
def _decode_api_key(raw_key: str) -> str | None:
|
||||
key = unquote(raw_key)
|
||||
@@ -220,33 +159,6 @@ def _decode_api_key(raw_key: str) -> str | None:
|
||||
return key
|
||||
|
||||
|
||||
def _mutation_payload(request: WsRequest) -> dict[str, Any] | None:
|
||||
payload = getattr(request, _WEBUI_MUTATION_PAYLOAD_ATTR, None)
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
return cast(dict[str, Any], payload)
|
||||
|
||||
|
||||
def _request_query(request: WsRequest) -> dict[str, list[str]]:
|
||||
payload = _mutation_payload(request)
|
||||
if payload is None:
|
||||
return _parse_query(request.path)
|
||||
query: dict[str, list[str]] = {}
|
||||
for key, value in payload.items():
|
||||
if not key:
|
||||
continue
|
||||
if isinstance(value, bool):
|
||||
text = "true" if value else "false"
|
||||
elif value is None:
|
||||
text = ""
|
||||
elif isinstance(value, (dict, list)):
|
||||
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
else:
|
||||
text = str(value)
|
||||
query[key] = [text]
|
||||
return query
|
||||
|
||||
|
||||
def _default_model_name_from_config() -> str | None:
|
||||
try:
|
||||
from nanobot.config.loader import load_config
|
||||
@@ -299,7 +211,6 @@ class GatewayHTTPHandler:
|
||||
media: WebUIMediaGateway,
|
||||
ingress: WebUIIngressPolicy,
|
||||
workspaces: WebUIWorkspaceController,
|
||||
settings: WebUISettingsServices,
|
||||
skills_workspace_path: Path,
|
||||
disabled_skills: set[str] | None = None,
|
||||
cron_service: CronService | None = None,
|
||||
@@ -308,8 +219,6 @@ class GatewayHTTPHandler:
|
||||
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
||||
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,
|
||||
log: Any = logger,
|
||||
) -> None:
|
||||
@@ -322,7 +231,6 @@ class GatewayHTTPHandler:
|
||||
self.media = media
|
||||
self.ingress = ingress
|
||||
self.workspaces = workspaces
|
||||
self.settings = settings
|
||||
self.skills_workspace_path = skills_workspace_path
|
||||
self.disabled_skills: set[str] = (
|
||||
disabled_skills if disabled_skills is not None else set()
|
||||
@@ -341,7 +249,6 @@ class GatewayHTTPHandler:
|
||||
|
||||
self._capabilities = _rc(runtime_surface, runtime_capabilities_overrides or {})
|
||||
self.settings_routes = WebUISettingsRouter(
|
||||
settings=settings,
|
||||
bus=bus,
|
||||
logger=self._log,
|
||||
check_api_token=self.check_api_token,
|
||||
@@ -352,9 +259,6 @@ class GatewayHTTPHandler:
|
||||
runtime_capabilities=self._capabilities,
|
||||
channel_feature_action=channel_feature_action,
|
||||
channel_runtime_status=channel_runtime_status,
|
||||
mcp_runtime_status=mcp_runtime_status,
|
||||
mcp_reload=mcp_reload,
|
||||
mcp_oauth_redirect_uri=self._mcp_oauth_redirect_uri,
|
||||
)
|
||||
|
||||
def workspace_controls_available(self, connection: Any) -> bool:
|
||||
@@ -381,86 +285,11 @@ class GatewayHTTPHandler:
|
||||
)
|
||||
|
||||
try:
|
||||
if self._is_webui_mutation_path(got):
|
||||
return _http_error(
|
||||
405,
|
||||
"WebUI mutations require an authenticated WebSocket",
|
||||
)
|
||||
response = await self._dispatch_resolved(connection, request, got)
|
||||
return response
|
||||
finally:
|
||||
self._log_slow_http(got, response, started)
|
||||
|
||||
async def dispatch_webui_mutation(
|
||||
self,
|
||||
connection: Any,
|
||||
action: str,
|
||||
payload: dict[str, Any],
|
||||
) -> Response:
|
||||
"""Run one explicitly allowlisted mutation for an authenticated WebUI socket."""
|
||||
path = self._webui_mutation_path(action, payload)
|
||||
if isinstance(path, Response):
|
||||
return path
|
||||
|
||||
source_request = getattr(connection, "request", None)
|
||||
source_headers = getattr(source_request, "headers", None)
|
||||
if source_headers is None:
|
||||
headers = Headers()
|
||||
else:
|
||||
try:
|
||||
headers = Headers(source_headers.raw_items())
|
||||
except (AttributeError, TypeError):
|
||||
try:
|
||||
headers = Headers(source_headers)
|
||||
except TypeError:
|
||||
headers = Headers()
|
||||
request = WsRequest(path, headers)
|
||||
setattr(request, "_nanobot_trusted_proxy_authenticated", True)
|
||||
setattr(request, _WEBUI_MUTATION_REQUEST_ATTR, True)
|
||||
setattr(request, _WEBUI_MUTATION_PAYLOAD_ATTR, dict(payload))
|
||||
response = await self._dispatch_resolved(connection, request, path)
|
||||
if isinstance(response, Response):
|
||||
return response
|
||||
return _http_error(404, "WebUI mutation action not found")
|
||||
|
||||
def _is_webui_mutation_path(self, path: str) -> bool:
|
||||
if self.settings_routes.is_mutation_path(path):
|
||||
return True
|
||||
if re.match(r"^/api/sessions/[^/]+/delete$", path):
|
||||
return True
|
||||
if re.match(r"^/api/webui/automations/(enable|disable|delete|run|update)$", path):
|
||||
return True
|
||||
return path in {
|
||||
"/api/webui/skills/install",
|
||||
"/api/webui/skills/update",
|
||||
"/api/webui/skills/delete",
|
||||
"/api/webui/sidebar-state/update",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _webui_mutation_path(
|
||||
action: str,
|
||||
payload: dict[str, Any],
|
||||
) -> str | Response:
|
||||
path = _WEBUI_MUTATION_PATHS.get(action)
|
||||
if path is not None:
|
||||
return path
|
||||
if action == "session.delete":
|
||||
key = payload.get("key")
|
||||
if not isinstance(key, str) or not key.strip():
|
||||
return _http_error(400, "missing session key")
|
||||
return f"/api/sessions/{quote(key, safe='')}/delete"
|
||||
connect_action = _WEBUI_CHANNEL_CONNECT_ACTIONS.get(action)
|
||||
if connect_action is not None:
|
||||
channel = payload.get("channel")
|
||||
if not isinstance(channel, str) or re.fullmatch(
|
||||
r"[A-Za-z0-9_-]{1,64}",
|
||||
channel,
|
||||
) is None:
|
||||
return _http_error(400, "invalid channel name")
|
||||
return f"/api/settings/channels/{channel}/connect/{connect_action}"
|
||||
return _http_error(404, "unknown WebUI mutation action")
|
||||
|
||||
async def _dispatch_resolved(
|
||||
self,
|
||||
connection: Any,
|
||||
@@ -548,16 +377,9 @@ class GatewayHTTPHandler:
|
||||
"too many outstanding issued tokens ({}), rejecting issuance",
|
||||
len(self.tokens.issued_tokens),
|
||||
)
|
||||
return _http_json_response(
|
||||
{"error": "too many outstanding tokens"},
|
||||
status=429,
|
||||
extra_headers=_NO_STORE_HEADERS,
|
||||
)
|
||||
return _http_json_response({"error": "too many outstanding tokens"}, status=429)
|
||||
token_value = self.tokens.issue_token(self.config.token_ttl_s)
|
||||
return _http_json_response(
|
||||
token_response_payload(token_value, self.config.token_ttl_s),
|
||||
extra_headers=_NO_STORE_HEADERS,
|
||||
)
|
||||
return _http_json_response(token_response_payload(token_value, self.config.token_ttl_s))
|
||||
|
||||
# -- Bootstrap ----------------------------------------------------------
|
||||
|
||||
@@ -587,7 +409,7 @@ class GatewayHTTPHandler:
|
||||
"runtime_surface": self._runtime_surface,
|
||||
"runtime_capabilities": self._capabilities,
|
||||
}
|
||||
return _http_json_response(payload, extra_headers=_NO_STORE_HEADERS)
|
||||
return _http_json_response(payload)
|
||||
|
||||
api_token_allowed = bool(secret) or is_local_browser
|
||||
if not self.tokens.can_issue(include_api_token=api_token_allowed):
|
||||
@@ -595,7 +417,6 @@ class GatewayHTTPHandler:
|
||||
json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"),
|
||||
status=429,
|
||||
content_type="application/json; charset=utf-8",
|
||||
extra_headers=_NO_STORE_HEADERS,
|
||||
)
|
||||
token = self.tokens.issue_token(self.config.token_ttl_s, audience="webui")
|
||||
api_token = (
|
||||
@@ -620,7 +441,7 @@ class GatewayHTTPHandler:
|
||||
}
|
||||
if api_token is not None:
|
||||
payload["api_token"] = api_token
|
||||
return _http_json_response(payload, extra_headers=_NO_STORE_HEADERS)
|
||||
return _http_json_response(payload)
|
||||
|
||||
def _bootstrap_ws_url(self, request: Any) -> str:
|
||||
headers = getattr(request, "headers", {}) or {}
|
||||
@@ -636,14 +457,6 @@ class GatewayHTTPHandler:
|
||||
expected_path = _normalize_config_path(self.config.path)
|
||||
return f"{scheme}://{host}{expected_path}"
|
||||
|
||||
def _mcp_oauth_redirect_uri(self, request: WsRequest) -> str:
|
||||
"""Derive the browser callback from the same public origin as WebSocket bootstrap."""
|
||||
from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH
|
||||
|
||||
public_ws_url = urlsplit(self._bootstrap_ws_url(request))
|
||||
scheme = "https" if public_ws_url.scheme == "wss" else "http"
|
||||
return urlunsplit((scheme, public_ws_url.netloc, MCP_OAUTH_CALLBACK_PATH, "", ""))
|
||||
|
||||
# -- Session routes -----------------------------------------------------
|
||||
|
||||
async def _dispatch_session_routes(self, request: WsRequest, got: str) -> Response | None:
|
||||
@@ -833,7 +646,7 @@ class GatewayHTTPHandler:
|
||||
return _http_error(400, "invalid session key")
|
||||
if not _is_websocket_channel_session_key(decoded_key):
|
||||
return _http_error(404, "session not found")
|
||||
query = _request_query(request)
|
||||
query = _parse_query(request.path)
|
||||
delete_automations = (_query_first(query, "delete_automations") or "").lower()
|
||||
automation_jobs = session_automation_jobs(
|
||||
self.cron_service,
|
||||
@@ -855,9 +668,9 @@ class GatewayHTTPHandler:
|
||||
self.local_trigger_store.delete(job.id)
|
||||
elif self.cron_service is not None:
|
||||
self.cron_service.remove_job(job.id)
|
||||
session_deleted = self.session_manager.delete_session(decoded_key)
|
||||
transcript_deleted = delete_webui_thread(decoded_key)
|
||||
return _http_json_response({"deleted": bool(session_deleted or transcript_deleted)})
|
||||
deleted = self.session_manager.delete_session(decoded_key)
|
||||
delete_webui_thread(decoded_key)
|
||||
return _http_json_response({"deleted": bool(deleted)})
|
||||
|
||||
# -- Automation routes --------------------------------------------------
|
||||
|
||||
@@ -929,7 +742,7 @@ class GatewayHTTPHandler:
|
||||
if self.cron_service is None and self.local_trigger_store is None:
|
||||
return _http_error(503, "automation service unavailable")
|
||||
|
||||
query = _request_query(request)
|
||||
query = _parse_query(request.path)
|
||||
job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip()
|
||||
if not job_id:
|
||||
return _http_error(400, "missing automation id")
|
||||
@@ -1161,7 +974,7 @@ class GatewayHTTPHandler:
|
||||
if self._skill_install_lock.locked():
|
||||
return _http_error(409, "another skill installation is already in progress")
|
||||
|
||||
query = _request_query(request)
|
||||
query = _parse_query(request.path)
|
||||
provider = _query_first(query, "provider") or "skills_sh"
|
||||
source = _query_first(query, "source") or ""
|
||||
skill_id = _query_first(query, "skill") or ""
|
||||
@@ -1202,7 +1015,7 @@ class GatewayHTTPHandler:
|
||||
def _handle_webui_skill_update(self, request: WsRequest) -> Response:
|
||||
if not self.check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
query = _request_query(request)
|
||||
query = _parse_query(request.path)
|
||||
name = _query_first(query, "name") or ""
|
||||
raw_enabled = (_query_first(query, "enabled") or "").lower()
|
||||
if raw_enabled not in {"true", "false"}:
|
||||
@@ -1234,7 +1047,7 @@ class GatewayHTTPHandler:
|
||||
return _http_error(401, "Unauthorized")
|
||||
if not _is_local_browser_request(connection, request.headers):
|
||||
return _http_error(403, "remote skill deletion is disabled")
|
||||
name = _query_first(_request_query(request), "name") or ""
|
||||
name = _query_first(_parse_query(request.path), "name") or ""
|
||||
try:
|
||||
action = delete_webui_skill(
|
||||
self.skills_workspace_path,
|
||||
@@ -1281,14 +1094,18 @@ class GatewayHTTPHandler:
|
||||
def _handle_webui_sidebar_state_update(self, request: WsRequest) -> Response:
|
||||
if not self.check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
payload = _mutation_payload(request)
|
||||
state_value = payload.get("state") if payload is not None else None
|
||||
if state_value is None:
|
||||
query = _parse_query(request.path)
|
||||
raw_state = _query_first(query, "state")
|
||||
if raw_state is None:
|
||||
return _http_error(400, "missing state")
|
||||
if not isinstance(state_value, dict):
|
||||
try:
|
||||
decoded = json.loads(raw_state)
|
||||
except json.JSONDecodeError:
|
||||
return _http_error(400, "state must be JSON")
|
||||
if not isinstance(decoded, dict):
|
||||
return _http_error(400, "state must be an object")
|
||||
try:
|
||||
state = write_webui_sidebar_state(cast(dict[str, Any], state_value))
|
||||
state = write_webui_sidebar_state(cast(dict[str, Any], decoded))
|
||||
except ValueError as e:
|
||||
return _http_error(400, str(e))
|
||||
except OSError:
|
||||
@@ -1357,10 +1174,16 @@ class GatewayHTTPHandler:
|
||||
|
||||
|
||||
def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None:
|
||||
payload = _mutation_payload(request)
|
||||
if payload is None or "values" not in payload:
|
||||
raw = _case_insensitive_header(request.headers, _AUTOMATION_VALUES_HEADER)
|
||||
if not raw:
|
||||
return {}
|
||||
values = payload.get("values")
|
||||
try:
|
||||
values = json.loads(raw)
|
||||
except Exception:
|
||||
try:
|
||||
values = json.loads(unquote(raw))
|
||||
except Exception:
|
||||
return None
|
||||
return cast(dict[str, Any], values) if isinstance(values, dict) else None
|
||||
|
||||
|
||||
|
||||
+5
-1
@@ -37,7 +37,6 @@ dependencies = [
|
||||
"readability-lxml>=0.8.4,<1.0.0",
|
||||
"lxml-html-clean>=0.4.0,<1.0.0",
|
||||
"rich>=14.0.0,<15.0.0",
|
||||
"qrcode[pil]>=8.0",
|
||||
"croniter>=6.0.0,<7.0.0",
|
||||
"prompt-toolkit>=3.0.50,<4.0.0",
|
||||
"questionary>=2.0.0,<3.0.0",
|
||||
@@ -89,6 +88,11 @@ pdf = [
|
||||
olostep = [
|
||||
"olostep>=0.1.0; python_version < '3.14'",
|
||||
]
|
||||
computer-use = [
|
||||
"pyautogui>=0.9.54",
|
||||
"pillow>=10.0.0",
|
||||
"playwright>=1.48.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=9.0.0,<10.0.0",
|
||||
"pytest-asyncio>=1.3.0,<2.0.0",
|
||||
|
||||
@@ -46,6 +46,7 @@ def make_loop(
|
||||
context_window_tokens: int = 128_000,
|
||||
session_ttl_minutes: int = 0,
|
||||
unified_session: bool = False,
|
||||
mcp_servers: dict | None = None,
|
||||
tools_config=None,
|
||||
model_presets: dict | None = None,
|
||||
hooks: list | None = None,
|
||||
@@ -71,6 +72,8 @@ def make_loop(
|
||||
session_ttl_minutes=session_ttl_minutes,
|
||||
unified_session=unified_session,
|
||||
)
|
||||
if mcp_servers is not None:
|
||||
kwargs["mcp_servers"] = mcp_servers
|
||||
if tools_config is not None:
|
||||
kwargs["tools_config"] = tools_config
|
||||
if model_presets is not None:
|
||||
|
||||
@@ -1,370 +0,0 @@
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent import plugins as agent_plugins
|
||||
from nanobot.agent.plugins import (
|
||||
AGENT_PLUGIN_MCP_SCHEMA,
|
||||
AGENT_PLUGIN_SCHEMA,
|
||||
agent_plugin_mcp_servers,
|
||||
discover_agent_plugins,
|
||||
enabled_agent_plugin_skill_dirs,
|
||||
enabled_agent_plugin_skills,
|
||||
set_agent_plugin_enabled,
|
||||
)
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
from nanobot.agent.tools.context import ToolContext
|
||||
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
from nanobot.security.workspace_access import (
|
||||
bind_workspace_scope,
|
||||
reset_workspace_scope,
|
||||
validate_workspace_scope_payload,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_plugin_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
agent_plugins, "get_config_path", lambda: tmp_path / "config" / "config.json"
|
||||
)
|
||||
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value), encoding="utf-8")
|
||||
|
||||
|
||||
def _manifest(name: str, **fields: object) -> dict[str, object]:
|
||||
return {"$schema": AGENT_PLUGIN_SCHEMA, "name": name, **fields}
|
||||
|
||||
|
||||
def _plugin(workspace: Path, name: str = "demo", **fields: object) -> Path:
|
||||
root = workspace / "plugins" / name
|
||||
_write_json(root / "plugin.json", _manifest(name, **fields))
|
||||
return root
|
||||
|
||||
|
||||
def _skill(root: Path, name: str, frontmatter: str | None = None, body: str = "") -> Path:
|
||||
path = root / name
|
||||
path.mkdir(parents=True)
|
||||
metadata = frontmatter or f"name: {name}\ndescription: Plugin skill."
|
||||
(path / "SKILL.md").write_text(f"---\n{metadata}\n---\n\n{body}\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _loaded_skills(workspace: Path) -> list[str]:
|
||||
return [name for name, _ in enabled_agent_plugin_skills(workspace)]
|
||||
|
||||
|
||||
def test_plugin_skill_lifecycle_and_precedence(tmp_path: Path) -> None:
|
||||
plugin = _plugin(tmp_path)
|
||||
_skill(
|
||||
plugin / "skills",
|
||||
"shared",
|
||||
"name: shared\ndescription: Plugin version.\nalways: true",
|
||||
"Plugin body.",
|
||||
)
|
||||
_skill(tmp_path / "builtin", "shared", body="Built-in body.")
|
||||
workspace_skill = _skill(
|
||||
tmp_path / "skills", "shared", "name: shared\ndescription: Workspace version."
|
||||
)
|
||||
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
|
||||
|
||||
assert [entry["source"] for entry in loader.list_skills()] == ["workspace"]
|
||||
assert "Workspace version" in (loader.load_skill("shared") or "")
|
||||
set_agent_plugin_enabled(tmp_path, "demo", True)
|
||||
assert [entry["source"] for entry in loader.list_skills()] == ["workspace"]
|
||||
|
||||
shutil.rmtree(workspace_skill)
|
||||
assert [entry["source"] for entry in loader.list_skills()] == ["plugin"]
|
||||
assert loader.get_explicitly_invoked_skills("Use $shared") == ["shared"]
|
||||
assert loader.get_always_skills() == ["shared"]
|
||||
assert "Plugin body" in (loader.load_skill("shared") or "")
|
||||
assert "`demo/skills/shared/SKILL.md`" in loader.build_skills_summary()
|
||||
|
||||
set_agent_plugin_enabled(tmp_path, "demo", False)
|
||||
assert [entry["source"] for entry in loader.list_skills()] == ["builtin"]
|
||||
assert "Built-in body" in (loader.load_skill("shared") or "")
|
||||
|
||||
|
||||
def test_plugin_skills_are_direct_valid_and_contained(tmp_path: Path) -> None:
|
||||
plugin = _plugin(tmp_path)
|
||||
skills = plugin / "skills"
|
||||
_skill(skills, "direct")
|
||||
_skill(skills / "group", "nested")
|
||||
for name, frontmatter in (
|
||||
("wrong-directory", "name: another\ndescription: Mismatch."),
|
||||
("missing-description", "name: missing-description"),
|
||||
("Bad-Name", "name: Bad-Name\ndescription: Invalid name."),
|
||||
):
|
||||
_skill(skills, name, frontmatter)
|
||||
outside = _skill(tmp_path / "outside", "escaped")
|
||||
try:
|
||||
(skills / "escaped").symlink_to(outside, target_is_directory=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
set_agent_plugin_enabled(tmp_path, "demo", True)
|
||||
assert _loaded_skills(tmp_path) == ["direct"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("manifest", "valid"),
|
||||
[
|
||||
({"$schema": "https://agent-plugins.org/schemas/2.0.0/plugin.schema.json", "name": "demo"}, False),
|
||||
(_manifest("Bad-Name"), False),
|
||||
(_manifest("demo", futureField=True, extensions="invalid but non-fatal"), True),
|
||||
],
|
||||
)
|
||||
def test_plugin_manifest_boundary(tmp_path: Path, manifest: object, valid: bool) -> None:
|
||||
_write_json(tmp_path / "plugins" / "candidate" / "plugin.json", manifest)
|
||||
assert bool(discover_agent_plugins(tmp_path)) is valid
|
||||
|
||||
|
||||
def test_plugin_logo_is_validated_and_contained(tmp_path: Path) -> None:
|
||||
extension = {"extensions": {"dev.nanobot": {"logo": "./assets/icon.png"}}}
|
||||
plugin = _plugin(tmp_path, "demo", **extension)
|
||||
icon = plugin / "assets" / "icon.png"
|
||||
icon.parent.mkdir()
|
||||
icon.write_bytes(b"\x89PNG\r\n\x1a\nlogo")
|
||||
escaped = _plugin(tmp_path, "escaped", **extension)
|
||||
(escaped / "assets").mkdir()
|
||||
try:
|
||||
(escaped / "assets" / "icon.png").symlink_to(icon)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
assert {plugin.name: plugin.logo for plugin in discover_agent_plugins(tmp_path)} == {
|
||||
"demo": "data:image/png;base64,iVBORw0KGgpsb2dv",
|
||||
"escaped": None,
|
||||
}
|
||||
|
||||
|
||||
def test_plugin_mcp_requires_explicit_enable(tmp_path: Path) -> None:
|
||||
plugin = _plugin(tmp_path, "desktop")
|
||||
executable = plugin / "bin" / "server"
|
||||
executable.parent.mkdir()
|
||||
executable.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
_write_json(
|
||||
plugin / "mcp.json",
|
||||
{
|
||||
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||
"mcpServers": {
|
||||
"desktop": {
|
||||
"type": "stdio",
|
||||
"command": "./bin/server",
|
||||
"args": ["--data", "${PLUGIN_DATA}/state"],
|
||||
"cwd": "${PLUGIN_ROOT}",
|
||||
},
|
||||
"public-http": {"type": "streamable-http", "url": "http://example.com/mcp"},
|
||||
"escape": {"type": "stdio", "command": "../outside"},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||
server = agent_plugin_mcp_servers(tmp_path)["desktop"]
|
||||
assert (server.command, server.cwd, server.env["PLUGIN_ROOT"]) == (
|
||||
str(executable),
|
||||
str(plugin),
|
||||
str(plugin),
|
||||
)
|
||||
assert server.args[1].endswith("/state")
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", False)
|
||||
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||
|
||||
|
||||
def test_plugin_mcp_namespaces_cannot_shadow_plugin_identities(tmp_path: Path) -> None:
|
||||
single = _plugin(tmp_path, "foo-bar")
|
||||
multi = _plugin(tmp_path, "foo")
|
||||
for root, servers in (
|
||||
(single, {"main": {"type": "stdio", "command": "echo", "args": ["single"]}}),
|
||||
(
|
||||
multi,
|
||||
{
|
||||
"bar": {"type": "stdio", "command": "echo", "args": ["multi"]},
|
||||
"other": {"type": "stdio", "command": "echo"},
|
||||
},
|
||||
),
|
||||
):
|
||||
_write_json(
|
||||
root / "mcp.json",
|
||||
{"$schema": AGENT_PLUGIN_MCP_SCHEMA, "mcpServers": servers},
|
||||
)
|
||||
set_agent_plugin_enabled(tmp_path, "foo-bar", True)
|
||||
set_agent_plugin_enabled(tmp_path, "foo", True)
|
||||
|
||||
servers = agent_plugin_mcp_servers(tmp_path)
|
||||
|
||||
assert set(servers) == {"foo-bar", "foo--bar", "foo--other"}
|
||||
assert servers["foo-bar"].args == ["single"]
|
||||
assert servers["foo--bar"].args == ["multi"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restricted_project_can_read_only_enabled_plugin_skill(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
agent_workspace = tmp_path / "agent"
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
plugin = _plugin(agent_workspace)
|
||||
skill = _skill(plugin / "skills", "demo-skill")
|
||||
resource = skill / "reference.md"
|
||||
resource.write_text("plugin reference", encoding="utf-8")
|
||||
ctx = ToolContext(
|
||||
config=ToolsConfig(restrict_to_workspace=True),
|
||||
workspace=str(agent_workspace),
|
||||
)
|
||||
read_tool = ReadFileTool.create(ctx)
|
||||
write_tool = WriteFileTool.create(ctx)
|
||||
set_agent_plugin_enabled(agent_workspace, "demo", True)
|
||||
activation_checks = 0
|
||||
activation_marker = agent_plugins._activation_marker
|
||||
|
||||
def count_activation_checks(plugin: agent_plugins.AgentPlugin) -> str | None:
|
||||
nonlocal activation_checks
|
||||
activation_checks += 1
|
||||
return activation_marker(plugin)
|
||||
|
||||
monkeypatch.setattr(agent_plugins, "_activation_marker", count_activation_checks)
|
||||
scope = validate_workspace_scope_payload(
|
||||
{"project_path": str(project), "access_mode": "restricted"},
|
||||
default_workspace=agent_workspace,
|
||||
default_restrict_to_workspace=True,
|
||||
)
|
||||
|
||||
token = bind_workspace_scope(scope)
|
||||
try:
|
||||
read_result = await read_tool.execute(path=str(resource))
|
||||
repeated_read_result = await read_tool.execute(path=str(resource))
|
||||
write_result = await write_tool.execute(path=str(resource), content="changed")
|
||||
set_agent_plugin_enabled(agent_workspace, "demo", False)
|
||||
disabled_result = await read_tool.execute(path=str(resource))
|
||||
finally:
|
||||
reset_workspace_scope(token)
|
||||
|
||||
assert "plugin reference" in read_result
|
||||
assert "File unchanged since last read" in repeated_read_result
|
||||
assert activation_checks == 1
|
||||
assert "outside allowed directory" in write_result
|
||||
assert "outside allowed directory" in disabled_result
|
||||
assert resource.read_text(encoding="utf-8") == "plugin reference"
|
||||
|
||||
|
||||
def test_plugin_state_symlink_cannot_escape_config_root(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config = tmp_path / "config"
|
||||
config.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
try:
|
||||
(config / "plugin-data").symlink_to(outside, target_is_directory=True)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"directory symlink unavailable: {exc}")
|
||||
monkeypatch.setattr(agent_plugins, "get_config_path", lambda: config / "config.json")
|
||||
_plugin(tmp_path, "desktop")
|
||||
|
||||
with pytest.raises(RuntimeError, match="escapes its parent"):
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||
|
||||
|
||||
def test_plugin_activation_requires_one_stable_package_identity(tmp_path: Path) -> None:
|
||||
roots = [tmp_path / "plugins" / directory for directory in ("first", "second")]
|
||||
for root, marker in zip(roots, ("trusted", "replacement"), strict=True):
|
||||
_write_json(root / "plugin.json", _manifest("duplicate"))
|
||||
_write_json(
|
||||
root / "mcp.json",
|
||||
{
|
||||
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||
"mcpServers": {
|
||||
"server": {"type": "stdio", "command": "echo", "args": [marker]}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert discover_agent_plugins(tmp_path) == []
|
||||
with pytest.raises(ValueError, match="unknown Agent Plugin"):
|
||||
set_agent_plugin_enabled(tmp_path, "duplicate", True)
|
||||
|
||||
shutil.rmtree(roots[1])
|
||||
set_agent_plugin_enabled(tmp_path, "duplicate", True)
|
||||
assert discover_agent_plugins(tmp_path)[0].enabled is True
|
||||
|
||||
moved = tmp_path / "plugins" / "moved"
|
||||
roots[0].rename(moved)
|
||||
assert discover_agent_plugins(tmp_path)[0].enabled is False
|
||||
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||
|
||||
|
||||
def test_legacy_path_activation_is_upgraded_to_package_fingerprint(tmp_path: Path) -> None:
|
||||
plugin = _plugin(tmp_path)
|
||||
set_agent_plugin_enabled(tmp_path, "demo", True)
|
||||
marker = next((tmp_path / "config" / "plugin-data").glob("*/demo/enabled"))
|
||||
marker.write_text(str(plugin), encoding="utf-8")
|
||||
|
||||
assert discover_agent_plugins(tmp_path)[0].enabled is True
|
||||
assert marker.read_text(encoding="utf-8").startswith('{"fingerprint":')
|
||||
|
||||
|
||||
def test_plugin_activation_does_not_survive_in_place_contract_replacement(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
plugin = _plugin(tmp_path, "desktop")
|
||||
mcp = plugin / "mcp.json"
|
||||
|
||||
def write_server(marker: str) -> None:
|
||||
_write_json(
|
||||
mcp,
|
||||
{
|
||||
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||
"mcpServers": {
|
||||
"server": {"type": "stdio", "command": "echo", "args": [marker]}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
write_server("trusted")
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||
assert agent_plugin_mcp_servers(tmp_path)["desktop"].args == ["trusted"]
|
||||
|
||||
write_server("replacement")
|
||||
|
||||
assert discover_agent_plugins(tmp_path)[0].enabled is False
|
||||
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||
|
||||
|
||||
def test_plugin_activation_does_not_survive_in_place_code_replacement(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
plugin = _plugin(tmp_path, "desktop")
|
||||
_skill(plugin / "skills", "demo")
|
||||
executable = plugin / "server.py"
|
||||
executable.write_text("print('trusted')\n", encoding="utf-8")
|
||||
_write_json(
|
||||
plugin / "mcp.json",
|
||||
{
|
||||
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||
"mcpServers": {
|
||||
"server": {
|
||||
"type": "stdio",
|
||||
"command": "python",
|
||||
"args": ["${PLUGIN_ROOT}/server.py"],
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||
assert discover_agent_plugins(tmp_path)[0].enabled is True
|
||||
assert enabled_agent_plugin_skill_dirs(tmp_path) == (plugin / "skills" / "demo",)
|
||||
|
||||
executable.write_text("print('replacement')\n", encoding="utf-8")
|
||||
|
||||
assert discover_agent_plugins(tmp_path)[0].enabled is False
|
||||
assert enabled_agent_plugin_skill_dirs(tmp_path) == ()
|
||||
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||
@@ -8,7 +8,6 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.command import CommandContext
|
||||
@@ -194,11 +193,7 @@ class TestIdleScanThrottling:
|
||||
})
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
loop = AgentLoop.from_config(
|
||||
config,
|
||||
tool_registry=ToolRegistry(),
|
||||
provider=provider,
|
||||
)
|
||||
loop = AgentLoop.from_config(config, provider=provider)
|
||||
loop.auto_compact.check_expired = MagicMock()
|
||||
|
||||
loop._check_expired_sessions_if_due()
|
||||
@@ -315,7 +310,7 @@ class TestAutoCompact:
|
||||
assert loop.auto_compact._is_expired(ts) is True
|
||||
ts2 = datetime.now() - timedelta(minutes=14, seconds=59)
|
||||
assert loop.auto_compact._is_expired(ts2) is False
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_expired_string_timestamp(self, tmp_path):
|
||||
@@ -325,7 +320,7 @@ class TestAutoCompact:
|
||||
assert loop.auto_compact._is_expired(ts) is True
|
||||
assert loop.auto_compact._is_expired(None) is False
|
||||
assert loop.auto_compact._is_expired("") is False
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_expired_only_archives_expired_sessions(self, tmp_path):
|
||||
@@ -348,7 +343,7 @@ class TestAutoCompact:
|
||||
active_after = loop.sessions.get_or_create("cli:active")
|
||||
assert len(active_after.messages) == 1
|
||||
assert active_after.messages[0]["content"] == "recent"
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_compact_archives_full_tail_without_deleting_history(self, tmp_path):
|
||||
@@ -372,7 +367,7 @@ class TestAutoCompact:
|
||||
assert len(visible) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
||||
assert visible[0]["content"] == "msg user 2"
|
||||
assert visible[-1]["content"] == "msg assistant 5"
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_compact_extends_recent_suffix_to_user_turn(self, tmp_path):
|
||||
@@ -403,7 +398,7 @@ class TestAutoCompact:
|
||||
for m in visible
|
||||
for tc in (m.get("tool_calls") or [])
|
||||
)
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_compact_stores_summary(self, tmp_path):
|
||||
@@ -427,7 +422,7 @@ class TestAutoCompact:
|
||||
assert len(session_after.get_history(max_messages=12)) == (
|
||||
loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
||||
)
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_compact_empty_session(self, tmp_path):
|
||||
@@ -441,7 +436,7 @@ class TestAutoCompact:
|
||||
session_after = loop.sessions.get_or_create("cli:test")
|
||||
assert len(session_after.messages) == 0
|
||||
assert "cli:test" not in loop.auto_compact._summaries
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_compact_respects_last_consolidated(self, tmp_path):
|
||||
@@ -460,7 +455,7 @@ class TestAutoCompact:
|
||||
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
|
||||
|
||||
assert len(archived_messages) == 10
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
|
||||
class TestAutoCompactIdleDetection:
|
||||
@@ -479,7 +474,7 @@ class TestAutoCompactIdleDetection:
|
||||
|
||||
session_after = loop.sessions.get_or_create("cli:test")
|
||||
assert any(m["content"] == "old message" for m in session_after.messages)
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_compact_triggers_on_idle(self, tmp_path):
|
||||
@@ -508,7 +503,7 @@ class TestAutoCompactIdleDetection:
|
||||
for m in session_after.get_history(max_messages=len(session_after.messages))
|
||||
)
|
||||
assert any(m["content"] == "new msg" for m in session_after.messages)
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_auto_compact_when_active(self, tmp_path):
|
||||
@@ -522,7 +517,7 @@ class TestAutoCompactIdleDetection:
|
||||
|
||||
session_after = loop.sessions.get_or_create("cli:test")
|
||||
assert any(m["content"] == "recent message" for m in session_after.messages)
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_compact_does_not_affect_priority_commands(self, tmp_path):
|
||||
@@ -545,7 +540,7 @@ class TestAutoCompactIdleDetection:
|
||||
# Session should be untouched since priority commands skip _process_message
|
||||
session_after = loop.sessions.get_or_create("cli:test")
|
||||
assert any(m["content"] == "old message" for m in session_after.messages)
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_compact_with_slash_new(self, tmp_path):
|
||||
@@ -567,7 +562,7 @@ class TestAutoCompactIdleDetection:
|
||||
|
||||
session_after = loop.sessions.get_or_create("cli:test")
|
||||
assert len(session_after.messages) == 0
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shortcut_command_persisted_with_command_flag(self, tmp_path):
|
||||
@@ -586,7 +581,7 @@ class TestAutoCompactIdleDetection:
|
||||
assert session_after.messages[1]["role"] == "assistant"
|
||||
assert session_after.messages[1].get("_command") is True
|
||||
assert AgentLoop._PENDING_USER_TURN_KEY not in session_after.metadata
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shortcut_command_excluded_from_get_history(self, tmp_path):
|
||||
@@ -602,7 +597,7 @@ class TestAutoCompactIdleDetection:
|
||||
assert len(history) == 2
|
||||
assert all(m["content"] != "/help" for m in history)
|
||||
assert all(m["content"] != "help text" for m in history)
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
|
||||
class TestAutoCompactSystemMessages:
|
||||
@@ -633,7 +628,7 @@ class TestAutoCompactSystemMessages:
|
||||
m["content"] == "old user 0"
|
||||
for m in session_after.get_history(max_messages=len(session_after.messages))
|
||||
)
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
|
||||
class TestAutoCompactEdgeCases:
|
||||
@@ -661,7 +656,7 @@ class TestAutoCompactEdgeCases:
|
||||
# "(nothing)" summary should not be stored
|
||||
assert "cli:test" not in loop.auto_compact._summaries
|
||||
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_compact_archive_failure_preserves_raw_history(self, tmp_path):
|
||||
@@ -682,7 +677,7 @@ class TestAutoCompactEdgeCases:
|
||||
loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
||||
)
|
||||
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_compact_preserves_runtime_checkpoint_before_check(self, tmp_path):
|
||||
@@ -714,7 +709,7 @@ class TestAutoCompactEdgeCases:
|
||||
assert any(m["content"] == "previous message" for m in session_after.messages)
|
||||
assert any(m["content"] == "interrupted response" for m in session_after.messages)
|
||||
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
|
||||
class TestAutoCompactIntegration:
|
||||
@@ -784,7 +779,7 @@ class TestAutoCompactIntegration:
|
||||
# The new message should be processed (response exists)
|
||||
assert response is not None
|
||||
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_context_markers_not_persisted_for_multi_paragraph_turn(self, tmp_path):
|
||||
@@ -812,7 +807,7 @@ class TestAutoCompactIntegration:
|
||||
content = str(persisted.get("content", ""))
|
||||
assert "[Runtime Context" not in content
|
||||
assert "[/Runtime Context]" not in content
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
|
||||
class TestProactiveAutoCompact:
|
||||
@@ -875,7 +870,7 @@ class TestProactiveAutoCompact:
|
||||
|
||||
session_after = loop.sessions.get_or_create("cli:test")
|
||||
assert len(session_after.messages) == 1
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proactive_archive_on_idle_tick(self, tmp_path):
|
||||
@@ -902,7 +897,7 @@ class TestProactiveAutoCompact:
|
||||
entry = loop.auto_compact._summaries.get("cli:test")
|
||||
assert entry is not None
|
||||
assert entry[0] == "User chatted about old things."
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proactive_archive_skips_dream_sessions(self, tmp_path):
|
||||
@@ -923,7 +918,7 @@ class TestProactiveAutoCompact:
|
||||
assert _fake_compact.state["count"] == 0
|
||||
assert "dream:20260602-155256" not in loop.auto_compact._archiving
|
||||
assert "dream:20260602-155256" not in loop.auto_compact._summaries
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_proactive_archive_when_active(self, tmp_path):
|
||||
@@ -937,7 +932,7 @@ class TestProactiveAutoCompact:
|
||||
|
||||
session_after = loop.sessions.get_or_create("cli:test")
|
||||
assert len(session_after.messages) == 1
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_duplicate_archive(self, tmp_path):
|
||||
@@ -973,7 +968,7 @@ class TestProactiveAutoCompact:
|
||||
# Clean up
|
||||
block_forever.set()
|
||||
await _drain_background_tasks(loop)
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proactive_archive_error_does_not_block(self, tmp_path):
|
||||
@@ -994,7 +989,7 @@ class TestProactiveAutoCompact:
|
||||
|
||||
# Key should be removed from _archiving (finally block)
|
||||
assert "cli:test" not in loop.auto_compact._archiving
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proactive_archive_skips_empty_sessions(self, tmp_path):
|
||||
@@ -1010,7 +1005,7 @@ class TestProactiveAutoCompact:
|
||||
|
||||
# Empty session should not produce a summary
|
||||
assert "cli:test" not in loop.auto_compact._summaries
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skip_expired_session_with_active_agent_task(self, tmp_path):
|
||||
@@ -1031,7 +1026,7 @@ class TestProactiveAutoCompact:
|
||||
session_after = loop.sessions.get_or_create("cli:test")
|
||||
assert len(session_after.messages) == 12 # All messages preserved
|
||||
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_after_active_task_completes(self, tmp_path):
|
||||
@@ -1052,7 +1047,7 @@ class TestProactiveAutoCompact:
|
||||
# Second tick: task completed, should archive
|
||||
await self._run_check_expired(loop)
|
||||
assert _fake_compact.state["count"] == 1
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_active_set_only_archives_inactive_expired(self, tmp_path):
|
||||
@@ -1088,7 +1083,7 @@ class TestProactiveAutoCompact:
|
||||
assert len(s2_after.messages) == 12 # Preserved
|
||||
s3_after = loop.sessions.get_or_create("cli:recent")
|
||||
assert len(s3_after.messages) == 1 # Preserved
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_reschedule_after_successful_archive(self, tmp_path):
|
||||
@@ -1109,7 +1104,7 @@ class TestProactiveAutoCompact:
|
||||
# Second tick: should NOT re-schedule because the session has no removable tail.
|
||||
await self._run_check_expired(loop)
|
||||
assert _fake_compact.state["count"] == 1 # Still 1, not re-scheduled
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_session_does_not_schedule_idle_compact(self, tmp_path):
|
||||
@@ -1129,7 +1124,7 @@ class TestProactiveAutoCompact:
|
||||
await self._run_check_expired(loop)
|
||||
assert _fake_compact.state["count"] == 0
|
||||
assert "cli:test" not in loop.auto_compact._summaries
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_can_be_compacted_again_after_new_messages(self, tmp_path):
|
||||
@@ -1160,7 +1155,7 @@ class TestProactiveAutoCompact:
|
||||
# Second compact cycle should succeed
|
||||
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
|
||||
assert _fake_compact.state["count"] == 2
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
|
||||
class TestSummaryPersistence:
|
||||
@@ -1187,7 +1182,7 @@ class TestSummaryPersistence:
|
||||
assert meta is not None
|
||||
assert meta["text"] == "User said hello."
|
||||
assert "last_active" in meta
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_recovered_after_restart(self, tmp_path):
|
||||
@@ -1223,7 +1218,7 @@ class TestSummaryPersistence:
|
||||
assert "Previous conversation summary" in summary
|
||||
# _last_summary persists in metadata for restart survival.
|
||||
assert "_last_summary" in reloaded.metadata
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metadata_persists_for_restart(self, tmp_path):
|
||||
@@ -1251,7 +1246,7 @@ class TestSummaryPersistence:
|
||||
assert "Summary." in summary2
|
||||
# _last_summary persists in metadata for restart survival.
|
||||
assert "_last_summary" in reloaded.metadata
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metadata_cleanup_on_inmemory_path(self, tmp_path):
|
||||
@@ -1277,7 +1272,7 @@ class TestSummaryPersistence:
|
||||
assert summary is not None
|
||||
# _last_summary persists in metadata for restart survival.
|
||||
assert "_last_summary" in reloaded.metadata
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_summary_overrides_old(self, tmp_path):
|
||||
@@ -1319,7 +1314,7 @@ class TestSummaryPersistence:
|
||||
_, summary2 = loop.auto_compact.prepare_session(reloaded, "cli:test")
|
||||
assert summary2 is not None
|
||||
assert "Second summary." in summary2
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_command_clears_last_summary(self, tmp_path):
|
||||
@@ -1347,4 +1342,4 @@ class TestSummaryPersistence:
|
||||
# After /new, metadata should no longer contain _last_summary
|
||||
fresh = loop.sessions.get_or_create("cli:test")
|
||||
assert "_last_summary" not in fresh.metadata
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||
|
||||
|
||||
def test_weather_skill_uses_windows_safe_single_today_request() -> None:
|
||||
content = (BUILTIN_SKILLS_DIR / "weather" / "SKILL.md").read_text(encoding="utf-8")
|
||||
normalized = " ".join(content.split())
|
||||
|
||||
assert "On Windows PowerShell, use `curl.exe`" in normalized
|
||||
assert "bare `curl` may resolve to `Invoke-WebRequest`" in normalized
|
||||
assert "https://wttr.in/London?1&m" in content
|
||||
assert 'curl.exe -s "https://wttr.in/Berlin.png" -o weather.png' in content
|
||||
assert "/tmp/weather.png" not in content
|
||||
assert (
|
||||
"Do not fetch current conditions separately when a today or forecast "
|
||||
"request already includes them."
|
||||
) in normalized
|
||||
@@ -538,7 +538,7 @@ class TestNewCommandArchival:
|
||||
session_after = loop.sessions.get_or_create("cli:test")
|
||||
assert len(session_after.messages) == 0
|
||||
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
assert call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -572,7 +572,7 @@ class TestNewCommandArchival:
|
||||
assert response is not None
|
||||
assert "new session started" in response.content.lower()
|
||||
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
assert archived_count == 3
|
||||
assert archived_session_key == "cli:test"
|
||||
|
||||
@@ -603,8 +603,8 @@ class TestNewCommandArchival:
|
||||
assert loop.sessions.get_or_create("cli:test").messages == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aclose_drains_background_tasks(self, tmp_path: Path) -> None:
|
||||
"""aclose waits for background tasks to complete."""
|
||||
async def test_close_mcp_drains_background_tasks(self, tmp_path: Path) -> None:
|
||||
"""close_mcp waits for background tasks to complete."""
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
loop = self._make_loop(tmp_path)
|
||||
@@ -632,5 +632,5 @@ class TestNewCommandArchival:
|
||||
|
||||
assert not archived.is_set()
|
||||
release_archive.set()
|
||||
await loop.aclose()
|
||||
await loop.close_mcp()
|
||||
assert archived.is_set()
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
from nanobot.agent.context_governance import ContextGovernor
|
||||
|
||||
|
||||
def _image_result(label: str) -> list[dict]:
|
||||
return [
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{label}"}},
|
||||
{"type": "text", "text": label},
|
||||
]
|
||||
|
||||
|
||||
def _assistant_tool_call(call_id: str) -> dict:
|
||||
return {
|
||||
"role": "assistant",
|
||||
@@ -37,3 +44,21 @@ def test_drop_orphan_tool_results_drops_duplicate_tool_result() -> None:
|
||||
tool_results = [m for m in result if m.get("role") == "tool"]
|
||||
assert len(tool_results) == 1
|
||||
assert tool_results[0]["content"] == "first"
|
||||
|
||||
|
||||
def test_drop_stale_visual_tool_images_keeps_latest_per_tool() -> None:
|
||||
messages = [
|
||||
{"role": "tool", "name": "computer_use", "content": _image_result("history")},
|
||||
{"role": "tool", "name": "computer_use", "content": _image_result("old")},
|
||||
{"role": "tool", "name": "browser", "content": _image_result("browser")},
|
||||
{"role": "tool", "name": "computer_use", "content": _image_result("latest")},
|
||||
]
|
||||
|
||||
result = ContextGovernor.drop_stale_visual_tool_images(messages, start_index=1)
|
||||
|
||||
assert result is not messages
|
||||
assert result[0]["content"] == messages[0]["content"]
|
||||
assert [block["type"] for block in result[1]["content"]] == ["text", "text"]
|
||||
assert result[2]["content"] == messages[2]["content"]
|
||||
assert result[3]["content"] == messages[3]["content"]
|
||||
assert messages[1]["content"][0]["type"] == "image_url"
|
||||
|
||||
@@ -10,7 +10,6 @@ from unittest.mock import patch
|
||||
|
||||
from nanobot.providers.base import ToolCallRequest
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
from nanobot.providers.registry import ProviderSpec
|
||||
|
||||
GEMINI_EXTRA = {"google": {"thought_signature": "sig-abc-123"}}
|
||||
|
||||
@@ -244,251 +243,3 @@ def test_stale_extra_content_in_tool_calls_survives_sanitize() -> None:
|
||||
sanitized = provider._sanitize_messages(messages)
|
||||
|
||||
assert sanitized[1]["tool_calls"][0]["extra_content"] == GEMINI_EXTRA
|
||||
|
||||
|
||||
# ── Replay to Gemini: preserve or backfill thought signatures ─────────
|
||||
|
||||
def _gemini_provider() -> OpenAICompatProvider:
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
return OpenAICompatProvider(
|
||||
spec=ProviderSpec(
|
||||
name="gemini", keywords=("gemini",), env_key="GEMINI_API_KEY"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _tool_call(tc_id: str, name: str, *, signed: bool = False) -> dict:
|
||||
tc: dict = {
|
||||
"id": tc_id,
|
||||
"type": "function",
|
||||
"function": {"name": name, "arguments": "{}"},
|
||||
}
|
||||
if signed:
|
||||
tc["extra_content"] = GEMINI_EXTRA
|
||||
return tc
|
||||
|
||||
|
||||
def test_gemini_backfills_unsigned_tool_calls_and_keeps_results() -> None:
|
||||
"""Cross-provider history stays intact and receives the documented fallback."""
|
||||
provider = _gemini_provider()
|
||||
messages = [
|
||||
{"role": "user", "content": "check the sensor"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "On it.",
|
||||
"tool_calls": [_tool_call("default_api:exec", "exec")],
|
||||
},
|
||||
{"role": "tool", "content": "done", "tool_call_id": "default_api:exec"},
|
||||
{"role": "user", "content": "thanks"},
|
||||
]
|
||||
|
||||
sanitized = provider._sanitize_messages(messages)
|
||||
|
||||
assert [m["role"] for m in sanitized] == ["user", "assistant", "tool", "user"]
|
||||
call = sanitized[1]["tool_calls"][0]
|
||||
assert call["extra_content"]["google"]["thought_signature"] == (
|
||||
"skip_thought_signature_validator"
|
||||
)
|
||||
assert sanitized[2]["tool_call_id"] == call["id"]
|
||||
assert sanitized[2]["content"] == "done"
|
||||
|
||||
|
||||
def test_gemini_preserves_parallel_calls_when_only_first_is_signed() -> None:
|
||||
"""Gemini signs only the first native parallel call; all calls must replay."""
|
||||
provider = _gemini_provider()
|
||||
messages = [
|
||||
{"role": "user", "content": "do both"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
_tool_call("call_signed", "read_file", signed=True),
|
||||
_tool_call("default_api:exec", "exec"),
|
||||
],
|
||||
},
|
||||
{"role": "tool", "content": "file contents", "tool_call_id": "call_signed"},
|
||||
{"role": "tool", "content": "done", "tool_call_id": "default_api:exec"},
|
||||
{"role": "user", "content": "thanks"},
|
||||
]
|
||||
|
||||
sanitized = provider._sanitize_messages(messages)
|
||||
|
||||
assert [m["role"] for m in sanitized] == [
|
||||
"user",
|
||||
"assistant",
|
||||
"tool",
|
||||
"tool",
|
||||
"user",
|
||||
]
|
||||
calls = sanitized[1]["tool_calls"]
|
||||
assert len(calls) == 2
|
||||
assert calls[0]["extra_content"] == GEMINI_EXTRA
|
||||
assert sanitized[2]["tool_call_id"] == calls[0]["id"]
|
||||
assert sanitized[2]["content"] == "file contents"
|
||||
assert "extra_content" not in calls[1]
|
||||
assert sanitized[3]["tool_call_id"] == calls[1]["id"]
|
||||
assert sanitized[3]["content"] == "done"
|
||||
|
||||
|
||||
def test_gemini_backfills_only_first_cross_provider_parallel_call() -> None:
|
||||
provider = _gemini_provider()
|
||||
messages = [
|
||||
{"role": "user", "content": "do both"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
_tool_call("call_1", "read_file"),
|
||||
_tool_call("call_2", "exec"),
|
||||
],
|
||||
},
|
||||
{"role": "tool", "content": "file contents", "tool_call_id": "call_1"},
|
||||
{"role": "tool", "content": "done", "tool_call_id": "call_2"},
|
||||
]
|
||||
|
||||
sanitized = provider._sanitize_messages(messages)
|
||||
|
||||
calls = sanitized[1]["tool_calls"]
|
||||
assert len(calls) == 2
|
||||
assert calls[0]["extra_content"]["google"]["thought_signature"] == (
|
||||
"skip_thought_signature_validator"
|
||||
)
|
||||
assert "extra_content" not in calls[1]
|
||||
assert [message["content"] for message in sanitized[2:]] == ["file contents", "done"]
|
||||
|
||||
|
||||
def test_gemini_requires_signature_on_first_parallel_call() -> None:
|
||||
provider = _gemini_provider()
|
||||
messages = [
|
||||
{"role": "user", "content": "do both"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
_tool_call("call_1", "read_file"),
|
||||
_tool_call("call_2", "exec", signed=True),
|
||||
],
|
||||
},
|
||||
{"role": "tool", "content": "contents", "tool_call_id": "call_1"},
|
||||
{"role": "tool", "content": "done", "tool_call_id": "call_2"},
|
||||
]
|
||||
|
||||
sanitized = provider._sanitize_messages(messages)
|
||||
|
||||
calls = sanitized[1]["tool_calls"]
|
||||
assert calls[0]["extra_content"]["google"]["thought_signature"] == (
|
||||
"skip_thought_signature_validator"
|
||||
)
|
||||
assert calls[1]["extra_content"] == GEMINI_EXTRA
|
||||
|
||||
|
||||
def test_gemini_replay_preserves_signed_tool_calls() -> None:
|
||||
"""A pure Gemini-origin history replays unchanged (signature intact)."""
|
||||
provider = _gemini_provider()
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [_tool_call("call_1", "get_weather", signed=True)],
|
||||
},
|
||||
{"role": "tool", "content": "sunny", "tool_call_id": "call_1"},
|
||||
{"role": "user", "content": "thanks"},
|
||||
]
|
||||
|
||||
sanitized = provider._sanitize_messages(messages)
|
||||
|
||||
assert [m["role"] for m in sanitized] == ["user", "assistant", "tool", "user"]
|
||||
calls = sanitized[1]["tool_calls"]
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["extra_content"] == GEMINI_EXTRA
|
||||
assert sanitized[2]["tool_call_id"] == calls[0]["id"]
|
||||
|
||||
|
||||
def test_non_gemini_provider_keeps_unsigned_tool_calls() -> None:
|
||||
"""The filter is Gemini-scoped: other providers still replay unsigned calls."""
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = OpenAICompatProvider()
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [_tool_call("default_api:exec", "exec")],
|
||||
},
|
||||
{"role": "tool", "content": "done", "tool_call_id": "default_api:exec"},
|
||||
{"role": "user", "content": "thanks"},
|
||||
]
|
||||
|
||||
sanitized = provider._sanitize_messages(messages)
|
||||
|
||||
assert len(sanitized[1]["tool_calls"]) == 1
|
||||
assert sanitized[2]["role"] == "tool"
|
||||
assert sanitized[2]["tool_call_id"] == sanitized[1]["tool_calls"][0]["id"]
|
||||
|
||||
|
||||
def test_gemini_drops_malformed_tool_call_entries_without_crashing() -> None:
|
||||
provider = _gemini_provider()
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": None, "tool_calls": [None]},
|
||||
{"role": "user", "content": "continue"},
|
||||
]
|
||||
|
||||
sanitized = provider._sanitize_messages(messages)
|
||||
|
||||
assert not any(message.get("tool_calls") for message in sanitized)
|
||||
|
||||
|
||||
def test_gemini_matches_duplicate_tool_ids_by_call_instance() -> None:
|
||||
provider = _gemini_provider()
|
||||
messages = [
|
||||
{"role": "user", "content": "old request"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [_tool_call("reused", "old_tool")],
|
||||
},
|
||||
{"role": "tool", "content": "old result", "tool_call_id": "reused"},
|
||||
{"role": "user", "content": "new request"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [_tool_call("reused", "new_tool", signed=True)],
|
||||
},
|
||||
{"role": "tool", "content": "new result", "tool_call_id": "reused"},
|
||||
]
|
||||
|
||||
sanitized = provider._sanitize_messages(messages)
|
||||
|
||||
assert any(message.get("content") == "old result" for message in sanitized)
|
||||
assert any(message.get("content") == "new result" for message in sanitized)
|
||||
calls = [
|
||||
call
|
||||
for message in sanitized
|
||||
for call in message.get("tool_calls", [])
|
||||
]
|
||||
assert len(calls) == 2
|
||||
assert calls[0]["function"]["name"] == "old_tool"
|
||||
assert calls[0]["extra_content"]["google"]["thought_signature"] == (
|
||||
"skip_thought_signature_validator"
|
||||
)
|
||||
assert calls[1]["function"]["name"] == "new_tool"
|
||||
|
||||
|
||||
def test_gemini_backfill_does_not_mutate_caller_history() -> None:
|
||||
provider = _gemini_provider()
|
||||
call = _tool_call("call_1", "read_file")
|
||||
messages = [
|
||||
{"role": "user", "content": "read it"},
|
||||
{"role": "assistant", "content": None, "tool_calls": [call]},
|
||||
{"role": "tool", "content": "contents", "tool_call_id": "call_1"},
|
||||
]
|
||||
|
||||
sanitized = provider._sanitize_messages(messages)
|
||||
|
||||
assert "extra_content" not in call
|
||||
assert sanitized[1]["tool_calls"][0]["extra_content"]["google"][
|
||||
"thought_signature"
|
||||
] == "skip_thought_signature_validator"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user