d20e6c0829
- Migrated global service tier settings to a per-model-family architecture (OpenAI, Anthropic, Google). - Implemented `ServiceTierByFamily` mapping to allow independent configuration and resolution per provider. - Added automatic migration logic for legacy service tier and fast-mode application settings. - Updated telemetry, session management, and task execution to support provider-specific tier resolution.
577 lines
23 KiB
TypeScript
577 lines
23 KiB
TypeScript
/**
|
|
* Host-side handler for the eval `agent()` helper.
|
|
*/
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { prompt, Snowflake } from "@oh-my-pi/pi-utils";
|
|
import { type } from "arktype";
|
|
import { resolveAgentModelPatterns } from "../config/model-resolver";
|
|
import type { LocalProtocolOptions } from "../internal-urls";
|
|
import { registerArtifactsDir } from "../internal-urls/registry-helpers";
|
|
import { MCPManager } from "../mcp/manager";
|
|
import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.md" with { type: "text" };
|
|
import { MAIN_AGENT_ID } from "../registry/agent-registry";
|
|
import * as taskDiscovery from "../task/discovery";
|
|
import type { ExecutorOptions } from "../task/executor";
|
|
import * as taskExecutor from "../task/executor";
|
|
import {
|
|
applyEligibleNestedPatches,
|
|
type IsolationContext,
|
|
makeIsolationCommitMessage,
|
|
mergeIsolatedChanges,
|
|
prepareIsolationContext,
|
|
runIsolatedSubprocess,
|
|
} from "../task/isolation-runner";
|
|
import { AgentOutputManager } from "../task/output-manager";
|
|
import type { AgentDefinition, AgentProgress, SingleResult } from "../task/types";
|
|
import { type NestedRepoPatch, parseIsolationMode } from "../task/worktree";
|
|
import type { ToolSession } from "../tools";
|
|
import { ToolError } from "../tools/tool-errors";
|
|
import { withBridgeTimeoutPause } from "./bridge-timeout";
|
|
import type { JsStatusEvent } from "./js/shared/types";
|
|
// Import review tools for side effects (registers subagent tool handlers).
|
|
import "../tools/review";
|
|
|
|
/** Synthetic bridge name reserved for the `agent()` helper across both runtimes. */
|
|
export const EVAL_AGENT_BRIDGE_NAME = "__agent__";
|
|
|
|
/** Hard recursion limit for eval-driven subagents. */
|
|
export const EVAL_AGENT_MAX_DEPTH = 3;
|
|
|
|
const DEFAULT_AGENT_TYPE = "task";
|
|
const DEFAULT_AGENT_LABEL = "EvalAgent";
|
|
|
|
const agentArgsSchema = type({
|
|
prompt: "string>0",
|
|
"agent?": "string>0",
|
|
"model?": "string>0|string>0[]",
|
|
"label?": "string",
|
|
"schema?": "unknown",
|
|
"isolated?": "boolean",
|
|
"apply?": "boolean",
|
|
"merge?": "boolean",
|
|
"handle?": "boolean",
|
|
});
|
|
|
|
interface EvalAgentArgs {
|
|
prompt: string;
|
|
agent?: string;
|
|
model?: string | string[];
|
|
label?: string;
|
|
schema?: unknown;
|
|
/**
|
|
* Run this subagent inside an isolation worktree (copy-on-write of the
|
|
* parent repo). Strict opt-in: defaults to `false` regardless of the
|
|
* session's `task.isolation.mode`, mirroring the `task` tool. Passing
|
|
* `true` while `task.isolation.mode === "none"` errors out instead of
|
|
* silently downgrading.
|
|
*/
|
|
isolated?: boolean;
|
|
/**
|
|
* When isolated, apply the captured patch / merge the captured branch back
|
|
* to the parent repo (default `true`). Pass `false` to keep changes in the
|
|
* isolation worktree only — the patch artifact path / branch name lands in
|
|
* the result so the caller can inspect or apply manually.
|
|
*/
|
|
apply?: boolean;
|
|
/**
|
|
* When isolated, allow branch-merge mode (cherry-pick onto HEAD). Defaults
|
|
* to `true`, in which case the active `task.isolation.merge` setting picks
|
|
* patch vs branch. Pass `false` to force patch mode even when the setting
|
|
* is `"branch"` — useful when a fan-out cannot tolerate the per-call git
|
|
* lock + repo mutation that branch mode performs.
|
|
*/
|
|
merge?: boolean;
|
|
/** True when a runtime helper will return an `agent://` handle backed by the output artifacts. */
|
|
handle?: boolean;
|
|
}
|
|
|
|
export interface EvalAgentBridgeOptions {
|
|
session: ToolSession;
|
|
signal?: AbortSignal;
|
|
emitStatus?: (event: JsStatusEvent) => void;
|
|
}
|
|
|
|
export interface EvalAgentResult {
|
|
text: string;
|
|
details: {
|
|
agent: string;
|
|
id: string;
|
|
model?: string | string[];
|
|
structured: boolean;
|
|
/** True iff this run executed inside an isolation worktree. */
|
|
isolated?: boolean;
|
|
/** Captured patch artifact (patch mode) — surfaced regardless of `apply`. */
|
|
patchPath?: string;
|
|
/** Captured branch (branch mode) — surfaced regardless of `apply`. */
|
|
branchName?: string;
|
|
/** Captured nested repository patches — surfaced for isolated `apply=false` manual application. */
|
|
nestedPatches?: NestedRepoPatch[];
|
|
/**
|
|
* Tri-state apply outcome for isolated runs:
|
|
* - `true` — apply ran (or had nothing to do) and left the repo clean.
|
|
* - `false` — apply attempted and failed; artifacts preserved.
|
|
* - `null` — caller opted out via `apply=false`.
|
|
* Omitted for non-isolated runs.
|
|
*/
|
|
changesApplied?: boolean | null;
|
|
/** Human-readable isolation apply/merge summary; kept out of schema-backed `text`. */
|
|
isolationSummary?: string;
|
|
};
|
|
}
|
|
|
|
function parseAgentArgs(args: unknown): EvalAgentArgs {
|
|
const result = agentArgsSchema(args);
|
|
if (result instanceof type.errors) {
|
|
throw new ToolError(`agent() received invalid arguments: ${result.summary}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function assertDepthAllowed(session: ToolSession): void {
|
|
const taskDepth = session.taskDepth ?? 0;
|
|
if (taskDepth >= EVAL_AGENT_MAX_DEPTH) {
|
|
throw new ToolError(
|
|
`agent() cannot spawn another agent at task depth ${taskDepth}; maximum depth is ${EVAL_AGENT_MAX_DEPTH}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertSpawnAllowed(session: ToolSession, agentName: string): void {
|
|
const parentSpawns = session.getSessionSpawns() ?? "*";
|
|
if (parentSpawns === "*") return;
|
|
if (parentSpawns === "") {
|
|
throw new ToolError(`Cannot spawn '${agentName}'. Allowed: none (spawns disabled for this agent)`);
|
|
}
|
|
const allowedSpawns = parentSpawns.split(",").map(spawn => spawn.trim());
|
|
if (!allowedSpawns.includes(agentName)) {
|
|
throw new ToolError(`Cannot spawn '${agentName}'. Allowed: ${parentSpawns}`);
|
|
}
|
|
}
|
|
|
|
function assertAgentEnabled(session: ToolSession, agentName: string, agents: AgentDefinition[]): void {
|
|
const disabledAgents = session.settings.get("task.disabledAgents") as string[];
|
|
if (!disabledAgents.includes(agentName)) return;
|
|
const enabled = agents.filter(agent => !disabledAgents.includes(agent.name)).map(agent => agent.name);
|
|
throw new ToolError(
|
|
`Agent "${agentName}" is disabled in settings. Enable it via /agents, or use a different agent type.${enabled.length > 0 ? ` Available: ${enabled.join(", ")}` : ""}`,
|
|
);
|
|
}
|
|
|
|
function assertNotPlanMode(session: ToolSession): void {
|
|
if (session.getPlanModeState?.()?.enabled) {
|
|
throw new ToolError("agent() is unavailable in plan mode.");
|
|
}
|
|
}
|
|
|
|
function renderSubagentPrompt(assignment: string): string {
|
|
return prompt.render(subagentUserPromptTemplate, { assignment: assignment.trim() });
|
|
}
|
|
|
|
function trimToUndefined(value: string | undefined): string | undefined {
|
|
const trimmed = value?.trim();
|
|
return trimmed ? trimmed : undefined;
|
|
}
|
|
|
|
function outputIdBase(label: string | undefined, agentName: string): string {
|
|
const source = trimToUndefined(label) ?? agentName ?? DEFAULT_AGENT_LABEL;
|
|
const sanitized = source.replace(/[^A-Za-z0-9_-]+/g, "").slice(0, 48);
|
|
return sanitized || DEFAULT_AGENT_LABEL;
|
|
}
|
|
|
|
function getOutputManager(session: ToolSession): AgentOutputManager {
|
|
if (session.agentOutputManager) return session.agentOutputManager;
|
|
const manager = new AgentOutputManager(session.getArtifactsDir ?? (() => null));
|
|
session.agentOutputManager = manager;
|
|
return manager;
|
|
}
|
|
|
|
interface ArtifactPaths {
|
|
sessionFile: string | null;
|
|
artifactsDir: string;
|
|
unregisterArtifactsDir?: () => void;
|
|
/**
|
|
* True when `artifactsDir` was created off the session path (no session
|
|
* file). Caller is then free to `rm -rf` it once all isolated patch
|
|
* artifacts have been consumed or applied.
|
|
*/
|
|
tempArtifactsDir: boolean;
|
|
}
|
|
|
|
async function getArtifacts(session: ToolSession): Promise<ArtifactPaths> {
|
|
const sessionFile = session.getSessionFile();
|
|
const sessionArtifactsDir = sessionFile ? sessionFile.slice(0, -6) : null;
|
|
const tempArtifactsDir = sessionArtifactsDir === null;
|
|
const artifactsDir = sessionArtifactsDir ?? path.join(os.tmpdir(), `omp-eval-agent-${Snowflake.next()}`);
|
|
await fs.mkdir(artifactsDir, { recursive: true });
|
|
const unregisterArtifactsDir = tempArtifactsDir ? registerArtifactsDir(artifactsDir) : undefined;
|
|
return { sessionFile, artifactsDir, unregisterArtifactsDir, tempArtifactsDir };
|
|
}
|
|
|
|
/**
|
|
* Persist nested-repo patches to the per-call artifacts dir so an isolated
|
|
* apply failure can surface their paths in the thrown ToolError. The
|
|
* isolation worktree is already gone by the time we run, so without this the
|
|
* captured nested patches would be unrecoverable.
|
|
*/
|
|
async function persistNestedPatches(
|
|
artifactsDir: string,
|
|
agentId: string,
|
|
nestedPatches: NestedRepoPatch[],
|
|
): Promise<string[]> {
|
|
const written: string[] = [];
|
|
for (let index = 0; index < nestedPatches.length; index++) {
|
|
const patch = nestedPatches[index];
|
|
if (!patch) continue;
|
|
const slug = patch.relativePath.replace(/[^A-Za-z0-9._-]+/g, "_") || `nested-${index}`;
|
|
const out = path.join(artifactsDir, `${agentId}.nested-${index}-${slug}.patch`);
|
|
await Bun.write(out, patch.patch);
|
|
written.push(out);
|
|
}
|
|
return written;
|
|
}
|
|
|
|
function plainIsolationSummary(summary: string): string {
|
|
return summary.replace(/<\/?system-notification>/g, "").trim();
|
|
}
|
|
|
|
function emitProgressStatus(emitStatus: ((event: JsStatusEvent) => void) | undefined, progress: AgentProgress): void {
|
|
if (!emitStatus) return;
|
|
const preview = (progress.assignment ?? progress.task ?? "").split("\n")[0]?.slice(0, 120);
|
|
emitStatus({
|
|
op: "agent",
|
|
id: progress.id,
|
|
agent: progress.agent,
|
|
status: progress.status,
|
|
lastIntent: progress.lastIntent,
|
|
currentTool: progress.currentTool,
|
|
currentToolArgs: progress.currentToolArgs,
|
|
taskPreview: preview || undefined,
|
|
toolCount: progress.toolCount,
|
|
tokens: progress.tokens,
|
|
contextTokens: progress.contextTokens,
|
|
contextWindow: progress.contextWindow,
|
|
cost: progress.cost,
|
|
durationMs: progress.durationMs,
|
|
model: progress.resolvedModel,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Coalesce a subagent failure into a non-empty, human-meaningful error message.
|
|
*
|
|
* When the executor aborts a subagent (runtime limit, parent cancellation, …)
|
|
* the actionable explanation lives on `abortReason`, while `error`/`stderr`
|
|
* are routinely empty strings. Plain `??` coalescing stops at the empty string
|
|
* and ships an empty error through the bridge — Python then surfaces only the
|
|
* generic `bridge call '__agent__' failed`. See #2006.
|
|
*/
|
|
function buildSubagentFailureMessage(agentName: string, result: SingleResult): string {
|
|
const abortReason = trimToUndefined(result.abortReason);
|
|
if (result.aborted && abortReason) return abortReason;
|
|
return (
|
|
trimToUndefined(result.error) ??
|
|
trimToUndefined(result.stderr) ??
|
|
abortReason ??
|
|
`agent() subagent '${agentName}' failed.`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Run a single subagent on behalf of an eval cell's `agent()` call.
|
|
*/
|
|
export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOptions): Promise<EvalAgentResult> {
|
|
const parsed = parseAgentArgs(args);
|
|
const agentName = parsed.agent ?? DEFAULT_AGENT_TYPE;
|
|
const structured = Object.hasOwn(parsed, "schema");
|
|
|
|
assertNotPlanMode(options.session);
|
|
assertDepthAllowed(options.session);
|
|
assertSpawnAllowed(options.session, agentName);
|
|
|
|
const turnBudget = options.session.getTurnBudget?.();
|
|
if (turnBudget?.hard && turnBudget.total !== null && turnBudget.spent >= turnBudget.total) {
|
|
throw new ToolError(
|
|
`agent() blocked: turn token budget exhausted (${turnBudget.spent}/${turnBudget.total} output tokens). Raise or drop the +Nk! ceiling to continue.`,
|
|
);
|
|
}
|
|
|
|
const { agents } = await taskDiscovery.discoverAgents(options.session.cwd);
|
|
const agent = taskDiscovery.getAgent(agents, agentName);
|
|
if (!agent) {
|
|
const available = agents.map(candidate => candidate.name).join(", ") || "none";
|
|
throw new ToolError(`Unknown agent "${agentName}". Available: ${available}`);
|
|
}
|
|
assertAgentEnabled(options.session, agentName, agents);
|
|
|
|
const effectiveAgent = agent;
|
|
const parentActiveModelPattern = options.session.getActiveModelString?.();
|
|
const agentModelOverrides = options.session.settings.get("task.agentModelOverrides");
|
|
const modelOverride = resolveAgentModelPatterns({
|
|
settingsOverride: parsed.model ?? agentModelOverrides[agentName],
|
|
agentModel: effectiveAgent.model,
|
|
settings: options.session.settings,
|
|
activeModelPattern: parentActiveModelPattern,
|
|
fallbackModelPattern: options.session.getModelString?.(),
|
|
});
|
|
const availableSkills = [...(options.session.skills ?? [])];
|
|
const resolvedAutoloadSkills =
|
|
effectiveAgent.autoloadSkills?.length && availableSkills.length > 0
|
|
? effectiveAgent.autoloadSkills
|
|
.map(name => availableSkills.find(skill => skill.name === name))
|
|
.filter((skill): skill is NonNullable<typeof skill> => skill !== undefined)
|
|
: [];
|
|
const contextFiles = options.session.contextFiles?.filter(
|
|
file => path.basename(file.path).toLowerCase() !== "agents.md",
|
|
);
|
|
const localProtocolOptions: LocalProtocolOptions = options.session.localProtocolOptions ?? {
|
|
getArtifactsDir: options.session.getArtifactsDir ?? (() => null),
|
|
getSessionId: options.session.getSessionId ?? (() => null),
|
|
};
|
|
const parentArtifactManager = options.session.getArtifactManager?.() ?? undefined;
|
|
const mcpManager = options.session.mcpManager ?? MCPManager.instance();
|
|
const { sessionFile, artifactsDir, unregisterArtifactsDir, tempArtifactsDir } = await getArtifacts(options.session);
|
|
const outputManager = getOutputManager(options.session);
|
|
const id = await outputManager.allocate(outputIdBase(parsed.label, agentName));
|
|
const assignment = parsed.prompt.trim();
|
|
|
|
// Isolation gating. Strict opt-in: only the explicit `isolated=true`
|
|
// argument turns it on; `task.isolation.mode` no longer drives the
|
|
// default. Mirrors the `task` tool so eval `agent()` and `task` callers
|
|
// see the same semantic. `isolated=true` while the mode is `"none"`
|
|
// surfaces a clear error instead of silently downgrading.
|
|
const isolationMode = options.session.settings.get("task.isolation.mode");
|
|
const isolationEnabledInSettings = isolationMode !== "none";
|
|
if (parsed.isolated === true && !isolationEnabledInSettings) {
|
|
throw new ToolError(`agent(isolated=True) requires task.isolation.mode to be set; current mode is "none".`);
|
|
}
|
|
const isIsolated = parsed.isolated === true;
|
|
const settingsMergeMode = options.session.settings.get("task.isolation.merge");
|
|
const mergeMode: "patch" | "branch" = parsed.merge === false ? "patch" : settingsMergeMode;
|
|
const applyChanges = parsed.apply !== false;
|
|
|
|
// Isolation context capture (prepareIsolationContext → captureBaseline)
|
|
// happens inside the timeout-pause closure below; on dirty/large repos the
|
|
// baseline walk can run long and must stay covered by the eval idle
|
|
// suspension.
|
|
|
|
const buildCommitMessage = makeIsolationCommitMessage(options.session);
|
|
|
|
const baseRunOptions: ExecutorOptions = {
|
|
cwd: options.session.cwd,
|
|
agent: effectiveAgent,
|
|
task: renderSubagentPrompt(assignment),
|
|
assignment,
|
|
description: trimToUndefined(parsed.label),
|
|
index: 0,
|
|
id,
|
|
taskDepth: options.session.taskDepth ?? 0,
|
|
modelOverride,
|
|
parentActiveModelPattern,
|
|
thinkingLevel: effectiveAgent.thinkingLevel,
|
|
outputSchema: structured ? parsed.schema : undefined,
|
|
sessionFile,
|
|
persistArtifacts: Boolean(sessionFile),
|
|
artifactsDir,
|
|
// Eval `agent()` subagents are short-lived programmatic helpers (data
|
|
// collection, structured output, parallel() fan-out). LSP server
|
|
// cold-start costs tens of seconds and is pure overhead here, so it is
|
|
// forced off regardless of the `task.enableLsp` setting — that knob only
|
|
// governs LSP-aware delegation through the `task` tool.
|
|
enableLsp: false,
|
|
signal: options.signal,
|
|
eventBus: options.session.eventBus,
|
|
onProgress: progress => emitProgressStatus(options.emitStatus, progress),
|
|
authStorage: options.session.authStorage,
|
|
modelRegistry: options.session.modelRegistry,
|
|
settings: options.session.settings,
|
|
// Eval `agent()` subagents are never wall-clock capped: the parent
|
|
// cell's idle watchdog is suspended for the whole bridge call
|
|
// (withBridgeTimeoutPause), so a long-running phase/recovery workflow
|
|
// must not be killed by `task.maxRuntimeMs`. Force the limit off
|
|
// regardless of the inherited session setting.
|
|
maxRuntimeMs: 0,
|
|
keepAlive: false,
|
|
mcpManager,
|
|
contextFiles,
|
|
skills: availableSkills,
|
|
autoloadSkills: resolvedAutoloadSkills,
|
|
workspaceTree: options.session.workspaceTree,
|
|
promptTemplates: options.session.promptTemplates,
|
|
localProtocolOptions,
|
|
parentArtifactManager,
|
|
parentHindsightSessionState: options.session.getHindsightSessionState?.(),
|
|
parentMnemopiSessionState: options.session.getMnemopiSessionState?.(),
|
|
parentTelemetry: options.session.getTelemetry?.(),
|
|
parentAgentId: options.session.getAgentId?.() ?? MAIN_AGENT_ID,
|
|
// Live source of truth for `tier.subagent: inherit` (null = explicit none).
|
|
parentServiceTier: options.session.getServiceTierByFamily
|
|
? (options.session.getServiceTierByFamily() ?? null)
|
|
: undefined,
|
|
// Deliberately omit parentEvalSessionId: the parent's Python kernel is
|
|
// blocked on this bridge call, so sharing the eval session would deadlock
|
|
// (subagent queues behind the parent's in-flight execution, parent waits
|
|
// for subagent → circular). Each bridge-spawned subagent gets its own
|
|
// eval session with an independent kernel.
|
|
};
|
|
|
|
// Suspend eval timeout accounting through the WHOLE bridge call: the
|
|
// subagent subprocess plus any isolation post-processing (merge,
|
|
// nested-patch apply, cleanup). All of that is host-side work while the
|
|
// runtime is parked waiting for the result, and the cell timeout must
|
|
// not abort us mid-cherry-pick or mid-nested-commit. The clock restarts
|
|
// only after we hand control back to the runtime.
|
|
const { result, mergeSummary, changesApplied } = await withBridgeTimeoutPause(options.emitStatus, async () => {
|
|
let isolationContext: IsolationContext | null = null;
|
|
if (isIsolated) {
|
|
try {
|
|
isolationContext = await prepareIsolationContext(options.session.cwd);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
throw new ToolError(`Isolated agent() execution requires a git repository. ${message}`);
|
|
}
|
|
}
|
|
const preferredBackend = isIsolated ? parseIsolationMode(isolationMode) : undefined;
|
|
|
|
const result = await (async () => {
|
|
if (!isolationContext) {
|
|
return taskExecutor.runSubprocess(baseRunOptions);
|
|
}
|
|
const taskStart = Date.now();
|
|
return runIsolatedSubprocess({
|
|
baseOptions: baseRunOptions,
|
|
context: isolationContext,
|
|
preferredBackend,
|
|
agentId: id,
|
|
mergeMode,
|
|
artifactsDir,
|
|
description: trimToUndefined(parsed.label),
|
|
buildCommitMessage,
|
|
buildFailureResult: err => {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return {
|
|
index: 0,
|
|
id,
|
|
agent: effectiveAgent.name,
|
|
agentSource: effectiveAgent.source,
|
|
task: renderSubagentPrompt(assignment),
|
|
assignment,
|
|
description: trimToUndefined(parsed.label),
|
|
exitCode: 1,
|
|
output: "",
|
|
stderr: message,
|
|
truncated: false,
|
|
durationMs: Date.now() - taskStart,
|
|
tokens: 0,
|
|
requests: 0,
|
|
modelOverride,
|
|
error: message,
|
|
};
|
|
},
|
|
});
|
|
})();
|
|
|
|
if (result.exitCode !== 0 || result.error || result.aborted) {
|
|
throw new ToolError(buildSubagentFailureMessage(agentName, result));
|
|
}
|
|
|
|
let mergeSummary = "";
|
|
let changesApplied: boolean | null = null;
|
|
if (isIsolated && isolationContext) {
|
|
if (applyChanges) {
|
|
const outcome = await mergeIsolatedChanges({
|
|
result,
|
|
repoRoot: isolationContext.repoRoot,
|
|
mergeMode,
|
|
});
|
|
mergeSummary = outcome.summary;
|
|
changesApplied = outcome.changesApplied;
|
|
if (outcome.changesApplied === false) {
|
|
const summaryText = outcome.summary.trim();
|
|
const recoveryParts: string[] = [];
|
|
if (result.patchPath) recoveryParts.push(`Captured patch preserved at ${result.patchPath}.`);
|
|
if (result.branchName) recoveryParts.push(`Captured branch preserved as ${result.branchName}.`);
|
|
if (result.nestedPatches?.length) {
|
|
const nestedPaths = await persistNestedPatches(artifactsDir, result.id, result.nestedPatches);
|
|
recoveryParts.push(
|
|
`Captured nested repository patches (${result.nestedPatches.length}) preserved at: ${nestedPaths.join(", ")}.`,
|
|
);
|
|
}
|
|
const recoveryHint = recoveryParts.length > 0 ? ` ${recoveryParts.join(" ")}` : "";
|
|
throw new ToolError(
|
|
`agent() isolated apply failed for ${result.id}${summaryText ? `: ${summaryText}` : ""}${recoveryHint}`,
|
|
);
|
|
}
|
|
|
|
const nestedSummary = await applyEligibleNestedPatches({
|
|
result,
|
|
repoRoot: isolationContext.repoRoot,
|
|
mergeMode,
|
|
changesApplied: outcome.changesApplied,
|
|
mergedBranchForNestedPatches: outcome.mergedBranchForNestedPatches,
|
|
commitMessage: buildCommitMessage(),
|
|
});
|
|
mergeSummary += nestedSummary;
|
|
if (structured && nestedSummary.trim()) {
|
|
const recoveryParts: string[] = [];
|
|
if (result.nestedPatches?.length) {
|
|
const nestedPaths = await persistNestedPatches(artifactsDir, result.id, result.nestedPatches);
|
|
recoveryParts.push(
|
|
`Captured nested repository patches (${result.nestedPatches.length}) preserved at: ${nestedPaths.join(", ")}.`,
|
|
);
|
|
}
|
|
const recoveryHint = recoveryParts.length > 0 ? ` ${recoveryParts.join(" ")}` : "";
|
|
throw new ToolError(
|
|
`agent() isolated nested patch apply failed for ${result.id}: ${plainIsolationSummary(nestedSummary)}${recoveryHint}`,
|
|
);
|
|
}
|
|
} else if (result.branchName) {
|
|
mergeSummary = `\n\nIsolation: changes captured on branch \`${result.branchName}\` (apply=false). Not merged.`;
|
|
} else if (result.patchPath) {
|
|
mergeSummary = `\n\nIsolation: changes captured at \`${result.patchPath}\` (apply=false). Not applied.`;
|
|
} else {
|
|
const nestedPatches = result.nestedPatches ?? [];
|
|
if (nestedPatches.length > 0) {
|
|
mergeSummary = `\n\nIsolation: changes captured for ${nestedPatches.length} nested repositor${nestedPatches.length === 1 ? "y" : "ies"} (apply=false). Not applied.`;
|
|
} else {
|
|
mergeSummary = "\n\nIsolation: no changes captured.";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up the temp artifacts dir we created for this call only when the
|
|
// caller will not need files from it later. Keep it when the runtime helper
|
|
// will return an `agent://` handle (the `.md`/`.jsonl` backing files live
|
|
// here) and on `apply=false` (`changesApplied === null`) where the caller
|
|
// consumes `details.patchPath` / `details.branchName` /
|
|
// `details.nestedPatches` out of band. Failed isolated applies throw
|
|
// earlier with a recovery hint, so they never reach this gate.
|
|
const shouldCleanupTempArtifacts = tempArtifactsDir && !parsed.handle && (!isIsolated || changesApplied === true);
|
|
if (shouldCleanupTempArtifacts) {
|
|
await fs.rm(artifactsDir, { recursive: true, force: true });
|
|
unregisterArtifactsDir?.();
|
|
}
|
|
|
|
options.session.recordEvalSubagentUsage?.(result.usage?.output ?? 0);
|
|
|
|
return { result, mergeSummary, changesApplied };
|
|
});
|
|
|
|
return {
|
|
text: structured ? result.output : result.output + mergeSummary,
|
|
details: {
|
|
agent: result.agent,
|
|
id: result.id,
|
|
model: result.resolvedModel ?? modelOverride,
|
|
structured,
|
|
isolated: isIsolated || undefined,
|
|
patchPath: result.patchPath,
|
|
branchName: result.branchName,
|
|
nestedPatches: result.nestedPatches?.length ? result.nestedPatches : undefined,
|
|
changesApplied: isIsolated ? changesApplied : undefined,
|
|
isolationSummary: mergeSummary ? mergeSummary.trim() : undefined,
|
|
},
|
|
};
|
|
}
|