Skip to content

Commit 77f2f33

Browse files
authored
Merge pull request #78 from altaidevorg/feat/typed-agent-steering
feat: add exact-run agent steering
2 parents 2e93e06 + 6500010 commit 77f2f33

5 files changed

Lines changed: 275 additions & 1 deletion

File tree

src/agent/mod.rs

Lines changed: 258 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1153,6 +1153,53 @@ struct ReasoningSpawnArgs {
11531153
struct ActiveRunHandle {
11541154
run_id: String,
11551155
token: Arc<tokio_util::sync::CancellationToken>,
1156+
steering: Arc<Mutex<SteeringInbox>>,
1157+
}
1158+
1159+
pub(crate) struct SteeringInbox {
1160+
accepting: bool,
1161+
pending: VecDeque<String>,
1162+
}
1163+
1164+
impl SteeringInbox {
1165+
pub(crate) fn open() -> Self {
1166+
Self {
1167+
accepting: true,
1168+
pending: VecDeque::new(),
1169+
}
1170+
}
1171+
1172+
fn push(&mut self, content: String) -> bool {
1173+
if !self.accepting {
1174+
return false;
1175+
}
1176+
self.pending.push_back(content);
1177+
true
1178+
}
1179+
1180+
fn drain(&mut self) -> Vec<String> {
1181+
self.pending.drain(..).collect()
1182+
}
1183+
1184+
fn close(&mut self) {
1185+
self.accepting = false;
1186+
self.pending.clear();
1187+
}
1188+
1189+
fn close_or_drain(&mut self) -> Vec<String> {
1190+
if self.pending.is_empty() {
1191+
self.accepting = false;
1192+
Vec::new()
1193+
} else {
1194+
self.drain()
1195+
}
1196+
}
1197+
}
1198+
1199+
fn steering_guard(inbox: &Mutex<SteeringInbox>) -> std::sync::MutexGuard<'_, SteeringInbox> {
1200+
inbox
1201+
.lock()
1202+
.unwrap_or_else(|poisoned| poisoned.into_inner())
11561203
}
11571204

11581205
fn send_background_job_notification(
@@ -1317,11 +1364,13 @@ fn spawn_main_chat_reasoning_turn(
13171364
) {
13181365
let chat_id = inbound.chat_id.clone();
13191366
let cancel_token = Arc::new(tokio_util::sync::CancellationToken::new());
1367+
let steering = Arc::new(Mutex::new(SteeringInbox::open()));
13201368
args.cancellation_tokens.insert(
13211369
chat_id.clone(),
13221370
ActiveRunHandle {
13231371
run_id: run_id.clone(),
13241372
token: cancel_token.clone(),
1373+
steering: steering.clone(),
13251374
},
13261375
);
13271376

@@ -1435,6 +1484,7 @@ fn spawn_main_chat_reasoning_turn(
14351484
logger_tx: logger_tx.clone(),
14361485
inbound,
14371486
run_id: run_id.clone(),
1487+
steering,
14381488
cancel_token: task_token_arc.as_ref().clone(),
14391489
clarification_hub,
14401490
tool_exec_ctx,
@@ -1667,6 +1717,7 @@ pub(crate) struct ReasoningLoopCtx {
16671717
pub(crate) logger_tx: LoggerHandle,
16681718
pub(crate) inbound: crate::bus::InboundMessage,
16691719
pub(crate) run_id: String,
1720+
pub(crate) steering: Arc<Mutex<SteeringInbox>>,
16701721
pub(crate) cancel_token: tokio_util::sync::CancellationToken,
16711722
pub(crate) clarification_hub: Arc<ClarificationHub>,
16721723
pub(crate) tool_exec_ctx: ToolExecCtx,
@@ -2009,6 +2060,7 @@ impl AgentLogic {
20092060
harness.cancel_children_for_parent(chat_id);
20102061
}
20112062
}
2063+
steering_guard(&active.steering).close();
20122064
active.token.cancel();
20132065
let _ = self.logger_tx.send(BusMessage::Log(
20142066
LogEvent::info(
@@ -2046,6 +2098,21 @@ impl ActorLogic<BusMessage> for AgentLogic {
20462098
self.cancel_active_run(&chat_id, Some(&run_id));
20472099
return Ok(None);
20482100
}
2101+
BusMessage::Steer {
2102+
chat_id,
2103+
run_id,
2104+
content,
2105+
} => {
2106+
if content.trim().is_empty() {
2107+
return Ok(None);
2108+
}
2109+
if let Some(active) = self.cancellation_tokens.get(&chat_id) {
2110+
if active.run_id == run_id {
2111+
steering_guard(&active.steering).push(content);
2112+
}
2113+
}
2114+
return Ok(None);
2115+
}
20492116
BusMessage::SwitchModel {
20502117
provider_name,
20512118
model_name,
@@ -2932,6 +2999,7 @@ impl AgentLogic {
29322999
logger_tx,
29333000
inbound,
29343001
run_id: _run_id,
3002+
steering,
29353003
cancel_token,
29363004
clarification_hub,
29373005
tool_exec_ctx,
@@ -3125,6 +3193,20 @@ impl AgentLogic {
31253193
}
31263194
iterations += 1;
31273195

3196+
// Tool paths return to the loop only after their result has been
3197+
// persisted. Consume steering before this next iteration performs
3198+
// compaction, calls another tool, or calls the provider.
3199+
let pending_steering = steering_guard(&steering).drain();
3200+
if !pending_steering.is_empty() {
3201+
for content in pending_steering {
3202+
mem.add_message(crate::utils::ChatMessage::user(&content))
3203+
.await
3204+
.map_err(ReasoningLoopError::persistence)?;
3205+
}
3206+
consecutive_doom_detections = 0;
3207+
forbid_final_nudges = 0;
3208+
}
3209+
31283210
let _ = logger_tx.send(BusMessage::Log(
31293211
LogEvent::debug(
31303212
&name,
@@ -3539,6 +3621,21 @@ impl AgentLogic {
35393621
LogEvent::debug(&name, "Provider responded.").with_chat_id(&inbound.chat_id),
35403622
));
35413623

3624+
// A steering request is consumed only at a safe boundary: the
3625+
// provider has returned, but its proposed response has not yet
3626+
// been persisted or allowed to start another tool call.
3627+
let pending_steering = steering_guard(&steering).drain();
3628+
if !pending_steering.is_empty() {
3629+
for content in pending_steering {
3630+
mem.add_message(crate::utils::ChatMessage::user(&content))
3631+
.await
3632+
.map_err(ReasoningLoopError::persistence)?;
3633+
}
3634+
consecutive_doom_detections = 0;
3635+
forbid_final_nudges = 0;
3636+
continue;
3637+
}
3638+
35423639
// Log USAGE telemetry
35433640
if let Some(usage) = &response.usage {
35443641
// Remember the exact server-counted input size for the compaction trigger.
@@ -3906,6 +4003,22 @@ impl AgentLogic {
39064003
.map_err(ReasoningLoopError::persistence)?;
39074004
continue;
39084005
}
4006+
4007+
// Atomically close steering acceptance before committing the
4008+
// final response. A request racing this boundary is therefore
4009+
// either drained and incorporated, or rejected by `push`; it
4010+
// can never be acknowledged into a stale next-run inbox.
4011+
let final_steering = steering_guard(&steering).close_or_drain();
4012+
if !final_steering.is_empty() {
4013+
for content in final_steering {
4014+
mem.add_message(crate::utils::ChatMessage::user(&content))
4015+
.await
4016+
.map_err(ReasoningLoopError::persistence)?;
4017+
}
4018+
consecutive_doom_detections = 0;
4019+
forbid_final_nudges = 0;
4020+
continue;
4021+
}
39094022
// Final outbound text
39104023
let final_response = thinking_strip_re
39114024
.replace_all(&response_text, "")
@@ -4155,7 +4268,10 @@ impl Tool for LoadSkillTool {
41554268

41564269
#[cfg(test)]
41574270
mod tests {
4158-
use super::{AgentLogic, AgentLogicParams, ReasoningLoopCtx, ReasoningLoopExit};
4271+
use super::{
4272+
steering_guard, AgentLogic, AgentLogicParams, ReasoningLoopCtx, ReasoningLoopExit,
4273+
SteeringInbox,
4274+
};
41594275
use async_trait::async_trait;
41604276
use axum::{
41614277
body::Body,
@@ -4719,6 +4835,7 @@ mod tests {
47194835
logger_tx,
47204836
inbound,
47214837
run_id: "test-run-id".to_string(),
4838+
steering: Arc::new(Mutex::new(SteeringInbox::open())),
47224839
cancel_token: cancel_token.clone(),
47234840
clarification_hub: ClarificationHub::shared(),
47244841
tool_exec_ctx: ToolExecCtx::new("terminal", "loop-test-chat", None)
@@ -5508,6 +5625,146 @@ mod tests {
55085625
));
55095626
}
55105627

5628+
#[tokio::test]
5629+
async fn steer_is_accepted_only_for_the_exact_active_run() {
5630+
let (mut agent, mut outbound_rx) = build_agent_with_provider(Box::new(LongSleepProvider {
5631+
calls: Arc::new(AtomicUsize::new(0)),
5632+
}));
5633+
let chat_id = "steer-run-chat";
5634+
agent
5635+
.process(BusMessage::Inbound(test_inbound(chat_id, "first")))
5636+
.await
5637+
.expect("start run");
5638+
let run_id = loop {
5639+
match outbound_rx.recv().await {
5640+
Some(BusMessage::RunLifecycle(RunLifecycleEvent::Started { run_id, .. })) => {
5641+
break run_id
5642+
}
5643+
Some(_) => continue,
5644+
None => panic!("outbound channel closed before start"),
5645+
}
5646+
};
5647+
5648+
agent
5649+
.process(BusMessage::Steer {
5650+
chat_id: chat_id.to_string(),
5651+
run_id: "stale-run".to_string(),
5652+
content: "ignore this".to_string(),
5653+
})
5654+
.await
5655+
.expect("stale steer is handled");
5656+
{
5657+
let active = agent.cancellation_tokens.get(chat_id).expect("active run");
5658+
assert!(steering_guard(&active.steering).pending.is_empty());
5659+
}
5660+
5661+
agent
5662+
.process(BusMessage::Steer {
5663+
chat_id: chat_id.to_string(),
5664+
run_id: run_id.clone(),
5665+
content: "change direction".to_string(),
5666+
})
5667+
.await
5668+
.expect("exact steer is handled");
5669+
{
5670+
let active = agent.cancellation_tokens.get(chat_id).expect("active run");
5671+
let inbox = steering_guard(&active.steering);
5672+
assert_eq!(
5673+
inbox.pending.front().map(String::as_str),
5674+
Some("change direction")
5675+
);
5676+
}
5677+
5678+
agent
5679+
.process(BusMessage::CancelRun {
5680+
chat_id: chat_id.to_string(),
5681+
run_id,
5682+
})
5683+
.await
5684+
.expect("cancel test run");
5685+
}
5686+
5687+
#[test]
5688+
fn steering_final_boundary_is_atomic_and_never_leaks_to_a_later_run() {
5689+
let mut inbox = SteeringInbox::open();
5690+
assert!(inbox.push("first revision".to_string()));
5691+
assert_eq!(inbox.close_or_drain(), vec!["first revision"]);
5692+
assert!(inbox.accepting, "draining a revision keeps this run open");
5693+
5694+
assert!(inbox.close_or_drain().is_empty());
5695+
assert!(!inbox.accepting, "empty final boundary closes acceptance");
5696+
assert!(!inbox.push("too late".to_string()));
5697+
assert!(inbox.pending.is_empty());
5698+
}
5699+
5700+
#[tokio::test]
5701+
async fn steering_at_provider_boundary_is_persisted_before_the_next_response() {
5702+
let (unblock_tx, unblock_rx) = tokio::sync::oneshot::channel();
5703+
let calls = Arc::new(AtomicUsize::new(0));
5704+
let provider = GateFirstChatProvider {
5705+
calls: calls.clone(),
5706+
first_unblock: Arc::new(tokio::sync::Mutex::new(Some(unblock_rx))),
5707+
};
5708+
let (mut agent, mut outbound_rx) = build_agent_with_provider(Box::new(provider));
5709+
let chat_id = "steer-provider-boundary";
5710+
agent
5711+
.process(BusMessage::Inbound(test_inbound(
5712+
chat_id,
5713+
"original request",
5714+
)))
5715+
.await
5716+
.expect("start run");
5717+
let run_id = loop {
5718+
match outbound_rx.recv().await {
5719+
Some(BusMessage::RunLifecycle(RunLifecycleEvent::Started { run_id, .. })) => {
5720+
break run_id
5721+
}
5722+
Some(_) => continue,
5723+
None => panic!("outbound channel closed before start"),
5724+
}
5725+
};
5726+
agent
5727+
.process(BusMessage::Steer {
5728+
chat_id: chat_id.to_string(),
5729+
run_id,
5730+
content: "use the revised direction".to_string(),
5731+
})
5732+
.await
5733+
.expect("queue steering");
5734+
unblock_tx
5735+
.send(())
5736+
.expect("release first provider response");
5737+
loop {
5738+
match outbound_rx.recv().await {
5739+
Some(BusMessage::RunLifecycle(RunLifecycleEvent::Terminated { .. })) => break,
5740+
Some(_) => continue,
5741+
None => panic!("outbound channel closed before terminal"),
5742+
}
5743+
}
5744+
assert_eq!(calls.load(Ordering::SeqCst), 2);
5745+
let session_key = clarification_session_key("terminal", chat_id, None);
5746+
let session = agent
5747+
.session_manager
5748+
.get_session(&session_key)
5749+
.await
5750+
.expect("session");
5751+
let context = session.get_context().await.expect("context");
5752+
let text: Vec<_> = context
5753+
.iter()
5754+
.map(|m| {
5755+
m.content
5756+
.as_ref()
5757+
.map(|content| content.text_content())
5758+
.unwrap_or_default()
5759+
})
5760+
.collect();
5761+
assert!(text
5762+
.iter()
5763+
.any(|value| value == "use the revised direction"));
5764+
assert!(text.iter().any(|value| value == "ok-1"));
5765+
assert!(!text.iter().any(|value| value == "ok-0"));
5766+
}
5767+
55115768
#[tokio::test]
55125769
async fn clarification_inbound_routes_via_hub_before_reasoning_spawn() {
55135770
let hub = Arc::new(ClarificationHub::new());

src/agent/subagent.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,7 @@ impl SubagentHarness {
574574
logger_tx: self.inner.deps.logger_tx.clone(),
575575
inbound,
576576
run_id: format!("subagent-{task_id}"),
577+
steering: std::sync::Arc::new(std::sync::Mutex::new(super::SteeringInbox::open())),
577578
cancel_token: task_cancel.clone(),
578579
clarification_hub: self.inner.deps.clarification_hub.clone(),
579580
tool_exec_ctx,

src/bus.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,13 @@ pub enum BusMessage {
658658
chat_id: String,
659659
run_id: String,
660660
},
661+
/// Apply new user direction to one exact active run at its next safe
662+
/// boundary, after the provider or current tool call completes.
663+
Steer {
664+
chat_id: String,
665+
run_id: String,
666+
content: String,
667+
},
661668
/// Signal to promote the current in-flight synchronous tool call (if any) to
662669
/// a background `ExecutionJobManager` job for the given chat. Triggered by
663670
/// the `/background` slash command.

src/logging.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ impl LoggingActor {
240240
BusMessage::LoggerControl(_) => return,
241241
BusMessage::Cancel(_) => return,
242242
BusMessage::CancelRun { .. } => return,
243+
BusMessage::Steer { .. } => return,
243244
BusMessage::PromoteSyncToBackground(_) => return,
244245
BusMessage::SetTerminalSessionChat { .. } => return,
245246
BusMessage::SwitchModel { .. } => return,
@@ -339,6 +340,13 @@ impl LoggingActor {
339340
),
340341
)
341342
.with_chat_id(chat_id),
343+
BusMessage::Steer {
344+
chat_id, run_id, ..
345+
} => LogEvent::info(
346+
"BusMessage",
347+
&format!("Steer active run for chat_id={} run_id={}", chat_id, run_id),
348+
)
349+
.with_chat_id(chat_id),
342350
BusMessage::PromoteSyncToBackground(chat_id) => LogEvent::info(
343351
"BusMessage",
344352
&format!("PromoteSyncToBackground requested for chat_id={}", chat_id),

0 commit comments

Comments
 (0)