LLM::Agent::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 file 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.
The platform and the OS are looked up once, on the first call, and kept for the life of the process ā neither changes under a running process, and an app that renders its environment every turn should not pay to ask again. The date is read on every call, so a session that runs past midnight reports the new day.
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).