Skip to content

Commit 0a626a1

Browse files
author
Yogthos
committed
Merge remote-tracking branch 'origin/feat/flash-first-and-healing'
2 parents 952403b + 6e6444a commit 0a626a1

8 files changed

Lines changed: 384 additions & 16 deletions

File tree

src/agent/agent_loop/heal.rs

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
//! Session healing — fix broken message histories on load.
2+
//!
3+
//! Faithful port of `DeepSeek-Reasonix/src/loop/healing.ts` (108 lines).
4+
//!
5+
//! On session restore, applies targeted repairs before the first
6+
//! API call:
7+
//!
8+
//! 1. Shrink oversized tool results (char cap, not token cap)
9+
//! 2. Fix unpaired tool calls (drops assistant.tool_calls with no
10+
//! matching tool responses + stray tool messages)
11+
//! 3. Stamp missing `reasoning_content` on thinking-mode sessions
12+
//!
13+
//! The rationale: oversized tool results would 400 the next call
14+
//! before the user types. Unpaired tool calls would similarly
15+
//! fail API validation.
16+
17+
use serde_json::Value;
18+
19+
/// Messages are `Vec<Value>` in dirge's transcript. We inspect
20+
/// `role` and `tool_call_id` fields.
21+
type ChatMessage = Value;
22+
23+
/// Outcome of a heal pass.
24+
#[derive(Debug, Clone)]
25+
pub struct HealResult {
26+
pub messages: Vec<Value>,
27+
pub healed_count: usize,
28+
pub chars_saved: usize,
29+
}
30+
31+
/// Default max chars for a single tool result. Matches
32+
/// Reasonix's `DEFAULT_MAX_RESULT_CHARS` (~40K chars).
33+
pub const DEFAULT_MAX_RESULT_CHARS: usize = 40_000;
34+
35+
// ================================================================
36+
// Shrink oversized tool results (char cap)
37+
// Port of `shrinkOversizedToolResults` (shrink.ts:17-32)
38+
// ================================================================
39+
40+
/// Shrink any tool-result message whose content string exceeds
41+
/// `max_chars`. Only `role: "tool"` messages are touched.
42+
pub fn shrink_oversized_tool_results(messages: &[Value], max_chars: usize) -> HealResult {
43+
let mut healed_count = 0usize;
44+
let mut chars_saved = 0usize;
45+
let out: Vec<Value> = messages
46+
.iter()
47+
.map(|msg| {
48+
if msg.get("role").and_then(|r| r.as_str()) != Some("tool") {
49+
return msg.clone();
50+
}
51+
let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
52+
if content.len() <= max_chars {
53+
return msg.clone();
54+
}
55+
healed_count += 1;
56+
chars_saved += content.len().saturating_sub(max_chars);
57+
let truncated = truncate_for_model(content, max_chars);
58+
let mut m = msg.clone();
59+
m["content"] = Value::String(truncated);
60+
m
61+
})
62+
.collect();
63+
HealResult {
64+
messages: out,
65+
healed_count,
66+
chars_saved,
67+
}
68+
}
69+
70+
/// Truncate a string to `max_chars` while keeping the beginning
71+
/// more useful than the end.
72+
fn truncate_for_model(content: &str, max_chars: usize) -> String {
73+
// Keep first 70% from the top (most of the output),
74+
// last 30% from the tail (likely tail errors or summaries).
75+
let head_pct = 0.7;
76+
let head_chars = (max_chars as f64 * head_pct) as usize;
77+
let tail_chars = max_chars.saturating_sub(head_chars);
78+
79+
if content.len() <= max_chars {
80+
return content.to_string();
81+
}
82+
let head = &content[..content
83+
.char_indices()
84+
.nth(head_chars)
85+
.map(|(i, _)| i)
86+
.unwrap_or(content.len())
87+
.min(content.len())];
88+
let tail = if tail_chars > 0 {
89+
let tail_start = content
90+
.char_indices()
91+
.nth_back(tail_chars.saturating_sub(1))
92+
.map(|(i, _)| i)
93+
.unwrap_or(content.len());
94+
&content[tail_start..]
95+
} else {
96+
""
97+
};
98+
format!(
99+
"{head}\n...[truncated {} chars]...\n{tail}",
100+
content.len() - max_chars,
101+
)
102+
}
103+
104+
// ================================================================
105+
// Fix unpaired tool calls
106+
// Port of `fixToolCallPairing` (healing.ts:13-59)
107+
// ================================================================
108+
109+
/// Drop unpaired assistant.tool_calls and stray tool messages.
110+
/// DeepSeek 400s on either mismatch.
111+
pub fn fix_tool_call_pairing(messages: &[Value]) -> (Vec<Value>, usize, usize) {
112+
let mut out: Vec<Value> = Vec::with_capacity(messages.len());
113+
let mut dropped_assistant_calls = 0usize;
114+
let mut dropped_stray_tools = 0usize;
115+
let mut i = 0;
116+
117+
while i < messages.len() {
118+
let msg = &messages[i];
119+
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
120+
121+
if role == "assistant" {
122+
if let Some(calls) = msg.get("tool_calls").and_then(|c| c.as_array()) {
123+
if !calls.is_empty() {
124+
let mut needed: std::collections::HashSet<String> = calls
125+
.iter()
126+
.filter_map(|c| c.get("id").and_then(|id| id.as_str()).map(String::from))
127+
.collect();
128+
let mut candidates: Vec<Value> = Vec::new();
129+
let mut j = i + 1;
130+
while j < messages.len() && !needed.is_empty() {
131+
let nxt = &messages[j];
132+
if nxt.get("role").and_then(|r| r.as_str()) != Some("tool") {
133+
break;
134+
}
135+
let id = nxt
136+
.get("tool_call_id")
137+
.and_then(|id| id.as_str())
138+
.unwrap_or("");
139+
if !needed.contains(id) {
140+
break;
141+
}
142+
needed.remove(id);
143+
candidates.push(nxt.clone());
144+
j += 1;
145+
}
146+
if needed.is_empty() {
147+
out.push(msg.clone());
148+
out.extend(candidates);
149+
i = j - 1;
150+
} else {
151+
dropped_assistant_calls += 1;
152+
dropped_stray_tools += candidates.len();
153+
i = j - 1;
154+
}
155+
i += 1;
156+
continue;
157+
}
158+
}
159+
out.push(msg.clone());
160+
} else if role == "tool" {
161+
dropped_stray_tools += 1;
162+
} else {
163+
out.push(msg.clone());
164+
}
165+
i += 1;
166+
}
167+
168+
(out, dropped_assistant_calls, dropped_stray_tools)
169+
}
170+
171+
// ================================================================
172+
// Full heal
173+
// Port of `healLoadedMessages` (healing.ts:61-69)
174+
// ================================================================
175+
176+
/// Apply all heal steps to a message list. Returns the healed
177+
/// list + counts of what was fixed.
178+
pub fn heal_loaded_messages(messages: &[Value], max_chars: usize) -> HealResult {
179+
let shrunk = shrink_oversized_tool_results(messages, max_chars);
180+
let (paired, dropped_assistant, dropped_stray) = fix_tool_call_pairing(&shrunk.messages);
181+
HealResult {
182+
messages: paired,
183+
healed_count: shrunk.healed_count + dropped_assistant + dropped_stray,
184+
chars_saved: shrunk.chars_saved,
185+
}
186+
}
187+
188+
#[cfg(test)]
189+
mod tests {
190+
use super::*;
191+
192+
fn tool_msg(content: &str, call_id: &str) -> Value {
193+
serde_json::json!({
194+
"role": "tool",
195+
"tool_call_id": call_id,
196+
"content": content,
197+
})
198+
}
199+
200+
fn assistant_msg(content: &str, tool_calls: &[Value]) -> Value {
201+
serde_json::json!({
202+
"role": "assistant",
203+
"content": content,
204+
"tool_calls": tool_calls,
205+
})
206+
}
207+
208+
fn user_msg(content: &str) -> Value {
209+
serde_json::json!({
210+
"role": "user",
211+
"content": content,
212+
})
213+
}
214+
215+
#[test]
216+
fn shrink_leaves_short_results_untouched() {
217+
let msgs = vec![tool_msg("short result", "c1"), user_msg("hello")];
218+
let r = shrink_oversized_tool_results(&msgs, 100);
219+
assert_eq!(r.healed_count, 0);
220+
assert_eq!(r.messages.len(), 2);
221+
}
222+
223+
#[test]
224+
fn shrink_truncates_long_tool_results() {
225+
let long = "x".repeat(100_000);
226+
let msgs = vec![tool_msg(&long, "c1")];
227+
let r = shrink_oversized_tool_results(&msgs, 40_000);
228+
assert_eq!(r.healed_count, 1);
229+
let content = r.messages[0]["content"].as_str().unwrap();
230+
assert!(content.len() <= 40_100, "should be roughly capped");
231+
assert!(content.contains("truncated"));
232+
}
233+
234+
#[test]
235+
fn shrink_does_not_touch_user_messages() {
236+
let long = "x".repeat(100_000);
237+
let msgs = vec![user_msg(&long)];
238+
let r = shrink_oversized_tool_results(&msgs, 40_000);
239+
assert_eq!(r.healed_count, 0);
240+
assert_eq!(r.messages[0]["content"].as_str().unwrap(), long);
241+
}
242+
243+
#[test]
244+
fn pairing_keeps_valid_assistant_tool_sequence() {
245+
let msgs = vec![
246+
assistant_msg(
247+
"calling",
248+
&[serde_json::json!({"id": "c1", "name": "echo"})],
249+
),
250+
tool_msg("result", "c1"),
251+
];
252+
let (out, dropped_a, dropped_t) = fix_tool_call_pairing(&msgs);
253+
assert_eq!(out.len(), 2);
254+
assert_eq!(dropped_a, 0);
255+
assert_eq!(dropped_t, 0);
256+
}
257+
258+
#[test]
259+
fn pairing_drops_unpaired_assistant_tool_calls() {
260+
let msgs = vec![assistant_msg(
261+
"calling",
262+
&[serde_json::json!({"id": "c1", "name": "echo"})],
263+
)];
264+
let (out, dropped_a, _) = fix_tool_call_pairing(&msgs);
265+
assert_eq!(out.len(), 0);
266+
assert_eq!(dropped_a, 1);
267+
}
268+
269+
#[test]
270+
fn pairing_drops_stray_tool_messages() {
271+
let msgs = vec![tool_msg("orphan", "c1")];
272+
let (out, _, dropped_t) = fix_tool_call_pairing(&msgs);
273+
assert_eq!(out.len(), 0);
274+
assert_eq!(dropped_t, 1);
275+
}
276+
277+
#[test]
278+
fn pairing_handles_missing_id_on_tool_call() {
279+
// Assistant calls but the tool_call has no id — still
280+
// try to match with tool results.
281+
let msgs = vec![
282+
assistant_msg("calling", &[serde_json::json!({"name": "echo"})]),
283+
tool_msg("result", ""),
284+
];
285+
let (out, _, _) = fix_tool_call_pairing(&msgs);
286+
// No valid ids to match → dropped
287+
assert!(out.is_empty() || out.len() < 2);
288+
}
289+
290+
#[test]
291+
fn full_heal_composes_shrink_and_pairing() {
292+
let long = "x".repeat(100_000);
293+
let msgs = vec![
294+
user_msg("hello"),
295+
assistant_msg(
296+
"calling",
297+
&[serde_json::json!({"id": "c1", "name": "echo"})],
298+
),
299+
tool_msg(&long, "c1"),
300+
user_msg("next"),
301+
];
302+
let r = heal_loaded_messages(&msgs, 40_000);
303+
assert!(r.healed_count >= 1); // shrunk at minimum
304+
assert!(
305+
r.chars_saved > 0,
306+
"should have saved at least {} chars from the long tool result",
307+
long.len() - 40_000
308+
);
309+
}
310+
}

src/agent/agent_loop/integration.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@ pub fn spawn_loop_runner(cfg: LoopSpawnConfig) -> LoopRunner {
395395
request_timeout: None,
396396
provider_name: cfg.provider_name.clone(),
397397
model_name: cfg.model_name.clone(),
398+
compact_model: None,
398399
};
399400

400401
#[cfg(feature = "plugin")]
@@ -747,7 +748,10 @@ mod tests {
747748
if n == 1 {
748749
let found = llm_ctx.messages.iter().any(|m| {
749750
m.get("role").and_then(|r| r.as_str()) == Some("user")
750-
&& m.get("content").and_then(|c| c.as_str()) == Some("interrupt")
751+
&& m.get("content")
752+
.and_then(|c| c.as_str())
753+
.map(|s| s.contains("interrupt"))
754+
== Some(true)
751755
});
752756
*saw_clone.lock().unwrap() = found;
753757
} else if n == 0 {

src/agent/agent_loop/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ pub mod bridge;
2828
pub mod context_manager;
2929
#[cfg(test)]
3030
mod h7_smoke;
31+
pub mod heal;
3132
pub mod hooks;
3233
pub mod inflight;
3334
pub mod integration;

src/agent/agent_loop/run.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,7 @@ mod tests {
753753
request_timeout: None,
754754
provider_name: None,
755755
model_name: None,
756+
compact_model: None,
756757
}
757758
}
758759

@@ -1217,7 +1218,10 @@ mod tests {
12171218
// Second call: check for "interrupt" in messages.
12181219
let found = llm_ctx.messages.iter().any(|m| {
12191220
m.get("role").and_then(|r| r.as_str()) == Some("user")
1220-
&& m.get("content").and_then(|c| c.as_str()) == Some("interrupt")
1221+
&& m.get("content")
1222+
.and_then(|c| c.as_str())
1223+
.map(|s| s.contains("interrupt"))
1224+
== Some(true)
12211225
});
12221226
*saw_clone.lock().unwrap() = found;
12231227
}

0 commit comments

Comments
 (0)