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`. | |||
| 252 | 252 | "intercept":["tool_call","turn_start","assistant_message"]} | |
| 253 | 253 | ``` | |
| 254 | 254 | ||
| 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). | |
| 257 | 261 | ||
| 258 | 262 | `tool_confirmation_requested` fires only when zot is about to wait for | |
| 259 | 263 | interactive approval. Calls running in yolo mode, calls covered by a | |
| @@ -503,7 +507,8 @@ marked as failed. | |||
| 503 | 507 | #### `event` | |
| 504 | 508 | ||
| 505 | 509 | 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 | 513 | ```json | |
| 509 | 514 | {"type":"event","event":"turn_start","step":1} | |
| @@ -514,6 +519,164 @@ Lifecycle notification for events the extension subscribed to via | |||
| 514 | 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 | 680 | #### `event_intercept` | |
| 518 | 681 | ||
| 519 | 682 | 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 for | 262 | `tool_confirmation_requested` fires only when zot is about to wait for | ||
| 259 | interactive approval. Calls running in yolo mode, calls covered by a | 263 | 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 via | 509 | 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 | ```json | 513 | ```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 | |||
| 14 | 14 | ag.BeforeStart = func(ctx context.Context, system string) string { | |
| 15 | 15 | session := ag.SessionID | |
| 16 | 16 | if session == "" { | |
| 17 | - | session = runtimeSession | |
| 17 | + | session, _ = mgr.SessionContext() | |
| 18 | + | if session == "" { | |
| 19 | + | session = runtimeSession | |
| 20 | + | } | |
| 18 | 21 | } | |
| 19 | 22 | return mgr.InterceptBeforeAgentStart(ctx, extproto.EventInterceptFromHost{ | |
| 20 | 23 | 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.SessionID | 15 | session := ag.SessionID | ||
| 16 | if session == "" { | 16 | if session == "" { | ||
| 17 | - | session = runtimeSession | 17 | + | 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) { | |||
| 165 | 165 | return | |
| 166 | 166 | } | |
| 167 | 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 | 181 | case core.EvTurnStart: | |
| 169 | 182 | mgr.EmitEvent(extproto.EventFromHost{Event: "turn_start", Step: e.Step}) | |
| 170 | 183 | case core.EvToolCall: | |
| @@ -308,7 +321,6 @@ func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved, | |||
| 308 | 321 | } | |
| 309 | 322 | extMgr.WaitForReady(3 * time.Second) | |
| 310 | 323 | r.MergeExtensionTools(&extToolAdapter{mgr: extMgr}) | |
| 311 | - | extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"}) | |
| 312 | 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 | 339 | return | |
| 328 | 340 | } | |
| 329 | 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 | 343 | res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments) | |
| 332 | 344 | 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 | |
| 334 | 347 | } | |
| 348 | + | emitPermissionDecision(ctx, extMgr, call, core.ConfirmDecision{Allow: true, Source: "yolo"}) | |
| 335 | 349 | return true, "", res.ModifiedArgs | |
| 336 | 350 | } | |
| 337 | 351 | ag.BeforeTurn = func(step int) (bool, string) { | |
| @@ -345,7 +359,7 @@ func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr | |||
| 345 | 359 | } | |
| 346 | 360 | 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 | 365 | type printStats struct { | |
| @@ -709,8 +723,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 709 | 723 | // in this outer scope rather than scoping it tighter. | |
| 710 | 724 | var swarmMgr *swarm.Swarm | |
| 711 | 725 | 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) }, | |
| 714 | 729 | ResolveCredential: func(ctx context.Context, providerID string) (swarm.Credential, error) { | |
| 715 | 730 | if providerID == "ollama" { | |
| 716 | 731 | return swarm.Credential{Value: "ollama", Method: "apikey"}, nil | |
| @@ -781,13 +796,14 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 781 | 796 | wireAgentExt := func(resolved Resolved) *core.Agent { | |
| 782 | 797 | a := resolved.NewAgent() | |
| 783 | 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 | 800 | // Guards run before confirmation because they may rewrite the | |
| 786 | 801 | // arguments. The user must approve the effective call that will | |
| 787 | 802 | // actually execute, not the model's original arguments. | |
| 788 | 803 | r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments) | |
| 789 | 804 | 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 | |
| 791 | 807 | } | |
| 792 | 808 | effectiveArgs := call.Arguments | |
| 793 | 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 | 815 | if previewer, ok := tool.(core.ToolPreviewer); ok { | |
| 800 | 816 | preview, err := previewer.Preview(ctx, effectiveArgs) | |
| 801 | 817 | 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 | |
| 803 | 820 | } | |
| 804 | 821 | for _, block := range preview.Content { | |
| 805 | 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 | 832 | ID: call.ID, | |
| 816 | 833 | Name: call.Name, | |
| 817 | 834 | Summary: core.BuildPreview(effectiveArgs, 120), | |
| 818 | 835 | Content: content.String(), | |
| 819 | 836 | }) | |
| 820 | - | if !ok { | |
| 821 | - | return false, reason, nil | |
| 837 | + | emitPermissionDecision(ctx, extMgr, call, decision) | |
| 838 | + | 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 | 844 | return true, "", r.ModifiedArgs | |
| 825 | 845 | } | |
| @@ -834,7 +854,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 834 | 854 | } | |
| 835 | 855 | return true, "", r.ReplaceText | |
| 836 | 856 | } | |
| 837 | - | a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) } | |
| 857 | + | wireLifecycleEvents(a, extMgr) | |
| 838 | 858 | return a | |
| 839 | 859 | } | |
| 840 | 860 | ||
| @@ -914,9 +934,6 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 914 | 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 | 937 | var sess *core.Session | |
| 921 | 938 | var sessBaselineMsgs int // messages already on disk when current session opened | |
| 922 | 939 | // persistMu guards sess + sessBaselineMsgs against concurrent access | |
| @@ -931,6 +948,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 931 | 948 | sessBaselineMsgs = len(ag.Messages()) | |
| 932 | 949 | } | |
| 933 | 950 | } | |
| 951 | + | startExtensionSession(extMgr, ag, r.CWD, "") | |
| 934 | 952 | defer func() { | |
| 935 | 953 | persistMu.Lock() | |
| 936 | 954 | defer persistMu.Unlock() | |
| @@ -1042,6 +1060,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1042 | 1060 | sess = newSess | |
| 1043 | 1061 | currentAg.SetMessages(msgs) | |
| 1044 | 1062 | bindAgentSession(currentAg, sess) | |
| 1063 | + | startExtensionSession(extMgr, currentAg, r.CWD, "session_switch") | |
| 1045 | 1064 | if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil { | |
| 1046 | 1065 | currentAg.SeedCost(cum) | |
| 1047 | 1066 | currentAg.SeedLastTurnUsage(last) | |
| @@ -1133,6 +1152,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1133 | 1152 | } | |
| 1134 | 1153 | sessBaselineMsgs = 0 | |
| 1135 | 1154 | persistMu.Unlock() | |
| 1155 | + | extMgr.EndSession("cwd_change") | |
| 1136 | 1156 | ||
| 1137 | 1157 | // Mutate captured state so subsequent agent rebuilds and | |
| 1138 | 1158 | // session opens see the new cwd. | |
| @@ -1178,6 +1198,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1178 | 1198 | bindAgentSession(newAg, newSess) | |
| 1179 | 1199 | } | |
| 1180 | 1200 | ||
| 1201 | + | startExtensionSession(extMgr, newAg, absPath, "cwd_change") | |
| 1202 | + | ||
| 1181 | 1203 | // Push the new state into the running Interactive. | |
| 1182 | 1204 | if iv != nil { | |
| 1183 | 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 | 1209 | // 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) | |
| 1190 | 1213 | } | |
| 1191 | 1214 | return nil | |
| 1192 | 1215 | } | |
| @@ -1261,12 +1284,15 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1261 | 1284 | // shows agents this session spawned (and any pre-upgrade unscoped | |
| 1262 | 1285 | // agents — see SnapshotAll docs). Updated again whenever the | |
| 1263 | 1286 | // user swaps sessions via loadSession below. | |
| 1264 | - | if sess != nil { | |
| 1265 | - | swarmMgr.SetActiveSession(sess.ID) | |
| 1266 | - | } | |
| 1287 | + | sessionID, _ := extMgr.SessionContext() | |
| 1288 | + | swarmMgr.SetActiveSession(sessionID) | |
| 1267 | 1289 | // Best-effort shutdown on interactive exit: stop all running | |
| 1268 | 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 | 1297 | var startupSkills []*skills.Skill | |
| 1272 | 1298 | if r.SkillTool != nil { | |
| @@ -165,6 +165,19 @@ func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) { | |||||
| 165 | return | 165 | 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 | return | 339 | 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, nil | 345 | + | 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.ModifiedArgs | 349 | 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.ReplaceText | 360 | 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.Swarm | 724 | 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"}, nil | 731 | 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 the | 800 | // Guards run before confirmation because they may rewrite the | ||
| 786 | // arguments. The user must approve the effective call that will | 801 | // 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, nil | 805 | + | emitPermissionDecision(ctx, extMgr, call, core.ConfirmDecision{Source: "policy", Reason: r.Reason}) |
| 806 | + | return false, r.Reason, r.ModifiedArgs | |||
| 791 | } | 807 | } | ||
| 792 | effectiveArgs := call.Arguments | 808 | 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(), nil | 818 | + | 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, nil | 838 | + | 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.ModifiedArgs | 844 | 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.ReplaceText | 855 | return true, "", r.ReplaceText | ||
| 836 | } | 856 | } | ||
| 837 | - | a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) } | 857 | + | wireLifecycleEvents(a, extMgr) |
| 838 | return a | 858 | 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.Session | 937 | var sess *core.Session | ||
| 921 | var sessBaselineMsgs int // messages already on disk when current session opened | 938 | var sessBaselineMsgs int // messages already on disk when current session opened | ||
| 922 | // persistMu guards sess + sessBaselineMsgs against concurrent access | 939 | // 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 = newSess | 1060 | 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 = 0 | 1153 | sessBaselineMsgs = 0 | ||
| 1135 | persistMu.Unlock() | 1154 | persistMu.Unlock() | ||
| 1155 | + | extMgr.EndSession("cwd_change") | |||
| 1136 | 1156 | ||||
| 1137 | // Mutate captured state so subsequent agent rebuilds and | 1157 | // 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 nil | 1214 | 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 unscoped | 1284 | // shows agents this session spawned (and any pre-upgrade unscoped | ||
| 1262 | // agents — see SnapshotAll docs). Updated again whenever the | 1285 | // 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 running | 1289 | // 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.Skill | 1297 | 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 | |||
| 63 | 63 | // worker. | |
| 64 | 64 | type EventHandler func(ev Event) | |
| 65 | 65 | ||
| 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: | |
| 68 | 72 | // | |
| 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 | |
| 73 | 80 | // 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. | |
| 75 | 89 | 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 | |
| 77 | 109 | ||
| 78 | 110 | Step int | |
| 79 | 111 | Stop string | |
| @@ -83,6 +115,7 @@ type Event struct { | |||
| 83 | 115 | ToolName string | |
| 84 | 116 | ToolArgs json.RawMessage | |
| 85 | 117 | ToolPreview string | |
| 118 | + | ToolArgsRaw string | |
| 86 | 119 | ||
| 87 | 120 | Text string | |
| 88 | 121 | } | |
| @@ -426,8 +459,9 @@ func (e *Extension) registerTool(name, description string, schema json.RawMessag | |||
| 426 | 459 | // On subscribes to a lifecycle event. fn is called for each | |
| 427 | 460 | // notification; the same name can only have one handler (later | |
| 428 | 461 | // 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. | |
| 431 | 465 | func (e *Extension) On(name string, fn EventHandler) { | |
| 432 | 466 | e.mu.Lock() | |
| 433 | 467 | if _, exists := e.eventHandlers[name]; !exists { | |
| @@ -669,8 +703,13 @@ func (e *Extension) Run() error { | |||
| 669 | 703 | }() | |
| 670 | 704 | handler(Event{ | |
| 671 | 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 | 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 | } | |
| @@ -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 populated | 66 | + | // 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 : Step | 74 | + | // session_end: Reason |
| 71 | - | // turn_end : Stop, optional Error | 75 | + | // user_prompt_submit: Text, Queued, ImageCount |
| 72 | - | // tool_call : ToolID, ToolName, ToolArgs | 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 | |||
| 73 | // tool_confirmation_requested: ToolID, ToolName, ToolPreview | 80 | // 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. | |||
| 75 | type Event struct { | 89 | 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 | |||
| 77 | 109 | ||||
| 78 | Step int | 110 | Step int | ||
| 79 | Stop string | 111 | Stop string | ||
| @@ -83,6 +115,7 @@ type Event struct { | |||||
| 83 | ToolName string | 115 | ToolName string | ||
| 84 | ToolArgs json.RawMessage | 116 | ToolArgs json.RawMessage | ||
| 85 | ToolPreview string | 117 | ToolPreview string | ||
| 118 | + | ToolArgsRaw string | |||
| 86 | 119 | ||||
| 87 | Text string | 120 | 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 each | 459 | // On subscribes to a lifecycle event. fn is called for each | ||
| 427 | // notification; the same name can only have one handler (later | 460 | // 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 | |||
| 20 | 20 | current = *ev.SystemPrompt | |
| 21 | 21 | } | |
| 22 | 22 | 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 | + | } | |
| 24 | 27 | if ev.Provider == "" { | |
| 25 | 28 | ev.Provider = m.provider | |
| 26 | 29 | } | |
| @@ -20,7 +20,10 @@ func (m *Manager) InterceptBeforeAgentStart(ctx context.Context, ev extproto.Eve | |||||
| 20 | current = *ev.SystemPrompt | 20 | current = *ev.SystemPrompt | ||
| 21 | } | 21 | } | ||
| 22 | ev.Event = "before_agent_start" | 22 | 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 | + | } | |||
| 24 | if ev.Provider == "" { | 27 | if ev.Provider == "" { | ||
| 25 | ev.Provider = m.provider | 28 | ev.Provider = m.provider | ||
| 26 | } | 29 | } | ||
M packages/agent/extensions/events.go +41 -11
| @@ -10,14 +10,38 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | // 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. | |
| 16 | 15 | // | |
| 17 | 16 | // Event names are documented on extproto.EventFromHost. Unknown event | |
| 18 | 17 | // names are still routed (subscribers can use any string they want). | |
| 19 | 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 | 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 | 45 | frame, err := extproto.Encode(ev) | |
| 22 | 46 | if err != nil { | |
| 23 | 47 | return | |
| @@ -35,14 +59,19 @@ func (m *Manager) EmitEvent(ev extproto.EventFromHost) { | |||
| 35 | 59 | m.mu.RUnlock() | |
| 36 | 60 | ||
| 37 | 61 | 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 | + | } | |
| 46 | 75 | } | |
| 47 | 76 | } | |
| 48 | 77 | ||
| @@ -81,6 +110,7 @@ func (m *Manager) InterceptToolCall(ctx context.Context, toolID, toolName string | |||
| 81 | 110 | ToolArgs: current, | |
| 82 | 111 | }) | |
| 83 | 112 | if r.Block { | |
| 113 | + | r.ModifiedArgs = current | |
| 84 | 114 | return r | |
| 85 | 115 | } | |
| 86 | 116 | 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 that | 12 | // EmitEvent fires a one-way lifecycle event to every extension that | ||
| 13 | - | // subscribed to it via SubscribeFromExt.events. Non-blocking: each | 13 | + | // subscribed to it via SubscribeFromExt.events. Notification enqueue is |
| 14 | - | // extension's pipe write happens on a per-call goroutine so a slow | 14 | + | // 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 event | 16 | // 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 | return | 47 | 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 kill | 64 | + | } |
| 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 r | 114 | 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 { | |||
| 169 | 169 | // by the host so it can rebuild the agent's tool registry with | |
| 170 | 170 | // the freshly-registered extension tools. | |
| 171 | 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 | 182 | // 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 | 732 | started = true | |
| 725 | 733 | ext.cmd = cmd | |
| 734 | + | stdin = newOrderedPipe(stdin) | |
| 726 | 735 | ext.stdin = stdin | |
| 727 | 736 | ext.stdout = stdout | |
| 728 | 737 | ||
| @@ -1238,7 +1247,7 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess | |||
| 1238 | 1247 | ext.mu.Lock() | |
| 1239 | 1248 | delete(ext.pendingTool, id) | |
| 1240 | 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 | 1251 | case <-ctx.Done(): | |
| 1243 | 1252 | ext.mu.Lock() | |
| 1244 | 1253 | delete(ext.pendingTool, id) | |
| @@ -1341,6 +1350,7 @@ func (m *Manager) SendPanelClose(extName, panelID string) error { | |||
| 1341 | 1350 | } | |
| 1342 | 1351 | ||
| 1343 | 1352 | func (m *Manager) Stop(gracePeriod time.Duration) { | |
| 1353 | + | m.stopLifecycle() | |
| 1344 | 1354 | m.mu.RLock() | |
| 1345 | 1355 | exts := make([]*Extension, 0, len(m.ext)) | |
| 1346 | 1356 | for _, e := range m.ext { | |
| @@ -1351,17 +1361,21 @@ func (m *Manager) Stop(gracePeriod time.Duration) { | |||
| 1351 | 1361 | } | |
| 1352 | 1362 | ||
| 1353 | 1363 | func stopExtensions(exts []*Extension, gracePeriod time.Duration) { | |
| 1364 | + | deadline := time.Now().Add(gracePeriod) | |
| 1354 | 1365 | for _, ext := range exts { | |
| 1355 | 1366 | if ext.stdin == nil { | |
| 1356 | 1367 | continue | |
| 1357 | 1368 | } | |
| 1358 | 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 | 1376 | _ = ext.stdin.Close() | |
| 1362 | 1377 | } | |
| 1363 | 1378 | ||
| 1364 | - | deadline := time.Now().Add(gracePeriod) | |
| 1365 | 1379 | for _, ext := range exts { | |
| 1366 | 1380 | if ext.cmd == nil { | |
| 1367 | 1381 | if ext.logFile != nil { | |
| @@ -169,6 +169,14 @@ type Manager struct { | |||||
| 169 | // by the host so it can rebuild the agent's tool registry with | 169 | // 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 from | 182 | // 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 = true | 732 | started = true | ||
| 725 | ext.cmd = cmd | 733 | ext.cmd = cmd | ||
| 734 | + | stdin = newOrderedPipe(stdin) | |||
| 726 | ext.stdin = stdin | 735 | ext.stdin = stdin | ||
| 727 | ext.stdout = stdout | 736 | 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 | continue | 1367 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "errors" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| @@ -58,7 +59,15 @@ func (t *extensionTool) Execute(ctx context.Context, args json.RawMessage, _ fun | |||
| 58 | 59 | } | |
| 59 | 60 | resp, err := t.manager.InvokeTool(ctx, t.name, args, t.timeout) | |
| 60 | 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 | 69 | return core.ToolResult{ | |
| 70 | + | Status: status, | |
| 62 | 71 | IsError: true, | |
| 63 | 72 | Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("extension %s/%s failed: %v", t.extension, t.name, err)}}, | |
| 64 | 73 | }, 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 | }, nil | 73 | }, nil | ||
M packages/agent/extproto/extproto.go +36 -10
| @@ -183,17 +183,43 @@ type ToolCallFromHost struct { | |||
| 183 | 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 | 193 | 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"` | |
| 197 | 223 | } | |
| 198 | 224 | ||
| 199 | 225 | 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 { | |||
| 278 | 278 | agent.Temperature = main.Temperature | |
| 279 | 279 | agent.MaxTokens = main.MaxTokens | |
| 280 | 280 | agent.BeforeToolExecute = main.BeforeToolExecute | |
| 281 | + | agent.BeforeToolExecuteContext = main.BeforeToolExecuteContext | |
| 281 | 282 | agent.BeforeTurn = main.BeforeTurn | |
| 282 | 283 | agent.BeforeAssistantMessage = main.BeforeAssistantMessage | |
| 283 | 284 | agent.MaxRetries = main.MaxRetries | |
| @@ -278,6 +278,7 @@ func newBtwAgent(main *core.Agent, system, model string) *core.Agent { | |||||
| 278 | agent.Temperature = main.Temperature | 278 | agent.Temperature = main.Temperature | ||
| 279 | agent.MaxTokens = main.MaxTokens | 279 | agent.MaxTokens = main.MaxTokens | ||
| 280 | agent.BeforeToolExecute = main.BeforeToolExecute | 280 | agent.BeforeToolExecute = main.BeforeToolExecute | ||
| 281 | + | agent.BeforeToolExecuteContext = main.BeforeToolExecuteContext | |||
| 281 | agent.BeforeTurn = main.BeforeTurn | 282 | agent.BeforeTurn = main.BeforeTurn | ||
| 282 | agent.BeforeAssistantMessage = main.BeforeAssistantMessage | 283 | agent.BeforeAssistantMessage = main.BeforeAssistantMessage | ||
| 283 | agent.MaxRetries = main.MaxRetries | 284 | 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 { | |||
| 84 | 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 | 89 | server := &rpcServer{ | |
| 90 | 90 | 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) { | |||
| 348 | 348 | Model: existing.Model, Provider: existing.Provider, | |
| 349 | 349 | InboxPath: inboxPath, EventLogPath: existing.EventLogPath, | |
| 350 | 350 | SessionPath: existing.SessionPath, | |
| 351 | + | SessionID: existing.SessionID, | |
| 351 | 352 | } | |
| 352 | 353 | ||
| 353 | 354 | a := &Agent{ | |
| @@ -361,6 +362,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||
| 361 | 362 | EventLogPath: m.EventLogPath, | |
| 362 | 363 | SessionPath: m.SessionPath, | |
| 363 | 364 | Resuming: true, | |
| 365 | + | SessionID: m.SessionID, | |
| 364 | 366 | inbox: NewInbox(m.InboxPath), | |
| 365 | 367 | status: StatusPending, | |
| 366 | 368 | 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 | |||
| 25 | 25 | ||
| 26 | 26 | import ( | |
| 27 | 27 | "context" | |
| 28 | + | "crypto/rand" | |
| 28 | 29 | "errors" | |
| 29 | 30 | "fmt" | |
| 30 | 31 | "os" | |
| @@ -47,8 +48,17 @@ const ( | |||
| 47 | 48 | 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 | 56 | // Config configures a Swarm. | |
| 51 | 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 | 62 | // Root is the directory under which per-agent state files live. | |
| 53 | 63 | // Typically <ZotHome>/swarm, but tests pass a tempdir. | |
| 54 | 64 | Root string | |
| @@ -288,8 +298,20 @@ func (f *Swarm) SendUserTurn(id, text string) error { | |||
| 288 | 298 | } | |
| 289 | 299 | ||
| 290 | 300 | 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() | |
| 293 | 315 | err := a.runner.Run(a.ctx, agentSink{a: a}) | |
| 294 | 316 | a.mu.Lock() | |
| 295 | 317 | a.finished = f.cfg.Now() | |
| @@ -307,7 +329,23 @@ func (f *Swarm) run(a *Agent) { | |||
| 307 | 329 | a.status = StatusDone | |
| 308 | 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 | 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 | 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 | 449 | // Remove tears down the per-agent state for a terminated agent. It | |
| 396 | 450 | // is an error to remove an agent that's still running; call Stop | |
| 397 | 451 | // 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 runner | 48 | 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 string | 64 | 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 = StatusDone | 329 | 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. It | 449 | // 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 Stop | 450 | // is an error to remove an agent that's still running; call Stop | ||
| 397 | // first and wait for the status to settle. Detached agents | 451 | // 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 | |||
| 201 | 201 | fmt.Fprintf(&sb, " Took %s", humanDuration(elapsed)) | |
| 202 | 202 | ||
| 203 | 203 | 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 | 211 | return core.ToolResult{ | |
| 212 | + | Status: status, | |
| 205 | 213 | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | |
| 206 | 214 | IsError: isErr, | |
| 207 | 215 | 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() != nil | 203 | 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 @@ | |||
| 1 | 1 | package tools | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 4 | 5 | "fmt" | |
| 5 | 6 | "path/filepath" | |
| 6 | 7 | "strings" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/packages/core" | |
| 7 | 10 | ) | |
| 8 | 11 | ||
| 9 | 12 | // PermissionSet is the local-runtime permission contract for a packaged .zot agent. | |
| @@ -55,7 +58,8 @@ func (s *Sandbox) SetPermissions(p *PermissionSet) { | |||
| 55 | 58 | 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 | 63 | if err := s.CheckPath(path); err != nil { | |
| 60 | 64 | return err | |
| 61 | 65 | } | |
| @@ -68,7 +72,8 @@ func (s *Sandbox) CheckReadPath(path string) error { | |||
| 68 | 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 | 77 | if err := s.CheckPath(path); err != nil { | |
| 73 | 78 | return err | |
| 74 | 79 | } | |
| @@ -98,7 +103,8 @@ func checkScopedPath(op, path string, scopes []string) error { | |||
| 98 | 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 | 108 | if s == nil || s.Permissions == nil { | |
| 103 | 109 | 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 | 154 | func commandNames(cmd string) []string { | |
| 140 | 155 | cmd = strings.NewReplacer("&&", ";", "||", ";", "|", ";").Replace(cmd) | |
| 141 | 156 | parts := strings.Split(cmd, ";") | |
| @@ -1,9 +1,12 @@ | |||||
| 1 | package tools | 1 | 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 = p | 58 | 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 err | 64 | 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 err | 78 | 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 nil | 109 | 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 @@ | |||
| 1 | 1 | package tools | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 4 | 5 | "path/filepath" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/packages/core" | |
| 7 | 10 | ) | |
| 8 | 11 | ||
| 9 | 12 | func TestPermissionSetEmptyScopesDenyAccess(t *testing.T) { | |
| @@ -83,3 +86,26 @@ func TestBashModeAskAllows(t *testing.T) { | |||
| 83 | 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 | + | } | |
| @@ -1,9 +1,12 @@ | |||||
| 1 | package tools | 1 | 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 | |||
| 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 | 41 | // The existing shell heuristics parse neither PowerShell expressions nor | |
| 41 | 42 | // cmdlets. Do not treat them as enforcement for a different language. | |
| 42 | 43 | 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 nor | 41 | // 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() } | |||
| 38 | 38 | // CheckPath verifies that path resolves inside the sandbox root. | |
| 39 | 39 | // Returns an error describing the violation if not. No-op when unlocked. | |
| 40 | 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 | 43 | if !s.Locked() { | |
| 43 | 44 | return nil | |
| 44 | 45 | } | |
| @@ -98,7 +99,8 @@ func (s *Sandbox) DisplayPath(abs, given string) string { | |||
| 98 | 99 | // when jailed. We cannot fully sandbox a shell, but we can reject the | |
| 99 | 100 | // most obvious escapes so the model does not accidentally touch files | |
| 100 | 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 | 104 | if !s.Locked() { | |
| 103 | 105 | return nil | |
| 104 | 106 | } | |
| @@ -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 nil | 44 | 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 the | 99 | // when jailed. We cannot fully sandbox a shell, but we can reject the | ||
| 99 | // most obvious escapes so the model does not accidentally touch files | 100 | // 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 nil | 105 | return nil | ||
| 104 | } | 106 | } | ||
M packages/core/agent.go +71 -10
| @@ -48,6 +48,10 @@ type Agent struct { | |||
| 48 | 48 | // malformed modifiedArgs is ignored. | |
| 49 | 49 | BeforeToolExecute func(call provider.ToolCallBlock) (allowed bool, reason string, modifiedArgs json.RawMessage) | |
| 50 | 50 | ||
| 51 | + | // 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 | 55 | // BeforeStart replaces the complete system prompt once per runtime session | |
| 52 | 56 | // or explicit prompt/model reset, before any turn starts. It is not called | |
| 53 | 57 | // for ordinary follow-up messages or tool-loop steps. | |
| @@ -78,7 +82,9 @@ type Agent struct { | |||
| 78 | 82 | // OnEvent, if set, mirrors every AgentEvent the loop emits to | |
| 79 | 83 | // this callback in addition to the per-Prompt sink. Used by the | |
| 80 | 84 | // 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 | 88 | OnEvent func(AgentEvent) | |
| 83 | 89 | ||
| 84 | 90 | // OnMessageAppended, if set, fires every time a message is | |
| @@ -149,6 +155,9 @@ func (a *Agent) QueueMessage(text string) bool { | |||
| 149 | 155 | if text == "" { | |
| 150 | 156 | return false | |
| 151 | 157 | } | |
| 158 | + | if a.OnEvent != nil { | |
| 159 | + | a.OnEvent(EvPromptSubmit{Text: text, Queued: true}) | |
| 160 | + | } | |
| 152 | 161 | a.mu.Lock() | |
| 153 | 162 | a.queued = append(a.queued, text) | |
| 154 | 163 | a.mu.Unlock() | |
| @@ -340,6 +349,9 @@ func (a *Agent) Prompt(ctx context.Context, text string, images []provider.Image | |||
| 340 | 349 | sink = func(AgentEvent) {} | |
| 341 | 350 | } | |
| 342 | 351 | sink = a.wrapSink(sink) | |
| 352 | + | if a.OnEvent != nil { | |
| 353 | + | a.OnEvent(EvPromptSubmit{Text: text, ImageCount: len(images)}) | |
| 354 | + | } | |
| 343 | 355 | content := []provider.Content{} | |
| 344 | 356 | if text != "" { | |
| 345 | 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 | 754 | addedTools = append(addedTools, name) | |
| 743 | 755 | } | |
| 744 | 756 | } | |
| 745 | - | sink(EvToolResult{ID: tc.ID, Result: res}) | |
| 746 | 757 | } | |
| 747 | 758 | ||
| 748 | 759 | return provider.Message{ | |
| @@ -753,7 +764,29 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun | |||
| 753 | 764 | }, 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 | 790 | tool, err := a.Tools.Get(tc.Name) | |
| 758 | 791 | if err != nil { | |
| 759 | 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 | 798 | // Intercept hook: an extension or other guard can refuse the | |
| 768 | 799 | // call before any side effect happens, OR rewrite the args | |
| 769 | 800 | // seen by the tool. The model sees the reason as the tool | |
| 770 | 801 | // error, learns from it, and (typically) proposes a different | |
| 771 | 802 | // action; rewrites are invisible to the model (they apply only | |
| 772 | 803 | // 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 | + | } | |
| 775 | 815 | if !allowed { | |
| 816 | + | status = "blocked" | |
| 817 | + | if ctx.Err() != nil { | |
| 818 | + | status = executionErrorStatus(ctx.Err()) | |
| 819 | + | } | |
| 776 | 820 | if reason == "" { | |
| 777 | 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 | 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 | 834 | if len(args) == 0 { | |
| 790 | 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 | 850 | out, err := tool.Execute(ctx, args, func(text string) { | |
| 805 | 851 | sink(EvToolProgress{ID: tc.ID, Text: text}) | |
| 806 | 852 | }) | |
| 807 | 853 | if err != nil { | |
| 854 | + | status = executionErrorStatus(err) | |
| 808 | 855 | if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { | |
| 809 | 856 | res = ToolResult{ | |
| 810 | 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 | 870 | 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 | 887 | // extractText concatenates all TextBlock content in a message. Used | |
| 827 | 888 | // by BeforeAssistantMessage so guards see a single string instead of | |
| 828 | 889 | // 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 session | 55 | // BeforeStart replaces the complete system prompt once per runtime session | ||
| 52 | // or explicit prompt/model reset, before any turn starts. It is not called | 56 | // 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 to | 82 | // OnEvent, if set, mirrors every AgentEvent the loop emits to | ||
| 79 | // this callback in addition to the per-Prompt sink. Used by the | 83 | // this callback in addition to the per-Prompt sink. Used by the | ||
| 80 | // extension manager to fan events out to subscribed extensions | 84 | // 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 is | 90 | // 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 false | 156 | 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 | }, hadError | 764 | }, 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 the | 798 | // Intercept hook: an extension or other guard can refuse the | ||
| 768 | // call before any side effect happens, OR rewrite the args | 799 | // call before any side effect happens, OR rewrite the args | ||
| 769 | // seen by the tool. The model sees the reason as the tool | 800 | // seen by the tool. The model sees the reason as the tool | ||
| 770 | // error, learns from it, and (typically) proposes a different | 801 | // error, learns from it, and (typically) proposes a different | ||
| 771 | // action; rewrites are invisible to the model (they apply only | 802 | // 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 res | 870 | 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. Used | 887 | // extractText concatenates all TextBlock content in a message. Used | ||
| 827 | // by BeforeAssistantMessage so guards see a single string instead of | 888 | // 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "crypto/rand" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "strconv" | |
| 7 | 8 | "strings" | |
| @@ -26,6 +27,18 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin | |||
| 26 | 27 | msgs := append([]provider.Message(nil), a.messages...) | |
| 27 | 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 | 42 | if len(msgs) == 0 { | |
| 30 | 43 | return "", fmt.Errorf("nothing to compact") | |
| 31 | 44 | } | |
| @@ -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 ( | |||
| 12 | 12 | // of the session. | |
| 13 | 13 | type ConfirmDecision struct { | |
| 14 | 14 | Allow bool | |
| 15 | + | // Source identifies the authority: user, remembered, policy, or yolo. | |
| 16 | + | Source string | |
| 15 | 17 | // Reason is shown to the model as the tool error when | |
| 16 | 18 | // Allow=false. Examples: "user declined", "user refused: rm -rf | |
| 17 | 19 | // looks dangerous". | |
| @@ -63,9 +65,10 @@ type ToolCallConfirmer interface { | |||
| 63 | 65 | type ConfirmGate struct { | |
| 64 | 66 | inner Confirmer | |
| 65 | 67 | ||
| 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 | |
| 69 | 72 | } | |
| 70 | 73 | ||
| 71 | 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 | 94 | // CheckToolCall behaves like Check and additionally gives rich confirmation | |
| 92 | 95 | // UIs the call ID and side-effect-free preview content. | |
| 93 | 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 | 103 | if g == nil { | |
| 95 | - | return true, "", nil | |
| 104 | + | return ConfirmDecision{Allow: true, Source: "yolo"} | |
| 96 | 105 | } | |
| 97 | 106 | g.mu.Lock() | |
| 98 | 107 | if g.allowAll { | |
| 108 | + | source := g.allowAllSource | |
| 99 | 109 | g.mu.Unlock() | |
| 100 | - | return true, "", nil | |
| 110 | + | return ConfirmDecision{Allow: true, Source: source} | |
| 101 | 111 | } | |
| 102 | 112 | if g.allowedTool[call.Name] { | |
| 103 | 113 | g.mu.Unlock() | |
| 104 | - | return true, "", nil | |
| 114 | + | return ConfirmDecision{Allow: true, Source: "remembered"} | |
| 105 | 115 | } | |
| 106 | 116 | inner := g.inner | |
| 107 | 117 | g.mu.Unlock() | |
| 108 | 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", 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"} | |
| 110 | 120 | } | |
| 111 | 121 | ||
| 112 | 122 | var decision ConfirmDecision | |
| @@ -120,6 +130,7 @@ func (g *ConfirmGate) CheckToolCall(call ToolCallConfirmation) (bool, string, js | |||
| 120 | 130 | if decision.Allow { | |
| 121 | 131 | if decision.RememberAll { | |
| 122 | 132 | g.allowAll = true | |
| 133 | + | g.allowAllSource = "remembered" | |
| 123 | 134 | } | |
| 124 | 135 | if decision.RememberTool { | |
| 125 | 136 | g.allowedTool[call.Name] = true | |
| @@ -131,7 +142,9 @@ func (g *ConfirmGate) CheckToolCall(call ToolCallConfirmation) (bool, string, js | |||
| 131 | 142 | if !decision.Allow && reason == "" { | |
| 132 | 143 | reason = "tool call refused by user" | |
| 133 | 144 | } | |
| 134 | - | return decision.Allow, reason, nil | |
| 145 | + | decision.Reason = reason | |
| 146 | + | decision.Source = "user" | |
| 147 | + | return decision | |
| 135 | 148 | } | |
| 136 | 149 | ||
| 137 | 150 | // Reset clears the session memory. Invoked when the user toggles | |
| @@ -155,6 +168,7 @@ func (g *ConfirmGate) AllowAll() { | |||
| 155 | 168 | } | |
| 156 | 169 | g.mu.Lock() | |
| 157 | 170 | g.allowAll = true | |
| 171 | + | g.allowAllSource = "yolo" | |
| 158 | 172 | g.mu.Unlock() | |
| 159 | 173 | } | |
| 160 | 174 | ||
| @@ -12,6 +12,8 @@ import ( | |||||
| 12 | // of the session. | 12 | // of the session. | ||
| 13 | type ConfirmDecision struct { | 13 | type ConfirmDecision struct { | ||
| 14 | Allow bool | 14 | 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 when | 17 | // Reason is shown to the model as the tool error when | ||
| 16 | // Allow=false. Examples: "user declined", "user refused: rm -rf | 18 | // 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 Confirmer | 66 | inner Confirmer | ||
| 65 | 67 | ||||
| 66 | - | mu sync.Mutex | 68 | + | mu sync.Mutex |
| 67 | - | allowAll bool | 69 | + | allowAll bool |
| 68 | - | allowedTool map[string]bool | 70 | + | 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 confirmation | 94 | // 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, "", nil | 104 | + | 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, "", nil | 110 | + | 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, "", nil | 114 | + | return ConfirmDecision{Allow: true, Source: "remembered"} |
| 105 | } | 115 | } | ||
| 106 | inner := g.inner | 116 | 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", 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"} |
| 110 | } | 120 | } | ||
| 111 | 121 | ||||
| 112 | var decision ConfirmDecision | 122 | 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 = true | 132 | g.allowAll = true | ||
| 133 | + | g.allowAllSource = "remembered" | |||
| 123 | } | 134 | } | ||
| 124 | if decision.RememberTool { | 135 | if decision.RememberTool { | ||
| 125 | g.allowedTool[call.Name] = true | 136 | 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, nil | 145 | + | decision.Reason = reason |
| 146 | + | decision.Source = "user" | |||
| 147 | + | return decision | |||
| 135 | } | 148 | } | ||
| 136 | 149 | ||||
| 137 | // Reset clears the session memory. Invoked when the user toggles | 150 | // 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 = true | 170 | 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 { | |||
| 19 | 19 | ||
| 20 | 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 | 32 | type EvUserMessage struct { | |
| 23 | 33 | Message provider.Message | |
| 24 | 34 | } | |
| @@ -85,12 +95,29 @@ type EvToolProgress struct { | |||
| 85 | 95 | func (EvToolProgress) Type() string { return "tool_progress" } | |
| 86 | 96 | ||
| 87 | 97 | 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 | |
| 90 | 104 | } | |
| 91 | 105 | ||
| 92 | 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 | 121 | type EvUsage struct { | |
| 95 | 122 | Usage provider.Usage | |
| 96 | 123 | 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.Message | 33 | 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 string | 98 | + | ID string |
| 89 | - | Result ToolResult | 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 | |||
| 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.Usage | 122 | Usage provider.Usage | ||
| 96 | Cumulative provider.Usage | 123 | 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 { | |||
| 32 | 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 | 42 | // ToolResult is the outcome of Tool.Execute. | |
| 36 | 43 | type ToolResult struct { | |
| 37 | 44 | // Content is sent back to the LLM (text and/or images). | |
| 38 | 45 | Content []provider.Content | |
| 39 | 46 | // IsError marks this result as an error to the LLM. | |
| 40 | 47 | 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 | 52 | // ActivateTools names previously deferred tools that become available | |
| 42 | 53 | // after this result. Unknown names are ignored by the agent. | |
| 43 | 54 | 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.Content | 45 | 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 bool | 47 | 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 available | 52 | // 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 []string | 54 | ActivateTools []string | ||