defect_agent/event.rs
1//! The event stream published by the agent main loop to external consumers.
2//!
3//! ## Decoupling-by-shape
4//!
5//! The main loop emits only [`AgentEvent`] — an internal enum — and three independent
6//! consumers each take what they need:
7//!
8//! ```text
9//! ┌──► defect-acp (translated into SessionUpdate / PromptResponse)
10//! AgentEvent ────┼──► defect-storage (jsonl persistence)
11//! └──► tracing (structured logging, observability)
12//! ```
13//!
14//! We define the enum **variants** ourselves (decoupling the persistence format from the
15//! wire, and expressing semantics absent from the wire such as turn boundaries and LLM
16//! calls), but we **reuse ACP's passive data structures** (`ToolCallUpdateFields`,
17//! `ContentBlock`, `StopReason`, etc.) as field types wherever possible, to avoid
18//! reinventing fields.
19
20use std::sync::Arc;
21
22use agent_client_protocol_schema::{
23 ContentBlock, PermissionOptionId, StopReason as AcpStopReason, ToolCallId, ToolCallUpdateFields,
24};
25use serde::{Deserialize, Serialize};
26
27use crate::llm::{Message, Usage};
28use crate::policy::PolicyDecision;
29
30/// Events published by the agent main loop.
31///
32/// Final-state semantics: the event stream for a turn starts with
33/// [`AgentEvent::TurnStarted`] and ends with [`AgentEvent::TurnEnded`]. After
34/// `TurnEnded`, no more events for that turn are produced — `defect-acp` stops pushing
35/// `session/update` and responds with `PromptResponse` upon seeing it.
36#[non_exhaustive]
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38#[serde(tag = "type", rename_all = "snake_case")]
39pub enum AgentEvent {
40 // ---------- turn boundary ----------
41 /// A prompt turn has started.
42 TurnStarted,
43
44 /// The user prompt has been committed to history by the main loop.
45 UserPromptCommitted { content: Vec<ContentBlock> },
46
47 /// A prompt turn ended. `reason` directly borrows the semantic category from ACP.
48 TurnEnded {
49 reason: AcpStopReason,
50 /// Cumulative token usage for this turn (field-wise sum from
51 /// [`crate::llm::ProviderChunk::Usage`]).
52 usage: Usage,
53 },
54
55 // ---------- Assistant output (pushed to wire) ----------
56 /// Incremental assistant text. Maps to ACP `SessionUpdate::AgentMessageChunk`.
57 AssistantText { content: ContentBlock },
58
59 /// Assistant thought chain delta. Maps to ACP `SessionUpdate::AgentThoughtChunk`.
60 AssistantThought { content: ContentBlock },
61
62 // ---------- Tool calls (pushed to wire) ----------
63 /// A tool call start declaration.
64 /// Maps to ACP `SessionUpdate::ToolCall` (status = Pending).
65 ToolCallStarted {
66 id: ToolCallId,
67 name: String,
68 fields: ToolCallUpdateFields,
69 },
70
71 /// Tool call progress delta.
72 /// Maps to ACP `SessionUpdate::ToolCallUpdate`.
73 ToolCallProgress {
74 id: ToolCallId,
75 fields: ToolCallUpdateFields,
76 },
77
78 /// Tool call finished (success/failure is indicated by `fields.status`).
79 /// Maps to ACP `SessionUpdate::ToolCallUpdate` (with a terminal status).
80 ToolCallFinished {
81 id: ToolCallId,
82 fields: ToolCallUpdateFields,
83 },
84
85 // ---------- Permission decisions (partially pushed to wire) ----------
86 /// The sandbox policy makes a decision about a tool call. `Ask` triggers the ACP
87 /// `session/request_permission`; `Allow` / `Deny` are only audited and not sent over
88 /// the wire.
89 PolicyDecision {
90 id: ToolCallId,
91 decision: PolicyDecision,
92 },
93
94 /// User's response to a [`PolicyDecision::Ask`]. Audit-only, not sent on the wire.
95 PermissionResolved {
96 id: ToolCallId,
97 outcome: PermissionResolution,
98 },
99
100 // Main loop orchestration (not sent over the wire; storage / tracing only)
101 /// A single LLM provider call has started.
102 LlmCallStarted {
103 model: String,
104 /// The attempt number (1-based). Retries are driven by the main loop.
105 attempt: u32,
106 /// A snapshot of the request sent to the provider (system message + full message
107 /// history).
108 ///
109 /// Used by observability to reconstruct the generation's `input` as a standard
110 /// chat message array (including the system message). Not sent over the wire;
111 /// storage currently ignores this field.
112 ///
113 /// Wrapped in `Arc`: when events are fanned out to subscribers via
114 /// [`crate::session::EventEmitter`], each subscriber clones the event. With long
115 /// contexts, deep-copying the entire message history repeatedly is expensive. The
116 /// snapshot is read-only once inside the event, so `Arc` reduces clone to a
117 /// reference-count increment.
118 /// `#[serde(skip)]`: the serde derive on `AgentEvent` is not currently used, and
119 /// we prefer not to enable serde's `rc` feature for it—on deserialization this
120 /// field takes the default empty snapshot.
121 #[serde(skip)]
122 request: Arc<LlmRequestSnapshot>,
123 },
124
125 /// A single LLM provider call has finished. `error` being `Some` indicates failure
126 /// (the retry hint determines whether to proceed to the next attempt).
127 LlmCallFinished {
128 model: String,
129 attempt: u32,
130 usage: Usage,
131 /// Error description on failure (the full error object is not stored here — it
132 /// goes into tracing).
133 error: Option<String>,
134 },
135
136 /// The main loop compressed / truncated the history.
137 ContextCompressed {
138 tokens_before: u64,
139 tokens_after: u64,
140 },
141
142 /// The main loop performed a **micro-compaction**: it cleaned up oversized
143 /// `tool_result` bodies from older turns (without calling the LLM or deleting
144 /// messages). `cleared` is the number of `tool_result` entries actually cleaned. This
145 /// is distinguished from [`Self::ContextCompressed`] so that observability and the UI
146 /// can display them separately.
147 ContextMicrocompacted {
148 tokens_before: u64,
149 tokens_after: u64,
150 cleared: usize,
151 },
152
153 // ---------- subagent nesting (observability only) ----------
154 /// A **leaf** event produced inside a `spawn_agent` sub-agent turn, bridged from the
155 /// sub-turn's isolated event stream into the parent session's event stream.
156 ///
157 /// Design intent: the sub-agent runs in a fresh, isolated context (its own
158 /// [`crate::session::EventEmitter`]), and the parent agent **cannot see** its
159 /// intermediate steps — this is the isolation contract of `spawn_agent`. However,
160 /// observability (langfuse) wants to display the sub-turn's LLM calls / tool calls
161 /// nested under the parent's `spawn_agent` tool call span. So `spawn_agent` attaches
162 /// a bridging subscriber to the sub-emitter, wrapping each sub-event as this variant
163 /// and forwarding it to the parent emitter.
164 ///
165 /// ## Flattening (supports recursive subagents)
166 ///
167 /// `inner` **is always a leaf event** (never another `Subagent`). Nesting depth is
168 /// expressed by the **ancestor chain** [`Self::Subagent::ancestor_path`], not by
169 /// nested `Box` wrappers: the chain lists ids from the top-level `spawn_agent` tool
170 /// call down to the current layer. Each bridging layer **prepends** its own
171 /// `parent_tool_call_id` to the chain head, leaving the leaf `inner` unchanged (see
172 /// the bridge closure in `spawn_agent.rs`). The projector uses the full chain to
173 /// locate the parent mount point — the chain is globally unique, naturally avoids
174 /// `ToolCallId` collisions across sub-sessions, and the projector does not need to
175 /// recursively unwrap.
176 ///
177 /// **Consumption contract**: only the langfuse projector processes this (emitting
178 /// nested generations/spans under the parent tool span). All other consumers
179 /// (`defect-storage` persistence, `defect-acp` wire projection, REPL rendering)
180 /// **ignore** it — the isolation contract remains unchanged for them.
181 Subagent {
182 /// Ancestor chain of `ToolCallId`s from the top-level `spawn_agent` tool call
183 /// down to the current subagent layer.
184 /// The head is the top-level `spawn_agent` (directly attached to the parent turn
185 /// trace), and the tail is the `spawn_agent` that initiated this leaf event.
186 /// Depth equals `ancestor_path.len()`.
187 ancestor_path: Vec<ToolCallId>,
188 /// The profile name of the subagent that initiated this leaf event (e.g.
189 /// `weebs-in`), used for naming / metadata of nested spans.
190 agent_type: String,
191 /// The bridged child turn **leaf** event (never another `Subagent`). `Box`
192 /// prevents the enum from growing unbounded due to self-reference.
193 inner: Box<AgentEvent>,
194 },
195}
196
197/// A snapshot of an LLM call request, containing only the fields needed for observability
198/// to reconstruct the generation `input` (system prompt + full message history). Does not
199/// include tools, sampling parameters, etc.
200///
201/// Defined separately rather than embedded in `CompletionRequest` to avoid making
202/// `AgentEvent` depend on the full request type, and to keep the snapshot minimal and
203/// serialization-stable.
204#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
205pub struct LlmRequestSnapshot {
206 /// The system prompt, if any. Observability reconstructs it as a single
207 /// `{role:"system"}` entry.
208 pub system: Option<Arc<str>>,
209 /// The full message history sent to the provider.
210 pub messages: Vec<Message>,
211}
212
213/// The user's response to an [`Ask`](crate::policy::Ask).
214#[non_exhaustive]
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(tag = "kind", rename_all = "snake_case")]
217pub enum PermissionResolution {
218 /// The user selected an option; `option_id` is provided by the ACP
219 /// `PermissionOption`.
220 Selected { option_id: PermissionOptionId },
221 /// The user cancelled the turn before making a selection.
222 Cancelled,
223}