feat(agent): add default-on Ponytail coding mode
- commit
- 4091863beacbff51442e41285b9b0f2f479a42cc
- author
- brice · · 2026-08-05 06:52 UTC
- committer
- brice · · 2026-08-05 07:29 UTC
- parent
- 6588644
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
20 files · +818 -36
M README.md +10 -5
| @@ -148,14 +148,18 @@ $ZOT_HOME/ | |||
| 148 | 148 | ├── sessions/ # jsonl transcripts, one dir per cwd | |
| 149 | 149 | ├── models-cache.json # live /v1/models discovery cache (6h ttl) | |
| 150 | 150 | ├── AGENTS.md # optional: global instructions appended to the prompt | |
| 151 | - | ├── SYSTEM.md # optional: replaces the default system prompt | |
| 151 | + | ├── SYSTEM.md # optional: replaces the built-in identity; addenda remain | |
| 152 | 152 | ├── skills/ # optional: user SKILL.md files | |
| 153 | 153 | ├── themes/ # optional: user theme JSON files | |
| 154 | 154 | ├── extensions/ # installed extensions, one dir per extension | |
| 155 | 155 | └── logs/ # app log files | |
| 156 | 156 | ``` | |
| 157 | 157 | ||
| 158 | - | Drop a `SYSTEM.md` in `$ZOT_HOME` to replace the built-in identity and guidelines for every run. `--system-prompt` still wins per-invocation. Delete the file to revert to the default. | |
| 158 | + | Drop a `SYSTEM.md` in `$ZOT_HOME` to replace the built-in identity for every run. Existing append addenda, including `AGENTS.md`, skills, and enabled Ponytail coding mode, remain layered on top. `--system-prompt` wins per invocation unless a selected replace-mode subagent profile supplies the child's identity. Delete the file to revert to the default identity. | |
| 159 | + | ||
| 160 | + | Ponytail coding mode is enabled by default, including when an existing `config.json` does not contain a `ponytail_enabled` field. When disabled, its compact guidance is omitted from resolved system prompts; when enabled, the guidance tells the model to apply itself only to engineering work. The setting persists its explicit on/off choice in `$ZOT_HOME/config.json` and can be changed from `/settings`. It is advisory guidance only: it does not change tool permissions, confirmations, jail behavior, or sandboxing. | |
| 161 | + | ||
| 162 | + | A selected subagent profile with `systemPromptMode: replace` supplies the child's base identity even when `--system-prompt` was provided for the parent run. Global append addenda, including enabled Ponytail guidance, still follow their normal inclusion rules. | |
| 159 | 163 | ||
| 160 | 164 | ## Persistent instructions (AGENTS.md) | |
| 161 | 165 | ||
| @@ -180,9 +184,9 @@ Treat questions and discussions as requests for explanation. Do not edit files o | |||
| 180 | 184 | |---|---|---| | |
| 181 | 185 | | `$ZOT_HOME/AGENTS.md` | global, every run | appended to the default prompt | | |
| 182 | 186 | | `./AGENTS.md` (and parent dirs) | project | appended to the default prompt | | |
| 183 | - | | `$ZOT_HOME/SYSTEM.md` | global, every run | replaces the default prompt entirely | | |
| 187 | + | | `$ZOT_HOME/SYSTEM.md` | global, every run | replaces the built-in identity; append addenda remain | | |
| 184 | 188 | | `--append-system-prompt <text>` | single run | appends for one invocation (repeatable) | | |
| 185 | - | | `--system-prompt <text>` | single run | replaces the prompt for one invocation | | |
| 189 | + | | `--system-prompt <text>` | single run | replaces the built-in identity for one invocation; append addenda remain | | |
| 186 | 190 | ||
| 187 | 191 | > **Note:** zot does not read a `CLAUDE.md` instruction file. The only Claude-compatible thing it picks up is skills under `.claude/skills/`. If you are migrating from Claude Code, move that content into `AGENTS.md` (global or per-project) and zot will use it. | |
| 188 | 192 | ||
| @@ -298,7 +302,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||
| 298 | 302 | | `/unjail` | Allow tools to touch paths outside again. | | |
| 299 | 303 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | |
| 300 | 304 | | `/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`. | | |
| 301 | - | | `/settings` | Change persistent settings, including inline images, terminal alerts, AI terminal titles, auto-subagents, fast mode, main/sub-agent LSP access, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. | | |
| 305 | + | | `/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 `$ZOT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. | | |
| 302 | 306 | | `/clear` | Clear the chat transcript. | | |
| 303 | 307 | | `/exit` | Exit zot. | | |
| 304 | 308 | ||
| @@ -405,6 +409,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||
| 405 | 409 | - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all. | |
| 406 | 410 | - **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 `zot: <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`. | |
| 407 | 411 | - **auto-subagents** — let the main agent spawn background sub-agents in parallel via the canonical `subagent_spawn` tool. Off by default. The tool accepts named profiles, reasoning, timeout, turn limits, and shared/worktree isolation; 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. | |
| 412 | + | - **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 `$ZOT_HOME/config.json`. It is included by interactive, print, stream, JSON, RPC, subagent, bot, SDK, and Zotfile agents. | |
| 408 | 413 | - **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. | |
| 409 | 414 | - **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`. | |
| 410 | 415 | - **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. | |
| @@ -148,14 +148,18 @@ $ZOT_HOME/ | |||||
| 148 | ├── sessions/ # jsonl transcripts, one dir per cwd | 148 | ├── sessions/ # jsonl transcripts, one dir per cwd | ||
| 149 | ├── models-cache.json # live /v1/models discovery cache (6h ttl) | 149 | ├── models-cache.json # live /v1/models discovery cache (6h ttl) | ||
| 150 | ├── AGENTS.md # optional: global instructions appended to the prompt | 150 | ├── AGENTS.md # optional: global instructions appended to the prompt | ||
| 151 | - | ├── SYSTEM.md # optional: replaces the default system prompt | 151 | + | ├── SYSTEM.md # optional: replaces the built-in identity; addenda remain |
| 152 | ├── skills/ # optional: user SKILL.md files | 152 | ├── skills/ # optional: user SKILL.md files | ||
| 153 | ├── themes/ # optional: user theme JSON files | 153 | ├── themes/ # optional: user theme JSON files | ||
| 154 | ├── extensions/ # installed extensions, one dir per extension | 154 | ├── extensions/ # installed extensions, one dir per extension | ||
| 155 | └── logs/ # app log files | 155 | └── logs/ # app log files | ||
| 156 | ``` | 156 | ``` | ||
| 157 | 157 | ||||
| 158 | - | Drop a `SYSTEM.md` in `$ZOT_HOME` to replace the built-in identity and guidelines for every run. `--system-prompt` still wins per-invocation. Delete the file to revert to the default. | 158 | + | Drop a `SYSTEM.md` in `$ZOT_HOME` to replace the built-in identity for every run. Existing append addenda, including `AGENTS.md`, skills, and enabled Ponytail coding mode, remain layered on top. `--system-prompt` wins per invocation unless a selected replace-mode subagent profile supplies the child's identity. Delete the file to revert to the default identity. |
| 159 | + | ||||
| 160 | + | Ponytail coding mode is enabled by default, including when an existing `config.json` does not contain a `ponytail_enabled` field. When disabled, its compact guidance is omitted from resolved system prompts; when enabled, the guidance tells the model to apply itself only to engineering work. The setting persists its explicit on/off choice in `$ZOT_HOME/config.json` and can be changed from `/settings`. It is advisory guidance only: it does not change tool permissions, confirmations, jail behavior, or sandboxing. | |||
| 161 | + | ||||
| 162 | + | A selected subagent profile with `systemPromptMode: replace` supplies the child's base identity even when `--system-prompt` was provided for the parent run. Global append addenda, including enabled Ponytail guidance, still follow their normal inclusion rules. | |||
| 159 | 163 | ||||
| 160 | ## Persistent instructions (AGENTS.md) | 164 | ## Persistent instructions (AGENTS.md) | ||
| 161 | 165 | ||||
| @@ -180,9 +184,9 @@ Treat questions and discussions as requests for explanation. Do not edit files o | |||||
| 180 | |---|---|---| | 184 | |---|---|---| | ||
| 181 | | `$ZOT_HOME/AGENTS.md` | global, every run | appended to the default prompt | | 185 | | `$ZOT_HOME/AGENTS.md` | global, every run | appended to the default prompt | | ||
| 182 | | `./AGENTS.md` (and parent dirs) | project | appended to the default prompt | | 186 | | `./AGENTS.md` (and parent dirs) | project | appended to the default prompt | | ||
| 183 | - | | `$ZOT_HOME/SYSTEM.md` | global, every run | replaces the default prompt entirely | | 187 | + | | `$ZOT_HOME/SYSTEM.md` | global, every run | replaces the built-in identity; append addenda remain | |
| 184 | | `--append-system-prompt <text>` | single run | appends for one invocation (repeatable) | | 188 | | `--append-system-prompt <text>` | single run | appends for one invocation (repeatable) | | ||
| 185 | - | | `--system-prompt <text>` | single run | replaces the prompt for one invocation | | 189 | + | | `--system-prompt <text>` | single run | replaces the built-in identity for one invocation; append addenda remain | |
| 186 | 190 | ||||
| 187 | > **Note:** zot does not read a `CLAUDE.md` instruction file. The only Claude-compatible thing it picks up is skills under `.claude/skills/`. If you are migrating from Claude Code, move that content into `AGENTS.md` (global or per-project) and zot will use it. | 191 | > **Note:** zot does not read a `CLAUDE.md` instruction file. The only Claude-compatible thing it picks up is skills under `.claude/skills/`. If you are migrating from Claude Code, move that content into `AGENTS.md` (global or per-project) and zot will use it. | ||
| 188 | 192 | ||||
| @@ -298,7 +302,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||||
| 298 | | `/unjail` | Allow tools to touch paths outside again. | | 302 | | `/unjail` | Allow tools to touch paths outside again. | | ||
| 299 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | 303 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | ||
| 300 | | `/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`. | | 304 | | `/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`. | | ||
| 301 | - | | `/settings` | Change persistent settings, including inline images, terminal alerts, AI terminal titles, auto-subagents, fast mode, main/sub-agent LSP access, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. | | 305 | + | | `/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 `$ZOT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. | |
| 302 | | `/clear` | Clear the chat transcript. | | 306 | | `/clear` | Clear the chat transcript. | | ||
| 303 | | `/exit` | Exit zot. | | 307 | | `/exit` | Exit zot. | | ||
| 304 | 308 | ||||
| @@ -405,6 +409,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||||
| 405 | - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all. | 409 | - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all. | ||
| 406 | - **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 `zot: <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`. | 410 | - **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 `zot: <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`. | ||
| 407 | - **auto-subagents** — let the main agent spawn background sub-agents in parallel via the canonical `subagent_spawn` tool. Off by default. The tool accepts named profiles, reasoning, timeout, turn limits, and shared/worktree isolation; 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. | 411 | - **auto-subagents** — let the main agent spawn background sub-agents in parallel via the canonical `subagent_spawn` tool. Off by default. The tool accepts named profiles, reasoning, timeout, turn limits, and shared/worktree isolation; 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. | ||
| 412 | + | - **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 `$ZOT_HOME/config.json`. It is included by interactive, print, stream, JSON, RPC, subagent, bot, SDK, and Zotfile agents. | |||
| 408 | - **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. | 413 | - **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. | ||
| 409 | - **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`. | 414 | - **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`. | ||
| 410 | - **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. | 415 | - **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. | ||
M docs/rpc.md +2 -0
| @@ -25,6 +25,8 @@ You'll see one JSON object per line on stdout: a response acknowledging the prom | |||
| 25 | 25 | ||
| 26 | 26 | `zot rpc` accepts the same flags as the other modes: `--provider`, `--model`, `--cwd`, `--api-key`, `--base-url`, `--system-prompt`, `--append-system-prompt`, `--reasoning`, `--max-steps`, `--no-tools`, `--tools`. Sessions are disabled by default in RPC mode — the embedding application owns persistence. | |
| 27 | 27 | ||
| 28 | + | RPC agents use the same resolved system-prompt policy as other modes. Ponytail coding mode is enabled when `ponytail_enabled` is missing or `true` in `$ZOT_HOME/config.json`, and disabled when it is `false`. A custom `--system-prompt` replaces the built-in identity, while enabled append addenda remain present. | |
| 29 | + | ||
| 28 | 30 | ## Auth | |
| 29 | 31 | ||
| 30 | 32 | If the environment variable `ZOTCORE_RPC_TOKEN` is set on the spawned process, the first line on stdin **must** be a `hello` command containing the matching token: | |
| @@ -25,6 +25,8 @@ You'll see one JSON object per line on stdout: a response acknowledging the prom | |||||
| 25 | 25 | ||||
| 26 | `zot rpc` accepts the same flags as the other modes: `--provider`, `--model`, `--cwd`, `--api-key`, `--base-url`, `--system-prompt`, `--append-system-prompt`, `--reasoning`, `--max-steps`, `--no-tools`, `--tools`. Sessions are disabled by default in RPC mode — the embedding application owns persistence. | 26 | `zot rpc` accepts the same flags as the other modes: `--provider`, `--model`, `--cwd`, `--api-key`, `--base-url`, `--system-prompt`, `--append-system-prompt`, `--reasoning`, `--max-steps`, `--no-tools`, `--tools`. Sessions are disabled by default in RPC mode — the embedding application owns persistence. | ||
| 27 | 27 | ||||
| 28 | + | RPC agents use the same resolved system-prompt policy as other modes. Ponytail coding mode is enabled when `ponytail_enabled` is missing or `true` in `$ZOT_HOME/config.json`, and disabled when it is `false`. A custom `--system-prompt` replaces the built-in identity, while enabled append addenda remain present. | |||
| 29 | + | ||||
| 28 | ## Auth | 30 | ## Auth | ||
| 29 | 31 | ||||
| 30 | If the environment variable `ZOTCORE_RPC_TOKEN` is set on the spawned process, the first line on stdin **must** be a `hello` command containing the matching token: | 32 | If the environment variable `ZOTCORE_RPC_TOKEN` is set on the spawned process, the first line on stdin **must** be a `hello` command containing the matching token: | ||
M docs/subagents.md +1 -1
| @@ -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`; zot 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. 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`; zot 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. | |
| 88 | 88 | ||
| 89 | 89 | ## Lifecycle, results, and recovery | |
| 90 | 90 | ||
| @@ -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`; zot 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. 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`; zot 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. |
| 88 | 88 | ||||
| 89 | ## Lifecycle, results, and recovery | 89 | ## Lifecycle, results, and recovery | ||
| 90 | 90 | ||||
M docs/zotfiles.md +4 -4
| @@ -103,11 +103,11 @@ Symlinks are not supported and cause `zot pack` to fail. | |||
| 103 | 103 | ||
| 104 | 104 | ## `AGENT.md` | |
| 105 | 105 | ||
| 106 | - | `AGENT.md` is optional. When present, it is appended to zot's system prompt by default. It should describe the agent's role, workflow, constraints, output format, and when to load bundled skills. When absent, the system prompt is left unchanged. | |
| 106 | + | `AGENT.md` is optional. When present, it is appended to zot's system prompt by default. It should describe the agent's role, workflow, constraints, output format, and when to load bundled skills. When absent, it contributes no agent-specific text; other enabled global addenda still follow their normal rules. | |
| 107 | 107 | ||
| 108 | 108 | Keep capability and security declarations out of this file. Permissions come only from `manifest.json` and are enforced independently of agent-authored prose. | |
| 109 | 109 | ||
| 110 | - | Set `replace_system_prompt` to `true` in the manifest to use `AGENT.md` as the replacement system prompt: | |
| 110 | + | Set `replace_system_prompt` to `true` in the manifest to use `AGENT.md` as the replacement base identity: | |
| 111 | 111 | ||
| 112 | 112 | ```json | |
| 113 | 113 | { | |
| @@ -115,7 +115,7 @@ Set `replace_system_prompt` to `true` in the manifest to use `AGENT.md` as the r | |||
| 115 | 115 | } | |
| 116 | 116 | ``` | |
| 117 | 117 | ||
| 118 | - | Replacement is intended for fully specialized agents. The default layering behavior is usually preferable because it retains zot's normal identity and tool-use guidance. | |
| 118 | + | Replacement is intended for fully specialized agents. It replaces zot's built-in identity, while global append addenda such as enabled Ponytail coding guidance, project context, and skills retain their normal inclusion rules. The default layering behavior is usually preferable because it retains zot's normal identity and tool-use guidance. | |
| 119 | 119 | ||
| 120 | 120 | ## Bundled skills | |
| 121 | 121 | ||
| @@ -205,7 +205,7 @@ The current manifest shape is: | |||
| 205 | 205 | | `permissions` | no | Filesystem and bash permission ceiling. Omitted scopes deny access. | | |
| 206 | 206 | | `requirements` | no | Required operating systems and executables. | | |
| 207 | 207 | | `entry` | no | Initial presentation and prompt metadata. | | |
| 208 | - | | `replace_system_prompt` | no | Replaces rather than appends to the normal system prompt when `true`. Defaults to `false`. | | |
| 208 | + | | `replace_system_prompt` | no | Replaces the built-in identity rather than appending the agent body when `true`; global append addenda retain their normal inclusion rules. Defaults to `false`. | | |
| 209 | 209 | ||
| 210 | 210 | ### Name rules | |
| 211 | 211 | ||
| @@ -103,11 +103,11 @@ Symlinks are not supported and cause `zot pack` to fail. | |||||
| 103 | 103 | ||||
| 104 | ## `AGENT.md` | 104 | ## `AGENT.md` | ||
| 105 | 105 | ||||
| 106 | - | `AGENT.md` is optional. When present, it is appended to zot's system prompt by default. It should describe the agent's role, workflow, constraints, output format, and when to load bundled skills. When absent, the system prompt is left unchanged. | 106 | + | `AGENT.md` is optional. When present, it is appended to zot's system prompt by default. It should describe the agent's role, workflow, constraints, output format, and when to load bundled skills. When absent, it contributes no agent-specific text; other enabled global addenda still follow their normal rules. |
| 107 | 107 | ||||
| 108 | Keep capability and security declarations out of this file. Permissions come only from `manifest.json` and are enforced independently of agent-authored prose. | 108 | Keep capability and security declarations out of this file. Permissions come only from `manifest.json` and are enforced independently of agent-authored prose. | ||
| 109 | 109 | ||||
| 110 | - | Set `replace_system_prompt` to `true` in the manifest to use `AGENT.md` as the replacement system prompt: | 110 | + | Set `replace_system_prompt` to `true` in the manifest to use `AGENT.md` as the replacement base identity: |
| 111 | 111 | ||||
| 112 | ```json | 112 | ```json | ||
| 113 | { | 113 | { | ||
| @@ -115,7 +115,7 @@ Set `replace_system_prompt` to `true` in the manifest to use `AGENT.md` as the r | |||||
| 115 | } | 115 | } | ||
| 116 | ``` | 116 | ``` | ||
| 117 | 117 | ||||
| 118 | - | Replacement is intended for fully specialized agents. The default layering behavior is usually preferable because it retains zot's normal identity and tool-use guidance. | 118 | + | Replacement is intended for fully specialized agents. It replaces zot's built-in identity, while global append addenda such as enabled Ponytail coding guidance, project context, and skills retain their normal inclusion rules. The default layering behavior is usually preferable because it retains zot's normal identity and tool-use guidance. |
| 119 | 119 | ||||
| 120 | ## Bundled skills | 120 | ## Bundled skills | ||
| 121 | 121 | ||||
| @@ -205,7 +205,7 @@ The current manifest shape is: | |||||
| 205 | | `permissions` | no | Filesystem and bash permission ceiling. Omitted scopes deny access. | | 205 | | `permissions` | no | Filesystem and bash permission ceiling. Omitted scopes deny access. | | ||
| 206 | | `requirements` | no | Required operating systems and executables. | | 206 | | `requirements` | no | Required operating systems and executables. | | ||
| 207 | | `entry` | no | Initial presentation and prompt metadata. | | 207 | | `entry` | no | Initial presentation and prompt metadata. | | ||
| 208 | - | | `replace_system_prompt` | no | Replaces rather than appends to the normal system prompt when `true`. Defaults to `false`. | | 208 | + | | `replace_system_prompt` | no | Replaces the built-in identity rather than appending the agent body when `true`; global append addenda retain their normal inclusion rules. Defaults to `false`. | |
| 209 | 209 | ||||
| 210 | ### Name rules | 210 | ### Name rules | ||
| 211 | 211 | ||||
M packages/agent/build.go +7 -1
| @@ -726,7 +726,13 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 726 | 726 | if selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext { | |
| 727 | 727 | contextFiles = loadAgentsContext(args.CWD, ZotHome()) | |
| 728 | 728 | } | |
| 729 | - | append_ := append([]string(nil), args.AppendSystemPrompt...) | |
| 729 | + | append_ := []string(nil) | |
| 730 | + | if cfg.PonytailModeEnabled() { | |
| 731 | + | if ponytailAddendum := PonytailSystemAddendum(); ponytailAddendum != "" { | |
| 732 | + | append_ = append(append_, ponytailAddendum) | |
| 733 | + | } | |
| 734 | + | } | |
| 735 | + | append_ = append(append_, args.AppendSystemPrompt...) | |
| 730 | 736 | if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" { | |
| 731 | 737 | append_ = append(append_, agentsAddendum) | |
| 732 | 738 | } | |
| @@ -726,7 +726,13 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 726 | if selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext { | 726 | if selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext { | ||
| 727 | contextFiles = loadAgentsContext(args.CWD, ZotHome()) | 727 | contextFiles = loadAgentsContext(args.CWD, ZotHome()) | ||
| 728 | } | 728 | } | ||
| 729 | - | append_ := append([]string(nil), args.AppendSystemPrompt...) | 729 | + | append_ := []string(nil) |
| 730 | + | if cfg.PonytailModeEnabled() { | |||
| 731 | + | if ponytailAddendum := PonytailSystemAddendum(); ponytailAddendum != "" { | |||
| 732 | + | append_ = append(append_, ponytailAddendum) | |||
| 733 | + | } | |||
| 734 | + | } | |||
| 735 | + | append_ = append(append_, args.AppendSystemPrompt...) | |||
| 730 | if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" { | 736 | if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" { | ||
| 731 | append_ = append(append_, agentsAddendum) | 737 | append_ = append(append_, agentsAddendum) | ||
| 732 | } | 738 | } | ||
M packages/agent/build_test.go +19 -0
| @@ -62,6 +62,22 @@ func TestMergeExtensionToolsRespectsDisabledSkillDiscovery(t *testing.T) { | |||
| 62 | 62 | } | |
| 63 | 63 | } | |
| 64 | 64 | ||
| 65 | + | func TestMergeExtensionToolsRetainsPonytailPromptAddendum(t *testing.T) { | |
| 66 | + | r := Resolved{ | |
| 67 | + | CWD: t.TempDir(), | |
| 68 | + | ToolRegistry: make(core.Registry), | |
| 69 | + | skillsEnabled: true, | |
| 70 | + | systemAppend: []string{PonytailSystemAddendum()}, | |
| 71 | + | } | |
| 72 | + | bundled := &skills.Skill{Name: "bundled", Description: "bundled skill", Source: "extension tasked-phases"} | |
| 73 | + | ||
| 74 | + | r.MergeExtensionTools(bundledSkillSource{bundled: []*skills.Skill{bundled}}) | |
| 75 | + | ||
| 76 | + | if !strings.Contains(r.SystemPrompt, PonytailSystemAddendum()) { | |
| 77 | + | t.Fatalf("extension prompt rebuild lost Ponytail addendum:\n%s", r.SystemPrompt) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 65 | 81 | func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | |
| 66 | 82 | root := t.TempDir() | |
| 67 | 83 | registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), true, true, false) | |
| @@ -247,6 +263,9 @@ You are a read-only reviewer. | |||
| 247 | 263 | if !strings.Contains(r.SystemPrompt, "You are a read-only reviewer.") || strings.Contains(r.SystemPrompt, "global context") { | |
| 248 | 264 | t.Fatalf("profile system prompt inheritance is wrong:\n%s", r.SystemPrompt) | |
| 249 | 265 | } | |
| 266 | + | if !strings.Contains(r.SystemPrompt, PonytailSystemAddendum()) { | |
| 267 | + | t.Fatalf("replace-mode profile lost global Ponytail addendum:\n%s", r.SystemPrompt) | |
| 268 | + | } | |
| 250 | 269 | } | |
| 251 | 270 | ||
| 252 | 271 | func TestResolveSubagentFastModeUsesHostSettingAsAnUpperBound(t *testing.T) { | |
| @@ -62,6 +62,22 @@ func TestMergeExtensionToolsRespectsDisabledSkillDiscovery(t *testing.T) { | |||||
| 62 | } | 62 | } | ||
| 63 | } | 63 | } | ||
| 64 | 64 | ||||
| 65 | + | func TestMergeExtensionToolsRetainsPonytailPromptAddendum(t *testing.T) { | |||
| 66 | + | r := Resolved{ | |||
| 67 | + | CWD: t.TempDir(), | |||
| 68 | + | ToolRegistry: make(core.Registry), | |||
| 69 | + | skillsEnabled: true, | |||
| 70 | + | systemAppend: []string{PonytailSystemAddendum()}, | |||
| 71 | + | } | |||
| 72 | + | bundled := &skills.Skill{Name: "bundled", Description: "bundled skill", Source: "extension tasked-phases"} | |||
| 73 | + | ||||
| 74 | + | r.MergeExtensionTools(bundledSkillSource{bundled: []*skills.Skill{bundled}}) | |||
| 75 | + | ||||
| 76 | + | if !strings.Contains(r.SystemPrompt, PonytailSystemAddendum()) { | |||
| 77 | + | t.Fatalf("extension prompt rebuild lost Ponytail addendum:\n%s", r.SystemPrompt) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 65 | func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | 81 | func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | ||
| 66 | root := t.TempDir() | 82 | root := t.TempDir() | ||
| 67 | registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), true, true, false) | 83 | registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), true, true, false) | ||
| @@ -247,6 +263,9 @@ You are a read-only reviewer. | |||||
| 247 | if !strings.Contains(r.SystemPrompt, "You are a read-only reviewer.") || strings.Contains(r.SystemPrompt, "global context") { | 263 | if !strings.Contains(r.SystemPrompt, "You are a read-only reviewer.") || strings.Contains(r.SystemPrompt, "global context") { | ||
| 248 | t.Fatalf("profile system prompt inheritance is wrong:\n%s", r.SystemPrompt) | 264 | t.Fatalf("profile system prompt inheritance is wrong:\n%s", r.SystemPrompt) | ||
| 249 | } | 265 | } | ||
| 266 | + | if !strings.Contains(r.SystemPrompt, PonytailSystemAddendum()) { | |||
| 267 | + | t.Fatalf("replace-mode profile lost global Ponytail addendum:\n%s", r.SystemPrompt) | |||
| 268 | + | } | |||
| 250 | } | 269 | } | ||
| 251 | 270 | ||||
| 252 | func TestResolveSubagentFastModeUsesHostSettingAsAnUpperBound(t *testing.T) { | 271 | func TestResolveSubagentFastModeUsesHostSettingAsAnUpperBound(t *testing.T) { | ||
M packages/agent/cli.go +48 -18
| @@ -322,7 +322,7 @@ func newPersistModelCallback(persistMu *sync.Mutex, sess **core.Session, activeP | |||
| 322 | 322 | ||
| 323 | 323 | func closeAgentLSP(ag *core.Agent) { | |
| 324 | 324 | if ag != nil { | |
| 325 | - | _ = tools.CloseLSPManagers(ag.Tools) | |
| 325 | + | _ = tools.CloseLSPManagers(ag.ToolsSnapshot()) | |
| 326 | 326 | } | |
| 327 | 327 | } | |
| 328 | 328 | ||
| @@ -903,7 +903,9 @@ func runPrintMode(ctx context.Context, args Args, version string) error { | |||
| 903 | 903 | if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, nil, os.Stderr); err != nil { | |
| 904 | 904 | return err | |
| 905 | 905 | } | |
| 906 | - | reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag) | |
| 906 | + | if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil { | |
| 907 | + | return err | |
| 908 | + | } | |
| 907 | 909 | started := time.Now() | |
| 908 | 910 | usage, err := modes.RunPrint(ctx, ag, prompt, nil, os.Stdout) | |
| 909 | 911 | elapsed := time.Since(started) | |
| @@ -969,7 +971,9 @@ func runStreamMode(ctx context.Context, args Args, version string) error { | |||
| 969 | 971 | return err | |
| 970 | 972 | } | |
| 971 | 973 | finishPre() | |
| 972 | - | reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag) | |
| 974 | + | if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil { | |
| 975 | + | return err | |
| 976 | + | } | |
| 973 | 977 | err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout) | |
| 974 | 978 | WriteNewTranscript(ag, sess, start) | |
| 975 | 979 | return err | |
| @@ -1073,7 +1077,10 @@ func runJSONMode(ctx context.Context, args Args, version string) error { | |||
| 1073 | 1077 | _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()}) | |
| 1074 | 1078 | return err | |
| 1075 | 1079 | } | |
| 1076 | - | reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag) | |
| 1080 | + | if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil { | |
| 1081 | + | _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()}) | |
| 1082 | + | return err | |
| 1083 | + | } | |
| 1077 | 1084 | err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout) | |
| 1078 | 1085 | WriteNewTranscript(ag, sess, start) | |
| 1079 | 1086 | return err | |
| @@ -1137,18 +1144,21 @@ func runZotfileStartupPre(ctx context.Context, pre, cwd string, sandbox *tools.S | |||
| 1137 | 1144 | return ag.Prompt(ctx, pre, nil, sink) | |
| 1138 | 1145 | } | |
| 1139 | 1146 | ||
| 1147 | + | var errInteractiveAgentChanged = errors.New("interactive agent changed during prompt refresh") | |
| 1148 | + | ||
| 1140 | 1149 | // refreshAgentToolsAndPrompt re-resolves tools (including rediscovered | |
| 1141 | 1150 | // skills and currently loaded extension tools) and updates the live | |
| 1142 | 1151 | // agent's registry and system prompt. Used after /reload-ext and after | |
| 1143 | 1152 | // zotfile entry.pre installs new skills or extensions. | |
| 1144 | 1153 | // mutateRegistry, if non-nil, can inject session-specific tools (e.g. subagent_spawn). | |
| 1145 | - | func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry) { | |
| 1154 | + | // interactive, when non-nil, serializes the final commit with agent replacement. | |
| 1155 | + | func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry, interactive *modes.Interactive) error { | |
| 1146 | 1156 | if ag == nil { | |
| 1147 | - | return | |
| 1157 | + | return nil | |
| 1148 | 1158 | } | |
| 1149 | 1159 | resolved, err := Resolve(args, true) | |
| 1150 | 1160 | if err != nil { | |
| 1151 | - | return | |
| 1161 | + | return fmt.Errorf("refresh agent prompt and tools: %w", err) | |
| 1152 | 1162 | } | |
| 1153 | 1163 | if sharedSandbox != nil { | |
| 1154 | 1164 | resolved.UseSandbox(sharedSandbox) | |
| @@ -1160,24 +1170,34 @@ func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extTool | |||
| 1160 | 1170 | if mutateRegistry != nil { | |
| 1161 | 1171 | reg = mutateRegistry(reg) | |
| 1162 | 1172 | } | |
| 1163 | - | oldTools := ag.Tools | |
| 1164 | - | ag.SetTools(reg) | |
| 1165 | - | ag.System = resolved.SystemPrompt | |
| 1173 | + | if interactive != nil { | |
| 1174 | + | oldTools, applied := interactive.ApplyAgentPromptConfig(ag, resolved.SystemPrompt, reg) | |
| 1175 | + | if !applied { | |
| 1176 | + | _ = tools.CloseLSPManagers(reg) | |
| 1177 | + | return errInteractiveAgentChanged | |
| 1178 | + | } | |
| 1179 | + | interactive.DeferUntilIdle(func() { | |
| 1180 | + | _ = tools.CloseLSPManagers(oldTools) | |
| 1181 | + | }) | |
| 1182 | + | return nil | |
| 1183 | + | } | |
| 1184 | + | oldTools := ag.SetPromptConfig(resolved.SystemPrompt, reg) | |
| 1166 | 1185 | _ = tools.CloseLSPManagers(oldTools) | |
| 1186 | + | return nil | |
| 1167 | 1187 | } | |
| 1168 | 1188 | ||
| 1169 | 1189 | // reloadResourcesAfterStartupPre reloads extensions (if any) and | |
| 1170 | 1190 | // refreshes the agent tool registry + system prompt so skills/extensions | |
| 1171 | 1191 | // installed by entry.pre are visible to the following turn. | |
| 1172 | - | func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) { | |
| 1192 | + | func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) error { | |
| 1173 | 1193 | if strings.TrimSpace(args.StartupPre) == "" || ag == nil { | |
| 1174 | - | return | |
| 1194 | + | return nil | |
| 1175 | 1195 | } | |
| 1176 | 1196 | adapter := &extToolAdapter{mgr: extMgr} | |
| 1177 | 1197 | if extMgr != nil { | |
| 1178 | 1198 | _ = extMgr.Reload(ctx, 2*time.Second) | |
| 1179 | 1199 | } | |
| 1180 | - | refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, nil) | |
| 1200 | + | return refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, nil, nil) | |
| 1181 | 1201 | } | |
| 1182 | 1202 | ||
| 1183 | 1203 | // ---- interactive mode: opens the TUI even without credentials ---- | |
| @@ -1416,7 +1436,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1416 | 1436 | } | |
| 1417 | 1437 | if confirmGate != nil { | |
| 1418 | 1438 | var content strings.Builder | |
| 1419 | - | if tool, err := a.Tools.Get(call.Name); err == nil { | |
| 1439 | + | _, currentTools := a.PromptConfig() | |
| 1440 | + | if tool, err := currentTools.Get(call.Name); err == nil { | |
| 1420 | 1441 | if previewer, ok := tool.(core.ToolPreviewer); ok { | |
| 1421 | 1442 | preview, err := previewer.Preview(ctx, effectiveArgs) | |
| 1422 | 1443 | if err != nil { | |
| @@ -1544,7 +1565,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1544 | 1565 | if current == nil { | |
| 1545 | 1566 | return | |
| 1546 | 1567 | } | |
| 1547 | - | refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn) | |
| 1568 | + | if err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv); err != nil && iv != nil { | |
| 1569 | + | iv.ReportError(err) | |
| 1570 | + | } | |
| 1548 | 1571 | }) | |
| 1549 | 1572 | ||
| 1550 | 1573 | var sess *core.Session | |
| @@ -2059,6 +2082,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 2059 | 2082 | TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled, | |
| 2060 | 2083 | TerminalTitleEnabled: initialCfg.TerminalTitleEnabled, | |
| 2061 | 2084 | AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled, | |
| 2085 | + | PonytailEnabled: initialCfg.PonytailEnabled, | |
| 2062 | 2086 | AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession, | |
| 2063 | 2087 | FastMode: &fastMode, | |
| 2064 | 2088 | LSPEnabled: initialCfg.LSPEnabled, | |
| @@ -2117,12 +2141,18 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 2117 | 2141 | _ = extMgr.Reload(context.Background(), 2*time.Second) | |
| 2118 | 2142 | } | |
| 2119 | 2143 | if current != nil { | |
| 2120 | - | refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn) | |
| 2144 | + | if err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv); err != nil { | |
| 2145 | + | iv.ReportError(err) | |
| 2146 | + | } | |
| 2121 | 2147 | } | |
| 2122 | 2148 | }, | |
| 2123 | - | RefreshTools: func() { | |
| 2149 | + | RefreshTools: func() error { | |
| 2150 | + | current := liveInteractiveAgent(iv, ag) | |
| 2151 | + | return refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv) | |
| 2152 | + | }, | |
| 2153 | + | RefreshPrompt: func() error { | |
| 2124 | 2154 | current := liveInteractiveAgent(iv, ag) | |
| 2125 | - | refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn) | |
| 2155 | + | return refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv) | |
| 2126 | 2156 | }, | |
| 2127 | 2157 | AuthManager: mgr, | |
| 2128 | 2158 | LlamaCPPConfig: ResolveLlamaCPPConfig, | |
| @@ -322,7 +322,7 @@ func newPersistModelCallback(persistMu *sync.Mutex, sess **core.Session, activeP | |||||
| 322 | 322 | ||||
| 323 | func closeAgentLSP(ag *core.Agent) { | 323 | func closeAgentLSP(ag *core.Agent) { | ||
| 324 | if ag != nil { | 324 | if ag != nil { | ||
| 325 | - | _ = tools.CloseLSPManagers(ag.Tools) | 325 | + | _ = tools.CloseLSPManagers(ag.ToolsSnapshot()) |
| 326 | } | 326 | } | ||
| 327 | } | 327 | } | ||
| 328 | 328 | ||||
| @@ -903,7 +903,9 @@ func runPrintMode(ctx context.Context, args Args, version string) error { | |||||
| 903 | if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, nil, os.Stderr); err != nil { | 903 | if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, nil, os.Stderr); err != nil { | ||
| 904 | return err | 904 | return err | ||
| 905 | } | 905 | } | ||
| 906 | - | reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag) | 906 | + | if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil { |
| 907 | + | return err | |||
| 908 | + | } | |||
| 907 | started := time.Now() | 909 | started := time.Now() | ||
| 908 | usage, err := modes.RunPrint(ctx, ag, prompt, nil, os.Stdout) | 910 | usage, err := modes.RunPrint(ctx, ag, prompt, nil, os.Stdout) | ||
| 909 | elapsed := time.Since(started) | 911 | elapsed := time.Since(started) | ||
| @@ -969,7 +971,9 @@ func runStreamMode(ctx context.Context, args Args, version string) error { | |||||
| 969 | return err | 971 | return err | ||
| 970 | } | 972 | } | ||
| 971 | finishPre() | 973 | finishPre() | ||
| 972 | - | reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag) | 974 | + | if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil { |
| 975 | + | return err | |||
| 976 | + | } | |||
| 973 | err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout) | 977 | err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout) | ||
| 974 | WriteNewTranscript(ag, sess, start) | 978 | WriteNewTranscript(ag, sess, start) | ||
| 975 | return err | 979 | return err | ||
| @@ -1073,7 +1077,10 @@ func runJSONMode(ctx context.Context, args Args, version string) error { | |||||
| 1073 | _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()}) | 1077 | _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()}) | ||
| 1074 | return err | 1078 | return err | ||
| 1075 | } | 1079 | } | ||
| 1076 | - | reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag) | 1080 | + | if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil { |
| 1081 | + | _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()}) | |||
| 1082 | + | return err | |||
| 1083 | + | } | |||
| 1077 | err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout) | 1084 | err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout) | ||
| 1078 | WriteNewTranscript(ag, sess, start) | 1085 | WriteNewTranscript(ag, sess, start) | ||
| 1079 | return err | 1086 | return err | ||
| @@ -1137,18 +1144,21 @@ func runZotfileStartupPre(ctx context.Context, pre, cwd string, sandbox *tools.S | |||||
| 1137 | return ag.Prompt(ctx, pre, nil, sink) | 1144 | return ag.Prompt(ctx, pre, nil, sink) | ||
| 1138 | } | 1145 | } | ||
| 1139 | 1146 | ||||
| 1147 | + | var errInteractiveAgentChanged = errors.New("interactive agent changed during prompt refresh") | |||
| 1148 | + | ||||
| 1140 | // refreshAgentToolsAndPrompt re-resolves tools (including rediscovered | 1149 | // refreshAgentToolsAndPrompt re-resolves tools (including rediscovered | ||
| 1141 | // skills and currently loaded extension tools) and updates the live | 1150 | // skills and currently loaded extension tools) and updates the live | ||
| 1142 | // agent's registry and system prompt. Used after /reload-ext and after | 1151 | // agent's registry and system prompt. Used after /reload-ext and after | ||
| 1143 | // zotfile entry.pre installs new skills or extensions. | 1152 | // zotfile entry.pre installs new skills or extensions. | ||
| 1144 | // mutateRegistry, if non-nil, can inject session-specific tools (e.g. subagent_spawn). | 1153 | // mutateRegistry, if non-nil, can inject session-specific tools (e.g. subagent_spawn). | ||
| 1145 | - | func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry) { | 1154 | + | // interactive, when non-nil, serializes the final commit with agent replacement. |
| 1155 | + | func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry, interactive *modes.Interactive) error { | |||
| 1146 | if ag == nil { | 1156 | if ag == nil { | ||
| 1147 | - | return | 1157 | + | return nil |
| 1148 | } | 1158 | } | ||
| 1149 | resolved, err := Resolve(args, true) | 1159 | resolved, err := Resolve(args, true) | ||
| 1150 | if err != nil { | 1160 | if err != nil { | ||
| 1151 | - | return | 1161 | + | return fmt.Errorf("refresh agent prompt and tools: %w", err) |
| 1152 | } | 1162 | } | ||
| 1153 | if sharedSandbox != nil { | 1163 | if sharedSandbox != nil { | ||
| 1154 | resolved.UseSandbox(sharedSandbox) | 1164 | resolved.UseSandbox(sharedSandbox) | ||
| @@ -1160,24 +1170,34 @@ func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extTool | |||||
| 1160 | if mutateRegistry != nil { | 1170 | if mutateRegistry != nil { | ||
| 1161 | reg = mutateRegistry(reg) | 1171 | reg = mutateRegistry(reg) | ||
| 1162 | } | 1172 | } | ||
| 1163 | - | oldTools := ag.Tools | 1173 | + | if interactive != nil { |
| 1164 | - | ag.SetTools(reg) | 1174 | + | oldTools, applied := interactive.ApplyAgentPromptConfig(ag, resolved.SystemPrompt, reg) |
| 1165 | - | ag.System = resolved.SystemPrompt | 1175 | + | if !applied { |
| 1176 | + | _ = tools.CloseLSPManagers(reg) | |||
| 1177 | + | return errInteractiveAgentChanged | |||
| 1178 | + | } | |||
| 1179 | + | interactive.DeferUntilIdle(func() { | |||
| 1180 | + | _ = tools.CloseLSPManagers(oldTools) | |||
| 1181 | + | }) | |||
| 1182 | + | return nil | |||
| 1183 | + | } | |||
| 1184 | + | oldTools := ag.SetPromptConfig(resolved.SystemPrompt, reg) | |||
| 1166 | _ = tools.CloseLSPManagers(oldTools) | 1185 | _ = tools.CloseLSPManagers(oldTools) | ||
| 1186 | + | return nil | |||
| 1167 | } | 1187 | } | ||
| 1168 | 1188 | ||||
| 1169 | // reloadResourcesAfterStartupPre reloads extensions (if any) and | 1189 | // reloadResourcesAfterStartupPre reloads extensions (if any) and | ||
| 1170 | // refreshes the agent tool registry + system prompt so skills/extensions | 1190 | // refreshes the agent tool registry + system prompt so skills/extensions | ||
| 1171 | // installed by entry.pre are visible to the following turn. | 1191 | // installed by entry.pre are visible to the following turn. | ||
| 1172 | - | func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) { | 1192 | + | func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) error { |
| 1173 | if strings.TrimSpace(args.StartupPre) == "" || ag == nil { | 1193 | if strings.TrimSpace(args.StartupPre) == "" || ag == nil { | ||
| 1174 | - | return | 1194 | + | return nil |
| 1175 | } | 1195 | } | ||
| 1176 | adapter := &extToolAdapter{mgr: extMgr} | 1196 | adapter := &extToolAdapter{mgr: extMgr} | ||
| 1177 | if extMgr != nil { | 1197 | if extMgr != nil { | ||
| 1178 | _ = extMgr.Reload(ctx, 2*time.Second) | 1198 | _ = extMgr.Reload(ctx, 2*time.Second) | ||
| 1179 | } | 1199 | } | ||
| 1180 | - | refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, nil) | 1200 | + | return refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, nil, nil) |
| 1181 | } | 1201 | } | ||
| 1182 | 1202 | ||||
| 1183 | // ---- interactive mode: opens the TUI even without credentials ---- | 1203 | // ---- interactive mode: opens the TUI even without credentials ---- | ||
| @@ -1416,7 +1436,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1416 | } | 1436 | } | ||
| 1417 | if confirmGate != nil { | 1437 | if confirmGate != nil { | ||
| 1418 | var content strings.Builder | 1438 | var content strings.Builder | ||
| 1419 | - | if tool, err := a.Tools.Get(call.Name); err == nil { | 1439 | + | _, currentTools := a.PromptConfig() |
| 1440 | + | if tool, err := currentTools.Get(call.Name); err == nil { | |||
| 1420 | if previewer, ok := tool.(core.ToolPreviewer); ok { | 1441 | if previewer, ok := tool.(core.ToolPreviewer); ok { | ||
| 1421 | preview, err := previewer.Preview(ctx, effectiveArgs) | 1442 | preview, err := previewer.Preview(ctx, effectiveArgs) | ||
| 1422 | if err != nil { | 1443 | if err != nil { | ||
| @@ -1544,7 +1565,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1544 | if current == nil { | 1565 | if current == nil { | ||
| 1545 | return | 1566 | return | ||
| 1546 | } | 1567 | } | ||
| 1547 | - | refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn) | 1568 | + | if err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv); err != nil && iv != nil { |
| 1569 | + | iv.ReportError(err) | |||
| 1570 | + | } | |||
| 1548 | }) | 1571 | }) | ||
| 1549 | 1572 | ||||
| 1550 | var sess *core.Session | 1573 | var sess *core.Session | ||
| @@ -2059,6 +2082,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 2059 | TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled, | 2082 | TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled, | ||
| 2060 | TerminalTitleEnabled: initialCfg.TerminalTitleEnabled, | 2083 | TerminalTitleEnabled: initialCfg.TerminalTitleEnabled, | ||
| 2061 | AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled, | 2084 | AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled, | ||
| 2085 | + | PonytailEnabled: initialCfg.PonytailEnabled, | |||
| 2062 | AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession, | 2086 | AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession, | ||
| 2063 | FastMode: &fastMode, | 2087 | FastMode: &fastMode, | ||
| 2064 | LSPEnabled: initialCfg.LSPEnabled, | 2088 | LSPEnabled: initialCfg.LSPEnabled, | ||
| @@ -2117,12 +2141,18 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 2117 | _ = extMgr.Reload(context.Background(), 2*time.Second) | 2141 | _ = extMgr.Reload(context.Background(), 2*time.Second) | ||
| 2118 | } | 2142 | } | ||
| 2119 | if current != nil { | 2143 | if current != nil { | ||
| 2120 | - | refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn) | 2144 | + | if err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv); err != nil { |
| 2145 | + | iv.ReportError(err) | |||
| 2146 | + | } | |||
| 2121 | } | 2147 | } | ||
| 2122 | }, | 2148 | }, | ||
| 2123 | - | RefreshTools: func() { | 2149 | + | RefreshTools: func() error { |
| 2150 | + | current := liveInteractiveAgent(iv, ag) | |||
| 2151 | + | return refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv) | |||
| 2152 | + | }, | |||
| 2153 | + | RefreshPrompt: func() error { | |||
| 2124 | current := liveInteractiveAgent(iv, ag) | 2154 | current := liveInteractiveAgent(iv, ag) | ||
| 2125 | - | refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn) | 2155 | + | return refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv) |
| 2126 | }, | 2156 | }, | ||
| 2127 | AuthManager: mgr, | 2157 | AuthManager: mgr, | ||
| 2128 | LlamaCPPConfig: ResolveLlamaCPPConfig, | 2158 | LlamaCPPConfig: ResolveLlamaCPPConfig, | ||
M packages/agent/config.go +10 -0
| @@ -95,6 +95,10 @@ type Config struct { | |||
| 95 | 95 | // default; nil/missing means disabled. Toggle from /settings. | |
| 96 | 96 | AutoSubagentsEnabled *bool `json:"auto_subagents_enabled,omitempty"` | |
| 97 | 97 | ||
| 98 | + | // PonytailEnabled controls the compact coding-guidance addendum. On by | |
| 99 | + | // default; nil/missing means enabled. Toggle from /settings. | |
| 100 | + | PonytailEnabled *bool `json:"ponytail_enabled,omitempty"` | |
| 101 | + | ||
| 98 | 102 | // LSPEnabled controls the built-in lsp tool and write-time diagnostics | |
| 99 | 103 | // for the main session. nil/missing means enabled. Toggle from | |
| 100 | 104 | // /settings. | |
| @@ -246,6 +250,12 @@ func (c Config) LSPEnabledFor(subagent bool) bool { | |||
| 246 | 250 | return value == nil || *value | |
| 247 | 251 | } | |
| 248 | 252 | ||
| 253 | + | // PonytailModeEnabled reports the default-on coding-guidance preference | |
| 254 | + | // without requiring a generated config entry. | |
| 255 | + | func (c Config) PonytailModeEnabled() bool { | |
| 256 | + | return c.PonytailEnabled == nil || *c.PonytailEnabled | |
| 257 | + | } | |
| 258 | + | ||
| 249 | 259 | // LSPDiagnosticsOnWriteEnabled reports the default-on write diagnostics | |
| 250 | 260 | // preference without requiring a generated config entry. | |
| 251 | 261 | func (c Config) LSPDiagnosticsOnWriteEnabled(subagent bool) bool { | |
| @@ -95,6 +95,10 @@ type Config struct { | |||||
| 95 | // default; nil/missing means disabled. Toggle from /settings. | 95 | // default; nil/missing means disabled. Toggle from /settings. | ||
| 96 | AutoSubagentsEnabled *bool `json:"auto_subagents_enabled,omitempty"` | 96 | AutoSubagentsEnabled *bool `json:"auto_subagents_enabled,omitempty"` | ||
| 97 | 97 | ||||
| 98 | + | // PonytailEnabled controls the compact coding-guidance addendum. On by | |||
| 99 | + | // default; nil/missing means enabled. Toggle from /settings. | |||
| 100 | + | PonytailEnabled *bool `json:"ponytail_enabled,omitempty"` | |||
| 101 | + | ||||
| 98 | // LSPEnabled controls the built-in lsp tool and write-time diagnostics | 102 | // LSPEnabled controls the built-in lsp tool and write-time diagnostics | ||
| 99 | // for the main session. nil/missing means enabled. Toggle from | 103 | // for the main session. nil/missing means enabled. Toggle from | ||
| 100 | // /settings. | 104 | // /settings. | ||
| @@ -246,6 +250,12 @@ func (c Config) LSPEnabledFor(subagent bool) bool { | |||||
| 246 | return value == nil || *value | 250 | return value == nil || *value | ||
| 247 | } | 251 | } | ||
| 248 | 252 | ||||
| 253 | + | // PonytailModeEnabled reports the default-on coding-guidance preference | |||
| 254 | + | // without requiring a generated config entry. | |||
| 255 | + | func (c Config) PonytailModeEnabled() bool { | |||
| 256 | + | return c.PonytailEnabled == nil || *c.PonytailEnabled | |||
| 257 | + | } | |||
| 258 | + | ||||
| 249 | // LSPDiagnosticsOnWriteEnabled reports the default-on write diagnostics | 259 | // LSPDiagnosticsOnWriteEnabled reports the default-on write diagnostics | ||
| 250 | // preference without requiring a generated config entry. | 260 | // preference without requiring a generated config entry. | ||
| 251 | func (c Config) LSPDiagnosticsOnWriteEnabled(subagent bool) bool { | 261 | func (c Config) LSPDiagnosticsOnWriteEnabled(subagent bool) bool { | ||
M packages/agent/modes/btw_dialog.go +1 -1
| @@ -272,7 +272,7 @@ func (d *btwDialog) submit(invalidate func()) { | |||
| 272 | 272 | } | |
| 273 | 273 | ||
| 274 | 274 | func newBtwAgent(main *core.Agent, system, model string) *core.Agent { | |
| 275 | - | agent := core.NewAgent(main.Client, model, system, main.Tools) | |
| 275 | + | agent := core.NewAgent(main.Client, model, system, main.ToolsSnapshot()) | |
| 276 | 276 | agent.MaxSteps = main.MaxSteps | |
| 277 | 277 | agent.Reasoning = main.Reasoning | |
| 278 | 278 | agent.Temperature = main.Temperature | |
| @@ -272,7 +272,7 @@ func (d *btwDialog) submit(invalidate func()) { | |||||
| 272 | } | 272 | } | ||
| 273 | 273 | ||||
| 274 | func newBtwAgent(main *core.Agent, system, model string) *core.Agent { | 274 | func newBtwAgent(main *core.Agent, system, model string) *core.Agent { | ||
| 275 | - | agent := core.NewAgent(main.Client, model, system, main.Tools) | 275 | + | agent := core.NewAgent(main.Client, model, system, main.ToolsSnapshot()) |
| 276 | agent.MaxSteps = main.MaxSteps | 276 | agent.MaxSteps = main.MaxSteps | ||
| 277 | agent.Reasoning = main.Reasoning | 277 | agent.Reasoning = main.Reasoning | ||
| 278 | agent.Temperature = main.Temperature | 278 | agent.Temperature = main.Temperature | ||
A packages/agent/modes/idle_work_test.go +31 -0
| @@ -0,0 +1,31 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestDeferUntilIdleRunsImmediatelyWhenIdle(t *testing.T) { | |
| 6 | + | interactive := &Interactive{} | |
| 7 | + | ran := false | |
| 8 | + | interactive.DeferUntilIdle(func() { ran = true }) | |
| 9 | + | if !ran { | |
| 10 | + | t.Fatal("idle callback did not run immediately") | |
| 11 | + | } | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | func TestDeferUntilIdleQueuesWhileBusy(t *testing.T) { | |
| 15 | + | interactive := &Interactive{} | |
| 16 | + | interactive.busy = true | |
| 17 | + | ran := false | |
| 18 | + | interactive.DeferUntilIdle(func() { ran = true }) | |
| 19 | + | if ran { | |
| 20 | + | t.Fatal("busy callback ran before the operation became idle") | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | interactive.mu.Lock() | |
| 24 | + | interactive.busy = false | |
| 25 | + | work := interactive.takePendingIdleWorkLocked() | |
| 26 | + | interactive.mu.Unlock() | |
| 27 | + | runPendingIdleWork(work) | |
| 28 | + | if !ran { | |
| 29 | + | t.Fatal("queued idle callback did not run after the operation became idle") | |
| 30 | + | } | |
| 31 | + | } | |
| @@ -0,0 +1,31 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestDeferUntilIdleRunsImmediatelyWhenIdle(t *testing.T) { | |||
| 6 | + | interactive := &Interactive{} | |||
| 7 | + | ran := false | |||
| 8 | + | interactive.DeferUntilIdle(func() { ran = true }) | |||
| 9 | + | if !ran { | |||
| 10 | + | t.Fatal("idle callback did not run immediately") | |||
| 11 | + | } | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | func TestDeferUntilIdleQueuesWhileBusy(t *testing.T) { | |||
| 15 | + | interactive := &Interactive{} | |||
| 16 | + | interactive.busy = true | |||
| 17 | + | ran := false | |||
| 18 | + | interactive.DeferUntilIdle(func() { ran = true }) | |||
| 19 | + | if ran { | |||
| 20 | + | t.Fatal("busy callback ran before the operation became idle") | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | interactive.mu.Lock() | |||
| 24 | + | interactive.busy = false | |||
| 25 | + | work := interactive.takePendingIdleWorkLocked() | |||
| 26 | + | interactive.mu.Unlock() | |||
| 27 | + | runPendingIdleWork(work) | |||
| 28 | + | if !ran { | |||
| 29 | + | t.Fatal("queued idle callback did not run after the operation became idle") | |||
| 30 | + | } | |||
| 31 | + | } | |||
M packages/agent/modes/interactive.go +179 -3
| @@ -72,6 +72,10 @@ type InteractiveConfig struct { | |||
| 72 | 72 | // re-reading config.json on every open. | |
| 73 | 73 | AutoSubagentsEnabled *bool | |
| 74 | 74 | ||
| 75 | + | // PonytailEnabled mirrors the persisted default-on coding-guidance | |
| 76 | + | // setting at startup. nil/missing means enabled. | |
| 77 | + | PonytailEnabled *bool | |
| 78 | + | ||
| 75 | 79 | // AutoSubagentsToolAllowed is nil for normal sessions and false when the | |
| 76 | 80 | // launch-time --no-tools/--tools policy excludes delegation. | |
| 77 | 81 | AutoSubagentsToolAllowed *bool | |
| @@ -173,7 +177,11 @@ type InteractiveConfig struct { | |||
| 173 | 177 | ||
| 174 | 178 | // RefreshTools re-resolves the live agent registry after a setting | |
| 175 | 179 | // changes the main session's tool availability. Optional for embedders. | |
| 176 | - | RefreshTools func() | |
| 180 | + | RefreshTools func() error | |
| 181 | + | ||
| 182 | + | // RefreshPrompt re-resolves the complete live prompt and tool registry | |
| 183 | + | // after a prompt-affecting setting changes. Optional for embedders. | |
| 184 | + | RefreshPrompt func() error | |
| 177 | 185 | ||
| 178 | 186 | // AutoSubmitInitial, when true, auto-submits InitialInput after | |
| 179 | 187 | // StartupPre completes (or immediately when StartupPre is empty). | |
| @@ -450,6 +458,10 @@ type fastModeSettingsStore interface { | |||
| 450 | 458 | SetFastMode(enabled bool) error | |
| 451 | 459 | } | |
| 452 | 460 | ||
| 461 | + | type ponytailSettingsStore interface { | |
| 462 | + | SetPonytailEnabled(enabled bool) error | |
| 463 | + | } | |
| 464 | + | ||
| 453 | 465 | type Interactive struct { | |
| 454 | 466 | cfg InteractiveConfig | |
| 455 | 467 | view *tui.View | |
| @@ -507,6 +519,7 @@ type Interactive struct { | |||
| 507 | 519 | cumUsage provider.Usage | |
| 508 | 520 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | |
| 509 | 521 | busy bool | |
| 522 | + | pendingIdleWork []func() | |
| 510 | 523 | dirty chan struct{} | |
| 511 | 524 | modelRefresh chan modelRefreshResult | |
| 512 | 525 | modelRefreshing bool | |
| @@ -3650,6 +3663,18 @@ func (i *Interactive) Display(extName, text string) { | |||
| 3650 | 3663 | i.invalidate() | |
| 3651 | 3664 | } | |
| 3652 | 3665 | ||
| 3666 | + | // ReportError surfaces a host-side error in the interactive status area. | |
| 3667 | + | func (i *Interactive) ReportError(err error) { | |
| 3668 | + | if err == nil { | |
| 3669 | + | return | |
| 3670 | + | } | |
| 3671 | + | i.mu.Lock() | |
| 3672 | + | i.statusOK = "" | |
| 3673 | + | i.statusErr = err.Error() | |
| 3674 | + | i.mu.Unlock() | |
| 3675 | + | i.invalidate() | |
| 3676 | + | } | |
| 3677 | + | ||
| 3653 | 3678 | // SetStatus replaces one persistent status item owned by an extension. | |
| 3654 | 3679 | func (i *Interactive) SetStatus(extName, key, level, text string) { | |
| 3655 | 3680 | if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" { | |
| @@ -3866,6 +3891,13 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3866 | 3891 | autoSubagents = false | |
| 3867 | 3892 | } | |
| 3868 | 3893 | ||
| 3894 | + | ponytailEnabled := i.ponytailEnabled() | |
| 3895 | + | ponytailDisabled := !i.ponytailSettingsAvailable() | |
| 3896 | + | ponytailHint := "" | |
| 3897 | + | if ponytailDisabled { | |
| 3898 | + | ponytailHint = "requires persistent settings and live prompt refresh support" | |
| 3899 | + | } | |
| 3900 | + | ||
| 3869 | 3901 | fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode | |
| 3870 | 3902 | fastModeHint := "OpenAI service tier" | |
| 3871 | 3903 | if !provider.SupportsFastMode(i.cfg.Provider) { | |
| @@ -3992,6 +4024,14 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3992 | 4024 | disabled: autoSubagentsDisabled, | |
| 3993 | 4025 | hint: autoSubagentsHint, | |
| 3994 | 4026 | }, | |
| 4027 | + | { | |
| 4028 | + | key: "ponytail_enabled", | |
| 4029 | + | label: "ponytail coding mode", | |
| 4030 | + | desc: "apply compact coding guidance that favors small, validated, maintainable changes", | |
| 4031 | + | value: ponytailEnabled, | |
| 4032 | + | disabled: ponytailDisabled, | |
| 4033 | + | hint: ponytailHint, | |
| 4034 | + | }, | |
| 3995 | 4035 | { | |
| 3996 | 4036 | key: "fast_mode", | |
| 3997 | 4037 | label: "fast mode", | |
| @@ -4321,6 +4361,15 @@ func (i *Interactive) resetSettingsToggle(key string, value bool) { | |||
| 4321 | 4361 | } | |
| 4322 | 4362 | } | |
| 4323 | 4363 | ||
| 4364 | + | func (i *Interactive) ponytailEnabled() bool { | |
| 4365 | + | return i.cfg.PonytailEnabled == nil || *i.cfg.PonytailEnabled | |
| 4366 | + | } | |
| 4367 | + | ||
| 4368 | + | func (i *Interactive) ponytailSettingsAvailable() bool { | |
| 4369 | + | _, persistent := i.cfg.SettingsStore.(ponytailSettingsStore) | |
| 4370 | + | return persistent && i.cfg.RefreshPrompt != nil | |
| 4371 | + | } | |
| 4372 | + | ||
| 4324 | 4373 | func (i *Interactive) applySettingToggle(key string, value bool) { | |
| 4325 | 4374 | // Every setting toggle forces a full repaint at the end — same | |
| 4326 | 4375 | // effect as the user pressing Ctrl+L — so any per-setting visual | |
| @@ -4396,6 +4445,56 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 4396 | 4445 | i.statusOK = "AI terminal titles " + onOff(value) | |
| 4397 | 4446 | i.statusErr = "" | |
| 4398 | 4447 | i.mu.Unlock() | |
| 4448 | + | case "ponytail_enabled": | |
| 4449 | + | previous := i.ponytailEnabled() | |
| 4450 | + | store, available := i.cfg.SettingsStore.(ponytailSettingsStore) | |
| 4451 | + | if !available || i.cfg.RefreshPrompt == nil { | |
| 4452 | + | i.resetSettingsToggle(key, previous) | |
| 4453 | + | i.mu.Lock() | |
| 4454 | + | i.statusOK = "" | |
| 4455 | + | i.statusErr = "ponytail coding mode unavailable: persistent settings and live prompt refresh are required" | |
| 4456 | + | i.mu.Unlock() | |
| 4457 | + | return | |
| 4458 | + | } | |
| 4459 | + | if err := store.SetPonytailEnabled(value); err != nil { | |
| 4460 | + | i.resetSettingsToggle(key, previous) | |
| 4461 | + | i.mu.Lock() | |
| 4462 | + | i.statusOK = "" | |
| 4463 | + | i.statusErr = "settings: " + err.Error() | |
| 4464 | + | i.mu.Unlock() | |
| 4465 | + | return | |
| 4466 | + | } | |
| 4467 | + | val := value | |
| 4468 | + | i.cfg.PonytailEnabled = &val | |
| 4469 | + | if err := i.cfg.RefreshPrompt(); err != nil { | |
| 4470 | + | errMsg := "settings: ponytail prompt refresh: " + err.Error() | |
| 4471 | + | if rollbackErr := store.SetPonytailEnabled(previous); rollbackErr != nil { | |
| 4472 | + | // The first write succeeded, so keep the in-memory setting at | |
| 4473 | + | // the value that remains durable instead of claiming that the | |
| 4474 | + | // rollback took effect. | |
| 4475 | + | i.resetSettingsToggle(key, value) | |
| 4476 | + | errMsg += "; rollback persistence: " + rollbackErr.Error() | |
| 4477 | + | if reconcileErr := i.cfg.RefreshPrompt(); reconcileErr != nil { | |
| 4478 | + | errMsg += "; durable-state refresh: " + reconcileErr.Error() | |
| 4479 | + | } | |
| 4480 | + | } else { | |
| 4481 | + | previousVal := previous | |
| 4482 | + | i.cfg.PonytailEnabled = &previousVal | |
| 4483 | + | i.resetSettingsToggle(key, previous) | |
| 4484 | + | if refreshErr := i.cfg.RefreshPrompt(); refreshErr != nil { | |
| 4485 | + | errMsg += "; rollback refresh: " + refreshErr.Error() | |
| 4486 | + | } | |
| 4487 | + | } | |
| 4488 | + | i.mu.Lock() | |
| 4489 | + | i.statusOK = "" | |
| 4490 | + | i.statusErr = errMsg | |
| 4491 | + | i.mu.Unlock() | |
| 4492 | + | return | |
| 4493 | + | } | |
| 4494 | + | i.mu.Lock() | |
| 4495 | + | i.statusOK = "ponytail coding mode " + onOff(value) | |
| 4496 | + | i.statusErr = "" | |
| 4497 | + | i.mu.Unlock() | |
| 4399 | 4498 | case "auto_subagents_enabled": | |
| 4400 | 4499 | if value && !i.autoSubagentsAvailable() { | |
| 4401 | 4500 | i.mu.Lock() | |
| @@ -4460,7 +4559,9 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 4460 | 4559 | i.statusErr = "" | |
| 4461 | 4560 | i.mu.Unlock() | |
| 4462 | 4561 | case "lsp_enabled": | |
| 4463 | - | if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok { | |
| 4562 | + | previous := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled | |
| 4563 | + | store, hasStore := i.cfg.SettingsStore.(lspSettingsStore) | |
| 4564 | + | if hasStore { | |
| 4464 | 4565 | if err := store.SetLSPEnabled(value); err != nil { | |
| 4465 | 4566 | i.mu.Lock() | |
| 4466 | 4567 | i.statusErr = "settings: " + err.Error() | |
| @@ -4471,7 +4572,23 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 4471 | 4572 | val := value | |
| 4472 | 4573 | i.cfg.LSPEnabled = &val | |
| 4473 | 4574 | if i.cfg.RefreshTools != nil { | |
| 4474 | - | i.cfg.RefreshTools() | |
| 4575 | + | if err := i.cfg.RefreshTools(); err != nil { | |
| 4576 | + | rollbackErr := error(nil) | |
| 4577 | + | if hasStore { | |
| 4578 | + | rollbackErr = store.SetLSPEnabled(previous) | |
| 4579 | + | } | |
| 4580 | + | previousVal := previous | |
| 4581 | + | i.cfg.LSPEnabled = &previousVal | |
| 4582 | + | errMsg := "settings: refresh tools: " + err.Error() | |
| 4583 | + | if rollbackErr != nil { | |
| 4584 | + | errMsg += "; rollback persistence: " + rollbackErr.Error() | |
| 4585 | + | } | |
| 4586 | + | i.mu.Lock() | |
| 4587 | + | i.statusOK = "" | |
| 4588 | + | i.statusErr = errMsg | |
| 4589 | + | i.mu.Unlock() | |
| 4590 | + | return | |
| 4591 | + | } | |
| 4475 | 4592 | } | |
| 4476 | 4593 | i.mu.Lock() | |
| 4477 | 4594 | i.statusOK = "lsp in main session " + onOff(value) | |
| @@ -6057,7 +6174,9 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) { | |||
| 6057 | 6174 | if ag == nil { | |
| 6058 | 6175 | i.mu.Lock() | |
| 6059 | 6176 | i.busy = false | |
| 6177 | + | pendingIdleWork := i.takePendingIdleWorkLocked() | |
| 6060 | 6178 | i.mu.Unlock() | |
| 6179 | + | runPendingIdleWork(pendingIdleWork) | |
| 6061 | 6180 | i.invalidate() | |
| 6062 | 6181 | return | |
| 6063 | 6182 | } | |
| @@ -6126,6 +6245,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||
| 6126 | 6245 | i.resetStreamingStateLocked() | |
| 6127 | 6246 | i.cancelTurn = nil | |
| 6128 | 6247 | i.autoCompacting = false | |
| 6248 | + | pendingIdleWork := i.takePendingIdleWorkLocked() | |
| 6129 | 6249 | ||
| 6130 | 6250 | // Drain pending work after the transcript is clean. Overflow recovery | |
| 6131 | 6251 | // continues the user message already in the transcript. Pre-turn | |
| @@ -6210,6 +6330,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||
| 6210 | 6330 | i.busy = true | |
| 6211 | 6331 | } | |
| 6212 | 6332 | i.mu.Unlock() | |
| 6333 | + | runPendingIdleWork(pendingIdleWork) | |
| 6213 | 6334 | i.invalidate() | |
| 6214 | 6335 | ||
| 6215 | 6336 | if hasNext || continueExisting || continueAutomatically { | |
| @@ -6333,6 +6454,7 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||
| 6333 | 6454 | i.shellLive = "" | |
| 6334 | 6455 | i.busy = false | |
| 6335 | 6456 | i.cancelTurn = nil | |
| 6457 | + | pendingIdleWork := i.takePendingIdleWorkLocked() | |
| 6336 | 6458 | awaitingPre := i.awaitingStartupPre | |
| 6337 | 6459 | if failed { | |
| 6338 | 6460 | if cancelled { | |
| @@ -6346,6 +6468,7 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||
| 6346 | 6468 | i.statusErr = "" | |
| 6347 | 6469 | } | |
| 6348 | 6470 | i.mu.Unlock() | |
| 6471 | + | runPendingIdleWork(pendingIdleWork) | |
| 6349 | 6472 | i.invalidate() | |
| 6350 | 6473 | if awaitingPre { | |
| 6351 | 6474 | i.completeStartupPre() | |
| @@ -6472,6 +6595,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 6472 | 6595 | i.streamOn = false | |
| 6473 | 6596 | } | |
| 6474 | 6597 | i.cancelTurn = nil | |
| 6598 | + | pendingIdleWork := i.takePendingIdleWorkLocked() | |
| 6475 | 6599 | if err != nil && ctx.Err() == nil { | |
| 6476 | 6600 | i.statusErr = err.Error() | |
| 6477 | 6601 | } | |
| @@ -6523,6 +6647,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 6523 | 6647 | // baseline so subsequent flushes only write new rows). | |
| 6524 | 6648 | flush := i.cfg.FlushSession | |
| 6525 | 6649 | i.mu.Unlock() | |
| 6650 | + | runPendingIdleWork(pendingIdleWork) | |
| 6526 | 6651 | if flush != nil { | |
| 6527 | 6652 | flush() | |
| 6528 | 6653 | } | |
| @@ -6909,6 +7034,57 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 6909 | 7034 | } | |
| 6910 | 7035 | } | |
| 6911 | 7036 | ||
| 7037 | + | // ApplyAgentPromptConfig commits a resolved prompt and tool registry only if | |
| 7038 | + | // ag is still the live interactive agent. It serializes the identity check | |
| 7039 | + | // with agent replacement and swaps both values atomically in core.Agent. | |
| 7040 | + | func (i *Interactive) ApplyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry) (core.Registry, bool) { | |
| 7041 | + | if ag == nil { | |
| 7042 | + | return nil, false | |
| 7043 | + | } | |
| 7044 | + | // Match the lock order used by dynamic tool mutations so a refresh | |
| 7045 | + | // cannot race a snapshot-and-replace operation that would reintroduce | |
| 7046 | + | // the old registry after the atomic prompt swap. | |
| 7047 | + | i.agentMu.Lock() | |
| 7048 | + | defer i.agentMu.Unlock() | |
| 7049 | + | i.mu.Lock() | |
| 7050 | + | defer i.mu.Unlock() | |
| 7051 | + | if i.agent != ag { | |
| 7052 | + | return nil, false | |
| 7053 | + | } | |
| 7054 | + | return ag.SetPromptConfig(system, tools), true | |
| 7055 | + | } | |
| 7056 | + | ||
| 7057 | + | // DeferUntilIdle runs work immediately when the interactive agent is idle, | |
| 7058 | + | // or after the current turn/compaction/shell operation releases the busy | |
| 7059 | + | // state. Callbacks run outside i.mu. | |
| 7060 | + | func (i *Interactive) DeferUntilIdle(fn func()) { | |
| 7061 | + | if fn == nil { | |
| 7062 | + | return | |
| 7063 | + | } | |
| 7064 | + | i.mu.Lock() | |
| 7065 | + | if i.busy { | |
| 7066 | + | i.pendingIdleWork = append(i.pendingIdleWork, fn) | |
| 7067 | + | i.mu.Unlock() | |
| 7068 | + | return | |
| 7069 | + | } | |
| 7070 | + | i.mu.Unlock() | |
| 7071 | + | fn() | |
| 7072 | + | } | |
| 7073 | + | ||
| 7074 | + | func (i *Interactive) takePendingIdleWorkLocked() []func() { | |
| 7075 | + | work := i.pendingIdleWork | |
| 7076 | + | i.pendingIdleWork = nil | |
| 7077 | + | return work | |
| 7078 | + | } | |
| 7079 | + | ||
| 7080 | + | func runPendingIdleWork(work []func()) { | |
| 7081 | + | for _, fn := range work { | |
| 7082 | + | if fn != nil { | |
| 7083 | + | fn() | |
| 7084 | + | } | |
| 7085 | + | } | |
| 7086 | + | } | |
| 7087 | + | ||
| 6912 | 7088 | // Agent returns the current agent, if any. Used by cli.go to flush the | |
| 6913 | 7089 | // final transcript to the session file. | |
| 6914 | 7090 | func (i *Interactive) Agent() *core.Agent { | |
| @@ -72,6 +72,10 @@ type InteractiveConfig struct { | |||||
| 72 | // re-reading config.json on every open. | 72 | // re-reading config.json on every open. | ||
| 73 | AutoSubagentsEnabled *bool | 73 | AutoSubagentsEnabled *bool | ||
| 74 | 74 | ||||
| 75 | + | // PonytailEnabled mirrors the persisted default-on coding-guidance | |||
| 76 | + | // setting at startup. nil/missing means enabled. | |||
| 77 | + | PonytailEnabled *bool | |||
| 78 | + | ||||
| 75 | // AutoSubagentsToolAllowed is nil for normal sessions and false when the | 79 | // AutoSubagentsToolAllowed is nil for normal sessions and false when the | ||
| 76 | // launch-time --no-tools/--tools policy excludes delegation. | 80 | // launch-time --no-tools/--tools policy excludes delegation. | ||
| 77 | AutoSubagentsToolAllowed *bool | 81 | AutoSubagentsToolAllowed *bool | ||
| @@ -173,7 +177,11 @@ type InteractiveConfig struct { | |||||
| 173 | 177 | ||||
| 174 | // RefreshTools re-resolves the live agent registry after a setting | 178 | // RefreshTools re-resolves the live agent registry after a setting | ||
| 175 | // changes the main session's tool availability. Optional for embedders. | 179 | // changes the main session's tool availability. Optional for embedders. | ||
| 176 | - | RefreshTools func() | 180 | + | RefreshTools func() error |
| 181 | + | ||||
| 182 | + | // RefreshPrompt re-resolves the complete live prompt and tool registry | |||
| 183 | + | // after a prompt-affecting setting changes. Optional for embedders. | |||
| 184 | + | RefreshPrompt func() error | |||
| 177 | 185 | ||||
| 178 | // AutoSubmitInitial, when true, auto-submits InitialInput after | 186 | // AutoSubmitInitial, when true, auto-submits InitialInput after | ||
| 179 | // StartupPre completes (or immediately when StartupPre is empty). | 187 | // StartupPre completes (or immediately when StartupPre is empty). | ||
| @@ -450,6 +458,10 @@ type fastModeSettingsStore interface { | |||||
| 450 | SetFastMode(enabled bool) error | 458 | SetFastMode(enabled bool) error | ||
| 451 | } | 459 | } | ||
| 452 | 460 | ||||
| 461 | + | type ponytailSettingsStore interface { | |||
| 462 | + | SetPonytailEnabled(enabled bool) error | |||
| 463 | + | } | |||
| 464 | + | ||||
| 453 | type Interactive struct { | 465 | type Interactive struct { | ||
| 454 | cfg InteractiveConfig | 466 | cfg InteractiveConfig | ||
| 455 | view *tui.View | 467 | view *tui.View | ||
| @@ -507,6 +519,7 @@ type Interactive struct { | |||||
| 507 | cumUsage provider.Usage | 519 | cumUsage provider.Usage | ||
| 508 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | 520 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | ||
| 509 | busy bool | 521 | busy bool | ||
| 522 | + | pendingIdleWork []func() | |||
| 510 | dirty chan struct{} | 523 | dirty chan struct{} | ||
| 511 | modelRefresh chan modelRefreshResult | 524 | modelRefresh chan modelRefreshResult | ||
| 512 | modelRefreshing bool | 525 | modelRefreshing bool | ||
| @@ -3650,6 +3663,18 @@ func (i *Interactive) Display(extName, text string) { | |||||
| 3650 | i.invalidate() | 3663 | i.invalidate() | ||
| 3651 | } | 3664 | } | ||
| 3652 | 3665 | ||||
| 3666 | + | // ReportError surfaces a host-side error in the interactive status area. | |||
| 3667 | + | func (i *Interactive) ReportError(err error) { | |||
| 3668 | + | if err == nil { | |||
| 3669 | + | return | |||
| 3670 | + | } | |||
| 3671 | + | i.mu.Lock() | |||
| 3672 | + | i.statusOK = "" | |||
| 3673 | + | i.statusErr = err.Error() | |||
| 3674 | + | i.mu.Unlock() | |||
| 3675 | + | i.invalidate() | |||
| 3676 | + | } | |||
| 3677 | + | ||||
| 3653 | // SetStatus replaces one persistent status item owned by an extension. | 3678 | // SetStatus replaces one persistent status item owned by an extension. | ||
| 3654 | func (i *Interactive) SetStatus(extName, key, level, text string) { | 3679 | func (i *Interactive) SetStatus(extName, key, level, text string) { | ||
| 3655 | if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" { | 3680 | if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" { | ||
| @@ -3866,6 +3891,13 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 3866 | autoSubagents = false | 3891 | autoSubagents = false | ||
| 3867 | } | 3892 | } | ||
| 3868 | 3893 | ||||
| 3894 | + | ponytailEnabled := i.ponytailEnabled() | |||
| 3895 | + | ponytailDisabled := !i.ponytailSettingsAvailable() | |||
| 3896 | + | ponytailHint := "" | |||
| 3897 | + | if ponytailDisabled { | |||
| 3898 | + | ponytailHint = "requires persistent settings and live prompt refresh support" | |||
| 3899 | + | } | |||
| 3900 | + | ||||
| 3869 | fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode | 3901 | fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode | ||
| 3870 | fastModeHint := "OpenAI service tier" | 3902 | fastModeHint := "OpenAI service tier" | ||
| 3871 | if !provider.SupportsFastMode(i.cfg.Provider) { | 3903 | if !provider.SupportsFastMode(i.cfg.Provider) { | ||
| @@ -3992,6 +4024,14 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 3992 | disabled: autoSubagentsDisabled, | 4024 | disabled: autoSubagentsDisabled, | ||
| 3993 | hint: autoSubagentsHint, | 4025 | hint: autoSubagentsHint, | ||
| 3994 | }, | 4026 | }, | ||
| 4027 | + | { | |||
| 4028 | + | key: "ponytail_enabled", | |||
| 4029 | + | label: "ponytail coding mode", | |||
| 4030 | + | desc: "apply compact coding guidance that favors small, validated, maintainable changes", | |||
| 4031 | + | value: ponytailEnabled, | |||
| 4032 | + | disabled: ponytailDisabled, | |||
| 4033 | + | hint: ponytailHint, | |||
| 4034 | + | }, | |||
| 3995 | { | 4035 | { | ||
| 3996 | key: "fast_mode", | 4036 | key: "fast_mode", | ||
| 3997 | label: "fast mode", | 4037 | label: "fast mode", | ||
| @@ -4321,6 +4361,15 @@ func (i *Interactive) resetSettingsToggle(key string, value bool) { | |||||
| 4321 | } | 4361 | } | ||
| 4322 | } | 4362 | } | ||
| 4323 | 4363 | ||||
| 4364 | + | func (i *Interactive) ponytailEnabled() bool { | |||
| 4365 | + | return i.cfg.PonytailEnabled == nil || *i.cfg.PonytailEnabled | |||
| 4366 | + | } | |||
| 4367 | + | ||||
| 4368 | + | func (i *Interactive) ponytailSettingsAvailable() bool { | |||
| 4369 | + | _, persistent := i.cfg.SettingsStore.(ponytailSettingsStore) | |||
| 4370 | + | return persistent && i.cfg.RefreshPrompt != nil | |||
| 4371 | + | } | |||
| 4372 | + | ||||
| 4324 | func (i *Interactive) applySettingToggle(key string, value bool) { | 4373 | func (i *Interactive) applySettingToggle(key string, value bool) { | ||
| 4325 | // Every setting toggle forces a full repaint at the end — same | 4374 | // Every setting toggle forces a full repaint at the end — same | ||
| 4326 | // effect as the user pressing Ctrl+L — so any per-setting visual | 4375 | // effect as the user pressing Ctrl+L — so any per-setting visual | ||
| @@ -4396,6 +4445,56 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||||
| 4396 | i.statusOK = "AI terminal titles " + onOff(value) | 4445 | i.statusOK = "AI terminal titles " + onOff(value) | ||
| 4397 | i.statusErr = "" | 4446 | i.statusErr = "" | ||
| 4398 | i.mu.Unlock() | 4447 | i.mu.Unlock() | ||
| 4448 | + | case "ponytail_enabled": | |||
| 4449 | + | previous := i.ponytailEnabled() | |||
| 4450 | + | store, available := i.cfg.SettingsStore.(ponytailSettingsStore) | |||
| 4451 | + | if !available || i.cfg.RefreshPrompt == nil { | |||
| 4452 | + | i.resetSettingsToggle(key, previous) | |||
| 4453 | + | i.mu.Lock() | |||
| 4454 | + | i.statusOK = "" | |||
| 4455 | + | i.statusErr = "ponytail coding mode unavailable: persistent settings and live prompt refresh are required" | |||
| 4456 | + | i.mu.Unlock() | |||
| 4457 | + | return | |||
| 4458 | + | } | |||
| 4459 | + | if err := store.SetPonytailEnabled(value); err != nil { | |||
| 4460 | + | i.resetSettingsToggle(key, previous) | |||
| 4461 | + | i.mu.Lock() | |||
| 4462 | + | i.statusOK = "" | |||
| 4463 | + | i.statusErr = "settings: " + err.Error() | |||
| 4464 | + | i.mu.Unlock() | |||
| 4465 | + | return | |||
| 4466 | + | } | |||
| 4467 | + | val := value | |||
| 4468 | + | i.cfg.PonytailEnabled = &val | |||
| 4469 | + | if err := i.cfg.RefreshPrompt(); err != nil { | |||
| 4470 | + | errMsg := "settings: ponytail prompt refresh: " + err.Error() | |||
| 4471 | + | if rollbackErr := store.SetPonytailEnabled(previous); rollbackErr != nil { | |||
| 4472 | + | // The first write succeeded, so keep the in-memory setting at | |||
| 4473 | + | // the value that remains durable instead of claiming that the | |||
| 4474 | + | // rollback took effect. | |||
| 4475 | + | i.resetSettingsToggle(key, value) | |||
| 4476 | + | errMsg += "; rollback persistence: " + rollbackErr.Error() | |||
| 4477 | + | if reconcileErr := i.cfg.RefreshPrompt(); reconcileErr != nil { | |||
| 4478 | + | errMsg += "; durable-state refresh: " + reconcileErr.Error() | |||
| 4479 | + | } | |||
| 4480 | + | } else { | |||
| 4481 | + | previousVal := previous | |||
| 4482 | + | i.cfg.PonytailEnabled = &previousVal | |||
| 4483 | + | i.resetSettingsToggle(key, previous) | |||
| 4484 | + | if refreshErr := i.cfg.RefreshPrompt(); refreshErr != nil { | |||
| 4485 | + | errMsg += "; rollback refresh: " + refreshErr.Error() | |||
| 4486 | + | } | |||
| 4487 | + | } | |||
| 4488 | + | i.mu.Lock() | |||
| 4489 | + | i.statusOK = "" | |||
| 4490 | + | i.statusErr = errMsg | |||
| 4491 | + | i.mu.Unlock() | |||
| 4492 | + | return | |||
| 4493 | + | } | |||
| 4494 | + | i.mu.Lock() | |||
| 4495 | + | i.statusOK = "ponytail coding mode " + onOff(value) | |||
| 4496 | + | i.statusErr = "" | |||
| 4497 | + | i.mu.Unlock() | |||
| 4399 | case "auto_subagents_enabled": | 4498 | case "auto_subagents_enabled": | ||
| 4400 | if value && !i.autoSubagentsAvailable() { | 4499 | if value && !i.autoSubagentsAvailable() { | ||
| 4401 | i.mu.Lock() | 4500 | i.mu.Lock() | ||
| @@ -4460,7 +4559,9 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||||
| 4460 | i.statusErr = "" | 4559 | i.statusErr = "" | ||
| 4461 | i.mu.Unlock() | 4560 | i.mu.Unlock() | ||
| 4462 | case "lsp_enabled": | 4561 | case "lsp_enabled": | ||
| 4463 | - | if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok { | 4562 | + | previous := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled |
| 4563 | + | store, hasStore := i.cfg.SettingsStore.(lspSettingsStore) | |||
| 4564 | + | if hasStore { | |||
| 4464 | if err := store.SetLSPEnabled(value); err != nil { | 4565 | if err := store.SetLSPEnabled(value); err != nil { | ||
| 4465 | i.mu.Lock() | 4566 | i.mu.Lock() | ||
| 4466 | i.statusErr = "settings: " + err.Error() | 4567 | i.statusErr = "settings: " + err.Error() | ||
| @@ -4471,7 +4572,23 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||||
| 4471 | val := value | 4572 | val := value | ||
| 4472 | i.cfg.LSPEnabled = &val | 4573 | i.cfg.LSPEnabled = &val | ||
| 4473 | if i.cfg.RefreshTools != nil { | 4574 | if i.cfg.RefreshTools != nil { | ||
| 4474 | - | i.cfg.RefreshTools() | 4575 | + | if err := i.cfg.RefreshTools(); err != nil { |
| 4576 | + | rollbackErr := error(nil) | |||
| 4577 | + | if hasStore { | |||
| 4578 | + | rollbackErr = store.SetLSPEnabled(previous) | |||
| 4579 | + | } | |||
| 4580 | + | previousVal := previous | |||
| 4581 | + | i.cfg.LSPEnabled = &previousVal | |||
| 4582 | + | errMsg := "settings: refresh tools: " + err.Error() | |||
| 4583 | + | if rollbackErr != nil { | |||
| 4584 | + | errMsg += "; rollback persistence: " + rollbackErr.Error() | |||
| 4585 | + | } | |||
| 4586 | + | i.mu.Lock() | |||
| 4587 | + | i.statusOK = "" | |||
| 4588 | + | i.statusErr = errMsg | |||
| 4589 | + | i.mu.Unlock() | |||
| 4590 | + | return | |||
| 4591 | + | } | |||
| 4475 | } | 4592 | } | ||
| 4476 | i.mu.Lock() | 4593 | i.mu.Lock() | ||
| 4477 | i.statusOK = "lsp in main session " + onOff(value) | 4594 | i.statusOK = "lsp in main session " + onOff(value) | ||
| @@ -6057,7 +6174,9 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) { | |||||
| 6057 | if ag == nil { | 6174 | if ag == nil { | ||
| 6058 | i.mu.Lock() | 6175 | i.mu.Lock() | ||
| 6059 | i.busy = false | 6176 | i.busy = false | ||
| 6177 | + | pendingIdleWork := i.takePendingIdleWorkLocked() | |||
| 6060 | i.mu.Unlock() | 6178 | i.mu.Unlock() | ||
| 6179 | + | runPendingIdleWork(pendingIdleWork) | |||
| 6061 | i.invalidate() | 6180 | i.invalidate() | ||
| 6062 | return | 6181 | return | ||
| 6063 | } | 6182 | } | ||
| @@ -6126,6 +6245,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||||
| 6126 | i.resetStreamingStateLocked() | 6245 | i.resetStreamingStateLocked() | ||
| 6127 | i.cancelTurn = nil | 6246 | i.cancelTurn = nil | ||
| 6128 | i.autoCompacting = false | 6247 | i.autoCompacting = false | ||
| 6248 | + | pendingIdleWork := i.takePendingIdleWorkLocked() | |||
| 6129 | 6249 | ||||
| 6130 | // Drain pending work after the transcript is clean. Overflow recovery | 6250 | // Drain pending work after the transcript is clean. Overflow recovery | ||
| 6131 | // continues the user message already in the transcript. Pre-turn | 6251 | // continues the user message already in the transcript. Pre-turn | ||
| @@ -6210,6 +6330,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||||
| 6210 | i.busy = true | 6330 | i.busy = true | ||
| 6211 | } | 6331 | } | ||
| 6212 | i.mu.Unlock() | 6332 | i.mu.Unlock() | ||
| 6333 | + | runPendingIdleWork(pendingIdleWork) | |||
| 6213 | i.invalidate() | 6334 | i.invalidate() | ||
| 6214 | 6335 | ||||
| 6215 | if hasNext || continueExisting || continueAutomatically { | 6336 | if hasNext || continueExisting || continueAutomatically { | ||
| @@ -6333,6 +6454,7 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||||
| 6333 | i.shellLive = "" | 6454 | i.shellLive = "" | ||
| 6334 | i.busy = false | 6455 | i.busy = false | ||
| 6335 | i.cancelTurn = nil | 6456 | i.cancelTurn = nil | ||
| 6457 | + | pendingIdleWork := i.takePendingIdleWorkLocked() | |||
| 6336 | awaitingPre := i.awaitingStartupPre | 6458 | awaitingPre := i.awaitingStartupPre | ||
| 6337 | if failed { | 6459 | if failed { | ||
| 6338 | if cancelled { | 6460 | if cancelled { | ||
| @@ -6346,6 +6468,7 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||||
| 6346 | i.statusErr = "" | 6468 | i.statusErr = "" | ||
| 6347 | } | 6469 | } | ||
| 6348 | i.mu.Unlock() | 6470 | i.mu.Unlock() | ||
| 6471 | + | runPendingIdleWork(pendingIdleWork) | |||
| 6349 | i.invalidate() | 6472 | i.invalidate() | ||
| 6350 | if awaitingPre { | 6473 | if awaitingPre { | ||
| 6351 | i.completeStartupPre() | 6474 | i.completeStartupPre() | ||
| @@ -6472,6 +6595,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 6472 | i.streamOn = false | 6595 | i.streamOn = false | ||
| 6473 | } | 6596 | } | ||
| 6474 | i.cancelTurn = nil | 6597 | i.cancelTurn = nil | ||
| 6598 | + | pendingIdleWork := i.takePendingIdleWorkLocked() | |||
| 6475 | if err != nil && ctx.Err() == nil { | 6599 | if err != nil && ctx.Err() == nil { | ||
| 6476 | i.statusErr = err.Error() | 6600 | i.statusErr = err.Error() | ||
| 6477 | } | 6601 | } | ||
| @@ -6523,6 +6647,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 6523 | // baseline so subsequent flushes only write new rows). | 6647 | // baseline so subsequent flushes only write new rows). | ||
| 6524 | flush := i.cfg.FlushSession | 6648 | flush := i.cfg.FlushSession | ||
| 6525 | i.mu.Unlock() | 6649 | i.mu.Unlock() | ||
| 6650 | + | runPendingIdleWork(pendingIdleWork) | |||
| 6526 | if flush != nil { | 6651 | if flush != nil { | ||
| 6527 | flush() | 6652 | flush() | ||
| 6528 | } | 6653 | } | ||
| @@ -6909,6 +7034,57 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 6909 | } | 7034 | } | ||
| 6910 | } | 7035 | } | ||
| 6911 | 7036 | ||||
| 7037 | + | // ApplyAgentPromptConfig commits a resolved prompt and tool registry only if | |||
| 7038 | + | // ag is still the live interactive agent. It serializes the identity check | |||
| 7039 | + | // with agent replacement and swaps both values atomically in core.Agent. | |||
| 7040 | + | func (i *Interactive) ApplyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry) (core.Registry, bool) { | |||
| 7041 | + | if ag == nil { | |||
| 7042 | + | return nil, false | |||
| 7043 | + | } | |||
| 7044 | + | // Match the lock order used by dynamic tool mutations so a refresh | |||
| 7045 | + | // cannot race a snapshot-and-replace operation that would reintroduce | |||
| 7046 | + | // the old registry after the atomic prompt swap. | |||
| 7047 | + | i.agentMu.Lock() | |||
| 7048 | + | defer i.agentMu.Unlock() | |||
| 7049 | + | i.mu.Lock() | |||
| 7050 | + | defer i.mu.Unlock() | |||
| 7051 | + | if i.agent != ag { | |||
| 7052 | + | return nil, false | |||
| 7053 | + | } | |||
| 7054 | + | return ag.SetPromptConfig(system, tools), true | |||
| 7055 | + | } | |||
| 7056 | + | ||||
| 7057 | + | // DeferUntilIdle runs work immediately when the interactive agent is idle, | |||
| 7058 | + | // or after the current turn/compaction/shell operation releases the busy | |||
| 7059 | + | // state. Callbacks run outside i.mu. | |||
| 7060 | + | func (i *Interactive) DeferUntilIdle(fn func()) { | |||
| 7061 | + | if fn == nil { | |||
| 7062 | + | return | |||
| 7063 | + | } | |||
| 7064 | + | i.mu.Lock() | |||
| 7065 | + | if i.busy { | |||
| 7066 | + | i.pendingIdleWork = append(i.pendingIdleWork, fn) | |||
| 7067 | + | i.mu.Unlock() | |||
| 7068 | + | return | |||
| 7069 | + | } | |||
| 7070 | + | i.mu.Unlock() | |||
| 7071 | + | fn() | |||
| 7072 | + | } | |||
| 7073 | + | ||||
| 7074 | + | func (i *Interactive) takePendingIdleWorkLocked() []func() { | |||
| 7075 | + | work := i.pendingIdleWork | |||
| 7076 | + | i.pendingIdleWork = nil | |||
| 7077 | + | return work | |||
| 7078 | + | } | |||
| 7079 | + | ||||
| 7080 | + | func runPendingIdleWork(work []func()) { | |||
| 7081 | + | for _, fn := range work { | |||
| 7082 | + | if fn != nil { | |||
| 7083 | + | fn() | |||
| 7084 | + | } | |||
| 7085 | + | } | |||
| 7086 | + | } | |||
| 7087 | + | ||||
| 6912 | // Agent returns the current agent, if any. Used by cli.go to flush the | 7088 | // Agent returns the current agent, if any. Used by cli.go to flush the | ||
| 6913 | // final transcript to the session file. | 7089 | // final transcript to the session file. | ||
| 6914 | func (i *Interactive) Agent() *core.Agent { | 7090 | func (i *Interactive) Agent() *core.Agent { | ||
A packages/agent/modes/ponytail_setting_test.go +230 -0
| @@ -0,0 +1,230 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/patriceckhart/zot/packages/core" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | type recordingPonytailSettingsStore struct { | |
| 12 | + | SettingsStore | |
| 13 | + | values []bool | |
| 14 | + | calls int | |
| 15 | + | err error | |
| 16 | + | failures map[int]error | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func (s *recordingPonytailSettingsStore) SetPonytailEnabled(enabled bool) error { | |
| 20 | + | s.calls++ | |
| 21 | + | if s.err != nil { | |
| 22 | + | return s.err | |
| 23 | + | } | |
| 24 | + | if err := s.failures[s.calls]; err != nil { | |
| 25 | + | return err | |
| 26 | + | } | |
| 27 | + | s.values = append(s.values, enabled) | |
| 28 | + | return nil | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestSettingsDialogOffersPonytailEnabledByDefault(t *testing.T) { | |
| 32 | + | store := &recordingPonytailSettingsStore{} | |
| 33 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 34 | + | SettingsStore: store, | |
| 35 | + | RefreshPrompt: func() error { return nil }, | |
| 36 | + | }) | |
| 37 | + | interactive.openSettingsDialog() | |
| 38 | + | ||
| 39 | + | item := findSettingsItem(interactive.settingsDialog.items, "ponytail_enabled") | |
| 40 | + | if item == nil { | |
| 41 | + | t.Fatal("settings dialog is missing Ponytail mode") | |
| 42 | + | } | |
| 43 | + | if !item.value { | |
| 44 | + | t.Fatal("Ponytail mode should be enabled when the config value is absent") | |
| 45 | + | } | |
| 46 | + | if item.disabled { | |
| 47 | + | t.Fatal("Ponytail mode should be available with persistence and refresh capabilities") | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestSettingsDialogDisablesPonytailWithoutCapabilities(t *testing.T) { | |
| 52 | + | cases := []struct { | |
| 53 | + | name string | |
| 54 | + | cfg InteractiveConfig | |
| 55 | + | }{ | |
| 56 | + | {name: "neither capability", cfg: InteractiveConfig{}}, | |
| 57 | + | {name: "persistence only", cfg: InteractiveConfig{SettingsStore: &recordingPonytailSettingsStore{}}}, | |
| 58 | + | {name: "refresh only", cfg: InteractiveConfig{RefreshPrompt: func() error { return nil }}}, | |
| 59 | + | } | |
| 60 | + | for _, tc := range cases { | |
| 61 | + | t.Run(tc.name, func(t *testing.T) { | |
| 62 | + | interactive := NewInteractive(tc.cfg) | |
| 63 | + | interactive.openSettingsDialog() | |
| 64 | + | ||
| 65 | + | item := findSettingsItem(interactive.settingsDialog.items, "ponytail_enabled") | |
| 66 | + | if item == nil { | |
| 67 | + | t.Fatal("settings dialog is missing Ponytail mode") | |
| 68 | + | } | |
| 69 | + | if !item.disabled { | |
| 70 | + | t.Fatal("Ponytail mode should be disabled without both capabilities") | |
| 71 | + | } | |
| 72 | + | if item.hint == "" { | |
| 73 | + | t.Fatal("disabled Ponytail mode should explain why it is unavailable") | |
| 74 | + | } | |
| 75 | + | }) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestApplyPonytailToggleRefreshesAndPersists(t *testing.T) { | |
| 80 | + | store := &recordingPonytailSettingsStore{} | |
| 81 | + | refreshCalls := 0 | |
| 82 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 83 | + | SettingsStore: store, | |
| 84 | + | RefreshPrompt: func() error { | |
| 85 | + | refreshCalls++ | |
| 86 | + | return nil | |
| 87 | + | }, | |
| 88 | + | }) | |
| 89 | + | interactive.rend = nil | |
| 90 | + | ||
| 91 | + | interactive.applySettingToggle("ponytail_enabled", false) | |
| 92 | + | if interactive.ponytailEnabled() { | |
| 93 | + | t.Fatal("Ponytail mode remained enabled after disabling it") | |
| 94 | + | } | |
| 95 | + | interactive.applySettingToggle("ponytail_enabled", true) | |
| 96 | + | if !interactive.ponytailEnabled() { | |
| 97 | + | t.Fatal("Ponytail mode remained disabled after enabling it") | |
| 98 | + | } | |
| 99 | + | if refreshCalls != 2 { | |
| 100 | + | t.Fatalf("refresh calls = %d, want 2", refreshCalls) | |
| 101 | + | } | |
| 102 | + | if len(store.values) != 2 || store.values[0] || !store.values[1] { | |
| 103 | + | t.Fatalf("persisted values = %v, want [false true]", store.values) | |
| 104 | + | } | |
| 105 | + | if interactive.statusErr != "" { | |
| 106 | + | t.Fatalf("unexpected status error: %q", interactive.statusErr) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func TestApplyPonytailToggleRollsBackWhenRefreshFails(t *testing.T) { | |
| 111 | + | store := &recordingPonytailSettingsStore{} | |
| 112 | + | refreshCalls := 0 | |
| 113 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 114 | + | SettingsStore: store, | |
| 115 | + | RefreshPrompt: func() error { | |
| 116 | + | refreshCalls++ | |
| 117 | + | if refreshCalls == 1 { | |
| 118 | + | return errors.New("refresh failed") | |
| 119 | + | } | |
| 120 | + | return nil | |
| 121 | + | }, | |
| 122 | + | }) | |
| 123 | + | interactive.rend = nil | |
| 124 | + | ||
| 125 | + | interactive.applySettingToggle("ponytail_enabled", false) | |
| 126 | + | if !interactive.ponytailEnabled() { | |
| 127 | + | t.Fatal("Ponytail mode stayed disabled after refresh rollback") | |
| 128 | + | } | |
| 129 | + | if refreshCalls != 2 { | |
| 130 | + | t.Fatalf("refresh calls = %d, want initial refresh and rollback refresh", refreshCalls) | |
| 131 | + | } | |
| 132 | + | if len(store.values) != 2 || store.values[0] || !store.values[1] { | |
| 133 | + | t.Fatalf("persisted rollback values = %v, want [false true]", store.values) | |
| 134 | + | } | |
| 135 | + | if !strings.Contains(interactive.statusErr, "refresh failed") { | |
| 136 | + | t.Fatalf("status error = %q, want refresh failure", interactive.statusErr) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func TestApplyPonytailToggleKeepsDurableStateWhenRollbackPersistenceFails(t *testing.T) { | |
| 141 | + | store := &recordingPonytailSettingsStore{ | |
| 142 | + | failures: map[int]error{2: errors.New("rollback disk full")}, | |
| 143 | + | } | |
| 144 | + | refreshCalls := 0 | |
| 145 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 146 | + | SettingsStore: store, | |
| 147 | + | RefreshPrompt: func() error { | |
| 148 | + | refreshCalls++ | |
| 149 | + | if refreshCalls == 1 { | |
| 150 | + | return errors.New("refresh failed") | |
| 151 | + | } | |
| 152 | + | return nil | |
| 153 | + | }, | |
| 154 | + | }) | |
| 155 | + | interactive.rend = nil | |
| 156 | + | ||
| 157 | + | interactive.applySettingToggle("ponytail_enabled", false) | |
| 158 | + | if interactive.ponytailEnabled() { | |
| 159 | + | t.Fatal("in-memory setting reverted despite rollback persistence failure") | |
| 160 | + | } | |
| 161 | + | if len(store.values) != 1 || store.values[0] { | |
| 162 | + | t.Fatalf("durable writes = %v, want only the successful disable", store.values) | |
| 163 | + | } | |
| 164 | + | if refreshCalls != 2 { | |
| 165 | + | t.Fatalf("refresh calls = %d, want failed refresh plus durable-state reconciliation", refreshCalls) | |
| 166 | + | } | |
| 167 | + | if !strings.Contains(interactive.statusErr, "rollback disk full") { | |
| 168 | + | t.Fatalf("status error = %q, want rollback failure", interactive.statusErr) | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func TestApplyPonytailToggleKeepsStateWhenPersistenceFails(t *testing.T) { | |
| 173 | + | store := &recordingPonytailSettingsStore{err: errors.New("disk full")} | |
| 174 | + | refreshCalls := 0 | |
| 175 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 176 | + | SettingsStore: store, | |
| 177 | + | RefreshPrompt: func() error { | |
| 178 | + | refreshCalls++ | |
| 179 | + | return nil | |
| 180 | + | }, | |
| 181 | + | }) | |
| 182 | + | interactive.rend = nil | |
| 183 | + | ||
| 184 | + | interactive.applySettingToggle("ponytail_enabled", false) | |
| 185 | + | if !interactive.ponytailEnabled() { | |
| 186 | + | t.Fatal("Ponytail mode changed despite persistence failure") | |
| 187 | + | } | |
| 188 | + | if refreshCalls != 0 { | |
| 189 | + | t.Fatalf("refresh calls = %d, want none", refreshCalls) | |
| 190 | + | } | |
| 191 | + | if !strings.Contains(interactive.statusErr, "disk full") { | |
| 192 | + | t.Fatalf("status error = %q, want persistence failure", interactive.statusErr) | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func TestApplyPonytailToggleUpdatesLiveAgentPrompt(t *testing.T) { | |
| 197 | + | const ponytailBlock = "[ponytail test block]" | |
| 198 | + | store := &recordingPonytailSettingsStore{} | |
| 199 | + | ag := core.NewAgent(nil, "model", "base\n\n"+ponytailBlock, nil) | |
| 200 | + | var interactive *Interactive | |
| 201 | + | refreshCalls := 0 | |
| 202 | + | interactive = NewInteractive(InteractiveConfig{ | |
| 203 | + | Agent: ag, | |
| 204 | + | SettingsStore: store, | |
| 205 | + | RefreshPrompt: func() error { | |
| 206 | + | refreshCalls++ | |
| 207 | + | system := "base" | |
| 208 | + | if interactive.ponytailEnabled() { | |
| 209 | + | system += "\n\n" + ponytailBlock | |
| 210 | + | } | |
| 211 | + | ag.SetSystemPrompt(system) | |
| 212 | + | return nil | |
| 213 | + | }, | |
| 214 | + | }) | |
| 215 | + | interactive.rend = nil | |
| 216 | + | ||
| 217 | + | interactive.applySettingToggle("ponytail_enabled", false) | |
| 218 | + | system, _ := ag.PromptConfig() | |
| 219 | + | if strings.Contains(system, ponytailBlock) { | |
| 220 | + | t.Fatalf("live prompt still contains disabled Ponytail block: %q", system) | |
| 221 | + | } | |
| 222 | + | interactive.applySettingToggle("ponytail_enabled", true) | |
| 223 | + | system, _ = ag.PromptConfig() | |
| 224 | + | if strings.Count(system, ponytailBlock) != 1 { | |
| 225 | + | t.Fatalf("live prompt block count = %d, want 1: %q", strings.Count(system, ponytailBlock), system) | |
| 226 | + | } | |
| 227 | + | if refreshCalls != 2 { | |
| 228 | + | t.Fatalf("refresh calls = %d, want 2", refreshCalls) | |
| 229 | + | } | |
| 230 | + | } | |
| @@ -0,0 +1,230 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | type recordingPonytailSettingsStore struct { | |||
| 12 | + | SettingsStore | |||
| 13 | + | values []bool | |||
| 14 | + | calls int | |||
| 15 | + | err error | |||
| 16 | + | failures map[int]error | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func (s *recordingPonytailSettingsStore) SetPonytailEnabled(enabled bool) error { | |||
| 20 | + | s.calls++ | |||
| 21 | + | if s.err != nil { | |||
| 22 | + | return s.err | |||
| 23 | + | } | |||
| 24 | + | if err := s.failures[s.calls]; err != nil { | |||
| 25 | + | return err | |||
| 26 | + | } | |||
| 27 | + | s.values = append(s.values, enabled) | |||
| 28 | + | return nil | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestSettingsDialogOffersPonytailEnabledByDefault(t *testing.T) { | |||
| 32 | + | store := &recordingPonytailSettingsStore{} | |||
| 33 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 34 | + | SettingsStore: store, | |||
| 35 | + | RefreshPrompt: func() error { return nil }, | |||
| 36 | + | }) | |||
| 37 | + | interactive.openSettingsDialog() | |||
| 38 | + | ||||
| 39 | + | item := findSettingsItem(interactive.settingsDialog.items, "ponytail_enabled") | |||
| 40 | + | if item == nil { | |||
| 41 | + | t.Fatal("settings dialog is missing Ponytail mode") | |||
| 42 | + | } | |||
| 43 | + | if !item.value { | |||
| 44 | + | t.Fatal("Ponytail mode should be enabled when the config value is absent") | |||
| 45 | + | } | |||
| 46 | + | if item.disabled { | |||
| 47 | + | t.Fatal("Ponytail mode should be available with persistence and refresh capabilities") | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestSettingsDialogDisablesPonytailWithoutCapabilities(t *testing.T) { | |||
| 52 | + | cases := []struct { | |||
| 53 | + | name string | |||
| 54 | + | cfg InteractiveConfig | |||
| 55 | + | }{ | |||
| 56 | + | {name: "neither capability", cfg: InteractiveConfig{}}, | |||
| 57 | + | {name: "persistence only", cfg: InteractiveConfig{SettingsStore: &recordingPonytailSettingsStore{}}}, | |||
| 58 | + | {name: "refresh only", cfg: InteractiveConfig{RefreshPrompt: func() error { return nil }}}, | |||
| 59 | + | } | |||
| 60 | + | for _, tc := range cases { | |||
| 61 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 62 | + | interactive := NewInteractive(tc.cfg) | |||
| 63 | + | interactive.openSettingsDialog() | |||
| 64 | + | ||||
| 65 | + | item := findSettingsItem(interactive.settingsDialog.items, "ponytail_enabled") | |||
| 66 | + | if item == nil { | |||
| 67 | + | t.Fatal("settings dialog is missing Ponytail mode") | |||
| 68 | + | } | |||
| 69 | + | if !item.disabled { | |||
| 70 | + | t.Fatal("Ponytail mode should be disabled without both capabilities") | |||
| 71 | + | } | |||
| 72 | + | if item.hint == "" { | |||
| 73 | + | t.Fatal("disabled Ponytail mode should explain why it is unavailable") | |||
| 74 | + | } | |||
| 75 | + | }) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestApplyPonytailToggleRefreshesAndPersists(t *testing.T) { | |||
| 80 | + | store := &recordingPonytailSettingsStore{} | |||
| 81 | + | refreshCalls := 0 | |||
| 82 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 83 | + | SettingsStore: store, | |||
| 84 | + | RefreshPrompt: func() error { | |||
| 85 | + | refreshCalls++ | |||
| 86 | + | return nil | |||
| 87 | + | }, | |||
| 88 | + | }) | |||
| 89 | + | interactive.rend = nil | |||
| 90 | + | ||||
| 91 | + | interactive.applySettingToggle("ponytail_enabled", false) | |||
| 92 | + | if interactive.ponytailEnabled() { | |||
| 93 | + | t.Fatal("Ponytail mode remained enabled after disabling it") | |||
| 94 | + | } | |||
| 95 | + | interactive.applySettingToggle("ponytail_enabled", true) | |||
| 96 | + | if !interactive.ponytailEnabled() { | |||
| 97 | + | t.Fatal("Ponytail mode remained disabled after enabling it") | |||
| 98 | + | } | |||
| 99 | + | if refreshCalls != 2 { | |||
| 100 | + | t.Fatalf("refresh calls = %d, want 2", refreshCalls) | |||
| 101 | + | } | |||
| 102 | + | if len(store.values) != 2 || store.values[0] || !store.values[1] { | |||
| 103 | + | t.Fatalf("persisted values = %v, want [false true]", store.values) | |||
| 104 | + | } | |||
| 105 | + | if interactive.statusErr != "" { | |||
| 106 | + | t.Fatalf("unexpected status error: %q", interactive.statusErr) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func TestApplyPonytailToggleRollsBackWhenRefreshFails(t *testing.T) { | |||
| 111 | + | store := &recordingPonytailSettingsStore{} | |||
| 112 | + | refreshCalls := 0 | |||
| 113 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 114 | + | SettingsStore: store, | |||
| 115 | + | RefreshPrompt: func() error { | |||
| 116 | + | refreshCalls++ | |||
| 117 | + | if refreshCalls == 1 { | |||
| 118 | + | return errors.New("refresh failed") | |||
| 119 | + | } | |||
| 120 | + | return nil | |||
| 121 | + | }, | |||
| 122 | + | }) | |||
| 123 | + | interactive.rend = nil | |||
| 124 | + | ||||
| 125 | + | interactive.applySettingToggle("ponytail_enabled", false) | |||
| 126 | + | if !interactive.ponytailEnabled() { | |||
| 127 | + | t.Fatal("Ponytail mode stayed disabled after refresh rollback") | |||
| 128 | + | } | |||
| 129 | + | if refreshCalls != 2 { | |||
| 130 | + | t.Fatalf("refresh calls = %d, want initial refresh and rollback refresh", refreshCalls) | |||
| 131 | + | } | |||
| 132 | + | if len(store.values) != 2 || store.values[0] || !store.values[1] { | |||
| 133 | + | t.Fatalf("persisted rollback values = %v, want [false true]", store.values) | |||
| 134 | + | } | |||
| 135 | + | if !strings.Contains(interactive.statusErr, "refresh failed") { | |||
| 136 | + | t.Fatalf("status error = %q, want refresh failure", interactive.statusErr) | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func TestApplyPonytailToggleKeepsDurableStateWhenRollbackPersistenceFails(t *testing.T) { | |||
| 141 | + | store := &recordingPonytailSettingsStore{ | |||
| 142 | + | failures: map[int]error{2: errors.New("rollback disk full")}, | |||
| 143 | + | } | |||
| 144 | + | refreshCalls := 0 | |||
| 145 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 146 | + | SettingsStore: store, | |||
| 147 | + | RefreshPrompt: func() error { | |||
| 148 | + | refreshCalls++ | |||
| 149 | + | if refreshCalls == 1 { | |||
| 150 | + | return errors.New("refresh failed") | |||
| 151 | + | } | |||
| 152 | + | return nil | |||
| 153 | + | }, | |||
| 154 | + | }) | |||
| 155 | + | interactive.rend = nil | |||
| 156 | + | ||||
| 157 | + | interactive.applySettingToggle("ponytail_enabled", false) | |||
| 158 | + | if interactive.ponytailEnabled() { | |||
| 159 | + | t.Fatal("in-memory setting reverted despite rollback persistence failure") | |||
| 160 | + | } | |||
| 161 | + | if len(store.values) != 1 || store.values[0] { | |||
| 162 | + | t.Fatalf("durable writes = %v, want only the successful disable", store.values) | |||
| 163 | + | } | |||
| 164 | + | if refreshCalls != 2 { | |||
| 165 | + | t.Fatalf("refresh calls = %d, want failed refresh plus durable-state reconciliation", refreshCalls) | |||
| 166 | + | } | |||
| 167 | + | if !strings.Contains(interactive.statusErr, "rollback disk full") { | |||
| 168 | + | t.Fatalf("status error = %q, want rollback failure", interactive.statusErr) | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func TestApplyPonytailToggleKeepsStateWhenPersistenceFails(t *testing.T) { | |||
| 173 | + | store := &recordingPonytailSettingsStore{err: errors.New("disk full")} | |||
| 174 | + | refreshCalls := 0 | |||
| 175 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 176 | + | SettingsStore: store, | |||
| 177 | + | RefreshPrompt: func() error { | |||
| 178 | + | refreshCalls++ | |||
| 179 | + | return nil | |||
| 180 | + | }, | |||
| 181 | + | }) | |||
| 182 | + | interactive.rend = nil | |||
| 183 | + | ||||
| 184 | + | interactive.applySettingToggle("ponytail_enabled", false) | |||
| 185 | + | if !interactive.ponytailEnabled() { | |||
| 186 | + | t.Fatal("Ponytail mode changed despite persistence failure") | |||
| 187 | + | } | |||
| 188 | + | if refreshCalls != 0 { | |||
| 189 | + | t.Fatalf("refresh calls = %d, want none", refreshCalls) | |||
| 190 | + | } | |||
| 191 | + | if !strings.Contains(interactive.statusErr, "disk full") { | |||
| 192 | + | t.Fatalf("status error = %q, want persistence failure", interactive.statusErr) | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func TestApplyPonytailToggleUpdatesLiveAgentPrompt(t *testing.T) { | |||
| 197 | + | const ponytailBlock = "[ponytail test block]" | |||
| 198 | + | store := &recordingPonytailSettingsStore{} | |||
| 199 | + | ag := core.NewAgent(nil, "model", "base\n\n"+ponytailBlock, nil) | |||
| 200 | + | var interactive *Interactive | |||
| 201 | + | refreshCalls := 0 | |||
| 202 | + | interactive = NewInteractive(InteractiveConfig{ | |||
| 203 | + | Agent: ag, | |||
| 204 | + | SettingsStore: store, | |||
| 205 | + | RefreshPrompt: func() error { | |||
| 206 | + | refreshCalls++ | |||
| 207 | + | system := "base" | |||
| 208 | + | if interactive.ponytailEnabled() { | |||
| 209 | + | system += "\n\n" + ponytailBlock | |||
| 210 | + | } | |||
| 211 | + | ag.SetSystemPrompt(system) | |||
| 212 | + | return nil | |||
| 213 | + | }, | |||
| 214 | + | }) | |||
| 215 | + | interactive.rend = nil | |||
| 216 | + | ||||
| 217 | + | interactive.applySettingToggle("ponytail_enabled", false) | |||
| 218 | + | system, _ := ag.PromptConfig() | |||
| 219 | + | if strings.Contains(system, ponytailBlock) { | |||
| 220 | + | t.Fatalf("live prompt still contains disabled Ponytail block: %q", system) | |||
| 221 | + | } | |||
| 222 | + | interactive.applySettingToggle("ponytail_enabled", true) | |||
| 223 | + | system, _ = ag.PromptConfig() | |||
| 224 | + | if strings.Count(system, ponytailBlock) != 1 { | |||
| 225 | + | t.Fatalf("live prompt block count = %d, want 1: %q", strings.Count(system, ponytailBlock), system) | |||
| 226 | + | } | |||
| 227 | + | if refreshCalls != 2 { | |||
| 228 | + | t.Fatalf("refresh calls = %d, want 2", refreshCalls) | |||
| 229 | + | } | |||
| 230 | + | } | |||
A packages/agent/ponytail.go +15 -0
| @@ -0,0 +1,15 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | _ "embed" | |
| 5 | + | "strings" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | //go:embed ponytail.md | |
| 9 | + | var ponytailSystemAddendum string | |
| 10 | + | ||
| 11 | + | // PonytailSystemAddendum returns the compact coding-guidance block included | |
| 12 | + | // in resolved system prompts when Ponytail mode is enabled. | |
| 13 | + | func PonytailSystemAddendum() string { | |
| 14 | + | return strings.TrimSpace(ponytailSystemAddendum) | |
| 15 | + | } | |
| @@ -0,0 +1,15 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | _ "embed" | |||
| 5 | + | "strings" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | //go:embed ponytail.md | |||
| 9 | + | var ponytailSystemAddendum string | |||
| 10 | + | ||||
| 11 | + | // PonytailSystemAddendum returns the compact coding-guidance block included | |||
| 12 | + | // in resolved system prompts when Ponytail mode is enabled. | |||
| 13 | + | func PonytailSystemAddendum() string { | |||
| 14 | + | return strings.TrimSpace(ponytailSystemAddendum) | |||
| 15 | + | } | |||
A packages/agent/ponytail.md +14 -0
| @@ -0,0 +1,14 @@ | |||
| 1 | + | # Ponytail coding mode | |
| 2 | + | ||
| 3 | + | When coding, debugging, reviewing, or changing software: | |
| 4 | + | ||
| 5 | + | - Understand the real flow and the owning boundary before editing. | |
| 6 | + | - Prefer the smallest correct implementation; follow YAGNI. | |
| 7 | + | - Reuse existing code and patterns before introducing abstractions. | |
| 8 | + | - Prefer the standard library and native platform features before dependencies. | |
| 9 | + | - Fix root causes rather than masking symptoms. | |
| 10 | + | - Keep validation, error handling, security, accessibility, data-loss protection, and runnable checks intact. | |
| 11 | + | - Avoid speculative features, boilerplate, and unrelated refactors. | |
| 12 | + | - Before finishing non-trivial work, run the narrowest useful check and report what remains uncertain. | |
| 13 | + | ||
| 14 | + | This is coding guidance, not a permission or security policy. Apply it to engineering work, not ordinary conversation or creative requests. | |
| @@ -0,0 +1,14 @@ | |||||
| 1 | + | # Ponytail coding mode | |||
| 2 | + | ||||
| 3 | + | When coding, debugging, reviewing, or changing software: | |||
| 4 | + | ||||
| 5 | + | - Understand the real flow and the owning boundary before editing. | |||
| 6 | + | - Prefer the smallest correct implementation; follow YAGNI. | |||
| 7 | + | - Reuse existing code and patterns before introducing abstractions. | |||
| 8 | + | - Prefer the standard library and native platform features before dependencies. | |||
| 9 | + | - Fix root causes rather than masking symptoms. | |||
| 10 | + | - Keep validation, error handling, security, accessibility, data-loss protection, and runnable checks intact. | |||
| 11 | + | - Avoid speculative features, boilerplate, and unrelated refactors. | |||
| 12 | + | - Before finishing non-trivial work, run the narrowest useful check and report what remains uncertain. | |||
| 13 | + | ||||
| 14 | + | This is coding guidance, not a permission or security policy. Apply it to engineering work, not ordinary conversation or creative requests. | |||
A packages/agent/ponytail_test.go +199 -0
| @@ -0,0 +1,199 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestConfigPonytailModeDefaultsToEnabled(t *testing.T) { | |
| 11 | + | cfg := Config{} | |
| 12 | + | if !cfg.PonytailModeEnabled() { | |
| 13 | + | t.Fatal("Ponytail mode should default to enabled") | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | disabled := false | |
| 17 | + | cfg.PonytailEnabled = &disabled | |
| 18 | + | if cfg.PonytailModeEnabled() { | |
| 19 | + | t.Fatal("Ponytail mode should honor an explicit false value") | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | enabled := true | |
| 23 | + | cfg.PonytailEnabled = &enabled | |
| 24 | + | if !cfg.PonytailModeEnabled() { | |
| 25 | + | t.Fatal("Ponytail mode should honor an explicit true value") | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestPonytailSystemAddendumIsCompact(t *testing.T) { | |
| 30 | + | addendum := PonytailSystemAddendum() | |
| 31 | + | if addendum == "" { | |
| 32 | + | t.Fatal("Ponytail system addendum is empty") | |
| 33 | + | } | |
| 34 | + | if len(addendum) > 2000 { | |
| 35 | + | t.Fatalf("Ponytail system addendum is %d bytes, want at most 2000", len(addendum)) | |
| 36 | + | } | |
| 37 | + | for _, want := range []string{"YAGNI", "standard library", "validation", "ordinary conversation"} { | |
| 38 | + | if !strings.Contains(addendum, want) { | |
| 39 | + | t.Fatalf("Ponytail system addendum missing %q:\n%s", want, addendum) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestResolvePonytailModeDefaultAndExplicitDisable(t *testing.T) { | |
| 45 | + | cases := []struct { | |
| 46 | + | name string | |
| 47 | + | disabled bool | |
| 48 | + | want bool | |
| 49 | + | }{ | |
| 50 | + | {name: "default enabled", want: true}, | |
| 51 | + | {name: "explicitly disabled", disabled: true, want: false}, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | for _, tc := range cases { | |
| 55 | + | t.Run(tc.name, func(t *testing.T) { | |
| 56 | + | root := t.TempDir() | |
| 57 | + | t.Setenv("ZOT_HOME", filepath.Join(root, "zot-home")) | |
| 58 | + | t.Setenv("HOME", filepath.Join(root, "home")) | |
| 59 | + | if tc.disabled { | |
| 60 | + | disabled := false | |
| 61 | + | if err := SaveConfig(Config{PonytailEnabled: &disabled}); err != nil { | |
| 62 | + | t.Fatal(err) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | resolved, err := Resolve(Args{ | |
| 67 | + | Provider: "ollama", | |
| 68 | + | Model: "any-local-model", | |
| 69 | + | CWD: filepath.Join(root, "project"), | |
| 70 | + | NoLSP: true, | |
| 71 | + | NoSkill: true, | |
| 72 | + | NoTools: true, | |
| 73 | + | }, false) | |
| 74 | + | if err != nil { | |
| 75 | + | t.Fatalf("Resolve failed: %v", err) | |
| 76 | + | } | |
| 77 | + | got := strings.Contains(resolved.SystemPrompt, PonytailSystemAddendum()) | |
| 78 | + | if got != tc.want { | |
| 79 | + | t.Fatalf("Ponytail prompt inclusion = %v, want %v:\n%s", got, tc.want, resolved.SystemPrompt) | |
| 80 | + | } | |
| 81 | + | if count := strings.Count(resolved.SystemPrompt, PonytailSystemAddendum()); count != btoi(tc.want) { | |
| 82 | + | t.Fatalf("Ponytail prompt occurrence count = %d, want %d", count, btoi(tc.want)) | |
| 83 | + | } | |
| 84 | + | }) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func btoi(value bool) int { | |
| 89 | + | if value { | |
| 90 | + | return 1 | |
| 91 | + | } | |
| 92 | + | return 0 | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestResolvePonytailModeRetainsAddendumWithSystemFile(t *testing.T) { | |
| 96 | + | root := t.TempDir() | |
| 97 | + | zotHome := filepath.Join(root, "zot-home") | |
| 98 | + | t.Setenv("ZOT_HOME", zotHome) | |
| 99 | + | t.Setenv("HOME", filepath.Join(root, "home")) | |
| 100 | + | if err := os.MkdirAll(zotHome, 0o755); err != nil { | |
| 101 | + | t.Fatal(err) | |
| 102 | + | } | |
| 103 | + | if err := os.WriteFile(filepath.Join(zotHome, "SYSTEM.md"), []byte("custom system identity"), 0o600); err != nil { | |
| 104 | + | t.Fatal(err) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | resolved, err := Resolve(Args{ | |
| 108 | + | Provider: "ollama", | |
| 109 | + | Model: "any-local-model", | |
| 110 | + | CWD: filepath.Join(root, "project"), | |
| 111 | + | NoLSP: true, | |
| 112 | + | NoSkill: true, | |
| 113 | + | NoTools: true, | |
| 114 | + | }, false) | |
| 115 | + | if err != nil { | |
| 116 | + | t.Fatalf("Resolve failed: %v", err) | |
| 117 | + | } | |
| 118 | + | if !strings.Contains(resolved.SystemPrompt, "custom system identity") { | |
| 119 | + | t.Fatalf("SYSTEM.md content missing:\n%s", resolved.SystemPrompt) | |
| 120 | + | } | |
| 121 | + | if !strings.Contains(resolved.SystemPrompt, PonytailSystemAddendum()) { | |
| 122 | + | t.Fatalf("SYSTEM.md replacement lost Ponytail addendum:\n%s", resolved.SystemPrompt) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func TestResolvePonytailModePrecedesExplicitAppendPrompt(t *testing.T) { | |
| 127 | + | root := t.TempDir() | |
| 128 | + | t.Setenv("ZOT_HOME", filepath.Join(root, "zot-home")) | |
| 129 | + | t.Setenv("HOME", filepath.Join(root, "home")) | |
| 130 | + | ||
| 131 | + | const explicit = "explicit invocation guidance" | |
| 132 | + | resolved, err := Resolve(Args{ | |
| 133 | + | Provider: "ollama", | |
| 134 | + | Model: "any-local-model", | |
| 135 | + | CWD: filepath.Join(root, "project"), | |
| 136 | + | NoLSP: true, | |
| 137 | + | NoSkill: true, | |
| 138 | + | NoTools: true, | |
| 139 | + | AppendSystemPrompt: []string{explicit}, | |
| 140 | + | }, false) | |
| 141 | + | if err != nil { | |
| 142 | + | t.Fatalf("Resolve failed: %v", err) | |
| 143 | + | } | |
| 144 | + | ponytailIndex := strings.Index(resolved.SystemPrompt, PonytailSystemAddendum()) | |
| 145 | + | explicitIndex := strings.Index(resolved.SystemPrompt, explicit) | |
| 146 | + | if ponytailIndex < 0 || explicitIndex < 0 || ponytailIndex > explicitIndex { | |
| 147 | + | t.Fatalf("prompt ordering = Ponytail %d, explicit %d:\n%s", ponytailIndex, explicitIndex, resolved.SystemPrompt) | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestResolvePonytailModeAppliesAcrossHeadlessModesAndCustomIdentity(t *testing.T) { | |
| 152 | + | root := t.TempDir() | |
| 153 | + | t.Setenv("ZOT_HOME", filepath.Join(root, "zot-home")) | |
| 154 | + | t.Setenv("HOME", filepath.Join(root, "home")) | |
| 155 | + | ||
| 156 | + | modes := []Mode{"", ModePrint, ModeStream, ModeJSON, ModeRPC, ModeSubagentWorker} | |
| 157 | + | for _, mode := range modes { | |
| 158 | + | resolved, err := Resolve(Args{ | |
| 159 | + | Mode: mode, | |
| 160 | + | Provider: "ollama", | |
| 161 | + | Model: "any-local-model", | |
| 162 | + | CWD: filepath.Join(root, "project"), | |
| 163 | + | NoLSP: true, | |
| 164 | + | NoSkill: true, | |
| 165 | + | NoTools: true, | |
| 166 | + | SystemPrompt: "custom identity", | |
| 167 | + | }, false) | |
| 168 | + | if err != nil { | |
| 169 | + | t.Fatalf("Resolve(%q) failed: %v", mode, err) | |
| 170 | + | } | |
| 171 | + | if !strings.Contains(resolved.SystemPrompt, "custom identity") { | |
| 172 | + | t.Fatalf("Resolve(%q) lost custom identity:\n%s", mode, resolved.SystemPrompt) | |
| 173 | + | } | |
| 174 | + | if !strings.Contains(resolved.SystemPrompt, PonytailSystemAddendum()) { | |
| 175 | + | t.Fatalf("Resolve(%q) omitted Ponytail guidance:\n%s", mode, resolved.SystemPrompt) | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func TestConfigSettingsStorePersistsPonytailWithoutChangingKnownFields(t *testing.T) { | |
| 181 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 182 | + | if err := SaveConfig(Config{Theme: "dark", Provider: "ollama", Model: "local"}); err != nil { | |
| 183 | + | t.Fatal(err) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | if err := (configSettingsStore{}).SetPonytailEnabled(false); err != nil { | |
| 187 | + | t.Fatal(err) | |
| 188 | + | } | |
| 189 | + | cfg, err := LoadConfig() | |
| 190 | + | if err != nil { | |
| 191 | + | t.Fatal(err) | |
| 192 | + | } | |
| 193 | + | if cfg.PonytailEnabled == nil || *cfg.PonytailEnabled { | |
| 194 | + | t.Fatal("Ponytail mode was not persisted as disabled") | |
| 195 | + | } | |
| 196 | + | if cfg.Theme != "dark" || cfg.Provider != "ollama" || cfg.Model != "local" { | |
| 197 | + | t.Fatalf("known config fields changed: %#v", cfg) | |
| 198 | + | } | |
| 199 | + | } | |
| @@ -0,0 +1,199 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestConfigPonytailModeDefaultsToEnabled(t *testing.T) { | |||
| 11 | + | cfg := Config{} | |||
| 12 | + | if !cfg.PonytailModeEnabled() { | |||
| 13 | + | t.Fatal("Ponytail mode should default to enabled") | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | disabled := false | |||
| 17 | + | cfg.PonytailEnabled = &disabled | |||
| 18 | + | if cfg.PonytailModeEnabled() { | |||
| 19 | + | t.Fatal("Ponytail mode should honor an explicit false value") | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | enabled := true | |||
| 23 | + | cfg.PonytailEnabled = &enabled | |||
| 24 | + | if !cfg.PonytailModeEnabled() { | |||
| 25 | + | t.Fatal("Ponytail mode should honor an explicit true value") | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestPonytailSystemAddendumIsCompact(t *testing.T) { | |||
| 30 | + | addendum := PonytailSystemAddendum() | |||
| 31 | + | if addendum == "" { | |||
| 32 | + | t.Fatal("Ponytail system addendum is empty") | |||
| 33 | + | } | |||
| 34 | + | if len(addendum) > 2000 { | |||
| 35 | + | t.Fatalf("Ponytail system addendum is %d bytes, want at most 2000", len(addendum)) | |||
| 36 | + | } | |||
| 37 | + | for _, want := range []string{"YAGNI", "standard library", "validation", "ordinary conversation"} { | |||
| 38 | + | if !strings.Contains(addendum, want) { | |||
| 39 | + | t.Fatalf("Ponytail system addendum missing %q:\n%s", want, addendum) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestResolvePonytailModeDefaultAndExplicitDisable(t *testing.T) { | |||
| 45 | + | cases := []struct { | |||
| 46 | + | name string | |||
| 47 | + | disabled bool | |||
| 48 | + | want bool | |||
| 49 | + | }{ | |||
| 50 | + | {name: "default enabled", want: true}, | |||
| 51 | + | {name: "explicitly disabled", disabled: true, want: false}, | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | for _, tc := range cases { | |||
| 55 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 56 | + | root := t.TempDir() | |||
| 57 | + | t.Setenv("ZOT_HOME", filepath.Join(root, "zot-home")) | |||
| 58 | + | t.Setenv("HOME", filepath.Join(root, "home")) | |||
| 59 | + | if tc.disabled { | |||
| 60 | + | disabled := false | |||
| 61 | + | if err := SaveConfig(Config{PonytailEnabled: &disabled}); err != nil { | |||
| 62 | + | t.Fatal(err) | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | resolved, err := Resolve(Args{ | |||
| 67 | + | Provider: "ollama", | |||
| 68 | + | Model: "any-local-model", | |||
| 69 | + | CWD: filepath.Join(root, "project"), | |||
| 70 | + | NoLSP: true, | |||
| 71 | + | NoSkill: true, | |||
| 72 | + | NoTools: true, | |||
| 73 | + | }, false) | |||
| 74 | + | if err != nil { | |||
| 75 | + | t.Fatalf("Resolve failed: %v", err) | |||
| 76 | + | } | |||
| 77 | + | got := strings.Contains(resolved.SystemPrompt, PonytailSystemAddendum()) | |||
| 78 | + | if got != tc.want { | |||
| 79 | + | t.Fatalf("Ponytail prompt inclusion = %v, want %v:\n%s", got, tc.want, resolved.SystemPrompt) | |||
| 80 | + | } | |||
| 81 | + | if count := strings.Count(resolved.SystemPrompt, PonytailSystemAddendum()); count != btoi(tc.want) { | |||
| 82 | + | t.Fatalf("Ponytail prompt occurrence count = %d, want %d", count, btoi(tc.want)) | |||
| 83 | + | } | |||
| 84 | + | }) | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func btoi(value bool) int { | |||
| 89 | + | if value { | |||
| 90 | + | return 1 | |||
| 91 | + | } | |||
| 92 | + | return 0 | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestResolvePonytailModeRetainsAddendumWithSystemFile(t *testing.T) { | |||
| 96 | + | root := t.TempDir() | |||
| 97 | + | zotHome := filepath.Join(root, "zot-home") | |||
| 98 | + | t.Setenv("ZOT_HOME", zotHome) | |||
| 99 | + | t.Setenv("HOME", filepath.Join(root, "home")) | |||
| 100 | + | if err := os.MkdirAll(zotHome, 0o755); err != nil { | |||
| 101 | + | t.Fatal(err) | |||
| 102 | + | } | |||
| 103 | + | if err := os.WriteFile(filepath.Join(zotHome, "SYSTEM.md"), []byte("custom system identity"), 0o600); err != nil { | |||
| 104 | + | t.Fatal(err) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | resolved, err := Resolve(Args{ | |||
| 108 | + | Provider: "ollama", | |||
| 109 | + | Model: "any-local-model", | |||
| 110 | + | CWD: filepath.Join(root, "project"), | |||
| 111 | + | NoLSP: true, | |||
| 112 | + | NoSkill: true, | |||
| 113 | + | NoTools: true, | |||
| 114 | + | }, false) | |||
| 115 | + | if err != nil { | |||
| 116 | + | t.Fatalf("Resolve failed: %v", err) | |||
| 117 | + | } | |||
| 118 | + | if !strings.Contains(resolved.SystemPrompt, "custom system identity") { | |||
| 119 | + | t.Fatalf("SYSTEM.md content missing:\n%s", resolved.SystemPrompt) | |||
| 120 | + | } | |||
| 121 | + | if !strings.Contains(resolved.SystemPrompt, PonytailSystemAddendum()) { | |||
| 122 | + | t.Fatalf("SYSTEM.md replacement lost Ponytail addendum:\n%s", resolved.SystemPrompt) | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func TestResolvePonytailModePrecedesExplicitAppendPrompt(t *testing.T) { | |||
| 127 | + | root := t.TempDir() | |||
| 128 | + | t.Setenv("ZOT_HOME", filepath.Join(root, "zot-home")) | |||
| 129 | + | t.Setenv("HOME", filepath.Join(root, "home")) | |||
| 130 | + | ||||
| 131 | + | const explicit = "explicit invocation guidance" | |||
| 132 | + | resolved, err := Resolve(Args{ | |||
| 133 | + | Provider: "ollama", | |||
| 134 | + | Model: "any-local-model", | |||
| 135 | + | CWD: filepath.Join(root, "project"), | |||
| 136 | + | NoLSP: true, | |||
| 137 | + | NoSkill: true, | |||
| 138 | + | NoTools: true, | |||
| 139 | + | AppendSystemPrompt: []string{explicit}, | |||
| 140 | + | }, false) | |||
| 141 | + | if err != nil { | |||
| 142 | + | t.Fatalf("Resolve failed: %v", err) | |||
| 143 | + | } | |||
| 144 | + | ponytailIndex := strings.Index(resolved.SystemPrompt, PonytailSystemAddendum()) | |||
| 145 | + | explicitIndex := strings.Index(resolved.SystemPrompt, explicit) | |||
| 146 | + | if ponytailIndex < 0 || explicitIndex < 0 || ponytailIndex > explicitIndex { | |||
| 147 | + | t.Fatalf("prompt ordering = Ponytail %d, explicit %d:\n%s", ponytailIndex, explicitIndex, resolved.SystemPrompt) | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestResolvePonytailModeAppliesAcrossHeadlessModesAndCustomIdentity(t *testing.T) { | |||
| 152 | + | root := t.TempDir() | |||
| 153 | + | t.Setenv("ZOT_HOME", filepath.Join(root, "zot-home")) | |||
| 154 | + | t.Setenv("HOME", filepath.Join(root, "home")) | |||
| 155 | + | ||||
| 156 | + | modes := []Mode{"", ModePrint, ModeStream, ModeJSON, ModeRPC, ModeSubagentWorker} | |||
| 157 | + | for _, mode := range modes { | |||
| 158 | + | resolved, err := Resolve(Args{ | |||
| 159 | + | Mode: mode, | |||
| 160 | + | Provider: "ollama", | |||
| 161 | + | Model: "any-local-model", | |||
| 162 | + | CWD: filepath.Join(root, "project"), | |||
| 163 | + | NoLSP: true, | |||
| 164 | + | NoSkill: true, | |||
| 165 | + | NoTools: true, | |||
| 166 | + | SystemPrompt: "custom identity", | |||
| 167 | + | }, false) | |||
| 168 | + | if err != nil { | |||
| 169 | + | t.Fatalf("Resolve(%q) failed: %v", mode, err) | |||
| 170 | + | } | |||
| 171 | + | if !strings.Contains(resolved.SystemPrompt, "custom identity") { | |||
| 172 | + | t.Fatalf("Resolve(%q) lost custom identity:\n%s", mode, resolved.SystemPrompt) | |||
| 173 | + | } | |||
| 174 | + | if !strings.Contains(resolved.SystemPrompt, PonytailSystemAddendum()) { | |||
| 175 | + | t.Fatalf("Resolve(%q) omitted Ponytail guidance:\n%s", mode, resolved.SystemPrompt) | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func TestConfigSettingsStorePersistsPonytailWithoutChangingKnownFields(t *testing.T) { | |||
| 181 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 182 | + | if err := SaveConfig(Config{Theme: "dark", Provider: "ollama", Model: "local"}); err != nil { | |||
| 183 | + | t.Fatal(err) | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | if err := (configSettingsStore{}).SetPonytailEnabled(false); err != nil { | |||
| 187 | + | t.Fatal(err) | |||
| 188 | + | } | |||
| 189 | + | cfg, err := LoadConfig() | |||
| 190 | + | if err != nil { | |||
| 191 | + | t.Fatal(err) | |||
| 192 | + | } | |||
| 193 | + | if cfg.PonytailEnabled == nil || *cfg.PonytailEnabled { | |||
| 194 | + | t.Fatal("Ponytail mode was not persisted as disabled") | |||
| 195 | + | } | |||
| 196 | + | if cfg.Theme != "dark" || cfg.Provider != "ollama" || cfg.Model != "local" { | |||
| 197 | + | t.Fatalf("known config fields changed: %#v", cfg) | |||
| 198 | + | } | |||
| 199 | + | } | |||
M packages/agent/rpc.go +1 -2
| @@ -116,8 +116,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error { | |||
| 116 | 116 | return | |
| 117 | 117 | } | |
| 118 | 118 | resolved.MergeExtensionTools(adapter) | |
| 119 | - | oldTools := ag.Tools | |
| 120 | - | ag.SetTools(resolved.ToolRegistry) | |
| 119 | + | oldTools := ag.SetPromptConfig(resolved.SystemPrompt, resolved.ToolRegistry) | |
| 121 | 120 | if closeErr := tools.CloseLSPManagers(oldTools); closeErr != nil { | |
| 122 | 121 | fmt.Fprintln(os.Stderr, "reload LSP cleanup:", closeErr) | |
| 123 | 122 | } | |
| @@ -116,8 +116,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error { | |||||
| 116 | return | 116 | return | ||
| 117 | } | 117 | } | ||
| 118 | resolved.MergeExtensionTools(adapter) | 118 | resolved.MergeExtensionTools(adapter) | ||
| 119 | - | oldTools := ag.Tools | 119 | + | oldTools := ag.SetPromptConfig(resolved.SystemPrompt, resolved.ToolRegistry) |
| 120 | - | ag.SetTools(resolved.ToolRegistry) | |||
| 121 | if closeErr := tools.CloseLSPManagers(oldTools); closeErr != nil { | 120 | if closeErr := tools.CloseLSPManagers(oldTools); closeErr != nil { | ||
| 122 | fmt.Fprintln(os.Stderr, "reload LSP cleanup:", closeErr) | 121 | fmt.Fprintln(os.Stderr, "reload LSP cleanup:", closeErr) | ||
| 123 | } | 122 | } | ||
M packages/agent/sdk/sdk.go +1 -1
| @@ -361,7 +361,7 @@ func (r *Runtime) Close() error { | |||
| 361 | 361 | ag := r.agent | |
| 362 | 362 | r.mu.Unlock() | |
| 363 | 363 | if ag != nil { | |
| 364 | - | return agenttools.CloseLSPManagers(ag.Tools) | |
| 364 | + | return agenttools.CloseLSPManagers(ag.ToolsSnapshot()) | |
| 365 | 365 | } | |
| 366 | 366 | return nil | |
| 367 | 367 | } | |
| @@ -361,7 +361,7 @@ func (r *Runtime) Close() error { | |||||
| 361 | ag := r.agent | 361 | ag := r.agent | ||
| 362 | r.mu.Unlock() | 362 | r.mu.Unlock() | ||
| 363 | if ag != nil { | 363 | if ag != nil { | ||
| 364 | - | return agenttools.CloseLSPManagers(ag.Tools) | 364 | + | return agenttools.CloseLSPManagers(ag.ToolsSnapshot()) |
| 365 | } | 365 | } | ||
| 366 | return nil | 366 | return nil | ||
| 367 | } | 367 | } | ||
M packages/agent/settings_store.go +14 -0
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package agent | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 4 | 6 | "github.com/patriceckhart/zot/packages/provider" | |
| 5 | 7 | "github.com/patriceckhart/zot/packages/tui" | |
| 6 | 8 | ) | |
| @@ -68,6 +70,18 @@ func (configSettingsStore) SetAutoSubagents(enabled bool) error { | |||
| 68 | 70 | return SaveConfig(cfg) | |
| 69 | 71 | } | |
| 70 | 72 | ||
| 73 | + | func (configSettingsStore) SetPonytailEnabled(enabled bool) error { | |
| 74 | + | cfg, err := LoadConfig() | |
| 75 | + | if err != nil { | |
| 76 | + | return fmt.Errorf("load config for Ponytail setting: %w", err) | |
| 77 | + | } | |
| 78 | + | cfg.PonytailEnabled = &enabled | |
| 79 | + | if err := SaveConfig(cfg); err != nil { | |
| 80 | + | return fmt.Errorf("save Ponytail setting: %w", err) | |
| 81 | + | } | |
| 82 | + | return nil | |
| 83 | + | } | |
| 84 | + | ||
| 71 | 85 | func (configSettingsStore) SetFastMode(enabled bool) error { | |
| 72 | 86 | cfg, err := LoadConfig() | |
| 73 | 87 | if err != nil { | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package agent | 1 | package agent | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "fmt" | |||
| 5 | + | ||||
| 4 | "github.com/patriceckhart/zot/packages/provider" | 6 | "github.com/patriceckhart/zot/packages/provider" | ||
| 5 | "github.com/patriceckhart/zot/packages/tui" | 7 | "github.com/patriceckhart/zot/packages/tui" | ||
| 6 | ) | 8 | ) | ||
| @@ -68,6 +70,18 @@ func (configSettingsStore) SetAutoSubagents(enabled bool) error { | |||||
| 68 | return SaveConfig(cfg) | 70 | return SaveConfig(cfg) | ||
| 69 | } | 71 | } | ||
| 70 | 72 | ||||
| 73 | + | func (configSettingsStore) SetPonytailEnabled(enabled bool) error { | |||
| 74 | + | cfg, err := LoadConfig() | |||
| 75 | + | if err != nil { | |||
| 76 | + | return fmt.Errorf("load config for Ponytail setting: %w", err) | |||
| 77 | + | } | |||
| 78 | + | cfg.PonytailEnabled = &enabled | |||
| 79 | + | if err := SaveConfig(cfg); err != nil { | |||
| 80 | + | return fmt.Errorf("save Ponytail setting: %w", err) | |||
| 81 | + | } | |||
| 82 | + | return nil | |||
| 83 | + | } | |||
| 84 | + | ||||
| 71 | func (configSettingsStore) SetFastMode(enabled bool) error { | 85 | func (configSettingsStore) SetFastMode(enabled bool) error { | ||
| 72 | cfg, err := LoadConfig() | 86 | cfg, err := LoadConfig() | ||
| 73 | if err != nil { | 87 | if err != nil { | ||
M packages/core/agent.go +12 -0
| @@ -275,6 +275,18 @@ func (a *Agent) SetSystemPrompt(system string) { | |||
| 275 | 275 | a.mu.Unlock() | |
| 276 | 276 | } | |
| 277 | 277 | ||
| 278 | + | // SetPromptConfig atomically replaces the system prompt and tool registry. | |
| 279 | + | // It returns the previous registry so the caller can close resources that are | |
| 280 | + | // no longer owned by the agent. | |
| 281 | + | func (a *Agent) SetPromptConfig(system string, tools Registry) Registry { | |
| 282 | + | a.mu.Lock() | |
| 283 | + | defer a.mu.Unlock() | |
| 284 | + | oldTools := a.Tools | |
| 285 | + | a.System = system | |
| 286 | + | a.Tools = tools | |
| 287 | + | return oldTools | |
| 288 | + | } | |
| 289 | + | ||
| 278 | 290 | // PromptConfig returns a consistent snapshot of the system prompt and tools | |
| 279 | 291 | // used to construct a provider request. | |
| 280 | 292 | func (a *Agent) PromptConfig() (string, Registry) { | |
| @@ -275,6 +275,18 @@ func (a *Agent) SetSystemPrompt(system string) { | |||||
| 275 | a.mu.Unlock() | 275 | a.mu.Unlock() | ||
| 276 | } | 276 | } | ||
| 277 | 277 | ||||
| 278 | + | // SetPromptConfig atomically replaces the system prompt and tool registry. | |||
| 279 | + | // It returns the previous registry so the caller can close resources that are | |||
| 280 | + | // no longer owned by the agent. | |||
| 281 | + | func (a *Agent) SetPromptConfig(system string, tools Registry) Registry { | |||
| 282 | + | a.mu.Lock() | |||
| 283 | + | defer a.mu.Unlock() | |||
| 284 | + | oldTools := a.Tools | |||
| 285 | + | a.System = system | |||
| 286 | + | a.Tools = tools | |||
| 287 | + | return oldTools | |||
| 288 | + | } | |||
| 289 | + | ||||
| 278 | // PromptConfig returns a consistent snapshot of the system prompt and tools | 290 | // PromptConfig returns a consistent snapshot of the system prompt and tools | ||
| 279 | // used to construct a provider request. | 291 | // used to construct a provider request. | ||
| 280 | func (a *Agent) PromptConfig() (string, Registry) { | 292 | func (a *Agent) PromptConfig() (string, Registry) { | ||
M packages/core/core_test.go +20 -0
| @@ -20,6 +20,26 @@ func TestAgentRejectsEmptyPrompt(t *testing.T) { | |||
| 20 | 20 | } | |
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | + | func TestAgentSetPromptConfigSwapsPromptAndToolsAtomically(t *testing.T) { | |
| 24 | + | oldTool := &recordingTool{} | |
| 25 | + | newTool := &recordingTool{} | |
| 26 | + | oldRegistry := Registry{"old": oldTool} | |
| 27 | + | newRegistry := Registry{"new": newTool} | |
| 28 | + | ag := NewAgent(nil, "model", "old system", oldRegistry) | |
| 29 | + | ||
| 30 | + | previous := ag.SetPromptConfig("new system", newRegistry) | |
| 31 | + | if previous["old"] != oldTool { | |
| 32 | + | t.Fatalf("previous registry = %#v, want old registry", previous) | |
| 33 | + | } | |
| 34 | + | system, tools := ag.PromptConfig() | |
| 35 | + | if system != "new system" { | |
| 36 | + | t.Fatalf("system prompt = %q, want new system", system) | |
| 37 | + | } | |
| 38 | + | if tools["new"] != newTool || len(tools) != 1 { | |
| 39 | + | t.Fatalf("current registry = %#v, want new registry", tools) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 23 | 43 | func TestSessionRoundTrip(t *testing.T) { | |
| 24 | 44 | dir := t.TempDir() | |
| 25 | 45 | os.Setenv("ZOT_HOME", dir) | |
| @@ -20,6 +20,26 @@ func TestAgentRejectsEmptyPrompt(t *testing.T) { | |||||
| 20 | } | 20 | } | ||
| 21 | } | 21 | } | ||
| 22 | 22 | ||||
| 23 | + | func TestAgentSetPromptConfigSwapsPromptAndToolsAtomically(t *testing.T) { | |||
| 24 | + | oldTool := &recordingTool{} | |||
| 25 | + | newTool := &recordingTool{} | |||
| 26 | + | oldRegistry := Registry{"old": oldTool} | |||
| 27 | + | newRegistry := Registry{"new": newTool} | |||
| 28 | + | ag := NewAgent(nil, "model", "old system", oldRegistry) | |||
| 29 | + | ||||
| 30 | + | previous := ag.SetPromptConfig("new system", newRegistry) | |||
| 31 | + | if previous["old"] != oldTool { | |||
| 32 | + | t.Fatalf("previous registry = %#v, want old registry", previous) | |||
| 33 | + | } | |||
| 34 | + | system, tools := ag.PromptConfig() | |||
| 35 | + | if system != "new system" { | |||
| 36 | + | t.Fatalf("system prompt = %q, want new system", system) | |||
| 37 | + | } | |||
| 38 | + | if tools["new"] != newTool || len(tools) != 1 { | |||
| 39 | + | t.Fatalf("current registry = %#v, want new registry", tools) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 23 | func TestSessionRoundTrip(t *testing.T) { | 43 | func TestSessionRoundTrip(t *testing.T) { | ||
| 24 | dir := t.TempDir() | 44 | dir := t.TempDir() | ||
| 25 | os.Setenv("ZOT_HOME", dir) | 45 | os.Setenv("ZOT_HOME", dir) | ||