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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 39 additions & 19 deletions src/tools/subagent/SubagentExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ import { stripRunStepResumeState } from '@/tools/runStepResume';
import { seedAgentInitialSessions } from '@/utils/toolSessions';
import { stableStringify } from '@/tools/eagerEventExecution';
import { convertInjectedMessages } from '@/messages/injected';
import { logSubagentResolutionFailure } from './diagnostics';
import { resolveClientOptionsModel } from '@/llm/request';
import { isBackgroundDenyMode } from '@/types/hitl';
import { composeAbortSignals } from '@/utils/misc';
Expand Down Expand Up @@ -1358,10 +1359,7 @@ export class SubagentExecutor {
};
let deliveryAttempts = 1;
let result = await executeAttempt();
while (
result.retryableDelivery === true &&
deliveryAttempts < 3
) {
while (result.retryableDelivery === true && deliveryAttempts < 3) {
if (deliveryAttempts > 1) {
await sleep(
Math.min(100 * 2 ** Math.min(deliveryAttempts - 2, 6), 5_000)
Expand Down Expand Up @@ -2245,7 +2243,10 @@ export class SubagentExecutor {
parentToolCallId,
parentConfigurable,
});
if (execution.completedResult != null && settled.output.status === 'error') {
if (
execution.completedResult != null &&
settled.output.status === 'error'
) {
return;
}
const { resumeExecution } = execution;
Expand Down Expand Up @@ -2492,6 +2493,12 @@ export class SubagentExecutor {
if (error instanceof StreamLimitExceededError) {
throw error;
}
logSubagentResolutionFailure(
'identity',
executableConfig.type,
childSignal,
error
);
return createSubagentFailure(SUBAGENT_RESOLUTION_ERROR_MESSAGE);
}
const { childRunId, childThreadId, approvalExecutionScope } = identity;
Expand Down Expand Up @@ -2522,6 +2529,12 @@ export class SubagentExecutor {
if (error instanceof StreamLimitExceededError) {
throw error;
}
logSubagentResolutionFailure(
'config',
executableConfig.type,
childSignal,
error
);
return createSubagentFailure(SUBAGENT_RESOLUTION_ERROR_MESSAGE);
}

Expand Down Expand Up @@ -2609,9 +2622,7 @@ export class SubagentExecutor {
}
} catch (error) {
if (childSignal.aborted) {
throw childSignal.reason instanceof Error
? childSignal.reason
: error;
throw childSignal.reason instanceof Error ? childSignal.reason : error;
}
if (execution.completedResult != null) {
return {
Expand Down Expand Up @@ -2958,10 +2969,11 @@ export class SubagentExecutor {
},
sessionId: currentHookSessionId,
matchQuery: subagentType,
}).catch((): AggregatedHookResult =>
isBackgroundDenyMode(this.humanInTheLoop)
? { ...HOOK_FALLBACK, hasHookFailures: true }
: HOOK_FALLBACK
}).catch(
(): AggregatedHookResult =>
isBackgroundDenyMode(this.humanInTheLoop)
? { ...HOOK_FALLBACK, hasHookFailures: true }
: HOOK_FALLBACK
);

const policyFailed =
Expand Down Expand Up @@ -3823,20 +3835,28 @@ export function sanitizeForwardedSubagentUpdateData(
if (eventName === GraphEvents.ON_TOOL_CALLS_DISPATCHED) {
if (!isObjectLike(data)) return undefined;
const event = data as { dispatched_at?: number; toolCalls?: unknown };
if (!Array.isArray(event.toolCalls) || typeof event.dispatched_at !== 'number') {
if (
!Array.isArray(event.toolCalls) ||
typeof event.dispatched_at !== 'number'
) {
return undefined;
}
return {
dispatched_at: event.dispatched_at,
toolCalls: (event.toolCalls as unknown[]).flatMap((call) => {
if (!isObjectLike(call)) return [];
const entry = call as Record<string, unknown>;
if (typeof entry.id !== 'string' || typeof entry.name !== 'string') return [];
return [{
id: entry.id,
name: entry.name,
...(typeof entry.stepId === 'string' ? { stepId: entry.stepId } : {}),
}];
if (typeof entry.id !== 'string' || typeof entry.name !== 'string')
return [];
return [
{
id: entry.id,
name: entry.name,
...(typeof entry.stepId === 'string'
? { stepId: entry.stepId }
: {}),
},
];
}),
};
}
Expand Down
40 changes: 40 additions & 0 deletions src/tools/subagent/__tests__/SubagentExecutor.lazy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ describe('SubagentExecutor lazy selected-subagent resolution', () => {
const healthyResolver = jest.fn(async () => makeAgent('healthy-child'));
const healthyConfig = makeLazyConfig('healthy', healthyResolver);
const executor = createExecutor([failingConfig, healthyConfig]);
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});

const failed = await executor.execute({
description: 'Fail this selected child.',
Expand All @@ -224,6 +225,45 @@ describe('SubagentExecutor lazy selected-subagent resolution', () => {
expect(failed.content).not.toContain('private-selected-resolver-secret');
expect(succeeded.content).toBe('Task completed');
expect(healthyResolver).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(
'[SubagentExecutor] Subagent resolution failed',
{
phase: 'config',
subagentType: 'failing',
aborted: false,
type: 'Error',
}
);
expect(JSON.stringify(warn.mock.calls)).not.toContain('oauth-token');
warn.mockRestore();
});

it('labels a start-up failure caused by an aborted child signal', async () => {
const controller = new AbortController();
const config = makeLazyConfig('aborted', async () => {
controller.abort();
throw new Error('resolver saw the abort');
});
const executor = createExecutor([config]);
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});

const failed = await executor.execute({
description: 'Abort while resolving.',
subagentType: config.type,
parentToolCallId: 'call_aborted',
signal: controller.signal,
});

expect(failed.content).toBe(
'Subagent error: Unable to initialize the selected subagent.'
);
expect(warn).toHaveBeenCalledWith(
'[SubagentExecutor] Subagent resolution failed',
expect.objectContaining({ subagentType: 'aborted', aborted: true })
);
expect(JSON.stringify(warn.mock.calls)).not.toContain('resolver saw');
warn.mockRestore();
});

it('releases lazy resolution when SubagentStart denies', async () => {
Expand Down
63 changes: 63 additions & 0 deletions src/tools/subagent/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {
SubagentSettlementBindingError,
SubagentDefinitionBindingError,
SubagentInvocationBindingError,
SubagentExecutionInvalidatedError,
} from './SubagentExecutionRegistry';
import { describeCodeApiError } from '@/tools/diagnostics';

/** Which step of child start-up failed: execution identity, then host config resolution. */
export type SubagentResolutionPhase = 'identity' | 'config';

const SUBAGENT_ERROR_TYPES = [
['SubagentExecutionInvalidatedError', SubagentExecutionInvalidatedError],
['SubagentDefinitionBindingError', SubagentDefinitionBindingError],
['SubagentInvocationBindingError', SubagentInvocationBindingError],
['SubagentSettlementBindingError', SubagentSettlementBindingError],
] as const;

type SubagentErrorLabel =
| (typeof SUBAGENT_ERROR_TYPES)[number][0]
| ReturnType<typeof describeCodeApiError>['type'];

/**
* Follows the rule in `@/tools/diagnostics`: every field is a value this
* module owns or a host-registered subagent type, never text from the error.
* The failure the model sees is a fixed sentence, so this line is the only
* operator account of it; the host logs its own error text where it throws.
*/
export type SubagentResolutionDiagnostic = {
phase: SubagentResolutionPhase;
subagentType: string;
aborted: boolean;
type: SubagentErrorLabel;
};

function describeSubagentError(error: unknown): SubagentErrorLabel {
try {
for (const [label, constructor] of SUBAGENT_ERROR_TYPES) {
if (error instanceof constructor) {
return label;
}
}
} catch {
return 'UndescribableError';
}
return describeCodeApiError(error).type;
}

export function logSubagentResolutionFailure(
phase: SubagentResolutionPhase,
subagentType: string,
signal: AbortSignal,
error: unknown
): void {
const detail: SubagentResolutionDiagnostic = {
phase,
subagentType,
aborted: signal.aborted,
type: describeSubagentError(error),
};
// eslint-disable-next-line no-console
console.warn('[SubagentExecutor] Subagent resolution failed', detail);
}
Loading