已合并
feat(core): concurrent tool batch, verified finish, plan on control path #193
RomanAndr创建于 6月14日
feat(core): concurrent tool batch, verified finish, plan on control path #193
已合并
RomanAndr创建于 6月14日
11 个文件变更+848-93
MCargo.lock+38-0
@@ -1700,10 +1700,12 @@ dependencies = [
1700 "reqwest",1700 "reqwest",
1701 "serde",1701 "serde",
1702 "serde_json",1702 "serde_json",
1703+ "temp-env",
1703 "tempfile",1704 "tempfile",
1704 "thiserror 1.0.69",1705 "thiserror 1.0.69",
1705 "tokio",1706 "tokio",
1706 "tokio-util",1707 "tokio-util",
1708+ "toml",
1707 "tracing",1709 "tracing",
1708 "uuid",1710 "uuid",
1709]1711]
@@ -3232,6 +3234,12 @@ version = "1.15.1"
3232source = "registry+https://github.com/rust-lang/crates.io-index"3234source = "registry+https://github.com/rust-lang/crates.io-index"
3233checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"3235checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
3234 3236 
3237+[[package]]
3238+name = "smawk"
3239+version = "0.3.2"
3240+source = "registry+https://github.com/rust-lang/crates.io-index"
3241+checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
3242+ 
3235[[package]]3243[[package]]
3236name = "socket2"3244name = "socket2"
3237version = "0.5.10"3245version = "0.5.10"
@@ -3389,6 +3397,15 @@ dependencies = [
3389 "windows",3397 "windows",
3390]3398]
3391 3399 
3400+[[package]]
3401+name = "temp-env"
3402+version = "0.3.6"
3403+source = "registry+https://github.com/rust-lang/crates.io-index"
3404+checksum = "96374855068f47402c3121c6eed88d29cb1de8f3ab27090e273e420bdabcf050"
3405+dependencies = [
3406+ "parking_lot",
3407+]
3408+ 
3392[[package]]3409[[package]]
3393name = "tempfile"3410name = "tempfile"
3394version = "3.27.0"3411version = "3.27.0"
@@ -3412,6 +3429,17 @@ dependencies = [
3412 "utf-8",3429 "utf-8",
3413]3430]
3414 3431 
3432+[[package]]
3433+name = "textwrap"
3434+version = "0.16.2"
3435+source = "registry+https://github.com/rust-lang/crates.io-index"
3436+checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
3437+dependencies = [
3438+ "smawk",
3439+ "unicode-linebreak",
3440+ "unicode-width 0.2.2",
3441+]
3442+ 
3415[[package]]3443[[package]]
3416name = "thiserror"3444name = "thiserror"
3417version = "1.0.69"3445version = "1.0.69"
@@ -3694,9 +3722,11 @@ dependencies = [
3694 "htmd",3722 "htmd",
3695 "html2text",3723 "html2text",
3696 "image",3724 "image",
3725+ "lazy_static",
3697 "lopdf",3726 "lopdf",
3698 "lsp",3727 "lsp",
3699 "operation_backend",3728 "operation_backend",
3729+ "regex",
3700 "reqwest",3730 "reqwest",
3701 "rustls-native-certs",3731 "rustls-native-certs",
3702 "serde",3732 "serde",
@@ -3949,6 +3979,12 @@ version = "1.0.24"
3949source = "registry+https://github.com/rust-lang/crates.io-index"3979source = "registry+https://github.com/rust-lang/crates.io-index"
3950checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"3980checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
3951 3981 
3982+[[package]]
3983+name = "unicode-linebreak"
3984+version = "0.1.5"
3985+source = "registry+https://github.com/rust-lang/crates.io-index"
3986+checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
3987+ 
3952[[package]]3988[[package]]
3953name = "unicode-segmentation"3989name = "unicode-segmentation"
3954version = "1.13.2"3990version = "1.13.2"
@@ -4814,6 +4850,7 @@ dependencies = [
4814 "subagent",4850 "subagent",
4815 "sysinfo",4851 "sysinfo",
4816 "tempfile",4852 "tempfile",
4853+ "textwrap",
4817 "thiserror 1.0.69",4854 "thiserror 1.0.69",
4818 "tokio",4855 "tokio",
4819 "tokio-stream",4856 "tokio-stream",
@@ -4845,6 +4882,7 @@ dependencies = [
4845 "arc-swap",4882 "arc-swap",
4846 "async-trait",4883 "async-trait",
4847 "compact",4884 "compact",
4885+ "futures-util",
4848 "llm-client",4886 "llm-client",
4849 "parking_lot",4887 "parking_lot",
4850 "reqwest",4888 "reqwest",
Mapps/xiaoo-app/src/gateway/session_supervisor.rs+64-40
@@ -409,14 +409,7 @@ impl SessionSupervisor {
409 .await?;409 .await?;
410 return Ok(terminal);410 return Ok(terminal);
411 }411 }
412- LoopRunResult::Suspended(suspended_call) => {412+ LoopRunResult::Suspended(suspended_calls) => {
413- let join_id = suspended_join_id(&suspended_call)?;
414- let receiver = self.take_join_receiver(&join_id).await?;
415- let terminal = receiver.await.map_err(|_| SessionServiceError::CoreRun {
416- message: format!("pending join receiver dropped before wake: {join_id}"),
417- })?;
418- self.remove_pending_join(&join_id).await;
419- 
420 let mut resumed_loop_state =413 let mut resumed_loop_state =
421 loop_state414 loop_state
422 .clone()415 .clone()
@@ -426,42 +419,48 @@ impl SessionSupervisor {
426 input.agent_id419 input.agent_id
427 ),420 ),
428 })?;421 })?;
429- let tool_result_msg =
430- build_join_tool_result_message(&suspended_call, terminal.clone())?;
431 422 
432- let resolved_call_id = &suspended_call.final_call.call_id;423+ for suspended_call in &suspended_calls {
433- if let Some(last_msg) = resumed_loop_state.messages.last_mut() {424+ let join_id = suspended_join_id(suspended_call)?;
434- if matches!(last_msg.role, agent_types::llm::MessageRole::Assistant) {425+ let receiver = self.take_join_receiver(&join_id).await?;
435- last_msg.blocks.retain(|b| match b {426+ let terminal =
436- agent_types::llm::ContentBlock::ToolUse { call_id, .. } => {427+ receiver.await.map_err(|_| SessionServiceError::CoreRun {
437- call_id == resolved_call_id428+ message: format!(
438- }429+ "pending join receiver dropped before wake: {join_id}"
439- _ => true,430+ ),
440- });431+ })?;
432+ self.remove_pending_join(&join_id).await;
433+ 
434+ let tool_result_msg =
435+ build_join_tool_result_message(suspended_call, terminal.clone())?;
436+ resumed_loop_state.messages.push(tool_result_msg);
437+ 
438+ if let Some(sink) = loop_event_sink.as_ref() {
439+ let output_preview = serde_json::to_string(
440+ &serde_json::json!({ "terminal": terminal }),
441+ )
442+ .unwrap_or_default();
443+ let is_error =
444+ terminal.status == subagent::SubagentTerminalKind::Failed;
445+ sink.on_tool_result(
446+ &input.agent_id,
447+ &agent_types::events::ToolResultEvent {
448+ call_id: suspended_call.final_call.call_id.clone(),
449+ tool_name: suspended_call.final_call.tool_name.clone(),
450+ output_preview,
451+ is_error,
452+ args_preview: serde_json::to_string_pretty(
453+ &suspended_call.final_call.input,
454+ )
455+ .unwrap_or_else(|_| {
456+ suspended_call.final_call.input.to_string()
457+ }),
458+ },
459+ );
441 }460 }
442 }461 }
443 462 
444- resumed_loop_state.messages.push(tool_result_msg.clone());463+ drop_unanswered_tool_uses(&mut resumed_loop_state.messages);
445- 
446- if let Some(sink) = loop_event_sink.as_ref() {
447- let output_preview =
448- serde_json::to_string(&serde_json::json!({ "terminal": terminal }))
449- .unwrap_or_default();
450- let is_error = terminal.status == subagent::SubagentTerminalKind::Failed;
451- sink.on_tool_result(
452- &input.agent_id,
453- &agent_types::events::ToolResultEvent {
454- call_id: suspended_call.final_call.call_id.clone(),
455- tool_name: suspended_call.final_call.tool_name.clone(),
456- output_preview,
457- is_error,
458- args_preview: serde_json::to_string_pretty(
459- &suspended_call.final_call.input,
460- )
461- .unwrap_or_else(|_| suspended_call.final_call.input.to_string()),
462- },
463- );
464- }
465 464 
466 loop_state = Some(resumed_loop_state.clone());465 loop_state = Some(resumed_loop_state.clone());
467 self.persist_lane_state(466 self.persist_lane_state(
@@ -942,6 +941,31 @@ fn terminal_from_outcome(
942 }941 }
943}942}
944 943 
944+/// Remove assistant `ToolUse` blocks whose `call_id` has no matching `ToolResult`
945+/// anywhere in the history, so a resumed conversation never sends a dangling
946+/// tool_use (which providers reject). After every suspended call of a turn is
947+/// resolved this is a no-op; it only fires for a sibling stranded by a stop
948+/// short-circuit in the same batch.
949+fn drop_unanswered_tool_uses(messages: &mut [agent_types::ChatMessage]) {
950+ use agent_types::llm::{ContentBlock, MessageRole};
951+ let answered: std::collections::HashSet<String> = messages
952+ .iter()
953+ .flat_map(|m| m.blocks.iter())
954+ .filter_map(|b| match b {
955+ ContentBlock::ToolResult { call_id, .. } => Some(call_id.clone()),
956+ _ => None,
957+ })
958+ .collect();
959+ for message in messages.iter_mut() {
960+ if matches!(message.role, MessageRole::Assistant) {
961+ message.blocks.retain(|b| match b {
962+ ContentBlock::ToolUse { call_id, .. } => answered.contains(call_id),
963+ _ => true,
964+ });
965+ }
966+ }
967+}
968+ 
945fn suspended_join_id(suspended_call: &SuspendedToolCall) -> Result<String, SessionServiceError> {969fn suspended_join_id(suspended_call: &SuspendedToolCall) -> Result<String, SessionServiceError> {
946 match &suspended_call.reason {970 match &suspended_call.reason {
947 LoopSuspendReason::ToolCall {971 LoopSuspendReason::ToolCall {
Mcrates/core/Cargo.toml+1-0
@@ -13,6 +13,7 @@ llm-client = { path = "../llm-client" }
13tokio = { workspace = true, features = ["rt", "time", "macros"] }13tokio = { workspace = true, features = ["rt", "time", "macros"] }
14tokio-util.workspace = true14tokio-util.workspace = true
15async-trait.workspace = true15async-trait.workspace = true
16+futures-util.workspace = true
16uuid.workspace = true17uuid.workspace = true
17serde.workspace = true18serde.workspace = true
18serde_json.workspace = true19serde_json.workspace = true
Mcrates/core/src/agent_loop.rs+689-52
@@ -10,7 +10,7 @@ use agent_types::compression::CompressedView;
10use agent_types::context::prompt::result::PromptBuildResult;10use agent_types::context::prompt::result::PromptBuildResult;
11use agent_types::events::ToolResultEvent;11use agent_types::events::ToolResultEvent;
12use agent_types::outcome::{AgentError, AgentOutcome};12use agent_types::outcome::{AgentError, AgentOutcome};
13-use agent_types::tool::{RawToolCall, RawToolOutcome, ToolExecutionResult};13+use agent_types::tool::{EffectProfile, RawToolCall, RawToolOutcome, ToolExecutionResult};
14use agent_types::{14use agent_types::{
15 AssistantMessage, ChatMessage, ContentBlock, LlmError, MessageRole, StreamChunk, ToolUseBlock,15 AssistantMessage, ChatMessage, ContentBlock, LlmError, MessageRole, StreamChunk, ToolUseBlock,
16};16};
@@ -177,8 +177,8 @@ pub async fn run_agent_loop(
177 return Err(error);177 return Err(error);
178 }178 }
179 update_turn_span_after_llm(&mut ctx).await;179 update_turn_span_after_llm(&mut ctx).await;
180- let suspended_call = match tool_exec(&mut ctx).await {180+ let suspended_calls = match tool_exec(&mut ctx).await {
181- Ok(suspended_call) => suspended_call,181+ Ok(suspended_calls) => suspended_calls,
182 Err(error) => {182 Err(error) => {
183 end_turn_span(183 end_turn_span(
184 &mut ctx,184 &mut ctx,
@@ -196,7 +196,7 @@ pub async fn run_agent_loop(
196 return Err(error);196 return Err(error);
197 }197 }
198 };198 };
199- if let Some(suspended_call) = suspended_call {199+ if !suspended_calls.is_empty() {
200 end_turn_span(200 end_turn_span(
201 &mut ctx,201 &mut ctx,
202 TraceOutcome::Ok,202 TraceOutcome::Ok,
@@ -211,7 +211,7 @@ pub async fn run_agent_loop(
211 "suspended",211 "suspended",
212 )212 )
213 .await;213 .await;
214- return Ok(LoopRunResult::Suspended(suspended_call));214+ return Ok(LoopRunResult::Suspended(suspended_calls));
215 }215 }
216 decide(&mut ctx);216 decide(&mut ctx);
217 217 
@@ -705,6 +705,92 @@ fn microcompact(ctx: &mut LoopContext<'_>) {
705 }705 }
706}706}
707 707 
708+fn prune_stale_tool_output(messages: &mut [ChatMessage]) {
709+ const KEEP_RECENT_TOOL_BYTES: usize = 40_000;
710+ const MIN_PRUNABLE_BYTES: usize = 1_000;
711+ const PRUNED_MARKER: &str =
712+ "[older tool output pruned to save context — re-run the tool or read the file if you still need it]";
713+ let mut kept = 0usize;
714+ let mut pruned = 0usize;
715+ for message in messages.iter_mut().rev() {
716+ for block in message.blocks.iter_mut() {
717+ if let ContentBlock::ToolResult { output, .. } = block {
718+ if output.as_str() == PRUNED_MARKER {
719+ continue;
720+ }
721+ if kept < KEEP_RECENT_TOOL_BYTES {
722+ kept += output.len();
723+ } else if output.len() > MIN_PRUNABLE_BYTES {
724+ *output = PRUNED_MARKER.to_string();
725+ pruned += 1;
726+ }
727+ }
728+ }
729+ }
730+ if pruned > 0 {
731+ tracing::debug!(pruned, "pruned stale tool output beyond recent window");
732+ }
733+}
734+ 
735+/// Per-turn dynamic context re-injected into the system prompt: the remaining
736+/// horizon and the live `todo_write` plan. Rendered into the volatile tail of
737+/// the system message (see `prompt::compose`), after the cache-stable prefix.
738+fn live_context_snippets(
739+ ctx: &LoopContext<'_>,
740+) -> Vec<agent_types::context::prompt::MemorySnippet> {
741+ use agent_types::context::prompt::MemorySnippet;
742+ let mut snippets = Vec::new();
743+ 
744+ let turn = ctx.turn.turn_number;
745+ let max_turns = ctx.snapshot.max_turns;
746+ let tokens_used = ctx.state.token_usage.total_tokens;
747+ let remaining = max_turns.saturating_sub(turn);
748+ if max_turns > 0 && remaining <= 5 {
749+ let horizon = format!(
750+ "- turn: {turn}/{max_turns} ({remaining} remaining)\n- tokens used so far: ~{tokens_used}\n- NEARING THE TURN LIMIT — stop investigating and converge now: apply your best fix, save the files, and finish this turn. A committed partial fix beats an unfinished exploration that gets cut off."
751+ );
752+ snippets.push(MemorySnippet {
753+ source: "horizon".to_string(),
754+ content: horizon,
755+ relevance_score: 1.0,
756+ });
757+ }
758+ 
759+ let window = ctx.snapshot.token_budget_config.total_budget;
760+ let context_input = ctx.state.token_usage.prompt_tokens;
761+ if window > 0 {
762+ let pct = context_input.saturating_mul(100) / window;
763+ if pct >= 25 {
764+ let mut line =
765+ format!("- context window: ~{pct}% used ({context_input}/{window} input tokens)");
766+ if pct >= 75 {
767+ line.push_str(
768+ " — running full; converge and finish before the earliest context is compacted away.",
769+ );
770+ }
771+ snippets.push(MemorySnippet {
772+ source: "budget".to_string(),
773+ content: line,
774+ relevance_score: 0.95,
775+ });
776+ }
777+ }
778+ 
779+ // Active plan: open `todo_write` items for this session, re-injected every
780+ // turn so plan state is load-bearing rather than write-only.
781+ if let Some(runtime_view) = ctx.input.runtime_view.as_ref() {
782+ for line in tool::open_todo_lines(runtime_view.as_ref()) {
783+ snippets.push(MemorySnippet {
784+ source: "plan".to_string(),
785+ content: line,
786+ relevance_score: 0.9,
787+ });
788+ }
789+ }
790+ 
791+ snippets
792+}
793+ 
708async fn build_messages(ctx: &mut LoopContext<'_>) -> Result<(), AgentError> {794async fn build_messages(ctx: &mut LoopContext<'_>) -> Result<(), AgentError> {
709 let skill_summaries = ctx.snapshot.skill_registry.list_skills();795 let skill_summaries = ctx.snapshot.skill_registry.list_skills();
710 796 
@@ -733,12 +819,23 @@ async fn build_messages(ctx: &mut LoopContext<'_>) -> Result<(), AgentError> {
733 None819 None
734 };820 };
735 821 
822+ let is_final_turn =
823+ ctx.snapshot.max_turns > 0 && ctx.turn.turn_number >= ctx.snapshot.max_turns;
824+ let visible_tools = if is_final_turn {
825+ Vec::new()
826+ } else {
827+ ctx.input.visible_tools.clone()
828+ };
829+ 
830+ let mut projected_messages = ctx.state.messages.read().clone();
831+ prune_stale_tool_output(&mut projected_messages);
832+ 
736 let input = PromptBuildInput {833 let input = PromptBuildInput {
737 system_prompt: ctx.snapshot.system_prompt.to_string(),834 system_prompt: ctx.snapshot.system_prompt.to_string(),
738- messages: ctx.state.messages.read().clone(),835+ messages: projected_messages,
739- visible_tools: ctx.input.visible_tools.clone(),836+ visible_tools,
740 skill_summaries,837 skill_summaries,
741- memory_snippets: Vec::new(),838+ memory_snippets: live_context_snippets(ctx),
742 environment: agent_types::context::prompt::EnvironmentInfo {839 environment: agent_types::context::prompt::EnvironmentInfo {
743 model: String::new(),840 model: String::new(),
744 cwd: String::new(),841 cwd: String::new(),
@@ -1018,7 +1115,11 @@ const TRANSIENT_MAX_DELAY_MS: u64 = 60_000;
1018fn is_transient(error: &LlmError) -> bool {1115fn is_transient(error: &LlmError) -> bool {
1019 matches!(1116 matches!(
1020 error,1117 error,
1021- LlmError::RateLimited { .. } | LlmError::HttpError(_) | LlmError::Timeout1118+ LlmError::RateLimited { .. }
1119+ | LlmError::HttpError(_)
1120+ | LlmError::Timeout
1121+ | LlmError::StreamError { .. }
1122+ | LlmError::IoError(_)
1022 )1123 )
1023}1124}
1024 1125 
@@ -1110,7 +1211,7 @@ fn stream_assistant_chunk(
1110 }1211 }
1111}1212}
1112 1213 
1113-async fn tool_exec(ctx: &mut LoopContext<'_>) -> Result<Option<SuspendedToolCall>, AgentError> {1214+async fn tool_exec(ctx: &mut LoopContext<'_>) -> Result<Vec<SuspendedToolCall>, AgentError> {
1114 let has_tool_calls = ctx1215 let has_tool_calls = ctx
1115 .turn1216 .turn
1116 .assistant_message1217 .assistant_message
@@ -1118,22 +1219,23 @@ async fn tool_exec(ctx: &mut LoopContext<'_>) -> Result<Option<SuspendedToolCall
1118 .map_or(false, |m| m.has_tool_calls());1219 .map_or(false, |m| m.has_tool_calls());
1119 1220 
1120 if ctx.turn.assistant_message.is_none() {1221 if ctx.turn.assistant_message.is_none() {
1121- return Ok(None);1222+ return Ok(Vec::new());
1122 }1223 }
1123 1224 
1124 if !has_tool_calls || !ctx.snapshot.feature_flags.tool_execution {1225 if !has_tool_calls || !ctx.snapshot.feature_flags.tool_execution {
1125 append_assistant_to_history(ctx);1226 append_assistant_to_history(ctx);
1126- return Ok(None);1227+ return Ok(Vec::new());
1127 }1228 }
1128 1229 
1129 if ctx.input.runtime_view.is_none() {1230 if ctx.input.runtime_view.is_none() {
1130 append_assistant_to_history(ctx);1231 append_assistant_to_history(ctx);
1131- return Ok(None);1232+ return Ok(Vec::new());
1132 }1233 }
1133 1234 
1134 // Repair empty call_ids before the validity partition below.1235 // Repair empty call_ids before the validity partition below.
1135 if let Some(msg) = ctx.turn.assistant_message.as_mut() {1236 if let Some(msg) = ctx.turn.assistant_message.as_mut() {
1136 synthesize_missing_call_ids(msg, ctx.state.turn_count);1237 synthesize_missing_call_ids(msg, ctx.state.turn_count);
1238+ repair_tool_names(msg, &ctx.input.visible_tools);
1137 }1239 }
1138 1240 
1139 let tool_calls: Vec<ToolUseBlock> = ctx1241 let tool_calls: Vec<ToolUseBlock> = ctx
@@ -1145,7 +1247,7 @@ async fn tool_exec(ctx: &mut LoopContext<'_>) -> Result<Option<SuspendedToolCall
1145 .clone();1247 .clone();
1146 1248 
1147 if ctx.input.agent_id.is_none() {1249 if ctx.input.agent_id.is_none() {
1148- return Ok(None);1250+ return Ok(Vec::new());
1149 }1251 }
1150 1252 
1151 // Partition tool calls into valid (non-empty call_id + tool_name) and invalid.1253 // Partition tool calls into valid (non-empty call_id + tool_name) and invalid.
@@ -1209,6 +1311,9 @@ async fn tool_exec(ctx: &mut LoopContext<'_>) -> Result<Option<SuspendedToolCall
1209 (valid_calls, invalid_calls)1311 (valid_calls, invalid_calls)
1210 };1312 };
1211 1313 
1314+ let mut valid_calls = valid_calls;
1315+ valid_calls.sort_by_key(|tc| tc.tool_name == "join_subagent");
1316+ 
1212 if let Some(msg) = ctx.turn.assistant_message.as_mut() {1317 if let Some(msg) = ctx.turn.assistant_message.as_mut() {
1213 msg.tool_calls = valid_calls.clone();1318 msg.tool_calls = valid_calls.clone();
1214 }1319 }
@@ -1250,9 +1355,8 @@ async fn tool_exec(ctx: &mut LoopContext<'_>) -> Result<Option<SuspendedToolCall
1250 }1355 }
1251 }1356 }
1252 1357 
1253- // Execute valid tool calls (original logic).1358+ // Pass 1 build every call (borrows ctx for the per-call tool filter).
1254- let runtime_view = ctx.input.runtime_view.as_ref().unwrap();1359+ let mut built = Vec::with_capacity(valid_calls.len());
1255- 
1256 for tc in &valid_calls {1360 for tc in &valid_calls {
1257 let raw_tool_call = RawToolCall {1361 let raw_tool_call = RawToolCall {
1258 call_id: tc.call_id.clone(),1362 call_id: tc.call_id.clone(),
@@ -1270,56 +1374,196 @@ async fn tool_exec(ctx: &mut LoopContext<'_>) -> Result<Option<SuspendedToolCall
1270 ctx.snapshot.tool_registry.as_ref(),1374 ctx.snapshot.tool_registry.as_ref(),
1271 );1375 );
1272 1376 
1273- let tool_call = match ToolCallBuilderImpl::new()1377+ match ToolCallBuilderImpl::new()
1274 .with_raw_llm_tool_call(raw_tool_call)1378 .with_raw_llm_tool_call(raw_tool_call)
1275 .with_tool_filter(per_call_filter)1379 .with_tool_filter(per_call_filter)
1276 .build()1380 .build()
1277 {1381 {
1278- Ok(tool_call) => tool_call,1382+ Ok(tool_call) => built.push(Ok(tool_call)),
1279- Err(error) => {1383+ Err(error) => built.push(Err(build_framework_failed_tool_result(
1280- let result = build_framework_failed_tool_result(1384+ fallback_final_call,
1281- fallback_final_call,1385+ format!("tool call build failed: {error}"),
1282- format!("tool call build failed: {error}"),1386+ ))),
1283- );1387+ }
1284- emit_tool_result_event(ctx, &result);1388+ }
1285- let tool_result_message = build_tool_result_message(&result);
1286- ctx.state.messages.write().push(tool_result_message);
1287- ctx.turn.tool_results.push(result);
1288- continue;
1289- }
1290- };
1291 1389 
1292- let result = match tool_call.execute(&**runtime_view).await {1390+ let serialize_batch = {
1293- Ok(result) => result,1391+ let profiles: std::collections::HashMap<&str, &EffectProfile> = ctx
1294- Err(error) => {1392+ .input
1295- let result =1393+ .visible_tools
1296- build_framework_failed_tool_result(fallback_final_call, error.to_string());1394+ .iter()
1297- emit_tool_result_event(ctx, &result);1395+ .map(|tool| (tool.name().0.as_str(), tool.effect_profile()))
1298- let tool_result_message = build_tool_result_message(&result);1396+ .collect();
1299- ctx.state.messages.write().push(tool_result_message);1397+ !built.iter().filter_map(|b| b.as_ref().ok()).all(|call| {
1300- ctx.turn.tool_results.push(result);1398+ profiles
1301- continue;1399+ .get(call.final_call().tool_name.as_str())
1400+ .is_some_and(|profile| is_parallel_safe(profile))
1401+ })
1402+ };
1403+ 
1404+ // Pass 2 — execute the successfully built calls. Clone the Arc runtime handle
1405+ // so the futures borrow it, not `ctx` (post-processing needs `&mut ctx`).
1406+ // Both paths preserve input order, so Pass 3/4 are unaffected.
1407+ let runtime_view = ctx.input.runtime_view.clone().unwrap();
1408+ let exec_outcomes: Vec<_> = if serialize_batch {
1409+ let mut outcomes = Vec::new();
1410+ for call in built.iter().filter_map(|b| b.as_ref().ok()) {
1411+ outcomes.push(call.execute(&*runtime_view).await);
1412+ }
1413+ outcomes
1414+ } else {
1415+ futures_util::future::join_all(
1416+ built
1417+ .iter()
1418+ .filter_map(|b| b.as_ref().ok().map(|call| call.execute(&*runtime_view))),
1419+ )
1420+ .await
1421+ };
1422+ 
1423+ // Pass 3 — stitch outcomes back into call order, pairing each executed call
1424+ // with its result (build failures already carry their own result).
1425+ let mut exec_outcomes = exec_outcomes.into_iter();
1426+ let mut results: Vec<ToolExecutionResult> = Vec::with_capacity(built.len());
1427+ for entry in built {
1428+ match entry {
1429+ Err(failed_result) => results.push(failed_result),
1430+ Ok(tool_call) => {
1431+ let result = match exec_outcomes.next().expect("one outcome per executed call") {
1432+ Ok(result) => result,
1433+ Err(error) => build_framework_failed_tool_result(
1434+ tool_call.final_call().clone(),
1435+ error.to_string(),
1436+ ),
1437+ };
1438+ results.push(result);
1302 }1439 }
1303- };1440+ }
1304- let should_stop_after_result = should_stop_after_tool_result(ctx, &result);1441+ }
1442+ 
1443+ // Pass 4 — record results in call order. Suspending calls (`join_subagent`)
1444+ // are sorted last (above), so by the time the first one is seen every
1445+ // side-effecting sibling already has its tool_result recorded.
1446+ let mut streak_note: Option<String> = None;
1447+ let mut suspended_calls: Vec<SuspendedToolCall> = Vec::new();
1448+ let mut stop_after_batch = false;
1449+ for result in results {
1450+ ctx.state.tool_executed = true;
1451+ if should_stop_after_tool_result(ctx, &result) {
1452+ stop_after_batch = true;
1453+ }
1305 emit_tool_result_event(ctx, &result);1454 emit_tool_result_event(ctx, &result);
1306 1455 
1307 if let Some(suspended_call) = SuspendedToolCall::from_tool_result(&result) {1456 if let Some(suspended_call) = SuspendedToolCall::from_tool_result(&result) {
1457+ // Defer: no tool_result message now (the resumer appends it once the
1458+ // child finishes). Recording the raw result keeps tool_results complete.
1308 ctx.turn.tool_results.push(result);1459 ctx.turn.tool_results.push(result);
1309- return Ok(Some(suspended_call));1460+ suspended_calls.push(suspended_call);
1461+ continue;
1310 }1462 }
1311 1463 
1312 let tool_result_message = build_tool_result_message(&result);1464 let tool_result_message = build_tool_result_message(&result);
1313 ctx.state.messages.write().push(tool_result_message);1465 ctx.state.messages.write().push(tool_result_message);
1466+ // Track repeated identical failing calls; any note is pushed after all
1467+ // tool results so the assistant/tool-result protocol stays intact.
1468+ if let Some(note) = update_tool_failure_streak(ctx, &result) {
1469+ streak_note = Some(note);
1470+ }
1314 ctx.turn.tool_results.push(result);1471 ctx.turn.tool_results.push(result);
1472+ }
1315 1473 
1316- if should_stop_after_result {1474+ if stop_after_batch && suspended_calls.is_empty() {
1317- ctx.turn.force_return_complete = true;1475+ ctx.turn.force_return_complete = true;
1318- break;1476+ }
1477+ 
1478+ // A pending suspend must not be followed by an injected user message: the
1479+ // resumer still has to slot tool_result(s) right after the assistant turn, so
1480+ // hold the streak nudge until everything is resolved (drop it this turn).
1481+ if suspended_calls.is_empty() {
1482+ if let Some(note) = streak_note {
1483+ ctx.state.messages.write().push(ChatMessage::user(note));
1319 }1484 }
1320 }1485 }
1321 1486 
1322- Ok(None)1487+ Ok(suspended_calls)
1488+}
1489+ 
1490+const REPEATED_FAILURE_THRESHOLD: u32 = 3;
1491+const REPEATED_SUCCESS_THRESHOLD: u32 = 3;
1492+ 
1493+fn update_tool_failure_streak(
1494+ ctx: &mut LoopContext<'_>,
1495+ result: &ToolExecutionResult,
1496+) -> Option<String> {
1497+ let sig = tool_call_signature(result);
1498+ if is_failure_result(result) {
1499+ ctx.state.last_success_sig = None;
1500+ ctx.state.repeated_success_count = 0;
1501+ if ctx.state.last_failure_sig == Some(sig) {
1502+ ctx.state.repeated_failure_count += 1;
1503+ } else {
1504+ ctx.state.last_failure_sig = Some(sig);
1505+ ctx.state.repeated_failure_count = 1;
1506+ }
1507+ if ctx.state.repeated_failure_count >= REPEATED_FAILURE_THRESHOLD {
1508+ let count = ctx.state.repeated_failure_count;
1509+ let tool = result.tool_name().to_string();
1510+ ctx.state.repeated_failure_count = 0;
1511+ ctx.state.last_failure_sig = None;
1512+ return Some(format!(
1513+ "The `{tool}` call has now failed {count} times in a row with identical arguments. \
1514+ Stop retrying it unchanged — change approach: fix the arguments, read the relevant \
1515+ file or state to understand why it fails, or use a different tool to reach the goal."
1516+ ));
1517+ }
1518+ return None;
1519+ }
1520+ ctx.state.last_failure_sig = None;
1521+ ctx.state.repeated_failure_count = 0;
1522+ if ctx.state.last_success_sig == Some(sig) {
1523+ ctx.state.repeated_success_count += 1;
1524+ } else {
1525+ ctx.state.last_success_sig = Some(sig);
1526+ ctx.state.repeated_success_count = 1;
1527+ }
1528+ if ctx.state.repeated_success_count >= REPEATED_SUCCESS_THRESHOLD {
1529+ let count = ctx.state.repeated_success_count;
1530+ let tool = result.tool_name().to_string();
1531+ ctx.state.repeated_success_count = 0;
1532+ ctx.state.last_success_sig = None;
1533+ return Some(format!(
1534+ "The `{tool}` call has now run {count} times in a row with identical arguments and the \
1535+ same result — that output is already in your context above. Stop repeating it: use \
1536+ what you have, or take a different action toward the goal."
1537+ ));
1538+ }
1539+ None
1540+}
1541+ 
1542+fn is_parallel_safe(profile: &EffectProfile) -> bool {
1543+ !profile.writes_filesystem
1544+ && !profile.side_effects
1545+ && (profile.reads_filesystem || profile.network_access)
1546+}
1547+ 
1548+fn is_failure_result(result: &ToolExecutionResult) -> bool {
1549+ matches!(
1550+ result,
1551+ ToolExecutionResult::Completed {
1552+ raw_outcome: RawToolOutcome::Error { .. },
1553+ ..
1554+ } | ToolExecutionResult::Failed { .. }
1555+ | ToolExecutionResult::Denied { .. }
1556+ )
1557+}
1558+ 
1559+fn tool_call_signature(result: &ToolExecutionResult) -> u64 {
1560+ use std::hash::{Hash, Hasher};
1561+ let mut hasher = std::collections::hash_map::DefaultHasher::new();
1562+ result.tool_name().hash(&mut hasher);
1563+ serde_json::to_string(&result.final_call().input)
1564+ .unwrap_or_default()
1565+ .hash(&mut hasher);
1566+ hasher.finish()
1323}1567}
1324 1568 
1325/// Fill empty `call_id`s with a stable, turn-scoped id (`call_<turn>_<idx>`) so a1569/// Fill empty `call_id`s with a stable, turn-scoped id (`call_<turn>_<idx>`) so a
@@ -1333,6 +1577,41 @@ fn synthesize_missing_call_ids(msg: &mut AssistantMessage, turn: u32) {
1333 }1577 }
1334}1578}
1335 1579 
1580+fn repair_tool_names(
1581+ msg: &mut AssistantMessage,
1582+ visible: &[std::sync::Arc<dyn agent_contracts::tool::ToolSpecView>],
1583+) {
1584+ if visible.is_empty() {
1585+ return;
1586+ }
1587+ let normalize = |s: &str| -> String {
1588+ s.chars()
1589+ .filter(|c| c.is_ascii_alphanumeric())
1590+ .map(|c| c.to_ascii_lowercase())
1591+ .collect()
1592+ };
1593+ let mut canonical = std::collections::HashSet::new();
1594+ let mut normalized = std::collections::HashMap::new();
1595+ for tool in visible {
1596+ let name = tool.name().0.clone();
1597+ normalized
1598+ .entry(normalize(&name))
1599+ .or_insert_with(|| name.clone());
1600+ canonical.insert(name);
1601+ }
1602+ for tc in msg.tool_calls.iter_mut() {
1603+ if canonical.contains(&tc.tool_name) {
1604+ continue;
1605+ }
1606+ if let Some(fixed) = normalized.get(&normalize(&tc.tool_name)) {
1607+ if *fixed != tc.tool_name {
1608+ tracing::debug!(from = %tc.tool_name, to = %fixed, "repaired tool name");
1609+ tc.tool_name = fixed.clone();
1610+ }
1611+ }
1612+ }
1613+}
1614+ 
1336fn is_valid_tool_call(tc: &ToolUseBlock) -> bool {1615fn is_valid_tool_call(tc: &ToolUseBlock) -> bool {
1337 is_valid_tool_call_id(&tc.call_id) && is_valid_tool_name(&tc.tool_name)1616 is_valid_tool_call_id(&tc.call_id) && is_valid_tool_name(&tc.tool_name)
1338}1617}
@@ -1588,6 +1867,52 @@ fn decide(ctx: &mut LoopContext<'_>) {
1588 }1867 }
1589 }1868 }
1590 1869 
1870+ // Don't accept a stop while the model still has open plan items. The first
1871+ // such stop triggers one reminder (bounded by `plan_nudged`, so never an
1872+ // infinite loop — if the model stops again it completes). Only fires when the
1873+ // model actually used `todo_write` and left items open.
1874+ if !ctx.state.plan_nudged && ctx.turn.turn_number < ctx.snapshot.max_turns {
1875+ let open = ctx
1876+ .input
1877+ .runtime_view
1878+ .as_ref()
1879+ .map(|runtime_view| tool::open_todo_lines(runtime_view.as_ref()))
1880+ .unwrap_or_default();
1881+ if !open.is_empty() {
1882+ ctx.state.plan_nudged = true;
1883+ let reminder = format!(
1884+ "You are about to stop, but your plan still has {} open item(s):\n{}\n\
1885+ Finish them now, or call todo_write to mark them completed/cancelled if they no longer apply — then stop.",
1886+ open.len(),
1887+ open.join("\n")
1888+ );
1889+ ctx.state.messages.write().push(ChatMessage::user(reminder));
1890+ ctx.turn.decision = Some(LoopDecision::Continue);
1891+ return;
1892+ }
1893+ }
1894+ 
1895+ // Only nudge agentic runs: the checklist is about verifying a code change, so
1896+ // it is noise for a tool-less, conversational turn (and would force every such
1897+ // reply through a wasted extra round-trip). Gate on tools being available.
1898+ if !ctx.state.completion_nudged
1899+ && ctx.turn.turn_number < ctx.snapshot.max_turns
1900+ && ctx.state.tool_executed
1901+ {
1902+ ctx.state.completion_nudged = true;
1903+ let checklist = "You are about to finish. Before you stop, re-read the ORIGINAL task and verify, do not assume:\n\
1904+ 1. Every requirement it states is met — including any exact error message, return value, output, or edge case it names; if it specifies a behavior, you have a check that exercises THAT behavior, not a different one that merely passes.\n\
1905+ 2. Your change is robust to changed inputs — different numbers, empty/None/zero, other files or config — not only the one case you tried, and it does not mutate shared state or leave unintended side effects.\n\
1906+ 3. Review the change once from three angles: as the test engineer who will grade it, as a QA reviewer hunting regressions, and as the user who filed the task.\n\
1907+ If any check fails, fix it now. If all hold, stop again and you are done.";
1908+ ctx.state
1909+ .messages
1910+ .write()
1911+ .push(ChatMessage::user(checklist.to_string()));
1912+ ctx.turn.decision = Some(LoopDecision::Continue);
1913+ return;
1914+ }
1915+ 
1591 ctx.turn.decision = Some(LoopDecision::ReturnComplete);1916 ctx.turn.decision = Some(LoopDecision::ReturnComplete);
1592}1917}
1593 1918 
@@ -1836,9 +2161,10 @@ mod tests {
1836 use std::sync::{Arc, Mutex as StdMutex};2161 use std::sync::{Arc, Mutex as StdMutex};
1837 2162 
1838 use agent_contracts::context::budget::TokenBudgetPolicy;2163 use agent_contracts::context::budget::TokenBudgetPolicy;
1839- use agent_contracts::tool::ToolSpecView;2164+ use agent_contracts::tool::{ToolExecutor, ToolFilter, ToolRegistry, ToolSpecView};
1840 use agent_contracts::{2165 use agent_contracts::{
1841- CompressionPipeline, LlmProvider, PromptBuilder, ProviderCapabilities, SkillRegistry,2166+ CompressionPipeline, LlmProvider, PromptBuilder, ProviderCapabilities, RuntimeView,
2167+ SkillRegistry,
1842 };2168 };
1843 use agent_llm::LlmRequestExt;2169 use agent_llm::LlmRequestExt;
1844 use agent_types::common::ids::{AgentId, ToolId, ToolName};2170 use agent_types::common::ids::{AgentId, ToolId, ToolName};
@@ -1846,7 +2172,9 @@ mod tests {
1846 use agent_types::context::prompt::{PromptBuildError, PromptBuildResult};2172 use agent_types::context::prompt::{PromptBuildError, PromptBuildResult};
1847 use agent_types::context::{FeatureFlags, TokenBudgetConfig};2173 use agent_types::context::{FeatureFlags, TokenBudgetConfig};
1848 use agent_types::events::LoopEndSummary;2174 use agent_types::events::LoopEndSummary;
2175+ use agent_types::tool::execution_types::{ToolExecutionError, ToolExecutorOutput};
1849 use agent_types::tool::spec_types::{EffectProfile, InputSchemaRef, OutputContract};2176 use agent_types::tool::spec_types::{EffectProfile, InputSchemaRef, OutputContract};
2177+ use agent_types::tool::FinalToolCall;
1850 use agent_types::{2178 use agent_types::{
1851 AssistantMessage, LlmError, LlmRequest, LlmResponse, StopReason, StreamChunk, ToolUseBlock,2179 AssistantMessage, LlmError, LlmRequest, LlmResponse, StopReason, StreamChunk, ToolUseBlock,
1852 Usage,2180 Usage,
@@ -2313,11 +2641,18 @@ mod tests {
2313 fn test_runtime_with_max_turns(2641 fn test_runtime_with_max_turns(
2314 provider: Arc<LlmProviderWrapper>,2642 provider: Arc<LlmProviderWrapper>,
2315 max_turns: u32,2643 max_turns: u32,
2644+ ) -> AgentRuntime {
2645+ test_runtime_with_registry(provider, max_turns, Arc::new(EmptyToolRegistry::new()))
2646+ }
2647+ 
2648+ fn test_runtime_with_registry(
2649+ provider: Arc<LlmProviderWrapper>,
2650+ max_turns: u32,
2651+ tool_registry: Arc<dyn ToolRegistry>,
2316 ) -> AgentRuntime {2652 ) -> AgentRuntime {
2317 let prompt_builder: Arc<dyn PromptBuilder> = Arc::new(FixedPromptBuilder);2653 let prompt_builder: Arc<dyn PromptBuilder> = Arc::new(FixedPromptBuilder);
2318 let compression_pipeline: Arc<dyn CompressionPipeline> =2654 let compression_pipeline: Arc<dyn CompressionPipeline> =
2319 Arc::new(compact::PassthroughCompressionPipeline::new());2655 Arc::new(compact::PassthroughCompressionPipeline::new());
2320- let tool_registry = Arc::new(EmptyToolRegistry::new());
2321 let skill_registry: Arc<dyn SkillRegistry> = Arc::new(EmptySkillRegistry::new());2656 let skill_registry: Arc<dyn SkillRegistry> = Arc::new(EmptySkillRegistry::new());
2322 let budget_config = TokenBudgetConfig {2657 let budget_config = TokenBudgetConfig {
2323 total_budget: 4096,2658 total_budget: 4096,
@@ -2628,7 +2963,10 @@ mod tests {
2628 outcome,2963 outcome,
2629 LoopRunResult::Complete(AgentOutcome::Complete { .. })2964 LoopRunResult::Complete(AgentOutcome::Complete { .. })
2630 ));2965 ));
2631- assert_eq!(loop_state.turn_count, 2);2966+ // turn 1: synthesize + run the tool; turn 2: model stops and the
2967+ // completion nudge (tools are visible here) adds one verification turn;
2968+ // turn 3: model stops again and the loop completes.
2969+ assert_eq!(loop_state.turn_count, 3);
2632 2970 
2633 let messages = loop_state.messages.read();2971 let messages = loop_state.messages.read();
2634 let tool_use = messages.iter().find_map(|m| {2972 let tool_use = messages.iter().find_map(|m| {
@@ -2826,4 +3164,303 @@ mod tests {
2826 let secrets3 = extract_secrets_from_messages(&vec![message_other_tool]);3164 let secrets3 = extract_secrets_from_messages(&vec![message_other_tool]);
2827 assert_eq!(secrets3.len(), 0);3165 assert_eq!(secrets3.len(), 0);
2828 }3166 }
3167+ 
3168+ #[test]
3169+ fn is_parallel_safe_allows_pure_readers_only() {
3170+ let reader = EffectProfile {
3171+ reads_filesystem: true,
3172+ writes_filesystem: false,
3173+ network_access: false,
3174+ side_effects: false,
3175+ };
3176+ let network_reader = EffectProfile {
3177+ reads_filesystem: false,
3178+ writes_filesystem: false,
3179+ network_access: true,
3180+ side_effects: false,
3181+ };
3182+ assert!(is_parallel_safe(&reader));
3183+ assert!(is_parallel_safe(&network_reader));
3184+ }
3185+ 
3186+ #[test]
3187+ fn is_parallel_safe_serializes_writers_side_effects_and_interactive() {
3188+ let writer = EffectProfile {
3189+ reads_filesystem: true,
3190+ writes_filesystem: true,
3191+ network_access: false,
3192+ side_effects: false,
3193+ };
3194+ let side_effecting = EffectProfile {
3195+ reads_filesystem: true,
3196+ writes_filesystem: true,
3197+ network_access: false,
3198+ side_effects: true,
3199+ };
3200+ let stateful = EffectProfile {
3201+ reads_filesystem: false,
3202+ writes_filesystem: false,
3203+ network_access: false,
3204+ side_effects: true,
3205+ };
3206+ let interactive = EffectProfile::default();
3207+ assert!(!is_parallel_safe(&writer));
3208+ assert!(!is_parallel_safe(&side_effecting));
3209+ assert!(!is_parallel_safe(&stateful));
3210+ assert!(
3211+ !is_parallel_safe(&interactive),
3212+ "an interactive prompt declares no read/network and must serialize"
3213+ );
3214+ }
3215+ 
3216+ #[tokio::test]
3217+ async fn completion_nudge_skips_conversational_run_with_visible_tools() {
3218+ let provider = Arc::new(LlmProviderWrapper::new(
3219+ Arc::new(StreamingTestProvider::new()),
3220+ None,
3221+ None,
3222+ ));
3223+ let runtime = test_runtime(provider);
3224+ let input = AgentLoopInput::new("explain how this code works")
3225+ .with_agent_id(AgentId("test-agent".to_string()))
3226+ .with_visible_tools(dummy_visible_tools())
3227+ .with_runtime_view(Arc::new(NoopRuntimeView::new()));
3228+ let mut loop_state = LoopState::new(uuid::Uuid::new_v4());
3229+ 
3230+ let outcome = run_agent_loop(&runtime, &mut loop_state, input)
3231+ .await
3232+ .expect("conversational loop should complete");
3233+ 
3234+ assert!(matches!(
3235+ outcome,
3236+ LoopRunResult::Complete(AgentOutcome::Complete { .. })
3237+ ));
3238+ assert!(
3239+ !loop_state.tool_executed,
3240+ "no tool ran, so the run is conversational"
3241+ );
3242+ assert_eq!(loop_state.turn_count, 1);
3243+ }
3244+ 
3245+ struct AlwaysSucceedsExecutor {
3246+ spec: Arc<VisibleToolSpec>,
3247+ }
3248+ 
3249+ #[async_trait]
3250+ impl ToolExecutor for AlwaysSucceedsExecutor {
3251+ fn spec(&self) -> &dyn ToolSpecView {
3252+ self.spec.as_ref()
3253+ }
3254+ 
3255+ async fn invoke(
3256+ &self,
3257+ call: &FinalToolCall,
3258+ _runtime: &dyn RuntimeView,
3259+ ) -> Result<ToolExecutorOutput, ToolExecutionError> {
3260+ Ok(ToolExecutorOutput::Completed {
3261+ raw_outcome: RawToolOutcome::Success {
3262+ output: format!("ran {}", call.call_id),
3263+ },
3264+ })
3265+ }
3266+ }
3267+ 
3268+ struct SingleToolRegistry {
3269+ spec: Arc<VisibleToolSpec>,
3270+ executor: Arc<dyn ToolExecutor>,
3271+ }
3272+ 
3273+ impl SingleToolRegistry {
3274+ fn new() -> Self {
3275+ let spec = Arc::new(VisibleToolSpec {
3276+ id: ToolId("tool.peek".to_string()),
3277+ name: ToolName("peek".to_string()),
3278+ description: "Read-only peek".to_string(),
3279+ input_schema: InputSchemaRef {
3280+ schema: serde_json::json!({"type": "object"}),
3281+ },
3282+ output_contract: OutputContract {
3283+ description: "peeked".to_string(),
3284+ },
3285+ effect_profile: EffectProfile {
3286+ reads_filesystem: true,
3287+ writes_filesystem: false,
3288+ network_access: false,
3289+ side_effects: false,
3290+ },
3291+ });
3292+ let executor: Arc<dyn ToolExecutor> = Arc::new(AlwaysSucceedsExecutor {
3293+ spec: Arc::clone(&spec),
3294+ });
3295+ Self { spec, executor }
3296+ }
3297+ 
3298+ fn visible(&self) -> Vec<Arc<dyn ToolSpecView>> {
3299+ vec![Arc::clone(&self.spec) as Arc<dyn ToolSpecView>]
3300+ }
3301+ }
3302+ 
3303+ impl ToolRegistry for SingleToolRegistry {
3304+ fn get_executor(&self, id: &ToolId) -> Option<Arc<dyn ToolExecutor>> {
3305+ (id == self.spec.id()).then(|| Arc::clone(&self.executor))
3306+ }
3307+ 
3308+ fn get_spec(&self, id: &ToolId) -> Option<&dyn ToolSpecView> {
3309+ (id == self.spec.id()).then(|| self.spec.as_ref() as &dyn ToolSpecView)
3310+ }
3311+ 
3312+ fn list_specs(&self) -> Vec<&dyn ToolSpecView> {
3313+ vec![self.spec.as_ref()]
3314+ }
3315+ 
3316+ fn filter_for(&self, _agent_id: &AgentId) -> Box<dyn ToolFilter> {
3317+ tool_filter_from_specs(&self.visible(), self)
3318+ }
3319+ }
3320+ 
3321+ struct TwoToolCallProvider {
3322+ capabilities: ProviderCapabilities,
3323+ calls: Arc<StdMutex<usize>>,
3324+ }
3325+ 
3326+ impl TwoToolCallProvider {
3327+ fn new() -> Self {
3328+ Self {
3329+ capabilities: ProviderCapabilities {
3330+ supports_streaming: true,
3331+ supports_tool_calls: true,
3332+ supports_json_mode: false,
3333+ max_context_window: 4096,
3334+ model_name: "two-tool-call-test".to_string(),
3335+ },
3336+ calls: Arc::new(StdMutex::new(0)),
3337+ }
3338+ }
3339+ }
3340+ 
3341+ #[async_trait]
3342+ impl LlmProvider for TwoToolCallProvider {
3343+ async fn complete(&self, _request: &LlmRequest) -> Result<LlmResponse, LlmError> {
3344+ panic!("streaming path should use complete_stream instead of complete");
3345+ }
3346+ 
3347+ async fn complete_stream(
3348+ &self,
3349+ _request: &LlmRequest,
3350+ _on_chunk: &(dyn Fn(StreamChunk) + Send + Sync),
3351+ ) -> Result<LlmResponse, LlmError> {
3352+ let call_number = {
3353+ let mut calls = self.calls.lock().expect("call counter mutex poisoned");
3354+ *calls += 1;
3355+ *calls
3356+ };
3357+ 
3358+ if call_number == 1 {
3359+ return Ok(LlmResponse {
3360+ message: AssistantMessage {
3361+ text: Some("calling tools".to_string()),
3362+ reasoning_content: None,
3363+ tool_calls: vec![
3364+ ToolUseBlock {
3365+ call_id: "call_a".to_string(),
3366+ tool_name: "peek".to_string(),
3367+ input: serde_json::json!({}),
3368+ },
3369+ ToolUseBlock {
3370+ call_id: "call_b".to_string(),
3371+ tool_name: "peek".to_string(),
3372+ input: serde_json::json!({}),
3373+ },
3374+ ],
3375+ usage: Usage {
3376+ prompt_tokens: 5,
3377+ completion_tokens: 3,
3378+ total_tokens: 8,
3379+ },
3380+ stop_reason: StopReason::ToolUse,
3381+ },
3382+ kv_cache_chunk_hashes: vec![],
3383+ });
3384+ }
3385+ 
3386+ Ok(LlmResponse {
3387+ message: AssistantMessage {
3388+ text: Some("done".to_string()),
3389+ reasoning_content: None,
3390+ tool_calls: Vec::new(),
3391+ usage: Usage {
3392+ prompt_tokens: 5,
3393+ completion_tokens: 1,
3394+ total_tokens: 6,
3395+ },
3396+ stop_reason: StopReason::EndTurn,
3397+ },
3398+ kv_cache_chunk_hashes: vec![],
3399+ })
3400+ }
3401+ 
3402+ fn capabilities(&self) -> &ProviderCapabilities {
3403+ &self.capabilities
3404+ }
3405+ }
3406+ 
3407+ #[tokio::test]
3408+ async fn mid_batch_stop_still_records_every_executed_result() {
3409+ let registry = Arc::new(SingleToolRegistry::new());
3410+ let visible = registry.visible();
3411+ let provider = Arc::new(LlmProviderWrapper::new(
3412+ Arc::new(TwoToolCallProvider::new()),
3413+ None,
3414+ None,
3415+ ));
3416+ let runtime = test_runtime_with_registry(provider, 4, registry);
3417+ let input = AgentLoopInput::new("go")
3418+ .with_agent_id(AgentId("test-agent".to_string()))
3419+ .with_visible_tools(visible)
3420+ .with_runtime_view(Arc::new(NoopRuntimeView::new()))
3421+ .with_stop_rules([LoopStopRule::AfterSuccessfulTool {
3422+ tool_name: "peek".to_string(),
3423+ }]);
3424+ let mut loop_state = LoopState::new(uuid::Uuid::new_v4());
3425+ 
3426+ let outcome = run_agent_loop(&runtime, &mut loop_state, input)
3427+ .await
3428+ .expect("loop should complete via the stop rule");
3429+ 
3430+ assert!(matches!(
3431+ outcome,
3432+ LoopRunResult::Complete(AgentOutcome::Complete { .. })
3433+ ));
3434+ assert_eq!(loop_state.turn_count, 1);
3435+ 
3436+ let messages = loop_state.messages.read();
3437+ let tool_use_ids: Vec<String> = messages
3438+ .iter()
3439+ .flat_map(|m| m.blocks.iter())
3440+ .filter_map(|b| match b {
3441+ ContentBlock::ToolUse { call_id, .. } => Some(call_id.clone()),
3442+ _ => None,
3443+ })
3444+ .collect();
3445+ let tool_result_ids: Vec<String> = messages
3446+ .iter()
3447+ .flat_map(|m| m.blocks.iter())
3448+ .filter_map(|b| match b {
3449+ ContentBlock::ToolResult { call_id, .. } => Some(call_id.clone()),
3450+ _ => None,
3451+ })
3452+ .collect();
3453+ 
3454+ assert_eq!(
3455+ tool_use_ids,
3456+ vec!["call_a".to_string(), "call_b".to_string()],
3457+ "both tool calls should be in history"
3458+ );
3459+ assert_eq!(
3460+ tool_result_ids,
3461+ vec!["call_a".to_string(), "call_b".to_string()],
3462+ "every executed tool_use must keep its paired tool_result even when an \
3463+ earlier call in the batch triggered the stop rule"
3464+ );
3465+ }
2829}3466}
Mcrates/core/src/loop_state.rs+21-0
@@ -16,6 +16,13 @@ pub struct LoopState {
16 pub compression_meta: CompressionMeta,16 pub compression_meta: CompressionMeta,
17 pub kv_cache_map: KvCacheMap,17 pub kv_cache_map: KvCacheMap,
18 pub cancel: CancellationToken,18 pub cancel: CancellationToken,
19+ pub plan_nudged: bool,
20+ pub last_failure_sig: Option<u64>,
21+ pub repeated_failure_count: u32,
22+ pub completion_nudged: bool,
23+ pub last_success_sig: Option<u64>,
24+ pub repeated_success_count: u32,
25+ pub tool_executed: bool,
19}26}
20 27 
21#[derive(Clone, Debug, Serialize, Deserialize)]28#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -38,6 +45,13 @@ impl LoopState {
38 compression_meta: CompressionMeta::default(),45 compression_meta: CompressionMeta::default(),
39 kv_cache_map: KvCacheMap::default(),46 kv_cache_map: KvCacheMap::default(),
40 cancel: CancellationToken::new(),47 cancel: CancellationToken::new(),
48+ plan_nudged: false,
49+ last_failure_sig: None,
50+ repeated_failure_count: 0,
51+ completion_nudged: false,
52+ last_success_sig: None,
53+ repeated_success_count: 0,
54+ tool_executed: false,
41 }55 }
42 }56 }
43 57 
@@ -61,6 +75,13 @@ impl LoopState {
61 compression_meta: snapshot.compression_meta,75 compression_meta: snapshot.compression_meta,
62 kv_cache_map: snapshot.kv_cache_map,76 kv_cache_map: snapshot.kv_cache_map,
63 cancel,77 cancel,
78+ plan_nudged: false,
79+ last_failure_sig: None,
80+ repeated_failure_count: 0,
81+ completion_nudged: false,
82+ last_success_sig: None,
83+ repeated_success_count: 0,
84+ tool_executed: false,
64 }85 }
65 }86 }
66 87 
Mcrates/core/src/suspend.rs+1-1
@@ -16,7 +16,7 @@ pub struct SuspendedToolCall {
16 16 
17pub enum LoopRunResult {17pub enum LoopRunResult {
18 Complete(agent_types::outcome::AgentOutcome),18 Complete(agent_types::outcome::AgentOutcome),
19- Suspended(SuspendedToolCall),19+ Suspended(Vec<SuspendedToolCall>),
20}20}
21 21 
22impl SuspendedToolCall {22impl SuspendedToolCall {
Mcrates/tool/src/impl/builtin/mod.rs+1-0
@@ -17,4 +17,5 @@ mod tool_source;
17mod webfetch;17mod webfetch;
18mod websearch;18mod websearch;
19 19 
20+pub use todo_write::open_todo_lines;
20pub use tool_source::BuiltinToolSource;21pub use tool_source::BuiltinToolSource;
Mcrates/tool/src/impl/builtin/todo_write/executor.rs+30-0
@@ -78,6 +78,36 @@ impl ToolExecutor for TodoWriteToolExecutor {
78 }78 }
79}79}
80 80 
81+/// Live plan readout for the agent loop: one formatted line per open
82+/// (non-`Completed`) todo for `runtime`'s session — `[~]` in-progress, `[ ]`
83+/// pending — or empty when no plan was written or all items are done. Lets the
84+/// loop re-inject open items each turn and consult them before accepting a stop.
85+pub fn open_todo_lines(runtime: &dyn RuntimeView) -> Vec<String> {
86+ let key = todo_key(runtime);
87+ let Some(store) = TODO_STORE.get() else {
88+ return Vec::new();
89+ };
90+ let Ok(store) = store.lock() else {
91+ return Vec::new();
92+ };
93+ store
94+ .get(&key)
95+ .map(|todos| {
96+ todos
97+ .iter()
98+ .filter(|todo| !matches!(todo.status, TodoStatus::Completed))
99+ .map(|todo| {
100+ let mark = match todo.status {
101+ TodoStatus::InProgress => "~",
102+ _ => " ",
103+ };
104+ format!("[{mark}] {}", todo.content.trim())
105+ })
106+ .collect()
107+ })
108+ .unwrap_or_default()
109+}
110+ 
81fn todo_key(runtime: &dyn RuntimeView) -> String {111fn todo_key(runtime: &dyn RuntimeView) -> String {
82 let metadata = runtime.agent_context().metadata();112 let metadata = runtime.agent_context().metadata();
83 metadata113 metadata
Mcrates/tool/src/impl/builtin/todo_write/mod.rs+1-0
@@ -4,3 +4,4 @@ mod spec;
4mod types;4mod types;
5 5 
6pub(crate) use discovered_tool::discover_todo_write;6pub(crate) use discovered_tool::discover_todo_write;
7+pub use executor::open_todo_lines;
Mcrates/tool/src/impl/mod.rs+1-0
@@ -8,5 +8,6 @@ mod source_loader;
8pub mod tool_input;8pub mod tool_input;
9 9 
10pub use builtin::file_read;10pub use builtin::file_read;
11+pub use builtin::open_todo_lines;
11pub use runtime_services::{SubagentRoleConfig, ToolRuntimeServices};12pub use runtime_services::{SubagentRoleConfig, ToolRuntimeServices};
12pub use source_loader::{load_tool_sources, load_tool_sources_with_services};13pub use source_loader::{load_tool_sources, load_tool_sources_with_services};
Mcrates/tool/src/lib.rs+1-0
@@ -14,6 +14,7 @@ pub use invocation_context::{
14 approve_current_sandbox_permission, current_sandbox_permission_scope, current_tool_name,14 approve_current_sandbox_permission, current_sandbox_permission_scope, current_tool_name,
15 register_once_sandbox_grant, scope_tool_invocation,15 register_once_sandbox_grant, scope_tool_invocation,
16};16};
17+pub use r#impl::open_todo_lines;
17pub use r#impl::reqwest_util;18pub use r#impl::reqwest_util;
18pub use r#impl::{19pub use r#impl::{
19 load_tool_sources, load_tool_sources_with_services, SubagentRoleConfig, ToolRuntimeServices,20 load_tool_sources, load_tool_sources_with_services, SubagentRoleConfig, ToolRuntimeServices,