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).

LLM::Agent v0.6.3

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.3+>:auth<zef:apogee>MCP::Client:ver<0.6.1+>:auth<zef:apogee>JSONL:ver<0.1.6+>:auth<zef:apogee>JSON::Fast:ver<0.21.0+>:auth<zef:timo>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::Revival
  • LLM::Agent::Run
  • LLM::Agent::RunContext
  • LLM::Agent::Session
  • LLM::Agent::Subagents
  • LLM::Agent::Test::Kit
  • LLM::Agent::TokenCount
  • LLM::Agent::ToolOperation
  • LLM::Agent::ToolRouting

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.