/** * prompts.ts — System prompt builder for agents. */ import type { EnvInfo } from "#src/session/env"; import type { ProjectContextLoader } from "#src/session/project-context"; import type { PromptInheritance } from "#src/types"; /** The prompt configuration a child is built from. */ export type AgentPromptConfig = { /** The agent's identifier, as the `` tag names it. */ name: string; /** The agent's standing instructions — the definition's body. */ instructions: string; }; /** The parent session's contribution to a child prompt, plus the cwd that text claims. */ export type InheritedPrompt = { /** The parent agent's effective system prompt. */ systemPrompt: string; /** The parent's working directory — the cwd its prompt footer names. */ cwd: string; /** * Which of the parent's contributions the child adopts as its identity. * Absent means `"full"`, the strategy every provider gets unless its * operator has said otherwise. */ strategy?: PromptInheritance; /** * The parent's operator-authored parts, for a `"portable"` child. May be * absent even then — the parent may have assembled no prompt yet, or have no * such parts. */ portablePrompt?: string; }; /** * Build the system prompt for an agent from its definition. * * The prompt is the inherited identity first, so it forms a leading prefix the * child shares with its parent across all subagent invocations. What that is * worth is host-dependent — see ADR 0008. * * The agent's standing instructions follow, wrapped in `` * when there are any. A definition has one prompt shape: there is no mode a * definition can select, and no frontmatter property that selects one. * * The `` bridge upstream carried in append mode was removed * in #890: its tool bullets duplicated the `promptGuidelines` Pi's own tools * contribute to every child's prompt, and it asserted them unconditionally — * telling a read-only child to use `edit` and `write` when it has neither. * * The prompt includes an `` tag so * downstream extensions (e.g. `@gotgenes/pi-permission-system`) can resolve * per-agent policy inside the child session by parsing the system prompt. * The tag follows the cacheable parent prefix. * * Only the parent prompt's identity is inherited — see `inheritedIdentity`. * * @param inherited The parent agent's effective system prompt and the cwd it names. * @param loadProjectContext Resolves a directory's project instructions, for a * child whose adopted identity carries none describing its own. */ export function buildAgentPrompt( config: AgentPromptConfig, cwd: string, env: EnvInfo, inherited?: InheritedPrompt, loadProjectContext?: ProjectContextLoader, ): string { const header = buildPromptHeader(config.name, cwd, env); const identity = inherited ? adoptedIdentity(inherited, cwd) : genericBase; const projectContext = ownProjectContext(inherited, cwd, loadProjectContext); const customSection = config.instructions.trim() ? `\n\n\n${config.instructions}\n` : ""; // Place the inherited identity first so it forms a shared leading prefix // with the parent session, which prefix-reusing inference engines reuse // instead of reprocessing. The tag and env block vary per // call and are placed after that prefix. return identity + projectContext + "\n\n" + header + customSection; } /** * The project-context section a child contributes for itself, or "" when the * identity it adopted already describes its directory. * * Two children need one. A child a `WorkspaceProvider` relocated had its * inherited block cut with the rest of the session-resolved tail, because that * block named the parent's files by absolute path (#918); and a `portable` * child adopts operator-authored text that carries no project context at all * (ADR 0009). Rendering it here rather than letting Pi append it keeps the * agent's own body last, which is what `prompt_mode: replace` promises. * * A directory that resolves no context file contributes nothing — project * instructions describe a project this child is not working in. */ function ownProjectContext( inherited: InheritedPrompt | undefined, cwd: string, loadProjectContext: ProjectContextLoader | undefined, ): string { if (!inherited || !loadProjectContext) return ""; const adoptedDescribesOwnDirectory = inherited.strategy !== "portable" && cwd === inherited.cwd; if (adoptedDescribesOwnDirectory) return ""; const block = loadProjectContext(cwd); return block ? `\n\n${block}` : ""; } /** * The parent contribution the child adopts, per the strategy its provider set. * * `full` takes the assembled prompt's identity region, which stays a leading * prefix shared with the parent (ADR 0008). `portable` takes the parent's * operator-authored parts instead, for a provider that re-homes the prompt into * a harness supplying its own base (ADR 0009). * * A parent whose identity is all harness text leaves the child nothing to * adopt: the base identity Pi writes, its tool surface, and its documentation * pointer are not inherited (ADR 0011), so the child's own base stands in * their place. An absent or whitespace-only portable capture falls back to the * same base, never to the full prompt: opting into portable must never * silently re-embed the harness base it exists to avoid. */ function adoptedIdentity(inherited: InheritedPrompt, cwd: string): string { if (inherited.strategy !== "portable") { return ( inheritedIdentity( inherited.systemPrompt, inherited.cwd, cwd !== inherited.cwd, ) || genericBase ); } return inherited.portablePrompt?.trim() || genericBase; } /** * The per-call header both prompt modes share: the `` tag and the * environment block. Both vary per invocation, so both sit after the cacheable * identity prefix — and both modes need any content added here, which is why it * has one home rather than being composed at each `return`. */ function buildPromptHeader( agentName: string, cwd: string, env: EnvInfo, ): string { const activeAgentTag = `\n\n`; const envBlock = `# Environment Working directory: ${cwd} ${env.isGitRepo ? `Git repository: yes\nBranch: ${env.branch}` : "Not a git repository"} Platform: ${env.platform}`; return `${activeAgentTag}${envBlock}`; } /** First line of the section Pi writes above the `` catalogue. */ const SKILLS_SECTION_HEADING = "The following skills provide specialized instructions for specific tasks."; /** Closing tag of that catalogue. */ const SKILLS_CATALOGUE_CLOSE = ""; /** Opening tag of the section Pi ≥0.86 wraps the catalogue in. */ const SKILLS_SECTION_OPEN = ""; /** Closing tag of that section. */ const SKILLS_SECTION_CLOSE = ""; /** Opening tag of the section Pi ≥0.86 renders the working directory into. */ const CWD_SECTION_OPEN = ""; /** Closing tag of that section. */ const CWD_SECTION_CLOSE = ""; /** Opening tag of the section Pi ≥0.86 lists the session's tools in. */ const TOOLS_SECTION_OPEN = ""; /** Closing tag of that section. */ const TOOLS_SECTION_CLOSE = ""; /** Opening tag of the section Pi ≥0.86 writes the session's guidelines in. */ const RULES_SECTION_OPEN = ""; /** Closing tag of that section. */ const RULES_SECTION_CLOSE = ""; /** Opening tag of the section Pi writes its own documentation pointer in. */ const DOCS_SECTION_OPEN = ""; /** Closing tag of that section. */ const DOCS_SECTION_CLOSE = ""; /** * Opening tags of the sections Pi ≥0.86 renders below ``, in its * order: ``, the `--append-system-prompt` addendum, project context, * the skills catalogue, and the cwd. */ const SECTIONS_BELOW_RULES: ReadonlySet = new Set([ "", "", "", "", "", ]); /** * Opening tags of the sections Pi renders below ``. * * The documentation pointer is the last of Pi's own sections above the * per-session ones, so this is the bound that tells Pi's `` from a tag * an operator quoted in an addendum or a context file. */ const SECTIONS_BELOW_DOCS: ReadonlySet = new Set([ "", "", "", "", ]); /** Opening tag of the block Pi renders the session's context files into. */ const PROJECT_CONTEXT_OPEN = ""; /** Closing tag of that block. */ const PROJECT_CONTEXT_CLOSE = ""; /** * The sentence Pi writes below the opening tag — two lines below it through * 0.85, whose block opens with a blank line, and directly below it from * 0.86's section renderer. * * Both offsets are accepted because the peer range (`>=0.81.0`) admits both * renderers. The 0.85 arm in `projectContextStart` is dead once that floor * moves past 0.85; drop it with the fixtures that exercise it rather than * carrying it forward. */ const PROJECT_CONTEXT_LEAD_IN = "Project-specific instructions and guidelines:"; /** * Reduce an inherited prompt to the identity a child may adopt as its own. * * Pi's `buildSystemPrompt` ends every prompt with layers it resolves per * session — the `` catalogue, then a * `Current working directory:` footer, or from 0.86 the same catalogue inside * a `` section followed by a `` section — and extensions append * further blocks after those from `before_agent_start`, rebuilt from the base * prompt on every turn. The child's own session rebuilds all of it against * the child's directory, tool set, and extensions, so an inherited copy is a * second, stale claim of each: a catalogue naming skills the child may not * have (#801), and a footer that walks a workspace-isolated child back into * the parent's directory (#640). * * Everything from the first such layer onward is therefore dropped. What * precedes it is returned byte for byte, so it stays a shared prefix with the * parent's prompt for hosts that reuse one over the system text (#180, #400). * * What precedes it is narrowed too: from 0.86 Pi writes its own text there — * the base identity above the tool surface, the `` and `` * sections, and the documentation pointer. The tool surface is as * session-resolved as the catalogue, and the other two describe the harness * rather than the child, so none of them is inherited (ADR 0011). The shared * prefix then holds the operator's own words, and a parent that runs Pi's own * prompt leaves the child none. * * A prompt carrying neither layer is not one `buildSystemPrompt` assembled, and * is returned unchanged. * * `cutProjectContext` extends the cut one layer earlier, to the * `` block, for a child whose workspace is not its parent's * (#918). That block names each context file by absolute path, so an inherited * copy tells a relocated child its files live in the parent's checkout. */ function inheritedIdentity( prompt: string, parentCwd: string, cutProjectContext: boolean, ): string { const lines = prompt.split("\n"); const tail = sessionResolvedTailStart(lines, parentCwd, cutProjectContext); if (tail.at === -1) return prompt; const head = lines.slice(0, tail.at); const kept = tail.shape === "section" ? withoutHarnessSections(head) : head; return kept.join("\n").trimEnd(); } /** * The head with the harness's own text removed: its base identity, its tool * surface, and its documentation pointer. * * The base identity is what Pi writes when no custom prompt is set — "You are * an expert coding assistant operating inside pi …" — and it describes the * *harness's* role: an assistant helping a user, with a shell and an editor. * A child is not that: it is a specialist with a named tool list, and a * read-only one has no shell and no editor. Inheriting the claim tells it to * do what it cannot do, and tells it to coordinate — the parent's role, not * its own. * * The identity is located positionally, like every other anchor here: Pi * writes the preamble alone, ahead of the `` section it renders next. * A parent running a custom prompt renders no tool surface at all, so the * cut finds no anchor and the operator's own words stand — they are the * operator's, and the operator keeps them. A `` block the operator * wrote, or one quoted in a context file, is not Pi's either: only the pair * Pi renders together says so. * * The documentation pointer goes with it: it is Pi talking about itself. * * The legacy prompt shape (through 0.85) is not narrowed: its preamble has no * marker to locate it by, and the shape is no longer rendered by the Pi this * package supports. */ function withoutHarnessSections( head: ReadonlyArray, ): ReadonlyArray { const surface = piToolSurface(head); if (!surface) return head; // Everything above the tool surface is Pi's own preamble: the identity it // writes when no custom prompt is set. const withoutPreamble = head.slice(surface.toolsAt); const span = surface.spanEnd - surface.toolsAt; return withoutDocs(withoutPreamble.slice(span)); } /** * The head with Pi's documentation pointer excised. * * The section names Pi's own documentation and when to read it — harness * text, in a session that is not about Pi. * * Located positionally, like every other anchor here: Pi writes the section * above the addendum, the project context, the catalogue, and the cwd. A * `` tag quoted in one of those is the operator's text, and it stays. */ function withoutDocs(head: ReadonlyArray): ReadonlyArray { const bound = head.findIndex((line) => SECTIONS_BELOW_DOCS.has(line)); const limit = bound === -1 ? head.length : bound; const docsAt = head.indexOf(DOCS_SECTION_OPEN); if (docsAt === -1 || docsAt >= limit) return head; const closeAt = head.indexOf(DOCS_SECTION_CLOSE, docsAt); if (closeAt === -1 || closeAt >= limit) return head; const end = head[closeAt + 1] === "" ? closeAt + 2 : closeAt + 1; return [...head.slice(0, docsAt), ...head.slice(end)]; } /** * Where Pi's own tool surface sits in the head, or null when it is not there. * * Both sections are rendered from the parent session's own tool set, and Pi * writes neither for a child, whose prompt is a `customPrompt` — so an * inherited copy is the parent's tool surface presented as the child's. * * Located positionally, like every other anchor here: Pi writes the pair * adjacent, `` one blank line below ``, and above every later * section it renders. A lone `` block is the operator's own text, not * Pi's, and a pair quoted in an addendum or a context file sits below that * bound. The blank line below `` goes with the pair, so the head * reads exactly as Pi renders it without them. */ function piToolSurface( head: ReadonlyArray, ): { readonly toolsAt: number; readonly spanEnd: number } | null { const bound = laterSectionStart(head); const toolsAt = head.indexOf(TOOLS_SECTION_OPEN); if (toolsAt === -1 || toolsAt >= bound) return null; const toolsCloseAt = head.indexOf(TOOLS_SECTION_CLOSE, toolsAt); if (toolsCloseAt === -1 || toolsCloseAt >= bound) return null; const rulesAt = toolsCloseAt + 2; if (head[toolsCloseAt + 1] !== "" || head[rulesAt] !== RULES_SECTION_OPEN) { return null; } const rulesCloseAt = head.indexOf(RULES_SECTION_CLOSE, rulesAt); if (rulesCloseAt === -1 || rulesCloseAt >= bound) return null; const spanEnd = head[rulesCloseAt + 1] === "" ? rulesCloseAt + 2 : rulesCloseAt + 1; return { toolsAt, spanEnd }; } /** * Line index of the first section Pi renders below ``, or the line * count when the head carries none of them. */ function laterSectionStart(head: ReadonlyArray): number { const at = head.findIndex((line) => SECTIONS_BELOW_RULES.has(line)); return at === -1 ? head.length : at; } /** * Which of Pi's prompt renderers anchored the session-resolved tail: the ≤0.85 * `Current working directory:` footer, the ≥0.86 `` section, or neither * (a prompt something downstream rewrote). */ type PromptShape = "footer" | "section" | "unanchored"; /** Where the session-resolved tail begins, and the prompt shape that placed it. */ type AnchoredTail = Readonly<{ /** Line index at which the tail begins, or -1 when none is present. */ at: number; shape: PromptShape; }>; /** * Where Pi's per-session layers begin, and the prompt shape that anchored them. * * The catalogue precedes the footer, so cutting at the catalogue already * removes it; the footer is the anchor only for a parent session that resolved * no skills. Matching whole lines makes the footer match exact, so a footer * naming a directory that merely shares a prefix with the parent's is not * mistaken for it, and it mirrors the separator normalization * `buildSystemPrompt` applies. */ function sessionResolvedTailStart( lines: ReadonlyArray, parentCwd: string, cutProjectContext: boolean, ): AnchoredTail { const tail = cwdAnchoredTailStart(lines, parentCwd); if (!cutProjectContext || tail.at === -1) return tail; const projectContextAt = projectContextStart(lines, tail.at); return projectContextAt === -1 ? tail : { ...tail, at: projectContextAt }; } /** * Where Pi's per-session layers begin across both of its prompt renderers — * a line index, or -1 when none is present — and which renderer's shape * placed them. * * Through 0.85 the layers end in a `Current working directory:` footer line, * and the catalogue is anchored to it positionally. From 0.86 the prompt is * assembled from tagged sections — the cwd as a `` section, the catalogue * inside a `` section — so the footer never matches and the cwd * section takes the anchor's place. The two shapes are told apart by which * cwd layer is present, never by version sniffing — which is what the name * records, so this reads apart from the `sessionResolvedTailStart` above it * that extends the cut past this anchor. */ function cwdAnchoredTailStart( lines: ReadonlyArray, parentCwd: string, ): AnchoredTail { const footerAt = lines.lastIndexOf( `Current working directory: ${toPromptPath(parentCwd)}`, ); if (footerAt !== -1) { const catalogueAt = skillsSectionStart(lines, footerAt); return { at: catalogueAt === -1 ? footerAt : catalogueAt, shape: "footer" }; } const cwdAt = cwdSectionStart(lines, parentCwd); if (cwdAt !== -1) { return { at: skillsSectionWrapperStart(lines, cwdAt), shape: "section" }; } // Neither cwd layer: something downstream rewrote a 0.85-shaped prompt, and // the last closing tag is the best remaining guess. return { at: skillsSectionStart(lines, -1), shape: "unanchored" }; } /** * Line index of Pi ≥0.86's `` section opening tag, or -1 when it wrote * none. * * Located by content, not document order: the section is accepted only when * the line inside it is exactly the parent's cwd and the closing tag follows, * so a `` quoted elsewhere — or one naming a directory that merely * shares a prefix with the parent's — is not mistaken for it, the same * whole-line discipline the 0.85 footer anchor applies. */ function cwdSectionStart( lines: ReadonlyArray, parentCwd: string, ): number { for ( let openAt = lines.lastIndexOf(CWD_SECTION_OPEN); openAt !== -1; openAt = lines.lastIndexOf(CWD_SECTION_OPEN, openAt - 1) ) { if ( lines[openAt + 1] === toPromptPath(parentCwd) && lines[openAt + 2] === CWD_SECTION_CLOSE ) { return openAt; } } return -1; } /** * Line index of the `` section's opening tag, or the cwd section's own * opening when the parent resolved no skills. * * The catalogue section sits immediately below the cwd section in * `buildSystemPrompt`'s order, separated only by the section join, so the * closing tag on the other side of that join is Pi's own. Its opening is then * accepted only when the heading is its first content line, keeping a custom * section that merely ends where Pi's does from being taken for it. Cutting at * the opening tag — not at the heading inside it — drops the wrapper with the * layers it carries, and leaves the previous section's closing tag adjacent * to the tail for the project-context anchor. */ function skillsSectionWrapperStart( lines: ReadonlyArray, cwdAt: number, ): number { let closeAt = cwdAt - 1; while (closeAt >= 0 && lines[closeAt] === "") closeAt--; if (closeAt < 0 || lines[closeAt] !== SKILLS_SECTION_CLOSE) return cwdAt; const openAt = lines.lastIndexOf(SKILLS_SECTION_OPEN, closeAt); if (openAt === -1 || lines[openAt + 1] !== SKILLS_SECTION_HEADING) return cwdAt; return openAt; } /** * Line index of the project-context block's opening tag, or -1 when the parent * session resolved no context files. * * Located by the same positional discipline as the catalogue: Pi writes the * block immediately before whichever session-resolved layer follows, so its * closing tag is the last non-blank line above the already-anchored tail. The * opening is then the nearest one above that tag carrying Pi's lead-in * sentence one or two lines below it — renderer-dependent, see * `PROJECT_CONTEXT_LEAD_IN` — which keeps a context file quoting the opening — * later in the document than the real one — from being taken for it. */ function projectContextStart( lines: ReadonlyArray, tailAt: number, ): number { let closeAt = tailAt - 1; while (closeAt >= 0 && lines[closeAt] === "") closeAt--; if (closeAt < 0 || lines[closeAt] !== PROJECT_CONTEXT_CLOSE) return -1; for ( let openAt = lines.lastIndexOf(PROJECT_CONTEXT_OPEN, closeAt); openAt !== -1; openAt = lines.lastIndexOf(PROJECT_CONTEXT_OPEN, openAt - 1) ) { if ( lines[openAt + 2] === PROJECT_CONTEXT_LEAD_IN || lines[openAt + 1] === PROJECT_CONTEXT_LEAD_IN ) { return openAt; } } return -1; } /** * Line index of the skills section's heading, or -1 when the section is absent. * * The heading is located by searching back from the catalogue's closing tag, so * prose quoting Pi's heading ahead of the section is not mistaken for it. */ function skillsSectionStart( lines: ReadonlyArray, footerAt: number, ): number { const catalogueEnd = catalogueCloseBefore(lines, footerAt); return catalogueEnd === -1 ? -1 : lines.lastIndexOf(SKILLS_SECTION_HEADING, catalogueEnd); } /** * Line index of Pi's own catalogue closing tag, or -1 when it wrote none. * * `buildSystemPrompt` writes the cwd footer immediately after the catalogue, in * both of its branches and unconditionally, so the tag on the line before the * footer is Pi's own. Identifying it by that position rather than by document * order keeps a catalogue quoted elsewhere — in a project-context file, or in a * block an extension appended after the footer — from being taken for the * section, in either direction. * * Without a footer to anchor on, something downstream has rewritten Pi's * output; the last closing tag is the best remaining guess. */ function catalogueCloseBefore( lines: ReadonlyArray, footerAt: number, ): number { if (footerAt === -1) { return lines.lastIndexOf(SKILLS_CATALOGUE_CLOSE); } return lines[footerAt - 1] === SKILLS_CATALOGUE_CLOSE ? footerAt - 1 : -1; } /** Render a path the way `buildSystemPrompt` writes it into a prompt. */ function toPromptPath(cwd: string): string { return cwd.replaceAll("\\", "/"); } /** * The identity a child adopts when no parent contribution is usable, in both * prompt modes. * * Every agent type reaches this constant, so it can assert nothing about the * child's tools, domain, or task — a role sentence here described one built-in * agent type to all of them, and its capability list told a read-only child it * could write files (#904, the claim [ADR 0008] removed one constant above). * What it omits is supplied more accurately downstream: the `` * tag names the agent, the agent's own prompt states its role, and the tool * array — plus `@gotgenes/pi-permission-system`'s per-session block, when * installed — states its tools. */ const genericBase = `# Instructions Do what has been asked; nothing more, nothing less.`;