已合并
Merge remote-tracking branch 'upstream/dev' into dev #202
hypothesier创建于 6月16日
Merge remote-tracking branch 'upstream/dev' into dev #202
已合并
hypothesier创建于 6月16日
24 个文件变更+1217-156
MCargo.lock+1-0
@@ -4818,6 +4818,7 @@ dependencies = [
4818 "arc-swap",4818 "arc-swap",
4819 "async-trait",4819 "async-trait",
4820 "compact",4820 "compact",
4821 "futures-util",
4821 "llm-client",4822 "llm-client",
4822 "parking_lot",4823 "parking_lot",
4823 "reqwest",4824 "reqwest",
Aapps/endside/src/prompts/subagent_system_prompt.txt+5-0
@@ -0,0 +1,5 @@
1You are a disposable read-only exploration subagent. A parent agent spawned you to investigate something specific in this workspace; find the answer with your search tools (file_read, glob, grep) and report back. You cannot edit files, run commands, or spawn further agents.
2 
3Explore aggressively and in parallel: every turn re-reads your whole context, so fire as many independent lookups as you can in a SINGLE turn — issue many glob/grep/file_read calls at once rather than one per turn. Use glob/grep to locate, then read only the smallest relevant slice. No preamble and no narration — act through tool calls.
4 
5Your final text reply is the ONLY thing the parent receives; everything else you read is discarded with you. Finish BEFORE your turn budget runs out: reply with a DISTILLED answer — exact file:line locations, the few snippets that matter, and a one-paragraph conclusion. Never dump whole files or raw search output. Be exact where the goal asks for a count, comparison, or location; never approximate or truncate the answer itself.
Mapps/endside/src/render/overlay.rs+153-24
@@ -1149,49 +1149,178 @@ fn truncate_chars(value: &str, max_chars: usize) -> String {
1149}1149}
1150 1150 
1151/// Calculate the visual cursor position considering automatic line wrapping.1151/// Calculate the visual cursor position considering automatic line wrapping.
1152/// Returns (visual_row, visual_col) where visual_row is the row on screen1152/// Matches ratatui's WordWrapper: tracks pending whitespace separately,
1153/// and visual_col is the column position within that row (visual width units).1153/// consumes trailing whitespace on wrap, preserves word continuity across wraps.
1154fn calculate_visual_cursor_position(1154fn calculate_visual_cursor_position(
1155 value: &str,1155 value: &str,
1156 cursor: usize,1156 cursor: usize,
1157 max_width: usize,1157 max_width: usize,
1158) -> (usize, usize) {1158) -> (usize, usize) {
1159 if max_width == 0 {1159 if max_width == 0 || value.is_empty() {
1160 return (0, 0);1160 return (0, 0);
1161 }1161 }
1162 1162 
1163 let chars: Vec<char> = value.chars().collect();1163 let chars: Vec<char> = value.chars().collect();
1164 let cursor = cursor.min(chars.len());1164 let cursor = cursor.min(chars.len());
1165 1165 
1166 let mut visual_row = 0usize;1166 let mut lines: Vec<(usize, usize)> = Vec::new();
1167 let mut visual_col = 0usize;1167 let mut line_start = 0usize;
1168 let mut current_row_width = 0usize;1168 let mut line_width = 0usize;
1169 let mut word_start = 0usize;
1170 let mut word_width = 0usize;
1171 let mut pending_space = 0usize;
1169 1172 
1170 for (idx, &ch) in chars.iter().enumerate() {1173 for (idx, &ch) in chars.iter().enumerate() {
1171 if idx == cursor {1174 if ch == '\n' {
1172 return (visual_row, visual_col);1175 if idx == cursor {
1176 return (lines.len(), line_width + pending_space + word_width);
1177 }
1178 lines.push((line_start, idx));
1179 line_start = idx + 1;
1180 line_width = 0;
1181 word_start = idx + 1;
1182 word_width = 0;
1183 pending_space = 0;
1184 continue;
1173 }1185 }
1174 1186 
1175 if ch == '\n' {1187 let char_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
1176 // Explicit newline: move to next line
1177 visual_row += 1;
1178 visual_col = 0;
1179 current_row_width = 0;
1180 } else {
1181 let char_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
1182 1188 
1183 if current_row_width + char_width > max_width {1189 if ch.is_whitespace() {
1184 // Automatic line wrap: move to next line, then add character1190 if word_width > 0 {
1185 visual_row += 1;1191 let total = line_width + pending_space + word_width;
1186 current_row_width = 0;1192 if total > max_width && line_width > 0 {
1193 push_line_consume_whitespace(
1194 &mut lines,
1195 &chars,
1196 &mut line_start,
1197 &mut line_width,
1198 word_start,
1199 &mut pending_space,
1200 max_width,
1201 );
1202 } else {
1203 line_width += pending_space + word_width;
1204 }
1205 word_width = 0;
1187 }1206 }
1207 pending_space += char_width;
1208 word_start = idx + 1;
1209 } else {
1210 if word_width == 0 {
1211 word_start = idx;
1212 }
1213 word_width += char_width;
1188 1214 
1189 // Add character width to current row1215 let total = line_width + pending_space + word_width;
1190 current_row_width += char_width;1216 if total > max_width {
1191 visual_col = current_row_width;1217 if line_width > 0 {
1218 push_line_consume_whitespace(
1219 &mut lines,
1220 &chars,
1221 &mut line_start,
1222 &mut line_width,
1223 word_start,
1224 &mut pending_space,
1225 max_width,
1226 );
1227 }
1228 
1229 while word_width > max_width {
1230 let mut break_idx = line_start;
1231 let mut break_width = 0usize;
1232 for &c in chars[line_start..=idx].iter() {
1233 let w = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
1234 if break_width + w > max_width && break_width > 0 {
1235 break;
1236 }
1237 break_width += w;
1238 break_idx += 1;
1239 }
1240 lines.push((line_start, break_idx));
1241 line_start = break_idx;
1242 word_width -= break_width;
1243 }
1244 }
1192 }1245 }
1193 }1246 }
1194 1247 
1195 // Cursor at end of text1248 if line_start < chars.len() {
1196 (visual_row, visual_col)1249 lines.push((line_start, chars.len()));
1250 }
1251 
1252 for (row, (start, end)) in lines.iter().enumerate() {
1253 if cursor >= *start && cursor <= *end {
1254 let col: usize = chars[*start..cursor.min(*end)]
1255 .iter()
1256 .map(|c| unicode_width::UnicodeWidthChar::width(*c).unwrap_or(0))
1257 .sum();
1258 return (row, col);
1259 }
1260 }
1261 
1262 if cursor >= line_start {
1263 return (lines.len(), 0);
1264 }
1265 
1266 (0, 0)
1267}
1268 
1269fn push_line_consume_whitespace(
1270 lines: &mut Vec<(usize, usize)>,
1271 chars: &[char],
1272 line_start: &mut usize,
1273 line_width: &mut usize,
1274 word_start: usize,
1275 pending_space: &mut usize,
1276 max_width: usize,
1277) {
1278 if *pending_space <= max_width.saturating_sub(*line_width) {
1279 *pending_space = 0;
1280 }
1281 let mut line_end = word_start;
1282 while line_end > *line_start && chars[line_end - 1].is_whitespace() {
1283 line_end -= 1;
1284 }
1285 lines.push((*line_start, line_end));
1286 *line_start = word_start;
1287 *line_width = 0;
1288 
1289 while *pending_space > 0
1290 && *line_start > line_end
1291 && chars[*line_start - 1].is_whitespace()
1292 {
1293 let w = unicode_width::UnicodeWidthChar::width(chars[*line_start - 1]).unwrap_or(0);
1294 *line_start -= 1;
1295 *pending_space = pending_space.saturating_sub(w);
1296 }
1297}
1298 
1299#[cfg(test)]
1300mod tests {
1301 use super::*;
1302 
1303 #[test]
1304 fn test_basic() {
1305 let (row, col) = calculate_visual_cursor_position("hello", 3, 10);
1306 assert_eq!((row, col), (0, 3));
1307 }
1308 
1309 #[test]
1310 fn test_newline() {
1311 let (row, col) = calculate_visual_cursor_position("hello\nworld", 6, 10);
1312 assert_eq!((row, col), (1, 0));
1313 }
1314 
1315 #[test]
1316 fn test_cursor_after_newline() {
1317 let (row, col) = calculate_visual_cursor_position("hello\n", 6, 10);
1318 assert_eq!((row, col), (1, 0));
1319 }
1320 
1321 #[test]
1322 fn test_empty() {
1323 let (row, col) = calculate_visual_cursor_position("", 0, 10);
1324 assert_eq!((row, col), (0, 0));
1325 }
1197}1326}
Mapps/serverside/src/daemon_runtime.rs+2-0
@@ -725,6 +725,7 @@ mod tests {
725 entry: GatewayEntryContext::channel(None),725 entry: GatewayEntryContext::channel(None),
726 agent_id_override: None,726 agent_id_override: None,
727 max_turns_override: None,727 max_turns_override: None,
728 subagent_role_id: None,
728 llm: None,729 llm: None,
729 };730 };
730 731 
@@ -791,6 +792,7 @@ mod tests {
791 },792 },
792 agent_id_override: None,793 agent_id_override: None,
793 max_turns_override: None,794 max_turns_override: None,
795 subagent_role_id: None,
794 llm: None,796 llm: None,
795 };797 };
796 798 
Mapps/shared/src/gateway/hosted_runtime_resolver.rs+62-15
@@ -1,7 +1,8 @@
1use crate::backend::GatewayBackendConfig;1use crate::backend::GatewayBackendConfig;
2use crate::gateway::prompt_utils::{compose_subagent_delegation_rules, generate_skills_dirs_table};2use crate::gateway::prompt_utils::{compose_subagent_delegation_rules, generate_skills_dirs_table};
3use crate::gateway::{3use crate::gateway::{
4 compose_workspace_system_prompt, ResolvedSessionRuntime, SessionRecord, SessionRuntimeBindings,4 compose_repo_map, compose_workspace_system_prompt, ResolvedSessionRuntime, SessionRecord,
5 SessionRuntimeBindings,
5 SessionRuntimeBuildInput, SessionRuntimeDescriptor, SessionRuntimeResolveError,6 SessionRuntimeBuildInput, SessionRuntimeDescriptor, SessionRuntimeResolveError,
6 SessionRuntimeResolver,7 SessionRuntimeResolver,
7};8};
@@ -33,6 +34,20 @@ pub struct SubagentRoleConfigEntry {
33 pub tools: BTreeMap<String, bool>,34 pub tools: BTreeMap<String, bool>,
34}35}
35 36 
37/// Slim system prompt for spawned exploration subagents, instead of the parent's
38/// full composed prompt (identity, output-economy rules, delegation guidance the
39/// child cannot act on, skills catalog), which would be re-sent every child turn.
40const SUBAGENT_SYSTEM_PROMPT: &str =
41 include_str!("../../../endside/src/prompts/subagent_system_prompt.txt");
42 
43/// Tools a read-only exploration subagent receives: the minimal search/read set.
44/// Allowlist, not blocklist — every other tool (mutation, side effects, user
45/// interaction, further delegation, web access) is dead weight in the child's
46/// per-turn tool-spec overhead, or a way to waste its final turn and lose its reply.
47fn is_subagent_allowed_tool(name: &str) -> bool {
48 matches!(name, "file_read" | "glob" | "grep")
49}
50 
36#[derive(Clone)]51#[derive(Clone)]
37pub struct HostedSessionRuntimeConfig {52pub struct HostedSessionRuntimeConfig {
38 pub descriptor: SessionRuntimeDescriptor,53 pub descriptor: SessionRuntimeDescriptor,
@@ -125,14 +140,18 @@ impl HostedSessionRuntimeResolver {
125 fn build_tool_registry(140 fn build_tool_registry(
126 &self,141 &self,
127 agent_id: &AgentId,142 agent_id: &AgentId,
143 apply_readonly_profile: bool,
128 services: ToolRuntimeServices,144 services: ToolRuntimeServices,
129 ) -> Result<Option<Arc<dyn ToolRegistry>>, SessionRuntimeResolveError> {145 ) -> Result<Option<Arc<dyn ToolRegistry>>, SessionRuntimeResolveError> {
130 let Some(visible_tool_names) = self.config.visible_tool_names.as_ref() else {146 let Some(visible_tool_names) = self.config.visible_tool_names.as_ref() else {
131 let tool_sources = load_tool_sources_with_services(services.clone());147 let tool_sources = load_tool_sources_with_services(services.clone());
148 // Default exploration subagents get the read-only search/read allowlist;
149 // the main agent and custom-role subagents keep the full toolset.
132 let all_tool_names = tool_sources150 let all_tool_names = tool_sources
133 .iter()151 .iter()
134 .flat_map(|source| source.discover())152 .flat_map(|source| source.discover())
135 .map(|tool| tool.spec.name().clone())153 .map(|tool| tool.spec.name().clone())
154 .filter(|name| !apply_readonly_profile || is_subagent_allowed_tool(&name.0))
136 .collect();155 .collect();
137 let mut per_agent_allowed_tools = HashMap::new();156 let mut per_agent_allowed_tools = HashMap::new();
138 per_agent_allowed_tools.insert(agent_id.clone(), all_tool_names);157 per_agent_allowed_tools.insert(agent_id.clone(), all_tool_names);
@@ -155,11 +174,15 @@ impl HostedSessionRuntimeResolver {
155 return Ok(None);174 return Ok(None);
156 }175 }
157 176 
177 let allowed: Vec<ToolName> = visible_tool_names
178 .iter()
179 .filter(|name| !apply_readonly_profile || is_subagent_allowed_tool(name.as_str()))
180 .cloned()
181 .map(ToolName)
182 .collect();
183 
158 let mut per_agent_allowed_tools = HashMap::new();184 let mut per_agent_allowed_tools = HashMap::new();
159 per_agent_allowed_tools.insert(185 per_agent_allowed_tools.insert(agent_id.clone(), allowed);
160 agent_id.clone(),
161 visible_tool_names.iter().cloned().map(ToolName).collect(),
162 );
163 186 
164 let registry = ToolRegistryBuilderImpl::new()187 let registry = ToolRegistryBuilderImpl::new()
165 .with_sources(load_tool_sources_with_services(services))188 .with_sources(load_tool_sources_with_services(services))
@@ -241,20 +264,37 @@ impl SessionRuntimeResolver for HostedSessionRuntimeResolver {
241 services.workspace_root = Some(self.config.descriptor.workspace_root.clone());264 services.workspace_root = Some(self.config.descriptor.workspace_root.clone());
242 let mut descriptor = self.config.descriptor.clone();265 let mut descriptor = self.config.descriptor.clone();
243 descriptor.agent_id = agent_id.clone();266 descriptor.agent_id = agent_id.clone();
244 descriptor.system_prompt =
245 compose_workspace_system_prompt(&descriptor.system_prompt, &descriptor.workspace_root);
246 267 
247 descriptor.system_prompt = descriptor.system_prompt.replace(268 let is_subagent = agent_id != self.config.descriptor.agent_id;
248 "{{skills_dirs_table}}",269 let is_default_subagent = is_subagent && request.subagent_role_id.is_none();
249 &generate_skills_dirs_table(&self.config.skills_config.skills_dirs),270 
250 );271 if is_default_subagent {
272 // The child gets the slim exploration prompt, not the parent's full
273 // composed one (re-billed every child turn). Its task arrives via the
274 // spawn_subagent template as the first user message, so the system
275 // slot only needs the exploration contract.
276 descriptor.system_prompt = SUBAGENT_SYSTEM_PROMPT.trim().to_string();
277 } else {
278 descriptor.system_prompt = compose_workspace_system_prompt(
279 &descriptor.system_prompt,
280 &descriptor.workspace_root,
281 );
282 descriptor.system_prompt = descriptor.system_prompt.replace(
283 "{{skills_dirs_table}}",
284 &generate_skills_dirs_table(&self.config.skills_config.skills_dirs),
285 );
286 if !is_subagent {
287 if let Some(repo_map) = compose_repo_map(&descriptor.workspace_root) {
288 descriptor.system_prompt.push_str("\n\n");
289 descriptor.system_prompt.push_str(&repo_map);
290 }
291 }
292 }
251 293 
252 if request.max_turns_override.is_some() {294 if request.max_turns_override.is_some() {
253 descriptor.max_turns = request.max_turns_override;295 descriptor.max_turns = request.max_turns_override;
254 }296 }
255 297 
256 let is_subagent = agent_id != self.config.descriptor.agent_id;
257 
258 if !is_subagent {298 if !is_subagent {
259 if let Some(rules) = compose_subagent_delegation_rules(&descriptor.subagent_roles) {299 if let Some(rules) = compose_subagent_delegation_rules(&descriptor.subagent_roles) {
260 // Insert Subagent Delegation after identity introduction300 // Insert Subagent Delegation after identity introduction
@@ -271,8 +311,15 @@ impl SessionRuntimeResolver for HostedSessionRuntimeResolver {
271 descriptor,311 descriptor,
272 entry_kind: request.entry.kind.clone(),312 entry_kind: request.entry.kind.clone(),
273 llm_provider,313 llm_provider,
274 tool_registry: self.build_tool_registry(&agent_id, services)?,314 tool_registry: self.build_tool_registry(&agent_id, is_default_subagent, services)?,
275 skill_registry: Some(Self::build_skill_registry(&self.config.skills_config)),315 // The default exploration child doesn't get the skills catalog: more
316 // per-turn prompt surface, and the `skill` tool is outside its allowlist
317 // anyway. Custom-role subagents keep skills, like the main agent.
318 skill_registry: if is_default_subagent {
319 None
320 } else {
321 Some(Self::build_skill_registry(&self.config.skills_config))
322 },
276 bindings: self.bindings.clone(),323 bindings: self.bindings.clone(),
277 trace: self.config.trace.clone(),324 trace: self.config.trace.clone(),
278 compression_pipeline: self.config.compression_pipeline.clone(),325 compression_pipeline: self.config.compression_pipeline.clone(),
Mapps/shared/src/gateway/mod.rs+1-1
@@ -46,4 +46,4 @@ pub use turns::{
46 AppTurnRequest, AppTurnResult, GatewayEntryContext, GatewayEntryKind, LlmRuntimeConfig,46 AppTurnRequest, AppTurnResult, GatewayEntryContext, GatewayEntryKind, LlmRuntimeConfig,
47 RuntimeTurnRequest, TurnMention,47 RuntimeTurnRequest, TurnMention,
48};48};
49pub use workspace_prompt::compose_workspace_system_prompt;49pub use workspace_prompt::{compose_repo_map, compose_workspace_system_prompt};
Mapps/shared/src/gateway/session_record.rs+2-0
@@ -78,6 +78,8 @@ pub struct SessionAgentRecord {
78 pub agent_id: AgentId,78 pub agent_id: AgentId,
79 #[serde(default)]79 #[serde(default)]
80 pub parent_agent_id: Option<AgentId>,80 pub parent_agent_id: Option<AgentId>,
81 #[serde(default)]
82 pub subagent_role_id: Option<String>,
81 pub loop_state: Option<LoopStateSnapshot>,83 pub loop_state: Option<LoopStateSnapshot>,
82 pub memory_snapshot: Option<MemorySnapshot>,84 pub memory_snapshot: Option<MemorySnapshot>,
83 #[serde(default)]85 #[serde(default)]
Mapps/shared/src/gateway/session_runtime/resolver.rs+4-0
@@ -60,6 +60,8 @@ pub struct SessionRuntimeBuildInput {
60 pub entry: GatewayEntryContext,60 pub entry: GatewayEntryContext,
61 pub agent_id_override: Option<AgentId>,61 pub agent_id_override: Option<AgentId>,
62 pub max_turns_override: Option<u32>,62 pub max_turns_override: Option<u32>,
63 #[serde(default)]
64 pub subagent_role_id: Option<String>,
63 pub llm: Option<LlmRuntimeConfig>,65 pub llm: Option<LlmRuntimeConfig>,
64}66}
65 67 
@@ -75,6 +77,7 @@ impl SessionRuntimeBuildInput {
75 entry: request.entry.clone(),77 entry: request.entry.clone(),
76 agent_id_override: None,78 agent_id_override: None,
77 max_turns_override: None,79 max_turns_override: None,
80 subagent_role_id: None,
78 llm: request.llm.clone(),81 llm: request.llm.clone(),
79 }82 }
80 }83 }
@@ -90,6 +93,7 @@ impl SessionRuntimeBuildInput {
90 entry: request.entry.clone(),93 entry: request.entry.clone(),
91 agent_id_override: None,94 agent_id_override: None,
92 max_turns_override: None,95 max_turns_override: None,
96 subagent_role_id: None,
93 llm: request.llm.clone(),97 llm: request.llm.clone(),
94 }98 }
95 }99 }
Mapps/shared/src/gateway/session_supervisor.rs+70-40
@@ -249,6 +249,7 @@ impl SessionSupervisor {
249 SessionAgentRecord {249 SessionAgentRecord {
250 agent_id: child_agent_id.clone(),250 agent_id: child_agent_id.clone(),
251 parent_agent_id: Some(request.parent_agent_id.clone()),251 parent_agent_id: Some(request.parent_agent_id.clone()),
252 subagent_role_id: request.subagent_role_id.clone(),
252 loop_state: None,253 loop_state: None,
253 memory_snapshot: None,254 memory_snapshot: None,
254 tool_manifest: None,255 tool_manifest: None,
@@ -415,14 +416,7 @@ impl SessionSupervisor {
415 .await?;416 .await?;
416 return Ok(terminal);417 return Ok(terminal);
417 }418 }
418 LoopRunResult::Suspended(suspended_call) => {419 LoopRunResult::Suspended(suspended_calls) => {
419 let join_id = suspended_join_id(&suspended_call)?;
420 let receiver = self.take_join_receiver(&join_id).await?;
421 let terminal = receiver.await.map_err(|_| SessionServiceError::CoreRun {
422 message: format!("pending join receiver dropped before wake: {join_id}"),
423 })?;
424 self.remove_pending_join(&join_id).await;
425 
426 let mut resumed_loop_state =420 let mut resumed_loop_state =
427 loop_state421 loop_state
428 .clone()422 .clone()
@@ -432,42 +426,48 @@ impl SessionSupervisor {
432 input.agent_id426 input.agent_id
433 ),427 ),
434 })?;428 })?;
435 let tool_result_msg =
436 build_join_tool_result_message(&suspended_call, terminal.clone())?;
437 429 
438 let resolved_call_id = &suspended_call.final_call.call_id;430 for suspended_call in &suspended_calls {
439 if let Some(last_msg) = resumed_loop_state.messages.last_mut() {431 let join_id = suspended_join_id(suspended_call)?;
440 if matches!(last_msg.role, agent_types::llm::MessageRole::Assistant) {432 let receiver = self.take_join_receiver(&join_id).await?;
441 last_msg.blocks.retain(|b| match b {433 let terminal =
442 agent_types::llm::ContentBlock::ToolUse { call_id, .. } => {434 receiver.await.map_err(|_| SessionServiceError::CoreRun {
443 call_id == resolved_call_id435 message: format!(
444 }436 "pending join receiver dropped before wake: {join_id}"
445 _ => true,437 ),
446 });438 })?;
439 self.remove_pending_join(&join_id).await;
440 
441 let tool_result_msg =
442 build_join_tool_result_message(suspended_call, terminal.clone())?;
443 resumed_loop_state.messages.push(tool_result_msg);
444 
445 if let Some(sink) = loop_event_sink.as_ref() {
446 let output_preview = serde_json::to_string(
447 &serde_json::json!({ "terminal": terminal }),
448 )
449 .unwrap_or_default();
450 let is_error =
451 terminal.status == subagent::SubagentTerminalKind::Failed;
452 sink.on_tool_result(
453 &input.agent_id,
454 &agent_types::events::ToolResultEvent {
455 call_id: suspended_call.final_call.call_id.clone(),
456 tool_name: suspended_call.final_call.tool_name.clone(),
457 output_preview,
458 is_error,
459 args_preview: serde_json::to_string_pretty(
460 &suspended_call.final_call.input,
461 )
462 .unwrap_or_else(|_| {
463 suspended_call.final_call.input.to_string()
464 }),
465 },
466 );
447 }467 }
448 }468 }
449 469 
450 resumed_loop_state.messages.push(tool_result_msg.clone());470 drop_unanswered_tool_uses(&mut resumed_loop_state.messages);
451 
452 if let Some(sink) = loop_event_sink.as_ref() {
453 let output_preview =
454 serde_json::to_string(&serde_json::json!({ "terminal": terminal }))
455 .unwrap_or_default();
456 let is_error = terminal.status == subagent::SubagentTerminalKind::Failed;
457 sink.on_tool_result(
458 &input.agent_id,
459 &agent_types::events::ToolResultEvent {
460 call_id: suspended_call.final_call.call_id.clone(),
461 tool_name: suspended_call.final_call.tool_name.clone(),
462 output_preview,
463 is_error,
464 args_preview: serde_json::to_string_pretty(
465 &suspended_call.final_call.input,
466 )
467 .unwrap_or_else(|_| suspended_call.final_call.input.to_string()),
468 },
469 );
470 }
471 471 
472 loop_state = Some(resumed_loop_state.clone());472 loop_state = Some(resumed_loop_state.clone());
473 self.persist_lane_state(473 self.persist_lane_state(
@@ -853,6 +853,10 @@ fn runtime_input_from_session(
853 max_turns_override: Option<u32>,853 max_turns_override: Option<u32>,
854) -> SessionRuntimeBuildInput {854) -> SessionRuntimeBuildInput {
855 let is_subagent = agent_id != session.runtime.agent_id;855 let is_subagent = agent_id != session.runtime.agent_id;
856 let subagent_role_id = session
857 .agents
858 .get(&agent_id.0)
859 .and_then(|record| record.subagent_role_id.clone());
856 SessionRuntimeBuildInput {860 SessionRuntimeBuildInput {
857 session_id: session.session_id.clone(),861 session_id: session.session_id.clone(),
858 conversation_id: session.conversation_id.clone(),862 conversation_id: session.conversation_id.clone(),
@@ -863,6 +867,7 @@ fn runtime_input_from_session(
863 entry: session.entry.clone(),867 entry: session.entry.clone(),
864 agent_id_override: if is_subagent { Some(agent_id) } else { None },868 agent_id_override: if is_subagent { Some(agent_id) } else { None },
865 max_turns_override,869 max_turns_override,
870 subagent_role_id,
866 llm: session.runtime.llm.clone(),871 llm: session.runtime.llm.clone(),
867 }872 }
868}873}
@@ -950,6 +955,31 @@ fn terminal_from_outcome(
950 }955 }
951}956}
952 957 
958/// Remove assistant `ToolUse` blocks whose `call_id` has no matching `ToolResult`
959/// anywhere in the history, so a resumed conversation never sends a dangling
960/// tool_use (which providers reject). After every suspended call of a turn is
961/// resolved this is a no-op; it only fires for a sibling stranded by a stop
962/// short-circuit in the same batch.
963fn drop_unanswered_tool_uses(messages: &mut [agent_types::ChatMessage]) {
964 use agent_types::llm::{ContentBlock, MessageRole};
965 let answered: std::collections::HashSet<String> = messages
966 .iter()
967 .flat_map(|m| m.blocks.iter())
968 .filter_map(|b| match b {
969 ContentBlock::ToolResult { call_id, .. } => Some(call_id.clone()),
970 _ => None,
971 })
972 .collect();
973 for message in messages.iter_mut() {
974 if matches!(message.role, MessageRole::Assistant) {
975 message.blocks.retain(|b| match b {
976 ContentBlock::ToolUse { call_id, .. } => answered.contains(call_id),
977 _ => true,
978 });
979 }
980 }
981}
982 
953fn suspended_join_id(suspended_call: &SuspendedToolCall) -> Result<String, SessionServiceError> {983fn suspended_join_id(suspended_call: &SuspendedToolCall) -> Result<String, SessionServiceError> {
954 match &suspended_call.reason {984 match &suspended_call.reason {
955 LoopSuspendReason::ToolCall {985 LoopSuspendReason::ToolCall {
Mapps/shared/src/gateway/workspace_prompt.rs+147-0
@@ -109,6 +109,153 @@ directory and applicable parent directories. Later files are more specific and t
109 section.trim_end().to_string()109 section.trim_end().to_string()
110}110}
111 111 
112const REPO_MAP_MAX_FILES: usize = 50;
113const REPO_MAP_MAX_SIGS_PER_FILE: usize = 8;
114const REPO_MAP_MAX_BYTES: usize = 6000;
115const REPO_MAP_MAX_DEPTH: usize = 3;
116const REPO_MAP_MAX_VISIT: usize = 5000;
117const REPO_MAP_SKIP_DIRS: &[&str] = &[
118 "target",
119 "node_modules",
120 "__pycache__",
121 "dist",
122 "build",
123 "vendor",
124];
125 
126fn repo_map_is_source(name: &str) -> bool {
127 const EXTS: &[&str] = &[
128 "rs", "py", "js", "ts", "tsx", "jsx", "go", "java", "rb", "c", "cc", "cpp", "h", "hpp",
129 "cs", "php", "kt", "swift", "scala", "sh", "lua", "ex", "exs",
130 ];
131 name.rsplit('.')
132 .next()
133 .map(|e| EXTS.contains(&e))
134 .unwrap_or(false)
135}
136 
137fn repo_map_collect(dir: &Path, out: &mut Vec<PathBuf>, visited: &mut usize, depth: usize) {
138 if depth > REPO_MAP_MAX_DEPTH || *visited >= REPO_MAP_MAX_VISIT {
139 return;
140 }
141 let Ok(entries) = std::fs::read_dir(dir) else {
142 return;
143 };
144 let mut subdirs = Vec::new();
145 for entry in entries.flatten() {
146 *visited += 1;
147 if *visited >= REPO_MAP_MAX_VISIT {
148 break;
149 }
150 let path = entry.path();
151 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
152 continue;
153 };
154 let Ok(file_type) = entry.file_type() else {
155 continue;
156 };
157 if file_type.is_dir() {
158 if name.starts_with('.') || REPO_MAP_SKIP_DIRS.contains(&name) {
159 continue;
160 }
161 subdirs.push(path);
162 } else if file_type.is_file() && repo_map_is_source(name) {
163 out.push(path);
164 }
165 }
166 for sub in subdirs {
167 repo_map_collect(&sub, out, visited, depth + 1);
168 }
169}
170 
171fn repo_map_signatures(path: &Path) -> Vec<String> {
172 const KW: &[&str] = &[
173 "pub fn ",
174 "pub async fn ",
175 "async fn ",
176 "fn ",
177 "pub struct ",
178 "struct ",
179 "pub enum ",
180 "enum ",
181 "pub trait ",
182 "trait ",
183 "impl ",
184 "def ",
185 "async def ",
186 "class ",
187 "func ",
188 "function ",
189 "export function ",
190 "export class ",
191 "export const ",
192 "export default ",
193 "interface ",
194 "type ",
195 ];
196 match std::fs::metadata(path) {
197 Ok(meta) if meta.len() <= 512 * 1024 => {}
198 _ => return Vec::new(),
199 }
200 let Ok(content) = std::fs::read_to_string(path) else {
201 return Vec::new();
202 };
203 let mut sigs = Vec::new();
204 for line in content.lines().take(3000) {
205 let trimmed = line.trim_start();
206 if KW.iter().any(|kw| trimmed.starts_with(kw)) {
207 let mut sig: String = trimmed.chars().take(110).collect();
208 if let Some(idx) = sig.find(|c| c == '{' || c == ';' || c == '=') {
209 sig.truncate(idx);
210 }
211 let sig = sig.trim_end().to_string();
212 if !sig.is_empty() {
213 sigs.push(sig);
214 }
215 if sigs.len() >= REPO_MAP_MAX_SIGS_PER_FILE {
216 break;
217 }
218 }
219 }
220 sigs
221}
222 
223pub fn compose_repo_map(workspace_root: &Path) -> Option<String> {
224 let root = workspace_root
225 .canonicalize()
226 .unwrap_or_else(|_| workspace_root.to_path_buf());
227 let mut files = Vec::new();
228 let mut visited = 0usize;
229 repo_map_collect(&root, &mut files, &mut visited, 0);
230 if files.is_empty() {
231 return None;
232 }
233 files.sort();
234 files.truncate(REPO_MAP_MAX_FILES);
235 let mut out = String::from(
236 "## Repository map\nStatic overview of source files and their top-level definitions \
237(not exhaustive; use glob/grep for full detail):\n",
238 );
239 for file in &files {
240 let rel = file
241 .strip_prefix(&root)
242 .unwrap_or(file)
243 .display()
244 .to_string();
245 out.push('\n');
246 out.push_str(&rel);
247 for sig in repo_map_signatures(file) {
248 out.push_str("\n ");
249 out.push_str(&sig);
250 }
251 if out.len() >= REPO_MAP_MAX_BYTES {
252 out.push_str("\n…[repo map truncated]…");
253 break;
254 }
255 }
256 Some(out.trim_end().to_string())
257}
258 
112#[cfg(test)]259#[cfg(test)]
113mod tests {260mod tests {
114 use super::compose_workspace_system_prompt;261 use super::compose_workspace_system_prompt;
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
16futures-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+691-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};
13use agent_types::tool::{RawToolCall, RawToolOutcome, ToolExecutionResult};13use 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 
@@ -707,6 +707,92 @@ fn microcompact(ctx: &mut LoopContext<'_>) {
707 }707 }
708}708}
709 709 
710fn prune_stale_tool_output(messages: &mut [ChatMessage]) {
711 const KEEP_RECENT_TOOL_BYTES: usize = 40_000;
712 const MIN_PRUNABLE_BYTES: usize = 1_000;
713 const PRUNED_MARKER: &str =
714 "[older tool output pruned to save context — re-run the tool or read the file if you still need it]";
715 let mut kept = 0usize;
716 let mut pruned = 0usize;
717 for message in messages.iter_mut().rev() {
718 for block in message.blocks.iter_mut() {
719 if let ContentBlock::ToolResult { output, .. } = block {
720 if output.as_str() == PRUNED_MARKER {
721 continue;
722 }
723 if kept < KEEP_RECENT_TOOL_BYTES {
724 kept += output.len();
725 } else if output.len() > MIN_PRUNABLE_BYTES {
726 *output = PRUNED_MARKER.to_string();
727 pruned += 1;
728 }
729 }
730 }
731 }
732 if pruned > 0 {
733 tracing::debug!(pruned, "pruned stale tool output beyond recent window");
734 }
735}
736 
737/// Per-turn dynamic context re-injected into the system prompt: the remaining
738/// horizon and the live `todo_write` plan. Rendered into the volatile tail of
739/// the system message (see `prompt::compose`), after the cache-stable prefix.
740fn live_context_snippets(
741 ctx: &LoopContext<'_>,
742) -> Vec<agent_types::context::prompt::MemorySnippet> {
743 use agent_types::context::prompt::MemorySnippet;
744 let mut snippets = Vec::new();
745 
746 let turn = ctx.turn.turn_number;
747 let max_turns = ctx.snapshot.max_turns;
748 let tokens_used = ctx.state.token_usage.total_tokens;
749 let remaining = max_turns.saturating_sub(turn);
750 if max_turns > 0 && remaining <= 5 {
751 let horizon = format!(
752 "- 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."
753 );
754 snippets.push(MemorySnippet {
755 source: "horizon".to_string(),
756 content: horizon,
757 relevance_score: 1.0,
758 });
759 }
760 
761 let window = ctx.snapshot.token_budget_config.total_budget;
762 let context_input = ctx.state.token_usage.prompt_tokens;
763 if window > 0 {
764 let pct = context_input.saturating_mul(100) / window;
765 if pct >= 25 {
766 let mut line =
767 format!("- context window: ~{pct}% used ({context_input}/{window} input tokens)");
768 if pct >= 75 {
769 line.push_str(
770 " — running full; converge and finish before the earliest context is compacted away.",
771 );
772 }
773 snippets.push(MemorySnippet {
774 source: "budget".to_string(),
775 content: line,
776 relevance_score: 0.95,
777 });
778 }
779 }
780 
781 // Active plan: open `todo_write` items for this session, re-injected every
782 // turn so plan state is load-bearing rather than write-only.
783 if let Some(runtime_view) = ctx.input.runtime_view.as_ref() {
784 for line in tool::open_todo_lines(runtime_view.as_ref()) {
785 snippets.push(MemorySnippet {
786 source: "plan".to_string(),
787 content: line,
788 relevance_score: 0.9,
789 });
790 }
791 }
792 
793 snippets
794}
795 
710async fn build_messages(ctx: &mut LoopContext<'_>) -> Result<(), AgentError> {796async fn build_messages(ctx: &mut LoopContext<'_>) -> Result<(), AgentError> {
711 let skill_summaries = ctx.snapshot.skill_registry.list_skills();797 let skill_summaries = ctx.snapshot.skill_registry.list_skills();
712 798 
@@ -735,12 +821,23 @@ async fn build_messages(ctx: &mut LoopContext<'_>) -> Result<(), AgentError> {
735 None821 None
736 };822 };
737 823 
824 let is_final_turn =
825 ctx.snapshot.max_turns > 0 && ctx.turn.turn_number >= ctx.snapshot.max_turns;
826 let visible_tools = if is_final_turn {
827 Vec::new()
828 } else {
829 ctx.input.visible_tools.clone()
830 };
831 
832 let mut projected_messages = ctx.state.messages.read().clone();
833 prune_stale_tool_output(&mut projected_messages);
834 
738 let input = PromptBuildInput {835 let input = PromptBuildInput {
739 system_prompt: ctx.snapshot.system_prompt.to_string(),836 system_prompt: ctx.snapshot.system_prompt.to_string(),
740 messages: ctx.state.messages.read().clone(),837 messages: projected_messages,
741 visible_tools: ctx.input.visible_tools.clone(),838 visible_tools,
742 skill_summaries,839 skill_summaries,
743 memory_snippets: Vec::new(),840 memory_snippets: live_context_snippets(ctx),
744 environment: agent_types::context::prompt::EnvironmentInfo {841 environment: agent_types::context::prompt::EnvironmentInfo {
745 model: String::new(),842 model: String::new(),
746 cwd: String::new(),843 cwd: String::new(),
@@ -1020,7 +1117,11 @@ const TRANSIENT_MAX_DELAY_MS: u64 = 60_000;
1020fn is_transient(error: &LlmError) -> bool {1117fn is_transient(error: &LlmError) -> bool {
1021 matches!(1118 matches!(
1022 error,1119 error,
1023 LlmError::RateLimited { .. } | LlmError::HttpError(_) | LlmError::Timeout1120 LlmError::RateLimited { .. }
1121 | LlmError::HttpError(_)
1122 | LlmError::Timeout
1123 | LlmError::StreamError { .. }
1124 | LlmError::IoError(_)
1024 )1125 )
1025}1126}
1026 1127 
@@ -1112,7 +1213,7 @@ fn stream_assistant_chunk(
1112 }1213 }
1113}1214}
1114 1215 
1115async fn tool_exec(ctx: &mut LoopContext<'_>) -> Result<Option<SuspendedToolCall>, AgentError> {1216async fn tool_exec(ctx: &mut LoopContext<'_>) -> Result<Vec<SuspendedToolCall>, AgentError> {
1116 let has_tool_calls = ctx1217 let has_tool_calls = ctx
1117 .turn1218 .turn
1118 .assistant_message1219 .assistant_message
@@ -1120,22 +1221,23 @@ async fn tool_exec(ctx: &mut LoopContext<'_>) -> Result<Option<SuspendedToolCall
1120 .map_or(false, |m| m.has_tool_calls());1221 .map_or(false, |m| m.has_tool_calls());
1121 1222 
1122 if ctx.turn.assistant_message.is_none() {1223 if ctx.turn.assistant_message.is_none() {
1123 return Ok(None);1224 return Ok(Vec::new());
1124 }1225 }
1125 1226 
1126 if !has_tool_calls || !ctx.snapshot.feature_flags.tool_execution {1227 if !has_tool_calls || !ctx.snapshot.feature_flags.tool_execution {
1127 append_assistant_to_history(ctx);1228 append_assistant_to_history(ctx);
1128 return Ok(None);1229 return Ok(Vec::new());
1129 }1230 }
1130 1231 
1131 if ctx.input.runtime_view.is_none() {1232 if ctx.input.runtime_view.is_none() {
1132 append_assistant_to_history(ctx);1233 append_assistant_to_history(ctx);
1133 return Ok(None);1234 return Ok(Vec::new());
1134 }1235 }
1135 1236 
1136 // Repair empty call_ids before the validity partition below.1237 // Repair empty call_ids before the validity partition below.
1137 if let Some(msg) = ctx.turn.assistant_message.as_mut() {1238 if let Some(msg) = ctx.turn.assistant_message.as_mut() {
1138 synthesize_missing_call_ids(msg, ctx.state.turn_count);1239 synthesize_missing_call_ids(msg, ctx.state.turn_count);
1240 repair_tool_names(msg, &ctx.input.visible_tools);
1139 }1241 }
1140 1242 
1141 let tool_calls: Vec<ToolUseBlock> = ctx1243 let tool_calls: Vec<ToolUseBlock> = ctx
@@ -1147,7 +1249,7 @@ async fn tool_exec(ctx: &mut LoopContext<'_>) -> Result<Option<SuspendedToolCall
1147 .clone();1249 .clone();
1148 1250 
1149 if ctx.input.agent_id.is_none() {1251 if ctx.input.agent_id.is_none() {
1150 return Ok(None);1252 return Ok(Vec::new());
1151 }1253 }
1152 1254 
1153 // Partition tool calls into valid (non-empty call_id + tool_name) and invalid.1255 // Partition tool calls into valid (non-empty call_id + tool_name) and invalid.
@@ -1211,6 +1313,9 @@ async fn tool_exec(ctx: &mut LoopContext<'_>) -> Result<Option<SuspendedToolCall
1211 (valid_calls, invalid_calls)1313 (valid_calls, invalid_calls)
1212 };1314 };
1213 1315 
1316 let mut valid_calls = valid_calls;
1317 valid_calls.sort_by_key(|tc| tc.tool_name == "join_subagent");
1318 
1214 if let Some(msg) = ctx.turn.assistant_message.as_mut() {1319 if let Some(msg) = ctx.turn.assistant_message.as_mut() {
1215 msg.tool_calls = valid_calls.clone();1320 msg.tool_calls = valid_calls.clone();
1216 }1321 }
@@ -1252,9 +1357,8 @@ async fn tool_exec(ctx: &mut LoopContext<'_>) -> Result<Option<SuspendedToolCall
1252 }1357 }
1253 }1358 }
1254 1359 
1255 // Execute valid tool calls (original logic).1360 // Pass 1 build every call (borrows ctx for the per-call tool filter).
1256 let runtime_view = ctx.input.runtime_view.as_ref().unwrap();1361 let mut built = Vec::with_capacity(valid_calls.len());
1257 
1258 for tc in &valid_calls {1362 for tc in &valid_calls {
1259 let raw_tool_call = RawToolCall {1363 let raw_tool_call = RawToolCall {
1260 call_id: tc.call_id.clone(),1364 call_id: tc.call_id.clone(),
@@ -1272,56 +1376,196 @@ async fn tool_exec(ctx: &mut LoopContext<'_>) -> Result<Option<SuspendedToolCall
1272 ctx.snapshot.tool_registry.as_ref(),1376 ctx.snapshot.tool_registry.as_ref(),
1273 );1377 );
1274 1378 
1275 let tool_call = match ToolCallBuilderImpl::new()1379 match ToolCallBuilderImpl::new()
1276 .with_raw_llm_tool_call(raw_tool_call)1380 .with_raw_llm_tool_call(raw_tool_call)
1277 .with_tool_filter(per_call_filter)1381 .with_tool_filter(per_call_filter)
1278 .build()1382 .build()
1279 {1383 {
1280 Ok(tool_call) => tool_call,1384 Ok(tool_call) => built.push(Ok(tool_call)),
1281 Err(error) => {1385 Err(error) => built.push(Err(build_framework_failed_tool_result(
1282 let result = build_framework_failed_tool_result(1386 fallback_final_call,
1283 fallback_final_call,1387 format!("tool call build failed: {error}"),
1284 format!("tool call build failed: {error}"),1388 ))),
1285 );1389 }
1286 emit_tool_result_event(ctx, &result);1390 }
1287 let tool_result_message = build_tool_result_message(&result);
1288 ctx.state.messages.write().push(tool_result_message);
1289 ctx.turn.tool_results.push(result);
1290 continue;
1291 }
1292 };
1293 1391 
1294 let result = match tool_call.execute(&**runtime_view).await {1392 let serialize_batch = {
1295 Ok(result) => result,1393 let profiles: std::collections::HashMap<&str, &EffectProfile> = ctx
1296 Err(error) => {1394 .input
1297 let result =1395 .visible_tools
1298 build_framework_failed_tool_result(fallback_final_call, error.to_string());1396 .iter()
1299 emit_tool_result_event(ctx, &result);1397 .map(|tool| (tool.name().0.as_str(), tool.effect_profile()))
1300 let tool_result_message = build_tool_result_message(&result);1398 .collect();
1301 ctx.state.messages.write().push(tool_result_message);1399 !built.iter().filter_map(|b| b.as_ref().ok()).all(|call| {
1302 ctx.turn.tool_results.push(result);1400 profiles
1303 continue;1401 .get(call.final_call().tool_name.as_str())
1402 .is_some_and(|profile| is_parallel_safe(profile))
1403 })
1404 };
1405 
1406 // Pass 2 — execute the successfully built calls. Clone the Arc runtime handle
1407 // so the futures borrow it, not `ctx` (post-processing needs `&mut ctx`).
1408 // Both paths preserve input order, so Pass 3/4 are unaffected.
1409 let runtime_view = ctx.input.runtime_view.clone().unwrap();
1410 let exec_outcomes: Vec<_> = if serialize_batch {
1411 let mut outcomes = Vec::new();
1412 for call in built.iter().filter_map(|b| b.as_ref().ok()) {
1413 outcomes.push(call.execute(&*runtime_view).await);
1414 }
1415 outcomes
1416 } else {
1417 futures_util::future::join_all(
1418 built
1419 .iter()
1420 .filter_map(|b| b.as_ref().ok().map(|call| call.execute(&*runtime_view))),
1421 )
1422 .await
1423 };
1424 
1425 // Pass 3 — stitch outcomes back into call order, pairing each executed call
1426 // with its result (build failures already carry their own result).
1427 let mut exec_outcomes = exec_outcomes.into_iter();
1428 let mut results: Vec<ToolExecutionResult> = Vec::with_capacity(built.len());
1429 for entry in built {
1430 match entry {
1431 Err(failed_result) => results.push(failed_result),
1432 Ok(tool_call) => {
1433 let result = match exec_outcomes.next().expect("one outcome per executed call") {
1434 Ok(result) => result,
1435 Err(error) => build_framework_failed_tool_result(
1436 tool_call.final_call().clone(),
1437 error.to_string(),
1438 ),
1439 };
1440 results.push(result);
1304 }1441 }
1305 };1442 }
1306 let should_stop_after_result = should_stop_after_tool_result(ctx, &result);1443 }
1444 
1445 // Pass 4 — record results in call order. Suspending calls (`join_subagent`)
1446 // are sorted last (above), so by the time the first one is seen every
1447 // side-effecting sibling already has its tool_result recorded.
1448 let mut streak_note: Option<String> = None;
1449 let mut suspended_calls: Vec<SuspendedToolCall> = Vec::new();
1450 let mut stop_after_batch = false;
1451 for result in results {
1452 ctx.state.tool_executed = true;
1453 if should_stop_after_tool_result(ctx, &result) {
1454 stop_after_batch = true;
1455 }
1307 emit_tool_result_event(ctx, &result);1456 emit_tool_result_event(ctx, &result);
1308 1457 
1309 if let Some(suspended_call) = SuspendedToolCall::from_tool_result(&result) {1458 if let Some(suspended_call) = SuspendedToolCall::from_tool_result(&result) {
1459 // Defer: no tool_result message now (the resumer appends it once the
1460 // child finishes). Recording the raw result keeps tool_results complete.
1310 ctx.turn.tool_results.push(result);1461 ctx.turn.tool_results.push(result);
1311 return Ok(Some(suspended_call));1462 suspended_calls.push(suspended_call);
1463 continue;
1312 }1464 }
1313 1465 
1314 let tool_result_message = build_tool_result_message(&result);1466 let tool_result_message = build_tool_result_message(&result);
1315 ctx.state.messages.write().push(tool_result_message);1467 ctx.state.messages.write().push(tool_result_message);
1468 // Track repeated identical failing calls; any note is pushed after all
1469 // tool results so the assistant/tool-result protocol stays intact.
1470 if let Some(note) = update_tool_failure_streak(ctx, &result) {
1471 streak_note = Some(note);
1472 }
1316 ctx.turn.tool_results.push(result);1473 ctx.turn.tool_results.push(result);
1474 }
1317 1475 
1318 if should_stop_after_result {1476 if stop_after_batch && suspended_calls.is_empty() {
1319 ctx.turn.force_return_complete = true;1477 ctx.turn.force_return_complete = true;
1320 break;1478 }
1479 
1480 // A pending suspend must not be followed by an injected user message: the
1481 // resumer still has to slot tool_result(s) right after the assistant turn, so
1482 // hold the streak nudge until everything is resolved (drop it this turn).
1483 if suspended_calls.is_empty() {
1484 if let Some(note) = streak_note {
1485 ctx.state.messages.write().push(ChatMessage::user(note));
1321 }1486 }
1322 }1487 }
1323 1488 
1324 Ok(None)1489 Ok(suspended_calls)
1490}
1491 
1492const REPEATED_FAILURE_THRESHOLD: u32 = 3;
1493const REPEATED_SUCCESS_THRESHOLD: u32 = 3;
1494 
1495fn update_tool_failure_streak(
1496 ctx: &mut LoopContext<'_>,
1497 result: &ToolExecutionResult,
1498) -> Option<String> {
1499 let sig = tool_call_signature(result);
1500 if is_failure_result(result) {
1501 ctx.state.last_success_sig = None;
1502 ctx.state.repeated_success_count = 0;
1503 if ctx.state.last_failure_sig == Some(sig) {
1504 ctx.state.repeated_failure_count += 1;
1505 } else {
1506 ctx.state.last_failure_sig = Some(sig);
1507 ctx.state.repeated_failure_count = 1;
1508 }
1509 if ctx.state.repeated_failure_count >= REPEATED_FAILURE_THRESHOLD {
1510 let count = ctx.state.repeated_failure_count;
1511 let tool = result.tool_name().to_string();
1512 ctx.state.repeated_failure_count = 0;
1513 ctx.state.last_failure_sig = None;
1514 return Some(format!(
1515 "The `{tool}` call has now failed {count} times in a row with identical arguments. \
1516 Stop retrying it unchanged — change approach: fix the arguments, read the relevant \
1517 file or state to understand why it fails, or use a different tool to reach the goal."
1518 ));
1519 }
1520 return None;
1521 }
1522 ctx.state.last_failure_sig = None;
1523 ctx.state.repeated_failure_count = 0;
1524 if ctx.state.last_success_sig == Some(sig) {
1525 ctx.state.repeated_success_count += 1;
1526 } else {
1527 ctx.state.last_success_sig = Some(sig);
1528 ctx.state.repeated_success_count = 1;
1529 }
1530 if ctx.state.repeated_success_count >= REPEATED_SUCCESS_THRESHOLD {
1531 let count = ctx.state.repeated_success_count;
1532 let tool = result.tool_name().to_string();
1533 ctx.state.repeated_success_count = 0;
1534 ctx.state.last_success_sig = None;
1535 return Some(format!(
1536 "The `{tool}` call has now run {count} times in a row with identical arguments and the \
1537 same result — that output is already in your context above. Stop repeating it: use \
1538 what you have, or take a different action toward the goal."
1539 ));
1540 }
1541 None
1542}
1543 
1544fn is_parallel_safe(profile: &EffectProfile) -> bool {
1545 !profile.writes_filesystem
1546 && !profile.side_effects
1547 && (profile.reads_filesystem || profile.network_access)
1548}
1549 
1550fn is_failure_result(result: &ToolExecutionResult) -> bool {
1551 matches!(
1552 result,
1553 ToolExecutionResult::Completed {
1554 raw_outcome: RawToolOutcome::Error { .. },
1555 ..
1556 } | ToolExecutionResult::Failed { .. }
1557 | ToolExecutionResult::Denied { .. }
1558 )
1559}
1560 
1561fn tool_call_signature(result: &ToolExecutionResult) -> u64 {
1562 use std::hash::{Hash, Hasher};
1563 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1564 result.tool_name().hash(&mut hasher);
1565 serde_json::to_string(&result.final_call().input)
1566 .unwrap_or_default()
1567 .hash(&mut hasher);
1568 hasher.finish()
1325}1569}
1326 1570 
1327/// Fill empty `call_id`s with a stable, turn-scoped id (`call_<turn>_<idx>`) so a1571/// Fill empty `call_id`s with a stable, turn-scoped id (`call_<turn>_<idx>`) so a
@@ -1335,6 +1579,41 @@ fn synthesize_missing_call_ids(msg: &mut AssistantMessage, turn: u32) {
1335 }1579 }
1336}1580}
1337 1581 
1582fn repair_tool_names(
1583 msg: &mut AssistantMessage,
1584 visible: &[std::sync::Arc<dyn agent_contracts::tool::ToolSpecView>],
1585) {
1586 if visible.is_empty() {
1587 return;
1588 }
1589 let normalize = |s: &str| -> String {
1590 s.chars()
1591 .filter(|c| c.is_ascii_alphanumeric())
1592 .map(|c| c.to_ascii_lowercase())
1593 .collect()
1594 };
1595 let mut canonical = std::collections::HashSet::new();
1596 let mut normalized = std::collections::HashMap::new();
1597 for tool in visible {
1598 let name = tool.name().0.clone();
1599 normalized
1600 .entry(normalize(&name))
1601 .or_insert_with(|| name.clone());
1602 canonical.insert(name);
1603 }
1604 for tc in msg.tool_calls.iter_mut() {
1605 if canonical.contains(&tc.tool_name) {
1606 continue;
1607 }
1608 if let Some(fixed) = normalized.get(&normalize(&tc.tool_name)) {
1609 if *fixed != tc.tool_name {
1610 tracing::debug!(from = %tc.tool_name, to = %fixed, "repaired tool name");
1611 tc.tool_name = fixed.clone();
1612 }
1613 }
1614 }
1615}
1616 
1338fn is_valid_tool_call(tc: &ToolUseBlock) -> bool {1617fn is_valid_tool_call(tc: &ToolUseBlock) -> bool {
1339 is_valid_tool_call_id(&tc.call_id) && is_valid_tool_name(&tc.tool_name)1618 is_valid_tool_call_id(&tc.call_id) && is_valid_tool_name(&tc.tool_name)
1340}1619}
@@ -1590,6 +1869,52 @@ fn decide(ctx: &mut LoopContext<'_>) {
1590 }1869 }
1591 }1870 }
1592 1871 
1872 // Don't accept a stop while the model still has open plan items. The first
1873 // such stop triggers one reminder (bounded by `plan_nudged`, so never an
1874 // infinite loop — if the model stops again it completes). Only fires when the
1875 // model actually used `todo_write` and left items open.
1876 if !ctx.state.plan_nudged && ctx.turn.turn_number < ctx.snapshot.max_turns {
1877 let open = ctx
1878 .input
1879 .runtime_view
1880 .as_ref()
1881 .map(|runtime_view| tool::open_todo_lines(runtime_view.as_ref()))
1882 .unwrap_or_default();
1883 if !open.is_empty() {
1884 ctx.state.plan_nudged = true;
1885 let reminder = format!(
1886 "You are about to stop, but your plan still has {} open item(s):\n{}\n\
1887 Finish them now, or call todo_write to mark them completed/cancelled if they no longer apply — then stop.",
1888 open.len(),
1889 open.join("\n")
1890 );
1891 ctx.state.messages.write().push(ChatMessage::user(reminder));
1892 ctx.turn.decision = Some(LoopDecision::Continue);
1893 return;
1894 }
1895 }
1896 
1897 // Only nudge agentic runs: the checklist is about verifying a code change, so
1898 // it is noise for a tool-less, conversational turn (and would force every such
1899 // reply through a wasted extra round-trip). Gate on tools being available.
1900 if !ctx.state.completion_nudged
1901 && ctx.turn.turn_number < ctx.snapshot.max_turns
1902 && ctx.state.tool_executed
1903 {
1904 ctx.state.completion_nudged = true;
1905 let checklist = "You are about to finish. Before you stop, re-read the ORIGINAL task and verify, do not assume:\n\
1906 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\
1907 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\
1908 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\
1909 If any check fails, fix it now. If all hold, stop again and you are done.";
1910 ctx.state
1911 .messages
1912 .write()
1913 .push(ChatMessage::user(checklist.to_string()));
1914 ctx.turn.decision = Some(LoopDecision::Continue);
1915 return;
1916 }
1917 
1593 ctx.turn.decision = Some(LoopDecision::ReturnComplete);1918 ctx.turn.decision = Some(LoopDecision::ReturnComplete);
1594}1919}
1595 1920 
@@ -1838,9 +2163,10 @@ mod tests {
1838 use std::sync::{Arc, Mutex as StdMutex};2163 use std::sync::{Arc, Mutex as StdMutex};
1839 2164 
1840 use agent_contracts::context::budget::TokenBudgetPolicy;2165 use agent_contracts::context::budget::TokenBudgetPolicy;
1841 use agent_contracts::tool::ToolSpecView;2166 use agent_contracts::tool::{ToolExecutor, ToolFilter, ToolRegistry, ToolSpecView};
1842 use agent_contracts::{2167 use agent_contracts::{
1843 CompressionPipeline, LlmProvider, PromptBuilder, ProviderCapabilities, SkillRegistry,2168 CompressionPipeline, LlmProvider, PromptBuilder, ProviderCapabilities, RuntimeView,
2169 SkillRegistry,
1844 };2170 };
1845 use agent_llm::LlmRequestExt;2171 use agent_llm::LlmRequestExt;
1846 use agent_types::common::ids::{AgentId, ToolId, ToolName};2172 use agent_types::common::ids::{AgentId, ToolId, ToolName};
@@ -1848,7 +2174,9 @@ mod tests {
1848 use agent_types::context::prompt::{PromptBuildError, PromptBuildResult};2174 use agent_types::context::prompt::{PromptBuildError, PromptBuildResult};
1849 use agent_types::context::{FeatureFlags, TokenBudgetConfig};2175 use agent_types::context::{FeatureFlags, TokenBudgetConfig};
1850 use agent_types::events::LoopEndSummary;2176 use agent_types::events::LoopEndSummary;
2177 use agent_types::tool::execution_types::{ToolExecutionError, ToolExecutorOutput};
1851 use agent_types::tool::spec_types::{EffectProfile, InputSchemaRef, OutputContract};2178 use agent_types::tool::spec_types::{EffectProfile, InputSchemaRef, OutputContract};
2179 use agent_types::tool::FinalToolCall;
1852 use agent_types::{2180 use agent_types::{
1853 AssistantMessage, LlmError, LlmRequest, LlmResponse, StopReason, StreamChunk, ToolUseBlock,2181 AssistantMessage, LlmError, LlmRequest, LlmResponse, StopReason, StreamChunk, ToolUseBlock,
1854 Usage,2182 Usage,
@@ -2318,11 +2646,18 @@ mod tests {
2318 fn test_runtime_with_max_turns(2646 fn test_runtime_with_max_turns(
2319 provider: Arc<LlmProviderWrapper>,2647 provider: Arc<LlmProviderWrapper>,
2320 max_turns: u32,2648 max_turns: u32,
2649 ) -> AgentRuntime {
2650 test_runtime_with_registry(provider, max_turns, Arc::new(EmptyToolRegistry::new()))
2651 }
2652 
2653 fn test_runtime_with_registry(
2654 provider: Arc<LlmProviderWrapper>,
2655 max_turns: u32,
2656 tool_registry: Arc<dyn ToolRegistry>,
2321 ) -> AgentRuntime {2657 ) -> AgentRuntime {
2322 let prompt_builder: Arc<dyn PromptBuilder> = Arc::new(FixedPromptBuilder);2658 let prompt_builder: Arc<dyn PromptBuilder> = Arc::new(FixedPromptBuilder);
2323 let compression_pipeline: Arc<dyn CompressionPipeline> =2659 let compression_pipeline: Arc<dyn CompressionPipeline> =
2324 Arc::new(compact::PassthroughCompressionPipeline::new());2660 Arc::new(compact::PassthroughCompressionPipeline::new());
2325 let tool_registry = Arc::new(EmptyToolRegistry::new());
2326 let skill_registry: Arc<dyn SkillRegistry> = Arc::new(EmptySkillRegistry::new());2661 let skill_registry: Arc<dyn SkillRegistry> = Arc::new(EmptySkillRegistry::new());
2327 let budget_config = TokenBudgetConfig {2662 let budget_config = TokenBudgetConfig {
2328 total_budget: 4096,2663 total_budget: 4096,
@@ -2635,7 +2970,10 @@ mod tests {
2635 outcome,2970 outcome,
2636 LoopRunResult::Complete(AgentOutcome::Complete { .. })2971 LoopRunResult::Complete(AgentOutcome::Complete { .. })
2637 ));2972 ));
2638 assert_eq!(loop_state.turn_count, 2);2973 // turn 1: synthesize + run the tool; turn 2: model stops and the
2974 // completion nudge (tools are visible here) adds one verification turn;
2975 // turn 3: model stops again and the loop completes.
2976 assert_eq!(loop_state.turn_count, 3);
2639 2977 
2640 let messages = loop_state.messages.read();2978 let messages = loop_state.messages.read();
2641 let tool_use = messages.iter().find_map(|m| {2979 let tool_use = messages.iter().find_map(|m| {
@@ -2833,4 +3171,305 @@ mod tests {
2833 let secrets3 = extract_secrets_from_messages(&vec![message_other_tool]);3171 let secrets3 = extract_secrets_from_messages(&vec![message_other_tool]);
2834 assert_eq!(secrets3.len(), 0);3172 assert_eq!(secrets3.len(), 0);
2835 }3173 }
3174 
3175 #[test]
3176 fn is_parallel_safe_allows_pure_readers_only() {
3177 let reader = EffectProfile {
3178 reads_filesystem: true,
3179 writes_filesystem: false,
3180 network_access: false,
3181 side_effects: false,
3182 };
3183 let network_reader = EffectProfile {
3184 reads_filesystem: false,
3185 writes_filesystem: false,
3186 network_access: true,
3187 side_effects: false,
3188 };
3189 assert!(is_parallel_safe(&reader));
3190 assert!(is_parallel_safe(&network_reader));
3191 }
3192 
3193 #[test]
3194 fn is_parallel_safe_serializes_writers_side_effects_and_interactive() {
3195 let writer = EffectProfile {
3196 reads_filesystem: true,
3197 writes_filesystem: true,
3198 network_access: false,
3199 side_effects: false,
3200 };
3201 let side_effecting = EffectProfile {
3202 reads_filesystem: true,
3203 writes_filesystem: true,
3204 network_access: false,
3205 side_effects: true,
3206 };
3207 let stateful = EffectProfile {
3208 reads_filesystem: false,
3209 writes_filesystem: false,
3210 network_access: false,
3211 side_effects: true,
3212 };
3213 let interactive = EffectProfile::default();
3214 assert!(!is_parallel_safe(&writer));
3215 assert!(!is_parallel_safe(&side_effecting));
3216 assert!(!is_parallel_safe(&stateful));
3217 assert!(
3218 !is_parallel_safe(&interactive),
3219 "an interactive prompt declares no read/network and must serialize"
3220 );
3221 }
3222 
3223 #[tokio::test]
3224 async fn completion_nudge_skips_conversational_run_with_visible_tools() {
3225 let provider = Arc::new(LlmProviderWrapper::new(
3226 Arc::new(StreamingTestProvider::new()),
3227 None,
3228 None,
3229 ));
3230 let runtime = test_runtime(provider);
3231 let input = AgentLoopInput::new("explain how this code works")
3232 .with_agent_id(AgentId("test-agent".to_string()))
3233 .with_visible_tools(dummy_visible_tools())
3234 .with_runtime_view(Arc::new(NoopRuntimeView::new()));
3235 let mut loop_state = LoopState::new(uuid::Uuid::new_v4());
3236 
3237 let outcome = run_agent_loop(&runtime, &mut loop_state, input)
3238 .await
3239 .expect("conversational loop should complete");
3240 
3241 assert!(matches!(
3242 outcome,
3243 LoopRunResult::Complete(AgentOutcome::Complete { .. })
3244 ));
3245 assert!(
3246 !loop_state.tool_executed,
3247 "no tool ran, so the run is conversational"
3248 );
3249 assert_eq!(loop_state.turn_count, 1);
3250 }
3251 
3252 struct AlwaysSucceedsExecutor {
3253 spec: Arc<VisibleToolSpec>,
3254 }
3255 
3256 #[async_trait]
3257 impl ToolExecutor for AlwaysSucceedsExecutor {
3258 fn spec(&self) -> &dyn ToolSpecView {
3259 self.spec.as_ref()
3260 }
3261 
3262 async fn invoke(
3263 &self,
3264 call: &FinalToolCall,
3265 _runtime: &dyn RuntimeView,
3266 ) -> Result<ToolExecutorOutput, ToolExecutionError> {
3267 Ok(ToolExecutorOutput::Completed {
3268 raw_outcome: RawToolOutcome::Success {
3269 output: format!("ran {}", call.call_id),
3270 },
3271 })
3272 }
3273 }
3274 
3275 struct SingleToolRegistry {
3276 spec: Arc<VisibleToolSpec>,
3277 executor: Arc<dyn ToolExecutor>,
3278 }
3279 
3280 impl SingleToolRegistry {
3281 fn new() -> Self {
3282 let spec = Arc::new(VisibleToolSpec {
3283 id: ToolId("tool.peek".to_string()),
3284 name: ToolName("peek".to_string()),
3285 description: "Read-only peek".to_string(),
3286 input_schema: InputSchemaRef {
3287 schema: serde_json::json!({"type": "object"}),
3288 },
3289 output_contract: OutputContract {
3290 description: "peeked".to_string(),
3291 },
3292 effect_profile: EffectProfile {
3293 reads_filesystem: true,
3294 writes_filesystem: false,
3295 network_access: false,
3296 side_effects: false,
3297 },
3298 });
3299 let executor: Arc<dyn ToolExecutor> = Arc::new(AlwaysSucceedsExecutor {
3300 spec: Arc::clone(&spec),
3301 });
3302 Self { spec, executor }
3303 }
3304 
3305 fn visible(&self) -> Vec<Arc<dyn ToolSpecView>> {
3306 vec![Arc::clone(&self.spec) as Arc<dyn ToolSpecView>]
3307 }
3308 }
3309 
3310 impl ToolRegistry for SingleToolRegistry {
3311 fn get_executor(&self, id: &ToolId) -> Option<Arc<dyn ToolExecutor>> {
3312 (id == self.spec.id()).then(|| Arc::clone(&self.executor))
3313 }
3314 
3315 fn get_spec(&self, id: &ToolId) -> Option<&dyn ToolSpecView> {
3316 (id == self.spec.id()).then(|| self.spec.as_ref() as &dyn ToolSpecView)
3317 }
3318 
3319 fn list_specs(&self) -> Vec<&dyn ToolSpecView> {
3320 vec![self.spec.as_ref()]
3321 }
3322 
3323 fn filter_for(&self, _agent_id: &AgentId) -> Box<dyn ToolFilter> {
3324 tool_filter_from_specs(&self.visible(), self)
3325 }
3326 }
3327 
3328 struct TwoToolCallProvider {
3329 capabilities: ProviderCapabilities,
3330 calls: Arc<StdMutex<usize>>,
3331 }
3332 
3333 impl TwoToolCallProvider {
3334 fn new() -> Self {
3335 Self {
3336 capabilities: ProviderCapabilities {
3337 supports_streaming: true,
3338 supports_tool_calls: true,
3339 supports_json_mode: false,
3340 max_context_window: 4096,
3341 model_name: "two-tool-call-test".to_string(),
3342 },
3343 calls: Arc::new(StdMutex::new(0)),
3344 }
3345 }
3346 }
3347 
3348 #[async_trait]
3349 impl LlmProvider for TwoToolCallProvider {
3350 async fn complete(&self, _request: &LlmRequest) -> Result<LlmResponse, LlmError> {
3351 panic!("streaming path should use complete_stream instead of complete");
3352 }
3353 
3354 async fn complete_stream(
3355 &self,
3356 _request: &LlmRequest,
3357 _on_chunk: &(dyn Fn(StreamChunk) + Send + Sync),
3358 ) -> Result<LlmResponse, LlmError> {
3359 let call_number = {
3360 let mut calls = self.calls.lock().expect("call counter mutex poisoned");
3361 *calls += 1;
3362 *calls
3363 };
3364 
3365 if call_number == 1 {
3366 return Ok(LlmResponse {
3367 message: AssistantMessage {
3368 text: Some("calling tools".to_string()),
3369 reasoning_content: None,
3370 tool_calls: vec![
3371 ToolUseBlock {
3372 call_id: "call_a".to_string(),
3373 tool_name: "peek".to_string(),
3374 input: serde_json::json!({}),
3375 },
3376 ToolUseBlock {
3377 call_id: "call_b".to_string(),
3378 tool_name: "peek".to_string(),
3379 input: serde_json::json!({}),
3380 },
3381 ],
3382 usage: Usage {
3383 cached_tokens: 0,
3384 prompt_tokens: 5,
3385 completion_tokens: 3,
3386 total_tokens: 8,
3387 },
3388 stop_reason: StopReason::ToolUse,
3389 },
3390 kv_cache_chunk_hashes: vec![],
3391 });
3392 }
3393 
3394 Ok(LlmResponse {
3395 message: AssistantMessage {
3396 text: Some("done".to_string()),
3397 reasoning_content: None,
3398 tool_calls: Vec::new(),
3399 usage: Usage {
3400 cached_tokens: 0,
3401 prompt_tokens: 5,
3402 completion_tokens: 1,
3403 total_tokens: 6,
3404 },
3405 stop_reason: StopReason::EndTurn,
3406 },
3407 kv_cache_chunk_hashes: vec![],
3408 })
3409 }
3410 
3411 fn capabilities(&self) -> &ProviderCapabilities {
3412 &self.capabilities
3413 }
3414 }
3415 
3416 #[tokio::test]
3417 async fn mid_batch_stop_still_records_every_executed_result() {
3418 let registry = Arc::new(SingleToolRegistry::new());
3419 let visible = registry.visible();
3420 let provider = Arc::new(LlmProviderWrapper::new(
3421 Arc::new(TwoToolCallProvider::new()),
3422 None,
3423 None,
3424 ));
3425 let runtime = test_runtime_with_registry(provider, 4, registry);
3426 let input = AgentLoopInput::new("go")
3427 .with_agent_id(AgentId("test-agent".to_string()))
3428 .with_visible_tools(visible)
3429 .with_runtime_view(Arc::new(NoopRuntimeView::new()))
3430 .with_stop_rules([LoopStopRule::AfterSuccessfulTool {
3431 tool_name: "peek".to_string(),
3432 }]);
3433 let mut loop_state = LoopState::new(uuid::Uuid::new_v4());
3434 
3435 let outcome = run_agent_loop(&runtime, &mut loop_state, input)
3436 .await
3437 .expect("loop should complete via the stop rule");
3438 
3439 assert!(matches!(
3440 outcome,
3441 LoopRunResult::Complete(AgentOutcome::Complete { .. })
3442 ));
3443 assert_eq!(loop_state.turn_count, 1);
3444 
3445 let messages = loop_state.messages.read();
3446 let tool_use_ids: Vec<String> = messages
3447 .iter()
3448 .flat_map(|m| m.blocks.iter())
3449 .filter_map(|b| match b {
3450 ContentBlock::ToolUse { call_id, .. } => Some(call_id.clone()),
3451 _ => None,
3452 })
3453 .collect();
3454 let tool_result_ids: Vec<String> = messages
3455 .iter()
3456 .flat_map(|m| m.blocks.iter())
3457 .filter_map(|b| match b {
3458 ContentBlock::ToolResult { call_id, .. } => Some(call_id.clone()),
3459 _ => None,
3460 })
3461 .collect();
3462 
3463 assert_eq!(
3464 tool_use_ids,
3465 vec!["call_a".to_string(), "call_b".to_string()],
3466 "both tool calls should be in history"
3467 );
3468 assert_eq!(
3469 tool_result_ids,
3470 vec!["call_a".to_string(), "call_b".to_string()],
3471 "every executed tool_use must keep its paired tool_result even when an \
3472 earlier call in the batch triggered the stop rule"
3473 );
3474 }
2836}3475}
Mcrates/core/src/loop_state.rs+22-1
@@ -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)]
@@ -41,7 +48,14 @@ impl LoopState {
41 token_usage: TokenUsage::default(),48 token_usage: TokenUsage::default(),
42 compression_meta: CompressionMeta::default(),49 compression_meta: CompressionMeta::default(),
43 kv_cache_map: KvCacheMap::default(),50 kv_cache_map: KvCacheMap::default(),
44 cancel,51 cancel: CancellationToken::new(),
52 plan_nudged: false,
53 last_failure_sig: None,
54 repeated_failure_count: 0,
55 completion_nudged: false,
56 last_success_sig: None,
57 repeated_success_count: 0,
58 tool_executed: false,
45 }59 }
46 }60 }
47 61 
@@ -65,6 +79,13 @@ impl LoopState {
65 compression_meta: snapshot.compression_meta,79 compression_meta: snapshot.compression_meta,
66 kv_cache_map: snapshot.kv_cache_map,80 kv_cache_map: snapshot.kv_cache_map,
67 cancel,81 cancel,
82 plan_nudged: false,
83 last_failure_sig: None,
84 repeated_failure_count: 0,
85 completion_nudged: false,
86 last_success_sig: None,
87 repeated_success_count: 0,
88 tool_executed: false,
68 }89 }
69 }90 }
70 91 
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/subagent/src/coordinator.rs+11-21
@@ -455,32 +455,22 @@ mod tests {
455 })),455 })),
456 );456 );
457 457 
458 assert_eq!(458 assert!(prompt.contains("Count files"));
459 prompt,459 assert!(prompt.contains("Use find"));
460 "You are a subagent summoned by a parent agent.\n\n\460 assert!(prompt.contains("disposable exploration worker"));
461For general conversations and non-sensitive tasks, respond normally without enabling security verification. When tasks involve secrets, credentials, or sensitive files, enable the security verification mechanism.\n\n\461 assert!(prompt.contains("strictly adheres to the following JSON schema"));
462Your primary goal is:\n\462 assert!(prompt.contains("\"count\""));
463Count files\n\n\463 assert!(prompt.contains("\"integer\""));
464Task Context:\n\
465Use find\n\n\
466You MUST conclude your task by producing a final result that strictly adheres to the following JSON schema. Do not include any other explanatory text in your final finish/terminal reply, ONLY the JSON matching this schema:\n\
467{\n \"properties\": {\n \"count\": {\n \"type\": \"integer\"\n }\n },\n \"required\": [\n \"count\"\n ],\n \"type\": \"object\"\n}"
468 );
469 }464 }
470 465 
471 #[test]466 #[test]
472 fn subagent_prompt_builder_without_schema() {467 fn subagent_prompt_builder_without_schema() {
473 let prompt = SubagentPromptBuilder::build("Summarize logs", "Check /var/log", None);468 let prompt = SubagentPromptBuilder::build("Summarize logs", "Check /var/log", None);
474 469 
475 assert_eq!(470 assert!(prompt.contains("Summarize logs"));
476 prompt,471 assert!(prompt.contains("Check /var/log"));
477 "You are a subagent summoned by a parent agent.\n\n\472 assert!(prompt.contains("disposable exploration worker"));
478For general conversations and non-sensitive tasks, respond normally without enabling security verification. When tasks involve secrets, credentials, or sensitive files, enable the security verification mechanism.\n\n\473 assert!(prompt.contains("Conclude your task by providing a clear, concise summary"));
479Your primary goal is:\n\474 assert!(!prompt.contains("JSON schema"));
480Summarize logs\n\n\
481Task Context:\n\
482Check /var/log\n\n\
483Conclude your task by providing a clear, concise summary of your findings."
484 );
485 }475 }
486}476}
Mcrates/subagent/src/prompts/subagent_prompt_template.txt+2-0
@@ -8,4 +8,6 @@ Your primary goal is:
8Task Context:8Task Context:
9{{task_context}}9{{task_context}}
10 10 
11You are a disposable exploration worker: your reads, greps, and command output are discarded when you finish — only your final reply returns to the parent. Therefore your final reply MUST be a DISTILLED summary, not a transcript: report the exact file:line locations and the few snippets that actually answer the goal, plus a short conclusion. Do NOT paste whole files, full search output, or evidence the parent does not need to act. Be exact where the goal asks for a count, comparison, or location; never approximate or truncate the answer itself.
12 
11{{output_schema_section}}13{{output_schema_section}}
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 
20pub use todo_write::open_todo_lines;
20pub use tool_source::BuiltinToolSource;21pub use tool_source::BuiltinToolSource;
Mcrates/tool/src/impl/builtin/spawn_subagent/executor.rs+1-1
@@ -107,7 +107,7 @@ impl ToolExecutor for SpawnSubagentExecutor {
107 output_schema: input.output_schema,107 output_schema: input.output_schema,
108 subagent_role_id: input.subagent_role_id,108 subagent_role_id: input.subagent_role_id,
109 predefined_prompt: None,109 predefined_prompt: None,
110 max_turns: None,110 max_turns: Some(input.max_turns.unwrap_or(20)),
111 })111 })
112 .await112 .await
113 .map_err(|error| ToolExecutionError::ExecutionFailed {113 .map_err(|error| ToolExecutionError::ExecutionFailed {
Mcrates/tool/src/impl/builtin/spawn_subagent/input.rs+2-0
@@ -10,4 +10,6 @@ pub struct SpawnSubagentInput {
10 pub output_schema: Option<serde_json::Value>,10 pub output_schema: Option<serde_json::Value>,
11 #[serde(default)]11 #[serde(default)]
12 pub subagent_role_id: Option<String>,12 pub subagent_role_id: Option<String>,
13 #[serde(default)]
14 pub max_turns: Option<u32>,
13}15}
Mcrates/tool/src/impl/builtin/spawn_subagent/spec.rs+5-0
@@ -81,6 +81,11 @@ impl SpawnSubagentToolSpec {
81 },81 },
82 "required": ["count"]82 "required": ["count"]
83 }]83 }]
84 },
85 "max_turns": {
86 "type": "integer",
87 "description": "Optional turn budget for this exploration child. A bounded cap (around 20) lets it explore aggressively in parallel without running away; it is force-terminated after this many turns. Omit to use the default budget.",
88 "examples": [20]
84 }89 }
85 },90 },
86 "required": ["description", "task_goal", "task_context"]91 "required": ["description", "task_goal", "task_context"]
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.
85pub 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;
7pub 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;
11pub 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};
17pub 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,