import { FILESYSTEM_TOOLS } from '../../constants/tools/index.js' import { assembleSystemPrompt, renderSkillsSection } from '../../persona/assembler.js' import { type PromptContributionContext, type PromptContributionRegistry, SKILLS_CONTRIBUTION_ID, } from '../../prompt/contributions.js' import type { AgentRuntimeContext } from '../../types/agent/base.js' import type { AgentContextLevel } from '../../types/agent/factory.js' import type { AgentPersona } from '../../types/persona/index.js' import type { Skill } from '../../types/skills/index.js' import type { ToolRegistryContract } from '../../types/tool/index.js' export interface PromptSegments { /** Layers 1-6: basePrompt, persona identity/expertise/reflexes/skills/outputDiscipline. Stable within a turn. */ readonly static: string /** Layers 7-10: tools, tier guidance, env context, sessionContext. May change per turn. */ readonly dynamic: string } export interface PromptBuilderConfig { systemPrompt?: string persona?: AgentPersona skills?: Skill[] basePrompt?: string tools: ToolRegistryContract allowedTools?: string[] runtimeContext?: AgentRuntimeContext /** * What else goes in the prompt, beyond what this builder knows about. * * Absent means exactly what it meant before this existed: the fixed * list. A capability that needs the model to know something registers * here instead of arguing for a branch or splicing into `systemPrompt` * and losing whatever was there. */ contributions?: PromptContributionRegistry } function buildEnvContext(workingDirectory: string, config: PromptBuilderConfig): string { const { runtimeContext, tools, allowedTools } = config const lines = [ ` Working directory: ${workingDirectory} Platform: ${process.platform}`, ] if (runtimeContext?.label) { lines.push(`Runtime: ${runtimeContext.label}`) } if (runtimeContext?.outputDirectory) { lines.push(`Output directory: ${runtimeContext.outputDirectory}`) } if (runtimeContext?.outputFileMarker) { lines.push(`Output file marker: ${runtimeContext.outputFileMarker}`) } if (runtimeContext?.notes?.length) { lines.push('Runtime notes:') for (const note of runtimeContext.notes) { lines.push(`- ${note}`) } } lines.push(` Resolve relative paths against the working directory above; use supplied absolute paths as given.`) if (hasFilesystemTools(tools, allowedTools)) { lines.push( 'When file contents are needed, read known file paths directly. Discover unknown paths with available filesystem tools using targeted directories and patterns.', ) const canGlob = tools.has('glob') && (!allowedTools || allowedTools.includes('glob')) && tools.getAvailability('glob') === 'active' lines.push( canGlob ? "For an immediate directory overview, use glob with pattern '*' or an available directory listing tool. Use '**' only when recursive discovery is needed." : 'For an immediate directory overview, use an available directory listing tool.', "Keep discovery within the task's scope; do not recursively inventory unrelated home directories or cache trees.", ) } return lines.join('\n') } /** * Does this turn have a tool that needs to know where it is? * * Decided from what a tool DECLARES — `category: 'filesystem'` or a * `file_read` / `file_write` permission — not from its name. * * The name set alone was a real hole: a host registering a perfectly * well-formed filesystem tool called `read_file` got no `` block at * all, so the model was never told the working directory and the host had * to hand-encode the paths into its system prompt. The structured facts * were right there on the `ToolDefinition` and the gate ignored them in * favour of the four built-in names. * * The name set is kept as a fallback for tools that declare neither. */ function hasFilesystemTools(tools: ToolRegistryContract, allowedTools?: string[]): boolean { const activeTools = allowedTools ?? tools.listNames() return activeTools.some((name) => { if (FILESYSTEM_TOOLS.has(name)) return true const tool = tools.get?.(name) if (!tool) return false if (tool.category === 'filesystem' || tool.category === 'shell') return true return Boolean( tool.permissions?.some( (p) => p === 'file_read' || p === 'file_write' || p === 'shell_execute', ), ) }) } export class PromptBuilder { private config: PromptBuilderConfig constructor(config: PromptBuilderConfig) { this.config = config } /** * The context every contribution is rendered against. * * `workingDirectory` is a call argument rather than config, so it is * threaded rather than read off `this`. */ private contributionContext(workingDirectory?: string): PromptContributionContext { return { ...(workingDirectory === undefined ? {} : { workingDirectory }), ...(this.config.runtimeContext ? { runtimeContext: this.config.runtimeContext } : {}), ...(this.config.skills ? { skills: this.config.skills } : {}), ...(this.config.allowedTools ? { allowedTools: this.config.allowedTools } : {}), } } /** * Skills, once, in the slot they were always in. * * The registry renders at the END of the prompt, and skills belong where * they have always been: immediately after the persona or system prompt. * So the built-in contribution is rendered IN PLACE here rather than * being left to the tail — a host that registers it gets the seam, not a * reordered prompt. * * This is also what makes it a real contributor rather than a decorative * one. Written first as "the builder renders skills, and the registry * skips its own copy", which a mutation caught immediately: the * contribution could be deleted with no observable effect, because the * builder's own branch rendered skills either way. A seam whose first * consumer is inert proves nothing about the seam. * * The persona branch is the exception and stays one: * `assembleSystemPrompt(persona, skills)` places skills inside the * persona's own section ordering, relative to constraints and output * discipline. Routing it through here would silently reorder every * persona-driven prompt in the estate. */ private renderSkillsInPlace(): string | null { const contribution = this.config.contributions ?.list() .find((c) => c.id === SKILLS_CONTRIBUTION_ID) if (contribution) return contribution.render(this.contributionContext()) return renderSkillsSection(this.config.skills) } /** * `static` and `dynamic` only, and the signature says so. * * A `turn` contribution rendered here would land in the system prompt — * cached for the turn under `static`, or read as a standing instruction * under `dynamic`. Either way the state it exists to report goes stale * silently, which is the exact failure `turn` was added to avoid. The * iteration loop renders those, into the ephemeral message. */ private renderContributions( placement: 'static' | 'dynamic', workingDirectory?: string, ): readonly string[] { const registry = this.config.contributions if (!registry) return [] const context = this.contributionContext(workingDirectory) return ( registry .list() .filter((contribution) => contribution.placement === placement) // Skills is rendered IN PLACE by `renderSkillsInPlace`, so the tail // must not render it again. Unconditional, not branch-dependent: // under a persona it is already inside the persona section, and // otherwise it is already in its own slot above. .filter((contribution) => contribution.id !== SKILLS_CONTRIBUTION_ID) .map((contribution) => contribution.render(context)) .filter((text): text is string => typeof text === 'string' && text.trim().length > 0) ) } build(contextLevel: AgentContextLevel = 'full', workingDirectory?: string): string { const parts: string[] = [] if (contextLevel === 'full' && this.config.basePrompt) { parts.push(this.config.basePrompt) } if (this.config.systemPrompt) { parts.push(this.config.systemPrompt) } else if (this.config.persona) { parts.push(assembleSystemPrompt(this.config.persona, this.config.skills)) } else { const skillSection = this.renderSkillsInPlace() if (skillSection) parts.push(skillSection) } if (this.config.systemPrompt) { const skillSection = this.renderSkillsInPlace() if (skillSection) parts.push(skillSection) } if (contextLevel !== 'minimal') { const toolSection = this.config.tools.toPromptSection(this.config.allowedTools) if (toolSection) { parts.push(toolSection) } const tierGuidance = this.config.tools.toTierGuidance() if (tierGuidance) { parts.push(tierGuidance) } } if (contextLevel !== 'minimal' && workingDirectory) { const shouldIncludeEnv = hasFilesystemTools(this.config.tools, this.config.allowedTools) || Boolean(this.config.runtimeContext) if (shouldIncludeEnv) { parts.push(buildEnvContext(workingDirectory, this.config)) } } // Static then dynamic, in that order and nothing between them, // because that is exactly how `buildSegmented` is rejoined upstream: // `${static}\n\n---\n\n${dynamic}`. The two methods produce the same // prompt for the same input, and a turn that hits the prompt cache // must not be asking a different question from one that misses it. parts.push(...this.renderContributions('static', workingDirectory)) parts.push(...this.renderContributions('dynamic', workingDirectory)) return parts.join('\n\n---\n\n') } buildSegmented( contextLevel: AgentContextLevel = 'full', workingDirectory?: string, ): PromptSegments { const separator = '\n\n---\n\n' const staticParts: string[] = [] const dynamicParts: string[] = [] if (contextLevel === 'full' && this.config.basePrompt) { staticParts.push(this.config.basePrompt) } if (this.config.systemPrompt) { staticParts.push(this.config.systemPrompt) } else if (this.config.persona) { const personaWithoutSession: AgentPersona = { ...this.config.persona, sessionContext: undefined, } staticParts.push(assembleSystemPrompt(personaWithoutSession, this.config.skills)) if (this.config.persona.sessionContext) { dynamicParts.push(`## Session Context\n${this.config.persona.sessionContext.trim()}`) } } else { const skillSection = this.renderSkillsInPlace() if (skillSection) staticParts.push(skillSection) } if (this.config.systemPrompt) { const skillSection = this.renderSkillsInPlace() if (skillSection) staticParts.push(skillSection) } if (contextLevel !== 'minimal') { const toolSection = this.config.tools.toPromptSection(this.config.allowedTools) if (toolSection) { dynamicParts.push(toolSection) } const tierGuidance = this.config.tools.toTierGuidance() if (tierGuidance) { dynamicParts.push(tierGuidance) } } if (contextLevel !== 'minimal' && workingDirectory) { const shouldIncludeEnv = hasFilesystemTools(this.config.tools, this.config.allowedTools) || Boolean(this.config.runtimeContext) if (shouldIncludeEnv) { dynamicParts.push(buildEnvContext(workingDirectory, this.config)) } } staticParts.push(...this.renderContributions('static', workingDirectory)) dynamicParts.push(...this.renderContributions('dynamic', workingDirectory)) return { static: staticParts.join(separator), dynamic: dynamicParts.join(separator), } } }