From d082afb4ae770c937d909230b62a1b7eb2a5f928 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 8 Jul 2026 17:20:51 +0800 Subject: [PATCH] fix(agent-core-v2): align agent and swarm tool behavior --- .../src/agent/userTool/userTool.ts | 2 + .../src/agent/userTool/userToolService.ts | 10 + .../tools/agent-background-enabled.md | 2 + .../src/session/agentLifecycle/tools/agent.md | 2 +- .../src/session/agentLifecycle/tools/agent.ts | 24 +- .../src/session/swarm/sessionSwarmService.ts | 4 + .../test/swarm/sessionSwarm.test.ts | 919 +++++++++- packages/agent-core-v2/test/tool/tool.test.ts | 1494 ++++++++++++++++- .../test/userTool/userTool.test.ts | 39 + 9 files changed, 2483 insertions(+), 13 deletions(-) diff --git a/packages/agent-core-v2/src/agent/userTool/userTool.ts b/packages/agent-core-v2/src/agent/userTool/userTool.ts index 86a05c640..82d5d644d 100644 --- a/packages/agent-core-v2/src/agent/userTool/userTool.ts +++ b/packages/agent-core-v2/src/agent/userTool/userTool.ts @@ -9,6 +9,8 @@ export interface UserToolRegistration { export interface IAgentUserToolService { readonly _serviceBrand: undefined; + list(): readonly UserToolRegistration[]; + inheritUserTools(parent: IAgentUserToolService): void; register(input: UserToolRegistration): void; unregister(name: string): void; } diff --git a/packages/agent-core-v2/src/agent/userTool/userToolService.ts b/packages/agent-core-v2/src/agent/userTool/userToolService.ts index a74865ded..6ec2233a7 100644 --- a/packages/agent-core-v2/src/agent/userTool/userToolService.ts +++ b/packages/agent-core-v2/src/agent/userTool/userToolService.ts @@ -53,6 +53,16 @@ export class AgentUserToolService extends Disposable implements IAgentUserToolSe this._register(this.wire.onRestored(() => this.restoreRegisteredTools())); } + list(): readonly UserToolRegistration[] { + return [...this.wire.getModel(UserToolModel).values()]; + } + + inheritUserTools(parent: IAgentUserToolService): void { + for (const registration of parent.list()) { + this.register(registration); + } + } + register(input: UserToolRegistration): void { this.wire.dispatch(registerUserTool(input)); this.applyRegister(input); diff --git a/packages/agent-core-v2/src/session/agentLifecycle/tools/agent-background-enabled.md b/packages/agent-core-v2/src/session/agentLifecycle/tools/agent-background-enabled.md index 126b3389b..27c587da1 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/tools/agent-background-enabled.md +++ b/packages/agent-core-v2/src/session/agentLifecycle/tools/agent-background-enabled.md @@ -1 +1,3 @@ +Default to a foreground subagent unless the task can run independently and there is a clear benefit to not waiting. + When `run_in_background=true`, the subagent runs detached from this turn. The completion arrives in a later turn as a synthetic user-role message containing its result — you do not need to poll, sleep, or check on its progress. Continue with other work or respond to the user. Never fabricate or predict what the result will say. diff --git a/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.md b/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.md index 6ff3f26a4..61775bf90 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.md +++ b/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.md @@ -11,6 +11,6 @@ Usage notes: - A subagent's result is only visible to you, not to the user. When the user needs to see what a subagent produced, summarize the relevant parts yourself in your own reply. - Subagents use a fixed 30-minute timeout. If one times out, resume the same agent instead of starting over. -When NOT to use Agent: skip delegation for trivial work you can do directly — reading a file whose path you already know, searching a small known set of files, or any task that takes only a step or two. Delegation has a context-handoff cost; it pays off only when the task is substantial enough to outweigh it. +When NOT to use Agent: skip delegation for trivial work you can do directly — reading a file whose path you already know, searching a small known set of files, or any task that takes only a step or two. Delegation has a context-handoff cost; it pays off only when the task is substantial enough to keep meaningful work out of your own context. Once a subagent is running, leave that scope to it: do not redo its searches or reads in parallel, and do not abandon it midway and finish the job manually. Both undo the context savings the delegation was meant to buy. diff --git a/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts b/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts index 7ced2d0b3..1f4f17282 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts @@ -25,6 +25,7 @@ import { import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentUserToolService } from '#/agent/userTool/userTool'; import { isAbortError } from '#/agent/loop/errors'; import { ToolAccesses } from '#/agent/tool/tool-access'; import type { @@ -89,7 +90,9 @@ export const AgentToolInputSchema = z.preprocess( resume: z .string() .optional() - .describe('Optional agent ID to resume instead of creating a new instance'), + .describe( + 'Optional agent ID to resume instead of creating a new instance. When set, do not also pass subagent_type; the resumed agent keeps its original type.', + ), run_in_background: z .boolean() .optional() @@ -256,6 +259,9 @@ export class AgentTool implements BuiltinTool { permissionMode: this.permissionMode.mode, labels: subagentLabels(this.callerAgentId), }); + created.accessor + .get(IAgentUserToolService) + .inheritUserTools(requester.accessor.get(IAgentUserToolService)); agentId = created.id; profileName = profile.name; promptText = await applyProfilePromptPrefix(profile, args.prompt, { @@ -326,6 +332,14 @@ export class AgentTool implements BuiltinTool { handle = await this.launch(args, toolCallId, controller); } catch (error) { signal.removeEventListener('abort', abortBeforeRegister); + this.log.warn('subagent launch failed', { + toolCallId, + runInBackground, + operation: isResume ? 'resume' : 'spawn', + subagentType: requestedProfileName ?? DEFAULT_PROFILE_NAME, + resumeAgentId: isResume ? resumeAgentId : undefined, + error, + }); throw error; } @@ -353,8 +367,12 @@ export class AgentTool implements BuiltinTool { subagentType: handle.profileName, error, }); + const message = error instanceof Error ? error.message : String(error); return { - output: error instanceof Error ? error.message : String(error), + output: + message === 'Too many detached tasks are already running.' + ? 'Too many background tasks are already running.' + : message, isError: true, }; } @@ -439,7 +457,7 @@ function formatBackgroundAgentResult( `description: ${description}`, '', allowBackground - ? `next_step: The completion arrives automatically in a later turn — no polling needed. To peek at progress without blocking, call TaskOutput(task_id="${taskId}", block=false).` + ? 'next_step: The completion arrives automatically in a later turn; do NOT wait, poll, or call TaskOutput on it. Continue with other work or respond to the user.' : 'next_step: The completion arrives automatically in a later turn.', `resume_hint: To continue or recover this same subagent later, call Agent(resume="${handle.agentId}", prompt="..."). The parameter is agent_id ("${handle.agentId}"), NOT task_id ("${taskId}") or source_id from a later . Recovery cases: a later for this subagent — its conversation history is preserved across session restarts and resume will pick it up.`, ].join('\n'); diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts index 2e73f071c..f6914f9f0 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts @@ -21,6 +21,7 @@ import { linkAbortSignal } from '#/_base/utils/abort'; import type { IAgentScopeHandle } from '#/_base/di/scope'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; +import { IAgentUserToolService } from '#/agent/userTool/userTool'; import type { SubagentSuspendedEvent } from '@moonshot-ai/protocol'; import { IEventBus } from '#/app/event/eventBus'; import { IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog'; @@ -151,6 +152,9 @@ export class SessionSwarmService implements ISessionSwarmService { permissionMode: caller.accessor.get(IAgentPermissionModeService).mode, labels: subagentLabels(callerAgentId, { swarmItem: options.swarmItem }), }); + child.accessor + .get(IAgentUserToolService) + .inheritUserTools(caller.accessor.get(IAgentUserToolService)); emitAgentRunSpawned(caller, child.id, { profileName: options.profileName, parentToolCallId: options.parentToolCallId, diff --git a/packages/agent-core-v2/test/swarm/sessionSwarm.test.ts b/packages/agent-core-v2/test/swarm/sessionSwarm.test.ts index ba7a2cd88..d0f9e77e4 100644 --- a/packages/agent-core-v2/test/swarm/sessionSwarm.test.ts +++ b/packages/agent-core-v2/test/swarm/sessionSwarm.test.ts @@ -1,3 +1,4 @@ +import { createControlledPromise } from '@antfu/utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { IAgentScopeHandle } from '#/_base/di/scope'; @@ -6,10 +7,13 @@ import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; import { Event } from '#/_base/event'; +import { userCancellationReason } from '#/_base/utils/abort'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { IAgentProfileService, type ProfileData } from '#/agent/profile/profile'; +import { IAgentUserToolService } from '#/agent/userTool/userTool'; import { IEventBus, type DomainEvent } from '#/app/event/eventBus'; import { IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { APIProviderRateLimitError } from '#/app/llmProtocol/errors'; import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry'; import { IAgentLifecycleService, @@ -29,7 +33,11 @@ import { ILogService } from '#/_base/log/log'; import { AgentRunBatch, resolveSwarmMaxConcurrency, + type AgentRunAttemptHandle, + type AgentRunAttemptOptions, type AgentRunBatchLauncher, + type AgentRunResult, + type AgentRunSuspendedEvent, type AgentSpawnAttemptOptions, type QueuedAgentRunTask, } from '#/session/swarm/agentRunBatch'; @@ -66,6 +74,702 @@ describe('resolveSwarmMaxConcurrency', () => { }); }); +describe('AgentRunBatch scheduling contract', () => { + it('normal phase starts five tasks immediately, then one task every 700ms', async () => { + vi.useFakeTimers(); + try { + const { runBatch, attempts } = createMockAgentRunBatchRunner(); + const running = runBatch( + Array.from({ length: 9 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: new AbortController().signal }, + ); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(699); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(1); + expect(attempts).toHaveLength(6); + + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(7); + + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(8); + + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(9); + + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(9); + + attempts.forEach((attempt, index) => { + attempt.outcome.resolve({ + task: attempt.task, + agentId: `agent-${String(index + 1)}`, + status: 'completed', + result: `result ${String(index + 1)}`, + }); + }); + const results = await running; + + expect(results).toHaveLength(9); + expect(results.every((result) => result.status === 'completed')).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('user cancellation returns completed, started, and not-started task results', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const { runBatch, attempts } = createMockAgentRunBatchRunner(); + const running = runBatch( + Array.from({ length: 6 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: controller.signal }, + ); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + + attempts[0]!.outcome.resolve({ + task: attempts[0]!.task, + agentId: 'agent-1', + status: 'completed', + result: 'completed 1', + }); + await vi.advanceTimersByTimeAsync(0); + + controller.abort(userCancellationReason()); + const results = await running; + + expect( + results.map((result) => ({ + data: result.task.data, + agentId: result.agentId, + status: result.status, + state: result.state, + result: result.result, + error: result.error, + })), + ).toEqual([ + { + data: 1, + agentId: 'agent-1', + status: 'completed', + state: undefined, + result: 'completed 1', + error: undefined, + }, + { + data: 2, + agentId: 'agent-2', + status: 'aborted', + state: 'started', + result: undefined, + error: 'The user manually interrupted this subagent batch before this subagent finished.', + }, + { + data: 3, + agentId: 'agent-3', + status: 'aborted', + state: 'started', + result: undefined, + error: 'The user manually interrupted this subagent batch before this subagent finished.', + }, + { + data: 4, + agentId: 'agent-4', + status: 'aborted', + state: 'started', + result: undefined, + error: 'The user manually interrupted this subagent batch before this subagent finished.', + }, + { + data: 5, + agentId: 'agent-5', + status: 'aborted', + state: 'started', + result: undefined, + error: 'The user manually interrupted this subagent batch before this subagent finished.', + }, + { + data: 6, + agentId: undefined, + status: 'aborted', + state: 'not_started', + result: undefined, + error: + 'The user manually interrupted this subagent batch before this subagent was started.', + }, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it('normal phase keeps processing completions while waiting for the next launch', async () => { + vi.useFakeTimers(); + try { + const { runBatch, attempts } = createMockAgentRunBatchRunner(); + const running = runBatch( + Array.from({ length: 6 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: new AbortController().signal }, + ); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + attempts[0]!.outcome.resolve({ + task: attempts[0]!.task, + agentId: 'agent-1', + status: 'completed', + result: 'completed 1', + }); + + await vi.advanceTimersByTimeAsync(699); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(1); + expect(attempts).toHaveLength(6); + + attempts.slice(1).forEach((attempt, index) => { + attempt.outcome.resolve({ + task: attempt.task, + agentId: `agent-${String(index + 2)}`, + status: 'completed', + result: `completed ${String(index + 2)}`, + }); + }); + await expect(running).resolves.toHaveLength(6); + } finally { + vi.useRealTimers(); + } + }); + + it('rate-limit phase starts when the first provider rate limit stops the normal ramp', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const { runBatch, attempts } = createMockAgentRunBatchRunner(); + const running = runBatch( + Array.from({ length: 9 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: controller.signal }, + ); + void running.catch(() => {}); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + attempts.forEach((attempt) => { + attempt.markReady(); + }); + + attempts[0]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-1' }); + await vi.advanceTimersByTimeAsync(0); + + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(5); + + attempts[1]!.outcome.resolve({ + task: attempts[1]!.task, + agentId: 'agent-2', + status: 'completed', + result: 'completed 2', + }); + await vi.advanceTimersByTimeAsync(3000); + expect(attempts).toHaveLength(6); + expect(attempts[5]!.task.data).toBe(1); + expect(attempts[5]!.retryAgentId).toBe('agent-1'); + + controller.abort(); + await expect(running).rejects.toThrow(); + } finally { + vi.useRealTimers(); + } + }); + + it('rate-limit phase requeues 429 tasks, emits suspended, and throttles launches', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const onSuspended = vi.fn(); + const { runBatch, attempts } = createMockAgentRunBatchRunner({ onSuspended }); + const running = runBatch( + Array.from({ length: 8 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: controller.signal }, + ); + void running.catch(() => {}); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + + attempts.forEach((attempt) => { + attempt.markReady(); + }); + attempts[0]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-1' }); + attempts[1]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-2' }); + await vi.advanceTimersByTimeAsync(0); + expect(onSuspended).toHaveBeenCalledTimes(2); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(500); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(2500); + expect(attempts).toHaveLength(6); + expect(attempts[5]!.task.data).toBe(2); + expect(attempts[5]!.retryAgentId).toBe('agent-2'); + + controller.abort(); + await expect(running).rejects.toThrow(); + } finally { + vi.useRealTimers(); + } + }); + + it('fails the only unfinished task on provider rate limit instead of suspending forever', async () => { + vi.useFakeTimers(); + try { + const onSuspended = vi.fn(); + const { runBatch, attempts } = createMockAgentRunBatchRunner({ onSuspended }); + const running = runBatch( + Array.from({ length: 2 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: new AbortController().signal }, + ); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(2); + attempts.forEach((attempt) => { + attempt.markReady(); + }); + + attempts[0]!.outcome.resolve({ + task: attempts[0]!.task, + agentId: 'agent-1', + status: 'completed', + result: 'completed 1', + }); + await vi.advanceTimersByTimeAsync(0); + + attempts[1]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-2' }); + await expect(running).resolves.toMatchObject([ + { + task: { data: 1 }, + agentId: 'agent-1', + status: 'completed', + result: 'completed 1', + }, + { + task: { data: 2 }, + agentId: 'agent-2', + status: 'failed', + state: 'started', + error: 'Rate limited', + }, + ]); + expect(onSuspended).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('rate-limit capacity blocks launches while active attempts fill all slots', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const { runBatch, attempts } = createMockAgentRunBatchRunner(); + const running = runBatch( + Array.from({ length: 12 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: controller.signal }, + ); + void running.catch(() => {}); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + attempts.slice(0, 5).forEach((attempt) => { + attempt.markReady(); + }); + + for (let count = 6; count <= 12; count += 1) { + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(count); + attempts[count - 1]!.markReady(); + } + + attempts.slice(0, 12).forEach((attempt) => { + attempt.markReady(); + }); + + attempts[0]!.outcome.resolve({ + type: 'rate_limited', + agentId: 'agent-1', + }); + await vi.advanceTimersByTimeAsync(0); + + await vi.advanceTimersByTimeAsync(3000); + expect(attempts).toHaveLength(12); + + controller.abort(); + await expect(running).rejects.toThrow(); + } finally { + vi.useRealTimers(); + } + }); + + it('rate-limit recovery adds one capacity slot after three quiet minutes with queued work', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const { runBatch, attempts } = createMockAgentRunBatchRunner(); + const running = runBatch( + Array.from({ length: 6 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: controller.signal }, + ); + void running.catch(() => {}); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + attempts.forEach((attempt) => { + attempt.markReady(); + }); + + attempts[0]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-1' }); + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(2000); + attempts[1]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-2' }); + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(2000); + attempts[2]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-3' }); + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(2000); + attempts[3]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-4' }); + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(179_999); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(1); + expect(attempts).toHaveLength(6); + expect(attempts[5]!.task.data).toBe(4); + expect(attempts[5]!.retryAgentId).toBe('agent-4'); + + controller.abort(); + await expect(running).rejects.toThrow(); + } finally { + vi.useRealTimers(); + } + }); + + it('rate-limit phase keeps launches bounded after repeated 429s', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const { runBatch, attempts } = createMockAgentRunBatchRunner(); + const running = runBatch( + Array.from({ length: 8 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: controller.signal }, + ); + void running.catch(() => {}); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + attempts.forEach((attempt) => { + attempt.markReady(); + }); + + for (let index = 0; index < 3; index += 1) { + attempts[index]!.outcome.resolve({ + type: 'rate_limited', + agentId: `agent-${String(index + 1)}`, + }); + await vi.advanceTimersByTimeAsync(0); + } + + await vi.advanceTimersByTimeAsync(3000); + expect(attempts).toHaveLength(6); + expect(attempts[5]!.task.data).toBe(3); + expect(attempts[5]!.retryAgentId).toBe('agent-3'); + + await vi.advanceTimersByTimeAsync(3000); + expect(attempts).toHaveLength(7); + expect(attempts[6]!.task.data).toBe(2); + expect(attempts[6]!.retryAgentId).toBe('agent-2'); + + controller.abort(); + await expect(running).rejects.toThrow(); + } finally { + vi.useRealTimers(); + } + }); + + it('rate-limit phase schedules another launch after starting while capacity remains', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const { runBatch, attempts } = createMockAgentRunBatchRunner(); + const running = runBatch( + Array.from({ length: 8 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: controller.signal }, + ); + void running.catch(() => {}); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + attempts.forEach((attempt) => { + attempt.markReady(); + }); + + attempts[0]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-1' }); + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + + attempts[1]!.outcome.resolve({ + task: attempts[1]!.task, + agentId: 'agent-2', + status: 'completed', + result: 'completed 2', + }); + attempts[2]!.outcome.resolve({ + task: attempts[2]!.task, + agentId: 'agent-3', + status: 'completed', + result: 'completed 3', + }); + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(2_999); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(1); + expect(attempts).toHaveLength(6); + expect(attempts[5]!.task.data).toBe(1); + expect(attempts[5]!.retryAgentId).toBe('agent-1'); + + await vi.advanceTimersByTimeAsync(2_999); + expect(attempts).toHaveLength(6); + + await vi.advanceTimersByTimeAsync(1); + expect(attempts).toHaveLength(7); + expect(attempts[6]!.task.data).toBe(6); + expect(attempts[6]!.retryAgentId).toBeUndefined(); + + controller.abort(); + await expect(running).rejects.toThrow(); + } finally { + vi.useRealTimers(); + } + }); + + it('task timeout fails only that task', async () => { + vi.useFakeTimers(); + try { + const { runBatch, attempts } = createMockAgentRunBatchRunner(); + const running = runBatch([{ ...queuedAgentRunTask(1), timeout: 10_000 }], { + signal: new AbortController().signal, + }); + + await vi.advanceTimersByTimeAsync(0); + attempts[0]!.markReady(); + + await vi.advanceTimersByTimeAsync(9999); + expect(attempts).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(1); + await expect(running).resolves.toMatchObject([ + { + task: { data: 1 }, + agentId: 'agent-1', + status: 'failed', + state: 'started', + error: 'Subagent timed out.', + }, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it('does not spend task timeout while the task is queued', async () => { + vi.useFakeTimers(); + try { + let settled = false; + const { runBatch, attempts } = createMockAgentRunBatchRunner(); + const running = runBatch( + [ + ...Array.from({ length: 5 }, (_, index) => queuedAgentRunTask(index + 1)), + { ...queuedAgentRunTask(6), timeout: 1000 }, + ], + { signal: new AbortController().signal }, + ); + void running.finally(() => { + settled = true; + }); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(699); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(1); + expect(attempts).toHaveLength(6); + + await vi.advanceTimersByTimeAsync(999); + expect(settled).toBe(false); + + attempts.slice(0, 5).forEach((attempt, index) => { + attempt.outcome.resolve({ + task: attempt.task, + agentId: `agent-${String(index + 1)}`, + status: 'completed', + result: `completed ${String(index + 1)}`, + }); + }); + await vi.advanceTimersByTimeAsync(1); + + await expect(running).resolves.toMatchObject([ + { task: { data: 1 }, status: 'completed' }, + { task: { data: 2 }, status: 'completed' }, + { task: { data: 3 }, status: 'completed' }, + { task: { data: 4 }, status: 'completed' }, + { task: { data: 5 }, status: 'completed' }, + { + task: { data: 6 }, + agentId: 'agent-6', + status: 'failed', + state: 'started', + error: 'Subagent timed out.', + }, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it('rate-limit phase continues launching after rate-limited attempts settle', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const { runBatch, attempts } = createMockAgentRunBatchRunner({ + readyDelay: (attemptIndex) => (attemptIndex >= 7 ? 100 : undefined), + }); + + const running = runBatch( + Array.from({ length: 9 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: controller.signal }, + ); + void running.catch(() => {}); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + attempts.slice(0, 5).forEach((attempt) => { + attempt.markReady(); + }); + + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(6); + + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(7); + + attempts[5]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-6' }); + attempts[6]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-7' }); + attempts[0]!.outcome.resolve({ + task: attempts[0]!.task, + agentId: 'agent-1', + status: 'completed', + result: 'completed 1', + }); + attempts[1]!.outcome.resolve({ + task: attempts[1]!.task, + agentId: 'agent-2', + status: 'completed', + result: 'completed 2', + }); + await vi.advanceTimersByTimeAsync(12_000); + expect(attempts).toHaveLength(8); + expect(attempts[7]!.task.data).toBe(7); + expect(attempts[7]!.retryAgentId).toBe('agent-7'); + + controller.abort(); + await expect(running).rejects.toThrow(); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('AgentRunBatch max concurrency cap', () => { + it('caps in-flight tasks at maxConcurrency during the normal phase', async () => { + vi.useFakeTimers(); + try { + const { runBatch, attempts } = createMockAgentRunBatchRunner({ maxConcurrency: 3 }); + const running = runBatch( + Array.from({ length: 9 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: new AbortController().signal }, + ); + const resolved = new Set(); + const resolveOne = (index: number) => { + const attempt = attempts[index]!; + resolved.add(index); + attempt.outcome.resolve({ + task: attempt.task, + agentId: `agent-${String(index + 1)}`, + status: 'completed', + result: `result ${String(index + 1)}`, + }); + }; + const inFlight = () => attempts.length - resolved.size; + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(3); + expect(inFlight()).toBe(3); + + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(3); + + resolveOne(0); + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(4); + expect(inFlight()).toBeLessThanOrEqual(3); + + resolveOne(1); + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + expect(inFlight()).toBeLessThanOrEqual(3); + + resolveOne(2); + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(6); + expect(inFlight()).toBeLessThanOrEqual(3); + + for (let index = 3; index < 9; index += 1) { + resolveOne(index); + await vi.advanceTimersByTimeAsync(700); + expect(inFlight()).toBeLessThanOrEqual(3); + } + + const results = await running; + expect(results).toHaveLength(9); + expect(results.every((result) => result.status === 'completed')).toBe(true); + } finally { + vi.useRealTimers(); + } + }); +}); + describe('AgentRunBatch swarm item forwarding', () => { function recordingLauncher() { const spawned: AgentSpawnAttemptOptions[] = []; @@ -133,13 +837,14 @@ describe('SessionSwarmService metadata compatibility', () => { let lifecycle: IAgentLifecycleService; let createAgent: ReturnType; let runAgent: ReturnType; + let eventBus: IEventBus; beforeEach(() => { disposables = new DisposableStore(); ix = disposables.add(new TestInstantiationService()); agents = {}; handles = new Map(); - const eventBus = eventBusStub(); + eventBus = eventBusStub(); lifecycle = lifecycleStub(handles, eventBus); createAgent = lifecycle.create as ReturnType; runAgent = lifecycle.run as ReturnType; @@ -298,6 +1003,42 @@ describe('SessionSwarmService metadata compatibility', () => { ); }); + it('inherits parent user tools on spawned children', async () => { + const parentUserTools = userToolServiceStub(); + const childUserTools = userToolServiceStub(); + handles.set( + 'main', + agentHandle('main', lifecycle, eventBus, {}, new Map([ + [IAgentUserToolService, parentUserTools], + ])), + ); + createAgent.mockImplementationOnce(async (opts: CreateAgentOptions = {}) => { + const id = opts.agentId ?? 'agent-new'; + const handle = agentHandle( + id, + lifecycle, + eventBus, + { + profileName: opts.binding?.profile ?? 'coder', + modelAlias: opts.binding?.model ?? 'kimi-test', + thinkingLevel: opts.binding?.thinking ?? 'medium', + cwd: opts.binding?.cwd ?? '/repo', + }, + new Map([[IAgentUserToolService, childUserTools]]), + ); + handles.set(id, handle); + return handle; + }); + const service = ix.get(ISessionSwarmService); + + await service.run({ + callerAgentId: 'main', + tasks: [spawnSessionTask('src/a.ts')], + }); + + expect(childUserTools.inheritUserTools).toHaveBeenCalledWith(parentUserTools); + }); + it('keeps v1 resume ownership errors inside the per-subagent result', async () => { agents['other-child'] = { homedir: '/tmp/kimi/s1/agents/other-child', @@ -397,6 +1138,7 @@ function agentHandle( lifecycle: IAgentLifecycleService, eventBus: IEventBus, data: Partial = {}, + services: ReadonlyMap = new Map(), ): IAgentScopeHandle { const profile = profileService({ cwd: '/repo', @@ -418,8 +1160,11 @@ function agentHandle( kind: LifecycleScope.Agent, accessor: { get: ((serviceId: unknown) => { + const service = services.get(serviceId); + if (service !== undefined) return service; if (serviceId === IAgentProfileService) return profile; if (serviceId === IAgentPermissionModeService) return permissionMode; + if (serviceId === IAgentUserToolService) return userToolServiceStub(); if (serviceId === IEventBus) return eventBus; if (serviceId === ITelemetryService) return noopTelemetryService; if (serviceId === IAgentLifecycleService) return lifecycle; @@ -437,6 +1182,16 @@ function profileService(data: ProfileData): IAgentProfileService { } as IAgentProfileService; } +function userToolServiceStub(): IAgentUserToolService { + return { + _serviceBrand: undefined, + list: () => [], + inheritUserTools: vi.fn<(parent: IAgentUserToolService) => void>(), + register: () => {}, + unregister: () => {}, + }; +} + function eventBusStub(): IEventBus { return { _serviceBrand: undefined, @@ -444,3 +1199,165 @@ function eventBusStub(): IEventBus { subscribe: vi.fn(() => ({ dispose: () => {} })) as IEventBus['subscribe'], }; } + +type MockAgentRunAttemptOutcome = + | AgentRunResult + | { + readonly type: 'rate_limited'; + readonly agentId: string; + }; + +type MockAgentRunAttemptRecord = { + readonly task: QueuedAgentRunTask; + readonly retryAgentId?: string; + readonly markReady: () => void; + readonly outcome: ReturnType>>; +}; + +type MockAgentRunBatchRunnerOptions = { + readonly onSuspended?: (event: AgentRunSuspendedEvent) => void; + readonly readyDelay?: (attemptIndex: number) => number | undefined; + readonly maxConcurrency?: number; +}; + +function createMockAgentRunBatchRunner( + options: MockAgentRunBatchRunnerOptions = {}, +): { + readonly runBatch: ( + tasks: readonly QueuedAgentRunTask[], + options?: { readonly signal?: AbortSignal }, + ) => Promise>>; + readonly attempts: MockAgentRunAttemptRecord[]; +} { + const attempts: MockAgentRunAttemptRecord[] = []; + let activeTasks: readonly QueuedAgentRunTask[] = []; + + const createHandle = ( + runOptions: AgentRunAttemptOptions, + agentId: string, + profileName: string, + retryAgentId?: string, + ): AgentRunAttemptHandle => { + const task = findMockAgentRunTask(activeTasks, runOptions); + const outcome = createControlledPromise>(); + const markReady = () => { + runOptions.onReady?.(); + }; + const attemptIndex = attempts.length; + attempts.push({ + task: task as unknown as QueuedAgentRunTask, + retryAgentId, + markReady, + outcome: outcome as unknown as MockAgentRunAttemptRecord['outcome'], + }); + + const delay = options.readyDelay?.(attemptIndex); + if (delay !== undefined) setTimeout(markReady, delay); + + return { + agentId, + profileName, + completion: completionFromMockAgentRunOutcome(outcome, runOptions.signal), + }; + }; + + const launcher: AgentRunBatchLauncher = { + spawn: async (spawnOptions) => { + const task = findMockAgentRunTask(activeTasks, spawnOptions); + return createHandle( + spawnOptions, + mockAgentRunId(task, attempts.length), + spawnOptions.profileName, + ); + }, + resume: async (agentId, runOptions) => createHandle(runOptions, agentId, 'subagent'), + retry: async (agentId, runOptions) => createHandle(runOptions, agentId, 'subagent', agentId), + suspended: (event) => { + options.onSuspended?.(event); + }, + }; + + return { + runBatch: ( + tasks: readonly QueuedAgentRunTask[], + runOptions?: { readonly signal?: AbortSignal }, + ) => { + activeTasks = tasks.map((task) => ({ + ...task, + signal: task.signal ?? runOptions?.signal, + })); + return new AgentRunBatch(launcher, activeTasks as readonly QueuedAgentRunTask[], { + maxConcurrency: options.maxConcurrency, + }).run(); + }, + attempts, + }; +} + +function findMockAgentRunTask( + tasks: readonly QueuedAgentRunTask[], + options: AgentRunAttemptOptions, +): QueuedAgentRunTask { + const task = tasks.find( + (candidate) => + candidate.prompt === options.prompt && + candidate.parentToolCallId === options.parentToolCallId, + ); + if (task === undefined) { + throw new Error(`No mock queued task for prompt "${options.prompt}"`); + } + return task as QueuedAgentRunTask; +} + +function mockAgentRunId(task: QueuedAgentRunTask, attemptIndex: number): string { + if (typeof task.data === 'number') return `agent-${String(task.data)}`; + return `agent-${String(attemptIndex + 1)}`; +} + +function completionFromMockAgentRunOutcome( + outcome: ReturnType>>, + signal: AbortSignal, +): AgentRunAttemptHandle['completion'] { + return new Promise((resolve, reject) => { + const abort = () => { + reject(signal.reason ?? new Error('Aborted')); + }; + signal.addEventListener('abort', abort, { once: true }); + outcome.then( + (result) => { + signal.removeEventListener('abort', abort); + if (isMockAgentRunRateLimitOutcome(result)) { + reject(new APIProviderRateLimitError('Rate limited', result.agentId)); + return; + } + if (result.status === 'completed') { + resolve({ result: result.result ?? '', usage: result.usage }); + return; + } + reject(new Error(result.error ?? result.status)); + }, + (error: unknown) => { + signal.removeEventListener('abort', abort); + reject(error); + }, + ); + }); +} + +function isMockAgentRunRateLimitOutcome( + outcome: MockAgentRunAttemptOutcome, +): outcome is Extract, { readonly type: 'rate_limited' }> { + return 'type' in outcome && outcome.type === 'rate_limited'; +} + +function queuedAgentRunTask(index: number): QueuedAgentRunTask { + return { + kind: 'spawn', + data: index, + profileName: 'coder', + parentToolCallId: 'call_swarm', + prompt: `Review item-${String(index)}`, + description: `Review #${String(index)}`, + runInBackground: false, + }; +} diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 35b9830b8..5e555053f 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -1,25 +1,1472 @@ import { Readable, type Writable } from 'node:stream'; +import type { IAgentScopeHandle } from '#/_base/di/scope'; +import { Event, type Event as KimiEvent } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import { toInputJsonSchema } from '#/_base/tools/support/input-schema'; +import { userCancellationReason } from '#/_base/utils/abort'; import type { ToolCall } from '#/app/llmProtocol/message'; +import type { TokenUsage } from '#/app/llmProtocol/usage'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { IAgentTaskService } from '#/agent/task/task'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { makeHookRunner } from '../externalHooks/runner-stub'; import { IAgentProfileService } from '#/agent/profile/profile'; +import { ToolAccesses } from '#/agent/tool/tool-access'; +import type { ExecutableTool } from '#/agent/tool/toolContract'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; +import { IAgentUserToolService, type UserToolRegistration } from '#/agent/userTool/userTool'; +import { + AgentSwarmToolInputSchema, + type AgentSwarmToolInput, +} from '#/agent/swarm/tools/agent-swarm'; +import { + AgentToolInputSchema, + type AgentToolInput, +} from '#/session/agentLifecycle/tools/agent'; +import { + IAgentLifecycleService, + type AgentRunHandle, + type AgentRunRequest, + type RunAgentOptions, +} from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionCronService } from '#/session/cron/sessionCronService'; +import type { + ISessionSwarmService, + SessionSwarmRunArgs, + SessionSwarmRunResult, +} from '#/session/swarm/sessionSwarm'; import type { IProcess, ISessionProcessRunner } from '#/session/process/processRunner'; +import { IAgentWireService } from '#/wire/tokens'; import { createFakeProcessRunner } from '../tools/fixtures/fake-exec'; import { + configServices, createCommandRunner, createTestAgent, execEnvServices, externalHookServices, + sessionService, + swarmServices, type TestAgentContext, + type TestAgentOptions, + type TestAgentServiceOverride, } from '../harness'; import { executeTool } from '../tools/fixtures/execute-tool'; const signal = new AbortController().signal; +function agentSchemaProperties(): Record { + return ( + toInputJsonSchema(AgentToolInputSchema) as { properties: Record } + ).properties; +} + +function agentSwarmSchemaProperties(): Record { + return ( + toInputJsonSchema(AgentSwarmToolInputSchema) as { properties: Record } + ).properties; +} + +function deferred(): { + readonly promise: Promise; + resolve(value: T): void; + reject(reason?: unknown): void; +} { + let resolve: (value: T) => void = () => {}; + let reject: (reason?: unknown) => void = () => {}; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +interface CapturedLogEntry { + readonly level: 'error' | 'warn' | 'info' | 'debug'; + readonly message: string; + readonly payload: unknown; +} + +function captureLogs(): { + readonly entries: CapturedLogEntry[]; + readonly logger: ILogService; +} { + const entries: CapturedLogEntry[] = []; + const capture = + (level: CapturedLogEntry['level']) => (message: string, payload?: unknown) => { + entries.push({ level, message, payload }); + }; + let logger: ILogService; + logger = { + _serviceBrand: undefined, + level: 'off', + setLevel: () => {}, + flush: async () => {}, + info: capture('info'), + warn: capture('warn'), + error: capture('error'), + debug: capture('debug'), + child: () => logger, + }; + return { entries, logger }; +} + +function hookSlot() { + return { + run: vi.fn(async (_input: T) => {}), + register: () => ({ dispose: () => {} }), + delete: () => false, + }; +} + +interface AgentLifecycleStubOptions { + readonly createAgentIds?: readonly string[]; + readonly runCompletion?: ( + agentId: string, + request: AgentRunRequest, + options: RunAgentOptions, + ) => Promise<{ readonly summary: string; readonly usage?: TokenUsage }>; + readonly createError?: Error; + readonly handleServices?: ReadonlyMap>; +} + +interface AgentLifecycleStub extends IAgentLifecycleService { + readonly create: ReturnType>; + readonly run: ReturnType>; + readonly getHandle: ReturnType>; + addHandle( + agentId: string, + profileName: string, + services?: ReadonlyMap, + ): void; +} + +function createAgentLifecycleStub(options: AgentLifecycleStubOptions = {}): AgentLifecycleStub { + let lifecycle: AgentLifecycleStub; + let created = 0; + const profileByAgentId = new Map(); + const handles = new Map(); + const servicesByAgentId = new Map(options.handleServices); + const handle = (agentId: string): IAgentScopeHandle => ({ + id: agentId, + kind: 2, + accessor: { + get: (serviceId) => { + const service = servicesByAgentId.get(agentId)?.get(serviceId); + if (service !== undefined) return service as never; + if (serviceId === IAgentLifecycleService) return lifecycle as never; + if (serviceId === IAgentContextInjectorService) { + return { + _serviceBrand: undefined, + register: () => ({ dispose: () => {} }), + } as never; + } + if (serviceId === IAgentContextMemoryService) { + return { + _serviceBrand: undefined, + get: () => [], + } as never; + } + if (serviceId === IAgentProfileService) { + return { + _serviceBrand: undefined, + data: () => ({ profileName: profileByAgentId.get(agentId) }), + isToolActive: () => false, + } as never; + } + if (serviceId === IAgentToolRegistryService) { + return { + _serviceBrand: undefined, + register: () => ({ dispose: () => {} }), + } as never; + } + if (serviceId === IAgentUserToolService) { + return { + _serviceBrand: undefined, + list: () => [], + inheritUserTools: () => {}, + register: () => {}, + unregister: () => {}, + } as never; + } + if (serviceId === IAgentWireService) { + return { + _serviceBrand: undefined, + dispatch: () => {}, + getModel: () => [], + onRestored: () => ({ dispose: () => {} }), + } as never; + } + return undefined as never; + }, + }, + dispose: () => {}, + }); + lifecycle = { + _serviceBrand: undefined, + hooks: { + onWillStartAgentTask: hookSlot(), + onDidStopAgentTask: hookSlot(), + }, + onDidCreate: Event.None as KimiEvent, + onDidCreateMain: Event.None as KimiEvent, + onDidDispose: Event.None as KimiEvent, + create: vi.fn(async (input = {}) => { + if (options.createError !== undefined) throw options.createError; + const agentId = + input.agentId ?? + options.createAgentIds?.[created] ?? + `agent-child-${String(created + 1)}`; + created += 1; + const profileName = input.binding?.profile ?? 'coder'; + profileByAgentId.set(agentId, profileName); + const createdHandle = handle(agentId); + handles.set(agentId, createdHandle); + return createdHandle; + }), + ensureMcpReady: vi.fn(async () => {}), + notifyMainCreated: vi.fn(), + fork: vi.fn(async () => { + throw new Error('unexpected fork'); + }), + run: vi.fn(async (agentId, request, runOptions): Promise => { + const completion = + options.runCompletion?.(agentId, request, runOptions) ?? + Promise.resolve({ summary: 'child result' }); + return { + agentId, + turn: {} as AgentRunHandle['turn'], + completion, + }; + }), + getHandle: vi.fn((agentId) => handles.get(agentId)), + list: vi.fn(() => [...handles.values()]), + remove: vi.fn(async (agentId) => { + handles.delete(agentId); + }), + addHandle: (agentId, profileName, services) => { + profileByAgentId.set(agentId, profileName); + if (services !== undefined) servicesByAgentId.set(agentId, services); + handles.set(agentId, handle(agentId)); + }, + }; + return lifecycle; +} + +function agentTool(ctx: TestAgentContext): ExecutableTool { + const tool = ctx.get(IAgentToolRegistryService).resolve('Agent'); + expect(tool).toBeDefined(); + return tool! as ExecutableTool; +} + +function agentSwarmTool(ctx: TestAgentContext): ExecutableTool { + const tool = ctx.get(IAgentToolRegistryService).resolve('AgentSwarm'); + expect(tool).toBeDefined(); + return tool! as ExecutableTool; +} + +function executeAgentTool( + ctx: TestAgentContext, + args: AgentToolInput, + inputSignal: AbortSignal = signal, +) { + return executeTool(agentTool(ctx), { + turnId: 0, + toolCallId: 'call_agent', + args, + signal: inputSignal, + }); +} + +const cronStub = { + _serviceBrand: undefined, + list: () => [], +} as unknown as ISessionCronService; + +describe('AgentToolInputSchema', () => { + it('accepts the snake_case background parameter', () => { + const parsed = AgentToolInputSchema.parse({ + prompt: 'Investigate', + description: 'Find cause', + subagent_type: 'explore', + run_in_background: true, + }); + + expect(parsed).toMatchObject({ + prompt: 'Investigate', + description: 'Find cause', + subagent_type: 'explore', + run_in_background: true, + }); + }); + + it('exposes run_in_background and not runInBackground in the JSON schema', () => { + const properties = agentSchemaProperties(); + + expect(properties).toHaveProperty('run_in_background'); + expect(properties).not.toHaveProperty('runInBackground'); + }); + + it('describes subagent_type and run_in_background parameters', () => { + const properties = agentSchemaProperties<{ description?: string }>(); + + const subagentTypeDescription = properties['subagent_type']?.description ?? ''; + expect(subagentTypeDescription).toContain('coder'); + expect(subagentTypeDescription).not.toContain('registry'); + expect(subagentTypeDescription).toContain('agent type'); + expect(properties['run_in_background']?.description).toContain('false'); + }); + + it('documents that resume excludes subagent_type', () => { + const properties = agentSchemaProperties<{ description?: string }>(); + + expect((properties['resume']?.description ?? '').toLowerCase()).toContain('subagent_type'); + }); + + it('does not expose timeout or model parameters in the JSON schema', () => { + const properties = agentSchemaProperties(); + + expect(properties).not.toHaveProperty('timeout'); + expect(properties).not.toHaveProperty('model'); + }); + + it('normalizes the default subagent type into tool args', () => { + expect( + AgentToolInputSchema.parse({ + prompt: 'Investigate', + description: 'Find cause', + }).subagent_type, + ).toBe('coder'); + expect( + AgentToolInputSchema.parse({ + prompt: 'Investigate', + description: 'Find cause', + subagent_type: '', + }).subagent_type, + ).toBe('coder'); + expect( + AgentToolInputSchema.parse({ + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }).subagent_type, + ).toBeUndefined(); + }); +}); + +describe('Agent tool description', () => { + let ctx: TestAgentContext; + + afterEach(async () => { + await ctx.dispose(); + }); + + function agentDescription(): string { + const tool = ctx.toolsData().find((entry) => entry.name === 'Agent'); + expect(tool).toBeDefined(); + return tool!.description; + } + + it('explains the fixed background subagent timeout', () => { + ctx = createTestAgent(); + + const description = agentDescription(); + + expect(description).toContain('fixed 30-minute timeout'); + expect(description).not.toContain('operator-configured background timeout'); + expect(description).not.toContain('no time limit'); + expect(description).toContain('Default to a foreground subagent'); + }); + + it('renders the tool set for each subagent type', () => { + ctx = createTestAgent(); + + const description = agentDescription(); + + expect(description).toContain('Tools: Bash, Read, ReadMediaFile, Glob, Grep, WebSearch, FetchURL'); + expect(description).toContain('Tools: Agent, AgentSwarm, Bash'); + }); + + it('mentions resume preference and result visibility', () => { + ctx = createTestAgent(); + + const description = agentDescription().toLowerCase(); + + expect(description).toContain('resume'); + expect(description).toContain('only visible to you'); + expect(description).toContain('when not to'); + expect(description).toContain('out of your own context'); + }); + + it('describes configured subagent types', () => { + ctx = createTestAgent(); + + const description = agentDescription(); + + expect(description).toContain('Available agent types'); + expect(description).toContain('- explore: Fast codebase exploration'); + expect(description).toContain('- coder: Good at general software engineering tasks.'); + }); +}); + +describe('Agent tool execution contract', () => { + let ctx: TestAgentContext | undefined; + + afterEach(async () => { + vi.useRealTimers(); + await ctx?.dispose(); + ctx = undefined; + }); + + function createAgentToolContext( + lifecycle: AgentLifecycleStub = createAgentLifecycleStub(), + ...extra: readonly (TestAgentServiceOverride | TestAgentOptions)[] + ): TestAgentContext { + ctx = createTestAgent( + sessionService(IAgentLifecycleService, lifecycle), + sessionService(ISessionCronService, cronStub), + ...extra, + ); + lifecycle.addHandle('main', 'agent'); + return ctx; + } + + it('declares no resource accesses so concurrent Agent calls can run in parallel', async () => { + const context = createAgentToolContext(); + + const execution = await agentTool(context).resolveExecution({ + prompt: 'Investigate', + description: 'Find cause', + subagent_type: 'explore', + }); + + if (execution.isError === true) throw new Error('expected runnable execution'); + expect(execution.accesses).toEqual(ToolAccesses.none()); + }); + + it('uses the resumed agent profile in the activity description', async () => { + const lifecycle = createAgentLifecycleStub(); + const context = createAgentToolContext(lifecycle); + lifecycle.addHandle('agent-existing', 'explore'); + + const execution = await agentTool(context).resolveExecution({ + prompt: 'Continue', + description: 'Continue work', + resume: ' agent-existing ', + }); + + if (execution.isError === true) throw new Error('expected runnable execution'); + expect(execution.description).toBe('Launching explore agent: Continue work'); + expect(lifecycle.getHandle).toHaveBeenCalledWith('agent-existing'); + }); + + it('returns an error when resuming with a subagent type', async () => { + const lifecycle = createAgentLifecycleStub(); + const context = createAgentToolContext(lifecycle); + lifecycle.addHandle('agent-existing', 'explore'); + + const result = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + subagent_type: 'explore', + }); + + expect(result).toMatchObject({ + isError: true, + output: 'Cannot set subagent_type when resuming an existing agent. Resume by agent id only.', + }); + expect(lifecycle.run).not.toHaveBeenCalled(); + }); + + it('spawns a foreground subagent and returns its summary', async () => { + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: async () => ({ summary: 'child result' }), + }); + const context = createAgentToolContext(lifecycle); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + subagent_type: 'explore', + }); + + expect(lifecycle.create).toHaveBeenCalledWith( + expect.objectContaining({ + binding: expect.objectContaining({ profile: 'explore' }), + labels: expect.objectContaining({ parentAgentId: 'main' }), + }), + ); + expect(lifecycle.run).toHaveBeenCalledWith( + 'agent-child', + { kind: 'prompt', prompt: expect.stringContaining('Investigate') }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(result.output).toContain('agent_id: agent-child'); + expect(result.output).toContain('actual_subagent_type: explore'); + expect(result.output).toContain('child result'); + }); + + it('inherits parent user tools when spawning a subagent', async () => { + const lookupTool: UserToolRegistration = { + name: 'Lookup', + description: 'Look up a short test value.', + parameters: { type: 'object', properties: { query: { type: 'string' } } }, + }; + const parentUserTools = { + _serviceBrand: undefined, + list: () => [lookupTool], + inheritUserTools: vi.fn(), + register: vi.fn(), + unregister: vi.fn(), + } as unknown as IAgentUserToolService; + const childUserTools = { + _serviceBrand: undefined, + list: () => [], + inheritUserTools: vi.fn(), + register: vi.fn(), + unregister: vi.fn(), + } as unknown as IAgentUserToolService; + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + handleServices: new Map([ + ['main', new Map([[IAgentUserToolService, parentUserTools]])], + ['agent-child', new Map([[IAgentUserToolService, childUserTools]])], + ]), + }); + const context = createAgentToolContext(lifecycle); + + await executeAgentTool(context, { + prompt: 'Use the available lookup tool', + description: 'Use lookup', + }); + + expect(childUserTools.inheritUserTools).toHaveBeenCalledWith(parentUserTools); + }); + + it('falls back to coder for an empty subagent type', async () => { + const lifecycle = createAgentLifecycleStub({ createAgentIds: ['agent-child'] }); + const context = createAgentToolContext(lifecycle); + + await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + subagent_type: '', + }); + + expect(lifecycle.create).toHaveBeenCalledWith( + expect.objectContaining({ + binding: expect.objectContaining({ profile: 'coder' }), + }), + ); + }); + + it('resumes a foreground subagent when resume is provided', async () => { + const lifecycle = createAgentLifecycleStub({ + runCompletion: async () => ({ summary: 'resumed result' }), + }); + const context = createAgentToolContext(lifecycle); + lifecycle.addHandle('agent-existing', 'explore'); + + const result = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + expect(lifecycle.create).not.toHaveBeenCalled(); + expect(lifecycle.run).toHaveBeenCalledWith( + 'agent-existing', + { kind: 'prompt', prompt: 'Continue' }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(result.output).toContain('agent_id: agent-existing'); + expect(result.output).toContain('actual_subagent_type: explore'); + expect(result.output).toContain('resumed result'); + }); + + it('registers background subagents with the task manager', async () => { + const completion = deferred<{ readonly summary: string }>(); + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: () => completion.promise, + }); + const context = createAgentToolContext(lifecycle); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + run_in_background: true, + }); + + expect(result.output).toContain('status: running'); + expect(result.output).toContain('agent_id: agent-child'); + if (typeof result.output !== 'string') throw new TypeError('expected string output'); + const taskId = result.output.match(/task_id: (agent-[0-9a-z]{8})/)?.[1]; + expect(taskId).toBeDefined(); + expect(context.get(IAgentTaskService).getTask(taskId!)).toMatchObject({ + status: 'running', + description: 'Find cause', + timeoutMs: 30 * 60 * 1000, + }); + completion.resolve({ summary: 'finished later' }); + }); + + it('rejects background subagents when background execution is disabled', async () => { + const lifecycle = createAgentLifecycleStub(); + const context = createAgentToolContext(lifecycle); + context.get(IAgentProfileService).update({ activeToolNames: ['Agent'] }); + + const description = context.toolsData().find((tool) => tool.name === 'Agent')?.description; + expect(description).toContain('Background agent execution is disabled for this agent.'); + expect(description).not.toContain('the subagent runs detached from this turn'); + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + run_in_background: true, + }); + + expect(result).toMatchObject({ + isError: true, + output: + 'Background agent execution is not available for this agent because TaskList, TaskOutput, and TaskStop are not enabled.', + }); + expect(lifecycle.create).not.toHaveBeenCalled(); + }); + + it('does not consume a background task slot when validation fails before launch', async () => { + const completion = deferred<{ readonly summary: string }>(); + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: () => completion.promise, + }); + const context = createAgentToolContext( + lifecycle, + configServices(() => ({ + providers: {}, + task: { maxRunningTasks: 1 }, + })), + ); + + const invalid = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Invalid background resume', + resume: 'agent-existing', + subagent_type: 'explore', + run_in_background: true, + }); + const valid = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + run_in_background: true, + }); + + expect(invalid).toMatchObject({ + isError: true, + output: 'Cannot set subagent_type when resuming an existing agent. Resume by agent id only.', + }); + expect(valid.output).toContain('status: running'); + expect(lifecycle.create).toHaveBeenCalledTimes(1); + completion.resolve({ summary: 'finished later' }); + }); + + it('returns an error when background registration hits the task limit', async () => { + const completions = [ + deferred<{ readonly summary: string }>(), + deferred<{ readonly summary: string }>(), + ]; + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-first', 'agent-second'], + runCompletion: (_agentId, _request, options) => { + const next = completions.shift(); + if (next === undefined) throw new Error('unexpected run'); + options.signal.addEventListener( + 'abort', + () => { + next.reject(options.signal.reason); + }, + { once: true }, + ); + return next.promise; + }, + }); + const context = createAgentToolContext( + lifecycle, + configServices(() => ({ + providers: {}, + task: { maxRunningTasks: 1 }, + })), + ); + + const first = await executeAgentTool(context, { + prompt: 'Investigate first', + description: 'Find first', + run_in_background: true, + }); + const second = await executeAgentTool(context, { + prompt: 'Investigate second', + description: 'Find second', + run_in_background: true, + }); + + expect(first.output).toContain('status: running'); + expect(second).toMatchObject({ + isError: true, + output: 'Too many background tasks are already running.', + }); + expect(lifecycle.create).toHaveBeenCalledTimes(2); + completions[0]?.resolve({ summary: 'finished later' }); + }); + + it('logs background registration failures', async () => { + const { entries, logger } = captureLogs(); + const completions = [ + deferred<{ readonly summary: string }>(), + deferred<{ readonly summary: string }>(), + ]; + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-first', 'agent-second'], + runCompletion: (_agentId, _request, options) => { + const next = completions.shift(); + if (next === undefined) throw new Error('unexpected run'); + options.signal.addEventListener('abort', () => next.reject(options.signal.reason), { + once: true, + }); + return next.promise; + }, + }); + const context = createAgentToolContext( + lifecycle, + configServices(() => ({ + providers: {}, + task: { maxRunningTasks: 1 }, + })), + sessionService(ILogService, logger), + ); + + await executeAgentTool(context, { + prompt: 'Investigate first', + description: 'Find first', + run_in_background: true, + }); + await executeAgentTool(context, { + prompt: 'Investigate second', + description: 'Find second', + run_in_background: true, + }); + + expect(entries).toContainEqual({ + level: 'warn', + message: 'background agent task registration failed', + payload: expect.objectContaining({ + toolCallId: 'call_agent', + agentId: 'agent-second', + subagentType: 'coder', + error: expect.any(Error), + }), + }); + completions[0]?.resolve({ summary: 'finished later' }); + }); + + it('returns tool errors and logs when spawning fails', async () => { + const error = new Error('missing subagent'); + const { entries, logger } = captureLogs(); + const lifecycle = createAgentLifecycleStub({ createError: error }); + const context = createAgentToolContext(lifecycle, sessionService(ILogService, logger)); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); + + expect(result).toMatchObject({ + isError: true, + output: 'subagent error: missing subagent', + }); + expect(entries).toContainEqual({ + level: 'warn', + message: 'subagent launch failed', + payload: expect.objectContaining({ + toolCallId: 'call_agent', + runInBackground: false, + operation: 'spawn', + subagentType: 'coder', + error, + }), + }); + }); + + it('can detach a foreground subagent through the task manager', async () => { + const completion = deferred<{ readonly summary: string }>(); + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: () => completion.promise, + }); + const context = createAgentToolContext(lifecycle); + const tasks = context.get(IAgentTaskService); + + const running = executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); + await vi.waitFor(() => { + expect(tasks.list(false)).toHaveLength(1); + }); + const task = tasks.list(false)[0]!; + + expect(task).toMatchObject({ + kind: 'agent', + detached: false, + agentId: 'agent-child', + }); + + tasks.detach(task.taskId); + const result = await running; + + expect(result.output).toContain(`task_id: ${task.taskId}`); + expect(result.output).toContain('agent_id: agent-child'); + expect(result.output).toContain('automatic_notification: true'); + + completion.resolve({ summary: 'finished later' }); + await expect(tasks.wait(task.taskId)).resolves.toMatchObject({ + status: 'completed', + detached: true, + }); + }); + + it('does not recommend disabled task tools when a foreground subagent is detached', async () => { + const completion = deferred<{ readonly summary: string }>(); + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: () => completion.promise, + }); + const context = createAgentToolContext(lifecycle); + context.get(IAgentProfileService).update({ activeToolNames: ['Agent'] }); + const tasks = context.get(IAgentTaskService); + + const running = executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); + await vi.waitFor(() => { + expect(tasks.list(false)).toHaveLength(1); + }); + const task = tasks.list(false)[0]!; + + tasks.detach(task.taskId); + const result = await running; + + expect(result.output).toContain(`task_id: ${task.taskId}`); + expect(result.output).toContain('next_step: The completion arrives automatically'); + expect(result.output).not.toContain('TaskOutput'); + expect(result.output).not.toContain('TaskStop'); + + completion.resolve({ summary: 'finished later' }); + await expect(tasks.wait(task.taskId)).resolves.toMatchObject({ + status: 'completed', + detached: true, + }); + }); + + it('steers the AI away from waiting and gives a resume hint on background launch', async () => { + const completion = deferred<{ readonly summary: string }>(); + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: () => completion.promise, + }); + const context = createAgentToolContext(lifecycle); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + run_in_background: true, + }); + + if (typeof result.output !== 'string') throw new TypeError('expected string output'); + const taskId = result.output.match(/task_id: (agent-[0-9a-z]{8})/)?.[1]; + expect(taskId).toBeDefined(); + expect(result.output).toContain('next_step:'); + expect(result.output).toContain('do NOT wait, poll, or call TaskOutput on it'); + expect(result.output).not.toContain('block=false'); + expect(result.output).toContain('resume_hint:'); + expect(result.output).toContain('Agent(resume="agent-child"'); + expect(result.output).toMatch(/agent_id.*not.*task_id|task_id.*not.*agent_id/i); + expect(result.output).toMatch(/task\.lost|task\.failed|task\.killed/); + completion.resolve({ summary: 'finished later' }); + }); + + it('reports a deliberate user interruption when a foreground subagent is cancelled by the user', async () => { + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: (_agentId, _request, options) => + new Promise((_resolve, reject) => { + options.signal.addEventListener( + 'abort', + () => { + reject(options.signal.reason); + }, + { once: true }, + ); + }), + }); + const context = createAgentToolContext(lifecycle); + const controller = new AbortController(); + + const resultPromise = executeAgentTool( + context, + { prompt: 'Investigate', description: 'Find cause' }, + controller.signal, + ); + await vi.waitFor(() => { + expect(context.get(IAgentTaskService).list(false)).toHaveLength(1); + }); + controller.abort(userCancellationReason()); + const result = await resultPromise; + + expect(result.isError).toBe(true); + expect(result.output).toContain('status: failed'); + expect(result.output).not.toContain('was stopped by the user'); + expect(result.output).toContain('not a system error'); + expect(result.output).toContain('capacity'); + expect(result.output).toContain('wait for the user'); + }); + + it('returns the spawned agent id when a foreground subagent times out', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: (_agentId, _request, options) => + new Promise((_resolve, reject) => { + options.signal.addEventListener( + 'abort', + () => { + reject(options.signal.reason); + }, + { once: true }, + ); + }), + }); + const context = createAgentToolContext(lifecycle); + + const resultPromise = executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); + await vi.waitFor(() => { + expect(context.get(IAgentTaskService).list(false)).toHaveLength(1); + }); + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); + const result = await resultPromise; + + expect(result).toMatchObject({ isError: true }); + expect(result.output).toContain('agent_id: agent-child'); + expect(result.output).toContain('actual_subagent_type: coder'); + expect(result.output).toContain('status: failed'); + expect(result.output).toContain('subagent error: Agent timed out after 30 minutes.'); + expect(result.output).toContain('resume_hint:'); + expect(result.output).toContain('Agent(resume="agent-child", prompt="continue")'); + expect(result.output).toContain('do not set subagent_type'); + expect(result.output).toContain('retains its prior context'); + }); +}); + +describe('AgentSwarmToolInputSchema', () => { + const spawnInput: AgentSwarmToolInput = { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + subagent_type: 'explore', + }; + + it('accepts item-based swarms up to 128 subagents', () => { + expect(AgentSwarmToolInputSchema.safeParse(spawnInput).success).toBe(true); + expect( + AgentSwarmToolInputSchema.safeParse({ + ...spawnInput, + items: Array.from({ length: 128 }, (_, index) => `src/${String(index + 1)}.ts`), + }).success, + ).toBe(true); + }); + + it('rejects more than 128 item-based subagents in the JSON args schema', () => { + expect( + AgentSwarmToolInputSchema.safeParse({ + ...spawnInput, + items: Array.from({ length: 129 }, (_, index) => `src/${String(index + 1)}.ts`), + }).success, + ).toBe(false); + }); + + it('allows resumed subagents without item-based spawns', () => { + expect( + AgentSwarmToolInputSchema.safeParse({ + description: 'Resume one agent', + resume_agent_ids: { + 'agent-old-1': 'Continue previous review', + }, + }).success, + ).toBe(true); + expect( + AgentSwarmToolInputSchema.safeParse({ + description: 'Resume two agents', + resume_agent_ids: { + 'agent-old-1': 'Continue previous review A', + 'agent-old-2': 'Continue previous review B', + }, + }).success, + ).toBe(true); + }); + + it('exposes subagent_type and resume_agent_ids parameters', () => { + const properties = agentSwarmSchemaProperties<{ description?: string }>(); + + expect(properties['subagent_type']?.description).toContain('defaults to coder'); + expect(properties['resume_agent_ids']?.description).toContain('Map of existing subagent'); + expect(Object.keys(properties).at(-1)).toBe('resume_agent_ids'); + expect(properties).not.toHaveProperty('run_in_background'); + expect(properties).not.toHaveProperty('timeout'); + expect(properties).not.toHaveProperty('model'); + }); +}); + +describe('AgentSwarm tool description', () => { + let ctx: TestAgentContext; + + afterEach(async () => { + await ctx.dispose(); + }); + + function agentSwarmDescription(): string { + const tool = ctx.toolsData().find((entry) => entry.name === 'AgentSwarm'); + expect(tool).toBeDefined(); + return tool!.description; + } + + it('states the enforced input requirements', () => { + ctx = createTestAgent(); + + const description = agentSwarmDescription(); + + expect(description).toContain('at least 2'); + expect(description).toContain('{{item}}'); + expect(description.toLowerCase()).toContain('distinct'); + expect(description).toContain('128 subagents'); + }); + + it('states AgentSwarm must be the only tool call in a response', () => { + ctx = createTestAgent(); + + expect(agentSwarmDescription()).toContain( + 'If `AgentSwarm` is called, that call must be the only tool call in the response.', + ); + }); +}); + +describe('AgentSwarm tool execution contract', () => { + let ctx: TestAgentContext; + + afterEach(async () => { + await ctx.dispose(); + }); + + it('runs item-based swarms through the session swarm service and renders XML results', async () => { + const runSwarm = vi.fn( + async ( + args: SessionSwarmRunArgs, + ): Promise[]> => { + return args.tasks.map((task, index) => ({ + task, + agentId: `agent-explore-${String(index + 1)}`, + status: 'completed' as const, + result: index === 0 ? 'explore result a' : 'explore result b', + })); + }, + ); + const swarmService: ISessionSwarmService = { + _serviceBrand: undefined, + getSwarmItem: async () => undefined, + run: runSwarm as ISessionSwarmService['run'], + cancel: () => {}, + }; + ctx = createTestAgent(swarmServices(swarmService)); + + const result = await executeTool(agentSwarmTool(ctx), { + turnId: 0, + toolCallId: 'call_swarm', + args: { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + subagent_type: 'explore', + }, + signal, + }); + + expect(runSwarm).toHaveBeenCalledWith({ + callerAgentId: 'main', + tasks: [ + { + kind: 'spawn', + data: { kind: 'spawn', index: 1, item: 'src/a.ts', prompt: 'Review src/a.ts' }, + profileName: 'explore', + parentToolCallId: 'call_swarm', + prompt: 'Review src/a.ts', + description: 'Review files #1 (explore)', + swarmIndex: 1, + swarmItem: 'src/a.ts', + runInBackground: false, + signal, + timeout: 30 * 60 * 1000, + }, + { + kind: 'spawn', + data: { kind: 'spawn', index: 2, item: 'src/b.ts', prompt: 'Review src/b.ts' }, + profileName: 'explore', + parentToolCallId: 'call_swarm', + prompt: 'Review src/b.ts', + description: 'Review files #2 (explore)', + swarmIndex: 2, + swarmItem: 'src/b.ts', + runInBackground: false, + signal, + timeout: 30 * 60 * 1000, + }, + ], + }); + expect(result.output).toBe([ + '', + 'completed: 2', + 'explore result a', + 'explore result b', + '', + ].join('\n')); + expect(result.isError).toBeUndefined(); + }); + + it('resumes mapped agents before spawning item subagents', async () => { + const persistedItems: Record = { + 'agent-old-1': 'src/old-a.ts', + 'agent-old-2': 'src/old-b.ts', + }; + const getSwarmItem = vi.fn( + async ({ agentId }: { readonly agentId: string }) => persistedItems[agentId], + ); + const runSwarm = vi.fn( + async ( + args: SessionSwarmRunArgs, + ): Promise[]> => { + return args.tasks.map((task, index) => ({ + task, + agentId: task.kind === 'resume' ? task.resumeAgentId : `agent-new-${String(index + 1)}`, + status: 'completed' as const, + result: `result ${String(index + 1)}`, + })); + }, + ); + const swarmService: ISessionSwarmService = { + _serviceBrand: undefined, + getSwarmItem, + run: runSwarm as ISessionSwarmService['run'], + cancel: () => {}, + }; + ctx = createTestAgent(swarmServices(swarmService)); + + const result = await executeTool(agentSwarmTool(ctx), { + turnId: 0, + toolCallId: 'call_swarm', + args: { + description: 'Finish review', + subagent_type: 'explore', + prompt_template: 'Review {{item}}', + items: ['src/new.ts'], + resume_agent_ids: { + 'agent-old-1': 'Continue previous review A', + 'agent-old-2': 'Continue previous review B', + }, + }, + signal, + }); + + expect(getSwarmItem).toHaveBeenCalledWith({ + callerAgentId: 'main', + agentId: 'agent-old-1', + }); + expect(getSwarmItem).toHaveBeenCalledWith({ + callerAgentId: 'main', + agentId: 'agent-old-2', + }); + expect(runSwarm).toHaveBeenCalledWith({ + callerAgentId: 'main', + tasks: [ + { + kind: 'resume', + data: { + kind: 'resume', + index: 1, + agentId: 'agent-old-1', + item: 'src/old-a.ts', + prompt: 'Continue previous review A', + }, + profileName: 'subagent', + parentToolCallId: 'call_swarm', + prompt: 'Continue previous review A', + description: 'Finish review #1 (resume)', + swarmIndex: 1, + swarmItem: 'src/old-a.ts', + runInBackground: false, + resumeAgentId: 'agent-old-1', + signal, + timeout: 30 * 60 * 1000, + }, + { + kind: 'resume', + data: { + kind: 'resume', + index: 2, + agentId: 'agent-old-2', + item: 'src/old-b.ts', + prompt: 'Continue previous review B', + }, + profileName: 'subagent', + parentToolCallId: 'call_swarm', + prompt: 'Continue previous review B', + description: 'Finish review #2 (resume)', + swarmIndex: 2, + swarmItem: 'src/old-b.ts', + runInBackground: false, + resumeAgentId: 'agent-old-2', + signal, + timeout: 30 * 60 * 1000, + }, + { + kind: 'spawn', + data: { + kind: 'spawn', + index: 3, + item: 'src/new.ts', + prompt: 'Review src/new.ts', + }, + profileName: 'explore', + parentToolCallId: 'call_swarm', + prompt: 'Review src/new.ts', + description: 'Finish review #3 (explore)', + swarmIndex: 3, + swarmItem: 'src/new.ts', + runInBackground: false, + signal, + timeout: 30 * 60 * 1000, + }, + ], + }); + expect(result.output).toBe([ + '', + 'completed: 3', + 'result 1', + 'result 2', + 'result 3', + '', + ].join('\n')); + expect(result.isError).toBeUndefined(); + }); + + it('reports failed subagents inside the XML result without failing the tool', async () => { + const runSwarm = vi.fn( + async ( + args: SessionSwarmRunArgs, + ): Promise[]> => [ + { + task: args.tasks[0]!, + agentId: 'agent-coder-1', + status: 'completed' as const, + result: 'imports are stable', + }, + { + task: args.tasks[1]!, + agentId: 'agent-coder-2', + status: 'failed' as const, + error: 'Agent timed out after 30s.', + }, + ], + ); + const swarmService: ISessionSwarmService = { + _serviceBrand: undefined, + getSwarmItem: async () => undefined, + run: runSwarm as ISessionSwarmService['run'], + cancel: () => {}, + }; + ctx = createTestAgent(swarmServices(swarmService)); + + const result = await executeTool(agentSwarmTool(ctx), { + turnId: 0, + toolCallId: 'call_swarm', + args: { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }, + signal, + }); + + expect(result.output).toBe([ + '', + 'completed: 1, failed: 1', + 'Call AgentSwarm with resume_agent_ids using the agent_id values in this result to continue unfinished work.', + 'imports are stable', + 'Agent timed out after 30s.', + '', + ].join('\n')); + expect(result.isError).toBeUndefined(); + }); + + it('omits the resume hint when incomplete subagents have no agent ids', async () => { + const runSwarm = vi.fn( + async ( + args: SessionSwarmRunArgs, + ): Promise[]> => [ + { + task: args.tasks[0]!, + status: 'failed' as const, + error: 'Agent did not start.', + }, + { + task: args.tasks[1]!, + status: 'failed' as const, + error: 'Agent also did not start.', + }, + ], + ); + const swarmService: ISessionSwarmService = { + _serviceBrand: undefined, + getSwarmItem: async () => undefined, + run: runSwarm as ISessionSwarmService['run'], + cancel: () => {}, + }; + ctx = createTestAgent(swarmServices(swarmService)); + + const result = await executeTool(agentSwarmTool(ctx), { + turnId: 0, + toolCallId: 'call_swarm', + args: { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }, + signal, + }); + + expect(result.output).toBe([ + '', + 'failed: 2', + 'Agent did not start.', + 'Agent also did not start.', + '', + ].join('\n')); + expect(result.output).not.toContain(''); + expect(result.isError).toBeUndefined(); + }); + + it('reports partial aborted subagents inside the XML result', async () => { + const runSwarm = vi.fn( + async ( + args: SessionSwarmRunArgs, + ): Promise[]> => [ + { + task: args.tasks[0]!, + agentId: 'agent-coder-1', + status: 'completed' as const, + result: 'imports are stable', + }, + { + task: args.tasks[1]!, + agentId: 'agent-coder-2', + status: 'aborted' as const, + state: 'started' as const, + error: 'The user manually interrupted this subagent batch before this subagent finished.', + }, + { + task: args.tasks[2]!, + status: 'aborted' as const, + state: 'not_started' as const, + error: 'The user manually interrupted this subagent batch before this subagent was started.', + }, + ], + ); + const swarmService: ISessionSwarmService = { + _serviceBrand: undefined, + getSwarmItem: async () => undefined, + run: runSwarm as ISessionSwarmService['run'], + cancel: () => {}, + }; + ctx = createTestAgent(swarmServices(swarmService)); + + const result = await executeTool(agentSwarmTool(ctx), { + turnId: 0, + toolCallId: 'call_swarm', + args: { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts', 'src/c.ts'], + }, + signal, + }); + + expect(result.output).toBe([ + '', + 'completed: 1, aborted: 2', + 'Call AgentSwarm with resume_agent_ids using the agent_id values in this result to continue unfinished work.', + 'imports are stable', + 'The user manually interrupted this subagent batch before this subagent finished.', + 'The user manually interrupted this subagent batch before this subagent was started.', + '', + ].join('\n')); + expect(result.isError).toBeUndefined(); + }); + + it('declares broad accesses and does not expose permission rule argument matching', async () => { + ctx = createTestAgent(); + + const execution = await agentSwarmTool(ctx).resolveExecution({ + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }); + + if (execution.isError === true) throw new Error('AgentSwarm resolveExecution returned an error'); + expect(execution.accesses).toEqual(ToolAccesses.all()); + expect(execution.approvalRule).toBe('AgentSwarm'); + expect(execution.matchesRule).toBeUndefined(); + expect(execution.description).toBe('Launching agent swarm: Review files'); + expect(execution.display).toMatchObject({ + kind: 'agent_call', + agent_name: 'swarm (2 subagents)', + prompt: 'Review files', + }); + }); + + it('counts resumed and item-based subagents in the display name', async () => { + ctx = createTestAgent(); + + const execution = await agentSwarmTool(ctx).resolveExecution({ + description: 'Finish review', + prompt_template: 'Review {{item}}', + items: ['src/new.ts'], + resume_agent_ids: { + 'agent-old-1': 'Continue previous review A', + 'agent-old-2': 'Continue previous review B', + }, + }); + + if (execution.isError === true) throw new Error('AgentSwarm resolveExecution returned an error'); + expect(execution.display).toMatchObject({ + agent_name: 'swarm (3 subagents)', + prompt: 'Finish review', + }); + }); +}); + describe('Agent tools', () => { let context: IAgentContextMemoryService; let ctx: TestAgentContext; @@ -215,18 +1662,49 @@ describe('Agent tools', () => { }); }); - describe.skip('foreground Agent tool recovery', () => { - // TODO: rewrite against the new session/agentLifecycle/tools/agent surface. - // The old `AgentToolRunOverride` seam is gone; equivalent tests will stub - // `IAgentLifecycleService.spawn` + a fake child scope's prompt turn. - let runOverride: unknown; - + describe('foreground Agent tool recovery', () => { beforeEach(() => { - runOverride = undefined; + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: async () => { + throw new Error('Subagent turn failed before completing its final summary: reason=max_tokens'); + }, + }); + ctx = createTestAgent( + sessionService(IAgentLifecycleService, lifecycle), + sessionService(ISessionCronService, cronStub), + ); + lifecycle.addHandle('main', 'agent'); }); it('continues after a foreground Agent tool returns a max_tokens failure', async () => { - expect(runOverride).toBeUndefined(); + ctx.mockNextResponse({ type: 'text', text: 'I will delegate.' }, agentCall()); + ctx.mockNextResponse({ type: 'text', text: 'I recovered from the subagent failure.' }); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Use an agent' }] }); + await ctx.untilTurnEnd(); + + expect(ctx.contextData().history).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: 'tool', + toolCallId: 'call_agent', + content: [ + expect.objectContaining({ + text: expect.stringContaining('reason=max_tokens'), + }), + ], + }), + expect.objectContaining({ + role: 'assistant', + content: [ + expect.objectContaining({ + text: 'I recovered from the subagent failure.', + }), + ], + }), + ]), + ); }); }); diff --git a/packages/agent-core-v2/test/userTool/userTool.test.ts b/packages/agent-core-v2/test/userTool/userTool.test.ts index 180363316..da551d981 100644 --- a/packages/agent-core-v2/test/userTool/userTool.test.ts +++ b/packages/agent-core-v2/test/userTool/userTool.test.ts @@ -129,6 +129,45 @@ describe('AgentUserToolService (wire-backed)', () => { ]); }); + it('inherits currently registered parent user tools into another agent service', async () => { + svc.register(toolA); + svc.register(toolB); + svc.unregister(toolB.name); + + const ixChild = disposables.add(new TestInstantiationService()); + ixChild.stub(IFileSystemStorageService, new InMemoryStorageService()); + ixChild.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ixChild.set( + IAgentWireService, + new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: 'user-tool-child' }]), + ); + ixChild.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService)); + const childProfile = createProfileStub(); + ixChild.stub(IAgentProfileService, childProfile); + ixChild.stub(ISessionInteractionService, createInteractionStub()); + ixChild.set(IAgentUserToolService, new SyncDescriptor(AgentUserToolService)); + + const child = ixChild.get(IAgentUserToolService); + const childWire = ixChild.get(IAgentWireService); + const childRegistry = ixChild.get(IAgentToolRegistryService); + child.inheritUserTools(svc); + + expect(child.list()).toEqual([toolA]); + expect(modelOf(childWire).get(toolA.name)).toEqual(toolA); + expect(modelOf(childWire).has(toolB.name)).toBe(false); + expect(childRegistry.resolve(toolA.name)).toBeDefined(); + expect(childProfile.active.has(toolA.name)).toBe(true); + expect(childProfile.active.has(toolB.name)).toBe(false); + + const childRecords: PersistedRecord[] = []; + for await (const record of ixChild + .get(IAppendLogStore) + .read(SCOPE, 'user-tool-child')) { + childRecords.push(record); + } + expect(childRecords).toEqual([{ type: 'tools.register_user_tool', ...toolA }]); + }); + it('re-registering an equal tool is a no-op on the model (same reference)', () => { svc.register(toolA); const before = modelOf(wire);