Skip to content

Commit c151abd

Browse files
yogthosYogthos
andauthored
fix(F5): correlate ACP parallel tool calls by rig id, FIFO fallback (#80)
Track F-HIGH #5 from ROADMAP.md. ## Problem `extras/acp/mod.rs:229-231` used a single `last_tool_call_id: Option<ToolCallId>` slot to pair `AgentEvent::ToolResult` with its originating `AgentEvent::ToolCall`. When the LLM dispatched N parallel tool calls (Anthropic + OpenAI both support this), each new `ToolCall` clobbered the previous slot. The first N-1 results ended up paired with an empty id, breaking client-side correlation. Phase 3 already plumbed `id: CompactString` through both events from rig's `tool_call.id` / `tool_result.id`. The ACP bridge was discarding it (`AgentEvent::ToolCall { id: _, ... }`). ## Fix New `ToolCallCorrelator` struct with two buckets: - `by_id: HashMap<String, ToolCallId>` — provider-supplied rig ids (Anthropic, OpenAI). `record(rig_id, acp_id)` inserts; `resolve(rig_id)` removes + returns the original acp_id. - `fifo: VecDeque<ToolCallId>` — fallback for providers that emit empty ids. `record("", acp_id)` pushes; `resolve("")` pops. rig emits results in dispatch order, so FIFO is correct here. `run_prompt` now uses the correlator for both record + resolve. Stub-empty acp_id only on stray-result edge cases that shouldn't happen with rig's stream. Extracted into its own struct (not just `HashMap` + `VecDeque` locals) so the F5 fix is unit-testable without standing up a full ACP server. ## Tests Four new tests in `extras::acp::tests`: - `correlator_matches_parallel_tool_calls_by_id`: two ids recorded, resolve in reverse order — both pair correctly. - `correlator_uses_fifo_for_empty_rig_ids`: empty-id calls resolve in dispatch order. - `correlator_separates_id_and_fifo_buckets`: mixed-mode doesn't cross-contaminate. - `correlator_returns_none_for_unknown_id`: stray result is a None, not a panic. 658 pass (unchanged total — F5 only adds extras-feature tests). All build profiles clean. Co-authored-by: Yogthos <yogthos@gmail.com>
1 parent 93c80e5 commit c151abd

1 file changed

Lines changed: 105 additions & 17 deletions

File tree

src/extras/acp/mod.rs

Lines changed: 105 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -183,12 +183,10 @@ async fn run_prompt(
183183
let runner = agent.spawn_runner(prompt_text.to_string(), vec![]);
184184
let mut rx = runner.event_rx;
185185

186-
// Track the most-recent ToolCall id so ToolResult updates can
187-
// correlate back to their originating call. dirge's runner emits
188-
// ToolCall + ToolResult as separate events with no id linkage —
189-
// they're paired by emission order. Capture the id at ToolCall
190-
// and reuse it on the next ToolResult.
191-
let mut last_tool_call_id: Option<ToolCallId> = None;
186+
// F5: correlate rig tool-call ids with ACP ids so parallel
187+
// calls pair with their results correctly. See
188+
// `ToolCallCorrelator` doc for the dual-mode logic.
189+
let mut correlator = ToolCallCorrelator::default();
192190
while let Some(event) = rx.recv().await {
193191
match event {
194192
AgentEvent::Token(text) => {
@@ -209,25 +207,21 @@ async fn run_prompt(
209207
);
210208
let _ = cx.send_notification(notif);
211209
}
212-
AgentEvent::ToolCall { id: _, name, args } => {
210+
AgentEvent::ToolCall { id, name, args } => {
213211
let args_str = args.to_string();
214-
let call_id = ToolCallId::new(uuid::Uuid::new_v4().to_string());
215-
last_tool_call_id = Some(call_id.clone());
216-
let tool_call = ToolCall::new(call_id, name.to_string())
212+
let acp_id = ToolCallId::new(uuid::Uuid::new_v4().to_string());
213+
correlator.record(id.as_str(), acp_id.clone());
214+
let tool_call = ToolCall::new(acp_id, name.to_string())
217215
.raw_input(serde_json::from_str(&args_str).ok());
218216
let notif = SessionNotification::new(
219217
session_id.clone(),
220218
SessionUpdate::ToolCall(tool_call),
221219
);
222220
let _ = cx.send_notification(notif);
223221
}
224-
AgentEvent::ToolResult { id: _, output } => {
225-
// Use the most recent ToolCall id so the client can
226-
// correlate result → call. Falls back to an empty id
227-
// only if a stray ToolResult arrives without a prior
228-
// ToolCall (shouldn't happen with rig's stream).
229-
let id = last_tool_call_id
230-
.take()
222+
AgentEvent::ToolResult { id, output } => {
223+
let id = correlator
224+
.resolve(id.as_str())
231225
.unwrap_or_else(|| ToolCallId::new(String::new()));
232226
let fields = ToolCallUpdateFields::new()
233227
.status(ToolCallStatus::Completed)
@@ -289,6 +283,44 @@ fn build_acp_permission(state: &AcpState) -> (Option<PermCheck>, Option<AskSende
289283
(Some(perm), Some(ask_tx))
290284
}
291285

286+
/// Two-mode correlator for matching rig `ToolResult` events back
287+
/// to their originating `ToolCall` event when bridging to ACP.
288+
/// Most providers (Anthropic, OpenAI) emit a stable `tool_call.id`
289+
/// on the request and re-emit it on the result → use the id map.
290+
/// Some providers (older OpenAI compat models) emit empty ids →
291+
/// fall back to FIFO since rig emits results in request order.
292+
///
293+
/// Extracted from `run_prompt` so the F5 fix is unit-testable
294+
/// without standing up a full ACP server.
295+
#[derive(Default)]
296+
struct ToolCallCorrelator {
297+
by_id: std::collections::HashMap<String, ToolCallId>,
298+
fifo: std::collections::VecDeque<ToolCallId>,
299+
}
300+
301+
impl ToolCallCorrelator {
302+
/// Record a new `(rig_id → acp_id)` mapping. Empty rig_id
303+
/// pushes onto the FIFO queue.
304+
fn record(&mut self, rig_id: &str, acp_id: ToolCallId) {
305+
if rig_id.is_empty() {
306+
self.fifo.push_back(acp_id);
307+
} else {
308+
self.by_id.insert(rig_id.to_string(), acp_id);
309+
}
310+
}
311+
312+
/// Resolve a result's rig_id to the originally-issued acp_id.
313+
/// Returns `None` if no matching call is in-flight; callers
314+
/// emit a stub empty id in that (shouldn't-happen) case.
315+
fn resolve(&mut self, rig_id: &str) -> Option<ToolCallId> {
316+
if !rig_id.is_empty() {
317+
self.by_id.remove(rig_id)
318+
} else {
319+
self.fifo.pop_front()
320+
}
321+
}
322+
}
323+
292324
/// Drain `ask_rx` by responding to every permission ask with
293325
/// `Deny`. ACP runs are non-interactive — there's no human at a
294326
/// keyboard to confirm prompts. Previously the receiver was simply
@@ -354,6 +386,62 @@ mod tests {
354386
);
355387
}
356388

389+
/// F5: two parallel tool calls with distinct rig ids → two
390+
/// results MUST pair with the right ACP ids. Previously the
391+
/// `last_tool_call_id` single-slot lost the first id when the
392+
/// second call arrived.
393+
#[test]
394+
fn correlator_matches_parallel_tool_calls_by_id() {
395+
let mut c = ToolCallCorrelator::default();
396+
let acp_a = ToolCallId::new("acp-A".to_string());
397+
let acp_b = ToolCallId::new("acp-B".to_string());
398+
c.record("rig-A", acp_a.clone());
399+
c.record("rig-B", acp_b.clone());
400+
401+
// Results can arrive in either order.
402+
assert_eq!(c.resolve("rig-B"), Some(acp_b));
403+
assert_eq!(c.resolve("rig-A"), Some(acp_a));
404+
}
405+
406+
/// Provider-empty ids fall to the FIFO queue, preserving
407+
/// request order (rig emits results in dispatch order for
408+
/// providers that don't supply ids).
409+
#[test]
410+
fn correlator_uses_fifo_for_empty_rig_ids() {
411+
let mut c = ToolCallCorrelator::default();
412+
let acp_a = ToolCallId::new("acp-A".to_string());
413+
let acp_b = ToolCallId::new("acp-B".to_string());
414+
c.record("", acp_a.clone());
415+
c.record("", acp_b.clone());
416+
417+
// First result pairs with first call; second with second.
418+
assert_eq!(c.resolve(""), Some(acp_a));
419+
assert_eq!(c.resolve(""), Some(acp_b));
420+
}
421+
422+
/// Mixed: an id'd call alongside an empty-id call. Each falls
423+
/// to its respective bucket — no cross-contamination.
424+
#[test]
425+
fn correlator_separates_id_and_fifo_buckets() {
426+
let mut c = ToolCallCorrelator::default();
427+
let acp_named = ToolCallId::new("acp-named".to_string());
428+
let acp_anon = ToolCallId::new("acp-anon".to_string());
429+
c.record("rig-X", acp_named.clone());
430+
c.record("", acp_anon.clone());
431+
432+
assert_eq!(c.resolve(""), Some(acp_anon));
433+
assert_eq!(c.resolve("rig-X"), Some(acp_named));
434+
}
435+
436+
/// Stray result (no matching call) → resolve returns None;
437+
/// the caller can choose a stub id. Don't panic.
438+
#[test]
439+
fn correlator_returns_none_for_unknown_id() {
440+
let mut c = ToolCallCorrelator::default();
441+
assert_eq!(c.resolve("missing"), None);
442+
assert_eq!(c.resolve(""), None);
443+
}
444+
357445
/// Multiple concurrent asks all get responded to.
358446
#[tokio::test]
359447
async fn acp_ask_drain_handles_multiple_concurrent_asks() {

0 commit comments

Comments
 (0)