mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-12 15:10:59 -07:00
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:
15
.dockerignore
Normal file
15
.dockerignore
Normal 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/**
|
||||
28
packages/a2a-server/Dockerfile
Normal file
28
packages/a2a-server/Dockerfile
Normal 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"]
|
||||
35
packages/a2a-server/cloudbuild.yaml
Normal file
35
packages/a2a-server/cloudbuild.yaml
Normal 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'
|
||||
72
packages/a2a-server/k8s/deployment.yaml
Normal file
72
packages/a2a-server/k8s/deployment.yaml
Normal 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
|
||||
157
packages/a2a-server/src/a2ui/a2ui-components.ts
Normal file
157
packages/a2a-server/src/a2ui/a2ui-components.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
194
packages/a2a-server/src/a2ui/a2ui-extension.ts
Normal file
194
packages/a2a-server/src/a2ui/a2ui-extension.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
468
packages/a2a-server/src/a2ui/a2ui-surface-manager.ts
Normal file
468
packages/a2a-server/src/a2ui/a2ui-surface-manager.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
Reference in New Issue
Block a user