@@ -154,6 +154,12 @@ pub async fn run_one_subagent(
154154 let mut output_estimate = 0usize ;
155155 let mut iterations = 0u32 ;
156156 let mut final_submit: Option < Value > = None ;
157+ // Names of all non-`submit_result` tool calls the subagent has actually
158+ // made in this run. Used by `validate_submit` to detect "blocked without
159+ // trying", where a model returns `status:"blocked"` claiming missing
160+ // workspace tools without ever calling `list_workspace_files` etc.
161+ let mut tools_called: std:: collections:: HashSet < String > = std:: collections:: HashSet :: new ( ) ;
162+ let has_workspace_read = groups. iter ( ) . any ( |g| matches ! ( g, ToolGroup :: WorkspaceRead ) ) ;
157163
158164 match provider {
159165 SubagentProvider :: OpenAi ( endpoint) => {
@@ -223,35 +229,58 @@ pub async fn run_one_subagent(
223229 if round. tool_calls . is_empty ( ) {
224230 break ;
225231 }
232+ let mut accepted_submit = false ;
226233 for tc in round. tool_calls {
227234 let internal_name = openai_tool_name_to_internal ( & tc. name ) ;
228- if handle_tool_call (
235+ match handle_tool_call (
229236 state,
230237 agent_id,
231238 & tc. id ,
232239 & internal_name,
233240 & tc. arguments ,
234241 groups,
235242 root_guard. as_ref ( ) ,
243+ & mut tools_called,
244+ has_workspace_read,
236245 & mut final_submit,
237246 ) {
238- messages. push ( json ! ( {
239- "role" : "tool" ,
240- "tool_call_id" : tc. id,
241- "content" : "submit_result accepted" ,
242- } ) ) ;
243- break ;
247+ ToolCallOutcome :: SubmitAccepted => {
248+ messages. push ( json ! ( {
249+ "role" : "tool" ,
250+ "tool_call_id" : tc. id,
251+ "content" : "submit_result accepted" ,
252+ } ) ) ;
253+ accepted_submit = true ;
254+ break ;
255+ }
256+ ToolCallOutcome :: SubmitRejected ( message) => {
257+ messages. push ( json ! ( {
258+ "role" : "tool" ,
259+ "tool_call_id" : tc. id,
260+ "content" : message,
261+ } ) ) ;
262+ // Loop continues so the model retries with a real
263+ // workspace probe before re-submitting.
264+ continue ;
265+ }
266+ ToolCallOutcome :: NotSubmit => {
267+ let args: Value =
268+ serde_json:: from_str ( & tc. arguments ) . unwrap_or ( json ! ( { } ) ) ;
269+ let outcome = execute_subagent_tool (
270+ & internal_name,
271+ & args,
272+ groups,
273+ root_guard. as_ref ( ) ,
274+ ) ;
275+ messages. push ( json ! ( {
276+ "role" : "tool" ,
277+ "tool_call_id" : tc. id,
278+ "content" : outcome. content,
279+ } ) ) ;
280+ }
244281 }
245- let args: Value =
246- serde_json:: from_str ( & tc. arguments ) . unwrap_or ( json ! ( { } ) ) ;
247- let outcome = execute_subagent_tool ( & internal_name, & args, groups, root_guard. as_ref ( ) ) ;
248- messages. push ( json ! ( {
249- "role" : "tool" ,
250- "tool_call_id" : tc. id,
251- "content" : outcome. content,
252- } ) ) ;
253282 }
254- if final_submit . is_some ( ) {
283+ if accepted_submit {
255284 break ;
256285 }
257286 }
@@ -306,35 +335,53 @@ pub async fn run_one_subagent(
306335 break ;
307336 }
308337 let mut result_blocks: Vec < Value > = Vec :: new ( ) ;
338+ let mut accepted_submit = false ;
309339 for ( id, name, args_str) in round. tool_uses {
310- if handle_tool_call (
340+ match handle_tool_call (
311341 state,
312342 agent_id,
313343 & id,
314344 & name,
315345 & args_str,
316346 groups,
317347 root_guard. as_ref ( ) ,
348+ & mut tools_called,
349+ has_workspace_read,
318350 & mut final_submit,
319351 ) {
320- result_blocks. push ( json ! ( {
321- "type" : "tool_result" ,
322- "tool_use_id" : id,
323- "content" : "submit_result accepted" ,
324- } ) ) ;
325- break ;
352+ ToolCallOutcome :: SubmitAccepted => {
353+ result_blocks. push ( json ! ( {
354+ "type" : "tool_result" ,
355+ "tool_use_id" : id,
356+ "content" : "submit_result accepted" ,
357+ } ) ) ;
358+ accepted_submit = true ;
359+ break ;
360+ }
361+ ToolCallOutcome :: SubmitRejected ( message) => {
362+ result_blocks. push ( json ! ( {
363+ "type" : "tool_result" ,
364+ "tool_use_id" : id,
365+ "content" : message,
366+ "is_error" : true ,
367+ } ) ) ;
368+ continue ;
369+ }
370+ ToolCallOutcome :: NotSubmit => {
371+ let args: Value = serde_json:: from_str ( & args_str) . unwrap_or ( json ! ( { } ) ) ;
372+ let outcome =
373+ execute_subagent_tool ( & name, & args, groups, root_guard. as_ref ( ) ) ;
374+ result_blocks. push ( json ! ( {
375+ "type" : "tool_result" ,
376+ "tool_use_id" : id,
377+ "content" : outcome. content,
378+ "is_error" : !outcome. ok,
379+ } ) ) ;
380+ }
326381 }
327- let args: Value = serde_json:: from_str ( & args_str) . unwrap_or ( json ! ( { } ) ) ;
328- let outcome = execute_subagent_tool ( & name, & args, groups, root_guard. as_ref ( ) ) ;
329- result_blocks. push ( json ! ( {
330- "type" : "tool_result" ,
331- "tool_use_id" : id,
332- "content" : outcome. content,
333- "is_error" : !outcome. ok,
334- } ) ) ;
335382 }
336383 messages. push ( json ! ( { "role" : "user" , "content" : result_blocks } ) ) ;
337- if final_submit . is_some ( ) {
384+ if accepted_submit {
338385 break ;
339386 }
340387 if round. stop_reason . as_deref ( ) == Some ( "end_turn" ) {
@@ -364,6 +411,21 @@ pub async fn run_one_subagent(
364411 result
365412}
366413
414+ /// Outcome reported back by [`handle_tool_call`] so the caller knows whether
415+ /// to finalize the loop, inject a corrective tool response, or proceed to
416+ /// regular tool execution.
417+ #[ derive( Debug ) ]
418+ enum ToolCallOutcome {
419+ /// `submit_result` accepted — caller should record it and break the loop.
420+ SubmitAccepted ,
421+ /// `submit_result` rejected by [`validate_submit`]; caller must push the
422+ /// embedded message back to the model as the tool response and keep
423+ /// looping so the model retries.
424+ SubmitRejected ( String ) ,
425+ /// Not a `submit_result` call — caller should run the regular tool.
426+ NotSubmit ,
427+ }
428+
367429fn handle_tool_call (
368430 state : & Arc < AgentEngineState > ,
369431 agent_id : & str ,
@@ -372,8 +434,10 @@ fn handle_tool_call(
372434 args_str : & str ,
373435 groups : & [ ToolGroup ] ,
374436 root : Option < & WorkspaceRootGuard > ,
437+ tools_called : & mut std:: collections:: HashSet < String > ,
438+ has_workspace_read : bool ,
375439 final_submit : & mut Option < Value > ,
376- ) -> bool {
440+ ) -> ToolCallOutcome {
377441 state. push ( AgentEvent :: SubagentToolCall {
378442 agent_id : agent_id. to_owned ( ) ,
379443 tool : name. to_owned ( ) ,
@@ -382,11 +446,20 @@ fn handle_tool_call(
382446 } ) ;
383447 if name == "submit_result" {
384448 let args: Value = serde_json:: from_str ( args_str) . unwrap_or ( json ! ( { } ) ) ;
385- * final_submit = Some ( truncate_submit_result ( args) ) ;
386- return true ;
449+ match validate_submit ( & args, tools_called, has_workspace_read) {
450+ SubmitVerdict :: Accept => {
451+ * final_submit = Some ( truncate_submit_result ( args) ) ;
452+ ToolCallOutcome :: SubmitAccepted
453+ }
454+ SubmitVerdict :: RejectBlockedWithoutTrying { message } => {
455+ ToolCallOutcome :: SubmitRejected ( message)
456+ }
457+ }
458+ } else {
459+ tools_called. insert ( name. to_owned ( ) ) ;
460+ let _ = ( groups, root) ;
461+ ToolCallOutcome :: NotSubmit
387462 }
388- let _ = ( groups, root) ;
389- false
390463}
391464
392465fn execute_subagent_tool (
@@ -427,6 +500,57 @@ fn finish_subagent(state: &Arc<AgentEngineState>, agent_id: &str, result: &Value
427500 } ) ;
428501}
429502
503+ /// Names of workspace-read tools whose use proves the subagent actually
504+ /// attempted to access the file system before submitting a result.
505+ const WORKSPACE_READ_TOOLS : & [ & str ] = & [
506+ "list_workspace_files" ,
507+ "read_workspace_file" ,
508+ "workspace_search" ,
509+ ] ;
510+
511+ /// Verdict for a candidate `submit_result` call from a subagent.
512+ #[ derive( Debug , PartialEq , Eq ) ]
513+ enum SubmitVerdict {
514+ /// Accept this submission as the final result.
515+ Accept ,
516+ /// Reject because the subagent claimed "blocked" without ever attempting
517+ /// a workspace read. `message` is fed back to the model as the tool
518+ /// response so it tries again instead of finalizing the run.
519+ RejectBlockedWithoutTrying { message : String } ,
520+ }
521+
522+ /// Decide whether a `submit_result` payload should be accepted or sent back
523+ /// to the model for another attempt. The only currently-enforced rule is
524+ /// the "blocked without trying" guard: if the role had `workspace_read`
525+ /// provisioned but the subagent never called any of the workspace tools
526+ /// and now wants to return `status:"blocked"`, we reject so the model can
527+ /// be forced to verify access first.
528+ fn validate_submit (
529+ args : & Value ,
530+ tools_called : & std:: collections:: HashSet < String > ,
531+ has_workspace_read : bool ,
532+ ) -> SubmitVerdict {
533+ let status = args. get ( "status" ) . and_then ( |v| v. as_str ( ) ) . unwrap_or ( "" ) ;
534+ if status != "blocked" || !has_workspace_read {
535+ return SubmitVerdict :: Accept ;
536+ }
537+ let attempted = WORKSPACE_READ_TOOLS
538+ . iter ( )
539+ . any ( |t| tools_called. contains ( * t) ) ;
540+ if attempted {
541+ return SubmitVerdict :: Accept ;
542+ }
543+ SubmitVerdict :: RejectBlockedWithoutTrying {
544+ message : "Rejected: you submitted status:\" blocked\" without ever calling \
545+ list_workspace_files, read_workspace_file, or workspace_search. These tools \
546+ ARE provisioned in this run and ARE present in your tool schema. \
547+ Call `list_workspace_files` with {\" path\" :\" .\" } now to verify access, \
548+ then proceed with the task. Do not return a blocked status until you have \
549+ actually attempted a workspace read and reported the concrete error string."
550+ . to_owned ( ) ,
551+ }
552+ }
553+
430554fn blocked_result ( role : SubagentRole , display_name : & str , summary : & str ) -> Value {
431555 json ! ( {
432556 "status" : "blocked" ,
0 commit comments