Skip to content

Commit 9e0dfbd

Browse files
committed
feat(events): add agent event schema and broker adapter
Adds aura_events::agent, the event vocabulary a running agent emits, and aura::agent_events, which republishes it onto the request-scoped brokers so consumers are unaffected. A differential test asserts both paths produce identical SSE. No producer emits the schema yet; agent_events_enabled reads AURA_AGENT_EVENTS and is the seam producers will gate on. Ref: #618 Signed-off-by: Jacob Hull <jacob@planethull.com>
1 parent 5da2fb2 commit 9e0dfbd

5 files changed

Lines changed: 925 additions & 0 deletions

File tree

crates/aura-events/src/agent.rs

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
//! The agent-produced event vocabulary.
2+
//!
3+
//! [`AgentEvent`] is what a running agent emits. Observers — an SSE producer, an
4+
//! A2A status bridge, an OTel exporter — consume this stream and project it into
5+
//! whatever shape they serve. Contrast [`crate::AuraStreamEvent`], which is the
6+
//! HTTP *wire* form of one such projection.
7+
//!
8+
//! Two properties distinguish this schema from the wire schema:
9+
//!
10+
//! - **Internally tagged.** [`crate::AuraStreamEvent`] is `#[serde(untagged)]`,
11+
//! so its variant order is load-bearing during deserialization. This enum
12+
//! carries a `type` discriminator instead, making variant order irrelevant.
13+
//! - **No correlation context.** The wire events flatten a
14+
//! [`CorrelationContext`](crate::CorrelationContext) (session id, trace id)
15+
//! into every payload. That is ambient request state, not something an agent
16+
//! knows, so it is applied by the observer rather than carried here.
17+
18+
use serde::{Deserialize, Serialize};
19+
20+
use crate::{
21+
AgentContext, ApprovalCompleted, ApprovalPending, ApprovalRequested, McpServerStatus,
22+
ProgressToken, WorkerPhase,
23+
};
24+
25+
#[derive(Clone, Debug, Serialize, Deserialize)]
26+
pub struct AgentEvent {
27+
pub agent: AgentContext,
28+
pub payload: AgentEventPayload,
29+
}
30+
31+
impl AgentEvent {
32+
pub fn new(agent: AgentContext, payload: AgentEventPayload) -> Self {
33+
Self { agent, payload }
34+
}
35+
36+
/// Attributes the payload to `agent_id: "main"`.
37+
pub fn single_agent(payload: AgentEventPayload) -> Self {
38+
Self::new(AgentContext::single_agent(), payload)
39+
}
40+
}
41+
42+
#[derive(Clone, Debug, Serialize, Deserialize)]
43+
#[serde(tag = "outcome", rename_all = "snake_case")]
44+
pub enum ToolOutcome {
45+
Success { result: String },
46+
Failure { error: String },
47+
}
48+
49+
/// What an agent has to say about its own execution.
50+
///
51+
/// `#[non_exhaustive]` because the vocabulary grows as producers move onto this
52+
/// schema — orchestration events in particular are not yet modelled here.
53+
#[derive(Clone, Debug, Serialize, Deserialize)]
54+
#[serde(tag = "type", rename_all = "snake_case")]
55+
#[non_exhaustive]
56+
pub enum AgentEventPayload {
57+
SessionInfo {
58+
model: String,
59+
#[serde(default, skip_serializing_if = "Option::is_none")]
60+
model_context_limit: Option<u64>,
61+
},
62+
63+
McpStatus {
64+
servers: Vec<McpServerStatus>,
65+
},
66+
67+
TextDelta {
68+
content: String,
69+
},
70+
71+
Reasoning {
72+
content: String,
73+
},
74+
75+
/// The model's decision to call a tool, ahead of any execution.
76+
ToolRequested {
77+
tool_id: String,
78+
tool_name: String,
79+
arguments: serde_json::Value,
80+
},
81+
82+
ToolStart {
83+
tool_id: String,
84+
tool_name: String,
85+
#[serde(default, skip_serializing_if = "Option::is_none")]
86+
progress_token: Option<ProgressToken>,
87+
},
88+
89+
ToolComplete {
90+
tool_id: String,
91+
tool_name: String,
92+
duration_ms: u64,
93+
#[serde(flatten)]
94+
outcome: ToolOutcome,
95+
},
96+
97+
ToolProgress {
98+
progress_token: ProgressToken,
99+
progress: f64,
100+
#[serde(default, skip_serializing_if = "Option::is_none")]
101+
total: Option<f64>,
102+
#[serde(default, skip_serializing_if = "Option::is_none")]
103+
message: Option<String>,
104+
},
105+
106+
WorkerPhase {
107+
phase: WorkerPhase,
108+
#[serde(default, skip_serializing_if = "Option::is_none")]
109+
task_id: Option<String>,
110+
},
111+
112+
/// Provider-billed tokens for one turn.
113+
ToolUsage {
114+
tool_ids: Vec<String>,
115+
prompt_tokens: u64,
116+
completion_tokens: u64,
117+
total_tokens: u64,
118+
},
119+
120+
/// Provider-billed tokens, cumulative across turns.
121+
Usage {
122+
prompt_tokens: u64,
123+
completion_tokens: u64,
124+
total_tokens: u64,
125+
},
126+
127+
/// Context-window occupancy, not billing.
128+
ContextUsage {
129+
context_tokens: u64,
130+
response_tokens: u64,
131+
#[serde(default, skip_serializing_if = "Option::is_none")]
132+
context_window: Option<u64>,
133+
},
134+
135+
ScratchpadUsage {
136+
tokens_intercepted: usize,
137+
tokens_extracted: usize,
138+
},
139+
140+
ApprovalRequested(ApprovalRequested),
141+
142+
ApprovalPending(ApprovalPending),
143+
144+
ApprovalCompleted(ApprovalCompleted),
145+
}
146+
147+
#[cfg(test)]
148+
mod tests {
149+
use super::*;
150+
use serde_json::json;
151+
152+
fn roundtrip(payload: AgentEventPayload) -> AgentEventPayload {
153+
let json = serde_json::to_string(&payload).expect("payload should serialize");
154+
serde_json::from_str(&json).expect("payload should deserialize")
155+
}
156+
157+
#[test]
158+
fn the_tag_names_the_variant() {
159+
let json = serde_json::to_value(AgentEventPayload::TextDelta {
160+
content: "hi".to_string(),
161+
})
162+
.expect("should serialize");
163+
164+
assert_eq!(json["type"], "text_delta");
165+
assert_eq!(json["content"], "hi");
166+
}
167+
168+
/// The wire enum needs `ToolComplete` declared before `ToolStart` and
169+
/// `ToolUsage` before `Usage` to deserialize correctly. The tag makes the
170+
/// same shapes unambiguous here regardless of declaration order.
171+
#[test]
172+
fn variants_the_wire_enum_must_order_are_unambiguous_here() {
173+
let start = roundtrip(AgentEventPayload::ToolStart {
174+
tool_id: "call_1".to_string(),
175+
tool_name: "list_files".to_string(),
176+
progress_token: None,
177+
});
178+
assert!(matches!(start, AgentEventPayload::ToolStart { .. }));
179+
180+
let usage = roundtrip(AgentEventPayload::Usage {
181+
prompt_tokens: 1,
182+
completion_tokens: 2,
183+
total_tokens: 3,
184+
});
185+
assert!(matches!(usage, AgentEventPayload::Usage { .. }));
186+
}
187+
188+
#[test]
189+
fn tool_outcome_flattens_onto_tool_complete() {
190+
let json = serde_json::to_value(AgentEventPayload::ToolComplete {
191+
tool_id: "call_1".to_string(),
192+
tool_name: "list_files".to_string(),
193+
duration_ms: 12,
194+
outcome: ToolOutcome::Failure {
195+
error: "boom".to_string(),
196+
},
197+
})
198+
.expect("should serialize");
199+
200+
assert_eq!(json["type"], "tool_complete");
201+
assert_eq!(json["outcome"], "failure");
202+
assert_eq!(json["error"], "boom");
203+
}
204+
205+
#[test]
206+
fn an_event_carries_its_emitting_agent() {
207+
let event = AgentEvent::new(
208+
AgentContext::worker("log_worker", None, "orchestrator"),
209+
AgentEventPayload::TextDelta {
210+
content: "scanning".to_string(),
211+
},
212+
);
213+
let json = serde_json::to_value(&event).expect("should serialize");
214+
215+
assert_eq!(json["agent"]["agent_id"], "log_worker");
216+
assert_eq!(json["agent"]["parent_agent_id"], "orchestrator");
217+
assert_eq!(json["payload"]["type"], "text_delta");
218+
}
219+
220+
#[test]
221+
fn arguments_survive_a_roundtrip() {
222+
let payload = roundtrip(AgentEventPayload::ToolRequested {
223+
tool_id: "call_1".to_string(),
224+
tool_name: "list_files".to_string(),
225+
arguments: json!({ "path": "/mock", "depth": 2 }),
226+
});
227+
228+
let AgentEventPayload::ToolRequested { arguments, .. } = payload else {
229+
panic!("expected ToolRequested");
230+
};
231+
assert_eq!(arguments, json!({ "path": "/mock", "depth": 2 }));
232+
}
233+
}

crates/aura-events/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
//! Both enums derive `Serialize + Deserialize` so they can be used for
1515
//! producing SSE (server) and parsing SSE (client) with the same types.
1616
17+
pub mod agent;
1718
pub mod event_names;
1819
pub mod orchestration;
1920

0 commit comments

Comments
 (0)