Skip to content

Commit d474647

Browse files
author
Yogthos
committed
fix: resolve 9 code review issues with TDD-verified fixes
HIGH: - Consolidate execute_tool_calls_with into execute_tool_calls: execute_tool_calls now accepts &[ToolCall] directly, new execute_tool_calls_from_msg wrapper preserves old extraction API. Removed 42-line duplicate from run.rs. MEDIUM: - Storm custom tool predicates via LoopConfig.storm_mutating_tools and LoopConfig.storm_exempt_tools. storm_for_config() merges custom lists with built-in defaults. - Heal in run_agent_loop_continue: matches spawn_loop_runner path, heals before entering run_loop. - Extract CHARS_PER_TOKEN_ESTIMATE constant to context_manager.rs for shared use by run.rs and session/mod.rs. - Remove dead code: FoldResult struct, FOLD_SUMMARY_TIMEOUT_SECS. LOW: - Guard ctx_max==0 in estimate_turn_start (matches decide_after_usage). - Guard max_chars<2 in truncate_for_model: early return for degenerate inputs. - Make default_mutating/default_exempt pub for storm_for_config reuse.
1 parent 0630c60 commit d474647

10 files changed

Lines changed: 108 additions & 82 deletions

File tree

src/agent/agent_loop/context_manager.rs

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,10 @@ pub const FORCE_SUMMARY_THRESHOLD: f64 = 0.8;
5454
/// paste).
5555
pub const TURN_START_FOLD_THRESHOLD: f64 = 0.9;
5656

57-
/// Hard deadline for fold summary requests (seconds).
58-
pub const FOLD_SUMMARY_TIMEOUT_SECS: u64 = 15;
57+
/// Approximate characters-per-token ratio for estimation when
58+
/// actual token counts aren't available. Used by both run.rs's
59+
/// turn-start heuristic and Session::estimate_message_tokens.
60+
pub const CHARS_PER_TOKEN_ESTIMATE: u64 = 4;
5961

6062
// ================================================================
6163
// Data types — port of context-manager.ts:67-85
@@ -88,15 +90,6 @@ pub struct PostUsageDecision {
8890
pub aggressive: bool,
8991
}
9092

91-
/// Result of an attempted fold.
92-
#[derive(Debug, Clone)]
93-
pub struct FoldResult {
94-
pub folded: bool,
95-
pub before_messages: usize,
96-
pub after_messages: usize,
97-
pub summary_chars: usize,
98-
}
99-
10093
/// Turn-start estimate result.
10194
#[derive(Debug, Clone, Copy)]
10295
pub struct TurnStartEstimate {
@@ -208,10 +201,15 @@ pub fn decide_after_usage(
208201
/// (messages + tools + system prompt).
209202
/// `ctx_max`: the model's context window size in tokens.
210203
pub fn estimate_turn_start(estimate_tokens: u64, ctx_max: u64) -> TurnStartEstimate {
204+
let ratio = if ctx_max == 0 {
205+
f64::INFINITY
206+
} else {
207+
estimate_tokens as f64 / ctx_max as f64
208+
};
211209
TurnStartEstimate {
212210
estimate_tokens,
213211
ctx_max,
214-
ratio: estimate_tokens as f64 / ctx_max as f64,
212+
ratio,
215213
}
216214
}
217215

src/agent/agent_loop/heal.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,12 @@ pub fn shrink_oversized_tool_results(messages: &[Value], max_chars: usize) -> He
7070
/// Truncate a string to `max_chars` while keeping the beginning
7171
/// more useful than the end.
7272
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).
73+
if content.len() <= max_chars || max_chars < 2 {
74+
return content.to_string();
75+
}
7576
let head_pct = 0.7;
7677
let head_chars = (max_chars as f64 * head_pct) as usize;
7778
let tail_chars = max_chars.saturating_sub(head_chars);
78-
79-
if content.len() <= max_chars {
80-
return content.to_string();
81-
}
8279
let head = &content[..content
8380
.char_indices()
8481
.nth(head_chars)

src/agent/agent_loop/integration.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,8 @@ pub fn spawn_loop_runner(cfg: LoopSpawnConfig) -> LoopRunner {
397397
provider_name: cfg.provider_name.clone(),
398398
model_name: cfg.model_name.clone(),
399399
compact_model: None,
400+
storm_mutating_tools: None,
401+
storm_exempt_tools: None,
400402
};
401403

402404
#[cfg(feature = "plugin")]

src/agent/agent_loop/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,8 @@ pub use tool_input_repair::{
8383
RepairKind, RepairResult, format_structured_error, is_path_field_name, validate_and_repair,
8484
};
8585
pub use tools::{
86-
ExecutedToolCallBatch, ToolCall, execute_tool_calls, execute_tool_calls_parallel,
87-
execute_tool_calls_sequential, extract_tool_calls,
86+
ExecutedToolCallBatch, ToolCall, execute_tool_calls, execute_tool_calls_from_msg,
87+
execute_tool_calls_parallel, execute_tool_calls_sequential, extract_tool_calls,
8888
};
8989
pub use types::{
9090
Context, ConvertToLlmFn, GetApiKeyFn, LoopConfig, QueueMode, ThinkingLevel, ToolExecutionMode,

src/agent/agent_loop/run.rs

Lines changed: 41 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,30 @@ impl std::fmt::Display for LoopError {
8080

8181
impl std::error::Error for LoopError {}
8282

83+
/// Build a `StormBreaker` from `LoopConfig`, merging custom
84+
/// mutating/exempt tool name lists with the built-in defaults.
85+
fn storm_for_config(config: &LoopConfig) -> StormBreaker {
86+
let has_custom = config.storm_mutating_tools.is_some() || config.storm_exempt_tools.is_some();
87+
if !has_custom {
88+
return StormBreaker::default();
89+
}
90+
let mutating: Option<Box<dyn Fn(&super::tools::ToolCall) -> bool + Send + Sync>> =
91+
config.storm_mutating_tools.as_ref().map(|extras| {
92+
let extra_set: std::collections::HashSet<String> = extras.iter().cloned().collect();
93+
Box::new(move |c: &super::tools::ToolCall| {
94+
super::storm::default_mutating(c) || extra_set.contains(&c.name)
95+
}) as Box<dyn Fn(&super::tools::ToolCall) -> bool + Send + Sync>
96+
});
97+
let exempt: Option<Box<dyn Fn(&super::tools::ToolCall) -> bool + Send + Sync>> =
98+
config.storm_exempt_tools.as_ref().map(|extras| {
99+
let extra_set: std::collections::HashSet<String> = extras.iter().cloned().collect();
100+
Box::new(move |c: &super::tools::ToolCall| {
101+
super::storm::default_exempt(c) || extra_set.contains(&c.name)
102+
}) as Box<dyn Fn(&super::tools::ToolCall) -> bool + Send + Sync>
103+
});
104+
StormBreaker::new(6, 3, mutating, exempt)
105+
}
106+
83107
/// Public entry point: start a new run from one or more prompt
84108
/// messages. Faithful port of pi `runAgentLoop` (agent-loop.ts:95).
85109
///
@@ -133,7 +157,7 @@ pub async fn run_agent_loop(
133157
/// - otherwise → emit agent_start + turn_start, enter loop with
134158
/// newMessages = [] (does NOT re-emit user-message events)
135159
pub async fn run_agent_loop_continue(
136-
context: Context,
160+
mut context: Context,
137161
config: LoopConfig,
138162
signal: AbortSignal,
139163
emit: &mpsc::Sender<LoopEvent>,
@@ -143,10 +167,7 @@ pub async fn run_agent_loop_continue(
143167
if context.messages.is_empty() {
144168
return Err(LoopError::NoMessages);
145169
}
146-
// Pi lines 131-133: last-message role check. Phase 4 reads
147-
// the role string from the placeholder `Vec<Value>` shape
148-
// since that's what context.messages carries. Phase ??? may
149-
// substitute typed messages.
170+
// Pi lines 131-133: last-message role check.
150171
let last_role = context
151172
.messages
152173
.last()
@@ -157,6 +178,15 @@ pub async fn run_agent_loop_continue(
157178
return Err(LoopError::CannotContinueFromAssistant);
158179
}
159180

181+
// Heal messages before entering the loop (shrink oversized tool
182+
// results, drop unpaired tool calls). Matches the healing done
183+
// in spawn_loop_runner for the session-restore path.
184+
let heal_result =
185+
super::heal::heal_loaded_messages(&context.messages, super::heal::DEFAULT_MAX_RESULT_CHARS);
186+
if heal_result.healed_count > 0 {
187+
context.messages = heal_result.messages;
188+
}
189+
160190
// Pi lines 135-139: newMessages = []; emit agent_start +
161191
// turn_start; enter loop.
162192
let new_messages: Vec<LoopMessage> = Vec::new();
@@ -192,7 +222,7 @@ pub async fn run_loop(
192222
// Storm breaker: tracks (tool_name, args) repeats to detect
193223
// stuck-in-a-loop behavior. Reset each new user turn.
194224
// Port of Reasonix `repair/index.ts:38-46` + `loop.ts:621`.
195-
let mut storm = StormBreaker::default();
225+
let mut storm = storm_for_config(&config);
196226

197227
// Inflight set: authoritative running-id tracker.
198228
// UI cards consult `inflight.has(call_id)` to derive spinner state.
@@ -415,9 +445,10 @@ pub async fn run_loop(
415445
has_more_tool_calls = false;
416446
}
417447

418-
// Dispatch surviving calls.
448+
// Dispatch surviving calls through the unified dispatch.
449+
// `execute_tool_calls` takes pre-extracted tool calls.
419450
if !surviving_calls.is_empty() {
420-
let batch = execute_tool_calls_with(
451+
let batch = super::tools::execute_tool_calls(
421452
&current_context,
422453
&assistant_msg,
423454
&surviving_calls,
@@ -588,53 +619,6 @@ fn extract_tool_calls_from(msg: &AssistantMessage) -> Vec<super::tools::ToolCall
588619
super::tools::extract_tool_calls(msg)
589620
}
590621

591-
/// Dispatch pre-filtered tool calls (after storm breaker has
592-
/// removed suppressed calls). Mirrors the dispatch logic in
593-
/// `execute_tool_calls` but takes the calls directly instead of
594-
/// extracting from the assistant message.
595-
async fn execute_tool_calls_with(
596-
context: &Context,
597-
assistant_message: &AssistantMessage,
598-
tool_calls: &[super::tools::ToolCall],
599-
config: &LoopConfig,
600-
signal: &AbortSignal,
601-
emit: &mpsc::Sender<LoopEvent>,
602-
inflight: &InflightSet,
603-
) -> super::tools::ExecutedToolCallBatch {
604-
use super::ExecutedToolCallBatch;
605-
let has_sequential = tool_calls.iter().any(|tc| {
606-
context
607-
.tools
608-
.iter()
609-
.find(|t| t.name() == tc.name)
610-
.and_then(|t| t.execution_mode())
611-
== Some(super::ToolExecutionMode::Sequential)
612-
});
613-
if config.tool_execution == super::ToolExecutionMode::Sequential || has_sequential {
614-
super::tools::execute_tool_calls_sequential(
615-
context,
616-
assistant_message,
617-
tool_calls,
618-
config,
619-
signal,
620-
emit,
621-
inflight,
622-
)
623-
.await
624-
} else {
625-
super::tools::execute_tool_calls_parallel(
626-
context,
627-
assistant_message,
628-
tool_calls,
629-
config,
630-
signal,
631-
emit,
632-
inflight,
633-
)
634-
.await
635-
}
636-
}
637-
638622
/// Convert a `LoopMessage` to the placeholder `Value` shape used
639623
/// in `Context.messages`. Mirrors `serialize_assistant` from
640624
/// stream.rs but covers every variant.
@@ -755,6 +739,8 @@ mod tests {
755739
provider_name: None,
756740
model_name: None,
757741
compact_model: None,
742+
storm_mutating_tools: None,
743+
storm_exempt_tools: None,
758744
}
759745
}
760746

src/agent/agent_loop/steering.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,8 @@ mod tests {
439439
provider_name: None,
440440
model_name: None,
441441
compact_model: None,
442+
storm_mutating_tools: None,
443+
storm_exempt_tools: None,
442444
};
443445
config.get_steering_messages = Some(steering_from_queue(queue.clone(), QueueMode::All));
444446

src/agent/agent_loop/storm.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ impl StormBreaker {
196196

197197
/// Built-in mutating tools: calls that change filesystem state.
198198
/// Kept in sync with `crate::agent::tools::BUILTIN_TOOL_NAMES`.
199-
fn default_mutating(call: &ToolCall) -> bool {
199+
pub fn default_mutating(call: &ToolCall) -> bool {
200200
matches!(
201201
call.name.as_str(),
202202
"write" | "edit" | "bash" | "apply_patch"
@@ -209,7 +209,7 @@ fn default_mutating(call: &ToolCall) -> bool {
209209
/// `find_callers` / `find_callees` are behind `#[cfg(feature = "semantic")]`
210210
/// but listing them here is harmless — the match simply won't fire
211211
/// when the feature is off.
212-
fn default_exempt(call: &ToolCall) -> bool {
212+
pub fn default_exempt(call: &ToolCall) -> bool {
213213
matches!(
214214
call.name.as_str(),
215215
"read"

src/agent/agent_loop/stream.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,8 @@ mod tests {
375375
provider_name: None,
376376
model_name: None,
377377
compact_model: None,
378+
storm_mutating_tools: None,
379+
storm_exempt_tools: None,
378380
}
379381
}
380382

@@ -669,6 +671,8 @@ mod tests {
669671
provider_name: None,
670672
model_name: None,
671673
compact_model: None,
674+
storm_mutating_tools: None,
675+
storm_exempt_tools: None,
672676
};
673677
let signal = AbortSignal::new();
674678
let (tx, mut rx) = mpsc::channel::<LoopEvent>(32);

src/agent/agent_loop/tools.rs

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -705,12 +705,12 @@ pub async fn execute_tool_calls_parallel(
705705
pub async fn execute_tool_calls(
706706
context: &Context,
707707
assistant_message: &AssistantMessage,
708+
tool_calls: &[ToolCall],
708709
config: &LoopConfig,
709710
signal: &AbortSignal,
710711
emit: &mpsc::Sender<LoopEvent>,
711712
inflight: &InflightSet,
712713
) -> ExecutedToolCallBatch {
713-
let tool_calls = extract_tool_calls(assistant_message);
714714
let has_sequential = tool_calls.iter().any(|tc| {
715715
context
716716
.tools
@@ -744,6 +744,29 @@ pub async fn execute_tool_calls(
744744
}
745745
}
746746

747+
/// Convenience: extract tool calls from the assistant message, then
748+
/// dispatch through [`execute_tool_calls`].
749+
pub async fn execute_tool_calls_from_msg(
750+
context: &Context,
751+
assistant_message: &AssistantMessage,
752+
config: &LoopConfig,
753+
signal: &AbortSignal,
754+
emit: &mpsc::Sender<LoopEvent>,
755+
inflight: &InflightSet,
756+
) -> ExecutedToolCallBatch {
757+
let tool_calls = extract_tool_calls(assistant_message);
758+
execute_tool_calls(
759+
context,
760+
assistant_message,
761+
&tool_calls,
762+
config,
763+
signal,
764+
emit,
765+
inflight,
766+
)
767+
.await
768+
}
769+
747770
/// Extract `ToolCall`s from an assistant message's content. Port
748771
/// of pi line 380 `message.content.filter((c) => c.type ===
749772
/// "toolCall")` adapted to our typed enum.
@@ -989,6 +1012,8 @@ mod tests {
9891012
provider_name: None,
9901013
model_name: None,
9911014
compact_model: None,
1015+
storm_mutating_tools: None,
1016+
storm_exempt_tools: None,
9921017
}
9931018
}
9941019

@@ -1530,7 +1555,7 @@ mod tests {
15301555

15311556
let (tx, _rx) = mpsc::channel::<LoopEvent>(128);
15321557
let signal = AbortSignal::new();
1533-
let batch = execute_tool_calls(
1558+
let batch = execute_tool_calls_from_msg(
15341559
&context,
15351560
&assistant,
15361561
&config,
@@ -1594,7 +1619,7 @@ mod tests {
15941619

15951620
let (tx, _rx) = mpsc::channel::<LoopEvent>(128);
15961621
let signal = AbortSignal::new();
1597-
let _ = execute_tool_calls(
1622+
let _ = execute_tool_calls_from_msg(
15981623
&context,
15991624
&assistant,
16001625
&config,
@@ -1630,7 +1655,7 @@ mod tests {
16301655

16311656
let (tx, _rx) = mpsc::channel::<LoopEvent>(128);
16321657
let signal = AbortSignal::new();
1633-
let _ = execute_tool_calls(
1658+
let _ = execute_tool_calls_from_msg(
16341659
&context,
16351660
&assistant,
16361661
&config,
@@ -1685,7 +1710,7 @@ mod tests {
16851710

16861711
let (tx, _rx) = mpsc::channel::<LoopEvent>(128);
16871712
let signal = AbortSignal::new();
1688-
let batch = execute_tool_calls(
1713+
let batch = execute_tool_calls_from_msg(
16891714
&context,
16901715
&assistant,
16911716
&config,

src/agent/agent_loop/types.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,16 @@ pub struct LoopConfig {
287287
/// `LoopSpawnConfig` / `provider.rs` plumbing. Until then this
288288
/// field is accepted but not acted on.
289289
pub compact_model: Option<String>,
290+
291+
/// Additional tool names to treat as mutating (clears read-only
292+
/// entries from the storm breaker window). Built-in defaults
293+
/// (`write`, `edit`, `bash`, `apply_patch`) are always included.
294+
pub storm_mutating_tools: Option<Vec<String>>,
295+
296+
/// Additional tool names to treat as storm-exempt (never
297+
/// suppressed regardless of repetition). Built-in defaults
298+
/// (`read`, `list_dir`, `grep`, etc.) are always included.
299+
pub storm_exempt_tools: Option<Vec<String>>,
290300
}
291301

292302
/// `convertToLlm` signature. Synchronous in pi (returns
@@ -389,6 +399,8 @@ impl Clone for LoopConfig {
389399
provider_name: self.provider_name.clone(),
390400
model_name: self.model_name.clone(),
391401
compact_model: self.compact_model.clone(),
402+
storm_mutating_tools: self.storm_mutating_tools.clone(),
403+
storm_exempt_tools: self.storm_exempt_tools.clone(),
392404
}
393405
}
394406
}

0 commit comments

Comments
 (0)