Generating Graphs from Language#
gap generate compiles a one-sentence instruction into a typed, verified
workflow directory β the same workflow.json + scripts/ + checkpoints/
artifact that gap.builder authors by hand and
gap run executes. Generation runs a multi-agent
LLM pipeline against your skill registries, validates the result with the
same validator the runtime uses, and writes every prompt and response to
disk for debugging.
Tip
End-to-end
The full loop gap generate "<task>" β gap run --sim works
end-to-end on the public quickstart task today: the codegen picks
perceiving-objects + grasping-with-planner + transporting-objects
from the registry, vendors their canonical scripts into a fresh graph
directory, and the runtime evaluates LLM-authored postcondition
checkpoints (target_obb_is_plausible, target_held,
target_in_container, β¦) against sim ground truth. With
gemini-3.1-flash-lite-preview on Vertex, codegen is on the order of
~20 s and the sim trial on the order of ~2 min.
Note
Requirements
An LLM API key (OPENROUTER_API_KEY for the default provider β see
LLM providers) and at least one skill registry (an
open-robot-skills checkout). Generation is pure LLM calls plus static
validation β no simulator is needed.
CLI#
export OPENROUTER_API_KEY=...
gap generate "pick up the alphabet soup can and place it in the basket" --out my_graph
Flag |
Meaning |
|---|---|
|
Skill registry root(s); repeatable, precedence-ordered. Default: the resolved registry set (see Registries). |
|
LLM provider override (default: |
|
LLM model override (default: the provider default β |
|
Output directory (default: |
|
Pipeline config YAML β |
|
Debug logging. |
On success the command prints OK: wrote <path> (N subgraph(s), M generated file(s)), renders the graph as box-drawing terminal text (color is
suppressed when stdout is not a TTY or NO_COLOR is set), and ends with
run it with: gap run <path>.
Exit codes:
Code |
Meaning |
|---|---|
|
Graph written and loaded. |
|
Generation failed ( |
|
No skill registries could be resolved ( |
Warning
The output lands in a task_00 subdirectory
--out my_graph does not make my_graph the workflow folder β the
workflow is written to my_graph/task_00/ (the generation entry point is
shared with the multi-task benchmark launcher, which numbers tasks). Run it
with gap run my_graph/task_00, not gap run my_graph.
Python API#
import gap
graph = gap.agent.generate_sync(
"pick up the alphabet soup can and place it in the basket",
out_dir="my_graph", # skills= omitted -> registries auto-discovered
)
print(graph) # the graph as box-drawing terminal text
print(graph.path) # my_graph/task_00
Both entry points share one signature; generate is async,
generate_sync wraps it in asyncio.run:
async def generate(
instruction: str,
*,
skills: str | Path | Sequence[str | Path] | None = None,
model: str | None = None,
provider: str | None = None,
out_dir: str | Path | None = None,
config: PipelineConfig | str | Path | None = None,
) -> GeneratedGraph
skillsβ registry root(s); a path or a precedence-ordered sequence. Omitted means the active registries are resolved ($GAP_SKILLS_PATH> project[tool.gap]> user config > an open-robot-skills checkout next to the GaP checkout). Generation needs at least one; resolution failure raisesFileNotFoundErrorlisting what was tried.model/providerβ per-call LLM overrides; they take precedence over the correspondingconfigfields.out_dirβ defaults tooutputs/generated_<YYYYmmdd_HHMMSS>; the workflow folder is always<out_dir>/task_00.configβ aPipelineConfiginstance or a YAML path for full control (per-role models, temperatures, retry budgets β see LLM providers).Raises
RuntimeErrorwhen generation fails andFileNotFoundErrorwhen no skill registry is resolvable.
The result is a GeneratedGraph dataclass:
Field |
Contents |
|---|---|
|
The written workflow folder ( |
|
The parsed |
|
LLM-generated sources keyed by workflow-relative path β inline scripts plus |
str(graph) renders the same box-drawing text the CLI prints
(gap.viz.text.to_text).
What gets written#
my_graph/task_00/ # = graph.path
βββ workflow.json # the v3 graph: top-level DAG + one entry per subgraph
βββ scripts/ # scripts referenced by type:script nodes
βββ checkpoints/ # postcondition sidecars, one module per subgraph
βββ agent_traces/ # per-agent prompts + responses (below)
βββ multi_agent_meta.json # {"pipeline": "gap_multi_agent", "schema_version": 3}
See the workflow schema reference for the file format and examples/generate-a-graph for a complete walkthrough with real output.
Pipeline anatomy#
instruction
β
βΌ
coordinator βββΊ subgraph agents (sequential, one per subgraph)
β β
βΌ βΌ
topology structure + inline scripts
β
βΌ
checkpoint agent (one whole-workflow call)
β
βΌ
assemble workflow.json + materialize canonical scripts
β
βΌ
validate β LLM fix loop (bounded)
Coordinator β turns the task into a workflow topology: which skills to use, the subgraph input/output schemas, and the conditional edges between subgraphs. It emits a Python block binding
spec = WorkflowSpec(...), which is executed in a sandbox and checked at parse time (non-empty nodes/edges/subgraphs; every subgraphβsskillmust exist in the registry).Subgraph agents β one call per subgraph, sequential by design: each agent sees the bound outputs of every upstream subgraph, so later prompts can reference earlier results. Each emits a builder block binding
sg = Subgraph(...)plus optional inline scripts as fenced code blocks whose info string carries the path (python:scripts/<sg>/<file>.py), validated against the structural rules described in Graph patterns (declared nodes, exit values matching the skillβs exit conditions, all outputs bound, β¦).Checkpoint agent β one whole-workflow call (enabled by
composition.checkpoint_agent, default on) that authors postcondition checkpoints for every subgraph at once. Its output is AST-validated (onlyadd_checkpoint(...)calls; predicate reads must match declared outputs) and semantically checked: every subgraph needs at least onevalidate=Truecheckpoint with a non-empty rationale, at most 6 per subgraph, and grasp/transport subgraphs that bind pose outputs must include a z-axis predicate. The sidecars land incheckpoints/<sg>.py.Assemble β the per-subgraph dicts are stitched into a
{"version": 3, ...}workflow.jsonand the folder is written.Validate + fix β the workflow is loaded and checked with the runtime validator. Script errors trigger up to
composition.max_validation_retries(default 2) LLM fix rounds, each a focused per-script βfix ONLY these errorsβ prompt.
Retries and corrective feedback#
Each agent role (coordinator, subgraph agent, checkpoint agent) gets 3 attempts. When an attempt fails parse-time validation, the error text is appended to the conversation as a corrective user turn (βYour previous response had errors: β¦ Fix every error and re-emit the full blockβ) and the model tries again; exhausting the budget fails the whole generation.
Below the agent loop, each LLM call has its own transport retries: 3 retries with exponential backoff (2 s initial, plus jitter). On the OpenAI-compatible path, 429 backs off, 5xx and transport errors retry, and any other 4xx raises immediately. Agents may also call codegen meta-tools (reading skill references and examples, requesting helper scripts from a coder subagent); the tool-use loop is capped at 6 rounds per call.
If an agent calls the report_missing_capability meta-tool β the task
needs a skill or tool the registries do not provide β the build aborts
after the subgraph stage with a structured list of the gaps, rather than
emitting a graph that cannot work.
Generated (invented) skills#
When no skill in the resolved registries fits a step the task requires β but the step can
still be built from existing tools plus some custom Python β the coordinator can invent a skill
inline instead of aborting via report_missing_capability. It does this by passing
generated=True to spec.declare_subgraph(...) in the workflow spec (the same flag is also
available on gap.builder.Subgraph for hand-authored graphs). The subgraph_agent then implements
the invented skill from scratch by composing type: tool nodes and authoring type: script
nodes:
spec.declare_subgraph(
"insert_peg",
skill="insert-peg-in-hole", # invented name β NOT from the catalog
generated=True,
description="Insert the held peg into the hole on the fixture",
inputs={"peg_pose": "Se3Pose", "hole_pose": "Se3Pose"},
outputs={"inserted": "bool"},
exit_success_values=["inserted"],
on_error="failed",
)
There is no canonical bundle on disk and no canonical script for these skills β the LLM
emits both the skill declaration and its scripts as part of codegen, all in the workflow folder.
The runtime synthesises a transient SkillInfo (a minimal SkillMeta carrying the agent-declared
contract) so the rest of the pipeline can treat the invented skill uniformly: structural
validation still runs in full (the input/output contract, exit_success_values / on_error
consistency, the subgraph S1-S12 rules, and allowed_tools against the flat tool catalog), and
validation errors flow back to the authoring agent in the same self-repair feedback loop used for
registered-skill subgraphs.
Use this for a one-off, task-specific micro-skill that isnβt worth promoting to the canonical
skill library β e.g. a compute_target_drop_zone step thatβs specific to one layout, or a
insert-peg-in-hole segment with bespoke fixture geometry. For anything you expect to reuse, ship
it as a proper bundle (Authoring bundles): an invented skill is
implemented from scratch every run, where a registry skill is tested and canonical. The coordinator
prompt enforces this preference: invent only when nothing in the Available Skills table fits.
The agent-side mirror of this rule lives in agent/skills/gap/references/authoring-graphs.md (the authoring digest the in-IDE skill loads when you build graphs by hand).
Canonical scripts always win#
Skills can ship canonical (skill-owned) scripts, e.g. the perception
bundleβs perceive_dino_vlm. When any script node references a path whose
stem matches a canonical script, the bundleβs file is copied into the
workflow folder β overwriting any LLM-emitted version of it. LLM
reimplementations of canonical scripts have repeatedly introduced
regressions (wrong imports, simplified pose math, dropped parameters), so
the canonical source is authoritative; inline LLM scripts persist only
under non-canonical stems.
Generation can βsucceedβ with residual validation errors#
Warning
If validation errors remain after the fix budget is exhausted, generation still returns successfully β the residual errors are only logged as a warning. Failures then surface at load or execution time. Always validate before running:
gap run my_graph/task_00 --validate-only
Agent trace artifacts#
Every prompt and every response is written under
<workflow_dir>/agent_traces/, one directory per agent:
agent_traces/
βββ _coordinator/
β βββ system_prompt.md # exact assembled system prompt
β βββ llm_response_attempt_0.md # one file per attempt
βββ <subgraph_name>/ # one dir per subgraph
β βββ system_prompt.md
β βββ subgraph_spec.json # the spec the coordinator handed down
β βββ llm_response_attempt_0.md
βββ _checkpoint_agent/
β βββ system_prompt.md
β βββ user_prompt.md # the workflow + builder sources it saw
β βββ llm_response_attempt_0.md
βββ _validation_fix/
βββ attempt_0/ # one dir per fix round
βββ <script>_prompt.txt
βββ <script>_response.txt
βββ <script>_fixed.py
When a generated graph misbehaves, read the responses in attempt order:
multiple llm_response_attempt_N.md files mean parse-time validation
rejected earlier attempts, and the corrective turns show exactly which
rule fired. These are generation-time traces; execution traces are
separate (see Traces).
The LLM disk cache#
Set GAP_LLM_CACHE_DIR (or cache_dir: under llm: in a config YAML) to
memoize plain completions on disk:
export GAP_LLM_CACHE_DIR=~/.cache/gap-llm
gap generate "pick up the milk and put it in the basket"
The cache key is a SHA-256 hash over provider + model + temperature +
max_tokens+ system prompt + messages; entries are<cache_dir>/<hash>.json. Reruns of an identical prompt return the cached response without an API call.GAP_LLM_NO_CACHE=1(ortrue/yes/on) bypasses the cache without deleting it.Tool-loop responses are never cached β tool side effects make memoization unsound β so agents that call meta-tools always hit the API.
Warning
The cache collapses sampling
The cache only correctly memoizes deterministic calls. At temperature > 0
it collapses K stochastic samples into one stored response β every βfreshβ
generation of the same prompt replays the first one. Set
GAP_LLM_NO_CACHE=1 when you want diverse regenerations.
This is the codegen cache. The perception pipeline has a separate
runtime cache (GAP_PERCEPTION_CACHE_DIR) keyed on the VLM provider
config β see LLM providers and the
environment variable reference.
Next steps#
LLM providers β provider setup and the pipeline config YAML (per-role models, temperatures, retry budgets).
examples/generate-a-graph β the full walkthrough, from instruction to a validated run.
Authoring with the builder β produce the identical artifact in Python, no LLM involved.
Benchmarking β drive this pipeline over task Γ seed grids.