File size: 9,028 Bytes
7a1ad33 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 | /**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
BaseToolInvocation,
type ToolConfirmationOutcome,
type ToolResult,
type ToolCallConfirmationDetails,
type ExecuteOptions,
} from '../tools/tools.js';
import {
DEFAULT_QUERY_STRING,
type RemoteAgentInputs,
type RemoteAgentDefinition,
type AgentInputs,
type SubagentProgress,
type SubagentActivityItem,
SubagentState,
getRemoteAgentTargetUrl,
} 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';
/** Optional configuration for remote agent invocations. */
export interface SubagentInvocationOptions {
toolName?: string;
toolDisplayName?: string;
onAgentEvent?: (event: AgentEvent) => void;
}
/**
* Session-based remote agent invocation.
*
* This implementation delegates execution to {@link RemoteSubagentSession},
* which wraps the A2A client streaming behind the AgentProtocol interface.
*
* Cross-invocation A2A session state (contextId/taskId) is persisted via a
* static map keyed by a composite of agent name and target URL. This ensures
* agents with the same name but different endpoints maintain independent state.
*/
export class RemoteSessionInvocation extends BaseToolInvocation<
RemoteAgentInputs,
ToolResult
> {
// Persist A2A conversation state across ephemeral invocation instances.
// Keyed by composite of name + target URL so agents with the same name
// but different endpoints don't share state.
private static readonly sessionState = new Map<
string,
{ contextId?: string; taskId?: string }
>();
/**
* Builds a composite key for the sessionState map.
* Format: `name::targetUrl` (or just `name` if no URL can be derived).
*/
private static sessionKey(definition: RemoteAgentDefinition): string {
const url = getRemoteAgentTargetUrl(definition);
return url ? `${definition.name}::${url}` : definition.name;
}
private readonly _onAgentEvent?: (event: AgentEvent) => void;
constructor(
private readonly definition: RemoteAgentDefinition,
private readonly context: AgentLoopContext,
params: AgentInputs,
messageBus: MessageBus,
options?: SubagentInvocationOptions,
) {
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,
options?.toolName ?? definition.name,
options?.toolDisplayName ?? definition.displayName,
);
this._onAgentEvent = options?.onAgentEvent;
// Validate that A2AClientManager is available at construction time
if (!this.context.config.getA2AClientManager()) {
throw new Error(
`Failed to initialize RemoteSessionInvocation 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> {
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(options: ExecuteOptions): Promise<ToolResult> {
const { abortSignal: _signal, updateOutput } = options;
const agentName = this.definition.displayName ?? this.definition.name;
const emptyActivity: SubagentActivityItem[] = [];
// Seed session with prior A2A conversation state
const stateKey = RemoteSessionInvocation.sessionKey(this.definition);
const priorState = RemoteSessionInvocation.sessionState.get(stateKey);
const session = new RemoteSubagentSession(
this.definition,
this.context,
this.messageBus,
priorState,
);
// Wire external abort signal to session abort
const abortListener = () => void session.abort();
_signal?.addEventListener('abort', abortListener, { once: true });
// Subscribe for parent session observability
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: SubagentState.RUNNING,
recentActivity: [
{
id: 'pending',
type: 'thought',
content: 'Working...',
status: SubagentState.RUNNING,
},
],
});
}
await session.send({
message: { content: [{ type: 'text', text: this.params.query }] },
});
const result = await session.getResult();
// The protocol resolves aborts with an empty result rather than
// rejecting. Detect this and surface proper error state.
if (_signal?.aborted) {
const partialProgress = session.getLatestProgress();
const recentActivity = this.stopRunningActivities(
partialProgress?.recentActivity ?? emptyActivity,
SubagentState.CANCELLED,
);
const errorProgress: SubagentProgress = {
isSubagentProgress: true,
agentName,
state: SubagentState.CANCELLED,
result:
typeof partialProgress?.result === 'string'
? partialProgress.result
: '',
recentActivity,
};
if (updateOutput) updateOutput(errorProgress);
return {
llmContent: [{ text: 'Operation cancelled by user' }],
returnDisplay: errorProgress,
};
}
// 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 isAbort =
(error instanceof Error && error.name === 'AbortError') ||
errorMessage.includes('Aborted');
const status = isAbort ? SubagentState.CANCELLED : SubagentState.ERROR;
const recentActivity = this.stopRunningActivities(
partialProgress?.recentActivity ?? emptyActivity,
status,
);
const errorProgress: SubagentProgress = {
isSubagentProgress: true,
agentName,
state: status,
result: fullDisplay,
recentActivity,
};
if (updateOutput) {
updateOutput(errorProgress);
}
return {
llmContent: [{ text: fullDisplay }],
returnDisplay: errorProgress,
};
} finally {
// Persist A2A state for next invocation — even on abort/error
RemoteSessionInvocation.sessionState.set(
stateKey,
session.getSessionState(),
);
_signal?.removeEventListener('abort', abortListener);
unsubscribeProgress();
unsubscribeParent?.();
}
}
private stopRunningActivities(
activity: SubagentActivityItem[],
status: SubagentState,
): SubagentActivityItem[] {
const result: SubagentActivityItem[] = [];
for (const item of activity) {
result.push(
item.status === SubagentState.RUNNING ? { ...item, status } : item,
);
}
return result;
}
/**
* 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 {
if (error instanceof A2AAgentError) {
return error.userMessage;
}
return `Error calling remote agent: ${
error instanceof Error ? error.message : String(error)
}`;
}
}
|