How to Implement Task Chaining#
Goal: Create a workflow where one task's output feeds into the next task.
When to use this: Your problem requires multiple steps where each builds on previous results.
Chained tasks are normal tasks: each dependency is declared at the input-field
level on the task itself (input_map), and at runtime the linked environments
share a single run store (task_runs) through their CorralState. The
dependency graph is always derived from the task definitions — no separate
group object holds runtime results.
1. Define dependent tasks#
from corral.backend.task import InputRef, TaskDefinition
# Task 1: Retrieve data
retrieve_task = TaskDefinition(
name="retrieve",
description="Fetch molecule structure from database",
tools=["database_query"],
scoring_fn=lambda x: 1.0 if x else 0.0,
submission_format={"structure": "string"},
initial_input={"molecule_id": "mol_123"},
)
# Task 2: Analyze (uses output from Task 1)
analyze_task = TaskDefinition(
name="analyze",
description="Analyze the molecule structure",
tools=["structure_analyzer"],
scoring_fn=lambda x: 1.0 if "properties" in x else 0.0,
submission_format={"analysis": "dict"},
input_map={
# The task receives an input named "structure", taken from the
# "answer" key of the "retrieve" task's output
"structure": InputRef("retrieve", key="answer"),
},
)
tasks = {"retrieve": retrieve_task, "analyze": analyze_task}
InputRef.key supports dotted paths into structured outputs, e.g.
InputRef("retrieve", key="answer.smiles").
2. Create the linked environments#
A single entry point takes the flat task collection and does everything — validates the graph, derives the grouping (each connected component of the dependency graph shares one run store) and returns server-ready environments:
from corral.backend.env import build_environments
environments = build_environments(
tasks,
base_work_dir="workdir",
name="workflow", # optional label for tracing / docs
available_tools=my_tools, # dict[str, Tool], resolved by each task's `tools`
)
There is no separate authoring path for a single task: it is just the
degenerate case of a one-node component, so build_environments({"solo": task})
works too.
3. Customize behaviour without subclassing#
Custom prompts, per-trial setup and scoring are injected through the
(immutable) definition, not by subclassing the environment. Dependency outputs
are always read through the shared CorralState:
from corral.backend.env import Environment
from corral.backend.task import InputRef, TaskDefinition
def analyze_prompt(env: Environment) -> str:
task = env.current_task
prompt = task.description
for name, ref in task.input_map.items():
if env.state.is_completed(ref.task_id):
prompt += f"\n{name}: {env.state.get_output(ref.task_id, ref.key)}"
return prompt
analyze_task = TaskDefinition(
name="analyze",
description="Analyze the molecule structure",
tools=["structure_analyzer"],
scoring_fn=score_analysis,
submission_format={"analysis": "dict"},
input_map={"structure": InputRef("retrieve", key="answer")},
prompt_fn=analyze_prompt, # custom prompt
# setup_fn=..., # per-trial setup / hidden tool args
resolve_answer=False, # don't path-resolve a non-file answer
)
For genuinely stateful integrations (hardware, pickled inventories) you can
still subclass Environment and pass the subclass to build_environments:
class MyEnvironment(Environment):
def configure_additional_apps(self):
... # set up an instrument before each trial
return "configured"
environments = build_environments(
tasks,
base_work_dir="workdir",
env_cls=MyEnvironment,
)
4. Run the chained workflow#
# The runner executes in dependency order; grouping is automatic.
runner.bench(task_ids=list(environments.keys()), trials_per_task=1)
Done: Tasks now execute sequentially with data passing between them.