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

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.