feat: add A2UI extension support and GKE deployment infrastructure

Implement A2UI v0.10 protocol compliance for the a2a-server:
- Add a2ui-extension.ts with constants, Part helpers, extension detection
- Add a2ui-components.ts with standard catalog component builders
- Add a2ui-surface-manager.ts for tool approval, response, thought surfaces
- Integrate A2UI into task.ts and executor.ts
- Add configurable bind host (CODER_AGENT_HOST) for k8s compatibility

Add GKE deployment infrastructure:
- Dockerfile, cloudbuild.yaml, k8s deployment manifest, .dockerignore
This commit is contained in:
Adam Weidman
2026-02-11 19:23:56 -05:00
parent f9fc9335f5
commit 5ea957c84b
10 changed files with 1199 additions and 9 deletions

15
.dockerignore Normal file
View File

@@ -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/**

View File

@@ -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"]

View File

@@ -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'

View File

@@ -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

View File

@@ -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<string, unknown>,
): 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<string, unknown> };
functionCall?: { call: string; args: Record<string, unknown> };
},
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<string, unknown>;
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,
};
}

View File

@@ -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<string, unknown>;
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<string, unknown>;
};
}
/**
* 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<string, unknown>,
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<string, unknown>;
} {
const params: Record<string, unknown> = {};
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)
);
}

View File

@@ -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<string, unknown>;
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);
}

View File

@@ -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,
};

View File

@@ -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> = 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<string, string> = new Map(); //toolCallId --> status
private toolCompletionPromise?: Promise<void>;
@@ -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<string, unknown> | 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;

View File

@@ -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']) {