Skip to content

Commit 2fe43b6

Browse files
committed
feat(infinity-daemon): add input-less Interrupt for in-flight generation
Adds a real interrupt capability to the protocol and daemon: clients can stop a thread's in-flight work without queuing input and without shutting the session down. ## Protocol (`infinity-protocol`) * New `ClientMessage::Interrupt { thread_id }`: interrupts the thread and its descendant subtree. Additive enum variant; old daemons silently skip unparsable frames (both the unix-socket and websocket handlers `continue` on deserialization failure), so sending to an old daemon is a safe no-op. ## Core (`infinity-agent-core`) * New `TaggedSyntheticKind::Interrupt` input marker (plus `SyntheticKind::is_interrupt` / `thread::is_interrupt_input`). The marker is consumed entirely by the thread driver — never folded into history or queued for the model. * Driver: an interrupt marker cancels the in-flight completion exactly like user text does (the cancelled step flushes partial output and emits its completion notification); markers reaching a parked or respawned driver settle the thread directly. User text in the same batch supersedes the marker. * New `Thread::interrupt()`: settles a trailing unanswered tool call with the synthetic "interrupted by user" result (so a late real result is matched as already-answered and dropped instead of restarting generation), sends best-effort RAP cancellation notifications, dismisses pending user choices (broadcast as `UserChoiceComplete`), and persists. The interrupted-call settlement logic is factored out of `step_no_defer` and shared. Exactly one `CompletionFinished` (→ `ResponseDone`) is emitted per interrupted round so clients settle their indicators. ## Daemon (`infinity-daemon`) * `SessionManager::interrupt_thread()`: walks the target's subtree (children tree) and delivers interrupt markers to the target plus every active descendant (live or parked drivers; idle descendants are skipped — nothing in flight to cancel). Threads stay alive and accept new input, like after a normal `ResponseDone`. * `ClientMessage::Interrupt` handled in the client handler (local and remote-proxy paths; thread refs are stripped/prefixed like `UserInput`). Web clients get the message for free via the existing ws passthrough. ## Tests * core: interrupt cancels an in-flight completion without queuing input; interrupt settles a pending async tool call so its late result is dropped; threads accept input afterwards. * daemon: `interrupt_thread` targets exactly the requested subtree — interrupting a child leaves the root streaming, and both threads accept new input after interruption.
1 parent 97dee40 commit 2fe43b6

10 files changed

Lines changed: 525 additions & 42 deletions

File tree

‎crates/infinity-agent-core/src/event_processor.rs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,11 @@ impl<C: ConversationStore, S: StateStore> HistoryManager<C, S> {
321321
/// If the last history entry is an unanswered tool call, inject a
322322
/// synthetic "interrupted" result. (A tool call at the tail is unanswered
323323
/// by construction: its result would have been appended after it.)
324-
fn interrupt_pending_tool_call(&self) {
324+
///
325+
/// Exposed to the crate for input-less interrupts
326+
/// ([`Thread::interrupt`][crate::system::thread::Thread::interrupt]),
327+
/// which settle the trailing call without folding any input into history.
328+
pub(crate) fn interrupt_pending_tool_call(&self) {
325329
let last_call = self.history.borrow().last().and_then(|m| {
326330
if let InfinityMessage::ToolCall { call, .. } = m {
327331
Some(call.clone())

‎crates/infinity-agent-core/src/message.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ pub enum TaggedSyntheticKind {
6868
Compaction,
6969
#[serde(rename = "compaction_complete")]
7070
CompactionComplete,
71+
/// Input-less interrupt: cancels the thread's in-flight completion and
72+
/// settles a trailing unanswered tool call, without queuing anything for
73+
/// the model. Consumed entirely by the thread driver — it is never
74+
/// folded into history.
75+
#[serde(rename = "interrupt")]
76+
Interrupt,
7177
}
7278

7379
impl SyntheticKind {
@@ -86,6 +92,7 @@ impl SyntheticKind {
8692
SyntheticKind::Tagged(TaggedSyntheticKind::CompactionComplete) => {
8793
ToolCallId::from_ref("")
8894
}
95+
SyntheticKind::Tagged(TaggedSyntheticKind::Interrupt) => ToolCallId::from_ref(""),
8996
SyntheticKind::SubscriptionEvent(id) => id,
9097
}
9198
}
@@ -115,6 +122,12 @@ impl SyntheticKind {
115122
)
116123
}
117124

125+
/// Whether this is an input-less interrupt (see
126+
/// [`TaggedSyntheticKind::Interrupt`]).
127+
pub fn is_interrupt(&self) -> bool {
128+
matches!(self, SyntheticKind::Tagged(TaggedSyntheticKind::Interrupt))
129+
}
130+
118131
/// Associative subscription events are injected inline into the subscribing
119132
/// thread's history (like thread reports) rather than spawning a child thread.
120133
pub fn is_associative(&self) -> bool {

‎crates/infinity-agent-core/src/system/local/driver.rs‎

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ use crate::system::builder::SystemInner;
2626
use crate::system::defer::InMemoryDeferQueue;
2727
use crate::system::events::AgentEvent;
2828
use crate::system::observer::ThreadObserver;
29-
use crate::system::thread::{StepOutcome, Thread, is_user_text_input};
29+
use crate::system::thread::{StepOutcome, Thread, is_interrupt_input, is_user_text_input};
3030

3131
type BoxError = Box<dyn std::error::Error + Send + Sync>;
3232

@@ -175,8 +175,11 @@ pub(crate) async fn drive_thread<C, S, H, O>(
175175
batch.push(item);
176176
}
177177

178-
if batch.iter().any(|(msg, _)| is_user_text_input(msg)) {
179-
// User text interrupts the in-flight completion.
178+
let has_user_text = batch.iter().any(|(msg, _)| is_user_text_input(msg));
179+
// Input-less interrupts cancel the in-flight completion
180+
// just like user text; the markers themselves are consumed
181+
// by the shared stripping below (never stepped).
182+
if has_user_text || batch.iter().any(|(msg, _)| is_interrupt_input(msg)) {
180183
let res = in_flight
181184
.take()
182185
.expect("bug: in-flight step missing during interrupt")
@@ -191,20 +194,41 @@ pub(crate) async fn drive_thread<C, S, H, O>(
191194
&mut pending_batch,
192195
);
193196

194-
let (mut user_inputs, non_user_inputs): (Vec<_>, Vec<_>) = batch
195-
.into_iter()
196-
.partition(|(msg, _)| is_user_text_input(msg));
197+
if has_user_text {
198+
// User text supersedes an interrupt marker in the
199+
// same batch: folding the text already settles a
200+
// trailing tool call, so the marker is redundant.
201+
batch.retain(|(msg, _)| !is_interrupt_input(msg));
202+
let (mut user_inputs, non_user_inputs): (Vec<_>, Vec<_>) = batch
203+
.into_iter()
204+
.partition(|(msg, _)| is_user_text_input(msg));
205+
206+
if let InputMessageContent::User(UserContent::Text(text)) =
207+
&mut user_inputs[0].0.content
208+
{
209+
text.text = format!("<interrupt>{}", text.text);
210+
} else {
211+
panic!("bug: user_inputs should only contain user text");
212+
}
197213

198-
if let InputMessageContent::User(UserContent::Text(text)) =
199-
&mut user_inputs[0].0.content
200-
{
201-
text.text = format!("<interrupt>{}", text.text);
214+
pending_batch.extend(non_user_inputs);
215+
user_inputs
202216
} else {
203-
panic!("bug: user_inputs should only contain user text");
217+
// Consume the markers: settle the thread without
218+
// emitting another completion notification (the
219+
// cancelled step already emitted one).
220+
batch.retain(|(msg, _)| !is_interrupt_input(msg));
221+
if let Err(e) = thread.interrupt(&observer, false).await {
222+
observer.on_event(
223+
&thread_id,
224+
&AgentEvent::Info {
225+
text: format!("Error: {e}"),
226+
},
227+
);
228+
}
229+
pending_batch.extend(batch);
230+
continue;
204231
}
205-
206-
pending_batch.extend(non_user_inputs);
207-
user_inputs
208232
} else {
209233
pending_batch.extend(batch);
210234
continue;
@@ -293,6 +317,26 @@ pub(crate) async fn drive_thread<C, S, H, O>(
293317
}
294318
}
295319

320+
// Consume input-less interrupt markers: settle the thread (synthetic
321+
// "interrupted" result for a trailing tool call, RAP cancellation
322+
// notifications, pending-choice dismissal) without queueing anything
323+
// for the model. Markers that cancelled an in-flight step were
324+
// consumed in the select arm above; these reached a parked or
325+
// respawned driver with nothing in flight, so the settlement itself
326+
// emits the completion notification that settles client indicators.
327+
let mut new_inputs = new_inputs;
328+
if new_inputs.iter().any(|(msg, _)| is_interrupt_input(msg)) {
329+
new_inputs.retain(|(msg, _)| !is_interrupt_input(msg));
330+
if let Err(e) = thread.interrupt(&observer, true).await {
331+
observer.on_event(
332+
&thread_id,
333+
&AgentEvent::Info {
334+
text: format!("Error: {e}"),
335+
},
336+
);
337+
}
338+
}
339+
296340
let batch = match thread.filter_deferrable(new_inputs, &mut defer).await {
297341
Ok(batch) => batch,
298342
Err(e) => {

‎crates/infinity-agent-core/src/system/test_support.rs‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,23 @@ pub(crate) fn user_text_input(group_id: &str, text: &str) -> (InputMessage, Stri
8383
)
8484
}
8585

86+
/// An input-less interrupt marker (see
87+
/// [`TaggedSyntheticKind::Interrupt`]): cancels the thread's in-flight
88+
/// completion without queuing anything for the model.
89+
pub(crate) fn interrupt_input(group_id: &str) -> (InputMessage, String) {
90+
(
91+
InputMessage {
92+
content: InputMessageContent::User(UserContent::text("")),
93+
group_id: group_id.into(),
94+
metadata: None,
95+
synthetic: Some(SyntheticKind::Tagged(TaggedSyntheticKind::Interrupt)),
96+
display_as: None,
97+
subscription: false,
98+
},
99+
uuid::Uuid::new_v4().to_string(),
100+
)
101+
}
102+
86103
pub(crate) fn tool_result_input(group_id: &str, id: &str, text: &str) -> (InputMessage, String) {
87104
(
88105
InputMessage {

‎crates/infinity-agent-core/src/system/tests.rs‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,108 @@ async fn user_text_interrupts_active_completion() {
221221
.await;
222222
}
223223

224+
/// An input-less interrupt marker cancels the in-flight completion without
225+
/// queuing anything for the model, and the thread accepts new input
226+
/// afterwards.
227+
#[tokio::test(flavor = "current_thread")]
228+
async fn interrupt_input_cancels_completion_without_queuing_input() {
229+
let local = tokio::task::LocalSet::new();
230+
local
231+
.run_until(async {
232+
let (mut running, mut rx, mut ctrl, _conv) = start_system(vec![], None);
233+
running
234+
.send_user_text(ThreadId::from_ref("t1"), "work on something")
235+
.await;
236+
let _req = ctrl.next_request().await;
237+
ctrl.send_text("partial...");
238+
// Wait until the chunk is observed so the completion is in flight.
239+
loop {
240+
if let Evt::E(AgentEvent::TextChunk { .. }) = next_evt(&mut rx).await {
241+
break;
242+
}
243+
}
244+
245+
// The interrupt cancels the stream: the round finishes without
246+
// the model ever sending a Final marker.
247+
let (interrupt, dedup) = interrupt_input("t1");
248+
running.send(interrupt, &dedup).await;
249+
loop {
250+
if let Evt::E(AgentEvent::CompletionFinished { .. }) = next_evt(&mut rx).await {
251+
break;
252+
}
253+
}
254+
wait_idle(&mut running).await;
255+
256+
// Nothing was queued for the model: no new completion round ran.
257+
assert!(
258+
ctrl.try_next_request().is_none(),
259+
"an interrupt must not trigger a completion round"
260+
);
261+
262+
// The thread stays alive and accepts new input normally.
263+
running
264+
.send_user_text(ThreadId::from_ref("t1"), "follow up")
265+
.await;
266+
let _req2 = ctrl.next_request().await;
267+
ctrl.send_text("sure");
268+
ctrl.finish();
269+
collect_until_finished(&mut rx).await;
270+
})
271+
.await;
272+
}
273+
274+
/// An interrupt arriving while the thread waits for an async tool result
275+
/// settles the pending call with a synthetic "interrupted" result, so the
276+
/// late real result is dropped instead of restarting generation.
277+
#[tokio::test(flavor = "current_thread")]
278+
async fn interrupt_settles_pending_tool_call_so_late_result_is_dropped() {
279+
let local = tokio::task::LocalSet::new();
280+
local
281+
.run_until(async {
282+
let (mut running, mut rx, mut ctrl, _conv) =
283+
start_system(vec![Box::new(AsyncTool)], None);
284+
running
285+
.send_user_text(ThreadId::from_ref("t1"), "run the tool")
286+
.await;
287+
let _req = ctrl.next_request().await;
288+
ctrl.send_tool_call("tc-1", "async_tool", serde_json::json!({}));
289+
ctrl.finish();
290+
collect_until_finished(&mut rx).await;
291+
292+
// The thread is now parked waiting for the tool result; the
293+
// interrupt settles the pending call instead.
294+
let (interrupt, dedup) = interrupt_input("t1");
295+
running.send(interrupt, &dedup).await;
296+
loop {
297+
if let Evt::E(AgentEvent::CompletionFinished { .. }) = next_evt(&mut rx).await {
298+
break;
299+
}
300+
}
301+
wait_idle(&mut running).await;
302+
303+
// The late real result matches an already-settled call: it must
304+
// not wake the model.
305+
running
306+
.send(tool_result_input("t1", "tc-1", "late output").0, "res-late")
307+
.await;
308+
wait_idle(&mut running).await;
309+
assert!(
310+
ctrl.try_next_request().is_none(),
311+
"a settled tool call's late result must not restart generation"
312+
);
313+
314+
// The thread still accepts new input.
315+
running
316+
.send_user_text(ThreadId::from_ref("t1"), "next task")
317+
.await;
318+
let _req2 = ctrl.next_request().await;
319+
ctrl.send_text("on it");
320+
ctrl.finish();
321+
collect_until_finished(&mut rx).await;
322+
})
323+
.await;
324+
}
325+
224326
/// A non-user-text input (e.g. a stale tool result) arriving while a
225327
/// completion is streaming must not interrupt it.
226328
#[tokio::test(flavor = "current_thread")]

0 commit comments

Comments
 (0)