@@ -213,12 +213,9 @@ fn script_allowed_tools(args: &serde_json::Value, registry: &ToolRegistry) -> Ha
213213 . unwrap_or_else ( || registry. list ( ) . into_iter ( ) . collect ( ) ) ;
214214
215215 allowed. remove ( "program" ) ;
216- // Delegation tools can't run inside a PTC script: child agents need the
217- // multi-threaded session runtime, but the script executes on a nested
218- // single-thread runtime where they can't fan out. Force the model to call
219- // them directly instead of `ctx.tool("parallel_task", ...)`.
220- allowed. remove ( "task" ) ;
221- allowed. remove ( "parallel_task" ) ;
216+ // `task`/`parallel_task` ARE allowed in PTC scripts now: host tool calls run
217+ // on the outer multi-threaded runtime (see execute_host_tool_json), so
218+ // `ctx.tool("parallel_task", …)` fans out child agents in parallel.
222219 allowed
223220}
224221
@@ -277,6 +274,9 @@ async fn run_quickjs_script(
277274 . max_output_bytes
278275 . unwrap_or ( DEFAULT_SCRIPT_MAX_OUTPUT_BYTES ) ;
279276 let executable_source = script_source_with_host_entrypoint ( source) ?;
277+ // Captured on the outer multi-threaded runtime (we're async here, before the
278+ // VM's nested single-thread runtime is built) so host tool calls fan out.
279+ let outer = tokio:: runtime:: Handle :: current ( ) ;
280280 let state = Arc :: new ( Mutex :: new ( ScriptVmState {
281281 registry,
282282 ctx,
@@ -285,6 +285,7 @@ async fn run_quickjs_script(
285285 max_output_bytes,
286286 tool_calls : 0 ,
287287 records : Vec :: new ( ) ,
288+ outer,
288289 } ) ) ;
289290
290291 let vm_state = Arc :: clone ( & state) ;
@@ -407,6 +408,10 @@ struct ScriptVmState {
407408 max_output_bytes : usize ,
408409 tool_calls : usize ,
409410 records : Vec < ScriptCallRecord > ,
411+ /// Handle to the OUTER multi-threaded session runtime. The script VM runs on
412+ /// a nested single-thread runtime; host tool calls are dispatched here so
413+ /// delegation tools (`parallel_task`/`task`) can actually fan out children.
414+ outer : tokio:: runtime:: Handle ,
410415}
411416
412417fn embedded_script_bootstrap ( inputs_json : & str ) -> String {
@@ -447,7 +452,7 @@ async fn execute_host_tool_json(
447452 let args = serde_json:: from_str ( & args_json) . map_err ( |err| {
448453 JsError :: new_from_js_message ( "string" , "object" , format ! ( "invalid tool args JSON: {err}" ) )
449454 } ) ?;
450- let ( registry, ctx, max_output_bytes) = {
455+ let ( registry, ctx, max_output_bytes, outer ) = {
451456 let mut script = state. lock ( ) . await ;
452457 if !script. allowed_tools . contains ( & tool) {
453458 return Err ( JsError :: new_from_js_message (
@@ -468,12 +473,22 @@ async fn execute_host_tool_json(
468473 Arc :: clone ( & script. registry ) ,
469474 script. ctx . clone ( ) ,
470475 script. max_output_bytes ,
476+ script. outer . clone ( ) ,
471477 )
472478 } ;
473479
474- let result = registry
475- . execute_with_context ( & tool, & args, & ctx)
480+ // Run the tool on the OUTER multi-threaded runtime (not this nested
481+ // single-thread VM runtime) so delegation tools can spawn child agents that
482+ // actually run in parallel — `ctx.tool("parallel_task", …)` now fans out.
483+ let tool_for_spawn = tool. clone ( ) ;
484+ let result = outer
485+ . spawn ( async move {
486+ registry
487+ . execute_with_context ( & tool_for_spawn, & args, & ctx)
488+ . await
489+ } )
476490 . await
491+ . map_err ( |err| JsError :: new_from_js_message ( "tool" , "spawn" , err. to_string ( ) ) ) ?
477492 . map_err ( |err| JsError :: new_from_js_message ( "tool" , "result" , err. to_string ( ) ) ) ?;
478493 let mut output = result. output ;
479494 if output. len ( ) > max_output_bytes {
@@ -676,6 +691,22 @@ mod tests {
676691 assert ! ( !allowed. contains( "program" ) ) ;
677692 }
678693
694+ #[ test]
695+ fn program_tool_allows_delegation_tools_in_scripts ( ) {
696+ // Delegation tools are allowed in PTC scripts again (host tool calls run
697+ // on the outer multi-threaded runtime, so they fan out). Only `program`
698+ // stays stripped (no nested PTC recursion).
699+ let registry = ToolRegistry :: new ( PathBuf :: from ( "/tmp" ) ) ;
700+ let args = serde_json:: json!( {
701+ "allowed_tools" : [ "parallel_task" , "task" , "program" , "echo" ]
702+ } ) ;
703+ let allowed = script_allowed_tools ( & args, & registry) ;
704+ assert ! ( allowed. contains( "parallel_task" ) ) ;
705+ assert ! ( allowed. contains( "task" ) ) ;
706+ assert ! ( allowed. contains( "echo" ) ) ;
707+ assert ! ( !allowed. contains( "program" ) ) ;
708+ }
709+
679710 #[ tokio:: test]
680711 async fn program_tool_source_uses_default_all_registered_tools ( ) {
681712 let registry = Arc :: new ( ToolRegistry :: new ( PathBuf :: from ( "/tmp" ) ) ) ;
0 commit comments