Skip to content

fix(graph): prefer node tools before synthetic handoff routing - #90

Merged
andrewklatzke merged 1 commit into
mainfrom
aklatzke/AIC-3365/tighten-graph-handoffs
Sep 16, 2026
Merged

andrewklatzke merged 1 commit into
mainfrom
aklatzke/AIC-3365/tighten-graph-handoffs

Conversation

@andrewklatzke

@andrewklatzke andrewklatzke commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Summary

When a graph node has multiple outgoing edges, route() augments the node's config with synthetic __handoff_* tools. In practice this crowded out the node's own tools — a node that reliably called its real tool standalone would call only a handoff tool inside a graph, so the response carried routing information instead of the tool's data.

Three things in the routing augmentation pushed the model that way, all fixed here:

  • The appended routing directive led with "Select exactly one transfer tool to route to the next agent", making the transfer read as the task and the node's real work optional. It now asks the model to complete its task with its available tools first, and only then call exactly one transfer tool.
  • Handoff tool descriptions fell back to the target node's instructions verbatim, so __handoff_<target> advertised itself as the tool that does the target's work. Descriptions now always lead with Transfer control to <key>., with any handoff.description or target-instructions text appended as trailing detail.
  • The handoff handler returned Transferring to <key>, which reads as though control had already left, so the model wrapped up instead of continuing. Selecting an edge only records the choice — execution continues until the model produces its final text — so the result now says the handoff is recorded and asks the model to finish its own work.

Behavior is unchanged for nodes with zero or one outgoing edge, which never enter this path. Framework-native runners build their own transfer_to_* tools and are untouched.

Test plan

  • Existing graph suite passes (27 tests, packages/client/tests/test_graph.py)
  • Verified against a real multi-node graph whose root node was skipping its tool: the node's own tool now fires and the handoff still routes correctly
  • Reviewer sanity check on the routing copy — this is model-steering, so wording matters

Note: the matching change for the JS SDK is launchdarkly/js-ai-sdk#59

Handoff tools were crowding out real node tools because routing copy led with "select a transfer tool", descriptions reused the target agent's instructions, and the tool result implied the turn was already over.

Co-authored-by: Cursor <cursoragent@cursor.com>
@andrewklatzke
andrewklatzke merged commit f830b2c into main Sep 16, 2026
8 checks passed
@andrewklatzke
andrewklatzke deleted the aklatzke/AIC-3365/tighten-graph-handoffs branch September 16, 2026 17:44
@github-actions github-actions Bot mentioned this pull request Sep 16, 2026
andrewklatzke added a commit to launchdarkly/js-ai-sdk that referenced this pull request Sep 16, 2026
## Summary

When a graph node has multiple outgoing edges, `route()` augments the
node's config with synthetic `__handoff_*` tools. In practice this
crowded out the node's own tools — a node that reliably called its real
tool standalone would call only a handoff tool inside a graph, so the
response carried routing information instead of the tool's data.

Three things in the routing augmentation pushed the model that way, all
fixed here:

- The appended routing directive led with "Select exactly one transfer
tool to route to the next agent", making the transfer read as the task
and the node's real work optional. It now asks the model to complete its
task with its available tools first, and only then call exactly one
transfer tool.
- Handoff tool descriptions fell back to the *target* node's
instructions verbatim, so `__handoff_<target>` advertised itself as the
tool that does the target's work. Descriptions now always lead with
`Transfer control to <key>.`, with any `handoff.description` or
target-instructions text appended as trailing detail.
- The handoff handler returned `Transferring to <key>`, which reads as
though control had already left, so the model wrapped up instead of
continuing. Selecting an edge only records the choice — execution
continues until the model produces its final text — so the result now
says the handoff is recorded and asks the model to finish its own work.

Behavior is unchanged for nodes with zero or one outgoing edge, which
never enter this path. Framework-native runners build their own
`transfer_to_*` tools and are untouched.

## Test plan

- [x] Existing client suite passes (30 tests, includes
`src/__tests__/graph.test.ts`)
- [x] Verified against a real multi-node graph whose root node was
skipping its tool: the node's own tool now fires and the handoff still
routes correctly
- [ ] Reviewer sanity check on the routing copy — this is
model-steering, so wording matters

Note: the matching change for the Python SDK is
launchdarkly/python-ai-sdk#90

Made with [Cursor](https://cursor.com)

Co-authored-by: Cursor <cursoragent@cursor.com>
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.

2 participants