feat: add before_agent_start prompt replacement hook
- commit
- e18c6ae5d2a0109ae9d8ac46e27a90f04a905271
- author
- patriceckhart · · 2026-09-05 06:34 UTC
- parent
- 0d096bd
- signature
- unsigned
- refs
- tag: v0.3.64
20 files · +1254 -72
M docs/extensions.md +65 -5
| @@ -263,6 +263,8 @@ shown in the confirmation dialog. | |||
| 263 | 263 | ||
| 264 | 264 | Interceptable events: | |
| 265 | 265 | ||
| 266 | + | - `before_agent_start`: inspect and replace the complete system prompt before | |
| 267 | + | the first model call. See [System prompt replacement](#system-prompt-replacement). | |
| 266 | 268 | - `tool_call`: block the call (model sees `reason` as the tool | |
| 267 | 269 | error) or rewrite args via `modified_args`. | |
| 268 | 270 | - `turn_start`: block the turn before the model is called. Useful | |
| @@ -273,6 +275,55 @@ Interceptable events: | |||
| 273 | 275 | text stays in the transcript so the model sees what it actually | |
| 274 | 276 | said on subsequent turns. | |
| 275 | 277 | ||
| 278 | + | #### System prompt replacement | |
| 279 | + | ||
| 280 | + | Subscribe with `{"type":"subscribe","intercept":["before_agent_start"]}`. | |
| 281 | + | After prompt assembly (including context files, skills, and extension tools), | |
| 282 | + | zot sends a request before the first agent model call: | |
| 283 | + | ||
| 284 | + | ```json | |
| 285 | + | {"type":"event_intercept","id":"start-1","event":"before_agent_start","session_id":"session-123","agent_run_id":"run-456","cwd":"/work/repo","provider":"anthropic","model":"claude-opus-4-7","system_prompt":"The complete current system prompt"} | |
| 286 | + | ``` | |
| 287 | + | ||
| 288 | + | Reply with an exact replacement: | |
| 289 | + | ||
| 290 | + | ```json | |
| 291 | + | {"type":"event_intercept_response","id":"start-1","system_prompt":"The complete replacement system prompt"} | |
| 292 | + | ``` | |
| 293 | + | ||
| 294 | + | No trimming, merging, or appending is performed by the host. `""` intentionally | |
| 295 | + | removes the prompt; omission leaves it unchanged. `block` does not block startup. | |
| 296 | + | Extensions run serially in successful load order (explicit extensions first, | |
| 297 | + | then discovery); each receives the previous extension's result. | |
| 298 | + | ||
| 299 | + | The result remains stable across normal user messages, retries, and tool-loop | |
| 300 | + | steps. The event runs again after a model change, conversation clear, session | |
| 301 | + | change, or explicit prompt rebuild/reset (including interactive extension reload). | |
| 302 | + | Rebuilds start from the unmodified base prompt, not the last extension result. | |
| 303 | + | If a prompt rebuild/reset occurs while a hook is waiting, zot discards that | |
| 304 | + | preparation's result and runs the chain again against the current base prompt, | |
| 305 | + | even when the reset keeps the same text. The new pass has a new `agent_run_id`. | |
| 306 | + | Cancellation stops preparation without committing a pending replacement. | |
| 307 | + | `session_start` remains a separate one-way setup notification. | |
| 308 | + | ||
| 309 | + | `session_id` is the persisted conversation ID when available, otherwise a | |
| 310 | + | runtime-local ID. `agent_run_id` identifies one preparation pass and changes | |
| 311 | + | on each rebuild. It is shared by all extensions in that pass. | |
| 312 | + | ||
| 313 | + | Each extension has a five-second deadline including the request pipe write. | |
| 314 | + | Invalid/non-string values, malformed responses, crashes, and timeouts preserve | |
| 315 | + | the current prompt and produce an extension log diagnostic and host warning. | |
| 316 | + | An omitted value is a normal no-op. The encoded `system_prompt` response value | |
| 317 | + | is limited to 1 MiB; complete transport frames are limited to 4 MiB. Exceeding | |
| 318 | + | the frame limit disconnects the extension's read loop. A blocked request write | |
| 319 | + | closes its input pipe to release the writer. Other subscribed extensions still | |
| 320 | + | run unless the user cancels startup. | |
| 321 | + | ||
| 322 | + | Supported in interactive, print, JSON, and RPC modes. RPC warnings use | |
| 323 | + | `ext_notify`. Subscribed extensions see the entire prompt, including private | |
| 324 | + | context-file instructions. This feature is not a security boundary and does | |
| 325 | + | not change tool permissions or validate the safety of replacement instructions. | |
| 326 | + | ||
| 276 | 327 | #### `event_intercept_response` | |
| 277 | 328 | ||
| 278 | 329 | Reply to an `event_intercept` from the host. All fields default to | |
| @@ -284,12 +335,14 @@ Reply to an `event_intercept` from the host. All fields default to | |||
| 284 | 335 | | `reason` | refusal text (on block) or pass-through note. | | |
| 285 | 336 | | `modified_args` | for `tool_call`: rewritten JSON args the tool will actually see. Must be a valid JSON object. Ignored when `block` is true. | | |
| 286 | 337 | | `replace_text` | for `assistant_message`: replaces the user-visible text. The model's original output still lives in the transcript. Ignored when `block` is true. | | |
| 338 | + | | `system_prompt` | for `before_agent_start`: exact string replacement, including an empty string. Omission keeps the current prompt. | | |
| 287 | 339 | ||
| 288 | - | Missing the response within 5s is treated as "allow" (i.e. an | |
| 289 | - | unresponsive extension never stalls the agent). When multiple | |
| 340 | + | Missing the response within 5s is treated as "allow, unchanged". For | |
| 341 | + | `before_agent_start`, this deadline also includes the request write; legacy | |
| 342 | + | interceptors start the timeout after writing the request. When multiple | |
| 290 | 343 | extensions subscribe to the same event, they're consulted serially; | |
| 291 | - | the first `block` wins and rewrites (args / text) chain: each | |
| 292 | - | subsequent interceptor sees the previous one's output. | |
| 344 | + | the first `block` wins for the blocking events and rewrites (args / text) | |
| 345 | + | chain. `before_agent_start` follows the non-blocking replacement rules above. | |
| 293 | 346 | ||
| 294 | 347 | ```json | |
| 295 | 348 | {"type":"event_intercept_response","id":"...", | |
| @@ -602,11 +655,18 @@ host metadata such as `HostInfo.CWD`, `Provider`, `Model`, `ZotVersion`, | |||
| 602 | 655 | `ExtensionDir`, or `DataDir`. The SDK sends `hello`, waits for | |
| 603 | 656 | `hello_ack`, runs `OnHello`, announces registrations, then sends `ready`. | |
| 604 | 657 | ||
| 605 | - | The SDK has four interceptor hooks, all optional: | |
| 658 | + | The SDK has five interceptor hooks, all optional: | |
| 606 | 659 | ||
| 607 | 660 | ```go | |
| 608 | 661 | // e is the *ext.Extension returned by ext.New(...). | |
| 609 | 662 | ||
| 663 | + | // Replace the complete prompt once per session or explicit rebuild. | |
| 664 | + | // Return nil to keep it, or a pointer to "" to remove it entirely. | |
| 665 | + | e.InterceptBeforeAgentStart(func(event ext.BeforeAgentStartEvent) *string { | |
| 666 | + | prompt := event.SystemPrompt + "\nUse the project's preferred coding style." | |
| 667 | + | return &prompt | |
| 668 | + | }) | |
| 669 | + | ||
| 610 | 670 | // Refuse calls or rewrite args before they run. | |
| 611 | 671 | e.InterceptToolCall(func(tool string, args json.RawMessage) (bool, string) { | |
| 612 | 672 | if tool == "bash" { /* inspect args, return false, reason */ } | |
| @@ -263,6 +263,8 @@ shown in the confirmation dialog. | |||||
| 263 | 263 | ||||
| 264 | Interceptable events: | 264 | Interceptable events: | ||
| 265 | 265 | ||||
| 266 | + | - `before_agent_start`: inspect and replace the complete system prompt before | |||
| 267 | + | the first model call. See [System prompt replacement](#system-prompt-replacement). | |||
| 266 | - `tool_call`: block the call (model sees `reason` as the tool | 268 | - `tool_call`: block the call (model sees `reason` as the tool | ||
| 267 | error) or rewrite args via `modified_args`. | 269 | error) or rewrite args via `modified_args`. | ||
| 268 | - `turn_start`: block the turn before the model is called. Useful | 270 | - `turn_start`: block the turn before the model is called. Useful | ||
| @@ -273,6 +275,55 @@ Interceptable events: | |||||
| 273 | text stays in the transcript so the model sees what it actually | 275 | text stays in the transcript so the model sees what it actually | ||
| 274 | said on subsequent turns. | 276 | said on subsequent turns. | ||
| 275 | 277 | ||||
| 278 | + | #### System prompt replacement | |||
| 279 | + | ||||
| 280 | + | Subscribe with `{"type":"subscribe","intercept":["before_agent_start"]}`. | |||
| 281 | + | After prompt assembly (including context files, skills, and extension tools), | |||
| 282 | + | zot sends a request before the first agent model call: | |||
| 283 | + | ||||
| 284 | + | ```json | |||
| 285 | + | {"type":"event_intercept","id":"start-1","event":"before_agent_start","session_id":"session-123","agent_run_id":"run-456","cwd":"/work/repo","provider":"anthropic","model":"claude-opus-4-7","system_prompt":"The complete current system prompt"} | |||
| 286 | + | ``` | |||
| 287 | + | ||||
| 288 | + | Reply with an exact replacement: | |||
| 289 | + | ||||
| 290 | + | ```json | |||
| 291 | + | {"type":"event_intercept_response","id":"start-1","system_prompt":"The complete replacement system prompt"} | |||
| 292 | + | ``` | |||
| 293 | + | ||||
| 294 | + | No trimming, merging, or appending is performed by the host. `""` intentionally | |||
| 295 | + | removes the prompt; omission leaves it unchanged. `block` does not block startup. | |||
| 296 | + | Extensions run serially in successful load order (explicit extensions first, | |||
| 297 | + | then discovery); each receives the previous extension's result. | |||
| 298 | + | ||||
| 299 | + | The result remains stable across normal user messages, retries, and tool-loop | |||
| 300 | + | steps. The event runs again after a model change, conversation clear, session | |||
| 301 | + | change, or explicit prompt rebuild/reset (including interactive extension reload). | |||
| 302 | + | Rebuilds start from the unmodified base prompt, not the last extension result. | |||
| 303 | + | If a prompt rebuild/reset occurs while a hook is waiting, zot discards that | |||
| 304 | + | preparation's result and runs the chain again against the current base prompt, | |||
| 305 | + | even when the reset keeps the same text. The new pass has a new `agent_run_id`. | |||
| 306 | + | Cancellation stops preparation without committing a pending replacement. | |||
| 307 | + | `session_start` remains a separate one-way setup notification. | |||
| 308 | + | ||||
| 309 | + | `session_id` is the persisted conversation ID when available, otherwise a | |||
| 310 | + | runtime-local ID. `agent_run_id` identifies one preparation pass and changes | |||
| 311 | + | on each rebuild. It is shared by all extensions in that pass. | |||
| 312 | + | ||||
| 313 | + | Each extension has a five-second deadline including the request pipe write. | |||
| 314 | + | Invalid/non-string values, malformed responses, crashes, and timeouts preserve | |||
| 315 | + | the current prompt and produce an extension log diagnostic and host warning. | |||
| 316 | + | An omitted value is a normal no-op. The encoded `system_prompt` response value | |||
| 317 | + | is limited to 1 MiB; complete transport frames are limited to 4 MiB. Exceeding | |||
| 318 | + | the frame limit disconnects the extension's read loop. A blocked request write | |||
| 319 | + | closes its input pipe to release the writer. Other subscribed extensions still | |||
| 320 | + | run unless the user cancels startup. | |||
| 321 | + | ||||
| 322 | + | Supported in interactive, print, JSON, and RPC modes. RPC warnings use | |||
| 323 | + | `ext_notify`. Subscribed extensions see the entire prompt, including private | |||
| 324 | + | context-file instructions. This feature is not a security boundary and does | |||
| 325 | + | not change tool permissions or validate the safety of replacement instructions. | |||
| 326 | + | ||||
| 276 | #### `event_intercept_response` | 327 | #### `event_intercept_response` | ||
| 277 | 328 | ||||
| 278 | Reply to an `event_intercept` from the host. All fields default to | 329 | Reply to an `event_intercept` from the host. All fields default to | ||
| @@ -284,12 +335,14 @@ Reply to an `event_intercept` from the host. All fields default to | |||||
| 284 | | `reason` | refusal text (on block) or pass-through note. | | 335 | | `reason` | refusal text (on block) or pass-through note. | | ||
| 285 | | `modified_args` | for `tool_call`: rewritten JSON args the tool will actually see. Must be a valid JSON object. Ignored when `block` is true. | | 336 | | `modified_args` | for `tool_call`: rewritten JSON args the tool will actually see. Must be a valid JSON object. Ignored when `block` is true. | | ||
| 286 | | `replace_text` | for `assistant_message`: replaces the user-visible text. The model's original output still lives in the transcript. Ignored when `block` is true. | | 337 | | `replace_text` | for `assistant_message`: replaces the user-visible text. The model's original output still lives in the transcript. Ignored when `block` is true. | | ||
| 338 | + | | `system_prompt` | for `before_agent_start`: exact string replacement, including an empty string. Omission keeps the current prompt. | | |||
| 287 | 339 | ||||
| 288 | - | Missing the response within 5s is treated as "allow" (i.e. an | 340 | + | Missing the response within 5s is treated as "allow, unchanged". For |
| 289 | - | unresponsive extension never stalls the agent). When multiple | 341 | + | `before_agent_start`, this deadline also includes the request write; legacy |
| 342 | + | interceptors start the timeout after writing the request. When multiple | |||
| 290 | extensions subscribe to the same event, they're consulted serially; | 343 | extensions subscribe to the same event, they're consulted serially; | ||
| 291 | - | the first `block` wins and rewrites (args / text) chain: each | 344 | + | the first `block` wins for the blocking events and rewrites (args / text) |
| 292 | - | subsequent interceptor sees the previous one's output. | 345 | + | chain. `before_agent_start` follows the non-blocking replacement rules above. |
| 293 | 346 | ||||
| 294 | ```json | 347 | ```json | ||
| 295 | {"type":"event_intercept_response","id":"...", | 348 | {"type":"event_intercept_response","id":"...", | ||
| @@ -602,11 +655,18 @@ host metadata such as `HostInfo.CWD`, `Provider`, `Model`, `ZotVersion`, | |||||
| 602 | `ExtensionDir`, or `DataDir`. The SDK sends `hello`, waits for | 655 | `ExtensionDir`, or `DataDir`. The SDK sends `hello`, waits for | ||
| 603 | `hello_ack`, runs `OnHello`, announces registrations, then sends `ready`. | 656 | `hello_ack`, runs `OnHello`, announces registrations, then sends `ready`. | ||
| 604 | 657 | ||||
| 605 | - | The SDK has four interceptor hooks, all optional: | 658 | + | The SDK has five interceptor hooks, all optional: |
| 606 | 659 | ||||
| 607 | ```go | 660 | ```go | ||
| 608 | // e is the *ext.Extension returned by ext.New(...). | 661 | // e is the *ext.Extension returned by ext.New(...). | ||
| 609 | 662 | ||||
| 663 | + | // Replace the complete prompt once per session or explicit rebuild. | |||
| 664 | + | // Return nil to keep it, or a pointer to "" to remove it entirely. | |||
| 665 | + | e.InterceptBeforeAgentStart(func(event ext.BeforeAgentStartEvent) *string { | |||
| 666 | + | prompt := event.SystemPrompt + "\nUse the project's preferred coding style." | |||
| 667 | + | return &prompt | |||
| 668 | + | }) | |||
| 669 | + | ||||
| 610 | // Refuse calls or rewrite args before they run. | 670 | // Refuse calls or rewrite args before they run. | ||
| 611 | e.InterceptToolCall(func(tool string, args json.RawMessage) (bool, string) { | 671 | e.InterceptToolCall(func(tool string, args json.RawMessage) (bool, string) { | ||
| 612 | if tool == "bash" { /* inspect args, return false, reason */ } | 672 | if tool == "bash" { /* inspect args, return false, reason */ } | ||
M docs/rpc.md +19 -0
| @@ -25,6 +25,16 @@ 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`, `--no-context-files`. Sessions are disabled by default in RPC mode; the embedding application owns persistence. | |
| 27 | 27 | ||
| 28 | + | ## Extension system prompts | |
| 29 | + | ||
| 30 | + | Extensions can subscribe to `before_agent_start` to inspect and replace the | |
| 31 | + | complete system prompt before the first agent model call. This uses the | |
| 32 | + | extension subprocess protocol, not a new RPC client command. The result is | |
| 33 | + | reused for ordinary follow-up prompts and tool-loop steps; `clear` and a model | |
| 34 | + | change invalidate it for the next prompt. Failures keep the current prompt and | |
| 35 | + | surface as `ext_notify` warnings. See [System prompt replacement](extensions.md#system-prompt-replacement) | |
| 36 | + | for chaining, deadlines, size limits, and privacy considerations. | |
| 37 | + | ||
| 28 | 38 | ## Auth | |
| 29 | 39 | ||
| 30 | 40 | 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: | |
| @@ -135,6 +145,15 @@ Full transcript. | |||
| 135 | 145 | ||
| 136 | 146 | Response data: `{"messages": [<message>, ...]}`. See **message shape** below. | |
| 137 | 147 | ||
| 148 | + | ### Runtime mutations while busy | |
| 149 | + | ||
| 150 | + | `clear`, `set_model`, and `set_reasoning` require an idle agent. During prompt | |
| 151 | + | preparation (including extension hooks), model/tool execution, or compaction, | |
| 152 | + | they return `success:false` with an `agent is busy` error and leave state | |
| 153 | + | unchanged. They are not queued. Wait for the active operation to finish, or | |
| 154 | + | send `abort`, wait for completion, and retry. The RPC read loop stays responsive | |
| 155 | + | to `abort` while an operation is busy. | |
| 156 | + | ||
| 138 | 157 | ### `clear` | |
| 139 | 158 | ||
| 140 | 159 | Drop the entire transcript. Equivalent to the `/clear` slash command. | |
| @@ -25,6 +25,16 @@ 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`, `--no-context-files`. 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`, `--no-context-files`. Sessions are disabled by default in RPC mode; the embedding application owns persistence. | ||
| 27 | 27 | ||||
| 28 | + | ## Extension system prompts | |||
| 29 | + | ||||
| 30 | + | Extensions can subscribe to `before_agent_start` to inspect and replace the | |||
| 31 | + | complete system prompt before the first agent model call. This uses the | |||
| 32 | + | extension subprocess protocol, not a new RPC client command. The result is | |||
| 33 | + | reused for ordinary follow-up prompts and tool-loop steps; `clear` and a model | |||
| 34 | + | change invalidate it for the next prompt. Failures keep the current prompt and | |||
| 35 | + | surface as `ext_notify` warnings. See [System prompt replacement](extensions.md#system-prompt-replacement) | |||
| 36 | + | for chaining, deadlines, size limits, and privacy considerations. | |||
| 37 | + | ||||
| 28 | ## Auth | 38 | ## Auth | ||
| 29 | 39 | ||||
| 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: | 40 | 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: | ||
| @@ -135,6 +145,15 @@ Full transcript. | |||||
| 135 | 145 | ||||
| 136 | Response data: `{"messages": [<message>, ...]}`. See **message shape** below. | 146 | Response data: `{"messages": [<message>, ...]}`. See **message shape** below. | ||
| 137 | 147 | ||||
| 148 | + | ### Runtime mutations while busy | |||
| 149 | + | ||||
| 150 | + | `clear`, `set_model`, and `set_reasoning` require an idle agent. During prompt | |||
| 151 | + | preparation (including extension hooks), model/tool execution, or compaction, | |||
| 152 | + | they return `success:false` with an `agent is busy` error and leave state | |||
| 153 | + | unchanged. They are not queued. Wait for the active operation to finish, or | |||
| 154 | + | send `abort`, wait for completion, and retry. The RPC read loop stays responsive | |||
| 155 | + | to `abort` while an operation is busy. | |||
| 156 | + | ||||
| 138 | ### `clear` | 157 | ### `clear` | ||
| 139 | 158 | ||||
| 140 | Drop the entire transcript. Equivalent to the `/clear` slash command. | 159 | Drop the entire transcript. Equivalent to the `/clear` slash command. | ||
A packages/agent/before_start.go +24 -0
| @@ -0,0 +1,24 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/rand" | |
| 6 | + | ||
| 7 | + | "github.com/patriceckhart/zot/packages/agent/extensions" | |
| 8 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 9 | + | "github.com/patriceckhart/zot/packages/core" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func wireBeforeAgentStart(ag *core.Agent, mgr *extensions.Manager, provider string) { | |
| 13 | + | runtimeSession := rand.Text() | |
| 14 | + | ag.BeforeStart = func(ctx context.Context, system string) string { | |
| 15 | + | session := ag.SessionID | |
| 16 | + | if session == "" { | |
| 17 | + | session = runtimeSession | |
| 18 | + | } | |
| 19 | + | return mgr.InterceptBeforeAgentStart(ctx, extproto.EventInterceptFromHost{ | |
| 20 | + | SystemPrompt: &system, SessionID: session, AgentRunID: rand.Text(), | |
| 21 | + | Provider: provider, Model: ag.Model, | |
| 22 | + | }) | |
| 23 | + | } | |
| 24 | + | } | |
| @@ -0,0 +1,24 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/rand" | |||
| 6 | + | ||||
| 7 | + | "github.com/patriceckhart/zot/packages/agent/extensions" | |||
| 8 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |||
| 9 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func wireBeforeAgentStart(ag *core.Agent, mgr *extensions.Manager, provider string) { | |||
| 13 | + | runtimeSession := rand.Text() | |||
| 14 | + | ag.BeforeStart = func(ctx context.Context, system string) string { | |||
| 15 | + | session := ag.SessionID | |||
| 16 | + | if session == "" { | |||
| 17 | + | session = runtimeSession | |||
| 18 | + | } | |||
| 19 | + | return mgr.InterceptBeforeAgentStart(ctx, extproto.EventInterceptFromHost{ | |||
| 20 | + | SystemPrompt: &system, SessionID: session, AgentRunID: rand.Text(), | |||
| 21 | + | Provider: provider, Model: ag.Model, | |||
| 22 | + | }) | |||
| 23 | + | } | |||
| 24 | + | } | |||
A packages/agent/before_start_test.go +107 -0
| @@ -0,0 +1,107 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "bytes" | |
| 6 | + | "context" | |
| 7 | + | "encoding/json" | |
| 8 | + | "fmt" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/patriceckhart/zot/packages/agent/extensions" | |
| 15 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 16 | + | "github.com/patriceckhart/zot/packages/core" | |
| 17 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func TestStartExtensionProcessHelper(t *testing.T) { | |
| 21 | + | if os.Getenv("ZOT_TEST_START_EXTENSION") != "1" { | |
| 22 | + | return | |
| 23 | + | } | |
| 24 | + | enc := json.NewEncoder(os.Stdout) | |
| 25 | + | enc.Encode(map[string]any{"type": "hello", "name": "start-test", "version": "1"}) | |
| 26 | + | enc.Encode(extproto.SubscribeFromExt{Type: "subscribe", Intercept: []string{"before_agent_start"}}) | |
| 27 | + | enc.Encode(extproto.ReadyFromExt{Type: "ready"}) | |
| 28 | + | scanner := bufio.NewScanner(os.Stdin) | |
| 29 | + | count := 0 | |
| 30 | + | for scanner.Scan() { | |
| 31 | + | var ev extproto.EventInterceptFromHost | |
| 32 | + | if json.Unmarshal(scanner.Bytes(), &ev) != nil { | |
| 33 | + | os.Exit(2) | |
| 34 | + | } | |
| 35 | + | if ev.Type == "shutdown" { | |
| 36 | + | os.Exit(0) | |
| 37 | + | } | |
| 38 | + | if ev.Event != "before_agent_start" { | |
| 39 | + | continue | |
| 40 | + | } | |
| 41 | + | if ev.SystemPrompt == nil || ev.CWD == "" || ev.Provider == "" || ev.Model == "" || ev.SessionID == "" || ev.AgentRunID == "" { | |
| 42 | + | os.Exit(3) | |
| 43 | + | } | |
| 44 | + | count++ | |
| 45 | + | value, _ := json.Marshal(fmt.Sprintf("%s/%d/%s", *ev.SystemPrompt, count, ev.Model)) | |
| 46 | + | enc.Encode(extproto.EventInterceptResponseFromExt{Type: "event_intercept_response", ID: ev.ID, SystemPrompt: value}) | |
| 47 | + | } | |
| 48 | + | os.Exit(0) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | type startCaptureClient struct{ requests []provider.Request } | |
| 52 | + | ||
| 53 | + | func (*startCaptureClient) Name() string { return "test" } | |
| 54 | + | func (c *startCaptureClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) { | |
| 55 | + | c.requests = append(c.requests, req) | |
| 56 | + | ch := make(chan provider.Event, 1) | |
| 57 | + | ch <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "ok"}}}} | |
| 58 | + | close(ch) | |
| 59 | + | return ch, nil | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestRPCBeforeAgentStart(t *testing.T) { | |
| 63 | + | t.Setenv("ZOT_TEST_START_EXTENSION", "1") | |
| 64 | + | exe, err := os.Executable() | |
| 65 | + | if err != nil { | |
| 66 | + | t.Fatal(err) | |
| 67 | + | } | |
| 68 | + | dir := t.TempDir() | |
| 69 | + | manifest, _ := json.Marshal(map[string]any{"name": "start-test", "version": "1", "exec": exe, "args": []string{"-test.run=^TestStartExtensionProcessHelper$"}, "enabled": true}) | |
| 70 | + | if err := os.WriteFile(filepath.Join(dir, "extension.json"), manifest, 0600); err != nil { | |
| 71 | + | t.Fatal(err) | |
| 72 | + | } | |
| 73 | + | models := provider.ModelsForProvider("anthropic") | |
| 74 | + | if len(models) < 2 { | |
| 75 | + | t.Fatal("need two catalog models") | |
| 76 | + | } | |
| 77 | + | m := extensions.New(t.TempDir(), dir, "test", "anthropic", models[0].ID, nil) | |
| 78 | + | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 79 | + | defer cancel() | |
| 80 | + | defer m.Stop(time.Second) | |
| 81 | + | if errs := m.LoadExplicit(ctx, []string{dir}); len(errs) > 0 { | |
| 82 | + | t.Fatal(errs) | |
| 83 | + | } | |
| 84 | + | m.WaitForReady(time.Second) | |
| 85 | + | client := &startCaptureClient{} | |
| 86 | + | ag := core.NewAgent(client, models[0].ID, "complete base", nil) | |
| 87 | + | wireNonInteractiveAgentExtHooks(ctx, ag, m) | |
| 88 | + | var out bytes.Buffer | |
| 89 | + | server := &rpcServer{agent: ag, provider: "anthropic", model: ag.Model, out: &out} | |
| 90 | + | prompt := func(want string) { | |
| 91 | + | t.Helper() | |
| 92 | + | if err := ag.Prompt(ctx, "hello", nil, nil); err != nil { | |
| 93 | + | t.Fatal(err) | |
| 94 | + | } | |
| 95 | + | if got := client.requests[len(client.requests)-1].System; got != want { | |
| 96 | + | t.Fatalf("request system=%q want %q", got, want) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | prompt("complete base/1/" + models[0].ID) | |
| 100 | + | prompt("complete base/1/" + models[0].ID) | |
| 101 | + | server.dispatch("clear", "1", nil) | |
| 102 | + | prompt("complete base/2/" + models[0].ID) | |
| 103 | + | raw, _ := json.Marshal(map[string]string{"model": models[1].ID}) | |
| 104 | + | server.dispatch("set_model", "2", raw) | |
| 105 | + | prompt("complete base/3/" + models[1].ID) | |
| 106 | + | prompt("complete base/3/" + models[1].ID) | |
| 107 | + | } | |
| @@ -0,0 +1,107 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "bytes" | |||
| 6 | + | "context" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "fmt" | |||
| 9 | + | "os" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/patriceckhart/zot/packages/agent/extensions" | |||
| 15 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |||
| 16 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 17 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | func TestStartExtensionProcessHelper(t *testing.T) { | |||
| 21 | + | if os.Getenv("ZOT_TEST_START_EXTENSION") != "1" { | |||
| 22 | + | return | |||
| 23 | + | } | |||
| 24 | + | enc := json.NewEncoder(os.Stdout) | |||
| 25 | + | enc.Encode(map[string]any{"type": "hello", "name": "start-test", "version": "1"}) | |||
| 26 | + | enc.Encode(extproto.SubscribeFromExt{Type: "subscribe", Intercept: []string{"before_agent_start"}}) | |||
| 27 | + | enc.Encode(extproto.ReadyFromExt{Type: "ready"}) | |||
| 28 | + | scanner := bufio.NewScanner(os.Stdin) | |||
| 29 | + | count := 0 | |||
| 30 | + | for scanner.Scan() { | |||
| 31 | + | var ev extproto.EventInterceptFromHost | |||
| 32 | + | if json.Unmarshal(scanner.Bytes(), &ev) != nil { | |||
| 33 | + | os.Exit(2) | |||
| 34 | + | } | |||
| 35 | + | if ev.Type == "shutdown" { | |||
| 36 | + | os.Exit(0) | |||
| 37 | + | } | |||
| 38 | + | if ev.Event != "before_agent_start" { | |||
| 39 | + | continue | |||
| 40 | + | } | |||
| 41 | + | if ev.SystemPrompt == nil || ev.CWD == "" || ev.Provider == "" || ev.Model == "" || ev.SessionID == "" || ev.AgentRunID == "" { | |||
| 42 | + | os.Exit(3) | |||
| 43 | + | } | |||
| 44 | + | count++ | |||
| 45 | + | value, _ := json.Marshal(fmt.Sprintf("%s/%d/%s", *ev.SystemPrompt, count, ev.Model)) | |||
| 46 | + | enc.Encode(extproto.EventInterceptResponseFromExt{Type: "event_intercept_response", ID: ev.ID, SystemPrompt: value}) | |||
| 47 | + | } | |||
| 48 | + | os.Exit(0) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | type startCaptureClient struct{ requests []provider.Request } | |||
| 52 | + | ||||
| 53 | + | func (*startCaptureClient) Name() string { return "test" } | |||
| 54 | + | func (c *startCaptureClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) { | |||
| 55 | + | c.requests = append(c.requests, req) | |||
| 56 | + | ch := make(chan provider.Event, 1) | |||
| 57 | + | ch <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "ok"}}}} | |||
| 58 | + | close(ch) | |||
| 59 | + | return ch, nil | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestRPCBeforeAgentStart(t *testing.T) { | |||
| 63 | + | t.Setenv("ZOT_TEST_START_EXTENSION", "1") | |||
| 64 | + | exe, err := os.Executable() | |||
| 65 | + | if err != nil { | |||
| 66 | + | t.Fatal(err) | |||
| 67 | + | } | |||
| 68 | + | dir := t.TempDir() | |||
| 69 | + | manifest, _ := json.Marshal(map[string]any{"name": "start-test", "version": "1", "exec": exe, "args": []string{"-test.run=^TestStartExtensionProcessHelper$"}, "enabled": true}) | |||
| 70 | + | if err := os.WriteFile(filepath.Join(dir, "extension.json"), manifest, 0600); err != nil { | |||
| 71 | + | t.Fatal(err) | |||
| 72 | + | } | |||
| 73 | + | models := provider.ModelsForProvider("anthropic") | |||
| 74 | + | if len(models) < 2 { | |||
| 75 | + | t.Fatal("need two catalog models") | |||
| 76 | + | } | |||
| 77 | + | m := extensions.New(t.TempDir(), dir, "test", "anthropic", models[0].ID, nil) | |||
| 78 | + | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |||
| 79 | + | defer cancel() | |||
| 80 | + | defer m.Stop(time.Second) | |||
| 81 | + | if errs := m.LoadExplicit(ctx, []string{dir}); len(errs) > 0 { | |||
| 82 | + | t.Fatal(errs) | |||
| 83 | + | } | |||
| 84 | + | m.WaitForReady(time.Second) | |||
| 85 | + | client := &startCaptureClient{} | |||
| 86 | + | ag := core.NewAgent(client, models[0].ID, "complete base", nil) | |||
| 87 | + | wireNonInteractiveAgentExtHooks(ctx, ag, m) | |||
| 88 | + | var out bytes.Buffer | |||
| 89 | + | server := &rpcServer{agent: ag, provider: "anthropic", model: ag.Model, out: &out} | |||
| 90 | + | prompt := func(want string) { | |||
| 91 | + | t.Helper() | |||
| 92 | + | if err := ag.Prompt(ctx, "hello", nil, nil); err != nil { | |||
| 93 | + | t.Fatal(err) | |||
| 94 | + | } | |||
| 95 | + | if got := client.requests[len(client.requests)-1].System; got != want { | |||
| 96 | + | t.Fatalf("request system=%q want %q", got, want) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | prompt("complete base/1/" + models[0].ID) | |||
| 100 | + | prompt("complete base/1/" + models[0].ID) | |||
| 101 | + | server.dispatch("clear", "1", nil) | |||
| 102 | + | prompt("complete base/2/" + models[0].ID) | |||
| 103 | + | raw, _ := json.Marshal(map[string]string{"model": models[1].ID}) | |||
| 104 | + | server.dispatch("set_model", "2", raw) | |||
| 105 | + | prompt("complete base/3/" + models[1].ID) | |||
| 106 | + | prompt("complete base/3/" + models[1].ID) | |||
| 107 | + | } | |||
M packages/agent/cli.go +9 -9
| @@ -326,6 +326,7 @@ func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr | |||
| 326 | 326 | if ag == nil || extMgr == nil { | |
| 327 | 327 | return | |
| 328 | 328 | } | |
| 329 | + | wireBeforeAgentStart(ag, extMgr, "") | |
| 329 | 330 | ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | |
| 330 | 331 | res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments) | |
| 331 | 332 | if res.Block { | |
| @@ -624,7 +625,7 @@ func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extTool | |||
| 624 | 625 | reg = mutateRegistry(reg) | |
| 625 | 626 | } | |
| 626 | 627 | ag.SetTools(reg) | |
| 627 | - | ag.System = resolved.SystemPrompt | |
| 628 | + | ag.SetSystem(resolved.SystemPrompt) | |
| 628 | 629 | } | |
| 629 | 630 | ||
| 630 | 631 | // reloadResourcesAfterStartupPre reloads extensions (if any) and | |
| @@ -777,10 +778,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 777 | 778 | ||
| 778 | 779 | // Capture current args in a closure so BuildAgent can re-resolve | |
| 779 | 780 | // after a successful login (picks up the newly stored credential). | |
| 780 | - | wireAgentExt := func(a *core.Agent) *core.Agent { | |
| 781 | - | if a == nil { | |
| 782 | - | return a | |
| 783 | - | } | |
| 781 | + | wireAgentExt := func(resolved Resolved) *core.Agent { | |
| 782 | + | a := resolved.NewAgent() | |
| 783 | + | wireBeforeAgentStart(a, extMgr, resolved.Provider) | |
| 784 | 784 | a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | |
| 785 | 785 | // Guards run before confirmation because they may rewrite the | |
| 786 | 786 | // arguments. The user must approve the effective call that will | |
| @@ -846,7 +846,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 846 | 846 | resolved.UseSandbox(sharedSandbox) | |
| 847 | 847 | resolved.MergeExtensionTools(extToolAdapter) | |
| 848 | 848 | injectSwarmSpawn(resolved.ToolRegistry) | |
| 849 | - | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | |
| 849 | + | return wireAgentExt(resolved), resolved.Provider, resolved.Model, nil | |
| 850 | 850 | } | |
| 851 | 851 | ||
| 852 | 852 | // Rebuild agent with an explicit provider/model override. | |
| @@ -865,7 +865,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 865 | 865 | resolved.UseSandbox(sharedSandbox) | |
| 866 | 866 | resolved.MergeExtensionTools(extToolAdapter) | |
| 867 | 867 | injectSwarmSpawn(resolved.ToolRegistry) | |
| 868 | - | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | |
| 868 | + | return wireAgentExt(resolved), resolved.Provider, resolved.Model, nil | |
| 869 | 869 | } | |
| 870 | 870 | ||
| 871 | 871 | // Rebuild agent for the rescue picker after a recoverable failure. | |
| @@ -892,12 +892,12 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 892 | 892 | resolved.UseSandbox(sharedSandbox) | |
| 893 | 893 | resolved.MergeExtensionTools(extToolAdapter) | |
| 894 | 894 | injectSwarmSpawn(resolved.ToolRegistry) | |
| 895 | - | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | |
| 895 | + | return wireAgentExt(resolved), resolved.Provider, resolved.Model, nil | |
| 896 | 896 | } | |
| 897 | 897 | ||
| 898 | 898 | var ag *core.Agent | |
| 899 | 899 | if r.HasCredential() { | |
| 900 | - | ag = wireAgentExt(r.NewAgent()) | |
| 900 | + | ag = wireAgentExt(r) | |
| 901 | 901 | } | |
| 902 | 902 | ||
| 903 | 903 | // /reload-ext callback: after the manager has respawned every | |
| @@ -326,6 +326,7 @@ func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr | |||||
| 326 | if ag == nil || extMgr == nil { | 326 | if ag == nil || extMgr == nil { | ||
| 327 | return | 327 | return | ||
| 328 | } | 328 | } | ||
| 329 | + | wireBeforeAgentStart(ag, extMgr, "") | |||
| 329 | ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | 330 | ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | ||
| 330 | res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments) | 331 | res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments) | ||
| 331 | if res.Block { | 332 | if res.Block { | ||
| @@ -624,7 +625,7 @@ func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extTool | |||||
| 624 | reg = mutateRegistry(reg) | 625 | reg = mutateRegistry(reg) | ||
| 625 | } | 626 | } | ||
| 626 | ag.SetTools(reg) | 627 | ag.SetTools(reg) | ||
| 627 | - | ag.System = resolved.SystemPrompt | 628 | + | ag.SetSystem(resolved.SystemPrompt) |
| 628 | } | 629 | } | ||
| 629 | 630 | ||||
| 630 | // reloadResourcesAfterStartupPre reloads extensions (if any) and | 631 | // reloadResourcesAfterStartupPre reloads extensions (if any) and | ||
| @@ -777,10 +778,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 777 | 778 | ||||
| 778 | // Capture current args in a closure so BuildAgent can re-resolve | 779 | // Capture current args in a closure so BuildAgent can re-resolve | ||
| 779 | // after a successful login (picks up the newly stored credential). | 780 | // after a successful login (picks up the newly stored credential). | ||
| 780 | - | wireAgentExt := func(a *core.Agent) *core.Agent { | 781 | + | wireAgentExt := func(resolved Resolved) *core.Agent { |
| 781 | - | if a == nil { | 782 | + | a := resolved.NewAgent() |
| 782 | - | return a | 783 | + | wireBeforeAgentStart(a, extMgr, resolved.Provider) |
| 783 | - | } | |||
| 784 | a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | 784 | a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | ||
| 785 | // Guards run before confirmation because they may rewrite the | 785 | // Guards run before confirmation because they may rewrite the | ||
| 786 | // arguments. The user must approve the effective call that will | 786 | // arguments. The user must approve the effective call that will | ||
| @@ -846,7 +846,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 846 | resolved.UseSandbox(sharedSandbox) | 846 | resolved.UseSandbox(sharedSandbox) | ||
| 847 | resolved.MergeExtensionTools(extToolAdapter) | 847 | resolved.MergeExtensionTools(extToolAdapter) | ||
| 848 | injectSwarmSpawn(resolved.ToolRegistry) | 848 | injectSwarmSpawn(resolved.ToolRegistry) | ||
| 849 | - | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | 849 | + | return wireAgentExt(resolved), resolved.Provider, resolved.Model, nil |
| 850 | } | 850 | } | ||
| 851 | 851 | ||||
| 852 | // Rebuild agent with an explicit provider/model override. | 852 | // Rebuild agent with an explicit provider/model override. | ||
| @@ -865,7 +865,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 865 | resolved.UseSandbox(sharedSandbox) | 865 | resolved.UseSandbox(sharedSandbox) | ||
| 866 | resolved.MergeExtensionTools(extToolAdapter) | 866 | resolved.MergeExtensionTools(extToolAdapter) | ||
| 867 | injectSwarmSpawn(resolved.ToolRegistry) | 867 | injectSwarmSpawn(resolved.ToolRegistry) | ||
| 868 | - | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | 868 | + | return wireAgentExt(resolved), resolved.Provider, resolved.Model, nil |
| 869 | } | 869 | } | ||
| 870 | 870 | ||||
| 871 | // Rebuild agent for the rescue picker after a recoverable failure. | 871 | // Rebuild agent for the rescue picker after a recoverable failure. | ||
| @@ -892,12 +892,12 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 892 | resolved.UseSandbox(sharedSandbox) | 892 | resolved.UseSandbox(sharedSandbox) | ||
| 893 | resolved.MergeExtensionTools(extToolAdapter) | 893 | resolved.MergeExtensionTools(extToolAdapter) | ||
| 894 | injectSwarmSpawn(resolved.ToolRegistry) | 894 | injectSwarmSpawn(resolved.ToolRegistry) | ||
| 895 | - | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | 895 | + | return wireAgentExt(resolved), resolved.Provider, resolved.Model, nil |
| 896 | } | 896 | } | ||
| 897 | 897 | ||||
| 898 | var ag *core.Agent | 898 | var ag *core.Agent | ||
| 899 | if r.HasCredential() { | 899 | if r.HasCredential() { | ||
| 900 | - | ag = wireAgentExt(r.NewAgent()) | 900 | + | ag = wireAgentExt(r) |
| 901 | } | 901 | } | ||
| 902 | 902 | ||||
| 903 | // /reload-ext callback: after the manager has respawned every | 903 | // /reload-ext callback: after the manager has respawned every | ||
A packages/agent/ext/before_start_test.go +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | package ext | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "io" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestBeforeAgentStartSDK(t *testing.T) { | |
| 12 | + | for _, mode := range []string{"replace", "empty", "omit"} { | |
| 13 | + | t.Run(mode, func(t *testing.T) { | |
| 14 | + | h := newHarness("start-ext") | |
| 15 | + | h.ext.InterceptBeforeAgentStart(func(ev BeforeAgentStartEvent) *string { | |
| 16 | + | if ev.SystemPrompt != "base" || ev.CWD != "/work" || ev.Provider != "p" || ev.Model != "m" || ev.SessionID != "s" || ev.AgentRunID != "r" { | |
| 17 | + | t.Error("wrong event metadata") | |
| 18 | + | } | |
| 19 | + | if mode == "omit" { | |
| 20 | + | return nil | |
| 21 | + | } | |
| 22 | + | value := "" | |
| 23 | + | if mode == "replace" { | |
| 24 | + | value = " new\n" | |
| 25 | + | } | |
| 26 | + | return &value | |
| 27 | + | }) | |
| 28 | + | done := make(chan struct{}) | |
| 29 | + | go func() { defer close(done); h.ext.Run() }() | |
| 30 | + | t.Cleanup(func() { h.hostW.Close(); <-done; h.ext.out.(io.Closer).Close() }) | |
| 31 | + | h.drainUntil(t, "hello") | |
| 32 | + | h.sendToExt(t, extproto.HelloAckFromHost{Type: "hello_ack", ProtocolVersion: extproto.ProtocolVersion}) | |
| 33 | + | sub := h.drainUntil(t, "subscribe") | |
| 34 | + | var subscription extproto.SubscribeFromExt | |
| 35 | + | if err := json.Unmarshal(sub.raw, &subscription); err != nil { | |
| 36 | + | t.Fatal(err) | |
| 37 | + | } | |
| 38 | + | if len(subscription.Intercept) != 1 || subscription.Intercept[0] != "before_agent_start" { | |
| 39 | + | t.Fatalf("subscription = %+v", subscription) | |
| 40 | + | } | |
| 41 | + | h.drainUntil(t, "ready") | |
| 42 | + | base := "base" | |
| 43 | + | h.sendToExt(t, extproto.EventInterceptFromHost{Type: "event_intercept", Event: "before_agent_start", ID: "1", SystemPrompt: &base, CWD: "/work", Provider: "p", Model: "m", SessionID: "s", AgentRunID: "r"}) | |
| 44 | + | frame := h.drainUntil(t, "event_intercept_response") | |
| 45 | + | var result extproto.EventInterceptResponseFromExt | |
| 46 | + | if err := json.Unmarshal(frame.raw, &result); err != nil { | |
| 47 | + | t.Fatal(err) | |
| 48 | + | } | |
| 49 | + | if result.ID != "1" { | |
| 50 | + | t.Fatal("missing correlation ID") | |
| 51 | + | } | |
| 52 | + | want := `" new\n"` | |
| 53 | + | if mode == "empty" { | |
| 54 | + | want = `""` | |
| 55 | + | } | |
| 56 | + | if mode == "omit" { | |
| 57 | + | want = "" | |
| 58 | + | } | |
| 59 | + | if string(result.SystemPrompt) != want { | |
| 60 | + | t.Fatalf("got %s want %s", result.SystemPrompt, want) | |
| 61 | + | } | |
| 62 | + | }) | |
| 63 | + | } | |
| 64 | + | } | |
| @@ -0,0 +1,64 @@ | |||||
| 1 | + | package ext | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "io" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestBeforeAgentStartSDK(t *testing.T) { | |||
| 12 | + | for _, mode := range []string{"replace", "empty", "omit"} { | |||
| 13 | + | t.Run(mode, func(t *testing.T) { | |||
| 14 | + | h := newHarness("start-ext") | |||
| 15 | + | h.ext.InterceptBeforeAgentStart(func(ev BeforeAgentStartEvent) *string { | |||
| 16 | + | if ev.SystemPrompt != "base" || ev.CWD != "/work" || ev.Provider != "p" || ev.Model != "m" || ev.SessionID != "s" || ev.AgentRunID != "r" { | |||
| 17 | + | t.Error("wrong event metadata") | |||
| 18 | + | } | |||
| 19 | + | if mode == "omit" { | |||
| 20 | + | return nil | |||
| 21 | + | } | |||
| 22 | + | value := "" | |||
| 23 | + | if mode == "replace" { | |||
| 24 | + | value = " new\n" | |||
| 25 | + | } | |||
| 26 | + | return &value | |||
| 27 | + | }) | |||
| 28 | + | done := make(chan struct{}) | |||
| 29 | + | go func() { defer close(done); h.ext.Run() }() | |||
| 30 | + | t.Cleanup(func() { h.hostW.Close(); <-done; h.ext.out.(io.Closer).Close() }) | |||
| 31 | + | h.drainUntil(t, "hello") | |||
| 32 | + | h.sendToExt(t, extproto.HelloAckFromHost{Type: "hello_ack", ProtocolVersion: extproto.ProtocolVersion}) | |||
| 33 | + | sub := h.drainUntil(t, "subscribe") | |||
| 34 | + | var subscription extproto.SubscribeFromExt | |||
| 35 | + | if err := json.Unmarshal(sub.raw, &subscription); err != nil { | |||
| 36 | + | t.Fatal(err) | |||
| 37 | + | } | |||
| 38 | + | if len(subscription.Intercept) != 1 || subscription.Intercept[0] != "before_agent_start" { | |||
| 39 | + | t.Fatalf("subscription = %+v", subscription) | |||
| 40 | + | } | |||
| 41 | + | h.drainUntil(t, "ready") | |||
| 42 | + | base := "base" | |||
| 43 | + | h.sendToExt(t, extproto.EventInterceptFromHost{Type: "event_intercept", Event: "before_agent_start", ID: "1", SystemPrompt: &base, CWD: "/work", Provider: "p", Model: "m", SessionID: "s", AgentRunID: "r"}) | |||
| 44 | + | frame := h.drainUntil(t, "event_intercept_response") | |||
| 45 | + | var result extproto.EventInterceptResponseFromExt | |||
| 46 | + | if err := json.Unmarshal(frame.raw, &result); err != nil { | |||
| 47 | + | t.Fatal(err) | |||
| 48 | + | } | |||
| 49 | + | if result.ID != "1" { | |||
| 50 | + | t.Fatal("missing correlation ID") | |||
| 51 | + | } | |||
| 52 | + | want := `" new\n"` | |||
| 53 | + | if mode == "empty" { | |||
| 54 | + | want = `""` | |||
| 55 | + | } | |||
| 56 | + | if mode == "omit" { | |||
| 57 | + | want = "" | |||
| 58 | + | } | |||
| 59 | + | if string(result.SystemPrompt) != want { | |||
| 60 | + | t.Fatalf("got %s want %s", result.SystemPrompt, want) | |||
| 61 | + | } | |||
| 62 | + | }) | |||
| 63 | + | } | |||
| 64 | + | } | |||
M packages/agent/ext/ext.go +38 -0
| @@ -117,6 +117,20 @@ type ToolCallDecision struct { | |||
| 117 | 117 | // when you want to rewrite args instead of only blocking. | |
| 118 | 118 | type ToolCallHandler func(toolName string, args json.RawMessage) ToolCallDecision | |
| 119 | 119 | ||
| 120 | + | // BeforeAgentStartEvent contains the complete prompt and effective runtime context. | |
| 121 | + | type BeforeAgentStartEvent struct { | |
| 122 | + | SystemPrompt string | |
| 123 | + | SessionID string | |
| 124 | + | AgentRunID string | |
| 125 | + | CWD string | |
| 126 | + | Provider string | |
| 127 | + | Model string | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // BeforeAgentStartHandler returns an exact replacement, or nil to keep the prompt. | |
| 131 | + | // A pointer to an empty string intentionally removes the system prompt. | |
| 132 | + | type BeforeAgentStartHandler func(BeforeAgentStartEvent) *string | |
| 133 | + | ||
| 120 | 134 | // TurnStartDecision controls whether the next model call runs. Zero | |
| 121 | 135 | // value means "allow". | |
| 122 | 136 | type TurnStartDecision struct { | |
| @@ -261,6 +275,7 @@ type Extension struct { | |||
| 261 | 275 | interceptTool InterceptHandler | |
| 262 | 276 | interceptToolRich ToolCallHandler | |
| 263 | 277 | interceptOn bool | |
| 278 | + | interceptStart BeforeAgentStartHandler | |
| 264 | 279 | interceptTurn TurnStartHandler | |
| 265 | 280 | interceptAssistant AssistantMessageHandler | |
| 266 | 281 | panelKeys map[string]func(key, text string) | |
| @@ -450,6 +465,13 @@ func (e *Extension) InterceptToolCallX(fn ToolCallHandler) { | |||
| 450 | 465 | e.mu.Unlock() | |
| 451 | 466 | } | |
| 452 | 467 | ||
| 468 | + | // InterceptBeforeAgentStart registers a session-scoped prompt replacement hook. | |
| 469 | + | func (e *Extension) InterceptBeforeAgentStart(fn BeforeAgentStartHandler) { | |
| 470 | + | e.mu.Lock() | |
| 471 | + | e.interceptStart = fn | |
| 472 | + | e.mu.Unlock() | |
| 473 | + | } | |
| 474 | + | ||
| 453 | 475 | // InterceptTurnStart registers a guard that runs before every turn's | |
| 454 | 476 | // model call. Return Block=true with Reason to abort the turn. | |
| 455 | 477 | // Useful for deny-by-default gates and usage quotas. | |
| @@ -529,6 +551,7 @@ func (e *Extension) Run() error { | |||
| 529 | 551 | descs := append([]descTuple(nil), e.descriptions...) | |
| 530 | 552 | toolDefs := append([]toolDef(nil), e.toolDefs...) | |
| 531 | 553 | eventNames := append([]string(nil), e.eventNames...) | |
| 554 | + | interceptStart := e.interceptStart != nil | |
| 532 | 555 | interceptTool := e.interceptOn | |
| 533 | 556 | interceptTurn := e.interceptTurn != nil | |
| 534 | 557 | interceptAsst := e.interceptAssistant != nil | |
| @@ -550,6 +573,9 @@ func (e *Extension) Run() error { | |||
| 550 | 573 | }) | |
| 551 | 574 | } | |
| 552 | 575 | var intercepts []string | |
| 576 | + | if interceptStart { | |
| 577 | + | intercepts = append(intercepts, "before_agent_start") | |
| 578 | + | } | |
| 553 | 579 | if interceptTool { | |
| 554 | 580 | intercepts = append(intercepts, "tool_call") | |
| 555 | 581 | } | |
| @@ -762,6 +788,18 @@ func (e *Extension) dispatchIntercept(ei extproto.EventInterceptFromHost) { | |||
| 762 | 788 | } | |
| 763 | 789 | ||
| 764 | 790 | switch ei.Event { | |
| 791 | + | case "before_agent_start": | |
| 792 | + | e.mu.Lock() | |
| 793 | + | fn := e.interceptStart | |
| 794 | + | e.mu.Unlock() | |
| 795 | + | if fn != nil && ei.SystemPrompt != nil { | |
| 796 | + | if replacement := fn(BeforeAgentStartEvent{ | |
| 797 | + | SystemPrompt: *ei.SystemPrompt, SessionID: ei.SessionID, AgentRunID: ei.AgentRunID, | |
| 798 | + | CWD: ei.CWD, Provider: ei.Provider, Model: ei.Model, | |
| 799 | + | }); replacement != nil { | |
| 800 | + | resp.SystemPrompt, _ = json.Marshal(*replacement) | |
| 801 | + | } | |
| 802 | + | } | |
| 765 | 803 | case "tool_call": | |
| 766 | 804 | e.mu.Lock() | |
| 767 | 805 | rich := e.interceptToolRich | |
| @@ -117,6 +117,20 @@ type ToolCallDecision struct { | |||||
| 117 | // when you want to rewrite args instead of only blocking. | 117 | // when you want to rewrite args instead of only blocking. | ||
| 118 | type ToolCallHandler func(toolName string, args json.RawMessage) ToolCallDecision | 118 | type ToolCallHandler func(toolName string, args json.RawMessage) ToolCallDecision | ||
| 119 | 119 | ||||
| 120 | + | // BeforeAgentStartEvent contains the complete prompt and effective runtime context. | |||
| 121 | + | type BeforeAgentStartEvent struct { | |||
| 122 | + | SystemPrompt string | |||
| 123 | + | SessionID string | |||
| 124 | + | AgentRunID string | |||
| 125 | + | CWD string | |||
| 126 | + | Provider string | |||
| 127 | + | Model string | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // BeforeAgentStartHandler returns an exact replacement, or nil to keep the prompt. | |||
| 131 | + | // A pointer to an empty string intentionally removes the system prompt. | |||
| 132 | + | type BeforeAgentStartHandler func(BeforeAgentStartEvent) *string | |||
| 133 | + | ||||
| 120 | // TurnStartDecision controls whether the next model call runs. Zero | 134 | // TurnStartDecision controls whether the next model call runs. Zero | ||
| 121 | // value means "allow". | 135 | // value means "allow". | ||
| 122 | type TurnStartDecision struct { | 136 | type TurnStartDecision struct { | ||
| @@ -261,6 +275,7 @@ type Extension struct { | |||||
| 261 | interceptTool InterceptHandler | 275 | interceptTool InterceptHandler | ||
| 262 | interceptToolRich ToolCallHandler | 276 | interceptToolRich ToolCallHandler | ||
| 263 | interceptOn bool | 277 | interceptOn bool | ||
| 278 | + | interceptStart BeforeAgentStartHandler | |||
| 264 | interceptTurn TurnStartHandler | 279 | interceptTurn TurnStartHandler | ||
| 265 | interceptAssistant AssistantMessageHandler | 280 | interceptAssistant AssistantMessageHandler | ||
| 266 | panelKeys map[string]func(key, text string) | 281 | panelKeys map[string]func(key, text string) | ||
| @@ -450,6 +465,13 @@ func (e *Extension) InterceptToolCallX(fn ToolCallHandler) { | |||||
| 450 | e.mu.Unlock() | 465 | e.mu.Unlock() | ||
| 451 | } | 466 | } | ||
| 452 | 467 | ||||
| 468 | + | // InterceptBeforeAgentStart registers a session-scoped prompt replacement hook. | |||
| 469 | + | func (e *Extension) InterceptBeforeAgentStart(fn BeforeAgentStartHandler) { | |||
| 470 | + | e.mu.Lock() | |||
| 471 | + | e.interceptStart = fn | |||
| 472 | + | e.mu.Unlock() | |||
| 473 | + | } | |||
| 474 | + | ||||
| 453 | // InterceptTurnStart registers a guard that runs before every turn's | 475 | // InterceptTurnStart registers a guard that runs before every turn's | ||
| 454 | // model call. Return Block=true with Reason to abort the turn. | 476 | // model call. Return Block=true with Reason to abort the turn. | ||
| 455 | // Useful for deny-by-default gates and usage quotas. | 477 | // Useful for deny-by-default gates and usage quotas. | ||
| @@ -529,6 +551,7 @@ func (e *Extension) Run() error { | |||||
| 529 | descs := append([]descTuple(nil), e.descriptions...) | 551 | descs := append([]descTuple(nil), e.descriptions...) | ||
| 530 | toolDefs := append([]toolDef(nil), e.toolDefs...) | 552 | toolDefs := append([]toolDef(nil), e.toolDefs...) | ||
| 531 | eventNames := append([]string(nil), e.eventNames...) | 553 | eventNames := append([]string(nil), e.eventNames...) | ||
| 554 | + | interceptStart := e.interceptStart != nil | |||
| 532 | interceptTool := e.interceptOn | 555 | interceptTool := e.interceptOn | ||
| 533 | interceptTurn := e.interceptTurn != nil | 556 | interceptTurn := e.interceptTurn != nil | ||
| 534 | interceptAsst := e.interceptAssistant != nil | 557 | interceptAsst := e.interceptAssistant != nil | ||
| @@ -550,6 +573,9 @@ func (e *Extension) Run() error { | |||||
| 550 | }) | 573 | }) | ||
| 551 | } | 574 | } | ||
| 552 | var intercepts []string | 575 | var intercepts []string | ||
| 576 | + | if interceptStart { | |||
| 577 | + | intercepts = append(intercepts, "before_agent_start") | |||
| 578 | + | } | |||
| 553 | if interceptTool { | 579 | if interceptTool { | ||
| 554 | intercepts = append(intercepts, "tool_call") | 580 | intercepts = append(intercepts, "tool_call") | ||
| 555 | } | 581 | } | ||
| @@ -762,6 +788,18 @@ func (e *Extension) dispatchIntercept(ei extproto.EventInterceptFromHost) { | |||||
| 762 | } | 788 | } | ||
| 763 | 789 | ||||
| 764 | switch ei.Event { | 790 | switch ei.Event { | ||
| 791 | + | case "before_agent_start": | |||
| 792 | + | e.mu.Lock() | |||
| 793 | + | fn := e.interceptStart | |||
| 794 | + | e.mu.Unlock() | |||
| 795 | + | if fn != nil && ei.SystemPrompt != nil { | |||
| 796 | + | if replacement := fn(BeforeAgentStartEvent{ | |||
| 797 | + | SystemPrompt: *ei.SystemPrompt, SessionID: ei.SessionID, AgentRunID: ei.AgentRunID, | |||
| 798 | + | CWD: ei.CWD, Provider: ei.Provider, Model: ei.Model, | |||
| 799 | + | }); replacement != nil { | |||
| 800 | + | resp.SystemPrompt, _ = json.Marshal(*replacement) | |||
| 801 | + | } | |||
| 802 | + | } | |||
| 765 | case "tool_call": | 803 | case "tool_call": | ||
| 766 | e.mu.Lock() | 804 | e.mu.Lock() | ||
| 767 | rich := e.interceptToolRich | 805 | rich := e.interceptToolRich | ||
A packages/agent/extensions/before_start.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | package extensions | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | ||
| 8 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // MaxSystemPromptResponseSize limits the encoded system_prompt value. The | |
| 12 | + | // transport additionally limits each complete response frame to 4 MiB. | |
| 13 | + | const MaxSystemPromptResponseSize = 1024 * 1024 | |
| 14 | + | ||
| 15 | + | // InterceptBeforeAgentStart chains exact prompt replacements in load order. | |
| 16 | + | // Failures are advisory, never permission decisions. | |
| 17 | + | func (m *Manager) InterceptBeforeAgentStart(ctx context.Context, ev extproto.EventInterceptFromHost) string { | |
| 18 | + | current := "" | |
| 19 | + | if ev.SystemPrompt != nil { | |
| 20 | + | current = *ev.SystemPrompt | |
| 21 | + | } | |
| 22 | + | ev.Event = "before_agent_start" | |
| 23 | + | ev.CWD = m.cwd | |
| 24 | + | if ev.Provider == "" { | |
| 25 | + | ev.Provider = m.provider | |
| 26 | + | } | |
| 27 | + | m.mu.RLock() | |
| 28 | + | subs := append([]*Extension(nil), m.extOrder...) | |
| 29 | + | m.mu.RUnlock() | |
| 30 | + | for _, ext := range subs { | |
| 31 | + | if ctx.Err() != nil { | |
| 32 | + | break | |
| 33 | + | } | |
| 34 | + | ext.mu.Lock() | |
| 35 | + | _, subscribed := ext.interceptSubs[ev.Event] | |
| 36 | + | ext.mu.Unlock() | |
| 37 | + | if !subscribed { | |
| 38 | + | continue | |
| 39 | + | } | |
| 40 | + | ev.SystemPrompt = ¤t | |
| 41 | + | callCtx, cancel := context.WithTimeout(ctx, interceptTimeout) | |
| 42 | + | r := m.askIntercept(callCtx, ext, ev) | |
| 43 | + | cancel() | |
| 44 | + | failure := r.failure | |
| 45 | + | if failure == "" && r.Block && len(r.SystemPrompt) == 0 { | |
| 46 | + | failure = "extension returned a blocking response; startup hooks cannot block" | |
| 47 | + | } | |
| 48 | + | if failure == "" && len(r.SystemPrompt) > 0 { | |
| 49 | + | var replacement string | |
| 50 | + | switch { | |
| 51 | + | case len(r.SystemPrompt) > MaxSystemPromptResponseSize: | |
| 52 | + | failure = "system_prompt exceeds 1 MiB encoded limit" | |
| 53 | + | case r.SystemPrompt[0] != '"' || json.Unmarshal(r.SystemPrompt, &replacement) != nil: | |
| 54 | + | failure = "system_prompt must be a string" | |
| 55 | + | default: | |
| 56 | + | current = replacement | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | if failure != "" { | |
| 60 | + | message := "before_agent_start: " + failure + "; keeping current system prompt" | |
| 61 | + | fmt.Fprintln(ext.logFile, "[zot] "+message) | |
| 62 | + | ext.recordDiagnostic(message) | |
| 63 | + | if m.hooks != nil { | |
| 64 | + | m.hooks.Notify(ext.Manifest.Name, "warn", message) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | return current | |
| 69 | + | } | |
| @@ -0,0 +1,69 @@ | |||||
| 1 | + | package extensions | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | ||||
| 8 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // MaxSystemPromptResponseSize limits the encoded system_prompt value. The | |||
| 12 | + | // transport additionally limits each complete response frame to 4 MiB. | |||
| 13 | + | const MaxSystemPromptResponseSize = 1024 * 1024 | |||
| 14 | + | ||||
| 15 | + | // InterceptBeforeAgentStart chains exact prompt replacements in load order. | |||
| 16 | + | // Failures are advisory, never permission decisions. | |||
| 17 | + | func (m *Manager) InterceptBeforeAgentStart(ctx context.Context, ev extproto.EventInterceptFromHost) string { | |||
| 18 | + | current := "" | |||
| 19 | + | if ev.SystemPrompt != nil { | |||
| 20 | + | current = *ev.SystemPrompt | |||
| 21 | + | } | |||
| 22 | + | ev.Event = "before_agent_start" | |||
| 23 | + | ev.CWD = m.cwd | |||
| 24 | + | if ev.Provider == "" { | |||
| 25 | + | ev.Provider = m.provider | |||
| 26 | + | } | |||
| 27 | + | m.mu.RLock() | |||
| 28 | + | subs := append([]*Extension(nil), m.extOrder...) | |||
| 29 | + | m.mu.RUnlock() | |||
| 30 | + | for _, ext := range subs { | |||
| 31 | + | if ctx.Err() != nil { | |||
| 32 | + | break | |||
| 33 | + | } | |||
| 34 | + | ext.mu.Lock() | |||
| 35 | + | _, subscribed := ext.interceptSubs[ev.Event] | |||
| 36 | + | ext.mu.Unlock() | |||
| 37 | + | if !subscribed { | |||
| 38 | + | continue | |||
| 39 | + | } | |||
| 40 | + | ev.SystemPrompt = ¤t | |||
| 41 | + | callCtx, cancel := context.WithTimeout(ctx, interceptTimeout) | |||
| 42 | + | r := m.askIntercept(callCtx, ext, ev) | |||
| 43 | + | cancel() | |||
| 44 | + | failure := r.failure | |||
| 45 | + | if failure == "" && r.Block && len(r.SystemPrompt) == 0 { | |||
| 46 | + | failure = "extension returned a blocking response; startup hooks cannot block" | |||
| 47 | + | } | |||
| 48 | + | if failure == "" && len(r.SystemPrompt) > 0 { | |||
| 49 | + | var replacement string | |||
| 50 | + | switch { | |||
| 51 | + | case len(r.SystemPrompt) > MaxSystemPromptResponseSize: | |||
| 52 | + | failure = "system_prompt exceeds 1 MiB encoded limit" | |||
| 53 | + | case r.SystemPrompt[0] != '"' || json.Unmarshal(r.SystemPrompt, &replacement) != nil: | |||
| 54 | + | failure = "system_prompt must be a string" | |||
| 55 | + | default: | |||
| 56 | + | current = replacement | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | if failure != "" { | |||
| 60 | + | message := "before_agent_start: " + failure + "; keeping current system prompt" | |||
| 61 | + | fmt.Fprintln(ext.logFile, "[zot] "+message) | |||
| 62 | + | ext.recordDiagnostic(message) | |||
| 63 | + | if m.hooks != nil { | |||
| 64 | + | m.hooks.Notify(ext.Manifest.Name, "warn", message) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | return current | |||
| 69 | + | } | |||
A packages/agent/extensions/before_start_test.go +167 -0
| @@ -0,0 +1,167 @@ | |||
| 1 | + | package extensions | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "io" | |
| 8 | + | "os" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | type startWriter struct{ write func([]byte) (int, error) } | |
| 17 | + | ||
| 18 | + | func (w startWriter) Write(p []byte) (int, error) { return w.write(p) } | |
| 19 | + | func (w startWriter) Close() error { return nil } | |
| 20 | + | ||
| 21 | + | func addStartExtension(t *testing.T, m *Manager, name string, response func(extproto.EventInterceptFromHost) string) *Extension { | |
| 22 | + | t.Helper() | |
| 23 | + | log, err := os.CreateTemp(t.TempDir(), "log") | |
| 24 | + | if err != nil { | |
| 25 | + | t.Fatal(err) | |
| 26 | + | } | |
| 27 | + | reader, writer := io.Pipe() | |
| 28 | + | ext := &Extension{Manifest: Manifest{Name: name}, logFile: log, readyCh: make(chan struct{}), pendingIntercept: map[string]chan extproto.EventInterceptResponseFromExt{}, interceptSubs: map[string]struct{}{}, eventSubs: map[string]struct{}{}} | |
| 29 | + | ext.stdin = startWriter{write: func(p []byte) (int, error) { | |
| 30 | + | var ev extproto.EventInterceptFromHost | |
| 31 | + | if err := json.Unmarshal(p, &ev); err != nil { | |
| 32 | + | return 0, err | |
| 33 | + | } | |
| 34 | + | _, err := io.WriteString(writer, response(ev)+"\n") | |
| 35 | + | return len(p), err | |
| 36 | + | }} | |
| 37 | + | m.mu.Lock() | |
| 38 | + | m.ext[name] = ext | |
| 39 | + | m.extOrder = append(m.extOrder, ext) | |
| 40 | + | m.mu.Unlock() | |
| 41 | + | done := make(chan struct{}) | |
| 42 | + | scanner := bufio.NewScanner(reader) | |
| 43 | + | scanner.Buffer(make([]byte, 4096), 4*1024*1024) | |
| 44 | + | go func() { defer close(done); m.readLoop(ext, scanner) }() | |
| 45 | + | t.Cleanup(func() { writer.Close(); <-done; reader.Close(); log.Close() }) | |
| 46 | + | if _, err := io.WriteString(writer, `{"type":"subscribe","intercept":["before_agent_start"]}`+"\n"+`{"type":"ready"}`+"\n"); err != nil { | |
| 47 | + | t.Fatal(err) | |
| 48 | + | } | |
| 49 | + | <-ext.readyCh | |
| 50 | + | return ext | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestBeforeAgentStartResponses(t *testing.T) { | |
| 54 | + | for _, tc := range []struct { | |
| 55 | + | name, raw, want string | |
| 56 | + | warning bool | |
| 57 | + | }{ | |
| 58 | + | {"unchanged", `"base"`, "base", false}, | |
| 59 | + | {"appended", `"base plus"`, "base plus", false}, | |
| 60 | + | {"rewritten", `"BASE"`, "BASE", false}, | |
| 61 | + | {"replaced", `" völlig neu\n\n"`, " völlig neu\n\n", false}, | |
| 62 | + | {"empty", `""`, "", false}, | |
| 63 | + | {"omitted", "", "base", false}, | |
| 64 | + | {"null", "null", "base", true}, | |
| 65 | + | {"number", "42", "base", true}, | |
| 66 | + | {"object", "{}", "base", true}, | |
| 67 | + | {"oversized", `"` + strings.Repeat("x", MaxSystemPromptResponseSize) + `"`, "base", true}, | |
| 68 | + | } { | |
| 69 | + | t.Run(tc.name, func(t *testing.T) { | |
| 70 | + | hooks := &stubHooks{} | |
| 71 | + | m := New(t.TempDir(), "/effective", "test", "provider", "model", hooks) | |
| 72 | + | ext := addStartExtension(t, m, "test", func(ev extproto.EventInterceptFromHost) string { | |
| 73 | + | if ev.Event != "before_agent_start" || ev.SystemPrompt == nil || *ev.SystemPrompt != "base" || ev.CWD != "/effective" || ev.SessionID != "session" || ev.AgentRunID != "run" || ev.Provider != "provider" || ev.Model != "model" { | |
| 74 | + | t.Errorf("wrong event metadata") | |
| 75 | + | } | |
| 76 | + | field := "" | |
| 77 | + | if tc.raw != "" { | |
| 78 | + | field = `,"system_prompt":` + tc.raw | |
| 79 | + | } | |
| 80 | + | return `{"type":"event_intercept_response","id":"` + ev.ID + `"` + field + `}` | |
| 81 | + | }) | |
| 82 | + | base := "base" | |
| 83 | + | got := m.InterceptBeforeAgentStart(context.Background(), extproto.EventInterceptFromHost{SystemPrompt: &base, SessionID: "session", AgentRunID: "run", Model: "model"}) | |
| 84 | + | if got != tc.want { | |
| 85 | + | t.Fatalf("got %q, want %q", got, tc.want) | |
| 86 | + | } | |
| 87 | + | ext.mu.Lock() | |
| 88 | + | warnings := len(ext.diagnostics) | |
| 89 | + | ext.mu.Unlock() | |
| 90 | + | if (warnings > 0) != tc.warning { | |
| 91 | + | t.Fatalf("warnings = %d", warnings) | |
| 92 | + | } | |
| 93 | + | hooks.mu.Lock() | |
| 94 | + | notifications := len(hooks.notifies) | |
| 95 | + | hooks.mu.Unlock() | |
| 96 | + | if (notifications > 0) != tc.warning { | |
| 97 | + | t.Fatalf("notifications = %d", notifications) | |
| 98 | + | } | |
| 99 | + | }) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestBeforeAgentStartChainsInLoadOrder(t *testing.T) { | |
| 104 | + | m := New(t.TempDir(), "", "test", "p", "m", nil) | |
| 105 | + | for _, name := range []string{"z-first", "a-second"} { | |
| 106 | + | addStartExtension(t, m, name, func(ev extproto.EventInterceptFromHost) string { | |
| 107 | + | raw, _ := json.Marshal(*ev.SystemPrompt + "/" + name) | |
| 108 | + | return `{"type":"event_intercept_response","id":"` + ev.ID + `","system_prompt":` + string(raw) + `}` | |
| 109 | + | }) | |
| 110 | + | } | |
| 111 | + | for range 20 { | |
| 112 | + | base := "base" | |
| 113 | + | if got := m.InterceptBeforeAgentStart(context.Background(), extproto.EventInterceptFromHost{SystemPrompt: &base}); got != "base/z-first/a-second" { | |
| 114 | + | t.Fatal(got) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func TestBeforeAgentStartMalformedResponseTimesOut(t *testing.T) { | |
| 120 | + | hooks := &stubHooks{} | |
| 121 | + | m := New(t.TempDir(), "", "test", "p", "m", hooks) | |
| 122 | + | ext := addStartExtension(t, m, "bad", func(extproto.EventInterceptFromHost) string { return `{invalid` }) | |
| 123 | + | addStartExtension(t, m, "good", func(ev extproto.EventInterceptFromHost) string { | |
| 124 | + | if *ev.SystemPrompt != "base" { | |
| 125 | + | t.Error("failed extension changed prompt") | |
| 126 | + | } | |
| 127 | + | return `{"type":"event_intercept_response","id":"` + ev.ID + `","system_prompt":"good"}` | |
| 128 | + | }) | |
| 129 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*interceptTimeout) | |
| 130 | + | defer cancel() | |
| 131 | + | base := "base" | |
| 132 | + | if got := m.InterceptBeforeAgentStart(ctx, extproto.EventInterceptFromHost{SystemPrompt: &base}); got != "good" { | |
| 133 | + | t.Fatalf("chain did not continue after timeout: %q", got) | |
| 134 | + | } | |
| 135 | + | ext.mu.Lock() | |
| 136 | + | pending := len(ext.pendingIntercept) | |
| 137 | + | ext.mu.Unlock() | |
| 138 | + | if pending != 0 { | |
| 139 | + | t.Fatal("pending request leaked") | |
| 140 | + | } | |
| 141 | + | hooks.mu.Lock() | |
| 142 | + | defer hooks.mu.Unlock() | |
| 143 | + | if len(hooks.notifies) != 1 || !strings.Contains(hooks.notifies[0], "timed out") { | |
| 144 | + | t.Fatalf("missing timeout warning: %v", hooks.notifies) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestBeforeAgentStartBoundsBlockedWrite(t *testing.T) { | |
| 149 | + | m := New(t.TempDir(), "", "test", "p", "m", nil) | |
| 150 | + | log, err := os.CreateTemp(t.TempDir(), "log") | |
| 151 | + | if err != nil { | |
| 152 | + | t.Fatal(err) | |
| 153 | + | } | |
| 154 | + | defer log.Close() | |
| 155 | + | reader, writer := io.Pipe() | |
| 156 | + | defer reader.Close() | |
| 157 | + | ext := &Extension{stdin: writer, logFile: log, pendingIntercept: map[string]chan extproto.EventInterceptResponseFromExt{}} | |
| 158 | + | ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) | |
| 159 | + | defer cancel() | |
| 160 | + | result := m.askIntercept(ctx, ext, extproto.EventInterceptFromHost{Event: "before_agent_start"}) | |
| 161 | + | if result.failure == "" { | |
| 162 | + | t.Fatal("expected write timeout") | |
| 163 | + | } | |
| 164 | + | if _, err := writer.Write([]byte("x")); err == nil { | |
| 165 | + | t.Fatal("blocked pipe not closed") | |
| 166 | + | } | |
| 167 | + | } | |
| @@ -0,0 +1,167 @@ | |||||
| 1 | + | package extensions | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "io" | |||
| 8 | + | "os" | |||
| 9 | + | "strings" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | type startWriter struct{ write func([]byte) (int, error) } | |||
| 17 | + | ||||
| 18 | + | func (w startWriter) Write(p []byte) (int, error) { return w.write(p) } | |||
| 19 | + | func (w startWriter) Close() error { return nil } | |||
| 20 | + | ||||
| 21 | + | func addStartExtension(t *testing.T, m *Manager, name string, response func(extproto.EventInterceptFromHost) string) *Extension { | |||
| 22 | + | t.Helper() | |||
| 23 | + | log, err := os.CreateTemp(t.TempDir(), "log") | |||
| 24 | + | if err != nil { | |||
| 25 | + | t.Fatal(err) | |||
| 26 | + | } | |||
| 27 | + | reader, writer := io.Pipe() | |||
| 28 | + | ext := &Extension{Manifest: Manifest{Name: name}, logFile: log, readyCh: make(chan struct{}), pendingIntercept: map[string]chan extproto.EventInterceptResponseFromExt{}, interceptSubs: map[string]struct{}{}, eventSubs: map[string]struct{}{}} | |||
| 29 | + | ext.stdin = startWriter{write: func(p []byte) (int, error) { | |||
| 30 | + | var ev extproto.EventInterceptFromHost | |||
| 31 | + | if err := json.Unmarshal(p, &ev); err != nil { | |||
| 32 | + | return 0, err | |||
| 33 | + | } | |||
| 34 | + | _, err := io.WriteString(writer, response(ev)+"\n") | |||
| 35 | + | return len(p), err | |||
| 36 | + | }} | |||
| 37 | + | m.mu.Lock() | |||
| 38 | + | m.ext[name] = ext | |||
| 39 | + | m.extOrder = append(m.extOrder, ext) | |||
| 40 | + | m.mu.Unlock() | |||
| 41 | + | done := make(chan struct{}) | |||
| 42 | + | scanner := bufio.NewScanner(reader) | |||
| 43 | + | scanner.Buffer(make([]byte, 4096), 4*1024*1024) | |||
| 44 | + | go func() { defer close(done); m.readLoop(ext, scanner) }() | |||
| 45 | + | t.Cleanup(func() { writer.Close(); <-done; reader.Close(); log.Close() }) | |||
| 46 | + | if _, err := io.WriteString(writer, `{"type":"subscribe","intercept":["before_agent_start"]}`+"\n"+`{"type":"ready"}`+"\n"); err != nil { | |||
| 47 | + | t.Fatal(err) | |||
| 48 | + | } | |||
| 49 | + | <-ext.readyCh | |||
| 50 | + | return ext | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestBeforeAgentStartResponses(t *testing.T) { | |||
| 54 | + | for _, tc := range []struct { | |||
| 55 | + | name, raw, want string | |||
| 56 | + | warning bool | |||
| 57 | + | }{ | |||
| 58 | + | {"unchanged", `"base"`, "base", false}, | |||
| 59 | + | {"appended", `"base plus"`, "base plus", false}, | |||
| 60 | + | {"rewritten", `"BASE"`, "BASE", false}, | |||
| 61 | + | {"replaced", `" völlig neu\n\n"`, " völlig neu\n\n", false}, | |||
| 62 | + | {"empty", `""`, "", false}, | |||
| 63 | + | {"omitted", "", "base", false}, | |||
| 64 | + | {"null", "null", "base", true}, | |||
| 65 | + | {"number", "42", "base", true}, | |||
| 66 | + | {"object", "{}", "base", true}, | |||
| 67 | + | {"oversized", `"` + strings.Repeat("x", MaxSystemPromptResponseSize) + `"`, "base", true}, | |||
| 68 | + | } { | |||
| 69 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 70 | + | hooks := &stubHooks{} | |||
| 71 | + | m := New(t.TempDir(), "/effective", "test", "provider", "model", hooks) | |||
| 72 | + | ext := addStartExtension(t, m, "test", func(ev extproto.EventInterceptFromHost) string { | |||
| 73 | + | if ev.Event != "before_agent_start" || ev.SystemPrompt == nil || *ev.SystemPrompt != "base" || ev.CWD != "/effective" || ev.SessionID != "session" || ev.AgentRunID != "run" || ev.Provider != "provider" || ev.Model != "model" { | |||
| 74 | + | t.Errorf("wrong event metadata") | |||
| 75 | + | } | |||
| 76 | + | field := "" | |||
| 77 | + | if tc.raw != "" { | |||
| 78 | + | field = `,"system_prompt":` + tc.raw | |||
| 79 | + | } | |||
| 80 | + | return `{"type":"event_intercept_response","id":"` + ev.ID + `"` + field + `}` | |||
| 81 | + | }) | |||
| 82 | + | base := "base" | |||
| 83 | + | got := m.InterceptBeforeAgentStart(context.Background(), extproto.EventInterceptFromHost{SystemPrompt: &base, SessionID: "session", AgentRunID: "run", Model: "model"}) | |||
| 84 | + | if got != tc.want { | |||
| 85 | + | t.Fatalf("got %q, want %q", got, tc.want) | |||
| 86 | + | } | |||
| 87 | + | ext.mu.Lock() | |||
| 88 | + | warnings := len(ext.diagnostics) | |||
| 89 | + | ext.mu.Unlock() | |||
| 90 | + | if (warnings > 0) != tc.warning { | |||
| 91 | + | t.Fatalf("warnings = %d", warnings) | |||
| 92 | + | } | |||
| 93 | + | hooks.mu.Lock() | |||
| 94 | + | notifications := len(hooks.notifies) | |||
| 95 | + | hooks.mu.Unlock() | |||
| 96 | + | if (notifications > 0) != tc.warning { | |||
| 97 | + | t.Fatalf("notifications = %d", notifications) | |||
| 98 | + | } | |||
| 99 | + | }) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestBeforeAgentStartChainsInLoadOrder(t *testing.T) { | |||
| 104 | + | m := New(t.TempDir(), "", "test", "p", "m", nil) | |||
| 105 | + | for _, name := range []string{"z-first", "a-second"} { | |||
| 106 | + | addStartExtension(t, m, name, func(ev extproto.EventInterceptFromHost) string { | |||
| 107 | + | raw, _ := json.Marshal(*ev.SystemPrompt + "/" + name) | |||
| 108 | + | return `{"type":"event_intercept_response","id":"` + ev.ID + `","system_prompt":` + string(raw) + `}` | |||
| 109 | + | }) | |||
| 110 | + | } | |||
| 111 | + | for range 20 { | |||
| 112 | + | base := "base" | |||
| 113 | + | if got := m.InterceptBeforeAgentStart(context.Background(), extproto.EventInterceptFromHost{SystemPrompt: &base}); got != "base/z-first/a-second" { | |||
| 114 | + | t.Fatal(got) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestBeforeAgentStartMalformedResponseTimesOut(t *testing.T) { | |||
| 120 | + | hooks := &stubHooks{} | |||
| 121 | + | m := New(t.TempDir(), "", "test", "p", "m", hooks) | |||
| 122 | + | ext := addStartExtension(t, m, "bad", func(extproto.EventInterceptFromHost) string { return `{invalid` }) | |||
| 123 | + | addStartExtension(t, m, "good", func(ev extproto.EventInterceptFromHost) string { | |||
| 124 | + | if *ev.SystemPrompt != "base" { | |||
| 125 | + | t.Error("failed extension changed prompt") | |||
| 126 | + | } | |||
| 127 | + | return `{"type":"event_intercept_response","id":"` + ev.ID + `","system_prompt":"good"}` | |||
| 128 | + | }) | |||
| 129 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*interceptTimeout) | |||
| 130 | + | defer cancel() | |||
| 131 | + | base := "base" | |||
| 132 | + | if got := m.InterceptBeforeAgentStart(ctx, extproto.EventInterceptFromHost{SystemPrompt: &base}); got != "good" { | |||
| 133 | + | t.Fatalf("chain did not continue after timeout: %q", got) | |||
| 134 | + | } | |||
| 135 | + | ext.mu.Lock() | |||
| 136 | + | pending := len(ext.pendingIntercept) | |||
| 137 | + | ext.mu.Unlock() | |||
| 138 | + | if pending != 0 { | |||
| 139 | + | t.Fatal("pending request leaked") | |||
| 140 | + | } | |||
| 141 | + | hooks.mu.Lock() | |||
| 142 | + | defer hooks.mu.Unlock() | |||
| 143 | + | if len(hooks.notifies) != 1 || !strings.Contains(hooks.notifies[0], "timed out") { | |||
| 144 | + | t.Fatalf("missing timeout warning: %v", hooks.notifies) | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func TestBeforeAgentStartBoundsBlockedWrite(t *testing.T) { | |||
| 149 | + | m := New(t.TempDir(), "", "test", "p", "m", nil) | |||
| 150 | + | log, err := os.CreateTemp(t.TempDir(), "log") | |||
| 151 | + | if err != nil { | |||
| 152 | + | t.Fatal(err) | |||
| 153 | + | } | |||
| 154 | + | defer log.Close() | |||
| 155 | + | reader, writer := io.Pipe() | |||
| 156 | + | defer reader.Close() | |||
| 157 | + | ext := &Extension{stdin: writer, logFile: log, pendingIntercept: map[string]chan extproto.EventInterceptResponseFromExt{}} | |||
| 158 | + | ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) | |||
| 159 | + | defer cancel() | |||
| 160 | + | result := m.askIntercept(ctx, ext, extproto.EventInterceptFromHost{Event: "before_agent_start"}) | |||
| 161 | + | if result.failure == "" { | |||
| 162 | + | t.Fatal("expected write timeout") | |||
| 163 | + | } | |||
| 164 | + | if _, err := writer.Write([]byte("x")); err == nil { | |||
| 165 | + | t.Fatal("blocked pipe not closed") | |||
| 166 | + | } | |||
| 167 | + | } | |||
M packages/agent/extensions/events.go +24 -5
| @@ -52,6 +52,8 @@ func (m *Manager) EmitEvent(ev extproto.EventFromHost) { | |||
| 52 | 52 | // optional rewrite fields (ModifiedArgs for tool_call, ReplaceText | |
| 53 | 53 | // for assistant_message) to carry the rewrite into the action. | |
| 54 | 54 | type InterceptResult struct { | |
| 55 | + | SystemPrompt json.RawMessage | |
| 56 | + | failure string | |
| 55 | 57 | Block bool | |
| 56 | 58 | Reason string | |
| 57 | 59 | ModifiedArgs json.RawMessage | |
| @@ -162,7 +164,8 @@ func (m *Manager) interceptSubsFor(event string) []*Extension { | |||
| 162 | 164 | ||
| 163 | 165 | // askIntercept sends one EventInterceptFromHost to ext and waits for | |
| 164 | 166 | // the reply, a timeout, or context cancellation. Returns a typed | |
| 165 | - | // result. Never blocks for longer than interceptTimeout. | |
| 167 | + | // result. before_agent_start callers bound the complete operation with a | |
| 168 | + | // context deadline; other events retain the legacy response-only timeout. | |
| 166 | 169 | func (m *Manager) askIntercept(ctx context.Context, ext *Extension, payload extproto.EventInterceptFromHost) InterceptResult { | |
| 167 | 170 | id := newCorrelationID() | |
| 168 | 171 | ch := make(chan extproto.EventInterceptResponseFromExt, 1) | |
| @@ -179,17 +182,33 @@ func (m *Manager) askIntercept(ctx context.Context, ext *Extension, payload extp | |||
| 179 | 182 | ext.mu.Unlock() | |
| 180 | 183 | return InterceptResult{} | |
| 181 | 184 | } | |
| 182 | - | if _, err := ext.stdin.Write(frame); err != nil { | |
| 185 | + | var writeErr error | |
| 186 | + | if payload.Event == "before_agent_start" { | |
| 187 | + | // Bound pipe writes too. Closing the pipe releases a blocked writer. | |
| 188 | + | done := make(chan error, 1) | |
| 189 | + | go func() { _, err := ext.stdin.Write(frame); done <- err }() | |
| 190 | + | select { | |
| 191 | + | case writeErr = <-done: | |
| 192 | + | case <-ctx.Done(): | |
| 193 | + | _ = ext.stdin.Close() | |
| 194 | + | <-done | |
| 195 | + | writeErr = ctx.Err() | |
| 196 | + | } | |
| 197 | + | } else { | |
| 198 | + | _, writeErr = ext.stdin.Write(frame) | |
| 199 | + | } | |
| 200 | + | if err := writeErr; err != nil { | |
| 183 | 201 | ext.mu.Lock() | |
| 184 | 202 | delete(ext.pendingIntercept, id) | |
| 185 | 203 | ext.mu.Unlock() | |
| 186 | 204 | fmt.Fprintf(ext.logFile, "[zot] intercept write failed: %v\n", err) | |
| 187 | - | return InterceptResult{} | |
| 205 | + | return InterceptResult{failure: "request write failed"} | |
| 188 | 206 | } | |
| 189 | 207 | ||
| 190 | 208 | select { | |
| 191 | 209 | case resp := <-ch: | |
| 192 | 210 | return InterceptResult{ | |
| 211 | + | SystemPrompt: resp.SystemPrompt, | |
| 193 | 212 | Block: resp.Block, | |
| 194 | 213 | Reason: resp.Reason, | |
| 195 | 214 | ModifiedArgs: resp.ModifiedArgs, | |
| @@ -200,12 +219,12 @@ func (m *Manager) askIntercept(ctx context.Context, ext *Extension, payload extp | |||
| 200 | 219 | delete(ext.pendingIntercept, id) | |
| 201 | 220 | ext.mu.Unlock() | |
| 202 | 221 | fmt.Fprintf(ext.logFile, "[zot] intercept %s timed out; allowing\n", payload.Event) | |
| 203 | - | return InterceptResult{} | |
| 222 | + | return InterceptResult{failure: "response timed out"} | |
| 204 | 223 | case <-ctx.Done(): | |
| 205 | 224 | ext.mu.Lock() | |
| 206 | 225 | delete(ext.pendingIntercept, id) | |
| 207 | 226 | ext.mu.Unlock() | |
| 208 | - | return InterceptResult{} | |
| 227 | + | return InterceptResult{failure: "response canceled or timed out"} | |
| 209 | 228 | } | |
| 210 | 229 | } | |
| 211 | 230 | ||
| @@ -52,6 +52,8 @@ func (m *Manager) EmitEvent(ev extproto.EventFromHost) { | |||||
| 52 | // optional rewrite fields (ModifiedArgs for tool_call, ReplaceText | 52 | // optional rewrite fields (ModifiedArgs for tool_call, ReplaceText | ||
| 53 | // for assistant_message) to carry the rewrite into the action. | 53 | // for assistant_message) to carry the rewrite into the action. | ||
| 54 | type InterceptResult struct { | 54 | type InterceptResult struct { | ||
| 55 | + | SystemPrompt json.RawMessage | |||
| 56 | + | failure string | |||
| 55 | Block bool | 57 | Block bool | ||
| 56 | Reason string | 58 | Reason string | ||
| 57 | ModifiedArgs json.RawMessage | 59 | ModifiedArgs json.RawMessage | ||
| @@ -162,7 +164,8 @@ func (m *Manager) interceptSubsFor(event string) []*Extension { | |||||
| 162 | 164 | ||||
| 163 | // askIntercept sends one EventInterceptFromHost to ext and waits for | 165 | // askIntercept sends one EventInterceptFromHost to ext and waits for | ||
| 164 | // the reply, a timeout, or context cancellation. Returns a typed | 166 | // the reply, a timeout, or context cancellation. Returns a typed | ||
| 165 | - | // result. Never blocks for longer than interceptTimeout. | 167 | + | // result. before_agent_start callers bound the complete operation with a |
| 168 | + | // context deadline; other events retain the legacy response-only timeout. | |||
| 166 | func (m *Manager) askIntercept(ctx context.Context, ext *Extension, payload extproto.EventInterceptFromHost) InterceptResult { | 169 | func (m *Manager) askIntercept(ctx context.Context, ext *Extension, payload extproto.EventInterceptFromHost) InterceptResult { | ||
| 167 | id := newCorrelationID() | 170 | id := newCorrelationID() | ||
| 168 | ch := make(chan extproto.EventInterceptResponseFromExt, 1) | 171 | ch := make(chan extproto.EventInterceptResponseFromExt, 1) | ||
| @@ -179,17 +182,33 @@ func (m *Manager) askIntercept(ctx context.Context, ext *Extension, payload extp | |||||
| 179 | ext.mu.Unlock() | 182 | ext.mu.Unlock() | ||
| 180 | return InterceptResult{} | 183 | return InterceptResult{} | ||
| 181 | } | 184 | } | ||
| 182 | - | if _, err := ext.stdin.Write(frame); err != nil { | 185 | + | var writeErr error |
| 186 | + | if payload.Event == "before_agent_start" { | |||
| 187 | + | // Bound pipe writes too. Closing the pipe releases a blocked writer. | |||
| 188 | + | done := make(chan error, 1) | |||
| 189 | + | go func() { _, err := ext.stdin.Write(frame); done <- err }() | |||
| 190 | + | select { | |||
| 191 | + | case writeErr = <-done: | |||
| 192 | + | case <-ctx.Done(): | |||
| 193 | + | _ = ext.stdin.Close() | |||
| 194 | + | <-done | |||
| 195 | + | writeErr = ctx.Err() | |||
| 196 | + | } | |||
| 197 | + | } else { | |||
| 198 | + | _, writeErr = ext.stdin.Write(frame) | |||
| 199 | + | } | |||
| 200 | + | if err := writeErr; err != nil { | |||
| 183 | ext.mu.Lock() | 201 | ext.mu.Lock() | ||
| 184 | delete(ext.pendingIntercept, id) | 202 | delete(ext.pendingIntercept, id) | ||
| 185 | ext.mu.Unlock() | 203 | ext.mu.Unlock() | ||
| 186 | fmt.Fprintf(ext.logFile, "[zot] intercept write failed: %v\n", err) | 204 | fmt.Fprintf(ext.logFile, "[zot] intercept write failed: %v\n", err) | ||
| 187 | - | return InterceptResult{} | 205 | + | return InterceptResult{failure: "request write failed"} |
| 188 | } | 206 | } | ||
| 189 | 207 | ||||
| 190 | select { | 208 | select { | ||
| 191 | case resp := <-ch: | 209 | case resp := <-ch: | ||
| 192 | return InterceptResult{ | 210 | return InterceptResult{ | ||
| 211 | + | SystemPrompt: resp.SystemPrompt, | |||
| 193 | Block: resp.Block, | 212 | Block: resp.Block, | ||
| 194 | Reason: resp.Reason, | 213 | Reason: resp.Reason, | ||
| 195 | ModifiedArgs: resp.ModifiedArgs, | 214 | ModifiedArgs: resp.ModifiedArgs, | ||
| @@ -200,12 +219,12 @@ func (m *Manager) askIntercept(ctx context.Context, ext *Extension, payload extp | |||||
| 200 | delete(ext.pendingIntercept, id) | 219 | delete(ext.pendingIntercept, id) | ||
| 201 | ext.mu.Unlock() | 220 | ext.mu.Unlock() | ||
| 202 | fmt.Fprintf(ext.logFile, "[zot] intercept %s timed out; allowing\n", payload.Event) | 221 | fmt.Fprintf(ext.logFile, "[zot] intercept %s timed out; allowing\n", payload.Event) | ||
| 203 | - | return InterceptResult{} | 222 | + | return InterceptResult{failure: "response timed out"} |
| 204 | case <-ctx.Done(): | 223 | case <-ctx.Done(): | ||
| 205 | ext.mu.Lock() | 224 | ext.mu.Lock() | ||
| 206 | delete(ext.pendingIntercept, id) | 225 | delete(ext.pendingIntercept, id) | ||
| 207 | ext.mu.Unlock() | 226 | ext.mu.Unlock() | ||
| 208 | - | return InterceptResult{} | 227 | + | return InterceptResult{failure: "response canceled or timed out"} |
| 209 | } | 228 | } | ||
| 210 | } | 229 | } | ||
| 211 | 230 | ||||
M packages/agent/extensions/manager.go +6 -3
| @@ -148,8 +148,9 @@ type Manager struct { | |||
| 148 | 148 | model string | |
| 149 | 149 | hooks HostHooks | |
| 150 | 150 | ||
| 151 | - | mu sync.RWMutex | |
| 152 | - | ext map[string]*Extension // keyed by manifest name | |
| 151 | + | mu sync.RWMutex | |
| 152 | + | ext map[string]*Extension // keyed by manifest name | |
| 153 | + | extOrder []*Extension // successful load order | |
| 153 | 154 | ||
| 154 | 155 | // commandIndex maps a lower-cased slash-command name (without the | |
| 155 | 156 | // leading /) to its canonical registration. First-come-first-served: | |
| @@ -429,6 +430,7 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error { | |||
| 429 | 430 | ||
| 430 | 431 | m.mu.Lock() | |
| 431 | 432 | m.ext[mf.Name] = ext | |
| 433 | + | m.extOrder = append(m.extOrder, ext) | |
| 432 | 434 | // Note: ext.commands and ext.tools may be empty here — they're | |
| 433 | 435 | // populated by the read loop as register_* frames arrive after | |
| 434 | 436 | // hello. Indexing happens in the read loop too. Discover()'s | |
| @@ -551,6 +553,7 @@ func (m *Manager) Reload(ctx context.Context, grace time.Duration) ReloadStats { | |||
| 551 | 553 | explicit := append([]string(nil), m.explicitPaths...) | |
| 552 | 554 | stats.Stopped = len(old) | |
| 553 | 555 | m.ext = map[string]*Extension{} | |
| 556 | + | m.extOrder = nil | |
| 554 | 557 | m.commandIndex = map[string]commandRegistration{} | |
| 555 | 558 | m.toolIndex = map[string]*Extension{} | |
| 556 | 559 | m.explicitPaths = nil | |
| @@ -931,7 +934,7 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||
| 931 | 934 | } | |
| 932 | 935 | for _, ev := range sub.Intercept { | |
| 933 | 936 | switch ev { | |
| 934 | - | case "tool_call", "turn_start", "assistant_message": | |
| 937 | + | case "tool_call", "turn_start", "assistant_message", "before_agent_start": | |
| 935 | 938 | ext.interceptSubs[ev] = struct{}{} | |
| 936 | 939 | } | |
| 937 | 940 | } | |
| @@ -148,8 +148,9 @@ type Manager struct { | |||||
| 148 | model string | 148 | model string | ||
| 149 | hooks HostHooks | 149 | hooks HostHooks | ||
| 150 | 150 | ||||
| 151 | - | mu sync.RWMutex | 151 | + | mu sync.RWMutex |
| 152 | - | ext map[string]*Extension // keyed by manifest name | 152 | + | ext map[string]*Extension // keyed by manifest name |
| 153 | + | extOrder []*Extension // successful load order | |||
| 153 | 154 | ||||
| 154 | // commandIndex maps a lower-cased slash-command name (without the | 155 | // commandIndex maps a lower-cased slash-command name (without the | ||
| 155 | // leading /) to its canonical registration. First-come-first-served: | 156 | // leading /) to its canonical registration. First-come-first-served: | ||
| @@ -429,6 +430,7 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error { | |||||
| 429 | 430 | ||||
| 430 | m.mu.Lock() | 431 | m.mu.Lock() | ||
| 431 | m.ext[mf.Name] = ext | 432 | m.ext[mf.Name] = ext | ||
| 433 | + | m.extOrder = append(m.extOrder, ext) | |||
| 432 | // Note: ext.commands and ext.tools may be empty here — they're | 434 | // Note: ext.commands and ext.tools may be empty here — they're | ||
| 433 | // populated by the read loop as register_* frames arrive after | 435 | // populated by the read loop as register_* frames arrive after | ||
| 434 | // hello. Indexing happens in the read loop too. Discover()'s | 436 | // hello. Indexing happens in the read loop too. Discover()'s | ||
| @@ -551,6 +553,7 @@ func (m *Manager) Reload(ctx context.Context, grace time.Duration) ReloadStats { | |||||
| 551 | explicit := append([]string(nil), m.explicitPaths...) | 553 | explicit := append([]string(nil), m.explicitPaths...) | ||
| 552 | stats.Stopped = len(old) | 554 | stats.Stopped = len(old) | ||
| 553 | m.ext = map[string]*Extension{} | 555 | m.ext = map[string]*Extension{} | ||
| 556 | + | m.extOrder = nil | |||
| 554 | m.commandIndex = map[string]commandRegistration{} | 557 | m.commandIndex = map[string]commandRegistration{} | ||
| 555 | m.toolIndex = map[string]*Extension{} | 558 | m.toolIndex = map[string]*Extension{} | ||
| 556 | m.explicitPaths = nil | 559 | m.explicitPaths = nil | ||
| @@ -931,7 +934,7 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||||
| 931 | } | 934 | } | ||
| 932 | for _, ev := range sub.Intercept { | 935 | for _, ev := range sub.Intercept { | ||
| 933 | switch ev { | 936 | switch ev { | ||
| 934 | - | case "tool_call", "turn_start", "assistant_message": | 937 | + | case "tool_call", "turn_start", "assistant_message", "before_agent_start": |
| 935 | ext.interceptSubs[ev] = struct{}{} | 938 | ext.interceptSubs[ev] = struct{}{} | ||
| 936 | } | 939 | } | ||
| 937 | } | 940 | } | ||
M packages/agent/extproto/extproto.go +15 -8
| @@ -58,6 +58,7 @@ type SubscribeFromExt struct { | |||
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | 60 | type EventInterceptResponseFromExt struct { | |
| 61 | + | SystemPrompt json.RawMessage `json:"system_prompt,omitempty"` | |
| 61 | 62 | Type string `json:"type"` | |
| 62 | 63 | ID string `json:"id"` | |
| 63 | 64 | Block bool `json:"block,omitempty"` | |
| @@ -196,14 +197,20 @@ type EventFromHost struct { | |||
| 196 | 197 | } | |
| 197 | 198 | ||
| 198 | 199 | type EventInterceptFromHost struct { | |
| 199 | - | Type string `json:"type"` | |
| 200 | - | ID string `json:"id"` | |
| 201 | - | Event string `json:"event"` | |
| 202 | - | ToolID string `json:"tool_id,omitempty"` | |
| 203 | - | ToolName string `json:"tool_name,omitempty"` | |
| 204 | - | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | |
| 205 | - | Step int `json:"step,omitempty"` | |
| 206 | - | Text string `json:"text,omitempty"` | |
| 200 | + | SystemPrompt *string `json:"system_prompt,omitempty"` | |
| 201 | + | SessionID string `json:"session_id,omitempty"` | |
| 202 | + | AgentRunID string `json:"agent_run_id,omitempty"` | |
| 203 | + | CWD string `json:"cwd,omitempty"` | |
| 204 | + | Provider string `json:"provider,omitempty"` | |
| 205 | + | Model string `json:"model,omitempty"` | |
| 206 | + | Type string `json:"type"` | |
| 207 | + | ID string `json:"id"` | |
| 208 | + | Event string `json:"event"` | |
| 209 | + | ToolID string `json:"tool_id,omitempty"` | |
| 210 | + | ToolName string `json:"tool_name,omitempty"` | |
| 211 | + | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | |
| 212 | + | Step int `json:"step,omitempty"` | |
| 213 | + | Text string `json:"text,omitempty"` | |
| 207 | 214 | } | |
| 208 | 215 | ||
| 209 | 216 | type PanelKeyFromHost struct { | |
| @@ -58,6 +58,7 @@ type SubscribeFromExt struct { | |||||
| 58 | } | 58 | } | ||
| 59 | 59 | ||||
| 60 | type EventInterceptResponseFromExt struct { | 60 | type EventInterceptResponseFromExt struct { | ||
| 61 | + | SystemPrompt json.RawMessage `json:"system_prompt,omitempty"` | |||
| 61 | Type string `json:"type"` | 62 | Type string `json:"type"` | ||
| 62 | ID string `json:"id"` | 63 | ID string `json:"id"` | ||
| 63 | Block bool `json:"block,omitempty"` | 64 | Block bool `json:"block,omitempty"` | ||
| @@ -196,14 +197,20 @@ type EventFromHost struct { | |||||
| 196 | } | 197 | } | ||
| 197 | 198 | ||||
| 198 | type EventInterceptFromHost struct { | 199 | type EventInterceptFromHost struct { | ||
| 199 | - | Type string `json:"type"` | 200 | + | SystemPrompt *string `json:"system_prompt,omitempty"` |
| 200 | - | ID string `json:"id"` | 201 | + | SessionID string `json:"session_id,omitempty"` |
| 201 | - | Event string `json:"event"` | 202 | + | AgentRunID string `json:"agent_run_id,omitempty"` |
| 202 | - | ToolID string `json:"tool_id,omitempty"` | 203 | + | CWD string `json:"cwd,omitempty"` |
| 203 | - | ToolName string `json:"tool_name,omitempty"` | 204 | + | Provider string `json:"provider,omitempty"` |
| 204 | - | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | 205 | + | Model string `json:"model,omitempty"` |
| 205 | - | Step int `json:"step,omitempty"` | 206 | + | Type string `json:"type"` |
| 206 | - | Text string `json:"text,omitempty"` | 207 | + | ID string `json:"id"` |
| 208 | + | Event string `json:"event"` | |||
| 209 | + | ToolID string `json:"tool_id,omitempty"` | |||
| 210 | + | ToolName string `json:"tool_name,omitempty"` | |||
| 211 | + | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | |||
| 212 | + | Step int `json:"step,omitempty"` | |||
| 213 | + | Text string `json:"text,omitempty"` | |||
| 207 | } | 214 | } | ||
| 208 | 215 | ||||
| 209 | type PanelKeyFromHost struct { | 216 | type PanelKeyFromHost struct { | ||
A packages/agent/modes/before_start_test.go +149 -0
| @@ -0,0 +1,149 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/packages/core" | |
| 10 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type preparationCaptureClient struct{ requests chan provider.Request } | |
| 14 | + | ||
| 15 | + | func (*preparationCaptureClient) Name() string { return "test" } | |
| 16 | + | func (c *preparationCaptureClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) { | |
| 17 | + | select { | |
| 18 | + | case c.requests <- req: | |
| 19 | + | case <-ctx.Done(): | |
| 20 | + | return nil, ctx.Err() | |
| 21 | + | } | |
| 22 | + | events := make(chan provider.Event, 1) | |
| 23 | + | events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "ok"}}}} | |
| 24 | + | close(events) | |
| 25 | + | return events, nil | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestAutoSwarmToggleDuringPromptPreparation(t *testing.T) { | |
| 29 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 30 | + | defer cancel() | |
| 31 | + | client := &preparationCaptureClient{requests: make(chan provider.Request, 1)} | |
| 32 | + | ag := core.NewAgent(client, "model", "base", nil) | |
| 33 | + | entered, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{}) | |
| 34 | + | calls := 0 | |
| 35 | + | ag.BeforeStart = func(ctx context.Context, system string) string { | |
| 36 | + | calls++ | |
| 37 | + | if calls == 1 { | |
| 38 | + | close(entered) | |
| 39 | + | select { | |
| 40 | + | case <-release: | |
| 41 | + | case <-ctx.Done(): | |
| 42 | + | } | |
| 43 | + | return "stale replacement" | |
| 44 | + | } | |
| 45 | + | return system + "/extension" | |
| 46 | + | } | |
| 47 | + | i := &Interactive{agent: ag, cfg: InteractiveConfig{AutoSwarmSystemAddendum: "swarm instructions"}} | |
| 48 | + | var runErr error | |
| 49 | + | go func() { defer close(done); runErr = ag.Prompt(ctx, "hello", nil, nil) }() | |
| 50 | + | t.Cleanup(func() { cancel(); <-done }) | |
| 51 | + | select { | |
| 52 | + | case <-entered: | |
| 53 | + | case <-ctx.Done(): | |
| 54 | + | t.Fatal("hook did not start") | |
| 55 | + | } | |
| 56 | + | i.applyAutoSwarmSystemPrompt(true) | |
| 57 | + | close(release) | |
| 58 | + | select { | |
| 59 | + | case <-done: | |
| 60 | + | case <-ctx.Done(): | |
| 61 | + | t.Fatal("prompt did not finish") | |
| 62 | + | } | |
| 63 | + | if runErr != nil { | |
| 64 | + | t.Fatal(runErr) | |
| 65 | + | } | |
| 66 | + | if calls != 2 { | |
| 67 | + | t.Fatalf("preparation calls = %d, want 2", calls) | |
| 68 | + | } | |
| 69 | + | select { | |
| 70 | + | case req := <-client.requests: | |
| 71 | + | if req.System != "base\n\nswarm instructions/extension" { | |
| 72 | + | t.Fatalf("provider received stale prompt: %q", req.System) | |
| 73 | + | } | |
| 74 | + | default: | |
| 75 | + | t.Fatal("provider was not called") | |
| 76 | + | } | |
| 77 | + | if ag.BaseSystem() != "base\n\nswarm instructions" { | |
| 78 | + | t.Fatal("rebuild base was lost") | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func TestAutoSwarmToggleAfterPreparationBeforeModelCall(t *testing.T) { | |
| 83 | + | client := &preparationCaptureClient{requests: make(chan provider.Request, 1)} | |
| 84 | + | ag := core.NewAgent(client, "model", "base", nil) | |
| 85 | + | i := &Interactive{agent: ag, cfg: InteractiveConfig{AutoSwarmSystemAddendum: "swarm instructions"}} | |
| 86 | + | calls := 0 | |
| 87 | + | ag.BeforeStart = func(_ context.Context, system string) string { | |
| 88 | + | calls++ | |
| 89 | + | return system + "/extension" | |
| 90 | + | } | |
| 91 | + | // Simulate a settings change while the turn-start guard runs, after | |
| 92 | + | // initial preparation but before the provider request is constructed. | |
| 93 | + | ag.BeforeTurn = func(int) (bool, string) { | |
| 94 | + | i.applyAutoSwarmSystemPrompt(true) | |
| 95 | + | return true, "" | |
| 96 | + | } | |
| 97 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 98 | + | defer cancel() | |
| 99 | + | if err := ag.Prompt(ctx, "hello", nil, nil); err != nil { | |
| 100 | + | t.Fatal(err) | |
| 101 | + | } | |
| 102 | + | if calls != 2 { | |
| 103 | + | t.Fatalf("preparation calls = %d, want 2", calls) | |
| 104 | + | } | |
| 105 | + | select { | |
| 106 | + | case req := <-client.requests: | |
| 107 | + | if req.System != "base\n\nswarm instructions/extension" { | |
| 108 | + | t.Fatalf("provider received unprepared prompt: %q", req.System) | |
| 109 | + | } | |
| 110 | + | default: | |
| 111 | + | t.Fatal("provider was not called") | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestAutoSwarmRebuildsBasePromptBeforeExtensions(t *testing.T) { | |
| 116 | + | ag := core.NewAgent(nil, "model", "base", nil) | |
| 117 | + | calls := 0 | |
| 118 | + | ag.BeforeStart = func(ctx context.Context, system string) string { | |
| 119 | + | calls++ | |
| 120 | + | if strings.Contains(system, "extension") { | |
| 121 | + | t.Fatal("replacement fed back into extension") | |
| 122 | + | } | |
| 123 | + | return system + "/extension" | |
| 124 | + | } | |
| 125 | + | ag.BeforeTurn = func(int) (bool, string) { return false, "test" } | |
| 126 | + | i := &Interactive{agent: ag, cfg: InteractiveConfig{AutoSwarmSystemAddendum: "swarm instructions"}} | |
| 127 | + | prepare := func() { | |
| 128 | + | t.Helper() | |
| 129 | + | if err := ag.Continue(context.Background(), nil); err != nil { | |
| 130 | + | t.Fatal(err) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | prepare() | |
| 134 | + | i.applyAutoSwarmSystemPrompt(true) | |
| 135 | + | prepare() | |
| 136 | + | if !strings.Contains(ag.System, "swarm instructions") || calls != 2 { | |
| 137 | + | t.Fatal("toggle did not rebuild prompt") | |
| 138 | + | } | |
| 139 | + | i.applyAutoSwarmSystemPrompt(true) | |
| 140 | + | prepare() | |
| 141 | + | if calls != 2 { | |
| 142 | + | t.Fatal("unchanged toggle reapplied extensions") | |
| 143 | + | } | |
| 144 | + | i.applyAutoSwarmSystemPrompt(false) | |
| 145 | + | prepare() | |
| 146 | + | if strings.Contains(ag.System, "swarm instructions") || calls != 3 { | |
| 147 | + | t.Fatal("toggle did not remove instructions") | |
| 148 | + | } | |
| 149 | + | } | |
| @@ -0,0 +1,149 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 10 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | type preparationCaptureClient struct{ requests chan provider.Request } | |||
| 14 | + | ||||
| 15 | + | func (*preparationCaptureClient) Name() string { return "test" } | |||
| 16 | + | func (c *preparationCaptureClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) { | |||
| 17 | + | select { | |||
| 18 | + | case c.requests <- req: | |||
| 19 | + | case <-ctx.Done(): | |||
| 20 | + | return nil, ctx.Err() | |||
| 21 | + | } | |||
| 22 | + | events := make(chan provider.Event, 1) | |||
| 23 | + | events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "ok"}}}} | |||
| 24 | + | close(events) | |||
| 25 | + | return events, nil | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestAutoSwarmToggleDuringPromptPreparation(t *testing.T) { | |||
| 29 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 30 | + | defer cancel() | |||
| 31 | + | client := &preparationCaptureClient{requests: make(chan provider.Request, 1)} | |||
| 32 | + | ag := core.NewAgent(client, "model", "base", nil) | |||
| 33 | + | entered, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{}) | |||
| 34 | + | calls := 0 | |||
| 35 | + | ag.BeforeStart = func(ctx context.Context, system string) string { | |||
| 36 | + | calls++ | |||
| 37 | + | if calls == 1 { | |||
| 38 | + | close(entered) | |||
| 39 | + | select { | |||
| 40 | + | case <-release: | |||
| 41 | + | case <-ctx.Done(): | |||
| 42 | + | } | |||
| 43 | + | return "stale replacement" | |||
| 44 | + | } | |||
| 45 | + | return system + "/extension" | |||
| 46 | + | } | |||
| 47 | + | i := &Interactive{agent: ag, cfg: InteractiveConfig{AutoSwarmSystemAddendum: "swarm instructions"}} | |||
| 48 | + | var runErr error | |||
| 49 | + | go func() { defer close(done); runErr = ag.Prompt(ctx, "hello", nil, nil) }() | |||
| 50 | + | t.Cleanup(func() { cancel(); <-done }) | |||
| 51 | + | select { | |||
| 52 | + | case <-entered: | |||
| 53 | + | case <-ctx.Done(): | |||
| 54 | + | t.Fatal("hook did not start") | |||
| 55 | + | } | |||
| 56 | + | i.applyAutoSwarmSystemPrompt(true) | |||
| 57 | + | close(release) | |||
| 58 | + | select { | |||
| 59 | + | case <-done: | |||
| 60 | + | case <-ctx.Done(): | |||
| 61 | + | t.Fatal("prompt did not finish") | |||
| 62 | + | } | |||
| 63 | + | if runErr != nil { | |||
| 64 | + | t.Fatal(runErr) | |||
| 65 | + | } | |||
| 66 | + | if calls != 2 { | |||
| 67 | + | t.Fatalf("preparation calls = %d, want 2", calls) | |||
| 68 | + | } | |||
| 69 | + | select { | |||
| 70 | + | case req := <-client.requests: | |||
| 71 | + | if req.System != "base\n\nswarm instructions/extension" { | |||
| 72 | + | t.Fatalf("provider received stale prompt: %q", req.System) | |||
| 73 | + | } | |||
| 74 | + | default: | |||
| 75 | + | t.Fatal("provider was not called") | |||
| 76 | + | } | |||
| 77 | + | if ag.BaseSystem() != "base\n\nswarm instructions" { | |||
| 78 | + | t.Fatal("rebuild base was lost") | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func TestAutoSwarmToggleAfterPreparationBeforeModelCall(t *testing.T) { | |||
| 83 | + | client := &preparationCaptureClient{requests: make(chan provider.Request, 1)} | |||
| 84 | + | ag := core.NewAgent(client, "model", "base", nil) | |||
| 85 | + | i := &Interactive{agent: ag, cfg: InteractiveConfig{AutoSwarmSystemAddendum: "swarm instructions"}} | |||
| 86 | + | calls := 0 | |||
| 87 | + | ag.BeforeStart = func(_ context.Context, system string) string { | |||
| 88 | + | calls++ | |||
| 89 | + | return system + "/extension" | |||
| 90 | + | } | |||
| 91 | + | // Simulate a settings change while the turn-start guard runs, after | |||
| 92 | + | // initial preparation but before the provider request is constructed. | |||
| 93 | + | ag.BeforeTurn = func(int) (bool, string) { | |||
| 94 | + | i.applyAutoSwarmSystemPrompt(true) | |||
| 95 | + | return true, "" | |||
| 96 | + | } | |||
| 97 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 98 | + | defer cancel() | |||
| 99 | + | if err := ag.Prompt(ctx, "hello", nil, nil); err != nil { | |||
| 100 | + | t.Fatal(err) | |||
| 101 | + | } | |||
| 102 | + | if calls != 2 { | |||
| 103 | + | t.Fatalf("preparation calls = %d, want 2", calls) | |||
| 104 | + | } | |||
| 105 | + | select { | |||
| 106 | + | case req := <-client.requests: | |||
| 107 | + | if req.System != "base\n\nswarm instructions/extension" { | |||
| 108 | + | t.Fatalf("provider received unprepared prompt: %q", req.System) | |||
| 109 | + | } | |||
| 110 | + | default: | |||
| 111 | + | t.Fatal("provider was not called") | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func TestAutoSwarmRebuildsBasePromptBeforeExtensions(t *testing.T) { | |||
| 116 | + | ag := core.NewAgent(nil, "model", "base", nil) | |||
| 117 | + | calls := 0 | |||
| 118 | + | ag.BeforeStart = func(ctx context.Context, system string) string { | |||
| 119 | + | calls++ | |||
| 120 | + | if strings.Contains(system, "extension") { | |||
| 121 | + | t.Fatal("replacement fed back into extension") | |||
| 122 | + | } | |||
| 123 | + | return system + "/extension" | |||
| 124 | + | } | |||
| 125 | + | ag.BeforeTurn = func(int) (bool, string) { return false, "test" } | |||
| 126 | + | i := &Interactive{agent: ag, cfg: InteractiveConfig{AutoSwarmSystemAddendum: "swarm instructions"}} | |||
| 127 | + | prepare := func() { | |||
| 128 | + | t.Helper() | |||
| 129 | + | if err := ag.Continue(context.Background(), nil); err != nil { | |||
| 130 | + | t.Fatal(err) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | prepare() | |||
| 134 | + | i.applyAutoSwarmSystemPrompt(true) | |||
| 135 | + | prepare() | |||
| 136 | + | if !strings.Contains(ag.System, "swarm instructions") || calls != 2 { | |||
| 137 | + | t.Fatal("toggle did not rebuild prompt") | |||
| 138 | + | } | |||
| 139 | + | i.applyAutoSwarmSystemPrompt(true) | |||
| 140 | + | prepare() | |||
| 141 | + | if calls != 2 { | |||
| 142 | + | t.Fatal("unchanged toggle reapplied extensions") | |||
| 143 | + | } | |||
| 144 | + | i.applyAutoSwarmSystemPrompt(false) | |||
| 145 | + | prepare() | |||
| 146 | + | if strings.Contains(ag.System, "swarm instructions") || calls != 3 { | |||
| 147 | + | t.Fatal("toggle did not remove instructions") | |||
| 148 | + | } | |||
| 149 | + | } | |||
M packages/agent/modes/interactive.go +5 -5
| @@ -6632,8 +6632,8 @@ func truncateForSummary(s string, n int) string { | |||
| 6632 | 6632 | // applyAutoSwarmSystemPrompt appends (active=true) or strips | |
| 6633 | 6633 | // (active=false) the auto-swarm system-prompt block on the running | |
| 6634 | 6634 | // agent so the model proactively considers swarm_spawn when the user | |
| 6635 | - | // flips the toggle. The block lives at the tail of agent.System so | |
| 6636 | - | // stripping is a plain suffix-trim; idempotent in both directions. | |
| 6635 | + | // flips the toggle. Edit the base prompt so extension replacements are not | |
| 6636 | + | // applied twice when the next turn prepares the rebuilt prompt. | |
| 6637 | 6637 | func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) { | |
| 6638 | 6638 | if i.agent == nil { | |
| 6639 | 6639 | return | |
| @@ -6642,16 +6642,16 @@ func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) { | |||
| 6642 | 6642 | if addendum == "" { | |
| 6643 | 6643 | return | |
| 6644 | 6644 | } | |
| 6645 | - | sys := i.agent.System | |
| 6645 | + | sys := i.agent.BaseSystem() | |
| 6646 | 6646 | has := strings.Contains(sys, addendum) | |
| 6647 | 6647 | switch { | |
| 6648 | 6648 | case active && !has: | |
| 6649 | 6649 | if sys != "" && !strings.HasSuffix(sys, "\n\n") { | |
| 6650 | 6650 | sys += "\n\n" | |
| 6651 | 6651 | } | |
| 6652 | - | i.agent.System = sys + addendum | |
| 6652 | + | i.agent.SetSystem(sys + addendum) | |
| 6653 | 6653 | case !active && has: | |
| 6654 | - | i.agent.System = strings.TrimRight(strings.ReplaceAll(sys, addendum, ""), "\n") + "\n" | |
| 6654 | + | i.agent.SetSystem(strings.TrimRight(strings.ReplaceAll(sys, addendum, ""), "\n") + "\n") | |
| 6655 | 6655 | } | |
| 6656 | 6656 | } | |
| 6657 | 6657 | ||
| @@ -6632,8 +6632,8 @@ func truncateForSummary(s string, n int) string { | |||||
| 6632 | // applyAutoSwarmSystemPrompt appends (active=true) or strips | 6632 | // applyAutoSwarmSystemPrompt appends (active=true) or strips | ||
| 6633 | // (active=false) the auto-swarm system-prompt block on the running | 6633 | // (active=false) the auto-swarm system-prompt block on the running | ||
| 6634 | // agent so the model proactively considers swarm_spawn when the user | 6634 | // agent so the model proactively considers swarm_spawn when the user | ||
| 6635 | - | // flips the toggle. The block lives at the tail of agent.System so | 6635 | + | // flips the toggle. Edit the base prompt so extension replacements are not |
| 6636 | - | // stripping is a plain suffix-trim; idempotent in both directions. | 6636 | + | // applied twice when the next turn prepares the rebuilt prompt. |
| 6637 | func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) { | 6637 | func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) { | ||
| 6638 | if i.agent == nil { | 6638 | if i.agent == nil { | ||
| 6639 | return | 6639 | return | ||
| @@ -6642,16 +6642,16 @@ func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) { | |||||
| 6642 | if addendum == "" { | 6642 | if addendum == "" { | ||
| 6643 | return | 6643 | return | ||
| 6644 | } | 6644 | } | ||
| 6645 | - | sys := i.agent.System | 6645 | + | sys := i.agent.BaseSystem() |
| 6646 | has := strings.Contains(sys, addendum) | 6646 | has := strings.Contains(sys, addendum) | ||
| 6647 | switch { | 6647 | switch { | ||
| 6648 | case active && !has: | 6648 | case active && !has: | ||
| 6649 | if sys != "" && !strings.HasSuffix(sys, "\n\n") { | 6649 | if sys != "" && !strings.HasSuffix(sys, "\n\n") { | ||
| 6650 | sys += "\n\n" | 6650 | sys += "\n\n" | ||
| 6651 | } | 6651 | } | ||
| 6652 | - | i.agent.System = sys + addendum | 6652 | + | i.agent.SetSystem(sys + addendum) |
| 6653 | case !active && has: | 6653 | case !active && has: | ||
| 6654 | - | i.agent.System = strings.TrimRight(strings.ReplaceAll(sys, addendum, ""), "\n") + "\n" | 6654 | + | i.agent.SetSystem(strings.TrimRight(strings.ReplaceAll(sys, addendum, ""), "\n") + "\n") |
| 6655 | } | 6655 | } | ||
| 6656 | } | 6656 | } | ||
| 6657 | 6657 | ||||
M packages/agent/rpc.go +13 -19
| @@ -68,25 +68,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error { | |||
| 68 | 68 | r.MergeExtensionTools(&extToolAdapter{mgr: extMgr}) | |
| 69 | 69 | ||
| 70 | 70 | ag := r.NewAgent() | |
| 71 | - | ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | |
| 72 | - | r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments) | |
| 73 | - | if r.Block { | |
| 74 | - | return false, r.Reason, nil | |
| 75 | - | } | |
| 76 | - | return true, "", r.ModifiedArgs | |
| 77 | - | } | |
| 78 | - | ag.BeforeTurn = func(step int) (bool, string) { | |
| 79 | - | r := extMgr.InterceptTurnStart(ctx, step) | |
| 80 | - | return !r.Block, r.Reason | |
| 81 | - | } | |
| 82 | - | ag.BeforeAssistantMessage = func(text string) (bool, string, string) { | |
| 83 | - | r := extMgr.InterceptAssistantMessage(ctx, text) | |
| 84 | - | if r.Block { | |
| 85 | - | return false, r.Reason, "" | |
| 86 | - | } | |
| 87 | - | return true, "", r.ReplaceText | |
| 88 | - | } | |
| 89 | - | ag.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) } | |
| 71 | + | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 90 | 72 | ||
| 91 | 73 | // /reload-ext hot-reload callback (also triggered via rpc | |
| 92 | 74 | // `reload_ext` if/when added). Rebuilds the tool registry on the | |
| @@ -236,6 +218,18 @@ func (s *rpcServer) run(in io.Reader) error { | |||
| 236 | 218 | // dispatch routes a command. Long-running commands (prompt, compact) | |
| 237 | 219 | // run on their own goroutine so the read loop stays responsive. | |
| 238 | 220 | func (s *rpcServer) dispatch(cmd, id string, raw []byte) { | |
| 221 | + | // Runtime mutations must not race with prompt preparation, model/tool | |
| 222 | + | // execution, or compaction. Do not wait here: this is also the read loop | |
| 223 | + | // that must keep accepting abort while an extension or provider is busy. | |
| 224 | + | switch cmd { | |
| 225 | + | case "clear", "set_model", "set_reasoning": | |
| 226 | + | if !s.turnMu.TryLock() { | |
| 227 | + | s.writeError(id, cmd, "agent is busy; wait for completion or abort before retrying") | |
| 228 | + | return | |
| 229 | + | } | |
| 230 | + | defer s.turnMu.Unlock() | |
| 231 | + | } | |
| 232 | + | ||
| 239 | 233 | switch cmd { | |
| 240 | 234 | case "hello": | |
| 241 | 235 | s.writeResponse(id, cmd, map[string]any{ | |
| @@ -68,25 +68,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error { | |||||
| 68 | r.MergeExtensionTools(&extToolAdapter{mgr: extMgr}) | 68 | r.MergeExtensionTools(&extToolAdapter{mgr: extMgr}) | ||
| 69 | 69 | ||||
| 70 | ag := r.NewAgent() | 70 | ag := r.NewAgent() | ||
| 71 | - | ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | 71 | + | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) |
| 72 | - | r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments) | |||
| 73 | - | if r.Block { | |||
| 74 | - | return false, r.Reason, nil | |||
| 75 | - | } | |||
| 76 | - | return true, "", r.ModifiedArgs | |||
| 77 | - | } | |||
| 78 | - | ag.BeforeTurn = func(step int) (bool, string) { | |||
| 79 | - | r := extMgr.InterceptTurnStart(ctx, step) | |||
| 80 | - | return !r.Block, r.Reason | |||
| 81 | - | } | |||
| 82 | - | ag.BeforeAssistantMessage = func(text string) (bool, string, string) { | |||
| 83 | - | r := extMgr.InterceptAssistantMessage(ctx, text) | |||
| 84 | - | if r.Block { | |||
| 85 | - | return false, r.Reason, "" | |||
| 86 | - | } | |||
| 87 | - | return true, "", r.ReplaceText | |||
| 88 | - | } | |||
| 89 | - | ag.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) } | |||
| 90 | 72 | ||||
| 91 | // /reload-ext hot-reload callback (also triggered via rpc | 73 | // /reload-ext hot-reload callback (also triggered via rpc | ||
| 92 | // `reload_ext` if/when added). Rebuilds the tool registry on the | 74 | // `reload_ext` if/when added). Rebuilds the tool registry on the | ||
| @@ -236,6 +218,18 @@ func (s *rpcServer) run(in io.Reader) error { | |||||
| 236 | // dispatch routes a command. Long-running commands (prompt, compact) | 218 | // dispatch routes a command. Long-running commands (prompt, compact) | ||
| 237 | // run on their own goroutine so the read loop stays responsive. | 219 | // run on their own goroutine so the read loop stays responsive. | ||
| 238 | func (s *rpcServer) dispatch(cmd, id string, raw []byte) { | 220 | func (s *rpcServer) dispatch(cmd, id string, raw []byte) { | ||
| 221 | + | // Runtime mutations must not race with prompt preparation, model/tool | |||
| 222 | + | // execution, or compaction. Do not wait here: this is also the read loop | |||
| 223 | + | // that must keep accepting abort while an extension or provider is busy. | |||
| 224 | + | switch cmd { | |||
| 225 | + | case "clear", "set_model", "set_reasoning": | |||
| 226 | + | if !s.turnMu.TryLock() { | |||
| 227 | + | s.writeError(id, cmd, "agent is busy; wait for completion or abort before retrying") | |||
| 228 | + | return | |||
| 229 | + | } | |||
| 230 | + | defer s.turnMu.Unlock() | |||
| 231 | + | } | |||
| 232 | + | ||||
| 239 | switch cmd { | 233 | switch cmd { | ||
| 240 | case "hello": | 234 | case "hello": | ||
| 241 | s.writeResponse(id, cmd, map[string]any{ | 235 | s.writeResponse(id, cmd, map[string]any{ | ||
A packages/agent/rpc_reset_test.go +134 -0
| @@ -0,0 +1,134 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/patriceckhart/zot/packages/core" | |
| 12 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestRPCRejectsMutationsDuringPromptPreparation(t *testing.T) { | |
| 16 | + | models := provider.ModelsForProvider("anthropic") | |
| 17 | + | if len(models) < 2 { | |
| 18 | + | t.Fatal("need two catalog models") | |
| 19 | + | } | |
| 20 | + | modelJSON, err := json.Marshal(map[string]string{"model": models[1].ID}) | |
| 21 | + | if err != nil { | |
| 22 | + | t.Fatal(err) | |
| 23 | + | } | |
| 24 | + | for _, tc := range []struct { | |
| 25 | + | command string | |
| 26 | + | raw []byte | |
| 27 | + | }{ | |
| 28 | + | {"clear", nil}, | |
| 29 | + | {"set_model", modelJSON}, | |
| 30 | + | {"set_reasoning", []byte(`{"reasoning":"max"}`)}, | |
| 31 | + | } { | |
| 32 | + | t.Run(tc.command, func(t *testing.T) { | |
| 33 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 34 | + | defer cancel() | |
| 35 | + | client := &startCaptureClient{} | |
| 36 | + | ag := core.NewAgent(client, models[0].ID, "base", nil) | |
| 37 | + | entered := make(chan struct{}) | |
| 38 | + | ag.BeforeStart = func(ctx context.Context, system string) string { | |
| 39 | + | close(entered) | |
| 40 | + | <-ctx.Done() | |
| 41 | + | return "stale replacement" | |
| 42 | + | } | |
| 43 | + | var out bytes.Buffer | |
| 44 | + | s := &rpcServer{ctx: ctx, agent: ag, provider: "anthropic", model: ag.Model, out: &out} | |
| 45 | + | t.Cleanup(func() { cancel(); s.inFlight.Wait() }) | |
| 46 | + | s.dispatch("prompt", "prompt-1", []byte(`{"message":"hello"}`)) | |
| 47 | + | select { | |
| 48 | + | case <-entered: | |
| 49 | + | case <-ctx.Done(): | |
| 50 | + | t.Fatal("preparation did not start") | |
| 51 | + | } | |
| 52 | + | dispatched := make(chan struct{}) | |
| 53 | + | go func() { defer close(dispatched); s.dispatch(tc.command, "mutation", tc.raw) }() | |
| 54 | + | select { | |
| 55 | + | case <-dispatched: | |
| 56 | + | case <-ctx.Done(): | |
| 57 | + | t.Fatal("mutation blocked the RPC read loop") | |
| 58 | + | } | |
| 59 | + | s.writeMu.Lock() | |
| 60 | + | responses := append([]byte(nil), out.Bytes()...) | |
| 61 | + | s.writeMu.Unlock() | |
| 62 | + | var rejected bool | |
| 63 | + | for _, line := range bytes.Split(responses, []byte("\n")) { | |
| 64 | + | if len(line) == 0 { | |
| 65 | + | continue | |
| 66 | + | } | |
| 67 | + | var response struct { | |
| 68 | + | ID string | |
| 69 | + | Success bool | |
| 70 | + | Error string | |
| 71 | + | } | |
| 72 | + | if err := json.Unmarshal(line, &response); err != nil { | |
| 73 | + | t.Fatal(err) | |
| 74 | + | } | |
| 75 | + | if response.ID == "mutation" { | |
| 76 | + | rejected = !response.Success && strings.Contains(response.Error, "busy") | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | // Abort must remain usable while the hook waits. Wait before inspecting | |
| 80 | + | // agent fields so the test itself does not race with prompt preparation. | |
| 81 | + | s.dispatch("abort", "abort-1", nil) | |
| 82 | + | s.inFlight.Wait() | |
| 83 | + | if !rejected { | |
| 84 | + | t.Errorf("mutation should return a busy error: %s", responses) | |
| 85 | + | } | |
| 86 | + | if ag.Model != models[0].ID || ag.Reasoning != "" || len(ag.Messages()) != 1 { | |
| 87 | + | t.Fatal("busy mutation changed active agent state") | |
| 88 | + | } | |
| 89 | + | if ag.System != "base" || len(client.requests) != 0 { | |
| 90 | + | t.Fatal("canceled hook result reached model") | |
| 91 | + | } | |
| 92 | + | // The same command succeeds when idle; the next preparation uses current | |
| 93 | + | // state rather than marking the canceled extension response as prepared. | |
| 94 | + | s.dispatch(tc.command, "idle-mutation", tc.raw) | |
| 95 | + | switch tc.command { | |
| 96 | + | case "clear": | |
| 97 | + | if len(ag.Messages()) != 0 { | |
| 98 | + | t.Fatal("idle clear failed") | |
| 99 | + | } | |
| 100 | + | case "set_model": | |
| 101 | + | if ag.Model != models[1].ID { | |
| 102 | + | t.Fatal("idle model change failed") | |
| 103 | + | } | |
| 104 | + | case "set_reasoning": | |
| 105 | + | if ag.Reasoning != "max" { | |
| 106 | + | t.Fatal("idle reasoning change failed") | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ag.BeforeStart = func(_ context.Context, base string) string { | |
| 110 | + | if base != "base" { | |
| 111 | + | t.Error("stale prompt became base") | |
| 112 | + | } | |
| 113 | + | return "fresh replacement" | |
| 114 | + | } | |
| 115 | + | s.runPrompt("prompt-2", "again", nil) | |
| 116 | + | if len(client.requests) != 1 || client.requests[0].System != "fresh replacement" || client.requests[0].Model != ag.Model { | |
| 117 | + | t.Fatal("next prompt did not use freshly prepared state") | |
| 118 | + | } | |
| 119 | + | }) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestRPCRejectsMutationsWhileTurnLockHeld(t *testing.T) { | |
| 124 | + | // The same lock covers active model/tool work and compaction, not just | |
| 125 | + | // preparation. Rejection must not depend on activeCancel being populated. | |
| 126 | + | var out bytes.Buffer | |
| 127 | + | s := &rpcServer{agent: core.NewAgent(nil, "model", "base", nil), out: &out} | |
| 128 | + | s.turnMu.Lock() | |
| 129 | + | defer s.turnMu.Unlock() | |
| 130 | + | s.dispatch("clear", "1", nil) | |
| 131 | + | if !strings.Contains(out.String(), `"success":false`) || !strings.Contains(out.String(), "busy") { | |
| 132 | + | t.Fatalf("missing busy response: %s", out.String()) | |
| 133 | + | } | |
| 134 | + | } | |
| @@ -0,0 +1,134 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 12 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestRPCRejectsMutationsDuringPromptPreparation(t *testing.T) { | |||
| 16 | + | models := provider.ModelsForProvider("anthropic") | |||
| 17 | + | if len(models) < 2 { | |||
| 18 | + | t.Fatal("need two catalog models") | |||
| 19 | + | } | |||
| 20 | + | modelJSON, err := json.Marshal(map[string]string{"model": models[1].ID}) | |||
| 21 | + | if err != nil { | |||
| 22 | + | t.Fatal(err) | |||
| 23 | + | } | |||
| 24 | + | for _, tc := range []struct { | |||
| 25 | + | command string | |||
| 26 | + | raw []byte | |||
| 27 | + | }{ | |||
| 28 | + | {"clear", nil}, | |||
| 29 | + | {"set_model", modelJSON}, | |||
| 30 | + | {"set_reasoning", []byte(`{"reasoning":"max"}`)}, | |||
| 31 | + | } { | |||
| 32 | + | t.Run(tc.command, func(t *testing.T) { | |||
| 33 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 34 | + | defer cancel() | |||
| 35 | + | client := &startCaptureClient{} | |||
| 36 | + | ag := core.NewAgent(client, models[0].ID, "base", nil) | |||
| 37 | + | entered := make(chan struct{}) | |||
| 38 | + | ag.BeforeStart = func(ctx context.Context, system string) string { | |||
| 39 | + | close(entered) | |||
| 40 | + | <-ctx.Done() | |||
| 41 | + | return "stale replacement" | |||
| 42 | + | } | |||
| 43 | + | var out bytes.Buffer | |||
| 44 | + | s := &rpcServer{ctx: ctx, agent: ag, provider: "anthropic", model: ag.Model, out: &out} | |||
| 45 | + | t.Cleanup(func() { cancel(); s.inFlight.Wait() }) | |||
| 46 | + | s.dispatch("prompt", "prompt-1", []byte(`{"message":"hello"}`)) | |||
| 47 | + | select { | |||
| 48 | + | case <-entered: | |||
| 49 | + | case <-ctx.Done(): | |||
| 50 | + | t.Fatal("preparation did not start") | |||
| 51 | + | } | |||
| 52 | + | dispatched := make(chan struct{}) | |||
| 53 | + | go func() { defer close(dispatched); s.dispatch(tc.command, "mutation", tc.raw) }() | |||
| 54 | + | select { | |||
| 55 | + | case <-dispatched: | |||
| 56 | + | case <-ctx.Done(): | |||
| 57 | + | t.Fatal("mutation blocked the RPC read loop") | |||
| 58 | + | } | |||
| 59 | + | s.writeMu.Lock() | |||
| 60 | + | responses := append([]byte(nil), out.Bytes()...) | |||
| 61 | + | s.writeMu.Unlock() | |||
| 62 | + | var rejected bool | |||
| 63 | + | for _, line := range bytes.Split(responses, []byte("\n")) { | |||
| 64 | + | if len(line) == 0 { | |||
| 65 | + | continue | |||
| 66 | + | } | |||
| 67 | + | var response struct { | |||
| 68 | + | ID string | |||
| 69 | + | Success bool | |||
| 70 | + | Error string | |||
| 71 | + | } | |||
| 72 | + | if err := json.Unmarshal(line, &response); err != nil { | |||
| 73 | + | t.Fatal(err) | |||
| 74 | + | } | |||
| 75 | + | if response.ID == "mutation" { | |||
| 76 | + | rejected = !response.Success && strings.Contains(response.Error, "busy") | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | // Abort must remain usable while the hook waits. Wait before inspecting | |||
| 80 | + | // agent fields so the test itself does not race with prompt preparation. | |||
| 81 | + | s.dispatch("abort", "abort-1", nil) | |||
| 82 | + | s.inFlight.Wait() | |||
| 83 | + | if !rejected { | |||
| 84 | + | t.Errorf("mutation should return a busy error: %s", responses) | |||
| 85 | + | } | |||
| 86 | + | if ag.Model != models[0].ID || ag.Reasoning != "" || len(ag.Messages()) != 1 { | |||
| 87 | + | t.Fatal("busy mutation changed active agent state") | |||
| 88 | + | } | |||
| 89 | + | if ag.System != "base" || len(client.requests) != 0 { | |||
| 90 | + | t.Fatal("canceled hook result reached model") | |||
| 91 | + | } | |||
| 92 | + | // The same command succeeds when idle; the next preparation uses current | |||
| 93 | + | // state rather than marking the canceled extension response as prepared. | |||
| 94 | + | s.dispatch(tc.command, "idle-mutation", tc.raw) | |||
| 95 | + | switch tc.command { | |||
| 96 | + | case "clear": | |||
| 97 | + | if len(ag.Messages()) != 0 { | |||
| 98 | + | t.Fatal("idle clear failed") | |||
| 99 | + | } | |||
| 100 | + | case "set_model": | |||
| 101 | + | if ag.Model != models[1].ID { | |||
| 102 | + | t.Fatal("idle model change failed") | |||
| 103 | + | } | |||
| 104 | + | case "set_reasoning": | |||
| 105 | + | if ag.Reasoning != "max" { | |||
| 106 | + | t.Fatal("idle reasoning change failed") | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ag.BeforeStart = func(_ context.Context, base string) string { | |||
| 110 | + | if base != "base" { | |||
| 111 | + | t.Error("stale prompt became base") | |||
| 112 | + | } | |||
| 113 | + | return "fresh replacement" | |||
| 114 | + | } | |||
| 115 | + | s.runPrompt("prompt-2", "again", nil) | |||
| 116 | + | if len(client.requests) != 1 || client.requests[0].System != "fresh replacement" || client.requests[0].Model != ag.Model { | |||
| 117 | + | t.Fatal("next prompt did not use freshly prepared state") | |||
| 118 | + | } | |||
| 119 | + | }) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestRPCRejectsMutationsWhileTurnLockHeld(t *testing.T) { | |||
| 124 | + | // The same lock covers active model/tool work and compaction, not just | |||
| 125 | + | // preparation. Rejection must not depend on activeCancel being populated. | |||
| 126 | + | var out bytes.Buffer | |||
| 127 | + | s := &rpcServer{agent: core.NewAgent(nil, "model", "base", nil), out: &out} | |||
| 128 | + | s.turnMu.Lock() | |||
| 129 | + | defer s.turnMu.Unlock() | |||
| 130 | + | s.dispatch("clear", "1", nil) | |||
| 131 | + | if !strings.Contains(out.String(), `"success":false`) || !strings.Contains(out.String(), "busy") { | |||
| 132 | + | t.Fatalf("missing busy response: %s", out.String()) | |||
| 133 | + | } | |||
| 134 | + | } | |||
M packages/core/agent.go +54 -18
| @@ -48,6 +48,11 @@ type Agent struct { | |||
| 48 | 48 | // malformed modifiedArgs is ignored. | |
| 49 | 49 | BeforeToolExecute func(call provider.ToolCallBlock) (allowed bool, reason string, modifiedArgs json.RawMessage) | |
| 50 | 50 | ||
| 51 | + | // BeforeStart replaces the complete system prompt once per runtime session | |
| 52 | + | // or explicit prompt/model reset, before any turn starts. It is not called | |
| 53 | + | // for ordinary follow-up messages or tool-loop steps. | |
| 54 | + | BeforeStart func(context.Context, string) string | |
| 55 | + | ||
| 51 | 56 | // BeforeTurn, if set, is called before each turn's model call. | |
| 52 | 57 | // Returning (allowed=false, reason) aborts the turn; reason is | |
| 53 | 58 | // surfaced as an assistant-like status line. Used for rate- | |
| @@ -98,6 +103,12 @@ type Agent struct { | |||
| 98 | 103 | // wholesale transcript replacement. | |
| 99 | 104 | OnTranscriptCompacted func(messages []provider.Message) | |
| 100 | 105 | ||
| 106 | + | // Preparation caches the effective prompt separately from its unmodified | |
| 107 | + | // base so a model or session reset cannot stack extension appendices. | |
| 108 | + | startPrepared bool | |
| 109 | + | startGeneration uint64 | |
| 110 | + | startBase, startSystem, startModel, startSession string | |
| 111 | + | ||
| 101 | 112 | mu sync.Mutex | |
| 102 | 113 | messages []provider.Message | |
| 103 | 114 | // rev increments whenever the transcript slice is replaced or a | |
| @@ -254,6 +265,9 @@ func (a *Agent) SetMessages(msgs []provider.Message) { | |||
| 254 | 265 | a.mu.Lock() | |
| 255 | 266 | defer a.mu.Unlock() | |
| 256 | 267 | a.messages = append(a.messages[:0], msgs...) | |
| 268 | + | if len(msgs) == 0 { | |
| 269 | + | a.resetStartLocked() | |
| 270 | + | } | |
| 257 | 271 | a.rev++ | |
| 258 | 272 | } | |
| 259 | 273 | ||
| @@ -371,6 +385,12 @@ func (a *Agent) wrapSink(sink func(AgentEvent)) func(AgentEvent) { | |||
| 371 | 385 | ||
| 372 | 386 | func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error { | |
| 373 | 387 | for step := 1; a.MaxSteps <= 0 || step <= a.MaxSteps; step++ { | |
| 388 | + | // Preparation is cached across steps and user messages. Only an | |
| 389 | + | // explicit prompt/model/session change invokes BeforeStart again. | |
| 390 | + | if err := a.prepareStart(ctx); err != nil { | |
| 391 | + | sink(EvDone{}) | |
| 392 | + | return err | |
| 393 | + | } | |
| 374 | 394 | // Messages queued while the agent was busy are delivered | |
| 375 | 395 | // before the next model call. This is the safe boundary: | |
| 376 | 396 | // any previous tool batch has already completed and its | |
| @@ -563,24 +583,40 @@ func (a *Agent) dropLastAssistantMessage() { | |||
| 563 | 583 | // oneTurn calls the LLM once, forwards events, returns the stop reason | |
| 564 | 584 | // and the assembled assistant message (already appended to the transcript). | |
| 565 | 585 | func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.StopReason, provider.Message, error) { | |
| 566 | - | req := provider.Request{ | |
| 567 | - | Model: a.Model, | |
| 568 | - | System: a.System, | |
| 569 | - | // Repair any dangling tool_use blocks before sending. A turn | |
| 570 | - | // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a | |
| 571 | - | // dev server, etc.) can leave an assistant tool_use with no | |
| 572 | - | // matching tool_result in the live transcript. The load-time | |
| 573 | - | // repair in OpenSession only runs on restart, so without this the | |
| 574 | - | // next in-process request is rejected by providers like Anthropic | |
| 575 | - | // with "tool_use ids were found without tool_result blocks". The | |
| 576 | - | // repair is pure and a no-op on already-valid transcripts. | |
| 577 | - | Messages: repairToolUseResultPairs(a.Messages()), | |
| 578 | - | Tools: a.Tools.Specs(), | |
| 579 | - | Reasoning: a.Reasoning, | |
| 580 | - | MaxTokens: a.MaxTokens, | |
| 581 | - | Temperature: a.Temperature, | |
| 582 | - | SessionID: a.SessionID, | |
| 583 | - | MaxToolCalls: a.MaxToolCalls, | |
| 586 | + | var req provider.Request | |
| 587 | + | for { | |
| 588 | + | if err := a.prepareStart(ctx); err != nil { | |
| 589 | + | return provider.StopError, provider.Message{}, err | |
| 590 | + | } | |
| 591 | + | a.mu.Lock() | |
| 592 | + | // A reset can also arrive after runLoop's preparation, for example | |
| 593 | + | // while BeforeTurn waits. Validate and snapshot under the same lock | |
| 594 | + | // so this request never receives an unprepared replacement prompt. | |
| 595 | + | if a.BeforeStart != nil && !a.startCurrentLocked() { | |
| 596 | + | a.mu.Unlock() | |
| 597 | + | continue | |
| 598 | + | } | |
| 599 | + | req = provider.Request{ | |
| 600 | + | Model: a.Model, | |
| 601 | + | System: a.System, | |
| 602 | + | // Repair any dangling tool_use blocks before sending. A turn | |
| 603 | + | // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a | |
| 604 | + | // dev server, etc.) can leave an assistant tool_use with no | |
| 605 | + | // matching tool_result in the live transcript. The load-time | |
| 606 | + | // repair in OpenSession only runs on restart, so without this the | |
| 607 | + | // next in-process request is rejected by providers like Anthropic | |
| 608 | + | // with "tool_use ids were found without tool_result blocks". The | |
| 609 | + | // repair is pure and a no-op on already-valid transcripts. | |
| 610 | + | Messages: repairToolUseResultPairs(append([]provider.Message(nil), a.messages...)), | |
| 611 | + | Tools: a.Tools.Specs(), | |
| 612 | + | Reasoning: a.Reasoning, | |
| 613 | + | MaxTokens: a.MaxTokens, | |
| 614 | + | Temperature: a.Temperature, | |
| 615 | + | SessionID: a.SessionID, | |
| 616 | + | MaxToolCalls: a.MaxToolCalls, | |
| 617 | + | } | |
| 618 | + | a.mu.Unlock() | |
| 619 | + | break | |
| 584 | 620 | } | |
| 585 | 621 | stream, err := a.Client.Stream(ctx, req) | |
| 586 | 622 | if err != nil { | |
| @@ -48,6 +48,11 @@ type Agent struct { | |||||
| 48 | // malformed modifiedArgs is ignored. | 48 | // malformed modifiedArgs is ignored. | ||
| 49 | BeforeToolExecute func(call provider.ToolCallBlock) (allowed bool, reason string, modifiedArgs json.RawMessage) | 49 | BeforeToolExecute func(call provider.ToolCallBlock) (allowed bool, reason string, modifiedArgs json.RawMessage) | ||
| 50 | 50 | ||||
| 51 | + | // BeforeStart replaces the complete system prompt once per runtime session | |||
| 52 | + | // or explicit prompt/model reset, before any turn starts. It is not called | |||
| 53 | + | // for ordinary follow-up messages or tool-loop steps. | |||
| 54 | + | BeforeStart func(context.Context, string) string | |||
| 55 | + | ||||
| 51 | // BeforeTurn, if set, is called before each turn's model call. | 56 | // BeforeTurn, if set, is called before each turn's model call. | ||
| 52 | // Returning (allowed=false, reason) aborts the turn; reason is | 57 | // Returning (allowed=false, reason) aborts the turn; reason is | ||
| 53 | // surfaced as an assistant-like status line. Used for rate- | 58 | // surfaced as an assistant-like status line. Used for rate- | ||
| @@ -98,6 +103,12 @@ type Agent struct { | |||||
| 98 | // wholesale transcript replacement. | 103 | // wholesale transcript replacement. | ||
| 99 | OnTranscriptCompacted func(messages []provider.Message) | 104 | OnTranscriptCompacted func(messages []provider.Message) | ||
| 100 | 105 | ||||
| 106 | + | // Preparation caches the effective prompt separately from its unmodified | |||
| 107 | + | // base so a model or session reset cannot stack extension appendices. | |||
| 108 | + | startPrepared bool | |||
| 109 | + | startGeneration uint64 | |||
| 110 | + | startBase, startSystem, startModel, startSession string | |||
| 111 | + | ||||
| 101 | mu sync.Mutex | 112 | mu sync.Mutex | ||
| 102 | messages []provider.Message | 113 | messages []provider.Message | ||
| 103 | // rev increments whenever the transcript slice is replaced or a | 114 | // rev increments whenever the transcript slice is replaced or a | ||
| @@ -254,6 +265,9 @@ func (a *Agent) SetMessages(msgs []provider.Message) { | |||||
| 254 | a.mu.Lock() | 265 | a.mu.Lock() | ||
| 255 | defer a.mu.Unlock() | 266 | defer a.mu.Unlock() | ||
| 256 | a.messages = append(a.messages[:0], msgs...) | 267 | a.messages = append(a.messages[:0], msgs...) | ||
| 268 | + | if len(msgs) == 0 { | |||
| 269 | + | a.resetStartLocked() | |||
| 270 | + | } | |||
| 257 | a.rev++ | 271 | a.rev++ | ||
| 258 | } | 272 | } | ||
| 259 | 273 | ||||
| @@ -371,6 +385,12 @@ func (a *Agent) wrapSink(sink func(AgentEvent)) func(AgentEvent) { | |||||
| 371 | 385 | ||||
| 372 | func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error { | 386 | func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error { | ||
| 373 | for step := 1; a.MaxSteps <= 0 || step <= a.MaxSteps; step++ { | 387 | for step := 1; a.MaxSteps <= 0 || step <= a.MaxSteps; step++ { | ||
| 388 | + | // Preparation is cached across steps and user messages. Only an | |||
| 389 | + | // explicit prompt/model/session change invokes BeforeStart again. | |||
| 390 | + | if err := a.prepareStart(ctx); err != nil { | |||
| 391 | + | sink(EvDone{}) | |||
| 392 | + | return err | |||
| 393 | + | } | |||
| 374 | // Messages queued while the agent was busy are delivered | 394 | // Messages queued while the agent was busy are delivered | ||
| 375 | // before the next model call. This is the safe boundary: | 395 | // before the next model call. This is the safe boundary: | ||
| 376 | // any previous tool batch has already completed and its | 396 | // any previous tool batch has already completed and its | ||
| @@ -563,24 +583,40 @@ func (a *Agent) dropLastAssistantMessage() { | |||||
| 563 | // oneTurn calls the LLM once, forwards events, returns the stop reason | 583 | // oneTurn calls the LLM once, forwards events, returns the stop reason | ||
| 564 | // and the assembled assistant message (already appended to the transcript). | 584 | // and the assembled assistant message (already appended to the transcript). | ||
| 565 | func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.StopReason, provider.Message, error) { | 585 | func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.StopReason, provider.Message, error) { | ||
| 566 | - | req := provider.Request{ | 586 | + | var req provider.Request |
| 567 | - | Model: a.Model, | 587 | + | for { |
| 568 | - | System: a.System, | 588 | + | if err := a.prepareStart(ctx); err != nil { |
| 569 | - | // Repair any dangling tool_use blocks before sending. A turn | 589 | + | return provider.StopError, provider.Message{}, err |
| 570 | - | // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a | 590 | + | } |
| 571 | - | // dev server, etc.) can leave an assistant tool_use with no | 591 | + | a.mu.Lock() |
| 572 | - | // matching tool_result in the live transcript. The load-time | 592 | + | // A reset can also arrive after runLoop's preparation, for example |
| 573 | - | // repair in OpenSession only runs on restart, so without this the | 593 | + | // while BeforeTurn waits. Validate and snapshot under the same lock |
| 574 | - | // next in-process request is rejected by providers like Anthropic | 594 | + | // so this request never receives an unprepared replacement prompt. |
| 575 | - | // with "tool_use ids were found without tool_result blocks". The | 595 | + | if a.BeforeStart != nil && !a.startCurrentLocked() { |
| 576 | - | // repair is pure and a no-op on already-valid transcripts. | 596 | + | a.mu.Unlock() |
| 577 | - | Messages: repairToolUseResultPairs(a.Messages()), | 597 | + | continue |
| 578 | - | Tools: a.Tools.Specs(), | 598 | + | } |
| 579 | - | Reasoning: a.Reasoning, | 599 | + | req = provider.Request{ |
| 580 | - | MaxTokens: a.MaxTokens, | 600 | + | Model: a.Model, |
| 581 | - | Temperature: a.Temperature, | 601 | + | System: a.System, |
| 582 | - | SessionID: a.SessionID, | 602 | + | // Repair any dangling tool_use blocks before sending. A turn |
| 583 | - | MaxToolCalls: a.MaxToolCalls, | 603 | + | // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a |
| 604 | + | // dev server, etc.) can leave an assistant tool_use with no | |||
| 605 | + | // matching tool_result in the live transcript. The load-time | |||
| 606 | + | // repair in OpenSession only runs on restart, so without this the | |||
| 607 | + | // next in-process request is rejected by providers like Anthropic | |||
| 608 | + | // with "tool_use ids were found without tool_result blocks". The | |||
| 609 | + | // repair is pure and a no-op on already-valid transcripts. | |||
| 610 | + | Messages: repairToolUseResultPairs(append([]provider.Message(nil), a.messages...)), | |||
| 611 | + | Tools: a.Tools.Specs(), | |||
| 612 | + | Reasoning: a.Reasoning, | |||
| 613 | + | MaxTokens: a.MaxTokens, | |||
| 614 | + | Temperature: a.Temperature, | |||
| 615 | + | SessionID: a.SessionID, | |||
| 616 | + | MaxToolCalls: a.MaxToolCalls, | |||
| 617 | + | } | |||
| 618 | + | a.mu.Unlock() | |||
| 619 | + | break | |||
| 584 | } | 620 | } | ||
| 585 | stream, err := a.Client.Stream(ctx, req) | 621 | stream, err := a.Client.Stream(ctx, req) | ||
| 586 | if err != nil { | 622 | if err != nil { | ||
A packages/core/agent_start.go +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import "context" | |
| 4 | + | ||
| 5 | + | // BaseSystem returns the prompt before BeforeStart replacements. Hosts editing | |
| 6 | + | // instructions should rebuild from this value, then call SetSystem. | |
| 7 | + | func (a *Agent) BaseSystem() string { | |
| 8 | + | a.mu.Lock() | |
| 9 | + | defer a.mu.Unlock() | |
| 10 | + | return a.baseSystemLocked() | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | func (a *Agent) baseSystemLocked() string { | |
| 14 | + | if a.startPrepared && a.System == a.startSystem { | |
| 15 | + | return a.startBase | |
| 16 | + | } | |
| 17 | + | return a.System | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // SetSystem replaces the unmodified prompt and invalidates its prepared value, | |
| 21 | + | // even when the replacement is identical. A pending BeforeStart result is | |
| 22 | + | // discarded and preparation repeats with the new base prompt. | |
| 23 | + | func (a *Agent) SetSystem(system string) { | |
| 24 | + | a.mu.Lock() | |
| 25 | + | defer a.mu.Unlock() | |
| 26 | + | a.startGeneration++ | |
| 27 | + | a.startPrepared = false | |
| 28 | + | a.System = system | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (a *Agent) resetStartLocked() { | |
| 32 | + | if a.startPrepared && a.System == a.startSystem { | |
| 33 | + | a.System = a.startBase | |
| 34 | + | } | |
| 35 | + | a.startGeneration++ | |
| 36 | + | a.startPrepared = false | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // startCurrentLocked reports whether the effective prompt matches the current | |
| 40 | + | // runtime. Callers hold mu when checking it and when consuming that prompt. | |
| 41 | + | func (a *Agent) startCurrentLocked() bool { | |
| 42 | + | return a.startPrepared && a.System == a.startSystem && a.Model == a.startModel && a.SessionID == a.startSession | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (a *Agent) prepareStart(ctx context.Context) error { | |
| 46 | + | for { | |
| 47 | + | if err := ctx.Err(); err != nil { | |
| 48 | + | return err | |
| 49 | + | } | |
| 50 | + | if a.BeforeStart == nil { | |
| 51 | + | return nil | |
| 52 | + | } | |
| 53 | + | a.mu.Lock() | |
| 54 | + | if a.startCurrentLocked() { | |
| 55 | + | a.mu.Unlock() | |
| 56 | + | return nil | |
| 57 | + | } | |
| 58 | + | base := a.baseSystemLocked() | |
| 59 | + | generation := a.startGeneration | |
| 60 | + | original, model, session := a.System, a.Model, a.SessionID | |
| 61 | + | a.mu.Unlock() | |
| 62 | + | ||
| 63 | + | // Do not hold the agent lock while invoking an external hook: prompt | |
| 64 | + | // rebuilds, context inspection, and cancellation must remain responsive. | |
| 65 | + | system := a.BeforeStart(ctx, base) | |
| 66 | + | if err := ctx.Err(); err != nil { | |
| 67 | + | return err | |
| 68 | + | } | |
| 69 | + | a.mu.Lock() | |
| 70 | + | if generation != a.startGeneration || original != a.System || model != a.Model || session != a.SessionID { | |
| 71 | + | // A reset can retain exactly the same prompt string. Comparing the | |
| 72 | + | // generation, not just its contents, prevents stale results from | |
| 73 | + | // becoming current after that reset (including an A-B-A rebuild). | |
| 74 | + | a.mu.Unlock() | |
| 75 | + | continue | |
| 76 | + | } | |
| 77 | + | a.System = system | |
| 78 | + | a.startBase, a.startSystem = base, system | |
| 79 | + | a.startModel, a.startSession = model, session | |
| 80 | + | a.startPrepared = true | |
| 81 | + | a.mu.Unlock() | |
| 82 | + | return nil | |
| 83 | + | } | |
| 84 | + | } | |
| @@ -0,0 +1,84 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import "context" | |||
| 4 | + | ||||
| 5 | + | // BaseSystem returns the prompt before BeforeStart replacements. Hosts editing | |||
| 6 | + | // instructions should rebuild from this value, then call SetSystem. | |||
| 7 | + | func (a *Agent) BaseSystem() string { | |||
| 8 | + | a.mu.Lock() | |||
| 9 | + | defer a.mu.Unlock() | |||
| 10 | + | return a.baseSystemLocked() | |||
| 11 | + | } | |||
| 12 | + | ||||
| 13 | + | func (a *Agent) baseSystemLocked() string { | |||
| 14 | + | if a.startPrepared && a.System == a.startSystem { | |||
| 15 | + | return a.startBase | |||
| 16 | + | } | |||
| 17 | + | return a.System | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // SetSystem replaces the unmodified prompt and invalidates its prepared value, | |||
| 21 | + | // even when the replacement is identical. A pending BeforeStart result is | |||
| 22 | + | // discarded and preparation repeats with the new base prompt. | |||
| 23 | + | func (a *Agent) SetSystem(system string) { | |||
| 24 | + | a.mu.Lock() | |||
| 25 | + | defer a.mu.Unlock() | |||
| 26 | + | a.startGeneration++ | |||
| 27 | + | a.startPrepared = false | |||
| 28 | + | a.System = system | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func (a *Agent) resetStartLocked() { | |||
| 32 | + | if a.startPrepared && a.System == a.startSystem { | |||
| 33 | + | a.System = a.startBase | |||
| 34 | + | } | |||
| 35 | + | a.startGeneration++ | |||
| 36 | + | a.startPrepared = false | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // startCurrentLocked reports whether the effective prompt matches the current | |||
| 40 | + | // runtime. Callers hold mu when checking it and when consuming that prompt. | |||
| 41 | + | func (a *Agent) startCurrentLocked() bool { | |||
| 42 | + | return a.startPrepared && a.System == a.startSystem && a.Model == a.startModel && a.SessionID == a.startSession | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func (a *Agent) prepareStart(ctx context.Context) error { | |||
| 46 | + | for { | |||
| 47 | + | if err := ctx.Err(); err != nil { | |||
| 48 | + | return err | |||
| 49 | + | } | |||
| 50 | + | if a.BeforeStart == nil { | |||
| 51 | + | return nil | |||
| 52 | + | } | |||
| 53 | + | a.mu.Lock() | |||
| 54 | + | if a.startCurrentLocked() { | |||
| 55 | + | a.mu.Unlock() | |||
| 56 | + | return nil | |||
| 57 | + | } | |||
| 58 | + | base := a.baseSystemLocked() | |||
| 59 | + | generation := a.startGeneration | |||
| 60 | + | original, model, session := a.System, a.Model, a.SessionID | |||
| 61 | + | a.mu.Unlock() | |||
| 62 | + | ||||
| 63 | + | // Do not hold the agent lock while invoking an external hook: prompt | |||
| 64 | + | // rebuilds, context inspection, and cancellation must remain responsive. | |||
| 65 | + | system := a.BeforeStart(ctx, base) | |||
| 66 | + | if err := ctx.Err(); err != nil { | |||
| 67 | + | return err | |||
| 68 | + | } | |||
| 69 | + | a.mu.Lock() | |||
| 70 | + | if generation != a.startGeneration || original != a.System || model != a.Model || session != a.SessionID { | |||
| 71 | + | // A reset can retain exactly the same prompt string. Comparing the | |||
| 72 | + | // generation, not just its contents, prevents stale results from | |||
| 73 | + | // becoming current after that reset (including an A-B-A rebuild). | |||
| 74 | + | a.mu.Unlock() | |||
| 75 | + | continue | |||
| 76 | + | } | |||
| 77 | + | a.System = system | |||
| 78 | + | a.startBase, a.startSystem = base, system | |||
| 79 | + | a.startModel, a.startSession = model, session | |||
| 80 | + | a.startPrepared = true | |||
| 81 | + | a.mu.Unlock() | |||
| 82 | + | return nil | |||
| 83 | + | } | |||
| 84 | + | } | |||
A packages/core/agent_start_generation_test.go +131 -0
| @@ -0,0 +1,131 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestBeforeStartDiscardsSupersededPreparation(t *testing.T) { | |
| 11 | + | for _, tc := range []struct { | |
| 12 | + | name string | |
| 13 | + | reset func(*Agent) | |
| 14 | + | wantBase string | |
| 15 | + | }{ | |
| 16 | + | {"new prompt", func(a *Agent) { a.SetSystem("new base") }, "new base"}, | |
| 17 | + | {"same prompt reset", func(a *Agent) { a.SetSystem("base") }, "base"}, | |
| 18 | + | {"ABA prompt reset", func(a *Agent) { a.SetSystem("temporary"); a.SetSystem("base") }, "base"}, | |
| 19 | + | {"multiple rebuilds", func(a *Agent) { a.SetSystem("intermediate"); a.SetSystem("latest") }, "latest"}, | |
| 20 | + | {"clear", func(a *Agent) { a.SetMessages(nil) }, "base"}, | |
| 21 | + | {"model change", func(a *Agent) { a.Model = "new model" }, "base"}, | |
| 22 | + | {"session change", func(a *Agent) { a.SessionID = "new session" }, "base"}, | |
| 23 | + | } { | |
| 24 | + | t.Run(tc.name, func(t *testing.T) { | |
| 25 | + | a := NewAgent(nil, "model", "base", nil) | |
| 26 | + | entered, release := make(chan struct{}), make(chan struct{}) | |
| 27 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 28 | + | defer cancel() | |
| 29 | + | var inputs []string | |
| 30 | + | a.BeforeStart = func(ctx context.Context, system string) string { | |
| 31 | + | inputs = append(inputs, system) | |
| 32 | + | if len(inputs) == 1 { | |
| 33 | + | close(entered) | |
| 34 | + | select { | |
| 35 | + | case <-release: | |
| 36 | + | case <-ctx.Done(): | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | return fmt.Sprintf("%s/replacement-%d", system, len(inputs)) | |
| 40 | + | } | |
| 41 | + | a.BeforeTurn = func(int) (bool, string) { return false, "test" } | |
| 42 | + | done := make(chan struct{}) | |
| 43 | + | var runErr error | |
| 44 | + | go func() { defer close(done); runErr = a.Continue(ctx, nil) }() | |
| 45 | + | t.Cleanup(func() { cancel(); <-done }) | |
| 46 | + | select { | |
| 47 | + | case <-entered: | |
| 48 | + | case <-ctx.Done(): | |
| 49 | + | t.Fatal("hook did not start") | |
| 50 | + | } | |
| 51 | + | // The hook is paused without the agent lock. Reset must remain responsive, | |
| 52 | + | // including when the base string has not changed at all. | |
| 53 | + | tc.reset(a) | |
| 54 | + | close(release) | |
| 55 | + | select { | |
| 56 | + | case <-done: | |
| 57 | + | if runErr != nil { | |
| 58 | + | t.Fatal(runErr) | |
| 59 | + | } | |
| 60 | + | case <-ctx.Done(): | |
| 61 | + | t.Fatal("preparation did not finish") | |
| 62 | + | } | |
| 63 | + | if len(inputs) != 2 || inputs[1] != tc.wantBase { | |
| 64 | + | t.Fatalf("hook inputs=%q, want [base %q]", inputs, tc.wantBase) | |
| 65 | + | } | |
| 66 | + | want := tc.wantBase + "/replacement-2" | |
| 67 | + | if a.System != want || a.BaseSystem() != tc.wantBase { | |
| 68 | + | t.Fatalf("system=%q base=%q", a.System, a.BaseSystem()) | |
| 69 | + | } | |
| 70 | + | if a.startModel != a.Model || a.startSession != a.SessionID { | |
| 71 | + | t.Fatal("prepared metadata does not match current runtime") | |
| 72 | + | } | |
| 73 | + | // The fresh result is cached; normal continuation must not run it again. | |
| 74 | + | if err := a.Continue(ctx, nil); err != nil { | |
| 75 | + | t.Fatal(err) | |
| 76 | + | } | |
| 77 | + | if len(inputs) != 2 { | |
| 78 | + | t.Fatal("fresh preparation was not cached") | |
| 79 | + | } | |
| 80 | + | }) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestBeforeStartCancellationDuringRepreparation(t *testing.T) { | |
| 85 | + | a := NewAgent(nil, "model", "base", nil) | |
| 86 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 87 | + | defer cancel() | |
| 88 | + | first, second, release := make(chan struct{}), make(chan struct{}), make(chan struct{}) | |
| 89 | + | calls := 0 | |
| 90 | + | a.BeforeStart = func(ctx context.Context, system string) string { | |
| 91 | + | calls++ | |
| 92 | + | if calls == 1 { | |
| 93 | + | close(first) | |
| 94 | + | select { | |
| 95 | + | case <-release: | |
| 96 | + | case <-ctx.Done(): | |
| 97 | + | } | |
| 98 | + | } else { | |
| 99 | + | close(second) | |
| 100 | + | <-ctx.Done() | |
| 101 | + | } | |
| 102 | + | return "discard this replacement" | |
| 103 | + | } | |
| 104 | + | a.BeforeTurn = func(int) (bool, string) { return false, "test" } | |
| 105 | + | done := make(chan struct{}) | |
| 106 | + | var runErr error | |
| 107 | + | go func() { defer close(done); runErr = a.Continue(ctx, nil) }() | |
| 108 | + | t.Cleanup(func() { cancel(); <-done }) | |
| 109 | + | select { | |
| 110 | + | case <-first: | |
| 111 | + | case <-ctx.Done(): | |
| 112 | + | t.Fatal("first hook did not start") | |
| 113 | + | } | |
| 114 | + | a.SetSystem("new base") | |
| 115 | + | close(release) | |
| 116 | + | select { | |
| 117 | + | case <-second: | |
| 118 | + | case <-done: | |
| 119 | + | t.Fatal("stale preparation was committed instead of retried") | |
| 120 | + | case <-ctx.Done(): | |
| 121 | + | t.Fatal("second hook did not start") | |
| 122 | + | } | |
| 123 | + | cancel() | |
| 124 | + | <-done | |
| 125 | + | if runErr != context.Canceled { | |
| 126 | + | t.Fatalf("error=%v", runErr) | |
| 127 | + | } | |
| 128 | + | if a.System != "new base" || a.startPrepared { | |
| 129 | + | t.Fatal("cancellation committed a stale result") | |
| 130 | + | } | |
| 131 | + | } | |
| @@ -0,0 +1,131 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestBeforeStartDiscardsSupersededPreparation(t *testing.T) { | |||
| 11 | + | for _, tc := range []struct { | |||
| 12 | + | name string | |||
| 13 | + | reset func(*Agent) | |||
| 14 | + | wantBase string | |||
| 15 | + | }{ | |||
| 16 | + | {"new prompt", func(a *Agent) { a.SetSystem("new base") }, "new base"}, | |||
| 17 | + | {"same prompt reset", func(a *Agent) { a.SetSystem("base") }, "base"}, | |||
| 18 | + | {"ABA prompt reset", func(a *Agent) { a.SetSystem("temporary"); a.SetSystem("base") }, "base"}, | |||
| 19 | + | {"multiple rebuilds", func(a *Agent) { a.SetSystem("intermediate"); a.SetSystem("latest") }, "latest"}, | |||
| 20 | + | {"clear", func(a *Agent) { a.SetMessages(nil) }, "base"}, | |||
| 21 | + | {"model change", func(a *Agent) { a.Model = "new model" }, "base"}, | |||
| 22 | + | {"session change", func(a *Agent) { a.SessionID = "new session" }, "base"}, | |||
| 23 | + | } { | |||
| 24 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 25 | + | a := NewAgent(nil, "model", "base", nil) | |||
| 26 | + | entered, release := make(chan struct{}), make(chan struct{}) | |||
| 27 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 28 | + | defer cancel() | |||
| 29 | + | var inputs []string | |||
| 30 | + | a.BeforeStart = func(ctx context.Context, system string) string { | |||
| 31 | + | inputs = append(inputs, system) | |||
| 32 | + | if len(inputs) == 1 { | |||
| 33 | + | close(entered) | |||
| 34 | + | select { | |||
| 35 | + | case <-release: | |||
| 36 | + | case <-ctx.Done(): | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | return fmt.Sprintf("%s/replacement-%d", system, len(inputs)) | |||
| 40 | + | } | |||
| 41 | + | a.BeforeTurn = func(int) (bool, string) { return false, "test" } | |||
| 42 | + | done := make(chan struct{}) | |||
| 43 | + | var runErr error | |||
| 44 | + | go func() { defer close(done); runErr = a.Continue(ctx, nil) }() | |||
| 45 | + | t.Cleanup(func() { cancel(); <-done }) | |||
| 46 | + | select { | |||
| 47 | + | case <-entered: | |||
| 48 | + | case <-ctx.Done(): | |||
| 49 | + | t.Fatal("hook did not start") | |||
| 50 | + | } | |||
| 51 | + | // The hook is paused without the agent lock. Reset must remain responsive, | |||
| 52 | + | // including when the base string has not changed at all. | |||
| 53 | + | tc.reset(a) | |||
| 54 | + | close(release) | |||
| 55 | + | select { | |||
| 56 | + | case <-done: | |||
| 57 | + | if runErr != nil { | |||
| 58 | + | t.Fatal(runErr) | |||
| 59 | + | } | |||
| 60 | + | case <-ctx.Done(): | |||
| 61 | + | t.Fatal("preparation did not finish") | |||
| 62 | + | } | |||
| 63 | + | if len(inputs) != 2 || inputs[1] != tc.wantBase { | |||
| 64 | + | t.Fatalf("hook inputs=%q, want [base %q]", inputs, tc.wantBase) | |||
| 65 | + | } | |||
| 66 | + | want := tc.wantBase + "/replacement-2" | |||
| 67 | + | if a.System != want || a.BaseSystem() != tc.wantBase { | |||
| 68 | + | t.Fatalf("system=%q base=%q", a.System, a.BaseSystem()) | |||
| 69 | + | } | |||
| 70 | + | if a.startModel != a.Model || a.startSession != a.SessionID { | |||
| 71 | + | t.Fatal("prepared metadata does not match current runtime") | |||
| 72 | + | } | |||
| 73 | + | // The fresh result is cached; normal continuation must not run it again. | |||
| 74 | + | if err := a.Continue(ctx, nil); err != nil { | |||
| 75 | + | t.Fatal(err) | |||
| 76 | + | } | |||
| 77 | + | if len(inputs) != 2 { | |||
| 78 | + | t.Fatal("fresh preparation was not cached") | |||
| 79 | + | } | |||
| 80 | + | }) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestBeforeStartCancellationDuringRepreparation(t *testing.T) { | |||
| 85 | + | a := NewAgent(nil, "model", "base", nil) | |||
| 86 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 87 | + | defer cancel() | |||
| 88 | + | first, second, release := make(chan struct{}), make(chan struct{}), make(chan struct{}) | |||
| 89 | + | calls := 0 | |||
| 90 | + | a.BeforeStart = func(ctx context.Context, system string) string { | |||
| 91 | + | calls++ | |||
| 92 | + | if calls == 1 { | |||
| 93 | + | close(first) | |||
| 94 | + | select { | |||
| 95 | + | case <-release: | |||
| 96 | + | case <-ctx.Done(): | |||
| 97 | + | } | |||
| 98 | + | } else { | |||
| 99 | + | close(second) | |||
| 100 | + | <-ctx.Done() | |||
| 101 | + | } | |||
| 102 | + | return "discard this replacement" | |||
| 103 | + | } | |||
| 104 | + | a.BeforeTurn = func(int) (bool, string) { return false, "test" } | |||
| 105 | + | done := make(chan struct{}) | |||
| 106 | + | var runErr error | |||
| 107 | + | go func() { defer close(done); runErr = a.Continue(ctx, nil) }() | |||
| 108 | + | t.Cleanup(func() { cancel(); <-done }) | |||
| 109 | + | select { | |||
| 110 | + | case <-first: | |||
| 111 | + | case <-ctx.Done(): | |||
| 112 | + | t.Fatal("first hook did not start") | |||
| 113 | + | } | |||
| 114 | + | a.SetSystem("new base") | |||
| 115 | + | close(release) | |||
| 116 | + | select { | |||
| 117 | + | case <-second: | |||
| 118 | + | case <-done: | |||
| 119 | + | t.Fatal("stale preparation was committed instead of retried") | |||
| 120 | + | case <-ctx.Done(): | |||
| 121 | + | t.Fatal("second hook did not start") | |||
| 122 | + | } | |||
| 123 | + | cancel() | |||
| 124 | + | <-done | |||
| 125 | + | if runErr != context.Canceled { | |||
| 126 | + | t.Fatalf("error=%v", runErr) | |||
| 127 | + | } | |||
| 128 | + | if a.System != "new base" || a.startPrepared { | |||
| 129 | + | t.Fatal("cancellation committed a stale result") | |||
| 130 | + | } | |||
| 131 | + | } | |||
A packages/core/agent_start_test.go +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestBeforeStartLifecycle(t *testing.T) { | |
| 9 | + | a := NewAgent(&queueFakeClient{}, "model", "base", Registry{}) | |
| 10 | + | calls, turns := 0, 0 | |
| 11 | + | a.BeforeStart = func(ctx context.Context, system string) string { | |
| 12 | + | calls++ | |
| 13 | + | if system != "base" { | |
| 14 | + | t.Fatalf("reapplied to modified prompt %q", system) | |
| 15 | + | } | |
| 16 | + | return system + "/extension" | |
| 17 | + | } | |
| 18 | + | a.BeforeTurn = func(int) (bool, string) { | |
| 19 | + | turns++ | |
| 20 | + | if calls != 1 || a.System != "base/extension" { | |
| 21 | + | t.Fatal("turn ran before preparation") | |
| 22 | + | } | |
| 23 | + | return true, "" | |
| 24 | + | } | |
| 25 | + | for range 2 { | |
| 26 | + | if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil { | |
| 27 | + | t.Fatal(err) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | if calls != 1 || turns != 3 { | |
| 31 | + | t.Fatalf("calls=%d turns=%d", calls, turns) | |
| 32 | + | } | |
| 33 | + | a.BeforeTurn = nil | |
| 34 | + | for _, reset := range []func(){ | |
| 35 | + | func() { a.Model = "other" }, | |
| 36 | + | func() { a.SetMessages(nil) }, | |
| 37 | + | func() { a.SessionID = "new-session" }, | |
| 38 | + | func() { a.SetSystem("base") }, | |
| 39 | + | } { | |
| 40 | + | reset() | |
| 41 | + | before := calls | |
| 42 | + | if err := a.Continue(context.Background(), nil); err != nil { | |
| 43 | + | t.Fatal(err) | |
| 44 | + | } | |
| 45 | + | if calls != before+1 || a.System != "base/extension" { | |
| 46 | + | t.Fatal("reset did not reprepare from base") | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | a.BeforeStart = func(context.Context, string) string { return "" } | |
| 50 | + | a.SetSystem("base") | |
| 51 | + | if err := a.Continue(context.Background(), nil); err != nil { | |
| 52 | + | t.Fatal(err) | |
| 53 | + | } | |
| 54 | + | if a.System != "" { | |
| 55 | + | t.Fatal("empty replacement lost") | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestBeforeStartCancellationRetriesPreparation(t *testing.T) { | |
| 60 | + | a := NewAgent(nil, "model", "base", nil) | |
| 61 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 62 | + | a.BeforeStart = func(context.Context, string) string { cancel(); return "modified" } | |
| 63 | + | if err := a.Continue(ctx, nil); err != context.Canceled { | |
| 64 | + | t.Fatalf("error=%v", err) | |
| 65 | + | } | |
| 66 | + | if a.System != "base" || a.startPrepared { | |
| 67 | + | t.Fatal("canceled preparation committed") | |
| 68 | + | } | |
| 69 | + | a.BeforeStart = func(context.Context, string) string { return "retry" } | |
| 70 | + | a.BeforeTurn = func(int) (bool, string) { return false, "test" } | |
| 71 | + | if err := a.Continue(context.Background(), nil); err != nil { | |
| 72 | + | t.Fatal(err) | |
| 73 | + | } | |
| 74 | + | if a.System != "retry" { | |
| 75 | + | t.Fatal("preparation not retried") | |
| 76 | + | } | |
| 77 | + | } | |
| @@ -0,0 +1,77 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestBeforeStartLifecycle(t *testing.T) { | |||
| 9 | + | a := NewAgent(&queueFakeClient{}, "model", "base", Registry{}) | |||
| 10 | + | calls, turns := 0, 0 | |||
| 11 | + | a.BeforeStart = func(ctx context.Context, system string) string { | |||
| 12 | + | calls++ | |||
| 13 | + | if system != "base" { | |||
| 14 | + | t.Fatalf("reapplied to modified prompt %q", system) | |||
| 15 | + | } | |||
| 16 | + | return system + "/extension" | |||
| 17 | + | } | |||
| 18 | + | a.BeforeTurn = func(int) (bool, string) { | |||
| 19 | + | turns++ | |||
| 20 | + | if calls != 1 || a.System != "base/extension" { | |||
| 21 | + | t.Fatal("turn ran before preparation") | |||
| 22 | + | } | |||
| 23 | + | return true, "" | |||
| 24 | + | } | |||
| 25 | + | for range 2 { | |||
| 26 | + | if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil { | |||
| 27 | + | t.Fatal(err) | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | if calls != 1 || turns != 3 { | |||
| 31 | + | t.Fatalf("calls=%d turns=%d", calls, turns) | |||
| 32 | + | } | |||
| 33 | + | a.BeforeTurn = nil | |||
| 34 | + | for _, reset := range []func(){ | |||
| 35 | + | func() { a.Model = "other" }, | |||
| 36 | + | func() { a.SetMessages(nil) }, | |||
| 37 | + | func() { a.SessionID = "new-session" }, | |||
| 38 | + | func() { a.SetSystem("base") }, | |||
| 39 | + | } { | |||
| 40 | + | reset() | |||
| 41 | + | before := calls | |||
| 42 | + | if err := a.Continue(context.Background(), nil); err != nil { | |||
| 43 | + | t.Fatal(err) | |||
| 44 | + | } | |||
| 45 | + | if calls != before+1 || a.System != "base/extension" { | |||
| 46 | + | t.Fatal("reset did not reprepare from base") | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | a.BeforeStart = func(context.Context, string) string { return "" } | |||
| 50 | + | a.SetSystem("base") | |||
| 51 | + | if err := a.Continue(context.Background(), nil); err != nil { | |||
| 52 | + | t.Fatal(err) | |||
| 53 | + | } | |||
| 54 | + | if a.System != "" { | |||
| 55 | + | t.Fatal("empty replacement lost") | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestBeforeStartCancellationRetriesPreparation(t *testing.T) { | |||
| 60 | + | a := NewAgent(nil, "model", "base", nil) | |||
| 61 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 62 | + | a.BeforeStart = func(context.Context, string) string { cancel(); return "modified" } | |||
| 63 | + | if err := a.Continue(ctx, nil); err != context.Canceled { | |||
| 64 | + | t.Fatalf("error=%v", err) | |||
| 65 | + | } | |||
| 66 | + | if a.System != "base" || a.startPrepared { | |||
| 67 | + | t.Fatal("canceled preparation committed") | |||
| 68 | + | } | |||
| 69 | + | a.BeforeStart = func(context.Context, string) string { return "retry" } | |||
| 70 | + | a.BeforeTurn = func(int) (bool, string) { return false, "test" } | |||
| 71 | + | if err := a.Continue(context.Background(), nil); err != nil { | |||
| 72 | + | t.Fatal(err) | |||
| 73 | + | } | |||
| 74 | + | if a.System != "retry" { | |||
| 75 | + | t.Fatal("preparation not retried") | |||
| 76 | + | } | |||
| 77 | + | } | |||