Skip to content

TaskContext

TaskContext is the execution boundary between a workflow definition and the code inside one task. It is deliberately small: a task receives its runtime inputs and its configuration, and nothing else. A task cannot climb up from its context to the Run, the workspace, or injected services — that is the pure task context contract, and it is what makes a task's cache identity (code + config + inputs) complete.

The Core View of a Task

Every task sees the same frozen shape:

class TaskContext[StateT, InputT]:
    inputs: InputT            # runtime data flowing in along the graph's edges
    config: Mapping           # build-time / profile configuration (read-only)
    workdir: Path | None      # content-addressed scratch dir for THIS task

ctx.inputs is the value produced by the immediate upstream task, or a keyed mapping when fan-in joins more than one upstream parent. For a root task of a tracked run, the engine injects {"params": <run params>, "workdir": <Path>} — the run's sweep parameters and the working directory arrive as inputs, not through an ambient handle. Loop-back and branch-routed values are also delivered on the edges: when a task returns (value, Next(label)), the routed target receives value as its ctx.inputs (a declared depends_on interface always wins), so loop accumulation reads the previous iteration's value from ctx.inputs.

ctx.config is the active configuration mapping: the resolved ProfileConfig when the workflow runs under a tracked run, or the config= kwarg of WorkflowRuntime.execute(...) otherwise. It always behaves like a read-only mapping.

ctx.workdir is a content-addressed scratch directory derived from the task's content identity — the sanctioned place a task writes intermediate files. It is a bare pathlib.Path, stable across runs for identical task content, and None when no workspace run is attached. A fan-out body shares one workdir across elements, so per-element bodies should sub-namespace it.

Deprecated: ctx.state

ctx.state is deprecated and scheduled for removal: accessing it emits a DeprecationWarning and returns a read-only snapshot (engine state cannot be mutated through it). Everything it was used for — reading the previous loop iteration's value, picking up a branch-routed value — now arrives via ctx.inputs. Migrate any remaining reads to the inputs channel.

There is no ctx.run_context and no ctx.deps. Capabilities that used to be reached through them — artifact persistence, asset lookup, run metadata — live on the driver-side RunContext (see below) or are delivered as inputs by the engine.

Reading Configuration

The common pattern is to read optional settings with .get() and supply workflow-level defaults in task code:

class Train(Task):
    async def execute(self, ctx: TaskContext) -> dict:
        lr = ctx.config.get("lr", 1e-3)
        batch = ctx.config.get("batch", 32)
        return {"lr": lr, "batch": batch}

That design keeps profile semantics in user code. MolExp resolves and preserves the selected profile, but it does not attach special meaning to arbitrary keys.

Working Under a Run

When execution happens under a persistent run, the workspace helpers live on the RunContext the driver opened via run.start() — outside the task bodies:

from molexp.workflow import WorkflowRuntime

with run.start(profile_config=cfg) as ctx:
    result = await WorkflowRuntime().execute(compiled, run_context=ctx)
    ctx.set_result("final_loss", result.outputs["train"])
    ctx.artifact.save("metrics.json", result.outputs["train"])
    ctx.log("train").append("done")

print(run.get_result("final_loss"))   # public read-back on the Run entity

ctx.set_result(...) stores lightweight values on the run record, ctx.artifact.save(...) registers an ArtifactAsset, ctx.log(name) appends to a LogAsset, ctx.checkpoint(...) chains CheckpointAssets, and ctx.find_asset(...) walks run → experiment → project → workspace. Assets written this way carry a Producer record automatically; while a task body is executing, the engine tags the active task id so queries like catalog.query_assets(producer_task="train") work. See the Unified Asset Model guide for the complete picture of scopes, catalog, and per-kind subclasses.

Inside the task, the run shows up only as data: root tasks get the sweep params and workdir injected into ctx.inputs, and every task gets the resolved profile as ctx.config. The same task code therefore runs unchanged in pure in-memory execution — there is simply no workdir and whatever config= the caller passed.

Streaming tasks (Actor)

Streaming Actor bodies receive the same TaskContext as batch tasks — there is no separate context type. An actor's run() is an async generator; the engine drives it to exhaustion and records the last yielded value as the task's output:

class Monitor(Actor):
    async def run(self, ctx: TaskContext):
        for item in ctx.inputs:
            yield transform(item)

There is no inter-task message-passing channel: an earlier receive() / send() surface was never wired (every path raised NotImplementedError) and has been removed. An actor consumes ctx.inputs and yields outputs; it does not exchange messages mid-run with peer tasks.

Typing and Ergonomics

For quick prototypes, plain TaskContext is usually enough. When you want static typing to line up across a larger workflow, parameterize the upstream and downstream task contexts explicitly (TaskContext[StateT, InputT]). Task subclasses can carry the same type information through their generic parameters (Task[StateT, InputT, OutputT]), which is often the cleaner style once workflows become reusable modules rather than one-file experiments.

If you need the broader runtime lifecycle around this context object, the next page to read is Workflow Runtime.

Runnable Example

examples/workflow/task_context.py exercises ctx.inputs, ctx.config, and ctx.workdir inside one tracked run, with the workspace helpers on the driver-side RunContext.