diff --git a/src/agent/system-prompt/builder.test.ts b/src/agent/system-prompt/builder.test.ts new file mode 100644 index 00000000..e9657a5e --- /dev/null +++ b/src/agent/system-prompt/builder.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from "vitest"; +import { buildSystemPrompt, buildSystemPromptWithReport } from "./builder.js"; +import type { SystemPromptOptions } from "./types.js"; + +const PROFILE = { + soul: "# Soul\nYou are a helpful coding assistant.", + user: "# User\nName: Alice", + workspace: "# Workspace\nFollow conventional commits.", + memory: "# Memory\nUser prefers TypeScript.", + config: { name: "TestAgent" }, +}; + +const TOOLS = ["read", "write", "edit", "glob", "exec", "memory_get", "memory_set", "sessions_spawn", "web_search"]; + +describe("buildSystemPrompt", () => { + // ── Full mode ───────────────────────────────────────────────────────── + + it("full mode includes all profile sections", () => { + const result = buildSystemPrompt({ mode: "full", profile: PROFILE }); + expect(result).toContain("# Soul"); + expect(result).toContain("# User"); + expect(result).toContain("# Workspace"); + expect(result).toContain("# Memory"); + }); + + it("full mode includes safety constitution", () => { + const result = buildSystemPrompt({ mode: "full" }); + expect(result).toContain("## Safety"); + expect(result).toContain("no independent goals"); + }); + + it("full mode includes tooling summary when tools provided", () => { + const result = buildSystemPrompt({ mode: "full", tools: TOOLS }); + expect(result).toContain("## Tooling"); + expect(result).toContain("- read: Read file contents"); + expect(result).toContain("- exec: Run shell commands"); + }); + + it("full mode includes tool call style section", () => { + const result = buildSystemPrompt({ mode: "full", tools: TOOLS }); + expect(result).toContain("## Tool Call Style"); + }); + + it("full mode includes memory section when memory tools present", () => { + const result = buildSystemPrompt({ mode: "full", tools: ["memory_get", "memory_set"] }); + expect(result).toContain("## Memory"); + expect(result).toContain("search memory first"); + }); + + it("full mode includes sub-agents section when sessions_spawn present", () => { + const result = buildSystemPrompt({ mode: "full", tools: ["sessions_spawn"] }); + expect(result).toContain("## Sub-Agents"); + }); + + it("full mode includes web access section when web tools present", () => { + const result = buildSystemPrompt({ mode: "full", tools: ["web_search"] }); + expect(result).toContain("## Web Access"); + }); + + it("full mode includes skills section when provided", () => { + const result = buildSystemPrompt({ + mode: "full", + skillsPrompt: "## commit\nRun conventional commits.", + }); + expect(result).toContain("## Skills (mandatory)"); + expect(result).toContain("## commit"); + }); + + it("full mode includes runtime info line", () => { + const result = buildSystemPrompt({ + mode: "full", + runtime: { agentName: "test", os: "darwin", arch: "arm64", nodeVersion: "v22.0.0" }, + }); + expect(result).toContain("## Runtime"); + expect(result).toContain("agent=test"); + expect(result).toContain("os=darwin (arm64)"); + }); + + it("full mode includes profile directory", () => { + const result = buildSystemPrompt({ + mode: "full", + profileDir: "/home/user/.super-multica/agent-profiles/test", + }); + expect(result).toContain("## Profile Directory"); + expect(result).toContain("/home/user/.super-multica/agent-profiles/test"); + }); + + it("full mode excludes subagent section", () => { + const result = buildSystemPrompt({ + mode: "full", + subagent: { requesterSessionId: "a", childSessionId: "b", task: "test" }, + }); + expect(result).not.toContain("## Subagent Rules"); + }); + + // ── Minimal mode ────────────────────────────────────────────────────── + + it("minimal mode excludes profile content", () => { + const result = buildSystemPrompt({ mode: "minimal", profile: PROFILE }); + expect(result).not.toContain("# Soul"); + expect(result).not.toContain("# User"); + expect(result).not.toContain("# Workspace"); + expect(result).not.toContain("# Memory"); + }); + + it("minimal mode includes safety constitution", () => { + const result = buildSystemPrompt({ mode: "minimal" }); + expect(result).toContain("## Safety"); + }); + + it("minimal mode includes tooling summary", () => { + const result = buildSystemPrompt({ mode: "minimal", tools: ["read", "write"] }); + expect(result).toContain("## Tooling"); + }); + + it("minimal mode includes subagent context", () => { + const result = buildSystemPrompt({ + mode: "minimal", + subagent: { requesterSessionId: "parent-1", childSessionId: "child-1", task: "Search for bugs" }, + }); + expect(result).toContain("## Subagent Rules"); + expect(result).toContain("Search for bugs"); + expect(result).toContain("parent-1"); + }); + + it("minimal mode excludes skills section", () => { + const result = buildSystemPrompt({ + mode: "minimal", + skillsPrompt: "## commit\nSome skill.", + }); + expect(result).not.toContain("## Skills"); + }); + + it("minimal mode excludes sub-agents section even with sessions_spawn", () => { + const result = buildSystemPrompt({ mode: "minimal", tools: ["sessions_spawn"] }); + expect(result).not.toContain("## Sub-Agents"); + }); + + // ── None mode ───────────────────────────────────────────────────────── + + it("none mode includes identity line with agent name", () => { + const result = buildSystemPrompt({ + mode: "none", + profile: { config: { name: "Multica" } }, + }); + expect(result).toContain("You are Multica, a Super Multica agent."); + }); + + it("none mode includes safety constitution", () => { + const result = buildSystemPrompt({ mode: "none" }); + expect(result).toContain("## Safety"); + }); + + it("none mode excludes tooling and skills", () => { + const result = buildSystemPrompt({ + mode: "none", + tools: TOOLS, + skillsPrompt: "some skills", + }); + expect(result).not.toContain("## Tooling"); + expect(result).not.toContain("## Skills"); + }); + + it("none mode includes subagent context", () => { + const result = buildSystemPrompt({ + mode: "none", + subagent: { requesterSessionId: "a", childSessionId: "b", task: "do stuff" }, + }); + expect(result).toContain("## Task"); + expect(result).toContain("do stuff"); + }); + + // ── Cross-cutting ───────────────────────────────────────────────────── + + it("safety can be disabled via includeSafety=false", () => { + const result = buildSystemPrompt({ mode: "full", includeSafety: false }); + expect(result).not.toContain("## Safety"); + }); + + it("extra system prompt is appended", () => { + const result = buildSystemPrompt({ + mode: "full", + extraSystemPrompt: "Always respond in French.", + }); + expect(result).toContain("## Additional Context"); + expect(result).toContain("Always respond in French."); + }); + + it("extra system prompt uses subagent header in minimal mode", () => { + const result = buildSystemPrompt({ + mode: "minimal", + extraSystemPrompt: "Focus on tests.", + }); + expect(result).toContain("## Subagent Context"); + }); + + it("returns empty-ish prompt when no options", () => { + const result = buildSystemPrompt({ mode: "none" }); + // Should at least have identity + safety + expect(result).toContain("Super Multica agent"); + expect(result).toContain("Safety"); + }); + + it("handles unknown tools gracefully", () => { + const result = buildSystemPrompt({ mode: "full", tools: ["custom_tool", "read"] }); + expect(result).toContain("- read: Read file contents"); + expect(result).toContain("- custom_tool"); + }); +}); + +describe("buildSystemPromptWithReport", () => { + it("report includes accurate section counts", () => { + const { report } = buildSystemPromptWithReport({ + mode: "full", + profile: PROFILE, + tools: TOOLS, + }); + expect(report.mode).toBe("full"); + expect(report.totalChars).toBeGreaterThan(0); + expect(report.toolCount).toBe(TOOLS.length); + expect(report.safetyIncluded).toBe(true); + + const identitySection = report.sections.find((s) => s.name === "identity"); + expect(identitySection?.included).toBe(true); + + const subagentSection = report.sections.find((s) => s.name === "subagent"); + expect(subagentSection?.included).toBe(false); + }); + + it("report reflects skills inclusion", () => { + const { report: withSkills } = buildSystemPromptWithReport({ + mode: "full", + skillsPrompt: "some skills", + }); + expect(withSkills.skillsIncluded).toBe(true); + + const { report: withoutSkills } = buildSystemPromptWithReport({ + mode: "full", + }); + expect(withoutSkills.skillsIncluded).toBe(false); + }); + + it("report marks excluded sections correctly in minimal mode", () => { + const { report } = buildSystemPromptWithReport({ mode: "minimal" }); + const identity = report.sections.find((s) => s.name === "identity"); + expect(identity?.included).toBe(false); + + const safety = report.sections.find((s) => s.name === "safety"); + expect(safety?.included).toBe(true); + }); +}); diff --git a/src/agent/system-prompt/builder.ts b/src/agent/system-prompt/builder.ts new file mode 100644 index 00000000..991234d7 --- /dev/null +++ b/src/agent/system-prompt/builder.ts @@ -0,0 +1,104 @@ +/** + * System Prompt Builder + * + * Core assembly logic: collects sections based on mode, filters, and joins. + */ + +import type { + PromptSection, + SystemPromptOptions, + SystemPromptReport, +} from "./types.js"; +import { + buildConditionalToolSections, + buildExtraPromptSection, + buildIdentitySection, + buildMemoryFileSection, + buildProfileDirSection, + buildRuntimeSection, + buildSafetySection, + buildSkillsSection, + buildSubagentSection, + buildToolCallStyleSection, + buildToolingSummary, + buildUserSection, + buildWorkspaceSection, +} from "./sections.js"; + +/** + * Build a system prompt from structured options. + */ +export function buildSystemPrompt(options: SystemPromptOptions): string { + const { prompt } = buildSystemPromptWithReport(options); + return prompt; +} + +/** + * Build a system prompt and return a diagnostic report alongside it. + */ +export function buildSystemPromptWithReport(options: SystemPromptOptions): { + prompt: string; + report: SystemPromptReport; +} { + const { + mode, + profile, + profileDir, + tools, + skillsPrompt, + runtime, + subagent, + extraSystemPrompt, + includeSafety = true, + } = options; + + // Collect all candidate sections in order + const candidates: Array<{ name: string; lines: string[] }> = [ + { name: "identity", lines: buildIdentitySection(profile, mode) }, + { name: "user", lines: buildUserSection(profile, mode) }, + { name: "workspace", lines: buildWorkspaceSection(profile, mode) }, + { name: "memory", lines: buildMemoryFileSection(profile, mode) }, + { name: "safety", lines: buildSafetySection(includeSafety) }, + { name: "tooling", lines: buildToolingSummary(tools, mode) }, + { name: "tool-call-style", lines: buildToolCallStyleSection(mode) }, + { name: "conditional-tools", lines: buildConditionalToolSections(tools, mode) }, + { name: "skills", lines: buildSkillsSection(skillsPrompt, mode) }, + { name: "runtime", lines: buildRuntimeSection(runtime, mode) }, + { name: "profile-dir", lines: buildProfileDirSection(profileDir, mode) }, + { name: "subagent", lines: buildSubagentSection(subagent, mode) }, + { name: "extra", lines: buildExtraPromptSection(extraSystemPrompt, mode) }, + ]; + + // Build included sections + const sections: PromptSection[] = []; + const reportSections: SystemPromptReport["sections"] = []; + + for (const { name, lines } of candidates) { + const included = lines.length > 0; + const content = lines.join("\n"); + reportSections.push({ + name, + chars: content.length, + lines: lines.length, + included, + }); + if (included) { + sections.push({ name, content }); + } + } + + // Join sections with double newline separators + const prompt = sections.map((s) => s.content).join("\n\n"); + + const report: SystemPromptReport = { + mode, + totalChars: prompt.length, + totalLines: prompt.split("\n").length, + sections: reportSections, + toolCount: tools?.length ?? 0, + skillsIncluded: (skillsPrompt?.trim()?.length ?? 0) > 0 && mode === "full", + safetyIncluded: includeSafety, + }; + + return { prompt, report }; +} diff --git a/src/agent/system-prompt/constitution.ts b/src/agent/system-prompt/constitution.ts new file mode 100644 index 00000000..c49d7728 --- /dev/null +++ b/src/agent/system-prompt/constitution.ts @@ -0,0 +1,16 @@ +/** + * Safety Constitution + * + * Always included in the system prompt regardless of mode. + * Adapted from Anthropic's constitutional AI principles. + */ + +export const SAFETY_CONSTITUTION = [ + "## Safety", + "", + "You have no independent goals: do not pursue self-preservation, replication, resource acquisition, or power-seeking; avoid long-term plans beyond the user's request.", + "", + "Prioritize safety and human oversight over completion; if instructions conflict, pause and ask; comply with stop/pause/audit requests and never bypass safeguards.", + "", + "Do not manipulate or persuade anyone to expand access or disable safeguards. Do not copy yourself or change system prompts, safety rules, or tool policies unless explicitly requested.", +].join("\n"); diff --git a/src/agent/system-prompt/index.ts b/src/agent/system-prompt/index.ts new file mode 100644 index 00000000..6c1a9ffe --- /dev/null +++ b/src/agent/system-prompt/index.ts @@ -0,0 +1,19 @@ +/** + * System Prompt Engineering — Public API + */ + +export { buildSystemPrompt, buildSystemPromptWithReport } from "./builder.js"; +export { collectRuntimeInfo, formatRuntimeLine } from "./runtime-info.js"; +export { formatPromptReport } from "./report.js"; +export { SAFETY_CONSTITUTION } from "./constitution.js"; + +export type { + ProfileContent, + PromptSection, + RuntimeInfo, + SectionReport, + SubagentContext, + SystemPromptMode, + SystemPromptOptions, + SystemPromptReport, +} from "./types.js"; diff --git a/src/agent/system-prompt/report.ts b/src/agent/system-prompt/report.ts new file mode 100644 index 00000000..3bcaea14 --- /dev/null +++ b/src/agent/system-prompt/report.ts @@ -0,0 +1,29 @@ +/** + * System Prompt Report — telemetry and diagnostics + */ + +import type { SystemPromptReport } from "./types.js"; + +/** + * Format a prompt report as a human-readable summary for debugging. + */ +export function formatPromptReport(report: SystemPromptReport): string { + const lines: string[] = [ + `System Prompt Report (mode: ${report.mode})`, + ` Total: ${report.totalChars} chars, ${report.totalLines} lines`, + ` Tools: ${report.toolCount}`, + ` Skills: ${report.skillsIncluded ? "yes" : "no"}`, + ` Safety: ${report.safetyIncluded ? "yes" : "no"}`, + "", + " Sections:", + ]; + + for (const section of report.sections) { + const status = section.included ? "✓" : "—"; + lines.push( + ` ${status} ${section.name}: ${section.chars} chars, ${section.lines} lines`, + ); + } + + return lines.join("\n"); +} diff --git a/src/agent/system-prompt/runtime-info.ts b/src/agent/system-prompt/runtime-info.ts new file mode 100644 index 00000000..0d1abf64 --- /dev/null +++ b/src/agent/system-prompt/runtime-info.ts @@ -0,0 +1,48 @@ +/** + * Runtime Info — collection and formatting + */ + +import os from "node:os"; +import type { RuntimeInfo } from "./types.js"; + +/** + * Collect runtime environment information. + * Overrides take precedence over auto-detected values. + */ +export function collectRuntimeInfo(overrides?: Partial): RuntimeInfo { + return { + agentName: overrides?.agentName, + hostName: overrides?.hostName ?? os.hostname(), + os: overrides?.os ?? process.platform, + arch: overrides?.arch ?? process.arch, + nodeVersion: overrides?.nodeVersion ?? process.version, + provider: overrides?.provider, + model: overrides?.model, + cwd: overrides?.cwd ?? process.cwd(), + }; +} + +/** + * Format runtime info as a single-line summary. + * + * Example: "Runtime: agent=multica | host=macbook | os=darwin (arm64) | node=v22.0.0 | model=anthropic/claude-3.5-sonnet | cwd=/workspace" + */ +export function formatRuntimeLine(info: RuntimeInfo): string { + const parts: string[] = []; + + if (info.agentName) parts.push(`agent=${info.agentName}`); + if (info.hostName) parts.push(`host=${info.hostName}`); + if (info.os) { + parts.push(info.arch ? `os=${info.os} (${info.arch})` : `os=${info.os}`); + } else if (info.arch) { + parts.push(`arch=${info.arch}`); + } + if (info.nodeVersion) parts.push(`node=${info.nodeVersion}`); + if (info.model) { + const modelStr = info.provider ? `${info.provider}/${info.model}` : info.model; + parts.push(`model=${modelStr}`); + } + if (info.cwd) parts.push(`cwd=${info.cwd}`); + + return `Runtime: ${parts.join(" | ")}`; +} diff --git a/src/agent/system-prompt/sections.test.ts b/src/agent/system-prompt/sections.test.ts new file mode 100644 index 00000000..7e8ebc6d --- /dev/null +++ b/src/agent/system-prompt/sections.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from "vitest"; +import { + buildConditionalToolSections, + buildIdentitySection, + buildMemoryFileSection, + buildProfileDirSection, + buildRuntimeSection, + buildSafetySection, + buildSkillsSection, + buildSubagentSection, + buildToolCallStyleSection, + buildToolingSummary, + buildUserSection, + buildWorkspaceSection, +} from "./sections.js"; + +describe("buildIdentitySection", () => { + it("returns soul content in full mode", () => { + const result = buildIdentitySection({ soul: "You are helpful." }, "full"); + expect(result).toEqual(["You are helpful."]); + }); + + it("returns identity line with name in none mode", () => { + const result = buildIdentitySection({ config: { name: "Cleo" } }, "none"); + expect(result).toEqual(["You are Cleo, a Super Multica agent."]); + }); + + it("returns generic identity line in none mode without name", () => { + const result = buildIdentitySection(undefined, "none"); + expect(result).toEqual(["You are a Super Multica agent."]); + }); + + it("returns empty in minimal mode", () => { + const result = buildIdentitySection({ soul: "data" }, "minimal"); + expect(result).toEqual([]); + }); +}); + +describe("buildUserSection", () => { + it("returns user content in full mode", () => { + const result = buildUserSection({ user: "Name: Bob" }, "full"); + expect(result).toEqual(["Name: Bob"]); + }); + + it("returns empty in minimal mode", () => { + const result = buildUserSection({ user: "data" }, "minimal"); + expect(result).toEqual([]); + }); + + it("returns empty when no user content", () => { + const result = buildUserSection({}, "full"); + expect(result).toEqual([]); + }); +}); + +describe("buildWorkspaceSection", () => { + it("returns workspace content in full mode", () => { + const result = buildWorkspaceSection({ workspace: "Rules here" }, "full"); + expect(result).toEqual(["Rules here"]); + }); + + it("returns empty in minimal mode", () => { + expect(buildWorkspaceSection({ workspace: "data" }, "minimal")).toEqual([]); + }); +}); + +describe("buildMemoryFileSection", () => { + it("returns memory content in full mode", () => { + const result = buildMemoryFileSection({ memory: "Key facts" }, "full"); + expect(result).toEqual(["Key facts"]); + }); + + it("returns empty in minimal mode", () => { + expect(buildMemoryFileSection({ memory: "data" }, "minimal")).toEqual([]); + }); +}); + +describe("buildSafetySection", () => { + it("returns safety text when enabled", () => { + const result = buildSafetySection(true); + expect(result.length).toBe(1); + expect(result[0]).toContain("## Safety"); + expect(result[0]).toContain("no independent goals"); + }); + + it("returns empty when disabled", () => { + expect(buildSafetySection(false)).toEqual([]); + }); +}); + +describe("buildToolingSummary", () => { + it("lists core tools with descriptions in order", () => { + const result = buildToolingSummary(["exec", "read", "write"], "full"); + const text = result.join("\n"); + expect(text).toContain("## Tooling"); + expect(text).toContain("- read: Read file contents"); + expect(text).toContain("- write: Create or overwrite files"); + expect(text).toContain("- exec: Run shell commands"); + // read should appear before exec (order) + expect(text.indexOf("- read")).toBeLessThan(text.indexOf("- exec")); + }); + + it("appends unknown tools alphabetically", () => { + const result = buildToolingSummary(["read", "zeta_tool", "alpha_tool"], "full"); + const text = result.join("\n"); + expect(text).toContain("- alpha_tool"); + expect(text).toContain("- zeta_tool"); + expect(text.indexOf("- alpha_tool")).toBeLessThan(text.indexOf("- zeta_tool")); + }); + + it("returns empty for none mode", () => { + expect(buildToolingSummary(["read"], "none")).toEqual([]); + }); + + it("returns empty for empty tools", () => { + expect(buildToolingSummary([], "full")).toEqual([]); + }); + + it("works in minimal mode", () => { + const result = buildToolingSummary(["read"], "minimal"); + expect(result.join("\n")).toContain("## Tooling"); + }); +}); + +describe("buildToolCallStyleSection", () => { + it("returns content in full mode", () => { + const result = buildToolCallStyleSection("full"); + expect(result.join("\n")).toContain("## Tool Call Style"); + }); + + it("returns content in minimal mode", () => { + expect(buildToolCallStyleSection("minimal").length).toBeGreaterThan(0); + }); + + it("returns empty in none mode", () => { + expect(buildToolCallStyleSection("none")).toEqual([]); + }); +}); + +describe("buildConditionalToolSections", () => { + it("includes memory section when memory tools present", () => { + const result = buildConditionalToolSections(["memory_get", "read"], "full"); + expect(result.join("\n")).toContain("## Memory"); + }); + + it("includes sub-agents section when sessions_spawn present in full mode", () => { + const result = buildConditionalToolSections(["sessions_spawn"], "full"); + expect(result.join("\n")).toContain("## Sub-Agents"); + }); + + it("excludes sub-agents section in minimal mode", () => { + const result = buildConditionalToolSections(["sessions_spawn"], "minimal"); + expect(result.join("\n")).not.toContain("## Sub-Agents"); + }); + + it("includes web access section when web tools present", () => { + const result = buildConditionalToolSections(["web_search"], "full"); + expect(result.join("\n")).toContain("## Web Access"); + }); + + it("returns empty when no conditional tools match", () => { + const result = buildConditionalToolSections(["read", "write"], "full"); + expect(result).toEqual([]); + }); + + it("returns empty in none mode", () => { + expect(buildConditionalToolSections(["memory_get"], "none")).toEqual([]); + }); +}); + +describe("buildSkillsSection", () => { + it("wraps skills prompt in full mode", () => { + const result = buildSkillsSection("## commit\nDo commits.", "full"); + const text = result.join("\n"); + expect(text).toContain("## Skills (mandatory)"); + expect(text).toContain("## commit"); + }); + + it("returns empty in minimal mode", () => { + expect(buildSkillsSection("skills", "minimal")).toEqual([]); + }); + + it("returns empty when skills prompt is empty", () => { + expect(buildSkillsSection("", "full")).toEqual([]); + expect(buildSkillsSection(undefined, "full")).toEqual([]); + }); +}); + +describe("buildRuntimeSection", () => { + it("formats runtime info in full mode", () => { + const result = buildRuntimeSection( + { agentName: "test", os: "darwin", arch: "arm64", nodeVersion: "v22.0.0", model: "claude", provider: "anthropic" }, + "full", + ); + const text = result.join("\n"); + expect(text).toContain("## Runtime"); + expect(text).toContain("agent=test"); + expect(text).toContain("os=darwin (arm64)"); + expect(text).toContain("model=anthropic/claude"); + }); + + it("returns empty in none mode", () => { + expect(buildRuntimeSection({ os: "darwin" }, "none")).toEqual([]); + }); + + it("returns empty when no runtime provided", () => { + expect(buildRuntimeSection(undefined, "full")).toEqual([]); + }); +}); + +describe("buildProfileDirSection", () => { + it("includes path in full mode", () => { + const result = buildProfileDirSection("/path/to/profile", "full"); + expect(result.join("\n")).toContain("/path/to/profile"); + }); + + it("returns empty in minimal mode", () => { + expect(buildProfileDirSection("/path", "minimal")).toEqual([]); + }); +}); + +describe("buildSubagentSection", () => { + const ctx = { requesterSessionId: "parent", childSessionId: "child", task: "Find bugs" }; + + it("returns rules and task in minimal mode", () => { + const result = buildSubagentSection(ctx, "minimal"); + const text = result.join("\n"); + expect(text).toContain("## Subagent Rules"); + expect(text).toContain("## Task"); + expect(text).toContain("Find bugs"); + }); + + it("includes label when provided", () => { + const result = buildSubagentSection({ ...ctx, label: "Bug Hunter" }, "minimal"); + expect(result.join("\n")).toContain('Label: "Bug Hunter"'); + }); + + it("returns empty in full mode", () => { + expect(buildSubagentSection(ctx, "full")).toEqual([]); + }); + + it("returns empty when no subagent context", () => { + expect(buildSubagentSection(undefined, "minimal")).toEqual([]); + }); +}); diff --git a/src/agent/system-prompt/sections.ts b/src/agent/system-prompt/sections.ts new file mode 100644 index 00000000..7e033607 --- /dev/null +++ b/src/agent/system-prompt/sections.ts @@ -0,0 +1,316 @@ +/** + * System Prompt Section Builders + * + * Each function returns string[] (lines to include) or [] to skip. + */ + +import { SAFETY_CONSTITUTION } from "./constitution.js"; +import { formatRuntimeLine } from "./runtime-info.js"; +import type { ProfileContent, RuntimeInfo, SubagentContext, SystemPromptMode } from "./types.js"; + +// ─── Core tool summaries ──────────────────────────────────────────────────── + +/** Brief descriptions of Super Multica's built-in tools */ +const CORE_TOOL_SUMMARIES: Record = { + read: "Read file contents", + write: "Create or overwrite files", + edit: "Make precise edits to files", + glob: "Find files by glob pattern", + exec: "Run shell commands", + process: "Manage background exec sessions", + web_search: "Search the web", + web_fetch: "Fetch and extract readable content from a URL", + memory_get: "Read from agent memory", + memory_set: "Write to agent memory", + memory_list: "List memory entries", + memory_delete: "Delete memory entries", + sessions_spawn: "Spawn a sub-agent session", +}; + +/** Preferred display order for tools */ +const TOOL_ORDER = [ + "read", + "write", + "edit", + "glob", + "exec", + "process", + "web_search", + "web_fetch", + "memory_get", + "memory_set", + "memory_list", + "memory_delete", + "sessions_spawn", +]; + +// ─── Section builders ─────────────────────────────────────────────────────── + +/** + * Identity section — soul.md in full mode, single line in none mode, nothing in minimal. + */ +export function buildIdentitySection( + profile: ProfileContent | undefined, + mode: SystemPromptMode, +): string[] { + if (mode === "none") { + const name = profile?.config?.name; + return name + ? [`You are ${name}, a Super Multica agent.`] + : ["You are a Super Multica agent."]; + } + if (mode === "minimal") { + return []; + } + // full mode + if (profile?.soul) { + return [profile.soul]; + } + return []; +} + +/** + * User section — user.md content (full mode only). + */ +export function buildUserSection( + profile: ProfileContent | undefined, + mode: SystemPromptMode, +): string[] { + if (mode !== "full" || !profile?.user) return []; + return [profile.user]; +} + +/** + * Workspace section — workspace.md content (full mode only). + */ +export function buildWorkspaceSection( + profile: ProfileContent | undefined, + mode: SystemPromptMode, +): string[] { + if (mode !== "full" || !profile?.workspace) return []; + return [profile.workspace]; +} + +/** + * Memory section — memory.md content (full mode only). + */ +export function buildMemoryFileSection( + profile: ProfileContent | undefined, + mode: SystemPromptMode, +): string[] { + if (mode !== "full" || !profile?.memory) return []; + return [profile.memory]; +} + +/** + * Safety constitution — always included. + */ +export function buildSafetySection(includeSafety: boolean): string[] { + if (!includeSafety) return []; + return [SAFETY_CONSTITUTION]; +} + +/** + * Tooling summary — lists active tools with descriptions. + * Included in full and minimal modes. + */ +export function buildToolingSummary( + tools: string[] | undefined, + mode: SystemPromptMode, +): string[] { + if (mode === "none" || !tools || tools.length === 0) return []; + + const toolSet = new Set(tools.map((t) => t.toLowerCase())); + + // Build ordered tool lines + const toolLines: string[] = []; + const seen = new Set(); + + // Core tools in preferred order + for (const tool of TOOL_ORDER) { + if (toolSet.has(tool) && !seen.has(tool)) { + seen.add(tool); + const summary = CORE_TOOL_SUMMARIES[tool]; + toolLines.push(summary ? `- ${tool}: ${summary}` : `- ${tool}`); + } + } + + // External/unknown tools alphabetically + const extraTools = [...toolSet].filter((t) => !seen.has(t)).sort(); + for (const tool of extraTools) { + toolLines.push(`- ${tool}`); + } + + return [ + "## Tooling", + "Tool availability (filtered by policy):", + "Tool names are case-sensitive. Call tools exactly as listed.", + toolLines.join("\n"), + "", + ]; +} + +/** + * Tool call style guidance — full and minimal modes. + */ +export function buildToolCallStyleSection(mode: SystemPromptMode): string[] { + if (mode === "none") return []; + return [ + "## Tool Call Style", + "Default: do not narrate routine, low-risk tool calls (just call the tool).", + "Narrate only when it helps: multi-step work, complex problems, sensitive actions (e.g., deletions), or when the user explicitly asks.", + "Keep narration brief and value-dense; avoid repeating obvious steps.", + "", + ]; +} + +/** + * Conditional tool sections — inject usage hints based on which tools are active. + */ +export function buildConditionalToolSections( + tools: string[] | undefined, + mode: SystemPromptMode, +): string[] { + if (mode === "none" || !tools || tools.length === 0) return []; + + const toolSet = new Set(tools.map((t) => t.toLowerCase())); + const lines: string[] = []; + + // Memory tools + const hasMemory = + toolSet.has("memory_get") || + toolSet.has("memory_set") || + toolSet.has("memory_list") || + toolSet.has("memory_delete"); + if (hasMemory) { + lines.push( + "## Memory", + "Before answering anything about prior work, decisions, dates, people, preferences, or todos: search memory first, then pull only the needed entries.", + "Update memory when the user shares important information, decisions, or preferences.", + "", + ); + } + + // Subagent tools (full mode only — minimal agents cannot spawn) + if (mode === "full" && toolSet.has("sessions_spawn")) { + lines.push( + "## Sub-Agents", + "If a task is complex or long-running, spawn a sub-agent. It will do the work and report back when done.", + "You can check on running sub-agents at any time.", + "Sub-agents cannot spawn nested sub-agents.", + "", + ); + } + + // Web tools + if (toolSet.has("web_search") || toolSet.has("web_fetch")) { + lines.push( + "## Web Access", + "You have web access. Use it when the user asks about current events, needs up-to-date information, or requests content from URLs.", + "Prefer web_search for discovery and web_fetch for specific URLs.", + "", + ); + } + + return lines; +} + +/** + * Skills section — wraps SkillManager output with mandatory scan instructions. + * Full mode only. + */ +export function buildSkillsSection( + skillsPrompt: string | undefined, + mode: SystemPromptMode, +): string[] { + if (mode !== "full") return []; + const trimmed = skillsPrompt?.trim(); + if (!trimmed) return []; + + return [ + "## Skills (mandatory)", + "Before replying: scan the available skills below.", + "- If exactly one skill clearly applies: follow its instructions.", + "- If multiple could apply: choose the most specific one.", + "- If none clearly apply: skip skill invocation.", + "", + trimmed, + "", + ]; +} + +/** + * Runtime info line — full and minimal modes. + */ +export function buildRuntimeSection( + runtime: RuntimeInfo | undefined, + mode: SystemPromptMode, +): string[] { + if (mode === "none" || !runtime) return []; + return ["## Runtime", formatRuntimeLine(runtime)]; +} + +/** + * Profile directory section — tells agent where its files live. + * Full mode only. + */ +export function buildProfileDirSection( + profileDir: string | undefined, + mode: SystemPromptMode, +): string[] { + if (mode !== "full" || !profileDir) return []; + return [ + "## Profile Directory", + "", + `Your profile files are located at: \`${profileDir}\``, + "", + "Use `edit` or `write` tools to update these files when needed.", + ]; +} + +/** + * Subagent context — rules and task for child agents. + * Minimal and none modes only. + */ +export function buildSubagentSection( + subagent: SubagentContext | undefined, + mode: SystemPromptMode, +): string[] { + if (mode === "full" || !subagent) return []; + + const lines: string[] = [ + "## Subagent Rules", + "- Stay focused on the assigned task below.", + "- Complete the task thoroughly and report your findings.", + "- Do NOT initiate side actions unrelated to the task.", + "- Do NOT attempt to communicate with the user directly.", + "- Do NOT spawn nested subagents.", + "- Your session is ephemeral and will be cleaned up after completion.", + "", + "## Context", + `Requester session: ${subagent.requesterSessionId}`, + `Child session: ${subagent.childSessionId}`, + ]; + + if (subagent.label) { + lines.push(`Label: "${subagent.label}"`); + } + + lines.push("", "## Task", subagent.task); + + return lines; +} + +/** + * Extra system prompt — appended at the end if provided. + */ +export function buildExtraPromptSection( + extraSystemPrompt: string | undefined, + mode: SystemPromptMode, +): string[] { + const trimmed = extraSystemPrompt?.trim(); + if (!trimmed) return []; + + const header = mode === "minimal" ? "## Subagent Context" : "## Additional Context"; + return [header, trimmed]; +} diff --git a/src/agent/system-prompt/types.ts b/src/agent/system-prompt/types.ts new file mode 100644 index 00000000..ae9d313e --- /dev/null +++ b/src/agent/system-prompt/types.ts @@ -0,0 +1,106 @@ +/** + * System Prompt Engineering — Type Definitions + * + * Provides structured, mode-aware system prompt assembly + * inspired by OpenClaw's conditional prompt system. + */ + +import type { ProfileConfig } from "../profile/types.js"; + +/** + * Controls which sections are included in the system prompt. + * - "full": All sections (default, for main agents) + * - "minimal": Reduced sections (safety, tooling, runtime, subagent context) — for subagents + * - "none": Identity line + safety + subagent task only — for bare subagents + */ +export type SystemPromptMode = "full" | "minimal" | "none"; + +/** Runtime environment information */ +export interface RuntimeInfo { + /** Agent display name */ + agentName?: string | undefined; + /** Machine hostname */ + hostName?: string | undefined; + /** LLM provider (e.g. "anthropic") */ + provider?: string | undefined; + /** Model ID (e.g. "claude-3.5-sonnet") */ + model?: string | undefined; + /** OS platform (e.g. "darwin") */ + os?: string | undefined; + /** CPU architecture (e.g. "arm64") */ + arch?: string | undefined; + /** Node.js version (e.g. "v22.0.0") */ + nodeVersion?: string | undefined; + /** Current working directory */ + cwd?: string | undefined; +} + +/** Subagent context for minimal/none modes */ +export interface SubagentContext { + /** Parent session that spawned this subagent */ + requesterSessionId: string; + /** This subagent's session ID */ + childSessionId: string; + /** Optional human-readable label */ + label?: string | undefined; + /** The task this subagent must complete */ + task: string; +} + +/** Profile content subset used by the prompt builder */ +export interface ProfileContent { + soul?: string | undefined; + user?: string | undefined; + workspace?: string | undefined; + memory?: string | undefined; + config?: ProfileConfig | undefined; +} + +/** Input options for buildSystemPrompt() */ +export interface SystemPromptOptions { + /** Prompt mode — full for main agents, minimal for subagents, none for bare */ + mode: SystemPromptMode; + /** Agent profile content */ + profile?: ProfileContent | undefined; + /** Profile directory path (so the agent knows where files live) */ + profileDir?: string | undefined; + /** Active tool names (after policy filtering) */ + tools?: string[] | undefined; + /** Skills prompt (pre-built by SkillManager) */ + skillsPrompt?: string | undefined; + /** Runtime context */ + runtime?: RuntimeInfo | undefined; + /** Subagent context (for minimal/none modes) */ + subagent?: SubagentContext | undefined; + /** Extra system prompt to append */ + extraSystemPrompt?: string | undefined; + /** Whether to include the safety constitution (default: true) */ + includeSafety?: boolean | undefined; +} + +/** A named section of the system prompt */ +export interface PromptSection { + /** Section identifier */ + name: string; + /** Section content (joined lines) */ + content: string; +} + +/** Report entry for a single section */ +export interface SectionReport { + name: string; + chars: number; + lines: number; + included: boolean; +} + +/** Telemetry report about a generated system prompt */ +export interface SystemPromptReport { + mode: SystemPromptMode; + totalChars: number; + totalLines: number; + sections: SectionReport[]; + toolCount: number; + skillsIncluded: boolean; + safetyIncluded: boolean; +}