detached a98ca1a

feat: expand extension lifecycle events

Add observational prompt, tool result, session end, compaction, swarm,
and permission decision events with protocol and Go extension SDK support.
Preserve effective arguments and typed outcomes, serialize outbound frames,
and bound delivery queues and shutdown draining.

Preserve existing turn_end timing and RPC client events. Document payload
limits, privacy considerations, and best-effort delivery semantics.

Add lifecycle, ordering, cancellation, policy, resume, and SDK regression
coverage.

Refs: https://github.com/patriceckhart/zot/discussions/170
commit
a98ca1a813e376533c4ea9df042b8b6cb1c00572
author
patriceckhart · · 2026-09-08 07:22 UTC
parent
80dd4c6
signature
unsigned
refs
tag: v0.3.68
34 files · +1803 -91
M docs/extensions.md +166 -3
@@ -252,8 +252,12 @@ which it wants to intercept. Send once after `hello`, before `ready`.
252252 "intercept":["tool_call","turn_start","assistant_message"]}
253253 ```
254254
255-Recognised event names: `session_start`, `turn_start`, `turn_end`,
256-`tool_call`, `tool_confirmation_requested`, `assistant_message`.
255+Recognised event names: `session_start`, `session_end`, `user_prompt_submit`,
256+`turn_start`, `turn_end`, `tool_call`, `tool_result`,
257+`tool_confirmation_requested`, `permission_decision`, `assistant_message`,
258+`pre_compact`, `post_compact`, `subagent_start`, `subagent_stop`.
259+The new lifecycle events are observational only; adding them to `intercept`
260+does not make them blocking hooks. See [Lifecycle payloads](#lifecycle-payloads).
257261
258262 `tool_confirmation_requested` fires only when zot is about to wait for
259263 interactive approval. Calls running in yolo mode, calls covered by a
@@ -503,7 +507,8 @@ marked as failed.
503507 #### `event`
504508
505509 Lifecycle notification for events the extension subscribed to via
506-`subscribe`. One-way — no response expected.
510+`subscribe`. One-way, no response expected. Fields are additive under protocol
511+version 1. Clients must tolerate unknown fields and event names.
507512
508513 ```json
509514 {"type":"event","event":"turn_start","step":1}
@@ -514,6 +519,164 @@ Lifecycle notification for events the extension subscribed to via
514519 {"type":"event","event":"turn_end","stop":"end_turn"}
515520 ```
516521
522+### Lifecycle payloads
523+
524+Notifications include `session_id`, `cwd`, and a monotonically increasing
525+`sequence` scoped to the extension manager's lifetime. Sequence numbers span
526+all notifications, not just an extension's subscriptions, so gaps do not by
527+themselves indicate lost delivery. They reset when zot restarts. Correlate tool
528+outcomes with `(session_id, tool_id)`, compaction with `compaction_id`, and swarm
529+runs with `agent_run_id`. These are payload identifiers, not request/reply IDs.
530+
531+| Event | Additional payload | Meaning |
532+|---|---|---|
533+| `session_start` | No additional fields | An active conversation opens |
534+| `session_end` | `reason` | The active conversation closes |
535+| `user_prompt_submit` | `text`, optional `queued`, `image_count` | Model input accepted, before appending or queueing |
536+| `turn_start` | `step` | A model step begins |
537+| `turn_end` | `stop`, optional `error` | A model response attempt finishes, before client tool execution |
538+| `tool_call` | `tool_id`, `tool_name`, `tool_args` | The model's original proposed call |
539+| `tool_confirmation_requested` | `tool_id`, `tool_name`, `tool_preview` | Interactive approval is about to be requested |
540+| `permission_decision` | `tool_id`, `tool_name`, `decision`, `source`, `stage`, optional `reason` | An approval or policy decision was resolved |
541+| `tool_result` | `tool_id`, `tool_name`, `tool_args`, `status`, `executed`, `result` | A client tool call reached a terminal outcome |
542+| `assistant_message` | `text` | Visible assistant text |
543+| `pre_compact` | `compaction_id`, `message_count`, `token_estimate` | A compaction attempt begins |
544+| `post_compact` | Same counts and ID, `status`, optional `error` | A compaction attempt finishes, including failure |
545+| `subagent_start` | `agent_id`, `agent_run_id`, `name` | A local swarm runner starts or resumes |
546+| `subagent_stop` | Same identity, `status`, optional `error` | That local swarm runner returns |
547+
548+#### Prompt submission
549+
550+`text` is the input handed to the agent after host preprocessing, not raw editor
551+keystrokes. This includes extension-generated prompts and queued follow-ups.
552+Queued input emits once at acceptance with `queued:true`, not again at
553+consumption; it can subsequently be withdrawn without reaching the model.
554+Image bytes are not included; `image_count` reports attached images. An
555+image-only prompt may omit `text`. Slash commands that do not submit model
556+input, transcript replay, compaction summaries, and internal image mirrors do
557+not emit this event.
558+
559+#### Tool outcomes and permission decisions
560+
561+```json
562+{"type":"event","event":"tool_result","session_id":"session-1","cwd":"/work","sequence":12,"tool_id":"call-1","tool_name":"bash","tool_args":{"command":"go test ./..."},"status":"completed","executed":true,"result":{"content":[{"type":"text","text":"ok"}],"is_error":false}}
563+```
564+
565+`status` is `completed`, `failed`, `blocked`, `cancelled`, or `timed_out`.
566+Failures include unknown tools, panics, and tool error results. Guards and
567+built-in jail/manifest policy refusals are `blocked`. Cancellation and timeout
568+classification uses typed execution errors or explicit tool outcome metadata,
569+not error-message matching. A custom tool that reports only `is_error` without
570+such metadata is classified as `failed`.
571+
572+`tool_args` contains effective arguments, including accepted interceptor
573+rewrites and the empty-argument default `{}`. For a call refused before
574+execution, it contains the last proposed arguments instead. If the arguments
575+are malformed JSON, `tool_args_raw` contains their raw text and `tool_args` is
576+omitted, so the outcome remains a valid protocol frame. `executed` means
577+`Tool.Execute` was entered, not that a side effect occurred or was rolled back.
578+A tool-local policy refusal can therefore be `blocked` with `executed:true`.
579+Pending calls cancelled before execution still receive terminal results.
580+Provider-executed server tools do not receive client `tool_result` events.
581+
582+`result.content` uses text and base64 image blocks. Observation payloads have a
583+256 KiB combined text/base64/MIME-data budget and a 128-block limit. Text may be
584+shortened at a UTF-8 boundary; oversized images and unsupported block types are
585+omitted. `result.truncated:true` marks any omission. Tool execution, the model's
586+result, and the persisted transcript are not truncated by this observation
587+limit. Private `Details` and tool activation metadata are not exported.
588+
589+```json
590+{"type":"event","event":"permission_decision","session_id":"session-1","tool_id":"call-1","tool_name":"bash","decision":"approved","source":"user","stage":"pre_execution"}
591+```
592+
593+`decision` is `approved`, `denied`, or `cancelled`. `source` is `user`,
594+`remembered`, `policy`, or `yolo`. Automatic approval is represented by an
595+approved decision with its source, not a separate decision value. Guard
596+refusals, preview failures, and no available confirmer use `policy`.
597+
598+A `pre_execution` approval does not bypass tool-local checks. If a tool later
599+refuses access, an additional denied decision with `source:"policy"` and
600+`stage:"tool_execution"` precedes its blocked result. These events cannot grant
601+permission or override a refusal. Calls cancelled before checking permission
602+or rejected as unknown tools can have a result without a permission decision.
603+Existing mode-specific confirmation behavior is unchanged, including modes
604+that do not support interactive approval.
605+
606+#### Session, compaction, and swarm boundaries
607+
608+Sessions use the persisted conversation ID when available, otherwise a
609+runtime-generated ID. Interactive and RPC sessions start when initialized;
610+single-shot/headless sessions start when the agent first emits an observation.
611+Switching conversations emits the old session's end before the new start.
612+Current end reasons are `shutdown`, `session_switch`, and `cwd_change`.
613+`session_end` is emitted at most once per activation on these graceful paths.
614+Switching models or clearing messages does not close the active conversation.
615+Reloading extensions does not close the session or replay earlier events.
616+
617+Every compaction attempt emits a matching `post_compact`, including empty
618+transcripts, failures, and cancellation. Status is `completed`, `failed`,
619+`cancelled`, or `timed_out`. Post counts describe the resulting in-memory
620+transcript; they do not assert that persistence succeeded. Token estimates
621+count serialized text bytes divided by four, not exact provider usage or image
622+tokens. The events do not let extensions modify the summary.
623+
624+Swarm events are emitted by the parent supervisor, including resumed tasks.
625+`session_id` identifies the task's parent conversation, even if the active
626+conversation later changes. `agent_id` survives resume; `agent_run_id` is fresh
627+for each local runner invocation and pairs start with stop. `name` currently
628+uses the agent ID. Stop statuses are `completed`, `failed`, `cancelled`, and
629+`timed_out`. Merely loading detached agents from disk does not replay lifecycle
630+events. Cancellation requests do not emit stop until the runner returns. On
631+interactive shutdown zot waits up to two seconds for local runners before
632+closing extensions; unresponsive runners may not produce a final notification.
633+
634+#### Ordering, delivery, and privacy
635+
636+For a normal client tool step, the observable sequence is:
637+
638+```text
639+user_prompt_submit
640+turn_start
641+assistant_message / tool_call
642+turn_end
643+synchronous tool interception
644+tool_confirmation_requested (when needed)
645+permission_decision
646+tool execution
647+tool_result
648+next turn_start (if continuing)
649+```
650+
651+This preserves the existing meaning of `turn_end`: it ends the model response,
652+not the tool batch. Multiple calls are announced before the batch executes;
653+each client call then gets its own permission and result events. Retries,
654+blocked turns, queued prompts, and concurrent swarm tasks can add or interleave
655+events; do not assume a globally linear workflow from the example alone.
656+
657+Frames are serialized per extension, including notifications and interception
658+requests. Notification enqueue does not wait for the subprocess. Each
659+extension has a bounded outbound queue (256 frames or 16 MiB of queued bytes,
660+plus one in-flight frame); overflow disconnects it rather than silently dropping
661+an event and continuing. Synchronous writes are bounded to five seconds.
662+Graceful shutdown attempts to drain queued frames, including `session_end`,
663+before sending `shutdown`, within the shutdown grace period.
664+
665+Delivery is best-effort, not a durable or exactly-once audit log. There is no
666+acknowledgement, replay, or crash recovery for notifications. A crash, forced
667+termination, slow consumer, or broken pipe can prevent delivery. The protocol's
668+4 MiB frame limit still applies. Slow event handlers can delay later
669+interception requests, so extension handlers should hand lengthy work to their
670+own bounded queue. Existing interception response and failure policies remain
671+unchanged.
672+
673+Prompts, effective tool arguments, outputs, paths, and error text may contain
674+secrets or private data. Only subscribe to content-bearing events you need.
675+There is no automatic secret redaction; subscriptions are not an extension
676+security sandbox. This surface is available where zot loads an extension
677+manager (interactive, print/stream/JSON, RPC, and swarm-child modes). It does
678+not add extension loading to other hosts or change the RPC client event schema.
679+
517680 #### `event_intercept`
518681
519682 Sent when zot wants to give the extension a chance to block, modify,
@@ -252,8 +252,12 @@ which it wants to intercept. Send once after `hello`, before `ready`.
252 "intercept":["tool_call","turn_start","assistant_message"]}252 "intercept":["tool_call","turn_start","assistant_message"]}
253 ```253 ```
254 254
255-Recognised event names: `session_start`, `turn_start`, `turn_end`,255+Recognised event names: `session_start`, `session_end`, `user_prompt_submit`,
256-`tool_call`, `tool_confirmation_requested`, `assistant_message`.256+`turn_start`, `turn_end`, `tool_call`, `tool_result`,
257+`tool_confirmation_requested`, `permission_decision`, `assistant_message`,
258+`pre_compact`, `post_compact`, `subagent_start`, `subagent_stop`.
259+The new lifecycle events are observational only; adding them to `intercept`
260+does not make them blocking hooks. See [Lifecycle payloads](#lifecycle-payloads).
257 261
258 `tool_confirmation_requested` fires only when zot is about to wait for262 `tool_confirmation_requested` fires only when zot is about to wait for
259 interactive approval. Calls running in yolo mode, calls covered by a263 interactive approval. Calls running in yolo mode, calls covered by a
@@ -503,7 +507,8 @@ marked as failed.
503 #### `event`507 #### `event`
504 508
505 Lifecycle notification for events the extension subscribed to via509 Lifecycle notification for events the extension subscribed to via
506-`subscribe`. One-way — no response expected.510+`subscribe`. One-way, no response expected. Fields are additive under protocol
511+version 1. Clients must tolerate unknown fields and event names.
507 512
508 ```json513 ```json
509 {"type":"event","event":"turn_start","step":1}514 {"type":"event","event":"turn_start","step":1}
@@ -514,6 +519,164 @@ Lifecycle notification for events the extension subscribed to via
514 {"type":"event","event":"turn_end","stop":"end_turn"}519 {"type":"event","event":"turn_end","stop":"end_turn"}
515 ```520 ```
516 521
522+### Lifecycle payloads
523+
524+Notifications include `session_id`, `cwd`, and a monotonically increasing
525+`sequence` scoped to the extension manager's lifetime. Sequence numbers span
526+all notifications, not just an extension's subscriptions, so gaps do not by
527+themselves indicate lost delivery. They reset when zot restarts. Correlate tool
528+outcomes with `(session_id, tool_id)`, compaction with `compaction_id`, and swarm
529+runs with `agent_run_id`. These are payload identifiers, not request/reply IDs.
530+
531+| Event | Additional payload | Meaning |
532+|---|---|---|
533+| `session_start` | No additional fields | An active conversation opens |
534+| `session_end` | `reason` | The active conversation closes |
535+| `user_prompt_submit` | `text`, optional `queued`, `image_count` | Model input accepted, before appending or queueing |
536+| `turn_start` | `step` | A model step begins |
537+| `turn_end` | `stop`, optional `error` | A model response attempt finishes, before client tool execution |
538+| `tool_call` | `tool_id`, `tool_name`, `tool_args` | The model's original proposed call |
539+| `tool_confirmation_requested` | `tool_id`, `tool_name`, `tool_preview` | Interactive approval is about to be requested |
540+| `permission_decision` | `tool_id`, `tool_name`, `decision`, `source`, `stage`, optional `reason` | An approval or policy decision was resolved |
541+| `tool_result` | `tool_id`, `tool_name`, `tool_args`, `status`, `executed`, `result` | A client tool call reached a terminal outcome |
542+| `assistant_message` | `text` | Visible assistant text |
543+| `pre_compact` | `compaction_id`, `message_count`, `token_estimate` | A compaction attempt begins |
544+| `post_compact` | Same counts and ID, `status`, optional `error` | A compaction attempt finishes, including failure |
545+| `subagent_start` | `agent_id`, `agent_run_id`, `name` | A local swarm runner starts or resumes |
546+| `subagent_stop` | Same identity, `status`, optional `error` | That local swarm runner returns |
547+
548+#### Prompt submission
549+
550+`text` is the input handed to the agent after host preprocessing, not raw editor
551+keystrokes. This includes extension-generated prompts and queued follow-ups.
552+Queued input emits once at acceptance with `queued:true`, not again at
553+consumption; it can subsequently be withdrawn without reaching the model.
554+Image bytes are not included; `image_count` reports attached images. An
555+image-only prompt may omit `text`. Slash commands that do not submit model
556+input, transcript replay, compaction summaries, and internal image mirrors do
557+not emit this event.
558+
559+#### Tool outcomes and permission decisions
560+
561+```json
562+{"type":"event","event":"tool_result","session_id":"session-1","cwd":"/work","sequence":12,"tool_id":"call-1","tool_name":"bash","tool_args":{"command":"go test ./..."},"status":"completed","executed":true,"result":{"content":[{"type":"text","text":"ok"}],"is_error":false}}
563+```
564+
565+`status` is `completed`, `failed`, `blocked`, `cancelled`, or `timed_out`.
566+Failures include unknown tools, panics, and tool error results. Guards and
567+built-in jail/manifest policy refusals are `blocked`. Cancellation and timeout
568+classification uses typed execution errors or explicit tool outcome metadata,
569+not error-message matching. A custom tool that reports only `is_error` without
570+such metadata is classified as `failed`.
571+
572+`tool_args` contains effective arguments, including accepted interceptor
573+rewrites and the empty-argument default `{}`. For a call refused before
574+execution, it contains the last proposed arguments instead. If the arguments
575+are malformed JSON, `tool_args_raw` contains their raw text and `tool_args` is
576+omitted, so the outcome remains a valid protocol frame. `executed` means
577+`Tool.Execute` was entered, not that a side effect occurred or was rolled back.
578+A tool-local policy refusal can therefore be `blocked` with `executed:true`.
579+Pending calls cancelled before execution still receive terminal results.
580+Provider-executed server tools do not receive client `tool_result` events.
581+
582+`result.content` uses text and base64 image blocks. Observation payloads have a
583+256 KiB combined text/base64/MIME-data budget and a 128-block limit. Text may be
584+shortened at a UTF-8 boundary; oversized images and unsupported block types are
585+omitted. `result.truncated:true` marks any omission. Tool execution, the model's
586+result, and the persisted transcript are not truncated by this observation
587+limit. Private `Details` and tool activation metadata are not exported.
588+
589+```json
590+{"type":"event","event":"permission_decision","session_id":"session-1","tool_id":"call-1","tool_name":"bash","decision":"approved","source":"user","stage":"pre_execution"}
591+```
592+
593+`decision` is `approved`, `denied`, or `cancelled`. `source` is `user`,
594+`remembered`, `policy`, or `yolo`. Automatic approval is represented by an
595+approved decision with its source, not a separate decision value. Guard
596+refusals, preview failures, and no available confirmer use `policy`.
597+
598+A `pre_execution` approval does not bypass tool-local checks. If a tool later
599+refuses access, an additional denied decision with `source:"policy"` and
600+`stage:"tool_execution"` precedes its blocked result. These events cannot grant
601+permission or override a refusal. Calls cancelled before checking permission
602+or rejected as unknown tools can have a result without a permission decision.
603+Existing mode-specific confirmation behavior is unchanged, including modes
604+that do not support interactive approval.
605+
606+#### Session, compaction, and swarm boundaries
607+
608+Sessions use the persisted conversation ID when available, otherwise a
609+runtime-generated ID. Interactive and RPC sessions start when initialized;
610+single-shot/headless sessions start when the agent first emits an observation.
611+Switching conversations emits the old session's end before the new start.
612+Current end reasons are `shutdown`, `session_switch`, and `cwd_change`.
613+`session_end` is emitted at most once per activation on these graceful paths.
614+Switching models or clearing messages does not close the active conversation.
615+Reloading extensions does not close the session or replay earlier events.
616+
617+Every compaction attempt emits a matching `post_compact`, including empty
618+transcripts, failures, and cancellation. Status is `completed`, `failed`,
619+`cancelled`, or `timed_out`. Post counts describe the resulting in-memory
620+transcript; they do not assert that persistence succeeded. Token estimates
621+count serialized text bytes divided by four, not exact provider usage or image
622+tokens. The events do not let extensions modify the summary.
623+
624+Swarm events are emitted by the parent supervisor, including resumed tasks.
625+`session_id` identifies the task's parent conversation, even if the active
626+conversation later changes. `agent_id` survives resume; `agent_run_id` is fresh
627+for each local runner invocation and pairs start with stop. `name` currently
628+uses the agent ID. Stop statuses are `completed`, `failed`, `cancelled`, and
629+`timed_out`. Merely loading detached agents from disk does not replay lifecycle
630+events. Cancellation requests do not emit stop until the runner returns. On
631+interactive shutdown zot waits up to two seconds for local runners before
632+closing extensions; unresponsive runners may not produce a final notification.
633+
634+#### Ordering, delivery, and privacy
635+
636+For a normal client tool step, the observable sequence is:
637+
638+```text
639+user_prompt_submit
640+turn_start
641+assistant_message / tool_call
642+turn_end
643+synchronous tool interception
644+tool_confirmation_requested (when needed)
645+permission_decision
646+tool execution
647+tool_result
648+next turn_start (if continuing)
649+```
650+
651+This preserves the existing meaning of `turn_end`: it ends the model response,
652+not the tool batch. Multiple calls are announced before the batch executes;
653+each client call then gets its own permission and result events. Retries,
654+blocked turns, queued prompts, and concurrent swarm tasks can add or interleave
655+events; do not assume a globally linear workflow from the example alone.
656+
657+Frames are serialized per extension, including notifications and interception
658+requests. Notification enqueue does not wait for the subprocess. Each
659+extension has a bounded outbound queue (256 frames or 16 MiB of queued bytes,
660+plus one in-flight frame); overflow disconnects it rather than silently dropping
661+an event and continuing. Synchronous writes are bounded to five seconds.
662+Graceful shutdown attempts to drain queued frames, including `session_end`,
663+before sending `shutdown`, within the shutdown grace period.
664+
665+Delivery is best-effort, not a durable or exactly-once audit log. There is no
666+acknowledgement, replay, or crash recovery for notifications. A crash, forced
667+termination, slow consumer, or broken pipe can prevent delivery. The protocol's
668+4 MiB frame limit still applies. Slow event handlers can delay later
669+interception requests, so extension handlers should hand lengthy work to their
670+own bounded queue. Existing interception response and failure policies remain
671+unchanged.
672+
673+Prompts, effective tool arguments, outputs, paths, and error text may contain
674+secrets or private data. Only subscribe to content-bearing events you need.
675+There is no automatic secret redaction; subscriptions are not an extension
676+security sandbox. This surface is available where zot loads an extension
677+manager (interactive, print/stream/JSON, RPC, and swarm-child modes). It does
678+not add extension loading to other hosts or change the RPC client event schema.
679+
517 #### `event_intercept`680 #### `event_intercept`
518 681
519 Sent when zot wants to give the extension a chance to block, modify,682 Sent when zot wants to give the extension a chance to block, modify,
M packages/agent/before_start.go +4 -1
@@ -14,7 +14,10 @@ func wireBeforeAgentStart(ag *core.Agent, mgr *extensions.Manager, provider stri
1414 ag.BeforeStart = func(ctx context.Context, system string) string {
1515 session := ag.SessionID
1616 if session == "" {
17- session = runtimeSession
17+ session, _ = mgr.SessionContext()
18+ if session == "" {
19+ session = runtimeSession
20+ }
1821 }
1922 return mgr.InterceptBeforeAgentStart(ctx, extproto.EventInterceptFromHost{
2023 SystemPrompt: &system, SessionID: session, AgentRunID: rand.Text(),
@@ -14,7 +14,10 @@ func wireBeforeAgentStart(ag *core.Agent, mgr *extensions.Manager, provider stri
14 ag.BeforeStart = func(ctx context.Context, system string) string {14 ag.BeforeStart = func(ctx context.Context, system string) string {
15 session := ag.SessionID15 session := ag.SessionID
16 if session == "" {16 if session == "" {
17- session = runtimeSession17+ session, _ = mgr.SessionContext()
18+ if session == "" {
19+ session = runtimeSession
20+ }
18 }21 }
19 return mgr.InterceptBeforeAgentStart(ctx, extproto.EventInterceptFromHost{22 return mgr.InterceptBeforeAgentStart(ctx, extproto.EventInterceptFromHost{
20 SystemPrompt: &system, SessionID: session, AgentRunID: rand.Text(),23 SystemPrompt: &system, SessionID: session, AgentRunID: rand.Text(),
M packages/agent/cli.go +48 -22
@@ -165,6 +165,19 @@ func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
165165 return
166166 }
167167 switch e := ev.(type) {
168+ case core.EvPromptSubmit:
169+ mgr.EmitEvent(extproto.EventFromHost{Event: "user_prompt_submit", Text: e.Text, Queued: e.Queued, ImageCount: e.ImageCount})
170+ case core.EvToolResult:
171+ if e.Executed && e.Status == "blocked" {
172+ mgr.EmitEvent(extproto.EventFromHost{Event: "permission_decision", ToolID: e.ID, ToolName: e.Name, Decision: "denied", Source: "policy", Stage: "tool_execution"})
173+ }
174+ mgr.EmitEvent(extproto.EventFromHost{Event: "tool_result", ToolID: e.ID, ToolName: e.Name, ToolArgs: e.Args, Status: e.Status, Executed: &e.Executed, Result: extensionEventResult(e.Result)})
175+ case core.EvCompact:
176+ event := extproto.EventFromHost{Event: e.Type(), CompactionID: e.ID, MessageCount: &e.MessageCount, TokenEstimate: &e.TokenEstimate, Status: e.Status}
177+ if e.Err != nil {
178+ event.Error = e.Err.Error()
179+ }
180+ mgr.EmitEvent(event)
168181 case core.EvTurnStart:
169182 mgr.EmitEvent(extproto.EventFromHost{Event: "turn_start", Step: e.Step})
170183 case core.EvToolCall:
@@ -308,7 +321,6 @@ func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved,
308321 }
309322 extMgr.WaitForReady(3 * time.Second)
310323 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
311- extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
312324 return extMgr, func() { extMgr.Stop(2 * time.Second) }
313325 }
314326
@@ -327,11 +339,13 @@ func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr
327339 return
328340 }
329341 wireBeforeAgentStart(ag, extMgr, "")
330- ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
342+ ag.BeforeToolExecuteContext = func(ctx context.Context, call provider.ToolCallBlock) (bool, string, json.RawMessage) {
331343 res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
332344 if res.Block {
333- return false, res.Reason, nil
345+ emitPermissionDecision(ctx, extMgr, call, core.ConfirmDecision{Source: "policy", Reason: res.Reason})
346+ return false, res.Reason, res.ModifiedArgs
334347 }
348+ emitPermissionDecision(ctx, extMgr, call, core.ConfirmDecision{Allow: true, Source: "yolo"})
335349 return true, "", res.ModifiedArgs
336350 }
337351 ag.BeforeTurn = func(step int) (bool, string) {
@@ -345,7 +359,7 @@ func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr
345359 }
346360 return true, "", res.ReplaceText
347361 }
348- ag.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
362+ wireLifecycleEvents(ag, extMgr)
349363 }
350364
351365 type printStats struct {
@@ -709,8 +723,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
709723 // in this outer scope rather than scoping it tighter.
710724 var swarmMgr *swarm.Swarm
711725 swarmMgr = swarm.New(swarm.Config{
712- Root: filepath.Join(ZotHome(), "swarm"),
713- RepoRoot: r.CWD,
726+ Root: filepath.Join(ZotHome(), "swarm"),
727+ RepoRoot: r.CWD,
728+ OnLifecycle: func(e swarm.LifecycleEvent) { emitSwarmLifecycle(extMgr, e) },
714729 ResolveCredential: func(ctx context.Context, providerID string) (swarm.Credential, error) {
715730 if providerID == "ollama" {
716731 return swarm.Credential{Value: "ollama", Method: "apikey"}, nil
@@ -781,13 +796,14 @@ func runInteractive(ctx context.Context, args Args, version string) error {
781796 wireAgentExt := func(resolved Resolved) *core.Agent {
782797 a := resolved.NewAgent()
783798 wireBeforeAgentStart(a, extMgr, resolved.Provider)
784- a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
799+ a.BeforeToolExecuteContext = func(ctx context.Context, call provider.ToolCallBlock) (bool, string, json.RawMessage) {
785800 // Guards run before confirmation because they may rewrite the
786801 // arguments. The user must approve the effective call that will
787802 // actually execute, not the model's original arguments.
788803 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
789804 if r.Block {
790- return false, r.Reason, nil
805+ emitPermissionDecision(ctx, extMgr, call, core.ConfirmDecision{Source: "policy", Reason: r.Reason})
806+ return false, r.Reason, r.ModifiedArgs
791807 }
792808 effectiveArgs := call.Arguments
793809 if len(r.ModifiedArgs) > 0 && json.Valid(r.ModifiedArgs) {
@@ -799,7 +815,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
799815 if previewer, ok := tool.(core.ToolPreviewer); ok {
800816 preview, err := previewer.Preview(ctx, effectiveArgs)
801817 if err != nil {
802- return false, err.Error(), nil
818+ emitPermissionDecision(ctx, extMgr, call, core.ConfirmDecision{Source: "policy", Reason: err.Error()})
819+ return false, err.Error(), effectiveArgs
803820 }
804821 for _, block := range preview.Content {
805822 if text, ok := block.(provider.TextBlock); ok {
@@ -811,15 +828,18 @@ func runInteractive(ctx context.Context, args Args, version string) error {
811828 }
812829 }
813830 }
814- ok, reason, _ := confirmGate.CheckToolCall(core.ToolCallConfirmation{
831+ decision := confirmGate.DecideToolCall(core.ToolCallConfirmation{
815832 ID: call.ID,
816833 Name: call.Name,
817834 Summary: core.BuildPreview(effectiveArgs, 120),
818835 Content: content.String(),
819836 })
820- if !ok {
821- return false, reason, nil
837+ emitPermissionDecision(ctx, extMgr, call, decision)
838+ if !decision.Allow {
839+ return false, decision.Reason, effectiveArgs
822840 }
841+ } else {
842+ emitPermissionDecision(ctx, extMgr, call, core.ConfirmDecision{Allow: true, Source: "yolo"})
823843 }
824844 return true, "", r.ModifiedArgs
825845 }
@@ -834,7 +854,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
834854 }
835855 return true, "", r.ReplaceText
836856 }
837- a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
857+ wireLifecycleEvents(a, extMgr)
838858 return a
839859 }
840860
@@ -914,9 +934,6 @@ func runInteractive(ctx context.Context, args Args, version string) error {
914934 refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
915935 })
916936
917- // Fire session_start once we know the manager's running.
918- extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
919-
920937 var sess *core.Session
921938 var sessBaselineMsgs int // messages already on disk when current session opened
922939 // persistMu guards sess + sessBaselineMsgs against concurrent access
@@ -931,6 +948,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
931948 sessBaselineMsgs = len(ag.Messages())
932949 }
933950 }
951+ startExtensionSession(extMgr, ag, r.CWD, "")
934952 defer func() {
935953 persistMu.Lock()
936954 defer persistMu.Unlock()
@@ -1042,6 +1060,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
10421060 sess = newSess
10431061 currentAg.SetMessages(msgs)
10441062 bindAgentSession(currentAg, sess)
1063+ startExtensionSession(extMgr, currentAg, r.CWD, "session_switch")
10451064 if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil {
10461065 currentAg.SeedCost(cum)
10471066 currentAg.SeedLastTurnUsage(last)
@@ -1133,6 +1152,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11331152 }
11341153 sessBaselineMsgs = 0
11351154 persistMu.Unlock()
1155+ extMgr.EndSession("cwd_change")
11361156
11371157 // Mutate captured state so subsequent agent rebuilds and
11381158 // session opens see the new cwd.
@@ -1178,6 +1198,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11781198 bindAgentSession(newAg, newSess)
11791199 }
11801200
1201+ startExtensionSession(extMgr, newAg, absPath, "cwd_change")
1202+
11811203 // Push the new state into the running Interactive.
11821204 if iv != nil {
11831205 startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZotHome()))
@@ -1185,8 +1207,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11851207 }
11861208
11871209 // Re-scope the swarm dashboard to the new session.
1188- if swarmMgr != nil && sess != nil {
1189- swarmMgr.SetActiveSession(sess.ID)
1210+ if swarmMgr != nil {
1211+ sessionID, _ := extMgr.SessionContext()
1212+ swarmMgr.SetActiveSession(sessionID)
11901213 }
11911214 return nil
11921215 }
@@ -1261,12 +1284,15 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12611284 // shows agents this session spawned (and any pre-upgrade unscoped
12621285 // agents — see SnapshotAll docs). Updated again whenever the
12631286 // user swaps sessions via loadSession below.
1264- if sess != nil {
1265- swarmMgr.SetActiveSession(sess.ID)
1266- }
1287+ sessionID, _ := extMgr.SessionContext()
1288+ swarmMgr.SetActiveSession(sessionID)
12671289 // Best-effort shutdown on interactive exit: stop all running
12681290 // agents so they don't outlive their parent zot.
1269- defer swarmMgr.StopAll()
1291+ defer func() {
1292+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
1293+ defer cancel()
1294+ swarmMgr.Shutdown(shutdownCtx)
1295+ }()
12701296
12711297 var startupSkills []*skills.Skill
12721298 if r.SkillTool != nil {
@@ -165,6 +165,19 @@ func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
165 return165 return
166 }166 }
167 switch e := ev.(type) {167 switch e := ev.(type) {
168+ case core.EvPromptSubmit:
169+ mgr.EmitEvent(extproto.EventFromHost{Event: "user_prompt_submit", Text: e.Text, Queued: e.Queued, ImageCount: e.ImageCount})
170+ case core.EvToolResult:
171+ if e.Executed && e.Status == "blocked" {
172+ mgr.EmitEvent(extproto.EventFromHost{Event: "permission_decision", ToolID: e.ID, ToolName: e.Name, Decision: "denied", Source: "policy", Stage: "tool_execution"})
173+ }
174+ mgr.EmitEvent(extproto.EventFromHost{Event: "tool_result", ToolID: e.ID, ToolName: e.Name, ToolArgs: e.Args, Status: e.Status, Executed: &e.Executed, Result: extensionEventResult(e.Result)})
175+ case core.EvCompact:
176+ event := extproto.EventFromHost{Event: e.Type(), CompactionID: e.ID, MessageCount: &e.MessageCount, TokenEstimate: &e.TokenEstimate, Status: e.Status}
177+ if e.Err != nil {
178+ event.Error = e.Err.Error()
179+ }
180+ mgr.EmitEvent(event)
168 case core.EvTurnStart:181 case core.EvTurnStart:
169 mgr.EmitEvent(extproto.EventFromHost{Event: "turn_start", Step: e.Step})182 mgr.EmitEvent(extproto.EventFromHost{Event: "turn_start", Step: e.Step})
170 case core.EvToolCall:183 case core.EvToolCall:
@@ -308,7 +321,6 @@ func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved,
308 }321 }
309 extMgr.WaitForReady(3 * time.Second)322 extMgr.WaitForReady(3 * time.Second)
310 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})323 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
311- extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
312 return extMgr, func() { extMgr.Stop(2 * time.Second) }324 return extMgr, func() { extMgr.Stop(2 * time.Second) }
313 }325 }
314 326
@@ -327,11 +339,13 @@ func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr
327 return339 return
328 }340 }
329 wireBeforeAgentStart(ag, extMgr, "")341 wireBeforeAgentStart(ag, extMgr, "")
330- ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {342+ ag.BeforeToolExecuteContext = func(ctx context.Context, call provider.ToolCallBlock) (bool, string, json.RawMessage) {
331 res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)343 res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
332 if res.Block {344 if res.Block {
333- return false, res.Reason, nil345+ emitPermissionDecision(ctx, extMgr, call, core.ConfirmDecision{Source: "policy", Reason: res.Reason})
346+ return false, res.Reason, res.ModifiedArgs
334 }347 }
348+ emitPermissionDecision(ctx, extMgr, call, core.ConfirmDecision{Allow: true, Source: "yolo"})
335 return true, "", res.ModifiedArgs349 return true, "", res.ModifiedArgs
336 }350 }
337 ag.BeforeTurn = func(step int) (bool, string) {351 ag.BeforeTurn = func(step int) (bool, string) {
@@ -345,7 +359,7 @@ func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr
345 }359 }
346 return true, "", res.ReplaceText360 return true, "", res.ReplaceText
347 }361 }
348- ag.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }362+ wireLifecycleEvents(ag, extMgr)
349 }363 }
350 364
351 type printStats struct {365 type printStats struct {
@@ -709,8 +723,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
709 // in this outer scope rather than scoping it tighter.723 // in this outer scope rather than scoping it tighter.
710 var swarmMgr *swarm.Swarm724 var swarmMgr *swarm.Swarm
711 swarmMgr = swarm.New(swarm.Config{725 swarmMgr = swarm.New(swarm.Config{
712- Root: filepath.Join(ZotHome(), "swarm"),726+ Root: filepath.Join(ZotHome(), "swarm"),
713- RepoRoot: r.CWD,727+ RepoRoot: r.CWD,
728+ OnLifecycle: func(e swarm.LifecycleEvent) { emitSwarmLifecycle(extMgr, e) },
714 ResolveCredential: func(ctx context.Context, providerID string) (swarm.Credential, error) {729 ResolveCredential: func(ctx context.Context, providerID string) (swarm.Credential, error) {
715 if providerID == "ollama" {730 if providerID == "ollama" {
716 return swarm.Credential{Value: "ollama", Method: "apikey"}, nil731 return swarm.Credential{Value: "ollama", Method: "apikey"}, nil
@@ -781,13 +796,14 @@ func runInteractive(ctx context.Context, args Args, version string) error {
781 wireAgentExt := func(resolved Resolved) *core.Agent {796 wireAgentExt := func(resolved Resolved) *core.Agent {
782 a := resolved.NewAgent()797 a := resolved.NewAgent()
783 wireBeforeAgentStart(a, extMgr, resolved.Provider)798 wireBeforeAgentStart(a, extMgr, resolved.Provider)
784- a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {799+ a.BeforeToolExecuteContext = func(ctx context.Context, call provider.ToolCallBlock) (bool, string, json.RawMessage) {
785 // Guards run before confirmation because they may rewrite the800 // Guards run before confirmation because they may rewrite the
786 // arguments. The user must approve the effective call that will801 // arguments. The user must approve the effective call that will
787 // actually execute, not the model's original arguments.802 // actually execute, not the model's original arguments.
788 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)803 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
789 if r.Block {804 if r.Block {
790- return false, r.Reason, nil805+ emitPermissionDecision(ctx, extMgr, call, core.ConfirmDecision{Source: "policy", Reason: r.Reason})
806+ return false, r.Reason, r.ModifiedArgs
791 }807 }
792 effectiveArgs := call.Arguments808 effectiveArgs := call.Arguments
793 if len(r.ModifiedArgs) > 0 && json.Valid(r.ModifiedArgs) {809 if len(r.ModifiedArgs) > 0 && json.Valid(r.ModifiedArgs) {
@@ -799,7 +815,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
799 if previewer, ok := tool.(core.ToolPreviewer); ok {815 if previewer, ok := tool.(core.ToolPreviewer); ok {
800 preview, err := previewer.Preview(ctx, effectiveArgs)816 preview, err := previewer.Preview(ctx, effectiveArgs)
801 if err != nil {817 if err != nil {
802- return false, err.Error(), nil818+ emitPermissionDecision(ctx, extMgr, call, core.ConfirmDecision{Source: "policy", Reason: err.Error()})
819+ return false, err.Error(), effectiveArgs
803 }820 }
804 for _, block := range preview.Content {821 for _, block := range preview.Content {
805 if text, ok := block.(provider.TextBlock); ok {822 if text, ok := block.(provider.TextBlock); ok {
@@ -811,15 +828,18 @@ func runInteractive(ctx context.Context, args Args, version string) error {
811 }828 }
812 }829 }
813 }830 }
814- ok, reason, _ := confirmGate.CheckToolCall(core.ToolCallConfirmation{831+ decision := confirmGate.DecideToolCall(core.ToolCallConfirmation{
815 ID: call.ID,832 ID: call.ID,
816 Name: call.Name,833 Name: call.Name,
817 Summary: core.BuildPreview(effectiveArgs, 120),834 Summary: core.BuildPreview(effectiveArgs, 120),
818 Content: content.String(),835 Content: content.String(),
819 })836 })
820- if !ok {837+ emitPermissionDecision(ctx, extMgr, call, decision)
821- return false, reason, nil838+ if !decision.Allow {
839+ return false, decision.Reason, effectiveArgs
822 }840 }
841+ } else {
842+ emitPermissionDecision(ctx, extMgr, call, core.ConfirmDecision{Allow: true, Source: "yolo"})
823 }843 }
824 return true, "", r.ModifiedArgs844 return true, "", r.ModifiedArgs
825 }845 }
@@ -834,7 +854,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
834 }854 }
835 return true, "", r.ReplaceText855 return true, "", r.ReplaceText
836 }856 }
837- a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }857+ wireLifecycleEvents(a, extMgr)
838 return a858 return a
839 }859 }
840 860
@@ -914,9 +934,6 @@ func runInteractive(ctx context.Context, args Args, version string) error {
914 refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)934 refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
915 })935 })
916 936
917- // Fire session_start once we know the manager's running.
918- extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
919-
920 var sess *core.Session937 var sess *core.Session
921 var sessBaselineMsgs int // messages already on disk when current session opened938 var sessBaselineMsgs int // messages already on disk when current session opened
922 // persistMu guards sess + sessBaselineMsgs against concurrent access939 // persistMu guards sess + sessBaselineMsgs against concurrent access
@@ -931,6 +948,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
931 sessBaselineMsgs = len(ag.Messages())948 sessBaselineMsgs = len(ag.Messages())
932 }949 }
933 }950 }
951+ startExtensionSession(extMgr, ag, r.CWD, "")
934 defer func() {952 defer func() {
935 persistMu.Lock()953 persistMu.Lock()
936 defer persistMu.Unlock()954 defer persistMu.Unlock()
@@ -1042,6 +1060,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1042 sess = newSess1060 sess = newSess
1043 currentAg.SetMessages(msgs)1061 currentAg.SetMessages(msgs)
1044 bindAgentSession(currentAg, sess)1062 bindAgentSession(currentAg, sess)
1063+ startExtensionSession(extMgr, currentAg, r.CWD, "session_switch")
1045 if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil {1064 if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil {
1046 currentAg.SeedCost(cum)1065 currentAg.SeedCost(cum)
1047 currentAg.SeedLastTurnUsage(last)1066 currentAg.SeedLastTurnUsage(last)
@@ -1133,6 +1152,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1133 }1152 }
1134 sessBaselineMsgs = 01153 sessBaselineMsgs = 0
1135 persistMu.Unlock()1154 persistMu.Unlock()
1155+ extMgr.EndSession("cwd_change")
1136 1156
1137 // Mutate captured state so subsequent agent rebuilds and1157 // Mutate captured state so subsequent agent rebuilds and
1138 // session opens see the new cwd.1158 // session opens see the new cwd.
@@ -1178,6 +1198,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1178 bindAgentSession(newAg, newSess)1198 bindAgentSession(newAg, newSess)
1179 }1199 }
1180 1200
1201+ startExtensionSession(extMgr, newAg, absPath, "cwd_change")
1202+
1181 // Push the new state into the running Interactive.1203 // Push the new state into the running Interactive.
1182 if iv != nil {1204 if iv != nil {
1183 startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZotHome()))1205 startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZotHome()))
@@ -1185,8 +1207,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1185 }1207 }
1186 1208
1187 // Re-scope the swarm dashboard to the new session.1209 // Re-scope the swarm dashboard to the new session.
1188- if swarmMgr != nil && sess != nil {1210+ if swarmMgr != nil {
1189- swarmMgr.SetActiveSession(sess.ID)1211+ sessionID, _ := extMgr.SessionContext()
1212+ swarmMgr.SetActiveSession(sessionID)
1190 }1213 }
1191 return nil1214 return nil
1192 }1215 }
@@ -1261,12 +1284,15 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1261 // shows agents this session spawned (and any pre-upgrade unscoped1284 // shows agents this session spawned (and any pre-upgrade unscoped
1262 // agents — see SnapshotAll docs). Updated again whenever the1285 // agents — see SnapshotAll docs). Updated again whenever the
1263 // user swaps sessions via loadSession below.1286 // user swaps sessions via loadSession below.
1264- if sess != nil {1287+ sessionID, _ := extMgr.SessionContext()
1265- swarmMgr.SetActiveSession(sess.ID)1288+ swarmMgr.SetActiveSession(sessionID)
1266- }
1267 // Best-effort shutdown on interactive exit: stop all running1289 // Best-effort shutdown on interactive exit: stop all running
1268 // agents so they don't outlive their parent zot.1290 // agents so they don't outlive their parent zot.
1269- defer swarmMgr.StopAll()1291+ defer func() {
1292+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
1293+ defer cancel()
1294+ swarmMgr.Shutdown(shutdownCtx)
1295+ }()
1270 1296
1271 var startupSkills []*skills.Skill1297 var startupSkills []*skills.Skill
1272 if r.SkillTool != nil {1298 if r.SkillTool != nil {
M packages/agent/ext/ext.go +50 -11
@@ -63,17 +63,49 @@ type ToolHandler func(args json.RawMessage) ToolResult
6363 // worker.
6464 type EventHandler func(ev Event)
6565
66-// Event is a lifecycle notification from zot. The fields populated
67-// depend on Name (the host's event_name string):
66+// EventResult contains bounded text/base64-image tool output for observers.
67+// Truncated reports omitted or shortened content, not execution failure.
68+type EventResult = extproto.EventResult
69+
70+// Event is an observational lifecycle notification. SessionID, CWD and Sequence
71+// identify its context. Name selects the remaining payload:
6872 //
69-// session_start : (no extra fields)
70-// turn_start : Step
71-// turn_end : Stop, optional Error
72-// tool_call : ToolID, ToolName, ToolArgs
73+// session_start: no additional fields
74+// session_end: Reason
75+// user_prompt_submit: Text, Queued, ImageCount
76+// turn_start: Step
77+// turn_end: Stop, Error (model-response boundary, before client tools)
78+// tool_call: ToolID, ToolName, ToolArgs (original arguments)
79+// tool_result: ToolID, ToolName, ToolArgs (effective), Status, Executed, Result
7380 // tool_confirmation_requested: ToolID, ToolName, ToolPreview
74-// assistant_message : Text
81+// permission_decision: ToolID, ToolName, Decision, Source, Stage, Reason
82+// assistant_message: Text
83+// pre_compact, post_compact: CompactionID, MessageCount, TokenEstimate, Status, Error
84+// subagent_start, subagent_stop: AgentID, AgentRunID, AgentName, Status, Error
85+//
86+// Result and count pointers distinguish absent fields from false/zero values.
87+// Notifications are best-effort, not a durable audit log. See docs/extensions.md
88+// for payload limits, privacy considerations, and delivery semantics.
7589 type Event struct {
76- Name string
90+ Name string
91+ AgentRunID string
92+ SessionID string
93+ Queued bool
94+ ImageCount int
95+ CWD string
96+ Sequence uint64
97+ Status string
98+ Reason string
99+ Source string
100+ Decision string
101+ Stage string
102+ Result *EventResult
103+ Executed *bool
104+ CompactionID string
105+ MessageCount *int
106+ TokenEstimate *int
107+ AgentID string
108+ AgentName string
77109
78110 Step int
79111 Stop string
@@ -83,6 +115,7 @@ type Event struct {
83115 ToolName string
84116 ToolArgs json.RawMessage
85117 ToolPreview string
118+ ToolArgsRaw string
86119
87120 Text string
88121 }
@@ -426,8 +459,9 @@ func (e *Extension) registerTool(name, description string, schema json.RawMessag
426459 // On subscribes to a lifecycle event. fn is called for each
427460 // notification; the same name can only have one handler (later
428461 // registrations replace earlier ones). Recognised names:
429-// session_start, turn_start, turn_end, tool_call,
430-// tool_confirmation_requested, assistant_message.
462+// session_start, session_end, user_prompt_submit, turn_start, turn_end,
463+// tool_call, tool_result, tool_confirmation_requested, permission_decision,
464+// assistant_message, pre_compact, post_compact, subagent_start, subagent_stop.
431465 func (e *Extension) On(name string, fn EventHandler) {
432466 e.mu.Lock()
433467 if _, exists := e.eventHandlers[name]; !exists {
@@ -669,8 +703,13 @@ func (e *Extension) Run() error {
669703 }()
670704 handler(Event{
671705 Name: ef.Event, Step: ef.Step, Stop: ef.Stop,
706+ SessionID: ef.SessionID, AgentRunID: ef.AgentRunID, Queued: ef.Queued, ImageCount: ef.ImageCount, CWD: ef.CWD, Sequence: ef.Sequence,
707+ Status: ef.Status, Reason: ef.Reason, Source: ef.Source, Decision: ef.Decision, Stage: ef.Stage,
708+ Result: ef.Result, Executed: ef.Executed, CompactionID: ef.CompactionID,
709+ MessageCount: ef.MessageCount, TokenEstimate: ef.TokenEstimate,
710+ AgentID: ef.AgentID, AgentName: ef.Name,
672711 Error: ef.Error, ToolID: ef.ToolID, ToolName: ef.ToolName,
673- ToolArgs: ef.ToolArgs, ToolPreview: ef.ToolPreview, Text: ef.Text,
712+ ToolArgs: ef.ToolArgs, ToolArgsRaw: ef.ToolArgsRaw, ToolPreview: ef.ToolPreview, Text: ef.Text,
674713 })
675714 }()
676715 }
@@ -63,17 +63,49 @@ type ToolHandler func(args json.RawMessage) ToolResult
63 // worker.63 // worker.
64 type EventHandler func(ev Event)64 type EventHandler func(ev Event)
65 65
66-// Event is a lifecycle notification from zot. The fields populated66+// EventResult contains bounded text/base64-image tool output for observers.
67-// depend on Name (the host's event_name string):67+// Truncated reports omitted or shortened content, not execution failure.
68+type EventResult = extproto.EventResult
69+
70+// Event is an observational lifecycle notification. SessionID, CWD and Sequence
71+// identify its context. Name selects the remaining payload:
68 //72 //
69-// session_start : (no extra fields)73+// session_start: no additional fields
70-// turn_start : Step74+// session_end: Reason
71-// turn_end : Stop, optional Error75+// user_prompt_submit: Text, Queued, ImageCount
72-// tool_call : ToolID, ToolName, ToolArgs76+// turn_start: Step
77+// turn_end: Stop, Error (model-response boundary, before client tools)
78+// tool_call: ToolID, ToolName, ToolArgs (original arguments)
79+// tool_result: ToolID, ToolName, ToolArgs (effective), Status, Executed, Result
73 // tool_confirmation_requested: ToolID, ToolName, ToolPreview80 // tool_confirmation_requested: ToolID, ToolName, ToolPreview
74-// assistant_message : Text81+// permission_decision: ToolID, ToolName, Decision, Source, Stage, Reason
82+// assistant_message: Text
83+// pre_compact, post_compact: CompactionID, MessageCount, TokenEstimate, Status, Error
84+// subagent_start, subagent_stop: AgentID, AgentRunID, AgentName, Status, Error
85+//
86+// Result and count pointers distinguish absent fields from false/zero values.
87+// Notifications are best-effort, not a durable audit log. See docs/extensions.md
88+// for payload limits, privacy considerations, and delivery semantics.
75 type Event struct {89 type Event struct {
76- Name string90+ Name string
91+ AgentRunID string
92+ SessionID string
93+ Queued bool
94+ ImageCount int
95+ CWD string
96+ Sequence uint64
97+ Status string
98+ Reason string
99+ Source string
100+ Decision string
101+ Stage string
102+ Result *EventResult
103+ Executed *bool
104+ CompactionID string
105+ MessageCount *int
106+ TokenEstimate *int
107+ AgentID string
108+ AgentName string
77 109
78 Step int110 Step int
79 Stop string111 Stop string
@@ -83,6 +115,7 @@ type Event struct {
83 ToolName string115 ToolName string
84 ToolArgs json.RawMessage116 ToolArgs json.RawMessage
85 ToolPreview string117 ToolPreview string
118+ ToolArgsRaw string
86 119
87 Text string120 Text string
88 }121 }
@@ -426,8 +459,9 @@ func (e *Extension) registerTool(name, description string, schema json.RawMessag
426 // On subscribes to a lifecycle event. fn is called for each459 // On subscribes to a lifecycle event. fn is called for each
427 // notification; the same name can only have one handler (later460 // notification; the same name can only have one handler (later
428 // registrations replace earlier ones). Recognised names:461 // registrations replace earlier ones). Recognised names:
429-// session_start, turn_start, turn_end, tool_call,462+// session_start, session_end, user_prompt_submit, turn_start, turn_end,
430-// tool_confirmation_requested, assistant_message.463+// tool_call, tool_result, tool_confirmation_requested, permission_decision,
464+// assistant_message, pre_compact, post_compact, subagent_start, subagent_stop.
431 func (e *Extension) On(name string, fn EventHandler) {465 func (e *Extension) On(name string, fn EventHandler) {
432 e.mu.Lock()466 e.mu.Lock()
433 if _, exists := e.eventHandlers[name]; !exists {467 if _, exists := e.eventHandlers[name]; !exists {
@@ -669,8 +703,13 @@ func (e *Extension) Run() error {
669 }()703 }()
670 handler(Event{704 handler(Event{
671 Name: ef.Event, Step: ef.Step, Stop: ef.Stop,705 Name: ef.Event, Step: ef.Step, Stop: ef.Stop,
706+ SessionID: ef.SessionID, AgentRunID: ef.AgentRunID, Queued: ef.Queued, ImageCount: ef.ImageCount, CWD: ef.CWD, Sequence: ef.Sequence,
707+ Status: ef.Status, Reason: ef.Reason, Source: ef.Source, Decision: ef.Decision, Stage: ef.Stage,
708+ Result: ef.Result, Executed: ef.Executed, CompactionID: ef.CompactionID,
709+ MessageCount: ef.MessageCount, TokenEstimate: ef.TokenEstimate,
710+ AgentID: ef.AgentID, AgentName: ef.Name,
672 Error: ef.Error, ToolID: ef.ToolID, ToolName: ef.ToolName,711 Error: ef.Error, ToolID: ef.ToolID, ToolName: ef.ToolName,
673- ToolArgs: ef.ToolArgs, ToolPreview: ef.ToolPreview, Text: ef.Text,712+ ToolArgs: ef.ToolArgs, ToolArgsRaw: ef.ToolArgsRaw, ToolPreview: ef.ToolPreview, Text: ef.Text,
674 })713 })
675 }()714 }()
676 }715 }
A packages/agent/ext/lifecycle_test.go +36 -0
@@ -0,0 +1,36 @@
1+package ext
2+
3+import (
4+ "encoding/json"
5+ "reflect"
6+ "testing"
7+ "time"
8+
9+ "github.com/patriceckhart/zot/packages/agent/extproto"
10+)
11+
12+func TestLifecycleEventFields(t *testing.T) {
13+ h := newHarness("lifecycle")
14+ defer h.hostW.Close()
15+ names := []string{"user_prompt_submit", "tool_result", "session_end", "pre_compact", "post_compact", "subagent_start", "subagent_stop", "permission_decision"}
16+ received := make(chan Event, len(names))
17+ for _, name := range names {
18+ h.ext.On(name, func(e Event) { received <- e })
19+ }
20+ go h.ext.Run()
21+ h.handshake(t)
22+ count, estimate, executed := 0, 10, false
23+ result := &extproto.EventResult{Content: []extproto.ContentBlock{{Type: "text", Text: "output"}}, IsError: true, Truncated: true}
24+ for _, name := range names {
25+ h.sendToExt(t, extproto.EventFromHost{Type: "event", Event: name, SessionID: "session", AgentRunID: "run", CWD: "/work", Sequence: 42, Queued: true, ImageCount: 1, Status: "blocked", Reason: "reason", Source: "policy", Decision: "denied", Stage: "pre_execution", Result: result, Executed: &executed, CompactionID: "compact", MessageCount: &count, TokenEstimate: &estimate, AgentID: "child", Name: "reviewer", ToolID: "call", ToolName: "bash", ToolArgs: json.RawMessage(`{}`), Text: "prompt"})
26+ select {
27+ case e := <-received:
28+ want := Event{Name: name, SessionID: "session", AgentRunID: "run", CWD: "/work", Sequence: 42, Queued: true, ImageCount: 1, Status: "blocked", Reason: "reason", Source: "policy", Decision: "denied", Stage: "pre_execution", Result: result, Executed: &executed, CompactionID: "compact", MessageCount: &count, TokenEstimate: &estimate, AgentID: "child", AgentName: "reviewer", ToolID: "call", ToolName: "bash", ToolArgs: json.RawMessage(`{}`), Text: "prompt"}
29+ if !reflect.DeepEqual(e, want) {
30+ t.Fatalf("event = %+v, want %+v", e, want)
31+ }
32+ case <-time.After(time.Second):
33+ t.Fatal("missing event")
34+ }
35+ }
36+}
@@ -0,0 +1,36 @@
1+package ext
2+
3+import (
4+ "encoding/json"
5+ "reflect"
6+ "testing"
7+ "time"
8+
9+ "github.com/patriceckhart/zot/packages/agent/extproto"
10+)
11+
12+func TestLifecycleEventFields(t *testing.T) {
13+ h := newHarness("lifecycle")
14+ defer h.hostW.Close()
15+ names := []string{"user_prompt_submit", "tool_result", "session_end", "pre_compact", "post_compact", "subagent_start", "subagent_stop", "permission_decision"}
16+ received := make(chan Event, len(names))
17+ for _, name := range names {
18+ h.ext.On(name, func(e Event) { received <- e })
19+ }
20+ go h.ext.Run()
21+ h.handshake(t)
22+ count, estimate, executed := 0, 10, false
23+ result := &extproto.EventResult{Content: []extproto.ContentBlock{{Type: "text", Text: "output"}}, IsError: true, Truncated: true}
24+ for _, name := range names {
25+ h.sendToExt(t, extproto.EventFromHost{Type: "event", Event: name, SessionID: "session", AgentRunID: "run", CWD: "/work", Sequence: 42, Queued: true, ImageCount: 1, Status: "blocked", Reason: "reason", Source: "policy", Decision: "denied", Stage: "pre_execution", Result: result, Executed: &executed, CompactionID: "compact", MessageCount: &count, TokenEstimate: &estimate, AgentID: "child", Name: "reviewer", ToolID: "call", ToolName: "bash", ToolArgs: json.RawMessage(`{}`), Text: "prompt"})
26+ select {
27+ case e := <-received:
28+ want := Event{Name: name, SessionID: "session", AgentRunID: "run", CWD: "/work", Sequence: 42, Queued: true, ImageCount: 1, Status: "blocked", Reason: "reason", Source: "policy", Decision: "denied", Stage: "pre_execution", Result: result, Executed: &executed, CompactionID: "compact", MessageCount: &count, TokenEstimate: &estimate, AgentID: "child", AgentName: "reviewer", ToolID: "call", ToolName: "bash", ToolArgs: json.RawMessage(`{}`), Text: "prompt"}
29+ if !reflect.DeepEqual(e, want) {
30+ t.Fatalf("event = %+v, want %+v", e, want)
31+ }
32+ case <-time.After(time.Second):
33+ t.Fatal("missing event")
34+ }
35+ }
36+}
M packages/agent/extensions/before_start.go +4 -1
@@ -20,7 +20,10 @@ func (m *Manager) InterceptBeforeAgentStart(ctx context.Context, ev extproto.Eve
2020 current = *ev.SystemPrompt
2121 }
2222 ev.Event = "before_agent_start"
23- ev.CWD = m.cwd
23+ _, ev.CWD = m.SessionContext()
24+ if ev.CWD == "" {
25+ ev.CWD = m.cwd
26+ }
2427 if ev.Provider == "" {
2528 ev.Provider = m.provider
2629 }
@@ -20,7 +20,10 @@ func (m *Manager) InterceptBeforeAgentStart(ctx context.Context, ev extproto.Eve
20 current = *ev.SystemPrompt20 current = *ev.SystemPrompt
21 }21 }
22 ev.Event = "before_agent_start"22 ev.Event = "before_agent_start"
23- ev.CWD = m.cwd23+ _, ev.CWD = m.SessionContext()
24+ if ev.CWD == "" {
25+ ev.CWD = m.cwd
26+ }
24 if ev.Provider == "" {27 if ev.Provider == "" {
25 ev.Provider = m.provider28 ev.Provider = m.provider
26 }29 }
M packages/agent/extensions/events.go +41 -11
@@ -10,14 +10,38 @@ import (
1010 )
1111
1212 // EmitEvent fires a one-way lifecycle event to every extension that
13-// subscribed to it via SubscribeFromExt.events. Non-blocking: each
14-// extension's pipe write happens on a per-call goroutine so a slow
15-// extension can't stall the agent loop.
13+// subscribed to it via SubscribeFromExt.events. Notification enqueue is
14+// ordered and non-blocking; a slow consumer is disconnected on overflow.
1615 //
1716 // Event names are documented on extproto.EventFromHost. Unknown event
1817 // names are still routed (subscribers can use any string they want).
1918 func (m *Manager) EmitEvent(ev extproto.EventFromHost) {
19+ m.lifecycleMu.Lock()
20+ defer m.lifecycleMu.Unlock()
21+ if m.stopping {
22+ return
23+ }
24+ m.emitEventLocked(ev)
25+}
26+
27+func (m *Manager) emitEventLocked(ev extproto.EventFromHost) {
2028 ev.Type = "event"
29+ m.sequence++
30+ ev.Sequence = m.sequence
31+ if ev.SessionID == "" {
32+ ev.SessionID = m.sessionID
33+ }
34+ if ev.CWD == "" {
35+ ev.CWD = m.sessionCWD
36+ }
37+ if ev.CWD == "" {
38+ ev.CWD = m.cwd
39+ }
40+ // A malformed model call must still have an observable terminal result.
41+ if len(ev.ToolArgs) > 0 && !json.Valid(ev.ToolArgs) {
42+ ev.ToolArgsRaw = string(ev.ToolArgs)
43+ ev.ToolArgs = nil
44+ }
2145 frame, err := extproto.Encode(ev)
2246 if err != nil {
2347 return
@@ -35,14 +59,19 @@ func (m *Manager) EmitEvent(ev extproto.EventFromHost) {
3559 m.mu.RUnlock()
3660
3761 for _, ext := range subs {
38- go func(ext *Extension) {
39- defer func() {
40- // A panicking write to a closed pipe shouldn't kill
41- // the calling goroutine.
42- _ = recover()
43- }()
44- _, _ = ext.stdin.Write(frame)
45- }(ext)
62+ if ext.stdin == nil {
63+ continue
64+ }
65+ var err error
66+ if pipe, ok := ext.stdin.(*orderedPipe); ok {
67+ err = pipe.enqueue(frame, nil)
68+ } else {
69+ // Tests may provide a synchronous in-memory writer.
70+ _, err = ext.stdin.Write(frame)
71+ }
72+ if err != nil && ext.logFile != nil {
73+ fmt.Fprintf(ext.logFile, "[zot] lifecycle delivery failed: %v\n", err)
74+ }
4675 }
4776 }
4877
@@ -81,6 +110,7 @@ func (m *Manager) InterceptToolCall(ctx context.Context, toolID, toolName string
81110 ToolArgs: current,
82111 })
83112 if r.Block {
113+ r.ModifiedArgs = current
84114 return r
85115 }
86116 if len(r.ModifiedArgs) > 0 && json.Valid(r.ModifiedArgs) {
@@ -10,14 +10,38 @@ import (
10 )10 )
11 11
12 // EmitEvent fires a one-way lifecycle event to every extension that12 // EmitEvent fires a one-way lifecycle event to every extension that
13-// subscribed to it via SubscribeFromExt.events. Non-blocking: each13+// subscribed to it via SubscribeFromExt.events. Notification enqueue is
14-// extension's pipe write happens on a per-call goroutine so a slow14+// ordered and non-blocking; a slow consumer is disconnected on overflow.
15-// extension can't stall the agent loop.
16 //15 //
17 // Event names are documented on extproto.EventFromHost. Unknown event16 // Event names are documented on extproto.EventFromHost. Unknown event
18 // names are still routed (subscribers can use any string they want).17 // names are still routed (subscribers can use any string they want).
19 func (m *Manager) EmitEvent(ev extproto.EventFromHost) {18 func (m *Manager) EmitEvent(ev extproto.EventFromHost) {
19+ m.lifecycleMu.Lock()
20+ defer m.lifecycleMu.Unlock()
21+ if m.stopping {
22+ return
23+ }
24+ m.emitEventLocked(ev)
25+}
26+
27+func (m *Manager) emitEventLocked(ev extproto.EventFromHost) {
20 ev.Type = "event"28 ev.Type = "event"
29+ m.sequence++
30+ ev.Sequence = m.sequence
31+ if ev.SessionID == "" {
32+ ev.SessionID = m.sessionID
33+ }
34+ if ev.CWD == "" {
35+ ev.CWD = m.sessionCWD
36+ }
37+ if ev.CWD == "" {
38+ ev.CWD = m.cwd
39+ }
40+ // A malformed model call must still have an observable terminal result.
41+ if len(ev.ToolArgs) > 0 && !json.Valid(ev.ToolArgs) {
42+ ev.ToolArgsRaw = string(ev.ToolArgs)
43+ ev.ToolArgs = nil
44+ }
21 frame, err := extproto.Encode(ev)45 frame, err := extproto.Encode(ev)
22 if err != nil {46 if err != nil {
23 return47 return
@@ -35,14 +59,19 @@ func (m *Manager) EmitEvent(ev extproto.EventFromHost) {
35 m.mu.RUnlock()59 m.mu.RUnlock()
36 60
37 for _, ext := range subs {61 for _, ext := range subs {
38- go func(ext *Extension) {62+ if ext.stdin == nil {
39- defer func() {63+ continue
40- // A panicking write to a closed pipe shouldn't kill64+ }
41- // the calling goroutine.65+ var err error
42- _ = recover()66+ if pipe, ok := ext.stdin.(*orderedPipe); ok {
43- }()67+ err = pipe.enqueue(frame, nil)
44- _, _ = ext.stdin.Write(frame)68+ } else {
45- }(ext)69+ // Tests may provide a synchronous in-memory writer.
70+ _, err = ext.stdin.Write(frame)
71+ }
72+ if err != nil && ext.logFile != nil {
73+ fmt.Fprintf(ext.logFile, "[zot] lifecycle delivery failed: %v\n", err)
74+ }
46 }75 }
47 }76 }
48 77
@@ -81,6 +110,7 @@ func (m *Manager) InterceptToolCall(ctx context.Context, toolID, toolName string
81 ToolArgs: current,110 ToolArgs: current,
82 })111 })
83 if r.Block {112 if r.Block {
113+ r.ModifiedArgs = current
84 return r114 return r
85 }115 }
86 if len(r.ModifiedArgs) > 0 && json.Valid(r.ModifiedArgs) {116 if len(r.ModifiedArgs) > 0 && json.Valid(r.ModifiedArgs) {
A packages/agent/extensions/lifecycle.go +67 -0
@@ -0,0 +1,67 @@
1+package extensions
2+
3+import (
4+ "crypto/rand"
5+
6+ "github.com/patriceckhart/zot/packages/agent/extproto"
7+)
8+
9+// StartSession opens an active-conversation lifecycle, closing its predecessor
10+// first. Empty IDs get a process-local identity for non-persisted sessions.
11+func (m *Manager) StartSession(id, cwd, reason string) {
12+ m.lifecycleMu.Lock()
13+ defer m.lifecycleMu.Unlock()
14+ if m.stopping {
15+ return
16+ }
17+ if cwd == "" {
18+ cwd = m.cwd
19+ }
20+ if id == "" && m.sessionID != "" && !m.sessionClosed && cwd == m.sessionCWD {
21+ return
22+ }
23+ if id != "" && id == m.sessionID && cwd == m.sessionCWD && !m.sessionClosed {
24+ return
25+ }
26+ if m.sessionID != "" && !m.sessionClosed {
27+ if reason == "" {
28+ reason = "session_switch"
29+ }
30+ m.emitEventLocked(extproto.EventFromHost{Event: "session_end", Reason: reason})
31+ }
32+ if id == "" {
33+ id = rand.Text()
34+ }
35+ m.sessionID, m.sessionCWD, m.sessionClosed = id, cwd, false
36+ m.emitEventLocked(extproto.EventFromHost{Event: "session_start"})
37+}
38+
39+// SessionContext returns the active conversation identity and working directory.
40+func (m *Manager) SessionContext() (string, string) {
41+ m.lifecycleMu.Lock()
42+ defer m.lifecycleMu.Unlock()
43+ return m.sessionID, m.sessionCWD
44+}
45+
46+// EndSession emits at most one end notification per active lifecycle. It is
47+// observational: it does not close the persisted session or stop subprocesses.
48+func (m *Manager) EndSession(reason string) {
49+ m.lifecycleMu.Lock()
50+ defer m.lifecycleMu.Unlock()
51+ m.endSessionLocked(reason)
52+}
53+
54+func (m *Manager) stopLifecycle() {
55+ m.lifecycleMu.Lock()
56+ defer m.lifecycleMu.Unlock()
57+ m.endSessionLocked("shutdown")
58+ m.stopping = true
59+}
60+
61+func (m *Manager) endSessionLocked(reason string) {
62+ if m.sessionID == "" || m.sessionClosed {
63+ return
64+ }
65+ m.emitEventLocked(extproto.EventFromHost{Event: "session_end", Reason: reason})
66+ m.sessionClosed = true
67+}
@@ -0,0 +1,67 @@
1+package extensions
2+
3+import (
4+ "crypto/rand"
5+
6+ "github.com/patriceckhart/zot/packages/agent/extproto"
7+)
8+
9+// StartSession opens an active-conversation lifecycle, closing its predecessor
10+// first. Empty IDs get a process-local identity for non-persisted sessions.
11+func (m *Manager) StartSession(id, cwd, reason string) {
12+ m.lifecycleMu.Lock()
13+ defer m.lifecycleMu.Unlock()
14+ if m.stopping {
15+ return
16+ }
17+ if cwd == "" {
18+ cwd = m.cwd
19+ }
20+ if id == "" && m.sessionID != "" && !m.sessionClosed && cwd == m.sessionCWD {
21+ return
22+ }
23+ if id != "" && id == m.sessionID && cwd == m.sessionCWD && !m.sessionClosed {
24+ return
25+ }
26+ if m.sessionID != "" && !m.sessionClosed {
27+ if reason == "" {
28+ reason = "session_switch"
29+ }
30+ m.emitEventLocked(extproto.EventFromHost{Event: "session_end", Reason: reason})
31+ }
32+ if id == "" {
33+ id = rand.Text()
34+ }
35+ m.sessionID, m.sessionCWD, m.sessionClosed = id, cwd, false
36+ m.emitEventLocked(extproto.EventFromHost{Event: "session_start"})
37+}
38+
39+// SessionContext returns the active conversation identity and working directory.
40+func (m *Manager) SessionContext() (string, string) {
41+ m.lifecycleMu.Lock()
42+ defer m.lifecycleMu.Unlock()
43+ return m.sessionID, m.sessionCWD
44+}
45+
46+// EndSession emits at most one end notification per active lifecycle. It is
47+// observational: it does not close the persisted session or stop subprocesses.
48+func (m *Manager) EndSession(reason string) {
49+ m.lifecycleMu.Lock()
50+ defer m.lifecycleMu.Unlock()
51+ m.endSessionLocked(reason)
52+}
53+
54+func (m *Manager) stopLifecycle() {
55+ m.lifecycleMu.Lock()
56+ defer m.lifecycleMu.Unlock()
57+ m.endSessionLocked("shutdown")
58+ m.stopping = true
59+}
60+
61+func (m *Manager) endSessionLocked(reason string) {
62+ if m.sessionID == "" || m.sessionClosed {
63+ return
64+ }
65+ m.emitEventLocked(extproto.EventFromHost{Event: "session_end", Reason: reason})
66+ m.sessionClosed = true
67+}
A packages/agent/extensions/lifecycle_test.go +245 -0
@@ -0,0 +1,245 @@
1+package extensions
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "io"
8+ "reflect"
9+ "testing"
10+ "time"
11+
12+ "github.com/patriceckhart/zot/packages/agent/extproto"
13+)
14+
15+func lifecyclePeer(t *testing.T, m *Manager, names ...string) (<-chan []byte, *Extension) {
16+ t.Helper()
17+ reader, writer := io.Pipe()
18+ out := make(chan []byte, 512)
19+ done := make(chan struct{})
20+ pipe := newOrderedPipe(writer)
21+ ext := &Extension{stdin: pipe, eventSubs: map[string]struct{}{}, interceptSubs: map[string]struct{}{}, pendingIntercept: map[string]chan extproto.EventInterceptResponseFromExt{}}
22+ for _, name := range names {
23+ ext.eventSubs[name] = struct{}{}
24+ }
25+ m.ext["test"] = ext
26+ go func() {
27+ defer close(done)
28+ scanner := bufio.NewScanner(reader)
29+ for scanner.Scan() {
30+ out <- append([]byte(nil), scanner.Bytes()...)
31+ }
32+ close(out)
33+ }()
34+ t.Cleanup(func() { pipe.Close(); reader.Close(); <-done })
35+ return out, ext
36+}
37+
38+func nextLifecycleFrame(t *testing.T, frames <-chan []byte) []byte {
39+ t.Helper()
40+ select {
41+ case frame, ok := <-frames:
42+ if !ok {
43+ t.Fatal("peer closed")
44+ }
45+ return frame
46+ case <-time.After(2 * time.Second):
47+ t.Fatal("timed out waiting for frame")
48+ return nil
49+ }
50+}
51+
52+func TestLifecycleOrderAndShutdownDrain(t *testing.T) {
53+ m := New(t.TempDir(), "/work", "test", "", "", nil)
54+ frames, _ := lifecyclePeer(t, m, "session_start", "session_end", "tool_call", "tool_result")
55+ m.StartSession("one", "/work", "")
56+ m.StartSession("one", "/work", "") // idempotent
57+ for n := 0; n < 50; n++ {
58+ m.EmitEvent(extproto.EventFromHost{Event: "tool_call", Step: n})
59+ m.EmitEvent(extproto.EventFromHost{Event: "tool_result", Step: n})
60+ }
61+ m.StartSession("two", "/other", "cwd_change")
62+ m.EndSession("user_exit")
63+ m.EndSession("shutdown")
64+ m.Stop(time.Second)
65+ m.StartSession("late", "/late", "")
66+ m.EmitEvent(extproto.EventFromHost{Event: "tool_result"})
67+ if id, _ := m.SessionContext(); id != "two" {
68+ t.Fatal("late callback reopened a stopped session")
69+ }
70+ var got []extproto.EventFromHost
71+ for {
72+ raw := nextLifecycleFrame(t, frames)
73+ var e extproto.EventFromHost
74+ if err := json.Unmarshal(raw, &e); err != nil {
75+ t.Fatal(err)
76+ }
77+ if e.Type == "shutdown" {
78+ break
79+ }
80+ if e.Sequence != uint64(len(got)+1) {
81+ t.Fatalf("out of order: %+v", e)
82+ }
83+ got = append(got, e)
84+ }
85+ if len(got) != 104 {
86+ t.Fatalf("got %d events", len(got))
87+ }
88+ for n := 0; n < 50; n++ {
89+ a, b := got[1+2*n], got[2+2*n]
90+ if a.Event != "tool_call" || b.Event != "tool_result" || a.Step != n || b.Step != n {
91+ t.Fatalf("wrong order at %d", n)
92+ }
93+ }
94+ if got[101].Event != "session_end" || got[101].SessionID != "one" || got[101].Reason != "cwd_change" || got[101].CWD != "/work" {
95+ t.Fatalf("old boundary: %+v", got[101])
96+ }
97+ if got[102].Event != "session_start" || got[102].SessionID != "two" || got[102].CWD != "/other" {
98+ t.Fatalf("new boundary: %+v", got[102])
99+ }
100+ if got[103].Event != "session_end" || got[103].Reason != "user_exit" {
101+ t.Fatalf("end: %+v", got[103])
102+ }
103+}
104+
105+func TestNotificationPrecedesInterceptOnWire(t *testing.T) {
106+ m := New(t.TempDir(), "/work", "test", "", "", nil)
107+ frames, ext := lifecyclePeer(t, m, "tool_call")
108+ ext.interceptSubs["tool_call"] = struct{}{}
109+ m.EmitEvent(extproto.EventFromHost{Event: "tool_call", ToolID: "call"})
110+ result := make(chan InterceptResult, 1)
111+ go func() { result <- m.InterceptToolCall(context.Background(), "call", "bash", json.RawMessage(`{}`)) }()
112+ var event extproto.EventFromHost
113+ json.Unmarshal(nextLifecycleFrame(t, frames), &event)
114+ if event.Type != "event" || event.Event != "tool_call" {
115+ t.Fatalf("first frame: %+v", event)
116+ }
117+ var intercept extproto.EventInterceptFromHost
118+ json.Unmarshal(nextLifecycleFrame(t, frames), &intercept)
119+ if intercept.Type != "event_intercept" {
120+ t.Fatalf("second frame: %+v", intercept)
121+ }
122+ ext.mu.Lock()
123+ reply := ext.pendingIntercept[intercept.ID]
124+ delete(ext.pendingIntercept, intercept.ID)
125+ ext.mu.Unlock()
126+ reply <- extproto.EventInterceptResponseFromExt{Block: true}
127+ select {
128+ case r := <-result:
129+ if !r.Block {
130+ t.Fatal("block lost")
131+ }
132+ case <-time.After(time.Second):
133+ t.Fatal("interceptor did not finish")
134+ }
135+}
136+
137+func TestSlowConsumerOverflowClosesPipe(t *testing.T) {
138+ reader, writer := io.Pipe()
139+ defer reader.Close()
140+ pipe := newOrderedPipe(writer)
141+ defer pipe.Close()
142+ var err error
143+ for n := 0; n < outboundFrames+2; n++ {
144+ err = pipe.enqueue([]byte("{}\n"), nil)
145+ if err != nil {
146+ break
147+ }
148+ }
149+ if err == nil {
150+ t.Fatal("unbounded queue")
151+ }
152+ select {
153+ case <-pipe.done:
154+ default:
155+ t.Fatal("overflow did not disconnect")
156+ }
157+ if _, err := pipe.Write([]byte("{}\n")); err == nil {
158+ t.Fatal("write after disconnect succeeded")
159+ }
160+}
161+
162+func TestLifecycleSubscriptionFiltering(t *testing.T) {
163+ m := New(t.TempDir(), "/work", "test", "", "", nil)
164+ frames, ext := lifecyclePeer(t, m, "post_compact", "permission_decision", "subagent_start", "subagent_stop", "user_prompt_submit")
165+ names := []string{"post_compact", "permission_decision", "subagent_start", "subagent_stop", "user_prompt_submit"}
166+ m.EmitEvent(extproto.EventFromHost{Event: "pre_compact"})
167+ for _, name := range names {
168+ m.EmitEvent(extproto.EventFromHost{Event: name})
169+ }
170+ if _, err := ext.stdin.Write([]byte("{\"type\":\"marker\"}\n")); err != nil {
171+ t.Fatal(err)
172+ }
173+ var got []string
174+ for range names {
175+ var e extproto.EventFromHost
176+ json.Unmarshal(nextLifecycleFrame(t, frames), &e)
177+ got = append(got, e.Event)
178+ }
179+ if !reflect.DeepEqual(got, names) {
180+ t.Fatalf("got %v", got)
181+ }
182+}
183+
184+func TestMalformedArgumentsStillEmitResult(t *testing.T) {
185+ m := New(t.TempDir(), "/work", "test", "", "", nil)
186+ frames, _ := lifecyclePeer(t, m, "tool_result")
187+ m.EmitEvent(extproto.EventFromHost{Event: "tool_result", ToolID: "bad-call", ToolArgs: json.RawMessage(`{invalid`), Status: "failed"})
188+ var e extproto.EventFromHost
189+ if err := json.Unmarshal(nextLifecycleFrame(t, frames), &e); err != nil {
190+ t.Fatal(err)
191+ }
192+ if e.ToolID != "bad-call" || e.Status != "failed" || len(e.ToolArgs) != 0 || e.ToolArgsRaw != `{invalid` {
193+ t.Fatalf("result: %+v", e)
194+ }
195+}
196+
197+func TestShutdownBoundsBlockedWriter(t *testing.T) {
198+ reader, writer := io.Pipe()
199+ defer reader.Close()
200+ pipe := newOrderedPipe(writer)
201+ defer pipe.Close()
202+ if err := pipe.enqueue([]byte("{}\n"), nil); err != nil {
203+ t.Fatal(err)
204+ }
205+ done := make(chan struct{})
206+ go func() { stopExtensions([]*Extension{{stdin: pipe}}, time.Millisecond); close(done) }()
207+ select {
208+ case <-done:
209+ case <-time.After(time.Second):
210+ t.Fatal("shutdown blocked on a slow consumer")
211+ }
212+}
213+
214+func TestExtensionToolTerminalStatus(t *testing.T) {
215+ for _, cancelled := range []bool{false, true} {
216+ name := "timed_out"
217+ if cancelled {
218+ name = "cancelled"
219+ }
220+ t.Run(name, func(t *testing.T) {
221+ m := New(t.TempDir(), "/work", "test", "", "", nil)
222+ _, ext := lifecyclePeer(t, m)
223+ ext.pendingTool = map[string]chan extproto.ToolResultFromExt{}
224+ m.toolIndex["probe"] = ext
225+ ctx, cancel := context.WithCancel(context.Background())
226+ defer cancel()
227+ timeout := time.Nanosecond
228+ if cancelled {
229+ cancel()
230+ timeout = time.Hour
231+ }
232+ tool := &extensionTool{name: "probe", extension: "test", manager: m, timeout: timeout}
233+ result, err := tool.Execute(ctx, json.RawMessage(`{}`), nil)
234+ if err != nil || !result.IsError || result.Status != name {
235+ t.Fatalf("result: %+v, error: %v", result, err)
236+ }
237+ ext.mu.Lock()
238+ pending := len(ext.pendingTool)
239+ ext.mu.Unlock()
240+ if pending != 0 {
241+ t.Fatal("terminal call left a pending reply")
242+ }
243+ })
244+ }
245+}
@@ -0,0 +1,245 @@
1+package extensions
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "io"
8+ "reflect"
9+ "testing"
10+ "time"
11+
12+ "github.com/patriceckhart/zot/packages/agent/extproto"
13+)
14+
15+func lifecyclePeer(t *testing.T, m *Manager, names ...string) (<-chan []byte, *Extension) {
16+ t.Helper()
17+ reader, writer := io.Pipe()
18+ out := make(chan []byte, 512)
19+ done := make(chan struct{})
20+ pipe := newOrderedPipe(writer)
21+ ext := &Extension{stdin: pipe, eventSubs: map[string]struct{}{}, interceptSubs: map[string]struct{}{}, pendingIntercept: map[string]chan extproto.EventInterceptResponseFromExt{}}
22+ for _, name := range names {
23+ ext.eventSubs[name] = struct{}{}
24+ }
25+ m.ext["test"] = ext
26+ go func() {
27+ defer close(done)
28+ scanner := bufio.NewScanner(reader)
29+ for scanner.Scan() {
30+ out <- append([]byte(nil), scanner.Bytes()...)
31+ }
32+ close(out)
33+ }()
34+ t.Cleanup(func() { pipe.Close(); reader.Close(); <-done })
35+ return out, ext
36+}
37+
38+func nextLifecycleFrame(t *testing.T, frames <-chan []byte) []byte {
39+ t.Helper()
40+ select {
41+ case frame, ok := <-frames:
42+ if !ok {
43+ t.Fatal("peer closed")
44+ }
45+ return frame
46+ case <-time.After(2 * time.Second):
47+ t.Fatal("timed out waiting for frame")
48+ return nil
49+ }
50+}
51+
52+func TestLifecycleOrderAndShutdownDrain(t *testing.T) {
53+ m := New(t.TempDir(), "/work", "test", "", "", nil)
54+ frames, _ := lifecyclePeer(t, m, "session_start", "session_end", "tool_call", "tool_result")
55+ m.StartSession("one", "/work", "")
56+ m.StartSession("one", "/work", "") // idempotent
57+ for n := 0; n < 50; n++ {
58+ m.EmitEvent(extproto.EventFromHost{Event: "tool_call", Step: n})
59+ m.EmitEvent(extproto.EventFromHost{Event: "tool_result", Step: n})
60+ }
61+ m.StartSession("two", "/other", "cwd_change")
62+ m.EndSession("user_exit")
63+ m.EndSession("shutdown")
64+ m.Stop(time.Second)
65+ m.StartSession("late", "/late", "")
66+ m.EmitEvent(extproto.EventFromHost{Event: "tool_result"})
67+ if id, _ := m.SessionContext(); id != "two" {
68+ t.Fatal("late callback reopened a stopped session")
69+ }
70+ var got []extproto.EventFromHost
71+ for {
72+ raw := nextLifecycleFrame(t, frames)
73+ var e extproto.EventFromHost
74+ if err := json.Unmarshal(raw, &e); err != nil {
75+ t.Fatal(err)
76+ }
77+ if e.Type == "shutdown" {
78+ break
79+ }
80+ if e.Sequence != uint64(len(got)+1) {
81+ t.Fatalf("out of order: %+v", e)
82+ }
83+ got = append(got, e)
84+ }
85+ if len(got) != 104 {
86+ t.Fatalf("got %d events", len(got))
87+ }
88+ for n := 0; n < 50; n++ {
89+ a, b := got[1+2*n], got[2+2*n]
90+ if a.Event != "tool_call" || b.Event != "tool_result" || a.Step != n || b.Step != n {
91+ t.Fatalf("wrong order at %d", n)
92+ }
93+ }
94+ if got[101].Event != "session_end" || got[101].SessionID != "one" || got[101].Reason != "cwd_change" || got[101].CWD != "/work" {
95+ t.Fatalf("old boundary: %+v", got[101])
96+ }
97+ if got[102].Event != "session_start" || got[102].SessionID != "two" || got[102].CWD != "/other" {
98+ t.Fatalf("new boundary: %+v", got[102])
99+ }
100+ if got[103].Event != "session_end" || got[103].Reason != "user_exit" {
101+ t.Fatalf("end: %+v", got[103])
102+ }
103+}
104+
105+func TestNotificationPrecedesInterceptOnWire(t *testing.T) {
106+ m := New(t.TempDir(), "/work", "test", "", "", nil)
107+ frames, ext := lifecyclePeer(t, m, "tool_call")
108+ ext.interceptSubs["tool_call"] = struct{}{}
109+ m.EmitEvent(extproto.EventFromHost{Event: "tool_call", ToolID: "call"})
110+ result := make(chan InterceptResult, 1)
111+ go func() { result <- m.InterceptToolCall(context.Background(), "call", "bash", json.RawMessage(`{}`)) }()
112+ var event extproto.EventFromHost
113+ json.Unmarshal(nextLifecycleFrame(t, frames), &event)
114+ if event.Type != "event" || event.Event != "tool_call" {
115+ t.Fatalf("first frame: %+v", event)
116+ }
117+ var intercept extproto.EventInterceptFromHost
118+ json.Unmarshal(nextLifecycleFrame(t, frames), &intercept)
119+ if intercept.Type != "event_intercept" {
120+ t.Fatalf("second frame: %+v", intercept)
121+ }
122+ ext.mu.Lock()
123+ reply := ext.pendingIntercept[intercept.ID]
124+ delete(ext.pendingIntercept, intercept.ID)
125+ ext.mu.Unlock()
126+ reply <- extproto.EventInterceptResponseFromExt{Block: true}
127+ select {
128+ case r := <-result:
129+ if !r.Block {
130+ t.Fatal("block lost")
131+ }
132+ case <-time.After(time.Second):
133+ t.Fatal("interceptor did not finish")
134+ }
135+}
136+
137+func TestSlowConsumerOverflowClosesPipe(t *testing.T) {
138+ reader, writer := io.Pipe()
139+ defer reader.Close()
140+ pipe := newOrderedPipe(writer)
141+ defer pipe.Close()
142+ var err error
143+ for n := 0; n < outboundFrames+2; n++ {
144+ err = pipe.enqueue([]byte("{}\n"), nil)
145+ if err != nil {
146+ break
147+ }
148+ }
149+ if err == nil {
150+ t.Fatal("unbounded queue")
151+ }
152+ select {
153+ case <-pipe.done:
154+ default:
155+ t.Fatal("overflow did not disconnect")
156+ }
157+ if _, err := pipe.Write([]byte("{}\n")); err == nil {
158+ t.Fatal("write after disconnect succeeded")
159+ }
160+}
161+
162+func TestLifecycleSubscriptionFiltering(t *testing.T) {
163+ m := New(t.TempDir(), "/work", "test", "", "", nil)
164+ frames, ext := lifecyclePeer(t, m, "post_compact", "permission_decision", "subagent_start", "subagent_stop", "user_prompt_submit")
165+ names := []string{"post_compact", "permission_decision", "subagent_start", "subagent_stop", "user_prompt_submit"}
166+ m.EmitEvent(extproto.EventFromHost{Event: "pre_compact"})
167+ for _, name := range names {
168+ m.EmitEvent(extproto.EventFromHost{Event: name})
169+ }
170+ if _, err := ext.stdin.Write([]byte("{\"type\":\"marker\"}\n")); err != nil {
171+ t.Fatal(err)
172+ }
173+ var got []string
174+ for range names {
175+ var e extproto.EventFromHost
176+ json.Unmarshal(nextLifecycleFrame(t, frames), &e)
177+ got = append(got, e.Event)
178+ }
179+ if !reflect.DeepEqual(got, names) {
180+ t.Fatalf("got %v", got)
181+ }
182+}
183+
184+func TestMalformedArgumentsStillEmitResult(t *testing.T) {
185+ m := New(t.TempDir(), "/work", "test", "", "", nil)
186+ frames, _ := lifecyclePeer(t, m, "tool_result")
187+ m.EmitEvent(extproto.EventFromHost{Event: "tool_result", ToolID: "bad-call", ToolArgs: json.RawMessage(`{invalid`), Status: "failed"})
188+ var e extproto.EventFromHost
189+ if err := json.Unmarshal(nextLifecycleFrame(t, frames), &e); err != nil {
190+ t.Fatal(err)
191+ }
192+ if e.ToolID != "bad-call" || e.Status != "failed" || len(e.ToolArgs) != 0 || e.ToolArgsRaw != `{invalid` {
193+ t.Fatalf("result: %+v", e)
194+ }
195+}
196+
197+func TestShutdownBoundsBlockedWriter(t *testing.T) {
198+ reader, writer := io.Pipe()
199+ defer reader.Close()
200+ pipe := newOrderedPipe(writer)
201+ defer pipe.Close()
202+ if err := pipe.enqueue([]byte("{}\n"), nil); err != nil {
203+ t.Fatal(err)
204+ }
205+ done := make(chan struct{})
206+ go func() { stopExtensions([]*Extension{{stdin: pipe}}, time.Millisecond); close(done) }()
207+ select {
208+ case <-done:
209+ case <-time.After(time.Second):
210+ t.Fatal("shutdown blocked on a slow consumer")
211+ }
212+}
213+
214+func TestExtensionToolTerminalStatus(t *testing.T) {
215+ for _, cancelled := range []bool{false, true} {
216+ name := "timed_out"
217+ if cancelled {
218+ name = "cancelled"
219+ }
220+ t.Run(name, func(t *testing.T) {
221+ m := New(t.TempDir(), "/work", "test", "", "", nil)
222+ _, ext := lifecyclePeer(t, m)
223+ ext.pendingTool = map[string]chan extproto.ToolResultFromExt{}
224+ m.toolIndex["probe"] = ext
225+ ctx, cancel := context.WithCancel(context.Background())
226+ defer cancel()
227+ timeout := time.Nanosecond
228+ if cancelled {
229+ cancel()
230+ timeout = time.Hour
231+ }
232+ tool := &extensionTool{name: "probe", extension: "test", manager: m, timeout: timeout}
233+ result, err := tool.Execute(ctx, json.RawMessage(`{}`), nil)
234+ if err != nil || !result.IsError || result.Status != name {
235+ t.Fatalf("result: %+v, error: %v", result, err)
236+ }
237+ ext.mu.Lock()
238+ pending := len(ext.pendingTool)
239+ ext.mu.Unlock()
240+ if pending != 0 {
241+ t.Fatal("terminal call left a pending reply")
242+ }
243+ })
244+ }
245+}
M packages/agent/extensions/manager.go +17 -3
@@ -169,6 +169,14 @@ type Manager struct {
169169 // by the host so it can rebuild the agent's tool registry with
170170 // the freshly-registered extension tools.
171171 onReload func()
172+
173+ // lifecycleMu serializes session boundaries and notification enqueue order.
174+ lifecycleMu sync.Mutex
175+ sessionID string
176+ sessionCWD string
177+ sequence uint64
178+ sessionClosed bool
179+ stopping bool
172180 }
173181
174182 // New constructs an empty Manager. Call Discover to populate it from
@@ -723,6 +731,7 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
723731 }
724732 started = true
725733 ext.cmd = cmd
734+ stdin = newOrderedPipe(stdin)
726735 ext.stdin = stdin
727736 ext.stdout = stdout
728737
@@ -1238,7 +1247,7 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess
12381247 ext.mu.Lock()
12391248 delete(ext.pendingTool, id)
12401249 ext.mu.Unlock()
1241- return extproto.ToolResultFromExt{}, fmt.Errorf("timeout waiting for %s/%s", ext.Manifest.Name, name)
1250+ return extproto.ToolResultFromExt{}, fmt.Errorf("timeout waiting for %s/%s: %w", ext.Manifest.Name, name, context.DeadlineExceeded)
12421251 case <-ctx.Done():
12431252 ext.mu.Lock()
12441253 delete(ext.pendingTool, id)
@@ -1341,6 +1350,7 @@ func (m *Manager) SendPanelClose(extName, panelID string) error {
13411350 }
13421351
13431352 func (m *Manager) Stop(gracePeriod time.Duration) {
1353+ m.stopLifecycle()
13441354 m.mu.RLock()
13451355 exts := make([]*Extension, 0, len(m.ext))
13461356 for _, e := range m.ext {
@@ -1351,17 +1361,21 @@ func (m *Manager) Stop(gracePeriod time.Duration) {
13511361 }
13521362
13531363 func stopExtensions(exts []*Extension, gracePeriod time.Duration) {
1364+ deadline := time.Now().Add(gracePeriod)
13541365 for _, ext := range exts {
13551366 if ext.stdin == nil {
13561367 continue
13571368 }
13581369 if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil {
1359- _, _ = ext.stdin.Write(frame)
1370+ if pipe, ok := ext.stdin.(*orderedPipe); ok {
1371+ _, _ = pipe.writeTimeout(frame, time.Until(deadline))
1372+ } else {
1373+ _, _ = ext.stdin.Write(frame)
1374+ }
13601375 }
13611376 _ = ext.stdin.Close()
13621377 }
13631378
1364- deadline := time.Now().Add(gracePeriod)
13651379 for _, ext := range exts {
13661380 if ext.cmd == nil {
13671381 if ext.logFile != nil {
@@ -169,6 +169,14 @@ type Manager struct {
169 // by the host so it can rebuild the agent's tool registry with169 // by the host so it can rebuild the agent's tool registry with
170 // the freshly-registered extension tools.170 // the freshly-registered extension tools.
171 onReload func()171 onReload func()
172+
173+ // lifecycleMu serializes session boundaries and notification enqueue order.
174+ lifecycleMu sync.Mutex
175+ sessionID string
176+ sessionCWD string
177+ sequence uint64
178+ sessionClosed bool
179+ stopping bool
172 }180 }
173 181
174 // New constructs an empty Manager. Call Discover to populate it from182 // New constructs an empty Manager. Call Discover to populate it from
@@ -723,6 +731,7 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
723 }731 }
724 started = true732 started = true
725 ext.cmd = cmd733 ext.cmd = cmd
734+ stdin = newOrderedPipe(stdin)
726 ext.stdin = stdin735 ext.stdin = stdin
727 ext.stdout = stdout736 ext.stdout = stdout
728 737
@@ -1238,7 +1247,7 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess
1238 ext.mu.Lock()1247 ext.mu.Lock()
1239 delete(ext.pendingTool, id)1248 delete(ext.pendingTool, id)
1240 ext.mu.Unlock()1249 ext.mu.Unlock()
1241- return extproto.ToolResultFromExt{}, fmt.Errorf("timeout waiting for %s/%s", ext.Manifest.Name, name)1250+ return extproto.ToolResultFromExt{}, fmt.Errorf("timeout waiting for %s/%s: %w", ext.Manifest.Name, name, context.DeadlineExceeded)
1242 case <-ctx.Done():1251 case <-ctx.Done():
1243 ext.mu.Lock()1252 ext.mu.Lock()
1244 delete(ext.pendingTool, id)1253 delete(ext.pendingTool, id)
@@ -1341,6 +1350,7 @@ func (m *Manager) SendPanelClose(extName, panelID string) error {
1341 }1350 }
1342 1351
1343 func (m *Manager) Stop(gracePeriod time.Duration) {1352 func (m *Manager) Stop(gracePeriod time.Duration) {
1353+ m.stopLifecycle()
1344 m.mu.RLock()1354 m.mu.RLock()
1345 exts := make([]*Extension, 0, len(m.ext))1355 exts := make([]*Extension, 0, len(m.ext))
1346 for _, e := range m.ext {1356 for _, e := range m.ext {
@@ -1351,17 +1361,21 @@ func (m *Manager) Stop(gracePeriod time.Duration) {
1351 }1361 }
1352 1362
1353 func stopExtensions(exts []*Extension, gracePeriod time.Duration) {1363 func stopExtensions(exts []*Extension, gracePeriod time.Duration) {
1364+ deadline := time.Now().Add(gracePeriod)
1354 for _, ext := range exts {1365 for _, ext := range exts {
1355 if ext.stdin == nil {1366 if ext.stdin == nil {
1356 continue1367 continue
1357 }1368 }
1358 if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil {1369 if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil {
1359- _, _ = ext.stdin.Write(frame)1370+ if pipe, ok := ext.stdin.(*orderedPipe); ok {
1371+ _, _ = pipe.writeTimeout(frame, time.Until(deadline))
1372+ } else {
1373+ _, _ = ext.stdin.Write(frame)
1374+ }
1360 }1375 }
1361 _ = ext.stdin.Close()1376 _ = ext.stdin.Close()
1362 }1377 }
1363 1378
1364- deadline := time.Now().Add(gracePeriod)
1365 for _, ext := range exts {1379 for _, ext := range exts {
1366 if ext.cmd == nil {1380 if ext.cmd == nil {
1367 if ext.logFile != nil {1381 if ext.logFile != nil {
A packages/agent/extensions/outbound.go +136 -0
@@ -0,0 +1,136 @@
1+package extensions
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "sync"
9+ "time"
10+)
11+
12+// orderedPipe serializes every host frame, not just notifications. Events
13+// enqueue without waiting for the subprocess; synchronous requests wait for
14+// their write. Overflow disconnects the consumer rather than silently dropping
15+// an event and continuing with a misleading audit stream.
16+type orderedPipe struct {
17+ pipe io.WriteCloser
18+ mu sync.Mutex
19+ queue []outboundFrame
20+ bytes int
21+ closed bool
22+ wake chan struct{}
23+ done chan struct{}
24+ once sync.Once
25+}
26+
27+type outboundFrame struct {
28+ data []byte
29+ ack chan error
30+}
31+
32+const outboundBytes = 16 * 1024 * 1024
33+const outboundFrames = 256
34+
35+func newOrderedPipe(pipe io.WriteCloser) *orderedPipe {
36+ p := &orderedPipe{pipe: pipe, wake: make(chan struct{}, 1), done: make(chan struct{})}
37+ go p.run()
38+ return p
39+}
40+
41+func (p *orderedPipe) enqueue(data []byte, ack chan error) error {
42+ p.mu.Lock()
43+ if p.closed {
44+ p.mu.Unlock()
45+ return io.ErrClosedPipe
46+ }
47+ if len(p.queue) >= outboundFrames || p.bytes+len(data) > outboundBytes {
48+ p.closed = true
49+ p.mu.Unlock()
50+ p.Close()
51+ return errors.New("extension outbound queue exceeded its limit")
52+ }
53+ p.queue = append(p.queue, outboundFrame{data: append([]byte(nil), data...), ack: ack})
54+ p.bytes += len(data)
55+ p.mu.Unlock()
56+ select {
57+ case p.wake <- struct{}{}:
58+ default:
59+ }
60+ return nil
61+}
62+
63+func (p *orderedPipe) Write(data []byte) (int, error) {
64+ return p.writeTimeout(data, interceptTimeout)
65+}
66+
67+func (p *orderedPipe) writeTimeout(data []byte, timeout time.Duration) (int, error) {
68+ if timeout <= 0 {
69+ p.Close()
70+ return 0, io.ErrClosedPipe
71+ }
72+ ack := make(chan error, 1)
73+ if err := p.enqueue(data, ack); err != nil {
74+ return 0, err
75+ }
76+ timer := time.NewTimer(timeout)
77+ defer timer.Stop()
78+ select {
79+ case err := <-ack:
80+ if err != nil {
81+ return 0, err
82+ }
83+ return len(data), nil
84+ case <-p.done:
85+ return 0, io.ErrClosedPipe
86+ case <-timer.C:
87+ p.Close()
88+ return 0, fmt.Errorf("extension outbound write timed out: %w", context.DeadlineExceeded)
89+ }
90+}
91+
92+func (p *orderedPipe) Close() error {
93+ p.once.Do(func() {
94+ p.mu.Lock()
95+ p.closed = true
96+ p.queue = nil
97+ p.bytes = 0
98+ p.mu.Unlock()
99+ close(p.done)
100+ _ = p.pipe.Close()
101+ })
102+ return nil
103+}
104+
105+func (p *orderedPipe) run() {
106+ for {
107+ select {
108+ case <-p.done:
109+ return
110+ case <-p.wake:
111+ }
112+ for {
113+ p.mu.Lock()
114+ if p.closed || len(p.queue) == 0 {
115+ p.mu.Unlock()
116+ break
117+ }
118+ frame := p.queue[0]
119+ p.queue[0] = outboundFrame{}
120+ p.queue = p.queue[1:]
121+ p.bytes -= len(frame.data)
122+ p.mu.Unlock()
123+ n, err := p.pipe.Write(frame.data)
124+ if err == nil && n != len(frame.data) {
125+ err = io.ErrShortWrite
126+ }
127+ if frame.ack != nil {
128+ frame.ack <- err
129+ }
130+ if err != nil {
131+ p.Close()
132+ return
133+ }
134+ }
135+ }
136+}
@@ -0,0 +1,136 @@
1+package extensions
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "sync"
9+ "time"
10+)
11+
12+// orderedPipe serializes every host frame, not just notifications. Events
13+// enqueue without waiting for the subprocess; synchronous requests wait for
14+// their write. Overflow disconnects the consumer rather than silently dropping
15+// an event and continuing with a misleading audit stream.
16+type orderedPipe struct {
17+ pipe io.WriteCloser
18+ mu sync.Mutex
19+ queue []outboundFrame
20+ bytes int
21+ closed bool
22+ wake chan struct{}
23+ done chan struct{}
24+ once sync.Once
25+}
26+
27+type outboundFrame struct {
28+ data []byte
29+ ack chan error
30+}
31+
32+const outboundBytes = 16 * 1024 * 1024
33+const outboundFrames = 256
34+
35+func newOrderedPipe(pipe io.WriteCloser) *orderedPipe {
36+ p := &orderedPipe{pipe: pipe, wake: make(chan struct{}, 1), done: make(chan struct{})}
37+ go p.run()
38+ return p
39+}
40+
41+func (p *orderedPipe) enqueue(data []byte, ack chan error) error {
42+ p.mu.Lock()
43+ if p.closed {
44+ p.mu.Unlock()
45+ return io.ErrClosedPipe
46+ }
47+ if len(p.queue) >= outboundFrames || p.bytes+len(data) > outboundBytes {
48+ p.closed = true
49+ p.mu.Unlock()
50+ p.Close()
51+ return errors.New("extension outbound queue exceeded its limit")
52+ }
53+ p.queue = append(p.queue, outboundFrame{data: append([]byte(nil), data...), ack: ack})
54+ p.bytes += len(data)
55+ p.mu.Unlock()
56+ select {
57+ case p.wake <- struct{}{}:
58+ default:
59+ }
60+ return nil
61+}
62+
63+func (p *orderedPipe) Write(data []byte) (int, error) {
64+ return p.writeTimeout(data, interceptTimeout)
65+}
66+
67+func (p *orderedPipe) writeTimeout(data []byte, timeout time.Duration) (int, error) {
68+ if timeout <= 0 {
69+ p.Close()
70+ return 0, io.ErrClosedPipe
71+ }
72+ ack := make(chan error, 1)
73+ if err := p.enqueue(data, ack); err != nil {
74+ return 0, err
75+ }
76+ timer := time.NewTimer(timeout)
77+ defer timer.Stop()
78+ select {
79+ case err := <-ack:
80+ if err != nil {
81+ return 0, err
82+ }
83+ return len(data), nil
84+ case <-p.done:
85+ return 0, io.ErrClosedPipe
86+ case <-timer.C:
87+ p.Close()
88+ return 0, fmt.Errorf("extension outbound write timed out: %w", context.DeadlineExceeded)
89+ }
90+}
91+
92+func (p *orderedPipe) Close() error {
93+ p.once.Do(func() {
94+ p.mu.Lock()
95+ p.closed = true
96+ p.queue = nil
97+ p.bytes = 0
98+ p.mu.Unlock()
99+ close(p.done)
100+ _ = p.pipe.Close()
101+ })
102+ return nil
103+}
104+
105+func (p *orderedPipe) run() {
106+ for {
107+ select {
108+ case <-p.done:
109+ return
110+ case <-p.wake:
111+ }
112+ for {
113+ p.mu.Lock()
114+ if p.closed || len(p.queue) == 0 {
115+ p.mu.Unlock()
116+ break
117+ }
118+ frame := p.queue[0]
119+ p.queue[0] = outboundFrame{}
120+ p.queue = p.queue[1:]
121+ p.bytes -= len(frame.data)
122+ p.mu.Unlock()
123+ n, err := p.pipe.Write(frame.data)
124+ if err == nil && n != len(frame.data) {
125+ err = io.ErrShortWrite
126+ }
127+ if frame.ack != nil {
128+ frame.ack <- err
129+ }
130+ if err != nil {
131+ p.Close()
132+ return
133+ }
134+ }
135+ }
136+}
M packages/agent/extensions/tool.go +9 -0
@@ -3,6 +3,7 @@ package extensions
33 import (
44 "context"
55 "encoding/json"
6+ "errors"
67 "fmt"
78 "time"
89
@@ -58,7 +59,15 @@ func (t *extensionTool) Execute(ctx context.Context, args json.RawMessage, _ fun
5859 }
5960 resp, err := t.manager.InvokeTool(ctx, t.name, args, t.timeout)
6061 if err != nil {
62+ status := "failed"
63+ if errors.Is(err, context.Canceled) {
64+ status = "cancelled"
65+ }
66+ if errors.Is(err, context.DeadlineExceeded) {
67+ status = "timed_out"
68+ }
6169 return core.ToolResult{
70+ Status: status,
6271 IsError: true,
6372 Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("extension %s/%s failed: %v", t.extension, t.name, err)}},
6473 }, nil
@@ -3,6 +3,7 @@ package extensions
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6+ "errors"
6 "fmt"7 "fmt"
7 "time"8 "time"
8 9
@@ -58,7 +59,15 @@ func (t *extensionTool) Execute(ctx context.Context, args json.RawMessage, _ fun
58 }59 }
59 resp, err := t.manager.InvokeTool(ctx, t.name, args, t.timeout)60 resp, err := t.manager.InvokeTool(ctx, t.name, args, t.timeout)
60 if err != nil {61 if err != nil {
62+ status := "failed"
63+ if errors.Is(err, context.Canceled) {
64+ status = "cancelled"
65+ }
66+ if errors.Is(err, context.DeadlineExceeded) {
67+ status = "timed_out"
68+ }
61 return core.ToolResult{69 return core.ToolResult{
70+ Status: status,
62 IsError: true,71 IsError: true,
63 Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("extension %s/%s failed: %v", t.extension, t.name, err)}},72 Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("extension %s/%s failed: %v", t.extension, t.name, err)}},
64 }, nil73 }, nil
M packages/agent/extproto/extproto.go +36 -10
@@ -183,17 +183,43 @@ type ToolCallFromHost struct {
183183 Args json.RawMessage `json:"args"`
184184 }
185185
186+// EventResult is a tool outcome without extension-private Details or activation data.
187+type EventResult struct {
188+ Content []ContentBlock `json:"content"`
189+ IsError bool `json:"is_error"`
190+ Truncated bool `json:"truncated,omitempty"`
191+}
192+
186193 type EventFromHost struct {
187- Type string `json:"type"`
188- Event string `json:"event"`
189- Step int `json:"step,omitempty"`
190- Stop string `json:"stop,omitempty"`
191- Error string `json:"error,omitempty"`
192- ToolID string `json:"tool_id,omitempty"`
193- ToolName string `json:"tool_name,omitempty"`
194- ToolArgs json.RawMessage `json:"tool_args,omitempty"`
195- ToolPreview string `json:"tool_preview,omitempty"`
196- Text string `json:"text,omitempty"`
194+ SessionID string `json:"session_id,omitempty"`
195+ AgentRunID string `json:"agent_run_id,omitempty"`
196+ Queued bool `json:"queued,omitempty"`
197+ ImageCount int `json:"image_count,omitempty"`
198+ CWD string `json:"cwd,omitempty"`
199+ Sequence uint64 `json:"sequence,omitempty"`
200+ Status string `json:"status,omitempty"`
201+ Reason string `json:"reason,omitempty"`
202+ Source string `json:"source,omitempty"`
203+ Decision string `json:"decision,omitempty"`
204+ Stage string `json:"stage,omitempty"`
205+ Result *EventResult `json:"result,omitempty"`
206+ Executed *bool `json:"executed,omitempty"`
207+ CompactionID string `json:"compaction_id,omitempty"`
208+ MessageCount *int `json:"message_count,omitempty"`
209+ TokenEstimate *int `json:"token_estimate,omitempty"`
210+ AgentID string `json:"agent_id,omitempty"`
211+ Name string `json:"name,omitempty"`
212+ Type string `json:"type"`
213+ Event string `json:"event"`
214+ Step int `json:"step,omitempty"`
215+ Stop string `json:"stop,omitempty"`
216+ Error string `json:"error,omitempty"`
217+ ToolID string `json:"tool_id,omitempty"`
218+ ToolName string `json:"tool_name,omitempty"`
219+ ToolArgs json.RawMessage `json:"tool_args,omitempty"`
220+ ToolPreview string `json:"tool_preview,omitempty"`
221+ ToolArgsRaw string `json:"tool_args_raw,omitempty"`
222+ Text string `json:"text,omitempty"`
197223 }
198224
199225 type EventInterceptFromHost struct {
@@ -183,17 +183,43 @@ type ToolCallFromHost struct {
183 Args json.RawMessage `json:"args"`183 Args json.RawMessage `json:"args"`
184 }184 }
185 185
186+// EventResult is a tool outcome without extension-private Details or activation data.
187+type EventResult struct {
188+ Content []ContentBlock `json:"content"`
189+ IsError bool `json:"is_error"`
190+ Truncated bool `json:"truncated,omitempty"`
191+}
192+
186 type EventFromHost struct {193 type EventFromHost struct {
187- Type string `json:"type"`194+ SessionID string `json:"session_id,omitempty"`
188- Event string `json:"event"`195+ AgentRunID string `json:"agent_run_id,omitempty"`
189- Step int `json:"step,omitempty"`196+ Queued bool `json:"queued,omitempty"`
190- Stop string `json:"stop,omitempty"`197+ ImageCount int `json:"image_count,omitempty"`
191- Error string `json:"error,omitempty"`198+ CWD string `json:"cwd,omitempty"`
192- ToolID string `json:"tool_id,omitempty"`199+ Sequence uint64 `json:"sequence,omitempty"`
193- ToolName string `json:"tool_name,omitempty"`200+ Status string `json:"status,omitempty"`
194- ToolArgs json.RawMessage `json:"tool_args,omitempty"`201+ Reason string `json:"reason,omitempty"`
195- ToolPreview string `json:"tool_preview,omitempty"`202+ Source string `json:"source,omitempty"`
196- Text string `json:"text,omitempty"`203+ Decision string `json:"decision,omitempty"`
204+ Stage string `json:"stage,omitempty"`
205+ Result *EventResult `json:"result,omitempty"`
206+ Executed *bool `json:"executed,omitempty"`
207+ CompactionID string `json:"compaction_id,omitempty"`
208+ MessageCount *int `json:"message_count,omitempty"`
209+ TokenEstimate *int `json:"token_estimate,omitempty"`
210+ AgentID string `json:"agent_id,omitempty"`
211+ Name string `json:"name,omitempty"`
212+ Type string `json:"type"`
213+ Event string `json:"event"`
214+ Step int `json:"step,omitempty"`
215+ Stop string `json:"stop,omitempty"`
216+ Error string `json:"error,omitempty"`
217+ ToolID string `json:"tool_id,omitempty"`
218+ ToolName string `json:"tool_name,omitempty"`
219+ ToolArgs json.RawMessage `json:"tool_args,omitempty"`
220+ ToolPreview string `json:"tool_preview,omitempty"`
221+ ToolArgsRaw string `json:"tool_args_raw,omitempty"`
222+ Text string `json:"text,omitempty"`
197 }223 }
198 224
199 type EventInterceptFromHost struct {225 type EventInterceptFromHost struct {
A packages/agent/lifecycle.go +84 -0
@@ -0,0 +1,84 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "unicode/utf8"
7+
8+ "github.com/patriceckhart/zot/packages/agent/extensions"
9+ "github.com/patriceckhart/zot/packages/agent/extproto"
10+ "github.com/patriceckhart/zot/packages/agent/swarm"
11+ "github.com/patriceckhart/zot/packages/core"
12+ "github.com/patriceckhart/zot/packages/provider"
13+)
14+
15+func startExtensionSession(mgr *extensions.Manager, ag *core.Agent, cwd, reason string) {
16+ id := ""
17+ if ag != nil {
18+ id = ag.SessionID
19+ }
20+ mgr.StartSession(id, cwd, reason)
21+}
22+
23+func wireLifecycleEvents(ag *core.Agent, mgr *extensions.Manager) {
24+ ag.OnEvent = func(ev core.AgentEvent) {
25+ _, cwd := mgr.SessionContext()
26+ startExtensionSession(mgr, ag, cwd, "session_switch")
27+ fanoutAgentEvent(mgr, ev)
28+ }
29+}
30+
31+// Bound observation payloads independently from tool output and transcripts.
32+// The budget includes base64 bytes; images are omitted rather than corrupted.
33+const eventResultBudget = 256 * 1024
34+
35+func extensionEventResult(result core.ToolResult) *extproto.EventResult {
36+ out := &extproto.EventResult{IsError: result.IsError, Content: []extproto.ContentBlock{}}
37+ remaining := eventResultBudget
38+ for _, block := range result.Content {
39+ if len(out.Content) >= 128 {
40+ out.Truncated = true
41+ break
42+ }
43+ switch b := block.(type) {
44+ case provider.TextBlock:
45+ text := b.Text
46+ if len(text) > remaining {
47+ end := remaining
48+ for end > 0 && !utf8.RuneStart(text[end]) {
49+ end--
50+ }
51+ text = text[:end]
52+ out.Truncated = true
53+ }
54+ remaining -= len(text)
55+ out.Content = append(out.Content, extproto.ContentBlock{Type: "text", Text: text})
56+ case provider.ImageBlock:
57+ size := base64.StdEncoding.EncodedLen(len(b.Data)) + len(b.MimeType)
58+ if size > remaining {
59+ out.Truncated = true
60+ continue
61+ }
62+ remaining -= size
63+ out.Content = append(out.Content, extproto.ContentBlock{Type: "image", MimeType: b.MimeType, Data: base64.StdEncoding.EncodeToString(b.Data)})
64+ default:
65+ out.Truncated = true
66+ }
67+ }
68+ return out
69+}
70+
71+func emitPermissionDecision(ctx context.Context, mgr *extensions.Manager, call provider.ToolCallBlock, d core.ConfirmDecision) {
72+ decision := "denied"
73+ if d.Allow {
74+ decision = "approved"
75+ }
76+ if ctx.Err() != nil {
77+ decision = "cancelled"
78+ }
79+ mgr.EmitEvent(extproto.EventFromHost{Event: "permission_decision", ToolID: call.ID, ToolName: call.Name, Decision: decision, Source: d.Source, Reason: d.Reason, Stage: "pre_execution"})
80+}
81+
82+func emitSwarmLifecycle(mgr *extensions.Manager, e swarm.LifecycleEvent) {
83+ mgr.EmitEvent(extproto.EventFromHost{Event: e.Event, SessionID: e.SessionID, AgentID: e.AgentID, AgentRunID: e.AgentRunID, Name: e.Name, CWD: e.CWD, Status: e.Status, Error: e.Error})
84+}
@@ -0,0 +1,84 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "unicode/utf8"
7+
8+ "github.com/patriceckhart/zot/packages/agent/extensions"
9+ "github.com/patriceckhart/zot/packages/agent/extproto"
10+ "github.com/patriceckhart/zot/packages/agent/swarm"
11+ "github.com/patriceckhart/zot/packages/core"
12+ "github.com/patriceckhart/zot/packages/provider"
13+)
14+
15+func startExtensionSession(mgr *extensions.Manager, ag *core.Agent, cwd, reason string) {
16+ id := ""
17+ if ag != nil {
18+ id = ag.SessionID
19+ }
20+ mgr.StartSession(id, cwd, reason)
21+}
22+
23+func wireLifecycleEvents(ag *core.Agent, mgr *extensions.Manager) {
24+ ag.OnEvent = func(ev core.AgentEvent) {
25+ _, cwd := mgr.SessionContext()
26+ startExtensionSession(mgr, ag, cwd, "session_switch")
27+ fanoutAgentEvent(mgr, ev)
28+ }
29+}
30+
31+// Bound observation payloads independently from tool output and transcripts.
32+// The budget includes base64 bytes; images are omitted rather than corrupted.
33+const eventResultBudget = 256 * 1024
34+
35+func extensionEventResult(result core.ToolResult) *extproto.EventResult {
36+ out := &extproto.EventResult{IsError: result.IsError, Content: []extproto.ContentBlock{}}
37+ remaining := eventResultBudget
38+ for _, block := range result.Content {
39+ if len(out.Content) >= 128 {
40+ out.Truncated = true
41+ break
42+ }
43+ switch b := block.(type) {
44+ case provider.TextBlock:
45+ text := b.Text
46+ if len(text) > remaining {
47+ end := remaining
48+ for end > 0 && !utf8.RuneStart(text[end]) {
49+ end--
50+ }
51+ text = text[:end]
52+ out.Truncated = true
53+ }
54+ remaining -= len(text)
55+ out.Content = append(out.Content, extproto.ContentBlock{Type: "text", Text: text})
56+ case provider.ImageBlock:
57+ size := base64.StdEncoding.EncodedLen(len(b.Data)) + len(b.MimeType)
58+ if size > remaining {
59+ out.Truncated = true
60+ continue
61+ }
62+ remaining -= size
63+ out.Content = append(out.Content, extproto.ContentBlock{Type: "image", MimeType: b.MimeType, Data: base64.StdEncoding.EncodeToString(b.Data)})
64+ default:
65+ out.Truncated = true
66+ }
67+ }
68+ return out
69+}
70+
71+func emitPermissionDecision(ctx context.Context, mgr *extensions.Manager, call provider.ToolCallBlock, d core.ConfirmDecision) {
72+ decision := "denied"
73+ if d.Allow {
74+ decision = "approved"
75+ }
76+ if ctx.Err() != nil {
77+ decision = "cancelled"
78+ }
79+ mgr.EmitEvent(extproto.EventFromHost{Event: "permission_decision", ToolID: call.ID, ToolName: call.Name, Decision: decision, Source: d.Source, Reason: d.Reason, Stage: "pre_execution"})
80+}
81+
82+func emitSwarmLifecycle(mgr *extensions.Manager, e swarm.LifecycleEvent) {
83+ mgr.EmitEvent(extproto.EventFromHost{Event: e.Event, SessionID: e.SessionID, AgentID: e.AgentID, AgentRunID: e.AgentRunID, Name: e.Name, CWD: e.CWD, Status: e.Status, Error: e.Error})
84+}
A packages/agent/lifecycle_test.go +208 -0
@@ -0,0 +1,208 @@
1+package agent
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ "time"
12+ "unicode/utf8"
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+// This subprocess is a real protocol peer. All data is synthetic and lives in
21+// t.TempDir; the parent reads the captured wire frames only after shutdown.
22+func TestLifecycleProcessHelper(t *testing.T) {
23+ if os.Getenv("ZOT_TEST_LIFECYCLE") != "1" {
24+ return
25+ }
26+ file, err := os.Create(os.Getenv("ZOT_TEST_LIFECYCLE_OUTPUT"))
27+ if err != nil {
28+ os.Exit(2)
29+ }
30+ enc := json.NewEncoder(os.Stdout)
31+ enc.Encode(extproto.HelloFromExt{Type: "hello", Name: "lifecycle", Version: "1"})
32+ enc.Encode(extproto.SubscribeFromExt{Type: "subscribe", Events: []string{"session_start", "session_end", "user_prompt_submit", "turn_start", "turn_end", "tool_call", "tool_result", "permission_decision", "pre_compact", "post_compact"}, Intercept: []string{"tool_call"}})
33+ enc.Encode(extproto.ReadyFromExt{Type: "ready"})
34+ scanner := bufio.NewScanner(os.Stdin)
35+ for scanner.Scan() {
36+ var e extproto.EventInterceptFromHost
37+ if json.Unmarshal(scanner.Bytes(), &e) != nil {
38+ os.Exit(3)
39+ }
40+ switch e.Type {
41+ case "event":
42+ if _, err := file.Write(append(append([]byte(nil), scanner.Bytes()...), '\n')); err != nil {
43+ os.Exit(4)
44+ }
45+ case "event_intercept":
46+ enc.Encode(extproto.EventInterceptResponseFromExt{Type: "event_intercept_response", ID: e.ID, Block: os.Getenv("ZOT_TEST_LIFECYCLE_BLOCK") == "1", Reason: "test policy", ModifiedArgs: json.RawMessage(`{"effective":true}`)})
47+ case "shutdown":
48+ file.Close()
49+ enc.Encode(extproto.ShutdownAckFromExt{Type: "shutdown_ack"})
50+ os.Exit(0)
51+ }
52+ }
53+ file.Close()
54+ os.Exit(0)
55+}
56+
57+type lifecycleClient struct{ step int }
58+
59+func (*lifecycleClient) Name() string { return "test" }
60+func (c *lifecycleClient) Stream(context.Context, provider.Request) (<-chan provider.Event, error) {
61+ c.step++
62+ stop := provider.StopEnd
63+ content := []provider.Content{provider.TextBlock{Text: "done"}}
64+ if c.step == 1 {
65+ stop = provider.StopToolUse
66+ content = []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "probe", Arguments: json.RawMessage(`{"original":true}`)}}
67+ }
68+ ch := make(chan provider.Event, 1)
69+ ch <- provider.EventDone{Stop: stop, Message: provider.Message{Role: provider.RoleAssistant, Content: content}}
70+ close(ch)
71+ return ch, nil
72+}
73+
74+type lifecycleTool struct{ args json.RawMessage }
75+
76+func (*lifecycleTool) Name() string { return "probe" }
77+func (*lifecycleTool) Description() string { return "test" }
78+func (*lifecycleTool) Schema() json.RawMessage { return json.RawMessage(`{}`) }
79+func (t *lifecycleTool) Execute(_ context.Context, args json.RawMessage, _ func(string)) (core.ToolResult, error) {
80+ t.args = append(json.RawMessage(nil), args...)
81+ return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "ok"}}}, nil
82+}
83+
84+func TestExtensionLifecycleEndToEnd(t *testing.T) {
85+ for _, blocked := range []bool{false, true} {
86+ name := "rewritten"
87+ if blocked {
88+ name = "blocked"
89+ }
90+ t.Run(name, func(t *testing.T) {
91+ dir := t.TempDir()
92+ output := filepath.Join(dir, "events.jsonl")
93+ t.Setenv("ZOT_TEST_LIFECYCLE", "1")
94+ t.Setenv("ZOT_TEST_LIFECYCLE_OUTPUT", output)
95+ if blocked {
96+ t.Setenv("ZOT_TEST_LIFECYCLE_BLOCK", "1")
97+ } else {
98+ t.Setenv("ZOT_TEST_LIFECYCLE_BLOCK", "0")
99+ }
100+ exe, err := os.Executable()
101+ if err != nil {
102+ t.Fatal(err)
103+ }
104+ manifest, err := json.Marshal(extensions.Manifest{Name: "lifecycle", Exec: exe, Args: []string{"-test.run=^TestLifecycleProcessHelper$"}})
105+ if err != nil {
106+ t.Fatal(err)
107+ }
108+ if err := os.WriteFile(filepath.Join(dir, "extension.json"), manifest, 0600); err != nil {
109+ t.Fatal(err)
110+ }
111+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
112+ defer cancel()
113+ mgr := extensions.New(t.TempDir(), dir, "test", "test", "test", nil)
114+ if errs := mgr.LoadExplicit(ctx, []string{dir}); len(errs) > 0 {
115+ t.Fatal(errs)
116+ }
117+ t.Cleanup(func() { mgr.Stop(time.Second) })
118+ mgr.WaitForReady(time.Second)
119+ tool := &lifecycleTool{}
120+ ag := core.NewAgent(&lifecycleClient{}, "test", "", core.NewRegistry(tool))
121+ ag.SessionID = "session-one"
122+ wireNonInteractiveAgentExtHooks(ctx, ag, mgr)
123+ if err := ag.Prompt(ctx, "review", nil, func(e core.AgentEvent) {
124+ if _, ok := e.(core.EvPromptSubmit); ok {
125+ t.Error("extension-only submission leaked into the RPC/SDK stream")
126+ }
127+ }); err != nil {
128+ t.Fatal(err)
129+ }
130+ // Exercise the same explicit boundary helper used by the session picker.
131+ ag.SessionID = "session-two"
132+ startExtensionSession(mgr, ag, dir, "session_switch")
133+ mgr.Stop(time.Second)
134+ data, err := os.ReadFile(output)
135+ if err != nil {
136+ t.Fatal(err)
137+ }
138+ var events []extproto.EventFromHost
139+ scanner := bufio.NewScanner(strings.NewReader(string(data)))
140+ for scanner.Scan() {
141+ var e extproto.EventFromHost
142+ if err := json.Unmarshal(scanner.Bytes(), &e); err != nil {
143+ t.Fatal(err)
144+ }
145+ if e.Sequence == 0 || (len(events) > 0 && e.Sequence <= events[len(events)-1].Sequence) {
146+ t.Fatalf("wire sequence: %+v", e)
147+ }
148+ events = append(events, e)
149+ }
150+ if err := scanner.Err(); err != nil {
151+ t.Fatal(err)
152+ }
153+ indexes := map[string]int{}
154+ for n, e := range events {
155+ if _, exists := indexes[e.Event]; !exists {
156+ indexes[e.Event] = n
157+ }
158+ if e.SessionID == "" || e.CWD == "" {
159+ t.Fatalf("missing correlation: %+v", e)
160+ }
161+ }
162+ names := []string{"session_start", "user_prompt_submit", "turn_start", "tool_call", "turn_end", "permission_decision", "tool_result", "session_end"}
163+ previous := -1
164+ for _, name := range names {
165+ n, ok := indexes[name]
166+ if !ok || n <= previous {
167+ t.Fatalf("missing/out-of-order %s: %+v", name, events)
168+ }
169+ previous = n
170+ }
171+ result := events[indexes["tool_result"]]
172+ if result.ToolID != "call-1" || result.ToolName != "probe" || result.Result == nil || result.Executed == nil {
173+ t.Fatalf("result: %+v", result)
174+ }
175+ if blocked {
176+ if result.Status != "blocked" || *result.Executed || tool.args != nil || !result.Result.IsError {
177+ t.Fatalf("blocked outcome: %+v", result)
178+ }
179+ } else {
180+ if result.Status != "completed" || !*result.Executed || string(result.ToolArgs) != string(tool.args) || string(tool.args) != `{"effective":true}` {
181+ t.Fatalf("effective result: %+v", result)
182+ }
183+ }
184+ tail := events[len(events)-3:]
185+ if tail[0].Reason != "session_switch" || tail[0].SessionID != "session-one" || tail[1].Event != "session_start" || tail[1].SessionID != "session-two" || tail[2].Reason != "shutdown" {
186+ t.Fatalf("session boundaries: %+v", tail)
187+ }
188+ })
189+ }
190+}
191+
192+func TestEventResultPayloadBudget(t *testing.T) {
193+ result := core.ToolResult{Content: []provider.Content{
194+ provider.TextBlock{Text: strings.Repeat("ü", eventResultBudget)},
195+ provider.ImageBlock{MimeType: "image/png", Data: []byte("synthetic")},
196+ }}
197+ out := extensionEventResult(result)
198+ if !out.Truncated || len(out.Content) != 1 || len(out.Content[0].Text) > eventResultBudget || !utf8.ValidString(out.Content[0].Text) {
199+ t.Fatalf("invalid bounded result")
200+ }
201+ if len(result.Content[0].(provider.TextBlock).Text) != 2*eventResultBudget {
202+ t.Fatal("transcript modified")
203+ }
204+ small := extensionEventResult(core.ToolResult{Content: []provider.Content{provider.ImageBlock{MimeType: "image/png", Data: []byte("test")}}})
205+ if small.Truncated || len(small.Content) != 1 || small.Content[0].Data != "dGVzdA==" {
206+ t.Fatalf("image: %+v", small)
207+ }
208+}
@@ -0,0 +1,208 @@
1+package agent
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ "time"
12+ "unicode/utf8"
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+// This subprocess is a real protocol peer. All data is synthetic and lives in
21+// t.TempDir; the parent reads the captured wire frames only after shutdown.
22+func TestLifecycleProcessHelper(t *testing.T) {
23+ if os.Getenv("ZOT_TEST_LIFECYCLE") != "1" {
24+ return
25+ }
26+ file, err := os.Create(os.Getenv("ZOT_TEST_LIFECYCLE_OUTPUT"))
27+ if err != nil {
28+ os.Exit(2)
29+ }
30+ enc := json.NewEncoder(os.Stdout)
31+ enc.Encode(extproto.HelloFromExt{Type: "hello", Name: "lifecycle", Version: "1"})
32+ enc.Encode(extproto.SubscribeFromExt{Type: "subscribe", Events: []string{"session_start", "session_end", "user_prompt_submit", "turn_start", "turn_end", "tool_call", "tool_result", "permission_decision", "pre_compact", "post_compact"}, Intercept: []string{"tool_call"}})
33+ enc.Encode(extproto.ReadyFromExt{Type: "ready"})
34+ scanner := bufio.NewScanner(os.Stdin)
35+ for scanner.Scan() {
36+ var e extproto.EventInterceptFromHost
37+ if json.Unmarshal(scanner.Bytes(), &e) != nil {
38+ os.Exit(3)
39+ }
40+ switch e.Type {
41+ case "event":
42+ if _, err := file.Write(append(append([]byte(nil), scanner.Bytes()...), '\n')); err != nil {
43+ os.Exit(4)
44+ }
45+ case "event_intercept":
46+ enc.Encode(extproto.EventInterceptResponseFromExt{Type: "event_intercept_response", ID: e.ID, Block: os.Getenv("ZOT_TEST_LIFECYCLE_BLOCK") == "1", Reason: "test policy", ModifiedArgs: json.RawMessage(`{"effective":true}`)})
47+ case "shutdown":
48+ file.Close()
49+ enc.Encode(extproto.ShutdownAckFromExt{Type: "shutdown_ack"})
50+ os.Exit(0)
51+ }
52+ }
53+ file.Close()
54+ os.Exit(0)
55+}
56+
57+type lifecycleClient struct{ step int }
58+
59+func (*lifecycleClient) Name() string { return "test" }
60+func (c *lifecycleClient) Stream(context.Context, provider.Request) (<-chan provider.Event, error) {
61+ c.step++
62+ stop := provider.StopEnd
63+ content := []provider.Content{provider.TextBlock{Text: "done"}}
64+ if c.step == 1 {
65+ stop = provider.StopToolUse
66+ content = []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "probe", Arguments: json.RawMessage(`{"original":true}`)}}
67+ }
68+ ch := make(chan provider.Event, 1)
69+ ch <- provider.EventDone{Stop: stop, Message: provider.Message{Role: provider.RoleAssistant, Content: content}}
70+ close(ch)
71+ return ch, nil
72+}
73+
74+type lifecycleTool struct{ args json.RawMessage }
75+
76+func (*lifecycleTool) Name() string { return "probe" }
77+func (*lifecycleTool) Description() string { return "test" }
78+func (*lifecycleTool) Schema() json.RawMessage { return json.RawMessage(`{}`) }
79+func (t *lifecycleTool) Execute(_ context.Context, args json.RawMessage, _ func(string)) (core.ToolResult, error) {
80+ t.args = append(json.RawMessage(nil), args...)
81+ return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "ok"}}}, nil
82+}
83+
84+func TestExtensionLifecycleEndToEnd(t *testing.T) {
85+ for _, blocked := range []bool{false, true} {
86+ name := "rewritten"
87+ if blocked {
88+ name = "blocked"
89+ }
90+ t.Run(name, func(t *testing.T) {
91+ dir := t.TempDir()
92+ output := filepath.Join(dir, "events.jsonl")
93+ t.Setenv("ZOT_TEST_LIFECYCLE", "1")
94+ t.Setenv("ZOT_TEST_LIFECYCLE_OUTPUT", output)
95+ if blocked {
96+ t.Setenv("ZOT_TEST_LIFECYCLE_BLOCK", "1")
97+ } else {
98+ t.Setenv("ZOT_TEST_LIFECYCLE_BLOCK", "0")
99+ }
100+ exe, err := os.Executable()
101+ if err != nil {
102+ t.Fatal(err)
103+ }
104+ manifest, err := json.Marshal(extensions.Manifest{Name: "lifecycle", Exec: exe, Args: []string{"-test.run=^TestLifecycleProcessHelper$"}})
105+ if err != nil {
106+ t.Fatal(err)
107+ }
108+ if err := os.WriteFile(filepath.Join(dir, "extension.json"), manifest, 0600); err != nil {
109+ t.Fatal(err)
110+ }
111+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
112+ defer cancel()
113+ mgr := extensions.New(t.TempDir(), dir, "test", "test", "test", nil)
114+ if errs := mgr.LoadExplicit(ctx, []string{dir}); len(errs) > 0 {
115+ t.Fatal(errs)
116+ }
117+ t.Cleanup(func() { mgr.Stop(time.Second) })
118+ mgr.WaitForReady(time.Second)
119+ tool := &lifecycleTool{}
120+ ag := core.NewAgent(&lifecycleClient{}, "test", "", core.NewRegistry(tool))
121+ ag.SessionID = "session-one"
122+ wireNonInteractiveAgentExtHooks(ctx, ag, mgr)
123+ if err := ag.Prompt(ctx, "review", nil, func(e core.AgentEvent) {
124+ if _, ok := e.(core.EvPromptSubmit); ok {
125+ t.Error("extension-only submission leaked into the RPC/SDK stream")
126+ }
127+ }); err != nil {
128+ t.Fatal(err)
129+ }
130+ // Exercise the same explicit boundary helper used by the session picker.
131+ ag.SessionID = "session-two"
132+ startExtensionSession(mgr, ag, dir, "session_switch")
133+ mgr.Stop(time.Second)
134+ data, err := os.ReadFile(output)
135+ if err != nil {
136+ t.Fatal(err)
137+ }
138+ var events []extproto.EventFromHost
139+ scanner := bufio.NewScanner(strings.NewReader(string(data)))
140+ for scanner.Scan() {
141+ var e extproto.EventFromHost
142+ if err := json.Unmarshal(scanner.Bytes(), &e); err != nil {
143+ t.Fatal(err)
144+ }
145+ if e.Sequence == 0 || (len(events) > 0 && e.Sequence <= events[len(events)-1].Sequence) {
146+ t.Fatalf("wire sequence: %+v", e)
147+ }
148+ events = append(events, e)
149+ }
150+ if err := scanner.Err(); err != nil {
151+ t.Fatal(err)
152+ }
153+ indexes := map[string]int{}
154+ for n, e := range events {
155+ if _, exists := indexes[e.Event]; !exists {
156+ indexes[e.Event] = n
157+ }
158+ if e.SessionID == "" || e.CWD == "" {
159+ t.Fatalf("missing correlation: %+v", e)
160+ }
161+ }
162+ names := []string{"session_start", "user_prompt_submit", "turn_start", "tool_call", "turn_end", "permission_decision", "tool_result", "session_end"}
163+ previous := -1
164+ for _, name := range names {
165+ n, ok := indexes[name]
166+ if !ok || n <= previous {
167+ t.Fatalf("missing/out-of-order %s: %+v", name, events)
168+ }
169+ previous = n
170+ }
171+ result := events[indexes["tool_result"]]
172+ if result.ToolID != "call-1" || result.ToolName != "probe" || result.Result == nil || result.Executed == nil {
173+ t.Fatalf("result: %+v", result)
174+ }
175+ if blocked {
176+ if result.Status != "blocked" || *result.Executed || tool.args != nil || !result.Result.IsError {
177+ t.Fatalf("blocked outcome: %+v", result)
178+ }
179+ } else {
180+ if result.Status != "completed" || !*result.Executed || string(result.ToolArgs) != string(tool.args) || string(tool.args) != `{"effective":true}` {
181+ t.Fatalf("effective result: %+v", result)
182+ }
183+ }
184+ tail := events[len(events)-3:]
185+ if tail[0].Reason != "session_switch" || tail[0].SessionID != "session-one" || tail[1].Event != "session_start" || tail[1].SessionID != "session-two" || tail[2].Reason != "shutdown" {
186+ t.Fatalf("session boundaries: %+v", tail)
187+ }
188+ })
189+ }
190+}
191+
192+func TestEventResultPayloadBudget(t *testing.T) {
193+ result := core.ToolResult{Content: []provider.Content{
194+ provider.TextBlock{Text: strings.Repeat("ü", eventResultBudget)},
195+ provider.ImageBlock{MimeType: "image/png", Data: []byte("synthetic")},
196+ }}
197+ out := extensionEventResult(result)
198+ if !out.Truncated || len(out.Content) != 1 || len(out.Content[0].Text) > eventResultBudget || !utf8.ValidString(out.Content[0].Text) {
199+ t.Fatalf("invalid bounded result")
200+ }
201+ if len(result.Content[0].(provider.TextBlock).Text) != 2*eventResultBudget {
202+ t.Fatal("transcript modified")
203+ }
204+ small := extensionEventResult(core.ToolResult{Content: []provider.Content{provider.ImageBlock{MimeType: "image/png", Data: []byte("test")}}})
205+ if small.Truncated || len(small.Content) != 1 || small.Content[0].Data != "dGVzdA==" {
206+ t.Fatalf("image: %+v", small)
207+ }
208+}
M packages/agent/modes/btw_dialog.go +1 -0
@@ -278,6 +278,7 @@ func newBtwAgent(main *core.Agent, system, model string) *core.Agent {
278278 agent.Temperature = main.Temperature
279279 agent.MaxTokens = main.MaxTokens
280280 agent.BeforeToolExecute = main.BeforeToolExecute
281+ agent.BeforeToolExecuteContext = main.BeforeToolExecuteContext
281282 agent.BeforeTurn = main.BeforeTurn
282283 agent.BeforeAssistantMessage = main.BeforeAssistantMessage
283284 agent.MaxRetries = main.MaxRetries
@@ -278,6 +278,7 @@ func newBtwAgent(main *core.Agent, system, model string) *core.Agent {
278 agent.Temperature = main.Temperature278 agent.Temperature = main.Temperature
279 agent.MaxTokens = main.MaxTokens279 agent.MaxTokens = main.MaxTokens
280 agent.BeforeToolExecute = main.BeforeToolExecute280 agent.BeforeToolExecute = main.BeforeToolExecute
281+ agent.BeforeToolExecuteContext = main.BeforeToolExecuteContext
281 agent.BeforeTurn = main.BeforeTurn282 agent.BeforeTurn = main.BeforeTurn
282 agent.BeforeAssistantMessage = main.BeforeAssistantMessage283 agent.BeforeAssistantMessage = main.BeforeAssistantMessage
283 agent.MaxRetries = main.MaxRetries284 agent.MaxRetries = main.MaxRetries
A packages/agent/modes/btw_lifecycle_test.go +32 -0
@@ -0,0 +1,32 @@
1+package modes
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/packages/core"
9+ "github.com/patriceckhart/zot/packages/provider"
10+)
11+
12+func TestBtwPreservesContextAwarePolicy(t *testing.T) {
13+ main := core.NewAgent(nil, "test", "", nil)
14+ ctx, cancel := context.WithCancel(context.Background())
15+ defer cancel()
16+ called := false
17+ main.BeforeToolExecuteContext = func(got context.Context, call provider.ToolCallBlock) (bool, string, json.RawMessage) {
18+ called = true
19+ if got != ctx || call.ID != "call" {
20+ t.Fatal("policy lost call context")
21+ }
22+ return false, "blocked", nil
23+ }
24+ side := newBtwAgent(main, "", "test")
25+ if side.BeforeToolExecuteContext == nil {
26+ t.Fatal("side chat bypasses policy")
27+ }
28+ allowed, reason, _ := side.BeforeToolExecuteContext(ctx, provider.ToolCallBlock{ID: "call"})
29+ if allowed || reason != "blocked" || !called {
30+ t.Fatal("policy decision changed")
31+ }
32+}
@@ -0,0 +1,32 @@
1+package modes
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/packages/core"
9+ "github.com/patriceckhart/zot/packages/provider"
10+)
11+
12+func TestBtwPreservesContextAwarePolicy(t *testing.T) {
13+ main := core.NewAgent(nil, "test", "", nil)
14+ ctx, cancel := context.WithCancel(context.Background())
15+ defer cancel()
16+ called := false
17+ main.BeforeToolExecuteContext = func(got context.Context, call provider.ToolCallBlock) (bool, string, json.RawMessage) {
18+ called = true
19+ if got != ctx || call.ID != "call" {
20+ t.Fatal("policy lost call context")
21+ }
22+ return false, "blocked", nil
23+ }
24+ side := newBtwAgent(main, "", "test")
25+ if side.BeforeToolExecuteContext == nil {
26+ t.Fatal("side chat bypasses policy")
27+ }
28+ allowed, reason, _ := side.BeforeToolExecuteContext(ctx, provider.ToolCallBlock{ID: "call"})
29+ if allowed || reason != "blocked" || !called {
30+ t.Fatal("policy decision changed")
31+ }
32+}
M packages/agent/rpc.go +1 -1
@@ -84,7 +84,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
8484 ag.SetTools(resolved.ToolRegistry)
8585 })
8686
87- extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
87+ startExtensionSession(extMgr, ag, r.CWD, "")
8888
8989 server := &rpcServer{
9090 ctx: ctx,
@@ -84,7 +84,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
84 ag.SetTools(resolved.ToolRegistry)84 ag.SetTools(resolved.ToolRegistry)
85 })85 })
86 86
87- extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})87+ startExtensionSession(extMgr, ag, r.CWD, "")
88 88
89 server := &rpcServer{89 server := &rpcServer{
90 ctx: ctx,90 ctx: ctx,
A packages/agent/swarm/lifecycle_test.go +120 -0
@@ -0,0 +1,120 @@
1+package swarm
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+)
9+
10+func nextSwarmLifecycle(t *testing.T, events <-chan LifecycleEvent) LifecycleEvent {
11+ t.Helper()
12+ select {
13+ case e := <-events:
14+ return e
15+ case <-time.After(2 * time.Second):
16+ t.Fatal("missing lifecycle event")
17+ return LifecycleEvent{}
18+ }
19+}
20+
21+func TestSubagentLifecycle(t *testing.T) {
22+ for _, tc := range []struct {
23+ status string
24+ err error
25+ }{
26+ {"completed", nil}, {"failed", errors.New("failed")}, {"cancelled", context.Canceled}, {"timed_out", context.DeadlineExceeded},
27+ } {
28+ t.Run(tc.status, func(t *testing.T) {
29+ root := t.TempDir()
30+ events := make(chan LifecycleEvent, 2)
31+ f := New(Config{Root: root, RepoRoot: root, OnLifecycle: func(e LifecycleEvent) { events <- e }, NewRunner: func(*Agent) Runner {
32+ return RunnerFunc(func(context.Context, Sink) error { return tc.err })
33+ }})
34+ f.SetActiveSession("parent")
35+ a, err := f.Spawn(context.Background(), "review")
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ done := make(chan struct{})
40+ go func() { a.Wait(); close(done) }()
41+ select {
42+ case <-done:
43+ case <-time.After(2 * time.Second):
44+ t.Fatal("runner did not finish")
45+ }
46+ first, last := nextSwarmLifecycle(t, events), nextSwarmLifecycle(t, events)
47+ if first.Event != "subagent_start" || last.Event != "subagent_stop" || last.Status != tc.status || first.AgentRunID == "" || first.AgentRunID != last.AgentRunID {
48+ t.Fatalf("events: %+v %+v", first, last)
49+ }
50+ for _, e := range []LifecycleEvent{first, last} {
51+ if e.SessionID != "parent" || e.AgentID != a.ID || e.CWD != root {
52+ t.Fatalf("identity: %+v", e)
53+ }
54+ }
55+ if err := f.Stop(a.ID); err != nil {
56+ t.Fatal(err)
57+ }
58+ select {
59+ case e := <-events:
60+ t.Fatalf("duplicate event: %+v", e)
61+ default:
62+ }
63+ })
64+ }
65+}
66+
67+func TestShutdownWaitsForSubagentStop(t *testing.T) {
68+ root := t.TempDir()
69+ events := make(chan LifecycleEvent, 2)
70+ started := make(chan struct{})
71+ f := New(Config{Root: root, RepoRoot: root, OnLifecycle: func(e LifecycleEvent) { events <- e }, NewRunner: func(*Agent) Runner {
72+ return RunnerFunc(func(ctx context.Context, _ Sink) error { close(started); <-ctx.Done(); return ctx.Err() })
73+ }})
74+ if _, err := f.Spawn(context.Background(), "wait"); err != nil {
75+ t.Fatal(err)
76+ }
77+ select {
78+ case <-started:
79+ case <-time.After(time.Second):
80+ t.Fatal("runner not started")
81+ }
82+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
83+ defer cancel()
84+ f.Shutdown(ctx)
85+ if ctx.Err() != nil {
86+ t.Fatal("shutdown did not finish")
87+ }
88+ if nextSwarmLifecycle(t, events).Event != "subagent_start" {
89+ t.Fatal("missing start")
90+ }
91+ if e := nextSwarmLifecycle(t, events); e.Event != "subagent_stop" || e.Status != "cancelled" {
92+ t.Fatalf("stop: %+v", e)
93+ }
94+}
95+
96+func TestResumedSubagentHasNewLifecycleRun(t *testing.T) {
97+ root := t.TempDir()
98+ events := make(chan LifecycleEvent, 4)
99+ f := New(Config{Root: root, RepoRoot: root, OnLifecycle: func(e LifecycleEvent) { events <- e }, NewRunner: func(*Agent) Runner {
100+ return RunnerFunc(func(context.Context, Sink) error { return nil })
101+ }})
102+ f.SetActiveSession("original")
103+ a, err := f.Spawn(context.Background(), "review")
104+ if err != nil {
105+ t.Fatal(err)
106+ }
107+ first := nextSwarmLifecycle(t, events)
108+ nextSwarmLifecycle(t, events)
109+ a.Wait()
110+ f.SetActiveSession("other")
111+ resumed, err := f.Resume(context.Background(), a.ID)
112+ if err != nil {
113+ t.Fatal(err)
114+ }
115+ start, stop := nextSwarmLifecycle(t, events), nextSwarmLifecycle(t, events)
116+ resumed.Wait()
117+ if start.Event != "subagent_start" || stop.Event != "subagent_stop" || start.AgentRunID == first.AgentRunID || start.AgentRunID != stop.AgentRunID || start.SessionID != "original" || stop.SessionID != "original" {
118+ t.Fatalf("resume: %+v %+v", start, stop)
119+ }
120+}
@@ -0,0 +1,120 @@
1+package swarm
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+)
9+
10+func nextSwarmLifecycle(t *testing.T, events <-chan LifecycleEvent) LifecycleEvent {
11+ t.Helper()
12+ select {
13+ case e := <-events:
14+ return e
15+ case <-time.After(2 * time.Second):
16+ t.Fatal("missing lifecycle event")
17+ return LifecycleEvent{}
18+ }
19+}
20+
21+func TestSubagentLifecycle(t *testing.T) {
22+ for _, tc := range []struct {
23+ status string
24+ err error
25+ }{
26+ {"completed", nil}, {"failed", errors.New("failed")}, {"cancelled", context.Canceled}, {"timed_out", context.DeadlineExceeded},
27+ } {
28+ t.Run(tc.status, func(t *testing.T) {
29+ root := t.TempDir()
30+ events := make(chan LifecycleEvent, 2)
31+ f := New(Config{Root: root, RepoRoot: root, OnLifecycle: func(e LifecycleEvent) { events <- e }, NewRunner: func(*Agent) Runner {
32+ return RunnerFunc(func(context.Context, Sink) error { return tc.err })
33+ }})
34+ f.SetActiveSession("parent")
35+ a, err := f.Spawn(context.Background(), "review")
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ done := make(chan struct{})
40+ go func() { a.Wait(); close(done) }()
41+ select {
42+ case <-done:
43+ case <-time.After(2 * time.Second):
44+ t.Fatal("runner did not finish")
45+ }
46+ first, last := nextSwarmLifecycle(t, events), nextSwarmLifecycle(t, events)
47+ if first.Event != "subagent_start" || last.Event != "subagent_stop" || last.Status != tc.status || first.AgentRunID == "" || first.AgentRunID != last.AgentRunID {
48+ t.Fatalf("events: %+v %+v", first, last)
49+ }
50+ for _, e := range []LifecycleEvent{first, last} {
51+ if e.SessionID != "parent" || e.AgentID != a.ID || e.CWD != root {
52+ t.Fatalf("identity: %+v", e)
53+ }
54+ }
55+ if err := f.Stop(a.ID); err != nil {
56+ t.Fatal(err)
57+ }
58+ select {
59+ case e := <-events:
60+ t.Fatalf("duplicate event: %+v", e)
61+ default:
62+ }
63+ })
64+ }
65+}
66+
67+func TestShutdownWaitsForSubagentStop(t *testing.T) {
68+ root := t.TempDir()
69+ events := make(chan LifecycleEvent, 2)
70+ started := make(chan struct{})
71+ f := New(Config{Root: root, RepoRoot: root, OnLifecycle: func(e LifecycleEvent) { events <- e }, NewRunner: func(*Agent) Runner {
72+ return RunnerFunc(func(ctx context.Context, _ Sink) error { close(started); <-ctx.Done(); return ctx.Err() })
73+ }})
74+ if _, err := f.Spawn(context.Background(), "wait"); err != nil {
75+ t.Fatal(err)
76+ }
77+ select {
78+ case <-started:
79+ case <-time.After(time.Second):
80+ t.Fatal("runner not started")
81+ }
82+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
83+ defer cancel()
84+ f.Shutdown(ctx)
85+ if ctx.Err() != nil {
86+ t.Fatal("shutdown did not finish")
87+ }
88+ if nextSwarmLifecycle(t, events).Event != "subagent_start" {
89+ t.Fatal("missing start")
90+ }
91+ if e := nextSwarmLifecycle(t, events); e.Event != "subagent_stop" || e.Status != "cancelled" {
92+ t.Fatalf("stop: %+v", e)
93+ }
94+}
95+
96+func TestResumedSubagentHasNewLifecycleRun(t *testing.T) {
97+ root := t.TempDir()
98+ events := make(chan LifecycleEvent, 4)
99+ f := New(Config{Root: root, RepoRoot: root, OnLifecycle: func(e LifecycleEvent) { events <- e }, NewRunner: func(*Agent) Runner {
100+ return RunnerFunc(func(context.Context, Sink) error { return nil })
101+ }})
102+ f.SetActiveSession("original")
103+ a, err := f.Spawn(context.Background(), "review")
104+ if err != nil {
105+ t.Fatal(err)
106+ }
107+ first := nextSwarmLifecycle(t, events)
108+ nextSwarmLifecycle(t, events)
109+ a.Wait()
110+ f.SetActiveSession("other")
111+ resumed, err := f.Resume(context.Background(), a.ID)
112+ if err != nil {
113+ t.Fatal(err)
114+ }
115+ start, stop := nextSwarmLifecycle(t, events), nextSwarmLifecycle(t, events)
116+ resumed.Wait()
117+ if start.Event != "subagent_start" || stop.Event != "subagent_stop" || start.AgentRunID == first.AgentRunID || start.AgentRunID != stop.AgentRunID || start.SessionID != "original" || stop.SessionID != "original" {
118+ t.Fatalf("resume: %+v %+v", start, stop)
119+ }
120+}
M packages/agent/swarm/persist.go +2 -0
@@ -348,6 +348,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
348348 Model: existing.Model, Provider: existing.Provider,
349349 InboxPath: inboxPath, EventLogPath: existing.EventLogPath,
350350 SessionPath: existing.SessionPath,
351+ SessionID: existing.SessionID,
351352 }
352353
353354 a := &Agent{
@@ -361,6 +362,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
361362 EventLogPath: m.EventLogPath,
362363 SessionPath: m.SessionPath,
363364 Resuming: true,
365+ SessionID: m.SessionID,
364366 inbox: NewInbox(m.InboxPath),
365367 status: StatusPending,
366368 activity: "resuming",
@@ -348,6 +348,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
348 Model: existing.Model, Provider: existing.Provider,348 Model: existing.Model, Provider: existing.Provider,
349 InboxPath: inboxPath, EventLogPath: existing.EventLogPath,349 InboxPath: inboxPath, EventLogPath: existing.EventLogPath,
350 SessionPath: existing.SessionPath,350 SessionPath: existing.SessionPath,
351+ SessionID: existing.SessionID,
351 }352 }
352 353
353 a := &Agent{354 a := &Agent{
@@ -361,6 +362,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
361 EventLogPath: m.EventLogPath,362 EventLogPath: m.EventLogPath,
362 SessionPath: m.SessionPath,363 SessionPath: m.SessionPath,
363 Resuming: true,364 Resuming: true,
365+ SessionID: m.SessionID,
364 inbox: NewInbox(m.InboxPath),366 inbox: NewInbox(m.InboxPath),
365 status: StatusPending,367 status: StatusPending,
366 activity: "resuming",368 activity: "resuming",
M packages/agent/swarm/swarm.go +56 -2
@@ -25,6 +25,7 @@ package swarm
2525
2626 import (
2727 "context"
28+ "crypto/rand"
2829 "errors"
2930 "fmt"
3031 "os"
@@ -47,8 +48,17 @@ const (
4748 StatusDetached Status = "detached" // reloaded from disk; no live runner
4849 )
4950
51+// LifecycleEvent reports a locally supervised task, not a reloaded detached task.
52+type LifecycleEvent struct {
53+ Event, SessionID, AgentID, AgentRunID, Name, CWD, Status, Error string
54+}
55+
5056 // Config configures a Swarm.
5157 type Config struct {
58+ // OnLifecycle observes start and terminal outcomes outside supervisor locks.
59+ // It must return promptly. Different agents may invoke it concurrently.
60+ OnLifecycle func(LifecycleEvent)
61+
5262 // Root is the directory under which per-agent state files live.
5363 // Typically <ZotHome>/swarm, but tests pass a tempdir.
5464 Root string
@@ -288,8 +298,20 @@ func (f *Swarm) SendUserTurn(id, text string) error {
288298 }
289299
290300 func (f *Swarm) run(a *Agent) {
291- a.setStatus(StatusRunning)
292- a.setActivity("starting")
301+ runID := rand.Text()
302+ sessionID := a.SessionID
303+ if sessionID == "" {
304+ sessionID = f.ActiveSession()
305+ }
306+ if f.cfg.OnLifecycle != nil {
307+ f.cfg.OnLifecycle(LifecycleEvent{Event: "subagent_start", SessionID: sessionID, AgentID: a.ID, AgentRunID: runID, Name: a.ID, CWD: a.Dir})
308+ }
309+ a.mu.Lock()
310+ if a.status == StatusPending {
311+ a.status = StatusRunning
312+ a.activity = "starting"
313+ }
314+ a.mu.Unlock()
293315 err := a.runner.Run(a.ctx, agentSink{a: a})
294316 a.mu.Lock()
295317 a.finished = f.cfg.Now()
@@ -307,7 +329,23 @@ func (f *Swarm) run(a *Agent) {
307329 a.status = StatusDone
308330 a.activity = "done"
309331 }
332+ status := "completed"
333+ switch {
334+ case a.status == StatusKilled:
335+ status = "cancelled"
336+ case errors.Is(err, context.DeadlineExceeded):
337+ status = "timed_out"
338+ case err != nil:
339+ status = "failed"
340+ }
310341 a.mu.Unlock()
342+ if f.cfg.OnLifecycle != nil {
343+ e := LifecycleEvent{Event: "subagent_stop", SessionID: sessionID, AgentID: a.ID, AgentRunID: runID, Name: a.ID, CWD: a.Dir, Status: status}
344+ if err != nil {
345+ e.Error = err.Error()
346+ }
347+ f.cfg.OnLifecycle(e)
348+ }
311349 close(a.done)
312350 }
313351
@@ -392,6 +430,22 @@ func (f *Swarm) StopAll() {
392430 }
393431 }
394432
433+// Shutdown cancels local tasks and waits for terminal notifications until ctx
434+// expires. Detached tasks have no local runner and are not waited on.
435+func (f *Swarm) Shutdown(ctx context.Context) {
436+ f.StopAll()
437+ for _, a := range f.List() {
438+ if a.Status() == StatusDetached {
439+ continue
440+ }
441+ select {
442+ case <-a.done:
443+ case <-ctx.Done():
444+ return
445+ }
446+ }
447+}
448+
395449 // Remove tears down the per-agent state for a terminated agent. It
396450 // is an error to remove an agent that's still running; call Stop
397451 // first and wait for the status to settle. Detached agents
@@ -25,6 +25,7 @@ package swarm
25 25
26 import (26 import (
27 "context"27 "context"
28+ "crypto/rand"
28 "errors"29 "errors"
29 "fmt"30 "fmt"
30 "os"31 "os"
@@ -47,8 +48,17 @@ const (
47 StatusDetached Status = "detached" // reloaded from disk; no live runner48 StatusDetached Status = "detached" // reloaded from disk; no live runner
48 )49 )
49 50
51+// LifecycleEvent reports a locally supervised task, not a reloaded detached task.
52+type LifecycleEvent struct {
53+ Event, SessionID, AgentID, AgentRunID, Name, CWD, Status, Error string
54+}
55+
50 // Config configures a Swarm.56 // Config configures a Swarm.
51 type Config struct {57 type Config struct {
58+ // OnLifecycle observes start and terminal outcomes outside supervisor locks.
59+ // It must return promptly. Different agents may invoke it concurrently.
60+ OnLifecycle func(LifecycleEvent)
61+
52 // Root is the directory under which per-agent state files live.62 // Root is the directory under which per-agent state files live.
53 // Typically <ZotHome>/swarm, but tests pass a tempdir.63 // Typically <ZotHome>/swarm, but tests pass a tempdir.
54 Root string64 Root string
@@ -288,8 +298,20 @@ func (f *Swarm) SendUserTurn(id, text string) error {
288 }298 }
289 299
290 func (f *Swarm) run(a *Agent) {300 func (f *Swarm) run(a *Agent) {
291- a.setStatus(StatusRunning)301+ runID := rand.Text()
292- a.setActivity("starting")302+ sessionID := a.SessionID
303+ if sessionID == "" {
304+ sessionID = f.ActiveSession()
305+ }
306+ if f.cfg.OnLifecycle != nil {
307+ f.cfg.OnLifecycle(LifecycleEvent{Event: "subagent_start", SessionID: sessionID, AgentID: a.ID, AgentRunID: runID, Name: a.ID, CWD: a.Dir})
308+ }
309+ a.mu.Lock()
310+ if a.status == StatusPending {
311+ a.status = StatusRunning
312+ a.activity = "starting"
313+ }
314+ a.mu.Unlock()
293 err := a.runner.Run(a.ctx, agentSink{a: a})315 err := a.runner.Run(a.ctx, agentSink{a: a})
294 a.mu.Lock()316 a.mu.Lock()
295 a.finished = f.cfg.Now()317 a.finished = f.cfg.Now()
@@ -307,7 +329,23 @@ func (f *Swarm) run(a *Agent) {
307 a.status = StatusDone329 a.status = StatusDone
308 a.activity = "done"330 a.activity = "done"
309 }331 }
332+ status := "completed"
333+ switch {
334+ case a.status == StatusKilled:
335+ status = "cancelled"
336+ case errors.Is(err, context.DeadlineExceeded):
337+ status = "timed_out"
338+ case err != nil:
339+ status = "failed"
340+ }
310 a.mu.Unlock()341 a.mu.Unlock()
342+ if f.cfg.OnLifecycle != nil {
343+ e := LifecycleEvent{Event: "subagent_stop", SessionID: sessionID, AgentID: a.ID, AgentRunID: runID, Name: a.ID, CWD: a.Dir, Status: status}
344+ if err != nil {
345+ e.Error = err.Error()
346+ }
347+ f.cfg.OnLifecycle(e)
348+ }
311 close(a.done)349 close(a.done)
312 }350 }
313 351
@@ -392,6 +430,22 @@ func (f *Swarm) StopAll() {
392 }430 }
393 }431 }
394 432
433+// Shutdown cancels local tasks and waits for terminal notifications until ctx
434+// expires. Detached tasks have no local runner and are not waited on.
435+func (f *Swarm) Shutdown(ctx context.Context) {
436+ f.StopAll()
437+ for _, a := range f.List() {
438+ if a.Status() == StatusDetached {
439+ continue
440+ }
441+ select {
442+ case <-a.done:
443+ case <-ctx.Done():
444+ return
445+ }
446+ }
447+}
448+
395 // Remove tears down the per-agent state for a terminated agent. It449 // Remove tears down the per-agent state for a terminated agent. It
396 // is an error to remove an agent that's still running; call Stop450 // is an error to remove an agent that's still running; call Stop
397 // first and wait for the status to settle. Detached agents451 // first and wait for the status to settle. Detached agents
M packages/agent/tools/bash.go +8 -0
@@ -201,7 +201,15 @@ func executeShell(ctx context.Context, raw json.RawMessage, progress func(string
201201 fmt.Fprintf(&sb, " Took %s", humanDuration(elapsed))
202202
203203 isErr := exitCode != 0 || runCtx.Err() != nil
204+ status := ""
205+ if runCtx.Err() == context.DeadlineExceeded {
206+ status = "timed_out"
207+ }
208+ if runCtx.Err() == context.Canceled {
209+ status = "cancelled"
210+ }
204211 return core.ToolResult{
212+ Status: status,
205213 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
206214 IsError: isErr,
207215 Details: map[string]any{
@@ -201,7 +201,15 @@ func executeShell(ctx context.Context, raw json.RawMessage, progress func(string
201 fmt.Fprintf(&sb, " Took %s", humanDuration(elapsed))201 fmt.Fprintf(&sb, " Took %s", humanDuration(elapsed))
202 202
203 isErr := exitCode != 0 || runCtx.Err() != nil203 isErr := exitCode != 0 || runCtx.Err() != nil
204+ status := ""
205+ if runCtx.Err() == context.DeadlineExceeded {
206+ status = "timed_out"
207+ }
208+ if runCtx.Err() == context.Canceled {
209+ status = "cancelled"
210+ }
204 return core.ToolResult{211 return core.ToolResult{
212+ Status: status,
205 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},213 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
206 IsError: isErr,214 IsError: isErr,
207 Details: map[string]any{215 Details: map[string]any{
M packages/agent/tools/permissions.go +18 -3
@@ -1,9 +1,12 @@
11 package tools
22
33 import (
4+ "errors"
45 "fmt"
56 "path/filepath"
67 "strings"
8+
9+ "github.com/patriceckhart/zot/packages/core"
710 )
811
912 // PermissionSet is the local-runtime permission contract for a packaged .zot agent.
@@ -55,7 +58,8 @@ func (s *Sandbox) SetPermissions(p *PermissionSet) {
5558 s.Permissions = p
5659 }
5760
58-func (s *Sandbox) CheckReadPath(path string) error {
61+func (s *Sandbox) CheckReadPath(path string) (err error) {
62+ defer markPolicyError(&err)
5963 if err := s.CheckPath(path); err != nil {
6064 return err
6165 }
@@ -68,7 +72,8 @@ func (s *Sandbox) CheckReadPath(path string) error {
6872 return checkScopedPath("read", path, s.Permissions.FS.Read)
6973 }
7074
71-func (s *Sandbox) CheckWritePath(path string) error {
75+func (s *Sandbox) CheckWritePath(path string) (err error) {
76+ defer markPolicyError(&err)
7277 if err := s.CheckPath(path); err != nil {
7378 return err
7479 }
@@ -98,7 +103,8 @@ func checkScopedPath(op, path string, scopes []string) error {
98103 return fmt.Errorf("permission denied: %s %q is outside declared scopes", op, path)
99104 }
100105
101-func (s *Sandbox) CheckBashPermission(cmd string) error {
106+func (s *Sandbox) CheckBashPermission(cmd string) (err error) {
107+ defer markPolicyError(&err)
102108 if s == nil || s.Permissions == nil {
103109 return nil
104110 }
@@ -136,6 +142,15 @@ func (s *Sandbox) CheckBashPermission(cmd string) error {
136142 }
137143 }
138144
145+// Keep the original error text and errors.Is identity while marking denials
146+// for lifecycle observers. Nested jail and manifest checks wrap only once.
147+func markPolicyError(err *error) {
148+ var policy *core.ToolPolicyError
149+ if *err != nil && !errors.As(*err, &policy) {
150+ *err = &core.ToolPolicyError{Err: *err}
151+ }
152+}
153+
139154 func commandNames(cmd string) []string {
140155 cmd = strings.NewReplacer("&&", ";", "||", ";", "|", ";").Replace(cmd)
141156 parts := strings.Split(cmd, ";")
@@ -1,9 +1,12 @@
1 package tools1 package tools
2 2
3 import (3 import (
4+ "errors"
4 "fmt"5 "fmt"
5 "path/filepath"6 "path/filepath"
6 "strings"7 "strings"
8+
9+ "github.com/patriceckhart/zot/packages/core"
7 )10 )
8 11
9 // PermissionSet is the local-runtime permission contract for a packaged .zot agent.12 // PermissionSet is the local-runtime permission contract for a packaged .zot agent.
@@ -55,7 +58,8 @@ func (s *Sandbox) SetPermissions(p *PermissionSet) {
55 s.Permissions = p58 s.Permissions = p
56 }59 }
57 60
58-func (s *Sandbox) CheckReadPath(path string) error {61+func (s *Sandbox) CheckReadPath(path string) (err error) {
62+ defer markPolicyError(&err)
59 if err := s.CheckPath(path); err != nil {63 if err := s.CheckPath(path); err != nil {
60 return err64 return err
61 }65 }
@@ -68,7 +72,8 @@ func (s *Sandbox) CheckReadPath(path string) error {
68 return checkScopedPath("read", path, s.Permissions.FS.Read)72 return checkScopedPath("read", path, s.Permissions.FS.Read)
69 }73 }
70 74
71-func (s *Sandbox) CheckWritePath(path string) error {75+func (s *Sandbox) CheckWritePath(path string) (err error) {
76+ defer markPolicyError(&err)
72 if err := s.CheckPath(path); err != nil {77 if err := s.CheckPath(path); err != nil {
73 return err78 return err
74 }79 }
@@ -98,7 +103,8 @@ func checkScopedPath(op, path string, scopes []string) error {
98 return fmt.Errorf("permission denied: %s %q is outside declared scopes", op, path)103 return fmt.Errorf("permission denied: %s %q is outside declared scopes", op, path)
99 }104 }
100 105
101-func (s *Sandbox) CheckBashPermission(cmd string) error {106+func (s *Sandbox) CheckBashPermission(cmd string) (err error) {
107+ defer markPolicyError(&err)
102 if s == nil || s.Permissions == nil {108 if s == nil || s.Permissions == nil {
103 return nil109 return nil
104 }110 }
@@ -136,6 +142,15 @@ func (s *Sandbox) CheckBashPermission(cmd string) error {
136 }142 }
137 }143 }
138 144
145+// Keep the original error text and errors.Is identity while marking denials
146+// for lifecycle observers. Nested jail and manifest checks wrap only once.
147+func markPolicyError(err *error) {
148+ var policy *core.ToolPolicyError
149+ if *err != nil && !errors.As(*err, &policy) {
150+ *err = &core.ToolPolicyError{Err: *err}
151+ }
152+}
153+
139 func commandNames(cmd string) []string {154 func commandNames(cmd string) []string {
140 cmd = strings.NewReplacer("&&", ";", "||", ";", "|", ";").Replace(cmd)155 cmd = strings.NewReplacer("&&", ";", "||", ";", "|", ";").Replace(cmd)
141 parts := strings.Split(cmd, ";")156 parts := strings.Split(cmd, ";")
M packages/agent/tools/permissions_test.go +26 -0
@@ -1,9 +1,12 @@
11 package tools
22
33 import (
4+ "errors"
45 "path/filepath"
56 "strings"
67 "testing"
8+
9+ "github.com/patriceckhart/zot/packages/core"
710 )
811
912 func TestPermissionSetEmptyScopesDenyAccess(t *testing.T) {
@@ -83,3 +86,26 @@ func TestBashModeAskAllows(t *testing.T) {
8386 t.Fatalf("ask mode rejected command: %v", err)
8487 }
8588 }
89+
90+func TestPolicyRefusalsCarryTypedOutcomes(t *testing.T) {
91+ s := NewSandbox(t.TempDir())
92+ s.SetPermissions(&PermissionSet{})
93+ for _, err := range []error{
94+ s.CheckReadPath(filepath.Join(s.Root, "file")),
95+ s.CheckWritePath(filepath.Join(s.Root, "file")),
96+ s.CheckBashPermission("echo ok"),
97+ (&PowerShellTool{Sandbox: s}).checkPermission("Write-Output ok"),
98+ } {
99+ var policy *core.ToolPolicyError
100+ if !errors.As(err, &policy) {
101+ t.Fatalf("untyped policy refusal: %v", err)
102+ }
103+ }
104+ s.Lock()
105+ for _, err := range []error{s.CheckPath(t.TempDir()), s.CheckCommand("sudo true"), (&PowerShellTool{Sandbox: s}).checkPermission("")} {
106+ var policy *core.ToolPolicyError
107+ if !errors.As(err, &policy) {
108+ t.Fatalf("untyped jail refusal: %v", err)
109+ }
110+ }
111+}
@@ -1,9 +1,12 @@
1 package tools1 package tools
2 2
3 import (3 import (
4+ "errors"
4 "path/filepath"5 "path/filepath"
5 "strings"6 "strings"
6 "testing"7 "testing"
8+
9+ "github.com/patriceckhart/zot/packages/core"
7 )10 )
8 11
9 func TestPermissionSetEmptyScopesDenyAccess(t *testing.T) {12 func TestPermissionSetEmptyScopesDenyAccess(t *testing.T) {
@@ -83,3 +86,26 @@ func TestBashModeAskAllows(t *testing.T) {
83 t.Fatalf("ask mode rejected command: %v", err)86 t.Fatalf("ask mode rejected command: %v", err)
84 }87 }
85 }88 }
89+
90+func TestPolicyRefusalsCarryTypedOutcomes(t *testing.T) {
91+ s := NewSandbox(t.TempDir())
92+ s.SetPermissions(&PermissionSet{})
93+ for _, err := range []error{
94+ s.CheckReadPath(filepath.Join(s.Root, "file")),
95+ s.CheckWritePath(filepath.Join(s.Root, "file")),
96+ s.CheckBashPermission("echo ok"),
97+ (&PowerShellTool{Sandbox: s}).checkPermission("Write-Output ok"),
98+ } {
99+ var policy *core.ToolPolicyError
100+ if !errors.As(err, &policy) {
101+ t.Fatalf("untyped policy refusal: %v", err)
102+ }
103+ }
104+ s.Lock()
105+ for _, err := range []error{s.CheckPath(t.TempDir()), s.CheckCommand("sudo true"), (&PowerShellTool{Sandbox: s}).checkPermission("")} {
106+ var policy *core.ToolPolicyError
107+ if !errors.As(err, &policy) {
108+ t.Fatalf("untyped jail refusal: %v", err)
109+ }
110+ }
111+}
M packages/agent/tools/powershell.go +2 -1
@@ -36,7 +36,8 @@ func (t *PowerShellTool) Execute(ctx context.Context, raw json.RawMessage, progr
3636 })
3737 }
3838
39-func (t *PowerShellTool) checkPermission(_ string) error {
39+func (t *PowerShellTool) checkPermission(_ string) (err error) {
40+ defer markPolicyError(&err)
4041 // The existing shell heuristics parse neither PowerShell expressions nor
4142 // cmdlets. Do not treat them as enforcement for a different language.
4243 if t.Sandbox.Locked() {
@@ -36,7 +36,8 @@ func (t *PowerShellTool) Execute(ctx context.Context, raw json.RawMessage, progr
36 })36 })
37 }37 }
38 38
39-func (t *PowerShellTool) checkPermission(_ string) error {39+func (t *PowerShellTool) checkPermission(_ string) (err error) {
40+ defer markPolicyError(&err)
40 // The existing shell heuristics parse neither PowerShell expressions nor41 // The existing shell heuristics parse neither PowerShell expressions nor
41 // cmdlets. Do not treat them as enforcement for a different language.42 // cmdlets. Do not treat them as enforcement for a different language.
42 if t.Sandbox.Locked() {43 if t.Sandbox.Locked() {
M packages/agent/tools/sandbox.go +4 -2
@@ -38,7 +38,8 @@ func (s *Sandbox) Locked() bool { return s != nil && s.locked.Load() }
3838 // CheckPath verifies that path resolves inside the sandbox root.
3939 // Returns an error describing the violation if not. No-op when unlocked.
4040 // Callers should pass an already-absolute path (use resolvePath() first).
41-func (s *Sandbox) CheckPath(path string) error {
41+func (s *Sandbox) CheckPath(path string) (err error) {
42+ defer markPolicyError(&err)
4243 if !s.Locked() {
4344 return nil
4445 }
@@ -98,7 +99,8 @@ func (s *Sandbox) DisplayPath(abs, given string) string {
9899 // when jailed. We cannot fully sandbox a shell, but we can reject the
99100 // most obvious escapes so the model does not accidentally touch files
100101 // outside root via shell arguments or redirections.
101-func (s *Sandbox) CheckCommand(cmd string) error {
102+func (s *Sandbox) CheckCommand(cmd string) (err error) {
103+ defer markPolicyError(&err)
102104 if !s.Locked() {
103105 return nil
104106 }
@@ -38,7 +38,8 @@ func (s *Sandbox) Locked() bool { return s != nil && s.locked.Load() }
38 // CheckPath verifies that path resolves inside the sandbox root.38 // CheckPath verifies that path resolves inside the sandbox root.
39 // Returns an error describing the violation if not. No-op when unlocked.39 // Returns an error describing the violation if not. No-op when unlocked.
40 // Callers should pass an already-absolute path (use resolvePath() first).40 // Callers should pass an already-absolute path (use resolvePath() first).
41-func (s *Sandbox) CheckPath(path string) error {41+func (s *Sandbox) CheckPath(path string) (err error) {
42+ defer markPolicyError(&err)
42 if !s.Locked() {43 if !s.Locked() {
43 return nil44 return nil
44 }45 }
@@ -98,7 +99,8 @@ func (s *Sandbox) DisplayPath(abs, given string) string {
98 // when jailed. We cannot fully sandbox a shell, but we can reject the99 // when jailed. We cannot fully sandbox a shell, but we can reject the
99 // most obvious escapes so the model does not accidentally touch files100 // most obvious escapes so the model does not accidentally touch files
100 // outside root via shell arguments or redirections.101 // outside root via shell arguments or redirections.
101-func (s *Sandbox) CheckCommand(cmd string) error {102+func (s *Sandbox) CheckCommand(cmd string) (err error) {
103+ defer markPolicyError(&err)
102 if !s.Locked() {104 if !s.Locked() {
103 return nil105 return nil
104 }106 }
M packages/core/agent.go +71 -10
@@ -48,6 +48,10 @@ type Agent struct {
4848 // malformed modifiedArgs is ignored.
4949 BeforeToolExecute func(call provider.ToolCallBlock) (allowed bool, reason string, modifiedArgs json.RawMessage)
5050
51+ // BeforeToolExecuteContext is the context-aware variant. When set it takes
52+ // precedence over BeforeToolExecute, retaining the legacy hook for embedders.
53+ BeforeToolExecuteContext func(context.Context, provider.ToolCallBlock) (bool, string, json.RawMessage)
54+
5155 // BeforeStart replaces the complete system prompt once per runtime session
5256 // or explicit prompt/model reset, before any turn starts. It is not called
5357 // for ordinary follow-up messages or tool-loop steps.
@@ -78,7 +82,9 @@ type Agent struct {
7882 // OnEvent, if set, mirrors every AgentEvent the loop emits to
7983 // this callback in addition to the per-Prompt sink. Used by the
8084 // extension manager to fan events out to subscribed extensions
81- // without each caller having to compose sinks manually.
85+ // without each caller having to compose sinks manually. Prompt submission
86+ // and compaction lifecycle notifications go only to OnEvent, preserving the
87+ // existing per-Prompt stream consumed by RPC and embedded SDK clients.
8288 OnEvent func(AgentEvent)
8389
8490 // OnMessageAppended, if set, fires every time a message is
@@ -149,6 +155,9 @@ func (a *Agent) QueueMessage(text string) bool {
149155 if text == "" {
150156 return false
151157 }
158+ if a.OnEvent != nil {
159+ a.OnEvent(EvPromptSubmit{Text: text, Queued: true})
160+ }
152161 a.mu.Lock()
153162 a.queued = append(a.queued, text)
154163 a.mu.Unlock()
@@ -340,6 +349,9 @@ func (a *Agent) Prompt(ctx context.Context, text string, images []provider.Image
340349 sink = func(AgentEvent) {}
341350 }
342351 sink = a.wrapSink(sink)
352+ if a.OnEvent != nil {
353+ a.OnEvent(EvPromptSubmit{Text: text, ImageCount: len(images)})
354+ }
343355 content := []provider.Content{}
344356 if text != "" {
345357 content = append(content, provider.TextBlock{Text: text})
@@ -742,7 +754,6 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
742754 addedTools = append(addedTools, name)
743755 }
744756 }
745- sink(EvToolResult{ID: tc.ID, Result: res})
746757 }
747758
748759 return provider.Message{
@@ -753,7 +764,29 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
753764 }, hadError
754765 }
755766
756-func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink func(AgentEvent)) ToolResult {
767+func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink func(AgentEvent)) (result ToolResult) {
768+ args := tc.Arguments
769+ status := ""
770+ executed := false
771+ defer func() {
772+ if status == "" {
773+ status = "completed"
774+ if result.IsError {
775+ status = "failed"
776+ switch result.Status {
777+ case "cancelled", "timed_out", "blocked":
778+ status = result.Status
779+ }
780+ }
781+ }
782+ if sink != nil {
783+ sink(EvToolResult{ID: tc.ID, Name: tc.Name, Args: args, Status: status, Executed: executed, Result: result})
784+ }
785+ }()
786+ if err := ctx.Err(); err != nil {
787+ status = executionErrorStatus(err)
788+ return ToolResult{IsError: true, Content: []provider.Content{provider.TextBlock{Text: "aborted: " + err.Error()}}}
789+ }
757790 tool, err := a.Tools.Get(tc.Name)
758791 if err != nil {
759792 return ToolResult{
@@ -762,17 +795,28 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink
762795 }
763796 }
764797
765- args := tc.Arguments
766-
767798 // Intercept hook: an extension or other guard can refuse the
768799 // call before any side effect happens, OR rewrite the args
769800 // seen by the tool. The model sees the reason as the tool
770801 // error, learns from it, and (typically) proposes a different
771802 // action; rewrites are invisible to the model (they apply only
772803 // to the execution).
773- if a.BeforeToolExecute != nil {
774- allowed, reason, modified := a.BeforeToolExecute(tc)
804+ before := a.BeforeToolExecute
805+ if a.BeforeToolExecuteContext != nil {
806+ before = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
807+ return a.BeforeToolExecuteContext(ctx, call)
808+ }
809+ }
810+ if before != nil {
811+ allowed, reason, modified := before(tc)
812+ if len(modified) > 0 && json.Valid(modified) {
813+ args = modified
814+ }
775815 if !allowed {
816+ status = "blocked"
817+ if ctx.Err() != nil {
818+ status = executionErrorStatus(ctx.Err())
819+ }
776820 if reason == "" {
777821 reason = "tool call refused by extension guard"
778822 }
@@ -781,11 +825,12 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink
781825 IsError: true,
782826 }
783827 }
784- if len(modified) > 0 && json.Valid(modified) {
785- args = modified
786- }
787828 }
788829
830+ if err := ctx.Err(); err != nil {
831+ status = executionErrorStatus(err)
832+ return ToolResult{IsError: true, Content: []provider.Content{provider.TextBlock{Text: "aborted: " + err.Error()}}}
833+ }
789834 if len(args) == 0 {
790835 args = json.RawMessage("{}")
791836 }
@@ -801,10 +846,12 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink
801846 }
802847 }
803848 }()
849+ executed = true
804850 out, err := tool.Execute(ctx, args, func(text string) {
805851 sink(EvToolProgress{ID: tc.ID, Text: text})
806852 })
807853 if err != nil {
854+ status = executionErrorStatus(err)
808855 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
809856 res = ToolResult{
810857 Content: []provider.Content{provider.TextBlock{Text: "aborted: " + err.Error()}},
@@ -823,6 +870,20 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink
823870 return res
824871 }
825872
873+func executionErrorStatus(err error) string {
874+ if errors.Is(err, context.DeadlineExceeded) {
875+ return "timed_out"
876+ }
877+ if errors.Is(err, context.Canceled) {
878+ return "cancelled"
879+ }
880+ var policyErr *ToolPolicyError
881+ if errors.As(err, &policyErr) {
882+ return "blocked"
883+ }
884+ return "failed"
885+}
886+
826887 // extractText concatenates all TextBlock content in a message. Used
827888 // by BeforeAssistantMessage so guards see a single string instead of
828889 // having to walk provider.Content themselves.
@@ -48,6 +48,10 @@ 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+ // BeforeToolExecuteContext is the context-aware variant. When set it takes
52+ // precedence over BeforeToolExecute, retaining the legacy hook for embedders.
53+ BeforeToolExecuteContext func(context.Context, provider.ToolCallBlock) (bool, string, json.RawMessage)
54+
51 // BeforeStart replaces the complete system prompt once per runtime session55 // BeforeStart replaces the complete system prompt once per runtime session
52 // or explicit prompt/model reset, before any turn starts. It is not called56 // or explicit prompt/model reset, before any turn starts. It is not called
53 // for ordinary follow-up messages or tool-loop steps.57 // for ordinary follow-up messages or tool-loop steps.
@@ -78,7 +82,9 @@ type Agent struct {
78 // OnEvent, if set, mirrors every AgentEvent the loop emits to82 // OnEvent, if set, mirrors every AgentEvent the loop emits to
79 // this callback in addition to the per-Prompt sink. Used by the83 // this callback in addition to the per-Prompt sink. Used by the
80 // extension manager to fan events out to subscribed extensions84 // extension manager to fan events out to subscribed extensions
81- // without each caller having to compose sinks manually.85+ // without each caller having to compose sinks manually. Prompt submission
86+ // and compaction lifecycle notifications go only to OnEvent, preserving the
87+ // existing per-Prompt stream consumed by RPC and embedded SDK clients.
82 OnEvent func(AgentEvent)88 OnEvent func(AgentEvent)
83 89
84 // OnMessageAppended, if set, fires every time a message is90 // OnMessageAppended, if set, fires every time a message is
@@ -149,6 +155,9 @@ func (a *Agent) QueueMessage(text string) bool {
149 if text == "" {155 if text == "" {
150 return false156 return false
151 }157 }
158+ if a.OnEvent != nil {
159+ a.OnEvent(EvPromptSubmit{Text: text, Queued: true})
160+ }
152 a.mu.Lock()161 a.mu.Lock()
153 a.queued = append(a.queued, text)162 a.queued = append(a.queued, text)
154 a.mu.Unlock()163 a.mu.Unlock()
@@ -340,6 +349,9 @@ func (a *Agent) Prompt(ctx context.Context, text string, images []provider.Image
340 sink = func(AgentEvent) {}349 sink = func(AgentEvent) {}
341 }350 }
342 sink = a.wrapSink(sink)351 sink = a.wrapSink(sink)
352+ if a.OnEvent != nil {
353+ a.OnEvent(EvPromptSubmit{Text: text, ImageCount: len(images)})
354+ }
343 content := []provider.Content{}355 content := []provider.Content{}
344 if text != "" {356 if text != "" {
345 content = append(content, provider.TextBlock{Text: text})357 content = append(content, provider.TextBlock{Text: text})
@@ -742,7 +754,6 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
742 addedTools = append(addedTools, name)754 addedTools = append(addedTools, name)
743 }755 }
744 }756 }
745- sink(EvToolResult{ID: tc.ID, Result: res})
746 }757 }
747 758
748 return provider.Message{759 return provider.Message{
@@ -753,7 +764,29 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
753 }, hadError764 }, hadError
754 }765 }
755 766
756-func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink func(AgentEvent)) ToolResult {767+func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink func(AgentEvent)) (result ToolResult) {
768+ args := tc.Arguments
769+ status := ""
770+ executed := false
771+ defer func() {
772+ if status == "" {
773+ status = "completed"
774+ if result.IsError {
775+ status = "failed"
776+ switch result.Status {
777+ case "cancelled", "timed_out", "blocked":
778+ status = result.Status
779+ }
780+ }
781+ }
782+ if sink != nil {
783+ sink(EvToolResult{ID: tc.ID, Name: tc.Name, Args: args, Status: status, Executed: executed, Result: result})
784+ }
785+ }()
786+ if err := ctx.Err(); err != nil {
787+ status = executionErrorStatus(err)
788+ return ToolResult{IsError: true, Content: []provider.Content{provider.TextBlock{Text: "aborted: " + err.Error()}}}
789+ }
757 tool, err := a.Tools.Get(tc.Name)790 tool, err := a.Tools.Get(tc.Name)
758 if err != nil {791 if err != nil {
759 return ToolResult{792 return ToolResult{
@@ -762,17 +795,28 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink
762 }795 }
763 }796 }
764 797
765- args := tc.Arguments
766-
767 // Intercept hook: an extension or other guard can refuse the798 // Intercept hook: an extension or other guard can refuse the
768 // call before any side effect happens, OR rewrite the args799 // call before any side effect happens, OR rewrite the args
769 // seen by the tool. The model sees the reason as the tool800 // seen by the tool. The model sees the reason as the tool
770 // error, learns from it, and (typically) proposes a different801 // error, learns from it, and (typically) proposes a different
771 // action; rewrites are invisible to the model (they apply only802 // action; rewrites are invisible to the model (they apply only
772 // to the execution).803 // to the execution).
773- if a.BeforeToolExecute != nil {804+ before := a.BeforeToolExecute
774- allowed, reason, modified := a.BeforeToolExecute(tc)805+ if a.BeforeToolExecuteContext != nil {
806+ before = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
807+ return a.BeforeToolExecuteContext(ctx, call)
808+ }
809+ }
810+ if before != nil {
811+ allowed, reason, modified := before(tc)
812+ if len(modified) > 0 && json.Valid(modified) {
813+ args = modified
814+ }
775 if !allowed {815 if !allowed {
816+ status = "blocked"
817+ if ctx.Err() != nil {
818+ status = executionErrorStatus(ctx.Err())
819+ }
776 if reason == "" {820 if reason == "" {
777 reason = "tool call refused by extension guard"821 reason = "tool call refused by extension guard"
778 }822 }
@@ -781,11 +825,12 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink
781 IsError: true,825 IsError: true,
782 }826 }
783 }827 }
784- if len(modified) > 0 && json.Valid(modified) {
785- args = modified
786- }
787 }828 }
788 829
830+ if err := ctx.Err(); err != nil {
831+ status = executionErrorStatus(err)
832+ return ToolResult{IsError: true, Content: []provider.Content{provider.TextBlock{Text: "aborted: " + err.Error()}}}
833+ }
789 if len(args) == 0 {834 if len(args) == 0 {
790 args = json.RawMessage("{}")835 args = json.RawMessage("{}")
791 }836 }
@@ -801,10 +846,12 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink
801 }846 }
802 }847 }
803 }()848 }()
849+ executed = true
804 out, err := tool.Execute(ctx, args, func(text string) {850 out, err := tool.Execute(ctx, args, func(text string) {
805 sink(EvToolProgress{ID: tc.ID, Text: text})851 sink(EvToolProgress{ID: tc.ID, Text: text})
806 })852 })
807 if err != nil {853 if err != nil {
854+ status = executionErrorStatus(err)
808 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {855 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
809 res = ToolResult{856 res = ToolResult{
810 Content: []provider.Content{provider.TextBlock{Text: "aborted: " + err.Error()}},857 Content: []provider.Content{provider.TextBlock{Text: "aborted: " + err.Error()}},
@@ -823,6 +870,20 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink
823 return res870 return res
824 }871 }
825 872
873+func executionErrorStatus(err error) string {
874+ if errors.Is(err, context.DeadlineExceeded) {
875+ return "timed_out"
876+ }
877+ if errors.Is(err, context.Canceled) {
878+ return "cancelled"
879+ }
880+ var policyErr *ToolPolicyError
881+ if errors.As(err, &policyErr) {
882+ return "blocked"
883+ }
884+ return "failed"
885+}
886+
826 // extractText concatenates all TextBlock content in a message. Used887 // extractText concatenates all TextBlock content in a message. Used
827 // by BeforeAssistantMessage so guards see a single string instead of888 // by BeforeAssistantMessage so guards see a single string instead of
828 // having to walk provider.Content themselves.889 // having to walk provider.Content themselves.
M packages/core/compact.go +13 -0
@@ -2,6 +2,7 @@ package core
22
33 import (
44 "context"
5+ "crypto/rand"
56 "fmt"
67 "strconv"
78 "strings"
@@ -26,6 +27,18 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin
2627 msgs := append([]provider.Message(nil), a.messages...)
2728 a.mu.Unlock()
2829
30+ id := rand.Text()
31+ if a.OnEvent != nil {
32+ a.OnEvent(EvCompact{Phase: "pre", ID: id, MessageCount: len(msgs), TokenEstimate: len(serializeTranscript(msgs)) / 4})
33+ defer func() {
34+ after := a.Messages()
35+ status := "completed"
36+ if err != nil {
37+ status = executionErrorStatus(err)
38+ }
39+ a.OnEvent(EvCompact{Phase: "post", ID: id, MessageCount: len(after), TokenEstimate: len(serializeTranscript(after)) / 4, Status: status, Err: err})
40+ }()
41+ }
2942 if len(msgs) == 0 {
3043 return "", fmt.Errorf("nothing to compact")
3144 }
@@ -2,6 +2,7 @@ package core
2 2
3 import (3 import (
4 "context"4 "context"
5+ "crypto/rand"
5 "fmt"6 "fmt"
6 "strconv"7 "strconv"
7 "strings"8 "strings"
@@ -26,6 +27,18 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin
26 msgs := append([]provider.Message(nil), a.messages...)27 msgs := append([]provider.Message(nil), a.messages...)
27 a.mu.Unlock()28 a.mu.Unlock()
28 29
30+ id := rand.Text()
31+ if a.OnEvent != nil {
32+ a.OnEvent(EvCompact{Phase: "pre", ID: id, MessageCount: len(msgs), TokenEstimate: len(serializeTranscript(msgs)) / 4})
33+ defer func() {
34+ after := a.Messages()
35+ status := "completed"
36+ if err != nil {
37+ status = executionErrorStatus(err)
38+ }
39+ a.OnEvent(EvCompact{Phase: "post", ID: id, MessageCount: len(after), TokenEstimate: len(serializeTranscript(after)) / 4, Status: status, Err: err})
40+ }()
41+ }
29 if len(msgs) == 0 {42 if len(msgs) == 0 {
30 return "", fmt.Errorf("nothing to compact")43 return "", fmt.Errorf("nothing to compact")
31 }44 }
A packages/core/compact_lifecycle_test.go +58 -0
@@ -0,0 +1,58 @@
1+package core
2+
3+import (
4+ "context"
5+ "errors"
6+ "reflect"
7+ "testing"
8+
9+ "github.com/patriceckhart/zot/packages/provider"
10+)
11+
12+type compactLifecycleClient struct{ err error }
13+
14+func (*compactLifecycleClient) Name() string { return "test" }
15+func (c *compactLifecycleClient) Stream(context.Context, provider.Request) (<-chan provider.Event, error) {
16+ ch := make(chan provider.Event, 2)
17+ ch <- provider.EventTextDelta{Delta: "summary"}
18+ ch <- provider.EventDone{Err: c.err}
19+ close(ch)
20+ return ch, nil
21+}
22+
23+func TestCompactionTerminalOutcomes(t *testing.T) {
24+ for _, tc := range []struct {
25+ status string
26+ err error
27+ }{
28+ {"completed", nil}, {"failed", errors.New("failure")}, {"cancelled", context.Canceled}, {"timed_out", context.DeadlineExceeded},
29+ } {
30+ t.Run(tc.status, func(t *testing.T) {
31+ a := NewAgent(&compactLifecycleClient{err: tc.err}, "test", "", nil)
32+ before := []provider.Message{
33+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first message"}}},
34+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "second message"}}},
35+ }
36+ a.SetMessages(before)
37+ var events []EvCompact
38+ a.OnEvent = func(e AgentEvent) { events = append(events, e.(EvCompact)) }
39+ _, err := a.Compact(context.Background(), 0, nil)
40+ if !errors.Is(err, tc.err) {
41+ t.Fatalf("error = %v", err)
42+ }
43+ if len(events) != 2 || events[0].Phase != "pre" || events[1].Phase != "post" || events[1].Status != tc.status || events[0].ID == "" || events[0].ID != events[1].ID {
44+ t.Fatalf("events: %+v", events)
45+ }
46+ if events[0].MessageCount != 2 || events[0].TokenEstimate <= 0 {
47+ t.Fatalf("pre counts: %+v", events[0])
48+ }
49+ if tc.err == nil {
50+ if events[1].MessageCount != 1 || events[1].TokenEstimate <= 0 {
51+ t.Fatalf("post counts: %+v", events[1])
52+ }
53+ } else if !reflect.DeepEqual(a.Messages(), before) {
54+ t.Fatal("failed compaction changed transcript")
55+ }
56+ })
57+ }
58+}
@@ -0,0 +1,58 @@
1+package core
2+
3+import (
4+ "context"
5+ "errors"
6+ "reflect"
7+ "testing"
8+
9+ "github.com/patriceckhart/zot/packages/provider"
10+)
11+
12+type compactLifecycleClient struct{ err error }
13+
14+func (*compactLifecycleClient) Name() string { return "test" }
15+func (c *compactLifecycleClient) Stream(context.Context, provider.Request) (<-chan provider.Event, error) {
16+ ch := make(chan provider.Event, 2)
17+ ch <- provider.EventTextDelta{Delta: "summary"}
18+ ch <- provider.EventDone{Err: c.err}
19+ close(ch)
20+ return ch, nil
21+}
22+
23+func TestCompactionTerminalOutcomes(t *testing.T) {
24+ for _, tc := range []struct {
25+ status string
26+ err error
27+ }{
28+ {"completed", nil}, {"failed", errors.New("failure")}, {"cancelled", context.Canceled}, {"timed_out", context.DeadlineExceeded},
29+ } {
30+ t.Run(tc.status, func(t *testing.T) {
31+ a := NewAgent(&compactLifecycleClient{err: tc.err}, "test", "", nil)
32+ before := []provider.Message{
33+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first message"}}},
34+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "second message"}}},
35+ }
36+ a.SetMessages(before)
37+ var events []EvCompact
38+ a.OnEvent = func(e AgentEvent) { events = append(events, e.(EvCompact)) }
39+ _, err := a.Compact(context.Background(), 0, nil)
40+ if !errors.Is(err, tc.err) {
41+ t.Fatalf("error = %v", err)
42+ }
43+ if len(events) != 2 || events[0].Phase != "pre" || events[1].Phase != "post" || events[1].Status != tc.status || events[0].ID == "" || events[0].ID != events[1].ID {
44+ t.Fatalf("events: %+v", events)
45+ }
46+ if events[0].MessageCount != 2 || events[0].TokenEstimate <= 0 {
47+ t.Fatalf("pre counts: %+v", events[0])
48+ }
49+ if tc.err == nil {
50+ if events[1].MessageCount != 1 || events[1].TokenEstimate <= 0 {
51+ t.Fatalf("post counts: %+v", events[1])
52+ }
53+ } else if !reflect.DeepEqual(a.Messages(), before) {
54+ t.Fatal("failed compaction changed transcript")
55+ }
56+ })
57+ }
58+}
M packages/core/confirm.go +22 -8
@@ -12,6 +12,8 @@ import (
1212 // of the session.
1313 type ConfirmDecision struct {
1414 Allow bool
15+ // Source identifies the authority: user, remembered, policy, or yolo.
16+ Source string
1517 // Reason is shown to the model as the tool error when
1618 // Allow=false. Examples: "user declined", "user refused: rm -rf
1719 // looks dangerous".
@@ -63,9 +65,10 @@ type ToolCallConfirmer interface {
6365 type ConfirmGate struct {
6466 inner Confirmer
6567
66- mu sync.Mutex
67- allowAll bool
68- allowedTool map[string]bool
68+ mu sync.Mutex
69+ allowAll bool
70+ allowAllSource string
71+ allowedTool map[string]bool
6972 }
7073
7174 // NewConfirmGate returns a gate backed by inner. Inner can be nil;
@@ -91,22 +94,29 @@ func (g *ConfirmGate) Check(toolName, preview string) (bool, string, json.RawMes
9194 // CheckToolCall behaves like Check and additionally gives rich confirmation
9295 // UIs the call ID and side-effect-free preview content.
9396 func (g *ConfirmGate) CheckToolCall(call ToolCallConfirmation) (bool, string, json.RawMessage) {
97+ d := g.DecideToolCall(call)
98+ return d.Allow, d.Reason, nil
99+}
100+
101+// DecideToolCall preserves decision provenance for observational consumers.
102+func (g *ConfirmGate) DecideToolCall(call ToolCallConfirmation) ConfirmDecision {
94103 if g == nil {
95- return true, "", nil
104+ return ConfirmDecision{Allow: true, Source: "yolo"}
96105 }
97106 g.mu.Lock()
98107 if g.allowAll {
108+ source := g.allowAllSource
99109 g.mu.Unlock()
100- return true, "", nil
110+ return ConfirmDecision{Allow: true, Source: source}
101111 }
102112 if g.allowedTool[call.Name] {
103113 g.mu.Unlock()
104- return true, "", nil
114+ return ConfirmDecision{Allow: true, Source: "remembered"}
105115 }
106116 inner := g.inner
107117 g.mu.Unlock()
108118 if inner == nil {
109- return false, "tool call refused: --no-yolo is active and there is no interactive prompt in this mode; ask the user what to do instead", nil
119+ return ConfirmDecision{Source: "policy", Reason: "tool call refused: --no-yolo is active and there is no interactive prompt in this mode; ask the user what to do instead"}
110120 }
111121
112122 var decision ConfirmDecision
@@ -120,6 +130,7 @@ func (g *ConfirmGate) CheckToolCall(call ToolCallConfirmation) (bool, string, js
120130 if decision.Allow {
121131 if decision.RememberAll {
122132 g.allowAll = true
133+ g.allowAllSource = "remembered"
123134 }
124135 if decision.RememberTool {
125136 g.allowedTool[call.Name] = true
@@ -131,7 +142,9 @@ func (g *ConfirmGate) CheckToolCall(call ToolCallConfirmation) (bool, string, js
131142 if !decision.Allow && reason == "" {
132143 reason = "tool call refused by user"
133144 }
134- return decision.Allow, reason, nil
145+ decision.Reason = reason
146+ decision.Source = "user"
147+ return decision
135148 }
136149
137150 // Reset clears the session memory. Invoked when the user toggles
@@ -155,6 +168,7 @@ func (g *ConfirmGate) AllowAll() {
155168 }
156169 g.mu.Lock()
157170 g.allowAll = true
171+ g.allowAllSource = "yolo"
158172 g.mu.Unlock()
159173 }
160174
@@ -12,6 +12,8 @@ import (
12 // of the session.12 // of the session.
13 type ConfirmDecision struct {13 type ConfirmDecision struct {
14 Allow bool14 Allow bool
15+ // Source identifies the authority: user, remembered, policy, or yolo.
16+ Source string
15 // Reason is shown to the model as the tool error when17 // Reason is shown to the model as the tool error when
16 // Allow=false. Examples: "user declined", "user refused: rm -rf18 // Allow=false. Examples: "user declined", "user refused: rm -rf
17 // looks dangerous".19 // looks dangerous".
@@ -63,9 +65,10 @@ type ToolCallConfirmer interface {
63 type ConfirmGate struct {65 type ConfirmGate struct {
64 inner Confirmer66 inner Confirmer
65 67
66- mu sync.Mutex68+ mu sync.Mutex
67- allowAll bool69+ allowAll bool
68- allowedTool map[string]bool70+ allowAllSource string
71+ allowedTool map[string]bool
69 }72 }
70 73
71 // NewConfirmGate returns a gate backed by inner. Inner can be nil;74 // NewConfirmGate returns a gate backed by inner. Inner can be nil;
@@ -91,22 +94,29 @@ func (g *ConfirmGate) Check(toolName, preview string) (bool, string, json.RawMes
91 // CheckToolCall behaves like Check and additionally gives rich confirmation94 // CheckToolCall behaves like Check and additionally gives rich confirmation
92 // UIs the call ID and side-effect-free preview content.95 // UIs the call ID and side-effect-free preview content.
93 func (g *ConfirmGate) CheckToolCall(call ToolCallConfirmation) (bool, string, json.RawMessage) {96 func (g *ConfirmGate) CheckToolCall(call ToolCallConfirmation) (bool, string, json.RawMessage) {
97+ d := g.DecideToolCall(call)
98+ return d.Allow, d.Reason, nil
99+}
100+
101+// DecideToolCall preserves decision provenance for observational consumers.
102+func (g *ConfirmGate) DecideToolCall(call ToolCallConfirmation) ConfirmDecision {
94 if g == nil {103 if g == nil {
95- return true, "", nil104+ return ConfirmDecision{Allow: true, Source: "yolo"}
96 }105 }
97 g.mu.Lock()106 g.mu.Lock()
98 if g.allowAll {107 if g.allowAll {
108+ source := g.allowAllSource
99 g.mu.Unlock()109 g.mu.Unlock()
100- return true, "", nil110+ return ConfirmDecision{Allow: true, Source: source}
101 }111 }
102 if g.allowedTool[call.Name] {112 if g.allowedTool[call.Name] {
103 g.mu.Unlock()113 g.mu.Unlock()
104- return true, "", nil114+ return ConfirmDecision{Allow: true, Source: "remembered"}
105 }115 }
106 inner := g.inner116 inner := g.inner
107 g.mu.Unlock()117 g.mu.Unlock()
108 if inner == nil {118 if inner == nil {
109- return false, "tool call refused: --no-yolo is active and there is no interactive prompt in this mode; ask the user what to do instead", nil119+ return ConfirmDecision{Source: "policy", Reason: "tool call refused: --no-yolo is active and there is no interactive prompt in this mode; ask the user what to do instead"}
110 }120 }
111 121
112 var decision ConfirmDecision122 var decision ConfirmDecision
@@ -120,6 +130,7 @@ func (g *ConfirmGate) CheckToolCall(call ToolCallConfirmation) (bool, string, js
120 if decision.Allow {130 if decision.Allow {
121 if decision.RememberAll {131 if decision.RememberAll {
122 g.allowAll = true132 g.allowAll = true
133+ g.allowAllSource = "remembered"
123 }134 }
124 if decision.RememberTool {135 if decision.RememberTool {
125 g.allowedTool[call.Name] = true136 g.allowedTool[call.Name] = true
@@ -131,7 +142,9 @@ func (g *ConfirmGate) CheckToolCall(call ToolCallConfirmation) (bool, string, js
131 if !decision.Allow && reason == "" {142 if !decision.Allow && reason == "" {
132 reason = "tool call refused by user"143 reason = "tool call refused by user"
133 }144 }
134- return decision.Allow, reason, nil145+ decision.Reason = reason
146+ decision.Source = "user"
147+ return decision
135 }148 }
136 149
137 // Reset clears the session memory. Invoked when the user toggles150 // Reset clears the session memory. Invoked when the user toggles
@@ -155,6 +168,7 @@ func (g *ConfirmGate) AllowAll() {
155 }168 }
156 g.mu.Lock()169 g.mu.Lock()
157 g.allowAll = true170 g.allowAll = true
171+ g.allowAllSource = "yolo"
158 g.mu.Unlock()172 g.mu.Unlock()
159 }173 }
160 174
A packages/core/confirm_lifecycle_test.go +35 -0
@@ -0,0 +1,35 @@
1+package core
2+
3+import "testing"
4+
5+func TestConfirmationDecisionSources(t *testing.T) {
6+ call := ToolCallConfirmation{ID: "call", Name: "bash"}
7+ var nilGate *ConfirmGate
8+ if d := nilGate.DecideToolCall(call); !d.Allow || d.Source != "yolo" {
9+ t.Fatalf("nil gate: %+v", d)
10+ }
11+ if d := NewConfirmGate(nil).DecideToolCall(call); d.Allow || d.Source != "policy" {
12+ t.Fatalf("no confirmer: %+v", d)
13+ }
14+ for _, rememberAll := range []bool{false, true} {
15+ inner := &recordingConfirmer{replies: []ConfirmDecision{{Allow: true, RememberTool: !rememberAll, RememberAll: rememberAll}}}
16+ gate := NewConfirmGate(inner)
17+ if d := gate.DecideToolCall(call); !d.Allow || d.Source != "user" {
18+ t.Fatalf("first: %+v", d)
19+ }
20+ if d := gate.DecideToolCall(call); !d.Allow || d.Source != "remembered" {
21+ t.Fatalf("remembered: %+v", d)
22+ }
23+ if len(inner.calls) != 1 {
24+ t.Fatal("remembered decision prompted again")
25+ }
26+ gate.AllowAll()
27+ if d := gate.DecideToolCall(call); !d.Allow || d.Source != "yolo" {
28+ t.Fatalf("explicit yolo: %+v", d)
29+ }
30+ gate.Reset()
31+ if d := gate.DecideToolCall(call); d.Allow || d.Source != "user" {
32+ t.Fatalf("reset: %+v", d)
33+ }
34+ }
35+}
@@ -0,0 +1,35 @@
1+package core
2+
3+import "testing"
4+
5+func TestConfirmationDecisionSources(t *testing.T) {
6+ call := ToolCallConfirmation{ID: "call", Name: "bash"}
7+ var nilGate *ConfirmGate
8+ if d := nilGate.DecideToolCall(call); !d.Allow || d.Source != "yolo" {
9+ t.Fatalf("nil gate: %+v", d)
10+ }
11+ if d := NewConfirmGate(nil).DecideToolCall(call); d.Allow || d.Source != "policy" {
12+ t.Fatalf("no confirmer: %+v", d)
13+ }
14+ for _, rememberAll := range []bool{false, true} {
15+ inner := &recordingConfirmer{replies: []ConfirmDecision{{Allow: true, RememberTool: !rememberAll, RememberAll: rememberAll}}}
16+ gate := NewConfirmGate(inner)
17+ if d := gate.DecideToolCall(call); !d.Allow || d.Source != "user" {
18+ t.Fatalf("first: %+v", d)
19+ }
20+ if d := gate.DecideToolCall(call); !d.Allow || d.Source != "remembered" {
21+ t.Fatalf("remembered: %+v", d)
22+ }
23+ if len(inner.calls) != 1 {
24+ t.Fatal("remembered decision prompted again")
25+ }
26+ gate.AllowAll()
27+ if d := gate.DecideToolCall(call); !d.Allow || d.Source != "yolo" {
28+ t.Fatalf("explicit yolo: %+v", d)
29+ }
30+ gate.Reset()
31+ if d := gate.DecideToolCall(call); d.Allow || d.Source != "user" {
32+ t.Fatalf("reset: %+v", d)
33+ }
34+ }
35+}
M packages/core/events.go +29 -2
@@ -19,6 +19,16 @@ type EvTurnStart struct {
1919
2020 func (EvTurnStart) Type() string { return "turn_start" }
2121
22+// EvPromptSubmit observes accepted model input before it is appended or queued.
23+// Queued input can subsequently be withdrawn without reaching the model.
24+type EvPromptSubmit struct {
25+ Text string
26+ Queued bool
27+ ImageCount int
28+}
29+
30+func (EvPromptSubmit) Type() string { return "user_prompt_submit" }
31+
2232 type EvUserMessage struct {
2333 Message provider.Message
2434 }
@@ -85,12 +95,29 @@ type EvToolProgress struct {
8595 func (EvToolProgress) Type() string { return "tool_progress" }
8696
8797 type EvToolResult struct {
88- ID string
89- Result ToolResult
98+ ID string
99+ Name string
100+ Args json.RawMessage // effective arguments, or last proposed arguments if blocked
101+ Status string // completed, failed, blocked, cancelled, timed_out
102+ Executed bool // whether Tool.Execute was entered
103+ Result ToolResult
90104 }
91105
92106 func (EvToolResult) Type() string { return "tool_result" }
93107
108+// EvCompact brackets a compaction attempt, including failed and cancelled attempts.
109+// TokenEstimate is a rough text estimate, not provider token usage.
110+type EvCompact struct {
111+ Phase string
112+ ID string
113+ MessageCount int
114+ TokenEstimate int
115+ Status string
116+ Err error
117+}
118+
119+func (e EvCompact) Type() string { return e.Phase + "_compact" }
120+
94121 type EvUsage struct {
95122 Usage provider.Usage
96123 Cumulative provider.Usage
@@ -19,6 +19,16 @@ type EvTurnStart struct {
19 19
20 func (EvTurnStart) Type() string { return "turn_start" }20 func (EvTurnStart) Type() string { return "turn_start" }
21 21
22+// EvPromptSubmit observes accepted model input before it is appended or queued.
23+// Queued input can subsequently be withdrawn without reaching the model.
24+type EvPromptSubmit struct {
25+ Text string
26+ Queued bool
27+ ImageCount int
28+}
29+
30+func (EvPromptSubmit) Type() string { return "user_prompt_submit" }
31+
22 type EvUserMessage struct {32 type EvUserMessage struct {
23 Message provider.Message33 Message provider.Message
24 }34 }
@@ -85,12 +95,29 @@ type EvToolProgress struct {
85 func (EvToolProgress) Type() string { return "tool_progress" }95 func (EvToolProgress) Type() string { return "tool_progress" }
86 96
87 type EvToolResult struct {97 type EvToolResult struct {
88- ID string98+ ID string
89- Result ToolResult99+ Name string
100+ Args json.RawMessage // effective arguments, or last proposed arguments if blocked
101+ Status string // completed, failed, blocked, cancelled, timed_out
102+ Executed bool // whether Tool.Execute was entered
103+ Result ToolResult
90 }104 }
91 105
92 func (EvToolResult) Type() string { return "tool_result" }106 func (EvToolResult) Type() string { return "tool_result" }
93 107
108+// EvCompact brackets a compaction attempt, including failed and cancelled attempts.
109+// TokenEstimate is a rough text estimate, not provider token usage.
110+type EvCompact struct {
111+ Phase string
112+ ID string
113+ MessageCount int
114+ TokenEstimate int
115+ Status string
116+ Err error
117+}
118+
119+func (e EvCompact) Type() string { return e.Phase + "_compact" }
120+
94 type EvUsage struct {121 type EvUsage struct {
95 Usage provider.Usage122 Usage provider.Usage
96 Cumulative provider.Usage123 Cumulative provider.Usage
A packages/core/lifecycle_test.go +143 -0
@@ -0,0 +1,143 @@
1+package core
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "testing"
8+
9+ "github.com/patriceckhart/zot/packages/provider"
10+)
11+
12+type outcomeTool struct {
13+ result ToolResult
14+ err error
15+ panicValue bool
16+ called bool
17+ args json.RawMessage
18+}
19+
20+func (t *outcomeTool) Name() string { return "outcome" }
21+func (t *outcomeTool) Description() string { return "test" }
22+func (t *outcomeTool) Schema() json.RawMessage { return json.RawMessage(`{}`) }
23+func (t *outcomeTool) Execute(_ context.Context, args json.RawMessage, _ func(string)) (ToolResult, error) {
24+ t.called = true
25+ t.args = append(json.RawMessage(nil), args...)
26+ if t.panicValue {
27+ panic("test panic")
28+ }
29+ return t.result, t.err
30+}
31+
32+func TestToolResultLifecycle(t *testing.T) {
33+ for _, tc := range []struct {
34+ name, status string
35+ err error
36+ isError, panicValue, block, cancelBefore bool
37+ resultStatus string
38+ }{
39+ {name: "success", status: "completed"},
40+ {name: "error", status: "failed", err: errors.New("failure")},
41+ {name: "error result", status: "failed", isError: true},
42+ {name: "panic", status: "failed", panicValue: true},
43+ {name: "guard", status: "blocked", block: true},
44+ {name: "tool policy", status: "blocked", err: &ToolPolicyError{Err: errors.New("denied")}},
45+ {name: "cancel", status: "cancelled", err: context.Canceled},
46+ {name: "deadline", status: "timed_out", err: context.DeadlineExceeded},
47+ {name: "cancel before execution", status: "cancelled", cancelBefore: true},
48+ {name: "tool timeout result", status: "timed_out", isError: true, resultStatus: "timed_out"},
49+ } {
50+ t.Run(tc.name, func(t *testing.T) {
51+ tool := &outcomeTool{err: tc.err, result: ToolResult{IsError: tc.isError, Status: tc.resultStatus}, panicValue: tc.panicValue}
52+ a := NewAgent(nil, "test", "", NewRegistry(tool))
53+ rewritten := json.RawMessage(`{"effective":true}`)
54+ a.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) { return !tc.block, "blocked", rewritten }
55+ ctx, cancel := context.WithCancel(context.Background())
56+ defer cancel()
57+ if tc.cancelBefore {
58+ cancel()
59+ }
60+ var events []EvToolResult
61+ msg, hadError := a.executeTools(ctx, provider.Message{Content: []provider.Content{provider.ToolCallBlock{ID: "call", Name: tool.Name(), Arguments: json.RawMessage(`{}`)}}}, func(e AgentEvent) {
62+ if result, ok := e.(EvToolResult); ok {
63+ events = append(events, result)
64+ }
65+ })
66+ if len(events) != 1 {
67+ t.Fatalf("got %d results", len(events))
68+ }
69+ e := events[0]
70+ if e.ID != "call" || e.Name != tool.Name() || e.Status != tc.status || e.Executed != tool.called {
71+ t.Fatalf("wrong event: %+v", e)
72+ }
73+ if !tc.cancelBefore && string(e.Args) != string(rewritten) {
74+ t.Fatalf("wrong effective args: %s", e.Args)
75+ }
76+ if tool.called && string(e.Args) != string(tool.args) {
77+ t.Fatal("reported args differ from execution")
78+ }
79+ if tool.called == (tc.block || tc.cancelBefore) {
80+ t.Fatal("unexpected execution")
81+ }
82+ if hadError != (tc.status != "completed") {
83+ t.Fatalf("hadError = %v", hadError)
84+ }
85+ if len(msg.Content) != 1 || msg.Content[0].(provider.ToolResultBlock).CallID != e.ID {
86+ t.Fatal("transcript result pairing lost")
87+ }
88+ })
89+ }
90+}
91+
92+func TestUnknownToolHasTerminalEvent(t *testing.T) {
93+ a := NewAgent(nil, "test", "", nil)
94+ var got EvToolResult
95+ a.runOneTool(context.Background(), provider.ToolCallBlock{ID: "missing", Name: "missing"}, func(e AgentEvent) { got = e.(EvToolResult) })
96+ if got.Status != "failed" || got.Executed || !got.Result.IsError {
97+ t.Fatalf("wrong result: %+v", got)
98+ }
99+}
100+
101+func TestPromptSubmitBeforeQueueConsumption(t *testing.T) {
102+ a := NewAgent(nil, "test", "", nil)
103+ var events []AgentEvent
104+ a.OnEvent = func(e AgentEvent) { events = append(events, e) }
105+ if a.QueueMessage(" ") {
106+ t.Fatal("empty queue accepted")
107+ }
108+ if !a.QueueMessage(" queued ") {
109+ t.Fatal("queue rejected")
110+ }
111+ if len(events) != 1 {
112+ t.Fatalf("events = %v", events)
113+ }
114+ got := events[0].(EvPromptSubmit)
115+ if got.Text != "queued" || !got.Queued {
116+ t.Fatalf("event = %+v", got)
117+ }
118+ a.appendQueuedAsUser(a.drainQueuedMessages(), a.OnEvent)
119+ count := 0
120+ for _, e := range events {
121+ if _, ok := e.(EvPromptSubmit); ok {
122+ count++
123+ }
124+ }
125+ if count != 1 {
126+ t.Fatalf("duplicate submission: %d", count)
127+ }
128+}
129+
130+func TestCompactionFailureHasPairedEvents(t *testing.T) {
131+ a := NewAgent(nil, "test", "", nil)
132+ var events []EvCompact
133+ a.OnEvent = func(e AgentEvent) { events = append(events, e.(EvCompact)) }
134+ if _, err := a.Compact(context.Background(), 0, nil); err == nil {
135+ t.Fatal("empty compaction succeeded")
136+ }
137+ if len(events) != 2 {
138+ t.Fatalf("events = %v", events)
139+ }
140+ if events[0].Phase != "pre" || events[1].Phase != "post" || events[0].ID == "" || events[0].ID != events[1].ID || events[1].Status != "failed" || events[1].Err == nil {
141+ t.Fatalf("events = %+v", events)
142+ }
143+}
@@ -0,0 +1,143 @@
1+package core
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "testing"
8+
9+ "github.com/patriceckhart/zot/packages/provider"
10+)
11+
12+type outcomeTool struct {
13+ result ToolResult
14+ err error
15+ panicValue bool
16+ called bool
17+ args json.RawMessage
18+}
19+
20+func (t *outcomeTool) Name() string { return "outcome" }
21+func (t *outcomeTool) Description() string { return "test" }
22+func (t *outcomeTool) Schema() json.RawMessage { return json.RawMessage(`{}`) }
23+func (t *outcomeTool) Execute(_ context.Context, args json.RawMessage, _ func(string)) (ToolResult, error) {
24+ t.called = true
25+ t.args = append(json.RawMessage(nil), args...)
26+ if t.panicValue {
27+ panic("test panic")
28+ }
29+ return t.result, t.err
30+}
31+
32+func TestToolResultLifecycle(t *testing.T) {
33+ for _, tc := range []struct {
34+ name, status string
35+ err error
36+ isError, panicValue, block, cancelBefore bool
37+ resultStatus string
38+ }{
39+ {name: "success", status: "completed"},
40+ {name: "error", status: "failed", err: errors.New("failure")},
41+ {name: "error result", status: "failed", isError: true},
42+ {name: "panic", status: "failed", panicValue: true},
43+ {name: "guard", status: "blocked", block: true},
44+ {name: "tool policy", status: "blocked", err: &ToolPolicyError{Err: errors.New("denied")}},
45+ {name: "cancel", status: "cancelled", err: context.Canceled},
46+ {name: "deadline", status: "timed_out", err: context.DeadlineExceeded},
47+ {name: "cancel before execution", status: "cancelled", cancelBefore: true},
48+ {name: "tool timeout result", status: "timed_out", isError: true, resultStatus: "timed_out"},
49+ } {
50+ t.Run(tc.name, func(t *testing.T) {
51+ tool := &outcomeTool{err: tc.err, result: ToolResult{IsError: tc.isError, Status: tc.resultStatus}, panicValue: tc.panicValue}
52+ a := NewAgent(nil, "test", "", NewRegistry(tool))
53+ rewritten := json.RawMessage(`{"effective":true}`)
54+ a.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) { return !tc.block, "blocked", rewritten }
55+ ctx, cancel := context.WithCancel(context.Background())
56+ defer cancel()
57+ if tc.cancelBefore {
58+ cancel()
59+ }
60+ var events []EvToolResult
61+ msg, hadError := a.executeTools(ctx, provider.Message{Content: []provider.Content{provider.ToolCallBlock{ID: "call", Name: tool.Name(), Arguments: json.RawMessage(`{}`)}}}, func(e AgentEvent) {
62+ if result, ok := e.(EvToolResult); ok {
63+ events = append(events, result)
64+ }
65+ })
66+ if len(events) != 1 {
67+ t.Fatalf("got %d results", len(events))
68+ }
69+ e := events[0]
70+ if e.ID != "call" || e.Name != tool.Name() || e.Status != tc.status || e.Executed != tool.called {
71+ t.Fatalf("wrong event: %+v", e)
72+ }
73+ if !tc.cancelBefore && string(e.Args) != string(rewritten) {
74+ t.Fatalf("wrong effective args: %s", e.Args)
75+ }
76+ if tool.called && string(e.Args) != string(tool.args) {
77+ t.Fatal("reported args differ from execution")
78+ }
79+ if tool.called == (tc.block || tc.cancelBefore) {
80+ t.Fatal("unexpected execution")
81+ }
82+ if hadError != (tc.status != "completed") {
83+ t.Fatalf("hadError = %v", hadError)
84+ }
85+ if len(msg.Content) != 1 || msg.Content[0].(provider.ToolResultBlock).CallID != e.ID {
86+ t.Fatal("transcript result pairing lost")
87+ }
88+ })
89+ }
90+}
91+
92+func TestUnknownToolHasTerminalEvent(t *testing.T) {
93+ a := NewAgent(nil, "test", "", nil)
94+ var got EvToolResult
95+ a.runOneTool(context.Background(), provider.ToolCallBlock{ID: "missing", Name: "missing"}, func(e AgentEvent) { got = e.(EvToolResult) })
96+ if got.Status != "failed" || got.Executed || !got.Result.IsError {
97+ t.Fatalf("wrong result: %+v", got)
98+ }
99+}
100+
101+func TestPromptSubmitBeforeQueueConsumption(t *testing.T) {
102+ a := NewAgent(nil, "test", "", nil)
103+ var events []AgentEvent
104+ a.OnEvent = func(e AgentEvent) { events = append(events, e) }
105+ if a.QueueMessage(" ") {
106+ t.Fatal("empty queue accepted")
107+ }
108+ if !a.QueueMessage(" queued ") {
109+ t.Fatal("queue rejected")
110+ }
111+ if len(events) != 1 {
112+ t.Fatalf("events = %v", events)
113+ }
114+ got := events[0].(EvPromptSubmit)
115+ if got.Text != "queued" || !got.Queued {
116+ t.Fatalf("event = %+v", got)
117+ }
118+ a.appendQueuedAsUser(a.drainQueuedMessages(), a.OnEvent)
119+ count := 0
120+ for _, e := range events {
121+ if _, ok := e.(EvPromptSubmit); ok {
122+ count++
123+ }
124+ }
125+ if count != 1 {
126+ t.Fatalf("duplicate submission: %d", count)
127+ }
128+}
129+
130+func TestCompactionFailureHasPairedEvents(t *testing.T) {
131+ a := NewAgent(nil, "test", "", nil)
132+ var events []EvCompact
133+ a.OnEvent = func(e AgentEvent) { events = append(events, e.(EvCompact)) }
134+ if _, err := a.Compact(context.Background(), 0, nil); err == nil {
135+ t.Fatal("empty compaction succeeded")
136+ }
137+ if len(events) != 2 {
138+ t.Fatalf("events = %v", events)
139+ }
140+ if events[0].Phase != "pre" || events[1].Phase != "post" || events[0].ID == "" || events[0].ID != events[1].ID || events[1].Status != "failed" || events[1].Err == nil {
141+ t.Fatalf("events = %+v", events)
142+ }
143+}
M packages/core/tool.go +11 -0
@@ -32,12 +32,23 @@ type ToolPreviewer interface {
3232 Preview(ctx context.Context, args json.RawMessage) (ToolResult, error)
3333 }
3434
35+// ToolPolicyError marks a tool-local policy refusal without changing its text.
36+// It does not imply that other calls in the same batch were rolled back.
37+type ToolPolicyError struct{ Err error }
38+
39+func (e *ToolPolicyError) Error() string { return e.Err.Error() }
40+func (e *ToolPolicyError) Unwrap() error { return e.Err }
41+
3542 // ToolResult is the outcome of Tool.Execute.
3643 type ToolResult struct {
3744 // Content is sent back to the LLM (text and/or images).
3845 Content []provider.Content
3946 // IsError marks this result as an error to the LLM.
4047 IsError bool
48+ // Status optionally refines an error result for lifecycle observers.
49+ // Tools that return cancellation as content rather than an error may set
50+ // cancelled or timed_out; policy refusals may set blocked.
51+ Status string
4152 // ActivateTools names previously deferred tools that become available
4253 // after this result. Unknown names are ignored by the agent.
4354 ActivateTools []string
@@ -32,12 +32,23 @@ type ToolPreviewer interface {
32 Preview(ctx context.Context, args json.RawMessage) (ToolResult, error)32 Preview(ctx context.Context, args json.RawMessage) (ToolResult, error)
33 }33 }
34 34
35+// ToolPolicyError marks a tool-local policy refusal without changing its text.
36+// It does not imply that other calls in the same batch were rolled back.
37+type ToolPolicyError struct{ Err error }
38+
39+func (e *ToolPolicyError) Error() string { return e.Err.Error() }
40+func (e *ToolPolicyError) Unwrap() error { return e.Err }
41+
35 // ToolResult is the outcome of Tool.Execute.42 // ToolResult is the outcome of Tool.Execute.
36 type ToolResult struct {43 type ToolResult struct {
37 // Content is sent back to the LLM (text and/or images).44 // Content is sent back to the LLM (text and/or images).
38 Content []provider.Content45 Content []provider.Content
39 // IsError marks this result as an error to the LLM.46 // IsError marks this result as an error to the LLM.
40 IsError bool47 IsError bool
48+ // Status optionally refines an error result for lifecycle observers.
49+ // Tools that return cancellation as content rather than an error may set
50+ // cancelled or timed_out; policy refusals may set blocked.
51+ Status string
41 // ActivateTools names previously deferred tools that become available52 // ActivateTools names previously deferred tools that become available
42 // after this result. Unknown names are ignored by the agent.53 // after this result. Unknown names are ignored by the agent.
43 ActivateTools []string54 ActivateTools []string

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