use crate::ir::StreamDelta;
use crate::tool_parser;
use super::super::stream_guards::{bump_f12_tool_call_count, flush_content_sanitizer};
use super::ctx::StreamCtx;
use super::state::{PendingRetry, StreamState};
type DeltaVec = Vec<StreamDelta>;
fn emit_or_buffer_tool_delta(
state: &mut StreamState,
ctx: &StreamCtx,
idx: usize,
delta: StreamDelta,
deltas: &mut DeltaVec,
) {
if ctx.tool_retry_enabled {
state
.buffered_tool_chunks
.entry(idx)
.or_default()
.push(delta);
} else {
deltas.push(delta);
}
}
fn flush_buffered_tool_chunks(state: &mut StreamState, idx: usize, deltas: &mut DeltaVec) {
if let Some(chunks) = state.buffered_tool_chunks.remove(&idx) {
deltas.extend(chunks);
}
}
fn drop_buffered_tool_chunks(state: &mut StreamState, idx: usize) {
state.buffered_tool_chunks.remove(&idx);
}
pub(super) fn handle_complete_tool_call(
state: &mut StreamState,
ctx: &StreamCtx,
tc: &mut tool_parser::ToolCall,
tc_idx: usize,
deltas: &mut DeltaVec,
) {
let pre_tool_tail = flush_content_sanitizer(
&mut state.tag_scan_buf,
&mut state.suppressing_param_leak,
&ctx.leak_markers,
);
if !pre_tool_tail.is_empty() {
deltas.push(StreamDelta::Content {
text: pre_tool_tail,
token_ids: state.take_ids_if(ctx.req_return_token_ids),
});
}
tool_parser::backfill_required_params(std::slice::from_mut(tc), &ctx.tool_defs_for_backfill);
if ctx.wants_typed_arguments {
tool_parser::coerce_all(std::slice::from_mut(tc), &ctx.tool_defs_for_backfill);
}
if let Some(ref cwd) = ctx.cwd_for_normalize {
tool_parser::normalize_paths(std::slice::from_mut(tc), cwd);
}
let validation = tool_parser::assess_tool_call(tc, &ctx.tool_defs_for_backfill).map_err(|i| {
(
matches!(
i,
tool_parser::ToolCallIssue::MissingParam(_)
| tool_parser::ToolCallIssue::EmptyRequired(_)
),
i.into_message(),
)
});
let is_soft = validation.as_ref().err().is_some_and(|(soft, _)| *soft);
if let Err((_, e)) = &validation
&& !is_soft
{
tracing::warn!(
tool = %tc.function.name,
"tool call validation error (hard): {e}; replacing with content and ending"
);
let msg = format!("[metrale] Tool call rejected: {e}");
deltas.push(StreamDelta::Content {
text: msg,
token_ids: state.take_ids_if(ctx.req_return_token_ids),
});
state.stop_string_triggered = true;
} else if let Err((_, e)) = &validation {
tracing::warn!(
tool = %tc.function.name,
"tool call validation error (soft): {e}; passing through to opencode"
);
bump_f12_tool_call_count(
&mut state.tool_calls_emitted_count,
ctx.max_tool_calls_per_response,
&mut state.stop_string_triggered,
);
let preview: String = tc.function.arguments.chars().take(120).collect();
let s = if tc.function.arguments.len() > preview.len() {
"…"
} else {
""
};
tracing::info!("Tool call: {}({preview}{s})", tc.function.name);
crate::metrics::TOOL_CALLS_TOTAL.inc();
deltas.push(StreamDelta::ToolCallStart {
index: tc_idx,
id: tc.id.clone(),
name: tc.function.name.clone(),
});
deltas.push(StreamDelta::ToolCallArgs {
index: tc_idx,
fragment: tc.function.arguments.clone(),
token_ids: Vec::new(),
});
if !state.corrective_hint_sent {
let empties = tool_parser::find_empty_required_params(tc, &ctx.tool_defs_for_backfill);
let garbled = tc.function.arguments.contains("</parameter<parameter=");
if !empties.is_empty() || garbled {
state.corrective_hint_sent = true;
let msg = format!(
"
[metrale] The {} call above has EMPTY required parameter(s): {}. It will fail. Re-issue the call with real values for every required parameter (do not repeat it unchanged).",
tc.function.name,
if empties.is_empty() {
"<garbled parameter boundary>".to_string()
} else {
empties.join(", ")
},
);
deltas.push(StreamDelta::Content {
text: msg,
token_ids: state.take_ids_if(ctx.req_return_token_ids),
});
}
}
} else if state
.tool_arg_dedup
.check(&tc.function.name, &tc.function.arguments)
{
tracing::warn!(
tool = %tc.function.name,
"tool-arg dedup tripped: refusing redundant tool_call and ending response"
);
state.stop_string_triggered = true;
state.tool_loop_capped = true;
state
.cancel_flag
.store(true, std::sync::atomic::Ordering::Release);
} else {
let run_len = advance_name_run(&mut state.name_run, &tc.function.name);
if run_len >= MAX_CONSEC_SAME_NAME_CALLS {
tracing::warn!(
tool = %tc.function.name,
run = run_len,
"Bug-2 name-run cap tripped (complete-call path): {run_len} successive `{}` tool calls; ending response",
tc.function.name
);
state.stop_string_triggered = true;
state.tool_loop_capped = true;
}
bump_f12_tool_call_count(
&mut state.tool_calls_emitted_count,
ctx.max_tool_calls_per_response,
&mut state.stop_string_triggered,
);
let preview: String = tc.function.arguments.chars().take(120).collect();
let s = if tc.function.arguments.len() > preview.len() {
"…"
} else {
""
};
tracing::info!("Tool call: {}({preview}{s})", tc.function.name);
crate::metrics::TOOL_CALLS_TOTAL.inc();
deltas.push(StreamDelta::ToolCallStart {
index: tc_idx,
id: tc.id.clone(),
name: tc.function.name.clone(),
});
deltas.push(StreamDelta::ToolCallArgs {
index: tc_idx,
fragment: tc.function.arguments.clone(),
token_ids: Vec::new(),
});
}
}
pub(super) fn handle_tool_call_start(
state: &mut StreamState,
ctx: &StreamCtx,
tc_id: String,
name: String,
idx: usize,
deltas: &mut DeltaVec,
) {
let pre_tool_tail = flush_content_sanitizer(
&mut state.tag_scan_buf,
&mut state.suppressing_param_leak,
&ctx.leak_markers,
);
if !pre_tool_tail.is_empty() {
deltas.push(StreamDelta::Content {
text: pre_tool_tail,
token_ids: state.take_ids_if(ctx.req_return_token_ids),
});
}
state
.streaming_tool_args
.insert(idx, (name.clone(), String::new()));
bump_f12_tool_call_count(
&mut state.tool_calls_emitted_count,
ctx.max_tool_calls_per_response,
&mut state.stop_string_triggered,
);
let start = StreamDelta::ToolCallStart {
index: idx,
id: tc_id,
name,
};
emit_or_buffer_tool_delta(state, ctx, idx, start, deltas);
}
pub(super) fn handle_tool_call_delta(
state: &mut StreamState,
ctx: &StreamCtx,
args: String,
idx: usize,
deltas: &mut DeltaVec,
) {
let mut emit_args = args.clone();
if let Some(entry) = state.streaming_tool_args.get_mut(&idx) {
let name = entry.0.clone();
let mut tc = tool_parser::ToolCall {
id: format!("call_{:016x}", idx),
call_type: "function".into(),
function: tool_parser::FunctionCall {
name: name.clone(),
arguments: args.clone(),
},
};
tool_parser::backfill_required_params(
std::slice::from_mut(&mut tc),
&ctx.tool_defs_for_backfill,
);
if ctx.wants_typed_arguments {
tool_parser::coerce_all(std::slice::from_mut(&mut tc), &ctx.tool_defs_for_backfill);
}
if let Some(ref cwd) = ctx.cwd_for_normalize {
tool_parser::normalize_paths(std::slice::from_mut(&mut tc), cwd);
}
if let Err(issue) = tool_parser::assess_tool_call(&tc, &ctx.tool_defs_for_backfill) {
let is_soft = matches!(
issue,
tool_parser::ToolCallIssue::MissingParam(_)
| tool_parser::ToolCallIssue::EmptyRequired(_)
);
let e = issue.into_message();
if is_soft {
tracing::warn!(
tool = %name,
"tool call validation error (stream Δ, soft): {e}; passing through so opencode can surface its own per-tool schema error"
);
emit_args = tc.function.arguments.clone();
entry.1.push_str(&emit_args);
} else if ctx.tool_retry_enabled {
tracing::warn!(
tool = %name,
"tool call validation error (stream Δ, hard, retry pending): {e}"
);
entry.1.push_str(&args);
let errors_summary = e.to_string();
drop_buffered_tool_chunks(state, idx);
state.pending_retry = Some(PendingRetry {
errors_summary,
failed_idx: idx,
});
state.stop_string_triggered = true;
state
.cancel_flag
.store(true, std::sync::atomic::Ordering::Release);
return;
} else {
tracing::warn!(
tool = %name,
"tool call validation error (stream Δ, hard): {e}; replacing with content and ending"
);
let msg = format!("[metrale] Tool call rejected: {e}");
deltas.push(StreamDelta::Content {
text: msg,
token_ids: Vec::new(),
});
state.stop_string_triggered = true;
entry.1.push_str(&args);
return;
}
} else {
emit_args = tc.function.arguments.clone();
entry.1.push_str(&emit_args);
}
} else if !args.is_empty() {
}
if !emit_args.is_empty() {
let frag = StreamDelta::ToolCallArgs {
index: idx,
fragment: emit_args,
token_ids: Vec::new(),
};
emit_or_buffer_tool_delta(state, ctx, idx, frag, deltas);
if ctx.tool_retry_enabled {
flush_buffered_tool_chunks(state, idx, deltas);
}
}
}
pub(super) fn handle_tool_call_args_fragment(
state: &mut StreamState,
_ctx: &StreamCtx,
fragment: String,
idx: usize,
deltas: &mut DeltaVec,
) {
let Some(entry) = state.streaming_tool_args.get_mut(&idx) else {
return;
};
entry.1.push_str(&fragment);
deltas.push(StreamDelta::ToolCallArgs {
index: idx,
fragment,
token_ids: Vec::new(),
});
}
fn advance_name_run(name_run: &mut Option<(String, u32)>, name: &str) -> u32 {
let run_len = match name_run {
Some((prev, n)) if prev == name => *n + 1,
_ => 1,
};
*name_run = Some((name.to_string(), run_len));
run_len
}
const MAX_CONSEC_SAME_NAME_CALLS: u32 = 8;
pub(super) fn handle_tool_call_end(state: &mut StreamState, _ctx: &StreamCtx, idx: usize) {
if let Some((name, args_json)) = state.streaming_tool_args.remove(&idx) {
if state.tool_arg_dedup_within.check(&name, &args_json) {
tracing::warn!(
tool = %name,
"F11 within-response dedup tripped: 2+ identical streaming tool calls; ending response"
);
state.stop_string_triggered = true;
state.tool_loop_capped = true;
}
let run_len = advance_name_run(&mut state.name_run, &name);
if run_len >= MAX_CONSEC_SAME_NAME_CALLS && !state.stop_string_triggered {
tracing::warn!(
tool = %name,
run = run_len,
"Bug-2 name-run cap tripped: {run_len} successive `{name}` tool calls; ending response (F11 missed because args drift)"
);
state.stop_string_triggered = true;
state.tool_loop_capped = true;
}
if !state.stop_string_triggered {
let preview: String = args_json.chars().take(120).collect();
let s = if args_json.len() > preview.len() {
"…"
} else {
""
};
tracing::info!("Tool call: {name}({preview}{s})");
crate::metrics::TOOL_CALLS_TOTAL.inc();
}
}
}
#[cfg(test)]
mod name_run_cap_tests {
use super::{MAX_CONSEC_SAME_NAME_CALLS, advance_name_run};
#[test]
fn three_call_parallel_fanout_stays_under_cap() {
let mut run = None;
for i in 1..=3u32 {
let len = advance_name_run(&mut run, "get_weather");
assert_eq!(len, i);
assert!(
len < MAX_CONSEC_SAME_NAME_CALLS,
"a 3-call same-name parallel fan-out must not trip the doom-loop cap"
);
}
}
#[test]
fn runaway_same_name_run_still_trips() {
let mut run = None;
let mut tripped_at = None;
for i in 1..=12u32 {
if advance_name_run(&mut run, "bash") >= MAX_CONSEC_SAME_NAME_CALLS {
tripped_at = Some(i);
break;
}
}
assert_eq!(
tripped_at,
Some(MAX_CONSEC_SAME_NAME_CALLS),
"cap must fire below the F12 total cap (12)"
);
}
#[test]
fn different_name_resets_run() {
let mut run = None;
assert_eq!(advance_name_run(&mut run, "get_weather"), 1);
assert_eq!(advance_name_run(&mut run, "get_weather"), 2);
assert_eq!(advance_name_run(&mut run, "get_time"), 1);
assert_eq!(advance_name_run(&mut run, "get_weather"), 1);
}
}