Skip to content

feat(AIC-3210): support streaming responses from agent graph nodes - #84

Open
jeffdupont wants to merge 7 commits into
mainfrom
jdupont/AIC-3210/support-graph-streaming
Open

jeffdupont wants to merge 7 commits into
mainfrom
jdupont/AIC-3210/support-graph-streaming

Conversation

@jeffdupont

@jeffdupont jeffdupont commented Sep 22, 2026 •

Copy link
Copy Markdown

Summary

Adds graph().stream() — an async-generator counterpart to graph().invoke() that yields node boundaries while the router keeps ownership of handoffs and graph-level telemetry.

  • New public GraphStreamEvent union: node_start, chunk (tagged with nodeKey), node_done, handoff, and a final done. Its chunk and done variants stay structurally assignable to StreamEvent, so renderers written against config().stream() type-check against graph events unchanged. Handoff events use sourceKey / targetKey, the same names as $ld:ai:graph:handoff_*.
  • bindSpanContext (a sibling of bindConversationId, next to it in conversation.ts) re-enters the graph span's context on every next(). A generator body suspends at each yield, so wrapping it once is not enough — without this, a streamed two-node graph emitted node spans with no parent across three separate trace ids, while the same graph through invoke() produced one correctly nested trace.
  • Both the OTel parent and the conversation id are captured at stream() call time rather than on first next(), so a caller can hand the generator to a renderer and have it iterated later without launchdarkly.graph detaching into its own trace.
  • The graph span is launchdarkly.graph with launchdarkly.graph.key, matching the prefix rename on main (refactor(telemetry): one prefix for LaunchDarkly span attributes #16). It is opened with startSpan (parent captured at call time) rather than startActiveSpan, because the generator suspends. invoke() drains this same span; it does not open a second one.
  • The graph-level judge runs with the graph span re-entered, so its spans share the graph trace the way invoke()'s already did.
  • Consumer abandonment (break mid-stream) ends the graph span with launchdarkly.stream.abandoned and tracks neither success nor failure — abandonment is neither, per the convention documented above endSpanOnce.
  • graph().invoke(), route(), and runNode() drain this walk. Handoffs, judges, and graph telemetry live in one place; the blocking callers read it to completion.
  • Multi-edge routing is built once, in buildHandoffRouting, and only streamRoute() calls it. route() drains that generator, so the two entry points cannot drift. An earlier streaming copy had been written against a pre-fix(graph): prefer node tools before synthetic handoff routing #59 route() and silently reverted that PR's three prompt fixes.
  • Handlers without .stream fall back to a single chunk per node, matching config().stream().
  • The OpenAI Agents handler yields on output_text_delta. @openai/agents 0.11.6 rewrites the Responses API response.output_text.delta to that name before the handler sees it; matching the wire name produced a successful run with no model text.

Also corrects packages/client/README.md's ProviderGraphResponse row, which listed a trackData field the type does not have.

examples/graph-streaming.ts is wired through main.ts, and integration-config.json lists graph-streaming with the same flag keys as graph.

Test plan

  • packages/client/src/__tests__/graph.test.ts: 59 tests — event ordering, per-node nodeKey tagging, aggregate usage, handoff placement between node_done and the next node_start, disabled-graph and missing-handler errors, graph telemetry, per-node $ld:ai:generation:success carrying graphKey, blocking-handler fallback, and judge results on done
  • OTel parenting pinned from both sides: handler spans nest under launchdarkly.graph on the stream path and the invoke path, every finished span sharing one traceId
  • Deferred iteration keeps launchdarkly.graph under the caller's span; graph-judge spans nest under launchdarkly.graph
  • Abandonment: graph span carries launchdarkly.stream.abandoned, and no $ld:ai:graph:invocation_success is tracked
  • Multi-edge branching fixture: handoff_success from the route branch, handoff_failure after a choice was captured, judges receiving the un-augmented config, and handoff descriptions / tool reply / routing suffix matching the blocking path
  • Each span-parenting and routing fix was confirmed red before its fix, then confirmed load-bearing after by reverting each in turn — exactly one test fails each time, and it is the intended one
  • OpenAI agents streaming matches the event @openai/agents 0.11.6 yields (output_text_delta). yarn test re-run on the merge commit 11bd299 exited 0: every workspace package, including 95 tests in @launchdarkly/ai-openai-agents and 421 in @launchdarkly/ai-server
  • Live graph-streaming success key travel-agent-flow ("I was double charged for my flight"), re-run on the merge commit 11bd299: exit 0, model text on stdout, one [conversation] line, [node_start]/[node_done] for travel-agent-orchestrator, Usage input 366 / output 49 / total 415 matching that node. No handoff, which is valid for a single-node run. No JSON file. stderr had no RuntimeError or aclose
  • Live graph-streaming failure key travel-agent-flow-wrong-key: exit 1, one Error: Agent graph "travel-agent-flow-wrong-key" is disabled, no unhandled rejection, no JSON file
  • After merging main, re-run on 11bd299: yarn test includes packages/client — 14 files, 421 passed (59 of them in graph.test.ts, covering event order, launchdarkly.graph parenting on both paths, deferred iteration, abandonment, and multi-edge routing). The three tests added by the merge are main's judge-config skip coverage (fix(judges): a judge's own config must not fail the run it grades #17)

Note: packages/client/tsconfig.json uses "include": ["src/*.ts"], so tsc --noEmit does not typecheck anything under src/__tests__/. Left as-is — out of scope here.

Known behaviour carried over, not introduced

handoff_success double-emits on a multi-edge hop (once from the route branch, once from the next node's opts.from). That was already true of route / runNode. The single walk keeps it, and the test pins the count at 2.

judgeResults is the same on both entry points now. invoke() reads the stream's done event, which omits the field when judges return {}. The earlier split — stream normalizing {} to undefined while invoke() passed {} through — is gone.

graph().invoke() drains handler.stream when the handler has one, so graph nodes on both entry points follow TESTING.md §1.9: streaming ignores outputFormat. config().invoke() still uses the blocking handler. Python graph().invoke() still does too (see python-ai-sdk#104); only its stream() path drops the schema. The next node receives the previous response as text ([Previous agent response]\n...) on either path, not as a parsed object. What outputFormat changes is whether the provider was constrained to that schema while producing the text.

Relationship to #40 and #39

#40 implements this same ticket and predates this PR by four weeks — it was open, unreviewed, and nobody caught the overlap before this was built. It is being closed as overly complex, and two specific things drove that:

  • Its done event mirrors invoke()'s return including path and nodes, which is why it needed feat(AIC-3211): expose graph traversal path in ProviderGraphResponse #39 (AIC-3211) stacked underneath it. TESTING.md §3.11 has required since the spec's initial commit that the graph return "contain only response, usage, and optionally judgeResults — no path or nodes fields". This PR's done conforms; feat(AIC-3210): stream responses from agent graph nodes #40's required changing that rule and shipping a second ticket first.
  • It put streamRoute on the public GraphDefinition. Here it stays internal to buildGraph, so resolveGraph()'s contract is unchanged and no constructor signature moves when Python mirrors this.

Credit where it is due: #40 factored the handoff-tool setup into a shared prepareRoute() from the start, and this PR originally forked it — which silently reverted #59's three routing-prompt fixes on the streaming path. buildHandoffRouting is the same idea, arrived at the hard way.

On streamNode / streamRoute

runNode and route drain streamNode and streamRoute. graph().invoke() drains graph().stream(). There is no second copy of the router.

The two generators are still split: one outgoing edge delegates to the node walk, and multiple edges build the handoff tools. That is the same split route had before it became a drain. Collapsing them would mix the single-edge path with the synthetic-tool path, which is the opposite of what §3.15a's routing-parity rule is pinning.

Integration coverage — read before approving

examples/graph-streaming.ts is wired through main.ts, and integration-config.json lists graph-streaming with the same flag keys as graph. Both keys were run live.

The first success run exited 0 and reported tokens, but stdout had no model text. The OpenAI agents handler was still matching response.output_text.delta. @openai/agents 0.11.6 yields output_text_delta instead. After that check was updated, the same success key printed the model reply. The failure key was a clean disabled-graph error. Details are in the test plan above.

Spec: TESTING.md §3.15a and Appendix A.13. The companion spec PR is merged: https://github.com/launchdarkly/ai-sdks-monorepo/pull/20. Python implementation: launchdarkly/python-ai-sdk#104.

Jira: AIC-3210

…ph nodes

Add graph().stream() with GraphStreamEvent node boundaries, bindSpanContext
so handler spans nest under ld.ai.graph, and cover parenting, abandonment,
and multi-edge routing. Preserves main's history and modelStampsFromMeta.

Co-authored-by: Cursor <cursoragent@cursor.com>
@jeffdupont
jeffdupont marked this pull request as draft September 22, 2026 21:34
@jeffdupont
jeffdupont marked this pull request as ready for review September 23, 2026 19:31
jeffdupont and others added 4 commits September 24, 2026 08:39
…tion gate exercises graph().stream()

Co-authored-by: Cursor <cursoragent@cursor.com>
…oute, and runNode

The blocking graph path duplicated the router. One walk now owns handoffs, judges, and telemetry, and the blocking callers read it to completion.
… emits

graph().stream() wrote no model text because the handler matched the Responses API event name. The SDK rewrites that to output_text_delta before the handler sees it.

Co-authored-by: Cursor <cursoragent@cursor.com>
…reaming

Keep the single streaming walk. Name its span launchdarkly.graph so it
matches the telemetry prefix rename on main.

Co-authored-by: Cursor <cursoragent@cursor.com>
jeffdupont added a commit to launchdarkly/python-ai-sdk that referenced this pull request Sep 25, 2026
## Summary
- Implements `graph().stream()` with TypeScript-parity event shape
(`node_start` / `chunk` / `node_done` / `handoff` / `done`), shared
handoff routing with `invoke()`, call-time conversation binding, and
`ld.ai.graph` OTel parenting (including aligned `invoke()` span).
- Adds §3.15a unit coverage (`test_graph_stream.py`) and the
`graph-streaming` example wired through `main.py`.
- Keeps `__handoff_*` tool wrappers sync so multi-edge routing can
record the chosen edge without awaiting.

## Follow-ups
- Invoke span ERROR status on failure (parity with stream / A.4):
[AIC-3440](https://launchdarkly.atlassian.net/browse/AIC-3440)
- Per-event helper for `graph().stream()` callers, so the examples stop
hand-rolling the type dispatch:
[AIC-3465](https://launchdarkly.atlassian.net/browse/AIC-3465)
- Appendix A.13 already requires Python. The spec change is
[ai-sdks-monorepo#20](launchdarkly/ai-sdks-monorepo#20),
which is merged. This pull request is the implementation that
requirement points at.

## Test plan
- [x] `pytest packages/client/tests/` — 516 passed (the plan originally
recorded 515; one more test is in the tree now)
- [x] mypy on `graph.py` and `tracking.py` — no issues
- [x] ruff check and `ruff format --check` on `graph.py`, `tracking.py`,
`examples/graph_streaming.py`, and `main.py` — clean
- [x] Live `python main.py graph-streaming travel-agent-flow "I was
double charged for my flight"`: exit 0, model text on stdout, one
`[conversation]` line, `[node_start]` / `[node_done]` for
`travel-agent-orchestrator`, Usage input 892 / output 64 / total 956
matching that node. No handoff, which is valid for a single-node run. No
JSON file. stderr had no `RuntimeError` or `aclose`
- [x] Live failure key `travel-agent-flow-wrong-key`: exit 1, one
`Error: Agent graph "travel-agent-flow-wrong-key" is disabled`, no
unhandled rejection, no JSON file
- [x] Monorepo `integration-config.json` `graph-streaming` uses those
same keys (`travel-agent-flow` / `travel-agent-flow-wrong-key`). No
`integration-config-local.json` override. `main.py` dispatches
`graph-streaming` to `examples.graph_streaming`, and both keys were run
against that example

Spec: `TESTING.md` §3.15a and Appendix A.13. TypeScript:
launchdarkly/js-ai-sdk#84.

Jira: AIC-3210

[AIC-3440]:
https://launchdarkly.atlassian.net/browse/AIC-3440?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ


[AIC-3465]:
https://launchdarkly.atlassian.net/browse/AIC-3465?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
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