Files
shannon/apps/worker/src/ai/pi/task-formation-executor.ts
T

790 lines
29 KiB
TypeScript

// Copyright (C) 2026 Keygraph, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License version 3
// as published by the Free Software Foundation.
/** Dedicated read-only Pi executor and live tool policy for Pass 1 task formation. */
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import type { AgentMessage } from '@earendil-works/pi-agent-core';
import {
type AgentSession,
type AgentSessionEvent,
createAgentSession,
DefaultResourceLoader,
defineTool,
getAgentDir,
SessionManager,
SettingsManager,
type ToolDefinition,
} from '@earendil-works/pi-coding-agent';
import { Type } from 'typebox';
import { providerFailureSentence } from '../../services/error-handling.js';
import type { ProviderFailure } from '../../types/errors.js';
import { type ModelHost, modelHost } from '../model-host.js';
import type { ModelSelection } from '../models.js';
import type { ValidatingSubmitTool } from '../reconciliation/submit-validation.js';
import { ConfinementError, compileRepositoryGlob, RepositoryConfinement } from '../sast/capella/tools/confinement.js';
import { createCapellaRepositoryTools } from '../sast/capella/tools/repository-tools.js';
import { PI_RETRY_SETTINGS } from './retry-settings.js';
import { PI_THINKING_LEVEL } from './thinking-level.js';
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1_000;
const DEFAULT_MAX_TURNS = 64;
const MAX_TIMEOUT_MS = 30 * 60 * 1_000;
const MAX_TURNS = 128;
const MAX_LIST_RESULTS = 500;
const DEFAULT_LIST_RESULTS = 200;
const MAX_OUTPUT_BYTES = 64 * 1024;
// The live-tool-side counterpart of the source jail's copy-time exclusion (source-jail.ts): even if
// one of these somehow existed in the jailed tree, the read/grep/find/ls/glob tools built below must
// still refuse to serve it. `.git` is deliverables history, `.shannon` is scan internals, `.pi` is
// provider credentials.
const ALWAYS_DENIED_PATHS = Object.freeze(['.git', '.shannon', '.pi'] as const);
const TRANSIENT_IO_CODES = new Set([
'EAGAIN',
'EBUSY',
'ECONNREFUSED',
'ECONNRESET',
'EIO',
'EMFILE',
'ENFILE',
'ENOMEM',
'ENOSPC',
'EPIPE',
'EROFS',
'ETIMEDOUT',
]);
export const TASK_FORMATION_TOOL_NAMES = Object.freeze([
'read',
'grep',
'find',
'ls',
'glob',
'submit_result',
] as const);
// The closed set of failure reasons the integration layer accepts as grounds to fall back to a
// single-agent formation. Only a failure carrying one of these becomes a fallback; any other
// failure propagates. Keep this in sync with the reasons the Temporal caller recognizes.
export const TASK_FORMATION_FALLBACK_REASONS = Object.freeze([
'retryable_model_failure',
'missing_accepted_submission',
'model_stage_timeout',
] as const);
export type TaskFormationFallbackReason = (typeof TASK_FORMATION_FALLBACK_REASONS)[number];
export type TaskFormationExecutorFailureKind = 'model' | 'input' | 'confinement' | 'infrastructure';
/** Safe, bounded fields supplied by the activity wrapper for per-attempt executor correlation. */
export interface TaskFormationExecutionContext {
readonly executionKey?: string;
readonly attempt?: number;
readonly stage?: string;
readonly vulnerabilityClass?: string;
}
export interface TaskFormationUsage {
readonly costUsd: number;
readonly inputTokens: number;
readonly outputTokens: number;
}
export class TaskFormationExecutorError extends Error {
override readonly name = 'TaskFormationExecutorError';
readonly code: string;
readonly retryable: boolean;
readonly failureKind: TaskFormationExecutorFailureKind;
readonly fallbackReason: TaskFormationFallbackReason | undefined;
readonly usage: TaskFormationUsage;
readonly modelCalls: number;
constructor(options: {
code: string;
message: string;
retryable: boolean;
failureKind: TaskFormationExecutorFailureKind;
fallbackReason?: TaskFormationFallbackReason;
usage?: TaskFormationUsage;
modelCalls?: number;
}) {
super(options.message);
this.code = options.code;
this.retryable = options.retryable;
this.failureKind = options.failureKind;
this.fallbackReason = options.fallbackReason;
this.usage = options.usage ?? zeroUsage();
this.modelCalls = options.modelCalls ?? 0;
}
}
export interface TaskFormationExecutorRequest {
readonly cwd: string;
readonly systemPrompt: string;
readonly modelContext: string;
readonly deniedPaths: readonly string[];
readonly submitTool: ValidatingSubmitTool;
readonly signal: AbortSignal;
readonly timeoutMs?: number;
readonly maxTurns?: number;
readonly correlation?: TaskFormationExecutionContext;
}
export interface TaskFormationExecutorResult {
readonly output: unknown;
readonly usage: TaskFormationUsage;
readonly providerId: string;
readonly modelId: string;
readonly modelCalls: 1;
readonly registeredTools: readonly string[];
}
export interface TaskFormationExecutor {
run(request: TaskFormationExecutorRequest): Promise<TaskFormationExecutorResult>;
}
interface SessionOutcome {
readonly pendingProviderError: unknown;
readonly promptError: unknown;
readonly usage: TaskFormationUsage;
}
interface ToolFactoryOptions {
readonly cwd: string;
readonly deniedPaths: readonly string[];
}
function zeroUsage(): TaskFormationUsage {
return { costUsd: 0, inputTokens: 0, outputTokens: 0 };
}
/** Reject unknown values from Temporal failure details instead of widening semantic fallback. */
export function isTaskFormationFallbackReason(value: unknown): value is TaskFormationFallbackReason {
return (TASK_FORMATION_FALLBACK_REASONS as readonly unknown[]).includes(value);
}
function errorCode(error: unknown): string | undefined {
if (typeof error !== 'object' || error === null || !('code' in error)) return undefined;
return typeof error.code === 'string' ? error.code : undefined;
}
function isTransientIoFailure(error: unknown): boolean {
const code = errorCode(error);
if (code !== undefined && TRANSIENT_IO_CODES.has(code)) return true;
if (error instanceof Error && error.cause !== undefined) return isTransientIoFailure(error.cause);
return false;
}
function safeIdentifier(value: string | undefined): string | undefined {
if (value === undefined || !/^[A-Za-z0-9._:-]{1,128}$/u.test(value)) return undefined;
return value;
}
// Emit only bounded, format-checked correlation fields. Prompt text, model context, and source
// content never enter the log line. An unsafe or missing identifier falls back to a synthetic one
// rather than logging the caller's raw value.
function executionLogContext(context: TaskFormationExecutionContext | undefined): Readonly<Record<string, unknown>> {
const attempt = context?.attempt;
return Object.freeze({
executionKey: safeIdentifier(context?.executionKey) ?? randomUUID(),
attempt: Number.isSafeInteger(attempt) && (attempt ?? 0) > 0 ? attempt : null,
stage: safeIdentifier(context?.stage) ?? 'task-formation',
class: safeIdentifier(context?.vulnerabilityClass) ?? 'unknown',
});
}
function finiteNonNegative(value: number): number {
return Number.isFinite(value) ? Math.max(0, value) : 0;
}
function sessionUsage(session: AgentSession): TaskFormationUsage {
const stats = session.getSessionStats();
return {
costUsd: finiteNonNegative(stats.cost),
inputTokens: finiteNonNegative(stats.tokens.input),
outputTokens: finiteNonNegative(stats.tokens.output),
};
}
function boundedText(value: string): string {
const bytes = Buffer.from(value, 'utf8');
if (bytes.byteLength <= MAX_OUTPUT_BYTES) return value;
return bytes.subarray(0, MAX_OUTPUT_BYTES).toString('utf8');
}
function uniqueDeniedPaths(deniedPaths: readonly string[]): readonly string[] {
return Object.freeze([...new Set([...ALWAYS_DENIED_PATHS, ...deniedPaths])]);
}
// The session must register exactly the allowlisted tools. This is checked against the built tool
// set and again against the live session's registered tools, so an injected or dropped tool fails
// the session closed before the model runs.
function hasExactToolSet(toolNames: readonly string[]): boolean {
const expected = [...TASK_FORMATION_TOOL_NAMES].sort();
const actual = [...toolNames].sort();
return actual.length === expected.length && actual.every((name, index) => name === expected[index]);
}
function createListTool(confinement: RepositoryConfinement): ToolDefinition {
return defineTool({
name: 'ls',
label: 'List source directory',
description: 'List bounded repository-relative entries without following symlinks.',
promptSnippet: 'ls: list entries below one source directory',
promptGuidelines: ['Use a repository-relative directory. Absolute paths and traversal are rejected.'],
parameters: Type.Object(
{
path: Type.Optional(Type.String({ minLength: 1, maxLength: 1_024 })),
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_LIST_RESULTS })),
},
{ additionalProperties: false },
),
async execute(_toolCallId, parameters, signal) {
const requestedPath = parameters.path ?? '.';
const budget = confinement.createBudget(signal);
const searchRoot = await confinement.resolveExisting(requestedPath, true, budget);
const entries = await confinement.enumerate(requestedPath, signal, budget);
const names = new Set<string>();
for (const entry of entries) {
confinement.checkBudget(budget);
const relativePath = pathRelative(searchRoot, entry.absolutePath);
const [first, ...remaining] = relativePath.split('/');
if (first) names.add(remaining.length > 0 ? `${first}/` : first);
}
const limit = parameters.limit ?? DEFAULT_LIST_RESULTS;
const output = [...names].sort().slice(0, limit);
return {
content: [{ type: 'text' as const, text: boundedText(output.join('\n') || 'No entries found.') }],
details: { count: output.length, truncated: names.size > output.length },
};
},
});
}
function pathRelative(root: string, candidate: string): string {
const relativePath = path.relative(root, candidate);
if (
!relativePath ||
relativePath.startsWith(`..${path.sep}`) ||
relativePath === '..' ||
path.isAbsolute(relativePath)
) {
throw new TaskFormationExecutorError({
code: 'TOOL_PATH_RACE',
message: 'Task-formation source path changed during access.',
retryable: false,
failureKind: 'confinement',
});
}
return relativePath.split(path.sep).join('/');
}
function createGlobTool(confinement: RepositoryConfinement): ToolDefinition {
return defineTool({
name: 'glob',
label: 'Glob source files',
description: 'Match bounded file globs from the source-jail root without following symlinks.',
promptSnippet: 'glob: match source files from the jail root',
promptGuidelines: ['Patterns are always rooted in the source jail.'],
parameters: Type.Object(
{
pattern: Type.String({ minLength: 1, maxLength: 256 }),
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_LIST_RESULTS })),
},
{ additionalProperties: false },
),
async execute(_toolCallId, parameters, signal) {
const budget = confinement.createBudget(signal);
const matcher = compileRepositoryGlob(parameters.pattern);
const entries = await confinement.enumerate('.', signal, budget);
const limit = parameters.limit ?? DEFAULT_LIST_RESULTS;
const matches: string[] = [];
let truncated = false;
for (const entry of entries) {
confinement.checkBudget(budget);
if (!matcher.test(entry.path)) continue;
if (matches.length >= limit) {
truncated = true;
break;
}
matches.push(entry.path);
}
return {
content: [{ type: 'text' as const, text: boundedText(matches.join('\n') || 'No files found.') }],
details: { count: matches.length, truncated },
};
},
});
}
/** Create the five code-owned source tools that share one canonical jail policy. */
export async function createTaskFormationSourceTools(options: ToolFactoryOptions): Promise<readonly ToolDefinition[]> {
const deniedPaths = uniqueDeniedPaths(options.deniedPaths);
const capellaTools = await createCapellaRepositoryTools({
repositoryRoot: options.cwd,
deniedPaths,
});
const confinement = await RepositoryConfinement.create({
repositoryRoot: options.cwd,
deniedPaths,
});
const byName = new Map(capellaTools.map((tool) => [tool.name, tool]));
const tools = [
byName.get('read'),
byName.get('grep'),
byName.get('find'),
createListTool(confinement),
createGlobTool(confinement),
];
if (tools.some((tool) => tool === undefined)) {
throw new TaskFormationExecutorError({
code: 'TOOL_FACTORY_MISMATCH',
message: 'Task-formation source tool factory returned an incomplete set.',
retryable: false,
failureKind: 'confinement',
});
}
return Object.freeze(tools as ToolDefinition[]);
}
function cancellationError(signal: AbortSignal): Error {
if (signal.reason instanceof Error) return signal.reason;
return new DOMException('Task formation was cancelled.', 'AbortError');
}
function raceWithAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) return Promise.reject(cancellationError(signal));
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => reject(cancellationError(signal));
signal.addEventListener('abort', onAbort, { once: true });
promise.then(
(value) => {
signal.removeEventListener('abort', onAbort);
resolve(value);
},
(error: unknown) => {
signal.removeEventListener('abort', onAbort);
reject(error);
},
);
});
}
function validateRequest(request: TaskFormationExecutorRequest): { timeoutMs: number; maxTurns: number } {
const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const maxTurns = request.maxTurns ?? DEFAULT_MAX_TURNS;
if (!request.cwd || !request.systemPrompt || !request.modelContext) {
throw new TaskFormationExecutorError({
code: 'INVALID_REQUEST',
message: 'Task-formation executor input is incomplete.',
retryable: false,
failureKind: 'input',
});
}
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMEOUT_MS) {
throw new TaskFormationExecutorError({
code: 'INVALID_TIMEOUT',
message: `Task-formation timeout must be a positive integer no greater than ${MAX_TIMEOUT_MS} milliseconds.`,
retryable: false,
failureKind: 'input',
});
}
if (!Number.isInteger(maxTurns) || maxTurns < 1 || maxTurns > MAX_TURNS) {
throw new TaskFormationExecutorError({
code: 'INVALID_TURN_LIMIT',
message: 'Task-formation turn limit is outside its bounded range.',
retryable: false,
failureKind: 'input',
});
}
return { timeoutMs, maxTurns };
}
function isAbortLike(error: unknown): boolean {
return error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError');
}
function classifyModelFailure(host: ModelHost, error: unknown): ProviderFailure {
if (isAbortLike(error)) {
return {
type: 'AgentExecutionError',
category: 'transport',
retryable: true,
message: 'The provider request ended before task formation completed.',
};
}
return host.classify(error);
}
function executorLog(level: 'info' | 'warn', fields: Readonly<Record<string, unknown>>): void {
console[level](JSON.stringify({ component: 'task-formation-executor', ...fields }));
}
class StandaloneTaskFormationExecutor implements TaskFormationExecutor {
private readonly host: ModelHost;
constructor(host: ModelHost) {
this.host = host;
}
async run(request: TaskFormationExecutorRequest): Promise<TaskFormationExecutorResult> {
const logContext = executionLogContext(request.correlation);
let timeoutMs: number;
let maxTurns: number;
try {
({ timeoutMs, maxTurns } = validateRequest(request));
} catch (error) {
const failure = this.normalizeFailure(error);
executorLog('warn', {
...logContext,
event: 'finished',
outcome: 'failed',
code: failure.code,
failureKind: failure.failureKind,
retryable: failure.retryable,
});
throw failure;
}
const controller = new AbortController();
let termination: 'cancellation' | 'timeout' | 'turn-limit' | undefined;
let session: AgentSession | undefined;
let unsubscribe: (() => void) | undefined;
let timeout: NodeJS.Timeout | undefined;
let requestStarted = false;
let turnCount = 0;
const terminate = (reason: 'cancellation' | 'timeout' | 'turn-limit'): void => {
if (termination !== undefined) return;
termination = reason;
controller.abort(new DOMException(`Task-formation session ${reason}.`, 'AbortError'));
void session?.abort().catch(() => undefined);
};
const onCancellation = (): void => terminate('cancellation');
if (request.signal.aborted) {
executorLog('info', { ...logContext, event: 'finished', outcome: 'cancelled' });
throw cancellationError(request.signal);
}
request.signal.addEventListener('abort', onCancellation, { once: true });
timeout = setTimeout(() => terminate('timeout'), timeoutMs);
try {
let selection: ModelSelection;
try {
selection = await raceWithAbort(this.host.resolve('medium'), controller.signal);
} catch (error) {
if (termination === 'cancellation') throw cancellationError(request.signal);
if (termination === 'timeout') throw this.timeoutError(zeroUsage(), 0);
const failure = classifyModelFailure(this.host, error);
throw new TaskFormationExecutorError({
code: 'MODEL_SELECTION_FAILURE',
message: providerFailureSentence(failure),
retryable: failure.retryable,
failureKind: 'model',
...(failure.retryable && { fallbackReason: 'retryable_model_failure' }),
});
}
const sourceTools = await raceWithAbort(
createTaskFormationSourceTools({ cwd: request.cwd, deniedPaths: request.deniedPaths }),
controller.signal,
);
const customTools = [...sourceTools, request.submitTool.tool];
const toolNames = customTools.map((tool) => tool.name);
if (!hasExactToolSet(toolNames)) {
throw new TaskFormationExecutorError({
code: 'TOOL_POLICY_MISMATCH',
message: 'Task-formation source tool policy does not match the exact allowlist.',
retryable: false,
failureKind: 'confinement',
});
}
const agentDir = getAgentDir();
const settingsManager = SettingsManager.inMemory({
retry: PI_RETRY_SETTINGS,
compaction: { enabled: true },
});
const resourceLoader = new DefaultResourceLoader({
cwd: request.cwd,
agentDir,
settingsManager,
systemPrompt: `${request.systemPrompt}${request.submitTool.directive ?? ''}`,
appendSystemPrompt: [],
noExtensions: true,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
});
await raceWithAbort(resourceLoader.reload(), controller.signal);
const sessionPromise = createAgentSession({
cwd: request.cwd,
agentDir,
model: selection.model,
thinkingLevel: PI_THINKING_LEVEL,
modelRuntime: selection.modelRuntime,
noTools: 'all',
tools: toolNames,
customTools,
resourceLoader,
sessionManager: SessionManager.inMemory(),
settingsManager,
});
try {
({ session } = await raceWithAbort(sessionPromise, controller.signal));
} catch (error) {
void sessionPromise.then(
async ({ session: lateSession }) => {
await lateSession.abort().catch(() => undefined);
lateSession.dispose();
},
() => undefined,
);
throw error;
}
if (controller.signal.aborted) {
await session.abort().catch(() => undefined);
} else {
controller.signal.addEventListener('abort', () => void session?.abort().catch(() => undefined), {
once: true,
});
}
const registeredTools = session.getAllTools().map((tool) => tool.name);
if (!hasExactToolSet(registeredTools)) {
throw new TaskFormationExecutorError({
code: 'LIVE_TOOL_POLICY_MISMATCH',
message: 'The live task-formation session registered a tool outside the exact allowlist.',
retryable: false,
failureKind: 'confinement',
});
}
executorLog('info', {
...logContext,
event: 'started',
provider: selection.providerId,
model: selection.modelId,
tools: registeredTools,
resources: { context: false, extensions: false, prompts: false, skills: false },
});
let pendingProviderError: unknown;
unsubscribe = session.subscribe((event: AgentSessionEvent) => {
if (event.type !== 'turn_end') return;
turnCount += 1;
const message: AgentMessage = event.message;
if (message.role === 'assistant' && message.stopReason === 'error') {
pendingProviderError ??= message;
}
const needsAnotherTurn = message.role === 'assistant' && message.stopReason === 'toolUse';
if (turnCount >= maxTurns && needsAnotherTurn && request.submitTool.getAcceptedCount() === 0) {
terminate('turn-limit');
}
});
let promptError: unknown;
requestStarted = true;
try {
await raceWithAbort(session.prompt(request.modelContext, { expandPromptTemplates: false }), controller.signal);
} catch (error) {
promptError = error;
}
const outcome: SessionOutcome = {
pendingProviderError,
promptError,
usage: sessionUsage(session),
};
const output = this.resolveOutcome(request, outcome, termination, requestStarted);
executorLog('info', { ...logContext, event: 'finished', outcome: 'succeeded', usage: outcome.usage });
return {
output,
usage: outcome.usage,
providerId: selection.providerId,
modelId: selection.modelId,
modelCalls: 1,
registeredTools: Object.freeze([...registeredTools]),
};
} catch (error) {
// Termination reason wins over whatever error surfaced. A local timeout or an abort aborts the
// in-flight provider call, so the caught error is usually that induced abort; reporting it as a
// model failure would erase the real cause. Cancellation keeps its own identity ahead of timeout.
if (termination === 'cancellation') {
executorLog('info', { ...logContext, event: 'finished', outcome: 'cancelled' });
throw cancellationError(request.signal);
}
let failure: TaskFormationExecutorError;
if (termination === 'timeout') {
const usage = session ? sessionUsage(session) : zeroUsage();
const modelCalls = requestStarted ? 1 : 0;
failure = this.timeoutError(usage, modelCalls);
} else {
failure = this.normalizeFailure(error);
}
executorLog('warn', {
...logContext,
event: 'finished',
outcome: 'failed',
code: failure.code,
failureKind: failure.failureKind,
retryable: failure.retryable,
...(failure.fallbackReason !== undefined && { fallbackReason: failure.fallbackReason }),
usage: failure.usage,
modelCalls: failure.modelCalls,
});
throw failure;
} finally {
if (timeout) clearTimeout(timeout);
request.signal.removeEventListener('abort', onCancellation);
unsubscribe?.();
try {
session?.dispose();
} catch {
executorLog('warn', { ...logContext, event: 'cleanup-failed' });
}
}
}
private normalizeFailure(error: unknown): TaskFormationExecutorError {
if (error instanceof TaskFormationExecutorError) return error;
if (error instanceof ConfinementError) {
return new TaskFormationExecutorError({
code: `CONFINEMENT_${error.code}`,
message: error.message,
retryable: false,
failureKind: 'confinement',
});
}
if (isTransientIoFailure(error)) {
return new TaskFormationExecutorError({
code: 'SESSION_INFRASTRUCTURE_FAILURE',
message: 'Task-formation session setup encountered a retryable infrastructure failure.',
retryable: true,
failureKind: 'infrastructure',
});
}
const failure = classifyModelFailure(this.host, error);
if (failure.type === 'ConfigurationError') {
return new TaskFormationExecutorError({
code: 'MODEL_CONFIGURATION_FAILURE',
message: providerFailureSentence(failure),
retryable: false,
failureKind: 'input',
});
}
return new TaskFormationExecutorError({
code: failure.type === 'AuthenticationError' ? 'PROVIDER_AUTHENTICATION_FAILURE' : 'MODEL_SESSION_FAILURE',
message: providerFailureSentence(failure),
retryable: failure.retryable,
failureKind: 'model',
...(failure.retryable && { fallbackReason: 'retryable_model_failure' }),
});
}
private timeoutError(usage: TaskFormationUsage, modelCalls: number): TaskFormationExecutorError {
return new TaskFormationExecutorError({
code: 'MODEL_STAGE_TIMEOUT',
message: 'Task formation exceeded its model-stage timeout.',
retryable: true,
failureKind: 'model',
fallbackReason: 'model_stage_timeout',
usage,
modelCalls,
});
}
private resolveOutcome(
request: TaskFormationExecutorRequest,
outcome: SessionOutcome,
termination: 'cancellation' | 'timeout' | 'turn-limit' | undefined,
requestStarted: boolean,
): unknown {
const modelCalls = requestStarted ? 1 : 0;
if (termination === 'cancellation') throw cancellationError(request.signal);
if (termination === 'timeout') throw this.timeoutError(outcome.usage, modelCalls);
if (termination === 'turn-limit') {
throw new TaskFormationExecutorError({
code: 'TURN_LIMIT',
message: 'Task formation exhausted its bounded model turn limit.',
retryable: true,
failureKind: 'model',
fallbackReason: 'retryable_model_failure',
usage: outcome.usage,
modelCalls,
});
}
if (request.submitTool.getAcceptedCount() > 1) {
throw new TaskFormationExecutorError({
code: 'DUPLICATE_ACCEPTED_SUBMISSION',
message: 'Task formation accepted more than one submission.',
retryable: true,
failureKind: 'model',
fallbackReason: 'retryable_model_failure',
usage: outcome.usage,
modelCalls,
});
}
if (outcome.pendingProviderError !== undefined) {
const failure = classifyModelFailure(this.host, outcome.pendingProviderError);
throw new TaskFormationExecutorError({
code: 'PROVIDER_FAILURE',
message: providerFailureSentence(failure),
retryable: failure.retryable,
failureKind: 'model',
...(failure.retryable && { fallbackReason: 'retryable_model_failure' }),
usage: outcome.usage,
modelCalls,
});
}
// A prompt error is a real failure unless exactly one submission was already accepted and the
// error is an abort: the submit tool terminates the session, so that abort is the expected end of
// a successful run, not a fault.
if (
outcome.promptError !== undefined &&
!(request.submitTool.getAcceptedCount() === 1 && isAbortLike(outcome.promptError))
) {
const failure = classifyModelFailure(this.host, outcome.promptError);
throw new TaskFormationExecutorError({
code: 'MODEL_SESSION_FAILURE',
message: providerFailureSentence(failure),
retryable: failure.retryable,
failureKind: 'model',
...(failure.retryable && { fallbackReason: 'retryable_model_failure' }),
usage: outcome.usage,
modelCalls,
});
}
const output = request.submitTool.getCaptured();
if (request.submitTool.getAcceptedCount() !== 1 || output === undefined) {
throw new TaskFormationExecutorError({
code: 'MISSING_ACCEPTED_SUBMISSION',
message: 'Task formation ended without one accepted submission.',
retryable: true,
failureKind: 'model',
fallbackReason: 'missing_accepted_submission',
usage: outcome.usage,
modelCalls,
});
}
return output;
}
}
export function createTaskFormationExecutor(host: ModelHost = modelHost): TaskFormationExecutor {
return new StandaloneTaskFormationExecutor(host);
}
export const taskFormationExecutor: TaskFormationExecutor = createTaskFormationExecutor();