Pre-release Harn is pre-1.0 — the language, standard library, and CLI may change between releases. See the release notes

Prompt assembly

Every system prompt Harn sends to a model is the deterministic reduction of an ordered list of fragments, unless the caller selects an exclusive replacement root. The public system option supplies a string, an ordered {content, title?, position?, enabled?} list, or {mode: "replace", content: string}. In composed mode, those fragments, the agent's primary system text, capability-gated tool guidance, project context profiles, and rendered system reminders all flow through one reducer. There is no parallel string-concatenation path, and there is no place where an instruction is glued on by hand and silently drifts from the rest of the prompt.

Because assembly is a single reduction, it is also fully auditable: the runtime can answer "why is this sentence in the prompt?" and "what would the prompt look like without tool X?" without anyone reverse-engineering a concatenation.

Each provider-bound call carries that reduction as a typed harn.llm.context_manifest.v1 value. The transcript retains it in a deduplicated context_manifest event before provider_call_request; the request's served_context.manifest_content_hash resolves to those exact retained manifest bytes. A stale or contradictory manifest fails the call before the request is journaled.

Exclusive replacement root

Use an exclusive root when the caller needs the supplied bytes to be the entire system prompt:

agent_loop(task, nil, {
  system: {
    mode: "replace",
    content: "You are the complete system prompt.",
  },
})

Replacement is structural, not a winning fragment or a list of fields to blank. It bypasses ordinary fragment reduction and suppresses the positional system argument, fragment lists, agent-loop contracts, context-profile fragments, skill and tool guidance, provider thinking directives, and conversation-level system/developer messages. The content bytes are not trimmed or joined. Tools and ordinary user/assistant history remain available; this contract replaces the system channel, not the capability or conversation surface.

prompt_explain reports root: "replacement" and exactly one included replacement fragment. At the live boundary, llm_mock_calls()[n].system provides the served-byte proof.

Fragments

A fragment is one contributor to the system string:

fieldmeaning
idstable identifier, e.g. host:system_before, primary:active_skills, tool:todo.guidance
source / producerwho contributed it (host:*, primary, reminder, tool:<name>)
bucketbefore (preamble … primary … reminders) or after (appendix/suffix and tail recitations)
requires_toolsincluded only when every named tool is in the active tool set
requires_capsincluded only when every named capability flag is set
bodythe already-rendered text; trimmed, and skipped if empty

The retained projection adds included, reason, bytes, and a redacted blake3: digest for every segment. Included segment bytes plus the documented blank-line separator bytes must exactly equal system_prompt_bytes; whole_prompt_digest must equal the served-context system-prompt digest. Duplicate included (id, digest) pairs and replacement roots containing any extra segment are rejected.

The reducer emits all included before fragments in declaration order, then all included after fragments, joined by a blank line.

The primary block is decomposed

The agent's per-turn primary system text is itself a composite — the base system prompt, MCP advisory context, active skills, the skill catalog, the progress-tool nudge, and the loop/tool contracts. Rather than glue these into one opaque primary string, agent_loop hands the assembler each part as its own fragment through the internal _system_fragments channel, so every part is traced on its own (primary:system, primary:active_skills, primary:loop_contract, …) and can be gated with requires_tools independently. Those internal fragments may also set bucket: "after" when a live recitation must land at the prompt tail. Joining the fragment bodies with a blank line reproduces the single string the legacy path produced, so the assembled prompt is unchanged — only its provenance is finer-grained. agent_build_turn_system_fragments is the stdlib helper that emits the list; agent_build_turn_system is the thin wrapper that joins it.

Tool guidance rides with the tool

The drift-proof way to attach an instruction to a tool is to co-locate it with the tool definition. Any tool that declares a guidance string auto-contributes a fragment gated on that tool's own presence:

tool_define(registry, "todo", "Add, update, and complete plan items", {
  parameters: { /* … */ },
  handler: todo_handler,
  guidance: "When working from a plan or task list, always update the TODO tracker after each item.",
})

When todo is in the active tool set, the instruction appears. Drop the tool and the instruction vanishes — the two share one source of truth and cannot drift. This is the canonical answer to "tell the model to use the TODO tracker, but only when a TODO tool is actually available." No if, no hand-maintained list of tool names in prose.

Guidance is prompt-side metadata: it is rendered into the system prompt but is never sent to the provider as part of the tool's schema.

Project profiles are fragments too

project_context_profile resolves project signals such as Git remotes, language/build files, supplied code-librarian signals, and available credential aliases into reducer-ready fragments. Agent preflight forwards context_profile.prompt_fragments through _system_fragments; direct llm_call and prompt_explain users pass context_profile in options.

Each profile fragment is capability-gated. For example, a Rust profile fragment carries requires_caps: ["language.rust"]; prompt_explain(...) then records the profile:rust trace with the capability reason instead of making the project guidance an opaque always-on paragraph.

Inspecting the assembled prompt

prompt_explain(options) assembles the system prompt from the same options you would hand agent_loop and returns the final string plus a provenance record for every fragment — included or excluded, with the reason and byte count:

const explained = prompt_explain({
  system: "You are a pragmatic engineering partner.",
  tools: [
    { name: "todo", description: "Track plan items", guidance: "Always update the TODO tracker." },
    { name: "read", description: "Read a file" },
  ],
})
// explained.system     → the assembled string
// explained.root       → "composed" (or "replacement")
// explained.fragments  → [{ id, source, producer, bucket, included, reason,
//                           bytes, digest }, …]
// explained.whole_prompt_digest / system_prompt_bytes → whole-prompt receipt
// explained.included / explained.excluded → counts

Each fragment's reason tells you exactly why it is or isn't present — for example tool(s) present: todo for a guidance fragment that made it in, or requires tool \todo` (not available)for one gated out. The bundledharn demo prompt-guidance` scenario assembles a prompt with and without a tool and prints the provenance side by side.

Session reminders are layered at live call time from the active session, so prompt_explain shows the static system prompt (host parts + primary + capability-gated tool guidance). See System reminders for the dynamic, turn-boundary layer that composes into the same reducer at call time.

The manifest accounts for the top-level system-prompt channel. Conversation messages are explicitly marked served_context_digest_only and remain covered by served_context.messages_content_hash; their per-message assembly is not claimed here. Retained manifests name their boundary as llm_request_payload_egress, after capability-derived directives and route-specific system placement have produced the send-safe payload. When that conversion changes the system bytes, the manifest carries an egress:system segment and an egress_delta joining the options-side and payload-side byte counts and digests. Provider adapter transforms and wire serialization below LlmRequestPayload remain outside the contract. Every Harn logical LLM entry point flows through observed_llm_call, and the raw prepared single-route transport primitives require an observer-issued attempt token. Compaction, rerank, workflow, fallback, and streaming provider attempts therefore cannot silently bypass the manifest spine. Digests are computed after the active transcript redaction policy. Readers continue to accept historical manifests whose boundary is observed_llm_call_pre_egress.

Set call_role when a harness needs to distinguish semantic callers, for example model.router. Harn-owned paths already declare the same purpose vocabulary through mock_scope (agent.main, compaction, completion.judge, step.judge, and the guardrail/classifier roles); production telemetry and fixture routing project that one declaration. Supplying both keys with different values is an error, and call_role alone also routes mock fixtures to the same scope. An undeclared purpose is retained as unattributed, never inferred from session_id. Both the manifest and provider_call_request retain the resolved role and the current delegated-worker actor_chain, when one exists.

Relationship to other primitives

  • System reminders are the dynamic subset of fragments: they carry a lifecycle (TTL, dedupe, preserve-on-compact) and are injected at turn boundaries, but they reduce through the same assembler.
  • Prompt templating (.harn.prompt) renders a fragment's body; assembly decides whether and where that body appears.
  • Capability-adaptive rendering chooses the wire format of the assembled prompt per model capability; fragment assembly chooses which content is present.