Agentic Workflows

Stabilize ships a small set of opt-in building blocks for LLM-agent systems. They are additive — existing workflows are unaffected — and layer on top of the engine’s durable, event-sourced execution so agent runs inherit crash recovery, replay, and the full control-flow pattern set.

Four capabilities are covered here:

  • Streaming — consume a workflow’s progress (and LLM tokens) live.

  • Human-in-the-loop (HITL) — pause for an approval and resume with a value.

  • Fan-in reducers — combine parallel branches instead of clobbering them.

  • LLM toolkit — a chat client, tool-calling, and a ReAct agent loop.

A complete, runnable program that uses all four (a multi-agent “software team” that designs, writes, tests, reviews, and packages a small library, with a chaos-recovery mode) lives in examples/agent_team/.

Streaming Progress

Tasks can emit custom progress events, and a caller can consume them live via WorkflowStream. This is built on the event bus, so it works whether or not durable event sourcing is configured.

from stabilize import WorkflowStream, emit_progress

# Inside a task's execute():
def execute(self, stage):
    emit_progress(stage, "generating candidate", percent=10)
    ...
    emit_progress(stage, "done", percent=100)
    return TaskResult.success(outputs={...})

# In the caller — callback form (non-blocking):
stream = WorkflowStream(execution_id)
stream.on_event(lambda item: print(item.data.get("message", "")))
# ... run the workflow ...
stream.close()

# Or iterate live until the workflow ends:
stream = WorkflowStream(execution_id, event_store=event_store)
for item in stream.follow(include_history=True, timeout=30):
    print(item.source, item.event_type, item.data)

Each item is a StreamItem with event_type, entity_id, workflow_id, data, sequence, and source ("replay" or "live"). Progress events carry event_type == "custom.progress"; lifecycle events (stage.completed, workflow.completed, …) flow through the same stream.

Human-in-the-Loop Approvals

The ApprovalTask suspends a stage until an approval signal arrives, then routes on it. It wraps the engine’s durable TaskResult.suspend() plus persistent signal buffering, so an approval sent before the stage suspends is not lost, and the whole gate survives a crash.

from stabilize import ApprovalTask, approve, reject

registry.register("approval", ApprovalTask)

gate = StageExecution(
    ref_id="gate",
    type="approval",
    requisite_stage_ref_ids={"prepare"},
    tasks=[TaskExecution.create(
        name="wait", implementing_class="approval",
        stage_start=True, stage_end=True,
    )],
)

# ... the stage runs, suspends, and waits ...

# A human (or another system) approves — the payload lands in the stage's
# outputs under "approval":
approve(queue, execution_id, gate.id, {"user": "alice"})
# or: reject(queue, execution_id, gate.id, {"reason": "needs work"})

By default a rejection is terminal; set context["approval_reject_continues"] = True to continue the pipeline (FAILED_CONTINUE) instead. For custom suspend/resume tasks, use send_signal() to deliver an arbitrary signal and get_signal() inside execute to read which signal woke the stage.

Declarative Fan-in Reducers

When several parallel branches write the same output key, the default ancestor-output merge is last-write-wins for scalar values (list values already concatenate). Set output_reducers on a join stage to combine the per-branch values declaratively instead of losing all but one.

from stabilize import StageExecution, JoinType

gather = StageExecution(
    ref_id="gather",
    join_type=JoinType.AND,
    requisite_stage_ref_ids={"gen_1", "gen_2", "gen_3"},
    output_reducers={"candidate": "collect", "score": "sum"},
    tasks=[...],
)

Each branch that produced candidate contributes to a list under that key; score values are summed. Built-in reducers: collect / append, extend, sum, max, min, merge, first, last. Register your own with register_reducer(). Keys without a reducer keep the existing last-write-wins behavior, so this is fully backward compatible.

LLM Toolkit

stabilize.llm is a dependency-light (standard-library only) toolkit for LLM agents. It is not imported by the core engine, so it adds no cost to workflows that do not use it. The model client is injectable, targeting OpenAI-compatible endpoints or Ollama (local or cloud).

from stabilize.llm import LLMClient, LLMTask, AgentLoopTask, tool, ToolRegistry

# A chat client (credentials from OLLAMA_API_KEY / OPENAI_API_KEY by default)
client = LLMClient(model="glm-5.2", base_url="https://ollama.com", api="ollama")

# A one-shot completion as a workflow task:
registry.register("llm", LLMTask(client=client))

# A tool-calling ReAct agent as a single durable task:
@tool
def get_weather(city: str) -> str:
    "Return the weather for a city."
    return lookup(city)

tools = ToolRegistry().add(get_weather)
registry.register("agent", AgentLoopTask(client=client, tools=tools))

AgentLoopTask runs a bounded model → tool-calls → results → model loop inside one task execution (capped by max_iterations), so the whole agent turn is one durable, retryable unit of work. The @tool decorator generates an OpenAI-style JSON schema from the function signature and docstring; ToolRegistry dispatches the model’s tool calls back to your functions.

Note

Provide API keys via environment variables (OLLAMA_API_KEY / OPENAI_API_KEY) — never hard-code them. The example in examples/agent_team/ reads OLLAMA_API_KEY and falls back to offline stubs when it is unset, so it always runs end to end.

See Also

  • Event Sourcing — the durable log the streaming surface consumes.

  • Flow Control — joins, suspend/resume signals, and dynamic routing that these features build on.