Prompts¶
A Prompt is a system prompt with an id and a version attached, so that
when quality changes you can say which wording changed it.
That is the whole package. agentkit.agents.prompts is deliberately boring: it
owns the shape of a prompt and nothing else. No templating engine, no
prompt store, no A/B router — those live in your app, or in a layer above,
and consume this shape.
Is this page for you?
Reach for it when you want to know which wording changed quality, or you ship prompts that outlive the code that calls them.
Skip it for now if a plain string prompt is still telling you everything you need to know.
The problem it solves¶
Prompt drift is a top source of silent regressions in agent systems. A teammate softens one sentence in a system prompt to fix one bad output, and three weeks later a different behaviour is worse and nobody can point at the change. There is no stack trace for "the wording moved".
Treating a prompt as a value — pinnable, diff-able, and carried
through the same middleware chain as every other call — makes a prompt
edit a code change with the same review surface as a change to a function
signature. The version label travels onto the trace and onto
AgentResult, so a regression localises to a specific template revision
rather than to "sometime last sprint".
The second thing it fixes is quieter and worse: a placeholder that
reaches the model unsubstituted. {tenant} arriving literally is not a
crash — it is a plausible-looking prompt that describes the wrong task,
and the model will happily answer it.
The smallest thing that works¶
import asyncio
from agentkit.agents import Agent
from agentkit.agents.prompts import Prompt
from agentkit.core.testing import FakeLLM, make_test_ctx
BRIEFER = Prompt(
id="briefer",
version="1.0.0",
inputs=("tenant",),
template='Brief tenant {tenant}. Reply as {"type": "object"}.',
)
async def main() -> None:
seen: list[str] = []
llm = FakeLLM(lambda system, user, model: seen.append(system) or "done")
ctx = make_test_ctx(llm=llm)
agent = Agent(name="briefer", model="fake-model", prompt=BRIEFER.bind(tenant="acme"))
await agent.run("summarise Q3", ctx)
print(seen[0]) # Brief tenant acme. Reply as {"type": "object"}.
# Handing the agent the UNBOUND prompt is refused at construction.
try:
Agent(name="briefer", model="fake-model", prompt=BRIEFER)
except ValueError as exc:
print(str(exc)[:60])
asyncio.run(main())
What a Prompt is¶
A Prompt is a template string plus the paperwork that makes it
traceable: a name, a version, and the list of blanks it expects you to
fill in.
The blanks are the part worth dwelling on. A template like
"You advise {tenant} on {topic}" declares two names, and a Prompt
knows it declares them. That means it can refuse to render until both
are supplied, and refuse a name it never asked for — so a typo becomes
an error at the point you make it, rather than a plausible-looking
prompt that quietly describes the wrong task.
| Field | What it carries |
|---|---|
id |
A stable identifier, used in tracing and evaluation reports. |
version |
A version label; travels into the trace envelope on every call. |
template |
The template string. |
inputs |
A tuple[str, ...] of the placeholder names the template declares. |
bound |
The values bound to those names — a read-only mapping. |
The names live on the value, not at the call site, so a stored prompt carries its own contract: you can validate a prompt against a call site before a run, and a prompt loaded from a database still knows what it needs.
Two methods:
bind(**values)— returns a newPromptwith those values bound. Sameid, sameversion, sameinputs, so a bound prompt is still pinnable, diff-able and attributable, and goes anywhere an unbound one goes.render(**values)— substitutes each declared name and returns the text, taking values fromboundand from**values, with the latter winning on a collision.
Binding, end to end¶
Values live on the prompt because nothing in the framework passes them
at call time. RequestBuilder and both CLI cognitions call render()
with no arguments. Binding is what makes inputs usable through an
Agent at all.
from agentkit.agents.prompts import Prompt
BRIEFER = Prompt(
id="briefer",
version="1.0.0",
inputs=("tenant", "tone"),
template='Brief tenant {tenant} in a {tone} voice. Reply as {"type": "object"}.',
)
# Binding is incremental, immutable, and last-write-wins.
half = BRIEFER.bind(tenant="acme")
print(half.render(tone="terse"))
print(half.bind(tenant="globex").bind(tone="warm").render())
print(BRIEFER.bound) # {} — the original never changed
# A missing input is refused rather than shipped half-filled.
try:
half.render()
except ValueError as exc:
print(str(exc)[:80])
# So is an undeclared name — at bind() time, not at render().
try:
BRIEFER.bind(tenat="acme")
except ValueError as exc:
print(str(exc)[:80])
That last one is the cheap catch: a renamed or mistyped placeholder is caught where you wrote it, not on the first drive. Quietly accepting it would render the old template with none of the new values.
An unbound prompt is refused at construction¶
Agent.check_prompt() runs from __post_init__, beside the model
capability check, and for the same reason. An unbound prompt is not a
maybe problem — nothing passes values later, so it is a guaranteed
ValueError on the first drive. The only question is whether you find
out before or after the run starts, and the alternative to refusing is
shipping a literal {tenant} to the model.
Agent is a mutable dataclass
Assigning agent.prompt after construction bypasses that check.
Re-assert with agent.check_prompt(), exactly as with
agent.check_capabilities().
A plain str prompt, a None prompt, and a Prompt that declares no
inputs are all unaffected.
Why not str.format¶
Substitution is a literal replacement of the declared names, never
str.format. System prompts are full of braces that are not
placeholders — a JSON Schema, an example payload, a code fence — and
format would either raise KeyError on them or silently eat the
doubling someone added to escape them.
import copy
import pickle
from agentkit.agents.prompts import Prompt
p = Prompt(id="x", version="1", inputs=("kind",),
template='Return {"type": "object"} for {kind}.')
print(p.render(kind="invoices")) # Return {"type": "object"} for invoices.
bound = p.bind(kind="invoices")
print(hash(bound) == hash(p)) # True — hashed on identity
print(copy.deepcopy(bound).render()) # deep-copyable
print(pickle.loads(pickle.dumps(bound)).render()) # and picklable
A prompt that declares no inputs renders exactly as it always did: the
stripped template, no arguments, no bind() needed.
A Prompt is a value all the way down¶
Three properties that are easy to assume and were, at one point, not true:
boundis frozen deeply, and it is a copy. A caller cannot keep editing the dict they bound, and nested containers inside it are frozen too — a shallow copy would leave the same bug one level down.- It is deep-copyable and picklable.
boundis adictsubclass rather than aMappingProxyType, and that matters concretely: the proxy made a bound prompt unpicklable and therefore un-deep-copyable — which the checkpointer does on every snapshot — and invisible tojson.dumpsanddataclasses.asdict. See Kernel › immutability. - It is hashable, on identity.
hash()covers(id, version, template, inputs)and deliberately notbound, so a bound and an unbound copy of the same template share a bucket — the useful behaviour for a cache keyed on prompt identity.__eq__still compares the bindings, so asetkeeps both.
Built-ins¶
agentkit.agents.prompts.builtin ships two vetted seed prompts used by the
LLM-driven compaction strategies: COMPACTION_SUMMARY (for
SummarizationCompactor) and COMPACTION_IMPORTANCE (for
ImportanceFilteringCompactor).
They live there rather than as string literals inside the compactors for three reasons. They get one source of truth, so an operator tuning compaction wording bumps a version instead of patching a file. They get attribution, so a compaction summary lands in traces with a version stamp. And a compaction strategy (when and how to fold a transcript) is kept separate from a compaction prompt (wording).
There is no built-in ReAct system prompt — cognitions author their own.
What bites people¶
- Nothing passes values at call time. If a template declares
inputs, bind them, or the run refuses. This is the single most common surprise on this page. - Undeclared names are refused, not ignored — at
bind(), and also atrender()for a prompt that declares no inputs. Passing values to a prompt that declares none is a mistake at the call site, not a no-op. bind()never mutates. A module-level prompt shared by several call sites cannot pick up another one's values. If you expected in-place binding, you will silently render the unbound template.render()strips the result. Leading and trailing whitespace in a template does not survive.- The version label is yours to bump. Nothing computes it from the template, so a wording change with an unchanged version defeats the point of the field.
The invariants it enforces¶
- Prompts are values. A
Promptis immutable, payload included. - Version travels with output. Every LLM call carries
prompt.idandprompt.versionon its trace envelope so a regression can be localised. - Rendering is pure.
render()does no I/O and reads no ambient state. To inject data, declare it ininputsand bind it. - Binding does not mutate.
bind()returns a new value; the original stays as it was.
Where this fits in the four themes
Prompt is a State-theme primitive — the versioned template that
anchors every chat call. Its id and version travel with every LLM
call through the middleware chain (the Behaviour theme), so a
regression can be localised to a specific template revision. See the
four-theme grid on the landing page.
Related¶
- Capabilities › RequestBuilder — where a prompt is rendered into a request.
- Agents — where
check_prompt()runs. - Context — the cache-stable prefix a rendered prompt lands in.
- API › prompts — the generated reference.