Tutorial: one agent, three models¶
Mini-project for: ModelPreset, VariantSpec, and SplitProfile.
The problem¶
You wrote one good summarizer prompt — but which model should run it? The strong cloud model is best, the cheaper one is fine for bulk work, and the local vLLM box is free once it's racked. Maintaining three copies of the agent is how definitions drift apart. Instead, define the agent once and let the compiler fan it out into three side-by-side variants you can invoke — and benchmark — interchangeably.
What you'll build¶
A single summarizer AgentDefinition compiled in one pass into three
opencode artifacts:
| Artifact | Provider | Model |
|---|---|---|
primary.md |
zai-coding-plan |
glm-4.5-air (cheap cloud) |
primary-glm51.md |
zai-coding-plan |
glm-5.1 (strong cloud) |
primary-qwen.md |
local-vllm-remote |
qwen35-27b (local vLLM) |
Prerequisites: installation, the
opencode CLI, and for the local variant an OpenAI-compatible endpoint
(e.g. vLLM at http://localhost:8000/v1). This tutorial is adapted
from examples/10_multi_provider.
Step 1 — define the agent once¶
agents.py:
from __future__ import annotations
from open_agent_compiler import (
AgentDefinition,
AgentHeader,
AgentRegistry,
CompilationConfig,
ModelParameters,
ModelPreset,
SamplingDefaults,
TemplateSlot,
TemplateTree,
VariantSpec,
)
def registry() -> AgentRegistry:
reg = AgentRegistry()
summariser = AgentDefinition(
header=AgentHeader(
agent_id="multi-summariser",
name="multi-summariser",
description="Summarises arbitrary user-supplied text in one paragraph.",
),
usage_explanation_long=(
"Reads whatever the user sends and returns a single dense"
" paragraph capturing the key points. No bullet lists, no"
" preamble — just the summary."
),
usage_explanation_short="one-paragraph summary",
system_prompt=(
"You are a concise summariser. Read the user's message and"
" reply with exactly one paragraph (3-5 sentences) capturing"
" the key points. Do NOT use bullet lists, do NOT add"
" preamble. Start the summary directly."
),
)
agent_id = reg.register_agent(
"multi-summariser", summariser,
ModelParameters(model_name="zai-coding-plan/glm-4.5-air", temperature=0.5),
)
reg.register_template(TemplateTree(
name="summariser-tpl",
slots=[TemplateSlot(name="primary", default_agent_id=agent_id)],
))
reg.create_compilation_config(
CompilationConfig(name="prod", template_name="summariser-tpl"),
)
return reg
Step 2 — describe the three models as VariantSpecs¶
A ModelPreset is the full per-model descriptor (provider, model id,
sampling defaults); a VariantSpec binds one preset to a compile pass
and a filename postfix:
VARIANTS: list[VariantSpec] = [
VariantSpec(
name="default", postfix="",
preset=ModelPreset(
name="zai-glm-45-air",
provider="zai-coding-plan", model_id="glm-4.5-air",
sampling=SamplingDefaults(temperature=0.5),
),
),
VariantSpec(
name="glm51", postfix="-glm51",
preset=ModelPreset(
name="zai-glm-51",
provider="zai-coding-plan", model_id="glm-5.1",
sampling=SamplingDefaults(temperature=0.5),
),
),
VariantSpec(
name="qwen", postfix="-qwen",
preset=ModelPreset(
name="vllm-qwen35-27b",
provider="local-vllm-remote", model_id="qwen35-27b",
sampling=SamplingDefaults(temperature=0.6),
),
),
]
The local-vllm-remote provider must exist in your
~/.config/opencode/opencode.json as an OpenAI-compatible provider
whose base URL points at your endpoint, e.g.
http://localhost:8000/v1. The framework's artifact is correct either
way — if the endpoint is down, opencode reports a connection error at
invocation time, not compile time.
Step 3 — compile all three in one pass¶
build_agents.py:
from pathlib import Path
import sys
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
from agents import VARIANTS, registry
from open_agent_compiler.compiler.script import CompileScript
def main() -> None:
script = CompileScript(
target=HERE / "build",
factory=registry,
config="prod",
variants=VARIANTS,
clean=True,
verbose=True,
)
result = script.run()
print(f"wrote {len(result.written_files)} file(s); variants: {result.variants}")
for path in sorted(result.written_files):
print(f" - {path.relative_to(result.target.parent)}")
if __name__ == "__main__":
main()
Run it¶
uv run python build_agents.py
cd build
TEXT="Summarise: Vector databases trade exact search for speed..."
opencode run --agent primary "$TEXT"
opencode run --agent primary-glm51 "$TEXT"
opencode run --agent primary-qwen "$TEXT"
The compile prints the three artifacts:
wrote 3 file(s); variants: ['default', 'glm51', 'qwen']
- build/.opencode/agents/primary.md
- build/.opencode/agents/primary-glm51.md
- build/.opencode/agents/primary-qwen.md
Each file's frontmatter carries its own model: line
(zai-coding-plan/glm-4.5-air, zai-coding-plan/glm-5.1,
local-vllm-remote/qwen35-27b — the preset's qualified_model_name).
Run the same text through all three and compare: the glm-5.1 summary is
usually the tightest, glm-4.5-air is close for a fraction of the cost,
and the local qwen variant costs nothing per token. You now have an
instant A/B/C harness for "is the cheap model good enough here?"
Bonus — routing by model class with SplitProfile¶
VariantSpec applies one preset to every agent in a pass. In a
larger tree you usually want "small agents on the fast model, deep
agents on the strong one" — that is SplitProfile, a VariantSpec
whose preset is chosen per agent from its declared model_class:
from open_agent_compiler import SplitProfile
split = SplitProfile(
name="prod-split", postfix="-split",
class_map={
"fast": VARIANTS[0].preset, # glm-4.5-air
"analytical": VARIANTS[1].preset, # glm-5.1
},
preset=VARIANTS[1].preset, # fallback
default_class="analytical",
)
An agent opts in at definition time with
AgentDefinition(..., model_class="fast"); agents in
passthrough_classes (by default ("vision",)) keep their original
model untouched.
Why it works¶
The agent definition never learns which model it runs on — provider
choice lives entirely in ModelPresets applied at compile time. That
one inversion is what makes model comparison, cost tiering, and cloud →
local migration a compile-flag change instead of a refactor, and it is
the same mechanism the improvement loop uses to tune prompts per
model rather than settling on one compromise prompt.
Going further¶
examples/25_per_model_optimization— the same split, driven through the improvement loop so each model class evolves its own winning prompt.- Variants and profiles guide
- Execution tiers — the same presets also feed the in-process interactive tier.