/** * prompts.ts — System prompt builder for agents. * * Every agent gets a fresh context — no inherited parent identity. * EnvInfo is imported from types.ts — branch is a string (empty when unknown). */ import type { EnvInfo } from "../types.js"; import type { AgentConfig, SystemPromptMode } from "../agents/types.js"; import type { SkillMeta, PreloadedSkill } from "./skill-loader.js"; import { formatSkillsForPrompt, type Skill } from "@earendil-works/pi-coding-agent"; /** Extra sections to inject into the system prompt (skills). */ export interface PromptExtras { /** Preloaded skill contents to inject (full content + description). */ skillBlocks?: PreloadedSkill[]; /** Skill metadata for whitelist display (name, description, location only). */ skillMetas?: SkillMeta[]; /** Parent system prompt (for inherit mode). */ parentSystemPrompt?: string; /** Custom system prompt content (for custom mode). */ customSystemPrompt?: string; /** Project context files (AGENTS.md) for custom mode. */ contextFiles?: Array<{ path: string; content: string }>; } /** * Strip pi scaffolding sections from a parent system prompt. * * In inherit mode, the parent's prompt already contains: * - ... (AGENTS.md) * - Skills block (text intro + ...) * - Current date: YYYY-MM-DD * - Current working directory: /path * * These are re-added by subagents-lite from the subagent's own config, * so we strip them to avoid duplication. * * @param prompt The parent system prompt to clean. * @returns The prompt with scaffolding sections removed. */ function stripScaffolding(prompt: string): string { let result = prompt; // 1. Strip ... block result = result.replace(/\n?<\s*project_context\s*>[\s\S]*?<\/\s*project_context\s*>\n?/g, "\n"); // 2. Strip skills block: optional intro text + ... result = result.replace(/\n?(?:The following skills provide[\s\S]*?)?<\s*available_skills\s*>[\s\S]*?<\/\s*available_skills\s*>\n?/g, "\n"); // 3. Strip Current date: line result = result.replace(/\n?Current date:.*\n?/g, "\n"); // 4. Strip Current working directory: line result = result.replace(/\n?Current working directory:.*\n?/g, "\n"); // Clean up: collapse runs of 3+ newlines into 2 result = result.replace(/\n{3,}/g, "\n\n"); return result.trim(); } /** * Build the system prompt for an agent from its config. * * Three modes: * - replace (default): generic header + env + agent's systemPrompt * - inherit: parent's system prompt (stripped of scaffolding) + env + agent's systemPrompt * - custom: content of ~/.pi/agent/subagents-lite-prompt.md + env + agent's systemPrompt * * Agent's own systemPrompt is always included in tags. * * @param config Agent configuration. * @param cwd Current working directory. * @param env Environment info. * @param extras Optional extra sections to inject (skills, parent/custom prompts). * @param mode System prompt mode (replace, inherit, custom). */ export function buildAgentPrompt( config: AgentConfig, cwd: string, env: EnvInfo, extras?: PromptExtras, mode: SystemPromptMode = "replace", ): string { const envLines = [ "# Environment", `Working directory: ${cwd}`, env.isGitRepo ? "Git repository: yes" : "Not a git repository", ]; if (env.isGitRepo && env.branch) { envLines.push(`Branch: ${env.branch}`); } envLines.push(`Platform: ${env.platform}`); const envBlock = envLines.join("\n"); // Unified skill index — all skills in one block const hasSkills = extras?.skillMetas?.length || extras?.skillBlocks?.length; let extrasSuffix = ""; if (hasSkills) { const skillLines: string[] = []; // Location-based skills: use Pi's formatSkillsForPrompt for XML escaping and // disable-model-invocation filtering, then extract the elements. if (extras?.skillMetas?.length) { const piSkills: Skill[] = extras.skillMetas.map((m) => ({ name: m.name, description: m.description, filePath: m.location, baseDir: "", sourceInfo: {} as any, disableModelInvocation: m.disableModelInvocation, })); const formatted = formatSkillsForPrompt(piSkills); const skillElements = formatted.match(/[\s\S]*?<\/skill>/g); if (skillElements) skillLines.push(...skillElements); } // Preloaded skills: content tags (not in Pi's formatSkillsForPrompt) for (const skill of extras?.skillBlocks ?? []) { skillLines.push(`${escapeXml(skill.name)}${escapeXml(skill.description)}${escapeXml(skill.content)}`); } const lines = [ "The following skills provide specialized instructions for specific tasks.", "Use the read tool to load a skill's file when the task matches its description.", "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.", "", "", ...skillLines, "", ]; extrasSuffix = `\n\n${lines.join("\n")}`; } // Agent's own system prompt wrapped in tags const agentInstructions = `\n\n${config.systemPrompt}\n`; // Project context files (AGENTS.md) — placed after agent_instructions, before extras let contextSuffix = ""; if (extras?.contextFiles?.length) { const lines = [ "", "", "Project-specific instructions and guidelines:", "", ]; for (const file of extras.contextFiles) { lines.push(``); lines.push(file.content); lines.push(``); lines.push(""); } lines.push(""); contextSuffix = `\n\n${lines.join("\n")}`; } // Build base prompt: mode-specific header if provided, otherwise default const activeAgentTag = ``; const rawHeader = mode === "inherit" ? extras?.parentSystemPrompt : mode === "custom" ? extras?.customSystemPrompt : undefined; // Parent/custom headers carry pi's scaffolding (context, skills, date, cwd); // strip it — we re-add these from the subagent's own config. (rawHeader is // undefined in replace mode, so nothing to strip there.) const customHeader = rawHeader ? stripScaffolding(rawHeader) : rawHeader; const basePrompt = customHeader ? `${customHeader}\n\n${envBlock}` : `You are a Pi, an expert coding sub-agent.\nYou have been invoked to handle a specific task autonomously.\n\n${envBlock}`; // active_agent goes AFTER shared prefix (header + env + context) for KV cache return `${basePrompt}${contextSuffix}\n${activeAgentTag}\n${agentInstructions}${extrasSuffix}`; } function escapeXml(value: string): string { // Only escape < and > — enough for XML-like tags, keeps text readable for LLMs return value .replace(//g, ">"); }