Skip to content

Graph streaming

StateGraph.stream(...) yields events as nodes complete — not buffered until the graph finishes. For an incident graph that means a live forensic replay: you watch which specialist decided what, when — triage scoring the alert, forensics pulling the timeline, containment isolating the host — instead of seeing only the verdict at the end.

Modes

from tulip.multiagent import StateGraph, StreamMode

# graph: triage -> forensics -> containment
async for event in graph.stream(incident, mode=StreamMode.UPDATES):
    print(event.node_id, event.data)   # who decided, what they wrote to state
Mode Yields per node Plus terminal event
StreamMode.VALUES (default) A snapshot of full state after the node completes StreamEvent(mode=VALUES, data=final_state)
StreamMode.UPDATES Just the node's own output dict
StreamMode.NODES The full NodeResult with status / duration / error
StreamMode.DEBUG {"result": NodeResult, "state": dict}
StreamMode.CUSTOM Whatever emit_custom(...) pushes from inside a node body

Custom events from inside a node

A forensics node sweeping many hosts can push intermediate progress with emit_custom so the replay shows each host as it clears. Outside a stream() context the call is a silent no-op, so the same node code runs unchanged under execute() too.

from tulip.multiagent import emit_custom

async def forensics_node(state: dict) -> dict:
    hosts = state["scope"]
    for i, host in enumerate(hosts):
        await emit_custom({"progress": i / len(hosts), "phase": f"timeline:{host}"})
        await scan_host(host)
    return {"compromised": ["web-07"]}

graph.add_node("forensics", forensics_node)

async for event in graph.stream(incident, mode=StreamMode.UPDATES):
    if event.mode == StreamMode.CUSTOM:
        ui.set_progress(event.data["progress"])     # sweep advancing, live
    elif event.mode == StreamMode.UPDATES:
        ui.mark_node_complete(event.node_id)         # specialist done deciding

emit_custom is exported from tulip.multiagent and accepts an optional node_id= kwarg if you want the event tagged with the emitting specialist's identity.

Real-time delivery

Decisions arrive as each specialist finishes, not at the end. A fast-triage / slow-forensics graph proves it:

async def triage(state):
    return {"severity": "high"}             # ~ms

async def forensics(state):
    await asyncio.sleep(2)                  # 2 seconds: deep timeline pull
    return {"compromised": ["web-07"]}

graph.add_node("triage", triage)
graph.add_node("forensics", forensics)
graph.add_edge(START, "triage"); graph.add_edge("triage", "forensics"); graph.add_edge("forensics", END)

start = time.perf_counter()
async for ev in graph.stream(incident, mode=StreamMode.UPDATES):
    print(f"{time.perf_counter() - start:.2f}s  {ev.node_id}")
# 0.05s  triage
# 2.05s  forensics

If stream() were buffering, both events would arrive at 2.05s. The unit test test_stategraph_streaming.py guards this property — fails the build if the first event lands at ≥ end / 2.

Error and cancellation

A specialist that raises has its NodeResult.success set to False with the error message; the stream still yields an event for it (no consumer deadlock), so the replay records the failed step instead of hanging. Breaking out of the iterator early — say the analyst already saw the containment decision and closed the panel — cancels the background driver task so no work continues in the background.

Source

src/tulip/multiagent/graph.py:emit_custom, StateGraph.stream, StreamMode.