Skip to content

Commit 360242d

Browse files
author
Yogthos
committed
fix(agent_loop): post-phase-5 code review — R1 drain collision, R3 signal threading, #2 provider name
Three fixes from the phase-4.6/phase-5 code review. **R1**: `prepare_next_turn_from_plugin_manager` was draining `harness-next-model` alongside `harness-next-thinking-level`. The model slot has pre-existing dirge semantics — read by the UI at end-of-run (`ui/mod.rs:2359`) to spawn a fresh agent against the new model. With the prepareNextTurn hook draining it first, the UI's consumer saw None and `harness/set-next-model` silently failed. Fix: only drain the thinking-level slot in the hook. The model slot stays for the UI consumer. Mid-run model swap isn't supported anyway (run.rs already logs a warning when TurnUpdate.model is set — code review #3); a separate API for real mid-run model swap waits on rig API growth. **R3**: `opts.signal` in StreamOptions was silently ignored by the rig stream adapter. Mid-stream cancellation against the rig request had no effect — signal only took effect at the next turn boundary. (Old runner.rs path had the same limitation; not a regression but a real gap.) Fix: thread `Option<AbortSignal>` into wrap_streamed_assistant. Per-chunk pre-poll check: if signal is cancelled, emit an Error event with "aborted" substring and exit. Mid-LLM-call cancel now actually stops the rig request promptly. **#2**: `get_api_key` hook was called with `""` instead of the provider name. Pi contract: `getApiKey(provider: string) => key`. Provider-aware hooks couldn't dispatch. Fix: add `provider_name: Option<String>` to LoopConfig (and LoopSpawnConfig, threaded through). `AnyAgent::provider_name()` returns the canonical name per variant ("anthropic", "glm", etc.). spawn_runner sets it. stream_assistant_response passes it to the hook. **Tests** (3 new): - prepare_next_turn_does_not_drain_next_model_slot (R1) - signal_cancels_stream_mid_flight (R3) - signal_none_does_not_affect_stream (R3 negative case) - test_get_api_key_receives_provider_name (#2) **Not fixed in this commit** (documented as deferred): - R2: opts.api_key silently ignored by rig adapter (rig's client carries the key at construction; per-request override would need a rig API change) - R4: opts.request_timeout silently ignored (same reason) - Per-provider reasoning mappers (separate larger commit) Gates: - cargo build (default) clean - cargo build --features plugin clean - cargo build --all-features clean - cargo test 849 passed; 6 ignored - cargo fmt clean
1 parent f6dc77a commit 360242d

11 files changed

Lines changed: 243 additions & 36 deletions

File tree

src/agent/agent_loop/h7_smoke.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ async fn h7_scenario_1_simple_text() {
176176
steering_queue: None,
177177
tool_execution: crate::agent::agent_loop::types::ToolExecutionMode::Parallel,
178178
event_channel_capacity: 256,
179+
provider_name: None,
179180
};
180181
let runner = spawn_loop_runner(cfg).into_agent_runner();
181182
let (events, response) = drain_to_done(runner).await;
@@ -239,6 +240,7 @@ async fn h7_scenario_2_turn_boundaries() {
239240
steering_queue: None,
240241
tool_execution: crate::agent::agent_loop::types::ToolExecutionMode::Parallel,
241242
event_channel_capacity: 256,
243+
provider_name: None,
242244
};
243245
let runner = spawn_loop_runner(cfg).into_agent_runner();
244246
let (events, response) = drain_to_done(runner).await;
@@ -336,6 +338,7 @@ async fn h7_scenario_5_auth_error_surfaces() {
336338
steering_queue: None,
337339
tool_execution: crate::agent::agent_loop::types::ToolExecutionMode::Parallel,
338340
event_channel_capacity: 256,
341+
provider_name: None,
339342
};
340343
let runner = spawn_loop_runner(cfg).into_agent_runner();
341344
let (events, _) = drain_to_done(runner).await;
@@ -485,6 +488,7 @@ async fn h7_scenario_3_tool_dispatch() {
485488
steering_queue: None,
486489
tool_execution: crate::agent::agent_loop::types::ToolExecutionMode::Sequential,
487490
event_channel_capacity: 256,
491+
provider_name: None,
488492
};
489493
let runner = spawn_loop_runner(cfg).into_agent_runner();
490494
let (events, response) = drain_to_done(runner).await;
@@ -587,6 +591,7 @@ async fn h7_glm_scenario_1_simple_text() {
587591
steering_queue: None,
588592
tool_execution: crate::agent::agent_loop::types::ToolExecutionMode::Parallel,
589593
event_channel_capacity: 256,
594+
provider_name: None,
590595
};
591596
let runner = spawn_loop_runner(cfg).into_agent_runner();
592597
let (events, response) = drain_to_done(runner).await;
@@ -707,6 +712,7 @@ async fn h7_glm_scenario_3_tool_dispatch() {
707712
steering_queue: None,
708713
tool_execution: crate::agent::agent_loop::types::ToolExecutionMode::Sequential,
709714
event_channel_capacity: 256,
715+
provider_name: None,
710716
};
711717
let runner = spawn_loop_runner(cfg).into_agent_runner();
712718
let (events, response) = drain_to_done(runner).await;

src/agent/agent_loop/integration.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,12 @@ pub struct LoopSpawnConfig {
321321
/// Channel capacity for the AgentEvent output. 256 matches
322322
/// the existing `runner::spawn_agent` choice.
323323
pub event_channel_capacity: usize,
324+
325+
/// Provider name forwarded to `LoopConfig.provider_name` so
326+
/// the `getApiKey` hook receives the canonical provider
327+
/// identifier. Code review #2 — was missing; hook used to
328+
/// receive empty string.
329+
pub provider_name: Option<String>,
324330
}
325331

326332
impl LoopSpawnConfig {
@@ -335,6 +341,7 @@ impl LoopSpawnConfig {
335341
history: Vec::new(),
336342
initial_prompt: prompt.into(),
337343
tools: Vec::new(),
344+
provider_name: None,
338345
#[cfg(feature = "plugin")]
339346
plugin_mgr: None,
340347
steering_queue: None,
@@ -379,6 +386,7 @@ pub fn spawn_loop_runner(cfg: LoopSpawnConfig) -> LoopRunner {
379386
headers: std::collections::HashMap::new(),
380387
metadata: std::collections::HashMap::new(),
381388
request_timeout: None,
389+
provider_name: cfg.provider_name.clone(),
382390
};
383391

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

src/agent/agent_loop/plugin_hooks.rs

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -232,36 +232,35 @@ fn flatten_text(content: &[serde_json::Value]) -> String {
232232
// ============================================================
233233

234234
/// Build a `PrepareNextTurnFn` that reads the
235-
/// `harness-next-thinking-level` slot (and `harness-next-model`)
236-
/// from the plugin manager. Plugins set these slots via
237-
/// `harness/set-next-thinking-level` / `harness/set-next-model`
235+
/// `harness-next-thinking-level` slot from the plugin manager.
236+
/// Plugins set the slot via `harness/set-next-thinking-level`
238237
/// inside `on-tool-end` (or any hook firing between turns).
239238
///
240-
/// Returns `Some(TurnUpdate)` with the requested fields when any
241-
/// slot was set; `None` otherwise. Context is never mutated by
242-
/// this factory — separate hooks compose if a plugin wants to
243-
/// rewrite the transcript.
239+
/// **Does NOT drain `harness-next-model`** (code review bug R1).
240+
/// That slot has pre-existing dirge semantics: read by the UI
241+
/// at end-of-run (`ui/mod.rs:2359`) to spawn a fresh agent
242+
/// against the new model. Mid-run model swap isn't supported
243+
/// today (rig's stream can't pivot mid-flight, and even
244+
/// `run_loop` only logs a warning when `TurnUpdate.model` is
245+
/// set — see code review #3). Draining the slot here would
246+
/// steal it from the UI consumer and break the existing
247+
/// `/model` swap flow.
244248
///
245-
/// Locking pattern matches before/after_hook_from_plugin_manager:
246-
/// acquire-read-release synchronously per call.
249+
/// Returns `Some(TurnUpdate)` with the requested thinking
250+
/// level when the slot was set; `None` otherwise.
247251
pub fn prepare_next_turn_from_plugin_manager(pm: Arc<Mutex<PluginManager>>) -> PrepareNextTurnFn {
248252
Arc::new(move |_ctx| {
249253
let pm = pm.clone();
250254
Box::pin(async move {
251-
let (thinking, model) = {
255+
let thinking = {
252256
let mut mgr = pm.lock().unwrap_or_else(|e| e.into_inner());
253-
let t = mgr.take_pending_next_thinking_level();
254-
let m = mgr.take_pending_next_model();
255-
(t, m)
257+
mgr.take_pending_next_thinking_level()
256258
};
257-
let thinking_level = thinking.and_then(parse_thinking_level);
258-
if thinking_level.is_none() && model.is_none() {
259-
return None;
260-
}
259+
let thinking_level = thinking.and_then(parse_thinking_level)?;
261260
Some(TurnUpdate {
262261
context: None,
263-
model,
264-
thinking_level,
262+
model: None,
263+
thinking_level: Some(thinking_level),
265264
})
266265
})
267266
})
@@ -696,6 +695,36 @@ mod tests {
696695
}
697696
}
698697

698+
/// R1 regression: `prepare_next_turn_from_plugin_manager`
699+
/// MUST NOT drain `harness-next-model`. That slot is owned
700+
/// by the UI's end-of-run handler (`ui/mod.rs::2359`).
701+
/// Earlier versions of phase 5 drained both slots in the
702+
/// hook, which silently broke `harness/set-next-model`
703+
/// because whichever consumer fired first stole the value.
704+
#[tokio::test]
705+
async fn prepare_next_turn_does_not_drain_next_model_slot() {
706+
let Some(pm) = try_pm() else { return };
707+
{
708+
let mut mgr = pm.lock().unwrap();
709+
mgr.eval(r#"(defn swap [_ctx] (harness/set-next-model "gpt-5"))"#)
710+
.unwrap();
711+
mgr.register("on-tool-end", "swap");
712+
mgr.dispatch_tool_hook("on-tool-end", "@{:tool \"t\" :output \"x\"}")
713+
.unwrap();
714+
}
715+
let hook = prepare_next_turn_from_plugin_manager(pm.clone());
716+
let result = hook(turn_ctx()).await;
717+
// prepareNextTurn returns None because thinking_level
718+
// wasn't set. The model slot remains intact.
719+
assert!(
720+
result.is_none(),
721+
"prepare_next_turn should ignore model slot",
722+
);
723+
// Critical: the UI's end-of-run consumer can still read it.
724+
let pending = pm.lock().unwrap().take_pending_next_model();
725+
assert_eq!(pending, Some("gpt-5".to_string()));
726+
}
727+
699728
/// Unknown thinking-level strings get filtered out — a
700729
/// plugin typo doesn't crash the run.
701730
#[tokio::test]

src/agent/agent_loop/rig_stream.rs

Lines changed: 96 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,12 @@ use super::message::{AssistantMessage, ContentBlock, DeltaPhase, StopReason, Str
6060
pub fn wrap_rig_stream<R>(
6161
rig_stream: StreamingCompletionResponse<R>,
6262
chunk_timeout: Option<std::time::Duration>,
63+
signal: Option<crate::agent::agent_loop::tool::AbortSignal>,
6364
) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>>
6465
where
6566
R: Clone + Unpin + Send + GetTokenUsage + 'static,
6667
{
67-
wrap_streamed_assistant(Box::pin(rig_stream), chunk_timeout)
68+
wrap_streamed_assistant(Box::pin(rig_stream), chunk_timeout, signal)
6869
}
6970

7071
/// Lower-level variant: wrap any `Stream<Result<StreamedAssistantContent<R>,
@@ -87,6 +88,7 @@ pub fn wrap_streamed_assistant<R>(
8788
Box<dyn Stream<Item = Result<StreamedAssistantContent<R>, CompletionError>> + Send>,
8889
>,
8990
chunk_timeout: Option<std::time::Duration>,
91+
signal: Option<crate::agent::agent_loop::tool::AbortSignal>,
9092
) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>>
9193
where
9294
R: Clone + Unpin + Send + 'static,
@@ -107,6 +109,23 @@ where
107109
std::collections::HashMap::new();
108110

109111
loop {
112+
// Code review R3: honor AbortSignal between chunks.
113+
// The loop / tools already check signal at their
114+
// boundaries; here we add a per-chunk check so a
115+
// mid-stream cancel actually stops the rig request
116+
// rather than waiting for the next turn boundary.
117+
// Pre-poll check covers the case where signal was
118+
// cancelled BEFORE the first chunk arrived; the
119+
// post-await check catches cancellation that
120+
// happened DURING the chunk wait.
121+
if let Some(sig) = signal.as_ref()
122+
&& sig.is_cancelled()
123+
{
124+
yield StreamEvent::Error {
125+
error: "stream aborted by cancellation signal".to_string(),
126+
};
127+
return;
128+
}
110129
// Apply per-chunk timeout if configured. The yield
111130
// pattern below mirrors `while let Some(...)` exactly
112131
// for the non-timeout path.
@@ -441,7 +460,7 @@ mod tests {
441460
text: " world".to_string(),
442461
})),
443462
]);
444-
let events = drain(wrap_streamed_assistant(raw, None)).await;
463+
let events = drain(wrap_streamed_assistant(raw, None, None)).await;
445464
let labels: Vec<_> = events.iter().map(label).collect();
446465
assert_eq!(
447466
labels,
@@ -481,7 +500,7 @@ mod tests {
481500
},
482501
internal_call_id: "internal_1".to_string(),
483502
})]);
484-
let events = drain(wrap_streamed_assistant(raw, None)).await;
503+
let events = drain(wrap_streamed_assistant(raw, None, None)).await;
485504
let labels: Vec<_> = events.iter().map(label).collect();
486505
assert_eq!(
487506
labels,
@@ -532,7 +551,7 @@ mod tests {
532551
content: ToolCallDeltaContent::Delta("th\":\"/tmp/x\"}".to_string()),
533552
}),
534553
]);
535-
let events = drain(wrap_streamed_assistant(raw, None)).await;
554+
let events = drain(wrap_streamed_assistant(raw, None, None)).await;
536555
let labels: Vec<_> = events.iter().map(label).collect();
537556
assert_eq!(
538557
labels,
@@ -605,7 +624,7 @@ mod tests {
605624
internal_call_id: "internal_x".to_string(),
606625
}),
607626
]);
608-
let events = drain(wrap_streamed_assistant(raw, None)).await;
627+
let events = drain(wrap_streamed_assistant(raw, None, None)).await;
609628
let final_msg = events
610629
.iter()
611630
.rev()
@@ -696,7 +715,7 @@ mod tests {
696715
reasoning: " about this".to_string(),
697716
}),
698717
]);
699-
let events = drain(wrap_streamed_assistant(raw, None)).await;
718+
let events = drain(wrap_streamed_assistant(raw, None, None)).await;
700719
let labels: Vec<_> = events.iter().map(label).collect();
701720
assert_eq!(
702721
labels,
@@ -727,7 +746,7 @@ mod tests {
727746
let raw = raw_stream(vec![Ok(StreamedAssistantContent::Reasoning(
728747
Reasoning::new("All thinking"),
729748
))]);
730-
let events = drain(wrap_streamed_assistant(raw, None)).await;
749+
let events = drain(wrap_streamed_assistant(raw, None, None)).await;
731750
assert!(matches!(events[0], StreamEvent::Start { .. }));
732751
assert!(matches!(
733752
events[1],
@@ -751,7 +770,7 @@ mod tests {
751770
text: " more text".to_string(),
752771
})),
753772
]);
754-
let events = drain(wrap_streamed_assistant(raw, None)).await;
773+
let events = drain(wrap_streamed_assistant(raw, None, None)).await;
755774
assert!(matches!(events.last(), Some(StreamEvent::Error { .. })));
756775
let dones = events
757776
.iter()
@@ -776,7 +795,7 @@ mod tests {
776795
text: "done".to_string(),
777796
})),
778797
]);
779-
let events = drain(wrap_streamed_assistant(raw, None)).await;
798+
let events = drain(wrap_streamed_assistant(raw, None, None)).await;
780799
let final_msg = events
781800
.iter()
782801
.rev()
@@ -844,7 +863,7 @@ mod tests {
844863
let raw = raw_stream(vec![Ok(StreamedAssistantContent::Text(Text {
845864
text: "ok".to_string(),
846865
}))]);
847-
let events = drain(wrap_streamed_assistant(raw, None)).await;
866+
let events = drain(wrap_streamed_assistant(raw, None, None)).await;
848867
// Normal completion — no Error.
849868
assert!(events.iter().any(|e| matches!(e, StreamEvent::Done { .. })));
850869
assert!(
@@ -863,7 +882,12 @@ mod tests {
863882
tokio::time::pause();
864883
let raw = stalling_stream();
865884
let drain_task = tokio::spawn(async move {
866-
drain(wrap_streamed_assistant(raw, Some(Duration::from_secs(5)))).await
885+
drain(wrap_streamed_assistant(
886+
raw,
887+
Some(Duration::from_secs(5)),
888+
None,
889+
))
890+
.await
867891
});
868892
tokio::time::advance(Duration::from_secs(10)).await;
869893
let events = drain_task.await.unwrap();
@@ -887,6 +911,66 @@ mod tests {
887911
);
888912
}
889913

914+
/// R3 regression: AbortSignal cancellation between chunks
915+
/// produces an Error event and stops the stream. Earlier
916+
/// versions silently ignored opts.signal at the rig
917+
/// adapter level — mid-stream cancel had no effect until
918+
/// the next turn boundary.
919+
#[tokio::test]
920+
async fn signal_cancels_stream_mid_flight() {
921+
use crate::agent::agent_loop::tool::AbortSignal;
922+
let raw = raw_stream(vec![
923+
Ok(StreamedAssistantContent::Text(Text {
924+
text: "first".to_string(),
925+
})),
926+
Ok(StreamedAssistantContent::Text(Text {
927+
text: " second".to_string(),
928+
})),
929+
]);
930+
let signal = AbortSignal::new();
931+
signal.cancel();
932+
let events = drain(wrap_streamed_assistant(raw, None, Some(signal))).await;
933+
// Pre-loop signal check fires before the first chunk
934+
// poll. Expect: Start, Error (no Text deltas).
935+
let kinds: Vec<&str> = events
936+
.iter()
937+
.map(|e| match e {
938+
StreamEvent::Start { .. } => "start",
939+
StreamEvent::Delta { .. } => "delta",
940+
StreamEvent::Done { .. } => "done",
941+
StreamEvent::Error { .. } => "error",
942+
})
943+
.collect();
944+
assert_eq!(kinds, vec!["start", "error"]);
945+
match events.last().unwrap() {
946+
StreamEvent::Error { error } => {
947+
assert!(
948+
error.contains("aborted"),
949+
"expected 'aborted' in error message; got: {error}"
950+
);
951+
}
952+
_ => panic!("expected Error last"),
953+
}
954+
}
955+
956+
/// R3: signal=None means the cancellation check is skipped.
957+
/// Pre-R3 behavior preserved when callers don't supply a
958+
/// signal (e.g. ad-hoc tests).
959+
#[tokio::test]
960+
async fn signal_none_does_not_affect_stream() {
961+
let raw = raw_stream(vec![Ok(StreamedAssistantContent::Text(Text {
962+
text: "ok".to_string(),
963+
}))]);
964+
let events = drain(wrap_streamed_assistant(raw, None, None)).await;
965+
// Normal completion — no Error.
966+
assert!(events.iter().any(|e| matches!(e, StreamEvent::Done { .. })));
967+
assert!(
968+
!events
969+
.iter()
970+
.any(|e| matches!(e, StreamEvent::Error { .. }))
971+
);
972+
}
973+
890974
/// Fast stream + tight timeout still completes normally —
891975
/// timeout only fires when a chunk takes longer than the
892976
/// deadline, not when the whole stream does. (Per-chunk
@@ -906,6 +990,7 @@ mod tests {
906990
let events = drain(wrap_streamed_assistant(
907991
raw,
908992
Some(Duration::from_millis(10)),
993+
None,
909994
))
910995
.await;
911996
assert!(events.iter().any(|e| matches!(e, StreamEvent::Done { .. })));

src/agent/agent_loop/rig_stream_factory.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ where
191191
// 4. Call model.stream; wrap result or emit error.
192192
match model.stream(request).await {
193193
Ok(response) => {
194-
let mut wrapped = wrap_rig_stream(response, chunk_timeout);
194+
let mut wrapped = wrap_rig_stream(response, chunk_timeout, Some(opts.signal.clone()));
195195
use futures::stream::StreamExt;
196196
while let Some(evt) = wrapped.next().await {
197197
yield evt;

0 commit comments

Comments
 (0)