Skip to content

Commit 5d9e33b

Browse files
bloveclaude
andcommitted
feat(middleware): emit_custom_event, the Python helper that survives ag-ui-langgraph
The ag-ui-langgraph bridge consumes the graph through astream_events, so the only path from a node to the adapter's customEvents() signal is adispatch_custom_event. A get_stream_writer() write with stream_mode="custom" surfaces at most as a raw event and is silently dropped — the payload never reaches the client, with no error anywhere. Adds threadplane.middleware.langgraph.emit_custom_event, an async wrapper around adispatch_custom_event that accepts the node's config when the caller has it and otherwise relies on the ambient run context. Exported from the package's __all__. The pytest drives a real one-node graph and asserts both calls arrive as on_custom_event through astream_events. Documents it in the package README, the Python LangGraph guide, and the AG-UI Custom Events guide, whose backend snippet now shows the helper. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 9de5c5d commit 5d9e33b

6 files changed

Lines changed: 148 additions & 7 deletions

File tree

apps/website/content/docs/ag-ui/guides/custom-events.mdx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,30 +35,34 @@ The adapter JSON-parses `value` when it arrives as a string, so consumers always
3535

3636
### The working path under ag-ui-langgraph
3737

38-
The `ag-ui-langgraph` bridge consumes the graph through `astream_events`, and it forwards every `on_custom_event` it sees one-for-one as an AG-UI `CUSTOM` frame carrying the same name and payload. LangChain's `adispatch_custom_event` is what puts an `on_custom_event` on that stream, so it is the call a node (or a callback handler running inside one) makes to reach `customEvents`:
38+
The `ag-ui-langgraph` bridge consumes the graph through `astream_events`, and it forwards every `on_custom_event` it sees one-for-one as an AG-UI `CUSTOM` frame carrying the same name and payload. LangChain's `adispatch_custom_event` is what puts an `on_custom_event` on that stream, so it is the call a node (or a callback handler running inside one) makes to reach `customEvents`.
39+
40+
The `threadplane-middleware` Python package wraps that call as `emit_custom_event`, which is the recommended way to make it:
3941

4042
```python
41-
from langchain_core.callbacks import adispatch_custom_event
4243
from langchain_core.runnables import RunnableConfig
44+
from threadplane.middleware.langgraph import emit_custom_event
4345

4446
async def analysis_node(state: State, config: RunnableConfig) -> State:
4547
# Emit a partial result as the node runs
46-
await adispatch_custom_event(
47-
"analysis_progress", {"step": "scoring", "pct": 42}
48+
await emit_custom_event(
49+
"analysis_progress", {"step": "scoring", "pct": 42}, config=config
4850
)
4951

5052
# ... do more work ...
5153

52-
await adispatch_custom_event(
53-
"analysis_progress", {"step": "scoring", "pct": 100}
54+
await emit_custom_event(
55+
"analysis_progress", {"step": "scoring", "pct": 100}, config=config
5456
)
5557
return state
5658
```
5759

60+
The signature is `emit_custom_event(name, value, *, config=None)`. Pass `config` when the node already receives one; omit it and the ambient run context is used. Backends that do not depend on the middleware package can call `adispatch_custom_event` from `langchain_core.callbacks` directly — the helper adds no wire behavior of its own.
61+
5862
The event name becomes `CustomStreamEvent.name` and the payload becomes `CustomStreamEvent.data`. This is the mechanism the [subagents example](/docs/ag-ui/guides/subagents) uses to stream child-agent tokens from a callback handler.
5963

6064
<Callout type="warning" title="get_stream_writer does not survive ag-ui-langgraph">
61-
Writing to `get_stream_writer()` with `stream_mode='custom'` does **not** produce a `CUSTOM` frame under `ag-ui-langgraph`. The bridge reads `astream_events`, where a stream-writer write surfaces at most as a raw event, so nothing is appended to `customEvents`. Use `adispatch_custom_event` instead. Other AG-UI runtimes that emit `CUSTOM` frames directly are unaffected by this constraint — the adapter only cares that a `CUSTOM` frame arrives.
65+
Writing to `get_stream_writer()` with `stream_mode='custom'` does **not** produce a `CUSTOM` frame under `ag-ui-langgraph`. The bridge reads `astream_events`, where a stream-writer write surfaces at most as a raw event, so nothing is appended to `customEvents`. Use `emit_custom_event` (or `adispatch_custom_event`) instead. Other AG-UI runtimes that emit `CUSTOM` frames directly are unaffected by this constraint — the adapter only cares that a `CUSTOM` frame arrives.
6266
</Callout>
6367

6468
### Graph state is a different signal

apps/website/content/docs/middleware/guides/python-langgraph.mdx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ from threadplane.middleware.langgraph import (
8282
bind_client_tools,
8383
client_tool_names,
8484
client_tool_specs,
85+
emit_custom_event,
8586
has_client_tool_call,
8687
has_server_tool_call,
8788
last_message,
@@ -100,9 +101,31 @@ from threadplane.middleware.langgraph import (
100101
| `last_message(state)` | Return the last message from `state["messages"]`, or `None`. |
101102
| `a2ui_client_capabilities(state)` | Return the A2UI capabilities the frontend advertised, or `None`. |
102103
| `announce_subagent(config, tool_call_id)` | Emit a custom event binding a child graph's stream namespace to the tool call that started it. |
104+
| `emit_custom_event(name, value, config=None)` | Push a payload to the frontend as an AG-UI `CUSTOM` event. |
103105

104106
That import list is the package's full `__all__`.
105107

108+
## Pushing data to the frontend mid-run
109+
110+
`emit_custom_event` is an async helper that wraps LangChain's `adispatch_custom_event`:
111+
112+
```python
113+
from langchain_core.runnables import RunnableConfig
114+
from threadplane.middleware.langgraph import emit_custom_event
115+
116+
async def analysis_node(state: State, config: RunnableConfig) -> State:
117+
await emit_custom_event("analysis_progress", {"pct": 42}, config=config)
118+
return state
119+
```
120+
121+
The `name` becomes `CustomStreamEvent.name` on the client and the `value` becomes `CustomStreamEvent.data`. Pass `config` when the node already receives one; omit it and the ambient run context is used.
122+
123+
<Callout type="warning" title="get_stream_writer does not reach the frontend">
124+
An `ag-ui-langgraph` backend consumes the graph through `astream_events`, and only `adispatch_custom_event` places an event on that stream. Writing to `get_stream_writer()` with `stream_mode="custom"` is silently dropped, so nothing reaches the adapter. Use `emit_custom_event` and the payload survives.
125+
</Callout>
126+
127+
The Angular side of this is documented in the AG-UI [Custom Events guide](/docs/ag-ui/guides/custom-events).
128+
106129
## Frontend contract
107130

108131
The middleware does not execute browser tools. The frontend still needs to send the catalog, observe the model tool call, execute the local function or UI interaction, and resume the graph with a `ToolMessage` containing the result.

packages/threadplane-middleware/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,24 @@ from threadplane.middleware.langgraph import (
7171
)
7272
```
7373

74+
## Pushing data to the frontend mid-run
75+
76+
```python
77+
from langchain_core.runnables import RunnableConfig
78+
from threadplane.middleware.langgraph import emit_custom_event
79+
80+
async def analysis_node(state, config: RunnableConfig):
81+
await emit_custom_event("analysis_progress", {"pct": 42}, config=config)
82+
return state
83+
```
84+
85+
`emit_custom_event(name, value, *, config=None)` wraps LangChain's
86+
`adispatch_custom_event`. An `ag-ui-langgraph` backend consumes the graph
87+
through `astream_events`, and only `adispatch_custom_event` places an event on
88+
that stream — a `get_stream_writer()` write with `stream_mode="custom"` is
89+
silently dropped and never reaches the client. Pass `config` when the node
90+
already receives one; omit it and the ambient run context is used.
91+
7492
## Development
7593

7694
```bash

packages/threadplane-middleware/src/threadplane/middleware/langgraph/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""threadplane-middleware — LangGraph middleware for client-declared tools."""
22

3+
from threadplane.middleware.langgraph.custom_events import emit_custom_event
34
from threadplane.middleware.langgraph.middleware import (
45
a2ui_client_capabilities,
56
announce_subagent,
@@ -18,6 +19,7 @@
1819
"bind_client_tools",
1920
"client_tool_names",
2021
"client_tool_specs",
22+
"emit_custom_event",
2123
"has_client_tool_call",
2224
"has_server_tool_call",
2325
"last_message",
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Emit a custom event that survives the ag-ui-langgraph bridge."""
2+
3+
from typing import Any, Optional
4+
5+
from langchain_core.callbacks.manager import adispatch_custom_event
6+
7+
8+
async def emit_custom_event(
9+
name: str,
10+
value: Any,
11+
*,
12+
config: Optional[Any] = None,
13+
) -> None:
14+
"""Push ``value`` to the frontend as an AG-UI ``CUSTOM`` event.
15+
16+
The ``ag-ui-langgraph`` bridge consumes the graph through ``astream_events``
17+
and forwards every ``on_custom_event`` it sees as a ``CUSTOM`` frame. Only
18+
``adispatch_custom_event`` puts an ``on_custom_event`` on that stream:
19+
writing to ``get_stream_writer()`` with ``stream_mode="custom"`` surfaces at
20+
most as a raw event and is silently dropped, so nothing reaches the
21+
adapter's ``customEvents()`` signal. This helper is that call, named for
22+
what it does::
23+
24+
from langchain_core.runnables import RunnableConfig
25+
from threadplane.middleware.langgraph import emit_custom_event
26+
27+
async def analysis_node(state: State, config: RunnableConfig) -> State:
28+
await emit_custom_event("analysis_progress", {"pct": 42}, config=config)
29+
return state
30+
31+
``name`` becomes ``CustomStreamEvent.name`` on the client and ``value``
32+
becomes ``CustomStreamEvent.data``.
33+
34+
Pass ``config`` when the node already receives one — LangChain then dispatches
35+
through that config's callback manager rather than the ambient contextvar,
36+
which is what keeps the event attributed correctly inside nested runnables
37+
and on Python 3.10, where the contextvar is not propagated automatically.
38+
Omit it and the ambient run context is used.
39+
"""
40+
if config is None:
41+
await adispatch_custom_event(name, value)
42+
else:
43+
await adispatch_custom_event(name, value, config=config)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Tests for threadplane.middleware.langgraph.custom_events.
2+
3+
``emit_custom_event`` must reach ``astream_events`` as an ``on_custom_event``,
4+
because that stream is the only path an ``ag-ui-langgraph`` bridge reads. A
5+
``get_stream_writer`` write does not surface there, so the helper wraps
6+
``adispatch_custom_event``.
7+
"""
8+
9+
import asyncio
10+
11+
from langgraph.graph import END, StateGraph
12+
from typing_extensions import TypedDict
13+
14+
from threadplane.middleware.langgraph import emit_custom_event
15+
16+
17+
class _State(TypedDict, total=False):
18+
value: int
19+
20+
21+
def _build_graph():
22+
async def node(state: _State, config=None) -> _State:
23+
# Once with the node's config, once relying on the contextvar.
24+
await emit_custom_event("analysis_progress", {"pct": 42}, config=config)
25+
await emit_custom_event("analysis_progress", {"pct": 100})
26+
return {"value": 1}
27+
28+
graph = StateGraph(_State)
29+
graph.add_node("work", node)
30+
graph.add_edge("__start__", "work")
31+
graph.add_edge("work", END)
32+
return graph.compile()
33+
34+
35+
def _collect_custom_events():
36+
async def run():
37+
graph = _build_graph()
38+
seen = []
39+
async for event in graph.astream_events({"value": 0}, version="v2"):
40+
if event["event"] == "on_custom_event":
41+
seen.append((event["name"], event["data"]))
42+
return seen
43+
44+
return asyncio.run(run())
45+
46+
47+
def test_emit_custom_event_reaches_astream_events():
48+
assert _collect_custom_events() == [
49+
("analysis_progress", {"pct": 42}),
50+
("analysis_progress", {"pct": 100}),
51+
]

0 commit comments

Comments
 (0)