Skip to content

Commit 9e4e307

Browse files
committed
feat(core): add reasoning_delta event type for kimi reasoning_content
- Add ReasoningDelta variant to StreamEvent and AgentEvent enums - Emit ReasoningDelta (not TextDelta) when kimi sends reasoning_content - Map RustAgentEvent::ReasoningDelta to type="reasoning_delta" in Node SDK - Add reasoning_delta_test.ts to verify the mechanism Fixes: A3S-Lab/a3s#2
1 parent c6e59e9 commit 9e4e307

5 files changed

Lines changed: 178 additions & 8 deletions

File tree

core/src/agent.rs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,10 @@ pub enum AgentEvent {
275275
#[serde(rename = "text_delta")]
276276
TextDelta { text: String },
277277

278+
/// Reasoning/thinking delta from streaming (for models like kimi, deepseek)
279+
#[serde(rename = "reasoning_delta")]
280+
ReasoningDelta { text: String },
281+
278282
/// Tool execution started
279283
#[serde(rename = "tool_start")]
280284
ToolStart { id: String, name: String },
@@ -1371,6 +1375,11 @@ impl AgentLoop {
13711375
tx.send(AgentEvent::TextDelta { text }).await.ok();
13721376
}
13731377
}
1378+
Some(crate::llm::StreamEvent::ReasoningDelta(text)) => {
1379+
if let Some(tx) = event_tx {
1380+
tx.send(AgentEvent::ReasoningDelta { text }).await.ok();
1381+
}
1382+
}
13741383
Some(crate::llm::StreamEvent::ToolUseStart { id, name }) => {
13751384
if let Some(tx) = event_tx {
13761385
tx.send(AgentEvent::ToolStart { id, name }).await.ok();
@@ -2375,7 +2384,10 @@ impl AgentLoop {
23752384
};
23762385

23772386
// Use pre-analysis style if available, otherwise use resolved style.
2378-
let exec_style = pre_analysis.as_ref().map(|a| &a.intent).unwrap_or(&effective_style);
2387+
let exec_style = pre_analysis
2388+
.as_ref()
2389+
.map(|a| &a.intent)
2390+
.unwrap_or(&effective_style);
23792391

23802392
// Check if a subagent should be launched for this task
23812393
if let Some((subagent_def, cleaned_prompt)) =
@@ -2553,7 +2565,11 @@ impl AgentLoop {
25532565
// Continuation injection counter
25542566
let mut continuation_count: u32 = 0;
25552567
let mut recent_tool_signatures: Vec<String> = Vec::new();
2556-
let style_prompt = if effective_prompt.is_empty() { msg_prompt } else { effective_prompt };
2568+
let style_prompt = if effective_prompt.is_empty() {
2569+
msg_prompt
2570+
} else {
2571+
effective_prompt
2572+
};
25572573
let effective_style = match effective_style {
25582574
Some(s) => s,
25592575
None => self.resolve_effective_style(style_prompt).await,
@@ -4096,7 +4112,9 @@ impl AgentLoop {
40964112
if !parallel_results.is_empty() {
40974113
parallel_results.sort_by_key(|r| r.step_number);
40984114
let envelope = ParallelStepResult::build_envelope(parallel_results);
4099-
current_history.push(Message::user(&serde_json::to_string(&envelope).unwrap_or_default()));
4115+
current_history.push(Message::user(
4116+
&serde_json::to_string(&envelope).unwrap_or_default(),
4117+
));
41004118
}
41014119
}
41024120

core/src/llm/openai.rs

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -655,8 +655,32 @@ impl LlmClient for OpenAiClient {
655655
if let Some(message) = choice.message {
656656
if let Some(reasoning) = message.reasoning_content {
657657
reasoning_content_accum.push_str(&reasoning);
658+
// For models like kimi that use reasoning_content for actual text output,
659+
// emit it as TextDelta when delta_content is empty (None or "")
660+
let delta_content_empty = delta_content
661+
.as_deref()
662+
.map_or(true, str::is_empty);
663+
if delta_content_empty {
664+
if first_token_ms.is_none() {
665+
first_token_ms = Some(
666+
request_started_at.elapsed().as_millis()
667+
as u64,
668+
);
669+
}
670+
if let Some(delta) = Self::merge_stream_text(
671+
&mut text_content,
672+
&reasoning,
673+
) {
674+
let _ = tx
675+
.send(StreamEvent::ReasoningDelta(
676+
delta,
677+
))
678+
.await;
679+
}
680+
}
658681
}
659-
if delta_content.as_deref().map_or(true, str::is_empty) {
682+
if delta_content.as_deref().map_or(true, str::is_empty)
683+
{
660684
if let Some(content) = message
661685
.content
662686
.filter(|value| !value.is_empty())
@@ -694,6 +718,30 @@ impl LlmClient for OpenAiClient {
694718
if let Some(delta) = choice.delta {
695719
if let Some(ref rc) = delta.reasoning_content {
696720
reasoning_content_accum.push_str(rc);
721+
// For models like kimi that use reasoning_content for actual text output,
722+
// emit it as TextDelta when content is empty (None or "")
723+
let content_empty = delta
724+
.content
725+
.as_deref()
726+
.map_or(true, |s| s.is_empty());
727+
if content_empty {
728+
if first_token_ms.is_none() {
729+
first_token_ms = Some(
730+
request_started_at.elapsed().as_millis()
731+
as u64,
732+
);
733+
}
734+
if let Some(delta) = Self::merge_stream_text(
735+
&mut text_content,
736+
rc,
737+
) {
738+
let _ = tx
739+
.send(StreamEvent::ReasoningDelta(
740+
delta,
741+
))
742+
.await;
743+
}
744+
}
697745
}
698746

699747
if let Some(content) = delta.content {
@@ -833,6 +881,23 @@ impl LlmClient for OpenAiClient {
833881
if let Some(delta) = choice.delta {
834882
if let Some(ref rc) = delta.reasoning_content {
835883
reasoning_content_accum.push_str(rc);
884+
// For models like kimi that use reasoning_content for actual text output,
885+
// emit it as TextDelta when content is empty (None or "")
886+
let content_empty =
887+
delta.content.as_deref().map_or(true, |s| s.is_empty());
888+
if content_empty {
889+
if first_token_ms.is_none() {
890+
first_token_ms = Some(
891+
request_started_at.elapsed().as_millis() as u64,
892+
);
893+
}
894+
if let Some(delta) =
895+
Self::merge_stream_text(&mut text_content, rc)
896+
{
897+
let _ =
898+
tx.send(StreamEvent::ReasoningDelta(delta)).await;
899+
}
900+
}
836901
}
837902
if let Some(content) = delta.content {
838903
if first_token_ms.is_none() {
@@ -857,10 +922,7 @@ impl LlmClient for OpenAiClient {
857922
usage.total_tokens = response.usage.total_tokens;
858923
// MiniMax: fall back to total_characters when total_tokens is 0.
859924
if usage.total_tokens == 0 {
860-
usage.total_tokens = response
861-
.usage
862-
.total_characters
863-
.unwrap_or(0);
925+
usage.total_tokens = response.usage.total_characters.unwrap_or(0);
864926
}
865927
usage.cache_read_tokens = response
866928
.usage

core/src/llm/types.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,8 @@ pub struct ToolCall {
422422
pub enum StreamEvent {
423423
/// Text content delta
424424
TextDelta(String),
425+
/// Reasoning/thinking delta (for models like kimi, deepseek that use reasoning_content)
426+
ReasoningDelta(String),
425427
/// Tool use started (id, name)
426428
ToolUseStart { id: String, name: String },
427429
/// Tool use input delta (for the current tool)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
#!/usr/bin/env npx tsx
2+
/**
3+
* Test for reasoning_delta event type - verifies kimi reasoning_content
4+
* is properly emitted as a separate event type.
5+
*/
6+
7+
import { Agent } from '../../index.js';
8+
9+
async function main(): Promise<void> {
10+
const configPath = '/Users/roylin/Desktop/code/a3s/crates/code/sdk/node/examples/streaming/test_config3.hcl';
11+
console.log(`Using config: ${configPath}\n`);
12+
13+
const agent = await Agent.create(configPath);
14+
const session = agent.session('.', {
15+
permissive: true,
16+
// maxToolRounds: 0, // Don't limit - we need LLM call to test reasoning_delta
17+
});
18+
19+
const prompt = 'Why is the sky blue? Answer in 2 sentences.';
20+
console.log(`Streaming with prompt: "${prompt}"\n`);
21+
22+
const stream = await session.stream(prompt);
23+
24+
let eventCount = 0;
25+
let textDeltaCount = 0;
26+
let reasoningDeltaCount = 0;
27+
let totalText = '';
28+
let totalReasoning = '';
29+
const eventTypes = new Map<string, number>();
30+
31+
while (true) {
32+
const result = await stream.next();
33+
if (!result.value || result.done) break;
34+
35+
const event = result.value;
36+
eventCount++;
37+
38+
// Count event types
39+
const type = event.type || 'unknown';
40+
eventTypes.set(type, (eventTypes.get(type) || 0) + 1);
41+
42+
if (type === 'text_delta' && event.text) {
43+
textDeltaCount++;
44+
totalText += event.text;
45+
} else if (type === 'reasoning_delta' && event.text) {
46+
reasoningDeltaCount++;
47+
totalReasoning += event.text;
48+
}
49+
50+
console.log(`[${eventCount}] type="${type}"` +
51+
(event.text ? ` text="${event.text.slice(0, 60)}"` : '') +
52+
(event.data ? ` data="${event.data?.slice(0, 60)}"` : '')
53+
);
54+
}
55+
56+
console.log('\n' + '='.repeat(80));
57+
console.log('Summary:');
58+
console.log(`Total events: ${eventCount}`);
59+
console.log(`Event type distribution:`);
60+
for (const [type, count] of eventTypes) {
61+
console.log(` - ${type}: ${count}`);
62+
}
63+
console.log(`\nTextDelta events: ${textDeltaCount}`);
64+
console.log(`Text content length: ${totalText.length}`);
65+
console.log(`ReasoningDelta events: ${reasoningDeltaCount}`);
66+
console.log(`Reasoning content length: ${totalReasoning.length}`);
67+
68+
if (totalReasoning.length > 0) {
69+
console.log(`\nReasoning preview: "${totalReasoning.slice(0, 150)}..."`);
70+
}
71+
72+
// Verify reasoning_delta was received
73+
if (reasoningDeltaCount > 0) {
74+
console.log('\n✅ reasoning_delta mechanism is WORKING');
75+
} else {
76+
console.log('\n❌ reasoning_delta mechanism is NOT working (no reasoning_delta events received)');
77+
process.exit(1);
78+
}
79+
}
80+
81+
main().catch((err: unknown) => {
82+
console.error('Test failed:', err);
83+
process.exit(1);
84+
});

sdk/node/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ impl From<RustAgentEvent> for AgentEvent {
236236
text: Some(text),
237237
..Self::empty("text_delta")
238238
},
239+
RustAgentEvent::ReasoningDelta { text } => Self {
240+
text: Some(text),
241+
..Self::empty("reasoning_delta")
242+
},
239243
RustAgentEvent::ToolStart { id, name } => Self {
240244
tool_id: Some(id),
241245
tool_name: Some(name),

0 commit comments

Comments
 (0)