mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-09 05:48:38 +03:00
Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43d592f8e4 | ||
|
|
4a6853f0ff | ||
|
|
3b03cc2079 | ||
|
|
c29601d303 | ||
|
|
9ce40969ce | ||
|
|
5847470b65 | ||
|
|
43eb658a0b | ||
|
|
4c5e340186 | ||
|
|
dcf76117ab | ||
|
|
dcb33cf919 | ||
|
|
072921893f | ||
|
|
21d9072190 | ||
|
|
973a5ee507 | ||
|
|
846410f936 | ||
|
|
7bec0f6e01 | ||
|
|
d75f80437c | ||
|
|
c6ea5aecff | ||
|
|
25a55fe1c7 | ||
|
|
4262375c19 | ||
|
|
5573a9d78e | ||
|
|
2b741ad4e5 | ||
|
|
e9f982785e | ||
|
|
0263dbd1c3 | ||
|
|
3357dcc05f | ||
|
|
b24b5f19fc | ||
|
|
6239114c46 | ||
|
|
04545b95d9 | ||
|
|
27d869d3cc | ||
|
|
201d442a85 | ||
|
|
04387bf9e3 | ||
|
|
23e12b84ff | ||
|
|
f56a73b2d3 | ||
|
|
8f1fe7337c | ||
|
|
e6957de622 | ||
|
|
87aaf8991a | ||
|
|
3aa90e539c | ||
|
|
acf408ced2 | ||
|
|
85a3ff1372 | ||
|
|
3ce0cd972e | ||
|
|
03c79817ac | ||
|
|
153f2d9529 | ||
|
|
848378d0db | ||
|
|
828759d1b6 | ||
|
|
167a53dc45 | ||
|
|
cbb4c0bad2 | ||
|
|
d7e73609d3 | ||
|
|
0439ecb802 | ||
|
|
04496e9e28 | ||
|
|
140c4fb49f | ||
|
|
f8bf6aea51 | ||
|
|
9814a3b9fe | ||
|
|
f85101f017 | ||
|
|
a54e56c69e | ||
|
|
5892c6913b | ||
|
|
8b42d0760e | ||
|
|
747f0a08c7 | ||
|
|
6cf1f8e164 | ||
|
|
5f2f694034 | ||
|
|
17e3183598 | ||
|
|
5b10102629 | ||
|
|
43830b7162 | ||
|
|
e08462ca30 |
@@ -99,3 +99,4 @@ temp/
|
||||
*.tmp
|
||||
exp/
|
||||
.playwright-mcp/
|
||||
bridge/node_modules/
|
||||
|
||||
@@ -208,7 +208,7 @@ If terminals, API keys, or config files are new to you, use the guided zero-back
|
||||
macOS / Linux:
|
||||
|
||||
```bash
|
||||
sh -c "$(curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh)"
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
@@ -217,12 +217,12 @@ Windows PowerShell:
|
||||
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. If you finish the wizard and save the config, skip the manual initialize/configure steps below and go straight to **Test one message**.
|
||||
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If you finish the wizard and save the config, skip the manual initialize/configure steps below and go straight to **Test one message**.
|
||||
|
||||
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
|
||||
|
||||
```bash
|
||||
sh -c "$(curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh)" -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dry-run
|
||||
```
|
||||
|
||||
```powershell
|
||||
@@ -232,7 +232,7 @@ sh -c "$(curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts
|
||||
To install the current `main` branch instead, pass `--dev`:
|
||||
|
||||
```bash
|
||||
sh -c "$(curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh)" -- --dev
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dev
|
||||
```
|
||||
|
||||
```powershell
|
||||
@@ -241,18 +241,20 @@ sh -c "$(curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts
|
||||
|
||||
If you prefer to inspect the script first, open [`scripts/install.sh`](./scripts/install.sh) or [`scripts/install.ps1`](./scripts/install.ps1).
|
||||
|
||||
**Install from PyPI**
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
```
|
||||
|
||||
**Install with `uv`**
|
||||
|
||||
```bash
|
||||
uv tool install nanobot-ai
|
||||
```
|
||||
|
||||
**Install from PyPI with pip**
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
```
|
||||
|
||||
If pip reports `externally-managed-environment` on macOS or Linux, use the one-command installer, `uv tool install nanobot-ai`, `pipx install nanobot-ai`, or install inside a virtual environment.
|
||||
|
||||
**Install from source**
|
||||
|
||||
```bash
|
||||
@@ -359,7 +361,7 @@ Need help with `PATH`, API keys, provider/model matching, or JSON errors? See th
|
||||
|
||||
## 🌐 WebUI
|
||||
|
||||
The WebUI ships **inside the published wheel** — no extra build step. Just enable the WebSocket channel and open it in your browser.
|
||||
The WebUI ships **inside the published wheel** — no extra build step. It is the browser workbench for chat sessions, workspace controls, Apps, Skills, Automations, and settings. For the full user guide, see [`docs/webui.md`](./docs/webui.md).
|
||||
|
||||
<p align="center">
|
||||
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
||||
@@ -381,12 +383,12 @@ nanobot gateway
|
||||
|
||||
**3. Open the WebUI**
|
||||
|
||||
Visit [`http://127.0.0.1:8765`](http://127.0.0.1:8765) in your browser. To open it from another device on your LAN, see [WebUI docs → LAN access](./webui/README.md#access-from-another-device-lan).
|
||||
Visit [`http://127.0.0.1:8765`](http://127.0.0.1:8765) in your browser. To open it from another device on your LAN, see [WebUI docs -> LAN access](./docs/webui.md#lan-access).
|
||||
|
||||
The WebUI is served by the WebSocket channel on port `8765` by default. The gateway's `18790` port is for the health endpoint, not the browser UI.
|
||||
|
||||
> [!TIP]
|
||||
> Working on the WebUI itself? Check out [`webui/README.md`](./webui/README.md) for the Vite dev server (HMR) workflow.
|
||||
> Working on the WebUI itself? Check out [`webui/README.md`](./webui/README.md) for the source-tree, Vite dev server, build, and test workflow.
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
|
||||
+3
-3
@@ -16,7 +16,7 @@ If you find a docs mistake, outdated command, or confusing step, please open an
|
||||
|---|---|---|
|
||||
| New to terminals and config files | [`start-without-technical-background.md`](./start-without-technical-background.md) | [`troubleshooting.md`](./troubleshooting.md) if the first reply fails |
|
||||
| Comfortable pasting commands and JSON | [`quick-start.md`](./quick-start.md) | [`provider-cookbook.md`](./provider-cookbook.md) for pasteable provider setups |
|
||||
| Operating a long-running bot | [`concepts.md`](./concepts.md) | [`chat-apps.md`](./chat-apps.md), [`../webui/README.md`](../webui/README.md), and [`deployment.md`](./deployment.md) |
|
||||
| Operating a long-running bot | [`concepts.md`](./concepts.md) | [`chat-apps.md`](./chat-apps.md), [`webui.md`](./webui.md), and [`deployment.md`](./deployment.md) |
|
||||
| Integrating or extending nanobot | [`architecture.md`](./architecture.md) | [`configuration.md`](./configuration.md), [`openai-api.md`](./openai-api.md), [`python-sdk.md`](./python-sdk.md), [`development.md`](./development.md), and [`channel-plugin-guide.md`](./channel-plugin-guide.md) |
|
||||
|
||||
## Start Here
|
||||
@@ -38,7 +38,7 @@ If a local `nanobot agent` session can already answer normally, you can also ask
|
||||
|
||||
| Next goal | Read | First check |
|
||||
|---|---|---|
|
||||
| Use nanobot in a browser | [`../webui/README.md`](../webui/README.md) | Enable WebSocket, run `nanobot gateway`, open `http://127.0.0.1:8765` |
|
||||
| Use nanobot in a browser | [`webui.md`](./webui.md) | Enable WebSocket, run `nanobot gateway`, open `http://127.0.0.1:8765` |
|
||||
| Talk through a chat app | [`chat-apps.md`](./chat-apps.md) | Merge one channel snippet, run `nanobot channels status`, keep `nanobot gateway` running |
|
||||
| Change provider or add fallbacks | [`provider-cookbook.md`](./provider-cookbook.md) | Keep `modelPresets` named and set `agents.defaults.modelPreset` |
|
||||
| Understand before operating long-term | [`concepts.md`](./concepts.md) | Know what config, workspace, gateway, sessions, memory, and tools mean |
|
||||
@@ -48,7 +48,7 @@ If a local `nanobot agent` session can already answer normally, you can also ask
|
||||
|
||||
| Goal | Read | Outcome |
|
||||
|---|---|---|
|
||||
| Open the bundled browser UI | [`../webui/README.md`](../webui/README.md) | WebUI on port `8765`, or Vite HMR when developing the frontend |
|
||||
| Open the bundled browser UI | [`webui.md`](./webui.md) | WebUI on port `8765`, chat workspace, Apps, Skills, Automations, and settings |
|
||||
| Connect Telegram, Discord, WeChat, Slack, and other apps | [`chat-apps.md`](./chat-apps.md) | A gateway-backed chat channel with access control |
|
||||
| Use slash commands and periodic tasks | [`chat-commands.md`](./chat-commands.md) | Pairing, model presets, heartbeat tasks, and chat-side controls |
|
||||
| Generate images | [`image-generation.md`](./image-generation.md) | Image provider config, WebUI image mode, and artifact behavior |
|
||||
|
||||
@@ -108,7 +108,8 @@ WebUI source lives in `webui/`. The production build is written to `nanobot/web/
|
||||
|
||||
Useful docs:
|
||||
|
||||
- [`../webui/README.md`](../webui/README.md) for WebUI use and development;
|
||||
- [`webui.md`](./webui.md) for the WebUI user guide;
|
||||
- [`../webui/README.md`](../webui/README.md) for frontend source development;
|
||||
- [`websocket.md`](./websocket.md) for protocol details.
|
||||
|
||||
## Tools
|
||||
|
||||
@@ -1865,7 +1865,7 @@ When a user is idle for longer than a configured threshold, nanobot **proactivel
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `agents.defaults.idleCompactAfterMinutes` | `0` (disabled) | Minutes of idle time before auto-compaction starts. Set to `0` to disable. Recommended: `15` — close to a typical LLM KV cache expiry window, so stale sessions get compacted before the user returns. |
|
||||
| `agents.defaults.idleCompactAfterMinutes` | `15` | Minutes of idle time before auto-compaction starts. Set to `0` to disable. The default is close to a typical LLM KV cache expiry window, so stale sessions get compacted before the user returns. |
|
||||
|
||||
`sessionTtlMinutes` remains accepted as a legacy alias for backward compatibility, but `idleCompactAfterMinutes` is the preferred config key going forward.
|
||||
|
||||
@@ -1880,7 +1880,7 @@ How it works:
|
||||
>
|
||||
> Concretely, auto compact rewrites `sessions/<key>.jsonl` in place: older messages (including their structured `tool_calls` / `tool_call_id` / `reasoning_content`) are replaced by just the retained recent suffix (currently 8 messages), while the archived prefix is preserved only as a plain-text summary appended to `memory/history.jsonl` (or a `[RAW] ...` flattened dump if LLM summarization fails). The original structured JSON of those turns is no longer recoverable from the session file.
|
||||
>
|
||||
> This differs from the **token-driven soft consolidation** that fires when a prompt exceeds the context budget: that path only advances an internal `last_consolidated` cursor and leaves the session file untouched, so the raw tool-call trail stays on disk and can still be replayed or audited. If you rely on that trail for debugging or auditing, leave `idleCompactAfterMinutes` at the default `0` and let only the token-driven path run.
|
||||
> This differs from the **token-driven soft consolidation** that fires when a prompt exceeds the context budget: that path only advances an internal `last_consolidated` cursor and leaves the session file untouched, so the raw tool-call trail stays on disk and can still be replayed or audited. If you rely on that trail for debugging or auditing, set `idleCompactAfterMinutes` to `0` and let only the token-driven path run.
|
||||
|
||||
## Timezone
|
||||
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ Restart the deployed process after editing `config.json`. Long-running processes
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> When the WebSocket `host` is `0.0.0.0`, the channel refuses to start unless `token` or `tokenIssueSecret` is also configured — see [`webui/README.md`](../webui/README.md) for details.
|
||||
> When the WebSocket `host` is `0.0.0.0`, the channel refuses to start unless `token` or `tokenIssueSecret` is also configured. See [`webui.md#lan-access`](./webui.md#lan-access) for details.
|
||||
|
||||
### Docker Compose
|
||||
|
||||
|
||||
+16
-5
@@ -23,7 +23,7 @@ Pick one install method.
|
||||
**One-command setup:**
|
||||
|
||||
```bash
|
||||
sh -c "$(curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh)"
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
On Windows PowerShell:
|
||||
@@ -32,12 +32,12 @@ On Windows PowerShell:
|
||||
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. If you finish the wizard and save the config, skip the manual initialize/configure steps and go straight to [Check the Setup](#4-check-the-setup).
|
||||
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If you finish the wizard and save the config, skip the manual initialize/configure steps and go straight to [Check the Setup](#4-check-the-setup).
|
||||
|
||||
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
|
||||
|
||||
```bash
|
||||
sh -c "$(curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh)" -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dry-run
|
||||
```
|
||||
|
||||
```powershell
|
||||
@@ -47,7 +47,7 @@ sh -c "$(curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts
|
||||
To install the current `main` branch instead, pass `--dev`:
|
||||
|
||||
```bash
|
||||
sh -c "$(curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh)" -- --dev
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dev
|
||||
```
|
||||
|
||||
```powershell
|
||||
@@ -72,6 +72,8 @@ python -m pip install nanobot-ai
|
||||
nanobot --version
|
||||
```
|
||||
|
||||
Use pip only inside an environment you control. If pip reports `externally-managed-environment` on macOS or Linux, use the one-command installer, `uv tool install nanobot-ai`, `pipx install nanobot-ai`, or create a virtual environment first.
|
||||
|
||||
**Latest source checkout:**
|
||||
|
||||
```bash
|
||||
@@ -271,7 +273,7 @@ Exit interactive mode with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
||||
| Understand config, workspace, gateway, channels, memory, and tools | [`concepts.md`](./concepts.md) |
|
||||
| Copy another provider or local model setup | [`provider-cookbook.md`](./provider-cookbook.md) |
|
||||
| Understand provider/model matching | [`providers.md`](./providers.md) |
|
||||
| Open the bundled browser UI | [`../webui/README.md`](../webui/README.md) |
|
||||
| Open the bundled browser UI | [`webui.md`](./webui.md) |
|
||||
| Connect Telegram, Discord, WeChat, Slack, Email, or another chat app | [`chat-apps.md`](./chat-apps.md) |
|
||||
| Configure web search, MCP, security, memory, gateway, or runtime settings | [`configuration.md`](./configuration.md) |
|
||||
| Run with Docker, systemd, or LaunchAgent | [`deployment.md`](./deployment.md) |
|
||||
@@ -286,6 +288,8 @@ python -m pip install -U nanobot-ai
|
||||
nanobot --version
|
||||
```
|
||||
|
||||
If pip reports `externally-managed-environment`, upgrade with the same isolated method you used to install nanobot, such as `uv tool upgrade nanobot-ai`, `pipx upgrade nanobot-ai`, or the managed venv created by the one-command installer.
|
||||
|
||||
**uv:**
|
||||
|
||||
```bash
|
||||
@@ -293,6 +297,13 @@ uv tool upgrade nanobot-ai
|
||||
nanobot --version
|
||||
```
|
||||
|
||||
**pipx:**
|
||||
|
||||
```bash
|
||||
pipx upgrade nanobot-ai
|
||||
nanobot --version
|
||||
```
|
||||
|
||||
**Source checkout:**
|
||||
|
||||
```bash
|
||||
|
||||
@@ -76,12 +76,12 @@ An OpenRouter key usually starts with `sk-or-v1-`. Other providers use different
|
||||
|
||||
## 4. Install nanobot
|
||||
|
||||
The easiest path is the one-command installer. It installs or upgrades nanobot, then starts the setup wizard.
|
||||
The easiest path is the one-command installer. It installs or upgrades nanobot, then starts the setup wizard. On macOS and Linux it avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`.
|
||||
|
||||
**macOS / Linux**
|
||||
|
||||
```bash
|
||||
sh -c "$(curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh)"
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
**Windows PowerShell**
|
||||
@@ -93,7 +93,7 @@ irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | i
|
||||
These commands install the stable PyPI package. To preview what the installer would do without changing your environment, pass `--dry-run`:
|
||||
|
||||
```bash
|
||||
sh -c "$(curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh)" -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dry-run
|
||||
```
|
||||
|
||||
```powershell
|
||||
@@ -103,21 +103,29 @@ sh -c "$(curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts
|
||||
Use the development installer only when a maintainer asks you to test the current `main` branch:
|
||||
|
||||
```bash
|
||||
sh -c "$(curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh)" -- --dev
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dev
|
||||
```
|
||||
|
||||
```powershell
|
||||
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dev
|
||||
```
|
||||
|
||||
If the command says `curl` or `irm` is not found, or it cannot download from GitHub, use the manual install command below.
|
||||
If the command says `curl` or `irm` is not found, or it cannot download from GitHub, use one of the manual install commands below.
|
||||
|
||||
If you prefer to install manually, run:
|
||||
If `uv` is installed, use:
|
||||
|
||||
```bash
|
||||
uv tool install nanobot-ai
|
||||
```
|
||||
|
||||
If you prefer pip, use it only inside an environment you control:
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
```
|
||||
|
||||
If pip reports `externally-managed-environment` on macOS or Linux, go back to the one-command installer, use `uv tool install nanobot-ai`, use `pipx install nanobot-ai`, or create a virtual environment first.
|
||||
|
||||
Then check that nanobot is installed:
|
||||
|
||||
```bash
|
||||
@@ -393,7 +401,7 @@ nanobot gateway
|
||||
|
||||
To stop the WebUI later, return to the gateway terminal and press `Ctrl+C`.
|
||||
|
||||
If `nanobot` is not found, run `python -m nanobot gateway`, `python3 -m nanobot gateway`, or `py -m nanobot gateway`, matching the Python command that worked earlier. More details are in [`../webui/README.md`](../webui/README.md).
|
||||
If `nanobot` is not found, run `python -m nanobot gateway`, `python3 -m nanobot gateway`, or `py -m nanobot gateway`, matching the Python command that worked earlier. More details are in [`webui.md`](./webui.md).
|
||||
|
||||
### Connect a Chat App
|
||||
|
||||
|
||||
@@ -65,14 +65,14 @@ Use the same Python command for install checks and module fallback. On macOS/Lin
|
||||
| Symptom | Check |
|
||||
|---|---|
|
||||
| `python: command not found` | Try `python3 --version` on macOS/Linux or `py --version` on Windows. Then replace `python` in docs commands with the command that worked. |
|
||||
| `curl: command not found` | The macOS/Linux one-command installer could not download the script. Install curl, or use manual install: `python -m pip install nanobot-ai`, replacing `python` with `python3` if needed. |
|
||||
| `irm` is not recognized | PowerShell could not run the download helper. Use manual install: `python -m pip install nanobot-ai`, or `py -m pip install nanobot-ai` on Windows. |
|
||||
| `curl: command not found` | The macOS/Linux one-command installer could not download the script. Install curl, or use a manual isolated install such as `uv tool install nanobot-ai` or `pipx install nanobot-ai`. |
|
||||
| `irm` is not recognized | PowerShell could not run the download helper. Use manual install: `uv tool install nanobot-ai`, `pipx install nanobot-ai`, or `py -m pip install nanobot-ai` inside an environment you control. |
|
||||
| Could not download `raw.githubusercontent.com` | Your network, proxy, or firewall blocked the installer script download. Use manual install from PyPI, or configure your proxy and rerun the command. |
|
||||
| `nanobot: command not found` | Use the module form, for example `python -m nanobot ...`, `python3 -m nanobot ...`, or `py -m nanobot ...`. Reinstall with the same Python command, or add that Python's scripts directory to `PATH`. |
|
||||
| `No module named nanobot` | You are running a different Python than the one used for installation. Run `python -m pip show nanobot-ai`, `python3 -m pip show nanobot-ai`, or `py -m pip show nanobot-ai`, matching the command that installed nanobot. |
|
||||
| `pip is not available` | The installer tries `python -m ensurepip --upgrade` first. If that fails, install pip for that Python, or use a Python installer/distribution that includes pip. |
|
||||
| `externally-managed-environment` | Your system Python blocks global pip installs. The one-command installer retries with `--user`; if that still fails, create a virtual environment or install with `uv`/`pipx`. |
|
||||
| Installer chose the wrong Python | Set `PYTHON` before running the installer, such as `PYTHON=python3 sh -c "$(curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh)"` or `$env:PYTHON="py"` before the PowerShell command. |
|
||||
| `pip is not available` | When the installer uses a virtual environment, it tries `python -m ensurepip --upgrade`. If that fails, install pip for that Python, or use a Python installer/distribution that includes pip. |
|
||||
| `externally-managed-environment` | Your system Python blocks global pip installs. Use the one-command installer, `uv tool install nanobot-ai`, `pipx install nanobot-ai`, or create a virtual environment; do not add `--break-system-packages` for nanobot. |
|
||||
| Installer chose the wrong Python | Set `PYTHON` before running the installer, such as `curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | PYTHON=python3 sh` or `$env:PYTHON="py"` before the PowerShell command. |
|
||||
| Editable source install does not update | From the repo root, run `python -m pip install -e .` again with the Python command used for development, then check `python -m nanobot --version` or `nanobot --version`. |
|
||||
| WebUI build tools missing | They are only needed for WebUI development. Packaged installs already include the WebUI bundle. |
|
||||
|
||||
@@ -205,7 +205,7 @@ http://127.0.0.1:8765
|
||||
|
||||
If accessing from another device, bind the WebSocket channel to `0.0.0.0` and set `token` or `tokenIssueSecret`. The WebSocket channel refuses public binds without a token or token issue secret.
|
||||
|
||||
See [`../webui/README.md`](../webui/README.md) for LAN and development setup.
|
||||
See [`webui.md#lan-access`](./webui.md#lan-access) for LAN setup and [`../webui/README.md`](../webui/README.md) for frontend development.
|
||||
|
||||
## Chat App Problems
|
||||
|
||||
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
# WebUI
|
||||
|
||||
The WebUI is nanobot's browser workbench. Use it after a basic CLI reply already
|
||||
works, when you want a persistent chat workspace, visible agent activity,
|
||||
workspace controls, Apps, Skills, settings, and Automations in one place.
|
||||
|
||||
The published `nanobot-ai` wheel already includes the WebUI bundle. You only need
|
||||
the `webui/` source directory when you are changing the frontend itself.
|
||||
|
||||
## Open the WebUI
|
||||
|
||||
First confirm your provider and model can answer:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
Then merge the WebSocket channel into your existing `~/.nanobot/config.json`:
|
||||
|
||||
```json
|
||||
{ "channels": { "websocket": { "enabled": true } } }
|
||||
```
|
||||
|
||||
If you are new to JSON snippets, see
|
||||
[`start-without-technical-background.md#how-to-merge-json-snippets`](./start-without-technical-background.md#how-to-merge-json-snippets).
|
||||
|
||||
Start the gateway:
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
Leave the gateway running and open
|
||||
[`http://127.0.0.1:8765`](http://127.0.0.1:8765). The WebUI is served by the
|
||||
WebSocket channel on port `8765` by default. The gateway health endpoint,
|
||||
`18790` by default, is not the browser UI.
|
||||
|
||||
## What It Is For
|
||||
|
||||
| Area | Use it for |
|
||||
|---|---|
|
||||
| Chat | Start, switch, search, fork, and delete browser sessions |
|
||||
| Agent activity | See thinking, tool calls, file activity, command output, and generated artifacts in context |
|
||||
| Workspace | Pick the project workspace before asking for file or shell work |
|
||||
| Access | Choose the access mode for local capabilities allowed by your gateway configuration |
|
||||
| Composer | Send text, images, voice input, slash commands, and `@` mentions for Apps or MCP presets |
|
||||
| Apps | Install, test, update, and use local CLI App adapters and MCP presets |
|
||||
| Skills | Inspect available built-in and workspace skills before relying on them |
|
||||
| Automations | Review, search, run, pause, edit, and delete scheduled agent turns |
|
||||
| Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options |
|
||||
|
||||
## Chat Workspace
|
||||
|
||||
The sidebar is the session switcher. A session keeps its own history, title,
|
||||
workspace metadata, and linked automations. Use a new session when you want a
|
||||
separate context; use fork when you want to continue from an existing point
|
||||
without changing the original thread.
|
||||
|
||||
The message timeline shows both user-visible replies and agent activity. Long
|
||||
tool or reasoning sections can be expanded when you need the details.
|
||||
|
||||
## Workspace and Access
|
||||
|
||||
Use the workspace picker before starting project-specific work. This gives the
|
||||
agent the right project context for file paths, shell commands, and session
|
||||
metadata.
|
||||
|
||||
The access control in the composer controls the local capability level for the
|
||||
chat. It does not bypass your gateway, provider, shell sandbox, or operating
|
||||
system configuration; it only selects among the capabilities that are already
|
||||
available to this WebUI session.
|
||||
|
||||
## Composer
|
||||
|
||||
The composer supports plain messages, image attachments, voice input when
|
||||
transcription is configured, slash commands, and `@` mentions for installed Apps
|
||||
or MCP presets. The model badge shows the current model or preset and links back
|
||||
to model settings when setup is incomplete.
|
||||
|
||||
For image generation, configure an image provider first and then use the WebUI
|
||||
image mode from the composer. See [`image-generation.md`](./image-generation.md)
|
||||
for provider setup and output behavior.
|
||||
|
||||
## Apps
|
||||
|
||||
Open Apps from the sidebar or settings navigation to manage integrations that
|
||||
nanobot can call from a chat. CLI Apps install local adapters that nanobot runs
|
||||
on your machine; they do not modify the native apps themselves. MCP presets add
|
||||
predefined MCP server configurations.
|
||||
|
||||
After an App or MCP preset is available, mention it from the composer with `@`
|
||||
to attach that capability to the next message.
|
||||
|
||||
## Skills
|
||||
|
||||
The Skills view shows the skill instructions available to the agent, including
|
||||
built-in skills and workspace-provided skills. Check this view when you want to
|
||||
know whether nanobot already has a focused workflow for a task before you ask it
|
||||
to perform that task.
|
||||
|
||||
## Automations
|
||||
|
||||
Automations are scheduled agent turns. They should be created from the chat,
|
||||
channel, or session where they are supposed to run so nanobot keeps the correct
|
||||
target context.
|
||||
|
||||
Use the Automations view to:
|
||||
|
||||
- Filter by all, active, paused, needs-attention, or system jobs.
|
||||
- Search by task name, message, linked chat, schedule, or status.
|
||||
- Sort by next run, last run, updated time, or name.
|
||||
- Run now, pause or resume, edit, or delete user-created automations.
|
||||
- Inspect protected system automations without changing them.
|
||||
|
||||
Search accepts plain text and field filters such as `name:backup`,
|
||||
`chat:WeChat`, `schedule:09:30`, `cron:"0 23 * * *"`, and `status:paused`.
|
||||
|
||||
An automation without a linked chat cannot be enabled or run from the WebUI,
|
||||
because nanobot would not know where to deliver the scheduled turn. Recreate it
|
||||
from the target chat or channel so the automation has complete context.
|
||||
|
||||
## Settings
|
||||
|
||||
Settings is the control surface for the browser session and gateway-backed
|
||||
runtime configuration. Use it to review or adjust model presets, provider
|
||||
visibility, image generation, voice transcription, web tools, Apps, Automations,
|
||||
Skills, runtime identity, and advanced safety controls.
|
||||
|
||||
Some settings take effect immediately. Runtime settings that affect the gateway
|
||||
or agent process may require a restart; the WebUI shows that requirement next to
|
||||
the relevant control.
|
||||
|
||||
## LAN Access
|
||||
|
||||
To open the WebUI from another device on the same network, bind the WebSocket
|
||||
channel to all interfaces and set a token or token issue secret:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"enabled": true,
|
||||
"host": "0.0.0.0",
|
||||
"port": 8765,
|
||||
"tokenIssueSecret": "your-secret-here"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The gateway refuses to start with `host` set to `"0.0.0.0"` unless `token` or
|
||||
`tokenIssueSecret` is configured. After the gateway starts, open
|
||||
`http://<your-ip>:8765` from the other device and enter the secret in the login
|
||||
form.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If the page does not open, check these in order:
|
||||
|
||||
1. `nanobot agent -m "Hello!"` works in the same Python environment.
|
||||
2. The WebSocket channel is enabled in `~/.nanobot/config.json`.
|
||||
3. `nanobot gateway` is still running.
|
||||
4. You are opening port `8765`, not the gateway health port.
|
||||
5. LAN access uses `host: "0.0.0.0"` and a token or token issue secret.
|
||||
|
||||
For detailed diagnostics, see
|
||||
[`troubleshooting.md#webui-problems`](./troubleshooting.md#webui-problems).
|
||||
For frontend development, see [`../webui/README.md`](../webui/README.md).
|
||||
@@ -17,7 +17,7 @@ from nanobot.utils.helpers import (
|
||||
current_time_str,
|
||||
detect_image_mime,
|
||||
load_bundled_template,
|
||||
truncate_text,
|
||||
truncate_text_to_tokens,
|
||||
)
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
|
||||
@@ -29,7 +29,7 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
|
||||
def runtime_lines(state: Any, msg: Any, workspace: Path, *, skip: bool = False) -> list[str]:
|
||||
"""Return model-visible runtime annotations for turn-attached capabilities."""
|
||||
return [
|
||||
lines = [
|
||||
*cli_app_utils.runtime_lines(msg, workspace, skip=skip),
|
||||
*mcp_tools.runtime_lines(
|
||||
msg,
|
||||
@@ -38,6 +38,11 @@ def runtime_lines(state: Any, msg: Any, workspace: Path, *, skip: bool = False)
|
||||
skip=skip,
|
||||
),
|
||||
]
|
||||
if not skip and getattr(state, "subagents", None) is not None:
|
||||
session_key = getattr(msg, "session_key", None)
|
||||
if session_key:
|
||||
lines.extend(state.subagents.runtime_status_lines(session_key))
|
||||
return lines
|
||||
|
||||
|
||||
async def connect_mcp(state: Any, tools: ToolRegistry) -> None:
|
||||
@@ -54,7 +59,7 @@ class ContextBuilder:
|
||||
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]
|
||||
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
|
||||
_MAX_RECENT_HISTORY = 50
|
||||
_MAX_HISTORY_CHARS = 32_000 # hard cap on recent history section size
|
||||
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
|
||||
_RUNTIME_CONTEXT_END = "[/Runtime Context]"
|
||||
|
||||
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
|
||||
@@ -108,7 +113,7 @@ class ContextBuilder:
|
||||
history_text = "\n".join(
|
||||
f"- [{e['timestamp']}] {e['content']}" for e in capped
|
||||
)
|
||||
history_text = truncate_text(history_text, self._MAX_HISTORY_CHARS)
|
||||
history_text = truncate_text_to_tokens(history_text, self._MAX_HISTORY_TOKENS)
|
||||
parts.append("# Recent History\n\n" + history_text)
|
||||
|
||||
if session_summary:
|
||||
|
||||
+52
-38
@@ -25,6 +25,10 @@ from nanobot.agent.memory import Consolidator
|
||||
from nanobot.agent.progress_hook import AgentProgressHook
|
||||
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.agent.subagent_delivery import (
|
||||
build_subagent_result_continuation,
|
||||
materialize_subagent_result_continuation,
|
||||
)
|
||||
from nanobot.agent.tools.context import RequestContext, bind_request_context, reset_request_context
|
||||
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
@@ -65,7 +69,6 @@ from nanobot.utils.image_generation_intent import image_generation_prompt
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
from nanobot.utils.runtime import (
|
||||
EMPTY_FINAL_RESPONSE_MESSAGE,
|
||||
SUSTAINED_GOAL_CONTINUE_PROMPT,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -288,6 +291,7 @@ class AgentLoop:
|
||||
max_iterations=self.max_iterations,
|
||||
max_concurrent_subagents=max_concurrent_subagents,
|
||||
llm_wall_timeout_for_session=lambda sk: runner_wall_llm_timeout_s(self.sessions, sk),
|
||||
on_result_ready=self._on_subagent_result_ready,
|
||||
)
|
||||
self._unified_session = unified_session
|
||||
self._max_messages = max_messages if max_messages > 0 else 120
|
||||
@@ -549,6 +553,21 @@ class AgentLoop:
|
||||
"""Build a progress callback that publishes to the message bus."""
|
||||
return build_bus_progress_callback(self.bus, msg)
|
||||
|
||||
async def _on_subagent_result_ready(self, result: Any) -> None:
|
||||
"""Wake the owning session when a subagent result becomes ready."""
|
||||
msg = build_subagent_result_continuation(result)
|
||||
queue = self._pending_queues.get(result.session_key)
|
||||
if queue is not None:
|
||||
try:
|
||||
queue.put_nowait(msg)
|
||||
return
|
||||
except asyncio.QueueFull:
|
||||
logger.warning(
|
||||
"Pending queue full for subagent result in session {}; queueing fresh turn",
|
||||
result.session_key,
|
||||
)
|
||||
await self.bus.publish_inbound(msg)
|
||||
|
||||
async def _build_retry_wait_callback(
|
||||
self, msg: InboundMessage
|
||||
) -> Callable[[str], Awaitable[None]]:
|
||||
@@ -732,11 +751,9 @@ class AgentLoop:
|
||||
async def _drain_pending(*, limit: int = _MAX_INJECTIONS_PER_TURN) -> list[dict[str, Any]]:
|
||||
"""Drain follow-up messages from the pending queue.
|
||||
|
||||
When no messages are immediately available but sub-agents
|
||||
spawned in this dispatch are still running, blocks until at
|
||||
least one result arrives (or timeout). This keeps the runner
|
||||
loop alive so subsequent sub-agent completions are consumed
|
||||
in-order rather than dispatched separately.
|
||||
This path is only for real same-session user follow-up messages.
|
||||
Worker results are read explicitly through the subagent mailbox
|
||||
tools instead of being injected as ordinary inbound messages.
|
||||
"""
|
||||
if pending_queue is None:
|
||||
return []
|
||||
@@ -753,30 +770,15 @@ class AgentLoop:
|
||||
items: list[dict[str, Any]] = []
|
||||
while len(items) < limit:
|
||||
try:
|
||||
items.append(_to_user_message(pending_queue.get_nowait()))
|
||||
pending_msg = pending_queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
|
||||
# Block if nothing drained but sub-agents spawned in this dispatch
|
||||
# are still running. Keeps the runner loop alive so subsequent
|
||||
# completions are injected in-order rather than dispatched separately.
|
||||
if (not items
|
||||
and session is not None
|
||||
and self.subagents.get_running_count_by_session(session.key) > 0):
|
||||
try:
|
||||
msg = await asyncio.wait_for(pending_queue.get(), timeout=300)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"Timeout waiting for sub-agent completion in session {}",
|
||||
session.key,
|
||||
)
|
||||
return items
|
||||
items.append(_to_user_message(msg))
|
||||
while len(items) < limit:
|
||||
try:
|
||||
items.append(_to_user_message(pending_queue.get_nowait()))
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
pending_msg = await materialize_subagent_result_continuation(
|
||||
pending_msg,
|
||||
session_key=active_session_key or pending_msg.session_key,
|
||||
subagents=self.subagents,
|
||||
)
|
||||
items.append(_to_user_message(pending_msg))
|
||||
|
||||
return items
|
||||
|
||||
@@ -796,15 +798,18 @@ class AgentLoop:
|
||||
file_state_token = bind_file_states(self._file_state_store.for_session(active_session_key))
|
||||
request_token = bind_request_context(request_ctx)
|
||||
workspace_token = bind_workspace_scope(effective_scope)
|
||||
# Build continuation message that embeds the active goal objective so
|
||||
# the LLM can see it even if earlier Runtime Context was truncated.
|
||||
_goal_lines = goal_state_runtime_lines(session.metadata if session is not None else None)
|
||||
_goal_continue = (
|
||||
"You have an active sustained goal:\n\n"
|
||||
+ "\n".join(_goal_lines)
|
||||
+ "\n\nPlease continue working toward the objective using your tools, "
|
||||
"or call complete_goal if the work is truly finished."
|
||||
) if _goal_lines else SUSTAINED_GOAL_CONTINUE_PROMPT
|
||||
# Compute lazily because long_task may create goal metadata during this run.
|
||||
def _goal_continue() -> str | None:
|
||||
_goal_lines = goal_state_runtime_lines(session.metadata if session is not None else None)
|
||||
if not _goal_lines:
|
||||
return None
|
||||
return (
|
||||
"You have an active sustained goal:\n\n"
|
||||
+ "\n".join(_goal_lines)
|
||||
+ "\n\nPlease continue working toward the objective using your tools, "
|
||||
"or call complete_goal if the work is truly finished."
|
||||
)
|
||||
|
||||
session_metadata = session.metadata if session is not None else None
|
||||
try:
|
||||
result = await self.runner.run(AgentRunSpec(
|
||||
@@ -1430,6 +1435,11 @@ class AgentLoop:
|
||||
ctx.session,
|
||||
replay_max_messages=self._max_messages,
|
||||
)
|
||||
ctx.msg = await materialize_subagent_result_continuation(
|
||||
ctx.msg,
|
||||
session_key=ctx.session_key,
|
||||
subagents=self.subagents,
|
||||
)
|
||||
self._set_tool_context(
|
||||
ctx.msg.channel,
|
||||
ctx.msg.chat_id,
|
||||
@@ -1808,12 +1818,16 @@ class AgentLoop:
|
||||
on_stream_end: Callable[..., Awaitable[None]] | None = None,
|
||||
ephemeral: bool = False,
|
||||
tools: ToolRegistry | None = None,
|
||||
persist_user_message: bool = True,
|
||||
) -> OutboundMessage | None:
|
||||
"""Process a message directly and return the outbound payload."""
|
||||
await self._connect_mcp()
|
||||
metadata: dict[str, Any] = {}
|
||||
if not persist_user_message:
|
||||
metadata[turn_continuation.SKIP_USER_PERSIST_META] = True
|
||||
msg = InboundMessage(
|
||||
channel=channel, sender_id="user", chat_id=chat_id,
|
||||
content=content, media=media or [],
|
||||
content=content, media=media or [], metadata=metadata,
|
||||
)
|
||||
# Share the dispatch lock so direct calls serialize with bus turns.
|
||||
lock = self._session_locks.setdefault(session_key, asyncio.Lock())
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
"""Durable mailbox primitives for manager-worker task coordination."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.utils.helpers import ensure_dir, safe_filename
|
||||
|
||||
TaskState = str # running | completed | failed | cancelled
|
||||
MailboxReadState = str # ready | running | not_found | consumed | timeout
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TaskRequest:
|
||||
"""Task request recorded when the manager dispatches a worker."""
|
||||
|
||||
task_id: str
|
||||
session_key: str
|
||||
label: str
|
||||
task: str
|
||||
origin: dict[str, Any] = field(default_factory=dict)
|
||||
created_at: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TaskResult:
|
||||
"""Worker result written to the manager mailbox."""
|
||||
|
||||
task_id: str
|
||||
session_key: str
|
||||
label: str
|
||||
task: str
|
||||
status: str
|
||||
content: str
|
||||
sender: str = "subagent"
|
||||
completed_at: float = field(default_factory=time.time)
|
||||
dedupe_key: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TaskSnapshot:
|
||||
"""Read-only view of a task in the mailbox."""
|
||||
|
||||
task_id: str
|
||||
session_key: str
|
||||
label: str
|
||||
task: str
|
||||
state: TaskState
|
||||
created_at: float
|
||||
completed_at: float | None = None
|
||||
consumed_at: float | None = None
|
||||
result_status: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MailboxRead:
|
||||
"""Result of a mailbox wait/consume operation."""
|
||||
|
||||
state: MailboxReadState
|
||||
task: TaskSnapshot | None = None
|
||||
result: TaskResult | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _TaskRecord:
|
||||
request: TaskRequest
|
||||
state: TaskState = "running"
|
||||
result: TaskResult | None = None
|
||||
consumed_at: float | None = None
|
||||
completed_at: float | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class MailboxStore:
|
||||
"""Durable task mailbox for local subagent coordination.
|
||||
|
||||
JSON files are the source of truth. The condition variable only wakes
|
||||
waiters inside this process; persisted records remain readable after a
|
||||
manager restart.
|
||||
"""
|
||||
|
||||
def __init__(self, workspace: str | Path, *, root: str | Path | None = None) -> None:
|
||||
base = Path(root).expanduser() if root is not None else Path(workspace) / "tasks" / "subagents"
|
||||
self.root = ensure_dir(base)
|
||||
self._changed = asyncio.Condition()
|
||||
|
||||
async def dispatch(self, request: TaskRequest) -> None:
|
||||
"""Record that a task was dispatched."""
|
||||
async with self._changed:
|
||||
path, record = self._load_by_task_id(request.task_id, session_key=request.session_key)
|
||||
if record is not None:
|
||||
return
|
||||
path = self._record_path(request.session_key, request.task_id)
|
||||
self._write_record(path, _TaskRecord(request=request))
|
||||
self._changed.notify_all()
|
||||
|
||||
async def record_result(self, result: TaskResult) -> bool:
|
||||
"""Record a worker result.
|
||||
|
||||
Returns ``True`` when this call writes a new terminal result and
|
||||
``False`` when the task was already finalized.
|
||||
"""
|
||||
async with self._changed:
|
||||
path, record = self._load_by_task_id(result.task_id, session_key=result.session_key)
|
||||
if record is None:
|
||||
request = TaskRequest(
|
||||
task_id=result.task_id,
|
||||
session_key=result.session_key,
|
||||
label=result.label,
|
||||
task=result.task,
|
||||
origin=dict(result.metadata),
|
||||
created_at=result.completed_at,
|
||||
)
|
||||
record = _TaskRecord(request=request)
|
||||
path = self._record_path(result.session_key, result.task_id)
|
||||
elif record.result is not None or record.state != "running":
|
||||
return False
|
||||
|
||||
record.result = result
|
||||
record.completed_at = result.completed_at
|
||||
record.state = self._state_for_result(result.status)
|
||||
record.error = result.content if result.status in {"error", "cancelled"} else None
|
||||
self._write_record(path, record)
|
||||
self._changed.notify_all()
|
||||
return True
|
||||
|
||||
async def mark_cancelled(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
session_key: str | None = None,
|
||||
reason: str = "Cancelled.",
|
||||
) -> bool:
|
||||
"""Mark a task cancelled and make the cancellation consumable once."""
|
||||
async with self._changed:
|
||||
path, record = self._load_by_task_id(task_id, session_key=session_key)
|
||||
if record is None or record.result is not None or record.state != "running":
|
||||
return False
|
||||
result = TaskResult(
|
||||
task_id=task_id,
|
||||
session_key=record.request.session_key,
|
||||
label=record.request.label,
|
||||
task=record.request.task,
|
||||
status="cancelled",
|
||||
content=reason,
|
||||
dedupe_key=task_id,
|
||||
)
|
||||
record.result = result
|
||||
record.completed_at = result.completed_at
|
||||
record.state = "cancelled"
|
||||
record.error = reason
|
||||
self._write_record(path, record)
|
||||
self._changed.notify_all()
|
||||
return True
|
||||
|
||||
async def poll(
|
||||
self,
|
||||
session_key: str,
|
||||
*,
|
||||
task_id: str | None = None,
|
||||
) -> list[TaskSnapshot]:
|
||||
"""Return snapshots for one task or all tasks in a session."""
|
||||
async with self._changed:
|
||||
return self.snapshot_sync(session_key, task_id=task_id)
|
||||
|
||||
def snapshot_sync(
|
||||
self,
|
||||
session_key: str,
|
||||
*,
|
||||
task_id: str | None = None,
|
||||
) -> list[TaskSnapshot]:
|
||||
"""Synchronous snapshot used while building runtime context."""
|
||||
if task_id is not None:
|
||||
_, record = self._load_by_task_id(task_id, session_key=session_key)
|
||||
if record is None:
|
||||
return []
|
||||
return [self._snapshot(record)]
|
||||
|
||||
records = self._load_session_records(session_key)
|
||||
snapshots = [self._snapshot(record) for record in records]
|
||||
snapshots.sort(key=lambda item: (item.completed_at is None, item.created_at, item.task_id))
|
||||
return snapshots
|
||||
|
||||
async def wait_for_result(
|
||||
self,
|
||||
session_key: str,
|
||||
*,
|
||||
task_id: str | None = None,
|
||||
timeout_seconds: float = 30.0,
|
||||
) -> MailboxRead:
|
||||
"""Wait for and consume a result once."""
|
||||
deadline = time.monotonic() + max(0.0, timeout_seconds)
|
||||
async with self._changed:
|
||||
while True:
|
||||
read = self._consume_ready_locked(session_key, task_id)
|
||||
if read.state != "running":
|
||||
return read
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return MailboxRead("timeout", task=read.task)
|
||||
try:
|
||||
await asyncio.wait_for(self._changed.wait(), timeout=remaining)
|
||||
except asyncio.TimeoutError:
|
||||
return MailboxRead("timeout", task=read.task)
|
||||
|
||||
def _consume_ready_locked(
|
||||
self,
|
||||
session_key: str,
|
||||
task_id: str | None,
|
||||
) -> MailboxRead:
|
||||
if task_id is not None:
|
||||
path, record = self._load_by_task_id(task_id, session_key=session_key)
|
||||
if record is None:
|
||||
return MailboxRead("not_found")
|
||||
snapshot = self._snapshot(record)
|
||||
if record.result is None:
|
||||
return MailboxRead("running", task=snapshot)
|
||||
if record.consumed_at is not None:
|
||||
return MailboxRead("consumed", task=snapshot, result=record.result)
|
||||
record.consumed_at = time.time()
|
||||
self._write_record(path, record)
|
||||
return MailboxRead("ready", task=self._snapshot(record), result=record.result)
|
||||
|
||||
records_with_paths = self._load_session_records_with_paths(session_key)
|
||||
ready = [
|
||||
(path, record)
|
||||
for path, record in records_with_paths
|
||||
if record.result is not None and record.consumed_at is None
|
||||
]
|
||||
if ready:
|
||||
ready.sort(key=lambda item: (
|
||||
item[1].completed_at or item[1].request.created_at,
|
||||
item[1].request.task_id,
|
||||
))
|
||||
path, record = ready[0]
|
||||
record.consumed_at = time.time()
|
||||
self._write_record(path, record)
|
||||
return MailboxRead("ready", task=self._snapshot(record), result=record.result)
|
||||
|
||||
running = [record for _, record in records_with_paths if record.result is None]
|
||||
if running:
|
||||
running.sort(key=lambda record: (record.request.created_at, record.request.task_id))
|
||||
return MailboxRead("running", task=self._snapshot(running[0]))
|
||||
if records_with_paths:
|
||||
records = [record for _, record in records_with_paths]
|
||||
records.sort(key=lambda record: (
|
||||
record.completed_at or record.request.created_at,
|
||||
record.request.task_id,
|
||||
))
|
||||
return MailboxRead("consumed", task=self._snapshot(records[-1]))
|
||||
return MailboxRead("not_found")
|
||||
|
||||
def _session_dir(self, session_key: str) -> Path:
|
||||
return self.root / safe_filename(session_key)
|
||||
|
||||
def _record_path(self, session_key: str, task_id: str) -> Path:
|
||||
return ensure_dir(self._session_dir(session_key)) / f"{safe_filename(task_id)}.json"
|
||||
|
||||
def _load_by_task_id(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
session_key: str | None = None,
|
||||
) -> tuple[Path, _TaskRecord | None]:
|
||||
if session_key is not None:
|
||||
path = self._record_path(session_key, task_id)
|
||||
return path, self._read_record(path)
|
||||
|
||||
filename = f"{safe_filename(task_id)}.json"
|
||||
for path in self.root.glob(f"*/{filename}"):
|
||||
record = self._read_record(path)
|
||||
if record is not None:
|
||||
return path, record
|
||||
return self.root / "_missing" / filename, None
|
||||
|
||||
def _load_session_records(self, session_key: str) -> list[_TaskRecord]:
|
||||
return [record for _, record in self._load_session_records_with_paths(session_key)]
|
||||
|
||||
def _load_session_records_with_paths(self, session_key: str) -> list[tuple[Path, _TaskRecord]]:
|
||||
directory = self._session_dir(session_key)
|
||||
if not directory.exists():
|
||||
return []
|
||||
records: list[tuple[Path, _TaskRecord]] = []
|
||||
for path in directory.glob("*.json"):
|
||||
record = self._read_record(path)
|
||||
if record is not None:
|
||||
records.append((path, record))
|
||||
return records
|
||||
|
||||
def _read_record(self, path: Path) -> _TaskRecord | None:
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return self._record_from_json(data)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _write_record(self, path: Path, record: _TaskRecord) -> None:
|
||||
ensure_dir(path.parent)
|
||||
payload = json.dumps(self._record_to_json(record), ensure_ascii=False, indent=2)
|
||||
tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
||||
try:
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(payload)
|
||||
f.write("\n")
|
||||
with suppress(OSError):
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
with suppress(OSError):
|
||||
fd = os.open(str(path.parent), os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
@staticmethod
|
||||
def _record_to_json(record: _TaskRecord) -> dict[str, Any]:
|
||||
result = record.result
|
||||
return {
|
||||
"version": 1,
|
||||
"task_id": record.request.task_id,
|
||||
"session_key": record.request.session_key,
|
||||
"label": record.request.label,
|
||||
"task": record.request.task,
|
||||
"origin": record.request.origin,
|
||||
"state": record.state,
|
||||
"result": None if result is None else {
|
||||
"task_id": result.task_id,
|
||||
"session_key": result.session_key,
|
||||
"label": result.label,
|
||||
"task": result.task,
|
||||
"status": result.status,
|
||||
"content": result.content,
|
||||
"sender": result.sender,
|
||||
"completed_at": result.completed_at,
|
||||
"dedupe_key": result.dedupe_key,
|
||||
"metadata": result.metadata,
|
||||
},
|
||||
"consumed_at": record.consumed_at,
|
||||
"created_at": record.request.created_at,
|
||||
"completed_at": record.completed_at,
|
||||
"updated_at": time.time(),
|
||||
"error": record.error,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _record_from_json(data: dict[str, Any]) -> _TaskRecord:
|
||||
request = TaskRequest(
|
||||
task_id=str(data["task_id"]),
|
||||
session_key=str(data["session_key"]),
|
||||
label=str(data.get("label") or data["task_id"]),
|
||||
task=str(data.get("task") or ""),
|
||||
origin=dict(data.get("origin") or {}),
|
||||
created_at=float(data.get("created_at") or time.time()),
|
||||
)
|
||||
raw_result = data.get("result")
|
||||
result = None
|
||||
if isinstance(raw_result, dict):
|
||||
result = TaskResult(
|
||||
task_id=str(raw_result.get("task_id") or request.task_id),
|
||||
session_key=str(raw_result.get("session_key") or request.session_key),
|
||||
label=str(raw_result.get("label") or request.label),
|
||||
task=str(raw_result.get("task") or request.task),
|
||||
status=str(raw_result.get("status") or "error"),
|
||||
content=str(raw_result.get("content") or ""),
|
||||
sender=str(raw_result.get("sender") or "subagent"),
|
||||
completed_at=float(raw_result.get("completed_at") or time.time()),
|
||||
dedupe_key=raw_result.get("dedupe_key"),
|
||||
metadata=dict(raw_result.get("metadata") or {}),
|
||||
)
|
||||
return _TaskRecord(
|
||||
request=request,
|
||||
state=str(data.get("state") or "running"),
|
||||
result=result,
|
||||
consumed_at=data.get("consumed_at"),
|
||||
completed_at=data.get("completed_at"),
|
||||
error=data.get("error"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _state_for_result(status: str) -> TaskState:
|
||||
if status == "ok":
|
||||
return "completed"
|
||||
if status == "cancelled":
|
||||
return "cancelled"
|
||||
return "failed"
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(record: _TaskRecord) -> TaskSnapshot:
|
||||
result = record.result
|
||||
return TaskSnapshot(
|
||||
task_id=record.request.task_id,
|
||||
session_key=record.request.session_key,
|
||||
label=record.request.label,
|
||||
task=record.request.task,
|
||||
state=record.state,
|
||||
created_at=record.request.created_at,
|
||||
completed_at=record.completed_at,
|
||||
consumed_at=record.consumed_at,
|
||||
result_status=result.status if result is not None else None,
|
||||
error=record.error,
|
||||
)
|
||||
+25
-11
@@ -13,7 +13,6 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterator
|
||||
|
||||
import tiktoken
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.session.manager import Session
|
||||
@@ -25,6 +24,7 @@ from nanobot.utils.helpers import (
|
||||
find_legal_message_start,
|
||||
strip_think,
|
||||
truncate_text,
|
||||
truncate_text_to_tokens,
|
||||
)
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
|
||||
@@ -61,6 +61,7 @@ class MemoryStore:
|
||||
self._cursor_file = self.memory_dir / ".cursor"
|
||||
self._dream_cursor_file = self.memory_dir / ".dream_cursor"
|
||||
self._corruption_logged = False # rate-limit non-int cursor warning
|
||||
self._malformed_entry_logged = False # rate-limit bad history shape warning
|
||||
self._oversize_logged = False # rate-limit oversized-entry warning
|
||||
self._append_lock = threading.Lock() # serialize cursor allocation + append
|
||||
self._git = GitStore(workspace, tracked_files=[
|
||||
@@ -295,8 +296,9 @@ class MemoryStore:
|
||||
return value
|
||||
|
||||
def _iter_valid_entries(self) -> Iterator[tuple[dict[str, Any], int]]:
|
||||
"""Yield ``(entry, cursor)`` for entries with int cursors; warn once on corruption."""
|
||||
"""Yield ``(entry, cursor)`` for well-formed entries; warn once on corruption."""
|
||||
poisoned: Any = None
|
||||
malformed_cursor: int | None = None
|
||||
for entry in self._read_entries():
|
||||
raw = entry.get("cursor")
|
||||
if raw is None:
|
||||
@@ -305,6 +307,9 @@ class MemoryStore:
|
||||
if cursor is None:
|
||||
poisoned = raw
|
||||
continue
|
||||
if not self._valid_history_payload(entry):
|
||||
malformed_cursor = cursor
|
||||
continue
|
||||
yield entry, cursor
|
||||
if poisoned is not None and not self._corruption_logged:
|
||||
self._corruption_logged = True
|
||||
@@ -313,6 +318,22 @@ class MemoryStore:
|
||||
"Usually caused by an external writer; further occurrences suppressed.",
|
||||
poisoned,
|
||||
)
|
||||
if malformed_cursor is not None and not self._malformed_entry_logged:
|
||||
self._malformed_entry_logged = True
|
||||
logger.warning(
|
||||
"history.jsonl contains a malformed entry at cursor {}; dropping it. "
|
||||
"Usually caused by an external writer; further occurrences suppressed.",
|
||||
malformed_cursor,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _valid_history_payload(entry: dict[str, Any]) -> bool:
|
||||
if not isinstance(entry.get("timestamp"), str):
|
||||
return False
|
||||
if not isinstance(entry.get("content"), str):
|
||||
return False
|
||||
session_key = entry.get("session_key")
|
||||
return session_key is None or isinstance(session_key, str)
|
||||
|
||||
def _next_cursor(self) -> int:
|
||||
"""Read the current cursor counter and return the next value."""
|
||||
@@ -785,14 +806,7 @@ class Consolidator:
|
||||
budget = self._input_token_budget
|
||||
if budget <= 0:
|
||||
return truncate_text(text, _RAW_ARCHIVE_MAX_CHARS)
|
||||
try:
|
||||
enc = tiktoken.get_encoding("cl100k_base")
|
||||
tokens = enc.encode(text)
|
||||
if len(tokens) <= budget:
|
||||
return text
|
||||
return enc.decode(tokens[:budget]) + "\n... (truncated)"
|
||||
except Exception:
|
||||
return truncate_text(text, budget * 4)
|
||||
return truncate_text_to_tokens(text, budget)
|
||||
|
||||
async def archive(
|
||||
self,
|
||||
@@ -985,7 +999,7 @@ class Consolidator:
|
||||
metadata={},
|
||||
last_consolidated=0,
|
||||
)
|
||||
dropped, already_consolidated = probe.retain_recent_legal_suffix(max_suffix)
|
||||
dropped, already_consolidated = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True)
|
||||
messages_to_keep = probe.messages
|
||||
messages_to_remove = dropped[already_consolidated:]
|
||||
|
||||
|
||||
+34
-7
@@ -54,6 +54,8 @@ from nanobot.utils.runtime import (
|
||||
repeated_workspace_violation_error,
|
||||
)
|
||||
|
||||
GoalContinueMessage = str | Callable[[], str | None]
|
||||
|
||||
_DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model."
|
||||
_ARREARAGE_ERROR_MESSAGE = (
|
||||
"The AI provider rejected the request because the API key is out of quota or the "
|
||||
@@ -109,7 +111,7 @@ class AgentRunSpec:
|
||||
injection_callback: Any | None = None
|
||||
llm_timeout_s: float | None = None
|
||||
goal_active_predicate: Callable[[], bool] | None = None
|
||||
goal_continue_message: str | None = None
|
||||
goal_continue_message: GoalContinueMessage | None = None
|
||||
finalize_on_max_iterations: bool = True
|
||||
|
||||
|
||||
@@ -198,7 +200,7 @@ class AgentRunner:
|
||||
if not injections and allow_goal_continue and assistant_message is not None:
|
||||
predicate = spec.goal_active_predicate
|
||||
if predicate is not None and predicate():
|
||||
injections = [build_goal_continue_message(spec.goal_continue_message)]
|
||||
injections = [self._build_goal_continue_message(spec)]
|
||||
if not injections:
|
||||
return False, injection_cycles
|
||||
if real_injection:
|
||||
@@ -227,6 +229,16 @@ class AgentRunner:
|
||||
logger.info("Injected sustained-goal continuation {}", phase)
|
||||
return True, injection_cycles
|
||||
|
||||
def _build_goal_continue_message(self, spec: AgentRunSpec) -> dict[str, str]:
|
||||
custom = spec.goal_continue_message
|
||||
if callable(custom):
|
||||
try:
|
||||
custom = custom()
|
||||
except Exception:
|
||||
logger.exception("goal_continue_message callback failed")
|
||||
custom = None
|
||||
return build_goal_continue_message(custom)
|
||||
|
||||
async def _drain_injections(self, spec: AgentRunSpec) -> list[dict[str, Any]]:
|
||||
"""Drain pending user messages via the injection callback.
|
||||
|
||||
@@ -257,12 +269,17 @@ class AgentRunner:
|
||||
return []
|
||||
injected_messages: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
if isinstance(item, dict) and item.get("role") == "user" and "content" in item:
|
||||
injected_messages.append(item)
|
||||
if item is None:
|
||||
continue
|
||||
text = getattr(item, "content", str(item))
|
||||
if text.strip():
|
||||
injected_messages.append({"role": "user", "content": text})
|
||||
if isinstance(item, dict) and item.get("role") == "user" and "content" in item:
|
||||
if self._has_injection_content(item.get("content")):
|
||||
injected_messages.append(item)
|
||||
continue
|
||||
if isinstance(item, dict):
|
||||
continue
|
||||
content = getattr(item, "content") if hasattr(item, "content") else str(item)
|
||||
if self._has_injection_content(content):
|
||||
injected_messages.append({"role": "user", "content": content})
|
||||
if len(injected_messages) > _MAX_INJECTIONS_PER_TURN:
|
||||
dropped = len(injected_messages) - _MAX_INJECTIONS_PER_TURN
|
||||
logger.warning(
|
||||
@@ -272,6 +289,16 @@ class AgentRunner:
|
||||
injected_messages = injected_messages[:_MAX_INJECTIONS_PER_TURN]
|
||||
return injected_messages
|
||||
|
||||
@staticmethod
|
||||
def _has_injection_content(content: Any) -> bool:
|
||||
if content is None:
|
||||
return False
|
||||
if isinstance(content, str):
|
||||
return bool(content.strip())
|
||||
if isinstance(content, list):
|
||||
return bool(content)
|
||||
return True
|
||||
|
||||
async def run(self, spec: AgentRunSpec) -> AgentRunResult:
|
||||
hook = spec.hook or AgentHook()
|
||||
messages = list(spec.initial_messages)
|
||||
|
||||
+141
-30
@@ -4,19 +4,20 @@ import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||
from nanobot.agent.mailbox import MailboxRead, MailboxStore, TaskRequest, TaskResult, TaskSnapshot
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
from nanobot.agent.tools.context import ToolContext
|
||||
from nanobot.agent.tools.file_state import FileStates
|
||||
from nanobot.agent.tools.loader import ToolLoader
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import AgentDefaults, ToolsConfig
|
||||
from nanobot.providers.base import LLMProvider
|
||||
@@ -87,6 +88,8 @@ class SubagentManager:
|
||||
max_iterations: int | None = None,
|
||||
max_concurrent_subagents: int | None = None,
|
||||
llm_wall_timeout_for_session: Callable[[str | None], float | None] | None = None,
|
||||
mailbox: MailboxStore | None = None,
|
||||
on_result_ready: Callable[[TaskResult], Awaitable[None]] | None = None,
|
||||
):
|
||||
defaults = AgentDefaults()
|
||||
self.provider = provider
|
||||
@@ -109,6 +112,8 @@ class SubagentManager:
|
||||
)
|
||||
self.runner = AgentRunner(provider)
|
||||
self._llm_wall_timeout_for_session = llm_wall_timeout_for_session
|
||||
self.mailbox = mailbox or MailboxStore(workspace)
|
||||
self._on_result_ready = on_result_ready
|
||||
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self._task_statuses: dict[str, SubagentStatus] = {}
|
||||
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
||||
@@ -162,6 +167,7 @@ class SubagentManager:
|
||||
"""Spawn a subagent to execute a task in the background."""
|
||||
task_id = str(uuid.uuid4())[:8]
|
||||
display_label = label or task[:30] + ("..." if len(task) > 30 else "")
|
||||
mailbox_session_key = session_key or f"{origin_channel}:{origin_chat_id}"
|
||||
origin = {"channel": origin_channel, "chat_id": origin_chat_id, "session_key": session_key}
|
||||
|
||||
status = SubagentStatus(
|
||||
@@ -171,6 +177,18 @@ class SubagentManager:
|
||||
started_at=time.monotonic(),
|
||||
)
|
||||
self._task_statuses[task_id] = status
|
||||
await self.mailbox.dispatch(TaskRequest(
|
||||
task_id=task_id,
|
||||
session_key=mailbox_session_key,
|
||||
label=display_label,
|
||||
task=task,
|
||||
origin={
|
||||
"channel": origin_channel,
|
||||
"chat_id": origin_chat_id,
|
||||
"session_key": session_key,
|
||||
"origin_message_id": origin_message_id,
|
||||
},
|
||||
))
|
||||
|
||||
bg_task = asyncio.create_task(
|
||||
self._run_subagent(
|
||||
@@ -199,14 +217,17 @@ class SubagentManager:
|
||||
bg_task.add_done_callback(_cleanup)
|
||||
|
||||
logger.info("Spawned subagent [{}]: {}", task_id, display_label)
|
||||
return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes."
|
||||
return (
|
||||
f"Subagent [{display_label}] started (id: {task_id}). "
|
||||
f"Use poll_subagents or wait_subagents with id {task_id} to get the result."
|
||||
)
|
||||
|
||||
async def _run_subagent(
|
||||
self,
|
||||
task_id: str,
|
||||
task: str,
|
||||
label: str,
|
||||
origin: dict[str, str],
|
||||
origin: dict[str, Any],
|
||||
status: SubagentStatus,
|
||||
origin_message_id: str | None = None,
|
||||
temperature: float | None = None,
|
||||
@@ -281,6 +302,12 @@ class SubagentManager:
|
||||
logger.info("Subagent [{}] completed successfully", task_id)
|
||||
await self._announce_result(task_id, label, task, final_result, origin, "ok", origin_message_id)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
status.phase = "cancelled"
|
||||
status.stop_reason = "cancelled"
|
||||
await self.mailbox.mark_cancelled(task_id, reason="Cancelled.")
|
||||
logger.info("Subagent [{}] cancelled", task_id)
|
||||
raise
|
||||
except Exception as e:
|
||||
status.phase = "error"
|
||||
status.error = str(e)
|
||||
@@ -293,44 +320,45 @@ class SubagentManager:
|
||||
label: str,
|
||||
task: str,
|
||||
result: str,
|
||||
origin: dict[str, str],
|
||||
origin: dict[str, Any],
|
||||
status: str,
|
||||
origin_message_id: str | None = None,
|
||||
) -> None:
|
||||
"""Announce the subagent result to the main agent via the message bus."""
|
||||
status_text = "completed successfully" if status == "ok" else "failed"
|
||||
|
||||
announce_content = render_template(
|
||||
"agent/subagent_announce.md",
|
||||
label=label,
|
||||
status_text=status_text,
|
||||
task=task,
|
||||
result=result,
|
||||
)
|
||||
|
||||
# Inject as system message to trigger main agent.
|
||||
# Use session_key_override to align with the main agent's effective
|
||||
# session key (which accounts for unified sessions) so the result is
|
||||
# routed to the correct pending queue (mid-turn injection) instead of
|
||||
# being dispatched as a competing independent task.
|
||||
"""Record the subagent result in the mailbox for explicit manager polling."""
|
||||
override = origin.get("session_key") or f"{origin['channel']}:{origin['chat_id']}"
|
||||
metadata: dict[str, Any] = {
|
||||
"injected_event": "subagent_result",
|
||||
"subagent_task_id": task_id,
|
||||
"origin_channel": origin.get("channel"),
|
||||
"origin_chat_id": origin.get("chat_id"),
|
||||
}
|
||||
if origin_message_id:
|
||||
metadata["origin_message_id"] = origin_message_id
|
||||
msg = InboundMessage(
|
||||
channel="system",
|
||||
sender_id="subagent",
|
||||
chat_id=f"{origin['channel']}:{origin['chat_id']}",
|
||||
content=announce_content,
|
||||
session_key_override=override,
|
||||
|
||||
task_result = TaskResult(
|
||||
task_id=task_id,
|
||||
session_key=override,
|
||||
label=label,
|
||||
task=task,
|
||||
status=status,
|
||||
content=result,
|
||||
dedupe_key=task_id,
|
||||
metadata=metadata,
|
||||
)
|
||||
written = await self.mailbox.record_result(task_result)
|
||||
|
||||
await self.bus.publish_inbound(msg)
|
||||
logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id'])
|
||||
if written:
|
||||
logger.debug(
|
||||
"Subagent [{}] wrote result to mailbox for session {}",
|
||||
task_id,
|
||||
override,
|
||||
)
|
||||
if self._on_result_ready is not None:
|
||||
try:
|
||||
await self._on_result_ready(task_result)
|
||||
except Exception:
|
||||
logger.exception("Subagent result-ready callback failed")
|
||||
else:
|
||||
logger.debug("Subagent [{}] result already recorded", task_id)
|
||||
|
||||
@staticmethod
|
||||
def _format_partial_progress(result) -> str:
|
||||
@@ -375,12 +403,95 @@ class SubagentManager:
|
||||
"""Cancel all subagents for the given session. Returns count cancelled."""
|
||||
tasks = [self._running_tasks[tid] for tid in self._session_tasks.get(session_key, [])
|
||||
if tid in self._running_tasks and not self._running_tasks[tid].done()]
|
||||
for tid in list(self._session_tasks.get(session_key, [])):
|
||||
if tid in self._running_tasks and not self._running_tasks[tid].done():
|
||||
await self.mailbox.mark_cancelled(
|
||||
tid,
|
||||
session_key=session_key,
|
||||
reason="Cancelled by /stop.",
|
||||
)
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
return len(tasks)
|
||||
|
||||
async def cancel_task(self, task_id: str, session_key: str | None = None) -> str:
|
||||
"""Cancel one running subagent task and record a cancelled mailbox state."""
|
||||
snapshots = await self.mailbox.poll(session_key, task_id=task_id) if session_key else []
|
||||
if session_key and not snapshots:
|
||||
return "not_found"
|
||||
task = self._running_tasks.get(task_id)
|
||||
if task is None or task.done():
|
||||
if snapshots:
|
||||
return snapshots[0].state
|
||||
return "not_found"
|
||||
await self.mailbox.mark_cancelled(
|
||||
task_id,
|
||||
session_key=session_key,
|
||||
reason="Cancelled by manager.",
|
||||
)
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError, Exception):
|
||||
await task
|
||||
return "cancelled"
|
||||
|
||||
async def poll(
|
||||
self,
|
||||
session_key: str,
|
||||
task_id: str | None = None,
|
||||
) -> list[TaskSnapshot]:
|
||||
"""Return mailbox task status snapshots for a session."""
|
||||
return await self.mailbox.poll(session_key, task_id=task_id)
|
||||
|
||||
async def wait_for_result(
|
||||
self,
|
||||
session_key: str,
|
||||
task_id: str | None = None,
|
||||
timeout_seconds: float = 30.0,
|
||||
) -> MailboxRead:
|
||||
"""Wait for and consume a mailbox result for a session."""
|
||||
return await self.mailbox.wait_for_result(
|
||||
session_key,
|
||||
task_id=task_id,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
|
||||
def runtime_status_lines(self, session_key: str, *, limit: int = 8) -> list[str]:
|
||||
"""Return compact model-visible task status lines for runtime context."""
|
||||
snapshots = self.mailbox.snapshot_sync(session_key)
|
||||
if not snapshots:
|
||||
return []
|
||||
|
||||
now = time.time()
|
||||
ordered = sorted(
|
||||
snapshots,
|
||||
key=lambda item: (
|
||||
item.consumed_at is not None,
|
||||
item.completed_at is None,
|
||||
item.created_at,
|
||||
item.task_id,
|
||||
),
|
||||
)
|
||||
lines = ["Subagent tasks:"]
|
||||
for snapshot in ordered[: max(0, limit)]:
|
||||
state = snapshot.state
|
||||
if snapshot.result_status and snapshot.consumed_at is None:
|
||||
state = f"{state}, result ready"
|
||||
elif snapshot.consumed_at is not None:
|
||||
state = f"{state}, result consumed"
|
||||
elapsed = max(0, int((snapshot.completed_at or now) - snapshot.created_at))
|
||||
label = " ".join(snapshot.label.split())
|
||||
if len(label) > 48:
|
||||
label = label[:45] + "..."
|
||||
lines.append(
|
||||
f"- {snapshot.task_id}: {state}, label=\"{label}\", elapsed={elapsed}s"
|
||||
)
|
||||
remaining = len(ordered) - limit
|
||||
if remaining > 0:
|
||||
lines.append(f"- ... {remaining} more subagent task(s)")
|
||||
return lines
|
||||
|
||||
def get_running_count(self) -> int:
|
||||
"""Return the number of currently running subagents."""
|
||||
return len(self._running_tasks)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Runtime delivery helpers for completed subagent task results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from typing import Any
|
||||
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.session import turn_continuation
|
||||
|
||||
_FORWARDED_METADATA_KEYS = frozenset({
|
||||
"message_id",
|
||||
"origin_message_id",
|
||||
"_wants_stream",
|
||||
"webui",
|
||||
"slack",
|
||||
})
|
||||
|
||||
|
||||
def build_subagent_result_continuation(result: Any) -> InboundMessage:
|
||||
"""Build an internal inbound wake-up for a ready subagent result."""
|
||||
metadata = dict(result.metadata or {})
|
||||
channel = str(metadata.get("origin_channel") or "")
|
||||
chat_id = str(metadata.get("origin_chat_id") or "")
|
||||
if not channel or not chat_id:
|
||||
channel, chat_id = _channel_chat_from_session_key(result.session_key)
|
||||
|
||||
wake_meta = turn_continuation.subagent_result_continuation_metadata(
|
||||
{key: value for key, value in metadata.items() if key in _FORWARDED_METADATA_KEYS},
|
||||
task_id=result.task_id,
|
||||
)
|
||||
return InboundMessage(
|
||||
channel=channel,
|
||||
sender_id="system:continuation",
|
||||
chat_id=chat_id,
|
||||
content=(
|
||||
"A subagent task result is ready. The runtime will attach the "
|
||||
"result to this continuation turn."
|
||||
),
|
||||
metadata=wake_meta,
|
||||
session_key_override=result.session_key,
|
||||
)
|
||||
|
||||
|
||||
async def materialize_subagent_result_continuation(
|
||||
msg: InboundMessage,
|
||||
*,
|
||||
session_key: str,
|
||||
subagents: Any,
|
||||
) -> InboundMessage:
|
||||
"""Replace a subagent-result continuation placeholder with the mailbox result."""
|
||||
task_id = turn_continuation.subagent_result_continuation_task_id(msg.metadata)
|
||||
if not task_id:
|
||||
return msg
|
||||
read = await subagents.wait_for_result(
|
||||
session_key,
|
||||
task_id=task_id,
|
||||
timeout_seconds=0,
|
||||
)
|
||||
return dataclasses.replace(msg, content=_subagent_result_continuation_content(read, task_id))
|
||||
|
||||
|
||||
def _channel_chat_from_session_key(session_key: str) -> tuple[str, str]:
|
||||
channel, _, chat_id = session_key.partition(":")
|
||||
return channel or "cli", chat_id or "direct"
|
||||
|
||||
|
||||
def _subagent_result_continuation_content(read: Any, requested_task_id: str) -> str:
|
||||
if read.state == "ready" and read.result is not None:
|
||||
status_text = {
|
||||
"ok": "completed",
|
||||
"error": "failed",
|
||||
"cancelled": "cancelled",
|
||||
}.get(read.result.status, read.result.status)
|
||||
return (
|
||||
"A subagent result was delivered by the runtime. Use this result "
|
||||
"as authoritative context for the next answer; do not mention the "
|
||||
"internal continuation boundary.\n\n"
|
||||
f"Subagent [{read.result.label}] "
|
||||
f"(id: {read.result.task_id}, status: {status_text})\n\n"
|
||||
f"Task:\n{read.result.task}\n\n"
|
||||
f"Result:\n{read.result.content}"
|
||||
)
|
||||
if read.state == "consumed":
|
||||
return (
|
||||
f"Subagent task {requested_task_id} already has a consumed result. "
|
||||
"Check poll_subagents if you need its current status."
|
||||
)
|
||||
if read.state == "running":
|
||||
return (
|
||||
f"Subagent task {requested_task_id} is still running. "
|
||||
"Use poll_subagents or wait_subagents if you need to block."
|
||||
)
|
||||
return f"Subagent task {requested_task_id} result is not available ({read.state})."
|
||||
@@ -63,7 +63,8 @@ class SpawnTool(Tool, ContextAware):
|
||||
return (
|
||||
"Spawn a subagent to handle a task in the background. "
|
||||
"Use this for complex or time-consuming tasks that can run independently. "
|
||||
"The subagent will complete the task and report back when done. "
|
||||
"The subagent writes its result to a mailbox; use poll_subagents "
|
||||
"or wait_subagents to retrieve it explicitly. "
|
||||
"For deliverables or existing projects, inspect the workspace first "
|
||||
"and use a dedicated subdirectory when helpful."
|
||||
)
|
||||
@@ -81,8 +82,8 @@ class SpawnTool(Tool, ContextAware):
|
||||
if running >= limit:
|
||||
return (
|
||||
f"Cannot spawn subagent: concurrency limit reached "
|
||||
f"({running}/{limit} running). Wait for a running subagent "
|
||||
f"to complete before spawning a new one."
|
||||
f"({running}/{limit} running). Use wait_subagents or cancel_subagent "
|
||||
f"before spawning a new one."
|
||||
)
|
||||
return await self._manager.spawn(
|
||||
task=task,
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Explicit mailbox tools for subagent coordination."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from nanobot.agent.mailbox import MailboxRead, TaskSnapshot
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
||||
from nanobot.agent.tools.schema import NumberSchema, StringSchema, tool_parameters_schema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
|
||||
|
||||
def _normalize_task_id(task_id: str | None) -> str | None:
|
||||
if task_id is None:
|
||||
return None
|
||||
task_id = task_id.strip()
|
||||
return task_id or None
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int = 120) -> str:
|
||||
text = " ".join(text.split())
|
||||
return text if len(text) <= limit else text[: limit - 3] + "..."
|
||||
|
||||
|
||||
class _SubagentMailboxTool(Tool, ContextAware):
|
||||
"""Shared context plumbing for subagent mailbox tools."""
|
||||
|
||||
def __init__(self, manager: "SubagentManager"):
|
||||
self._manager = manager
|
||||
self._session_key: ContextVar[str] = ContextVar(
|
||||
f"{self.__class__.__name__}_session_key",
|
||||
default="cli:direct",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def enabled(cls, ctx: Any) -> bool:
|
||||
return getattr(ctx, "subagent_manager", None) is not None
|
||||
|
||||
@classmethod
|
||||
def create(cls, ctx: Any) -> Tool:
|
||||
return cls(manager=ctx.subagent_manager)
|
||||
|
||||
def set_context(self, ctx: RequestContext) -> None:
|
||||
self._session_key.set(ctx.session_key or f"{ctx.channel}:{ctx.chat_id}")
|
||||
|
||||
|
||||
@tool_parameters(
|
||||
tool_parameters_schema(
|
||||
task_id=StringSchema(
|
||||
"Optional subagent task id. Omit to list all subagent tasks for this session.",
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
class PollSubagentsTool(_SubagentMailboxTool):
|
||||
"""Non-blocking task status check."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "poll_subagents"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Check subagent task status without blocking. Use this to see whether a "
|
||||
"spawned subagent is still running or has a result ready to consume."
|
||||
)
|
||||
|
||||
@property
|
||||
def read_only(self) -> bool:
|
||||
return True
|
||||
|
||||
async def execute(self, task_id: str | None = None, **_: Any) -> str:
|
||||
task_id = _normalize_task_id(task_id)
|
||||
session_key = self._session_key.get()
|
||||
snapshots = await self._manager.poll(session_key, task_id=task_id)
|
||||
if not snapshots:
|
||||
if task_id:
|
||||
return f"Subagent task {task_id} not found for this session."
|
||||
return "No subagent tasks found for this session."
|
||||
return self._format_snapshots(snapshots)
|
||||
|
||||
@staticmethod
|
||||
def _format_snapshots(snapshots: list[TaskSnapshot]) -> str:
|
||||
lines = ["Subagent task status:"]
|
||||
for snapshot in snapshots:
|
||||
state = snapshot.state
|
||||
if snapshot.result_status and snapshot.consumed_at is None:
|
||||
state = f"{state}, result ready"
|
||||
elif snapshot.consumed_at is not None:
|
||||
state = f"{state}, result consumed"
|
||||
lines.append(
|
||||
f"- id: {snapshot.task_id} | label: {snapshot.label} | "
|
||||
f"status: {state} | task: {_truncate(snapshot.task)}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@tool_parameters(
|
||||
tool_parameters_schema(
|
||||
task_id=StringSchema(
|
||||
"Optional subagent task id. Omit to consume the next ready result.",
|
||||
nullable=True,
|
||||
),
|
||||
timeout_seconds=NumberSchema(
|
||||
description="How long to wait for a result before returning. Defaults to 30 seconds.",
|
||||
minimum=0.0,
|
||||
maximum=300.0,
|
||||
),
|
||||
)
|
||||
)
|
||||
class WaitSubagentsTool(_SubagentMailboxTool):
|
||||
"""Wait for and consume one task result."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "wait_subagents"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Wait for a subagent result and consume it once. Use this after spawn "
|
||||
"when you need the worker's result before continuing."
|
||||
)
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
task_id: str | None = None,
|
||||
timeout_seconds: float = 30.0,
|
||||
**_: Any,
|
||||
) -> str:
|
||||
task_id = _normalize_task_id(task_id)
|
||||
read = await self._manager.wait_for_result(
|
||||
self._session_key.get(),
|
||||
task_id=task_id,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
return self._format_read(read, task_id)
|
||||
|
||||
@staticmethod
|
||||
def _format_read(read: MailboxRead, requested_task_id: str | None) -> str:
|
||||
if read.state == "not_found":
|
||||
target = f" {requested_task_id}" if requested_task_id else ""
|
||||
return f"Subagent task{target} not found for this session."
|
||||
if read.state == "timeout":
|
||||
target = f" {read.task.task_id}" if read.task is not None else ""
|
||||
return f"Timed out waiting for subagent task{target}."
|
||||
if read.state == "consumed":
|
||||
target = f" {read.task.task_id}" if read.task is not None else ""
|
||||
return f"Subagent result for task{target} was already consumed."
|
||||
if read.result is None or read.task is None:
|
||||
return "No subagent result is ready."
|
||||
|
||||
status_text = {
|
||||
"ok": "completed",
|
||||
"error": "failed",
|
||||
"cancelled": "cancelled",
|
||||
}.get(read.result.status, read.result.status)
|
||||
return (
|
||||
f"Subagent result for [{read.result.label}] "
|
||||
f"(id: {read.result.task_id}, status: {status_text}).\n\n"
|
||||
f"Task: {read.result.task}\n\n"
|
||||
f"Result:\n{read.result.content}"
|
||||
)
|
||||
|
||||
|
||||
@tool_parameters(
|
||||
tool_parameters_schema(
|
||||
task_id=StringSchema("Subagent task id to cancel"),
|
||||
required=["task_id"],
|
||||
)
|
||||
)
|
||||
class CancelSubagentTool(_SubagentMailboxTool):
|
||||
"""Cancel one running task."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "cancel_subagent"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Cancel a running subagent task and record a cancelled mailbox state. "
|
||||
"Use this only when the delegated task is no longer needed."
|
||||
)
|
||||
|
||||
async def execute(self, task_id: str, **_: Any) -> str:
|
||||
task_id = _normalize_task_id(task_id)
|
||||
if task_id is None:
|
||||
return "Error: task_id is required."
|
||||
state = await self._manager.cancel_task(task_id, session_key=self._session_key.get())
|
||||
if state == "cancelled":
|
||||
return f"Cancelled subagent task {task_id}."
|
||||
if state == "not_found":
|
||||
return f"Subagent task {task_id} not found for this session."
|
||||
if state in {"completed", "failed"}:
|
||||
return (
|
||||
f"Subagent task {task_id} already {state}; "
|
||||
"use wait_subagents to consume its result if needed."
|
||||
)
|
||||
if state == "cancelled":
|
||||
return f"Subagent task {task_id} is already cancelled."
|
||||
return f"Subagent task {task_id} is {state}."
|
||||
+17
-3
@@ -54,7 +54,14 @@ def _error_json(status: int, message: str, err_type: str = "invalid_request_erro
|
||||
)
|
||||
|
||||
|
||||
def _chat_completion_response(content: str, model: str) -> dict[str, Any]:
|
||||
def _chat_completion_response(
|
||||
content: str,
|
||||
model: str,
|
||||
usage: dict[str, int] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
prompt = (usage or {}).get("prompt_tokens", 0)
|
||||
completion = (usage or {}).get("completion_tokens", 0)
|
||||
total = (usage or {}).get("total_tokens", 0) or prompt + completion
|
||||
return {
|
||||
"id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
|
||||
"object": "chat.completion",
|
||||
@@ -67,7 +74,11 @@ def _chat_completion_response(content: str, model: str) -> dict[str, Any]:
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
||||
"usage": {
|
||||
"prompt_tokens": prompt,
|
||||
"completion_tokens": completion,
|
||||
"total_tokens": total,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -329,6 +340,7 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
||||
session_key=session_key,
|
||||
channel="api",
|
||||
chat_id=API_CHAT_ID,
|
||||
persist_user_message=False,
|
||||
),
|
||||
timeout=timeout_s,
|
||||
)
|
||||
@@ -346,7 +358,9 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
||||
logger.exception("Unexpected API lock error for session {}", session_key)
|
||||
return _error_json(500, "Internal server error", err_type="server_error")
|
||||
|
||||
return web.json_response(_chat_completion_response(response_text, model_name))
|
||||
return web.json_response(
|
||||
_chat_completion_response(response_text, model_name, getattr(agent_loop, "_last_usage", None))
|
||||
)
|
||||
|
||||
|
||||
async def handle_models(request: web.Request) -> web.Response:
|
||||
|
||||
@@ -896,6 +896,7 @@ class WebSocketChannel(BaseChannel):
|
||||
goal_state=gs_blob,
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
await self.send_session_updated(msg.chat_id, scope="thread")
|
||||
return
|
||||
if msg.metadata.get("_session_updated"):
|
||||
if conns:
|
||||
@@ -1146,8 +1147,8 @@ class WebSocketChannel(BaseChannel):
|
||||
await self._safe_send_to(connection, raw, label=" goal_status ")
|
||||
|
||||
async def send_session_updated(self, chat_id: str, *, scope: str | None = None) -> None:
|
||||
"""Notify clients that session metadata changed outside the main turn."""
|
||||
conns = list(self._subs.get(chat_id, ()))
|
||||
"""Notify WebUI clients that a session row should refresh."""
|
||||
conns = list(self._conn_chats)
|
||||
if not conns:
|
||||
return
|
||||
body: dict[str, Any] = {"event": "session_updated", "chat_id": chat_id}
|
||||
|
||||
@@ -326,7 +326,8 @@ async def cmd_dream(ctx: CommandContext) -> OutboundMessage:
|
||||
if result is None:
|
||||
await loop.bus.publish_outbound(OutboundMessage(
|
||||
channel=msg.channel, chat_id=msg.chat_id,
|
||||
content="Dream: nothing to process.",
|
||||
content=_format_dream_no_input_message(),
|
||||
metadata={"render_as": "text"},
|
||||
))
|
||||
return
|
||||
prompt, last_cursor = result
|
||||
@@ -374,6 +375,23 @@ async def cmd_dream(ctx: CommandContext) -> OutboundMessage:
|
||||
)
|
||||
|
||||
|
||||
def _format_dream_no_input_message() -> str:
|
||||
return "\n".join([
|
||||
"Dream has no conversation history to process yet.",
|
||||
"",
|
||||
"Dream reads new entries from `memory/history.jsonl` after the current Dream cursor.",
|
||||
(
|
||||
"Short chats only reach that file after token compaction or idle auto-compact, "
|
||||
"so a fresh or short WebUI chat may leave Dream with no input."
|
||||
),
|
||||
"",
|
||||
"Next steps:",
|
||||
"- Enable `agents.defaults.idleCompactAfterMinutes` so completed chats become Dream input automatically.",
|
||||
"- Compact the current chat into memory once that manual action is available.",
|
||||
"- If you expected history to exist, check whether `memory/history.jsonl` has new entries after the Dream cursor.",
|
||||
])
|
||||
|
||||
|
||||
def _extract_changed_files(diff: str) -> list[str]:
|
||||
"""Extract changed file paths from a unified diff."""
|
||||
files: list[str] = []
|
||||
|
||||
@@ -145,7 +145,7 @@ class AgentDefaults(Base):
|
||||
unified_session: bool = False # Share one session across all channels (single-user multi-device)
|
||||
disabled_skills: list[str] = Field(default_factory=list) # Skill names to exclude from loading (e.g. ["summarize", "skill-creator"])
|
||||
session_ttl_minutes: int = Field(
|
||||
default=0,
|
||||
default=15,
|
||||
ge=0,
|
||||
validation_alias=AliasChoices("idleCompactAfterMinutes", "sessionTtlMinutes"),
|
||||
serialization_alias="idleCompactAfterMinutes",
|
||||
|
||||
+54
-1
@@ -136,6 +136,10 @@ class CronService:
|
||||
"""Service for managing and executing scheduled jobs."""
|
||||
|
||||
_MAX_RUN_HISTORY = 20
|
||||
_UNBOUND_AGENT_JOB_REASON = (
|
||||
"agent cron payload is missing bound session delivery context; "
|
||||
"recreate it from a chat session"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -154,6 +158,42 @@ class CronService:
|
||||
self._timer_active = False
|
||||
self.max_sleep_ms = max_sleep_ms
|
||||
|
||||
def _is_unbound_agent_job(self, job: CronJob) -> bool:
|
||||
return job.payload.kind == "agent_turn" and not is_bound_cron_job(job)
|
||||
|
||||
def _enforce_agent_binding(self, job: CronJob) -> bool:
|
||||
"""Disable user cron jobs that cannot be routed to a concrete session."""
|
||||
if not self._is_unbound_agent_job(job):
|
||||
return False
|
||||
if (
|
||||
not job.enabled
|
||||
and job.state.next_run_at_ms is None
|
||||
and job.state.last_status == "error"
|
||||
and job.state.last_error
|
||||
):
|
||||
return False
|
||||
|
||||
job.enabled = False
|
||||
job.state.next_run_at_ms = None
|
||||
job.state.last_status = "error"
|
||||
job.state.last_error = self._UNBOUND_AGENT_JOB_REASON
|
||||
job.updated_at_ms = max(job.updated_at_ms, _now_ms())
|
||||
logger.warning(
|
||||
"Cron: disabled unbound agent job '{}' ({}): {}",
|
||||
job.name,
|
||||
job.id,
|
||||
self._UNBOUND_AGENT_JOB_REASON,
|
||||
)
|
||||
return True
|
||||
|
||||
def _enforce_store_agent_bindings(self) -> bool:
|
||||
if not self._store:
|
||||
return False
|
||||
changed = False
|
||||
for job in self._store.jobs:
|
||||
changed = self._enforce_agent_binding(job) or changed
|
||||
return changed
|
||||
|
||||
def _load_jobs(self) -> tuple[list[CronJob], int] | None:
|
||||
"""Load jobs from disk.
|
||||
|
||||
@@ -312,6 +352,8 @@ class CronService:
|
||||
jobs, version = loaded
|
||||
self._store = CronStore(version=version, jobs=jobs)
|
||||
self._merge_action()
|
||||
if self._enforce_store_agent_bindings() and self._running:
|
||||
self._save_store()
|
||||
|
||||
return self._store
|
||||
|
||||
@@ -456,6 +498,8 @@ class CronService:
|
||||
return
|
||||
now = _now_ms()
|
||||
for job in self._store.jobs:
|
||||
if self._enforce_agent_binding(job):
|
||||
continue
|
||||
if job.enabled:
|
||||
job.state.next_run_at_ms = _compute_next_run(job.schedule, now)
|
||||
|
||||
@@ -638,6 +682,7 @@ class CronService:
|
||||
delete_after_run=delete_after_run,
|
||||
)
|
||||
_normalize_agent_turn_job(job)
|
||||
self._enforce_agent_binding(job)
|
||||
if self._running:
|
||||
store = self._load_store()
|
||||
store.jobs.append(job)
|
||||
@@ -695,7 +740,8 @@ class CronService:
|
||||
if job.id == job_id:
|
||||
job.enabled = enabled
|
||||
job.updated_at_ms = _now_ms()
|
||||
if enabled:
|
||||
self._enforce_agent_binding(job)
|
||||
if job.enabled:
|
||||
job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
|
||||
else:
|
||||
job.state.next_run_at_ms = None
|
||||
@@ -747,10 +793,13 @@ class CronService:
|
||||
if delete_after_run is not None:
|
||||
job.delete_after_run = delete_after_run
|
||||
_normalize_agent_turn_job(job)
|
||||
self._enforce_agent_binding(job)
|
||||
|
||||
job.updated_at_ms = _now_ms()
|
||||
if job.enabled:
|
||||
job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
|
||||
else:
|
||||
job.state.next_run_at_ms = None
|
||||
|
||||
if self._running:
|
||||
self._save_store()
|
||||
@@ -769,6 +818,10 @@ class CronService:
|
||||
store = self._load_store()
|
||||
for job in store.jobs:
|
||||
if job.id == job_id:
|
||||
if self._is_unbound_agent_job(job):
|
||||
self._enforce_agent_binding(job)
|
||||
self._save_store()
|
||||
return False
|
||||
if not force and not job.enabled:
|
||||
return False
|
||||
await self._execute_job(job)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
@@ -14,6 +13,7 @@ from nanobot.providers.base import (
|
||||
LLMProvider,
|
||||
LLMResponse,
|
||||
ToolCallRequest,
|
||||
resolve_stream_idle_timeout_s,
|
||||
tool_arguments_object_for_replay,
|
||||
)
|
||||
|
||||
@@ -613,7 +613,7 @@ class AnthropicProvider(LLMProvider):
|
||||
messages, tools, model, max_tokens, temperature,
|
||||
reasoning_effort, tool_choice,
|
||||
)
|
||||
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
||||
idle_timeout_s = resolve_stream_idle_timeout_s()
|
||||
try:
|
||||
async with self._client.messages.stream(**kwargs) as stream:
|
||||
if on_content_delta or on_thinking_delta or on_tool_call_delta:
|
||||
@@ -682,7 +682,7 @@ class AnthropicProvider(LLMProvider):
|
||||
return LLMResponse(
|
||||
content=(
|
||||
f"Error calling LLM: stream stalled for more than "
|
||||
f"{idle_timeout_s} seconds"
|
||||
f"{idle_timeout_s:g} seconds"
|
||||
),
|
||||
finish_reason="error",
|
||||
error_kind="timeout",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Awaitable, Callable
|
||||
@@ -16,6 +17,34 @@ from loguru import logger
|
||||
|
||||
from nanobot.utils.helpers import image_placeholder_text
|
||||
|
||||
STREAM_IDLE_TIMEOUT_ENV = "NANOBOT_STREAM_IDLE_TIMEOUT_S"
|
||||
DEFAULT_STREAM_IDLE_TIMEOUT_S = 90.0
|
||||
MAX_STREAM_IDLE_TIMEOUT_S = 3600.0
|
||||
|
||||
|
||||
def resolve_stream_idle_timeout_s(
|
||||
*,
|
||||
env_value: str | None = None,
|
||||
default: float = DEFAULT_STREAM_IDLE_TIMEOUT_S,
|
||||
maximum: float = MAX_STREAM_IDLE_TIMEOUT_S,
|
||||
) -> float:
|
||||
"""Return a safe streaming idle timeout from env/config text."""
|
||||
raw = os.environ.get(STREAM_IDLE_TIMEOUT_ENV) if env_value is None else env_value
|
||||
if raw is None or not raw.strip():
|
||||
return default
|
||||
try:
|
||||
value = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Ignoring invalid {}={!r}; using {}", STREAM_IDLE_TIMEOUT_ENV, raw, default)
|
||||
return default
|
||||
if value <= 0:
|
||||
logger.warning("Ignoring non-positive {}={!r}; using {}", STREAM_IDLE_TIMEOUT_ENV, raw, default)
|
||||
return default
|
||||
if value > maximum:
|
||||
logger.warning("Clamping {}={!r} to {}", STREAM_IDLE_TIMEOUT_ENV, raw, maximum)
|
||||
return maximum
|
||||
return value
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCallRequest:
|
||||
|
||||
@@ -15,6 +15,7 @@ from nanobot.providers.base import (
|
||||
LLMResponse,
|
||||
ToolCallRequest,
|
||||
parse_tool_arguments,
|
||||
resolve_stream_idle_timeout_s,
|
||||
tool_arguments_object_for_replay,
|
||||
)
|
||||
|
||||
@@ -701,7 +702,7 @@ class BedrockProvider(LLMProvider):
|
||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||
) -> LLMResponse:
|
||||
_ = on_thinking_delta, on_tool_call_delta
|
||||
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
||||
idle_timeout_s = resolve_stream_idle_timeout_s()
|
||||
content_parts: list[str] = []
|
||||
reasoning_parts: list[str] = []
|
||||
thinking_blocks: list[dict[str, Any]] = []
|
||||
@@ -742,7 +743,7 @@ class BedrockProvider(LLMProvider):
|
||||
return LLMResponse(
|
||||
content=(
|
||||
f"Error calling LLM: stream stalled for more than "
|
||||
f"{idle_timeout_s} seconds"
|
||||
f"{idle_timeout_s:g} seconds"
|
||||
),
|
||||
finish_reason="error",
|
||||
error_kind="timeout",
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
@@ -13,7 +12,12 @@ import httpx
|
||||
from loguru import logger
|
||||
from oauth_cli_kit import get_token as get_codex_token
|
||||
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
from nanobot.providers.base import (
|
||||
LLMProvider,
|
||||
LLMResponse,
|
||||
ToolCallRequest,
|
||||
resolve_stream_idle_timeout_s,
|
||||
)
|
||||
from nanobot.providers.openai_responses import (
|
||||
consume_sse_with_reasoning,
|
||||
convert_messages,
|
||||
@@ -199,7 +203,7 @@ async def _request_codex(
|
||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
|
||||
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
||||
idle_timeout_s = resolve_stream_idle_timeout_s()
|
||||
async with httpx.AsyncClient(timeout=idle_timeout_s, verify=verify) as client:
|
||||
async with client.stream("POST", url, headers=headers, json=body) as response:
|
||||
if response.status_code != 200:
|
||||
|
||||
@@ -25,6 +25,7 @@ from nanobot.providers.base import (
|
||||
LLMResponse,
|
||||
ToolCallRequest,
|
||||
parse_tool_arguments,
|
||||
resolve_stream_idle_timeout_s,
|
||||
tool_arguments_json_for_replay,
|
||||
)
|
||||
from nanobot.providers.openai_responses import (
|
||||
@@ -60,8 +61,15 @@ _DEFAULT_OPENROUTER_HEADERS = {
|
||||
_KIMI_THINKING_MODELS: frozenset[str] = frozenset({
|
||||
"kimi-k2.5",
|
||||
"kimi-k2.6",
|
||||
"kimi-k2.7",
|
||||
"kimi-k2.7-code",
|
||||
"kimi-k2.7-code-highspeed",
|
||||
"k2.6-code-preview",
|
||||
})
|
||||
_KIMI_ALWAYS_THINKING_MODELS: frozenset[str] = frozenset({
|
||||
"kimi-k2.7-code",
|
||||
"kimi-k2.7-code-highspeed",
|
||||
})
|
||||
# Thinking-capable MiMo models per Xiaomi docs (see
|
||||
# tests/providers/test_xiaomi_mimo_thinking.py). mimo-v2-flash is omitted
|
||||
# because it does not support thinking.
|
||||
@@ -692,13 +700,20 @@ class OpenAICompatProvider(LLMProvider):
|
||||
# Only send thinking controls when reasoning_effort is explicit so
|
||||
# omitting the config preserves each provider's default.
|
||||
if reasoning_effort is not None:
|
||||
slug = _model_slug(model_name)
|
||||
thinking_enabled = semantic_effort not in ("none", "minimal")
|
||||
for thinking_style in _thinking_styles_for(spec, model_name):
|
||||
if not thinking_enabled and slug in _KIMI_ALWAYS_THINKING_MODELS:
|
||||
continue
|
||||
extra = _thinking_extra_body(thinking_style, thinking_enabled)
|
||||
if extra:
|
||||
kwargs.setdefault("extra_body", {}).update(extra)
|
||||
gateway_style = getattr(spec, "gateway_reasoning_style", "") if spec else ""
|
||||
if gateway_style and _model_thinking_style(model_name):
|
||||
if (
|
||||
gateway_style
|
||||
and _model_thinking_style(model_name)
|
||||
and (thinking_enabled or slug not in _KIMI_ALWAYS_THINKING_MODELS)
|
||||
):
|
||||
extra = _gateway_reasoning_extra_body(gateway_style, semantic_effort)
|
||||
if extra:
|
||||
kwargs.setdefault("extra_body", {}).update(extra)
|
||||
@@ -708,7 +723,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
# user's intent via the provider-native shape, so drop the
|
||||
# redundant wire-level kwarg. Only kimi models need this —
|
||||
# Xiaomi's API accepts both params.
|
||||
if _model_slug(model_name) in _KIMI_THINKING_MODELS:
|
||||
if slug in _KIMI_THINKING_MODELS:
|
||||
kwargs.pop("reasoning_effort", None)
|
||||
|
||||
if tools:
|
||||
@@ -1372,7 +1387,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||
) -> LLMResponse:
|
||||
await self._ensure_client()
|
||||
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
||||
idle_timeout_s = resolve_stream_idle_timeout_s()
|
||||
try:
|
||||
if self._should_use_responses_api(model, reasoning_effort):
|
||||
try:
|
||||
@@ -1489,7 +1504,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
return LLMResponse(
|
||||
content=(
|
||||
f"Error calling LLM: stream stalled for more than "
|
||||
f"{idle_timeout_s} seconds"
|
||||
f"{idle_timeout_s:g} seconds"
|
||||
),
|
||||
finish_reason="error",
|
||||
error_kind="timeout",
|
||||
|
||||
@@ -352,7 +352,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
default_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
thinking_style="enable_thinking",
|
||||
),
|
||||
# Moonshot (月之暗面): Kimi K2.5 / K2.6 enforce temperature >= 1.0.
|
||||
# Moonshot (月之暗面): Kimi K2.5+ enforce temperature >= 1.0.
|
||||
ProviderSpec(
|
||||
name="moonshot",
|
||||
keywords=("moonshot", "kimi"),
|
||||
@@ -363,6 +363,9 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
model_overrides=(
|
||||
("kimi-k2.5", {"temperature": 1.0}),
|
||||
("kimi-k2.6", {"temperature": 1.0}),
|
||||
("kimi-k2.7", {"temperature": 1.0}),
|
||||
("kimi-k2.7-code", {"temperature": 1.0}),
|
||||
("kimi-k2.7-code-highspeed", {"temperature": 1.0}),
|
||||
),
|
||||
),
|
||||
# MiniMax: OpenAI-compatible API
|
||||
|
||||
+22
-10
@@ -287,8 +287,13 @@ class Session:
|
||||
self.updated_at = datetime.now()
|
||||
self.metadata.pop("_last_summary", None)
|
||||
|
||||
def retain_recent_legal_suffix(self, max_messages: int) -> tuple[list[dict], int]:
|
||||
"""Keep a legal recent suffix constrained by a hard message cap.
|
||||
def retain_recent_legal_suffix(
|
||||
self,
|
||||
max_messages: int,
|
||||
*,
|
||||
extend_to_user: bool = False,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Keep a legal recent suffix, optionally extending it back to a user turn.
|
||||
|
||||
Returns ``(dropped, already_consolidated_count)`` where *dropped* is
|
||||
the list of removed messages (in original order) and
|
||||
@@ -307,30 +312,37 @@ class Session:
|
||||
original = list(self.messages)
|
||||
before_lc = self.last_consolidated
|
||||
|
||||
retained = list(self.messages[-max_messages:])
|
||||
start_idx = max(0, len(self.messages) - max_messages)
|
||||
if extend_to_user:
|
||||
start_idx = next(
|
||||
(i for i in range(start_idx, -1, -1) if self.messages[i].get("role") == "user"),
|
||||
start_idx,
|
||||
)
|
||||
|
||||
# Prefer starting at a user turn when one exists within the tail.
|
||||
retained = self.messages[start_idx:]
|
||||
|
||||
# Prefer starting at a user turn when one exists within the retained window.
|
||||
first_user = next((i for i, m in enumerate(retained) if m.get("role") == "user"), None)
|
||||
if first_user is not None:
|
||||
retained = retained[first_user:]
|
||||
else:
|
||||
# If the tail is assistant/tool-only, anchor to the latest user in
|
||||
# the full session and take a capped forward window from there.
|
||||
elif not extend_to_user:
|
||||
# If the hard-capped tail is assistant/tool-only, anchor to the
|
||||
# latest user in the full session and take a capped forward window.
|
||||
latest_user = next(
|
||||
(i for i in range(len(self.messages) - 1, -1, -1)
|
||||
if self.messages[i].get("role") == "user"),
|
||||
None,
|
||||
)
|
||||
if latest_user is not None:
|
||||
retained = list(self.messages[latest_user: latest_user + max_messages])
|
||||
retained = self.messages[latest_user: latest_user + max_messages]
|
||||
|
||||
# Mirror get_history(): avoid persisting orphan tool results at the front.
|
||||
start = find_legal_message_start(retained)
|
||||
if start:
|
||||
retained = retained[start:]
|
||||
|
||||
# Hard-cap guarantee: never keep more than max_messages.
|
||||
if len(retained) > max_messages:
|
||||
# Hard-cap guarantee unless the caller requested user-turn extension.
|
||||
if not extend_to_user and len(retained) > max_messages:
|
||||
retained = retained[-max_messages:]
|
||||
start = find_legal_message_start(retained)
|
||||
if start:
|
||||
|
||||
@@ -22,8 +22,11 @@ INTERNAL_CONTINUATION_META = "_internal_continuation"
|
||||
INTERNAL_CONTINUATION_KIND_META = "_internal_continuation_kind"
|
||||
INTERNAL_CONTINUATION_PENDING_META = "_internal_continuation_pending"
|
||||
INTERNAL_CONTINUATION_RUN_STARTED_AT_META = "_internal_continuation_run_started_at"
|
||||
SKIP_USER_PERSIST_META = "_skip_user_persist"
|
||||
|
||||
_GOAL_CONTINUATION_KIND = "sustained_goal"
|
||||
SUBAGENT_RESULT_CONTINUATION_KIND = "subagent_result"
|
||||
SUBAGENT_RESULT_TASK_ID_META = "_subagent_result_task_id"
|
||||
_GOAL_CONTINUATION_SENDER = "system:continuation"
|
||||
_GOAL_CONTINUATION_ROUNDS_KEY = "_sustained_goal_continuation_rounds"
|
||||
_MAX_GOAL_CONTINUATION_ROUNDS = 12
|
||||
@@ -57,8 +60,42 @@ def internal_continuation_run_started_at(metadata: Mapping[str, Any] | None) ->
|
||||
return started_at if started_at > 0 else None
|
||||
|
||||
|
||||
def subagent_result_continuation_inbound(metadata: Mapping[str, Any] | None) -> bool:
|
||||
"""True for an internal continuation caused by a ready subagent result."""
|
||||
return bool(
|
||||
internal_continuation_inbound(metadata)
|
||||
and metadata.get(INTERNAL_CONTINUATION_KIND_META) == SUBAGENT_RESULT_CONTINUATION_KIND
|
||||
)
|
||||
|
||||
|
||||
def subagent_result_continuation_task_id(metadata: Mapping[str, Any] | None) -> str | None:
|
||||
"""Return the ready subagent task id carried by a continuation message."""
|
||||
if not subagent_result_continuation_inbound(metadata):
|
||||
return None
|
||||
value = metadata.get(SUBAGENT_RESULT_TASK_ID_META) if metadata else None
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def subagent_result_continuation_metadata(
|
||||
message_metadata: Mapping[str, Any] | None,
|
||||
*,
|
||||
task_id: str,
|
||||
run_started_at: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build sanitized metadata for a subagent-result continuation turn."""
|
||||
metadata = _internal_continuation_metadata(
|
||||
message_metadata,
|
||||
kind=SUBAGENT_RESULT_CONTINUATION_KIND,
|
||||
run_started_at=run_started_at,
|
||||
)
|
||||
metadata[SUBAGENT_RESULT_TASK_ID_META] = task_id
|
||||
return metadata
|
||||
|
||||
|
||||
def should_persist_user_message(metadata: Mapping[str, Any] | None) -> bool:
|
||||
"""Return whether this inbound message should be persisted as user input."""
|
||||
if metadata and metadata.get(SKIP_USER_PERSIST_META) is True:
|
||||
return False
|
||||
return not internal_continuation_inbound(metadata)
|
||||
|
||||
|
||||
@@ -180,6 +217,8 @@ def _save_skip_for_turn(
|
||||
user_persisted_early: bool,
|
||||
) -> int:
|
||||
"""Return the persisted-message append boundary for this turn."""
|
||||
if message_metadata and message_metadata.get(SKIP_USER_PERSIST_META) is True:
|
||||
return initial_message_count
|
||||
if internal_continuation_inbound(message_metadata):
|
||||
return initial_message_count
|
||||
# build_messages may merge the current message into a same-role history tail.
|
||||
@@ -218,11 +257,12 @@ def _increment_goal_continuation_round(session_metadata: MutableMapping[str, Any
|
||||
def _internal_continuation_metadata(
|
||||
message_metadata: Mapping[str, Any] | None,
|
||||
*,
|
||||
kind: str = _GOAL_CONTINUATION_KIND,
|
||||
run_started_at: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
metadata = dict(message_metadata or {})
|
||||
metadata[INTERNAL_CONTINUATION_META] = True
|
||||
metadata[INTERNAL_CONTINUATION_KIND_META] = _GOAL_CONTINUATION_KIND
|
||||
metadata[INTERNAL_CONTINUATION_KIND_META] = kind
|
||||
if run_started_at is not None:
|
||||
metadata[INTERNAL_CONTINUATION_RUN_STARTED_AT_META] = float(run_started_at)
|
||||
for key in _STRIPPED_INBOUND_META_KEYS:
|
||||
|
||||
@@ -218,6 +218,7 @@ _TOOL_RESULT_PREVIEW_CHARS = 1200
|
||||
_TOOL_RESULTS_DIR = ".nanobot/tool-results"
|
||||
_TOOL_RESULT_RETENTION_SECS = 7 * 24 * 60 * 60
|
||||
_TOOL_RESULT_MAX_BUCKETS = 32
|
||||
_TRUNCATED_SUFFIX = "\n... (truncated)"
|
||||
|
||||
|
||||
def safe_filename(name: str) -> str:
|
||||
@@ -234,7 +235,39 @@ def truncate_text(text: str, max_chars: int) -> str:
|
||||
"""Truncate text with a stable suffix."""
|
||||
if max_chars <= 0 or len(text) <= max_chars:
|
||||
return text
|
||||
return text[:max_chars] + "\n... (truncated)"
|
||||
return text[:max_chars] + _TRUNCATED_SUFFIX
|
||||
|
||||
|
||||
def truncate_text_to_tokens(text: str, max_tokens: int) -> str:
|
||||
"""Truncate text to a token budget with a stable suffix.
|
||||
|
||||
Unlike :func:`truncate_text`, this measures actual tokens, so the cap holds
|
||||
regardless of language or content (CJK and code cost more tokens per char).
|
||||
Falls back to a char-based estimate (~4 chars/token) if tiktoken is
|
||||
unavailable.
|
||||
"""
|
||||
if max_tokens <= 0:
|
||||
return text
|
||||
try:
|
||||
enc = tiktoken.get_encoding("cl100k_base")
|
||||
tokens = enc.encode(text)
|
||||
if len(tokens) <= max_tokens:
|
||||
return text
|
||||
suffix_tokens = enc.encode(_TRUNCATED_SUFFIX)
|
||||
body_budget = max_tokens - len(suffix_tokens)
|
||||
if body_budget <= 0:
|
||||
return enc.decode(tokens[:max_tokens])
|
||||
for candidate_budget in range(body_budget, -1, -1):
|
||||
result = enc.decode(tokens[:candidate_budget]) + _TRUNCATED_SUFFIX
|
||||
if len(enc.encode(result)) <= max_tokens:
|
||||
return result
|
||||
return enc.decode(tokens[:max_tokens])
|
||||
except Exception:
|
||||
max_chars = max_tokens * 4
|
||||
suffix_chars = len(_TRUNCATED_SUFFIX)
|
||||
if max_chars <= suffix_chars:
|
||||
return text[:max_chars]
|
||||
return truncate_text(text, max_chars - suffix_chars)
|
||||
|
||||
|
||||
def find_legal_message_start(messages: list[dict[str, Any]]) -> int:
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
"""Session-scoped automation payloads for the embedded WebUI."""
|
||||
"""Automation payloads for the embedded WebUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Collection
|
||||
from typing import Any, Protocol
|
||||
|
||||
from nanobot.cron.session_turns import CRON_HISTORY_META
|
||||
from nanobot.cron.types import CronJob
|
||||
from nanobot.session.manager import _message_preview_text
|
||||
|
||||
|
||||
class _CronServiceLike(Protocol):
|
||||
def list_jobs(self, include_disabled: bool = False) -> list[CronJob]: ...
|
||||
|
||||
def list_bound_cron_jobs_for_session(
|
||||
self,
|
||||
session_key: str,
|
||||
@@ -17,6 +21,10 @@ class _CronServiceLike(Protocol):
|
||||
) -> list[CronJob]: ...
|
||||
|
||||
|
||||
class _SessionManagerLike(Protocol):
|
||||
def read_session_file(self, key: str) -> dict[str, Any] | None: ...
|
||||
|
||||
|
||||
def session_automation_jobs(
|
||||
cron_service: _CronServiceLike | None,
|
||||
session_key: str,
|
||||
@@ -45,16 +53,50 @@ def session_automations_payload(
|
||||
}
|
||||
|
||||
|
||||
def all_automations_payload(
|
||||
cron_service: _CronServiceLike | None,
|
||||
*,
|
||||
session_manager: _SessionManagerLike | None = None,
|
||||
pending_job_ids: Collection[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return all cron jobs visible to the WebUI automation manager."""
|
||||
jobs = cron_service.list_jobs(include_disabled=True) if cron_service is not None else []
|
||||
return {
|
||||
"jobs": serialize_automation_jobs(
|
||||
jobs,
|
||||
pending_job_ids=pending_job_ids,
|
||||
include_details=True,
|
||||
session_manager=session_manager,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def serialize_automation_jobs(
|
||||
jobs: list[CronJob],
|
||||
*,
|
||||
pending_job_ids: Collection[str] | None = None,
|
||||
include_details: bool = False,
|
||||
session_manager: _SessionManagerLike | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
return [_serialize_job(job, pending=job.id in (pending_job_ids or ())) for job in jobs]
|
||||
return [
|
||||
_serialize_job(
|
||||
job,
|
||||
pending=job.id in (pending_job_ids or ()),
|
||||
include_details=include_details,
|
||||
session_manager=session_manager,
|
||||
)
|
||||
for job in jobs
|
||||
]
|
||||
|
||||
|
||||
def _serialize_job(job: CronJob, *, pending: bool = False) -> dict[str, Any]:
|
||||
return {
|
||||
def _serialize_job(
|
||||
job: CronJob,
|
||||
*,
|
||||
pending: bool = False,
|
||||
include_details: bool = False,
|
||||
session_manager: _SessionManagerLike | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
"id": job.id,
|
||||
"name": job.name,
|
||||
"enabled": job.enabled,
|
||||
@@ -74,3 +116,80 @@ def _serialize_job(job: CronJob, *, pending: bool = False) -> dict[str, Any]:
|
||||
"pending": pending,
|
||||
},
|
||||
}
|
||||
if not include_details:
|
||||
return payload
|
||||
|
||||
payload["protected"] = job.payload.kind == "system_event"
|
||||
payload["delete_after_run"] = job.delete_after_run
|
||||
payload["created_at_ms"] = job.created_at_ms
|
||||
payload["updated_at_ms"] = job.updated_at_ms
|
||||
payload["payload"].update({"kind": job.payload.kind})
|
||||
payload["state"].update(
|
||||
{
|
||||
"last_run_at_ms": job.state.last_run_at_ms,
|
||||
"last_error": job.state.last_error,
|
||||
"run_history": [
|
||||
{
|
||||
"run_at_ms": record.run_at_ms,
|
||||
"status": record.status,
|
||||
"duration_ms": record.duration_ms,
|
||||
"error": record.error,
|
||||
}
|
||||
for record in job.state.run_history[-5:]
|
||||
],
|
||||
}
|
||||
)
|
||||
payload["origin"] = _origin_payload(job, session_manager)
|
||||
return payload
|
||||
|
||||
|
||||
def _origin_payload(
|
||||
job: CronJob,
|
||||
session_manager: _SessionManagerLike | None,
|
||||
) -> dict[str, Any] | None:
|
||||
channel = job.payload.origin_channel
|
||||
chat_id = job.payload.origin_chat_id
|
||||
if not channel or not chat_id:
|
||||
return None
|
||||
title = ""
|
||||
preview = ""
|
||||
if channel != "websocket":
|
||||
return {
|
||||
"channel": channel,
|
||||
"title": title,
|
||||
"preview": preview,
|
||||
}
|
||||
|
||||
session_key = f"{channel}:{chat_id}"
|
||||
if session_manager is not None:
|
||||
data = session_manager.read_session_file(session_key)
|
||||
if isinstance(data, dict):
|
||||
title = str(data.get("title") or "")
|
||||
preview = _session_preview(data.get("messages"))
|
||||
|
||||
return {
|
||||
"session_key": session_key,
|
||||
"channel": channel,
|
||||
"chat_id": chat_id,
|
||||
"title": title,
|
||||
"preview": preview,
|
||||
}
|
||||
|
||||
|
||||
def _session_preview(messages: Any) -> str:
|
||||
if not isinstance(messages, list):
|
||||
return ""
|
||||
fallback_preview = ""
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
if message.get(CRON_HISTORY_META) is True:
|
||||
continue
|
||||
text = _message_preview_text(message)
|
||||
if not text:
|
||||
continue
|
||||
if message.get("role") == "user":
|
||||
return text
|
||||
if not fallback_preview and message.get("role") == "assistant":
|
||||
fallback_preview = text
|
||||
return fallback_preview
|
||||
|
||||
@@ -9,11 +9,13 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.config.paths import get_webui_dir
|
||||
from nanobot.cron.session_turns import CRON_HISTORY_META
|
||||
from nanobot.session.manager import (
|
||||
_SESSION_LIST_PREVIEW_MAX_CHARS,
|
||||
@@ -26,6 +28,8 @@ from nanobot.session.manager import (
|
||||
|
||||
_INDEX_VERSION = 1
|
||||
_INDEX_FILENAME = ".webui_session_index.json"
|
||||
_WEBUI_ACTIVITY_MTIME_NS = "webui_activity_mtime_ns"
|
||||
_WEBUI_ACTIVITY_SIZE = "webui_activity_size"
|
||||
|
||||
|
||||
def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]:
|
||||
@@ -117,7 +121,13 @@ def _indexed_row_matches_file(row: dict[str, Any], path: Path) -> bool:
|
||||
signature = _file_signature(path)
|
||||
except OSError:
|
||||
return False
|
||||
return row.get("mtime_ns") == signature["mtime_ns"] and row.get("size") == signature["size"]
|
||||
activity_signature = _webui_activity_signature(str(row.get("key")))
|
||||
return (
|
||||
row.get("mtime_ns") == signature["mtime_ns"]
|
||||
and row.get("size") == signature["size"]
|
||||
and row.get(_WEBUI_ACTIVITY_MTIME_NS) == activity_signature[_WEBUI_ACTIVITY_MTIME_NS]
|
||||
and row.get(_WEBUI_ACTIVITY_SIZE) == activity_signature[_WEBUI_ACTIVITY_SIZE]
|
||||
)
|
||||
|
||||
|
||||
def _public_row(sessions_dir: Path, row: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -155,17 +165,69 @@ def _preview_from_messages(messages: list[dict[str, Any]]) -> str:
|
||||
return fallback_preview
|
||||
|
||||
|
||||
def _webui_activity_paths(session_key: str) -> list[Path]:
|
||||
stem = SessionManager.safe_key(session_key)
|
||||
webui_dir = get_webui_dir()
|
||||
return [
|
||||
webui_dir / f"{stem}.jsonl",
|
||||
webui_dir / f"{stem}.json",
|
||||
]
|
||||
|
||||
|
||||
def _webui_activity_signature(session_key: str) -> dict[str, int]:
|
||||
latest_mtime_ns = 0
|
||||
total_size = 0
|
||||
for path in _webui_activity_paths(session_key):
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
if not path.is_file():
|
||||
continue
|
||||
latest_mtime_ns = max(latest_mtime_ns, stat.st_mtime_ns)
|
||||
total_size += stat.st_size
|
||||
return {
|
||||
_WEBUI_ACTIVITY_MTIME_NS: latest_mtime_ns,
|
||||
_WEBUI_ACTIVITY_SIZE: total_size,
|
||||
}
|
||||
|
||||
|
||||
def _webui_activity_updated_at(signature: dict[str, int]) -> str | None:
|
||||
mtime_ns = signature.get(_WEBUI_ACTIVITY_MTIME_NS, 0)
|
||||
if mtime_ns <= 0:
|
||||
return None
|
||||
return datetime.fromtimestamp(mtime_ns / 1_000_000_000).isoformat()
|
||||
|
||||
|
||||
def _timestamp(value: str | None) -> float:
|
||||
if not value:
|
||||
return 0.0
|
||||
try:
|
||||
return datetime.fromisoformat(value).timestamp()
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _latest_updated_at(stored: str | None, activity: str | None) -> str | None:
|
||||
if _timestamp(activity) > _timestamp(stored):
|
||||
return activity
|
||||
return stored
|
||||
|
||||
|
||||
def _indexed_row_for_session(session: Session, path: Path) -> dict[str, Any]:
|
||||
signature = _file_signature(path)
|
||||
activity_signature = _webui_activity_signature(session.key)
|
||||
activity_updated_at = _webui_activity_updated_at(activity_signature)
|
||||
return {
|
||||
"key": session.key,
|
||||
"created_at": session.created_at.isoformat(),
|
||||
"updated_at": session.updated_at.isoformat(),
|
||||
"updated_at": _latest_updated_at(session.updated_at.isoformat(), activity_updated_at),
|
||||
"title": _metadata_title(session.metadata),
|
||||
"preview": _preview_from_messages(session.messages),
|
||||
"file": path.name,
|
||||
"mtime_ns": signature["mtime_ns"],
|
||||
"size": signature["size"],
|
||||
**activity_signature,
|
||||
}
|
||||
|
||||
|
||||
@@ -207,15 +269,19 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
|
||||
if not fallback_preview and item.get("role") == "assistant":
|
||||
fallback_preview = text
|
||||
signature = _file_signature(path)
|
||||
key = data.get("key") or fallback_key
|
||||
activity_signature = _webui_activity_signature(key)
|
||||
activity_updated_at = _webui_activity_updated_at(activity_signature)
|
||||
return {
|
||||
"key": data.get("key") or fallback_key,
|
||||
"key": key,
|
||||
"created_at": data.get("created_at"),
|
||||
"updated_at": data.get("updated_at"),
|
||||
"updated_at": _latest_updated_at(data.get("updated_at"), activity_updated_at),
|
||||
"title": _metadata_title(data.get("metadata", {})),
|
||||
"preview": preview or fallback_preview,
|
||||
"file": path.name,
|
||||
"mtime_ns": signature["mtime_ns"],
|
||||
"size": signature["size"],
|
||||
**activity_signature,
|
||||
}
|
||||
except Exception:
|
||||
repaired = session_manager._repair(fallback_key)
|
||||
|
||||
+243
-2
@@ -17,12 +17,15 @@ import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
from loguru import logger
|
||||
from websockets.http11 import Request as WsRequest
|
||||
from websockets.http11 import Response
|
||||
|
||||
from nanobot.command.builtin import builtin_command_palette
|
||||
from nanobot.cron.session_turns import is_bound_cron_job
|
||||
from nanobot.cron.types import CronJob, CronSchedule
|
||||
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
|
||||
from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload
|
||||
from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_payload
|
||||
@@ -64,6 +67,7 @@ from nanobot.webui.http_utils import (
|
||||
)
|
||||
from nanobot.webui.media_gateway import WebUIMediaGateway
|
||||
from nanobot.webui.session_automations import (
|
||||
all_automations_payload,
|
||||
serialize_automation_jobs,
|
||||
session_automation_jobs,
|
||||
session_automations_payload,
|
||||
@@ -79,6 +83,7 @@ from nanobot.webui.transcript import build_webui_thread_response
|
||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||
|
||||
_SLOW_WEBUI_HTTP_LOG_MS = 1_000
|
||||
_AUTOMATION_VALUES_HEADER = "X-Nanobot-Automation-Values"
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.bus.queue import MessageBus
|
||||
@@ -87,8 +92,6 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
def _decode_api_key(raw_key: str) -> str | None:
|
||||
from urllib.parse import unquote
|
||||
|
||||
key = unquote(raw_key)
|
||||
_api_key_re = re.compile(r"^[A-Za-z0-9_:.-]{1,128}$")
|
||||
if _api_key_re.match(key) is None:
|
||||
@@ -236,6 +239,11 @@ class GatewayHTTPHandler:
|
||||
if response is not None:
|
||||
return response
|
||||
|
||||
# Automation routes
|
||||
response = await self._dispatch_automation_routes(request, got)
|
||||
if response is not None:
|
||||
return response
|
||||
|
||||
# Misc routes
|
||||
response = await self._dispatch_misc_routes(connection, request, got)
|
||||
if response is not None:
|
||||
@@ -514,6 +522,112 @@ class GatewayHTTPHandler:
|
||||
delete_webui_thread(decoded_key)
|
||||
return _http_json_response({"deleted": bool(deleted)})
|
||||
|
||||
# -- Automation routes --------------------------------------------------
|
||||
|
||||
async def _dispatch_automation_routes(
|
||||
self,
|
||||
request: WsRequest,
|
||||
got: str,
|
||||
) -> Response | None:
|
||||
if got == "/api/webui/automations":
|
||||
return self._handle_webui_automations(request)
|
||||
m = re.match(r"^/api/webui/automations/(enable|disable|delete|run|update)$", got)
|
||||
if m:
|
||||
return await self._handle_webui_automation_action(request, m.group(1))
|
||||
return None
|
||||
|
||||
def _pending_cron_job_ids_for_all(self) -> set[str]:
|
||||
if self.cron_service is None or self.cron_pending_job_ids is None:
|
||||
return set()
|
||||
pending: set[str] = set()
|
||||
for job in self.cron_service.list_jobs(include_disabled=True):
|
||||
session_key = job.payload.session_key
|
||||
if not session_key and job.payload.origin_channel and job.payload.origin_chat_id:
|
||||
session_key = f"{job.payload.origin_channel}:{job.payload.origin_chat_id}"
|
||||
if session_key:
|
||||
pending.update(self.cron_pending_job_ids(session_key))
|
||||
return pending
|
||||
|
||||
def _handle_webui_automations(self, request: WsRequest) -> Response:
|
||||
if not self.check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
return _http_json_response(
|
||||
all_automations_payload(
|
||||
self.cron_service,
|
||||
session_manager=self.session_manager,
|
||||
pending_job_ids=self._pending_cron_job_ids_for_all(),
|
||||
)
|
||||
)
|
||||
|
||||
async def _handle_webui_automation_action(
|
||||
self,
|
||||
request: WsRequest,
|
||||
action: str,
|
||||
) -> Response:
|
||||
if not self.check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
if self.cron_service is None:
|
||||
return _http_error(503, "cron service unavailable")
|
||||
|
||||
query = _parse_query(request.path)
|
||||
job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip()
|
||||
if not job_id:
|
||||
return _http_error(400, "missing automation id")
|
||||
job = self.cron_service.get_job(job_id)
|
||||
if job is None:
|
||||
return _http_error(404, "automation not found")
|
||||
if job.payload.kind == "system_event":
|
||||
return _http_error(403, "system automation is protected")
|
||||
if action in {"enable", "run"} and not is_bound_cron_job(job):
|
||||
return _http_error(409, "automation has no linked chat")
|
||||
|
||||
if action == "enable":
|
||||
if self.cron_service.enable_job(job_id, enabled=True) is None:
|
||||
return _http_error(404, "automation not found")
|
||||
elif action == "disable":
|
||||
if self.cron_service.enable_job(job_id, enabled=False) is None:
|
||||
return _http_error(404, "automation not found")
|
||||
elif action == "delete":
|
||||
result = self.cron_service.remove_job(job_id)
|
||||
if result == "not_found":
|
||||
return _http_error(404, "automation not found")
|
||||
if result == "protected":
|
||||
return _http_error(403, "system automation is protected")
|
||||
elif action == "run":
|
||||
if not job.enabled:
|
||||
return _http_error(409, "automation is disabled")
|
||||
task = asyncio.create_task(self.cron_service.run_job(job_id, force=False))
|
||||
task.add_done_callback(self._log_automation_run_result)
|
||||
elif action == "update":
|
||||
values = _automation_values_from_request(request)
|
||||
if values is None:
|
||||
return _http_error(400, "invalid automation update payload")
|
||||
parsed = _parse_automation_update(values, current_job=job)
|
||||
if isinstance(parsed, str):
|
||||
return _http_error(400, parsed)
|
||||
try:
|
||||
result = self.cron_service.update_job(job_id, **parsed)
|
||||
except ValueError as exc:
|
||||
return _http_error(400, str(exc))
|
||||
if result == "not_found":
|
||||
return _http_error(404, "automation not found")
|
||||
if result == "protected":
|
||||
return _http_error(403, "system automation is protected")
|
||||
else:
|
||||
return _http_error(404, "unknown automation action")
|
||||
|
||||
return self._handle_webui_automations(request)
|
||||
|
||||
@staticmethod
|
||||
def _log_automation_run_result(task: asyncio.Task[bool]) -> None:
|
||||
try:
|
||||
ran = task.result()
|
||||
except Exception:
|
||||
logger.exception("WebUI automation run-now task failed")
|
||||
return
|
||||
if not ran:
|
||||
logger.warning("WebUI automation run-now task did not execute")
|
||||
|
||||
# -- Media routes -------------------------------------------------------
|
||||
|
||||
def _dispatch_media_routes(self, request: WsRequest, got: str) -> Response | None:
|
||||
@@ -662,5 +776,132 @@ class GatewayHTTPHandler:
|
||||
extra_headers=[("Cache-Control", cache)],
|
||||
)
|
||||
|
||||
|
||||
def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None:
|
||||
raw = _case_insensitive_header(request.headers, _AUTOMATION_VALUES_HEADER)
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
values = json.loads(raw)
|
||||
except Exception:
|
||||
try:
|
||||
values = json.loads(unquote(raw))
|
||||
except Exception:
|
||||
return None
|
||||
return values if isinstance(values, dict) else None
|
||||
|
||||
|
||||
def _parse_automation_update(
|
||||
values: dict[str, Any],
|
||||
*,
|
||||
current_job: CronJob | None = None,
|
||||
) -> dict[str, Any] | str:
|
||||
update: dict[str, Any] = {}
|
||||
if "name" in values:
|
||||
raw_name = values.get("name")
|
||||
if not isinstance(raw_name, str):
|
||||
return "name must be a string"
|
||||
name = raw_name.strip()
|
||||
if not name:
|
||||
return "name cannot be empty"
|
||||
update["name"] = name
|
||||
if "message" in values:
|
||||
raw_message = values.get("message")
|
||||
if not isinstance(raw_message, str):
|
||||
return "message must be a string"
|
||||
message = raw_message.strip()
|
||||
if not message:
|
||||
return "message cannot be empty"
|
||||
update["message"] = message
|
||||
if "schedule" in values:
|
||||
raw_schedule = values.get("schedule")
|
||||
if not isinstance(raw_schedule, dict):
|
||||
return "schedule must be an object"
|
||||
parsed_schedule = _parse_automation_schedule(raw_schedule)
|
||||
if isinstance(parsed_schedule, str):
|
||||
return parsed_schedule
|
||||
if current_job is not None and _schedule_matches_job(parsed_schedule, current_job):
|
||||
return update
|
||||
schedule_error = _validate_automation_schedule(parsed_schedule)
|
||||
if schedule_error:
|
||||
return schedule_error
|
||||
update["schedule"] = parsed_schedule
|
||||
update["delete_after_run"] = parsed_schedule.kind == "at"
|
||||
return update
|
||||
|
||||
|
||||
def _parse_automation_schedule(values: dict[str, Any]) -> CronSchedule | str:
|
||||
raw_kind = values.get("kind")
|
||||
if not isinstance(raw_kind, str):
|
||||
return "schedule kind must be a string"
|
||||
kind = raw_kind.strip()
|
||||
if kind == "every":
|
||||
every_ms = _positive_int(values.get("every_ms"))
|
||||
if every_ms is None:
|
||||
return "every schedule requires positive every_ms"
|
||||
return CronSchedule(kind="every", every_ms=every_ms)
|
||||
if kind == "cron":
|
||||
raw_expr = values.get("expr")
|
||||
if not isinstance(raw_expr, str):
|
||||
return "cron schedule requires expr"
|
||||
expr = raw_expr.strip()
|
||||
if not expr:
|
||||
return "cron schedule requires expr"
|
||||
raw_tz = values.get("tz")
|
||||
if raw_tz is not None and not isinstance(raw_tz, str):
|
||||
return "cron schedule timezone must be a string"
|
||||
tz = raw_tz.strip() if isinstance(raw_tz, str) else ""
|
||||
return CronSchedule(kind="cron", expr=expr, tz=tz or None)
|
||||
if kind == "at":
|
||||
at_ms = _positive_int(values.get("at_ms"))
|
||||
if at_ms is None:
|
||||
return "one-time schedule requires positive at_ms"
|
||||
return CronSchedule(kind="at", at_ms=at_ms)
|
||||
return "unknown schedule kind"
|
||||
|
||||
|
||||
def _schedule_matches_job(schedule: CronSchedule, job: CronJob) -> bool:
|
||||
current = job.schedule
|
||||
if schedule.kind != current.kind:
|
||||
return False
|
||||
if schedule.kind == "at":
|
||||
return schedule.at_ms == current.at_ms
|
||||
if schedule.kind == "every":
|
||||
return schedule.every_ms == current.every_ms
|
||||
if schedule.kind == "cron":
|
||||
return (schedule.expr or "") == (current.expr or "") and (
|
||||
schedule.tz or None
|
||||
) == (current.tz or None)
|
||||
return False
|
||||
|
||||
|
||||
def _validate_automation_schedule(schedule: CronSchedule) -> str | None:
|
||||
if schedule.kind == "at":
|
||||
if not schedule.at_ms or schedule.at_ms <= int(time.time() * 1000):
|
||||
return "one-time schedule must be in the future"
|
||||
return None
|
||||
if schedule.kind != "cron":
|
||||
return None
|
||||
|
||||
try:
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from croniter import croniter
|
||||
|
||||
tz = ZoneInfo(schedule.tz) if schedule.tz else datetime.now().astimezone().tzinfo
|
||||
base = datetime.now(tz=tz)
|
||||
croniter(schedule.expr, base).get_next(datetime)
|
||||
except Exception:
|
||||
return "cron schedule is invalid"
|
||||
return None
|
||||
|
||||
|
||||
def _positive_int(value: Any) -> int | None:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
return None
|
||||
return value if value > 0 else None
|
||||
|
||||
|
||||
def _is_websocket_channel_session_key(key: str) -> bool:
|
||||
return key.startswith("websocket:")
|
||||
|
||||
+180
-35
@@ -11,6 +11,9 @@ $Package = "nanobot-ai"
|
||||
$MainSource = "https://github.com/HKUDS/nanobot/archive/refs/heads/main.zip"
|
||||
$InstallTarget = $Package
|
||||
$InstallSource = "PyPI"
|
||||
$script:NanobotRunner = $null
|
||||
$script:NanobotPython = $null
|
||||
$script:LastInstallSucceeded = $false
|
||||
|
||||
function Write-Info {
|
||||
param([string]$Message)
|
||||
@@ -23,13 +26,15 @@ function Fail {
|
||||
}
|
||||
|
||||
function Show-InstallFailureHint {
|
||||
[Console]::Error.WriteLine("Error: pip could not install nanobot from $InstallSource.")
|
||||
[Console]::Error.WriteLine("If pip mentioned externally-managed-environment, install in a virtual environment or use uv/pipx.")
|
||||
[Console]::Error.WriteLine("Error: could not install nanobot from $InstallSource.")
|
||||
[Console]::Error.WriteLine("If pip mentioned externally-managed-environment, use uv, pipx, or a virtual environment instead of system pip.")
|
||||
[Console]::Error.WriteLine("You can also run manually:")
|
||||
[Console]::Error.WriteLine(" $Python -m pip install --upgrade $InstallTarget")
|
||||
[Console]::Error.WriteLine(" uv tool install --force --upgrade $InstallTarget")
|
||||
[Console]::Error.WriteLine(" $Python -m venv `$HOME\.nanobot\venv")
|
||||
[Console]::Error.WriteLine(" `$HOME\.nanobot\venv\Scripts\python.exe -m pip install --upgrade $InstallTarget")
|
||||
[Console]::Error.WriteLine("Then start setup with:")
|
||||
[Console]::Error.WriteLine(" $Python -m nanobot onboard --wizard")
|
||||
throw "pip could not install nanobot from $InstallSource"
|
||||
[Console]::Error.WriteLine(" nanobot onboard --wizard")
|
||||
throw "could not install nanobot from $InstallSource"
|
||||
}
|
||||
|
||||
function Show-Usage {
|
||||
@@ -72,6 +77,131 @@ function Find-Python {
|
||||
Fail "Python 3.11 or newer was not found. Install Python first, then rerun this command."
|
||||
}
|
||||
|
||||
function Test-VirtualEnv {
|
||||
param([string]$Command)
|
||||
try {
|
||||
& $Command -c "import sys; raise SystemExit(0 if sys.prefix != sys.base_prefix else 1)" *> $null
|
||||
return $LASTEXITCODE -eq 0
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Ensure-Pip {
|
||||
param([string]$Command)
|
||||
|
||||
try {
|
||||
& $Command -m pip --version *> $null
|
||||
} catch {}
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
return
|
||||
}
|
||||
|
||||
Write-Info "pip was not found for $Command. Trying ensurepip..."
|
||||
& $Command -m ensurepip --upgrade *> $null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Fail "pip is not available. Install pip for $Command, then rerun this command."
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Nanobot {
|
||||
param([string[]]$NanobotArgs)
|
||||
|
||||
switch ($script:NanobotRunner) {
|
||||
"uv" {
|
||||
& uv tool run --from $InstallTarget nanobot @NanobotArgs
|
||||
}
|
||||
"pipx" {
|
||||
& pipx run --spec $InstallTarget nanobot @NanobotArgs
|
||||
}
|
||||
"python" {
|
||||
& $script:NanobotPython -m nanobot @NanobotArgs
|
||||
}
|
||||
default {
|
||||
Fail "nanobot was installed, but no runner was configured."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-NanobotCommand {
|
||||
switch ($script:NanobotRunner) {
|
||||
"uv" { return "uv tool run --from $InstallTarget nanobot" }
|
||||
"pipx" { return "pipx run --spec $InstallTarget nanobot" }
|
||||
"python" { return "$script:NanobotPython -m nanobot" }
|
||||
default { return "nanobot" }
|
||||
}
|
||||
}
|
||||
|
||||
function Install-WithActivePython {
|
||||
Write-Info "Detected an active virtual environment. Installing into it..."
|
||||
Ensure-Pip $Python
|
||||
& $Python -m pip install --upgrade $InstallTarget
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Show-InstallFailureHint
|
||||
}
|
||||
$script:NanobotRunner = "python"
|
||||
$script:NanobotPython = $Python
|
||||
}
|
||||
|
||||
function Install-WithUv {
|
||||
$script:LastInstallSucceeded = $false
|
||||
Write-Info "Installing or upgrading nanobot from $InstallSource with uv tool..."
|
||||
& uv tool install --python $Python --force --upgrade $InstallTarget
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
return
|
||||
}
|
||||
$script:NanobotRunner = "uv"
|
||||
$script:LastInstallSucceeded = $true
|
||||
}
|
||||
|
||||
function Install-WithPipx {
|
||||
$script:LastInstallSucceeded = $false
|
||||
Write-Info "Installing or upgrading nanobot from $InstallSource with pipx..."
|
||||
& pipx install --python $Python --force $InstallTarget
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
return
|
||||
}
|
||||
$script:NanobotRunner = "pipx"
|
||||
$script:LastInstallSucceeded = $true
|
||||
}
|
||||
|
||||
function Install-WithManagedVenv {
|
||||
$HomeDir = if ($env:HOME) { $env:HOME } elseif ($env:USERPROFILE) { $env:USERPROFILE } else { $null }
|
||||
if (-not $HomeDir) {
|
||||
Fail "HOME is not set; cannot create a managed virtual environment."
|
||||
}
|
||||
|
||||
$VenvDir = if ($env:NANOBOT_VENV) { $env:NANOBOT_VENV } else { Join-Path $HomeDir ".nanobot\venv" }
|
||||
$VenvPython = Join-Path $VenvDir "Scripts\python.exe"
|
||||
|
||||
if (-not (Test-Path $VenvPython)) {
|
||||
Write-Info "Creating a dedicated virtual environment at $VenvDir..."
|
||||
$Parent = Split-Path -Parent $VenvDir
|
||||
if ($Parent) {
|
||||
New-Item -ItemType Directory -Force -Path $Parent *> $null
|
||||
}
|
||||
& $Python -m venv $VenvDir
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Show-InstallFailureHint
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Python $VenvPython)) {
|
||||
Fail "The managed venv uses Python older than 3.11. Remove it or set NANOBOT_VENV to a new path."
|
||||
}
|
||||
|
||||
Write-Info "Installing or upgrading nanobot from $InstallSource in $VenvDir..."
|
||||
Ensure-Pip $VenvPython
|
||||
& $VenvPython -m pip install --upgrade $InstallTarget
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Show-InstallFailureHint
|
||||
}
|
||||
|
||||
$script:NanobotRunner = "python"
|
||||
$script:NanobotPython = $VenvPython
|
||||
}
|
||||
|
||||
foreach ($Arg in $RemainingArgs) {
|
||||
switch ($Arg) {
|
||||
"--dev" {
|
||||
@@ -103,61 +233,76 @@ $Python = Find-Python
|
||||
$Version = & $Python --version
|
||||
Write-Info "Using Python: $Version"
|
||||
|
||||
try {
|
||||
& $Python -m pip --version *> $null
|
||||
} catch {}
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
if ($DryRun) {
|
||||
Write-Info "Dry run: pip was not found. Install would try: $Python -m ensurepip --upgrade"
|
||||
} else {
|
||||
Write-Info "pip was not found for this Python. Trying ensurepip..."
|
||||
& $Python -m ensurepip --upgrade *> $null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Fail "pip is not available. Install pip for $Python, then rerun this command."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($DryRun) {
|
||||
Write-Info "Dry run: would install or upgrade nanobot from $InstallSource."
|
||||
Write-Info "Dry run: would run: $Python -m pip install --upgrade $InstallTarget"
|
||||
Write-Info "Dry run: if that fails because system site-packages are not writable, would retry: $Python -m pip install --user --upgrade $InstallTarget"
|
||||
if (Test-VirtualEnv $Python) {
|
||||
Write-Info "Dry run: active virtual environment detected; would run: $Python -m pip install --upgrade $InstallTarget"
|
||||
Write-Info "Dry run: would run nanobot as: $Python -m nanobot"
|
||||
} elseif (Get-Command uv -ErrorAction SilentlyContinue) {
|
||||
Write-Info "Dry run: would run: uv tool install --python $Python --force --upgrade $InstallTarget"
|
||||
Write-Info "Dry run: would run nanobot as: uv tool run --from $InstallTarget nanobot"
|
||||
} elseif (Get-Command pipx -ErrorAction SilentlyContinue) {
|
||||
Write-Info "Dry run: would run: pipx install --python $Python --force $InstallTarget"
|
||||
Write-Info "Dry run: would run nanobot as: pipx run --spec $InstallTarget nanobot"
|
||||
} else {
|
||||
$HomeDir = if ($env:HOME) { $env:HOME } elseif ($env:USERPROFILE) { $env:USERPROFILE } else { "~" }
|
||||
$VenvDir = if ($env:NANOBOT_VENV) { $env:NANOBOT_VENV } else { Join-Path $HomeDir ".nanobot\venv" }
|
||||
Write-Info "Dry run: would create or reuse a dedicated virtual environment: $VenvDir"
|
||||
Write-Info "Dry run: would run: $VenvDir\Scripts\python.exe -m pip install --upgrade $InstallTarget"
|
||||
Write-Info "Dry run: would run nanobot as: $VenvDir\Scripts\python.exe -m nanobot"
|
||||
}
|
||||
if ($env:NANOBOT_SKIP_WIZARD -eq "1") {
|
||||
Write-Info "Dry run: would skip setup wizard because NANOBOT_SKIP_WIZARD=1."
|
||||
} else {
|
||||
Write-Info "Dry run: would run: $Python -m nanobot onboard --wizard"
|
||||
Write-Info "Dry run: would run the setup wizard."
|
||||
}
|
||||
Write-Info "Dry run: no changes made."
|
||||
return
|
||||
}
|
||||
|
||||
Write-Info "Installing or upgrading nanobot from $InstallSource..."
|
||||
& $Python -m pip install --upgrade $InstallTarget
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Info "Install failed. Retrying as a user install..."
|
||||
& $Python -m pip install --user --upgrade $InstallTarget
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Show-InstallFailureHint
|
||||
if (Test-VirtualEnv $Python) {
|
||||
Install-WithActivePython
|
||||
} else {
|
||||
$Installed = $false
|
||||
|
||||
if (Get-Command uv -ErrorAction SilentlyContinue) {
|
||||
Install-WithUv
|
||||
$Installed = $script:LastInstallSucceeded
|
||||
if (-not $Installed) {
|
||||
Write-Info "uv tool install failed. Trying the next isolated install method..."
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $Installed -and (Get-Command pipx -ErrorAction SilentlyContinue)) {
|
||||
Install-WithPipx
|
||||
$Installed = $script:LastInstallSucceeded
|
||||
if (-not $Installed) {
|
||||
Write-Info "pipx install failed. Trying the managed virtual environment..."
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $Installed) {
|
||||
Write-Info "Using a dedicated virtual environment to avoid system pip."
|
||||
Install-WithManagedVenv
|
||||
}
|
||||
}
|
||||
|
||||
Write-Info "Installed nanobot:"
|
||||
& $Python -m nanobot --version
|
||||
Invoke-Nanobot @("--version")
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Fail "nanobot was installed, but the command could not be started."
|
||||
}
|
||||
|
||||
if ($env:NANOBOT_SKIP_WIZARD -eq "1") {
|
||||
Write-Info "Skipping setup wizard because NANOBOT_SKIP_WIZARD=1."
|
||||
Write-Info "Run this later: $Python -m nanobot onboard --wizard"
|
||||
Write-Info "Run this later: $(Get-NanobotCommand) onboard --wizard"
|
||||
return
|
||||
}
|
||||
|
||||
Write-Info "Starting setup wizard..."
|
||||
& $Python -m nanobot onboard --wizard
|
||||
Invoke-Nanobot @("onboard", "--wizard")
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Fail "Setup wizard did not complete."
|
||||
}
|
||||
|
||||
Write-Info "Done. Try: $Python -m nanobot agent -m `"Hello!`""
|
||||
Write-Info "Done. Try: $(Get-NanobotCommand) agent -m `"Hello!`""
|
||||
|
||||
+170
-24
@@ -6,6 +6,8 @@ main_source="https://github.com/HKUDS/nanobot/archive/refs/heads/main.zip"
|
||||
install_target="$package"
|
||||
install_source="PyPI"
|
||||
dry_run="0"
|
||||
nanobot_runner=""
|
||||
nanobot_python=""
|
||||
|
||||
info() {
|
||||
printf '%s\n' "$*"
|
||||
@@ -17,12 +19,14 @@ fail() {
|
||||
}
|
||||
|
||||
install_failure_hint() {
|
||||
printf '%s\n' "Error: pip could not install nanobot from $install_source." >&2
|
||||
printf '%s\n' "If pip mentioned externally-managed-environment, install in a virtual environment or use uv/pipx." >&2
|
||||
printf '%s\n' "Error: could not install nanobot from $install_source." >&2
|
||||
printf '%s\n' "If pip mentioned externally-managed-environment, use uv, pipx, or a virtual environment instead of system pip." >&2
|
||||
printf '%s\n' "You can also run manually:" >&2
|
||||
printf ' %s\n' "$python_bin -m pip install --upgrade $install_target" >&2
|
||||
printf ' %s\n' "uv tool install --force --upgrade $install_target" >&2
|
||||
printf ' %s\n' "$python_bin -m venv ~/.nanobot/venv" >&2
|
||||
printf ' %s\n' "~/.nanobot/venv/bin/python -m pip install --upgrade $install_target" >&2
|
||||
printf '%s\n' "Then start setup with:" >&2
|
||||
printf ' %s\n' "$python_bin -m nanobot onboard --wizard" >&2
|
||||
printf ' %s\n' "nanobot onboard --wizard" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -52,6 +56,123 @@ PY
|
||||
return 1
|
||||
}
|
||||
|
||||
python_is_virtual_env() {
|
||||
"$python_bin" - <<'PY'
|
||||
import sys
|
||||
raise SystemExit(0 if sys.prefix != sys.base_prefix else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
ensure_pip() {
|
||||
target_python="$1"
|
||||
if "$target_python" -m pip --version >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
info "pip was not found for $target_python. Trying ensurepip..."
|
||||
"$target_python" -m ensurepip --upgrade >/dev/null 2>&1
|
||||
}
|
||||
|
||||
run_nanobot() {
|
||||
case "$nanobot_runner" in
|
||||
uv)
|
||||
uv tool run --from "$install_target" nanobot "$@"
|
||||
;;
|
||||
pipx)
|
||||
pipx run --spec "$install_target" nanobot "$@"
|
||||
;;
|
||||
python)
|
||||
"$nanobot_python" -m nanobot "$@"
|
||||
;;
|
||||
*)
|
||||
fail "nanobot was installed, but no runner was configured"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
nanobot_try_command() {
|
||||
case "$nanobot_runner" in
|
||||
uv)
|
||||
printf '%s\n' "uv tool run --from $install_target nanobot"
|
||||
;;
|
||||
pipx)
|
||||
printf '%s\n' "pipx run --spec $install_target nanobot"
|
||||
;;
|
||||
python)
|
||||
printf '%s\n' "$nanobot_python -m nanobot"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
install_with_active_python() {
|
||||
info "Detected an active virtual environment. Installing into it..."
|
||||
ensure_pip "$python_bin" || return 1
|
||||
"$python_bin" -m pip install --upgrade "$install_target" || return 1
|
||||
nanobot_runner="python"
|
||||
nanobot_python="$python_bin"
|
||||
}
|
||||
|
||||
install_with_uv() {
|
||||
info "Installing or upgrading nanobot from $install_source with uv tool..."
|
||||
uv tool install --python "$python_bin" --force --upgrade "$install_target" || return 1
|
||||
nanobot_runner="uv"
|
||||
}
|
||||
|
||||
install_with_pipx() {
|
||||
info "Installing or upgrading nanobot from $install_source with pipx..."
|
||||
pipx install --python "$python_bin" --force "$install_target" || return 1
|
||||
nanobot_runner="pipx"
|
||||
}
|
||||
|
||||
write_managed_wrapper() {
|
||||
bin_dir="${NANOBOT_BIN_DIR:-$HOME/.local/bin}"
|
||||
wrapper="$bin_dir/nanobot"
|
||||
mkdir -p "$bin_dir" || return 0
|
||||
|
||||
if [ -e "$wrapper" ] && ! grep -q "Generated by nanobot installer" "$wrapper" 2>/dev/null; then
|
||||
info "Not updating $wrapper because it already exists."
|
||||
return 0
|
||||
fi
|
||||
|
||||
cat > "$wrapper" <<EOF
|
||||
#!/bin/sh
|
||||
# Generated by nanobot installer.
|
||||
exec "$nanobot_python" -m nanobot "\$@"
|
||||
EOF
|
||||
chmod +x "$wrapper" || return 0
|
||||
|
||||
if ! command -v nanobot >/dev/null 2>&1; then
|
||||
info "Installed a nanobot launcher at $wrapper."
|
||||
info "Add $bin_dir to PATH to run nanobot directly."
|
||||
fi
|
||||
}
|
||||
|
||||
install_with_managed_venv() {
|
||||
[ -n "${HOME:-}" ] || fail "HOME is not set; cannot create a managed virtual environment"
|
||||
|
||||
venv_dir="${NANOBOT_VENV:-$HOME/.nanobot/venv}"
|
||||
venv_python="$venv_dir/bin/python"
|
||||
|
||||
if [ ! -x "$venv_python" ]; then
|
||||
info "Creating a dedicated virtual environment at $venv_dir..."
|
||||
mkdir -p "$(dirname "$venv_dir")"
|
||||
"$python_bin" -m venv "$venv_dir" || return 1
|
||||
fi
|
||||
|
||||
"$venv_python" - <<'PY' >/dev/null 2>&1 || fail "The managed venv uses Python older than 3.11. Remove it or set NANOBOT_VENV to a new path."
|
||||
import sys
|
||||
raise SystemExit(0 if sys.version_info >= (3, 11) else 1)
|
||||
PY
|
||||
|
||||
info "Installing or upgrading nanobot from $install_source in $venv_dir..."
|
||||
ensure_pip "$venv_python" || return 1
|
||||
"$venv_python" -m pip install --upgrade "$install_target" || return 1
|
||||
|
||||
nanobot_runner="python"
|
||||
nanobot_python="$venv_python"
|
||||
write_managed_wrapper
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dev)
|
||||
@@ -86,44 +207,69 @@ fi
|
||||
|
||||
info "Using Python: $("$python_bin" --version 2>&1)"
|
||||
|
||||
if ! "$python_bin" -m pip --version >/dev/null 2>&1; then
|
||||
if [ "$dry_run" = "1" ]; then
|
||||
info "Dry run: pip was not found. Install would try: $python_bin -m ensurepip --upgrade"
|
||||
else
|
||||
info "pip was not found for this Python. Trying ensurepip..."
|
||||
"$python_bin" -m ensurepip --upgrade >/dev/null 2>&1 || fail "pip is not available. Install pip for $python_bin, then rerun this command."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$dry_run" = "1" ]; then
|
||||
info "Dry run: would install or upgrade nanobot from $install_source."
|
||||
info "Dry run: would run: $python_bin -m pip install --upgrade $install_target"
|
||||
info "Dry run: if that fails because system site-packages are not writable, would retry: $python_bin -m pip install --user --upgrade $install_target"
|
||||
if python_is_virtual_env; then
|
||||
info "Dry run: active virtual environment detected; would run: $python_bin -m pip install --upgrade $install_target"
|
||||
info "Dry run: would run nanobot as: $python_bin -m nanobot"
|
||||
elif command -v uv >/dev/null 2>&1; then
|
||||
info "Dry run: would run: uv tool install --python $python_bin --force --upgrade $install_target"
|
||||
info "Dry run: would run nanobot as: uv tool run --from $install_target nanobot"
|
||||
elif command -v pipx >/dev/null 2>&1; then
|
||||
info "Dry run: would run: pipx install --python $python_bin --force $install_target"
|
||||
info "Dry run: would run nanobot as: pipx run --spec $install_target nanobot"
|
||||
else
|
||||
venv_dir="${NANOBOT_VENV:-$HOME/.nanobot/venv}"
|
||||
info "Dry run: would create or reuse a dedicated virtual environment: $venv_dir"
|
||||
info "Dry run: would run: $venv_dir/bin/python -m pip install --upgrade $install_target"
|
||||
info "Dry run: would run nanobot as: $venv_dir/bin/python -m nanobot"
|
||||
fi
|
||||
if [ "${NANOBOT_SKIP_WIZARD:-}" = "1" ]; then
|
||||
info "Dry run: would skip setup wizard because NANOBOT_SKIP_WIZARD=1."
|
||||
else
|
||||
info "Dry run: would run: $python_bin -m nanobot onboard --wizard"
|
||||
info "Dry run: would run the setup wizard."
|
||||
fi
|
||||
info "Dry run: no changes made."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
info "Installing or upgrading nanobot from $install_source..."
|
||||
if ! "$python_bin" -m pip install --upgrade "$install_target"; then
|
||||
info "Install failed. Retrying as a user install..."
|
||||
"$python_bin" -m pip install --user --upgrade "$install_target" || install_failure_hint
|
||||
if python_is_virtual_env; then
|
||||
install_with_active_python || install_failure_hint
|
||||
else
|
||||
installed="0"
|
||||
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
if install_with_uv; then
|
||||
installed="1"
|
||||
else
|
||||
info "uv tool install failed. Trying the next isolated install method..."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$installed" != "1" ] && command -v pipx >/dev/null 2>&1; then
|
||||
if install_with_pipx; then
|
||||
installed="1"
|
||||
else
|
||||
info "pipx install failed. Trying the managed virtual environment..."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$installed" != "1" ]; then
|
||||
info "Using a dedicated virtual environment to avoid system pip."
|
||||
install_with_managed_venv || install_failure_hint
|
||||
fi
|
||||
fi
|
||||
|
||||
info "Installed nanobot:"
|
||||
"$python_bin" -m nanobot --version
|
||||
run_nanobot --version
|
||||
|
||||
if [ "${NANOBOT_SKIP_WIZARD:-}" = "1" ]; then
|
||||
info "Skipping setup wizard because NANOBOT_SKIP_WIZARD=1."
|
||||
info "Run this later: $python_bin -m nanobot onboard --wizard"
|
||||
info "Run this later: $(nanobot_try_command) onboard --wizard"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
info "Starting setup wizard..."
|
||||
"$python_bin" -m nanobot onboard --wizard
|
||||
run_nanobot onboard --wizard
|
||||
|
||||
info "Done. Try: $python_bin -m nanobot agent -m \"Hello!\""
|
||||
info "Done. Try: $(nanobot_try_command) agent -m \"Hello!\""
|
||||
|
||||
@@ -45,6 +45,33 @@ def _add_turns(session, turns: int, *, prefix: str = "msg") -> None:
|
||||
session.add_message("assistant", f"{prefix} assistant {i}")
|
||||
|
||||
|
||||
def _add_tool_turn(session, prefix: str, idx: int) -> None:
|
||||
call_id = f"{prefix}_{idx}"
|
||||
session.messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {"name": "exec", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
)
|
||||
session.messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"name": "exec",
|
||||
"content": "ok",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _make_fake_compact(
|
||||
loop: AgentLoop,
|
||||
*,
|
||||
@@ -76,7 +103,10 @@ def _make_fake_compact(
|
||||
metadata={},
|
||||
last_consolidated=0,
|
||||
)
|
||||
dropped, already_consolidated = probe.retain_recent_legal_suffix(max_suffix)
|
||||
dropped, already_consolidated = probe.retain_recent_legal_suffix(
|
||||
max_suffix,
|
||||
extend_to_user=True,
|
||||
)
|
||||
kept = probe.messages
|
||||
archive_msgs = dropped[already_consolidated:]
|
||||
|
||||
@@ -121,9 +151,14 @@ async def _drain_background_tasks(loop: AgentLoop) -> None:
|
||||
class TestSessionTTLConfig:
|
||||
"""Test session TTL configuration."""
|
||||
|
||||
def test_default_ttl_is_zero(self):
|
||||
"""Default TTL should be 0 (disabled)."""
|
||||
def test_default_ttl_is_fifteen_minutes(self):
|
||||
"""Default TTL should proactively compact stale sessions."""
|
||||
defaults = AgentDefaults()
|
||||
assert defaults.session_ttl_minutes == 15
|
||||
|
||||
def test_explicit_zero_disables_ttl(self):
|
||||
"""Explicit 0 should still disable idle auto-compact."""
|
||||
defaults = AgentDefaults(session_ttl_minutes=0)
|
||||
assert defaults.session_ttl_minutes == 0
|
||||
|
||||
def test_custom_ttl(self):
|
||||
@@ -305,6 +340,35 @@ class TestAutoCompact:
|
||||
assert session_after.messages[-1]["content"] == "msg assistant 5"
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_compact_extends_recent_suffix_to_user_turn(self, tmp_path):
|
||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
_add_turns(session, 2, prefix="old")
|
||||
session.add_message("user", "record this")
|
||||
for i in range(8):
|
||||
_add_tool_turn(session, "recent", i)
|
||||
session.add_message("assistant", "done")
|
||||
loop.sessions.save(session)
|
||||
|
||||
await loop.auto_compact._archive("cli:test")
|
||||
|
||||
session_after = loop.sessions.get_or_create("cli:test")
|
||||
assert len(session_after.messages) > loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
||||
assert session_after.messages[0]["content"] == "record this"
|
||||
assert session_after.messages[-1]["content"] == "done"
|
||||
tool_results = {
|
||||
m.get("tool_call_id")
|
||||
for m in session_after.messages
|
||||
if m.get("role") == "tool"
|
||||
}
|
||||
assert all(
|
||||
tc["id"] in tool_results
|
||||
for m in session_after.messages
|
||||
for tc in (m.get("tool_calls") or [])
|
||||
)
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_compact_stores_summary(self, tmp_path):
|
||||
"""_archive should store the summary in _summaries."""
|
||||
|
||||
@@ -561,8 +561,8 @@ class TestCompactIdleSession:
|
||||
real_consolidator,
|
||||
mock_provider,
|
||||
):
|
||||
"""Assistant-only tails retain a non-contiguous slice, so archive the
|
||||
actual dropped messages rather than a computed prefix."""
|
||||
"""Assistant-only tails extend back to the latest user turn, so archive
|
||||
the actual dropped messages rather than a computed prefix."""
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||
content="Tail summary.", finish_reason="stop"
|
||||
)
|
||||
@@ -585,12 +585,16 @@ class TestCompactIdleSession:
|
||||
"assistant-02",
|
||||
"assistant-03",
|
||||
"assistant-04",
|
||||
"assistant-05",
|
||||
"assistant-06",
|
||||
"assistant-07",
|
||||
"assistant-08",
|
||||
"assistant-09",
|
||||
]
|
||||
|
||||
# #4264: idle compaction now summarizes the full unconsolidated tail, so
|
||||
# the dropped head (user-00), the non-contiguous dropped tail
|
||||
# (assistant-09), and the retained suffix (user-14) are all summarized.
|
||||
# Retention above still proves the non-contiguous suffix is handled.
|
||||
# the dropped head (user-00) and retained suffix (user-14 through
|
||||
# assistant-09) are all summarized.
|
||||
archived_call = mock_provider.chat_with_retry.call_args
|
||||
user_content = archived_call.kwargs["messages"][1]["content"]
|
||||
assert "user-00" in user_content
|
||||
@@ -821,4 +825,4 @@ class TestArchiveTruncation:
|
||||
enc = tiktoken.get_encoding("cl100k_base")
|
||||
sent_content = mock_provider.chat_with_retry.call_args.kwargs["messages"][1]["content"]
|
||||
token_count = len(enc.encode(sent_content))
|
||||
assert token_count <= 9_900 + 10 # small margin for truncation suffix
|
||||
assert token_count <= 9_900
|
||||
|
||||
@@ -222,18 +222,22 @@ def test_recent_history_capped_at_max(tmp_path) -> None:
|
||||
assert f"entry-{builder._MAX_RECENT_HISTORY + 19}" in prompt
|
||||
|
||||
|
||||
def test_recent_history_truncated_at_max_chars(tmp_path) -> None:
|
||||
"""Recent History section must be truncated at _MAX_HISTORY_CHARS."""
|
||||
def test_recent_history_truncated_at_max_tokens(tmp_path) -> None:
|
||||
"""Recent History section must be truncated to _MAX_HISTORY_TOKENS."""
|
||||
import tiktoken
|
||||
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
big_entry = "x" * (builder._MAX_HISTORY_CHARS + 5_000)
|
||||
big_entry = "word " * (builder._MAX_HISTORY_TOKENS + 5_000)
|
||||
builder.memory.append_history(big_entry)
|
||||
|
||||
prompt = builder.build_system_prompt()
|
||||
history_section = prompt.split("# Recent History\n\n", 1)
|
||||
assert len(history_section) == 2
|
||||
assert len(history_section[1]) < builder._MAX_HISTORY_CHARS + 200
|
||||
|
||||
enc = tiktoken.get_encoding("cl100k_base")
|
||||
assert len(enc.encode(history_section[1])) <= builder._MAX_HISTORY_TOKENS
|
||||
|
||||
|
||||
def test_no_recent_history_when_dream_has_processed_all(tmp_path) -> None:
|
||||
|
||||
@@ -87,6 +87,21 @@ class TestBuildDreamPrompt:
|
||||
assert "entry-21" in next_prompt
|
||||
assert "entry-25" in next_prompt
|
||||
|
||||
def test_skips_malformed_history_entries(self, store):
|
||||
"""Dream prompt building should tolerate externally corrupted JSONL rows."""
|
||||
store.history_file.write_text(
|
||||
'{"cursor": 1, "timestamp": "2026-04-01 10:00"}\n'
|
||||
'{"cursor": 2, "timestamp": "2026-04-01 10:01", "content": "usable memory"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = store.build_dream_prompt()
|
||||
|
||||
assert result is not None
|
||||
prompt, cursor = result
|
||||
assert cursor == 2
|
||||
assert "usable memory" in prompt
|
||||
|
||||
def test_dream_prompt_consumes_consolidator_attribute_tags(self):
|
||||
prompt = render_template(
|
||||
"agent/dream.md",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -13,8 +14,10 @@ from nanobot.providers.base import LLMResponse
|
||||
from nanobot.session.goal_state import GOAL_STATE_KEY
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.session.turn_continuation import (
|
||||
INTERNAL_CONTINUATION_KIND_META,
|
||||
INTERNAL_CONTINUATION_META,
|
||||
INTERNAL_CONTINUATION_RUN_STARTED_AT_META,
|
||||
SUBAGENT_RESULT_CONTINUATION_KIND,
|
||||
)
|
||||
from nanobot.session.webui_turns import (
|
||||
TITLE_GENERATION_MAX_TOKENS,
|
||||
@@ -39,6 +42,7 @@ def _mk_loop() -> AgentLoop:
|
||||
def _make_full_loop(tmp_path: Path) -> AgentLoop:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.generation = SimpleNamespace(max_tokens=4096)
|
||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Test title"))
|
||||
loop = AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
|
||||
WebuiTurnCoordinator(
|
||||
@@ -862,6 +866,100 @@ async def test_websocket_internal_continuation_keeps_single_visible_run(
|
||||
assert isinstance(turn_end[0].metadata.get("latency_ms"), int)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_context_lists_ready_subagent_result(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
await loop.subagents._announce_result(
|
||||
"sub-ready",
|
||||
"research",
|
||||
"look up the answer",
|
||||
"worker answer",
|
||||
{"channel": "cli", "chat_id": "test", "session_key": "cli:test"},
|
||||
"ok",
|
||||
)
|
||||
|
||||
seen: dict[str, list[dict]] = {}
|
||||
|
||||
async def fake_run_agent_loop(initial_messages, **_kwargs):
|
||||
seen["initial_messages"] = initial_messages
|
||||
return (
|
||||
"done",
|
||||
[],
|
||||
[*initial_messages, {"role": "assistant", "content": "done"}],
|
||||
"completed",
|
||||
False,
|
||||
)
|
||||
|
||||
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
|
||||
|
||||
await loop._process_message(
|
||||
InboundMessage(channel="cli", sender_id="user", chat_id="test", content="continue")
|
||||
)
|
||||
|
||||
rendered = "\n".join(str(msg.get("content", "")) for msg in seen["initial_messages"])
|
||||
assert "Subagent tasks:" in rendered
|
||||
assert "sub-ready: completed, result ready" in rendered
|
||||
assert "worker answer" not in rendered
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subagent_result_continuation_delivers_result_without_user_history(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
await loop.subagents._announce_result(
|
||||
"sub-deliver",
|
||||
"worker",
|
||||
"calculate the answer",
|
||||
"the worker result",
|
||||
{"channel": "cli", "chat_id": "test", "session_key": "cli:test"},
|
||||
"ok",
|
||||
)
|
||||
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
|
||||
|
||||
assert queued.metadata[INTERNAL_CONTINUATION_META] is True
|
||||
assert queued.metadata[INTERNAL_CONTINUATION_KIND_META] == SUBAGENT_RESULT_CONTINUATION_KIND
|
||||
assert "the worker result" not in queued.content
|
||||
|
||||
seen: dict[str, list[dict]] = {}
|
||||
|
||||
async def fake_run_agent_loop(initial_messages, **_kwargs):
|
||||
seen["initial_messages"] = initial_messages
|
||||
return (
|
||||
"reported",
|
||||
[],
|
||||
[*initial_messages, {"role": "assistant", "content": "reported"}],
|
||||
"completed",
|
||||
False,
|
||||
)
|
||||
|
||||
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
|
||||
|
||||
response = await loop._process_message(queued, pending_queue=asyncio.Queue())
|
||||
|
||||
assert response is not None
|
||||
assert response.content == "reported"
|
||||
rendered = "\n".join(str(msg.get("content", "")) for msg in seen["initial_messages"])
|
||||
assert "the worker result" in rendered
|
||||
|
||||
read = await loop.subagents.wait_for_result(
|
||||
"cli:test",
|
||||
task_id="sub-deliver",
|
||||
timeout_seconds=0,
|
||||
)
|
||||
assert read.state == "consumed"
|
||||
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
assert [
|
||||
{k: v for k, v in m.items() if k in {"role", "content"}}
|
||||
for m in session.messages
|
||||
] == [{"role": "assistant", "content": "reported"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_uses_context_chat_id_for_runtime_prompt(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
@@ -953,6 +1051,68 @@ async def test_process_message_uses_explicit_session_metadata_for_goal_context(
|
||||
assert GOAL_STATE_KEY not in kwargs["session_metadata"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_agent_loop_goal_continue_message_reads_latest_metadata(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from nanobot.agent.runner import AgentRunResult
|
||||
|
||||
loop = _make_full_loop(tmp_path)
|
||||
session = loop.sessions.get_or_create("websocket:late-goal")
|
||||
seen: dict[str, str | None] = {}
|
||||
|
||||
async def fake_run(spec):
|
||||
assert callable(spec.goal_continue_message)
|
||||
session.metadata[GOAL_STATE_KEY] = {
|
||||
"status": "active",
|
||||
"objective": "Goal created during this runner call.",
|
||||
}
|
||||
seen["goal_continue"] = spec.goal_continue_message()
|
||||
return AgentRunResult(
|
||||
final_content="ok",
|
||||
messages=[{"role": "assistant", "content": "ok"}],
|
||||
)
|
||||
|
||||
loop.runner.run = fake_run # type: ignore[method-assign]
|
||||
|
||||
await loop._run_agent_loop(
|
||||
[],
|
||||
session=session,
|
||||
channel="websocket",
|
||||
chat_id="late-goal",
|
||||
session_key=session.key,
|
||||
)
|
||||
|
||||
assert "Goal created during this runner call." in (seen["goal_continue"] or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_direct_skip_user_persist_does_not_save_retry_user(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop._connect_mcp = AsyncMock()
|
||||
session = loop.sessions.get_or_create("api:default")
|
||||
session.add_message("user", "hello")
|
||||
session.add_message("assistant", "previous empty-response attempt")
|
||||
loop.sessions.save(session)
|
||||
|
||||
await loop.process_direct(
|
||||
"hello",
|
||||
session_key=session.key,
|
||||
channel="api",
|
||||
chat_id="default",
|
||||
persist_user_message=False,
|
||||
)
|
||||
|
||||
session = loop.sessions.get_or_create("api:default")
|
||||
assert [(m["role"], m["content"]) for m in session.messages] == [
|
||||
("user", "hello"),
|
||||
("assistant", "previous empty-response attempt"),
|
||||
("assistant", "Test title"),
|
||||
]
|
||||
|
||||
|
||||
def test_set_tool_context_uses_effective_key_for_spawn_tool(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
spawn_tool = loop.tools.get("spawn")
|
||||
|
||||
@@ -171,6 +171,23 @@ class TestHistoryWithCursor:
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert [e["cursor"] for e in entries] == [2, 3]
|
||||
|
||||
def test_read_unprocessed_skips_malformed_history_payloads(self, store):
|
||||
"""Externally edited JSONL can keep an int cursor but miss required payload fields."""
|
||||
store.history_file.write_text(
|
||||
'{"cursor": 1, "timestamp": "2026-04-01 10:00", "content": "valid"}\n'
|
||||
'{"cursor": 2, "timestamp": "2026-04-01 10:01"}\n'
|
||||
'{"cursor": 3, "content": "missing timestamp"}\n'
|
||||
'{"cursor": 4, "timestamp": "2026-04-01 10:03", "content": 123}\n'
|
||||
'{"cursor": 5, "timestamp": "2026-04-01 10:04", "content": "bad session", "session_key": 42}\n'
|
||||
'{"cursor": 6, "timestamp": "2026-04-01 10:05", "content": "also valid", "session_key": "telegram:chat-1"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
|
||||
assert [e["cursor"] for e in entries] == [1, 6]
|
||||
assert [e["content"] for e in entries] == ["valid", "also valid"]
|
||||
|
||||
def test_next_cursor_falls_back_when_last_entry_has_no_cursor(self, store):
|
||||
"""Regression: _next_cursor should not KeyError on entries without cursor."""
|
||||
store.history_file.write_text(
|
||||
|
||||
@@ -210,3 +210,37 @@ async def test_runner_uses_custom_goal_continue_message():
|
||||
|
||||
user_msgs = [m for m in result.messages if m.get("role") == "user"]
|
||||
assert any(custom_msg in str(m.get("content", "")) for m in user_msgs)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_resolves_goal_continue_message_lazily():
|
||||
"""The continuation text can depend on goal metadata created during the run."""
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||
content="still working", tool_calls=[], usage={},
|
||||
))
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
calls = {"n": 0}
|
||||
|
||||
def dynamic_msg() -> str:
|
||||
calls["n"] += 1
|
||||
return "Goal (active):\nWrite the article draft."
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "do task"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
goal_active_predicate=lambda: True,
|
||||
goal_continue_message=dynamic_msg,
|
||||
finalize_on_max_iterations=False,
|
||||
))
|
||||
|
||||
user_msgs = [m for m in result.messages if m.get("role") == "user"]
|
||||
assert calls["n"] == 1
|
||||
assert any("Write the article draft." in str(m.get("content", "")) for m in user_msgs)
|
||||
|
||||
@@ -152,6 +152,70 @@ async def test_drain_injections_skips_empty_content():
|
||||
assert result == [{"role": "user", "content": "valid"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_injections_filters_empty_dict_payloads():
|
||||
"""Pre-normalized dict injections should obey the same empty-content guard."""
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock()
|
||||
runner = AgentRunner(provider)
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
multimodal = [{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}]
|
||||
msgs = [
|
||||
{"role": "user", "content": ""},
|
||||
{"role": "user", "content": " "},
|
||||
{"role": "user", "content": None},
|
||||
{"role": "assistant", "content": "should not be re-injected as user"},
|
||||
None,
|
||||
{"role": "user", "content": "valid"},
|
||||
{"role": "user", "content": multimodal},
|
||||
]
|
||||
|
||||
async def cb():
|
||||
return msgs
|
||||
|
||||
spec = AgentRunSpec(
|
||||
initial_messages=[], tools=tools, model="m",
|
||||
max_iterations=1, max_tool_result_chars=1000,
|
||||
injection_callback=cb,
|
||||
)
|
||||
result = await runner._drain_injections(spec)
|
||||
assert result == [
|
||||
{"role": "user", "content": "valid"},
|
||||
{"role": "user", "content": multimodal},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_injections_skips_objects_with_none_content():
|
||||
"""Objects exposing content=None should be skipped rather than stringified."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock()
|
||||
runner = AgentRunner(provider)
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
async def cb():
|
||||
return [
|
||||
SimpleNamespace(content=None),
|
||||
SimpleNamespace(content=""),
|
||||
SimpleNamespace(content="valid"),
|
||||
]
|
||||
|
||||
spec = AgentRunSpec(
|
||||
initial_messages=[], tools=tools, model="m",
|
||||
max_iterations=1, max_tool_result_chars=1000,
|
||||
injection_callback=cb,
|
||||
)
|
||||
result = await runner._drain_injections(spec)
|
||||
assert result == [{"role": "user", "content": "valid"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_injections_handles_callback_exception():
|
||||
"""If the callback raises, return empty list (error is logged)."""
|
||||
@@ -1155,4 +1219,3 @@ async def test_injection_cycle_cap_on_error_path():
|
||||
assert result.had_injections is True
|
||||
# Should cap: _MAX_INJECTION_CYCLES drained rounds + 1 final round that breaks
|
||||
assert call_count["n"] == _MAX_INJECTION_CYCLES + 1
|
||||
|
||||
|
||||
@@ -623,6 +623,24 @@ def test_retain_recent_legal_suffix_hard_cap_with_long_non_user_chain():
|
||||
assert len(session.messages) <= 6
|
||||
|
||||
|
||||
def test_retain_recent_legal_suffix_can_extend_to_user_for_long_recent_turn():
|
||||
session = Session(key="test:extend-to-user")
|
||||
session.messages.append({"role": "user", "content": "old"})
|
||||
session.messages.append({"role": "assistant", "content": "old answer"})
|
||||
session.messages.append({"role": "user", "content": "record this"})
|
||||
for i in range(4):
|
||||
session.messages.extend(_tool_turn("recent", i))
|
||||
session.messages.append({"role": "assistant", "content": "done"})
|
||||
|
||||
session.retain_recent_legal_suffix(8, extend_to_user=True)
|
||||
|
||||
assert len(session.messages) > 8
|
||||
assert session.messages[0]["content"] == "record this"
|
||||
assert session.messages[-1]["content"] == "done"
|
||||
history = session.get_history(max_messages=500)
|
||||
_assert_no_orphans(history)
|
||||
|
||||
|
||||
# --- enforce_file_cap archive correctness (issue #4128) ---
|
||||
|
||||
|
||||
|
||||
@@ -285,80 +285,76 @@ class TestRunSubagent:
|
||||
|
||||
class TestAnnounceResult:
|
||||
@pytest.mark.asyncio
|
||||
async def test_publishes_inbound_message(self, tmp_path):
|
||||
async def test_records_mailbox_result_without_publishing_inbound(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
published = []
|
||||
sm.bus.publish_inbound = AsyncMock(side_effect=lambda msg: published.append(msg))
|
||||
sm.bus.publish_inbound = AsyncMock()
|
||||
|
||||
await sm._announce_result(
|
||||
"t1", "label", "task", "result text",
|
||||
{"channel": "cli", "chat_id": "direct"}, "ok",
|
||||
)
|
||||
|
||||
assert len(published) == 1
|
||||
msg = published[0]
|
||||
assert msg.channel == "system"
|
||||
assert msg.sender_id == "subagent"
|
||||
assert msg.metadata["injected_event"] == "subagent_result"
|
||||
assert msg.metadata["subagent_task_id"] == "t1"
|
||||
sm.bus.publish_inbound.assert_not_awaited()
|
||||
snapshots = await sm.mailbox.poll("cli:direct", task_id="t1")
|
||||
assert snapshots[0].state == "completed"
|
||||
read = await sm.mailbox.wait_for_result("cli:direct", task_id="t1", timeout_seconds=0)
|
||||
assert read.state == "ready"
|
||||
assert read.result is not None
|
||||
assert read.result.content == "result text"
|
||||
assert read.result.metadata["subagent_task_id"] == "t1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_key_override(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
published = []
|
||||
sm.bus.publish_inbound = AsyncMock(side_effect=lambda msg: published.append(msg))
|
||||
|
||||
await sm._announce_result(
|
||||
"t1", "label", "task", "result",
|
||||
{"channel": "telegram", "chat_id": "123", "session_key": "s1"}, "ok",
|
||||
)
|
||||
|
||||
assert published[0].session_key_override == "s1"
|
||||
assert await sm.mailbox.poll("s1", task_id="t1")
|
||||
assert await sm.mailbox.poll("telegram:123", task_id="t1") == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_key_override_fallback(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
published = []
|
||||
sm.bus.publish_inbound = AsyncMock(side_effect=lambda msg: published.append(msg))
|
||||
|
||||
await sm._announce_result(
|
||||
"t1", "label", "task", "result",
|
||||
{"channel": "telegram", "chat_id": "123"}, "ok",
|
||||
)
|
||||
|
||||
assert published[0].session_key_override == "telegram:123"
|
||||
snapshots = await sm.mailbox.poll("telegram:123", task_id="t1")
|
||||
assert snapshots[0].session_key == "telegram:123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ok_status_text(self, tmp_path):
|
||||
async def test_ok_status_records_completed_state(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
published = []
|
||||
sm.bus.publish_inbound = AsyncMock(side_effect=lambda msg: published.append(msg))
|
||||
|
||||
await sm._announce_result(
|
||||
"t1", "label", "task", "result",
|
||||
{"channel": "cli", "chat_id": "direct"}, "ok",
|
||||
)
|
||||
|
||||
assert "completed successfully" in published[0].content
|
||||
snapshots = await sm.mailbox.poll("cli:direct", task_id="t1")
|
||||
assert snapshots[0].state == "completed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_status_text(self, tmp_path):
|
||||
async def test_error_status_records_failed_state(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
published = []
|
||||
sm.bus.publish_inbound = AsyncMock(side_effect=lambda msg: published.append(msg))
|
||||
|
||||
await sm._announce_result(
|
||||
"t1", "label", "task", "error details",
|
||||
{"channel": "cli", "chat_id": "direct"}, "error",
|
||||
)
|
||||
|
||||
assert "failed" in published[0].content
|
||||
snapshots = await sm.mailbox.poll("cli:direct", task_id="t1")
|
||||
assert snapshots[0].state == "failed"
|
||||
assert snapshots[0].error == "error details"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_origin_message_id_in_metadata(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
published = []
|
||||
sm.bus.publish_inbound = AsyncMock(side_effect=lambda msg: published.append(msg))
|
||||
|
||||
await sm._announce_result(
|
||||
"t1", "label", "task", "result",
|
||||
@@ -366,7 +362,29 @@ class TestAnnounceResult:
|
||||
origin_message_id="msg-123",
|
||||
)
|
||||
|
||||
assert published[0].metadata["origin_message_id"] == "msg-123"
|
||||
read = await sm.mailbox.wait_for_result("cli:direct", task_id="t1", timeout_seconds=0)
|
||||
assert read.result is not None
|
||||
assert read.result.metadata["origin_message_id"] == "msg-123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_results_are_not_consumed_twice(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
|
||||
await sm._announce_result(
|
||||
"t1", "label", "task", "first",
|
||||
{"channel": "cli", "chat_id": "direct"}, "ok",
|
||||
)
|
||||
await sm._announce_result(
|
||||
"t1", "label", "task", "second",
|
||||
{"channel": "cli", "chat_id": "direct"}, "ok",
|
||||
)
|
||||
|
||||
first = await sm.mailbox.wait_for_result("cli:direct", task_id="t1", timeout_seconds=0)
|
||||
second = await sm.mailbox.wait_for_result("cli:direct", task_id="t1", timeout_seconds=0)
|
||||
assert first.state == "ready"
|
||||
assert first.result is not None
|
||||
assert first.result.content == "first"
|
||||
assert second.state == "consumed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -427,7 +427,7 @@ class TestSubagentCancellation:
|
||||
|
||||
|
||||
class TestSubagentAnnounceSessionKey:
|
||||
"""Verify _announce_result uses the effective session key for mid-turn routing."""
|
||||
"""Verify _announce_result stores results under the effective session key."""
|
||||
|
||||
def _make_mgr(self):
|
||||
"""Create a SubagentManager with mocked deps and its bus."""
|
||||
@@ -448,27 +448,27 @@ class TestSubagentAnnounceSessionKey:
|
||||
@pytest.mark.asyncio
|
||||
async def test_announce_uses_effective_key_in_unified_mode(self):
|
||||
"""In unified session mode, session_key_override must be 'unified:default'
|
||||
so the result matches the pending queue key."""
|
||||
so the result matches the manager mailbox session key."""
|
||||
mgr, bus = self._make_mgr()
|
||||
|
||||
origin = {"channel": "telegram", "chat_id": "111", "session_key": UNIFIED_SESSION_KEY}
|
||||
await mgr._announce_result("sub-1", "label", "task", "result", origin, "ok")
|
||||
|
||||
msg = await bus.consume_inbound()
|
||||
assert msg.session_key_override == UNIFIED_SESSION_KEY
|
||||
assert msg.session_key == UNIFIED_SESSION_KEY
|
||||
assert bus.inbound.empty()
|
||||
snapshots = await mgr.mailbox.poll("unified:default", task_id="sub-1")
|
||||
assert snapshots[0].session_key == "unified:default"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_announce_uses_raw_key_in_normal_mode(self):
|
||||
"""Without unified sessions, session_key_override is the raw channel:chat_id."""
|
||||
"""Without unified sessions, the mailbox session is the raw channel:chat_id."""
|
||||
mgr, bus = self._make_mgr()
|
||||
|
||||
origin = {"channel": "telegram", "chat_id": "222", "session_key": "telegram:222"}
|
||||
await mgr._announce_result("sub-2", "label", "task", "result", origin, "ok")
|
||||
|
||||
msg = await bus.consume_inbound()
|
||||
assert msg.session_key_override == "telegram:222"
|
||||
assert msg.session_key == "telegram:222"
|
||||
assert bus.inbound.empty()
|
||||
snapshots = await mgr.mailbox.poll("telegram:222", task_id="sub-2")
|
||||
assert snapshots[0].session_key == "telegram:222"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_announce_falls_back_to_origin_when_no_session_key(self):
|
||||
@@ -478,10 +478,9 @@ class TestSubagentAnnounceSessionKey:
|
||||
origin = {"channel": "discord", "chat_id": "333", "session_key": None}
|
||||
await mgr._announce_result("sub-3", "label", "task", "result", origin, "ok")
|
||||
|
||||
msg = await bus.consume_inbound()
|
||||
assert msg.session_key_override == "discord:333"
|
||||
assert msg.channel == "system"
|
||||
assert msg.chat_id == "discord:333"
|
||||
assert bus.inbound.empty()
|
||||
snapshots = await mgr.mailbox.poll("discord:333", task_id="sub-3")
|
||||
assert snapshots[0].session_key == "discord:333"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_key_flows_through_run_subagent(self):
|
||||
@@ -510,5 +509,6 @@ class TestSubagentAnnounceSessionKey:
|
||||
status,
|
||||
)
|
||||
|
||||
msg = await bus.consume_inbound()
|
||||
assert msg.session_key_override == UNIFIED_SESSION_KEY
|
||||
assert bus.inbound.empty()
|
||||
snapshots = await mgr.mailbox.poll("unified:default", task_id="sub-4")
|
||||
assert snapshots[0].session_key == "unified:default"
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Tests for explicit subagent mailbox tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.runner import AgentRunResult
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
from nanobot.agent.tools.subagent_mailbox import (
|
||||
CancelSubagentTool,
|
||||
PollSubagentsTool,
|
||||
WaitSubagentsTool,
|
||||
)
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
|
||||
|
||||
def _manager(tmp_path: Path) -> SubagentManager:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
return SubagentManager(
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
bus=MessageBus(),
|
||||
max_tool_result_chars=AgentDefaults().max_tool_result_chars,
|
||||
)
|
||||
|
||||
|
||||
def _bind(tool, session_key: str = "cli:test") -> None:
|
||||
tool.set_context(RequestContext(channel="cli", chat_id="test", session_key=session_key))
|
||||
|
||||
|
||||
async def _drain(mgr: SubagentManager) -> None:
|
||||
tasks = list(mgr._running_tasks.values())
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_subagents_returns_result_once(tmp_path: Path) -> None:
|
||||
mgr = _manager(tmp_path)
|
||||
mgr.runner.run = AsyncMock(
|
||||
return_value=AgentRunResult(final_content="worker result", messages=[], stop_reason="completed")
|
||||
)
|
||||
|
||||
await mgr.spawn("do work", label="worker", session_key="cli:test")
|
||||
task_id = next(iter(mgr._running_tasks))
|
||||
await _drain(mgr)
|
||||
|
||||
wait_tool = WaitSubagentsTool(mgr)
|
||||
_bind(wait_tool)
|
||||
|
||||
first = await wait_tool.execute(task_id=task_id, timeout_seconds=0)
|
||||
second = await wait_tool.execute(task_id=task_id, timeout_seconds=0)
|
||||
|
||||
assert "worker result" in first
|
||||
assert f"id: {task_id}" in first
|
||||
assert "already consumed" in second
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_subagents_reads_result_after_manager_recreation(tmp_path: Path) -> None:
|
||||
mgr = _manager(tmp_path)
|
||||
mgr.runner.run = AsyncMock(
|
||||
return_value=AgentRunResult(final_content="durable worker result", messages=[], stop_reason="completed")
|
||||
)
|
||||
|
||||
await mgr.spawn("do durable work", label="worker", session_key="cli:test")
|
||||
task_id = next(iter(mgr._running_tasks))
|
||||
await _drain(mgr)
|
||||
|
||||
recreated = _manager(tmp_path)
|
||||
wait_tool = WaitSubagentsTool(recreated)
|
||||
poll_tool = PollSubagentsTool(recreated)
|
||||
_bind(wait_tool)
|
||||
_bind(poll_tool)
|
||||
|
||||
first = await wait_tool.execute(task_id=task_id, timeout_seconds=0)
|
||||
after = await poll_tool.execute(task_id=task_id)
|
||||
|
||||
assert "durable worker result" in first
|
||||
assert "result consumed" in after
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_subagents_reports_running_completed_and_not_found(tmp_path: Path) -> None:
|
||||
mgr = _manager(tmp_path)
|
||||
release = asyncio.Event()
|
||||
|
||||
async def _run(_spec):
|
||||
await release.wait()
|
||||
return AgentRunResult(final_content="done", messages=[], stop_reason="completed")
|
||||
|
||||
mgr.runner.run = AsyncMock(side_effect=_run)
|
||||
await mgr.spawn("slow work", label="slow", session_key="cli:test")
|
||||
task_id = next(iter(mgr._running_tasks))
|
||||
|
||||
poll_tool = PollSubagentsTool(mgr)
|
||||
_bind(poll_tool)
|
||||
|
||||
running = await poll_tool.execute(task_id=task_id)
|
||||
missing = await poll_tool.execute(task_id="missing")
|
||||
release.set()
|
||||
await _drain(mgr)
|
||||
completed = await poll_tool.execute(task_id=task_id)
|
||||
|
||||
assert "status: running" in running
|
||||
assert "not found" in missing
|
||||
assert "completed, result ready" in completed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_subagent_marks_cancelled_result(tmp_path: Path) -> None:
|
||||
mgr = _manager(tmp_path)
|
||||
started = asyncio.Event()
|
||||
|
||||
async def _run(_spec):
|
||||
started.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
mgr.runner.run = AsyncMock(side_effect=_run)
|
||||
await mgr.spawn("slow work", label="slow", session_key="cli:test")
|
||||
task_id = next(iter(mgr._running_tasks))
|
||||
await asyncio.wait_for(started.wait(), timeout=1.0)
|
||||
|
||||
cancel_tool = CancelSubagentTool(mgr)
|
||||
wait_tool = WaitSubagentsTool(mgr)
|
||||
_bind(cancel_tool)
|
||||
_bind(wait_tool)
|
||||
|
||||
cancelled = await cancel_tool.execute(task_id=task_id)
|
||||
result = await wait_tool.execute(task_id=task_id, timeout_seconds=0)
|
||||
|
||||
assert cancelled == f"Cancelled subagent task {task_id}."
|
||||
assert "status: cancelled" in result
|
||||
assert "Cancelled by manager." in result
|
||||
@@ -279,8 +279,8 @@ async def test_agent_loop_syncs_updated_max_iterations_before_run(tmp_path):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_pending_blocks_while_subagents_running(tmp_path):
|
||||
"""_drain_pending should block when no messages are available but sub-agents are still running."""
|
||||
async def test_drain_pending_does_not_block_while_subagents_running(tmp_path):
|
||||
"""_drain_pending should ignore running workers unless user messages are queued."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
@@ -336,31 +336,24 @@ async def test_drain_pending_blocks_while_subagents_running(tmp_path):
|
||||
|
||||
assert injection_callback is not None
|
||||
|
||||
# Now test the callback directly
|
||||
# With sub-agents running and an empty queue, it should block
|
||||
drain_task = asyncio.create_task(injection_callback())
|
||||
# Running subagents alone must not keep the current turn alive.
|
||||
results = await asyncio.wait_for(injection_callback(), timeout=1.0)
|
||||
assert results == []
|
||||
|
||||
# Let the task enter the blocking queue wait.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# Should still be running (blocked on pending_queue.get())
|
||||
assert not drain_task.done(), "drain should block while sub-agents are running"
|
||||
|
||||
# Now put a message in the queue (simulating sub-agent completion)
|
||||
# Real follow-up messages still use the ordinary pending queue path.
|
||||
await pending_queue.put(InboundMessage(
|
||||
sender_id="subagent",
|
||||
sender_id="user",
|
||||
channel="test",
|
||||
chat_id="c1",
|
||||
content="Sub-agent result",
|
||||
content="User follow-up",
|
||||
media=None,
|
||||
metadata={},
|
||||
))
|
||||
|
||||
# Should unblock and return results
|
||||
results = await asyncio.wait_for(drain_task, timeout=2.0)
|
||||
results = await asyncio.wait_for(injection_callback(), timeout=1.0)
|
||||
assert len(results) >= 1
|
||||
assert results[0]["role"] == "user"
|
||||
assert "Sub-agent result" in str(results[0]["content"])
|
||||
assert "User follow-up" in str(results[0]["content"])
|
||||
|
||||
# Cleanup
|
||||
hang_task.cancel()
|
||||
@@ -417,8 +410,8 @@ async def test_drain_pending_no_block_when_no_subagents(tmp_path):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_pending_timeout(tmp_path):
|
||||
"""_drain_pending should return empty after timeout when sub-agents hang."""
|
||||
async def test_drain_pending_does_not_wait_for_hung_subagents(tmp_path):
|
||||
"""_drain_pending should not call asyncio.wait_for for hung subagents."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.session.manager import Session
|
||||
@@ -467,14 +460,10 @@ async def test_drain_pending_timeout(tmp_path):
|
||||
|
||||
assert injection_callback is not None
|
||||
|
||||
# Patch the timeout path without leaking the queue.get() coroutine.
|
||||
async def _timeout(awaitable, timeout):
|
||||
awaitable.close()
|
||||
raise asyncio.TimeoutError
|
||||
|
||||
with patch("nanobot.agent.loop.asyncio.wait_for", side_effect=_timeout):
|
||||
with patch("nanobot.agent.loop.asyncio.wait_for") as wait_for:
|
||||
results = await injection_callback()
|
||||
assert results == []
|
||||
wait_for.assert_not_called()
|
||||
|
||||
# Cleanup
|
||||
hang_task.cancel()
|
||||
|
||||
@@ -119,6 +119,42 @@ async def _http_get(url: str, headers: dict[str, str] | None = None) -> httpx.Re
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_session_updated_broadcasts_to_other_webui_connections(bus) -> None:
|
||||
class Conn:
|
||||
remote_address = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[str] = []
|
||||
|
||||
async def send(self, raw: str) -> None:
|
||||
self.sent.append(raw)
|
||||
|
||||
channel = _ch(bus)
|
||||
active_conn = Conn()
|
||||
other_conn = Conn()
|
||||
channel._attach(active_conn, "chat-a")
|
||||
channel._attach(other_conn, "chat-b")
|
||||
assert sorted(channel._subs) == ["chat-a", "chat-b"]
|
||||
assert sum(len(conns) for conns in channel._subs.values()) == 2
|
||||
|
||||
await channel.send_session_updated("chat-a", scope="thread")
|
||||
|
||||
active_events = [json.loads(raw)["event"] for raw in active_conn.sent]
|
||||
other_events = [json.loads(raw)["event"] for raw in other_conn.sent]
|
||||
|
||||
assert (active_events, other_events) == (
|
||||
["session_updated"],
|
||||
["session_updated"],
|
||||
)
|
||||
payload = json.loads(other_conn.sent[0])
|
||||
assert payload == {
|
||||
"event": "session_updated",
|
||||
"chat_id": "chat-a",
|
||||
"scope": "thread",
|
||||
}
|
||||
|
||||
|
||||
async def _recv_ws_event(client: Any, event: str) -> dict[str, Any]:
|
||||
"""Receive until a specific websocket event appears."""
|
||||
for _ in range(10):
|
||||
@@ -128,6 +164,10 @@ async def _recv_ws_event(client: Any, event: str) -> dict[str, Any]:
|
||||
raise AssertionError(f"websocket event {event!r} was not received")
|
||||
|
||||
|
||||
def _sent_ws_payloads(mock_ws: AsyncMock) -> list[dict[str, Any]]:
|
||||
return [json.loads(call.args[0]) for call in mock_ws.send.await_args_list]
|
||||
|
||||
|
||||
def test_normalize_http_path_strips_trailing_slash_except_root() -> None:
|
||||
assert _normalize_http_path("/chat/") == "/chat"
|
||||
assert _normalize_http_path("/chat?x=1") == "/chat"
|
||||
@@ -1234,9 +1274,10 @@ async def test_send_turn_end_emits_turn_end_event() -> None:
|
||||
metadata={"_turn_end": True},
|
||||
))
|
||||
|
||||
mock_ws.send.assert_awaited_once()
|
||||
body = json.loads(mock_ws.send.await_args.args[0])
|
||||
assert body == {"event": "turn_end", "chat_id": "chat-1"}
|
||||
assert _sent_ws_payloads(mock_ws) == [
|
||||
{"event": "turn_end", "chat_id": "chat-1"},
|
||||
{"event": "session_updated", "chat_id": "chat-1", "scope": "thread"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1253,9 +1294,10 @@ async def test_send_turn_end_includes_latency_ms_when_present() -> None:
|
||||
metadata={"_turn_end": True, "latency_ms": 1500},
|
||||
))
|
||||
|
||||
mock_ws.send.assert_awaited_once()
|
||||
body = json.loads(mock_ws.send.await_args.args[0])
|
||||
assert body == {"event": "turn_end", "chat_id": "chat-1", "latency_ms": 1500}
|
||||
assert _sent_ws_payloads(mock_ws) == [
|
||||
{"event": "turn_end", "chat_id": "chat-1", "latency_ms": 1500},
|
||||
{"event": "session_updated", "chat_id": "chat-1", "scope": "thread"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1273,9 +1315,10 @@ async def test_send_turn_end_includes_goal_state_when_present() -> None:
|
||||
metadata={"_turn_end": True, "goal_state": blob},
|
||||
))
|
||||
|
||||
mock_ws.send.assert_awaited_once()
|
||||
body = json.loads(mock_ws.send.await_args.args[0])
|
||||
assert body == {"event": "turn_end", "chat_id": "chat-1", "goal_state": blob}
|
||||
assert _sent_ws_payloads(mock_ws) == [
|
||||
{"event": "turn_end", "chat_id": "chat-1", "goal_state": blob},
|
||||
{"event": "session_updated", "chat_id": "chat-1", "scope": "thread"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
import asyncio
|
||||
import functools
|
||||
import json
|
||||
import random
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -23,6 +25,18 @@ from nanobot.webui.gateway_services import GatewayServices, build_gateway_servic
|
||||
_PORT = 29900
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
for _ in range(100):
|
||||
port = random.randint(30_000, 60_000)
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
try:
|
||||
sock.bind(("127.0.0.1", port))
|
||||
except OSError:
|
||||
continue
|
||||
return port
|
||||
raise RuntimeError("could not find a free localhost port")
|
||||
|
||||
|
||||
def _make_handler(
|
||||
cfg: dict[str, Any] | WebSocketConfig,
|
||||
bus: Any,
|
||||
@@ -813,6 +827,255 @@ async def test_session_delete_removes_file(
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
port = _free_port()
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
cron = CronService(tmp_path / "cron" / "jobs.json")
|
||||
user_job = cron.add_job(
|
||||
name="Daily repo check",
|
||||
schedule=CronSchedule(kind="every", every_ms=86_400_000),
|
||||
message="Check the repo status",
|
||||
session_key="websocket:abc",
|
||||
origin_channel="websocket",
|
||||
origin_chat_id="abc",
|
||||
)
|
||||
incomplete_job = cron.add_job(
|
||||
name="english-quiz",
|
||||
schedule=CronSchedule(kind="every", every_ms=3_600_000),
|
||||
message="Practice English",
|
||||
session_key="unified:default",
|
||||
)
|
||||
external_job = cron.add_job(
|
||||
name="WeChat quiz",
|
||||
schedule=CronSchedule(kind="every", every_ms=3_600_000),
|
||||
message="Send a quiz",
|
||||
session_key="weixin:wx-chat",
|
||||
origin_channel="weixin",
|
||||
origin_chat_id="wx-chat",
|
||||
)
|
||||
past_one_shot_job = cron.add_job(
|
||||
name="Past one-shot",
|
||||
schedule=CronSchedule(kind="at", at_ms=1),
|
||||
message="Old one-shot message",
|
||||
session_key="websocket:abc",
|
||||
origin_channel="websocket",
|
||||
origin_chat_id="abc",
|
||||
delete_after_run=True,
|
||||
)
|
||||
cron.register_system_job(
|
||||
CronJob(
|
||||
id="heartbeat",
|
||||
name="heartbeat",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
payload=CronPayload(kind="system_event"),
|
||||
)
|
||||
)
|
||||
session_manager = _seed_session(tmp_path, key="websocket:abc")
|
||||
external_session = Session(key="weixin:wx-chat")
|
||||
external_session.add_message("user", "Scheduled cron job triggered")
|
||||
session_manager.save(external_session)
|
||||
channel = _ch(
|
||||
bus,
|
||||
session_manager=session_manager,
|
||||
cron_service=cron,
|
||||
cron_pending_job_ids=lambda key: {user_job.id} if key == "websocket:abc" else set(),
|
||||
port=port,
|
||||
)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
deny = await _http_get(f"{base_url}/api/webui/automations")
|
||||
assert deny.status_code == 401, deny.text
|
||||
|
||||
boot = await _http_get(f"{base_url}/webui/bootstrap")
|
||||
token = boot.json()["token"]
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
resp = await _http_get(
|
||||
f"{base_url}/api/webui/automations",
|
||||
headers=auth,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "wx-chat" not in resp.text
|
||||
assert "unified:default" not in resp.text
|
||||
body = resp.json()
|
||||
by_id = {job["id"]: job for job in body["jobs"]}
|
||||
assert by_id[user_job.id]["protected"] is False
|
||||
assert by_id[user_job.id]["state"]["pending"] is True
|
||||
assert by_id[user_job.id]["state"]["run_history"] == []
|
||||
assert by_id[user_job.id]["origin"]["session_key"] == "websocket:abc"
|
||||
assert by_id[user_job.id]["origin"]["preview"] == "hi"
|
||||
assert "session_key" not in by_id[incomplete_job.id]["payload"]
|
||||
assert "origin_channel" not in by_id[incomplete_job.id]["payload"]
|
||||
assert "origin_chat_id" not in by_id[incomplete_job.id]["payload"]
|
||||
assert by_id[incomplete_job.id]["origin"] is None
|
||||
assert "session_key" not in by_id[external_job.id]["payload"]
|
||||
assert "origin_channel" not in by_id[external_job.id]["payload"]
|
||||
assert "origin_chat_id" not in by_id[external_job.id]["payload"]
|
||||
assert by_id[external_job.id]["origin"]["channel"] == "weixin"
|
||||
assert "session_key" not in by_id[external_job.id]["origin"]
|
||||
assert "chat_id" not in by_id[external_job.id]["origin"]
|
||||
assert by_id[external_job.id]["origin"]["preview"] == ""
|
||||
assert by_id["heartbeat"]["protected"] is True
|
||||
|
||||
updated = await _http_get(
|
||||
f"{base_url}/api/webui/automations/update?id={user_job.id}",
|
||||
headers={
|
||||
**auth,
|
||||
"X-Nanobot-Automation-Values": json.dumps(
|
||||
{
|
||||
"name": "Daily quiz",
|
||||
"message": "Ask the daily quiz",
|
||||
"schedule": {
|
||||
"kind": "cron",
|
||||
"expr": "0 9 * * *",
|
||||
"tz": "UTC",
|
||||
},
|
||||
}
|
||||
),
|
||||
},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
by_id = {job["id"]: job for job in updated.json()["jobs"]}
|
||||
assert by_id[user_job.id]["name"] == "Daily quiz"
|
||||
assert by_id[user_job.id]["payload"]["message"] == "Ask the daily quiz"
|
||||
assert by_id[user_job.id]["schedule"]["kind"] == "cron"
|
||||
assert by_id[user_job.id]["schedule"]["expr"] == "0 9 * * *"
|
||||
assert by_id[user_job.id]["schedule"]["tz"] == "UTC"
|
||||
|
||||
unicode_update = await _http_get(
|
||||
f"{base_url}/api/webui/automations/update?id={user_job.id}",
|
||||
headers={
|
||||
**auth,
|
||||
"X-Nanobot-Automation-Values": quote(
|
||||
json.dumps(
|
||||
{
|
||||
"name": "每日测验",
|
||||
"message": "问今日测验",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
safe="",
|
||||
),
|
||||
},
|
||||
)
|
||||
assert unicode_update.status_code == 200
|
||||
assert cron.get_job(user_job.id).name == "每日测验"
|
||||
assert cron.get_job(user_job.id).payload.message == "问今日测验"
|
||||
|
||||
malformed_update = await _http_get(
|
||||
f"{base_url}/api/webui/automations/update?id={user_job.id}",
|
||||
headers={
|
||||
**auth,
|
||||
"X-Nanobot-Automation-Values": json.dumps({"message": ["bad"]}),
|
||||
},
|
||||
)
|
||||
assert malformed_update.status_code == 400
|
||||
assert cron.get_job(user_job.id).payload.message == "问今日测验"
|
||||
|
||||
invalid_cron_update = await _http_get(
|
||||
f"{base_url}/api/webui/automations/update?id={user_job.id}",
|
||||
headers={
|
||||
**auth,
|
||||
"X-Nanobot-Automation-Values": json.dumps(
|
||||
{"schedule": {"kind": "cron", "expr": "not a cron", "tz": "UTC"}}
|
||||
),
|
||||
},
|
||||
)
|
||||
assert invalid_cron_update.status_code == 400
|
||||
assert cron.get_job(user_job.id).schedule.expr == "0 9 * * *"
|
||||
|
||||
past_one_shot_update = await _http_get(
|
||||
f"{base_url}/api/webui/automations/update?id={past_one_shot_job.id}",
|
||||
headers={
|
||||
**auth,
|
||||
"X-Nanobot-Automation-Values": json.dumps(
|
||||
{
|
||||
"message": "Updated one-shot message",
|
||||
"schedule": {"kind": "at", "at_ms": 1},
|
||||
}
|
||||
),
|
||||
},
|
||||
)
|
||||
assert past_one_shot_update.status_code == 200
|
||||
assert cron.get_job(past_one_shot_job.id).payload.message == "Updated one-shot message"
|
||||
assert cron.get_job(past_one_shot_job.id).schedule.at_ms == 1
|
||||
|
||||
protected_update = await _http_get(
|
||||
f"{base_url}/api/webui/automations/update?id=heartbeat",
|
||||
headers={
|
||||
**auth,
|
||||
"X-Nanobot-Automation-Values": json.dumps({"name": "bad"}),
|
||||
},
|
||||
)
|
||||
assert protected_update.status_code == 403
|
||||
|
||||
disabled = await _http_get(
|
||||
f"{base_url}/api/webui/automations/disable?id={user_job.id}",
|
||||
headers=auth,
|
||||
)
|
||||
assert disabled.status_code == 200
|
||||
by_id = {job["id"]: job for job in disabled.json()["jobs"]}
|
||||
assert by_id[user_job.id]["enabled"] is False
|
||||
|
||||
disabled_run = await _http_get(
|
||||
f"{base_url}/api/webui/automations/run?id={user_job.id}",
|
||||
headers=auth,
|
||||
)
|
||||
assert disabled_run.status_code == 409
|
||||
|
||||
unbound_run = await _http_get(
|
||||
f"{base_url}/api/webui/automations/run?id={incomplete_job.id}",
|
||||
headers=auth,
|
||||
)
|
||||
assert unbound_run.status_code == 409
|
||||
assert "no linked chat" in unbound_run.text
|
||||
|
||||
unbound_enable = await _http_get(
|
||||
f"{base_url}/api/webui/automations/enable?id={incomplete_job.id}",
|
||||
headers=auth,
|
||||
)
|
||||
assert unbound_enable.status_code == 409
|
||||
assert "no linked chat" in unbound_enable.text
|
||||
|
||||
protected_delete = await _http_get(
|
||||
f"{base_url}/api/webui/automations/delete?id=heartbeat",
|
||||
headers=auth,
|
||||
)
|
||||
assert protected_delete.status_code == 403
|
||||
protected_disable = await _http_get(
|
||||
f"{base_url}/api/webui/automations/disable?id=heartbeat",
|
||||
headers=auth,
|
||||
)
|
||||
assert protected_disable.status_code == 403
|
||||
protected_run = await _http_get(
|
||||
f"{base_url}/api/webui/automations/run?id=heartbeat",
|
||||
headers=auth,
|
||||
)
|
||||
assert protected_run.status_code == 403
|
||||
|
||||
enabled = await _http_get(
|
||||
f"{base_url}/api/webui/automations/enable?id={user_job.id}",
|
||||
headers=auth,
|
||||
)
|
||||
assert enabled.status_code == 200
|
||||
by_id = {job["id"]: job for job in enabled.json()["jobs"]}
|
||||
assert by_id[user_job.id]["enabled"] is True
|
||||
|
||||
deleted = await _http_get(
|
||||
f"{base_url}/api/webui/automations/delete?id={user_job.id}",
|
||||
headers=auth,
|
||||
)
|
||||
assert deleted.status_code == 200
|
||||
assert user_job.id not in {job["id"] for job in deleted.json()["jobs"]}
|
||||
assert "heartbeat" in {job["id"] for job in deleted.json()["jobs"]}
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_delete_blocks_when_bound_automation_exists(
|
||||
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.command.builtin import cmd_dream_log, cmd_dream_restore
|
||||
from nanobot.command.builtin import cmd_dream, cmd_dream_log, cmd_dream_restore
|
||||
from nanobot.command.router import CommandContext
|
||||
from nanobot.utils.gitstore import CommitInfo
|
||||
|
||||
|
||||
class _FakeStore:
|
||||
def __init__(self, git, last_dream_cursor: int = 1):
|
||||
def __init__(self, git, last_dream_cursor: int = 1, dream_prompt_result=None):
|
||||
self.git = git
|
||||
self._last_dream_cursor = last_dream_cursor
|
||||
self._dream_prompt_result = dream_prompt_result
|
||||
self.compact_history_called = False
|
||||
|
||||
def get_last_dream_cursor(self) -> int:
|
||||
return self._last_dream_cursor
|
||||
|
||||
def build_dream_prompt(self):
|
||||
return self._dream_prompt_result
|
||||
|
||||
def compact_history(self) -> None:
|
||||
self.compact_history_called = True
|
||||
|
||||
|
||||
class _FakeGit:
|
||||
def __init__(
|
||||
@@ -45,6 +54,17 @@ class _FakeGit:
|
||||
def revert(self, sha: str) -> str | None:
|
||||
return self._revert_result
|
||||
|
||||
def auto_commit(self, message: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
class _FakeBus:
|
||||
def __init__(self):
|
||||
self.outbound = []
|
||||
|
||||
async def publish_outbound(self, message):
|
||||
self.outbound.append(message)
|
||||
|
||||
|
||||
def _make_ctx(raw: str, git: _FakeGit, *, args: str = "", last_dream_cursor: int = 1) -> CommandContext:
|
||||
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content=raw)
|
||||
@@ -53,6 +73,38 @@ def _make_ctx(raw: str, git: _FakeGit, *, args: str = "", last_dream_cursor: int
|
||||
return CommandContext(msg=msg, session=None, key=msg.session_key, raw=raw, args=args, loop=loop)
|
||||
|
||||
|
||||
def _make_dream_ctx(tmp_path) -> tuple[CommandContext, _FakeBus]:
|
||||
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content="/dream")
|
||||
store = _FakeStore(_FakeGit(initialized=False), dream_prompt_result=None)
|
||||
bus = _FakeBus()
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
loop = SimpleNamespace(
|
||||
bus=bus,
|
||||
context=SimpleNamespace(memory=store, timezone="UTC"),
|
||||
sessions=SimpleNamespace(sessions_dir=sessions_dir),
|
||||
)
|
||||
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/dream", args="", loop=loop)
|
||||
return ctx, bus
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dream_no_history_explains_how_to_create_input(tmp_path) -> None:
|
||||
ctx, bus = _make_dream_ctx(tmp_path)
|
||||
|
||||
immediate = await cmd_dream(ctx)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert immediate.content == "Dreaming..."
|
||||
assert len(bus.outbound) == 1
|
||||
content = bus.outbound[0].content
|
||||
assert "Dream has no conversation history to process yet." in content
|
||||
assert "`memory/history.jsonl`" in content
|
||||
assert "idle auto-compact" in content
|
||||
assert "Dream cursor" in content
|
||||
assert "agents.defaults.idleCompactAfterMinutes" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dream_log_latest_is_more_user_friendly() -> None:
|
||||
commit = CommitInfo(sha="abcd1234", message="dream: 2026-04-04, 2 change(s)", timestamp="2026-04-04 12:00")
|
||||
|
||||
@@ -17,6 +17,14 @@ async def _wait_until(predicate, *, timeout: float = 1.0, interval: float = 0.01
|
||||
assert predicate()
|
||||
|
||||
|
||||
def _bound_chat(chat_id: str = "chat-1") -> dict[str, str]:
|
||||
return {
|
||||
"session_key": f"websocket:{chat_id}",
|
||||
"origin_channel": "websocket",
|
||||
"origin_chat_id": chat_id,
|
||||
}
|
||||
|
||||
|
||||
def test_add_job_rejects_unknown_timezone(tmp_path) -> None:
|
||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
||||
|
||||
@@ -37,12 +45,74 @@ def test_add_job_accepts_valid_timezone(tmp_path) -> None:
|
||||
name="tz ok",
|
||||
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="America/Vancouver"),
|
||||
message="hello",
|
||||
**_bound_chat(),
|
||||
)
|
||||
|
||||
assert job.schedule.tz == "America/Vancouver"
|
||||
assert job.state.next_run_at_ms is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unbound_agent_jobs_are_disabled_on_add(tmp_path) -> None:
|
||||
called: list[str] = []
|
||||
|
||||
async def on_job(job):
|
||||
called.append(job.id)
|
||||
|
||||
service = CronService(
|
||||
tmp_path / "cron" / "jobs.json",
|
||||
on_job=on_job,
|
||||
)
|
||||
job = service.add_job(
|
||||
name="unbound",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
)
|
||||
|
||||
assert job.enabled is False
|
||||
assert job.state.next_run_at_ms is None
|
||||
assert job.state.last_status == "error"
|
||||
assert "missing bound session delivery context" in (job.state.last_error or "")
|
||||
assert await service.run_job(job.id, force=True) is False
|
||||
assert called == []
|
||||
|
||||
|
||||
def test_unbound_agent_jobs_are_disabled_on_load(tmp_path) -> None:
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
store_path.parent.mkdir(parents=True)
|
||||
store_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"jobs": [
|
||||
{
|
||||
"id": "unbound-1",
|
||||
"name": "Unbound reminder",
|
||||
"enabled": True,
|
||||
"schedule": {"kind": "every", "everyMs": 60_000},
|
||||
"payload": {
|
||||
"kind": "agent_turn",
|
||||
"message": "check status",
|
||||
},
|
||||
"state": {"nextRunAtMs": 1},
|
||||
"createdAtMs": 1,
|
||||
"updatedAtMs": 1,
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
job = CronService(store_path).get_job("unbound-1")
|
||||
|
||||
assert job is not None
|
||||
assert job.enabled is False
|
||||
assert job.state.next_run_at_ms is None
|
||||
assert job.state.last_status == "error"
|
||||
assert "missing bound session delivery context" in (job.state.last_error or "")
|
||||
|
||||
|
||||
def test_add_job_migrates_legacy_delivery_context(tmp_path) -> None:
|
||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
||||
meta = {"slack": {"thread_ts": "1234567890.123456", "channel_type": "channel"}}
|
||||
@@ -263,6 +333,7 @@ async def test_execute_job_records_run_history(tmp_path) -> None:
|
||||
name="hist",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
**_bound_chat(),
|
||||
)
|
||||
await service.run_job(job.id)
|
||||
|
||||
@@ -287,6 +358,7 @@ async def test_run_history_records_errors(tmp_path) -> None:
|
||||
name="fail",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
**_bound_chat(),
|
||||
)
|
||||
await service.run_job(job.id)
|
||||
|
||||
@@ -308,6 +380,7 @@ async def test_run_history_records_skipped_jobs(tmp_path) -> None:
|
||||
name="skip",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
**_bound_chat(),
|
||||
)
|
||||
await service.run_job(job.id)
|
||||
|
||||
@@ -332,7 +405,7 @@ async def test_run_history_records_job_cancellation(tmp_path) -> None:
|
||||
name="cancel",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
session_key="websocket:chat-1",
|
||||
**_bound_chat(),
|
||||
)
|
||||
|
||||
assert await service.run_job(job.id) is True
|
||||
@@ -355,6 +428,7 @@ async def test_run_history_trimmed_to_max(tmp_path) -> None:
|
||||
name="trim",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
**_bound_chat(),
|
||||
)
|
||||
for _ in range(25):
|
||||
await service.run_job(job.id)
|
||||
@@ -371,6 +445,7 @@ async def test_run_history_persisted_to_disk(tmp_path) -> None:
|
||||
name="persist",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
**_bound_chat(),
|
||||
)
|
||||
await service.run_job(job.id)
|
||||
|
||||
@@ -395,6 +470,7 @@ async def test_run_job_disabled_does_not_flip_running_state(tmp_path) -> None:
|
||||
name="disabled",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
**_bound_chat(),
|
||||
)
|
||||
service.enable_job(job.id, enabled=False)
|
||||
|
||||
@@ -413,6 +489,7 @@ async def test_run_job_preserves_running_service_state(tmp_path) -> None:
|
||||
name="manual",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
**_bound_chat(),
|
||||
)
|
||||
|
||||
result = await service.run_job(job.id, force=True)
|
||||
@@ -435,6 +512,7 @@ async def test_running_service_honors_external_disable(tmp_path) -> None:
|
||||
name="external-disable",
|
||||
schedule=CronSchedule(kind="every", every_ms=200),
|
||||
message="hello",
|
||||
**_bound_chat(),
|
||||
)
|
||||
await service.start()
|
||||
try:
|
||||
@@ -483,6 +561,7 @@ async def test_start_server_not_jobs(tmp_path):
|
||||
name="hist",
|
||||
schedule=CronSchedule(kind="every", every_ms=100),
|
||||
message="hello",
|
||||
**_bound_chat(),
|
||||
)
|
||||
assert len(service.list_jobs()) == 1
|
||||
await _wait_until(lambda: bool(called), timeout=0.8)
|
||||
@@ -503,6 +582,7 @@ async def test_subsecond_job_not_delayed_to_one_second(tmp_path):
|
||||
name="fast",
|
||||
schedule=CronSchedule(kind="every", every_ms=100),
|
||||
message="hello",
|
||||
**_bound_chat(),
|
||||
)
|
||||
await service.start()
|
||||
try:
|
||||
@@ -526,6 +606,7 @@ async def test_running_service_picks_up_external_add(tmp_path):
|
||||
name="heartbeat",
|
||||
schedule=CronSchedule(kind="every", every_ms=100),
|
||||
message="tick",
|
||||
**_bound_chat("heartbeat"),
|
||||
)
|
||||
await service.start()
|
||||
try:
|
||||
@@ -536,6 +617,7 @@ async def test_running_service_picks_up_external_add(tmp_path):
|
||||
name="external",
|
||||
schedule=CronSchedule(kind="every", every_ms=100),
|
||||
message="ping",
|
||||
**_bound_chat("external"),
|
||||
)
|
||||
|
||||
await _wait_until(lambda: "external" in called, timeout=0.8)
|
||||
@@ -557,6 +639,7 @@ async def test_add_job_during_jobs_exec(tmp_path):
|
||||
name="test",
|
||||
schedule=CronSchedule(kind="every", every_ms=150),
|
||||
message="tick",
|
||||
**_bound_chat("test"),
|
||||
)
|
||||
run_once = False
|
||||
|
||||
@@ -565,6 +648,7 @@ async def test_add_job_during_jobs_exec(tmp_path):
|
||||
name="heartbeat",
|
||||
schedule=CronSchedule(kind="every", every_ms=100),
|
||||
message="tick",
|
||||
**_bound_chat("heartbeat"),
|
||||
)
|
||||
assert len(service.list_jobs()) == 1
|
||||
await service.start()
|
||||
@@ -585,6 +669,7 @@ async def test_external_update_preserves_run_history_records(tmp_path):
|
||||
name="history",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
**_bound_chat(),
|
||||
)
|
||||
await service.run_job(job.id, force=True)
|
||||
|
||||
@@ -626,6 +711,7 @@ async def test_timer_execution_is_not_rolled_back_by_list_jobs_reload(tmp_path):
|
||||
name="race",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
**_bound_chat(),
|
||||
)
|
||||
job.state.next_run_at_ms = max(1, int(time.time() * 1000) - 1_000)
|
||||
service._save_store()
|
||||
@@ -650,6 +736,7 @@ def test_update_job_changes_name(tmp_path) -> None:
|
||||
name="old name",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
**_bound_chat(),
|
||||
)
|
||||
result = service.update_job(job.id, name="new name")
|
||||
assert isinstance(result, CronJob)
|
||||
@@ -663,6 +750,7 @@ def test_update_job_changes_schedule(tmp_path) -> None:
|
||||
name="sched",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
**_bound_chat(),
|
||||
)
|
||||
old_next = job.state.next_run_at_ms
|
||||
|
||||
@@ -679,6 +767,7 @@ def test_update_job_changes_message(tmp_path) -> None:
|
||||
name="msg",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="old message",
|
||||
**_bound_chat(),
|
||||
)
|
||||
result = service.update_job(job.id, message="new message")
|
||||
assert isinstance(result, CronJob)
|
||||
@@ -691,6 +780,7 @@ def test_update_job_changes_cron_expression(tmp_path) -> None:
|
||||
name="cron-job",
|
||||
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"),
|
||||
message="hello",
|
||||
**_bound_chat(),
|
||||
)
|
||||
result = service.update_job(
|
||||
job.id,
|
||||
@@ -726,6 +816,7 @@ def test_update_job_validates_schedule(tmp_path) -> None:
|
||||
name="validate",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
**_bound_chat(),
|
||||
)
|
||||
with pytest.raises(ValueError, match="unknown timezone"):
|
||||
service.update_job(
|
||||
@@ -743,6 +834,7 @@ async def test_update_job_preserves_run_history(tmp_path) -> None:
|
||||
name="hist",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
**_bound_chat(),
|
||||
)
|
||||
await service.run_job(job.id)
|
||||
|
||||
@@ -758,6 +850,7 @@ def test_update_job_offline_writes_action(tmp_path) -> None:
|
||||
name="offline",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
**_bound_chat(),
|
||||
)
|
||||
service.update_job(job.id, name="updated-offline")
|
||||
|
||||
@@ -811,6 +904,7 @@ async def test_list_jobs_during_on_job_does_not_cause_stale_reload(tmp_path) ->
|
||||
name=name,
|
||||
schedule=CronSchedule(kind="every", every_ms=3_600_000),
|
||||
message="test",
|
||||
**_bound_chat(name),
|
||||
)
|
||||
# Force next_run to the past so _on_timer picks them up
|
||||
for job in service._store.jobs:
|
||||
|
||||
@@ -20,6 +20,14 @@ def _make_tool_with_tz(tmp_path, tz: str) -> CronTool:
|
||||
return CronTool(service, default_timezone=tz)
|
||||
|
||||
|
||||
def _bound_chat(chat_id: str = "chat-1") -> dict[str, str]:
|
||||
return {
|
||||
"session_key": f"websocket:{chat_id}",
|
||||
"origin_channel": "websocket",
|
||||
"origin_chat_id": chat_id,
|
||||
}
|
||||
|
||||
|
||||
# -- _format_timing tests --
|
||||
|
||||
|
||||
@@ -146,6 +154,7 @@ def test_list_cron_job_shows_expression_and_timezone(tmp_path) -> None:
|
||||
name="Morning scan",
|
||||
schedule=CronSchedule(kind="cron", expr="0 9 * * 1-5", tz="America/Denver"),
|
||||
message="scan",
|
||||
**_bound_chat(),
|
||||
)
|
||||
result = tool._list_jobs()
|
||||
assert "cron: 0 9 * * 1-5 (America/Denver)" in result
|
||||
@@ -157,6 +166,7 @@ def test_list_every_job_shows_human_interval(tmp_path) -> None:
|
||||
name="Frequent check",
|
||||
schedule=CronSchedule(kind="every", every_ms=1_800_000),
|
||||
message="check",
|
||||
**_bound_chat(),
|
||||
)
|
||||
result = tool._list_jobs()
|
||||
assert "every 30m" in result
|
||||
@@ -168,6 +178,7 @@ def test_list_every_job_hours(tmp_path) -> None:
|
||||
name="Hourly check",
|
||||
schedule=CronSchedule(kind="every", every_ms=7_200_000),
|
||||
message="check",
|
||||
**_bound_chat(),
|
||||
)
|
||||
result = tool._list_jobs()
|
||||
assert "every 2h" in result
|
||||
@@ -179,6 +190,7 @@ def test_list_every_job_seconds(tmp_path) -> None:
|
||||
name="Fast check",
|
||||
schedule=CronSchedule(kind="every", every_ms=30_000),
|
||||
message="check",
|
||||
**_bound_chat(),
|
||||
)
|
||||
result = tool._list_jobs()
|
||||
assert "every 30s" in result
|
||||
@@ -190,6 +202,7 @@ def test_list_every_job_non_minute_seconds(tmp_path) -> None:
|
||||
name="Ninety-second check",
|
||||
schedule=CronSchedule(kind="every", every_ms=90_000),
|
||||
message="check",
|
||||
**_bound_chat(),
|
||||
)
|
||||
result = tool._list_jobs()
|
||||
assert "every 90s" in result
|
||||
@@ -201,6 +214,7 @@ def test_list_every_job_milliseconds(tmp_path) -> None:
|
||||
name="Sub-second check",
|
||||
schedule=CronSchedule(kind="every", every_ms=200),
|
||||
message="check",
|
||||
**_bound_chat(),
|
||||
)
|
||||
result = tool._list_jobs()
|
||||
assert "every 200ms" in result
|
||||
@@ -212,6 +226,7 @@ def test_list_at_job_shows_iso_timestamp(tmp_path) -> None:
|
||||
name="One-shot",
|
||||
schedule=CronSchedule(kind="at", at_ms=1773684000000),
|
||||
message="fire",
|
||||
**_bound_chat(),
|
||||
)
|
||||
result = tool._list_jobs()
|
||||
assert "at 2026-" in result
|
||||
@@ -226,6 +241,7 @@ async def test_list_shows_last_run_state(tmp_path) -> None:
|
||||
name="Stateful job",
|
||||
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"),
|
||||
message="test",
|
||||
**_bound_chat(),
|
||||
)
|
||||
# Simulate a completed run by updating state in the store
|
||||
job.state.last_run_at_ms = 1773673200000
|
||||
@@ -245,6 +261,7 @@ async def test_list_shows_error_message(tmp_path) -> None:
|
||||
name="Failed job",
|
||||
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"),
|
||||
message="test",
|
||||
**_bound_chat(),
|
||||
)
|
||||
job.state.last_run_at_ms = 1773673200000
|
||||
job.state.last_status = "error"
|
||||
@@ -262,6 +279,7 @@ def test_list_shows_next_run(tmp_path) -> None:
|
||||
name="Upcoming job",
|
||||
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"),
|
||||
message="test",
|
||||
**_bound_chat(),
|
||||
)
|
||||
result = tool._list_jobs()
|
||||
assert "Next run:" in result
|
||||
|
||||
@@ -1573,12 +1573,49 @@ def test_kimi_k26_thinking_enabled_with_openrouter_prefix() -> None:
|
||||
assert "reasoning_effort" not in kw
|
||||
|
||||
|
||||
def test_kimi_k27_code_thinking_enabled() -> None:
|
||||
"""Kimi K2.7 Code supports native thinking controls."""
|
||||
kw = _build_kwargs_for("moonshot", "kimi-k2.7-code", reasoning_effort="medium")
|
||||
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
|
||||
assert "reasoning_effort" not in kw
|
||||
|
||||
|
||||
def test_kimi_k27_code_thinking_enabled_with_openrouter_prefix() -> None:
|
||||
"""OpenRouter-routed Kimi K2.7 Code should carry both thinking shapes."""
|
||||
kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.7-code", reasoning_effort="high")
|
||||
assert kw.get("extra_body") == {
|
||||
"thinking": {"type": "enabled"},
|
||||
"reasoning": {"effort": "high"},
|
||||
}
|
||||
assert "reasoning_effort" not in kw
|
||||
|
||||
|
||||
def test_kimi_k27_code_thinking_none_omits_disabled() -> None:
|
||||
"""Kimi K2.7 Code is always-thinking; disabled thinking is invalid upstream."""
|
||||
kw = _build_kwargs_for("moonshot", "kimi-k2.7-code", reasoning_effort="none")
|
||||
assert "extra_body" not in kw
|
||||
assert "reasoning_effort" not in kw
|
||||
|
||||
|
||||
def test_kimi_k27_code_thinking_none_with_openrouter_prefix_omits_disabled() -> None:
|
||||
"""OpenRouter-routed Kimi K2.7 Code should not request disabled thinking."""
|
||||
kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.7-code", reasoning_effort="none")
|
||||
assert "extra_body" not in kw
|
||||
assert "reasoning_effort" not in kw
|
||||
|
||||
|
||||
def test_moonshot_kimi_k26_temperature_override() -> None:
|
||||
"""Moonshot registry forces temperature 1.0 for kimi-k2.6 (API requirement)."""
|
||||
kw = _build_kwargs_for("moonshot", "kimi-k2.6", reasoning_effort=None)
|
||||
assert kw["temperature"] == 1.0
|
||||
|
||||
|
||||
def test_moonshot_kimi_k27_code_temperature_override() -> None:
|
||||
"""Moonshot registry should force temperature 1.0 for Kimi K2.7 Code."""
|
||||
kw = _build_kwargs_for("moonshot", "kimi-k2.7-code", reasoning_effort=None)
|
||||
assert kw["temperature"] == 1.0
|
||||
|
||||
|
||||
def test_kimi_k25_thinking_disabled_with_openrouter_prefix() -> None:
|
||||
"""OpenRouter names must NOT trigger thinking without reasoning_effort."""
|
||||
kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.5", reasoning_effort=None)
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import nanobot.providers.openai_codex_provider as codex_provider
|
||||
from nanobot.providers.anthropic_provider import AnthropicProvider
|
||||
from nanobot.providers.base import (
|
||||
DEFAULT_STREAM_IDLE_TIMEOUT_S,
|
||||
MAX_STREAM_IDLE_TIMEOUT_S,
|
||||
resolve_stream_idle_timeout_s,
|
||||
)
|
||||
from nanobot.providers.bedrock_provider import BedrockProvider
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
|
||||
|
||||
class _AsyncStream:
|
||||
def __init__(self, chunks: list[Any]) -> None:
|
||||
self._chunks = chunks
|
||||
self._idx = 0
|
||||
|
||||
def __aiter__(self) -> _AsyncStream:
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> Any:
|
||||
if self._idx >= len(self._chunks):
|
||||
raise StopAsyncIteration
|
||||
chunk = self._chunks[self._idx]
|
||||
self._idx += 1
|
||||
return chunk
|
||||
|
||||
|
||||
class _AnthropicStream(_AsyncStream):
|
||||
def __init__(self, chunks: list[Any]) -> None:
|
||||
super().__init__(chunks)
|
||||
self.get_final_message = AsyncMock(return_value=SimpleNamespace(
|
||||
content=[SimpleNamespace(type="text", text="ok")],
|
||||
stop_reason="end_turn",
|
||||
usage=SimpleNamespace(input_tokens=1, output_tokens=1),
|
||||
))
|
||||
|
||||
async def __aenter__(self) -> _AnthropicStream:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_exc: object) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _BedrockClient:
|
||||
def converse_stream(self, **_kwargs: Any) -> dict[str, Any]:
|
||||
return {"stream": iter([
|
||||
{"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "ok"}}},
|
||||
{"messageStop": {"stopReason": "end_turn"}},
|
||||
])}
|
||||
|
||||
|
||||
def test_stream_idle_timeout_parser_rejects_invalid_values() -> None:
|
||||
assert resolve_stream_idle_timeout_s(env_value="abc") == DEFAULT_STREAM_IDLE_TIMEOUT_S
|
||||
assert resolve_stream_idle_timeout_s(env_value="-1") == DEFAULT_STREAM_IDLE_TIMEOUT_S
|
||||
assert resolve_stream_idle_timeout_s(env_value="0") == DEFAULT_STREAM_IDLE_TIMEOUT_S
|
||||
|
||||
|
||||
def test_stream_idle_timeout_parser_accepts_and_clamps_numeric_values() -> None:
|
||||
assert resolve_stream_idle_timeout_s(env_value="1.5") == 1.5
|
||||
assert resolve_stream_idle_timeout_s(env_value="7200") == MAX_STREAM_IDLE_TIMEOUT_S
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_compat_stream_ignores_invalid_idle_timeout_env(monkeypatch) -> None:
|
||||
monkeypatch.setenv("NANOBOT_STREAM_IDLE_TIMEOUT_S", "abc")
|
||||
provider = OpenAICompatProvider(api_key="sk-test", api_base="https://example.com/v1")
|
||||
|
||||
chunk = SimpleNamespace(
|
||||
choices=[SimpleNamespace(
|
||||
delta=SimpleNamespace(
|
||||
content="ok",
|
||||
reasoning_content=None,
|
||||
reasoning=None,
|
||||
tool_calls=None,
|
||||
function_call=None,
|
||||
),
|
||||
finish_reason="stop",
|
||||
)],
|
||||
usage=None,
|
||||
)
|
||||
provider._client = SimpleNamespace(
|
||||
chat=SimpleNamespace(completions=SimpleNamespace(
|
||||
create=AsyncMock(return_value=_AsyncStream([chunk])),
|
||||
)),
|
||||
)
|
||||
|
||||
result = await provider.chat_stream(messages=[{"role": "user", "content": "hi"}])
|
||||
|
||||
assert result.content == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_stream_ignores_invalid_idle_timeout_env(monkeypatch) -> None:
|
||||
monkeypatch.setenv("NANOBOT_STREAM_IDLE_TIMEOUT_S", "abc")
|
||||
provider = AnthropicProvider(api_key="sk-test")
|
||||
provider._client = MagicMock()
|
||||
provider._client.messages.stream = MagicMock(return_value=_AnthropicStream([]))
|
||||
|
||||
result = await provider.chat_stream(messages=[{"role": "user", "content": "hi"}])
|
||||
|
||||
assert result.content == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bedrock_stream_ignores_invalid_idle_timeout_env(monkeypatch) -> None:
|
||||
monkeypatch.setenv("NANOBOT_STREAM_IDLE_TIMEOUT_S", "abc")
|
||||
provider = BedrockProvider(region="us-east-1", client=_BedrockClient())
|
||||
|
||||
result = await provider.chat_stream(messages=[{"role": "user", "content": "hi"}])
|
||||
|
||||
assert result.content == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_stream_ignores_invalid_idle_timeout_env(monkeypatch) -> None:
|
||||
monkeypatch.setenv("NANOBOT_STREAM_IDLE_TIMEOUT_S", "abc")
|
||||
original_client = httpx.AsyncClient
|
||||
seen: dict[str, float] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, request=request)
|
||||
|
||||
def fake_client(*, timeout: float, verify: bool) -> httpx.AsyncClient:
|
||||
seen["timeout"] = timeout
|
||||
return original_client(transport=httpx.MockTransport(handler), timeout=timeout)
|
||||
|
||||
monkeypatch.setattr(codex_provider.httpx, "AsyncClient", fake_client)
|
||||
|
||||
await codex_provider._request_codex(
|
||||
"https://codex.example/responses",
|
||||
{},
|
||||
{"input": []},
|
||||
verify=True,
|
||||
)
|
||||
|
||||
assert seen["timeout"] == DEFAULT_STREAM_IDLE_TIMEOUT_S
|
||||
@@ -14,12 +14,16 @@ from nanobot.session.turn_continuation import (
|
||||
INTERNAL_CONTINUATION_META,
|
||||
INTERNAL_CONTINUATION_PENDING_META,
|
||||
INTERNAL_CONTINUATION_RUN_STARTED_AT_META,
|
||||
SUBAGENT_RESULT_CONTINUATION_KIND,
|
||||
_save_skip_for_turn,
|
||||
internal_continuation_pending,
|
||||
internal_continuation_run_started_at,
|
||||
maybe_continue_turn,
|
||||
should_finalize_on_max_iterations,
|
||||
should_stream_budget_response,
|
||||
subagent_result_continuation_inbound,
|
||||
subagent_result_continuation_metadata,
|
||||
subagent_result_continuation_task_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -165,3 +169,23 @@ def test_save_skip_unchanged_for_standalone_current_message():
|
||||
history_count=1,
|
||||
user_persisted_early=False,
|
||||
) == 2
|
||||
|
||||
|
||||
def test_subagent_result_continuation_metadata():
|
||||
meta = subagent_result_continuation_metadata(
|
||||
{
|
||||
"message_id": "msg-1",
|
||||
"_stream_id": "old-stream",
|
||||
"_stream_delta": True,
|
||||
},
|
||||
task_id="sub-1",
|
||||
run_started_at=42.0,
|
||||
)
|
||||
|
||||
assert meta[INTERNAL_CONTINUATION_META] is True
|
||||
assert meta[INTERNAL_CONTINUATION_KIND_META] == SUBAGENT_RESULT_CONTINUATION_KIND
|
||||
assert meta[INTERNAL_CONTINUATION_RUN_STARTED_AT_META] == 42.0
|
||||
assert subagent_result_continuation_inbound(meta)
|
||||
assert subagent_result_continuation_task_id(meta) == "sub-1"
|
||||
assert "_stream_id" not in meta
|
||||
assert "_stream_delta" not in meta
|
||||
|
||||
@@ -32,6 +32,7 @@ def _make_mock_agent(response_text: str = "mock response") -> MagicMock:
|
||||
agent.process_direct = AsyncMock(return_value=response_text)
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
return agent
|
||||
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ def _make_streaming_agent(tokens: list[str]) -> MagicMock:
|
||||
return " ".join(tokens)
|
||||
|
||||
agent.process_direct = fake_process_direct
|
||||
agent._last_usage = {}
|
||||
return agent
|
||||
|
||||
|
||||
@@ -133,6 +134,7 @@ async def test_stream_false_returns_json(aiohttp_client) -> None:
|
||||
agent.process_direct = AsyncMock(return_value="normal reply")
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@@ -155,6 +157,7 @@ async def test_stream_default_is_false(aiohttp_client) -> None:
|
||||
agent.process_direct = AsyncMock(return_value="default reply")
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@@ -209,6 +212,7 @@ async def test_stream_passes_on_stream_callbacks(aiohttp_client) -> None:
|
||||
agent.process_direct = fake_process_direct
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@@ -241,6 +245,7 @@ async def test_stream_segment_end_does_not_close_sse(aiohttp_client) -> None:
|
||||
agent.process_direct = fake_process_direct
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@@ -279,6 +284,7 @@ async def test_stream_uses_final_response_when_no_deltas(aiohttp_client) -> None
|
||||
agent.process_direct = fake_process_direct
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@@ -320,6 +326,7 @@ async def test_stream_with_session_id(aiohttp_client) -> None:
|
||||
agent.process_direct = fake_process_direct
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@@ -348,6 +355,7 @@ async def test_streaming_backend_failure_does_not_emit_success_terminator(aiohtt
|
||||
agent.process_direct = boom
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
@@ -33,6 +33,7 @@ def _make_mock_agent(response_text: str = "mock response") -> MagicMock:
|
||||
agent.process_direct = AsyncMock(return_value=response_text)
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
||||
return agent
|
||||
|
||||
|
||||
@@ -78,6 +79,25 @@ def test_chat_completion_response() -> None:
|
||||
assert result["choices"][0]["message"]["content"] == "hello world"
|
||||
assert result["choices"][0]["finish_reason"] == "stop"
|
||||
assert result["id"].startswith("chatcmpl-")
|
||||
assert result["usage"]["prompt_tokens"] == 0
|
||||
assert result["usage"]["completion_tokens"] == 0
|
||||
assert result["usage"]["total_tokens"] == 0
|
||||
|
||||
|
||||
def test_chat_completion_response_with_usage() -> None:
|
||||
usage = {"prompt_tokens": 150, "completion_tokens": 42}
|
||||
result = _chat_completion_response("hello world", "test-model", usage)
|
||||
assert result["usage"]["prompt_tokens"] == 150
|
||||
assert result["usage"]["completion_tokens"] == 42
|
||||
assert result["usage"]["total_tokens"] == 192
|
||||
|
||||
|
||||
def test_chat_completion_response_preserves_provider_total_usage() -> None:
|
||||
usage = {"total_tokens": 77}
|
||||
result = _chat_completion_response("hello world", "test-model", usage)
|
||||
assert result["usage"]["prompt_tokens"] == 0
|
||||
assert result["usage"]["completion_tokens"] == 0
|
||||
assert result["usage"]["total_tokens"] == 77
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||
@@ -213,6 +233,7 @@ async def test_followup_requests_share_same_session_key(aiohttp_client) -> None:
|
||||
agent.process_direct = fake_process
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@@ -250,6 +271,7 @@ async def test_fixed_session_requests_are_serialized(aiohttp_client) -> None:
|
||||
agent.process_direct = slow_process
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@@ -364,6 +386,7 @@ async def test_empty_response_retry_then_success(aiohttp_client) -> None:
|
||||
agent.process_direct = sometimes_empty
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@@ -377,6 +400,32 @@ async def test_empty_response_retry_then_success(aiohttp_client) -> None:
|
||||
assert call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_response_retry_does_not_duplicate_user_turn(aiohttp_client) -> None:
|
||||
persist_flags = []
|
||||
|
||||
async def record(content, session_key="", channel="", chat_id="", **kwargs):
|
||||
persist_flags.append(kwargs.get("persist_user_message", True))
|
||||
return "" if len(persist_flags) == 1 else "recovered response"
|
||||
|
||||
agent = MagicMock()
|
||||
agent.process_direct = record
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
resp = await client.post(
|
||||
"/v1/chat/completions",
|
||||
json={"messages": [{"role": "user", "content": "hello"}]},
|
||||
)
|
||||
assert resp.status == 200
|
||||
# first call persists the user turn; the retry must not persist it again
|
||||
assert persist_flags == [True, False]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_response_falls_back(aiohttp_client) -> None:
|
||||
@@ -393,6 +442,7 @@ async def test_empty_response_falls_back(aiohttp_client) -> None:
|
||||
agent.process_direct = always_empty
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
@@ -1,7 +1,33 @@
|
||||
from nanobot.utils.helpers import split_message
|
||||
import tiktoken
|
||||
|
||||
from nanobot.utils.helpers import split_message, truncate_text_to_tokens
|
||||
|
||||
|
||||
def test_split_message_no_code_blocks_unchanged():
|
||||
content = "alpha beta gamma delta"
|
||||
|
||||
assert split_message(content, max_len=12) == ["alpha beta", "gamma delta"]
|
||||
|
||||
|
||||
def test_truncate_text_to_tokens_keeps_text_within_budget():
|
||||
text = "hello world " * 100
|
||||
|
||||
result = truncate_text_to_tokens(text, 10_000)
|
||||
|
||||
assert result == text
|
||||
|
||||
|
||||
def test_truncate_text_to_tokens_truncates_over_budget():
|
||||
enc = tiktoken.get_encoding("cl100k_base")
|
||||
text = "word " * 1_000
|
||||
|
||||
result = truncate_text_to_tokens(text, 50)
|
||||
|
||||
assert result.endswith("\n... (truncated)")
|
||||
assert len(enc.encode(result)) <= 50
|
||||
|
||||
|
||||
def test_truncate_text_to_tokens_non_positive_budget_returns_text():
|
||||
text = "anything"
|
||||
|
||||
assert truncate_text_to_tokens(text, 0) == text
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import nanobot.webui.session_list_index as session_list_index
|
||||
@@ -86,5 +88,86 @@ def test_webui_session_list_skips_cron_internal_user_preview(tmp_path: Path) ->
|
||||
assert list_webui_sessions(manager)[0]["preview"] == "提醒已经到期。"
|
||||
|
||||
|
||||
def test_webui_session_list_uses_webui_transcript_activity_for_sort(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
webui_dir = tmp_path / "webui"
|
||||
webui_dir.mkdir()
|
||||
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
||||
|
||||
manager = SessionManager(tmp_path)
|
||||
old_session = manager.get_or_create("websocket:old-metadata")
|
||||
old_session.created_at = datetime(2026, 6, 15, 10, 0, 0)
|
||||
old_session.updated_at = datetime(2026, 6, 15, 10, 0, 0)
|
||||
old_session.add_message("user", "old metadata")
|
||||
old_session.updated_at = datetime(2026, 6, 15, 10, 0, 0)
|
||||
manager.save(old_session)
|
||||
|
||||
newer_metadata = manager.get_or_create("websocket:newer-metadata")
|
||||
newer_metadata.created_at = datetime(2026, 6, 15, 11, 0, 0)
|
||||
newer_metadata.updated_at = datetime(2026, 6, 15, 11, 0, 0)
|
||||
newer_metadata.add_message("user", "newer metadata")
|
||||
newer_metadata.updated_at = datetime(2026, 6, 15, 11, 0, 0)
|
||||
manager.save(newer_metadata)
|
||||
|
||||
transcript = webui_dir / "websocket_old-metadata.jsonl"
|
||||
transcript.write_text(
|
||||
'{"event":"turn_end","chat_id":"old-metadata"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
activity_ns = int(datetime(2026, 6, 15, 12, 0, 0).timestamp() * 1_000_000_000)
|
||||
os.utime(transcript, ns=(activity_ns, activity_ns))
|
||||
|
||||
rows = list_webui_sessions(manager)
|
||||
|
||||
assert [row["key"] for row in rows] == [
|
||||
"websocket:old-metadata",
|
||||
"websocket:newer-metadata",
|
||||
]
|
||||
assert rows[0]["updated_at"].startswith("2026-06-15T12:00:00")
|
||||
|
||||
|
||||
def test_webui_session_list_rescans_when_transcript_changes(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
webui_dir = tmp_path / "webui"
|
||||
webui_dir.mkdir()
|
||||
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
||||
|
||||
manager = SessionManager(tmp_path)
|
||||
session = manager.get_or_create("websocket:transcript-change")
|
||||
session.created_at = datetime(2026, 6, 15, 10, 0, 0)
|
||||
session.updated_at = datetime(2026, 6, 15, 10, 0, 0)
|
||||
session.add_message("user", "preview")
|
||||
session.updated_at = datetime(2026, 6, 15, 10, 0, 0)
|
||||
manager.save(session)
|
||||
|
||||
assert list_webui_sessions(manager)[0]["preview"] == "preview"
|
||||
|
||||
transcript = webui_dir / "websocket_transcript-change.jsonl"
|
||||
transcript.write_text(
|
||||
'{"event":"turn_end","chat_id":"transcript-change"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
activity_ns = int(datetime(2026, 6, 15, 12, 30, 0).timestamp() * 1_000_000_000)
|
||||
os.utime(transcript, ns=(activity_ns, activity_ns))
|
||||
|
||||
original_scan = session_list_index._scan_session_row
|
||||
scanned: list[str] = []
|
||||
|
||||
def record_scan(session_manager: SessionManager, path: Path) -> dict | None:
|
||||
scanned.append(path.name)
|
||||
return original_scan(session_manager, path)
|
||||
|
||||
monkeypatch.setattr(session_list_index, "_scan_session_row", record_scan)
|
||||
|
||||
rows = list_webui_sessions(manager)
|
||||
|
||||
assert scanned == [manager._get_session_path("websocket:transcript-change").name]
|
||||
assert rows[0]["updated_at"].startswith("2026-06-15T12:30:00")
|
||||
|
||||
|
||||
def list_webui_sessions(manager: SessionManager) -> list[dict]:
|
||||
return session_list_index.list_webui_sessions(manager)
|
||||
|
||||
+11
-60
@@ -1,6 +1,10 @@
|
||||
# nanobot WebUI
|
||||
# nanobot WebUI Source
|
||||
|
||||
The WebUI is the browser workbench served by `nanobot gateway`. If you installed `nanobot-ai` from PyPI, the WebUI bundle is already included; this `webui/` source tree is only needed when you are changing the frontend.
|
||||
This directory contains the React/TypeScript source for the nanobot WebUI. If
|
||||
you installed `nanobot-ai` from PyPI and only want to use the bundled browser UI,
|
||||
read the user guide in [`docs/webui.md`](../docs/webui.md). You do not need
|
||||
Node.js, Bun, Vite, or anything in this directory unless you are changing the
|
||||
frontend.
|
||||
|
||||
For the project overview, install guide, and general docs map, see the root [`README.md`](../README.md) and [`docs/README.md`](../docs/README.md).
|
||||
|
||||
@@ -8,46 +12,14 @@ For the project overview, install guide, and general docs map, see the root [`RE
|
||||
|
||||
| Goal | Start with | Opens at |
|
||||
|---|---|---|
|
||||
| Use the bundled browser UI | [Just want to use the WebUI?](#just-want-to-use-the-webui) | `http://127.0.0.1:8765` |
|
||||
| Use the WebUI from another device | [Access from another device (LAN)](#access-from-another-device-lan) | `http://<your-ip>:8765` |
|
||||
| Use the bundled browser UI | [`docs/webui.md`](../docs/webui.md) | `http://127.0.0.1:8765` |
|
||||
| Use the WebUI from another device | [`docs/webui.md#lan-access`](../docs/webui.md#lan-access) | `http://<your-ip>:8765` |
|
||||
| Change WebUI source code | [Develop the WebUI (Vite HMR)](#develop-the-webui-vite-hmr) | `http://127.0.0.1:5173` |
|
||||
| Debug setup failures | [`docs/troubleshooting.md#webui-problems`](../docs/troubleshooting.md#webui-problems) | Diagnosis order and common fixes |
|
||||
|
||||
## Just want to use the WebUI?
|
||||
|
||||
If you installed nanobot via `python -m pip install nanobot-ai`, the WebUI is **already bundled** in the wheel. You do **not** need Node.js, Bun, Vite, or anything in this directory unless you are changing the WebUI source code.
|
||||
|
||||
First prove the provider path:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
If the shell cannot find `nanobot`, use the module form from the same Python environment:
|
||||
|
||||
```bash
|
||||
python -m nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
Then merge this WebSocket snippet into your existing `~/.nanobot/config.json` instead of replacing the whole file:
|
||||
|
||||
```json
|
||||
{ "channels": { "websocket": { "enabled": true } } }
|
||||
```
|
||||
|
||||
If you are new to JSON snippets, see [`docs/start-without-technical-background.md#how-to-merge-json-snippets`](../docs/start-without-technical-background.md#how-to-merge-json-snippets).
|
||||
|
||||
Start the gateway:
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
Leave this terminal running while you use the WebUI. Closing it stops the browser UI and WebSocket connection.
|
||||
|
||||
Open [`http://127.0.0.1:8765`](http://127.0.0.1:8765). The gateway's `18790` port is only the health endpoint, not the browser UI. For setup failures, use [`docs/troubleshooting.md`](../docs/troubleshooting.md#webui-problems).
|
||||
|
||||
This `webui/` tree is for people **changing the WebUI source code**. It is built with Vite + React 18 + TypeScript + Tailwind 3 + shadcn/ui, talks to the gateway over the WebSocket multiplex protocol, and reads session metadata from the embedded REST surface on the same port.
|
||||
The source app is built with Vite + React 18 + TypeScript + Tailwind 3 +
|
||||
shadcn/ui. It talks to the gateway over the WebSocket multiplex protocol and
|
||||
reads session metadata from the embedded REST surface on the same port.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -104,27 +76,6 @@ If your gateway listens on a non-default port, point the dev server at it:
|
||||
NANOBOT_API_URL=http://127.0.0.1:9000 bun run dev
|
||||
```
|
||||
|
||||
### Access from another device (LAN)
|
||||
|
||||
To use the WebUI from another device on the same network, set `host` to `"0.0.0.0"` and configure a `token` or `tokenIssueSecret` in `~/.nanobot/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"enabled": true,
|
||||
"host": "0.0.0.0",
|
||||
"port": 8765,
|
||||
"tokenIssueSecret": "your-secret-here"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The gateway will refuse to start if `host` is `"0.0.0.0"` and neither `token` nor `tokenIssueSecret` is set.
|
||||
|
||||
Then open `http://<your-ip>:8765` on the other device. The WebUI will show an authentication form where you enter the secret. It is saved in your browser so you only need to enter it once.
|
||||
|
||||
## Build for packaged runtime
|
||||
|
||||
You usually do not need to run this by hand: `python -m build` invokes the WebUI build automatically when packaging the wheel.
|
||||
|
||||
+54
-21
@@ -65,14 +65,15 @@ type BootState =
|
||||
};
|
||||
|
||||
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
|
||||
const COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1";
|
||||
const SESSION_UPDATES_STORAGE_KEY = "nanobot-webui.sidebar.session-updates.v1";
|
||||
const LEGACY_COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1";
|
||||
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
|
||||
const SIDEBAR_WIDTH = 272;
|
||||
const SIDEBAR_RAIL_WIDTH = 56;
|
||||
const MOBILE_SIDEBAR_WIDTH = `min(${SIDEBAR_WIDTH}px, calc(100vw - 0.75rem))`;
|
||||
const TOKEN_REFRESH_MARGIN_MS = 30_000;
|
||||
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
|
||||
type ShellView = "chat" | "settings" | "apps" | "skills";
|
||||
type ShellView = "chat" | "settings" | "apps" | "automations" | "skills";
|
||||
type ShellRoute = {
|
||||
view: ShellView;
|
||||
activeKey: string | null;
|
||||
@@ -87,6 +88,7 @@ const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [
|
||||
"voice",
|
||||
"browser",
|
||||
"apps",
|
||||
"automations",
|
||||
"skills",
|
||||
"runtime",
|
||||
"advanced",
|
||||
@@ -101,7 +103,7 @@ function defaultShellRoute(): ShellRoute {
|
||||
}
|
||||
|
||||
function shellViewForSettingsSection(section: SettingsSectionKey): ShellView {
|
||||
if (section === "apps" || section === "skills") return section;
|
||||
if (section === "apps" || section === "automations" || section === "skills") return section;
|
||||
return "settings";
|
||||
}
|
||||
|
||||
@@ -130,6 +132,9 @@ function readShellRoute(): ShellRoute {
|
||||
if (path === "/apps") {
|
||||
return { view: "apps", activeKey, settingsSection: "apps" };
|
||||
}
|
||||
if (path === "/automations") {
|
||||
return { view: "automations", activeKey, settingsSection: "automations" };
|
||||
}
|
||||
if (path === "/skills") {
|
||||
return { view: "skills", activeKey, settingsSection: "skills" };
|
||||
}
|
||||
@@ -255,10 +260,12 @@ function readSidebarOpen(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function readCompletedRunChatIds(): Set<string> {
|
||||
function readSessionUpdateChatIds(): Set<string> {
|
||||
if (typeof window === "undefined") return new Set();
|
||||
try {
|
||||
const raw = window.localStorage.getItem(COMPLETED_RUNS_STORAGE_KEY);
|
||||
const raw =
|
||||
window.localStorage.getItem(SESSION_UPDATES_STORAGE_KEY)
|
||||
?? window.localStorage.getItem(LEGACY_COMPLETED_RUNS_STORAGE_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
if (!Array.isArray(parsed)) return new Set();
|
||||
return new Set(parsed.filter((item): item is string => typeof item === "string"));
|
||||
@@ -267,10 +274,10 @@ function readCompletedRunChatIds(): Set<string> {
|
||||
}
|
||||
}
|
||||
|
||||
function writeCompletedRunChatIds(chatIds: Set<string>): void {
|
||||
function writeSessionUpdateChatIds(chatIds: Set<string>): void {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
COMPLETED_RUNS_STORAGE_KEY,
|
||||
SESSION_UPDATES_STORAGE_KEY,
|
||||
JSON.stringify(Array.from(chatIds)),
|
||||
);
|
||||
} catch {
|
||||
@@ -570,7 +577,7 @@ function Shell({
|
||||
const [restartToast, setRestartToast] = useState<string | null>(null);
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set());
|
||||
const [completedChatIds, setCompletedChatIds] = useState<Set<string>>(readCompletedRunChatIds);
|
||||
const [updatedChatIds, setUpdatedChatIds] = useState<Set<string>>(readSessionUpdateChatIds);
|
||||
const [workspaces, setWorkspaces] = useState<WorkspacesPayload | null>(null);
|
||||
const skills = useSkills(token);
|
||||
const [settingsSnapshot, setSettingsSnapshot] = useState<SettingsPayload | null>(null);
|
||||
@@ -638,20 +645,20 @@ function Shell({
|
||||
}, [hostSidebarOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
writeCompletedRunChatIds(completedChatIds);
|
||||
}, [completedChatIds]);
|
||||
writeSessionUpdateChatIds(updatedChatIds);
|
||||
}, [updatedChatIds]);
|
||||
|
||||
const activeSession = useMemo<ChatSummary | null>(() => {
|
||||
if (!activeKey) return null;
|
||||
return sessions.find((s) => s.key === activeKey) ?? null;
|
||||
}, [sessions, activeKey]);
|
||||
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
||||
const completedChatIdList = useMemo(() => Array.from(completedChatIds), [completedChatIds]);
|
||||
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
||||
const activeChatId = activeSession?.chatId ?? null;
|
||||
useEffect(() => {
|
||||
activeChatIdRef.current = activeChatId;
|
||||
if (!activeChatId) return;
|
||||
setCompletedChatIds((current) => {
|
||||
setUpdatedChatIds((current) => {
|
||||
if (!current.has(activeChatId)) return current;
|
||||
const next = new Set(current);
|
||||
next.delete(activeChatId);
|
||||
@@ -691,7 +698,7 @@ function Shell({
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
const knownChatIds = new Set(sessions.map((session) => session.chatId));
|
||||
setCompletedChatIds((current) => {
|
||||
setUpdatedChatIds((current) => {
|
||||
const next = new Set(
|
||||
Array.from(current).filter((chatId) => knownChatIds.has(chatId)),
|
||||
);
|
||||
@@ -719,12 +726,25 @@ function Shell({
|
||||
}, [activeKey, loading, navigate, sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
return client.onSessionUpdate((_chatId, _scope, workspaceScope) => {
|
||||
return client.onSessionUpdate((chatId, scope, workspaceScope) => {
|
||||
if (scope === "thread") {
|
||||
setUpdatedChatIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (activeChatIdRef.current === chatId) {
|
||||
next.delete(chatId);
|
||||
} else {
|
||||
next.add(chatId);
|
||||
}
|
||||
return next.size === current.size && next.has(chatId) === current.has(chatId)
|
||||
? current
|
||||
: next;
|
||||
});
|
||||
}
|
||||
if (!workspaceScope) return;
|
||||
const next = normalizeWorkspaceScope(workspaceScope);
|
||||
setWorkspaceOverrides((current) => ({
|
||||
...current,
|
||||
[_chatId]: next,
|
||||
[chatId]: next,
|
||||
}));
|
||||
setDraftWorkspaceScope(next);
|
||||
setWorkspaceError(null);
|
||||
@@ -761,7 +781,7 @@ function Shell({
|
||||
runningChatIdsRef.current = next;
|
||||
return next;
|
||||
});
|
||||
setCompletedChatIds((current) => {
|
||||
setUpdatedChatIds((current) => {
|
||||
let changed = false;
|
||||
const next = new Set(current);
|
||||
for (const chatId of activeRunIds) {
|
||||
@@ -958,7 +978,7 @@ function Shell({
|
||||
const selected = sessions.find((session) => session.key === key);
|
||||
const selectedChatId = selected?.chatId;
|
||||
if (selectedChatId) {
|
||||
setCompletedChatIds((current) => {
|
||||
setUpdatedChatIds((current) => {
|
||||
if (!current.has(selectedChatId)) return current;
|
||||
const next = new Set(current);
|
||||
next.delete(selectedChatId);
|
||||
@@ -1166,6 +1186,12 @@ function Shell({
|
||||
setMobileSidebarOpen(false);
|
||||
}, [activeKey, navigate]);
|
||||
|
||||
const onOpenAutomations = useCallback(() => {
|
||||
setSessionSearchOpen(false);
|
||||
navigate({ view: "automations", activeKey, settingsSection: "automations" });
|
||||
setMobileSidebarOpen(false);
|
||||
}, [activeKey, navigate]);
|
||||
|
||||
const onOpenSkills = useCallback(() => {
|
||||
setSessionSearchOpen(false);
|
||||
navigate({ view: "skills", activeKey, settingsSection: "skills" });
|
||||
@@ -1223,7 +1249,7 @@ function Shell({
|
||||
nextRunning.add(chatId);
|
||||
runningChatIdsRef.current = nextRunning;
|
||||
setRunningChatIds(nextRunning);
|
||||
setCompletedChatIds((current) => {
|
||||
setUpdatedChatIds((current) => {
|
||||
if (!current.has(chatId)) return current;
|
||||
const next = new Set(current);
|
||||
next.delete(chatId);
|
||||
@@ -1237,7 +1263,7 @@ function Shell({
|
||||
nextRunning.delete(chatId);
|
||||
runningChatIdsRef.current = nextRunning;
|
||||
setRunningChatIds(nextRunning);
|
||||
setCompletedChatIds((current) => {
|
||||
setUpdatedChatIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (activeChatIdRef.current === chatId) {
|
||||
next.delete(chatId);
|
||||
@@ -1341,6 +1367,12 @@ function Shell({
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (view === "automations") {
|
||||
document.title = t("app.documentTitle.chat", {
|
||||
title: t("settings.nav.automations", { defaultValue: "Automations" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (view === "skills") {
|
||||
document.title = t("app.documentTitle.chat", {
|
||||
title: t("settings.nav.skills", { defaultValue: "Skills" }),
|
||||
@@ -1367,9 +1399,10 @@ function Shell({
|
||||
onNewChatInProject,
|
||||
onOpenSettings,
|
||||
onOpenApps,
|
||||
onOpenAutomations,
|
||||
onOpenSkills,
|
||||
onOpenSearch: onOpenSessionSearch,
|
||||
activeUtility: view === "apps" || view === "skills" ? view : null,
|
||||
activeUtility: view === "apps" || view === "automations" || view === "skills" ? view : null,
|
||||
onToggleArchived,
|
||||
pinnedKeys: sidebarState.pinned_keys,
|
||||
archivedKeys: sidebarState.archived_keys,
|
||||
@@ -1377,7 +1410,7 @@ function Shell({
|
||||
projectNameOverrides: sidebarState.project_name_overrides,
|
||||
collapsedGroups: sidebarState.collapsed_groups,
|
||||
runningChatIds: runningChatIdList,
|
||||
completedChatIds: completedChatIdList,
|
||||
updatedChatIds: updatedChatIdList,
|
||||
viewState: sidebarState.view,
|
||||
showArchived: sidebarState.view.show_archived,
|
||||
archivedCount: sidebarState.archived_keys.length,
|
||||
|
||||
@@ -60,7 +60,7 @@ interface ChatListProps {
|
||||
projectNameOverrides?: Record<string, string>;
|
||||
collapsedGroups?: Record<string, boolean>;
|
||||
runningChatIds?: string[];
|
||||
completedChatIds?: string[];
|
||||
updatedChatIds?: string[];
|
||||
density?: SidebarDensity;
|
||||
showPreviews?: boolean;
|
||||
showTimestamps?: boolean;
|
||||
@@ -89,7 +89,7 @@ export const ChatList = memo(function ChatList({
|
||||
projectNameOverrides = {},
|
||||
collapsedGroups = {},
|
||||
runningChatIds = [],
|
||||
completedChatIds = [],
|
||||
updatedChatIds = [],
|
||||
density = "comfortable",
|
||||
showPreviews = false,
|
||||
showTimestamps = false,
|
||||
@@ -175,7 +175,7 @@ export const ChatList = memo(function ChatList({
|
||||
const pinned = new Set(pinnedKeys);
|
||||
const archived = new Set(archivedKeys);
|
||||
const running = new Set(runningChatIds);
|
||||
const completed = new Set(completedChatIds);
|
||||
const updated = new Set(updatedChatIds);
|
||||
const compact = density === "compact";
|
||||
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
|
||||
|
||||
@@ -245,8 +245,8 @@ export const ChatList = memo(function ChatList({
|
||||
const projectMode = group.kind === "project";
|
||||
const activityState = running.has(s.chatId)
|
||||
? "running"
|
||||
: completed.has(s.chatId) && !active
|
||||
? "complete"
|
||||
: updated.has(s.chatId) && !active
|
||||
? "updated"
|
||||
: null;
|
||||
return (
|
||||
<li key={s.key} className="min-w-0">
|
||||
@@ -525,7 +525,7 @@ function ChatsFoldFooter({
|
||||
function SessionActivityIndicator({
|
||||
state,
|
||||
}: {
|
||||
state: "running" | "complete" | null;
|
||||
state: "running" | "updated" | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -542,15 +542,15 @@ function SessionActivityIndicator({
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "complete") {
|
||||
const label = t("chat.activity.complete");
|
||||
if (state === "updated") {
|
||||
const label = t("chat.activity.updated");
|
||||
return (
|
||||
<span
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className="grid h-4 w-4 shrink-0 place-items-center"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-blue-500 dark:bg-blue-400" />
|
||||
<span className="h-2 w-2 rounded-full bg-[#ff8a3d] shadow-[0_0_0_2px_rgba(255,138,61,0.16)]" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,16 +80,16 @@ export function DeleteConfirm({
|
||||
</div>
|
||||
) : null}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="mt-7 grid grid-cols-1 gap-3 space-x-0 sm:grid-cols-2">
|
||||
<AlertDialogFooter className="mt-7 !grid grid-cols-1 gap-3 space-x-0 sm:grid-cols-2 sm:space-x-0">
|
||||
<AlertDialogCancel
|
||||
onClick={onCancel}
|
||||
className="mt-0 h-11 rounded-full border-0 bg-muted/70 px-5 text-[15px] font-semibold text-foreground shadow-none hover:bg-muted"
|
||||
className="mt-0 h-11 w-full min-w-0 rounded-full border-0 bg-muted/70 px-5 text-[15px] font-semibold text-foreground shadow-none hover:bg-muted"
|
||||
>
|
||||
{t("deleteConfirm.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
className="h-11 rounded-full bg-destructive px-5 text-[15px] font-semibold text-destructive-foreground shadow-[0_10px_25px_rgba(239,68,68,0.28)] hover:bg-destructive/90"
|
||||
className="h-11 w-full min-w-0 !whitespace-normal rounded-full bg-destructive px-5 text-center text-[15px] font-semibold text-destructive-foreground shadow-[0_10px_25px_rgba(239,68,68,0.28)] hover:bg-destructive/90"
|
||||
>
|
||||
{hasAutomations
|
||||
? t("deleteConfirm.confirmWithAutomations")
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, type ReactNode } from "react";
|
||||
import {
|
||||
Archive,
|
||||
Brain,
|
||||
CalendarClock,
|
||||
Menu,
|
||||
Search,
|
||||
Settings,
|
||||
@@ -36,8 +37,9 @@ interface SidebarProps {
|
||||
onOpenSettings: () => void;
|
||||
onOpenApps: () => void;
|
||||
onOpenSkills: () => void;
|
||||
onOpenAutomations: () => void;
|
||||
onOpenSearch: () => void;
|
||||
activeUtility?: "apps" | "skills" | null;
|
||||
activeUtility?: "apps" | "skills" | "automations" | null;
|
||||
onToggleArchived: () => void;
|
||||
onCollapse: () => void;
|
||||
onExpand?: () => void;
|
||||
@@ -49,7 +51,7 @@ interface SidebarProps {
|
||||
projectNameOverrides?: Record<string, string>;
|
||||
collapsedGroups?: Record<string, boolean>;
|
||||
runningChatIds?: string[];
|
||||
completedChatIds?: string[];
|
||||
updatedChatIds?: string[];
|
||||
viewState?: SidebarViewState;
|
||||
showArchived?: boolean;
|
||||
archivedCount?: number;
|
||||
@@ -166,6 +168,13 @@ export function Sidebar(props: SidebarProps) {
|
||||
active={props.activeUtility === "skills"}
|
||||
icon={<Brain className="h-4 w-4" />}
|
||||
/>
|
||||
<SidebarActionButton
|
||||
collapsed={collapsed}
|
||||
label={t("sidebar.automations", { defaultValue: "Automations" })}
|
||||
onClick={props.onOpenAutomations}
|
||||
active={props.activeUtility === "automations"}
|
||||
icon={<CalendarClock className="h-4 w-4" />}
|
||||
/>
|
||||
{props.archivedCount ? (
|
||||
<SidebarActionButton
|
||||
collapsed={collapsed}
|
||||
@@ -201,7 +210,7 @@ export function Sidebar(props: SidebarProps) {
|
||||
projectNameOverrides={props.projectNameOverrides}
|
||||
collapsedGroups={props.collapsedGroups}
|
||||
runningChatIds={props.runningChatIds}
|
||||
completedChatIds={props.completedChatIds}
|
||||
updatedChatIds={props.updatedChatIds}
|
||||
density={props.viewState?.density}
|
||||
showPreviews={props.viewState?.show_previews}
|
||||
showTimestamps={props.viewState?.show_timestamps}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,6 +55,7 @@
|
||||
"ariaLabel": "Change language"
|
||||
},
|
||||
"apps": "Apps",
|
||||
"automations": "Automations",
|
||||
"skills": {
|
||||
"title": "Skills"
|
||||
}
|
||||
@@ -80,6 +81,7 @@
|
||||
"runtime": "System",
|
||||
"advanced": "Security",
|
||||
"apps": "Apps",
|
||||
"automations": "Automations",
|
||||
"skills": "Skills"
|
||||
},
|
||||
"sections": {
|
||||
@@ -469,6 +471,127 @@
|
||||
"loading": "Loading Apps...",
|
||||
"empty": "No apps match this filter."
|
||||
},
|
||||
"automations": {
|
||||
"filters": {
|
||||
"all": "All",
|
||||
"active": "Active",
|
||||
"paused": "Paused",
|
||||
"failed": "Needs attention",
|
||||
"system": "System"
|
||||
},
|
||||
"sort": {
|
||||
"next": "Next run",
|
||||
"last": "Last run",
|
||||
"updated": "Updated",
|
||||
"name": "Name"
|
||||
},
|
||||
"search": "Search task, message, linked chat, or schedule",
|
||||
"queue": "Queue",
|
||||
"loading": "Loading automations...",
|
||||
"noMatches": "No automations match this view.",
|
||||
"empty": "No automations yet.",
|
||||
"emptyHint": "Create one from where it should run so nanobot keeps the right context.",
|
||||
"oneShot": "One-time",
|
||||
"systemTask": "System-managed automation",
|
||||
"labels": {
|
||||
"schedule": "Schedule",
|
||||
"next": "Next",
|
||||
"origin": "Linked chat",
|
||||
"created": "Created",
|
||||
"updated": "Updated"
|
||||
},
|
||||
"runNow": "Run now",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"protected": "Protected",
|
||||
"editTitle": "Edit automation",
|
||||
"save": "Save",
|
||||
"deleteTitle": "Delete automation",
|
||||
"deleteDescription": "This removes {{name}} from the cron store. Past chat messages stay in the session.",
|
||||
"cancel": "Cancel",
|
||||
"status": {
|
||||
"system": "System",
|
||||
"running": "Running now",
|
||||
"paused": "Paused",
|
||||
"failed": "Failed",
|
||||
"completed": "Completed",
|
||||
"noSchedule": "No schedule",
|
||||
"active": "Active"
|
||||
},
|
||||
"origin": {
|
||||
"system": "System",
|
||||
"unknown": "No linked chat"
|
||||
},
|
||||
"channels": {
|
||||
"api": "API",
|
||||
"cli": "CLI",
|
||||
"dingtalk": "DingTalk",
|
||||
"discord": "Discord",
|
||||
"email": "Email",
|
||||
"feishu": "Feishu",
|
||||
"matrix": "Matrix",
|
||||
"msteams": "Microsoft Teams",
|
||||
"qq": "QQ",
|
||||
"slack": "Slack",
|
||||
"telegram": "Telegram",
|
||||
"wechat": "WeChat",
|
||||
"wecom": "WeCom",
|
||||
"weixin": "WeChat",
|
||||
"whatsapp": "WhatsApp"
|
||||
},
|
||||
"schedule": {
|
||||
"at": "At {{time}}",
|
||||
"every": "Every {{duration}}",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"withTz": "{{summary}} · {{tz}}",
|
||||
"dailyAt": "Daily at {{time}}",
|
||||
"weekdaysAt": "Weekdays at {{time}}",
|
||||
"hourlyAt": "Hourly at :{{minute}}",
|
||||
"hourlyWindow": "Hourly {{start}}-{{end}} at :{{minute}}",
|
||||
"custom": "Custom schedule"
|
||||
},
|
||||
"next": {
|
||||
"paused": "Paused",
|
||||
"pending": "Running now",
|
||||
"none": "No next run"
|
||||
},
|
||||
"message": {
|
||||
"showMore": "Show full message",
|
||||
"showLess": "Show less"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"message": "Message",
|
||||
"scheduleType": "Schedule type",
|
||||
"every": "Every",
|
||||
"unit": "Unit",
|
||||
"cronExpression": "Cron expression",
|
||||
"timezone": "Timezone",
|
||||
"runAt": "Run at"
|
||||
},
|
||||
"scheduleTypes": {
|
||||
"every": "Interval",
|
||||
"cron": "Cron",
|
||||
"at": "Once"
|
||||
},
|
||||
"everyUnits": {
|
||||
"second": "Seconds",
|
||||
"minute": "Minutes",
|
||||
"hour": "Hours",
|
||||
"day": "Days"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Name is required.",
|
||||
"messageRequired": "Message is required.",
|
||||
"intervalRequired": "Interval must be a positive number.",
|
||||
"cronRequired": "Cron expression is required.",
|
||||
"timeRequired": "Run time is required.",
|
||||
"futureRequired": "Run time must be in the future."
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"authentication": "OAuth authentication",
|
||||
"signIn": "Sign in",
|
||||
@@ -527,7 +650,8 @@
|
||||
"newInProject": "Start a new chat in {{project}}",
|
||||
"activity": {
|
||||
"running": "Agent running",
|
||||
"complete": "Agent finished"
|
||||
"complete": "Agent finished",
|
||||
"updated": "New activity"
|
||||
},
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
@@ -562,7 +686,7 @@
|
||||
"confirm": "Delete",
|
||||
"automationsDescription": "This chat has scheduled automations. Deleting it will also delete them.",
|
||||
"moreAutomations": "+ {{count}} more",
|
||||
"confirmWithAutomations": "Delete chat and automations",
|
||||
"confirmWithAutomations": "Delete",
|
||||
"schedule": {
|
||||
"at": "{{time}}",
|
||||
"every": "Every {{duration}}",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"ariaLabel": "Cambiar idioma"
|
||||
},
|
||||
"apps": "Apps",
|
||||
"automations": "Automatizaciones",
|
||||
"skills": {
|
||||
"title": "Habilidades"
|
||||
}
|
||||
@@ -80,6 +81,7 @@
|
||||
"cliApps": "Apps CLI",
|
||||
"mcp": "MCP",
|
||||
"apps": "Aplicaciones",
|
||||
"automations": "Automatizaciones",
|
||||
"skills": "Habilidades"
|
||||
},
|
||||
"sections": {
|
||||
@@ -469,6 +471,127 @@
|
||||
"loading": "Cargando apps...",
|
||||
"empty": "Ninguna app coincide con este filtro."
|
||||
},
|
||||
"automations": {
|
||||
"filters": {
|
||||
"all": "Todas",
|
||||
"active": "Activas",
|
||||
"paused": "Pausadas",
|
||||
"failed": "Requieren atención",
|
||||
"system": "Sistema"
|
||||
},
|
||||
"sort": {
|
||||
"next": "Próxima ejecución",
|
||||
"last": "Última ejecución",
|
||||
"updated": "Actualizada",
|
||||
"name": "Nombre"
|
||||
},
|
||||
"search": "Buscar tarea, mensaje, chat vinculado u horario",
|
||||
"queue": "Cola",
|
||||
"loading": "Cargando automatizaciones...",
|
||||
"noMatches": "No hay automatizaciones que coincidan con esta vista.",
|
||||
"empty": "Aún no hay automatizaciones.",
|
||||
"emptyHint": "Créala desde donde debe ejecutarse para que nanobot conserve el contexto correcto.",
|
||||
"oneShot": "Una vez",
|
||||
"systemTask": "Automatización administrada por el sistema",
|
||||
"labels": {
|
||||
"schedule": "Programación",
|
||||
"next": "Siguiente",
|
||||
"origin": "Chat vinculado",
|
||||
"created": "Creada",
|
||||
"updated": "Actualizada"
|
||||
},
|
||||
"runNow": "Ejecutar ahora",
|
||||
"pause": "Pausar",
|
||||
"resume": "Reanudar",
|
||||
"edit": "Editar",
|
||||
"delete": "Eliminar",
|
||||
"protected": "Protegida",
|
||||
"editTitle": "Editar automatización",
|
||||
"save": "Guardar",
|
||||
"deleteTitle": "Eliminar automatización",
|
||||
"deleteDescription": "Esto elimina {{name}} del almacén cron. Los mensajes de chat anteriores permanecen en la sesión.",
|
||||
"cancel": "Cancelar",
|
||||
"status": {
|
||||
"system": "Sistema",
|
||||
"running": "Ejecutándose ahora",
|
||||
"paused": "Pausada",
|
||||
"failed": "Fallida",
|
||||
"completed": "Completada",
|
||||
"noSchedule": "Sin programación",
|
||||
"active": "Activa"
|
||||
},
|
||||
"origin": {
|
||||
"system": "Sistema",
|
||||
"unknown": "Sin chat vinculado"
|
||||
},
|
||||
"channels": {
|
||||
"api": "API",
|
||||
"cli": "CLI",
|
||||
"dingtalk": "DingTalk",
|
||||
"discord": "Discord",
|
||||
"email": "Email",
|
||||
"feishu": "Feishu",
|
||||
"matrix": "Matrix",
|
||||
"msteams": "Microsoft Teams",
|
||||
"qq": "QQ",
|
||||
"slack": "Slack",
|
||||
"telegram": "Telegram",
|
||||
"wechat": "WeChat",
|
||||
"wecom": "WeCom",
|
||||
"weixin": "WeChat",
|
||||
"whatsapp": "WhatsApp"
|
||||
},
|
||||
"schedule": {
|
||||
"at": "A las {{time}}",
|
||||
"every": "Cada {{duration}}",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"withTz": "{{summary}} · {{tz}}",
|
||||
"dailyAt": "Diaria a las {{time}}",
|
||||
"weekdaysAt": "Días laborables a las {{time}}",
|
||||
"hourlyAt": "Cada hora en :{{minute}}",
|
||||
"hourlyWindow": "Cada hora {{start}}-{{end}} en :{{minute}}",
|
||||
"custom": "Programación personalizada"
|
||||
},
|
||||
"next": {
|
||||
"paused": "Pausada",
|
||||
"pending": "Ejecutándose ahora",
|
||||
"none": "Sin próxima ejecución"
|
||||
},
|
||||
"message": {
|
||||
"showMore": "Mostrar mensaje completo",
|
||||
"showLess": "Mostrar menos"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Nombre",
|
||||
"message": "Mensaje",
|
||||
"scheduleType": "Tipo de programación",
|
||||
"every": "Cada",
|
||||
"unit": "Unidad",
|
||||
"cronExpression": "Expresión cron",
|
||||
"timezone": "Zona horaria",
|
||||
"runAt": "Ejecutar a las"
|
||||
},
|
||||
"scheduleTypes": {
|
||||
"every": "Intervalo",
|
||||
"cron": "Cron",
|
||||
"at": "Una vez"
|
||||
},
|
||||
"everyUnits": {
|
||||
"second": "Segundos",
|
||||
"minute": "Minutos",
|
||||
"hour": "Horas",
|
||||
"day": "Días"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "El nombre es obligatorio.",
|
||||
"messageRequired": "El mensaje es obligatorio.",
|
||||
"intervalRequired": "El intervalo debe ser un número positivo.",
|
||||
"cronRequired": "La expresión cron es obligatoria.",
|
||||
"timeRequired": "La hora de ejecución es obligatoria.",
|
||||
"futureRequired": "La hora de ejecución debe estar en el futuro."
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"authentication": "Autenticación OAuth",
|
||||
"signIn": "Iniciar sesión",
|
||||
@@ -527,7 +650,8 @@
|
||||
"newInProject": "Iniciar un chat nuevo en {{project}}",
|
||||
"activity": {
|
||||
"running": "Agent running",
|
||||
"complete": "Agent finished"
|
||||
"complete": "Agent finished",
|
||||
"updated": "New activity"
|
||||
},
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
@@ -562,7 +686,7 @@
|
||||
"confirm": "Eliminar",
|
||||
"automationsDescription": "Este chat tiene automatizaciones programadas. Al eliminarlo también se eliminarán.",
|
||||
"moreAutomations": "+ {{count}} más",
|
||||
"confirmWithAutomations": "Eliminar chat y automatizaciones",
|
||||
"confirmWithAutomations": "Eliminar",
|
||||
"schedule": {
|
||||
"at": "{{time}}",
|
||||
"every": "Cada {{duration}}",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"ariaLabel": "Changer de langue"
|
||||
},
|
||||
"apps": "Apps",
|
||||
"automations": "Automatisations",
|
||||
"skills": {
|
||||
"title": "Compétences"
|
||||
}
|
||||
@@ -80,6 +81,7 @@
|
||||
"cliApps": "Apps CLI",
|
||||
"mcp": "MCP",
|
||||
"apps": "Applications",
|
||||
"automations": "Automatisations",
|
||||
"skills": "Compétences"
|
||||
},
|
||||
"sections": {
|
||||
@@ -469,6 +471,127 @@
|
||||
"loading": "Chargement des apps...",
|
||||
"empty": "Aucune app ne correspond."
|
||||
},
|
||||
"automations": {
|
||||
"filters": {
|
||||
"all": "Toutes",
|
||||
"active": "Actives",
|
||||
"paused": "En pause",
|
||||
"failed": "À traiter",
|
||||
"system": "Système"
|
||||
},
|
||||
"sort": {
|
||||
"next": "Prochaine exécution",
|
||||
"last": "Dernière exécution",
|
||||
"updated": "Mise à jour",
|
||||
"name": "Nom"
|
||||
},
|
||||
"search": "Rechercher tâche, message, discussion liée ou planning",
|
||||
"queue": "File",
|
||||
"loading": "Chargement des automatisations...",
|
||||
"noMatches": "Aucune automatisation ne correspond à cette vue.",
|
||||
"empty": "Aucune automatisation pour le moment.",
|
||||
"emptyHint": "Créez-la depuis son point d'exécution pour que nanobot conserve le bon contexte.",
|
||||
"oneShot": "Ponctuelle",
|
||||
"systemTask": "Automatisation gérée par le système",
|
||||
"labels": {
|
||||
"schedule": "Planning",
|
||||
"next": "Prochaine",
|
||||
"origin": "Discussion liée",
|
||||
"created": "Créée",
|
||||
"updated": "Modifiée"
|
||||
},
|
||||
"runNow": "Exécuter maintenant",
|
||||
"pause": "Mettre en pause",
|
||||
"resume": "Reprendre",
|
||||
"edit": "Modifier",
|
||||
"delete": "Supprimer",
|
||||
"protected": "Protégée",
|
||||
"editTitle": "Modifier l’automatisation",
|
||||
"save": "Enregistrer",
|
||||
"deleteTitle": "Supprimer l’automatisation",
|
||||
"deleteDescription": "Cela supprime {{name}} du stockage cron. Les anciens messages de chat restent dans la session.",
|
||||
"cancel": "Annuler",
|
||||
"status": {
|
||||
"system": "Système",
|
||||
"running": "En cours d’exécution",
|
||||
"paused": "En pause",
|
||||
"failed": "Échouée",
|
||||
"completed": "Terminée",
|
||||
"noSchedule": "Aucun planning",
|
||||
"active": "En cours"
|
||||
},
|
||||
"origin": {
|
||||
"system": "Système",
|
||||
"unknown": "Aucune discussion liée"
|
||||
},
|
||||
"channels": {
|
||||
"api": "API",
|
||||
"cli": "CLI",
|
||||
"dingtalk": "DingTalk",
|
||||
"discord": "Discord",
|
||||
"email": "Email",
|
||||
"feishu": "Feishu",
|
||||
"matrix": "Matrix",
|
||||
"msteams": "Microsoft Teams",
|
||||
"qq": "QQ",
|
||||
"slack": "Slack",
|
||||
"telegram": "Telegram",
|
||||
"wechat": "WeChat",
|
||||
"wecom": "WeCom",
|
||||
"weixin": "WeChat",
|
||||
"whatsapp": "WhatsApp"
|
||||
},
|
||||
"schedule": {
|
||||
"at": "À {{time}}",
|
||||
"every": "Toutes les {{duration}}",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"withTz": "{{summary}} · {{tz}}",
|
||||
"dailyAt": "Chaque jour à {{time}}",
|
||||
"weekdaysAt": "Jours ouvrés à {{time}}",
|
||||
"hourlyAt": "Toutes les heures à :{{minute}}",
|
||||
"hourlyWindow": "Toutes les heures {{start}}-{{end}} à :{{minute}}",
|
||||
"custom": "Planning personnalisé"
|
||||
},
|
||||
"next": {
|
||||
"paused": "En pause",
|
||||
"pending": "En cours d’exécution",
|
||||
"none": "Aucune prochaine exécution"
|
||||
},
|
||||
"message": {
|
||||
"showMore": "Afficher le message complet",
|
||||
"showLess": "Afficher moins"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Nom",
|
||||
"message": "Message",
|
||||
"scheduleType": "Type de planning",
|
||||
"every": "Toutes les",
|
||||
"unit": "Unité",
|
||||
"cronExpression": "Expression cron",
|
||||
"timezone": "Fuseau horaire",
|
||||
"runAt": "Exécuter à"
|
||||
},
|
||||
"scheduleTypes": {
|
||||
"every": "Intervalle",
|
||||
"cron": "Cron",
|
||||
"at": "Une fois"
|
||||
},
|
||||
"everyUnits": {
|
||||
"second": "Secondes",
|
||||
"minute": "Minutes",
|
||||
"hour": "Heures",
|
||||
"day": "Jours"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Le nom est obligatoire.",
|
||||
"messageRequired": "Le message est obligatoire.",
|
||||
"intervalRequired": "L’intervalle doit être un nombre positif.",
|
||||
"cronRequired": "L’expression cron est obligatoire.",
|
||||
"timeRequired": "L’heure d’exécution est obligatoire.",
|
||||
"futureRequired": "L’heure d’exécution doit être dans le futur."
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"authentication": "Authentification OAuth",
|
||||
"signIn": "Se connecter",
|
||||
@@ -527,7 +650,8 @@
|
||||
"newInProject": "Démarrer une nouvelle discussion dans {{project}}",
|
||||
"activity": {
|
||||
"running": "Agent running",
|
||||
"complete": "Agent finished"
|
||||
"complete": "Agent finished",
|
||||
"updated": "New activity"
|
||||
},
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
@@ -562,7 +686,7 @@
|
||||
"confirm": "Supprimer",
|
||||
"automationsDescription": "Cette discussion contient des automatisations planifiées. La supprimer les supprimera aussi.",
|
||||
"moreAutomations": "+ {{count}} autres",
|
||||
"confirmWithAutomations": "Supprimer la discussion et les automatisations",
|
||||
"confirmWithAutomations": "Supprimer",
|
||||
"schedule": {
|
||||
"at": "{{time}}",
|
||||
"every": "Tous les {{duration}}",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"ariaLabel": "Ganti bahasa"
|
||||
},
|
||||
"apps": "Aplikasi",
|
||||
"automations": "Otomasi",
|
||||
"skills": {
|
||||
"title": "Skill"
|
||||
}
|
||||
@@ -80,6 +81,7 @@
|
||||
"cliApps": "Aplikasi CLI",
|
||||
"mcp": "MCP",
|
||||
"apps": "Aplikasi",
|
||||
"automations": "Otomasi",
|
||||
"skills": "Skill"
|
||||
},
|
||||
"sections": {
|
||||
@@ -469,6 +471,127 @@
|
||||
"loading": "Memuat aplikasi...",
|
||||
"empty": "Tidak ada aplikasi yang cocok."
|
||||
},
|
||||
"automations": {
|
||||
"filters": {
|
||||
"all": "Semua",
|
||||
"active": "Aktif",
|
||||
"paused": "Dijeda",
|
||||
"failed": "Perlu ditangani",
|
||||
"system": "Sistem"
|
||||
},
|
||||
"sort": {
|
||||
"next": "Jalankan berikutnya",
|
||||
"last": "Jalankan terakhir",
|
||||
"updated": "Diperbarui",
|
||||
"name": "Nama"
|
||||
},
|
||||
"search": "Cari tugas, pesan, chat terkait, atau jadwal",
|
||||
"queue": "Antrean",
|
||||
"loading": "Memuat otomasi...",
|
||||
"noMatches": "Tidak ada otomasi yang cocok dengan tampilan ini.",
|
||||
"empty": "Belum ada otomasi.",
|
||||
"emptyHint": "Buat dari tempat tugas ini berjalan agar nanobot menyimpan konteks yang tepat.",
|
||||
"oneShot": "Satu kali",
|
||||
"systemTask": "Automasi yang dikelola sistem",
|
||||
"labels": {
|
||||
"schedule": "Jadwal",
|
||||
"next": "Berikutnya",
|
||||
"origin": "Chat tertaut",
|
||||
"created": "Dibuat",
|
||||
"updated": "Diperbarui"
|
||||
},
|
||||
"runNow": "Jalankan sekarang",
|
||||
"pause": "Jeda",
|
||||
"resume": "Lanjutkan",
|
||||
"edit": "Edit",
|
||||
"delete": "Hapus",
|
||||
"protected": "Terlindungi",
|
||||
"editTitle": "Edit otomasi",
|
||||
"save": "Simpan",
|
||||
"deleteTitle": "Hapus otomasi",
|
||||
"deleteDescription": "Ini menghapus {{name}} dari penyimpanan cron. Pesan chat sebelumnya tetap ada di sesi.",
|
||||
"cancel": "Batal",
|
||||
"status": {
|
||||
"system": "Sistem",
|
||||
"running": "Sedang berjalan",
|
||||
"paused": "Dijeda",
|
||||
"failed": "Gagal",
|
||||
"completed": "Selesai",
|
||||
"noSchedule": "Tanpa jadwal",
|
||||
"active": "Aktif"
|
||||
},
|
||||
"origin": {
|
||||
"system": "Sistem",
|
||||
"unknown": "Tidak ada chat tertaut"
|
||||
},
|
||||
"channels": {
|
||||
"api": "API",
|
||||
"cli": "CLI",
|
||||
"dingtalk": "DingTalk",
|
||||
"discord": "Discord",
|
||||
"email": "Email",
|
||||
"feishu": "Feishu",
|
||||
"matrix": "Matrix",
|
||||
"msteams": "Microsoft Teams",
|
||||
"qq": "QQ",
|
||||
"slack": "Slack",
|
||||
"telegram": "Telegram",
|
||||
"wechat": "WeChat",
|
||||
"wecom": "WeCom",
|
||||
"weixin": "WeChat",
|
||||
"whatsapp": "WhatsApp"
|
||||
},
|
||||
"schedule": {
|
||||
"at": "Pada {{time}}",
|
||||
"every": "Setiap {{duration}}",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"withTz": "{{summary}} · {{tz}}",
|
||||
"dailyAt": "Setiap hari pukul {{time}}",
|
||||
"weekdaysAt": "Hari kerja pukul {{time}}",
|
||||
"hourlyAt": "Setiap jam pada :{{minute}}",
|
||||
"hourlyWindow": "Setiap jam {{start}}-{{end}} pada :{{minute}}",
|
||||
"custom": "Jadwal khusus"
|
||||
},
|
||||
"next": {
|
||||
"paused": "Dijeda",
|
||||
"pending": "Sedang berjalan",
|
||||
"none": "Tidak ada jadwal berikutnya"
|
||||
},
|
||||
"message": {
|
||||
"showMore": "Tampilkan pesan lengkap",
|
||||
"showLess": "Tampilkan lebih sedikit"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Nama",
|
||||
"message": "Pesan",
|
||||
"scheduleType": "Jenis jadwal",
|
||||
"every": "Setiap",
|
||||
"unit": "Unit",
|
||||
"cronExpression": "Ekspresi cron",
|
||||
"timezone": "Zona waktu",
|
||||
"runAt": "Jalankan pada"
|
||||
},
|
||||
"scheduleTypes": {
|
||||
"every": "Interval",
|
||||
"cron": "Cron",
|
||||
"at": "Sekali"
|
||||
},
|
||||
"everyUnits": {
|
||||
"second": "Detik",
|
||||
"minute": "Menit",
|
||||
"hour": "Jam",
|
||||
"day": "Hari"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Nama wajib diisi.",
|
||||
"messageRequired": "Pesan wajib diisi.",
|
||||
"intervalRequired": "Interval harus berupa angka positif.",
|
||||
"cronRequired": "Ekspresi cron wajib diisi.",
|
||||
"timeRequired": "Waktu eksekusi wajib diisi.",
|
||||
"futureRequired": "Waktu eksekusi harus berada di masa depan."
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"authentication": "Autentikasi OAuth",
|
||||
"signIn": "Masuk",
|
||||
@@ -527,7 +650,8 @@
|
||||
"newInProject": "Mulai obrolan baru di {{project}}",
|
||||
"activity": {
|
||||
"running": "Agent running",
|
||||
"complete": "Agent finished"
|
||||
"complete": "Agent finished",
|
||||
"updated": "New activity"
|
||||
},
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
@@ -562,7 +686,7 @@
|
||||
"confirm": "Hapus",
|
||||
"automationsDescription": "Obrolan ini memiliki automasi terjadwal. Menghapusnya juga akan menghapus automasi tersebut.",
|
||||
"moreAutomations": "+ {{count}} lagi",
|
||||
"confirmWithAutomations": "Hapus obrolan dan automasi",
|
||||
"confirmWithAutomations": "Hapus",
|
||||
"schedule": {
|
||||
"at": "{{time}}",
|
||||
"every": "Setiap {{duration}}",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"ariaLabel": "言語を変更"
|
||||
},
|
||||
"apps": "アプリ",
|
||||
"automations": "自動タスク",
|
||||
"skills": {
|
||||
"title": "スキル"
|
||||
}
|
||||
@@ -80,6 +81,7 @@
|
||||
"cliApps": "CLI アプリ",
|
||||
"mcp": "MCP",
|
||||
"apps": "アプリ",
|
||||
"automations": "自動タスク",
|
||||
"skills": "スキル"
|
||||
},
|
||||
"sections": {
|
||||
@@ -469,6 +471,127 @@
|
||||
"loading": "アプリを読み込み中...",
|
||||
"empty": "一致するアプリはありません。"
|
||||
},
|
||||
"automations": {
|
||||
"filters": {
|
||||
"all": "すべて",
|
||||
"active": "実行中",
|
||||
"paused": "一時停止",
|
||||
"failed": "要対応",
|
||||
"system": "システム"
|
||||
},
|
||||
"sort": {
|
||||
"next": "次回実行",
|
||||
"last": "前回実行",
|
||||
"updated": "更新日時",
|
||||
"name": "名前"
|
||||
},
|
||||
"search": "タスク、メッセージ、関連チャット、予定を検索",
|
||||
"queue": "キュー",
|
||||
"loading": "自動タスクを読み込み中...",
|
||||
"noMatches": "この表示に一致する自動タスクはありません。",
|
||||
"empty": "自動タスクはまだありません。",
|
||||
"emptyHint": "実行元から作成すると、nanobot が正しいコンテキストを保持できます。",
|
||||
"oneShot": "一回限り",
|
||||
"systemTask": "システム管理の自動タスク",
|
||||
"labels": {
|
||||
"schedule": "スケジュール",
|
||||
"next": "次回",
|
||||
"origin": "関連チャット",
|
||||
"created": "作成",
|
||||
"updated": "更新"
|
||||
},
|
||||
"runNow": "今すぐ実行",
|
||||
"pause": "一時停止",
|
||||
"resume": "再開",
|
||||
"edit": "編集",
|
||||
"delete": "削除",
|
||||
"protected": "保護済み",
|
||||
"editTitle": "自動タスクを編集",
|
||||
"save": "保存",
|
||||
"deleteTitle": "自動タスクを削除",
|
||||
"deleteDescription": "{{name}} を cron ストアから削除します。過去のチャットメッセージはセッションに残ります。",
|
||||
"cancel": "キャンセル",
|
||||
"status": {
|
||||
"system": "システム",
|
||||
"running": "実行中",
|
||||
"paused": "一時停止",
|
||||
"failed": "失敗",
|
||||
"completed": "完了",
|
||||
"noSchedule": "スケジュールなし",
|
||||
"active": "実行中"
|
||||
},
|
||||
"origin": {
|
||||
"system": "システム",
|
||||
"unknown": "関連チャットなし"
|
||||
},
|
||||
"channels": {
|
||||
"api": "API",
|
||||
"cli": "CLI",
|
||||
"dingtalk": "DingTalk",
|
||||
"discord": "Discord",
|
||||
"email": "Email",
|
||||
"feishu": "Feishu",
|
||||
"matrix": "Matrix",
|
||||
"msteams": "Microsoft Teams",
|
||||
"qq": "QQ",
|
||||
"slack": "Slack",
|
||||
"telegram": "Telegram",
|
||||
"wechat": "WeChat",
|
||||
"wecom": "WeCom",
|
||||
"weixin": "WeChat",
|
||||
"whatsapp": "WhatsApp"
|
||||
},
|
||||
"schedule": {
|
||||
"at": "{{time}}",
|
||||
"every": "{{duration}} ごと",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"withTz": "{{summary}} · {{tz}}",
|
||||
"dailyAt": "毎日 {{time}}",
|
||||
"weekdaysAt": "平日 {{time}}",
|
||||
"hourlyAt": "毎時 :{{minute}}",
|
||||
"hourlyWindow": "{{start}}-{{end}} の毎時 :{{minute}}",
|
||||
"custom": "カスタムスケジュール"
|
||||
},
|
||||
"next": {
|
||||
"paused": "一時停止",
|
||||
"pending": "実行中",
|
||||
"none": "次回実行なし"
|
||||
},
|
||||
"message": {
|
||||
"showMore": "メッセージ全文を表示",
|
||||
"showLess": "折りたたむ"
|
||||
},
|
||||
"fields": {
|
||||
"name": "名前",
|
||||
"message": "メッセージ",
|
||||
"scheduleType": "スケジュール種別",
|
||||
"every": "間隔",
|
||||
"unit": "単位",
|
||||
"cronExpression": "Cron 式",
|
||||
"timezone": "タイムゾーン",
|
||||
"runAt": "実行日時"
|
||||
},
|
||||
"scheduleTypes": {
|
||||
"every": "間隔",
|
||||
"cron": "Cron",
|
||||
"at": "一回限り"
|
||||
},
|
||||
"everyUnits": {
|
||||
"second": "秒",
|
||||
"minute": "分",
|
||||
"hour": "時間",
|
||||
"day": "日"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "名前は必須です。",
|
||||
"messageRequired": "メッセージは必須です。",
|
||||
"intervalRequired": "間隔は正の数で指定してください。",
|
||||
"cronRequired": "Cron 式は必須です。",
|
||||
"timeRequired": "実行日時は必須です。",
|
||||
"futureRequired": "実行日時は現在より後にしてください。"
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"authentication": "OAuth 認証",
|
||||
"signIn": "サインイン",
|
||||
@@ -527,7 +650,8 @@
|
||||
"newInProject": "「{{project}}」で新しいチャットを開始",
|
||||
"activity": {
|
||||
"running": "Agent running",
|
||||
"complete": "Agent finished"
|
||||
"complete": "Agent finished",
|
||||
"updated": "New activity"
|
||||
},
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
@@ -562,7 +686,7 @@
|
||||
"confirm": "削除",
|
||||
"automationsDescription": "このチャットにはスケジュール済みの自動タスクがあります。削除するとそれらも削除されます。",
|
||||
"moreAutomations": "他 {{count}} 件",
|
||||
"confirmWithAutomations": "チャットと自動タスクを削除",
|
||||
"confirmWithAutomations": "削除",
|
||||
"schedule": {
|
||||
"at": "{{time}}",
|
||||
"every": "{{duration}} ごと",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"ariaLabel": "언어 변경"
|
||||
},
|
||||
"apps": "앱",
|
||||
"automations": "자동화",
|
||||
"skills": {
|
||||
"title": "스킬"
|
||||
}
|
||||
@@ -80,6 +81,7 @@
|
||||
"cliApps": "CLI 앱",
|
||||
"mcp": "MCP",
|
||||
"apps": "앱",
|
||||
"automations": "자동화",
|
||||
"skills": "스킬"
|
||||
},
|
||||
"sections": {
|
||||
@@ -469,6 +471,127 @@
|
||||
"loading": "앱을 불러오는 중...",
|
||||
"empty": "일치하는 앱이 없습니다."
|
||||
},
|
||||
"automations": {
|
||||
"filters": {
|
||||
"all": "전체",
|
||||
"active": "활성",
|
||||
"paused": "일시 중지",
|
||||
"failed": "확인 필요",
|
||||
"system": "시스템"
|
||||
},
|
||||
"sort": {
|
||||
"next": "다음 실행",
|
||||
"last": "마지막 실행",
|
||||
"updated": "업데이트",
|
||||
"name": "이름"
|
||||
},
|
||||
"search": "작업, 메시지, 연결된 채팅 또는 일정 검색",
|
||||
"queue": "대기열",
|
||||
"loading": "자동화를 불러오는 중...",
|
||||
"noMatches": "이 보기와 일치하는 자동화가 없습니다.",
|
||||
"empty": "아직 자동화가 없습니다.",
|
||||
"emptyHint": "실행될 위치에서 만들면 nanobot이 올바른 컨텍스트를 유지합니다.",
|
||||
"oneShot": "일회성",
|
||||
"systemTask": "시스템 관리 자동화",
|
||||
"labels": {
|
||||
"schedule": "일정",
|
||||
"next": "다음",
|
||||
"origin": "연결된 채팅",
|
||||
"created": "생성",
|
||||
"updated": "업데이트"
|
||||
},
|
||||
"runNow": "지금 실행",
|
||||
"pause": "일시 중지",
|
||||
"resume": "재개",
|
||||
"edit": "편집",
|
||||
"delete": "삭제",
|
||||
"protected": "보호됨",
|
||||
"editTitle": "자동화 편집",
|
||||
"save": "저장",
|
||||
"deleteTitle": "자동화 삭제",
|
||||
"deleteDescription": "{{name}}을 cron 저장소에서 삭제합니다. 이전 채팅 메시지는 세션에 남습니다.",
|
||||
"cancel": "취소",
|
||||
"status": {
|
||||
"system": "시스템",
|
||||
"running": "실행 중",
|
||||
"paused": "일시 중지",
|
||||
"failed": "실패",
|
||||
"completed": "완료",
|
||||
"noSchedule": "일정 없음",
|
||||
"active": "활성"
|
||||
},
|
||||
"origin": {
|
||||
"system": "시스템",
|
||||
"unknown": "연결된 채팅 없음"
|
||||
},
|
||||
"channels": {
|
||||
"api": "API",
|
||||
"cli": "CLI",
|
||||
"dingtalk": "DingTalk",
|
||||
"discord": "Discord",
|
||||
"email": "Email",
|
||||
"feishu": "Feishu",
|
||||
"matrix": "Matrix",
|
||||
"msteams": "Microsoft Teams",
|
||||
"qq": "QQ",
|
||||
"slack": "Slack",
|
||||
"telegram": "Telegram",
|
||||
"wechat": "WeChat",
|
||||
"wecom": "WeCom",
|
||||
"weixin": "WeChat",
|
||||
"whatsapp": "WhatsApp"
|
||||
},
|
||||
"schedule": {
|
||||
"at": "{{time}}",
|
||||
"every": "{{duration}}마다",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"withTz": "{{summary}} · {{tz}}",
|
||||
"dailyAt": "매일 {{time}}",
|
||||
"weekdaysAt": "평일 {{time}}",
|
||||
"hourlyAt": "매시간 :{{minute}}",
|
||||
"hourlyWindow": "{{start}}-{{end}} 사이 매시간 :{{minute}}",
|
||||
"custom": "사용자 지정 일정"
|
||||
},
|
||||
"next": {
|
||||
"paused": "일시 중지",
|
||||
"pending": "실행 중",
|
||||
"none": "다음 실행 없음"
|
||||
},
|
||||
"message": {
|
||||
"showMore": "전체 메시지 보기",
|
||||
"showLess": "접기"
|
||||
},
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"message": "메시지",
|
||||
"scheduleType": "일정 유형",
|
||||
"every": "간격",
|
||||
"unit": "단위",
|
||||
"cronExpression": "Cron 식",
|
||||
"timezone": "시간대",
|
||||
"runAt": "실행 시간"
|
||||
},
|
||||
"scheduleTypes": {
|
||||
"every": "간격",
|
||||
"cron": "Cron",
|
||||
"at": "일회성"
|
||||
},
|
||||
"everyUnits": {
|
||||
"second": "초",
|
||||
"minute": "분",
|
||||
"hour": "시간",
|
||||
"day": "일"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "이름은 필수입니다.",
|
||||
"messageRequired": "메시지는 필수입니다.",
|
||||
"intervalRequired": "간격은 양수여야 합니다.",
|
||||
"cronRequired": "Cron 식은 필수입니다.",
|
||||
"timeRequired": "실행 시간은 필수입니다.",
|
||||
"futureRequired": "실행 시간은 현재보다 이후여야 합니다."
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"authentication": "OAuth 인증",
|
||||
"signIn": "로그인",
|
||||
@@ -527,7 +650,8 @@
|
||||
"newInProject": "{{project}}에서 새 채팅 시작",
|
||||
"activity": {
|
||||
"running": "Agent running",
|
||||
"complete": "Agent finished"
|
||||
"complete": "Agent finished",
|
||||
"updated": "New activity"
|
||||
},
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
@@ -562,7 +686,7 @@
|
||||
"confirm": "삭제",
|
||||
"automationsDescription": "이 채팅에는 예약된 자동화가 있습니다. 채팅을 삭제하면 자동화도 함께 삭제됩니다.",
|
||||
"moreAutomations": "+ {{count}}개 더",
|
||||
"confirmWithAutomations": "채팅과 자동화 삭제",
|
||||
"confirmWithAutomations": "삭제",
|
||||
"schedule": {
|
||||
"at": "{{time}}",
|
||||
"every": "{{duration}}마다",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"ariaLabel": "Đổi ngôn ngữ"
|
||||
},
|
||||
"apps": "Ứng dụng",
|
||||
"automations": "Tự động hóa",
|
||||
"skills": {
|
||||
"title": "Kỹ năng"
|
||||
}
|
||||
@@ -80,6 +81,7 @@
|
||||
"cliApps": "Ứng dụng CLI",
|
||||
"mcp": "MCP",
|
||||
"apps": "Ứng dụng",
|
||||
"automations": "Tự động hóa",
|
||||
"skills": "Kỹ năng"
|
||||
},
|
||||
"sections": {
|
||||
@@ -469,6 +471,127 @@
|
||||
"loading": "Đang tải ứng dụng...",
|
||||
"empty": "Không có ứng dụng phù hợp."
|
||||
},
|
||||
"automations": {
|
||||
"filters": {
|
||||
"all": "Tất cả",
|
||||
"active": "Đang chạy",
|
||||
"paused": "Đã tạm dừng",
|
||||
"failed": "Cần xử lý",
|
||||
"system": "Hệ thống"
|
||||
},
|
||||
"sort": {
|
||||
"next": "Lần chạy tiếp theo",
|
||||
"last": "Lần chạy trước",
|
||||
"updated": "Đã cập nhật",
|
||||
"name": "Tên"
|
||||
},
|
||||
"search": "Tìm tác vụ, tin nhắn, cuộc trò chuyện liên kết hoặc lịch",
|
||||
"queue": "Hàng đợi",
|
||||
"loading": "Đang tải tự động hóa...",
|
||||
"noMatches": "Không có tự động hóa phù hợp với chế độ xem này.",
|
||||
"empty": "Chưa có tự động hóa.",
|
||||
"emptyHint": "Tạo từ nơi tác vụ sẽ chạy để nanobot giữ đúng ngữ cảnh.",
|
||||
"oneShot": "Một lần",
|
||||
"systemTask": "Tự động hóa do hệ thống quản lý",
|
||||
"labels": {
|
||||
"schedule": "Lịch",
|
||||
"next": "Tiếp theo",
|
||||
"origin": "Cuộc trò chuyện liên kết",
|
||||
"created": "Đã tạo",
|
||||
"updated": "Đã cập nhật"
|
||||
},
|
||||
"runNow": "Chạy ngay",
|
||||
"pause": "Tạm dừng",
|
||||
"resume": "Tiếp tục",
|
||||
"edit": "Sửa",
|
||||
"delete": "Xóa",
|
||||
"protected": "Được bảo vệ",
|
||||
"editTitle": "Sửa tự động hóa",
|
||||
"save": "Lưu",
|
||||
"deleteTitle": "Xóa tự động hóa",
|
||||
"deleteDescription": "Thao tác này xóa {{name}} khỏi kho cron. Tin nhắn chat trước đó vẫn ở trong phiên.",
|
||||
"cancel": "Hủy",
|
||||
"status": {
|
||||
"system": "Hệ thống",
|
||||
"running": "Đang chạy",
|
||||
"paused": "Đã tạm dừng",
|
||||
"failed": "Thất bại",
|
||||
"completed": "Hoàn tất",
|
||||
"noSchedule": "Không có lịch",
|
||||
"active": "Đang chạy"
|
||||
},
|
||||
"origin": {
|
||||
"system": "Hệ thống",
|
||||
"unknown": "Chưa liên kết cuộc trò chuyện"
|
||||
},
|
||||
"channels": {
|
||||
"api": "API",
|
||||
"cli": "CLI",
|
||||
"dingtalk": "DingTalk",
|
||||
"discord": "Discord",
|
||||
"email": "Email",
|
||||
"feishu": "Feishu",
|
||||
"matrix": "Matrix",
|
||||
"msteams": "Microsoft Teams",
|
||||
"qq": "QQ",
|
||||
"slack": "Slack",
|
||||
"telegram": "Telegram",
|
||||
"wechat": "WeChat",
|
||||
"wecom": "WeCom",
|
||||
"weixin": "WeChat",
|
||||
"whatsapp": "WhatsApp"
|
||||
},
|
||||
"schedule": {
|
||||
"at": "Vào {{time}}",
|
||||
"every": "Mỗi {{duration}}",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"withTz": "{{summary}} · {{tz}}",
|
||||
"dailyAt": "Hằng ngày lúc {{time}}",
|
||||
"weekdaysAt": "Ngày làm việc lúc {{time}}",
|
||||
"hourlyAt": "Mỗi giờ tại :{{minute}}",
|
||||
"hourlyWindow": "Mỗi giờ {{start}}-{{end}} tại :{{minute}}",
|
||||
"custom": "Lịch tùy chỉnh"
|
||||
},
|
||||
"next": {
|
||||
"paused": "Đã tạm dừng",
|
||||
"pending": "Đang chạy",
|
||||
"none": "Không có lần chạy tiếp theo"
|
||||
},
|
||||
"message": {
|
||||
"showMore": "Hiển thị toàn bộ tin nhắn",
|
||||
"showLess": "Thu gọn"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Tên",
|
||||
"message": "Tin nhắn",
|
||||
"scheduleType": "Loại lịch",
|
||||
"every": "Mỗi",
|
||||
"unit": "Đơn vị",
|
||||
"cronExpression": "Biểu thức cron",
|
||||
"timezone": "Múi giờ",
|
||||
"runAt": "Chạy lúc"
|
||||
},
|
||||
"scheduleTypes": {
|
||||
"every": "Khoảng lặp",
|
||||
"cron": "Cron",
|
||||
"at": "Một lần"
|
||||
},
|
||||
"everyUnits": {
|
||||
"second": "Giây",
|
||||
"minute": "Phút",
|
||||
"hour": "Giờ",
|
||||
"day": "Ngày"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Tên là bắt buộc.",
|
||||
"messageRequired": "Tin nhắn là bắt buộc.",
|
||||
"intervalRequired": "Khoảng lặp phải là số dương.",
|
||||
"cronRequired": "Biểu thức cron là bắt buộc.",
|
||||
"timeRequired": "Thời gian chạy là bắt buộc.",
|
||||
"futureRequired": "Thời gian chạy phải ở tương lai."
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"authentication": "Xác thực OAuth",
|
||||
"signIn": "Đăng nhập",
|
||||
@@ -527,7 +650,8 @@
|
||||
"newInProject": "Bắt đầu cuộc trò chuyện mới trong {{project}}",
|
||||
"activity": {
|
||||
"running": "Agent running",
|
||||
"complete": "Agent finished"
|
||||
"complete": "Agent finished",
|
||||
"updated": "New activity"
|
||||
},
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
@@ -562,7 +686,7 @@
|
||||
"confirm": "Xóa",
|
||||
"automationsDescription": "Cuộc trò chuyện này có các tự động hóa đã lên lịch. Xóa cuộc trò chuyện cũng sẽ xóa chúng.",
|
||||
"moreAutomations": "+ {{count}} mục nữa",
|
||||
"confirmWithAutomations": "Xóa trò chuyện và tự động hóa",
|
||||
"confirmWithAutomations": "Xóa",
|
||||
"schedule": {
|
||||
"at": "{{time}}",
|
||||
"every": "Mỗi {{duration}}",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"ariaLabel": "切换语言"
|
||||
},
|
||||
"apps": "应用",
|
||||
"automations": "自动任务",
|
||||
"skills": {
|
||||
"title": "技能"
|
||||
}
|
||||
@@ -80,6 +81,7 @@
|
||||
"runtime": "系统",
|
||||
"advanced": "安全",
|
||||
"apps": "应用",
|
||||
"automations": "自动任务",
|
||||
"skills": "技能"
|
||||
},
|
||||
"sections": {
|
||||
@@ -469,6 +471,127 @@
|
||||
"loading": "正在加载应用...",
|
||||
"empty": "没有匹配的应用。"
|
||||
},
|
||||
"automations": {
|
||||
"filters": {
|
||||
"all": "全部",
|
||||
"active": "运行中",
|
||||
"paused": "已暂停",
|
||||
"failed": "异常",
|
||||
"system": "系统"
|
||||
},
|
||||
"sort": {
|
||||
"next": "下次运行",
|
||||
"last": "上次运行",
|
||||
"updated": "更新时间",
|
||||
"name": "名称"
|
||||
},
|
||||
"search": "搜索任务、消息、关联会话或计划",
|
||||
"queue": "任务队列",
|
||||
"loading": "正在加载自动任务...",
|
||||
"noMatches": "当前视图没有匹配的自动任务。",
|
||||
"empty": "暂无自动任务。",
|
||||
"emptyHint": "请从它应该运行的来源处创建,这样 nanobot 才能保留正确上下文。",
|
||||
"oneShot": "一次性",
|
||||
"systemTask": "系统管理的自动任务",
|
||||
"labels": {
|
||||
"schedule": "计划",
|
||||
"next": "下次",
|
||||
"origin": "关联会话",
|
||||
"created": "创建于",
|
||||
"updated": "更新于"
|
||||
},
|
||||
"runNow": "立即运行",
|
||||
"pause": "暂停",
|
||||
"resume": "恢复",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"protected": "受保护",
|
||||
"editTitle": "编辑自动任务",
|
||||
"save": "保存",
|
||||
"deleteTitle": "删除自动任务",
|
||||
"deleteDescription": "这会从 cron 存储中删除 {{name}},历史聊天消息会保留在会话中。",
|
||||
"cancel": "取消",
|
||||
"status": {
|
||||
"system": "系统",
|
||||
"running": "正在运行",
|
||||
"paused": "已暂停",
|
||||
"failed": "失败",
|
||||
"completed": "已完成",
|
||||
"noSchedule": "无计划",
|
||||
"active": "运行中"
|
||||
},
|
||||
"origin": {
|
||||
"system": "系统",
|
||||
"unknown": "未关联会话"
|
||||
},
|
||||
"channels": {
|
||||
"api": "API",
|
||||
"cli": "CLI",
|
||||
"dingtalk": "钉钉",
|
||||
"discord": "Discord",
|
||||
"email": "邮件",
|
||||
"feishu": "飞书",
|
||||
"matrix": "Matrix",
|
||||
"msteams": "Microsoft Teams",
|
||||
"qq": "QQ",
|
||||
"slack": "Slack",
|
||||
"telegram": "Telegram",
|
||||
"wechat": "微信",
|
||||
"wecom": "企业微信",
|
||||
"weixin": "微信",
|
||||
"whatsapp": "WhatsApp"
|
||||
},
|
||||
"schedule": {
|
||||
"at": "在 {{time}}",
|
||||
"every": "每 {{duration}}",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"withTz": "{{summary}} · {{tz}}",
|
||||
"dailyAt": "每天 {{time}}",
|
||||
"weekdaysAt": "工作日 {{time}}",
|
||||
"hourlyAt": "每小时第 {{minute}} 分钟",
|
||||
"hourlyWindow": "{{start}}-{{end}} 点每小时第 {{minute}} 分钟",
|
||||
"custom": "自定义计划"
|
||||
},
|
||||
"next": {
|
||||
"paused": "已暂停",
|
||||
"pending": "正在运行",
|
||||
"none": "没有下次运行"
|
||||
},
|
||||
"message": {
|
||||
"showMore": "查看完整消息",
|
||||
"showLess": "收起消息"
|
||||
},
|
||||
"fields": {
|
||||
"name": "名称",
|
||||
"message": "消息",
|
||||
"scheduleType": "计划类型",
|
||||
"every": "每隔",
|
||||
"unit": "单位",
|
||||
"cronExpression": "Cron 表达式",
|
||||
"timezone": "时区",
|
||||
"runAt": "运行时间"
|
||||
},
|
||||
"scheduleTypes": {
|
||||
"every": "间隔",
|
||||
"cron": "Cron",
|
||||
"at": "一次性"
|
||||
},
|
||||
"everyUnits": {
|
||||
"second": "秒",
|
||||
"minute": "分钟",
|
||||
"hour": "小时",
|
||||
"day": "天"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "名称不能为空。",
|
||||
"messageRequired": "消息不能为空。",
|
||||
"intervalRequired": "间隔必须是正整数。",
|
||||
"cronRequired": "Cron 表达式不能为空。",
|
||||
"timeRequired": "运行时间不能为空。",
|
||||
"futureRequired": "运行时间必须晚于当前时间。"
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"authentication": "OAuth 认证",
|
||||
"signIn": "登录",
|
||||
@@ -527,7 +650,8 @@
|
||||
"newInProject": "在 {{project}} 中开始新对话",
|
||||
"activity": {
|
||||
"running": "Agent 正在运行",
|
||||
"complete": "Agent 已完成"
|
||||
"complete": "Agent 已完成",
|
||||
"updated": "有新内容"
|
||||
},
|
||||
"pin": "置顶",
|
||||
"unpin": "取消置顶",
|
||||
@@ -562,7 +686,7 @@
|
||||
"confirm": "删除",
|
||||
"automationsDescription": "这个对话有关联的自动任务。删除对话也会删除这些自动任务。",
|
||||
"moreAutomations": "另有 {{count}} 个",
|
||||
"confirmWithAutomations": "删除对话和自动任务",
|
||||
"confirmWithAutomations": "删除",
|
||||
"schedule": {
|
||||
"at": "{{time}}",
|
||||
"every": "每 {{duration}}",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"ariaLabel": "切換語言"
|
||||
},
|
||||
"apps": "應用",
|
||||
"automations": "自動任務",
|
||||
"skills": {
|
||||
"title": "技能"
|
||||
}
|
||||
@@ -80,6 +81,7 @@
|
||||
"cliApps": "CLI 應用",
|
||||
"mcp": "MCP",
|
||||
"apps": "應用",
|
||||
"automations": "自動任務",
|
||||
"skills": "技能"
|
||||
},
|
||||
"sections": {
|
||||
@@ -469,6 +471,127 @@
|
||||
"loading": "正在載入應用...",
|
||||
"empty": "沒有符合的應用。"
|
||||
},
|
||||
"automations": {
|
||||
"filters": {
|
||||
"all": "全部",
|
||||
"active": "執行中",
|
||||
"paused": "已暫停",
|
||||
"failed": "異常",
|
||||
"system": "系統"
|
||||
},
|
||||
"sort": {
|
||||
"next": "下次執行",
|
||||
"last": "上次執行",
|
||||
"updated": "更新時間",
|
||||
"name": "名稱"
|
||||
},
|
||||
"search": "搜尋任務、訊息、關聯對話或排程",
|
||||
"queue": "任務佇列",
|
||||
"loading": "正在載入自動任務...",
|
||||
"noMatches": "目前檢視沒有符合的自動任務。",
|
||||
"empty": "尚無自動任務。",
|
||||
"emptyHint": "請從它應該執行的來源處建立,這樣 nanobot 才能保留正確上下文。",
|
||||
"oneShot": "一次性",
|
||||
"systemTask": "系統管理的自動任務",
|
||||
"labels": {
|
||||
"schedule": "排程",
|
||||
"next": "下次",
|
||||
"origin": "關聯會話",
|
||||
"created": "建立於",
|
||||
"updated": "更新於"
|
||||
},
|
||||
"runNow": "立即執行",
|
||||
"pause": "暫停",
|
||||
"resume": "恢復",
|
||||
"edit": "編輯",
|
||||
"delete": "刪除",
|
||||
"protected": "受保護",
|
||||
"editTitle": "編輯自動任務",
|
||||
"save": "儲存",
|
||||
"deleteTitle": "刪除自動任務",
|
||||
"deleteDescription": "這會從 cron 儲存中刪除 {{name}},歷史聊天訊息會保留在會話中。",
|
||||
"cancel": "取消",
|
||||
"status": {
|
||||
"system": "系統",
|
||||
"running": "正在執行",
|
||||
"paused": "已暫停",
|
||||
"failed": "失敗",
|
||||
"completed": "已完成",
|
||||
"noSchedule": "無排程",
|
||||
"active": "執行中"
|
||||
},
|
||||
"origin": {
|
||||
"system": "系統",
|
||||
"unknown": "未關聯會話"
|
||||
},
|
||||
"channels": {
|
||||
"api": "API",
|
||||
"cli": "CLI",
|
||||
"dingtalk": "釘釘",
|
||||
"discord": "Discord",
|
||||
"email": "電子郵件",
|
||||
"feishu": "飛書",
|
||||
"matrix": "Matrix",
|
||||
"msteams": "Microsoft Teams",
|
||||
"qq": "QQ",
|
||||
"slack": "Slack",
|
||||
"telegram": "Telegram",
|
||||
"wechat": "微信",
|
||||
"wecom": "企業微信",
|
||||
"weixin": "微信",
|
||||
"whatsapp": "WhatsApp"
|
||||
},
|
||||
"schedule": {
|
||||
"at": "於 {{time}}",
|
||||
"every": "每 {{duration}}",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"withTz": "{{summary}} · {{tz}}",
|
||||
"dailyAt": "每天 {{time}}",
|
||||
"weekdaysAt": "工作日 {{time}}",
|
||||
"hourlyAt": "每小時第 {{minute}} 分鐘",
|
||||
"hourlyWindow": "{{start}}-{{end}} 點每小時第 {{minute}} 分鐘",
|
||||
"custom": "自訂排程"
|
||||
},
|
||||
"next": {
|
||||
"paused": "已暫停",
|
||||
"pending": "正在執行",
|
||||
"none": "沒有下次執行"
|
||||
},
|
||||
"message": {
|
||||
"showMore": "查看完整訊息",
|
||||
"showLess": "收起訊息"
|
||||
},
|
||||
"fields": {
|
||||
"name": "名稱",
|
||||
"message": "訊息",
|
||||
"scheduleType": "排程類型",
|
||||
"every": "每隔",
|
||||
"unit": "單位",
|
||||
"cronExpression": "Cron 表達式",
|
||||
"timezone": "時區",
|
||||
"runAt": "執行時間"
|
||||
},
|
||||
"scheduleTypes": {
|
||||
"every": "間隔",
|
||||
"cron": "Cron",
|
||||
"at": "一次性"
|
||||
},
|
||||
"everyUnits": {
|
||||
"second": "秒",
|
||||
"minute": "分鐘",
|
||||
"hour": "小時",
|
||||
"day": "天"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "名稱不能為空。",
|
||||
"messageRequired": "訊息不能為空。",
|
||||
"intervalRequired": "間隔必須是正整數。",
|
||||
"cronRequired": "Cron 表達式不能為空。",
|
||||
"timeRequired": "執行時間不能為空。",
|
||||
"futureRequired": "執行時間必須晚於目前時間。"
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"authentication": "OAuth 認證",
|
||||
"signIn": "登入",
|
||||
@@ -527,7 +650,8 @@
|
||||
"newInProject": "在 {{project}} 中開始新對話",
|
||||
"activity": {
|
||||
"running": "Agent 正在執行",
|
||||
"complete": "Agent 已完成"
|
||||
"complete": "Agent 已完成",
|
||||
"updated": "有新內容"
|
||||
},
|
||||
"pin": "置頂",
|
||||
"unpin": "取消置頂",
|
||||
@@ -562,7 +686,7 @@
|
||||
"confirm": "刪除",
|
||||
"automationsDescription": "這個對話有關聯的自動任務。刪除對話也會刪除這些自動任務。",
|
||||
"moreAutomations": "另有 {{count}} 個",
|
||||
"confirmWithAutomations": "刪除對話和自動任務",
|
||||
"confirmWithAutomations": "刪除",
|
||||
"schedule": {
|
||||
"at": "{{time}}",
|
||||
"every": "每 {{duration}}",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type {
|
||||
AutomationsPayload,
|
||||
AutomationUpdatePayload,
|
||||
ChatSummary,
|
||||
CliAppsPayload,
|
||||
FilePreviewPayload,
|
||||
@@ -87,6 +89,10 @@ function mcpValuesHeader(values: Record<string, unknown>): HeadersInit | undefin
|
||||
return { "X-Nanobot-MCP-Values": JSON.stringify(payload) };
|
||||
}
|
||||
|
||||
function automationValuesHeader(values: AutomationUpdatePayload): HeadersInit {
|
||||
return { "X-Nanobot-Automation-Values": encodeURIComponent(JSON.stringify(values)) };
|
||||
}
|
||||
|
||||
function splitKey(key: string): { channel: string; chatId: string } {
|
||||
const idx = key.indexOf(":");
|
||||
if (idx === -1) return { channel: "", chatId: key };
|
||||
@@ -184,6 +190,52 @@ export async function fetchSessionAutomations(
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchAutomations(
|
||||
token: string,
|
||||
base: string = "",
|
||||
): Promise<AutomationsPayload> {
|
||||
return request<AutomationsPayload>(
|
||||
`${base}/api/webui/automations`,
|
||||
token,
|
||||
undefined,
|
||||
API_READ_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runAutomationAction(
|
||||
token: string,
|
||||
action: "enable" | "disable" | "delete" | "run",
|
||||
id: string,
|
||||
base: string = "",
|
||||
): Promise<AutomationsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("id", id);
|
||||
return request<AutomationsPayload>(
|
||||
`${base}/api/webui/automations/${action}?${query}`,
|
||||
token,
|
||||
undefined,
|
||||
API_READ_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateAutomation(
|
||||
token: string,
|
||||
id: string,
|
||||
values: AutomationUpdatePayload,
|
||||
base: string = "",
|
||||
): Promise<AutomationsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("id", id);
|
||||
return request<AutomationsPayload>(
|
||||
`${base}/api/webui/automations/update?${query}`,
|
||||
token,
|
||||
{
|
||||
headers: automationValuesHeader(values),
|
||||
},
|
||||
API_READ_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchSkills(
|
||||
token: string,
|
||||
base: string = "",
|
||||
|
||||
@@ -72,20 +72,20 @@ export function deriveWsUrl(
|
||||
wsUrl?: string | null,
|
||||
): string {
|
||||
const query = `?token=${encodeURIComponent(token)}`;
|
||||
if (wsUrl && /^(wss?|nanobot-host):\/\//i.test(wsUrl)) {
|
||||
const join = wsUrl.includes("?") ? "&" : "?";
|
||||
return `${wsUrl}${join}token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
const path = wsPath && wsPath.startsWith("/") ? wsPath : `/${wsPath || ""}`;
|
||||
if (typeof window === "undefined") {
|
||||
return `ws://127.0.0.1:8765${path}${query}`;
|
||||
}
|
||||
if (window.location.port === "5173") {
|
||||
if (typeof window !== "undefined" && window.location.port === "5173") {
|
||||
const host = window.location.hostname.includes(":")
|
||||
? `[${window.location.hostname}]`
|
||||
: window.location.hostname;
|
||||
return `ws://${host}:8765${path}${query}`;
|
||||
}
|
||||
if (wsUrl && /^(wss?|nanobot-host):\/\//i.test(wsUrl)) {
|
||||
const join = wsUrl.includes("?") ? "&" : "?";
|
||||
return `${wsUrl}${join}token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
if (typeof window === "undefined") {
|
||||
return `ws://127.0.0.1:8765${path}${query}`;
|
||||
}
|
||||
const scheme = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const host = window.location.host;
|
||||
return `${scheme}://${host}${path}${query}`;
|
||||
|
||||
@@ -100,6 +100,10 @@ export interface SessionAutomationJob {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
protected?: boolean;
|
||||
delete_after_run?: boolean;
|
||||
created_at_ms?: number | null;
|
||||
updated_at_ms?: number | null;
|
||||
schedule: {
|
||||
kind: "at" | "every" | "cron" | string;
|
||||
at_ms?: number | null;
|
||||
@@ -109,15 +113,43 @@ export interface SessionAutomationJob {
|
||||
};
|
||||
payload: {
|
||||
message: string;
|
||||
kind?: "agent_turn" | "system_event" | string;
|
||||
};
|
||||
state: {
|
||||
next_run_at_ms?: number | null;
|
||||
last_run_at_ms?: number | null;
|
||||
last_status?: "ok" | "error" | "skipped" | string | null;
|
||||
last_error?: string | null;
|
||||
pending?: boolean;
|
||||
run_history?: Array<{
|
||||
run_at_ms: number;
|
||||
status: "ok" | "error" | "skipped" | string;
|
||||
duration_ms?: number;
|
||||
error?: string | null;
|
||||
}>;
|
||||
};
|
||||
origin?: {
|
||||
session_key?: string;
|
||||
channel: string;
|
||||
chat_id?: string;
|
||||
title?: string;
|
||||
preview?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface SessionAutomationsPayload { jobs: SessionAutomationJob[]; }
|
||||
export interface AutomationsPayload { jobs: SessionAutomationJob[]; }
|
||||
export interface AutomationUpdatePayload {
|
||||
name?: string;
|
||||
message?: string;
|
||||
schedule?: {
|
||||
kind: "at" | "every" | "cron";
|
||||
at_ms?: number;
|
||||
every_ms?: number;
|
||||
expr?: string;
|
||||
tz?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SessionDeleteResult {
|
||||
deleted: boolean;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createModelConfiguration,
|
||||
deleteSession,
|
||||
fetchFilePreview,
|
||||
fetchAutomations,
|
||||
fetchCliApps,
|
||||
fetchInstalledCliApps,
|
||||
fetchMcpPresets,
|
||||
@@ -20,9 +21,11 @@ import {
|
||||
listSlashCommands,
|
||||
loginProviderOAuth,
|
||||
logoutProviderOAuth,
|
||||
runAutomationAction,
|
||||
runCliAppAction,
|
||||
runMcpPresetAction,
|
||||
saveCustomMcpServer,
|
||||
updateAutomation,
|
||||
updateSidebarState,
|
||||
updateImageGenerationSettings,
|
||||
updateModelConfiguration,
|
||||
@@ -99,6 +102,49 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches workspace automations", async () => {
|
||||
await fetchAutomations("tok");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/automations",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes workspace automation actions", async () => {
|
||||
await runAutomationAction("tok", "disable", "job 1/2");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/automations/disable?id=job+1%2F2",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes workspace automation updates", async () => {
|
||||
const values = {
|
||||
name: "每日测验",
|
||||
message: "Ask 今日 quiz",
|
||||
schedule: { kind: "cron", expr: "0 9 * * *", tz: "Asia/Shanghai" },
|
||||
} as const;
|
||||
await updateAutomation("tok", "job 1/2", values);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/automations/update?id=job+1%2F2",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-Automation-Values": encodeURIComponent(JSON.stringify(values)),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const header = vi.mocked(fetch).mock.calls[0][1]?.headers as Record<string, string>;
|
||||
expect(header["X-Nanobot-Automation-Values"]).not.toContain("每日");
|
||||
});
|
||||
|
||||
it("fetches the WebUI skill summary", async () => {
|
||||
await fetchSkills("tok");
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ const toggleThemeSpy = vi.fn();
|
||||
const updateUrlSpy = vi.fn();
|
||||
const attachSpy = vi.fn();
|
||||
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
|
||||
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
||||
let mockSessions: ChatSummary[] = [];
|
||||
const HERO_GREETING_PATTERN =
|
||||
/What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/;
|
||||
@@ -194,7 +195,10 @@ vi.mock("@/lib/nanobot-client", () => {
|
||||
onRuntimeModelUpdate = () => () => {};
|
||||
onError = () => () => {};
|
||||
onChat = () => () => {};
|
||||
onSessionUpdate = () => () => {};
|
||||
onSessionUpdate = (handler: (chatId: string, scope?: string) => void) => {
|
||||
sessionUpdateHandlers.add(handler);
|
||||
return () => sessionUpdateHandlers.delete(handler);
|
||||
};
|
||||
onRunStatus = (handler: (chatId: string, startedAt: number | null) => void) => {
|
||||
runStatusHandlers.add(handler);
|
||||
return () => runStatusHandlers.delete(handler);
|
||||
@@ -227,10 +231,12 @@ describe("App layout", () => {
|
||||
toggleThemeSpy.mockReset();
|
||||
attachSpy.mockReset();
|
||||
runStatusHandlers.clear();
|
||||
sessionUpdateHandlers.clear();
|
||||
window.history.replaceState(null, "", "/");
|
||||
setNavigatorPlatform("Linux x86_64");
|
||||
localStorage.removeItem("nanobot-webui.sidebar");
|
||||
localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
|
||||
localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1");
|
||||
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
|
||||
token: "tok",
|
||||
ws_path: "/",
|
||||
@@ -265,6 +271,23 @@ describe("App layout", () => {
|
||||
expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true);
|
||||
});
|
||||
|
||||
it("places Automations after Skills in the main sidebar", async () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
const appsButton = within(sidebar).getByRole("button", { name: "Apps" });
|
||||
const skillsButton = within(sidebar).getByRole("button", { name: "Skills" });
|
||||
const automationsButton = within(sidebar).getByRole("button", { name: "Automations" });
|
||||
|
||||
expect(appsButton.compareDocumentPosition(skillsButton) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
.toBeTruthy();
|
||||
expect(
|
||||
skillsButton.compareDocumentPosition(automationsButton) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens Skills from the main sidebar", async () => {
|
||||
mockFetchRoutes({
|
||||
"/api/settings": baseSettingsPayload(),
|
||||
@@ -334,6 +357,331 @@ describe("App layout", () => {
|
||||
expect(screen.getByText(/Use GitHub CLI/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens Automations from the main sidebar", async () => {
|
||||
mockFetchRoutes({
|
||||
"/api/settings": baseSettingsPayload(),
|
||||
"/api/webui/automations": {
|
||||
jobs: [
|
||||
{
|
||||
id: "job-1",
|
||||
name: "Daily repo check",
|
||||
enabled: true,
|
||||
protected: false,
|
||||
delete_after_run: false,
|
||||
schedule: { kind: "every", every_ms: 86_400_000 },
|
||||
payload: {
|
||||
message: "Check the repo status",
|
||||
kind: "agent_turn",
|
||||
},
|
||||
state: {
|
||||
next_run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0),
|
||||
last_status: "ok",
|
||||
pending: false,
|
||||
run_history: [],
|
||||
},
|
||||
origin: {
|
||||
session_key: "websocket:chat-a",
|
||||
channel: "websocket",
|
||||
chat_id: "chat-a",
|
||||
title: "Release prep",
|
||||
preview: "Check release blockers",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "external-quiz",
|
||||
name: "WeChat quiz",
|
||||
enabled: true,
|
||||
protected: false,
|
||||
delete_after_run: false,
|
||||
schedule: { kind: "cron", expr: "30 9-23 * * *", tz: "Asia/Shanghai" },
|
||||
payload: {
|
||||
message: "Send a quiz",
|
||||
kind: "agent_turn",
|
||||
},
|
||||
state: {
|
||||
next_run_at_ms: Date.UTC(2026, 3, 17, 11, 30, 0),
|
||||
last_status: "ok",
|
||||
pending: false,
|
||||
run_history: [],
|
||||
},
|
||||
origin: {
|
||||
channel: "weixin",
|
||||
title: "",
|
||||
preview: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "heartbeat",
|
||||
name: "heartbeat",
|
||||
enabled: true,
|
||||
protected: true,
|
||||
schedule: { kind: "every", every_ms: 60_000 },
|
||||
payload: { message: "", kind: "system_event" },
|
||||
state: { next_run_at_ms: null, pending: false, run_history: [] },
|
||||
origin: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
const automationsButton = within(sidebar).getByRole("button", {
|
||||
name: "Automations",
|
||||
});
|
||||
|
||||
fireEvent.click(automationsButton);
|
||||
|
||||
const heading = await screen.findByRole("heading", { name: "Automations" });
|
||||
expect(heading).toBeInTheDocument();
|
||||
const automationsMain = heading.closest("main");
|
||||
expect(automationsMain).not.toBeNull();
|
||||
expect(within(automationsMain as HTMLElement).queryByText("Settings")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText("Daily repo check").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("Check the repo status").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("Release prep").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("WeChat quiz")).toBeInTheDocument();
|
||||
expect(screen.getByText("WeChat")).toBeInTheDocument();
|
||||
expect(screen.queryByText("weixin:wx-chat")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("memory with dream state")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("heartbeat")).toBeInTheDocument();
|
||||
expect(within(sidebar).getByRole("button", { name: "Automations" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
);
|
||||
expect(document.title).toBe("Automations · nanobot");
|
||||
|
||||
const searchInput = within(automationsMain as HTMLElement).getByPlaceholderText(
|
||||
"Search task, message, linked chat, or schedule",
|
||||
);
|
||||
fireEvent.change(searchInput, { target: { value: "WeChat" } });
|
||||
await waitFor(() => expect(screen.queryByText("Daily repo check")).not.toBeInTheDocument());
|
||||
expect(screen.getAllByText("WeChat quiz").length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "09-23" } });
|
||||
await waitFor(() => expect(screen.queryByText("Daily repo check")).not.toBeInTheDocument());
|
||||
expect(screen.getAllByText("WeChat quiz").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("edits a past one-time automation without resubmitting its old schedule", async () => {
|
||||
const pastOneShot = {
|
||||
id: "past-one-shot",
|
||||
name: "Past one-shot",
|
||||
enabled: true,
|
||||
protected: false,
|
||||
delete_after_run: true,
|
||||
schedule: { kind: "at", at_ms: 1 },
|
||||
payload: {
|
||||
message: "Old one-shot message",
|
||||
kind: "agent_turn",
|
||||
},
|
||||
state: {
|
||||
next_run_at_ms: null,
|
||||
last_status: "ok",
|
||||
pending: false,
|
||||
run_history: [],
|
||||
},
|
||||
origin: {
|
||||
session_key: "websocket:chat-a",
|
||||
channel: "websocket",
|
||||
chat_id: "chat-a",
|
||||
title: "Release prep",
|
||||
preview: "Check release blockers",
|
||||
},
|
||||
};
|
||||
mockFetchRoutes({
|
||||
"/api/settings": baseSettingsPayload(),
|
||||
"/api/webui/automations": { jobs: [pastOneShot] },
|
||||
"/api/webui/automations/update?id=past-one-shot": {
|
||||
jobs: [
|
||||
{
|
||||
...pastOneShot,
|
||||
payload: { ...pastOneShot.payload, message: "Updated one-shot message" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Automations" }));
|
||||
|
||||
expect((await screen.findAllByText("Past one-shot")).length).toBeGreaterThanOrEqual(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||
expect(screen.queryByText("Run time must be in the future.")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Update the prompt and schedule. The linked chat stays unchanged."),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("Old one-shot message")).toHaveClass(
|
||||
"min-h-[160px]",
|
||||
"resize-none",
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue("Old one-shot message"), {
|
||||
target: { value: "Updated one-shot message" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/automations/update?id=past-one-shot",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
const updateCall = vi.mocked(fetch).mock.calls.find(
|
||||
([url]) => String(url) === "/api/webui/automations/update?id=past-one-shot",
|
||||
);
|
||||
expect(updateCall).toBeTruthy();
|
||||
const headers = updateCall?.[1]?.headers as Record<string, string>;
|
||||
expect(JSON.parse(decodeURIComponent(headers["X-Nanobot-Automation-Values"]))).toEqual({
|
||||
name: "Past one-shot",
|
||||
message: "Updated one-shot message",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps long automation details expandable without nested scrolling", async () => {
|
||||
const longMessage = [
|
||||
"Review the release plan and prepare a concise status update for the channel.",
|
||||
"Include blockers, owners, follow-up dates, and any risky assumptions that changed since yesterday.",
|
||||
"Keep the output actionable and avoid repeating context that the team already confirmed in the thread.",
|
||||
"If a dependency looks stale, call it out explicitly and ask for a fresh owner update.",
|
||||
"This message is intentionally long enough to require progressive disclosure in the automation details panel.",
|
||||
"The full content should remain available without forcing the user into a small nested scroll area.",
|
||||
].join("\n");
|
||||
const history = [
|
||||
{ run_at_ms: Date.UTC(2026, 3, 12, 10, 0, 0), status: "error", duration_ms: 900, error: "oldest failure" },
|
||||
{ run_at_ms: Date.UTC(2026, 3, 13, 10, 0, 0), status: "error", duration_ms: 800, error: "second oldest failure" },
|
||||
{ run_at_ms: Date.UTC(2026, 3, 14, 10, 0, 0), status: "ok", duration_ms: 700 },
|
||||
{ run_at_ms: Date.UTC(2026, 3, 15, 10, 0, 0), status: "ok", duration_ms: 600 },
|
||||
{ run_at_ms: Date.UTC(2026, 3, 16, 10, 0, 0), status: "ok", duration_ms: 500 },
|
||||
{ run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0), status: "ok", duration_ms: 400 },
|
||||
];
|
||||
mockFetchRoutes({
|
||||
"/api/settings": baseSettingsPayload(),
|
||||
"/api/webui/automations": {
|
||||
jobs: [
|
||||
{
|
||||
id: "long-details",
|
||||
name: "Long detail automation",
|
||||
enabled: true,
|
||||
protected: false,
|
||||
delete_after_run: false,
|
||||
schedule: { kind: "every", every_ms: 3_600_000 },
|
||||
payload: {
|
||||
message: longMessage,
|
||||
kind: "agent_turn",
|
||||
},
|
||||
state: {
|
||||
next_run_at_ms: Date.UTC(2026, 3, 18, 10, 0, 0),
|
||||
last_status: "ok",
|
||||
pending: false,
|
||||
run_history: history,
|
||||
},
|
||||
origin: {
|
||||
session_key: "websocket:chat-a",
|
||||
channel: "websocket",
|
||||
chat_id: "chat-a",
|
||||
title: "Release prep",
|
||||
preview: "Check release blockers",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Automations" }));
|
||||
|
||||
const detailHeading = await screen.findByRole("heading", { name: "Long detail automation" });
|
||||
const detailPanel = detailHeading.closest("article") as HTMLElement;
|
||||
expect(detailPanel).not.toBeNull();
|
||||
const message = Array.from(detailPanel.querySelectorAll("section div")).find(
|
||||
(node) => node.textContent === longMessage,
|
||||
) as HTMLElement | undefined;
|
||||
expect(message).toBeTruthy();
|
||||
expect(message!).toHaveClass("line-clamp-6");
|
||||
|
||||
fireEvent.click(within(detailPanel).getByRole("button", { name: "Show full message" }));
|
||||
expect(within(detailPanel).getByRole("button", { name: "Show less" })).toBeInTheDocument();
|
||||
expect(message!).not.toHaveClass("line-clamp-6");
|
||||
|
||||
expect(within(detailPanel).queryByText("Recent health")).not.toBeInTheDocument();
|
||||
expect(within(detailPanel).queryByRole("button", { name: /Run history/ })).not.toBeInTheDocument();
|
||||
expect(within(detailPanel).queryByText(/oldest failure/)).not.toBeInTheDocument();
|
||||
expect(within(detailPanel).queryByText("No error recorded")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("localizes the Automations surface", async () => {
|
||||
await i18n.changeLanguage("zh-CN");
|
||||
mockFetchRoutes({
|
||||
"/api/settings": baseSettingsPayload(),
|
||||
"/api/webui/automations": {
|
||||
jobs: [
|
||||
{
|
||||
id: "job-zh",
|
||||
name: "每日检查",
|
||||
enabled: true,
|
||||
protected: false,
|
||||
delete_after_run: false,
|
||||
schedule: { kind: "every", every_ms: 86_400_000 },
|
||||
payload: {
|
||||
message: "检查仓库状态",
|
||||
kind: "agent_turn",
|
||||
},
|
||||
state: {
|
||||
next_run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0),
|
||||
last_run_at_ms: Date.UTC(2026, 3, 16, 10, 0, 0),
|
||||
last_status: "ok",
|
||||
pending: false,
|
||||
run_history: [
|
||||
{
|
||||
run_at_ms: Date.UTC(2026, 3, 16, 10, 0, 0),
|
||||
status: "ok",
|
||||
duration_ms: 500,
|
||||
},
|
||||
],
|
||||
},
|
||||
origin: {
|
||||
session_key: "websocket:chat-a",
|
||||
channel: "websocket",
|
||||
chat_id: "chat-a",
|
||||
title: "发布准备",
|
||||
preview: "检查发布阻塞项",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "侧边栏导航" });
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "自动任务" }));
|
||||
|
||||
const heading = await screen.findByRole("heading", { name: "自动任务" });
|
||||
expect(heading).toBeInTheDocument();
|
||||
const automationsMain = heading.closest("main");
|
||||
expect(automationsMain).not.toBeNull();
|
||||
expect(within(automationsMain as HTMLElement).queryByText("设置")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("任务队列")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("每日检查").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("检查仓库状态").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("每 1天")).toBeInTheDocument();
|
||||
expect(screen.queryByText("最近健康状态")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("近期无问题")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Workspace automations")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "刷新" })).not.toBeInTheDocument();
|
||||
expect(document.title).toBe("自动任务 · nanobot");
|
||||
});
|
||||
|
||||
it("fully collapses the native host sidebar and previews it on hover", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
@@ -497,7 +845,7 @@ describe("App layout", () => {
|
||||
screen.queryByText("This chat has scheduled automations. Deleting it will also delete them."),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "删除对话和自动任务" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "删除" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(deleteChatSpy).toHaveBeenCalledWith("websocket:chat-a", {
|
||||
@@ -754,15 +1102,15 @@ describe("App layout", () => {
|
||||
for (const handler of runStatusHandlers) handler("chat-a", null);
|
||||
});
|
||||
expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument();
|
||||
expect(within(sidebar).getByTitle("Agent finished")).toBeInTheDocument();
|
||||
expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: /^Working chat$/ }));
|
||||
});
|
||||
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
|
||||
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show a completed dot later when the active session finishes", async () => {
|
||||
it("does not show an updated dot later when the active session finishes", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
key: "websocket:chat-a",
|
||||
@@ -806,12 +1154,53 @@ describe("App layout", () => {
|
||||
for (const handler of runStatusHandlers) handler("chat-a", null);
|
||||
});
|
||||
expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument();
|
||||
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
|
||||
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: /^Other chat$/ }));
|
||||
});
|
||||
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
|
||||
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks inactive sessions when a thread update arrives", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
key: "websocket:chat-a",
|
||||
channel: "websocket",
|
||||
chatId: "chat-a",
|
||||
createdAt: "2026-04-16T10:00:00Z",
|
||||
updatedAt: "2026-04-16T10:00:00Z",
|
||||
preview: "Open chat",
|
||||
},
|
||||
{
|
||||
key: "websocket:chat-b",
|
||||
channel: "websocket",
|
||||
chatId: "chat-b",
|
||||
createdAt: "2026-04-16T11:00:00Z",
|
||||
updatedAt: "2026-04-16T11:00:00Z",
|
||||
preview: "Scheduled update target",
|
||||
},
|
||||
];
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
await act(async () => {
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: /^Open chat$/ }));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
for (const handler of sessionUpdateHandlers) handler("chat-b", "thread");
|
||||
});
|
||||
|
||||
expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: /^Scheduled update target$/ }));
|
||||
});
|
||||
|
||||
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores sidebar run indicators after a page reload", async () => {
|
||||
@@ -835,7 +1224,7 @@ describe("App layout", () => {
|
||||
},
|
||||
];
|
||||
localStorage.setItem(
|
||||
"nanobot-webui.sidebar.completed-runs.v1",
|
||||
"nanobot-webui.sidebar.session-updates.v1",
|
||||
JSON.stringify(["chat-b"]),
|
||||
);
|
||||
|
||||
@@ -846,7 +1235,7 @@ describe("App layout", () => {
|
||||
await waitFor(() =>
|
||||
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument(),
|
||||
);
|
||||
expect(within(sidebar).getByTitle("Agent finished")).toBeInTheDocument();
|
||||
expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument();
|
||||
expect(attachSpy).toHaveBeenCalledWith("chat-a");
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,19 @@ describe("bootstrap helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("overrides the server-provided websocket URL when on dev server port 5173", () => {
|
||||
vi.stubGlobal("window", {
|
||||
location: {
|
||||
port: "5173",
|
||||
hostname: "192.168.1.100",
|
||||
protocol: "http:",
|
||||
},
|
||||
});
|
||||
expect(deriveWsUrl("/", "tok", "ws://127.0.0.1:8765/")).toBe(
|
||||
"ws://192.168.1.100:8765/?token=tok",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the host socket bridge URL", () => {
|
||||
expect(deriveWsUrl("/", "tok en", "nanobot-host://engine/")).toBe(
|
||||
"nanobot-host://engine/?token=tok%20en",
|
||||
|
||||
@@ -18,6 +18,44 @@ function session(overrides: Partial<ChatSummary>): ChatSummary {
|
||||
}
|
||||
|
||||
describe("ChatList", () => {
|
||||
it("orders chats by latest session activity by default", () => {
|
||||
const sessions = [
|
||||
session({
|
||||
chatId: "older",
|
||||
title: "Older chat",
|
||||
updatedAt: "2026-05-21T10:00:00Z",
|
||||
}),
|
||||
session({
|
||||
chatId: "newest",
|
||||
title: "Newest chat",
|
||||
updatedAt: "2026-05-21T12:00:00Z",
|
||||
}),
|
||||
session({
|
||||
chatId: "middle",
|
||||
title: "Middle chat",
|
||||
updatedAt: "2026-05-21T11:00:00Z",
|
||||
}),
|
||||
];
|
||||
|
||||
render(
|
||||
<ChatList
|
||||
sessions={sessions}
|
||||
activeKey={null}
|
||||
onSelect={vi.fn()}
|
||||
onRequestDelete={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const chatsSection = screen.getAllByRole("region")[0];
|
||||
const text = chatsSection.textContent ?? "";
|
||||
|
||||
expect(text.indexOf("Newest chat")).toBeLessThan(text.indexOf("Middle chat"));
|
||||
expect(text.indexOf("Middle chat")).toBeLessThan(text.indexOf("Older chat"));
|
||||
});
|
||||
|
||||
it("groups WebUI chats by workspace project while preserving in-project sorting and activity", () => {
|
||||
const sessions = [
|
||||
session({
|
||||
@@ -179,7 +217,7 @@ describe("ChatList", () => {
|
||||
expect(onRequestRenameProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos");
|
||||
});
|
||||
|
||||
it("hides the completed dot for the active chat", () => {
|
||||
it("hides the updated dot for the active chat", () => {
|
||||
const sessions = [
|
||||
session({
|
||||
chatId: "active",
|
||||
@@ -200,13 +238,13 @@ describe("ChatList", () => {
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
completedChatIds={["active", "done"]}
|
||||
updatedChatIds={["active", "done"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const finished = screen.getAllByLabelText("Agent finished");
|
||||
expect(finished).toHaveLength(1);
|
||||
expect(finished[0].firstElementChild).toHaveClass("h-2", "w-2");
|
||||
const updated = screen.getAllByLabelText("New activity");
|
||||
expect(updated).toHaveLength(1);
|
||||
expect(updated[0].firstElementChild).toHaveClass("h-2", "w-2");
|
||||
});
|
||||
|
||||
it("folds long default workspace chats and can show all", () => {
|
||||
|
||||
@@ -31,6 +31,7 @@ const SETTINGS_NAV_KEYS = [
|
||||
"image",
|
||||
"browser",
|
||||
"apps",
|
||||
"automations",
|
||||
"runtime",
|
||||
"advanced",
|
||||
];
|
||||
@@ -43,8 +44,17 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
|
||||
"settings.nav.models",
|
||||
"settings.nav.providers",
|
||||
"settings.nav.apps",
|
||||
"settings.nav.automations",
|
||||
"settings.nav.runtime",
|
||||
"settings.nav.advanced",
|
||||
"sidebar.automations",
|
||||
"settings.automations.filters.active",
|
||||
"settings.automations.queue",
|
||||
"settings.automations.empty",
|
||||
"settings.automations.systemTask",
|
||||
"settings.automations.labels.schedule",
|
||||
"settings.automations.status.active",
|
||||
"settings.automations.deleteTitle",
|
||||
"settings.sections.interface",
|
||||
"settings.sections.localPreferences",
|
||||
"settings.sections.webSearch",
|
||||
|
||||
@@ -159,8 +159,9 @@ const installedAnyGen = {
|
||||
|
||||
function renderSettingsView(
|
||||
options: {
|
||||
initialSection?: "overview" | "apps" | "advanced" | "models";
|
||||
initialSection?: "overview" | "apps" | "automations" | "advanced" | "models";
|
||||
initialSettings?: SettingsPayload;
|
||||
showSidebar?: boolean;
|
||||
onSettingsChange?: (payload: SettingsPayload) => void;
|
||||
onNativeEngineRestart?: () => Promise<string>;
|
||||
} = {},
|
||||
@@ -171,6 +172,7 @@ function renderSettingsView(
|
||||
theme="light"
|
||||
initialSection={options.initialSection ?? "apps"}
|
||||
initialSettings={options.initialSettings}
|
||||
showSidebar={options.showSidebar}
|
||||
onToggleTheme={() => {}}
|
||||
onBackToChat={() => {}}
|
||||
onModelNameChange={() => {}}
|
||||
@@ -187,6 +189,25 @@ describe("SettingsView Apps catalog", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("does not show the Settings kicker on the standalone Automations surface", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||
if (url === "/api/webui/automations") return jsonResponse({ jobs: [] });
|
||||
return jsonResponse({});
|
||||
}));
|
||||
|
||||
renderSettingsView({
|
||||
initialSection: "automations",
|
||||
initialSettings: settingsPayload(),
|
||||
showSidebar: false,
|
||||
});
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Automations" })).toBeInTheDocument();
|
||||
expect(await screen.findByText("No automations yet.")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Settings")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a visible uninstall button for installed CLI apps and calls uninstall", async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
|
||||
Reference in New Issue
Block a user