1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
use supercode_interchange::ToolCall;
use crate::Usage;
/// Streaming events emitted by a native runtime agent as a turn unfolds.
///
/// Attach an [`EventSink`] to a runtime configuration to observe these live — for
/// example to render tokens to a terminal as they arrive, or to surface tool
/// activity in a UI.
///
/// `#[non_exhaustive]` because new event kinds will be added over time; match
/// with a `_` arm so a new variant is not a breaking change.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum AgentEvent {
/// A chunk of assistant text was produced.
TextDelta(String),
/// The assistant finished a text/tool turn (one round-trip to the model).
TurnCompleted,
/// The model requested a tool call (fired before the tool runs).
ToolCallStarted {
/// Provider-assigned call id.
id: String,
/// Tool name.
name: String,
/// Raw JSON argument string as sent by the model.
arguments: String,
},
/// A tool finished running.
ToolCallCompleted {
/// Provider-assigned call id.
id: String,
/// Tool name.
name: String,
/// The tool's textual output (truncated for display upstream if needed).
output: String,
/// Whether the tool reported an error.
is_error: bool,
},
/// UX-26 (B7-warn): the turn that just completed likely paid a
/// full-price prompt-cache miss despite reuse being expected under
/// an imported-prefix cache plan. Emitted at most once per turn, only when
/// cache warnings are enabled (default on) and reuse was genuinely
/// expected (never on a
/// first/establishing request, a same-turn tool-schema-tier bust, or
/// under a disabled cache plan — so this never fires as a false
/// positive on a cold-by-design request).
CacheWarning {
/// Ready-to-print, human-readable warning line (no trailing newline).
message: String,
},
/// UX-23: token accounting for the request that just completed (one per
/// model round-trip — a multi-tool-call turn emits one of these per
/// round-trip, same cadence as [`AgentEvent::TurnCompleted`], which this
/// is always emitted immediately before). Reuses the provider
/// completion's usage return rather than introducing a
/// second accounting path, so `--trace`/`stream-json` consumers see
/// exactly the numbers the provider reported — never a derived estimate.
Usage(Usage),
/// P5-6 (COMPOSABLE-HARNESS-DESIGN.md §2 module 4 `tools.background`,
/// D1 "monitor/event feed"): new output a background job (spawned via
/// the `background_exec` intrinsic) has produced since the last
/// `background_status` poll — the "event feed" `capabilities.
/// tools_background` promises. Emitted from
/// the runtime agent's background-status operation, at most once per poll,
/// only when there IS new output (an idle poll of a still-running job
/// with nothing new to report emits nothing).
BackgroundOutput {
/// The job id `background_exec` returned.
job_id: String,
/// The newly captured text since the previous poll (never a repeat
/// of already-emitted output).
chunk: String,
/// Whether this job's RETAINED capture has hit
/// `capabilities.tools_background.max_output_bytes` — `chunk`
/// itself is never truncated mid-character, but once this is
/// `true` no further output from this job will ever be retained or
/// emitted, even though the process may still be producing it.
truncated: bool,
},
}
impl AgentEvent {
/// Build the event emitted immediately before a tool call executes.
pub fn tool_started(call: &ToolCall) -> Self {
AgentEvent::ToolCallStarted {
id: call.id.clone(),
name: call.function.name.clone(),
arguments: call.function.arguments.clone(),
}
}
/// P5-8 (§2 module 31 `server`, completing Obligation 9's "partial"
/// core commitment): the canonical `{"type": ..., ...}` JSONL
/// projection of this event — field names mirror the enum's own
/// (`id`/`name`/`arguments`/`output`/`is_error`/`prompt_tokens`/…)
/// rather than a hand-maintained parallel vocabulary, so the wire shape
/// can never silently drift from the enum it projects.
///
/// Shared by the CLI's `--output-format stream-json` sink (UX-23) and
/// the `server` module's RPC/SSE event-notification channel, so both
/// out-of-process surfaces stay byte-identical for the same event
/// instead of maintaining two hand-written projections that could
/// silently diverge.
///
/// Match is exhaustive with NO wildcard arm on purpose: `#[non_exhaustive]`
/// only affects callers OUTSIDE this crate (it forced the CLI's old,
/// external copy of this projection to carry a `{"type":"unknown"}`
/// fallback arm) — from INSIDE the crate that defines the enum, adding a
/// future `AgentEvent` variant makes this fail to COMPILE until it's
/// given a real projection here, which is strictly safer than silently
/// falling back to an opaque `"unknown"` line for a new event kind.
pub fn to_json(&self) -> serde_json::Value {
match self {
AgentEvent::TextDelta(text) => {
serde_json::json!({"type": "text_delta", "text": text})
}
AgentEvent::TurnCompleted => serde_json::json!({"type": "turn_completed"}),
AgentEvent::ToolCallStarted {
id,
name,
arguments,
} => {
serde_json::json!({
"type": "tool_call_started",
"id": id,
"name": name,
"arguments": arguments,
})
}
AgentEvent::ToolCallCompleted {
id,
name,
output,
is_error,
} => {
serde_json::json!({
"type": "tool_call_completed",
"id": id,
"name": name,
"output": output,
"is_error": is_error,
})
}
AgentEvent::CacheWarning { message } => {
serde_json::json!({"type": "cache_warning", "message": message})
}
AgentEvent::Usage(usage) => {
serde_json::json!({
"type": "usage",
"prompt_tokens": usage.prompt_tokens,
"completion_tokens": usage.completion_tokens,
"total_tokens": usage.total_tokens,
"cached_tokens": usage.prompt_tokens_details.as_ref().map(|d| d.cached_tokens),
})
}
// P5-8: the one event kind added since `stream_json_sink` was
// first written (P5-6, module 4 `tools.background`) — it fell
// into the generic "unknown" catch-all before this method
// existed; giving it a real projection is part of "emit the
// full event set" (this unit's ladder rung 1 completion).
AgentEvent::BackgroundOutput {
job_id,
chunk,
truncated,
} => {
serde_json::json!({
"type": "background_output",
"job_id": job_id,
"chunk": chunk,
"truncated": truncated,
})
}
}
}
}
/// A sink for [`AgentEvent`]s.
///
/// This is a boxed closure so callers can wire up whatever they like (printing,
/// channels, metrics) without the crate dictating a concurrency model.
pub type EventSink = Box<dyn Fn(AgentEvent) + Send + Sync>;
#[cfg(test)]
mod tests {
use super::*;
use crate::{PromptTokensDetails, Usage};
#[test]
fn text_delta_projects_type_and_text() {
let v = AgentEvent::TextDelta("hi".to_string()).to_json();
assert_eq!(v["type"], "text_delta");
assert_eq!(v["text"], "hi");
}
#[test]
fn turn_completed_projects_bare_type() {
assert_eq!(
AgentEvent::TurnCompleted.to_json(),
serde_json::json!({"type": "turn_completed"})
);
}
#[test]
fn tool_call_started_projects_all_fields() {
let v = AgentEvent::ToolCallStarted {
id: "call_1".into(),
name: "bash".into(),
arguments: "{\"cmd\":\"ls\"}".into(),
}
.to_json();
assert_eq!(v["type"], "tool_call_started");
assert_eq!(v["id"], "call_1");
assert_eq!(v["name"], "bash");
assert_eq!(v["arguments"], "{\"cmd\":\"ls\"}");
}
#[test]
fn tool_call_completed_projects_all_fields() {
let v = AgentEvent::ToolCallCompleted {
id: "call_1".into(),
name: "bash".into(),
output: "ok".into(),
is_error: false,
}
.to_json();
assert_eq!(v["type"], "tool_call_completed");
assert_eq!(v["output"], "ok");
assert_eq!(v["is_error"], false);
}
#[test]
fn usage_projects_cached_tokens_when_present() {
let v = AgentEvent::Usage(Usage {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
prompt_tokens_details: Some(PromptTokensDetails { cached_tokens: 4 }),
})
.to_json();
assert_eq!(v["type"], "usage");
assert_eq!(v["prompt_tokens"], 10);
assert_eq!(v["cached_tokens"], 4);
}
#[test]
fn usage_projects_null_cached_tokens_when_absent() {
let v = AgentEvent::Usage(Usage {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
prompt_tokens_details: None,
})
.to_json();
assert!(v["cached_tokens"].is_null());
}
#[test]
fn background_output_projects_all_fields_not_unknown() {
let v = AgentEvent::BackgroundOutput {
job_id: "job_1".into(),
chunk: "more output".into(),
truncated: true,
}
.to_json();
assert_eq!(v["type"], "background_output");
assert_eq!(v["job_id"], "job_1");
assert_eq!(v["chunk"], "more output");
assert_eq!(v["truncated"], true);
}
}