Skip to content

Commit 6947904

Browse files
committed
fix(openai): keep streamed reasoning out of text_content so reasoning-model agent loops don't terminate early; bump 4.2.5
complete_streaming merged each reasoning delta into BOTH reasoning_content_accum AND text_content (via merge_stream_text). For a reasoning model (glm5.1/zhipu, DeepSeek-R1), the chain-of-thought thus landed in the assistant message's text content. Consequences: - response.text() returned the reasoning, so a pure 'thinking' turn (reasoning, no content, no tool call) looked like a finished answer -> the agent loop's complete_no_tool_response treated it as final (looks_incomplete(reasoning) is usually false) and TERMINATED instead of giving the model another turn to act. In the multi-agent diagnose this is why workers never reached generate_object -> '未返回结构化输出'. - skip_content (= !text_content.is_empty()) tripped once reasoning arrived, dropping the model's real content. Fix: reasoning accumulates ONLY into reasoning_content (4 sites: message/delta branches of both streaming fns); text_content holds real content only. A reasoning-only turn now yields empty text() so looks_incomplete('')==true keeps the loop going. Two streaming regression tests (mock SSE) lock it. Upstream agent-loop half of the same cross-model reasoning bug as 4.2.4's generate_object extraction fix.
1 parent 69c6b29 commit 6947904

11 files changed

Lines changed: 165 additions & 43 deletions

File tree

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
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-code-core"
3-
version = "4.2.4"
3+
version = "4.2.5"
44
edition = "2021"
55
authors = ["A3S Lab Team"]
66
license = "MIT"

core/src/llm/openai.rs

Lines changed: 134 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -731,15 +731,23 @@ impl OpenAiClient {
731731
// skip message.content to avoid sending duplicate full content
732732
let skip_content = !text_content.is_empty();
733733
if let Some(reasoning) = message.reasoning_content {
734-
reasoning_content_accum.push_str(&reasoning);
734+
// Reasoning is its OWN channel — accumulate into
735+
// reasoning_content, NEVER text_content. Merging it into
736+
// text_content made the assistant's `content` carry the
737+
// chain-of-thought, so a reasoning-only turn looked like a
738+
// finished text answer (response.text() non-empty) and the
739+
// agent loop terminated before the model emitted its tool
740+
// call → workers never reach generate_object → asset-diagnose
741+
// "未返回结构化输出". It also tripped `skip_content`, dropping
742+
// the model's real content.
735743
if first_token_ms.is_none() {
736744
first_token_ms = Some(
737745
request_started_at.elapsed().as_millis()
738746
as u64,
739747
);
740748
}
741749
if let Some(delta) = Self::merge_stream_text(
742-
&mut text_content,
750+
&mut reasoning_content_accum,
743751
&reasoning,
744752
) {
745753
let _ = tx
@@ -782,16 +790,18 @@ impl OpenAiClient {
782790
}
783791
} else if let Some(delta) = choice.delta {
784792
if let Some(ref rc) = delta.reasoning_content {
785-
reasoning_content_accum.push_str(rc);
793+
// Reasoning stays in reasoning_content, never text_content
794+
// (see the message-branch note above).
786795
if first_token_ms.is_none() {
787796
first_token_ms = Some(
788797
request_started_at.elapsed().as_millis()
789798
as u64,
790799
);
791800
}
792-
if let Some(delta) =
793-
Self::merge_stream_text(&mut text_content, rc)
794-
{
801+
if let Some(delta) = Self::merge_stream_text(
802+
&mut reasoning_content_accum,
803+
rc,
804+
) {
795805
let _ = tx
796806
.send(StreamEvent::ReasoningDelta(delta))
797807
.await;
@@ -904,14 +914,16 @@ impl OpenAiClient {
904914
let skip_content = !text_content.is_empty();
905915
if let Some(message) = choice.message {
906916
if let Some(reasoning) = message.reasoning_content {
907-
reasoning_content_accum.push_str(&reasoning);
917+
// Reasoning → reasoning_content only, never text_content
918+
// (see the note in complete_streaming).
908919
if first_token_ms.is_none() {
909920
first_token_ms =
910921
Some(request_started_at.elapsed().as_millis() as u64);
911922
}
912-
if let Some(delta) =
913-
Self::merge_stream_text(&mut text_content, &reasoning)
914-
{
923+
if let Some(delta) = Self::merge_stream_text(
924+
&mut reasoning_content_accum,
925+
&reasoning,
926+
) {
915927
let _ = tx.send(StreamEvent::ReasoningDelta(delta)).await;
916928
}
917929
}
@@ -941,13 +953,14 @@ impl OpenAiClient {
941953
}
942954
} else if let Some(delta) = choice.delta {
943955
if let Some(ref rc) = delta.reasoning_content {
944-
reasoning_content_accum.push_str(rc);
956+
// Reasoning → reasoning_content only, never text_content
957+
// (see the note in complete_streaming).
945958
if first_token_ms.is_none() {
946959
first_token_ms =
947960
Some(request_started_at.elapsed().as_millis() as u64);
948961
}
949962
if let Some(delta) =
950-
Self::merge_stream_text(&mut text_content, rc)
963+
Self::merge_stream_text(&mut reasoning_content_accum, rc)
951964
{
952965
let _ = tx.send(StreamEvent::ReasoningDelta(delta)).await;
953966
}
@@ -1200,6 +1213,115 @@ mod tests {
12001213
OpenAiClient::new("test-key".to_string(), "gpt-test".to_string())
12011214
}
12021215

1216+
// --- streaming reasoning-channel regression -----------------------------
1217+
// Reasoning models (glm5.1/zhipu) stream chain-of-thought under `reasoning`.
1218+
// It must land in reasoning_content, NEVER in the text content — otherwise
1219+
// response.text() looks like a finished answer and the agent loop terminates
1220+
// before the model emits its tool call (asset-diagnose "未返回结构化输出").
1221+
1222+
struct MockSseHttp {
1223+
chunks: Vec<String>,
1224+
}
1225+
1226+
#[async_trait::async_trait]
1227+
impl crate::llm::http::HttpClient for MockSseHttp {
1228+
async fn post(
1229+
&self,
1230+
_url: &str,
1231+
_headers: Vec<(&str, &str)>,
1232+
_body: &serde_json::Value,
1233+
_cancel: tokio_util::sync::CancellationToken,
1234+
) -> anyhow::Result<crate::llm::http::HttpResponse> {
1235+
anyhow::bail!("post is unused in the streaming test")
1236+
}
1237+
1238+
async fn post_streaming(
1239+
&self,
1240+
_url: &str,
1241+
_headers: Vec<(&str, &str)>,
1242+
_body: &serde_json::Value,
1243+
_cancel: tokio_util::sync::CancellationToken,
1244+
) -> anyhow::Result<crate::llm::http::StreamingHttpResponse> {
1245+
let items: Vec<anyhow::Result<bytes::Bytes>> = self
1246+
.chunks
1247+
.iter()
1248+
.map(|s| Ok(bytes::Bytes::from(s.clone())))
1249+
.collect();
1250+
Ok(crate::llm::http::StreamingHttpResponse {
1251+
status: 200,
1252+
retry_after: None,
1253+
byte_stream: Box::pin(futures::stream::iter(items)),
1254+
error_body: String::new(),
1255+
})
1256+
}
1257+
}
1258+
1259+
fn glm_client(chunks: Vec<String>) -> OpenAiClient {
1260+
OpenAiClient::new("k".to_string(), "glm-test".to_string())
1261+
.with_http_client(std::sync::Arc::new(MockSseHttp { chunks }))
1262+
}
1263+
1264+
async fn drain_to_done(client: &OpenAiClient) -> crate::llm::LlmResponse {
1265+
use crate::llm::{LlmClient, StreamEvent};
1266+
let mut rx = client
1267+
.complete_streaming(
1268+
&[Message::user("go")],
1269+
None,
1270+
&[],
1271+
tokio_util::sync::CancellationToken::new(),
1272+
)
1273+
.await
1274+
.expect("stream opened");
1275+
let mut done = None;
1276+
while let Some(ev) = rx.recv().await {
1277+
if let StreamEvent::Done(resp) = ev {
1278+
done = Some(resp);
1279+
}
1280+
}
1281+
done.expect("a Done event")
1282+
}
1283+
1284+
#[tokio::test]
1285+
async fn streaming_reasoning_does_not_leak_into_content_and_keeps_tool_call() {
1286+
let chunks = vec![
1287+
"data: {\"choices\":[{\"delta\":{\"reasoning\":\"Let me plan the workers\"}}]}\n\n"
1288+
.to_string(),
1289+
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"parallel_task\",\"arguments\":\"{}\"}}]}}]}\n\n"
1290+
.to_string(),
1291+
"data: [DONE]\n\n".to_string(),
1292+
];
1293+
let resp = drain_to_done(&glm_client(chunks)).await;
1294+
// Reasoning must NOT appear as text content.
1295+
assert_eq!(resp.message.text(), "", "reasoning leaked into content");
1296+
assert_eq!(
1297+
resp.message.reasoning_content.as_deref(),
1298+
Some("Let me plan the workers")
1299+
);
1300+
// The tool call still survives, so the agent can act.
1301+
let calls = resp.message.tool_calls();
1302+
assert_eq!(calls.len(), 1);
1303+
assert_eq!(calls[0].name, "parallel_task");
1304+
}
1305+
1306+
#[tokio::test]
1307+
async fn streaming_reasoning_only_turn_yields_empty_text() {
1308+
// A pure "thinking" turn (reasoning, no content, no tool call) must yield empty
1309+
// text() so the agent loop's looks_incomplete("")==true path CONTINUES instead of
1310+
// terminating prematurely — the multi-worker diagnose failure root cause.
1311+
let chunks = vec![
1312+
"data: {\"choices\":[{\"delta\":{\"reasoning\":\"still thinking, no answer yet\"}}]}\n\n"
1313+
.to_string(),
1314+
"data: [DONE]\n\n".to_string(),
1315+
];
1316+
let resp = drain_to_done(&glm_client(chunks)).await;
1317+
assert_eq!(resp.message.text(), "");
1318+
assert_eq!(
1319+
resp.message.reasoning_content.as_deref(),
1320+
Some("still thinking, no answer yet")
1321+
);
1322+
assert!(resp.message.tool_calls().is_empty());
1323+
}
1324+
12031325
#[test]
12041326
fn test_apply_directive_forced_function_tool_choice() {
12051327
let mut req = serde_json::json!({ "model": "m" });

sdk/node/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-code-node"
3-
version = "4.2.4"
3+
version = "4.2.5"
44
edition = "2021"
55
authors = ["A3S Lab Team"]
66
license = "MIT"
@@ -11,7 +11,7 @@ description = "A3S Code Node.js bindings - Native addon via napi-rs"
1111
crate-type = ["cdylib"]
1212

1313
[dependencies]
14-
a3s-code-core = { version = "4.2.4", path = "../../core", features = ["ahp", "s3", "serve"] }
14+
a3s-code-core = { version = "4.2.5", path = "../../core", features = ["ahp", "s3", "serve"] }
1515
napi = { version = "2", features = ["async", "napi6", "serde-json"] }
1616
napi-derive = "2"
1717
tokio = { version = "1.35", features = ["full"] }

sdk/node/examples/package-lock.json

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

sdk/node/package-lock.json

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

sdk/node/package.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@a3s-lab/code",
3-
"version": "4.2.4",
3+
"version": "4.2.5",
44
"description": "A3S Code - Native Node.js bindings for the coding-agent runtime",
55
"main": "index.js",
66
"types": "index.d.ts",
@@ -43,11 +43,11 @@
4343
"test:helpers": "node test-helpers.mjs"
4444
},
4545
"optionalDependencies": {
46-
"@a3s-lab/code-darwin-arm64": "4.2.4",
47-
"@a3s-lab/code-linux-x64-gnu": "4.2.4",
48-
"@a3s-lab/code-linux-x64-musl": "4.2.4",
49-
"@a3s-lab/code-linux-arm64-gnu": "4.2.4",
50-
"@a3s-lab/code-linux-arm64-musl": "4.2.4",
51-
"@a3s-lab/code-win32-x64-msvc": "4.2.4"
46+
"@a3s-lab/code-darwin-arm64": "4.2.5",
47+
"@a3s-lab/code-linux-x64-gnu": "4.2.5",
48+
"@a3s-lab/code-linux-x64-musl": "4.2.5",
49+
"@a3s-lab/code-linux-arm64-gnu": "4.2.5",
50+
"@a3s-lab/code-linux-arm64-musl": "4.2.5",
51+
"@a3s-lab/code-win32-x64-msvc": "4.2.5"
5252
}
5353
}

sdk/python-bootstrap/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ name = "a3s-code"
77
# Keep in sync with crates/code core release. The bootstrap loader fetches
88
# the matching native wheel from `https://github.com/AI45Lab/Code/releases/tag/v<version>`
99
# at import time.
10-
version = "4.2.4"
10+
version = "4.2.5"
1111
description = "A3S Code Python SDK — pure-Python bootstrap that fetches the native wheel from GitHub Releases"
1212
readme = "README.md"
1313
license = {text = "MIT"}

sdk/python-bootstrap/src/a3s_code/_bootstrap.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131

3232
# Version is the bootstrap's own version, which equals the matching native
3333
# wheel version on GH Releases. Bumped by the release workflow.
34-
__version__ = "4.2.4"
34+
__version__ = "4.2.5"
3535

3636
_DEFAULT_BASE_URL = "https://github.com/AI45Lab/Code/releases/download"
3737
_REQUEST_TIMEOUT_S = 120

sdk/python/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-code-py"
3-
version = "4.2.4"
3+
version = "4.2.5"
44
edition = "2021"
55
authors = ["A3S Lab Team"]
66
license = "MIT"
@@ -12,7 +12,7 @@ name = "a3s_code"
1212
crate-type = ["cdylib"]
1313

1414
[dependencies]
15-
a3s-code-core = { version = "4.2.4", path = "../../core", features = ["ahp", "s3", "serve"] }
15+
a3s-code-core = { version = "4.2.5", path = "../../core", features = ["ahp", "s3", "serve"] }
1616
pyo3 = "0.23"
1717
tokio = { version = "1.35", features = ["full"] }
1818
serde_json = "1.0"

0 commit comments

Comments
 (0)