Dynamic Workflows¶
Use these capability snippets inside your application’s native runtime. Installation is through PyFly Agentic extras.
Copyright 2026 Firefly Software Foundation. Licensed under the Apache License 2.0.
pyfly.agentic.workflows is a code-defined orchestration DSL for
fanning out isolated sub-agents and reducing their results in plain Python. The
plan lives in ordinary Python control flow, making orchestration inspectable.
A journal can replay completed agent calls when the call sequence remains stable.
Model outputs and external side effects retain their own execution semantics.
It is a peer of, not a replacement for, two existing layers:
- Reasoning patterns drive a single agent's reason → act → observe loop. A workflow provides code-defined orchestration across many agents.
- Pipelines are declarative DAGs with checkpointing and HITL. A workflow is an imperative code-first façade for fan-out/gather/verify patterns that read more naturally as a function than as a graph.
Quick start¶
Configure PYFLY_AGENTIC_DEFAULT_MODEL and provider credentials using the
model guide. This example summarizes supplied source passages, reviews
claims against those passages, and synthesizes a report. External retrieval is a
separate tool integration; the example only uses the text supplied by the caller.
from pydantic import BaseModel, Field
from pyfly.agentic import get_config
from pyfly.agentic.workflows import (
workflow, agent, parallel, pipeline, phase, run_workflow, WorkflowBudget,
)
class ResearchArgs(BaseModel):
passages: list[str]
model: str = Field(default_factory=lambda: get_config().default_model)
@workflow(name="deep_research", args_schema=ResearchArgs)
async def deep_research(args, ctx):
with phase("extract"):
hits = await parallel([
(lambda passage=passage: agent(f"Extract the key claims from this source: {passage}", model=args.model))
for passage in args.passages
])
candidates = [
{"source": source, "claims": claims, "model": args.model}
for source, claims in zip(args.passages, hits, strict=True) if claims is not None
]
with phase("verify"):
verified = await pipeline(candidates, verify_stage, score_stage)
with phase("synthesize"):
notes = "\n".join(str(note) for note in verified if note is not None)
if not notes:
raise ValueError("No source passage completed review")
return await agent(f"Write a concise report using only these reviewed notes:\n{notes}", model=args.model)
async def verify_stage(prev, item, index):
return await agent(
f"Keep only claims supported by source {index + 1}. Include the source number.\n"
f"Source: {item['source']}\nClaims: {prev['claims']}",
model=item["model"],
)
async def score_stage(prev, item, index):
return await agent(
f"Rank these source-grounded claims by relevance to reliability engineering. "
f"Preserve their source numbers and return the ranked notes:\n{prev}",
model=item["model"],
)
args = ResearchArgs(passages=[
"Bounded retries stop after three attempts. Backoff increases the delay between attempts.",
"A circuit breaker stops requests after repeated failures and probes for recovery later.",
])
report = await run_workflow(
"deep_research", args,
budget=WorkflowBudget(max_concurrent_agents=8, max_tokens=50_000),
)
A @workflow-decorated function can be invoked directly (await deep_research(args))
or by name through run_workflow("deep_research", args).
Primitives¶
| Primitive | Signature | Semantics |
|---|---|---|
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. |
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. |
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. |
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. |
phase |
with phase("title"): |
Group enclosed work for telemetry (phase.start / phase.end events). |
log |
log("message") |
Emit a narrator line to the run's event handler. |
Reduce in Python, not with a model. The strength of the DSL is that the
glue between agent calls — dedup, rank, filter, branch — is ordinary
deterministic Python. Reach for an agent() only when you genuinely need a
model.
Type safety¶
The DSL is statically typed end-to-end. agent(output_type=T) is typed to
return T (not Any), and @workflow produces a Workflow[OutputT] inferred
from the function's return annotation — so the awaited result is typed too:
from pydantic import BaseModel
from pyfly.agentic.workflows import workflow, agent
class Report(BaseModel):
summary: str
sources: list[str]
@workflow(name="research")
async def research(args, ctx) -> Report:
return await agent("write the report", output_type=Report) # typed Report
report: Report = await research(args) # research is Workflow[Report]; result is Report
Without output_type, agent() returns Any (the raw string output). Pyright
infers all of the above with no casts.
Budgets¶
Every run is bounded by a WorkflowBudget:
WorkflowBudget(
max_concurrent_agents=8, # asyncio.Semaphore; default min(16, cpu - 2)
max_agents_total=1000, # hard kill-switch (runaway-loop backstop)
max_tokens=500_000, # optional output-token ceiling for the whole run
max_cost_usd=2.50, # optional USD ceiling (priced via genai-prices)
max_wall_seconds=120, # optional wall-clock ceiling for the whole run
)
Exceeding any ceiling raises WorkflowBudgetError — including inside a
parallel/pipeline fan-out (a kill-switch aborts the run; only ordinary
branch failures resolve to None). Token and dollar cost are fed by each
AgentRunner call's reported usage, priced through the same genai-prices cost
stack the rest of the framework uses; read live spend with
ctx.tokens_spent / ctx.cost_spent_usd and the remaining headroom with
ctx.remaining_tokens() / ctx.remaining_cost_usd().
Resume with a call journal¶
Every agent() call is keyed by its sequence number and recorded in a Journal.
Re-running the workflow with a populated journal serves completed calls from
cache and runs only new calls live:
from pyfly.agentic.workflows import Journal
journal = Journal()
await run_workflow("deep_research", args, journal=journal) # full run, populates journal
# Run again with the same arguments and call sequence:
await run_workflow("deep_research", args, journal=journal) # cached calls replay instantly
Persist journal.to_dict() (and rebuild with Journal.from_dict(...)) to resume
manually. To persist completed calls, attach a JournalBackend; the journal
attempts a flush after every completed call. Backend errors are logged and
suppressed, so durability depends on successful storage:
from pyfly.agentic.workflows import Journal, FileJournalBackend
backend = FileJournalBackend("/var/lib/firefly/workflows")
journal = Journal(backend=backend, run_key="research-job-42") # loads if present
await run_workflow("deep_research", args, journal=journal) # auto-flushes each call
# ... process crashes; in a fresh process, the same run_key reloads progress:
await run_workflow("deep_research", args,
journal=Journal(backend=backend, run_key="research-job-42"))
FileJournalBackend stores each run as JSON (strings/numbers/lists/dicts
round-trip exactly; structured outputs round-trip as their serialized form).
Implement the JournalBackend protocol (load/save) for Postgres/Redis/S3.
Replay contract. Journal keys are call sequence numbers, not hashes of the prompt, model, or arguments. Resume with the same inputs and call sequence; code changes or timing-dependent branches can assign a cached output to a different call. Sequence numbers are assigned before the first
awaitinsideagent(), but calls launched after earlier asynchronous work can still depend on completion order. Keep replay-sensitive control flow stable and use a new journal when the inputs or sequence change. External side effects outside journaled calls may run again, and a crash before a successful journal write may repeat a model call.
The agent runner¶
agent() delegates LLM execution to a pluggable AgentRunner:
class AgentRunner(Protocol):
async def run(self, prompt, *, model=None, output_type=None, instructions=None,
deps=None, tools=None, toolsets=None, using=None) -> AgentCall: ...
ManagedAgentRunner(default) runs each call through aAgent, so sub-agents inherit the full framework stack — see below.DefaultAgentRunner(lightweight alternative) runs each call as a fresh barepydantic_ai.Agentwith no shared message history — isolation by construction and zero coupling. Choose it explicitly (runner=DefaultAgentRunner()) when you want the minimal path with none of the Agent middleware.- Custom runners make workflows trivially testable: inject a deterministic
fake that returns canned
AgentCall(output=..., tokens=...)and no network is touched.
ManagedAgentRunner — sub-agents with the full Agent stack¶
This is the default runner: every workflow sub-agent runs through a
Agent. Ephemeral agents get default logging and conditional observability;
configured agents retain their middleware, tools, and model. Guards, caching,
explainability, and validation remain opt-in. Rate-limit retry and usage tracking
follow the same configuration as top-level agents. Construct the runner explicitly
to select a default model or a specific agent source:
from pyfly.agentic import get_config
from pyfly.agentic.workflows import workflow, agent, ManagedAgentRunner
# The default — no runner needed; sub-agents are Agent instances already:
await my_workflow(args)
# Give every ephemeral sub-agent a default model (built auto_register=False, memory=None):
await my_workflow(args, runner=ManagedAgentRunner(default_model=get_config().default_model))
# Or reuse one configured agent for every call (keeps its model/tools/middleware):
await my_workflow(args, runner=ManagedAgentRunner(my_configured_agent))
The agent= source decides how each call's agent is obtained: None → a fresh
ephemeral agent per call (isolated, mirrors DefaultAgentRunner); a
Agent instance → reuse it; a registry name (str) → resolve it
per call; a zero-arg factory → build a fresh agent per call.
Per-call targeting with using= — a single workflow can route different
calls to different configured agents (multi-model sub-agents and per-task cost
optimisation):
@workflow(name="triage")
async def triage(args, ctx):
# cheap, fast model for the easy classification...
label = await agent("classify the ticket", using="cheap-classifier")
# ...escalate the hard synthesis to a configured premium agent
return await agent("write the resolution", using="premium-writer")
await triage(args, runner=ManagedAgentRunner()) # both agents come from the registry
using accepts a Agent instance or a registry name and overrides the
runner's source for that one call. DefaultAgentRunner rejects using= with a
clear error (it has no agent registry to resolve against); SmartRoutingRunner
forwards it to its base_runner, so using= works when the base is a
ManagedAgentRunner.
Notes:
- Separate usage ledgers. Per-call tokens/cost feed the per-run
WorkflowBudget;Agentalso records completed calls in the active runtime tracker when cost tracking is enabled. A journal replay makes no new model request. - Isolation. The ephemeral path sets
auto_register=Falseandmemory=None. A factory controls those settings itself; return a fresh agent with those options for the same isolation. The runner supplies noconversation_id. A reused agent may still share state through its cache, tools, middleware, or working memory. tools=vstoolsets=. Per-calltools=only applies on the ephemeral path (pydantic-ai has no per-calltoolsparameter); for a reused agent, configure tools on the agent or passtoolsets=.- Composes with everything:
SmartRoutingRunner(base_runner=ManagedAgentRunner(...)),cascade(), budgets and the journal all work unchanged.
Streaming¶
Stream a single sub-agent's output token-by-token with the stream() context
manager (requires a streaming-capable runner — DefaultAgentRunner and
ManagedAgentRunner both are). It
honours the budget, concurrency gate, journal and cost accounting exactly like
agent():
from pyfly.agentic.workflows import workflow, stream
@workflow(name="live_report")
async def live_report(args, ctx):
async with stream("write a cited report", model=args.model) as s:
async for delta in s.text():
print(delta, end="", flush=True) # surface tokens as they arrive
return s.output # the full text, available after the block
On resume, a cached stream() call yields its full output once (the model is not
re-invoked). A runner that doesn't support streaming raises WorkflowError.
Structured output. stream() accepts output_type= like agent(). Token
deltas are inherently a text concept, so for a structured (non-text) run
s.text() transparently falls back to yielding stringified partial-object
snapshots (whole objects, not token deltas) instead of crashing; s.output
still holds the validated model after the block.
Model routing & cost optimization¶
The cheapest model that can do the job should do the job. Two complementary tools
give a workflow automatic per-task model selection — the workflow body is
unchanged; only which model runs each agent() call changes.
SmartRoutingRunner — cheapest capable model per call (a-priori)¶
A drop-in AgentRunner: give it model tiers ordered cheap → expensive and a
selection strategy. Each agent() call with no explicit model= is routed;
an explicit model= always wins. A transient error escalates up the ladder.
from pyfly.agentic.workflows import (
SmartRoutingRunner, ComplexityHeuristicStrategy, CostFloorStrategy,
)
runner = SmartRoutingRunner(
tiers=["anthropic:claude-haiku-4-5", "anthropic:claude-sonnet-4-5"],
strategy=ComplexityHeuristicStrategy(), # training-free: routes by prompt length + cues
fallback=True, # escalate to the next tier on a transient error
)
report = await run_workflow("deep_research", args, runner=runner)
ComplexityHeuristicStrategy(default) — no extra LLM call; short/simple prompts route to the cheapest tier, long or reasoning-heavy prompts escalate.CostFloorStrategy— always start at the genai-prices cheapest tier (priced from the actual prompt length), leaving escalation tofallback.- Custom — implement
ModelSelectionStrategy.select(prompt, tiers) -> int(e.g. an embedding router or a cheap classifier model).
Every decision emits a structured event (route.select, route.escalate), so a
cheap-model quality regression is observable, not silent.
cascade() — escalate only on low confidence (a-posteriori)¶
The FrugalGPT trade-off: run the cheapest tier, score its answer, and escalate
only when confidence is below threshold. Typically the strongest cost lever.
from pyfly.agentic.workflows import cascade
result = await cascade(
"Summarise this contract clause: ...",
tiers=["anthropic:claude-haiku-4-5", "anthropic:claude-sonnet-4-5"],
threshold=0.7, # accept the first tier scoring >= 0.7
# confidence=my_async_scorer(output)->float, or judge_model=... (defaults to the cheapest tier)
output_type=None, # constrain each tier to a pydantic model (like agent())
instructions=None, # per-call system instructions for every tier
max_escalations=None, # cap how many tiers to climb (default: all of them)
)
result.output, result.tier, result.model, result.confidence, result.escalations
When no confidence callable is given, a judge model rates each answer 0–1
(defaulting to the cheapest tier as judge). Each tier emits a cascade.tier
event. Cascades go through agent(), so they honour the run's budget and journal.
Pricing a model directly¶
from pyfly.agentic.workflows import price_model
usd = price_model("anthropic:claude-haiku-4-5", input_tokens=1000, output_tokens=500)
Verify combinators¶
Built on the primitives, these encode the "fan-out → reduce → decide" patterns that make multi-agent output trustworthy:
from pyfly.agentic import get_config
from pyfly.agentic.workflows import adversarial_verify, judge_panel, loop_until_dry
# Spawn N skeptics prompted to REFUTE; survives only if fewer than a majority refute.
ok = await adversarial_verify("claim: X causes Y", model=get_config().default_model, n=3)
# A panel of DIFFERENT models votes; returns a structured Verdict.
verdict = await judge_panel("X causes Y", judges=["openai-responses:gpt-6-luna", "anthropic:claude-sonnet-5"])
verdict.survived, verdict.support, verdict.votes # bool, fraction, ((model, yes), ...)
# Keep producing until K consecutive rounds surface nothing new (catches the tail).
items = await loop_until_dry(produce_batch, dry_rounds=2, max_rounds=8, key=lambda x: x.id)
A heterogeneous-model panel (judge_panel) is a stronger signal than asking one
model repeatedly, and its Verdict doubles as a confidence oracle for cascade().
Sub-workflows¶
Compose workflows by running one inline as a step of another with subworkflow.
The child inherits the parent's budget, concurrency gate, journal and runner —
its agent() calls count against the same budget and resume from the same journal
(one sequence stream across the whole nested run):
from pyfly.agentic.workflows import workflow, agent, subworkflow
@workflow(name="summarize")
async def summarize(args, ctx):
return await agent(f"summarize: {args}")
@workflow(name="report")
async def report(args, ctx):
bullets = await subworkflow("summarize", args) # by name, or pass the Workflow object
return await agent(f"turn into a report: {bullets}")
To run a workflow in isolation (its own fresh budget), call other_wf.run(...)
directly instead.
Human-in-the-loop¶
human(prompt) pauses a run for external input. It raises WorkflowInterrupt;
you collect the answer, provide() it, and re-run with the same journal to
resume past the pause. The answer is journaled under its sequence number, so the
same replay requirements apply:
from pyfly.agentic.workflows import workflow, agent, human, Journal, WorkflowInterrupt
@workflow(name="triage")
async def triage(args, ctx):
draft = await agent(f"draft a reply to: {args}")
decision = await human(f"Approve this reply?\n\n{draft}")
if decision.lower().startswith("y"):
return draft
return await agent(f"revise per feedback '{decision}': {draft}")
journal = Journal()
try:
result = await triage.run(ticket, journal=journal)
except WorkflowInterrupt as it:
it.provide(input(it.prompt)) # a human reviews and answers
result = await triage.run(ticket, journal=journal) # resumes past human()
Pair human() with a JournalBackend for durable approvals that survive a
process restart.
Telemetry¶
Pass events=callable to a run to receive structured events:
workflow.start / workflow.end (with agents, tokens, cost_usd),
phase.start / phase.end, agent.start / agent.end (with label, phase,
seq, tokens, cost_usd; streamed calls also carry stream: True),
route.select / route.escalate, cascade.tier,
subworkflow.start / subworkflow.end, human.pause, and log. Wire this into
the observability layer for live per-phase token/cost/agent/time
counters.
API reference¶
| Symbol | Purpose |
|---|---|
workflow(name=None, *, args_schema=None, description="", register=True) |
Decorator → a registered, runnable Workflow. |
Workflow.run(args, *, budget, runner, journal, events, run_id) |
Execute; returns the body's return value. |
run_workflow(name, args, **opts) |
Look up a registered workflow by name and run it. |
subworkflow(name_or_wf, args) |
Run another workflow inline, inheriting the parent's budget/journal/runner. |
agent / parallel / pipeline / phase / log |
The DSL primitives. |
stream(prompt, ...) |
Async context manager → a StreamHandle (.text() deltas, .output); streams one sub-agent. |
StreamHandle / StreamingAgentRunner |
The streaming result handle and the runner protocol a runner implements to support stream. |
human(prompt) |
Pause for external input (raises WorkflowInterrupt; resume via the same journal). |
map_agents(items, fn, *, strict=False) |
Run fn(item) per item concurrently — sugar over parallel (no late-binding lambda). |
WorkflowBudget |
Concurrency / agent-count / token / USD cost / wall-clock ceilings. |
Journal / JournalBackend / FileJournalBackend |
Sequence-keyed resume cache with optional best-effort persistence. |
AgentRunner / AgentCall |
The runner seam (AgentCall carries output, tokens, cost_usd). |
ManagedAgentRunner |
Default runner: each call runs through a Agent with its configured middleware, tools, and model. Pair with agent(..., using=) for multi-model sub-agents. |
DefaultAgentRunner |
Lightweight alternative: each call is a bare pydantic_ai.Agent. Pass it explicitly for the zero-coupling path. |
SmartRoutingRunner / ComplexityHeuristicStrategy / CostFloorStrategy / ModelSelectionStrategy |
Cheapest-capable model per call (with fallback). |
cascade / CascadeResult |
Cheap-first, escalate on low confidence. |
price_model(model, *, input_tokens, output_tokens) |
USD price for a model at a token shape. |
adversarial_verify / judge_panel / Verdict / loop_until_dry |
Quality combinators. |
workflow_registry |
The active runtime’s WorkflowRegistry. |
current_workflow() |
The active WorkflowContext (raises outside a run). |
Exceptions live in pyfly.agentic.exceptions: WorkflowError (base),
WorkflowNotFoundError, WorkflowBudgetError, WorkflowContextError, and
WorkflowInterrupt (the human-in-the-loop pause signal).