a58584ff3c
- Added shared Python call and literal serialization utilities with multiline verbatim support. - Standardized tool inventories to format as an OpenAI-Harmony functions namespace using TypeScript declarations. - Updated tool normalization and rendering functions to accept options objects and default to Python-syntax examples. - Refactored Gemini dialect rendering to leverage shared serialization functions directly.
922 lines
36 KiB
TypeScript
922 lines
36 KiB
TypeScript
/**
|
|
* System prompt construction and project context loading
|
|
*/
|
|
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
import type { ToolExample, TSchema } from "@oh-my-pi/pi-ai";
|
|
import { renderToolInventory } from "@oh-my-pi/pi-ai/dialect";
|
|
import { $env, getGpuCachePath, getProjectDir, hasFsCode, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils";
|
|
import { contextFileCapability } from "./capability/context-file";
|
|
import { systemPromptCapability } from "./capability/system-prompt";
|
|
import { findConfigFile } from "./config";
|
|
import type { Personality, SkillsSettings } from "./config/settings";
|
|
import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile } from "./discovery";
|
|
import { expandAtImports } from "./discovery/at-imports";
|
|
import { loadSkills, type Skill } from "./extensibility/skills";
|
|
import { hasObsidian } from "./internal-urls/vault-protocol";
|
|
import activeRepoContextTemplate from "./prompts/system/active-repo-context.md" with { type: "text" };
|
|
import computerSafetyPrompt from "./prompts/system/computer-safety.md" with { type: "text" };
|
|
import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
|
|
import defaultPersonality from "./prompts/system/personalities/default.md" with { type: "text" };
|
|
import friendlyPersonality from "./prompts/system/personalities/friendly.md" with { type: "text" };
|
|
import pragmaticPersonality from "./prompts/system/personalities/pragmatic.md" with { type: "text" };
|
|
import projectPromptTemplate from "./prompts/system/project-prompt.md" with { type: "text" };
|
|
import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
|
|
import { normalizeConcurrencyLimit } from "./task/parallel";
|
|
import { usesCodexTaskPrompt } from "./task/prompt-policy";
|
|
import { type ActiveRepoContext, resolveActiveRepoContext } from "./utils/active-repo-context";
|
|
import { formatLocalCalendarDate } from "./utils/local-date";
|
|
import { normalizePromptPath } from "./utils/prompt-path";
|
|
import { AGENTS_MD_LIMIT, buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
|
|
|
|
/** Bundled personality specs, keyed by the `personality` setting value. */
|
|
const PERSONALITY_SPECS: Record<Exclude<Personality, "none">, string> = {
|
|
default: defaultPersonality,
|
|
friendly: friendlyPersonality,
|
|
pragmatic: pragmaticPersonality,
|
|
};
|
|
|
|
interface AlwaysApplyRule {
|
|
name: string;
|
|
content: string;
|
|
path: string;
|
|
}
|
|
|
|
function normalizePromptBlock(content: string): string {
|
|
return prompt.format(content, { renderPhase: "post-render" }).trim();
|
|
}
|
|
|
|
function splitComparablePromptBlocks(content: string | null | undefined): string[] {
|
|
const normalized = firstNonEmpty(content);
|
|
if (!normalized) return [];
|
|
|
|
return normalizePromptBlock(normalized)
|
|
.split(/\n{2,}/)
|
|
.map(block => block.trim())
|
|
.filter(block => block.length > 0);
|
|
}
|
|
|
|
function promptSourceContainsRule(source: string | null | undefined, ruleContent: string): boolean {
|
|
const sourceBlocks = splitComparablePromptBlocks(source);
|
|
const ruleBlocks = splitComparablePromptBlocks(ruleContent);
|
|
if (sourceBlocks.length === 0 || ruleBlocks.length === 0 || ruleBlocks.length > sourceBlocks.length) return false;
|
|
|
|
for (let start = 0; start <= sourceBlocks.length - ruleBlocks.length; start += 1) {
|
|
if (ruleBlocks.every((block, offset) => sourceBlocks[start + offset] === block)) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function dedupeAlwaysApplyRules(
|
|
alwaysApplyRules: AlwaysApplyRule[] | undefined,
|
|
promptSources: Array<string | null | undefined>,
|
|
): AlwaysApplyRule[] {
|
|
if (!alwaysApplyRules || alwaysApplyRules.length === 0) return [];
|
|
|
|
return alwaysApplyRules.filter(
|
|
rule => !promptSources.some(source => promptSourceContainsRule(source, rule.content)),
|
|
);
|
|
}
|
|
|
|
function dedupePromptSource(source: string | null | undefined, otherSources: Array<string | null | undefined>): string {
|
|
const resolvedSource = firstNonEmpty(source);
|
|
if (!resolvedSource) return "";
|
|
|
|
return otherSources.some(otherSource => promptSourceContainsRule(otherSource, resolvedSource)) ? "" : resolvedSource;
|
|
}
|
|
|
|
function firstNonEmpty(...values: (string | undefined | null)[]): string | null {
|
|
for (const value of values) {
|
|
const trimmed = value?.trim();
|
|
if (trimmed) return trimmed;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function renderActiveRepoContextPrompt(activeRepoContext: ActiveRepoContext | null): string {
|
|
if (!activeRepoContext) return "";
|
|
return prompt
|
|
.render(activeRepoContextTemplate, {
|
|
relativeRepoRoot: normalizePromptPath(activeRepoContext.relativeRepoRoot),
|
|
})
|
|
.trim();
|
|
}
|
|
|
|
function parseWmicTable(output: string, header: string): string | null {
|
|
const lines = output
|
|
.split("\n")
|
|
.map(line => line.trim())
|
|
.filter(Boolean);
|
|
const filtered = lines.filter(line => line.toLowerCase() !== header.toLowerCase());
|
|
return filtered[0] ?? null;
|
|
}
|
|
|
|
const SYSTEM_PROMPT_PREP_TIMEOUT_MS = 5000;
|
|
/** Kept below prep timeout so timed-out probes can still write the null cache before fallback. */
|
|
const GPU_PROBE_TIMEOUT_MS = SYSTEM_PROMPT_PREP_TIMEOUT_MS - 500;
|
|
/** Drop stdout from a probe descendant that inherited the pipe after the probe exited. */
|
|
const GPU_PROBE_STDOUT_DRAIN_MS = 250;
|
|
|
|
async function runGpuProbe(cmd: string[]): Promise<string | null> {
|
|
try {
|
|
const proc = Bun.spawn({
|
|
cmd,
|
|
stdout: "pipe",
|
|
stderr: "ignore",
|
|
stdin: "ignore",
|
|
timeout: GPU_PROBE_TIMEOUT_MS,
|
|
// SIGKILL so a probe ignoring SIGTERM (PATH wrapper, wedged WMI) still
|
|
// dies at the deadline and lets getCachedGpu reach the null-cache write.
|
|
killSignal: "SIGKILL",
|
|
});
|
|
const stdoutReader = proc.stdout.getReader();
|
|
let stdout = "";
|
|
const decoder = new TextDecoder();
|
|
const stdoutDone = (async () => {
|
|
while (true) {
|
|
const chunk = await stdoutReader.read();
|
|
if (chunk.done) break;
|
|
stdout += decoder.decode(chunk.value, { stream: true });
|
|
}
|
|
stdout += decoder.decode();
|
|
})();
|
|
const exitCode = await proc.exited;
|
|
// Even on exit 0, a probe wrapper can leave a descendant holding stdout open.
|
|
// Bound the EOF wait so getCachedGpu cannot outlive the probe in either path;
|
|
// keep whatever bytes the reader already captured before cancelling.
|
|
const drained = await Promise.race([
|
|
stdoutDone.then(() => "ok" as const).catch(() => "err" as const),
|
|
Bun.sleep(GPU_PROBE_STDOUT_DRAIN_MS).then(() => "timeout" as const),
|
|
]);
|
|
if (drained !== "ok") {
|
|
await stdoutReader.cancel().catch(() => undefined);
|
|
await stdoutDone.catch(() => undefined);
|
|
}
|
|
return exitCode === 0 ? stdout : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function getGpuModel(): Promise<string | null> {
|
|
switch (process.platform) {
|
|
case "win32": {
|
|
const output = await runGpuProbe(["wmic", "path", "win32_VideoController", "get", "name"]);
|
|
return output ? parseWmicTable(output, "Name") : null;
|
|
}
|
|
case "linux": {
|
|
const output = await runGpuProbe(["lspci"]);
|
|
if (!output) return null;
|
|
const gpus: Array<{ name: string; priority: number }> = [];
|
|
for (const line of output.split("\n")) {
|
|
if (!/(VGA|3D|Display)/i.test(line)) continue;
|
|
const parts = line.split(":");
|
|
const name = parts.length > 1 ? parts.slice(1).join(":").trim() : line.trim();
|
|
const nameLower = name.toLowerCase();
|
|
// Skip BMC/server management adapters
|
|
if (/aspeed|matrox g200|mgag200/i.test(name)) continue;
|
|
// Prioritize discrete GPUs
|
|
let priority = 0;
|
|
if (
|
|
nameLower.includes("nvidia") ||
|
|
nameLower.includes("geforce") ||
|
|
nameLower.includes("quadro") ||
|
|
nameLower.includes("rtx")
|
|
) {
|
|
priority = 3;
|
|
} else if (nameLower.includes("amd") || nameLower.includes("radeon") || nameLower.includes("rx ")) {
|
|
priority = 3;
|
|
} else if (nameLower.includes("intel")) {
|
|
priority = 1;
|
|
} else {
|
|
priority = 2;
|
|
}
|
|
gpus.push({ name, priority });
|
|
}
|
|
if (gpus.length === 0) return null;
|
|
gpus.sort((a, b) => b.priority - a.priority);
|
|
return gpus[0].name;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getTerminalName(): string | undefined {
|
|
const termProgram = Bun.env.TERM_PROGRAM;
|
|
const termProgramVersion = Bun.env.TERM_PROGRAM_VERSION;
|
|
if (termProgram) {
|
|
return termProgramVersion ? `${termProgram} ${termProgramVersion}` : termProgram;
|
|
}
|
|
|
|
if (Bun.env.WT_SESSION) return "Windows Terminal";
|
|
|
|
const term = firstNonEmpty(Bun.env.TERM, Bun.env.COLORTERM, Bun.env.TERMINAL_EMULATOR);
|
|
return term ?? undefined;
|
|
}
|
|
|
|
/** Cached GPU probe result. */
|
|
interface GpuCache {
|
|
gpu: string | null;
|
|
}
|
|
|
|
async function loadGpuCache(): Promise<GpuCache | null> {
|
|
try {
|
|
const cachePath = getGpuCachePath();
|
|
const content = await Bun.file(cachePath).json();
|
|
if (content && typeof content === "object" && "gpu" in content) {
|
|
const gpu = content.gpu;
|
|
return { gpu: typeof gpu === "string" ? gpu : null };
|
|
}
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function saveGpuCache(info: GpuCache): Promise<void> {
|
|
try {
|
|
const cachePath = getGpuCachePath();
|
|
await Bun.write(cachePath, JSON.stringify(info, null, "\t"));
|
|
} catch {
|
|
// Silently ignore cache write failures
|
|
}
|
|
}
|
|
|
|
async function getCachedGpu(): Promise<string | undefined> {
|
|
const cached = await logger.time("getCachedGpu:loadGpuCache", loadGpuCache);
|
|
if (cached) return cached.gpu ?? undefined;
|
|
const gpu = await logger.time("getCachedGpu:getGpuModel", getGpuModel);
|
|
await logger.time("getCachedGpu:saveGpuCache", saveGpuCache, { gpu });
|
|
return gpu ?? undefined;
|
|
}
|
|
|
|
async function getCpuModel(): Promise<string | undefined> {
|
|
if (process.platform !== "linux") return os.cpus()[0]?.model;
|
|
try {
|
|
const cpuInfo = await Bun.file("/proc/cpuinfo").text();
|
|
const match = /^model name\s*:\s*(.+)$/m.exec(cpuInfo);
|
|
return match?.[1]?.trim() || undefined;
|
|
} catch (error) {
|
|
if (!isEnoent(error)) {
|
|
logger.debug("Could not read Linux CPU model", { error: String(error) });
|
|
}
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Kernel identity for the workstation block. Prefers the uname build string
|
|
* from `os.version()`, but Bun on macOS 15+ (Darwin 24/25) returns the literal
|
|
* `"unknown"` when `uv_os_uname()`'s `version` field is empty — which surfaces
|
|
* `Kernel: unknown` in the system prompt and makes the model misidentify the
|
|
* host as Windows (#4141). Fall back to `<type> <release>` (uname -s + -r) so
|
|
* macOS is always tagged as `Darwin <release>` and Linux keeps its build info.
|
|
*/
|
|
function getKernelIdentity(): string {
|
|
const version = os.version()?.trim();
|
|
if (version && version.toLowerCase() !== "unknown") return version;
|
|
return `${os.type()} ${os.release()}`.trim();
|
|
}
|
|
|
|
function getEnvironmentInfo(
|
|
cpuModel: string | undefined,
|
|
gpu: string | undefined,
|
|
): Array<{ label: string; value: string }> {
|
|
const entries: Array<{ label: string; value: string | undefined }> = [
|
|
{ label: "OS", value: `${os.platform()} ${os.release()}` },
|
|
{ label: "Distro", value: os.type() },
|
|
{ label: "Kernel", value: getKernelIdentity() },
|
|
{ label: "Arch", value: os.arch() },
|
|
{ label: "CPU", value: cpuModel },
|
|
{ label: "GPU", value: gpu },
|
|
{ label: "Terminal", value: getTerminalName() },
|
|
];
|
|
return entries.filter((e): e is { label: string; value: string } => !!e.value);
|
|
}
|
|
|
|
/** Discover TITLE_SYSTEM.md file for automatic session-title prompt overrides */
|
|
export function discoverTitleSystemPromptFile(cwd?: string): string | undefined {
|
|
const projectPath = findConfigFile("TITLE_SYSTEM.md", { user: false, cwd });
|
|
if (projectPath) {
|
|
return projectPath;
|
|
}
|
|
const globalPath = findConfigFile("TITLE_SYSTEM.md", { user: true, cwd });
|
|
if (globalPath) {
|
|
return globalPath;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** Resolve input as file path or literal string */
|
|
export async function resolvePromptInput(input: string | undefined, description: string): Promise<string | undefined> {
|
|
if (!input) {
|
|
return undefined;
|
|
} else if (input.includes("\n")) {
|
|
return input;
|
|
}
|
|
|
|
try {
|
|
return await Bun.file(input).text();
|
|
} catch (error) {
|
|
if (!hasFsCode(error, "ENAMETOOLONG") && !isEnoent(error)) {
|
|
logger.warn(`Could not read ${description} file`, { path: input, error: String(error) });
|
|
}
|
|
return input;
|
|
}
|
|
}
|
|
|
|
export interface LoadContextFilesOptions {
|
|
/** Working directory to start walking up from. Default: getProjectDir() */
|
|
cwd?: string;
|
|
/** Disabled extension IDs to honor instead of the process-global settings. */
|
|
disabledExtensions?: string[];
|
|
}
|
|
|
|
function dedupeExactContextFiles(
|
|
contextFiles: Array<{ path: string; content: string; depth?: number }>,
|
|
): Array<{ path: string; content: string; depth?: number }> {
|
|
const lastIndexByContent = new Map<string, number>();
|
|
for (const [index, file] of contextFiles.entries()) {
|
|
// Keep the closest matching context entry when content is byte-for-byte identical.
|
|
lastIndexByContent.set(file.content, index);
|
|
}
|
|
|
|
return contextFiles.filter((file, index) => lastIndexByContent.get(file.content) === index);
|
|
}
|
|
|
|
/**
|
|
* Load all project context files using the capability API.
|
|
* Returns {path, content, depth} entries for all discovered context files.
|
|
* Files are sorted by depth (descending) so files closer to cwd appear last/more prominent.
|
|
*/
|
|
export async function loadProjectContextFiles(
|
|
options: LoadContextFilesOptions = {},
|
|
): Promise<Array<{ path: string; content: string; depth?: number }>> {
|
|
const resolvedCwd = options.cwd ?? getProjectDir();
|
|
|
|
const result = await loadCapability(contextFileCapability.id, {
|
|
cwd: resolvedCwd,
|
|
disabledExtensions: options.disabledExtensions,
|
|
});
|
|
|
|
// Materialize ContextFile items, expanding any `@path/to/file` includes
|
|
// in their content. The expansion uses the file's own directory as the
|
|
// resolution base so relative imports work the same way Claude Code,
|
|
// Goose, and other tools document.
|
|
const files = await Promise.all(
|
|
result.items.map(async item => {
|
|
const contextFile = item as ContextFile;
|
|
return {
|
|
path: contextFile.path,
|
|
content: await expandAtImports(contextFile.content, contextFile.path),
|
|
depth: contextFile.depth,
|
|
};
|
|
}),
|
|
);
|
|
|
|
// Sort by depth (descending): higher depth (farther from cwd) comes first,
|
|
// so files closer to cwd appear later and are more prominent
|
|
files.sort((a, b) => {
|
|
const depthA = a.depth ?? -1;
|
|
const depthB = b.depth ?? -1;
|
|
return depthB - depthA;
|
|
});
|
|
|
|
return dedupeExactContextFiles(files);
|
|
}
|
|
|
|
/**
|
|
* Load the effective system prompt customization from SYSTEM.md.
|
|
* Project-level SYSTEM.md overrides user-level SYSTEM.md.
|
|
*/
|
|
export async function loadSystemPromptFiles(options: LoadContextFilesOptions = {}): Promise<string | null> {
|
|
const resolvedCwd = options.cwd ?? getProjectDir();
|
|
|
|
const result = await loadCapability<SystemPromptFile>(systemPromptCapability.id, { cwd: resolvedCwd });
|
|
|
|
if (result.items.length === 0) return null;
|
|
|
|
const projectLevel = result.items.find(item => item.level === "project");
|
|
if (projectLevel) {
|
|
return projectLevel.content;
|
|
}
|
|
|
|
const userLevel = result.items.find(item => item.level === "user");
|
|
return userLevel?.content ?? null;
|
|
}
|
|
|
|
export const DEFAULT_SYSTEM_PROMPT_TOOL_NAMES = ["read", "bash", "edit", "write"] as const;
|
|
|
|
export interface SystemPromptToolMetadata {
|
|
label: string;
|
|
description: string;
|
|
/** Tool name the model sees on the provider wire. Defaults to the internal tool name. */
|
|
wireName?: string;
|
|
/** Tool parameters schema (Zod or JSON Schema), fed to the verbose inventory renderer. */
|
|
parameters?: TSchema;
|
|
/** Illustrative examples rendered into the verbose inventory. */
|
|
examples?: readonly ToolExample[];
|
|
}
|
|
|
|
export type SystemPromptToolMetadataProjection =
|
|
| {
|
|
mode: "compact";
|
|
toolNames: readonly string[];
|
|
overrides?: Partial<Record<string, Partial<SystemPromptToolMetadata>>>;
|
|
}
|
|
| {
|
|
mode: "full";
|
|
overrides?: Partial<Record<string, Partial<SystemPromptToolMetadata>>>;
|
|
};
|
|
|
|
export function buildSystemPromptToolMetadata(
|
|
tools: Map<string, AgentTool>,
|
|
overrides: Partial<Record<string, Partial<SystemPromptToolMetadata>>> = {},
|
|
): Map<string, SystemPromptToolMetadata> {
|
|
return projectSystemPromptToolMetadata(tools, { mode: "full", overrides });
|
|
}
|
|
|
|
/** Builds a mode-specific metadata snapshot for internal prompt assembly. */
|
|
export function projectSystemPromptToolMetadata(
|
|
tools: Map<string, AgentTool>,
|
|
projection: SystemPromptToolMetadataProjection,
|
|
): Map<string, SystemPromptToolMetadata> {
|
|
const metadata = new Map<string, SystemPromptToolMetadata>();
|
|
const addTool = (name: string, tool: AgentTool): void => {
|
|
const override = projection.overrides?.[name];
|
|
const labelValue = override?.label ?? tool.label;
|
|
const wireNameValue = override?.wireName ?? tool.customWireName;
|
|
const label = typeof labelValue === "string" ? labelValue : "";
|
|
const wireName = typeof wireNameValue === "string" ? wireNameValue : undefined;
|
|
|
|
if (projection.mode === "compact") {
|
|
metadata.set(name, { label, description: "", wireName });
|
|
return;
|
|
}
|
|
|
|
const descriptionValue = override?.description ?? tool.description;
|
|
metadata.set(name, {
|
|
label,
|
|
description: typeof descriptionValue === "string" ? descriptionValue : "",
|
|
parameters: tool.parameters,
|
|
examples: tool.examples,
|
|
wireName,
|
|
});
|
|
};
|
|
|
|
if (projection.mode === "compact") {
|
|
for (const name of projection.toolNames) {
|
|
const tool = tools.get(name);
|
|
if (tool) addTool(name, tool);
|
|
}
|
|
} else {
|
|
for (const [name, tool] of tools) addTool(name, tool);
|
|
}
|
|
|
|
return metadata;
|
|
}
|
|
|
|
export interface BuildSystemPromptOptions {
|
|
/** Custom system prompt (replaces default). */
|
|
customPrompt?: string;
|
|
/** Already-loaded custom system prompt text; bypasses path resolution. */
|
|
resolvedCustomPrompt?: string;
|
|
/** Tools to include in prompt. */
|
|
tools?: Map<string, SystemPromptToolMetadata>;
|
|
/** Tool names to include in prompt. */
|
|
toolNames?: string[];
|
|
/** Text to append to system prompt. */
|
|
appendSystemPrompt?: string;
|
|
/** Already-loaded append prompt text; bypasses path resolution. */
|
|
resolvedAppendSystemPrompt?: string;
|
|
/** Inline full tool descriptors in the system prompt. Default: false */
|
|
inlineToolDescriptors?: boolean;
|
|
/**
|
|
* Whether provider-native tool calling is active (no owned/in-band syntax).
|
|
* When true and `inlineToolDescriptors` is false, the inventory renders as a
|
|
* compact tool-name list; otherwise it renders the full Harmony-style
|
|
* `namespace functions { … }` catalog. Default: true
|
|
*/
|
|
nativeTools?: boolean;
|
|
/** Skills settings for discovery. */
|
|
skillsSettings?: SkillsSettings;
|
|
/** Working directory. Default: getProjectDir() */
|
|
cwd?: string;
|
|
/** Additional workspace directories beyond cwd (multi-root), absolute. Injected into the project prompt. */
|
|
additionalWorkspaceRoots?: string[];
|
|
/** Pre-loaded context files (skips discovery if provided). */
|
|
contextFiles?: Array<{ path: string; content: string; depth?: number }>;
|
|
/** Skills provided directly to system prompt construction. */
|
|
skills?: readonly Skill[];
|
|
/** Pre-loaded rulebook rules (descriptions, excluding TTSR and always-apply). */
|
|
rules?: Array<{ name: string; description?: string; path: string; globs?: string[] }>;
|
|
/** Intent field name injected into every tool schema. If set, explains the field in the prompt. */
|
|
intentField?: string;
|
|
/** Encourage the agent to delegate via tasks unless changes are trivial. */
|
|
eagerTasks?: boolean;
|
|
/** When true, the Eager Tasks section uses the hard MUST/ONLY wording (`task.eager: always`) rather than the softer `preferred` nudge. */
|
|
eagerTasksAlways?: boolean;
|
|
/** Whether `task.batch` is enabled; selects the centralized delegation guidance's call shape. */
|
|
taskBatch?: boolean;
|
|
/** Effective task concurrency limit displayed in centralized delegation guidance. Zero means unlimited. */
|
|
taskMaxConcurrency?: number;
|
|
/** Whether IRC-backed parallel coordination can be included in delegation policy. */
|
|
taskIrcEnabled?: boolean;
|
|
/** Whether the read-only `scout` subagent is spawnable (not disabled, allowed by spawn policy). Defaults to true. */
|
|
scoutAvailable?: boolean;
|
|
|
|
/** Rules with alwaysApply=true — their full content is injected into the prompt. */
|
|
alwaysApplyRules?: AlwaysApplyRule[];
|
|
/** Whether secret obfuscation is active. When true, explains the redaction format in the prompt. */
|
|
secretsEnabled?: boolean;
|
|
/** Pre-loaded workspace tree (skips discovery if provided). May be a Promise to allow early kick-off. */
|
|
workspaceTree?: WorkspaceTree | Promise<WorkspaceTree>;
|
|
/** Whether the local memory://root summary is active. */
|
|
memoryRootEnabled?: boolean;
|
|
/** Whether the read-only security:// resource namespace is active. */
|
|
securityEnabled?: boolean;
|
|
/** Active model identifier (e.g. "anthropic/claude-opus-4") used by prompt policy and optionally surfaced. */
|
|
model?: string;
|
|
/** Whether to surface `model` in the workstation block. Model-specific prompt policy still uses it. Default: true. */
|
|
includeModelInPrompt?: boolean;
|
|
/** Personality preset rendered into the default system prompt. "none" omits the block. Default: "default" */
|
|
personality?: Personality;
|
|
/** Whether to include the workspace directory tree in the system prompt. Default: false */
|
|
includeWorkspaceTree?: boolean;
|
|
/** Whether Mermaid fenced blocks render as terminal ASCII diagrams. Default: true */
|
|
renderMermaid?: boolean;
|
|
/** Pre-resolved nested active repo context. Undefined resolves from cwd. */
|
|
activeRepoContext?: ActiveRepoContext | null;
|
|
/** Tools mounted under `xd://`; renders the protocol section when non-empty. `dynamic` marks external devices whose summary is third-party metadata. */
|
|
xdevTools?: Array<{ name: string; summary: string; dynamic?: boolean }>;
|
|
/** Full docs + JSON schema for every `xd://`-mounted tool, inlined into the protocol section so no discovery `read` is needed. */
|
|
xdevDocs?: string;
|
|
/** Whether Auto-QA grievance reporting is enabled; renders the `xd://report_issue` note. */
|
|
autoQaEnabled?: boolean;
|
|
}
|
|
|
|
/** Result of building provider-facing system prompt messages. */
|
|
export interface BuildSystemPromptResult {
|
|
/** Ordered system prompt blocks. Providers should preserve entries as distinct messages/blocks. */
|
|
systemPrompt: string[];
|
|
/**
|
|
* Names of `xd://` devices whose catalog/protocol section this prompt renders.
|
|
* Empty/undefined when no catalog was emitted (no mounted devices, or a custom
|
|
* prompt template that omits the section). Lets the session fold these devices
|
|
* into its announced-mount baseline so a same-turn mount notice does not re-list
|
|
* a catalog the prompt already carries (issue #7139).
|
|
*/
|
|
xdevCatalogNames?: readonly string[];
|
|
}
|
|
|
|
/** Build the system prompt with tools, guidelines, and context */
|
|
export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): Promise<BuildSystemPromptResult> {
|
|
if ($env.NULL_PROMPT === "true") {
|
|
return { systemPrompt: [] };
|
|
}
|
|
|
|
const {
|
|
customPrompt,
|
|
resolvedCustomPrompt: providedResolvedCustomPrompt,
|
|
tools,
|
|
appendSystemPrompt,
|
|
inlineToolDescriptors: providedInlineToolDescriptors,
|
|
resolvedAppendSystemPrompt: providedResolvedAppendPrompt,
|
|
nativeTools = true,
|
|
skillsSettings,
|
|
toolNames: providedToolNames,
|
|
cwd,
|
|
additionalWorkspaceRoots = [],
|
|
contextFiles: providedContextFiles,
|
|
skills: providedSkills,
|
|
rules,
|
|
alwaysApplyRules,
|
|
intentField,
|
|
eagerTasks = false,
|
|
eagerTasksAlways = false,
|
|
taskBatch = true,
|
|
taskMaxConcurrency = 0,
|
|
taskIrcEnabled = false,
|
|
secretsEnabled = false,
|
|
workspaceTree: providedWorkspaceTree,
|
|
scoutAvailable = true,
|
|
memoryRootEnabled = false,
|
|
securityEnabled = false,
|
|
model,
|
|
includeModelInPrompt = true,
|
|
personality = "default",
|
|
includeWorkspaceTree = false,
|
|
renderMermaid = true,
|
|
xdevTools = [],
|
|
xdevDocs = "",
|
|
autoQaEnabled = false,
|
|
activeRepoContext: providedActiveRepoContext,
|
|
} = options;
|
|
const inlineToolDescriptors = providedInlineToolDescriptors ?? false;
|
|
const resolvedCwd = cwd ?? getProjectDir();
|
|
|
|
const prepDefaults = {
|
|
resolvedCustomPrompt: undefined as string | undefined,
|
|
resolvedAppendPrompt: undefined as string | undefined,
|
|
systemPromptCustomization: null as string | null,
|
|
contextFiles: dedupeExactContextFiles(providedContextFiles ?? []),
|
|
skills: providedSkills ?? ([] as Skill[]),
|
|
workspaceTree: {
|
|
rootPath: resolvedCwd,
|
|
rendered: "",
|
|
truncated: false,
|
|
totalLines: 0,
|
|
agentsMdFiles: [],
|
|
} satisfies WorkspaceTree,
|
|
activeRepoContext: null as ActiveRepoContext | null,
|
|
cpuModel: undefined as string | undefined,
|
|
gpu: undefined as string | undefined,
|
|
};
|
|
|
|
const { promise: deadline, resolve: fireDeadline } = Promise.withResolvers<"__timeout__">();
|
|
const deadlineTimer = setTimeout(() => fireDeadline("__timeout__"), SYSTEM_PROMPT_PREP_TIMEOUT_MS);
|
|
// Unref so a fast prep does not hold a one-shot CLI alive waiting for this timer.
|
|
deadlineTimer.unref();
|
|
const timedOut: string[] = [];
|
|
const failed: Array<{ name: string; error: unknown }> = [];
|
|
|
|
async function withDeadline<T>(name: string, work: Promise<T>, fallback: T): Promise<T> {
|
|
const tagged = work
|
|
.then(value => ({ kind: "ok" as const, value }))
|
|
.catch(error => ({ kind: "err" as const, error }));
|
|
const result = await Promise.race([tagged, deadline]);
|
|
if (result === "__timeout__") {
|
|
timedOut.push(name);
|
|
// Let the work continue in the background so its caches still warm; just log on completion.
|
|
void tagged.then(r => {
|
|
if (r.kind === "err") {
|
|
logger.warn("Background system prompt preparation step failed", { name, error: String(r.error) });
|
|
} else {
|
|
logger.debug("Background system prompt preparation step completed after timeout", { name });
|
|
}
|
|
});
|
|
return fallback;
|
|
}
|
|
if (result.kind === "err") {
|
|
failed.push({ name, error: result.error });
|
|
return fallback;
|
|
}
|
|
return result.value;
|
|
}
|
|
|
|
// Caller-supplied `customPrompt` / `resolvedCustomPrompt` owns block 0; the
|
|
// secondary capability-path `SYSTEM.md` walk-up MUST NOT silently augment it,
|
|
// because that would defeat CLI precedence over project/user `SYSTEM.md`.
|
|
const callerControlsCustomPrompt =
|
|
(typeof providedResolvedCustomPrompt === "string" && providedResolvedCustomPrompt.length > 0) ||
|
|
(typeof customPrompt === "string" && customPrompt.length > 0);
|
|
const systemPromptCustomizationPromise: Promise<string | null> = callerControlsCustomPrompt
|
|
? Promise.resolve(null)
|
|
: logger.time("loadSystemPromptFiles", loadSystemPromptFiles, { cwd: resolvedCwd });
|
|
const contextFilesPromise = (async () => {
|
|
const primary = providedContextFiles
|
|
? providedContextFiles
|
|
: await logger.time("loadProjectContextFiles", loadProjectContextFiles, { cwd: resolvedCwd });
|
|
// Also discover context files (AGENTS.md, rules, etc.) for each additional workspace root.
|
|
const additionalRoots = additionalWorkspaceRoots.filter(d => path.resolve(d) !== path.resolve(resolvedCwd));
|
|
if (additionalRoots.length === 0) return primary;
|
|
const extra = await Promise.all(
|
|
additionalRoots.map(root => loadProjectContextFiles({ cwd: root }).catch(() => [])),
|
|
);
|
|
return dedupeExactContextFiles([...primary, ...extra.flat()]);
|
|
})();
|
|
const additionalRootsForTree = additionalWorkspaceRoots.filter(d => path.resolve(d) !== path.resolve(resolvedCwd));
|
|
const workspaceTreePromise = (async () => {
|
|
const primary =
|
|
providedWorkspaceTree !== undefined
|
|
? await Promise.resolve(providedWorkspaceTree)
|
|
: includeWorkspaceTree
|
|
? await logger.time("buildWorkspaceTree", () =>
|
|
buildWorkspaceTree(resolvedCwd, { timeoutMs: SYSTEM_PROMPT_PREP_TIMEOUT_MS }),
|
|
)
|
|
: { rootPath: resolvedCwd, rendered: "", truncated: false, totalLines: 0, agentsMdFiles: [] };
|
|
if (additionalRootsForTree.length === 0 || !includeWorkspaceTree) return primary;
|
|
const extraTrees = await Promise.all(
|
|
additionalRootsForTree.map(root =>
|
|
buildWorkspaceTree(root, { timeoutMs: SYSTEM_PROMPT_PREP_TIMEOUT_MS }).catch(() => ({
|
|
rootPath: root,
|
|
rendered: "",
|
|
truncated: false,
|
|
totalLines: 0,
|
|
agentsMdFiles: [],
|
|
})),
|
|
),
|
|
);
|
|
return { ...primary, agentsMdFiles: [...primary.agentsMdFiles, ...extraTrees.flatMap(t => t.agentsMdFiles)] };
|
|
})();
|
|
const skillsPromise: Promise<readonly Skill[]> =
|
|
providedSkills !== undefined
|
|
? Promise.resolve(providedSkills)
|
|
: skillsSettings?.enabled !== false
|
|
? loadSkills({ ...skillsSettings, cwd: resolvedCwd }).then(result => result.skills)
|
|
: Promise.resolve([]);
|
|
const activeRepoContextPromise =
|
|
providedActiveRepoContext !== undefined
|
|
? Promise.resolve(providedActiveRepoContext)
|
|
: logger.time("resolveActiveRepoContext", () => resolveActiveRepoContext(resolvedCwd));
|
|
const cpuModelPromise = logger.time("getCpuModel", getCpuModel);
|
|
const gpuPromise = logger.time("getCachedGpu", getCachedGpu);
|
|
|
|
const [
|
|
resolvedCustomPrompt,
|
|
resolvedAppendPrompt,
|
|
systemPromptCustomization,
|
|
contextFiles,
|
|
skills,
|
|
workspaceTree,
|
|
activeRepoContext,
|
|
cpuModel,
|
|
gpu,
|
|
] = await Promise.all([
|
|
withDeadline(
|
|
"customPrompt",
|
|
providedResolvedCustomPrompt !== undefined
|
|
? Promise.resolve(providedResolvedCustomPrompt)
|
|
: resolvePromptInput(customPrompt, "system prompt"),
|
|
prepDefaults.resolvedCustomPrompt,
|
|
),
|
|
withDeadline(
|
|
"appendSystemPrompt",
|
|
providedResolvedAppendPrompt !== undefined
|
|
? Promise.resolve(providedResolvedAppendPrompt)
|
|
: resolvePromptInput(appendSystemPrompt, "append system prompt"),
|
|
prepDefaults.resolvedAppendPrompt,
|
|
),
|
|
withDeadline("loadSystemPromptFiles", systemPromptCustomizationPromise, prepDefaults.systemPromptCustomization),
|
|
withDeadline("loadProjectContextFiles", contextFilesPromise, prepDefaults.contextFiles).then(
|
|
dedupeExactContextFiles,
|
|
),
|
|
withDeadline("loadSkills", skillsPromise, prepDefaults.skills),
|
|
withDeadline("buildWorkspaceTree", workspaceTreePromise, prepDefaults.workspaceTree),
|
|
withDeadline("resolveActiveRepoContext", activeRepoContextPromise, prepDefaults.activeRepoContext),
|
|
withDeadline("getCpuModel", cpuModelPromise, prepDefaults.cpuModel),
|
|
withDeadline("getCachedGpu", gpuPromise, prepDefaults.gpu),
|
|
]);
|
|
clearTimeout(deadlineTimer);
|
|
const agentsMdFiles = Array.from(new Set(workspaceTree.agentsMdFiles)).sort().slice(0, AGENTS_MD_LIMIT);
|
|
|
|
if (timedOut.length > 0) {
|
|
logger.warn("System prompt preparation steps timed out; using minimal fallback for those steps", {
|
|
cwd: resolvedCwd,
|
|
timeoutMs: SYSTEM_PROMPT_PREP_TIMEOUT_MS,
|
|
steps: timedOut,
|
|
});
|
|
process.stderr.write(
|
|
`Warning: system prompt preparation steps timed out after ${SYSTEM_PROMPT_PREP_TIMEOUT_MS}ms (${timedOut.join(", ")}); using minimal fallback for those steps.\n`,
|
|
);
|
|
}
|
|
if (failed.length > 0) {
|
|
for (const { name, error } of failed) {
|
|
logger.warn("System prompt preparation step failed; using minimal fallback", {
|
|
cwd: resolvedCwd,
|
|
step: name,
|
|
error: String(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
const date = formatLocalCalendarDate();
|
|
const dateTime = date;
|
|
const promptCwd = normalizePromptPath(resolvedCwd);
|
|
const activeRepoContextPrompt = renderActiveRepoContextPrompt(activeRepoContext);
|
|
|
|
// Build tool metadata for system prompt rendering.
|
|
// Priority: explicit list > tools map > conservative SDK fallback.
|
|
let toolNames = providedToolNames;
|
|
if (!toolNames) {
|
|
toolNames = tools ? Array.from(tools.keys()) : [...DEFAULT_SYSTEM_PROMPT_TOOL_NAMES];
|
|
}
|
|
|
|
// List mode shows a compact tool-name list; it only applies when descriptors
|
|
// stay in provider-native tool schemas AND native tool calling is active.
|
|
// Otherwise render the full functions-namespace catalog in the system prompt.
|
|
const toolListMode = !inlineToolDescriptors && nativeTools;
|
|
// Build tool descriptions for system prompt rendering.
|
|
const toolPromptNames = new Map<string, string>(toolNames.map(name => [name, tools?.get(name)?.wireName ?? name]));
|
|
// xd://-mounted tools count as present for prompt gates ({{#has tools "lsp"}})
|
|
// and resolve their own name as the reference — the xd:// section explains
|
|
// the access path. The Tool Inventory list stays limited to real defs.
|
|
for (const mounted of xdevTools) {
|
|
if (!toolPromptNames.has(mounted.name)) toolPromptNames.set(mounted.name, mounted.name);
|
|
}
|
|
const toolRefs = Object.fromEntries(toolPromptNames.entries());
|
|
const xdevToolNames = new Set(xdevTools.map(mounted => mounted.name));
|
|
// A direct custom tool can share a name with a retained built-in device.
|
|
// Presence in both toolNames and tools proves it still has a top-level definition.
|
|
const inventoryToolNames =
|
|
xdevToolNames.size === 0 ? toolNames : toolNames.filter(name => tools?.has(name) || !xdevToolNames.has(name));
|
|
const toolInfo = inventoryToolNames.map(name => ({
|
|
name: toolPromptNames.get(name) ?? name,
|
|
internalName: name,
|
|
label: tools?.get(name)?.label ?? "",
|
|
}));
|
|
const toolInventory = toolListMode
|
|
? ""
|
|
: renderToolInventory(
|
|
inventoryToolNames.map(name => {
|
|
const meta = tools?.get(name);
|
|
return {
|
|
name: toolPromptNames.get(name) ?? name,
|
|
description: meta?.description ?? "",
|
|
parameters: meta?.parameters ?? ({ type: "object" } as TSchema),
|
|
examples: meta?.examples,
|
|
};
|
|
}),
|
|
);
|
|
|
|
// Filter skills for the rendered system prompt:
|
|
// - require the `read` tool so the model can actually fetch skill content;
|
|
// - drop skills with frontmatter `hide: true` (still loadable via skill:// and /skill:<name>).
|
|
const hasRead = toolNames.includes("read");
|
|
const filteredSkills = hasRead ? skills.filter(skill => skill.hide !== true) : [];
|
|
|
|
const effectiveSystemPromptCustomization = dedupePromptSource(systemPromptCustomization, [
|
|
resolvedCustomPrompt,
|
|
resolvedAppendPrompt,
|
|
]);
|
|
const contextPromptSources = contextFiles.map(file => file.content);
|
|
const promptSources = [
|
|
effectiveSystemPromptCustomization,
|
|
resolvedCustomPrompt,
|
|
resolvedAppendPrompt,
|
|
...contextPromptSources,
|
|
];
|
|
const injectedAlwaysApplyRules = dedupeAlwaysApplyRules(alwaysApplyRules, promptSources);
|
|
|
|
const environment = getEnvironmentInfo(cpuModel, gpu);
|
|
const data = {
|
|
systemPromptCustomization: effectiveSystemPromptCustomization,
|
|
customPrompt: resolvedCustomPrompt,
|
|
appendPrompt: resolvedAppendPrompt ?? "",
|
|
tools: [...new Set([...toolNames, ...xdevTools.map(mounted => mounted.name)])],
|
|
toolInfo,
|
|
toolInventory,
|
|
inlineToolDescriptors,
|
|
toolListMode,
|
|
toolRefs,
|
|
environment,
|
|
contextFiles,
|
|
agentsMdSearch: { files: agentsMdFiles },
|
|
workspaceTree,
|
|
skills: filteredSkills,
|
|
rules: rules ?? [],
|
|
alwaysApplyRules: injectedAlwaysApplyRules,
|
|
date,
|
|
dateTime,
|
|
cwd: promptCwd,
|
|
additionalWorkspaceRoots: additionalWorkspaceRoots.filter(d => path.resolve(d) !== path.resolve(resolvedCwd)),
|
|
model: includeModelInPrompt ? (model ?? "") : "",
|
|
useCodexTaskPrompt: usesCodexTaskPrompt(model),
|
|
personality: personality === "none" ? "" : PERSONALITY_SPECS[personality].trim(),
|
|
intentTracing: !!intentField,
|
|
intentField: intentField ?? "",
|
|
eagerTasks,
|
|
eagerTasksAlways,
|
|
taskBatch,
|
|
MAX_CONCURRENCY: normalizeConcurrencyLimit(taskMaxConcurrency),
|
|
scoutAvailable,
|
|
taskIrcEnabled,
|
|
secretsEnabled,
|
|
hasMemoryRoot: memoryRootEnabled,
|
|
securityEnabled,
|
|
hasObsidian: hasObsidian(),
|
|
includeWorkspaceTree,
|
|
renderMermaid,
|
|
xdevTools,
|
|
hasDynamicXdevTools: xdevTools.some(mounted => mounted.dynamic === true),
|
|
xdevDocs,
|
|
autoQaEnabled,
|
|
};
|
|
const rendered = prompt.render(resolvedCustomPrompt ? customSystemPromptTemplate : systemPromptTemplate, data);
|
|
const systemPrompt = [rendered];
|
|
if (toolNames.includes("computer")) {
|
|
systemPrompt.push(computerSafetyPrompt.trim());
|
|
}
|
|
// Custom prompt templates already render context files and append text; the
|
|
// project footer still carries environment, cwd, workspace, and dir-context.
|
|
const projectPrompt = prompt
|
|
.render(projectPromptTemplate, resolvedCustomPrompt ? { ...data, contextFiles: [], appendPrompt: "" } : data)
|
|
.trim();
|
|
if (projectPrompt) {
|
|
systemPrompt.push(projectPrompt);
|
|
}
|
|
if (activeRepoContextPrompt) {
|
|
systemPrompt.push(activeRepoContextPrompt);
|
|
}
|
|
|
|
// The xd:// protocol section (with its device catalog) is only rendered by the
|
|
// default template; a resolved custom prompt uses a template that omits it.
|
|
const xdevCatalogNames =
|
|
!resolvedCustomPrompt && xdevTools.length > 0 ? xdevTools.map(mounted => mounted.name) : undefined;
|
|
return { systemPrompt, xdevCatalogNames };
|
|
}
|