Skip to content

Commit 16b2a25

Browse files
authored
Merge pull request #294 from fireflyframework/feat/workflows-streaming-generics
feat(workflows): token-level streaming and typed generics
2 parents ba9346b + 52ddda4 commit 16b2a25

9 files changed

Lines changed: 437 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
Copyright 2026 Firefly Software Foundation. Licensed under the Apache License 2.0.
99

10+
## [26.06.4] - 2026-06-21
11+
12+
Dynamic Workflows — the final SOTA wave: token-level streaming and end-to-end
13+
static typing of the DSL.
14+
15+
### Added
16+
17+
- **Streaming**`stream(prompt, ...)` is an async context manager that streams one
18+
sub-agent's output token-by-token: iterate `handle.text()` for deltas, then read
19+
`handle.output` for the full result after the block. It honours the budget,
20+
concurrency gate, journal (a resumed call yields its cached output once) and cost
21+
accounting exactly like `agent()`. Backed by a `StreamingAgentRunner` protocol that
22+
`DefaultAgentRunner` implements via pydantic-ai's `run_stream`; a non-streaming
23+
runner raises `WorkflowError`. Streamed calls emit `agent.start` / `agent.end` with
24+
`stream: True`. New exports: `stream`, `StreamHandle`, `StreamingAgentRunner`.
25+
- **Typed generics**`agent(output_type=T)` is now typed to return `T` (via
26+
`@overload`) instead of `Any`, and `@workflow` produces a `Workflow[OutputT]`
27+
inferred from the function's return annotation, so `await my_workflow(args)` is
28+
statically typed end-to-end with no casts.
29+
1030
## [26.06.3] - 2026-06-20
1131

1232
Dynamic Workflows — the durable-composition wave: sub-workflows, durable resume,

docs/workflows.md

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ or by name through `run_workflow("deep_research", args)`.
7777
| `agent` | `await agent(prompt, *, label, model, output_type, instructions, deps, tools, toolsets)` | Run one isolated sub-agent; returns its `output` (a `str` or a validated `output_type`). A sub-agent can use `tools=`/`toolsets=` (e.g. `ToolKit.as_toolset()` or an MCP server) just like a top-level agent. Honours the budget, the concurrency gate, and the resume journal. |
7878
| `parallel` | `await parallel(thunks)` | **Barrier.** Run zero-arg async thunks concurrently; a thunk that raises resolves to `None` (the call never propagates). Returns a list aligned to `thunks`. |
7979
| `pipeline` | `await pipeline(items, *stages)` | **No inter-stage barrier.** Each item flows through every stage independently (item A can be in stage 3 while B is in stage 1). Each stage receives `(prev, item, index)` — declare only the params you need. A stage that raises drops *that* item to `None`. |
80+
| `stream` | `async with stream(prompt, ...) as s:` | Stream one sub-agent token-by-token: iterate `s.text()` for deltas; `s.output` holds the full output after the block. Same budget/journal/cost accounting as `agent`. Requires a streaming runner. See [Streaming](#streaming). |
8081
| `phase` | `with phase("title"):` | Group enclosed work for telemetry (`phase.start` / `phase.end` events). |
8182
| `log` | `log("message")` | Emit a narrator line to the run's event handler. |
8283

@@ -85,6 +86,30 @@ glue between agent calls — dedup, rank, filter, branch — is ordinary
8586
deterministic Python. Reach for an `agent()` only when you genuinely need a
8687
model.
8788

89+
### Type safety
90+
91+
The DSL is statically typed end-to-end. `agent(output_type=T)` is typed to
92+
return `T` (not `Any`), and `@workflow` produces a `Workflow[OutputT]` inferred
93+
from the function's return annotation — so the awaited result is typed too:
94+
95+
```python
96+
from pydantic import BaseModel
97+
from fireflyframework_agentic.workflows import workflow, agent
98+
99+
class Report(BaseModel):
100+
summary: str
101+
sources: list[str]
102+
103+
@workflow(name="research")
104+
async def research(args, ctx) -> Report:
105+
return await agent("write the report", output_type=Report) # typed Report
106+
107+
report: Report = await research(args) # research is Workflow[Report]; result is Report
108+
```
109+
110+
Without `output_type`, `agent()` returns `Any` (the raw string output). Pyright
111+
infers all of the above with no casts.
112+
88113
---
89114

90115
## Budgets
@@ -177,6 +202,29 @@ await deep_research(args, runner=MyFakeRunner())
177202

178203
---
179204

205+
## Streaming
206+
207+
Stream a single sub-agent's output token-by-token with the `stream()` context
208+
manager (requires a streaming-capable runner — `DefaultAgentRunner` is). It
209+
honours the budget, concurrency gate, journal and cost accounting exactly like
210+
`agent()`:
211+
212+
```python
213+
from fireflyframework_agentic.workflows import workflow, stream
214+
215+
@workflow(name="live_report")
216+
async def live_report(args, ctx):
217+
async with stream("write a cited report", model=args.model) as s:
218+
async for delta in s.text():
219+
print(delta, end="", flush=True) # surface tokens as they arrive
220+
return s.output # the full text, available after the block
221+
```
222+
223+
On resume, a cached `stream()` call yields its full output once (the model is not
224+
re-invoked). A runner that doesn't support streaming raises `WorkflowError`.
225+
226+
---
227+
180228
## Model routing & cost optimization
181229

182230
The cheapest model that can do the job should do the job. Two complementary tools
@@ -326,7 +374,8 @@ process restart.
326374
Pass `events=callable` to a run to receive structured events:
327375
`workflow.start` / `workflow.end` (with `agents`, `tokens`, `cost_usd`),
328376
`phase.start` / `phase.end`, `agent.start` / `agent.end` (with `label`, `phase`,
329-
`seq`, `tokens`, `cost_usd`), `route.select` / `route.escalate`, `cascade.tier`,
377+
`seq`, `tokens`, `cost_usd`; streamed calls also carry `stream: True`),
378+
`route.select` / `route.escalate`, `cascade.tier`,
330379
`subworkflow.start` / `subworkflow.end`, `human.pause`, and `log`. Wire this into
331380
the [observability](observability.md) layer for live per-phase token/cost/agent/time
332381
counters.
@@ -342,6 +391,8 @@ counters.
342391
| `run_workflow(name, args, **opts)` | Look up a registered workflow by name and run it. |
343392
| `subworkflow(name_or_wf, args)` | Run another workflow inline, inheriting the parent's budget/journal/runner. |
344393
| `agent` / `parallel` / `pipeline` / `phase` / `log` | The DSL primitives. |
394+
| `stream(prompt, ...)` | Async context manager → a `StreamHandle` (`.text()` deltas, `.output`); streams one sub-agent. |
395+
| `StreamHandle` / `StreamingAgentRunner` | The streaming result handle and the runner protocol a runner implements to support `stream`. |
345396
| `human(prompt)` | Pause for external input (raises `WorkflowInterrupt`; resume via the same journal). |
346397
| `map_agents(items, fn, *, strict=False)` | Run `fn(item)` per item concurrently — sugar over `parallel` (no late-binding lambda). |
347398
| `WorkflowBudget` | Concurrency / agent-count / token / **USD cost** / **wall-clock** ceilings. |

fireflyframework_agentic/workflows/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ async def deep_research(args, ctx):
5353
parallel,
5454
phase,
5555
pipeline,
56+
stream,
5657
)
5758
from fireflyframework_agentic.workflows.registry import (
5859
Workflow,
@@ -73,6 +74,8 @@ async def deep_research(args, ctx):
7374
AgentCall,
7475
AgentRunner,
7576
DefaultAgentRunner,
77+
StreamHandle,
78+
StreamingAgentRunner,
7679
)
7780
from fireflyframework_agentic.workflows.verify import (
7881
CascadeResult,
@@ -95,6 +98,8 @@ async def deep_research(args, ctx):
9598
"JournalBackend",
9699
"ModelSelectionStrategy",
97100
"SmartRoutingRunner",
101+
"StreamHandle",
102+
"StreamingAgentRunner",
98103
"Verdict",
99104
"Workflow",
100105
"WorkflowBudget",
@@ -115,6 +120,7 @@ async def deep_research(args, ctx):
115120
"pipeline",
116121
"price_model",
117122
"run_workflow",
123+
"stream",
118124
"subworkflow",
119125
"workflow",
120126
"workflow_registry",

fireflyframework_agentic/workflows/primitives.py

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,23 +35,54 @@
3535
import contextlib
3636
import inspect
3737
import logging
38-
from collections.abc import Awaitable, Callable, Iterable, Iterator
39-
from typing import Any
38+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Iterator
39+
from typing import Any, TypeVar, overload
4040

4141
from fireflyframework_agentic.exceptions import (
4242
WorkflowBudgetError,
4343
WorkflowContextError,
44+
WorkflowError,
4445
WorkflowInterrupt,
4546
)
4647
from fireflyframework_agentic.workflows.context import current_workflow
48+
from fireflyframework_agentic.workflows.runner import AgentCall, StreamingAgentRunner
4749

4850
logger = logging.getLogger(__name__)
51+
_T = TypeVar("_T")
4952

5053
# Structural / kill-switch / control-flow signals must abort the run, not be
5154
# swallowed to ``None`` by a parallel branch or a pipeline stage.
5255
_NEVER_SWALLOW = (WorkflowBudgetError, WorkflowContextError, WorkflowInterrupt)
5356

5457

58+
@overload
59+
async def agent(
60+
prompt: Any,
61+
*,
62+
label: str | None = ...,
63+
model: Any | None = ...,
64+
output_type: type[_T],
65+
instructions: str | None = ...,
66+
deps: Any = ...,
67+
tools: Any | None = ...,
68+
toolsets: Any | None = ...,
69+
) -> _T: ...
70+
71+
72+
@overload
73+
async def agent(
74+
prompt: Any,
75+
*,
76+
label: str | None = ...,
77+
model: Any | None = ...,
78+
output_type: None = ...,
79+
instructions: str | None = ...,
80+
deps: Any = ...,
81+
tools: Any | None = ...,
82+
toolsets: Any | None = ...,
83+
) -> Any: ...
84+
85+
5586
async def agent(
5687
prompt: Any,
5788
*,
@@ -142,6 +173,88 @@ async def human(prompt: str, *, label: str | None = None) -> Any:
142173
raise WorkflowInterrupt(prompt, seq=seq, journal=ctx.journal)
143174

144175

176+
class _CachedStream:
177+
"""Stream handle for a journal-cached call: yields the full output once."""
178+
179+
def __init__(self, output: Any) -> None:
180+
self._output = output
181+
self.call = AgentCall(output=output)
182+
183+
async def text(self) -> AsyncIterator[str]:
184+
yield str(self._output)
185+
186+
@property
187+
def output(self) -> Any:
188+
return self._output
189+
190+
191+
@contextlib.asynccontextmanager
192+
async def stream(
193+
prompt: Any,
194+
*,
195+
label: str | None = None,
196+
model: Any | None = None,
197+
output_type: Any | None = None,
198+
instructions: str | None = None,
199+
deps: Any = None,
200+
tools: Any | None = None,
201+
toolsets: Any | None = None,
202+
) -> AsyncIterator[Any]:
203+
"""Stream one sub-agent's output token-by-token, as a context manager.
204+
205+
Iterate ``handle.text()`` for deltas; after the block, ``handle.output`` holds
206+
the full output. Honours the budget, concurrency gate, journal (a resumed call
207+
yields its cached output once) and cost accounting, exactly like ``agent()``.
208+
Requires the run's runner to support streaming (``DefaultAgentRunner`` does).
209+
210+
Example::
211+
212+
async with stream("write a cited report", model=args.model) as s:
213+
async for delta in s.text():
214+
print(delta, end="", flush=True)
215+
report = s.output
216+
"""
217+
ctx = current_workflow()
218+
seq = ctx.next_call_seq()
219+
if ctx.journal.has(seq):
220+
yield _CachedStream(ctx.journal.get(seq))
221+
return
222+
223+
runner = ctx.runner
224+
if not isinstance(runner, StreamingAgentRunner):
225+
raise WorkflowError(f"the active runner {type(runner).__name__} does not support streaming")
226+
227+
ctx.reserve_agent()
228+
display = label or (prompt[:40] if isinstance(prompt, str) else f"stream#{seq}")
229+
ctx.emit("agent.start", {"label": display, "phase": ctx.current_phase, "seq": seq, "stream": True})
230+
async with ctx.semaphore:
231+
async with runner.run_stream(
232+
prompt,
233+
model=model,
234+
output_type=output_type,
235+
instructions=instructions,
236+
deps=deps,
237+
tools=tools,
238+
toolsets=toolsets,
239+
) as handle:
240+
yield handle
241+
call = handle.call if handle.call is not None else AgentCall(output=handle.output)
242+
ctx.record_tokens(call.tokens)
243+
ctx.record_cost_usd(call.cost_usd)
244+
ctx.journal.record(seq, call.output)
245+
ctx.emit(
246+
"agent.end",
247+
{
248+
"label": display,
249+
"phase": ctx.current_phase,
250+
"seq": seq,
251+
"tokens": call.tokens,
252+
"cost_usd": call.cost_usd,
253+
"stream": True,
254+
},
255+
)
256+
257+
145258
async def parallel(thunks: Iterable[Callable[[], Awaitable[Any]]]) -> list[Any]:
146259
"""Run zero-arg async ``thunks`` concurrently and return their results.
147260

fireflyframework_agentic/workflows/registry.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@
2828
import logging
2929
import threading
3030
import uuid
31-
from typing import Any
31+
from collections.abc import Awaitable, Callable
32+
from typing import Any, Generic, TypeVar
3233

3334
from fireflyframework_agentic.exceptions import WorkflowBudgetError, WorkflowNotFoundError
3435
from fireflyframework_agentic.workflows.context import (
@@ -43,6 +44,7 @@
4344
logger = logging.getLogger(__name__)
4445

4546
EventHandler = Any # Callable[[str, dict], None]
47+
OutputT = TypeVar("OutputT")
4648

4749

4850
def _wants_context(fn: Any) -> bool:
@@ -58,13 +60,13 @@ def _wants_context(fn: Any) -> bool:
5860
return len(params) >= 2
5961

6062

61-
class Workflow:
62-
"""A registered, runnable workflow."""
63+
class Workflow(Generic[OutputT]):
64+
"""A registered, runnable workflow (generic over its return type)."""
6365

6466
def __init__(
6567
self,
6668
name: str,
67-
fn: Any,
69+
fn: Callable[..., Awaitable[OutputT]],
6870
*,
6971
args_schema: Any | None = None,
7072
description: str = "",
@@ -83,7 +85,7 @@ async def run(
8385
journal: Journal | None = None,
8486
events: EventHandler | None = None,
8587
run_id: str | None = None,
86-
) -> Any:
88+
) -> OutputT:
8789
"""Execute the workflow, returning whatever its body returns.
8890
8991
Pass the same ``journal`` from a prior run to resume: completed agent
@@ -127,7 +129,7 @@ async def run(
127129
finally:
128130
_current.reset(token)
129131

130-
async def __call__(self, args: Any = None, **kwargs: Any) -> Any:
132+
async def __call__(self, args: Any = None, **kwargs: Any) -> OutputT:
131133
return await self.run(args, **kwargs)
132134

133135

@@ -177,20 +179,23 @@ def workflow(
177179
args_schema: Any | None = None,
178180
description: str = "",
179181
register: bool = True,
180-
) -> Any:
182+
) -> Callable[[Callable[..., Awaitable[OutputT]]], Workflow[OutputT]]:
181183
"""Decorator: turn an async function into a registered :class:`Workflow`.
182184
185+
The returned :class:`Workflow` is generic over the function's return type, so
186+
``await my_workflow(args)`` is typed as that return type rather than ``Any``.
187+
183188
Example::
184189
185190
@workflow(name="deep_research", args_schema=ResearchArgs)
186-
async def deep_research(args, ctx):
191+
async def deep_research(args, ctx) -> Report:
187192
with phase("search"):
188193
hits = await parallel([lambda q=q: agent(f"search: {q}") for q in args.queries])
189-
return await agent("synthesize", deps=hits)
194+
return await agent("synthesize", deps=hits, output_type=Report)
190195
"""
191196

192-
def decorator(fn: Any) -> Workflow:
193-
wf_name = name or fn.__name__
197+
def decorator(fn: Callable[..., Awaitable[OutputT]]) -> Workflow[OutputT]:
198+
wf_name = name or getattr(fn, "__name__", "workflow")
194199
wf = Workflow(wf_name, fn, args_schema=args_schema, description=description)
195200
if register:
196201
workflow_registry.register(wf_name, wf)

0 commit comments

Comments
 (0)