Compare commits

..
Author SHA1 Message Date
Xubin Ren 234da592f5 fix(cli): preserve gateway log stream boundaries 2026-08-29 21:39:18 +08:00
Xubin Ren 2d138b92fc fix(cli): stream gateway logs in WebUI launcher 2026-08-29 21:34:24 +08:00
Xubin Ren c02f013b17 fix(providers): harden OAuth model discovery 2026-08-29 21:22:20 +08:00
Xubin Ren 1c6483147e refactor(providers): localize OAuth model discovery 2026-08-29 21:22:20 +08:00
Xubin Ren 7941450a5d fix(providers): recover incomplete Grok searches 2026-08-29 21:22:20 +08:00
Xubin Ren e6c839ee37 refactor(webui): simplify preset save label 2026-08-29 21:22:20 +08:00
Xubin Ren 97fb9aaf72 fix(providers): complete OAuth model discovery 2026-08-29 21:22:20 +08:00
Xubin Ren bc4de246a4 feat(providers): discover OAuth model catalogs online 2026-08-29 21:22:20 +08:00
Xubin Ren 2389ab1f5a feat(providers): add Grok 4.6 subscription model 2026-08-29 21:22:20 +08:00
Xubin Ren 65f2a6dbf5 fix(webui): hide SkillHub install counts 2026-08-29 15:29:18 +08:00
Xubin Ren caab883f9f fix(webui): preserve named pane groups 2026-08-29 14:22:19 +08:00
Xubin Ren 1fe14f2ee6 fix(cli): preserve root shell completion 2026-08-29 11:26:50 +08:00
Xubin Ren 7fc90ca6aa feat(cli): make nanobot launch the terminal agent 2026-08-29 11:26:50 +08:00
chengyongruandchengyongru 559b2d2e5d test(tui): avoid clipboard status race 2026-08-28 17:14:39 +08:00
chengyongruandchengyongru a339966543 fix(tui): preserve full UI in Herdr panes 2026-08-28 16:36:07 +08:00
chengyongruandGitHub e73cce706c refactor(agent): extract tool execution boundary (#5569)
* refactor(agent): extract tool execution boundary

* test(agent): use extracted tool execution boundary
2026-08-28 13:52:03 +08:00
chengyongruandGitHub cace42af14 refactor(memory): remove consolidation ratio (#5575)
* refactor(memory): remove consolidation ratio

* docs(memory): document fixed consolidation policy

* docs(memory): simplify consolidation overview

* docs(memory): rely on soft wrapping
2026-08-28 13:20:09 +08:00
chengyongruandGitHub 29025f5a8b fix(agent): default request concurrency to unlimited (#5572)
* fix(agent): default request concurrency to unlimited

* test(agent): clarify session serialization coverage
2026-08-27 23:31:38 +08:00
chengyongruandGitHub 3c61fef7e8 refactor(memory): decouple archival from provider state (#5565)
* refactor(memory): decouple archival from provider state

* test(memory): remove obsolete consolidation offset coverage
2026-08-27 21:21:15 +08:00
chengyongruandGitHub 4d204ba077 feat(tui): support pasting clipboard images (#5563)
* feat(tui): support pasting clipboard images

* fix(tui): keep image placeholders atomic

* fix(tui): reconcile duplicate image placeholders

* fix(tui): retain highlighted image placeholders

* fix(tui): preserve image placeholder layout

* fix(tui): keep image display state local

* fix(tui): reject images in commands
2026-08-27 20:37:43 +08:00
chengyongruandGitHub b9e7c7f6fe fix: queue concurrent subagents (#5566)
* fix: queue concurrent subagents

* chore: keep spawn schema concise
2026-08-27 17:53:28 +08:00
chengyongruandGitHub 39de4594d7 refactor(agent): decouple loop from message tool state (#5559)
* refactor(agent): decouple loop from message tool state

* refactor(agent): scope message delivery tracking per run

* refactor(agent): clarify message delivery scope name
2026-08-27 17:23:16 +08:00
chengyongruandchengyongru d6c112ab74 refactor(agent): load MyTool through tool loader 2026-08-27 14:05:21 +08:00
chengyongruandchengyongru 91f5a85db0 fix(agent): complete native reasoning lifecycle 2026-08-27 13:20:04 +08:00
96 changed files with 5367 additions and 2761 deletions
+4 -4
View File
@@ -146,7 +146,7 @@ Activate it with `source .venv/bin/activate` on macOS/Linux or
python -m pip install -e . python -m pip install -e .
``` ```
After that, the normal commands are identical to a stable install. `nanobot agent` runs the TUI After that, the normal commands are identical to a stable install. `nanobot` runs the TUI
from this checkout, and `nanobot webui` rebuilds stale frontend assets automatically. A later from this checkout, and `nanobot webui` rebuilds stale frontend assets automatically. A later
`git pull --ff-only` updates the Python, TUI, and WebUI source together; rerun `git pull --ff-only` updates the Python, TUI, and WebUI source together; rerun
`python -m pip install -e .` when Python dependencies change. Contributors should also read `python -m pip install -e .` when Python dependencies change. Contributors should also read
@@ -206,10 +206,10 @@ Use `nanobot gateway --background` for the same direct entry point without keepi
**Prefer to work entirely in the terminal?** **Prefer to work entirely in the terminal?**
```bash ```bash
nanobot agent nanobot
``` ```
This opens the native terminal client with the launch directory as its workspace. It shares saved conversations and the local gateway with the WebUI. This opens the native terminal client with the launch directory as its workspace. It shares saved conversations and the local gateway with the WebUI. The explicit `nanobot agent` form remains available for compatibility.
- Type `/` to discover commands, `/sessions` to switch conversations, or `@` to mention an app, MCP server, or saved session. - Type `/` to discover commands, `/sessions` to switch conversations, or `@` to mention an app, MCP server, or saved session.
- Press `Enter` to send. While nanobot is working, `Enter` sends now and `Tab` sends after the current response. Press `Shift+Enter` to add a newline (`Ctrl+J` works in terminals that cannot distinguish modified Enter keys). - Press `Enter` to send. While nanobot is working, `Enter` sends now and `Tab` sends after the current response. Press `Shift+Enter` to add a newline (`Ctrl+J` works in terminals that cannot distinguish modified Enter keys).
@@ -220,7 +220,7 @@ Each launch starts a new session by default. Use `--session` to resume one and `
For one request and an immediate exit, use: For one request and an immediate exit, use:
```bash ```bash
nanobot agent -m "Hello!" nanobot -m "Hello!"
``` ```
The one-shot form is useful for a quick provider check, shell scripts, and local automation. If you have not configured a model yet, run `nanobot webui` and open **Settings → Models** first. The one-shot form is useful for a quick provider check, shell scripts, and local automation. If you have not configured a model yet, run `nanobot webui` and open **Settings → Models** first.
+13 -13
View File
@@ -12,8 +12,8 @@ Use this page when you know what you want to run and need the command shape. For
| Use guided setup | `nanobot onboard --wizard` | Best when you prefer prompts over hand-editing JSON | | Use guided setup | `nanobot onboard --wizard` | Best when you prefer prompts over hand-editing JSON |
| Open the browser workbench | `nanobot webui` | Prepares local WebUI settings, starts the gateway, and opens the browser | | Open the browser workbench | `nanobot webui` | Prepares local WebUI settings, starts the gateway, and opens the browser |
| Check readiness without calling a model | `nanobot status` | Summarizes config/workspace and validates the active provider/model configuration | | Check readiness without calling a model | `nanobot status` | Summarizes config/workspace and validates the active provider/model configuration |
| Send one test message | `nanobot agent -m "Hello!"` | First proof that install, config, provider, model, and workspace all work | | Send one test message | `nanobot -m "Hello!"` | First proof that install, config, provider, model, and workspace all work |
| Chat in the terminal | `nanobot agent` | Interactive local chat; exit with `exit`, `/exit`, `:q`, or `Ctrl+D` | | Chat in the terminal | `nanobot` | Interactive local chat; `nanobot agent` remains an explicit alias |
| Run the gateway directly | `nanobot gateway` | Service/ops command for WebUI, chat apps, cron, and heartbeat | | Run the gateway directly | `nanobot gateway` | Service/ops command for WebUI, chat apps, cron, and heartbeat |
| Deliver a local trigger | `nanobot trigger <id> "message"` | Created first with `/trigger <name>` in the target chat/session | | Deliver a local trigger | `nanobot trigger <id> "message"` | Created first with `/trigger <name>` in the target chat/session |
| Serve an OpenAI-compatible API | `nanobot serve` | Starts `/v1/chat/completions`, `/v1/models`, and `/health` | | Serve an OpenAI-compatible API | `nanobot serve` | Starts `/v1/chat/completions`, `/v1/models`, and `/health` |
@@ -86,15 +86,15 @@ follow the printed WebUI **Settings → Models** or `nanobot onboard --wizard` r
| Command | Description | | Command | Description |
|---|---| |---|---|
| `nanobot agent -m "Hello!"` | Send one message and exit | | `nanobot -m "Hello!"` | Send one message and exit |
| `nanobot agent` | Start interactive terminal chat | | `nanobot` | Start interactive terminal chat |
| `nanobot agent --session <id>` | Use a WebSocket session key; add `--classic` for another channel | | `nanobot --session <id>` | Use a WebSocket session key; add `--classic` for another channel |
| `nanobot agent --workspace <path>` | Override workspace | | `nanobot --workspace <path>` | Override workspace |
| `nanobot agent --config <path>` | Use a specific config file | | `nanobot --config <path>` | Use a specific config file |
| `nanobot agent --classic` | Use the classic Python prompt instead of the native terminal UI | | `nanobot --classic` | Use the classic Python prompt instead of the native terminal UI |
| `nanobot agent --theme auto\|dark\|light` | Auto-detect the terminal appearance or force a TUI palette | | `nanobot --theme auto\|dark\|light` | Auto-detect the terminal appearance or force a TUI palette |
| `nanobot agent --no-markdown` | Use the classic prompt and print plain text instead of Markdown | | `nanobot --no-markdown` | Use the classic prompt and print plain text instead of Markdown |
| `nanobot agent --logs` | Use the classic prompt and show runtime logs while chatting | | `nanobot --logs` | Use the classic prompt and show runtime logs while chatting |
Inside the native TUI, `/sessions` switches saved conversations, `/new-chat` starts another saved Inside the native TUI, `/sessions` switches saved conversations, `/new-chat` starts another saved
conversation, and `/context` explains the compacted summary and raw session suffix available to conversation, and `/context` explains the compacted summary and raw session suffix available to
@@ -139,7 +139,7 @@ Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
| Command | Description | | Command | Description |
|---|---| |---|---|
| `nanobot webui` | Create config/workspace if needed, enable the local WebUI channel after confirmation, start the gateway, and open `http://127.0.0.1:8765` | | `nanobot webui` | Create config/workspace if needed, enable the local WebUI channel after confirmation, start the gateway, open `http://127.0.0.1:8765`, and follow new gateway logs |
| `nanobot webui --background` | Deprecated; prints the equivalent explicit `nanobot gateway --background` command and exits | | `nanobot webui --background` | Deprecated; prints the equivalent explicit `nanobot gateway --background` command and exits |
| `nanobot webui --dev` | Start the gateway and Vite together at `http://127.0.0.1:5173`, with live frontend updates | | `nanobot webui --dev` | Start the gateway and Vite together at `http://127.0.0.1:5173`, with live frontend updates |
| `nanobot webui --no-open` | Prepare and start the WebUI without opening a browser | | `nanobot webui --no-open` | Prepare and start the WebUI without opening a browser |
@@ -344,7 +344,7 @@ remain accepted as no-op compatibility aliases.
| Command | Description | | Command | Description |
|---|---| |---|---|
| `nanobot provider login openai-codex --set-main` | Authenticate Codex and select its current default model | | `nanobot provider login openai-codex --set-main` | Authenticate Codex and select its current default model |
| `nanobot provider login xai-grok --set-main` | Authenticate an eligible X Premium / Grok subscription and select Grok 4.5; hosted X Search is enabled for models that advertise support | | `nanobot provider login xai-grok --set-main` | Authenticate an eligible X Premium / Grok subscription and select Grok 4.6; hosted X Search is enabled for models that advertise support |
| `nanobot provider login github-copilot --set-main` | Authenticate GitHub Copilot and select its current default model | | `nanobot provider login github-copilot --set-main` | Authenticate GitHub Copilot and select its current default model |
| `nanobot provider logout openai-codex` | Remove OpenAI Codex OAuth state | | `nanobot provider logout openai-codex` | Remove OpenAI Codex OAuth state |
| `nanobot provider logout xai-grok --config <path>` | Remove the selected nanobot instance's xAI OAuth state | | `nanobot provider logout xai-grok --config <path>` | Remove the selected nanobot instance's xAI OAuth state |
+20 -8
View File
@@ -188,7 +188,7 @@ These variables are process-level switches. Set them in the same terminal, servi
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `NANOBOT_MAX_CONCURRENT_REQUESTS` | `3` | Maximum concurrently running inbound agent requests. Must be an integer; set `0` or a negative value for unlimited. | | `NANOBOT_MAX_CONCURRENT_REQUESTS` | Unlimited | Maximum concurrently running inbound agent requests. Set a positive integer to apply a cap; unset, `0`, or a negative value means unlimited. |
| `NANOBOT_LLM_TIMEOUT_S` | `300` | Wall-clock timeout, in seconds. Ordinary requests use this value; streaming requests use the greater of 300 seconds or twice this value. Set `0` to disable. Sustained-goal turns bypass this wall-clock cap. | | `NANOBOT_LLM_TIMEOUT_S` | `300` | Wall-clock timeout, in seconds. Ordinary requests use this value; streaming requests use the greater of 300 seconds or twice this value. Set `0` to disable. Sustained-goal turns bypass this wall-clock cap. |
| `NANOBOT_STREAM_IDLE_TIMEOUT_S` | `90` | Streaming idle timeout, in seconds, used by streaming providers. Invalid or non-positive values are ignored; values above `3600` are clamped. | | `NANOBOT_STREAM_IDLE_TIMEOUT_S` | `90` | Streaming idle timeout, in seconds, used by streaming providers. Invalid or non-positive values are ignored; values above `3600` are clamped. |
| `NANOBOT_OPENAI_COMPAT_TIMEOUT_S` | `120` | HTTP request timeout, in seconds, for OpenAI-compatible providers. Invalid or non-positive values are ignored. | | `NANOBOT_OPENAI_COMPAT_TIMEOUT_S` | `120` | HTTP request timeout, in seconds, for OpenAI-compatible providers. Invalid or non-positive values are ignored. |
@@ -729,6 +729,11 @@ Then run:
nanobot agent -m "Hello!" nanobot agent -m "Hello!"
``` ```
The WebUI model selector loads the models available to the signed-in account
from Codex's online catalog. Context-window and reasoning-effort metadata come
from that response; if discovery is unavailable, nanobot keeps a small built-in
fallback instead of emptying the selector.
Codex Fast mode can be enabled from the WebUI provider settings, or with: Codex Fast mode can be enabled from the WebUI provider settings, or with:
```json ```json
@@ -764,11 +769,14 @@ nanobot provider login xai-grok --set-main
nanobot agent -m "Hello from Grok." nanobot agent -m "Hello from Grok."
``` ```
The default model is `xai-grok/grok-4.5` with a 500,000-token context window. The default model is `xai-grok/grok-4.6` with a 500,000-token context window.
The provider reads xAI's model catalog and includes the server-hosted `x_search` The provider reads and caches xAI's online model catalog for both WebUI model
tool only when the selected model advertises `supportsBackendSearch`. Models selection and runtime capabilities. Newly available models appear automatically;
without that capability continue normally without hosted X Search. When enabled, when discovery fails, the last successful catalog or built-in fallback remains
searches run inside xAI's Responses API and citations arrive as inline links. available. The server-hosted `x_search` tool is included only when the selected
model advertises support. Models without that capability continue normally
without hosted X Search. When enabled, searches run inside xAI's Responses API
and citations arrive as inline links.
Hosted X Search is on by default to preserve this behavior. It can be turned off in the Hosted X Search is on by default to preserve this behavior. It can be turned off in the
WebUI provider settings or with `providers.xaiGrok.extraBody.tools: []`. WebUI provider settings or with `providers.xaiGrok.extraBody.tools: []`.
@@ -805,6 +813,10 @@ a nanobot update.
GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured. No `providers.github_copilot` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config. GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured. No `providers.github_copilot` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
After login, the WebUI loads the account-specific Copilot model catalog online.
Only models compatible with nanobot's current chat-completions or Responses
transport are shown.
For GitHub Enterprise / Copilot for Business, set the endpoint overrides you need before login: For GitHub Enterprise / Copilot for Business, set the endpoint overrides you need before login:
```bash ```bash
export NANOBOT_GITHUB_COPILOT_CLIENT_ID="your-enterprise-client-id" export NANOBOT_GITHUB_COPILOT_CLIENT_ID="your-enterprise-client-id"
@@ -2213,7 +2225,7 @@ The notification gate runs on a built-in system prompt. Advanced users can overr
## Subagent Concurrency ## Subagent Concurrency
By default, nanobot only allows one spawned subagent at a time. When the limit is reached, the `spawn` tool returns an error so the agent can decide to wait or rearrange its work. This protects local LLM servers from loading multiple KV caches at once. If your provider can handle more parallel work, raise the limit: By default, nanobot allows four subagents to run at the same time. Additional subagents wait for capacity instead of being rejected. Lower the limit if a local model server cannot hold multiple KV caches, or raise it when the provider can handle more parallel work:
```json ```json
{ {
@@ -2229,7 +2241,7 @@ The deprecated `agents.defaults.failOnToolError` field is silently ignored when
| Option | Default | Description | | Option | Default | Description |
|--------|---------|-------------| |--------|---------|-------------|
| `agents.defaults.maxConcurrentSubagents` | `1` | Maximum number of spawned subagents that may run at the same time. Attempts to spawn beyond this limit return an error. | | `agents.defaults.maxConcurrentSubagents` | `4` | Maximum number of subagents that may run at the same time. Additional tasks wait for capacity. |
## Auto Compact ## Auto Compact
+1 -3
View File
@@ -29,9 +29,7 @@ Memory moves through nanobot in two stages.
### Stage 1: Consolidator ### Stage 1: Consolidator
When a conversation grows large enough to pressure the context window, nanobot does not try to carry every old message forever. When a conversation grows large, the `Consolidator` summarizes older turns and appends the result to `memory/history.jsonl`, while keeping recent conversation available. Each summary preserves useful long-term facts and a short handoff for active work.
Instead, the `Consolidator` summarizes the oldest safe slice of the conversation and appends that summary to `memory/history.jsonl`.
This file is: This file is:
+15 -3
View File
@@ -572,15 +572,23 @@ For OpenAI Codex:
nanobot provider login openai-codex --set-main nanobot provider login openai-codex --set-main
``` ```
The WebUI reads the account's Codex model catalog online, including current
context-window and reasoning-effort metadata. A small compatible catalog remains
available when the service cannot be reached.
For an eligible X Premium / Grok subscription: For an eligible X Premium / Grok subscription:
```bash ```bash
nanobot provider login xai-grok --set-main nanobot provider login xai-grok --set-main
``` ```
This selects `xai-grok/grok-4.5`. The provider reads xAI's model catalog and This selects `xai-grok/grok-4.6`. The WebUI model selector reads xAI's online
exposes the hosted `x_search` tool only when the selected model advertises model catalog, so newly available subscription models appear without a nanobot
`supportsBackendSearch`; otherwise the model runs without hosted X Search. release. Online metadata is cached and enriched with nanobot's curated labels;
if xAI is temporarily unavailable, nanobot uses the last successful catalog or
a small built-in fallback instead of emptying the selector. The same catalog
controls whether the provider exposes the hosted `x_search` tool; models that do
not advertise support continue without hosted X Search.
When enabled, Grok can search current X posts and return inline source links When enabled, Grok can search current X posts and return inline source links
without invoking a local nanobot tool. Credentials are stored under the without invoking a local nanobot tool. Credentials are stored under the
active instance's `auth/xai.json` (normally `~/.nanobot/auth/xai.json`), not in active instance's `auth/xai.json` (normally `~/.nanobot/auth/xai.json`), not in
@@ -599,6 +607,10 @@ For GitHub Copilot:
nanobot provider login github-copilot --set-main nanobot provider login github-copilot --set-main
``` ```
The WebUI reads the models enabled for the signed-in Copilot account. nanobot
lists entries that support its current Copilot chat-completions or Responses
transport and hides models that it cannot route safely.
Each command authenticates the selected provider and makes its current default model active. OpenAI Codex and eligible GitHub Copilot models participate in [Responses state retention](./configuration.md#responses-state-and-compaction), while native compaction remains provider-capability-specific. OAuth providers are not valid automatic fallbacks. See [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems) for proxy, headless-login, model-name, and config-key errors. Each command authenticates the selected provider and makes its current default model active. OpenAI Codex and eligible GitHub Copilot models participate in [Responses state retention](./configuration.md#responses-state-and-compaction), while native compaction remains provider-capability-specific. OAuth providers are not valid automatic fallbacks. See [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems) for proxy, headless-login, model-name, and config-key errors.
## Provider Resolution ## Provider Resolution
+3 -3
View File
@@ -103,13 +103,13 @@ Use `nanobot gateway logs`, `restart`, and `stop` to manage that background gate
If you do not want the browser or need to isolate a WebUI problem, send one message directly: If you do not want the browser or need to isolate a WebUI problem, send one message directly:
```bash ```bash
nanobot agent -m "Hello!" nanobot -m "Hello!"
``` ```
Then start an interactive terminal chat with: Then start an interactive terminal chat with:
```bash ```bash
nanobot agent nanobot
``` ```
In interactive mode, `Enter` sends and `Shift+Enter` inserts a newline (`Ctrl+J` is the In interactive mode, `Enter` sends and `Shift+Enter` inserts a newline (`Ctrl+J` is the
@@ -173,7 +173,7 @@ nanobot webui
``` ```
The source path follows current `main` and can be newer than the published package. The editable The source path follows current `main` and can be newer than the published package. The editable
install keeps Python pointed at the checkout; `nanobot agent` runs `tui/` with Bun, and install keeps Python pointed at the checkout; `nanobot` runs `tui/` with Bun, and
`nanobot webui` automatically rebuilds `webui/` when its bundled assets are stale. All normal `nanobot webui` automatically rebuilds `webui/` when its bundled assets are stale. All normal
commands remain the same as a stable install. For development details, follow commands remain the same as a stable install. For development details, follow
[`../CONTRIBUTING.md`](../CONTRIBUTING.md). [`../CONTRIBUTING.md`](../CONTRIBUTING.md).
+3 -1
View File
@@ -23,7 +23,9 @@ one is missing, starts or joins the same on-demand gateway used by the native
TUI, and opens the browser. With a fresh config, TUI, and opens the browser. With a fresh config,
it can open before a model is configured so you can finish setup in **Settings it can open before a model is configured so you can finish setup in **Settings
→ Models**. The first-run path binds the WebUI to `127.0.0.1` by default, so → Models**. The first-run path binds the WebUI to `127.0.0.1` by default, so
it is not available from other devices on your LAN. it is not available from other devices on your LAN. While the launcher remains
attached, it mirrors new log output from that exact gateway instance in the
terminal without replaying older logs.
After model setup, explicitly promote the shared gateway when you do not want to keep a client open: After model setup, explicitly promote the shared gateway when you do not want to keep a client open:
+1 -1
View File
@@ -48,7 +48,7 @@ class AutoCompact:
def _has_unarchived_messages(self, key: str) -> bool: def _has_unarchived_messages(self, key: str) -> bool:
session = self.sessions.get_or_create(key) session = self.sessions.get_or_create(key)
return session.last_consolidated < len(session.messages) return session.last_archived < len(session.messages)
@classmethod @classmethod
def _is_internal_session(cls, key: str) -> bool: def _is_internal_session(cls, key: str) -> bool:
+11 -30
View File
@@ -38,10 +38,9 @@ from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.context import RequestContext, bind_request_context, reset_request_context from nanobot.agent.tools.context import RequestContext, bind_request_context, reset_request_context
from nanobot.agent.tools.exec_session import ExecSessionManager from nanobot.agent.tools.exec_session import ExecSessionManager
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
from nanobot.agent.tools.message import MessageTool from nanobot.agent.tools.message import capture_message_deliveries
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.runtime_control import AgentRuntimeControl from nanobot.agent.tools.runtime_control import AgentRuntimeControl
from nanobot.agent.tools.self import MyTool
from nanobot.agent.turn_delivery import ( from nanobot.agent.turn_delivery import (
TurnDelivery, TurnDelivery,
TurnDeliveryFactory, TurnDeliveryFactory,
@@ -145,7 +144,6 @@ class TurnContext:
final_content: str | None = None final_content: str | None = None
all_messages: list[dict[str, Any]] = field(default_factory=list) all_messages: list[dict[str, Any]] = field(default_factory=list)
stop_reason: str = "" stop_reason: str = ""
had_injections: bool = False
streamed_content: bool = False streamed_content: bool = False
input_persisted_early: bool = False input_persisted_early: bool = False
@@ -275,7 +273,6 @@ class AgentLoop:
channels_config: ChannelsConfig | None = None, channels_config: ChannelsConfig | None = None,
timezone: str | None = None, timezone: str | None = None,
session_ttl_minutes: int = 0, session_ttl_minutes: int = 0,
consolidation_ratio: float = 0.5,
hooks: list[AgentHook] | None = None, hooks: list[AgentHook] | None = None,
hook_factories: list[AgentTurnHookFactory] | None = None, hook_factories: list[AgentTurnHookFactory] | None = None,
unified_session: bool = False, unified_session: bool = False,
@@ -432,8 +429,8 @@ class AgentLoop:
("cron", self._cron_turns), ("cron", self._cron_turns),
("local trigger", self._local_trigger_turns), ("local trigger", self._local_trigger_turns),
) )
# NANOBOT_MAX_CONCURRENT_REQUESTS: <=0 means unlimited; default 3. # NANOBOT_MAX_CONCURRENT_REQUESTS: unset or <=0 means unlimited.
_max = int(os.environ.get("NANOBOT_MAX_CONCURRENT_REQUESTS", "3")) _max = int(os.environ.get("NANOBOT_MAX_CONCURRENT_REQUESTS", "0"))
self._concurrency_gate: asyncio.Semaphore | None = ( self._concurrency_gate: asyncio.Semaphore | None = (
asyncio.Semaphore(_max) if _max > 0 else None asyncio.Semaphore(_max) if _max > 0 else None
) )
@@ -446,7 +443,6 @@ class AgentLoop:
workspace_scopes=self.workspace_scopes, workspace_scopes=self.workspace_scopes,
unified_session=unified_session, unified_session=unified_session,
), ),
consolidation_ratio=consolidation_ratio,
unified_session=unified_session, unified_session=unified_session,
) )
self.auto_compact = AutoCompact( self.auto_compact = AutoCompact(
@@ -519,7 +515,6 @@ class AgentLoop:
disabled_skills=defaults.disabled_skills, disabled_skills=defaults.disabled_skills,
session_ttl_minutes=defaults.session_ttl_minutes, session_ttl_minutes=defaults.session_ttl_minutes,
idle_compact_check_interval_seconds=defaults.idle_compact_check_interval_seconds, idle_compact_check_interval_seconds=defaults.idle_compact_check_interval_seconds,
consolidation_ratio=defaults.consolidation_ratio,
tools_config=config.tools, tools_config=config.tools,
model_presets=preset_helpers.configured_model_presets(config), model_presets=preset_helpers.configured_model_presets(config),
model_preset=defaults.model_preset, model_preset=defaults.model_preset,
@@ -644,20 +639,11 @@ class AgentLoop:
timezone=self.context.timezone or "UTC", timezone=self.context.timezone or "UTC",
workspace_sandbox=self.workspace_scopes.sandbox_status, workspace_sandbox=self.workspace_scopes.sandbox_status,
runtime_events=self.runtime_events, runtime_events=self.runtime_events,
runtime_control=AgentRuntimeControl(self),
) )
loader = ToolLoader() loader = ToolLoader()
registered = loader.load(ctx, self.tools) registered = loader.load(ctx, self.tools)
# MyTool receives only the explicit runtime-control capability.
if self.tools_config.my.enable:
self.tools.register(
MyTool(
runtime_control=AgentRuntimeControl(self),
modify_allowed=self.tools_config.my.allow_set,
)
)
registered.append("my")
logger.info("Registered {} tools: {}", len(registered), registered) logger.info("Registered {} tools: {}", len(registered), registered)
def register_runtime_context_provider( def register_runtime_context_provider(
@@ -1733,18 +1719,12 @@ class AgentLoop:
msg: InboundMessage, msg: InboundMessage,
final_content: str, final_content: str,
stop_reason: str, stop_reason: str,
had_injections: bool,
streamed_content: bool, streamed_content: bool,
*, *,
log_content: bool = True, log_content: bool = True,
turn_latency_ms: int | None = None, turn_latency_ms: int | None = None,
) -> OutboundMessage | None: ) -> OutboundMessage | None:
"""Assemble the final outbound message from turn results.""" """Assemble the final outbound message from turn results."""
# MessageTool suppression
if (mt := self.tools.get("message")) and isinstance(mt, MessageTool) and mt._sent_in_turn:
if not had_injections or stop_reason == "empty_final_response":
return None
if log_content: if log_content:
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
@@ -1900,10 +1880,6 @@ class AgentLoop:
) )
is_subagent = ctx.kind is TurnKind.SYSTEM and ctx.msg.sender_id == "subagent" is_subagent = ctx.kind is TurnKind.SYSTEM and ctx.msg.sender_id == "subagent"
if ctx.kind is TurnKind.USER and (message_tool := self.tools.get("message")):
if isinstance(message_tool, MessageTool):
message_tool.start_turn()
_hist_kwargs: dict[str, Any] = { _hist_kwargs: dict[str, Any] = {
"max_tokens": self._replay_token_budget(runtime), "max_tokens": self._replay_token_budget(runtime),
"extend_to_user": is_subagent, "extend_to_user": is_subagent,
@@ -2004,6 +1980,7 @@ class AgentLoop:
if ctx.visible_run_started_at is None: if ctx.visible_run_started_at is None:
ctx.visible_run_started_at = time.time() ctx.visible_run_started_at = time.time()
await ctx.delivery.running(started_at=ctx.visible_run_started_at) await ctx.delivery.running(started_at=ctx.visible_run_started_at)
with capture_message_deliveries() as message_sends:
result = await self._run_agent_loop( result = await self._run_agent_loop(
ctx.initial_messages, ctx.initial_messages,
runtime=runtime, runtime=runtime,
@@ -2025,7 +2002,12 @@ class AgentLoop:
ctx.final_content = result.final_content ctx.final_content = result.final_content
ctx.all_messages = result.messages ctx.all_messages = result.messages
ctx.stop_reason = result.stop_reason ctx.stop_reason = result.stop_reason
ctx.had_injections = result.had_injections if (
ctx.kind is TurnKind.USER
and (ctx.delivery.route.channel, ctx.delivery.route.chat_id) in message_sends
and (not result.had_injections or result.stop_reason == "empty_final_response")
):
ctx.suppress_response = True
ctx.usage = result.usage ctx.usage = result.usage
ctx.delivery.record_usage(ctx.usage) ctx.delivery.record_usage(ctx.usage)
if ctx.kind is TurnKind.USER: if ctx.kind is TurnKind.USER:
@@ -2094,7 +2076,6 @@ class AgentLoop:
ctx.delivery.delivery_message, ctx.delivery.delivery_message,
cast(str, ctx.final_content), cast(str, ctx.final_content),
ctx.stop_reason, ctx.stop_reason,
ctx.had_injections,
ctx.streamed_content, ctx.streamed_content,
log_content=ctx.require_session().policy.log_content, log_content=ctx.require_session().policy.log_content,
turn_latency_ms=ctx.turn_latency_ms, turn_latency_ms=ctx.turn_latency_ms,
+205 -179
View File
@@ -1,4 +1,4 @@
"""Memory system: pure file I/O store and lightweight Consolidator.""" """Memory storage, transcript archiving, and legacy consolidation coordination."""
# Tool schemas are installed by the ``@tool_parameters`` class decorator at # Tool schemas are installed by the ``@tool_parameters`` class decorator at
# runtime; static analyzers cannot observe that it clears ``parameters`` from # runtime; static analyzers cannot observe that it clears ``parameters`` from
@@ -32,7 +32,6 @@ from nanobot.utils.gitstore import GitStore
from nanobot.utils.helpers import ( from nanobot.utils.helpers import (
content_with_media_breadcrumbs, content_with_media_breadcrumbs,
ensure_dir, ensure_dir,
estimate_message_tokens,
estimate_prompt_tokens_chain, estimate_prompt_tokens_chain,
strip_think, strip_think,
truncate_text, truncate_text,
@@ -785,7 +784,7 @@ class MemoryStore:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Consolidator — lightweight token-budget triggered consolidation # Memory ingestion and legacy context-pressure coordination
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Individual history.jsonl writers cap their own payloads tightly; the # Individual history.jsonl writers cap their own payloads tightly; the
@@ -796,10 +795,165 @@ _ARCHIVE_SUMMARY_MAX_CHARS = 8_000 # LLM-produced consolidation summary
_HISTORY_ENTRY_HARD_CAP = 64_000 # emergency cap in append_history _HISTORY_ENTRY_HARD_CAP = 64_000 # emergency cap in append_history
class Consolidator: class MemoryArchiver:
"""Summarize compacted messages into history.jsonl.""" """Write durable transcript batches to the Memory ingestion journal.
_MAX_CONSOLIDATION_ROUNDS = 5 The archiver deliberately has no SessionManager dependency: it may read a
captured transcript batch and append to history.jsonl, but it cannot mutate
provider continuation state or advance a session watermark.
"""
def __init__(
self,
store: MemoryStore,
build_messages: Callable[..., list[dict[str, Any]]],
get_tool_definitions: Callable[[], list[dict[str, Any]]],
resolve_prompt_context: Callable[[Session], tuple[str | None, Path | None]] | None = None,
unified_session: bool = False,
) -> None:
self.store = store
self._build_messages = build_messages
self._get_tool_definitions = get_tool_definitions
self._resolve_prompt_context = resolve_prompt_context
self.unified_session = unified_session
async def archive(
self,
messages: list[dict[str, Any]],
*,
runtime: LLMRuntime,
session_key: str,
request_messages: list[dict[str, Any]],
request_tools: list[dict[str, Any]],
) -> str | None:
"""Execute a prepared archive request and persist its result."""
if not messages:
return None
try:
with llm_usage_source("dream"):
response = await runtime.provider.chat_with_retry(
model=runtime.model,
messages=request_messages,
tools=request_tools,
tool_choice="none",
temperature=runtime.generation.temperature,
max_tokens=runtime.generation.max_tokens,
reasoning_effort=runtime.generation.reasoning_effort,
)
except Exception:
logger.warning("Memory archive provider call failed, raw-dumping to history")
self.store.raw_archive(messages, session_key=session_key)
return None
if response.finish_reason in {"error", "length"}:
logger.warning(
"Memory archive provider did not complete ({}), raw-dumping to history",
response.finish_reason,
)
self.store.raw_archive(messages, session_key=session_key)
return None
if response.has_tool_calls is True:
logger.warning("Memory archive provider returned tool calls, raw-dumping to history")
self.store.raw_archive(messages, session_key=session_key)
return None
summary = response.content
if not summary or not summary.strip():
logger.warning("Memory archive provider returned no summary, raw-dumping to history")
self.store.raw_archive(messages, session_key=session_key)
return None
if summary.strip() == "(nothing)":
return "(nothing)"
self.store.append_history(
summary,
max_chars=_ARCHIVE_SUMMARY_MAX_CHARS,
session_key=session_key,
)
return summary
async def archive_session(
self,
session: Session,
*,
archive_end: int,
runtime: LLMRuntime,
input_token_budget: int,
) -> str | None:
"""Archive a captured session prefix without mutating the session."""
messages = list(session.messages[session.last_archived:archive_end])
if not messages:
return None
if input_token_budget <= 0:
logger.debug(
"Memory archive has no safe input budget for {}; raw-dumping",
session.key,
)
self.store.raw_archive(messages, session_key=session.key)
return None
prefix = Session(
key=session.key,
messages=list(session.messages[:archive_end]),
last_consolidated=session.last_archived,
)
history = prefix.get_history(max_tokens=input_token_budget)
archive_history = Session(
key=session.key,
messages=messages,
).get_history()
if not archive_history or history[-len(archive_history):] != archive_history:
logger.debug(
"Memory archive cannot replay the full chunk for {}; raw-dumping",
session.key,
)
self.store.raw_archive(messages, session_key=session.key)
return None
prompt = render_template(
"agent/consolidator_archive.md",
strip=True,
archive_count=len(archive_history),
)
channel = session.key.split(":", 1)[0] if ":" in session.key else None
workspace: Path | None = None
if self._resolve_prompt_context is not None:
channel, workspace = self._resolve_prompt_context(session)
request_messages = self._build_messages(
history=history,
current_message=prompt,
channel=channel,
session_summary=session_summary_from_metadata(
session.metadata,
fallback_last_active=session.updated_at,
),
workspace=workspace,
session_key=session.key,
unified_session=self.unified_session,
)
tools = self._get_tool_definitions()
estimated, source = estimate_prompt_tokens_chain(
runtime.provider,
runtime.model,
request_messages,
tools,
)
if estimated > input_token_budget:
logger.debug(
"Memory archive prefix exceeds budget for {}; raw-dumping: {}/{} via {}",
session.key,
estimated,
input_token_budget,
source,
)
self.store.raw_archive(messages, session_key=session.key)
return None
return await self.archive(
messages,
runtime=runtime,
session_key=session.key,
request_messages=request_messages,
request_tools=tools,
)
class Consolidator:
"""Legacy context-pressure coordinator backed by a MemoryArchiver."""
_SAFETY_BUFFER = 1024 # extra headroom for tokenizer estimation drift _SAFETY_BUFFER = 1024 # extra headroom for tokenizer estimation drift
@@ -810,16 +964,21 @@ class Consolidator:
build_messages: Callable[..., list[dict[str, Any]]], build_messages: Callable[..., list[dict[str, Any]]],
get_tool_definitions: Callable[[], list[dict[str, Any]]], get_tool_definitions: Callable[[], list[dict[str, Any]]],
resolve_prompt_context: Callable[[Session], tuple[str | None, Path | None]] | None = None, resolve_prompt_context: Callable[[Session], tuple[str | None, Path | None]] | None = None,
consolidation_ratio: float = 0.5,
unified_session: bool = False, unified_session: bool = False,
): ):
self.store = store self.store = store
self.sessions = sessions self.sessions = sessions
self.consolidation_ratio = consolidation_ratio
self.unified_session = unified_session self.unified_session = unified_session
self._build_messages = build_messages self._build_messages = build_messages
self._get_tool_definitions = get_tool_definitions self._get_tool_definitions = get_tool_definitions
self._resolve_prompt_context = resolve_prompt_context self._resolve_prompt_context = resolve_prompt_context
self.archiver = MemoryArchiver(
store=store,
build_messages=build_messages,
get_tool_definitions=get_tool_definitions,
resolve_prompt_context=resolve_prompt_context,
unified_session=unified_session,
)
self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = ( self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
weakref.WeakValueDictionary() weakref.WeakValueDictionary()
) )
@@ -831,24 +990,19 @@ class Consolidator:
def pick_consolidation_boundary( def pick_consolidation_boundary(
self, self,
session: Session, session: Session,
tokens_to_remove: int, ) -> int | None:
) -> tuple[int, int] | None: """Return the fixed user-led boundary before the recent replay tail."""
"""Pick a user-turn boundary that removes enough old prompt tokens.""" if not session.messages:
start = session.last_consolidated
if start >= len(session.messages) or tokens_to_remove <= 0:
return None return None
boundary = max(0, len(session.messages) - MIN_COMPACTED_REPLAY_MESSAGES)
removed_tokens = 0 while boundary > 0 and session.messages[boundary].get("role") != "user":
last_boundary: tuple[int, int] | None = None boundary -= 1
for idx in range(start, len(session.messages)): if (
message = session.messages[idx] boundary <= session.last_archived
if idx > start and message.get("role") == "user": or session.messages[boundary].get("role") != "user"
last_boundary = (idx, removed_tokens) ):
if removed_tokens >= tokens_to_remove: return None
return last_boundary return boundary
removed_tokens += estimate_message_tokens(message)
return last_boundary
@staticmethod @staticmethod
def _full_replay_history( def _full_replay_history(
@@ -912,48 +1066,14 @@ class Consolidator:
request_messages: list[dict[str, Any]], request_messages: list[dict[str, Any]],
request_tools: list[dict[str, Any]], request_tools: list[dict[str, Any]],
) -> str | None: ) -> str | None:
"""Execute a prepared consolidation request and persist its result.""" """Compatibility wrapper for the extracted MemoryArchiver."""
if not messages: return await self.archiver.archive(
return None messages,
try: runtime=runtime,
with llm_usage_source("dream"):
response = await runtime.provider.chat_with_retry(
model=runtime.model,
messages=request_messages,
tools=request_tools,
tool_choice="none",
temperature=runtime.generation.temperature,
max_tokens=runtime.generation.max_tokens,
reasoning_effort=runtime.generation.reasoning_effort,
)
except Exception:
logger.warning("Consolidation provider call failed, raw-dumping to history")
self.store.raw_archive(messages, session_key=session_key)
return None
if response.finish_reason in {"error", "length"}:
logger.warning(
"Consolidation provider did not complete ({}), raw-dumping to history",
response.finish_reason,
)
self.store.raw_archive(messages, session_key=session_key)
return None
if response.has_tool_calls is True:
logger.warning("Consolidation provider returned tool calls, raw-dumping to history")
self.store.raw_archive(messages, session_key=session_key)
return None
summary = response.content
if not summary or not summary.strip():
logger.warning("Consolidation provider returned no summary, raw-dumping to history")
self.store.raw_archive(messages, session_key=session_key)
return None
if summary.strip() == "(nothing)":
return "(nothing)"
self.store.append_history(
summary,
max_chars=_ARCHIVE_SUMMARY_MAX_CHARS,
session_key=session_key, session_key=session_key,
request_messages=request_messages,
request_tools=request_tools,
) )
return summary
async def archive_session( async def archive_session(
self, self,
@@ -962,82 +1082,12 @@ class Consolidator:
archive_end: int, archive_end: int,
runtime: LLMRuntime, runtime: LLMRuntime,
) -> str | None: ) -> str | None:
"""Archive a session prefix by appending a consolidation instruction.""" """Compatibility wrapper for the extracted MemoryArchiver."""
messages = list(session.messages[session.last_consolidated:archive_end]) return await self.archiver.archive_session(
if not messages: session,
return None archive_end=archive_end,
budget = self._input_token_budget(runtime)
if budget <= 0:
logger.debug(
"Consolidation has no safe input budget for {}; raw-dumping",
session.key,
)
self.store.raw_archive(messages, session_key=session.key)
return None
prefix = Session(
key=session.key,
messages=list(session.messages[:archive_end]),
last_consolidated=session.last_consolidated,
)
history = prefix.get_history(max_tokens=budget)
archive_history = Session(
key=session.key,
messages=messages,
).get_history()
if (
not archive_history
or history[-len(archive_history):] != archive_history
):
logger.debug(
"Consolidation cannot replay the full chunk for {}; raw-dumping",
session.key,
)
self.store.raw_archive(messages, session_key=session.key)
return None
prompt = render_template(
"agent/consolidator_archive.md",
strip=True,
archive_count=len(archive_history),
)
channel = session.key.split(":", 1)[0] if ":" in session.key else None
workspace: Path | None = None
if self._resolve_prompt_context is not None:
channel, workspace = self._resolve_prompt_context(session)
request_messages = self._build_messages(
history=history,
current_message=prompt,
channel=channel,
session_summary=session_summary_from_metadata(
session.metadata,
fallback_last_active=session.updated_at,
),
workspace=workspace,
session_key=session.key,
unified_session=self.unified_session,
)
tools = self._get_tool_definitions()
estimated, source = estimate_prompt_tokens_chain(
runtime.provider,
runtime.model,
request_messages,
tools,
)
if estimated > budget:
logger.debug(
"Consolidation prefix exceeds budget for {}; raw-dumping: {}/{} via {}",
session.key,
estimated,
budget,
source,
)
self.store.raw_archive(messages, session_key=session.key)
return None
return await self.archive(
messages,
runtime=runtime, runtime=runtime,
session_key=session.key, input_token_budget=self._input_token_budget(runtime),
request_messages=request_messages,
request_tools=tools,
) )
async def maybe_consolidate_by_tokens( async def maybe_consolidate_by_tokens(
@@ -1046,7 +1096,7 @@ class Consolidator:
*, *,
runtime: LLMRuntime, runtime: LLMRuntime,
) -> None: ) -> None:
"""Loop: archive old messages until prompt fits within safe budget. """Archive one fixed old prefix when the prompt exceeds the safe budget.
The budget reserves space for completion tokens and a safety buffer The budget reserves space for completion tokens and a safety buffer
so the LLM request never exceeds the context window. so the LLM request never exceeds the context window.
@@ -1064,7 +1114,6 @@ class Consolidator:
return return
budget = self._input_token_budget(runtime) budget = self._input_token_budget(runtime)
target = int(budget * self.consolidation_ratio)
last_summary: str | None = None last_summary: str | None = None
estimated, source = self.estimate_session_prompt_tokens( estimated, source = self.estimate_session_prompt_tokens(
session, session,
@@ -1074,40 +1123,32 @@ class Consolidator:
self._persist_last_summary(session, last_summary) self._persist_last_summary(session, last_summary)
return return
if estimated < budget: if estimated < budget:
unconsolidated_count = len(session.messages) - session.last_consolidated unarchived_count = len(session.messages) - session.last_archived
logger.debug( logger.debug(
"Token consolidation idle {}: {}/{} via {}, msgs={}", "Token consolidation idle {}: {}/{} via {}, msgs={}",
session.key, session.key,
estimated, estimated,
runtime.context_window_tokens, runtime.context_window_tokens,
source, source,
unconsolidated_count, unarchived_count,
) )
self._persist_last_summary(session, last_summary) self._persist_last_summary(session, last_summary)
return return
for round_num in range(self._MAX_CONSOLIDATION_ROUNDS): end_idx = self.pick_consolidation_boundary(session)
if estimated <= target: if end_idx is None:
break
boundary = self.pick_consolidation_boundary(session, max(1, estimated - target))
if boundary is None:
logger.debug( logger.debug(
"Token consolidation: no safe boundary for {} (round {})", "Token consolidation: no safe fixed boundary for {}",
session.key, session.key,
round_num,
) )
break return
end_idx = boundary[0] chunk = session.messages[session.last_archived:end_idx]
chunk = session.messages[session.last_consolidated:end_idx]
if not chunk: if not chunk:
break return
logger.info( logger.info(
"Token consolidation round {} for {}: {}/{} via {}, chunk={} msgs", "Token consolidation for {}: {}/{} via {}, chunk={} msgs",
round_num,
session.key, session.key,
estimated, estimated,
runtime.context_window_tokens, runtime.context_window_tokens,
@@ -1119,26 +1160,12 @@ class Consolidator:
archive_end=end_idx, archive_end=end_idx,
runtime=runtime, runtime=runtime,
) )
# Advance the cursor either way: on success the chunk was # Advance either way: archive_session raw-archives on degradation,
# summarized; on failure archive_session() raw-archived it as # and replaying the same chunk would duplicate Memory material.
# a breadcrumb. Re-archiving the same chunk on the next call
# would just emit duplicate [RAW] entries.
if summary: if summary:
last_summary = summary last_summary = summary
session.last_consolidated = end_idx session.last_archived = end_idx
session.provider_state = None
self.sessions.save(session) self.sessions.save(session)
if not summary:
# LLM is degraded — stop hammering it this call;
# the next invocation can retry a fresh chunk.
break
estimated, source = self.estimate_session_prompt_tokens(
session,
runtime=runtime,
)
if estimated <= 0:
break
# Persist the last summary to session metadata so it can be injected # Persist the last summary to session metadata so it can be injected
# into the runtime context on the next prepare_session() call, aligning # into the runtime context on the next prepare_session() call, aligning
@@ -1170,7 +1197,7 @@ class Consolidator:
self.sessions.invalidate(session_key) self.sessions.invalidate(session_key)
session = self.sessions.get_or_create(session_key) session = self.sessions.get_or_create(session_key)
archive_start = session.last_consolidated archive_start = session.last_archived
messages_to_archive = list(session.messages[archive_start:]) messages_to_archive = list(session.messages[archive_start:])
if not messages_to_archive: if not messages_to_archive:
return "" return ""
@@ -1191,8 +1218,7 @@ class Consolidator:
# A turn can append while the provider call is in flight. Advance only # A turn can append while the provider call is in flight. Advance only
# through the captured batch so new messages remain eligible next time. # through the captured batch so new messages remain eligible next time.
session.last_consolidated = archive_end session.last_archived = archive_end
session.provider_state = None
self.sessions.save(session) self.sessions.save(session)
visible = session.get_history( visible = session.get_history(
+23 -283
View File
@@ -19,7 +19,8 @@ from nanobot.agent.context_governance import (
ContextGovernor, ContextGovernor,
) )
from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext
from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result from nanobot.agent.tools.execution import execute_tool_calls
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.llm_usage.context import ( from nanobot.llm_usage.context import (
LLMUsageSource, LLMUsageSource,
bind_llm_usage_source, bind_llm_usage_source,
@@ -32,7 +33,6 @@ from nanobot.providers.base import (
LLMUsage, LLMUsage,
ProviderCallContext, ProviderCallContext,
ProviderConversationState, ProviderConversationState,
ToolCallRequest,
) )
from nanobot.providers.conversation_state import ( from nanobot.providers.conversation_state import (
ProviderConversationStateController, ProviderConversationStateController,
@@ -60,8 +60,6 @@ from nanobot.utils.runtime import (
build_finalization_retry_message, build_finalization_retry_message,
build_length_recovery_message, build_length_recovery_message,
is_blank_text, is_blank_text,
repeated_external_lookup_error,
repeated_workspace_violation_error,
) )
ContinuationCallback = Callable[[], str | None] ContinuationCallback = Callable[[], str | None]
@@ -586,13 +584,14 @@ class AgentRunner:
await hook.before_execute_tools(context) await hook.before_execute_tools(context)
results, new_events = await self._execute_tools( results, new_events = await execute_tool_calls(
spec, spec.tools,
response.tool_calls, response.tool_calls,
external_lookup_counts, concurrent=spec.concurrent_tools,
workspace_violation_counts, external_lookup_counts=external_lookup_counts,
hook, workspace_violation_counts=workspace_violation_counts,
context, hook=hook,
context=context,
) )
tool_events.extend(new_events) tool_events.extend(new_events)
tools_used.extend( tools_used.extend(
@@ -949,6 +948,7 @@ class AgentRunner:
wants_streaming = hook.wants_streaming() wants_streaming = hook.wants_streaming()
active_hosted_tools: dict[str, dict[str, Any]] = {} active_hosted_tools: dict[str, dict[str, Any]] = {}
native_reasoning_open = False
request_started_at = 0.0 request_started_at = 0.0
first_output_at: float | None = None first_output_at: float | None = None
generation_started_at: float | None = None generation_started_at: float | None = None
@@ -971,9 +971,17 @@ class AgentRunner:
generation_elapsed_s += max(0.0, time.perf_counter() - generation_started_at) generation_elapsed_s += max(0.0, time.perf_counter() - generation_started_at)
generation_started_at = None generation_started_at = None
async def _close_native_reasoning() -> None:
nonlocal native_reasoning_open
if not native_reasoning_open:
return
native_reasoning_open = False
await hook.emit_reasoning_end()
async def _provider_tool_event(event: dict[str, Any]) -> None: async def _provider_tool_event(event: dict[str, Any]) -> None:
if event.get("kind") != "hosted_tool": if event.get("kind") != "hosted_tool":
return return
await _close_native_reasoning()
await hook.on_provider_tool_event(context, event) await hook.on_provider_tool_event(context, event)
call_id = event.get("call_id") call_id = event.get("call_id")
if not call_id: if not call_id:
@@ -991,10 +999,11 @@ class AgentRunner:
_generation_delta(delta) _generation_delta(delta)
if delta: if delta:
context.streamed_content = True context.streamed_content = True
await _close_native_reasoning()
await hook.on_stream(context, delta) await hook.on_stream(context, delta)
async def _thinking(delta: str) -> None: async def _thinking(delta: str) -> None:
nonlocal thinking_buf nonlocal native_reasoning_open, thinking_buf
if not delta: if not delta:
return return
_generation_delta(delta) _generation_delta(delta)
@@ -1004,10 +1013,12 @@ class AgentRunner:
incremental = new_clean[len(prev_clean):] incremental = new_clean[len(prev_clean):]
if incremental: if incremental:
context.streamed_reasoning = True context.streamed_reasoning = True
native_reasoning_open = True
await hook.emit_reasoning(incremental) await hook.emit_reasoning(incremental)
async def _stream_recover() -> None: async def _stream_recover() -> None:
_pause_generation() _pause_generation()
await _close_native_reasoning()
await hook.on_stream_end(context, resuming=True) await hook.on_stream_end(context, resuming=True)
coro = spec.runtime.provider.chat_stream_with_retry( coro = spec.runtime.provider.chat_stream_with_retry(
@@ -1054,6 +1065,7 @@ class AgentRunner:
error_kind="timeout", error_kind="timeout",
) )
_pause_generation() _pause_generation()
await _close_native_reasoning()
if first_output_at is not None: if first_output_at is not None:
response.ttft_ms = max(0, round((first_output_at - request_started_at) * 1000)) response.ttft_ms = max(0, round((first_output_at - request_started_at) * 1000))
if generation_elapsed_s > 0: if generation_elapsed_s > 0:
@@ -1372,253 +1384,6 @@ class AgentRunner:
return left return left
return left + right return left + right
async def _execute_tools(
self,
spec: AgentRunSpec,
tool_calls: list[ToolCallRequest],
external_lookup_counts: dict[str, int],
workspace_violation_counts: dict[str, int],
hook: AgentHook | None = None,
context: AgentHookContext | None = None,
) -> tuple[list[Any], list[dict[str, str]]]:
hook = hook or AgentHook()
context = context or AgentHookContext(iteration=0, messages=[])
batches = self._partition_tool_batches(spec, tool_calls)
tool_results: list[tuple[Any, dict[str, str]]] = []
for batch in batches:
if spec.concurrent_tools and len(batch) > 1:
batch_results = await asyncio.gather(*(
self._run_tool(
spec,
tool_call,
external_lookup_counts,
workspace_violation_counts,
hook,
context,
)
for tool_call in batch
))
tool_results.extend(batch_results)
else:
batch_results: list[tuple[Any, dict[str, str]]] = []
for tool_call in batch:
result = await self._run_tool(
spec,
tool_call,
external_lookup_counts,
workspace_violation_counts,
hook,
context,
)
tool_results.append(result)
batch_results.append(result)
results: list[Any] = []
events: list[dict[str, str]] = []
for result, event in tool_results:
results.append(result)
events.append(event)
return results, events
async def _run_tool(
self,
spec: AgentRunSpec,
tool_call: ToolCallRequest,
external_lookup_counts: dict[str, int],
workspace_violation_counts: dict[str, int],
hook: AgentHook | None = None,
context: AgentHookContext | None = None,
) -> tuple[Any, dict[str, str]]:
hook = hook or AgentHook()
context = context or AgentHookContext(iteration=0, messages=[])
hint = "\n\n[Analyze the error above and try a different approach.]"
lookup_error = repeated_external_lookup_error(
tool_call.name,
tool_call.arguments,
external_lookup_counts,
)
if lookup_error:
event = {
"name": tool_call.name,
"status": "error",
"detail": "repeated external lookup blocked",
}
return lookup_error + hint, event
prepare_call = cast(
Callable[[str, Any], object] | None,
getattr(spec.tools, "prepare_call", None),
)
tool, params, prep_error = None, tool_call.arguments, None
if callable(prepare_call):
prepared = prepare_call(tool_call.name, tool_call.arguments)
if isinstance(prepared, tuple):
prepared_tuple = cast(tuple[object, ...], prepared)
if len(prepared_tuple) == 3:
tool, params, prep_error = cast(tuple[Any, Any, str | None], prepared_tuple)
if prep_error:
event = {
"name": tool_call.name,
"status": "error",
"detail": prep_error.split(": ", 1)[-1][:120],
}
handled = self._classify_violation(
raw_text=prep_error,
soft_payload=prep_error + hint,
event=event,
tool_call=tool_call,
workspace_violation_counts=workspace_violation_counts,
)
if handled is not None:
return handled
return prep_error + hint, event
await hook.before_execute_tool(context, tool_call, tool, params)
try:
if tool is not None:
result = await tool.execute(**params)
else:
result = await spec.tools.execute(tool_call.name, params)
except asyncio.CancelledError:
raise
except Exception as exc:
await hook.on_execute_tool_error(context, tool_call, tool, params, exc)
event = {
"name": tool_call.name,
"status": "error",
"detail": str(exc),
}
payload = f"Error: {type(exc).__name__}: {exc}"
handled = self._classify_violation(
raw_text=str(exc),
# Preserve legacy exception payloads without the retry hint.
soft_payload=payload,
event=event,
tool_call=tool_call,
workspace_violation_counts=workspace_violation_counts,
)
if handled is not None:
return handled
return payload, event
if is_tool_error_result(result):
await hook.on_execute_tool_error(context, tool_call, tool, params, result)
event = {
"name": tool_call.name,
"status": "error",
"detail": result.replace("\n", " ").strip()[:120],
}
handled = self._classify_violation(
raw_text=result,
soft_payload=result + hint,
event=event,
tool_call=tool_call,
workspace_violation_counts=workspace_violation_counts,
)
if handled is not None:
return handled
return result + hint, event
await hook.after_execute_tool(context, tool_call, tool, params, result)
detail = "" if result is None else str(result)
detail = detail.replace("\n", " ").strip()
if not detail:
detail = "(empty)"
elif len(detail) > 120:
detail = detail[:120] + "..."
return result, {"name": tool_call.name, "status": "ok", "detail": detail}
# SSRF is a hard security block at the tool boundary, but the agent turn
# should recover conversationally instead of aborting the runtime.
_SSRF_MARKERS: tuple[str, ...] = (
"internal/private url detected",
"private/internal address",
"private address",
)
_SSRF_BOUNDARY_NOTE: str = (
"This is a non-bypassable security boundary. Stop trying to access "
"private/internal URLs. Do not retry with curl, wget, encoded IPs, "
"alternate DNS, redirects, proxies, or another tool. Ask the user for "
"local files, logs, screenshots, or an explicit safe public URL instead. "
"If the user explicitly trusts this private URL, ask them to whitelist "
"the exact IP/CIDR via tools.ssrfWhitelist."
)
# Non-SSRF boundary markers returned to the LLM as recoverable tool errors.
_WORKSPACE_VIOLATION_MARKERS: tuple[str, ...] = (
"outside the configured workspace",
"outside allowed directory",
"working_dir is outside",
"working_dir could not be resolved",
"path outside working dir",
"path traversal detected",
)
@classmethod
def _is_ssrf_violation(cls, text: str) -> bool:
if not text:
return False
lowered = text.lower()
return any(marker in lowered for marker in cls._SSRF_MARKERS)
@classmethod
def _is_workspace_violation(cls, text: str) -> bool:
"""True when *text* looks like any policy boundary rejection."""
if not text:
return False
lowered = text.lower()
if cls._is_ssrf_violation(lowered):
return True
return any(marker in lowered for marker in cls._WORKSPACE_VIOLATION_MARKERS)
def _classify_violation(
self,
*,
raw_text: str,
soft_payload: str,
event: dict[str, str],
tool_call: ToolCallRequest,
workspace_violation_counts: dict[str, int],
) -> tuple[Any, dict[str, str]] | None:
"""Classify safety-boundary failures, or return ``None`` to pass through."""
if self._is_ssrf_violation(raw_text):
logger.warning(
"Tool {} blocked by SSRF guard; returning non-retryable tool error: {}",
tool_call.name,
raw_text.replace("\n", " ").strip()[:200],
)
event["detail"] = self._event_detail("ssrf_violation: ", raw_text)
return self._ssrf_soft_payload(raw_text), event
if self._is_workspace_violation(raw_text):
escalation = repeated_workspace_violation_error(
tool_call.name,
tool_call.arguments,
workspace_violation_counts,
)
event["detail"] = self._event_detail("workspace_violation: ", raw_text)
if escalation is not None:
logger.warning(
"Tool {} hit workspace boundary repeatedly; escalating hint",
tool_call.name,
)
event["detail"] = self._event_detail(
"workspace_violation_escalated: ",
raw_text,
)
return escalation, event
return soft_payload, event
return None
@classmethod
def _ssrf_soft_payload(cls, raw_text: str) -> str:
text = raw_text.strip() or "Error: request blocked by SSRF guard"
return f"{text}\n\n{cls._SSRF_BOUNDARY_NOTE}"
@staticmethod
def _event_detail(prefix: str, text: str, limit: int = 160) -> str:
return (prefix + text.replace("\n", " ").strip())[:limit]
async def _emit_checkpoint( async def _emit_checkpoint(
self, self,
spec: AgentRunSpec, spec: AgentRunSpec,
@@ -1648,28 +1413,3 @@ class AgentRunner:
if messages and messages[-1].get("role") == "assistant" and not messages[-1].get("tool_calls"): if messages and messages[-1].get("role") == "assistant" and not messages[-1].get("tool_calls"):
return return
messages.append(build_assistant_message(_PERSISTED_MODEL_ERROR_PLACEHOLDER)) messages.append(build_assistant_message(_PERSISTED_MODEL_ERROR_PLACEHOLDER))
def _partition_tool_batches(
self,
spec: AgentRunSpec,
tool_calls: list[ToolCallRequest],
) -> list[list[ToolCallRequest]]:
if not spec.concurrent_tools:
return [[tool_call] for tool_call in tool_calls]
batches: list[list[ToolCallRequest]] = []
current: list[ToolCallRequest] = []
for tool_call in tool_calls:
get_tool = cast(Callable[[str], Any] | None, getattr(spec.tools, "get", None))
tool = get_tool(tool_call.name) if callable(get_tool) else None
can_batch = bool(tool and tool.concurrency_safe)
if can_batch:
current.append(tool_call)
continue
if current:
batches.append(current)
current = []
batches.append([tool_call])
if current:
batches.append(current)
return batches
+32 -1
View File
@@ -55,7 +55,8 @@ class SubagentStatus:
label: str label: str
task_description: str task_description: str
started_at: float # time.monotonic() started_at: float # time.monotonic()
phase: str = "initializing" # initializing | awaiting_tools | tools_completed | final_response | done | error # queued | initializing | awaiting_tools | tools_completed | final_response | done | error
phase: str = "initializing"
iteration: int = 0 iteration: int = 0
tool_events: list[dict[str, str]] = field(default_factory=list) tool_events: list[dict[str, str]] = field(default_factory=list)
usage: LLMUsage | None = None usage: LLMUsage | None = None
@@ -147,6 +148,7 @@ class SubagentManager:
if max_concurrent_subagents is not None if max_concurrent_subagents is not None
else defaults.max_concurrent_subagents else defaults.max_concurrent_subagents
) )
self._run_slots = asyncio.Semaphore(self.max_concurrent_subagents)
self.runner = AgentRunner() self.runner = AgentRunner()
self._exec_session_manager = ExecSessionManager() self._exec_session_manager = ExecSessionManager()
self._llm_wall_timeout_for_session = llm_wall_timeout_for_session self._llm_wall_timeout_for_session = llm_wall_timeout_for_session
@@ -363,6 +365,35 @@ class SubagentManager:
workspace_scope: WorkspaceScope | None = None, workspace_scope: WorkspaceScope | None = None,
*, *,
announce: bool = True, announce: bool = True,
) -> str:
"""Wait for capacity, then execute one subagent task."""
status.phase = "queued"
async with self._run_slots:
status.phase = "initializing"
return await self._run_admitted_subagent(
task_id,
task,
label,
origin,
status,
runtime,
origin_message_id,
workspace_scope,
announce=announce,
)
async def _run_admitted_subagent(
self,
task_id: str,
task: str,
label: str,
origin: _SubagentOrigin,
status: SubagentStatus,
runtime: LLMRuntime,
origin_message_id: str | None = None,
workspace_scope: WorkspaceScope | None = None,
*,
announce: bool = True,
) -> str: ) -> str:
"""Execute the subagent task and announce the result.""" """Execute the subagent task and announce the result."""
logger.info("Subagent [{}] starting task: {}", task_id, label) logger.info("Subagent [{}] starting task: {}", task_id, label)
+2
View File
@@ -11,6 +11,7 @@ if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentManager from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.exec_session import ExecSessionManager from nanobot.agent.tools.exec_session import ExecSessionManager
from nanobot.agent.tools.file_state import FileStates from nanobot.agent.tools.file_state import FileStates
from nanobot.agent.tools.runtime_control import RuntimeControl
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeEventBus from nanobot.bus.runtime_events import RuntimeEventBus
from nanobot.config.schema import ProviderConfig, ToolsConfig from nanobot.config.schema import ProviderConfig, ToolsConfig
@@ -90,3 +91,4 @@ class ToolContext:
timezone: str = "UTC" timezone: str = "UTC"
workspace_sandbox: WorkspaceSandboxStatus | None = None workspace_sandbox: WorkspaceSandboxStatus | None = None
runtime_events: RuntimeEventBus | None = None runtime_events: RuntimeEventBus | None = None
runtime_control: RuntimeControl | None = None
+285
View File
@@ -0,0 +1,285 @@
"""Execute tool calls and turn their outcomes into model observations."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from typing import Any, cast
from loguru import logger
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
from nanobot.providers.base import ToolCallRequest
from nanobot.utils.runtime import (
repeated_external_lookup_error,
repeated_workspace_violation_error,
)
_RETRY_HINT = "\n\n[Analyze the error above and try a different approach.]"
# SSRF is a hard security block at the tool boundary, but the agent turn
# should recover conversationally instead of aborting the runtime.
_SSRF_MARKERS: tuple[str, ...] = (
"internal/private url detected",
"private/internal address",
"private address",
)
_SSRF_BOUNDARY_NOTE = (
"This is a non-bypassable security boundary. Stop trying to access "
"private/internal URLs. Do not retry with curl, wget, encoded IPs, "
"alternate DNS, redirects, proxies, or another tool. Ask the user for "
"local files, logs, screenshots, or an explicit safe public URL instead. "
"If the user explicitly trusts this private URL, ask them to whitelist "
"the exact IP/CIDR via tools.ssrfWhitelist."
)
# Non-SSRF boundary markers returned to the model as recoverable tool errors.
_WORKSPACE_VIOLATION_MARKERS: tuple[str, ...] = (
"outside the configured workspace",
"outside allowed directory",
"working_dir is outside",
"working_dir could not be resolved",
"path outside working dir",
"path traversal detected",
)
async def execute_tool_calls(
tools: ToolRegistry,
tool_calls: list[ToolCallRequest],
*,
concurrent: bool,
external_lookup_counts: dict[str, int],
workspace_violation_counts: dict[str, int],
hook: AgentHook,
context: AgentHookContext,
) -> tuple[list[Any], list[dict[str, str]]]:
"""Execute one model response's tool calls in stable result order."""
tool_results: list[tuple[Any, dict[str, str]]] = []
for batch in _partition_tool_batches(tools, tool_calls, concurrent=concurrent):
if concurrent and len(batch) > 1:
batch_results = await asyncio.gather(*(
_execute_tool_call(
tools,
tool_call,
external_lookup_counts,
workspace_violation_counts,
hook,
context,
)
for tool_call in batch
))
tool_results.extend(batch_results)
else:
for tool_call in batch:
result = await _execute_tool_call(
tools,
tool_call,
external_lookup_counts,
workspace_violation_counts,
hook,
context,
)
tool_results.append(result)
results = [result for result, _event in tool_results]
events = [event for _result, event in tool_results]
return results, events
async def _execute_tool_call(
tools: ToolRegistry,
tool_call: ToolCallRequest,
external_lookup_counts: dict[str, int],
workspace_violation_counts: dict[str, int],
hook: AgentHook,
context: AgentHookContext,
) -> tuple[Any, dict[str, str]]:
lookup_error = repeated_external_lookup_error(
tool_call.name,
tool_call.arguments,
external_lookup_counts,
)
if lookup_error:
event = {
"name": tool_call.name,
"status": "error",
"detail": "repeated external lookup blocked",
}
return lookup_error + _RETRY_HINT, event
prepare_call = cast(
Callable[[str, Any], object] | None,
getattr(tools, "prepare_call", None),
)
tool, params, prep_error = None, tool_call.arguments, None
if callable(prepare_call):
prepared = prepare_call(tool_call.name, tool_call.arguments)
if isinstance(prepared, tuple):
prepared_tuple = cast(tuple[object, ...], prepared)
if len(prepared_tuple) == 3:
tool, params, prep_error = cast(tuple[Any, Any, str | None], prepared_tuple)
if prep_error:
event = {
"name": tool_call.name,
"status": "error",
"detail": prep_error.split(": ", 1)[-1][:120],
}
handled = _classify_violation(
raw_text=prep_error,
soft_payload=prep_error + _RETRY_HINT,
event=event,
tool_call=tool_call,
workspace_violation_counts=workspace_violation_counts,
)
if handled is not None:
return handled
return prep_error + _RETRY_HINT, event
await hook.before_execute_tool(context, tool_call, tool, params)
try:
if tool is not None:
result = await tool.execute(**params)
else:
result = await tools.execute(tool_call.name, params)
except asyncio.CancelledError:
raise
except Exception as exc:
await hook.on_execute_tool_error(context, tool_call, tool, params, exc)
event = {
"name": tool_call.name,
"status": "error",
"detail": str(exc),
}
payload = f"Error: {type(exc).__name__}: {exc}"
handled = _classify_violation(
raw_text=str(exc),
# Preserve legacy exception payloads without the retry hint.
soft_payload=payload,
event=event,
tool_call=tool_call,
workspace_violation_counts=workspace_violation_counts,
)
if handled is not None:
return handled
return payload, event
if is_tool_error_result(result):
await hook.on_execute_tool_error(context, tool_call, tool, params, result)
event = {
"name": tool_call.name,
"status": "error",
"detail": result.replace("\n", " ").strip()[:120],
}
handled = _classify_violation(
raw_text=result,
soft_payload=result + _RETRY_HINT,
event=event,
tool_call=tool_call,
workspace_violation_counts=workspace_violation_counts,
)
if handled is not None:
return handled
return result + _RETRY_HINT, event
await hook.after_execute_tool(context, tool_call, tool, params, result)
detail = "" if result is None else str(result)
detail = detail.replace("\n", " ").strip()
if not detail:
detail = "(empty)"
elif len(detail) > 120:
detail = detail[:120] + "..."
return result, {"name": tool_call.name, "status": "ok", "detail": detail}
def is_ssrf_violation(text: str) -> bool:
"""Return whether a tool error describes a blocked private-network request."""
if not text:
return False
lowered = text.lower()
return any(marker in lowered for marker in _SSRF_MARKERS)
def _is_workspace_violation(text: str) -> bool:
"""Return whether text describes any workspace or network boundary rejection."""
if not text:
return False
lowered = text.lower()
if is_ssrf_violation(lowered):
return True
return any(marker in lowered for marker in _WORKSPACE_VIOLATION_MARKERS)
def _classify_violation(
*,
raw_text: str,
soft_payload: str,
event: dict[str, str],
tool_call: ToolCallRequest,
workspace_violation_counts: dict[str, int],
) -> tuple[Any, dict[str, str]] | None:
if is_ssrf_violation(raw_text):
logger.warning(
"Tool {} blocked by SSRF guard; returning non-retryable tool error: {}",
tool_call.name,
raw_text.replace("\n", " ").strip()[:200],
)
event["detail"] = _event_detail("ssrf_violation: ", raw_text)
return _ssrf_soft_payload(raw_text), event
if _is_workspace_violation(raw_text):
escalation = repeated_workspace_violation_error(
tool_call.name,
tool_call.arguments,
workspace_violation_counts,
)
event["detail"] = _event_detail("workspace_violation: ", raw_text)
if escalation is not None:
logger.warning(
"Tool {} hit workspace boundary repeatedly; escalating hint",
tool_call.name,
)
event["detail"] = _event_detail(
"workspace_violation_escalated: ",
raw_text,
)
return escalation, event
return soft_payload, event
return None
def _ssrf_soft_payload(raw_text: str) -> str:
text = raw_text.strip() or "Error: request blocked by SSRF guard"
return f"{text}\n\n{_SSRF_BOUNDARY_NOTE}"
def _event_detail(prefix: str, text: str, limit: int = 160) -> str:
return (prefix + text.replace("\n", " ").strip())[:limit]
def _partition_tool_batches(
tools: ToolRegistry,
tool_calls: list[ToolCallRequest],
*,
concurrent: bool,
) -> list[list[ToolCallRequest]]:
if not concurrent:
return [[tool_call] for tool_call in tool_calls]
batches: list[list[ToolCallRequest]] = []
current: list[ToolCallRequest] = []
for tool_call in tool_calls:
get_tool = cast(Callable[[str], Any] | None, getattr(tools, "get", None))
tool = get_tool(tool_call.name) if callable(get_tool) else None
can_batch = bool(tool and tool.concurrency_safe)
if can_batch:
current.append(tool_call)
continue
if current:
batches.append(current)
current = []
batches.append([tool_call])
if current:
batches.append(current)
return batches
+22 -16
View File
@@ -2,9 +2,11 @@
# pyright: reportIncompatibleMethodOverride=false # pyright: reportIncompatibleMethodOverride=false
from collections.abc import Awaitable, Callable, Generator
from contextlib import contextmanager
from contextvars import ContextVar, Token from contextvars import ContextVar, Token
from pathlib import Path from pathlib import Path
from typing import Any, Awaitable, Callable, cast from typing import Any, cast
from loguru import logger from loguru import logger
@@ -16,6 +18,22 @@ from nanobot.bus.events import OutboundMessage
from nanobot.config.paths import get_workspace_path from nanobot.config.paths import get_workspace_path
from nanobot.security.workspace_access import current_tool_workspace from nanobot.security.workspace_access import current_tool_workspace
_CURRENT_MESSAGE_SENDS: ContextVar[set[tuple[str, str]] | None] = ContextVar(
"message_sends",
default=None,
)
@contextmanager
def capture_message_deliveries() -> Generator[set[tuple[str, str]], None, None]:
"""Record successful MessageTool targets within one agent run."""
sends: set[tuple[str, str]] = set()
token = _CURRENT_MESSAGE_SENDS.set(sends)
try:
yield sends
finally:
_CURRENT_MESSAGE_SENDS.reset(token)
@tool_parameters( @tool_parameters(
tool_parameters_schema( tool_parameters_schema(
@@ -68,7 +86,6 @@ class MessageTool(Tool):
self._fallback_chat_id = default_chat_id self._fallback_chat_id = default_chat_id
self._fallback_message_id = default_message_id self._fallback_message_id = default_message_id
self._fallback_metadata: dict[str, Any] = {} self._fallback_metadata: dict[str, Any] = {}
self._sent_in_turn_var: ContextVar[bool] = ContextVar("message_sent_in_turn", default=False)
self._suppress_delivery_var: ContextVar[bool] = ContextVar( self._suppress_delivery_var: ContextVar[bool] = ContextVar(
"message_suppress_delivery", "message_suppress_delivery",
default=False, default=False,
@@ -87,10 +104,6 @@ class MessageTool(Tool):
"""Set the callback for sending messages.""" """Set the callback for sending messages."""
self._send_callback = callback self._send_callback = callback
def start_turn(self) -> None:
"""Reset per-turn send tracking."""
self._sent_in_turn = False
def set_suppress_delivery(self, active: bool) -> Token[bool]: def set_suppress_delivery(self, active: bool) -> Token[bool]:
"""Acknowledge but don't deliver tool sends (heartbeat internal check).""" """Acknowledge but don't deliver tool sends (heartbeat internal check)."""
return self._suppress_delivery_var.set(active) return self._suppress_delivery_var.set(active)
@@ -99,14 +112,6 @@ class MessageTool(Tool):
"""Restore previous delivery-suppression state.""" """Restore previous delivery-suppression state."""
self._suppress_delivery_var.reset(token) self._suppress_delivery_var.reset(token)
@property
def _sent_in_turn(self) -> bool:
return self._sent_in_turn_var.get()
@_sent_in_turn.setter
def _sent_in_turn(self, value: bool) -> None:
self._sent_in_turn_var.set(value)
@property @property
def name(self) -> str: def name(self) -> str:
return "message" return "message"
@@ -244,8 +249,9 @@ class MessageTool(Tool):
try: try:
await self._send_callback(msg) await self._send_callback(msg)
if channel == default_channel and chat_id == default_chat_id: sends = _CURRENT_MESSAGE_SENDS.get()
self._sent_in_turn = True if sends is not None:
sends.add((channel, chat_id))
media_info = f" with {len(media)} attachments" if media else "" media_info = f" with {len(media)} attachments" if media else ""
button_info = ( button_info = (
f" with {sum(len(row) for row in button_rows)} button(s)" f" with {sum(len(row) for row in button_rows)} button(s)"
+10 -2
View File
@@ -58,7 +58,6 @@ def _is_string_mapping(value: object) -> TypeGuard[Mapping[str, object]]:
class MyTool(Tool): class MyTool(Tool):
"""Check and set the agent loop's runtime configuration.""" """Check and set the agent loop's runtime configuration."""
_plugin_discoverable = False # Requires AgentLoop reference; registered manually
config_key = "my" config_key = "my"
@classmethod @classmethod
@@ -67,7 +66,16 @@ class MyTool(Tool):
@classmethod @classmethod
def enabled(cls, ctx: ToolContext) -> bool: def enabled(cls, ctx: ToolContext) -> bool:
return ctx.config.my.enable return ctx.runtime_control is not None and ctx.config.my.enable
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
if ctx.runtime_control is None:
raise RuntimeError("MyTool requires a runtime control capability")
return cls(
runtime_control=ctx.runtime_control,
modify_allowed=ctx.config.my.allow_set,
)
BLOCKED = frozenset({ BLOCKED = frozenset({
# Core infrastructure # Core infrastructure
+5 -8
View File
@@ -73,6 +73,11 @@ class SpawnTool(Tool):
"and use a dedicated subdirectory when helpful." "and use a dedicated subdirectory when helpful."
) )
@property
def concurrency_safe(self) -> bool:
"""Each call owns its task state; the manager serializes capacity admission."""
return True
async def execute( async def execute(
self, self,
task: str, task: str,
@@ -82,14 +87,6 @@ class SpawnTool(Tool):
**kwargs: Any, **kwargs: Any,
) -> str: ) -> str:
"""Spawn a subagent to execute the given task.""" """Spawn a subagent to execute the given task."""
running = self._manager.get_running_count()
limit = self._manager.max_concurrent_subagents
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."
)
request_ctx = current_request_context() request_ctx = current_request_context()
if request_ctx is None or request_ctx.runtime is None: if request_ctx is None or request_ctx.runtime is None:
return ToolResult.error("Error: spawn requires an active model runtime") return ToolResult.error("Error: spawn requires an active model runtime")
+12 -3
View File
@@ -87,7 +87,12 @@ app = typer.Typer(
name="nanobot", name="nanobot",
context_settings={"help_option_names": ["-h", "--help"]}, context_settings={"help_option_names": ["-h", "--help"]},
help=f"{__logo__} nanobot - Personal AI Assistant", help=f"{__logo__} nanobot - Personal AI Assistant",
no_args_is_help=True, epilog=(
"Run `nanobot` without a subcommand to start the terminal agent. "
"Use `nanobot agent --help` for agent options."
),
invoke_without_command=True,
no_args_is_help=False,
) )
console = Console() console = Console()
@@ -98,7 +103,7 @@ def version_callback(value: bool):
raise typer.Exit() raise typer.Exit()
@app.callback() @app.callback(invoke_without_command=True)
def main( def main(
ctx: typer.Context, ctx: typer.Context,
version: bool = typer.Option( version: bool = typer.Option(
@@ -110,7 +115,11 @@ def main(
# imports this Typer app directly instead of ``nanobot.cli.entry``. Keep the # imports this Typer app directly instead of ``nanobot.cli.entry``. Keep the
# role identity correct until that launcher is regenerated. # role identity correct until that launcher is regenerated.
command = ctx.invoked_subcommand command = ctx.invoked_subcommand
set_cli_process_identity([command] if command else sys.argv[1:]) set_cli_process_identity([command] if command else ["agent"])
if command is None:
from nanobot.cli.entry import _run_agent
_run_agent([], prog_name="nanobot")
# ============================================================================ # ============================================================================
+47 -9
View File
@@ -8,6 +8,28 @@ from contextlib import suppress
from nanobot.cli.process_identity import set_cli_process_identity from nanobot.cli.process_identity import set_cli_process_identity
_ROOT_OPTIONS = frozenset(
{
"-h",
"--help",
"-v",
"--version",
"--install-completion",
"--show-completion",
}
)
def _agent_invocation_args(args: list[str]) -> list[str] | None:
"""Return agent arguments when the root command should act as ``agent``."""
if not args:
return []
if args[0] == "agent":
return args[1:]
if args[0].startswith("-") and args[0].split("=", 1)[0] not in _ROOT_OPTIONS:
return args
return None
def _native_tui_candidate(args: list[str]) -> bool: def _native_tui_candidate(args: list[str]) -> bool:
"""Return whether ``agent`` can start without the classic agent stack.""" """Return whether ``agent`` can start without the classic agent stack."""
@@ -34,19 +56,35 @@ def _configure_windows_console() -> None:
reconfigure(encoding="utf-8", errors="replace") reconfigure(encoding="utf-8", errors="replace")
def main() -> None: def _run_agent(args: list[str], *, prog_name: str) -> None:
"""Dispatch native TUI startup without importing the complete CLI graph.""" """Run the shared agent command without importing the complete CLI graph."""
set_cli_process_identity(sys.argv[1:])
_configure_windows_console()
if _native_tui_candidate(sys.argv[1:]):
import typer import typer
from nanobot.cli.agent import agent from nanobot.cli.agent import agent
fast_app = typer.Typer(add_completion=False) agent_app = typer.Typer(add_completion=False)
fast_app.command()(agent) agent_app.command()(agent)
command = typer.main.get_command(fast_app) command = typer.main.get_command(agent_app)
command.main(args=sys.argv[2:], prog_name="nanobot agent") command.main(args=args, prog_name=prog_name)
def main() -> None:
"""Dispatch native TUI startup without importing the complete CLI graph."""
raw_args = sys.argv[1:]
# Installed completion scripts call ``nanobot`` without positional arguments
# and pass the request through this environment variable. Keep those requests
# on the root command so subcommands remain discoverable.
shell_completion = bool(os.environ.get("_NANOBOT_COMPLETE"))
agent_args = None if shell_completion else _agent_invocation_args(raw_args)
dispatch_args = ["agent", *agent_args] if agent_args is not None else raw_args
set_cli_process_identity(dispatch_args)
_configure_windows_console()
root_agent_alias = agent_args is not None and raw_args[:1] != ["agent"]
if agent_args is not None and (
root_agent_alias or _native_tui_candidate(dispatch_args)
):
prog_name = "nanobot" if root_agent_alias else "nanobot agent"
_run_agent(agent_args, prog_name=prog_name)
return return
from nanobot.cli.commands import app from nanobot.cli.commands import app
+5 -2
View File
@@ -29,7 +29,7 @@ _PROVIDER_DISPLAY: dict[str, str] = {
_OAUTH_PROVIDER_DEFAULT_MODELS: dict[str, str] = { _OAUTH_PROVIDER_DEFAULT_MODELS: dict[str, str] = {
"openai_codex": "openai-codex/gpt-5.6-sol", "openai_codex": "openai-codex/gpt-5.6-sol",
"xai_grok": "xai-grok/grok-4.5", "xai_grok": "xai-grok/grok-4.6",
"github_copilot": "github-copilot/gpt-5.4-mini", "github_copilot": "github-copilot/gpt-5.4-mini",
} }
@@ -134,7 +134,10 @@ def _set_oauth_provider_as_main(
config.agents.defaults.model_preset = None config.agents.defaults.model_preset = None
config.agents.defaults.provider = provider_name config.agents.defaults.provider = provider_name
config.agents.defaults.model = selected_model config.agents.defaults.model = selected_model
if provider_name == "xai_grok" and selected_model == "xai-grok/grok-4.5": if provider_name == "xai_grok" and selected_model in {
"xai-grok/grok-4.5",
"xai-grok/grok-4.6",
}:
config.agents.defaults.context_window_tokens = 500_000 config.agents.defaults.context_window_tokens = 500_000
save_config(config, resolved_config_path) save_config(config, resolved_config_path)
-48
View File
@@ -67,8 +67,6 @@ _TUI_RELEASE_LIMITS = {
_TUI_DETACH_EXIT_CODE = 90 _TUI_DETACH_EXIT_CODE = 90
_GATEWAY_READY_TIMEOUT_S = 20.0 _GATEWAY_READY_TIMEOUT_S = 20.0
_GATEWAY_READY_POLL_S = 0.1 _GATEWAY_READY_POLL_S = 0.1
_TUI_DEPENDENCY_METADATA = ("package.json", "bun.lock")
_TUI_DEPENDENCY_CACHE = ".nanobot-install.sha256"
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -226,21 +224,6 @@ def _tui_source_dir(project_root: Path) -> Path | None:
def _resolve_source_tui_command(source_dir: Path, bun: str) -> list[str]: def _resolve_source_tui_command(source_dir: Path, bun: str) -> list[str]:
dependency = source_dir / "node_modules" / "@opentui" / "core" dependency = source_dir / "node_modules" / "@opentui" / "core"
cache = source_dir / "node_modules" / _TUI_DEPENDENCY_CACHE
fingerprint = _tui_dependency_fingerprint(source_dir)
if dependency.is_dir() and fingerprint is not None:
try:
if cache.read_text(encoding="ascii") == f"{fingerprint}\n":
return _source_tui_command(source_dir, bun)
except (OSError, UnicodeError):
pass
try:
cache.unlink(missing_ok=True)
except OSError as exc:
raise TuiUnavailableError(
f"could not prepare the TUI dependency install: {exc}"
) from exc
try: try:
install = subprocess.run( install = subprocess.run(
[bun, "install", "--frozen-lockfile"], [bun, "install", "--frozen-lockfile"],
@@ -255,37 +238,6 @@ def _resolve_source_tui_command(source_dir: Path, bun: str) -> list[str]:
detail = (install.stderr or install.stdout).strip().splitlines() detail = (install.stderr or install.stdout).strip().splitlines()
suffix = f": {detail[-1]}" if detail else "" suffix = f": {detail[-1]}" if detail else ""
raise TuiUnavailableError(f"could not install TUI dependencies{suffix}") raise TuiUnavailableError(f"could not install TUI dependencies{suffix}")
current_fingerprint = _tui_dependency_fingerprint(source_dir)
if fingerprint is not None and current_fingerprint == fingerprint:
pending = cache.with_name(f"{cache.name}.tmp-{os.getpid()}")
try:
pending.write_text(f"{fingerprint}\n", encoding="ascii")
pending.replace(cache)
except OSError:
try:
pending.unlink(missing_ok=True)
except OSError:
pass
return _source_tui_command(source_dir, bun)
def _tui_dependency_fingerprint(source_dir: Path) -> str | None:
digest = hashlib.sha256()
try:
for name in _TUI_DEPENDENCY_METADATA:
content = (source_dir / name).read_bytes()
digest.update(name.encode())
digest.update(b"\0")
digest.update(len(content).to_bytes(8, "big"))
digest.update(content)
except OSError:
return None
return digest.hexdigest()
def _source_tui_command(source_dir: Path, bun: str) -> list[str]:
executable = named_executable( executable = named_executable(
bun, bun,
name="nanobot-tui", name="nanobot-tui",
+83 -4
View File
@@ -1,12 +1,14 @@
"""Shared WebUI setup, URL, health, and browser helpers.""" """Shared WebUI setup, URL, health, and browser helpers."""
import os
import subprocess import subprocess
import sys import sys
import time import time
import webbrowser import webbrowser
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, BinaryIO
import typer import typer
from pydantic import ValidationError from pydantic import ValidationError
@@ -457,27 +459,104 @@ def _print_webui_foreground_lifecycle(*, attached: bool) -> None:
console.print("[green]WebUI is attached to the shared gateway.[/green]") console.print("[green]WebUI is attached to the shared gateway.[/green]")
console.print("[dim]Closing the browser does not stop channels or automations.[/dim]") console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
console.print( console.print(
"[dim]Press Ctrl+C to detach; the gateway stops only when the last local client exits.[/dim]" "[dim]Following live gateway logs. Press Ctrl+C to detach; the gateway stops "
"only when the last local client exits.[/dim]"
) )
_LOG_ANCHOR_BYTES = 64
@dataclass
class _GatewayLogCursor:
offset: int = 0
identity: tuple[int, int] | None = None
anchor: bytes = b""
pending: bytes = b""
def _log_anchor(handle: BinaryIO, offset: int) -> bytes:
size = min(offset, _LOG_ANCHOR_BYTES)
handle.seek(offset - size)
return handle.read(size)
def _start_gateway_log_cursor(log_path: Path) -> _GatewayLogCursor:
"""Start following at the current end of *log_path*."""
try:
with log_path.open("rb") as handle:
stat = os.fstat(handle.fileno())
offset = stat.st_size
return _GatewayLogCursor(
offset=offset,
identity=(stat.st_dev, stat.st_ino),
anchor=_log_anchor(handle, offset),
)
except OSError:
return _GatewayLogCursor()
def _read_new_gateway_logs(
log_path: Path,
cursor: _GatewayLogCursor,
*,
flush: bool = False,
) -> list[str]:
"""Read complete gateway log lines appended after *cursor*."""
try:
with log_path.open("rb") as handle:
stat = os.fstat(handle.fileno())
identity = (stat.st_dev, stat.st_ino)
reset = cursor.identity != identity or stat.st_size < cursor.offset
if not reset and cursor.offset:
reset = _log_anchor(handle, cursor.offset) != cursor.anchor
if reset:
cursor.offset = 0
cursor.pending = b""
handle.seek(cursor.offset)
chunk = handle.read()
cursor.offset = handle.tell()
cursor.identity = identity
cursor.anchor = _log_anchor(handle, cursor.offset)
except OSError:
return []
parts = (cursor.pending + chunk).split(b"\n")
cursor.pending = parts.pop()
if flush and cursor.pending:
parts.append(cursor.pending)
cursor.pending = b""
return [part.removesuffix(b"\r").decode("utf-8", errors="replace") for part in parts]
def _attach_to_background_gateway( def _attach_to_background_gateway(
runtime: "GatewayRuntime", runtime: "GatewayRuntime",
*, *,
poll_hook: Callable[[], None] | None = None, poll_hook: Callable[[], None] | None = None,
sleep: Callable[[float], None] = time.sleep, sleep: Callable[[float], None] = time.sleep,
) -> None: ) -> None:
"""Keep a WebUI launcher attached without taking ownership of the gateway.""" """Keep the launcher attached and mirror this gateway's new log output."""
status = runtime.status()
log_path = status.log_path
cursor = _start_gateway_log_cursor(log_path)
_print_webui_foreground_lifecycle(attached=True) _print_webui_foreground_lifecycle(attached=True)
try: try:
while runtime.status().running: while status.running:
for line in _read_new_gateway_logs(log_path, cursor):
console.print(line, markup=False, highlight=False)
if poll_hook is not None: if poll_hook is not None:
poll_hook() poll_hook()
sleep(0.5) sleep(0.5)
status = runtime.status()
except KeyboardInterrupt: except KeyboardInterrupt:
for line in _read_new_gateway_logs(log_path, cursor, flush=True):
console.print(line, markup=False, highlight=False)
console.print("\n[yellow]WebUI launcher detached.[/yellow]") console.print("\n[yellow]WebUI launcher detached.[/yellow]")
return return
for line in _read_new_gateway_logs(log_path, cursor, flush=True):
console.print(line, markup=False, highlight=False)
console.print("[yellow]Gateway stopped.[/yellow]") console.print("[yellow]Gateway stopped.[/yellow]")
+1 -1
View File
@@ -311,7 +311,7 @@ async def cmd_new(ctx: CommandContext) -> OutboundMessage:
snapshot = list(session.messages) snapshot = list(session.messages)
archive_snapshot = None archive_snapshot = None
runtime = None runtime = None
if session.last_consolidated < len(snapshot): if session.last_archived < len(snapshot):
runtime = ctx.runtime or loop.runtime_for_session(session) runtime = ctx.runtime or loop.runtime_for_session(session)
archive_snapshot = replace( archive_snapshot = replace(
session, session,
+1 -8
View File
@@ -128,7 +128,7 @@ class AgentDefaults(Base):
temperature: float = 0.1 temperature: float = 0.1
fallback_models: list[FallbackCandidate] = Field(default_factory=list) fallback_models: list[FallbackCandidate] = Field(default_factory=list)
max_tool_iterations: int = 200 max_tool_iterations: int = 200
max_concurrent_subagents: int = Field(default=1, ge=1) max_concurrent_subagents: int = Field(default=4, ge=1)
max_tool_result_chars: int = 16_000 max_tool_result_chars: int = 16_000
provider_retry_mode: Literal["standard", "persistent"] = "standard" provider_retry_mode: Literal["standard", "persistent"] = "standard"
tool_hint_max_length: int = Field( tool_hint_max_length: int = Field(
@@ -155,13 +155,6 @@ class AgentDefaults(Base):
default=60, default=60,
ge=0, ge=0,
) # Minimum interval in seconds between scans for idle sessions ) # Minimum interval in seconds between scans for idle sessions
consolidation_ratio: float = Field(
default=0.5,
ge=0.1,
le=0.95,
validation_alias=AliasChoices("consolidationRatio"),
serialization_alias="consolidationRatio",
) # Consolidation target ratio (0.5 = 50% of budget retained after compression)
dream: DreamConfig = Field(default_factory=DreamConfig) dream: DreamConfig = Field(default_factory=DreamConfig)
@model_validator(mode="before") @model_validator(mode="before")
+183 -4
View File
@@ -5,6 +5,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hashlib
import os import os
import time import time
import webbrowser import webbrowser
@@ -17,7 +18,12 @@ from oauth_cli_kit.models import OAuthToken
from oauth_cli_kit.storage import FileTokenStorage from oauth_cli_kit.storage import FileTokenStorage
from nanobot.providers.base import LLMResponse, ProviderCallContext from nanobot.providers.base import LLMResponse, ProviderCallContext
from nanobot.providers.oauth_model_catalog import (
OAuthModelCatalog,
OAuthModelCatalogSnapshot,
)
from nanobot.providers.openai_compat_provider import OpenAICompatProvider from nanobot.providers.openai_compat_provider import OpenAICompatProvider
from nanobot.providers.registry import ProviderModelSpec, find_by_name
DEFAULT_GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code" DEFAULT_GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"
DEFAULT_GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" DEFAULT_GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
@@ -96,7 +102,9 @@ def login_github_copilot(
device_code = str(payload["device_code"]) device_code = str(payload["device_code"])
user_code = str(payload["user_code"]) user_code = str(payload["user_code"])
verify_url = str(payload.get("verification_uri") or payload.get("verification_uri_complete") or "") verify_url = str(
payload.get("verification_uri") or payload.get("verification_uri_complete") or ""
)
verify_complete = str(payload.get("verification_uri_complete") or verify_url) verify_complete = str(payload.get("verification_uri_complete") or verify_url)
interval = max(1, int(payload.get("interval") or 5)) interval = max(1, int(payload.get("interval") or 5))
expires_in = int(payload.get("expires_in") or 900) expires_in = int(payload.get("expires_in") or 900)
@@ -180,8 +188,6 @@ class GitHubCopilotProvider(OpenAICompatProvider):
*, *,
provider_name: str = "github_copilot", provider_name: str = "github_copilot",
): ):
from nanobot.providers.registry import find_by_name
self._copilot_access_token: str | None = None self._copilot_access_token: str | None = None
self._copilot_expires_at: float = 0.0 self._copilot_expires_at: float = 0.0
self._copilot_token_lock: asyncio.Lock = asyncio.Lock() self._copilot_token_lock: asyncio.Lock = asyncio.Lock()
@@ -217,7 +223,9 @@ class GitHubCopilotProvider(OpenAICompatProvider):
) )
timeout = httpx.Timeout(20.0, connect=20.0) timeout = httpx.Timeout(20.0, connect=20.0)
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, trust_env=True) as client: async with httpx.AsyncClient(
timeout=timeout, follow_redirects=True, trust_env=True
) as client:
response = await client.get( response = await client.get(
_resolve("NANOBOT_COPILOT_TOKEN_URL", DEFAULT_COPILOT_TOKEN_URL), _resolve("NANOBOT_COPILOT_TOKEN_URL", DEFAULT_COPILOT_TOKEN_URL),
headers=_copilot_headers(github_token.access), headers=_copilot_headers(github_token.access),
@@ -296,3 +304,174 @@ class GitHubCopilotProvider(OpenAICompatProvider):
on_tool_call_delta=on_tool_call_delta, on_tool_call_delta=on_tool_call_delta,
provider_context=provider_context, provider_context=provider_context,
) )
def get_github_copilot_model_catalog(
proxy: str | None = None,
) -> OAuthModelCatalogSnapshot:
storage = get_storage()
token = storage.load()
account_key = _catalog_account_key(getattr(token, "account_id", None))
cache_key = (
f"{storage.get_token_path()}\0{account_key}\0"
f"{_resolve('NANOBOT_COPILOT_BASE_URL', DEFAULT_COPILOT_BASE_URL)}\0{proxy or ''}"
)
return _GITHUB_COPILOT_MODEL_CATALOG.get(cache_key=cache_key, proxy=proxy)
def invalidate_github_copilot_model_catalog() -> None:
_GITHUB_COPILOT_MODEL_CATALOG.invalidate()
def _fetch_github_copilot_models(proxy: str | None) -> tuple[ProviderModelSpec, ...]:
github_token = get_storage().load()
if not github_token or not github_token.access:
raise RuntimeError("GitHub Copilot is not logged in")
common_headers = {
"Accept": "application/json",
"User-Agent": USER_AGENT,
"Editor-Version": EDITOR_VERSION,
"Editor-Plugin-Version": EDITOR_PLUGIN_VERSION,
}
client_kwargs: dict[str, Any] = {"timeout": 20.0, "follow_redirects": True}
if proxy:
client_kwargs.update(proxy=proxy, trust_env=False)
with httpx.Client(**client_kwargs) as client:
exchange = client.get(
_resolve("NANOBOT_COPILOT_TOKEN_URL", DEFAULT_COPILOT_TOKEN_URL),
headers={**common_headers, "Authorization": f"token {github_token.access}"},
)
exchange.raise_for_status()
exchange_mapping = _catalog_mapping(exchange.json())
copilot_token = exchange_mapping.get("token")
if not isinstance(copilot_token, str) or not copilot_token:
raise RuntimeError("GitHub Copilot token exchange returned no token")
endpoint_base = _catalog_first_text(
_catalog_mapping(exchange_mapping.get("endpoints")),
"api",
)
base_url = endpoint_base or _resolve(
"NANOBOT_COPILOT_BASE_URL",
DEFAULT_COPILOT_BASE_URL,
)
models_url = (
base_url
if base_url.rstrip("/").endswith("/models")
else f"{base_url.rstrip('/')}/models"
)
response = client.get(
models_url,
headers={**common_headers, "Authorization": f"Bearer {copilot_token}"},
)
response.raise_for_status()
return _parse_github_copilot_models(response.json())
def _parse_github_copilot_models(payload: Any) -> tuple[ProviderModelSpec, ...]:
rows = cast(dict[str, Any], payload).get("data") if isinstance(payload, dict) else None
if not isinstance(rows, list):
return ()
fallback_models = _oauth_fallback_models("github_copilot")
fallback_by_id = {model.id.split("/", 1)[-1]: model for model in fallback_models}
models: list[ProviderModelSpec] = []
seen: set[str] = set()
for value in cast(list[object], rows):
if not isinstance(value, dict):
continue
row = cast(dict[str, Any], value)
wire_id = _catalog_first_text(row, "id")
policy = _catalog_mapping(row.get("policy"))
endpoints = row.get("supported_endpoints")
if (
not wire_id
or wire_id in seen
or row.get("model_picker_enabled") is not True
or policy.get("state") == "disabled"
or not _copilot_transport_supported(wire_id, endpoints)
):
continue
seen.add(wire_id)
capabilities = _catalog_mapping(row.get("capabilities"))
supports = _catalog_mapping(capabilities.get("supports"))
limits = _catalog_mapping(capabilities.get("limits"))
fallback = fallback_by_id.get(wire_id)
models.append(
ProviderModelSpec(
id=f"github-copilot/{wire_id}",
label=(
_catalog_first_text(row, "name")
or (fallback.label if fallback is not None else wire_id)
),
description=(fallback.description if fallback is not None else ""),
owned_by="GitHub Copilot",
context_window=(
_catalog_positive_int(limits, "max_context_window_tokens")
or (fallback.context_window if fallback is not None else None)
),
reasoning_efforts=_catalog_reasoning_efforts(supports.get("reasoning_effort")),
)
)
return tuple(models)
def _copilot_transport_supported(wire_id: str, endpoints: object) -> bool:
if not isinstance(endpoints, list):
return True
supported = cast(list[object], endpoints)
if "/chat/completions" in supported:
return True
model = wire_id.lower()
return "/responses" in supported and any(
token in model for token in ("gpt-5", "o1", "o3", "o4")
)
def _oauth_fallback_models(provider_name: str) -> tuple[ProviderModelSpec, ...]:
spec = find_by_name(provider_name)
assert spec is not None
return spec.builtin_models
def _catalog_account_key(account_id: object) -> str:
value = account_id if isinstance(account_id, str) else ""
return hashlib.sha256(value.encode()).hexdigest()[:16] if value else "anonymous"
def _catalog_mapping(value: Any) -> dict[str, Any]:
return cast(dict[str, Any], value) if isinstance(value, dict) else {}
def _catalog_first_text(row: dict[str, Any], *keys: str) -> str:
for key in keys:
value = row.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _catalog_positive_int(row: dict[str, Any], *keys: str) -> int | None:
for key in keys:
value = row.get(key)
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
return int(value)
return None
def _catalog_reasoning_efforts(value: Any) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
return tuple(
dict.fromkeys(
item.strip()
for item in cast(list[object], value)
if isinstance(item, str) and item.strip()
)
)
_GITHUB_COPILOT_MODEL_CATALOG = OAuthModelCatalog(
fallback_models=_oauth_fallback_models("github_copilot"),
fetch=_fetch_github_copilot_models,
)
+224
View File
@@ -0,0 +1,224 @@
"""Shared cache seam for OAuth provider model discovery."""
from __future__ import annotations
import threading
import time
from collections.abc import Callable, Sequence
from dataclasses import dataclass, replace
from typing import Literal
from loguru import logger
from nanobot.providers.registry import ProviderModelSpec
CatalogSource = Literal["remote", "cache", "stale", "fallback"]
@dataclass(frozen=True, slots=True)
class OAuthModelCatalogSnapshot:
"""One usable catalog view, including where it came from."""
models: tuple[ProviderModelSpec, ...]
source: CatalogSource
fetched_at: float
message: str | None = None
def find(self, model: str) -> ProviderModelSpec | None:
wire_id = model.split("/", 1)[-1]
return next(
(item for item in self.models if item.id.split("/", 1)[-1] == wire_id),
None,
)
@dataclass(frozen=True, slots=True)
class _CacheEntry:
snapshot: OAuthModelCatalogSnapshot
stored_at: float
class OAuthModelCatalog:
"""Cache one provider's discovery behind a small failure-tolerant interface."""
def __init__(
self,
*,
fallback_models: Sequence[ProviderModelSpec],
fetch: Callable[[str | None], Sequence[ProviderModelSpec]],
fresh_ttl_s: float = 5 * 60,
stale_ttl_s: float = 24 * 60 * 60,
failure_ttl_s: float = 30,
max_entries: int = 8,
monotonic: Callable[[], float] = time.monotonic,
wall_clock: Callable[[], float] = time.time,
) -> None:
if fresh_ttl_s < 0 or stale_ttl_s < fresh_ttl_s or failure_ttl_s < 0:
raise ValueError("catalog cache TTLs are invalid")
if max_entries < 1:
raise ValueError("catalog cache must allow at least one entry")
self._fallback_models = tuple(fallback_models)
self._fetch = fetch
self._fresh_ttl_s = fresh_ttl_s
self._stale_ttl_s = stale_ttl_s
self._failure_ttl_s = failure_ttl_s
self._max_entries = max_entries
self._monotonic = monotonic
self._wall_clock = wall_clock
self._condition = threading.Condition()
self._entries: dict[str, _CacheEntry] = {}
self._failures: dict[str, float] = {}
self._inflight: set[str] = set()
self._generation = 0
def get(self, *, cache_key: str, proxy: str | None = None) -> OAuthModelCatalogSnapshot:
"""Return a fresh catalog, sharing concurrent work and retaining a fallback."""
with self._condition:
generation = self._generation
cached = self._cached_result(cache_key)
if cached is not None:
return cached
while cache_key in self._inflight:
self._condition.wait()
if generation != self._generation:
return self._stale_or_fallback(None, self._monotonic())
cached = self._cached_result(cache_key)
if cached is not None:
return cached
self._inflight.add(cache_key)
try:
models = tuple(self._fetch(proxy))
if not models:
raise ValueError("provider returned an empty model catalog")
except Exception as exc:
logger.warning("OAuth model catalog refresh failed: type={}", type(exc).__name__)
with self._condition:
result = (
self._stale_or_fallback(None, self._monotonic())
if generation != self._generation
else self._failure_result(cache_key)
)
else:
now = self._monotonic()
result = OAuthModelCatalogSnapshot(
models=models,
source="remote",
fetched_at=self._wall_clock(),
)
with self._condition:
if generation != self._generation:
result = self._stale_or_fallback(None, now)
else:
self._store(cache_key, _CacheEntry(snapshot=result, stored_at=now))
self._failures.pop(cache_key, None)
finally:
with self._condition:
self._inflight.discard(cache_key)
self._condition.notify_all()
return result
def invalidate(self) -> None:
"""Drop cached work and prevent an older identity refresh from being stored."""
with self._condition:
self._generation += 1
self._entries.clear()
self._failures.clear()
self._condition.notify_all()
def _cached_result(self, cache_key: str) -> OAuthModelCatalogSnapshot | None:
now = self._monotonic()
entry = self._entries.get(cache_key)
if entry is not None and now - entry.stored_at < self._fresh_ttl_s:
return replace(entry.snapshot, source="cache")
failure_until = self._failures.get(cache_key)
if failure_until is not None and failure_until <= now:
self._failures.pop(cache_key, None)
elif failure_until is not None:
return self._stale_or_fallback(entry, now)
return None
def _failure_result(self, cache_key: str) -> OAuthModelCatalogSnapshot:
now = self._monotonic()
self._reserve(cache_key)
self._failures[cache_key] = now + self._failure_ttl_s
return self._stale_or_fallback(self._entries.get(cache_key), now)
def _stale_or_fallback(
self,
entry: _CacheEntry | None,
now: float,
) -> OAuthModelCatalogSnapshot:
if entry is not None and now - entry.stored_at < self._stale_ttl_s:
return replace(
entry.snapshot,
source="stale",
message="Could not refresh the online model list; showing cached models.",
)
return OAuthModelCatalogSnapshot(
models=self._fallback_models,
source="fallback",
fetched_at=self._wall_clock(),
message="Could not load the online model list; showing built-in fallback models.",
)
def _store(self, cache_key: str, entry: _CacheEntry) -> None:
self._reserve(cache_key)
self._entries[cache_key] = entry
def _reserve(self, cache_key: str) -> None:
known = set(self._entries) | set(self._failures)
if cache_key in known or len(known) < self._max_entries:
return
oldest = min(
known,
key=lambda key: (
self._entries[key].stored_at
if key in self._entries
else self._failures[key] - self._failure_ttl_s
),
)
self._entries.pop(oldest, None)
self._failures.pop(oldest, None)
def get_oauth_model_catalog(
provider_name: str,
*,
proxy: str | None = None,
) -> OAuthModelCatalogSnapshot:
"""Discover models through the owning provider module."""
if provider_name == "openai_codex":
from nanobot.providers.openai_codex_provider import get_openai_codex_model_catalog
return get_openai_codex_model_catalog(proxy)
if provider_name == "xai_grok":
from nanobot.providers.xai_grok_provider import get_xai_grok_model_catalog
return get_xai_grok_model_catalog(proxy)
if provider_name == "github_copilot":
from nanobot.providers.github_copilot_provider import get_github_copilot_model_catalog
return get_github_copilot_model_catalog(proxy)
raise ValueError(f"OAuth model discovery is not available for {provider_name}")
def invalidate_oauth_model_catalog(provider_name: str) -> None:
"""Invalidate provider discovery after its OAuth identity changes."""
if provider_name == "openai_codex":
from nanobot.providers.openai_codex_provider import (
invalidate_openai_codex_model_catalog,
)
invalidate_openai_codex_model_catalog()
elif provider_name == "xai_grok":
from nanobot.providers.xai_grok_provider import invalidate_xai_grok_model_catalog
invalidate_xai_grok_model_catalog()
elif provider_name == "github_copilot":
from nanobot.providers.github_copilot_provider import (
invalidate_github_copilot_model_catalog,
)
invalidate_github_copilot_model_catalog()
+168 -23
View File
@@ -14,7 +14,10 @@ from typing import Any, cast
import httpx import httpx
from loguru import logger from loguru import logger
from oauth_cli_kit import get_token as get_codex_token from oauth_cli_kit import get_token as get_codex_token
from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER
from oauth_cli_kit.storage import FileTokenStorage
from nanobot import __version__
from nanobot.providers.base import ( from nanobot.providers.base import (
LLMProvider, LLMProvider,
LLMResponse, LLMResponse,
@@ -22,6 +25,10 @@ from nanobot.providers.base import (
ProviderConversationState, ProviderConversationState,
resolve_stream_idle_timeout_s, resolve_stream_idle_timeout_s,
) )
from nanobot.providers.oauth_model_catalog import (
OAuthModelCatalog,
OAuthModelCatalogSnapshot,
)
from nanobot.providers.openai_responses import ( from nanobot.providers.openai_responses import (
ResponsesStreamCapture, ResponsesStreamCapture,
build_responses_state, build_responses_state,
@@ -35,8 +42,11 @@ from nanobot.providers.openai_responses import (
responses_state_items, responses_state_items,
responses_state_matches, responses_state_matches,
) )
from nanobot.providers.registry import ProviderModelSpec, find_by_name
DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses" DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
DEFAULT_OPENAI_CODEX_MODELS_URL = "https://chatgpt.com/backend-api/codex/models"
OPENAI_CODEX_CATALOG_CLIENT_VERSION = "0.144.0"
DEFAULT_ORIGINATOR = "nanobot" DEFAULT_ORIGINATOR = "nanobot"
_COMPACTION_RETAINED_CHAR_BUDGET = 256_000 _COMPACTION_RETAINED_CHAR_BUDGET = 256_000
@@ -87,9 +97,7 @@ class OpenAICodexProvider(LLMProvider):
model = model or self.default_model model = model or self.default_model
sanitized_messages = self._sanitize_empty_content(messages) sanitized_messages = self._sanitize_empty_content(messages)
sanitized_state = ( sanitized_state = (
provider_context.conversation_state provider_context.conversation_state if provider_context is not None else None
if provider_context is not None
else None
) )
if sanitized_state is not None: if sanitized_state is not None:
sanitized_state = sanitized_state.with_pending_messages( sanitized_state = sanitized_state.with_pending_messages(
@@ -168,11 +176,7 @@ class OpenAICodexProvider(LLMProvider):
) )
compact_threshold = resolve_compact_threshold( compact_threshold = resolve_compact_threshold(
( (provider_context.context_window_tokens if provider_context is not None else None),
provider_context.context_window_tokens
if provider_context is not None
else None
),
max_tokens, max_tokens,
) )
if ( if (
@@ -236,8 +240,12 @@ class OpenAICodexProvider(LLMProvider):
return response return response
async def chat( async def chat(
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, self,
model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
reasoning_effort: str | None = None, reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None, tool_choice: str | dict[str, Any] | None = None,
provider_context: ProviderCallContext | None = None, provider_context: ProviderCallContext | None = None,
@@ -264,8 +272,12 @@ class OpenAICodexProvider(LLMProvider):
) )
async def chat_stream( async def chat_stream(
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, self,
model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
reasoning_effort: str | None = None, reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None, tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
@@ -344,11 +356,7 @@ def _without_response_item_ids(
sanitized_input.append(raw_item) sanitized_input.append(raw_item)
continue continue
item = cast(dict[str, Any], raw_item) item = cast(dict[str, Any], raw_item)
sanitized_input.append({ sanitized_input.append({key: value for key, value in item.items() if key != "id"})
key: value
for key, value in item.items()
if key != "id"
})
body = dict(request_body) body = dict(request_body)
body["input"] = sanitized_input body["input"] = sanitized_input
@@ -444,9 +452,7 @@ async def _request_codex(
raw = text.decode("utf-8", "ignore") raw = text.decode("utf-8", "ignore")
retry_after = LLMProvider._extract_retry_after_from_headers(response.headers) retry_after = LLMProvider._extract_retry_after_from_headers(response.headers)
error_type, error_code = LLMProvider._extract_error_type_code(raw) error_type, error_code = LLMProvider._extract_error_type_code(raw)
compaction_unsupported = ( compaction_unsupported = response.status_code in {400, 404, 422} and any(
response.status_code in {400, 404, 422}
and any(
marker in raw.lower() marker in raw.lower()
for marker in ( for marker in (
"context_management", "context_management",
@@ -454,14 +460,15 @@ async def _request_codex(
"compaction_trigger", "compaction_trigger",
) )
) )
)
raise _CodexHTTPError( raise _CodexHTTPError(
_friendly_error(response.status_code, raw), _friendly_error(response.status_code, raw),
status_code=response.status_code, status_code=response.status_code,
retry_after=retry_after, retry_after=retry_after,
error_type=error_type, error_type=error_type,
error_code=error_code, error_code=error_code,
should_retry=_should_retry_status(response.status_code, error_type, error_code, raw), should_retry=_should_retry_status(
response.status_code, error_type, error_code, raw
),
compaction_unsupported=compaction_unsupported, compaction_unsupported=compaction_unsupported,
) )
capture = ResponsesStreamCapture() capture = ResponsesStreamCapture()
@@ -534,7 +541,9 @@ def _codex_error_response(exc: Exception) -> LLMResponse:
default_detail = "HTTP request failed" default_detail = "HTTP request failed"
if status_code is not None and should_retry is None: if status_code is not None and should_retry is None:
retry_content = None if int(status_code) == 429 and isinstance(exc, _CodexHTTPError) else detail retry_content = (
None if int(status_code) == 429 and isinstance(exc, _CodexHTTPError) else detail
)
should_retry = _should_retry_status( should_retry = _should_retry_status(
int(status_code), int(status_code),
getattr(exc, "error_type", None), getattr(exc, "error_type", None),
@@ -592,3 +601,139 @@ def _should_retry_status(
) )
) )
return status_code in LLMProvider._RETRYABLE_STATUS_CODES or status_code >= 500 return status_code in LLMProvider._RETRYABLE_STATUS_CODES or status_code >= 500
def get_openai_codex_model_catalog(
proxy: str | None = None,
) -> OAuthModelCatalogSnapshot:
storage = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename)
token = storage.load()
account_id = getattr(token, "account_id", None)
account_key = _catalog_account_key(account_id)
cache_key = f"{storage.get_token_path()}\0{account_key}\0{proxy or ''}"
return _OPENAI_CODEX_MODEL_CATALOG.get(cache_key=cache_key, proxy=proxy)
def invalidate_openai_codex_model_catalog() -> None:
_OPENAI_CODEX_MODEL_CATALOG.invalidate()
def _fetch_openai_codex_models(proxy: str | None) -> tuple[ProviderModelSpec, ...]:
token = get_codex_token(proxy=proxy)
account_id = getattr(token, "account_id", None)
if not isinstance(account_id, str) or not account_id:
raise RuntimeError("OpenAI Codex OAuth token has no account ID")
client_kwargs: dict[str, Any] = {"timeout": 10.0, "follow_redirects": False}
if proxy:
client_kwargs.update(proxy=proxy, trust_env=False)
with httpx.Client(**client_kwargs) as client:
response = client.get(
DEFAULT_OPENAI_CODEX_MODELS_URL,
params={"client_version": OPENAI_CODEX_CATALOG_CLIENT_VERSION},
headers={
"Authorization": f"Bearer {token.access}",
"chatgpt-account-id": account_id,
"originator": DEFAULT_ORIGINATOR,
"User-Agent": f"nanobot/{__version__} (python)",
"accept": "application/json",
},
)
response.raise_for_status()
return _parse_openai_codex_models(response.json())
def _parse_openai_codex_models(payload: Any) -> tuple[ProviderModelSpec, ...]:
rows = cast(dict[str, Any], payload).get("models") if isinstance(payload, dict) else None
if not isinstance(rows, list):
return ()
fallback_models = _oauth_fallback_models("openai_codex")
fallback_by_id = {model.id.split("/", 1)[-1]: model for model in fallback_models}
parsed: list[tuple[int, ProviderModelSpec]] = []
seen: set[str] = set()
for value in cast(list[object], rows):
if not isinstance(value, dict):
continue
row = cast(dict[str, Any], value)
wire_id = _catalog_first_text(row, "slug", "id")
if not wire_id or wire_id in seen or row.get("visibility") in {"hide", "none"}:
continue
seen.add(wire_id)
fallback = fallback_by_id.get(wire_id)
priority = row.get("priority")
parsed.append(
(
priority if isinstance(priority, int) and not isinstance(priority, bool) else 2**31,
ProviderModelSpec(
id=f"openai-codex/{wire_id}",
label=(
_catalog_first_text(row, "display_name", "name")
or (fallback.label if fallback is not None else wire_id)
),
description=(
_catalog_first_text(row, "description")
or (fallback.description if fallback is not None else "")
),
owned_by="OpenAI Codex",
context_window=(
_catalog_positive_int(row, "context_window")
or (fallback.context_window if fallback is not None else None)
),
reasoning_efforts=(
_catalog_reasoning_efforts(row.get("supported_reasoning_levels"))
or (fallback.reasoning_efforts if fallback is not None else ())
),
),
)
)
parsed.sort(key=lambda item: item[0])
return tuple(model for _, model in parsed)
def _oauth_fallback_models(provider_name: str) -> tuple[ProviderModelSpec, ...]:
spec = find_by_name(provider_name)
assert spec is not None
return spec.builtin_models
def _catalog_account_key(account_id: object) -> str:
value = account_id if isinstance(account_id, str) else ""
return hashlib.sha256(value.encode()).hexdigest()[:16] if value else "anonymous"
def _catalog_first_text(row: dict[str, Any], *keys: str) -> str:
for key in keys:
value = row.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _catalog_positive_int(row: dict[str, Any], *keys: str) -> int | None:
for key in keys:
value = row.get(key)
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
return int(value)
return None
def _catalog_reasoning_efforts(value: Any) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
efforts: list[str] = []
for item in cast(list[object], value):
if isinstance(item, str):
effort = item.strip()
elif isinstance(item, dict):
effort = _catalog_first_text(cast(dict[str, Any], item), "effort", "value", "id")
else:
effort = ""
if effort and effort not in efforts:
efforts.append(effort)
return tuple(efforts)
_OPENAI_CODEX_MODEL_CATALOG = OAuthModelCatalog(
fallback_models=_oauth_fallback_models("openai_codex"),
fetch=_fetch_openai_codex_models,
)
+41 -8
View File
@@ -20,12 +20,15 @@ from pydantic.alias_generators import to_snake
@dataclass(frozen=True) @dataclass(frozen=True)
class ProviderModelSpec: class ProviderModelSpec:
"""A curated model exposed by providers without a model-list endpoint.""" """Curated model metadata used for fixed catalogs or online fallback."""
id: str id: str
label: str = "" label: str = ""
description: str = "" description: str = ""
owned_by: str = ""
context_window: int | None = None context_window: int | None = None
reasoning_efforts: tuple[str, ...] = ()
supports_backend_search: bool = False
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -42,7 +45,7 @@ class ProviderSpec:
keywords: tuple[str, ...] # model-name keywords for matching (lowercase) keywords: tuple[str, ...] # model-name keywords for matching (lowercase)
env_key: str # env var for API key, e.g. "DASHSCOPE_API_KEY" env_key: str # env var for API key, e.g. "DASHSCOPE_API_KEY"
display_name: str = "" # shown in `nanobot status` display_name: str = "" # shown in `nanobot status`
model_catalog: str = "auto" # WebUI model-list source model_catalog: str = "auto" # WebUI model-list source, including builtin/hybrid
builtin_models: tuple[ProviderModelSpec, ...] = () builtin_models: tuple[ProviderModelSpec, ...] = ()
settings_alias_for: str = "" # compatibility alias grouped under this provider in Settings settings_alias_for: str = "" # compatibility alias grouped under this provider in Settings
@@ -407,45 +410,56 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
keywords=("openai-codex",), keywords=("openai-codex",),
env_key="", env_key="",
display_name="OpenAI Codex", display_name="OpenAI Codex",
model_catalog="builtin", model_catalog="hybrid",
builtin_models=( builtin_models=(
ProviderModelSpec( ProviderModelSpec(
id="openai-codex/gpt-5.6-sol", id="openai-codex/gpt-5.6-sol",
label="GPT-5.6-Sol", label="GPT-5.6-Sol",
description="Latest frontier agentic coding model.", description="Latest frontier agentic coding model.",
context_window=372000, context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh", "max", "ultra"),
), ),
ProviderModelSpec( ProviderModelSpec(
id="openai-codex/gpt-5.6-terra", id="openai-codex/gpt-5.6-terra",
label="GPT-5.6-Terra", label="GPT-5.6-Terra",
description="Balanced agentic coding model for everyday work.", description="Balanced agentic coding model for everyday work.",
context_window=372000, context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh", "max", "ultra"),
), ),
ProviderModelSpec( ProviderModelSpec(
id="openai-codex/gpt-5.6-luna", id="openai-codex/gpt-5.6-luna",
label="GPT-5.6-Luna", label="GPT-5.6-Luna",
description="Fast and affordable agentic coding model.", description="Fast and affordable agentic coding model.",
context_window=372000, context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh", "max"),
), ),
ProviderModelSpec( ProviderModelSpec(
id="openai-codex/gpt-5.5", id="openai-codex/gpt-5.5",
label="GPT-5.5", label="GPT-5.5",
description="Frontier model for complex coding, research, and real-world work.", description="Frontier model for complex coding, research, and real-world work.",
context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh"),
), ),
ProviderModelSpec( ProviderModelSpec(
id="openai-codex/gpt-5.4", id="openai-codex/gpt-5.4",
label="GPT-5.4", label="GPT-5.4",
description="Strong model for everyday coding.", description="Strong model for everyday coding.",
context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh"),
), ),
ProviderModelSpec( ProviderModelSpec(
id="openai-codex/gpt-5.4-mini", id="openai-codex/gpt-5.4-mini",
label="GPT-5.4-Mini", label="GPT-5.4-Mini",
description="Small, fast, and cost-efficient model for simpler coding tasks.", description="Small, fast, and cost-efficient model for simpler coding tasks.",
context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh"),
), ),
ProviderModelSpec( ProviderModelSpec(
id="openai-codex/gpt-5.3-codex-spark", id="openai-codex/gpt-5.3-codex-spark",
label="GPT-5.3-Codex-Spark", label="GPT-5.3-Codex-Spark",
description="Ultra-fast coding model.", description="Ultra-fast coding model.",
context_window=128_000,
reasoning_efforts=("low", "medium", "high", "xhigh"),
), ),
), ),
backend="openai_codex", backend="openai_codex",
@@ -459,13 +473,19 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
keywords=("xai-grok", "xai_grok"), keywords=("xai-grok", "xai_grok"),
env_key="", env_key="",
display_name="xAI Grok", display_name="xAI Grok",
model_catalog="builtin", model_catalog="hybrid",
builtin_models=( builtin_models=(
ProviderModelSpec(
id="xai-grok/grok-4.6",
label="Grok 4.6",
description="Grok via xAI subscription; X Search is enabled when supported.",
context_window=500_000,
),
ProviderModelSpec( ProviderModelSpec(
id="xai-grok/grok-4.5", id="xai-grok/grok-4.5",
label="Grok 4.5", label="Grok 4.5",
description="Grok via xAI subscription; X Search is enabled when supported.", description="Grok via xAI subscription; X Search is enabled when supported.",
context_window=500000, context_window=500_000,
), ),
), ),
backend="xai_grok", backend="xai_grok",
@@ -478,6 +498,19 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
keywords=("github_copilot", "copilot"), keywords=("github_copilot", "copilot"),
env_key="", env_key="",
display_name="Github Copilot", display_name="Github Copilot",
model_catalog="hybrid",
builtin_models=(
ProviderModelSpec(
id="github-copilot/gpt-5.4-mini",
label="GPT-5.4 Mini",
description="GitHub Copilot Responses model.",
),
ProviderModelSpec(
id="github-copilot/gpt-4.1",
label="GPT-4.1",
description="GitHub Copilot chat model.",
),
),
backend="github_copilot", backend="github_copilot",
default_api_base="https://api.githubcopilot.com", default_api_base="https://api.githubcopilot.com",
strip_model_prefix=True, strip_model_prefix=True,
+371 -165
View File
@@ -4,9 +4,9 @@ from __future__ import annotations
import asyncio import asyncio
import base64 import base64
import hashlib
import json import json
import re import re
import time
import uuid import uuid
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any, cast from typing import Any, cast
@@ -22,21 +22,24 @@ from nanobot.providers.base import (
ToolCallRequest, ToolCallRequest,
resolve_stream_idle_timeout_s, resolve_stream_idle_timeout_s,
) )
from nanobot.providers.oauth_model_catalog import OAuthModelCatalog, OAuthModelCatalogSnapshot
from nanobot.providers.openai_responses import ( from nanobot.providers.openai_responses import (
consume_sse_with_reasoning, consume_sse_with_reasoning,
convert_messages, convert_messages,
convert_tools, convert_tools,
) )
from nanobot.providers.registry import ProviderModelSpec, find_by_name
from nanobot.providers.xai_oauth import ( from nanobot.providers.xai_oauth import (
XAI_CLIENT_VERSION, XAI_CLIENT_VERSION,
XAIToken, get_xai_oauth_login_status,
get_xai_oauth_storage_path,
get_xai_oauth_token, get_xai_oauth_token,
) )
DEFAULT_XAI_GROK_MODEL = "xai-grok/grok-4.6"
DEFAULT_XAI_GROK_URL = "https://cli-chat-proxy.grok.com/v1/responses" DEFAULT_XAI_GROK_URL = "https://cli-chat-proxy.grok.com/v1/responses"
DEFAULT_XAI_GROK_MODELS_URL = "https://cli-chat-proxy.grok.com/v1/models" DEFAULT_XAI_GROK_MODELS_URL = "https://cli-chat-proxy.grok.com/v1/models"
DEFAULT_XAI_GROK_MODEL = "xai-grok/grok-4.5" _HOSTED_SEARCH_MAX_TURNS = 5
_MODEL_CAPABILITIES_TTL_S = 5 * 60
_MAX_ERROR_BODY_CHARS = 1000 _MAX_ERROR_BODY_CHARS = 1000
_SENSITIVE_ERROR_KEYS = { _SENSITIVE_ERROR_KEYS = {
"accesstoken", "accesstoken",
@@ -63,6 +66,10 @@ def _is_named_x_search_tool(value: object) -> bool:
class XAIGrokProvider(LLMProvider): class XAIGrokProvider(LLMProvider):
"""Call xAI's subscription proxy and expose supported hosted tools.""" """Call xAI's subscription proxy and expose supported hosted tools."""
# An incomplete hosted-tool stream can already have emitted answer text. Let the
# provider close that stream segment before its one bounded recovery attempt.
supports_stream_recover_callback = True
def __init__( def __init__(
self, self,
default_model: str = DEFAULT_XAI_GROK_MODEL, default_model: str = DEFAULT_XAI_GROK_MODEL,
@@ -75,37 +82,19 @@ class XAIGrokProvider(LLMProvider):
self.default_model = default_model self.default_model = default_model
self.proxy = proxy or None self.proxy = proxy or None
self._extra_body = dict(extra_body or {}) self._extra_body = dict(extra_body or {})
self._model_capabilities: dict[str, bool] | None = None
self._model_capabilities_fetched_at = 0.0
async def _supports_backend_search(self, token: XAIToken, model: str) -> bool: async def _supports_backend_search(self, model: str) -> bool:
now = time.monotonic() catalog = await asyncio.to_thread(
capabilities = self._model_capabilities get_xai_grok_model_catalog,
if ( self.proxy,
capabilities is None
or now - self._model_capabilities_fetched_at >= _MODEL_CAPABILITIES_TTL_S
):
try:
capabilities = await _fetch_xai_model_capabilities(
DEFAULT_XAI_GROK_MODELS_URL,
_build_model_headers(token),
proxy=self.proxy,
) )
except Exception as exc: if catalog.message:
logger.warning( logger.warning(
"xAI model capability lookup failed; hosted X Search disabled for model {}: " "xAI model catalog unavailable; hosted X Search disabled unless cached: {}",
"type={} error={}", catalog.message,
model,
type(exc).__name__,
str(exc).strip() or "unexpected error",
) )
capabilities = {} info = catalog.find(model)
self._model_capabilities = capabilities return bool(info and info.supports_backend_search)
self._model_capabilities_fetched_at = now
else:
self._model_capabilities = capabilities
self._model_capabilities_fetched_at = now
return capabilities.get(model, False)
async def _call_xai( async def _call_xai(
self, self,
@@ -119,6 +108,7 @@ class XAIGrokProvider(LLMProvider):
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None, on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
) -> LLMResponse: ) -> LLMResponse:
wire_model = _strip_model_prefix(model or self.default_model) wire_model = _strip_model_prefix(model or self.default_model)
system_prompt, input_items = convert_messages(messages) system_prompt, input_items = convert_messages(messages)
@@ -128,17 +118,13 @@ class XAIGrokProvider(LLMProvider):
token = await asyncio.to_thread(get_xai_oauth_token, proxy=self.proxy) token = await asyncio.to_thread(get_xai_oauth_token, proxy=self.proxy)
configured_tools = self._extra_body.get("tools") configured_tools = self._extra_body.get("tools")
tools_are_explicit = "tools" in self._extra_body tools_are_explicit = "tools" in self._extra_body
configured_hosted_search = ( configured_hosted_search = isinstance(configured_tools, list) and any(
isinstance(configured_tools, list) _is_hosted_x_search_tool(tool) for tool in cast(list[object], configured_tools)
and any(
_is_hosted_x_search_tool(tool)
for tool in cast(list[object], configured_tools)
)
) )
supports_backend_search = False supports_backend_search = False
if not tools_are_explicit: if not tools_are_explicit:
stage = "model_capabilities" stage = "model_capabilities"
supports_backend_search = await self._supports_backend_search(token, wire_model) supports_backend_search = await self._supports_backend_search(wire_model)
converted_tools = convert_tools(tools or []) converted_tools = convert_tools(tools or [])
if isinstance(configured_tools, list): if isinstance(configured_tools, list):
converted_tools.extend(cast(list[dict[str, Any]], configured_tools)) converted_tools.extend(cast(list[dict[str, Any]], configured_tools))
@@ -149,6 +135,8 @@ class XAIGrokProvider(LLMProvider):
if supports_backend_search: if supports_backend_search:
converted_tools.append({"type": "x_search"}) converted_tools.append({"type": "x_search"})
hosted_search_enabled = supports_backend_search or configured_hosted_search
body: dict[str, Any] = { body: dict[str, Any] = {
"model": wire_model, "model": wire_model,
"store": False, "store": False,
@@ -164,17 +152,24 @@ class XAIGrokProvider(LLMProvider):
"temperature": temperature, "temperature": temperature,
"reasoning": _build_reasoning_options(reasoning_effort), "reasoning": _build_reasoning_options(reasoning_effort),
} }
if hosted_search_enabled:
# xAI's global default is intentionally unspecified. Five turns is
# their documented balanced setting and prevents a search from
# stopping after a single unsuccessful lookup.
body["max_turns"] = _HOSTED_SEARCH_MAX_TURNS
if self._extra_body: if self._extra_body:
body.update({ body.update(
key: value {key: value for key, value in self._extra_body.items() if key != "tools"}
for key, value in self._extra_body.items() )
if key != "tools"
})
if tools_are_explicit and not isinstance(configured_tools, list): if tools_are_explicit and not isinstance(configured_tools, list):
body["tools"] = configured_tools body["tools"] = configured_tools
headers = _build_headers(token.access, wire_model) headers = _build_headers(token.access, wire_model)
stage = "xai_request" stage = "xai_request"
auth_retried = False
hosted_tool_retried = False
retry_usage: LLMUsage | None = None
while True:
try: try:
result = await _request_xai( result = await _request_xai(
DEFAULT_XAI_GROK_URL, DEFAULT_XAI_GROK_URL,
@@ -185,30 +180,37 @@ class XAIGrokProvider(LLMProvider):
on_thinking_delta=on_thinking_delta, on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta, on_tool_call_delta=on_tool_call_delta,
) )
break
except _XAIHTTPError as exc: except _XAIHTTPError as exc:
if exc.status_code != 401: if exc.status_code != 401 or auth_retried:
raise raise
auth_retried = True
stage = "oauth_refresh" stage = "oauth_refresh"
token = await asyncio.to_thread( token = await asyncio.to_thread(
get_xai_oauth_token, get_xai_oauth_token,
proxy=self.proxy, proxy=self.proxy,
force_refresh=True, force_refresh=True,
) )
self._model_capabilities = None
self._model_capabilities_fetched_at = 0.0
headers = _build_headers(token.access, wire_model) headers = _build_headers(token.access, wire_model)
stage = "xai_request_retry" stage = "xai_request_after_oauth_refresh"
result = await _request_xai( except _XAIIncompleteHostedToolError as exc:
DEFAULT_XAI_GROK_URL, retry_usage = _combine_usage(retry_usage, exc.usage)
headers, cannot_recover_stream = exc.stream_output_emitted and on_stream_recover is None
body, if hosted_tool_retried or cannot_recover_stream:
proxy=self.proxy, exc.usage = retry_usage
on_content_delta=on_content_delta, raise
on_thinking_delta=on_thinking_delta, hosted_tool_retried = True
on_tool_call_delta=on_tool_call_delta, stage = "hosted_tool_recovery"
logger.warning(
"xAI response ended with unfinished hosted tool(s): {}; retrying once",
", ".join(exc.tool_names),
) )
if on_stream_recover is not None:
await on_stream_recover()
headers = _build_headers(token.access, wire_model)
content, tool_calls, finish_reason, usage, reasoning_content = result content, tool_calls, finish_reason, usage, reasoning_content = result
usage = _combine_usage(retry_usage, usage)
return LLMResponse( return LLMResponse(
content=content, content=content,
tool_calls=tool_calls, tool_calls=tool_calls,
@@ -257,6 +259,7 @@ class XAIGrokProvider(LLMProvider):
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None, on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
) -> LLMResponse: ) -> LLMResponse:
return await self._call_xai( return await self._call_xai(
messages, messages,
@@ -269,6 +272,7 @@ class XAIGrokProvider(LLMProvider):
on_content_delta, on_content_delta,
on_thinking_delta, on_thinking_delta,
on_tool_call_delta, on_tool_call_delta,
on_stream_recover,
) )
def get_default_model(self) -> str: def get_default_model(self) -> str:
@@ -288,6 +292,14 @@ def _build_reasoning_options(reasoning_effort: str | None) -> dict[str, str]:
return options return options
def _combine_usage(left: LLMUsage | None, right: LLMUsage | None) -> LLMUsage | None:
if left is None:
return right
if right is None:
return left
return left + right
def _build_headers(token: str, model: str) -> dict[str, str]: def _build_headers(token: str, model: str) -> dict[str, str]:
conversation_id = str(uuid.uuid4()) conversation_id = str(uuid.uuid4())
return { return {
@@ -308,44 +320,6 @@ def _build_headers(token: str, model: str) -> dict[str, str]:
} }
def _build_model_headers(token: XAIToken) -> dict[str, str]:
headers = {
"Authorization": f"Bearer {token.access}",
"X-XAI-Token-Auth": "xai-grok-cli",
"x-grok-client-version": XAI_CLIENT_VERSION,
"x-grok-client-identifier": "nanobot",
"x-grok-client-mode": "headless",
"User-Agent": f"nanobot/{__version__} (python)",
"accept": "application/json",
}
claims = _decode_access_token_claims(token.access)
user_id = claims.get("sub")
if claims.get("principal_type") == "Team":
user_id = claims.get("principal_id") or user_id
if isinstance(user_id, str) and user_id:
headers["x-userid"] = user_id
email = claims.get("email")
if not isinstance(email, str) or "@" not in email:
email = token.account_id if token.account_id and "@" in token.account_id else None
if email:
headers["x-email"] = email
return headers
def _decode_access_token_claims(token: str) -> dict[str, Any]:
"""Read identity hints from the signed token; the server still authenticates it."""
parts = token.split(".")
if len(parts) < 2 or not parts[1]:
return {}
payload = parts[1]
try:
decoded = base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))
claims = json.loads(decoded)
except (ValueError, TypeError):
return {}
return cast(dict[str, Any], claims) if isinstance(claims, dict) else {}
class _XAIHTTPError(RuntimeError): class _XAIHTTPError(RuntimeError):
def __init__( def __init__(
self, self,
@@ -367,65 +341,25 @@ class _XAIHTTPError(RuntimeError):
self.response_body = response_body self.response_body = response_body
async def _fetch_xai_model_capabilities( class _XAIIncompleteHostedToolError(RuntimeError):
url: str, """A nominally successful xAI stream ended before a hosted tool did."""
headers: dict[str, str],
should_retry = False # _call_xai already performs the one safe recovery attempt.
def __init__(
self,
active_tools: list[dict[str, Any]],
*, *,
proxy: str | None = None, usage: LLMUsage | None,
) -> dict[str, bool]: stream_output_emitted: bool = False,
client_kwargs: dict[str, Any] = {"timeout": 10.0, "follow_redirects": False} ) -> None:
if proxy: names = [str(event.get("name") or "hosted_tool") for event in active_tools]
client_kwargs.update(proxy=proxy, trust_env=False) super().__init__(
async with httpx.AsyncClient(**client_kwargs) as client: "xAI ended the response before its hosted tool completed: " + ", ".join(names)
response = await client.get(url, headers=headers)
if response.status_code != 200:
raw = response.content.decode("utf-8", "ignore")
raise _build_xai_http_error(response.status_code, response.headers, raw)
try:
payload = response.json()
except ValueError as exc:
raise RuntimeError("xAI model catalog returned invalid JSON.") from exc
return _parse_xai_model_capabilities(payload)
def _parse_xai_model_capabilities(payload: Any) -> dict[str, bool]:
if isinstance(payload, dict):
payload = cast(dict[str, Any], payload)
rows: object = payload.get("data")
if not isinstance(rows, list):
rows = payload.get("models")
else:
rows = payload
if not isinstance(rows, list):
return {}
capabilities: dict[str, bool] = {}
for row_value in cast(list[object], rows):
if not isinstance(row_value, dict):
continue
row = cast(dict[str, Any], row_value)
meta_value = row.get("_meta")
meta = cast(dict[str, Any], meta_value) if isinstance(meta_value, dict) else {}
support_value = row.get("supportsBackendSearch")
if not isinstance(support_value, bool):
support_value = row.get("supports_backend_search")
if not isinstance(support_value, bool):
support_value = meta.get("supportsBackendSearch")
if not isinstance(support_value, bool):
support_value = meta.get("supports_backend_search")
supports_backend_search = support_value if isinstance(support_value, bool) else False
identifiers = (
row.get("model"),
row.get("modelId"),
row.get("id"),
meta.get("model"),
meta.get("modelId"),
) )
for identifier in identifiers: self.tool_names = tuple(names)
if isinstance(identifier, str) and identifier.strip(): self.usage = usage
capabilities[_strip_model_prefix(identifier.strip())] = supports_backend_search self.stream_output_emitted = stream_output_emitted
return capabilities
async def _request_xai( async def _request_xai(
@@ -438,10 +372,39 @@ async def _request_xai(
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None, on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str, LLMUsage | None, str | None]: ) -> tuple[str, list[ToolCallRequest], str, LLMUsage | None, str | None]:
active_hosted_tools: dict[str, dict[str, Any]] = {}
stream_output_emitted = False
async def _forward_content_delta(delta: str) -> None:
nonlocal stream_output_emitted
if delta:
stream_output_emitted = True
if on_content_delta is not None:
await on_content_delta(delta)
async def _forward_thinking_delta(delta: str) -> None:
nonlocal stream_output_emitted
if delta:
stream_output_emitted = True
if on_thinking_delta is not None:
await on_thinking_delta(delta)
async def _track_and_forward_tool_event(event: dict[str, Any]) -> None:
if event.get("kind") == "hosted_tool":
call_id = event.get("call_id")
if call_id:
call_id = str(call_id)
if event.get("phase") == "start":
active_hosted_tools[call_id] = dict(event)
elif event.get("phase") in {"end", "error"}:
active_hosted_tools.pop(call_id, None)
if on_tool_call_delta is not None:
await on_tool_call_delta(event)
async def _on_response_event(event: dict[str, Any]) -> None: async def _on_response_event(event: dict[str, Any]) -> None:
hosted_event = _xai_hosted_tool_event(event) hosted_event = _xai_hosted_tool_event(event)
if hosted_event is not None and on_tool_call_delta is not None: if hosted_event is not None:
await on_tool_call_delta(hosted_event) await _track_and_forward_tool_event(hosted_event)
client_kwargs: dict[str, Any] = {"timeout": resolve_stream_idle_timeout_s()} client_kwargs: dict[str, Any] = {"timeout": resolve_stream_idle_timeout_s()}
if proxy: if proxy:
@@ -452,13 +415,34 @@ async def _request_xai(
content = await response.aread() content = await response.aread()
raw = content.decode("utf-8", "ignore") raw = content.decode("utf-8", "ignore")
raise _build_xai_http_error(response.status_code, response.headers, raw) raise _build_xai_http_error(response.status_code, response.headers, raw)
return await consume_sse_with_reasoning( result = await consume_sse_with_reasoning(
response, response,
on_content_delta=on_content_delta, on_content_delta=(_forward_content_delta if on_content_delta is not None else None),
on_tool_call_delta=on_tool_call_delta, # Always observe tool events so protocol validation also works for
on_reasoning_delta=on_thinking_delta, # non-streaming callers that did not request UI progress callbacks.
on_response_event=_on_response_event if on_tool_call_delta else None, on_tool_call_delta=_track_and_forward_tool_event,
on_reasoning_delta=(
_forward_thinking_delta if on_thinking_delta is not None else None
),
on_response_event=_on_response_event,
) )
if result[2] != "error" and active_hosted_tools:
active = list(active_hosted_tools.values())
for event in active:
await _track_and_forward_tool_event(
{
**event,
"phase": "error",
"result": None,
"error": "xAI ended the response before this hosted tool completed.",
}
)
raise _XAIIncompleteHostedToolError(
active,
usage=result[3],
stream_output_emitted=stream_output_emitted,
)
return result
def _xai_hosted_tool_event(event: dict[str, Any]) -> dict[str, Any] | None: def _xai_hosted_tool_event(event: dict[str, Any]) -> dict[str, Any] | None:
@@ -472,19 +456,33 @@ def _xai_hosted_tool_event(event: dict[str, Any]) -> dict[str, Any] | None:
"phase": "start", "phase": "start",
"call_id": str(call_id), "call_id": str(call_id),
"name": "x_search", "name": "x_search",
"arguments": _xai_hosted_tool_arguments( "arguments": _xai_hosted_tool_arguments(event.get("input", event.get("arguments"))),
event.get("input", event.get("arguments"))
),
"result": None, "result": None,
} }
if event_type != "response.output_item.done": if event_type not in {"response.output_item.added", "response.output_item.done"}:
return None return None
item = event.get("item") item = event.get("item")
if not isinstance(item, dict): if not isinstance(item, dict):
return None return None
item = cast(dict[str, Any], item) item = cast(dict[str, Any], item)
if item.get("type") != "custom_tool_call": item_type = item.get("type")
if item_type == "x_search_call":
call_id = item.get("id") or item.get("call_id") or event.get("item_id")
if not call_id:
return None
phase = "start" if event_type == "response.output_item.added" else "end"
return {
"kind": "hosted_tool",
"phase": phase,
"call_id": str(call_id),
"name": "x_search",
"arguments": _xai_hosted_tool_arguments(item.get("action")),
"result": (
{"status": str(item.get("status") or "completed")} if phase == "end" else None
),
}
if event_type != "response.output_item.done" or item_type != "custom_tool_call":
return None return None
tool_name = item.get("name") tool_name = item.get("name")
if not isinstance(tool_name, str) or not tool_name.startswith("x_"): if not isinstance(tool_name, str) or not tool_name.startswith("x_"):
@@ -497,9 +495,7 @@ def _xai_hosted_tool_event(event: dict[str, Any]) -> dict[str, Any] | None:
"phase": "end", "phase": "end",
"call_id": str(call_id), "call_id": str(call_id),
"name": "x_search", "name": "x_search",
"arguments": _xai_hosted_tool_arguments( "arguments": _xai_hosted_tool_arguments(item.get("input", item.get("arguments"))),
item.get("input", item.get("arguments"))
),
# Keep the useful search subtype, but do not persist large hosted results # Keep the useful search subtype, but do not persist large hosted results
# in WebUI activity messages. The model answer already carries citations. # in WebUI activity messages. The model answer already carries citations.
"result": {"name": tool_name}, "result": {"name": tool_name},
@@ -608,6 +604,8 @@ def _xai_error_response(exc: Exception) -> LLMResponse:
should_retry = True if should_retry is None else should_retry should_retry = True if should_retry is None else should_retry
elif isinstance(exc, _XAIHTTPError): elif isinstance(exc, _XAIHTTPError):
error_kind = "http" error_kind = "http"
elif isinstance(exc, _XAIIncompleteHostedToolError):
error_kind = "provider"
if status_code is not None and should_retry is None: if status_code is not None and should_retry is None:
should_retry = _should_retry_status( should_retry = _should_retry_status(
int(status_code), int(status_code),
@@ -617,9 +615,11 @@ def _xai_error_response(exc: Exception) -> LLMResponse:
) )
message = str(exc).strip() or "unexpected error" message = str(exc).strip() or "unexpected error"
retry_after = getattr(exc, "retry_after", None) retry_after = getattr(exc, "retry_after", None)
usage = getattr(exc, "usage", None)
return LLMResponse( return LLMResponse(
content=f"Error calling xAI ({type(exc).__name__}): {message}", content=f"Error calling xAI ({type(exc).__name__}): {message}",
finish_reason="error", finish_reason="error",
usage=usage if isinstance(usage, LLMUsage) else None,
retry_after=retry_after, retry_after=retry_after,
error_status_code=int(status_code) if status_code is not None else None, error_status_code=int(status_code) if status_code is not None else None,
error_kind=error_kind, error_kind=error_kind,
@@ -647,3 +647,209 @@ def _should_retry_status(
) )
) )
return status_code in LLMProvider._RETRYABLE_STATUS_CODES or status_code >= 500 # pyright: ignore[reportPrivateUsage] return status_code in LLMProvider._RETRYABLE_STATUS_CODES or status_code >= 500 # pyright: ignore[reportPrivateUsage]
def get_xai_grok_model_catalog(proxy: str | None = None) -> OAuthModelCatalogSnapshot:
token = get_xai_oauth_login_status()
account_key = _catalog_account_key(getattr(token, "account_id", None))
cache_key = f"{get_xai_oauth_storage_path()}\0{account_key}\0{proxy or ''}"
return _XAI_GROK_MODEL_CATALOG.get(cache_key=cache_key, proxy=proxy)
def invalidate_xai_grok_model_catalog() -> None:
_XAI_GROK_MODEL_CATALOG.invalidate()
def _fetch_xai_grok_models(proxy: str | None) -> tuple[ProviderModelSpec, ...]:
token = get_xai_oauth_token(proxy=proxy)
client_kwargs: dict[str, Any] = {"timeout": 10.0, "follow_redirects": False}
if proxy:
client_kwargs.update(proxy=proxy, trust_env=False)
with httpx.Client(**client_kwargs) as client:
response = client.get(
DEFAULT_XAI_GROK_MODELS_URL,
headers=_build_xai_model_headers(token.access, token.account_id),
)
response.raise_for_status()
return _parse_xai_grok_models(response.json())
def _parse_xai_grok_models(payload: Any) -> tuple[ProviderModelSpec, ...]:
if isinstance(payload, dict):
payload_mapping = cast(dict[str, Any], payload)
rows: object = payload_mapping.get("data")
if not isinstance(rows, list):
rows = payload_mapping.get("models")
else:
rows = payload
if not isinstance(rows, list):
return ()
fallback_models = _oauth_fallback_models("xai_grok")
fallback_by_id = {model.id.split("/", 1)[-1]: model for model in fallback_models}
models: list[ProviderModelSpec] = []
seen: set[str] = set()
for value in cast(list[object], rows):
if not isinstance(value, dict):
continue
row = cast(dict[str, Any], value)
meta = _catalog_mapping(row.get("_meta"))
raw_id = next(
(
candidate.strip()
for candidate in (
row.get("id"),
row.get("model"),
row.get("modelId"),
row.get("name"),
meta.get("id"),
meta.get("model"),
meta.get("modelId"),
)
if isinstance(candidate, str) and candidate.strip()
),
None,
)
if raw_id is None:
continue
wire_id = raw_id.split("/", 1)[-1]
if wire_id in seen:
continue
seen.add(wire_id)
fallback = fallback_by_id.get(wire_id)
label = _catalog_first_text(row, "display_name", "label", "name") or _catalog_first_text(
meta,
"display_name",
"label",
"name",
)
if not label or label == raw_id:
label = fallback.label if fallback is not None else wire_id
models.append(
ProviderModelSpec(
id=f"xai-grok/{wire_id}",
label=label,
description=(
_catalog_first_text(row, "description")
or _catalog_first_text(meta, "description")
or (fallback.description if fallback is not None else "")
),
owned_by=(
_catalog_first_text(row, "owned_by", "owner", "organization")
or _catalog_first_text(meta, "owned_by", "owner", "organization")
or (fallback.owned_by if fallback is not None else "xAI")
),
context_window=(
_catalog_positive_int(row, "context_window", "context_length")
or _catalog_positive_int(meta, "context_window", "context_length")
or (fallback.context_window if fallback is not None else None)
),
reasoning_efforts=_catalog_reasoning_efforts(
row.get("reasoning_efforts", meta.get("reasoning_efforts"))
),
supports_backend_search=_catalog_bool_field(
row,
"supports_backend_search",
"supportsBackendSearch",
),
)
)
return tuple(models)
def _build_xai_model_headers(access_token: str, account_id: str | None) -> dict[str, str]:
headers = {
"Authorization": f"Bearer {access_token}",
"X-XAI-Token-Auth": "xai-grok-cli",
"x-grok-client-version": XAI_CLIENT_VERSION,
"x-grok-client-identifier": "nanobot",
"x-grok-client-mode": "headless",
"User-Agent": f"nanobot/{__version__} (python)",
"accept": "application/json",
}
claims = _decode_access_token_claims(access_token)
user_id = claims.get("sub")
if claims.get("principal_type") == "Team":
user_id = claims.get("principal_id") or user_id
if isinstance(user_id, str) and user_id:
headers["x-userid"] = user_id
email = claims.get("email")
if not isinstance(email, str) or "@" not in email:
email = account_id if account_id and "@" in account_id else None
if email:
headers["x-email"] = email
return headers
def _decode_access_token_claims(token: str) -> dict[str, Any]:
parts = token.split(".")
if len(parts) < 2 or not parts[1]:
return {}
try:
decoded = base64.urlsafe_b64decode(parts[1] + "=" * (-len(parts[1]) % 4))
claims = json.loads(decoded)
except (ValueError, TypeError):
return {}
return cast(dict[str, Any], claims) if isinstance(claims, dict) else {}
def _oauth_fallback_models(provider_name: str) -> tuple[ProviderModelSpec, ...]:
spec = find_by_name(provider_name)
assert spec is not None
return spec.builtin_models
def _catalog_account_key(account_id: object) -> str:
value = account_id if isinstance(account_id, str) else ""
return hashlib.sha256(value.encode()).hexdigest()[:16] if value else "anonymous"
def _catalog_mapping(value: Any) -> dict[str, Any]:
return cast(dict[str, Any], value) if isinstance(value, dict) else {}
def _catalog_first_text(row: dict[str, Any], *keys: str) -> str:
for key in keys:
value = row.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _catalog_positive_int(row: dict[str, Any], *keys: str) -> int | None:
for key in keys:
value = row.get(key)
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
return int(value)
return None
def _catalog_bool_field(row: dict[str, Any], *keys: str) -> bool:
for key in keys:
value = row.get(key)
if isinstance(value, bool):
return value
meta = row.get("_meta")
return _catalog_bool_field(_catalog_mapping(meta), *keys) if isinstance(meta, dict) else False
def _catalog_reasoning_efforts(value: Any) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
efforts: list[str] = []
for item in cast(list[object], value):
if isinstance(item, str):
effort = item.strip()
elif isinstance(item, dict):
effort = _catalog_first_text(cast(dict[str, Any], item), "effort", "value", "id")
else:
effort = ""
if effort and effort not in efforts:
efforts.append(effort)
return tuple(efforts)
_XAI_GROK_MODEL_CATALOG = OAuthModelCatalog(
fallback_models=_oauth_fallback_models("xai_grok"),
fetch=_fetch_xai_grok_models,
)
+39 -25
View File
@@ -82,6 +82,15 @@ def _json_object(value: object) -> dict[str, Any]:
return cast(dict[str, Any], value) return cast(dict[str, Any], value)
def _archive_offset(data: dict[str, Any]) -> int:
"""Read the Memory archive watermark across the field-name migration."""
for key in ("last_archived", "last_consolidated"):
offset = cast(object, data.get(key))
if isinstance(offset, int) and not isinstance(offset, bool):
return offset
return 0
# TODO(0.3.2): Remove the write_stdin replay migration after 0.3.1. # TODO(0.3.2): Remove the write_stdin replay migration after 0.3.1.
def _migrate_legacy_exec_arguments(container: dict[str, Any]) -> bool: def _migrate_legacy_exec_arguments(container: dict[str, Any]) -> bool:
raw_arguments = cast(object, container.get("arguments")) raw_arguments = cast(object, container.get("arguments"))
@@ -277,7 +286,10 @@ class Session:
created_at: datetime = field(default_factory=datetime.now) created_at: datetime = field(default_factory=datetime.now)
updated_at: datetime = field(default_factory=datetime.now) updated_at: datetime = field(default_factory=datetime.now)
metadata: dict[str, Any] = field(default_factory=dict) metadata: dict[str, Any] = field(default_factory=dict)
last_consolidated: int = 0 # Number of messages already consolidated to files # Legacy storage name for the Memory ingestion watermark. New code should
# use ``last_archived`` so this progress is not confused with model-context
# compaction. Keep the field while persisted sessions and SDK callers migrate.
last_consolidated: int = 0
provider_state: ProviderConversationState | None = field(default=None, repr=False) provider_state: ProviderConversationState | None = field(default=None, repr=False)
policy: SessionPolicy = field(default_factory=SessionPolicy, repr=False, compare=False) policy: SessionPolicy = field(default_factory=SessionPolicy, repr=False, compare=False)
@@ -295,6 +307,15 @@ class Session:
): ):
self.last_consolidated = 0 self.last_consolidated = 0
@property
def last_archived(self) -> int:
"""Number of transcript messages already written to the Memory journal."""
return self.last_consolidated
@last_archived.setter
def last_archived(self, value: int) -> None:
self.last_consolidated = value
def add_message(self, role: str, content: str, **kwargs: Any) -> None: def add_message(self, role: str, content: str, **kwargs: Any) -> None:
"""Add a message to the session.""" """Add a message to the session."""
msg = { msg = {
@@ -319,9 +340,9 @@ class Session:
A positive ``max_messages`` applies an explicit caller-owned count A positive ``max_messages`` applies an explicit caller-owned count
limit. The normal model path relies on ``max_tokens`` instead. limit. The normal model path relies on ``max_tokens`` instead.
""" """
replay_start = self.last_consolidated replay_start = self.last_archived
if replay_start: if replay_start:
# ``last_consolidated`` is archive progress, not a replay boundary. # ``last_archived`` is archive progress, not a replay boundary.
# Keep a small raw suffix for continuity, extending back to the user # Keep a small raw suffix for continuity, extending back to the user
# that started an assistant/tool sequence when necessary. # that started an assistant/tool sequence when necessary.
recent_start = recent_message_start_index( recent_start = recent_message_start_index(
@@ -335,8 +356,8 @@ class Session:
if max_messages <= 0: if max_messages <= 0:
start_idx = 0 start_idx = 0
else: else:
unarchived_count = len(self.messages) - self.last_consolidated unarchived_count = len(self.messages) - self.last_archived
if replay_start < self.last_consolidated and unarchived_count < max_messages: if replay_start < self.last_archived and unarchived_count < max_messages:
# The archived replay suffix can exceed the nominal count when one # The archived replay suffix can exceed the nominal count when one
# tool-heavy turn spans the boundary. Preserve that complete turn. # tool-heavy turn spans the boundary. Preserve that complete turn.
start_idx = 0 start_idx = 0
@@ -459,7 +480,7 @@ class Session:
def clear(self) -> None: def clear(self) -> None:
"""Clear all messages and reset session to initial state.""" """Clear all messages and reset session to initial state."""
self.messages = [] self.messages = []
self.last_consolidated = 0 self.last_archived = 0
self.provider_state = None self.provider_state = None
self.updated_at = datetime.now() self.updated_at = datetime.now()
self.metadata.pop("_last_summary", None) self.metadata.pop("_last_summary", None)
@@ -474,11 +495,11 @@ class Session:
Returns a RetentionResult with dropped messages and how many of those Returns a RetentionResult with dropped messages and how many of those
were in the already-consolidated prefix. This method mutates were in the already-consolidated prefix. This method mutates
self.messages and self.last_consolidated in place. self.messages and self.last_archived in place.
""" """
if max_messages <= 0: if max_messages <= 0:
dropped = list(self.messages) dropped = list(self.messages)
lc = self.last_consolidated lc = self.last_archived
self.clear() self.clear()
return RetentionResult( return RetentionResult(
dropped=dropped, dropped=dropped,
@@ -491,7 +512,7 @@ class Session:
) )
original = list(self.messages) original = list(self.messages)
before_lc = self.last_consolidated before_lc = self.last_archived
start_idx = max(0, len(self.messages) - max_messages) start_idx = max(0, len(self.messages) - max_messages)
if extend_to_user: if extend_to_user:
@@ -551,7 +572,7 @@ class Session:
if i < before_lc and id(m) not in retained_ids if i < before_lc and id(m) not in retained_ids
) )
# New last_consolidated = count of retained messages that were inside # New last_archived = count of retained messages that were inside
# the old consolidated prefix. # the old consolidated prefix.
new_lc = sum( new_lc = sum(
1 for i, m in enumerate(original) 1 for i, m in enumerate(original)
@@ -559,7 +580,7 @@ class Session:
) )
self.messages = retained self.messages = retained
self.last_consolidated = new_lc self.last_archived = new_lc
if dropped: if dropped:
self.provider_state = None self.provider_state = None
self.updated_at = datetime.now() self.updated_at = datetime.now()
@@ -1167,12 +1188,7 @@ class JsonlSessionStore:
if isinstance(updated_at_value, str) and updated_at_value if isinstance(updated_at_value, str) and updated_at_value
else None else None
) )
offset = cast(object, data.get("last_consolidated", 0)) last_consolidated = _archive_offset(data)
last_consolidated = (
offset
if isinstance(offset, int) and not isinstance(offset, bool)
else 0
)
elif record_type == _PROVIDER_STATE_RECORD_TYPE: elif record_type == _PROVIDER_STATE_RECORD_TYPE:
provider_state = ProviderConversationState.from_private_record( provider_state = ProviderConversationState.from_private_record(
data.get("state") data.get("state")
@@ -1254,12 +1270,7 @@ class JsonlSessionStore:
if isinstance(updated_at_value, str) and updated_at_value: if isinstance(updated_at_value, str) and updated_at_value:
with suppress(ValueError): with suppress(ValueError):
updated_at = datetime.fromisoformat(updated_at_value) updated_at = datetime.fromisoformat(updated_at_value)
offset = cast(object, data.get("last_consolidated", 0)) last_consolidated = _archive_offset(data)
last_consolidated = (
offset
if isinstance(offset, int) and not isinstance(offset, bool)
else 0
)
elif record_type == _PROVIDER_STATE_RECORD_TYPE: elif record_type == _PROVIDER_STATE_RECORD_TYPE:
candidate = ProviderConversationState.from_private_record( candidate = ProviderConversationState.from_private_record(
data.get("state") data.get("state")
@@ -1419,6 +1430,9 @@ class JsonlSessionStore:
"created_at": session.created_at.isoformat(), "created_at": session.created_at.isoformat(),
"updated_at": session.updated_at.isoformat(), "updated_at": session.updated_at.isoformat(),
"metadata": session.metadata, "metadata": session.metadata,
"last_archived": session.last_archived,
# Keep old nanobot releases able to read sessions written
# during the field-name migration.
"last_consolidated": session.last_consolidated, "last_consolidated": session.last_consolidated,
} }
f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n") f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n")
@@ -2011,8 +2025,8 @@ class SessionManager:
for key in _FORK_VOLATILE_METADATA_KEYS: for key in _FORK_VOLATILE_METADATA_KEYS:
metadata.pop(key, None) metadata.pop(key, None)
last_consolidated = min(source.last_consolidated, len(copied)) last_consolidated = min(source.last_archived, len(copied))
if source.last_consolidated > len(copied): if source.last_archived > len(copied):
metadata.pop("_last_summary", None) metadata.pop("_last_summary", None)
last_consolidated = 0 last_consolidated = 0
@@ -6,6 +6,8 @@ Use [skip] unless a fact meets all SNIP criteria:
- Important: prevents rework or captures preferences / rules - Important: prevents rework or captures preferences / rules
- Persistent: still relevant after 2 weeks - Persistent: still relevant after 2 weeks
Also preserve a compact working-state handoff even when it is not Persistent: the active objective, current status, completed steps, unresolved blockers, next action, and exact identifiers needed to continue without rework. Mark these facts [ephemeral].
Format each fact as: Format each fact as:
- [mark] fact content - [mark] fact content
+1 -1
View File
@@ -44,7 +44,7 @@ def session_context_payload(session: Session) -> dict[str, Any]:
"schema_version": 1, "schema_version": 1,
"session_key": session.key, "session_key": session.key,
"total_messages": len(session.messages), "total_messages": len(session.messages),
"archived_messages": min(session.last_consolidated, len(session.messages)), "archived_messages": min(session.last_archived, len(session.messages)),
"replay_messages": len(replay), "replay_messages": len(replay),
"estimated_replay_tokens": replay_tokens, "estimated_replay_tokens": replay_tokens,
"estimated_summary_tokens": summary_tokens, "estimated_summary_tokens": summary_tokens,
+32
View File
@@ -28,6 +28,10 @@ from nanobot.config.loader import resolve_config_env_vars
from nanobot.config.schema import Config, FallbackCandidate, ModelPresetConfig, ProviderConfig from nanobot.config.schema import Config, FallbackCandidate, ModelPresetConfig, ProviderConfig
from nanobot.providers.image_generation import get_image_gen_provider from nanobot.providers.image_generation import get_image_gen_provider
from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE
from nanobot.providers.oauth_model_catalog import (
get_oauth_model_catalog,
invalidate_oauth_model_catalog,
)
from nanobot.providers.registry import PROVIDERS, create_dynamic_spec, find_by_name from nanobot.providers.registry import PROVIDERS, create_dynamic_spec, find_by_name
from nanobot.webui.settings_contracts import ( from nanobot.webui.settings_contracts import (
QueryParams, QueryParams,
@@ -661,6 +665,30 @@ def provider_models_payload(
"models": rows, "models": rows,
"model_count": len(rows), "model_count": len(rows),
} }
if catalog_kind == "hybrid":
proxy = _resolve_env_placeholders(provider_config.proxy)
catalog = get_oauth_model_catalog(spec.name, proxy=proxy)
rows = [
{
"id": model.id,
"label": model.label or None,
"description": model.description or None,
"owned_by": model.owned_by or spec.label,
"context_window": model.context_window,
"reasoning_efforts": list(model.reasoning_efforts),
"supports_backend_search": model.supports_backend_search,
}
for model in catalog.models
]
return {
**base_payload,
"status": "available",
"source": catalog.source,
"models": rows,
"model_count": len(rows),
"message": catalog.message,
"fetched_at": catalog.fetched_at,
}
api_base = _resolve_env_placeholders(provider_config.api_base) or spec.default_api_base api_base = _resolve_env_placeholders(provider_config.api_base) or spec.default_api_base
if spec.name == "openai" and not api_base: if spec.name == "openai" and not api_base:
@@ -1506,6 +1534,7 @@ def login_oauth_provider(
token = login_github_copilot(print_fn=lambda _message: None) token = login_github_copilot(print_fn=lambda _message: None)
if not (token and token.access): if not (token and token.access):
raise WebUISettingsError("OAuth login failed", status=401) raise WebUISettingsError("OAuth login failed", status=401)
invalidate_oauth_model_catalog(spec.name)
return settings_payload(config_path=config_path) return settings_payload(config_path=config_path)
if spec.name == "xai_grok": if spec.name == "xai_grok":
@@ -1591,6 +1620,7 @@ def complete_oauth_provider(
oauth_flows.remove(spec.name, flow_id, flow, cancel=False) oauth_flows.remove(spec.name, flow_id, flow, cancel=False)
if not token.access: if not token.access:
raise WebUISettingsError("OAuth login failed", status=401) raise WebUISettingsError("OAuth login failed", status=401)
invalidate_oauth_model_catalog(spec.name)
return settings_payload(config_path=config_path) return settings_payload(config_path=config_path)
@@ -1629,6 +1659,7 @@ def logout_oauth_provider(
oauth_flows.clear(spec.name) oauth_flows.clear(spec.name)
logout_xai_oauth() logout_xai_oauth()
invalidate_oauth_model_catalog(spec.name)
return settings_payload(config_path=config_path) return settings_payload(config_path=config_path)
else: else:
raise WebUISettingsError("OAuth logout is not supported for this provider") raise WebUISettingsError("OAuth logout is not supported for this provider")
@@ -1636,6 +1667,7 @@ def logout_oauth_provider(
for path in (token_path, token_path.with_suffix(".lock")): for path in (token_path, token_path.with_suffix(".lock")):
with suppress(FileNotFoundError): with suppress(FileNotFoundError):
path.unlink() path.unlink()
invalidate_oauth_model_catalog(spec.name)
return settings_payload(config_path=config_path) return settings_payload(config_path=config_path)
+6 -6
View File
@@ -88,11 +88,11 @@ def _make_fake_compact(
state["count"] += 1 state["count"] += 1
session = loop.sessions.get_or_create(key) session = loop.sessions.get_or_create(key)
tail = list(session.messages[session.last_consolidated:]) tail = list(session.messages[session.last_archived:])
if not tail: if not tail:
loop.sessions.save(session) loop.sessions.save(session)
return "" return ""
archive_end = session.last_consolidated + len(tail) archive_end = session.last_archived + len(tail)
archive_msgs = tail archive_msgs = tail
last_active = session.updated_at last_active = session.updated_at
@@ -109,7 +109,7 @@ def _make_fake_compact(
"last_active": last_active.isoformat(), "last_active": last_active.isoformat(),
} }
session.last_consolidated = archive_end session.last_archived = archive_end
loop.sessions.save(session) loop.sessions.save(session)
return s return s
@@ -399,12 +399,12 @@ class TestAutoCompact:
await loop.aclose() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_compact_respects_last_consolidated(self, tmp_path): async def test_auto_compact_respects_last_archived(self, tmp_path):
"""_archive should only archive un-consolidated messages.""" """_archive should process only unarchived messages."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 14) _add_turns(session, 14)
session.last_consolidated = 18 session.last_archived = 18
loop.sessions.save(session) loop.sessions.save(session)
archived_messages = [] archived_messages = []
+3 -3
View File
@@ -16,7 +16,7 @@ def _runtime(_session: Session | None = None):
def _make_session( def _make_session(
key: str = "cli:test", key: str = "cli:test",
messages: list | None = None, messages: list | None = None,
last_consolidated: int = 0, last_archived: int = 0,
updated_at: datetime | None = None, updated_at: datetime | None = None,
metadata: dict | None = None, metadata: dict | None = None,
) -> Session: ) -> Session:
@@ -25,8 +25,8 @@ def _make_session(
key=key, key=key,
messages=messages or [], messages=messages or [],
metadata=metadata or {}, metadata=metadata or {},
last_consolidated=last_consolidated,
) )
session.last_archived = last_archived
if updated_at is not None: if updated_at is not None:
session.updated_at = updated_at session.updated_at = updated_at
return session return session
@@ -408,7 +408,7 @@ class TestCheckExpired:
last_active = datetime(2026, 1, 1, 10, 0, 0) last_active = datetime(2026, 1, 1, 10, 0, 0)
session = _make_session("cli:done", updated_at=last_active) session = _make_session("cli:done", updated_at=last_active)
_add_turns(session, 2) _add_turns(session, 2)
session.last_consolidated = len(session.messages) session.last_archived = len(session.messages)
mock_sm.list_sessions.return_value = [ mock_sm.list_sessions.return_value = [
{"key": "cli:done", "updated_at": last_active.isoformat()}, {"key": "cli:done", "updated_at": last_active.isoformat()},
] ]
-650
View File
@@ -1,650 +0,0 @@
"""Test session management with cache-friendly message handling."""
import asyncio
from collections.abc import Coroutine
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.session.manager import Session, SessionManager
# Test constants
MEMORY_WINDOW = 50
KEEP_COUNT = MEMORY_WINDOW // 2 # 25
def create_session_with_messages(key: str, count: int, role: str = "user") -> Session:
"""Create a session and add the specified number of messages.
Args:
key: Session identifier
count: Number of messages to add
role: Message role (default: "user")
Returns:
Session with the specified messages
"""
session = Session(key=key)
for i in range(count):
session.add_message(role, f"msg{i}")
return session
def assert_messages_content(messages: list, start_index: int, end_index: int) -> None:
"""Assert that messages contain expected content from start to end index.
Args:
messages: List of message dictionaries
start_index: Expected first message index
end_index: Expected last message index
"""
assert len(messages) > 0
assert messages[0]["content"] == f"msg{start_index}"
assert messages[-1]["content"] == f"msg{end_index}"
def get_old_messages(session: Session, last_consolidated: int, keep_count: int) -> list:
"""Extract messages that would be consolidated using the standard slice logic.
Args:
session: The session containing messages
last_consolidated: Index of last consolidated message
keep_count: Number of recent messages to keep
Returns:
List of messages that would be consolidated
"""
return session.messages[last_consolidated:-keep_count]
class TestSessionLastConsolidated:
"""Test last_consolidated tracking to avoid duplicate processing."""
def test_initial_last_consolidated_zero(self) -> None:
"""Test that new session starts with last_consolidated=0."""
session = Session(key="test:initial")
assert session.last_consolidated == 0
def test_last_consolidated_persistence(self, tmp_path) -> None:
"""Test that last_consolidated persists across save/load."""
manager = SessionManager(Path(tmp_path))
session1 = create_session_with_messages("test:persist", 20)
session1.last_consolidated = 15
manager.save(session1)
session2 = manager.get_or_create("test:persist")
assert session2.last_consolidated == 15
assert len(session2.messages) == 20
def test_clear_resets_last_consolidated(self) -> None:
"""Test that clear() resets last_consolidated to 0."""
session = create_session_with_messages("test:clear", 10)
session.last_consolidated = 5
session.clear()
assert len(session.messages) == 0
assert session.last_consolidated == 0
class TestSessionImmutableHistory:
"""Test Session message immutability for cache efficiency."""
def test_initial_state(self) -> None:
"""Test that new session has empty messages list."""
session = Session(key="test:initial")
assert len(session.messages) == 0
def test_add_messages_appends_only(self) -> None:
"""Test that adding messages only appends, never modifies."""
session = Session(key="test:preserve")
session.add_message("user", "msg1")
session.add_message("assistant", "resp1")
session.add_message("user", "msg2")
assert len(session.messages) == 3
assert session.messages[0]["content"] == "msg1"
def test_get_history_returns_most_recent(self) -> None:
"""Test get_history returns the most recent messages."""
session = Session(key="test:history")
for i in range(10):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
history = session.get_history(max_messages=6)
assert len(history) == 6
assert history[0]["content"] == "msg7"
assert history[-1]["content"] == "resp9"
def test_get_history_with_all_messages(self) -> None:
"""Test get_history with max_messages larger than actual."""
session = create_session_with_messages("test:all", 5)
history = session.get_history(max_messages=100)
assert len(history) == 5
assert history[0]["content"] == "msg0"
def test_get_history_stable_for_same_session(self) -> None:
"""Test that get_history returns same content for same max_messages."""
session = create_session_with_messages("test:stable", 20)
history1 = session.get_history(max_messages=10)
history2 = session.get_history(max_messages=10)
assert history1 == history2
def test_messages_list_never_modified(self) -> None:
"""Test that messages list is never modified after creation."""
session = create_session_with_messages("test:immutable", 5)
original_len = len(session.messages)
session.get_history(max_messages=2)
assert len(session.messages) == original_len
for _ in range(10):
session.get_history(max_messages=3)
assert len(session.messages) == original_len
class TestSessionPersistence:
"""Test Session persistence and reload."""
@pytest.fixture
def temp_manager(self, tmp_path):
return SessionManager(Path(tmp_path))
def test_persistence_roundtrip(self, temp_manager):
"""Test that messages persist across save/load."""
session1 = create_session_with_messages("test:persistence", 20)
temp_manager.save(session1)
session2 = temp_manager.get_or_create("test:persistence")
assert len(session2.messages) == 20
assert session2.messages[0]["content"] == "msg0"
assert session2.messages[-1]["content"] == "msg19"
def test_get_history_after_reload(self, temp_manager):
"""Test that get_history works correctly after reload."""
session1 = create_session_with_messages("test:reload", 30)
temp_manager.save(session1)
session2 = temp_manager.get_or_create("test:reload")
history = session2.get_history(max_messages=10)
assert len(history) == 10
assert history[0]["content"] == "msg20"
assert history[-1]["content"] == "msg29"
def test_clear_resets_session(self, temp_manager):
"""Test that clear() properly resets session."""
session = create_session_with_messages("test:clear", 10)
assert len(session.messages) == 10
session.clear()
assert len(session.messages) == 0
class TestConsolidationTriggerConditions:
"""Test consolidation trigger conditions and logic."""
def test_consolidation_needed_when_messages_exceed_window(self):
"""Test consolidation logic: should trigger when messages exceed the window."""
session = create_session_with_messages("test:trigger", 60)
total_messages = len(session.messages)
messages_to_process = total_messages - session.last_consolidated
assert total_messages > MEMORY_WINDOW
assert messages_to_process > 0
expected_consolidate_count = total_messages - KEEP_COUNT
assert expected_consolidate_count == 35
def test_consolidation_skipped_when_within_keep_count(self):
"""Test consolidation skipped when total messages <= keep_count."""
session = create_session_with_messages("test:skip", 20)
total_messages = len(session.messages)
assert total_messages <= KEEP_COUNT
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
assert len(old_messages) == 0
def test_consolidation_skipped_when_no_new_messages(self):
"""Test consolidation skipped when messages_to_process <= 0."""
session = create_session_with_messages("test:already_consolidated", 40)
session.last_consolidated = len(session.messages) - KEEP_COUNT # 15
# Add a few more messages
for i in range(40, 42):
session.add_message("user", f"msg{i}")
total_messages = len(session.messages)
messages_to_process = total_messages - session.last_consolidated
assert messages_to_process > 0
# Simulate last_consolidated catching up
session.last_consolidated = total_messages - KEEP_COUNT
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
assert len(old_messages) == 0
class TestLastConsolidatedEdgeCases:
"""Test last_consolidated edge cases and data corruption scenarios."""
def test_last_consolidated_exceeds_message_count(self):
"""Test behavior when last_consolidated > len(messages) (data corruption)."""
session = create_session_with_messages("test:corruption", 10)
session.last_consolidated = 20
total_messages = len(session.messages)
messages_to_process = total_messages - session.last_consolidated
assert messages_to_process <= 0
old_messages = get_old_messages(session, session.last_consolidated, 5)
assert len(old_messages) == 0
def test_last_consolidated_negative_value(self):
"""Test behavior with negative last_consolidated (invalid state)."""
session = create_session_with_messages("test:negative", 10)
session.last_consolidated = -5
keep_count = 3
old_messages = get_old_messages(session, session.last_consolidated, keep_count)
# messages[-5:-3] with 10 messages gives indices 5,6
assert len(old_messages) == 2
assert old_messages[0]["content"] == "msg5"
assert old_messages[-1]["content"] == "msg6"
def test_messages_added_after_consolidation(self):
"""Test correct behavior when new messages arrive after consolidation."""
session = create_session_with_messages("test:new_messages", 40)
session.last_consolidated = len(session.messages) - KEEP_COUNT # 15
# Add new messages after consolidation
for i in range(40, 50):
session.add_message("user", f"msg{i}")
total_messages = len(session.messages)
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
expected_consolidate_count = total_messages - KEEP_COUNT - session.last_consolidated
assert len(old_messages) == expected_consolidate_count
assert_messages_content(old_messages, 15, 24)
def test_slice_behavior_when_indices_overlap(self):
"""Test slice behavior when last_consolidated >= total - keep_count."""
session = create_session_with_messages("test:overlap", 30)
session.last_consolidated = 12
old_messages = get_old_messages(session, session.last_consolidated, 20)
assert len(old_messages) == 0
class TestArchiveAllMode:
"""Test archive_all mode (used by /new command)."""
def test_archive_all_consolidates_everything(self):
"""Test archive_all=True consolidates all messages."""
session = create_session_with_messages("test:archive_all", 50)
archive_all = True
if archive_all:
old_messages = session.messages
assert len(old_messages) == 50
assert session.last_consolidated == 0
def test_archive_all_resets_last_consolidated(self):
"""Test that archive_all mode resets last_consolidated to 0."""
session = create_session_with_messages("test:reset", 40)
session.last_consolidated = 15
archive_all = True
if archive_all:
session.last_consolidated = 0
assert session.last_consolidated == 0
assert len(session.messages) == 40
def test_archive_all_vs_normal_consolidation(self):
"""Test difference between archive_all and normal consolidation."""
# Normal consolidation
session1 = create_session_with_messages("test:normal", 60)
session1.last_consolidated = len(session1.messages) - KEEP_COUNT
# archive_all mode
session2 = create_session_with_messages("test:all", 60)
session2.last_consolidated = 0
assert session1.last_consolidated == 35
assert len(session1.messages) == 60
assert session2.last_consolidated == 0
assert len(session2.messages) == 60
class TestCacheImmutability:
"""Test that consolidation doesn't modify session.messages (cache safety)."""
def test_consolidation_does_not_modify_messages_list(self):
"""Test that consolidation leaves messages list unchanged."""
session = create_session_with_messages("test:immutable", 50)
original_messages = session.messages.copy()
original_len = len(session.messages)
session.last_consolidated = original_len - KEEP_COUNT
assert len(session.messages) == original_len
assert session.messages == original_messages
def test_get_history_does_not_modify_messages(self):
"""Test that get_history doesn't modify messages list."""
session = create_session_with_messages("test:history_immutable", 40)
original_messages = [m.copy() for m in session.messages]
for _ in range(5):
history = session.get_history(max_messages=10)
assert len(history) == 10
assert len(session.messages) == 40
for i, msg in enumerate(session.messages):
assert msg["content"] == original_messages[i]["content"]
def test_consolidation_only_updates_last_consolidated(self):
"""Test that consolidation only updates last_consolidated field."""
session = create_session_with_messages("test:field_only", 60)
original_messages = session.messages.copy()
original_key = session.key
original_metadata = session.metadata.copy()
session.last_consolidated = len(session.messages) - KEEP_COUNT
assert session.messages == original_messages
assert session.key == original_key
assert session.metadata == original_metadata
assert session.last_consolidated == 35
class TestSliceLogic:
"""Test the slice logic: messages[last_consolidated:-keep_count]."""
def test_slice_extracts_correct_range(self):
"""Test that slice extracts the correct message range."""
session = create_session_with_messages("test:slice", 60)
old_messages = get_old_messages(session, 0, KEEP_COUNT)
assert len(old_messages) == 35
assert_messages_content(old_messages, 0, 34)
remaining = session.messages[-KEEP_COUNT:]
assert len(remaining) == 25
assert_messages_content(remaining, 35, 59)
def test_slice_with_partial_consolidation(self):
"""Test slice when some messages already consolidated."""
session = create_session_with_messages("test:partial", 70)
last_consolidated = 30
old_messages = get_old_messages(session, last_consolidated, KEEP_COUNT)
assert len(old_messages) == 15
assert_messages_content(old_messages, 30, 44)
def test_slice_with_various_keep_counts(self):
"""Test slice behavior with different keep_count values."""
session = create_session_with_messages("test:keep_counts", 50)
test_cases = [(10, 40), (20, 30), (30, 20), (40, 10)]
for keep_count, expected_count in test_cases:
old_messages = session.messages[0:-keep_count]
assert len(old_messages) == expected_count
def test_slice_when_keep_count_exceeds_messages(self):
"""Test slice when keep_count > len(messages)."""
session = create_session_with_messages("test:exceed", 10)
old_messages = session.messages[0:-20]
assert len(old_messages) == 0
class TestEmptyAndBoundarySessions:
"""Test empty sessions and boundary conditions."""
def test_empty_session_consolidation(self):
"""Test consolidation behavior with empty session."""
session = Session(key="test:empty")
assert len(session.messages) == 0
assert session.last_consolidated == 0
messages_to_process = len(session.messages) - session.last_consolidated
assert messages_to_process == 0
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
assert len(old_messages) == 0
def test_single_message_session(self):
"""Test consolidation with single message."""
session = Session(key="test:single")
session.add_message("user", "only message")
assert len(session.messages) == 1
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
assert len(old_messages) == 0
def test_exactly_keep_count_messages(self):
"""Test session with exactly keep_count messages."""
session = create_session_with_messages("test:exact", KEEP_COUNT)
assert len(session.messages) == KEEP_COUNT
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
assert len(old_messages) == 0
def test_just_over_keep_count(self):
"""Test session with one message over keep_count."""
session = create_session_with_messages("test:over", KEEP_COUNT + 1)
assert len(session.messages) == 26
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
assert len(old_messages) == 1
assert old_messages[0]["content"] == "msg0"
def test_very_large_session(self):
"""Test consolidation with very large message count."""
session = create_session_with_messages("test:large", 1000)
assert len(session.messages) == 1000
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
assert len(old_messages) == 975
assert_messages_content(old_messages, 0, 974)
remaining = session.messages[-KEEP_COUNT:]
assert len(remaining) == 25
assert_messages_content(remaining, 975, 999)
def test_session_with_gaps_in_consolidation(self):
"""Test session with potential gaps in consolidation history."""
session = create_session_with_messages("test:gaps", 50)
session.last_consolidated = 10
# Add more messages
for i in range(50, 60):
session.add_message("user", f"msg{i}")
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
expected_count = 60 - KEEP_COUNT - 10
assert len(old_messages) == expected_count
assert_messages_content(old_messages, 10, 34)
class TestNewCommandArchival:
"""Test /new archival behavior with the simplified consolidation flow."""
@staticmethod
def _make_loop(tmp_path: Path):
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import GenerationSettings, LLMResponse
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.estimate_prompt_tokens.return_value = (10_000, "test")
provider.generation = GenerationSettings(max_tokens=100)
loop = AgentLoop(
bus=bus,
provider=provider,
workspace=tmp_path,
model="test-model",
context_window_tokens=1,
)
loop.provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
loop.tools.get_definitions = MagicMock(return_value=[])
return loop
@pytest.mark.asyncio
async def test_new_clears_session_immediately_even_if_archive_fails(self, tmp_path: Path) -> None:
"""/new clears session immediately; archive is fire-and-forget."""
from nanobot.bus.events import InboundMessage
loop = self._make_loop(tmp_path)
session = loop.sessions.get_or_create("cli:test")
for i in range(5):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
loop.sessions.save(session)
call_count = 0
expected_runtime = loop.llm_runtime()
async def _failing_summarize(session, *, archive_end, runtime) -> None:
nonlocal call_count
assert runtime is expected_runtime
assert session.key == "cli:test"
assert archive_end == len(session.messages)
call_count += 1
loop.consolidator.archive_session = _failing_summarize # type: ignore[method-assign]
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
response = await loop._process_message(new_msg, runtime=expected_runtime)
assert response is not None
assert "new session started" in response.content.lower()
session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == 0
await loop.aclose()
assert call_count == 1
@pytest.mark.asyncio
async def test_new_reuses_replay_prefix_and_archives_only_unconsolidated_messages(
self,
tmp_path: Path,
) -> None:
from nanobot.bus.events import InboundMessage
loop = self._make_loop(tmp_path)
loop.set_runtime_context_window(128_000)
session = loop.sessions.get_or_create("cli:test")
for i in range(5):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
session.last_consolidated = len(session.messages) - 2
ordinary_history = session.get_history()
assert [message["content"] for message in ordinary_history] == [
"msg1",
"resp1",
"msg2",
"resp2",
"msg3",
"resp3",
"msg4",
"resp4",
]
loop.sessions.save(session)
expected_runtime = loop.llm_runtime()
scheduled: list[Coroutine[Any, Any, object]] = []
loop.schedule_background = scheduled.append # type: ignore[method-assign]
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
response = await loop._process_message(new_msg, runtime=expected_runtime)
assert response is not None
assert "new session started" in response.content.lower()
assert len(scheduled) == 1
await scheduled[0]
await loop.aclose()
sent = loop.provider.chat_with_retry.call_args.kwargs["messages"]
assert sent[1:-1] == ordinary_history
assert "final 2 conversation messages" in sent[-1]["content"]
@pytest.mark.asyncio
async def test_new_clears_session_and_responds(self, tmp_path: Path) -> None:
from nanobot.bus.events import InboundMessage
loop = self._make_loop(tmp_path)
session = loop.sessions.get_or_create("cli:test")
for i in range(3):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
loop.sessions.save(session)
expected_runtime = loop.llm_runtime()
async def _ok_summarize(session, *, archive_end, runtime) -> str:
assert runtime is expected_runtime
assert session.key == "cli:test"
assert archive_end == len(session.messages)
return "Summary."
loop.consolidator.archive_session = _ok_summarize # type: ignore[method-assign]
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
response = await loop._process_message(new_msg, runtime=expected_runtime)
assert response is not None
assert "new session started" in response.content.lower()
assert loop.sessions.get_or_create("cli:test").messages == []
@pytest.mark.asyncio
async def test_aclose_drains_background_tasks(self, tmp_path: Path) -> None:
"""aclose waits for background tasks to complete."""
from nanobot.bus.events import InboundMessage
loop = self._make_loop(tmp_path)
session = loop.sessions.get_or_create("cli:test")
for i in range(3):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
loop.sessions.save(session)
archived = asyncio.Event()
release_archive = asyncio.Event()
expected_runtime = loop.llm_runtime()
async def _slow_summarize(session, *, archive_end, runtime) -> str:
assert runtime is expected_runtime
assert session.key == "cli:test"
assert archive_end == len(session.messages)
await release_archive.wait()
archived.set()
return "Summary."
loop.consolidator.archive_session = _slow_summarize # type: ignore[method-assign]
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
await loop._process_message(new_msg, runtime=expected_runtime)
assert not archived.is_set()
release_archive.set()
await loop.aclose()
assert archived.is_set()
-112
View File
@@ -1,112 +0,0 @@
"""Tests for configurable consolidation_ratio."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from pydantic import ValidationError
import nanobot.agent.memory as memory_module
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import GenerationSettings, LLMResponse
def _make_loop(
tmp_path,
*,
estimated_tokens: int = 0,
context_window_tokens: int = 200,
consolidation_ratio: float = 0.5,
) -> AgentLoop:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.generation = GenerationSettings(max_tokens=0)
provider.estimate_prompt_tokens.return_value = (estimated_tokens, "test-counter")
_response = LLMResponse(content="ok", tool_calls=[])
provider.chat_with_retry = AsyncMock(return_value=_response)
provider.chat_stream_with_retry = AsyncMock(return_value=_response)
loop = AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
model="test-model",
context_window_tokens=context_window_tokens,
consolidation_ratio=consolidation_ratio,
)
loop.tools.get_definitions = MagicMock(return_value=[])
loop.consolidator._SAFETY_BUFFER = 0
return loop
def _session_with_turns(loop: AgentLoop, *, turns: int):
session = loop.sessions.get_or_create("cli:test")
session.messages = []
for i in range(turns):
session.messages.append({"role": "user", "content": f"u{i}", "timestamp": f"2026-01-01T00:00:{i:02d}"})
session.messages.append({"role": "assistant", "content": f"a{i}", "timestamp": f"2026-01-01T00:01:{i:02d}"})
loop.sessions.save(session)
return session
@pytest.mark.asyncio
@pytest.mark.parametrize(
("ratio", "context_window_tokens", "estimates", "expected_archives"),
[
(0.5, 200, [250, 90], 1),
(0.1, 1000, [1200, 800, 400, 50], 2),
(0.9, 200, [300, 175], 1),
],
)
async def test_consolidation_ratio_controls_target(
tmp_path,
monkeypatch,
ratio: float,
context_window_tokens: int,
estimates: list[int],
expected_archives: int,
) -> None:
loop = _make_loop(
tmp_path,
context_window_tokens=context_window_tokens,
consolidation_ratio=ratio,
)
loop.consolidator.archive_session = AsyncMock(return_value=True) # type: ignore[method-assign]
session = _session_with_turns(loop, turns=10)
remaining_estimates = list(estimates)
runtime = loop.llm_runtime()
def mock_estimate(_session, *, runtime):
return (remaining_estimates.pop(0), "test")
loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 100)
await loop.consolidator.maybe_consolidate_by_tokens(
session,
runtime=runtime,
)
assert loop.consolidator.archive_session.await_count == expected_archives
def test_ratio_propagated_from_config_schema() -> None:
defaults = AgentDefaults()
assert defaults.consolidation_ratio == 0.5
defaults = AgentDefaults.model_validate({"consolidationRatio": 0.3})
assert defaults.consolidation_ratio == 0.3
dumped = defaults.model_dump(by_alias=True)
assert dumped["consolidationRatio"] == 0.3
def test_ratio_validation_rejects_out_of_range() -> None:
with pytest.raises(ValidationError):
AgentDefaults(consolidation_ratio=0.05)
with pytest.raises(ValidationError):
AgentDefaults(consolidation_ratio=1.0)
+46 -42
View File
@@ -232,17 +232,19 @@ class TestConsolidatorSummarize:
class TestConsolidatorPromptContract: class TestConsolidatorPromptContract:
def test_archive_prompt_outputs_attribute_tags_without_missing_context_claims(self): def test_archive_prompt_preserves_working_state_with_memory_facts(self):
prompt = render_template("agent/consolidator_archive.md", strip=True, archive_count=4) prompt = render_template("agent/consolidator_archive.md", strip=True, archive_count=4)
assert "SNIP" in prompt assert "SNIP" in prompt
assert "final 4 conversation messages" in prompt assert "final 4 conversation messages" in prompt
for mark in ("[permanent]", "[durable]", "[ephemeral]", "[correction]", "[skip]"): for mark in ("[permanent]", "[durable]", "[ephemeral]", "[correction]", "[skip]"):
assert mark in prompt assert mark in prompt
assert "check context below" not in prompt.lower() assert "working-state handoff" in prompt
assert "exact identifiers needed to continue without rework" in prompt
assert "Do not output facts already present in the system prompt's Recent History" in prompt assert "Do not output facts already present in the system prompt's Recent History" in prompt
assert "Do not mark something [skip] merely because it might already exist" in prompt assert "Do not mark something [skip] merely because it might already exist" in prompt
class TestConsolidatorArchiveErrorHandling: class TestConsolidatorArchiveErrorHandling:
"""archive() must fall back when the LLM does not complete its overview. """archive() must fall back when the LLM does not complete its overview.
@@ -342,7 +344,7 @@ class TestConsolidatorTokenBudget:
): ):
"""No consolidation when tokens are within budget.""" """No consolidation when tokens are within budget."""
session = MagicMock() session = MagicMock()
session.last_consolidated = 0 session.last_archived = 0
session.messages = [{"role": "user", "content": "hi"}] session.messages = [{"role": "user", "content": "hi"}]
session.key = "test:key" session.key = "test:key"
consolidator.sessions._session_cache[session.key] = session consolidator.sessions._session_cache[session.key] = session
@@ -362,7 +364,7 @@ class TestConsolidatorTokenBudget:
with pytest.raises(RuntimeError, match="counter failed"): with pytest.raises(RuntimeError, match="counter failed"):
await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime) await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime)
async def test_estimate_uses_full_unconsolidated_tail(self, consolidator, runtime): async def test_estimate_uses_full_unarchived_tail(self, consolidator, runtime):
"""Consolidation pressure must account for the full unarchived tail.""" """Consolidation pressure must account for the full unarchived tail."""
session = Session(key="test:full-tail") session = Session(key="test:full-tail")
for i in range(160): for i in range(160):
@@ -385,7 +387,7 @@ class TestConsolidatorTokenBudget:
session = Session(key="test:archived-replay") session = Session(key="test:archived-replay")
for i in range(10): for i in range(10):
session.add_message("user", f"msg-{i}") session.add_message("user", f"msg-{i}")
session.last_consolidated = len(session.messages) session.last_archived = len(session.messages)
captured: dict[str, list[dict]] = {} captured: dict[str, list[dict]] = {}
@@ -420,8 +422,8 @@ class TestConsolidatorTokenBudget:
consolidator.estimate_session_prompt_tokens = MagicMock( consolidator.estimate_session_prompt_tokens = MagicMock(
side_effect=[(1200, "tiktoken"), (400, "tiktoken")] side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
) )
consolidator.pick_consolidation_boundary = MagicMock(return_value=(50, 800)) consolidator.pick_consolidation_boundary = MagicMock(return_value=50)
consolidator._build_messages = MagicMock(side_effect=_build_test_messages) consolidator.archiver._build_messages = MagicMock(side_effect=_build_test_messages)
mock_provider.estimate_prompt_tokens.return_value = (100, "test-counter") mock_provider.estimate_prompt_tokens.return_value = (100, "test-counter")
mock_provider.chat_with_retry.return_value = LLMResponse( mock_provider.chat_with_retry.return_value = LLMResponse(
content="Token overflow summary.", content="Token overflow summary.",
@@ -437,10 +439,10 @@ class TestConsolidatorTokenBudget:
assert "final 50 conversation messages" in request["messages"][-1]["content"] assert "final 50 conversation messages" in request["messages"][-1]["content"]
assert request["tools"] == [] assert request["tools"] == []
assert request["tool_choice"] == "none" assert request["tool_choice"] == "none"
assert session.last_consolidated == 50 assert session.last_archived == 50
assert session.provider_state is None assert session.provider_state == _provider_state()
async def test_raw_archive_fallback_advances_last_consolidated( async def test_raw_archive_fallback_advances_archive_watermark(
self, consolidator, runtime self, consolidator, runtime
): ):
"""When archive() falls back to raw-archive (LLM failed), the cursor """When archive() falls back to raw-archive (LLM failed), the cursor
@@ -448,14 +450,12 @@ class TestConsolidatorTokenBudget:
on every subsequent maybe_consolidate_by_tokens() call, spamming on every subsequent maybe_consolidate_by_tokens() call, spamming
duplicate [RAW] entries into history.jsonl.""" duplicate [RAW] entries into history.jsonl."""
consolidator._SAFETY_BUFFER = 0 consolidator._SAFETY_BUFFER = 0
session = MagicMock() session = Session(key="test:key")
session.last_consolidated = 0 session.provider_state = _provider_state()
session.key = "test:key"
session.messages = [ session.messages = [
{"role": "user" if i in {0, 50} else "assistant", "content": f"m{i}"} {"role": "user" if i in {0, 50} else "assistant", "content": f"m{i}"}
for i in range(70) for i in range(70)
] ]
session.metadata = {}
consolidator.sessions._session_cache[session.key] = session consolidator.sessions._session_cache[session.key] = session
consolidator.estimate_session_prompt_tokens = MagicMock( consolidator.estimate_session_prompt_tokens = MagicMock(
side_effect=[(1200, "tiktoken"), (400, "tiktoken")] side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
@@ -467,8 +467,10 @@ class TestConsolidatorTokenBudget:
consolidator.archive_session.assert_awaited_once() consolidator.archive_session.assert_awaited_once()
# The chunk is considered "materialized" (as a raw-archive breadcrumb), # The chunk is considered "materialized" (as a raw-archive breadcrumb),
# so last_consolidated must have moved past it. # so the archive watermark must have moved past it without touching
assert session.last_consolidated == 50 # the provider-owned continuation state.
assert session.last_archived == 50
assert session.provider_state == _provider_state()
async def test_raw_archive_fallback_breaks_round_loop( async def test_raw_archive_fallback_breaks_round_loop(
self, consolidator, runtime self, consolidator, runtime
@@ -477,7 +479,7 @@ class TestConsolidatorTokenBudget:
same maybe_consolidate_by_tokens invocation bail after one fallback.""" same maybe_consolidate_by_tokens invocation bail after one fallback."""
consolidator._SAFETY_BUFFER = 0 consolidator._SAFETY_BUFFER = 0
session = MagicMock() session = MagicMock()
session.last_consolidated = 0 session.last_archived = 0
session.key = "test:key" session.key = "test:key"
session.messages = [ session.messages = [
{"role": "user" if i in {0, 20, 40, 60} else "assistant", "content": f"m{i}"} {"role": "user" if i in {0, 20, 40, 60} else "assistant", "content": f"m{i}"}
@@ -493,7 +495,7 @@ class TestConsolidatorTokenBudget:
await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime) await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime)
# Exactly one fallback per call — not _MAX_CONSOLIDATION_ROUNDS. # The fixed policy archives at most one prefix per call.
assert consolidator.archive_session.await_count == 1 assert consolidator.archive_session.await_count == 1
async def test_boundary_respected_when_no_intermediate_user_turn( async def test_boundary_respected_when_no_intermediate_user_turn(
@@ -502,7 +504,7 @@ class TestConsolidatorTokenBudget:
"""When boundary points past a long tool chain, the full chunk is archived.""" """When boundary points past a long tool chain, the full chunk is archived."""
consolidator._SAFETY_BUFFER = 0 consolidator._SAFETY_BUFFER = 0
session = MagicMock() session = MagicMock()
session.last_consolidated = 0 session.last_archived = 0
session.key = "test:key" session.key = "test:key"
session.messages = [ session.messages = [
{ {
@@ -520,8 +522,8 @@ class TestConsolidatorTokenBudget:
await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime) await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime)
consolidator.archive_session.assert_awaited_once() consolidator.archive_session.assert_awaited_once()
# pick_consolidation_boundary finds the only boundary at idx=61 # The fixed recent tail expands backward to the user at idx=61.
assert session.last_consolidated == 61 assert session.last_archived == 61
class TestCompactIdleSession: class TestCompactIdleSession:
@@ -575,8 +577,8 @@ class TestCompactIdleSession:
reloaded = sessions.get_or_create("cli:test") reloaded = sessions.get_or_create("cli:test")
assert len(reloaded.messages) == 40 assert len(reloaded.messages) == 40
assert reloaded.messages[0]["content"] == "user msg 0" assert reloaded.messages[0]["content"] == "user msg 0"
assert reloaded.last_consolidated == 40 assert reloaded.last_archived == 40
assert reloaded.provider_state is None assert reloaded.provider_state == _provider_state()
visible = reloaded.get_history(max_messages=40) visible = reloaded.get_history(max_messages=40)
assert len(visible) == 8 assert len(visible) == 8
assert visible[0]["content"] == "user msg 16" assert visible[0]["content"] == "user msg 16"
@@ -608,7 +610,7 @@ class TestCompactIdleSession:
mock_provider.chat_with_retry.assert_awaited_once() mock_provider.chat_with_retry.assert_awaited_once()
assert len(store.read_unprocessed_history(since_cursor=0)) == 1 assert len(store.read_unprocessed_history(since_cursor=0)) == 1
reloaded = sessions.get_or_create("cli:short") reloaded = sessions.get_or_create("cli:short")
assert reloaded.last_consolidated == 2 assert reloaded.last_archived == 2
assert [message["content"] for message in reloaded.get_history()] == ["hello", "hi"] assert [message["content"] for message in reloaded.get_history()] == ["hello", "hi"]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -640,7 +642,7 @@ class TestCompactIdleSession:
"second assistant", "second assistant",
] ]
assert "final 2 conversation messages" in latest_messages[-1]["content"] assert "final 2 conversation messages" in latest_messages[-1]["content"]
assert sessions.get_or_create("cli:incremental").last_consolidated == 4 assert sessions.get_or_create("cli:incremental").last_archived == 4
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_concurrent_append_remains_unarchived( async def test_concurrent_append_remains_unarchived(
@@ -664,13 +666,13 @@ class TestCompactIdleSession:
reloaded = sessions.get_or_create("cli:concurrent") reloaded = sessions.get_or_create("cli:concurrent")
assert len(reloaded.messages) == 4 assert len(reloaded.messages) == 4
assert reloaded.last_consolidated == 2 assert reloaded.last_archived == 2
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_summarizes_retained_suffix_not_just_dropped_prefix( async def test_summarizes_retained_suffix_not_just_dropped_prefix(
self, real_consolidator, mock_provider, runtime self, real_consolidator, mock_provider, runtime
): ):
"""idleCompact must summarize over the full unconsolidated tail, including """idleCompact must summarize over the full unarchived tail, including
the recent suffix it retains. Otherwise a late user correction / final the recent suffix it retains. Otherwise a late user correction / final
result that lands in the kept suffix is excluded from the persisted result that lands in the kept suffix is excluded from the persisted
summary, leaving a stale wrong conclusion in history. Regression for #4264.""" summary, leaving a stale wrong conclusion in history. Regression for #4264."""
@@ -705,6 +707,7 @@ class TestCompactIdleSession:
mock_provider.chat_with_retry.side_effect = RuntimeError("LLM unavailable") mock_provider.chat_with_retry.side_effect = RuntimeError("LLM unavailable")
sessions = real_consolidator.sessions sessions = real_consolidator.sessions
session = sessions.get_or_create("cli:rawdrop") session = sessions.get_or_create("cli:rawdrop")
session.provider_state = _provider_state()
for i in range(18): for i in range(18):
session.add_message("user", f"user msg {i}") session.add_message("user", f"user msg {i}")
session.add_message("assistant", f"assistant msg {i}") session.add_message("assistant", f"assistant msg {i}")
@@ -723,6 +726,7 @@ class TestCompactIdleSession:
reloaded = sessions.get_or_create("cli:rawdrop") reloaded = sessions.get_or_create("cli:rawdrop")
assert len(reloaded.messages) == 38 assert len(reloaded.messages) == 38
assert reloaded.messages[-1]["content"] == "RETAINED_SUFFIX_marker" assert reloaded.messages[-1]["content"] == "RETAINED_SUFFIX_marker"
assert reloaded.provider_state == _provider_state()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_idle_compact_writes_session_key_to_history( async def test_idle_compact_writes_session_key_to_history(
@@ -818,7 +822,7 @@ class TestCompactIdleSession:
reloaded = sessions.get_or_create("cli:fail") reloaded = sessions.get_or_create("cli:fail")
assert len(reloaded.messages) == 20 assert len(reloaded.messages) == 20
assert reloaded.messages[0]["content"] == "u0" assert reloaded.messages[0]["content"] == "u0"
assert reloaded.last_consolidated == 20 assert reloaded.last_archived == 20
assert [m["content"] for m in reloaded.get_history(max_messages=20)] == [ assert [m["content"] for m in reloaded.get_history(max_messages=20)] == [
"u6", "u6",
"a6", "a6",
@@ -831,10 +835,10 @@ class TestCompactIdleSession:
] ]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_respects_last_consolidated( async def test_respects_last_archived(
self, real_consolidator, mock_provider, runtime self, real_consolidator, mock_provider, runtime
): ):
"""30 turns with last_consolidated=50 → only unconsolidated tail considered.""" """30 turns with last_archived=50 → only the unarchived tail is considered."""
mock_provider.chat_with_retry.return_value = MagicMock( mock_provider.chat_with_retry.return_value = MagicMock(
content="Tail summary.", finish_reason="stop" content="Tail summary.", finish_reason="stop"
) )
@@ -843,7 +847,7 @@ class TestCompactIdleSession:
for i in range(30): for i in range(30):
session.add_message("user", f"u{i}") session.add_message("user", f"u{i}")
session.add_message("assistant", f"a{i}") session.add_message("assistant", f"a{i}")
session.last_consolidated = 50 # Only 10 messages unconsolidated session.last_archived = 50 # Only 10 messages remain unarchived
sessions.save(session) sessions.save(session)
result = await real_consolidator.compact_idle_session( result = await real_consolidator.compact_idle_session(
@@ -852,10 +856,10 @@ class TestCompactIdleSession:
assert result == "Tail summary." assert result == "Tail summary."
reloaded = sessions.get_or_create("cli:offset") reloaded = sessions.get_or_create("cli:offset")
assert len(reloaded.messages) == 60 assert len(reloaded.messages) == 60
assert reloaded.last_consolidated == 60 assert reloaded.last_archived == 60
# Verify only the unconsolidated tail was processed: # Verify only the unarchived tail was processed:
# All 10 unconsolidated messages (50-59) are archived exactly once. # All 10 unarchived messages (50-59) are archived exactly once.
archived_call = mock_provider.chat_with_retry.call_args archived_call = mock_provider.chat_with_retry.call_args
sent_messages = archived_call.kwargs["messages"] sent_messages = archived_call.kwargs["messages"]
sent_content = [message.get("content") for message in sent_messages] sent_content = [message.get("content") for message in sent_messages]
@@ -890,7 +894,7 @@ class TestCompactIdleSession:
reloaded = sessions.get_or_create("cli:noncontiguous") reloaded = sessions.get_or_create("cli:noncontiguous")
assert len(reloaded.messages) == 25 assert len(reloaded.messages) == 25
assert reloaded.last_consolidated == 25 assert reloaded.last_archived == 25
assert [m["content"] for m in reloaded.get_history(max_messages=25)] == [ assert [m["content"] for m in reloaded.get_history(max_messages=25)] == [
"user-14", "user-14",
"assistant-00", "assistant-00",
@@ -905,7 +909,7 @@ class TestCompactIdleSession:
"assistant-09", "assistant-09",
] ]
# #4264: idle compaction now summarizes the full unconsolidated tail, so # #4264: idle compaction now summarizes the full unarchived tail, so
# the dropped head (user-00) and retained suffix (user-14 through # the dropped head (user-00) and retained suffix (user-14 through
# assistant-09) are all summarized. # assistant-09) are all summarized.
archived_call = mock_provider.chat_with_retry.call_args archived_call = mock_provider.chat_with_retry.call_args
@@ -923,7 +927,7 @@ class TestCompactIdleSession:
runtime, runtime,
): ):
tools = [{"type": "function", "function": {"name": "lookup"}}] tools = [{"type": "function", "function": {"name": "lookup"}}]
real_consolidator._get_tool_definitions.return_value = tools real_consolidator.archiver._get_tool_definitions.return_value = tools
mock_provider.chat_with_retry.return_value = LLMResponse( mock_provider.chat_with_retry.return_value = LLMResponse(
content="Overview from the temporary turn.", content="Overview from the temporary turn.",
finish_reason="stop", finish_reason="stop",
@@ -997,7 +1001,7 @@ class TestCompactIdleSession:
assert len(entries) == 1 assert len(entries) == 1
assert entries[0]["content"].startswith("[RAW] ") assert entries[0]["content"].startswith("[RAW] ")
assert "important answer" in entries[0]["content"] assert "important answer" in entries[0]["content"]
assert sessions.get_or_create("cli:unexpected-tool").last_consolidated == 2 assert sessions.get_or_create("cli:unexpected-tool").last_archived == 2
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_empty_response_uses_raw_fallback( async def test_empty_response_uses_raw_fallback(
@@ -1027,7 +1031,7 @@ class TestCompactIdleSession:
assert len(entries) == 1 assert len(entries) == 1
assert entries[0]["content"].startswith("[RAW] ") assert entries[0]["content"].startswith("[RAW] ")
assert "important answer" in entries[0]["content"] assert "important answer" in entries[0]["content"]
assert sessions.get_or_create("cli:empty-summary").last_consolidated == 2 assert sessions.get_or_create("cli:empty-summary").last_archived == 2
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_oversized_prefix_raw_archives_without_flattened_llm_retry( async def test_oversized_prefix_raw_archives_without_flattened_llm_retry(
@@ -1053,7 +1057,7 @@ class TestCompactIdleSession:
entries = store.read_unprocessed_history(since_cursor=0) entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 1 assert len(entries) == 1
assert entries[0]["content"].startswith("[RAW] ") assert entries[0]["content"].startswith("[RAW] ")
assert sessions.get_or_create("sdk:oversized").last_consolidated == 1 assert sessions.get_or_create("sdk:oversized").last_archived == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_incremental_scope_counts_only_model_visible_messages( async def test_incremental_scope_counts_only_model_visible_messages(
@@ -1070,7 +1074,7 @@ class TestCompactIdleSession:
session = sessions.get_or_create("cli:commands") session = sessions.get_or_create("cli:commands")
session.add_message("user", "already archived user") session.add_message("user", "already archived user")
session.add_message("assistant", "already archived answer") session.add_message("assistant", "already archived answer")
session.last_consolidated = 2 session.last_archived = 2
session.add_message("user", "/status", _command=True) session.add_message("user", "/status", _command=True)
session.add_message("assistant", "status output", _command=True) session.add_message("assistant", "status output", _command=True)
session.add_message("user", "new user") session.add_message("user", "new user")
@@ -1278,7 +1282,7 @@ class TestConsolidatorSessionRefresh:
session_after = sessions.get_or_create("cli:test") session_after = sessions.get_or_create("cli:test")
assert len(session_after.messages) == 40 assert len(session_after.messages) == 40
assert session_after.last_consolidated == 40 assert session_after.last_archived == 40
assert len(session_after.get_history(max_messages=40)) == 8 assert len(session_after.get_history(max_messages=40)) == 8
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
def _provider() -> MagicMock:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.generation = SimpleNamespace(
max_tokens=4096,
temperature=0.1,
reasoning_effort=None,
)
return provider
def test_request_concurrency_is_unlimited_by_default(
monkeypatch: pytest.MonkeyPatch,
loop_factory,
) -> None:
monkeypatch.delenv("NANOBOT_MAX_CONCURRENT_REQUESTS", raising=False)
loop = loop_factory(provider=_provider(), patch_deps=True)
assert loop._concurrency_gate is None
@pytest.mark.asyncio
async def test_positive_request_concurrency_keeps_explicit_cap(
monkeypatch: pytest.MonkeyPatch,
loop_factory,
) -> None:
monkeypatch.setenv("NANOBOT_MAX_CONCURRENT_REQUESTS", "2")
loop = loop_factory(provider=_provider(), patch_deps=True)
gate = loop._concurrency_gate
assert gate is not None
for _ in range(2):
await gate.acquire()
try:
assert gate.locked()
finally:
for _ in range(2):
gate.release()
+19 -111
View File
@@ -2,7 +2,6 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
import nanobot.agent.memory as memory_module
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse from nanobot.providers.base import LLMResponse
@@ -41,17 +40,16 @@ async def test_prompt_below_threshold_does_not_consolidate(tmp_path) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_prompt_above_threshold_triggers_consolidation(tmp_path, monkeypatch) -> None: async def test_prompt_above_threshold_triggers_consolidation(tmp_path) -> None:
loop = _make_loop(tmp_path, estimated_tokens=1000, context_window_tokens=200) loop = _make_loop(tmp_path, estimated_tokens=1000, context_window_tokens=200)
loop.consolidator.archive_session = AsyncMock(return_value=True) # type: ignore[method-assign] loop.consolidator.archive_session = AsyncMock(return_value=True) # type: ignore[method-assign]
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
session.messages = [ session.messages = [
{"role": "user", "content": "u1", "timestamp": "2026-01-01T00:00:00"}, {"role": role, "content": f"{role[0]}{turn}"}
{"role": "assistant", "content": "a1", "timestamp": "2026-01-01T00:00:01"}, for turn in range(10)
{"role": "user", "content": "u2", "timestamp": "2026-01-01T00:00:02"}, for role in ("user", "assistant")
] ]
loop.sessions.save(session) loop.sessions.save(session)
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _message: 500)
await loop.process_direct("hello", session_key="cli:test") await loop.process_direct("hello", session_key="cli:test")
@@ -59,23 +57,18 @@ async def test_prompt_above_threshold_triggers_consolidation(tmp_path, monkeypat
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_prompt_above_threshold_archives_until_next_user_boundary(tmp_path, monkeypatch) -> None: async def test_prompt_above_threshold_uses_fixed_recent_tail(tmp_path) -> None:
loop = _make_loop(tmp_path, estimated_tokens=1000, context_window_tokens=200) loop = _make_loop(tmp_path, estimated_tokens=1000, context_window_tokens=200)
loop.consolidator.archive_session = AsyncMock(return_value=True) # type: ignore[method-assign] loop.consolidator.archive_session = AsyncMock(return_value=True) # type: ignore[method-assign]
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
session.messages = [ session.messages = [
{"role": "user", "content": "u1", "timestamp": "2026-01-01T00:00:00"}, {"role": role, "content": f"{role[0]}{turn}"}
{"role": "assistant", "content": "a1", "timestamp": "2026-01-01T00:00:01"}, for turn in range(10)
{"role": "user", "content": "u2", "timestamp": "2026-01-01T00:00:02"}, for role in ("user", "assistant")
{"role": "assistant", "content": "a2", "timestamp": "2026-01-01T00:00:03"},
{"role": "user", "content": "u3", "timestamp": "2026-01-01T00:00:04"},
] ]
loop.sessions.save(session) loop.sessions.save(session)
token_map = {"u1": 120, "a1": 120, "u2": 120, "a2": 120, "u3": 120}
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda message: token_map[message["content"]])
await loop.consolidator.maybe_consolidate_by_tokens( await loop.consolidator.maybe_consolidate_by_tokens(
session, session,
runtime=loop.llm_runtime(), runtime=loop.llm_runtime(),
@@ -83,112 +76,29 @@ async def test_prompt_above_threshold_archives_until_next_user_boundary(tmp_path
archive_end = loop.consolidator.archive_session.await_args.kwargs["archive_end"] archive_end = loop.consolidator.archive_session.await_args.kwargs["archive_end"]
archived_chunk = session.messages[:archive_end] archived_chunk = session.messages[:archive_end]
assert [message["content"] for message in archived_chunk] == ["u1", "a1", "u2", "a2"] assert [message["content"] for message in archived_chunk] == [
assert session.last_consolidated == 4 "u0", "a0", "u1", "a1", "u2", "a2", "u3", "a3", "u4", "a4", "u5", "a5",
@pytest.mark.asyncio
async def test_consolidation_loops_until_target_met(tmp_path, monkeypatch) -> None:
"""Verify maybe_consolidate_by_tokens keeps looping until under threshold."""
loop = _make_loop(tmp_path, estimated_tokens=0, context_window_tokens=200)
loop.consolidator.archive_session = AsyncMock(return_value=True) # type: ignore[method-assign]
session = loop.sessions.get_or_create("cli:test")
session.messages = [
{"role": "user", "content": "u1", "timestamp": "2026-01-01T00:00:00"},
{"role": "assistant", "content": "a1", "timestamp": "2026-01-01T00:00:01"},
{"role": "user", "content": "u2", "timestamp": "2026-01-01T00:00:02"},
{"role": "assistant", "content": "a2", "timestamp": "2026-01-01T00:00:03"},
{"role": "user", "content": "u3", "timestamp": "2026-01-01T00:00:04"},
{"role": "assistant", "content": "a3", "timestamp": "2026-01-01T00:00:05"},
{"role": "user", "content": "u4", "timestamp": "2026-01-01T00:00:06"},
] ]
loop.sessions.save(session) assert session.last_archived == 12
call_count = [0]
def mock_estimate(_session, *, runtime):
call_count[0] += 1
if call_count[0] == 1:
return (500, "test")
if call_count[0] == 2:
return (300, "test")
return (80, "test")
loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 100)
await loop.consolidator.maybe_consolidate_by_tokens(
session,
runtime=loop.llm_runtime(),
)
assert loop.consolidator.archive_session.await_count == 2
assert session.last_consolidated == 6
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_consolidation_continues_below_trigger_until_half_target(tmp_path, monkeypatch) -> None: async def test_consolidation_persists_summary_for_next_prepare_session(tmp_path) -> None:
"""Once triggered, consolidation should continue until it drops below half threshold."""
loop = _make_loop(tmp_path, estimated_tokens=0, context_window_tokens=200)
loop.consolidator.archive_session = AsyncMock(return_value=True) # type: ignore[method-assign]
session = loop.sessions.get_or_create("cli:test")
session.messages = [
{"role": "user", "content": "u1", "timestamp": "2026-01-01T00:00:00"},
{"role": "assistant", "content": "a1", "timestamp": "2026-01-01T00:00:01"},
{"role": "user", "content": "u2", "timestamp": "2026-01-01T00:00:02"},
{"role": "assistant", "content": "a2", "timestamp": "2026-01-01T00:00:03"},
{"role": "user", "content": "u3", "timestamp": "2026-01-01T00:00:04"},
{"role": "assistant", "content": "a3", "timestamp": "2026-01-01T00:00:05"},
{"role": "user", "content": "u4", "timestamp": "2026-01-01T00:00:06"},
]
loop.sessions.save(session)
call_count = [0]
def mock_estimate(_session, *, runtime):
call_count[0] += 1
if call_count[0] == 1:
return (500, "test")
if call_count[0] == 2:
return (150, "test")
return (80, "test")
loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 100)
await loop.consolidator.maybe_consolidate_by_tokens(
session,
runtime=loop.llm_runtime(),
)
assert loop.consolidator.archive_session.await_count == 2
assert session.last_consolidated == 6
@pytest.mark.asyncio
async def test_consolidation_persists_summary_for_next_prepare_session(tmp_path, monkeypatch) -> None:
loop = _make_loop(tmp_path, estimated_tokens=0, context_window_tokens=200) loop = _make_loop(tmp_path, estimated_tokens=0, context_window_tokens=200)
loop.consolidator.archive_session = AsyncMock(return_value="User discussed project status.") # type: ignore[method-assign] loop.consolidator.archive_session = AsyncMock(return_value="User discussed project status.") # type: ignore[method-assign]
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
session.messages = [ session.messages = [
{"role": "user", "content": "u1", "timestamp": "2026-01-01T00:00:00"}, {"role": role, "content": f"{role[0]}{turn}"}
{"role": "assistant", "content": "a1", "timestamp": "2026-01-01T00:00:01"}, for turn in range(5)
{"role": "user", "content": "u2", "timestamp": "2026-01-01T00:00:02"}, for role in ("user", "assistant")
] ]
loop.sessions.save(session) loop.sessions.save(session)
call_count = [0]
def mock_estimate(_session, *, runtime): def mock_estimate(_session, *, runtime):
call_count[0] += 1
if call_count[0] == 1:
return (500, "test") return (500, "test")
return (80, "test")
loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign] loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 150)
await loop.consolidator.maybe_consolidate_by_tokens( await loop.consolidator.maybe_consolidate_by_tokens(
session, session,
@@ -235,7 +145,7 @@ async def test_preflight_consolidation_receives_pending_summary(tmp_path) -> Non
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_preflight_consolidation_before_llm_call(tmp_path, monkeypatch) -> None: async def test_preflight_consolidation_before_llm_call(tmp_path) -> None:
"""Verify preflight consolidation runs before the LLM call in process_direct.""" """Verify preflight consolidation runs before the LLM call in process_direct."""
order: list[str] = [] order: list[str] = []
@@ -258,13 +168,11 @@ async def test_preflight_consolidation_before_llm_call(tmp_path, monkeypatch) ->
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
session.messages = [ session.messages = [
{"role": "user", "content": "u1", "timestamp": "2026-01-01T00:00:00"}, {"role": role, "content": f"{role[0]}{turn}"}
{"role": "assistant", "content": "a1", "timestamp": "2026-01-01T00:00:01"}, for turn in range(10)
{"role": "user", "content": "u2", "timestamp": "2026-01-01T00:00:02"}, for role in ("user", "assistant")
] ]
loop.sessions.save(session) loop.sessions.save(session)
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 500)
call_count = [0] call_count = [0]
def mock_estimate(_session, *, runtime): def mock_estimate(_session, *, runtime):
call_count[0] += 1 call_count[0] += 1
+181
View File
@@ -0,0 +1,181 @@
"""Test /new archival behavior."""
import asyncio
from collections.abc import Coroutine
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
class TestNewCommandArchival:
"""Test /new archival behavior with the structured archive flow."""
@staticmethod
def _make_loop(tmp_path: Path):
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import GenerationSettings, LLMResponse
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.estimate_prompt_tokens.return_value = (10_000, "test")
provider.generation = GenerationSettings(max_tokens=100)
loop = AgentLoop(
bus=bus,
provider=provider,
workspace=tmp_path,
model="test-model",
context_window_tokens=1,
)
loop.provider.chat_with_retry = AsyncMock(
return_value=LLMResponse(content="ok", tool_calls=[])
)
loop.tools.get_definitions = MagicMock(return_value=[])
return loop
@pytest.mark.asyncio
async def test_new_clears_session_immediately_even_if_archive_fails(
self,
tmp_path: Path,
) -> None:
"""/new clears session immediately; archive is fire-and-forget."""
from nanobot.bus.events import InboundMessage
loop = self._make_loop(tmp_path)
session = loop.sessions.get_or_create("cli:test")
for i in range(5):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
loop.sessions.save(session)
call_count = 0
expected_runtime = loop.llm_runtime()
async def _failing_summarize(session, *, archive_end, runtime) -> None:
nonlocal call_count
assert runtime is expected_runtime
assert session.key == "cli:test"
assert archive_end == len(session.messages)
call_count += 1
loop.consolidator.archive_session = _failing_summarize # type: ignore[method-assign]
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
response = await loop._process_message(new_msg, runtime=expected_runtime)
assert response is not None
assert "new session started" in response.content.lower()
session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == 0
await loop.aclose()
assert call_count == 1
@pytest.mark.asyncio
async def test_new_reuses_replay_prefix_and_archives_only_unarchived_messages(
self,
tmp_path: Path,
) -> None:
from nanobot.bus.events import InboundMessage
loop = self._make_loop(tmp_path)
loop.set_runtime_context_window(128_000)
session = loop.sessions.get_or_create("cli:test")
for i in range(5):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
session.last_archived = len(session.messages) - 2
ordinary_history = session.get_history()
assert [message["content"] for message in ordinary_history] == [
"msg1",
"resp1",
"msg2",
"resp2",
"msg3",
"resp3",
"msg4",
"resp4",
]
loop.sessions.save(session)
expected_runtime = loop.llm_runtime()
scheduled: list[Coroutine[Any, Any, object]] = []
loop.schedule_background = scheduled.append # type: ignore[method-assign]
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
response = await loop._process_message(new_msg, runtime=expected_runtime)
assert response is not None
assert "new session started" in response.content.lower()
assert len(scheduled) == 1
await scheduled[0]
await loop.aclose()
sent = loop.provider.chat_with_retry.call_args.kwargs["messages"]
assert sent[1:-1] == ordinary_history
assert "final 2 conversation messages" in sent[-1]["content"]
@pytest.mark.asyncio
async def test_new_clears_session_and_responds(self, tmp_path: Path) -> None:
from nanobot.bus.events import InboundMessage
loop = self._make_loop(tmp_path)
session = loop.sessions.get_or_create("cli:test")
for i in range(3):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
loop.sessions.save(session)
expected_runtime = loop.llm_runtime()
async def _ok_summarize(session, *, archive_end, runtime) -> str:
assert runtime is expected_runtime
assert session.key == "cli:test"
assert archive_end == len(session.messages)
return "Summary."
loop.consolidator.archive_session = _ok_summarize # type: ignore[method-assign]
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
response = await loop._process_message(new_msg, runtime=expected_runtime)
assert response is not None
assert "new session started" in response.content.lower()
assert loop.sessions.get_or_create("cli:test").messages == []
@pytest.mark.asyncio
async def test_aclose_drains_background_tasks(self, tmp_path: Path) -> None:
"""aclose waits for background tasks to complete."""
from nanobot.bus.events import InboundMessage
loop = self._make_loop(tmp_path)
session = loop.sessions.get_or_create("cli:test")
for i in range(3):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
loop.sessions.save(session)
archived = asyncio.Event()
release_archive = asyncio.Event()
expected_runtime = loop.llm_runtime()
async def _slow_summarize(session, *, archive_end, runtime) -> str:
assert runtime is expected_runtime
assert session.key == "cli:test"
assert archive_end == len(session.messages)
await release_archive.wait()
archived.set()
return "Summary."
loop.consolidator.archive_session = _slow_summarize # type: ignore[method-assign]
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
await loop._process_message(new_msg, runtime=expected_runtime)
assert not archived.is_set()
release_archive.set()
await loop.aclose()
assert archived.is_set()
+9 -18
View File
@@ -9,7 +9,9 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from agent.runner_helpers import make_run_spec from agent.runner_helpers import make_run_spec
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.tools import ToolResult from nanobot.agent.tools import ToolResult
from nanobot.agent.tools.execution import execute_tool_calls
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
@@ -55,11 +57,7 @@ async def test_runner_returns_tool_exception_to_model_for_recovery():
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize("control_error", [KeyboardInterrupt, SystemExit]) @pytest.mark.parametrize("control_error", [KeyboardInterrupt, SystemExit])
async def test_runner_propagates_tool_control_flow_exceptions(control_error: type[BaseException]): async def test_tool_execution_propagates_control_flow_exceptions(control_error: type[BaseException]):
from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider)
async def execute(_name, _args): async def execute(_name, _args):
raise control_error("stop") raise control_error("stop")
@@ -67,22 +65,15 @@ async def test_runner_propagates_tool_control_flow_exceptions(control_error: typ
get_definitions=lambda: [], get_definitions=lambda: [],
execute=execute, execute=execute,
) )
runner = AgentRunner()
spec = make_run_spec(
provider,
initial_messages=[],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
)
with pytest.raises(control_error): with pytest.raises(control_error):
await runner._run_tool( await execute_tool_calls(
spec, tools,
ToolCallRequest(id="call_1", name="list_dir", arguments={}), [ToolCallRequest(id="call_1", name="list_dir", arguments={})],
concurrent=False,
external_lookup_counts={}, external_lookup_counts={},
workspace_violation_counts={}, workspace_violation_counts={},
hook=AgentHook(),
context=AgentHookContext(iteration=0, messages=[]),
) )
+183
View File
@@ -9,6 +9,7 @@ channels, gated by ``context.streamed_reasoning`` rather than
from __future__ import annotations from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
@@ -48,6 +49,39 @@ class _StreamRecordingHook(_RecordingHook):
self.streamed.append(delta) self.streamed.append(delta)
class _LifecycleRecordingHook(AgentHook):
def __init__(self) -> None:
super().__init__()
self.events: list[str] = []
def wants_streaming(self) -> bool:
return True
async def emit_reasoning(self, reasoning_content: str | None) -> None:
if reasoning_content:
self.events.append(f"reasoning:{reasoning_content}")
async def emit_reasoning_end(self) -> None:
self.events.append("reasoning_end")
async def on_stream(self, _ctx: AgentHookContext, delta: str) -> None:
self.events.append(f"content:{delta}")
async def on_stream_end(self, _ctx: AgentHookContext, *, resuming: bool) -> None:
self.events.append(f"stream_end:{resuming}")
async def before_execute_tools(self, context: AgentHookContext) -> None:
names = ",".join(call.name for call in context.tool_calls)
self.events.append(f"local_tools:{names}")
async def on_provider_tool_event(
self,
_context: AgentHookContext,
event: dict[str, Any],
) -> None:
self.events.append(f"hosted_tool:{event.get('phase')}")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_preserves_reasoning_fields_in_assistant_history(): async def test_runner_preserves_reasoning_fields_in_assistant_history():
"""Reasoning fields ride along on the persisted assistant message so """Reasoning fields ride along on the persisted assistant message so
@@ -371,6 +405,155 @@ async def test_runner_streams_native_thinking_deltas_without_post_hoc_dup():
assert hook.emitted == ["part1", "part2"] assert hook.emitted == ["part1", "part2"]
@pytest.mark.asyncio
async def test_runner_closes_native_reasoning_before_streaming_answer():
from nanobot.agent.runner import AgentRunner
provider = MagicMock()
async def chat_stream_with_retry(
*, on_content_delta=None, on_thinking_delta=None, **kwargs
):
if on_thinking_delta:
await on_thinking_delta("inspect")
if on_content_delta:
await on_content_delta("done")
return LLMResponse(content="done", tool_calls=[], usage=None)
provider.chat_stream_with_retry = chat_stream_with_retry
tools = MagicMock()
tools.get_definitions.return_value = []
hook = _LifecycleRecordingHook()
result = await AgentRunner().run(make_run_spec(
provider,
initial_messages=[{"role": "user", "content": "q"}],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
hook=hook,
))
assert result.final_content == "done"
assert hook.events == [
"reasoning:inspect",
"reasoning_end",
"content:done",
"stream_end:False",
]
@pytest.mark.asyncio
async def test_runner_closes_native_reasoning_before_local_tool_execution():
from nanobot.agent.runner import AgentRunner
provider = MagicMock()
responses = iter([
LLMResponse(
content="",
finish_reason="tool_calls",
tool_calls=[ToolCallRequest(id="call-1", name="list_dir", arguments={"path": "."})],
usage=None,
),
LLMResponse(content="done", tool_calls=[], usage=None),
])
async def chat_stream_with_retry(
*, on_content_delta=None, on_thinking_delta=None, **kwargs
):
response = next(responses)
if response.tool_calls:
if on_thinking_delta:
await on_thinking_delta("inspect")
elif on_content_delta:
await on_content_delta("done")
return response
provider.chat_stream_with_retry = chat_stream_with_retry
tools = MagicMock()
tools.get_definitions.return_value = []
tools.execute = AsyncMock(return_value="tool result")
hook = _LifecycleRecordingHook()
result = await AgentRunner().run(make_run_spec(
provider,
initial_messages=[{"role": "user", "content": "inspect"}],
tools=tools,
model="test-model",
max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
hook=hook,
))
assert result.final_content == "done"
assert hook.events == [
"reasoning:inspect",
"reasoning_end",
"stream_end:True",
"local_tools:list_dir",
"content:done",
"stream_end:False",
]
@pytest.mark.asyncio
async def test_runner_closes_native_reasoning_before_hosted_tool_event():
from nanobot.agent.runner import AgentRunner
provider = MagicMock()
async def chat_stream_with_retry(
*, on_content_delta=None, on_thinking_delta=None, on_tool_call_delta=None, **kwargs
):
if on_thinking_delta:
await on_thinking_delta("search")
if on_tool_call_delta:
await on_tool_call_delta({
"kind": "hosted_tool",
"phase": "start",
"call_id": "search-1",
"name": "web_search",
"arguments": {"query": "nanobot"},
})
await on_tool_call_delta({
"kind": "hosted_tool",
"phase": "end",
"call_id": "search-1",
"name": "web_search",
"arguments": {"query": "nanobot"},
"result": {"count": 1},
})
if on_content_delta:
await on_content_delta("done")
return LLMResponse(content="done", tool_calls=[], usage=None)
provider.chat_stream_with_retry = chat_stream_with_retry
tools = MagicMock()
tools.get_definitions.return_value = []
hook = _LifecycleRecordingHook()
result = await AgentRunner().run(make_run_spec(
provider,
initial_messages=[{"role": "user", "content": "search"}],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
hook=hook,
))
assert result.final_content == "done"
assert hook.events == [
"reasoning:search",
"reasoning_end",
"hosted_tool:start",
"hosted_tool:end",
"content:done",
"stream_end:False",
]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_strips_thinking_tags_from_native_thinking_deltas(): async def test_runner_strips_thinking_tags_from_native_thinking_deltas():
from nanobot.agent.runner import AgentRunner from nanobot.agent.runner import AgentRunner
+6 -5
View File
@@ -9,6 +9,7 @@ import pytest
from agent.runner_helpers import make_run_spec from agent.runner_helpers import make_run_spec
from nanobot.agent.runner import AgentRunner from nanobot.agent.runner import AgentRunner
from nanobot.agent.tools import ToolResult from nanobot.agent.tools import ToolResult
from nanobot.agent.tools.execution import is_ssrf_violation
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest from nanobot.providers.base import LLMResponse, ToolCallRequest
@@ -66,20 +67,20 @@ async def test_runner_does_not_abort_on_workspace_violation_anymore():
def test_is_ssrf_violation_recognizes_private_url_blocks(): def test_is_ssrf_violation_recognizes_private_url_blocks():
"""SSRF rejections are classified separately from workspace boundaries.""" """SSRF rejections are classified separately from workspace boundaries."""
ssrf_msg = "Error: Command blocked by safety guard (internal/private URL detected)" ssrf_msg = "Error: Command blocked by safety guard (internal/private URL detected)"
assert AgentRunner._is_ssrf_violation(ssrf_msg) is True assert is_ssrf_violation(ssrf_msg) is True
assert AgentRunner._is_ssrf_violation( assert is_ssrf_violation(
"URL validation failed: Blocked: host resolves to private/internal address 192.168.1.2" "URL validation failed: Blocked: host resolves to private/internal address 192.168.1.2"
) is True ) is True
# Workspace-bound markers are NOT classified as SSRF. # Workspace-bound markers are NOT classified as SSRF.
assert AgentRunner._is_ssrf_violation( assert is_ssrf_violation(
"Error: Command blocked by safety guard (path outside working dir)" "Error: Command blocked by safety guard (path outside working dir)"
) is False ) is False
assert AgentRunner._is_ssrf_violation( assert is_ssrf_violation(
"Path /tmp/x is outside allowed directory /ws" "Path /tmp/x is outside allowed directory /ws"
) is False ) is False
# Deny / allowlist filter messages stay non-fatal too. # Deny / allowlist filter messages stay non-fatal too.
assert AgentRunner._is_ssrf_violation( assert is_ssrf_violation(
"Error: Command blocked by deny pattern filter" "Error: Command blocked by deny pattern filter"
) is False ) is False
+70 -41
View File
@@ -3,14 +3,17 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from agent.runner_helpers import make_run_spec from agent.runner_helpers import make_run_spec
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.runner import AgentRunner from nanobot.agent.runner import AgentRunner
from nanobot.agent.tools.base import Tool, ToolResult from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.context import ToolContext from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.execution import execute_tool_calls
from nanobot.agent.tools.loader import ToolLoader from nanobot.agent.tools.loader import ToolLoader
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
@@ -150,31 +153,69 @@ def _tool_message(result, tool_call_id: str) -> dict:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_propagates_tool_preparation_failure(): async def test_tool_execution_propagates_preparation_failure():
tools = MagicMock() tools = MagicMock()
tools.prepare_call.side_effect = RuntimeError("tool preparation failed") tools.prepare_call.side_effect = RuntimeError("tool preparation failed")
tools.execute = AsyncMock() tools.execute = AsyncMock()
with pytest.raises(RuntimeError, match="tool preparation failed"): with pytest.raises(RuntimeError, match="tool preparation failed"):
await AgentRunner()._run_tool( await execute_tool_calls(
make_run_spec( tools,
MagicMock(), [ToolCallRequest(id="call-1", name="demo", arguments={})],
initial_messages=[], concurrent=False,
tools=tools, external_lookup_counts={},
model="test-model", workspace_violation_counts={},
max_iterations=1, hook=AgentHook(),
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, context=AgentHookContext(iteration=0, messages=[]),
),
ToolCallRequest(id="call-1", name="demo", arguments={}),
{},
{},
) )
tools.execute.assert_not_awaited() tools.execute.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_batches_read_only_tools_before_exclusive_work(): async def test_tool_execution_propagates_cancellation_without_error_hook():
tools = MagicMock()
tools.prepare_call.return_value = (None, {}, None)
tools.execute = AsyncMock(side_effect=asyncio.CancelledError)
events: list[str] = []
class RecordingHook(AgentHook):
async def before_execute_tool(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
) -> None:
events.append("before")
async def on_execute_tool_error(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
error: Any,
) -> None:
events.append("error")
with pytest.raises(asyncio.CancelledError):
await execute_tool_calls(
tools,
[ToolCallRequest(id="call-1", name="demo", arguments={})],
concurrent=False,
external_lookup_counts={},
workspace_violation_counts={},
hook=RecordingHook(),
context=AgentHookContext(iteration=0, messages=[]),
)
assert events == ["before"]
@pytest.mark.asyncio
async def test_tool_execution_batches_read_only_tools_before_exclusive_work():
tools = ToolRegistry() tools = ToolRegistry()
shared_events: list[str] = [] shared_events: list[str] = []
read_a = _DelayTool("read_a", delay=0.05, read_only=True, shared_events=shared_events) read_a = _DelayTool("read_a", delay=0.05, read_only=True, shared_events=shared_events)
@@ -184,24 +225,18 @@ async def test_runner_batches_read_only_tools_before_exclusive_work():
tools.register(read_b) tools.register(read_b)
tools.register(write_a) tools.register(write_a)
provider = MagicMock() await execute_tool_calls(
runner = AgentRunner() tools,
await runner._execute_tools(
make_run_spec(provider,
initial_messages=[],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
concurrent_tools=True,
),
[ [
ToolCallRequest(id="ro1", name="read_a", arguments={}), ToolCallRequest(id="ro1", name="read_a", arguments={}),
ToolCallRequest(id="ro2", name="read_b", arguments={}), ToolCallRequest(id="ro2", name="read_b", arguments={}),
ToolCallRequest(id="rw1", name="write_a", arguments={}), ToolCallRequest(id="rw1", name="write_a", arguments={}),
], ],
{}, concurrent=True,
{}, external_lookup_counts={},
workspace_violation_counts={},
hook=AgentHook(),
context=AgentHookContext(iteration=0, messages=[]),
) )
assert shared_events[0:2] == ["start:read_a", "start:read_b"] assert shared_events[0:2] == ["start:read_a", "start:read_b"]
@@ -212,7 +247,7 @@ async def test_runner_batches_read_only_tools_before_exclusive_work():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_does_not_batch_exclusive_read_only_tools(): async def test_tool_execution_does_not_batch_exclusive_read_only_tools():
tools = ToolRegistry() tools = ToolRegistry()
shared_events: list[str] = [] shared_events: list[str] = []
read_a = _DelayTool("read_a", delay=0.03, read_only=True, shared_events=shared_events) read_a = _DelayTool("read_a", delay=0.03, read_only=True, shared_events=shared_events)
@@ -228,24 +263,18 @@ async def test_runner_does_not_batch_exclusive_read_only_tools():
tools.register(ddg_like) tools.register(ddg_like)
tools.register(read_b) tools.register(read_b)
provider = MagicMock() await execute_tool_calls(
runner = AgentRunner() tools,
await runner._execute_tools(
make_run_spec(provider,
initial_messages=[],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
concurrent_tools=True,
),
[ [
ToolCallRequest(id="ro1", name="read_a", arguments={}), ToolCallRequest(id="ro1", name="read_a", arguments={}),
ToolCallRequest(id="ddg1", name="ddg_like", arguments={}), ToolCallRequest(id="ddg1", name="ddg_like", arguments={}),
ToolCallRequest(id="ro2", name="read_b", arguments={}), ToolCallRequest(id="ro2", name="read_b", arguments={}),
], ],
{}, concurrent=True,
{}, external_lookup_counts={},
workspace_violation_counts={},
hook=AgentHook(),
context=AgentHookContext(iteration=0, messages=[]),
) )
assert shared_events[0] == "start:read_a" assert shared_events[0] == "start:read_a"
+24 -25
View File
@@ -148,28 +148,28 @@ def test_retain_recent_legal_suffix_keeps_recent_messages():
assert session.messages[-1]["content"] == "msg9" assert session.messages[-1]["content"] == "msg9"
def test_retain_recent_legal_suffix_adjusts_last_consolidated(): def test_retain_recent_legal_suffix_adjusts_last_archived():
session = Session(key="test:trim-cons") session = Session(key="test:trim-cons")
for i in range(10): for i in range(10):
session.messages.append({"role": "user", "content": f"msg{i}"}) session.messages.append({"role": "user", "content": f"msg{i}"})
session.last_consolidated = 7 session.last_archived = 7
session.retain_recent_legal_suffix(4) session.retain_recent_legal_suffix(4)
assert len(session.messages) == 4 assert len(session.messages) == 4
assert session.last_consolidated == 1 assert session.last_archived == 1
def test_retain_recent_legal_suffix_zero_clears_session(): def test_retain_recent_legal_suffix_zero_clears_session():
session = Session(key="test:trim-zero") session = Session(key="test:trim-zero")
for i in range(10): for i in range(10):
session.messages.append({"role": "user", "content": f"msg{i}"}) session.messages.append({"role": "user", "content": f"msg{i}"})
session.last_consolidated = 5 session.last_archived = 5
session.retain_recent_legal_suffix(0) session.retain_recent_legal_suffix(0)
assert session.messages == [] assert session.messages == []
assert session.last_consolidated == 0 assert session.last_archived == 0
def test_retain_recent_legal_suffix_keeps_legal_tool_boundary(): def test_retain_recent_legal_suffix_keeps_legal_tool_boundary():
@@ -188,15 +188,15 @@ def test_retain_recent_legal_suffix_keeps_legal_tool_boundary():
assert history[0]["content"] == "keep" assert history[0]["content"] == "keep"
# --- last_consolidated > 0 --- # --- last_archived > 0 ---
def test_orphan_trim_with_last_consolidated(): def test_orphan_trim_with_last_archived():
"""Orphan trimming works correctly when session is partially consolidated.""" """Orphan trimming works correctly when a session is partially archived."""
session = Session(key="test:consolidated") session = Session(key="test:consolidated")
for i in range(10): for i in range(10):
session.messages.append({"role": "user", "content": f"old {i}"}) session.messages.append({"role": "user", "content": f"old {i}"})
session.messages.extend(_tool_turn("cons", i)) session.messages.extend(_tool_turn("cons", i))
session.last_consolidated = 30 session.last_archived = 30
session.messages.append({"role": "user", "content": "recent"}) session.messages.append({"role": "user", "content": "recent"})
for i in range(15): for i in range(15):
@@ -213,7 +213,7 @@ def test_get_history_replays_recent_messages_after_full_archive():
for i in range(10): for i in range(10):
session.messages.append({"role": "user", "content": f"u{i}"}) session.messages.append({"role": "user", "content": f"u{i}"})
session.messages.append({"role": "assistant", "content": f"a{i}"}) session.messages.append({"role": "assistant", "content": f"a{i}"})
session.last_consolidated = len(session.messages) session.last_archived = len(session.messages)
history = session.get_history(max_messages=100) history = session.get_history(max_messages=100)
@@ -229,8 +229,8 @@ def test_get_history_replays_recent_messages_after_full_archive():
] ]
def test_get_history_extends_compacted_replay_to_preceding_user(): def test_get_history_extends_archived_replay_to_preceding_user():
session = Session(key="test:compacted-tool-turn") session = Session(key="test:archived-tool-turn")
session.messages.extend( session.messages.extend(
[ [
{"role": "user", "content": "old"}, {"role": "user", "content": "old"},
@@ -242,7 +242,7 @@ def test_get_history_extends_compacted_replay_to_preceding_user():
{"role": "assistant", "content": "done"}, {"role": "assistant", "content": "done"},
] ]
) )
session.last_consolidated = len(session.messages) session.last_archived = len(session.messages)
history = session.get_history(max_messages=100) history = session.get_history(max_messages=100)
@@ -251,8 +251,8 @@ def test_get_history_extends_compacted_replay_to_preceding_user():
_assert_no_orphans(history) _assert_no_orphans(history)
def test_compacted_tool_turn_can_extend_past_message_cap(): def test_archived_tool_turn_can_extend_past_message_cap():
session = Session(key="test:long-compacted-tool-turn") session = Session(key="test:long-archived-tool-turn")
session.messages.extend( session.messages.extend(
[ [
{"role": "user", "content": "old"}, {"role": "user", "content": "old"},
@@ -263,7 +263,7 @@ def test_compacted_tool_turn_can_extend_past_message_cap():
for i in range(50): for i in range(50):
session.messages.extend(_tool_turn("keep", i)) session.messages.extend(_tool_turn("keep", i))
session.messages.append({"role": "assistant", "content": "done"}) session.messages.append({"role": "assistant", "content": "done"})
session.last_consolidated = len(session.messages) session.last_archived = len(session.messages)
history = session.get_history(max_messages=120) history = session.get_history(max_messages=120)
@@ -635,7 +635,7 @@ def test_fork_session_allows_index_equal_to_user_count(tmp_path):
assert [m["content"] for m in forked.messages] == ["round1", "answer1"] assert [m["content"] for m in forked.messages] == ["round1", "answer1"]
def test_fork_session_drops_summary_when_fork_point_is_inside_consolidated_prefix(tmp_path): def test_fork_session_drops_summary_when_fork_point_is_inside_archived_prefix(tmp_path):
manager = SessionManager(tmp_path) manager = SessionManager(tmp_path)
source = manager.get_or_create("websocket:source") source = manager.get_or_create("websocket:source")
source.messages = [ source.messages = [
@@ -644,7 +644,7 @@ def test_fork_session_drops_summary_when_fork_point_is_inside_consolidated_prefi
{"role": "user", "content": "round2 fork me"}, {"role": "user", "content": "round2 fork me"},
{"role": "assistant", "content": "answer2"}, {"role": "assistant", "content": "answer2"},
] ]
source.last_consolidated = 4 source.last_archived = 4
source.metadata["_last_summary"] = {"text": "round2 fork me and answer2"} source.metadata["_last_summary"] = {"text": "round2 fork me and answer2"}
manager.save(source) manager.save(source)
@@ -656,7 +656,7 @@ def test_fork_session_drops_summary_when_fork_point_is_inside_consolidated_prefi
assert forked is not None assert forked is not None
assert [m["content"] for m in forked.messages] == ["round1", "answer1"] assert [m["content"] for m in forked.messages] == ["round1", "answer1"]
assert forked.last_consolidated == 0 assert forked.last_archived == 0
assert "_last_summary" not in forked.metadata assert "_last_summary" not in forked.metadata
@@ -880,7 +880,7 @@ def test_retain_recent_legal_suffix_returns_all_on_zero():
session = Session(key="test:zero-return") session = Session(key="test:zero-return")
for i in range(5): for i in range(5):
session.messages.append({"role": "user", "content": f"msg{i}"}) session.messages.append({"role": "user", "content": f"msg{i}"})
session.last_consolidated = 3 session.last_archived = 3
result = session.retain_recent_legal_suffix(0) result = session.retain_recent_legal_suffix(0)
@@ -889,22 +889,21 @@ def test_retain_recent_legal_suffix_returns_all_on_zero():
assert session.messages == [] assert session.messages == []
def test_retain_recent_legal_suffix_last_consolidated_correct_in_else_branch(): def test_retain_recent_legal_suffix_last_archived_correct_in_else_branch():
"""last_consolidated after retain_recent_legal_suffix should reflect how """last_archived should count retained messages from the old archived prefix."""
many retained messages were inside the old consolidated prefix."""
session = Session(key="test:else-lc-correct") session = Session(key="test:else-lc-correct")
# 20 messages: u0..u9, a0..a9 # 20 messages: u0..u9, a0..a9
for i in range(10): for i in range(10):
session.messages.append({"role": "user", "content": f"u{i}"}) session.messages.append({"role": "user", "content": f"u{i}"})
for i in range(10): for i in range(10):
session.messages.append({"role": "assistant", "content": f"a{i}"}) session.messages.append({"role": "assistant", "content": f"a{i}"})
session.last_consolidated = 12 # u0..u9, a0, a1 consolidated session.last_archived = 12 # u0..u9, a0, a1 archived
result = session.retain_recent_legal_suffix(4) result = session.retain_recent_legal_suffix(4)
# Retained messages start from latest user (u9) + max_messages forward # Retained messages start from latest user (u9) + max_messages forward
# so retained = [u9, a0..a9][:4] → but these are from original indices 9..12 # so retained = [u9, a0..a9][:4] → but these are from original indices 9..12
# Of those, indices 9,10,11 are < 12 (before_lc), so new_lc = 3 # Of those, indices 9,10,11 are < 12 (before_lc), so new_lc = 3
assert session.last_consolidated == 3 assert session.last_archived == 3
# already_cons should count dropped messages with original index < 12 # already_cons should count dropped messages with original index < 12
assert result.already_consolidated_count == 9 assert result.already_consolidated_count == 9
+1 -1
View File
@@ -179,7 +179,7 @@ def test_compact_probe_keeps_delivery_in_visible_suffix():
{"role": "assistant", "content": "a2"}, {"role": "assistant", "content": "a2"},
{"role": "assistant", "content": "a3"}, {"role": "assistant", "content": "a3"},
] ]
probe = Session(key="test:probe", messages=tail, last_consolidated=0) probe = Session(key="test:probe", messages=tail)
probe.retain_recent_legal_suffix(3, extend_to_user=True) probe.retain_recent_legal_suffix(3, extend_to_user=True)
+29
View File
@@ -520,6 +520,35 @@ class TestCancelBySession:
count = await sm.cancel_by_session("nonexistent") count = await sm.cancel_by_session("nonexistent")
assert count == 0 assert count == 0
@pytest.mark.asyncio
async def test_cancels_active_and_queued_tasks(self, tmp_path):
sm = _manager(tmp_path, max_concurrent_subagents=1)
active_entered = asyncio.Event()
queued_entered = asyncio.Event()
async def _blocked_run(spec):
task = spec.initial_messages[-1]["content"]
if task == "active":
active_entered.set()
else:
queued_entered.set()
await asyncio.Event().wait()
sm.runner.run = _blocked_run
runtime = _runtime()
await sm.spawn("active", runtime=runtime, session_key="s1")
await asyncio.wait_for(active_entered.wait(), timeout=1.0)
await sm.spawn("queued", runtime=runtime, session_key="s1")
await asyncio.sleep(0)
assert not queued_entered.is_set()
assert await sm.cancel_by_session("s1") == 2
await asyncio.sleep(0)
assert not queued_entered.is_set()
assert sm._running_tasks == {}
assert sm._session_tasks == {}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_already_done_not_counted(self, tmp_path): async def test_already_done_not_counted(self, tmp_path):
sm = _manager(tmp_path) sm = _manager(tmp_path)
+1 -1
View File
@@ -254,7 +254,7 @@ class TestDispatch:
assert isinstance(second.event, StreamEndEvent) assert isinstance(second.event, StreamEndEvent)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_processing_lock_serializes(self): async def test_same_session_dispatches_serialize(self):
from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.events import InboundMessage, OutboundMessage
loop, bus = _make_loop() loop, bus = _make_loop()
+1 -1
View File
@@ -291,7 +291,7 @@ class TestCmdNewUnifiedSession:
archived = loop.consolidator.archive_session.call_args.args[0] archived = loop.consolidator.archive_session.call_args.args[0]
assert archived.key == "unified:default" assert archived.key == "unified:default"
assert archived.messages == expected_snapshot assert archived.messages == expected_snapshot
assert archived.last_consolidated == 0 assert archived.last_archived == 0
loop.consolidator.archive_session.assert_called_once_with( loop.consolidator.archive_session.assert_called_once_with(
archived, archived,
archive_end=len(expected_snapshot), archive_end=len(expected_snapshot),
+129 -26
View File
@@ -211,8 +211,8 @@ async def test_spawn_forwards_temperature_to_run_spec(tmp_path):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_spawn_tool_rejects_when_at_concurrency_limit(tmp_path): async def test_background_spawn_waits_for_concurrency_capacity(tmp_path):
"""SpawnTool should return an error string when the concurrency limit is reached.""" """Background tasks should be accepted and start when capacity becomes available."""
from nanobot.agent.subagent import SubagentManager from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.spawn import SpawnTool from nanobot.agent.tools.spawn import SpawnTool
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
@@ -224,14 +224,23 @@ async def test_spawn_tool_rejects_when_at_concurrency_limit(tmp_path):
workspace=tmp_path, workspace=tmp_path,
bus=bus, bus=bus,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
max_concurrent_subagents=1,
) )
mgr._announce_result = AsyncMock() mgr._announce_result = AsyncMock()
# Block the first subagent so it stays "running" first_entered = asyncio.Event()
release = asyncio.Event() second_entered = asyncio.Event()
release_first = asyncio.Event()
release_second = asyncio.Event()
async def fake_run(spec): async def fake_run(spec):
await release.wait() task = spec.initial_messages[-1]["content"]
if task == "first task":
first_entered.set()
await release_first.wait()
else:
second_entered.set()
await release_second.wait()
return SimpleNamespace( return SimpleNamespace(
stop_reason="done", stop_reason="done",
final_content="done", final_content="done",
@@ -250,19 +259,24 @@ async def test_spawn_tool_rejects_when_at_concurrency_limit(tmp_path):
session_key="test:c1", session_key="test:c1",
runtime=_runtime(provider), runtime=_runtime(provider),
)): )):
# First spawn succeeds first_result = await tool.execute(task="first task")
result = await tool.execute(task="first task") assert "started" in first_result
assert "started" in result await asyncio.wait_for(first_entered.wait(), timeout=1.0)
# Second spawn should be rejected (default limit is 1) second_result = await tool.execute(task="second task")
result = await tool.execute(task="second task") assert "started" in second_result
assert "Cannot spawn subagent" in result tasks = list(mgr._running_tasks.values())
assert "concurrency limit reached" in result await asyncio.sleep(0)
assert not second_entered.is_set()
phases = {status.task_description: status.phase for status in mgr._task_statuses.values()}
assert phases == {"first task": "initializing", "second task": "queued"}
# Release the first subagent release_first.set()
release.set() await asyncio.wait_for(second_entered.wait(), timeout=1.0)
# Allow cleanup release_second.set()
await asyncio.gather(*mgr._running_tasks.values(), return_exceptions=True) await asyncio.gather(*tasks, return_exceptions=True)
await asyncio.sleep(0)
assert mgr._running_tasks == {}
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -300,7 +314,7 @@ async def test_spawn_tool_waits_for_inline_result():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_inline_spawn_counts_toward_concurrency_limit(tmp_path): async def test_inline_spawn_waits_for_concurrency_capacity(tmp_path):
from nanobot.agent.subagent import SubagentManager from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.context import RequestContext, request_context from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.agent.tools.spawn import SpawnTool from nanobot.agent.tools.spawn import SpawnTool
@@ -312,12 +326,19 @@ async def test_inline_spawn_counts_toward_concurrency_limit(tmp_path):
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
max_concurrent_subagents=1, max_concurrent_subagents=1,
) )
release = asyncio.Event() first_entered = asyncio.Event()
entered = asyncio.Event() second_entered = asyncio.Event()
release_first = asyncio.Event()
release_second = asyncio.Event()
async def fake_run(spec): async def fake_run(spec):
entered.set() task = spec.initial_messages[-1]["content"]
await release.wait() if task == "first":
first_entered.set()
await release_first.wait()
else:
second_entered.set()
await release_second.wait()
return SimpleNamespace( return SimpleNamespace(
stop_reason="done", stop_reason="done",
final_content="done", final_content="done",
@@ -334,19 +355,100 @@ async def test_inline_spawn_counts_toward_concurrency_limit(tmp_path):
runtime=_runtime(MagicMock()), runtime=_runtime(MagicMock()),
)): )):
first = asyncio.create_task(tool.execute(task="first", wait=True)) first = asyncio.create_task(tool.execute(task="first", wait=True))
await asyncio.wait_for(entered.wait(), timeout=1.0) await asyncio.wait_for(first_entered.wait(), timeout=1.0)
second = await tool.execute(task="second", wait=True) second = asyncio.create_task(tool.execute(task="second", wait=True))
await asyncio.sleep(0)
assert "concurrency limit reached" in second assert not second.done()
assert manager.get_running_count() == 1 assert not second_entered.is_set()
release.set() assert manager.get_running_count() == 2
release_first.set()
assert await first == "done" assert await first == "done"
await asyncio.wait_for(second_entered.wait(), timeout=1.0)
release_second.set()
assert await second == "done"
assert manager.get_running_count() == 0 assert manager.get_running_count() == 0
assert manager._session_tasks == {} assert manager._session_tasks == {}
@pytest.mark.asyncio
async def test_runner_executes_inline_spawn_batch_concurrently(tmp_path):
"""Adjacent blocking consultations should share one concurrent tool batch."""
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.agent.tools.execution import execute_tool_calls
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.spawn import SpawnTool
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import ToolCallRequest
manager = SubagentManager(
workspace=tmp_path,
bus=MessageBus(),
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
max_concurrent_subagents=2,
)
both_entered = asyncio.Event()
release = asyncio.Event()
entered: list[str] = []
async def fake_run(spec):
entered.append(spec.initial_messages[-1]["content"])
if len(entered) == 2:
both_entered.set()
await release.wait()
return SimpleNamespace(
stop_reason="done",
final_content=spec.initial_messages[-1]["content"],
error=None,
tool_events=[],
)
manager.runner.run = AsyncMock(side_effect=fake_run)
tools = ToolRegistry()
tools.register(SpawnTool(manager))
runtime = _runtime(MagicMock())
calls = [
ToolCallRequest(
id="spawn-1",
name="spawn",
arguments={"task": "first", "wait": True},
),
ToolCallRequest(
id="spawn-2",
name="spawn",
arguments={"task": "second", "wait": True},
),
]
with request_context(RequestContext(
channel="test",
chat_id="c1",
session_key="test:c1",
runtime=runtime,
)):
execution = asyncio.create_task(execute_tool_calls(
tools,
calls,
concurrent=True,
external_lookup_counts={},
workspace_violation_counts={},
hook=AgentHook(),
context=AgentHookContext(iteration=0, messages=[], session_key="test:c1"),
))
await asyncio.wait_for(both_entered.wait(), timeout=1.0)
release.set()
results, events = await execution
assert set(entered) == {"first", "second"}
assert results == ["first", "second"]
assert [event["status"] for event in events] == ["ok", "ok"]
assert manager._running_tasks == {}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cancel_by_session_cancels_inline_subagent(tmp_path): async def test_cancel_by_session_cancels_inline_subagent(tmp_path):
from nanobot.agent.subagent import SubagentManager from nanobot.agent.subagent import SubagentManager
@@ -391,6 +493,7 @@ def test_subagent_default_max_concurrent_matches_agent_defaults(tmp_path):
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
) )
assert AgentDefaults().max_concurrent_subagents == 4
assert mgr.max_concurrent_subagents == AgentDefaults().max_concurrent_subagents assert mgr.max_concurrent_subagents == AgentDefaults().max_concurrent_subagents
+85 -5
View File
@@ -799,7 +799,7 @@ def test_provider_login_can_set_xai_grok_as_main_provider(tmp_path):
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8"))) saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
assert saved.agents.defaults.provider == "xai_grok" assert saved.agents.defaults.provider == "xai_grok"
assert saved.agents.defaults.model == "xai-grok/grok-4.5" assert saved.agents.defaults.model == "xai-grok/grok-4.6"
assert saved.agents.defaults.context_window_tokens == 500_000 assert saved.agents.defaults.context_window_tokens == 500_000
assert saved.agents.defaults.model_preset is None assert saved.agents.defaults.model_preset is None
assert make_provider(saved).__class__.__name__ == "XAIGrokProvider" assert make_provider(saved).__class__.__name__ == "XAIGrokProvider"
@@ -2654,12 +2654,14 @@ def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_
assert seen["lease_release_wait_for_stop"] is False assert seen["lease_release_wait_for_stop"] is False
def test_attach_to_background_gateway_detaches_on_ctrl_c(capsys) -> None: def test_attach_to_background_gateway_detaches_on_ctrl_c(capsys, tmp_path: Path) -> None:
stopped = False stopped = False
log_path = tmp_path / "gateway.log"
log_path.touch()
class _FakeRuntime: class _FakeRuntime:
def status(self): def status(self):
return SimpleNamespace(running=True) return SimpleNamespace(running=True, log_path=log_path)
def stop(self): def stop(self):
nonlocal stopped nonlocal stopped
@@ -2679,10 +2681,88 @@ def test_attach_to_background_gateway_detaches_on_ctrl_c(capsys) -> None:
assert "WebUI launcher detached" in rendered assert "WebUI launcher detached" in rendered
def test_attach_to_background_gateway_checks_owned_sidecar() -> None: def test_attach_to_background_gateway_follows_only_new_logs(capsys, tmp_path: Path) -> None:
log_path = tmp_path / "gateway.log"
log_path.write_text("historical log\n", encoding="utf-8")
polls = 0
class _FakeRuntime: class _FakeRuntime:
def status(self): def status(self):
return SimpleNamespace(running=True) return SimpleNamespace(running=True, log_path=log_path)
def _append_then_interrupt(_seconds: float) -> None:
nonlocal polls
if polls == 0:
with log_path.open("a", encoding="utf-8") as handle:
handle.write("[websocket] live log\n")
polls += 1
return
raise KeyboardInterrupt
cli_webui_support._attach_to_background_gateway(
_FakeRuntime(),
sleep=_append_then_interrupt,
)
output = capsys.readouterr().out
assert "[websocket] live log" in output
assert "historical log" not in output
def test_read_new_gateway_logs_recovers_after_truncation(tmp_path: Path) -> None:
log_path = tmp_path / "gateway.log"
log_path.write_text("a much longer historical log line\n", encoding="utf-8")
cursor = cli_webui_support._start_gateway_log_cursor(log_path)
log_path.write_text("fresh log\n", encoding="utf-8")
lines = cli_webui_support._read_new_gateway_logs(log_path, cursor)
assert lines == ["fresh log"]
assert cursor.offset == log_path.stat().st_size
def test_read_new_gateway_logs_detects_fast_rewrite_past_offset(tmp_path: Path) -> None:
log_path = tmp_path / "gateway.log"
log_path.write_text("historical log\n", encoding="utf-8")
cursor = cli_webui_support._start_gateway_log_cursor(log_path)
log_path.write_text("first fresh log\nsecond fresh log\n", encoding="utf-8")
lines = cli_webui_support._read_new_gateway_logs(log_path, cursor)
assert lines == ["first fresh log", "second fresh log"]
def test_read_new_gateway_logs_waits_for_complete_utf8_line(tmp_path: Path) -> None:
log_path = tmp_path / "gateway.log"
log_path.touch()
cursor = cli_webui_support._start_gateway_log_cursor(log_path)
encoded = "模型 ready\n".encode()
log_path.write_bytes(encoded[:2])
assert cli_webui_support._read_new_gateway_logs(log_path, cursor) == []
with log_path.open("ab") as handle:
handle.write(encoded[2:])
assert cli_webui_support._read_new_gateway_logs(log_path, cursor) == ["模型 ready"]
def test_read_new_gateway_logs_tolerates_missing_file(tmp_path: Path) -> None:
log_path = tmp_path / "missing.log"
cursor = cli_webui_support._start_gateway_log_cursor(log_path)
lines = cli_webui_support._read_new_gateway_logs(log_path, cursor)
assert lines == []
assert cursor.offset == 0
def test_attach_to_background_gateway_checks_owned_sidecar(tmp_path: Path) -> None:
log_path = tmp_path / "gateway.log"
log_path.touch()
class _FakeRuntime:
def status(self):
return SimpleNamespace(running=True, log_path=log_path)
def sidecar_exited() -> None: def sidecar_exited() -> None:
raise WebUIDevError("WebUI development server exited unexpectedly (code 23)") raise WebUIDevError("WebUI development server exited unexpectedly (code 23)")
+82 -1
View File
@@ -1,4 +1,85 @@
from nanobot.cli.entry import _native_tui_candidate import os
import subprocess
import sys
from pathlib import Path
from nanobot.cli import entry
from nanobot.cli.entry import _agent_invocation_args, _native_tui_candidate
def test_root_command_routes_to_agent_without_copying_agent_options() -> None:
assert _agent_invocation_args([]) == []
assert _agent_invocation_args(["agent", "--theme", "dark"]) == ["--theme", "dark"]
assert _agent_invocation_args(["--workspace", "./project"]) == [
"--workspace",
"./project",
]
assert _agent_invocation_args(["-mhello"]) == ["-mhello"]
def test_root_metadata_and_subcommands_keep_the_root_cli() -> None:
for args in (
["--help"],
["--version"],
["--install-completion"],
["gateway"],
["webui"],
):
assert _agent_invocation_args(args) is None
def test_root_shell_completion_keeps_root_subcommands() -> None:
env = os.environ.copy()
env.update(
{
"_NANOBOT_COMPLETE": "complete_bash",
"COMP_WORDS": "nanobot ",
"COMP_CWORD": "1",
}
)
script = (
"import sys; "
"from nanobot.cli.entry import main; "
"sys.argv = ['nanobot']; "
"main()"
)
result = subprocess.run(
[sys.executable, "-c", script],
cwd=Path(__file__).parents[2],
env=env,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
assert {"agent", "gateway", "webui"} <= set(result.stdout.splitlines())
assert "not supported" not in result.stderr
def test_root_alias_dispatches_the_shared_agent_command(monkeypatch) -> None:
calls: dict[str, object] = {}
monkeypatch.setattr(entry.sys, "argv", ["nanobot", "-m", "hello"])
monkeypatch.setattr(
entry,
"set_cli_process_identity",
lambda args: calls.__setitem__("identity", args),
)
monkeypatch.setattr(entry, "_configure_windows_console", lambda: None)
monkeypatch.setattr(
entry,
"_run_agent",
lambda args, *, prog_name: calls.update(args=args, prog_name=prog_name),
)
entry.main()
assert calls == {
"identity": ["agent", "-m", "hello"],
"args": ["-m", "hello"],
"prog_name": "nanobot",
}
def test_native_agent_invocations_use_the_lightweight_entrypoint() -> None: def test_native_agent_invocations_use_the_lightweight_entrypoint() -> None:
+18
View File
@@ -58,6 +58,24 @@ def test_legacy_console_entrypoint_still_sets_subcommand_identity(
assert commands == [["webui"]] assert commands == [["webui"]]
def test_legacy_console_entrypoint_routes_bare_command_to_agent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
identities: list[list[str]] = []
launches: list[tuple[list[str], str]] = []
monkeypatch.setattr("nanobot.cli.commands.set_cli_process_identity", identities.append)
monkeypatch.setattr(
"nanobot.cli.entry._run_agent",
lambda args, *, prog_name: launches.append((args, prog_name)),
)
result = CliRunner().invoke(app, [])
assert result.exit_code == 0
assert identities == [["agent"]]
assert launches == [([], "nanobot")]
def test_named_executable_creates_stable_role_symlink( def test_named_executable_creates_stable_role_symlink(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
+4 -88
View File
@@ -51,14 +51,6 @@ def _release_archive(
return payload, checksum return payload, checksum
def _tui_source(tmp_path: Path) -> Path:
source_dir = tmp_path / "tui"
source_dir.mkdir()
(source_dir / "package.json").write_text('{"dependencies": {}}\n', encoding="utf-8")
(source_dir / "bun.lock").write_text('lockfileVersion = 1\n', encoding="utf-8")
return source_dir
@pytest.mark.parametrize( @pytest.mark.parametrize(
("session_id", "expected"), ("session_id", "expected"),
[ [
@@ -533,18 +525,18 @@ def test_classic_options_require_an_explicit_classic_prompt(
) )
def test_source_checkout_installs_missing_locked_tui_dependencies( def test_source_checkout_refreshes_locked_tui_dependencies(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
source_dir = _tui_source(tmp_path) source_dir = tmp_path / "tui"
dependency = source_dir / "node_modules" / "@opentui" / "core" source_dir.mkdir()
(source_dir / "node_modules" / "@opentui" / "core").mkdir(parents=True)
bun = str(tmp_path / "bun") bun = str(tmp_path / "bun")
def install(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: def install(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
assert command == [bun, "install", "--frozen-lockfile"] assert command == [bun, "install", "--frozen-lockfile"]
assert kwargs["cwd"] == source_dir assert kwargs["cwd"] == source_dir
dependency.mkdir(parents=True)
return subprocess.CompletedProcess(command, 0, "", "") return subprocess.CompletedProcess(command, 0, "", "")
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", install) monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", install)
@@ -559,82 +551,6 @@ def test_source_checkout_installs_missing_locked_tui_dependencies(
] ]
def test_source_checkout_skips_install_when_locked_dependencies_are_current(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
source_dir = _tui_source(tmp_path)
dependency = source_dir / "node_modules" / "@opentui" / "core"
installs: list[list[str]] = []
def install(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
installs.append(command)
dependency.mkdir(parents=True)
return subprocess.CompletedProcess(command, 0, "", "")
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", install)
_resolve_source_tui_command(source_dir, "bun")
_resolve_source_tui_command(source_dir, "bun")
assert installs == [["bun", "install", "--frozen-lockfile"]]
@pytest.mark.parametrize("metadata_name", ["package.json", "bun.lock"])
def test_source_checkout_refreshes_dependencies_when_metadata_changes(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
metadata_name: str,
) -> None:
source_dir = _tui_source(tmp_path)
dependency = source_dir / "node_modules" / "@opentui" / "core"
installs: list[list[str]] = []
def install(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
installs.append(command)
dependency.mkdir(parents=True, exist_ok=True)
return subprocess.CompletedProcess(command, 0, "", "")
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", install)
_resolve_source_tui_command(source_dir, "bun")
with (source_dir / metadata_name).open("a", encoding="utf-8") as metadata:
metadata.write("changed\n")
_resolve_source_tui_command(source_dir, "bun")
assert installs == [
["bun", "install", "--frozen-lockfile"],
["bun", "install", "--frozen-lockfile"],
]
def test_failed_source_dependency_install_does_not_leave_a_valid_cache(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
source_dir = _tui_source(tmp_path)
dependency = source_dir / "node_modules" / "@opentui" / "core"
outcomes = iter((0, 1, 0))
installs = 0
def install(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
nonlocal installs
installs += 1
dependency.mkdir(parents=True, exist_ok=True)
returncode = next(outcomes)
return subprocess.CompletedProcess(command, returncode, "", "partial install")
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", install)
_resolve_source_tui_command(source_dir, "bun")
dependency.rmdir()
with pytest.raises(TuiUnavailableError, match="partial install"):
_resolve_source_tui_command(source_dir, "bun")
_resolve_source_tui_command(source_dir, "bun")
assert installs == 3
def test_source_checkout_fails_when_locked_dependencies_cannot_be_refreshed( def test_source_checkout_fails_when_locked_dependencies_cannot_be_refreshed(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
+1 -1
View File
@@ -110,7 +110,7 @@ class TestMidTurnCommandDispatchedDirectly:
loop = MagicMock() loop = MagicMock()
loop.sessions = MagicMock() loop.sessions = MagicMock()
loop.sessions.get_or_create = MagicMock(return_value=MagicMock( loop.sessions.get_or_create = MagicMock(return_value=MagicMock(
messages=[], last_consolidated=0, clear=MagicMock(), messages=[], last_archived=0, clear=MagicMock(),
)) ))
loop.sessions.save = MagicMock() loop.sessions.save = MagicMock()
loop.sessions.invalidate = MagicMock() loop.sessions.invalidate = MagicMock()
+504
View File
@@ -0,0 +1,504 @@
from __future__ import annotations
import base64
import json
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from types import SimpleNamespace
import httpx
import pytest
from nanobot.providers.oauth_model_catalog import (
OAuthModelCatalog,
get_oauth_model_catalog,
invalidate_oauth_model_catalog,
)
from nanobot.providers.openai_codex_provider import (
DEFAULT_OPENAI_CODEX_MODELS_URL,
OPENAI_CODEX_CATALOG_CLIENT_VERSION,
)
from nanobot.providers.registry import ProviderModelSpec
from nanobot.providers.xai_grok_provider import DEFAULT_XAI_GROK_MODELS_URL
from nanobot.providers.xai_oauth import XAIToken
@pytest.fixture(autouse=True)
def _clear_oauth_catalogs() -> None:
for provider in ("openai_codex", "xai_grok", "github_copilot"):
invalidate_oauth_model_catalog(provider)
yield
for provider in ("openai_codex", "xai_grok", "github_copilot"):
invalidate_oauth_model_catalog(provider)
def _fallback_model() -> ProviderModelSpec:
return ProviderModelSpec(id="provider/fallback", label="Fallback")
def test_xai_catalog_fetches_remote_models_and_reuses_capability_metadata(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
original_client = httpx.Client
captured: dict[str, object] = {}
payload = (
base64.urlsafe_b64encode(
json.dumps({"sub": "user-42", "email": "user@example.com"}).encode()
)
.decode()
.rstrip("=")
)
token = XAIToken(
access=f"header.{payload}.signature",
refresh="refresh-token",
expires=int(time.time() * 1000) + 3_600_000,
account_id="user@example.com",
)
def handler(request: httpx.Request) -> httpx.Response:
captured["request"] = request
return httpx.Response(
200,
json={
"data": [
{
"id": "grok-4.6",
"name": "Grok 4.6",
"description": "Latest frontier model",
"owned_by": "xAI",
"context_window": 500_000,
"supports_backend_search": True,
"reasoning_efforts": [
{"value": "xhigh"},
{"value": "high"},
{"value": "low"},
],
},
{
"id": "grok-next",
"_meta": {
"name": "Grok Next",
"context_window": 750_000,
"reasoning_efforts": ["high", "low"],
},
},
]
},
request=request,
)
def fake_client(**kwargs: object) -> httpx.Client:
captured["kwargs"] = kwargs
return original_client(
transport=httpx.MockTransport(handler),
timeout=kwargs["timeout"],
follow_redirects=kwargs["follow_redirects"],
)
monkeypatch.setattr(
"nanobot.providers.xai_grok_provider.get_xai_oauth_storage_path",
lambda: tmp_path / "auth" / "xai.json",
)
monkeypatch.setattr(
"nanobot.providers.xai_grok_provider.get_xai_oauth_login_status",
lambda: token,
)
monkeypatch.setattr(
"nanobot.providers.xai_grok_provider.get_xai_oauth_token",
lambda **_kwargs: token,
)
monkeypatch.setattr("nanobot.providers.xai_grok_provider.httpx.Client", fake_client)
catalog = get_oauth_model_catalog("xai_grok")
assert catalog.source == "remote"
assert [model.id for model in catalog.models] == [
"xai-grok/grok-4.6",
"xai-grok/grok-next",
]
grok = catalog.find("grok-4.6")
assert grok is not None
assert grok.description == "Latest frontier model"
assert grok.context_window == 500_000
assert grok.reasoning_efforts == ("xhigh", "high", "low")
assert grok.supports_backend_search is True
next_model = catalog.find("xai-grok/grok-next")
assert next_model is not None
assert next_model.label == "Grok Next"
assert next_model.context_window == 750_000
assert next_model.reasoning_efforts == ("high", "low")
request = captured["request"]
assert isinstance(request, httpx.Request)
assert str(request.url) == DEFAULT_XAI_GROK_MODELS_URL
assert request.headers["Authorization"] == f"Bearer {token.access}"
assert request.headers["X-XAI-Token-Auth"] == "xai-grok-cli"
assert request.headers["x-userid"] == "user-42"
assert request.headers["x-email"] == "user@example.com"
assert captured["kwargs"] == {"timeout": 10.0, "follow_redirects": False}
assert get_oauth_model_catalog("xai_grok").source == "cache"
def test_openai_codex_catalog_uses_account_catalog_and_filters_hidden_models(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
original_client = httpx.Client
captured: dict[str, object] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["request"] = request
return httpx.Response(
200,
json={
"models": [
{
"slug": "gpt-new",
"display_name": "GPT New",
"description": "New model",
"context_window": 300_000,
"priority": 2,
"visibility": "list",
"supported_reasoning_levels": [
{"effort": "low"},
{"effort": "high"},
],
},
{
"slug": "gpt-first",
"display_name": "GPT First",
"priority": 1,
},
{
"slug": "internal-model",
"display_name": "Internal",
"visibility": "hide",
"priority": 0,
},
]
},
request=request,
)
def fake_client(**kwargs: object) -> httpx.Client:
captured["kwargs"] = kwargs
return original_client(
transport=httpx.MockTransport(handler),
timeout=kwargs["timeout"],
follow_redirects=kwargs["follow_redirects"],
)
class Storage:
def load(self) -> SimpleNamespace:
return SimpleNamespace(access="secret", account_id="account-42")
def get_token_path(self) -> Path:
return tmp_path / "auth" / "openai-codex.json"
monkeypatch.setattr(
"nanobot.providers.openai_codex_provider.FileTokenStorage",
lambda **_kwargs: Storage(),
)
monkeypatch.setattr(
"nanobot.providers.openai_codex_provider.get_codex_token",
lambda **_kwargs: SimpleNamespace(access="secret", account_id="account-42"),
)
monkeypatch.setattr("nanobot.providers.openai_codex_provider.httpx.Client", fake_client)
catalog = get_oauth_model_catalog("openai_codex")
assert catalog.source == "remote"
assert [model.id for model in catalog.models] == [
"openai-codex/gpt-first",
"openai-codex/gpt-new",
]
assert catalog.models[1].context_window == 300_000
assert catalog.models[1].reasoning_efforts == ("low", "high")
request = captured["request"]
assert isinstance(request, httpx.Request)
assert request.url.copy_with(query=None) == httpx.URL(DEFAULT_OPENAI_CODEX_MODELS_URL)
assert request.url.params["client_version"] == OPENAI_CODEX_CATALOG_CLIENT_VERSION
assert request.headers["Authorization"] == "Bearer secret"
assert request.headers["chatgpt-account-id"] == "account-42"
def test_github_copilot_catalog_only_lists_compatible_chat_models(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
original_client = httpx.Client
captured: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append(request)
if request.url.path.endswith("/copilot_internal/v2/token"):
return httpx.Response(
200,
json={
"token": "copilot-secret",
"endpoints": {"api": "https://api.individual.githubcopilot.com"},
},
request=request,
)
return httpx.Response(
200,
json={
"data": [
{
"id": "claude-sonnet",
"name": "Claude Sonnet",
"model_picker_enabled": True,
"policy": {"state": "enabled"},
"supported_endpoints": ["/chat/completions"],
"capabilities": {
"supports": {"reasoning_effort": ["low", "high"]},
"limits": {"max_context_window_tokens": 200_000},
},
},
{
"id": "gpt-5.4-mini",
"name": "GPT-5.4 Mini",
"model_picker_enabled": True,
"supported_endpoints": ["/responses"],
},
{
"id": "unknown-responses-only",
"name": "Unknown Responses only",
"model_picker_enabled": True,
"supported_endpoints": ["/responses"],
},
{
"id": "disabled",
"model_picker_enabled": True,
"policy": {"state": "disabled"},
"supported_endpoints": ["/chat/completions"],
},
]
},
request=request,
)
def fake_client(**kwargs: object) -> httpx.Client:
return original_client(
transport=httpx.MockTransport(handler),
timeout=kwargs["timeout"],
follow_redirects=kwargs["follow_redirects"],
)
class Storage:
def load(self) -> SimpleNamespace:
return SimpleNamespace(access="github-secret", account_id="octocat")
def get_token_path(self) -> Path:
return tmp_path / "auth" / "github-copilot.json"
monkeypatch.setattr(
"nanobot.providers.github_copilot_provider.get_storage",
lambda: Storage(),
)
monkeypatch.setattr("nanobot.providers.github_copilot_provider.httpx.Client", fake_client)
catalog = get_oauth_model_catalog("github_copilot")
assert catalog.source == "remote"
assert [model.id for model in catalog.models] == [
"github-copilot/claude-sonnet",
"github-copilot/gpt-5.4-mini",
]
assert catalog.models[0].context_window == 200_000
assert catalog.models[0].reasoning_efforts == ("low", "high")
assert len(captured) == 2
assert captured[0].headers["Authorization"] == "token github-secret"
assert captured[1].headers["Authorization"] == "Bearer copilot-secret"
assert str(captured[1].url) == "https://api.individual.githubcopilot.com/models"
assert get_oauth_model_catalog("github_copilot").source == "cache"
assert get_oauth_model_catalog(
"github_copilot",
proxy="http://proxy.example:8080",
).source == "remote"
assert len(captured) == 4
def test_catalog_single_flights_concurrent_refreshes() -> None:
calls = 0
calls_lock = threading.Lock()
barrier = threading.Barrier(8)
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
nonlocal calls
with calls_lock:
calls += 1
time.sleep(0.05)
return (ProviderModelSpec(id="provider/remote", label="Remote"),)
catalog = OAuthModelCatalog(fallback_models=(_fallback_model(),), fetch=fetch)
def get_catalog(_index: int):
barrier.wait()
return catalog.get(cache_key="shared")
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(get_catalog, range(8)))
assert calls == 1
assert {result.models[0].id for result in results} == {"provider/remote"}
assert [result.source for result in results].count("remote") == 1
assert [result.source for result in results].count("cache") == 7
def test_catalog_invalidation_discards_an_inflight_account_refresh() -> None:
started = threading.Event()
release = threading.Event()
identity = ["old-account"]
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
current = identity[0]
if current == "old-account":
started.set()
assert release.wait(timeout=2)
return (ProviderModelSpec(id=f"provider/{current}", label=current),)
catalog = OAuthModelCatalog(fallback_models=(_fallback_model(),), fetch=fetch)
with ThreadPoolExecutor(max_workers=2) as pool:
old_future = pool.submit(catalog.get, cache_key="old-key")
assert started.wait(timeout=2)
identity[0] = "new-account"
catalog.invalidate()
new_future = pool.submit(catalog.get, cache_key="new-key")
new_result = new_future.result(timeout=2)
release.set()
old_result = old_future.result(timeout=2)
assert old_result.source == "fallback"
assert new_result.models[0].id == "provider/new-account"
identity[0] = "old-account"
assert catalog.get(cache_key="old-key").models[0].id == "provider/old-account"
def test_catalog_bounds_failure_only_keys() -> None:
calls = 0
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
nonlocal calls
calls += 1
raise httpx.ConnectError("offline")
catalog = OAuthModelCatalog(
fallback_models=(_fallback_model(),),
fetch=fetch,
max_entries=2,
)
for key in ("one", "two", "three"):
assert catalog.get(cache_key=key).source == "fallback"
assert calls == 3
assert catalog.get(cache_key="one").source == "fallback"
assert calls == 4
def test_catalog_returns_stale_then_negative_caches_refresh_failure() -> None:
now = [0.0]
calls = 0
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
nonlocal calls
calls += 1
if calls > 1:
raise httpx.ConnectError("offline")
return (ProviderModelSpec(id="provider/remote", label="Remote"),)
catalog = OAuthModelCatalog(
fallback_models=(_fallback_model(),),
fetch=fetch,
fresh_ttl_s=10,
stale_ttl_s=100,
failure_ttl_s=30,
monotonic=lambda: now[0],
wall_clock=lambda: 123.0,
)
assert catalog.get(cache_key="one").source == "remote"
now[0] = 11
stale = catalog.get(cache_key="one")
assert stale.source == "stale"
assert stale.models[0].id == "provider/remote"
assert catalog.get(cache_key="one").source == "stale"
assert calls == 2
now[0] = 101
fallback = catalog.get(cache_key="one")
assert fallback.source == "fallback"
assert fallback.models[0].id == "provider/fallback"
assert calls == 3
@pytest.mark.parametrize(
"failure",
[
httpx.ConnectError("offline"),
ValueError("invalid JSON"),
httpx.HTTPStatusError(
"unauthorized",
request=httpx.Request("GET", DEFAULT_XAI_GROK_MODELS_URL),
response=httpx.Response(401),
),
httpx.HTTPStatusError(
"rate limited",
request=httpx.Request("GET", DEFAULT_XAI_GROK_MODELS_URL),
response=httpx.Response(429),
),
httpx.HTTPStatusError(
"upstream failure",
request=httpx.Request("GET", DEFAULT_XAI_GROK_MODELS_URL),
response=httpx.Response(503),
),
],
)
def test_catalog_falls_back_for_remote_failures(failure: Exception) -> None:
calls = 0
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
nonlocal calls
calls += 1
raise failure
catalog = OAuthModelCatalog(
fallback_models=(_fallback_model(),),
fetch=fetch,
failure_ttl_s=30,
)
first = catalog.get(cache_key="one")
second = catalog.get(cache_key="one")
assert first.source == "fallback"
assert second.source == "fallback"
assert first.models == (_fallback_model(),)
assert calls == 1
def test_catalog_treats_empty_remote_list_as_failure_and_can_be_invalidated() -> None:
calls = 0
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
nonlocal calls
calls += 1
return () if calls == 1 else (ProviderModelSpec(id="provider/new", label="New"),)
catalog = OAuthModelCatalog(
fallback_models=(_fallback_model(),),
fetch=fetch,
failure_ttl_s=30,
)
assert catalog.get(cache_key="one").source == "fallback"
catalog.invalidate()
refreshed = catalog.get(cache_key="one")
assert refreshed.source == "remote"
assert refreshed.models[0].id == "provider/new"
assert calls == 2
+216 -107
View File
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import base64
import json import json
import time import time
from types import SimpleNamespace from types import SimpleNamespace
@@ -12,21 +11,19 @@ import pytest
from nanobot.config.schema import Config from nanobot.config.schema import Config
from nanobot.providers.base import LLMUsage from nanobot.providers.base import LLMUsage
from nanobot.providers.factory import make_provider from nanobot.providers.factory import make_provider
from nanobot.providers.registry import find_by_name from nanobot.providers.oauth_model_catalog import OAuthModelCatalogSnapshot
from nanobot.providers.registry import ProviderModelSpec, find_by_name
from nanobot.providers.xai_grok_provider import ( from nanobot.providers.xai_grok_provider import (
DEFAULT_XAI_GROK_MODEL, DEFAULT_XAI_GROK_MODEL,
DEFAULT_XAI_GROK_MODELS_URL,
XAIGrokProvider, XAIGrokProvider,
_bounded_error_body, _bounded_error_body,
_build_headers, _build_headers,
_build_model_headers,
_build_reasoning_options, _build_reasoning_options,
_build_xai_http_error, _build_xai_http_error,
_fetch_xai_model_capabilities,
_parse_xai_model_capabilities,
_request_xai, _request_xai,
_xai_error_response, _xai_error_response,
_XAIHTTPError, _XAIHTTPError,
_XAIIncompleteHostedToolError,
) )
@@ -51,22 +48,41 @@ def _mock_model_capabilities(
*, *,
supports_backend_search: bool, supports_backend_search: bool,
) -> None: ) -> None:
async def fake_fetch(*_args, **_kwargs): def fake_catalog(*_args, **_kwargs):
return {"grok-4.5": supports_backend_search} return OAuthModelCatalogSnapshot(
models=(
ProviderModelSpec(
id="xai-grok/grok-4.5",
label="Grok 4.5",
supports_backend_search=supports_backend_search,
),
ProviderModelSpec(
id="xai-grok/grok-4.6",
label="Grok 4.6",
supports_backend_search=supports_backend_search,
),
),
source="remote",
fetched_at=1,
)
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities", "nanobot.providers.xai_grok_provider.get_xai_grok_model_catalog",
fake_fetch, fake_catalog,
) )
def test_xai_grok_registry_exposes_curated_x_search_model() -> None: def test_xai_grok_registry_exposes_curated_x_search_models() -> None:
spec = find_by_name("xai_grok") spec = find_by_name("xai_grok")
assert spec is not None assert spec is not None
assert spec.is_oauth is True assert spec.is_oauth is True
assert spec.backend == "xai_grok" assert spec.backend == "xai_grok"
assert spec.builtin_models[0].id == DEFAULT_XAI_GROK_MODEL assert spec.builtin_models[0].id == DEFAULT_XAI_GROK_MODEL
assert [model.id for model in spec.builtin_models] == [
"xai-grok/grok-4.6",
"xai-grok/grok-4.5",
]
assert spec.builtin_models[0].context_window == 500000 assert spec.builtin_models[0].context_window == 500000
assert "when supported" in spec.builtin_models[0].description assert "when supported" in spec.builtin_models[0].description
@@ -117,7 +133,7 @@ async def test_provider_injects_hosted_x_search_and_required_proxy_headers(monke
assert response.content == "answer [[1]](https://x.com/example/status/1)" assert response.content == "answer [[1]](https://x.com/example/status/1)"
url, headers, body = calls[0] url, headers, body = calls[0]
assert url == "https://cli-chat-proxy.grok.com/v1/responses" assert url == "https://cli-chat-proxy.grok.com/v1/responses"
assert body["model"] == "grok-4.5" assert body["model"] == "grok-4.6"
assert body["tools"] == [ assert body["tools"] == [
{ {
"type": "function", "type": "function",
@@ -132,12 +148,13 @@ async def test_provider_injects_hosted_x_search_and_required_proxy_headers(monke
assert body["stream_tool_calls"] is True assert body["stream_tool_calls"] is True
assert body["reasoning"] == {"summary": "concise", "effort": "high"} assert body["reasoning"] == {"summary": "concise", "effort": "high"}
assert body["store"] is False assert body["store"] is False
assert body["max_turns"] == 5
assert headers["Authorization"] == "Bearer subscription-token" assert headers["Authorization"] == "Bearer subscription-token"
assert headers["X-XAI-Token-Auth"] == "xai-grok-cli" assert headers["X-XAI-Token-Auth"] == "xai-grok-cli"
assert headers["x-authenticateresponse"] == "authenticate-response" assert headers["x-authenticateresponse"] == "authenticate-response"
assert headers["x-grok-client-identifier"] == "nanobot" assert headers["x-grok-client-identifier"] == "nanobot"
assert headers["x-grok-client-mode"] == "headless" assert headers["x-grok-client-mode"] == "headless"
assert headers["x-grok-model-override"] == "grok-4.5" assert headers["x-grok-model-override"] == "grok-4.6"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -147,7 +164,7 @@ async def test_explicit_parameterized_x_search_is_preserved_without_catalog_look
_mock_token(monkeypatch) _mock_token(monkeypatch)
bodies: list[dict[str, Any]] = [] bodies: list[dict[str, Any]] = []
async def unexpected_catalog_lookup(*_args, **_kwargs): def unexpected_catalog_lookup(*_args, **_kwargs):
raise AssertionError("explicit raw tools must not depend on model catalog metadata") raise AssertionError("explicit raw tools must not depend on model catalog metadata")
async def fake_request(_url, _headers, body, **_kwargs): async def fake_request(_url, _headers, body, **_kwargs):
@@ -155,7 +172,7 @@ async def test_explicit_parameterized_x_search_is_preserved_without_catalog_look
return "ok", [], "stop", {}, None return "ok", [], "stop", {}, None
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities", "nanobot.providers.xai_grok_provider.get_xai_grok_model_catalog",
unexpected_catalog_lookup, unexpected_catalog_lookup,
) )
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request) monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
@@ -164,10 +181,12 @@ async def test_explicit_parameterized_x_search_is_preserved_without_catalog_look
"allowed_x_handles": ["nanobot_ai"], "allowed_x_handles": ["nanobot_ai"],
"from_date": "2026-01-01", "from_date": "2026-01-01",
} }
provider = XAIGrokProvider(extra_body={ provider = XAIGrokProvider(
extra_body={
"parallel_tool_calls": False, "parallel_tool_calls": False,
"tools": [hosted_tool, {"type": "code_interpreter", "container": "auto"}], "tools": [hosted_tool, {"type": "code_interpreter", "container": "auto"}],
}) }
)
response = await provider.chat( response = await provider.chat(
[{"role": "user", "content": "search"}], [{"role": "user", "content": "search"}],
@@ -210,7 +229,7 @@ async def test_explicit_empty_tools_disables_catalog_lookup_and_hosted_tool(monk
_mock_token(monkeypatch) _mock_token(monkeypatch)
bodies: list[dict[str, Any]] = [] bodies: list[dict[str, Any]] = []
async def unexpected_catalog_lookup(*_args, **_kwargs): def unexpected_catalog_lookup(*_args, **_kwargs):
raise AssertionError("explicitly disabled X Search must not fetch model capabilities") raise AssertionError("explicitly disabled X Search must not fetch model capabilities")
async def fake_request(_url, _headers, body, **_kwargs): async def fake_request(_url, _headers, body, **_kwargs):
@@ -218,7 +237,7 @@ async def test_explicit_empty_tools_disables_catalog_lookup_and_hosted_tool(monk
return "ok", [], "stop", {}, None return "ok", [], "stop", {}, None
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities", "nanobot.providers.xai_grok_provider.get_xai_grok_model_catalog",
unexpected_catalog_lookup, unexpected_catalog_lookup,
) )
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request) monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
@@ -226,23 +245,28 @@ async def test_explicit_empty_tools_disables_catalog_lookup_and_hosted_tool(monk
response = await provider.chat( response = await provider.chat(
[{"role": "user", "content": "hello"}], [{"role": "user", "content": "hello"}],
tools=[{ tools=[
{
"type": "function", "type": "function",
"function": { "function": {
"name": "read_file", "name": "read_file",
"description": "Read a file", "description": "Read a file",
"parameters": {"type": "object"}, "parameters": {"type": "object"},
}, },
}], }
],
) )
assert response.content == "ok" assert response.content == "ok"
assert bodies[0]["tools"] == [{ assert bodies[0]["tools"] == [
{
"type": "function", "type": "function",
"name": "read_file", "name": "read_file",
"description": "Read a file", "description": "Read a file",
"parameters": {"type": "object"}, "parameters": {"type": "object"},
}] }
]
assert "max_turns" not in bodies[0]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -281,35 +305,8 @@ async def test_provider_keeps_local_x_search_when_model_does_not_support_hosted_
"parameters": {"type": "object"}, "parameters": {"type": "object"},
} }
] ]
assert "max_turns" not in bodies[0]
assert bodies[0]["instructions"] == ""
@pytest.mark.asyncio
async def test_provider_fails_closed_and_caches_model_catalog_failure(monkeypatch) -> None:
_mock_token(monkeypatch)
fetch_calls = 0
bodies: list[dict[str, Any]] = []
async def failing_fetch(*_args, **_kwargs):
nonlocal fetch_calls
fetch_calls += 1
raise httpx.ConnectError("catalog unavailable")
async def fake_request(_url, _headers, body, **_kwargs):
bodies.append(body)
return "ok", [], "stop", {}, None
monkeypatch.setattr(
"nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities",
failing_fetch,
)
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
provider = XAIGrokProvider()
await provider.chat([{"role": "user", "content": "first"}])
await provider.chat([{"role": "user", "content": "second"}])
assert fetch_calls == 1
assert all({"type": "x_search"} not in body["tools"] for body in bodies)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -395,7 +392,10 @@ async def test_factory_builds_xai_provider_and_applies_explicit_body_overrides(m
"providers": { "providers": {
"xaiGrok": { "xaiGrok": {
"proxy": "http://127.0.0.1:7890", "proxy": "http://127.0.0.1:7890",
"extraBody": {"parallel_tool_calls": False}, "extraBody": {
"parallel_tool_calls": False,
"max_turns": 2,
},
} }
}, },
} }
@@ -408,6 +408,7 @@ async def test_factory_builds_xai_provider_and_applies_explicit_body_overrides(m
assert provider.proxy == "http://127.0.0.1:7890" assert provider.proxy == "http://127.0.0.1:7890"
assert response.content == "ok" assert response.content == "ok"
assert bodies[0]["parallel_tool_calls"] is False assert bodies[0]["parallel_tool_calls"] is False
assert bodies[0]["max_turns"] == 2
assert {"type": "x_search"} in bodies[0]["tools"] assert {"type": "x_search"} in bodies[0]["tools"]
@@ -527,75 +528,183 @@ async def test_raw_response_request_streams_hosted_x_search_lifecycle(monkeypatc
assert "large hosted result" not in json.dumps(tool_events) assert "large hosted result" not in json.dumps(tool_events)
def test_model_capabilities_follow_upstream_aliases_and_default_to_disabled() -> None:
capabilities = _parse_xai_model_capabilities(
{
"data": [
{"id": "grok-4.5", "supportsBackendSearch": False},
{
"model": "grok-search",
"supports_backend_search": True,
},
{
"modelId": "grok-meta",
"_meta": {"supportsBackendSearch": True},
},
{"id": "grok-unknown"},
]
}
)
assert capabilities == {
"grok-4.5": False,
"grok-search": True,
"grok-meta": True,
"grok-unknown": False,
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_model_capability_request_uses_subscription_headers(monkeypatch) -> None: async def test_raw_response_request_streams_official_x_search_lifecycle(monkeypatch) -> None:
original_client = httpx.AsyncClient original_client = httpx.AsyncClient
captured: dict[str, Any] = {} events = [
{
"type": "response.output_item.added",
"item": {
"type": "x_search_call",
"id": "x-search-1",
"status": "in_progress",
"action": {"query": "nanobot oauth"},
},
},
{
"type": "response.output_item.done",
"item": {
"type": "x_search_call",
"id": "x-search-1",
"status": "completed",
"action": {"query": "nanobot oauth"},
},
},
{
"type": "response.completed",
"response": {"status": "completed", "usage": {}},
},
]
content = "".join(f"data: {json.dumps(event)}\n\n" for event in events)
def handler(request: httpx.Request) -> httpx.Response: def handler(request: httpx.Request) -> httpx.Response:
captured["request"] = request return httpx.Response(200, content=content, request=request)
return httpx.Response(
200,
json={"data": [{"id": "grok-search", "supportsBackendSearch": True}]},
request=request,
)
def fake_client(**kwargs) -> httpx.AsyncClient: def fake_client(**kwargs) -> httpx.AsyncClient:
captured["kwargs"] = kwargs
return original_client( return original_client(
transport=httpx.MockTransport(handler), transport=httpx.MockTransport(handler),
timeout=kwargs["timeout"], timeout=kwargs["timeout"],
follow_redirects=kwargs["follow_redirects"],
) )
monkeypatch.setattr("nanobot.providers.xai_grok_provider.httpx.AsyncClient", fake_client) monkeypatch.setattr("nanobot.providers.xai_grok_provider.httpx.AsyncClient", fake_client)
payload = base64.urlsafe_b64encode( tool_events: list[dict[str, Any]] = []
json.dumps({"sub": "user-42", "email": "user@example.com"}).encode()
).decode().rstrip("=")
access_token = f"header.{payload}.signature"
headers = _build_model_headers(_token(access_token))
capabilities = await _fetch_xai_model_capabilities( await _request_xai(
DEFAULT_XAI_GROK_MODELS_URL, "https://cli-chat-proxy.grok.com/v1/responses",
headers, _build_headers("secret", "grok-4.6"),
{"model": "grok-4.6", "tools": [{"type": "x_search"}]},
on_tool_call_delta=lambda event: _append(tool_events, event),
) )
request = captured["request"] assert [(event["phase"], event["name"]) for event in tool_events] == [
assert isinstance(request, httpx.Request) ("start", "x_search"),
assert request.method == "GET" ("end", "x_search"),
assert str(request.url) == DEFAULT_XAI_GROK_MODELS_URL ]
assert request.headers["Authorization"] == f"Bearer {access_token}" assert tool_events[-1]["result"] == {"status": "completed"}
assert request.headers["X-XAI-Token-Auth"] == "xai-grok-cli"
assert request.headers["x-userid"] == "user-42"
assert request.headers["x-email"] == "user@example.com" @pytest.mark.asyncio
assert captured["kwargs"] == {"timeout": 10.0, "follow_redirects": False} async def test_raw_response_rejects_unfinished_hosted_tool_and_closes_progress(
assert capabilities == {"grok-search": True} monkeypatch,
) -> None:
original_client = httpx.AsyncClient
events = [
{
"type": "response.custom_tool_call_input.done",
"item_id": "x-search-1",
"input": '{"query":"nanobot oauth"}',
},
{"type": "response.output_text.delta", "delta": "I will keep searching."},
{
"type": "response.completed",
"response": {
"status": "completed",
"usage": {"input_tokens": 8, "output_tokens": 4, "total_tokens": 12},
},
},
]
content = "".join(f"data: {json.dumps(event)}\n\n" for event in events)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, content=content, request=request)
def fake_client(**kwargs) -> httpx.AsyncClient:
return original_client(
transport=httpx.MockTransport(handler),
timeout=kwargs["timeout"],
)
monkeypatch.setattr("nanobot.providers.xai_grok_provider.httpx.AsyncClient", fake_client)
tool_events: list[dict[str, Any]] = []
with pytest.raises(_XAIIncompleteHostedToolError) as caught:
await _request_xai(
"https://cli-chat-proxy.grok.com/v1/responses",
_build_headers("secret", "grok-4.6"),
{"model": "grok-4.6", "tools": [{"type": "x_search"}]},
on_tool_call_delta=lambda event: _append(tool_events, event),
)
assert caught.value.usage == LLMUsage.reported(input_tokens=8, output_tokens=4)
assert [event["phase"] for event in tool_events] == ["start", "error"]
assert "before this hosted tool completed" in tool_events[-1]["error"]
@pytest.mark.asyncio
async def test_provider_recovers_unfinished_hosted_tool_once_and_preserves_usage(
monkeypatch,
) -> None:
_mock_token(monkeypatch)
_mock_model_capabilities(monkeypatch, supports_backend_search=True)
attempts = 0
request_ids: list[str] = []
streamed: list[str] = []
recovered: list[bool] = []
first_usage = LLMUsage.reported(input_tokens=10, output_tokens=2)
second_usage = LLMUsage.reported(input_tokens=11, output_tokens=4)
async def fake_request(_url, headers, body, **kwargs):
nonlocal attempts
attempts += 1
request_ids.append(headers["x-grok-req-id"])
assert body["max_turns"] == 5
if attempts == 1:
await kwargs["on_content_delta"]("I will keep searching.")
raise _XAIIncompleteHostedToolError(
[{"name": "x_search", "call_id": "search-1"}],
usage=first_usage,
)
await kwargs["on_content_delta"]("Final researched answer.")
return "Final researched answer.", [], "stop", second_usage, None
async def on_recover() -> None:
recovered.append(True)
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
provider = XAIGrokProvider()
response = await provider.chat_stream_with_retry(
[{"role": "user", "content": "Search X"}],
on_content_delta=lambda delta: _append(streamed, delta),
on_stream_recover=on_recover,
)
assert attempts == 2
assert len(set(request_ids)) == 2
assert recovered == [True]
assert streamed == ["I will keep searching.", "Final researched answer."]
assert response.content == "Final researched answer."
assert response.usage == first_usage + second_usage
@pytest.mark.asyncio
async def test_provider_preserves_usage_when_hosted_tool_recovery_also_fails(
monkeypatch,
) -> None:
_mock_token(monkeypatch)
_mock_model_capabilities(monkeypatch, supports_backend_search=True)
attempts = 0
usage = LLMUsage.reported(input_tokens=10, output_tokens=2)
async def fake_request(*_args, **_kwargs):
nonlocal attempts
attempts += 1
raise _XAIIncompleteHostedToolError(
[{"name": "x_search", "call_id": f"search-{attempts}"}],
usage=usage,
)
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
provider = XAIGrokProvider()
response = await provider.chat_stream_with_retry(
[{"role": "user", "content": "Search X"}],
on_stream_recover=lambda: _append([], True),
)
assert attempts == 2
assert response.finish_reason == "error"
assert response.usage == usage + usage
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -57,9 +57,41 @@ def test_loaded_corrupt_offset_keeps_messages(tmp_path: Path):
def test_valid_offset_is_preserved(): def test_valid_offset_is_preserved():
session = _session(10, 4) session = _session(10, 4)
assert session.last_consolidated == 4 assert session.last_consolidated == 4
assert session.last_archived == 4
assert len(session.get_history()) == 8 assert len(session.get_history()) == 8
def test_last_archived_field_migrates_with_legacy_alias(tmp_path: Path):
manager = SessionManager(tmp_path)
path = manager._get_session_path("chan:chat")
path.parent.mkdir(parents=True, exist_ok=True)
messages = [
{"role": "user", "content": "first"},
{"role": "assistant", "content": "second"},
]
path.write_text(
"\n".join([
json.dumps({
"_type": "metadata",
"key": "chan:chat",
"metadata": {},
"last_archived": 1,
}),
*(json.dumps(message) for message in messages),
]) + "\n",
encoding="utf-8",
)
session = manager.get_or_create("chan:chat")
assert session.last_archived == 1
assert session.last_consolidated == 1
manager.save(session)
metadata = json.loads(path.read_text(encoding="utf-8").splitlines()[0])
assert metadata["last_archived"] == 1
assert metadata["last_consolidated"] == 1
def test_loaded_null_metadata_becomes_empty_dict(tmp_path: Path): def test_loaded_null_metadata_becomes_empty_dict(tmp_path: Path):
"""Session jsonl metadata:null must load as {} so agent .pop/.get work.""" """Session jsonl metadata:null must load as {} so agent .pop/.get work."""
manager = SessionManager(tmp_path) manager = SessionManager(tmp_path)
+36 -18
View File
@@ -24,7 +24,12 @@ class TestMessageToolSuppressLogic:
"""Final reply suppressed only when message tool sends to the same target.""" """Final reply suppressed only when message tool sends to the same target."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_suppress_when_sent_to_same_target(self, tmp_path: Path) -> None: @pytest.mark.parametrize("ephemeral", [False, True])
async def test_suppress_when_sent_to_same_target(
self,
tmp_path: Path,
ephemeral: bool,
) -> None:
loop = _make_loop(tmp_path) loop = _make_loop(tmp_path)
tool_call = ToolCallRequest( tool_call = ToolCallRequest(
id="call1", name="message", id="call1", name="message",
@@ -43,7 +48,7 @@ class TestMessageToolSuppressLogic:
mt.set_send_callback(AsyncMock(side_effect=lambda m: sent.append(m))) mt.set_send_callback(AsyncMock(side_effect=lambda m: sent.append(m)))
msg = InboundMessage(channel="feishu", sender_id="user1", chat_id="chat123", content="Send") msg = InboundMessage(channel="feishu", sender_id="user1", chat_id="chat123", content="Send")
result = await loop._process_message(msg) result = await loop._process_message(msg, ephemeral=ephemeral)
assert len(sent) == 1 assert len(sent) == 1
assert result is None # suppressed assert result is None # suppressed
@@ -87,6 +92,34 @@ class TestMessageToolSuppressLogic:
assert result is not None assert result is not None
assert "Hello" in result.content assert "Hello" in result.content
@pytest.mark.asyncio
async def test_internal_message_check_keeps_final_response(self, tmp_path: Path) -> None:
loop = _make_loop(tmp_path)
tool_call = ToolCallRequest(
id="call1", name="message",
arguments={"content": "all clear", "channel": "feishu", "chat_id": "chat123"},
)
calls = iter([
LLMResponse(content="", tool_calls=[tool_call]),
LLMResponse(content="Heartbeat summary", tool_calls=[]),
])
loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
loop.tools.get_definitions = MagicMock(return_value=[])
mt = loop.tools.get("message")
assert isinstance(mt, MessageTool)
token = mt.set_suppress_delivery(True)
try:
msg = InboundMessage(
channel="feishu", sender_id="user1", chat_id="chat123", content="Check",
)
result = await loop._process_message(msg)
finally:
mt.reset_suppress_delivery(token)
assert result is not None
assert result.content == "Heartbeat summary"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_injected_followup_with_message_tool_does_not_emit_empty_fallback( async def test_injected_followup_with_message_tool_does_not_emit_empty_fallback(
self, tmp_path: Path self, tmp_path: Path
@@ -154,22 +187,7 @@ class TestMessageToolSuppressLogic:
('read foo.txt', True), ('read foo.txt', True),
] ]
class TestMessageToolTurnTracking: class TestMessageToolSchema:
def test_sent_in_turn_tracks_same_target(self) -> None:
tool = MessageTool()
from nanobot.agent.tools.context import RequestContext, request_context
with request_context(RequestContext(channel="feishu", chat_id="chat1")):
assert not tool._sent_in_turn
tool._sent_in_turn = True
assert tool._sent_in_turn
def test_start_turn_resets(self) -> None:
tool = MessageTool()
tool._sent_in_turn = True
tool.start_turn()
assert not tool._sent_in_turn
def test_schema_discourages_current_chat_replies(self) -> None: def test_schema_discourages_current_chat_replies(self) -> None:
tool = MessageTool() tool = MessageTool()
+21 -1
View File
@@ -59,6 +59,7 @@ def test_tool_context_has_required_fields():
"config", "workspace", "bus", "subagent_manager", "config", "workspace", "bus", "subagent_manager",
"cron_service", "exec_session_manager", "file_state_store", "cron_service", "exec_session_manager", "file_state_store",
"provider_snapshot_loader", "image_generation_provider_configs", "timezone", "provider_snapshot_loader", "image_generation_provider_configs", "timezone",
"runtime_control",
} }
assert required <= field_names assert required <= field_names
@@ -71,6 +72,7 @@ def test_tool_context_defaults():
assert ctx.exec_session_manager is None assert ctx.exec_session_manager is None
assert ctx.provider_snapshot_loader is None assert ctx.provider_snapshot_loader is None
assert ctx.image_generation_provider_configs is None assert ctx.image_generation_provider_configs is None
assert ctx.runtime_control is None
assert ctx.timezone == "UTC" assert ctx.timezone == "UTC"
@@ -91,6 +93,7 @@ def test_discover_finds_concrete_tools():
assert "ExecTool" in class_names assert "ExecTool" in class_names
assert "CliAppsTool" in class_names assert "CliAppsTool" in class_names
assert "MessageTool" in class_names assert "MessageTool" in class_names
assert "MyTool" in class_names
assert "SpawnTool" in class_names assert "SpawnTool" in class_names
assert "ExecSessionTool" in class_names assert "ExecSessionTool" in class_names
@@ -373,12 +376,26 @@ def test_my_tool_enabled():
from nanobot.agent.tools.self import MyTool from nanobot.agent.tools.self import MyTool
mock_config = MagicMock() mock_config = MagicMock()
mock_config.my.enable = True mock_config.my.enable = True
ctx = ToolContext(config=mock_config, workspace="/tmp") ctx = ToolContext(
config=mock_config,
workspace="/tmp",
runtime_control=MagicMock(),
)
assert MyTool.enabled(ctx) is True assert MyTool.enabled(ctx) is True
mock_config.my.enable = False mock_config.my.enable = False
assert MyTool.enabled(ctx) is False assert MyTool.enabled(ctx) is False
def test_my_tool_requires_runtime_control():
from nanobot.agent.tools.self import MyTool
mock_config = MagicMock()
mock_config.my.enable = True
ctx = ToolContext(config=mock_config, workspace="/tmp")
assert MyTool.enabled(ctx) is False
def test_mcp_wrappers_not_discoverable(): def test_mcp_wrappers_not_discoverable():
from nanobot.agent.tools.mcp import MCPPromptWrapper, MCPResourceWrapper, MCPToolWrapper from nanobot.agent.tools.mcp import MCPPromptWrapper, MCPResourceWrapper, MCPToolWrapper
assert MCPToolWrapper._plugin_discoverable is False assert MCPToolWrapper._plugin_discoverable is False
@@ -411,6 +428,7 @@ def test_loader_registers_same_tools_as_old_hardcoded():
mock_config.web.user_agent = None mock_config.web.user_agent = None
mock_config.image_generation.enabled = False mock_config.image_generation.enabled = False
mock_config.my.enable = True mock_config.my.enable = True
mock_config.my.allow_set = False
ctx = ToolContext( ctx = ToolContext(
config=mock_config, config=mock_config,
@@ -419,6 +437,7 @@ def test_loader_registers_same_tools_as_old_hardcoded():
subagent_manager=MagicMock(), subagent_manager=MagicMock(),
cron_service=MagicMock(), cron_service=MagicMock(),
timezone="UTC", timezone="UTC",
runtime_control=MagicMock(),
) )
registry = ToolRegistry() registry = ToolRegistry()
loader = ToolLoader() loader = ToolLoader()
@@ -429,6 +448,7 @@ def test_loader_registers_same_tools_as_old_hardcoded():
"find_files", "grep", "exec", "exec_session", "list_exec_sessions", "find_files", "grep", "exec", "exec_session", "list_exec_sessions",
"web_search", "web_fetch", "web_search", "web_fetch",
"message", "spawn", "cron", "message", "spawn", "cron",
"my",
} }
actual = set(registered) actual = set(registered)
assert expected <= actual, f"Missing tools: {expected - actual}" assert expected <= actual, f"Missing tools: {expected - actual}"
+1 -1
View File
@@ -14,7 +14,6 @@ def test_session_context_separates_archive_progress_from_replay() -> None:
session = Session( session = Session(
key="websocket:context", key="websocket:context",
messages=messages, messages=messages,
last_consolidated=2,
metadata={ metadata={
"_last_summary": { "_last_summary": {
"text": "The archived conversation settled the old question.", "text": "The archived conversation settled the old question.",
@@ -23,6 +22,7 @@ def test_session_context_separates_archive_progress_from_replay() -> None:
}, },
) )
session.last_archived = 2
replay = session.get_history(max_messages=0, include_runtime_context=False) replay = session.get_history(max_messages=0, include_runtime_context=False)
replay_tokens = sum(estimate_message_tokens(message) for message in replay) replay_tokens = sum(estimate_message_tokens(message) for message in replay)
summary_tokens = estimate_message_tokens( summary_tokens = estimate_message_tokens(
+136 -36
View File
@@ -13,7 +13,8 @@ from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfi
from nanobot.llm_usage import get_llm_usage_store from nanobot.llm_usage import get_llm_usage_store
from nanobot.llm_usage.models import LLMCallRecord from nanobot.llm_usage.models import LLMCallRecord
from nanobot.providers.base import LLMUsage from nanobot.providers.base import LLMUsage
from nanobot.providers.registry import find_by_name from nanobot.providers.oauth_model_catalog import OAuthModelCatalogSnapshot
from nanobot.providers.registry import ProviderModelSpec, find_by_name
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
from nanobot.webui.settings_api import ( from nanobot.webui.settings_api import (
@@ -183,11 +184,13 @@ def test_update_api_settings_requires_key_for_network_access(
with pytest.raises(WebUISettingsError, match="API key"): with pytest.raises(WebUISettingsError, match="API key"):
update_api_settings({"host": ["0.0.0.0"], "port": ["8900"]}) update_api_settings({"host": ["0.0.0.0"], "port": ["8900"]})
payload = update_api_settings({ payload = update_api_settings(
{
"host": ["0.0.0.0"], "host": ["0.0.0.0"],
"port": ["9900"], "port": ["9900"],
"api_key": ["secret-token"], "api_key": ["secret-token"],
}) }
)
saved = load_config(config_path) saved = load_config(config_path)
assert saved.api.host == "0.0.0.0" assert saved.api.host == "0.0.0.0"
assert saved.api.port == 9900 assert saved.api.port == 9900
@@ -346,13 +349,15 @@ def test_create_model_configuration_rejects_dynamic_custom_provider_without_api_
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
config = Config.model_validate({ config = Config.model_validate(
{
"providers": { "providers": {
DYNAMIC_PROVIDER_NAME: { DYNAMIC_PROVIDER_NAME: {
"apiKey": "sk-test", "apiKey": "sk-test",
} }
} }
}) }
)
save_config(config, config_path) save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
@@ -497,9 +502,7 @@ def test_update_model_configuration_rolls_back_sessions_when_config_save_fails(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
config = Config( config = Config(model_presets={"openai": ModelPresetConfig(model="openai/gpt-4.1")})
model_presets={"openai": ModelPresetConfig(model="openai/gpt-4.1")}
)
save_config(config, config_path) save_config(config, config_path)
calls: list[tuple[str, str]] = [] calls: list[tuple[str, str]] = []
@@ -890,11 +893,13 @@ def test_update_provider_settings_updates_and_clears_oauth_proxy(
}, },
) )
payload = update_provider_settings({ payload = update_provider_settings(
{
"provider": [provider_name], "provider": [provider_name],
"proxy": [" http://127.0.0.1:7890 "], "proxy": [" http://127.0.0.1:7890 "],
"extraBody": [json.dumps({"tools": []})], "extraBody": [json.dumps({"tools": []})],
}) }
)
providers = {row["name"]: row for row in payload["providers"]} providers = {row["name"]: row for row in payload["providers"]}
assert providers[provider_name]["proxy"] == "http://127.0.0.1:7890" assert providers[provider_name]["proxy"] == "http://127.0.0.1:7890"
@@ -1099,7 +1104,8 @@ def test_settings_payload_groups_opencode_compatibility_alias(tmp_path, monkeypa
def test_settings_payload_keeps_configured_opencode_legacy_alias(tmp_path, monkeypatch) -> None: def test_settings_payload_keeps_configured_opencode_legacy_alias(tmp_path, monkeypatch) -> None:
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
config = Config.model_validate({ config = Config.model_validate(
{
"providers": {"opencodeZen": {"apiKey": "legacy-key"}}, "providers": {"opencodeZen": {"apiKey": "legacy-key"}},
"agents": { "agents": {
"defaults": { "defaults": {
@@ -1107,7 +1113,8 @@ def test_settings_payload_keeps_configured_opencode_legacy_alias(tmp_path, monke
"model": "opencode/deepseek-v4-pro", "model": "opencode/deepseek-v4-pro",
} }
}, },
}) }
)
save_config(config, config_path) save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
@@ -1124,13 +1131,15 @@ def test_settings_payload_marks_dynamic_custom_provider_without_api_base_unconfi
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
config = Config.model_validate({ config = Config.model_validate(
{
"providers": { "providers": {
DYNAMIC_PROVIDER_NAME: { DYNAMIC_PROVIDER_NAME: {
"apiKey": "sk-test", "apiKey": "sk-test",
} }
} }
}) }
)
save_config(config, config_path) save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
@@ -1466,7 +1475,8 @@ def test_settings_payload_includes_token_usage_summary(
config = Config() config = Config()
save_config(config, config_path) save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
get_llm_usage_store().record(LLMCallRecord( get_llm_usage_store().record(
LLMCallRecord(
started_at_ms=int(time.time() * 1000), started_at_ms=int(time.time() * 1000),
duration_ms=1, duration_ms=1,
provider="openai", provider="openai",
@@ -1475,7 +1485,8 @@ def test_settings_payload_includes_token_usage_summary(
stream=False, stream=False,
finish_reason="stop", finish_reason="stop",
usage=LLMUsage.reported(input_tokens=10, output_tokens=5), usage=LLMUsage.reported(input_tokens=10, output_tokens=5),
)) )
)
payload = settings_payload() payload = settings_payload()
@@ -1496,7 +1507,8 @@ def test_settings_usage_payload_returns_lightweight_token_usage(
config = Config() config = Config()
save_config(config, config_path) save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
get_llm_usage_store().record(LLMCallRecord( get_llm_usage_store().record(
LLMCallRecord(
started_at_ms=int(time.time() * 1000), started_at_ms=int(time.time() * 1000),
duration_ms=1, duration_ms=1,
provider="openai", provider="openai",
@@ -1505,7 +1517,8 @@ def test_settings_usage_payload_returns_lightweight_token_usage(
stream=False, stream=False,
finish_reason="stop", finish_reason="stop",
usage=LLMUsage.reported(input_tokens=20, output_tokens=2), usage=LLMUsage.reported(input_tokens=20, output_tokens=2),
)) )
)
payload = settings_usage_payload() payload = settings_usage_payload()
@@ -1929,9 +1942,7 @@ def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
) )
assert exc.value.status == 502 assert exc.value.status == 502
assert str(exc.value) == ( assert str(exc.value) == ("xAI OAuth login failed: Could not reach xAI sign-in: ConnectError.")
"xAI OAuth login failed: Could not reach xAI sign-in: ConnectError."
)
assert exc.value.__cause__ is failure assert exc.value.__cause__ is failure
@@ -1995,39 +2006,126 @@ def test_provider_models_payload_fetches_openai_compatible_models(
assert payload["models"][1]["context_window"] == 65536 assert payload["models"][1]["context_window"] == 65536
def test_provider_models_payload_returns_curated_openai_codex_models() -> None: def test_provider_models_payload_returns_online_openai_codex_models(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"nanobot.webui.settings_models.get_oauth_model_catalog",
lambda *_args, **_kwargs: OAuthModelCatalogSnapshot(
models=(
ProviderModelSpec(
id="openai-codex/gpt-5.6-sol",
label="GPT-5.6-Sol",
description="Latest frontier agentic coding model.",
owned_by="OpenAI Codex",
context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh", "max", "ultra"),
),
),
source="remote",
fetched_at=123,
),
)
payload = provider_models_payload({"provider": ["openai_codex"]}) payload = provider_models_payload({"provider": ["openai_codex"]})
assert payload["status"] == "available" assert payload["status"] == "available"
assert payload["catalog_kind"] == "builtin" assert payload["catalog_kind"] == "hybrid"
assert payload["model_count"] == 7 assert payload["source"] == "remote"
assert payload["model_count"] == 1
assert payload["models"][0] == { assert payload["models"][0] == {
"id": "openai-codex/gpt-5.6-sol", "id": "openai-codex/gpt-5.6-sol",
"label": "GPT-5.6-Sol", "label": "GPT-5.6-Sol",
"description": "Latest frontier agentic coding model.", "description": "Latest frontier agentic coding model.",
"owned_by": "OpenAI Codex", "owned_by": "OpenAI Codex",
"context_window": 372000, "context_window": 272000,
"reasoning_efforts": ["low", "medium", "high", "xhigh", "max", "ultra"],
"supports_backend_search": False,
} }
assert [model["id"] for model in payload["models"][:3]] == [
"openai-codex/gpt-5.6-sol",
"openai-codex/gpt-5.6-terra",
"openai-codex/gpt-5.6-luna",
]
def test_provider_models_payload_returns_xai_grok_model() -> None: def test_provider_models_payload_returns_online_github_copilot_models(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"nanobot.webui.settings_models.get_oauth_model_catalog",
lambda *_args, **_kwargs: OAuthModelCatalogSnapshot(
models=(
ProviderModelSpec(
id="github-copilot/claude-sonnet",
label="Claude Sonnet",
owned_by="GitHub Copilot",
context_window=200_000,
),
),
source="remote",
fetched_at=123,
),
)
payload = provider_models_payload({"provider": ["github_copilot"]})
assert payload["status"] == "available"
assert payload["catalog_kind"] == "hybrid"
assert payload["source"] == "remote"
assert payload["models"][0]["id"] == "github-copilot/claude-sonnet"
def test_provider_models_payload_returns_online_xai_grok_models(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"nanobot.webui.settings_models.get_oauth_model_catalog",
lambda *_args, **_kwargs: OAuthModelCatalogSnapshot(
models=(
ProviderModelSpec(
id="xai-grok/grok-4.6",
label="Grok 4.6",
description="Latest frontier model",
owned_by="xAI",
context_window=500_000,
reasoning_efforts=("xhigh", "high", "medium", "low"),
supports_backend_search=True,
),
ProviderModelSpec(
id="xai-grok/grok-4.5",
label="Grok 4.5",
owned_by="xAI",
context_window=500_000,
reasoning_efforts=("high", "medium", "low"),
supports_backend_search=True,
),
),
source="remote",
fetched_at=123,
),
)
payload = provider_models_payload({"provider": ["xai_grok"]}) payload = provider_models_payload({"provider": ["xai_grok"]})
assert payload["status"] == "available" assert payload["status"] == "available"
assert payload["catalog_kind"] == "builtin" assert payload["catalog_kind"] == "hybrid"
assert payload["source"] == "remote"
assert payload["fetched_at"] == 123
assert payload["models"] == [ assert payload["models"] == [
{
"id": "xai-grok/grok-4.6",
"label": "Grok 4.6",
"description": "Latest frontier model",
"owned_by": "xAI",
"context_window": 500000,
"reasoning_efforts": ["xhigh", "high", "medium", "low"],
"supports_backend_search": True,
},
{ {
"id": "xai-grok/grok-4.5", "id": "xai-grok/grok-4.5",
"label": "Grok 4.5", "label": "Grok 4.5",
"description": "Grok via xAI subscription; X Search is enabled when supported.", "description": None,
"owned_by": "xAI Grok", "owned_by": "xAI",
"context_window": 500000, "context_window": 500000,
} "reasoning_efforts": ["high", "medium", "low"],
"supports_backend_search": True,
},
] ]
@@ -2160,7 +2258,9 @@ def test_model_catalog_kind_uses_provider_spec_metadata() -> None:
assert _model_catalog_kind(find_by_name("anthropic")) == "unsupported" assert _model_catalog_kind(find_by_name("anthropic")) == "unsupported"
assert _model_catalog_kind(find_by_name("openrouter")) == "catalog" assert _model_catalog_kind(find_by_name("openrouter")) == "catalog"
assert _model_catalog_kind(find_by_name("orcarouter")) == "catalog" assert _model_catalog_kind(find_by_name("orcarouter")) == "catalog"
assert _model_catalog_kind(find_by_name("openai_codex")) == "builtin" assert _model_catalog_kind(find_by_name("openai_codex")) == "hybrid"
assert _model_catalog_kind(find_by_name("xai_grok")) == "hybrid"
assert _model_catalog_kind(find_by_name("github_copilot")) == "hybrid"
def test_create_model_configuration_accepts_configured_oauth_provider( def test_create_model_configuration_accepts_configured_oauth_provider(
+8 -7
View File
@@ -9,19 +9,17 @@ bun run --cwd tui test
bun run --cwd tui build bun run --cwd tui build
``` ```
`nanobot agent` launches this client, leases the shared local gateway or starts it on demand, and passes the local bootstrap endpoint through environment variables. The client paints before gateway readiness, retries bootstrap in the background, and obtains fresh WebSocket and REST credentials for each connection. Other terminals and the WebUI keep that gateway alive; the final interactive launcher to exit releases the on-demand process. `/detach` closes the TUI after promoting the gateway to persistent background mode, keeping any active agent turn running without clients; the restored terminal prints the exact stop command for that config and explicit workspace. `nanobot gateway --background` can start or promote it persistently before opening a client. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified archive that keeps the executable together with its licenses, notices, corresponding application source, source offer, and relinking instructions. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is only selected with `nanobot agent --classic`. `nanobot` (or the explicit `nanobot agent` form) launches this client, leases the shared local gateway or starts it on demand, and passes the local bootstrap endpoint through environment variables. The client paints before gateway readiness, retries bootstrap in the background, and obtains fresh WebSocket and REST credentials for each connection. Other terminals and the WebUI keep that gateway alive; the final interactive launcher to exit releases the on-demand process. `/detach` closes the TUI after promoting the gateway to persistent background mode, keeping any active agent turn running without clients; the restored terminal prints the exact stop command for that config and explicit workspace. `nanobot gateway --background` can start or promote it persistently before opening a client. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified archive that keeps the executable together with its licenses, notices, corresponding application source, source offer, and relinking instructions. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is selected with `nanobot --classic` or `nanobot agent --classic`.
Standalone terminals use OpenTUI's retained full-screen layout: the transcript reflows with the terminal while the composer stays fixed at the bottom. Mouse and keyboard scrolling operate inside the transcript, and leaving the TUI restores the previous terminal screen. The TUI uses OpenTUI's retained full-screen layout: the transcript reflows with the terminal while the composer stays fixed at the bottom. Mouse and keyboard scrolling operate inside the transcript, and leaving the TUI restores the previous terminal screen.
Assistant math written with `$...$`, `$$...$$`, `\\(...\\)`, or `\\[...\\]` is presented as Assistant math written with `$...$`, `$$...$$`, `\\(...\\)`, or `\\[...\\]` is presented as
Unicode plain text so formulas remain readable in terminals without a math renderer. Currency and Unicode plain text so formulas remain readable in terminals without a math renderer. Currency and
LaTeX inside inline or fenced code remain literal. LaTeX inside inline or fenced code remain literal.
## Herdr host mode ## Herdr pane titles
When Herdr supplies `HERDR_ENV=1` and `HERDR_PANE_ID`, nanobot becomes a quiet hosted client. It uses OpenTUI's main-screen mode instead of hiding the whole run in a temporary alternate screen, removes the launch card and persistent session/model/task chrome, and keeps only the transcript, compact progress, and composer. Herdr remains responsible for workspace, tab, pane, task, and attention navigation, while nanobot keeps its application-level session, new-chat, and branch commands. When Herdr supplies `HERDR_ENV=1` and `HERDR_PANE_ID`, nanobot keeps the same full-screen layout, controls, and navigation available in any other terminal. Its only host-specific behavior is reporting the latest user task as the Herdr pane title through the supported pane CLI. Creating a new chat, switching to a chat without a task, and exiting the TUI clear that title. Nanobot does not report agent lifecycle, session, model, Git branch, workspace, or action metadata to Herdr.
The TUI reports its WebSocket session ID, model, Git branch, workspace, last task, and current action through Herdr's supported pane CLI. Sending work reports `working`; a persisted explicit nanobot goal block reports `blocked`; a completed turn reports `idle`; exit releases lifecycle authority. The gateway session remains the durable transcript and resume path. Standalone terminals keep the richer full-screen navigation described below.
The model preset and workspace access labels above the composer are live controls. Click either The model preset and workspace access labels above the composer are live controls. Click either
label, then click a choice; arrow keys, `Enter`, and `Esc` provide the same flow without a mouse. label, then click a choice; arrow keys, `Enter`, and `Esc` provide the same flow without a mouse.
@@ -29,7 +27,10 @@ Changes reuse the gateway's normal model command and workspace policy checks.
When you scroll away from the latest output, the scrollbar and `Ctrl+End` hint appear only until When you scroll away from the latest output, the scrollbar and `Ctrl+End` hint appear only until
you return to the bottom. Large pastes are represented by a short editable placeholder in the you return to the bottom. Large pastes are represented by a short editable placeholder in the
composer; nanobot sends the original text unchanged. composer; nanobot sends the original text unchanged. Press `Ctrl+V` or `Alt+V` while the composer
is focused to attach an image from the system clipboard. Image bytes stay behind removable
`[Image #n]` placeholders until the message is sent; each placeholder behaves as one unit, and
deleting it removes its image.
While nanobot is working, the composer prompt becomes While nanobot is working, the composer prompt becomes
`Enter send now · Tab send next`; narrow terminals shorten it to `Enter now · Tab next`. `Enter send now · Tab send next`; narrow terminals shorten it to `Enter now · Tab next`.
+331 -44
View File
@@ -1,5 +1,12 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { BoxRenderable, CliRenderEvents, TextareaRenderable, TextRenderable } from "@opentui/core" import {
BoxRenderable,
CliRenderEvents,
StyledText,
TextareaRenderable,
TextAttributes,
TextRenderable,
} from "@opentui/core"
import { import {
MockTreeSitterClient, MockTreeSitterClient,
createTestRenderer, createTestRenderer,
@@ -14,8 +21,9 @@ import type {
SlashCommand, SlashCommand,
WorkspaceScopePayload, WorkspaceScopePayload,
} from "./protocol" } from "./protocol"
import type { HostAgentState, HostMetadata, TuiHost } from "./host" import type { TuiHost } from "./host"
import type { Transcript } from "./transcript" import type { ClipboardImageReader } from "./clipboard-image"
import { userMessageText, type Transcript } from "./transcript"
const options: AppOptions = { const options: AppOptions = {
wsUrl: "ws://localhost.invalid/ws", wsUrl: "ws://localhost.invalid/ws",
@@ -51,6 +59,20 @@ test("formats a reusable session ID after exit", () => {
) )
}) })
test("projects image media as stable placeholders without exposing filenames", () => {
expect(userMessageText("What is this?", [
{ name: "clipboard-image-2.png" },
{ kind: "image", name: "screenshot.png" },
{ kind: "file", name: "report.pdf" },
])).toBe([
"What is this? [Image #2] [Image #1]",
"Attachments: report.pdf",
].join("\n"))
expect(userMessageText("What is this?", [
{ name: "clipboard-image-1.png" },
], "What is this? [Image #1]")).toBe("What is this? [Image #1]")
})
function contrastRatio(foreground: string, background: string): number { function contrastRatio(foreground: string, background: string): number {
const luminance = (color: string) => { const luminance = (color: string) => {
const channel = (offset: number) => { const channel = (offset: number) => {
@@ -306,6 +328,277 @@ describe("NanobotTui layout", () => {
expect(ui.composer.plainText).toBe("") expect(ui.composer.plainText).toBe("")
}) })
test("pastes clipboard images into removable placeholders and sends their data", async () => {
const sent: string[] = []
const sentOptions: MessageOptions[] = []
let disposed = false
const clipboard: ClipboardImageReader = {
read: async () => ({
mimeType: "image/png",
dataUrl: "data:image/png;base64,AAEC/w==",
}),
dispose: async () => { disposed = true },
}
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
const transport = client(sent, [], [], sentOptions)
const recordSend = transport.send
transport.send = (content, messageOptions) => {
recordSend(content, messageOptions)
return `image-turn-${sent.length}`
}
const app = NanobotTui.mount(
setup.renderer,
options,
transport,
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
undefined,
clipboard,
)
app.accept({ event: "attached", chat_id: "chat" })
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
const ui = app as unknown as {
composer: TextareaRenderable
draft: { imageCount: number }
promptHistory: string[]
status: { plainText: string }
transcript: {
userMessages: Set<{ renderable: TextRenderable }>
}
}
setup.mockInput.pressKey("v", { ctrl: true })
await waitUntil(() => ui.composer.plainText === "[Image #1] ")
expect(ui.status.plainText).toContain("Pasted Image #1")
const placeholderStyle = ui.composer.syntaxStyle?.getStyle("image.placeholder")
expect(placeholderStyle?.bold).toBeTrue()
expect(placeholderStyle?.fg?.toInts().slice(0, 3)).toEqual([239, 142, 48])
const placeholderStyleId = ui.composer.syntaxStyle?.getStyleId("image.placeholder")
if (placeholderStyleId === null || placeholderStyleId === undefined) {
throw new Error("image placeholder style was not registered")
}
expect(ui.composer.getLineHighlights(0)).toEqual([{
start: 0,
end: 10,
styleId: placeholderStyleId,
priority: 100,
hlRef: 0,
}])
ui.composer.setText("")
await waitUntil(() => ui.draft.imageCount === 0)
expect(ui.composer.getLineHighlights(0)).toEqual([])
setup.mockInput.pressKey("v", { ctrl: true })
await waitUntil(() => ui.composer.plainText === "[Image #1] ")
await setup.mockInput.typeText("[Image #1]")
ui.composer.submit()
await waitUntil(() => ui.status.plainText.includes("Duplicate image placeholder"))
expect(sent).toEqual([])
ui.composer.setText("[Image #1]")
ui.composer.submit()
await waitUntil(() => sent.length === 1)
expect(sent).toEqual([""])
expect(ui.promptHistory).toEqual([])
expect(sentOptions[0]?.media).toEqual([{
data_url: "data:image/png;base64,AAEC/w==",
name: "clipboard-image-1.png",
}])
expect(sentOptions[0]).not.toHaveProperty("displayContent")
await setup.flush()
const frame = setup.captureCharFrame()
expect(frame).toContain("[Image #1]")
expect(frame).not.toContain("clipboard-image-1.png")
const userContent = [...ui.transcript.userMessages].at(-1)?.renderable.content
expect(userContent).toBeInstanceOf(StyledText)
const imageChunk = (userContent as StyledText).chunks.find(({ text }) => text === "[Image #1]")
expect(imageChunk?.attributes).toBe(TextAttributes.BOLD)
expect(imageChunk?.fg?.toInts().slice(0, 3)).toEqual([239, 142, 48])
await setup.mockInput.typeText("这是什么? ")
setup.mockInput.pressKey("v", { ctrl: true })
await waitUntil(() => ui.composer.plainText === "这是什么? [Image #1] ")
setup.mockInput.pressTab()
expect(ui.status.plainText).toContain("Images cannot be queued")
expect(ui.composer.plainText).toBe("这是什么? [Image #1] ")
ui.composer.submit()
await waitUntil(() => sent.length === 2)
expect(sent[1]).toBe("这是什么?")
expect(sentOptions[1]?.media).toHaveLength(1)
expect(sentOptions[1]).not.toHaveProperty("displayContent")
await setup.flush()
expect(setup.captureCharFrame()).toContain("这是什么? [Image #1]")
setup.renderer.destroy()
expect(disposed).toBeTrue()
})
test("keeps image placeholders atomic for cursor movement and deletion", async () => {
const clipboard: ClipboardImageReader = {
read: async () => ({
mimeType: "image/png",
dataUrl: "data:image/png;base64,AAEC/w==",
}),
dispose: async () => undefined,
}
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
const app = NanobotTui.mount(
setup.renderer,
options,
client(),
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
undefined,
clipboard,
)
app.accept({ event: "attached", chat_id: "chat" })
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
const ui = app as unknown as {
composer: TextareaRenderable
draft: { imageCount: number }
status: { plainText: string }
}
setup.mockInput.pressKey("v", { ctrl: true })
await waitUntil(() => ui.composer.plainText === "[Image #1] ")
await setup.flush()
await setup.mockMouse.click(ui.composer.x + 5, ui.composer.y)
expect(ui.composer.cursorOffset > 0 && ui.composer.cursorOffset < 10).toBeFalse()
ui.composer.cursorOffset = 0
setup.mockInput.pressArrow("right")
await waitUntil(() => ui.composer.cursorOffset === 10)
setup.mockInput.pressArrow("left")
await waitUntil(() => ui.composer.cursorOffset === 0)
setup.mockInput.pressArrow("right", { shift: true })
await waitUntil(() => ui.composer.cursorOffset === 10)
await setup.mockInput.typeText("replacement")
await waitUntil(() => ui.draft.imageCount === 0)
expect(ui.composer.plainText).toContain("replacement")
expect(ui.composer.plainText).not.toContain("Image #1")
ui.composer.setText("")
setup.mockInput.pressKey("v", { ctrl: true })
await waitUntil(() => ui.composer.plainText === "[Image #1] ")
ui.composer.cursorOffset = 0
setup.mockInput.pressKey("DELETE")
await waitUntil(() => ui.draft.imageCount === 0)
expect(ui.composer.plainText.trim()).toBe("")
expect(ui.status.plainText).toContain("Removed Image #1")
ui.composer.setText("")
setup.mockInput.pressKey("v", { ctrl: true })
await waitUntil(() => ui.composer.plainText === "[Image #1] ")
ui.composer.cursorOffset = 10
setup.mockInput.pressBackspace()
await waitUntil(() => ui.draft.imageCount === 0)
expect(ui.composer.plainText.trim()).toBe("")
ui.composer.setText("")
setup.mockInput.pressKey("v", { ctrl: true })
await waitUntil(() => ui.composer.plainText === "[Image #1] ")
ui.composer.setText("Image #1] ")
await waitUntil(() => ui.draft.imageCount === 0)
expect(ui.composer.plainText.trim()).toBe("")
})
test("keeps clipboard failures visible while an agent turn is active", async () => {
const sent: string[] = []
const clipboard: ClipboardImageReader = {
read: async () => { throw new Error("No image in clipboard") },
dispose: async () => undefined,
}
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
const app = NanobotTui.mount(
setup.renderer,
options,
client(sent),
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
undefined,
clipboard,
)
app.accept({ event: "attached", chat_id: "chat" })
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
const composer = (app as unknown as { composer: TextareaRenderable }).composer
composer.setText("start")
composer.submit()
await waitUntil(() => sent.length === 1)
setup.mockInput.pressKey("v", { ctrl: true })
await waitUntil(() => setup?.captureCharFrame().includes("No image in clipboard") === true)
})
test("keeps image placeholders out of command arguments", async () => {
const sent: string[] = []
const clipboard: ClipboardImageReader = {
read: async () => ({
mimeType: "image/png",
dataUrl: "data:image/png;base64,AAEC/w==",
}),
dispose: async () => undefined,
}
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
const app = NanobotTui.mount(
setup.renderer,
options,
client(sent),
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
undefined,
clipboard,
)
app.accept({ event: "attached", chat_id: "chat" })
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
const ui = app as unknown as {
composer: TextareaRenderable
status: { plainText: string }
commandMenu: { setCommands(commands: SlashCommand[]): void }
}
ui.commandMenu.setCommands([{
command: "/model",
title: "Model",
description: "Show or switch model presets",
argHint: "[preset]",
lifecycle: "side_channel",
acceptsArgs: true,
}])
await setup.mockInput.typeText("/model ")
setup.mockInput.pressKey("v", { ctrl: true })
await waitUntil(() => ui.composer.plainText === "/model [Image #1] ")
ui.composer.submit()
await waitUntil(() => ui.status.plainText.includes("Images cannot be used with commands"))
expect(sent).toEqual([])
expect(ui.composer.plainText).toBe("/model [Image #1] ")
})
test("ignores a clipboard result that finishes after the renderer is destroyed", async () => {
let resolveRead: ((image: {
mimeType: "image/png"
dataUrl: string
}) => void) | undefined
let disposed = false
const clipboard: ClipboardImageReader = {
read: () => new Promise((resolve) => { resolveRead = resolve }),
dispose: async () => { disposed = true },
}
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
const app = NanobotTui.mount(
setup.renderer,
options,
client(),
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
undefined,
clipboard,
)
app.accept({ event: "attached", chat_id: "chat" })
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
setup.mockInput.pressKey("v", { ctrl: true })
await waitUntil(() => resolveRead !== undefined)
setup.renderer.destroy()
resolveRead?.({ mimeType: "image/png", dataUrl: "data:image/png;base64,AAAA" })
await Bun.sleep(10)
expect(disposed).toBeTrue()
})
test("steers with Enter, queues with Tab, and restores queued text with Alt+Up", async () => { test("steers with Enter, queues with Tab, and restores queued text with Alt+Up", async () => {
const sent: string[] = [] const sent: string[] = []
const sentOptions: MessageOptions[] = [] const sentOptions: MessageOptions[] = []
@@ -412,6 +705,11 @@ describe("NanobotTui layout", () => {
turn_id: "remote-steer", turn_id: "remote-steer",
active_turn_id: "remote-turn", active_turn_id: "remote-turn",
starts_turn: false, starts_turn: false,
media_urls: [{
kind: "image",
url: "/api/media/sig/image",
name: "clipboard-image-2.png",
}],
}) })
await setup.flush() await setup.flush()
@@ -420,6 +718,9 @@ describe("NanobotTui layout", () => {
expect(occurrences(frame, "hello from terminal A")).toBe(1) expect(occurrences(frame, "hello from terminal A")).toBe(1)
expect(occurrences(frame, "Attachments: report.pdf")).toBe(1) expect(occurrences(frame, "Attachments: report.pdf")).toBe(1)
expect(occurrences(frame, "one more remote detail")).toBe(1) expect(occurrences(frame, "one more remote detail")).toBe(1)
expect(occurrences(frame, "[Image #2]")).toBe(1)
expect(frame).toContain("one more remote detail [Image #2]")
expect(frame).not.toContain("clipboard-image-2.png")
expect(state.activeTurn).toBeTrue() expect(state.activeTurn).toBeTrue()
expect(state.activeTurnId).toBe("remote-turn") expect(state.activeTurnId).toBe("remote-turn")
@@ -1790,19 +2091,26 @@ describe("NanobotTui layout", () => {
composer: { composer: {
backgroundColor: { intent: string; toInts(): number[] } backgroundColor: { intent: string; toInts(): number[] }
textColor: { toInts(): number[] } textColor: { toInts(): number[] }
syntaxStyle: { getStyle(name: string): { fg?: { toInts(): number[] } } | undefined } | null
} }
transcript: { transcript: {
markdown: Set<{ syntaxStyle: object }> markdown: Set<{ syntaxStyle: object }>
frames: Set<{ borderColor: { toInts(): number[] } }> frames: Set<{ borderColor: { toInts(): number[] } }>
userRows: Set<{ backgroundColor: { intent: string; toInts(): number[] } }> userRows: Set<{ backgroundColor: { intent: string; toInts(): number[] } }>
user(content: string): void userMessages: Set<{ renderable: TextRenderable }>
user(content: string, turnId?: string, media?: Array<{ kind: "image"; name: string }>): void
} }
} }
internals.transcript.user("Existing question") internals.transcript.user("Existing question", undefined, [{
kind: "image",
name: "clipboard-image-1.png",
}])
const userRow = [...internals.transcript.userRows][0] const userRow = [...internals.transcript.userRows][0]
const userMessage = [...internals.transcript.userMessages][0]
const markdown = [...internals.transcript.markdown][0] const markdown = [...internals.transcript.markdown][0]
const sessionFrame = [...internals.transcript.frames][0] const sessionFrame = [...internals.transcript.frames][0]
const darkSyntax = markdown?.syntaxStyle const darkSyntax = markdown?.syntaxStyle
const darkComposerSyntax = internals.composer.syntaxStyle
expect(userRow?.backgroundColor.intent).toBe("default") expect(userRow?.backgroundColor.intent).toBe("default")
@@ -1821,6 +2129,12 @@ describe("NanobotTui layout", () => {
expect(sessionFrame?.borderColor.toInts().slice(0, 3)).toEqual([212, 212, 216]) expect(sessionFrame?.borderColor.toInts().slice(0, 3)).toEqual([212, 212, 216])
expect(userRow?.backgroundColor.toInts().slice(0, 3)).toEqual([240, 240, 240]) expect(userRow?.backgroundColor.toInts().slice(0, 3)).toEqual([240, 240, 240])
expect(markdown?.syntaxStyle).not.toBe(darkSyntax) expect(markdown?.syntaxStyle).not.toBe(darkSyntax)
expect(internals.composer.syntaxStyle).not.toBe(darkComposerSyntax)
expect(internals.composer.syntaxStyle?.getStyle("image.placeholder")?.fg?.toInts().slice(0, 3))
.toEqual([185, 77, 11])
const recolored = userMessage?.renderable.content as StyledText
expect(recolored.chunks.find(({ text }) => text === "[Image #1]")?.fg?.toInts().slice(0, 3))
.toEqual([185, 77, 11])
}) })
test("distinguishes the composer with a quiet focus edge", async () => { test("distinguishes the composer with a quiet focus edge", async () => {
@@ -2760,23 +3074,18 @@ describe("NanobotTui layout", () => {
}) })
}) })
describe("NanobotTui in a Herdr pane", () => { describe("NanobotTui with a Herdr pane title reporter", () => {
test("keeps local navigation while reporting task, session, lifecycle, and metadata", async () => { test("keeps the full terminal experience while reporting task titles", async () => {
const setup = await createTestRenderer({ width: 80, height: 22, screenMode: "main-screen" }) const setup = await createTestRenderer({ width: 80, height: 22, screenMode: "alternate-screen" })
const states: Array<{ state: HostAgentState; message?: string }> = [] const titles: string[] = []
const metadata: HostMetadata[] = []
const sessions: string[] = []
let released = false let released = false
const host: TuiHost = { const host: TuiHost = {
hosted: true, reportTitle(title) { titles.push(title) },
reportState(state, message) { states.push({ state, ...(message ? { message } : {}) }) },
reportSession(sessionId) { sessions.push(sessionId) },
reportMetadata(value) { metadata.push(value) },
release() { released = true }, release() { released = true },
} }
const app = NanobotTui.mount( const app = NanobotTui.mount(
setup.renderer, setup.renderer,
{ ...options, branch: "feat/herdr" }, options,
client(), client(),
new MockTreeSitterClient({ autoResolveTimeout: 0 }), new MockTreeSitterClient({ autoResolveTimeout: 0 }),
host, host,
@@ -2812,10 +3121,14 @@ describe("NanobotTui in a Herdr pane", () => {
}) })
await setup.flush() await setup.flush()
const activeFrame = setup.captureCharFrame() const activeFrame = setup.captureCharFrame()
expect(activeFrame).toContain(">_ nanobot")
expect(activeFrame).toContain("test/model")
expect(occurrences(activeFrame, " Ship the Herdr integration")).toBe(1) expect(occurrences(activeFrame, " Ship the Herdr integration")).toBe(1)
expect(occurrences(activeFrame, "app.ts")).toBe(1) expect(occurrences(activeFrame, "app.ts")).toBe(1)
expect(ui.composer.placeholder).toBe("Enter send now · Tab send next") expect(ui.composer.placeholder).toBe("Enter send now · Tab send next")
expect(ui.composerFrame.height).toBe(3) expect(ui.composerFrame.height).toBe(3)
expect(titles).toEqual(["Ship the Herdr integration"])
app.accept({ app.accept({
event: "turn_end", event: "turn_end",
chat_id: "chat", chat_id: "chat",
@@ -2826,27 +3139,6 @@ describe("NanobotTui in a Herdr pane", () => {
ui_summary: "Approval required", ui_summary: "Approval required",
}, },
}) })
await setup.flush()
const frame = setup.captureCharFrame()
expect(sessions).toEqual(["chat"])
expect(occurrences(frame, " Ship the Herdr integration")).toBe(1)
expect(frame).not.toContain(">_ nanobot")
expect(frame).not.toContain("test/model")
expect(states.some(({ state }) => state === "working")).toBe(true)
expect(states.at(-1)).toEqual({ state: "blocked", message: "Approval required" })
expect(metadata.at(-1)).toMatchObject({
model: "default · test/model",
branch: "feat/herdr",
workspace: "/tmp/nanobot-workspace",
task: "Ship the Herdr integration",
action: "Approval required",
})
setup.resize(42, 6)
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain(" Ship the Herdr integration")
app.accept({ app.accept({
event: "user_message", event: "user_message",
chat_id: "chat", chat_id: "chat",
@@ -2854,13 +3146,8 @@ describe("NanobotTui in a Herdr pane", () => {
turn_id: "turn-2", turn_id: "turn-2",
starts_turn: true, starts_turn: true,
}) })
app.accept({
event: "turn_end", expect(titles).toEqual(["Ship the Herdr integration", "Approved"])
chat_id: "chat",
turn_id: "turn-2",
goal_state: { active: false },
})
expect(states.at(-1)?.state).toBe("idle")
app.stop() app.stop()
expect(released).toBe(true) expect(released).toBe(true)
+244 -180
View File
@@ -65,7 +65,11 @@ import {
type TranscriptNavigation, type TranscriptNavigation,
type TranscriptTheme, type TranscriptTheme,
} from "./transcript" } from "./transcript"
import { ComposerDraft } from "./composer-draft" import { ComposerDraft, MAX_DRAFT_IMAGES } from "./composer-draft"
import {
createClipboardImageReader,
type ClipboardImageReader,
} from "./clipboard-image"
import { BranchMenu, branchPoints } from "./branch-menu" import { BranchMenu, branchPoints } from "./branch-menu"
import { import {
MentionMenu, MentionMenu,
@@ -90,7 +94,7 @@ import {
type FooterMode, type FooterMode,
type FooterHintTheme, type FooterHintTheme,
} from "./footer-hints" } from "./footer-hints"
import { createTuiHost, currentGitBranch, type TuiHost } from "./host" import { createTuiHost, type TuiHost } from "./host"
interface AppOptions { interface AppOptions {
wsUrl?: string wsUrl?: string
@@ -103,8 +107,6 @@ interface AppOptions {
model: string model: string
modelPreset: string modelPreset: string
workspace: string workspace: string
hostWorkspace?: string
branch?: string
version: string version: string
access: string access: string
theme: "auto" | ThemeMode theme: "auto" | ThemeMode
@@ -184,6 +186,7 @@ const LIGHT: Palette = {
const COMPOSER_PLACEHOLDER = "Ask nanobot anything" const COMPOSER_PLACEHOLDER = "Ask nanobot anything"
const ACTIVE_COMPOSER_PLACEHOLDER = "Enter send now · Tab send next" const ACTIVE_COMPOSER_PLACEHOLDER = "Enter send now · Tab send next"
const COMPACT_ACTIVE_COMPOSER_PLACEHOLDER = "Enter now · Tab next" const COMPACT_ACTIVE_COMPOSER_PLACEHOLDER = "Enter now · Tab next"
const IMAGE_PLACEHOLDER_STYLE = "image.placeholder"
const SHIMMER_PAUSE = 16 const SHIMMER_PAUSE = 16
const SHIMMER_BAND = 4 const SHIMMER_BAND = 4
const SHIMMER_INTERVAL_MS = 80 const SHIMMER_INTERVAL_MS = 80
@@ -259,6 +262,12 @@ function syntaxStyle(palette: Palette): SyntaxStyle {
}) })
} }
function composerSyntaxStyle(palette: Palette): SyntaxStyle {
return SyntaxStyle.fromStyles({
[IMAGE_PLACEHOLDER_STYLE]: { fg: RGBA.fromHex(palette.accent), bold: true },
})
}
function transcriptTheme(palette: Palette, backgroundKnown: boolean): TranscriptTheme { function transcriptTheme(palette: Palette, backgroundKnown: boolean): TranscriptTheme {
return { return {
text: palette.text, text: palette.text,
@@ -393,10 +402,6 @@ function connectionStatusText(
return "Session ended" return "Session ended"
} }
function singleLine(value: string, limit = 120): string {
return value.replace(/\s+/gu, " ").trim().slice(0, limit)
}
export function sessionExitMessage(chatId: string): string { export function sessionExitMessage(chatId: string): string {
const sessionId = `websocket:${chatId}` const sessionId = `websocket:${chatId}`
return `Resume with: nanobot agent --session ${sessionId}\n` return `Resume with: nanobot agent --session ${sessionId}\n`
@@ -440,6 +445,7 @@ export class NanobotTui {
private readonly titleText: TextRenderable private readonly titleText: TextRenderable
private readonly composerFrame: BoxRenderable private readonly composerFrame: BoxRenderable
private readonly composer: TextareaRenderable private readonly composer: TextareaRenderable
private composerSyntax: SyntaxStyle
private readonly status: TextRenderable private readonly status: TextRenderable
private readonly meta: TextRenderable private readonly meta: TextRenderable
private readonly host: TuiHost private readonly host: TuiHost
@@ -453,7 +459,6 @@ export class NanobotTui {
private activeTurnId: string | null = null private activeTurnId: string | null = null
private activeLabel = "Thinking" private activeLabel = "Thinking"
private activeStartedAt = 0 private activeStartedAt = 0
private lastProgress = ""
private finalMessage = "" private finalMessage = ""
private turnHadAnswer = false private turnHadAnswer = false
private historyLoaded = false private historyLoaded = false
@@ -502,16 +507,17 @@ export class NanobotTui {
private readonly silentCommandTurns = new Set<string>() private readonly silentCommandTurns = new Set<string>()
private currentFileEdits: FileEditEvent[] = [] private currentFileEdits: FileEditEvent[] = []
private lastFileEdits: FileEditEvent[] = [] private lastFileEdits: FileEditEvent[] = []
private currentTask = ""
private currentAction = ""
private hostBlocked = false
private recoveryState: RecoveryState | null = null private recoveryState: RecoveryState | null = null
private recoveryPending = false private recoveryPending = false
private hostWorkspace: string
private hostBranch: string
private readonly apiReauthenticator: ApiReauthenticator | undefined private readonly apiReauthenticator: ApiReauthenticator | undefined
private readonly clipboardImageReader: ClipboardImageReader
private apiRefreshPromise: Promise<GatewayApiConnection> | null = null private apiRefreshPromise: Promise<GatewayApiConnection> | null = null
private skillLoadId = 0 private skillLoadId = 0
private clipboardImagePending = false
private clipboardPasteGeneration = 0
private composerValue = ""
private composerCursor = 0
private reconcilingComposer = false
private constructor( private constructor(
renderer: CliRenderer, renderer: CliRenderer,
@@ -519,14 +525,14 @@ export class NanobotTui {
client?: ChatClient, client?: ChatClient,
treeSitterClient = getTreeSitterClient(), treeSitterClient = getTreeSitterClient(),
host: TuiHost = createTuiHost({}), host: TuiHost = createTuiHost({}),
clipboardImageReader: ClipboardImageReader = createClipboardImageReader(),
) { ) {
this.renderer = renderer this.renderer = renderer
this.clipboardImageReader = clipboardImageReader
this.defaultModelName = options.model this.defaultModelName = options.model
this.defaultModelPreset = options.modelPreset this.defaultModelPreset = options.modelPreset
this.modelName = options.model this.modelName = options.model
this.modelPreset = options.modelPreset this.modelPreset = options.modelPreset
this.hostWorkspace = options.hostWorkspace || options.workspace
this.hostBranch = options.branch || ""
this.apiReauthenticator = options.bootstrapUrl this.apiReauthenticator = options.bootstrapUrl
? (rejectedApiToken) => this.refreshApiConnection(rejectedApiToken) ? (rejectedApiToken) => this.refreshApiConnection(rejectedApiToken)
: undefined : undefined
@@ -534,13 +540,13 @@ export class NanobotTui {
this.backgroundKnown = options.theme !== "auto" || renderer.themeMode !== null this.backgroundKnown = options.theme !== "auto" || renderer.themeMode !== null
this.activeThemeMode = this.resolveThemeMode(renderer.themeMode) this.activeThemeMode = this.resolveThemeMode(renderer.themeMode)
this.palette = this.activeThemeMode === "light" ? LIGHT : DARK this.palette = this.activeThemeMode === "light" ? LIGHT : DARK
this.composerSyntax = composerSyntaxStyle(this.palette)
this.host = host this.host = host
this.transcript = new Transcript( this.transcript = new Transcript(
renderer, renderer,
transcriptTheme(this.palette, this.backgroundKnown), transcriptTheme(this.palette, this.backgroundKnown),
treeSitterClient, treeSitterClient,
(state) => this.handleTranscriptNavigation(state), (state) => this.handleTranscriptNavigation(state),
!host.hosted,
options.workspace, options.workspace,
) )
this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette)) this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette))
@@ -651,7 +657,6 @@ export class NanobotTui {
truncate: true, truncate: true,
fg: this.palette.muted, fg: this.palette.muted,
selectable: false, selectable: false,
...(host.hosted ? {} : {
onMouseOver: () => { this.titleText.fg = this.palette.accent }, onMouseOver: () => { this.titleText.fg = this.palette.accent },
onMouseOut: () => this.renderTitleColor(), onMouseOut: () => this.renderTitleColor(),
onMouseDown: (event) => { onMouseDown: (event) => {
@@ -665,7 +670,6 @@ export class NanobotTui {
} }
void this.openSessions() void this.openSessions()
}, },
}),
}) })
this.runtimeControls = new RuntimeControls( this.runtimeControls = new RuntimeControls(
renderer, renderer,
@@ -700,11 +704,9 @@ export class NanobotTui {
}, },
) )
this.title.add(this.titleText) this.title.add(this.titleText)
if (!host.hosted) {
this.title.add(this.runtimeControls.modelText) this.title.add(this.runtimeControls.modelText)
this.title.add(this.runtimeControls.accessText) this.title.add(this.runtimeControls.accessText)
this.title.add(this.runtimeControls.contextText) this.title.add(this.runtimeControls.contextText)
}
const composerSurface = this.composerSurface() const composerSurface = this.composerSurface()
this.composerFrame = new BoxRenderable(renderer, { this.composerFrame = new BoxRenderable(renderer, {
id: "nanobot-tui-composer-frame", id: "nanobot-tui-composer-frame",
@@ -730,6 +732,7 @@ export class NanobotTui {
backgroundColor: composerSurface, backgroundColor: composerSurface,
focusedBackgroundColor: composerSurface, focusedBackgroundColor: composerSurface,
cursorColor: this.palette.accent, cursorColor: this.palette.accent,
syntaxStyle: this.composerSyntax,
// A steady line cursor avoids the block-cell trails produced by some // A steady line cursor avoids the block-cell trails produced by some
// terminals when a retained full-screen UI redraws around the composer. // terminals when a retained full-screen UI redraws around the composer.
cursorStyle: { style: "line", blinking: false }, cursorStyle: { style: "line", blinking: false },
@@ -743,23 +746,14 @@ export class NanobotTui {
{ name: "return", action: "submit" }, { name: "return", action: "submit" },
], ],
onCursorChange: () => { onCursorChange: () => {
this.keepComposerCursorOutsideImages()
if (!this.sessionMenu.visible && !this.branchMenu.visible) this.syncComposerMenus() if (!this.sessionMenu.visible && !this.branchMenu.visible) this.syncComposerMenus()
}, },
onContentChange: () => { onContentChange: () => this.handleComposerContentChange(),
this.draft.prune(this.composer.plainText) onMouseDown: () => queueMicrotask(() => this.keepComposerCursorOutsideImages()),
const clearedUnsent = this.unsentSubmit && !this.composer.plainText.trim() onMouseUp: () => queueMicrotask(() => this.keepComposerCursorOutsideImages()),
if (clearedUnsent) this.unsentSubmit = false onMouseDrag: () => queueMicrotask(() => this.keepComposerCursorOutsideImages()),
this.runtimeControls.hide() onMouseDragEnd: () => queueMicrotask(() => this.keepComposerCursorOutsideImages()),
if (this.contextPanel.visible && this.composer.plainText) this.contextPanel.hide()
this.syncComposerPlaceholder()
if (this.sessionMenu.visible) this.syncSessionMenu()
else if (this.branchMenu.visible) this.syncBranchMenu()
else this.syncComposerMenus()
this.resizeComposer()
if (clearedUnsent && !this.activeTurn) {
this.status.content = this.ready ? this.readyStatus() : this.connectionMessage
}
},
// IMEs may commit their final composed glyph after Enter. Matching the // IMEs may commit their final composed glyph after Enter. Matching the
// OpenCode/OpenTUI integration, defer twice before reading plainText. // OpenCode/OpenTUI integration, defer twice before reading plainText.
onSubmit: () => this.deferSubmit(), onSubmit: () => this.deferSubmit(),
@@ -806,7 +800,7 @@ export class NanobotTui {
this.shell.add(this.branchMenu.root) this.shell.add(this.branchMenu.root)
this.shell.add(this.contextPanel.root) this.shell.add(this.contextPanel.root)
this.shell.add(this.runtimeControls.menuRoot) this.shell.add(this.runtimeControls.menuRoot)
if (!host.hosted) this.shell.add(this.title) this.shell.add(this.title)
this.shell.add(this.queuePreview.root) this.shell.add(this.queuePreview.root)
this.shell.add(this.recoveryNotice.root) this.shell.add(this.recoveryNotice.root)
this.shell.add(this.composerFrame) this.shell.add(this.composerFrame)
@@ -823,7 +817,6 @@ export class NanobotTui {
this.handleResize() this.handleResize()
this.composer.focus() this.composer.focus()
this.transcript.header(options) this.transcript.header(options)
this.syncHostMetadata()
} }
static async create(options: AppOptions): Promise<NanobotTui> { static async create(options: AppOptions): Promise<NanobotTui> {
@@ -832,7 +825,7 @@ export class NanobotTui {
targetFps: 30, targetFps: 30,
exitOnCtrlC: false, exitOnCtrlC: false,
useMouse: true, useMouse: true,
screenMode: host.hosted ? "main-screen" : "alternate-screen", screenMode: "alternate-screen",
externalOutputMode: "passthrough", externalOutputMode: "passthrough",
consoleMode: "disabled", consoleMode: "disabled",
}) })
@@ -845,15 +838,22 @@ export class NanobotTui {
client?: ChatClient, client?: ChatClient,
treeSitterClient?: TreeSitterClient, treeSitterClient?: TreeSitterClient,
host?: TuiHost, host?: TuiHost,
clipboardImageReader?: ClipboardImageReader,
): NanobotTui { ): NanobotTui {
return new NanobotTui(renderer, options, client, treeSitterClient, host) return new NanobotTui(
renderer,
options,
client,
treeSitterClient,
host,
clipboardImageReader,
)
} }
async start(): Promise<void> { async start(): Promise<void> {
// Network setup and small menu payloads do not depend on terminal colors. // Network setup and small menu payloads do not depend on terminal colors.
// Start them while OSC theme detection is in flight instead of serializing // Start them while OSC theme detection is in flight instead of serializing
// up to one second of otherwise independent startup work. // up to one second of otherwise independent startup work.
this.host.reportState("unknown", "Getting ready")
this.client.connect() this.client.connect()
void this.loadCommands() void this.loadCommands()
void this.loadMentions() void this.loadMentions()
@@ -889,7 +889,6 @@ export class NanobotTui {
private submit(): void { private submit(): void {
if (this.quitting || this.composer.isDestroyed) return if (this.quitting || this.composer.isDestroyed) return
const visibleContent = this.composer.plainText.trim() const visibleContent = this.composer.plainText.trim()
const content = this.draft.expand(visibleContent).trim()
if (this.sessionLoading) { if (this.sessionLoading) {
this.status.content = "Loading sessions…" this.status.content = "Loading sessions…"
return return
@@ -945,6 +944,10 @@ export class NanobotTui {
return return
} }
const command = this.commandMenu.resolve(visibleContent) const command = this.commandMenu.resolve(visibleContent)
if ((command || visibleContent.startsWith("!")) && this.draft.media(visibleContent).length) {
this.status.content = "Images cannot be used with commands · remove the image first"
return
}
if (command?.source === "tui") { if (command?.source === "tui") {
if (command.command.action === "sessions") void this.openSessions() if (command.command.action === "sessions") void this.openSessions()
else if (command.command.action === "context") void this.openContext() else if (command.command.action === "context") void this.openContext()
@@ -968,7 +971,8 @@ export class NanobotTui {
this.markSubmitUnsent() this.markSubmitUnsent()
return return
} }
const prompt = { content, options: mentionOptions(content, this.availableMentions()) } const prompt = this.composerPrompt()
if (!this.canSendPrompt(prompt)) return
if (this.activeTurn) { if (this.activeTurn) {
this.sendPrompt(prompt, true) this.sendPrompt(prompt, true)
return return
@@ -990,9 +994,13 @@ export class NanobotTui {
this.mentionMenu.hide() this.mentionMenu.hide()
this.skillMenu.hide() this.skillMenu.hide()
this.recordPrompt(prompt.content) this.recordPrompt(prompt.content)
this.transcript.user(prompt.content, turnId) this.transcript.user(
this.hostBlocked = false prompt.content,
this.setCurrentTask(prompt.content) turnId,
prompt.options.media,
prompt.displayContent,
)
this.host.reportTitle(prompt.content)
if (steering) { if (steering) {
this.renderActiveStatus() this.renderActiveStatus()
this.updateMeta() this.updateMeta()
@@ -1007,12 +1015,9 @@ export class NanobotTui {
this.readyDetail = "" this.readyDetail = ""
this.finalMessage = "" this.finalMessage = ""
this.turnHadAnswer = false this.turnHadAnswer = false
this.lastProgress = ""
this.activeLabel = "Thinking" this.activeLabel = "Thinking"
this.currentFileEdits = [] this.currentFileEdits = []
this.setCurrentAction("Thinking")
this.setActive(true, startedAt) this.setActive(true, startedAt)
this.reportHostWorking()
} }
private reconcileTurnOwnership(event: { private reconcileTurnOwnership(event: {
@@ -1035,7 +1040,6 @@ export class NanobotTui {
if (event.event === "attached") { if (event.event === "attached") {
const switchedSession = Boolean(this.currentChatId && this.currentChatId !== event.chat_id) const switchedSession = Boolean(this.currentChatId && this.currentChatId !== event.chat_id)
this.currentChatId = event.chat_id this.currentChatId = event.chat_id
this.host.reportSession(event.chat_id)
if (event.usage) this.lastUsage = event.usage if (event.usage) this.lastUsage = event.usage
if (event.model_preset !== undefined) { if (event.model_preset !== undefined) {
this.applyModelPreset(event.model_preset) this.applyModelPreset(event.model_preset)
@@ -1080,23 +1084,20 @@ export class NanobotTui {
this.reconcileTurnOwnership(event) this.reconcileTurnOwnership(event)
return return
case "user_message": { case "user_message": {
const attachments = event.media_urls?.map((media) => media.name).filter(Boolean) || [] if (this.transcript.user(
const content = [
event.text, event.text,
attachments.length ? `Attachments: ${attachments.join(", ")}` : "", event.turn_id,
].filter(Boolean).join("\n") event.media_urls,
if (this.transcript.user(content, event.turn_id)) this.recordPrompt(event.text) )) {
this.hostBlocked = false this.recordPrompt(event.text)
this.setCurrentTask(event.text) }
this.host.reportTitle(event.text)
this.reconcileTurnOwnership(event) this.reconcileTurnOwnership(event)
if (this.activeTurn) this.reportHostWorking()
return return
} }
case "delta": case "delta":
this.setActive(true) this.setActive(true)
this.activeLabel = "Writing" this.activeLabel = "Writing"
if (!this.currentAction) this.setCurrentAction("Writing")
this.reportHostWorking()
this.turnHadAnswer = true this.turnHadAnswer = true
this.transcript.stream(event.text) this.transcript.stream(event.text)
return return
@@ -1116,11 +1117,8 @@ export class NanobotTui {
} }
if (event.kind) { if (event.kind) {
this.activeLabel = event.kind === "tool_hint" ? "Working" : "Thinking" this.activeLabel = event.kind === "tool_hint" ? "Working" : "Thinking"
this.lastProgress = this.transcript.progress(event.text, event.tool_events) this.transcript.progress(event.text, event.tool_events)
if (this.lastProgress) this.setCurrentAction(this.lastProgress)
else if (!this.currentAction) this.setCurrentAction(this.activeLabel)
this.setActive(true) this.setActive(true)
this.reportHostWorking()
} else { } else {
this.finalMessage = event.text this.finalMessage = event.text
} }
@@ -1129,10 +1127,8 @@ export class NanobotTui {
this.activeLabel = "Editing" this.activeLabel = "Editing"
this.currentFileEdits = mergeFileEdits(this.currentFileEdits, event.edits) this.currentFileEdits = mergeFileEdits(this.currentFileEdits, event.edits)
if (this.diffViewer.visible) this.diffViewer.update(this.currentFileEdits) if (this.diffViewer.visible) this.diffViewer.update(this.currentFileEdits)
this.lastProgress = this.transcript.fileEdits(event.edits) this.transcript.fileEdits(event.edits)
this.setCurrentAction(this.lastProgress || "Editing")
this.setActive(true) this.setActive(true)
this.reportHostWorking()
return return
case "reasoning_delta": case "reasoning_delta":
this.activeLabel = "Thinking" this.activeLabel = "Thinking"
@@ -1166,7 +1162,6 @@ export class NanobotTui {
if (typeof event.context_window_tokens === "number") { if (typeof event.context_window_tokens === "number") {
this.contextWindowTokens = event.context_window_tokens this.contextWindowTokens = event.context_window_tokens
} }
this.applyHostGoalState(event.goal_state)
this.updateTitle() this.updateTitle()
this.setActive(false) this.setActive(false)
// A synthetic/rehydrated turn may already be idle, in which case // A synthetic/rehydrated turn may already be idle, in which case
@@ -1176,7 +1171,6 @@ export class NanobotTui {
? `${(event.latency_ms / 1000).toFixed(1)}s` ? `${(event.latency_ms / 1000).toFixed(1)}s`
: "" : ""
this.status.content = this.readyStatus() this.status.content = this.readyStatus()
this.reportHostResting()
if (this.contextTokens !== null) void this.refreshContextEstimate(event.chat_id) if (this.contextTokens !== null) void this.refreshContextEstimate(event.chat_id)
this.sendNextFollowUp() this.sendNextFollowUp()
return return
@@ -1185,17 +1179,12 @@ export class NanobotTui {
if (event.status === "running") { if (event.status === "running") {
if (event.turn_id) this.activeTurnId = event.turn_id if (event.turn_id) this.activeTurnId = event.turn_id
this.activeLabel = "Working" this.activeLabel = "Working"
if (!this.currentAction) this.setCurrentAction("Working")
this.setActive(true, typeof event.started_at === "number" ? event.started_at * 1000 : undefined) this.setActive(true, typeof event.started_at === "number" ? event.started_at * 1000 : undefined)
this.reportHostWorking()
} else { } else {
this.setActive(false) this.setActive(false)
this.reportHostResting()
} }
return return
case "goal_state": case "goal_state":
this.applyHostGoalState(event.goal_state)
if (!this.activeTurn) this.reportHostResting()
return return
case "recovery_state": case "recovery_state":
this.applyRecoveryState(event) this.applyRecoveryState(event)
@@ -1242,8 +1231,6 @@ export class NanobotTui {
this.turnHadAnswer = false this.turnHadAnswer = false
this.restoreQueuedPrompts() this.restoreQueuedPrompts()
this.setActive(false) this.setActive(false)
this.setCurrentAction(event.reason || event.detail || "Error")
this.reportHostResting()
return return
} }
} }
@@ -1281,11 +1268,7 @@ export class NanobotTui {
this.restorePromptHistory(history.messages) this.restorePromptHistory(history.messages)
const reversedHistory = [...history.messages].reverse() const reversedHistory = [...history.messages].reverse()
const lastUser = reversedHistory.find((message) => message.role === "user") const lastUser = reversedHistory.find((message) => message.role === "user")
if (lastUser) this.setCurrentTask(lastUser.content) if (lastUser) this.host.reportTitle(lastUser.content)
const lastActivity = reversedHistory.find((message) => message.role === "activity")
if (lastActivity) {
this.setCurrentAction(lastActivity.fileEdits?.length ? "Edited" : lastActivity.content)
}
this.lastFileEdits = latestTurnFileEdits(history.messages) this.lastFileEdits = latestTurnFileEdits(history.messages)
if (this.diffViewer.visible) this.diffViewer.update(this.lastFileEdits) if (this.diffViewer.visible) this.diffViewer.update(this.lastFileEdits)
} }
@@ -1297,7 +1280,6 @@ export class NanobotTui {
this.ready = true this.ready = true
if (!this.activeTurn) { if (!this.activeTurn) {
this.status.content = this.readyStatus() this.status.content = this.readyStatus()
this.reportHostResting()
} }
} }
} }
@@ -1323,35 +1305,24 @@ export class NanobotTui {
this.recoveryPending = false this.recoveryPending = false
if (state.status === "resuming") { if (state.status === "resuming") {
this.recoveryNotice.hide() this.recoveryNotice.hide()
this.hostBlocked = false
this.activeLabel = "Continuing" this.activeLabel = "Continuing"
this.setCurrentAction("Continuing interrupted task")
this.setActive(true) this.setActive(true)
this.reportHostWorking()
return return
} }
if (state.status === "awaiting_user" || state.status === "failed") { if (state.status === "awaiting_user" || state.status === "failed") {
this.activeTurnId = null this.activeTurnId = null
this.setActive(false) this.setActive(false)
this.hostBlocked = true
this.recoveryNotice.show(state) this.recoveryNotice.show(state)
const detail = state.reason || (state.status === "failed"
? "Recovery failed"
: "Task interrupted")
this.setCurrentAction(detail)
this.status.content = state.can_continue === false this.status.content = state.can_continue === false
? "Interrupted · dismiss to start a new message" ? "Interrupted · dismiss to start a new message"
: "Interrupted · continue or dismiss" : "Interrupted · continue or dismiss"
this.host.reportState("blocked", detail)
this.composer.focus() this.composer.focus()
return return
} }
this.clearRecoveryState() this.clearRecoveryState()
this.activeTurnId = null this.activeTurnId = null
this.hostBlocked = false
this.setActive(false) this.setActive(false)
if (this.ready) this.status.content = this.readyStatus() if (this.ready) this.status.content = this.readyStatus()
this.reportHostResting()
} }
private async updateRecovery(action: "continue" | "dismiss"): Promise<void> { private async updateRecovery(action: "continue" | "dismiss"): Promise<void> {
@@ -1383,7 +1354,6 @@ export class NanobotTui {
this.recoveryPending = false this.recoveryPending = false
this.recoveryNotice.setBusy(false) this.recoveryNotice.setBusy(false)
this.status.content = error instanceof Error ? error.message : String(error) this.status.content = error instanceof Error ? error.message : String(error)
this.host.reportState("blocked", state.reason || "Task interrupted")
} finally { } finally {
this.composer.focus() this.composer.focus()
} }
@@ -1437,13 +1407,11 @@ export class NanobotTui {
this.connectionMessage = connectionStatusText(status, info) this.connectionMessage = connectionStatusText(status, info)
if (status === "connected") { if (status === "connected") {
this.ready = false this.ready = false
this.host.reportState("unknown", "Getting ready")
this.renderConnectionMessage() this.renderConnectionMessage()
return return
} }
if (["starting", "connecting", "reconnecting", "unavailable"].includes(status)) { if (["starting", "connecting", "reconnecting", "unavailable"].includes(status)) {
this.ready = false this.ready = false
this.host.reportState("unknown", this.connectionMessage)
if (status === "reconnecting" || status === "unavailable") this.setActive(false) if (status === "reconnecting" || status === "unavailable") this.setActive(false)
this.renderConnectionMessage() this.renderConnectionMessage()
return return
@@ -1451,14 +1419,12 @@ export class NanobotTui {
if (status === "error") { if (status === "error") {
if (info) this.ready = false if (info) this.ready = false
this.setActive(false) this.setActive(false)
this.host.reportState("unknown", this.connectionMessage)
this.renderConnectionMessage() this.renderConnectionMessage()
return return
} }
if (!this.quitting) { if (!this.quitting) {
this.ready = false this.ready = false
this.setActive(false) this.setActive(false)
this.host.reportState("unknown", "Disconnected")
this.renderConnectionMessage() this.renderConnectionMessage()
} }
} }
@@ -1498,7 +1464,6 @@ export class NanobotTui {
} }
if (this.shimmerTimer) clearInterval(this.shimmerTimer) if (this.shimmerTimer) clearInterval(this.shimmerTimer)
this.shimmerTimer = null this.shimmerTimer = null
this.lastProgress = ""
this.status.content = this.readyStatus() this.status.content = this.readyStatus()
} }
@@ -1544,6 +1509,36 @@ export class NanobotTui {
return queue return queue
} }
private composerPrompt(): QueuedPrompt {
const visible = this.composer.plainText.trim()
const content = this.draft.expand(visible).trim()
const media = this.draft.media(visible)
const displayContent = this.draft.display(visible).trim()
return {
content,
...(media.length ? { displayContent } : {}),
options: {
...mentionOptions(content, this.availableMentions()),
...(media.length ? { media } : {}),
},
}
}
private hasPrompt(prompt: QueuedPrompt): boolean {
return Boolean(prompt.content || prompt.options.media?.length)
}
private canSendPrompt(prompt: QueuedPrompt): boolean {
if (this.draft.hasImageLabelConflict(this.composer.plainText)) {
this.status.content = "Duplicate image placeholder text · rename or remove it before sending"
return false
}
if (!this.hasPrompt(prompt)) return false
if ((prompt.options.media?.length || 0) <= MAX_DRAFT_IMAGES) return true
this.status.content = `Remove images until ${MAX_DRAFT_IMAGES} or fewer remain`
return false
}
private restoreQueuedPrompts(): void { private restoreQueuedPrompts(): void {
const queued = this.promptQueue.restore() const queued = this.promptQueue.restore()
if (!queued.length) return if (!queued.length) return
@@ -1555,6 +1550,10 @@ export class NanobotTui {
private queueFollowUp(): void { private queueFollowUp(): void {
if (!this.activeTurn || !this.ready) return if (!this.activeTurn || !this.ready) return
const visibleContent = this.composer.plainText.trim() const visibleContent = this.composer.plainText.trim()
if (this.draft.media(visibleContent).length) {
this.status.content = "Images cannot be queued · press Enter to send now"
return
}
const content = this.draft.expand(visibleContent).trim() const content = this.draft.expand(visibleContent).trim()
if (!content) return if (!content) return
this.promptQueue.enqueue({ this.promptQueue.enqueue({
@@ -1719,6 +1718,18 @@ export class NanobotTui {
return return
} }
} }
if (
(key.ctrl || key.meta)
&& key.name.toLocaleLowerCase() === "v"
&& !this.sessionLoading
&& !this.sessionMenu.visible
&& !this.branchMenu.visible
&& !this.contextPanel.visible
) {
key.preventDefault()
void this.pasteClipboardImage()
return
}
if (this.activeTurn && !key.ctrl && !key.meta && key.name === "tab") { if (this.activeTurn && !key.ctrl && !key.meta && key.name === "tab") {
this.queueFollowUp() this.queueFollowUp()
key.preventDefault() key.preventDefault()
@@ -1735,6 +1746,20 @@ export class NanobotTui {
key.preventDefault() key.preventDefault()
return return
} }
if (!key.ctrl && !key.meta && !key.shift && (key.name === "left" || key.name === "right")) {
const direction = key.name === "left" ? -1 : 1
const target = this.draft.moveImageCursor(
this.composer.plainText,
this.composerStringCursor(),
direction,
)
if (target !== null) {
this.composerCursor = target
this.setComposerStringCursor(this.composer.plainText, target)
key.preventDefault()
return
}
}
if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) { if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) {
const direction = key.name === "up" ? -1 : 1 const direction = key.name === "up" ? -1 : 1
const boundary = direction < 0 ? 0 : this.composer.plainText.length const boundary = direction < 0 ? 0 : this.composer.plainText.length
@@ -1795,7 +1820,7 @@ export class NanobotTui {
} }
private navigateHistory(direction: -1 | 1): boolean { private navigateHistory(direction: -1 | 1): boolean {
if (this.promptHistory.length === 0) return false if (this.promptHistory.length === 0 || this.draft.imageCount) return false
if (direction < 0) { if (direction < 0) {
if (this.historyCursor === this.promptHistory.length) this.historyDraft = this.composer.plainText if (this.historyCursor === this.promptHistory.length) this.historyDraft = this.composer.plainText
if (this.historyCursor === 0) return false if (this.historyCursor === 0) return false
@@ -1846,6 +1871,11 @@ export class NanobotTui {
this.composer.textColor = this.palette.text this.composer.textColor = this.palette.text
this.composer.focusedTextColor = this.palette.text this.composer.focusedTextColor = this.palette.text
this.composer.cursorColor = this.palette.accent this.composer.cursorColor = this.palette.accent
const previousComposerSyntax = this.composerSyntax
this.composerSyntax = composerSyntaxStyle(this.palette)
this.composer.syntaxStyle = this.composerSyntax
this.syncComposerImageHighlights(this.composer.plainText)
void this.renderer.idle().catch(() => {}).finally(() => previousComposerSyntax.destroy())
this.renderTitleColor() this.renderTitleColor()
this.status.fg = this.palette.muted this.status.fg = this.palette.muted
this.meta.fg = this.palette.faint this.meta.fg = this.palette.faint
@@ -1857,7 +1887,7 @@ export class NanobotTui {
this.syncComposerPlaceholder() this.syncComposerPlaceholder()
this.contextPanel.resize(this.renderer.height) this.contextPanel.resize(this.renderer.height)
this.diffViewer.resize(this.renderer.width) this.diffViewer.resize(this.renderer.width)
if (!this.host.hosted) this.title.visible = this.renderer.height >= 14 this.title.visible = this.renderer.height >= 14
this.runtimeControls.resize(this.renderer.width) this.runtimeControls.resize(this.renderer.width)
this.updateTitle() this.updateTitle()
this.updateMeta() this.updateMeta()
@@ -1926,10 +1956,6 @@ export class NanobotTui {
} }
private updateTitle(): void { private updateTitle(): void {
if (this.host.hosted) {
this.syncHostMetadata()
return
}
const identity = this.sessionTitle.trim() || "nanobot" const identity = this.sessionTitle.trim() || "nanobot"
this.titleText.maxWidth = Math.max(8, Math.floor(this.renderer.width * 0.38)) this.titleText.maxWidth = Math.max(8, Math.floor(this.renderer.width * 0.38))
this.titleText.content = identity this.titleText.content = identity
@@ -1940,69 +1966,14 @@ export class NanobotTui {
: ""} ctx` : ""} ctx`
this.runtimeControls.updateModel(this.modelName, this.modelPreset) this.runtimeControls.updateModel(this.modelName, this.modelPreset)
this.runtimeControls.updateContext(context) this.runtimeControls.updateContext(context)
this.syncHostMetadata()
} }
private renderTitleColor(): void { private renderTitleColor(): void {
this.titleText.fg = !this.host.hosted && (this.sessionLoading || this.sessionMenu.visible) this.titleText.fg = this.sessionLoading || this.sessionMenu.visible
? this.palette.accent ? this.palette.accent
: this.palette.muted : this.palette.muted
} }
private setCurrentTask(task: string): void {
const next = singleLine(task)
if (!next || next === this.currentTask) return
this.currentTask = next
this.updateTitle()
}
private setCurrentAction(action: string): void {
const next = singleLine(action.replace(/^\s*[·×]\s*/u, ""), 80)
if (!next || next === this.currentAction) return
this.currentAction = next
this.syncHostMetadata()
}
private clearHostContext(): void {
this.currentTask = ""
this.currentAction = ""
this.hostBlocked = false
this.updateTitle()
}
private syncHostMetadata(): void {
const model = [this.modelPreset, this.modelName].filter(Boolean).join(" · ")
this.host.reportMetadata({
model,
branch: this.hostBranch,
workspace: this.hostWorkspace,
task: this.currentTask,
action: this.currentAction,
})
}
private applyHostGoalState(state: Record<string, unknown> | undefined): void {
if (!state) return
this.hostBlocked = state.status === "blocked"
if (!this.hostBlocked) return
const summary = typeof state.ui_summary === "string" ? state.ui_summary : ""
const recap = typeof state.recap === "string" ? state.recap : ""
const objective = typeof state.objective === "string" ? state.objective : ""
this.setCurrentAction(summary || recap || objective || "Needs input")
this.host.reportState("blocked", summary || recap || objective || this.currentTask)
}
private reportHostResting(): void {
this.host.reportState(
this.hostBlocked ? "blocked" : "idle",
this.hostBlocked ? this.currentAction || this.currentTask : this.currentAction,
)
}
private reportHostWorking(): void {
if (!this.hostBlocked) this.host.reportState("working", this.currentTask)
}
private resizeComposer(): void { private resizeComposer(): void {
const verticalPadding = this.renderer.height >= 12 ? 1 : 0 const verticalPadding = this.renderer.height >= 12 ? 1 : 0
const maxContentHeight = Math.max(1, Math.min(12, Math.floor(this.renderer.height / 3))) const maxContentHeight = Math.max(1, Math.min(12, Math.floor(this.renderer.height / 3)))
@@ -2116,7 +2087,58 @@ export class NanobotTui {
return this.composer.editBuffer.getTextRange(0, this.composer.cursorOffset).length return this.composer.editBuffer.getTextRange(0, this.composer.cursorOffset).length
} }
private keepComposerCursorOutsideImages(): void {
if (this.reconcilingComposer) return
const value = this.composer.plainText
const cursor = this.composerStringCursor()
const target = this.draft.snapImageCursor(value, cursor, this.composerCursor)
this.composerCursor = target
if (target !== cursor) this.setComposerStringCursor(value, target)
}
private handleComposerContentChange(): void {
if (this.reconcilingComposer) return
let value = this.composer.plainText
let cursor = this.composerStringCursor()
const edit = this.draft.reconcileImageEdit(this.composerValue, value, cursor)
if (edit.value !== value) {
this.reconcilingComposer = true
try {
this.composer.replaceText(edit.value)
this.composer.clearSelection()
this.setComposerStringCursor(edit.value, edit.cursor)
} finally {
this.reconcilingComposer = false
}
value = edit.value
cursor = edit.cursor
}
this.composerValue = value
this.composerCursor = cursor
this.draft.prune(value)
this.syncComposerImageHighlights(value)
const clearedUnsent = this.unsentSubmit && !value.trim()
if (clearedUnsent) this.unsentSubmit = false
this.runtimeControls.hide()
if (this.contextPanel.visible && value) this.contextPanel.hide()
this.syncComposerPlaceholder()
if (this.sessionMenu.visible) this.syncSessionMenu()
else if (this.branchMenu.visible) this.syncBranchMenu()
else this.syncComposerMenus()
this.resizeComposer()
if (clearedUnsent && !this.activeTurn) {
this.status.content = this.ready ? this.readyStatus() : this.connectionMessage
}
if (edit.removedImages.length) {
this.status.content = `Removed ${edit.removedImages.join(", ")}`
}
}
private setComposerStringCursor(value: string, cursor: number): void { private setComposerStringCursor(value: string, cursor: number): void {
this.composer.cursorOffset = this.composerOffsetForStringIndex(value, cursor)
}
private composerOffsetForStringIndex(value: string, cursor: number): number {
const target = Math.min(Math.max(cursor, 0), value.length) const target = Math.min(Math.max(cursor, 0), value.length)
const before = value.slice(0, target) const before = value.slice(0, target)
const row = before.split("\n").length - 1 const row = before.split("\n").length - 1
@@ -2130,10 +2152,25 @@ export class NanobotTui {
if (candidateLength > target) break if (candidateLength > target) break
if (candidateLength === target) offset = candidate if (candidateLength === target) offset = candidate
} }
this.composer.cursorOffset = offset return offset
}
private syncComposerImageHighlights(value: string): void {
this.composer.clearAllHighlights()
const styleId = this.composerSyntax.getStyleId(IMAGE_PLACEHOLDER_STYLE)
if (styleId === null) return
for (const range of this.draft.imagePlaceholderRanges(value)) {
this.composer.addHighlightByCharRange({
start: this.composerOffsetForStringIndex(value, range.start),
end: this.composerOffsetForStringIndex(value, range.end),
styleId,
priority: 100,
})
}
} }
private setComposer(content: string): void { private setComposer(content: string): void {
this.clipboardPasteGeneration += 1
this.draft.clear() this.draft.clear()
this.composer.setText(content) this.composer.setText(content)
this.composer.cursorOffset = content.length this.composer.cursorOffset = content.length
@@ -2141,8 +2178,42 @@ export class NanobotTui {
private clearComposer(): void { private clearComposer(): void {
this.unsentSubmit = false this.unsentSubmit = false
this.draft.clear() this.setComposer("")
this.composer.setText("") }
private async pasteClipboardImage(): Promise<void> {
if (this.clipboardImagePending) return
if (this.draft.imageCount >= MAX_DRAFT_IMAGES) {
this.status.content = `A message can include up to ${MAX_DRAFT_IMAGES} images`
return
}
const generation = this.clipboardPasteGeneration
this.clipboardImagePending = true
this.status.content = "Reading clipboard image…"
try {
const image = await this.clipboardImageReader.read()
if (this.quitting || generation !== this.clipboardPasteGeneration) return
const insertion = this.draft.image(image, this.composer.plainText)
if (!insertion) {
this.status.content = `A message can include up to ${MAX_DRAFT_IMAGES} images`
return
}
this.composer.insertText(insertion.text)
this.status.content = `Pasted ${insertion.description} · review before sending`
} catch (error) {
if (
this.quitting
|| this.composer.isDestroyed
|| generation !== this.clipboardPasteGeneration
) return
const message = error instanceof Error
? error.message
: "Clipboard image paste is unavailable"
this.status.content = message
this.transcript.notice(message, true)
} finally {
this.clipboardImagePending = false
}
} }
private handlePaste(event: PasteEvent): void { private handlePaste(event: PasteEvent): void {
@@ -2188,11 +2259,6 @@ export class NanobotTui {
} }
private applyWorkspaceScope(scope: WorkspaceScopePayload): void { private applyWorkspaceScope(scope: WorkspaceScopePayload): void {
if (scope.project_path) {
this.hostWorkspace = scope.project_path
this.hostBranch = currentGitBranch(scope.project_path)
this.syncHostMetadata()
}
this.runtimeControls.updateWorkspaceScope(scope) this.runtimeControls.updateWorkspaceScope(scope)
this.updateTitle() this.updateTitle()
if (!this.activeTurn && this.ready) this.status.content = this.readyStatus() if (!this.activeTurn && this.ready) this.status.content = this.readyStatus()
@@ -2284,8 +2350,7 @@ export class NanobotTui {
this.clearPromptQueue() this.clearPromptQueue()
this.sessionMetadataId += 1 this.sessionMetadataId += 1
this.sessionTitle = `Fork · ${preview.slice(0, 48)}` this.sessionTitle = `Fork · ${preview.slice(0, 48)}`
this.clearHostContext() this.host.reportTitle(preview)
this.setCurrentTask(preview)
this.contextTokens = null this.contextTokens = null
this.lastUsage = null this.lastUsage = null
this.readyDetail = "" this.readyDetail = ""
@@ -2382,7 +2447,7 @@ export class NanobotTui {
this.clearRecoveryState() this.clearRecoveryState()
this.queuePreview.update([]) this.queuePreview.update([])
this.sessionMetadataId += 1 this.sessionMetadataId += 1
this.clearHostContext() this.host.reportTitle("")
this.sessionTitle = sessionLabel(session) this.sessionTitle = sessionLabel(session)
this.applySessionModel(session) this.applySessionModel(session)
this.applySessionScope(session) this.applySessionScope(session)
@@ -2418,7 +2483,7 @@ export class NanobotTui {
this.clearRecoveryState() this.clearRecoveryState()
this.clearPromptQueue() this.clearPromptQueue()
this.sessionMetadataId += 1 this.sessionMetadataId += 1
this.clearHostContext() this.host.reportTitle("")
this.sessionTitle = "New chat" this.sessionTitle = "New chat"
this.sessionModelPreset = null this.sessionModelPreset = null
this.modelName = this.defaultModelName this.modelName = this.defaultModelName
@@ -2465,17 +2530,13 @@ export class NanobotTui {
if (!silent) this.recordPrompt(content) if (!silent) this.recordPrompt(content)
if (lifecycle === "agent_turn") { if (lifecycle === "agent_turn") {
this.hostBlocked = false this.host.reportTitle(content)
this.setCurrentTask(content)
this.activeTurnId = turnId this.activeTurnId = turnId
this.finalMessage = "" this.finalMessage = ""
this.turnHadAnswer = false this.turnHadAnswer = false
this.lastProgress = ""
this.activeLabel = "Thinking" this.activeLabel = "Thinking"
this.currentFileEdits = [] this.currentFileEdits = []
this.setCurrentAction("Thinking")
this.setActive(true) this.setActive(true)
this.reportHostWorking()
} else if (lifecycle === "finalize_active_turn") { } else if (lifecycle === "finalize_active_turn") {
this.activeTurnId = null this.activeTurnId = null
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage) this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
@@ -2483,12 +2544,10 @@ export class NanobotTui {
this.finalMessage = "" this.finalMessage = ""
this.turnHadAnswer = false this.turnHadAnswer = false
this.setActive(false) this.setActive(false)
this.reportHostResting()
this.status.content = "Resetting chat…" this.status.content = "Resetting chat…"
} else if (lifecycle === "stop_active_turn") { } else if (lifecycle === "stop_active_turn") {
this.activeTurnId = null this.activeTurnId = null
this.setActive(false) this.setActive(false)
this.reportHostResting()
this.status.content = "Stopping…" this.status.content = "Stopping…"
} else if (!this.activeTurn) { } else if (!this.activeTurn) {
this.status.content = `Running ${content.split(/\s+/u, 1)[0]}` this.status.content = `Running ${content.split(/\s+/u, 1)[0]}`
@@ -2496,6 +2555,7 @@ export class NanobotTui {
} }
private recordPrompt(content: string): void { private recordPrompt(content: string): void {
if (!content) return
if (this.promptHistory.at(-1) !== content) this.promptHistory.push(content) if (this.promptHistory.at(-1) !== content) this.promptHistory.push(content)
if (this.promptHistory.length > 50) this.promptHistory.shift() if (this.promptHistory.length > 50) this.promptHistory.shift()
this.historyCursor = this.promptHistory.length this.historyCursor = this.promptHistory.length
@@ -2723,10 +2783,14 @@ export class NanobotTui {
} }
private handleDestroy = (): void => { private handleDestroy = (): void => {
this.quitting = true
this.clipboardPasteGeneration += 1
if (this.shimmerTimer) clearInterval(this.shimmerTimer) if (this.shimmerTimer) clearInterval(this.shimmerTimer)
this.stopSessionRefresh() this.stopSessionRefresh()
this.composerSyntax.destroy()
this.transcript.destroy() this.transcript.destroy()
this.diffViewer.destroy() this.diffViewer.destroy()
void this.clipboardImageReader.dispose().catch(() => {})
this.host.release() this.host.release()
this.client.close() this.client.close()
} }
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test"
import type { ClipboardReadResult, HostClipboardService } from "@opentui/core"
import { createClipboardImageReader } from "./clipboard-image"
function clipboard(result: ClipboardReadResult) {
let disposed = false
const service = {
maxWriteBytes: 1,
read: async () => result,
writeText: async () => ({ status: "unsupported" as const }),
clear: async () => ({ status: "unsupported" as const }),
dispose: async () => { disposed = true },
} satisfies HostClipboardService
return { service, disposed: () => disposed }
}
describe("clipboard image reader", () => {
test("encodes supported native clipboard bytes as a data URL", async () => {
const fake = clipboard({
status: "read",
representation: { mimeType: "image/png", bytes: Uint8Array.from([0, 1, 2, 255]) },
})
const reader = createClipboardImageReader(() => fake.service)
expect(await reader.read()).toEqual({
mimeType: "image/png",
dataUrl: "data:image/png;base64,AAEC/w==",
})
await reader.dispose()
expect(fake.disposed()).toBeTrue()
})
test.each([
["empty", "No image in clipboard"],
["limit-exceeded", "Clipboard image is larger than 6 MB"],
["timed-out", "Clipboard image read timed out"],
["unsupported", "Clipboard image paste is unavailable"],
] as const)("reports %s without exposing native details", async (status, message) => {
const fake = clipboard({ status })
const reader = createClipboardImageReader(() => fake.service)
await expect(reader.read()).rejects.toThrow(message)
await reader.dispose()
})
})
+64
View File
@@ -0,0 +1,64 @@
import {
createHostClipboard,
type HostClipboardService,
} from "@opentui/core"
const IMAGE_MIME_TYPES = [
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
] as const
const MAX_IMAGE_BYTES = 6 * 1024 * 1024
export interface ClipboardImage {
dataUrl: string
mimeType: typeof IMAGE_MIME_TYPES[number]
}
export interface ClipboardImageReader {
read(): Promise<ClipboardImage>
dispose(): Promise<void>
}
type ClipboardFactory = () => HostClipboardService
function readFailure(status: string): Error {
if (status === "empty") return new Error("No image in clipboard")
if (status === "limit-exceeded") return new Error("Clipboard image is larger than 6 MB")
if (status === "timed-out") return new Error("Clipboard image read timed out")
return new Error("Clipboard image paste is unavailable")
}
/** Lazily owns OpenTUI's native host clipboard so ordinary TUI startup does no clipboard work. */
export function createClipboardImageReader(
createClipboard: ClipboardFactory = () => createHostClipboard({ maxReadBytes: MAX_IMAGE_BYTES }),
): ClipboardImageReader {
let clipboard: HostClipboardService | null = null
let disposed = false
return {
async read(): Promise<ClipboardImage> {
if (disposed) throw new Error("Clipboard image paste is unavailable")
clipboard ||= createClipboard()
const result = await clipboard.read({ preferredTypes: IMAGE_MIME_TYPES })
if (result.status !== "read") throw readFailure(result.status)
const normalizedMime = result.representation.mimeType.toLowerCase()
const mimeType = IMAGE_MIME_TYPES.find((candidate) => candidate === normalizedMime)
if (!mimeType) throw new Error("Clipboard does not contain a supported image")
const bytes = result.representation.bytes
if (!bytes.length) throw new Error("Clipboard image is empty")
if (bytes.length > MAX_IMAGE_BYTES) throw new Error("Clipboard image is larger than 6 MB")
return {
mimeType,
dataUrl: `data:${mimeType};base64,${Buffer.from(bytes).toString("base64")}`,
}
},
async dispose(): Promise<void> {
if (disposed) return
disposed = true
await clipboard?.dispose()
clipboard = null
},
}
}
+124 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { ComposerDraft } from "./composer-draft" import { ComposerDraft, MAX_DRAFT_IMAGES } from "./composer-draft"
describe("ComposerDraft", () => { describe("ComposerDraft", () => {
test("keeps ordinary pastes editable as ordinary text", () => { test("keeps ordinary pastes editable as ordinary text", () => {
@@ -25,4 +25,127 @@ describe("ComposerDraft", () => {
expect(draft.expand(first.text.trim())).toBe(first.text.trim()) expect(draft.expand(first.text.trim())).toBe(first.text.trim())
expect(draft.expand(second.text.trim())).toBe(content) expect(draft.expand(second.text.trim())).toBe(content)
}) })
test("keeps image bytes outside the editor and drops attachments with deleted placeholders", () => {
const draft = new ComposerDraft()
const first = draft.image({ mimeType: "image/png", dataUrl: "data:image/png;base64,AAAA" })
const second = draft.image({ mimeType: "image/jpeg", dataUrl: "data:image/jpeg;base64,BBBB" })
expect(first?.text).toBe("[Image #1] ")
expect(second?.text).toBe("[Image #2] ")
const visible = `compare ${second?.text}${first?.text}`
expect(draft.expand(visible)).toBe("compare ")
expect(draft.display(visible)).toBe(visible)
expect(draft.media(visible)).toEqual([
{ data_url: "data:image/jpeg;base64,BBBB", name: "clipboard-image-2.jpg" },
{ data_url: "data:image/png;base64,AAAA", name: "clipboard-image-1.png" },
])
draft.prune(first?.text || "")
expect(draft.imageCount).toBe(1)
expect(draft.media(second?.text || "")).toEqual([])
})
test("removes a partially edited image placeholder as one atomic unit", () => {
const draft = new ComposerDraft()
const image = draft.image({ mimeType: "image/png", dataUrl: "data:image/png;base64,AAAA" })
const previous = `before ${image?.text}after`
const value = previous.replace("[Image #1]", "Image #1]")
expect(draft.reconcileImageEdit(previous, value, 7)).toEqual({
value: "before after",
cursor: 7,
removedImages: ["Image #1"],
})
expect(draft.imageCount).toBe(0)
expect(draft.media(value)).toEqual([])
})
test("removes an edited duplicate occurrence without leaving a placeholder fragment", () => {
const draft = new ComposerDraft()
const image = draft.image({ mimeType: "image/png", dataUrl: "data:image/png;base64,AAAA" })
const label = image?.text.trim() || ""
const previous = `${label} ${label}`
expect(draft.reconcileImageEdit(previous, previous.slice(1), 0)).toEqual({
value: ` ${label}`,
cursor: 0,
removedImages: [],
})
expect(draft.imageCount).toBe(1)
})
test("snaps cursor movement across complete image placeholders", () => {
const draft = new ComposerDraft()
const image = draft.image({ mimeType: "image/png", dataUrl: "data:image/png;base64,AAAA" })
const visible = `a ${image?.text}b`
expect(draft.snapImageCursor(visible, 3, 2)).toBe(12)
expect(draft.snapImageCursor(visible, 11, 12)).toBe(2)
expect(draft.snapImageCursor(visible, 2, 0)).toBe(2)
expect(draft.snapImageCursor(visible, 12, 13)).toBe(12)
expect(draft.moveImageCursor(visible, 2, 1)).toBe(12)
expect(draft.moveImageCursor(visible, 12, -1)).toBe(2)
})
test("allocates image labels around literal composer text", () => {
const draft = new ComposerDraft()
const content = "Explain [Image #1]"
const insertion = draft.image(
{ mimeType: "image/png", dataUrl: "data:image/png;base64,AAAA" },
content,
)
expect(insertion?.text).toBe("[Image #2] ")
expect(draft.expand(`${content} ${insertion?.text}`.trim())).toBe(`${content} `)
})
test("detects image labels duplicated after insertion without deleting text", () => {
const draft = new ComposerDraft()
const image = draft.image({ mimeType: "image/png", dataUrl: "data:image/png;base64,AAAA" })
const visible = `${image?.text}Explain [Image #1]`
expect(draft.hasImageLabelConflict(visible)).toBeTrue()
expect(draft.expand(visible)).toBe(visible)
})
test("detects image labels inside compacted paste text added afterward", () => {
const draft = new ComposerDraft()
const image = draft.image({ mimeType: "image/png", dataUrl: "data:image/png;base64,AAAA" })
const content = ["Explain [Image #1]", ...Array.from({ length: 11 }, () => "detail")].join("\n")
const paste = draft.paste(content)
const visible = `${image?.text}${paste.text}`
expect(draft.hasImageLabelConflict(visible)).toBeTrue()
expect(draft.expand(visible)).toContain("Explain [Image #1]")
})
test("allocates image labels around hidden compacted paste text", () => {
const draft = new ComposerDraft()
const content = ["Explain [Image #1]", ...Array.from({ length: 11 }, () => "detail")].join("\n")
const paste = draft.paste(content)
const image = draft.image(
{ mimeType: "image/png", dataUrl: "data:image/png;base64,AAAA" },
paste.text,
)
expect(image?.text).toBe("[Image #2] ")
expect(draft.expand(`${paste.text}${image?.text}`)).toContain("Explain [Image #1]")
})
test("matches the gateway image count before accepting another placeholder", () => {
const draft = new ComposerDraft()
for (let index = 0; index < MAX_DRAFT_IMAGES; index += 1) {
expect(draft.image({
mimeType: "image/png",
dataUrl: `data:image/png;base64,${index}`,
})).not.toBeNull()
}
expect(draft.image({
mimeType: "image/png",
dataUrl: "data:image/png;base64,overflow",
})).toBeNull()
expect(draft.imageCount).toBe(MAX_DRAFT_IMAGES)
})
}) })
+157 -2
View File
@@ -1,5 +1,8 @@
import type { OutboundMedia } from "./protocol"
const LARGE_PASTE_CHARS = 1_000 const LARGE_PASTE_CHARS = 1_000
const LARGE_PASTE_LINES = 10 const LARGE_PASTE_LINES = 10
export const MAX_DRAFT_IMAGES = 4
export interface PasteInsertion { export interface PasteInsertion {
text: string text: string
@@ -7,9 +10,32 @@ export interface PasteInsertion {
description: string description: string
} }
/** Keeps large pasted text out of the editor without changing what is sent. */ export interface DraftEditReconciliation {
value: string
cursor: number
removedImages: string[]
}
const IMAGE_EXTENSIONS = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
"image/gif": "gif",
} as const
interface DraftImage {
dataUrl: string
mimeType: keyof typeof IMAGE_EXTENSIONS
}
/** Keeps large pasted text and image payloads out of the editable composer surface. */
export class ComposerDraft { export class ComposerDraft {
private readonly pastes = new Map<string, string>() private readonly pastes = new Map<string, string>()
private readonly images = new Map<string, OutboundMedia>()
get imageCount(): number {
return this.images.size
}
paste(value: string): PasteInsertion { paste(value: string): PasteInsertion {
const text = value.replace(/\r\n/gu, "\n").replace(/\r/gu, "\n") const text = value.replace(/\r\n/gu, "\n").replace(/\r/gu, "\n")
@@ -26,19 +52,148 @@ export class ComposerDraft {
return { text: `${label} `, compacted: true, description } return { text: `${label} `, compacted: true, description }
} }
expand(visible: string): string { private imageLabelInUse(label: string, visible: string): boolean {
if (this.images.has(label) || visible.includes(label)) return true
for (const content of this.pastes.values()) {
if (content.includes(label)) return true
}
return false
}
private nextImageIndex(visible: string): number {
let index = 1
while (this.imageLabelInUse(`[Image #${index}]`, visible)) index += 1
return index
}
image(image: DraftImage, visible = ""): PasteInsertion | null {
if (this.images.size >= MAX_DRAFT_IMAGES) return null
const index = this.nextImageIndex(visible)
const label = `[Image #${index}]`
this.images.set(label, {
data_url: image.dataUrl,
name: `clipboard-image-${index}.${IMAGE_EXTENSIONS[image.mimeType]}`,
})
return { text: `${label} `, compacted: true, description: label.slice(1, -1) }
}
private expandPastes(visible: string): string {
let expanded = visible let expanded = visible
for (const [label, content] of this.pastes) expanded = expanded.split(label).join(content) for (const [label, content] of this.pastes) expanded = expanded.split(label).join(content)
return expanded return expanded
} }
private labelOccurrences(content: string, label: string): number {
return content.split(label).length - 1
}
imagePlaceholderRanges(visible: string): Array<{ start: number; end: number }> {
const ranges: Array<{ start: number; end: number }> = []
for (const label of this.images.keys()) {
let start = visible.indexOf(label)
while (start >= 0) {
ranges.push({ start, end: start + label.length })
start = visible.indexOf(label, start + label.length)
}
}
return ranges.sort((left, right) => left.start - right.start)
}
snapImageCursor(visible: string, cursor: number, previousCursor: number): number {
const range = this.imagePlaceholderRanges(visible)
.find(({ start, end }) => cursor > start && cursor < end)
if (!range) return cursor
if (previousCursor <= range.start) return range.end
if (previousCursor >= range.end) return range.start
return cursor - range.start < range.end - cursor ? range.start : range.end
}
moveImageCursor(visible: string, cursor: number, direction: -1 | 1): number | null {
const range = this.imagePlaceholderRanges(visible).find(({ start, end }) => (
direction < 0
? cursor > start && cursor <= end
: cursor >= start && cursor < end
))
if (!range) return null
return direction < 0 ? range.start : range.end
}
reconcileImageEdit(
previous: string,
value: string,
cursor: number,
): DraftEditReconciliation {
let oldStart = 0
const sharedLength = Math.min(previous.length, value.length)
while (oldStart < sharedLength && previous[oldStart] === value[oldStart]) oldStart += 1
let oldEnd = previous.length
let newEnd = value.length
while (
oldEnd > oldStart
&& newEnd > oldStart
&& previous[oldEnd - 1] === value[newEnd - 1]
) {
oldEnd -= 1
newEnd -= 1
}
const ranges = this.imagePlaceholderRanges(previous).filter(({ start, end }) => (
oldStart === oldEnd
? oldStart > start && oldStart < end
: oldStart < end && oldEnd > start
))
if (!ranges.length) return { value, cursor, removedImages: [] }
const replaceStart = Math.min(oldStart, ...ranges.map((range) => range.start))
const replaceEnd = Math.max(oldEnd, ...ranges.map((range) => range.end))
const inserted = value.slice(oldStart, newEnd)
const reconciled = previous.slice(0, replaceStart) + inserted + previous.slice(replaceEnd)
const missing = [...this.images.keys()].filter((label) => !reconciled.includes(label))
for (const label of missing) this.images.delete(label)
return {
value: reconciled,
cursor: replaceStart + inserted.length,
removedImages: missing.map((label) => label.slice(1, -1)),
}
}
hasImageLabelConflict(visible: string): boolean {
const expanded = this.expandPastes(visible)
return [...this.images.keys()]
.some((label) => this.labelOccurrences(expanded, label) !== 1)
}
expand(visible: string): string {
let expanded = this.expandPastes(visible)
for (const label of this.images.keys()) {
if (this.labelOccurrences(expanded, label) === 1) expanded = expanded.replace(label, "")
}
return expanded
}
display(visible: string): string {
return this.expandPastes(visible)
}
media(visible: string): OutboundMedia[] {
return [...this.images]
.filter(([label]) => visible.includes(label))
.sort(([left], [right]) => visible.indexOf(left) - visible.indexOf(right))
.map(([, media]) => media)
}
prune(visible: string): void { prune(visible: string): void {
for (const label of this.pastes.keys()) { for (const label of this.pastes.keys()) {
if (!visible.includes(label)) this.pastes.delete(label) if (!visible.includes(label)) this.pastes.delete(label)
} }
for (const label of this.images.keys()) {
if (!visible.includes(label)) this.images.delete(label)
}
} }
clear(): void { clear(): void {
this.pastes.clear() this.pastes.clear()
this.images.clear()
} }
} }
+38 -53
View File
@@ -1,82 +1,67 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { createTuiHost, currentGitBranch } from "./host" import { createTuiHost } from "./host"
async function settle(): Promise<void> { async function settle(): Promise<void> {
await Bun.sleep(40) await Bun.sleep(0)
await Bun.sleep(0) await Bun.sleep(0)
} }
describe("TUI host integration", () => { describe("TUI host integration", () => {
test("reads the current workspace branch without leaking git errors", () => {
expect(currentGitBranch(process.cwd())).not.toBe("")
expect(currentGitBranch("/definitely/not/a/repository")).toBe("")
})
test("standalone terminals remain a no-op", async () => { test("standalone terminals remain a no-op", async () => {
const commands: string[][] = [] const commands: string[][] = []
const host = createTuiHost({}, async (command) => { commands.push([...command]) }) const host = createTuiHost({}, async (command) => { commands.push([...command]) })
host.reportState("working", "task") host.reportTitle("task")
host.reportSession("chat")
host.reportMetadata({ model: "gpt", task: "task" })
host.release() host.release()
await settle() await settle()
expect(host.hosted).toBe(false)
expect(commands).toEqual([]) expect(commands).toEqual([])
}) })
test("reports semantic lifecycle, session identity, metadata, and release", async () => { test("requires both Herdr environment markers", async () => {
const commands: string[][] = []
const run = async (command: readonly string[]) => { commands.push([...command]) }
createTuiHost({ HERDR_ENV: "1" }, run).reportTitle("missing pane")
createTuiHost({ HERDR_PANE_ID: "w1:p2" }, run).reportTitle("missing host")
await settle()
expect(commands).toEqual([])
})
test("reports only normalized pane title changes and clears the title on release", async () => {
const commands: string[][] = [] const commands: string[][] = []
const host = createTuiHost( const host = createTuiHost(
{ HERDR_ENV: "1", HERDR_PANE_ID: "w1:p2", HERDR_BIN_PATH: "/bin/herdr" }, { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p2", HERDR_BIN_PATH: "/bin/herdr" },
async (command) => { commands.push([...command]) }, async (command) => { commands.push([...command]) },
) )
host.reportMetadata({ host.reportTitle(" Fix\nHerdr integration ")
model: "openai/gpt", host.reportTitle("Fix Herdr integration")
branch: "feat/host", host.reportTitle("Review results")
workspace: "/repo",
task: " Fix\nHerdr integration ",
action: "Testing",
})
host.reportSession("chat-1")
host.reportState("working", "Fix Herdr integration")
host.reportState("working", "Fix Herdr integration")
host.reportState("blocked", "Approval required")
host.release() host.release()
host.reportTitle("ignored after release")
await settle() await settle()
expect(host.hosted).toBe(true) expect(commands).toEqual([
expect(commands).toHaveLength(6) [
expect(commands[0]).toContain("pane") "/bin/herdr", "pane", "report-metadata", "w1:p2",
expect(commands[0]).toContain("report-metadata") "--source", "nanobot:tui:metadata", "--seq", "1",
expect(commands[0]).toContain("task=Fix Herdr integration") "--title", "Fix Herdr integration",
expect(commands[1]).toContain("report-agent-session") ],
expect(commands[1]).toContain("chat-1") [
expect(commands[2]).toContain("working") "/bin/herdr", "pane", "report-metadata", "w1:p2",
expect(commands[2]).toContain("--agent-session-id") "--source", "nanobot:tui:metadata", "--seq", "2",
expect(commands[3]).toContain("blocked") "--title", "Review results",
expect(commands[4]).toContain("--clear-token") ],
expect(commands[5]).toContain("release-agent") [
}) "/bin/herdr", "pane", "report-metadata", "w1:p2",
"--source", "nanobot:tui:metadata", "--seq", "3", "--clear-title",
test("metadata patches only changed tokens", async () => { ],
const commands: string[][] = [] ])
const host = createTuiHost( expect(commands.flat()).not.toContain("report-agent")
{ HERDR_ENV: "1", HERDR_PANE_ID: "w1:p2" }, expect(commands.flat()).not.toContain("report-agent-session")
async (command) => { commands.push([...command]) }, expect(commands.flat()).not.toContain("--token")
)
host.reportMetadata({ model: "gpt", branch: "main" })
host.reportMetadata({ model: "gpt", branch: "main" })
host.reportMetadata({ model: "gpt", branch: "" })
await settle()
expect(commands).toHaveLength(1)
expect(commands[0]).toContain("model=gpt")
expect(commands[0]).toContain("--clear-token")
expect(commands[0]).toContain("branch")
}) })
}) })
+12 -128
View File
@@ -1,60 +1,22 @@
export type HostAgentState = "idle" | "working" | "blocked" | "unknown"
export interface HostMetadata {
model?: string
branch?: string
workspace?: string
task?: string
action?: string
}
export interface TuiHost { export interface TuiHost {
readonly hosted: boolean reportTitle(title: string): void
reportState(state: HostAgentState, message?: string): void
reportSession(sessionId: string): void
reportMetadata(metadata: HostMetadata): void
release(): void release(): void
} }
export function currentGitBranch(workspace: string): string {
const path = workspace.trim()
if (!path) return ""
try {
const branch = spawnText(["git", "-C", path, "branch", "--show-current"])
if (branch) return branch
const revision = spawnText(["git", "-C", path, "rev-parse", "--short", "HEAD"])
return revision ? `@${revision}` : ""
} catch {
return ""
}
}
type Environment = Record<string, string | undefined> type Environment = Record<string, string | undefined>
type CommandRunner = (command: readonly string[]) => Promise<void> type CommandRunner = (command: readonly string[]) => Promise<void>
const AGENT = "nanobot"
const LIFECYCLE_SOURCE = "nanobot:tui"
const METADATA_SOURCE = "nanobot:tui:metadata" const METADATA_SOURCE = "nanobot:tui:metadata"
const METADATA_KEYS = ["model", "branch", "workspace", "task", "action"] as const
const METADATA_FLUSH_MS = 32
class StandaloneHost implements TuiHost { class StandaloneHost implements TuiHost {
readonly hosted = false reportTitle(): void {}
reportState(): void {}
reportSession(): void {}
reportMetadata(): void {}
release(): void {} release(): void {}
} }
class HerdrHost implements TuiHost { class HerdrHost implements TuiHost {
readonly hosted = true
private sequence = 0 private sequence = 0
private released = false private released = false
private lastState = "" private lastTitle = ""
private lastSession = ""
private metadata: HostMetadata = {}
private readonly pendingMetadata = new Set<typeof METADATA_KEYS[number]>()
private metadataTimer: ReturnType<typeof setTimeout> | null = null
private queue: Promise<void> = Promise.resolve() private queue: Promise<void> = Promise.resolve()
constructor( constructor(
@@ -63,97 +25,25 @@ class HerdrHost implements TuiHost {
private readonly run: CommandRunner, private readonly run: CommandRunner,
) {} ) {}
reportState(state: HostAgentState, message = ""): void { reportTitle(title: string): void {
if (this.released) return if (this.released) return
const cleanMessage = normalize(message) const cleanTitle = normalize(title)
const fingerprint = `${state}\0${cleanMessage}\0${this.lastSession}` if (cleanTitle === this.lastTitle) return
if (fingerprint === this.lastState) return this.lastTitle = cleanTitle
this.lastState = fingerprint
// Preserve causal ordering when a semantic state transition follows a
// pending metadata snapshot; repeated working heartbeats still stay free.
this.flushMetadata()
const args = [
"pane", "report-agent", this.paneId,
"--source", LIFECYCLE_SOURCE,
"--agent", AGENT,
"--state", state,
"--seq", String(this.nextSequence()),
]
if (cleanMessage) args.push("--message", cleanMessage)
if (this.lastSession) args.push("--agent-session-id", this.lastSession)
this.enqueue(args)
}
reportSession(sessionId: string): void {
if (this.released) return
const cleanSession = normalize(sessionId, 256)
if (!cleanSession || cleanSession === this.lastSession) return
this.lastSession = cleanSession
this.lastState = ""
this.flushMetadata()
this.enqueue([
"pane", "report-agent-session", this.paneId,
"--source", LIFECYCLE_SOURCE,
"--agent", AGENT,
"--agent-session-id", cleanSession,
"--seq", String(this.nextSequence()),
])
}
reportMetadata(next: HostMetadata): void {
if (this.released) return
for (const key of METADATA_KEYS) {
if (!(key in next)) continue
const value = normalize(next[key])
if (value === normalize(this.metadata[key])) continue
this.pendingMetadata.add(key)
}
if (!this.pendingMetadata.size) return
this.metadata = { ...this.metadata, ...next }
if (this.metadataTimer) return
this.metadataTimer = setTimeout(() => this.flushMetadata(), METADATA_FLUSH_MS)
}
private flushMetadata(): void {
if (this.metadataTimer) clearTimeout(this.metadataTimer)
this.metadataTimer = null
if (!this.pendingMetadata.size) return
const args = [ const args = [
"pane", "report-metadata", this.paneId, "pane", "report-metadata", this.paneId,
"--source", METADATA_SOURCE, "--source", METADATA_SOURCE,
"--agent", AGENT,
"--display-agent", AGENT,
"--seq", String(this.nextSequence()), "--seq", String(this.nextSequence()),
cleanTitle ? "--title" : "--clear-title",
] ]
const task = normalize(this.metadata.task) if (cleanTitle) args.push(cleanTitle)
args.push(task ? "--title" : "--clear-title")
if (task) args.push(task)
for (const key of this.pendingMetadata) {
const value = normalize(this.metadata[key])
args.push(value ? "--token" : "--clear-token", value ? `${key}=${value}` : key)
}
this.pendingMetadata.clear()
this.enqueue(args) this.enqueue(args)
} }
release(): void { release(): void {
if (this.released) return if (this.released) return
this.flushMetadata() this.reportTitle("")
this.released = true this.released = true
const clear = [
"pane", "report-metadata", this.paneId,
"--source", METADATA_SOURCE,
"--clear-title", "--clear-display-agent", "--clear-state-labels",
"--seq", String(this.nextSequence()),
]
for (const key of METADATA_KEYS) clear.push("--clear-token", key)
this.enqueue(clear)
this.enqueue([
"pane", "release-agent", this.paneId,
"--source", LIFECYCLE_SOURCE,
"--agent", AGENT,
"--seq", String(this.nextSequence()),
])
} }
private nextSequence(): number { private nextSequence(): number {
@@ -167,8 +57,8 @@ class HerdrHost implements TuiHost {
} }
} }
function normalize(value: string | undefined, limit = 80): string { function normalize(value: string, limit = 80): string {
return (value || "").replace(/[\u0000-\u001f\u007f]+/gu, " ").replace(/\s+/gu, " ").trim().slice(0, limit) return value.replace(/[\u0000-\u001f\u007f]+/gu, " ").replace(/\s+/gu, " ").trim().slice(0, limit)
} }
async function runCommand(command: readonly string[]): Promise<void> { async function runCommand(command: readonly string[]): Promise<void> {
@@ -176,12 +66,6 @@ async function runCommand(command: readonly string[]): Promise<void> {
await child.exited await child.exited
} }
function spawnText(command: readonly string[]): string {
const result = Bun.spawnSync([...command], { stdout: "pipe", stderr: "ignore" })
if (result.exitCode !== 0) return ""
return new TextDecoder().decode(result.stdout).trim()
}
export function createTuiHost( export function createTuiHost(
environment: Environment = process.env, environment: Environment = process.env,
run: CommandRunner = runCommand, run: CommandRunner = runCommand,
-4
View File
@@ -1,5 +1,4 @@
import { NanobotTui, sessionExitMessage, type AppOptions } from "./app" import { NanobotTui, sessionExitMessage, type AppOptions } from "./app"
import { currentGitBranch } from "./host"
// Keep in sync with _TUI_DETACH_EXIT_CODE in nanobot/cli/tui_launcher.py. // Keep in sync with _TUI_DETACH_EXIT_CODE in nanobot/cli/tui_launcher.py.
const TUI_DETACH_EXIT_CODE = 90 const TUI_DETACH_EXIT_CODE = 90
@@ -11,7 +10,6 @@ function themePreference(): AppOptions["theme"] {
} }
const workspace = process.env.NANOBOT_TUI_WORKSPACE?.trim() || "" const workspace = process.env.NANOBOT_TUI_WORKSPACE?.trim() || ""
const hostWorkspace = process.cwd()
const bootstrapUrl = process.env.NANOBOT_TUI_BOOTSTRAP_URL?.trim() || "" const bootstrapUrl = process.env.NANOBOT_TUI_BOOTSTRAP_URL?.trim() || ""
const wsUrl = process.env.NANOBOT_TUI_WS_URL?.trim() || "" const wsUrl = process.env.NANOBOT_TUI_WS_URL?.trim() || ""
const healthUrl = process.env.NANOBOT_TUI_HEALTH_URL?.trim() || "" const healthUrl = process.env.NANOBOT_TUI_HEALTH_URL?.trim() || ""
@@ -34,8 +32,6 @@ const options: AppOptions = {
model: process.env.NANOBOT_TUI_MODEL?.trim() || "unknown model", model: process.env.NANOBOT_TUI_MODEL?.trim() || "unknown model",
modelPreset: process.env.NANOBOT_TUI_MODEL_PRESET?.trim() || "default", modelPreset: process.env.NANOBOT_TUI_MODEL_PRESET?.trim() || "default",
workspace, workspace,
hostWorkspace,
branch: currentGitBranch(hostWorkspace),
version: process.env.NANOBOT_TUI_VERSION?.trim() || "dev", version: process.env.NANOBOT_TUI_VERSION?.trim() || "dev",
access: process.env.NANOBOT_TUI_ACCESS?.trim() || "workspace access", access: process.env.NANOBOT_TUI_ACCESS?.trim() || "workspace access",
theme: themePreference(), theme: themePreference(),
+1
View File
@@ -2,6 +2,7 @@ import type { MessageOptions } from "./protocol"
export interface QueuedPrompt { export interface QueuedPrompt {
content: string content: string
displayContent?: string
options: MessageOptions options: MessageOptions
} }
+18 -2
View File
@@ -459,6 +459,7 @@ describe("gateway protocol", () => {
}), }),
}) })
client.send("hello", { client.send("hello", {
media: [{ data_url: "data:image/png;base64,AAAA", name: "clipboard-image-1.png" }],
cliApps: [{ name: "github" }], cliApps: [{ name: "github" }],
sessionMentions: [{ name: "plan", session_key: "websocket:plan" }], sessionMentions: [{ name: "plan", session_key: "websocket:plan" }],
userShell: true, userShell: true,
@@ -477,6 +478,9 @@ describe("gateway protocol", () => {
expect(outbound[1]?.chat_id).toBe("terminal") expect(outbound[1]?.chat_id).toBe("terminal")
expect(outbound[1]?.content).toBe("hello") expect(outbound[1]?.content).toBe("hello")
expect(outbound[1]?.user_shell).toBe(true) expect(outbound[1]?.user_shell).toBe(true)
expect(outbound[1]?.media).toEqual([
{ data_url: "data:image/png;base64,AAAA", name: "clipboard-image-1.png" },
])
expect(outbound[1]?.cli_apps).toEqual([{ name: "github" }]) expect(outbound[1]?.cli_apps).toEqual([{ name: "github" }])
expect(outbound[1]?.session_mentions).toEqual([ expect(outbound[1]?.session_mentions).toEqual([
{ name: "plan", session_key: "websocket:plan" }, { name: "plan", session_key: "websocket:plan" },
@@ -875,6 +879,12 @@ describe("gateway protocol", () => {
return Promise.resolve(new Response(JSON.stringify({ return Promise.resolve(new Response(JSON.stringify({
messages: [ messages: [
{ role: "user", content: "hello", turnId: "turn-1" }, { role: "user", content: "hello", turnId: "turn-1" },
{
role: "user",
content: "",
turnId: "turn-image",
media: [{ kind: "image", url: "/api/media/sig/image", name: "shot.png" }],
},
{ {
role: "tool", role: "tool",
kind: "trace", kind: "trace",
@@ -883,7 +893,7 @@ describe("gateway protocol", () => {
toolEvents: [{ phase: "end", call_id: "read-1", name: "read_file" }], toolEvents: [{ phase: "end", call_id: "read-1", name: "read_file" }],
}, },
{ role: "assistant", kind: "reasoning", content: "private thought" }, { role: "assistant", kind: "reasoning", content: "private thought" },
{ role: "assistant", content: "hi", forkIndex: 1 }, { role: "assistant", content: "hi", forkIndex: 2 },
], ],
page: { has_more_before: true, before_cursor: "older-1" }, page: { has_more_before: true, before_cursor: "older-1" },
}))) })))
@@ -894,12 +904,18 @@ describe("gateway protocol", () => {
expect(history).toEqual({ expect(history).toEqual({
messages: [ messages: [
{ role: "user", content: "hello", turnId: "turn-1" }, { role: "user", content: "hello", turnId: "turn-1" },
{
role: "user",
content: "",
turnId: "turn-image",
media: [{ kind: "image", url: "/api/media/sig/image", name: "shot.png" }],
},
{ {
role: "activity", role: "activity",
content: "read_file", content: "read_file",
toolEvents: [{ phase: "end", call_id: "read-1", name: "read_file" }], toolEvents: [{ phase: "end", call_id: "read-1", name: "read_file" }],
}, },
{ role: "assistant", content: "hi", forkIndex: 1 }, { role: "assistant", content: "hi", forkIndex: 2 },
], ],
hasMoreBefore: true, hasMoreBefore: true,
beforeCursor: "older-1", beforeCursor: "older-1",
+14 -3
View File
@@ -53,12 +53,17 @@ interface FileDiff {
text?: string text?: string
} }
interface MediaAttachment { export interface MediaAttachment {
kind: "image" | "video" | "file" kind: "image" | "video" | "file"
url: string url: string
name?: string name?: string
} }
export interface OutboundMedia {
data_url: string
name?: string
}
export interface WorkspaceScopePayload { export interface WorkspaceScopePayload {
project_path: string project_path: string
project_name?: string project_name?: string
@@ -181,6 +186,7 @@ type OutboundEvent =
turn_id: string turn_id: string
webui: true webui: true
workspace_scope?: WorkspaceScopePayload workspace_scope?: WorkspaceScopePayload
media?: OutboundMedia[]
cli_apps?: Array<{ name: string }> cli_apps?: Array<{ name: string }>
mcp_presets?: Array<{ name: string }> mcp_presets?: Array<{ name: string }>
session_mentions?: SessionMention[] session_mentions?: SessionMention[]
@@ -225,6 +231,7 @@ export interface HistoryMessage {
role: "user" | "assistant" | "activity" role: "user" | "assistant" | "activity"
content: string content: string
turnId?: string turnId?: string
media?: MediaAttachment[]
toolEvents?: ToolProgressEvent[] toolEvents?: ToolProgressEvent[]
fileEdits?: FileEditEvent[] fileEdits?: FileEditEvent[]
forkIndex?: number forkIndex?: number
@@ -288,6 +295,7 @@ export interface SkillCandidate {
} }
export interface MessageOptions { export interface MessageOptions {
media?: OutboundMedia[]
cliApps?: Array<{ name: string }> cliApps?: Array<{ name: string }>
mcpPresets?: Array<{ name: string }> mcpPresets?: Array<{ name: string }>
sessionMentions?: SessionMention[] sessionMentions?: SessionMention[]
@@ -623,18 +631,20 @@ export async function fetchHistory(
(role !== "user" && role !== "assistant") (role !== "user" && role !== "assistant")
|| message.kind === "reasoning" || message.kind === "reasoning"
|| typeof content !== "string" || typeof content !== "string"
|| !content.trim()
) { ) {
continue continue
} }
const media = Array.isArray(message.media) ? message.media.filter(isMediaAttachment) : []
if (role === "user") { if (role === "user") {
if (!content.trim() && !media.length) continue
userIndex += 1 userIndex += 1
messages.push({ messages.push({
role: "user", role: "user",
content, content,
...(media.length ? { media } : {}),
...(typeof message.turnId === "string" ? { turnId: message.turnId } : {}), ...(typeof message.turnId === "string" ? { turnId: message.turnId } : {}),
}) })
} else { } else if (content.trim()) {
messages.push({ role: "assistant", content, forkIndex: userIndex }) messages.push({ role: "assistant", content, forkIndex: userIndex })
} }
} }
@@ -1190,6 +1200,7 @@ export class NanobotClient {
webui: true, webui: true,
...(this.workspaceScope ? { workspace_scope: this.workspaceScope } : {}), ...(this.workspaceScope ? { workspace_scope: this.workspaceScope } : {}),
...(options.userShell ? { user_shell: true } : {}), ...(options.userShell ? { user_shell: true } : {}),
...(options.media?.length ? { media: options.media } : {}),
...(options.cliApps?.length ? { cli_apps: options.cliApps } : {}), ...(options.cliApps?.length ? { cli_apps: options.cliApps } : {}),
...(options.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}), ...(options.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
...(options.sessionMentions?.length ...(options.sessionMentions?.length
+156 -11
View File
@@ -3,14 +3,21 @@ import {
MarkdownRenderable, MarkdownRenderable,
RGBA, RGBA,
ScrollBoxRenderable, ScrollBoxRenderable,
StyledText,
SyntaxStyle, SyntaxStyle,
TextAttributes, TextAttributes,
TextRenderable, TextRenderable,
type CliRenderer, type CliRenderer,
type TextChunk,
type TreeSitterClient, type TreeSitterClient,
} from "@opentui/core" } from "@opentui/core"
import type { FileEditEvent, HistoryMessage, ToolProgressEvent } from "./protocol" import type {
FileEditEvent,
HistoryMessage,
MediaAttachment,
ToolProgressEvent,
} from "./protocol"
import { renderLatexAsUnicode } from "./latex" import { renderLatexAsUnicode } from "./latex"
import { hideScrollbars } from "./scrollbox" import { hideScrollbars } from "./scrollbox"
import { mergeToolEvent, renderToolEvent } from "./tool-renderers" import { mergeToolEvent, renderToolEvent } from "./tool-renderers"
@@ -58,6 +65,54 @@ const ACTIVITY_PREVIEW_LINES = 4
// subsequent deltas to the renderer cadence. // subsequent deltas to the renderer cadence.
const STREAM_FLUSH_MS = 32 const STREAM_FLUSH_MS = 32
export interface UserMessageMedia {
kind?: MediaAttachment["kind"]
name?: string
}
interface UserMessageProjection {
imageLabels: string[]
attachmentNames: string[]
}
function projectUserMessage(media: readonly UserMessageMedia[]): UserMessageProjection {
const imageNames: Array<string | undefined> = []
const attachmentNames: string[] = []
for (const item of media) {
// Outbound TUI media has no explicit kind because this path currently only
// sends clipboard images. Gateway and history media carry the kind.
if (item.kind === undefined || item.kind === "image") imageNames.push(item.name)
else if (item.name) attachmentNames.push(item.name)
}
const used = new Set<number>()
let next = 1
const imageLabels = imageNames.map((name) => {
const match = name?.match(/^clipboard-image-(\d+)\.[^.]+$/iu)
const preferred = match ? Number(match[1]) : 0
let index = Number.isSafeInteger(preferred) && preferred > 0 && !used.has(preferred)
? preferred
: next
while (used.has(index)) index += 1
used.add(index)
while (used.has(next)) next += 1
return `[Image #${index}]`
})
return { imageLabels, attachmentNames }
}
export function userMessageText(
content: string,
media: readonly UserMessageMedia[] = [],
displayContent?: string,
): string {
const { imageLabels, attachmentNames } = projectUserMessage(media)
return [
displayContent ?? [content, imageLabels.join(" ")].filter(Boolean).join(" "),
attachmentNames.length ? `Attachments: ${attachmentNames.join(", ")}` : "",
].filter(Boolean).join("\n")
}
/** Projects gateway events into retained, reflowable conversation cells. */ /** Projects gateway events into retained, reflowable conversation cells. */
export class Transcript { export class Transcript {
readonly root: ScrollBoxRenderable readonly root: ScrollBoxRenderable
@@ -71,6 +126,12 @@ export class Transcript {
private readonly activities = new Set<Activity>() private readonly activities = new Set<Activity>()
private readonly frames = new Set<BoxRenderable>() private readonly frames = new Set<BoxRenderable>()
private readonly userRows = new Set<BoxRenderable>() private readonly userRows = new Set<BoxRenderable>()
private readonly userMessages = new Set<{
renderable: TextRenderable
content: string
media: UserMessageMedia[]
displayContent?: string
}>()
private readonly userTurnIds = new Set<string>() private readonly userTurnIds = new Set<string>()
private wrote = false private wrote = false
private nextId = 0 private nextId = 0
@@ -84,7 +145,6 @@ export class Transcript {
private theme: TranscriptTheme, private theme: TranscriptTheme,
private readonly treeSitterClient: TreeSitterClient, private readonly treeSitterClient: TreeSitterClient,
private readonly onNavigationChange?: (state: TranscriptNavigation) => void, private readonly onNavigationChange?: (state: TranscriptNavigation) => void,
private readonly showHeader = true,
private readonly workspace = "", private readonly workspace = "",
) { ) {
this.root = new ScrollBoxRenderable(renderer, { this.root = new ScrollBoxRenderable(renderer, {
@@ -116,6 +176,13 @@ export class Transcript {
const previousSyntax = this.theme.syntax const previousSyntax = this.theme.syntax
this.theme = theme this.theme = theme
for (const { renderable, tone } of this.styledText) renderable.fg = theme[tone] for (const { renderable, tone } of this.styledText) renderable.fg = theme[tone]
for (const message of this.userMessages) {
message.renderable.content = this.userMessageContent(
message.content,
message.media,
message.displayContent,
)
}
for (const renderable of this.markdown) renderable.syntaxStyle = theme.syntax for (const renderable of this.markdown) renderable.syntaxStyle = theme.syntax
for (const frame of this.frames) frame.borderColor = theme.border for (const frame of this.frames) frame.borderColor = theme.border
for (const row of this.userRows) { for (const row of this.userRows) {
@@ -130,7 +197,6 @@ export class Transcript {
} }
header(options: TranscriptHeader): void { header(options: TranscriptHeader): void {
if (!this.showHeader) return
const row = new BoxRenderable(this.renderer, { const row = new BoxRenderable(this.renderer, {
id: this.id("header-row"), id: this.id("header-row"),
width: "100%", width: "100%",
@@ -171,6 +237,7 @@ export class Transcript {
this.activities.clear() this.activities.clear()
this.frames.clear() this.frames.clear()
this.userRows.clear() this.userRows.clear()
this.userMessages.clear()
this.userTurnIds.clear() this.userTurnIds.clear()
this.wrote = false this.wrote = false
this.nextId = 0 this.nextId = 0
@@ -182,7 +249,9 @@ export class Transcript {
history(messages: HistoryMessage[]): void { history(messages: HistoryMessage[]): void {
for (const message of messages) { for (const message of messages) {
if (message.role === "user") this.user(message.content, message.turnId) if (message.role === "user") {
this.user(message.content, message.turnId, message.media)
}
else if (message.role === "assistant") this.assistant(message.content) else if (message.role === "assistant") this.assistant(message.content)
else if (message.fileEdits?.length) this.fileEdits(message.fileEdits) else if (message.fileEdits?.length) this.fileEdits(message.fileEdits)
else this.progress(message.content, message.toolEvents) else this.progress(message.content, message.toolEvents)
@@ -194,11 +263,11 @@ export class Transcript {
if (messages.length === 0) return if (messages.length === 0) return
const previousTop = this.root.scrollTop const previousTop = this.root.scrollTop
const previousHeight = this.root.scrollHeight const previousHeight = this.root.scrollHeight
let index = this.showHeader ? 1 : 0 let index = 1 // Keep the launch header first.
for (const message of messages) { for (const message of messages) {
if (message.role === "user") { if (message.role === "user") {
if (message.turnId && this.userTurnIds.has(message.turnId)) continue if (message.turnId && this.userTurnIds.has(message.turnId)) continue
this.writeRole("", message.content, "user", index++) this.writeUser(message.content, message.media, index++)
if (message.turnId) this.userTurnIds.add(message.turnId) if (message.turnId) this.userTurnIds.add(message.turnId)
} else if (message.role === "assistant") { } else if (message.role === "assistant") {
this.writeMarkdown(message.content, false, index++) this.writeMarkdown(message.content, false, index++)
@@ -224,11 +293,16 @@ export class Transcript {
return this.root.scrollTop <= 0 return this.root.scrollTop <= 0
} }
user(content: string, turnId?: string): boolean { user(
content: string,
turnId?: string,
media: readonly UserMessageMedia[] = [],
displayContent?: string,
): boolean {
if (turnId && this.userTurnIds.has(turnId)) return false if (turnId && this.userTurnIds.has(turnId)) return false
this.noteOutput() this.noteOutput()
this.finishActivity() this.finishActivity()
this.writeRole("", content, "user") this.writeUser(content, media, undefined, displayContent)
if (turnId) this.userTurnIds.add(turnId) if (turnId) this.userTurnIds.add(turnId)
return true return true
} }
@@ -336,6 +410,7 @@ export class Transcript {
this.activity = null this.activity = null
this.frames.clear() this.frames.clear()
this.userRows.clear() this.userRows.clear()
this.userMessages.clear()
this.theme.syntax.destroy() this.theme.syntax.destroy()
} }
@@ -468,7 +543,7 @@ export class Transcript {
} }
private createText( private createText(
content: string, content: string | StyledText,
tone: "text" | "muted" | "error" | "user", tone: "text" | "muted" | "error" | "user",
bold = false, bold = false,
id = "text", id = "text",
@@ -487,10 +562,10 @@ export class Transcript {
private writeRole( private writeRole(
marker: string, marker: string,
content: string, content: string | StyledText,
tone: "muted" | "error" | "user", tone: "muted" | "error" | "user",
index?: number, index?: number,
): void { ): TextRenderable {
const row = this.createRow(tone === "user" ? "user" : "notice", "row") const row = this.createRow(tone === "user" ? "user" : "notice", "row")
if (tone === "user") { if (tone === "user") {
row.backgroundColor = this.theme.userBackground row.backgroundColor = this.theme.userBackground
@@ -509,6 +584,76 @@ export class Transcript {
row.add(text) row.add(text)
this.root.add(row, index) this.root.add(row, index)
this.wrote = true this.wrote = true
return text
}
private writeUser(
content: string,
media: readonly UserMessageMedia[] = [],
index?: number,
displayContent?: string,
): void {
const retainedMedia = [...media]
const renderable = this.writeRole(
"",
this.userMessageContent(content, retainedMedia, displayContent),
"user",
index,
)
this.userMessages.add({ renderable, content, media: retainedMedia, displayContent })
}
private userMessageContent(
content: string,
media: readonly UserMessageMedia[],
displayContent?: string,
): StyledText {
const { imageLabels, attachmentNames } = projectUserMessage(media)
const chunks: TextChunk[] = []
const append = (text: string) => {
if (text) chunks.push({ __isChunk: true, text })
}
const nextLine = () => {
if (chunks.length) append("\n")
}
if (displayContent !== undefined) {
const ranges = imageLabels
.map((label) => ({ label, start: displayContent.indexOf(label) }))
.filter(({ start }) => start >= 0)
.sort((left, right) => left.start - right.start)
let cursor = 0
for (const { label, start } of ranges) {
append(displayContent.slice(cursor, start))
chunks.push({
__isChunk: true,
text: label,
fg: RGBA.fromHex(this.theme.user),
attributes: TextAttributes.BOLD,
})
cursor = start + label.length
}
append(displayContent.slice(cursor))
} else {
append(content)
}
if (displayContent === undefined && imageLabels.length) {
if (chunks.length) append(" ")
for (const [index, label] of imageLabels.entries()) {
if (index > 0) append(" ")
chunks.push({
__isChunk: true,
text: label,
fg: RGBA.fromHex(this.theme.user),
attributes: TextAttributes.BOLD,
})
}
}
if (attachmentNames.length) {
nextLine()
append(`Attachments: ${attachmentNames.join(", ")}`)
}
return new StyledText(chunks)
} }
private createMarkdown(content: string, streaming: boolean, id = "markdown"): MarkdownRenderable { private createMarkdown(content: string, streaming: boolean, id = "markdown"): MarkdownRenderable {
+9 -2
View File
@@ -2154,9 +2154,16 @@ function Shell({
const hasAutomations = (pendingDelete.automations?.length ?? 0) > 0; const hasAutomations = (pendingDelete.automations?.length ?? 0) > 0;
const deletingActive = activeKey !== null && deletingKeys.has(activeKey); const deletingActive = activeKey !== null && deletingKeys.has(activeKey);
const currentIndex = topicSessions.findIndex((s) => s.key === activeKey); const currentIndex = topicSessions.findIndex((s) => s.key === activeKey);
const availableKeys = new Set(topicSessions.map((session) => session.key));
const siblingFallbackKey = deletingActive
? activeTabState?.paneKeys.find((key) => (
!deletingKeys.has(key) && availableKeys.has(key)
)) ?? null
: null;
const fallbackKey = deletingActive const fallbackKey = deletingActive
? ( ? (
topicSessions.slice(currentIndex + 1).find((session) => ( siblingFallbackKey
?? topicSessions.slice(currentIndex + 1).find((session) => (
!deletingKeys.has(session.key) !deletingKeys.has(session.key)
))?.key ))?.key
?? topicSessions.slice(0, Math.max(0, currentIndex)).reverse().find((session) => ( ?? topicSessions.slice(0, Math.max(0, currentIndex)).reverse().find((session) => (
@@ -2191,7 +2198,7 @@ function Shell({
} catch (e) { } catch (e) {
console.error("Failed to delete session", e); console.error("Failed to delete session", e);
} }
}, [pendingDelete, deleteChat, activeKey, navigate, topicSessions]); }, [pendingDelete, deleteChat, activeKey, activeTabState, navigate, topicSessions]);
const onRequestDeleteMany = useCallback(async (items: SidebarDeleteItem[]) => { const onRequestDeleteMany = useCallback(async (items: SidebarDeleteItem[]) => {
const uniqueItems = Array.from(new Map(items.map((item) => [item.key, item])).values()); const uniqueItems = Array.from(new Map(items.map((item) => [item.key, item])).values());
@@ -527,6 +527,8 @@ function MarketplaceSkillRow({
<div className="mt-1 flex min-w-0 items-center gap-1.5 truncate text-[12px] text-muted-foreground"> <div className="mt-1 flex min-w-0 items-center gap-1.5 truncate text-[12px] text-muted-foreground">
{skill.source} {skill.source}
{skill.version ? <span>· v{skill.version}</span> : null} {skill.version ? <span>· v{skill.version}</span> : null}
{skill.provider === "skills_sh" ? (
<>
<span>·</span> <span>·</span>
{skill.metric === "installs_24h" {skill.metric === "installs_24h"
? t("settings.skills.marketplaceInstalls24h", { ? t("settings.skills.marketplaceInstalls24h", {
@@ -539,6 +541,8 @@ function MarketplaceSkillRow({
formattedCount: skill.installs.toLocaleString(), formattedCount: skill.installs.toLocaleString(),
defaultValue: "{{formattedCount}} installs", defaultValue: "{{formattedCount}} installs",
})} })}
</>
) : null}
</div> </div>
</div> </div>
{skill.provider === "skills_sh" ? <TrendSparkline values={trend} /> : null} {skill.provider === "skills_sh" ? <TrendSparkline values={trend} /> : null}
@@ -546,7 +546,7 @@ export function ModelsSettings({
> >
{saving || creatingSaving {saving || creatingSaving
? tx("settings.actions.saving", "Saving...") ? tx("settings.actions.saving", "Saving...")
: tx("settings.actions.savePreset", "Save preset")} : tx("settings.actions.savePreset", "Save")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -204,13 +204,15 @@ export function ModelIdPicker({
const providerConfigured = settingsProviderConfigured(settings, effectiveProvider); const providerConfigured = settingsProviderConfigured(settings, effectiveProvider);
const providerRequiresConfiguration = const providerRequiresConfiguration =
!hasStaticModels && hasConcreteProvider && !providerConfigured; !hasStaticModels && hasConcreteProvider && !providerConfigured;
const providerHasBuiltinModels = providerRow?.model_catalog === "builtin"; const providerHasManagedModels = ["builtin", "hybrid"].includes(
providerRow?.model_catalog ?? "",
);
const providerUsesManualModelIds = const providerUsesManualModelIds =
!hasStaticModels && !hasStaticModels &&
hasConcreteProvider && hasConcreteProvider &&
providerConfigured && providerConfigured &&
providerRow?.auth_type === "oauth" && providerRow?.auth_type === "oauth" &&
!providerHasBuiltinModels; !providerHasManagedModels;
const canFetchModels = const canFetchModels =
!hasStaticModels && !hasStaticModels &&
hasConcreteProvider && providerConfigured && !providerUsesManualModelIds; hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
@@ -72,9 +72,10 @@ function normalizeTab(value: unknown): WorkbenchTabState {
...requestedLayoutPaneKeys, ...requestedLayoutPaneKeys,
...paneKeys.filter((key) => !requestedLayoutPaneKeys.includes(key)), ...paneKeys.filter((key) => !requestedLayoutPaneKeys.includes(key)),
]; ];
const title = normalizeTitle(candidate.title);
return { return {
explicit: candidate.explicit === true, explicit: candidate.explicit === true || title !== null,
title: normalizeTitle(candidate.title), title,
paneKeys, paneKeys,
layoutPaneKeys, layoutPaneKeys,
layout: isLayout(candidate.layout) ? candidate.layout : "columns", layout: isLayout(candidate.layout) ? candidate.layout : "columns",
@@ -309,7 +310,9 @@ export function renameWorkbenchTab(
const normalized = normalizeTitle(title); const normalized = normalizeTitle(title);
if (!normalized) return state; if (!normalized) return state;
return updateTab(state, tabKey, (tab) => ( return updateTab(state, tabKey, (tab) => (
tab.title === normalized ? tab : { ...tab, title: normalized } tab.title === normalized && tab.explicit
? tab
: { ...tab, explicit: true, title: normalized }
)); ));
} }
+1 -1
View File
@@ -483,7 +483,7 @@
"save": "Save", "save": "Save",
"saving": "Saving", "saving": "Saving",
"saveOrder": "Save order", "saveOrder": "Save order",
"savePreset": "Save preset", "savePreset": "Save",
"edit": "Edit", "edit": "Edit",
"delete": "Delete", "delete": "Delete",
"deleting": "Deleting...", "deleting": "Deleting...",
+1 -1
View File
@@ -271,7 +271,7 @@
"save": "Guardar", "save": "Guardar",
"saving": "Guardando", "saving": "Guardando",
"saveOrder": "Guardar orden", "saveOrder": "Guardar orden",
"savePreset": "Guardar preajuste", "savePreset": "Guardar",
"delete": "Eliminar", "delete": "Eliminar",
"deleting": "Eliminando...", "deleting": "Eliminando...",
"edit": "Editar", "edit": "Editar",
+1 -1
View File
@@ -271,7 +271,7 @@
"save": "Enregistrer", "save": "Enregistrer",
"saving": "Enregistrement", "saving": "Enregistrement",
"saveOrder": "Enregistrer lordre", "saveOrder": "Enregistrer lordre",
"savePreset": "Enregistrer le préréglage", "savePreset": "Enregistrer",
"delete": "Supprimer", "delete": "Supprimer",
"deleting": "Suppression...", "deleting": "Suppression...",
"edit": "Modifier", "edit": "Modifier",
+1 -1
View File
@@ -271,7 +271,7 @@
"save": "Simpan", "save": "Simpan",
"saving": "Menyimpan", "saving": "Menyimpan",
"saveOrder": "Simpan urutan", "saveOrder": "Simpan urutan",
"savePreset": "Simpan prasetel", "savePreset": "Simpan",
"delete": "Hapus", "delete": "Hapus",
"deleting": "Menghapus...", "deleting": "Menghapus...",
"edit": "Ubah", "edit": "Ubah",
+1 -1
View File
@@ -271,7 +271,7 @@
"save": "保存", "save": "保存",
"saving": "保存中", "saving": "保存中",
"saveOrder": "順序を保存", "saveOrder": "順序を保存",
"savePreset": "プリセットを保存", "savePreset": "保存",
"delete": "削除", "delete": "削除",
"deleting": "削除中...", "deleting": "削除中...",
"edit": "編集", "edit": "編集",
+1 -1
View File
@@ -271,7 +271,7 @@
"save": "저장", "save": "저장",
"saving": "저장 중", "saving": "저장 중",
"saveOrder": "순서 저장", "saveOrder": "순서 저장",
"savePreset": "프리셋 저장", "savePreset": "저장",
"delete": "삭제", "delete": "삭제",
"deleting": "삭제 중...", "deleting": "삭제 중...",
"edit": "편집", "edit": "편집",
+1 -1
View File
@@ -483,7 +483,7 @@
"save": "Salvar", "save": "Salvar",
"saving": "Salvando", "saving": "Salvando",
"saveOrder": "Salvar ordem", "saveOrder": "Salvar ordem",
"savePreset": "Salvar predefinição", "savePreset": "Salvar",
"delete": "Excluir", "delete": "Excluir",
"deleting": "Excluindo...", "deleting": "Excluindo...",
"edit": "Editar", "edit": "Editar",
+1 -1
View File
@@ -271,7 +271,7 @@
"save": "Lưu", "save": "Lưu",
"saving": "Đang lưu", "saving": "Đang lưu",
"saveOrder": "Lưu thứ tự", "saveOrder": "Lưu thứ tự",
"savePreset": "Lưu cấu hình đặt trước", "savePreset": "Lưu",
"delete": "Xóa", "delete": "Xóa",
"deleting": "Đang xóa...", "deleting": "Đang xóa...",
"edit": "Sửa", "edit": "Sửa",
+1 -1
View File
@@ -483,7 +483,7 @@
"save": "保存", "save": "保存",
"saving": "正在保存", "saving": "正在保存",
"saveOrder": "保存顺序", "saveOrder": "保存顺序",
"savePreset": "保存预设", "savePreset": "保存",
"edit": "编辑", "edit": "编辑",
"delete": "删除", "delete": "删除",
"deleting": "正在删除...", "deleting": "正在删除...",
+1 -1
View File
@@ -271,7 +271,7 @@
"save": "儲存", "save": "儲存",
"saving": "正在儲存", "saving": "正在儲存",
"saveOrder": "儲存順序", "saveOrder": "儲存順序",
"savePreset": "儲存預設", "savePreset": "儲存",
"delete": "刪除", "delete": "刪除",
"deleting": "正在刪除…", "deleting": "正在刪除…",
"edit": "編輯", "edit": "編輯",
+11 -1
View File
@@ -510,6 +510,8 @@ interface ProviderModelInfo {
description?: string | null; description?: string | null;
owned_by?: string | null; owned_by?: string | null;
context_window?: number | null; context_window?: number | null;
reasoning_efforts?: string[];
supports_backend_search?: boolean;
} }
export interface ProviderModelsPayload { export interface ProviderModelsPayload {
@@ -521,7 +523,15 @@ export interface ProviderModelsPayload {
| "not_configured" | "not_configured"
| "missing_api_base" | "missing_api_base"
| "error"; | "error";
catalog_kind: "builtin" | "official" | "catalog" | "local" | "custom" | "unsupported"; catalog_kind:
| "builtin"
| "hybrid"
| "official"
| "catalog"
| "local"
| "custom"
| "unsupported";
source?: "remote" | "cache" | "stale" | "fallback";
models: ProviderModelInfo[]; models: ProviderModelInfo[];
model_count: number; model_count: number;
message?: string | null; message?: string | null;
+103 -1
View File
@@ -1201,6 +1201,7 @@ describe("App layout", () => {
expect(screen.getAllByText("SkillHub")).toHaveLength(2); expect(screen.getAllByText("SkillHub")).toHaveLength(2);
expect(screen.getAllByText("skills.sh")).toHaveLength(2); expect(screen.getAllByText("skills.sh")).toHaveLength(2);
expect(screen.getByText(/14,481 installs \/ 24h/)).toBeInTheDocument(); expect(screen.getByText(/14,481 installs \/ 24h/)).toBeInTheDocument();
expect(screen.queryByText(/11,831 installs/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "SkillHub" })); fireEvent.click(screen.getByRole("tab", { name: "SkillHub" }));
expect(screen.getByText("ima-skills")).toBeInTheDocument(); expect(screen.getByText("ima-skills")).toBeInTheDocument();
expect(screen.queryByText("find-skills")).not.toBeInTheDocument(); expect(screen.queryByText("find-skills")).not.toBeInTheDocument();
@@ -2530,7 +2531,7 @@ describe("App layout", () => {
).toBe(true); ).toBe(true);
await user.click(screen.getByRole("button", { name: "Select model" })); await user.click(screen.getByRole("button", { name: "Select model" }));
await user.click(await screen.findByRole("option", { name: /openai\/gpt-4o-mini/ })); await user.click(await screen.findByRole("option", { name: /openai\/gpt-4o-mini/ }));
expect(screen.getByRole("button", { name: "Save preset" })).toBeEnabled(); expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: "Cancel" })); fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByText("Up to date.")).not.toBeInTheDocument(); expect(screen.queryByText("Up to date.")).not.toBeInTheDocument();
fireEvent.click( fireEvent.click(
@@ -3414,6 +3415,23 @@ describe("App layout", () => {
expect(restoredGrid).toHaveAttribute("data-layout", "rows"); expect(restoredGrid).toHaveAttribute("data-layout", "rows");
}); });
const alphaGroupButton = within(sidebar).getByRole("button", {
name: "Group: Alpha",
});
const alphaGroup = alphaGroupButton.closest("[data-sidebar-tab-group]") as HTMLElement;
fireEvent.pointerDown(within(alphaGroup).getByLabelText("Topic actions for Alpha"), {
button: 0,
ctrlKey: false,
});
fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" }));
const renameDialog = await screen.findByRole("dialog", { name: "Rename group" });
fireEvent.change(within(renameDialog).getByPlaceholderText("Group name"), {
target: { value: "Research" },
});
fireEvent.click(within(renameDialog).getByRole("button", { name: "Save" }));
expect(await within(sidebar).findByRole("button", { name: "Group: Research" }))
.toBeInTheDocument();
fireEvent.pointerDown(within(sidebar).getByRole("button", { fireEvent.pointerDown(within(sidebar).getByRole("button", {
name: "New topic pane actions", name: "New topic pane actions",
}), { button: 0, ctrlKey: false }); }), { button: 0, ctrlKey: false });
@@ -3421,9 +3439,93 @@ describe("App layout", () => {
name: "Remove", name: "Remove",
})); }));
await waitFor(() => expect(screen.getByTestId("pane-grid").children).toHaveLength(1)); await waitFor(() => expect(screen.getByTestId("pane-grid").children).toHaveLength(1));
const researchGroup = within(sidebar).getByRole("button", {
name: "Group: Research",
}).closest("[data-sidebar-tab-group]") as HTMLElement;
expect(within(researchGroup).getByRole("list", { name: "Panes in Research" }))
.toBeInTheDocument();
expect(within(researchGroup).getByRole("button", { name: "Alpha" }))
.toBeInTheDocument();
expect(within(sidebar).getAllByRole("button", { name: "New topic" })).toHaveLength(2); expect(within(sidebar).getAllByRole("button", { name: "New topic" })).toHaveLength(2);
}); });
it("keeps a named group and its remaining pane active after deleting a pane", async () => {
mockSessions = [
{
key: "websocket:new-pane",
channel: "websocket",
chatId: "new-pane",
createdAt: "2026-08-05T12:00:00Z",
updatedAt: "2026-08-05T12:00:00Z",
title: "New topic",
preview: "",
},
{
key: "websocket:unrelated",
channel: "websocket",
chatId: "unrelated",
createdAt: "2026-08-05T11:00:00Z",
updatedAt: "2026-08-05T11:00:00Z",
title: "Unrelated",
preview: "",
},
{
key: "websocket:alpha",
channel: "websocket",
chatId: "alpha",
createdAt: "2026-08-05T10:00:00Z",
updatedAt: "2026-08-05T10:00:00Z",
title: "Alpha",
preview: "",
},
];
window.history.replaceState(null, "", "/#/chat/websocket%3Anew-pane");
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string | URL | Request) => {
if (String(url) === "/api/webui/sidebar-state") {
return {
ok: true,
json: async () => ({
workbench: {
version: 1,
tabs: {
"tab:websocket:alpha": {
explicit: false,
title: "Research",
paneKeys: ["websocket:alpha", "websocket:new-pane"],
layoutPaneKeys: ["websocket:alpha", "websocket:new-pane"],
layout: "columns",
splitRatios: [],
},
},
},
}),
};
}
return { ok: false, status: 404 };
}));
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
expect(await within(sidebar).findByRole("button", { name: "Group: Research" }))
.toBeInTheDocument();
fireEvent.pointerDown(within(sidebar).getByRole("button", {
name: "New topic pane actions",
}), { button: 0, ctrlKey: false });
fireEvent.click(await screen.findByRole("menuitem", { name: "Delete" }));
expect(await screen.findByText("Delete this topic?")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(deleteChatSpy).toHaveBeenCalledWith("websocket:new-pane"));
await waitFor(() => expect(window.location.hash).toBe("#/chat/websocket%3Aalpha"));
expect(within(sidebar).getByRole("button", { name: "Group: Research" }))
.toBeInTheDocument();
expect(screen.getByTestId("pane-grid").children).toHaveLength(1);
expect(screen.getByTestId("pane-grid").firstElementChild)
.toHaveAttribute("aria-label", "Alpha");
}, 15_000);
it("opens search from the keyboard shortcut", async () => { it("opens search from the keyboard shortcut", async () => {
mockSessions = [ mockSessions = [
{ {
+90 -8
View File
@@ -141,7 +141,7 @@ describe("Settings models", () => {
fireEvent.change(screen.getByLabelText("Temperature"), { fireEvent.change(screen.getByLabelText("Temperature"), {
target: { value: "0.4" }, target: { value: "0.4" },
}); });
fireEvent.click(screen.getByRole("button", { name: "Save preset" })); fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => { await waitFor(() => {
expect(requestMutationMock).toHaveBeenCalledWith( expect(requestMutationMock).toHaveBeenCalledWith(
@@ -173,7 +173,7 @@ describe("Settings models", () => {
const nameInput = screen.getByRole("textbox", { name: "Preset name" }); const nameInput = screen.getByRole("textbox", { name: "Preset name" });
fireEvent.change(nameInput, { target: { value: "Codex" } }); fireEvent.change(nameInput, { target: { value: "Codex" } });
fireEvent.click(screen.getByRole("button", { name: "Save preset" })); fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => { await waitFor(() => {
expect(requestMutationMock).toHaveBeenCalledWith( expect(requestMutationMock).toHaveBeenCalledWith(
@@ -196,7 +196,7 @@ describe("Settings models", () => {
const nameInput = screen.getByRole("textbox", { name: "Preset name" }); const nameInput = screen.getByRole("textbox", { name: "Preset name" });
fireEvent.change(nameInput, { target: { value: "Codex" } }); fireEvent.change(nameInput, { target: { value: "Codex" } });
fireEvent.click(screen.getByRole("button", { name: "Save preset" })); fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("alert")).toHaveTextContent( expect(await screen.findByRole("alert")).toHaveTextContent(
"A preset with this name already exists.", "A preset with this name already exists.",
@@ -368,7 +368,7 @@ describe("Settings models", () => {
expect(screen.queryByRole("button", { name: "Save order" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Save order" })).not.toBeInTheDocument();
expect(screen.getByLabelText("Temperature")).toHaveValue(0.4); expect(screen.getByLabelText("Temperature")).toHaveValue(0.4);
expect(screen.getByRole("button", { name: "Save preset" })).toBeEnabled(); expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
}); });
it("keeps repeated fallback preset rows stable when changing the primary preset", async () => { it("keeps repeated fallback preset rows stable when changing the primary preset", async () => {
@@ -604,7 +604,7 @@ describe("Settings models", () => {
); );
fireEvent.click(screen.getByRole("button", { name: "New model preset" })); fireEvent.click(screen.getByRole("button", { name: "New model preset" }));
expect(screen.queryByRole("dialog", { name: "New model preset" })).not.toBeInTheDocument(); expect(screen.queryByRole("dialog", { name: "New model preset" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save preset" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
expect( expect(
screen.queryByText("Complete the preset before saving."), screen.queryByText("Complete the preset before saving."),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
@@ -619,7 +619,7 @@ describe("Settings models", () => {
target: { value: "openai/gpt-4o-mini" }, target: { value: "openai/gpt-4o-mini" },
}); });
fireEvent.keyDown(modelSearch, { key: "Enter" }); fireEvent.keyDown(modelSearch, { key: "Enter" });
const saveButton = screen.getByRole("button", { name: "Save preset" }); const saveButton = screen.getByRole("button", { name: "Save" });
expect(saveButton).toBeEnabled(); expect(saveButton).toBeEnabled();
fireEvent.click(saveButton); fireEvent.click(saveButton);
@@ -656,7 +656,7 @@ describe("Settings models", () => {
}); });
fireEvent.change(modelSearch, { target: { value: "openai/gpt-4o-mini" } }); fireEvent.change(modelSearch, { target: { value: "openai/gpt-4o-mini" } });
fireEvent.keyDown(modelSearch, { key: "Enter" }); fireEvent.keyDown(modelSearch, { key: "Enter" });
fireEvent.click(screen.getByRole("button", { name: "Save preset" })); fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(requestMutationMock).not.toHaveBeenCalled(); expect(requestMutationMock).not.toHaveBeenCalled();
expect(nameInput).toHaveAttribute("aria-invalid", "true"); expect(nameInput).toHaveAttribute("aria-invalid", "true");
@@ -1295,6 +1295,88 @@ describe("Settings models", () => {
); );
}); });
it("loads hybrid online models for configured OAuth providers", async () => {
const base = settingsPayload();
const payload: SettingsPayload = {
...base,
agent: {
...base.agent,
model: "xai-grok/grok-4.5",
provider: "xai_grok",
resolved_provider: "xai_grok",
},
model_presets: [
{
...base.model_presets[0],
model: "xai-grok/grok-4.5",
provider: "xai_grok",
},
],
providers: [
{
name: "xai_grok",
label: "xAI Grok",
configured: true,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://cli-chat-proxy.grok.com/v1",
model_catalog: "hybrid",
oauth_account: "acct-test",
oauth_expires_at: null,
oauth_login_supported: true,
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings/provider-models?provider=xai_grok") {
return jsonResponse({
provider: "xai_grok",
label: "xAI Grok",
status: "available",
catalog_kind: "hybrid",
source: "remote",
models: [
{
id: "xai-grok/grok-4.6",
label: "Grok 4.6",
description: "Latest frontier model",
owned_by: "xAI",
context_window: 500_000,
},
{
id: "xai-grok/grok-4.5",
label: "Grok 4.5",
owned_by: "xAI",
context_window: 500_000,
},
],
model_count: 2,
fetched_at: 1,
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
await togglePresetEditor();
const modelButtons = await screen.findAllByRole("button", {
name: /xai-grok\/grok-4\.5/i,
});
await openPopover(modelButtons[modelButtons.length - 1]);
expect(await screen.findByText("Grok 4.6")).toBeInTheDocument();
expect(screen.getByText(/Latest frontier model/)).toBeInTheDocument();
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/provider-models?provider=xai_grok",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
);
});
it("creates presets in the inline editor and can cancel without opening a dialog", async () => { it("creates presets in the inline editor and can cancel without opening a dialog", async () => {
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
@@ -1417,7 +1499,7 @@ describe("Settings models", () => {
fireEvent.change(screen.getByLabelText("Reasoning effort"), { fireEvent.change(screen.getByLabelText("Reasoning effort"), {
target: { value: "provider-native-mode" }, target: { value: "provider-native-mode" },
}); });
fireEvent.click(screen.getByRole("button", { name: "Save preset" })); fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
+20 -2
View File
@@ -53,7 +53,7 @@ describe("workbench model", () => {
state = renameWorkbenchTab(state, tabKey, "Research"); state = renameWorkbenchTab(state, tabKey, "Research");
expect(workbenchTab(state, tabKey)).toEqual({ expect(workbenchTab(state, tabKey)).toEqual({
explicit: false, explicit: true,
title: "Research", title: "Research",
paneKeys: ["pane-a", "pane-b"], paneKeys: ["pane-a", "pane-b"],
layoutPaneKeys: ["pane-a", "pane-b"], layoutPaneKeys: ["pane-a", "pane-b"],
@@ -62,6 +62,24 @@ describe("workbench model", () => {
}); });
}); });
it("preserves a named group when a pane is detached", () => {
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
state = renameWorkbenchTab(state, tabKey, "Research");
state = detachWorkbenchPane(state, tabKey, "pane-b");
expect(workbenchTab(state, tabKey)).toEqual({
explicit: true,
title: "Research",
paneKeys: ["pane-a"],
layoutPaneKeys: ["pane-a"],
layout: "columns",
splitRatios: [],
});
expect(normalizeWorkbenchState(state)).toEqual(state);
expect(reconcileWorkbench(state, new Set(["pane-a"]))).toEqual(state);
});
it("detaches a pane without persisting its standalone projection", () => { it("detaches a pane without persisting its standalone projection", () => {
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b"); let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
const tabKey = workbenchTabForPane(state, "pane-a").tabKey; const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
@@ -198,7 +216,7 @@ describe("workbench model", () => {
); );
expect(workbenchTab(reconciled, "alpha")).toEqual({ expect(workbenchTab(reconciled, "alpha")).toEqual({
explicit: false, explicit: true,
title: "Alpha", title: "Alpha",
paneKeys: ["pane-a", "pane-b"], paneKeys: ["pane-a", "pane-b"],
layoutPaneKeys: ["pane-a", "pane-b"], layoutPaneKeys: ["pane-a", "pane-b"],