Skip to content

Commit 966c455

Browse files
committed
feat(events): orchestration emits the agent event schema
Orchestration carried its own OrchestratorEvent enum between the orchestrator and the SSE projection. Its variants move onto AgentEventPayload, so orchestrated and single-agent runs share one vocabulary, and StreamItem carries that payload directly. Emitted frames are unchanged; the projection reads the same field sets under a different type. Ref: #623 Signed-off-by: Jacob Hull <jacob@planethull.com>
1 parent a5d7a22 commit 966c455

14 files changed

Lines changed: 363 additions & 339 deletions

File tree

crates/aura-events/src/agent.rs

Lines changed: 111 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
1818
use serde::{Deserialize, Serialize};
1919

20+
use crate::orchestration::{IterationTimings, RoutingMode};
2021
use crate::{
2122
AgentContext, ApprovalCompleted, ApprovalPending, ApprovalRequested, McpServerStatus,
2223
ProgressToken, WorkerPhase,
@@ -41,15 +42,11 @@ impl AgentEvent {
4142

4243
#[derive(Clone, Debug, Serialize, Deserialize)]
4344
#[serde(tag = "outcome", rename_all = "snake_case")]
44-
pub enum ToolOutcome {
45+
pub enum Outcome {
4546
Success { result: String },
4647
Failure { error: String },
4748
}
4849

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.
5350
#[derive(Clone, Debug, Serialize, Deserialize)]
5451
#[serde(tag = "type", rename_all = "snake_case")]
5552
#[non_exhaustive]
@@ -70,6 +67,8 @@ pub enum AgentEventPayload {
7067

7168
Reasoning {
7269
content: String,
70+
#[serde(default, skip_serializing_if = "Option::is_none")]
71+
task_id: Option<usize>,
7372
},
7473

7574
/// The model's decision to call a tool, ahead of any execution.
@@ -84,14 +83,22 @@ pub enum AgentEventPayload {
8483
tool_name: String,
8584
#[serde(default, skip_serializing_if = "Option::is_none")]
8685
progress_token: Option<ProgressToken>,
86+
/// Present when the producer knows them at start; a run that announces
87+
/// the call separately carries them on [`Self::ToolRequested`].
88+
#[serde(default, skip_serializing_if = "Option::is_none")]
89+
arguments: Option<serde_json::Value>,
90+
#[serde(default, skip_serializing_if = "Option::is_none")]
91+
task_id: Option<usize>,
8792
},
8893

8994
ToolComplete {
9095
tool_id: String,
9196
tool_name: String,
9297
duration_ms: u64,
9398
#[serde(flatten)]
94-
outcome: ToolOutcome,
99+
outcome: Outcome,
100+
#[serde(default, skip_serializing_if = "Option::is_none")]
101+
task_id: Option<usize>,
95102
},
96103

97104
ToolProgress {
@@ -106,7 +113,7 @@ pub enum AgentEventPayload {
106113
WorkerPhase {
107114
phase: WorkerPhase,
108115
#[serde(default, skip_serializing_if = "Option::is_none")]
109-
task_id: Option<String>,
116+
task_id: Option<usize>,
110117
},
111118

112119
/// Provider-billed tokens for one turn.
@@ -142,6 +149,76 @@ pub enum AgentEventPayload {
142149
ApprovalPending(ApprovalPending),
143150

144151
ApprovalCompleted(ApprovalCompleted),
152+
153+
PlanCreated {
154+
goal: String,
155+
tasks: Vec<String>,
156+
routing_mode: RoutingMode,
157+
routing_rationale: String,
158+
planning_response: String,
159+
},
160+
161+
DirectAnswer {
162+
response: String,
163+
routing_rationale: String,
164+
},
165+
166+
ClarificationNeeded {
167+
question: String,
168+
#[serde(default, skip_serializing_if = "Option::is_none")]
169+
options: Option<Vec<String>>,
170+
routing_rationale: String,
171+
},
172+
173+
TaskStarted {
174+
task_id: usize,
175+
description: String,
176+
orchestrator_id: String,
177+
},
178+
179+
TaskCompleted {
180+
task_id: usize,
181+
duration_ms: u64,
182+
orchestrator_id: String,
183+
#[serde(flatten)]
184+
outcome: Outcome,
185+
},
186+
187+
/// A worker's gated call is waiting on an approval the run will not block
188+
/// for; one per parked call.
189+
TaskBlocked {
190+
task_id: usize,
191+
tool_call_id: String,
192+
decision_id: String,
193+
tool_name: String,
194+
orchestrator_id: String,
195+
},
196+
197+
/// The run stopped to await its parked approvals. Terminal.
198+
RunParked {
199+
run_id: String,
200+
decision_ids: Vec<String>,
201+
expires_at: String,
202+
iteration: usize,
203+
},
204+
205+
IterationComplete {
206+
iteration: usize,
207+
will_replan: bool,
208+
reasoning: String,
209+
gaps: Vec<String>,
210+
timings: IterationTimings,
211+
},
212+
213+
ReplanStarted {
214+
iteration: usize,
215+
/// `"coordinator"` or `"failure"`.
216+
trigger: String,
217+
},
218+
219+
Synthesizing {
220+
iteration: usize,
221+
},
145222
}
146223

147224
#[cfg(test)]
@@ -174,6 +251,8 @@ mod tests {
174251
tool_id: "call_1".to_string(),
175252
tool_name: "list_files".to_string(),
176253
progress_token: None,
254+
arguments: None,
255+
task_id: None,
177256
});
178257
assert!(matches!(start, AgentEventPayload::ToolStart { .. }));
179258

@@ -188,10 +267,11 @@ mod tests {
188267
#[test]
189268
fn tool_outcome_flattens_onto_tool_complete() {
190269
let json = serde_json::to_value(AgentEventPayload::ToolComplete {
270+
task_id: None,
191271
tool_id: "call_1".to_string(),
192272
tool_name: "list_files".to_string(),
193273
duration_ms: 12,
194-
outcome: ToolOutcome::Failure {
274+
outcome: Outcome::Failure {
195275
error: "boom".to_string(),
196276
},
197277
})
@@ -202,6 +282,29 @@ mod tests {
202282
assert_eq!(json["error"], "boom");
203283
}
204284

285+
/// `ToolStart` is emitted by a lone agent and by an orchestration worker,
286+
/// so nothing about the payload says which frames it becomes — only the
287+
/// envelope does.
288+
#[test]
289+
fn a_shared_variant_is_told_apart_by_its_agent_not_its_payload() {
290+
let payload = || AgentEventPayload::ToolStart {
291+
tool_id: "call_1".to_string(),
292+
tool_name: "list_files".to_string(),
293+
progress_token: None,
294+
arguments: None,
295+
task_id: None,
296+
};
297+
298+
let alone = AgentEvent::single_agent(payload());
299+
let worker = AgentEvent::new(
300+
AgentContext::worker("log_worker", None, "coordinator"),
301+
payload(),
302+
);
303+
304+
assert!(alone.agent.is_single_agent());
305+
assert!(!worker.agent.is_single_agent());
306+
}
307+
205308
#[test]
206309
fn an_event_carries_its_emitting_agent() {
207310
let event = AgentEvent::new(

crates/aura-events/src/lib.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,24 @@ impl AgentContext {
101101
}
102102
}
103103

104+
/// Whether this is the lone agent of an unorchestrated run.
105+
///
106+
/// Several payload variants are emitted by both modes, so the projection
107+
/// that turns an event into a frame reads this rather than the variant.
108+
pub fn is_single_agent(&self) -> bool {
109+
self.agent_id == "main"
110+
}
111+
112+
/// The agent that plans an orchestrated run and synthesises its answer.
113+
/// Workers name it as their parent.
114+
pub fn coordinator() -> Self {
115+
Self {
116+
agent_id: "coordinator".to_string(),
117+
agent_name: None,
118+
parent_agent_id: None,
119+
}
120+
}
121+
104122
/// Create a worker agent context with parent hierarchy
105123
pub fn worker(
106124
id: impl Into<String>,

0 commit comments

Comments
 (0)