diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..5cf366bcb9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.github +.gcp +bundle +evals +integration-tests +docs +packages/cli +packages/vscode-ide-companion +packages/test-utils +**/*.test.ts +**/*.test.js +**/src/**/*.ts +!packages/a2a-server/dist/** +!packages/core/dist/** diff --git a/packages/a2a-server/Dockerfile b/packages/a2a-server/Dockerfile new file mode 100644 index 0000000000..c5014281e6 --- /dev/null +++ b/packages/a2a-server/Dockerfile @@ -0,0 +1,28 @@ +# Pre-built production image for a2a-server +# Used with Cloud Build: npm install + build runs in step 1, then Docker copies artifacts +FROM docker.io/library/node:20-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 curl git jq ripgrep ca-certificates \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy everything including pre-installed node_modules and pre-built dist +COPY package.json package-lock.json ./ +COPY node_modules/ node_modules/ +COPY packages/core/ packages/core/ +COPY packages/a2a-server/ packages/a2a-server/ + +# Create workspace directory for agent operations +RUN mkdir -p /workspace && chown -R node:node /workspace + +USER node + +ENV CODER_AGENT_WORKSPACE_PATH=/workspace +ENV CODER_AGENT_PORT=8080 +ENV NODE_ENV=production + +EXPOSE 8080 + +CMD ["node", "packages/a2a-server/dist/src/http/server.js"] diff --git a/packages/a2a-server/cloudbuild.yaml b/packages/a2a-server/cloudbuild.yaml new file mode 100644 index 0000000000..13459b8752 --- /dev/null +++ b/packages/a2a-server/cloudbuild.yaml @@ -0,0 +1,35 @@ +steps: + # Step 1: Install all dependencies and build + - name: 'node:20-slim' + entrypoint: 'bash' + args: + - '-c' + - | + apt-get update && apt-get install -y python3 make g++ git + npm pkg delete scripts.prepare + npm install + npm run build + env: + - 'HUSKY=0' + + # Step 2: Build Docker image (using pre-built dist/ from step 1) + - name: 'gcr.io/cloud-builders/docker' + args: + - 'build' + - '-t' + - 'us-central1-docker.pkg.dev/$PROJECT_ID/gemini-a2a/a2a-server:latest' + - '-f' + - 'packages/a2a-server/Dockerfile' + - '.' + + # Step 3: Push to Artifact Registry + - name: 'gcr.io/cloud-builders/docker' + args: + - 'push' + - 'us-central1-docker.pkg.dev/$PROJECT_ID/gemini-a2a/a2a-server:latest' + +images: + - 'us-central1-docker.pkg.dev/$PROJECT_ID/gemini-a2a/a2a-server:latest' +timeout: '1800s' +options: + machineType: 'E2_HIGHCPU_8' diff --git a/packages/a2a-server/k8s/deployment.yaml b/packages/a2a-server/k8s/deployment.yaml new file mode 100644 index 0000000000..bda37203d3 --- /dev/null +++ b/packages/a2a-server/k8s/deployment.yaml @@ -0,0 +1,72 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: gemini-a2a-server + labels: + app: gemini-a2a-server +spec: + replicas: 1 + selector: + matchLabels: + app: gemini-a2a-server + template: + metadata: + labels: + app: gemini-a2a-server + spec: + containers: + - name: a2a-server + image: us-central1-docker.pkg.dev/adamfweidman-test/gemini-a2a/a2a-server:latest + ports: + - containerPort: 8080 + protocol: TCP + env: + - name: CODER_AGENT_PORT + value: "8080" + - name: CODER_AGENT_HOST + value: "0.0.0.0" + - name: CODER_AGENT_WORKSPACE_PATH + value: "/workspace" + - name: GEMINI_API_KEY + valueFrom: + secretKeyRef: + name: gemini-secrets + key: api-key + - name: GEMINI_YOLO_MODE + value: "true" + - name: NODE_ENV + value: "production" + resources: + requests: + cpu: "500m" + memory: "512Mi" + limits: + cpu: "2000m" + memory: "2Gi" + readinessProbe: + httpGet: + path: /.well-known/agent-card.json + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /.well-known/agent-card.json + port: 8080 + initialDelaySeconds: 15 + periodSeconds: 30 +--- +apiVersion: v1 +kind: Service +metadata: + name: gemini-a2a-server + labels: + app: gemini-a2a-server +spec: + type: ClusterIP + selector: + app: gemini-a2a-server + ports: + - port: 80 + targetPort: 8080 + protocol: TCP diff --git a/packages/a2a-server/src/a2ui/a2ui-components.ts b/packages/a2a-server/src/a2ui/a2ui-components.ts new file mode 100644 index 0000000000..348f19231f --- /dev/null +++ b/packages/a2a-server/src/a2ui/a2ui-components.ts @@ -0,0 +1,157 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Builder functions for A2UI standard catalog components. + * These create the component objects that go into updateComponents messages. + */ + +import type { A2UIComponent } from './a2ui-extension.js'; + +// Layout components + +export function column( + id: string, + children: string[], + opts?: { align?: string; justify?: string; weight?: number }, +): A2UIComponent { + return { + id, + component: 'Column', + children, + ...opts, + }; +} + +export function row( + id: string, + children: string[], + opts?: { align?: string; justify?: string }, +): A2UIComponent { + return { + id, + component: 'Row', + children, + ...opts, + }; +} + +export function card( + id: string, + child: string, + opts?: Record, +): A2UIComponent { + return { + id, + component: 'Card', + child, + ...opts, + }; +} + +// Content components + +export function text( + id: string, + textContent: string | { path: string }, + opts?: { variant?: string }, +): A2UIComponent { + return { + id, + component: 'Text', + text: textContent, + ...opts, + }; +} + +export function icon(id: string, name: string): A2UIComponent { + return { + id, + component: 'Icon', + name, + }; +} + +export function divider( + id: string, + axis: 'horizontal' | 'vertical' = 'horizontal', +): A2UIComponent { + return { + id, + component: 'Divider', + axis, + }; +} + +// Interactive components + +export function button( + id: string, + child: string, + action: { + event?: { name: string; context: Record }; + functionCall?: { call: string; args: Record }; + }, + opts?: { variant?: 'primary' | 'borderless' }, +): A2UIComponent { + return { + id, + component: 'Button', + child, + action, + ...opts, + }; +} + +export function textField( + id: string, + label: string, + valuePath: string, + opts?: { + variant?: 'shortText' | 'longText'; + checks?: Array<{ + call: string; + args: Record; + message: string; + }>; + }, +): A2UIComponent { + return { + id, + component: 'TextField', + label, + value: { path: valuePath }, + ...opts, + }; +} + +export function checkBox( + id: string, + label: string, + valuePath: string, +): A2UIComponent { + return { + id, + component: 'CheckBox', + label, + value: { path: valuePath }, + }; +} + +export function choicePicker( + id: string, + options: Array<{ label: string; value: string }>, + valuePath: string, + opts?: { variant?: 'mutuallyExclusive' | 'multiSelect' }, +): A2UIComponent { + return { + id, + component: 'ChoicePicker', + options, + value: { path: valuePath }, + ...opts, + }; +} diff --git a/packages/a2a-server/src/a2ui/a2ui-extension.ts b/packages/a2a-server/src/a2ui/a2ui-extension.ts new file mode 100644 index 0000000000..604519974b --- /dev/null +++ b/packages/a2a-server/src/a2ui/a2ui-extension.ts @@ -0,0 +1,194 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * A2UI (Agent-to-UI) Extension for A2A protocol. + * Implements the A2UI v0.10 specification for generating declarative UI + * messages that clients can render natively. + * + * @see https://a2ui.org/specification/v0_10/docs/a2ui_protocol.md + * @see https://a2ui.org/specification/v0_10/docs/a2ui_extension_specification.md + */ + +import type { Part } from '@a2a-js/sdk'; + +// Extension constants +export const A2UI_EXTENSION_URI = 'https://a2ui.org/a2a-extension/a2ui/v0.10'; +export const A2UI_MIME_TYPE = 'application/json+a2ui'; +export const A2UI_VERSION = 'v0.10'; +export const STANDARD_CATALOG_ID = + 'https://a2ui.org/specification/v0_10/standard_catalog.json'; + +// Metadata keys +export const MIME_TYPE_KEY = 'mimeType'; +export const A2UI_CLIENT_CAPABILITIES_KEY = 'a2uiClientCapabilities'; +export const A2UI_CLIENT_DATA_MODEL_KEY = 'a2uiClientDataModel'; + +/** + * A2UI message types (server-to-client). + */ +export interface CreateSurfaceMessage { + version: typeof A2UI_VERSION; + createSurface: { + surfaceId: string; + catalogId: string; + theme?: Record; + sendDataModel?: boolean; + }; +} + +export interface UpdateComponentsMessage { + version: typeof A2UI_VERSION; + updateComponents: { + surfaceId: string; + components: A2UIComponent[]; + }; +} + +export interface UpdateDataModelMessage { + version: typeof A2UI_VERSION; + updateDataModel: { + surfaceId: string; + path?: string; + value?: unknown; + }; +} + +export interface DeleteSurfaceMessage { + version: typeof A2UI_VERSION; + deleteSurface: { + surfaceId: string; + }; +} + +export type A2UIServerMessage = + | CreateSurfaceMessage + | UpdateComponentsMessage + | UpdateDataModelMessage + | DeleteSurfaceMessage; + +/** + * A2UI component definition. + */ +export interface A2UIComponent { + id: string; + component: string; + [key: string]: unknown; +} + +/** + * A2UI client-to-server action message. + */ +export interface A2UIActionMessage { + version: typeof A2UI_VERSION; + action: { + name: string; + surfaceId: string; + sourceComponentId: string; + timestamp: string; + context: Record; + }; +} + +/** + * A2UI client capabilities sent in metadata. + */ +export interface A2UIClientCapabilities { + supportedCatalogIds: string[]; + inlineCatalogs?: unknown[]; +} + +/** + * Creates an A2A DataPart containing A2UI messages. + * Per the spec, the data field contains an ARRAY of A2UI messages. + */ +export function createA2UIPart(messages: A2UIServerMessage[]): Part { + + return { + kind: 'data', + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + data: messages as unknown as Record, + metadata: { + [MIME_TYPE_KEY]: A2UI_MIME_TYPE, + }, + } as Part; +} + +/** + * Creates a single A2A DataPart from one A2UI message. + */ +export function createA2UISinglePart(message: A2UIServerMessage): Part { + return createA2UIPart([message]); +} + +/** + * Checks if an A2A Part contains A2UI data. + */ +export function isA2UIPart(part: Part): boolean { + return ( + part.kind === 'data' && + part.metadata != null && + part.metadata[MIME_TYPE_KEY] === A2UI_MIME_TYPE + ); +} + +/** + * Extracts A2UI action messages from an A2A Part. + */ +export function extractA2UIActions(part: Part): A2UIActionMessage[] { + if (!isA2UIPart(part)) return []; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const data = (part as unknown as { data?: unknown[] }).data; + if (!Array.isArray(data)) return []; + return data.filter( + (msg): msg is A2UIActionMessage => + typeof msg === 'object' && + msg !== null && + 'action' in msg && + 'version' in msg, + ); +} + +/** + * Creates the A2UI AgentExtension configuration for the AgentCard. + */ +export function getA2UIAgentExtension( + supportedCatalogIds: string[] = [STANDARD_CATALOG_ID], + acceptsInlineCatalogs = false, +): { + uri: string; + description: string; + required: boolean; + params: Record; +} { + const params: Record = {}; + if (supportedCatalogIds.length > 0) { + params['supportedCatalogIds'] = supportedCatalogIds; + } + if (acceptsInlineCatalogs) { + params['acceptsInlineCatalogs'] = true; + } + + return { + uri: A2UI_EXTENSION_URI, + description: 'Provides agent driven UI using the A2UI JSON format.', + required: false, + params, + }; +} + +/** + * Checks if the A2UI extension was requested via extension headers or message. + */ +export function isA2UIRequested( + requestedExtensions?: string[], + messageExtensions?: string[], +): boolean { + return ( + (requestedExtensions?.includes(A2UI_EXTENSION_URI) ?? false) || + (messageExtensions?.includes(A2UI_EXTENSION_URI) ?? false) + ); +} diff --git a/packages/a2a-server/src/a2ui/a2ui-surface-manager.ts b/packages/a2a-server/src/a2ui/a2ui-surface-manager.ts new file mode 100644 index 0000000000..1e933a6530 --- /dev/null +++ b/packages/a2a-server/src/a2ui/a2ui-surface-manager.ts @@ -0,0 +1,468 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Manages A2UI surfaces for the Gemini CLI A2A server. + * Creates and updates surfaces for: + * - Tool call approval UIs + * - Agent text/thought streaming displays + * - Task status indicators + */ + +import type { Part } from '@a2a-js/sdk'; +import { logger } from '../utils/logger.js'; +import { + A2UI_VERSION, + STANDARD_CATALOG_ID, + createA2UIPart, + type A2UIServerMessage, + type A2UIComponent, +} from './a2ui-extension.js'; +import { + column, + row, + text, + button, + card, + icon, + divider, +} from './a2ui-components.js'; + +/** + * Generates A2UI parts for tool call approval surfaces. + */ +export function createToolCallApprovalSurface( + taskId: string, + toolCall: { + callId: string; + name: string; + displayName?: string; + description?: string; + args?: Record; + kind?: string; + }, +): Part { + const surfaceId = `tool_approval_${taskId}_${toolCall.callId}`; + const toolDisplayName = toolCall.displayName || toolCall.name; + const argsPreview = toolCall.args + ? JSON.stringify(toolCall.args, null, 2).substring(0, 500) + : 'No arguments'; + + logger.info( + `[A2UI] Creating tool approval surface: ${surfaceId} for tool: ${toolDisplayName}`, + ); + + const messages: A2UIServerMessage[] = [ + // 1. Create the surface + { + version: A2UI_VERSION, + createSurface: { + surfaceId, + catalogId: STANDARD_CATALOG_ID, + theme: { + primaryColor: '#1a73e8', + agentDisplayName: 'Gemini CLI Agent', + }, + sendDataModel: true, + }, + }, + // 2. Define the components + { + version: A2UI_VERSION, + updateComponents: { + surfaceId, + components: buildToolApprovalComponents( + taskId, + toolCall.callId, + toolDisplayName, + toolCall.description || '', + argsPreview, + toolCall.kind || 'tool', + ), + }, + }, + // 3. Populate the data model + { + version: A2UI_VERSION, + updateDataModel: { + surfaceId, + value: { + tool: { + callId: toolCall.callId, + name: toolCall.name, + displayName: toolDisplayName, + description: toolCall.description || '', + args: argsPreview, + kind: toolCall.kind || 'tool', + status: 'awaiting_approval', + }, + taskId, + }, + }, + }, + ]; + + return createA2UIPart(messages); +} + +function buildToolApprovalComponents( + taskId: string, + callId: string, + toolName: string, + description: string, + argsPreview: string, + kind: string, +): A2UIComponent[] { + return [ + // Root card + card('root', 'main_column'), + + // Main vertical layout + column( + 'main_column', + [ + 'header_row', + 'description_text', + 'divider_1', + 'args_label', + 'args_text', + 'divider_2', + 'action_row', + ], + { align: 'stretch' }, + ), + + // Header with icon and tool name + row('header_row', ['tool_icon', 'tool_name_text'], { + align: 'center', + }), + icon('tool_icon', kind === 'shell' ? 'terminal' : 'build'), + text('tool_name_text', `**${toolName}** requires approval`, { + variant: 'h3', + }), + + // Description + text( + 'description_text', + description || 'This tool needs your permission to execute.', + ), + + divider('divider_1'), + + // Arguments preview + text('args_label', '**Arguments:**', { variant: 'caption' }), + text('args_text', `\`\`\`\n${argsPreview}\n\`\`\``), + + divider('divider_2'), + + // Action buttons row + row( + 'action_row', + ['approve_button', 'approve_always_button', 'reject_button'], + { justify: 'spaceBetween' }, + ), + + // Approve button + text('approve_label', 'Approve'), + button( + 'approve_button', + 'approve_label', + { + event: { + name: 'tool_confirmation', + context: { + taskId, + callId, + outcome: 'proceed_once', + }, + }, + }, + { variant: 'primary' }, + ), + + // Approve always button + text('approve_always_label', 'Always Allow'), + button('approve_always_button', 'approve_always_label', { + event: { + name: 'tool_confirmation', + context: { + taskId, + callId, + outcome: 'proceed_always_tool', + }, + }, + }), + + // Reject button + text('reject_label', 'Reject'), + button('reject_button', 'reject_label', { + event: { + name: 'tool_confirmation', + context: { + taskId, + callId, + outcome: 'cancel', + }, + }, + }), + ]; +} + +/** + * Creates an A2UI surface update for tool execution status. + */ +export function updateToolCallStatus( + taskId: string, + callId: string, + status: string, + output?: string, +): Part { + const surfaceId = `tool_approval_${taskId}_${callId}`; + + logger.info( + `[A2UI] Updating tool status surface: ${surfaceId} status: ${status}`, + ); + + const messages: A2UIServerMessage[] = [ + { + version: A2UI_VERSION, + updateDataModel: { + surfaceId, + path: '/tool/status', + value: status, + }, + }, + ]; + + // If tool completed, update the UI to show result + if (['success', 'error', 'cancelled'].includes(status)) { + messages.push({ + version: A2UI_VERSION, + updateComponents: { + surfaceId, + components: [ + // Replace action row with status indicator + row('action_row', ['status_icon', 'status_text'], { + align: 'center', + }), + icon( + 'status_icon', + status === 'success' + ? 'check_circle' + : status === 'error' + ? 'error' + : 'cancel', + ), + text( + 'status_text', + status === 'success' + ? 'Tool executed successfully' + : status === 'error' + ? 'Tool execution failed' + : 'Tool execution cancelled', + ), + ], + }, + }); + + if (output) { + messages.push({ + version: A2UI_VERSION, + updateDataModel: { + surfaceId, + path: '/tool/output', + value: output, + }, + }); + } + } + + return createA2UIPart(messages); +} + +/** + * Creates an A2UI text content surface for agent messages. + */ +export function createTextContentPart( + taskId: string, + content: string, + surfaceId?: string, +): Part { + const sid = surfaceId || `agent_text_${taskId}`; + + const messages: A2UIServerMessage[] = [ + { + version: A2UI_VERSION, + updateDataModel: { + surfaceId: sid, + path: '/content/text', + value: content, + }, + }, + ]; + + return createA2UIPart(messages); +} + +/** + * Creates the initial agent response surface. + */ +export function createAgentResponseSurface(taskId: string): Part { + const surfaceId = `agent_response_${taskId}`; + + logger.info(`[A2UI] Creating agent response surface: ${surfaceId}`); + + const messages: A2UIServerMessage[] = [ + { + version: A2UI_VERSION, + createSurface: { + surfaceId, + catalogId: STANDARD_CATALOG_ID, + theme: { + primaryColor: '#1a73e8', + agentDisplayName: 'Gemini CLI Agent', + }, + }, + }, + { + version: A2UI_VERSION, + updateComponents: { + surfaceId, + components: [ + card('root', 'response_column'), + column('response_column', ['response_text', 'status_text'], { + align: 'stretch', + }), + text('response_text', { path: '/response/text' }), + text( + 'status_text', + { path: '/response/status' }, + { + variant: 'caption', + }, + ), + ], + }, + }, + { + version: A2UI_VERSION, + updateDataModel: { + surfaceId, + value: { + response: { + text: '', + status: 'Working...', + }, + }, + }, + }, + ]; + + return createA2UIPart(messages); +} + +/** + * Updates the agent response surface with new text content. + */ +export function updateAgentResponseText( + taskId: string, + content: string, + status?: string, +): Part { + const surfaceId = `agent_response_${taskId}`; + + const messages: A2UIServerMessage[] = [ + { + version: A2UI_VERSION, + updateDataModel: { + surfaceId, + path: '/response/text', + value: content, + }, + }, + ]; + + if (status) { + messages.push({ + version: A2UI_VERSION, + updateDataModel: { + surfaceId, + path: '/response/status', + value: status, + }, + }); + } + + return createA2UIPart(messages); +} + +/** + * Creates an A2UI thought surface. + */ +export function createThoughtPart( + taskId: string, + subject: string, + description: string, +): Part { + const surfaceId = `thought_${taskId}_${Date.now()}`; + + const messages: A2UIServerMessage[] = [ + { + version: A2UI_VERSION, + createSurface: { + surfaceId, + catalogId: STANDARD_CATALOG_ID, + theme: { + primaryColor: '#7c4dff', + agentDisplayName: 'Gemini CLI Agent', + }, + }, + }, + { + version: A2UI_VERSION, + updateComponents: { + surfaceId, + components: [ + card('root', 'thought_column'), + column('thought_column', ['thought_icon_row', 'thought_desc'], { + align: 'stretch', + }), + row('thought_icon_row', ['thought_icon', 'thought_subject'], { + align: 'center', + }), + icon('thought_icon', 'psychology'), + text('thought_subject', `*${subject}*`, { variant: 'h4' }), + text('thought_desc', description), + ], + }, + }, + ]; + + return createA2UIPart(messages); +} + +/** + * Deletes a tool approval surface after resolution. + */ +export function deleteToolApprovalSurface( + taskId: string, + callId: string, +): Part { + const surfaceId = `tool_approval_${taskId}_${callId}`; + + logger.info(`[A2UI] Deleting tool approval surface: ${surfaceId}`); + + const messages: A2UIServerMessage[] = [ + { + version: A2UI_VERSION, + deleteSurface: { + surfaceId, + }, + }, + ]; + + return createA2UIPart(messages); +} diff --git a/packages/a2a-server/src/agent/executor.ts b/packages/a2a-server/src/agent/executor.ts index b0522a945f..9cde587118 100644 --- a/packages/a2a-server/src/agent/executor.ts +++ b/packages/a2a-server/src/agent/executor.ts @@ -36,6 +36,10 @@ import { loadExtensions } from '../config/extension.js'; import { Task } from './task.js'; import { requestStorage } from '../http/requestStorage.js'; import { pushTaskStateFailed } from '../utils/executor_utils.js'; +import { + A2UI_CLIENT_CAPABILITIES_KEY, + A2UI_EXTENSION_URI, +} from '../a2ui/a2ui-extension.js'; /** * Provides a wrapper for Task. Passes data from Task to SDKTask. @@ -435,6 +439,22 @@ export class CoderAgentExecutor implements AgentExecutor { const currentTask = wrapper.task; + // Detect A2UI extension activation from the request + // Check if user message metadata contains A2UI client capabilities + // or if the extensions header includes the A2UI URI + const messageMetadata = userMessage.metadata; + const hasA2UICapabilities = + messageMetadata?.[A2UI_CLIENT_CAPABILITIES_KEY] != null; + // Also check if extension URI is referenced in message extensions + const messageExtensions = messageMetadata?.['extensions']; + const hasA2UIExtension = + Array.isArray(messageExtensions) && + messageExtensions.includes(A2UI_EXTENSION_URI); + if (hasA2UICapabilities || hasA2UIExtension) { + currentTask.a2uiEnabled = true; + logger.info(`[CoderAgentExecutor] A2UI enabled for task ${taskId}`); + } + if (['canceled', 'failed', 'completed'].includes(currentTask.taskState)) { logger.warn( `[CoderAgentExecutor] Attempted to execute task ${taskId} which is already in state ${currentTask.taskState}. Ignoring.`, @@ -552,6 +572,9 @@ export class CoderAgentExecutor implements AgentExecutor { logger.info( `[CoderAgentExecutor] Task ${taskId}: Agent turn finished, setting to input-required.`, ); + // Finalize A2UI surfaces before marking complete + currentTask.finalizeA2UISurfaces(); + const stateChange: StateChange = { kind: CoderAgentEvent.StateChangeEvent, }; diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts index 890bc85b11..96941131f1 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -56,6 +56,15 @@ import type { Citation, } from '../types.js'; import type { PartUnion, Part as genAiPart } from '@google/genai'; +import { + createToolCallApprovalSurface, + updateToolCallStatus, + createAgentResponseSurface, + updateAgentResponseText, + createThoughtPart as createA2UIThoughtPart, + deleteToolApprovalSurface, +} from '../a2ui/a2ui-surface-manager.js'; +import { isA2UIPart, extractA2UIActions } from '../a2ui/a2ui-extension.js'; type UnionKeys = T extends T ? keyof T : never; @@ -75,6 +84,11 @@ export class Task { promptCount = 0; autoExecute: boolean; + // A2UI support + a2uiEnabled = false; + private accumulatedText = ''; + private a2uiResponseSurfaceCreated = false; + // For tool waiting logic private pendingToolCalls: Map = new Map(); //toolCallId --> status private toolCompletionPromise?: Promise; @@ -391,6 +405,44 @@ export class Task { : { kind: CoderAgentEvent.ToolCallUpdateEvent }; const message = this.toolStatusMessage(tc, this.id, this.contextId); + // Add A2UI parts for tool call updates if A2UI is enabled + if (this.a2uiEnabled) { + try { + if (tc.status === 'awaiting_approval') { + const a2uiPart = createToolCallApprovalSurface(this.id, { + callId: tc.request.callId, + name: tc.request.name, + displayName: tc.tool?.displayName || tc.tool?.name, + description: tc.tool?.description, + args: tc.request.args as Record | undefined, + kind: tc.tool?.kind, + }); + message.parts.push(a2uiPart); + logger.info( + `[Task] A2UI: Added tool approval surface for ${tc.request.callId}`, + ); + } else if (['success', 'error', 'cancelled'].includes(tc.status)) { + const output = + 'liveOutput' in tc ? String(tc.liveOutput) : undefined; + const a2uiPart = updateToolCallStatus( + this.id, + tc.request.callId, + tc.status, + output, + ); + message.parts.push(a2uiPart); + logger.info( + `[Task] A2UI: Updated tool status for ${tc.request.callId}: ${tc.status}`, + ); + } + } catch (a2uiError) { + logger.error( + '[Task] A2UI: Error generating tool call surface:', + a2uiError, + ); + } + } + const event = this._createStatusUpdateEvent( this.taskState, coderAgentMessage, @@ -954,7 +1006,66 @@ export class Task { let anyConfirmationHandled = false; let hasContentForLlm = false; + // Reset A2UI accumulated text for new user turn + if (this.a2uiEnabled) { + this.accumulatedText = ''; + this.a2uiResponseSurfaceCreated = false; + } + for (const part of userMessage.parts) { + // Handle A2UI action messages (e.g., button clicks for tool approval) + if (this.a2uiEnabled && isA2UIPart(part)) { + const actions = extractA2UIActions(part); + for (const action of actions) { + if (action.action.name === 'tool_confirmation') { + const ctx = action.action.context; + // Convert A2UI action to a tool confirmation data part + const syntheticPart: Part = { + kind: 'data', + data: { + callId: ctx['callId'], + outcome: ctx['outcome'], + }, + } as Part; + const handled = + await this._handleToolConfirmationPart(syntheticPart); + if (handled) { + anyConfirmationHandled = true; + // Emit a delete surface part for the approval UI + try { + const deletePart = deleteToolApprovalSurface( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (ctx['taskId'] as string) || this.id, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + ctx['callId'] as string, + ); + const deleteMessage: Message = { + kind: 'message', + role: 'agent', + parts: [deletePart], + messageId: uuidv4(), + taskId: this.id, + contextId: this.contextId, + }; + const event = this._createStatusUpdateEvent( + this.taskState, + { kind: CoderAgentEvent.ToolCallUpdateEvent }, + deleteMessage, + false, + ); + this.eventBus?.publish(event); + } catch (a2uiError) { + logger.error( + '[Task] A2UI: Error deleting approval surface:', + a2uiError, + ); + } + } + } + } + continue; + } + const confirmationHandled = await this._handleToolConfirmationPart(part); if (confirmationHandled) { anyConfirmationHandled = true; @@ -1020,6 +1131,33 @@ export class Task { } logger.info('[Task] Sending text content to event bus.'); const message = this._createTextMessage(content); + + // Add A2UI response surface parts if A2UI is enabled + if (this.a2uiEnabled) { + try { + this.accumulatedText += content; + if (!this.a2uiResponseSurfaceCreated) { + const surfacePart = createAgentResponseSurface(this.id); + message.parts.push(surfacePart); + this.a2uiResponseSurfaceCreated = true; + logger.info( + `[Task] A2UI: Created agent response surface for task ${this.id}`, + ); + } + const updatePart = updateAgentResponseText( + this.id, + this.accumulatedText, + 'Working...', + ); + message.parts.push(updatePart); + } catch (a2uiError) { + logger.error( + '[Task] A2UI: Error generating text content surface:', + a2uiError, + ); + } + } + const textContent: TextContent = { kind: CoderAgentEvent.TextContentEvent, }; @@ -1041,15 +1179,35 @@ export class Task { return; } logger.info('[Task] Sending thought to event bus.'); + const parts: Part[] = [ + { + kind: 'data', + data: content, + } as Part, + ]; + + // Add A2UI thought surface if A2UI is enabled + if (this.a2uiEnabled) { + try { + const a2uiPart = createA2UIThoughtPart( + this.id, + content.subject || 'Thinking...', + content.description || '', + ); + parts.push(a2uiPart); + logger.info(`[Task] A2UI: Added thought surface for task ${this.id}`); + } catch (a2uiError) { + logger.error( + '[Task] A2UI: Error generating thought surface:', + a2uiError, + ); + } + } + const message: Message = { kind: 'message', role: 'agent', - parts: [ - { - kind: 'data', - data: content, - } as Part, - ], + parts, messageId: uuidv4(), taskId: this.id, contextId: this.contextId, @@ -1070,6 +1228,43 @@ export class Task { ); } + /** + * Finalizes A2UI surfaces when the agent turn is complete. + * Updates the response surface status to "Done". + */ + finalizeA2UISurfaces(): void { + if (!this.a2uiEnabled || !this.a2uiResponseSurfaceCreated) { + return; + } + try { + const finalPart = updateAgentResponseText( + this.id, + this.accumulatedText, + 'Done', + ); + const message: Message = { + kind: 'message', + role: 'agent', + parts: [finalPart], + messageId: uuidv4(), + taskId: this.id, + contextId: this.contextId, + }; + const event = this._createStatusUpdateEvent( + this.taskState, + { kind: CoderAgentEvent.TextContentEvent }, + message, + false, + ); + this.eventBus?.publish(event); + logger.info( + `[Task] A2UI: Finalized response surface for task ${this.id}`, + ); + } catch (a2uiError) { + logger.error('[Task] A2UI: Error finalizing surfaces:', a2uiError); + } + } + _sendCitation(citation: string) { if (!citation || citation.trim() === '') { return; diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts index c061d4e3b3..064a65d418 100644 --- a/packages/a2a-server/src/http/app.ts +++ b/packages/a2a-server/src/http/app.ts @@ -28,6 +28,7 @@ import { commandRegistry } from '../commands/command-registry.js'; import { debugLogger, SimpleExtensionLoader } from '@google/gemini-cli-core'; import type { Command, CommandArgument } from '../commands/types.js'; import { GitService } from '@google/gemini-cli-core'; +import { getA2UIAgentExtension } from '../a2ui/a2ui-extension.js'; type CommandResponse = { name: string; @@ -46,11 +47,12 @@ const coderAgentCard: AgentCard = { url: 'https://google.com', }, protocolVersion: '0.3.0', - version: '0.0.2', // Incremented version + version: '0.1.0', // A2UI-enabled version capabilities: { streaming: true, - pushNotifications: false, + pushNotifications: true, stateTransitionHistory: true, + extensions: [getA2UIAgentExtension()], }, securitySchemes: undefined, security: undefined, @@ -330,7 +332,8 @@ export async function main() { const expressApp = await createApp(); const port = Number(process.env['CODER_AGENT_PORT'] || 0); - const server = expressApp.listen(port, 'localhost', () => { + const host = process.env['CODER_AGENT_HOST'] || 'localhost'; + const server = expressApp.listen(port, host, () => { const address = server.address(); let actualPort; if (process.env['CODER_AGENT_PORT']) {