Skip to main content

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}