feat: subagent feature wip

This commit is contained in:
tanzhenxin
2025-09-10 13:41:28 +08:00
parent 549f296eb5
commit 6b09aee32b
30 changed files with 329 additions and 239 deletions

View File

@@ -49,7 +49,6 @@ export type {
RunConfig,
ToolConfig,
SubagentTerminateMode,
OutputObject,
} from './types.js';
export { SubAgentScope } from './subagent.js';

View File

@@ -412,7 +412,7 @@ describe('subagent.ts', () => {
await expect(scope.runNonInteractive(context)).rejects.toThrow(
'Missing context values for the following keys: missing',
);
expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.ERROR);
expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.ERROR);
});
it('should validate that systemPrompt and initialMessages are mutually exclusive', async () => {
@@ -434,7 +434,7 @@ describe('subagent.ts', () => {
await expect(agent.runNonInteractive(context)).rejects.toThrow(
'PromptConfig cannot have both `systemPrompt` and `initialMessages` defined.',
);
expect(agent.output.terminate_reason).toBe(SubagentTerminateMode.ERROR);
expect(agent.getTerminateMode()).toBe(SubagentTerminateMode.ERROR);
});
});
@@ -457,8 +457,7 @@ describe('subagent.ts', () => {
await scope.runNonInteractive(new ContextState());
expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.GOAL);
expect(scope.output.result).toBe('Done.');
expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.GOAL);
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
// Check the initial message
expect(mockSendMessageStream.mock.calls[0][0].message).toEqual([
@@ -482,8 +481,7 @@ describe('subagent.ts', () => {
await scope.runNonInteractive(new ContextState());
expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.GOAL);
expect(scope.output.result).toBe('Done.');
expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.GOAL);
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
});
@@ -549,7 +547,7 @@ describe('subagent.ts', () => {
{ text: 'file1.txt\nfile2.ts' },
]);
expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.GOAL);
expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.GOAL);
});
it('should provide specific tool error responses to the model', async () => {
@@ -645,9 +643,7 @@ describe('subagent.ts', () => {
await scope.runNonInteractive(new ContextState());
expect(mockSendMessageStream).toHaveBeenCalledTimes(2);
expect(scope.output.terminate_reason).toBe(
SubagentTerminateMode.MAX_TURNS,
);
expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.MAX_TURNS);
});
it('should terminate with TIMEOUT if the time limit is reached during an LLM call', async () => {
@@ -690,9 +686,7 @@ describe('subagent.ts', () => {
await runPromise;
expect(scope.output.terminate_reason).toBe(
SubagentTerminateMode.TIMEOUT,
);
expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.TIMEOUT);
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
vi.useRealTimers();
@@ -713,7 +707,7 @@ describe('subagent.ts', () => {
await expect(
scope.runNonInteractive(new ContextState()),
).rejects.toThrow('API Failure');
expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.ERROR);
expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.ERROR);
});
});
});

View File

@@ -20,7 +20,6 @@ import {
} from '@google/genai';
import { GeminiChat } from '../core/geminiChat.js';
import {
OutputObject,
SubagentTerminateMode,
PromptConfig,
ModelConfig,
@@ -150,10 +149,6 @@ function templateString(template: string, context: ContextState): string {
* runtime context, and the collection of its outputs.
*/
export class SubAgentScope {
output: OutputObject = {
terminate_reason: SubagentTerminateMode.ERROR,
result: '',
};
executionStats: ExecutionStats = {
startTimeMs: 0,
totalDurationMs: 0,
@@ -179,6 +174,7 @@ export class SubAgentScope {
>();
private eventEmitter?: SubAgentEventEmitter;
private finalText: string = '';
private terminateMode: SubagentTerminateMode = SubagentTerminateMode.ERROR;
private readonly stats = new SubagentStatistics();
private hooks?: SubagentHooks;
private readonly subagentId: string;
@@ -312,14 +308,18 @@ export class SubAgentScope {
const chat = await this.createChatObject(context);
if (!chat) {
this.output.terminate_reason = SubagentTerminateMode.ERROR;
this.terminateMode = SubagentTerminateMode.ERROR;
return;
}
const abortController = new AbortController();
const onAbort = () => abortController.abort();
if (externalSignal) {
if (externalSignal.aborted) abortController.abort();
if (externalSignal.aborted) {
abortController.abort();
this.terminateMode = SubagentTerminateMode.CANCELLED;
return;
}
externalSignal.addEventListener('abort', onAbort, { once: true });
}
const toolRegistry = this.runtimeContext.getToolRegistry();
@@ -381,7 +381,7 @@ export class SubAgentScope {
this.runConfig.max_turns &&
turnCounter >= this.runConfig.max_turns
) {
this.output.terminate_reason = SubagentTerminateMode.MAX_TURNS;
this.terminateMode = SubagentTerminateMode.MAX_TURNS;
break;
}
let durationMin = (Date.now() - startTime) / (1000 * 60);
@@ -389,7 +389,7 @@ export class SubAgentScope {
this.runConfig.max_time_minutes &&
durationMin >= this.runConfig.max_time_minutes
) {
this.output.terminate_reason = SubagentTerminateMode.TIMEOUT;
this.terminateMode = SubagentTerminateMode.TIMEOUT;
break;
}
@@ -418,7 +418,10 @@ export class SubAgentScope {
let lastUsage: GenerateContentResponseUsageMetadata | undefined =
undefined;
for await (const resp of responseStream) {
if (abortController.signal.aborted) return;
if (abortController.signal.aborted) {
this.terminateMode = SubagentTerminateMode.CANCELLED;
return;
}
if (resp.functionCalls) functionCalls.push(...resp.functionCalls);
const content = resp.candidates?.[0]?.content;
const parts = content?.parts || [];
@@ -443,7 +446,7 @@ export class SubAgentScope {
this.runConfig.max_time_minutes &&
durationMin >= this.runConfig.max_time_minutes
) {
this.output.terminate_reason = SubagentTerminateMode.TIMEOUT;
this.terminateMode = SubagentTerminateMode.TIMEOUT;
break;
}
@@ -483,8 +486,7 @@ export class SubAgentScope {
// No tool calls — treat this as the model's final answer.
if (roundText && roundText.trim().length > 0) {
this.finalText = roundText.trim();
this.output.result = this.finalText;
this.output.terminate_reason = SubagentTerminateMode.GOAL;
this.terminateMode = SubagentTerminateMode.GOAL;
break;
}
// Otherwise, nudge the model to finalize a result.
@@ -508,7 +510,7 @@ export class SubAgentScope {
}
} catch (error) {
console.error('Error during subagent execution:', error);
this.output.terminate_reason = SubagentTerminateMode.ERROR;
this.terminateMode = SubagentTerminateMode.ERROR;
this.eventEmitter?.emit(SubAgentEventType.ERROR, {
subagentId: this.subagentId,
error: error instanceof Error ? error.message : String(error),
@@ -529,7 +531,7 @@ export class SubAgentScope {
const summary = this.stats.getSummary(Date.now());
this.eventEmitter?.emit(SubAgentEventType.FINISH, {
subagentId: this.subagentId,
terminate_reason: this.output.terminate_reason,
terminate_reason: this.terminateMode,
timestamp: Date.now(),
rounds: summary.rounds,
totalDurationMs: summary.totalDurationMs,
@@ -541,14 +543,13 @@ export class SubAgentScope {
totalTokens: summary.totalTokens,
} as SubAgentFinishEvent);
// Log telemetry for subagent completion
const completionEvent = new SubagentExecutionEvent(
this.name,
this.output.terminate_reason === SubagentTerminateMode.GOAL
this.terminateMode === SubagentTerminateMode.GOAL
? 'completed'
: 'failed',
{
terminate_reason: this.output.terminate_reason,
terminate_reason: this.terminateMode,
result: this.finalText,
execution_summary: this.stats.formatCompact(
'Subagent execution completed',
@@ -560,7 +561,7 @@ export class SubAgentScope {
await this.hooks?.onStop?.({
subagentId: this.subagentId,
name: this.name,
terminateReason: this.output.terminate_reason,
terminateReason: this.terminateMode,
summary: summary as unknown as Record<string, unknown>,
timestamp: Date.now(),
});
@@ -751,6 +752,10 @@ export class SubAgentScope {
return this.finalText;
}
getTerminateMode(): SubagentTerminateMode {
return this.terminateMode;
}
private async createChatObject(context: ContextState) {
if (!this.promptConfig.systemPrompt && !this.promptConfig.initialMessages) {
throw new Error(

View File

@@ -183,24 +183,10 @@ export enum SubagentTerminateMode {
* Indicates that the subagent's execution terminated because it exceeded the maximum number of turns.
*/
MAX_TURNS = 'MAX_TURNS',
}
/**
* Represents the output structure of a subagent's execution.
* This interface defines the data that a subagent will return upon completion,
* including the final result and the reason for its termination.
*/
export interface OutputObject {
/**
* The final result text returned by the subagent upon completion.
* This contains the direct output from the model's final response.
* Indicates that the subagent's execution was cancelled via an abort signal.
*/
result: string;
/**
* The reason for the subagent's termination, indicating whether it completed
* successfully, timed out, or encountered an error.
*/
terminate_reason: SubagentTerminateMode;
CANCELLED = 'CANCELLED',
}
/**