import { homedir } from "node:os"; import type { ConfigSnapshot } from "@leitwerk-dev/protocol"; import Mustache from "mustache"; import type { LlmTurnDefinition } from "./define-process.js"; import { PI_BUILT_IN_TOOL_NAMES, type PiBuiltInToolName, type ProcessPiConfig, type ResolvedProcessPiConfig, } from "./types.js"; /** @internal */ type TemplateScalar = string | number | boolean; /** @internal */ type TemplateValue = TemplateScalar | TemplateContext | readonly TemplateValue[] | undefined; /** @internal */ export interface TemplateContext { /** @internal */ [key: string]: TemplateValue; } function isScalarValue(value: unknown): value is string | number | boolean { return typeof value === "string" || typeof value === "number" || typeof value === "boolean"; } function toTemplateContextRecord(value: unknown): TemplateContext { if (typeof value !== "object" || value === null || Array.isArray(value)) { return {}; } const context: TemplateContext = {}; for (const [key, candidate] of Object.entries(value)) { if (isScalarValue(candidate)) { context[key] = candidate; } } return context; } /** @internal */ export function createTemplateContext(input: { /** @internal */ params?: unknown; /** @internal */ process?: { /** @internal */ processId?: string | null; /** @internal */ externalId?: string | null; /** @internal */ externalUrl?: string | null; /** @internal */ selectedTurnId?: string | null; }; /** @internal */ projects?: ReadonlyArray<{ /** @internal */ key: string; /** @internal */ repoLocator: string; /** @internal */ baseBranch: string; /** @internal */ workBranch?: string | null; }>; }): TemplateContext { const context: TemplateContext = { ...toTemplateContextRecord(input.params), }; if (input.process) { context.processId = input.process.processId ?? undefined; context.externalId = input.process.externalId ?? undefined; context.externalUrl = input.process.externalUrl ?? undefined; context.selectedTurnId = input.process.selectedTurnId ?? undefined; } const firstProject = input.projects?.[0]; if (firstProject) { context.projectKey = firstProject.key; context.repositoryUrl = firstProject.repoLocator; context.baseBranch = firstProject.baseBranch; context.workBranch = firstProject.workBranch ?? undefined; } return context; } /** @internal */ export function interpolateTemplate(template: string, context: TemplateContext): string { return Mustache.render(template, context); } /** @internal */ type TurnToolDefinitionLike = | { /** @internal */ kind: "llm"; /** @internal */ availableTools: readonly PiBuiltInToolName[]; } | { /** @internal */ kind: string; }; /** @internal */ type ProcessTurnBindingLike = { /** @internal */ definition: TurnToolDefinitionLike; }; /** @internal */ type ProcessToolSource = TurnToolDefinitionLike | ProcessTurnBindingLike; function isTurnDefinitionSource(source: ProcessToolSource): source is TurnToolDefinitionLike { return "kind" in source; } function resolveToolSourceDefinition(source: ProcessToolSource): TurnToolDefinitionLike { return isTurnDefinitionSource(source) ? source : source.definition; } /** @internal */ export function validatePiBuiltInToolArray( toolNames: readonly unknown[], context: string, ): string[] { const errors: string[] = []; const seen = new Set(); for (const rawName of toolNames) { if (typeof rawName !== "string") { errors.push(`${context} must contain only string tool names`); continue; } const name = rawName.trim(); if (name === "") { errors.push(`${context} must not contain empty tool names`); continue; } if (name !== rawName) { errors.push(`${context} must use canonical Pi tool names without surrounding whitespace`); continue; } if (!PI_BUILT_IN_TOOL_NAMES.includes(name as PiBuiltInToolName)) { errors.push( `${context} references unknown Pi tool '${name}'. Expected one of ${PI_BUILT_IN_TOOL_NAMES.join(", ")}`, ); continue; } if (seen.has(name)) { errors.push(`${context} must not contain duplicate Pi tool '${name}'`); continue; } seen.add(name); } return errors; } /** @internal */ export function resolveTurnAvailableToolNames(input: { /** @internal */ turnId: string; /** @internal */ turnDef: LlmTurnDefinition; }): string[] { const errors = validatePiBuiltInToolArray( input.turnDef.availableTools, `turn '${input.turnId}' availableTools`, ); if (errors.length > 0) { throw new Error(errors.join("; ")); } return [...input.turnDef.availableTools]; } /** @internal */ export function collectProcessAvailableToolNames( turns: Iterable | undefined, ): string[] { const usedToolNames = new Set(); for (const source of turns ?? []) { const definition = resolveToolSourceDefinition(source); if (definition.kind !== "llm" || !("availableTools" in definition)) { continue; } const errors = validatePiBuiltInToolArray(definition.availableTools, "LLM turn availableTools"); if (errors.length > 0) { throw new Error(errors.join("; ")); } for (const toolName of definition.availableTools) { usedToolNames.add(toolName); } } return PI_BUILT_IN_TOOL_NAMES.filter((toolName) => usedToolNames.has(toolName)); } const DEFAULT_TOOL_DESCRIPTIONS: Record = { read: "Read file contents", bash: "Execute bash commands (ls, grep, find, etc.)", edit: "Make surgical edits to files (find exact text and replace)", write: "Create or overwrite files", grep: "Search file contents", find: "Find files by name/path", ls: "List directory contents", }; function buildDefaultSystemPrompt(availableToolNames: readonly string[]): string { const toolList = availableToolNames.length > 0 ? availableToolNames .map((toolName) => { const description = DEFAULT_TOOL_DESCRIPTIONS[toolName as PiBuiltInToolName] ?? "Built-in Pi tool"; return `- ${toolName}: ${description}`; }) .join("\n") : "- No built-in tools are available for this process unless a turn registers custom tools."; return `You are an expert coding assistant operating inside pi, a coding agent harness. You help users by using the tools made available for the active turn. Available built-in tools for this process: ${toolList} In addition to the tools above, you may have access to other custom tools depending on the active turn. Guidelines: - Use only tools that are available in the active turn. - Use read/search tools to examine files before editing. - Use edit for precise changes when it is available (old text must match exactly). - Use write only for new files or complete rewrites when it is available. - When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did. - Be concise in your responses. - Show file paths clearly when working with files.`; } /** @internal */ export function resolveProcessPiConfig(input: { /** @internal */ processId: string; /** @internal */ processPiConfig?: ProcessPiConfig; /** @internal */ turns?: Iterable; /** @internal */ configSnapshot?: ConfigSnapshot; /** @internal */ templateContext: TemplateContext; }): ResolvedProcessPiConfig { const processConfig = input.configSnapshot?.process_configs?.[input.processId]; const processPiOverride = processConfig?.pi; const availableToolNames = collectProcessAvailableToolNames(input.turns); const configSystemPromptTemplate = input.configSnapshot?.pi?.system_prompt_template; const systemPromptTemplate = processPiOverride?.system_prompt_template ?? input.processPiConfig?.systemPromptTemplate ?? configSystemPromptTemplate; const appendSystemPromptTemplate = processPiOverride?.append_system_prompt_template ?? input.processPiConfig?.appendSystemPromptTemplate; const resolvedSystemPrompt = systemPromptTemplate ? interpolateTemplate(systemPromptTemplate, input.templateContext) : buildDefaultSystemPrompt(availableToolNames); const sessionCwd = input.processPiConfig?.sessionCwdTemplate ? interpolateTemplate(input.processPiConfig.sessionCwdTemplate, input.templateContext).trim() : undefined; return { systemPrompt: resolvedSystemPrompt, appendSystemPrompt: appendSystemPromptTemplate ? interpolateTemplate(appendSystemPromptTemplate, input.templateContext) : undefined, ...(sessionCwd ? { sessionCwd } : {}), availableToolNames, }; } export { resolveTurnAvailableToolNames as resolveTurnActiveToolNames }; // Mirror Pi's PI_CODING_AGENT_DIR expansion semantics from // pi-mono/packages/coding-agent/src/config.ts. /** @internal */ export function expandPiAgentDir(agentDir: string): string { if (agentDir === "~") { return homedir(); } if (agentDir.startsWith("~/")) { return homedir() + agentDir.slice(1); } return agentDir; } /** @internal */ export function resolvePiAgentDir(options: { /** @internal */ agentDir?: string | null; /** @internal */ configSnapshot?: ConfigSnapshot; /** @internal */ fallbackDir: string; }): string { const configuredAgentDir = typeof options.agentDir === "string" && options.agentDir.trim() !== "" ? options.agentDir : typeof options.configSnapshot?.pi?.agent_dir === "string" && options.configSnapshot.pi.agent_dir.trim() !== "" ? options.configSnapshot.pi.agent_dir : options.fallbackDir; return expandPiAgentDir(configuredAgentDir); }