Skip to content

feat(runner): dependency-aware parallel tool execution#44

Merged
windsornguyen merged 2 commits intonextfrom
feat/dep-graph-scheduler
Feb 7, 2026
Merged

feat(runner): dependency-aware parallel tool execution#44
windsornguyen merged 2 commits intonextfrom
feat/dep-graph-scheduler

Conversation

@windsornguyen
Copy link
Member

Summary

Dependency-aware parallel local tool execution. The SDK topo-sorts pending tool calls by declared dependencies and fires independent tools concurrently via asyncio.gather. 5x speedup on independent tools.

Changes

  • _scheduler.py: topo-sort via graphlib.TopologicalSorter, layer-by-layer parallel dispatch, cycle fallback
  • core.py: all 4 execution paths (async/sync x streaming/non-streaming) delegate to scheduler
  • core.py: _ModelConfig replaced with api_kwargs passthrough -- full CompletionCreateParamsBase mirrored on run()
  • 11 tests in tests/test_local_scheduler.py

Test plan

  • 11 unit tests (parallel timing, dependency ordering, diamond deps, cycle fallback, error recording, message format)
  • Benchmark: 5x on independent tools, 2x on parallel chains, 1.7x on diamond deps

@cursor
Copy link

cursor bot commented Feb 7, 2026

PR Summary

Medium Risk
Touches core runner execution paths and model-parameter plumbing, so regressions could affect tool ordering/parallelism and API request kwargs, but changes are localized and covered by new unit tests.

Overview
Adds a new dependency-aware local tool scheduler (_scheduler.py) that topo-sorts tool calls by declared dependencies, runs independent calls concurrently (async) or in correct order (sync), and falls back to sequential execution on cycles or bad dependency data.

Refactors DedalusRunner.run()/_ModelConfig to treat model parameters as a generic api_kwargs passthrough (expanding run()’s signature to mirror chat-completions params and extracting defaults from DedalusModel), and updates all tool-execution paths (async/sync + streaming/non-streaming) to delegate local tool execution to the scheduler while leaving server/MCP tool streaming behavior intact.

Adds unit tests covering parallel timing, dependency ordering (chain/diamond), cycle fallback, error/message recording, and edge cases like empty calls and unknown deps (tests/test_local_scheduler.py).

Written by Cursor Bugbot for commit 5d0ce6d. This will update automatically on new commits. Configure here.

@windsornguyen windsornguyen changed the base branch from main to next February 7, 2026 02:53
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is ON. A Cloud Agent has been kicked off to fix the reported issue.

if isinstance(result, Exception):
# Already recorded in messages by _run_one_async.
pass
sorter.done(call_id)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gather silently swallows unrecorded exceptions in parallel path

Low Severity

When multiple tools run in the asyncio.gather path with return_exceptions=True, any exception that escapes _run_one_async (not caught by its internal except Exception) is captured as a result but never recorded in messages. The comment "Already recorded in messages by _run_one_async" is incorrect for this case. Meanwhile sorter.done(call_id) is still called, so dependent tools proceed without the prerequisite's result message — leading to a malformed conversation. In the single-tool path, the same exception propagates correctly and aborts execution. This inconsistency means the same failure mode is a clear error for one tool but silent data loss for two or more.

🔬 Verification Test

Why verification test was not possible: The development VM was unreachable during analysis (all tool calls returned "Pod exists but exec-daemon is unreachable"). However, this bug is demonstrable through static analysis: the pass on line 142 takes no corrective action (no message recorded, no re-raise), and sorter.done(call_id) on line 143 runs unconditionally regardless of whether the exception was recorded. The comment on line 141 is provably incorrect — _run_one_async only records errors inside its own except Exception block, and any exception that escapes was by definition NOT caught there.

Additional Locations (1)

Fix in Cursor Fix in Web

@cursor
Copy link

cursor bot commented Feb 7, 2026

Bugbot Autofix prepared fixes for 1 of the 1 bugs found in the latest run.

  • ✅ Fixed: Gather silently swallows unrecorded exceptions in parallel path
    • Changed the isinstance check from Exception to BaseException and added a re-raise for non-Exception BaseException subclasses (e.g. KeyboardInterrupt, CancelledError) to match the single-tool path behavior where such exceptions propagate naturally.

Create PR

Or push these changes by commenting:

@cursor push bae5551f54
Preview (bae5551f54)
diff --git a/src/dedalus_labs/lib/runner/_scheduler.py b/src/dedalus_labs/lib/runner/_scheduler.py
--- a/src/dedalus_labs/lib/runner/_scheduler.py
+++ b/src/dedalus_labs/lib/runner/_scheduler.py
@@ -137,9 +137,15 @@
             )
 
             for call_id, result in zip(ready, results):
-                if isinstance(result, Exception):
-                    # Already recorded in messages by _run_one_async.
-                    pass
+                if isinstance(result, BaseException):
+                    if isinstance(result, Exception):
+                        # Already recorded in messages by _run_one_async.
+                        pass
+                    else:
+                        # BaseException subclass (e.g. KeyboardInterrupt,
+                        # CancelledError) not caught by _run_one_async —
+                        # re-raise to match single-tool path behavior.
+                        raise result
                 sorter.done(call_id)

@windsornguyen
Copy link
Member Author

Need to tighten up the types.

@windsornguyen windsornguyen merged commit a72f70f into next Feb 7, 2026
5 of 7 checks passed
@stainless-app stainless-app bot mentioned this pull request Feb 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant