mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-14 16:19:17 +03:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
452f5e2214 | ||
|
|
6807f915e1 | ||
|
|
2e61fbc889 | ||
|
|
0e42166bb1 | ||
|
|
af582246f1 | ||
|
|
e07ecc8cc5 | ||
|
|
0c684c5a99 | ||
|
|
d3382d7e57 | ||
|
|
76f629e925 | ||
|
|
5f916bbd3a | ||
|
|
31a71d6cd5 | ||
|
|
498070d036 | ||
|
|
b7f0ae95a9 | ||
|
|
b571d3b9ff | ||
|
|
3741ecda0b | ||
|
|
edec29e997 | ||
|
|
01c7323d74 | ||
|
|
001a7492c2 | ||
|
|
6fc0807fbf | ||
|
|
cd7480945b | ||
|
|
45245b5e55 | ||
|
|
d2cbe6536e | ||
|
|
b34f1bd0e8 | ||
|
|
edaef4e4f5 | ||
|
|
5fc8303f9e | ||
|
|
e455a2b7fa | ||
|
|
19997d20bb | ||
|
|
686dd0603e | ||
|
|
4b5319b760 | ||
|
|
1656664a47 | ||
|
|
a6193932a0 | ||
|
|
bcf5d8a6ed | ||
|
|
d64b84604c |
@@ -189,6 +189,44 @@ 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,6 +6,7 @@ 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
|
||||||
@@ -22,6 +23,32 @@ 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,6 +8,15 @@ 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:
|
||||||
|
|||||||
+10
-3
@@ -51,6 +51,13 @@ 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
|
||||||
@@ -142,7 +149,7 @@ Defaults:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Config | `~/.nanobot/config.json` |
|
| Config | `~/.nanobot/config.json` |
|
||||||
| Workspace | `~/.nanobot/workspace/` |
|
| Workspace | `~/.nanobot/workspace/` |
|
||||||
| Sessions | `<workspace>/sessions/*.jsonl` |
|
| Sessions | `<config-dir>/sessions/<workspace-id>/*.jsonl` (default: `~/.nanobot/sessions/...`) |
|
||||||
| 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/` |
|
||||||
@@ -157,7 +164,7 @@ a WebUI chat may select a separate project:
|
|||||||
|
|
||||||
| Concern | Path owner |
|
| Concern | Path owner |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Sessions, `SOUL.md`, `USER.md`, memory, and custom skills | Configured agent workspace |
|
| Session namespace, `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 |
|
||||||
|
|
||||||
@@ -173,7 +180,7 @@ Session history is the near-term conversation replay. Memory is the longer-term
|
|||||||
|
|
||||||
| Store | File area |
|
| Store | File area |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Session JSONL files | `<workspace>/sessions/` |
|
| Session JSONL files | `<config-dir>/sessions/<workspace-id>/` |
|
||||||
| 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,6 +94,24 @@ 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`.
|
||||||
|
|||||||
+9
-2
@@ -26,7 +26,8 @@ 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, sessions, heartbeat tasks, cron jobs, skills, and generated artifacts |
|
| `~/.nanobot/workspace/` | Agent workspace: memory, heartbeat tasks, cron jobs, skills, and generated artifacts |
|
||||||
|
| `~/.nanobot/sessions/<workspace-id>/` | Session history stored outside the agent-accessible workspace; the opaque ID follows workspace moves |
|
||||||
|
|
||||||
You can override both with command flags:
|
You can override both with command flags:
|
||||||
|
|
||||||
@@ -125,11 +126,17 @@ nanobot uses two related stores:
|
|||||||
|
|
||||||
| Store | Location | Purpose |
|
| Store | Location | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Sessions | `<workspace>/sessions/*.jsonl` | Recent conversation turns replayed into context |
|
| Sessions | `<config-dir>/sessions/<workspace-id>/*.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
|
||||||
|
|||||||
+14
-6
@@ -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 uses DeepSeek's native Responses API. Its provider-hosted web search is
|
DeepSeek V4 Flash and Pro use DeepSeek's native Responses API. Their 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`; DeepSeek models that remain on Chat Completions
|
The switch applies to `deepseek-v4-flash` and `deepseek-v4-pro`; DeepSeek models that remain on
|
||||||
cannot use this Responses tool. Native search calls appear in the WebUI activity stream, and
|
Chat Completions cannot use this Responses tool. Native search calls appear in the WebUI activity
|
||||||
their opaque output items are preserved for multi-turn Responses state replay.
|
stream, and 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 Flash, and compatible GitHub Copilot models.
|
nanobot preserves Responses conversation state automatically for OpenAI Responses, OpenAI Codex, Azure OpenAI, DeepSeek V4, 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,6 +1921,14 @@ 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
|
||||||
@@ -2095,7 +2103,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 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.
|
**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.
|
||||||
|
|
||||||
|
|
||||||
## Pairing
|
## Pairing
|
||||||
|
|||||||
+15
-6
@@ -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 |
|
||||||
| `~/.nanobot/` or your custom config/workspace path is persistent | Sessions, memory, channel login state, generated artifacts, and cron jobs live there |
|
| The active config directory (including `sessions/`) and workspace are persistent | Sessions follow `--config`; memory, generated artifacts, and the workspace identity marker follow the workspace |
|
||||||
| 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,8 +160,11 @@ docker compose logs -f nanobot-gateway # view logs
|
|||||||
docker compose down # stop
|
docker compose down # stop
|
||||||
```
|
```
|
||||||
|
|
||||||
The default Compose file drops all Linux capabilities and keeps Docker's default
|
The default Compose file drops all Linux capabilities except `CHOWN`, `SETUID`, and
|
||||||
AppArmor/seccomp profiles enabled. If you explicitly set
|
`SETGID`, which the root entrypoint needs to fix bind-mount ownership and become UID
|
||||||
|
1000. It also enables `no-new-privileges`, so the non-root process cannot regain those
|
||||||
|
bootstrap capabilities through setuid binaries or file capabilities. Docker's default
|
||||||
|
AppArmor/seccomp profiles remain enabled. If you explicitly set
|
||||||
`"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:
|
||||||
|
|
||||||
@@ -170,8 +173,10 @@ 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 grants `CAP_SYS_ADMIN` and disables AppArmor/seccomp confinement for
|
The override adds `CAP_SYS_ADMIN` and disables AppArmor/seccomp confinement for the
|
||||||
the container so bubblewrap can create its nested namespaces. Use it only when the
|
container so bubblewrap can create its nested namespaces. It preserves
|
||||||
|
`no-new-privileges`. The host must also allow unprivileged user namespaces; the
|
||||||
|
override cannot bypass a host-level namespace restriction. Use it only when the
|
||||||
bwrap sandbox is enabled.
|
bwrap sandbox is enabled.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
@@ -197,6 +202,8 @@ 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
|
||||||
@@ -205,7 +212,9 @@ 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-add SYS_ADMIN \
|
--cap-drop ALL \
|
||||||
|
--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,6 +81,10 @@ 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 config, workspace, sessions, memory files, channel login state, and
|
- Persist the active config directory's `sessions/` folder together with the workspace
|
||||||
generated artifacts.
|
(including `.nanobot/workspace-id`), memory files, channel login state, and generated artifacts.
|
||||||
- Restart the gateway after editing `config.json`.
|
- Restart the gateway after editing `config.json`.
|
||||||
|
|
||||||
## Security notes
|
## Security notes
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ 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/` |
|
||||||
|
|
||||||
@@ -126,6 +127,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
|
||||||
- Use a different workspace per instance if you want isolated memory, sessions, and skills
|
- Session data follows the active config directory; use a different workspace per instance to isolate memory, skills, and the stable session namespace ID
|
||||||
- `--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` 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.
|
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.
|
||||||
|
|
||||||
### Custom OpenAI-Compatible Endpoint
|
### Custom OpenAI-Compatible Endpoint
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -48,7 +48,8 @@ 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/` | Sessions, memory, skills, automations, and generated files |
|
| `~/.nanobot/workspace/` | Memory, skills, automations, and generated files |
|
||||||
|
| `~/.nanobot/sessions/<workspace-id>/` | Recent session history stored outside the workspace; the ID remains stable across workspace moves |
|
||||||
|
|
||||||
If the installer did not open the browser, run:
|
If the installer did not open the browser, run:
|
||||||
|
|
||||||
|
|||||||
@@ -319,7 +319,8 @@ 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. |
|
||||||
| Old sessions appear after moving config | Session files are stored under `<workspace>/sessions/`; verify the workspace path. |
|
| Sessions disappear after changing `--config` | Sessions follow the config directory at `<config-dir>/sessions/<workspace-id>/`; use the original config path or copy that `sessions/` directory into the new config directory while nanobot is stopped. |
|
||||||
|
| Sessions disappear after moving a workspace | Keep the workspace's `.nanobot/workspace-id` file with the move or backup. If it was lost, restore that marker from backup before starting nanobot. |
|
||||||
| 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,29 +42,11 @@ 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
|
||||||
for handler in (
|
return await image_generation_tools.handle_runtime_control(state, msg, tools)
|
||||||
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:
|
||||||
|
|||||||
+24
-33
@@ -95,11 +95,9 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -271,7 +269,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,
|
||||||
mcp_servers: dict[str, MCPServerConfig] | None = None,
|
tool_registry: ToolRegistry | 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,
|
||||||
@@ -379,7 +377,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 = ToolRegistry()
|
self.tools = tool_registry if tool_registry is not None else 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()
|
||||||
@@ -399,15 +397,11 @@ 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_mcp_lock = asyncio.Lock()
|
self._close_lock = asyncio.Lock()
|
||||||
self._session_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
|
self._session_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
|
||||||
weakref.WeakValueDictionary()
|
weakref.WeakValueDictionary()
|
||||||
)
|
)
|
||||||
@@ -464,10 +458,15 @@ 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``).
|
||||||
@@ -477,6 +476,12 @@ 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
|
||||||
@@ -486,8 +491,6 @@ 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,
|
||||||
@@ -502,7 +505,6 @@ 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,
|
||||||
@@ -517,6 +519,7 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -643,14 +646,6 @@ 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,
|
||||||
@@ -1162,7 +1157,6 @@ 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:
|
||||||
@@ -1253,8 +1247,7 @@ 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:
|
||||||
# MCP stdio transports use AnyIO cancel scopes; close them from the task that opened them.
|
await self.aclose()
|
||||||
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."""
|
||||||
@@ -1372,24 +1365,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 close_mcp(self) -> None:
|
async def aclose(self) -> None:
|
||||||
"""Stop active work, then close exec, subagent, and MCP resources.
|
"""Stop active work, then close resources owned by the agent loop.
|
||||||
|
|
||||||
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 agent loop closes itself from ``run()`` while gateway shutdown also
|
# The loop closes itself from ``run()`` while application 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 subprocess transports concurrently.
|
# tear down the same resources concurrently.
|
||||||
close_lock = getattr(self, "_close_mcp_lock", None)
|
close_lock = getattr(self, "_close_lock", None)
|
||||||
if close_lock is None:
|
if close_lock is None:
|
||||||
close_lock = self._close_mcp_lock = asyncio.Lock()
|
close_lock = self._close_lock = asyncio.Lock()
|
||||||
async with close_lock:
|
async with close_lock:
|
||||||
await self._close_mcp_unlocked()
|
await self._aclose_unlocked()
|
||||||
|
|
||||||
async def _close_mcp_unlocked(self) -> None:
|
async def _aclose_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})
|
||||||
@@ -1412,7 +1405,6 @@ 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:
|
||||||
@@ -2301,7 +2293,6 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""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,7 +209,11 @@ 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 _reap_pid # pyright: ignore[reportPrivateUsage]
|
from nanobot.agent.tools.shell import ( # 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()
|
||||||
|
|||||||
+244
-263
@@ -1,4 +1,6 @@
|
|||||||
"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
|
"""MCP client and dynamic tool-provider lifecycle."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -7,23 +9,15 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping
|
||||||
from contextlib import AsyncExitStack, suppress
|
from contextlib import AsyncExitStack, suppress
|
||||||
from typing import TYPE_CHECKING, Any, Literal, Mapping, Protocol, cast
|
from typing import TYPE_CHECKING, Any, Literal, 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,
|
||||||
@@ -39,7 +33,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 MCPServerConfig
|
from nanobot.config.schema import Config, 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
|
||||||
@@ -60,18 +54,37 @@ _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."""
|
||||||
|
|
||||||
@@ -492,11 +505,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
|
||||||
@@ -586,9 +599,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)
|
||||||
@@ -748,9 +761,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)
|
||||||
@@ -852,9 +865,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)
|
||||||
@@ -985,10 +998,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.
|
||||||
|
|
||||||
@@ -1002,7 +1015,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
|
||||||
@@ -1244,7 +1257,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()
|
||||||
@@ -1282,8 +1295,11 @@ 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:
|
try:
|
||||||
result = await connect_single_server(name, cfg)
|
result = await connect_single_server(name, cfg)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1291,6 +1307,17 @@ async def connect_mcp_servers(
|
|||||||
continue
|
continue
|
||||||
if result[1] is not None:
|
if result[1] is not None:
|
||||||
server_stacks[result[0]] = result[1]
|
server_stacks[result[0]] = result[1]
|
||||||
|
except BaseException:
|
||||||
|
# Callers can bound readiness/reload with a timeout. If cancellation
|
||||||
|
# interrupts a later server, ownership of earlier connections has not
|
||||||
|
# transferred yet, so roll the whole batch back before propagating it.
|
||||||
|
for name in attempted_names:
|
||||||
|
_unregister_server_tools(registry, name)
|
||||||
|
try:
|
||||||
|
await _close_mcp_connections(server_stacks)
|
||||||
|
except BaseException as cleanup_exc:
|
||||||
|
logger.debug("MCP batch rollback cleanup error (can be ignored): {}", cleanup_exc)
|
||||||
|
raise
|
||||||
|
|
||||||
return server_stacks
|
return server_stacks
|
||||||
|
|
||||||
@@ -1301,69 +1328,101 @@ 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 _runtime_status_store(
|
def _configured_servers(config: Config) -> dict[str, MCPServerConfig]:
|
||||||
state: Any,
|
from nanobot.agent.plugins import agent_plugin_mcp_servers
|
||||||
|
|
||||||
|
return agent_plugin_mcp_servers(
|
||||||
|
config.workspace_path,
|
||||||
|
config.tools.mcp_servers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_current_servers() -> dict[str, MCPServerConfig]:
|
||||||
|
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||||
|
|
||||||
|
return _configured_servers(resolve_config_env_vars(load_config()))
|
||||||
|
|
||||||
|
|
||||||
|
class MCPProvider:
|
||||||
|
"""Own configured MCP connections and their dynamic tool registrations."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
servers: Mapping[str, MCPServerConfig],
|
||||||
|
registry: ToolRegistry,
|
||||||
*,
|
*,
|
||||||
create: bool = False,
|
server_loader: MCPServerLoader | None = None,
|
||||||
) -> dict[str, MCPRuntimeStatus] | None:
|
) -> None:
|
||||||
raw_statuses: object = getattr(state, "_mcp_runtime_statuses", None)
|
self._servers = dict(servers)
|
||||||
if isinstance(raw_statuses, dict):
|
self._registry = registry
|
||||||
return cast(dict[str, MCPRuntimeStatus], raw_statuses)
|
self._server_loader = server_loader or _load_current_servers
|
||||||
if not create:
|
self._connections: dict[str, MCPConnection] = {}
|
||||||
return None
|
self._runtime_statuses: dict[str, MCPRuntimeStatus] = {}
|
||||||
statuses: dict[str, MCPRuntimeStatus] = {}
|
self._lock = asyncio.Lock()
|
||||||
state._mcp_runtime_statuses = statuses
|
self._closing = False
|
||||||
return statuses
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(
|
||||||
|
cls,
|
||||||
|
config: Config,
|
||||||
|
registry: ToolRegistry,
|
||||||
|
*,
|
||||||
|
server_loader: MCPServerLoader | None = None,
|
||||||
|
) -> MCPProvider:
|
||||||
|
return cls(
|
||||||
|
_configured_servers(config),
|
||||||
|
registry,
|
||||||
|
server_loader=server_loader,
|
||||||
|
)
|
||||||
|
|
||||||
def runtime_status(state: Any) -> dict[str, MCPRuntimeStatus]:
|
@property
|
||||||
"""Return the latest connection-attempt result for configured MCP servers."""
|
def configured_server_names(self) -> set[str]:
|
||||||
statuses = _runtime_status_store(state)
|
return set(self._servers)
|
||||||
raw_configured: object = getattr(state, "_mcp_servers", None)
|
|
||||||
if statuses is None or not isinstance(raw_configured, dict):
|
@property
|
||||||
return {}
|
def connected_server_names(self) -> set[str]:
|
||||||
configured = cast(dict[str, Any], raw_configured)
|
return set(self._connections)
|
||||||
|
|
||||||
|
def runtime_status(self) -> dict[str, MCPRuntimeStatus]:
|
||||||
|
"""Return the latest connection-attempt result for configured servers."""
|
||||||
return {
|
return {
|
||||||
name: status
|
name: status
|
||||||
for name, status in statuses.items()
|
for name, status in self._runtime_statuses.items()
|
||||||
if name in configured and status in _MCP_RUNTIME_STATUSES
|
if name in self._servers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _set_runtime_status(
|
def _set_runtime_status(
|
||||||
state: Any,
|
self,
|
||||||
server_names: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...],
|
server_names: Iterable[str],
|
||||||
status: MCPRuntimeStatus,
|
status: MCPRuntimeStatus,
|
||||||
) -> None:
|
) -> None:
|
||||||
statuses = _runtime_status_store(state, create=True)
|
|
||||||
assert statuses is not None
|
|
||||||
for name in server_names:
|
for name in server_names:
|
||||||
statuses[name] = status
|
self._runtime_statuses[name] = status
|
||||||
|
|
||||||
|
|
||||||
def _record_connection_result(
|
def _record_connection_result(
|
||||||
state: Any,
|
self,
|
||||||
attempted: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...],
|
attempted: Iterable[str],
|
||||||
connected: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...],
|
connected: Iterable[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
attempted_names = set(attempted)
|
attempted_names = set(attempted)
|
||||||
connected_names = set(connected)
|
connected_names = set(connected)
|
||||||
_set_runtime_status(state, connected_names, "connected")
|
self._set_runtime_status(connected_names, "connected")
|
||||||
_set_runtime_status(state, attempted_names - connected_names, "failed")
|
self._set_runtime_status(attempted_names - connected_names, "failed")
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
|
"""Connect configured servers that are not currently live."""
|
||||||
"""Connect configured MCP servers that are not currently live."""
|
async with self._lock:
|
||||||
async with _reload_lock(state):
|
if self._closing:
|
||||||
if getattr(state, "_mcp_closing", False):
|
|
||||||
return
|
return
|
||||||
configured_missing = {
|
configured_missing = {
|
||||||
name: cfg for name, cfg in state._mcp_servers.items() if name not in state._mcp_stacks
|
name: cfg
|
||||||
|
for name, cfg in self._servers.items()
|
||||||
|
if name not in self._connections
|
||||||
}
|
}
|
||||||
oauth_servers = {
|
oauth_servers = {
|
||||||
name: cfg
|
name: cfg
|
||||||
for name, cfg in configured_missing.items()
|
for name, cfg in configured_missing.items()
|
||||||
if getattr(cfg, "auth", None) == "oauth"
|
if cfg.auth == "oauth"
|
||||||
}
|
}
|
||||||
authorization_pending: set[str] = set()
|
authorization_pending: set[str] = set()
|
||||||
if oauth_servers:
|
if oauth_servers:
|
||||||
@@ -1374,62 +1433,53 @@ async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
|
|||||||
for name, cfg in oauth_servers.items()
|
for name, cfg in oauth_servers.items()
|
||||||
if not mcp_oauth_has_credentials(name, cfg.url)
|
if not mcp_oauth_has_credentials(name, cfg.url)
|
||||||
}
|
}
|
||||||
statuses = _runtime_status_store(state)
|
|
||||||
if statuses is not None:
|
|
||||||
for name in authorization_pending:
|
for name in authorization_pending:
|
||||||
statuses.pop(name, None)
|
self._runtime_statuses.pop(name, None)
|
||||||
missing_servers = {
|
missing_servers = {
|
||||||
name: cfg
|
name: cfg
|
||||||
for name, cfg in configured_missing.items()
|
for name, cfg in configured_missing.items()
|
||||||
if name not in authorization_pending
|
if name not in authorization_pending
|
||||||
}
|
}
|
||||||
if state._mcp_connecting or not missing_servers:
|
if not missing_servers:
|
||||||
return
|
return
|
||||||
state._mcp_connecting = True
|
self._set_runtime_status(missing_servers, "connecting")
|
||||||
_set_runtime_status(state, missing_servers, "connecting")
|
|
||||||
try:
|
try:
|
||||||
connected = await connect_mcp_servers(missing_servers, registry)
|
connected = await connect_mcp_servers(missing_servers, self._registry)
|
||||||
if getattr(state, "_mcp_closing", False):
|
if self._closing:
|
||||||
for connection in connected.values():
|
await _close_mcp_connections(connected)
|
||||||
await connection.aclose()
|
|
||||||
return
|
return
|
||||||
state._mcp_stacks.update(connected)
|
self._connections.update(connected)
|
||||||
_record_connection_result(state, missing_servers, connected)
|
self._record_connection_result(missing_servers, connected)
|
||||||
_attach_reconnect_handlers(state, registry, connected)
|
self._attach_reconnect_handlers(connected)
|
||||||
if connected:
|
if connected:
|
||||||
logger.info("MCP connected servers: {}", sorted(connected))
|
logger.info("MCP connected servers: {}", sorted(connected))
|
||||||
else:
|
else:
|
||||||
logger.warning("No MCP servers connected successfully (will retry next message)")
|
logger.warning(
|
||||||
|
"No MCP servers connected successfully "
|
||||||
|
"(will retry on the next readiness check)"
|
||||||
|
)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
self._set_runtime_status(missing_servers, "failed")
|
||||||
if task_is_cancelling():
|
if task_is_cancelling():
|
||||||
raise
|
raise
|
||||||
_set_runtime_status(state, missing_servers, "failed")
|
logger.warning(
|
||||||
logger.warning("MCP connection cancelled (will retry next message)")
|
"MCP connection cancelled (will retry on the next readiness check)"
|
||||||
except BaseException as e:
|
|
||||||
_set_runtime_status(state, missing_servers, "failed")
|
|
||||||
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
|
|
||||||
finally:
|
|
||||||
state._mcp_connecting = False
|
|
||||||
|
|
||||||
|
|
||||||
async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|
||||||
"""Reconcile live MCP connections with the current config file."""
|
|
||||||
async with _reload_lock(state):
|
|
||||||
if getattr(state, "_mcp_closing", False):
|
|
||||||
return {
|
|
||||||
"ok": False,
|
|
||||||
"message": "MCP connections are shutting down.",
|
|
||||||
"requires_restart": True,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
from nanobot.agent.plugins import agent_plugin_mcp_servers
|
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
|
||||||
|
|
||||||
config = resolve_config_env_vars(load_config())
|
|
||||||
next_servers = agent_plugin_mcp_servers(
|
|
||||||
config.workspace_path,
|
|
||||||
config.tools.mcp_servers,
|
|
||||||
)
|
)
|
||||||
|
except 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:
|
except Exception as exc:
|
||||||
logger.warning("MCP hot reload could not read config: {}", exc)
|
logger.warning("MCP hot reload could not read config: {}", exc)
|
||||||
return {
|
return {
|
||||||
@@ -1439,7 +1489,7 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
}
|
}
|
||||||
|
|
||||||
current_servers = dict(state._mcp_servers)
|
current_servers = dict(self._servers)
|
||||||
current_names = set(current_servers)
|
current_names = set(current_servers)
|
||||||
next_names = set(next_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
|
||||||
@@ -1454,52 +1504,54 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|||||||
changed = sorted(
|
changed = sorted(
|
||||||
name
|
name
|
||||||
for name in current_names & next_names
|
for name in current_names & next_names
|
||||||
if _server_signature(current_servers[name]) != _server_signature(next_servers[name])
|
if _server_signature(current_servers[name])
|
||||||
|
!= _server_signature(next_servers[name])
|
||||||
)
|
)
|
||||||
|
|
||||||
tools_removed = 0
|
tools_removed = 0
|
||||||
for name in [*removed, *changed]:
|
for name in [*removed, *changed]:
|
||||||
tools_removed += _unregister_server_tools(registry, name)
|
tools_removed += _unregister_server_tools(self._registry, name)
|
||||||
await _close_server(state, name)
|
await self._close_server(name)
|
||||||
|
|
||||||
runtime_statuses = _runtime_status_store(state)
|
|
||||||
if runtime_statuses is not None:
|
|
||||||
for name in [*removed, *authorization_pending]:
|
for name in [*removed, *authorization_pending]:
|
||||||
runtime_statuses.pop(name, None)
|
self._runtime_statuses.pop(name, None)
|
||||||
|
|
||||||
state._mcp_servers = next_servers
|
self._servers = next_servers
|
||||||
retry_missing = sorted(
|
retry_missing = sorted(
|
||||||
name
|
name
|
||||||
for name in next_names
|
for name in next_names
|
||||||
if name not in state._mcp_stacks
|
if name not in self._connections
|
||||||
and name not in set(added) | set(changed)
|
and name not in set(added) | set(changed)
|
||||||
and name not in authorization_pending
|
and name not in authorization_pending
|
||||||
)
|
)
|
||||||
to_connect_names = sorted(
|
to_connect_names = sorted(
|
||||||
(set(added) | set(changed) | set(retry_missing)) - authorization_pending
|
(set(added) | set(changed) | set(retry_missing))
|
||||||
|
- authorization_pending
|
||||||
)
|
)
|
||||||
to_connect = {name: next_servers[name] for name in to_connect_names}
|
to_connect = {name: next_servers[name] for name in to_connect_names}
|
||||||
connected: dict[str, MCPConnection] = {}
|
connected: dict[str, MCPConnection] = {}
|
||||||
if to_connect:
|
if to_connect:
|
||||||
_set_runtime_status(state, to_connect, "connecting")
|
self._set_runtime_status(to_connect, "connecting")
|
||||||
connected = await connect_mcp_servers(to_connect, registry)
|
try:
|
||||||
if getattr(state, "_mcp_closing", False):
|
connected = await connect_mcp_servers(to_connect, self._registry)
|
||||||
for connection in connected.values():
|
except BaseException:
|
||||||
await connection.aclose()
|
self._set_runtime_status(to_connect, "failed")
|
||||||
return {
|
raise
|
||||||
"ok": False,
|
if self._closing:
|
||||||
"message": "MCP connections are shutting down.",
|
await _close_mcp_connections(connected)
|
||||||
"requires_restart": True,
|
return self._closing_result()
|
||||||
}
|
self._connections.update(connected)
|
||||||
state._mcp_stacks.update(connected)
|
self._record_connection_result(to_connect, connected)
|
||||||
_record_connection_result(state, to_connect, connected)
|
self._attach_reconnect_handlers(connected)
|
||||||
_attach_reconnect_handlers(state, registry, connected)
|
|
||||||
|
|
||||||
failed = sorted(set(to_connect) - set(connected))
|
failed = sorted(set(to_connect) - set(connected))
|
||||||
unchanged = not removed and not added and not changed and not retry_missing
|
unchanged = not removed and not added and not changed and not retry_missing
|
||||||
ok = not failed
|
ok = not failed
|
||||||
if failed:
|
if failed:
|
||||||
message = "MCP config reloaded, but some servers did not connect: " + ", ".join(failed)
|
message = (
|
||||||
|
"MCP config reloaded, but some servers did not connect: "
|
||||||
|
+ ", ".join(failed)
|
||||||
|
)
|
||||||
elif unchanged:
|
elif unchanged:
|
||||||
message = "MCP config is already live."
|
message = "MCP config is already live."
|
||||||
elif retry_missing and not added and not changed and not removed:
|
elif retry_missing and not added and not changed and not removed:
|
||||||
@@ -1508,7 +1560,8 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|||||||
message = "MCP config reloaded without restarting nanobot."
|
message = "MCP config reloaded without restarting nanobot."
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"MCP hot reload: added={} changed={} removed={} retried={} connected={} failed={} tools_removed={}",
|
"MCP hot reload: added={} changed={} removed={} retried={} "
|
||||||
|
"connected={} failed={} tools_removed={}",
|
||||||
added,
|
added,
|
||||||
changed,
|
changed,
|
||||||
removed,
|
removed,
|
||||||
@@ -1524,114 +1577,51 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|||||||
"changed": changed,
|
"changed": changed,
|
||||||
"removed": removed,
|
"removed": removed,
|
||||||
"retried": retry_missing,
|
"retried": retry_missing,
|
||||||
"connected": sorted(state._mcp_stacks),
|
"connected": sorted(self._connections),
|
||||||
"configured": sorted(state._mcp_servers),
|
"configured": sorted(self._servers),
|
||||||
"failed": failed,
|
"failed": failed,
|
||||||
"tools_removed": tools_removed,
|
"tools_removed": tools_removed,
|
||||||
"requires_restart": False,
|
"requires_restart": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
async def request_mcp_reload(
|
def _closing_result() -> dict[str, Any]:
|
||||||
bus: MessageBus,
|
|
||||||
*,
|
|
||||||
timeout: float = 15.0,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Ask the running agent loop to reconcile live MCP connections."""
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
ack: asyncio.Future[dict[str, Any]] = loop.create_future()
|
|
||||||
await bus.publish_inbound(
|
|
||||||
InboundMessage(
|
|
||||||
channel="system",
|
|
||||||
sender_id="webui-settings",
|
|
||||||
chat_id="runtime",
|
|
||||||
content=RUNTIME_CONTROL_MCP_RELOAD,
|
|
||||||
metadata={
|
|
||||||
INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_MCP_RELOAD,
|
|
||||||
RUNTIME_CONTROL_ACK: ack,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
result = await asyncio.wait_for(ack, timeout=timeout)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"message": "MCP hot reload timed out. Restart nanobot to pick up changes.",
|
"message": "MCP connections are shutting down.",
|
||||||
"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,
|
"requires_restart": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _attach_reconnect_handlers(self, server_names: Iterable[str]) -> None:
|
||||||
async def handle_runtime_control(state: Any, msg: InboundMessage, registry: ToolRegistry) -> bool:
|
async def reconnect(
|
||||||
metadata = msg.metadata if isinstance(cast(object, msg.metadata), dict) else {}
|
server_name: str,
|
||||||
control = metadata.get(INBOUND_META_RUNTIME_CONTROL)
|
tool_name: str,
|
||||||
if control != RUNTIME_CONTROL_MCP_RELOAD:
|
stale_tool: Tool,
|
||||||
return False
|
) -> Tool | None:
|
||||||
|
return await self._refresh_terminated_server(
|
||||||
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,
|
server_name,
|
||||||
tool_name,
|
tool_name,
|
||||||
stale_tool,
|
stale_tool,
|
||||||
)
|
)
|
||||||
|
|
||||||
for server_name in server_names:
|
for server_name in server_names:
|
||||||
for tool_name in list(registry.tool_names):
|
for tool_name in list(self._registry.tool_names):
|
||||||
tool = registry.get(tool_name)
|
tool = self._registry.get(tool_name)
|
||||||
if not _tool_belongs_to_server(tool, tool_name, server_name):
|
if not _tool_belongs_to_server(tool, tool_name, server_name):
|
||||||
continue
|
continue
|
||||||
if isinstance(tool, _MCPWrapperBase):
|
if isinstance(tool, _MCPWrapperBase):
|
||||||
tool.set_reconnect_handler(reconnect)
|
tool.set_reconnect_handler(reconnect)
|
||||||
|
|
||||||
|
|
||||||
async def _refresh_terminated_server(
|
async def _refresh_terminated_server(
|
||||||
state: Any,
|
self,
|
||||||
registry: ToolRegistry,
|
|
||||||
server_name: str,
|
server_name: str,
|
||||||
tool_name: str,
|
tool_name: str,
|
||||||
stale_tool: Tool,
|
stale_tool: Tool,
|
||||||
) -> Tool | None:
|
) -> Tool | None:
|
||||||
async with _reload_lock(state):
|
async with self._lock:
|
||||||
if getattr(state, "_mcp_closing", False):
|
if self._closing:
|
||||||
return None
|
return None
|
||||||
cfg = state._mcp_servers.get(server_name)
|
cfg = self._servers.get(server_name)
|
||||||
if cfg is None:
|
if cfg is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"MCP server '{}' session terminated but is no longer configured",
|
"MCP server '{}' session terminated but is no longer configured",
|
||||||
@@ -1639,31 +1629,56 @@ async def _refresh_terminated_server(
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
current_tool = registry.get(tool_name)
|
current_tool = self._registry.get(tool_name)
|
||||||
if (
|
if (
|
||||||
current_tool is not None
|
current_tool is not None
|
||||||
and current_tool is not stale_tool
|
and current_tool is not stale_tool
|
||||||
and server_name in state._mcp_stacks
|
and server_name in self._connections
|
||||||
):
|
):
|
||||||
return current_tool
|
return current_tool
|
||||||
|
|
||||||
logger.warning("MCP server '{}' session terminated; refreshing connection", server_name)
|
logger.warning(
|
||||||
_unregister_server_tools(registry, server_name)
|
"MCP server '{}' session terminated; refreshing connection",
|
||||||
await _close_server(state, server_name)
|
server_name,
|
||||||
|
)
|
||||||
|
_unregister_server_tools(self._registry, server_name)
|
||||||
|
await self._close_server(server_name)
|
||||||
|
|
||||||
_set_runtime_status(state, {server_name}, "connecting")
|
self._set_runtime_status({server_name}, "connecting")
|
||||||
connected = await connect_mcp_servers({server_name: cfg}, registry)
|
connected = await connect_mcp_servers(
|
||||||
if getattr(state, "_mcp_closing", False):
|
{server_name: cfg},
|
||||||
for connection in connected.values():
|
self._registry,
|
||||||
await connection.aclose()
|
)
|
||||||
|
if self._closing:
|
||||||
|
await _close_mcp_connections(connected)
|
||||||
return None
|
return None
|
||||||
state._mcp_stacks.update(connected)
|
self._connections.update(connected)
|
||||||
_record_connection_result(state, {server_name}, connected)
|
self._record_connection_result({server_name}, connected)
|
||||||
_attach_reconnect_handlers(state, registry, connected)
|
self._attach_reconnect_handlers(connected)
|
||||||
if server_name not in connected:
|
if server_name not in connected:
|
||||||
logger.warning("MCP server '{}' reconnect failed after session termination", server_name)
|
logger.warning(
|
||||||
|
"MCP server '{}' reconnect failed after session termination",
|
||||||
|
server_name,
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
return registry.get(tool_name)
|
return self._registry.get(tool_name)
|
||||||
|
|
||||||
|
async def _close_server(self, server_name: str) -> None:
|
||||||
|
connection = self._connections.pop(server_name, None)
|
||||||
|
if connection is None:
|
||||||
|
return
|
||||||
|
await _close_mcp_connection(server_name, connection)
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
"""Close every connection while excluding reconnect and hot reload."""
|
||||||
|
self._closing = True
|
||||||
|
async with self._lock:
|
||||||
|
connections = dict(self._connections)
|
||||||
|
self._connections.clear()
|
||||||
|
self._runtime_statuses.clear()
|
||||||
|
for name in self._servers:
|
||||||
|
_unregister_server_tools(self._registry, name)
|
||||||
|
await _close_mcp_connections(connections)
|
||||||
|
|
||||||
|
|
||||||
def _server_signature(cfg: Any) -> Any:
|
def _server_signature(cfg: Any) -> Any:
|
||||||
@@ -1690,37 +1705,3 @@ 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)
|
||||||
"_mcp_servers", "_mcp_stacks", "_pending_queues",
|
"_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",
|
||||||
|
|||||||
+221
-15
@@ -5,6 +5,7 @@ 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
|
||||||
@@ -12,7 +13,8 @@ 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
|
from typing import Any, Protocol, cast
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
@@ -42,6 +44,17 @@ 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:
|
||||||
@@ -326,6 +339,7 @@ class ExecTool(Tool):
|
|||||||
prepared.env,
|
prepared.env,
|
||||||
prepared.shell_program,
|
prepared.shell_program,
|
||||||
prepared.login,
|
prepared.login,
|
||||||
|
process_tree=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -334,10 +348,10 @@ class ExecTool(Tool):
|
|||||||
timeout=prepared.timeout,
|
timeout=prepared.timeout,
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
await self._kill_process(process)
|
await self._kill_process_tree(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(process)
|
await self._kill_process_tree(process)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# Safety-net reap: asyncio *should* have reaped the child via
|
# Safety-net reap: asyncio *should* have reaped the child via
|
||||||
@@ -368,13 +382,14 @@ 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(process)
|
await self._kill_process_tree(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(
|
||||||
@@ -537,22 +552,31 @@ 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,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
command = ExecTool._normalize_powershell_command(command)
|
command = ExecTool._normalize_powershell_command(command)
|
||||||
command = (
|
command = (
|
||||||
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)\n"
|
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)\n"
|
||||||
@@ -561,14 +585,25 @@ class ExecTool(Tool):
|
|||||||
f"{command}\n"
|
f"{command}\n"
|
||||||
"if ($LASTEXITCODE -ne $null) { exit $LASTEXITCODE }"
|
"if ($LASTEXITCODE -ne $null) { exit $LASTEXITCODE }"
|
||||||
)
|
)
|
||||||
return await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
program, "-NoProfile", "-NonInteractive", "-Command", command,
|
program, "-NoProfile", "-NonInteractive", "-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=env,
|
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()
|
||||||
@@ -687,11 +722,12 @@ 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."""
|
||||||
if process.returncode is not None:
|
owner = ExecTool._process_tree_owner(process)
|
||||||
_reap_pid(process.pid)
|
|
||||||
return
|
|
||||||
try:
|
try:
|
||||||
if _IS_WINDOWS:
|
if owner is not None:
|
||||||
|
owner.terminate()
|
||||||
|
elif _IS_WINDOWS:
|
||||||
|
if process.returncode is None:
|
||||||
with suppress(OSError, asyncio.TimeoutError):
|
with suppress(OSError, asyncio.TimeoutError):
|
||||||
await asyncio.wait_for(
|
await asyncio.wait_for(
|
||||||
asyncio.to_thread(
|
asyncio.to_thread(
|
||||||
@@ -715,8 +751,36 @@ 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.
|
||||||
|
|
||||||
@@ -826,12 +890,27 @@ 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
|
||||||
|
|
||||||
@@ -914,7 +993,9 @@ class ExecTool(Tool):
|
|||||||
):
|
):
|
||||||
current.append(ch)
|
current.append(ch)
|
||||||
operator_len = 1
|
operator_len = 1
|
||||||
elif ch in {";", "|"}:
|
# A newline separates commands just like ";" does, so a payload
|
||||||
|
# smuggled onto its own line must be checked on its own too.
|
||||||
|
elif ch in {";", "|", "\n", "\r"}:
|
||||||
operator_len = 1
|
operator_len = 1
|
||||||
|
|
||||||
if operator_len:
|
if operator_len:
|
||||||
@@ -948,9 +1029,134 @@ 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
|
||||||
)
|
)
|
||||||
posix_paths = re.findall(r"(?:^|[\s|>='\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
|
try:
|
||||||
home_paths = re.findall(r"(?:^|[\s>='\"])(~[/+][^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~/ or ~+
|
lexer = shlex.shlex(command, posix=True, punctuation_chars="();<>|&")
|
||||||
return win_paths + posix_paths + home_paths
|
lexer.whitespace_split = True
|
||||||
|
lexer.commenters = ""
|
||||||
|
tokens = list(lexer)
|
||||||
|
except ValueError:
|
||||||
|
# Keep malformed quoting fail-closed. The shell will normally reject
|
||||||
|
# it too, but a conservative raw scan must not turn it into a bypass.
|
||||||
|
tokens = [command]
|
||||||
|
|
||||||
|
paths = [*win_paths]
|
||||||
|
seen = set(win_paths)
|
||||||
|
for index, token in enumerate(tokens):
|
||||||
|
for path in ExecTool._extract_posix_paths_from_token(token):
|
||||||
|
if path not in seen:
|
||||||
|
paths.append(path)
|
||||||
|
seen.add(path)
|
||||||
|
if index > 0 and tokens[index - 1] in {"-c", "-lc", "--command"}:
|
||||||
|
for path in ExecTool._extract_absolute_paths(token):
|
||||||
|
if path not in seen:
|
||||||
|
paths.append(path)
|
||||||
|
seen.add(path)
|
||||||
|
return paths
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_posix_paths_from_token(token: str) -> list[str]:
|
||||||
|
"""Extract local POSIX/home paths from one shell-decoded token.
|
||||||
|
|
||||||
|
``shlex`` separates real grouping/redirection operators while preserving
|
||||||
|
parentheses and spaces that were quoted or escaped as part of a path.
|
||||||
|
Embedded scripts (for example ``sh -c \"cat /tmp/x\"``) still need a
|
||||||
|
small boundary scan. Colons are not general boundaries: treating them
|
||||||
|
as such misclassifies URLs, ``host:/remote`` and ``C:/Windows``. They
|
||||||
|
are considered only inside a syntactically valid assignment, where
|
||||||
|
shells expand each colon-delimited tilde component.
|
||||||
|
"""
|
||||||
|
paths: list[str] = []
|
||||||
|
for match in re.finditer(
|
||||||
|
r"file://(?:[^/\s\"']+)?(/[^\s\"'<>|;&]*)",
|
||||||
|
token,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
):
|
||||||
|
uri_prefix = token[: match.start()]
|
||||||
|
raw_path = match.group(1)
|
||||||
|
if uri_prefix.count("(") > uri_prefix.count(")"):
|
||||||
|
raw_path = raw_path.split(")", 1)[0]
|
||||||
|
if uri_prefix.count("{") > uri_prefix.count("}"):
|
||||||
|
raw_path = raw_path.split(",", 1)[0].split("}", 1)[0]
|
||||||
|
raw_path = raw_path.split("?", 1)[0].split("#", 1)[0]
|
||||||
|
if raw_path:
|
||||||
|
paths.append(unquote(raw_path))
|
||||||
|
boundary_chars = frozenset(" \t\r\n=({,<>|;&\"'")
|
||||||
|
i = 0
|
||||||
|
while i < len(token):
|
||||||
|
is_posix = token[i] == "/"
|
||||||
|
home_match = re.match(
|
||||||
|
r"~(?:[+-](?:\d+)?|[A-Za-z0-9_.@-]+)?(?=/|:|$)",
|
||||||
|
token[i:],
|
||||||
|
)
|
||||||
|
is_home = home_match is not None
|
||||||
|
if not is_posix and not is_home:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
prefix = token[:i]
|
||||||
|
parameter_default = (
|
||||||
|
i >= 2 and token[i - 2] == ":" and token[i - 1] in "-+?="
|
||||||
|
)
|
||||||
|
word_start = max(
|
||||||
|
(prefix.rfind(char) for char in " \t\r\n<>|;&"),
|
||||||
|
default=-1,
|
||||||
|
) + 1
|
||||||
|
word_prefix = prefix[word_start:]
|
||||||
|
assignment_component = bool(
|
||||||
|
re.fullmatch(
|
||||||
|
r"(?:[A-Za-z_][A-Za-z0-9_]*|--?[A-Za-z0-9_.-]+)="
|
||||||
|
r"(?:[^:=\s]*:)*",
|
||||||
|
word_prefix,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
at_boundary = i == 0 or token[i - 1] in boundary_chars
|
||||||
|
if is_home:
|
||||||
|
# A shell word beginning with ``~`` is a separate shlex token.
|
||||||
|
# Mid-token expansion is valid only after ``=`` or a colon in
|
||||||
|
# an assignment. This avoids PromQL/Loki ``=~`` and ``|~``
|
||||||
|
# match operators while covering PATH-like values.
|
||||||
|
at_boundary = i == 0 or assignment_component
|
||||||
|
if not at_boundary and not parameter_default:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if re.search(r"[A-Za-z][A-Za-z0-9+.-]*://", word_prefix) or re.match(
|
||||||
|
r"(?:[^/:=\s]+@)?[^/:=\s]+:$",
|
||||||
|
word_prefix,
|
||||||
|
):
|
||||||
|
# HTTP-style URL path/query fragments and scp-style remote paths
|
||||||
|
# are not local filesystem references. ``file://`` paths were
|
||||||
|
# decoded above. Windows drive paths are already captured by the
|
||||||
|
# platform-specific expression above.
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
assignment_value = assignment_component
|
||||||
|
if i == 0 or assignment_value:
|
||||||
|
end = len(token)
|
||||||
|
if assignment_value:
|
||||||
|
separator = token.find(":", i)
|
||||||
|
if separator >= 0:
|
||||||
|
end = separator
|
||||||
|
elif token[i - 1] in {"'", '"'}:
|
||||||
|
quote = token[i - 1]
|
||||||
|
closing = token.find(quote, i)
|
||||||
|
end = len(token) if closing < 0 else closing
|
||||||
|
else:
|
||||||
|
end_chars = set(" \t\r\n\"'<>|;&")
|
||||||
|
if prefix.count("(") > prefix.count(")"):
|
||||||
|
end_chars.add(")")
|
||||||
|
if prefix.count("{") > prefix.count("}"):
|
||||||
|
end_chars.update({",", "}"})
|
||||||
|
end = i
|
||||||
|
while end < len(token) and token[end] not in end_chars:
|
||||||
|
end += 1
|
||||||
|
|
||||||
|
candidate = token[i:end]
|
||||||
|
if candidate:
|
||||||
|
paths.append(candidate)
|
||||||
|
i = max(end, i + 1)
|
||||||
|
return paths
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_bind_roots(paths: list[str] | None) -> list[Path]:
|
def _normalize_bind_roots(paths: list[str] | None) -> list[Path]:
|
||||||
|
|||||||
+120
-17
@@ -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 quote, urljoin, urlparse
|
from urllib.parse import parse_qsl, quote, urljoin, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -148,6 +148,59 @@ 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,
|
||||||
@@ -191,13 +244,14 @@ 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]:
|
) -> tuple[httpx.Response | None, Any | None, str | None, bool]:
|
||||||
"""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}"
|
return None, None, f"Redirect blocked: {error_msg}", chain_carries_credentials
|
||||||
|
|
||||||
stream = client.stream(
|
stream = client.stream(
|
||||||
"GET",
|
"GET",
|
||||||
@@ -210,26 +264,39 @@ 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 None, None, f"Redirect blocked: {unsafe_error}"
|
return (
|
||||||
|
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
|
return response, stream, None, chain_carries_credentials
|
||||||
|
|
||||||
location = response.headers.get("location")
|
location = response.headers.get("location")
|
||||||
if not location:
|
if not location:
|
||||||
return response, stream, None
|
return response, stream, None, chain_carries_credentials
|
||||||
|
|
||||||
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}"
|
return None, None, f"Redirect blocked: {error_msg}", chain_carries_credentials
|
||||||
|
|
||||||
await stream.__aexit__(None, None, None)
|
await stream.__aexit__(None, None, None)
|
||||||
current_url = next_url
|
current_url = next_url
|
||||||
|
|
||||||
return None, None, f"Too many redirects: exceeded limit of {MAX_REDIRECTS}"
|
return (
|
||||||
|
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:
|
||||||
@@ -1043,20 +1110,26 @@ 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 = await _stream_with_safe_redirects(
|
r, stream, redirect_error, chain_carries_credentials = (
|
||||||
|
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", "")
|
||||||
@@ -1071,10 +1144,14 @@ 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("Pre-fetch image detection failed for {}: {}", url, e)
|
logger.debug(
|
||||||
|
"Pre-fetch image detection failed for {} ({})",
|
||||||
|
_redact_url_for_log(url),
|
||||||
|
type(e).__name__,
|
||||||
|
)
|
||||||
|
|
||||||
result = None
|
result = None
|
||||||
if self.config.use_jina_reader:
|
if self.config.use_jina_reader and jina_remote_safe:
|
||||||
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)
|
||||||
@@ -1082,13 +1159,23 @@ 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/{url}", headers=headers)
|
r = await client.get(f"https://r.jina.ai/{forwarded_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
|
||||||
@@ -1113,7 +1200,11 @@ 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("Jina Reader failed for {}, falling back to readability: {}", url, e)
|
logger.debug(
|
||||||
|
"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:
|
||||||
@@ -1144,7 +1235,11 @@ 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("Readability failed for {}, using raw HTML fallback: {}", url, e)
|
logger.warning(
|
||||||
|
"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"
|
||||||
@@ -1160,10 +1255,18 @@ 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.exception("WebFetch proxy error for {}", url)
|
logger.warning(
|
||||||
|
"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.exception("WebFetch error for {}", url)
|
logger.warning(
|
||||||
|
"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:
|
||||||
|
|||||||
+21
-8
@@ -48,6 +48,7 @@ _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()
|
||||||
|
|
||||||
|
|
||||||
@@ -66,6 +67,17 @@ 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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -346,8 +358,9 @@ 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:
|
||||||
response = await asyncio.wait_for(
|
async with asyncio.timeout(timeout_s):
|
||||||
agent_loop.process_direct(
|
await _prepare_agent(request.app)
|
||||||
|
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,
|
||||||
@@ -355,8 +368,6 @@ 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)
|
||||||
@@ -390,15 +401,14 @@ async def handle_chat_completions(request: web.Request) -> web.Response | web.St
|
|||||||
try:
|
try:
|
||||||
async with session_lock:
|
async with session_lock:
|
||||||
try:
|
try:
|
||||||
response = await asyncio.wait_for(
|
async with asyncio.timeout(timeout_s):
|
||||||
agent_loop.process_direct(
|
await _prepare_agent(request.app)
|
||||||
|
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():
|
||||||
@@ -452,6 +462,7 @@ 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.
|
||||||
|
|
||||||
@@ -460,12 +471,14 @@ 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(
|
||||||
|
|||||||
@@ -1029,6 +1029,7 @@ 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()
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ 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,7 +1,13 @@
|
|||||||
|
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 { FeishuAssistantsPanel } from "./FeishuAssistantsPanel";
|
const FeishuAssistantsPanel = lazy(() =>
|
||||||
|
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 Callable, Iterable, Mapping
|
from collections.abc import Awaitable, 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,6 +101,7 @@ 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,
|
||||||
):
|
):
|
||||||
@@ -121,6 +122,7 @@ 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] = {}
|
||||||
@@ -190,6 +192,7 @@ 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,6 +530,10 @@ 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)
|
||||||
@@ -848,7 +852,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(
|
saved_state = await asyncio.to_thread(
|
||||||
write_webui_sidebar_state,
|
write_webui_sidebar_state,
|
||||||
cast(dict[str, Any], state),
|
cast(dict[str, Any], state),
|
||||||
)
|
)
|
||||||
@@ -859,6 +863,11 @@ class WebSocketChannel(BaseChannel):
|
|||||||
detail="invalid_sidebar_state",
|
detail="invalid_sidebar_state",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
await self._broadcast_webui_event(
|
||||||
|
"sidebar_state_updated",
|
||||||
|
state=saved_state,
|
||||||
|
)
|
||||||
|
return
|
||||||
if t == "set_workspace_scope":
|
if t == "set_workspace_scope":
|
||||||
cid = envelope.get("chat_id")
|
cid = envelope.get("chat_id")
|
||||||
if not _is_valid_chat_id(cid):
|
if not _is_valid_chat_id(cid):
|
||||||
@@ -1207,6 +1216,11 @@ 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,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""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="/auth/token",
|
tokenIssuePath="/custom-token",
|
||||||
websocketRequiresToken=True,
|
websocketRequiresToken=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -725,15 +725,16 @@ 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}/auth/token")
|
denied = await _http_get(f"http://127.0.0.1:{port}/custom-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}/auth/token",
|
f"http://127.0.0.1:{port}/custom-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
|
||||||
@@ -1037,6 +1038,63 @@ 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)
|
||||||
@@ -3746,6 +3804,7 @@ 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,6 +31,11 @@ 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"
|
||||||
@@ -75,6 +80,7 @@ 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()
|
||||||
@@ -94,6 +100,7 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -111,6 +118,7 @@ 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] = {
|
||||||
@@ -134,6 +142,7 @@ 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)
|
||||||
|
|
||||||
@@ -223,6 +232,7 @@ 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"
|
||||||
@@ -278,6 +288,53 @@ 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
|
||||||
@@ -2054,14 +2111,15 @@ async def test_mcp_presets_routes_require_token_and_return_payload(
|
|||||||
_custom_action,
|
_custom_action,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _hot_reload(_bus):
|
async def _hot_reload():
|
||||||
return {"ok": True, "message": "MCP config reloaded.", "requires_restart": False}
|
return {"ok": True, "message": "MCP config reloaded.", "requires_restart": False}
|
||||||
|
|
||||||
monkeypatch.setattr(
|
channel = _ch(
|
||||||
"nanobot.webui.settings_routes.request_mcp_reload",
|
bus,
|
||||||
_hot_reload,
|
session_manager=_seed_session(tmp_path),
|
||||||
|
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")
|
||||||
@@ -2261,6 +2319,40 @@ 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,6 +486,35 @@ 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,6 +66,63 @@ 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,28 +33,10 @@ import {
|
|||||||
WEIXIN_AUTH_EXPIRED_MESSAGE,
|
WEIXIN_AUTH_EXPIRED_MESSAGE,
|
||||||
WeixinConnectFlow,
|
WeixinConnectFlow,
|
||||||
} from "./WeixinConnectFlow";
|
} from "./WeixinConnectFlow";
|
||||||
|
import {
|
||||||
export const WEIXIN_PRIMARY_FIELD_KEYS = [
|
WEIXIN_ADVANCED_FIELD_KEYS,
|
||||||
"channels.weixin.sendProgress",
|
WEIXIN_PRIMARY_FIELD_KEYS,
|
||||||
"channels.weixin.sendToolHints",
|
} from "./presentation";
|
||||||
"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,12 +1,21 @@
|
|||||||
|
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,
|
||||||
WeixinPanel,
|
} from "./presentation";
|
||||||
} 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,
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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;
|
||||||
+17
-2
@@ -13,6 +13,8 @@ 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,
|
||||||
@@ -84,6 +86,8 @@ 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)
|
||||||
|
|
||||||
@@ -95,6 +99,7 @@ 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)
|
||||||
@@ -106,6 +111,12 @@ 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
|
||||||
|
|
||||||
@@ -149,6 +160,8 @@ 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:
|
||||||
|
await mcp_provider.connect()
|
||||||
renderer = StreamRenderer(
|
renderer = StreamRenderer(
|
||||||
render_markdown=markdown,
|
render_markdown=markdown,
|
||||||
bot_name=runtime_config.agents.defaults.bot_name,
|
bot_name=runtime_config.agents.defaults.bot_name,
|
||||||
@@ -172,7 +185,8 @@ def agent(
|
|||||||
metadata=response.metadata if response else None,
|
metadata=response.metadata if response else None,
|
||||||
**print_kwargs,
|
**print_kwargs,
|
||||||
)
|
)
|
||||||
await agent_loop.close_mcp()
|
finally:
|
||||||
|
await _close_runtime()
|
||||||
|
|
||||||
asyncio.run(run_once())
|
asyncio.run(run_once())
|
||||||
else:
|
else:
|
||||||
@@ -209,6 +223,7 @@ 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()
|
||||||
@@ -347,6 +362,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 agent_loop.close_mcp()
|
await _close_runtime()
|
||||||
|
|
||||||
asyncio.run(run_interactive())
|
asyncio.run(run_interactive())
|
||||||
|
|||||||
+49
-2
@@ -49,6 +49,8 @@ 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
|
||||||
@@ -351,12 +353,15 @@ 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]")
|
||||||
@@ -378,13 +383,17 @@ 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 agent_loop._connect_mcp()
|
await mcp_provider.connect()
|
||||||
|
|
||||||
async def on_cleanup(_app: Any) -> None:
|
async def on_cleanup(_app: Any) -> None:
|
||||||
await agent_loop.close_mcp()
|
try:
|
||||||
|
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)
|
||||||
@@ -431,6 +440,44 @@ 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,6 +14,8 @@ 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 (
|
||||||
@@ -233,6 +235,7 @@ 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,
|
||||||
@@ -240,18 +243,13 @@ 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 agent resources.
|
"""Cancel runtime tasks, then deterministically close application 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 exec sessions,
|
turn) are cancelled and awaited -- bounded -- before the loop-owned resources
|
||||||
subagents, and MCP servers are torn down, so no active turn is using a
|
and the application-owned MCP provider are torn down. The final close is
|
||||||
shared resource when it closes. The final close is bounded and idempotent:
|
bounded and idempotent, so it also covers a cancelled or incomplete loop
|
||||||
the agent loop's own finally also calls ``close_mcp()``, so this runs again
|
cleanup without leaving subprocess transports alive past ``loop.close()``.
|
||||||
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.
|
||||||
@@ -272,10 +270,14 @@ 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 (
|
||||||
|
("agent", agent.aclose),
|
||||||
|
("MCP provider", mcp_provider.aclose),
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(agent.close_mcp(), timeout=close_timeout)
|
await asyncio.wait_for(close(), timeout=close_timeout)
|
||||||
except BaseException as exc: # noqa: BLE001 - shutdown must proceed
|
except BaseException as exc: # noqa: BLE001 - shutdown must proceed
|
||||||
logger.warning("Gateway shutdown: agent resource cleanup incomplete: {}", exc)
|
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():
|
||||||
@@ -414,6 +416,9 @@ 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,
|
||||||
@@ -431,6 +436,7 @@ 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))
|
||||||
@@ -512,6 +518,7 @@ 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,
|
||||||
@@ -589,6 +596,7 @@ 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",
|
||||||
@@ -668,7 +676,8 @@ 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=agent.mcp_runtime_status,
|
webui_mcp_runtime_status=mcp_provider.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),
|
||||||
)
|
)
|
||||||
@@ -844,6 +853,13 @@ 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(
|
||||||
@@ -852,7 +868,7 @@ def _run_gateway(
|
|||||||
),
|
),
|
||||||
name="nanobot-config-watcher",
|
name="nanobot-config-watcher",
|
||||||
),
|
),
|
||||||
asyncio.create_task(agent.run(), name="nanobot-agent-loop"),
|
asyncio.create_task(_run_agent(), 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(
|
||||||
@@ -910,7 +926,13 @@ 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(agent, channels, tasks, runtime_tasks)
|
await _close_gateway_runtime(
|
||||||
|
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,6 +75,7 @@ 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
|
||||||
|
|
||||||
@@ -130,6 +131,7 @@ 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, field_validator, model_validator
|
from pydantic import AliasChoices, ConfigDict, Field, PrivateAttr, 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,6 +431,8 @@ 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)
|
||||||
@@ -449,6 +451,15 @@ 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:
|
||||||
|
|||||||
+24
-4
@@ -10,6 +10,8 @@ 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
|
||||||
@@ -71,9 +73,16 @@ class Nanobot:
|
|||||||
print(result.content)
|
print(result.content)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, loop: AgentLoop, *, config: Config | None = None) -> None:
|
def __init__(
|
||||||
|
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)
|
||||||
@@ -120,12 +129,15 @@ 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)
|
return cls(loop, config=config, mcp_provider=mcp_provider)
|
||||||
|
|
||||||
async def run(
|
async def run(
|
||||||
self,
|
self,
|
||||||
@@ -178,6 +190,8 @@ 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,
|
||||||
@@ -259,6 +273,8 @@ 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,
|
||||||
@@ -327,8 +343,12 @@ class Nanobot:
|
|||||||
await run.aclose()
|
await run.aclose()
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
"""Release resources held by this instance (MCP connections, etc.)."""
|
"""Release resources held by this instance."""
|
||||||
await self._loop.close_mcp()
|
try:
|
||||||
|
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,6 +56,8 @@ 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 (
|
||||||
@@ -690,6 +692,8 @@ 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):
|
||||||
@@ -767,6 +771,81 @@ 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
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -1158,7 +1237,8 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
self._sanitize_empty_content(sanitized_state.pending_messages)
|
self._sanitize_empty_content(sanitized_state.pending_messages)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
preserve_reasoning = bool(self._spec and self._spec.name == "deepseek")
|
is_deepseek = 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,
|
||||||
@@ -1194,7 +1274,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":
|
if reasoning_effort and (reasoning_effort.lower() != "none" or is_deepseek):
|
||||||
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,8 +112,7 @@ 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 (DeepSeek V4
|
# because providers may add Responses support incrementally.
|
||||||
# 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
|
||||||
@@ -482,7 +481,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",),
|
responses_models=("deepseek-v4-flash", "deepseek-v4-pro"),
|
||||||
responses_default_tools=("web_search",),
|
responses_default_tools=("web_search",),
|
||||||
),
|
),
|
||||||
# Gemini: Google's OpenAI-compatible endpoint
|
# Gemini: Google's OpenAI-compatible endpoint
|
||||||
|
|||||||
+477
-5
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
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
|
||||||
@@ -14,9 +17,10 @@ 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
|
from nanobot.config.paths import get_legacy_sessions_dir, get_runtime_subdir
|
||||||
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,
|
||||||
@@ -57,6 +61,11 @@ _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]:
|
||||||
@@ -503,6 +512,23 @@ 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: ...
|
||||||
|
|
||||||
@@ -520,9 +546,445 @@ class SessionStore(Protocol):
|
|||||||
class JsonlSessionStore:
|
class JsonlSessionStore:
|
||||||
"""JSONL implementation of session persistence."""
|
"""JSONL implementation of session persistence."""
|
||||||
|
|
||||||
def __init__(self, workspace: Path):
|
def __init__(self, workspace: Path, *, sessions_root: Path | None = None):
|
||||||
self.sessions_dir = ensure_dir(workspace / "sessions")
|
canonical_workspace = Path(workspace).expanduser().resolve(strict=False)
|
||||||
|
ensure_dir(canonical_workspace)
|
||||||
|
root = (
|
||||||
|
Path(sessions_root).expanduser().resolve(strict=False)
|
||||||
|
if sessions_root is not None
|
||||||
|
else get_runtime_subdir("sessions").resolve(strict=False)
|
||||||
|
)
|
||||||
|
if root == canonical_workspace or root.is_relative_to(canonical_workspace):
|
||||||
|
raise RuntimeError(
|
||||||
|
"session storage must be outside the agent workspace; "
|
||||||
|
"move --config outside --workspace or choose a nested workspace directory"
|
||||||
|
)
|
||||||
|
ensure_dir(root)
|
||||||
|
with suppress(OSError):
|
||||||
|
os.chmod(root, 0o700)
|
||||||
|
self.workspace = canonical_workspace
|
||||||
|
self._migration_lock = FileLock(
|
||||||
|
str(root / ".workspace-migration.lock"),
|
||||||
|
timeout=_SESSION_MIGRATION_LOCK_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
with self._migration_lock:
|
||||||
|
workspace_id = self._load_or_create_workspace_id(canonical_workspace, root)
|
||||||
|
workspace_id = self._claim_workspace_namespace(
|
||||||
|
root,
|
||||||
|
canonical_workspace,
|
||||||
|
workspace_id,
|
||||||
|
)
|
||||||
|
self.sessions_dir = ensure_dir(root / workspace_id)
|
||||||
self.legacy_sessions_dir = get_legacy_sessions_dir()
|
self.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:
|
||||||
@@ -991,9 +1453,15 @@ class JsonlSessionStore:
|
|||||||
class SessionManager:
|
class SessionManager:
|
||||||
"""Manage session identity, caching, retention, and persistence."""
|
"""Manage session identity, caching, retention, and persistence."""
|
||||||
|
|
||||||
def __init__(self, workspace: Path, *, store: SessionStore | None = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
workspace: Path,
|
||||||
|
*,
|
||||||
|
store: SessionStore | None = None,
|
||||||
|
sessions_root: Path | None = None,
|
||||||
|
):
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
self._jsonl_store = JsonlSessionStore(workspace)
|
self._jsonl_store = JsonlSessionStore(workspace, sessions_root=sessions_root)
|
||||||
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
|
||||||
@@ -1159,6 +1627,10 @@ 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 Mapping
|
from collections.abc import Awaitable, 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,6 +66,7 @@ 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:
|
||||||
@@ -119,6 +120,7 @@ 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,6 +100,7 @@ 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 = [
|
||||||
@@ -112,6 +113,8 @@ 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,7 +56,6 @@ _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"}
|
||||||
@@ -1097,7 +1096,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 tool surface."""
|
"""Connect to an enabled MCP preset and report its complete 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()
|
||||||
@@ -1157,9 +1156,10 @@ 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: cfg}, registry),
|
connect_mcp_servers({name: inspection_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[:_MAX_TEST_TOOLS],
|
"tool_names": tool_names,
|
||||||
"checked_at": _checked_at(),
|
"checked_at": _checked_at(),
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
"""Cache-only WebUI session list index.
|
"""Cache-only WebUI session list index.
|
||||||
|
|
||||||
The core ``SessionManager`` owns durable conversation history. This module owns
|
The core ``SessionManager`` owns model context while the WebUI transcript owns
|
||||||
the WebUI sidebar optimization so core session writes stay independent from UI
|
durable display history. The sidebar discovers both without reconstructing one
|
||||||
presentation caches.
|
store from the other, so core session writes stay independent from UI state.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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
|
||||||
@@ -30,9 +31,12 @@ 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 = 6
|
_INDEX_VERSION = 7
|
||||||
_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(
|
||||||
@@ -42,7 +46,12 @@ _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]]:
|
||||||
@@ -53,41 +62,79 @@ 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 = [_public_row(session_manager.sessions_dir, row) for row in rows]
|
sessions = [
|
||||||
|
_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_file = {
|
existing_by_source = {
|
||||||
row.get("file"): row
|
(row.get(_ROW_SOURCE_FIELD), row.get("file")): row
|
||||||
for row in existing_rows or []
|
for row in existing_rows or []
|
||||||
if isinstance(row.get("file"), str)
|
if isinstance(row.get(_ROW_SOURCE_FIELD), str)
|
||||||
|
and isinstance(row.get("file"), str)
|
||||||
}
|
}
|
||||||
paths = sorted(
|
|
||||||
path
|
|
||||||
for path in session_manager.sessions_dir.glob("*.jsonl")
|
|
||||||
if SessionManager._session_key_from_path(path) is not None # pyright: ignore[reportPrivateUsage]
|
|
||||||
)
|
|
||||||
if not paths:
|
|
||||||
return [], existing_rows != []
|
|
||||||
|
|
||||||
webui_dir = get_webui_dir()
|
webui_dir = get_webui_dir()
|
||||||
|
session_paths: dict[str, Path] = {}
|
||||||
|
for path in sorted(session_manager.sessions_dir.glob("*.jsonl")):
|
||||||
|
key = SessionManager._session_key_from_path(path) # pyright: ignore[reportPrivateUsage]
|
||||||
|
if key is not None:
|
||||||
|
session_paths[key] = path
|
||||||
|
|
||||||
|
session_keys_by_stem = {
|
||||||
|
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 path in paths:
|
for key, path in sorted(session_paths.items()):
|
||||||
row = existing_by_file.get(path.name)
|
identity = (_SESSION_SOURCE, 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)
|
||||||
|
|
||||||
if set(existing_by_file) != {path.name for path in paths}:
|
for stem, paths in _webui_transcript_sources(webui_dir).items():
|
||||||
|
if stem in session_keys_by_stem:
|
||||||
|
continue
|
||||||
|
identity = (_TRANSCRIPT_SOURCE, stem)
|
||||||
|
row = existing_by_source.get(identity)
|
||||||
|
cached_key = row.get("key") if row is not None else None
|
||||||
|
key = (
|
||||||
|
cached_key
|
||||||
|
if isinstance(cached_key, str) and _valid_transcript_session_key(cached_key, stem)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if key is not None and row is not None and _indexed_transcript_row_matches(
|
||||||
|
row,
|
||||||
|
key,
|
||||||
|
webui_dir,
|
||||||
|
):
|
||||||
|
rows.append(row)
|
||||||
|
expected_sources.add(identity)
|
||||||
|
continue
|
||||||
|
|
||||||
|
changed = True
|
||||||
|
scanned = _scan_transcript_row(key, stem, paths, webui_dir)
|
||||||
|
scanned_key = scanned.get("key") if scanned is not None else None
|
||||||
|
if scanned is not None and scanned_key not in session_paths:
|
||||||
|
rows.append(scanned)
|
||||||
|
expected_sources.add(identity)
|
||||||
|
|
||||||
|
if set(existing_by_source) != expected_sources:
|
||||||
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
|
||||||
@@ -144,7 +191,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("file") != path.name:
|
if row.get(_ROW_SOURCE_FIELD) != _SESSION_SOURCE or row.get("file") != path.name:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
signature = _file_signature(path)
|
signature = _file_signature(path)
|
||||||
@@ -156,10 +203,39 @@ 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 _public_row(sessions_dir: Path, row: dict[str, Any]) -> dict[str, Any]:
|
def _indexed_transcript_row_matches(
|
||||||
|
row: dict[str, Any],
|
||||||
|
session_key: str,
|
||||||
|
webui_dir: Path,
|
||||||
|
) -> bool:
|
||||||
|
if not all(isinstance(row.get(key), str) for key in ("key", "created_at", "updated_at")):
|
||||||
|
return False
|
||||||
|
if row.get(_ROW_SOURCE_FIELD) != _TRANSCRIPT_SOURCE:
|
||||||
|
return False
|
||||||
|
if row.get("key") != session_key or row.get("file") != SessionManager.safe_key(session_key):
|
||||||
|
return False
|
||||||
|
if not isinstance(row.get("title", ""), str) or not isinstance(row.get("preview", ""), str):
|
||||||
|
return False
|
||||||
|
if not isinstance(row.get(_WORKSPACE_SCOPE_PRESENT_FIELD), bool):
|
||||||
|
return False
|
||||||
|
signature = _webui_activity_signature(session_key, webui_dir)
|
||||||
|
return (
|
||||||
|
row.get(_WEBUI_ACTIVITY_MTIME_NS) == signature[_WEBUI_ACTIVITY_MTIME_NS]
|
||||||
|
and row.get(_WEBUI_ACTIVITY_SIZE) == signature[_WEBUI_ACTIVITY_SIZE]
|
||||||
|
and row.get(_WEBUI_ACTIVITY_FILES) == signature[_WEBUI_ACTIVITY_FILES]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _public_row(sessions_dir: Path, webui_dir: Path, row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
file = str(row.get("file", ""))
|
||||||
|
if row.get(_ROW_SOURCE_FIELD) == _TRANSCRIPT_SOURCE:
|
||||||
|
path = webui_dir / f"{file}.jsonl"
|
||||||
|
else:
|
||||||
|
path = sessions_dir / file
|
||||||
return {
|
return {
|
||||||
"key": row.get("key"),
|
"key": row.get("key"),
|
||||||
"created_at": row.get("created_at"),
|
"created_at": row.get("created_at"),
|
||||||
@@ -169,7 +245,7 @@ def _public_row(sessions_dir: Path, row: dict[str, Any]) -> dict[str, Any]:
|
|||||||
_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(sessions_dir / str(row.get("file", ""))),
|
"path": str(path),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -242,17 +318,90 @@ 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)
|
||||||
return [
|
paths = [
|
||||||
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()
|
||||||
@@ -260,11 +409,13 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -333,6 +484,7 @@ 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"],
|
||||||
@@ -340,6 +492,122 @@ 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,
|
||||||
@@ -418,6 +686,7 @@ 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,14 +5,13 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Awaitable, 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.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
@@ -71,6 +70,7 @@ _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
|
||||||
|
|
||||||
|
|
||||||
@@ -227,6 +227,7 @@ 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
|
||||||
@@ -241,6 +242,7 @@ 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()
|
||||||
@@ -472,7 +474,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=lambda: request_mcp_reload(self.bus),
|
reload_mcp=self._reload_mcp_runtime,
|
||||||
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,
|
||||||
@@ -499,6 +501,33 @@ 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)
|
||||||
|
|
||||||
@@ -622,7 +651,7 @@ class WebUISettingsRouter:
|
|||||||
name,
|
name,
|
||||||
cfg,
|
cfg,
|
||||||
redirect_uri,
|
redirect_uri,
|
||||||
reload_mcp=lambda: request_mcp_reload(self.bus),
|
reload_mcp=self._reload_mcp_runtime,
|
||||||
reset_credentials=reset,
|
reset_credentials=reset,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ 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
|
||||||
@@ -24,8 +26,11 @@ _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:
|
||||||
@@ -42,6 +47,7 @@ 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,
|
||||||
@@ -76,6 +82,20 @@ 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 {}
|
||||||
@@ -131,8 +151,56 @@ 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 schema-v1 sidebar state from any older/partial input."""
|
"""Return a validated canonical sidebar state."""
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
raw = {}
|
raw = {}
|
||||||
raw = cast(dict[str, Any], raw)
|
raw = cast(dict[str, Any], raw)
|
||||||
@@ -146,6 +214,7 @@ 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
|
||||||
@@ -169,6 +238,11 @@ 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 Callable, Mapping
|
from collections.abc import Awaitable, 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,6 +121,7 @@ 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",
|
||||||
@@ -308,6 +309,7 @@ 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:
|
||||||
@@ -351,6 +353,7 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -545,9 +548,16 @@ 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({"error": "too many outstanding tokens"}, status=429)
|
return _http_json_response(
|
||||||
|
{"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(token_response_payload(token_value, self.config.token_ttl_s))
|
return _http_json_response(
|
||||||
|
token_response_payload(token_value, self.config.token_ttl_s),
|
||||||
|
extra_headers=_NO_STORE_HEADERS,
|
||||||
|
)
|
||||||
|
|
||||||
# -- Bootstrap ----------------------------------------------------------
|
# -- Bootstrap ----------------------------------------------------------
|
||||||
|
|
||||||
@@ -577,7 +587,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)
|
return _http_json_response(payload, extra_headers=_NO_STORE_HEADERS)
|
||||||
|
|
||||||
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):
|
||||||
@@ -585,6 +595,7 @@ 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 = (
|
||||||
@@ -609,7 +620,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)
|
return _http_json_response(payload, extra_headers=_NO_STORE_HEADERS)
|
||||||
|
|
||||||
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 {}
|
||||||
@@ -844,9 +855,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)
|
||||||
deleted = self.session_manager.delete_session(decoded_key)
|
session_deleted = self.session_manager.delete_session(decoded_key)
|
||||||
delete_webui_thread(decoded_key)
|
transcript_deleted = delete_webui_thread(decoded_key)
|
||||||
return _http_json_response({"deleted": bool(deleted)})
|
return _http_json_response({"deleted": bool(session_deleted or transcript_deleted)})
|
||||||
|
|
||||||
# -- Automation routes --------------------------------------------------
|
# -- Automation routes --------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ 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,
|
||||||
@@ -72,8 +71,6 @@ 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,6 +8,7 @@ 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
|
||||||
@@ -193,7 +194,11 @@ 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(config, provider=provider)
|
loop = AgentLoop.from_config(
|
||||||
|
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()
|
||||||
@@ -310,7 +315,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -320,7 +325,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -343,7 +348,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -367,7 +372,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -398,7 +403,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -422,7 +427,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -436,7 +441,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -455,7 +460,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
|
|
||||||
class TestAutoCompactIdleDetection:
|
class TestAutoCompactIdleDetection:
|
||||||
@@ -474,7 +479,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -503,7 +508,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -517,7 +522,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -540,7 +545,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -562,7 +567,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -581,7 +586,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -597,7 +602,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
|
|
||||||
class TestAutoCompactSystemMessages:
|
class TestAutoCompactSystemMessages:
|
||||||
@@ -628,7 +633,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
|
|
||||||
class TestAutoCompactEdgeCases:
|
class TestAutoCompactEdgeCases:
|
||||||
@@ -656,7 +661,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -677,7 +682,7 @@ class TestAutoCompactEdgeCases:
|
|||||||
loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
||||||
)
|
)
|
||||||
|
|
||||||
await loop.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -709,7 +714,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
|
|
||||||
class TestAutoCompactIntegration:
|
class TestAutoCompactIntegration:
|
||||||
@@ -779,7 +784,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -807,7 +812,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
|
|
||||||
class TestProactiveAutoCompact:
|
class TestProactiveAutoCompact:
|
||||||
@@ -870,7 +875,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -897,7 +902,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -918,7 +923,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -932,7 +937,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_no_duplicate_archive(self, tmp_path):
|
async def test_no_duplicate_archive(self, tmp_path):
|
||||||
@@ -968,7 +973,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -989,7 +994,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -1005,7 +1010,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -1026,7 +1031,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -1047,7 +1052,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -1083,7 +1088,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -1104,7 +1109,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -1124,7 +1129,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -1155,7 +1160,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
|
|
||||||
class TestSummaryPersistence:
|
class TestSummaryPersistence:
|
||||||
@@ -1182,7 +1187,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -1218,7 +1223,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -1246,7 +1251,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -1272,7 +1277,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -1314,7 +1319,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
@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):
|
||||||
@@ -1342,4 +1347,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.close_mcp()
|
await loop.aclose()
|
||||||
|
|||||||
@@ -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.close_mcp()
|
await loop.aclose()
|
||||||
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.close_mcp()
|
await loop.aclose()
|
||||||
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_close_mcp_drains_background_tasks(self, tmp_path: Path) -> None:
|
async def test_aclose_drains_background_tasks(self, tmp_path: Path) -> None:
|
||||||
"""close_mcp waits for background tasks to complete."""
|
"""aclose 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.close_mcp()
|
await loop.aclose()
|
||||||
assert archived.is_set()
|
assert archived.is_set()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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"}}
|
||||||
|
|
||||||
@@ -243,3 +244,251 @@ 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,7 +93,6 @@ 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,13 +1519,11 @@ 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()
|
||||||
|
|
||||||
|
|
||||||
@@ -1534,7 +1532,6 @@ 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,8 +123,7 @@ 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, "_connect_mcp", AsyncMock())
|
monkeypatch.setattr(loop, "aclose", 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,4 +1,5 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -11,8 +12,10 @@ 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
|
||||||
|
|
||||||
@@ -56,6 +59,51 @@ 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 MCP connection lifecycle in AgentLoop."""
|
"""Tests for the application-owned MCP provider lifecycle."""
|
||||||
|
|
||||||
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, MagicMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
import pytest
|
import pytest
|
||||||
@@ -15,11 +15,10 @@ 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 MCPResourceWrapper, MCPToolWrapper
|
from nanobot.agent.tools.mcp import MCPProvider, MCPResourceWrapper, MCPToolWrapper
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
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
|
||||||
|
|
||||||
@@ -74,18 +73,20 @@ class _FakeMcpTool(Tool):
|
|||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
def _make_loop(tmp_path, *, mcp_servers: dict | None = None) -> AgentLoop:
|
def _stdio_server(command: str = "test-mcp") -> MCPServerConfig:
|
||||||
bus = MessageBus()
|
return MCPServerConfig(type="stdio", command=command)
|
||||||
provider = MagicMock()
|
|
||||||
provider.get_default_model.return_value = "test-model"
|
|
||||||
provider.generation.max_tokens = 4096
|
def _make_provider(
|
||||||
return AgentLoop(
|
*,
|
||||||
bus=bus,
|
mcp_servers: dict[str, MCPServerConfig] | None = None,
|
||||||
provider=provider,
|
) -> tuple[MCPProvider, ToolRegistry]:
|
||||||
workspace=tmp_path,
|
registry = ToolRegistry()
|
||||||
model="test-model",
|
provider = MCPProvider(
|
||||||
mcp_servers=mcp_servers or {"test": object()},
|
mcp_servers if mcp_servers is not None else {"test": _stdio_server()},
|
||||||
|
registry,
|
||||||
)
|
)
|
||||||
|
return provider, registry
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -140,7 +141,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):
|
||||||
loop = _make_loop(tmp_path)
|
provider, _registry = _make_provider()
|
||||||
attempts = 0
|
attempts = 0
|
||||||
|
|
||||||
async def _fake_connect(_servers, _registry):
|
async def _fake_connect(_servers, _registry):
|
||||||
@@ -150,12 +151,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 loop._connect_mcp()
|
await provider.connect()
|
||||||
await loop._connect_mcp()
|
await provider.connect()
|
||||||
|
|
||||||
assert attempts == 2
|
assert attempts == 2
|
||||||
assert loop._mcp_stacks == {}
|
assert provider.connected_server_names == set()
|
||||||
assert loop.mcp_runtime_status() == {"test": "failed"}
|
assert provider.runtime_status() == {"test": "failed"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -168,7 +169,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",
|
||||||
)
|
)
|
||||||
loop = _make_loop(tmp_path, mcp_servers={"oauth-app": cfg})
|
provider, _registry = _make_provider(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(
|
||||||
@@ -176,19 +177,20 @@ async def test_connect_mcp_does_not_report_failure_before_oauth_authorization(
|
|||||||
lambda _name, _url: False,
|
lambda _name, _url: False,
|
||||||
)
|
)
|
||||||
|
|
||||||
await loop._connect_mcp()
|
await provider.connect()
|
||||||
|
|
||||||
connect.assert_not_awaited()
|
connect.assert_not_awaited()
|
||||||
assert loop.mcp_runtime_status() == {}
|
assert provider.runtime_status() == {}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_agent_loop_run_closes_mcp_from_connection_owner_task(
|
async def test_mcp_provider_closes_connections_independently_from_agent_loop(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
):
|
):
|
||||||
loop = _make_loop(tmp_path, mcp_servers={"playwright": object()})
|
provider, registry = _make_provider(
|
||||||
connected = asyncio.Event()
|
mcp_servers={"playwright": _stdio_server("playwright")}
|
||||||
|
)
|
||||||
owner_tasks: list[asyncio.Task | None] = []
|
owner_tasks: list[asyncio.Task | None] = []
|
||||||
closed_tasks: list[asyncio.Task | None] = []
|
closed_tasks: list[asyncio.Task | None] = []
|
||||||
|
|
||||||
@@ -203,40 +205,38 @@ async def test_agent_loop_run_closes_mcp_from_connection_owner_task(
|
|||||||
|
|
||||||
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)
|
||||||
|
|
||||||
task = asyncio.create_task(loop.run())
|
await provider.connect()
|
||||||
await asyncio.wait_for(connected.wait(), timeout=1)
|
registry.register(_FakeMcpTool("mcp_playwright_search"))
|
||||||
loop.stop()
|
await provider.aclose()
|
||||||
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 loop._mcp_stacks == {}
|
assert provider.connected_server_names == set()
|
||||||
|
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):
|
||||||
loop = _make_loop(tmp_path)
|
provider, _registry = _make_provider()
|
||||||
|
|
||||||
class _ServerCancelledStack:
|
class _ServerCancelledStack:
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
raise asyncio.CancelledError()
|
raise asyncio.CancelledError()
|
||||||
|
|
||||||
loop._mcp_stacks = {"test": _ServerCancelledStack()}
|
provider._connections = {"test": _ServerCancelledStack()}
|
||||||
|
|
||||||
await mcp_runtime._close_server(loop, "test")
|
await provider._close_server("test")
|
||||||
|
|
||||||
assert loop._mcp_stacks == {}
|
assert provider.connected_server_names == set()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_close_mcp_servers_continues_after_server_cancelled_error(tmp_path):
|
async def test_provider_close_continues_after_server_cancelled_error(tmp_path):
|
||||||
loop = _make_loop(tmp_path)
|
provider, _registry = _make_provider()
|
||||||
closed: list[str] = []
|
closed: list[str] = []
|
||||||
|
|
||||||
class _ServerCancelledStack:
|
class _ServerCancelledStack:
|
||||||
@@ -247,21 +247,53 @@ async def test_close_mcp_servers_continues_after_server_cancelled_error(tmp_path
|
|||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
closed.append("second")
|
closed.append("second")
|
||||||
|
|
||||||
loop._mcp_stacks = {
|
provider._connections = {
|
||||||
"first": _ServerCancelledStack(),
|
"first": _ServerCancelledStack(),
|
||||||
"second": _TrackedStack(),
|
"second": _TrackedStack(),
|
||||||
}
|
}
|
||||||
|
|
||||||
await mcp_runtime.close_mcp_servers(loop)
|
await provider.aclose()
|
||||||
|
|
||||||
assert closed == ["second"]
|
assert closed == ["second"]
|
||||||
assert loop._mcp_stacks == {}
|
assert provider.connected_server_names == set()
|
||||||
|
|
||||||
|
|
||||||
|
@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):
|
||||||
loop = _make_loop(tmp_path)
|
provider, _registry = _make_provider()
|
||||||
started = asyncio.Event()
|
started = asyncio.Event()
|
||||||
|
|
||||||
class _BlockingStack:
|
class _BlockingStack:
|
||||||
@@ -269,12 +301,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()
|
||||||
|
|
||||||
loop._mcp_stacks = {"test": _BlockingStack()}
|
provider._connections = {"test": _BlockingStack()}
|
||||||
|
|
||||||
if close_all:
|
if close_all:
|
||||||
task = asyncio.create_task(mcp_runtime.close_mcp_servers(loop))
|
task = asyncio.create_task(provider.aclose())
|
||||||
else:
|
else:
|
||||||
task = asyncio.create_task(mcp_runtime._close_server(loop, "test"))
|
task = asyncio.create_task(provider._close_server("test"))
|
||||||
await asyncio.wait_for(started.wait(), timeout=1)
|
await asyncio.wait_for(started.wait(), timeout=1)
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
|
||||||
@@ -312,41 +344,38 @@ 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)
|
||||||
loop = _make_loop(tmp_path, mcp_servers={})
|
provider, registry = _make_provider(mcp_servers={})
|
||||||
|
|
||||||
added = await mcp_runtime.reload_servers(loop, loop.tools)
|
added = await provider.reload()
|
||||||
|
|
||||||
assert added["ok"] is True
|
assert added["ok"] is True
|
||||||
assert added["added"] == ["browserbase"]
|
assert added["added"] == ["browserbase"]
|
||||||
assert loop.tools.has("mcp_browserbase_navigate")
|
assert registry.has("mcp_browserbase_navigate")
|
||||||
assert "browserbase" in loop._mcp_stacks
|
assert provider.connected_server_names == {"browserbase"}
|
||||||
|
|
||||||
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 mcp_runtime.reload_servers(loop, loop.tools)
|
removed = await provider.reload()
|
||||||
|
|
||||||
assert removed["ok"] is True
|
assert removed["ok"] is True
|
||||||
assert removed["removed"] == ["browserbase"]
|
assert removed["removed"] == ["browserbase"]
|
||||||
assert not loop.tools.has("mcp_browserbase_navigate")
|
assert not registry.has("mcp_browserbase_navigate")
|
||||||
assert "browserbase" not in loop._mcp_stacks
|
assert provider.connected_server_names == set()
|
||||||
assert closed == ["browserbase"]
|
assert closed == ["browserbase"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_request_mcp_reload_reaches_runtime_control_without_restart(
|
async def test_reload_is_a_direct_provider_operation_without_an_agent_loop(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
):
|
):
|
||||||
config_path = tmp_path / "config.json"
|
browserbase = MCPServerConfig(
|
||||||
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",
|
||||||
)
|
)
|
||||||
save_config(config)
|
configured: dict[str, MCPServerConfig] = {"browserbase": browserbase}
|
||||||
|
|
||||||
closed: list[str] = []
|
closed: list[str] = []
|
||||||
|
|
||||||
@@ -364,37 +393,68 @@ async def test_request_mcp_reload_reaches_runtime_control_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)
|
||||||
loop = _make_loop(tmp_path, mcp_servers={})
|
registry = ToolRegistry()
|
||||||
|
provider = MCPProvider({}, registry, server_loader=lambda: configured)
|
||||||
|
|
||||||
async def _handle_one_runtime_control() -> None:
|
result = await provider.reload()
|
||||||
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 loop.tools.has("mcp_browserbase_navigate")
|
assert registry.has("mcp_browserbase_navigate")
|
||||||
|
|
||||||
config = load_config()
|
configured = {}
|
||||||
del config.tools.mcp_servers["browserbase"]
|
|
||||||
save_config(config)
|
|
||||||
|
|
||||||
consumer = asyncio.create_task(_handle_one_runtime_control())
|
result = await provider.reload()
|
||||||
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 loop.tools.has("mcp_browserbase_navigate")
|
assert not registry.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,
|
||||||
@@ -419,16 +479,18 @@ 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)
|
||||||
loop = _make_loop(tmp_path, mcp_servers={"browserbase": config.tools.mcp_servers["browserbase"]})
|
provider, registry = _make_provider(
|
||||||
|
mcp_servers={"browserbase": config.tools.mcp_servers["browserbase"]}
|
||||||
|
)
|
||||||
|
|
||||||
result = await mcp_runtime.reload_servers(loop, loop.tools)
|
result = await provider.reload()
|
||||||
|
|
||||||
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 loop.tools.has("mcp_browserbase_navigate")
|
assert registry.has("mcp_browserbase_navigate")
|
||||||
await loop.close_mcp()
|
await provider.aclose()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -465,16 +527,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",
|
||||||
)
|
)
|
||||||
loop = _make_loop(tmp_path, mcp_servers={"notion": notion})
|
provider, _registry = _make_provider(mcp_servers={"notion": notion})
|
||||||
|
|
||||||
result = await mcp_runtime.reload_servers(loop, loop.tools)
|
result = await provider.reload()
|
||||||
|
|
||||||
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 loop.close_mcp()
|
await provider.aclose()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -482,7 +544,9 @@ async def test_mcp_tool_reconnects_after_session_terminated(
|
|||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
):
|
):
|
||||||
loop = _make_loop(tmp_path, mcp_servers={"remote": object()})
|
provider, registry = _make_provider(
|
||||||
|
mcp_servers={"remote": _stdio_server("remote")}
|
||||||
|
)
|
||||||
closed: list[str] = []
|
closed: list[str] = []
|
||||||
sessions: list[Any] = []
|
sessions: list[Any] = []
|
||||||
connect_count = 0
|
connect_count = 0
|
||||||
@@ -525,8 +589,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 loop._connect_mcp()
|
await provider.connect()
|
||||||
old_tool = loop.tools.get("mcp_remote_quote")
|
old_tool = registry.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")
|
||||||
@@ -536,8 +600,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 "remote" in loop._mcp_stacks
|
assert provider.connected_server_names == {"remote"}
|
||||||
assert loop.tools.get("mcp_remote_quote") is not old_tool
|
assert registry.get("mcp_remote_quote") is not old_tool
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -545,7 +609,9 @@ async def test_mcp_reconnect_handler_uses_sanitized_server_prefix(
|
|||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
):
|
):
|
||||||
loop = _make_loop(tmp_path, mcp_servers={"remote_": object()})
|
provider, registry = _make_provider(
|
||||||
|
mcp_servers={"remote_": _stdio_server("remote")}
|
||||||
|
)
|
||||||
connect_count = 0
|
connect_count = 0
|
||||||
|
|
||||||
class _FakeSession:
|
class _FakeSession:
|
||||||
@@ -578,15 +644,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 loop._connect_mcp()
|
await provider.connect()
|
||||||
old_tool = loop.tools.get("mcp_remote_quote")
|
old_tool = registry.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 loop.tools.get("mcp_remote_quote") is not old_tool
|
assert registry.get("mcp_remote_quote") is not old_tool
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -594,7 +660,9 @@ async def test_concurrent_mcp_reconnect_reuses_fresh_session(
|
|||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
):
|
):
|
||||||
loop = _make_loop(tmp_path, mcp_servers={"remote": object()})
|
provider, registry = _make_provider(
|
||||||
|
mcp_servers={"remote": _stdio_server("remote")}
|
||||||
|
)
|
||||||
closed: list[str] = []
|
closed: list[str] = []
|
||||||
connect_count = 0
|
connect_count = 0
|
||||||
|
|
||||||
@@ -638,9 +706,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 loop._connect_mcp()
|
await provider.connect()
|
||||||
old_alpha = loop.tools.get("mcp_remote_resource_alpha")
|
old_alpha = registry.get("mcp_remote_resource_alpha")
|
||||||
old_beta = loop.tools.get("mcp_remote_resource_beta")
|
old_beta = registry.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,15 +15,13 @@ 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 MCPToolWrapper
|
from nanobot.agent.tools.mcp import MCPProvider, MCPToolWrapper
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
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
|
||||||
|
|
||||||
@@ -113,18 +111,9 @@ def mcp_server_url():
|
|||||||
process.join(timeout=2.0)
|
process.join(timeout=2.0)
|
||||||
|
|
||||||
|
|
||||||
def _make_loop(tmp_path, *, mcp_servers: dict) -> AgentLoop:
|
def _make_provider(*, mcp_servers: dict) -> tuple[MCPProvider, ToolRegistry]:
|
||||||
bus = MessageBus()
|
registry = ToolRegistry()
|
||||||
provider = MagicMock()
|
return MCPProvider(mcp_servers, registry), registry
|
||||||
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)
|
||||||
@@ -170,12 +159,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=["*"],
|
||||||
)
|
)
|
||||||
loop = _make_loop(tmp_path, mcp_servers={"repro": cfg})
|
provider, registry = _make_provider(mcp_servers={"repro": cfg})
|
||||||
|
|
||||||
await asyncio.create_task(loop._connect_mcp())
|
await asyncio.create_task(provider.connect())
|
||||||
assert "repro" in loop._mcp_stacks
|
assert provider.connected_server_names == {"repro"}
|
||||||
|
|
||||||
tool = loop.tools.get("mcp_repro_greet")
|
tool = registry.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"))
|
||||||
@@ -187,7 +176,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(loop.close_mcp())
|
await asyncio.create_task(provider.aclose())
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -203,10 +192,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=["*"],
|
||||||
)
|
)
|
||||||
loop = _make_loop(tmp_path, mcp_servers={"repro": cfg})
|
provider, registry = _make_provider(mcp_servers={"repro": cfg})
|
||||||
|
|
||||||
await asyncio.create_task(loop._connect_mcp())
|
await asyncio.create_task(provider.connect())
|
||||||
tool = loop.tools.get("mcp_repro_greet")
|
tool = registry.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"))
|
||||||
@@ -224,7 +213,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(loop.close_mcp())
|
close_task = asyncio.create_task(provider.aclose())
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
finish_reconnect.set()
|
finish_reconnect.set()
|
||||||
|
|
||||||
@@ -245,4 +234,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 loop._mcp_stacks == {}
|
assert provider.connected_server_names == set()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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
|
||||||
@@ -312,7 +313,11 @@ 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(config, provider_snapshot_loader=loader)
|
loop = AgentLoop.from_config(
|
||||||
|
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,6 +5,7 @@ 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
|
||||||
@@ -390,7 +391,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)
|
loop = AgentLoop.from_config(config, tool_registry=ToolRegistry())
|
||||||
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
|
||||||
@@ -407,7 +408,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)
|
loop = AgentLoop.from_config(config, tool_registry=ToolRegistry())
|
||||||
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_close_mcp_cancels_active_turn_before_resources(self):
|
async def test_aclose_cancels_active_turn_before_resources(self):
|
||||||
loop, _bus = _make_loop()
|
loop, _bus = _make_loop()
|
||||||
events: list[str] = []
|
events: list[str] = []
|
||||||
|
|
||||||
@@ -76,14 +76,13 @@ 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()
|
||||||
with patch("nanobot.agent.loop.agent_context.close_mcp", AsyncMock()):
|
await loop.aclose()
|
||||||
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_close_mcp_serializes_duplicate_cleanup(self):
|
async def test_aclose_serializes_duplicate_cleanup(self):
|
||||||
loop, _bus = _make_loop()
|
loop, _bus = _make_loop()
|
||||||
entered = asyncio.Event()
|
entered = asyncio.Event()
|
||||||
release = asyncio.Event()
|
release = asyncio.Event()
|
||||||
@@ -100,10 +99,9 @@ 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()
|
||||||
with patch("nanobot.agent.loop.agent_context.close_mcp", AsyncMock()):
|
first = asyncio.create_task(loop.aclose())
|
||||||
first = asyncio.create_task(loop.close_mcp())
|
|
||||||
await entered.wait()
|
await entered.wait()
|
||||||
second = asyncio.create_task(loop.close_mcp())
|
second = asyncio.create_task(loop.aclose())
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
assert not second.done()
|
assert not second.done()
|
||||||
release.set()
|
release.set()
|
||||||
@@ -172,8 +170,7 @@ 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._connect_mcp = AsyncMock()
|
loop.aclose = 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,20 +493,6 @@ 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."""
|
||||||
@@ -535,13 +521,6 @@ 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,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
from nanobot.apps.cli.service import CliAppManager
|
from nanobot.apps.cli.service import CliAppManager
|
||||||
|
|
||||||
|
|
||||||
@@ -67,3 +69,22 @@ 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.close_mcp_calls = 0
|
self.aclose_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 close_mcp(self) -> None:
|
async def aclose(self) -> None:
|
||||||
self.close_mcp_calls += 1
|
self.aclose_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.close_mcp_calls == 1
|
assert loop.aclose_calls == 1
|
||||||
assert len(renderers) == 1
|
assert len(renderers) == 1
|
||||||
renderer = renderers[0]
|
renderer = renderers[0]
|
||||||
assert isinstance(renderer, _Renderer)
|
assert isinstance(renderer, _Renderer)
|
||||||
|
|||||||
+50
-19
@@ -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.close_mcp = AsyncMock(return_value=None)
|
agent_loop.aclose = 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 close_mcp(self) -> None:
|
async def aclose(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 close_mcp(self) -> None:
|
async def aclose(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 close_mcp(self) -> None:
|
async def aclose(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 close_mcp(self) -> None:
|
async def aclose(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 close_mcp(self) -> None:
|
async def aclose(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
@@ -2738,10 +2738,7 @@ 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 _connect_mcp(self) -> None:
|
async def aclose(self) -> None:
|
||||||
return None
|
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _fake_create_app(
|
def _fake_create_app(
|
||||||
@@ -2749,11 +2746,13 @@ 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):
|
||||||
@@ -2914,7 +2913,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 close_mcp(self) -> None:
|
async def aclose(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
@@ -3033,7 +3032,7 @@ def test_gateway_bound_cron_runs_as_session_turn(
|
|||||||
content="Checked the repo.",
|
content="Checked the repo.",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
async def aclose(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
@@ -3253,7 +3252,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 close_mcp(self) -> None:
|
async def aclose(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
@@ -3499,7 +3498,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 close_mcp(self) -> None:
|
async def aclose(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
@@ -3668,7 +3667,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_shutdown_lets_agent_task_own_mcp_cleanup(
|
def test_gateway_agent_task_owns_initial_mcp_provider_close(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -3696,17 +3695,41 @@ def test_gateway_shutdown_lets_agent_task_own_mcp_cleanup(
|
|||||||
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 close_mcp(self) -> None:
|
async def aclose(self) -> None:
|
||||||
raise AssertionError("gateway must not close MCP from the outer task")
|
seen["agent_closed"] = True
|
||||||
|
|
||||||
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"]
|
||||||
@@ -3753,6 +3776,7 @@ def test_gateway_shutdown_lets_agent_task_own_mcp_cleanup(
|
|||||||
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)
|
||||||
@@ -3761,9 +3785,15 @@ def test_gateway_shutdown_lets_agent_task_own_mcp_cleanup(
|
|||||||
|
|
||||||
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(
|
||||||
@@ -3800,8 +3830,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 close_mcp(self) -> None:
|
async def aclose(self) -> None:
|
||||||
raise AssertionError("gateway must not close MCP from the outer task")
|
seen["agent_closed"] = True
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
seen["agent_stopped"] = True
|
seen["agent_stopped"] = True
|
||||||
@@ -3881,6 +3911,7 @@ 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 close_mcp(self) -> None:
|
async def aclose(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("close_mcp")
|
self.events.append("aclose")
|
||||||
|
|
||||||
|
|
||||||
class _FakeChannels:
|
class _FakeChannels:
|
||||||
@@ -43,6 +43,16 @@ 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)
|
||||||
@@ -64,13 +74,14 @@ 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, channels, [task], None)
|
await _close_gateway_runtime(agent, provider, channels, [task], None)
|
||||||
|
|
||||||
assert events == ["cancelled", "close_mcp"] # cancel happens before close
|
assert events == ["cancelled", "aclose", "mcp_closed"]
|
||||||
assert channels.stopped == 1
|
assert channels.stopped == 1
|
||||||
assert agent.close_calls == 1
|
assert agent.close_calls == 1
|
||||||
assert task.cancelled()
|
assert task.cancelled()
|
||||||
@@ -78,6 +89,7 @@ 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}
|
||||||
|
|
||||||
@@ -87,7 +99,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, channels, [], None)
|
await _close_gateway_runtime(agent, provider, channels, [], None)
|
||||||
|
|
||||||
assert done["done"] is True
|
assert done["done"] is True
|
||||||
assert agent.close_calls == 1
|
assert agent.close_calls == 1
|
||||||
@@ -95,6 +107,7 @@ 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))
|
||||||
@@ -104,6 +117,7 @@ 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,
|
||||||
@@ -117,70 +131,88 @@ 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(agent, channels, [], None, close_timeout=0.05)
|
await _close_gateway_runtime(
|
||||||
|
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, channels, [], None)
|
await _close_gateway_runtime(agent, provider, 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, channels, [task], None)
|
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)
|
||||||
|
|
||||||
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, channels, [], runtime_tasks)
|
await _close_gateway_runtime(agent, provider, 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, channels, [], runtime_tasks)
|
await _close_gateway_runtime(agent, provider, 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
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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()
|
||||||
@@ -442,12 +442,15 @@ 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,14 +3,29 @@ 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
|
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||||
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 = load_config(tmp_path / "missing.json")
|
config_path = tmp_path / "instance" / "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_stream_forwards_reasoning_deltas_deepseek_style() -> None:
|
async def test_openai_compat_chat_stream_forwards_reasoning_deltas_deepseek_style() -> None:
|
||||||
"""Regression: DeepSeek-V4 / reasoner expose ``delta.reasoning_content`` during streaming."""
|
"""DeepSeek Chat Completions exposes ``delta.reasoning_content`` while 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,6 +321,7 @@ async def test_openai_compat_stream_forwards_reasoning_deltas_deepseek_style() -
|
|||||||
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",
|
||||||
@@ -336,6 +337,37 @@ async def test_openai_compat_stream_forwards_reasoning_deltas_deepseek_style() -
|
|||||||
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,6 +11,7 @@ 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()
|
||||||
@@ -30,30 +31,22 @@ 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
|
||||||
|
|
||||||
|
|
||||||
def test_deepseek_v4_flash_uses_responses_by_model(provider):
|
@pytest.mark.parametrize("model", ["deepseek-v4-flash", "deepseek-v4-pro"])
|
||||||
provider._spec = type("Spec", (), {
|
def test_deepseek_v4_models_use_responses_by_model(provider, model):
|
||||||
"name": "deepseek",
|
provider._spec = find_by_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 = "deepseek-v4-flash"
|
provider.default_model = model
|
||||||
|
|
||||||
assert provider._should_use_responses_api("deepseek-v4-flash", None) is True
|
assert provider._should_use_responses_api(model, None) is True
|
||||||
assert provider._should_use_responses_api("deepseek-v4-pro", None) is False
|
assert provider._should_use_responses_api("deepseek-chat", None) is False
|
||||||
|
|
||||||
|
|
||||||
def test_deepseek_v4_flash_matches_provider_prefixed_model(provider):
|
@pytest.mark.parametrize("model", ["deepseek-v4-flash", "deepseek-v4-pro"])
|
||||||
provider._spec = type("Spec", (), {
|
def test_deepseek_v4_models_match_provider_prefixed_model(provider, model):
|
||||||
"name": "deepseek",
|
provider._spec = find_by_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("deepseek/deepseek-v4-flash", None) is True
|
assert provider._should_use_responses_api(f"deepseek/{model}", None) is True
|
||||||
|
|
||||||
|
|
||||||
def test_direct_openai_enables_server_compaction(provider):
|
def test_direct_openai_enables_server_compaction(provider):
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
"""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,8 +32,7 @@ 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._connect_mcp = AsyncMock()
|
agent.aclose = AsyncMock()
|
||||||
agent.close_mcp = AsyncMock()
|
|
||||||
agent._last_usage = {}
|
agent._last_usage = {}
|
||||||
return agent
|
return agent
|
||||||
|
|
||||||
|
|||||||
@@ -64,8 +64,7 @@ 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._connect_mcp = AsyncMock()
|
agent.aclose = 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,
|
||||||
@@ -136,8 +135,7 @@ 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._connect_mcp = AsyncMock()
|
agent.aclose = 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)
|
||||||
@@ -160,8 +158,7 @@ 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._connect_mcp = AsyncMock()
|
agent.aclose = 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)
|
||||||
@@ -217,8 +214,7 @@ 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._connect_mcp = AsyncMock()
|
agent.aclose = 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)
|
||||||
@@ -251,8 +247,7 @@ 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._connect_mcp = AsyncMock()
|
agent.aclose = 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)
|
||||||
@@ -291,8 +286,7 @@ 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._connect_mcp = AsyncMock()
|
agent.aclose = 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)
|
||||||
@@ -334,8 +328,7 @@ 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._connect_mcp = AsyncMock()
|
agent.aclose = 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)
|
||||||
@@ -364,8 +357,7 @@ 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._connect_mcp = AsyncMock()
|
agent.aclose = 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,7 +46,9 @@ def _write_config(tmp_path: Path, overrides: dict | None = None) -> Path:
|
|||||||
}
|
}
|
||||||
if overrides:
|
if overrides:
|
||||||
data.update(overrides)
|
data.update(overrides)
|
||||||
config_path = tmp_path / "config.json"
|
config_dir = tmp_path.parent / f"{tmp_path.name}-instance"
|
||||||
|
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
|
||||||
|
|
||||||
@@ -96,9 +98,24 @@ 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)
|
||||||
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
workspace = tmp_path / "workspace"
|
||||||
|
bot = Nanobot.from_config(config_path, workspace=workspace)
|
||||||
assert bot._loop is not None
|
assert bot._loop is not None
|
||||||
assert bot._loop.workspace == tmp_path
|
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)
|
||||||
|
|
||||||
|
assert bot._mcp_provider is not None
|
||||||
|
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):
|
||||||
@@ -1637,37 +1654,40 @@ async def test_runtime_helpers_expose_model_workspace_and_compact(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_aclose_delegates_to_loop_close_mcp(tmp_path):
|
async def test_aclose_releases_loop_and_mcp_provider(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.close_mcp = AsyncMock()
|
bot._loop.aclose = AsyncMock()
|
||||||
|
assert bot._mcp_provider is not None
|
||||||
|
bot._mcp_provider.aclose = AsyncMock()
|
||||||
|
|
||||||
await bot.aclose()
|
await bot.aclose()
|
||||||
|
|
||||||
bot._loop.close_mcp.assert_awaited_once()
|
bot._loop.aclose.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.close_mcp = AsyncMock()
|
bot._loop.aclose = AsyncMock()
|
||||||
|
|
||||||
async with bot as b:
|
async with bot as b:
|
||||||
assert b is bot
|
assert b is bot
|
||||||
|
|
||||||
bot._loop.close_mcp.assert_awaited_once()
|
bot._loop.aclose.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.close_mcp = AsyncMock()
|
bot._loop.aclose = 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.close_mcp.assert_awaited_once()
|
bot._loop.aclose.assert_awaited_once()
|
||||||
|
|||||||
@@ -34,8 +34,7 @@ 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._connect_mcp = AsyncMock()
|
agent.aclose = 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
|
||||||
|
|
||||||
@@ -149,6 +148,59 @@ 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:
|
||||||
@@ -275,8 +327,7 @@ 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._connect_mcp = AsyncMock()
|
agent.aclose = 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)
|
||||||
@@ -315,8 +366,7 @@ 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._connect_mcp = AsyncMock()
|
agent.aclose = 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)
|
||||||
@@ -433,8 +483,7 @@ 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._connect_mcp = AsyncMock()
|
agent.aclose = 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)
|
||||||
@@ -457,7 +506,6 @@ 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,6 +76,31 @@ 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, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -21,6 +21,19 @@ _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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -133,6 +146,32 @@ 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."""
|
||||||
@@ -302,7 +341,9 @@ class TestPathAppendPlatform:
|
|||||||
captured_cmd = None
|
captured_cmd = None
|
||||||
captured_env = {}
|
captured_env = {}
|
||||||
|
|
||||||
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True):
|
async def capture_spawn(
|
||||||
|
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)
|
||||||
@@ -331,7 +372,9 @@ class TestPathAppendPlatform:
|
|||||||
captured_cmd = None
|
captured_cmd = None
|
||||||
captured_env = {}
|
captured_env = {}
|
||||||
|
|
||||||
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True, *, stdin=None):
|
async def capture_spawn(
|
||||||
|
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)
|
||||||
@@ -359,7 +402,9 @@ class TestPathAppendPlatform:
|
|||||||
captured_cmd = None
|
captured_cmd = None
|
||||||
captured_env = {}
|
captured_env = {}
|
||||||
|
|
||||||
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True, *, stdin=None):
|
async def capture_spawn(
|
||||||
|
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)
|
||||||
@@ -389,7 +434,9 @@ class TestPathAppendPlatform:
|
|||||||
|
|
||||||
captured_env = {}
|
captured_env = {}
|
||||||
|
|
||||||
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True):
|
async def capture_spawn(
|
||||||
|
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
|
||||||
|
|
||||||
@@ -412,7 +459,9 @@ class TestPathAppendPlatform:
|
|||||||
|
|
||||||
captured_env = {}
|
captured_env = {}
|
||||||
|
|
||||||
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True, *, stdin=None):
|
async def capture_spawn(
|
||||||
|
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
|
||||||
|
|
||||||
@@ -558,7 +607,9 @@ class TestExecuteEndToEnd:
|
|||||||
mock_proc.returncode = 0
|
mock_proc.returncode = 0
|
||||||
captured_login = []
|
captured_login = []
|
||||||
|
|
||||||
async def capture_spawn(cmd, cwd, env, shell_program=None, login=None, *, stdin=None):
|
async def capture_spawn(
|
||||||
|
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
|
||||||
|
|
||||||
@@ -649,6 +700,7 @@ 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
|
||||||
@@ -670,6 +722,7 @@ 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
|
||||||
@@ -733,6 +786,7 @@ 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
|
||||||
@@ -754,6 +808,7 @@ 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,6 +159,7 @@ 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()
|
||||||
@@ -179,6 +180,7 @@ 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()
|
||||||
@@ -274,6 +276,7 @@ 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()
|
||||||
@@ -426,6 +429,7 @@ 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()
|
||||||
@@ -445,6 +449,7 @@ 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()
|
||||||
@@ -496,3 +501,194 @@ 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,7 +12,6 @@ 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 (
|
||||||
@@ -712,7 +711,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, monkeypatch):
|
def test_agent_loop_shutdown_closes_exec_sessions(tmp_path):
|
||||||
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)
|
||||||
@@ -723,14 +722,13 @@ def test_agent_loop_shutdown_closes_exec_sessions(tmp_path, monkeypatch):
|
|||||||
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.close_mcp()
|
await loop.aclose()
|
||||||
await loop.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
assert process.returncode is not None
|
assert process.returncode is not None
|
||||||
assert manager._sessions == {}
|
assert manager._sessions == {}
|
||||||
@@ -739,7 +737,7 @@ def test_agent_loop_shutdown_closes_exec_sessions(tmp_path, monkeypatch):
|
|||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
def test_agent_loop_shutdown_attempts_all_cleanup_after_errors(monkeypatch):
|
def test_agent_loop_shutdown_attempts_all_cleanup_after_errors():
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
loop = object.__new__(AgentLoop)
|
loop = object.__new__(AgentLoop)
|
||||||
loop._background_tasks = set()
|
loop._background_tasks = set()
|
||||||
@@ -749,16 +747,12 @@ def test_agent_loop_shutdown_attempts_all_cleanup_after_errors(monkeypatch):
|
|||||||
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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
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())
|
||||||
|
|
||||||
@@ -892,7 +886,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(monkeypatch):
|
def test_agent_loop_shutdown_preserves_single_cleanup_error():
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
loop = object.__new__(AgentLoop)
|
loop = object.__new__(AgentLoop)
|
||||||
loop._background_tasks = set()
|
loop._background_tasks = set()
|
||||||
@@ -900,13 +894,9 @@ def test_agent_loop_shutdown_preserves_single_cleanup_error(monkeypatch):
|
|||||||
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.close_mcp()
|
await loop.aclose()
|
||||||
|
|
||||||
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,6 +13,7 @@ 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,
|
||||||
@@ -153,7 +154,7 @@ def _make_wrapper(session: object, *, timeout: float = 0.1) -> MCPToolWrapper:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_connect_missing_servers_propagates_external_cancellation(monkeypatch) -> None:
|
async def test_mcp_provider_connect_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:
|
||||||
@@ -161,24 +162,21 @@ async def test_connect_missing_servers_propagates_external_cancellation(monkeypa
|
|||||||
await asyncio.sleep(60)
|
await asyncio.sleep(60)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
class State:
|
provider = MCPProvider(
|
||||||
pass
|
{"test": MCPServerConfig(command="fake")},
|
||||||
|
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(mcp_mod.connect_missing_servers(state, ToolRegistry()))
|
task = asyncio.create_task(provider.connect())
|
||||||
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 state._mcp_connecting is False
|
assert provider.connected_server_names == set()
|
||||||
|
assert provider.runtime_status() == {"test": "failed"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -223,25 +221,20 @@ async def test_saved_oauth_http_403_projects_failed_runtime_without_details(
|
|||||||
rejected_streamable_http,
|
rejected_streamable_http,
|
||||||
)
|
)
|
||||||
|
|
||||||
class State:
|
provider = MCPProvider(
|
||||||
pass
|
{
|
||||||
|
|
||||||
state = State()
|
|
||||||
state._mcp_closing = False
|
|
||||||
state._mcp_servers = {
|
|
||||||
"xmind": MCPServerConfig(
|
"xmind": MCPServerConfig(
|
||||||
type="streamableHttp",
|
type="streamableHttp",
|
||||||
auth="oauth",
|
auth="oauth",
|
||||||
url="https://app.xmind.com/api/mcp",
|
url="https://app.xmind.com/api/mcp",
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
state._mcp_stacks = {}
|
ToolRegistry(),
|
||||||
state._mcp_runtime_statuses = {}
|
)
|
||||||
state._mcp_connecting = False
|
|
||||||
|
|
||||||
await mcp_mod.connect_missing_servers(state, ToolRegistry())
|
await provider.connect()
|
||||||
|
|
||||||
snapshot = mcp_mod.runtime_status(state)
|
snapshot = provider.runtime_status()
|
||||||
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)
|
||||||
@@ -1263,6 +1256,59 @@ 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],
|
||||||
@@ -1900,7 +1946,11 @@ 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))
|
||||||
|
|
||||||
mcp_mod._attach_reconnect_handlers(SimpleNamespace(), registry, {server_name})
|
provider = MCPProvider(
|
||||||
|
{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
|
||||||
|
|
||||||
|
|||||||
+104
-11
@@ -3,6 +3,9 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -12,6 +15,12 @@ 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:
|
||||||
@@ -112,20 +121,37 @@ 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),
|
patch.object(ExecTool, "_spawn", return_value=mock_proc) as spawn,
|
||||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
patch.object(ExecTool, "_guard_command", return_value=None),
|
||||||
patch("nanobot.agent.tools.shell._reap_pid") as reap,
|
patch.object(ExecTool, "_kill_process_tree", new_callable=AsyncMock) as kill_tree,
|
||||||
):
|
):
|
||||||
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()
|
||||||
mock_proc.kill.assert_called_once()
|
kill_tree.assert_awaited_once_with(mock_proc)
|
||||||
reap.assert_called_with(1002)
|
assert spawn.await_args.kwargs["process_tree"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
@@ -161,21 +187,88 @@ 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("nanobot.agent.tools.shell._reap_pid") as reap,
|
patch.object(ExecTool, "_kill_process_tree", new_callable=AsyncMock) as kill_tree,
|
||||||
):
|
):
|
||||||
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
|
||||||
mock_proc.kill.assert_called_once()
|
kill_tree.assert_awaited_once_with(mock_proc)
|
||||||
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,10 +290,11 @@ 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"
|
cmd = "curl --output=/etc/passwd --config=~/.nanobot/config.json --user-home=~root"
|
||||||
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:
|
||||||
@@ -302,6 +303,29 @@ 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)
|
||||||
@@ -319,6 +343,48 @@ 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))
|
||||||
@@ -328,6 +394,15 @@ 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))
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""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,5 +1,9 @@
|
|||||||
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,
|
||||||
@@ -17,7 +21,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_old_or_partial_payload(tmp_path, monkeypatch) -> None:
|
def test_sidebar_state_normalizes_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)
|
||||||
@@ -31,6 +35,23 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
|
|||||||
"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"},
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -47,6 +68,19 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
|
|||||||
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,
|
||||||
@@ -80,3 +114,67 @@ 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
|
from nanobot.config.loader import load_config, save_config
|
||||||
from nanobot.webui.mcp_presets_api import (
|
from nanobot.webui.mcp_presets_api import (
|
||||||
McpPresetError,
|
McpPresetError,
|
||||||
custom_mcp_action,
|
custom_mcp_action,
|
||||||
@@ -454,6 +454,46 @@ 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,6 +1,7 @@
|
|||||||
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
|
||||||
@@ -17,6 +18,13 @@ 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,
|
||||||
@@ -208,6 +216,269 @@ 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"
|
||||||
@@ -269,7 +540,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()
|
webui_dir.mkdir(exist_ok=True)
|
||||||
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)
|
||||||
@@ -311,7 +582,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()
|
webui_dir.mkdir(exist_ok=True)
|
||||||
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)
|
||||||
@@ -416,4 +687,3 @@ 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,7 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Awaitable, 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
|
||||||
@@ -22,6 +23,7 @@ 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()),
|
||||||
@@ -37,6 +39,7 @@ 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",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -89,6 +92,33 @@ async def test_mcp_list_serializes_local_runtime_failure_snapshot(tmp_path) -> N
|
|||||||
assert snapshot_calls == 1
|
assert snapshot_calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mcp_reload_callback_is_bounded(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
started = asyncio.Event()
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mcp_oauth_start_uses_gateway_callback_and_requires_api_auth(monkeypatch) -> None:
|
async def test_mcp_oauth_start_uses_gateway_callback_and_requires_api_auth(monkeypatch) -> None:
|
||||||
config = SimpleNamespace(
|
config = SimpleNamespace(
|
||||||
|
|||||||
+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, maximum-scale=1, user-scalable=no"
|
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||||
/>
|
/>
|
||||||
<meta name="color-scheme" content="light dark" />
|
<meta name="color-scheme" content="light dark" />
|
||||||
<meta
|
<meta
|
||||||
|
|||||||
+92
-33
@@ -1,5 +1,23 @@
|
|||||||
const CACHE_NAME = "nanobot-static-v1";
|
const CACHE_PREFIX = "nanobot-static-";
|
||||||
const PRECACHE = ["/", "/manifest.json"];
|
const CACHE_NAME = `${CACHE_PREFIX}v2`;
|
||||||
|
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(
|
||||||
@@ -20,6 +38,42 @@ 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
|
||||||
@@ -31,11 +85,16 @@ 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 (url.pathname === "/" || url.pathname === "/manifest.json") return;
|
if (
|
||||||
|
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);
|
||||||
})
|
})
|
||||||
@@ -49,7 +108,7 @@ self.addEventListener("activate", (event) => {
|
|||||||
.then((keys) =>
|
.then((keys) =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
keys
|
keys
|
||||||
.filter((k) => k !== CACHE_NAME)
|
.filter((k) => k.startsWith(CACHE_PREFIX) && k !== CACHE_NAME)
|
||||||
.map((k) => caches.delete(k))
|
.map((k) => caches.delete(k))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -65,28 +124,13 @@ 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; the /__nanobot exclusion below still protects its HTTP
|
// cannot be cached. Unknown HTTP endpoints are passed through below.
|
||||||
// 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
|
||||||
@@ -96,7 +140,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 (response.ok) {
|
if (responseMayBeCached(response)) {
|
||||||
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));
|
||||||
}
|
}
|
||||||
@@ -107,20 +151,35 @@ self.addEventListener("fetch", (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything else: network-first (index.html, manifest, brand assets, etc.)
|
// Cache only explicit public files and browser navigations. Dynamic routes
|
||||||
event.respondWith(
|
// are deliberately passed through because token_issue_path and extension
|
||||||
fetch(request)
|
// endpoints are configurable and cannot be safely identified by prefixes.
|
||||||
.then((response) => {
|
const isNavigation = request.mode === "navigate";
|
||||||
if (response.ok) {
|
if (!isNavigation && !NETWORK_FIRST_STATIC_PATHS.has(path)) return;
|
||||||
const clone = response.clone();
|
|
||||||
caches.open(CACHE_NAME).then((c) => c.put(request, clone));
|
// App shell and public files: network-first with an offline fallback.
|
||||||
// The shell just changed; prune entries the new index.html no longer
|
const networkResponse = fetch(request);
|
||||||
// references so hashed assets from old builds do not accumulate even
|
event.waitUntil(
|
||||||
// when sw.js itself is unchanged between deployments.
|
networkResponse
|
||||||
if (path === "/") pruneStaleEntries();
|
.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();
|
||||||
}
|
}
|
||||||
return response;
|
|
||||||
})
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
);
|
||||||
|
event.respondWith(
|
||||||
|
networkResponse
|
||||||
.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.
|
||||||
|
|||||||
+539
-54
@@ -12,8 +12,27 @@ 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";
|
||||||
|
|
||||||
@@ -23,6 +42,7 @@ 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";
|
||||||
@@ -35,7 +55,7 @@ import {
|
|||||||
loadSavedSecret,
|
loadSavedSecret,
|
||||||
saveSecret,
|
saveSecret,
|
||||||
} from "@/lib/bootstrap";
|
} from "@/lib/bootstrap";
|
||||||
import { displayTitle } from "@/lib/chat-groups";
|
import { displayTitle, sortSessions } 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";
|
||||||
@@ -1017,7 +1037,11 @@ function Shell({
|
|||||||
deleteChat,
|
deleteChat,
|
||||||
getSessionAutomations,
|
getSessionAutomations,
|
||||||
} = useSessions();
|
} = useSessions();
|
||||||
const { state: sidebarState, update: updateSidebarState } =
|
const {
|
||||||
|
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();
|
||||||
@@ -1034,15 +1058,31 @@ 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<{
|
||||||
key: string;
|
items: SidebarDeleteItem[];
|
||||||
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;
|
||||||
@@ -1220,9 +1260,21 @@ 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 = activeSession?.chatId ?? null;
|
const activeChatId = activePaneSession?.chatId ?? null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeChatIdRef.current = activeChatId;
|
activeChatIdRef.current = activeChatId;
|
||||||
if (!activeChatId) return;
|
if (!activeChatId) return;
|
||||||
@@ -1242,13 +1294,13 @@ function Shell({
|
|||||||
if (activeChatId && workspaceOverrides[activeChatId]) {
|
if (activeChatId && workspaceOverrides[activeChatId]) {
|
||||||
return workspaceOverrides[activeChatId];
|
return workspaceOverrides[activeChatId];
|
||||||
}
|
}
|
||||||
if (activeSession?.workspaceScope) {
|
if (activePaneSession?.workspaceScope) {
|
||||||
return activeSession.workspaceScope;
|
return activePaneSession.workspaceScope;
|
||||||
}
|
}
|
||||||
return draftWorkspaceScope ?? workspaces?.default_scope ?? null;
|
return draftWorkspaceScope ?? workspaces?.default_scope ?? null;
|
||||||
}, [
|
}, [
|
||||||
activeChatId,
|
activeChatId,
|
||||||
activeSession?.workspaceScope,
|
activePaneSession?.workspaceScope,
|
||||||
draftWorkspaceScope,
|
draftWorkspaceScope,
|
||||||
temporaryChatRequested,
|
temporaryChatRequested,
|
||||||
workspaceOverrides,
|
workspaceOverrides,
|
||||||
@@ -1284,6 +1336,19 @@ 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;
|
||||||
@@ -1715,6 +1780,18 @@ 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) => {
|
||||||
@@ -1788,7 +1865,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 = sessions.find((session) => !archived.has(session.key));
|
const next = topicSessions.find((session) => !archived.has(session.key));
|
||||||
navigate({
|
navigate({
|
||||||
view: "chat",
|
view: "chat",
|
||||||
activeKey: next?.key ?? null,
|
activeKey: next?.key ?? null,
|
||||||
@@ -1796,18 +1873,7 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeKey, navigate, sessions, sidebarState.archived_keys, updateSidebarState],
|
[activeKey, navigate, sidebarState.archived_keys, topicSessions, 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(() => {
|
||||||
@@ -1825,6 +1891,57 @@ 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;
|
||||||
@@ -1902,15 +2019,15 @@ function Shell({
|
|||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
const nextKey = (() => {
|
const nextKey = (() => {
|
||||||
if (!activeKey) return null;
|
if (!activeKey) return null;
|
||||||
if (sessions.some((session) => session.key === activeKey)) return activeKey;
|
if (topicSessions.some((session) => session.key === activeKey)) return activeKey;
|
||||||
return sessions[0]?.key ?? null;
|
return topicSessions[0]?.key ?? null;
|
||||||
})();
|
})();
|
||||||
navigate({
|
navigate({
|
||||||
view: "chat",
|
view: "chat",
|
||||||
activeKey: nextKey,
|
activeKey: nextKey,
|
||||||
settingsSection: "overview",
|
settingsSection: "overview",
|
||||||
});
|
});
|
||||||
}, [activeKey, navigate, sessions]);
|
}, [activeKey, navigate, topicSessions]);
|
||||||
|
|
||||||
const onRestart = useCallback(() => {
|
const onRestart = useCallback(() => {
|
||||||
const chatId = activeSession?.chatId ?? client.defaultChatId;
|
const chatId = activeSession?.chatId ?? client.defaultChatId;
|
||||||
@@ -2017,31 +2134,43 @@ function Shell({
|
|||||||
}, [client, t]);
|
}, [client, t]);
|
||||||
|
|
||||||
const onTurnEnd = useDeferredTitleRefresh(
|
const onTurnEnd = useDeferredTitleRefresh(
|
||||||
temporaryChatActive ? null : activeSession,
|
temporaryChatActive ? null : activePaneSession,
|
||||||
refresh,
|
refresh,
|
||||||
);
|
);
|
||||||
|
|
||||||
const onConfirmDelete = useCallback(async () => {
|
const onConfirmDelete = useCallback(async () => {
|
||||||
if (!pendingDelete) return;
|
if (!pendingDelete) return;
|
||||||
const key = pendingDelete.key;
|
const items = pendingDelete.items;
|
||||||
|
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 === key;
|
const deletingActive = activeKey !== null && deletingKeys.has(activeKey);
|
||||||
const currentIndex = sessions.findIndex((s) => s.key === key);
|
const currentIndex = topicSessions.findIndex((s) => s.key === activeKey);
|
||||||
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 item = items[index];
|
||||||
const result = await deleteChat(
|
const result = await deleteChat(
|
||||||
key,
|
item.key,
|
||||||
hasAutomations ? { deleteAutomations: true } : undefined,
|
hasAutomations ? { deleteAutomations: true } : undefined,
|
||||||
);
|
);
|
||||||
if (result.blocked_by_automations) {
|
if (result.blocked_by_automations) {
|
||||||
setPendingDelete({
|
setPendingDelete({
|
||||||
...pendingDelete,
|
items: items.slice(index),
|
||||||
automations: result.automations ?? [],
|
automations: result.automations ?? [],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
setPendingDelete(null);
|
setPendingDelete(null);
|
||||||
if (deletingActive) {
|
if (deletingActive) {
|
||||||
navigate({
|
navigate({
|
||||||
@@ -2053,18 +2182,24 @@ function Shell({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to delete session", e);
|
console.error("Failed to delete session", e);
|
||||||
}
|
}
|
||||||
}, [pendingDelete, deleteChat, activeKey, navigate, sessions]);
|
}, [pendingDelete, deleteChat, activeKey, navigate, topicSessions]);
|
||||||
|
|
||||||
const onRequestDelete = useCallback(async (key: string, label: string) => {
|
const onRequestDeleteMany = useCallback(async (items: SidebarDeleteItem[]) => {
|
||||||
let automations: SessionAutomationJob[] = [];
|
const uniqueItems = Array.from(new Map(items.map((item) => [item.key, item])).values());
|
||||||
try {
|
if (uniqueItems.length === 0) return;
|
||||||
automations = await getSessionAutomations(key);
|
const automationResults = await Promise.allSettled(
|
||||||
} catch {
|
uniqueItems.map((item) => getSessionAutomations(item.key)),
|
||||||
// Delete remains protected by the backend block; prefetch only improves the first prompt.
|
);
|
||||||
}
|
const automations = automationResults.flatMap((result) => (
|
||||||
setPendingDelete({ key, label, automations });
|
result.status === "fulfilled" ? result.value : []
|
||||||
|
));
|
||||||
|
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();
|
||||||
@@ -2109,13 +2244,218 @@ 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
|
||||||
? sidebarState.title_overrides[activeSession.key] ||
|
? titleForSession(activeSession)
|
||||||
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") {
|
||||||
@@ -2147,20 +2487,49 @@ 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,
|
sessions: sidebarTopicSessions,
|
||||||
temporarySessions: temporarySessionList,
|
temporarySessions: temporarySessionList,
|
||||||
activeKey: view === "chat" ? activeKey : null,
|
activeKey: view === "chat"
|
||||||
|
? (temporaryChatActive ? activeKey : activeSidebarKey)
|
||||||
|
: null,
|
||||||
loading,
|
loading,
|
||||||
newChatActive: view === "chat" && activeKey === null,
|
newChatActive: view === "chat" && activeKey === null,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
onSelect: onSelectChat,
|
onSelect: onSelectSidebarItem,
|
||||||
onCloseTemporaryChat,
|
onCloseTemporaryChat,
|
||||||
onRequestDelete,
|
onRequestDelete,
|
||||||
|
onRequestDeleteMany,
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
onRequestRename,
|
onRequestRename,
|
||||||
onToggleArchive,
|
onToggleArchive,
|
||||||
onReorderSessions,
|
onRequestRenameTab,
|
||||||
|
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,
|
||||||
@@ -2172,17 +2541,19 @@ 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: sidebarState.pinned_keys,
|
pinnedKeys: sidebarPinnedTabKeys,
|
||||||
archivedKeys: sidebarState.archived_keys,
|
archivedKeys: sidebarArchivedTabKeys,
|
||||||
|
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,
|
viewState: { ...sidebarState.view, sort: automaticSidebarSort },
|
||||||
showArchived: sidebarState.view.show_archived,
|
showArchived: sidebarState.view.show_archived,
|
||||||
archivedCount: sidebarState.archived_keys.length,
|
archivedCount: sidebarArchivedTabKeys.length,
|
||||||
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
|
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
|
||||||
};
|
};
|
||||||
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
|
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
|
||||||
@@ -2318,7 +2689,7 @@ function Shell({
|
|||||||
<SessionSearchDialog
|
<SessionSearchDialog
|
||||||
open
|
open
|
||||||
onOpenChange={setSessionSearchOpen}
|
onOpenChange={setSessionSearchOpen}
|
||||||
sessions={sessions}
|
sessions={topicSessions}
|
||||||
activeKey={activeKey}
|
activeKey={activeKey}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
titleOverrides={sidebarState.title_overrides}
|
titleOverrides={sidebarState.title_overrides}
|
||||||
@@ -2337,6 +2708,43 @@ function Shell({
|
|||||||
view !== "chat" && "hidden",
|
view !== "chat" && "hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<PaneWorkbench
|
||||||
|
panes={renderedWorkbenchPanes}
|
||||||
|
activePaneKey={renderedActivePaneKey}
|
||||||
|
layout={renderedWorkbenchLayout}
|
||||||
|
splitRatios={renderedWorkbenchSplitRatios}
|
||||||
|
chrome={paneChromeEnabled}
|
||||||
|
showLayoutControl={activeTabVisible}
|
||||||
|
addPaneDisabled={creatingPane || activePaneLimitReached}
|
||||||
|
addPaneDisabledLabel={activePaneLimitReached
|
||||||
|
? t("workbench.paneLimit", {
|
||||||
|
defaultValue: "Maximum {{count}} panes",
|
||||||
|
count: MAX_WORKBENCH_PANES,
|
||||||
|
})
|
||||||
|
: undefined}
|
||||||
|
onActivatePane={onActivateWorkbenchPane}
|
||||||
|
onAddPane={onAddPane}
|
||||||
|
onLayoutChange={(layout) => {
|
||||||
|
if (!activeTabKey) return;
|
||||||
|
updateWorkbenchState((current) => (
|
||||||
|
setWorkbenchLayout(current, activeTabKey, layout)
|
||||||
|
));
|
||||||
|
}}
|
||||||
|
onPaneOrderChange={(paneKeys) => {
|
||||||
|
if (!activeTabKey) return;
|
||||||
|
updateWorkbenchState((current) => (
|
||||||
|
setWorkbenchPaneLayoutOrder(current, activeTabKey, paneKeys)
|
||||||
|
));
|
||||||
|
}}
|
||||||
|
onSplitRatiosChange={(splitRatios) => {
|
||||||
|
if (!activeTabKey) return;
|
||||||
|
updateWorkbenchState((current) => (
|
||||||
|
setWorkbenchSplitRatios(current, activeTabKey, splitRatios)
|
||||||
|
));
|
||||||
|
}}
|
||||||
|
renderPane={(pane, context) => {
|
||||||
|
if (!paneChromeEnabled) {
|
||||||
|
return (
|
||||||
<ThreadShell
|
<ThreadShell
|
||||||
session={activeSession}
|
session={activeSession}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
@@ -2349,7 +2757,9 @@ function Shell({
|
|||||||
}
|
}
|
||||||
onToggleSidebar={toggleSidebar}
|
onToggleSidebar={toggleSidebar}
|
||||||
onNewChat={onNewChat}
|
onNewChat={onNewChat}
|
||||||
onCreateChat={temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat}
|
onCreateChat={
|
||||||
|
temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat
|
||||||
|
}
|
||||||
onForkChat={temporaryChatActive ? undefined : onForkChat}
|
onForkChat={temporaryChatActive ? undefined : onForkChat}
|
||||||
onTurnEnd={onTurnEnd}
|
onTurnEnd={onTurnEnd}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
@@ -2367,6 +2777,67 @@ function Shell({
|
|||||||
onOpenModelSettings={onOpenModelSettings}
|
onOpenModelSettings={onOpenModelSettings}
|
||||||
skills={skills}
|
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" && (
|
||||||
<div className="absolute inset-0 flex flex-col">
|
<div className="absolute inset-0 flex flex-col">
|
||||||
@@ -2398,7 +2869,8 @@ function Shell({
|
|||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<DeleteConfirm
|
<DeleteConfirm
|
||||||
open
|
open
|
||||||
title={pendingDelete.label}
|
title={pendingDelete.items[0]?.label ?? ""}
|
||||||
|
count={pendingDelete.items.length}
|
||||||
automations={pendingDelete.automations}
|
automations={pendingDelete.automations}
|
||||||
onCancel={() => setPendingDelete(null)}
|
onCancel={() => setPendingDelete(null)}
|
||||||
onConfirm={onConfirmDelete}
|
onConfirm={onConfirmDelete}
|
||||||
@@ -2415,6 +2887,19 @@ 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,17 +9,20 @@ 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, module] of Object.entries(modules)) {
|
for (const [modulePath, loader] 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}'`);
|
||||||
@@ -28,25 +31,27 @@ for (const [modulePath, module] 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 translations = translationsByChannel.get(channel) ?? new Map();
|
const loaders = loadersByChannel.get(channel) ?? new Map();
|
||||||
if (translations.has(locale as SupportedLocale)) {
|
if (loaders.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`);
|
||||||
}
|
}
|
||||||
translations.set(locale as SupportedLocale, messages);
|
loaders.set(locale as SupportedLocale, loader);
|
||||||
translationsByChannel.set(channel, translations);
|
loadersByChannel.set(channel, loaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function channelLocaleNamespaces(): string[] {
|
export function channelLocaleNamespaces(): string[] {
|
||||||
return [...translationsByChannel.keys()].map(channelNamespace);
|
return [...loadersByChannel.keys()].map(channelNamespace);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function channelLocaleResources(locale: SupportedLocale): Record<string, unknown> {
|
export async function channelLocaleResources(
|
||||||
return Object.fromEntries(
|
locale: SupportedLocale,
|
||||||
[...translationsByChannel.keys()].map((channel) => [
|
): Promise<Record<string, ChannelMessages>> {
|
||||||
|
return Object.fromEntries(await Promise.all(
|
||||||
|
[...loadersByChannel.keys()].map(async (channel) => [
|
||||||
channelNamespace(channel),
|
channelNamespace(channel),
|
||||||
channelLocaleMessages(channel, locale) ?? {},
|
await loadChannelLocale(channel, locale),
|
||||||
]),
|
]),
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function channelLocaleMessages(
|
export function channelLocaleMessages(
|
||||||
@@ -63,3 +68,25 @@ 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/**/*.{ts,tsx}",
|
"../../../nanobot/channels/*/webui/index.{ts,tsx}",
|
||||||
{
|
{
|
||||||
eager: true,
|
eager: true,
|
||||||
},
|
},
|
||||||
|
|||||||
+852
-121
File diff suppressed because it is too large
Load Diff
@@ -104,37 +104,6 @@ export function splitCapabilityMentionSegments(
|
|||||||
return segments.length ? segments : [{ kind: "text", text: value }];
|
return segments.length ? segments : [{ kind: "text", text: value }];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CliAppMentionText({
|
|
||||||
text,
|
|
||||||
cliApps,
|
|
||||||
mcpPresets = [],
|
|
||||||
sessionMentions = [],
|
|
||||||
}: {
|
|
||||||
text: string;
|
|
||||||
cliApps: CliAppInfo[];
|
|
||||||
mcpPresets?: McpPresetInfo[];
|
|
||||||
sessionMentions?: SessionMention[];
|
|
||||||
}) {
|
|
||||||
const segments = splitCapabilityMentionSegments(text, cliApps, mcpPresets, sessionMentions);
|
|
||||||
if (!segments.some((segment) => segment.kind !== "text")) return <>{text}</>;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{segments.map((segment, index) => {
|
|
||||||
if (segment.kind === "text") {
|
|
||||||
return <span key={`text-${index}`}>{segment.text}</span>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<CapabilityMentionToken
|
|
||||||
key={`${segment.kind}-${index}`}
|
|
||||||
segment={segment}
|
|
||||||
variant="message"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CapabilityMentionToken({
|
export function CapabilityMentionToken({
|
||||||
segment,
|
segment,
|
||||||
variant,
|
variant,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { SessionAutomationJob } from "@/lib/types";
|
|||||||
interface DeleteConfirmProps {
|
interface DeleteConfirmProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
|
count?: number;
|
||||||
automations?: SessionAutomationJob[];
|
automations?: SessionAutomationJob[];
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
@@ -26,6 +27,7 @@ interface DeleteConfirmProps {
|
|||||||
export function DeleteConfirm({
|
export function DeleteConfirm({
|
||||||
open,
|
open,
|
||||||
title,
|
title,
|
||||||
|
count = 1,
|
||||||
automations = [],
|
automations = [],
|
||||||
onCancel,
|
onCancel,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
@@ -33,6 +35,7 @@ export function DeleteConfirm({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const locale = currentLocale();
|
const locale = currentLocale();
|
||||||
const hasAutomations = automations.length > 0;
|
const hasAutomations = automations.length > 0;
|
||||||
|
const multiple = count > 1;
|
||||||
const visibleAutomations = automations.slice(0, 4);
|
const visibleAutomations = automations.slice(0, 4);
|
||||||
const hiddenCount = Math.max(0, automations.length - visibleAutomations.length);
|
const hiddenCount = Math.max(0, automations.length - visibleAutomations.length);
|
||||||
return (
|
return (
|
||||||
@@ -47,11 +50,24 @@ export function DeleteConfirm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AlertDialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
|
<AlertDialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
|
||||||
{t("deleteConfirm.title", { title })}
|
{multiple
|
||||||
|
? t("deleteConfirm.titleMany", {
|
||||||
|
defaultValue: "Delete {{count}} conversations?",
|
||||||
|
count,
|
||||||
|
})
|
||||||
|
: t("deleteConfirm.title", { title })}
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription className="mt-3 max-w-[17rem] text-center text-[14px] leading-6 text-muted-foreground">
|
<AlertDialogDescription className="mt-3 max-w-[17rem] text-center text-[14px] leading-6 text-muted-foreground">
|
||||||
{hasAutomations
|
{hasAutomations
|
||||||
? t("deleteConfirm.automationsDescription")
|
? multiple
|
||||||
|
? t("deleteConfirm.automationsDescriptionMany", {
|
||||||
|
defaultValue: "Linked automations will also be deleted.",
|
||||||
|
})
|
||||||
|
: t("deleteConfirm.automationsDescription")
|
||||||
|
: multiple
|
||||||
|
? t("deleteConfirm.descriptionMany", {
|
||||||
|
defaultValue: "This action cannot be undone.",
|
||||||
|
})
|
||||||
: t("deleteConfirm.description")}
|
: t("deleteConfirm.description")}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
{hasAutomations ? (
|
{hasAutomations ? (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user