Workflow#
Workflow is OpenRath’s composition layer. Normal transforming workflows express runtime logic as Session -> Session; Selector is the explicit routing exception that returns the next workflow choice.
This page explains the structure from single-agent to multi-agent workflows, module-tree registration, Provider placement, Selector-driven routing, static compilation, and how session, sandbox, and tool traces move through the call chain.
The diagram below shows the intended mental model: a workflow is a callable
module over Session, and its internal agents can fork, compose, and compress
state explicitly.
Workflow.forward(session) -> Session keeps orchestration in ordinary Python
while preserving session graph and sandbox traceability.#
Overview#
OpenRath workflows follow a pattern close to PyTorch modules:
PyTorch intuition |
OpenRath equivalent |
|---|---|
|
|
Child modules are attached as attributes |
|
Tensors move between modules |
|
Module tree can be printed and inspected |
|
Workflow has a small job: collect directly attached AgentParam values, provide the forward(...) convention, and make instances callable through workflow(session). Execution order, branching, compression, tool injection, and child workflow calls are written explicitly in normal Python code.
Source map#
File |
Responsibility |
|---|---|
|
|
|
Static manifest, preflight validation, and memory-store lifecycle. |
|
|
|
|
|
|
|
|
|
|
|
Runs the LLM loop, tool calls, sandbox transfer, and lineage writeback. |
|
One-shot selection request and index parsing. |
|
Smallest preset |
|
Preset |
|
Dynamic |
Minimal Workflow#
Inherit from Workflow and implement forward(self, session) -> Session:
from rath.flow import Workflow
from rath.session import Session
class IdentityWorkflow(Workflow):
def forward(self, session: Session) -> Session:
return session
Workflow.__call__(session) directly calls forward(session). The base forward(...) raises NotImplementedError, so subclasses must define their own runtime logic.
AgentParam Auto-Registration#
When an AgentParam is assigned as a workflow attribute, Workflow.__setattr__ records it in _agents:
from rath.flow import AgentParam, Provider, Workflow
from rath.session import Session
class PlanningWorkflow(Workflow):
def __init__(self):
super().__init__()
self.planner = AgentParam(
Session.from_agent_prompt("Plan the work."),
Provider(api_key="sk-...", model="gpt-5.5"),
)
That assignment has two effects:
Result |
Behavior |
|---|---|
Python attribute |
Usable through |
workflow registry |
Visible through |
named_agents() returns a tuple sorted by attribute name. When an attribute is deleted, Workflow.__delattr__ removes the matching registered item from _agents.
Module Tree And Provider Placement#
v1.3.0 also registers nested Workflow attributes in _children:
class Team(Workflow):
def __init__(self, provider: Provider) -> None:
super().__init__()
self.research = ResearchWorkflow(provider)
self.writer = AgentParam(
Session.from_agent_prompt("Write the result."),
provider,
)
API |
Scope |
|---|---|
|
Direct |
|
Direct nested |
|
This workflow followed by all descendants in depth-first pre-order. |
|
Direct agents on this node only; it does not recurse. |
Provider placement is chainable:
agent_param.to(Provider(model="gpt-5.5", api_key="..."))
agent_param.to(provider="main") # reads config now
agent_param.to(model="new-model") # overlays the current Provider
workflow.to(Provider(model="gpt-5.5", api_key="..."))
# Rebind a nested tree explicitly.
for module in workflow.modules():
module.to(Provider(model="gpt-5.5", api_key="..."))
provider="name" calls Provider.from_config(...) during .to(...); it is
not deferred until the next model request. Provider itself has no .to()
method.
Single-Agent To Multi-Agent#
The smallest runnable path can use the preset flow.Agent directly:
from rath import flow
from rath.llm import Provider
agent = flow.Agent(
system_prompt="Answer clearly.",
provider=Provider(api_key="sk-...", model="gpt-5.5"),
)
out = agent(user_session)
For multiple roles, define each role as an AgentParam and call run_session_loop(...) step by step in forward(...):
from rath.flow import AgentParam, Provider, Workflow
from rath.session import Session, run_session_loop
class ReviewWorkflow(Workflow):
def __init__(self, provider: Provider):
super().__init__()
self.writer = AgentParam(
Session.from_agent_prompt("Write a first draft."),
provider,
)
self.reviewer = AgentParam(
Session.from_agent_prompt("Review the draft and tighten it."),
provider,
)
def forward(self, session: Session) -> Session:
draft = run_session_loop(
session,
self.writer.agent_session,
agent_provider=self.writer.provider,
)
return run_session_loop(
draft,
self.reviewer.agent_session,
agent_provider=self.reviewer.provider,
)
The first loop output becomes the second loop input. The session graph records the parents for each loop, and the sandbox handle moves from input session to output session.
Session Is The Composition Unit#
Workflow instances communicate through Session. That keeps several composition patterns consistent:
Pattern |
Code shape |
Use case |
|---|---|---|
Sequential call |
|
Roles work in a fixed order. |
Branching exploration |
|
Derive multiple candidate paths from the same context. |
Session-level parallelism |
|
Send multiple forked sessions to different agents at the same time. |
Detach from history |
|
Reuse content while cutting lineage. |
Compress context |
|
Shorten history before the next stage. |
Nested workflow |
|
Split complex flows into smaller modules. |
All of these operations still revolve around the session graph. fork(), detach(), run_session_loop(...), and run_session_compress(...) write lineage to output sessions; tool results remain as chunks in the session table; sandbox lifecycle is owned and transferred by the session.
Session-Level Parallelism#
OpenRath multi-agent parallelism is based on session branches, not a special scheduling DSL. After an upstream agent produces a session, use fork() to derive branches, then use normal Python concurrency tools to send those branches to different agents.
from concurrent.futures import ThreadPoolExecutor
from rath.session import Session
def forward(self, session: Session) -> Session:
analysed = run_session_loop(
session,
self.analyst.agent_session,
agent_provider=self.analyst.provider,
tools=[market_tool],
)
bear_input = analysed.fork()
bull_input = analysed.fork()
with ThreadPoolExecutor(max_workers=2) as pool:
bear_future = pool.submit(
run_session_loop,
bear_input,
self.researcher_bear.agent_session,
agent_provider=self.researcher_bear.provider,
tools=None,
)
bull_future = pool.submit(
run_session_loop,
bull_input,
self.researcher_bull.agent_session,
agent_provider=self.researcher_bull.provider,
tools=None,
)
bear_session = bear_future.result()
bull_session = bull_future.result()
def last_assistant_text(s: Session) -> str:
return s.text() or ""
trader_input = Session.from_user_message(
"Combine the two research branches.\n\n"
f"Bear branch:\n{last_assistant_text(bear_session)}\n\n"
f"Bull branch:\n{last_assistant_text(bull_session)}"
).to("local")
return run_session_loop(
trader_input,
self.trader.agent_session,
agent_provider=self.trader.provider,
tools=None,
)
This pattern has three boundaries:
Boundary |
Notes |
|---|---|
lineage |
Both forked sessions keep the same parent, and later loop outputs record their own agent parent. |
sandbox |
If the source has an open sandbox, |
aggregation |
|
OpenRath’s parallel unit is therefore the session. Tool stream concurrency belongs to the backend layer, and Provider.parallel_tool_calls belongs to LLM tool-call parameters; both are separate from session-level parallelism.
If branches write to the workspace, assign different directories explicitly. When the source has an open sandbox, forked branches share the same handle; when it only has spec=".", both branches still target the same host directory on lazy open. A safer pattern is to reset a branch-specific workspace after fork:
auth_input = session.fork().to("local", spec=".workspace/auth-branch")
data_input = session.fork().to("local", spec=".workspace/data-branch")
OpenSandbox follows the same rule: retarget a branch before tool execution if it needs an independent container or host bind path.
Preset Workflows#
OpenRath v1.3.0 provides four preset subclasses:
Class |
Wraps |
Best for |
|---|---|---|
|
One |
Single-agent calls and quick tool integration. |
|
One |
Compressing a long session into a new user-side session. |
|
One routing |
Choosing the next self-describing workflow. |
|
Identity |
Representing no match or a completed routing loop without returning |
Agent.register_tool(...) deduplicates by tool name. Compressor asks the model to produce a new user message; the compressed result keeps session lineage and continues to hold the input session’s sandbox configuration and handle.
Dynamic Workflow Routing#
Workflow.description is routing metadata. Selector.forward(session, *workflows) builds a numbered menu from those descriptions, makes one model
request with tools disabled, and returns a candidate workflow or an
EmptyWorkflow:
selector = flow.Selector(provider)
triage = flow.Agent(
"Triage the request.",
provider,
description="Classify a new support request",
)
resolve = flow.Agent(
"Resolve the technical problem.",
provider,
description="Installation, configuration, or runtime errors",
)
chosen = selector.forward(session, triage, resolve)
if not isinstance(chosen, flow.EmptyWorkflow):
session = chosen(session)
Selection is a decision boundary rather than a session transform:
Property |
v1.2.1 behavior |
|---|---|
return type |
A |
completion |
|
request |
One completion, |
parsing |
First integer token; missing, negative, or out-of-range becomes no match. |
lineage |
|
streaming |
The |
Keep loops bounded because the model may repeatedly select a candidate. Candidate descriptions should be short, mutually distinguishable, and describe when the workflow applies rather than restating its system prompt.
Static Compilation#
Workflow.compile() walks the registered module tree without running a model
or materializing a session:
compiled = workflow.compile()
manifest = compiled.manifest
models = manifest.provider_models()
kinds = manifest.provider_kinds()
problems = compiled.validate()
Each AgentResource records path, a Provider snapshot, has_memory, and
agent_session_id. Each DynamicNode records path, kind, and reason.
A Selector is dynamic because compile cannot predict its runtime target; its
own router AgentParam remains a static resource.
CompiledWorkflow.validate() is intentionally narrow. It checks registered
provider kinds and whether a credential resolves through the applicable
offline resolver. It does not check model existence, network access, sandbox
configuration, tools, memory health, or Selector candidates. LiteLLM is treated
as satisfiable because it may resolve vendor-specific credentials internally.
The manifest is a compile-time snapshot. The wrapper still calls the current underlying workflow, so change the module tree or Provider placement only after planning to recompile.
As a context manager, CompiledWorkflow acquires every distinct reachable
memory store once and releases them in reverse order. Providers have no
lifecycle, and sandbox handles still open lazily from sessions.
Nested Workflow#
A nested workflow is still ordinary Python composition:
class EngineeringProjectWorkflow(Workflow):
def __init__(self, provider: Provider) -> None:
super().__init__()
self.lead = AgentParam(Session.from_agent_prompt(LEAD_ENGINEER_SYSTEM), provider)
self._squad = FeatureSquadWorkflow(provider)
self._qa = QualityAssuranceWorkflow(provider)
def forward(self, session: Session) -> Session:
s = run_session_loop(
session,
self.lead.agent_session,
agent_provider=self.lead.provider,
tools=None,
)
s = self._squad.forward(s)
return self._qa.forward(s)
In v1.3.0, self._squad and self._qa register as child workflows. Runtime order still comes only from explicit forward(...) calls; registration makes the tree inspectable and compilable, not automatically executable.
Sequential Multi-Agent Pattern#
A fixed-order multi-role flow usually looks like:
analyst
researcher_bear
researcher_bull
trader
risk_pm
The first stage may inject a domain tool; later stages read the tool result and assistant content already stored in the session. External tools can be given to a single role, and their results pass to later roles through the session.
Public examples require users to set their own API keys explicitly so a default key is not mistaken for a product capability.
Hierarchical Composition Pattern#
Hierarchical composition keeps parent and child workflows explicit:
Level |
Workflow |
Execution |
|---|---|---|
L1 |
|
lead plan -> feature squad -> QA. |
L2 |
|
architect -> backend pair -> frontend. |
L3 |
|
backend auth -> backend data. |
QA |
|
Tests and risk checks based on the full session. |
The example shows how to organize complex engineering work: each workflow owns its local sequence, the parent workflow chains child workflows, and all stages share the same session-passing chain.
Tool And Sandbox Boundaries#
Inside a workflow, tools and sandbox still take effect through run_session_loop(...):
Item |
Where it happens |
|---|---|
Tool list merge |
At the start of each |
Tool call record |
Written to the output session as a |
sandbox handle |
Shared from the input session via |
sandbox backend spec |
Stored on the output session. |
lineage |
Output session records both the user session and agent session as parents. |
A workflow can therefore give different roles different tools; the same sandbox can move through multiple roles with the session; later agents can see results produced by earlier tools.
Call Path#
workflow(session)
Workflow.__call__
subclass.forward(session)
run_session_loop or child workflow
returned Session carries new chunks, sandbox, lineage
When using the preset Agent:
flow.Agent.forward(session)
run_session_loop(
user_session=session,
agent_session=self.agent.agent_session,
agent_provider=self.agent.provider,
tools=self.tools,
)
When using the preset Compressor:
flow.Compressor.forward(session)
run_session_compress(
user_session=session,
agent_session=self.agent.agent_session,
agent_provider=self.agent.provider,
)
When using the preset Selector:
flow.Selector.forward(session, *workflows)
select_session(session, routing_prompt, *descriptions)
parse one zero-based index
return workflows[index] or EmptyWorkflow()
Current Boundaries#
Behavior |
Current implementation |
|---|---|
attribute registration |
Only attributes assigned to |
child registration |
Attributes assigned to |
deletion |
|
ordering |
|
base execution |
|
nested placement |
|
compile boundary |
The manifest is a static resource snapshot, not a predicted branch/loop/fork execution plan. |
selector contract |
|
async support |
|
scheduling policy |
Ordering, branching, retries, and concurrency are expressed by the user in Python code. |
Code Reading Checkpoints#
In
workflow.py, check_agents,_children, and__setattr__.In
workflow.py, check the sorting rule innamed_agents().In
agent.py, check howAgent.forward(...)callsrun_session_loop(...).In
compressor.py, check how the compression workflow callsrun_session_compress(...).In
example/01_hello_agent.py, check the presetflow.Agentpath.In
example/08_compress.py, check the preset compression path.In
tests/flow/test_workflow_agent.py, check workflow registration and sandbox transfer tests.In
selector.pyandsession/select.py, check no-match parsing and the no-lineage selection boundary.In
compile.py, check static traversal, validation scope, and memory acquisition order.
Test Coverage#
Behavior |
Tests |
|---|---|
workflow registration and agent call |
|
selector routing and empty fallback |
|
module tree, manifest, validation, lifecycle |
|
import contract |
|
session compressor live behavior |
|