Skip to content

Commit aabb8db

Browse files
author
Yogthos
committed
feat(agent): phase 4 — scavenge tool calls from reasoning content
Faithful port of DeepSeek-Reasonix src/repair/scavenge.ts (201 LOC). ScavengeToolCalls scans reasoning_content for tool calls the model forgot to emit in the structured tool_calls field. Three patterns: 1. DSML invoke blocks (<|DSML|invoke name="tool">...</>) with full-width-pipe (U+FF5C) and ASCII pipe variants 2. Raw JSON objects: {name, arguments} OpenAI-style: {type:"function", function:{name,arguments}} R1 free-form: {tool_name, tool_args} 3. Deduplication by (name, args) signature prevents double-counting Integration: after extracting declared tool calls, the loop scans reasoning content (Thinking blocks) + assistant text (Text blocks), calls scavenge_tool_calls with the context's tool names as the allowlist, and merges novel calls. Port of repair/index.ts:65-85. Safety: 100KB input cap, max-calls=4 cap, allowed-names gate. Tests: 13 tests ported from Reasonix tests/repair/scavenge.test.ts. All 190 agent_loop tests pass. Build + fmt + clippy clean.
1 parent 714a29a commit aabb8db

3 files changed

Lines changed: 525 additions & 1 deletion

File tree

src/agent/agent_loop/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ pub mod rig_stream;
3838
pub mod rig_stream_factory;
3939
pub mod rig_tool;
4040
pub mod run;
41+
pub mod scavenge;
4142
pub mod steering;
4243
pub mod stream;
4344
pub mod tool;

src/agent/agent_loop/run.rs

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,77 @@ pub async fn run_loop(
257257
}
258258

259259
// Pi lines 202-216: tool calls + results.
260-
let tool_calls = extract_tool_calls_from(&assistant_msg);
260+
let mut tool_calls = extract_tool_calls_from(&assistant_msg);
261+
262+
// Scavenge: scan reasoning content for tool calls the
263+
// model forgot to emit in `tool_calls`. Port of Reasonix
264+
// repair/index.ts:65-85.
265+
//
266+
// Only tools in the current context's tool set are
267+
// accepted. Deduplication by (name, args) signature
268+
// prevents double-counting if the same call appears in
269+
// both reasoning and declared tool_calls.
270+
let allowed_names: std::collections::HashSet<String> = current_context
271+
.tools
272+
.iter()
273+
.map(|t| t.name().to_string())
274+
.collect();
275+
let reasoning_text: String = assistant_msg
276+
.content
277+
.iter()
278+
.filter_map(|b| match b {
279+
ContentBlock::Thinking { text } => Some(text.as_str()),
280+
_ => None,
281+
})
282+
.collect::<Vec<_>>()
283+
.join("\n");
284+
let assistant_text: String = assistant_msg
285+
.content
286+
.iter()
287+
.filter_map(|b| match b {
288+
ContentBlock::Text { text } => Some(text.as_str()),
289+
_ => None,
290+
})
291+
.collect::<Vec<_>>()
292+
.join("\n");
293+
let combined = if reasoning_text.is_empty() {
294+
if assistant_text.is_empty() {
295+
None
296+
} else {
297+
Some(assistant_text)
298+
}
299+
} else if assistant_text.is_empty() {
300+
Some(reasoning_text)
301+
} else {
302+
Some(format!("{}\n{}", reasoning_text, assistant_text))
303+
};
304+
if let Some(scan_text) = combined.as_deref() {
305+
let scavenge_result =
306+
super::scavenge::scavenge_tool_calls(Some(scan_text), &allowed_names, 4);
307+
if !scavenge_result.calls.is_empty() {
308+
let seen_signatures: std::collections::HashSet<String> = tool_calls
309+
.iter()
310+
.map(|tc| {
311+
format!(
312+
"{}::{}",
313+
tc.name,
314+
serde_json::to_string(&tc.arguments).unwrap_or_default()
315+
)
316+
})
317+
.collect();
318+
for sc in &scavenge_result.calls {
319+
let sig = format!(
320+
"{}::{}",
321+
sc.name,
322+
serde_json::to_string(&sc.arguments).unwrap_or_default()
323+
);
324+
if !seen_signatures.contains(&sig) {
325+
tool_calls.push(sc.clone());
326+
}
327+
}
328+
}
329+
}
330+
261331
let mut tool_results: Vec<ToolResultMessage> = Vec::new();
262332
has_more_tool_calls = false;
263333
if !tool_calls.is_empty() {

0 commit comments

Comments
 (0)