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";@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:
- Sum
coreCharsacross allcore-tier candidates. activeCeiling = coreChars + Math.floor((budget - coreChars) * activeBudgetFraction).- For each candidate in order:
core→ always include.active→ include iftotalChars + chars <= activeCeiling.informational→ include iftotalChars + 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
getEffectiveBudget(config) is the function the composer
consults. Precedence:
config.totalBudget— direct override; useful for tests.config.resolveBudget({ depth, runtimeRole })— closure mapping config to budget.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.
| 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
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.
| 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:
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.