Files
gemini-cli/packages/core/src/agents/remote-invocation.ts
T
Adam Weidman f1a3387160 feat(core): wrap local and remote subagents behind AgentProtocol/AgentSession
Introduce LocalSubagentProtocol and RemoteSubagentProtocol that implement
AgentProtocol, wrapping LocalAgentExecutor and A2A client streaming
respectively. LocalSubagentSession and RemoteSubagentSession extend
AgentSession and are the public entry points.

- LocalSubagentProtocol: translates SubagentActivityEvent -> AgentEvent
  (THOUGHT_CHUNK->message/thought, TOOL_CALL_START->tool_request,
  TOOL_CALL_END->tool_response, ERROR->error). Accepts optional
  rawActivityCallback for rich SubagentProgress display without losing
  displayName/errorType detail that AgentEvent types do not carry.

- RemoteSubagentProtocol: wraps A2A sendMessageStream, maintains
  contextId/taskId session state, tracks SubagentProgress per chunk
  for error recovery, and returns a ToolResult with proper
  SubagentProgress as returnDisplay.

- LocalSubagentInvocation: now uses LocalSubagentSession internally,
  preserving all existing SubagentProgress display logic via
  rawActivityCallback. External AbortSignal wired through session.abort().

- RemoteAgentInvocation: now uses RemoteSubagentSession, subscribing
  to message events for live progress updates.

- SubagentToolWrapper and SubagentTool: add optional onAgentEvent
  callback for future parent session observability (currently unused,
  wired through invocation constructors to avoid a second pass later).

- index.ts: export LocalSubagentSession and RemoteSubagentSession.

No behavioral change to SubagentProgress display or ToolResult output.
2026-03-26 12:09:47 -04:00

197 lines
6.0 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
BaseToolInvocation,
type ToolConfirmationOutcome,
type ToolResult,
type ToolCallConfirmationDetails,
type ToolLiveOutput,
} from '../tools/tools.js';
import {
DEFAULT_QUERY_STRING,
type RemoteAgentInputs,
type RemoteAgentDefinition,
type AgentInputs,
type SubagentProgress,
} from './types.js';
import { type AgentLoopContext } from '../config/agent-loop-context.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { A2AAgentError } from './a2a-errors.js';
import { RemoteSubagentSession } from './remote-subagent-protocol.js';
import type { AgentEvent } from '../agent/types.js';
/**
* A tool invocation that proxies to a remote A2A agent.
*
* This implementation delegates execution to {@link RemoteSubagentSession},
* which wraps the A2A client streaming behind the AgentProtocol interface.
*/
export class RemoteAgentInvocation extends BaseToolInvocation<
RemoteAgentInputs,
ToolResult
> {
constructor(
private readonly definition: RemoteAgentDefinition,
private readonly context: AgentLoopContext,
params: AgentInputs,
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
private readonly _onAgentEvent?: (event: AgentEvent) => void,
) {
const query = params['query'] ?? DEFAULT_QUERY_STRING;
if (typeof query !== 'string') {
throw new Error(
`Remote agent '${definition.name}' requires a string 'query' input.`,
);
}
// Safe to pass strict object to super
super(
{ query },
messageBus,
_toolName ?? definition.name,
_toolDisplayName ?? definition.displayName,
);
// Validate that A2AClientManager is available at construction time
if (!this.context.config.getA2AClientManager()) {
throw new Error(
`Failed to initialize RemoteAgentInvocation for '${definition.name}': A2AClientManager is not available.`,
);
}
}
getDescription(): string {
return `Calling remote agent ${this.definition.displayName ?? this.definition.name}`;
}
protected override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
// For now, always require confirmation for remote agents until we have a policy system for them.
return {
type: 'info',
title: `Call Remote Agent: ${this.definition.displayName ?? this.definition.name}`,
prompt: `Calling remote agent: "${this.params.query}"`,
onConfirm: async (_outcome: ToolConfirmationOutcome) => {
// Policy updates are now handled centrally by the scheduler
},
};
}
async execute(
_signal: AbortSignal,
updateOutput?: (output: ToolLiveOutput) => void,
): Promise<ToolResult> {
const agentName = this.definition.displayName ?? this.definition.name;
const session = new RemoteSubagentSession(
this.definition,
this.context,
this.messageBus,
);
// Wire external abort signal to session abort
const abortListener = () => void session.abort();
_signal.addEventListener('abort', abortListener, { once: true });
// Subscribe for parent session observability (future use)
let unsubscribeParent: (() => void) | undefined;
if (this._onAgentEvent) {
unsubscribeParent = session.subscribe(this._onAgentEvent);
}
// Subscribe to message events for live SubagentProgress updates
const unsubscribeProgress = session.subscribe((event: AgentEvent) => {
if (event.type === 'message' && updateOutput) {
const currentProgress = session.getLatestProgress();
if (currentProgress) updateOutput(currentProgress);
}
});
try {
if (updateOutput) {
updateOutput({
isSubagentProgress: true,
agentName,
state: 'running',
recentActivity: [
{
id: 'pending',
type: 'thought',
content: 'Working...',
status: 'running',
},
],
});
}
await session.send({
message: [{ type: 'text', text: this.params.query }],
});
const result = await session.getResult();
// Emit final completed progress
if (updateOutput) {
const finalProgress = session.getLatestProgress();
if (finalProgress) updateOutput(finalProgress);
}
return result;
} catch (error: unknown) {
const partialProgress = session.getLatestProgress();
const partialOutput =
typeof partialProgress?.result === 'string'
? partialProgress.result
: '';
const errorMessage = this.formatExecutionError(error);
const fullDisplay = partialOutput
? `${partialOutput}\n\n${errorMessage}`
: errorMessage;
const errorProgress: SubagentProgress = {
isSubagentProgress: true,
agentName,
state: 'error',
result: fullDisplay,
recentActivity: partialProgress?.recentActivity ?? [],
};
if (updateOutput) {
updateOutput(errorProgress);
}
return {
llmContent: [{ text: fullDisplay }],
returnDisplay: errorProgress,
};
} finally {
_signal.removeEventListener('abort', abortListener);
unsubscribeProgress();
unsubscribeParent?.();
}
}
/**
* Formats an execution error into a user-friendly message.
* Recognizes typed A2AAgentError subclasses and falls back to
* a generic message for unknown errors.
*/
private formatExecutionError(error: unknown): string {
// All A2A-specific errors include a human-friendly `userMessage` on the
// A2AAgentError base class. Rely on that to avoid duplicating messages
// for specific subclasses, which improves maintainability.
if (error instanceof A2AAgentError) {
return error.userMessage;
}
return `Error calling remote agent: ${
error instanceof Error ? error.message : String(error)
}`;
}
}