1717- tool results are sanitized (None -> error placeholder, non-str -> str)
1818- tool-call batches never strand the machine: failures become error
1919 results
20- - every tool call in a round runs concurrently in a thread pool (results
21- delivered in original order); async tools (e.g. Bash) return a
22- ``PendingToolResult`` and deliver their result when the work completes,
23- without occupying a pool slot while waiting; interactive prompts stay
24- serialized
20+ - tool execution mirrors gptel's `gptel--handle-tool-use': synchronous
21+ tools (Read, Edit, Glob, ...) run ONE AT A TIME in model-emitted
22+ order; asynchronous tools (Bash, Agent) return a ``PendingToolResult``
23+ and run concurrently in the background, their results awaited
24+ afterwards in original call order; interactive prompts stay serialized
2525- token calibration is updated from API-reported input tokens
2626- sessions are auto-saved after each response
2727- a cancelled run with no successor salvages its partial history
@@ -303,7 +303,7 @@ def compact(self) -> bool:
303303 # ------------------------------------------------------------------
304304 # tool execution
305305 # ------------------------------------------------------------------
306- def _execute_tool_call (self , call : ToolCall ) -> str :
306+ def _execute_tool_call (self , call : ToolCall ) -> str | PendingToolResult :
307307 if not self .top_level and call .name in config .SUBAGENT_EXCLUDED_TOOLS :
308308 # defense in depth: a hallucinated call must never reach the
309309 # registry — the spec was filtered, so refuse it here too
@@ -351,64 +351,69 @@ def _deliver_tool_result(self, p: ToolCall, result: str) -> None:
351351 # conversation history (the TUI renders from it)
352352 self .session .last_messages = list (self .messages )
353353
354- def _run_tools_parallel (
354+ def _run_tools (
355355 self , calls : list [ToolCall ], results : dict [str , str ]
356356 ) -> None :
357- """Run CALLS concurrently in a thread pool, filling RESULTS.
358-
359- Each call executes in its own worker thread: the session's
360- shared state is concurrency-safe (thread-local diff slots,
361- serialized interactive prompts), so every
362- tool issued in the same round — Agent calls included, whose
363- sub-agents are isolated by design — runs in parallel. Delivery
364- happens later, in original tool-call order, by the parent
365- thread.
357+ """Run CALLS in model-emitted order, filling RESULTS.
358+
359+ Mirrors gptel's `gptel--handle-tool-use': synchronous tools
360+ (Read, Edit, Glob, ...) execute ONE AT A TIME, in call order;
361+ asynchronous tools (Bash, Agent — those whose ``run`` returns a
362+ ``PendingToolResult``) are dispatched in line and run
363+ concurrently in the background, their results awaited
364+ afterwards, again in original call order. Delivery happens
365+ later, in original tool-call order, by the caller.
366+
367+ A cancel landing before a call starts skips it (tools have side
368+ effects); a call already running — or an async tool already
369+ dispatched — cannot be stopped, but its result stays local to
370+ the (dead) run.
366371 """
367- from concurrent .futures import ThreadPoolExecutor , as_completed
368-
369- def run_one (p : ToolCall ) -> str :
370- # A cancel landing while the task is still QUEUED must skip
371- # it (tools have side effects): the sequential loop used to
372- # check before every call, so keep that guarantee — a
373- # task already RUNNING cannot be stopped, but one that has
374- # not started yet must not run after Ctrl-C.
372+ async_calls : list [tuple [ToolCall , PendingToolResult ]] = []
373+ for p in calls :
374+ # A cancel landing while a call is still QUEUED must skip
375+ # it (tools have side effects): the sequential loop checks
376+ # before every call, so a call that has not started yet must
377+ # not run after Ctrl-C.
375378 if self ._is_cancelled ():
376- return "Error: tool call cancelled (user aborted the run)."
377- return self ._execute_tool_call (p )
378-
379- with ThreadPoolExecutor (
380- max_workers = min (len (calls ), config .PARALLEL_TOOL_MAX ),
381- thread_name_prefix = "tool" ,
382- ) as pool :
383- futures = {pool .submit (run_one , p ): p for p in calls }
384- for fut in as_completed (futures ):
385- p = futures [fut ]
386- try :
387- result = fut .result ()
388- if isinstance (result , PendingToolResult ):
389- # async tool (e.g. Bash): the worker returned
390- # its handle as soon as the work was spawned and
391- # freed its pool slot; wait for the real result
392- # here (delivered when the process exits)
393- result = result .wait ()
394- results [p .id ] = sanitize_tool_result (result )
395- except Exception as e : # noqa: BLE001 - containment boundary
396- results [p .id ] = (
397- f"Error: tool { p .name !r} crashed in a worker "
398- f"thread — { e } "
399- )
379+ results [p .id ] = (
380+ "Error: tool call cancelled (user aborted the run)."
381+ )
382+ continue
383+ try :
384+ result = self ._execute_tool_call (p )
385+ except Exception as e : # noqa: BLE001 - containment boundary
386+ results [p .id ] = (
387+ f"Error: tool { p .name !r} crashed during execution — { e } "
388+ )
389+ continue
390+ if isinstance (result , PendingToolResult ):
391+ # async tool (e.g. Bash): run() spawned the work and
392+ # returned its handle immediately; await the real result
393+ # after the sequential loop so sibling calls keep
394+ # executing in the meantime
395+ async_calls .append ((p , result ))
396+ else :
397+ results [p .id ] = sanitize_tool_result (result )
398+ for p , pending in async_calls :
399+ try :
400+ results [p .id ] = sanitize_tool_result (pending .wait ())
401+ except Exception as e : # noqa: BLE001 - containment boundary
402+ results [p .id ] = (
403+ f"Error: tool { p .name !r} crashed during execution — { e } "
404+ )
400405
401406 def _execute_pending (self ) -> None :
402- """TOOL state: run the round's pending tool calls concurrently .
407+ """TOOL state: run the round's pending tool calls.
403408
404409 The assistant message carrying the tool calls was already
405410 appended by the WAIT state. Results land in
406411 ``self.info["tool_result"]`` and are delivered by the TRET
407412 state in original tool-call order.
408413
409- All tools issued in the round run CONCURRENTLY in a thread
410- pool — the session's shared state is concurrency-safe
411- (thread-local diff slots, serialized interactive prompts) .
414+ Synchronous tools run ONE AT A TIME in model-emitted order
415+ (gptel-style); asynchronous tools (Bash, Agent) are dispatched
416+ in line and run concurrently in the background .
412417 """
413418 pending = list (self .pending )
414419 if not pending :
@@ -420,7 +425,7 @@ def _execute_pending(self) -> None:
420425 # run's `session.last_messages`.
421426 return
422427 results : dict [str , str ] = {}
423- self ._run_tools_parallel (pending , results )
428+ self ._run_tools (pending , results )
424429 if self ._is_cancelled ():
425430 # cancelled mid-round: tools already submitted may have run
426431 # (their side effects are done), but the results stay local
@@ -457,7 +462,8 @@ def _deliver_results(self) -> None:
457462 self .pending = []
458463
459464 def _run_tool_round (self ) -> None :
460- """Execute all pending tool calls concurrently; deliver results.
465+ """Execute all pending tool calls (sync one at a time, async
466+ dispatched); deliver results.
461467
462468 Convenience wrapper around the FSM's TOOL (execute) and TRET
463469 (deliver) handlers, kept for direct callers and tests; the
@@ -663,9 +669,8 @@ def safe_delta(text: str) -> None:
663669 )
664670
665671 def _handle_tool (self ) -> None :
666- """TOOL — run the round's tools concurrently (see
667- ``_execute_pending``); the table routes ABRT on cancel and TRET
668- otherwise."""
672+ """TOOL — run the round's tools (see ``_execute_pending``); the
673+ table routes ABRT on cancel and TRET otherwise."""
669674 self .pending = list (self .info ["tool_calls" ])
670675 self .supervisor .reset_nudges ()
671676 # Notify the TUI that tool execution is starting: this clears
0 commit comments