Files
oh-my-pi/packages/coding-agent/src/task/index.ts
T
2026-06-10 08:26:01 +02:00

1496 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Task tool - Delegate tasks to specialized agents.
*
* Discovers agent definitions from:
* - Bundled agents (shipped with omp-coding-agent)
* - ~/.omp/agent/agents/*.md (user-level)
* - .omp/agents/*.md (project-level)
*
* Supports:
* - Single agent execution
* - Parallel execution with concurrency limits
* - Progress tracking via JSON events
* - Session artifacts for debugging
*/
import * as fs from "node:fs/promises";
import * as os from "node:os";
import path from "node:path";
import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { Usage } from "@oh-my-pi/pi-ai";
import { $env, logger, prompt, Snowflake } from "@oh-my-pi/pi-utils";
import type { ToolSession } from "..";
import { resolveAgentModelPatterns } from "../config/model-resolver";
import { MCPManager } from "../mcp/manager";
import type { Theme } from "../modes/theme/theme";
import planModeSubagentPrompt from "../prompts/system/plan-mode-subagent.md" with { type: "text" };
import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.md" with { type: "text" };
import taskDescriptionTemplate from "../prompts/tools/task.md" with { type: "text" };
import taskSummaryTemplate from "../prompts/tools/task-summary.md" with { type: "text" };
import { truncateForPrompt } from "../tools/approval";
import { formatBytes, formatDuration } from "../tools/render-utils";
import {
type AgentDefinition,
type AgentProgress,
getTaskSchema,
type SingleResult,
type TaskParams,
type TaskToolDetails,
type TaskToolSchemaInstance,
} from "./types";
// Import review tools for side effects (registers subagent tool handlers)
import "../tools/review";
import type { LocalProtocolOptions } from "../internal-urls";
import { loadOverallPlanReference } from "../plan-mode/plan-handoff";
import { generateCommitMessage } from "../utils/commit-message-generator";
import * as git from "../utils/git";
import { type DiscoveryResult, discoverAgents, getAgent } from "./discovery";
import { runSubprocess } from "./executor";
import { AgentOutputManager } from "./output-manager";
import { mapWithConcurrencyLimit, Semaphore } from "./parallel";
import { renderResult, renderCall as renderTaskCall } from "./render";
import { repairTaskParams } from "./repair-args";
import { getTaskSimpleModeCapabilities, type TaskSimpleMode } from "./simple-mode";
import {
applyNestedPatches,
captureBaseline,
captureDeltaPatch,
cleanupIsolation,
cleanupTaskBranches,
commitToBranch,
ensureIsolation,
getRepoRoot,
type IsolationHandle,
mergeTaskBranches,
parseIsolationMode,
type WorktreeBaseline,
} from "./worktree";
function renderSubagentUserPrompt(assignment: string, simpleMode: TaskSimpleMode): string {
return prompt.render(subagentUserPromptTemplate, {
assignment: assignment.trim(),
independentMode: simpleMode === "independent",
});
}
function createUsageTotals(): Usage {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
function addUsageTotals(target: Usage, usage: Partial<Usage>): void {
const input = usage.input ?? 0;
const output = usage.output ?? 0;
const cacheRead = usage.cacheRead ?? 0;
const cacheWrite = usage.cacheWrite ?? 0;
const totalTokens = usage.totalTokens ?? input + output + cacheRead + cacheWrite;
const cost =
usage.cost ??
({
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
} satisfies Usage["cost"]);
target.input += input;
target.output += output;
target.cacheRead += cacheRead;
target.cacheWrite += cacheWrite;
target.totalTokens += totalTokens;
target.cost.input += cost.input;
target.cost.output += cost.output;
target.cost.cacheRead += cost.cacheRead;
target.cost.cacheWrite += cost.cacheWrite;
target.cost.total += cost.total;
}
// Re-export types and utilities
export { loadBundledAgents as BUNDLED_AGENTS } from "./agents";
export { discoverCommands, expandCommand, getCommand } from "./commands";
export { discoverAgents, getAgent } from "./discovery";
export { AgentOutputManager } from "./output-manager";
export type {
AgentDefinition,
AgentProgress,
SingleResult,
SubagentEventPayload,
SubagentLifecyclePayload,
SubagentProgressPayload,
TaskParams,
TaskToolDetails,
} from "./types";
export {
TASK_SUBAGENT_EVENT_CHANNEL,
TASK_SUBAGENT_LIFECYCLE_CHANNEL,
TASK_SUBAGENT_PROGRESS_CHANNEL,
taskSchema,
} from "./types";
// Built-in tools whose approval tier is "read" (see tool classes' `approval`).
// An agent is read-only iff its declared tools are a non-empty subset of this set.
// Fail-safe: any unknown tool makes the agent not read-only.
export const READ_ONLY_TOOL_NAMES: ReadonlySet<string> = new Set([
"read",
"search",
"find",
"web_search",
"ast_grep",
"yield",
"irc",
"ask",
"job",
"todo",
"recall",
"reflect",
"retain",
"memory_edit",
"render_mermaid",
"inspect_image",
"checkpoint",
"rewind",
"resolve",
"report_finding",
"search_tool_bm25",
]);
const PLAN_MODE_AGENT_TOOL_ALLOWLIST: ReadonlySet<string> = new Set(["ast_grep", "report_finding"]);
export function isReadOnlyAgent(agent: AgentDefinition): boolean {
return !!agent.tools?.length && agent.tools.every(tool => READ_ONLY_TOOL_NAMES.has(tool));
}
/**
* Render the tool description from a cached agent list and current settings.
*/
function renderDescription(
agents: AgentDefinition[],
maxConcurrency: number,
isolationEnabled: boolean,
asyncEnabled: boolean,
disabledAgents: string[],
simpleMode: TaskSimpleMode,
ircEnabled: boolean,
parentSpawns: string,
): string {
const spawningDisabled = parentSpawns === "";
let filteredAgents = disabledAgents.length > 0 ? agents.filter(a => !disabledAgents.includes(a.name)) : agents;
if (spawningDisabled) {
filteredAgents = [];
} else if (parentSpawns !== "*") {
const allowed = new Set(
parentSpawns
.split(",")
.map(s => s.trim())
.filter(Boolean),
);
filteredAgents = filteredAgents.filter(a => allowed.has(a.name));
}
const renderedAgents = filteredAgents.map(agent => ({
name: agent.name,
description: agent.description,
readOnly: isReadOnlyAgent(agent),
}));
const { contextEnabled, customSchemaEnabled } = getTaskSimpleModeCapabilities(simpleMode);
return prompt.render(taskDescriptionTemplate, {
agents: renderedAgents,
spawningDisabled,
MAX_CONCURRENCY: maxConcurrency,
isolationEnabled,
asyncEnabled,
contextEnabled,
customSchemaEnabled,
ircEnabled,
defaultMode: simpleMode === "default",
schemaFreeMode: simpleMode === "schema-free",
independentMode: simpleMode === "independent",
});
}
function createTaskModeError(text: string): AgentToolResult<TaskToolDetails> {
return {
content: [{ type: "text", text }],
details: { projectAgentsDir: null, results: [], totalDurationMs: 0 },
};
}
function validateTaskModeParams(simpleMode: TaskSimpleMode, params: TaskParams): string | undefined {
const { contextEnabled, customSchemaEnabled } = getTaskSimpleModeCapabilities(simpleMode);
const disallowedFields: string[] = [];
if (!contextEnabled && params.context !== undefined) {
disallowedFields.push("context");
}
if (!customSchemaEnabled && params.schema !== undefined) {
disallowedFields.push("schema");
}
if (disallowedFields.length === 0) {
return undefined;
}
if (simpleMode === "schema-free") {
return "task.simple is set to schema-free, so the task tool does not accept `schema`. Remove it and rely on the selected agent definition or inherited session schema.";
}
if (disallowedFields.length === 1) {
return `task.simple is set to independent, so the task tool does not accept \`${disallowedFields[0]}\`. Put everything the subagent needs inside each task assignment.`;
}
return "task.simple is set to independent, so the task tool does not accept `context` or `schema`. Put all required background and output expectations inside each task assignment or the selected agent definition.";
}
/** Sentinel for async jobs whose subagent finished with a failing result; batch counters are already updated. */
class TaskJobError extends Error {}
/**
* Validate task ids: every task needs a non-empty id and ids must be unique
* (case-insensitive). Returns a problem description, or undefined when valid.
*/
function validateTaskIds(tasks: TaskParams["tasks"]): string | undefined {
const missingTaskIndexes: number[] = [];
const idIndexes = new Map<string, number[]>();
for (let i = 0; i < tasks.length; i++) {
const id = tasks[i]?.id;
if (typeof id !== "string" || id.trim() === "") {
missingTaskIndexes.push(i);
continue;
}
const normalizedId = id.toLowerCase();
const indexes = idIndexes.get(normalizedId);
if (indexes) {
indexes.push(i);
} else {
idIndexes.set(normalizedId, [i]);
}
}
const duplicateIds: Array<{ id: string; indexes: number[] }> = [];
for (const [normalizedId, indexes] of idIndexes.entries()) {
if (indexes.length > 1) {
duplicateIds.push({
id: tasks[indexes[0]]?.id ?? normalizedId,
indexes,
});
}
}
if (missingTaskIndexes.length === 0 && duplicateIds.length === 0) {
return undefined;
}
const problems: string[] = [];
if (missingTaskIndexes.length > 0) {
problems.push(`Missing task ids at indexes: ${missingTaskIndexes.join(", ")}`);
}
if (duplicateIds.length > 0) {
const details = duplicateIds.map(entry => `${entry.id} (indexes ${entry.indexes.join(", ")})`).join("; ");
problems.push(`Duplicate task ids detected (case-insensitive): ${details}`);
}
return `Invalid tasks: ${problems.join(". ")}`;
}
/**
* Process-level memo for create-time agent discovery, keyed by resolved cwd.
*
* `TaskTool.create` runs for every (sub)agent session in this process and the
* walk-up + plugin-registry scan in `discoverAgents` is identical for a given
* cwd, so repeat creations reuse the first scan. Execution-time discovery
* (`#executeSync`) intentionally stays fresh. The memo also tracks the live
* `discoverAgents` binding: test spies swap that binding, which invalidates
* the memo automatically.
*/
const discoveryMemo = new Map<string, Promise<DiscoveryResult>>();
let discoveryMemoFn: typeof discoverAgents | undefined;
function discoverAgentsForCreate(cwd: string): Promise<DiscoveryResult> {
const fn = discoverAgents;
if (discoveryMemoFn !== fn) {
discoveryMemoFn = fn;
discoveryMemo.clear();
}
const key = path.resolve(cwd);
let pending = discoveryMemo.get(key);
if (!pending) {
pending = fn(cwd);
discoveryMemo.set(key, pending);
pending.catch(() => {
if (discoveryMemo.get(key) === pending) discoveryMemo.delete(key);
});
}
return pending;
}
// ═══════════════════════════════════════════════════════════════════════════
// Tool Class
// ═══════════════════════════════════════════════════════════════════════════
/**
* Task tool - Delegate tasks to specialized agents.
*
* Requires async initialization to discover available agents.
* Use `TaskTool.create(session)` to instantiate.
*/
export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetails, Theme> {
readonly name = "task";
readonly approval = "exec" as const;
readonly formatApprovalDetails = (args: unknown): string[] => {
const params = args as Partial<TaskParams>;
const lines: string[] = [];
if (typeof params.agent === "string") {
lines.push(`Agent: ${truncateForPrompt(params.agent)}`);
}
const tasks = Array.isArray(params.tasks) ? params.tasks : [];
const firstTask = tasks[0];
if (firstTask) {
lines.push(`Task: ${truncateForPrompt(firstTask.id)}`);
lines.push(`Assignment:\n${truncateForPrompt(firstTask.assignment)}`);
if (tasks.length > 1) {
lines.push(`+${tasks.length - 1} more task${tasks.length === 2 ? "" : "s"}`);
}
}
return lines;
};
readonly label = "Task";
readonly summary = "Spawn a subagent to complete a parallel task";
readonly strict = true;
readonly loadMode = "discoverable";
readonly renderResult = renderResult;
// Suppress the streaming call preview once a (partial or final) result exists
// so the task renders as ONE block that transitions in place — not a pending
// call frame stacked above the result frame. Mirrors `taskToolRenderer`.
readonly mergeCallAndResult = true;
readonly #discoveredAgents: AgentDefinition[];
readonly #blockedAgent: string | undefined;
get parameters(): TaskToolSchemaInstance {
const isolationEnabled = this.session.settings.get("task.isolation.mode") !== "none";
return getTaskSchema({ isolationEnabled, simpleMode: this.#getTaskSimpleMode() });
}
renderCall(args: unknown, options: Parameters<typeof renderTaskCall>[1], theme: Theme) {
return renderTaskCall(repairTaskParams(args as TaskParams), options, theme);
}
/** Dynamic description that reflects current disabled-agent settings */
get description(): string {
const disabledAgents = this.session.settings.get("task.disabledAgents") as string[];
const maxConcurrency = this.session.settings.get("task.maxConcurrency");
const isolationMode = this.session.settings.get("task.isolation.mode");
return renderDescription(
this.#discoveredAgents,
maxConcurrency,
isolationMode !== "none",
this.session.settings.get("async.enabled"),
disabledAgents,
this.#getTaskSimpleMode(),
this.session.settings.get("irc.enabled") === true,
this.session.getSessionSpawns() ?? "*",
);
}
private constructor(
private readonly session: ToolSession,
discoveredAgents: AgentDefinition[],
) {
this.#blockedAgent = $env.PI_BLOCKED_AGENT;
this.#discoveredAgents = discoveredAgents;
}
#getTaskSimpleMode(): TaskSimpleMode {
return this.session.settings.get("task.simple");
}
/**
* Create a TaskTool instance with async agent discovery.
*/
static async create(session: ToolSession): Promise<TaskTool> {
const { agents } = await discoverAgentsForCreate(session.cwd);
return new TaskTool(session, agents);
}
async execute(
toolCallId: string,
rawParams: unknown,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
): Promise<AgentToolResult<TaskToolDetails>> {
const params = repairTaskParams(rawParams as TaskParams);
const simpleMode = this.#getTaskSimpleMode();
const validationError = validateTaskModeParams(simpleMode, params);
if (validationError) {
return createTaskModeError(validationError);
}
const asyncEnabled = this.session.settings.get("async.enabled");
const selectedAgent = this.#discoveredAgents.find(agent => agent.name === params.agent);
if (!asyncEnabled || selectedAgent?.blocking === true) {
return this.#executeSync(toolCallId, params, signal, onUpdate);
}
const manager = this.session.asyncJobManager;
if (!manager) {
// Async was requested but no manager is registered (e.g. an
// orphaned session whose host never wired one up). Falling back
// to the sync path keeps the tool usable; only background/job-poll
// semantics are lost.
logger.warn("task: async.enabled but no AsyncJobManager registered; falling back to sync execution");
return this.#executeSync(toolCallId, params, signal, onUpdate);
}
const taskItems = params.tasks ?? [];
if (taskItems.length === 0) {
return this.#executeSync(toolCallId, params, signal, onUpdate);
}
const taskIdProblem = validateTaskIds(taskItems);
if (taskIdProblem) {
return createTaskModeError(taskIdProblem);
}
const outputManager =
this.session.agentOutputManager ?? new AgentOutputManager(this.session.getArtifactsDir ?? (() => null));
const uniqueIds = await outputManager.allocateBatch(taskItems.map(t => t.id));
const fallbackAgentSource =
this.#discoveredAgents.find(agent => agent.name === params.agent)?.source ?? "bundled";
const progressByTaskId = new Map<string, AgentProgress>();
for (let index = 0; index < taskItems.length; index++) {
const taskItem = taskItems[index];
const assignment = taskItem.assignment.trim();
progressByTaskId.set(taskItem.id, {
index,
id: taskItem.id,
agent: params.agent,
agentSource: fallbackAgentSource,
status: "pending",
task: renderSubagentUserPrompt(assignment, simpleMode),
assignment,
description: taskItem.description,
recentTools: [],
recentOutput: [],
toolCount: 0,
tokens: 0,
cost: 0,
durationMs: 0,
});
}
const startedJobs: Array<{ jobId: string; taskId: string }> = [];
const failedSchedules: string[] = [];
let completedJobs = 0;
let failedJobs = 0;
const getProgressSnapshot = (): AgentProgress[] => {
// Shallow copies: top-level fields are reassigned (never mutated in
// place) and the large nested payloads (extractedToolData) are
// immutable once attached — structuredClone here cost O(batch × payload)
// per progress event.
return Array.from(progressByTaskId.values())
.sort((a, b) => a.index - b.index)
.map(progress => ({ ...progress }));
};
const buildAsyncDetails = (state: "running" | "completed" | "failed", jobId: string): TaskToolDetails => ({
projectAgentsDir: null,
results: [],
totalDurationMs: 0,
progress: getProgressSnapshot(),
async: { state, jobId, type: "task" },
});
const emitAsyncUpdate = (state: "running" | "completed" | "failed", text: string): void => {
const primaryJobId = startedJobs[0]?.jobId ?? "task";
onUpdate?.({
content: [{ type: "text", text }],
details: buildAsyncDetails(state, primaryJobId),
});
};
const maxConcurrency = this.session.settings.get("task.maxConcurrency");
const semaphore = new Semaphore(maxConcurrency);
for (let i = 0; i < taskItems.length; i++) {
const taskItem = taskItems[i];
if (signal?.aborted) {
failedSchedules.push(`${taskItem.id}: cancelled before scheduling`);
completedJobs += 1;
const progress = progressByTaskId.get(taskItem.id);
if (progress) {
progress.status = "aborted";
}
continue;
}
const uniqueId = uniqueIds[i];
const singleParams: TaskParams = { ...params, tasks: [taskItem] };
const label = uniqueId;
try {
const jobId = manager.register(
"task",
label,
async ({ signal: runSignal, reportProgress, markRunning }) => {
const startedAt = Date.now();
const progress = progressByTaskId.get(taskItem.id);
await semaphore.acquire();
if (runSignal.aborted) {
semaphore.release();
if (progress) {
progress.status = "aborted";
}
completedJobs += 1;
failedJobs += 1;
throw new Error("Aborted before execution");
}
markRunning();
if (progress) {
progress.status = "running";
}
await reportProgress(
`Running background task ${taskItem.id}...`,
buildAsyncDetails("running", startedJobs[0]?.jobId ?? label) as unknown as Record<string, unknown>,
);
try {
const result = await this.#executeSync(toolCallId, singleParams, runSignal, undefined, [uniqueId]);
const finalText = result.content.find(part => part.type === "text")?.text ?? "(no output)";
const singleResult = result.details?.results[0];
// A missing per-task result means #executeSync failed at the
// tool level (results: []) — treat it as a failure, not success.
const resultFailed =
!singleResult || (singleResult.aborted ?? false) || singleResult.exitCode !== 0;
if (progress) {
progress.status = singleResult?.aborted ? "aborted" : resultFailed ? "failed" : "completed";
progress.durationMs = singleResult?.durationMs ?? Math.max(0, Date.now() - startedAt);
progress.tokens = singleResult?.tokens ?? 0;
progress.contextTokens = singleResult?.contextTokens;
progress.contextWindow = singleResult?.contextWindow;
progress.cost = singleResult?.usage?.cost.total ?? 0;
progress.extractedToolData = singleResult?.extractedToolData;
progress.retryFailure = singleResult?.retryFailure;
progress.retryState = undefined;
}
completedJobs += 1;
if (resultFailed) {
failedJobs += 1;
}
const remaining = taskItems.length - completedJobs;
const isDone = remaining === 0;
await reportProgress(
isDone
? `Background task batch complete: ${completedJobs}/${taskItems.length} finished.`
: `Background task batch progress: ${completedJobs}/${taskItems.length} finished (${remaining} running).`,
buildAsyncDetails(
isDone ? (failedJobs > 0 || failedSchedules.length > 0 ? "failed" : "completed") : "running",
startedJobs[0]?.jobId ?? label,
) as unknown as Record<string, unknown>,
);
if (isDone) {
emitAsyncUpdate(
failedJobs > 0 || failedSchedules.length > 0 ? "failed" : "completed",
`Background task batch complete: ${completedJobs}/${taskItems.length} finished.`,
);
}
if (resultFailed) {
// Mark the job itself failed; counters above are already updated.
throw new TaskJobError(finalText);
}
return finalText;
} catch (error) {
if (error instanceof TaskJobError) {
throw error;
}
if (progress) {
progress.status = "failed";
progress.durationMs = Math.max(0, Date.now() - startedAt);
}
completedJobs += 1;
failedJobs += 1;
const remaining = taskItems.length - completedJobs;
const isDone = remaining === 0;
await reportProgress(
isDone
? `Background task batch complete with failures: ${failedJobs} failed.`
: `Background task batch progress: ${completedJobs}/${taskItems.length} finished (${remaining} running).`,
buildAsyncDetails(
isDone ? "failed" : "running",
startedJobs[0]?.jobId ?? label,
) as unknown as Record<string, unknown>,
);
if (isDone) {
emitAsyncUpdate(
"failed",
`Background task batch complete with failures: ${failedJobs} failed.`,
);
}
throw error;
} finally {
semaphore.release();
}
},
{
id: label,
queued: true,
ownerId: this.session.getAgentId?.() ?? undefined,
onProgress: (text, details) => {
const progressDetails =
(details as TaskToolDetails | undefined) ??
buildAsyncDetails("running", startedJobs[0]?.jobId ?? label);
onUpdate?.({ content: [{ type: "text", text }], details: progressDetails });
},
},
);
startedJobs.push({ jobId, taskId: taskItem.id });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failedSchedules.push(`${taskItem.id}: ${message}`);
completedJobs += 1;
const progress = progressByTaskId.get(taskItem.id);
if (progress) {
progress.status = "failed";
}
}
}
if (startedJobs.length === 0) {
const failureText = `Failed to start background task jobs: ${failedSchedules.join("; ")}`;
return {
content: [{ type: "text", text: failureText }],
details: { projectAgentsDir: null, results: [], totalDurationMs: 0 },
};
}
emitAsyncUpdate(
"running",
`Launching ${startedJobs.length} background ${startedJobs.length === 1 ? "task" : "tasks"}...`,
);
const scheduleFailureSummary =
failedSchedules.length > 0
? ` Failed to schedule ${failedSchedules.length} task${failedSchedules.length === 1 ? "" : "s"}.`
: "";
const ircEnabled = this.session.settings.get("irc.enabled") === true;
const taskIdByItemId = new Map<string, string>();
for (let i = 0; i < taskItems.length; i++) {
taskIdByItemId.set(taskItems[i].id, uniqueIds[i]);
}
const startedListing = startedJobs
.map(({ taskId, jobId }) => {
const id = taskIdByItemId.get(taskId) ?? taskId;
const desc = progressByTaskId.get(taskId)?.description;
const prefix = `- \`${id}\` (job \`${jobId}\`)`;
return desc ? `${prefix} — ${desc}` : prefix;
})
.join("\n");
const coordinationHint = ircEnabled
? ` DM these ids via \`irc\` to coordinate while they run; reach for \`job\` only to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task.`
: ` Use \`job\` to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task by id.`;
return {
content: [
{
type: "text",
text: `Started ${startedJobs.length} background task job${startedJobs.length === 1 ? "" : "s"} using ${params.agent}.${scheduleFailureSummary} Results will be delivered when complete.\n${startedListing}\n${coordinationHint}`,
},
],
details: {
projectAgentsDir: null,
results: [],
totalDurationMs: 0,
progress: getProgressSnapshot(),
async: { state: "running", jobId: startedJobs[0].jobId, type: "task" },
},
};
}
async #executeSync(
toolCallId: string,
params: TaskParams,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
preAllocatedIds?: string[],
): Promise<AgentToolResult<TaskToolDetails>> {
const startTime = Date.now();
const { agents, projectAgentsDir } = await discoverAgents(this.session.cwd);
const { agent: agentName, context, schema: outputSchema } = params;
const simpleMode = this.#getTaskSimpleMode();
const { contextEnabled, customSchemaEnabled } = getTaskSimpleModeCapabilities(simpleMode);
const sharedContext = contextEnabled ? context?.trim() : undefined;
const isolationMode = this.session.settings.get("task.isolation.mode");
const isolationRequested = "isolated" in params ? params.isolated === true : false;
const isIsolated = isolationMode !== "none" && isolationRequested;
const mergeMode = this.session.settings.get("task.isolation.merge");
const commitStyle = this.session.settings.get("task.isolation.commits");
const maxConcurrency = this.session.settings.get("task.maxConcurrency");
const taskDepth = this.session.taskDepth ?? 0;
const subagentLspEnabled = (this.session.enableLsp ?? true) && this.session.settings.get("task.enableLsp");
if (isolationMode === "none" && "isolated" in params) {
return {
content: [
{
type: "text",
text: "Task isolation is disabled.",
},
],
details: {
projectAgentsDir,
results: [],
totalDurationMs: 0,
},
};
}
// Validate agent exists
const agent = getAgent(agents, agentName);
if (!agent) {
const available = agents.map(a => a.name).join(", ") || "none";
return {
content: [
{
type: "text",
text: `Unknown agent "${agentName}". Available: ${available}`,
},
],
details: {
projectAgentsDir,
results: [],
totalDurationMs: 0,
},
};
}
// Check if agent is disabled in settings
const disabledAgents = this.session.settings.get("task.disabledAgents") as string[];
if (disabledAgents.length > 0 && disabledAgents.includes(agentName)) {
const enabled = agents.filter(a => !disabledAgents.includes(a.name)).map(a => a.name);
return {
content: [
{
type: "text",
text: `Agent "${agentName}" is disabled in settings. Enable it via /agents, or use a different agent type.${enabled.length > 0 ? ` Available: ${enabled.join(", ")}` : ""}`,
},
],
details: {
projectAgentsDir,
results: [],
totalDurationMs: 0,
},
};
}
const planModeState = this.session.getPlanModeState?.();
const planModeBaseTools = ["read", "search", "find", "lsp", "web_search"];
const planModeTools = [
...planModeBaseTools,
...(agent.tools ?? []).filter(
tool => PLAN_MODE_AGENT_TOOL_ALLOWLIST.has(tool) && !planModeBaseTools.includes(tool),
),
];
const effectiveAgent: typeof agent = planModeState?.enabled
? {
...agent,
systemPrompt: `${planModeSubagentPrompt}\n\n${agent.systemPrompt}`,
tools: planModeTools,
spawns: undefined,
}
: agent;
// Apply per-agent model override from settings (highest priority)
const agentModelOverrides = this.session.settings.get("task.agentModelOverrides");
const settingsModelOverride = agentModelOverrides[agentName];
const parentActiveModelPattern = this.session.getActiveModelString?.();
const modelOverride = resolveAgentModelPatterns({
settingsOverride: settingsModelOverride,
agentModel: effectiveAgent.model,
settings: this.session.settings,
activeModelPattern: parentActiveModelPattern,
fallbackModelPattern: this.session.getModelString?.(),
});
const thinkingLevelOverride = effectiveAgent.thinkingLevel;
// Output schema priority: task call > agent frontmatter > inherited parent session.
// task.simple can disable the task-call override while leaving agent/session schemas intact.
const effectiveOutputSchema = customSchemaEnabled
? (outputSchema ?? effectiveAgent.output ?? this.session.outputSchema)
: (effectiveAgent.output ?? this.session.outputSchema);
// Handle empty or missing tasks
if (!params.tasks || params.tasks.length === 0) {
return {
content: [
{
type: "text",
text: contextEnabled
? "No tasks provided. Use: { agent, context?, tasks: [{ id, description, assignment }, ...] }"
: "No tasks provided. Use: { agent, tasks: [{ id, description, assignment }, ...] }",
},
],
details: {
projectAgentsDir,
results: [],
totalDurationMs: 0,
},
};
}
const tasks = params.tasks;
const taskIdProblem = validateTaskIds(tasks);
if (taskIdProblem) {
return {
content: [{ type: "text", text: taskIdProblem }],
details: {
projectAgentsDir,
results: [],
totalDurationMs: 0,
},
};
}
let repoRoot: string | null = null;
let baseline: WorktreeBaseline | null = null;
if (isIsolated) {
try {
repoRoot = await getRepoRoot(this.session.cwd);
baseline = await captureBaseline(repoRoot);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
content: [
{
type: "text",
text: `Isolated task execution requires a git repository. ${message}`,
},
],
details: {
projectAgentsDir,
results: [],
totalDurationMs: Date.now() - startTime,
},
};
}
}
const preferredIsolationBackend = parseIsolationMode(isolationMode);
// Derive artifacts directory
const sessionFile = this.session.getSessionFile();
const artifactsDir = sessionFile ? sessionFile.slice(0, -6) : null;
const tempArtifactsDir = artifactsDir ? null : path.join(os.tmpdir(), `omp-task-${Snowflake.next()}`);
const effectiveArtifactsDir = artifactsDir || tempArtifactsDir!;
const localProtocolOptions: LocalProtocolOptions = this.session.localProtocolOptions ?? {
getArtifactsDir: this.session.getArtifactsDir ?? (() => null),
getSessionId: this.session.getSessionId ?? (() => null),
};
// Subagents adopt the parent's ArtifactManager so artifact IDs are unique
// across the whole tree and outputs land flat in the parent's dir.
const parentArtifactManager = this.session.getArtifactManager?.() ?? undefined;
// When the session is executing an approved plan, hand the overall plan to
// every subagent so they share the main agent's plan context. Skipped in
// plan mode (read-only exploration uses planModeSubagentPrompt instead) and
// when no plan file exists at the session's reference path.
const planReference = planModeState?.enabled
? undefined
: await loadOverallPlanReference(
this.session.getPlanReferencePath?.() ?? "local://PLAN.md",
localProtocolOptions,
);
// Initialize progress tracking
const progressMap = new Map<number, AgentProgress>();
// Update callback
const emitProgress = () => {
const progress = Array.from(progressMap.values()).sort((a, b) => a.index - b.index);
onUpdate?.({
content: [{ type: "text", text: `Running ${params.tasks.length} agents...` }],
details: {
projectAgentsDir,
results: [],
totalDurationMs: Date.now() - startTime,
progress,
},
});
};
try {
// Check self-recursion prevention
if (this.#blockedAgent && agentName === this.#blockedAgent) {
return {
content: [
{
type: "text",
text: `Cannot spawn ${this.#blockedAgent} agent from within itself (recursion prevention). Use a different agent type.`,
},
],
details: {
projectAgentsDir,
results: [],
totalDurationMs: Date.now() - startTime,
},
};
}
// Check spawn restrictions from parent
const parentSpawns = this.session.getSessionSpawns() ?? "*";
const allowedSpawns = parentSpawns.split(",").map(s => s.trim());
const isSpawnAllowed = (): boolean => {
if (parentSpawns === "") return false; // Empty = deny all
if (parentSpawns === "*") return true; // Wildcard = allow all
return allowedSpawns.includes(agentName);
};
if (!isSpawnAllowed()) {
const allowed = parentSpawns === "" ? "none (spawns disabled for this agent)" : parentSpawns;
return {
content: [{ type: "text", text: `Cannot spawn '${agentName}'. Allowed: ${allowed}` }],
details: {
projectAgentsDir,
results: [],
totalDurationMs: Date.now() - startTime,
},
};
}
// Write parent conversation context for subagents. When IRC is available,
// subagents should ask live peers instead of reading a stale markdown dump.
await fs.mkdir(effectiveArtifactsDir, { recursive: true });
const shouldWriteConversationContext = this.session.settings.get("irc.enabled") !== true;
const compactContext = shouldWriteConversationContext ? this.session.getCompactContext?.() : undefined;
let contextFilePath: string | undefined;
if (compactContext) {
contextFilePath = path.join(effectiveArtifactsDir, "context.md");
await Bun.write(contextFilePath, compactContext);
}
// Build full prompts with context prepended
// Allocate unique IDs across the session to prevent artifact collisions
let uniqueIds: string[];
if (preAllocatedIds && preAllocatedIds.length === tasks.length) {
uniqueIds = preAllocatedIds;
} else {
const outputManager =
this.session.agentOutputManager ?? new AgentOutputManager(this.session.getArtifactsDir ?? (() => null));
uniqueIds = await outputManager.allocateBatch(tasks.map(t => t.id));
}
const tasksWithUniqueIds = tasks.map((t, i) => ({ ...t, id: uniqueIds[i] }));
const availableSkills = [...(this.session.skills ?? [])];
// Resolve autoload skills from agent definition against available skills
const resolvedAutoloadSkills =
agent.autoloadSkills?.length && availableSkills.length > 0
? agent.autoloadSkills
.map(name => availableSkills.find(s => s.name === name))
.filter((s): s is NonNullable<typeof s> => s !== undefined)
: [];
const contextFiles = this.session.contextFiles?.filter(
file => path.basename(file.path).toLowerCase() !== "agents.md",
);
const promptTemplates = this.session.promptTemplates;
const parentEvalSessionId = this.session.getEvalSessionId?.() ?? undefined;
const mcpManager = this.session.mcpManager ?? MCPManager.instance();
// Initialize progress for all tasks
for (let i = 0; i < tasksWithUniqueIds.length; i++) {
const taskItem = tasksWithUniqueIds[i];
const assignment = taskItem.assignment.trim();
progressMap.set(i, {
index: i,
id: taskItem.id,
agent: agentName,
agentSource: agent.source,
status: "pending",
task: renderSubagentUserPrompt(assignment, simpleMode),
assignment,
recentTools: [],
recentOutput: [],
toolCount: 0,
tokens: 0,
cost: 0,
durationMs: 0,
modelOverride,
description: taskItem.description,
});
}
emitProgress();
const runTask = async (
task: (typeof tasksWithUniqueIds)[number],
index: number,
workerSignal?: AbortSignal,
) => {
if (!isIsolated) {
return runSubprocess({
cwd: this.session.cwd,
agent: effectiveAgent,
task: renderSubagentUserPrompt(task.assignment, simpleMode),
assignment: task.assignment.trim(),
context: sharedContext,
planReference,
description: task.description,
index,
parentToolCallId: toolCallId,
id: task.id,
taskDepth,
modelOverride,
parentActiveModelPattern,
thinkingLevel: thinkingLevelOverride,
outputSchema: effectiveOutputSchema,
sessionFile,
persistArtifacts: !!artifactsDir,
artifactsDir: effectiveArtifactsDir,
contextFile: contextFilePath,
enableLsp: subagentLspEnabled,
signal: workerSignal ?? signal,
eventBus: this.session.eventBus,
onProgress: progress => {
// Shallow snapshot; recentTools is mutated in place by the
// executor, the rest is reassigned or immutable. A deep clone
// here cost O(extractedToolData) per progress event.
progressMap.set(index, { ...progress, recentTools: progress.recentTools.slice() });
emitProgress();
},
authStorage: this.session.authStorage,
modelRegistry: this.session.modelRegistry,
settings: this.session.settings,
mcpManager,
contextFiles,
skills: availableSkills,
autoloadSkills: resolvedAutoloadSkills,
workspaceTree: this.session.workspaceTree,
promptTemplates,
rules: this.session.rules,
preloadedExtensionPaths: this.session.extensionPaths,
preloadedCustomToolPaths: this.session.customToolPaths,
localProtocolOptions,
parentArtifactManager,
parentHindsightSessionState: this.session.getHindsightSessionState?.(),
parentMnemopiSessionState: this.session.getMnemopiSessionState?.(),
parentTelemetry: this.session.getTelemetry?.(),
parentEvalSessionId,
});
}
const taskStart = Date.now();
let isolationHandle: IsolationHandle | undefined;
try {
if (!repoRoot || !baseline) {
throw new Error("Isolated task execution not initialized.");
}
const taskBaseline = structuredClone(baseline);
isolationHandle = await ensureIsolation(repoRoot, task.id, preferredIsolationBackend);
const isolationDir = isolationHandle.mergedDir;
const result = await runSubprocess({
cwd: this.session.cwd,
worktree: isolationDir,
agent: effectiveAgent,
task: renderSubagentUserPrompt(task.assignment, simpleMode),
assignment: task.assignment.trim(),
context: sharedContext,
planReference,
description: task.description,
index,
parentToolCallId: toolCallId,
id: task.id,
taskDepth,
modelOverride,
parentActiveModelPattern,
thinkingLevel: thinkingLevelOverride,
outputSchema: effectiveOutputSchema,
sessionFile,
persistArtifacts: !!artifactsDir,
artifactsDir: effectiveArtifactsDir,
contextFile: contextFilePath,
enableLsp: subagentLspEnabled,
signal: workerSignal ?? signal,
eventBus: this.session.eventBus,
onProgress: progress => {
progressMap.set(index, { ...progress, recentTools: progress.recentTools.slice() });
emitProgress();
},
authStorage: this.session.authStorage,
modelRegistry: this.session.modelRegistry,
settings: this.session.settings,
mcpManager,
contextFiles,
skills: availableSkills,
autoloadSkills: resolvedAutoloadSkills,
workspaceTree: this.session.workspaceTree,
promptTemplates,
rules: this.session.rules,
localProtocolOptions,
parentArtifactManager,
parentHindsightSessionState: this.session.getHindsightSessionState?.(),
parentMnemopiSessionState: this.session.getMnemopiSessionState?.(),
parentTelemetry: this.session.getTelemetry?.(),
parentEvalSessionId,
});
if (mergeMode === "branch" && result.exitCode === 0) {
try {
const commitMsg =
commitStyle === "ai" && this.session.modelRegistry
? async (diff: string) => {
return generateCommitMessage(
diff,
this.session.modelRegistry!,
this.session.settings,
this.session.getSessionId?.() ?? undefined,
);
}
: undefined;
const commitResult = await commitToBranch(
isolationDir,
taskBaseline,
task.id,
task.description,
commitMsg,
);
return {
...result,
branchName: commitResult?.branchName,
nestedPatches: commitResult?.nestedPatches,
};
} catch (mergeErr) {
// Agent succeeded but branch commit failed — clean up stale branch
const branchName = `omp/task/${task.id}`;
await git.branch.tryDelete(repoRoot, branchName);
const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
return { ...result, error: `Merge failed: ${msg}` };
}
}
if (result.exitCode === 0) {
try {
const delta = await captureDeltaPatch(isolationDir, taskBaseline);
const patchPath = path.join(effectiveArtifactsDir, `${task.id}.patch`);
await Bun.write(patchPath, delta.rootPatch);
return {
...result,
patchPath,
nestedPatches: delta.nestedPatches,
};
} catch (patchErr) {
const msg = patchErr instanceof Error ? patchErr.message : String(patchErr);
return { ...result, error: `Patch capture failed: ${msg}` };
}
}
return result;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const assignment = task.assignment.trim();
return {
index,
id: task.id,
agent: agent.name,
agentSource: agent.source,
task: renderSubagentUserPrompt(assignment, simpleMode),
assignment,
description: task.description,
exitCode: 1,
output: "",
stderr: message,
truncated: false,
durationMs: Date.now() - taskStart,
tokens: 0,
modelOverride,
error: message,
};
} finally {
if (isolationHandle) {
await cleanupIsolation(isolationHandle);
}
}
};
// Execute in parallel with concurrency limit
const { results: partialResults, aborted } = await mapWithConcurrencyLimit(
tasksWithUniqueIds,
maxConcurrency,
runTask,
signal,
);
// Fill in skipped tasks (undefined entries from abort) with placeholder results
const results: SingleResult[] = partialResults.map((result, index) => {
if (result !== undefined) {
return result;
}
const task = tasksWithUniqueIds[index];
const assignment = task.assignment.trim();
return {
index,
id: task.id,
agent: agentName,
agentSource: agent.source,
task: renderSubagentUserPrompt(assignment, simpleMode),
assignment,
description: task.description,
exitCode: 1,
output: "",
stderr: "Skipped (cancelled before start)",
truncated: false,
durationMs: 0,
tokens: 0,
modelOverride,
error: "Cancelled before start",
aborted: true,
abortReason: "Cancelled before start",
};
});
// Aggregate usage from executor results (already accumulated incrementally)
const aggregatedUsage = createUsageTotals();
let hasAggregatedUsage = false;
for (const result of results) {
if (result.usage) {
addUsageTotals(aggregatedUsage, result.usage);
hasAggregatedUsage = true;
}
}
// Collect output paths (artifacts already written by executor in real-time)
const outputPaths: string[] = [];
const patchPaths: string[] = [];
for (const result of results) {
if (result.outputPath) {
outputPaths.push(result.outputPath);
}
if (result.patchPath) {
patchPaths.push(result.patchPath);
}
}
let mergeSummary = "";
let changesApplied: boolean | null = null;
let hadAnyChanges = false;
let mergedBranchesForNestedPatches: Set<string> | null = null;
if (isIsolated && repoRoot) {
try {
if (mergeMode === "branch") {
// Branch mode: merge task branches sequentially
const branchEntries = results
.filter(r => r.branchName && r.exitCode === 0 && !r.aborted)
.map(r => ({ branchName: r.branchName!, taskId: r.id, description: r.description }));
if (branchEntries.length === 0) {
changesApplied = true;
hadAnyChanges = false;
mergeSummary = "\n\nNo changes to apply.";
} else {
const mergeResult = await mergeTaskBranches(repoRoot, branchEntries);
mergedBranchesForNestedPatches = new Set(mergeResult.merged);
changesApplied = mergeResult.failed.length === 0;
hadAnyChanges = changesApplied && mergeResult.merged.length > 0;
if (changesApplied) {
mergeSummary = hadAnyChanges
? `\n\nMerged ${mergeResult.merged.length} branch${mergeResult.merged.length === 1 ? "" : "es"}: ${mergeResult.merged.join(", ")}`
: "\n\nNo changes to apply.";
} else {
const mergedPart =
mergeResult.merged.length > 0 ? `Merged: ${mergeResult.merged.join(", ")}.\n` : "";
const failedPart = `Failed: ${mergeResult.failed.join(", ")}.`;
const conflictPart = mergeResult.conflict ? `\nConflict: ${mergeResult.conflict}` : "";
mergeSummary = `\n\n<system-notification>Branch merge failed. ${mergedPart}${failedPart}${conflictPart}\nUnmerged branches remain for manual resolution.</system-notification>`;
}
if (mergeResult.stashConflict) {
mergeSummary += `\n\n<system-notification>${mergeResult.stashConflict}</system-notification>`;
}
}
// Clean up merged branches (keep failed ones for manual resolution)
const allBranches = branchEntries.map(b => b.branchName);
if (changesApplied) {
await cleanupTaskBranches(repoRoot, allBranches);
}
} else {
// Patch mode: apply patches from successful tasks. Failed or
// aborted siblings must not block completed work from landing.
const successfulResults = results.filter(r => r.exitCode === 0 && !r.error && !r.aborted);
const patchesInOrder = successfulResults.map(result => result.patchPath).filter(Boolean) as string[];
const missingPatch = successfulResults.some(result => !result.patchPath);
if (missingPatch) {
changesApplied = false;
hadAnyChanges = false;
} else {
const patchStats = await Promise.all(
patchesInOrder.map(async patchPath => ({
patchPath,
size: (await fs.stat(patchPath)).size,
})),
);
const nonEmptyPatches = patchStats.filter(patch => patch.size > 0).map(patch => patch.patchPath);
if (nonEmptyPatches.length === 0) {
changesApplied = true;
hadAnyChanges = false;
} else {
const patchTexts = await Promise.all(
nonEmptyPatches.map(async patchPath => Bun.file(patchPath).text()),
);
const combinedPatch = patchTexts
.map(text => (text.endsWith("\n") ? text : `${text}\n`))
.join("");
if (!combinedPatch.trim()) {
changesApplied = true;
hadAnyChanges = false;
} else {
changesApplied = await git.patch.canApplyText(repoRoot, combinedPatch);
if (changesApplied) {
try {
await git.patch.applyText(repoRoot, combinedPatch);
hadAnyChanges = true;
} catch {
changesApplied = false;
hadAnyChanges = false;
}
}
}
}
}
if (changesApplied) {
mergeSummary = hadAnyChanges ? "\n\nApplied patches: yes" : "\n\nNo changes to apply.";
} else {
const notification =
"<system-notification>Patches were not applied and must be handled manually.</system-notification>";
const patchList =
patchPaths.length > 0
? `\n\nPatch artifacts:\n${patchPaths.map(patch => `- ${patch}`).join("\n")}`
: "";
mergeSummary = `\n\n${notification}${patchList}`;
}
}
} catch (mergeErr) {
const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
changesApplied = false;
hadAnyChanges = false;
mergeSummary = `\n\n<system-notification>Merge phase failed: ${msg}\nTask outputs are preserved but changes were not applied.</system-notification>`;
}
}
// Apply nested repo patches (separate from parent git)
if (isIsolated && repoRoot && (mergeMode === "branch" || changesApplied !== false)) {
const allNestedPatches = results
.filter(r => {
if (!r.nestedPatches || r.nestedPatches.length === 0 || r.exitCode !== 0 || r.aborted) {
return false;
}
if (mergeMode !== "branch") {
return true;
}
if (!r.branchName || !mergedBranchesForNestedPatches) {
return false;
}
return mergedBranchesForNestedPatches.has(r.branchName);
})
.flatMap(r => r.nestedPatches!);
if (allNestedPatches.length > 0) {
try {
const commitMsg =
commitStyle === "ai" && this.session.modelRegistry
? async (diff: string) => {
return generateCommitMessage(
diff,
this.session.modelRegistry!,
this.session.settings,
this.session.getSessionId?.() ?? undefined,
);
}
: undefined;
await applyNestedPatches(repoRoot, allNestedPatches, commitMsg);
} catch {
// Nested patch failures are non-fatal to the parent merge
mergeSummary +=
"\n\n<system-notification>Some nested repository patches failed to apply.</system-notification>";
}
}
}
// Build final output - match plugin format
const cancelledCount = results.filter(r => r.aborted).length;
const successCount = results.filter(r => r.exitCode === 0 && !r.error && !r.aborted).length;
const totalDuration = Date.now() - startTime;
const summaries = results.map(r => {
const status = r.aborted
? "cancelled"
: r.exitCode === 0 && r.error
? "merge failed"
: r.exitCode === 0
? "completed"
: `failed (exit ${r.exitCode})`;
const output = r.output.trim() || r.stderr.trim() || "(no output)";
const outputCharCount = r.outputMeta?.charCount ?? output.length;
const fullOutputThreshold = 5000;
let preview = output;
let truncated = false;
if (outputCharCount > fullOutputThreshold) {
const slice = output.slice(0, fullOutputThreshold);
const lastNewline = slice.lastIndexOf("\n");
preview = lastNewline >= 0 ? slice.slice(0, lastNewline) : slice;
truncated = true;
}
return {
agent: r.agent,
status,
id: r.id,
preview,
truncated,
meta: r.outputMeta
? {
lineCount: r.outputMeta.lineCount,
charSize: formatBytes(r.outputMeta.charCount),
}
: undefined,
};
});
const outputIds = results.filter(r => !r.aborted || r.output.trim()).map(r => `agent://${r.id}`);
const summary = prompt.render(taskSummaryTemplate, {
successCount,
totalCount: results.length,
cancelledCount,
hasCancelledNote: aborted && cancelledCount > 0,
duration: formatDuration(totalDuration),
summaries,
outputIds,
agentName,
mergeSummary,
});
// Cleanup temp directory if used
const shouldCleanupTempArtifacts =
tempArtifactsDir && (!isIsolated || changesApplied === true || changesApplied === null);
if (shouldCleanupTempArtifacts) {
await fs.rm(tempArtifactsDir, { recursive: true, force: true });
}
return {
content: [{ type: "text", text: summary }],
details: {
projectAgentsDir,
results: results,
totalDurationMs: totalDuration,
usage: hasAggregatedUsage ? aggregatedUsage : undefined,
outputPaths,
},
};
} catch (err) {
return {
content: [{ type: "text", text: `Task execution failed: ${err}` }],
details: {
projectAgentsDir,
results: [],
totalDurationMs: Date.now() - startTime,
},
};
}
}
}