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    // ---------- 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}