Merge pull request #47 from bnema/feat/fast-mode-command
feat(agent): add fast mode toggle
14 files · +144 -33
M README.md +3 -2
| @@ -278,6 +278,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||
| 278 | 278 | | `/logout [provider]` | Clear credentials for any logged-in provider, or all when omitted. `/logout openai-codex` clears ChatGPT/Codex subscription auth while preserving a public OpenAI API key; `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zut again. | | |
| 279 | 279 | | `/model` | Pick a model from a list (or `/model <id>` to set directly). | | |
| 280 | 280 | | `/reasoning` | Set the reasoning level for subsequent model calls. | | |
| 281 | + | | `/fast` | Toggle fast mode for subsequent model calls. | | |
| 281 | 282 | | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. | | |
| 282 | 283 | | `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. | | |
| 283 | 284 | | `/fork` | Pick a previous user message and fork the current session after that turn. The selected turn becomes branch context and no provider turn starts during the checkout. | | |
| @@ -292,7 +293,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||
| 292 | 293 | | `/unjail` | Allow tools to touch paths outside again. | | |
| 293 | 294 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | |
| 294 | 295 | | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. | | |
| 295 | - | | `/settings` | Change persistent settings, including inline images, terminal alerts, AI terminal titles, auto-subagents, Ponytail coding mode, fast mode, main/sub-agent LSP access, and the auto-compact threshold. Saved to `$ZUT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. | | |
| 296 | + | | `/settings` | Change persistent settings, including inline images, terminal alerts, AI terminal titles, auto-subagents, Ponytail coding mode, fast mode, main/sub-agent LSP access, and the auto-compact threshold. `/fast` is a shortcut for the fast-mode toggle. Saved to `$ZUT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. | | |
| 296 | 297 | | `/clear` | Clear the chat transcript. | | |
| 297 | 298 | | `/exit` | Exit zut. | | |
| 298 | 299 | ||
| @@ -400,7 +401,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||
| 400 | 401 | - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zut: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`. | |
| 401 | 402 | - **auto-subagents** — let the main agent spawn background sub-agents in parallel via `subagent_spawn` and query live state via `subagent_status`. Off by default. The tools accept named profiles, reasoning, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion updates still arrive through the auto-subagents watcher. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details. | |
| 402 | 403 | - **Ponytail coding mode** — include compact engineering guidance in each resolved system prompt; the guidance tells the model to apply it to coding, debugging, and review work rather than ordinary conversation. It favors understanding the real flow, small validated changes, reuse, and preserving safety checks. Enabled by default; changes apply to the next model call and persist as `ponytail_enabled` in `$ZUT_HOME/config.json`. It is included by interactive, print, stream, JSON, RPC, subagent, bot, SDK, and Zutfile agents. | |
| 403 | - | - **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account. | |
| 404 | + | - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account. | |
| 404 | 405 | - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`. | |
| 405 | 406 | - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts. | |
| 406 | 407 | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. After a successful threshold compaction, zut automatically resumes an unfinished most-recent intent; a prompt queued during compaction takes priority. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. | |
| @@ -278,6 +278,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||||
| 278 | | `/logout [provider]` | Clear credentials for any logged-in provider, or all when omitted. `/logout openai-codex` clears ChatGPT/Codex subscription auth while preserving a public OpenAI API key; `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zut again. | | 278 | | `/logout [provider]` | Clear credentials for any logged-in provider, or all when omitted. `/logout openai-codex` clears ChatGPT/Codex subscription auth while preserving a public OpenAI API key; `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zut again. | | ||
| 279 | | `/model` | Pick a model from a list (or `/model <id>` to set directly). | | 279 | | `/model` | Pick a model from a list (or `/model <id>` to set directly). | | ||
| 280 | | `/reasoning` | Set the reasoning level for subsequent model calls. | | 280 | | `/reasoning` | Set the reasoning level for subsequent model calls. | | ||
| 281 | + | | `/fast` | Toggle fast mode for subsequent model calls. | | |||
| 281 | | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. | | 282 | | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. | | ||
| 282 | | `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. | | 283 | | `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. | | ||
| 283 | | `/fork` | Pick a previous user message and fork the current session after that turn. The selected turn becomes branch context and no provider turn starts during the checkout. | | 284 | | `/fork` | Pick a previous user message and fork the current session after that turn. The selected turn becomes branch context and no provider turn starts during the checkout. | | ||
| @@ -292,7 +293,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||||
| 292 | | `/unjail` | Allow tools to touch paths outside again. | | 293 | | `/unjail` | Allow tools to touch paths outside again. | | ||
| 293 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | 294 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | ||
| 294 | | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. | | 295 | | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. | | ||
| 295 | - | | `/settings` | Change persistent settings, including inline images, terminal alerts, AI terminal titles, auto-subagents, Ponytail coding mode, fast mode, main/sub-agent LSP access, and the auto-compact threshold. Saved to `$ZUT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. | | 296 | + | | `/settings` | Change persistent settings, including inline images, terminal alerts, AI terminal titles, auto-subagents, Ponytail coding mode, fast mode, main/sub-agent LSP access, and the auto-compact threshold. `/fast` is a shortcut for the fast-mode toggle. Saved to `$ZUT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. | |
| 296 | | `/clear` | Clear the chat transcript. | | 297 | | `/clear` | Clear the chat transcript. | | ||
| 297 | | `/exit` | Exit zut. | | 298 | | `/exit` | Exit zut. | | ||
| 298 | 299 | ||||
| @@ -400,7 +401,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||||
| 400 | - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zut: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`. | 401 | - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zut: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`. | ||
| 401 | - **auto-subagents** — let the main agent spawn background sub-agents in parallel via `subagent_spawn` and query live state via `subagent_status`. Off by default. The tools accept named profiles, reasoning, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion updates still arrive through the auto-subagents watcher. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details. | 402 | - **auto-subagents** — let the main agent spawn background sub-agents in parallel via `subagent_spawn` and query live state via `subagent_status`. Off by default. The tools accept named profiles, reasoning, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion updates still arrive through the auto-subagents watcher. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details. | ||
| 402 | - **Ponytail coding mode** — include compact engineering guidance in each resolved system prompt; the guidance tells the model to apply it to coding, debugging, and review work rather than ordinary conversation. It favors understanding the real flow, small validated changes, reuse, and preserving safety checks. Enabled by default; changes apply to the next model call and persist as `ponytail_enabled` in `$ZUT_HOME/config.json`. It is included by interactive, print, stream, JSON, RPC, subagent, bot, SDK, and Zutfile agents. | 403 | - **Ponytail coding mode** — include compact engineering guidance in each resolved system prompt; the guidance tells the model to apply it to coding, debugging, and review work rather than ordinary conversation. It favors understanding the real flow, small validated changes, reuse, and preserving safety checks. Enabled by default; changes apply to the next model call and persist as `ponytail_enabled` in `$ZUT_HOME/config.json`. It is included by interactive, print, stream, JSON, RPC, subagent, bot, SDK, and Zutfile agents. | ||
| 403 | - | - **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account. | 404 | + | - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account. |
| 404 | - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`. | 405 | - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`. | ||
| 405 | - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts. | 406 | - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts. | ||
| 406 | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. After a successful threshold compaction, zut automatically resumes an unfinished most-recent intent; a prompt queued during compaction takes priority. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. | 407 | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. After a successful threshold compaction, zut automatically resumes an unfinished most-recent intent; a prompt queued during compaction takes priority. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. | ||
M docs/providers.md +12 -10
| @@ -115,16 +115,6 @@ show instructions and should be configured with environment variables. | |||
| 115 | 115 | | Cloudflare AI Gateway | `CLOUDFLARE_API_KEY` | `cloudflare-ai-gateway` | | |
| 116 | 116 | | Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` | | |
| 117 | 117 | ||
| 118 | - | ### OpenAI Fast mode | |
| 119 | - | ||
| 120 | - | OpenAI, OpenAI Responses, and OpenAI Codex requests can opt into OpenAI's | |
| 121 | - | Fast service tier from `/settings` by enabling **fast mode**. The setting is | |
| 122 | - | off by default and is stored as `fast_mode` in `$ZUT_HOME/config.json`. | |
| 123 | - | ||
| 124 | - | Fast mode is currently limited to those OpenAI provider IDs. Enabling it while | |
| 125 | - | using another provider returns an error instead of silently changing that | |
| 126 | - | provider's request. Subagent children inherit the setting from their parent. | |
| 127 | - | ||
| 128 | 118 | Example: | |
| 129 | 119 | ||
| 130 | 120 | ```bash | |
| @@ -132,6 +122,18 @@ export OPENROUTER_API_KEY=... | |||
| 132 | 122 | zut --provider openrouter | |
| 133 | 123 | ``` | |
| 134 | 124 | ||
| 125 | + | ## Fast mode | |
| 126 | + | ||
| 127 | + | Use `/fast` or `/settings` to enable **fast mode** for subsequent model calls. | |
| 128 | + | The setting is off by default and is stored as `fast_mode` in | |
| 129 | + | `$ZUT_HOME/config.json`. | |
| 130 | + | ||
| 131 | + | Fast mode currently requests OpenAI's Fast service tier for OpenAI, OpenAI | |
| 132 | + | Responses, and OpenAI Codex requests. Enabling it while using another provider | |
| 133 | + | currently returns an error instead of silently changing that provider's request. | |
| 134 | + | Subagent children inherit the setting from their parent unless their profile | |
| 135 | + | sets `fastMode` explicitly. | |
| 136 | + | ||
| 135 | 137 | ## Local llama.cpp router | |
| 136 | 138 | ||
| 137 | 139 | The `llama.cpp` provider connects to a multi-model router and is separate from the `ollama` provider. Ollama normally uses `http://localhost:11434`; entering that URL for llama.cpp management produces a 404 because Ollama does not implement the router endpoints. | |
| @@ -115,16 +115,6 @@ show instructions and should be configured with environment variables. | |||||
| 115 | | Cloudflare AI Gateway | `CLOUDFLARE_API_KEY` | `cloudflare-ai-gateway` | | 115 | | Cloudflare AI Gateway | `CLOUDFLARE_API_KEY` | `cloudflare-ai-gateway` | | ||
| 116 | | Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` | | 116 | | Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` | | ||
| 117 | 117 | ||||
| 118 | - | ### OpenAI Fast mode | |||
| 119 | - | ||||
| 120 | - | OpenAI, OpenAI Responses, and OpenAI Codex requests can opt into OpenAI's | |||
| 121 | - | Fast service tier from `/settings` by enabling **fast mode**. The setting is | |||
| 122 | - | off by default and is stored as `fast_mode` in `$ZUT_HOME/config.json`. | |||
| 123 | - | ||||
| 124 | - | Fast mode is currently limited to those OpenAI provider IDs. Enabling it while | |||
| 125 | - | using another provider returns an error instead of silently changing that | |||
| 126 | - | provider's request. Subagent children inherit the setting from their parent. | |||
| 127 | - | ||||
| 128 | Example: | 118 | Example: | ||
| 129 | 119 | ||||
| 130 | ```bash | 120 | ```bash | ||
| @@ -132,6 +122,18 @@ export OPENROUTER_API_KEY=... | |||||
| 132 | zut --provider openrouter | 122 | zut --provider openrouter | ||
| 133 | ``` | 123 | ``` | ||
| 134 | 124 | ||||
| 125 | + | ## Fast mode | |||
| 126 | + | ||||
| 127 | + | Use `/fast` or `/settings` to enable **fast mode** for subsequent model calls. | |||
| 128 | + | The setting is off by default and is stored as `fast_mode` in | |||
| 129 | + | `$ZUT_HOME/config.json`. | |||
| 130 | + | ||||
| 131 | + | Fast mode currently requests OpenAI's Fast service tier for OpenAI, OpenAI | |||
| 132 | + | Responses, and OpenAI Codex requests. Enabling it while using another provider | |||
| 133 | + | currently returns an error instead of silently changing that provider's request. | |||
| 134 | + | Subagent children inherit the setting from their parent unless their profile | |||
| 135 | + | sets `fastMode` explicitly. | |||
| 136 | + | ||||
| 135 | ## Local llama.cpp router | 137 | ## Local llama.cpp router | ||
| 136 | 138 | ||||
| 137 | The `llama.cpp` provider connects to a multi-model router and is separate from the `ollama` provider. Ollama normally uses `http://localhost:11434`; entering that URL for llama.cpp management produces a 404 because Ollama does not implement the router endpoints. | 139 | The `llama.cpp` provider connects to a multi-model router and is separate from the `ollama` provider. Ollama normally uses `http://localhost:11434`; entering that URL for llama.cpp management produces a 404 because Ollama does not implement the router endpoints. | ||
M docs/subagents.md +2 -2
| @@ -48,7 +48,7 @@ Supported metadata: | |||
| 48 | 48 | | `systemPromptMode` | `append` (default) or `replace`. | | |
| 49 | 49 | | `inheritProjectContext` | Set to `false` to omit `AGENTS.md` context from the child. | | |
| 50 | 50 | | `inheritSkills` | Set to `false` to omit skill discovery and the conditional `skill` loader from the child; `--no-skill` has the same effect for a run. | | |
| 51 | - | | `fastMode` | Optional fast-mode restriction. Omit it to inherit the host setting; `false` disables fast mode for this profile; `true` only permits fast mode when the host setting is enabled. | | |
| 51 | + | | `fastMode` | Optional fast-mode override. Omit it to inherit the host setting; `false` disables fast mode for this profile; `true` enables it for this profile even when the host setting is off. | | |
| 52 | 52 | ||
| 53 | 53 | Other frontmatter from another agent host is ignored when zut does not have an equivalent setting. Recursive child spawning is disabled in v1; a worker cannot invoke `subagent_spawn` to create descendants. | |
| 54 | 54 | ||
| @@ -84,7 +84,7 @@ The interactive command also supports the same selection explicitly: | |||
| 84 | 84 | /subagents new --agent implementer --reasoning high Implement the parser change | |
| 85 | 85 | ``` | |
| 86 | 86 | ||
| 87 | - | Shared mode preserves the historical host working directory. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zut creates a detached Git worktree, captures changed files and a patch, and never merges automatically. Named profiles change the child's instructions and configuration; they are not a security sandbox. A profile's `systemPromptMode` controls its own body relative to the built-in identity; global append addenda, including enabled Ponytail coding guidance, remain present. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` cannot enable fast mode when the host setting is off. Non-OpenAI child providers return an unsupported-provider error instead of silently ignoring an enabled setting. | |
| 87 | + | Shared mode preserves the historical host working directory. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zut creates a detached Git worktree, captures changed files and a patch, and never merges automatically. Named profiles change the child's instructions and configuration; they are not a security sandbox. A profile's `systemPromptMode` controls its own body relative to the built-in identity; globally appended instructions, including enabled Ponytail coding guidance, remain present. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` enables fast mode even when the host setting is off; the `subagent_spawn` result warns the parent session when this override occurs. Child providers that do not yet support fast mode return an unsupported-provider error instead of silently ignoring an enabled setting. | |
| 88 | 88 | ||
| 89 | 89 | ## Lifecycle, results, and recovery | |
| 90 | 90 | ||
| @@ -48,7 +48,7 @@ Supported metadata: | |||||
| 48 | | `systemPromptMode` | `append` (default) or `replace`. | | 48 | | `systemPromptMode` | `append` (default) or `replace`. | | ||
| 49 | | `inheritProjectContext` | Set to `false` to omit `AGENTS.md` context from the child. | | 49 | | `inheritProjectContext` | Set to `false` to omit `AGENTS.md` context from the child. | | ||
| 50 | | `inheritSkills` | Set to `false` to omit skill discovery and the conditional `skill` loader from the child; `--no-skill` has the same effect for a run. | | 50 | | `inheritSkills` | Set to `false` to omit skill discovery and the conditional `skill` loader from the child; `--no-skill` has the same effect for a run. | | ||
| 51 | - | | `fastMode` | Optional fast-mode restriction. Omit it to inherit the host setting; `false` disables fast mode for this profile; `true` only permits fast mode when the host setting is enabled. | | 51 | + | | `fastMode` | Optional fast-mode override. Omit it to inherit the host setting; `false` disables fast mode for this profile; `true` enables it for this profile even when the host setting is off. | |
| 52 | 52 | ||||
| 53 | Other frontmatter from another agent host is ignored when zut does not have an equivalent setting. Recursive child spawning is disabled in v1; a worker cannot invoke `subagent_spawn` to create descendants. | 53 | Other frontmatter from another agent host is ignored when zut does not have an equivalent setting. Recursive child spawning is disabled in v1; a worker cannot invoke `subagent_spawn` to create descendants. | ||
| 54 | 54 | ||||
| @@ -84,7 +84,7 @@ The interactive command also supports the same selection explicitly: | |||||
| 84 | /subagents new --agent implementer --reasoning high Implement the parser change | 84 | /subagents new --agent implementer --reasoning high Implement the parser change | ||
| 85 | ``` | 85 | ``` | ||
| 86 | 86 | ||||
| 87 | - | Shared mode preserves the historical host working directory. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zut creates a detached Git worktree, captures changed files and a patch, and never merges automatically. Named profiles change the child's instructions and configuration; they are not a security sandbox. A profile's `systemPromptMode` controls its own body relative to the built-in identity; global append addenda, including enabled Ponytail coding guidance, remain present. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` cannot enable fast mode when the host setting is off. Non-OpenAI child providers return an unsupported-provider error instead of silently ignoring an enabled setting. | 87 | + | Shared mode preserves the historical host working directory. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zut creates a detached Git worktree, captures changed files and a patch, and never merges automatically. Named profiles change the child's instructions and configuration; they are not a security sandbox. A profile's `systemPromptMode` controls its own body relative to the built-in identity; globally appended instructions, including enabled Ponytail coding guidance, remain present. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` enables fast mode even when the host setting is off; the `subagent_spawn` result warns the parent session when this override occurs. Child providers that do not yet support fast mode return an unsupported-provider error instead of silently ignoring an enabled setting. |
| 88 | 88 | ||||
| 89 | ## Lifecycle, results, and recovery | 89 | ## Lifecycle, results, and recovery | ||
| 90 | 90 | ||||
M packages/agent/build.go +4 -5
| @@ -780,12 +780,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 780 | 780 | if args.FastModeSet { | |
| 781 | 781 | fastMode = args.FastMode | |
| 782 | 782 | } | |
| 783 | - | // A profile can opt out of the effective host/child fast-mode setting, | |
| 784 | - | // but it does not enable fast mode by itself. Keep this as a | |
| 785 | - | // restriction on the effective value rather than an override so an | |
| 786 | - | // explicit child --fast-mode flag cannot bypass fastMode: false. | |
| 783 | + | // A profile's explicit fastMode setting overrides the global setting | |
| 784 | + | // for this child. This preserves fastMode: false as an opt-out while | |
| 785 | + | // allowing fastMode: true to request the fast tier for a focused worker. | |
| 787 | 786 | if selectedProfile != nil && selectedProfile.FastMode != nil { | |
| 788 | - | fastMode = fastMode && *selectedProfile.FastMode | |
| 787 | + | fastMode = *selectedProfile.FastMode | |
| 789 | 788 | } | |
| 790 | 789 | ||
| 791 | 790 | max := args.MaxSteps // 0 = unlimited | |
| @@ -780,12 +780,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 780 | if args.FastModeSet { | 780 | if args.FastModeSet { | ||
| 781 | fastMode = args.FastMode | 781 | fastMode = args.FastMode | ||
| 782 | } | 782 | } | ||
| 783 | - | // A profile can opt out of the effective host/child fast-mode setting, | 783 | + | // A profile's explicit fastMode setting overrides the global setting |
| 784 | - | // but it does not enable fast mode by itself. Keep this as a | 784 | + | // for this child. This preserves fastMode: false as an opt-out while |
| 785 | - | // restriction on the effective value rather than an override so an | 785 | + | // allowing fastMode: true to request the fast tier for a focused worker. |
| 786 | - | // explicit child --fast-mode flag cannot bypass fastMode: false. | |||
| 787 | if selectedProfile != nil && selectedProfile.FastMode != nil { | 786 | if selectedProfile != nil && selectedProfile.FastMode != nil { | ||
| 788 | - | fastMode = fastMode && *selectedProfile.FastMode | 787 | + | fastMode = *selectedProfile.FastMode |
| 789 | } | 788 | } | ||
| 790 | 789 | ||||
| 791 | max := args.MaxSteps // 0 = unlimited | 790 | max := args.MaxSteps // 0 = unlimited | ||
M packages/agent/build_test.go +2 -2
| @@ -300,7 +300,7 @@ You are a read-only reviewer. | |||
| 300 | 300 | } | |
| 301 | 301 | } | |
| 302 | 302 | ||
| 303 | - | func TestResolveSubagentFastModeUsesHostSettingAsAnUpperBound(t *testing.T) { | |
| 303 | + | func TestResolveSubagentFastModeProfileOverridesHostSetting(t *testing.T) { | |
| 304 | 304 | cases := []struct { | |
| 305 | 305 | name string | |
| 306 | 306 | hostFastMode bool | |
| @@ -312,7 +312,7 @@ func TestResolveSubagentFastModeUsesHostSettingAsAnUpperBound(t *testing.T) { | |||
| 312 | 312 | {name: "unset inherits enabled host", hostFastMode: true, wantFastMode: true}, | |
| 313 | 313 | {name: "unset inherits disabled host", hostFastMode: false, wantFastMode: false}, | |
| 314 | 314 | {name: "false disables enabled host", hostFastMode: true, profileFast: boolPtr(false), wantFastMode: false}, | |
| 315 | - | {name: "true cannot enable disabled host", hostFastMode: false, profileFast: boolPtr(true), wantFastMode: false}, | |
| 315 | + | {name: "true enables disabled host", hostFastMode: false, profileFast: boolPtr(true), wantFastMode: true}, | |
| 316 | 316 | {name: "false stays disabled with disabled host", hostFastMode: false, profileFast: boolPtr(false), wantFastMode: false}, | |
| 317 | 317 | {name: "profile false cannot be bypassed", hostFastMode: true, profileFast: boolPtr(false), argFastMode: true, argFastSet: true, wantFastMode: false}, | |
| 318 | 318 | } | |
| @@ -300,7 +300,7 @@ You are a read-only reviewer. | |||||
| 300 | } | 300 | } | ||
| 301 | } | 301 | } | ||
| 302 | 302 | ||||
| 303 | - | func TestResolveSubagentFastModeUsesHostSettingAsAnUpperBound(t *testing.T) { | 303 | + | func TestResolveSubagentFastModeProfileOverridesHostSetting(t *testing.T) { |
| 304 | cases := []struct { | 304 | cases := []struct { | ||
| 305 | name string | 305 | name string | ||
| 306 | hostFastMode bool | 306 | hostFastMode bool | ||
| @@ -312,7 +312,7 @@ func TestResolveSubagentFastModeUsesHostSettingAsAnUpperBound(t *testing.T) { | |||||
| 312 | {name: "unset inherits enabled host", hostFastMode: true, wantFastMode: true}, | 312 | {name: "unset inherits enabled host", hostFastMode: true, wantFastMode: true}, | ||
| 313 | {name: "unset inherits disabled host", hostFastMode: false, wantFastMode: false}, | 313 | {name: "unset inherits disabled host", hostFastMode: false, wantFastMode: false}, | ||
| 314 | {name: "false disables enabled host", hostFastMode: true, profileFast: boolPtr(false), wantFastMode: false}, | 314 | {name: "false disables enabled host", hostFastMode: true, profileFast: boolPtr(false), wantFastMode: false}, | ||
| 315 | - | {name: "true cannot enable disabled host", hostFastMode: false, profileFast: boolPtr(true), wantFastMode: false}, | 315 | + | {name: "true enables disabled host", hostFastMode: false, profileFast: boolPtr(true), wantFastMode: true}, |
| 316 | {name: "false stays disabled with disabled host", hostFastMode: false, profileFast: boolPtr(false), wantFastMode: false}, | 316 | {name: "false stays disabled with disabled host", hostFastMode: false, profileFast: boolPtr(false), wantFastMode: false}, | ||
| 317 | {name: "profile false cannot be bypassed", hostFastMode: true, profileFast: boolPtr(false), argFastMode: true, argFastSet: true, wantFastMode: false}, | 317 | {name: "profile false cannot be bypassed", hostFastMode: true, profileFast: boolPtr(false), argFastMode: true, argFastSet: true, wantFastMode: false}, | ||
| 318 | } | 318 | } | ||
M packages/agent/modes/fast_mode_test.go +33 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | + | "github.com/bnema/zut/packages/agent/subagents" | |
| 9 | 10 | "github.com/bnema/zut/packages/core" | |
| 10 | 11 | "github.com/bnema/zut/packages/provider" | |
| 11 | 12 | ) | |
| @@ -76,6 +77,38 @@ func TestApplyFastModeUpdatesAgentAndStore(t *testing.T) { | |||
| 76 | 77 | } | |
| 77 | 78 | } | |
| 78 | 79 | ||
| 80 | + | func TestFastSlashTogglesFastMode(t *testing.T) { | |
| 81 | + | store := &recordingFastModeSettingsStore{} | |
| 82 | + | agent := core.NewAgent(fastModeProviderClient{}, "gpt-5", "", nil) | |
| 83 | + | supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()}) | |
| 84 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 85 | + | Provider: "openai", | |
| 86 | + | Model: "gpt-5", | |
| 87 | + | SettingsStore: store, | |
| 88 | + | Agent: agent, | |
| 89 | + | Supervisor: supervisor, | |
| 90 | + | }) | |
| 91 | + | interactive.rend = nil | |
| 92 | + | ||
| 93 | + | if interactive.runSlash(context.Background(), "/FAST") { | |
| 94 | + | t.Fatal("/fast requested exit") | |
| 95 | + | } | |
| 96 | + | if interactive.cfg.FastMode == nil || !*interactive.cfg.FastMode || !agent.FastMode || !supervisor.FastMode() { | |
| 97 | + | t.Fatalf("/fast did not enable every live fast-mode target: cfg=%v agent=%v supervisor=%v", interactive.cfg.FastMode, agent.FastMode, supervisor.FastMode()) | |
| 98 | + | } | |
| 99 | + | if store.setCalls != 1 || !store.last || interactive.statusOK != "fast mode enabled" { | |
| 100 | + | t.Fatalf("enable persistence/status = calls=%d last=%v status=%q", store.setCalls, store.last, interactive.statusOK) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | interactive.runSlash(context.Background(), "/fast") | |
| 104 | + | if interactive.cfg.FastMode == nil || *interactive.cfg.FastMode || agent.FastMode || supervisor.FastMode() { | |
| 105 | + | t.Fatalf("/fast did not disable every live fast-mode target: cfg=%v agent=%v supervisor=%v", interactive.cfg.FastMode, agent.FastMode, supervisor.FastMode()) | |
| 106 | + | } | |
| 107 | + | if store.setCalls != 2 || store.last || interactive.statusOK != "fast mode disabled" { | |
| 108 | + | t.Fatalf("disable persistence/status = calls=%d last=%v status=%q", store.setCalls, store.last, interactive.statusOK) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 79 | 112 | func TestApplyFastModeRejectsUnsupportedProvider(t *testing.T) { | |
| 80 | 113 | store := &recordingFastModeSettingsStore{} | |
| 81 | 114 | interactive := NewInteractive(InteractiveConfig{ | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "strings" | 6 | "strings" | ||
| 7 | "testing" | 7 | "testing" | ||
| 8 | 8 | ||||
| 9 | + | "github.com/bnema/zut/packages/agent/subagents" | |||
| 9 | "github.com/bnema/zut/packages/core" | 10 | "github.com/bnema/zut/packages/core" | ||
| 10 | "github.com/bnema/zut/packages/provider" | 11 | "github.com/bnema/zut/packages/provider" | ||
| 11 | ) | 12 | ) | ||
| @@ -76,6 +77,38 @@ func TestApplyFastModeUpdatesAgentAndStore(t *testing.T) { | |||||
| 76 | } | 77 | } | ||
| 77 | } | 78 | } | ||
| 78 | 79 | ||||
| 80 | + | func TestFastSlashTogglesFastMode(t *testing.T) { | |||
| 81 | + | store := &recordingFastModeSettingsStore{} | |||
| 82 | + | agent := core.NewAgent(fastModeProviderClient{}, "gpt-5", "", nil) | |||
| 83 | + | supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()}) | |||
| 84 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 85 | + | Provider: "openai", | |||
| 86 | + | Model: "gpt-5", | |||
| 87 | + | SettingsStore: store, | |||
| 88 | + | Agent: agent, | |||
| 89 | + | Supervisor: supervisor, | |||
| 90 | + | }) | |||
| 91 | + | interactive.rend = nil | |||
| 92 | + | ||||
| 93 | + | if interactive.runSlash(context.Background(), "/FAST") { | |||
| 94 | + | t.Fatal("/fast requested exit") | |||
| 95 | + | } | |||
| 96 | + | if interactive.cfg.FastMode == nil || !*interactive.cfg.FastMode || !agent.FastMode || !supervisor.FastMode() { | |||
| 97 | + | t.Fatalf("/fast did not enable every live fast-mode target: cfg=%v agent=%v supervisor=%v", interactive.cfg.FastMode, agent.FastMode, supervisor.FastMode()) | |||
| 98 | + | } | |||
| 99 | + | if store.setCalls != 1 || !store.last || interactive.statusOK != "fast mode enabled" { | |||
| 100 | + | t.Fatalf("enable persistence/status = calls=%d last=%v status=%q", store.setCalls, store.last, interactive.statusOK) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | interactive.runSlash(context.Background(), "/fast") | |||
| 104 | + | if interactive.cfg.FastMode == nil || *interactive.cfg.FastMode || agent.FastMode || supervisor.FastMode() { | |||
| 105 | + | t.Fatalf("/fast did not disable every live fast-mode target: cfg=%v agent=%v supervisor=%v", interactive.cfg.FastMode, agent.FastMode, supervisor.FastMode()) | |||
| 106 | + | } | |||
| 107 | + | if store.setCalls != 2 || store.last || interactive.statusOK != "fast mode disabled" { | |||
| 108 | + | t.Fatalf("disable persistence/status = calls=%d last=%v status=%q", store.setCalls, store.last, interactive.statusOK) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 79 | func TestApplyFastModeRejectsUnsupportedProvider(t *testing.T) { | 112 | func TestApplyFastModeRejectsUnsupportedProvider(t *testing.T) { | ||
| 80 | store := &recordingFastModeSettingsStore{} | 113 | store := &recordingFastModeSettingsStore{} | ||
| 81 | interactive := NewInteractive(InteractiveConfig{ | 114 | interactive := NewInteractive(InteractiveConfig{ | ||
M packages/agent/modes/interactive.go +4 -1
| @@ -4317,7 +4317,7 @@ func (i *Interactive) openSettingsDialog() { | |||
| 4317 | 4317 | { | |
| 4318 | 4318 | key: "fast_mode", | |
| 4319 | 4319 | label: "fast mode", | |
| 4320 | - | desc: "request OpenAI's fast service tier; unsupported providers return an error", | |
| 4320 | + | desc: "request the provider's fast tier where supported; unsupported providers return an error", | |
| 4321 | 4321 | value: fastMode, | |
| 4322 | 4322 | hint: fastModeHint, | |
| 4323 | 4323 | }, | |
| @@ -5544,6 +5544,9 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 5544 | 5544 | } | |
| 5545 | 5545 | case "/reasoning": | |
| 5546 | 5546 | i.openReasoningDialog() | |
| 5547 | + | case "/fast": | |
| 5548 | + | enabled := i.cfg.FastMode == nil || !*i.cfg.FastMode | |
| 5549 | + | i.applySettingToggle("fast_mode", enabled) | |
| 5547 | 5550 | case "/settings": | |
| 5548 | 5551 | i.openSettingsDialog() | |
| 5549 | 5552 | case "/sessions": | |
| @@ -4317,7 +4317,7 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 4317 | { | 4317 | { | ||
| 4318 | key: "fast_mode", | 4318 | key: "fast_mode", | ||
| 4319 | label: "fast mode", | 4319 | label: "fast mode", | ||
| 4320 | - | desc: "request OpenAI's fast service tier; unsupported providers return an error", | 4320 | + | desc: "request the provider's fast tier where supported; unsupported providers return an error", |
| 4321 | value: fastMode, | 4321 | value: fastMode, | ||
| 4322 | hint: fastModeHint, | 4322 | hint: fastModeHint, | ||
| 4323 | }, | 4323 | }, | ||
| @@ -5544,6 +5544,9 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 5544 | } | 5544 | } | ||
| 5545 | case "/reasoning": | 5545 | case "/reasoning": | ||
| 5546 | i.openReasoningDialog() | 5546 | i.openReasoningDialog() | ||
| 5547 | + | case "/fast": | |||
| 5548 | + | enabled := i.cfg.FastMode == nil || !*i.cfg.FastMode | |||
| 5549 | + | i.applySettingToggle("fast_mode", enabled) | |||
| 5547 | case "/settings": | 5550 | case "/settings": | ||
| 5548 | i.openSettingsDialog() | 5551 | i.openSettingsDialog() | ||
| 5549 | case "/sessions": | 5552 | case "/sessions": | ||
M packages/agent/modes/slash_suggest.go +1 -0
| @@ -51,6 +51,7 @@ var slashCatalog = []slashCommand{ | |||
| 51 | 51 | {Name: "/logout", Desc: "clear a provider's credentials"}, | |
| 52 | 52 | {Name: "/model", Desc: "pick a model (or /model <id>)"}, | |
| 53 | 53 | {Name: "/reasoning", Desc: "set the reasoning level"}, | |
| 54 | + | {Name: "/fast", Desc: "toggle fast mode"}, | |
| 54 | 55 | {Name: "/llama", Desc: "manage llama.cpp router models"}, | |
| 55 | 56 | {Name: "/sessions", Desc: "resume a previous session for this directory"}, | |
| 56 | 57 | {Name: "/fork", Desc: "pick a user message and fork the current session"}, | |
| @@ -51,6 +51,7 @@ var slashCatalog = []slashCommand{ | |||||
| 51 | {Name: "/logout", Desc: "clear a provider's credentials"}, | 51 | {Name: "/logout", Desc: "clear a provider's credentials"}, | ||
| 52 | {Name: "/model", Desc: "pick a model (or /model <id>)"}, | 52 | {Name: "/model", Desc: "pick a model (or /model <id>)"}, | ||
| 53 | {Name: "/reasoning", Desc: "set the reasoning level"}, | 53 | {Name: "/reasoning", Desc: "set the reasoning level"}, | ||
| 54 | + | {Name: "/fast", Desc: "toggle fast mode"}, | |||
| 54 | {Name: "/llama", Desc: "manage llama.cpp router models"}, | 55 | {Name: "/llama", Desc: "manage llama.cpp router models"}, | ||
| 55 | {Name: "/sessions", Desc: "resume a previous session for this directory"}, | 56 | {Name: "/sessions", Desc: "resume a previous session for this directory"}, | ||
| 56 | {Name: "/fork", Desc: "pick a user message and fork the current session"}, | 57 | {Name: "/fork", Desc: "pick a user message and fork the current session"}, | ||
M packages/agent/modes/slash_suggest_test.go +10 -0
| @@ -44,6 +44,16 @@ func TestSlashSuggesterHasSupervisor(t *testing.T) { | |||
| 44 | 44 | } | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | + | func TestSlashSuggesterHasFastModeToggle(t *testing.T) { | |
| 48 | + | s := newSlashSuggester() | |
| 49 | + | if got := commandNames(s.matches("/fa")); !contains(got, "/fast") { | |
| 50 | + | t.Fatalf("/fast missing from suggestions, got %v", got) | |
| 51 | + | } | |
| 52 | + | if !isKnownSlashCommand("/FAST") { | |
| 53 | + | t.Fatal("/FAST was not recognized as a built-in command") | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 47 | 57 | func TestSlashCommandsAreCaseInsensitive(t *testing.T) { | |
| 48 | 58 | s := newSlashSuggester() | |
| 49 | 59 | if got := commandNames(s.matches("/EX")); !contains(got, "/exit") { | |
| @@ -44,6 +44,16 @@ func TestSlashSuggesterHasSupervisor(t *testing.T) { | |||||
| 44 | } | 44 | } | ||
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
| 47 | + | func TestSlashSuggesterHasFastModeToggle(t *testing.T) { | |||
| 48 | + | s := newSlashSuggester() | |||
| 49 | + | if got := commandNames(s.matches("/fa")); !contains(got, "/fast") { | |||
| 50 | + | t.Fatalf("/fast missing from suggestions, got %v", got) | |||
| 51 | + | } | |||
| 52 | + | if !isKnownSlashCommand("/FAST") { | |||
| 53 | + | t.Fatal("/FAST was not recognized as a built-in command") | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 47 | func TestSlashCommandsAreCaseInsensitive(t *testing.T) { | 57 | func TestSlashCommandsAreCaseInsensitive(t *testing.T) { | ||
| 48 | s := newSlashSuggester() | 58 | s := newSlashSuggester() | ||
| 49 | if got := commandNames(s.matches("/EX")); !contains(got, "/exit") { | 59 | if got := commandNames(s.matches("/EX")); !contains(got, "/exit") { | ||
M packages/agent/subagents/agent.go +12 -0
| @@ -57,6 +57,12 @@ type Agent struct { | |||
| 57 | 57 | FastMode bool | |
| 58 | 58 | Subagent string | |
| 59 | 59 | ||
| 60 | + | // fastModeOverridesHost records the spawn-time case where an explicit | |
| 61 | + | // profile fastMode: true enabled the child while the host was off. It is | |
| 62 | + | // intentionally transient: the parent only needs it to report the initial | |
| 63 | + | // spawn warning, while the effective FastMode remains durable state. | |
| 64 | + | fastModeOverridesHost bool | |
| 65 | + | ||
| 60 | 66 | // SessionID, when non-empty, scopes the agent to a particular | |
| 61 | 67 | // host zut session: the dashboard only surfaces agents whose | |
| 62 | 68 | // SessionID matches the active session. Empty means "unscoped" and | |
| @@ -167,6 +173,12 @@ type Agent struct { | |||
| 167 | 173 | // doesn't speak the daemon protocol). | |
| 168 | 174 | func (a *Agent) Inbox() *Inbox { return a.inbox } | |
| 169 | 175 | ||
| 176 | + | // FastModeOverridesHost reports whether this child was created with an | |
| 177 | + | // explicit fast-mode override while the host setting was disabled. | |
| 178 | + | func (a *Agent) FastModeOverridesHost() bool { | |
| 179 | + | return a != nil && a.fastModeOverridesHost | |
| 180 | + | } | |
| 181 | + | ||
| 170 | 182 | // Status returns the current high-level status. Cheap; safe from any goroutine. | |
| 171 | 183 | func (a *Agent) Status() Status { | |
| 172 | 184 | a.mu.Lock() | |
| @@ -57,6 +57,12 @@ type Agent struct { | |||||
| 57 | FastMode bool | 57 | FastMode bool | ||
| 58 | Subagent string | 58 | Subagent string | ||
| 59 | 59 | ||||
| 60 | + | // fastModeOverridesHost records the spawn-time case where an explicit | |||
| 61 | + | // profile fastMode: true enabled the child while the host was off. It is | |||
| 62 | + | // intentionally transient: the parent only needs it to report the initial | |||
| 63 | + | // spawn warning, while the effective FastMode remains durable state. | |||
| 64 | + | fastModeOverridesHost bool | |||
| 65 | + | ||||
| 60 | // SessionID, when non-empty, scopes the agent to a particular | 66 | // SessionID, when non-empty, scopes the agent to a particular | ||
| 61 | // host zut session: the dashboard only surfaces agents whose | 67 | // host zut session: the dashboard only surfaces agents whose | ||
| 62 | // SessionID matches the active session. Empty means "unscoped" and | 68 | // SessionID matches the active session. Empty means "unscoped" and | ||
| @@ -167,6 +173,12 @@ type Agent struct { | |||||
| 167 | // doesn't speak the daemon protocol). | 173 | // doesn't speak the daemon protocol). | ||
| 168 | func (a *Agent) Inbox() *Inbox { return a.inbox } | 174 | func (a *Agent) Inbox() *Inbox { return a.inbox } | ||
| 169 | 175 | ||||
| 176 | + | // FastModeOverridesHost reports whether this child was created with an | |||
| 177 | + | // explicit fast-mode override while the host setting was disabled. | |||
| 178 | + | func (a *Agent) FastModeOverridesHost() bool { | |||
| 179 | + | return a != nil && a.fastModeOverridesHost | |||
| 180 | + | } | |||
| 181 | + | ||||
| 170 | // Status returns the current high-level status. Cheap; safe from any goroutine. | 182 | // Status returns the current high-level status. Cheap; safe from any goroutine. | ||
| 171 | func (a *Agent) Status() Status { | 183 | func (a *Agent) Status() Status { | ||
| 172 | a.mu.Lock() | 184 | a.mu.Lock() | ||
M packages/agent/subagents/fast_mode_test.go +10 -6
| @@ -58,18 +58,19 @@ func TestSupervisorFastModePropagatesToChild(t *testing.T) { | |||
| 58 | 58 | } | |
| 59 | 59 | } | |
| 60 | 60 | ||
| 61 | - | func TestSpawnRequestFastModeIsBoundByHostSetting(t *testing.T) { | |
| 61 | + | func TestSpawnRequestFastModeProfileOverridesHostSetting(t *testing.T) { | |
| 62 | 62 | falseValue, trueValue := false, true | |
| 63 | 63 | cases := []struct { | |
| 64 | - | name string | |
| 65 | - | hostFast bool | |
| 66 | - | profileFast *bool | |
| 67 | - | want bool | |
| 64 | + | name string | |
| 65 | + | hostFast bool | |
| 66 | + | profileFast *bool | |
| 67 | + | want bool | |
| 68 | + | wantHostOverride bool | |
| 68 | 69 | }{ | |
| 69 | 70 | {name: "unset inherits enabled host", hostFast: true, want: true}, | |
| 70 | 71 | {name: "unset inherits disabled host", hostFast: false, want: false}, | |
| 71 | 72 | {name: "false disables enabled host", hostFast: true, profileFast: &falseValue, want: false}, | |
| 72 | - | {name: "true cannot enable disabled host", hostFast: false, profileFast: &trueValue, want: false}, | |
| 73 | + | {name: "true enables disabled host", hostFast: false, profileFast: &trueValue, want: true, wantHostOverride: true}, | |
| 73 | 74 | {name: "true preserves enabled host", hostFast: true, profileFast: &trueValue, want: true}, | |
| 74 | 75 | } | |
| 75 | 76 | for _, tc := range cases { | |
| @@ -91,6 +92,9 @@ func TestSpawnRequestFastModeIsBoundByHostSetting(t *testing.T) { | |||
| 91 | 92 | if a.FastMode != tc.want { | |
| 92 | 93 | t.Fatalf("FastMode = %v, want %v", a.FastMode, tc.want) | |
| 93 | 94 | } | |
| 95 | + | if a.FastModeOverridesHost() != tc.wantHostOverride { | |
| 96 | + | t.Fatalf("FastModeOverridesHost() = %v, want %v", a.FastModeOverridesHost(), tc.wantHostOverride) | |
| 97 | + | } | |
| 94 | 98 | args := defaultChildArgs("/zut", a, "/session", "/inbox") | |
| 95 | 99 | wantFlag := "--no-fast-mode" | |
| 96 | 100 | if tc.want { | |
| @@ -58,18 +58,19 @@ func TestSupervisorFastModePropagatesToChild(t *testing.T) { | |||||
| 58 | } | 58 | } | ||
| 59 | } | 59 | } | ||
| 60 | 60 | ||||
| 61 | - | func TestSpawnRequestFastModeIsBoundByHostSetting(t *testing.T) { | 61 | + | func TestSpawnRequestFastModeProfileOverridesHostSetting(t *testing.T) { |
| 62 | falseValue, trueValue := false, true | 62 | falseValue, trueValue := false, true | ||
| 63 | cases := []struct { | 63 | cases := []struct { | ||
| 64 | - | name string | 64 | + | name string |
| 65 | - | hostFast bool | 65 | + | hostFast bool |
| 66 | - | profileFast *bool | 66 | + | profileFast *bool |
| 67 | - | want bool | 67 | + | want bool |
| 68 | + | wantHostOverride bool | |||
| 68 | }{ | 69 | }{ | ||
| 69 | {name: "unset inherits enabled host", hostFast: true, want: true}, | 70 | {name: "unset inherits enabled host", hostFast: true, want: true}, | ||
| 70 | {name: "unset inherits disabled host", hostFast: false, want: false}, | 71 | {name: "unset inherits disabled host", hostFast: false, want: false}, | ||
| 71 | {name: "false disables enabled host", hostFast: true, profileFast: &falseValue, want: false}, | 72 | {name: "false disables enabled host", hostFast: true, profileFast: &falseValue, want: false}, | ||
| 72 | - | {name: "true cannot enable disabled host", hostFast: false, profileFast: &trueValue, want: false}, | 73 | + | {name: "true enables disabled host", hostFast: false, profileFast: &trueValue, want: true, wantHostOverride: true}, |
| 73 | {name: "true preserves enabled host", hostFast: true, profileFast: &trueValue, want: true}, | 74 | {name: "true preserves enabled host", hostFast: true, profileFast: &trueValue, want: true}, | ||
| 74 | } | 75 | } | ||
| 75 | for _, tc := range cases { | 76 | for _, tc := range cases { | ||
| @@ -91,6 +92,9 @@ func TestSpawnRequestFastModeIsBoundByHostSetting(t *testing.T) { | |||||
| 91 | if a.FastMode != tc.want { | 92 | if a.FastMode != tc.want { | ||
| 92 | t.Fatalf("FastMode = %v, want %v", a.FastMode, tc.want) | 93 | t.Fatalf("FastMode = %v, want %v", a.FastMode, tc.want) | ||
| 93 | } | 94 | } | ||
| 95 | + | if a.FastModeOverridesHost() != tc.wantHostOverride { | |||
| 96 | + | t.Fatalf("FastModeOverridesHost() = %v, want %v", a.FastModeOverridesHost(), tc.wantHostOverride) | |||
| 97 | + | } | |||
| 94 | args := defaultChildArgs("/zut", a, "/session", "/inbox") | 98 | args := defaultChildArgs("/zut", a, "/session", "/inbox") | ||
| 95 | wantFlag := "--no-fast-mode" | 99 | wantFlag := "--no-fast-mode" | ||
| 96 | if tc.want { | 100 | if tc.want { | ||
M packages/agent/subagents/supervisor.go +7 -5
| @@ -306,7 +306,7 @@ func (f *Supervisor) uniqueAgentIDLocked(base string) string { | |||
| 306 | 306 | // chosen model regardless of the parent's current selection. A named | |
| 307 | 307 | // Subagent is passed to the child as --subagent, where it resolves the | |
| 308 | 308 | // markdown profile and applies its instructions. Reasoning is passed | |
| 309 | - | // as --reasoning when set. FastMode restricts the host setting when a | |
| 309 | + | // as --reasoning when set. FastMode overrides the host setting when a | |
| 310 | 310 | // selected profile declares a fastMode preference. | |
| 311 | 311 | type SpawnRequest struct { | |
| 312 | 312 | Task string | |
| @@ -317,9 +317,8 @@ type SpawnRequest struct { | |||
| 317 | 317 | // effective custom provider endpoint. | |
| 318 | 318 | InsecureTLS bool | |
| 319 | 319 | Reasoning string // optional reasoning/thinking level override | |
| 320 | - | // FastMode is an optional profile restriction. A non-nil false value | |
| 321 | - | // disables fast mode for this child; true still cannot enable fast mode | |
| 322 | - | // when Config.FastMode is false. Nil inherits Config.FastMode. | |
| 320 | + | // FastMode is an optional profile override. A non-nil value enables or | |
| 321 | + | // disables fast mode for this child; nil inherits Config.FastMode. | |
| 323 | 322 | FastMode *bool | |
| 324 | 323 | Subagent string // optional named markdown profile | |
| 325 | 324 | ||
| @@ -386,8 +385,10 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er | |||
| 386 | 385 | configInsecureTLS := f.cfg.InsecureTLS | |
| 387 | 386 | sessionID := f.activeSession | |
| 388 | 387 | f.mu.Unlock() | |
| 388 | + | fastModeOverridesHost := false | |
| 389 | 389 | if req.FastMode != nil { | |
| 390 | - | fastMode = fastMode && *req.FastMode | |
| 390 | + | fastModeOverridesHost = !fastMode && *req.FastMode | |
| 391 | + | fastMode = *req.FastMode | |
| 391 | 392 | } | |
| 392 | 393 | childProvider := strings.TrimSpace(req.Provider) | |
| 393 | 394 | inheritProviderSettings := childProvider == "" || configProvider == "" || strings.EqualFold(childProvider, strings.TrimSpace(configProvider)) | |
| @@ -509,6 +510,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er | |||
| 509 | 510 | done: make(chan struct{}), | |
| 510 | 511 | turnResults: make(chan *TurnResult, 16), | |
| 511 | 512 | } | |
| 513 | + | a.fastModeOverridesHost = fastModeOverridesHost | |
| 512 | 514 | if a.RootSessionID == "" { | |
| 513 | 515 | a.RootSessionID = sessionID | |
| 514 | 516 | } | |
| @@ -306,7 +306,7 @@ func (f *Supervisor) uniqueAgentIDLocked(base string) string { | |||||
| 306 | // chosen model regardless of the parent's current selection. A named | 306 | // chosen model regardless of the parent's current selection. A named | ||
| 307 | // Subagent is passed to the child as --subagent, where it resolves the | 307 | // Subagent is passed to the child as --subagent, where it resolves the | ||
| 308 | // markdown profile and applies its instructions. Reasoning is passed | 308 | // markdown profile and applies its instructions. Reasoning is passed | ||
| 309 | - | // as --reasoning when set. FastMode restricts the host setting when a | 309 | + | // as --reasoning when set. FastMode overrides the host setting when a |
| 310 | // selected profile declares a fastMode preference. | 310 | // selected profile declares a fastMode preference. | ||
| 311 | type SpawnRequest struct { | 311 | type SpawnRequest struct { | ||
| 312 | Task string | 312 | Task string | ||
| @@ -317,9 +317,8 @@ type SpawnRequest struct { | |||||
| 317 | // effective custom provider endpoint. | 317 | // effective custom provider endpoint. | ||
| 318 | InsecureTLS bool | 318 | InsecureTLS bool | ||
| 319 | Reasoning string // optional reasoning/thinking level override | 319 | Reasoning string // optional reasoning/thinking level override | ||
| 320 | - | // FastMode is an optional profile restriction. A non-nil false value | 320 | + | // FastMode is an optional profile override. A non-nil value enables or |
| 321 | - | // disables fast mode for this child; true still cannot enable fast mode | 321 | + | // disables fast mode for this child; nil inherits Config.FastMode. |
| 322 | - | // when Config.FastMode is false. Nil inherits Config.FastMode. | |||
| 323 | FastMode *bool | 322 | FastMode *bool | ||
| 324 | Subagent string // optional named markdown profile | 323 | Subagent string // optional named markdown profile | ||
| 325 | 324 | ||||
| @@ -386,8 +385,10 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er | |||||
| 386 | configInsecureTLS := f.cfg.InsecureTLS | 385 | configInsecureTLS := f.cfg.InsecureTLS | ||
| 387 | sessionID := f.activeSession | 386 | sessionID := f.activeSession | ||
| 388 | f.mu.Unlock() | 387 | f.mu.Unlock() | ||
| 388 | + | fastModeOverridesHost := false | |||
| 389 | if req.FastMode != nil { | 389 | if req.FastMode != nil { | ||
| 390 | - | fastMode = fastMode && *req.FastMode | 390 | + | fastModeOverridesHost = !fastMode && *req.FastMode |
| 391 | + | fastMode = *req.FastMode | |||
| 391 | } | 392 | } | ||
| 392 | childProvider := strings.TrimSpace(req.Provider) | 393 | childProvider := strings.TrimSpace(req.Provider) | ||
| 393 | inheritProviderSettings := childProvider == "" || configProvider == "" || strings.EqualFold(childProvider, strings.TrimSpace(configProvider)) | 394 | inheritProviderSettings := childProvider == "" || configProvider == "" || strings.EqualFold(childProvider, strings.TrimSpace(configProvider)) | ||
| @@ -509,6 +510,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er | |||||
| 509 | done: make(chan struct{}), | 510 | done: make(chan struct{}), | ||
| 510 | turnResults: make(chan *TurnResult, 16), | 511 | turnResults: make(chan *TurnResult, 16), | ||
| 511 | } | 512 | } | ||
| 513 | + | a.fastModeOverridesHost = fastModeOverridesHost | |||
| 512 | if a.RootSessionID == "" { | 514 | if a.RootSessionID == "" { | ||
| 513 | a.RootSessionID = sessionID | 515 | a.RootSessionID = sessionID | ||
| 514 | } | 516 | } | ||
M packages/agent/tools/subagent_spawn.go +3 -0
| @@ -274,6 +274,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||
| 274 | 274 | if agent.FastMode { | |
| 275 | 275 | sb.WriteString("fast mode: enabled\n") | |
| 276 | 276 | } | |
| 277 | + | if fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() { | |
| 278 | + | sb.WriteString("warning: subagent profile has fast mode enabled, overriding global fast mode off\n") | |
| 279 | + | } | |
| 277 | 280 | sb.WriteString("\nThe sub-agent is running in the background. Use /subagents in the TUI to monitor it. ") | |
| 278 | 281 | sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.") | |
| 279 | 282 | return core.ToolResult{ | |
| @@ -274,6 +274,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||||
| 274 | if agent.FastMode { | 274 | if agent.FastMode { | ||
| 275 | sb.WriteString("fast mode: enabled\n") | 275 | sb.WriteString("fast mode: enabled\n") | ||
| 276 | } | 276 | } | ||
| 277 | + | if fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() { | |||
| 278 | + | sb.WriteString("warning: subagent profile has fast mode enabled, overriding global fast mode off\n") | |||
| 279 | + | } | |||
| 277 | sb.WriteString("\nThe sub-agent is running in the background. Use /subagents in the TUI to monitor it. ") | 280 | sb.WriteString("\nThe sub-agent is running in the background. Use /subagents in the TUI to monitor it. ") | ||
| 278 | sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.") | 281 | sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.") | ||
| 279 | return core.ToolResult{ | 282 | return core.ToolResult{ | ||
M packages/agent/tools/subagent_spawn_test.go +41 -0
| @@ -299,6 +299,47 @@ func TestSubagentSpawnAppliesProfileFastModeRestriction(t *testing.T) { | |||
| 299 | 299 | } | |
| 300 | 300 | } | |
| 301 | 301 | ||
| 302 | + | func TestSubagentSpawnProfileFastModeOverridesGlobalOffWithWarning(t *testing.T) { | |
| 303 | + | root := t.TempDir() | |
| 304 | + | profileFastMode := true | |
| 305 | + | f := subagents.New(subagents.Config{ | |
| 306 | + | Root: filepath.Join(root, "subagents"), | |
| 307 | + | RepoRoot: root, | |
| 308 | + | NewRunner: func(*subagents.Agent) subagents.Runner { | |
| 309 | + | return noopSupervisorRunner{} | |
| 310 | + | }, | |
| 311 | + | }) | |
| 312 | + | t.Cleanup(f.StopAll) | |
| 313 | + | tool := &SubagentSpawnTool{ | |
| 314 | + | Supervisor: f, | |
| 315 | + | Enabled: func() bool { return true }, | |
| 316 | + | ResolveSubagent: func(name string) (*subagents.Profile, error) { | |
| 317 | + | if name != "fast-worker" { | |
| 318 | + | return nil, nil | |
| 319 | + | } | |
| 320 | + | return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil | |
| 321 | + | }, | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"fast-worker"}`), nil) | |
| 325 | + | if err != nil { | |
| 326 | + | t.Fatal(err) | |
| 327 | + | } | |
| 328 | + | if res.IsError { | |
| 329 | + | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |
| 330 | + | } | |
| 331 | + | agents := f.List() | |
| 332 | + | if len(agents) != 1 || !agents[0].FastMode { | |
| 333 | + | t.Fatalf("agent fast mode = %#v, want enabled by profile", agents) | |
| 334 | + | } | |
| 335 | + | if got := res.Details.(map[string]any)["fast_mode"]; got != true { | |
| 336 | + | t.Fatalf("fast_mode detail = %v, want true", got) | |
| 337 | + | } | |
| 338 | + | if text := textResult(res.Content); !strings.Contains(text, "warning: subagent profile has fast mode enabled, overriding global fast mode off") { | |
| 339 | + | t.Fatalf("spawn result missing profile override warning:\n%s", text) | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | ||
| 302 | 343 | func TestSubagentSpawnUsesProfileReasoningWhenOmitted(t *testing.T) { | |
| 303 | 344 | tool := &SubagentSpawnTool{ | |
| 304 | 345 | Supervisor: newTestSupervisor(t), | |
| @@ -299,6 +299,47 @@ func TestSubagentSpawnAppliesProfileFastModeRestriction(t *testing.T) { | |||||
| 299 | } | 299 | } | ||
| 300 | } | 300 | } | ||
| 301 | 301 | ||||
| 302 | + | func TestSubagentSpawnProfileFastModeOverridesGlobalOffWithWarning(t *testing.T) { | |||
| 303 | + | root := t.TempDir() | |||
| 304 | + | profileFastMode := true | |||
| 305 | + | f := subagents.New(subagents.Config{ | |||
| 306 | + | Root: filepath.Join(root, "subagents"), | |||
| 307 | + | RepoRoot: root, | |||
| 308 | + | NewRunner: func(*subagents.Agent) subagents.Runner { | |||
| 309 | + | return noopSupervisorRunner{} | |||
| 310 | + | }, | |||
| 311 | + | }) | |||
| 312 | + | t.Cleanup(f.StopAll) | |||
| 313 | + | tool := &SubagentSpawnTool{ | |||
| 314 | + | Supervisor: f, | |||
| 315 | + | Enabled: func() bool { return true }, | |||
| 316 | + | ResolveSubagent: func(name string) (*subagents.Profile, error) { | |||
| 317 | + | if name != "fast-worker" { | |||
| 318 | + | return nil, nil | |||
| 319 | + | } | |||
| 320 | + | return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil | |||
| 321 | + | }, | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"fast-worker"}`), nil) | |||
| 325 | + | if err != nil { | |||
| 326 | + | t.Fatal(err) | |||
| 327 | + | } | |||
| 328 | + | if res.IsError { | |||
| 329 | + | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |||
| 330 | + | } | |||
| 331 | + | agents := f.List() | |||
| 332 | + | if len(agents) != 1 || !agents[0].FastMode { | |||
| 333 | + | t.Fatalf("agent fast mode = %#v, want enabled by profile", agents) | |||
| 334 | + | } | |||
| 335 | + | if got := res.Details.(map[string]any)["fast_mode"]; got != true { | |||
| 336 | + | t.Fatalf("fast_mode detail = %v, want true", got) | |||
| 337 | + | } | |||
| 338 | + | if text := textResult(res.Content); !strings.Contains(text, "warning: subagent profile has fast mode enabled, overriding global fast mode off") { | |||
| 339 | + | t.Fatalf("spawn result missing profile override warning:\n%s", text) | |||
| 340 | + | } | |||
| 341 | + | } | |||
| 342 | + | ||||
| 302 | func TestSubagentSpawnUsesProfileReasoningWhenOmitted(t *testing.T) { | 343 | func TestSubagentSpawnUsesProfileReasoningWhenOmitted(t *testing.T) { | ||
| 303 | tool := &SubagentSpawnTool{ | 344 | tool := &SubagentSpawnTool{ | ||
| 304 | Supervisor: newTestSupervisor(t), | 345 | Supervisor: newTestSupervisor(t), | ||