mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-14 16:19:17 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f94e1d73e2 |
@@ -189,44 +189,6 @@ jobs:
|
|||||||
- name: Build image with default channel dependencies
|
- name: Build image with default channel dependencies
|
||||||
run: docker build -t nanobot:test .
|
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
|
- name: Verify default WhatsApp dependencies
|
||||||
run: docker run --rm --entrypoint python nanobot:test -c "import neonize, segno"
|
run: docker run --rm --entrypoint python nanobot:test -c "import neonize, segno"
|
||||||
|
|
||||||
|
|||||||
-27
@@ -6,7 +6,6 @@ import os
|
|||||||
import ssl
|
import ssl
|
||||||
import sys
|
import sys
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import certifi
|
import certifi
|
||||||
import pytest
|
import pytest
|
||||||
@@ -23,32 +22,6 @@ def _isolate_nanobot_log_activation() -> Iterator[None]:
|
|||||||
logger.enable("nanobot")
|
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)
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
def _use_windows_system_ca_for_default_http_clients() -> Iterator[None]:
|
def _use_windows_system_ca_for_default_http_clients() -> Iterator[None]:
|
||||||
"""Avoid reparsing certifi's CA bundle for every offline HTTP client.
|
"""Avoid reparsing certifi's CA bundle for every offline HTTP client.
|
||||||
|
|||||||
@@ -8,15 +8,6 @@ x-common-config: &common-config
|
|||||||
- ~/.nanobot:/home/nanobot/.nanobot
|
- ~/.nanobot:/home/nanobot/.nanobot
|
||||||
cap_drop:
|
cap_drop:
|
||||||
- ALL
|
- 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:
|
services:
|
||||||
nanobot-gateway:
|
nanobot-gateway:
|
||||||
|
|||||||
+3
-10
@@ -51,13 +51,6 @@ Main files:
|
|||||||
- feeds tool results back into the model;
|
- feeds tool results back into the model;
|
||||||
- stops when a final answer is produced or runtime limits are hit.
|
- 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`.
|
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
|
## Providers
|
||||||
@@ -149,7 +142,7 @@ Defaults:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Config | `~/.nanobot/config.json` |
|
| Config | `~/.nanobot/config.json` |
|
||||||
| Workspace | `~/.nanobot/workspace/` |
|
| Workspace | `~/.nanobot/workspace/` |
|
||||||
| Sessions | `<config-dir>/sessions/<workspace-id>/*.jsonl` (default: `~/.nanobot/sessions/...`) |
|
| Sessions | `<workspace>/sessions/*.jsonl` |
|
||||||
| Memory | `<workspace>/memory/` |
|
| Memory | `<workspace>/memory/` |
|
||||||
| Cron store | `<workspace>/cron/jobs.json` |
|
| Cron store | `<workspace>/cron/jobs.json` |
|
||||||
| WebUI/media/log runtime data | config directory subdirectories such as `webui/`, `media/`, and `logs/` |
|
| WebUI/media/log runtime data | config directory subdirectories such as `webui/`, `media/`, and `logs/` |
|
||||||
@@ -164,7 +157,7 @@ a WebUI chat may select a separate project:
|
|||||||
|
|
||||||
| Concern | Path owner |
|
| 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 |
|
| Project `AGENTS.md`, relative tool paths, and shell working directory | Effective project workspace |
|
||||||
| Workspace access mode and project metadata | Session workspace scope |
|
| Workspace access mode and project metadata | Session workspace scope |
|
||||||
|
|
||||||
@@ -180,7 +173,7 @@ Session history is the near-term conversation replay. Memory is the longer-term
|
|||||||
|
|
||||||
| Store | File area |
|
| Store | File area |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Session JSONL files | `<config-dir>/sessions/<workspace-id>/` |
|
| Session JSONL files | `<workspace>/sessions/` |
|
||||||
| Long-term memory | `<workspace>/memory/MEMORY.md` |
|
| Long-term memory | `<workspace>/memory/MEMORY.md` |
|
||||||
| Consolidation source history | `<workspace>/memory/history.jsonl` |
|
| Consolidation source history | `<workspace>/memory/history.jsonl` |
|
||||||
| Bootstrap identity files | `<workspace>/SOUL.md`, `<workspace>/USER.md`, templates under `nanobot/templates/` |
|
| Bootstrap identity files | `<workspace>/SOUL.md`, `<workspace>/USER.md`, templates under `nanobot/templates/` |
|
||||||
|
|||||||
@@ -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 --no-markdown` | Print plain text instead of Rich-rendered Markdown |
|
||||||
| `nanobot agent --logs` | Show runtime logs while chatting |
|
| `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.
|
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`.
|
Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
||||||
|
|||||||
+2
-9
@@ -26,8 +26,7 @@ The default instance lives under `~/.nanobot/`:
|
|||||||
| Path | Meaning |
|
| Path | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `~/.nanobot/config.json` | Instance configuration: providers, model defaults, channels, tools, gateway, API, and runtime options |
|
| `~/.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/workspace/` | Agent workspace: memory, sessions, 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 |
|
|
||||||
|
|
||||||
You can override both with command flags:
|
You can override both with command flags:
|
||||||
|
|
||||||
@@ -126,17 +125,11 @@ nanobot uses two related stores:
|
|||||||
|
|
||||||
| Store | Location | Purpose |
|
| 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 |
|
| 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.
|
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.
|
See [`memory.md`](./memory.md) for the detailed design.
|
||||||
|
|
||||||
## Apps and Agent Plugins
|
## Apps and Agent Plugins
|
||||||
|
|||||||
+6
-14
@@ -360,7 +360,7 @@ request, while other tools such as `web_fetch` remain available.
|
|||||||
<details>
|
<details>
|
||||||
<summary><b>DeepSeek native web search</b></summary>
|
<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
|
enabled by default because it does not require a separate paid add-on. Turn it off from the
|
||||||
WebUI provider settings, or with:
|
WebUI provider settings, or with:
|
||||||
|
|
||||||
@@ -377,9 +377,9 @@ WebUI provider settings, or with:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The switch applies to `deepseek-v4-flash` and `deepseek-v4-pro`; DeepSeek models that remain on
|
The switch applies to `deepseek-v4-flash`; DeepSeek models that remain on Chat Completions
|
||||||
Chat Completions cannot use this Responses tool. Native search calls appear in the WebUI activity
|
cannot use this Responses tool. Native search calls appear in the WebUI activity stream, and
|
||||||
stream, and their opaque output items are preserved for multi-turn Responses state replay.
|
their opaque output items are preserved for multi-turn Responses state replay.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -391,7 +391,7 @@ Providers that use the Responses API can keep reasoning context across a
|
|||||||
conversation, which helps with multi-step tasks. Supported providers can also
|
conversation, which helps with multi-step tasks. Supported providers can also
|
||||||
compact long conversations automatically.
|
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
|
Native compaction is also automatic when the provider supports it. The
|
||||||
threshold is derived from the active model's context window and reserved output
|
threshold is derived from the active model's context window and reserved output
|
||||||
headroom; no provider configuration is required.
|
headroom; no provider configuration is required.
|
||||||
@@ -1921,14 +1921,6 @@ Create a key at [serper.dev](https://serper.dev). You can also set `SERPER_API_K
|
|||||||
|
|
||||||
nanobot by default uses [Jina Reader](https://jina.ai/reader/), a third-party API, to convert arbitrary pages into Markdown format for easy digestion by the LLM, with a local fallback based on [readability-lxml](https://github.com/buriy/python-readability) if the former fails.
|
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:
|
If you want to always use the local conversion, you can force it using:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -2103,7 +2095,7 @@ For API keys, tokens, and other secrets, see [Environment Variables for Secrets]
|
|||||||
| `tools.ssrfWhitelist` | `[]` | CIDR ranges exempted from the shared SSRF guard used by web fetches and HTTP/SSE MCP connections. Prefer exact host CIDRs such as `192.168.1.50/32`; broad ranges increase SSRF exposure. |
|
| `tools.ssrfWhitelist` | `[]` | CIDR ranges exempted from the shared SSRF guard used by web fetches and HTTP/SSE MCP connections. Prefer exact host CIDRs such as `192.168.1.50/32`; broad ranges increase SSRF exposure. |
|
||||||
| `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. |
|
| `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. |
|
||||||
|
|
||||||
**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
|
## Pairing
|
||||||
|
|||||||
+6
-15
@@ -11,7 +11,7 @@ Check these once before Render, Docker, systemd, or LaunchAgent:
|
|||||||
| `nanobot status` shows the expected config and workspace | Confirms the process will read the instance you meant to run |
|
| `nanobot 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 |
|
| `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 |
|
| 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 |
|
| 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` |
|
| 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 |
|
| 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
|
docker compose down # stop
|
||||||
```
|
```
|
||||||
|
|
||||||
The default Compose file drops all Linux capabilities except `CHOWN`, `SETUID`, and
|
The default Compose file drops all Linux capabilities and keeps Docker's default
|
||||||
`SETGID`, which the root entrypoint needs to fix bind-mount ownership and become UID
|
AppArmor/seccomp profiles enabled. If you explicitly set
|
||||||
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
|
|
||||||
`"tools.exec.sandbox": "bwrap"` in `~/.nanobot/config.json`, add the bwrap
|
`"tools.exec.sandbox": "bwrap"` in `~/.nanobot/config.json`, add the bwrap
|
||||||
override file when starting containers:
|
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!"
|
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
|
The override grants `CAP_SYS_ADMIN` and disables AppArmor/seccomp confinement for
|
||||||
container so bubblewrap can create its nested namespaces. It preserves
|
the container so bubblewrap can create its nested namespaces. Use it only when the
|
||||||
`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
|
|
||||||
bwrap sandbox is enabled.
|
bwrap sandbox is enabled.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
@@ -202,8 +197,6 @@ vim ~/.nanobot/config.json
|
|||||||
# health endpoint on 18790.
|
# health endpoint on 18790.
|
||||||
docker run \
|
docker run \
|
||||||
--cap-drop ALL \
|
--cap-drop ALL \
|
||||||
--cap-add CHOWN --cap-add SETGID --cap-add SETUID \
|
|
||||||
--security-opt no-new-privileges:true \
|
|
||||||
-v ~/.nanobot:/home/nanobot/.nanobot \
|
-v ~/.nanobot:/home/nanobot/.nanobot \
|
||||||
-p 18790:18790 -p 8765:8765 \
|
-p 18790:18790 -p 8765:8765 \
|
||||||
nanobot gateway
|
nanobot gateway
|
||||||
@@ -212,9 +205,7 @@ docker run \
|
|||||||
# bubblewrap needs for nested namespaces. Without them, `bwrap` may exit with
|
# bubblewrap needs for nested namespaces. Without them, `bwrap` may exit with
|
||||||
# `clone3: Operation not permitted`.
|
# `clone3: Operation not permitted`.
|
||||||
docker run \
|
docker run \
|
||||||
--cap-drop ALL \
|
--cap-drop ALL --cap-add SYS_ADMIN \
|
||||||
--cap-add CHOWN --cap-add SETGID --cap-add SETUID --cap-add SYS_ADMIN \
|
|
||||||
--security-opt no-new-privileges:true \
|
|
||||||
--security-opt apparmor=unconfined \
|
--security-opt apparmor=unconfined \
|
||||||
--security-opt seccomp=unconfined \
|
--security-opt seccomp=unconfined \
|
||||||
-v ~/.nanobot:/home/nanobot/.nanobot \
|
-v ~/.nanobot:/home/nanobot/.nanobot \
|
||||||
|
|||||||
@@ -81,10 +81,6 @@ in the WebUI or logs.
|
|||||||
- Web fetch and HTTP MCP share an SSRF guard.
|
- Web fetch and HTTP MCP share an SSRF guard.
|
||||||
- Private, loopback, link-local, and cloud metadata addresses are blocked by
|
- Private, loopback, link-local, and cloud metadata addresses are blocked by
|
||||||
default.
|
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.
|
- Add `tools.ssrfWhitelist` only for narrow trusted CIDRs.
|
||||||
- Do not give public chat users unrestricted web and shell access without
|
- Do not give public chat users unrestricted web and shell access without
|
||||||
review.
|
review.
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ nanobot gateway logs
|
|||||||
- Docker Compose is the most repeatable Linux container path.
|
- Docker Compose is the most repeatable Linux container path.
|
||||||
- systemd user services are useful for Linux user-level gateway deployments.
|
- systemd user services are useful for Linux user-level gateway deployments.
|
||||||
- macOS LaunchAgent keeps the gateway alive after login.
|
- macOS LaunchAgent keeps the gateway alive after login.
|
||||||
- Persist the active config directory's `sessions/` folder together with the workspace
|
- Persist config, workspace, sessions, memory files, channel login state, and
|
||||||
(including `.nanobot/workspace-id`), memory files, channel login state, and generated artifacts.
|
generated artifacts.
|
||||||
- Restart the gateway after editing `config.json`.
|
- Restart the gateway after editing `config.json`.
|
||||||
|
|
||||||
## Security notes
|
## 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` |
|
| **Config** | `--config` path | `~/.nanobot-A/config.json` |
|
||||||
| **Workspace** | `--workspace` or config | `~/.nanobot-A/workspace/` |
|
| **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/` |
|
| **Cron Jobs** | workspace directory | `~/.nanobot-A/workspace/cron/` |
|
||||||
| **Media / runtime state** | config directory | `~/.nanobot-A/media/` |
|
| **Media / runtime state** | config directory | `~/.nanobot-A/media/` |
|
||||||
|
|
||||||
@@ -127,6 +126,6 @@ nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobo
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Each instance must use a different port if they run at the same time
|
- 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
|
- `--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
|
- Cron jobs are stored in the active workspace; runtime media/state is derived from the config directory
|
||||||
|
|||||||
+1
-1
@@ -287,7 +287,7 @@ Arbitrary custom provider names are OpenAI-compatible only; they do not use the
|
|||||||
|
|
||||||
`providers.openai.apiType` may be set when you need to force a specific OpenAI API surface. Other providers reject `apiType`; leave it unset outside `providers.openai`. Replace the model with a model ID available to your OpenAI account. Direct OpenAI Responses, OpenAI Codex, Azure OpenAI Responses, and eligible GitHub Copilot models share [opaque Responses state retention](./configuration.md#responses-state-and-compaction); native compaction is enabled only where the backend supports it. The WebUI exposes provider-native switches for OpenAI web search, Codex Fast mode, DeepSeek web search, and Grok X Search. These switches write the corresponding raw provider request fields under `extraBody`.
|
`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
|
### Custom OpenAI-Compatible Endpoint
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -48,8 +48,7 @@ The WebUI launcher creates or updates:
|
|||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `~/.nanobot/config.json` | Provider, model, WebUI, channel, tool, and runtime settings |
|
| `~/.nanobot/config.json` | Provider, model, WebUI, channel, tool, and runtime settings |
|
||||||
| `~/.nanobot/workspace/` | Memory, skills, automations, and generated files |
|
| `~/.nanobot/workspace/` | Sessions, memory, skills, automations, and generated files |
|
||||||
| `~/.nanobot/sessions/<workspace-id>/` | Recent session history stored outside the workspace; the ID remains stable across workspace moves |
|
|
||||||
|
|
||||||
If the installer did not open the browser, run:
|
If the installer did not open the browser, run:
|
||||||
|
|
||||||
|
|||||||
@@ -319,8 +319,7 @@ See [`chat-apps.md`](./chat-apps.md) for channel-specific setup.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Conversation context seems wrong | Confirm the active workspace and session. WebUI chats and chat app threads may use different sessions. |
|
| 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. |
|
| 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. |
|
| Old sessions appear after moving config | Session files are stored under `<workspace>/sessions/`; verify the workspace path. |
|
||||||
| 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. |
|
|
||||||
| You want one shared session across devices | Set `agents.defaults.unifiedSession` intentionally; otherwise keep separate sessions. |
|
| You want one shared session across devices | Set `agents.defaults.unifiedSession` intentionally; otherwise keep separate sessions. |
|
||||||
|
|
||||||
## Collect Useful Evidence
|
## Collect Useful Evidence
|
||||||
|
|||||||
@@ -42,11 +42,29 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def connect_mcp(state: Any, tools: ToolRegistry) -> None:
|
||||||
|
await mcp_tools.connect_missing_servers(state, tools)
|
||||||
|
|
||||||
|
|
||||||
|
def mcp_runtime_status(state: Any) -> dict[str, mcp_tools.MCPRuntimeStatus]:
|
||||||
|
return mcp_tools.runtime_status(state)
|
||||||
|
|
||||||
|
|
||||||
|
async def close_mcp(state: Any) -> None:
|
||||||
|
await mcp_tools.close_mcp_servers(state)
|
||||||
|
|
||||||
|
|
||||||
async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
|
async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
|
||||||
if msg.metadata.get(INBOUND_META_RUNTIME_CONTROL) == RUNTIME_CONTROL_SESSION_DISCARD:
|
if msg.metadata.get(INBOUND_META_RUNTIME_CONTROL) == RUNTIME_CONTROL_SESSION_DISCARD:
|
||||||
await state.discard_session(msg.session_key)
|
await state.discard_session(msg.session_key)
|
||||||
return True
|
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:
|
class ContextBuilder:
|
||||||
|
|||||||
+33
-24
@@ -95,9 +95,11 @@ from nanobot.utils.runtime import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.agent.tools.mcp import MCPConnection, MCPRuntimeStatus
|
||||||
from nanobot.config.schema import (
|
from nanobot.config.schema import (
|
||||||
ChannelsConfig,
|
ChannelsConfig,
|
||||||
Config,
|
Config,
|
||||||
|
MCPServerConfig,
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
ToolsConfig,
|
ToolsConfig,
|
||||||
)
|
)
|
||||||
@@ -269,7 +271,7 @@ class AgentLoop:
|
|||||||
cron_service: CronService | None = None,
|
cron_service: CronService | None = None,
|
||||||
restrict_to_workspace: bool = False,
|
restrict_to_workspace: bool = False,
|
||||||
session_manager: SessionManager | None = None,
|
session_manager: SessionManager | None = None,
|
||||||
tool_registry: ToolRegistry | None = None,
|
mcp_servers: dict[str, MCPServerConfig] | None = None,
|
||||||
channels_config: ChannelsConfig | None = None,
|
channels_config: ChannelsConfig | None = None,
|
||||||
timezone: str | None = None,
|
timezone: str | None = None,
|
||||||
session_ttl_minutes: int = 0,
|
session_ttl_minutes: int = 0,
|
||||||
@@ -377,7 +379,7 @@ class AgentLoop:
|
|||||||
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
|
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
|
||||||
self.sessions = session_manager or SessionManager(workspace)
|
self.sessions = session_manager or SessionManager(workspace)
|
||||||
self.sessions.set_file_cap_archiver(self.context.memory.raw_archive)
|
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
|
# One file-read/write tracker per logical session. The tool registry is
|
||||||
# shared by this loop, so tools resolve the active state via contextvars.
|
# shared by this loop, so tools resolve the active state via contextvars.
|
||||||
self._file_state_store = FileStateStore()
|
self._file_state_store = FileStateStore()
|
||||||
@@ -397,11 +399,15 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
self._unified_session = unified_session
|
self._unified_session = unified_session
|
||||||
self._running = False
|
self._running = False
|
||||||
|
self._mcp_servers = mcp_servers or {}
|
||||||
|
self._mcp_stacks: dict[str, MCPConnection] = {}
|
||||||
|
self._mcp_runtime_statuses: dict[str, MCPRuntimeStatus] = {}
|
||||||
|
self._mcp_connecting = False
|
||||||
self._runtime_context_providers: list[RuntimeContextProvider] = []
|
self._runtime_context_providers: list[RuntimeContextProvider] = []
|
||||||
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
|
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
|
||||||
self._discarding_sessions: set[str] = set()
|
self._discarding_sessions: set[str] = set()
|
||||||
self._background_tasks: set[asyncio.Task[Any]] = 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] = (
|
self._session_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
|
||||||
weakref.WeakValueDictionary()
|
weakref.WeakValueDictionary()
|
||||||
)
|
)
|
||||||
@@ -458,15 +464,10 @@ class AgentLoop:
|
|||||||
cls,
|
cls,
|
||||||
config: Config,
|
config: Config,
|
||||||
bus: MessageBus | None = None,
|
bus: MessageBus | None = None,
|
||||||
*,
|
|
||||||
tool_registry: ToolRegistry,
|
|
||||||
**extra: Any,
|
**extra: Any,
|
||||||
) -> AgentLoop:
|
) -> AgentLoop:
|
||||||
"""Create an AgentLoop from config with the common parameter set.
|
"""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__``,
|
Extra keyword arguments are forwarded to ``AgentLoop.__init__``,
|
||||||
allowing callers to override or extend the standard config-derived
|
allowing callers to override or extend the standard config-derived
|
||||||
parameters (e.g. ``cron_service``, ``session_manager``).
|
parameters (e.g. ``cron_service``, ``session_manager``).
|
||||||
@@ -476,12 +477,6 @@ class AgentLoop:
|
|||||||
if bus is None:
|
if bus is None:
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
defaults = config.agents.defaults
|
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)
|
provider = extra.pop("provider", None) or make_provider(config)
|
||||||
resolved = config.resolve_preset()
|
resolved = config.resolve_preset()
|
||||||
model = extra.pop("model", None) or resolved.model
|
model = extra.pop("model", None) or resolved.model
|
||||||
@@ -491,6 +486,8 @@ class AgentLoop:
|
|||||||
config,
|
config,
|
||||||
provider_snapshot_loader,
|
provider_snapshot_loader,
|
||||||
)
|
)
|
||||||
|
from nanobot.agent.plugins import agent_plugin_mcp_servers
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
bus=bus,
|
bus=bus,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
@@ -505,6 +502,7 @@ class AgentLoop:
|
|||||||
provider_retry_mode=defaults.provider_retry_mode,
|
provider_retry_mode=defaults.provider_retry_mode,
|
||||||
tool_hint_max_length=defaults.tool_hint_max_length,
|
tool_hint_max_length=defaults.tool_hint_max_length,
|
||||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||||
|
mcp_servers=agent_plugin_mcp_servers(config.workspace_path, config.tools.mcp_servers),
|
||||||
channels_config=config.channels,
|
channels_config=config.channels,
|
||||||
timezone=defaults.timezone,
|
timezone=defaults.timezone,
|
||||||
unified_session=defaults.unified_session,
|
unified_session=defaults.unified_session,
|
||||||
@@ -519,7 +517,6 @@ class AgentLoop:
|
|||||||
restart_mode=config.gateway.restart_mode,
|
restart_mode=config.gateway.restart_mode,
|
||||||
provider_snapshot_loader=provider_snapshot_loader,
|
provider_snapshot_loader=provider_snapshot_loader,
|
||||||
preset_snapshot_loader=preset_snapshot_loader,
|
preset_snapshot_loader=preset_snapshot_loader,
|
||||||
tool_registry=tool_registry,
|
|
||||||
**extra,
|
**extra,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -646,6 +643,14 @@ class AgentLoop:
|
|||||||
|
|
||||||
logger.info("Registered {} tools: {}", len(registered), registered)
|
logger.info("Registered {} tools: {}", len(registered), registered)
|
||||||
|
|
||||||
|
async def _connect_mcp(self) -> None:
|
||||||
|
"""Connect configured MCP servers."""
|
||||||
|
await agent_context.connect_mcp(self, self.tools)
|
||||||
|
|
||||||
|
def mcp_runtime_status(self) -> dict[str, MCPRuntimeStatus]:
|
||||||
|
"""Return connection state learned from real MCP runtime attempts."""
|
||||||
|
return agent_context.mcp_runtime_status(self)
|
||||||
|
|
||||||
def register_runtime_context_provider(
|
def register_runtime_context_provider(
|
||||||
self,
|
self,
|
||||||
provider: RuntimeContextProvider,
|
provider: RuntimeContextProvider,
|
||||||
@@ -1157,6 +1162,7 @@ class AgentLoop:
|
|||||||
"""Run the agent loop, dispatching messages as tasks to stay responsive to /stop."""
|
"""Run the agent loop, dispatching messages as tasks to stay responsive to /stop."""
|
||||||
self._running = True
|
self._running = True
|
||||||
try:
|
try:
|
||||||
|
await self._connect_mcp()
|
||||||
logger.info("Agent loop started")
|
logger.info("Agent loop started")
|
||||||
|
|
||||||
while self._running:
|
while self._running:
|
||||||
@@ -1247,7 +1253,8 @@ class AgentLoop:
|
|||||||
active_tasks.add(task)
|
active_tasks.add(task)
|
||||||
task.add_done_callback(active_tasks.discard)
|
task.add_done_callback(active_tasks.discard)
|
||||||
finally:
|
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:
|
async def _dispatch(self, msg: InboundMessage) -> None:
|
||||||
"""Process a message: per-session serial, cross-session concurrent."""
|
"""Process a message: per-session serial, cross-session concurrent."""
|
||||||
@@ -1365,24 +1372,24 @@ class AgentLoop:
|
|||||||
await delivery.idle()
|
await delivery.idle()
|
||||||
await self._publish_next_deferred_automation_turn(session_key)
|
await self._publish_next_deferred_automation_turn(session_key)
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
"""Stop active work, then close resources owned by the agent loop.
|
"""Stop active work, then close exec, subagent, and MCP resources.
|
||||||
|
|
||||||
Resource teardown must still run if cancellation interrupts task draining.
|
Resource teardown must still run if cancellation interrupts task draining.
|
||||||
Gateway shutdown deliberately bounds this coroutine, so keeping the cleanup
|
Gateway shutdown deliberately bounds this coroutine, so keeping the cleanup
|
||||||
phase in ``finally`` prevents a timed-out background task from leaving
|
phase in ``finally`` prevents a timed-out background task from leaving
|
||||||
subprocess transports alive after the event loop closes.
|
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
|
# performs a guaranteed final close. Serialize those owners so they cannot
|
||||||
# tear down the same resources concurrently.
|
# tear down the same subprocess transports concurrently.
|
||||||
close_lock = getattr(self, "_close_lock", None)
|
close_lock = getattr(self, "_close_mcp_lock", None)
|
||||||
if close_lock is 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:
|
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] = []
|
errors: list[BaseException] = []
|
||||||
active_task_groups = getattr(self, "_active_tasks", {})
|
active_task_groups = getattr(self, "_active_tasks", {})
|
||||||
active_tasks = tuple({task for tasks in active_task_groups.values() for task in tasks})
|
active_tasks = tuple({task for tasks in active_task_groups.values() for task in tasks})
|
||||||
@@ -1405,6 +1412,7 @@ class AgentLoop:
|
|||||||
cleanup_steps = (
|
cleanup_steps = (
|
||||||
self.subagents.close,
|
self.subagents.close,
|
||||||
self._exec_session_manager.close_all,
|
self._exec_session_manager.close_all,
|
||||||
|
lambda: agent_context.close_mcp(self),
|
||||||
)
|
)
|
||||||
for cleanup in cleanup_steps:
|
for cleanup in cleanup_steps:
|
||||||
try:
|
try:
|
||||||
@@ -2293,6 +2301,7 @@ class AgentLoop:
|
|||||||
"""Process an external message directly and return the outbound payload."""
|
"""Process an external message directly and return the outbound payload."""
|
||||||
if channel == "system":
|
if channel == "system":
|
||||||
raise ValueError("channel 'system' is reserved for internal messages")
|
raise ValueError("channel 'system' is reserved for internal messages")
|
||||||
|
await self._connect_mcp()
|
||||||
metadata: dict[str, Any] = {}
|
metadata: dict[str, Any] = {}
|
||||||
if not persist_user_message:
|
if not persist_user_message:
|
||||||
metadata[turn_continuation.SKIP_USER_PERSIST_META] = True
|
metadata[turn_continuation.SKIP_USER_PERSIST_META] = True
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
"""Windows Job Object ownership for subprocess trees."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ctypes
|
|
||||||
from ctypes import wintypes
|
|
||||||
|
|
||||||
_CREATE_SUSPENDED = 0x00000004
|
|
||||||
_PROCESS_SET_QUOTA = 0x0100
|
|
||||||
_PROCESS_TERMINATE = 0x0001
|
|
||||||
_TH32CS_SNAPTHREAD = 0x00000004
|
|
||||||
_THREAD_SUSPEND_RESUME = 0x0002
|
|
||||||
_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
|
|
||||||
_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9
|
|
||||||
_INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
|
|
||||||
|
|
||||||
|
|
||||||
class _IoCounters(ctypes.Structure):
|
|
||||||
_fields_ = [
|
|
||||||
("ReadOperationCount", ctypes.c_ulonglong),
|
|
||||||
("WriteOperationCount", ctypes.c_ulonglong),
|
|
||||||
("OtherOperationCount", ctypes.c_ulonglong),
|
|
||||||
("ReadTransferCount", ctypes.c_ulonglong),
|
|
||||||
("WriteTransferCount", ctypes.c_ulonglong),
|
|
||||||
("OtherTransferCount", ctypes.c_ulonglong),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class _BasicLimitInformation(ctypes.Structure):
|
|
||||||
_fields_ = [
|
|
||||||
("PerProcessUserTimeLimit", ctypes.c_longlong),
|
|
||||||
("PerJobUserTimeLimit", ctypes.c_longlong),
|
|
||||||
("LimitFlags", wintypes.DWORD),
|
|
||||||
("MinimumWorkingSetSize", ctypes.c_size_t),
|
|
||||||
("MaximumWorkingSetSize", ctypes.c_size_t),
|
|
||||||
("ActiveProcessLimit", wintypes.DWORD),
|
|
||||||
("Affinity", ctypes.c_size_t),
|
|
||||||
("PriorityClass", wintypes.DWORD),
|
|
||||||
("SchedulingClass", wintypes.DWORD),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class _ExtendedLimitInformation(ctypes.Structure):
|
|
||||||
_fields_ = [
|
|
||||||
("BasicLimitInformation", _BasicLimitInformation),
|
|
||||||
("IoInfo", _IoCounters),
|
|
||||||
("ProcessMemoryLimit", ctypes.c_size_t),
|
|
||||||
("JobMemoryLimit", ctypes.c_size_t),
|
|
||||||
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
||||||
("PeakJobMemoryUsed", ctypes.c_size_t),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class _ThreadEntry32(ctypes.Structure):
|
|
||||||
_fields_ = [
|
|
||||||
("dwSize", wintypes.DWORD),
|
|
||||||
("cntUsage", wintypes.DWORD),
|
|
||||||
("th32ThreadID", wintypes.DWORD),
|
|
||||||
("th32OwnerProcessID", wintypes.DWORD),
|
|
||||||
("tpBasePri", wintypes.LONG),
|
|
||||||
("tpDeltaPri", wintypes.LONG),
|
|
||||||
("dwFlags", wintypes.DWORD),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
||||||
_kernel32.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR]
|
|
||||||
_kernel32.CreateJobObjectW.restype = wintypes.HANDLE
|
|
||||||
_kernel32.SetInformationJobObject.argtypes = [
|
|
||||||
wintypes.HANDLE,
|
|
||||||
ctypes.c_int,
|
|
||||||
ctypes.c_void_p,
|
|
||||||
wintypes.DWORD,
|
|
||||||
]
|
|
||||||
_kernel32.SetInformationJobObject.restype = wintypes.BOOL
|
|
||||||
_kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
||||||
_kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
||||||
_kernel32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
|
|
||||||
_kernel32.AssignProcessToJobObject.restype = wintypes.BOOL
|
|
||||||
_kernel32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT]
|
|
||||||
_kernel32.TerminateProcess.restype = wintypes.BOOL
|
|
||||||
_kernel32.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT]
|
|
||||||
_kernel32.TerminateJobObject.restype = wintypes.BOOL
|
|
||||||
_kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD]
|
|
||||||
_kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE
|
|
||||||
_kernel32.Thread32First.argtypes = [wintypes.HANDLE, ctypes.POINTER(_ThreadEntry32)]
|
|
||||||
_kernel32.Thread32First.restype = wintypes.BOOL
|
|
||||||
_kernel32.Thread32Next.argtypes = [wintypes.HANDLE, ctypes.POINTER(_ThreadEntry32)]
|
|
||||||
_kernel32.Thread32Next.restype = wintypes.BOOL
|
|
||||||
_kernel32.OpenThread.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
||||||
_kernel32.OpenThread.restype = wintypes.HANDLE
|
|
||||||
_kernel32.ResumeThread.argtypes = [wintypes.HANDLE]
|
|
||||||
_kernel32.ResumeThread.restype = wintypes.DWORD
|
|
||||||
_kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
||||||
_kernel32.CloseHandle.restype = wintypes.BOOL
|
|
||||||
|
|
||||||
|
|
||||||
def _win_error(operation: str) -> OSError:
|
|
||||||
code = ctypes.get_last_error()
|
|
||||||
return OSError(code, f"{operation} failed (Windows error {code})")
|
|
||||||
|
|
||||||
|
|
||||||
def _close_handle(handle: int | None) -> None:
|
|
||||||
if handle:
|
|
||||||
_kernel32.CloseHandle(handle)
|
|
||||||
|
|
||||||
|
|
||||||
def _set_kill_on_close(handle: int, enabled: bool) -> None:
|
|
||||||
info = _ExtendedLimitInformation()
|
|
||||||
if enabled:
|
|
||||||
info.BasicLimitInformation.LimitFlags = _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
||||||
if not _kernel32.SetInformationJobObject(
|
|
||||||
handle,
|
|
||||||
_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION,
|
|
||||||
ctypes.byref(info),
|
|
||||||
ctypes.sizeof(info),
|
|
||||||
):
|
|
||||||
raise _win_error("SetInformationJobObject")
|
|
||||||
|
|
||||||
|
|
||||||
def _resume_primary_thread(pid: int) -> None:
|
|
||||||
snapshot = _kernel32.CreateToolhelp32Snapshot(_TH32CS_SNAPTHREAD, 0)
|
|
||||||
if snapshot == _INVALID_HANDLE_VALUE:
|
|
||||||
raise _win_error("CreateToolhelp32Snapshot")
|
|
||||||
try:
|
|
||||||
entry = _ThreadEntry32()
|
|
||||||
entry.dwSize = ctypes.sizeof(entry)
|
|
||||||
found = _kernel32.Thread32First(snapshot, ctypes.byref(entry))
|
|
||||||
while found:
|
|
||||||
if entry.th32OwnerProcessID == pid:
|
|
||||||
thread = _kernel32.OpenThread(
|
|
||||||
_THREAD_SUSPEND_RESUME,
|
|
||||||
False,
|
|
||||||
entry.th32ThreadID,
|
|
||||||
)
|
|
||||||
if not thread:
|
|
||||||
raise _win_error("OpenThread")
|
|
||||||
try:
|
|
||||||
if _kernel32.ResumeThread(thread) == 0xFFFFFFFF:
|
|
||||||
raise _win_error("ResumeThread")
|
|
||||||
return
|
|
||||||
finally:
|
|
||||||
_close_handle(thread)
|
|
||||||
found = _kernel32.Thread32Next(snapshot, ctypes.byref(entry))
|
|
||||||
raise RuntimeError(f"suspended process {pid} has no resumable thread")
|
|
||||||
finally:
|
|
||||||
_close_handle(snapshot)
|
|
||||||
|
|
||||||
|
|
||||||
class WindowsJob:
|
|
||||||
"""Own a process tree even after its root process exits."""
|
|
||||||
|
|
||||||
creation_flags = _CREATE_SUSPENDED
|
|
||||||
|
|
||||||
def __init__(self, handle: int) -> None:
|
|
||||||
self._handle: int | None = handle
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create(cls) -> WindowsJob:
|
|
||||||
handle = _kernel32.CreateJobObjectW(None, None)
|
|
||||||
if not handle:
|
|
||||||
raise _win_error("CreateJobObjectW")
|
|
||||||
try:
|
|
||||||
_set_kill_on_close(handle, True)
|
|
||||||
except Exception:
|
|
||||||
_close_handle(handle)
|
|
||||||
raise
|
|
||||||
return cls(handle)
|
|
||||||
|
|
||||||
def assign_and_resume(self, pid: int) -> None:
|
|
||||||
"""Atomically establish tree ownership before the root can spawn."""
|
|
||||||
if self._handle is None:
|
|
||||||
raise RuntimeError("Windows job is already closed")
|
|
||||||
process = _kernel32.OpenProcess(
|
|
||||||
_PROCESS_SET_QUOTA | _PROCESS_TERMINATE,
|
|
||||||
False,
|
|
||||||
pid,
|
|
||||||
)
|
|
||||||
if not process:
|
|
||||||
error = _win_error("OpenProcess")
|
|
||||||
self.close()
|
|
||||||
raise error
|
|
||||||
|
|
||||||
if not _kernel32.AssignProcessToJobObject(self._handle, process):
|
|
||||||
error = _win_error("AssignProcessToJobObject")
|
|
||||||
_kernel32.TerminateProcess(process, 1)
|
|
||||||
_close_handle(process)
|
|
||||||
self.close()
|
|
||||||
raise error
|
|
||||||
|
|
||||||
try:
|
|
||||||
_resume_primary_thread(pid)
|
|
||||||
except Exception:
|
|
||||||
self.terminate()
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
_close_handle(process)
|
|
||||||
|
|
||||||
def release(self) -> None:
|
|
||||||
"""Release ownership after successful output collection."""
|
|
||||||
if self._handle is None:
|
|
||||||
return
|
|
||||||
_set_kill_on_close(self._handle, False)
|
|
||||||
self.close()
|
|
||||||
|
|
||||||
def terminate(self) -> None:
|
|
||||||
"""Terminate every process in the job and close its handle."""
|
|
||||||
if self._handle is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
_kernel32.TerminateJobObject(self._handle, 1)
|
|
||||||
finally:
|
|
||||||
self.close()
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
handle = self._handle
|
|
||||||
self._handle = None
|
|
||||||
_close_handle(handle)
|
|
||||||
@@ -209,11 +209,7 @@ class _ExecSession:
|
|||||||
timeout=2.0,
|
timeout=2.0,
|
||||||
)
|
)
|
||||||
# Safety-net reap after normal exit.
|
# Safety-net reap after normal exit.
|
||||||
from nanobot.agent.tools.shell import ( # pyright: ignore[reportPrivateUsage]
|
from nanobot.agent.tools.shell import _reap_pid # pyright: ignore[reportPrivateUsage]
|
||||||
ExecTool,
|
|
||||||
_reap_pid, # pyright: ignore[reportPrivateUsage]
|
|
||||||
)
|
|
||||||
ExecTool._release_process_tree(self.process) # pyright: ignore[reportPrivateUsage]
|
|
||||||
_reap_pid(self.process.pid) # pyright: ignore[reportPrivateUsage]
|
_reap_pid(self.process.pid) # pyright: ignore[reportPrivateUsage]
|
||||||
elif yield_time_ms > 0:
|
elif yield_time_ms > 0:
|
||||||
await self._wait_for_buffered_output()
|
await self._wait_for_buffered_output()
|
||||||
|
|||||||
+400
-381
@@ -1,6 +1,4 @@
|
|||||||
"""MCP client and dynamic tool-provider lifecycle."""
|
"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -9,15 +7,23 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import urllib.parse
|
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 contextlib import AsyncExitStack, suppress
|
||||||
from typing import TYPE_CHECKING, Any, Literal, Protocol, cast
|
from typing import TYPE_CHECKING, Any, Literal, Mapping, Protocol, cast
|
||||||
|
from weakref import WeakKeyDictionary
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult
|
from nanobot.agent.tools.base import Tool, ToolResult
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
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 (
|
from nanobot.security.network import (
|
||||||
PinnedDNSAsyncTransport,
|
PinnedDNSAsyncTransport,
|
||||||
env_proxy_applies_to_url,
|
env_proxy_applies_to_url,
|
||||||
@@ -33,7 +39,7 @@ if TYPE_CHECKING:
|
|||||||
from mcp.types import Tool as MCPToolDefinition
|
from mcp.types import Tool as MCPToolDefinition
|
||||||
|
|
||||||
from nanobot.agent.tools.mcp_oauth import MCPOAuthHandlers
|
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.
|
# Transient connection errors that warrant a single retry.
|
||||||
# These typically happen when an MCP server restarts or a network
|
# These typically happen when an MCP server restarts or a network
|
||||||
@@ -54,37 +60,18 @@ _WINDOWS_SHELL_LAUNCHERS: frozenset[str] = frozenset(("npx", "npm", "pnpm", "yar
|
|||||||
# Characters allowed in tool names by model providers (Anthropic, OpenAI, etc.).
|
# Characters allowed in tool names by model providers (Anthropic, OpenAI, etc.).
|
||||||
# Replace anything outside [a-zA-Z0-9_-] with underscore and collapse runs.
|
# Replace anything outside [a-zA-Z0-9_-] with underscore and collapse runs.
|
||||||
_SANITIZE_RE = re.compile(r"_+")
|
_SANITIZE_RE = re.compile(r"_+")
|
||||||
|
_RELOAD_LOCKS: WeakKeyDictionary[Any, asyncio.Lock] = WeakKeyDictionary()
|
||||||
_ReconnectCallback = Callable[[str, str, Tool], Awaitable[Tool | None]]
|
_ReconnectCallback = Callable[[str, str, Tool], Awaitable[Tool | None]]
|
||||||
MCPServerLoader = Callable[[], Mapping[str, "MCPServerConfig"]]
|
|
||||||
MCPRuntimeStatus = Literal["connecting", "connected", "failed"]
|
MCPRuntimeStatus = Literal["connecting", "connected", "failed"]
|
||||||
|
_MCP_RUNTIME_STATUSES: frozenset[MCPRuntimeStatus] = frozenset(
|
||||||
|
("connecting", "connected", "failed")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MCPConnection(Protocol):
|
class MCPConnection(Protocol):
|
||||||
async def aclose(self) -> None: ...
|
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:
|
class _OwnedMCPConnection:
|
||||||
"""Close an MCP transport from the task that originally opened it."""
|
"""Close an MCP transport from the task that originally opened it."""
|
||||||
|
|
||||||
@@ -505,11 +492,11 @@ class _MCPWrapperBase(Tool):
|
|||||||
"""Common reconnect handling for wrappers bound to one MCP server session."""
|
"""Common reconnect handling for wrappers bound to one MCP server session."""
|
||||||
|
|
||||||
_plugin_discoverable = False
|
_plugin_discoverable = False
|
||||||
_session: ClientSession
|
_session: "ClientSession"
|
||||||
_server_name: str
|
_server_name: str
|
||||||
_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._session = session
|
||||||
self._server_name = server_name
|
self._server_name = server_name
|
||||||
self._reconnect: _ReconnectCallback | None = None
|
self._reconnect: _ReconnectCallback | None = None
|
||||||
@@ -599,9 +586,9 @@ class MCPToolWrapper(_MCPWrapperBase):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
session: ClientSession,
|
session: "ClientSession",
|
||||||
server_name: str,
|
server_name: str,
|
||||||
tool_def: MCPToolDefinition,
|
tool_def: "MCPToolDefinition",
|
||||||
tool_timeout: int = 30,
|
tool_timeout: int = 30,
|
||||||
):
|
):
|
||||||
self._set_mcp_connection(session, server_name)
|
self._set_mcp_connection(session, server_name)
|
||||||
@@ -761,9 +748,9 @@ class MCPResourceWrapper(_MCPWrapperBase):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
session: ClientSession,
|
session: "ClientSession",
|
||||||
server_name: str,
|
server_name: str,
|
||||||
resource_def: Resource,
|
resource_def: "Resource",
|
||||||
resource_timeout: int = 30,
|
resource_timeout: int = 30,
|
||||||
):
|
):
|
||||||
self._set_mcp_connection(session, server_name)
|
self._set_mcp_connection(session, server_name)
|
||||||
@@ -865,9 +852,9 @@ class MCPPromptWrapper(_MCPWrapperBase):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
session: ClientSession,
|
session: "ClientSession",
|
||||||
server_name: str,
|
server_name: str,
|
||||||
prompt_def: Prompt,
|
prompt_def: "Prompt",
|
||||||
prompt_timeout: int = 30,
|
prompt_timeout: int = 30,
|
||||||
):
|
):
|
||||||
self._set_mcp_connection(session, server_name)
|
self._set_mcp_connection(session, server_name)
|
||||||
@@ -998,10 +985,10 @@ class MCPPromptWrapper(_MCPWrapperBase):
|
|||||||
|
|
||||||
|
|
||||||
async def connect_mcp_servers(
|
async def connect_mcp_servers(
|
||||||
mcp_servers: dict[str, MCPServerConfig],
|
mcp_servers: "dict[str, MCPServerConfig]",
|
||||||
registry: ToolRegistry,
|
registry: ToolRegistry,
|
||||||
*,
|
*,
|
||||||
oauth_handlers: Mapping[str, MCPOAuthHandlers] | None = None,
|
oauth_handlers: Mapping[str, "MCPOAuthHandlers"] | None = None,
|
||||||
) -> dict[str, MCPConnection]:
|
) -> dict[str, MCPConnection]:
|
||||||
"""Connect to configured MCP servers and register their tools, resources, prompts.
|
"""Connect to configured MCP servers and register their tools, resources, prompts.
|
||||||
|
|
||||||
@@ -1015,7 +1002,7 @@ async def connect_mcp_servers(
|
|||||||
from mcp.client.streamable_http import streamable_http_client
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
|
|
||||||
async def open_single_server(
|
async def open_single_server(
|
||||||
name: str, cfg: MCPServerConfig, server_stack: AsyncExitStack
|
name: str, cfg: "MCPServerConfig", server_stack: AsyncExitStack
|
||||||
) -> bool:
|
) -> bool:
|
||||||
try:
|
try:
|
||||||
transport_type = cfg.type
|
transport_type = cfg.type
|
||||||
@@ -1257,7 +1244,7 @@ async def connect_mcp_servers(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def connect_single_server(
|
async def connect_single_server(
|
||||||
name: str, cfg: MCPServerConfig
|
name: str, cfg: "MCPServerConfig"
|
||||||
) -> tuple[str, MCPConnection | None]:
|
) -> tuple[str, MCPConnection | None]:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
ready: asyncio.Future[bool] = loop.create_future()
|
ready: asyncio.Future[bool] = loop.create_future()
|
||||||
@@ -1295,29 +1282,15 @@ async def connect_mcp_servers(
|
|||||||
return name, connection
|
return name, connection
|
||||||
|
|
||||||
server_stacks: dict[str, MCPConnection] = {}
|
server_stacks: dict[str, MCPConnection] = {}
|
||||||
attempted_names: list[str] = []
|
|
||||||
|
|
||||||
try:
|
for name, cfg in mcp_servers.items():
|
||||||
for name, cfg in mcp_servers.items():
|
|
||||||
attempted_names.append(name)
|
|
||||||
try:
|
|
||||||
result = await connect_single_server(name, cfg)
|
|
||||||
except Exception as e:
|
|
||||||
_log_mcp_connection_failure(name, e)
|
|
||||||
continue
|
|
||||||
if result[1] is not None:
|
|
||||||
server_stacks[result[0]] = result[1]
|
|
||||||
except BaseException:
|
|
||||||
# Callers can bound readiness/reload with a timeout. If cancellation
|
|
||||||
# interrupts a later server, ownership of earlier connections has not
|
|
||||||
# transferred yet, so roll the whole batch back before propagating it.
|
|
||||||
for name in attempted_names:
|
|
||||||
_unregister_server_tools(registry, name)
|
|
||||||
try:
|
try:
|
||||||
await _close_mcp_connections(server_stacks)
|
result = await connect_single_server(name, cfg)
|
||||||
except BaseException as cleanup_exc:
|
except Exception as e:
|
||||||
logger.debug("MCP batch rollback cleanup error (can be ignored): {}", cleanup_exc)
|
_log_mcp_connection_failure(name, e)
|
||||||
raise
|
continue
|
||||||
|
if result[1] is not None:
|
||||||
|
server_stacks[result[0]] = result[1]
|
||||||
|
|
||||||
return server_stacks
|
return server_stacks
|
||||||
|
|
||||||
@@ -1328,357 +1301,369 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
|||||||
return {"mcp_presets": mcp_presets} if isinstance(mcp_presets, list) and mcp_presets else {}
|
return {"mcp_presets": mcp_presets} if isinstance(mcp_presets, list) and mcp_presets else {}
|
||||||
|
|
||||||
|
|
||||||
def _configured_servers(config: Config) -> dict[str, MCPServerConfig]:
|
def _runtime_status_store(
|
||||||
from nanobot.agent.plugins import agent_plugin_mcp_servers
|
state: Any,
|
||||||
|
*,
|
||||||
return agent_plugin_mcp_servers(
|
create: bool = False,
|
||||||
config.workspace_path,
|
) -> dict[str, MCPRuntimeStatus] | None:
|
||||||
config.tools.mcp_servers,
|
raw_statuses: object = getattr(state, "_mcp_runtime_statuses", None)
|
||||||
)
|
if isinstance(raw_statuses, dict):
|
||||||
|
return cast(dict[str, MCPRuntimeStatus], raw_statuses)
|
||||||
|
if not create:
|
||||||
|
return None
|
||||||
|
statuses: dict[str, MCPRuntimeStatus] = {}
|
||||||
|
state._mcp_runtime_statuses = statuses
|
||||||
|
return statuses
|
||||||
|
|
||||||
|
|
||||||
def _load_current_servers() -> dict[str, MCPServerConfig]:
|
def runtime_status(state: Any) -> dict[str, MCPRuntimeStatus]:
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
"""Return the latest connection-attempt result for configured MCP servers."""
|
||||||
|
statuses = _runtime_status_store(state)
|
||||||
return _configured_servers(resolve_config_env_vars(load_config()))
|
raw_configured: object = getattr(state, "_mcp_servers", None)
|
||||||
|
if statuses is None or not isinstance(raw_configured, dict):
|
||||||
|
return {}
|
||||||
|
configured = cast(dict[str, Any], raw_configured)
|
||||||
|
return {
|
||||||
|
name: status
|
||||||
|
for name, status in statuses.items()
|
||||||
|
if name in configured and status in _MCP_RUNTIME_STATUSES
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class MCPProvider:
|
def _set_runtime_status(
|
||||||
"""Own configured MCP connections and their dynamic tool registrations."""
|
state: Any,
|
||||||
|
server_names: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...],
|
||||||
|
status: MCPRuntimeStatus,
|
||||||
|
) -> None:
|
||||||
|
statuses = _runtime_status_store(state, create=True)
|
||||||
|
assert statuses is not None
|
||||||
|
for name in server_names:
|
||||||
|
statuses[name] = status
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
servers: Mapping[str, MCPServerConfig],
|
|
||||||
registry: ToolRegistry,
|
|
||||||
*,
|
|
||||||
server_loader: MCPServerLoader | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._servers = dict(servers)
|
|
||||||
self._registry = registry
|
|
||||||
self._server_loader = server_loader or _load_current_servers
|
|
||||||
self._connections: dict[str, MCPConnection] = {}
|
|
||||||
self._runtime_statuses: dict[str, MCPRuntimeStatus] = {}
|
|
||||||
self._lock = asyncio.Lock()
|
|
||||||
self._closing = False
|
|
||||||
|
|
||||||
@classmethod
|
def _record_connection_result(
|
||||||
def from_config(
|
state: Any,
|
||||||
cls,
|
attempted: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...],
|
||||||
config: Config,
|
connected: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...],
|
||||||
registry: ToolRegistry,
|
) -> None:
|
||||||
*,
|
attempted_names = set(attempted)
|
||||||
server_loader: MCPServerLoader | None = None,
|
connected_names = set(connected)
|
||||||
) -> MCPProvider:
|
_set_runtime_status(state, connected_names, "connected")
|
||||||
return cls(
|
_set_runtime_status(state, attempted_names - connected_names, "failed")
|
||||||
_configured_servers(config),
|
|
||||||
registry,
|
|
||||||
server_loader=server_loader,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def configured_server_names(self) -> set[str]:
|
|
||||||
return set(self._servers)
|
|
||||||
|
|
||||||
@property
|
async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
|
||||||
def connected_server_names(self) -> set[str]:
|
"""Connect configured MCP servers that are not currently live."""
|
||||||
return set(self._connections)
|
async with _reload_lock(state):
|
||||||
|
if getattr(state, "_mcp_closing", False):
|
||||||
def runtime_status(self) -> dict[str, MCPRuntimeStatus]:
|
return
|
||||||
"""Return the latest connection-attempt result for configured servers."""
|
configured_missing = {
|
||||||
return {
|
name: cfg for name, cfg in state._mcp_servers.items() if name not in state._mcp_stacks
|
||||||
name: status
|
|
||||||
for name, status in self._runtime_statuses.items()
|
|
||||||
if name in self._servers
|
|
||||||
}
|
}
|
||||||
|
oauth_servers = {
|
||||||
def _set_runtime_status(
|
name: cfg
|
||||||
self,
|
for name, cfg in configured_missing.items()
|
||||||
server_names: Iterable[str],
|
if getattr(cfg, "auth", None) == "oauth"
|
||||||
status: MCPRuntimeStatus,
|
}
|
||||||
) -> None:
|
authorization_pending: set[str] = set()
|
||||||
for name in server_names:
|
if oauth_servers:
|
||||||
self._runtime_statuses[name] = status
|
|
||||||
|
|
||||||
def _record_connection_result(
|
|
||||||
self,
|
|
||||||
attempted: Iterable[str],
|
|
||||||
connected: Iterable[str],
|
|
||||||
) -> None:
|
|
||||||
attempted_names = set(attempted)
|
|
||||||
connected_names = set(connected)
|
|
||||||
self._set_runtime_status(connected_names, "connected")
|
|
||||||
self._set_runtime_status(attempted_names - connected_names, "failed")
|
|
||||||
|
|
||||||
async def connect(self) -> None:
|
|
||||||
"""Connect configured servers that are not currently live."""
|
|
||||||
async with self._lock:
|
|
||||||
if self._closing:
|
|
||||||
return
|
|
||||||
configured_missing = {
|
|
||||||
name: cfg
|
|
||||||
for name, cfg in self._servers.items()
|
|
||||||
if name not in self._connections
|
|
||||||
}
|
|
||||||
oauth_servers = {
|
|
||||||
name: cfg
|
|
||||||
for name, cfg in configured_missing.items()
|
|
||||||
if cfg.auth == "oauth"
|
|
||||||
}
|
|
||||||
authorization_pending: set[str] = set()
|
|
||||||
if oauth_servers:
|
|
||||||
from nanobot.agent.tools.mcp_oauth import mcp_oauth_has_credentials
|
|
||||||
|
|
||||||
authorization_pending = {
|
|
||||||
name
|
|
||||||
for name, cfg in oauth_servers.items()
|
|
||||||
if not mcp_oauth_has_credentials(name, cfg.url)
|
|
||||||
}
|
|
||||||
for name in authorization_pending:
|
|
||||||
self._runtime_statuses.pop(name, None)
|
|
||||||
missing_servers = {
|
|
||||||
name: cfg
|
|
||||||
for name, cfg in configured_missing.items()
|
|
||||||
if name not in authorization_pending
|
|
||||||
}
|
|
||||||
if not missing_servers:
|
|
||||||
return
|
|
||||||
self._set_runtime_status(missing_servers, "connecting")
|
|
||||||
try:
|
|
||||||
connected = await connect_mcp_servers(missing_servers, self._registry)
|
|
||||||
if self._closing:
|
|
||||||
await _close_mcp_connections(connected)
|
|
||||||
return
|
|
||||||
self._connections.update(connected)
|
|
||||||
self._record_connection_result(missing_servers, connected)
|
|
||||||
self._attach_reconnect_handlers(connected)
|
|
||||||
if connected:
|
|
||||||
logger.info("MCP connected servers: {}", sorted(connected))
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"No MCP servers connected successfully "
|
|
||||||
"(will retry on the next readiness check)"
|
|
||||||
)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
self._set_runtime_status(missing_servers, "failed")
|
|
||||||
if task_is_cancelling():
|
|
||||||
raise
|
|
||||||
logger.warning(
|
|
||||||
"MCP connection cancelled (will retry on the next readiness check)"
|
|
||||||
)
|
|
||||||
except BaseException as exc:
|
|
||||||
self._set_runtime_status(missing_servers, "failed")
|
|
||||||
logger.warning(
|
|
||||||
"Failed to connect MCP servers "
|
|
||||||
"(will retry on the next readiness check): {}",
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def reload(self) -> dict[str, Any]:
|
|
||||||
"""Reconcile live MCP connections with the current configuration."""
|
|
||||||
async with self._lock:
|
|
||||||
if self._closing:
|
|
||||||
return self._closing_result()
|
|
||||||
try:
|
|
||||||
next_servers = dict(self._server_loader())
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("MCP hot reload could not read config: {}", exc)
|
|
||||||
return {
|
|
||||||
"ok": False,
|
|
||||||
"message": "Could not reload MCP config. Restart nanobot to pick up changes.",
|
|
||||||
"requires_restart": True,
|
|
||||||
"error": str(exc),
|
|
||||||
}
|
|
||||||
|
|
||||||
current_servers = dict(self._servers)
|
|
||||||
current_names = set(current_servers)
|
|
||||||
next_names = set(next_servers)
|
|
||||||
from nanobot.agent.tools.mcp_oauth import mcp_oauth_has_credentials
|
from nanobot.agent.tools.mcp_oauth import mcp_oauth_has_credentials
|
||||||
|
|
||||||
authorization_pending = {
|
authorization_pending = {
|
||||||
name
|
name
|
||||||
for name, cfg in next_servers.items()
|
for name, cfg in oauth_servers.items()
|
||||||
if cfg.auth == "oauth" and not mcp_oauth_has_credentials(name, cfg.url)
|
if not mcp_oauth_has_credentials(name, cfg.url)
|
||||||
}
|
}
|
||||||
removed = sorted(current_names - next_names)
|
statuses = _runtime_status_store(state)
|
||||||
added = sorted(next_names - current_names)
|
if statuses is not None:
|
||||||
changed = sorted(
|
for name in authorization_pending:
|
||||||
name
|
statuses.pop(name, None)
|
||||||
for name in current_names & next_names
|
missing_servers = {
|
||||||
if _server_signature(current_servers[name])
|
name: cfg
|
||||||
!= _server_signature(next_servers[name])
|
for name, cfg in configured_missing.items()
|
||||||
)
|
if name not in authorization_pending
|
||||||
|
}
|
||||||
tools_removed = 0
|
if state._mcp_connecting or not missing_servers:
|
||||||
for name in [*removed, *changed]:
|
return
|
||||||
tools_removed += _unregister_server_tools(self._registry, name)
|
state._mcp_connecting = True
|
||||||
await self._close_server(name)
|
_set_runtime_status(state, missing_servers, "connecting")
|
||||||
|
try:
|
||||||
for name in [*removed, *authorization_pending]:
|
connected = await connect_mcp_servers(missing_servers, registry)
|
||||||
self._runtime_statuses.pop(name, None)
|
if getattr(state, "_mcp_closing", False):
|
||||||
|
for connection in connected.values():
|
||||||
self._servers = next_servers
|
await connection.aclose()
|
||||||
retry_missing = sorted(
|
return
|
||||||
name
|
state._mcp_stacks.update(connected)
|
||||||
for name in next_names
|
_record_connection_result(state, missing_servers, connected)
|
||||||
if name not in self._connections
|
_attach_reconnect_handlers(state, registry, connected)
|
||||||
and name not in set(added) | set(changed)
|
if connected:
|
||||||
and name not in authorization_pending
|
logger.info("MCP connected servers: {}", sorted(connected))
|
||||||
)
|
|
||||||
to_connect_names = sorted(
|
|
||||||
(set(added) | set(changed) | set(retry_missing))
|
|
||||||
- authorization_pending
|
|
||||||
)
|
|
||||||
to_connect = {name: next_servers[name] for name in to_connect_names}
|
|
||||||
connected: dict[str, MCPConnection] = {}
|
|
||||||
if to_connect:
|
|
||||||
self._set_runtime_status(to_connect, "connecting")
|
|
||||||
try:
|
|
||||||
connected = await connect_mcp_servers(to_connect, self._registry)
|
|
||||||
except BaseException:
|
|
||||||
self._set_runtime_status(to_connect, "failed")
|
|
||||||
raise
|
|
||||||
if self._closing:
|
|
||||||
await _close_mcp_connections(connected)
|
|
||||||
return self._closing_result()
|
|
||||||
self._connections.update(connected)
|
|
||||||
self._record_connection_result(to_connect, connected)
|
|
||||||
self._attach_reconnect_handlers(connected)
|
|
||||||
|
|
||||||
failed = sorted(set(to_connect) - set(connected))
|
|
||||||
unchanged = not removed and not added and not changed and not retry_missing
|
|
||||||
ok = not failed
|
|
||||||
if failed:
|
|
||||||
message = (
|
|
||||||
"MCP config reloaded, but some servers did not connect: "
|
|
||||||
+ ", ".join(failed)
|
|
||||||
)
|
|
||||||
elif unchanged:
|
|
||||||
message = "MCP config is already live."
|
|
||||||
elif retry_missing and not added and not changed and not removed:
|
|
||||||
message = "MCP connections refreshed without restarting nanobot."
|
|
||||||
else:
|
else:
|
||||||
message = "MCP config reloaded without restarting nanobot."
|
logger.warning("No MCP servers connected successfully (will retry next message)")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if task_is_cancelling():
|
||||||
|
raise
|
||||||
|
_set_runtime_status(state, missing_servers, "failed")
|
||||||
|
logger.warning("MCP connection cancelled (will retry next message)")
|
||||||
|
except BaseException as e:
|
||||||
|
_set_runtime_status(state, missing_servers, "failed")
|
||||||
|
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
|
||||||
|
finally:
|
||||||
|
state._mcp_connecting = False
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"MCP hot reload: added={} changed={} removed={} retried={} "
|
async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
||||||
"connected={} failed={} tools_removed={}",
|
"""Reconcile live MCP connections with the current config file."""
|
||||||
added,
|
async with _reload_lock(state):
|
||||||
changed,
|
if getattr(state, "_mcp_closing", False):
|
||||||
removed,
|
|
||||||
retry_missing,
|
|
||||||
sorted(connected),
|
|
||||||
failed,
|
|
||||||
tools_removed,
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"ok": ok,
|
"ok": False,
|
||||||
"message": message,
|
"message": "MCP connections are shutting down.",
|
||||||
"added": added,
|
"requires_restart": True,
|
||||||
"changed": changed,
|
}
|
||||||
"removed": removed,
|
try:
|
||||||
"retried": retry_missing,
|
from nanobot.agent.plugins import agent_plugin_mcp_servers
|
||||||
"connected": sorted(self._connections),
|
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||||
"configured": sorted(self._servers),
|
|
||||||
"failed": failed,
|
config = resolve_config_env_vars(load_config())
|
||||||
"tools_removed": tools_removed,
|
next_servers = agent_plugin_mcp_servers(
|
||||||
"requires_restart": False,
|
config.workspace_path,
|
||||||
|
config.tools.mcp_servers,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("MCP hot reload could not read config: {}", exc)
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"message": "Could not reload MCP config. Restart nanobot to pick up changes.",
|
||||||
|
"requires_restart": True,
|
||||||
|
"error": str(exc),
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
current_servers = dict(state._mcp_servers)
|
||||||
def _closing_result() -> dict[str, Any]:
|
current_names = set(current_servers)
|
||||||
|
next_names = set(next_servers)
|
||||||
|
from nanobot.agent.tools.mcp_oauth import mcp_oauth_has_credentials
|
||||||
|
|
||||||
|
authorization_pending = {
|
||||||
|
name
|
||||||
|
for name, cfg in next_servers.items()
|
||||||
|
if cfg.auth == "oauth" and not mcp_oauth_has_credentials(name, cfg.url)
|
||||||
|
}
|
||||||
|
removed = sorted(current_names - next_names)
|
||||||
|
added = sorted(next_names - current_names)
|
||||||
|
changed = sorted(
|
||||||
|
name
|
||||||
|
for name in current_names & next_names
|
||||||
|
if _server_signature(current_servers[name]) != _server_signature(next_servers[name])
|
||||||
|
)
|
||||||
|
|
||||||
|
tools_removed = 0
|
||||||
|
for name in [*removed, *changed]:
|
||||||
|
tools_removed += _unregister_server_tools(registry, name)
|
||||||
|
await _close_server(state, name)
|
||||||
|
|
||||||
|
runtime_statuses = _runtime_status_store(state)
|
||||||
|
if runtime_statuses is not None:
|
||||||
|
for name in [*removed, *authorization_pending]:
|
||||||
|
runtime_statuses.pop(name, None)
|
||||||
|
|
||||||
|
state._mcp_servers = next_servers
|
||||||
|
retry_missing = sorted(
|
||||||
|
name
|
||||||
|
for name in next_names
|
||||||
|
if name not in state._mcp_stacks
|
||||||
|
and name not in set(added) | set(changed)
|
||||||
|
and name not in authorization_pending
|
||||||
|
)
|
||||||
|
to_connect_names = sorted(
|
||||||
|
(set(added) | set(changed) | set(retry_missing)) - authorization_pending
|
||||||
|
)
|
||||||
|
to_connect = {name: next_servers[name] for name in to_connect_names}
|
||||||
|
connected: dict[str, MCPConnection] = {}
|
||||||
|
if to_connect:
|
||||||
|
_set_runtime_status(state, to_connect, "connecting")
|
||||||
|
connected = await connect_mcp_servers(to_connect, registry)
|
||||||
|
if getattr(state, "_mcp_closing", False):
|
||||||
|
for connection in connected.values():
|
||||||
|
await connection.aclose()
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"message": "MCP connections are shutting down.",
|
||||||
|
"requires_restart": True,
|
||||||
|
}
|
||||||
|
state._mcp_stacks.update(connected)
|
||||||
|
_record_connection_result(state, to_connect, connected)
|
||||||
|
_attach_reconnect_handlers(state, registry, connected)
|
||||||
|
|
||||||
|
failed = sorted(set(to_connect) - set(connected))
|
||||||
|
unchanged = not removed and not added and not changed and not retry_missing
|
||||||
|
ok = not failed
|
||||||
|
if failed:
|
||||||
|
message = "MCP config reloaded, but some servers did not connect: " + ", ".join(failed)
|
||||||
|
elif unchanged:
|
||||||
|
message = "MCP config is already live."
|
||||||
|
elif retry_missing and not added and not changed and not removed:
|
||||||
|
message = "MCP connections refreshed without restarting nanobot."
|
||||||
|
else:
|
||||||
|
message = "MCP config reloaded without restarting nanobot."
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"MCP hot reload: added={} changed={} removed={} retried={} connected={} failed={} tools_removed={}",
|
||||||
|
added,
|
||||||
|
changed,
|
||||||
|
removed,
|
||||||
|
retry_missing,
|
||||||
|
sorted(connected),
|
||||||
|
failed,
|
||||||
|
tools_removed,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": ok,
|
||||||
"message": "MCP connections are shutting down.",
|
"message": message,
|
||||||
"requires_restart": True,
|
"added": added,
|
||||||
|
"changed": changed,
|
||||||
|
"removed": removed,
|
||||||
|
"retried": retry_missing,
|
||||||
|
"connected": sorted(state._mcp_stacks),
|
||||||
|
"configured": sorted(state._mcp_servers),
|
||||||
|
"failed": failed,
|
||||||
|
"tools_removed": tools_removed,
|
||||||
|
"requires_restart": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _attach_reconnect_handlers(self, server_names: Iterable[str]) -> None:
|
|
||||||
async def reconnect(
|
|
||||||
server_name: str,
|
|
||||||
tool_name: str,
|
|
||||||
stale_tool: Tool,
|
|
||||||
) -> Tool | None:
|
|
||||||
return await self._refresh_terminated_server(
|
|
||||||
server_name,
|
|
||||||
tool_name,
|
|
||||||
stale_tool,
|
|
||||||
)
|
|
||||||
|
|
||||||
for server_name in server_names:
|
async def request_mcp_reload(
|
||||||
for tool_name in list(self._registry.tool_names):
|
bus: MessageBus,
|
||||||
tool = self._registry.get(tool_name)
|
*,
|
||||||
if not _tool_belongs_to_server(tool, tool_name, server_name):
|
timeout: float = 15.0,
|
||||||
continue
|
) -> dict[str, Any]:
|
||||||
if isinstance(tool, _MCPWrapperBase):
|
"""Ask the running agent loop to reconcile live MCP connections."""
|
||||||
tool.set_reconnect_handler(reconnect)
|
loop = asyncio.get_running_loop()
|
||||||
|
ack: asyncio.Future[dict[str, Any]] = loop.create_future()
|
||||||
|
await bus.publish_inbound(
|
||||||
|
InboundMessage(
|
||||||
|
channel="system",
|
||||||
|
sender_id="webui-settings",
|
||||||
|
chat_id="runtime",
|
||||||
|
content=RUNTIME_CONTROL_MCP_RELOAD,
|
||||||
|
metadata={
|
||||||
|
INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_MCP_RELOAD,
|
||||||
|
RUNTIME_CONTROL_ACK: ack,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(ack, timeout=timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"message": "MCP hot reload timed out. Restart nanobot to pick up changes.",
|
||||||
|
"requires_restart": True,
|
||||||
|
}
|
||||||
|
return result if isinstance(cast(object, result), dict) else {
|
||||||
|
"ok": False,
|
||||||
|
"message": "MCP hot reload returned an unexpected response.",
|
||||||
|
"requires_restart": True,
|
||||||
|
}
|
||||||
|
|
||||||
async def _refresh_terminated_server(
|
|
||||||
self,
|
|
||||||
server_name: str,
|
|
||||||
tool_name: str,
|
|
||||||
stale_tool: Tool,
|
|
||||||
) -> Tool | None:
|
|
||||||
async with self._lock:
|
|
||||||
if self._closing:
|
|
||||||
return None
|
|
||||||
cfg = self._servers.get(server_name)
|
|
||||||
if cfg is None:
|
|
||||||
logger.warning(
|
|
||||||
"MCP server '{}' session terminated but is no longer configured",
|
|
||||||
server_name,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
current_tool = self._registry.get(tool_name)
|
async def handle_runtime_control(state: Any, msg: InboundMessage, registry: ToolRegistry) -> bool:
|
||||||
if (
|
metadata = msg.metadata if isinstance(cast(object, msg.metadata), dict) else {}
|
||||||
current_tool is not None
|
control = metadata.get(INBOUND_META_RUNTIME_CONTROL)
|
||||||
and current_tool is not stale_tool
|
if control != RUNTIME_CONTROL_MCP_RELOAD:
|
||||||
and server_name in self._connections
|
return False
|
||||||
):
|
|
||||||
return current_tool
|
|
||||||
|
|
||||||
|
ack = metadata.get(RUNTIME_CONTROL_ACK)
|
||||||
|
try:
|
||||||
|
result = await reload_servers(state, registry)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("MCP hot reload failed")
|
||||||
|
result = {
|
||||||
|
"ok": False,
|
||||||
|
"message": "MCP hot reload failed. Restart nanobot to pick up changes.",
|
||||||
|
"requires_restart": True,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
if isinstance(ack, asyncio.Future) and not ack.done():
|
||||||
|
cast(asyncio.Future[dict[str, Any]], ack).set_result(result)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _reload_lock(state: Any) -> asyncio.Lock:
|
||||||
|
try:
|
||||||
|
return _RELOAD_LOCKS[state]
|
||||||
|
except KeyError:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
_RELOAD_LOCKS[state] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_reconnect_handlers(
|
||||||
|
state: Any,
|
||||||
|
registry: ToolRegistry,
|
||||||
|
server_names: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...],
|
||||||
|
) -> None:
|
||||||
|
async def reconnect(server_name: str, tool_name: str, stale_tool: Tool) -> Tool | None:
|
||||||
|
return await _refresh_terminated_server(
|
||||||
|
state,
|
||||||
|
registry,
|
||||||
|
server_name,
|
||||||
|
tool_name,
|
||||||
|
stale_tool,
|
||||||
|
)
|
||||||
|
|
||||||
|
for server_name in server_names:
|
||||||
|
for tool_name in list(registry.tool_names):
|
||||||
|
tool = registry.get(tool_name)
|
||||||
|
if not _tool_belongs_to_server(tool, tool_name, server_name):
|
||||||
|
continue
|
||||||
|
if isinstance(tool, _MCPWrapperBase):
|
||||||
|
tool.set_reconnect_handler(reconnect)
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh_terminated_server(
|
||||||
|
state: Any,
|
||||||
|
registry: ToolRegistry,
|
||||||
|
server_name: str,
|
||||||
|
tool_name: str,
|
||||||
|
stale_tool: Tool,
|
||||||
|
) -> Tool | None:
|
||||||
|
async with _reload_lock(state):
|
||||||
|
if getattr(state, "_mcp_closing", False):
|
||||||
|
return None
|
||||||
|
cfg = state._mcp_servers.get(server_name)
|
||||||
|
if cfg is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"MCP server '{}' session terminated; refreshing connection",
|
"MCP server '{}' session terminated but is no longer configured",
|
||||||
server_name,
|
server_name,
|
||||||
)
|
)
|
||||||
_unregister_server_tools(self._registry, server_name)
|
return None
|
||||||
await self._close_server(server_name)
|
|
||||||
|
|
||||||
self._set_runtime_status({server_name}, "connecting")
|
current_tool = registry.get(tool_name)
|
||||||
connected = await connect_mcp_servers(
|
if (
|
||||||
{server_name: cfg},
|
current_tool is not None
|
||||||
self._registry,
|
and current_tool is not stale_tool
|
||||||
)
|
and server_name in state._mcp_stacks
|
||||||
if self._closing:
|
):
|
||||||
await _close_mcp_connections(connected)
|
return current_tool
|
||||||
return None
|
|
||||||
self._connections.update(connected)
|
|
||||||
self._record_connection_result({server_name}, connected)
|
|
||||||
self._attach_reconnect_handlers(connected)
|
|
||||||
if server_name not in connected:
|
|
||||||
logger.warning(
|
|
||||||
"MCP server '{}' reconnect failed after session termination",
|
|
||||||
server_name,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
return self._registry.get(tool_name)
|
|
||||||
|
|
||||||
async def _close_server(self, server_name: str) -> None:
|
logger.warning("MCP server '{}' session terminated; refreshing connection", server_name)
|
||||||
connection = self._connections.pop(server_name, None)
|
_unregister_server_tools(registry, server_name)
|
||||||
if connection is None:
|
await _close_server(state, server_name)
|
||||||
return
|
|
||||||
await _close_mcp_connection(server_name, connection)
|
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
_set_runtime_status(state, {server_name}, "connecting")
|
||||||
"""Close every connection while excluding reconnect and hot reload."""
|
connected = await connect_mcp_servers({server_name: cfg}, registry)
|
||||||
self._closing = True
|
if getattr(state, "_mcp_closing", False):
|
||||||
async with self._lock:
|
for connection in connected.values():
|
||||||
connections = dict(self._connections)
|
await connection.aclose()
|
||||||
self._connections.clear()
|
return None
|
||||||
self._runtime_statuses.clear()
|
state._mcp_stacks.update(connected)
|
||||||
for name in self._servers:
|
_record_connection_result(state, {server_name}, connected)
|
||||||
_unregister_server_tools(self._registry, name)
|
_attach_reconnect_handlers(state, registry, connected)
|
||||||
await _close_mcp_connections(connections)
|
if server_name not in connected:
|
||||||
|
logger.warning("MCP server '{}' reconnect failed after session termination", server_name)
|
||||||
|
return None
|
||||||
|
return registry.get(tool_name)
|
||||||
|
|
||||||
|
|
||||||
def _server_signature(cfg: Any) -> Any:
|
def _server_signature(cfg: Any) -> Any:
|
||||||
@@ -1705,3 +1690,37 @@ def _unregister_server_tools(registry: ToolRegistry, server_name: str) -> int:
|
|||||||
registry.unregister(tool_name)
|
registry.unregister(tool_name)
|
||||||
removed += 1
|
removed += 1
|
||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
async def _close_server(state: Any, server_name: str) -> None:
|
||||||
|
stack = state._mcp_stacks.pop(server_name, None)
|
||||||
|
if stack is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await stack.aclose()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if task_is_cancelling():
|
||||||
|
raise
|
||||||
|
logger.debug("MCP server '{}' cleanup error (can be ignored)", server_name)
|
||||||
|
except (RuntimeError, BaseExceptionGroup):
|
||||||
|
logger.debug("MCP server '{}' cleanup error (can be ignored)", server_name)
|
||||||
|
|
||||||
|
|
||||||
|
async def close_mcp_servers(state: Any) -> None:
|
||||||
|
"""Close every MCP connection while excluding reconnect and hot reload."""
|
||||||
|
state._mcp_closing = True
|
||||||
|
async with _reload_lock(state):
|
||||||
|
connections = list(state._mcp_stacks.items())
|
||||||
|
state._mcp_stacks.clear()
|
||||||
|
statuses = _runtime_status_store(state)
|
||||||
|
if statuses is not None:
|
||||||
|
statuses.clear()
|
||||||
|
for name, connection in connections:
|
||||||
|
try:
|
||||||
|
await connection.aclose()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if task_is_cancelling():
|
||||||
|
raise
|
||||||
|
logger.debug("MCP server '{}' cleanup error (can be ignored)", name)
|
||||||
|
except (RuntimeError, BaseExceptionGroup):
|
||||||
|
logger.debug("MCP server '{}' cleanup error (can be ignored)", name)
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class MyTool(Tool):
|
|||||||
"runner", "sessions", "consolidator",
|
"runner", "sessions", "consolidator",
|
||||||
"dream", "auto_compact", "context", "commands",
|
"dream", "auto_compact", "context", "commands",
|
||||||
# Sensitive runtime state (credentials, message routing, task tracking)
|
# Sensitive runtime state (credentials, message routing, task tracking)
|
||||||
"_pending_queues",
|
"_mcp_servers", "_mcp_stacks", "_pending_queues",
|
||||||
"_session_locks", "_active_tasks", "_background_tasks",
|
"_session_locks", "_active_tasks", "_background_tasks",
|
||||||
# Security boundaries (inspect + modify both blocked)
|
# Security boundaries (inspect + modify both blocked)
|
||||||
"restrict_to_workspace", "channels_config",
|
"restrict_to_workspace", "channels_config",
|
||||||
|
|||||||
+50
-256
@@ -5,7 +5,6 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shlex
|
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -13,8 +12,7 @@ import sys
|
|||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path, PureWindowsPath
|
from pathlib import Path, PureWindowsPath
|
||||||
from typing import Any, Protocol, cast
|
from typing import Any
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import Field
|
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
|
from nanobot.security.workspace_policy import is_path_within
|
||||||
|
|
||||||
_IS_WINDOWS = sys.platform == "win32"
|
_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:
|
def _reap_pid(pid: int) -> None:
|
||||||
@@ -339,7 +326,6 @@ class ExecTool(Tool):
|
|||||||
prepared.env,
|
prepared.env,
|
||||||
prepared.shell_program,
|
prepared.shell_program,
|
||||||
prepared.login,
|
prepared.login,
|
||||||
process_tree=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -348,10 +334,10 @@ class ExecTool(Tool):
|
|||||||
timeout=prepared.timeout,
|
timeout=prepared.timeout,
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
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")
|
return ToolResult.error(f"Error: Command timed out after {prepared.timeout} seconds")
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
await self._kill_process_tree(process)
|
await self._kill_process(process)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# Safety-net reap: asyncio *should* have reaped the child via
|
# Safety-net reap: asyncio *should* have reaped the child via
|
||||||
@@ -382,14 +368,13 @@ class ExecTool(Tool):
|
|||||||
+ result[-half:]
|
+ result[-half:]
|
||||||
)
|
)
|
||||||
|
|
||||||
self._release_process_tree(process)
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Kill and reap the child if it was spawned but an unexpected
|
# Kill and reap the child if it was spawned but an unexpected
|
||||||
# error prevented communicate() from completing.
|
# error prevented communicate() from completing.
|
||||||
if process is not None:
|
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)}")
|
return ToolResult.error(f"Error executing command: {str(e)}")
|
||||||
|
|
||||||
async def _execute_session(
|
async def _execute_session(
|
||||||
@@ -552,58 +537,38 @@ class ExecTool(Tool):
|
|||||||
) -> asyncio.subprocess.Process:
|
) -> asyncio.subprocess.Process:
|
||||||
"""Launch *command* in a platform-appropriate shell."""
|
"""Launch *command* in a platform-appropriate shell."""
|
||||||
if _IS_WINDOWS:
|
if _IS_WINDOWS:
|
||||||
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
|
# Default to PowerShell so single-line and multi-line commands
|
||||||
# share the same shell semantics. cmd.exe is reachable via the
|
# share the same shell semantics. cmd.exe is reachable via the
|
||||||
# explicit shell="cmd" parameter (see _resolve_shell).
|
# explicit shell="cmd" parameter (see _resolve_shell).
|
||||||
default_program = shutil.which("pwsh") or shutil.which("powershell") or "powershell"
|
default_program = shutil.which("pwsh") or shutil.which("powershell") or "powershell"
|
||||||
program = shell_program or default_program
|
program = shell_program or default_program
|
||||||
program_name = PureWindowsPath(program).name.lower()
|
program_name = PureWindowsPath(program).name.lower()
|
||||||
try:
|
if program_name in ("cmd", "cmd.exe"):
|
||||||
if program_name in ("cmd", "cmd.exe"):
|
cmd_env = {**env, "COMSPEC": program}
|
||||||
cmd_env = {**env, "COMSPEC": program}
|
return await asyncio.create_subprocess_shell(
|
||||||
process = await asyncio.create_subprocess_shell(
|
command,
|
||||||
command,
|
stdin=stdin,
|
||||||
stdin=stdin,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
cwd=cwd,
|
||||||
cwd=cwd,
|
env=cmd_env,
|
||||||
env=cmd_env,
|
)
|
||||||
creationflags=creation_flags,
|
command = ExecTool._normalize_powershell_command(command)
|
||||||
)
|
command = (
|
||||||
else:
|
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)\n"
|
||||||
command = ExecTool._normalize_powershell_command(command)
|
"if ($PSVersionTable.PSVersion.Major -lt 6) { $OutputEncoding = [Console]::OutputEncoding }\n"
|
||||||
command = (
|
"$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'\n"
|
||||||
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)\n"
|
f"{command}\n"
|
||||||
"if ($PSVersionTable.PSVersion.Major -lt 6) { $OutputEncoding = [Console]::OutputEncoding }\n"
|
"if ($LASTEXITCODE -ne $null) { exit $LASTEXITCODE }"
|
||||||
"$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'\n"
|
)
|
||||||
f"{command}\n"
|
return await asyncio.create_subprocess_exec(
|
||||||
"if ($LASTEXITCODE -ne $null) { exit $LASTEXITCODE }"
|
program, "-NoProfile", "-NonInteractive", "-Command", command,
|
||||||
)
|
stdin=stdin,
|
||||||
process = await asyncio.create_subprocess_exec(
|
stdout=asyncio.subprocess.PIPE,
|
||||||
program, "-NoProfile", "-NonInteractive", "-Command", command,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
stdin=stdin,
|
cwd=cwd,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
env=env,
|
||||||
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"
|
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
|
||||||
args: list[str] = [shell_program]
|
args: list[str] = [shell_program]
|
||||||
shell_name = Path(shell_program).name.lower()
|
shell_name = Path(shell_program).name.lower()
|
||||||
@@ -722,23 +687,22 @@ class ExecTool(Tool):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def _kill_process_tree(process: asyncio.subprocess.Process) -> None:
|
async def _kill_process_tree(process: asyncio.subprocess.Process) -> None:
|
||||||
"""Kill a session process and descendants, then reap the root process."""
|
"""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:
|
try:
|
||||||
if owner is not None:
|
if _IS_WINDOWS:
|
||||||
owner.terminate()
|
with suppress(OSError, asyncio.TimeoutError):
|
||||||
elif _IS_WINDOWS:
|
await asyncio.wait_for(
|
||||||
if process.returncode is None:
|
asyncio.to_thread(
|
||||||
with suppress(OSError, asyncio.TimeoutError):
|
subprocess.run,
|
||||||
await asyncio.wait_for(
|
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
||||||
asyncio.to_thread(
|
check=False,
|
||||||
subprocess.run,
|
stdout=subprocess.DEVNULL,
|
||||||
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
stderr=subprocess.DEVNULL,
|
||||||
check=False,
|
),
|
||||||
stdout=subprocess.DEVNULL,
|
timeout=5.0,
|
||||||
stderr=subprocess.DEVNULL,
|
)
|
||||||
),
|
|
||||||
timeout=5.0,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
os.killpg(process.pid, signal.SIGKILL)
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
@@ -751,36 +715,8 @@ class ExecTool(Tool):
|
|||||||
with suppress(asyncio.TimeoutError):
|
with suppress(asyncio.TimeoutError):
|
||||||
await asyncio.wait_for(process.wait(), timeout=5.0)
|
await asyncio.wait_for(process.wait(), timeout=5.0)
|
||||||
finally:
|
finally:
|
||||||
if owner is not None:
|
|
||||||
ExecTool._drop_process_tree_owner(process)
|
|
||||||
_reap_pid(process.pid)
|
_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]:
|
def _build_env(self) -> dict[str, str]:
|
||||||
"""Build a minimal environment for subprocess execution.
|
"""Build a minimal environment for subprocess execution.
|
||||||
|
|
||||||
@@ -890,27 +826,12 @@ class ExecTool(Tool):
|
|||||||
for raw in self._extract_absolute_paths(cmd):
|
for raw in self._extract_absolute_paths(cmd):
|
||||||
try:
|
try:
|
||||||
expanded = os.path.expandvars(raw.strip())
|
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,
|
# Match against the un-resolved path first. On Linux,
|
||||||
# /dev/stderr is a symlink to /proc/self/fd/2 and
|
# /dev/stderr is a symlink to /proc/self/fd/2 and
|
||||||
# ``Path.resolve()`` would mask the device-file intent.
|
# ``Path.resolve()`` would mask the device-file intent.
|
||||||
if self._is_benign_device_path(expanded):
|
if self._is_benign_device_path(expanded):
|
||||||
continue
|
continue
|
||||||
|
p = Path(expanded).expanduser().resolve()
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -993,9 +914,7 @@ class ExecTool(Tool):
|
|||||||
):
|
):
|
||||||
current.append(ch)
|
current.append(ch)
|
||||||
operator_len = 1
|
operator_len = 1
|
||||||
# A newline separates commands just like ";" does, so a payload
|
elif ch in {";", "|"}:
|
||||||
# smuggled onto its own line must be checked on its own too.
|
|
||||||
elif ch in {";", "|", "\n", "\r"}:
|
|
||||||
operator_len = 1
|
operator_len = 1
|
||||||
|
|
||||||
if operator_len:
|
if operator_len:
|
||||||
@@ -1029,134 +948,9 @@ class ExecTool(Tool):
|
|||||||
r"(?<![A-Za-z])(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
|
r"(?<![A-Za-z])(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
|
||||||
command
|
command
|
||||||
)
|
)
|
||||||
try:
|
posix_paths = re.findall(r"(?:^|[\s|>='\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
|
||||||
lexer = shlex.shlex(command, posix=True, punctuation_chars="();<>|&")
|
home_paths = re.findall(r"(?:^|[\s>='\"])(~[/+][^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~/ or ~+
|
||||||
lexer.whitespace_split = True
|
return win_paths + posix_paths + home_paths
|
||||||
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
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_bind_roots(paths: list[str] | None) -> list[Path]:
|
def _normalize_bind_roots(paths: list[str] | None) -> list[Path]:
|
||||||
|
|||||||
+20
-123
@@ -11,7 +11,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
from urllib.parse import parse_qsl, quote, urljoin, urlparse
|
from urllib.parse import quote, urljoin, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
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
|
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(
|
async def _get_with_safe_redirects(
|
||||||
client: httpx.AsyncClient,
|
client: httpx.AsyncClient,
|
||||||
url: str,
|
url: str,
|
||||||
@@ -244,14 +191,13 @@ async def _stream_with_safe_redirects(
|
|||||||
client: httpx.AsyncClient,
|
client: httpx.AsyncClient,
|
||||||
url: str,
|
url: str,
|
||||||
headers: dict[str, str] | None = None,
|
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."""
|
"""Open a streamed response while validating every redirect target first."""
|
||||||
current_url = url
|
current_url = url
|
||||||
chain_carries_credentials = _url_carries_credentials(url)
|
|
||||||
for _ in range(MAX_REDIRECTS + 1):
|
for _ in range(MAX_REDIRECTS + 1):
|
||||||
is_valid, error_msg, _ = _resolve_url_safe(current_url)
|
is_valid, error_msg, _ = _resolve_url_safe(current_url)
|
||||||
if not is_valid:
|
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(
|
stream = client.stream(
|
||||||
"GET",
|
"GET",
|
||||||
@@ -264,39 +210,26 @@ async def _stream_with_safe_redirects(
|
|||||||
except httpx.RequestError as exc:
|
except httpx.RequestError as exc:
|
||||||
unsafe_error = _unsafe_url_request_error(exc)
|
unsafe_error = _unsafe_url_request_error(exc)
|
||||||
if unsafe_error is not None:
|
if unsafe_error is not None:
|
||||||
return (
|
return None, None, f"Redirect blocked: {unsafe_error}"
|
||||||
None,
|
|
||||||
None,
|
|
||||||
f"Redirect blocked: {unsafe_error}",
|
|
||||||
chain_carries_credentials,
|
|
||||||
)
|
|
||||||
raise
|
raise
|
||||||
is_redirect = 300 <= response.status_code < 400
|
is_redirect = 300 <= response.status_code < 400
|
||||||
if not is_redirect:
|
if not is_redirect:
|
||||||
return response, stream, None, chain_carries_credentials
|
return response, stream, None
|
||||||
|
|
||||||
location = response.headers.get("location")
|
location = response.headers.get("location")
|
||||||
if not location:
|
if not location:
|
||||||
return response, stream, None, chain_carries_credentials
|
return response, stream, None
|
||||||
|
|
||||||
next_url = urljoin(str(response.url), location)
|
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)
|
is_valid, error_msg = _validate_url_safe(next_url)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
await stream.__aexit__(None, None, None)
|
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)
|
await stream.__aexit__(None, None, None)
|
||||||
current_url = next_url
|
current_url = next_url
|
||||||
|
|
||||||
return (
|
return None, None, f"Too many redirects: exceeded limit of {MAX_REDIRECTS}"
|
||||||
None,
|
|
||||||
None,
|
|
||||||
f"Too many redirects: exceeded limit of {MAX_REDIRECTS}",
|
|
||||||
chain_carries_credentials,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _format_results(query: str, items: list[dict[str, Any]], n: int) -> str:
|
def _format_results(query: str, items: list[dict[str, Any]], n: int) -> str:
|
||||||
@@ -1110,26 +1043,20 @@ class WebFetchTool(Tool):
|
|||||||
if not is_valid:
|
if not is_valid:
|
||||||
return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False)
|
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.
|
# 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
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
**_fetch_client_kwargs(self.proxy, 15.0),
|
**_fetch_client_kwargs(self.proxy, 15.0),
|
||||||
) as client:
|
) as client:
|
||||||
r, stream, redirect_error, chain_carries_credentials = (
|
r, stream, redirect_error = await _stream_with_safe_redirects(
|
||||||
await _stream_with_safe_redirects(
|
client,
|
||||||
client,
|
url,
|
||||||
url,
|
headers={"User-Agent": self.user_agent},
|
||||||
headers={"User-Agent": self.user_agent},
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if redirect_error:
|
if redirect_error:
|
||||||
return json.dumps({"error": redirect_error, "url": url}, ensure_ascii=False)
|
return json.dumps({"error": redirect_error, "url": url}, ensure_ascii=False)
|
||||||
if r is None:
|
if r is None:
|
||||||
return json.dumps({"error": "Fetch failed", "url": url}, ensure_ascii=False)
|
return json.dumps({"error": "Fetch failed", "url": url}, ensure_ascii=False)
|
||||||
jina_remote_safe = not chain_carries_credentials
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ctype = r.headers.get("content-type", "")
|
ctype = r.headers.get("content-type", "")
|
||||||
@@ -1144,14 +1071,10 @@ class WebFetchTool(Tool):
|
|||||||
unsafe_error = _unsafe_url_request_error(e)
|
unsafe_error = _unsafe_url_request_error(e)
|
||||||
if unsafe_error is not None:
|
if unsafe_error is not None:
|
||||||
return json.dumps({"error": f"URL validation failed: {unsafe_error}", "url": url}, ensure_ascii=False)
|
return json.dumps({"error": f"URL validation failed: {unsafe_error}", "url": url}, ensure_ascii=False)
|
||||||
logger.debug(
|
logger.debug("Pre-fetch image detection failed for {}: {}", url, e)
|
||||||
"Pre-fetch image detection failed for {} ({})",
|
|
||||||
_redact_url_for_log(url),
|
|
||||||
type(e).__name__,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = None
|
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)
|
result = await self._fetch_jina(url, max_chars)
|
||||||
if result is None:
|
if result is None:
|
||||||
result = await self._fetch_readability(url, extract_mode, max_chars)
|
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:
|
async def _fetch_jina(self, url: str, max_chars: int) -> str | None:
|
||||||
"""Try fetching via Jina Reader API. Returns None on failure."""
|
"""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:
|
try:
|
||||||
headers = {"Accept": "application/json", "User-Agent": self.user_agent}
|
headers = {"Accept": "application/json", "User-Agent": self.user_agent}
|
||||||
jina_key = os.environ.get("JINA_API_KEY", "")
|
jina_key = os.environ.get("JINA_API_KEY", "")
|
||||||
if jina_key:
|
if jina_key:
|
||||||
headers["Authorization"] = f"Bearer {jina_key}"
|
headers["Authorization"] = f"Bearer {jina_key}"
|
||||||
async with httpx.AsyncClient(proxy=self.proxy, timeout=20.0) as client:
|
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:
|
if r.status_code == 429:
|
||||||
logger.debug("Jina Reader rate limited, falling back to readability")
|
logger.debug("Jina Reader rate limited, falling back to readability")
|
||||||
return None
|
return None
|
||||||
@@ -1200,11 +1113,7 @@ class WebFetchTool(Tool):
|
|||||||
"untrusted": True, "text": text,
|
"untrusted": True, "text": text,
|
||||||
}, ensure_ascii=False)
|
}, ensure_ascii=False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(
|
logger.debug("Jina Reader failed for {}, falling back to readability: {}", url, e)
|
||||||
"Jina Reader failed for {}, falling back to readability ({})",
|
|
||||||
_redact_url_for_log(url),
|
|
||||||
type(e).__name__,
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _fetch_readability(self, url: str, extract_mode: str, max_chars: int) -> Any:
|
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)
|
text = self._extract_readable_html(r.text, extract_mode)
|
||||||
extractor = "readability"
|
extractor = "readability"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning("Readability failed for {}, using raw HTML fallback: {}", url, e)
|
||||||
"Readability failed for {}, using raw HTML fallback ({})",
|
|
||||||
_redact_url_for_log(url),
|
|
||||||
type(e).__name__,
|
|
||||||
)
|
|
||||||
text, extractor = _normalize(_strip_tags(r.text)), "html"
|
text, extractor = _normalize(_strip_tags(r.text)), "html"
|
||||||
else:
|
else:
|
||||||
text, extractor = r.text, "raw"
|
text, extractor = r.text, "raw"
|
||||||
@@ -1255,18 +1160,10 @@ class WebFetchTool(Tool):
|
|||||||
"untrusted": True, "text": text,
|
"untrusted": True, "text": text,
|
||||||
}, ensure_ascii=False)
|
}, ensure_ascii=False)
|
||||||
except httpx.ProxyError as e:
|
except httpx.ProxyError as e:
|
||||||
logger.warning(
|
logger.exception("WebFetch proxy error for {}", url)
|
||||||
"WebFetch proxy error for {} ({})",
|
|
||||||
_redact_url_for_log(url),
|
|
||||||
type(e).__name__,
|
|
||||||
)
|
|
||||||
return json.dumps({"error": f"Proxy error: {e}", "url": url}, ensure_ascii=False)
|
return json.dumps({"error": f"Proxy error: {e}", "url": url}, ensure_ascii=False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.exception("WebFetch error for {}", url)
|
||||||
"WebFetch error for {} ({})",
|
|
||||||
_redact_url_for_log(url),
|
|
||||||
type(e).__name__,
|
|
||||||
)
|
|
||||||
return json.dumps({"error": str(e), "url": url}, ensure_ascii=False)
|
return json.dumps({"error": str(e), "url": url}, ensure_ascii=False)
|
||||||
|
|
||||||
def _extract_readable_html(self, html_content: str, extract_mode: str) -> str:
|
def _extract_readable_html(self, html_content: str, extract_mode: str) -> str:
|
||||||
|
|||||||
+10
-23
@@ -48,7 +48,6 @@ _AGENT_LOOP_KEY = web.AppKey[Any]("agent_loop")
|
|||||||
_MODEL_NAME_KEY = web.AppKey[str]("model_name")
|
_MODEL_NAME_KEY = web.AppKey[str]("model_name")
|
||||||
_REQUEST_TIMEOUT_KEY = web.AppKey[float]("request_timeout")
|
_REQUEST_TIMEOUT_KEY = web.AppKey[float]("request_timeout")
|
||||||
_SESSION_LOCKS_KEY = web.AppKey[dict[str, asyncio.Lock]]("session_locks")
|
_SESSION_LOCKS_KEY = web.AppKey[dict[str, asyncio.Lock]]("session_locks")
|
||||||
_PREPARE_AGENT_KEY = web.AppKey[Callable[[], Awaitable[None]] | None]("prepare_agent")
|
|
||||||
_MISSING = object()
|
_MISSING = object()
|
||||||
|
|
||||||
|
|
||||||
@@ -67,17 +66,6 @@ def _app_value(
|
|||||||
return app.get(legacy_key, default)
|
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
|
# Response helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -358,9 +346,8 @@ async def handle_chat_completions(request: web.Request) -> web.Response | web.St
|
|||||||
nonlocal stream_failed
|
nonlocal stream_failed
|
||||||
try:
|
try:
|
||||||
async with session_lock:
|
async with session_lock:
|
||||||
async with asyncio.timeout(timeout_s):
|
response = await asyncio.wait_for(
|
||||||
await _prepare_agent(request.app)
|
agent_loop.process_direct(
|
||||||
response = await agent_loop.process_direct(
|
|
||||||
content=text,
|
content=text,
|
||||||
media=media_paths if media_paths else None,
|
media=media_paths if media_paths else None,
|
||||||
session_key=session_key,
|
session_key=session_key,
|
||||||
@@ -368,7 +355,9 @@ async def handle_chat_completions(request: web.Request) -> web.Response | web.St
|
|||||||
chat_id=API_CHAT_ID,
|
chat_id=API_CHAT_ID,
|
||||||
on_stream=_on_stream,
|
on_stream=_on_stream,
|
||||||
on_stream_end=_on_stream_end,
|
on_stream_end=_on_stream_end,
|
||||||
)
|
),
|
||||||
|
timeout=timeout_s,
|
||||||
|
)
|
||||||
if not emitted_content:
|
if not emitted_content:
|
||||||
response_text = _response_text(response)
|
response_text = _response_text(response)
|
||||||
if response_text.strip():
|
if response_text.strip():
|
||||||
@@ -401,15 +390,16 @@ async def handle_chat_completions(request: web.Request) -> web.Response | web.St
|
|||||||
try:
|
try:
|
||||||
async with session_lock:
|
async with session_lock:
|
||||||
try:
|
try:
|
||||||
async with asyncio.timeout(timeout_s):
|
response = await asyncio.wait_for(
|
||||||
await _prepare_agent(request.app)
|
agent_loop.process_direct(
|
||||||
response = await agent_loop.process_direct(
|
|
||||||
content=text,
|
content=text,
|
||||||
media=media_paths if media_paths else None,
|
media=media_paths if media_paths else None,
|
||||||
session_key=session_key,
|
session_key=session_key,
|
||||||
channel="api",
|
channel="api",
|
||||||
chat_id=API_CHAT_ID,
|
chat_id=API_CHAT_ID,
|
||||||
)
|
),
|
||||||
|
timeout=timeout_s,
|
||||||
|
)
|
||||||
response_text = _response_text(response)
|
response_text = _response_text(response)
|
||||||
if not response_text or not response_text.strip():
|
if not response_text or not response_text.strip():
|
||||||
logger.warning("Empty response for session {}, using fallback", session_key)
|
logger.warning("Empty response for session {}, using fallback", session_key)
|
||||||
@@ -462,7 +452,6 @@ def create_app(
|
|||||||
model_name: str = "nanobot",
|
model_name: str = "nanobot",
|
||||||
request_timeout: float = 120.0,
|
request_timeout: float = 120.0,
|
||||||
api_key: str = "",
|
api_key: str = "",
|
||||||
prepare_agent: Callable[[], Awaitable[None]] | None = None,
|
|
||||||
) -> web.Application:
|
) -> web.Application:
|
||||||
"""Create the aiohttp application.
|
"""Create the aiohttp application.
|
||||||
|
|
||||||
@@ -471,14 +460,12 @@ def create_app(
|
|||||||
model_name: Model name reported in responses.
|
model_name: Model name reported in responses.
|
||||||
request_timeout: Per-request timeout in seconds.
|
request_timeout: Per-request timeout in seconds.
|
||||||
api_key: Optional API key for Bearer-token authentication on API routes.
|
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 = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images
|
||||||
app[_AGENT_LOOP_KEY] = agent_loop
|
app[_AGENT_LOOP_KEY] = agent_loop
|
||||||
app[_MODEL_NAME_KEY] = model_name
|
app[_MODEL_NAME_KEY] = model_name
|
||||||
app[_REQUEST_TIMEOUT_KEY] = request_timeout
|
app[_REQUEST_TIMEOUT_KEY] = request_timeout
|
||||||
app[_SESSION_LOCKS_KEY] = {} # per-user locks, keyed by session_key
|
app[_SESSION_LOCKS_KEY] = {} # per-user locks, keyed by session_key
|
||||||
app[_PREPARE_AGENT_KEY] = prepare_agent
|
|
||||||
|
|
||||||
@web.middleware
|
@web.middleware
|
||||||
async def auth_middleware(
|
async def auth_middleware(
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ _BRANDS: dict[str, tuple[str, str]] = {
|
|||||||
"audacity": ("audacity", "#0000CC"),
|
"audacity": ("audacity", "#0000CC"),
|
||||||
"blender": ("blender", "#E87D0D"),
|
"blender": ("blender", "#E87D0D"),
|
||||||
"browser": ("googlechrome", "#4285F4"),
|
"browser": ("googlechrome", "#4285F4"),
|
||||||
"calibre": ("calibre", "#45B29D"),
|
|
||||||
"chromadb": ("chroma", "#FFDE2D"),
|
"chromadb": ("chroma", "#FFDE2D"),
|
||||||
"comfyui": ("comfyui", "#111827"),
|
"comfyui": ("comfyui", "#111827"),
|
||||||
"contentful": ("contentful", "#2478CC"),
|
"contentful": ("contentful", "#2478CC"),
|
||||||
@@ -158,6 +157,7 @@ _BRANDS: dict[str, tuple[str, str]] = {
|
|||||||
_BRAND_DOMAINS: dict[str, tuple[str, str]] = {
|
_BRAND_DOMAINS: dict[str, tuple[str, str]] = {
|
||||||
"3mf": ("3mf.io", "#00A1DE"),
|
"3mf": ("3mf.io", "#00A1DE"),
|
||||||
"anygen": ("anygen.io", "#111827"),
|
"anygen": ("anygen.io", "#111827"),
|
||||||
|
"calibre": ("calibre-ebook.com", "#45B29D"),
|
||||||
"clibrowser": ("github.com/allthingssecurity/clibrowser", "#24292F"),
|
"clibrowser": ("github.com/allthingssecurity/clibrowser", "#24292F"),
|
||||||
"cloudanalyzer": ("github.com/rsasaki0109/CloudAnalyzer", "#2563EB"),
|
"cloudanalyzer": ("github.com/rsasaki0109/CloudAnalyzer", "#2563EB"),
|
||||||
"cloudcompare": ("cloudcompare.org", "#4D83C3"),
|
"cloudcompare": ("cloudcompare.org", "#4D83C3"),
|
||||||
@@ -201,6 +201,13 @@ _BRAND_ALIASES: dict[str, str] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_BRAND_TRAILING_WORDS = ("cli", "workflow", "workflows", "app", "apps", "tool", "tools")
|
_BRAND_TRAILING_WORDS = ("cli", "workflow", "workflows", "app", "apps", "tool", "tools")
|
||||||
|
_GENERIC_HOMEPAGE_HOSTS = frozenset({
|
||||||
|
"bitbucket.org",
|
||||||
|
"github.com",
|
||||||
|
"gitlab.com",
|
||||||
|
"npmjs.com",
|
||||||
|
"pypi.org",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def _now() -> float:
|
def _now() -> float:
|
||||||
@@ -333,11 +340,25 @@ def _brand_candidates(app: dict[str, Any]) -> list[str]:
|
|||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _homepage_domain(app: dict[str, Any]) -> str | None:
|
||||||
|
value = str(app.get("homepage") or "").strip()
|
||||||
|
try:
|
||||||
|
parsed = urlparse(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
host = (parsed.hostname or "").lower().removeprefix("www.")
|
||||||
|
if parsed.scheme not in {"http", "https"} or host in _GENERIC_HOMEPAGE_HOSTS:
|
||||||
|
return None
|
||||||
|
if not host or "." not in host or any(not label for label in host.split(".")):
|
||||||
|
return None
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
def _brand_payload(app: dict[str, Any]) -> tuple[str | None, str | None]:
|
def _brand_payload(app: dict[str, Any]) -> tuple[str | None, str | None]:
|
||||||
declared_logo = str(app.get("logo_url") or "").strip()
|
declared_logo = str(app.get("logo_url") or "").strip()
|
||||||
|
declared_color = str(app.get("brand_color") or "").strip() or None
|
||||||
if declared_logo.startswith(("https://", "/")):
|
if declared_logo.startswith(("https://", "/")):
|
||||||
declared_color = str(app.get("brand_color") or "").strip()
|
return declared_logo, declared_color
|
||||||
return declared_logo, declared_color or None
|
|
||||||
|
|
||||||
brand = None
|
brand = None
|
||||||
domain_brand = None
|
domain_brand = None
|
||||||
@@ -349,13 +370,21 @@ def _brand_payload(app: dict[str, Any]) -> tuple[str | None, str | None]:
|
|||||||
domain_brand = _BRAND_DOMAINS.get(key)
|
domain_brand = _BRAND_DOMAINS.get(key)
|
||||||
if domain_brand:
|
if domain_brand:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
brand_color = declared_color or (brand or domain_brand or (None, None))[1]
|
||||||
|
homepage_domain = _homepage_domain(app)
|
||||||
|
if homepage_domain:
|
||||||
|
return (
|
||||||
|
f"https://www.google.com/s2/favicons?domain={homepage_domain}&sz=64",
|
||||||
|
brand_color,
|
||||||
|
)
|
||||||
if not brand:
|
if not brand:
|
||||||
if not domain_brand:
|
if not domain_brand:
|
||||||
return None, None
|
return None, None
|
||||||
domain, color = domain_brand
|
domain, color = domain_brand
|
||||||
return f"https://www.google.com/s2/favicons?domain={domain}&sz=64", color
|
return f"https://www.google.com/s2/favicons?domain={domain}&sz=64", color
|
||||||
slug, color = brand
|
slug, color = brand
|
||||||
return f"https://cdn.simpleicons.org/{slug}/{color.lstrip('#')}", color
|
return f"https://cdn.simpleicons.org/{slug}/{color.lstrip('#')}", brand_color
|
||||||
|
|
||||||
|
|
||||||
def _read_json(path: Path) -> dict[str, Any] | None:
|
def _read_json(path: Path) -> dict[str, Any] | None:
|
||||||
@@ -1029,7 +1058,6 @@ class CliAppManager:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
errors="replace",
|
errors="replace",
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
env=self._subprocess_env(),
|
|
||||||
)
|
)
|
||||||
logger.info("CLI Apps: command exited with code {}: {}", result.returncode, command)
|
logger.info("CLI Apps: command exited with code {}: {}", result.returncode, command)
|
||||||
output = (result.stderr or result.stdout or "").strip()
|
output = (result.stderr or result.stdout or "").strip()
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Small, fail-safe registry for the Apps page Featured section."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
REGISTRY_URL = "https://nanobot.wiki/registry/v1/discovery.json"
|
||||||
|
CACHE_TTL_S = 60 * 60
|
||||||
|
_MAX_RESPONSE_BYTES = 64 * 1024
|
||||||
|
_APP_ID_RE = re.compile(r"^(?:cli|mcp):[a-z0-9][a-z0-9._-]*$")
|
||||||
|
_FALLBACK = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"updated_at": "2026-08-12T00:00:00Z",
|
||||||
|
"featured": [
|
||||||
|
"mcp:github",
|
||||||
|
"mcp:playwright",
|
||||||
|
"mcp:notion",
|
||||||
|
"mcp:figma",
|
||||||
|
"mcp:context7",
|
||||||
|
"cli:obsidian",
|
||||||
|
"mcp:linear",
|
||||||
|
"cli:browser",
|
||||||
|
"cli:1password-cli",
|
||||||
|
"cli:blender",
|
||||||
|
"cli:libreoffice",
|
||||||
|
"cli:zotero",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
_refresh_tasks: dict[Path, asyncio.Task[None]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_payload(value: Any) -> dict[str, Any] | None:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return None
|
||||||
|
payload = cast(dict[str, object], value)
|
||||||
|
if payload.get("schema_version") != 1:
|
||||||
|
return None
|
||||||
|
updated_at = payload.get("updated_at")
|
||||||
|
raw_featured = payload.get("featured")
|
||||||
|
if not isinstance(updated_at, str) or not updated_at.strip():
|
||||||
|
return None
|
||||||
|
if not isinstance(raw_featured, list):
|
||||||
|
return None
|
||||||
|
featured_values = cast(list[object], raw_featured)
|
||||||
|
if not 1 <= len(featured_values) <= 12:
|
||||||
|
return None
|
||||||
|
featured: list[str] = []
|
||||||
|
for item in featured_values:
|
||||||
|
if not isinstance(item, str) or _APP_ID_RE.fullmatch(item) is None:
|
||||||
|
return None
|
||||||
|
featured.append(item)
|
||||||
|
if len(featured) != len(set(featured)):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"updated_at": updated_at,
|
||||||
|
"featured": featured,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_cache(path: Path) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
return _validated_payload(json.loads(path.read_text(encoding="utf-8")))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_remote() -> dict[str, Any]:
|
||||||
|
request = urllib.request.Request(
|
||||||
|
REGISTRY_URL,
|
||||||
|
headers={"Accept": "application/json", "User-Agent": "nanobot-apps/1"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(request, timeout=3) as response:
|
||||||
|
raw = response.read(_MAX_RESPONSE_BYTES + 1)
|
||||||
|
if len(raw) > _MAX_RESPONSE_BYTES:
|
||||||
|
raise ValueError("Apps discovery response is too large")
|
||||||
|
payload = _validated_payload(json.loads(raw))
|
||||||
|
if payload is None:
|
||||||
|
raise ValueError("Invalid Apps discovery response")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _write_cache(path: Path, payload: dict[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||||
|
try:
|
||||||
|
temporary.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||||
|
os.replace(temporary, path)
|
||||||
|
finally:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh(path: Path) -> None:
|
||||||
|
try:
|
||||||
|
payload = await asyncio.to_thread(_fetch_remote)
|
||||||
|
await asyncio.to_thread(_write_cache, path, payload)
|
||||||
|
except Exception:
|
||||||
|
# Discovery is optional: the bundled list remains usable offline.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_refresh(path: Path) -> None:
|
||||||
|
task = _refresh_tasks.get(path)
|
||||||
|
if task is not None and not task.done():
|
||||||
|
return
|
||||||
|
task = asyncio.create_task(_refresh(path))
|
||||||
|
_refresh_tasks[path] = task
|
||||||
|
task.add_done_callback(lambda completed: _refresh_tasks.pop(path, None))
|
||||||
|
|
||||||
|
|
||||||
|
async def discovery_payload(*, data_dir: Path) -> dict[str, Any]:
|
||||||
|
"""Return cached Featured IDs immediately and refresh stale data in the background."""
|
||||||
|
cache_path = data_dir / "apps-discovery.json"
|
||||||
|
cached = _read_cache(cache_path)
|
||||||
|
try:
|
||||||
|
fresh = cached is not None and time.time() - cache_path.stat().st_mtime < CACHE_TTL_S
|
||||||
|
except OSError:
|
||||||
|
fresh = False
|
||||||
|
if fresh and cached is not None:
|
||||||
|
return cached
|
||||||
|
_schedule_refresh(cache_path)
|
||||||
|
return {**(cached or _FALLBACK), "refresh_pending": True}
|
||||||
@@ -16,6 +16,7 @@ OUTBOUND_META_AGENT_UI = "_agent_ui"
|
|||||||
# loop to update runtime state without going through a user session.
|
# loop to update runtime state without going through a user session.
|
||||||
INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
|
INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
|
||||||
RUNTIME_CONTROL_ACK = "_ack"
|
RUNTIME_CONTROL_ACK = "_ack"
|
||||||
|
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
|
||||||
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
|
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
|
||||||
RUNTIME_CONTROL_SESSION_DISCARD = "session_discard"
|
RUNTIME_CONTROL_SESSION_DISCARD = "session_discard"
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { lazy } from "react";
|
|
||||||
|
|
||||||
import type { ChannelUiContribution } from "@/channel-plugins/types";
|
import type { ChannelUiContribution } from "@/channel-plugins/types";
|
||||||
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
|
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
|
||||||
|
|
||||||
const FeishuAssistantsPanel = lazy(() =>
|
import { FeishuAssistantsPanel } from "./FeishuAssistantsPanel";
|
||||||
import("./FeishuAssistantsPanel").then(({ FeishuAssistantsPanel: component }) => ({
|
|
||||||
default: component,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
Panel: FeishuAssistantsPanel,
|
Panel: FeishuAssistantsPanel,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import inspect
|
import inspect
|
||||||
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
from collections.abc import Callable, Iterable, Mapping
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
@@ -101,7 +101,6 @@ class ChannelManager:
|
|||||||
webui_runtime_surface: str = "browser",
|
webui_runtime_surface: str = "browser",
|
||||||
webui_runtime_capabilities: dict[str, Any] | None = None,
|
webui_runtime_capabilities: dict[str, Any] | None = None,
|
||||||
webui_mcp_runtime_status: Callable[[], Mapping[str, str]] | 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,
|
webui_skill_state_action: Callable[[set[str]], None] | None = None,
|
||||||
config_path: Path | None = None,
|
config_path: Path | None = None,
|
||||||
):
|
):
|
||||||
@@ -122,7 +121,6 @@ class ChannelManager:
|
|||||||
self._webui_runtime_surface = webui_runtime_surface
|
self._webui_runtime_surface = webui_runtime_surface
|
||||||
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
|
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
|
||||||
self._webui_mcp_runtime_status = webui_mcp_runtime_status
|
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._webui_skill_state_action = webui_skill_state_action
|
||||||
self.channels: dict[str, BaseChannel] = {}
|
self.channels: dict[str, BaseChannel] = {}
|
||||||
self._channel_owners: dict[str, str] = {}
|
self._channel_owners: dict[str, str] = {}
|
||||||
@@ -192,7 +190,6 @@ class ChannelManager:
|
|||||||
channel_feature_action=self.apply_channel_feature_action,
|
channel_feature_action=self.apply_channel_feature_action,
|
||||||
channel_runtime_status=self.get_status,
|
channel_runtime_status=self.get_status,
|
||||||
mcp_runtime_status=self._webui_mcp_runtime_status,
|
mcp_runtime_status=self._webui_mcp_runtime_status,
|
||||||
mcp_reload=self._webui_mcp_reload,
|
|
||||||
skill_state_action=self._webui_skill_state_action,
|
skill_state_action=self._webui_skill_state_action,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -530,10 +530,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning("failed to send {} event: {}", event, 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
|
@classmethod
|
||||||
def default_config(cls) -> dict[str, Any]:
|
def default_config(cls) -> dict[str, Any]:
|
||||||
return WebSocketConfig().model_dump(by_alias=True)
|
return WebSocketConfig().model_dump(by_alias=True)
|
||||||
@@ -852,7 +848,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
saved_state = await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
write_webui_sidebar_state,
|
write_webui_sidebar_state,
|
||||||
cast(dict[str, Any], state),
|
cast(dict[str, Any], state),
|
||||||
)
|
)
|
||||||
@@ -862,11 +858,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
"error",
|
"error",
|
||||||
detail="invalid_sidebar_state",
|
detail="invalid_sidebar_state",
|
||||||
)
|
)
|
||||||
return
|
|
||||||
await self._broadcast_webui_event(
|
|
||||||
"sidebar_state_updated",
|
|
||||||
state=saved_state,
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
if t == "set_workspace_scope":
|
if t == "set_workspace_scope":
|
||||||
cid = envelope.get("chat_id")
|
cid = envelope.get("chat_id")
|
||||||
@@ -1216,11 +1207,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
message="WebUI mutation returned an invalid response",
|
message="WebUI mutation returned an invalid response",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if action == "sidebar.update" and isinstance(result, dict):
|
|
||||||
await self._broadcast_webui_event(
|
|
||||||
"sidebar_state_updated",
|
|
||||||
state=result,
|
|
||||||
)
|
|
||||||
await self._send_webui_response(
|
await self._send_webui_response(
|
||||||
connection,
|
connection,
|
||||||
request_id,
|
request_id,
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -717,7 +717,7 @@ async def test_token_issue_route_requires_secret_when_static_token_configured(bu
|
|||||||
bus,
|
bus,
|
||||||
port=port,
|
port=port,
|
||||||
token="static-token",
|
token="static-token",
|
||||||
tokenIssuePath="/custom-token",
|
tokenIssuePath="/auth/token",
|
||||||
websocketRequiresToken=True,
|
websocketRequiresToken=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -725,16 +725,15 @@ async def test_token_issue_route_requires_secret_when_static_token_configured(bu
|
|||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
try:
|
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
|
assert denied.status_code == 401
|
||||||
|
|
||||||
allowed = await _http_get(
|
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"},
|
headers={"Authorization": "Bearer static-token"},
|
||||||
)
|
)
|
||||||
assert allowed.status_code == 200
|
assert allowed.status_code == 200
|
||||||
assert allowed.json()["token"].startswith("nbwt_")
|
assert allowed.json()["token"].startswith("nbwt_")
|
||||||
assert allowed.headers["Cache-Control"] == "no-store"
|
|
||||||
finally:
|
finally:
|
||||||
await channel.stop()
|
await channel.stop()
|
||||||
await server_task
|
await server_task
|
||||||
@@ -1038,63 +1037,6 @@ async def test_webui_persists_sidebar_state_larger_than_http_request_line(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_webui_sidebar_state_update_broadcasts_workbench_to_other_devices(
|
|
||||||
bus: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
|
||||||
channel = _ch(bus)
|
|
||||||
source = AsyncMock()
|
|
||||||
source.request = SimpleNamespace(headers=Headers())
|
|
||||||
other_device = AsyncMock()
|
|
||||||
channel._webui_connections.update({source, other_device})
|
|
||||||
request_id = "sidebar-workbench-state"
|
|
||||||
|
|
||||||
await channel._dispatch_envelope(
|
|
||||||
source,
|
|
||||||
"webui-client",
|
|
||||||
{
|
|
||||||
"type": "webui_request",
|
|
||||||
"request_id": request_id,
|
|
||||||
"action": "sidebar.update",
|
|
||||||
"payload": {
|
|
||||||
"state": {
|
|
||||||
"workbench": {
|
|
||||||
"version": 1,
|
|
||||||
"tabs": {
|
|
||||||
"tab:websocket:a": {
|
|
||||||
"explicit": True,
|
|
||||||
"title": "Research",
|
|
||||||
"paneKeys": ["websocket:a", "websocket:b"],
|
|
||||||
"layoutPaneKeys": ["websocket:b", "websocket:a"],
|
|
||||||
"layout": "columns",
|
|
||||||
"splitRatios": [0.35],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
|
|
||||||
|
|
||||||
event = json.loads(other_device.send.await_args.args[0])
|
|
||||||
assert event["event"] == "sidebar_state_updated"
|
|
||||||
assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["paneKeys"] == [
|
|
||||||
"websocket:a",
|
|
||||||
"websocket:b",
|
|
||||||
]
|
|
||||||
assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["layoutPaneKeys"] == [
|
|
||||||
"websocket:b",
|
|
||||||
"websocket:a",
|
|
||||||
]
|
|
||||||
assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["splitRatios"] == [
|
|
||||||
0.35
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_client_cannot_self_assert_webui_quote_context(bus: MagicMock) -> None:
|
async def test_client_cannot_self_assert_webui_quote_context(bus: MagicMock) -> None:
|
||||||
channel = _ch(bus)
|
channel = _ch(bus)
|
||||||
@@ -3804,7 +3746,6 @@ async def test_token_issue_rejects_when_at_capacity(bus: MagicMock) -> None:
|
|||||||
headers={"Authorization": "Bearer s"},
|
headers={"Authorization": "Bearer s"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 429
|
assert resp.status_code == 429
|
||||||
assert resp.headers["Cache-Control"] == "no-store"
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert "error" in data
|
assert "error" in data
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -31,11 +31,6 @@ from .ws_test_client import http_get as _http_get
|
|||||||
_PORT = 29900
|
_PORT = 29900
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _isolate_runtime_data(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
|
||||||
|
|
||||||
|
|
||||||
class _MatrixChannel(BaseChannel):
|
class _MatrixChannel(BaseChannel):
|
||||||
name = "matrix"
|
name = "matrix"
|
||||||
display_name = "Matrix"
|
display_name = "Matrix"
|
||||||
@@ -80,7 +75,6 @@ def _make_handler(
|
|||||||
local_trigger_pending_ids: Any | None = None,
|
local_trigger_pending_ids: Any | None = None,
|
||||||
channel_feature_action: Any | None = None,
|
channel_feature_action: Any | None = None,
|
||||||
channel_runtime_status: Any | None = None,
|
channel_runtime_status: Any | None = None,
|
||||||
mcp_reload: Any | None = None,
|
|
||||||
) -> GatewayServices:
|
) -> GatewayServices:
|
||||||
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
|
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
|
||||||
workspace = workspace_path or Path.cwd()
|
workspace = workspace_path or Path.cwd()
|
||||||
@@ -100,7 +94,6 @@ def _make_handler(
|
|||||||
local_trigger_pending_ids=local_trigger_pending_ids,
|
local_trigger_pending_ids=local_trigger_pending_ids,
|
||||||
channel_feature_action=channel_feature_action,
|
channel_feature_action=channel_feature_action,
|
||||||
channel_runtime_status=channel_runtime_status,
|
channel_runtime_status=channel_runtime_status,
|
||||||
mcp_reload=mcp_reload,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -118,7 +111,6 @@ def _ch(
|
|||||||
local_trigger_pending_ids: Any | None = None,
|
local_trigger_pending_ids: Any | None = None,
|
||||||
channel_feature_action: Any | None = None,
|
channel_feature_action: Any | None = None,
|
||||||
channel_runtime_status: Any | None = None,
|
channel_runtime_status: Any | None = None,
|
||||||
mcp_reload: Any | None = None,
|
|
||||||
**extra: Any,
|
**extra: Any,
|
||||||
) -> WebSocketChannel:
|
) -> WebSocketChannel:
|
||||||
cfg: dict[str, Any] = {
|
cfg: dict[str, Any] = {
|
||||||
@@ -142,7 +134,6 @@ def _ch(
|
|||||||
local_trigger_pending_ids=local_trigger_pending_ids,
|
local_trigger_pending_ids=local_trigger_pending_ids,
|
||||||
channel_feature_action=channel_feature_action,
|
channel_feature_action=channel_feature_action,
|
||||||
channel_runtime_status=channel_runtime_status,
|
channel_runtime_status=channel_runtime_status,
|
||||||
mcp_reload=mcp_reload,
|
|
||||||
)
|
)
|
||||||
return InProcessHttpChannel(cfg, bus, gateway=gateway)
|
return InProcessHttpChannel(cfg, bus, gateway=gateway)
|
||||||
|
|
||||||
@@ -232,7 +223,6 @@ async def test_bootstrap_returns_token_for_localhost(
|
|||||||
try:
|
try:
|
||||||
resp = await _http_get("http://127.0.0.1:29901/webui/bootstrap")
|
resp = await _http_get("http://127.0.0.1:29901/webui/bootstrap")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.headers["Cache-Control"] == "no-store"
|
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["token"].startswith("nbwt_")
|
assert body["token"].startswith("nbwt_")
|
||||||
assert channel.gateway.tokens.issued_token_audiences[body["token"]] == "webui"
|
assert channel.gateway.tokens.issued_token_audiences[body["token"]] == "webui"
|
||||||
@@ -288,53 +278,6 @@ async def test_sessions_list_requires_bearer_token(
|
|||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sessions_list_and_thread_restore_transcript_without_canonical_file(
|
|
||||||
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
|
||||||
sm = SessionManager(tmp_path / "workspace")
|
|
||||||
from nanobot.webui.transcript import append_transcript_object
|
|
||||||
|
|
||||||
key = "websocket:restored-history"
|
|
||||||
append_transcript_object(
|
|
||||||
key,
|
|
||||||
{"event": "user", "chat_id": "restored-history", "text": "original question"},
|
|
||||||
)
|
|
||||||
append_transcript_object(
|
|
||||||
key,
|
|
||||||
{"event": "message", "chat_id": "restored-history", "text": "original answer"},
|
|
||||||
)
|
|
||||||
assert not sm._get_session_path(key).exists()
|
|
||||||
|
|
||||||
port = _free_port()
|
|
||||||
channel = _ch(bus, session_manager=sm, port=port)
|
|
||||||
server_task = asyncio.create_task(channel.start())
|
|
||||||
try:
|
|
||||||
token = channel.gateway.tokens.issue_api_token(300)
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
|
||||||
|
|
||||||
listing = await _http_get(f"http://127.0.0.1:{port}/api/sessions", headers=auth)
|
|
||||||
thread = await _http_get(
|
|
||||||
f"http://127.0.0.1:{port}/api/sessions/"
|
|
||||||
"websocket%3Arestored-history/webui-thread",
|
|
||||||
headers=auth,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert listing.status_code == 200
|
|
||||||
assert [row["key"] for row in listing.json()["sessions"]] == [key]
|
|
||||||
assert listing.json()["sessions"][0]["preview"] == "original question"
|
|
||||||
assert thread.status_code == 200
|
|
||||||
assert [message["content"] for message in thread.json()["messages"]] == [
|
|
||||||
"original question",
|
|
||||||
"original answer",
|
|
||||||
]
|
|
||||||
assert not sm._get_session_path(key).exists()
|
|
||||||
finally:
|
|
||||||
await channel.stop()
|
|
||||||
await server_task
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_legacy_session_messages_route_is_not_exposed(
|
async def test_legacy_session_messages_route_is_not_exposed(
|
||||||
bus: MagicMock, tmp_path: Path
|
bus: MagicMock, tmp_path: Path
|
||||||
@@ -2111,15 +2054,14 @@ async def test_mcp_presets_routes_require_token_and_return_payload(
|
|||||||
_custom_action,
|
_custom_action,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _hot_reload():
|
async def _hot_reload(_bus):
|
||||||
return {"ok": True, "message": "MCP config reloaded.", "requires_restart": False}
|
return {"ok": True, "message": "MCP config reloaded.", "requires_restart": False}
|
||||||
|
|
||||||
channel = _ch(
|
monkeypatch.setattr(
|
||||||
bus,
|
"nanobot.webui.settings_routes.request_mcp_reload",
|
||||||
session_manager=_seed_session(tmp_path),
|
_hot_reload,
|
||||||
port=29913,
|
|
||||||
mcp_reload=_hot_reload,
|
|
||||||
)
|
)
|
||||||
|
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29913)
|
||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
try:
|
try:
|
||||||
deny = await _http_get("http://127.0.0.1:29913/api/settings/mcp-presets")
|
deny = await _http_get("http://127.0.0.1:29913/api/settings/mcp-presets")
|
||||||
@@ -2319,40 +2261,6 @@ async def test_session_delete_removes_file(
|
|||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_session_delete_removes_transcript_without_canonical_file(
|
|
||||||
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
|
||||||
sm = SessionManager(tmp_path / "workspace")
|
|
||||||
from nanobot.webui.transcript import append_transcript_object
|
|
||||||
|
|
||||||
key = "websocket:transcript-only"
|
|
||||||
append_transcript_object(
|
|
||||||
key,
|
|
||||||
{"event": "user", "chat_id": "transcript-only", "text": "recover me"},
|
|
||||||
)
|
|
||||||
assert not sm._get_session_path(key).exists()
|
|
||||||
webui_path = tmp_path / "webui" / f"{SessionManager.safe_key(key)}.jsonl"
|
|
||||||
assert webui_path.is_file()
|
|
||||||
|
|
||||||
channel = _ch(bus, session_manager=sm, port=_free_port())
|
|
||||||
server_task = asyncio.create_task(channel.start())
|
|
||||||
try:
|
|
||||||
response = await _webui_mutate(
|
|
||||||
channel,
|
|
||||||
"session.delete",
|
|
||||||
{"key": key},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["deleted"] is True
|
|
||||||
assert not webui_path.exists()
|
|
||||||
finally:
|
|
||||||
await channel.stop()
|
|
||||||
await server_task
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
|
async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
|
||||||
bus: MagicMock, tmp_path: Path
|
bus: MagicMock, tmp_path: Path
|
||||||
|
|||||||
@@ -486,35 +486,6 @@ class WeixinChannel(BaseChannel):
|
|||||||
if base_url:
|
if base_url:
|
||||||
self.config.base_url = base_url
|
self.config.base_url = base_url
|
||||||
self._save_state(force=True)
|
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)
|
# HTTP helpers (matches api.ts buildHeaders / apiFetch)
|
||||||
|
|||||||
@@ -66,63 +66,6 @@ async def test_weixin_connect_store_saves_confirmed_qr_login(
|
|||||||
assert saved["token"] == "wx-token"
|
assert saved["token"] == "wx-token"
|
||||||
assert saved["base_url"] == "https://weixin.example"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_weixin_reconnect_keeps_existing_account_until_scan_succeeds(
|
async def test_weixin_reconnect_keeps_existing_account_until_scan_succeeds(
|
||||||
|
|||||||
@@ -33,10 +33,28 @@ import {
|
|||||||
WEIXIN_AUTH_EXPIRED_MESSAGE,
|
WEIXIN_AUTH_EXPIRED_MESSAGE,
|
||||||
WeixinConnectFlow,
|
WeixinConnectFlow,
|
||||||
} from "./WeixinConnectFlow";
|
} from "./WeixinConnectFlow";
|
||||||
import {
|
|
||||||
WEIXIN_ADVANCED_FIELD_KEYS,
|
export const WEIXIN_PRIMARY_FIELD_KEYS = [
|
||||||
WEIXIN_PRIMARY_FIELD_KEYS,
|
"channels.weixin.sendProgress",
|
||||||
} from "./presentation";
|
"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({
|
export function WeixinPanel({
|
||||||
token,
|
token,
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
import { lazy } from "react";
|
|
||||||
|
|
||||||
import type { ChannelUiContribution } from "@/channel-plugins/types";
|
import type { ChannelUiContribution } from "@/channel-plugins/types";
|
||||||
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
|
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
|
||||||
|
|
||||||
|
import { WeixinConnectFlow } from "./WeixinConnectFlow";
|
||||||
import {
|
import {
|
||||||
WEIXIN_ADVANCED_FIELD_KEYS,
|
WEIXIN_ADVANCED_FIELD_KEYS,
|
||||||
WEIXIN_PRIMARY_FIELD_KEYS,
|
WEIXIN_PRIMARY_FIELD_KEYS,
|
||||||
} from "./presentation";
|
WeixinPanel,
|
||||||
|
} from "./WeixinPanel";
|
||||||
const WeixinPanel = lazy(() =>
|
|
||||||
import("./WeixinPanel").then(({ WeixinPanel: component }) => ({ default: component })),
|
|
||||||
);
|
|
||||||
const WeixinConnectFlow = lazy(() =>
|
|
||||||
import("./WeixinConnectFlow").then(({ WeixinConnectFlow: component }) => ({
|
|
||||||
default: component,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
Panel: WeixinPanel,
|
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;
|
|
||||||
+23
-38
@@ -13,8 +13,6 @@ from rich.console import Console
|
|||||||
from nanobot import __logo__
|
from nanobot import __logo__
|
||||||
from nanobot.agent.hooks import create_file_edit_activity_hook
|
from nanobot.agent.hooks import create_file_edit_activity_hook
|
||||||
from nanobot.agent.loop import AgentLoop
|
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 (
|
from nanobot.bus.outbound_events import (
|
||||||
StreamDeltaEvent,
|
StreamDeltaEvent,
|
||||||
StreamedResponseEvent,
|
StreamedResponseEvent,
|
||||||
@@ -86,8 +84,6 @@ def agent(
|
|||||||
# Create cron service with workspace-scoped store
|
# Create cron service with workspace-scoped store
|
||||||
cron_store_path = runtime_config.workspace_path / "cron" / "jobs.json"
|
cron_store_path = runtime_config.workspace_path / "cron" / "jobs.json"
|
||||||
cron = CronService(cron_store_path)
|
cron = CronService(cron_store_path)
|
||||||
tools = ToolRegistry()
|
|
||||||
mcp_provider = MCPProvider.from_config(runtime_config, tools)
|
|
||||||
|
|
||||||
_set_nanobot_logs(logs)
|
_set_nanobot_logs(logs)
|
||||||
|
|
||||||
@@ -99,7 +95,6 @@ def agent(
|
|||||||
cron_service=cron,
|
cron_service=cron,
|
||||||
image_generation_provider_configs=image_gen_provider_configs(runtime_config),
|
image_generation_provider_configs=image_gen_provider_configs(runtime_config),
|
||||||
hook_factories=[create_file_edit_activity_hook],
|
hook_factories=[create_file_edit_activity_hook],
|
||||||
tool_registry=tools,
|
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
_print_agent_start_error(exc)
|
_print_agent_start_error(exc)
|
||||||
@@ -111,12 +106,6 @@ def agent(
|
|||||||
render_markdown=False,
|
render_markdown=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _close_runtime() -> None:
|
|
||||||
try:
|
|
||||||
await agent_loop.aclose()
|
|
||||||
finally:
|
|
||||||
await mcp_provider.aclose()
|
|
||||||
|
|
||||||
# Shared reference for progress callbacks
|
# Shared reference for progress callbacks
|
||||||
_thinking: ThinkingSpinner | None = None
|
_thinking: ThinkingSpinner | None = None
|
||||||
|
|
||||||
@@ -160,33 +149,30 @@ def agent(
|
|||||||
if message:
|
if message:
|
||||||
# Single message mode — direct call, no bus needed
|
# Single message mode — direct call, no bus needed
|
||||||
async def run_once() -> None:
|
async def run_once() -> None:
|
||||||
try:
|
renderer = StreamRenderer(
|
||||||
await mcp_provider.connect()
|
render_markdown=markdown,
|
||||||
renderer = StreamRenderer(
|
bot_name=runtime_config.agents.defaults.bot_name,
|
||||||
|
bot_icon=runtime_config.agents.defaults.bot_icon,
|
||||||
|
)
|
||||||
|
response = await agent_loop.process_direct(
|
||||||
|
message,
|
||||||
|
session_id,
|
||||||
|
on_progress=_make_progress(renderer),
|
||||||
|
on_stream=renderer.on_delta,
|
||||||
|
on_stream_end=renderer.on_end,
|
||||||
|
)
|
||||||
|
if not renderer.streamed:
|
||||||
|
await renderer.close()
|
||||||
|
print_kwargs: dict[str, Any] = {}
|
||||||
|
if renderer.header_printed:
|
||||||
|
print_kwargs["show_header"] = False
|
||||||
|
cli_terminal._print_agent_response(
|
||||||
|
response.content if response else "",
|
||||||
render_markdown=markdown,
|
render_markdown=markdown,
|
||||||
bot_name=runtime_config.agents.defaults.bot_name,
|
metadata=response.metadata if response else None,
|
||||||
bot_icon=runtime_config.agents.defaults.bot_icon,
|
**print_kwargs,
|
||||||
)
|
)
|
||||||
response = await agent_loop.process_direct(
|
await agent_loop.close_mcp()
|
||||||
message,
|
|
||||||
session_id,
|
|
||||||
on_progress=_make_progress(renderer),
|
|
||||||
on_stream=renderer.on_delta,
|
|
||||||
on_stream_end=renderer.on_end,
|
|
||||||
)
|
|
||||||
if not renderer.streamed:
|
|
||||||
await renderer.close()
|
|
||||||
print_kwargs: dict[str, Any] = {}
|
|
||||||
if renderer.header_printed:
|
|
||||||
print_kwargs["show_header"] = False
|
|
||||||
cli_terminal._print_agent_response(
|
|
||||||
response.content if response else "",
|
|
||||||
render_markdown=markdown,
|
|
||||||
metadata=response.metadata if response else None,
|
|
||||||
**print_kwargs,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
await _close_runtime()
|
|
||||||
|
|
||||||
asyncio.run(run_once())
|
asyncio.run(run_once())
|
||||||
else:
|
else:
|
||||||
@@ -223,7 +209,6 @@ def agent(
|
|||||||
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
|
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
|
||||||
|
|
||||||
async def run_interactive() -> None:
|
async def run_interactive() -> None:
|
||||||
await mcp_provider.connect()
|
|
||||||
bus_task = asyncio.create_task(agent_loop.run())
|
bus_task = asyncio.create_task(agent_loop.run())
|
||||||
turn_done = asyncio.Event()
|
turn_done = asyncio.Event()
|
||||||
turn_done.set()
|
turn_done.set()
|
||||||
@@ -362,6 +347,6 @@ def agent(
|
|||||||
agent_loop.stop()
|
agent_loop.stop()
|
||||||
outbound_task.cancel()
|
outbound_task.cancel()
|
||||||
await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
|
await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
|
||||||
await _close_runtime()
|
await agent_loop.close_mcp()
|
||||||
|
|
||||||
asyncio.run(run_interactive())
|
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 import optional_features as feature_support # noqa: E402
|
||||||
from nanobot.agent.hooks import create_file_edit_activity_hook # 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.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 import terminal as cli_terminal # noqa: E402
|
||||||
from nanobot.cli.agent import agent # noqa: E402
|
from nanobot.cli.agent import agent # noqa: E402
|
||||||
from nanobot.cli.gateway import create_gateway_app # 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)
|
sync_workspace_templates(runtime_config.workspace_path)
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
session_manager = SessionManager(runtime_config.workspace_path)
|
session_manager = SessionManager(runtime_config.workspace_path)
|
||||||
tools = ToolRegistry()
|
|
||||||
mcp_provider = MCPProvider.from_config(runtime_config, tools)
|
|
||||||
try:
|
try:
|
||||||
agent_loop = AgentLoop.from_config(
|
agent_loop = AgentLoop.from_config(
|
||||||
runtime_config, bus,
|
runtime_config, bus,
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
image_generation_provider_configs=image_gen_provider_configs(runtime_config),
|
image_generation_provider_configs=image_gen_provider_configs(runtime_config),
|
||||||
hook_factories=[create_file_edit_activity_hook],
|
hook_factories=[create_file_edit_activity_hook],
|
||||||
tool_registry=tools,
|
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
console.print(f"[red]Error: {exc}[/red]")
|
console.print(f"[red]Error: {exc}[/red]")
|
||||||
@@ -383,17 +378,13 @@ def serve(
|
|||||||
api_app = create_app(
|
api_app = create_app(
|
||||||
agent_loop, model_name=model_name, request_timeout=timeout,
|
agent_loop, model_name=model_name, request_timeout=timeout,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
prepare_agent=mcp_provider.connect,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def on_startup(_app: Any) -> None:
|
async def on_startup(_app: Any) -> None:
|
||||||
await mcp_provider.connect()
|
await agent_loop._connect_mcp()
|
||||||
|
|
||||||
async def on_cleanup(_app: Any) -> None:
|
async def on_cleanup(_app: Any) -> None:
|
||||||
try:
|
await agent_loop.close_mcp()
|
||||||
await agent_loop.aclose()
|
|
||||||
finally:
|
|
||||||
await mcp_provider.aclose()
|
|
||||||
|
|
||||||
api_app.on_startup.append(on_startup)
|
api_app.on_startup.append(on_startup)
|
||||||
api_app.on_cleanup.append(on_cleanup)
|
api_app.on_cleanup.append(on_cleanup)
|
||||||
@@ -440,44 +431,6 @@ app.add_typer(
|
|||||||
app.command(name="agent")(agent)
|
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
|
# Channel Commands
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ from rich.console import Console
|
|||||||
from nanobot import __logo__, __version__
|
from nanobot import __logo__, __version__
|
||||||
from nanobot.agent.hooks import create_file_edit_activity_hook
|
from nanobot.agent.hooks import create_file_edit_activity_hook
|
||||||
from nanobot.agent.loop import AgentLoop
|
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 import terminal as cli_terminal
|
||||||
from nanobot.cli.runtime_config import _migrate_cron_store
|
from nanobot.cli.runtime_config import _migrate_cron_store
|
||||||
from nanobot.cli.webui_support import (
|
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(
|
async def _close_gateway_runtime(
|
||||||
agent: AgentLoop,
|
agent: AgentLoop,
|
||||||
mcp_provider: MCPProvider,
|
|
||||||
channels: Any,
|
channels: Any,
|
||||||
tasks: list[asyncio.Task[Any]],
|
tasks: list[asyncio.Task[Any]],
|
||||||
runtime_tasks: asyncio.Future[list[Any]] | None,
|
runtime_tasks: asyncio.Future[list[Any]] | None,
|
||||||
@@ -243,13 +240,18 @@ async def _close_gateway_runtime(
|
|||||||
task_wait_timeout: float = 15.0,
|
task_wait_timeout: float = 15.0,
|
||||||
close_timeout: float = 15.0,
|
close_timeout: float = 15.0,
|
||||||
) -> None:
|
) -> 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
|
Order matters: runtime tasks (including the agent loop and any in-flight
|
||||||
turn) are cancelled and awaited -- bounded -- before the loop-owned resources
|
turn) are cancelled and awaited -- bounded -- before exec sessions,
|
||||||
and the application-owned MCP provider are torn down. The final close is
|
subagents, and MCP servers are torn down, so no active turn is using a
|
||||||
bounded and idempotent, so it also covers a cancelled or incomplete loop
|
shared resource when it closes. The final close is bounded and idempotent:
|
||||||
cleanup without leaving subprocess transports alive past ``loop.close()``.
|
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.
|
# Some SDKs swallow task cancellation while attempting to reconnect.
|
||||||
# Close channel transports before waiting for their runners to exit.
|
# Close channel transports before waiting for their runners to exit.
|
||||||
@@ -270,14 +272,10 @@ async def _close_gateway_runtime(
|
|||||||
task.cancel()
|
task.cancel()
|
||||||
if runtime_tasks is not None and not runtime_tasks.done():
|
if runtime_tasks is not None and not runtime_tasks.done():
|
||||||
runtime_tasks.cancel()
|
runtime_tasks.cancel()
|
||||||
for label, close in (
|
try:
|
||||||
("agent", agent.aclose),
|
await asyncio.wait_for(agent.close_mcp(), timeout=close_timeout)
|
||||||
("MCP provider", mcp_provider.aclose),
|
except BaseException as exc: # noqa: BLE001 - shutdown must proceed
|
||||||
):
|
logger.warning("Gateway shutdown: agent resource cleanup incomplete: {}", exc)
|
||||||
try:
|
|
||||||
await asyncio.wait_for(close(), timeout=close_timeout)
|
|
||||||
except BaseException as exc: # noqa: BLE001 - shutdown must proceed
|
|
||||||
logger.warning("Gateway shutdown: {} cleanup incomplete: {}", label, exc)
|
|
||||||
# Retrieving an already-finished gather prevents noisy unhandled exceptions,
|
# Retrieving an already-finished gather prevents noisy unhandled exceptions,
|
||||||
# but never wait for it here: its children were bounded individually above.
|
# but never wait for it here: its children were bounded individually above.
|
||||||
if runtime_tasks is not None and runtime_tasks.done():
|
if runtime_tasks is not None and runtime_tasks.done():
|
||||||
@@ -416,9 +414,6 @@ def _run_gateway(
|
|||||||
route_policy=WebuiTurnRoutePolicy(session_manager),
|
route_policy=WebuiTurnRoutePolicy(session_manager),
|
||||||
)
|
)
|
||||||
|
|
||||||
tools = ToolRegistry()
|
|
||||||
mcp_provider = MCPProvider.from_config(config, tools)
|
|
||||||
|
|
||||||
# Create agent with cron service
|
# Create agent with cron service
|
||||||
agent = AgentLoop.from_config(
|
agent = AgentLoop.from_config(
|
||||||
config, bus,
|
config, bus,
|
||||||
@@ -436,7 +431,6 @@ def _run_gateway(
|
|||||||
hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)],
|
hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)],
|
||||||
local_trigger_store=trigger_store,
|
local_trigger_store=trigger_store,
|
||||||
hook_factories=[create_file_edit_activity_hook],
|
hook_factories=[create_file_edit_activity_hook],
|
||||||
tool_registry=tools,
|
|
||||||
)
|
)
|
||||||
def _schedule_webui_background(awaitable: Awaitable[None]) -> None:
|
def _schedule_webui_background(awaitable: Awaitable[None]) -> None:
|
||||||
agent.schedule_background(cast(Coroutine[Any, Any, None], awaitable))
|
agent.schedule_background(cast(Coroutine[Any, Any, None], awaitable))
|
||||||
@@ -518,7 +512,6 @@ def _run_gateway(
|
|||||||
prompt, last_cursor = result
|
prompt, last_cursor = result
|
||||||
key = dream_session_key()
|
key = dream_session_key()
|
||||||
dream_runtime = agent.dream_runtime()
|
dream_runtime = agent.dream_runtime()
|
||||||
await mcp_provider.connect()
|
|
||||||
resp = await agent.process_direct(
|
resp = await agent.process_direct(
|
||||||
prompt,
|
prompt,
|
||||||
session_key=key,
|
session_key=key,
|
||||||
@@ -596,7 +589,6 @@ def _run_gateway(
|
|||||||
if isinstance(message_tool, MessageTool):
|
if isinstance(message_tool, MessageTool):
|
||||||
suppress_token = message_tool.set_suppress_delivery(True)
|
suppress_token = message_tool.set_suppress_delivery(True)
|
||||||
try:
|
try:
|
||||||
await mcp_provider.connect()
|
|
||||||
resp = await agent.process_direct(
|
resp = await agent.process_direct(
|
||||||
prompt,
|
prompt,
|
||||||
session_key="heartbeat",
|
session_key="heartbeat",
|
||||||
@@ -676,8 +668,7 @@ def _run_gateway(
|
|||||||
webui_static_dist=webui_static_dist,
|
webui_static_dist=webui_static_dist,
|
||||||
webui_runtime_surface=webui_runtime_surface,
|
webui_runtime_surface=webui_runtime_surface,
|
||||||
webui_runtime_capabilities=webui_runtime_capabilities,
|
webui_runtime_capabilities=webui_runtime_capabilities,
|
||||||
webui_mcp_runtime_status=mcp_provider.runtime_status,
|
webui_mcp_runtime_status=agent.mcp_runtime_status,
|
||||||
webui_mcp_reload=mcp_provider.reload,
|
|
||||||
webui_skill_state_action=_webui_skill_state_action,
|
webui_skill_state_action=_webui_skill_state_action,
|
||||||
config_path=Path(config_path),
|
config_path=Path(config_path),
|
||||||
)
|
)
|
||||||
@@ -853,13 +844,6 @@ def _run_gateway(
|
|||||||
await cron.start()
|
await cron.start()
|
||||||
# Re-read once on first admission to close the watcher subscription window.
|
# Re-read once on first admission to close the watcher subscription window.
|
||||||
agent.runtime_resolver.invalidate()
|
agent.runtime_resolver.invalidate()
|
||||||
async def _run_agent() -> None:
|
|
||||||
try:
|
|
||||||
await mcp_provider.connect()
|
|
||||||
await agent.run()
|
|
||||||
finally:
|
|
||||||
await mcp_provider.aclose()
|
|
||||||
|
|
||||||
tasks = [
|
tasks = [
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
watch_config_file(
|
watch_config_file(
|
||||||
@@ -868,7 +852,7 @@ def _run_gateway(
|
|||||||
),
|
),
|
||||||
name="nanobot-config-watcher",
|
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(channels.start_all(), name="nanobot-channels"),
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
run_local_trigger_queue(
|
run_local_trigger_queue(
|
||||||
@@ -926,13 +910,7 @@ def _run_gateway(
|
|||||||
agent.stop()
|
agent.stop()
|
||||||
# Cancel runtime tasks first, then deterministically close
|
# Cancel runtime tasks first, then deterministically close
|
||||||
# exec/MCP resources while the event loop is still alive.
|
# exec/MCP resources while the event loop is still alive.
|
||||||
await _close_gateway_runtime(
|
await _close_gateway_runtime(agent, channels, tasks, runtime_tasks)
|
||||||
agent,
|
|
||||||
mcp_provider,
|
|
||||||
channels,
|
|
||||||
tasks,
|
|
||||||
runtime_tasks,
|
|
||||||
)
|
|
||||||
# Flush all cached sessions to durable storage before exit.
|
# Flush all cached sessions to durable storage before exit.
|
||||||
# This prevents data loss on filesystems with write-back
|
# This prevents data loss on filesystems with write-back
|
||||||
# caching (rclone VFS, NFS, FUSE mounts, etc.).
|
# 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.",
|
summary="Environment-based configuration is invalid.",
|
||||||
issues=validation_issues(exc),
|
issues=validation_issues(exc),
|
||||||
) from exc
|
) from exc
|
||||||
config.bind_source_path(path)
|
|
||||||
_apply_ssrf_whitelist(config)
|
_apply_ssrf_whitelist(config)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
@@ -131,7 +130,6 @@ def load_config(config_path: Path | None = None) -> Config:
|
|||||||
issues=issues,
|
issues=issues,
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
config.bind_source_path(path)
|
|
||||||
_apply_ssrf_whitelist(config)
|
_apply_ssrf_whitelist(config)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast
|
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 pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
from nanobot.config.timezone import detect_system_timezone
|
from nanobot.config.timezone import detect_system_timezone
|
||||||
@@ -431,8 +431,6 @@ class ToolsConfig(Base):
|
|||||||
class Config(BaseSettings):
|
class Config(BaseSettings):
|
||||||
"""Root configuration for nanobot."""
|
"""Root configuration for nanobot."""
|
||||||
|
|
||||||
_source_path: Path | None = PrivateAttr(default=None)
|
|
||||||
|
|
||||||
agents: AgentsConfig = Field(default_factory=AgentsConfig)
|
agents: AgentsConfig = Field(default_factory=AgentsConfig)
|
||||||
channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
|
channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
|
||||||
transcription: TranscriptionConfig = Field(default_factory=TranscriptionConfig)
|
transcription: TranscriptionConfig = Field(default_factory=TranscriptionConfig)
|
||||||
@@ -451,15 +449,6 @@ class Config(BaseSettings):
|
|||||||
_resolve_tool_config_refs()
|
_resolve_tool_config_refs()
|
||||||
super().__init__(**values)
|
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")
|
@model_validator(mode="after")
|
||||||
def _validate_model_preset(self) -> "Config":
|
def _validate_model_preset(self) -> "Config":
|
||||||
if "default" in self.model_presets:
|
if "default" in self.model_presets:
|
||||||
|
|||||||
+4
-24
@@ -10,8 +10,6 @@ from typing import Any
|
|||||||
from nanobot.agent.hook import AgentHook, SDKCaptureHook
|
from nanobot.agent.hook import AgentHook, SDKCaptureHook
|
||||||
from nanobot.agent.hooks import create_file_edit_activity_hook
|
from nanobot.agent.hooks import create_file_edit_activity_hook
|
||||||
from nanobot.agent.loop import AgentLoop
|
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.config.schema import Config
|
||||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||||
from nanobot.sdk.clients import MemoryClient, RuntimeClient, SessionClient
|
from nanobot.sdk.clients import MemoryClient, RuntimeClient, SessionClient
|
||||||
@@ -73,16 +71,9 @@ class Nanobot:
|
|||||||
print(result.content)
|
print(result.content)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, loop: AgentLoop, *, config: Config | None = None) -> None:
|
||||||
self,
|
|
||||||
loop: AgentLoop,
|
|
||||||
*,
|
|
||||||
config: Config | None = None,
|
|
||||||
mcp_provider: MCPProvider | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._loop = loop
|
self._loop = loop
|
||||||
self._config = config
|
self._config = config
|
||||||
self._mcp_provider = mcp_provider
|
|
||||||
self.sessions = SessionClient(loop)
|
self.sessions = SessionClient(loop)
|
||||||
self.memory = MemoryClient(loop)
|
self.memory = MemoryClient(loop)
|
||||||
self.runtime = RuntimeClient(loop)
|
self.runtime = RuntimeClient(loop)
|
||||||
@@ -129,15 +120,12 @@ class Nanobot:
|
|||||||
elif model_preset is not None:
|
elif model_preset is not None:
|
||||||
config.agents.defaults.model_preset = model_preset
|
config.agents.defaults.model_preset = model_preset
|
||||||
|
|
||||||
tools = ToolRegistry()
|
|
||||||
mcp_provider = MCPProvider.from_config(config, tools)
|
|
||||||
loop = AgentLoop.from_config(
|
loop = AgentLoop.from_config(
|
||||||
config,
|
config,
|
||||||
image_generation_provider_configs=image_gen_provider_configs(config),
|
image_generation_provider_configs=image_gen_provider_configs(config),
|
||||||
hook_factories=[create_file_edit_activity_hook],
|
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(
|
async def run(
|
||||||
self,
|
self,
|
||||||
@@ -190,8 +178,6 @@ class Nanobot:
|
|||||||
)
|
)
|
||||||
if runtime is not None:
|
if runtime is not None:
|
||||||
kwargs["runtime"] = runtime
|
kwargs["runtime"] = runtime
|
||||||
if self._mcp_provider is not None:
|
|
||||||
await self._mcp_provider.connect()
|
|
||||||
response = await self._loop.process_direct(
|
response = await self._loop.process_direct(
|
||||||
message,
|
message,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
@@ -273,8 +259,6 @@ class Nanobot:
|
|||||||
if override_runtime is not None:
|
if override_runtime is not None:
|
||||||
kwargs["runtime"] = override_runtime
|
kwargs["runtime"] = override_runtime
|
||||||
try:
|
try:
|
||||||
if self._mcp_provider is not None:
|
|
||||||
await self._mcp_provider.connect()
|
|
||||||
response = await self._loop.process_direct(
|
response = await self._loop.process_direct(
|
||||||
message,
|
message,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
@@ -343,12 +327,8 @@ class Nanobot:
|
|||||||
await run.aclose()
|
await run.aclose()
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
"""Release resources held by this instance."""
|
"""Release resources held by this instance (MCP connections, etc.)."""
|
||||||
try:
|
await self._loop.close_mcp()
|
||||||
await self._loop.aclose()
|
|
||||||
finally:
|
|
||||||
if self._mcp_provider is not None:
|
|
||||||
await self._mcp_provider.aclose()
|
|
||||||
|
|
||||||
async def __aenter__(self) -> Nanobot:
|
async def __aenter__(self) -> Nanobot:
|
||||||
return self
|
return self
|
||||||
|
|||||||
@@ -56,8 +56,6 @@ if TYPE_CHECKING:
|
|||||||
# that ``unittest.mock.patch`` can find and replace it.
|
# that ``unittest.mock.patch`` can find and replace it.
|
||||||
AsyncOpenAI: Any = None
|
AsyncOpenAI: Any = None
|
||||||
|
|
||||||
_GEMINI_SKIP_THOUGHT_SIGNATURE = "skip_thought_signature_validator"
|
|
||||||
|
|
||||||
|
|
||||||
def _is_hosted_web_search_type(value: object) -> bool:
|
def _is_hosted_web_search_type(value: object) -> bool:
|
||||||
return isinstance(value, str) and (
|
return isinstance(value, str) and (
|
||||||
@@ -692,8 +690,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
if strip_reasoning:
|
if strip_reasoning:
|
||||||
for msg in sanitized:
|
for msg in sanitized:
|
||||||
msg.pop("reasoning_content", None)
|
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:
|
def map_id(value: Any) -> Any:
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
@@ -771,81 +767,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
clean["content"] = self._coerce_content_to_string(clean.get("content"))
|
clean["content"] = self._coerce_content_to_string(clean.get("content"))
|
||||||
return self._enforce_role_alternation(sanitized)
|
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
|
# Build kwargs
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -1237,8 +1158,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
self._sanitize_empty_content(sanitized_state.pending_messages)
|
self._sanitize_empty_content(sanitized_state.pending_messages)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
is_deepseek = bool(self._spec and self._spec.name == "deepseek")
|
preserve_reasoning = bool(self._spec and self._spec.name == "deepseek")
|
||||||
preserve_reasoning = is_deepseek
|
|
||||||
instructions, input_items, replayed = prepare_responses_input(
|
instructions, input_items, replayed = prepare_responses_input(
|
||||||
sanitized_messages,
|
sanitized_messages,
|
||||||
state=sanitized_state,
|
state=sanitized_state,
|
||||||
@@ -1274,7 +1194,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
|
|
||||||
if not self._supports_temperature(model_name, reasoning_effort) and not preserve_reasoning:
|
if not self._supports_temperature(model_name, reasoning_effort) and not preserve_reasoning:
|
||||||
body["include"] = ["reasoning.encrypted_content"]
|
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}
|
body["reasoning"] = {"effort": reasoning_effort}
|
||||||
if replayed and "gpt-5.6" in model_name.lower():
|
if replayed and "gpt-5.6" in model_name.lower():
|
||||||
body.setdefault("reasoning", {})["context"] = "all_turns"
|
body.setdefault("reasoning", {})["context"] = "all_turns"
|
||||||
|
|||||||
@@ -112,7 +112,8 @@ class ProviderSpec:
|
|||||||
implicit_reasoning_models: tuple[str, ...] = ()
|
implicit_reasoning_models: tuple[str, ...] = ()
|
||||||
|
|
||||||
# Models that expose the OpenAI Responses wire format. This is model-level
|
# 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, ...] = ()
|
responses_models: tuple[str, ...] = ()
|
||||||
|
|
||||||
# Provider-hosted Responses tools sent unless extraBody.tools explicitly
|
# Provider-hosted Responses tools sent unless extraBody.tools explicitly
|
||||||
@@ -481,7 +482,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
backend="openai_compat",
|
backend="openai_compat",
|
||||||
default_api_base="https://api.deepseek.com",
|
default_api_base="https://api.deepseek.com",
|
||||||
thinking_style="thinking_type",
|
thinking_style="thinking_type",
|
||||||
responses_models=("deepseek-v4-flash", "deepseek-v4-pro"),
|
responses_models=("deepseek-v4-flash",),
|
||||||
responses_default_tools=("web_search",),
|
responses_default_tools=("web_search",),
|
||||||
),
|
),
|
||||||
# Gemini: Google's OpenAI-compatible endpoint
|
# Gemini: Google's OpenAI-compatible endpoint
|
||||||
|
|||||||
+6
-478
@@ -2,12 +2,9 @@
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import errno
|
import errno
|
||||||
import hashlib
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
|
||||||
import stat
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
@@ -17,10 +14,9 @@ from pathlib import Path
|
|||||||
from typing import Any, Callable, Collection, Protocol, TypedDict, cast
|
from typing import Any, Callable, Collection, Protocol, TypedDict, cast
|
||||||
from weakref import WeakValueDictionary
|
from weakref import WeakValueDictionary
|
||||||
|
|
||||||
from filelock import FileLock
|
|
||||||
from loguru import logger
|
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.providers.base import ProviderConversationState
|
||||||
from nanobot.runtime_context import (
|
from nanobot.runtime_context import (
|
||||||
RUNTIME_CONTEXT_HISTORY_META,
|
RUNTIME_CONTEXT_HISTORY_META,
|
||||||
@@ -61,11 +57,6 @@ _FORK_VOLATILE_METADATA_KEYS = {
|
|||||||
"title",
|
"title",
|
||||||
"title_user_edited",
|
"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]:
|
def _json_object(value: object) -> dict[str, Any]:
|
||||||
@@ -512,23 +503,6 @@ class SessionInfo(TypedDict):
|
|||||||
path: str
|
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):
|
class SessionStore(Protocol):
|
||||||
def load(self, key: str) -> Session | None: ...
|
def load(self, key: str) -> Session | None: ...
|
||||||
|
|
||||||
@@ -546,445 +520,9 @@ class SessionStore(Protocol):
|
|||||||
class JsonlSessionStore:
|
class JsonlSessionStore:
|
||||||
"""JSONL implementation of session persistence."""
|
"""JSONL implementation of session persistence."""
|
||||||
|
|
||||||
def __init__(self, workspace: Path, *, sessions_root: Path | None = None):
|
def __init__(self, workspace: Path):
|
||||||
canonical_workspace = Path(workspace).expanduser().resolve(strict=False)
|
self.sessions_dir = ensure_dir(workspace / "sessions")
|
||||||
ensure_dir(canonical_workspace)
|
self.legacy_sessions_dir = get_legacy_sessions_dir()
|
||||||
root = (
|
|
||||||
Path(sessions_root).expanduser().resolve(strict=False)
|
|
||||||
if sessions_root is not None
|
|
||||||
else get_runtime_subdir("sessions").resolve(strict=False)
|
|
||||||
)
|
|
||||||
if root == canonical_workspace or root.is_relative_to(canonical_workspace):
|
|
||||||
raise RuntimeError(
|
|
||||||
"session storage must be outside the agent workspace; "
|
|
||||||
"move --config outside --workspace or choose a nested workspace directory"
|
|
||||||
)
|
|
||||||
ensure_dir(root)
|
|
||||||
with suppress(OSError):
|
|
||||||
os.chmod(root, 0o700)
|
|
||||||
self.workspace = canonical_workspace
|
|
||||||
self._migration_lock = FileLock(
|
|
||||||
str(root / ".workspace-migration.lock"),
|
|
||||||
timeout=_SESSION_MIGRATION_LOCK_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
with self._migration_lock:
|
|
||||||
workspace_id = self._load_or_create_workspace_id(canonical_workspace, root)
|
|
||||||
workspace_id = self._claim_workspace_namespace(
|
|
||||||
root,
|
|
||||||
canonical_workspace,
|
|
||||||
workspace_id,
|
|
||||||
)
|
|
||||||
self.sessions_dir = ensure_dir(root / workspace_id)
|
|
||||||
self.legacy_sessions_dir = get_legacy_sessions_dir()
|
|
||||||
self._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
|
@staticmethod
|
||||||
def safe_key(key: str) -> str:
|
def safe_key(key: str) -> str:
|
||||||
@@ -1453,15 +991,9 @@ class JsonlSessionStore:
|
|||||||
class SessionManager:
|
class SessionManager:
|
||||||
"""Manage session identity, caching, retention, and persistence."""
|
"""Manage session identity, caching, retention, and persistence."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, workspace: Path, *, store: SessionStore | None = None):
|
||||||
self,
|
|
||||||
workspace: Path,
|
|
||||||
*,
|
|
||||||
store: SessionStore | None = None,
|
|
||||||
sessions_root: Path | None = None,
|
|
||||||
):
|
|
||||||
self.workspace = workspace
|
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._store: SessionStore = store if store is not None else self._jsonl_store
|
||||||
self.sessions_dir = self._jsonl_store.sessions_dir
|
self.sessions_dir = self._jsonl_store.sessions_dir
|
||||||
self.legacy_sessions_dir = self._jsonl_store.legacy_sessions_dir
|
self.legacy_sessions_dir = self._jsonl_store.legacy_sessions_dir
|
||||||
@@ -1627,10 +1159,6 @@ class SessionManager:
|
|||||||
self.invalidate(key)
|
self.invalidate(key)
|
||||||
return self._store.delete(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(
|
def fork_session_before_user_index(
|
||||||
self,
|
self,
|
||||||
source_key: str,
|
source_key: str,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Awaitable, Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Callable
|
from typing import TYPE_CHECKING, Any, Callable
|
||||||
@@ -66,7 +66,6 @@ def build_gateway_services(
|
|||||||
channel_feature_action: Callable[..., Any] | None = None,
|
channel_feature_action: Callable[..., Any] | None = None,
|
||||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
||||||
mcp_runtime_status: Callable[[], Mapping[str, str]] | 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,
|
skill_state_action: Callable[[set[str]], None] | None = None,
|
||||||
logger: Any = default_logger,
|
logger: Any = default_logger,
|
||||||
) -> GatewayServices:
|
) -> GatewayServices:
|
||||||
@@ -120,7 +119,6 @@ def build_gateway_services(
|
|||||||
channel_feature_action=channel_feature_action,
|
channel_feature_action=channel_feature_action,
|
||||||
channel_runtime_status=channel_runtime_status,
|
channel_runtime_status=channel_runtime_status,
|
||||||
mcp_runtime_status=mcp_runtime_status,
|
mcp_runtime_status=mcp_runtime_status,
|
||||||
mcp_reload=mcp_reload,
|
|
||||||
skill_state_action=skill_state_action,
|
skill_state_action=skill_state_action,
|
||||||
log=logger,
|
log=logger,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ def http_json_response(
|
|||||||
*,
|
*,
|
||||||
status: int = 200,
|
status: int = 200,
|
||||||
accept_encoding: str | None = None,
|
accept_encoding: str | None = None,
|
||||||
extra_headers: list[tuple[str, str]] | None = None,
|
|
||||||
) -> Response:
|
) -> Response:
|
||||||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||||
headers = [
|
headers = [
|
||||||
@@ -113,8 +112,6 @@ def http_json_response(
|
|||||||
if len(body) >= _JSON_GZIP_MIN_BYTES and accepts_gzip(accept_encoding):
|
if len(body) >= _JSON_GZIP_MIN_BYTES and accepts_gzip(accept_encoding):
|
||||||
body = gzip.compress(body, compresslevel=_JSON_GZIP_LEVEL, mtime=0)
|
body = gzip.compress(body, compresslevel=_JSON_GZIP_LEVEL, mtime=0)
|
||||||
headers.append(("Content-Encoding", "gzip"))
|
headers.append(("Content-Encoding", "gzip"))
|
||||||
if extra_headers:
|
|
||||||
headers.extend(extra_headers)
|
|
||||||
headers.append(("Content-Length", str(len(body))))
|
headers.append(("Content-Length", str(len(body))))
|
||||||
reason = http.HTTPStatus(status).phrase
|
reason = http.HTTPStatus(status).phrase
|
||||||
return Response(status, reason, Headers(headers), body)
|
return Response(status, reason, Headers(headers), body)
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ _MCP_ATTACHMENT_KEYS = (
|
|||||||
"status",
|
"status",
|
||||||
"configured",
|
"configured",
|
||||||
)
|
)
|
||||||
|
_MAX_TEST_TOOLS = 16
|
||||||
_DEFAULT_TEST_TIMEOUT = 20
|
_DEFAULT_TEST_TIMEOUT = 20
|
||||||
_DEFAULT_CUSTOM_TIMEOUT = 30
|
_DEFAULT_CUSTOM_TIMEOUT = 30
|
||||||
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
|
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
|
||||||
@@ -1096,7 +1097,7 @@ async def mcp_presets_test_action(
|
|||||||
*,
|
*,
|
||||||
config_path: Path | None = None,
|
config_path: Path | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Connect to an enabled MCP preset and report its complete tool surface."""
|
"""Connect to an enabled MCP preset and report its tool surface."""
|
||||||
from nanobot.agent.tools.mcp import connect_mcp_servers
|
from nanobot.agent.tools.mcp import connect_mcp_servers
|
||||||
|
|
||||||
name = (_query_first(query, "name") or "").strip()
|
name = (_query_first(query, "name") or "").strip()
|
||||||
@@ -1156,10 +1157,9 @@ async def mcp_presets_test_action(
|
|||||||
|
|
||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
stacks: dict[str, Any] = {}
|
stacks: dict[str, Any] = {}
|
||||||
inspection_cfg = cfg.model_copy(update={"enabled_tools": ["*"]})
|
|
||||||
try:
|
try:
|
||||||
stacks = await asyncio.wait_for(
|
stacks = await asyncio.wait_for(
|
||||||
connect_mcp_servers({name: inspection_cfg}, registry),
|
connect_mcp_servers({name: cfg}, registry),
|
||||||
timeout=_test_timeout(cfg),
|
timeout=_test_timeout(cfg),
|
||||||
)
|
)
|
||||||
tool_prefix = f"mcp_{name}_"
|
tool_prefix = f"mcp_{name}_"
|
||||||
@@ -1178,7 +1178,7 @@ async def mcp_presets_test_action(
|
|||||||
else f"{display_name} connected, but reported no tools."
|
else f"{display_name} connected, but reported no tools."
|
||||||
),
|
),
|
||||||
"tool_count": len(tool_names),
|
"tool_count": len(tool_names),
|
||||||
"tool_names": tool_names,
|
"tool_names": tool_names[:_MAX_TEST_TOOLS],
|
||||||
"checked_at": _checked_at(),
|
"checked_at": _checked_at(),
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
"""Cache-only WebUI session list index.
|
"""Cache-only WebUI session list index.
|
||||||
|
|
||||||
The core ``SessionManager`` owns model context while the WebUI transcript owns
|
The core ``SessionManager`` owns durable conversation history. This module owns
|
||||||
durable display history. The sidebar discovers both without reconstructing one
|
the WebUI sidebar optimization so core session writes stay independent from UI
|
||||||
store from the other, so core session writes stay independent from UI state.
|
presentation caches.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
@@ -31,12 +30,9 @@ from nanobot.session.manager import (
|
|||||||
)
|
)
|
||||||
from nanobot.session.model_selection import model_preset_from_metadata
|
from nanobot.session.model_selection import model_preset_from_metadata
|
||||||
|
|
||||||
_INDEX_VERSION = 7
|
_INDEX_VERSION = 6
|
||||||
_INDEX_FILENAME = ".webui_session_index.json"
|
_INDEX_FILENAME = ".webui_session_index.json"
|
||||||
_MODEL_PRESET_FIELD = "model_preset"
|
_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_PRESENT_FIELD = "_workspace_scope_present"
|
||||||
_WORKSPACE_SCOPE_VALUE_FIELD = "_workspace_scope_value"
|
_WORKSPACE_SCOPE_VALUE_FIELD = "_workspace_scope_value"
|
||||||
WEBUI_SESSION_INDEX_INTERNAL_FIELDS = frozenset(
|
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
|
_MAX_INDEXED_WORKSPACE_SCOPE_BYTES = 4096
|
||||||
_WEBUI_ACTIVITY_MTIME_NS = "webui_activity_mtime_ns"
|
_WEBUI_ACTIVITY_MTIME_NS = "webui_activity_mtime_ns"
|
||||||
_WEBUI_ACTIVITY_SIZE = "webui_activity_size"
|
_WEBUI_ACTIVITY_SIZE = "webui_activity_size"
|
||||||
_WEBUI_ACTIVITY_FILES = "webui_activity_files"
|
|
||||||
_VISIBLE_TRANSCRIPT_ROLES = {"user", "assistant"}
|
_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]]:
|
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)
|
_write_index_rows(session_manager.sessions_dir, rows)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Failed to write WebUI session list index: {}", e)
|
logger.debug("Failed to write WebUI session list index: {}", e)
|
||||||
sessions = [
|
sessions = [_public_row(session_manager.sessions_dir, row) for row in rows]
|
||||||
_public_row(session_manager.sessions_dir, get_webui_dir(), row)
|
|
||||||
for row in rows
|
|
||||||
]
|
|
||||||
return sorted(sessions, key=lambda row: row.get("updated_at", ""), reverse=True)
|
return sorted(sessions, key=lambda row: row.get("updated_at", ""), reverse=True)
|
||||||
|
|
||||||
|
|
||||||
def _reconcile_index(session_manager: SessionManager) -> tuple[list[dict[str, Any]], bool]:
|
def _reconcile_index(session_manager: SessionManager) -> tuple[list[dict[str, Any]], bool]:
|
||||||
existing_rows = _read_index_rows(session_manager.sessions_dir)
|
existing_rows = _read_index_rows(session_manager.sessions_dir)
|
||||||
existing_by_source = {
|
existing_by_file = {
|
||||||
(row.get(_ROW_SOURCE_FIELD), row.get("file")): row
|
row.get("file"): row
|
||||||
for row in existing_rows or []
|
for row in existing_rows or []
|
||||||
if isinstance(row.get(_ROW_SOURCE_FIELD), str)
|
if isinstance(row.get("file"), str)
|
||||||
and isinstance(row.get("file"), str)
|
|
||||||
}
|
}
|
||||||
webui_dir = get_webui_dir()
|
paths = sorted(
|
||||||
session_paths: dict[str, Path] = {}
|
path
|
||||||
for path in sorted(session_manager.sessions_dir.glob("*.jsonl")):
|
for path in session_manager.sessions_dir.glob("*.jsonl")
|
||||||
key = SessionManager._session_key_from_path(path) # pyright: ignore[reportPrivateUsage]
|
if SessionManager._session_key_from_path(path) is not None # pyright: ignore[reportPrivateUsage]
|
||||||
if key is not None:
|
)
|
||||||
session_paths[key] = path
|
if not paths:
|
||||||
|
return [], existing_rows != []
|
||||||
|
|
||||||
session_keys_by_stem = {
|
webui_dir = get_webui_dir()
|
||||||
SessionManager.safe_key(key): key
|
|
||||||
for key in session_paths
|
|
||||||
if key.startswith("websocket:")
|
|
||||||
}
|
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
changed = existing_rows is None
|
changed = existing_rows is None
|
||||||
expected_sources: set[tuple[str, str]] = set()
|
|
||||||
|
|
||||||
for key, path in sorted(session_paths.items()):
|
for path in paths:
|
||||||
identity = (_SESSION_SOURCE, path.name)
|
row = existing_by_file.get(path.name)
|
||||||
row = existing_by_source.get(identity)
|
|
||||||
if row is not None and _indexed_row_matches_file(row, path, webui_dir):
|
if row is not None and _indexed_row_matches_file(row, path, webui_dir):
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
expected_sources.add(identity)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
changed = True
|
changed = True
|
||||||
scanned = _scan_session_row(session_manager, path, webui_dir)
|
scanned = _scan_session_row(session_manager, path, webui_dir)
|
||||||
if scanned is not None:
|
if scanned is not None:
|
||||||
rows.append(scanned)
|
rows.append(scanned)
|
||||||
expected_sources.add(identity)
|
|
||||||
|
|
||||||
for stem, paths in _webui_transcript_sources(webui_dir).items():
|
if set(existing_by_file) != {path.name for path in paths}:
|
||||||
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:
|
|
||||||
changed = True
|
changed = True
|
||||||
if existing_rows is not None and rows != existing_rows:
|
if existing_rows is not None and rows != existing_rows:
|
||||||
changed = True
|
changed = True
|
||||||
@@ -191,7 +144,7 @@ def _indexed_row_matches_file(row: dict[str, Any], path: Path, webui_dir: Path)
|
|||||||
return False
|
return False
|
||||||
if not isinstance(row.get(_WORKSPACE_SCOPE_PRESENT_FIELD), bool):
|
if not isinstance(row.get(_WORKSPACE_SCOPE_PRESENT_FIELD), bool):
|
||||||
return False
|
return False
|
||||||
if row.get(_ROW_SOURCE_FIELD) != _SESSION_SOURCE or row.get("file") != path.name:
|
if row.get("file") != path.name:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
signature = _file_signature(path)
|
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("size") == signature["size"]
|
||||||
and row.get(_WEBUI_ACTIVITY_MTIME_NS) == activity_signature[_WEBUI_ACTIVITY_MTIME_NS]
|
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_SIZE) == activity_signature[_WEBUI_ACTIVITY_SIZE]
|
||||||
and row.get(_WEBUI_ACTIVITY_FILES) == activity_signature[_WEBUI_ACTIVITY_FILES]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _indexed_transcript_row_matches(
|
def _public_row(sessions_dir: Path, row: dict[str, Any]) -> dict[str, Any]:
|
||||||
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
|
|
||||||
return {
|
return {
|
||||||
"key": row.get("key"),
|
"key": row.get("key"),
|
||||||
"created_at": row.get("created_at"),
|
"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),
|
_MODEL_PRESET_FIELD: row.get(_MODEL_PRESET_FIELD),
|
||||||
_WORKSPACE_SCOPE_PRESENT_FIELD: row.get(_WORKSPACE_SCOPE_PRESENT_FIELD, False),
|
_WORKSPACE_SCOPE_PRESENT_FIELD: row.get(_WORKSPACE_SCOPE_PRESENT_FIELD, False),
|
||||||
_WORKSPACE_SCOPE_VALUE_FIELD: row.get(_WORKSPACE_SCOPE_VALUE_FIELD),
|
_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
|
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]:
|
def _webui_activity_paths(session_key: str, webui_dir: Path) -> list[Path]:
|
||||||
stem = SessionManager.safe_key(session_key)
|
stem = SessionManager.safe_key(session_key)
|
||||||
paths = [
|
return [
|
||||||
webui_dir / f"{stem}.jsonl",
|
webui_dir / f"{stem}.jsonl",
|
||||||
webui_dir / f"{stem}.json",
|
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]:
|
def _webui_activity_signature(session_key: str, webui_dir: Path) -> dict[str, int]:
|
||||||
latest_mtime_ns = 0
|
latest_mtime_ns = 0
|
||||||
total_size = 0
|
total_size = 0
|
||||||
file_count = 0
|
|
||||||
for path in _webui_activity_paths(session_key, webui_dir):
|
for path in _webui_activity_paths(session_key, webui_dir):
|
||||||
try:
|
try:
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
@@ -409,13 +260,11 @@ def _webui_activity_signature(session_key: str, webui_dir: Path) -> dict[str, in
|
|||||||
continue
|
continue
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
continue
|
continue
|
||||||
file_count += 1
|
|
||||||
latest_mtime_ns = max(latest_mtime_ns, stat.st_mtime_ns)
|
latest_mtime_ns = max(latest_mtime_ns, stat.st_mtime_ns)
|
||||||
total_size += stat.st_size
|
total_size += stat.st_size
|
||||||
return {
|
return {
|
||||||
_WEBUI_ACTIVITY_MTIME_NS: latest_mtime_ns,
|
_WEBUI_ACTIVITY_MTIME_NS: latest_mtime_ns,
|
||||||
_WEBUI_ACTIVITY_SIZE: total_size,
|
_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),
|
"preview": _preview_from_messages(session.messages),
|
||||||
_MODEL_PRESET_FIELD: model_preset_from_metadata(session.metadata),
|
_MODEL_PRESET_FIELD: model_preset_from_metadata(session.metadata),
|
||||||
**_indexed_workspace_scope_fields(session.metadata),
|
**_indexed_workspace_scope_fields(session.metadata),
|
||||||
_ROW_SOURCE_FIELD: _SESSION_SOURCE,
|
|
||||||
"file": path.name,
|
"file": path.name,
|
||||||
"mtime_ns": signature["mtime_ns"],
|
"mtime_ns": signature["mtime_ns"],
|
||||||
"size": signature["size"],
|
"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(
|
def _scan_session_row(
|
||||||
session_manager: SessionManager,
|
session_manager: SessionManager,
|
||||||
path: Path,
|
path: Path,
|
||||||
@@ -686,7 +418,6 @@ def _scan_session_row(
|
|||||||
"preview": preview or fallback_preview,
|
"preview": preview or fallback_preview,
|
||||||
_MODEL_PRESET_FIELD: model_preset_from_metadata(metadata),
|
_MODEL_PRESET_FIELD: model_preset_from_metadata(metadata),
|
||||||
**_indexed_workspace_scope_fields(metadata),
|
**_indexed_workspace_scope_fields(metadata),
|
||||||
_ROW_SOURCE_FIELD: _SESSION_SOURCE,
|
|
||||||
"file": path.name,
|
"file": path.name,
|
||||||
"mtime_ns": signature["mtime_ns"],
|
"mtime_ns": signature["mtime_ns"],
|
||||||
"size": signature["size"],
|
"size": signature["size"],
|
||||||
|
|||||||
@@ -5,15 +5,17 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
from collections.abc import Awaitable, Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from websockets.http11 import Request as WsRequest
|
from websockets.http11 import Request as WsRequest
|
||||||
from websockets.http11 import Response
|
from websockets.http11 import Response
|
||||||
|
|
||||||
from nanobot.agent.tools.image_generation import request_image_generation_reload
|
from nanobot.agent.tools.image_generation import request_image_generation_reload
|
||||||
|
from nanobot.agent.tools.mcp import request_mcp_reload
|
||||||
from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH
|
from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH
|
||||||
from nanobot.api.runtime import ApiRuntime, api_runtime_paths
|
from nanobot.api.runtime import ApiRuntime, api_runtime_paths
|
||||||
|
from nanobot.apps.discovery import discovery_payload
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.registry import load_channel_plugin
|
from nanobot.channels.registry import load_channel_plugin
|
||||||
from nanobot.channels.validation import validate_channel_config
|
from nanobot.channels.validation import validate_channel_config
|
||||||
@@ -70,7 +72,6 @@ _WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
|
|||||||
_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
|
_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
|
||||||
_CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"})
|
_CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"})
|
||||||
_MCP_OAUTH_CALLBACK_URL_MAX_BYTES = 8 * 1024
|
_MCP_OAUTH_CALLBACK_URL_MAX_BYTES = 8 * 1024
|
||||||
_MCP_RELOAD_TIMEOUT_SECONDS = 15.0
|
|
||||||
_query_first = contracts.query_first
|
_query_first = contracts.query_first
|
||||||
|
|
||||||
|
|
||||||
@@ -127,6 +128,7 @@ _CAPABILITY_ROUTES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_SYSTEM_ROUTES = {
|
_SYSTEM_ROUTES = {
|
||||||
|
"/api/settings/apps-discovery": "apps-discovery",
|
||||||
"/api/settings/cli-apps": "cli-list",
|
"/api/settings/cli-apps": "cli-list",
|
||||||
"/api/settings/cli-apps/install": "cli-install",
|
"/api/settings/cli-apps/install": "cli-install",
|
||||||
"/api/settings/cli-apps/update": "cli-update",
|
"/api/settings/cli-apps/update": "cli-update",
|
||||||
@@ -227,7 +229,6 @@ class WebUISettingsRouter:
|
|||||||
channel_feature_action: Callable[..., Any] | None = None,
|
channel_feature_action: Callable[..., Any] | None = None,
|
||||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
||||||
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
|
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
|
||||||
mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
|
|
||||||
mcp_oauth_redirect_uri: Callable[[WsRequest], str] | None = None,
|
mcp_oauth_redirect_uri: Callable[[WsRequest], str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
@@ -242,7 +243,6 @@ class WebUISettingsRouter:
|
|||||||
self._channel_feature_action = channel_feature_action
|
self._channel_feature_action = channel_feature_action
|
||||||
self._channel_runtime_status = channel_runtime_status
|
self._channel_runtime_status = channel_runtime_status
|
||||||
self._mcp_runtime_status = mcp_runtime_status
|
self._mcp_runtime_status = mcp_runtime_status
|
||||||
self._mcp_reload = mcp_reload
|
|
||||||
self._mcp_oauth_redirect_uri = mcp_oauth_redirect_uri
|
self._mcp_oauth_redirect_uri = mcp_oauth_redirect_uri
|
||||||
self._mcp_oauth = McpOAuthManager()
|
self._mcp_oauth = McpOAuthManager()
|
||||||
self._restart_sections: set[str] = set()
|
self._restart_sections: set[str] = set()
|
||||||
@@ -463,6 +463,7 @@ class WebUISettingsRouter:
|
|||||||
|
|
||||||
def _system_operations(self) -> system_domain.SystemSettingsOperations:
|
def _system_operations(self) -> system_domain.SystemSettingsOperations:
|
||||||
return system_domain.SystemSettingsOperations(
|
return system_domain.SystemSettingsOperations(
|
||||||
|
apps_discovery_payload=discovery_payload,
|
||||||
cli_apps_payload=cli_apps_payload,
|
cli_apps_payload=cli_apps_payload,
|
||||||
cli_apps_action=cli_apps_action,
|
cli_apps_action=cli_apps_action,
|
||||||
nanobot_features_payload=nanobot_features_payload,
|
nanobot_features_payload=nanobot_features_payload,
|
||||||
@@ -474,7 +475,7 @@ class WebUISettingsRouter:
|
|||||||
approve_code=approve_code,
|
approve_code=approve_code,
|
||||||
deny_code=deny_code,
|
deny_code=deny_code,
|
||||||
mcp_presets_action=mcp_presets_settings_action,
|
mcp_presets_action=mcp_presets_settings_action,
|
||||||
reload_mcp=self._reload_mcp_runtime,
|
reload_mcp=lambda: request_mcp_reload(self.bus),
|
||||||
mcp_runtime_status=self._mcp_runtime_status,
|
mcp_runtime_status=self._mcp_runtime_status,
|
||||||
check_for_update=check_for_update,
|
check_for_update=check_for_update,
|
||||||
channel_feature_action=self._channel_feature_action,
|
channel_feature_action=self._channel_feature_action,
|
||||||
@@ -501,33 +502,6 @@ class WebUISettingsRouter:
|
|||||||
self._restart_sections.discard("image")
|
self._restart_sections.discard("image")
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
async def _reload_mcp_runtime(self) -> dict[str, Any]:
|
|
||||||
if self._mcp_reload is None:
|
|
||||||
return {
|
|
||||||
"ok": False,
|
|
||||||
"message": "MCP runtime reload is unavailable. Restart nanobot to apply changes.",
|
|
||||||
"requires_restart": True,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
return await asyncio.wait_for(
|
|
||||||
self._mcp_reload(),
|
|
||||||
timeout=_MCP_RELOAD_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
return {
|
|
||||||
"ok": False,
|
|
||||||
"message": "MCP hot reload timed out. Restart nanobot to pick up changes.",
|
|
||||||
"requires_restart": True,
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.exception("MCP hot reload failed")
|
|
||||||
return {
|
|
||||||
"ok": False,
|
|
||||||
"message": "MCP hot reload failed. Restart nanobot to pick up changes.",
|
|
||||||
"requires_restart": True,
|
|
||||||
"error": str(exc),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
|
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
|
||||||
return self._query(request)
|
return self._query(request)
|
||||||
|
|
||||||
@@ -651,7 +625,7 @@ class WebUISettingsRouter:
|
|||||||
name,
|
name,
|
||||||
cfg,
|
cfg,
|
||||||
redirect_uri,
|
redirect_uri,
|
||||||
reload_mcp=self._reload_mcp_runtime,
|
reload_mcp=lambda: request_mcp_reload(self.bus),
|
||||||
reset_credentials=reset,
|
reset_credentials=reset,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ SettingsOperation = Callable[..., Any]
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SystemSettingsOperations:
|
class SystemSettingsOperations:
|
||||||
|
apps_discovery_payload: SettingsOperation
|
||||||
cli_apps_payload: SettingsOperation
|
cli_apps_payload: SettingsOperation
|
||||||
cli_apps_action: SettingsOperation
|
cli_apps_action: SettingsOperation
|
||||||
nanobot_features_payload: SettingsOperation
|
nanobot_features_payload: SettingsOperation
|
||||||
@@ -372,6 +373,8 @@ class SystemSettingsHandler:
|
|||||||
channel_name: str | None = None,
|
channel_name: str | None = None,
|
||||||
connect_action: str | None = None,
|
connect_action: str | None = None,
|
||||||
) -> SettingsRouteResult:
|
) -> SettingsRouteResult:
|
||||||
|
if action == "apps-discovery":
|
||||||
|
return await self._apps_discovery(operations)
|
||||||
if action == "cli-list":
|
if action == "cli-list":
|
||||||
return await self._cli_apps(request, operations)
|
return await self._cli_apps(request, operations)
|
||||||
if action.startswith("cli-"):
|
if action.startswith("cli-"):
|
||||||
@@ -419,6 +422,15 @@ class SystemSettingsHandler:
|
|||||||
return await self._version_check(operations)
|
return await self._version_check(operations)
|
||||||
return SettingsRouteResult.failure(404, "unknown settings action")
|
return SettingsRouteResult.failure(404, "unknown settings action")
|
||||||
|
|
||||||
|
async def _apps_discovery(
|
||||||
|
self,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
payload = await operations.apps_discovery_payload(
|
||||||
|
data_dir=self.settings.config.path.parent / "catalog",
|
||||||
|
)
|
||||||
|
return SettingsRouteResult.success(payload)
|
||||||
|
|
||||||
async def _cli_apps(
|
async def _cli_apps(
|
||||||
self,
|
self,
|
||||||
request: SettingsRequest,
|
request: SettingsRequest,
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ does not modify agent sessions.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import math
|
|
||||||
import os
|
import os
|
||||||
import threading
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
@@ -26,11 +24,8 @@ _MAX_MAP_ITEMS = 2_000
|
|||||||
_MAX_KEY_LEN = 512
|
_MAX_KEY_LEN = 512
|
||||||
_MAX_TITLE_LEN = 160
|
_MAX_TITLE_LEN = 160
|
||||||
_MAX_TAG_LEN = 40
|
_MAX_TAG_LEN = 40
|
||||||
_MAX_WORKBENCH_PANES = 4
|
|
||||||
_ALLOWED_DENSITIES = {"comfortable", "compact"}
|
_ALLOWED_DENSITIES = {"comfortable", "compact"}
|
||||||
_ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc", "manual"}
|
_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:
|
def webui_sidebar_state_path() -> Path:
|
||||||
@@ -47,7 +42,6 @@ def default_webui_sidebar_state() -> dict[str, Any]:
|
|||||||
"project_name_overrides": {},
|
"project_name_overrides": {},
|
||||||
"tags_by_key": {},
|
"tags_by_key": {},
|
||||||
"collapsed_groups": {},
|
"collapsed_groups": {},
|
||||||
"workbench": {"version": 1, "tabs": {}},
|
|
||||||
"view": {
|
"view": {
|
||||||
"density": "comfortable",
|
"density": "comfortable",
|
||||||
"show_previews": False,
|
"show_previews": False,
|
||||||
@@ -82,20 +76,6 @@ def _clean_string_list(value: Any, *, max_len: int = _MAX_KEY_LEN) -> list[str]:
|
|||||||
return out
|
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]:
|
def _clean_bool_map(value: Any) -> dict[str, bool]:
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
return {}
|
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]:
|
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):
|
if not isinstance(raw, dict):
|
||||||
raw = {}
|
raw = {}
|
||||||
raw = cast(dict[str, Any], 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["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key"))
|
||||||
state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups"))
|
state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups"))
|
||||||
state["workbench"] = _clean_workbench(raw.get("workbench"))
|
|
||||||
state["view"] = _clean_view(raw.get("view"))
|
state["view"] = _clean_view(raw.get("view"))
|
||||||
updated_at = raw.get("updated_at")
|
updated_at = raw.get("updated_at")
|
||||||
state["updated_at"] = updated_at if isinstance(updated_at, str) else None
|
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]:
|
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 = normalize_webui_sidebar_state(raw)
|
||||||
state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||||
encoded = json.dumps(
|
encoded = json.dumps(
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import json
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from collections.abc import Awaitable, Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
from urllib.parse import quote, unquote, urlsplit, urlunsplit
|
from urllib.parse import quote, unquote, urlsplit, urlunsplit
|
||||||
@@ -121,7 +121,6 @@ from nanobot.webui.workspaces import WebUIWorkspaceController
|
|||||||
_SLOW_WEBUI_HTTP_LOG_MS = 1_000
|
_SLOW_WEBUI_HTTP_LOG_MS = 1_000
|
||||||
_WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
|
_WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
|
||||||
_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
|
_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
|
||||||
_NO_STORE_HEADERS = [("Cache-Control", "no-store")]
|
|
||||||
|
|
||||||
_WEBUI_MUTATION_PATHS = {
|
_WEBUI_MUTATION_PATHS = {
|
||||||
"automation.enable": "/api/webui/automations/enable",
|
"automation.enable": "/api/webui/automations/enable",
|
||||||
@@ -309,7 +308,6 @@ class GatewayHTTPHandler:
|
|||||||
channel_feature_action: Callable[..., Any] | None = None,
|
channel_feature_action: Callable[..., Any] | None = None,
|
||||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
||||||
mcp_runtime_status: Callable[[], Mapping[str, str]] | 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,
|
skill_state_action: Callable[[set[str]], None] | None = None,
|
||||||
log: Any = logger,
|
log: Any = logger,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -353,7 +351,6 @@ class GatewayHTTPHandler:
|
|||||||
channel_feature_action=channel_feature_action,
|
channel_feature_action=channel_feature_action,
|
||||||
channel_runtime_status=channel_runtime_status,
|
channel_runtime_status=channel_runtime_status,
|
||||||
mcp_runtime_status=mcp_runtime_status,
|
mcp_runtime_status=mcp_runtime_status,
|
||||||
mcp_reload=mcp_reload,
|
|
||||||
mcp_oauth_redirect_uri=self._mcp_oauth_redirect_uri,
|
mcp_oauth_redirect_uri=self._mcp_oauth_redirect_uri,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -548,16 +545,9 @@ class GatewayHTTPHandler:
|
|||||||
"too many outstanding issued tokens ({}), rejecting issuance",
|
"too many outstanding issued tokens ({}), rejecting issuance",
|
||||||
len(self.tokens.issued_tokens),
|
len(self.tokens.issued_tokens),
|
||||||
)
|
)
|
||||||
return _http_json_response(
|
return _http_json_response({"error": "too many outstanding tokens"}, status=429)
|
||||||
{"error": "too many outstanding tokens"},
|
|
||||||
status=429,
|
|
||||||
extra_headers=_NO_STORE_HEADERS,
|
|
||||||
)
|
|
||||||
token_value = self.tokens.issue_token(self.config.token_ttl_s)
|
token_value = self.tokens.issue_token(self.config.token_ttl_s)
|
||||||
return _http_json_response(
|
return _http_json_response(token_response_payload(token_value, self.config.token_ttl_s))
|
||||||
token_response_payload(token_value, self.config.token_ttl_s),
|
|
||||||
extra_headers=_NO_STORE_HEADERS,
|
|
||||||
)
|
|
||||||
|
|
||||||
# -- Bootstrap ----------------------------------------------------------
|
# -- Bootstrap ----------------------------------------------------------
|
||||||
|
|
||||||
@@ -587,7 +577,7 @@ class GatewayHTTPHandler:
|
|||||||
"runtime_surface": self._runtime_surface,
|
"runtime_surface": self._runtime_surface,
|
||||||
"runtime_capabilities": self._capabilities,
|
"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
|
api_token_allowed = bool(secret) or is_local_browser
|
||||||
if not self.tokens.can_issue(include_api_token=api_token_allowed):
|
if not self.tokens.can_issue(include_api_token=api_token_allowed):
|
||||||
@@ -595,7 +585,6 @@ class GatewayHTTPHandler:
|
|||||||
json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"),
|
json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"),
|
||||||
status=429,
|
status=429,
|
||||||
content_type="application/json; charset=utf-8",
|
content_type="application/json; charset=utf-8",
|
||||||
extra_headers=_NO_STORE_HEADERS,
|
|
||||||
)
|
)
|
||||||
token = self.tokens.issue_token(self.config.token_ttl_s, audience="webui")
|
token = self.tokens.issue_token(self.config.token_ttl_s, audience="webui")
|
||||||
api_token = (
|
api_token = (
|
||||||
@@ -620,7 +609,7 @@ class GatewayHTTPHandler:
|
|||||||
}
|
}
|
||||||
if api_token is not None:
|
if api_token is not None:
|
||||||
payload["api_token"] = api_token
|
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:
|
def _bootstrap_ws_url(self, request: Any) -> str:
|
||||||
headers = getattr(request, "headers", {}) or {}
|
headers = getattr(request, "headers", {}) or {}
|
||||||
@@ -855,9 +844,9 @@ class GatewayHTTPHandler:
|
|||||||
self.local_trigger_store.delete(job.id)
|
self.local_trigger_store.delete(job.id)
|
||||||
elif self.cron_service is not None:
|
elif self.cron_service is not None:
|
||||||
self.cron_service.remove_job(job.id)
|
self.cron_service.remove_job(job.id)
|
||||||
session_deleted = self.session_manager.delete_session(decoded_key)
|
deleted = self.session_manager.delete_session(decoded_key)
|
||||||
transcript_deleted = delete_webui_thread(decoded_key)
|
delete_webui_thread(decoded_key)
|
||||||
return _http_json_response({"deleted": bool(session_deleted or transcript_deleted)})
|
return _http_json_response({"deleted": bool(deleted)})
|
||||||
|
|
||||||
# -- Automation routes --------------------------------------------------
|
# -- Automation routes --------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ def make_loop(
|
|||||||
context_window_tokens: int = 128_000,
|
context_window_tokens: int = 128_000,
|
||||||
session_ttl_minutes: int = 0,
|
session_ttl_minutes: int = 0,
|
||||||
unified_session: bool = False,
|
unified_session: bool = False,
|
||||||
|
mcp_servers: dict | None = None,
|
||||||
tools_config=None,
|
tools_config=None,
|
||||||
model_presets: dict | None = None,
|
model_presets: dict | None = None,
|
||||||
hooks: list | None = None,
|
hooks: list | None = None,
|
||||||
@@ -71,6 +72,8 @@ def make_loop(
|
|||||||
session_ttl_minutes=session_ttl_minutes,
|
session_ttl_minutes=session_ttl_minutes,
|
||||||
unified_session=unified_session,
|
unified_session=unified_session,
|
||||||
)
|
)
|
||||||
|
if mcp_servers is not None:
|
||||||
|
kwargs["mcp_servers"] = mcp_servers
|
||||||
if tools_config is not None:
|
if tools_config is not None:
|
||||||
kwargs["tools_config"] = tools_config
|
kwargs["tools_config"] = tools_config
|
||||||
if model_presets is not None:
|
if model_presets is not None:
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from unittest.mock import AsyncMock, MagicMock
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.command import CommandContext
|
from nanobot.command import CommandContext
|
||||||
@@ -194,11 +193,7 @@ class TestIdleScanThrottling:
|
|||||||
})
|
})
|
||||||
provider = MagicMock()
|
provider = MagicMock()
|
||||||
provider.get_default_model.return_value = "test-model"
|
provider.get_default_model.return_value = "test-model"
|
||||||
loop = AgentLoop.from_config(
|
loop = AgentLoop.from_config(config, provider=provider)
|
||||||
config,
|
|
||||||
tool_registry=ToolRegistry(),
|
|
||||||
provider=provider,
|
|
||||||
)
|
|
||||||
loop.auto_compact.check_expired = MagicMock()
|
loop.auto_compact.check_expired = MagicMock()
|
||||||
|
|
||||||
loop._check_expired_sessions_if_due()
|
loop._check_expired_sessions_if_due()
|
||||||
@@ -315,7 +310,7 @@ class TestAutoCompact:
|
|||||||
assert loop.auto_compact._is_expired(ts) is True
|
assert loop.auto_compact._is_expired(ts) is True
|
||||||
ts2 = datetime.now() - timedelta(minutes=14, seconds=59)
|
ts2 = datetime.now() - timedelta(minutes=14, seconds=59)
|
||||||
assert loop.auto_compact._is_expired(ts2) is False
|
assert loop.auto_compact._is_expired(ts2) is False
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_is_expired_string_timestamp(self, tmp_path):
|
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(ts) is True
|
||||||
assert loop.auto_compact._is_expired(None) is False
|
assert loop.auto_compact._is_expired(None) is False
|
||||||
assert loop.auto_compact._is_expired("") is False
|
assert loop.auto_compact._is_expired("") is False
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_check_expired_only_archives_expired_sessions(self, tmp_path):
|
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")
|
active_after = loop.sessions.get_or_create("cli:active")
|
||||||
assert len(active_after.messages) == 1
|
assert len(active_after.messages) == 1
|
||||||
assert active_after.messages[0]["content"] == "recent"
|
assert active_after.messages[0]["content"] == "recent"
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_compact_archives_full_tail_without_deleting_history(self, tmp_path):
|
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 len(visible) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
||||||
assert visible[0]["content"] == "msg user 2"
|
assert visible[0]["content"] == "msg user 2"
|
||||||
assert visible[-1]["content"] == "msg assistant 5"
|
assert visible[-1]["content"] == "msg assistant 5"
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_compact_extends_recent_suffix_to_user_turn(self, tmp_path):
|
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 m in visible
|
||||||
for tc in (m.get("tool_calls") or [])
|
for tc in (m.get("tool_calls") or [])
|
||||||
)
|
)
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_compact_stores_summary(self, tmp_path):
|
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)) == (
|
assert len(session_after.get_history(max_messages=12)) == (
|
||||||
loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
||||||
)
|
)
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_compact_empty_session(self, tmp_path):
|
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")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(session_after.messages) == 0
|
assert len(session_after.messages) == 0
|
||||||
assert "cli:test" not in loop.auto_compact._summaries
|
assert "cli:test" not in loop.auto_compact._summaries
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_compact_respects_last_consolidated(self, tmp_path):
|
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())
|
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
|
||||||
|
|
||||||
assert len(archived_messages) == 10
|
assert len(archived_messages) == 10
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
|
|
||||||
class TestAutoCompactIdleDetection:
|
class TestAutoCompactIdleDetection:
|
||||||
@@ -479,7 +474,7 @@ class TestAutoCompactIdleDetection:
|
|||||||
|
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert any(m["content"] == "old message" for m in session_after.messages)
|
assert any(m["content"] == "old message" for m in session_after.messages)
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_compact_triggers_on_idle(self, tmp_path):
|
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))
|
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)
|
assert any(m["content"] == "new msg" for m in session_after.messages)
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_no_auto_compact_when_active(self, tmp_path):
|
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")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert any(m["content"] == "recent message" for m in session_after.messages)
|
assert any(m["content"] == "recent message" for m in session_after.messages)
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_compact_does_not_affect_priority_commands(self, tmp_path):
|
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 should be untouched since priority commands skip _process_message
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert any(m["content"] == "old message" for m in session_after.messages)
|
assert any(m["content"] == "old message" for m in session_after.messages)
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_compact_with_slash_new(self, tmp_path):
|
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")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(session_after.messages) == 0
|
assert len(session_after.messages) == 0
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_shortcut_command_persisted_with_command_flag(self, tmp_path):
|
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]["role"] == "assistant"
|
||||||
assert session_after.messages[1].get("_command") is True
|
assert session_after.messages[1].get("_command") is True
|
||||||
assert AgentLoop._PENDING_USER_TURN_KEY not in session_after.metadata
|
assert AgentLoop._PENDING_USER_TURN_KEY not in session_after.metadata
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_shortcut_command_excluded_from_get_history(self, tmp_path):
|
async def test_shortcut_command_excluded_from_get_history(self, tmp_path):
|
||||||
@@ -602,7 +597,7 @@ class TestAutoCompactIdleDetection:
|
|||||||
assert len(history) == 2
|
assert len(history) == 2
|
||||||
assert all(m["content"] != "/help" for m in history)
|
assert all(m["content"] != "/help" for m in history)
|
||||||
assert all(m["content"] != "help text" for m in history)
|
assert all(m["content"] != "help text" for m in history)
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
|
|
||||||
class TestAutoCompactSystemMessages:
|
class TestAutoCompactSystemMessages:
|
||||||
@@ -633,7 +628,7 @@ class TestAutoCompactSystemMessages:
|
|||||||
m["content"] == "old user 0"
|
m["content"] == "old user 0"
|
||||||
for m in session_after.get_history(max_messages=len(session_after.messages))
|
for m in session_after.get_history(max_messages=len(session_after.messages))
|
||||||
)
|
)
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
|
|
||||||
class TestAutoCompactEdgeCases:
|
class TestAutoCompactEdgeCases:
|
||||||
@@ -661,7 +656,7 @@ class TestAutoCompactEdgeCases:
|
|||||||
# "(nothing)" summary should not be stored
|
# "(nothing)" summary should not be stored
|
||||||
assert "cli:test" not in loop.auto_compact._summaries
|
assert "cli:test" not in loop.auto_compact._summaries
|
||||||
|
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_compact_archive_failure_preserves_raw_history(self, tmp_path):
|
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
|
loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
||||||
)
|
)
|
||||||
|
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_compact_preserves_runtime_checkpoint_before_check(self, tmp_path):
|
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"] == "previous message" for m in session_after.messages)
|
||||||
assert any(m["content"] == "interrupted response" 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:
|
class TestAutoCompactIntegration:
|
||||||
@@ -784,7 +779,7 @@ class TestAutoCompactIntegration:
|
|||||||
# The new message should be processed (response exists)
|
# The new message should be processed (response exists)
|
||||||
assert response is not None
|
assert response is not None
|
||||||
|
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runtime_context_markers_not_persisted_for_multi_paragraph_turn(self, tmp_path):
|
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", ""))
|
content = str(persisted.get("content", ""))
|
||||||
assert "[Runtime Context" not in content
|
assert "[Runtime Context" not in content
|
||||||
assert "[/Runtime Context]" not in content
|
assert "[/Runtime Context]" not in content
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
|
|
||||||
class TestProactiveAutoCompact:
|
class TestProactiveAutoCompact:
|
||||||
@@ -875,7 +870,7 @@ class TestProactiveAutoCompact:
|
|||||||
|
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(session_after.messages) == 1
|
assert len(session_after.messages) == 1
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_proactive_archive_on_idle_tick(self, tmp_path):
|
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")
|
entry = loop.auto_compact._summaries.get("cli:test")
|
||||||
assert entry is not None
|
assert entry is not None
|
||||||
assert entry[0] == "User chatted about old things."
|
assert entry[0] == "User chatted about old things."
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_proactive_archive_skips_dream_sessions(self, tmp_path):
|
async def test_proactive_archive_skips_dream_sessions(self, tmp_path):
|
||||||
@@ -923,7 +918,7 @@ class TestProactiveAutoCompact:
|
|||||||
assert _fake_compact.state["count"] == 0
|
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._archiving
|
||||||
assert "dream:20260602-155256" not in loop.auto_compact._summaries
|
assert "dream:20260602-155256" not in loop.auto_compact._summaries
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_no_proactive_archive_when_active(self, tmp_path):
|
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")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(session_after.messages) == 1
|
assert len(session_after.messages) == 1
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_no_duplicate_archive(self, tmp_path):
|
async def test_no_duplicate_archive(self, tmp_path):
|
||||||
@@ -973,7 +968,7 @@ class TestProactiveAutoCompact:
|
|||||||
# Clean up
|
# Clean up
|
||||||
block_forever.set()
|
block_forever.set()
|
||||||
await _drain_background_tasks(loop)
|
await _drain_background_tasks(loop)
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_proactive_archive_error_does_not_block(self, tmp_path):
|
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)
|
# Key should be removed from _archiving (finally block)
|
||||||
assert "cli:test" not in loop.auto_compact._archiving
|
assert "cli:test" not in loop.auto_compact._archiving
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_proactive_archive_skips_empty_sessions(self, tmp_path):
|
async def test_proactive_archive_skips_empty_sessions(self, tmp_path):
|
||||||
@@ -1010,7 +1005,7 @@ class TestProactiveAutoCompact:
|
|||||||
|
|
||||||
# Empty session should not produce a summary
|
# Empty session should not produce a summary
|
||||||
assert "cli:test" not in loop.auto_compact._summaries
|
assert "cli:test" not in loop.auto_compact._summaries
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_skip_expired_session_with_active_agent_task(self, tmp_path):
|
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")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(session_after.messages) == 12 # All messages preserved
|
assert len(session_after.messages) == 12 # All messages preserved
|
||||||
|
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_archive_after_active_task_completes(self, tmp_path):
|
async def test_archive_after_active_task_completes(self, tmp_path):
|
||||||
@@ -1052,7 +1047,7 @@ class TestProactiveAutoCompact:
|
|||||||
# Second tick: task completed, should archive
|
# Second tick: task completed, should archive
|
||||||
await self._run_check_expired(loop)
|
await self._run_check_expired(loop)
|
||||||
assert _fake_compact.state["count"] == 1
|
assert _fake_compact.state["count"] == 1
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_partial_active_set_only_archives_inactive_expired(self, tmp_path):
|
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
|
assert len(s2_after.messages) == 12 # Preserved
|
||||||
s3_after = loop.sessions.get_or_create("cli:recent")
|
s3_after = loop.sessions.get_or_create("cli:recent")
|
||||||
assert len(s3_after.messages) == 1 # Preserved
|
assert len(s3_after.messages) == 1 # Preserved
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_no_reschedule_after_successful_archive(self, tmp_path):
|
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.
|
# Second tick: should NOT re-schedule because the session has no removable tail.
|
||||||
await self._run_check_expired(loop)
|
await self._run_check_expired(loop)
|
||||||
assert _fake_compact.state["count"] == 1 # Still 1, not re-scheduled
|
assert _fake_compact.state["count"] == 1 # Still 1, not re-scheduled
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_empty_session_does_not_schedule_idle_compact(self, tmp_path):
|
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)
|
await self._run_check_expired(loop)
|
||||||
assert _fake_compact.state["count"] == 0
|
assert _fake_compact.state["count"] == 0
|
||||||
assert "cli:test" not in loop.auto_compact._summaries
|
assert "cli:test" not in loop.auto_compact._summaries
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_session_can_be_compacted_again_after_new_messages(self, tmp_path):
|
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
|
# Second compact cycle should succeed
|
||||||
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
|
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
|
||||||
assert _fake_compact.state["count"] == 2
|
assert _fake_compact.state["count"] == 2
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
|
|
||||||
class TestSummaryPersistence:
|
class TestSummaryPersistence:
|
||||||
@@ -1187,7 +1182,7 @@ class TestSummaryPersistence:
|
|||||||
assert meta is not None
|
assert meta is not None
|
||||||
assert meta["text"] == "User said hello."
|
assert meta["text"] == "User said hello."
|
||||||
assert "last_active" in meta
|
assert "last_active" in meta
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_summary_recovered_after_restart(self, tmp_path):
|
async def test_summary_recovered_after_restart(self, tmp_path):
|
||||||
@@ -1223,7 +1218,7 @@ class TestSummaryPersistence:
|
|||||||
assert "Previous conversation summary" in summary
|
assert "Previous conversation summary" in summary
|
||||||
# _last_summary persists in metadata for restart survival.
|
# _last_summary persists in metadata for restart survival.
|
||||||
assert "_last_summary" in reloaded.metadata
|
assert "_last_summary" in reloaded.metadata
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_metadata_persists_for_restart(self, tmp_path):
|
async def test_metadata_persists_for_restart(self, tmp_path):
|
||||||
@@ -1251,7 +1246,7 @@ class TestSummaryPersistence:
|
|||||||
assert "Summary." in summary2
|
assert "Summary." in summary2
|
||||||
# _last_summary persists in metadata for restart survival.
|
# _last_summary persists in metadata for restart survival.
|
||||||
assert "_last_summary" in reloaded.metadata
|
assert "_last_summary" in reloaded.metadata
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_metadata_cleanup_on_inmemory_path(self, tmp_path):
|
async def test_metadata_cleanup_on_inmemory_path(self, tmp_path):
|
||||||
@@ -1277,7 +1272,7 @@ class TestSummaryPersistence:
|
|||||||
assert summary is not None
|
assert summary is not None
|
||||||
# _last_summary persists in metadata for restart survival.
|
# _last_summary persists in metadata for restart survival.
|
||||||
assert "_last_summary" in reloaded.metadata
|
assert "_last_summary" in reloaded.metadata
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_new_summary_overrides_old(self, tmp_path):
|
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")
|
_, summary2 = loop.auto_compact.prepare_session(reloaded, "cli:test")
|
||||||
assert summary2 is not None
|
assert summary2 is not None
|
||||||
assert "Second summary." in summary2
|
assert "Second summary." in summary2
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_new_command_clears_last_summary(self, tmp_path):
|
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
|
# After /new, metadata should no longer contain _last_summary
|
||||||
fresh = loop.sessions.get_or_create("cli:test")
|
fresh = loop.sessions.get_or_create("cli:test")
|
||||||
assert "_last_summary" not in fresh.metadata
|
assert "_last_summary" not in fresh.metadata
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|||||||
@@ -538,7 +538,7 @@ class TestNewCommandArchival:
|
|||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(session_after.messages) == 0
|
assert len(session_after.messages) == 0
|
||||||
|
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
assert call_count == 1
|
assert call_count == 1
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -572,7 +572,7 @@ class TestNewCommandArchival:
|
|||||||
assert response is not None
|
assert response is not None
|
||||||
assert "new session started" in response.content.lower()
|
assert "new session started" in response.content.lower()
|
||||||
|
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
assert archived_count == 3
|
assert archived_count == 3
|
||||||
assert archived_session_key == "cli:test"
|
assert archived_session_key == "cli:test"
|
||||||
|
|
||||||
@@ -603,8 +603,8 @@ class TestNewCommandArchival:
|
|||||||
assert loop.sessions.get_or_create("cli:test").messages == []
|
assert loop.sessions.get_or_create("cli:test").messages == []
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_aclose_drains_background_tasks(self, tmp_path: Path) -> None:
|
async def test_close_mcp_drains_background_tasks(self, tmp_path: Path) -> None:
|
||||||
"""aclose waits for background tasks to complete."""
|
"""close_mcp waits for background tasks to complete."""
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
|
|
||||||
loop = self._make_loop(tmp_path)
|
loop = self._make_loop(tmp_path)
|
||||||
@@ -632,5 +632,5 @@ class TestNewCommandArchival:
|
|||||||
|
|
||||||
assert not archived.is_set()
|
assert not archived.is_set()
|
||||||
release_archive.set()
|
release_archive.set()
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
assert archived.is_set()
|
assert archived.is_set()
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from nanobot.providers.base import ToolCallRequest
|
from nanobot.providers.base import ToolCallRequest
|
||||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||||
from nanobot.providers.registry import ProviderSpec
|
|
||||||
|
|
||||||
GEMINI_EXTRA = {"google": {"thought_signature": "sig-abc-123"}}
|
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)
|
sanitized = provider._sanitize_messages(messages)
|
||||||
|
|
||||||
assert sanitized[1]["tool_calls"][0]["extra_content"] == GEMINI_EXTRA
|
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"
|
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ async def test_process_direct_websocket_clears_run_status(tmp_path) -> None:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_direct_reuses_existing_session_lock(tmp_path) -> None:
|
async def test_process_direct_reuses_existing_session_lock(tmp_path) -> None:
|
||||||
loop = _make_loop(tmp_path)
|
loop = _make_loop(tmp_path)
|
||||||
|
loop._connect_mcp = AsyncMock()
|
||||||
session_key = "api:fixed"
|
session_key = "api:fixed"
|
||||||
lock = loop._session_locks.setdefault(session_key, asyncio.Lock())
|
lock = loop._session_locks.setdefault(session_key, asyncio.Lock())
|
||||||
await lock.acquire()
|
await lock.acquire()
|
||||||
|
|||||||
@@ -1519,11 +1519,13 @@ async def test_run_agent_loop_goal_continue_message_reads_latest_metadata(
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_direct_rejects_reserved_system_channel(tmp_path: Path) -> None:
|
async def test_process_direct_rejects_reserved_system_channel(tmp_path: Path) -> None:
|
||||||
loop = _make_full_loop(tmp_path)
|
loop = _make_full_loop(tmp_path)
|
||||||
|
loop._connect_mcp = AsyncMock() # type: ignore[method-assign]
|
||||||
loop._process_message = AsyncMock(return_value=None) # type: ignore[method-assign]
|
loop._process_message = AsyncMock(return_value=None) # type: ignore[method-assign]
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="reserved for internal messages"):
|
with pytest.raises(ValueError, match="reserved for internal messages"):
|
||||||
await loop.process_direct("external input", channel="system")
|
await loop.process_direct("external input", channel="system")
|
||||||
|
|
||||||
|
loop._connect_mcp.assert_not_awaited()
|
||||||
loop._process_message.assert_not_awaited()
|
loop._process_message.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@@ -1532,6 +1534,7 @@ async def test_process_direct_skip_user_persist_does_not_save_retry_user(
|
|||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
loop = _make_full_loop(tmp_path)
|
loop = _make_full_loop(tmp_path)
|
||||||
|
loop._connect_mcp = AsyncMock()
|
||||||
session = loop.sessions.get_or_create("api:default")
|
session = loop.sessions.get_or_create("api:default")
|
||||||
session.add_message("user", "hello")
|
session.add_message("user", "hello")
|
||||||
session.add_message("assistant", "previous empty-response attempt")
|
session.add_message("assistant", "previous empty-response attempt")
|
||||||
|
|||||||
@@ -123,7 +123,8 @@ async def test_session_discard_control_cancels_active_turn(tmp_path, monkeypatch
|
|||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
loop.provider.chat_with_retry = AsyncMock(side_effect=block_provider)
|
loop.provider.chat_with_retry = AsyncMock(side_effect=block_provider)
|
||||||
monkeypatch.setattr(loop, "aclose", AsyncMock())
|
monkeypatch.setattr(loop, "_connect_mcp", AsyncMock())
|
||||||
|
monkeypatch.setattr(loop, "close_mcp", AsyncMock())
|
||||||
terminate_exec_sessions = AsyncMock(return_value=1)
|
terminate_exec_sessions = AsyncMock(return_value=1)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
loop._exec_session_manager,
|
loop._exec_session_manager,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import inspect
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
@@ -12,10 +11,8 @@ from nanobot.agent.tools.context import (
|
|||||||
current_request_context,
|
current_request_context,
|
||||||
reset_request_context,
|
reset_request_context,
|
||||||
)
|
)
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.config.schema import Config
|
|
||||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||||
from nanobot.session.turn_continuation import INTERNAL_CONTINUATION_META
|
from nanobot.session.turn_continuation import INTERNAL_CONTINUATION_META
|
||||||
|
|
||||||
@@ -59,51 +56,6 @@ class _Tools:
|
|||||||
return (self.tool, arguments, None) if name == "cron" else (None, arguments, None)
|
return (self.tool, arguments, None) if name == "cron" else (None, arguments, None)
|
||||||
|
|
||||||
|
|
||||||
def test_loop_registers_default_tools_in_injected_registry(tmp_path: Path) -> None:
|
|
||||||
provider = MagicMock()
|
|
||||||
provider.get_default_model.return_value = "test-model"
|
|
||||||
registry = ToolRegistry()
|
|
||||||
|
|
||||||
loop = AgentLoop(
|
|
||||||
bus=MessageBus(),
|
|
||||||
provider=provider,
|
|
||||||
workspace=tmp_path,
|
|
||||||
tool_registry=registry,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert loop.tools is registry
|
|
||||||
assert registry.has("read_file")
|
|
||||||
|
|
||||||
|
|
||||||
def _config_for_loop(tmp_path: Path) -> Config:
|
|
||||||
return Config.model_validate({"agents": {"defaults": {"workspace": str(tmp_path)}}})
|
|
||||||
|
|
||||||
|
|
||||||
def _provider_for_loop() -> MagicMock:
|
|
||||||
provider = MagicMock()
|
|
||||||
provider.get_default_model.return_value = "test-model"
|
|
||||||
return provider
|
|
||||||
|
|
||||||
|
|
||||||
def test_loop_from_config_requires_caller_owned_registry(tmp_path: Path) -> None:
|
|
||||||
signature = inspect.signature(AgentLoop.from_config)
|
|
||||||
|
|
||||||
with pytest.raises(TypeError, match="tool_registry"):
|
|
||||||
signature.bind(_config_for_loop(tmp_path))
|
|
||||||
|
|
||||||
|
|
||||||
def test_loop_from_config_uses_caller_owned_registry(tmp_path: Path) -> None:
|
|
||||||
registry = ToolRegistry()
|
|
||||||
loop = AgentLoop.from_config(
|
|
||||||
_config_for_loop(tmp_path),
|
|
||||||
tool_registry=registry,
|
|
||||||
provider=_provider_for_loop(),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert loop.tools is registry
|
|
||||||
assert loop.tools.has("read_file")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_loop_binds_request_context_for_tool_execution(tmp_path: Path) -> None:
|
async def test_loop_binds_request_context_for_tool_execution(tmp_path: Path) -> None:
|
||||||
provider = MagicMock()
|
provider = MagicMock()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for the application-owned MCP provider lifecycle."""
|
"""Tests for MCP connection lifecycle in AgentLoop."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ import asyncio
|
|||||||
from contextlib import AsyncExitStack
|
from contextlib import AsyncExitStack
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
import pytest
|
import pytest
|
||||||
@@ -15,10 +15,11 @@ from mcp.shared.exceptions import McpError
|
|||||||
from mcp.shared.message import SessionMessage
|
from mcp.shared.message import SessionMessage
|
||||||
from mcp.types import ErrorData
|
from mcp.types import ErrorData
|
||||||
|
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.agent.tools import mcp as mcp_runtime
|
from nanobot.agent.tools import mcp as mcp_runtime
|
||||||
from nanobot.agent.tools.base import Tool
|
from nanobot.agent.tools.base import Tool
|
||||||
from nanobot.agent.tools.mcp import MCPProvider, MCPResourceWrapper, MCPToolWrapper
|
from nanobot.agent.tools.mcp import MCPResourceWrapper, MCPToolWrapper
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.config.loader import load_config, save_config
|
from nanobot.config.loader import load_config, save_config
|
||||||
from nanobot.config.schema import MCPServerConfig
|
from nanobot.config.schema import MCPServerConfig
|
||||||
|
|
||||||
@@ -73,20 +74,18 @@ class _FakeMcpTool(Tool):
|
|||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
def _stdio_server(command: str = "test-mcp") -> MCPServerConfig:
|
def _make_loop(tmp_path, *, mcp_servers: dict | None = None) -> AgentLoop:
|
||||||
return MCPServerConfig(type="stdio", command=command)
|
bus = MessageBus()
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_default_model.return_value = "test-model"
|
||||||
def _make_provider(
|
provider.generation.max_tokens = 4096
|
||||||
*,
|
return AgentLoop(
|
||||||
mcp_servers: dict[str, MCPServerConfig] | None = None,
|
bus=bus,
|
||||||
) -> tuple[MCPProvider, ToolRegistry]:
|
provider=provider,
|
||||||
registry = ToolRegistry()
|
workspace=tmp_path,
|
||||||
provider = MCPProvider(
|
model="test-model",
|
||||||
mcp_servers if mcp_servers is not None else {"test": _stdio_server()},
|
mcp_servers=mcp_servers or {"test": object()},
|
||||||
registry,
|
|
||||||
)
|
)
|
||||||
return provider, registry
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -141,7 +140,7 @@ async def test_owned_mcp_connection_closes_from_its_owner_task():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_connect_mcp_retries_when_no_servers_connect(tmp_path, monkeypatch: pytest.MonkeyPatch):
|
async def test_connect_mcp_retries_when_no_servers_connect(tmp_path, monkeypatch: pytest.MonkeyPatch):
|
||||||
provider, _registry = _make_provider()
|
loop = _make_loop(tmp_path)
|
||||||
attempts = 0
|
attempts = 0
|
||||||
|
|
||||||
async def _fake_connect(_servers, _registry):
|
async def _fake_connect(_servers, _registry):
|
||||||
@@ -151,12 +150,12 @@ async def test_connect_mcp_retries_when_no_servers_connect(tmp_path, monkeypatch
|
|||||||
|
|
||||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
||||||
|
|
||||||
await provider.connect()
|
await loop._connect_mcp()
|
||||||
await provider.connect()
|
await loop._connect_mcp()
|
||||||
|
|
||||||
assert attempts == 2
|
assert attempts == 2
|
||||||
assert provider.connected_server_names == set()
|
assert loop._mcp_stacks == {}
|
||||||
assert provider.runtime_status() == {"test": "failed"}
|
assert loop.mcp_runtime_status() == {"test": "failed"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -169,7 +168,7 @@ async def test_connect_mcp_does_not_report_failure_before_oauth_authorization(
|
|||||||
auth="oauth",
|
auth="oauth",
|
||||||
url="https://mcp.example.com/mcp",
|
url="https://mcp.example.com/mcp",
|
||||||
)
|
)
|
||||||
provider, _registry = _make_provider(mcp_servers={"oauth-app": cfg})
|
loop = _make_loop(tmp_path, mcp_servers={"oauth-app": cfg})
|
||||||
connect = AsyncMock()
|
connect = AsyncMock()
|
||||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", connect)
|
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", connect)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -177,20 +176,19 @@ async def test_connect_mcp_does_not_report_failure_before_oauth_authorization(
|
|||||||
lambda _name, _url: False,
|
lambda _name, _url: False,
|
||||||
)
|
)
|
||||||
|
|
||||||
await provider.connect()
|
await loop._connect_mcp()
|
||||||
|
|
||||||
connect.assert_not_awaited()
|
connect.assert_not_awaited()
|
||||||
assert provider.runtime_status() == {}
|
assert loop.mcp_runtime_status() == {}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mcp_provider_closes_connections_independently_from_agent_loop(
|
async def test_agent_loop_run_closes_mcp_from_connection_owner_task(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
):
|
):
|
||||||
provider, registry = _make_provider(
|
loop = _make_loop(tmp_path, mcp_servers={"playwright": object()})
|
||||||
mcp_servers={"playwright": _stdio_server("playwright")}
|
connected = asyncio.Event()
|
||||||
)
|
|
||||||
owner_tasks: list[asyncio.Task | None] = []
|
owner_tasks: list[asyncio.Task | None] = []
|
||||||
closed_tasks: list[asyncio.Task | None] = []
|
closed_tasks: list[asyncio.Task | None] = []
|
||||||
|
|
||||||
@@ -205,38 +203,40 @@ async def test_mcp_provider_closes_connections_independently_from_agent_loop(
|
|||||||
|
|
||||||
async def _fake_connect(servers, _registry):
|
async def _fake_connect(servers, _registry):
|
||||||
stacks = {name: _OwnerCheckedStack() for name in servers}
|
stacks = {name: _OwnerCheckedStack() for name in servers}
|
||||||
|
connected.set()
|
||||||
return stacks
|
return stacks
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
||||||
|
|
||||||
await provider.connect()
|
task = asyncio.create_task(loop.run())
|
||||||
registry.register(_FakeMcpTool("mcp_playwright_search"))
|
await asyncio.wait_for(connected.wait(), timeout=1)
|
||||||
await provider.aclose()
|
loop.stop()
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(task, return_exceptions=True)
|
||||||
|
|
||||||
assert owner_tasks
|
assert owner_tasks
|
||||||
assert closed_tasks == owner_tasks
|
assert closed_tasks == owner_tasks
|
||||||
assert provider.connected_server_names == set()
|
assert loop._mcp_stacks == {}
|
||||||
assert registry.get("mcp_playwright_search") is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_close_server_ignores_server_cancelled_error(tmp_path):
|
async def test_close_server_ignores_server_cancelled_error(tmp_path):
|
||||||
provider, _registry = _make_provider()
|
loop = _make_loop(tmp_path)
|
||||||
|
|
||||||
class _ServerCancelledStack:
|
class _ServerCancelledStack:
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
raise asyncio.CancelledError()
|
raise asyncio.CancelledError()
|
||||||
|
|
||||||
provider._connections = {"test": _ServerCancelledStack()}
|
loop._mcp_stacks = {"test": _ServerCancelledStack()}
|
||||||
|
|
||||||
await provider._close_server("test")
|
await mcp_runtime._close_server(loop, "test")
|
||||||
|
|
||||||
assert provider.connected_server_names == set()
|
assert loop._mcp_stacks == {}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_provider_close_continues_after_server_cancelled_error(tmp_path):
|
async def test_close_mcp_servers_continues_after_server_cancelled_error(tmp_path):
|
||||||
provider, _registry = _make_provider()
|
loop = _make_loop(tmp_path)
|
||||||
closed: list[str] = []
|
closed: list[str] = []
|
||||||
|
|
||||||
class _ServerCancelledStack:
|
class _ServerCancelledStack:
|
||||||
@@ -247,53 +247,21 @@ async def test_provider_close_continues_after_server_cancelled_error(tmp_path):
|
|||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
closed.append("second")
|
closed.append("second")
|
||||||
|
|
||||||
provider._connections = {
|
loop._mcp_stacks = {
|
||||||
"first": _ServerCancelledStack(),
|
"first": _ServerCancelledStack(),
|
||||||
"second": _TrackedStack(),
|
"second": _TrackedStack(),
|
||||||
}
|
}
|
||||||
|
|
||||||
await provider.aclose()
|
await mcp_runtime.close_mcp_servers(loop)
|
||||||
|
|
||||||
assert closed == ["second"]
|
assert closed == ["second"]
|
||||||
assert provider.connected_server_names == set()
|
assert loop._mcp_stacks == {}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_provider_close_finishes_other_connections_before_propagating_cancellation(
|
|
||||||
tmp_path,
|
|
||||||
):
|
|
||||||
provider, _registry = _make_provider()
|
|
||||||
started = asyncio.Event()
|
|
||||||
closed: list[str] = []
|
|
||||||
|
|
||||||
class _BlockingStack:
|
|
||||||
async def aclose(self) -> None:
|
|
||||||
started.set()
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
|
|
||||||
class _TrackedStack:
|
|
||||||
async def aclose(self) -> None:
|
|
||||||
closed.append("second")
|
|
||||||
|
|
||||||
provider._connections = {
|
|
||||||
"first": _BlockingStack(),
|
|
||||||
"second": _TrackedStack(),
|
|
||||||
}
|
|
||||||
task = asyncio.create_task(provider.aclose())
|
|
||||||
await asyncio.wait_for(started.wait(), timeout=1)
|
|
||||||
|
|
||||||
task.cancel()
|
|
||||||
with pytest.raises(asyncio.CancelledError):
|
|
||||||
await task
|
|
||||||
|
|
||||||
assert closed == ["second"]
|
|
||||||
assert provider.connected_server_names == set()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize("close_all", [False, True], ids=["single", "all"])
|
@pytest.mark.parametrize("close_all", [False, True], ids=["single", "all"])
|
||||||
async def test_mcp_cleanup_re_raises_external_cancellation(tmp_path, close_all: bool):
|
async def test_mcp_cleanup_re_raises_external_cancellation(tmp_path, close_all: bool):
|
||||||
provider, _registry = _make_provider()
|
loop = _make_loop(tmp_path)
|
||||||
started = asyncio.Event()
|
started = asyncio.Event()
|
||||||
|
|
||||||
class _BlockingStack:
|
class _BlockingStack:
|
||||||
@@ -301,12 +269,12 @@ async def test_mcp_cleanup_re_raises_external_cancellation(tmp_path, close_all:
|
|||||||
started.set()
|
started.set()
|
||||||
await asyncio.Event().wait()
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
provider._connections = {"test": _BlockingStack()}
|
loop._mcp_stacks = {"test": _BlockingStack()}
|
||||||
|
|
||||||
if close_all:
|
if close_all:
|
||||||
task = asyncio.create_task(provider.aclose())
|
task = asyncio.create_task(mcp_runtime.close_mcp_servers(loop))
|
||||||
else:
|
else:
|
||||||
task = asyncio.create_task(provider._close_server("test"))
|
task = asyncio.create_task(mcp_runtime._close_server(loop, "test"))
|
||||||
await asyncio.wait_for(started.wait(), timeout=1)
|
await asyncio.wait_for(started.wait(), timeout=1)
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
|
||||||
@@ -344,38 +312,41 @@ async def test_reload_mcp_servers_adds_and_removes_tools_without_restart(
|
|||||||
return stacks
|
return stacks
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
||||||
provider, registry = _make_provider(mcp_servers={})
|
loop = _make_loop(tmp_path, mcp_servers={})
|
||||||
|
|
||||||
added = await provider.reload()
|
added = await mcp_runtime.reload_servers(loop, loop.tools)
|
||||||
|
|
||||||
assert added["ok"] is True
|
assert added["ok"] is True
|
||||||
assert added["added"] == ["browserbase"]
|
assert added["added"] == ["browserbase"]
|
||||||
assert registry.has("mcp_browserbase_navigate")
|
assert loop.tools.has("mcp_browserbase_navigate")
|
||||||
assert provider.connected_server_names == {"browserbase"}
|
assert "browserbase" in loop._mcp_stacks
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
del config.tools.mcp_servers["browserbase"]
|
del config.tools.mcp_servers["browserbase"]
|
||||||
save_config(config)
|
save_config(config)
|
||||||
|
|
||||||
removed = await provider.reload()
|
removed = await mcp_runtime.reload_servers(loop, loop.tools)
|
||||||
|
|
||||||
assert removed["ok"] is True
|
assert removed["ok"] is True
|
||||||
assert removed["removed"] == ["browserbase"]
|
assert removed["removed"] == ["browserbase"]
|
||||||
assert not registry.has("mcp_browserbase_navigate")
|
assert not loop.tools.has("mcp_browserbase_navigate")
|
||||||
assert provider.connected_server_names == set()
|
assert "browserbase" not in loop._mcp_stacks
|
||||||
assert closed == ["browserbase"]
|
assert closed == ["browserbase"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reload_is_a_direct_provider_operation_without_an_agent_loop(
|
async def test_request_mcp_reload_reaches_runtime_control_without_restart(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
):
|
):
|
||||||
browserbase = MCPServerConfig(
|
config_path = tmp_path / "config.json"
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
config = load_config()
|
||||||
|
config.tools.mcp_servers["browserbase"] = MCPServerConfig(
|
||||||
type="stdio",
|
type="stdio",
|
||||||
command="browserbase-mcp",
|
command="browserbase-mcp",
|
||||||
)
|
)
|
||||||
configured: dict[str, MCPServerConfig] = {"browserbase": browserbase}
|
save_config(config)
|
||||||
|
|
||||||
closed: list[str] = []
|
closed: list[str] = []
|
||||||
|
|
||||||
@@ -393,68 +364,37 @@ async def test_reload_is_a_direct_provider_operation_without_an_agent_loop(
|
|||||||
return stacks
|
return stacks
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
||||||
registry = ToolRegistry()
|
loop = _make_loop(tmp_path, mcp_servers={})
|
||||||
provider = MCPProvider({}, registry, server_loader=lambda: configured)
|
|
||||||
|
|
||||||
result = await provider.reload()
|
async def _handle_one_runtime_control() -> None:
|
||||||
|
msg = await loop.bus.consume_inbound()
|
||||||
|
handled = await mcp_runtime.handle_runtime_control(loop, msg, loop.tools)
|
||||||
|
assert handled is True
|
||||||
|
|
||||||
|
consumer = asyncio.create_task(_handle_one_runtime_control())
|
||||||
|
result = await mcp_runtime.request_mcp_reload(loop.bus, timeout=2.0)
|
||||||
|
await consumer
|
||||||
|
|
||||||
assert result["ok"] is True
|
assert result["ok"] is True
|
||||||
assert result["added"] == ["browserbase"]
|
assert result["added"] == ["browserbase"]
|
||||||
assert result["requires_restart"] is False
|
assert result["requires_restart"] is False
|
||||||
assert registry.has("mcp_browserbase_navigate")
|
assert loop.tools.has("mcp_browserbase_navigate")
|
||||||
|
|
||||||
configured = {}
|
config = load_config()
|
||||||
|
del config.tools.mcp_servers["browserbase"]
|
||||||
|
save_config(config)
|
||||||
|
|
||||||
result = await provider.reload()
|
consumer = asyncio.create_task(_handle_one_runtime_control())
|
||||||
|
result = await mcp_runtime.request_mcp_reload(loop.bus, timeout=2.0)
|
||||||
|
await consumer
|
||||||
|
|
||||||
assert result["ok"] is True
|
assert result["ok"] is True
|
||||||
assert result["removed"] == ["browserbase"]
|
assert result["removed"] == ["browserbase"]
|
||||||
assert result["requires_restart"] is False
|
assert result["requires_restart"] is False
|
||||||
assert not registry.has("mcp_browserbase_navigate")
|
assert not loop.tools.has("mcp_browserbase_navigate")
|
||||||
assert closed == ["browserbase"]
|
assert closed == ["browserbase"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_reload_timeout_marks_attempted_server_failed_and_allows_retry(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
):
|
|
||||||
server = _stdio_server("slow-mcp")
|
|
||||||
started = asyncio.Event()
|
|
||||||
attempts = 0
|
|
||||||
|
|
||||||
async def _fake_connect(servers, _registry):
|
|
||||||
nonlocal attempts
|
|
||||||
attempts += 1
|
|
||||||
if attempts == 1:
|
|
||||||
started.set()
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
stack = AsyncExitStack()
|
|
||||||
await stack.__aenter__()
|
|
||||||
return {name: stack for name in servers}
|
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
|
||||||
provider = MCPProvider(
|
|
||||||
{"test": server},
|
|
||||||
ToolRegistry(),
|
|
||||||
server_loader=lambda: {"test": server},
|
|
||||||
)
|
|
||||||
|
|
||||||
reload_task = asyncio.create_task(provider.reload())
|
|
||||||
await asyncio.wait_for(started.wait(), timeout=1.0)
|
|
||||||
with pytest.raises(asyncio.TimeoutError):
|
|
||||||
await asyncio.wait_for(reload_task, timeout=0.01)
|
|
||||||
|
|
||||||
assert provider.connected_server_names == set()
|
|
||||||
assert provider.runtime_status() == {"test": "failed"}
|
|
||||||
|
|
||||||
result = await provider.reload()
|
|
||||||
|
|
||||||
assert result["ok"] is True
|
|
||||||
assert provider.connected_server_names == {"test"}
|
|
||||||
assert provider.runtime_status() == {"test": "connected"}
|
|
||||||
await provider.aclose()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reload_mcp_servers_retries_configured_server_without_live_stack(
|
async def test_reload_mcp_servers_retries_configured_server_without_live_stack(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
@@ -479,18 +419,16 @@ async def test_reload_mcp_servers_retries_configured_server_without_live_stack(
|
|||||||
return stacks
|
return stacks
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
||||||
provider, registry = _make_provider(
|
loop = _make_loop(tmp_path, mcp_servers={"browserbase": config.tools.mcp_servers["browserbase"]})
|
||||||
mcp_servers={"browserbase": config.tools.mcp_servers["browserbase"]}
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await provider.reload()
|
result = await mcp_runtime.reload_servers(loop, loop.tools)
|
||||||
|
|
||||||
assert result["ok"] is True
|
assert result["ok"] is True
|
||||||
assert result["added"] == []
|
assert result["added"] == []
|
||||||
assert result["changed"] == []
|
assert result["changed"] == []
|
||||||
assert result["retried"] == ["browserbase"]
|
assert result["retried"] == ["browserbase"]
|
||||||
assert registry.has("mcp_browserbase_navigate")
|
assert loop.tools.has("mcp_browserbase_navigate")
|
||||||
await provider.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -527,16 +465,16 @@ async def test_reload_mcp_servers_skips_oauth_server_waiting_for_authorization(
|
|||||||
"nanobot.agent.tools.mcp_oauth.mcp_oauth_has_credentials",
|
"nanobot.agent.tools.mcp_oauth.mcp_oauth_has_credentials",
|
||||||
lambda name, _url: name == "linear",
|
lambda name, _url: name == "linear",
|
||||||
)
|
)
|
||||||
provider, _registry = _make_provider(mcp_servers={"notion": notion})
|
loop = _make_loop(tmp_path, mcp_servers={"notion": notion})
|
||||||
|
|
||||||
result = await provider.reload()
|
result = await mcp_runtime.reload_servers(loop, loop.tools)
|
||||||
|
|
||||||
assert attempted == ["linear"]
|
assert attempted == ["linear"]
|
||||||
assert result["ok"] is True
|
assert result["ok"] is True
|
||||||
assert result["failed"] == []
|
assert result["failed"] == []
|
||||||
assert result["retried"] == []
|
assert result["retried"] == []
|
||||||
assert result["connected"] == ["linear"]
|
assert result["connected"] == ["linear"]
|
||||||
await provider.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -544,9 +482,7 @@ async def test_mcp_tool_reconnects_after_session_terminated(
|
|||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
):
|
):
|
||||||
provider, registry = _make_provider(
|
loop = _make_loop(tmp_path, mcp_servers={"remote": object()})
|
||||||
mcp_servers={"remote": _stdio_server("remote")}
|
|
||||||
)
|
|
||||||
closed: list[str] = []
|
closed: list[str] = []
|
||||||
sessions: list[Any] = []
|
sessions: list[Any] = []
|
||||||
connect_count = 0
|
connect_count = 0
|
||||||
@@ -589,8 +525,8 @@ async def test_mcp_tool_reconnects_after_session_terminated(
|
|||||||
|
|
||||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
||||||
|
|
||||||
await provider.connect()
|
await loop._connect_mcp()
|
||||||
old_tool = registry.get("mcp_remote_quote")
|
old_tool = loop.tools.get("mcp_remote_quote")
|
||||||
assert isinstance(old_tool, MCPToolWrapper)
|
assert isinstance(old_tool, MCPToolWrapper)
|
||||||
|
|
||||||
output = await old_tool.execute(symbol="AAPL")
|
output = await old_tool.execute(symbol="AAPL")
|
||||||
@@ -600,8 +536,8 @@ async def test_mcp_tool_reconnects_after_session_terminated(
|
|||||||
assert closed == ["remote"]
|
assert closed == ["remote"]
|
||||||
assert sessions[0].call_count == 1
|
assert sessions[0].call_count == 1
|
||||||
assert sessions[1].call_count == 1
|
assert sessions[1].call_count == 1
|
||||||
assert provider.connected_server_names == {"remote"}
|
assert "remote" in loop._mcp_stacks
|
||||||
assert registry.get("mcp_remote_quote") is not old_tool
|
assert loop.tools.get("mcp_remote_quote") is not old_tool
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -609,9 +545,7 @@ async def test_mcp_reconnect_handler_uses_sanitized_server_prefix(
|
|||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
):
|
):
|
||||||
provider, registry = _make_provider(
|
loop = _make_loop(tmp_path, mcp_servers={"remote_": object()})
|
||||||
mcp_servers={"remote_": _stdio_server("remote")}
|
|
||||||
)
|
|
||||||
connect_count = 0
|
connect_count = 0
|
||||||
|
|
||||||
class _FakeSession:
|
class _FakeSession:
|
||||||
@@ -644,15 +578,15 @@ async def test_mcp_reconnect_handler_uses_sanitized_server_prefix(
|
|||||||
|
|
||||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
||||||
|
|
||||||
await provider.connect()
|
await loop._connect_mcp()
|
||||||
old_tool = registry.get("mcp_remote_quote")
|
old_tool = loop.tools.get("mcp_remote_quote")
|
||||||
assert isinstance(old_tool, MCPToolWrapper)
|
assert isinstance(old_tool, MCPToolWrapper)
|
||||||
|
|
||||||
output = await old_tool.execute()
|
output = await old_tool.execute()
|
||||||
|
|
||||||
assert output == "recovered"
|
assert output == "recovered"
|
||||||
assert connect_count == 2
|
assert connect_count == 2
|
||||||
assert registry.get("mcp_remote_quote") is not old_tool
|
assert loop.tools.get("mcp_remote_quote") is not old_tool
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -660,9 +594,7 @@ async def test_concurrent_mcp_reconnect_reuses_fresh_session(
|
|||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
):
|
):
|
||||||
provider, registry = _make_provider(
|
loop = _make_loop(tmp_path, mcp_servers={"remote": object()})
|
||||||
mcp_servers={"remote": _stdio_server("remote")}
|
|
||||||
)
|
|
||||||
closed: list[str] = []
|
closed: list[str] = []
|
||||||
connect_count = 0
|
connect_count = 0
|
||||||
|
|
||||||
@@ -706,9 +638,9 @@ async def test_concurrent_mcp_reconnect_reuses_fresh_session(
|
|||||||
|
|
||||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
||||||
|
|
||||||
await provider.connect()
|
await loop._connect_mcp()
|
||||||
old_alpha = registry.get("mcp_remote_resource_alpha")
|
old_alpha = loop.tools.get("mcp_remote_resource_alpha")
|
||||||
old_beta = registry.get("mcp_remote_resource_beta")
|
old_beta = loop.tools.get("mcp_remote_resource_beta")
|
||||||
assert isinstance(old_alpha, MCPResourceWrapper)
|
assert isinstance(old_alpha, MCPResourceWrapper)
|
||||||
assert isinstance(old_beta, MCPResourceWrapper)
|
assert isinstance(old_beta, MCPResourceWrapper)
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,15 @@ import asyncio
|
|||||||
import multiprocessing
|
import multiprocessing
|
||||||
import socket
|
import socket
|
||||||
import time
|
import time
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.agent.tools import mcp as mcp_module
|
from nanobot.agent.tools import mcp as mcp_module
|
||||||
from nanobot.agent.tools.mcp import MCPProvider, MCPToolWrapper
|
from nanobot.agent.tools.mcp import MCPToolWrapper
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.config.schema import MCPServerConfig
|
from nanobot.config.schema import MCPServerConfig
|
||||||
from nanobot.security import network as security_network
|
from nanobot.security import network as security_network
|
||||||
|
|
||||||
@@ -111,9 +113,18 @@ def mcp_server_url():
|
|||||||
process.join(timeout=2.0)
|
process.join(timeout=2.0)
|
||||||
|
|
||||||
|
|
||||||
def _make_provider(*, mcp_servers: dict) -> tuple[MCPProvider, ToolRegistry]:
|
def _make_loop(tmp_path, *, mcp_servers: dict) -> AgentLoop:
|
||||||
registry = ToolRegistry()
|
bus = MessageBus()
|
||||||
return MCPProvider(mcp_servers, registry), registry
|
provider = MagicMock()
|
||||||
|
provider.get_default_model.return_value = "test-model"
|
||||||
|
provider.generation.max_tokens = 4096
|
||||||
|
return AgentLoop(
|
||||||
|
bus=bus,
|
||||||
|
provider=provider,
|
||||||
|
workspace=tmp_path,
|
||||||
|
model="test-model",
|
||||||
|
mcp_servers=mcp_servers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -159,12 +170,12 @@ async def test_mcp_reconnect_after_session_timeout(tmp_path, mcp_server_url):
|
|||||||
tool_timeout=_TOOL_TIMEOUT_SECONDS,
|
tool_timeout=_TOOL_TIMEOUT_SECONDS,
|
||||||
enabled_tools=["*"],
|
enabled_tools=["*"],
|
||||||
)
|
)
|
||||||
provider, registry = _make_provider(mcp_servers={"repro": cfg})
|
loop = _make_loop(tmp_path, mcp_servers={"repro": cfg})
|
||||||
|
|
||||||
await asyncio.create_task(provider.connect())
|
await asyncio.create_task(loop._connect_mcp())
|
||||||
assert provider.connected_server_names == {"repro"}
|
assert "repro" in loop._mcp_stacks
|
||||||
|
|
||||||
tool = registry.get("mcp_repro_greet")
|
tool = loop.tools.get("mcp_repro_greet")
|
||||||
assert isinstance(tool, MCPToolWrapper)
|
assert isinstance(tool, MCPToolWrapper)
|
||||||
|
|
||||||
output = await asyncio.create_task(tool.execute(name="first"))
|
output = await asyncio.create_task(tool.execute(name="first"))
|
||||||
@@ -176,7 +187,7 @@ async def test_mcp_reconnect_after_session_timeout(tmp_path, mcp_server_url):
|
|||||||
output = await asyncio.create_task(tool.execute(name="second"))
|
output = await asyncio.create_task(tool.execute(name="second"))
|
||||||
assert "Hello, second" in output
|
assert "Hello, second" in output
|
||||||
|
|
||||||
await asyncio.create_task(provider.aclose())
|
await asyncio.create_task(loop.close_mcp())
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -192,10 +203,10 @@ async def test_mcp_reconnect_during_shutdown_does_not_crash(
|
|||||||
tool_timeout=_TOOL_TIMEOUT_SECONDS,
|
tool_timeout=_TOOL_TIMEOUT_SECONDS,
|
||||||
enabled_tools=["*"],
|
enabled_tools=["*"],
|
||||||
)
|
)
|
||||||
provider, registry = _make_provider(mcp_servers={"repro": cfg})
|
loop = _make_loop(tmp_path, mcp_servers={"repro": cfg})
|
||||||
|
|
||||||
await asyncio.create_task(provider.connect())
|
await asyncio.create_task(loop._connect_mcp())
|
||||||
tool = registry.get("mcp_repro_greet")
|
tool = loop.tools.get("mcp_repro_greet")
|
||||||
assert isinstance(tool, MCPToolWrapper)
|
assert isinstance(tool, MCPToolWrapper)
|
||||||
|
|
||||||
await asyncio.create_task(tool.execute(name="first"))
|
await asyncio.create_task(tool.execute(name="first"))
|
||||||
@@ -213,7 +224,7 @@ async def test_mcp_reconnect_during_shutdown_does_not_crash(
|
|||||||
monkeypatch.setattr(mcp_module, "connect_mcp_servers", gated_connect)
|
monkeypatch.setattr(mcp_module, "connect_mcp_servers", gated_connect)
|
||||||
call_task = asyncio.create_task(tool.execute(name="second"))
|
call_task = asyncio.create_task(tool.execute(name="second"))
|
||||||
await asyncio.wait_for(reconnect_started.wait(), timeout=5)
|
await asyncio.wait_for(reconnect_started.wait(), timeout=5)
|
||||||
close_task = asyncio.create_task(provider.aclose())
|
close_task = asyncio.create_task(loop.close_mcp())
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
finish_reconnect.set()
|
finish_reconnect.set()
|
||||||
|
|
||||||
@@ -234,4 +245,4 @@ async def test_mcp_reconnect_during_shutdown_does_not_crash(
|
|||||||
unhandled.append(exc)
|
unhandled.append(exc)
|
||||||
|
|
||||||
assert not unhandled, f"Unhandled exception leaked during reconnect/shutdown: {unhandled[0]}"
|
assert not unhandled, f"Unhandled exception leaked during reconnect/shutdown: {unhandled[0]}"
|
||||||
assert provider.connected_server_names == set()
|
assert loop._mcp_stacks == {}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from unittest.mock import MagicMock
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.bus.runtime_events import RuntimeModelChanged
|
from nanobot.bus.runtime_events import RuntimeModelChanged
|
||||||
from nanobot.config.errors import ConfigLoadError
|
from nanobot.config.errors import ConfigLoadError
|
||||||
@@ -313,11 +312,7 @@ def test_settings_context_window_refreshes_runtime_state(
|
|||||||
def loader(*, preset_name: str | None = None) -> ProviderSnapshot:
|
def loader(*, preset_name: str | None = None) -> ProviderSnapshot:
|
||||||
return load_provider_snapshot(config_path, preset_name=preset_name)
|
return load_provider_snapshot(config_path, preset_name=preset_name)
|
||||||
|
|
||||||
loop = AgentLoop.from_config(
|
loop = AgentLoop.from_config(config, provider_snapshot_loader=loader)
|
||||||
config,
|
|
||||||
tool_registry=ToolRegistry(),
|
|
||||||
provider_snapshot_loader=loader,
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = update_agent_settings({"context_window_tokens": ["262144"]})
|
payload = update_agent_settings({"context_window_tokens": ["262144"]})
|
||||||
loop.runtime_resolver.invalidate()
|
loop.runtime_resolver.invalidate()
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import pytest
|
|||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.agent.tools.context import RequestContext, request_context
|
from nanobot.agent.tools.context import RequestContext, request_context
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
|
||||||
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
|
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
|
||||||
from nanobot.agent.tools.self import MyTool
|
from nanobot.agent.tools.self import MyTool
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
@@ -391,7 +390,7 @@ def test_from_config_injects_default_preset(tmp_path) -> None:
|
|||||||
})
|
})
|
||||||
fake_provider = _provider("openai/gpt-4.1")
|
fake_provider = _provider("openai/gpt-4.1")
|
||||||
with patch("nanobot.providers.factory.make_provider", return_value=fake_provider):
|
with patch("nanobot.providers.factory.make_provider", return_value=fake_provider):
|
||||||
loop = AgentLoop.from_config(config, tool_registry=ToolRegistry())
|
loop = AgentLoop.from_config(config)
|
||||||
assert loop.model == "openai/gpt-4.1"
|
assert loop.model == "openai/gpt-4.1"
|
||||||
assert loop.model_preset is None
|
assert loop.model_preset is None
|
||||||
assert "default" in loop.model_presets
|
assert "default" in loop.model_presets
|
||||||
@@ -408,7 +407,7 @@ def test_from_config_static_preset_loader_does_not_enable_hot_reload(tmp_path) -
|
|||||||
})
|
})
|
||||||
fake_provider = _provider("openai/gpt-4.1")
|
fake_provider = _provider("openai/gpt-4.1")
|
||||||
with patch("nanobot.providers.factory.make_provider", return_value=fake_provider):
|
with patch("nanobot.providers.factory.make_provider", return_value=fake_provider):
|
||||||
loop = AgentLoop.from_config(config, tool_registry=ToolRegistry())
|
loop = AgentLoop.from_config(config)
|
||||||
default_runtime = loop.runtime_resolver.runtime
|
default_runtime = loop.runtime_resolver.runtime
|
||||||
resolved = loop.runtime_resolver.resolve_preset("fast")
|
resolved = loop.runtime_resolver.resolve_preset("fast")
|
||||||
assert resolved.model == "openai/gpt-4.1-mini"
|
assert resolved.model == "openai/gpt-4.1-mini"
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class TestHandleStop:
|
|||||||
assert "No active task" in out.content
|
assert "No active task" in out.content
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_aclose_cancels_active_turn_before_resources(self):
|
async def test_close_mcp_cancels_active_turn_before_resources(self):
|
||||||
loop, _bus = _make_loop()
|
loop, _bus = _make_loop()
|
||||||
events: list[str] = []
|
events: list[str] = []
|
||||||
|
|
||||||
@@ -76,13 +76,14 @@ class TestHandleStop:
|
|||||||
|
|
||||||
loop.subagents.close = close_subagents
|
loop.subagents.close = close_subagents
|
||||||
loop._exec_session_manager.close_all = AsyncMock()
|
loop._exec_session_manager.close_all = AsyncMock()
|
||||||
await loop.aclose()
|
with patch("nanobot.agent.loop.agent_context.close_mcp", AsyncMock()):
|
||||||
|
await loop.close_mcp()
|
||||||
|
|
||||||
assert events == ["turn_cancelled", "resources_closed"]
|
assert events == ["turn_cancelled", "resources_closed"]
|
||||||
assert task.cancelled()
|
assert task.cancelled()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_aclose_serializes_duplicate_cleanup(self):
|
async def test_close_mcp_serializes_duplicate_cleanup(self):
|
||||||
loop, _bus = _make_loop()
|
loop, _bus = _make_loop()
|
||||||
entered = asyncio.Event()
|
entered = asyncio.Event()
|
||||||
release = asyncio.Event()
|
release = asyncio.Event()
|
||||||
@@ -99,13 +100,14 @@ class TestHandleStop:
|
|||||||
|
|
||||||
loop.subagents.close = close_subagents
|
loop.subagents.close = close_subagents
|
||||||
loop._exec_session_manager.close_all = AsyncMock()
|
loop._exec_session_manager.close_all = AsyncMock()
|
||||||
first = asyncio.create_task(loop.aclose())
|
with patch("nanobot.agent.loop.agent_context.close_mcp", AsyncMock()):
|
||||||
await entered.wait()
|
first = asyncio.create_task(loop.close_mcp())
|
||||||
second = asyncio.create_task(loop.aclose())
|
await entered.wait()
|
||||||
await asyncio.sleep(0)
|
second = asyncio.create_task(loop.close_mcp())
|
||||||
assert not second.done()
|
await asyncio.sleep(0)
|
||||||
release.set()
|
assert not second.done()
|
||||||
await asyncio.gather(first, second)
|
release.set()
|
||||||
|
await asyncio.gather(first, second)
|
||||||
|
|
||||||
assert max_concurrent == 1
|
assert max_concurrent == 1
|
||||||
|
|
||||||
@@ -170,7 +172,8 @@ class TestDispatch:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_logs_and_continues_after_leaked_cancelled_error(self, monkeypatch):
|
async def test_run_logs_and_continues_after_leaked_cancelled_error(self, monkeypatch):
|
||||||
loop, bus = _make_loop()
|
loop, bus = _make_loop()
|
||||||
loop.aclose = AsyncMock()
|
loop._connect_mcp = AsyncMock()
|
||||||
|
loop.close_mcp = AsyncMock()
|
||||||
loop.auto_compact.check_expired = MagicMock()
|
loop.auto_compact.check_expired = MagicMock()
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
calls = 0
|
calls = 0
|
||||||
|
|||||||
@@ -493,6 +493,20 @@ class TestModifyOpen:
|
|||||||
assert "Set workspace" in result
|
assert "Set workspace" in result
|
||||||
assert tool._runtime_control.snapshot().workspace == "/new/path"
|
assert tool._runtime_control.snapshot().workspace == "/new/path"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modify_mcp_servers_blocked(self):
|
||||||
|
"""_mcp_servers contains API credentials — must be blocked."""
|
||||||
|
tool = _make_tool()
|
||||||
|
result = await tool.execute(action="set", key="_mcp_servers", value={"evil": "leaked"})
|
||||||
|
assert "protected" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modify_mcp_stacks_blocked(self):
|
||||||
|
"""_mcp_stacks holds connection handles — must be blocked."""
|
||||||
|
tool = _make_tool()
|
||||||
|
result = await tool.execute(action="set", key="_mcp_stacks", value={})
|
||||||
|
assert "protected" in result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_pending_queues_blocked(self):
|
async def test_modify_pending_queues_blocked(self):
|
||||||
"""_pending_queues controls message routing — must be blocked."""
|
"""_pending_queues controls message routing — must be blocked."""
|
||||||
@@ -521,6 +535,13 @@ class TestModifyOpen:
|
|||||||
result = await tool.execute(action="set", key="_background_tasks", value=[])
|
result = await tool.execute(action="set", key="_background_tasks", value=[])
|
||||||
assert "protected" in result
|
assert "protected" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inspect_mcp_servers_blocked(self):
|
||||||
|
"""_mcp_servers contains credentials — check must be blocked too."""
|
||||||
|
tool = _make_tool()
|
||||||
|
result = await tool.execute(action="check", key="_mcp_servers")
|
||||||
|
assert "not accessible" in result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_wrapped_denied(self):
|
async def test_modify_wrapped_denied(self):
|
||||||
"""__wrapped__ allows decorator bypass — must be denied."""
|
"""__wrapped__ allows decorator bypass — must be denied."""
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from nanobot.apps.cli.service import CliAppManager
|
from nanobot.apps.cli.service import CliAppManager
|
||||||
|
|
||||||
|
|
||||||
@@ -69,22 +67,3 @@ def test_run_passes_filtered_env(monkeypatch, tmp_path) -> None:
|
|||||||
env = captured.get("env")
|
env = captured.get("env")
|
||||||
assert isinstance(env, dict)
|
assert isinstance(env, dict)
|
||||||
assert "OPENAI_API_KEY" not in env
|
assert "OPENAI_API_KEY" not in env
|
||||||
|
|
||||||
|
|
||||||
def test_management_subprocesses_use_filtered_env(monkeypatch, tmp_path) -> None:
|
|
||||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-should-not-leak")
|
|
||||||
captured: dict[str, object] = {}
|
|
||||||
|
|
||||||
def fake_run(*args, **kwargs):
|
|
||||||
captured.update(kwargs)
|
|
||||||
return subprocess.CompletedProcess(args[0], 0, stdout="ok", stderr="")
|
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.run", fake_run)
|
|
||||||
manager = CliAppManager(workspace=tmp_path, data_dir=tmp_path / "cli-apps")
|
|
||||||
|
|
||||||
manager._run_argv(["example-cli", "--help"], timeout=5)
|
|
||||||
|
|
||||||
env = captured.get("env")
|
|
||||||
assert isinstance(env, dict)
|
|
||||||
assert "OPENAI_API_KEY" not in env
|
|
||||||
assert env["PYTHONUNBUFFERED"] == "1"
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ def test_interactive_agent_routes_a_complete_user_turn(
|
|||||||
def __init__(self, bus) -> None:
|
def __init__(self, bus) -> None:
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
self.stopped = asyncio.Event()
|
self.stopped = asyncio.Event()
|
||||||
self.aclose_calls = 0
|
self.close_mcp_calls = 0
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
message = await self.bus.consume_inbound()
|
message = await self.bus.consume_inbound()
|
||||||
@@ -97,8 +97,8 @@ def test_interactive_agent_routes_a_complete_user_turn(
|
|||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self.stopped.set()
|
self.stopped.set()
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
self.aclose_calls += 1
|
self.close_mcp_calls += 1
|
||||||
|
|
||||||
read_input = AsyncMock(side_effect=["hello nanobot", "exit"])
|
read_input = AsyncMock(side_effect=["hello nanobot", "exit"])
|
||||||
print_response = MagicMock()
|
print_response = MagicMock()
|
||||||
@@ -136,7 +136,7 @@ def test_interactive_agent_routes_a_complete_user_turn(
|
|||||||
assert inbound.metadata == {"_wants_stream": True}
|
assert inbound.metadata == {"_wants_stream": True}
|
||||||
loop = seen["loop"]
|
loop = seen["loop"]
|
||||||
assert isinstance(loop, _AgentLoop)
|
assert isinstance(loop, _AgentLoop)
|
||||||
assert loop.aclose_calls == 1
|
assert loop.close_mcp_calls == 1
|
||||||
assert len(renderers) == 1
|
assert len(renderers) == 1
|
||||||
renderer = renderers[0]
|
renderer = renderers[0]
|
||||||
assert isinstance(renderer, _Renderer)
|
assert isinstance(renderer, _Renderer)
|
||||||
|
|||||||
+19
-50
@@ -1543,7 +1543,7 @@ def mock_agent_runtime(tmp_path):
|
|||||||
agent_loop.process_direct = AsyncMock(
|
agent_loop.process_direct = AsyncMock(
|
||||||
return_value=OutboundMessage(channel="cli", chat_id="direct", content="mock-response"),
|
return_value=OutboundMessage(channel="cli", chat_id="direct", content="mock-response"),
|
||||||
)
|
)
|
||||||
agent_loop.aclose = AsyncMock(return_value=None)
|
agent_loop.close_mcp = AsyncMock(return_value=None)
|
||||||
mock_from_config.return_value = agent_loop
|
mock_from_config.return_value = agent_loop
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
@@ -1621,7 +1621,7 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
|
|||||||
async def process_direct(self, *_args, **_kwargs):
|
async def process_direct(self, *_args, **_kwargs):
|
||||||
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cli.agent.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.agent.AgentLoop", _FakeAgentLoop)
|
||||||
@@ -1662,7 +1662,7 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
|
|||||||
async def process_direct(self, *_args, **_kwargs):
|
async def process_direct(self, *_args, **_kwargs):
|
||||||
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
@@ -1712,7 +1712,7 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
|
|||||||
async def process_direct(self, *_args, **_kwargs):
|
async def process_direct(self, *_args, **_kwargs):
|
||||||
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
@@ -1768,7 +1768,7 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
|
|||||||
async def process_direct(self, *_args, **_kwargs):
|
async def process_direct(self, *_args, **_kwargs):
|
||||||
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
@@ -2062,7 +2062,7 @@ def test_heartbeat_empty_response_still_retains_recent_messages(
|
|||||||
async def process_direct(self, *_args, **_kwargs):
|
async def process_direct(self, *_args, **_kwargs):
|
||||||
return SimpleNamespace(content="")
|
return SimpleNamespace(content="")
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
@@ -2738,7 +2738,10 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
|
|||||||
def __init__(self, **kwargs) -> None:
|
def __init__(self, **kwargs) -> None:
|
||||||
seen["workspace"] = kwargs["workspace"]
|
seen["workspace"] = kwargs["workspace"]
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def _connect_mcp(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def close_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _fake_create_app(
|
def _fake_create_app(
|
||||||
@@ -2746,13 +2749,11 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
|
|||||||
model_name: str,
|
model_name: str,
|
||||||
request_timeout: float,
|
request_timeout: float,
|
||||||
api_key: str = "",
|
api_key: str = "",
|
||||||
prepare_agent=None,
|
|
||||||
):
|
):
|
||||||
seen["agent_loop"] = agent_loop
|
seen["agent_loop"] = agent_loop
|
||||||
seen["model_name"] = model_name
|
seen["model_name"] = model_name
|
||||||
seen["request_timeout"] = request_timeout
|
seen["request_timeout"] = request_timeout
|
||||||
seen["api_key"] = api_key
|
seen["api_key"] = api_key
|
||||||
seen["prepare_agent"] = prepare_agent
|
|
||||||
return _FakeApiApp()
|
return _FakeApiApp()
|
||||||
|
|
||||||
def _fake_run_app(api_app, host: str, port: int, print):
|
def _fake_run_app(api_app, host: str, port: int, print):
|
||||||
@@ -2913,7 +2914,7 @@ def test_gateway_unbound_agent_cron_is_skipped(
|
|||||||
async def submit_cron_turn(self, _msg: InboundMessage):
|
async def submit_cron_turn(self, _msg: InboundMessage):
|
||||||
raise AssertionError("unbound cron job must not run as a bound cron turn")
|
raise AssertionError("unbound cron job must not run as a bound cron turn")
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
@@ -3032,7 +3033,7 @@ def test_gateway_bound_cron_runs_as_session_turn(
|
|||||||
content="Checked the repo.",
|
content="Checked the repo.",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
@@ -3252,7 +3253,7 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
|
|||||||
self.runtime_resolver.invalidate.assert_called_once_with()
|
self.runtime_resolver.invalidate.assert_called_once_with()
|
||||||
await asyncio.Event().wait()
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
@@ -3498,7 +3499,7 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
|||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
await asyncio.Event().wait()
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
@@ -3667,7 +3668,7 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
|||||||
assert timed_out_writer.output == b""
|
assert timed_out_writer.output == b""
|
||||||
|
|
||||||
|
|
||||||
def test_gateway_agent_task_owns_initial_mcp_provider_close(
|
def test_gateway_shutdown_lets_agent_task_own_mcp_cleanup(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -3695,41 +3696,17 @@ def test_gateway_agent_task_owns_initial_mcp_provider_close(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
seen["agent_task"] = asyncio.current_task()
|
|
||||||
try:
|
try:
|
||||||
await asyncio.Event().wait()
|
await asyncio.Event().wait()
|
||||||
finally:
|
finally:
|
||||||
seen["agent_task_cleaned_up"] = True
|
seen["agent_task_cleaned_up"] = True
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
seen["agent_closed"] = True
|
raise AssertionError("gateway must not close MCP from the outer task")
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
seen["agent_stopped"] = True
|
seen["agent_stopped"] = True
|
||||||
|
|
||||||
class _FakeMCPProvider:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.connect_task: asyncio.Task | None = None
|
|
||||||
self.close_tasks: list[asyncio.Task | None] = []
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_config(cls, _config, _registry):
|
|
||||||
provider = cls()
|
|
||||||
seen["mcp_provider"] = provider
|
|
||||||
return provider
|
|
||||||
|
|
||||||
async def connect(self) -> None:
|
|
||||||
self.connect_task = asyncio.current_task()
|
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
|
||||||
self.close_tasks.append(asyncio.current_task())
|
|
||||||
|
|
||||||
def runtime_status(self) -> dict[str, str]:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
async def reload(self) -> dict[str, object]:
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
class _FakeChannelManager:
|
class _FakeChannelManager:
|
||||||
def __init__(self, _config, _bus, **_kwargs) -> None:
|
def __init__(self, _config, _bus, **_kwargs) -> None:
|
||||||
self.enabled_channels = ["telegram"]
|
self.enabled_channels = ["telegram"]
|
||||||
@@ -3776,7 +3753,6 @@ def test_gateway_agent_task_owns_initial_mcp_provider_close(
|
|||||||
session_manager=lambda _workspace: object(),
|
session_manager=lambda _workspace: object(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.cli.gateway_runtime.MCPProvider", _FakeMCPProvider)
|
|
||||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
|
||||||
monkeypatch.setattr("asyncio.start_server", _fake_start_server)
|
monkeypatch.setattr("asyncio.start_server", _fake_start_server)
|
||||||
@@ -3785,15 +3761,9 @@ def test_gateway_agent_task_owns_initial_mcp_provider_close(
|
|||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert seen["agent_stopped"] is True
|
assert seen["agent_stopped"] is True
|
||||||
assert seen["agent_closed"] is True
|
|
||||||
assert seen["agent_task_cleaned_up"] is True
|
assert seen["agent_task_cleaned_up"] is True
|
||||||
assert seen["channels_stopped"] is True
|
assert seen["channels_stopped"] is True
|
||||||
assert seen["cron_stopped"] is True
|
assert seen["cron_stopped"] is True
|
||||||
mcp_provider = seen["mcp_provider"]
|
|
||||||
assert isinstance(mcp_provider, _FakeMCPProvider)
|
|
||||||
assert mcp_provider.connect_task is seen["agent_task"]
|
|
||||||
assert mcp_provider.close_tasks[0] is mcp_provider.connect_task
|
|
||||||
assert len(mcp_provider.close_tasks) == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_gateway_shutdown_event_exits_forever_runtime_tasks(
|
def test_gateway_shutdown_event_exits_forever_runtime_tasks(
|
||||||
@@ -3830,8 +3800,8 @@ def test_gateway_shutdown_event_exits_forever_runtime_tasks(
|
|||||||
finally:
|
finally:
|
||||||
seen["agent_task_cleaned_up"] = True
|
seen["agent_task_cleaned_up"] = True
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
seen["agent_closed"] = True
|
raise AssertionError("gateway must not close MCP from the outer task")
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
seen["agent_stopped"] = True
|
seen["agent_stopped"] = True
|
||||||
@@ -3911,7 +3881,6 @@ def test_gateway_shutdown_event_exits_forever_runtime_tasks(
|
|||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert seen["agent_stopped"] is True
|
assert seen["agent_stopped"] is True
|
||||||
assert seen["agent_closed"] is True
|
|
||||||
assert seen["agent_task_cleaned_up"] is True
|
assert seen["agent_task_cleaned_up"] is True
|
||||||
assert seen["channel_task_cleaned_up"] is True
|
assert seen["channel_task_cleaned_up"] is True
|
||||||
assert seen["channels_stopped"] is True
|
assert seen["channels_stopped"] is True
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class _FakeAgent:
|
|||||||
self.raise_on_close = False
|
self.raise_on_close = False
|
||||||
self.background: asyncio.Task[None] | None = None
|
self.background: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
self.close_calls += 1
|
self.close_calls += 1
|
||||||
if self.hang_on_close:
|
if self.hang_on_close:
|
||||||
await asyncio.sleep(3600)
|
await asyncio.sleep(3600)
|
||||||
@@ -30,7 +30,7 @@ class _FakeAgent:
|
|||||||
raise RuntimeError("cleanup exploded")
|
raise RuntimeError("cleanup exploded")
|
||||||
if self.background is not None:
|
if self.background is not None:
|
||||||
await self.background
|
await self.background
|
||||||
self.events.append("aclose")
|
self.events.append("close_mcp")
|
||||||
|
|
||||||
|
|
||||||
class _FakeChannels:
|
class _FakeChannels:
|
||||||
@@ -43,16 +43,6 @@ class _FakeChannels:
|
|||||||
self.events.append("channels_stopped")
|
self.events.append("channels_stopped")
|
||||||
|
|
||||||
|
|
||||||
class _FakeMCPProvider:
|
|
||||||
def __init__(self, events: list[str] | None = None) -> None:
|
|
||||||
self.close_calls = 0
|
|
||||||
self.events = events if events is not None else []
|
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
|
||||||
self.close_calls += 1
|
|
||||||
self.events.append("mcp_closed")
|
|
||||||
|
|
||||||
|
|
||||||
async def _cancellable_task(events: list[str]) -> None:
|
async def _cancellable_task(events: list[str]) -> None:
|
||||||
try:
|
try:
|
||||||
await asyncio.sleep(3600)
|
await asyncio.sleep(3600)
|
||||||
@@ -74,14 +64,13 @@ async def _stubborn_task(events: list[str]) -> None:
|
|||||||
async def test_runtime_tasks_cancelled_before_resources_closed() -> None:
|
async def test_runtime_tasks_cancelled_before_resources_closed() -> None:
|
||||||
events: list[str] = []
|
events: list[str] = []
|
||||||
agent = _FakeAgent(events)
|
agent = _FakeAgent(events)
|
||||||
provider = _FakeMCPProvider(events)
|
|
||||||
channels = _FakeChannels()
|
channels = _FakeChannels()
|
||||||
task = asyncio.create_task(_cancellable_task(events))
|
task = asyncio.create_task(_cancellable_task(events))
|
||||||
await asyncio.sleep(0) # let the task start (cancellation pre-start skips its body)
|
await asyncio.sleep(0) # let the task start (cancellation pre-start skips its body)
|
||||||
|
|
||||||
await _close_gateway_runtime(agent, provider, channels, [task], None)
|
await _close_gateway_runtime(agent, channels, [task], None)
|
||||||
|
|
||||||
assert events == ["cancelled", "aclose", "mcp_closed"]
|
assert events == ["cancelled", "close_mcp"] # cancel happens before close
|
||||||
assert channels.stopped == 1
|
assert channels.stopped == 1
|
||||||
assert agent.close_calls == 1
|
assert agent.close_calls == 1
|
||||||
assert task.cancelled()
|
assert task.cancelled()
|
||||||
@@ -89,7 +78,6 @@ async def test_runtime_tasks_cancelled_before_resources_closed() -> None:
|
|||||||
|
|
||||||
async def test_pending_background_work_is_drained_before_close_returns() -> None:
|
async def test_pending_background_work_is_drained_before_close_returns() -> None:
|
||||||
agent = _FakeAgent()
|
agent = _FakeAgent()
|
||||||
provider = _FakeMCPProvider()
|
|
||||||
channels = _FakeChannels()
|
channels = _FakeChannels()
|
||||||
done: dict[str, bool] = {"done": False}
|
done: dict[str, bool] = {"done": False}
|
||||||
|
|
||||||
@@ -99,7 +87,7 @@ async def test_pending_background_work_is_drained_before_close_returns() -> None
|
|||||||
|
|
||||||
agent.background = asyncio.create_task(background_work())
|
agent.background = asyncio.create_task(background_work())
|
||||||
|
|
||||||
await _close_gateway_runtime(agent, provider, channels, [], None)
|
await _close_gateway_runtime(agent, channels, [], None)
|
||||||
|
|
||||||
assert done["done"] is True
|
assert done["done"] is True
|
||||||
assert agent.close_calls == 1
|
assert agent.close_calls == 1
|
||||||
@@ -107,7 +95,6 @@ async def test_pending_background_work_is_drained_before_close_returns() -> None
|
|||||||
|
|
||||||
async def test_stubborn_task_does_not_block_past_wait_timeout() -> None:
|
async def test_stubborn_task_does_not_block_past_wait_timeout() -> None:
|
||||||
agent = _FakeAgent()
|
agent = _FakeAgent()
|
||||||
provider = _FakeMCPProvider()
|
|
||||||
channels = _FakeChannels()
|
channels = _FakeChannels()
|
||||||
events: list[str] = []
|
events: list[str] = []
|
||||||
task = asyncio.create_task(_stubborn_task(events))
|
task = asyncio.create_task(_stubborn_task(events))
|
||||||
@@ -117,7 +104,6 @@ async def test_stubborn_task_does_not_block_past_wait_timeout() -> None:
|
|||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
await _close_gateway_runtime(
|
await _close_gateway_runtime(
|
||||||
agent,
|
agent,
|
||||||
provider,
|
|
||||||
channels,
|
channels,
|
||||||
[task],
|
[task],
|
||||||
runtime_tasks,
|
runtime_tasks,
|
||||||
@@ -131,88 +117,70 @@ async def test_stubborn_task_does_not_block_past_wait_timeout() -> None:
|
|||||||
assert task.done() # the timed-out task received a second cancellation
|
assert task.done() # the timed-out task received a second cancellation
|
||||||
assert runtime_tasks.done()
|
assert runtime_tasks.done()
|
||||||
assert agent.close_calls == 1 # resources still closed underneath it
|
assert agent.close_calls == 1 # resources still closed underneath it
|
||||||
assert provider.close_calls == 1
|
|
||||||
assert elapsed < 1.0 # bounded, not held open by the stubborn task
|
assert elapsed < 1.0 # bounded, not held open by the stubborn task
|
||||||
|
|
||||||
|
|
||||||
async def test_hanging_close_is_bounded_and_does_not_raise() -> None:
|
async def test_hanging_close_is_bounded_and_does_not_raise() -> None:
|
||||||
agent = _FakeAgent()
|
agent = _FakeAgent()
|
||||||
provider = _FakeMCPProvider()
|
|
||||||
agent.hang_on_close = True
|
agent.hang_on_close = True
|
||||||
channels = _FakeChannels()
|
channels = _FakeChannels()
|
||||||
|
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
await _close_gateway_runtime(
|
await _close_gateway_runtime(agent, channels, [], None, close_timeout=0.05)
|
||||||
agent,
|
|
||||||
provider,
|
|
||||||
channels,
|
|
||||||
[],
|
|
||||||
None,
|
|
||||||
close_timeout=0.05,
|
|
||||||
)
|
|
||||||
elapsed = time.monotonic() - start
|
elapsed = time.monotonic() - start
|
||||||
|
|
||||||
assert agent.close_calls == 1
|
assert agent.close_calls == 1
|
||||||
assert provider.close_calls == 1
|
|
||||||
assert channels.stopped == 1
|
assert channels.stopped == 1
|
||||||
assert elapsed < 1.0
|
assert elapsed < 1.0
|
||||||
|
|
||||||
|
|
||||||
async def test_failing_close_is_logged_but_shutdown_proceeds() -> None:
|
async def test_failing_close_is_logged_but_shutdown_proceeds() -> None:
|
||||||
agent = _FakeAgent()
|
agent = _FakeAgent()
|
||||||
provider = _FakeMCPProvider()
|
|
||||||
agent.raise_on_close = True
|
agent.raise_on_close = True
|
||||||
channels = _FakeChannels()
|
channels = _FakeChannels()
|
||||||
|
|
||||||
await _close_gateway_runtime(agent, provider, channels, [], None)
|
await _close_gateway_runtime(agent, channels, [], None)
|
||||||
|
|
||||||
assert agent.close_calls == 1
|
assert agent.close_calls == 1
|
||||||
assert provider.close_calls == 1
|
|
||||||
assert channels.stopped == 1 # teardown continued past the failure
|
assert channels.stopped == 1 # teardown continued past the failure
|
||||||
|
|
||||||
|
|
||||||
async def test_duplicate_cleanup_is_idempotent() -> None:
|
async def test_duplicate_cleanup_is_idempotent() -> None:
|
||||||
agent = _FakeAgent()
|
agent = _FakeAgent()
|
||||||
provider = _FakeMCPProvider()
|
|
||||||
channels = _FakeChannels()
|
channels = _FakeChannels()
|
||||||
task = asyncio.create_task(_cancellable_task([]))
|
task = asyncio.create_task(_cancellable_task([]))
|
||||||
|
|
||||||
await _close_gateway_runtime(agent, provider, channels, [task], None)
|
await _close_gateway_runtime(agent, channels, [task], None)
|
||||||
await _close_gateway_runtime(agent, provider, channels, [task], None)
|
await _close_gateway_runtime(agent, channels, [task], None)
|
||||||
|
|
||||||
assert agent.close_calls == 2 # second pass is a clean no-op
|
assert agent.close_calls == 2 # second pass is a clean no-op
|
||||||
assert provider.close_calls == 2
|
|
||||||
assert channels.stopped == 2
|
assert channels.stopped == 2
|
||||||
assert task.cancelled()
|
assert task.cancelled()
|
||||||
|
|
||||||
|
|
||||||
async def test_finished_runtime_tasks_gather_is_retrieved() -> None:
|
async def test_finished_runtime_tasks_gather_is_retrieved() -> None:
|
||||||
agent = _FakeAgent()
|
agent = _FakeAgent()
|
||||||
provider = _FakeMCPProvider()
|
|
||||||
channels = _FakeChannels()
|
channels = _FakeChannels()
|
||||||
finished = asyncio.get_running_loop().create_future()
|
finished = asyncio.get_running_loop().create_future()
|
||||||
finished.set_result(None)
|
finished.set_result(None)
|
||||||
runtime_tasks = asyncio.gather(finished)
|
runtime_tasks = asyncio.gather(finished)
|
||||||
await asyncio.sleep(0) # let the gather observe the finished child
|
await asyncio.sleep(0) # let the gather observe the finished child
|
||||||
|
|
||||||
await _close_gateway_runtime(agent, provider, channels, [], runtime_tasks)
|
await _close_gateway_runtime(agent, channels, [], runtime_tasks)
|
||||||
|
|
||||||
assert runtime_tasks.done()
|
assert runtime_tasks.done()
|
||||||
assert agent.close_calls == 1
|
assert agent.close_calls == 1
|
||||||
assert provider.close_calls == 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_cancelled_runtime_tasks_gather_does_not_raise() -> None:
|
async def test_cancelled_runtime_tasks_gather_does_not_raise() -> None:
|
||||||
agent = _FakeAgent()
|
agent = _FakeAgent()
|
||||||
provider = _FakeMCPProvider()
|
|
||||||
channels = _FakeChannels()
|
channels = _FakeChannels()
|
||||||
runtime_tasks = asyncio.gather(asyncio.sleep(3600))
|
runtime_tasks = asyncio.gather(asyncio.sleep(3600))
|
||||||
runtime_tasks.cancel()
|
runtime_tasks.cancel()
|
||||||
|
|
||||||
await _close_gateway_runtime(agent, provider, channels, [], runtime_tasks)
|
await _close_gateway_runtime(agent, channels, [], runtime_tasks)
|
||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
await runtime_tasks # settle the cancelled gather without raising
|
await runtime_tasks # settle the cancelled gather without raising
|
||||||
|
|
||||||
assert runtime_tasks.done() # the cancelled gather was awaited without raising
|
assert runtime_tasks.done() # the cancelled gather was awaited without raising
|
||||||
assert agent.close_calls == 1
|
assert agent.close_calls == 1
|
||||||
assert provider.close_calls == 1
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from typer.testing import CliRunner
|
|
||||||
|
|
||||||
from nanobot.cli import commands
|
|
||||||
from nanobot.config.loader import load_config
|
|
||||||
from nanobot.session.manager import SessionManager
|
|
||||||
|
|
||||||
|
|
||||||
def test_sessions_restore_workspace_command_prepares_downgrade(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch,
|
|
||||||
) -> None:
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
config_path = tmp_path / "instance" / "config.json"
|
|
||||||
config = load_config(config_path)
|
|
||||||
config.agents.defaults.workspace = str(workspace)
|
|
||||||
manager = SessionManager(workspace, sessions_root=config_path.parent / "sessions")
|
|
||||||
session = manager.get_or_create("cli:rollback")
|
|
||||||
session.add_message("user", "restore-me")
|
|
||||||
manager.save(session, fsync=True)
|
|
||||||
monkeypatch.setattr(commands, "_load_runtime_config", lambda *_args: config)
|
|
||||||
|
|
||||||
result = CliRunner().invoke(commands.app, ["sessions", "restore-workspace"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0, result.output
|
|
||||||
assert "Restored 1 session file(s)" in result.output
|
|
||||||
restored = workspace / "sessions" / manager._get_session_path(session.key).name
|
|
||||||
assert restored.exists()
|
|
||||||
assert "restore-me" in restored.read_text(encoding="utf-8")
|
|
||||||
assert manager._get_session_path(session.key).exists()
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.apps import discovery
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_discovery_returns_fallback_then_caches_remote_registry(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
remote = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"updated_at": "2026-08-12T01:00:00Z",
|
||||||
|
"featured": ["mcp:notion", "cli:obsidian"],
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(discovery, "_fetch_remote", lambda: remote)
|
||||||
|
|
||||||
|
initial = await discovery.discovery_payload(data_dir=tmp_path)
|
||||||
|
assert initial["featured"][0] == "mcp:github"
|
||||||
|
assert initial["refresh_pending"] is True
|
||||||
|
|
||||||
|
await asyncio.gather(*discovery._refresh_tasks.values())
|
||||||
|
cached = await discovery.discovery_payload(data_dir=tmp_path)
|
||||||
|
assert cached == remote
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_discovery_keeps_last_valid_registry_when_refresh_fails(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
cached = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"updated_at": "2026-08-11T01:00:00Z",
|
||||||
|
"featured": ["mcp:github"],
|
||||||
|
}
|
||||||
|
discovery._write_cache(tmp_path / "apps-discovery.json", cached)
|
||||||
|
monkeypatch.setattr(discovery, "CACHE_TTL_S", -1)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
discovery,
|
||||||
|
"_fetch_remote",
|
||||||
|
lambda: (_ for _ in ()).throw(OSError("offline")),
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await discovery.discovery_payload(data_dir=tmp_path)
|
||||||
|
assert payload == {**cached, "refresh_pending": True}
|
||||||
|
await asyncio.gather(*discovery._refresh_tasks.values())
|
||||||
|
assert discovery._read_cache(tmp_path / "apps-discovery.json") == cached
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"featured",
|
||||||
|
[[], ["unknown:github"], ["mcp:github", "mcp:github"], ["mcp:github"] * 13],
|
||||||
|
)
|
||||||
|
def test_discovery_rejects_invalid_featured_lists(featured: list[str]) -> None:
|
||||||
|
assert discovery._validated_payload({
|
||||||
|
"schema_version": 1,
|
||||||
|
"updated_at": "2026-08-12T01:00:00Z",
|
||||||
|
"featured": featured,
|
||||||
|
}) is None
|
||||||
@@ -204,6 +204,76 @@ def test_payload_uses_anygen_official_domain_for_logo(tmp_path: Path) -> None:
|
|||||||
assert app["logo_url"] == "https://www.google.com/s2/favicons?domain=anygen.io&sz=64"
|
assert app["logo_url"] == "https://www.google.com/s2/favicons?domain=anygen.io&sz=64"
|
||||||
|
|
||||||
|
|
||||||
|
def test_payload_uses_calibre_official_domain_for_logo(tmp_path: Path) -> None:
|
||||||
|
manager = _manager(tmp_path)
|
||||||
|
_write_cache(
|
||||||
|
manager._cache_path("harness"),
|
||||||
|
{
|
||||||
|
"meta": {"updated": "2026-04-16"},
|
||||||
|
"clis": [
|
||||||
|
{
|
||||||
|
"name": "calibre",
|
||||||
|
"display_name": "Calibre",
|
||||||
|
"entry_point": "cli-anything-calibre",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
app = next(app for app in manager.payload()["apps"] if app["name"] == "calibre")
|
||||||
|
|
||||||
|
assert app["logo_url"] == (
|
||||||
|
"https://www.google.com/s2/favicons?domain=calibre-ebook.com&sz=64"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_payload_prefers_colored_official_homepage_logo(tmp_path: Path) -> None:
|
||||||
|
manager = _manager(tmp_path)
|
||||||
|
_write_cache(
|
||||||
|
manager._cache_path("harness"),
|
||||||
|
{
|
||||||
|
"meta": {"updated": "2026-04-16"},
|
||||||
|
"clis": [
|
||||||
|
{
|
||||||
|
"name": "blender",
|
||||||
|
"display_name": "Blender",
|
||||||
|
"homepage": "https://www.blender.org/features/",
|
||||||
|
"entry_point": "cli-anything-blender",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
app = next(app for app in manager.payload()["apps"] if app["name"] == "blender")
|
||||||
|
|
||||||
|
assert app["logo_url"] == (
|
||||||
|
"https://www.google.com/s2/favicons?domain=blender.org&sz=64"
|
||||||
|
)
|
||||||
|
assert app["brand_color"] == "#E87D0D"
|
||||||
|
|
||||||
|
|
||||||
|
def test_payload_does_not_use_repository_host_as_the_app_logo(tmp_path: Path) -> None:
|
||||||
|
manager = _manager(tmp_path)
|
||||||
|
_write_cache(
|
||||||
|
manager._cache_path("harness"),
|
||||||
|
{
|
||||||
|
"meta": {"updated": "2026-04-16"},
|
||||||
|
"clis": [
|
||||||
|
{
|
||||||
|
"name": "gimp",
|
||||||
|
"display_name": "GIMP",
|
||||||
|
"homepage": "https://github.com/example/gimp-wrapper",
|
||||||
|
"entry_point": "cli-anything-gimp",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
app = next(app for app in manager.payload()["apps"] if app["name"] == "gimp")
|
||||||
|
|
||||||
|
assert app["logo_url"] == "https://cdn.simpleicons.org/gimp/5C5543"
|
||||||
|
|
||||||
|
|
||||||
def test_payload_resolves_obsidian_agent_cli_brand(tmp_path: Path) -> None:
|
def test_payload_resolves_obsidian_agent_cli_brand(tmp_path: Path) -> None:
|
||||||
manager = _manager(tmp_path)
|
manager = _manager(tmp_path)
|
||||||
_write_cache(
|
_write_cache(
|
||||||
@@ -442,15 +512,12 @@ def test_run_argv_logs_command_exit_and_output(
|
|||||||
encoding: str,
|
encoding: str,
|
||||||
errors: str,
|
errors: str,
|
||||||
timeout: int,
|
timeout: int,
|
||||||
env: dict[str, str],
|
|
||||||
) -> subprocess.CompletedProcess[str]:
|
) -> subprocess.CompletedProcess[str]:
|
||||||
assert capture_output is True
|
assert capture_output is True
|
||||||
assert text is True
|
assert text is True
|
||||||
assert encoding == "utf-8"
|
assert encoding == "utf-8"
|
||||||
assert errors == "replace"
|
assert errors == "replace"
|
||||||
assert timeout == 5
|
assert timeout == 5
|
||||||
assert "OPENAI_API_KEY" not in env
|
|
||||||
assert env["PYTHONUNBUFFERED"] == "1"
|
|
||||||
return subprocess.CompletedProcess(argv, 0, stdout="installed ok", stderr="")
|
return subprocess.CompletedProcess(argv, 0, stdout="installed ok", stderr="")
|
||||||
|
|
||||||
monkeypatch.setattr(cli_service, "logger", _Logger())
|
monkeypatch.setattr(cli_service, "logger", _Logger())
|
||||||
|
|||||||
@@ -3,29 +3,14 @@ import json
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.config.errors import ConfigLoadError
|
from nanobot.config.errors import ConfigLoadError
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
from nanobot.config.loader import load_config
|
||||||
from nanobot.config.schema import ApiConfig
|
from nanobot.config.schema import ApiConfig
|
||||||
|
|
||||||
|
|
||||||
def test_load_config_missing_file_uses_defaults(tmp_path) -> None:
|
def test_load_config_missing_file_uses_defaults(tmp_path) -> None:
|
||||||
config_path = tmp_path / "instance" / "missing.json"
|
config = load_config(tmp_path / "missing.json")
|
||||||
config = load_config(config_path)
|
|
||||||
|
|
||||||
assert config.agents.defaults.model
|
assert config.agents.defaults.model
|
||||||
assert config.runtime_data_dir == config_path.parent
|
|
||||||
|
|
||||||
|
|
||||||
def test_env_resolution_preserves_config_runtime_data_dir(
|
|
||||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
config_path = tmp_path / "instance" / "config.json"
|
|
||||||
config_path.parent.mkdir()
|
|
||||||
config_path.write_text('{"providers": {"openai": {"apiKey": "${TEST_API_KEY}"}}}')
|
|
||||||
monkeypatch.setenv("TEST_API_KEY", "resolved")
|
|
||||||
|
|
||||||
config = resolve_config_env_vars(load_config(config_path), config_path=config_path)
|
|
||||||
|
|
||||||
assert config.runtime_data_dir == config_path.parent
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_config_reports_malformed_environment_safely(
|
def test_load_config_reports_malformed_environment_safely(
|
||||||
|
|||||||
@@ -299,8 +299,8 @@ def _fake_chat_stream_legacy_function_call_chunks():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_openai_compat_chat_stream_forwards_reasoning_deltas_deepseek_style() -> None:
|
async def test_openai_compat_stream_forwards_reasoning_deltas_deepseek_style() -> None:
|
||||||
"""DeepSeek Chat Completions exposes ``delta.reasoning_content`` while streaming."""
|
"""Regression: DeepSeek-V4 / reasoner expose ``delta.reasoning_content`` during streaming."""
|
||||||
mock_chat = AsyncMock(return_value=_fake_chat_stream_reasoning_chunks())
|
mock_chat = AsyncMock(return_value=_fake_chat_stream_reasoning_chunks())
|
||||||
spec = find_by_name("deepseek")
|
spec = find_by_name("deepseek")
|
||||||
thinking: list[str] = []
|
thinking: list[str] = []
|
||||||
@@ -321,7 +321,6 @@ async def test_openai_compat_chat_stream_forwards_reasoning_deltas_deepseek_styl
|
|||||||
default_model="deepseek-v4-pro",
|
default_model="deepseek-v4-pro",
|
||||||
spec=spec,
|
spec=spec,
|
||||||
)
|
)
|
||||||
provider._api_type = "chat_completions"
|
|
||||||
result = await provider.chat_stream(
|
result = await provider.chat_stream(
|
||||||
messages=[{"role": "user", "content": "hi"}],
|
messages=[{"role": "user", "content": "hi"}],
|
||||||
model="deepseek-v4-pro",
|
model="deepseek-v4-pro",
|
||||||
@@ -337,37 +336,6 @@ async def test_openai_compat_chat_stream_forwards_reasoning_deltas_deepseek_styl
|
|||||||
mock_chat.assert_awaited_once()
|
mock_chat.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_deepseek_v4_pro_uses_responses_api() -> None:
|
|
||||||
mock_chat = AsyncMock(return_value=_fake_chat_response())
|
|
||||||
mock_responses = AsyncMock(return_value=_fake_responses_response("from responses"))
|
|
||||||
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_class:
|
|
||||||
client_instance = mock_client_class.return_value
|
|
||||||
client_instance.chat.completions.create = mock_chat
|
|
||||||
client_instance.responses.create = mock_responses
|
|
||||||
|
|
||||||
provider = OpenAICompatProvider(
|
|
||||||
api_key="sk-test",
|
|
||||||
default_model="deepseek-v4-pro",
|
|
||||||
spec=find_by_name("deepseek"),
|
|
||||||
)
|
|
||||||
result = await provider.chat(
|
|
||||||
messages=[{"role": "user", "content": "hello"}],
|
|
||||||
model="deepseek-v4-pro",
|
|
||||||
reasoning_effort="none",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.content == "from responses"
|
|
||||||
mock_responses.assert_awaited_once()
|
|
||||||
mock_chat.assert_not_awaited()
|
|
||||||
call_kwargs = mock_responses.call_args.kwargs
|
|
||||||
assert call_kwargs["model"] == "deepseek-v4-pro"
|
|
||||||
assert call_kwargs["reasoning"] == {"effort": "none"}
|
|
||||||
assert call_kwargs["tools"] == [{"type": "web_search"}]
|
|
||||||
assert "include" not in call_kwargs
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("provider_name", "model"),
|
("provider_name", "model"),
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from nanobot.providers.openai_compat_provider import (
|
|||||||
OpenAICompatProvider,
|
OpenAICompatProvider,
|
||||||
)
|
)
|
||||||
from nanobot.providers.openai_responses.state import build_responses_state
|
from nanobot.providers.openai_responses.state import build_responses_state
|
||||||
from nanobot.providers.registry import find_by_name
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
@@ -31,22 +30,30 @@ def test_responses_api_available_by_default(provider):
|
|||||||
assert provider._should_use_responses_api("gpt-5", None) is True
|
assert provider._should_use_responses_api("gpt-5", None) is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("model", ["deepseek-v4-flash", "deepseek-v4-pro"])
|
def test_deepseek_v4_flash_uses_responses_by_model(provider):
|
||||||
def test_deepseek_v4_models_use_responses_by_model(provider, model):
|
provider._spec = type("Spec", (), {
|
||||||
provider._spec = find_by_name("deepseek")
|
"name": "deepseek",
|
||||||
|
"responses_models": ("deepseek-v4-flash",),
|
||||||
|
"strip_model_prefix": False,
|
||||||
|
"strip_model_prefixes": (),
|
||||||
|
})()
|
||||||
provider._effective_base = "https://api.deepseek.com"
|
provider._effective_base = "https://api.deepseek.com"
|
||||||
provider.default_model = model
|
provider.default_model = "deepseek-v4-flash"
|
||||||
|
|
||||||
assert provider._should_use_responses_api(model, None) is True
|
assert provider._should_use_responses_api("deepseek-v4-flash", None) is True
|
||||||
assert provider._should_use_responses_api("deepseek-chat", None) is False
|
assert provider._should_use_responses_api("deepseek-v4-pro", None) is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("model", ["deepseek-v4-flash", "deepseek-v4-pro"])
|
def test_deepseek_v4_flash_matches_provider_prefixed_model(provider):
|
||||||
def test_deepseek_v4_models_match_provider_prefixed_model(provider, model):
|
provider._spec = type("Spec", (), {
|
||||||
provider._spec = find_by_name("deepseek")
|
"name": "deepseek",
|
||||||
|
"responses_models": ("deepseek-v4-flash",),
|
||||||
|
"strip_model_prefix": False,
|
||||||
|
"strip_model_prefixes": (),
|
||||||
|
})()
|
||||||
provider._effective_base = "https://api.deepseek.com"
|
provider._effective_base = "https://api.deepseek.com"
|
||||||
|
|
||||||
assert provider._should_use_responses_api(f"deepseek/{model}", None) is True
|
assert provider._should_use_responses_api("deepseek/deepseek-v4-flash", None) is True
|
||||||
|
|
||||||
|
|
||||||
def test_direct_openai_enables_server_compaction(provider):
|
def test_direct_openai_enables_server_compaction(provider):
|
||||||
|
|||||||
@@ -1,330 +0,0 @@
|
|||||||
"""Session storage location: outside the agent workspace (ADR-0001)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import shutil
|
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.config.loader import load_config
|
|
||||||
from nanobot.session.manager import JsonlSessionStore, SessionManager
|
|
||||||
|
|
||||||
|
|
||||||
def _write_legacy_session(
|
|
||||||
old_dir: Path,
|
|
||||||
key: str,
|
|
||||||
content: str,
|
|
||||||
*,
|
|
||||||
updated_at: str = "2026-01-01T00:00:00",
|
|
||||||
) -> Path:
|
|
||||||
"""Write a valid session file in the legacy in-workspace location."""
|
|
||||||
old_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
path = old_dir / f"{JsonlSessionStore.storage_key(key)}.jsonl"
|
|
||||||
path.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"_type": "metadata",
|
|
||||||
"key": key,
|
|
||||||
"created_at": "2026-01-01T00:00:00",
|
|
||||||
"updated_at": updated_at,
|
|
||||||
"metadata": {},
|
|
||||||
"last_consolidated": 0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
+ "\n"
|
|
||||||
+ json.dumps({"role": "user", "content": content})
|
|
||||||
+ "\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def test_sessions_are_stored_outside_workspace(tmp_path: Path) -> None:
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
manager = SessionManager(workspace=workspace)
|
|
||||||
|
|
||||||
session = manager.get_or_create("telegram:1")
|
|
||||||
session.add_message("user", "hello")
|
|
||||||
manager.save(session)
|
|
||||||
|
|
||||||
# The session file must NOT live inside the workspace.
|
|
||||||
workspace_sessions = workspace / "sessions"
|
|
||||||
assert not workspace_sessions.exists() or not any(workspace_sessions.glob("*.jsonl"))
|
|
||||||
|
|
||||||
# The out-of-workspace store records which workspace it belongs to and the
|
|
||||||
# workspace carries only a non-secret stable identity marker.
|
|
||||||
marker = manager.sessions_dir / ".workspace"
|
|
||||||
assert marker.read_text(encoding="utf-8").strip() == str(workspace.resolve())
|
|
||||||
workspace_id = (workspace / ".nanobot" / "workspace-id").read_text(encoding="utf-8").strip()
|
|
||||||
assert manager.sessions_dir.name == workspace_id
|
|
||||||
assert manager.sessions_dir.parent.name == "sessions"
|
|
||||||
|
|
||||||
# And it must still round-trip through a fresh manager for the same workspace.
|
|
||||||
reloaded = SessionManager(workspace=workspace).get_or_create("telegram:1")
|
|
||||||
assert reloaded.messages[-1]["content"] == "hello"
|
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_identity_marker_contains_no_session_content(tmp_path: Path) -> None:
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
secret = f"session-secret-{uuid.uuid4()}"
|
|
||||||
manager = SessionManager(workspace=workspace)
|
|
||||||
session = manager.get_or_create("telegram:secret")
|
|
||||||
session.add_message("user", secret)
|
|
||||||
manager.save(session)
|
|
||||||
|
|
||||||
marker = workspace / ".nanobot" / "workspace-id"
|
|
||||||
assert marker.read_text(encoding="utf-8").strip() == manager.sessions_dir.name
|
|
||||||
assert secret not in marker.read_text(encoding="utf-8")
|
|
||||||
assert not any(
|
|
||||||
secret in path.read_text(encoding="utf-8")
|
|
||||||
for path in workspace.rglob("*")
|
|
||||||
if path.is_file()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_different_workspaces_are_isolated(tmp_path: Path) -> None:
|
|
||||||
workspace_a = tmp_path / "ws_a"
|
|
||||||
workspace_b = tmp_path / "ws_b"
|
|
||||||
|
|
||||||
manager_a = SessionManager(workspace=workspace_a)
|
|
||||||
session = manager_a.get_or_create("telegram:1")
|
|
||||||
session.add_message("user", "secret-for-a")
|
|
||||||
manager_a.save(session)
|
|
||||||
|
|
||||||
# A second workspace must not see A's session.
|
|
||||||
assert manager_a.sessions_dir != SessionManager(workspace=workspace_b).sessions_dir
|
|
||||||
in_b = SessionManager(workspace=workspace_b).get_or_create("telegram:1")
|
|
||||||
assert in_b.messages == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_sessions_follow_active_custom_config_data_root(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
custom_instance = tmp_path / "instance-b"
|
|
||||||
custom_config = custom_instance / "config.json"
|
|
||||||
default_home = tmp_path / "read-only-home"
|
|
||||||
default_home.mkdir()
|
|
||||||
default_home.chmod(0o500)
|
|
||||||
monkeypatch.setenv("HOME", str(default_home))
|
|
||||||
config = load_config(custom_config)
|
|
||||||
data_dir = config.runtime_data_dir
|
|
||||||
assert data_dir == custom_instance
|
|
||||||
|
|
||||||
manager = SessionManager(
|
|
||||||
workspace=tmp_path / "workspace-b",
|
|
||||||
sessions_root=data_dir / "sessions",
|
|
||||||
)
|
|
||||||
session = manager.get_or_create("telegram:custom")
|
|
||||||
session.add_message("user", "custom-instance")
|
|
||||||
manager.save(session)
|
|
||||||
|
|
||||||
assert manager.sessions_dir.parent == custom_instance / "sessions"
|
|
||||||
assert manager._get_session_path(session.key).exists()
|
|
||||||
assert not (default_home / ".nanobot" / "sessions").exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_session_root_inside_workspace_fails_closed(tmp_path: Path) -> None:
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="must be outside the agent workspace"):
|
|
||||||
SessionManager(workspace=workspace, sessions_root=workspace / "sessions")
|
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_move_preserves_session_identity(tmp_path: Path) -> None:
|
|
||||||
original = tmp_path / "project-old"
|
|
||||||
manager = SessionManager(workspace=original)
|
|
||||||
session = manager.get_or_create("telegram:1")
|
|
||||||
session.add_message("user", "survives-move")
|
|
||||||
manager.save(session)
|
|
||||||
|
|
||||||
moved = tmp_path / "project-new"
|
|
||||||
original.rename(moved)
|
|
||||||
reloaded = SessionManager(workspace=moved)
|
|
||||||
|
|
||||||
assert reloaded.sessions_dir == manager.sessions_dir
|
|
||||||
assert reloaded.get_or_create("telegram:1").messages[-1]["content"] == "survives-move"
|
|
||||||
assert (reloaded.sessions_dir / ".workspace").read_text(encoding="utf-8").strip() == str(
|
|
||||||
moved.resolve()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_deleted_workspace_identity_marker_is_recovered(tmp_path: Path) -> None:
|
|
||||||
workspace = tmp_path / "project"
|
|
||||||
manager = SessionManager(workspace=workspace)
|
|
||||||
session = manager.get_or_create("telegram:1")
|
|
||||||
session.add_message("user", "survives-cleanup")
|
|
||||||
manager.save(session)
|
|
||||||
|
|
||||||
shutil.rmtree(workspace / ".nanobot")
|
|
||||||
reloaded = SessionManager(workspace=workspace)
|
|
||||||
|
|
||||||
assert reloaded.sessions_dir == manager.sessions_dir
|
|
||||||
assert reloaded.get_or_create("telegram:1").messages[-1]["content"] == "survives-cleanup"
|
|
||||||
assert (workspace / ".nanobot" / "workspace-id").read_text(encoding="utf-8").strip() == (
|
|
||||||
manager.sessions_dir.name
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_copied_workspace_gets_isolated_session_identity(tmp_path: Path) -> None:
|
|
||||||
original = tmp_path / "project-a"
|
|
||||||
original.mkdir()
|
|
||||||
manager = SessionManager(workspace=original)
|
|
||||||
session = manager.get_or_create("telegram:1")
|
|
||||||
session.add_message("user", "secret-for-a")
|
|
||||||
manager.save(session)
|
|
||||||
|
|
||||||
copied = tmp_path / "project-b"
|
|
||||||
shutil.copytree(original, copied)
|
|
||||||
copied_manager = SessionManager(workspace=copied)
|
|
||||||
|
|
||||||
assert copied_manager.sessions_dir != manager.sessions_dir
|
|
||||||
assert copied_manager.get_or_create("telegram:1").messages == []
|
|
||||||
assert (copied / ".nanobot" / "workspace-id").read_text(encoding="utf-8") != (
|
|
||||||
original / ".nanobot" / "workspace-id"
|
|
||||||
).read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def test_equivalent_workspace_paths_share_one_store(tmp_path: Path) -> None:
|
|
||||||
real_workspace = tmp_path / "real_ws"
|
|
||||||
real_workspace.mkdir()
|
|
||||||
link_workspace = tmp_path / "link_ws"
|
|
||||||
link_workspace.symlink_to(real_workspace, target_is_directory=True)
|
|
||||||
|
|
||||||
# Save via the real path, then read via a symlink to the same directory.
|
|
||||||
manager = SessionManager(workspace=real_workspace)
|
|
||||||
session = manager.get_or_create("telegram:1")
|
|
||||||
session.add_message("user", "via-real")
|
|
||||||
manager.save(session)
|
|
||||||
|
|
||||||
via_link = SessionManager(workspace=link_workspace).get_or_create("telegram:1")
|
|
||||||
assert via_link.messages[-1]["content"] == "via-real"
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_in_workspace_sessions_are_migrated(tmp_path: Path) -> None:
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
key = "telegram:1"
|
|
||||||
old_file = _write_legacy_session(workspace / "sessions", key, "migrated-msg")
|
|
||||||
|
|
||||||
manager = SessionManager(workspace=workspace)
|
|
||||||
|
|
||||||
# The session is readable through the normal store.
|
|
||||||
loaded = manager.get_or_create(key)
|
|
||||||
assert loaded.messages[-1]["content"] == "migrated-msg"
|
|
||||||
# The legacy in-workspace file has been moved away...
|
|
||||||
assert not old_file.exists()
|
|
||||||
# ...into the out-of-workspace store.
|
|
||||||
assert (manager.sessions_dir / old_file.name).exists()
|
|
||||||
|
|
||||||
# Migration is idempotent: a second construction must not corrupt anything.
|
|
||||||
again = SessionManager(workspace=workspace).get_or_create(key)
|
|
||||||
assert again.messages[-1]["content"] == "migrated-msg"
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_keeps_source_when_install_fails(tmp_path: Path) -> None:
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
key = "telegram:partial"
|
|
||||||
old_file = _write_legacy_session(workspace / "sessions", key, "still-safe")
|
|
||||||
|
|
||||||
with patch.object(JsonlSessionStore, "_install_snapshot", side_effect=OSError("disk full")):
|
|
||||||
manager = SessionManager(workspace=workspace)
|
|
||||||
|
|
||||||
assert old_file.exists()
|
|
||||||
assert not (manager.sessions_dir / old_file.name).exists()
|
|
||||||
|
|
||||||
retried = SessionManager(workspace=workspace)
|
|
||||||
assert retried.get_or_create(key).messages[-1]["content"] == "still-safe"
|
|
||||||
assert not old_file.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_preserves_newest_valid_conflict(tmp_path: Path) -> None:
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
key = "telegram:conflict"
|
|
||||||
old_file = _write_legacy_session(workspace / "sessions", key, "newer-workspace")
|
|
||||||
manager = SessionManager(workspace=workspace)
|
|
||||||
|
|
||||||
# Recreate an older legacy source while a newer destination already exists.
|
|
||||||
manager_session = manager.get_or_create(key)
|
|
||||||
manager_session.add_message("assistant", "newer-destination")
|
|
||||||
manager.save(manager_session)
|
|
||||||
_write_legacy_session(
|
|
||||||
workspace / "sessions",
|
|
||||||
key,
|
|
||||||
"older-workspace",
|
|
||||||
updated_at="2025-01-01T00:00:00",
|
|
||||||
)
|
|
||||||
|
|
||||||
retried = SessionManager(workspace=workspace)
|
|
||||||
loaded = retried.get_or_create(key)
|
|
||||||
|
|
||||||
assert loaded.messages[-1]["content"] == "newer-destination"
|
|
||||||
conflicts = list((retried.sessions_dir / ".migration-conflicts").glob("*.jsonl"))
|
|
||||||
assert len(conflicts) == 1
|
|
||||||
assert "older-workspace" in conflicts[0].read_text(encoding="utf-8")
|
|
||||||
assert not old_file.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_explicit_rollback_restore_copies_sessions_back_without_deleting_new_store(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
manager = SessionManager(workspace=workspace)
|
|
||||||
session = manager.get_or_create("telegram:rollback")
|
|
||||||
session.add_message("user", "available-to-old-version")
|
|
||||||
manager.save(session, fsync=True)
|
|
||||||
|
|
||||||
result = manager.restore_sessions_to_workspace()
|
|
||||||
legacy_file = workspace / "sessions" / manager._get_session_path(session.key).name
|
|
||||||
|
|
||||||
assert result.restored == 1
|
|
||||||
assert result.unchanged == 0
|
|
||||||
assert result.conflicts == ()
|
|
||||||
assert legacy_file.exists()
|
|
||||||
assert manager._get_session_path(session.key).exists()
|
|
||||||
assert "available-to-old-version" in legacy_file.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
repeated = manager.restore_sessions_to_workspace()
|
|
||||||
assert repeated.restored == 0
|
|
||||||
assert repeated.unchanged == 1
|
|
||||||
assert repeated.conflicts == ()
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_migration_rejects_symlinked_session_file(tmp_path: Path) -> None:
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
old_dir = workspace / "sessions"
|
|
||||||
old_dir.mkdir(parents=True)
|
|
||||||
key = "telegram:symlink"
|
|
||||||
outside = _write_legacy_session(tmp_path / "outside", key, "outside-secret")
|
|
||||||
source = old_dir / outside.name
|
|
||||||
try:
|
|
||||||
source.symlink_to(outside)
|
|
||||||
except OSError as exc:
|
|
||||||
pytest.skip(f"file symlink unavailable: {exc}")
|
|
||||||
|
|
||||||
manager = SessionManager(workspace=workspace)
|
|
||||||
|
|
||||||
assert source.is_symlink()
|
|
||||||
assert not (manager.sessions_dir / source.name).exists()
|
|
||||||
assert manager.get_or_create(key).messages == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_migration_rejects_symlinked_sessions_directory(tmp_path: Path) -> None:
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
outside = tmp_path / "outside"
|
|
||||||
key = "telegram:directory-symlink"
|
|
||||||
outside_file = _write_legacy_session(outside, key, "outside-secret")
|
|
||||||
workspace.mkdir()
|
|
||||||
try:
|
|
||||||
(workspace / "sessions").symlink_to(outside, target_is_directory=True)
|
|
||||||
except OSError as exc:
|
|
||||||
pytest.skip(f"directory symlink unavailable: {exc}")
|
|
||||||
|
|
||||||
manager = SessionManager(workspace=workspace)
|
|
||||||
|
|
||||||
assert outside_file.exists()
|
|
||||||
assert not (manager.sessions_dir / outside_file.name).exists()
|
|
||||||
assert manager.get_or_create(key).messages == []
|
|
||||||
@@ -32,7 +32,8 @@ AUTH_HEADERS = {"Authorization": f"Bearer {API_KEY}"}
|
|||||||
def _make_mock_agent(response_text: str = "mock response") -> MagicMock:
|
def _make_mock_agent(response_text: str = "mock response") -> MagicMock:
|
||||||
agent = MagicMock()
|
agent = MagicMock()
|
||||||
agent.process_direct = AsyncMock(return_value=response_text)
|
agent.process_direct = AsyncMock(return_value=response_text)
|
||||||
agent.aclose = AsyncMock()
|
agent._connect_mcp = AsyncMock()
|
||||||
|
agent.close_mcp = AsyncMock()
|
||||||
agent._last_usage = {}
|
agent._last_usage = {}
|
||||||
return agent
|
return agent
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ def test_sse_done_format() -> None:
|
|||||||
def _make_streaming_agent(tokens: list[str]) -> MagicMock:
|
def _make_streaming_agent(tokens: list[str]) -> MagicMock:
|
||||||
"""Create a mock agent that streams tokens via on_stream callback."""
|
"""Create a mock agent that streams tokens via on_stream callback."""
|
||||||
agent = MagicMock()
|
agent = MagicMock()
|
||||||
agent.aclose = AsyncMock()
|
agent._connect_mcp = AsyncMock()
|
||||||
|
agent.close_mcp = AsyncMock()
|
||||||
|
|
||||||
async def fake_process_direct(*, content="", media=None, session_key="",
|
async def fake_process_direct(*, content="", media=None, session_key="",
|
||||||
channel="", chat_id="", on_stream=None,
|
channel="", chat_id="", on_stream=None,
|
||||||
@@ -135,7 +136,8 @@ async def test_stream_false_returns_json(aiohttp_client) -> None:
|
|||||||
"""stream=false should still return regular JSON response."""
|
"""stream=false should still return regular JSON response."""
|
||||||
agent = MagicMock()
|
agent = MagicMock()
|
||||||
agent.process_direct = AsyncMock(return_value="normal reply")
|
agent.process_direct = AsyncMock(return_value="normal reply")
|
||||||
agent.aclose = AsyncMock()
|
agent._connect_mcp = AsyncMock()
|
||||||
|
agent.close_mcp = AsyncMock()
|
||||||
agent._last_usage = {}
|
agent._last_usage = {}
|
||||||
|
|
||||||
app = create_app(agent, model_name="m", api_key=API_KEY)
|
app = create_app(agent, model_name="m", api_key=API_KEY)
|
||||||
@@ -158,7 +160,8 @@ async def test_stream_default_is_false(aiohttp_client) -> None:
|
|||||||
"""Omitting stream should behave like stream=false."""
|
"""Omitting stream should behave like stream=false."""
|
||||||
agent = MagicMock()
|
agent = MagicMock()
|
||||||
agent.process_direct = AsyncMock(return_value="default reply")
|
agent.process_direct = AsyncMock(return_value="default reply")
|
||||||
agent.aclose = AsyncMock()
|
agent._connect_mcp = AsyncMock()
|
||||||
|
agent.close_mcp = AsyncMock()
|
||||||
agent._last_usage = {}
|
agent._last_usage = {}
|
||||||
|
|
||||||
app = create_app(agent, model_name="m", api_key=API_KEY)
|
app = create_app(agent, model_name="m", api_key=API_KEY)
|
||||||
@@ -214,7 +217,8 @@ async def test_stream_passes_on_stream_callbacks(aiohttp_client) -> None:
|
|||||||
|
|
||||||
agent = MagicMock()
|
agent = MagicMock()
|
||||||
agent.process_direct = fake_process_direct
|
agent.process_direct = fake_process_direct
|
||||||
agent.aclose = AsyncMock()
|
agent._connect_mcp = AsyncMock()
|
||||||
|
agent.close_mcp = AsyncMock()
|
||||||
agent._last_usage = {}
|
agent._last_usage = {}
|
||||||
|
|
||||||
app = create_app(agent, model_name="m", api_key=API_KEY)
|
app = create_app(agent, model_name="m", api_key=API_KEY)
|
||||||
@@ -247,7 +251,8 @@ async def test_stream_segment_end_does_not_close_sse(aiohttp_client) -> None:
|
|||||||
return "planning final"
|
return "planning final"
|
||||||
|
|
||||||
agent.process_direct = fake_process_direct
|
agent.process_direct = fake_process_direct
|
||||||
agent.aclose = AsyncMock()
|
agent._connect_mcp = AsyncMock()
|
||||||
|
agent.close_mcp = AsyncMock()
|
||||||
agent._last_usage = {}
|
agent._last_usage = {}
|
||||||
|
|
||||||
app = create_app(agent, model_name="m", api_key=API_KEY)
|
app = create_app(agent, model_name="m", api_key=API_KEY)
|
||||||
@@ -286,7 +291,8 @@ async def test_stream_uses_final_response_when_no_deltas(aiohttp_client) -> None
|
|||||||
return "plain final"
|
return "plain final"
|
||||||
|
|
||||||
agent.process_direct = fake_process_direct
|
agent.process_direct = fake_process_direct
|
||||||
agent.aclose = AsyncMock()
|
agent._connect_mcp = AsyncMock()
|
||||||
|
agent.close_mcp = AsyncMock()
|
||||||
agent._last_usage = {}
|
agent._last_usage = {}
|
||||||
|
|
||||||
app = create_app(agent, model_name="m", api_key=API_KEY)
|
app = create_app(agent, model_name="m", api_key=API_KEY)
|
||||||
@@ -328,7 +334,8 @@ async def test_stream_with_session_id(aiohttp_client) -> None:
|
|||||||
|
|
||||||
agent = MagicMock()
|
agent = MagicMock()
|
||||||
agent.process_direct = fake_process_direct
|
agent.process_direct = fake_process_direct
|
||||||
agent.aclose = AsyncMock()
|
agent._connect_mcp = AsyncMock()
|
||||||
|
agent.close_mcp = AsyncMock()
|
||||||
agent._last_usage = {}
|
agent._last_usage = {}
|
||||||
|
|
||||||
app = create_app(agent, model_name="m", api_key=API_KEY)
|
app = create_app(agent, model_name="m", api_key=API_KEY)
|
||||||
@@ -357,7 +364,8 @@ async def test_streaming_backend_failure_does_not_emit_success_terminator(aiohtt
|
|||||||
raise RuntimeError("backend blew up")
|
raise RuntimeError("backend blew up")
|
||||||
|
|
||||||
agent.process_direct = boom
|
agent.process_direct = boom
|
||||||
agent.aclose = AsyncMock()
|
agent._connect_mcp = AsyncMock()
|
||||||
|
agent.close_mcp = AsyncMock()
|
||||||
agent._last_usage = {}
|
agent._last_usage = {}
|
||||||
|
|
||||||
app = create_app(agent, model_name="m", api_key=API_KEY)
|
app = create_app(agent, model_name="m", api_key=API_KEY)
|
||||||
|
|||||||
@@ -46,9 +46,7 @@ def _write_config(tmp_path: Path, overrides: dict | None = None) -> Path:
|
|||||||
}
|
}
|
||||||
if overrides:
|
if overrides:
|
||||||
data.update(overrides)
|
data.update(overrides)
|
||||||
config_dir = tmp_path.parent / f"{tmp_path.name}-instance"
|
config_path = tmp_path / "config.json"
|
||||||
config_dir.mkdir(exist_ok=True)
|
|
||||||
config_path = config_dir / "config.json"
|
|
||||||
config_path.write_text(json.dumps(data))
|
config_path.write_text(json.dumps(data))
|
||||||
return config_path
|
return config_path
|
||||||
|
|
||||||
@@ -98,24 +96,9 @@ def test_from_config_missing_env_reports_explicit_config_path(
|
|||||||
|
|
||||||
def test_from_config_creates_instance(tmp_path):
|
def test_from_config_creates_instance(tmp_path):
|
||||||
config_path = _write_config(tmp_path)
|
config_path = _write_config(tmp_path)
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
bot = Nanobot.from_config(config_path, workspace=workspace)
|
|
||||||
assert bot._loop is not None
|
|
||||||
assert bot._loop.workspace == workspace
|
|
||||||
assert bot._loop.sessions.sessions_dir.parent == config_path.parent / "sessions"
|
|
||||||
|
|
||||||
|
|
||||||
def test_from_config_composes_configured_mcp_outside_agent_loop(tmp_path):
|
|
||||||
config_path = _write_config(
|
|
||||||
tmp_path,
|
|
||||||
{"tools": {"mcpServers": {"demo": {"command": "fake-mcp"}}}},
|
|
||||||
)
|
|
||||||
|
|
||||||
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
||||||
|
assert bot._loop is not None
|
||||||
assert bot._mcp_provider is not None
|
assert bot._loop.workspace == tmp_path
|
||||||
assert bot._mcp_provider.configured_server_names == {"demo"}
|
|
||||||
assert bot._mcp_provider._registry is bot._loop.tools
|
|
||||||
|
|
||||||
|
|
||||||
def test_from_config_accepts_default_model_override(tmp_path):
|
def test_from_config_accepts_default_model_override(tmp_path):
|
||||||
@@ -1654,40 +1637,37 @@ async def test_runtime_helpers_expose_model_workspace_and_compact(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_aclose_releases_loop_and_mcp_provider(tmp_path):
|
async def test_aclose_delegates_to_loop_close_mcp(tmp_path):
|
||||||
config_path = _write_config(tmp_path)
|
config_path = _write_config(tmp_path)
|
||||||
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
||||||
bot._loop.aclose = AsyncMock()
|
bot._loop.close_mcp = AsyncMock()
|
||||||
assert bot._mcp_provider is not None
|
|
||||||
bot._mcp_provider.aclose = AsyncMock()
|
|
||||||
|
|
||||||
await bot.aclose()
|
await bot.aclose()
|
||||||
|
|
||||||
bot._loop.aclose.assert_awaited_once()
|
bot._loop.close_mcp.assert_awaited_once()
|
||||||
bot._mcp_provider.aclose.assert_awaited_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_context_manager_calls_aclose_on_exit(tmp_path):
|
async def test_context_manager_calls_aclose_on_exit(tmp_path):
|
||||||
config_path = _write_config(tmp_path)
|
config_path = _write_config(tmp_path)
|
||||||
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
||||||
bot._loop.aclose = AsyncMock()
|
bot._loop.close_mcp = AsyncMock()
|
||||||
|
|
||||||
async with bot as b:
|
async with bot as b:
|
||||||
assert b is bot
|
assert b is bot
|
||||||
|
|
||||||
bot._loop.aclose.assert_awaited_once()
|
bot._loop.close_mcp.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_context_manager_does_not_swallow_exceptions(tmp_path):
|
async def test_context_manager_does_not_swallow_exceptions(tmp_path):
|
||||||
config_path = _write_config(tmp_path)
|
config_path = _write_config(tmp_path)
|
||||||
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
||||||
bot._loop.aclose = AsyncMock()
|
bot._loop.close_mcp = AsyncMock()
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
async with bot as b:
|
async with bot as b:
|
||||||
assert b is bot
|
assert b is bot
|
||||||
raise ValueError("boom")
|
raise ValueError("boom")
|
||||||
|
|
||||||
bot._loop.aclose.assert_awaited_once()
|
bot._loop.close_mcp.assert_awaited_once()
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ AUTH_HEADERS = {"Authorization": f"Bearer {API_KEY}"}
|
|||||||
def _make_mock_agent(response_text: str = "mock response") -> MagicMock:
|
def _make_mock_agent(response_text: str = "mock response") -> MagicMock:
|
||||||
agent = MagicMock()
|
agent = MagicMock()
|
||||||
agent.process_direct = AsyncMock(return_value=response_text)
|
agent.process_direct = AsyncMock(return_value=response_text)
|
||||||
agent.aclose = AsyncMock()
|
agent._connect_mcp = AsyncMock()
|
||||||
|
agent.close_mcp = AsyncMock()
|
||||||
agent._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
agent._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
||||||
return agent
|
return agent
|
||||||
|
|
||||||
@@ -148,59 +149,6 @@ async def test_api_routes_allow_requests_without_configured_api_key(aiohttp_clie
|
|||||||
mock_agent.process_direct.assert_called_once()
|
mock_agent.process_direct.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_api_prepares_application_resources_before_each_turn(aiohttp_client) -> None:
|
|
||||||
events: list[str] = []
|
|
||||||
agent = _make_mock_agent()
|
|
||||||
|
|
||||||
async def prepare_agent() -> None:
|
|
||||||
events.append("prepare")
|
|
||||||
|
|
||||||
async def process_direct(**_kwargs):
|
|
||||||
events.append("process")
|
|
||||||
return "ready"
|
|
||||||
|
|
||||||
agent.process_direct = process_direct
|
|
||||||
app = create_app(agent, prepare_agent=prepare_agent)
|
|
||||||
client = await aiohttp_client(app)
|
|
||||||
|
|
||||||
response = await client.post(
|
|
||||||
"/v1/chat/completions",
|
|
||||||
json={"messages": [{"role": "user", "content": "hello"}]},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status == 200
|
|
||||||
assert events == ["prepare", "process"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_api_preparation_is_bounded_by_request_timeout(aiohttp_client) -> None:
|
|
||||||
agent = _make_mock_agent()
|
|
||||||
started = asyncio.Event()
|
|
||||||
|
|
||||||
async def prepare_agent() -> None:
|
|
||||||
started.set()
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
|
|
||||||
app = create_app(
|
|
||||||
agent,
|
|
||||||
request_timeout=0.01,
|
|
||||||
prepare_agent=prepare_agent,
|
|
||||||
)
|
|
||||||
client = await aiohttp_client(app)
|
|
||||||
|
|
||||||
response = await client.post(
|
|
||||||
"/v1/chat/completions",
|
|
||||||
json={"messages": [{"role": "user", "content": "hello"}]},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert started.is_set()
|
|
||||||
assert response.status == 504
|
|
||||||
agent.process_direct.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_no_user_message_returns_400(aiohttp_client, app) -> None:
|
async def test_no_user_message_returns_400(aiohttp_client, app) -> None:
|
||||||
@@ -327,7 +275,8 @@ async def test_followup_requests_share_same_session_key(aiohttp_client) -> None:
|
|||||||
|
|
||||||
agent = MagicMock()
|
agent = MagicMock()
|
||||||
agent.process_direct = fake_process
|
agent.process_direct = fake_process
|
||||||
agent.aclose = AsyncMock()
|
agent._connect_mcp = AsyncMock()
|
||||||
|
agent.close_mcp = AsyncMock()
|
||||||
agent._last_usage = {}
|
agent._last_usage = {}
|
||||||
|
|
||||||
app = create_app(agent, model_name="m", api_key=API_KEY)
|
app = create_app(agent, model_name="m", api_key=API_KEY)
|
||||||
@@ -366,7 +315,8 @@ async def test_fixed_session_requests_are_serialized(aiohttp_client) -> None:
|
|||||||
|
|
||||||
agent = MagicMock()
|
agent = MagicMock()
|
||||||
agent.process_direct = slow_process
|
agent.process_direct = slow_process
|
||||||
agent.aclose = AsyncMock()
|
agent._connect_mcp = AsyncMock()
|
||||||
|
agent.close_mcp = AsyncMock()
|
||||||
agent._last_usage = {}
|
agent._last_usage = {}
|
||||||
|
|
||||||
app = create_app(agent, model_name="m", api_key=API_KEY)
|
app = create_app(agent, model_name="m", api_key=API_KEY)
|
||||||
@@ -483,7 +433,8 @@ async def test_empty_response_falls_back_without_retry(aiohttp_client) -> None:
|
|||||||
|
|
||||||
agent = MagicMock()
|
agent = MagicMock()
|
||||||
agent.process_direct = always_empty
|
agent.process_direct = always_empty
|
||||||
agent.aclose = AsyncMock()
|
agent._connect_mcp = AsyncMock()
|
||||||
|
agent.close_mcp = AsyncMock()
|
||||||
agent._last_usage = {}
|
agent._last_usage = {}
|
||||||
|
|
||||||
app = create_app(agent, model_name="m", api_key=API_KEY)
|
app = create_app(agent, model_name="m", api_key=API_KEY)
|
||||||
@@ -506,6 +457,7 @@ async def test_process_direct_accepts_media() -> None:
|
|||||||
from nanobot.bus.runtime_events import RuntimeEventPublisher
|
from nanobot.bus.runtime_events import RuntimeEventPublisher
|
||||||
|
|
||||||
loop = AgentLoop.__new__(AgentLoop)
|
loop = AgentLoop.__new__(AgentLoop)
|
||||||
|
loop._connect_mcp = AsyncMock()
|
||||||
loop._session_locks = {}
|
loop._session_locks = {}
|
||||||
loop.runtime_event_publisher = RuntimeEventPublisher()
|
loop.runtime_event_publisher = RuntimeEventPublisher()
|
||||||
|
|
||||||
|
|||||||
@@ -76,31 +76,6 @@ def test_guard_allow_patterns_block_single_ampersand_chained_segment():
|
|||||||
assert "allowlist" in result.lower()
|
assert "allowlist" in result.lower()
|
||||||
|
|
||||||
|
|
||||||
def test_guard_allow_patterns_block_newline_chained_segment():
|
|
||||||
"""A newline separates commands, so each line must match on its own."""
|
|
||||||
tool = ExecTool(allow_patterns=[r"echo\s+allowlisted\s*.*"])
|
|
||||||
|
|
||||||
result = tool._guard_command("echo allowlisted\ntouch /tmp/evil", "/tmp")
|
|
||||||
assert result is not None
|
|
||||||
assert "allowlist" in result.lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_guard_newline_chained_segment_still_hits_deny_patterns():
|
|
||||||
"""An allowlisted first line does not exempt a denied later line."""
|
|
||||||
tool = ExecTool(allow_patterns=[r"echo\s+allowlisted\s*.*"])
|
|
||||||
|
|
||||||
result = tool._guard_command("echo allowlisted\nrm -rf /", "/tmp")
|
|
||||||
assert result is not None
|
|
||||||
assert "deny pattern filter" in result.lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_split_shell_segments_keep_line_continuation_intact():
|
|
||||||
"""A backslash-escaped newline continues one command, not a new segment."""
|
|
||||||
assert ExecTool._split_shell_segments("echo allowlisted \\\nextra") == [
|
|
||||||
"echo allowlisted \\\nextra"
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_guard_allow_patterns_preserve_trailing_background_operator():
|
def test_guard_allow_patterns_preserve_trailing_background_operator():
|
||||||
tool = ExecTool(allow_patterns=[r"echo\s+allowlisted"])
|
tool = ExecTool(allow_patterns=[r"echo\s+allowlisted"])
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ platform-specific binaries (all subprocess calls are mocked).
|
|||||||
import asyncio
|
import asyncio
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -21,19 +21,6 @@ _WINDOWS_ENV_KEYS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class _FakeWindowsJob:
|
|
||||||
creation_flags = 0
|
|
||||||
|
|
||||||
def assign_and_resume(self, pid: int) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def release(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def terminate(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _build_env
|
# _build_env
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -146,32 +133,6 @@ class TestSpawnUnix:
|
|||||||
|
|
||||||
class TestSpawnWindows:
|
class TestSpawnWindows:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_job_assignment_failure_kills_suspended_process(self):
|
|
||||||
env = {"PATH": ""}
|
|
||||||
process = AsyncMock()
|
|
||||||
process.pid = 123
|
|
||||||
process.returncode = None
|
|
||||||
process.kill = MagicMock()
|
|
||||||
process.wait.return_value = -9
|
|
||||||
job = MagicMock(spec=_FakeWindowsJob)
|
|
||||||
job.creation_flags = 0x4
|
|
||||||
job.assign_and_resume.side_effect = OSError("OpenProcess failed")
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
|
||||||
patch("nanobot.agent.tools.shell.sys", MagicMock(platform="win32")),
|
|
||||||
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
|
|
||||||
patch.object(ExecTool, "_create_windows_job", return_value=job),
|
|
||||||
pytest.raises(OSError, match="OpenProcess failed"),
|
|
||||||
):
|
|
||||||
mock_exec.return_value = process
|
|
||||||
await ExecTool._spawn("echo hi", r"C:\work", env, process_tree=True)
|
|
||||||
|
|
||||||
job.terminate.assert_called_once_with()
|
|
||||||
process.kill.assert_called_once_with()
|
|
||||||
process.wait.assert_awaited_once_with()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_single_line_uses_powershell(self):
|
async def test_single_line_uses_powershell(self):
|
||||||
"""Single-line commands on Windows now route through PowerShell."""
|
"""Single-line commands on Windows now route through PowerShell."""
|
||||||
@@ -341,9 +302,7 @@ class TestPathAppendPlatform:
|
|||||||
captured_cmd = None
|
captured_cmd = None
|
||||||
captured_env = {}
|
captured_env = {}
|
||||||
|
|
||||||
async def capture_spawn(
|
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True):
|
||||||
cmd, cwd, env, shell_program=None, login=True, *, process_tree=False,
|
|
||||||
):
|
|
||||||
nonlocal captured_cmd
|
nonlocal captured_cmd
|
||||||
captured_cmd = cmd
|
captured_cmd = cmd
|
||||||
captured_env.update(env)
|
captured_env.update(env)
|
||||||
@@ -372,9 +331,7 @@ class TestPathAppendPlatform:
|
|||||||
captured_cmd = None
|
captured_cmd = None
|
||||||
captured_env = {}
|
captured_env = {}
|
||||||
|
|
||||||
async def capture_spawn(
|
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True, *, stdin=None):
|
||||||
cmd, cwd, env, shell_program=None, login=True, *, stdin=None, process_tree=False,
|
|
||||||
):
|
|
||||||
nonlocal captured_cmd
|
nonlocal captured_cmd
|
||||||
captured_cmd = cmd
|
captured_cmd = cmd
|
||||||
captured_env.update(env)
|
captured_env.update(env)
|
||||||
@@ -402,9 +359,7 @@ class TestPathAppendPlatform:
|
|||||||
captured_cmd = None
|
captured_cmd = None
|
||||||
captured_env = {}
|
captured_env = {}
|
||||||
|
|
||||||
async def capture_spawn(
|
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True, *, stdin=None):
|
||||||
cmd, cwd, env, shell_program=None, login=True, *, stdin=None, process_tree=False,
|
|
||||||
):
|
|
||||||
nonlocal captured_cmd
|
nonlocal captured_cmd
|
||||||
captured_cmd = cmd
|
captured_cmd = cmd
|
||||||
captured_env.update(env)
|
captured_env.update(env)
|
||||||
@@ -434,9 +389,7 @@ class TestPathAppendPlatform:
|
|||||||
|
|
||||||
captured_env = {}
|
captured_env = {}
|
||||||
|
|
||||||
async def capture_spawn(
|
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True):
|
||||||
cmd, cwd, env, shell_program=None, login=True, *, process_tree=False,
|
|
||||||
):
|
|
||||||
captured_env.update(env)
|
captured_env.update(env)
|
||||||
return mock_proc
|
return mock_proc
|
||||||
|
|
||||||
@@ -459,9 +412,7 @@ class TestPathAppendPlatform:
|
|||||||
|
|
||||||
captured_env = {}
|
captured_env = {}
|
||||||
|
|
||||||
async def capture_spawn(
|
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True, *, stdin=None):
|
||||||
cmd, cwd, env, shell_program=None, login=True, *, stdin=None, process_tree=False,
|
|
||||||
):
|
|
||||||
captured_env.update(env)
|
captured_env.update(env)
|
||||||
return mock_proc
|
return mock_proc
|
||||||
|
|
||||||
@@ -607,9 +558,7 @@ class TestExecuteEndToEnd:
|
|||||||
mock_proc.returncode = 0
|
mock_proc.returncode = 0
|
||||||
captured_login = []
|
captured_login = []
|
||||||
|
|
||||||
async def capture_spawn(
|
async def capture_spawn(cmd, cwd, env, shell_program=None, login=None, *, stdin=None):
|
||||||
cmd, cwd, env, shell_program=None, login=None, *, stdin=None, process_tree=False,
|
|
||||||
):
|
|
||||||
captured_login.append(login)
|
captured_login.append(login)
|
||||||
return mock_proc
|
return mock_proc
|
||||||
|
|
||||||
@@ -700,7 +649,6 @@ class TestWindowsMultilineExec:
|
|||||||
with (
|
with (
|
||||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
||||||
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
|
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
|
||||||
patch.object(ExecTool, "_create_windows_job", side_effect=_FakeWindowsJob),
|
|
||||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
patch.object(ExecTool, "_guard_command", return_value=None),
|
||||||
):
|
):
|
||||||
mock_exec.return_value = mock_proc
|
mock_exec.return_value = mock_proc
|
||||||
@@ -722,7 +670,6 @@ class TestWindowsMultilineExec:
|
|||||||
with (
|
with (
|
||||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
||||||
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
|
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
|
||||||
patch.object(ExecTool, "_create_windows_job", side_effect=_FakeWindowsJob),
|
|
||||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
patch.object(ExecTool, "_guard_command", return_value=None),
|
||||||
):
|
):
|
||||||
mock_exec.return_value = mock_proc
|
mock_exec.return_value = mock_proc
|
||||||
@@ -786,7 +733,6 @@ class TestResolveShellWindows:
|
|||||||
with (
|
with (
|
||||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
||||||
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
|
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
|
||||||
patch.object(ExecTool, "_create_windows_job", side_effect=_FakeWindowsJob),
|
|
||||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
patch.object(ExecTool, "_guard_command", return_value=None),
|
||||||
):
|
):
|
||||||
mock_exec.return_value = mock_proc
|
mock_exec.return_value = mock_proc
|
||||||
@@ -808,7 +754,6 @@ class TestResolveShellWindows:
|
|||||||
with (
|
with (
|
||||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
||||||
patch("asyncio.create_subprocess_shell", new_callable=AsyncMock) as mock_shell,
|
patch("asyncio.create_subprocess_shell", new_callable=AsyncMock) as mock_shell,
|
||||||
patch.object(ExecTool, "_create_windows_job", side_effect=_FakeWindowsJob),
|
|
||||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
patch.object(ExecTool, "_guard_command", return_value=None),
|
||||||
):
|
):
|
||||||
mock_shell.return_value = mock_proc
|
mock_shell.return_value = mock_proc
|
||||||
|
|||||||
@@ -159,7 +159,6 @@ async def test_exec_blocks_chained_internal_url():
|
|||||||
"cp /tmp/x memory/.dream_cursor",
|
"cp /tmp/x memory/.dream_cursor",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_exec_blocks_writes_to_history_jsonl(command):
|
def test_exec_blocks_writes_to_history_jsonl(command):
|
||||||
"""Direct writes to history.jsonl / .dream_cursor must be blocked (#2989)."""
|
"""Direct writes to history.jsonl / .dream_cursor must be blocked (#2989)."""
|
||||||
tool = ExecTool()
|
tool = ExecTool()
|
||||||
@@ -180,7 +179,6 @@ def test_exec_blocks_writes_to_history_jsonl(command):
|
|||||||
"echo history.jsonl",
|
"echo history.jsonl",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_exec_allows_reads_of_history_jsonl(command):
|
def test_exec_allows_reads_of_history_jsonl(command):
|
||||||
"""Read-only access to history.jsonl must still be allowed."""
|
"""Read-only access to history.jsonl must still be allowed."""
|
||||||
tool = ExecTool()
|
tool = ExecTool()
|
||||||
@@ -276,7 +274,6 @@ async def test_exec_ignores_workspace_check_when_not_restricted(tmp_path):
|
|||||||
"cat /dev/fd/3",
|
"cat /dev/fd/3",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_exec_allows_benign_device_targets_inside_workspace(tmp_path, command):
|
def test_exec_allows_benign_device_targets_inside_workspace(tmp_path, command):
|
||||||
workspace = tmp_path / "workspace"
|
workspace = tmp_path / "workspace"
|
||||||
workspace.mkdir()
|
workspace.mkdir()
|
||||||
@@ -429,7 +426,6 @@ def test_exec_bwrap_bind_parent_does_not_widen_workspace_guard(tmp_path, monkeyp
|
|||||||
"|format",
|
"|format",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_exec_blocks_format_command(command):
|
def test_exec_blocks_format_command(command):
|
||||||
"""The Windows ``format`` disk command must be denied."""
|
"""The Windows ``format`` disk command must be denied."""
|
||||||
tool = ExecTool()
|
tool = ExecTool()
|
||||||
@@ -449,7 +445,6 @@ def test_exec_blocks_format_command(command):
|
|||||||
"echo reformat",
|
"echo reformat",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_exec_allows_format_in_url_and_args(command):
|
def test_exec_allows_format_in_url_and_args(command):
|
||||||
"""``format`` inside URL parameters or as a non-command arg must be allowed."""
|
"""``format`` inside URL parameters or as a non-command arg must be allowed."""
|
||||||
tool = ExecTool()
|
tool = ExecTool()
|
||||||
@@ -501,194 +496,3 @@ def test_exec_blocks_outside_paths_from_subdirectory(tmp_path):
|
|||||||
)
|
)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert "path outside working dir" in result
|
assert "path outside working dir" in result
|
||||||
|
|
||||||
def test_exec_blocks_outside_paths_with_redirection_and_delimiters(tmp_path):
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
workspace.mkdir()
|
|
||||||
outside = tmp_path / "secrets"
|
|
||||||
outside.mkdir()
|
|
||||||
|
|
||||||
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
|
|
||||||
|
|
||||||
for cmd in (
|
|
||||||
f"cat<{outside / 'key.pem'}",
|
|
||||||
f"cat <{outside / 'key.pem'}",
|
|
||||||
f"({outside / 'key.pem'})",
|
|
||||||
f"cat {{{outside / 'key.pem'}}}",
|
|
||||||
):
|
|
||||||
result = tool._guard_command(cmd, str(workspace), workspace_root=str(workspace))
|
|
||||||
assert result is not None, f"Expected {cmd} to be blocked"
|
|
||||||
assert "path outside working dir" in result
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink and quoting semantics")
|
|
||||||
@pytest.mark.parametrize("quoted", [True, False])
|
|
||||||
def test_exec_does_not_truncate_parentheses_in_symlink_paths(tmp_path, quoted):
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
outside = tmp_path / "outside"
|
|
||||||
workspace.mkdir()
|
|
||||||
outside.mkdir()
|
|
||||||
(outside / "secret.txt").write_text("secret")
|
|
||||||
link = workspace / "linked)dir"
|
|
||||||
link.symlink_to(outside, target_is_directory=True)
|
|
||||||
escaped_link = str(link).replace(")", r"\)")
|
|
||||||
rendered = f'"{link}/secret.txt"' if quoted else f"{escaped_link}/secret.txt"
|
|
||||||
command = f"cat {rendered}"
|
|
||||||
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
|
|
||||||
|
|
||||||
assert f"{link}/secret.txt" in tool._extract_absolute_paths(command)
|
|
||||||
result = tool._guard_command(command, str(workspace), workspace_root=str(workspace))
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert "path outside working dir" in result
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX command substitution semantics")
|
|
||||||
def test_exec_checks_leaf_symlink_inside_command_substitution(tmp_path):
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
outside = tmp_path / "outside"
|
|
||||||
workspace.mkdir()
|
|
||||||
outside.mkdir()
|
|
||||||
link = workspace / "secret-link"
|
|
||||||
link.symlink_to(outside, target_is_directory=True)
|
|
||||||
command = f'cat "$(printf %s {link})"'
|
|
||||||
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
|
|
||||||
|
|
||||||
assert str(link) in tool._extract_absolute_paths(command)
|
|
||||||
result = tool._guard_command(command, str(workspace), workspace_root=str(workspace))
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert "path outside working dir" in result
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("command", "not_a_posix_path"),
|
|
||||||
[
|
|
||||||
("curl https://example.com/outside/file", "/outside/file"),
|
|
||||||
("curl 'https://example.com/?next=/etc/passwd'", "/etc/passwd"),
|
|
||||||
("curl --url=https://example.com/?next=/etc/passwd", "/etc/passwd"),
|
|
||||||
("scp host:/etc/passwd .", "/etc/passwd"),
|
|
||||||
("echo C:/Windows/System32", "/Windows/System32"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_exec_does_not_misclassify_nonlocal_slash_strings(command, not_a_posix_path):
|
|
||||||
assert not_a_posix_path not in ExecTool._extract_absolute_paths(command)
|
|
||||||
|
|
||||||
|
|
||||||
def test_exec_extracts_quoted_path_with_shell_punctuation():
|
|
||||||
path = "/tmp/a file)/with, punctuation"
|
|
||||||
|
|
||||||
assert ExecTool._extract_absolute_paths(f'cat "{path}"') == [path]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("uri", ["file:///etc/passwd", "file://localhost/%65tc/passwd"])
|
|
||||||
def test_exec_extracts_local_file_uri(uri):
|
|
||||||
assert "/etc/passwd" in ExecTool._extract_absolute_paths(f"curl {uri}")
|
|
||||||
|
|
||||||
|
|
||||||
def test_exec_blocks_file_uri_outside_workspace(tmp_path):
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
outside = tmp_path / "outside file.txt"
|
|
||||||
workspace.mkdir()
|
|
||||||
outside.write_text("secret")
|
|
||||||
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
|
|
||||||
|
|
||||||
result = tool._guard_command(
|
|
||||||
f"curl {outside.as_uri()}",
|
|
||||||
str(workspace),
|
|
||||||
workspace_root=str(workspace),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert "path outside working dir" in result
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX command substitution semantics")
|
|
||||||
def test_exec_checks_file_uri_leaf_symlink_inside_command_substitution(tmp_path):
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
outside = tmp_path / "outside"
|
|
||||||
workspace.mkdir()
|
|
||||||
outside.mkdir()
|
|
||||||
link = workspace / "secret-link"
|
|
||||||
link.symlink_to(outside, target_is_directory=True)
|
|
||||||
command = f'curl "$(printf file://{link})"'
|
|
||||||
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
|
|
||||||
|
|
||||||
assert str(link) in tool._extract_absolute_paths(command)
|
|
||||||
result = tool._guard_command(command, str(workspace), workspace_root=str(workspace))
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert "path outside working dir" in result
|
|
||||||
|
|
||||||
|
|
||||||
def test_exec_keeps_quoted_parenthesis_path_inside_workspace_allowed(tmp_path):
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
inside = workspace / "linked)dir" / "file.txt"
|
|
||||||
inside.parent.mkdir(parents=True)
|
|
||||||
inside.write_text("safe")
|
|
||||||
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
|
|
||||||
|
|
||||||
assert tool._guard_command(
|
|
||||||
f'cat "{inside}"',
|
|
||||||
str(workspace),
|
|
||||||
workspace_root=str(workspace),
|
|
||||||
) is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink and assignment semantics")
|
|
||||||
def test_exec_keeps_quoted_assignment_punctuation_inside_workspace_allowed(tmp_path):
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
outside = tmp_path / "outside"
|
|
||||||
workspace.mkdir()
|
|
||||||
outside.mkdir()
|
|
||||||
(workspace / "linked").symlink_to(outside, target_is_directory=True)
|
|
||||||
inside = workspace / "linked;dir" / "file.txt"
|
|
||||||
inside.parent.mkdir()
|
|
||||||
inside.write_text("safe")
|
|
||||||
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
|
|
||||||
|
|
||||||
assert tool._guard_command(
|
|
||||||
f'x="{inside}"; cat "$x"',
|
|
||||||
str(workspace),
|
|
||||||
workspace_root=str(workspace),
|
|
||||||
) is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell command-string semantics")
|
|
||||||
def test_exec_recursively_checks_compact_shell_command_string(tmp_path):
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
outside = tmp_path / "outside"
|
|
||||||
workspace.mkdir()
|
|
||||||
outside.mkdir()
|
|
||||||
link = workspace / "secret-link"
|
|
||||||
link.symlink_to(outside, target_is_directory=True)
|
|
||||||
command = f'sh -c "x={link};cat \\"$x\\""'
|
|
||||||
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
|
|
||||||
|
|
||||||
assert str(link) in tool._extract_absolute_paths(command)
|
|
||||||
result = tool._guard_command(command, str(workspace), workspace_root=str(workspace))
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert "path outside working dir" in result
|
|
||||||
|
|
||||||
|
|
||||||
def test_exec_malformed_quote_still_extracts_path():
|
|
||||||
assert "/etc/passwd" in ExecTool._extract_absolute_paths('cat "/etc/passwd')
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX double-slash path semantics")
|
|
||||||
@pytest.mark.parametrize("path", ["//etc/passwd", "///etc/passwd"])
|
|
||||||
def test_exec_blocks_double_slash_absolute_paths(tmp_path, path):
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
workspace.mkdir()
|
|
||||||
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
|
|
||||||
|
|
||||||
assert path in tool._extract_absolute_paths(f"cat {path}")
|
|
||||||
result = tool._guard_command(
|
|
||||||
f"cat {path}",
|
|
||||||
str(workspace),
|
|
||||||
workspace_root=str(workspace),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert "path outside working dir" in result
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from unittest.mock import AsyncMock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent import context as agent_context
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.agent.tools.context import RequestContext, bind_request_context, reset_request_context
|
from nanobot.agent.tools.context import RequestContext, bind_request_context, reset_request_context
|
||||||
from nanobot.agent.tools.exec_session import (
|
from nanobot.agent.tools.exec_session import (
|
||||||
@@ -711,7 +712,7 @@ def test_exec_session_manager_preserves_single_cleanup_error():
|
|||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
def test_agent_loop_shutdown_closes_exec_sessions(tmp_path):
|
def test_agent_loop_shutdown_closes_exec_sessions(tmp_path, monkeypatch):
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
manager = ExecSessionManager()
|
manager = ExecSessionManager()
|
||||||
tool = ExecTool(working_dir=str(tmp_path), timeout=30, session_manager=manager)
|
tool = ExecTool(working_dir=str(tmp_path), timeout=30, session_manager=manager)
|
||||||
@@ -722,13 +723,14 @@ def test_agent_loop_shutdown_closes_exec_sessions(tmp_path):
|
|||||||
sid = _session_id(initial)
|
sid = _session_id(initial)
|
||||||
process = manager._sessions[sid].process
|
process = manager._sessions[sid].process
|
||||||
|
|
||||||
|
monkeypatch.setattr(agent_context, "close_mcp", lambda _state: asyncio.sleep(0))
|
||||||
loop = object.__new__(AgentLoop)
|
loop = object.__new__(AgentLoop)
|
||||||
loop._background_tasks = set()
|
loop._background_tasks = set()
|
||||||
loop._exec_session_manager = manager
|
loop._exec_session_manager = manager
|
||||||
loop.subagents = SimpleNamespace(close=AsyncMock())
|
loop.subagents = SimpleNamespace(close=AsyncMock())
|
||||||
|
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
assert process.returncode is not None
|
assert process.returncode is not None
|
||||||
assert manager._sessions == {}
|
assert manager._sessions == {}
|
||||||
@@ -737,7 +739,7 @@ def test_agent_loop_shutdown_closes_exec_sessions(tmp_path):
|
|||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
def test_agent_loop_shutdown_attempts_all_cleanup_after_errors():
|
def test_agent_loop_shutdown_attempts_all_cleanup_after_errors(monkeypatch):
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
loop = object.__new__(AgentLoop)
|
loop = object.__new__(AgentLoop)
|
||||||
loop._background_tasks = set()
|
loop._background_tasks = set()
|
||||||
@@ -747,12 +749,16 @@ def test_agent_loop_shutdown_attempts_all_cleanup_after_errors():
|
|||||||
loop._exec_session_manager = SimpleNamespace(
|
loop._exec_session_manager = SimpleNamespace(
|
||||||
close_all=AsyncMock(side_effect=OSError("exec cleanup failed")),
|
close_all=AsyncMock(side_effect=OSError("exec cleanup failed")),
|
||||||
)
|
)
|
||||||
|
close_mcp = AsyncMock()
|
||||||
|
monkeypatch.setattr(agent_context, "close_mcp", close_mcp)
|
||||||
|
|
||||||
with pytest.raises(BaseExceptionGroup) as exc_info:
|
with pytest.raises(BaseExceptionGroup) as exc_info:
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
assert len(exc_info.value.exceptions) == 2
|
assert len(exc_info.value.exceptions) == 2
|
||||||
loop.subagents.close.assert_awaited_once()
|
loop.subagents.close.assert_awaited_once()
|
||||||
loop._exec_session_manager.close_all.assert_awaited_once()
|
loop._exec_session_manager.close_all.assert_awaited_once()
|
||||||
|
close_mcp.assert_awaited_once_with(loop)
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
@@ -886,7 +892,7 @@ def test_terminate_by_owner_skips_sessions_without_owner_key(tmp_path):
|
|||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
def test_agent_loop_shutdown_preserves_single_cleanup_error():
|
def test_agent_loop_shutdown_preserves_single_cleanup_error(monkeypatch):
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
loop = object.__new__(AgentLoop)
|
loop = object.__new__(AgentLoop)
|
||||||
loop._background_tasks = set()
|
loop._background_tasks = set()
|
||||||
@@ -894,9 +900,13 @@ def test_agent_loop_shutdown_preserves_single_cleanup_error():
|
|||||||
close=AsyncMock(side_effect=RuntimeError("subagent cleanup failed")),
|
close=AsyncMock(side_effect=RuntimeError("subagent cleanup failed")),
|
||||||
)
|
)
|
||||||
loop._exec_session_manager = SimpleNamespace(close_all=AsyncMock())
|
loop._exec_session_manager = SimpleNamespace(close_all=AsyncMock())
|
||||||
|
close_mcp = AsyncMock()
|
||||||
|
monkeypatch.setattr(agent_context, "close_mcp", close_mcp)
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="subagent cleanup failed"):
|
with pytest.raises(RuntimeError, match="subagent cleanup failed"):
|
||||||
await loop.aclose()
|
await loop.close_mcp()
|
||||||
|
|
||||||
loop._exec_session_manager.close_all.assert_awaited_once()
|
loop._exec_session_manager.close_all.assert_awaited_once()
|
||||||
|
close_mcp.assert_awaited_once_with(loop)
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import pytest
|
|||||||
import nanobot.agent.tools.mcp as mcp_mod
|
import nanobot.agent.tools.mcp as mcp_mod
|
||||||
from nanobot.agent.tools.mcp import (
|
from nanobot.agent.tools.mcp import (
|
||||||
MCPPromptWrapper,
|
MCPPromptWrapper,
|
||||||
MCPProvider,
|
|
||||||
MCPResourceWrapper,
|
MCPResourceWrapper,
|
||||||
MCPToolWrapper,
|
MCPToolWrapper,
|
||||||
_normalize_windows_stdio_command,
|
_normalize_windows_stdio_command,
|
||||||
@@ -154,7 +153,7 @@ def _make_wrapper(session: object, *, timeout: float = 0.1) -> MCPToolWrapper:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mcp_provider_connect_propagates_external_cancellation(monkeypatch) -> None:
|
async def test_connect_missing_servers_propagates_external_cancellation(monkeypatch) -> None:
|
||||||
started = asyncio.Event()
|
started = asyncio.Event()
|
||||||
|
|
||||||
async def connect_mcp_servers(_servers: dict, _registry: ToolRegistry) -> dict:
|
async def connect_mcp_servers(_servers: dict, _registry: ToolRegistry) -> dict:
|
||||||
@@ -162,21 +161,24 @@ async def test_mcp_provider_connect_propagates_external_cancellation(monkeypatch
|
|||||||
await asyncio.sleep(60)
|
await asyncio.sleep(60)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
provider = MCPProvider(
|
class State:
|
||||||
{"test": MCPServerConfig(command="fake")},
|
pass
|
||||||
ToolRegistry(),
|
|
||||||
)
|
state = State()
|
||||||
|
state._mcp_closing = False
|
||||||
|
state._mcp_servers = {"test": MCPServerConfig(command="fake")}
|
||||||
|
state._mcp_stacks = {}
|
||||||
|
state._mcp_connecting = False
|
||||||
monkeypatch.setattr(mcp_mod, "connect_mcp_servers", connect_mcp_servers)
|
monkeypatch.setattr(mcp_mod, "connect_mcp_servers", connect_mcp_servers)
|
||||||
|
|
||||||
task = asyncio.create_task(provider.connect())
|
task = asyncio.create_task(mcp_mod.connect_missing_servers(state, ToolRegistry()))
|
||||||
await asyncio.wait_for(started.wait(), timeout=1.0)
|
await asyncio.wait_for(started.wait(), timeout=1.0)
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
|
||||||
with pytest.raises(asyncio.CancelledError):
|
with pytest.raises(asyncio.CancelledError):
|
||||||
await task
|
await task
|
||||||
|
|
||||||
assert provider.connected_server_names == set()
|
assert state._mcp_connecting is False
|
||||||
assert provider.runtime_status() == {"test": "failed"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -221,20 +223,25 @@ async def test_saved_oauth_http_403_projects_failed_runtime_without_details(
|
|||||||
rejected_streamable_http,
|
rejected_streamable_http,
|
||||||
)
|
)
|
||||||
|
|
||||||
provider = MCPProvider(
|
class State:
|
||||||
{
|
pass
|
||||||
"xmind": MCPServerConfig(
|
|
||||||
|
state = State()
|
||||||
|
state._mcp_closing = False
|
||||||
|
state._mcp_servers = {
|
||||||
|
"xmind": MCPServerConfig(
|
||||||
type="streamableHttp",
|
type="streamableHttp",
|
||||||
auth="oauth",
|
auth="oauth",
|
||||||
url="https://app.xmind.com/api/mcp",
|
url="https://app.xmind.com/api/mcp",
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
ToolRegistry(),
|
state._mcp_stacks = {}
|
||||||
)
|
state._mcp_runtime_statuses = {}
|
||||||
|
state._mcp_connecting = False
|
||||||
|
|
||||||
await provider.connect()
|
await mcp_mod.connect_missing_servers(state, ToolRegistry())
|
||||||
|
|
||||||
snapshot = provider.runtime_status()
|
snapshot = mcp_mod.runtime_status(state)
|
||||||
assert snapshot == {"xmind": "failed"}
|
assert snapshot == {"xmind": "failed"}
|
||||||
assert "saved-oauth-secret" not in str(snapshot)
|
assert "saved-oauth-secret" not in str(snapshot)
|
||||||
assert "app.xmind.com" not in str(snapshot)
|
assert "app.xmind.com" not in str(snapshot)
|
||||||
@@ -1256,59 +1263,6 @@ async def test_connect_mcp_servers_propagates_external_cancellation(
|
|||||||
await asyncio.wait_for(closed.wait(), timeout=1.0)
|
await asyncio.wait_for(closed.wait(), timeout=1.0)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_connect_mcp_servers_rolls_back_completed_batch_on_cancellation(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
slow_started = asyncio.Event()
|
|
||||||
closed: list[str] = []
|
|
||||||
sessions = {"fast": _make_fake_session(["demo"])}
|
|
||||||
|
|
||||||
class _SelectiveClientSession:
|
|
||||||
def __init__(self, read: object, _write: object) -> None:
|
|
||||||
self._session = sessions[str(read)]
|
|
||||||
|
|
||||||
async def __aenter__(self) -> object:
|
|
||||||
return self._session
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def _selective_stdio_client(params: object):
|
|
||||||
command = str(params.command)
|
|
||||||
try:
|
|
||||||
if command == "slow":
|
|
||||||
slow_started.set()
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
yield command, object()
|
|
||||||
finally:
|
|
||||||
closed.append(command)
|
|
||||||
|
|
||||||
monkeypatch.setattr(sys.modules["mcp"], "ClientSession", _SelectiveClientSession)
|
|
||||||
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _selective_stdio_client)
|
|
||||||
|
|
||||||
registry = ToolRegistry()
|
|
||||||
task = asyncio.create_task(
|
|
||||||
connect_mcp_servers(
|
|
||||||
{
|
|
||||||
"fast": MCPServerConfig(command="fast"),
|
|
||||||
"slow": MCPServerConfig(command="slow"),
|
|
||||||
},
|
|
||||||
registry,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await asyncio.wait_for(slow_started.wait(), timeout=1.0)
|
|
||||||
assert registry.tool_names == ["mcp_fast_demo"]
|
|
||||||
|
|
||||||
task.cancel()
|
|
||||||
with pytest.raises(asyncio.CancelledError):
|
|
||||||
await task
|
|
||||||
|
|
||||||
assert registry.tool_names == []
|
|
||||||
assert sorted(closed) == ["fast", "slow"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
|
async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
|
||||||
fake_mcp_runtime: dict[str, object | None],
|
fake_mcp_runtime: dict[str, object | None],
|
||||||
@@ -1946,11 +1900,7 @@ def test_long_server_name_tools_are_matched_by_server_name() -> None:
|
|||||||
assert len(wrapper.name) == 64
|
assert len(wrapper.name) == 64
|
||||||
assert not wrapper.name.startswith(mcp_mod._tool_prefix(server_name))
|
assert not wrapper.name.startswith(mcp_mod._tool_prefix(server_name))
|
||||||
|
|
||||||
provider = MCPProvider(
|
mcp_mod._attach_reconnect_handlers(SimpleNamespace(), registry, {server_name})
|
||||||
{server_name: MCPServerConfig(command="fake")},
|
|
||||||
registry,
|
|
||||||
)
|
|
||||||
provider._attach_reconnect_handlers({server_name})
|
|
||||||
assert wrapper._reconnect is not None
|
assert wrapper._reconnect is not None
|
||||||
assert other_wrapper._reconnect is None
|
assert other_wrapper._reconnect is None
|
||||||
|
|
||||||
|
|||||||
+11
-104
@@ -3,9 +3,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
|
||||||
import shlex
|
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
@@ -15,12 +12,6 @@ from nanobot.agent.tools.exec_session import _ExecSession
|
|||||||
from nanobot.agent.tools.shell import ExecTool, _reap_pid
|
from nanobot.agent.tools.shell import ExecTool, _reap_pid
|
||||||
|
|
||||||
|
|
||||||
def _python_command(code: str) -> str:
|
|
||||||
if sys.platform == "win32":
|
|
||||||
return f"{subprocess.list2cmdline([sys.executable])} -u -c {subprocess.list2cmdline([code])}"
|
|
||||||
return f"{shlex.quote(sys.executable)} -u -c {shlex.quote(code)}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_reap_pid_noops_without_waitpid():
|
def test_reap_pid_noops_without_waitpid():
|
||||||
"""On platforms (or test stubs) without waitpid, reaping is a no-op."""
|
"""On platforms (or test stubs) without waitpid, reaping is a no-op."""
|
||||||
with patch("nanobot.agent.tools.shell.os") as mock_os:
|
with patch("nanobot.agent.tools.shell.os") as mock_os:
|
||||||
@@ -121,37 +112,20 @@ async def test_execute_timeout_kills_and_reaps():
|
|||||||
mock_proc.pid = 1002
|
mock_proc.pid = 1002
|
||||||
mock_proc.returncode = None
|
mock_proc.returncode = None
|
||||||
mock_proc.communicate.side_effect = asyncio.TimeoutError()
|
mock_proc.communicate.side_effect = asyncio.TimeoutError()
|
||||||
|
mock_proc.kill = MagicMock()
|
||||||
|
mock_proc.wait = AsyncMock(return_value=-9)
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(ExecTool, "_spawn", return_value=mock_proc) as spawn,
|
patch.object(ExecTool, "_spawn", return_value=mock_proc),
|
||||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
patch.object(ExecTool, "_guard_command", return_value=None),
|
||||||
patch.object(ExecTool, "_kill_process_tree", new_callable=AsyncMock) as kill_tree,
|
patch("nanobot.agent.tools.shell._reap_pid") as reap,
|
||||||
):
|
):
|
||||||
tool = ExecTool(timeout=1)
|
tool = ExecTool(timeout=1)
|
||||||
result = await tool.execute(command="sleep 99", timeout=1)
|
result = await tool.execute(command="sleep 99", timeout=1)
|
||||||
|
|
||||||
assert "timed out" in result.lower()
|
assert "timed out" in result.lower()
|
||||||
kill_tree.assert_awaited_once_with(mock_proc)
|
mock_proc.kill.assert_called_once()
|
||||||
assert spawn.await_args.kwargs["process_tree"] is True
|
reap.assert_called_with(1002)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_execute_cancellation_kills_process_tree():
|
|
||||||
mock_proc = AsyncMock()
|
|
||||||
mock_proc.pid = 1005
|
|
||||||
mock_proc.returncode = None
|
|
||||||
mock_proc.communicate.side_effect = asyncio.CancelledError()
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.object(ExecTool, "_spawn", return_value=mock_proc) as spawn,
|
|
||||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
|
||||||
patch.object(ExecTool, "_kill_process_tree", new_callable=AsyncMock) as kill_tree,
|
|
||||||
):
|
|
||||||
with pytest.raises(asyncio.CancelledError):
|
|
||||||
await ExecTool().execute(command="sleep 99")
|
|
||||||
|
|
||||||
kill_tree.assert_awaited_once_with(mock_proc)
|
|
||||||
assert spawn.await_args.kwargs["process_tree"] is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -187,88 +161,21 @@ async def test_execute_exception_during_communicate_kills_live_process():
|
|||||||
mock_proc.pid = 1004
|
mock_proc.pid = 1004
|
||||||
mock_proc.returncode = None
|
mock_proc.returncode = None
|
||||||
mock_proc.communicate.side_effect = OSError("pipe broken")
|
mock_proc.communicate.side_effect = OSError("pipe broken")
|
||||||
|
mock_proc.kill = MagicMock()
|
||||||
|
mock_proc.wait = AsyncMock(return_value=-1)
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(ExecTool, "_spawn", return_value=mock_proc),
|
patch.object(ExecTool, "_spawn", return_value=mock_proc),
|
||||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
patch.object(ExecTool, "_guard_command", return_value=None),
|
||||||
patch.object(ExecTool, "_kill_process_tree", new_callable=AsyncMock) as kill_tree,
|
patch("nanobot.agent.tools.shell._reap_pid") as reap,
|
||||||
):
|
):
|
||||||
tool = ExecTool()
|
tool = ExecTool()
|
||||||
result = await tool.execute(command="broken")
|
result = await tool.execute(command="broken")
|
||||||
|
|
||||||
assert "Error executing command" in result
|
assert "Error executing command" in result
|
||||||
assert "pipe broken" in result
|
assert "pipe broken" in result
|
||||||
kill_tree.assert_awaited_once_with(mock_proc)
|
mock_proc.kill.assert_called_once()
|
||||||
|
reap.assert_called_with(1004)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_kill_process_tree_targets_group_after_root_exits():
|
|
||||||
process = AsyncMock()
|
|
||||||
process.pid = 1006
|
|
||||||
process.returncode = 0
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", False),
|
|
||||||
patch("nanobot.agent.tools.shell.os.killpg", create=True) as kill_group,
|
|
||||||
patch("nanobot.agent.tools.shell.signal.SIGKILL", 9, create=True),
|
|
||||||
patch("nanobot.agent.tools.shell._reap_pid") as reap,
|
|
||||||
):
|
|
||||||
await ExecTool._kill_process_tree(process)
|
|
||||||
|
|
||||||
kill_group.assert_called_once_with(1006, 9)
|
|
||||||
process.kill.assert_not_called()
|
|
||||||
reap.assert_called_once_with(1006)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(sys.platform == "win32", reason="requires Unix process groups")
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_execute_timeout_kills_background_process_tree(tmp_path):
|
|
||||||
"""A one-shot timeout must stop background descendants before they write."""
|
|
||||||
marker = tmp_path / "child-survived"
|
|
||||||
command = f"(sleep 2; touch {shlex.quote(str(marker))}) >/dev/null 2>&1 & sleep 30"
|
|
||||||
|
|
||||||
result = await ExecTool(working_dir=str(tmp_path), timeout=1).execute(
|
|
||||||
command=command,
|
|
||||||
timeout=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "timed out" in result.lower()
|
|
||||||
await asyncio.sleep(2.5)
|
|
||||||
assert not marker.exists()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_execute_timeout_kills_descendant_after_root_exits(tmp_path):
|
|
||||||
"""Tree ownership must outlive a root shell that exits before timeout."""
|
|
||||||
marker = tmp_path / "child-survived-root"
|
|
||||||
child_code = (
|
|
||||||
"import pathlib,time; time.sleep(3.5); "
|
|
||||||
f"pathlib.Path({str(marker)!r}).write_text('alive')"
|
|
||||||
)
|
|
||||||
child_payload = base64.b64encode(child_code.encode()).decode()
|
|
||||||
parent_code = (
|
|
||||||
"import base64,subprocess,sys; "
|
|
||||||
f"child=base64.b64decode('{child_payload}').decode(); "
|
|
||||||
"subprocess.Popen([sys.executable, '-c', child])"
|
|
||||||
)
|
|
||||||
spawned = []
|
|
||||||
original_spawn = ExecTool._spawn
|
|
||||||
|
|
||||||
async def capture_spawn(*args, **kwargs):
|
|
||||||
process = await original_spawn(*args, **kwargs)
|
|
||||||
spawned.append(process)
|
|
||||||
return process
|
|
||||||
|
|
||||||
with patch.object(ExecTool, "_spawn", side_effect=capture_spawn):
|
|
||||||
result = await ExecTool(working_dir=str(tmp_path), timeout=2).execute(
|
|
||||||
command=_python_command(parent_code),
|
|
||||||
timeout=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "timed out" in result.lower()
|
|
||||||
assert spawned[0].returncode == 0
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
assert not marker.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def _mock_session_process(*, pid: int, returncode: int | None):
|
def _mock_session_process(*, pid: int, returncode: int | None):
|
||||||
|
|||||||
@@ -290,11 +290,10 @@ def test_exec_extract_absolute_paths_captures_home_paths() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_exec_extract_absolute_paths_captures_paths_after_equals() -> None:
|
def test_exec_extract_absolute_paths_captures_paths_after_equals() -> None:
|
||||||
cmd = "curl --output=/etc/passwd --config=~/.nanobot/config.json --user-home=~root"
|
cmd = "curl --output=/etc/passwd --config=~/.nanobot/config.json"
|
||||||
paths = ExecTool._extract_absolute_paths(cmd)
|
paths = ExecTool._extract_absolute_paths(cmd)
|
||||||
assert "/etc/passwd" in paths
|
assert "/etc/passwd" in paths
|
||||||
assert "~/.nanobot/config.json" in paths
|
assert "~/.nanobot/config.json" in paths
|
||||||
assert "~root" in paths
|
|
||||||
|
|
||||||
|
|
||||||
def test_exec_extract_absolute_paths_does_not_capture_query_tilde() -> None:
|
def test_exec_extract_absolute_paths_does_not_capture_query_tilde() -> None:
|
||||||
@@ -303,29 +302,6 @@ def test_exec_extract_absolute_paths_does_not_capture_query_tilde() -> None:
|
|||||||
assert not any(p.startswith("~") for p in paths)
|
assert not any(p.startswith("~") for p in paths)
|
||||||
|
|
||||||
|
|
||||||
def test_exec_extract_absolute_paths_captures_bare_and_named_user_home_paths() -> None:
|
|
||||||
paths = ExecTool._extract_absolute_paths("cd ~ && cat ~root/.bashrc")
|
|
||||||
assert "~" in paths
|
|
||||||
assert "~root/.bashrc" in paths
|
|
||||||
|
|
||||||
|
|
||||||
def test_exec_extract_absolute_paths_captures_tilde_after_shell_operators() -> None:
|
|
||||||
paths = ExecTool._extract_absolute_paths(
|
|
||||||
"cat <~root/.bashrc;~root/bin/tool|~daemon/bin/tool"
|
|
||||||
)
|
|
||||||
assert "~root/.bashrc" in paths
|
|
||||||
assert paths.count("~root/bin/tool") == 1
|
|
||||||
assert "~daemon/bin/tool" in paths
|
|
||||||
|
|
||||||
|
|
||||||
def test_exec_extract_absolute_paths_captures_tilde_assignment_components() -> None:
|
|
||||||
paths = ExecTool._extract_absolute_paths(
|
|
||||||
"HOME=~ PATH=bin:~root/bin curl --config=~"
|
|
||||||
)
|
|
||||||
assert "~" in paths
|
|
||||||
assert "~root/bin" in paths
|
|
||||||
|
|
||||||
|
|
||||||
def test_exec_extract_absolute_paths_captures_quoted_paths() -> None:
|
def test_exec_extract_absolute_paths_captures_quoted_paths() -> None:
|
||||||
cmd = 'cat "/tmp/data.txt" "~/.nanobot/config.json"'
|
cmd = 'cat "/tmp/data.txt" "~/.nanobot/config.json"'
|
||||||
paths = ExecTool._extract_absolute_paths(cmd)
|
paths = ExecTool._extract_absolute_paths(cmd)
|
||||||
@@ -343,48 +319,6 @@ def test_exec_guard_blocks_home_path_outside_workspace(tmp_path) -> None:
|
|||||||
assert "hard policy boundary" in error
|
assert "hard policy boundary" in error
|
||||||
|
|
||||||
|
|
||||||
def test_exec_guard_blocks_bare_tilde_cwd_escape(tmp_path) -> None:
|
|
||||||
tool = ExecTool(restrict_to_workspace=True)
|
|
||||||
error = tool._guard_command("cd ~ && cat secret.txt", str(tmp_path))
|
|
||||||
assert error is not None
|
|
||||||
assert error.startswith(
|
|
||||||
"Error: Command blocked by safety guard (path outside working dir)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_exec_guard_blocks_named_user_home_path(tmp_path) -> None:
|
|
||||||
tool = ExecTool(restrict_to_workspace=True)
|
|
||||||
error = tool._guard_command("cat ~root/.bashrc", str(tmp_path))
|
|
||||||
assert error is not None
|
|
||||||
assert error.startswith(
|
|
||||||
"Error: Command blocked by safety guard (path outside working dir)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"command",
|
|
||||||
[
|
|
||||||
"cat <~root/.bashrc",
|
|
||||||
"cat ~-/.bashrc",
|
|
||||||
"cat ~+1/.bashrc",
|
|
||||||
"cat ~-1/.bashrc",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_exec_guard_blocks_home_paths_with_special_shell_contexts(
|
|
||||||
tmp_path, command: str
|
|
||||||
) -> None:
|
|
||||||
error = ExecTool(restrict_to_workspace=True)._guard_command(command, str(tmp_path))
|
|
||||||
assert error is not None
|
|
||||||
assert error.startswith(
|
|
||||||
"Error: Command blocked by safety guard (path outside working dir)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_exec_guard_allows_current_directory_tilde(tmp_path) -> None:
|
|
||||||
tool = ExecTool(restrict_to_workspace=True)
|
|
||||||
assert tool._guard_command("cat ~+/file.txt", str(tmp_path)) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_exec_guard_blocks_equals_home_path_outside_workspace(tmp_path) -> None:
|
def test_exec_guard_blocks_equals_home_path_outside_workspace(tmp_path) -> None:
|
||||||
tool = ExecTool(restrict_to_workspace=True)
|
tool = ExecTool(restrict_to_workspace=True)
|
||||||
error = tool._guard_command("cat --config=~/.nanobot/config.json", str(tmp_path))
|
error = tool._guard_command("cat --config=~/.nanobot/config.json", str(tmp_path))
|
||||||
@@ -394,15 +328,6 @@ def test_exec_guard_blocks_equals_home_path_outside_workspace(tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_exec_guard_blocks_equals_named_user_home_path(tmp_path) -> None:
|
|
||||||
tool = ExecTool(restrict_to_workspace=True)
|
|
||||||
error = tool._guard_command("cat --config=~root/.bashrc", str(tmp_path))
|
|
||||||
assert error is not None
|
|
||||||
assert error.startswith(
|
|
||||||
"Error: Command blocked by safety guard (path outside working dir)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_exec_guard_blocks_quoted_home_path_outside_workspace(tmp_path) -> None:
|
def test_exec_guard_blocks_quoted_home_path_outside_workspace(tmp_path) -> None:
|
||||||
tool = ExecTool(restrict_to_workspace=True)
|
tool = ExecTool(restrict_to_workspace=True)
|
||||||
error = tool._guard_command('cat "~/.nanobot/config.json"', str(tmp_path))
|
error = tool._guard_command('cat "~/.nanobot/config.json"', str(tmp_path))
|
||||||
|
|||||||
@@ -1,279 +0,0 @@
|
|||||||
"""Tests that the Jina Reader path never discloses credential-bearing URLs."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import socket
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.agent.tools import web as web_module
|
|
||||||
from nanobot.agent.tools.web import (
|
|
||||||
WebFetchTool,
|
|
||||||
_redact_url_for_log,
|
|
||||||
_url_carries_credentials,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _fake_resolve_public(hostname, port, family=0, type_=0):
|
|
||||||
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("93.184.216.34", 0))]
|
|
||||||
|
|
||||||
|
|
||||||
class _RecordingJinaClient:
|
|
||||||
"""Fake httpx.AsyncClient that records every requested URL."""
|
|
||||||
|
|
||||||
requested: list[str] = []
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *args):
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def get(self, url, **kwargs):
|
|
||||||
_RecordingJinaClient.requested.append(url)
|
|
||||||
|
|
||||||
class _Response:
|
|
||||||
status_code = 200
|
|
||||||
|
|
||||||
def raise_for_status(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def json(self):
|
|
||||||
return {"data": {"title": "T", "content": "body", "url": url}}
|
|
||||||
|
|
||||||
return _Response()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def jina_client():
|
|
||||||
_RecordingJinaClient.requested = []
|
|
||||||
with patch("nanobot.agent.tools.web.httpx.AsyncClient", _RecordingJinaClient):
|
|
||||||
yield _RecordingJinaClient
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"url",
|
|
||||||
[
|
|
||||||
"https://user:secret@example.com/report",
|
|
||||||
"https://user@example.com/report",
|
|
||||||
"https://example.com/download?token=abc123",
|
|
||||||
"https://example.com/download?access_token=abc123",
|
|
||||||
"https://example.com/doc?Signature=xyz&Expires=1700000000",
|
|
||||||
"https://bucket.s3.amazonaws.com/key?X-Amz-Signature=deadbeef",
|
|
||||||
"https://storage.googleapis.com/o/file?X-Goog-Signature=deadbeef",
|
|
||||||
"https://example.com/blob?sig=sas-token-material",
|
|
||||||
"https://maps.example.com/api?key=AIzaFixture",
|
|
||||||
"https://example.com/callback?code=oauth-code",
|
|
||||||
"https://example.com/download?API-KEY=secret",
|
|
||||||
"https://example.com/download?file=report;token=secret",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_credential_urls_are_detected(url: str) -> None:
|
|
||||||
assert _url_carries_credentials(url) is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"url",
|
|
||||||
[
|
|
||||||
"https://example.com/",
|
|
||||||
"https://example.com/watch?v=abc123",
|
|
||||||
"https://example.com/search?q=token+design&page=2",
|
|
||||||
"https://example.com/page#section-3",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_plain_urls_are_not_detected(url: str) -> None:
|
|
||||||
assert _url_carries_credentials(url) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_log_label_excludes_every_credential_bearing_component() -> None:
|
|
||||||
url = "https://user:secret@example.com:8443/private/webhook-token?token=abc#secret"
|
|
||||||
assert _redact_url_for_log(url) == "https://example.com:8443"
|
|
||||||
|
|
||||||
|
|
||||||
def test_log_label_preserves_ipv6_origin_without_credentials() -> None:
|
|
||||||
url = "https://user:secret@[2001:db8::1]:8443/private?token=abc"
|
|
||||||
assert _redact_url_for_log(url) == "https://[2001:db8::1]:8443"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_jina_is_skipped_for_credential_urls(jina_client) -> None:
|
|
||||||
tool = WebFetchTool()
|
|
||||||
result = await tool._fetch_jina(
|
|
||||||
"https://example.com/download?token=abc123", max_chars=1000
|
|
||||||
)
|
|
||||||
assert result is None
|
|
||||||
assert jina_client.requested == []
|
|
||||||
|
|
||||||
|
|
||||||
async def test_jina_is_skipped_for_userinfo_urls(jina_client) -> None:
|
|
||||||
tool = WebFetchTool()
|
|
||||||
result = await tool._fetch_jina(
|
|
||||||
"https://user:secret@example.com/report", max_chars=1000
|
|
||||||
)
|
|
||||||
assert result is None
|
|
||||||
assert jina_client.requested == []
|
|
||||||
|
|
||||||
|
|
||||||
async def test_jina_skip_log_does_not_contain_url_credentials(
|
|
||||||
jina_client, monkeypatch
|
|
||||||
) -> None:
|
|
||||||
logged: list[tuple[object, ...]] = []
|
|
||||||
monkeypatch.setattr(web_module.logger, "debug", lambda *args: logged.append(args))
|
|
||||||
|
|
||||||
result = await WebFetchTool()._fetch_jina(
|
|
||||||
"https://user:secret@example.com/private/webhook-token?token=abc",
|
|
||||||
max_chars=1000,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
assert jina_client.requested == []
|
|
||||||
rendered_log_arguments = " ".join(str(item) for call in logged for item in call)
|
|
||||||
assert "secret" not in rendered_log_arguments
|
|
||||||
assert "webhook-token" not in rendered_log_arguments
|
|
||||||
assert "token=abc" not in rendered_log_arguments
|
|
||||||
|
|
||||||
|
|
||||||
async def test_jina_still_used_for_plain_urls(jina_client) -> None:
|
|
||||||
tool = WebFetchTool()
|
|
||||||
result = await tool._fetch_jina("https://example.com/watch?v=abc123", max_chars=1000)
|
|
||||||
assert result is not None
|
|
||||||
assert json.loads(result)["extractor"] == "jina"
|
|
||||||
assert jina_client.requested == [
|
|
||||||
"https://r.jina.ai/https://example.com/watch?v=abc123"
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_fragment_is_never_forwarded(jina_client) -> None:
|
|
||||||
tool = WebFetchTool()
|
|
||||||
result = await tool._fetch_jina(
|
|
||||||
"https://example.com/page?q=1#access_token=leaked", max_chars=1000
|
|
||||||
)
|
|
||||||
assert result is not None
|
|
||||||
assert jina_client.requested == ["https://r.jina.ai/https://example.com/page?q=1"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_execute_fetches_credential_urls_locally(monkeypatch) -> None:
|
|
||||||
"""The tool boundary: a credential URL must use the local extractor and
|
|
||||||
produce zero requests to the remote reader."""
|
|
||||||
|
|
||||||
tool = WebFetchTool()
|
|
||||||
requested: list[str] = []
|
|
||||||
|
|
||||||
class FakeStreamResponse:
|
|
||||||
status_code = 200
|
|
||||||
headers = {"content-type": "text/html"}
|
|
||||||
url = "https://example.com/download"
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc, tb):
|
|
||||||
return False
|
|
||||||
|
|
||||||
class FakeResponse:
|
|
||||||
status_code = 200
|
|
||||||
url = "https://example.com/download"
|
|
||||||
text = "<html><head><title>T</title></head><body><p>ok</p></body></html>"
|
|
||||||
headers = {"content-type": "text/html"}
|
|
||||||
is_redirect = False
|
|
||||||
|
|
||||||
def raise_for_status(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
class FakeClient:
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc, tb):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def stream(self, method, url, headers=None, **kwargs):
|
|
||||||
requested.append(str(url))
|
|
||||||
return FakeStreamResponse()
|
|
||||||
|
|
||||||
async def get(self, url, headers=None, **kwargs):
|
|
||||||
requested.append(str(url))
|
|
||||||
return FakeResponse()
|
|
||||||
|
|
||||||
monkeypatch.setattr(tool, "_extract_readable_html", lambda html, mode: "ok")
|
|
||||||
monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient)
|
|
||||||
monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())
|
|
||||||
|
|
||||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
|
|
||||||
result = await tool.execute(url="https://example.com/download?token=abc123")
|
|
||||||
|
|
||||||
data = json.loads(result)
|
|
||||||
assert data["extractor"] == "readability"
|
|
||||||
assert all("r.jina.ai" not in url for url in requested)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_execute_does_not_send_redirected_credential_url_to_jina(monkeypatch) -> None:
|
|
||||||
"""A plain short URL that redirects through a signed URL must stay local."""
|
|
||||||
|
|
||||||
tool = WebFetchTool()
|
|
||||||
requested: list[str] = []
|
|
||||||
short_url = "https://example.com/short"
|
|
||||||
signed_url = "https://cdn.example.com/file?token=secret"
|
|
||||||
|
|
||||||
class FakeStreamResponse:
|
|
||||||
def __init__(self, url: str):
|
|
||||||
self.url = url
|
|
||||||
self.status_code = 302 if url == short_url else 200
|
|
||||||
self.headers = (
|
|
||||||
{"location": signed_url}
|
|
||||||
if url == short_url
|
|
||||||
else {"content-type": "text/html"}
|
|
||||||
)
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc, tb):
|
|
||||||
return False
|
|
||||||
|
|
||||||
class FakeResponse:
|
|
||||||
status_code = 200
|
|
||||||
url = signed_url
|
|
||||||
text = "<html><head><title>T</title></head><body><p>ok</p></body></html>"
|
|
||||||
headers = {"content-type": "text/html"}
|
|
||||||
is_redirect = False
|
|
||||||
|
|
||||||
def raise_for_status(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
class FakeClient:
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc, tb):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def stream(self, method, url, headers=None, **kwargs):
|
|
||||||
requested.append(str(url))
|
|
||||||
return FakeStreamResponse(str(url))
|
|
||||||
|
|
||||||
async def get(self, url, headers=None, **kwargs):
|
|
||||||
requested.append(str(url))
|
|
||||||
return FakeResponse()
|
|
||||||
|
|
||||||
monkeypatch.setattr(tool, "_extract_readable_html", lambda html, mode: "ok")
|
|
||||||
monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient)
|
|
||||||
monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())
|
|
||||||
|
|
||||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
|
|
||||||
result = await tool.execute(url=short_url)
|
|
||||||
|
|
||||||
data = json.loads(result)
|
|
||||||
assert data["extractor"] == "readability"
|
|
||||||
assert signed_url in requested
|
|
||||||
assert all("r.jina.ai" not in url for url in requested)
|
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
|
|
||||||
import nanobot.webui.sidebar_state as sidebar_state
|
|
||||||
from nanobot.webui.sidebar_state import (
|
from nanobot.webui.sidebar_state import (
|
||||||
default_webui_sidebar_state,
|
default_webui_sidebar_state,
|
||||||
read_webui_sidebar_state,
|
read_webui_sidebar_state,
|
||||||
@@ -21,7 +17,7 @@ def test_sidebar_state_defaults_when_file_missing(tmp_path, monkeypatch) -> None
|
|||||||
assert webui_sidebar_state_path() == tmp_path / "webui" / "sidebar-state.json"
|
assert webui_sidebar_state_path() == tmp_path / "webui" / "sidebar-state.json"
|
||||||
|
|
||||||
|
|
||||||
def test_sidebar_state_normalizes_partial_payload(tmp_path, monkeypatch) -> None:
|
def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch) -> None:
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
path = webui_sidebar_state_path()
|
path = webui_sidebar_state_path()
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -35,23 +31,6 @@ def test_sidebar_state_normalizes_partial_payload(tmp_path, monkeypatch) -> None
|
|||||||
"project_name_overrides": {"/repo": " Core ", "bad": ""},
|
"project_name_overrides": {"/repo": " Core ", "bad": ""},
|
||||||
"tags_by_key": {"websocket:a": ["work", "work", ""]},
|
"tags_by_key": {"websocket:a": ["work", "work", ""]},
|
||||||
"collapsed_groups": {"Earlier": 1},
|
"collapsed_groups": {"Earlier": 1},
|
||||||
"workbench": {
|
|
||||||
"version": 1,
|
|
||||||
"tabs": {
|
|
||||||
"tab:websocket:a": {
|
|
||||||
"explicit": True,
|
|
||||||
"title": " Research ",
|
|
||||||
"paneKeys": ["websocket:a", "websocket:b", "websocket:a"],
|
|
||||||
"layoutPaneKeys": ["websocket:b", "missing", "websocket:a"],
|
|
||||||
"layout": "invalid-layout",
|
|
||||||
"splitRatios": [0.4, 2, "bad", float("nan")],
|
|
||||||
},
|
|
||||||
"tab:websocket:b": {
|
|
||||||
"paneKeys": ["websocket:b", "websocket:c"],
|
|
||||||
"layout": "bsp",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"view": {"density": "tiny", "show_archived": True, "sort": "nope"},
|
"view": {"density": "tiny", "show_archived": True, "sort": "nope"},
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -68,19 +47,6 @@ def test_sidebar_state_normalizes_partial_payload(tmp_path, monkeypatch) -> None
|
|||||||
assert state["project_name_overrides"] == {"/repo": "Core"}
|
assert state["project_name_overrides"] == {"/repo": "Core"}
|
||||||
assert state["tags_by_key"] == {"websocket:a": ["work"]}
|
assert state["tags_by_key"] == {"websocket:a": ["work"]}
|
||||||
assert state["collapsed_groups"] == {"Earlier": True}
|
assert state["collapsed_groups"] == {"Earlier": True}
|
||||||
assert 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.4, 0.95],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
assert state["view"] == {
|
assert state["view"] == {
|
||||||
"density": "comfortable",
|
"density": "comfortable",
|
||||||
"show_previews": False,
|
"show_previews": False,
|
||||||
@@ -114,67 +80,3 @@ def test_sidebar_state_write_is_scoped_to_config_data_dir(tmp_path, monkeypatch)
|
|||||||
assert state["view"]["sort"] == "manual"
|
assert state["view"]["sort"] == "manual"
|
||||||
assert webui_sidebar_state_path().is_file()
|
assert webui_sidebar_state_path().is_file()
|
||||||
assert read_webui_sidebar_state()["pinned_keys"] == ["websocket:a"]
|
assert read_webui_sidebar_state()["pinned_keys"] == ["websocket:a"]
|
||||||
|
|
||||||
|
|
||||||
def test_sidebar_state_persists_only_visible_workbench_groups(tmp_path, monkeypatch) -> None:
|
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
|
||||||
tabs = {
|
|
||||||
f"tab:websocket:{index}": {
|
|
||||||
"explicit": False,
|
|
||||||
"paneKeys": [f"websocket:{index}"],
|
|
||||||
"layoutPaneKeys": [f"websocket:{index}"],
|
|
||||||
"layout": "columns",
|
|
||||||
"splitRatios": [],
|
|
||||||
}
|
|
||||||
for index in range(2_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
state = write_webui_sidebar_state({"workbench": {"version": 1, "tabs": tabs}})
|
|
||||||
|
|
||||||
assert state["workbench"] == {"version": 1, "tabs": {}}
|
|
||||||
assert webui_sidebar_state_path().stat().st_size < 2_048
|
|
||||||
|
|
||||||
|
|
||||||
def test_sidebar_state_requires_supported_workbench_version(tmp_path, monkeypatch) -> None:
|
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
|
||||||
|
|
||||||
state = write_webui_sidebar_state(
|
|
||||||
{
|
|
||||||
"workbench": {
|
|
||||||
"version": 2,
|
|
||||||
"tabs": {
|
|
||||||
"tab:websocket:a": {
|
|
||||||
"explicit": True,
|
|
||||||
"paneKeys": ["websocket:a"],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert state["workbench"] == {"version": 1, "tabs": {}}
|
|
||||||
|
|
||||||
|
|
||||||
def test_sidebar_state_serializes_concurrent_writes(monkeypatch) -> None:
|
|
||||||
counter_lock = threading.Lock()
|
|
||||||
active_writes = 0
|
|
||||||
peak_writes = 0
|
|
||||||
|
|
||||||
def fake_write(raw: dict[str, object]) -> dict[str, object]:
|
|
||||||
nonlocal active_writes, peak_writes
|
|
||||||
with counter_lock:
|
|
||||||
active_writes += 1
|
|
||||||
peak_writes = max(peak_writes, active_writes)
|
|
||||||
time.sleep(0.01)
|
|
||||||
with counter_lock:
|
|
||||||
active_writes -= 1
|
|
||||||
return raw
|
|
||||||
|
|
||||||
monkeypatch.setattr(sidebar_state, "_write_webui_sidebar_state", fake_write)
|
|
||||||
payloads = [{"write": index} for index in range(12)]
|
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
|
||||||
results = list(executor.map(sidebar_state.write_webui_sidebar_state, payloads))
|
|
||||||
|
|
||||||
assert results == payloads
|
|
||||||
assert peak_writes == 1
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from mcp.shared.auth import OAuthToken
|
|||||||
|
|
||||||
from nanobot.agent.plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA
|
from nanobot.agent.plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA
|
||||||
from nanobot.agent.tools.mcp_oauth import MCPOAuthStorage, mcp_oauth_has_credentials
|
from nanobot.agent.tools.mcp_oauth import MCPOAuthStorage, mcp_oauth_has_credentials
|
||||||
from nanobot.config.loader import load_config, save_config
|
from nanobot.config.loader import load_config
|
||||||
from nanobot.webui.mcp_presets_api import (
|
from nanobot.webui.mcp_presets_api import (
|
||||||
McpPresetError,
|
McpPresetError,
|
||||||
custom_mcp_action,
|
custom_mcp_action,
|
||||||
@@ -454,46 +454,6 @@ def test_test_mcp_preset_connects_and_reports_tools(
|
|||||||
assert payload["last_action"]["tool_names"] == ["mcp_playwright_browser_navigate"]
|
assert payload["last_action"]["tool_names"] == ["mcp_playwright_browser_navigate"]
|
||||||
|
|
||||||
|
|
||||||
def test_test_mcp_preset_inspects_tools_outside_the_enabled_allowlist(
|
|
||||||
tmp_path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
_use_config(tmp_path, monkeypatch)
|
|
||||||
mcp_presets_action("enable", {"name": ["playwright"]})
|
|
||||||
config = load_config()
|
|
||||||
config.tools.mcp_servers["playwright"].enabled_tools = [
|
|
||||||
"mcp_playwright_browser_navigate",
|
|
||||||
]
|
|
||||||
save_config(config)
|
|
||||||
|
|
||||||
class FakeStack:
|
|
||||||
async def aclose(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def fake_connect(servers, registry):
|
|
||||||
assert servers["playwright"].enabled_tools == ["*"]
|
|
||||||
|
|
||||||
class FakeTool:
|
|
||||||
def __init__(self, name: str) -> None:
|
|
||||||
self.name = name
|
|
||||||
|
|
||||||
def to_schema(self):
|
|
||||||
return {"name": self.name, "description": "", "parameters": {}}
|
|
||||||
|
|
||||||
for index in range(20):
|
|
||||||
registry.register(FakeTool(f"mcp_playwright_tool_{index:02d}"))
|
|
||||||
return {"playwright": FakeStack()}
|
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", fake_connect)
|
|
||||||
|
|
||||||
payload = asyncio.run(mcp_presets_test_action({"name": ["playwright"]}))
|
|
||||||
|
|
||||||
assert payload["last_action"]["tool_count"] == 20
|
|
||||||
assert len(payload["last_action"]["tool_names"]) == 20
|
|
||||||
row = next(item for item in payload["presets"] if item["name"] == "playwright")
|
|
||||||
assert row["enabled_tools"] == ["mcp_playwright_browser_navigate"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_test_mcp_preset_scrubs_connection_errors(
|
def test_test_mcp_preset_scrubs_connection_errors(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -18,13 +17,6 @@ from nanobot.session.manager import SessionManager
|
|||||||
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
|
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _isolate_webui_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
webui_dir = tmp_path / "webui"
|
|
||||||
webui_dir.mkdir(exist_ok=True)
|
|
||||||
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_reuses_valid_index_without_scanning_files(
|
def test_webui_session_list_reuses_valid_index_without_scanning_files(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
@@ -216,269 +208,6 @@ def test_webui_session_list_drops_deleted_index_rows(tmp_path: Path) -> None:
|
|||||||
assert list_webui_sessions(manager) == []
|
assert list_webui_sessions(manager) == []
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_recovers_transcript_without_canonical_session(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
webui_dir = tmp_path / "webui"
|
|
||||||
webui_dir.mkdir(exist_ok=True)
|
|
||||||
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
|
||||||
key = "websocket:restored"
|
|
||||||
transcript = webui_dir / f"{SessionManager.safe_key(key)}.jsonl"
|
|
||||||
transcript.write_text(
|
|
||||||
'{"event":"user","chat_id":"restored","text":"original question",'
|
|
||||||
'"created_at_ms":1785502800000}\n'
|
|
||||||
'{"event":"message","chat_id":"restored","text":"original answer",'
|
|
||||||
'"created_at_ms":1785502801000}\n'
|
|
||||||
'{"event":"turn_end","chat_id":"restored","created_at_ms":1785502802000}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
manager = SessionManager(tmp_path / "workspace")
|
|
||||||
|
|
||||||
[row] = list_webui_sessions(manager)
|
|
||||||
|
|
||||||
assert row["key"] == key
|
|
||||||
assert row["preview"] == "original question"
|
|
||||||
assert row["created_at"] == datetime.fromtimestamp(1785502800).isoformat()
|
|
||||||
assert not manager._get_session_path(key).exists()
|
|
||||||
assert manager.list_sessions() == []
|
|
||||||
|
|
||||||
reloaded = SessionManager(tmp_path / "workspace")
|
|
||||||
assert [row["key"] for row in list_webui_sessions(reloaded)] == [key]
|
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_recovers_colon_chat_id_from_transcript(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
webui_dir = tmp_path / "webui"
|
|
||||||
webui_dir.mkdir(exist_ok=True)
|
|
||||||
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
|
||||||
key = "websocket:scope:child"
|
|
||||||
transcript = webui_dir / f"{SessionManager.safe_key(key)}.jsonl"
|
|
||||||
transcript.write_text(
|
|
||||||
'{"event":"user","chat_id":"scope:child","text":"scoped history"}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
[row] = list_webui_sessions(SessionManager(tmp_path / "workspace"))
|
|
||||||
|
|
||||||
assert row["key"] == key
|
|
||||||
assert row["preview"] == "scoped history"
|
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_normalizes_transcript_preview(tmp_path: Path) -> None:
|
|
||||||
key = "websocket:long-preview"
|
|
||||||
transcript = tmp_path / "webui" / f"{SessionManager.safe_key(key)}.jsonl"
|
|
||||||
transcript.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"event": "user",
|
|
||||||
"chat_id": "long-preview",
|
|
||||||
"text": "first\n\n" + "word " * 100,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
+ "\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
[row] = list_webui_sessions(SessionManager(tmp_path / "workspace"))
|
|
||||||
|
|
||||||
assert row["preview"].startswith("first word")
|
|
||||||
assert "\n" not in row["preview"]
|
|
||||||
assert row["preview"].endswith("…")
|
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_tolerates_invalid_transcript_timestamp(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
key = "websocket:bad-time"
|
|
||||||
transcript = tmp_path / "webui" / f"{SessionManager.safe_key(key)}.jsonl"
|
|
||||||
transcript.write_text(
|
|
||||||
'{"event":"user","chat_id":"bad-time","text":"still visible",'
|
|
||||||
'"created_at_ms":1e100}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
[row] = list_webui_sessions(SessionManager(tmp_path / "workspace"))
|
|
||||||
|
|
||||||
assert row["preview"] == "still visible"
|
|
||||||
datetime.fromisoformat(row["created_at"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_ignores_invalid_transcript_chat_id(tmp_path: Path) -> None:
|
|
||||||
transcript = tmp_path / "webui" / "websocket_.._outside.jsonl"
|
|
||||||
transcript.write_text(
|
|
||||||
'{"event":"user","chat_id":"../outside","text":"do not expose"}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert list_webui_sessions(SessionManager(tmp_path / "workspace")) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_recovers_segment_only_transcript(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
webui_dir = tmp_path / "webui"
|
|
||||||
webui_dir.mkdir(exist_ok=True)
|
|
||||||
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
|
||||||
key = "websocket:segmented"
|
|
||||||
segments = webui_dir / f"{SessionManager.safe_key(key)}.segments"
|
|
||||||
segments.mkdir()
|
|
||||||
(segments / "000001.jsonl").write_text(
|
|
||||||
'{"event":"user","chat_id":"segmented","text":"older segment"}\n'
|
|
||||||
'{"event":"turn_end","chat_id":"segmented"}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
[row] = list_webui_sessions(SessionManager(tmp_path / "workspace"))
|
|
||||||
|
|
||||||
assert row["key"] == key
|
|
||||||
assert row["preview"] == "older segment"
|
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_prefers_canonical_metadata_without_duplicate(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
webui_dir = tmp_path / "webui"
|
|
||||||
webui_dir.mkdir(exist_ok=True)
|
|
||||||
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
|
||||||
key = "websocket:canonical"
|
|
||||||
(webui_dir / f"{SessionManager.safe_key(key)}.jsonl").write_text(
|
|
||||||
'{"event":"user","chat_id":"canonical","text":"display copy"}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
manager = SessionManager(tmp_path / "workspace")
|
|
||||||
session = manager.get_or_create(key)
|
|
||||||
session.metadata["title"] = "Canonical title"
|
|
||||||
session.add_message("user", "canonical preview")
|
|
||||||
manager.save(session)
|
|
||||||
|
|
||||||
rows = list_webui_sessions(manager)
|
|
||||||
|
|
||||||
assert len(rows) == 1
|
|
||||||
assert rows[0]["key"] == key
|
|
||||||
assert rows[0]["preview"] == "canonical preview"
|
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_reuses_unchanged_transcript_index(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
webui_dir = tmp_path / "webui"
|
|
||||||
webui_dir.mkdir(exist_ok=True)
|
|
||||||
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
|
||||||
key = "websocket:cached-transcript"
|
|
||||||
(webui_dir / f"{SessionManager.safe_key(key)}.jsonl").write_text(
|
|
||||||
'{"event":"user","chat_id":"cached-transcript","text":"cached"}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
manager = SessionManager(tmp_path / "workspace")
|
|
||||||
assert list_webui_sessions(manager)[0]["preview"] == "cached"
|
|
||||||
|
|
||||||
def fail_scan(*args, **kwargs):
|
|
||||||
raise AssertionError("unchanged transcript should reuse its index row")
|
|
||||||
|
|
||||||
monkeypatch.setattr(session_list_index, "_scan_transcript_row", fail_scan)
|
|
||||||
|
|
||||||
assert list_webui_sessions(manager)[0]["preview"] == "cached"
|
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_does_not_cache_changed_transcript_with_old_signature(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
webui_dir = tmp_path / "webui"
|
|
||||||
key = "websocket:transcript-race"
|
|
||||||
transcript = webui_dir / f"{SessionManager.safe_key(key)}.jsonl"
|
|
||||||
transcript.write_text(
|
|
||||||
'{"event":"user","chat_id":"transcript-race","text":"initial"}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
manager = SessionManager(tmp_path / "workspace")
|
|
||||||
assert list_webui_sessions(manager)[0]["preview"] == "initial"
|
|
||||||
transcript.write_text(
|
|
||||||
'{"event":"user","chat_id":"transcript-race","text":"first scan"}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
original_open = open
|
|
||||||
changed = False
|
|
||||||
|
|
||||||
class RacingReader(io.StringIO):
|
|
||||||
def __next__(self) -> str:
|
|
||||||
nonlocal changed
|
|
||||||
if not changed:
|
|
||||||
changed = True
|
|
||||||
transcript.write_text(
|
|
||||||
'{"event":"user","chat_id":"transcript-race","text":"second scan"}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
return super().__next__()
|
|
||||||
|
|
||||||
def racing_open(path, *args, **kwargs):
|
|
||||||
if Path(path) == transcript:
|
|
||||||
with original_open(path, *args, **kwargs) as source:
|
|
||||||
return RacingReader(source.read())
|
|
||||||
return original_open(path, *args, **kwargs)
|
|
||||||
|
|
||||||
monkeypatch.setattr(session_list_index, "open", racing_open, raising=False)
|
|
||||||
|
|
||||||
first = list_webui_sessions(manager)
|
|
||||||
second = list_webui_sessions(manager)
|
|
||||||
|
|
||||||
assert first[0]["preview"] == "first scan"
|
|
||||||
assert second[0]["preview"] == "second scan"
|
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_drops_deleted_transcript_index_row(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
webui_dir = tmp_path / "webui"
|
|
||||||
webui_dir.mkdir(exist_ok=True)
|
|
||||||
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
|
||||||
key = "websocket:deleted-transcript"
|
|
||||||
transcript = webui_dir / f"{SessionManager.safe_key(key)}.jsonl"
|
|
||||||
transcript.write_text(
|
|
||||||
'{"event":"user","chat_id":"deleted-transcript","text":"delete me"}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
manager = SessionManager(tmp_path / "workspace")
|
|
||||||
assert list_webui_sessions(manager)[0]["key"] == key
|
|
||||||
|
|
||||||
transcript.unlink()
|
|
||||||
|
|
||||||
assert list_webui_sessions(manager) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_keeps_runtime_instances_isolated(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
first_dir = tmp_path / "instance-a" / "webui"
|
|
||||||
second_dir = tmp_path / "instance-b" / "webui"
|
|
||||||
first_dir.mkdir(parents=True)
|
|
||||||
second_dir.mkdir(parents=True)
|
|
||||||
(first_dir / "websocket_first.jsonl").write_text(
|
|
||||||
'{"event":"user","chat_id":"first","text":"first instance"}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
(second_dir / "websocket_second.jsonl").write_text(
|
|
||||||
'{"event":"user","chat_id":"second","text":"second instance"}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
manager = SessionManager(tmp_path / "workspace")
|
|
||||||
|
|
||||||
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: first_dir)
|
|
||||||
assert [row["key"] for row in list_webui_sessions(manager)] == ["websocket:first"]
|
|
||||||
|
|
||||||
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: second_dir)
|
|
||||||
assert [row["key"] for row in list_webui_sessions(manager)] == ["websocket:second"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_ignores_legacy_stem(tmp_path: Path) -> None:
|
def test_webui_session_list_ignores_legacy_stem(tmp_path: Path) -> None:
|
||||||
manager = SessionManager(tmp_path)
|
manager = SessionManager(tmp_path)
|
||||||
legacy_path = manager.sessions_dir / "websocket_legacy.jsonl"
|
legacy_path = manager.sessions_dir / "websocket_legacy.jsonl"
|
||||||
@@ -540,7 +269,7 @@ def test_webui_session_list_uses_webui_transcript_activity_for_sort(
|
|||||||
monkeypatch,
|
monkeypatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
webui_dir = tmp_path / "webui"
|
webui_dir = tmp_path / "webui"
|
||||||
webui_dir.mkdir(exist_ok=True)
|
webui_dir.mkdir()
|
||||||
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
||||||
|
|
||||||
manager = SessionManager(tmp_path)
|
manager = SessionManager(tmp_path)
|
||||||
@@ -582,7 +311,7 @@ def test_webui_session_list_rescans_when_transcript_changes(
|
|||||||
monkeypatch,
|
monkeypatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
webui_dir = tmp_path / "webui"
|
webui_dir = tmp_path / "webui"
|
||||||
webui_dir.mkdir(exist_ok=True)
|
webui_dir.mkdir()
|
||||||
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
||||||
|
|
||||||
manager = SessionManager(tmp_path)
|
manager = SessionManager(tmp_path)
|
||||||
@@ -687,3 +416,4 @@ def test_session_manager_list_sessions_fallback_time_when_missing(tmp_path: Path
|
|||||||
assert sessions[0]["updated_at"] is not None
|
assert sessions[0]["updated_at"] is not None
|
||||||
datetime.fromisoformat(sessions[0]["created_at"])
|
datetime.fromisoformat(sessions[0]["created_at"])
|
||||||
datetime.fromisoformat(sessions[0]["updated_at"])
|
datetime.fromisoformat(sessions[0]["updated_at"])
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
from collections.abc import Awaitable, Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import ANY, AsyncMock, MagicMock
|
from unittest.mock import ANY, AsyncMock, MagicMock
|
||||||
@@ -23,7 +22,6 @@ def _router(
|
|||||||
authorized: bool = True,
|
authorized: bool = True,
|
||||||
config_path: Path | None = None,
|
config_path: Path | None = None,
|
||||||
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
|
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
|
||||||
mcp_reload: Callable[[], Awaitable[dict[str, object]]] | None = None,
|
|
||||||
) -> WebUISettingsRouter:
|
) -> WebUISettingsRouter:
|
||||||
return WebUISettingsRouter(
|
return WebUISettingsRouter(
|
||||||
settings=WebUISettingsServices.create(config_path or get_config_path()),
|
settings=WebUISettingsServices.create(config_path or get_config_path()),
|
||||||
@@ -39,7 +37,6 @@ def _router(
|
|||||||
runtime_surface="browser",
|
runtime_surface="browser",
|
||||||
runtime_capabilities={},
|
runtime_capabilities={},
|
||||||
mcp_runtime_status=mcp_runtime_status,
|
mcp_runtime_status=mcp_runtime_status,
|
||||||
mcp_reload=mcp_reload,
|
|
||||||
mcp_oauth_redirect_uri=lambda _request: "https://gateway.example/auth/mcp/callback",
|
mcp_oauth_redirect_uri=lambda _request: "https://gateway.example/auth/mcp/callback",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -93,30 +90,22 @@ async def test_mcp_list_serializes_local_runtime_failure_snapshot(tmp_path) -> N
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mcp_reload_callback_is_bounded(
|
async def test_apps_discovery_route_returns_registry(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
payload = {
|
||||||
) -> None:
|
"schema_version": 1,
|
||||||
started = asyncio.Event()
|
"updated_at": "2026-08-12T00:00:00Z",
|
||||||
|
"featured": ["mcp:github"],
|
||||||
async def reload_mcp() -> dict[str, object]:
|
|
||||||
started.set()
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"nanobot.webui.settings_routes._MCP_RELOAD_TIMEOUT_SECONDS",
|
|
||||||
0.01,
|
|
||||||
)
|
|
||||||
router = _router(mcp_reload=reload_mcp)
|
|
||||||
|
|
||||||
result = await router._reload_mcp_runtime()
|
|
||||||
|
|
||||||
assert started.is_set()
|
|
||||||
assert result == {
|
|
||||||
"ok": False,
|
|
||||||
"message": "MCP hot reload timed out. Restart nanobot to pick up changes.",
|
|
||||||
"requires_restart": True,
|
|
||||||
}
|
}
|
||||||
|
discover = AsyncMock(return_value=payload)
|
||||||
|
monkeypatch.setattr("nanobot.webui.settings_routes.discovery_payload", discover)
|
||||||
|
request = SimpleNamespace(path="/api/settings/apps-discovery", headers=Headers())
|
||||||
|
|
||||||
|
response = await _router().dispatch(None, request, request.path)
|
||||||
|
|
||||||
|
assert response is not None
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert json.loads(response.body) == payload
|
||||||
|
discover.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1, user-scalable=no"
|
||||||
/>
|
/>
|
||||||
<meta name="color-scheme" content="light dark" />
|
<meta name="color-scheme" content="light dark" />
|
||||||
<meta
|
<meta
|
||||||
|
|||||||
+34
-93
@@ -1,23 +1,5 @@
|
|||||||
const CACHE_PREFIX = "nanobot-static-";
|
const CACHE_NAME = "nanobot-static-v1";
|
||||||
const CACHE_NAME = `${CACHE_PREFIX}v2`;
|
const PRECACHE = ["/", "/manifest.json"];
|
||||||
const ASSET_MANIFEST_PATH = "/asset-manifest.json";
|
|
||||||
const PRECACHE = ["/", "/manifest.json", ASSET_MANIFEST_PATH];
|
|
||||||
const NETWORK_FIRST_STATIC_PATHS = new Set([
|
|
||||||
"/",
|
|
||||||
"/manifest.json",
|
|
||||||
ASSET_MANIFEST_PATH,
|
|
||||||
"/brand/nanobot_apple_touch.png",
|
|
||||||
"/brand/nanobot_favicon_32.png",
|
|
||||||
"/brand/nanobot_icon_192.png",
|
|
||||||
"/brand/nanobot_icon_512.png",
|
|
||||||
"/brand/nanobot_icon_maskable.png",
|
|
||||||
"/brand/nanobot_mark.svg",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function responseMayBeCached(response) {
|
|
||||||
const cacheControl = response.headers?.get("Cache-Control") ?? "";
|
|
||||||
return response.ok && !/(?:^|,)\s*(?:private|no-cache|no-store)\b/i.test(cacheControl);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
@@ -38,42 +20,6 @@ function referencedAssetPaths(html) {
|
|||||||
return refs;
|
return refs;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vite's build manifest contains every emitted entry, static dependency, and
|
|
||||||
// lazy chunk. The HTML alone only references the entry chunk, so pruning from
|
|
||||||
// its tags can delete a current build's not-yet-requested dynamic imports.
|
|
||||||
async function manifestedAssetPaths(cache) {
|
|
||||||
const response = await cache.match(ASSET_MANIFEST_PATH);
|
|
||||||
if (!response) return new Set();
|
|
||||||
try {
|
|
||||||
const manifest = await response.json();
|
|
||||||
const refs = new Set();
|
|
||||||
for (const entry of Object.values(manifest)) {
|
|
||||||
if (!entry || typeof entry !== "object") continue;
|
|
||||||
for (const file of [entry.file, ...(entry.css ?? []), ...(entry.assets ?? [])]) {
|
|
||||||
if (typeof file !== "string") continue;
|
|
||||||
const url = new URL(file, self.location.origin);
|
|
||||||
if (url.origin === self.location.origin) refs.add(url.pathname + url.search);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return refs;
|
|
||||||
} catch {
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshAssetManifest(cache) {
|
|
||||||
const response = await fetch(ASSET_MANIFEST_PATH, { cache: "no-store" });
|
|
||||||
if (!response.ok) return false;
|
|
||||||
try {
|
|
||||||
const manifest = await response.clone().json();
|
|
||||||
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) return false;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
await cache.put(ASSET_MANIFEST_PATH, response);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drop cached entries that the current index.html no longer references.
|
// Drop cached entries that the current index.html no longer references.
|
||||||
// CACHE_NAME is stable across deployments, so without this, hashed assets from
|
// CACHE_NAME is stable across deployments, so without this, hashed assets from
|
||||||
// previous builds would pile up in the same cache forever. The cached
|
// previous builds would pile up in the same cache forever. The cached
|
||||||
@@ -85,16 +31,11 @@ async function pruneStaleEntries() {
|
|||||||
const cachedIndex = await cache.match("/");
|
const cachedIndex = await cache.match("/");
|
||||||
if (!cachedIndex) return;
|
if (!cachedIndex) return;
|
||||||
const refs = referencedAssetPaths(await cachedIndex.text());
|
const refs = referencedAssetPaths(await cachedIndex.text());
|
||||||
for (const path of await manifestedAssetPaths(cache)) refs.add(path);
|
|
||||||
const keys = await cache.keys();
|
const keys = await cache.keys();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
keys.map(async (request) => {
|
keys.map(async (request) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (
|
if (url.pathname === "/" || url.pathname === "/manifest.json") return;
|
||||||
url.pathname === "/"
|
|
||||||
|| url.pathname === "/manifest.json"
|
|
||||||
|| url.pathname === ASSET_MANIFEST_PATH
|
|
||||||
) return;
|
|
||||||
if (refs.has(url.pathname + url.search)) return;
|
if (refs.has(url.pathname + url.search)) return;
|
||||||
await cache.delete(request);
|
await cache.delete(request);
|
||||||
})
|
})
|
||||||
@@ -108,7 +49,7 @@ self.addEventListener("activate", (event) => {
|
|||||||
.then((keys) =>
|
.then((keys) =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
keys
|
keys
|
||||||
.filter((k) => k.startsWith(CACHE_PREFIX) && k !== CACHE_NAME)
|
.filter((k) => k !== CACHE_NAME)
|
||||||
.map((k) => caches.delete(k))
|
.map((k) => caches.delete(k))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -124,13 +65,28 @@ self.addEventListener("fetch", (event) => {
|
|||||||
// (never reconstructed), so their credentials mode is preserved and gateway
|
// (never reconstructed), so their credentials mode is preserved and gateway
|
||||||
// auth cookies flow through on every path we touch. WebSocket upgrades are
|
// auth cookies flow through on every path we touch. WebSocket upgrades are
|
||||||
// never dispatched to a service worker's fetch handler, so the WS endpoint
|
// never dispatched to a service worker's fetch handler, so the WS endpoint
|
||||||
// cannot be cached. Unknown HTTP endpoints are passed through below.
|
// cannot be cached; the /__nanobot exclusion below still protects its HTTP
|
||||||
|
// polling/socket bootstrap endpoints.
|
||||||
if (request.method !== "GET") return;
|
if (request.method !== "GET") return;
|
||||||
if (new URL(request.url).origin !== self.location.origin) return;
|
if (new URL(request.url).origin !== self.location.origin) return;
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const path = url.pathname;
|
const path = url.pathname;
|
||||||
|
|
||||||
|
// Never cache API, auth, WebSocket, HMR, or WebUI endpoint paths. In
|
||||||
|
// particular /webui/bootstrap issues fresh gateway credentials on every page
|
||||||
|
// load and must never be cached or replayed offline. The /auth prefix covers
|
||||||
|
// the default token endpoint; custom token_issue_path values should be kept
|
||||||
|
// under one of these prefixes.
|
||||||
|
if (
|
||||||
|
path.startsWith("/api") ||
|
||||||
|
path.startsWith("/auth") ||
|
||||||
|
path.startsWith("/__nanobot") ||
|
||||||
|
path.startsWith("/webui")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Static assets: cache-first. Only files under /assets/ carry content hashes
|
// Static assets: cache-first. Only files under /assets/ carry content hashes
|
||||||
// (the gateway serves them immutable); brand icons, the favicon and other
|
// (the gateway serves them immutable); brand icons, the favicon and other
|
||||||
// un-hashed files can change between releases and stay on the network-first
|
// un-hashed files can change between releases and stay on the network-first
|
||||||
@@ -140,7 +96,7 @@ self.addEventListener("fetch", (event) => {
|
|||||||
caches.match(request).then((cached) => {
|
caches.match(request).then((cached) => {
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
return fetch(request).then((response) => {
|
return fetch(request).then((response) => {
|
||||||
if (responseMayBeCached(response)) {
|
if (response.ok) {
|
||||||
const clone = response.clone();
|
const clone = response.clone();
|
||||||
caches.open(CACHE_NAME).then((c) => c.put(request, clone));
|
caches.open(CACHE_NAME).then((c) => c.put(request, clone));
|
||||||
}
|
}
|
||||||
@@ -151,35 +107,20 @@ self.addEventListener("fetch", (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache only explicit public files and browser navigations. Dynamic routes
|
// Everything else: network-first (index.html, manifest, brand assets, etc.)
|
||||||
// are deliberately passed through because token_issue_path and extension
|
|
||||||
// endpoints are configurable and cannot be safely identified by prefixes.
|
|
||||||
const isNavigation = request.mode === "navigate";
|
|
||||||
if (!isNavigation && !NETWORK_FIRST_STATIC_PATHS.has(path)) return;
|
|
||||||
|
|
||||||
// App shell and public files: network-first with an offline fallback.
|
|
||||||
const networkResponse = fetch(request);
|
|
||||||
event.waitUntil(
|
|
||||||
networkResponse
|
|
||||||
.then(async (response) => {
|
|
||||||
if (!responseMayBeCached(response)) return;
|
|
||||||
// Clone before the first await. The original response is also handed
|
|
||||||
// to respondWith(), which may lock its body as soon as this callback
|
|
||||||
// yields to the event loop.
|
|
||||||
const cachedResponse = response.clone();
|
|
||||||
const cache = await caches.open(CACHE_NAME);
|
|
||||||
await cache.put(isNavigation ? "/" : request, cachedResponse);
|
|
||||||
// Refresh the complete build graph before pruning. A deployment can
|
|
||||||
// change index.html without changing sw.js, so this cannot rely only
|
|
||||||
// on the manifest cached when the worker was installed.
|
|
||||||
if (isNavigation || path === "/") {
|
|
||||||
if (await refreshAssetManifest(cache)) await pruneStaleEntries();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => undefined)
|
|
||||||
);
|
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
networkResponse
|
fetch(request)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
const clone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((c) => c.put(request, clone));
|
||||||
|
// The shell just changed; prune entries the new index.html no longer
|
||||||
|
// references so hashed assets from old builds do not accumulate even
|
||||||
|
// when sw.js itself is unchanged between deployments.
|
||||||
|
if (path === "/") pruneStaleEntries();
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Offline: serve the app shell for navigations (deep links resolve
|
// Offline: serve the app shell for navigations (deep links resolve
|
||||||
// client-side), the last cached copy for everything else.
|
// client-side), the last cached copy for everything else.
|
||||||
|
|||||||
+90
-575
@@ -12,27 +12,8 @@ import { Eye, EyeOff, Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react"
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { channelUiPresentation } from "@/channel-plugins/registry";
|
import { channelUiPresentation } from "@/channel-plugins/registry";
|
||||||
import { Sidebar } from "@/components/Sidebar";
|
import { Sidebar } from "@/components/Sidebar";
|
||||||
import type { SidebarDeleteItem } from "@/components/ChatList";
|
|
||||||
import type { SettingsSectionKey } from "@/components/settings/SettingsView";
|
import type { SettingsSectionKey } from "@/components/settings/SettingsView";
|
||||||
import { ThreadShell } from "@/components/thread/ThreadShell";
|
import { ThreadShell } from "@/components/thread/ThreadShell";
|
||||||
import { PaneWorkbench } from "@/components/workbench/PaneWorkbench";
|
|
||||||
import {
|
|
||||||
MAX_WORKBENCH_PANES,
|
|
||||||
addWorkbenchPane,
|
|
||||||
attachWorkbenchPane,
|
|
||||||
createWorkbenchTab,
|
|
||||||
detachWorkbenchPane,
|
|
||||||
dissolveWorkbenchTab,
|
|
||||||
orderWorkbenchTabs,
|
|
||||||
reconcileWorkbench,
|
|
||||||
renameWorkbenchTab,
|
|
||||||
setWorkbenchLayout,
|
|
||||||
setWorkbenchPaneLayoutOrder,
|
|
||||||
setWorkbenchSplitRatios,
|
|
||||||
workbenchTab,
|
|
||||||
workbenchTabForPane,
|
|
||||||
type WorkbenchState,
|
|
||||||
} from "@/components/workbench/workbench-model";
|
|
||||||
import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface";
|
import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface";
|
||||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||||
|
|
||||||
@@ -42,7 +23,6 @@ import { useSidebarState } from "@/hooks/useSidebarState";
|
|||||||
import { useSkills } from "@/hooks/useSkills";
|
import { useSkills } from "@/hooks/useSkills";
|
||||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||||
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||||
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
|
||||||
import { ThemeProvider, useTheme } from "@/hooks/useTheme";
|
import { ThemeProvider, useTheme } from "@/hooks/useTheme";
|
||||||
import { logoFallbackUrls } from "@/lib/provider-brand";
|
import { logoFallbackUrls } from "@/lib/provider-brand";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -55,7 +35,7 @@ import {
|
|||||||
loadSavedSecret,
|
loadSavedSecret,
|
||||||
saveSecret,
|
saveSecret,
|
||||||
} from "@/lib/bootstrap";
|
} from "@/lib/bootstrap";
|
||||||
import { displayTitle, sortSessions } from "@/lib/chat-groups";
|
import { displayTitle } from "@/lib/chat-groups";
|
||||||
import { deriveTitle } from "@/lib/format";
|
import { deriveTitle } from "@/lib/format";
|
||||||
import { NanobotClient } from "@/lib/nanobot-client";
|
import { NanobotClient } from "@/lib/nanobot-client";
|
||||||
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
||||||
@@ -1037,11 +1017,7 @@ function Shell({
|
|||||||
deleteChat,
|
deleteChat,
|
||||||
getSessionAutomations,
|
getSessionAutomations,
|
||||||
} = useSessions();
|
} = useSessions();
|
||||||
const {
|
const { state: sidebarState, update: updateSidebarState } =
|
||||||
state: sidebarState,
|
|
||||||
loading: sidebarStateLoading,
|
|
||||||
update: updateSidebarState,
|
|
||||||
} =
|
|
||||||
useSidebarState(sessions, !loading);
|
useSidebarState(sessions, !loading);
|
||||||
const initialRouteRef = useRef<ShellRoute | null>(null);
|
const initialRouteRef = useRef<ShellRoute | null>(null);
|
||||||
if (!initialRouteRef.current) initialRouteRef.current = readShellRoute();
|
if (!initialRouteRef.current) initialRouteRef.current = readShellRoute();
|
||||||
@@ -1058,31 +1034,15 @@ function Shell({
|
|||||||
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
|
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
|
const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
|
||||||
const mobileWorkbench = useMediaQuery("(max-width: 767px)");
|
|
||||||
const workbenchState = sidebarState.workbench;
|
|
||||||
const updateWorkbenchState = useCallback((
|
|
||||||
updater: (current: WorkbenchState) => WorkbenchState,
|
|
||||||
) => {
|
|
||||||
void updateSidebarState((current) => {
|
|
||||||
const next = updater(current.workbench);
|
|
||||||
return next === current.workbench ? current : { ...current, workbench: next };
|
|
||||||
});
|
|
||||||
}, [updateSidebarState]);
|
|
||||||
const lastActivePaneByTabRef = useRef(new Map<string, string>());
|
|
||||||
const [creatingPane, setCreatingPane] = useState(false);
|
|
||||||
const topicSessions = sessions;
|
|
||||||
const [pendingDelete, setPendingDelete] = useState<{
|
const [pendingDelete, setPendingDelete] = useState<{
|
||||||
items: SidebarDeleteItem[];
|
key: string;
|
||||||
|
label: string;
|
||||||
automations?: SessionAutomationJob[];
|
automations?: SessionAutomationJob[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [pendingRename, setPendingRename] = useState<{
|
const [pendingRename, setPendingRename] = useState<{
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [pendingTabRename, setPendingTabRename] = useState<{
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
} | null>(null);
|
|
||||||
const [pendingProjectRename, setPendingProjectRename] = useState<{
|
const [pendingProjectRename, setPendingProjectRename] = useState<{
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -1260,21 +1220,9 @@ function Shell({
|
|||||||
if (temporarySessions[activeKey]) return temporarySessions[activeKey];
|
if (temporarySessions[activeKey]) return temporarySessions[activeKey];
|
||||||
return sessions.find((s) => s.key === activeKey) ?? null;
|
return sessions.find((s) => s.key === activeKey) ?? null;
|
||||||
}, [sessions, activeKey, temporarySessions]);
|
}, [sessions, activeKey, temporarySessions]);
|
||||||
const activeTabMatch = useMemo(() => (
|
|
||||||
activeKey && !temporarySessions[activeKey]
|
|
||||||
? workbenchTabForPane(workbenchState, activeKey)
|
|
||||||
: null
|
|
||||||
), [activeKey, temporarySessions, workbenchState]);
|
|
||||||
const activeTabKey = activeTabMatch?.tabKey ?? null;
|
|
||||||
const activeTabState = activeTabMatch?.tab ?? null;
|
|
||||||
const activePaneSession = activeSession;
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeTabKey || !activeKey || !activeTabState?.paneKeys.includes(activeKey)) return;
|
|
||||||
lastActivePaneByTabRef.current.set(activeTabKey, activeKey);
|
|
||||||
}, [activeKey, activeTabKey, activeTabState]);
|
|
||||||
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
||||||
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
||||||
const activeChatId = activePaneSession?.chatId ?? null;
|
const activeChatId = activeSession?.chatId ?? null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeChatIdRef.current = activeChatId;
|
activeChatIdRef.current = activeChatId;
|
||||||
if (!activeChatId) return;
|
if (!activeChatId) return;
|
||||||
@@ -1294,13 +1242,13 @@ function Shell({
|
|||||||
if (activeChatId && workspaceOverrides[activeChatId]) {
|
if (activeChatId && workspaceOverrides[activeChatId]) {
|
||||||
return workspaceOverrides[activeChatId];
|
return workspaceOverrides[activeChatId];
|
||||||
}
|
}
|
||||||
if (activePaneSession?.workspaceScope) {
|
if (activeSession?.workspaceScope) {
|
||||||
return activePaneSession.workspaceScope;
|
return activeSession.workspaceScope;
|
||||||
}
|
}
|
||||||
return draftWorkspaceScope ?? workspaces?.default_scope ?? null;
|
return draftWorkspaceScope ?? workspaces?.default_scope ?? null;
|
||||||
}, [
|
}, [
|
||||||
activeChatId,
|
activeChatId,
|
||||||
activePaneSession?.workspaceScope,
|
activeSession?.workspaceScope,
|
||||||
draftWorkspaceScope,
|
draftWorkspaceScope,
|
||||||
temporaryChatRequested,
|
temporaryChatRequested,
|
||||||
workspaceOverrides,
|
workspaceOverrides,
|
||||||
@@ -1336,19 +1284,6 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}, [loading, sessions]);
|
}, [loading, sessions]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (loading || sidebarStateLoading) return;
|
|
||||||
const validKeys = new Set(sessions.map((session) => session.key));
|
|
||||||
updateWorkbenchState((current) => {
|
|
||||||
return reconcileWorkbench(current, validKeys);
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
loading,
|
|
||||||
sidebarStateLoading,
|
|
||||||
sessions,
|
|
||||||
updateWorkbenchState,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
const pendingCreatedKey = pendingCreatedSessionKeyRef.current;
|
const pendingCreatedKey = pendingCreatedSessionKeyRef.current;
|
||||||
@@ -1780,18 +1715,6 @@ function Shell({
|
|||||||
[pendingRename, updateSidebarState],
|
[pendingRename, updateSidebarState],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onRequestRenameTab = useCallback((key: string, label: string) => {
|
|
||||||
setPendingTabRename({ key, label });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onConfirmTabRename = useCallback((title: string) => {
|
|
||||||
if (!pendingTabRename) return;
|
|
||||||
updateWorkbenchState((current) => (
|
|
||||||
renameWorkbenchTab(current, pendingTabRename.key, title)
|
|
||||||
));
|
|
||||||
setPendingTabRename(null);
|
|
||||||
}, [pendingTabRename, updateWorkbenchState]);
|
|
||||||
|
|
||||||
const onToggleGroup = useCallback(
|
const onToggleGroup = useCallback(
|
||||||
(groupId: string) => {
|
(groupId: string) => {
|
||||||
void updateSidebarState((current) => {
|
void updateSidebarState((current) => {
|
||||||
@@ -1865,7 +1788,7 @@ function Shell({
|
|||||||
});
|
});
|
||||||
if (activeKey === key && !sidebarState.archived_keys.includes(key)) {
|
if (activeKey === key && !sidebarState.archived_keys.includes(key)) {
|
||||||
const archived = new Set([...sidebarState.archived_keys, key]);
|
const archived = new Set([...sidebarState.archived_keys, key]);
|
||||||
const next = topicSessions.find((session) => !archived.has(session.key));
|
const next = sessions.find((session) => !archived.has(session.key));
|
||||||
navigate({
|
navigate({
|
||||||
view: "chat",
|
view: "chat",
|
||||||
activeKey: next?.key ?? null,
|
activeKey: next?.key ?? null,
|
||||||
@@ -1873,7 +1796,18 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeKey, navigate, sidebarState.archived_keys, topicSessions, updateSidebarState],
|
[activeKey, navigate, sessions, sidebarState.archived_keys, updateSidebarState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onReorderSessions = useCallback(
|
||||||
|
(sessionOrder: string[]) => {
|
||||||
|
void updateSidebarState((current) => ({
|
||||||
|
...current,
|
||||||
|
session_order: sessionOrder,
|
||||||
|
view: { ...current.view, sort: "manual" },
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[updateSidebarState],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onToggleArchived = useCallback(() => {
|
const onToggleArchived = useCallback(() => {
|
||||||
@@ -1891,57 +1825,6 @@ function Shell({
|
|||||||
setSessionSearchOpen(true);
|
setSessionSearchOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onAddPane = useCallback(async () => {
|
|
||||||
const tabKey = activeTabKey;
|
|
||||||
if (
|
|
||||||
!tabKey
|
|
||||||
|| !activeKey
|
|
||||||
|| !activeSession
|
|
||||||
|| creatingPane
|
|
||||||
|| (activeTabState?.paneKeys.length ?? 0) >= MAX_WORKBENCH_PANES
|
|
||||||
|| temporarySessionsRef.current[activeKey]
|
|
||||||
) return;
|
|
||||||
setMobileSidebarOpen(false);
|
|
||||||
setSessionSearchOpen(false);
|
|
||||||
setCreatingPane(true);
|
|
||||||
try {
|
|
||||||
const scope = activeWorkspaceScope;
|
|
||||||
const chatId = await createChat(scope);
|
|
||||||
const paneKey = `websocket:${chatId}`;
|
|
||||||
pendingCreatedSessionKeyRef.current = paneKey;
|
|
||||||
updateWorkbenchState((current) => addWorkbenchPane(current, activeKey, paneKey));
|
|
||||||
navigate({
|
|
||||||
view: "chat",
|
|
||||||
activeKey: paneKey,
|
|
||||||
settingsSection: "overview",
|
|
||||||
});
|
|
||||||
if (scope) {
|
|
||||||
setWorkspaceOverrides((current) => ({
|
|
||||||
...current,
|
|
||||||
[chatId]: normalizeWorkspaceScope(scope),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to create pane", error);
|
|
||||||
if (error instanceof Error && error.message.startsWith("workspace_scope_rejected:")) {
|
|
||||||
setWorkspaceError(t("errors.workspaceScopeRejected.body"));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setCreatingPane(false);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
activeKey,
|
|
||||||
activeSession,
|
|
||||||
activeTabKey,
|
|
||||||
activeTabState,
|
|
||||||
activeWorkspaceScope,
|
|
||||||
createChat,
|
|
||||||
creatingPane,
|
|
||||||
navigate,
|
|
||||||
t,
|
|
||||||
updateWorkbenchState,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||||
if (event.defaultPrevented) return;
|
if (event.defaultPrevented) return;
|
||||||
@@ -2019,15 +1902,15 @@ function Shell({
|
|||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
const nextKey = (() => {
|
const nextKey = (() => {
|
||||||
if (!activeKey) return null;
|
if (!activeKey) return null;
|
||||||
if (topicSessions.some((session) => session.key === activeKey)) return activeKey;
|
if (sessions.some((session) => session.key === activeKey)) return activeKey;
|
||||||
return topicSessions[0]?.key ?? null;
|
return sessions[0]?.key ?? null;
|
||||||
})();
|
})();
|
||||||
navigate({
|
navigate({
|
||||||
view: "chat",
|
view: "chat",
|
||||||
activeKey: nextKey,
|
activeKey: nextKey,
|
||||||
settingsSection: "overview",
|
settingsSection: "overview",
|
||||||
});
|
});
|
||||||
}, [activeKey, navigate, topicSessions]);
|
}, [activeKey, navigate, sessions]);
|
||||||
|
|
||||||
const onRestart = useCallback(() => {
|
const onRestart = useCallback(() => {
|
||||||
const chatId = activeSession?.chatId ?? client.defaultChatId;
|
const chatId = activeSession?.chatId ?? client.defaultChatId;
|
||||||
@@ -2134,42 +2017,30 @@ function Shell({
|
|||||||
}, [client, t]);
|
}, [client, t]);
|
||||||
|
|
||||||
const onTurnEnd = useDeferredTitleRefresh(
|
const onTurnEnd = useDeferredTitleRefresh(
|
||||||
temporaryChatActive ? null : activePaneSession,
|
temporaryChatActive ? null : activeSession,
|
||||||
refresh,
|
refresh,
|
||||||
);
|
);
|
||||||
|
|
||||||
const onConfirmDelete = useCallback(async () => {
|
const onConfirmDelete = useCallback(async () => {
|
||||||
if (!pendingDelete) return;
|
if (!pendingDelete) return;
|
||||||
const items = pendingDelete.items;
|
const key = pendingDelete.key;
|
||||||
const deletingKeys = new Set(items.map((item) => item.key));
|
|
||||||
const hasAutomations = (pendingDelete.automations?.length ?? 0) > 0;
|
const hasAutomations = (pendingDelete.automations?.length ?? 0) > 0;
|
||||||
const deletingActive = activeKey !== null && deletingKeys.has(activeKey);
|
const deletingActive = activeKey === key;
|
||||||
const currentIndex = topicSessions.findIndex((s) => s.key === activeKey);
|
const currentIndex = sessions.findIndex((s) => s.key === key);
|
||||||
const fallbackKey = deletingActive
|
const fallbackKey = deletingActive
|
||||||
? (
|
? (sessions[currentIndex + 1]?.key ?? sessions[currentIndex - 1]?.key ?? null)
|
||||||
topicSessions.slice(currentIndex + 1).find((session) => (
|
|
||||||
!deletingKeys.has(session.key)
|
|
||||||
))?.key
|
|
||||||
?? topicSessions.slice(0, Math.max(0, currentIndex)).reverse().find((session) => (
|
|
||||||
!deletingKeys.has(session.key)
|
|
||||||
))?.key
|
|
||||||
?? null
|
|
||||||
)
|
|
||||||
: activeKey;
|
: activeKey;
|
||||||
try {
|
try {
|
||||||
for (let index = 0; index < items.length; index += 1) {
|
const result = await deleteChat(
|
||||||
const item = items[index];
|
key,
|
||||||
const result = await deleteChat(
|
hasAutomations ? { deleteAutomations: true } : undefined,
|
||||||
item.key,
|
);
|
||||||
hasAutomations ? { deleteAutomations: true } : undefined,
|
if (result.blocked_by_automations) {
|
||||||
);
|
setPendingDelete({
|
||||||
if (result.blocked_by_automations) {
|
...pendingDelete,
|
||||||
setPendingDelete({
|
automations: result.automations ?? [],
|
||||||
items: items.slice(index),
|
});
|
||||||
automations: result.automations ?? [],
|
return;
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setPendingDelete(null);
|
setPendingDelete(null);
|
||||||
if (deletingActive) {
|
if (deletingActive) {
|
||||||
@@ -2182,24 +2053,18 @@ function Shell({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to delete session", e);
|
console.error("Failed to delete session", e);
|
||||||
}
|
}
|
||||||
}, [pendingDelete, deleteChat, activeKey, navigate, topicSessions]);
|
}, [pendingDelete, deleteChat, activeKey, navigate, sessions]);
|
||||||
|
|
||||||
const onRequestDeleteMany = useCallback(async (items: SidebarDeleteItem[]) => {
|
const onRequestDelete = useCallback(async (key: string, label: string) => {
|
||||||
const uniqueItems = Array.from(new Map(items.map((item) => [item.key, item])).values());
|
let automations: SessionAutomationJob[] = [];
|
||||||
if (uniqueItems.length === 0) return;
|
try {
|
||||||
const automationResults = await Promise.allSettled(
|
automations = await getSessionAutomations(key);
|
||||||
uniqueItems.map((item) => getSessionAutomations(item.key)),
|
} catch {
|
||||||
);
|
// Delete remains protected by the backend block; prefetch only improves the first prompt.
|
||||||
const automations = automationResults.flatMap((result) => (
|
}
|
||||||
result.status === "fulfilled" ? result.value : []
|
setPendingDelete({ key, label, automations });
|
||||||
));
|
|
||||||
setPendingDelete({ items: uniqueItems, automations });
|
|
||||||
}, [getSessionAutomations]);
|
}, [getSessionAutomations]);
|
||||||
|
|
||||||
const onRequestDelete = useCallback((key: string, label: string) => {
|
|
||||||
void onRequestDeleteMany([{ key, label }]);
|
|
||||||
}, [onRequestDeleteMany]);
|
|
||||||
|
|
||||||
const visiblePairingRequests = useMemo(
|
const visiblePairingRequests = useMemo(
|
||||||
() => {
|
() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -2244,218 +2109,13 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const titleForSession = useCallback((session: ChatSummary) => (
|
|
||||||
sidebarState.title_overrides[session.key]
|
|
||||||
|| session.title
|
|
||||||
|| deriveTitle(session.preview, t("chat.newChat"))
|
|
||||||
), [sidebarState.title_overrides, t]);
|
|
||||||
|
|
||||||
const automaticSidebarSort = sidebarState.view.sort === "manual"
|
|
||||||
? "updated_desc"
|
|
||||||
: sidebarState.view.sort;
|
|
||||||
const orderedWorkbenchTabs = useMemo(() => {
|
|
||||||
const orderedSessions = sortSessions(
|
|
||||||
sessions,
|
|
||||||
automaticSidebarSort,
|
|
||||||
sidebarState.title_overrides,
|
|
||||||
sidebarState.session_order,
|
|
||||||
);
|
|
||||||
const updatedAtByKey = new Map(sessions.map((session) => [
|
|
||||||
session.key,
|
|
||||||
session.updatedAt ?? session.createdAt,
|
|
||||||
]));
|
|
||||||
return orderWorkbenchTabs(
|
|
||||||
workbenchState,
|
|
||||||
orderedSessions.map((session) => session.key),
|
|
||||||
updatedAtByKey,
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
automaticSidebarSort,
|
|
||||||
sessions,
|
|
||||||
sidebarState.session_order,
|
|
||||||
sidebarState.title_overrides,
|
|
||||||
workbenchState,
|
|
||||||
]);
|
|
||||||
const orderedWorkbenchTabsByKey = useMemo(
|
|
||||||
() => new Map(orderedWorkbenchTabs.map((tab) => [tab.tabKey, tab])),
|
|
||||||
[orderedWorkbenchTabs],
|
|
||||||
);
|
|
||||||
const sidebarTabPresentations = useMemo(() => {
|
|
||||||
const sessionsByKey = new Map(sessions.map((session) => [session.key, session]));
|
|
||||||
return orderedWorkbenchTabs.flatMap((tab) => {
|
|
||||||
const anchorKey = tab.tab.paneKeys.find((key) => sessionsByKey.has(key))
|
|
||||||
?? tab.paneKeys[0];
|
|
||||||
const anchor = sessionsByKey.get(anchorKey);
|
|
||||||
if (!anchor) return [];
|
|
||||||
const title = tab.tab.title ?? titleForSession(anchor);
|
|
||||||
const visible = tab.tab.explicit || tab.paneKeys.length > 1;
|
|
||||||
const rowKey = visible ? tab.tabKey : tab.paneKeys[0];
|
|
||||||
return [{
|
|
||||||
orderedTab: tab,
|
|
||||||
rowKey,
|
|
||||||
title,
|
|
||||||
session: visible
|
|
||||||
? {
|
|
||||||
...anchor,
|
|
||||||
key: tab.tabKey,
|
|
||||||
chatId: `workbench-tab:${tab.tabKey}`,
|
|
||||||
title,
|
|
||||||
preview: "",
|
|
||||||
updatedAt: tab.updatedAt,
|
|
||||||
}
|
|
||||||
: anchor,
|
|
||||||
}];
|
|
||||||
});
|
|
||||||
}, [orderedWorkbenchTabs, sessions, titleForSession]);
|
|
||||||
const sidebarTopicSessions = useMemo(
|
|
||||||
() => sidebarTabPresentations.map((presentation) => presentation.session),
|
|
||||||
[sidebarTabPresentations],
|
|
||||||
);
|
|
||||||
|
|
||||||
const headerTitle = temporaryChatActive
|
const headerTitle = temporaryChatActive
|
||||||
? deriveTemporaryChatTitle(activeSession?.preview, t("temporaryChat.title"))
|
? deriveTemporaryChatTitle(activeSession?.preview, t("temporaryChat.title"))
|
||||||
: activeSession
|
: activeSession
|
||||||
? titleForSession(activeSession)
|
? sidebarState.title_overrides[activeSession.key] ||
|
||||||
|
activeSession.title ||
|
||||||
|
deriveTitle(activeSession.preview, t("chat.newChat"))
|
||||||
: t("app.brand");
|
: t("app.brand");
|
||||||
const workbenchPaneSessions = useMemo(() => {
|
|
||||||
if (!activeTabState) return [];
|
|
||||||
const byKey = new Map(sessions.map((session) => [session.key, session]));
|
|
||||||
const sortedPaneKeys = activeTabKey
|
|
||||||
? orderedWorkbenchTabsByKey.get(activeTabKey)?.paneKeys ?? activeTabState.paneKeys
|
|
||||||
: activeTabState.paneKeys;
|
|
||||||
const paneKeys = [
|
|
||||||
...activeTabState.layoutPaneKeys.filter((key) => byKey.has(key)),
|
|
||||||
...sortedPaneKeys.filter((key) => !activeTabState.layoutPaneKeys.includes(key)),
|
|
||||||
];
|
|
||||||
return paneKeys
|
|
||||||
.map((key) => byKey.get(key))
|
|
||||||
.filter((session): session is ChatSummary => session !== undefined);
|
|
||||||
}, [activeTabKey, activeTabState, orderedWorkbenchTabsByKey, sessions]);
|
|
||||||
const paneChromeEnabled = Boolean(
|
|
||||||
activeKey && activeSession && !temporaryChatActive && activeTabState,
|
|
||||||
);
|
|
||||||
const activeTabVisible = Boolean(
|
|
||||||
activeTabState
|
|
||||||
&& (activeTabState.explicit || activeTabState.paneKeys.length > 1),
|
|
||||||
);
|
|
||||||
const renderedWorkbenchPanes = useMemo(() => {
|
|
||||||
if (paneChromeEnabled) {
|
|
||||||
return workbenchPaneSessions.map((session) => ({
|
|
||||||
key: session.key,
|
|
||||||
reactKey: session.key === activeTabState?.paneKeys[0]
|
|
||||||
? "tab-root"
|
|
||||||
: `pane:${session.key}`,
|
|
||||||
title: titleForSession(session),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return [{
|
|
||||||
key: activeKey ?? "new-topic",
|
|
||||||
reactKey: "tab-root",
|
|
||||||
title: headerTitle,
|
|
||||||
}];
|
|
||||||
}, [
|
|
||||||
activeKey,
|
|
||||||
activeTabState?.paneKeys,
|
|
||||||
headerTitle,
|
|
||||||
paneChromeEnabled,
|
|
||||||
titleForSession,
|
|
||||||
workbenchPaneSessions,
|
|
||||||
]);
|
|
||||||
const renderedActivePaneKey = activeKey ?? renderedWorkbenchPanes[0].key;
|
|
||||||
const renderedWorkbenchLayout = paneChromeEnabled && activeTabState
|
|
||||||
? activeTabState.layout
|
|
||||||
: "columns";
|
|
||||||
const renderedWorkbenchSplitRatios = paneChromeEnabled && activeTabState
|
|
||||||
? activeTabState.splitRatios
|
|
||||||
: [];
|
|
||||||
const sidebarPaneGroups = useMemo(() => {
|
|
||||||
const sessionsByKey = new Map(sessions.map((session) => [session.key, session]));
|
|
||||||
return Object.fromEntries(sidebarTabPresentations.map((presentation) => {
|
|
||||||
const orderedTab = presentation.orderedTab;
|
|
||||||
const panes = orderedTab.paneKeys
|
|
||||||
.map((key) => sessionsByKey.get(key))
|
|
||||||
.filter((session): session is ChatSummary => session !== undefined)
|
|
||||||
.map((session) => ({
|
|
||||||
key: session.key,
|
|
||||||
chatId: session.chatId,
|
|
||||||
title: titleForSession(session),
|
|
||||||
}));
|
|
||||||
return [presentation.rowKey, {
|
|
||||||
tabKey: orderedTab.tabKey,
|
|
||||||
title: presentation.title,
|
|
||||||
activePaneKey: activeKey && orderedTab.paneKeys.includes(activeKey)
|
|
||||||
? activeKey
|
|
||||||
: orderedTab.paneKeys[0],
|
|
||||||
visible: orderedTab.tab.explicit || orderedTab.paneKeys.length > 1,
|
|
||||||
panes,
|
|
||||||
}];
|
|
||||||
}));
|
|
||||||
}, [
|
|
||||||
activeKey,
|
|
||||||
sessions,
|
|
||||||
sidebarTabPresentations,
|
|
||||||
titleForSession,
|
|
||||||
]);
|
|
||||||
const activePaneLimitReached = Boolean(
|
|
||||||
activeTabState && activeTabState.paneKeys.length >= MAX_WORKBENCH_PANES,
|
|
||||||
);
|
|
||||||
|
|
||||||
const onActivateWorkbenchPane = useCallback((paneKey: string) => {
|
|
||||||
onSelectChat(paneKey);
|
|
||||||
}, [onSelectChat]);
|
|
||||||
|
|
||||||
const onSelectSidebarTab = useCallback((tabKey: string) => {
|
|
||||||
const tab = workbenchTab(workbenchState, tabKey);
|
|
||||||
if (!tab) return;
|
|
||||||
const rememberedPaneKey = lastActivePaneByTabRef.current.get(tabKey);
|
|
||||||
onSelectChat(
|
|
||||||
rememberedPaneKey && tab.paneKeys.includes(rememberedPaneKey)
|
|
||||||
? rememberedPaneKey
|
|
||||||
: tab.paneKeys[0],
|
|
||||||
);
|
|
||||||
}, [onSelectChat, workbenchState]);
|
|
||||||
|
|
||||||
const onSelectSidebarItem = useCallback((key: string) => {
|
|
||||||
if (
|
|
||||||
temporarySessionsRef.current[key]
|
|
||||||
|| sessions.some((session) => session.key === key)
|
|
||||||
) {
|
|
||||||
onSelectChat(key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSelectSidebarTab(key);
|
|
||||||
}, [onSelectChat, onSelectSidebarTab, sessions]);
|
|
||||||
|
|
||||||
const onSelectSidebarPane = useCallback((_tabKey: string, paneKey: string) => {
|
|
||||||
onSelectChat(paneKey);
|
|
||||||
}, [onSelectChat]);
|
|
||||||
|
|
||||||
const onDetachWorkbenchPane = useCallback((tabKey: string, paneKey: string) => {
|
|
||||||
updateWorkbenchState((current) => detachWorkbenchPane(current, tabKey, paneKey));
|
|
||||||
}, [updateWorkbenchState]);
|
|
||||||
|
|
||||||
const onCreateWorkbenchTab = useCallback((paneKey: string) => {
|
|
||||||
updateWorkbenchState((current) => createWorkbenchTab(current, paneKey));
|
|
||||||
}, [updateWorkbenchState]);
|
|
||||||
|
|
||||||
const onDissolveWorkbenchTab = useCallback((tabKey: string) => {
|
|
||||||
updateWorkbenchState((current) => dissolveWorkbenchTab(current, tabKey));
|
|
||||||
}, [updateWorkbenchState]);
|
|
||||||
|
|
||||||
const onAttachWorkbenchPane = useCallback((
|
|
||||||
paneKey: string,
|
|
||||||
tabKey: string,
|
|
||||||
) => {
|
|
||||||
updateWorkbenchState((current) => {
|
|
||||||
const target = workbenchTab(current, tabKey);
|
|
||||||
if (
|
|
||||||
!target
|
|
||||||
|| (!target.explicit && target.paneKeys.length < 2)
|
|
||||||
|| (!target.paneKeys.includes(paneKey) && target.paneKeys.length >= MAX_WORKBENCH_PANES)
|
|
||||||
) return current;
|
|
||||||
return attachWorkbenchPane(current, tabKey, paneKey);
|
|
||||||
});
|
|
||||||
}, [updateWorkbenchState]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (view === "settings") {
|
if (view === "settings") {
|
||||||
@@ -2487,49 +2147,20 @@ function Shell({
|
|||||||
: t("app.documentTitle.base");
|
: t("app.documentTitle.base");
|
||||||
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
|
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
|
||||||
|
|
||||||
const pinnedPaneKeys = useMemo(
|
|
||||||
() => new Set(sidebarState.pinned_keys),
|
|
||||||
[sidebarState.pinned_keys],
|
|
||||||
);
|
|
||||||
const archivedPaneKeys = useMemo(
|
|
||||||
() => new Set(sidebarState.archived_keys),
|
|
||||||
[sidebarState.archived_keys],
|
|
||||||
);
|
|
||||||
const sidebarPinnedTabKeys = useMemo(() => sidebarTabPresentations
|
|
||||||
.filter(({ orderedTab }) => orderedTab.paneKeys.some((key) => pinnedPaneKeys.has(key)))
|
|
||||||
.map(({ rowKey }) => rowKey), [pinnedPaneKeys, sidebarTabPresentations]);
|
|
||||||
const sidebarArchivedTabKeys = useMemo(() => sidebarTabPresentations
|
|
||||||
.filter(({ orderedTab }) => orderedTab.paneKeys.every((key) => archivedPaneKeys.has(key)))
|
|
||||||
.map(({ rowKey }) => rowKey), [archivedPaneKeys, sidebarTabPresentations]);
|
|
||||||
const activeSidebarKey = activeTabKey
|
|
||||||
? sidebarTabPresentations.find(({ orderedTab }) => (
|
|
||||||
orderedTab.tabKey === activeTabKey
|
|
||||||
))?.rowKey ?? activeKey
|
|
||||||
: activeKey;
|
|
||||||
|
|
||||||
const sidebarProps = {
|
const sidebarProps = {
|
||||||
sessions: sidebarTopicSessions,
|
sessions,
|
||||||
temporarySessions: temporarySessionList,
|
temporarySessions: temporarySessionList,
|
||||||
activeKey: view === "chat"
|
activeKey: view === "chat" ? activeKey : null,
|
||||||
? (temporaryChatActive ? activeKey : activeSidebarKey)
|
|
||||||
: null,
|
|
||||||
loading,
|
loading,
|
||||||
newChatActive: view === "chat" && activeKey === null,
|
newChatActive: view === "chat" && activeKey === null,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
onSelect: onSelectSidebarItem,
|
onSelect: onSelectChat,
|
||||||
onCloseTemporaryChat,
|
onCloseTemporaryChat,
|
||||||
onRequestDelete,
|
onRequestDelete,
|
||||||
onRequestDeleteMany,
|
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
onRequestRename,
|
onRequestRename,
|
||||||
onToggleArchive,
|
onToggleArchive,
|
||||||
onRequestRenameTab,
|
onReorderSessions,
|
||||||
paneGroups: sidebarPaneGroups,
|
|
||||||
onSelectPane: onSelectSidebarPane,
|
|
||||||
onCreateTab: mobileWorkbench ? undefined : onCreateWorkbenchTab,
|
|
||||||
onDetachPane: mobileWorkbench ? undefined : onDetachWorkbenchPane,
|
|
||||||
onDissolveTab: mobileWorkbench ? undefined : onDissolveWorkbenchTab,
|
|
||||||
onAttachPane: mobileWorkbench ? undefined : onAttachWorkbenchPane,
|
|
||||||
onToggleGroup,
|
onToggleGroup,
|
||||||
onRequestRenameProject,
|
onRequestRenameProject,
|
||||||
onNewChatInProject,
|
onNewChatInProject,
|
||||||
@@ -2541,19 +2172,17 @@ function Shell({
|
|||||||
onOpenSearch: onOpenSessionSearch,
|
onOpenSearch: onOpenSessionSearch,
|
||||||
activeUtility: view === "apps" || view === "automations" || view === "skills" ? view : null,
|
activeUtility: view === "apps" || view === "automations" || view === "skills" ? view : null,
|
||||||
onToggleArchived,
|
onToggleArchived,
|
||||||
pinnedKeys: sidebarPinnedTabKeys,
|
pinnedKeys: sidebarState.pinned_keys,
|
||||||
archivedKeys: sidebarArchivedTabKeys,
|
archivedKeys: sidebarState.archived_keys,
|
||||||
pinnedPaneKeys: sidebarState.pinned_keys,
|
|
||||||
archivedPaneKeys: sidebarState.archived_keys,
|
|
||||||
sessionOrder: sidebarState.session_order,
|
sessionOrder: sidebarState.session_order,
|
||||||
titleOverrides: sidebarState.title_overrides,
|
titleOverrides: sidebarState.title_overrides,
|
||||||
projectNameOverrides: sidebarState.project_name_overrides,
|
projectNameOverrides: sidebarState.project_name_overrides,
|
||||||
collapsedGroups: sidebarState.collapsed_groups,
|
collapsedGroups: sidebarState.collapsed_groups,
|
||||||
runningChatIds: runningChatIdList,
|
runningChatIds: runningChatIdList,
|
||||||
updatedChatIds: updatedChatIdList,
|
updatedChatIds: updatedChatIdList,
|
||||||
viewState: { ...sidebarState.view, sort: automaticSidebarSort },
|
viewState: sidebarState.view,
|
||||||
showArchived: sidebarState.view.show_archived,
|
showArchived: sidebarState.view.show_archived,
|
||||||
archivedCount: sidebarArchivedTabKeys.length,
|
archivedCount: sidebarState.archived_keys.length,
|
||||||
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
|
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
|
||||||
};
|
};
|
||||||
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
|
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
|
||||||
@@ -2689,7 +2318,7 @@ function Shell({
|
|||||||
<SessionSearchDialog
|
<SessionSearchDialog
|
||||||
open
|
open
|
||||||
onOpenChange={setSessionSearchOpen}
|
onOpenChange={setSessionSearchOpen}
|
||||||
sessions={topicSessions}
|
sessions={sessions}
|
||||||
activeKey={activeKey}
|
activeKey={activeKey}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
titleOverrides={sidebarState.title_overrides}
|
titleOverrides={sidebarState.title_overrides}
|
||||||
@@ -2708,135 +2337,35 @@ function Shell({
|
|||||||
view !== "chat" && "hidden",
|
view !== "chat" && "hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<PaneWorkbench
|
<ThreadShell
|
||||||
panes={renderedWorkbenchPanes}
|
session={activeSession}
|
||||||
activePaneKey={renderedActivePaneKey}
|
sessions={sessions}
|
||||||
layout={renderedWorkbenchLayout}
|
title={headerTitle}
|
||||||
splitRatios={renderedWorkbenchSplitRatios}
|
temporary={temporaryChatRequested}
|
||||||
chrome={paneChromeEnabled}
|
temporaryChatIds={temporaryChatIds}
|
||||||
showLayoutControl={activeTabVisible}
|
temporaryChatEnabled={temporaryChatEnabled}
|
||||||
addPaneDisabled={creatingPane || activePaneLimitReached}
|
onTemporaryChatEnabledChange={
|
||||||
addPaneDisabledLabel={activePaneLimitReached
|
!activeKey ? onTemporaryChatEnabledChange : undefined
|
||||||
? t("workbench.paneLimit", {
|
}
|
||||||
defaultValue: "Maximum {{count}} panes",
|
onToggleSidebar={toggleSidebar}
|
||||||
count: MAX_WORKBENCH_PANES,
|
onNewChat={onNewChat}
|
||||||
})
|
onCreateChat={temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat}
|
||||||
: undefined}
|
onForkChat={temporaryChatActive ? undefined : onForkChat}
|
||||||
onActivatePane={onActivateWorkbenchPane}
|
onTurnEnd={onTurnEnd}
|
||||||
onAddPane={onAddPane}
|
theme={theme}
|
||||||
onLayoutChange={(layout) => {
|
onToggleTheme={toggle}
|
||||||
if (!activeTabKey) return;
|
hideSidebarToggleForHostChrome
|
||||||
updateWorkbenchState((current) => (
|
hostChromeTitleInset={hostSidebarCollapsed}
|
||||||
setWorkbenchLayout(current, activeTabKey, layout)
|
hideHeader={false}
|
||||||
));
|
workspaceScope={activeWorkspaceScope}
|
||||||
}}
|
workspaceDefaultScope={workspaces?.default_scope ?? null}
|
||||||
onPaneOrderChange={(paneKeys) => {
|
workspaceControls={workspaces?.controls ?? null}
|
||||||
if (!activeTabKey) return;
|
workspaceScopeDisabled={activeChatRunning}
|
||||||
updateWorkbenchState((current) => (
|
workspaceError={workspaceError}
|
||||||
setWorkbenchPaneLayoutOrder(current, activeTabKey, paneKeys)
|
onWorkspaceScopeChange={applyWorkspaceScope}
|
||||||
));
|
settingsSnapshot={settingsSnapshot}
|
||||||
}}
|
onOpenModelSettings={onOpenModelSettings}
|
||||||
onSplitRatiosChange={(splitRatios) => {
|
skills={skills}
|
||||||
if (!activeTabKey) return;
|
|
||||||
updateWorkbenchState((current) => (
|
|
||||||
setWorkbenchSplitRatios(current, activeTabKey, splitRatios)
|
|
||||||
));
|
|
||||||
}}
|
|
||||||
renderPane={(pane, context) => {
|
|
||||||
if (!paneChromeEnabled) {
|
|
||||||
return (
|
|
||||||
<ThreadShell
|
|
||||||
session={activeSession}
|
|
||||||
sessions={sessions}
|
|
||||||
title={headerTitle}
|
|
||||||
temporary={temporaryChatRequested}
|
|
||||||
temporaryChatIds={temporaryChatIds}
|
|
||||||
temporaryChatEnabled={temporaryChatEnabled}
|
|
||||||
onTemporaryChatEnabledChange={
|
|
||||||
!activeKey ? onTemporaryChatEnabledChange : undefined
|
|
||||||
}
|
|
||||||
onToggleSidebar={toggleSidebar}
|
|
||||||
onNewChat={onNewChat}
|
|
||||||
onCreateChat={
|
|
||||||
temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat
|
|
||||||
}
|
|
||||||
onForkChat={temporaryChatActive ? undefined : onForkChat}
|
|
||||||
onTurnEnd={onTurnEnd}
|
|
||||||
theme={theme}
|
|
||||||
onToggleTheme={toggle}
|
|
||||||
hideSidebarToggleForHostChrome
|
|
||||||
hostChromeTitleInset={hostSidebarCollapsed}
|
|
||||||
hideHeader={false}
|
|
||||||
workspaceScope={activeWorkspaceScope}
|
|
||||||
workspaceDefaultScope={workspaces?.default_scope ?? null}
|
|
||||||
workspaceControls={workspaces?.controls ?? null}
|
|
||||||
workspaceScopeDisabled={activeChatRunning}
|
|
||||||
workspaceError={workspaceError}
|
|
||||||
onWorkspaceScopeChange={applyWorkspaceScope}
|
|
||||||
settingsSnapshot={settingsSnapshot}
|
|
||||||
onOpenModelSettings={onOpenModelSettings}
|
|
||||||
skills={skills}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const paneSession = workbenchPaneSessions.find(
|
|
||||||
(session) => session.key === pane.key,
|
|
||||||
);
|
|
||||||
if (!paneSession) return null;
|
|
||||||
const paneScope = workspaceOverrides[paneSession.chatId]
|
|
||||||
?? paneSession.workspaceScope
|
|
||||||
?? workspaces?.default_scope
|
|
||||||
?? null;
|
|
||||||
const paneRunning = runningChatIds.has(paneSession.chatId);
|
|
||||||
return (
|
|
||||||
<ThreadShell
|
|
||||||
session={paneSession}
|
|
||||||
sessions={sessions}
|
|
||||||
title={pane.title}
|
|
||||||
onToggleSidebar={toggleSidebar}
|
|
||||||
onNewChat={onNewChat}
|
|
||||||
onCreateChat={onCreateChat}
|
|
||||||
onForkChat={onForkChat}
|
|
||||||
onTurnEnd={context.active ? onTurnEnd : () => void refresh()}
|
|
||||||
theme={theme}
|
|
||||||
onToggleTheme={toggle}
|
|
||||||
hideSidebarToggle={!context.active}
|
|
||||||
hideSidebarToggleForHostChrome={context.active}
|
|
||||||
hostChromeTitleInset={hostSidebarCollapsed}
|
|
||||||
hideThemeButton={!context.active}
|
|
||||||
hideHeaderTitle
|
|
||||||
headerActions={context.headerActions}
|
|
||||||
headerPortalTarget={context.headerPortalTarget}
|
|
||||||
headerActive={context.active}
|
|
||||||
composerPortalTarget={context.composerPortalTarget}
|
|
||||||
composerActive={context.active}
|
|
||||||
composerInputAriaLabel={t("workbench.composerAria", {
|
|
||||||
defaultValue: "Message {{title}}",
|
|
||||||
title: pane.title,
|
|
||||||
})}
|
|
||||||
emptyComposerVariant="thread"
|
|
||||||
workspaceScope={paneScope}
|
|
||||||
workspaceDefaultScope={workspaces?.default_scope ?? null}
|
|
||||||
workspaceControls={workspaces?.controls ?? null}
|
|
||||||
workspaceScopeDisabled={paneRunning}
|
|
||||||
workspaceError={context.active ? workspaceError : null}
|
|
||||||
onWorkspaceScopeChange={(scope) => {
|
|
||||||
if (paneRunning) return;
|
|
||||||
const next = normalizeWorkspaceScope(scope);
|
|
||||||
setWorkspaceError(null);
|
|
||||||
setWorkspaceOverrides((current) => ({
|
|
||||||
...current,
|
|
||||||
[paneSession.chatId]: next,
|
|
||||||
}));
|
|
||||||
client.setWorkspaceScope(paneSession.chatId, next);
|
|
||||||
}}
|
|
||||||
settingsSnapshot={settingsSnapshot}
|
|
||||||
onOpenModelSettings={onOpenModelSettings}
|
|
||||||
skills={skills}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{view !== "chat" && (
|
{view !== "chat" && (
|
||||||
@@ -2869,8 +2398,7 @@ function Shell({
|
|||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<DeleteConfirm
|
<DeleteConfirm
|
||||||
open
|
open
|
||||||
title={pendingDelete.items[0]?.label ?? ""}
|
title={pendingDelete.label}
|
||||||
count={pendingDelete.items.length}
|
|
||||||
automations={pendingDelete.automations}
|
automations={pendingDelete.automations}
|
||||||
onCancel={() => setPendingDelete(null)}
|
onCancel={() => setPendingDelete(null)}
|
||||||
onConfirm={onConfirmDelete}
|
onConfirm={onConfirmDelete}
|
||||||
@@ -2887,19 +2415,6 @@ function Shell({
|
|||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
) : null}
|
) : null}
|
||||||
{pendingTabRename ? (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<RenameChatDialog
|
|
||||||
open
|
|
||||||
title={pendingTabRename.label}
|
|
||||||
dialogTitle={t("workbench.renameTabTitle")}
|
|
||||||
description={t("workbench.renameTabDescription")}
|
|
||||||
placeholder={t("workbench.renameTabPlaceholder")}
|
|
||||||
onCancel={() => setPendingTabRename(null)}
|
|
||||||
onConfirm={onConfirmTabRename}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
) : null}
|
|
||||||
{pendingProjectRename ? (
|
{pendingProjectRename ? (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<RenameChatDialog
|
<RenameChatDialog
|
||||||
|
|||||||
@@ -9,20 +9,17 @@ type ChannelMessagesModule = {
|
|||||||
default?: ChannelMessages;
|
default?: ChannelMessages;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChannelMessagesLoader = () => Promise<ChannelMessagesModule>;
|
|
||||||
|
|
||||||
const modules = import.meta.glob<ChannelMessagesModule>(
|
const modules = import.meta.glob<ChannelMessagesModule>(
|
||||||
"../../../nanobot/channels/*/webui/locales/*.json",
|
"../../../nanobot/channels/*/webui/locales/*.json",
|
||||||
|
{ eager: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadersByChannel = new Map<
|
|
||||||
string,
|
|
||||||
Map<SupportedLocale, ChannelMessagesLoader>
|
|
||||||
>();
|
|
||||||
const translationsByChannel = new Map<string, Map<SupportedLocale, ChannelMessages>>();
|
const translationsByChannel = new Map<string, Map<SupportedLocale, ChannelMessages>>();
|
||||||
const supportedLocaleCodes = new Set<string>(supportedLocales.map(({ code }) => code));
|
const supportedLocaleCodes = new Set<string>(supportedLocales.map(({ code }) => code));
|
||||||
|
|
||||||
for (const [modulePath, loader] of Object.entries(modules)) {
|
for (const [modulePath, module] of Object.entries(modules)) {
|
||||||
|
const messages = module.default;
|
||||||
|
if (!messages) continue;
|
||||||
const match = modulePath.match(/nanobot\/channels\/([^/]+)\/webui\/locales\/([^/]+)\.json$/);
|
const match = modulePath.match(/nanobot\/channels\/([^/]+)\/webui\/locales\/([^/]+)\.json$/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error(`Cannot derive channel locale identity from '${modulePath}'`);
|
throw new Error(`Cannot derive channel locale identity from '${modulePath}'`);
|
||||||
@@ -31,27 +28,25 @@ for (const [modulePath, loader] of Object.entries(modules)) {
|
|||||||
if (!supportedLocaleCodes.has(locale)) {
|
if (!supportedLocaleCodes.has(locale)) {
|
||||||
throw new Error(`Channel '${channel}' has unsupported locale '${locale}'`);
|
throw new Error(`Channel '${channel}' has unsupported locale '${locale}'`);
|
||||||
}
|
}
|
||||||
const loaders = loadersByChannel.get(channel) ?? new Map();
|
const translations = translationsByChannel.get(channel) ?? new Map();
|
||||||
if (loaders.has(locale as SupportedLocale)) {
|
if (translations.has(locale as SupportedLocale)) {
|
||||||
throw new Error(`Channel '${channel}' registers locale '${locale}' more than once`);
|
throw new Error(`Channel '${channel}' registers locale '${locale}' more than once`);
|
||||||
}
|
}
|
||||||
loaders.set(locale as SupportedLocale, loader);
|
translations.set(locale as SupportedLocale, messages);
|
||||||
loadersByChannel.set(channel, loaders);
|
translationsByChannel.set(channel, translations);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function channelLocaleNamespaces(): string[] {
|
export function channelLocaleNamespaces(): string[] {
|
||||||
return [...loadersByChannel.keys()].map(channelNamespace);
|
return [...translationsByChannel.keys()].map(channelNamespace);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function channelLocaleResources(
|
export function channelLocaleResources(locale: SupportedLocale): Record<string, unknown> {
|
||||||
locale: SupportedLocale,
|
return Object.fromEntries(
|
||||||
): Promise<Record<string, ChannelMessages>> {
|
[...translationsByChannel.keys()].map((channel) => [
|
||||||
return Object.fromEntries(await Promise.all(
|
|
||||||
[...loadersByChannel.keys()].map(async (channel) => [
|
|
||||||
channelNamespace(channel),
|
channelNamespace(channel),
|
||||||
await loadChannelLocale(channel, locale),
|
channelLocaleMessages(channel, locale) ?? {},
|
||||||
]),
|
]),
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function channelLocaleMessages(
|
export function channelLocaleMessages(
|
||||||
@@ -68,25 +63,3 @@ export function registeredChannelLocales(): ReadonlyMap<
|
|||||||
> {
|
> {
|
||||||
return translationsByChannel;
|
return translationsByChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadChannelLocale(
|
|
||||||
channel: string,
|
|
||||||
locale: SupportedLocale,
|
|
||||||
): Promise<ChannelMessages> {
|
|
||||||
const translations = translationsByChannel.get(channel) ?? new Map();
|
|
||||||
const loaded = translations.get(locale);
|
|
||||||
if (loaded) return loaded;
|
|
||||||
|
|
||||||
const loaders = loadersByChannel.get(channel);
|
|
||||||
const loader = loaders?.get(locale) ?? loaders?.get("en");
|
|
||||||
if (!loader) {
|
|
||||||
throw new Error(`Channel '${channel}' has no locale loader for '${locale}' or 'en'`);
|
|
||||||
}
|
|
||||||
const messages = (await loader()).default;
|
|
||||||
if (!messages) {
|
|
||||||
throw new Error(`Channel '${channel}' locale '${locale}' has no default export`);
|
|
||||||
}
|
|
||||||
translations.set(locale, messages);
|
|
||||||
translationsByChannel.set(channel, translations);
|
|
||||||
return messages;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type ChannelUiContributionModule = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const modules = import.meta.glob<ChannelUiContributionModule>(
|
const modules = import.meta.glob<ChannelUiContributionModule>(
|
||||||
"../../../nanobot/channels/*/webui/index.{ts,tsx}",
|
"../../../nanobot/channels/*/webui/**/*.{ts,tsx}",
|
||||||
{
|
{
|
||||||
eager: true,
|
eager: true,
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user