diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 893c25683..eec18ff8a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -15,6 +15,7 @@ - Exposed `copyToClipboard` utility for extensions ([#926](https://github.com/badlogic/pi-mono/issues/926) by [@mitsuhiko](https://github.com/mitsuhiko)) - Skill invocation messages are now collapsible in chat output, showing collapsed by default with skill name and expand hint ([#894](https://github.com/badlogic/pi-mono/issues/894)) - Header values in `models.json` now support environment variables and shell commands, matching `apiKey` resolution ([#909](https://github.com/badlogic/pi-mono/issues/909)) +- Template variables (`{{tools}}`, `{{context}}`, `{{skills}}`) for custom system prompts in SYSTEM.md, allowing precise control over where dynamic content is injected - `markdown.codeBlockIndent` setting to customize code block indentation in rendered output - Extension package management with `pi install`, `pi remove`, `pi update`, and `pi list` commands ([#645](https://github.com/badlogic/pi-mono/issues/645)) - Package filtering: selectively load resources from packages using object form in `packages` array ([#645](https://github.com/badlogic/pi-mono/issues/645)) diff --git a/packages/coding-agent/examples/sdk/12-full-control.ts b/packages/coding-agent/examples/sdk/12-full-control.ts index 66823c83b..b1ea198a9 100644 --- a/packages/coding-agent/examples/sdk/12-full-control.ts +++ b/packages/coding-agent/examples/sdk/12-full-control.ts @@ -52,6 +52,7 @@ const resourceLoader: ResourceLoader = { getAgentsFiles: () => ({ agentsFiles: [] }), getSystemPrompt: () => `You are a minimal assistant. Available: read, bash. Be concise.`, + getSystemPromptTemplates: () => undefined, getAppendSystemPrompt: () => [], getPathMetadata: () => new Map(), reload: async () => {}, diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 270545534..4d4931f29 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -610,7 +610,7 @@ export class AgentSession { const loadedSkills = this._resourceLoader.getSkills().skills; const loadedContextFiles = this._resourceLoader.getAgentsFiles().agentsFiles; - return buildSystemPrompt({ + const result = buildSystemPrompt({ cwd: this._cwd, skills: loadedSkills, contextFiles: loadedContextFiles, @@ -618,6 +618,8 @@ export class AgentSession { appendSystemPrompt, selectedTools: validToolNames, }); + + return result.prompt; } // ========================================================================= diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index 56a1bd6ae..c9ee2ba12 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -23,6 +23,13 @@ import { SettingsManager } from "./settings-manager.js"; import type { Skill } from "./skills.js"; import { loadSkills } from "./skills.js"; +/** Template variable usage in custom SYSTEM.md */ +export interface SystemPromptTemplates { + tools: boolean; + context: boolean; + skills: boolean; +} + export interface ResourceLoader { getExtensions(): LoadExtensionsResult; getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] }; @@ -30,6 +37,8 @@ export interface ResourceLoader { getThemes(): { themes: Theme[]; diagnostics: ResourceDiagnostic[] }; getAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> }; getSystemPrompt(): string | undefined; + /** Returns template variable usage if custom SYSTEM.md exists, undefined otherwise */ + getSystemPromptTemplates(): SystemPromptTemplates | undefined; getAppendSystemPrompt(): string[]; getPathMetadata(): Map; reload(): Promise; @@ -258,6 +267,15 @@ export class DefaultResourceLoader implements ResourceLoader { return this.systemPrompt; } + getSystemPromptTemplates(): SystemPromptTemplates | undefined { + if (!this.systemPrompt) return undefined; + return { + tools: this.systemPrompt.includes("{{tools}}"), + context: this.systemPrompt.includes("{{context}}"), + skills: this.systemPrompt.includes("{{skills}}"), + }; + } + getAppendSystemPrompt(): string[] { return this.appendSystemPrompt; } diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index dd13d3a9d..f18c4cf43 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -31,8 +31,17 @@ export interface BuildSystemPromptOptions { skills?: Skill[]; } +export interface BuildSystemPromptResult { + /** The built system prompt */ + prompt: string; + /** Whether context files were injected (always true for default prompt, depends on {{context}} for custom) */ + contextInjected: boolean; + /** Whether skills were injected (always true for default prompt, depends on {{skills}} for custom) */ + skillsInjected: boolean; +} + /** Build the system prompt with tools, guidelines, and context */ -export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string { +export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): BuildSystemPromptResult { const { customPrompt, selectedTools, @@ -63,30 +72,48 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin if (customPrompt) { let prompt = customPrompt; - if (appendSection) { - prompt += appendSection; + // Template variable replacement (opt-in injection) + // No template variables = full replacement mode (no automatic appending) + const contextInjected = prompt.includes("{{context}}"); + const skillsInjected = prompt.includes("{{skills}}"); + + if (prompt.includes("{{tools}}")) { + const tools = selectedTools || ["read", "bash", "edit", "write"]; + const toolsList = + tools.length > 0 + ? tools.map((t) => `- ${t}: ${toolDescriptions[t] ?? "Custom tool"}`).join("\n") + : "(none)"; + prompt = prompt.replace("{{tools}}", toolsList); } - // Append project context files - if (contextFiles.length > 0) { - prompt += "\n\n# Project Context\n\n"; - prompt += "Project-specific instructions and guidelines:\n\n"; - for (const { path: filePath, content } of contextFiles) { - prompt += `## ${filePath}\n\n${content}\n\n`; + if (contextInjected) { + let contextStr = ""; + if (contextFiles.length > 0) { + contextStr = "# Project Context\n\n"; + contextStr += "Project-specific instructions and guidelines:\n\n"; + for (const { path: filePath, content } of contextFiles) { + contextStr += `## ${filePath}\n\n${content}\n\n`; + } } + prompt = prompt.replace("{{context}}", contextStr); } - // Append skills section (only if read tool is available) - const customPromptHasRead = !selectedTools || selectedTools.includes("read"); - if (customPromptHasRead && skills.length > 0) { - prompt += formatSkillsForPrompt(skills); + if (skillsInjected) { + const customPromptHasRead = !selectedTools || selectedTools.includes("read"); + const skillsStr = customPromptHasRead && skills.length > 0 ? formatSkillsForPrompt(skills) : ""; + prompt = prompt.replace("{{skills}}", skillsStr); + } + + // Append section always applies (for --append-system-prompt) + if (appendSection) { + prompt += appendSection; } - // Add date/time and working directory last + // Add date/time and working directory last (always) prompt += `\nCurrent date and time: ${dateTime}`; prompt += `\nCurrent working directory: ${resolvedCwd}`; - return prompt; + return { prompt, contextInjected, skillsInjected }; } // Get absolute paths to documentation and examples @@ -175,7 +202,8 @@ Pi documentation (read only when the user asks about pi itself, its SDK, extensi } // Append skills section (only if read tool is available) - if (hasRead && skills.length > 0) { + const skillsInjected = hasRead; + if (skillsInjected && skills.length > 0) { prompt += formatSkillsForPrompt(skills); } @@ -183,5 +211,6 @@ Pi documentation (read only when the user asks about pi itself, its SDK, extensi prompt += `\nCurrent date and time: ${dateTime}`; prompt += `\nCurrent working directory: ${resolvedCwd}`; - return prompt; + // Default prompt always injects context, skills depend on read tool + return { prompt, contextInjected: true, skillsInjected }; } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 5ababa022..ed8f84ffe 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -126,7 +126,12 @@ export type { ResolvedPaths, } from "./core/package-manager.js"; export { DefaultPackageManager } from "./core/package-manager.js"; -export type { ResourceCollision, ResourceDiagnostic, ResourceLoader } from "./core/resource-loader.js"; +export type { + ResourceCollision, + ResourceDiagnostic, + ResourceLoader, + SystemPromptTemplates, +} from "./core/resource-loader.js"; export { DefaultResourceLoader } from "./core/resource-loader.js"; // SDK for programmatic usage export { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 0bca4ea6b..c71068800 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -866,26 +866,40 @@ export class InteractiveMode { } const metadata = this.session.resourceLoader.getPathMetadata(); + // Check if custom SYSTEM.md uses template variables + // undefined = no custom prompt (default behavior, show all) + // defined = custom prompt, only show if template is used + const promptTemplates = this.session.resourceLoader.getSystemPromptTemplates(); const sectionHeader = (name: string, color: ThemeColor = "mdHeading") => theme.fg(color, `[${name}]`); - const contextFiles = this.session.resourceLoader.getAgentsFiles().agentsFiles; - if (contextFiles.length > 0) { - const contextList = contextFiles.map((f) => theme.fg("dim", ` ${this.formatDisplayPath(f.path)}`)).join("\n"); - this.chatContainer.addChild(new Text(`${sectionHeader("Context")}\n${contextList}`, 0, 0)); - this.chatContainer.addChild(new Spacer(1)); + // Show context only if: no custom prompt OR custom prompt uses {{context}} + const showContext = !promptTemplates || promptTemplates.context; + if (showContext) { + const contextFiles = this.session.resourceLoader.getAgentsFiles().agentsFiles; + if (contextFiles.length > 0) { + const contextList = contextFiles + .map((f) => theme.fg("dim", ` ${this.formatDisplayPath(f.path)}`)) + .join("\n"); + this.chatContainer.addChild(new Text(`${sectionHeader("Context")}\n${contextList}`, 0, 0)); + this.chatContainer.addChild(new Spacer(1)); + } } - const skills = this.session.resourceLoader.getSkills().skills; - if (skills.length > 0) { - const skillPaths = skills.map((s) => s.filePath); - const groups = this.buildScopeGroups(skillPaths, metadata); - const skillList = this.formatScopeGroups(groups, { - formatPath: (p) => this.formatDisplayPath(p), - formatPackagePath: (p, source) => this.getShortPath(p, source), - }); - this.chatContainer.addChild(new Text(`${sectionHeader("Skills")}\n${skillList}`, 0, 0)); - this.chatContainer.addChild(new Spacer(1)); + // Show skills only if: no custom prompt OR custom prompt uses {{skills}} + const showSkills = !promptTemplates || promptTemplates.skills; + if (showSkills) { + const skills = this.session.resourceLoader.getSkills().skills; + if (skills.length > 0) { + const skillPaths = skills.map((s) => s.filePath); + const groups = this.buildScopeGroups(skillPaths, metadata); + const skillList = this.formatScopeGroups(groups, { + formatPath: (p) => this.formatDisplayPath(p), + formatPackagePath: (p, source) => this.getShortPath(p, source), + }); + this.chatContainer.addChild(new Text(`${sectionHeader("Skills")}\n${skillList}`, 0, 0)); + this.chatContainer.addChild(new Spacer(1)); + } } const skillDiagnostics = this.session.resourceLoader.getSkills().diagnostics; diff --git a/packages/coding-agent/test/sdk-skills.test.ts b/packages/coding-agent/test/sdk-skills.test.ts index 48c96cdd7..ef4d45701 100644 --- a/packages/coding-agent/test/sdk-skills.test.ts +++ b/packages/coding-agent/test/sdk-skills.test.ts @@ -57,6 +57,7 @@ This is a test skill. getThemes: () => ({ themes: [], diagnostics: [] }), getAgentsFiles: () => ({ agentsFiles: [] }), getSystemPrompt: () => undefined, + getSystemPromptTemplates: () => undefined, getAppendSystemPrompt: () => [], getPathMetadata: () => new Map(), reload: async () => {}, @@ -90,6 +91,7 @@ This is a test skill. getThemes: () => ({ themes: [], diagnostics: [] }), getAgentsFiles: () => ({ agentsFiles: [] }), getSystemPrompt: () => undefined, + getSystemPromptTemplates: () => undefined, getAppendSystemPrompt: () => [], getPathMetadata: () => new Map(), reload: async () => {}, diff --git a/packages/coding-agent/test/system-prompt.test.ts b/packages/coding-agent/test/system-prompt.test.ts index af20f1554..9a80b89de 100644 --- a/packages/coding-agent/test/system-prompt.test.ts +++ b/packages/coding-agent/test/system-prompt.test.ts @@ -4,37 +4,224 @@ import { buildSystemPrompt } from "../src/core/system-prompt.js"; describe("buildSystemPrompt", () => { describe("empty tools", () => { test("shows (none) for empty tools list", () => { - const prompt = buildSystemPrompt({ + const result = buildSystemPrompt({ selectedTools: [], contextFiles: [], skills: [], }); - expect(prompt).toContain("Available tools:\n(none)"); + expect(result.prompt).toContain("Available tools:\n(none)"); }); test("shows file paths guideline even with no tools", () => { - const prompt = buildSystemPrompt({ + const result = buildSystemPrompt({ selectedTools: [], contextFiles: [], skills: [], }); - expect(prompt).toContain("Show file paths clearly"); + expect(result.prompt).toContain("Show file paths clearly"); }); }); describe("default tools", () => { test("includes all default tools", () => { - const prompt = buildSystemPrompt({ + const result = buildSystemPrompt({ contextFiles: [], skills: [], }); - expect(prompt).toContain("- read:"); - expect(prompt).toContain("- bash:"); - expect(prompt).toContain("- edit:"); - expect(prompt).toContain("- write:"); + expect(result.prompt).toContain("- read:"); + expect(result.prompt).toContain("- bash:"); + expect(result.prompt).toContain("- edit:"); + expect(result.prompt).toContain("- write:"); + }); + }); + + describe("template variables", () => { + test("replaces {{tools}} with tool list", () => { + const result = buildSystemPrompt({ + customPrompt: "My tools:\n{{tools}}", + selectedTools: ["read", "bash"], + contextFiles: [], + skills: [], + }); + + expect(result.prompt).toContain("My tools:"); + expect(result.prompt).toContain("- read:"); + expect(result.prompt).toContain("- bash:"); + expect(result.prompt).not.toContain("{{tools}}"); + }); + + test("replaces {{context}} with context files", () => { + const result = buildSystemPrompt({ + customPrompt: "Context:\n{{context}}\nEnd context.", + contextFiles: [{ path: "/test/AGENTS.md", content: "Test content" }], + skills: [], + }); + + expect(result.prompt).toContain("Context:"); + expect(result.prompt).toContain("# Project Context"); + expect(result.prompt).toContain("/test/AGENTS.md"); + expect(result.prompt).toContain("Test content"); + expect(result.prompt).toContain("End context."); + expect(result.prompt).not.toContain("{{context}}"); + expect(result.contextInjected).toBe(true); + }); + + test("replaces {{skills}} with skills section", () => { + const result = buildSystemPrompt({ + customPrompt: "Skills:\n{{skills}}", + selectedTools: ["read"], + contextFiles: [], + skills: [ + { + name: "test-skill", + description: "A test skill", + filePath: "/test/skill.md", + baseDir: "/test", + source: "project", + disableModelInvocation: false, + }, + ], + }); + + expect(result.prompt).toContain("Skills:"); + expect(result.prompt).toContain("test-skill"); + expect(result.prompt).not.toContain("{{skills}}"); + expect(result.skillsInjected).toBe(true); + }); + + test("{{skills}} is empty when read tool not available", () => { + const result = buildSystemPrompt({ + customPrompt: "Skills: [{{skills}}]", + selectedTools: ["bash"], // no read + contextFiles: [], + skills: [ + { + name: "test-skill", + description: "A test skill", + filePath: "/test/skill.md", + baseDir: "/test", + source: "project", + disableModelInvocation: false, + }, + ], + }); + + expect(result.prompt).toContain("Skills: []"); + expect(result.prompt).not.toContain("test-skill"); + expect(result.skillsInjected).toBe(true); // Still true, just empty + }); + + test("multiple template variables work together", () => { + const result = buildSystemPrompt({ + customPrompt: "Tools:\n{{tools}}\n\n{{context}}\n\n{{skills}}", + selectedTools: ["read", "edit"], + contextFiles: [{ path: "/AGENTS.md", content: "Project rules" }], + skills: [ + { + name: "my-skill", + description: "My skill", + filePath: "/skill.md", + baseDir: "/", + source: "project", + disableModelInvocation: false, + }, + ], + }); + + expect(result.prompt).toContain("- read:"); + expect(result.prompt).toContain("- edit:"); + expect(result.prompt).toContain("Project rules"); + expect(result.prompt).toContain("my-skill"); + expect(result.prompt).not.toContain("{{tools}}"); + expect(result.prompt).not.toContain("{{context}}"); + expect(result.prompt).not.toContain("{{skills}}"); + expect(result.contextInjected).toBe(true); + expect(result.skillsInjected).toBe(true); + }); + + test("without template vars, context and skills are NOT appended (full replacement)", () => { + const result = buildSystemPrompt({ + customPrompt: "My custom prompt", + contextFiles: [{ path: "/AGENTS.md", content: "Context here" }], + skills: [ + { + name: "skill1", + description: "Skill 1", + filePath: "/skill.md", + baseDir: "/", + source: "project", + disableModelInvocation: false, + }, + ], + }); + + // Full replacement mode: no automatic appending + expect(result.prompt).toContain("My custom prompt"); + expect(result.prompt).not.toContain("Context here"); + expect(result.prompt).not.toContain("skill1"); + expect(result.contextInjected).toBe(false); + expect(result.skillsInjected).toBe(false); + }); + + test("with template vars, content only appears where requested", () => { + const result = buildSystemPrompt({ + customPrompt: "Only tools: {{tools}}", + contextFiles: [{ path: "/AGENTS.md", content: "Should not appear" }], + skills: [ + { + name: "skill1", + description: "Should not appear", + filePath: "/skill.md", + baseDir: "/", + source: "project", + disableModelInvocation: false, + }, + ], + }); + + // Has {{tools}} so template mode, but no {{context}} or {{skills}} + expect(result.prompt).toContain("- read:"); + expect(result.prompt).not.toContain("Should not appear"); + expect(result.prompt).not.toContain("skill1"); + expect(result.contextInjected).toBe(false); + expect(result.skillsInjected).toBe(false); + }); + + test("always includes datetime and cwd", () => { + const result = buildSystemPrompt({ + customPrompt: "{{tools}}", + cwd: "/test/dir", + contextFiles: [], + skills: [], + }); + + expect(result.prompt).toContain("Current date and time:"); + expect(result.prompt).toContain("Current working directory: /test/dir"); + }); + + test("appendSystemPrompt still works with template vars", () => { + const result = buildSystemPrompt({ + customPrompt: "Main: {{tools}}", + appendSystemPrompt: "Extra instructions", + contextFiles: [], + skills: [], + }); + + expect(result.prompt).toContain("Main:"); + expect(result.prompt).toContain("Extra instructions"); + }); + + test("default prompt returns contextInjected and skillsInjected true", () => { + const result = buildSystemPrompt({ + contextFiles: [{ path: "/AGENTS.md", content: "Context" }], + skills: [], + }); + + expect(result.contextInjected).toBe(true); + expect(result.skillsInjected).toBe(true); }); }); }); diff --git a/packages/coding-agent/test/utilities.ts b/packages/coding-agent/test/utilities.ts index 6e4e60b43..46c88d29d 100644 --- a/packages/coding-agent/test/utilities.ts +++ b/packages/coding-agent/test/utilities.ts @@ -182,6 +182,7 @@ export function createTestResourceLoader(): ResourceLoader { getThemes: () => ({ themes: [], diagnostics: [] }), getAgentsFiles: () => ({ agentsFiles: [] }), getSystemPrompt: () => undefined, + getSystemPromptTemplates: () => undefined, getAppendSystemPrompt: () => [], getPathMetadata: () => new Map(), reload: async () => {}, diff --git a/packages/mom/src/agent.ts b/packages/mom/src/agent.ts index 2733dbd57..ff6980979 100644 --- a/packages/mom/src/agent.ts +++ b/packages/mom/src/agent.ts @@ -457,6 +457,7 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi getThemes: () => ({ themes: [], diagnostics: [] }), getAgentsFiles: () => ({ agentsFiles: [] }), getSystemPrompt: () => systemPrompt, + getSystemPromptTemplates: () => undefined, getAppendSystemPrompt: () => [], getPathMetadata: () => new Map(), reload: async () => {},