ExecutionPlanToolkit — Reference¶
Feature: FEAT-419
Module: parrot/tools/execution_plan/
Class: ExecutionPlanToolkit(AbstractToolkit)
Overview¶
ExecutionPlanToolkit lets a plain BasicAgent run a deterministic
tool-call DAG (an ExecutionPlan) through one bounded tool call, with
zero LLM tokens spent while the plan executes. A thinking model (or a
versioned file) authors the plan once; the toolkit validates it statically
against the live ToolManager, compiles it to a FlowDefinition, and runs
it on AgentsFlow. Tool payloads never enter the agent's context — they
land in WorkingMemory under keys the plan itself chose, and only small,
bounded ArtifactRefs and an ExecutionManifest travel back.
The invoking agent stays a normal BasicAgent. It does not become a
Flow or a Crew — it invokes a flow through a tool.
This is the wrapper around the frozen parrot.bots.flows.plan module
(ExecutionPlan, PlanNode, PlanToolNode, validate_plan,
to_flow_definition, …) — that module's schema and executor semantics are
constraints, not design space, for anything built on top of it.
Wiring¶
from parrot.bots.agent import BasicAgent
from parrot.tools.execution_plan import ExecutionPlanToolkit
from parrot.tools.working_memory import WorkingMemoryToolkit
working_memory = WorkingMemoryToolkit()
toolkit = ExecutionPlanToolkit(
tool_manager=agent.tool_manager, # SAME manager the agent's other tools use
working_memory=working_memory, # SAME instance the analyst reads back from
planner_llm="google:gemini-2.5-flash", # enables `objective` mode; omit to disable it
plans_dir="examples/plans", # enables `plan_name` mode; omit to disable it
allowed_tools=["s3_filter_reports", "s3_get_latest_report", "compare_scans"],
soft_timeout=60.0,
)
agent.tool_manager.register_tool(toolkit)
# Also register `working_memory` with the agent so the analyst can read
# back artifacts under the keys the plan chose — constructor injection,
# never auto-detected: `BasicAgent._inject_answer_memory_into_toolkits()`
# does not match wrapped `ToolkitTool`s.
agent.tool_manager.register_tool(working_memory)
Both planner_llm and plans_dir are optional and independent — set
either, both, or neither. Neither one being set means the toolkit only
exposes plan_status/plan_artifacts (nothing to acquire a plan from).
allowed_tools=None (the default) means every tool registered on
tool_manager is allowed; setting it is both the security boundary (a plan
naming a tool outside the list fails validation before anything runs) and
the planner's tool catalog.
Tools¶
plan_execute(objective=None, plan_name=None, params=None)¶
Acquire → validate → compile → run. Exactly one of objective (planner-
authored) or plan_name (versioned file under plans_dir) must be given.
objectivemode: the toolkit's internalPlanPlannermakes one structured-output LLM call to author the plan. If validation fails, the planner is re-prompted exactly once with the fullValidationReport(whose messages are written for this) — then the toolkit gives up.plan_namemode: loadsplans_dir/<plan_name>.(yaml|yml|json)with load-time{params.<name>}substitution, then validates. No repair round — a persisted plan that fails validation is a broken file to fix, not something to patch at runtime.
Returns the full ExecutionManifest if the run finishes within
soft_timeout, else {run_id, status: "running", nodes_total, nodes_done}
while execution continues in the background — poll plan_status(run_id).
Failure semantics: the manifest is always the success payload —
status is completed | partial | failed with per-node errors inside
(capped at 20). A plan that partially failed is data the agent inspects
and reacts to, not an exception. Tool-level errors are reserved for
structural failures: both/neither plan source given, params combined
with objective, objective without a configured planner_llm,
plan_name without a configured plans_dir, an unreadable plan file, and
an invalid plan after the repair round (nothing to run).
plan_status(run_id)¶
Progress counts (nodes_total/nodes_done) while a run is still
executing; the final ExecutionManifest once it has finished.
plan_artifacts(run_id)¶
The ArtifactRef list produced so far — the WorkingMemory key map the
analyst reads back from, available even while the run is still going.
plan_validate(objective=None, plan_name=None, params=None)¶
Dry run: same arbitration, acquisition and (in objective mode) repair
round as plan_execute, but never executes a tool. Returns the
acquired plan JSON verbatim — including a planner-generated plan in
objective mode — plus the full ValidationReport (ok flag and every
issue's node_id/code/message/severity). Because the response
always includes the plan JSON, even when ok is false, this is also the
save-and-promote workflow: inspect what the planner produced, fix it by
hand if needed, and drop it into plans_dir as a new versioned
plan_name.
The plan file + {params.<name>} contract¶
A plan_name-mode file is a plain YAML or JSON document matching
ExecutionPlan.model_json_schema() (see
sdd/artifacts/execution_plan.schema.json), with one addition: string
leaves may contain {params.<name>} placeholders, substituted before
validation:
- A leaf that is exactly one placeholder resolves to the parameter's
native value (an
intparam stays anint). - A leaf with an embedded placeholder is interpolated as text.
- Every placeholder in the file must have a matching key in
params, and every key inparamsmust be referenced somewhere in the file — nothing is silently missing or silently unused.
{params.<name>} is a load-time-only concept, handled entirely by
PlanFileStore before the plan ever reaches the executor. It is distinct
from — and never touches — the executor's own runtime placeholder
families, which are resolved per-node while the plan runs:
| Placeholder | Resolved by | When |
|---|---|---|
{params.<name>} |
PlanFileStore.load() |
Load time, plan_name mode only |
{nodes.<id>.output} |
PlanToolNode._resolve_args |
Runtime — the small published ArtifactRef |
{artifacts.<id>} |
PlanToolNode._resolve_args |
Runtime — the full stored body (code reads it, never a model) |
{item} / {item.<field>} / {index} |
PlanToolNode._resolve_args |
Runtime, inside a for_each node |
See examples/plans/daily_security_sweep.json for a complete example
(4-node plan: list reports → fan out and fetch each → diff against the
previous scan → map new findings to SOC2 controls), using {params.date}.
result = await toolkit.plan_execute(
plan_name="daily_security_sweep",
params={"date": "2026-08-06"},
)
Soft-timeout / run_id flow¶
plan_execute waits up to soft_timeout seconds (default 60.0) for the
run to finish. If it finishes in time, the full manifest comes back
directly. If not, the run keeps going in the background — the timeout
never cancels it — and the tool call returns a small summary instead:
The agent then polls:
status = await toolkit.plan_status(run_id="run_ab12cd")
# → RunningSummary again while it's still going, or the final
# ExecutionManifest once it's done.
This is how a 300-item fan-out over minutes of wall-clock time coexists with a normal per-tool-call timeout on the agent side.
v1 caveats (read before relying on this in production)¶
WorkingMemoryis in-RAM, with no guardrail. Every payload a plan fetches — including a 300-item fan-out over hundreds of MB of scanner reports — lives in the process's memory for the whole run and beyond, until something explicitly drops it. There is no size cap, no eviction, no spill-to-disk.bytes_stored(perArtifactRef) andtotal_bytes_stored(on the manifest) make the cost visible; they do not bound it. A persistentWorkingMemorybackend is a separate, future feature.- The run registry is lost on a process restart.
RunRecords and the liveasyncio.Tasks behind them are toolkit-instance state, not persisted anywhere. There is noplan_resume(run_id)— do not build workflows that assume one exists. The toolkit explicitly disablesAgentsFlow's own flow-level checkpointing (FEAT-399) for every plan run — that mechanism defaults to a Redis-backed checkpoint store, which would otherwise be a silent, undocumented external dependency contradicting the "pure in-RAM, no persistent backend" design above. - Recovery is re-issue +
skip_existing, not resume. If a process dies mid-run, re-issuing the sameplan_execute(plan_name=..., params= ...)call redoes only the work that never got stored — everyfor_eachnode defaults toskip_existing=True, so astore_askey already present inWorkingMemoryis not re-fetched. This is idempotence, not checkpoint/resume; a plan with nofor_eachnodes has no such recovery story and simply re-runs from the top. - Run-registry bounds: completed/failed runs beyond
max_completed_runs(default50) are evicted oldest-first. In-flight runs are never evicted, and there is no cap on concurrent runs in v1. allowed_tools=Nonemeans "trust every tool on thisToolManager". If the manager is shared with components that register tools you would not want a planner-authored plan to invoke, setallowed_toolsexplicitly.
See also¶
sdd/specs/execution-plan-tool.spec.md— the full design spec (FEAT-419).parrot/bots/flows/plan/— the frozen plan schema/validator/compiler/executor.examples/plans/daily_security_sweep.json— the shipped example plan.packages/ai-parrot/tests/tools/execution_plan/test_integration.py— the end-to-end proof (zero-token execution, resumable fan-out, allowlist enforcement,AgentCrew.add_tool_node()regression).