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:

  • @conversation never 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-context envelope 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).

LLM::Agent v0.6.1

a streaming agent loop: tools, retry, fallback, a durable

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

Digest::SHA256::Native:ver<1.0.0+>:auth<zef:bduggan>LLM::Chat:ver<0.10.0+>:auth<zef:apogee>MCP::Client:ver<0.5.0+>:auth<zef:apogee>JSONL:ver<0.1.6+>:auth<zef:apogee>JSON::Fast:ver<0.19+>:auth<cpan:TIMOTIMO>UUID::V4:ver<1.0.0+>:auth<zef:masukomi>

Test Dependencies

Provides

  • LLM::Agent
  • LLM::Agent::Artifacts
  • LLM::Agent::Canonical
  • LLM::Agent::Compactor
  • LLM::Agent::CompletionBus
  • LLM::Agent::Event
  • LLM::Agent::Loop
  • LLM::Agent::Prompt
  • LLM::Agent::RequestBudget
  • LLM::Agent::Run
  • LLM::Agent::RunContext
  • LLM::Agent::Session
  • LLM::Agent::Subagents
  • LLM::Agent::TokenCount
  • LLM::Agent::ToolOperation

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite — the markup and publishing tools behind this site.