multica/src/agent/system-prompt/sections.ts

375 lines
11 KiB
TypeScript

/**
* 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 { resolveHeartbeatPrompt } from "../../heartbeat/heartbeat-text.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<string, string> = {
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 via Devv Search",
web_fetch: "Fetch and extract readable content from a URL",
memory_search: "Search memory files by keyword",
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_search",
"sessions_spawn",
];
// ─── Section builders ───────────────────────────────────────────────────────
/**
* Identity section — brief identity line only.
* Full profile content (soul.md) is loaded on-demand by the agent.
*/
export function buildIdentitySection(
profile: ProfileContent | undefined,
mode: SystemPromptMode,
): string[] {
const name = profile?.config?.name;
if (mode === "none" || mode === "minimal") {
return name
? [`You are ${name}, a Super Multica agent.`]
: ["You are a Super Multica agent."];
}
// full mode - just identity line, agent reads soul.md on demand
return name
? [`You are ${name}, a Super Multica agent.`]
: ["You are a Super Multica agent."];
}
/**
* User section — no longer injected into system prompt.
* Agent reads user.md on demand from profile directory.
*/
export function buildUserSection(
_profile: ProfileContent | undefined,
_mode: SystemPromptMode,
): string[] {
// Progressive disclosure: agent reads user.md on demand
return [];
}
/**
* Workspace section — workspace.md content with profile directory path.
* This is the primary profile content injected into system prompt.
* Other profile files (soul.md, user.md, memory.md) are read on demand.
*/
export function buildWorkspaceSection(
profile: ProfileContent | undefined,
mode: SystemPromptMode,
profileDir?: string,
): string[] {
if (mode !== "full") return [];
const lines: string[] = [];
// Add profile directory context first
if (profileDir) {
lines.push(
"## Profile",
"",
`Your profile directory: \`${profileDir}\``,
"Use this as the base path for profile files (soul.md, user.md, memory.md, heartbeat.md, memory/*.md).",
"",
"Profile files:",
"- `soul.md` — Your identity and values",
"- `user.md` — Information about your user",
"- `workspace.md` — Guidelines and conventions (below)",
"- `memory.md` — Persistent knowledge",
"- `heartbeat.md` — Background heartbeat loop instructions",
"",
);
}
// Add workspace.md content
if (profile?.workspace) {
lines.push(profile.workspace);
}
return lines;
}
/**
* Memory section — no longer injected into system prompt.
* Agent reads memory.md on demand from profile directory.
*/
export function buildMemoryFileSection(
_profile: ProfileContent | undefined,
_mode: SystemPromptMode,
): string[] {
// Progressive disclosure: agent reads memory.md on demand
return [];
}
/**
* Heartbeat section — full mode only.
* Keeps heartbeat protocol explicit in the agent instructions.
*/
export function buildHeartbeatSection(
profile: ProfileContent | undefined,
mode: SystemPromptMode,
): string[] {
if (mode !== "full") return [];
const prompt = resolveHeartbeatPrompt(profile?.config?.heartbeat?.prompt);
return [
"## Heartbeats",
`Heartbeat prompt: ${prompt}`,
'If you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:',
"HEARTBEAT_OK",
'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.',
"",
];
}
/**
* 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.
* Preserves original tool casing while deduplicating by lowercase.
*/
export function buildToolingSummary(
tools: string[] | undefined,
mode: SystemPromptMode,
): string[] {
if (mode === "none" || !tools || tools.length === 0) return [];
// Preserve original casing: first occurrence wins per normalized name
const canonicalByNormalized = new Map<string, string>();
for (const name of tools) {
const normalized = name.toLowerCase();
if (!canonicalByNormalized.has(normalized)) {
canonicalByNormalized.set(normalized, name);
}
}
const resolveToolName = (normalized: string) =>
canonicalByNormalized.get(normalized) ?? normalized;
const normalizedTools = new Set(canonicalByNormalized.keys());
// Build ordered tool lines
const toolLines: string[] = [];
const seen = new Set<string>();
// Core tools in preferred order
for (const tool of TOOL_ORDER) {
if (normalizedTools.has(tool) && !seen.has(tool)) {
seen.add(tool);
const displayName = resolveToolName(tool);
const summary = CORE_TOOL_SUMMARIES[tool];
toolLines.push(
summary ? `- ${displayName}: ${summary}` : `- ${displayName}`,
);
}
}
// External/unknown tools alphabetically
const extraTools = [...normalizedTools].filter((t) => !seen.has(t)).sort();
for (const tool of extraTools) {
const displayName = resolveToolName(tool);
toolLines.push(`- ${displayName}`);
}
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
if (toolSet.has("memory_search")) {
lines.push(
"## Memory Recall",
"Before answering anything about prior work, decisions, dates, people, preferences, or todos:",
"1. Run `memory_search` to find relevant entries in memory files",
"2. Use `read` to pull needed context",
"",
"To update memory, use `edit` on the appropriate file:",
"- `memory.md` — Long-term knowledge (decisions, preferences, important context)",
"- `memory/YYYY-MM-DD.md` — Daily logs and session notes",
"",
);
}
// 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 — now merged into buildWorkspaceSection.
* Kept for backwards compatibility but returns empty.
*/
export function buildProfileDirSection(
_profileDir: string | undefined,
_mode: SystemPromptMode,
): string[] {
// Profile directory info is now part of workspace section
return [];
}
/**
* 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];
}