feat(AIC-3210): support streaming responses from agent graph nodes - #84
Open
jeffdupont wants to merge 7 commits into
Open
jeffdupont wants to merge 7 commits into
jeffdupont wants to merge 7 commits into
Conversation
…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
marked this pull request as draft
September 22, 2026 21:34
jeffdupont
marked this pull request as ready for review
September 23, 2026 19:31
…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>
6 tasks
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
andrewklatzke
approved these changes
Sep 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
graph().stream()— an async-generator counterpart tograph().invoke()that yields node boundaries while the router keeps ownership of handoffs and graph-level telemetry.GraphStreamEventunion:node_start,chunk(tagged withnodeKey),node_done,handoff, and a finaldone. Itschunkanddonevariants stay structurally assignable toStreamEvent, so renderers written againstconfig().stream()type-check against graph events unchanged. Handoff events usesourceKey/targetKey, the same names as$ld:ai:graph:handoff_*.bindSpanContext(a sibling ofbindConversationId, next to it inconversation.ts) re-enters the graph span's context on everynext(). A generator body suspends at eachyield, 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 throughinvoke()produced one correctly nested trace.stream()call time rather than on firstnext(), so a caller can hand the generator to a renderer and have it iterated later withoutlaunchdarkly.graphdetaching into its own trace.launchdarkly.graphwithlaunchdarkly.graph.key, matching the prefix rename on main (refactor(telemetry): one prefix for LaunchDarkly span attributes #16). It is opened withstartSpan(parent captured at call time) rather thanstartActiveSpan, because the generator suspends.invoke()drains this same span; it does not open a second one.invoke()'s already did.breakmid-stream) ends the graph span withlaunchdarkly.stream.abandonedand tracks neither success nor failure — abandonment is neither, per the convention documented aboveendSpanOnce.graph().invoke(),route(), andrunNode()drain this walk. Handoffs, judges, and graph telemetry live in one place; the blocking callers read it to completion.buildHandoffRouting, and onlystreamRoute()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 #59route()and silently reverted that PR's three prompt fixes..streamfall back to a single chunk per node, matchingconfig().stream().output_text_delta.@openai/agents0.11.6 rewrites the Responses APIresponse.output_text.deltato 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'sProviderGraphResponserow, which listed atrackDatafield the type does not have.examples/graph-streaming.tsis wired throughmain.ts, andintegration-config.jsonlistsgraph-streamingwith the same flag keys asgraph.Test plan
packages/client/src/__tests__/graph.test.ts: 59 tests — event ordering, per-nodenodeKeytagging, aggregate usage, handoff placement betweennode_doneand the nextnode_start, disabled-graph and missing-handler errors, graph telemetry, per-node$ld:ai:generation:successcarryinggraphKey, blocking-handler fallback, and judge results ondonelaunchdarkly.graphon the stream path and the invoke path, every finished span sharing onetraceIdlaunchdarkly.graphunder the caller's span; graph-judge spans nest underlaunchdarkly.graphlaunchdarkly.stream.abandoned, and no$ld:ai:graph:invocation_successis trackedhandoff_successfrom the route branch,handoff_failureafter a choice was captured, judges receiving the un-augmented config, and handoff descriptions / tool reply / routing suffix matching the blocking path@openai/agents0.11.6 yields (output_text_delta).yarn testre-run on the merge commit11bd299exited 0: every workspace package, including 95 tests in@launchdarkly/ai-openai-agentsand 421 in@launchdarkly/ai-servergraph-streamingsuccess keytravel-agent-flow("I was double charged for my flight"), re-run on the merge commit11bd299: exit 0, model text on stdout, one[conversation]line,[node_start]/[node_done]fortravel-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 noRuntimeErrororaclosegraph-streamingfailure keytravel-agent-flow-wrong-key: exit 1, oneError: Agent graph "travel-agent-flow-wrong-key" is disabled, no unhandled rejection, no JSON file11bd299:yarn testincludespackages/client— 14 files, 421 passed (59 of them ingraph.test.ts, covering event order,launchdarkly.graphparenting 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.jsonuses"include": ["src/*.ts"], sotsc --noEmitdoes not typecheck anything undersrc/__tests__/. Left as-is — out of scope here.Known behaviour carried over, not introduced
handoff_successdouble-emits on a multi-edge hop (once from the route branch, once from the next node'sopts.from). That was already true ofroute/runNode. The single walk keeps it, and the test pins the count at 2.judgeResultsis the same on both entry points now.invoke()reads the stream'sdoneevent, which omits the field when judges return{}. The earlier split — stream normalizing{}toundefinedwhileinvoke()passed{}through — is gone.graph().invoke()drainshandler.streamwhen the handler has one, so graph nodes on both entry points follow TESTING.md §1.9: streaming ignoresoutputFormat.config().invoke()still uses the blocking handler. Pythongraph().invoke()still does too (see python-ai-sdk#104); only itsstream()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. WhatoutputFormatchanges 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:
doneevent mirrorsinvoke()'s return includingpathandnodes, 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 onlyresponse,usage, and optionallyjudgeResults— nopathornodesfields". This PR'sdoneconforms; feat(AIC-3210): stream responses from agent graph nodes #40's required changing that rule and shipping a second ticket first.streamRouteon the publicGraphDefinition. Here it stays internal tobuildGraph, soresolveGraph()'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.buildHandoffRoutingis the same idea, arrived at the hard way.On
streamNode/streamRouterunNodeandroutedrainstreamNodeandstreamRoute.graph().invoke()drainsgraph().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
routehad 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.tsis wired throughmain.ts, andintegration-config.jsonlistsgraph-streamingwith the same flag keys asgraph. 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/agents0.11.6 yieldsoutput_text_deltainstead. 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