Step-Result Cache
Cache a workflow step's output by its inputs â pay the LLM once for a deterministic step
A workflow step-result cache stores a step’s output keyed by its inputs, so a deterministic step reuses its answer instead of paying the full LLM cost on every run.
from praisonaiagents import Agent, AgentFlow
formatter = Agent(name="Formatter", instructions="Format the text as clean JSON.")
summariser = Agent(name="Summariser", instructions="Summarise in one sentence.")
workflow = AgentFlow(steps=[formatter, summariser], cache=True)
workflow.start("Same input each time")
workflow.start("Same input each time") # second call served from cachefrom praisonaiagents import Agent, AgentFlow
from praisonaiagents.workflows.step_cache import FileStepCache
formatter = Agent(name="Formatter", instructions="Format the text as clean JSON.")
summariser = Agent(name="Summariser", instructions="Summarise in one sentence.")
workflow = AgentFlow(steps=[formatter, summariser], cache=FileStepCache("run.json"))
workflow.start("Same input each time") # interrupted mid-flow
workflow.start("Same input each time") # new process: continues from where it stoppedQuick Start
- Enable with True
Pass
cache=Truefor a process-scoped LRU cache (defaultmax_entries=128).python from praisonaiagents import Agent, AgentFlow formatter = Agent(name="Formatter", instructions="Format the text as clean JSON.") workflow = AgentFlow(steps=[formatter], cache=True) workflow.start("Same input each time") workflow.start("Same input each time") # served from cache - Tune the cache size
Pass an
InMemoryStepCacheinstance to set capacity or share one cache across flows.python from praisonaiagents import Agent, AgentFlow from praisonaiagents.workflows.step_cache import InMemoryStepCache formatter = Agent(name="Formatter", instructions="Format the text as clean JSON.") cache = InMemoryStepCache(max_entries=256) workflow = AgentFlow(steps=[formatter], cache=cache) workflow.start("Same input each time") - Bring your own cache
Any object with
get()andset()works â back the cache with Redis, disk, or anything else.python from praisonaiagents import Agent, AgentFlow class RedisStepCache: def __init__(self, client): self.client = client def get(self, key): raw = self.client.get(key) return None if raw is None else __import__("json").loads(raw) def set(self, key, value): self.client.set(key, __import__("json").dumps(value)) formatter = Agent(name="Formatter", instructions="Format the text as clean JSON.") workflow = AgentFlow(steps=[formatter], cache=RedisStepCache(client)) workflow.start("Same input each time")
Durable Resume (FileStepCache)
FileStepCache is a drop-in durable replacement for InMemoryStepCache â it keeps cached step results in a JSON file so an interrupted flow resumes from where it stopped in a new process.
from praisonaiagents import Agent, AgentFlow
from praisonaiagents.workflows.step_cache import FileStepCache
researcher = Agent(name="Researcher", instructions="Research the topic in depth.")
writer = Agent(name="Writer", instructions="Write a one-page briefing from the research.")
flow = AgentFlow(steps=[researcher, writer], cache=FileStepCache("briefing.json"))
flow.start("quantum error correction")
# Ctrl-C after Researcher finishes, before Writer finishes
# ...in a new process, same path...
flow.start("quantum error correction") # Researcher served from briefing.json; Writer runs- Start a long flow
Pass
cache=FileStepCache("run.json"). Each successful step’s result is written atomically torun.json. - Get interrupted
A deploy, crash, Ctrl-C, OOM or a closed laptop stops the run part-way.
- Re-run the same command
Completed steps are served from the file; the flow continues at the first unfinished step.
- Edit a step and re-run
A step whose code or instructions changed â and every step after it â runs again automatically. No stale results are served.
- Changed a tool? Clear first
Tool implementations are not fingerprinted, so call
FileStepCache("run.json").clear()before re-running.
When to pick which
Pick the lightest cache that covers where your results need to live.
What invalidates a cached step
Resume is content-addressed, not index-based, so a step re-runs whenever anything it depends on changes.
| Change | Step re-runs? |
|---|---|
| The input changes | â Yes |
| An upstream step’s output changes | â Yes (downstream keys see a new previous output) |
A handler’s code, or an immutable default / closure / __kwdefaults__ value, changes â even under the same name | â Yes |
| A module-level helper function the handler calls is edited | â Yes |
An agent’s role, goal, backstory, instructions, llm, action, description, expected_output or tool names change | â Yes |
| A workflow variable (or its type) changes | â Yes |
Not detected â call clear() after changing | Why |
|---|---|
| Mutable captured state (a list the handler appends to, a client in a closure) | Left out so the key does not drift during a run |
| Methods the handler calls, or imported libraries’ behaviour | Only module-level Python functions are fingerprinted |
| A tool’s underlying implementation | Only the tool’s name is in the key |
Crash-safety and concurrent writers
Every set() is a locked, atomic read-modify-write of the file, so a crash mid-write keeps the last good file. Two caches on the same path â in one process or two â both add to the file; neither erases the other’s entries. Only successful steps are cached, as with the in-memory cache.
FileStepCache Options
| Option / method | Type | Default | Description |
|---|---|---|---|
path | str | required | File path for the JSON store. ~ is expanded; an absolute path is used internally. The parent directory is created on first write. |
get(key) | method | â | Deep copy of the stored entry, or None on miss. |
set(key, value) | method | â | Atomic locked write. Raises TypeError if the value doesn’t JSON round-trip unchanged. |
clear() | method | â | Removes all entries and deletes the file. |
__len__() | method | â | Number of cached entries. |
When to Cache
Cache deterministic steps; skip steps that call tools with side effects, read a clock, or depend on randomness.
How It Works
On a miss the step runs and its output is stored; on a hit the cached output is returned without an LLM call.
The key is a stable SHA-256 (first 32 hex chars) built from five fields:
| Field | What it captures |
|---|---|
| Step identity | String steps use their text; other steps use .name / .__name__, else type(step).__name__:id(step) |
| Step definition | A handler’s code and immutable configuration, or an agent’s role, instructions, model and tool names â so an edited step misses the cache |
| Previous output | The output of the prior step |
| Input text | The current step’s rendered input |
| Variables | The workflow’s variables at that point, tagged with each value’s type |
Correctness Guarantees
Opt-in only
Nothing caches unless cache= is set. Agent steps are not always deterministic, so silent caching would be wrong.
Failures are never cached
Only successful steps are stored. Caching a failure would serve it again on every re-run, turning a transient error into a permanent one.
Both executors covered
The linear executor and the pattern executor (used by Parallel, If, etc.) are both wrapped, so caching works regardless of where a step sits.
Bounded LRU
InMemoryStepCache caps at max_entries (default 128). An unbounded cache would be a memory leak on long-running flows.
Thread-safe
InMemoryStepCache uses an internal lock, so it is safe to share across Parallel branches.
Deep-copied snapshots
Values are deep-copied on set and on get, so a caller mutating a returned value cannot corrupt future hits.
A hit replays the full step delta
On a cache hit the full step delta is replayed â output, the step’s output_variable (or {step_name}_output), any handler-supplied variables, the step’s status and retries, and the StepResult.stop_workflow signal. Downstream {{step_name_output}} substitutions, ctx.variables[...] reads, and result["steps"][...] metadata all resolve the same way on cold and cached runs.
A cached stop still stops
A handler that returned StepResult(stop_workflow=True) on the cold run also stops the workflow on the cached run â downstream steps never execute on the second run. With verbose=True the cache hit prints đ Workflow stopped at: {step.name}, matching the cold-run message.
Handler variables survive a hit
StepResult.variables returned by the handler are cached and re-applied on a hit, so a downstream consumer reading ctx.variables["answer"] sees the same value on cold and cached runs. If guardrails rejected one attempt and accepted the next, the union of their variables is cached â matching what the live run accumulated, not just the last attempt’s keys.
No spurious output variable on early stop
If the cold run stopped before writing <step>_output (because stop_workflow=True), the cached hit does not insert it either â variables keeps the exact shape the cold run produced.
A hit replays the whole payload the cold run produced, not just the output.
| Replayed on a hit | Where it lands |
|---|---|
output | result["output"], next step’s context |
<step>_output (or output_variable) | all_variables â only when the cold run did not stop first |
Handler-supplied variables | all_variables â union across guardrail-retried attempts |
StepResult.stop_workflow | Stops the cached run at this step |
Step status, retries | result["steps"][...] metadata |
from praisonaiagents import AgentFlow
from praisonaiagents.workflows.workflows import StepResult
def finish(ctx):
return StepResult(output="done", stop_workflow=True)
def downstream(ctx):
return "unexpected" # never reached, cold or cached
flow = AgentFlow(steps=[finish, downstream], cache=True)
flow.run("same") # cold: stops at finish
flow.run("same") # cached: also stops at finishfrom praisonaiagents import AgentFlow
from praisonaiagents.workflows.workflows import StepResult
def producer(ctx):
return StepResult(output="p", variables={"answer": 42})
def consumer(ctx):
return f"answer={ctx.variables.get('answer')}"
flow = AgentFlow(steps=[producer, consumer], cache=True)
first = flow.run("same") # "answer=42"
second = flow.run("same") # "answer=42" (cached)
assert second["output"] == first["output"]InMemoryStepCache Options
| Option | Type | Default | Description |
|---|---|---|---|
max_entries | int | 128 | LRU capacity. Must be > 0 (raises ValueError otherwise) |
Public attributes: hits, misses. Public methods: get, set, clear, __len__.
Accepted cache= values:
| Value | Meaning |
|---|---|
None / False | No caching |
True | Process-scoped InMemoryStepCache() (default max_entries=128) |
An InMemoryStepCache instance | Use the given cache â share across flows, tune size |
A FileStepCache instance | Durable JSON-backed cache â resume across processes |
Any object with get() and set() | Custom cache (Redis, disk, etc.) |
| Anything else | TypeError at construction |
Seeing Cache Hits
Turn on verbose to print a line for each hit.
from praisonaiagents import Agent, AgentFlow
formatter = Agent(name="Formatter", instructions="Format the text as clean JSON.")
workflow = AgentFlow(steps=[formatter], cache=True, verbose=True)
workflow.start("Same input each time")
workflow.start("Same input each time") # prints: âŠī¸ cache hit: FormatterBest Practices
Cache deterministic steps only
Formatters, lookups, and summarisers over unchanged text are safe. Their output depends only on their input.
Skip steps with side effects
Do not cache steps that call tools with side effects or read a clock â a cached answer would skip the effect the caller expected.
Share one cache across flows
Create a single InMemoryStepCache and pass it to multiple AgentFlows to reuse results across pipelines.
Use variables to force a re-run
Change a workflow variable when you want a fresh call â different variables produce a different key.
Bound the cache to your working set
Set max_entries to the number of results you actually reuse; a larger cache only wastes memory.
Use FileStepCache for long runs and expensive steps
Any flow that costs real money or minutes to re-run deserves durable resume. The overhead is one atomic JSON write per successful step.
Call clear() after editing a tool or a mutable dependency
The fingerprint doesn’t cross into tool implementations or mutable captured state.
Keep each run on its own path for strict isolation
Or deliberately share a path to pool results across runs and processes â both writers add; neither erases.