Subagent activity UX. (#17570)

This commit is contained in:
Christian Gunderman
2026-03-02 21:04:31 +00:00
committed by GitHub
parent ce5a2d0760
commit 7ca3a33f8b
25 changed files with 827 additions and 88 deletions
@@ -16,8 +16,11 @@
import type { Config } from '../../config/config.js';
import { LocalAgentExecutor } from '../local-executor.js';
import type { AnsiOutput } from '../../utils/terminalSerializer.js';
import { BaseToolInvocation, type ToolResult } from '../../tools/tools.js';
import {
BaseToolInvocation,
type ToolResult,
type ToolLiveOutput,
} from '../../tools/tools.js';
import { ToolErrorType } from '../../tools/tool-error.js';
import type { AgentInputs, SubagentActivityEvent } from '../types.js';
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
@@ -82,7 +85,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
*/
async execute(
signal: AbortSignal,
updateOutput?: (output: string | AnsiOutput) => void,
updateOutput?: (output: ToolLiveOutput) => void,
): Promise<ToolResult> {
let browserManager;
@@ -711,25 +711,28 @@ describe('LocalAgentExecutor', () => {
expect.arrayContaining([
expect.objectContaining({
type: 'THOUGHT_CHUNK',
data: { text: 'T1: Listing' },
data: expect.objectContaining({ text: 'T1: Listing' }),
}),
expect.objectContaining({
type: 'TOOL_CALL_END',
data: { name: LS_TOOL_NAME, output: 'file1.txt' },
data: expect.objectContaining({
name: LS_TOOL_NAME,
output: 'file1.txt',
}),
}),
expect.objectContaining({
type: 'TOOL_CALL_START',
data: {
data: expect.objectContaining({
name: TASK_COMPLETE_TOOL_NAME,
args: { finalResult: 'Found file1.txt' },
},
}),
}),
expect.objectContaining({
type: 'TOOL_CALL_END',
data: {
data: expect.objectContaining({
name: TASK_COMPLETE_TOOL_NAME,
output: expect.stringContaining('Output submitted'),
},
}),
}),
]),
);
+31 -2
View File
@@ -269,13 +269,22 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
};
}
const { nextMessage, submittedOutput, taskCompleted } =
const { nextMessage, submittedOutput, taskCompleted, aborted } =
await this.processFunctionCalls(
functionCalls,
combinedSignal,
promptId,
onWaitingForConfirmation,
);
if (aborted) {
return {
status: 'stop',
terminateReason: AgentTerminateMode.ABORTED,
finalResult: null,
};
}
if (taskCompleted) {
const finalResult = submittedOutput ?? 'Task completed successfully.';
return {
@@ -857,6 +866,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
nextMessage: Content;
submittedOutput: string | null;
taskCompleted: boolean;
aborted: boolean;
}> {
const allowedToolNames = new Set(this.toolRegistry.getAllToolNames());
// Always allow the completion tool
@@ -864,6 +874,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
let submittedOutput: string | null = null;
let taskCompleted = false;
let aborted = false;
// We'll separate complete_task from other tools
const toolRequests: ToolCallRequestInfo[] = [];
@@ -878,8 +889,24 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const toolName = functionCall.name as string;
let displayName = toolName;
let description: string | undefined = undefined;
try {
const tool = this.toolRegistry.getTool(toolName);
if (tool) {
displayName = tool.displayName ?? toolName;
const invocation = tool.build(args);
description = invocation.getDescription();
}
} catch {
// Ignore errors during formatting for activity emission
}
this.emitActivity('TOOL_CALL_START', {
name: toolName,
displayName,
description,
args,
});
@@ -1077,8 +1104,9 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
this.emitActivity('ERROR', {
context: 'tool_call',
name: toolName,
error: 'Tool call was cancelled.',
error: 'Request cancelled.',
});
aborted = true;
}
// Add result to syncResults to preserve order later
@@ -1111,6 +1139,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
nextMessage: { role: 'user', parts: toolResponseParts },
submittedOutput,
taskCompleted,
aborted,
};
}
@@ -4,17 +4,25 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mocked,
} from 'vitest';
import type {
LocalAgentDefinition,
SubagentActivityEvent,
AgentInputs,
SubagentProgress,
} from './types.js';
import { LocalSubagentInvocation } from './local-invocation.js';
import { LocalAgentExecutor } from './local-executor.js';
import { AgentTerminateMode } from './types.js';
import { makeFakeConfig } from '../test-utils/config.js';
import { ToolErrorType } from '../tools/tool-error.js';
import type { Config } from '../config/config.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { type z } from 'zod';
@@ -29,6 +37,7 @@ let mockConfig: Config;
const testDefinition: LocalAgentDefinition<z.ZodUnknown> = {
kind: 'local',
name: 'MockAgent',
displayName: 'Mock Agent',
description: 'A mock agent.',
inputConfig: {
inputSchema: {
@@ -70,6 +79,10 @@ describe('LocalSubagentInvocation', () => {
);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should pass the messageBus to the parent constructor', () => {
const params = { task: 'Analyze data' };
const invocation = new LocalSubagentInvocation(
@@ -173,7 +186,12 @@ describe('LocalSubagentInvocation', () => {
mockConfig,
expect.any(Function),
);
expect(updateOutput).toHaveBeenCalledWith('Subagent starting...\n');
expect(updateOutput).toHaveBeenCalledWith(
expect.objectContaining({
isSubagentProgress: true,
agentName: 'MockAgent',
}),
);
expect(mockExecutorInstance.run).toHaveBeenCalledWith(params, signal);
@@ -211,13 +229,17 @@ describe('LocalSubagentInvocation', () => {
await invocation.execute(signal, updateOutput);
expect(updateOutput).toHaveBeenCalledWith('Subagent starting...\n');
expect(updateOutput).toHaveBeenCalledWith('🤖💭 Analyzing...');
expect(updateOutput).toHaveBeenCalledWith('🤖💭 Still thinking.');
expect(updateOutput).toHaveBeenCalledTimes(3); // Initial message + 2 thoughts
expect(updateOutput).toHaveBeenCalledTimes(3); // Initial + 2 updates
const lastCall = updateOutput.mock.calls[2][0] as SubagentProgress;
expect(lastCall.recentActivity).toContainEqual(
expect.objectContaining({
type: 'thought',
content: 'Analyzing... Still thinking.',
}),
);
});
it('should NOT stream other activities (e.g., TOOL_CALL_START, ERROR)', async () => {
it('should stream other activities (e.g., TOOL_CALL_START, ERROR)', async () => {
mockExecutorInstance.run.mockImplementation(async () => {
const onActivity = MockLocalAgentExecutor.create.mock.calls[0][2];
@@ -226,7 +248,7 @@ describe('LocalSubagentInvocation', () => {
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'ls' },
data: { name: 'ls', args: {} },
} as SubagentActivityEvent);
onActivity({
isSubagentActivityEvent: true,
@@ -240,9 +262,15 @@ describe('LocalSubagentInvocation', () => {
await invocation.execute(signal, updateOutput);
// Should only contain the initial "Subagent starting..." message
expect(updateOutput).toHaveBeenCalledTimes(1);
expect(updateOutput).toHaveBeenCalledWith('Subagent starting...\n');
expect(updateOutput).toHaveBeenCalledTimes(3);
const lastCall = updateOutput.mock.calls[2][0] as SubagentProgress;
expect(lastCall.recentActivity).toContainEqual(
expect.objectContaining({
type: 'thought',
content: 'Error: Failed',
status: 'error',
}),
);
});
it('should run successfully without an updateOutput callback', async () => {
@@ -272,16 +300,19 @@ describe('LocalSubagentInvocation', () => {
const result = await invocation.execute(signal, updateOutput);
expect(result.error).toEqual({
message: error.message,
type: ToolErrorType.EXECUTION_FAILED,
});
expect(result.returnDisplay).toBe(
`Subagent Failed: MockAgent\nError: ${error.message}`,
);
expect(result.error).toBeUndefined();
expect(result.llmContent).toBe(
`Subagent 'MockAgent' failed. Error: ${error.message}`,
);
const display = result.returnDisplay as SubagentProgress;
expect(display.isSubagentProgress).toBe(true);
expect(display.recentActivity).toContainEqual(
expect.objectContaining({
type: 'thought',
content: `Error: ${error.message}`,
status: 'error',
}),
);
});
it('should handle executor creation failure', async () => {
@@ -291,19 +322,21 @@ describe('LocalSubagentInvocation', () => {
const result = await invocation.execute(signal, updateOutput);
expect(mockExecutorInstance.run).not.toHaveBeenCalled();
expect(result.error).toEqual({
message: creationError.message,
type: ToolErrorType.EXECUTION_FAILED,
});
expect(result.returnDisplay).toContain(`Error: ${creationError.message}`);
expect(result.error).toBeUndefined();
expect(result.llmContent).toContain(creationError.message);
const display = result.returnDisplay as SubagentProgress;
expect(display.recentActivity).toContainEqual(
expect.objectContaining({
content: `Error: ${creationError.message}`,
status: 'error',
}),
);
});
/**
* This test verifies that the AbortSignal is correctly propagated and
* that a rejection from the executor due to abortion is handled gracefully.
*/
it('should handle abortion signal during execution', async () => {
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
mockExecutorInstance.run.mockRejectedValue(abortError);
const controller = new AbortController();
@@ -312,14 +345,24 @@ describe('LocalSubagentInvocation', () => {
updateOutput,
);
controller.abort();
const result = await executePromise;
await expect(executePromise).rejects.toThrow('Aborted');
expect(mockExecutorInstance.run).toHaveBeenCalledWith(
params,
controller.signal,
);
expect(result.error?.message).toBe('Aborted');
expect(result.error?.type).toBe(ToolErrorType.EXECUTION_FAILED);
});
it('should throw an error and bubble cancellation when execution returns ABORTED', async () => {
const mockOutput = {
result: 'Cancelled by user',
terminate_reason: AgentTerminateMode.ABORTED,
};
mockExecutorInstance.run.mockResolvedValue(mockOutput);
await expect(invocation.execute(signal, updateOutput)).rejects.toThrow(
'Operation cancelled by user',
);
});
});
});
+197 -19
View File
@@ -6,18 +6,25 @@
import type { Config } from '../config/config.js';
import { LocalAgentExecutor } from './local-executor.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import { BaseToolInvocation, type ToolResult } from '../tools/tools.js';
import { ToolErrorType } from '../tools/tool-error.js';
import type {
LocalAgentDefinition,
AgentInputs,
SubagentActivityEvent,
import {
BaseToolInvocation,
type ToolResult,
type ToolLiveOutput,
} from '../tools/tools.js';
import {
type LocalAgentDefinition,
type AgentInputs,
type SubagentActivityEvent,
type SubagentProgress,
type SubagentActivityItem,
AgentTerminateMode,
} from './types.js';
import { randomUUID } from 'node:crypto';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
const INPUT_PREVIEW_MAX_LENGTH = 50;
const DESCRIPTION_MAX_LENGTH = 200;
const MAX_RECENT_ACTIVITY = 3;
/**
* Represents a validated, executable instance of a subagent tool.
@@ -81,11 +88,20 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
*/
async execute(
signal: AbortSignal,
updateOutput?: (output: string | AnsiOutput) => void,
updateOutput?: (output: ToolLiveOutput) => void,
): Promise<ToolResult> {
let recentActivity: SubagentActivityItem[] = [];
try {
if (updateOutput) {
updateOutput('Subagent starting...\n');
// Send initial state
const initialProgress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [],
state: 'running',
};
updateOutput(initialProgress);
}
// Create an activity callback to bridge the executor's events to the
@@ -93,11 +109,114 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
const onActivity = (activity: SubagentActivityEvent): void => {
if (!updateOutput) return;
if (
activity.type === 'THOUGHT_CHUNK' &&
typeof activity.data['text'] === 'string'
) {
updateOutput(`🤖💭 ${activity.data['text']}`);
let updated = false;
switch (activity.type) {
case 'THOUGHT_CHUNK': {
const text = String(activity.data['text']);
const lastItem = recentActivity[recentActivity.length - 1];
if (
lastItem &&
lastItem.type === 'thought' &&
lastItem.status === 'running'
) {
lastItem.content += text;
} else {
recentActivity.push({
id: randomUUID(),
type: 'thought',
content: text,
status: 'running',
});
}
updated = true;
break;
}
case 'TOOL_CALL_START': {
const name = String(activity.data['name']);
const displayName = activity.data['displayName']
? String(activity.data['displayName'])
: undefined;
const description = activity.data['description']
? String(activity.data['description'])
: undefined;
const args = JSON.stringify(activity.data['args']);
recentActivity.push({
id: randomUUID(),
type: 'tool_call',
content: name,
displayName,
description,
args,
status: 'running',
});
updated = true;
break;
}
case 'TOOL_CALL_END': {
const name = String(activity.data['name']);
// Find the last running tool call with this name
for (let i = recentActivity.length - 1; i >= 0; i--) {
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].content === name &&
recentActivity[i].status === 'running'
) {
recentActivity[i].status = 'completed';
updated = true;
break;
}
}
break;
}
case 'ERROR': {
const error = String(activity.data['error']);
const isCancellation = error === 'Request cancelled.';
const toolName = activity.data['name']
? String(activity.data['name'])
: undefined;
if (toolName && isCancellation) {
for (let i = recentActivity.length - 1; i >= 0; i--) {
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].content === toolName &&
recentActivity[i].status === 'running'
) {
recentActivity[i].status = 'cancelled';
updated = true;
break;
}
}
}
recentActivity.push({
id: randomUUID(),
type: 'thought', // Treat errors as thoughts for now, or add an error type
content: isCancellation ? error : `Error: ${error}`,
status: isCancellation ? 'cancelled' : 'error',
});
updated = true;
break;
}
default:
break;
}
if (updated) {
// Keep only the last N items
if (recentActivity.length > MAX_RECENT_ACTIVITY) {
recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY);
}
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [...recentActivity], // Copy to avoid mutation issues
state: 'running',
};
updateOutput(progress);
}
};
@@ -109,6 +228,23 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
const output = await executor.run(this.params, signal);
if (output.terminate_reason === AgentTerminateMode.ABORTED) {
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [...recentActivity],
state: 'cancelled',
};
if (updateOutput) {
updateOutput(progress);
}
const cancelError = new Error('Operation cancelled by user');
cancelError.name = 'AbortError';
throw cancelError;
}
const resultContent = `Subagent '${this.definition.name}' finished.
Termination Reason: ${output.terminate_reason}
Result:
@@ -131,13 +267,55 @@ ${output.result}
const errorMessage =
error instanceof Error ? error.message : String(error);
const isAbort =
(error instanceof Error && error.name === 'AbortError') ||
errorMessage.includes('Aborted');
// Mark any running items as error/cancelled
for (const item of recentActivity) {
if (item.status === 'running') {
item.status = isAbort ? 'cancelled' : 'error';
}
}
// Ensure the error is reflected in the recent activity for display
// But only if it's NOT an abort, or if we want to show "Cancelled" as a thought
if (!isAbort) {
const lastActivity = recentActivity[recentActivity.length - 1];
if (!lastActivity || lastActivity.status !== 'error') {
recentActivity.push({
id: randomUUID(),
type: 'thought',
content: `Error: ${errorMessage}`,
status: 'error',
});
// Maintain size limit
if (recentActivity.length > MAX_RECENT_ACTIVITY) {
recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY);
}
}
}
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [...recentActivity],
state: isAbort ? 'cancelled' : 'error',
};
if (updateOutput) {
updateOutput(progress);
}
if (isAbort) {
throw error;
}
return {
llmContent: `Subagent '${this.definition.name}' failed. Error: ${errorMessage}`,
returnDisplay: `Subagent Failed: ${this.definition.name}\nError: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.EXECUTION_FAILED,
},
returnDisplay: progress,
// We omit the 'error' property so that the UI renders our rich returnDisplay
// instead of the raw error message. The llmContent still informs the agent of the failure.
};
}
}
@@ -120,6 +120,16 @@ describe('SubAgentInvocation', () => {
);
});
it('should return the correct description', () => {
const tool = new SubagentTool(testDefinition, mockConfig, mockMessageBus);
const params = {};
// @ts-expect-error - accessing protected method for testing
const invocation = tool.createInvocation(params, mockMessageBus);
expect(invocation.getDescription()).toBe(
"Delegating to agent 'LocalAgent'",
);
});
it('should delegate shouldConfirmExecute to the inner sub-invocation (remote)', async () => {
const tool = new SubagentTool(
testRemoteDefinition,
+2 -2
View File
@@ -12,8 +12,8 @@ import {
BaseToolInvocation,
type ToolCallConfirmationDetails,
isTool,
type ToolLiveOutput,
} from '../tools/tools.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import type { Config } from '../config/config.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { AgentDefinition, AgentInputs } from './types.js';
@@ -155,7 +155,7 @@ class SubAgentInvocation extends BaseToolInvocation<AgentInputs, ToolResult> {
async execute(
signal: AbortSignal,
updateOutput?: (output: string | AnsiOutput) => void,
updateOutput?: (output: ToolLiveOutput) => void,
): Promise<ToolResult> {
const validationError = SchemaValidator.validate(
this.definition.inputConfig.inputSchema,
+26
View File
@@ -71,6 +71,32 @@ export interface SubagentActivityEvent {
data: Record<string, unknown>;
}
export interface SubagentActivityItem {
id: string;
type: 'thought' | 'tool_call';
content: string;
displayName?: string;
description?: string;
args?: string;
status: 'running' | 'completed' | 'error' | 'cancelled';
}
export interface SubagentProgress {
isSubagentProgress: true;
agentName: string;
recentActivity: SubagentActivityItem[];
state?: 'running' | 'completed' | 'error' | 'cancelled';
}
export function isSubagentProgress(obj: unknown): obj is SubagentProgress {
return (
typeof obj === 'object' &&
obj !== null &&
'isSubagentProgress' in obj &&
obj.isSubagentProgress === true
);
}
/**
* The base definition for an agent.
* @template TOutput The specific Zod schema for the agent's final output object.