Compare commits

...
Author SHA1 Message Date
chengyongru 43d592f8e4 feat(agent): persist subagent result delivery 2026-06-17 23:16:48 +08:00
chengyongruandchengyongru 4a6853f0ff chore: remove internal mailbox plan from PR
Maintainer edit: the implementation plan is useful local context, but it should not be included in the submitted PR diff.
2026-06-17 23:16:21 +08:00
chengyongruandchengyongru 3b03cc2079 feat(subagent): add mailbox-backed worker results 2026-06-17 23:16:21 +08:00
Xubin RenandGitHub c29601d303 Merge PR #4330: feat(webui): add automation management view
feat(webui): add automation management view
2026-06-17 01:55:39 +08:00
Xubin Ren 9ce40969ce style(webui): add subtle automation filter tones 2026-06-17 01:49:24 +08:00
HaisamandXubin Ren 5847470b65 docs: use pipe pattern for curl installer commands
Replace `sh -c "$(curl ...)"` with `curl ... | sh` across all
documentation. The subshell pattern breaks when users embed the
command inside other scripts (e.g. Dockerfiles using here-docs),
because the outer shell eagerly expands `$(curl ...)` before writing
the script to disk, mangling the installer contents.

The pipe pattern avoids this problem and is friendlier to further
scripting. For commands that pass arguments (--dry-run, --dev),
`sh -s --` is used to forward them through stdin.
2026-06-17 00:59:36 +08:00
chengyongruandXubin Ren 43eb658a0b fix installer for externally managed Python 2026-06-17 00:48:13 +08:00
chengyongruandXubin Ren 4c5e340186 feat(memory): enable idle auto compact by default 2026-06-17 00:48:06 +08:00
chengyongruandXubin Ren dcf76117ab fix(command): explain empty dream runs 2026-06-17 00:47:59 +08:00
chengyongruandXubin Ren dcb33cf919 refactor: simplify token truncation loop
Maintainer edit: keep the strict token-budget behavior while removing the duplicate pre-loop result construction in the shared truncation helper.
2026-06-17 00:47:52 +08:00
chengyongruandXubin Ren 072921893f fix: reuse token truncation helper
Maintainer edit: make token truncation include the suffix within the budget and route the consolidator through the shared helper so recent-history and archive truncation keep the same semantics.
2026-06-17 00:47:52 +08:00
w.antarandXubin Ren 21d9072190 fix(tests): update recent history truncation to use token limits 2026-06-17 00:47:52 +08:00
w.antarandXubin Ren 973a5ee507 fix(context): cap recent-history digest by tokens, not characters
The recent-history section injected into the system prompt was capped by character count (_MAX_HISTORY_CHARS = 32_000). Characters are a poor proxy for tokens: ~32k chars of English is ~8k tokens, but the same char count of CJK text or code can be far more, so the cap could let the section blow well past its intended size on non English/code-heavy histories.

Add a reusable truncate_text_to_tokens() helper (reusing the tiktoken
 cl100k_base encoder already used elsewhere, with a char-based fallback) and
  switch the digest cap to a token budget (_MAX_HISTORY_TOKENS = 8_000),
  matching the previous English-text size while holding regardless of
  content.
2026-06-17 00:47:52 +08:00
yu-xin-candXubin Ren 846410f936 fix(providers): validate stream idle timeout config 2026-06-17 00:47:44 +08:00
Xubin Ren 7bec0f6e01 fix(api): honor skip-user persist through save boundary 2026-06-16 21:31:26 +08:00
04cbandXubin Ren d75f80437c fix(api): don't re-persist user turn on empty-response retry (#4079)
The non-streaming retry called process_direct again with the same
content, persisting a duplicate user turn. Pass persist_user_message=False
so the retry recovers a response without re-recording the user message.
2026-06-16 21:31:26 +08:00
chengyongru c6ea5aecff docs: add webui user guide 2026-06-16 19:52:16 +08:00
Xubin Ren 25a55fe1c7 fix(providers): enable thinking for Kimi K2.7 models 2026-06-16 17:44:22 +08:00
comadrejaandXubin Ren 4262375c19 chore: ignore bridge/node_modules
The .gitignore already excludes desktop/ and webui/ node_modules but
not the bridge's. Add bridge/node_modules/ so the compiled bridge deps
are never accidentally committed.
2026-06-16 17:32:02 +08:00
Heng Wei BinandXubin Ren 5573a9d78e fix(webui): override wsUrl with local LAN IP when on dev server port 5173 2026-06-16 17:31:32 +08:00
chengyongru 2b741ad4e5 fix(webui): align automation settings layout
Maintainer edit: reuse the same standalone SettingsView shell as Apps and Skills for Automations while keeping the automation queue height constrained.
2026-06-16 16:31:24 +08:00
chengyongru e9f982785e fix(webui): remove automation background layer
Maintainer edit: keep the automation workspace on the page background and remove the extra card-like backdrop that made the layout show square edges around the panels.
2026-06-16 16:24:35 +08:00
chengyongru 0263dbd1c3 chore: trim automation ui cleanup 2026-06-16 15:10:51 +08:00
chengyongru 3357dcc05f docs: drop README change from automation PR 2026-06-16 14:37:30 +08:00
chengyongru b24b5f19fc fix(cron): always require bound automation sessions 2026-06-16 14:16:12 +08:00
chengyongru 6239114c46 fix(cron): prevent unbound automation execution 2026-06-16 14:07:08 +08:00
chengyongru 04545b95d9 fix(webui): refine automation styling and delete confirmation 2026-06-16 11:41:05 +08:00
chengyongruandXubin Ren 27d869d3cc fix(agent): refresh goal continuation context 2026-06-16 11:19:24 +08:00
chengyongru 201d442a85 fix(webui): soften automation manager styling 2026-06-16 11:07:48 +08:00
chengyongru 04387bf9e3 fix(webui): keep automation workspace within viewport 2026-06-16 10:08:50 +08:00
chengyongru 23e12b84ff fix(webui): clarify automation search placeholder 2026-06-16 09:48:53 +08:00
chengyongru f56a73b2d3 fix(webui): align automation toolbar controls 2026-06-16 00:59:42 +08:00
chengyongru 8f1fe7337c fix(webui): tighten automation toolbar layout 2026-06-16 00:42:25 +08:00
chengyongru e6957de622 fix(webui): drop legacy automation setup state 2026-06-16 00:26:06 +08:00
chengyongru 87aaf8991a fix(webui): simplify automation details 2026-06-15 22:52:26 +08:00
chengyongru 3aa90e539c fix(webui): make automation history diagnostic 2026-06-15 21:54:09 +08:00
chengyongru acf408ced2 fix(webui): collapse automation run history by default 2026-06-15 21:03:48 +08:00
chengyongru 85a3ff1372 fix(webui): smooth automation detail overflow 2026-06-15 20:29:18 +08:00
chengyongruandXubin Ren 3ce0cd972e fix(session): keep auto compact suffix on user turn 2026-06-15 19:03:37 +08:00
chengyongru 03c79817ac test(websocket): expect session refresh after turn end 2026-06-15 18:25:00 +08:00
chengyongru 153f2d9529 fix(webui): move automations after skills 2026-06-15 18:12:54 +08:00
chengyongru 848378d0db Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	webui/src/components/settings/SettingsView.tsx
2026-06-15 18:11:22 +08:00
chengyongru 828759d1b6 fix(webui): hide settings kicker on automations 2026-06-15 18:08:44 +08:00
chengyongru 167a53dc45 fix(webui): sort sessions by transcript activity 2026-06-15 17:44:26 +08:00
chengyongru cbb4c0bad2 fix(webui): polish automation layout and session updates 2026-06-15 17:30:06 +08:00
chengyongru d7e73609d3 fix(webui): redesign automation management layout 2026-06-15 16:35:15 +08:00
chengyongru 0439ecb802 fix(webui): refine automation row layout 2026-06-15 15:41:35 +08:00
chengyongru 04496e9e28 fix(webui): improve automation edit message field 2026-06-15 15:22:01 +08:00
chengyongru 140c4fb49f fix(webui): clarify automation anomaly labels 2026-06-15 15:17:38 +08:00
chengyongru f8bf6aea51 fix(webui): encode automation update values 2026-06-15 15:14:24 +08:00
9814a3b9fe fix(api): forward real LLM usage in /v1/chat/completions response (#4310)
* fix(api): forward real LLM usage in /v1/chat/completions response

_chat_completion_response() hardcoded prompt_tokens/completion_tokens
to zero.  Now reads agent_loop._last_usage (set by process_direct
after every LLM call) and forwards the actual prompt/completion counts.

Streaming path is unchanged; usage is only surfaced in non-streaming
responses for now.

Fixes #4309

* fix: use defensive getattr for _last_usage and add it to all test mock agents

- Use getattr(agent_loop, '_last_usage', None) in server.py for safety
- Add _last_usage = {} to mock agents in test_api_attachment.py and test_api_stream.py
- Prevents AttributeError/500 when mock agents don't have the attribute

* fix(api): preserve provider total usage

---------

Co-authored-by: michaelxer <michaelxer@users.noreply.github.com>
Co-authored-by: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
2026-06-15 15:13:11 +08:00
Stellar鱼andGitHub f85101f017 fix(memory): ignore malformed history entries (#4315) 2026-06-15 15:13:07 +08:00
Stellar鱼andGitHub a54e56c69e fix(runner): ignore empty injected payloads (#4337) 2026-06-15 15:13:03 +08:00
chengyongru 5892c6913b fix(webui): allow content-only automation edits
Avoid resubmitting unchanged schedules from the automation edit dialog so completed one-time automations can still have their content updated. Treat unchanged schedules as already-valid on the backend while preserving validation for actual schedule changes.
2026-06-15 00:59:23 +08:00
chengyongru 8b42d0760e fix(webui): harden automation management API
Maintainer edit: redact external channel chat identifiers from the WebUI automation payload and reject malformed or unschedulable automation updates before they mutate cron jobs.
2026-06-14 22:19:28 +08:00
chengyongru 747f0a08c7 fix(webui): improve automation management 2026-06-14 18:24:59 +08:00
chengyongru 6cf1f8e164 fix(webui): trim automation creation prompt 2026-06-14 02:10:16 +08:00
chengyongru 5f2f694034 fix(webui): reduce automation dashboard copy 2026-06-14 01:03:53 +08:00
chengyongru 17e3183598 fix(webui): simplify automation source display 2026-06-14 00:32:53 +08:00
chengyongru 5b10102629 fix(webui): avoid fake automation origins 2026-06-14 00:04:15 +08:00
chengyongru 43830b7162 fix(webui): localize automation runtime labels 2026-06-13 23:06:28 +08:00
chengyongru e08462ca30 feat(webui): add automation management view 2026-06-13 23:06:28 +08:00
88 changed files with 7102 additions and 468 deletions
+1
View File
@@ -99,3 +99,4 @@ temp/
*.tmp
exp/
.playwright-mcp/
bridge/node_modules/
+15 -13
View File
@@ -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
View File
@@ -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 |
+2 -1
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+15 -7
View File
@@ -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
+6 -6
View File
@@ -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
View File
@@ -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).
+9 -4
View File
@@ -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
View File
@@ -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())
+415
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+94
View File
@@ -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})."
+4 -3
View File
@@ -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,
+207
View File
@@ -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
View File
@@ -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:
+3 -2
View File
@@ -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}
+19 -1
View File
@@ -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] = []
+1 -1
View File
@@ -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
View File
@@ -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 -3
View File
@@ -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",
+29
View File
@@ -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:
+3 -2
View File
@@ -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",
+7 -3
View File
@@ -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:
+19 -4
View File
@@ -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",
+4 -1
View File
@@ -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
View File
@@ -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:
+41 -1
View File
@@ -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:
+34 -1
View File
@@ -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:
+123 -4
View File
@@ -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
+70 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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!\""
+67 -3
View File
@@ -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."""
+10 -6
View File
@@ -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
+8 -4
View File
@@ -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:
+15
View File
@@ -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",
+160
View File
@@ -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")
+17
View File
@@ -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(
+34
View File
@@ -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)
+64 -1
View File
@@ -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) ---
+44 -26
View File
@@ -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"
# ---------------------------------------------------------------------------
+15 -15
View File
@@ -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
+14 -25
View File
@@ -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()
+52 -9
View File
@@ -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
+264 -1
View File
@@ -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
+54 -2
View File
@@ -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")
+95 -1
View File
@@ -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:
+18
View File
@@ -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
+37
View File
@@ -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
+24
View File
@@ -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
+1
View File
@@ -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
+8
View File
@@ -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)
+50
View File
@@ -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)
+27 -1
View File
@@ -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
+83
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+9 -9
View File
@@ -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>
);
}
+3 -3
View File
@@ -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")
+12 -3
View File
@@ -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
+126 -2
View File
@@ -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}}",
+126 -2
View File
@@ -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}}",
+126 -2
View File
@@ -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 lautomatisation",
"save": "Enregistrer",
"deleteTitle": "Supprimer lautomatisation",
"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 dexé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 dexé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": "Lintervalle doit être un nombre positif.",
"cronRequired": "Lexpression cron est obligatoire.",
"timeRequired": "Lheure dexécution est obligatoire.",
"futureRequired": "Lheure dexé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}}",
+126 -2
View File
@@ -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}}",
+126 -2
View File
@@ -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}} ごと",
+126 -2
View File
@@ -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}}마다",
+126 -2
View File
@@ -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}}",
+126 -2
View File
@@ -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}}",
+126 -2
View File
@@ -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}}",
+52
View File
@@ -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 = "",
+8 -8
View File
@@ -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}`;
+32
View File
@@ -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;
+46
View File
@@ -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");
+398 -9
View File
@@ -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");
});
+13
View File
@@ -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",
+43 -5
View File
@@ -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", () => {
+10
View File
@@ -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",
+22 -1
View File
@@ -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);