Skip to content

Commit 3f14912

Browse files
authored
feat: add SSE event normalizer module (vllm-project#49)
## Summary Adds `events/` module to `agentic-core` — a pure parsing library that normalizes raw SSE data lines into typed `EventFrame` structs. This is the foundation for tool dispatch, streaming tee, and loop control (Phases 2–4 of the core API design in PR vllm-project#44). - **20-variant `SSEEventType` enum** covering all Responses API event types (text, function_call, reasoning, file_search, web_search) - **`EventPayload` enum** with typed extraction per event (no downstream JSON access needed) - **`normalize_sse_line(&str) → Option<EventFrame>`** — pure function, no state, no async - Handles both vLLM (`response.done`) and OpenAI (`response.completed`) wire formats - `#[non_exhaustive]` on enums for forward compatibility - No dependency on the executor module — lands on main independently of PR vllm-project#46 Per discussion with @maralbahari on [PR vllm-project#46](vllm-project#46 (comment)): this is a separate core module to avoid bloating the accumulator. Once PR vllm-project#46 merges, a follow-up refactors the accumulator to consume `EventFrame` instead of inline JSON parsing. **Validated against live vLLM** (google/gemma-4-26B-A4B-it, v0.21.0) — cassettes recorded from real streaming responses including function_call tool use. ## Test Plan - 33 integration tests in `tests/event_normalizer_test.rs`: - Per-event-type parsing (text, function_call, reasoning, response lifecycle, content_part, file/web search) - Edge cases: `[DONE]`, empty lines, malformed JSON, unknown events, empty deltas, unicode - Full streaming sessions: text-only, function_call, parallel calls, mixed text+tool - Real vLLM cassette replay (recorded from live gemma-4-26B-A4B-it) - YAML cassette-driven tests (2 cassette files) - `cargo clippy --workspace --all-targets -- -D warnings` — clean - `cargo fmt --check` — clean - All 117 workspace tests pass (no regressions) --------- Signed-off-by: Ashwin Giridharan <girida@amazon.com>
1 parent 553ea8f commit 3f14912

9 files changed

Lines changed: 1026 additions & 0 deletions

File tree

‎Cargo.lock‎

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/agentic-core/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ uuid = { version = "1", features = ["v7", "serde"] }
2727
axum.workspace = true
2828
criterion = { workspace = true }
2929
serde_yaml = "0.9"
30+
serde_yml = "0.0.12"
3031
tokio = { workspace = true, features = ["full"] }
3132

3233
[[bench]]
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pub mod normalize;
2+
pub mod types;
3+
4+
pub use normalize::normalize_sse_line;
5+
pub use types::{EventFrame, EventPayload, SSEEventType};
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
use serde_json::Value;
2+
3+
use super::types::{EventFrame, EventPayload, SSEEventType};
4+
use crate::utils::common::deserialize_from_str_opt;
5+
6+
/// Normalize a raw SSE data line into a typed [`EventFrame`].
7+
///
8+
/// Expects input in the form `data: {...}` (the `data: ` prefix is required).
9+
/// Returns `None` for non-data lines, empty lines, and the `data: [DONE]`
10+
/// sentinel.
11+
#[must_use]
12+
pub fn normalize_sse_line(line: &str) -> Option<EventFrame> {
13+
let data_str = line.strip_prefix("data: ")?;
14+
if data_str == "[DONE]" {
15+
return None;
16+
}
17+
18+
let json: Value = deserialize_from_str_opt(data_str)?;
19+
20+
let event_type = json
21+
.get("type")
22+
.and_then(Value::as_str)
23+
.map_or(SSEEventType::Other, classify_event_type);
24+
25+
let sequence_number = json.get("sequence_number").and_then(Value::as_u64);
26+
27+
let payload = extract_payload(event_type, &json);
28+
29+
Some(EventFrame {
30+
event_type,
31+
payload,
32+
sequence_number,
33+
})
34+
}
35+
36+
/// Map a wire-format event type string to our enum.
37+
fn classify_event_type(type_str: &str) -> SSEEventType {
38+
match type_str {
39+
"response.created" => SSEEventType::ResponseCreated,
40+
"response.in_progress" => SSEEventType::ResponseInProgress,
41+
"response.completed" | "response.done" => SSEEventType::ResponseCompleted,
42+
"response.failed" => SSEEventType::ResponseFailed,
43+
"response.incomplete" => SSEEventType::ResponseIncomplete,
44+
"response.output_item.added" => SSEEventType::OutputItemAdded,
45+
"response.output_item.done" => SSEEventType::OutputItemDone,
46+
"response.output_text.delta" => SSEEventType::OutputTextDelta,
47+
"response.output_text.done" => SSEEventType::OutputTextDone,
48+
"response.content_part.added" => SSEEventType::ContentPartAdded,
49+
"response.content_part.done" => SSEEventType::ContentPartDone,
50+
"response.function_call_arguments.delta" => SSEEventType::FunctionCallArgumentsDelta,
51+
"response.function_call_arguments.done" => SSEEventType::FunctionCallArgumentsDone,
52+
"response.reasoning_summary_text.delta" => SSEEventType::ReasoningSummaryTextDelta,
53+
"response.reasoning_summary_text.done" => SSEEventType::ReasoningSummaryTextDone,
54+
"response.file_search_call.searching" => SSEEventType::FileSearchCallSearching,
55+
"response.file_search_call.completed" => SSEEventType::FileSearchCallCompleted,
56+
"response.web_search_call.searching" => SSEEventType::WebSearchCallSearching,
57+
"response.web_search_call.completed" => SSEEventType::WebSearchCallCompleted,
58+
_ => SSEEventType::Other,
59+
}
60+
}
61+
62+
/// Extract a typed payload from the JSON body based on the classified event type.
63+
fn extract_payload(event_type: SSEEventType, json: &Value) -> EventPayload {
64+
match event_type {
65+
SSEEventType::ResponseCreated
66+
| SSEEventType::ResponseInProgress
67+
| SSEEventType::ResponseCompleted
68+
| SSEEventType::ResponseFailed
69+
| SSEEventType::ResponseIncomplete => extract_response_payload(json),
70+
71+
SSEEventType::OutputItemAdded => extract_output_item_added(json),
72+
SSEEventType::OutputItemDone => extract_output_item_done(json),
73+
74+
SSEEventType::OutputTextDelta => extract_text_delta(json),
75+
SSEEventType::OutputTextDone => extract_text_done(json),
76+
77+
SSEEventType::FunctionCallArgumentsDelta => extract_fn_call_args_delta(json),
78+
SSEEventType::FunctionCallArgumentsDone => extract_fn_call_args_done(json),
79+
80+
SSEEventType::ReasoningSummaryTextDelta => extract_reasoning_delta(json),
81+
SSEEventType::ReasoningSummaryTextDone => extract_reasoning_done(json),
82+
83+
SSEEventType::ContentPartAdded
84+
| SSEEventType::ContentPartDone
85+
| SSEEventType::FileSearchCallSearching
86+
| SSEEventType::FileSearchCallCompleted
87+
| SSEEventType::WebSearchCallSearching
88+
| SSEEventType::WebSearchCallCompleted
89+
| SSEEventType::Other => EventPayload::Raw(json.clone()),
90+
}
91+
}
92+
93+
fn json_str(json: &Value, key: &str) -> String {
94+
json[key].as_str().unwrap_or_default().to_string()
95+
}
96+
97+
fn json_str_opt(json: &Value, key: &str) -> Option<String> {
98+
json[key].as_str().map(ToString::to_string)
99+
}
100+
101+
fn json_u32(json: &Value, key: &str) -> u32 {
102+
u32::try_from(json[key].as_u64().unwrap_or(0)).unwrap_or(u32::MAX)
103+
}
104+
105+
fn extract_response_payload(json: &Value) -> EventPayload {
106+
let response = &json["response"];
107+
EventPayload::Response {
108+
id: json_str(response, "id"),
109+
status: json_str(response, "status"),
110+
usage: response.get("usage").filter(|v| !v.is_null()).cloned(),
111+
}
112+
}
113+
114+
fn extract_output_item_added(json: &Value) -> EventPayload {
115+
let item = &json["item"];
116+
EventPayload::OutputItemAdded {
117+
item_id: json_str(item, "id"),
118+
item_type: json_str(item, "type"),
119+
output_index: json_u32(json, "output_index"),
120+
name: json_str_opt(item, "name"),
121+
call_id: json_str_opt(item, "call_id"),
122+
}
123+
}
124+
125+
fn extract_output_item_done(json: &Value) -> EventPayload {
126+
let item = &json["item"];
127+
EventPayload::OutputItemDone {
128+
item_id: json_str(item, "id"),
129+
item_type: json_str(item, "type"),
130+
output_index: json_u32(json, "output_index"),
131+
item: item.clone(),
132+
}
133+
}
134+
135+
fn extract_text_delta(json: &Value) -> EventPayload {
136+
EventPayload::TextDelta {
137+
delta: json_str(json, "delta"),
138+
item_id: json_str(json, "item_id"),
139+
output_index: json_u32(json, "output_index"),
140+
content_index: json_u32(json, "content_index"),
141+
}
142+
}
143+
144+
fn extract_text_done(json: &Value) -> EventPayload {
145+
EventPayload::TextDone {
146+
text: json_str(json, "text"),
147+
item_id: json_str(json, "item_id"),
148+
output_index: json_u32(json, "output_index"),
149+
}
150+
}
151+
152+
fn extract_fn_call_args_delta(json: &Value) -> EventPayload {
153+
EventPayload::FunctionCallArgsDelta {
154+
delta: json_str(json, "delta"),
155+
call_id: json_str_opt(json, "call_id"),
156+
item_id: json_str(json, "item_id"),
157+
output_index: json_u32(json, "output_index"),
158+
}
159+
}
160+
161+
fn extract_fn_call_args_done(json: &Value) -> EventPayload {
162+
EventPayload::FunctionCallArgsDone {
163+
arguments: json_str(json, "arguments"),
164+
call_id: json_str_opt(json, "call_id"),
165+
item_id: json_str(json, "item_id"),
166+
name: json_str(json, "name"),
167+
output_index: json_u32(json, "output_index"),
168+
}
169+
}
170+
171+
fn extract_reasoning_delta(json: &Value) -> EventPayload {
172+
EventPayload::ReasoningDelta {
173+
delta: json_str(json, "delta"),
174+
item_id: json_str(json, "item_id"),
175+
}
176+
}
177+
178+
fn extract_reasoning_done(json: &Value) -> EventPayload {
179+
EventPayload::ReasoningDone {
180+
text: json_str(json, "text"),
181+
item_id: json_str(json, "item_id"),
182+
}
183+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
use serde_json::Value;
2+
3+
/// Classification of SSE event types from the Responses API.
4+
///
5+
/// Covers both the `OpenAI` and vLLM wire formats (e.g. `response.done` vs
6+
/// `response.completed`).
7+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8+
#[non_exhaustive]
9+
pub enum SSEEventType {
10+
// Response lifecycle
11+
ResponseCreated,
12+
ResponseInProgress,
13+
ResponseCompleted,
14+
ResponseFailed,
15+
ResponseIncomplete,
16+
17+
// Output item lifecycle
18+
OutputItemAdded,
19+
OutputItemDone,
20+
21+
// Text content
22+
OutputTextDelta,
23+
OutputTextDone,
24+
ContentPartAdded,
25+
ContentPartDone,
26+
27+
// Function calls
28+
FunctionCallArgumentsDelta,
29+
FunctionCallArgumentsDone,
30+
31+
// Reasoning
32+
ReasoningSummaryTextDelta,
33+
ReasoningSummaryTextDone,
34+
35+
// Built-in tool calls
36+
FileSearchCallSearching,
37+
FileSearchCallCompleted,
38+
WebSearchCallSearching,
39+
WebSearchCallCompleted,
40+
41+
// Catch-all for unrecognized events
42+
Other,
43+
}
44+
45+
/// Typed payload extracted from an SSE event's JSON data.
46+
#[derive(Debug, Clone)]
47+
#[non_exhaustive]
48+
pub enum EventPayload {
49+
/// `response.created` / `response.completed` / `response.failed` /
50+
/// `response.incomplete` / `response.in_progress`
51+
Response {
52+
id: String,
53+
status: String,
54+
usage: Option<Value>,
55+
},
56+
57+
/// `response.output_item.added`
58+
OutputItemAdded {
59+
item_id: String,
60+
item_type: String,
61+
output_index: u32,
62+
name: Option<String>,
63+
call_id: Option<String>,
64+
},
65+
66+
/// `response.output_item.done`
67+
OutputItemDone {
68+
item_id: String,
69+
item_type: String,
70+
output_index: u32,
71+
item: Value,
72+
},
73+
74+
/// `response.output_text.delta`
75+
TextDelta {
76+
delta: String,
77+
item_id: String,
78+
output_index: u32,
79+
content_index: u32,
80+
},
81+
82+
/// `response.output_text.done`
83+
TextDone {
84+
text: String,
85+
item_id: String,
86+
output_index: u32,
87+
},
88+
89+
/// `response.function_call_arguments.delta`
90+
FunctionCallArgsDelta {
91+
delta: String,
92+
call_id: Option<String>,
93+
item_id: String,
94+
output_index: u32,
95+
},
96+
97+
/// `response.function_call_arguments.done`
98+
FunctionCallArgsDone {
99+
arguments: String,
100+
call_id: Option<String>,
101+
item_id: String,
102+
name: String,
103+
output_index: u32,
104+
},
105+
106+
/// `response.reasoning_summary_text.delta`
107+
ReasoningDelta { delta: String, item_id: String },
108+
109+
/// `response.reasoning_summary_text.done`
110+
ReasoningDone { text: String, item_id: String },
111+
112+
/// Events we classify but don't deeply parse yet.
113+
Raw(Value),
114+
115+
/// No meaningful payload (e.g. unparseable content).
116+
None,
117+
}
118+
119+
/// A normalized SSE event frame — the output of [`normalize_sse_line`].
120+
///
121+
/// [`normalize_sse_line`]: crate::events::normalize::normalize_sse_line
122+
#[derive(Debug, Clone)]
123+
pub struct EventFrame {
124+
pub event_type: SSEEventType,
125+
pub payload: EventPayload,
126+
pub sequence_number: Option<u64>,
127+
}

‎crates/agentic-core/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
pub mod config;
22
pub mod error;
3+
pub mod events;
34
pub mod executor;
45
pub mod proxy;
56
pub mod readiness;

0 commit comments

Comments
 (0)