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 /// A prompt turn failed permanently (e.g. a provider error after retries) and was
56 /// rolled back. Everything the turn appended to history — starting with the user
57 /// prompt — has been discarded in memory; consumers that persist or mirror history
58 /// (storage) must drop the same tail so a failed turn leaves no orphan to be replayed
59 /// on reload or re-sent on the next request. Audit-only on the wire (the ACP bridge
60 /// reports failure via the JSON-RPC error, not this event).
61 TurnAborted,
62
63 // ---------- Assistant output (pushed to wire) ----------
64 /// Incremental assistant text. Maps to ACP `SessionUpdate::AgentMessageChunk`.
65 AssistantText { content: ContentBlock },
66
67 /// Assistant thought chain delta. Maps to ACP `SessionUpdate::AgentThoughtChunk`.
68 AssistantThought { content: ContentBlock },
69
70 // ---------- Tool calls (pushed to wire) ----------
71 /// A tool call start declaration.
72 /// Maps to ACP `SessionUpdate::ToolCall` (status = Pending).
73 ToolCallStarted {
74 id: ToolCallId,
75 name: String,
76 fields: ToolCallUpdateFields,
77 },
78
79 /// Tool call progress delta.
80 /// Maps to ACP `SessionUpdate::ToolCallUpdate`.
81 ToolCallProgress {
82 id: ToolCallId,
83 fields: ToolCallUpdateFields,
84 },
85
86 /// Tool call finished (success/failure is indicated by `fields.status`).
87 /// Maps to ACP `SessionUpdate::ToolCallUpdate` (with a terminal status).
88 ToolCallFinished {
89 id: ToolCallId,
90 fields: ToolCallUpdateFields,
91 },
92
93 // ---------- Permission decisions (partially pushed to wire) ----------
94 /// The sandbox policy makes a decision about a tool call. `Ask` triggers the ACP
95 /// `session/request_permission`; `Allow` / `Deny` are only audited and not sent over
96 /// the wire.
97 PolicyDecision {
98 id: ToolCallId,
99 decision: PolicyDecision,
100 },
101
102 /// User's response to a [`PolicyDecision::Ask`]. Audit-only, not sent on the wire.
103 PermissionResolved {
104 id: ToolCallId,
105 outcome: PermissionResolution,
106 },
107
108 // Main loop orchestration (not sent over the wire; storage / tracing only)
109 /// A single LLM provider call has started.
110 LlmCallStarted {
111 model: String,
112 /// The attempt number (1-based). Retries are driven by the main loop.
113 attempt: u32,
114 /// A snapshot of the request sent to the provider (system message + full message
115 /// history).
116 ///
117 /// Used by observability to reconstruct the generation's `input` as a standard
118 /// chat message array (including the system message). Not sent over the wire;
119 /// storage currently ignores this field.
120 ///
121 /// Wrapped in `Arc`: when events are fanned out to subscribers via
122 /// [`crate::session::EventEmitter`], each subscriber clones the event. With long
123 /// contexts, deep-copying the entire message history repeatedly is expensive. The
124 /// snapshot is read-only once inside the event, so `Arc` reduces clone to a
125 /// reference-count increment.
126 /// `#[serde(skip)]`: the serde derive on `AgentEvent` is not currently used, and
127 /// we prefer not to enable serde's `rc` feature for it—on deserialization this
128 /// field takes the default empty snapshot.
129 #[serde(skip)]
130 request: Arc<LlmRequestSnapshot>,
131 },
132
133 /// A single LLM provider call has finished. `error` being `Some` indicates failure
134 /// (the retry hint determines whether to proceed to the next attempt).
135 LlmCallFinished {
136 model: String,
137 attempt: u32,
138 usage: Usage,
139 /// Error description on failure (the full error object is not stored here — it
140 /// goes into tracing).
141 error: Option<String>,
142 },
143
144 /// The main loop compressed / truncated the history.
145 ContextCompressed {
146 tokens_before: u64,
147 tokens_after: u64,
148 },
149
150 /// The main loop performed a **micro-compaction**: it cleaned up oversized
151 /// `tool_result` bodies from older turns (without calling the LLM or deleting
152 /// messages). `cleared` is the number of `tool_result` entries actually cleaned. This
153 /// is distinguished from [`Self::ContextCompressed`] so that observability and the UI
154 /// can display them separately.
155 ContextMicrocompacted {
156 tokens_before: u64,
157 tokens_after: u64,
158 cleared: usize,
159 },
160
161 // ---------- subagent nesting (observability only) ----------
162 /// A **leaf** event produced inside a `spawn_agent` sub-agent turn, bridged from the
163 /// sub-turn's isolated event stream into the parent session's event stream.
164 ///
165 /// Design intent: the sub-agent runs in a fresh, isolated context (its own
166 /// [`crate::session::EventEmitter`]), and the parent agent **cannot see** its
167 /// intermediate steps — this is the isolation contract of `spawn_agent`. However,
168 /// observability (langfuse) wants to display the sub-turn's LLM calls / tool calls
169 /// nested under the parent's `spawn_agent` tool call span. So `spawn_agent` attaches
170 /// a bridging subscriber to the sub-emitter, wrapping each sub-event as this variant
171 /// and forwarding it to the parent emitter.
172 ///
173 /// ## Flattening (supports recursive subagents)
174 ///
175 /// `inner` **is always a leaf event** (never another `Subagent`). Nesting depth is
176 /// expressed by the **ancestor chain** [`Self::Subagent::ancestor_path`], not by
177 /// nested `Box` wrappers: the chain lists ids from the top-level `spawn_agent` tool
178 /// call down to the current layer. Each bridging layer **prepends** its own
179 /// `parent_tool_call_id` to the chain head, leaving the leaf `inner` unchanged (see
180 /// the bridge closure in `spawn_agent.rs`). The projector uses the full chain to
181 /// locate the parent mount point — the chain is globally unique, naturally avoids
182 /// `ToolCallId` collisions across sub-sessions, and the projector does not need to
183 /// recursively unwrap.
184 ///
185 /// **Consumption contract**: only the langfuse projector processes this (emitting
186 /// nested generations/spans under the parent tool span). All other consumers
187 /// (`defect-storage` persistence, `defect-acp` wire projection, REPL rendering)
188 /// **ignore** it — the isolation contract remains unchanged for them.
189 Subagent {
190 /// Ancestor chain of `ToolCallId`s from the top-level `spawn_agent` tool call
191 /// down to the current subagent layer.
192 /// The head is the top-level `spawn_agent` (directly attached to the parent turn
193 /// trace), and the tail is the `spawn_agent` that initiated this leaf event.
194 /// Depth equals `ancestor_path.len()`.
195 ancestor_path: Vec<ToolCallId>,
196 /// The profile name of the subagent that initiated this leaf event (e.g.
197 /// `weebs-in`), used for naming / metadata of nested spans.
198 agent_type: String,
199 /// The bridged child turn **leaf** event (never another `Subagent`). `Box`
200 /// prevents the enum from growing unbounded due to self-reference.
201 inner: Box<AgentEvent>,
202 },
203}
204
205/// A snapshot of an LLM call request, containing only the fields needed for observability
206/// to reconstruct the generation `input` (system prompt + full message history). Does not
207/// include tools, sampling parameters, etc.
208///
209/// Defined separately rather than embedded in `CompletionRequest` to avoid making
210/// `AgentEvent` depend on the full request type, and to keep the snapshot minimal and
211/// serialization-stable.
212#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
213pub struct LlmRequestSnapshot {
214 /// The system prompt, if any. Observability reconstructs it as a single
215 /// `{role:"system"}` entry.
216 pub system: Option<Arc<str>>,
217 /// The full message history sent to the provider.
218 pub messages: Vec<Message>,
219}
220
221/// The user's response to an [`Ask`](crate::policy::Ask).
222#[non_exhaustive]
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(tag = "kind", rename_all = "snake_case")]
225pub enum PermissionResolution {
226 /// The user selected an option; `option_id` is provided by the ACP
227 /// `PermissionOption`.
228 Selected { option_id: PermissionOptionId },
229 /// The user cancelled the turn before making a selection.
230 Cancelled,
231}