Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion src/agents/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export interface BuiltinAgentOverrideBase {
systemPrompt: string;
skills?: string[];
tools?: string[];
output?: string;
defaultReads?: string[];
defaultProgress?: boolean;
mcpDirectTools?: string[];
completionGuard?: boolean;
}
Expand All @@ -61,6 +64,9 @@ interface BuiltinAgentOverrideConfig {
systemPrompt?: string;
skills?: string[] | false;
tools?: string[] | false;
output?: string | false;
defaultReads?: string[] | false;
defaultProgress?: boolean;
completionGuard?: boolean;
}

Expand Down Expand Up @@ -185,6 +191,9 @@ function cloneOverrideBase(agent: AgentConfig): BuiltinAgentOverrideBase {
systemPrompt: agent.systemPrompt,
skills: agent.skills ? [...agent.skills] : undefined,
tools: agent.tools ? [...agent.tools] : undefined,
output: agent.output,
defaultReads: agent.defaultReads ? [...agent.defaultReads] : undefined,
defaultProgress: agent.defaultProgress,
mcpDirectTools: agent.mcpDirectTools ? [...agent.mcpDirectTools] : undefined,
completionGuard: agent.completionGuard,
};
Expand All @@ -205,6 +214,9 @@ function cloneOverrideValue(override: BuiltinAgentOverrideConfig): BuiltinAgentO
...(override.systemPrompt !== undefined ? { systemPrompt: override.systemPrompt } : {}),
...(override.skills !== undefined ? { skills: override.skills === false ? false : [...override.skills] } : {}),
...(override.tools !== undefined ? { tools: override.tools === false ? false : [...override.tools] } : {}),
...(override.output !== undefined ? { output: override.output } : {}),
...(override.defaultReads !== undefined ? { defaultReads: override.defaultReads === false ? false : [...override.defaultReads] } : {}),
...(override.defaultProgress !== undefined ? { defaultProgress: override.defaultProgress } : {}),
...(override.completionGuard !== undefined ? { completionGuard: override.completionGuard } : {}),
};
}
Expand Down Expand Up @@ -364,6 +376,19 @@ function parseBuiltinOverrideEntry(
const tools = parseOverrideStringArrayOrFalse(input.tools, { filePath, name, field: "tools" });
if (tools !== undefined) override.tools = tools;

if ("output" in input) {
if (typeof input.output === "string" || input.output === false) override.output = input.output;
else throw new Error(`Builtin override '${name}' in '${filePath}' has invalid 'output'; expected a string or false.`);
}

const defaultReads = parseOverrideStringArrayOrFalse(input.defaultReads, { filePath, name, field: "defaultReads" });
if (defaultReads !== undefined) override.defaultReads = defaultReads;

if ("defaultProgress" in input) {
if (typeof input.defaultProgress === "boolean") override.defaultProgress = input.defaultProgress;
else throw new Error(`Builtin override '${name}' in '${filePath}' has invalid 'defaultProgress'; expected a boolean.`);
}

return Object.keys(override).length > 0 ? override : undefined;
}

Expand Down Expand Up @@ -422,6 +447,9 @@ function applyBuiltinOverride(
next.tools = tools;
next.mcpDirectTools = mcpDirectTools;
}
if (override.output !== undefined) next.output = override.output === false ? undefined : override.output;
if (override.defaultReads !== undefined) next.defaultReads = override.defaultReads === false ? undefined : [...override.defaultReads];
if (override.defaultProgress !== undefined) next.defaultProgress = override.defaultProgress;
if (override.completionGuard !== undefined) next.completionGuard = override.completionGuard;

return next;
Expand Down Expand Up @@ -462,7 +490,7 @@ function applyBuiltinOverrides(

export function buildBuiltinOverrideConfig(
base: BuiltinAgentOverrideBase,
draft: Pick<AgentConfig, "model" | "fallbackModels" | "thinking" | "systemPromptMode" | "inheritProjectContext" | "inheritSkills" | "defaultContext" | "disabled" | "systemPrompt" | "skills" | "tools" | "mcpDirectTools" | "completionGuard">,
draft: Pick<AgentConfig, "model" | "fallbackModels" | "thinking" | "systemPromptMode" | "inheritProjectContext" | "inheritSkills" | "defaultContext" | "disabled" | "systemPrompt" | "skills" | "tools" | "output" | "defaultReads" | "defaultProgress" | "mcpDirectTools" | "completionGuard">,
): BuiltinAgentOverrideConfig | undefined {
const override: BuiltinAgentOverrideConfig = {};

Expand All @@ -480,6 +508,9 @@ export function buildBuiltinOverrideConfig(
const baseTools = joinToolList(base);
const draftTools = joinToolList(draft);
if (!arraysEqual(draftTools, baseTools)) override.tools = draftTools ? [...draftTools] : false;
if (draft.output !== base.output) override.output = draft.output ?? false;
if (!arraysEqual(draft.defaultReads, base.defaultReads)) override.defaultReads = draft.defaultReads ? [...draft.defaultReads] : false;
if ((draft.defaultProgress ?? false) !== (base.defaultProgress ?? false)) override.defaultProgress = draft.defaultProgress ?? false;
if ((draft.completionGuard !== false) !== (base.completionGuard !== false)) {
override.completionGuard = draft.completionGuard !== false;
}
Expand Down
17 changes: 14 additions & 3 deletions src/runs/foreground/chain-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,12 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
if (result.exitCode !== 0 && failFast) {
aborted = true;
}
recordRun(task.agent, cleanTask, result.exitCode, result.progressSummary?.durationMs ?? 0);
recordRun(task.agent, cleanTask, result.exitCode, result.progressSummary?.durationMs ?? 0, {
model: result.model,
cwd: taskCwd,
tool_calls: result.progressSummary?.toolCount,
error_excerpt: result.error,
});
return result;
},
);
Expand Down Expand Up @@ -778,8 +783,9 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
};
}

const stepCwd = resolveChildCwd(cwd ?? ctx.cwd, seqStep.cwd);
const r = await runSync(ctx.cwd, agents, seqStep.agent, stepTask, {
cwd: resolveChildCwd(cwd ?? ctx.cwd, seqStep.cwd),
cwd: stepCwd,
signal,
interruptSignal: interruptController.signal,
allowIntercomDetach: agentConfig.systemPrompt?.includes(INTERCOM_BRIDGE_MARKER) === true,
Expand Down Expand Up @@ -840,7 +846,12 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
foregroundControl.interrupt = undefined;
foregroundControl.updatedAt = Date.now();
}
recordRun(seqStep.agent, cleanTask, r.exitCode, r.progressSummary?.durationMs ?? 0);
recordRun(seqStep.agent, cleanTask, r.exitCode, r.progressSummary?.durationMs ?? 0, {
model: r.model,
cwd: stepCwd,
tool_calls: r.progressSummary?.toolCount,
error_excerpt: r.error,
});

globalTaskIndex++;
results.push(r);
Expand Down
14 changes: 12 additions & 2 deletions src/runs/foreground/subagent-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1779,7 +1779,12 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
});
for (let i = 0; i < results.length; i++) {
const run = results[i]!;
recordRun(run.agent, taskTexts[i]!, run.exitCode, run.progressSummary?.durationMs ?? 0);
recordRun(run.agent, taskTexts[i]!, run.exitCode, run.progressSummary?.durationMs ?? 0, {
model: run.model,
cwd: resolveParallelTaskCwd(tasks[i]!, effectiveCwd, worktreeSetup, i),
tool_calls: run.progressSummary?.toolCount,
error_excerpt: run.error,
});
}

for (const result of results) {
Expand Down Expand Up @@ -2066,7 +2071,12 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
foregroundControl.toolCount = r.progress?.toolCount;
foregroundControl.updatedAt = Date.now();
}
recordRun(params.agent!, cleanTask, r.exitCode, r.progressSummary?.durationMs ?? 0);
recordRun(params.agent!, cleanTask, r.exitCode, r.progressSummary?.durationMs ?? 0, {
model: r.model,
cwd: effectiveCwd,
tool_calls: r.progressSummary?.toolCount,
error_excerpt: r.error,
});

if (r.progress) allProgress.push(r.progress);
if (r.artifactPaths) allArtifactPaths.push(r.artifactPaths);
Expand Down
56 changes: 46 additions & 10 deletions src/runs/shared/run-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export interface RunEntry {
status: "ok" | "error";
duration: number;
exit?: number;
model?: string;
cwd?: string;
error_excerpt?: string;
tool_calls?: number;
}

const ROTATE_READ_THRESHOLD = 1200;
Expand All @@ -18,7 +22,32 @@ function getHistoryPath(): string {
return path.join(getAgentDir(), "run-history.jsonl");
}

export function recordRun(agent: string, task: string, exitCode: number, durationMs: number): void {
// Best-effort GC: when the log outgrows the threshold, trim to the last ROTATE_KEEP by
// writing a per-process-unique temp file and atomically renaming it into place. Concurrent
// rotators each write a unique temp and rename; the last rename wins, and the file is a
// complete valid snapshot either way — no duplication, no corruption. An append landing in
// a rotate's read->rename window is dropped (acceptable for telemetry); trimming also
// intentionally discards the oldest entries.
function maybeRotate(historyPath: string): void {
const lines = fs.readFileSync(historyPath, "utf-8").split("\n").filter((l) => l.trim().length > 0);
if (lines.length <= ROTATE_READ_THRESHOLD) return;
const tmp = `${historyPath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tmp, `${lines.slice(-ROTATE_KEEP).join("\n")}\n`);
fs.renameSync(tmp, historyPath);
}
Comment on lines +31 to +37

// Appends one JSON line via O_APPEND: on a local POSIX filesystem the kernel serializes
// concurrent appends from separate processes, so they never interleave or tear (telemetry
// entries are small — comfortably within a single atomic write()). Synchronous and
// best-effort: it never waits on a lock, and it swallows I/O errors rather than disrupting
// the agent flow (a swallowed error simply drops that one entry).
export function recordRun(
agent: string,
task: string,
exitCode: number,
durationMs: number,
extra?: { model?: string; cwd?: string; tool_calls?: number; error_excerpt?: string },
): void {
try {
const entry: RunEntry = {
agent,
Expand All @@ -27,10 +56,15 @@ export function recordRun(agent: string, task: string, exitCode: number, duratio
status: exitCode === 0 ? "ok" : "error",
duration: durationMs,
...(exitCode !== 0 ? { exit: exitCode } : {}),
...(extra?.model ? { model: extra.model } : {}),
...(extra?.cwd ? { cwd: extra.cwd } : {}),
...(extra?.tool_calls !== undefined ? { tool_calls: extra.tool_calls } : {}),
...(extra?.error_excerpt ? { error_excerpt: extra.error_excerpt.slice(0, 300) } : {}),
};
const historyPath = getHistoryPath();
fs.mkdirSync(path.dirname(historyPath), { recursive: true });
fs.appendFileSync(historyPath, `${JSON.stringify(entry)}\n`);
maybeRotate(historyPath);
} catch {
// Best-effort — never crash the execution flow for history recording
}
Expand All @@ -46,15 +80,17 @@ export function loadRunsForAgent(agent: string): RunEntry[] {
return [];
}

let lines = raw.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);

if (lines.length > ROTATE_READ_THRESHOLD) {
lines = lines.slice(-ROTATE_KEEP);
try { fs.writeFileSync(historyPath, `${lines.join("\n")}\n`, "utf-8"); } catch {}
}

return lines
.map((line) => { try { return JSON.parse(line) as RunEntry; } catch { return undefined; } })
return raw
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => {
try {
return JSON.parse(line) as RunEntry;
} catch {
return undefined;
}
})
Comment on lines +83 to +93
.filter((entry): entry is RunEntry => Boolean(entry) && entry.agent === agent)
.reverse();
}
110 changes: 109 additions & 1 deletion test/unit/agent-overrides.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import { buildBuiltinOverrideConfig, discoverAgents, discoverAgentsAll, removeBuiltinAgentOverride } from "../../src/agents/agents.ts";
import { buildBuiltinOverrideConfig, discoverAgents, discoverAgentsAll, removeBuiltinAgentOverride, saveBuiltinAgentOverride } from "../../src/agents/agents.ts";

let tempHome = "";
let tempProject = "";
Expand Down Expand Up @@ -268,4 +268,112 @@ describe("builtin agent overrides", () => {
completionGuard: true,
});
});

it("applies output / defaultReads / defaultProgress overrides to builtin agents", () => {
writeJson(path.join(tempHome, ".pi", "agent", "settings.json"), {
subagents: {
agentOverrides: {
scout: { output: "custom.md", defaultReads: ["a.md", "b.md"], defaultProgress: false },
},
},
});

const scout = discoverAgents(tempProject, "both").agents.find((agent) => agent.name === "scout");
assert.ok(scout);
assert.equal(scout.output, "custom.md");
assert.deepEqual(scout.defaultReads, ["a.md", "b.md"]);
assert.equal(scout.defaultProgress, false);
});

it("clears a builtin's output / defaultReads via the false sentinel", () => {
writeJson(path.join(tempHome, ".pi", "agent", "settings.json"), {
subagents: {
agentOverrides: {
scout: { output: false },
planner: { defaultReads: false },
},
},
});

const agents = discoverAgents(tempProject, "both").agents;
const scout = agents.find((agent) => agent.name === "scout");
const planner = agents.find((agent) => agent.name === "planner");
assert.ok(scout);
assert.ok(planner);
// scout ships `output: context.md`; planner ships `defaultReads: context.md`.
assert.equal(scout.output, undefined);
assert.equal(planner.defaultReads, undefined);
});

it("surfaces malformed output override values", () => {
const settingsPath = path.join(tempHome, ".pi", "agent", "settings.json");
writeJson(settingsPath, { subagents: { agentOverrides: { scout: { output: 123 } } } });
assert.throws(
() => discoverAgents(tempProject, "both"),
(error: unknown) => error instanceof Error && error.message.includes("scout") && error.message.includes("output"),
);
});

it("surfaces malformed defaultReads override values", () => {
const settingsPath = path.join(tempHome, ".pi", "agent", "settings.json");
writeJson(settingsPath, { subagents: { agentOverrides: { scout: { defaultReads: "context.md" } } } });
assert.throws(
() => discoverAgents(tempProject, "both"),
(error: unknown) => error instanceof Error && error.message.includes("scout") && error.message.includes("defaultReads"),
);
});

it("surfaces malformed defaultProgress override values", () => {
const settingsPath = path.join(tempHome, ".pi", "agent", "settings.json");
writeJson(settingsPath, { subagents: { agentOverrides: { scout: { defaultProgress: "true" } } } });
assert.throws(
() => discoverAgents(tempProject, "both"),
(error: unknown) => error instanceof Error && error.message.includes("scout") && error.message.includes("defaultProgress"),
);
});

it("round-trips output / defaultReads / defaultProgress through the override builder", () => {
const base = {
model: undefined,
fallbackModels: undefined,
thinking: undefined,
systemPromptMode: "append" as const,
inheritProjectContext: false,
inheritSkills: false,
defaultContext: undefined,
disabled: undefined,
systemPrompt: "P",
skills: undefined,
tools: undefined,
mcpDirectTools: undefined,
output: "context.md",
defaultReads: ["context.md"],
defaultProgress: true,
completionGuard: undefined,
};

// Clearing the builtin defaults emits the false sentinels.
const cleared = buildBuiltinOverrideConfig(base, { ...base, output: undefined, defaultReads: undefined, defaultProgress: false });
assert.deepEqual(cleared, { output: false, defaultReads: false, defaultProgress: false });

// Changing to new values emits those; an unchanged field (defaultProgress) is omitted.
const changed = buildBuiltinOverrideConfig(base, { ...base, output: "custom.md", defaultReads: ["a.md"] });
assert.deepEqual(changed, { output: "custom.md", defaultReads: ["a.md"] });
});

it("persists output / defaultReads / defaultProgress through save and reload", () => {
// Crosses the save seam (cloneOverrideValue) that previously dropped these fields.
saveBuiltinAgentOverride(tempProject, "scout", "user", { output: "custom.md", defaultReads: ["a.md"], defaultProgress: false });
saveBuiltinAgentOverride(tempProject, "planner", "user", { defaultReads: false });

const agents = discoverAgents(tempProject, "both").agents;
const scout = agents.find((agent) => agent.name === "scout");
const planner = agents.find((agent) => agent.name === "planner");
assert.ok(scout);
assert.ok(planner);
assert.equal(scout.output, "custom.md");
assert.deepEqual(scout.defaultReads, ["a.md"]);
assert.equal(scout.defaultProgress, false);
assert.equal(planner.defaultReads, undefined); // false sentinel survives save+reload
});
});
Loading