Skip to content

Commit afda86b

Browse files
author
Yogthos
committed
feat(agent): phase 5 — inflight tracking for tool dispatch
Faithful port of DeepSeek-Reasonix src/core/inflight.ts (52 LOC). InflightSet: authoritative running-id tracker. Thread-safe (Mutex<HashSet<String>>). Cards derive spinner state from inflight.has(call_id) instead of trusting end-event delivery. - Loop creates InflightSet at run_loop start (Reasonix loop.ts:147) - Sequential dispatch: inflight.add after ToolExecutionStart, inflight.delete after tool_result_message — finally-contract - Parallel dispatch: inflight.add at preflight, inflight.delete inline for Immediate path and in spawned future for Prepared - execute_tool_calls umbrella passes inflight through to children Tests: 8 tests ported from Reasonix tests/inflight.test.ts. All 198 agent_loop tests pass. Build + fmt clean.
1 parent aabb8db commit afda86b

4 files changed

Lines changed: 255 additions & 13 deletions

File tree

src/agent/agent_loop/inflight.rs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
//! Inflight set — authoritative running-id tracker.
2+
//!
3+
//! Faithful port of `DeepSeek-Reasonix/src/core/inflight.ts` (52 lines).
4+
//!
5+
//! UI cards consult `inflight.has(call_id)` to derive spinner state
6+
//! instead of trusting end-event delivery. The loop adds on dispatch
7+
//! entry and deletes in `finally` so every exit path cleans up.
8+
//!
9+
//! Thread-safe: wraps a `Mutex<HashSet<String>>` so multiple tokio
10+
//! tasks can add/delete ids concurrently (parallel tool dispatch).
11+
12+
use std::collections::HashSet;
13+
use std::sync::{Arc, Mutex};
14+
15+
/// Authoritative running-id set. Cards derive `running` from
16+
/// `has(id)` instead of trusting end-event delivery.
17+
#[derive(Debug, Clone, Default)]
18+
pub struct InflightSet {
19+
ids: Arc<Mutex<HashSet<String>>>,
20+
}
21+
22+
impl InflightSet {
23+
pub fn new() -> Self {
24+
Self {
25+
ids: Arc::new(Mutex::new(HashSet::new())),
26+
}
27+
}
28+
29+
/// Add an id to the set. Idempotent — re-adding the same id is a no-op.
30+
pub fn add(&self, id: &str) {
31+
self.ids.lock().unwrap().insert(id.to_string());
32+
}
33+
34+
/// Remove an id from the set. No-op if the id was not present.
35+
pub fn delete(&self, id: &str) {
36+
self.ids.lock().unwrap().remove(id);
37+
}
38+
39+
/// Check whether an id is currently in the set.
40+
pub fn has(&self, id: &str) -> bool {
41+
self.ids.lock().unwrap().contains(id)
42+
}
43+
44+
/// Number of ids currently in flight.
45+
pub fn len(&self) -> usize {
46+
self.ids.lock().unwrap().len()
47+
}
48+
49+
/// True when no ids are in flight.
50+
pub fn is_empty(&self) -> bool {
51+
self.ids.lock().unwrap().is_empty()
52+
}
53+
54+
/// Drop everything — used at session reset.
55+
/// No-op on an empty set.
56+
pub fn clear(&self) {
57+
self.ids.lock().unwrap().clear();
58+
}
59+
}
60+
61+
#[cfg(test)]
62+
mod tests {
63+
use super::*;
64+
65+
#[test]
66+
fn add_has_delete_round_trips() {
67+
let s = InflightSet::new();
68+
assert!(!s.has("a"));
69+
s.add("a");
70+
assert!(s.has("a"));
71+
assert_eq!(s.len(), 1);
72+
s.delete("a");
73+
assert!(!s.has("a"));
74+
assert_eq!(s.len(), 0);
75+
}
76+
77+
#[test]
78+
fn add_is_idempotent() {
79+
let s = InflightSet::new();
80+
s.add("a");
81+
s.add("a");
82+
s.add("a");
83+
assert_eq!(s.len(), 1);
84+
assert!(s.has("a"));
85+
}
86+
87+
#[test]
88+
fn delete_on_missing_id_is_noop() {
89+
let s = InflightSet::new();
90+
s.delete("never-added");
91+
assert_eq!(s.len(), 0);
92+
}
93+
94+
#[test]
95+
fn clear_empties_the_set() {
96+
let s = InflightSet::new();
97+
s.add("a");
98+
s.add("b");
99+
assert_eq!(s.len(), 2);
100+
s.clear();
101+
assert_eq!(s.len(), 0);
102+
assert!(!s.has("a"));
103+
assert!(!s.has("b"));
104+
}
105+
106+
#[test]
107+
fn clear_on_empty_set_is_noop() {
108+
let s = InflightSet::new();
109+
s.clear();
110+
assert_eq!(s.len(), 0);
111+
}
112+
113+
#[test]
114+
fn is_empty_reflects_state() {
115+
let s = InflightSet::new();
116+
assert!(s.is_empty());
117+
s.add("a");
118+
assert!(!s.is_empty());
119+
s.delete("a");
120+
assert!(s.is_empty());
121+
}
122+
123+
/// Port of inflight.test.ts:89 — finally contract: id removed
124+
/// even when work throws.
125+
#[test]
126+
fn finally_contract_id_removed_when_work_throws() {
127+
let s = InflightSet::new();
128+
s.add("job-1");
129+
// Simulate work that fails; "finally" block deletes.
130+
s.delete("job-1");
131+
assert!(!s.has("job-1"));
132+
assert_eq!(s.len(), 0);
133+
}
134+
135+
/// Cloned InflightSet shares state.
136+
#[test]
137+
fn clones_share_state() {
138+
let s1 = InflightSet::new();
139+
let s2 = s1.clone();
140+
s1.add("a");
141+
assert!(s2.has("a"));
142+
s2.delete("a");
143+
assert!(!s1.has("a"));
144+
}
145+
}

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
#[cfg(test)]
2929
mod h7_smoke;
3030
pub mod hooks;
31+
pub mod inflight;
3132
pub mod integration;
3233
pub mod message;
3334
#[cfg(feature = "plugin")]

src/agent/agent_loop/run.rs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
use serde_json::Value;
4545
use tokio::sync::mpsc;
4646

47+
use super::inflight::InflightSet;
4748
use super::message::{
4849
AssistantMessage, ContentBlock, LoopEvent, LoopMessage, StopReason, ToolResultMessage,
4950
};
@@ -187,6 +188,11 @@ pub async fn run_loop(
187188
) -> Vec<LoopMessage> {
188189
let mut first_turn = true;
189190

191+
// Inflight set: authoritative running-id tracker.
192+
// UI cards consult `inflight.has(call_id)` to derive spinner state.
193+
// Port of Reasonix `loop.ts:147` InflightSet.
194+
let inflight = InflightSet::new();
195+
190196
// Pi line 167: initial steering poll.
191197
let mut pending_messages: Vec<LoopMessage> = match &config.get_steering_messages {
192198
Some(get) => get().await,
@@ -331,9 +337,15 @@ pub async fn run_loop(
331337
let mut tool_results: Vec<ToolResultMessage> = Vec::new();
332338
has_more_tool_calls = false;
333339
if !tool_calls.is_empty() {
334-
let batch =
335-
execute_tool_calls(&current_context, &assistant_msg, &config, &signal, emit)
336-
.await;
340+
let batch = execute_tool_calls(
341+
&current_context,
342+
&assistant_msg,
343+
&config,
344+
&signal,
345+
emit,
346+
&inflight,
347+
)
348+
.await;
337349
tool_results = batch.messages;
338350
has_more_tool_calls = !batch.terminate;
339351
for result in &tool_results {

0 commit comments

Comments
 (0)