RunContext
NAME
LLM::Agent::RunContext - the refreshable half of a prompt: what is true right now, rendered into the request rather than into the conversation
SYNOPSIS
use LLM::Agent::RunContext;
# Built fresh per run ā the caller reads the clock, the repository and
# the instruction files; this class only takes strings.
my $context = LLM::Agent::RunContext.new(
head-sections => [
identity => 'You are a coding assistant working in a checked-out repository.',
instructions => LLM::Agent::Prompt::instructions-from-files(['AGENTS.md']),
],
facts => [
platform => 'darwin (arm64)',
date => Date.today.Str,
cwd => $*CWD.Str,
'git-head' => $head,
],
tail-sections => [
reminders => "The test suite is `prove6 -I. t`.",
],
);
my $run = $loop.run(@messages, :$context);
The request that goes out is < [head, |@messages, tail] >; @messages
itself is untouched, and so is the transcript. The tail reads:
## Current context
- platform: darwin (arm64)
- date: 2026-08-10
- cwd: /srv/project
- git-head: 4f2c9ab
The test suite is `prove6 -I. t`.
This block supersedes any environment description, tool catalogue or
project instructions that appear earlier in this conversation; where they
disagree, this block is authoritative.
This block is context, not a request: do not acknowledge it, and do not
reply to it.
DESCRIPTION
A system prompt has two halves with completely different lifetimes. One
is history: what the agent is, what the human asked, what the tools
said. The other is context: today's date, the working directory, the
branch, the contents of AGENTS.md. The first belongs in the
conversation, is written to the transcript and must never change under a
resumed run ā the loop's seed check exists to enforce exactly that. The
second is true only while it is true.
Baked together into one sticky message at index 0 they become the same thing, and the durable one wins: a session resumed in October replays August's date, August's tool catalogue and August's project instructions, for as long as the transcript lives.
A RunContext is the second half, kept out of the conversation
entirely. LLM::Agent::Loop renders it into the request ā and only
the request ā for the run it was handed to:
@conversationnever contains it, so the seed check, the session appends, LLM::Agent::Compactor and< %outcome<messages> >all see exactly what they saw before;nothing about it is digest-locked, so the next run may say something completely different without a resumed run being refused;
what it said is still recorded ā the loop writes one
run-contextenvelope per run (see LLM::Agent::Session), so a transcript can still answer "what was this agent told, that day?".
Facts are Pairs, and that is not a style choice
facts is a List of Pairs, in the order they should be rendered. A
Hash is refused at construction, and the reason is worth spelling out
because the bug it prevents is close to undetectable:
Raku randomises Hash iteration order per process. Rendering facts
straight out of a Hash would therefore produce a different string in every
process ā quietly defeating any prompt cache keyed on the text ā while
.digest, which is canonical JSON with sorted keys, stayed
byte-identical. Two processes would render two different prompts, agree
that they were the same context, and nothing anywhere would disagree.
So the order is the caller's, explicitly:
# Right: rendered in this order, every process, every time.
facts => [ date => $today, cwd => $cwd ]
# Refused at construction, with that explanation.
facts => { date => $today, cwd => $cwd }
# Also refused, and told what it really is: parentheses around one Pair
# are that Pair, not a list of one. The fix is a comma (or brackets).
facts => (date => $today)
An undefined value is dropped rather than rendered as an empty string ā
the same rule LLM::Agent::Prompt's env-block follows, so an optional
fact can be passed as an undefined variable with no conditional at the
call site. A fact whose value is not a Str and not undefined is refused:
stringifying a Hash into a prompt is never what the caller meant.
Head and tail, and why the split is not cosmetic
Sections come in two lists, and they land on opposite sides of the conversation:
| List | Where it goes | What belongs in it |
|---|---|---|
| head-sections | before message 0 | identity, instructions ā stable across turns |
| tail-sections | after the last | anything volatile, and anything that must win |
The split is about prefix caching. Every backend worth using caches the KV prefix of a request and charges less for the part it did not have to re-prefill; the cache holds up to the first byte that differs from last time. A block that changes every turn ā a clock, a git HEAD, a list of open files ā placed near index 0 therefore invalidates the whole conversation on every single request. The same block at the end costs only itself.
So: put the prose that is stable for the life of the session in the head, and everything that moves in the tail. Facts always render in the tail, because facts are what move.
The tail also has the last word, which is the other half of the argument: a model reading a fifty-turn conversation weights what it read most recently, and "the date is October 3rd" arriving after fifty turns of August is more likely to be believed than the same sentence at the top.
What the tail says, and why
Beyond the facts and the tail sections, the block ends with two fixed lines, and both are load-bearing:
the supersedes line ā the block outranks any environment description, tool catalogue or project instructions that appear earlier in the conversation, and where they disagree it is authoritative. That is what makes a legacy transcript ā one whose index 0 is a fat system prompt from before this class existed ā safe to resume: the stale environment block is still in the conversation, and the model has been told, at the end, which one to believe.
the not-a-request line ā the block is context, not an instruction to respond to. Without it a model that has just been handed a list of facts will cheerfully reply "Thanks! I see you are on branch main" instead of answering the question.
What is normalised, and what is not
Trailing whitespace comes off every value at construction, so the string
this class stores, the string it renders and the string it digests are
one string. That is what keeps an AGENTS.md that gained a final
newline from counting as a section that changed ā and therefore from
being stored a second time in the transcript.
Nothing else is touched. A fact value with a newline in the middle of it renders as two lines of the block, because a class that quietly rewrote what it was told to say would be a worse problem than an ugly bullet.
Nothing is inferred, and nothing is re-read
Every value is a Str the caller passed in. This class never calls
Date.today, never looks at $*CWD, never runs git and never reads
a file: it does not know what a fact means, and an agent whose tools run
somewhere other than where the prompt says they do has been lied to by a
layer that had no way of knowing better.
That also makes it deterministic: the same arguments produce the same
strings and the same digest, in every process. All of the rendering
happens once, at construction, so .head-message and .tail-message
cost nothing to ask for repeatedly and cannot change under a run that is
already using them.
The digest, and the per-section digests
.digest is a canonical hash (LLM::Agent::Canonical) over the facts
and both section lists, order included. Two contexts with the same digest
are the same context; the loop uses it to decide whether a calibrated
token count from the previous run still describes anything (see
LLM::Agent::TokenCount's invalidate), and the session records it so
a transcript can say which runs shared a context.
The same digest decides what the context costs: LLM::Agent::Loop
weighs the two rendered messages once per run, as text, through its own
counter ā not the per-model counter a LLM::Agent::RequestBudget
Profile may carry, which counts only the conversation. The context is
priced before a backend has been chosen, the way the tool declarations
are.
.sections is the same information one section at a time ā name,
digest and rendered ā head sections first, then tail. That is what
LLM::Agent::Session's append-run-context writes, and what lets a
transcript store one copy of an AGENTS.md that did not change between
forty runs instead of forty copies of it.
Empty is empty
An empty section is dropped, exactly as LLM::Agent::Prompt's
assemble drops one, so a caller can pass
< instructions-from-files(@paths) > without first checking whether any
of the paths existed.
If every head section is empty, .head-message is an undefined
Message and no head message is sent at all. If there are no facts and
no non-empty tail sections, the same is true of .tail-message ā the
supersedes boilerplate on its own is not worth a message, and a context
with nothing in it costs a request nothing.
my $nothing = LLM::Agent::RunContext.new;
$nothing.head-message.defined; # False
$nothing.tail-message.defined; # False ā the request is unchanged
SEE ALSO
LLM::Agent::Loop (< run(@messages, :$context) >),
LLM::Agent::Session (the run-context envelope),
LLM::Agent::Prompt (where the section strings usually come from),
LLM::Agent::Canonical (the digests).