Prompt
NAME
LLM::Agent::Prompt - the four pieces almost every system prompt is made of
SYNOPSIS
use LLM::Agent::Prompt;
my $system = assemble(
identity => q:to/END/.trim,
You are a coding assistant working in a checked-out repository.
Prefer small, reviewable edits. Never commit.
END
sections => [
env-block(extra => { cwd => $*CWD.Str, branch => $branch }),
tool-docs($policy.tools-for-llm),
instructions-from-files([
'AGENTS.md',
$*HOME.add('.config/sadna/AGENTS.md').Str,
]),
],
);
my $run = $loop.run([$system, user-message($question)]);
Which produces one sticky system Message whose content reads:
You are a coding assistant working in a checked-out repository.
Prefer small, reviewable edits. Never commit.
## Environment
- platform: darwin (arm64)
- os: macos 26.3.1
- date: 2026-08-09
- branch: feature/agent-loop
- cwd: /srv/project
## Tools
### fs_read
Read a file from disk.
Parameters:
- path (string, required) - Absolute path of the file to read.
- limit (integer) - Maximum number of lines to return.
## AGENTS.md
Run the test suite with `prove6 -Ilib t/`...
DESCRIPTION
Four pure functions. They take what you give them, format it, and return
a string; assemble turns strings into the one Message that goes at
the top of a conversation. There is no configuration, no discovery, no
XDG lookup and no caching ā an app knows where its own instruction files
live and under what rules, and this module is deliberately too dumb to
have an opinion about it.
Nothing is inferred
env-block reports the platform, the OS and today's date, because those
are facts about the process that no caller can be expected to marshal.
Everything else in it comes from :%extra. It does not add the
current directory, the git branch, the project name or the user's shell:
an agent that is told "cwd: /srv/project" when the app actually runs tools
somewhere else has been lied to, and the app is the only layer that knows
the truth. Pass what you know; nothing else appears.
For the same reason instructions-from-files takes explicit paths.
Where an AGENTS.md may come from, whether a parent directory's copy is
merged, whether a user-level file outranks a project one ā those are
product decisions, and they belong to the app.
Determinism
Every function is deterministic given its inputs, apart from
env-block's date and platform lookups. :%extra keys are rendered in
sorted order rather than hash order, so two runs on the same machine on
the same day produce byte-identical prompts ā which is what makes prompt
diffs and cache keys meaningful.
SUBROUTINES
env-block(:%extra --> Str)
The ## Environment section: platform (kernel name and hardware),
os (distro name and version), date (local date, ISO format ā
what "today" means to the human the agent is working with), then every
pair of :%extra in sorted key order.
An extra whose key is one of the three built-ins replaces it, in
place: the caller is closer to the truth than a uname call, and an app
running an agent inside a container has a legitimate reason to say so.
Undefined extra values are dropped, so an optional fact can be passed as
an undefined variable without a conditional at the call site. Values are
stringified with .Str; a Hash value is not rendered as a nested block ā
flatten it yourself if you need one.
A platform lookup that fails (an exotic kernel with no hardware) yields
unknown for that part rather than throwing.
tool-docs(@tools --> Str)
The ## Tools section, rendered from the exact
< { type => 'function', function = { name, description, parameters } } >>
declarations tools-for-llm returns ā so it is documenting what the
model can really call, not a hand-maintained copy that drifts.
Per tool: an ### name header, the description on its own line, then
either Takes no arguments. or a Parameters: list of
- name (type, required) - description. A type given as a list
(["string", "null"]) is rendered string|null.
Parameters are ordered required first ā in the order the schema's
required array names them, which really is an ordered JSON array ā
then the rest alphabetically. Deliberately not the properties
order: that decodes to a plain Raku Hash whose iteration order is
randomised per process, so rendering it as it comes would give the same
tool a different system prompt on every run, and quietly defeat any
prompt cache keyed on the text.
This is a summary, not a substitute for the schema: the model still receives the full JSON Schema through the API's own tool declarations. Its job is to make a tool discoverable to a model reading its system prompt, and to give a backend with no native tool-calling something to work from.
Anything malformed is skipped rather than thrown over: an entry that is
not a hash, a function with no name, a parameters that is not an
object. An empty tool list yields the empty string (not an empty
## Tools header) so it can be passed to assemble unconditionally.
instructions-from-files(@paths --> Str)
Each path that exists and is a file, in the order given, under an
## <basename> header. Missing paths are skipped silently ā an optional
project convention file is expected to be absent.
A path that exists but cannot be read is not skipped: the exception propagates. That is a misconfiguration (a permission bug, a dangling symlink), and silently omitting the instructions the operator thinks the agent is following is worse than failing loudly.
Trailing whitespace is trimmed from each file; an empty file contributes its header and nothing else, which is a visible signal that it was found and had nothing to say.
assemble(Str:D :$identity!, :@sections --> Message)
The finished system message: $identity, then every non-empty section,
joined by blank lines. The Message comes back with role = 'system'>,
sysprompt = True> and sticky = True> ā which is what keeps
LLM::Agent::Compactor from ever summarizing away the agent's own
instructions.
Undefined and empty sections are dropped, so a call site can pass
tool-docs(@tools) without first checking whether there are any tools.
An empty $identity throws: an agent with no identity is a
misconfiguration, not a style choice.
What a baked prompt fossilizes
assemble produces a sticky message, and a sticky message that
reaches a session is written to the transcript, digest-locked by
LLM::Agent::Loop's seed check and replayed verbatim by every resume
afterwards. That is exactly right for identity, and exactly wrong for
everything env-block and instructions-from-files put beside it: a
transcript resumed in October opens with August's date, August's tool
catalogue and August's AGENTS.md, and nothing in the system will ever
correct it.
So: bake what is true for the life of the conversation, and pass what is
true today as a LLM::Agent::RunContext instead
(< $loop.run(@messages, :$context) >) ā same section strings, same
functions, rendered into the request per run rather than into the
conversation once. An app that has moved to a context entirely sends no
system message at all and calls assemble not at all;
instructions-from-files and env-block are just as useful feeding a
context's sections and facts.
SEE ALSO
LLM::Chat::Conversation::Message, LLM::Agent::Loop,
LLM::Agent::RunContext (the refreshable half, and why a baked prompt
cannot be it).