# Prompt builder (/docs/prompt-builder)



The prompt builder is the second half of the **prompts**
[thematic island](/docs/concept-clusters#what-lives-outside-the-cluster-pattern) —
paired with the [contribution registry](/docs/prompts). The
registry collects typed contributions; the builder composes them.

`composeBudgetedPrompt` is the last step of prompt construction.
It takes an ordered candidate list, applies a tier gate against
the resolved budget, and joins the included sections with `\n\n`.
Two calls with the same candidates and the same budget produce
byte-identical output — that's what makes
[fingerprint](/docs/fingerprint)-based caching and
`@pleach/eval@0.1.0` work.

The active-tier ceiling uses `Math.floor` to avoid floating-point
drift between platforms. The composer never lets you see fractional
budgets; every section's allocation is an integer. See
[Prompts](/docs/prompts) for the contribution contract feeding the
composer.

```typescript
import {
  composeBudgetedPrompt,
  applyBudgetGate,
  getEffectiveBudget,
  shouldCondense,
  skipInformational,
  wouldFit,
  buildPromptBuiltEvent,
  DEFAULT_ACTIVE_BUDGET_FRACTION,
  DEFAULT_CONDENSE_DEPTH,
  DEFAULT_SKIP_INFORMATIONAL_DEPTH,
} from "@pleach/core/prompt-builder";
import type {
  BudgetConfig,
  BudgetedSection,
  BudgetResolver,
  ComposedPrompt,
  PromptBuiltEvent,
  PromptTier,
} from "@pleach/core/prompt-builder";
```

<SourceMeta subpath="@pleach/core/prompt-builder" source="{ label: &#x22;src/prompt-builder/&#x22;, href: &#x22;https://github.com/pleachhq/core/tree/main/src/prompt-builder&#x22; }" />

## The composer signature [#the-composer-signature]

```typescript
function composeBudgetedPrompt(
  candidates: readonly BudgetedSection[],
  config: BudgetConfig,
  probes?: { onBuilt?: (event: PromptBuiltEvent) => void },
): ComposedPrompt;

interface BudgetedSection {
  readonly id:      string;
  readonly content: string;
  readonly tier:    "core" | "active" | "informational";
  readonly chars:   number;  // canonically content.length
}

interface BudgetConfig {
  readonly depth:                number;
  readonly runtimeRole?:         keyof RuntimeRoles;
  readonly resolveBudget?:       BudgetResolver;
  readonly totalBudget?:         number;
  readonly activeBudgetFraction?: number;  // default 0.95
}

interface ComposedPrompt {
  readonly composed:         string;     // included sections joined by "\n\n"
  readonly included:         readonly BudgetedSection[];
  readonly dropped:          readonly string[];  // ids of dropped candidates
  readonly totalChars:       number;
  readonly totalTokensEst:   number;     // Math.ceil(totalChars / 4)
  readonly effectiveBudget:  number;
  readonly budgetUsedPct:    number;
  readonly sectionChars:     Readonly<Record<string, number>>;
}
```

The caller picks section ordering; the composer never reorders.
`composed` is `included.map(s => s.content).join("\n\n")` —
byte-identical inputs produce byte-identical output.

## When you call it directly [#when-you-call-it-directly]

Most agent code doesn't. The runtime composes the system prompt
internally at each LLM call — contributions registered through
[plugins](/docs/plugin-contract) ride through the hooks and the
composer assembles them.

Call `composeBudgetedPrompt` directly when you're building a custom
provider and need the composed prompt outside the standard seam,
writing prompt-introspection tooling (DevTools panels, prompt
diffing), or testing prompt assembly in isolation.

## Composition order and the tier gate [#composition-order-and-the-tier-gate]

The composer makes one pass over `candidates` and decides
inclusion per section by `tier`:

1. Sum `coreChars` across all `core`-tier candidates.
2. `activeCeiling = coreChars + Math.floor((budget - coreChars) * activeBudgetFraction)`.
3. For each candidate in order:
   * `core` → always include.
   * `active` → include if `totalChars + chars <= activeCeiling`.
   * `informational` → include if `totalChars + chars <= budget`.

| Tier            | Drop behavior                                                                                                                                             |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `core`          | Never dropped. If a `core` section can't fit, the composition still includes it and the budget overflows; the caller decides whether to surface as error. |
| `active`        | Preserved while within the active-tier ceiling.                                                                                                           |
| `informational` | Preserved while strictly within the remaining budget. First to drop under pressure.                                                                       |

The active-ceiling-via-remaining-after-core computation matters:
a large core base (\~60K chars in production hosts) consuming a
fraction of the raw budget would otherwise starve the active
tier even when active sections total only \~2K chars.

## Budget resolution [#budget-resolution]

`getEffectiveBudget(config)` is the function the composer
consults. Precedence:

1. `config.totalBudget` — direct override; useful for tests.
2. `config.resolveBudget({ depth, runtimeRole })` — closure mapping config to budget.
3. `Number.POSITIVE_INFINITY` — pass-through; every candidate is included.

```typescript
const config: BudgetConfig = {
  depth: 1,
  resolveBudget: ({ depth }) =>
    depth === 0 ? 60_000 : depth === 1 ? 40_000 : 20_000,
};
```

## Budget quantization [#budget-quantization]

The active-ceiling computation uses `Math.floor` to avoid
floating-point drift between platforms. Two callers with the same
candidates and the same budget produce byte-identical output even
when float arithmetic would otherwise diverge.

### Why the floor matters in practice [#why-the-floor-matters-in-practice]

Replay-deterministic caching depends on byte equality. If
session A composes a prompt at `0.7833333` tokens-per-section
and session B at `0.7833334` (rounding difference from float
arithmetic), the resulting prompts differ by one token boundary,
the fingerprints diverge, and the cache misses.

The composer never lets you see fractional budgets — every
section's allocation is an integer. The user-facing budget
contract is "I gave you `maxTokens`; you give me deterministic
bytes."

## Dropped sections [#dropped-sections]

When candidates exceed the budget, the gate drops sections from
the lowest-tier end. `result.dropped` lists the ids that didn't
make it in; `result.sectionChars` records the char count for each
included section by id.

```typescript
const result = composeBudgetedPrompt(candidates, {
  depth: 0,
  totalBudget: 2_000,  // tight
});

if (result.dropped.length > 0) {
  console.warn(`Dropped ${result.dropped.length} sections:`, result.dropped);
}
```

`core`-tier sections are never dropped — if they overflow the
budget, the composition includes them anyway and the caller
decides what to do with the overflow.

## Depth-based predicates [#depth-based-predicates]

Two helpers expose the same depth threshold the host's default
composition path uses. The composer itself doesn't consult them —
they're for callers building the candidate list.

| Helper                            | Default threshold                               |
| --------------------------------- | ----------------------------------------------- |
| `shouldCondense(depth)`           | `depth >= DEFAULT_CONDENSE_DEPTH` (2)           |
| `skipInformational(depth)`        | `depth >= DEFAULT_SKIP_INFORMATIONAL_DEPTH` (3) |
| `wouldFit(section, used, budget)` | `used + section.chars <= budget`                |

## `buildPromptBuiltEvent` and the `probes` hook [#buildpromptbuiltevent-and-the-probes-hook]

Pass `probes.onBuilt` to fire a structured event once per
composition. The composer hands you a `PromptBuiltEvent` you can
forward to your logger without re-deriving section sizes.

```typescript
const result = composeBudgetedPrompt(candidates, config, {
  onBuilt: (event) => eventLog.write({ type: "prompt.built", ...event }),
});

interface PromptBuiltEvent {
  sections:        number;
  totalChars:      number;
  totalTokensEst:  number;
  budgetUsedPct:   number;
  effectiveBudget: number;
  sectionChars:    Record<string, number>;
  dropped?:        readonly string[];
  droppedChars?:   Record<string, number>;
}
```

`buildPromptBuiltEvent` is also exported directly for callers
that want to construct the event shape outside the composer.

## Reference section builders [#reference-section-builders]

`@pleach/core/prompt-builder/sections` ships generic builders for
common runtime-state-driven sections. Each returns a string body
the caller wraps as a `BudgetedSection`.

| Builder                               | Produces                                            |
| ------------------------------------- | --------------------------------------------------- |
| `buildExhaustedToolsSection`          | "Tools you've already tried this turn" recap        |
| `buildToolGuidanceSection`            | Inline guidance for tool-error recovery             |
| `buildRecentToolErrorsSection`        | Bounded list of recent tool errors                  |
| `buildPriorTruncationRecoverySection` | Recovery prompt when the prior turn was truncated   |
| `buildProviderErrorRecoverySection`   | Recovery prompt when the provider errored last turn |
| `buildUserRequestedToolsSection`      | "User asked for these tools" recap                  |

The guidance subsystem is registry-driven:

```typescript
import {
  createGuidanceRegistry,
  resolveGuidance,
  estimateGuidanceChars,
  DEFAULT_RECENT_TOOL_ERRORS_LIMIT,
} from "@pleach/core/prompt-builder";

const registry = createGuidanceRegistry([
  { match: { toolName: "search_*" }, mode: "prefix", text: "Always cite sources." },
]);

const resolved = resolveGuidance({ registry, toolName: "search_corpus" });
```

`GuidanceEntry`, `GuidanceMatchMode`, `GuidanceRegistry`,
`RecentToolErrorEntry`, `ResolveGuidanceParams`, and
`ResolvedGuidance` are the typed surfaces.

## Reference assemblies [#reference-assemblies]

`@pleach/core/prompt-builder/examples` ships reference assemblies
that exercise every branch of the composer. Crib from these when
building your own; they're the substrate's own tests, so they
round-trip through replay.

## Friendly API: appendPrompt / prependPersona / replaceCore / scopedPrompt / gatedPrompt / createPlugin [#friendly-api-appendprompt--prependpersona--replacecore--scopedprompt--gatedprompt--createplugin]

These helpers wrap the underlying `contributePrompts` and
`contributeRuntimeAwarePrompts` hooks for the common composition
shapes. Import them from `@pleach/core/prompts`. They return
contribution objects you hand to a `HarnessPlugin`; the contract
itself lives at [plugin contract](/docs/plugin-contract).

### `appendPrompt(prompt)` [#appendpromptprompt]

Appends content after the existing composed prompt at its slot.
Use when your plugin adds context the runtime should see last,
without disturbing prior contributions.

### `prependPersona(persona)` [#prependpersonapersona]

Places persona content at the top of the persona slot, ahead of
other persona contributions. Use when your plugin needs to set
the voice before any downstream persona contribution layers on.

### `replaceCore(replacement)` [#replacecorereplacement]

Replaces the bundled core fragment for a given fragment id. Use
sparingly — the bundled core is what gives the runtime its
baseline behavior, and replacing a fragment opts you out of any
future improvement to that fragment.

### `scopedPrompt({ scope, content })` [#scopedprompt-scope-content-]

Contributes a prompt conditionally — only when the runtime
context matches the scope. Scopes typically resolve against a
channel, a tenant, or a runtime role; the composer skips the
contribution entirely when the scope doesn't match.

### `gatedPrompt({ when, content })` [#gatedprompt-when-content-]

Conditional contribution gated by a predicate evaluated per turn.
Use when inclusion depends on per-turn state — recent tool
errors, prior truncation, an in-flight async job — rather than
on static scope.

### `createPlugin({ ... })` [#createplugin--]

Sugar for assembling a `HarnessPlugin` from a flat object of
contributions. Useful when the plugin doesn't need lifecycle
hooks and you're only wiring up prompt contributions.

## `core.*-template` fragments [#core-template-fragments]

The bundled core ships around 30 additive `core.*-template`
fragments. Identifiers include `core.response-stylesheet-defaults`,
`core.role-tool-allowlist-template`, `core.async-job-lifecycle-template`,
`core.job-history-template`, `core.rules-condensed-template`,
`core.role-condensed-template`, and `core.export-rules-template`.

These fragments exist on `ALL_CORE_FRAGMENT_TEMPLATE_IDS` but the
runtime does not auto-compose them into the base prompt. Hosts
opt in by contributing a replacement for the matching id via
`replaceCore`, or through `contributePrompts` with `mode: "replace"`.

The shape is an additive-scaffold protocol. A fragment id can
land in the core surface without affecting any host that hasn't
opted in. New ids are pure additions — no consumer breaks when
the bundled core grows a new template slot.

For the full identifier list, read
`ALL_CORE_FRAGMENT_TEMPLATE_IDS` from the bundled core source at
[github.com/pleachhq/core](https://github.com/pleachhq/core).

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Prompts" href="/docs/prompts" description="The contribution contract — static vs runtime-aware, namespacing, scope." />

  <Card title="Fingerprint" href="/docs/fingerprint" description="Why the composer's determinism armor matters." />

  <Card title="Safety policies" href="/docs/safety" description="The contributions that compose last with reserved budget." />

  <Card title="Determinism" href="/docs/determinism" description="The replay-determinism story end-to-end." />
</Cards>
