pleach
Build

Prompt builder

`composeBudgetedPrompt` — the deterministic composer that turns resolved contributions into the final system prompt with quantized budget math.

The prompt builder is the second half of the prompts thematic island — paired with the contribution registry. 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-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 for the contribution contract feeding the composer.

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";
Subpath@pleach/core/prompt-builderSourcesrc/prompt-builder/

The composer signature

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

Most agent code doesn't. The runtime composes the system prompt internally at each LLM call — contributions registered through plugins 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

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.
TierDrop behavior
coreNever 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.
activePreserved while within the active-tier ceiling.
informationalPreserved 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

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.
const config: BudgetConfig = {
  depth: 1,
  resolveBudget: ({ depth }) =>
    depth === 0 ? 60_000 : depth === 1 ? 40_000 : 20_000,
};

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

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

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.

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

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.

HelperDefault 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

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.

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

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

BuilderProduces
buildExhaustedToolsSection"Tools you've already tried this turn" recap
buildToolGuidanceSectionInline guidance for tool-error recovery
buildRecentToolErrorsSectionBounded list of recent tool errors
buildPriorTruncationRecoverySectionRecovery prompt when the prior turn was truncated
buildProviderErrorRecoverySectionRecovery prompt when the provider errored last turn
buildUserRequestedToolsSection"User asked for these tools" recap

The guidance subsystem is registry-driven:

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

@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

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.

appendPrompt(prompt)

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)

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)

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

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

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({ ... })

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

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.

Where to go next

On this page