detached e18c6ae

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.
263263
264264 Interceptable events:
265265
266+- `before_agent_start`: inspect and replace the complete system prompt before
267+ the first model call. See [System prompt replacement](#system-prompt-replacement).
266268 - `tool_call`: block the call (model sees `reason` as the tool
267269 error) or rewrite args via `modified_args`.
268270 - `turn_start`: block the turn before the model is called. Useful
@@ -273,6 +275,55 @@ Interceptable events:
273275 text stays in the transcript so the model sees what it actually
274276 said on subsequent turns.
275277
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+
276327 #### `event_intercept_response`
277328
278329 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
284335 | `reason` | refusal text (on block) or pass-through note. |
285336 | `modified_args` | for `tool_call`: rewritten JSON args the tool will actually see. Must be a valid JSON object. Ignored when `block` is true. |
286337 | `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. |
287339
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
290343 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.
293346
294347 ```json
295348 {"type":"event_intercept_response","id":"...",
@@ -602,11 +655,18 @@ host metadata such as `HostInfo.CWD`, `Provider`, `Model`, `ZotVersion`,
602655 `ExtensionDir`, or `DataDir`. The SDK sends `hello`, waits for
603656 `hello_ack`, runs `OnHello`, announces registrations, then sends `ready`.
604657
605-The SDK has four interceptor hooks, all optional:
658+The SDK has five interceptor hooks, all optional:
606659
607660 ```go
608661 // e is the *ext.Extension returned by ext.New(...).
609662
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+
610670 // Refuse calls or rewrite args before they run.
611671 e.InterceptToolCall(func(tool string, args json.RawMessage) (bool, string) {
612672 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 tool268 - `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. Useful270 - `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 actually275 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 to329 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. an340+Missing the response within 5s is treated as "allow, unchanged". For
289-unresponsive extension never stalls the agent). When multiple341+`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: each344+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 ```json347 ```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 for655 `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 ```go660 ```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
2525
2626 `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.
2727
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+
2838 ## Auth
2939
3040 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.
135145
136146 Response data: `{"messages": [<message>, ...]}`. See **message shape** below.
137147
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+
138157 ### `clear`
139158
140159 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 ## Auth38 ## 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
326326 if ag == nil || extMgr == nil {
327327 return
328328 }
329+ wireBeforeAgentStart(ag, extMgr, "")
329330 ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
330331 res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
331332 if res.Block {
@@ -624,7 +625,7 @@ func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extTool
624625 reg = mutateRegistry(reg)
625626 }
626627 ag.SetTools(reg)
627- ag.System = resolved.SystemPrompt
628+ ag.SetSystem(resolved.SystemPrompt)
628629 }
629630
630631 // reloadResourcesAfterStartupPre reloads extensions (if any) and
@@ -777,10 +778,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
777778
778779 // Capture current args in a closure so BuildAgent can re-resolve
779780 // 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)
784784 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
785785 // Guards run before confirmation because they may rewrite the
786786 // arguments. The user must approve the effective call that will
@@ -846,7 +846,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
846846 resolved.UseSandbox(sharedSandbox)
847847 resolved.MergeExtensionTools(extToolAdapter)
848848 injectSwarmSpawn(resolved.ToolRegistry)
849- return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
849+ return wireAgentExt(resolved), resolved.Provider, resolved.Model, nil
850850 }
851851
852852 // Rebuild agent with an explicit provider/model override.
@@ -865,7 +865,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
865865 resolved.UseSandbox(sharedSandbox)
866866 resolved.MergeExtensionTools(extToolAdapter)
867867 injectSwarmSpawn(resolved.ToolRegistry)
868- return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
868+ return wireAgentExt(resolved), resolved.Provider, resolved.Model, nil
869869 }
870870
871871 // Rebuild agent for the rescue picker after a recoverable failure.
@@ -892,12 +892,12 @@ func runInteractive(ctx context.Context, args Args, version string) error {
892892 resolved.UseSandbox(sharedSandbox)
893893 resolved.MergeExtensionTools(extToolAdapter)
894894 injectSwarmSpawn(resolved.ToolRegistry)
895- return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
895+ return wireAgentExt(resolved), resolved.Provider, resolved.Model, nil
896896 }
897897
898898 var ag *core.Agent
899899 if r.HasCredential() {
900- ag = wireAgentExt(r.NewAgent())
900+ ag = wireAgentExt(r)
901901 }
902902
903903 // /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 return327 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.SystemPrompt628+ ag.SetSystem(resolved.SystemPrompt)
628 }629 }
629 630
630 // reloadResourcesAfterStartupPre reloads extensions (if any) and631 // 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-resolve779 // 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 a783+ 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 the785 // Guards run before confirmation because they may rewrite the
786 // arguments. The user must approve the effective call that will786 // 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, nil849+ 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, nil868+ 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, nil895+ return wireAgentExt(resolved), resolved.Provider, resolved.Model, nil
896 }896 }
897 897
898 var ag *core.Agent898 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 every903 // /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 {
117117 // when you want to rewrite args instead of only blocking.
118118 type ToolCallHandler func(toolName string, args json.RawMessage) ToolCallDecision
119119
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+
120134 // TurnStartDecision controls whether the next model call runs. Zero
121135 // value means "allow".
122136 type TurnStartDecision struct {
@@ -261,6 +275,7 @@ type Extension struct {
261275 interceptTool InterceptHandler
262276 interceptToolRich ToolCallHandler
263277 interceptOn bool
278+ interceptStart BeforeAgentStartHandler
264279 interceptTurn TurnStartHandler
265280 interceptAssistant AssistantMessageHandler
266281 panelKeys map[string]func(key, text string)
@@ -450,6 +465,13 @@ func (e *Extension) InterceptToolCallX(fn ToolCallHandler) {
450465 e.mu.Unlock()
451466 }
452467
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+
453475 // InterceptTurnStart registers a guard that runs before every turn's
454476 // model call. Return Block=true with Reason to abort the turn.
455477 // Useful for deny-by-default gates and usage quotas.
@@ -529,6 +551,7 @@ func (e *Extension) Run() error {
529551 descs := append([]descTuple(nil), e.descriptions...)
530552 toolDefs := append([]toolDef(nil), e.toolDefs...)
531553 eventNames := append([]string(nil), e.eventNames...)
554+ interceptStart := e.interceptStart != nil
532555 interceptTool := e.interceptOn
533556 interceptTurn := e.interceptTurn != nil
534557 interceptAsst := e.interceptAssistant != nil
@@ -550,6 +573,9 @@ func (e *Extension) Run() error {
550573 })
551574 }
552575 var intercepts []string
576+ if interceptStart {
577+ intercepts = append(intercepts, "before_agent_start")
578+ }
553579 if interceptTool {
554580 intercepts = append(intercepts, "tool_call")
555581 }
@@ -762,6 +788,18 @@ func (e *Extension) dispatchIntercept(ei extproto.EventInterceptFromHost) {
762788 }
763789
764790 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+ }
765803 case "tool_call":
766804 e.mu.Lock()
767805 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) ToolCallDecision118 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. Zero134 // 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 InterceptHandler275 interceptTool InterceptHandler
262 interceptToolRich ToolCallHandler276 interceptToolRich ToolCallHandler
263 interceptOn bool277 interceptOn bool
278+ interceptStart BeforeAgentStartHandler
264 interceptTurn TurnStartHandler279 interceptTurn TurnStartHandler
265 interceptAssistant AssistantMessageHandler280 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's475 // 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.interceptOn555 interceptTool := e.interceptOn
533 interceptTurn := e.interceptTurn != nil556 interceptTurn := e.interceptTurn != nil
534 interceptAsst := e.interceptAssistant != nil557 interceptAsst := e.interceptAssistant != nil
@@ -550,6 +573,9 @@ func (e *Extension) Run() error {
550 })573 })
551 }574 }
552 var intercepts []string575 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.interceptToolRich805 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 = &current
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 = &current
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) {
5252 // optional rewrite fields (ModifiedArgs for tool_call, ReplaceText
5353 // for assistant_message) to carry the rewrite into the action.
5454 type InterceptResult struct {
55+ SystemPrompt json.RawMessage
56+ failure string
5557 Block bool
5658 Reason string
5759 ModifiedArgs json.RawMessage
@@ -162,7 +164,8 @@ func (m *Manager) interceptSubsFor(event string) []*Extension {
162164
163165 // askIntercept sends one EventInterceptFromHost to ext and waits for
164166 // 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.
166169 func (m *Manager) askIntercept(ctx context.Context, ext *Extension, payload extproto.EventInterceptFromHost) InterceptResult {
167170 id := newCorrelationID()
168171 ch := make(chan extproto.EventInterceptResponseFromExt, 1)
@@ -179,17 +182,33 @@ func (m *Manager) askIntercept(ctx context.Context, ext *Extension, payload extp
179182 ext.mu.Unlock()
180183 return InterceptResult{}
181184 }
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 {
183201 ext.mu.Lock()
184202 delete(ext.pendingIntercept, id)
185203 ext.mu.Unlock()
186204 fmt.Fprintf(ext.logFile, "[zot] intercept write failed: %v\n", err)
187- return InterceptResult{}
205+ return InterceptResult{failure: "request write failed"}
188206 }
189207
190208 select {
191209 case resp := <-ch:
192210 return InterceptResult{
211+ SystemPrompt: resp.SystemPrompt,
193212 Block: resp.Block,
194213 Reason: resp.Reason,
195214 ModifiedArgs: resp.ModifiedArgs,
@@ -200,12 +219,12 @@ func (m *Manager) askIntercept(ctx context.Context, ext *Extension, payload extp
200219 delete(ext.pendingIntercept, id)
201220 ext.mu.Unlock()
202221 fmt.Fprintf(ext.logFile, "[zot] intercept %s timed out; allowing\n", payload.Event)
203- return InterceptResult{}
222+ return InterceptResult{failure: "response timed out"}
204223 case <-ctx.Done():
205224 ext.mu.Lock()
206225 delete(ext.pendingIntercept, id)
207226 ext.mu.Unlock()
208- return InterceptResult{}
227+ return InterceptResult{failure: "response canceled or timed out"}
209228 }
210229 }
211230
@@ -52,6 +52,8 @@ func (m *Manager) EmitEvent(ev extproto.EventFromHost) {
52 // optional rewrite fields (ModifiedArgs for tool_call, ReplaceText52 // 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 bool57 Block bool
56 Reason string58 Reason string
57 ModifiedArgs json.RawMessage59 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 for165 // askIntercept sends one EventInterceptFromHost to ext and waits for
164 // the reply, a timeout, or context cancellation. Returns a typed166 // 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 {
148148 model string
149149 hooks HostHooks
150150
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
153154
154155 // commandIndex maps a lower-cased slash-command name (without the
155156 // leading /) to its canonical registration. First-come-first-served:
@@ -429,6 +430,7 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
429430
430431 m.mu.Lock()
431432 m.ext[mf.Name] = ext
433+ m.extOrder = append(m.extOrder, ext)
432434 // Note: ext.commands and ext.tools may be empty here — they're
433435 // populated by the read loop as register_* frames arrive after
434436 // 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 {
551553 explicit := append([]string(nil), m.explicitPaths...)
552554 stats.Stopped = len(old)
553555 m.ext = map[string]*Extension{}
556+ m.extOrder = nil
554557 m.commandIndex = map[string]commandRegistration{}
555558 m.toolIndex = map[string]*Extension{}
556559 m.explicitPaths = nil
@@ -931,7 +934,7 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
931934 }
932935 for _, ev := range sub.Intercept {
933936 switch ev {
934- case "tool_call", "turn_start", "assistant_message":
937+ case "tool_call", "turn_start", "assistant_message", "before_agent_start":
935938 ext.interceptSubs[ev] = struct{}{}
936939 }
937940 }
@@ -148,8 +148,9 @@ type Manager struct {
148 model string148 model string
149 hooks HostHooks149 hooks HostHooks
150 150
151- mu sync.RWMutex151+ mu sync.RWMutex
152- ext map[string]*Extension // keyed by manifest name152+ 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 the155 // 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] = ext432 m.ext[mf.Name] = ext
433+ m.extOrder = append(m.extOrder, ext)
432 // Note: ext.commands and ext.tools may be empty here — they're434 // Note: ext.commands and ext.tools may be empty here — they're
433 // populated by the read loop as register_* frames arrive after435 // populated by the read loop as register_* frames arrive after
434 // hello. Indexing happens in the read loop too. Discover()'s436 // 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 = nil559 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 {
5858 }
5959
6060 type EventInterceptResponseFromExt struct {
61+ SystemPrompt json.RawMessage `json:"system_prompt,omitempty"`
6162 Type string `json:"type"`
6263 ID string `json:"id"`
6364 Block bool `json:"block,omitempty"`
@@ -196,14 +197,20 @@ type EventFromHost struct {
196197 }
197198
198199 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"`
207214 }
208215
209216 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 {
66326632 // applyAutoSwarmSystemPrompt appends (active=true) or strips
66336633 // (active=false) the auto-swarm system-prompt block on the running
66346634 // 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.
66376637 func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) {
66386638 if i.agent == nil {
66396639 return
@@ -6642,16 +6642,16 @@ func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) {
66426642 if addendum == "" {
66436643 return
66446644 }
6645- sys := i.agent.System
6645+ sys := i.agent.BaseSystem()
66466646 has := strings.Contains(sys, addendum)
66476647 switch {
66486648 case active && !has:
66496649 if sys != "" && !strings.HasSuffix(sys, "\n\n") {
66506650 sys += "\n\n"
66516651 }
6652- i.agent.System = sys + addendum
6652+ i.agent.SetSystem(sys + addendum)
66536653 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")
66556655 }
66566656 }
66576657
@@ -6632,8 +6632,8 @@ func truncateForSummary(s string, n int) string {
6632 // applyAutoSwarmSystemPrompt appends (active=true) or strips6632 // applyAutoSwarmSystemPrompt appends (active=true) or strips
6633 // (active=false) the auto-swarm system-prompt block on the running6633 // (active=false) the auto-swarm system-prompt block on the running
6634 // agent so the model proactively considers swarm_spawn when the user6634 // agent so the model proactively considers swarm_spawn when the user
6635-// flips the toggle. The block lives at the tail of agent.System so6635+// 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 return6639 return
@@ -6642,16 +6642,16 @@ func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) {
6642 if addendum == "" {6642 if addendum == "" {
6643 return6643 return
6644 }6644 }
6645- sys := i.agent.System6645+ 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 + addendum6652+ 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 {
6868 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
6969
7070 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)
9072
9173 // /reload-ext hot-reload callback (also triggered via rpc
9274 // `reload_ext` if/when added). Rebuilds the tool registry on the
@@ -236,6 +218,18 @@ func (s *rpcServer) run(in io.Reader) error {
236218 // dispatch routes a command. Long-running commands (prompt, compact)
237219 // run on their own goroutine so the read loop stays responsive.
238220 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+
239233 switch cmd {
240234 case "hello":
241235 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 rpc73 // /reload-ext hot-reload callback (also triggered via rpc
92 // `reload_ext` if/when added). Rebuilds the tool registry on the74 // `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 {
4848 // malformed modifiedArgs is ignored.
4949 BeforeToolExecute func(call provider.ToolCallBlock) (allowed bool, reason string, modifiedArgs json.RawMessage)
5050
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+
5156 // BeforeTurn, if set, is called before each turn's model call.
5257 // Returning (allowed=false, reason) aborts the turn; reason is
5358 // surfaced as an assistant-like status line. Used for rate-
@@ -98,6 +103,12 @@ type Agent struct {
98103 // wholesale transcript replacement.
99104 OnTranscriptCompacted func(messages []provider.Message)
100105
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+
101112 mu sync.Mutex
102113 messages []provider.Message
103114 // rev increments whenever the transcript slice is replaced or a
@@ -254,6 +265,9 @@ func (a *Agent) SetMessages(msgs []provider.Message) {
254265 a.mu.Lock()
255266 defer a.mu.Unlock()
256267 a.messages = append(a.messages[:0], msgs...)
268+ if len(msgs) == 0 {
269+ a.resetStartLocked()
270+ }
257271 a.rev++
258272 }
259273
@@ -371,6 +385,12 @@ func (a *Agent) wrapSink(sink func(AgentEvent)) func(AgentEvent) {
371385
372386 func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
373387 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+ }
374394 // Messages queued while the agent was busy are delivered
375395 // before the next model call. This is the safe boundary:
376396 // any previous tool batch has already completed and its
@@ -563,24 +583,40 @@ func (a *Agent) dropLastAssistantMessage() {
563583 // oneTurn calls the LLM once, forwards events, returns the stop reason
564584 // and the assembled assistant message (already appended to the transcript).
565585 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
584620 }
585621 stream, err := a.Client.Stream(ctx, req)
586622 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 is57 // 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.Mutex112 mu sync.Mutex
102 messages []provider.Message113 messages []provider.Message
103 // rev increments whenever the transcript slice is replaced or a114 // 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 delivered394 // 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 its396 // 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 reason583 // 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 turn589+ return provider.StopError, provider.Message{}, err
570- // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a590+ }
571- // dev server, etc.) can leave an assistant tool_use with no591+ a.mu.Lock()
572- // matching tool_result in the live transcript. The load-time592+ // A reset can also arrive after runLoop's preparation, for example
573- // repair in OpenSession only runs on restart, so without this the593+ // while BeforeTurn waits. Validate and snapshot under the same lock
574- // next in-process request is rejected by providers like Anthropic594+ // so this request never receives an unprepared replacement prompt.
575- // with "tool_use ids were found without tool_result blocks". The595+ 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close