Skip to content

Commit 94553d0

Browse files
committed
fix(llm): accept complete streams without done
1 parent 366183d commit 94553d0

7 files changed

Lines changed: 149 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
- Preserved bounded fallback notices and failure metadata when web search runs
2222
inside a batch workflow.
2323

24+
### Fixed
25+
26+
- Accepted a complete schema-valid streamed object after a short terminal-event
27+
grace period, so an OpenAI-compatible endpoint that omits or delays its final
28+
`Done` event cannot turn an already generated result into a timeout.
29+
2430
## [6.3.1] - 2026-07-22
2531

2632
### Fixed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ a3s-common = { version = "0.1.1", path = "../../common" }
1717
a3s-memory = { version = "0.1.2", path = "../../memory" }
1818
a3s-lane = { version = "0.5", path = "../../lane" }
1919
a3s-search = { version = "2.1.0", path = "../../search", default-features = false, features = ["lightpanda"] }
20-
a3s-flow = { version = "0.4.2", path = "../../flow" }
20+
a3s-flow = { version = "0.4.3", path = "../../flow" }
2121

2222
# Async runtime
2323
tokio = { version = "1.35", features = [

core/src/llm/structured.rs

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -303,18 +303,65 @@ pub async fn generate_streaming(
303303

304304
let cancel_token = CancellationToken::new();
305305
let mut rx = client
306-
.complete_streaming_structured(&messages, Some(&system), &tools, &directive, cancel_token)
306+
.complete_streaming_structured(
307+
&messages,
308+
Some(&system),
309+
&tools,
310+
&directive,
311+
cancel_token.clone(),
312+
)
307313
.await
308314
.context("LLM streaming call failed during structured generation")?;
309315

310316
let mut json_buffer = String::new();
311317
let mut last_valid_partial: Option<Value> = None;
312318
let mut final_response: Option<super::LlmResponse> = None;
313319
let mut last_parse_len: usize = 0;
320+
let mut complete_candidate: Option<(Value, String, tokio::time::Instant)> = None;
314321
// Minimum bytes of new data before attempting a partial parse (reduces CPU)
315322
const PARSE_THRESHOLD: usize = 8;
316-
317-
while let Some(event) = rx.recv().await {
323+
// Well-behaved providers send Done immediately after the complete object.
324+
// A short grace preserves their final usage metadata while preventing an
325+
// otherwise valid result from hanging on a compatible endpoint that never
326+
// terminates its stream.
327+
const DONE_GRACE: std::time::Duration = std::time::Duration::from_millis(250);
328+
loop {
329+
let event = if let Some((_, _, deadline)) = complete_candidate.as_ref() {
330+
tokio::select! {
331+
event = rx.recv() => event,
332+
_ = tokio::time::sleep_until(*deadline) => {
333+
let candidate = complete_candidate
334+
.take()
335+
.expect("complete streamed candidate exists");
336+
let (value, raw_text, _) = candidate;
337+
cancel_token.cancel();
338+
on_partial(&value);
339+
return Ok(StructuredResult {
340+
object: value,
341+
raw_text: Some(raw_text),
342+
usage: TokenUsage::default(),
343+
repair_rounds: 0,
344+
mode_used: mode,
345+
});
346+
}
347+
}
348+
} else {
349+
rx.recv().await
350+
};
351+
let Some(event) = event else {
352+
if let Some((value, raw_text, _)) = complete_candidate.take() {
353+
cancel_token.cancel();
354+
on_partial(&value);
355+
return Ok(StructuredResult {
356+
object: value,
357+
raw_text: Some(raw_text),
358+
usage: TokenUsage::default(),
359+
repair_rounds: 0,
360+
mode_used: mode,
361+
});
362+
}
363+
break;
364+
};
318365
match event {
319366
StreamEvent::ToolUseInputDelta { delta, .. } if mode == StructuredMode::Tool => {
320367
if final_response.is_some() {
@@ -334,6 +381,17 @@ pub async fn generate_streaming(
334381
}
335382
last_parse_len = json_buffer.len();
336383
}
384+
if complete_candidate.is_none() && (delta.contains('}') || delta.contains(']')) {
385+
complete_candidate = resolve_structured(
386+
std::slice::from_ref(&json_buffer),
387+
&req.schema,
388+
envelope,
389+
)
390+
.valid
391+
.map(|(value, raw_text)| {
392+
(value, raw_text, tokio::time::Instant::now() + DONE_GRACE)
393+
});
394+
}
337395
}
338396
StreamEvent::TextDelta(delta) if mode != StructuredMode::Tool => {
339397
if final_response.is_some() {
@@ -356,9 +414,21 @@ pub async fn generate_streaming(
356414
}
357415
last_parse_len = json_buffer.len();
358416
}
417+
if complete_candidate.is_none() && (delta.contains('}') || delta.contains(']')) {
418+
complete_candidate = resolve_structured(
419+
std::slice::from_ref(&json_buffer),
420+
&req.schema,
421+
envelope,
422+
)
423+
.valid
424+
.map(|(value, raw_text)| {
425+
(value, raw_text, tokio::time::Instant::now() + DONE_GRACE)
426+
});
427+
}
359428
}
360429
StreamEvent::Done(resp) => {
361430
final_response = Some(resp);
431+
break;
362432
}
363433
_ => {}
364434
}

core/src/llm/structured_tests.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,39 @@ impl LlmClient for MockStructuredClient {
155155
}
156156
}
157157

158+
struct CompleteObjectWithoutDoneClient;
159+
160+
#[async_trait]
161+
impl LlmClient for CompleteObjectWithoutDoneClient {
162+
async fn complete(
163+
&self,
164+
_messages: &[Message],
165+
_system: Option<&str>,
166+
_tools: &[ToolDefinition],
167+
) -> anyhow::Result<LlmResponse> {
168+
anyhow::bail!("blocking generation is not used in this test")
169+
}
170+
171+
async fn complete_streaming(
172+
&self,
173+
_messages: &[Message],
174+
_system: Option<&str>,
175+
_tools: &[ToolDefinition],
176+
cancel_token: CancellationToken,
177+
) -> anyhow::Result<mpsc::Receiver<StreamEvent>> {
178+
let (tx, rx) = mpsc::channel(4);
179+
tokio::spawn(async move {
180+
tx.send(StreamEvent::TextDelta(
181+
r#"{"color":"blue","count":3}"#.to_string(),
182+
))
183+
.await
184+
.ok();
185+
cancel_token.cancelled().await;
186+
});
187+
Ok(rx)
188+
}
189+
}
190+
158191
// ========================================================================
159192
// extract_json_value tests
160193
// ========================================================================
@@ -1211,6 +1244,39 @@ async fn test_generate_streaming_text_mode() {
12111244
let result = generate_streaming(&client, &req, callback).await.unwrap();
12121245
assert_eq!(result.object["color"], "blue");
12131246
assert_eq!(result.object["count"], 3);
1247+
assert_eq!(result.usage.total_tokens, 15);
1248+
}
1249+
1250+
#[tokio::test]
1251+
async fn test_generate_streaming_accepts_a_complete_valid_object_without_done() {
1252+
let req = StructuredRequest {
1253+
prompt: "test".to_string(),
1254+
system: None,
1255+
schema: serde_json::json!({
1256+
"type": "object",
1257+
"additionalProperties": false,
1258+
"required": ["color", "count"],
1259+
"properties": {
1260+
"color": {"type": "string"},
1261+
"count": {"type": "integer"}
1262+
}
1263+
}),
1264+
schema_name: "result".to_string(),
1265+
schema_description: None,
1266+
mode: StructuredMode::Prompt,
1267+
max_repair_attempts: 0,
1268+
};
1269+
1270+
let result = tokio::time::timeout(
1271+
std::time::Duration::from_secs(1),
1272+
generate_streaming(&CompleteObjectWithoutDoneClient, &req, Box::new(|_| {})),
1273+
)
1274+
.await
1275+
.expect("a schema-valid streamed object must not wait forever for Done")
1276+
.expect("complete streamed object");
1277+
1278+
assert_eq!(result.object["color"], "blue");
1279+
assert_eq!(result.object["count"], 3);
12141280
}
12151281

12161282
#[tokio::test]

sdk/node/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sdk/python/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)