Skip to content

Commit 1772b95

Browse files
feat: add A2a agent quickstart sample (#277)
* feat: add A2A agent quickstart sample * remove dead code
1 parent 60989f8 commit 1772b95

File tree

8 files changed

+5585
-0
lines changed

8 files changed

+5585
-0
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"scriptId": "1e8Fa-KxSSdMAKa8vAvOOg6jBrpOU_ygtAheMkktCHU4d5M_M6lJk_uy6"}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
// Copyright 2025 Google LLC. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the 'License');
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an 'AS IS' BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
////////////////////////////////////////////////////
16+
// --- A2A-wrapped ADK AI Agent handling logic ---
17+
////////////////////////////////////////////////////
18+
19+
// Sends a request to the AI agent and processes the response using the agent
20+
function requestAgent(userName, input) {
21+
// Sync call that sends the message to the agent
22+
const sendResponseContentText = UrlFetchApp.fetch(
23+
`https://${getLocation()}-aiplatform.googleapis.com/v1beta1/${getReasoningEngine()}/a2a/v1/message:send`,
24+
{
25+
method: 'post',
26+
headers: { 'Authorization': `Bearer ${getCredentials().getAccessToken()}` },
27+
contentType: 'application/json',
28+
payload: JSON.stringify({
29+
"message": {
30+
"messageId": Utilities.getUuid(),
31+
"role": "1",
32+
"content": extractContentFromInput(input)
33+
}
34+
}),
35+
muteHttpExceptions: true
36+
}
37+
).getContentText();
38+
if (isInDebugMode()) {
39+
console.log("Send response: " + sendResponseContentText);
40+
}
41+
42+
// Retrieve the ID of the resulting task
43+
const sendResponse = JSON.parse(sendResponseContentText);
44+
taskId = sendResponse.task.id;
45+
console.log(`The agent started the task ${taskId}.`);
46+
47+
// Poll task status until it's in a final state
48+
let processedMessageIds = [];
49+
let taskResponseStatus = null;
50+
do {
51+
Utilities.sleep(1000); // Wait a bit before polling
52+
const taskResponseContentText = UrlFetchApp.fetch(
53+
`https://${getLocation()}-aiplatform.googleapis.com/v1beta1/${getReasoningEngine()}/a2a/v1/tasks/${taskId}?history_length=1`,
54+
{
55+
method: 'get',
56+
headers: { 'Authorization': `Bearer ${getCredentials().getAccessToken()}` },
57+
contentType: 'application/json',
58+
muteHttpExceptions: true
59+
}
60+
).getContentText();
61+
if (isInDebugMode()) {
62+
console.log("Get task response: " + taskResponseContentText);
63+
}
64+
const taskResponse = JSON.parse(taskResponseContentText);
65+
66+
// Retrieve messages already processed
67+
const history = taskResponse.history || [];
68+
const pastMessages = history.filter(entry => {
69+
return entry.role === "ROLE_AGENT" && processedMessageIds.includes(entry.messageId);
70+
});
71+
72+
// Retrieve new messages to process
73+
const newMessages = history.filter(entry => {
74+
return entry.role === "ROLE_AGENT" && !processedMessageIds.includes(entry.messageId);
75+
});
76+
77+
// Process new messages
78+
let nextSubAgentSeqIndex = pastMessages.length;
79+
for (const newMessage of newMessages) {
80+
if (isInDebugMode()) {
81+
console.log("Processing new message: " + JSON.stringify(newMessage));
82+
}
83+
84+
// Retrieve the agent responsible for generating this message
85+
const author = SUBAGENT_SEQ[nextSubAgentSeqIndex];
86+
87+
// Handle text answers
88+
const text = newMessage.content[0].text;
89+
if (text) {
90+
console.log(`${author}: ${text}`);
91+
answer(author, text, taskResponse.metadata.adk_grounding_metadata);
92+
}
93+
94+
// Update client processing status
95+
processedMessageIds.push(newMessage.messageId);
96+
nextSubAgentSeqIndex++;
97+
}
98+
taskResponseStatus = taskResponse.status.state;
99+
// See https://agent2agent.info/docs/concepts/task/#task-state-taskstate
100+
} while(['TASK_STATE_SUBMITTED', 'TASK_STATE_WORKING'].includes(taskResponseStatus));
101+
}
102+
103+
// Transforms the user input to AI message with contents.
104+
function extractContentFromInput(input) {
105+
// For Chat host apps, the input can contain text and attachments
106+
const parts = [{ "text": input.text }];
107+
if (input.attachment && Array.isArray(input.attachment)) {
108+
for (const attachment of input.attachment) {
109+
parts.push({ "file": {
110+
"mime_type": attachment.contentType,
111+
"file_with_bytes": Utilities.base64Encode(downloadChatAttachment(
112+
attachment.attachmentDataRef.resourceName
113+
))
114+
}});
115+
}
116+
}
117+
return parts;
118+
}
119+
120+
// Sends an answer as a Chat message.
121+
function answer(author, text, groundingMetadata) {
122+
const widgets = getAgentResponseWidgets(author, text, groundingMetadata);
123+
createMessage(buildMessage(author, [wrapWidgetsInCardsV2(widgets)]));
124+
}
125+
126+
// --- Utility functions ---
127+
128+
// Builds a Chat message for the given author, text, and cards_v2.
129+
function buildMessage(author, cardsV2) {
130+
const messageBuilder = CardService.newChatResponseBuilder();
131+
messageBuilder.setText(`${getAuthorEmoji(author)} *${snakeToUserReadable(author)}* ✅`);
132+
cardsV2.forEach(cardV2 => { messageBuilder.addCardsV2(cardV2) });
133+
let message = JSON.parse(messageBuilder.build().printJson());
134+
135+
if(isInDebugMode()) {
136+
console.log(`Built message: ${JSON.stringify(message)}`);
137+
}
138+
139+
return message;
140+
}
141+
142+
// Converts a snake_case_string to a user-readable Title Case string.
143+
function snakeToUserReadable(snakeCaseString = "") {
144+
return snakeCaseString.replace(/_/g, ' ').split(' ').map(word => {
145+
if (!word) return '';
146+
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
147+
}).join(' ');
148+
}
149+
150+
// Wraps the given widgets in Chat cards_v2 structure.
151+
function wrapWidgetsInCardsV2(widgets = []) {
152+
const section = CardService.newCardSection();
153+
widgets.forEach(widget => { section.addWidget(widget) });
154+
return CardService.newCardWithId().setCard(CardService.newCardBuilder().addSection(section).build());
155+
}
156+
157+
///////////////////////////////////////////////////////////////
158+
// --- UI rendering logic for the LLM Auditor AI Agent. ---
159+
///////////////////////////////////////////////////////////////
160+
161+
// The sub-agent sequence
162+
const SUBAGENT_SEQ = ["critic_agent", "reviser_agent"];
163+
164+
// Returns an emoji representing the author.
165+
function getAuthorEmoji(author) {
166+
switch (author) {
167+
case "critic_agent": return "ℹ️";
168+
case "reviser_agent": return "✏️";
169+
default: return "🤖";
170+
}
171+
}
172+
173+
// Returns the widgets to render for a given agent response.
174+
function getAgentResponseWidgets(name, text, groundingMetadata) {
175+
let widgets = [];
176+
switch (name) {
177+
case "critic_agent":
178+
widgets = createMarkdownAndGroundingWidgets(text, groundingMetadata);
179+
break;
180+
case "reviser_agent":
181+
widgets = createMarkdownWidgets(text);
182+
break;
183+
default:
184+
}
185+
return widgets;
186+
}
187+
188+
// --- Utility functions ---
189+
190+
// Creates widgets for the markdown text and grounding response.
191+
function createMarkdownAndGroundingWidgets(text, groundingMetadata) {
192+
// Remove the references from text
193+
let widgets = createMarkdownWidgets(text.replace(/^\s*```(json)?[\s\S]*?```\s*/i, '').replace(/Reference(s)?:[\s\S]*/i, ''))
194+
// Add sources from grounding data
195+
if (groundingMetadata.groundingChunks) {
196+
const sourceButtons = CardService.newButtonSet();
197+
for (const groundingChunk of groundingMetadata.groundingChunks) {
198+
sourceButtons.addButton(CardService.newTextButton()
199+
.setText(groundingChunk.web.domain)
200+
.setOpenLink(CardService.newOpenLink().setUrl(groundingChunk.web.uri)));
201+
}
202+
widgets.push(sourceButtons);
203+
}
204+
return widgets;
205+
}
206+
207+
// Creates widgets for markdown text response.
208+
function createMarkdownWidgets(markdown) {
209+
if (!markdown) return [];
210+
const textParagraph = CardService.newTextParagraph();
211+
textParagraph.setText(new showdown.Converter().makeHtml(markdown));
212+
return [textParagraph];
213+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright 2025 Google LLC. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the 'License');
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an 'AS IS' BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Service that handles Google Chat operations.
16+
17+
// Handle incoming Google Chat message events, actions will be taken via Google Chat API calls
18+
function onMessage(event) {
19+
if (isInDebugMode()) {
20+
console.log(`Message event received (Chat): ${JSON.stringify(event)}`);
21+
}
22+
// Extract data from the event.
23+
const chatEvent = event.chat;
24+
setChatConfig(chatEvent.messagePayload.space.name);
25+
26+
// Request AI agent to answer the message
27+
requestAgent(chatEvent.user.name, chatEvent.messagePayload.message)
28+
// Respond with an empty response to the Google Chat platform to acknowledge execution
29+
return null;
30+
}
31+
32+
// --- Utility functions ---
33+
34+
// The Chat direct message (DM) space associated with the user
35+
const SPACE_NAME_PROPERTY = "DM_SPACE_NAME"
36+
37+
// Sets the Chat DM space name for subsequent operations.
38+
function setChatConfig(spaceName) {
39+
const userProperties = PropertiesService.getUserProperties();
40+
userProperties.setProperty(SPACE_NAME_PROPERTY, spaceName);
41+
console.log(`Space is set to ${spaceName}`);
42+
}
43+
44+
// Retrieved the Chat DM space name to sent messages to.
45+
function getConfiguredChat() {
46+
const userProperties = PropertiesService.getUserProperties();
47+
return userProperties.getProperty(SPACE_NAME_PROPERTY);
48+
}
49+
50+
// Finds the Chat DM space name between the Chat app and the given user.
51+
function findChatAppDm(userName) {
52+
return Chat.Spaces.findDirectMessage(
53+
{ 'name': userName },
54+
{'Authorization': `Bearer ${getCredentials().getAccessToken()}`}
55+
).name;
56+
}
57+
58+
// Downloads a Chat message attachment and returns its content as a base64 encoded string.
59+
function downloadChatAttachment(attachmentName) {
60+
const response = UrlFetchApp.fetch(
61+
`https://chat.googleapis.com/v1/media/${attachmentName}?alt=media`,
62+
{
63+
method: 'get',
64+
headers: { 'Authorization': `Bearer ${getCredentials().getAccessToken()}` },
65+
muteHttpExceptions: true
66+
}
67+
);
68+
return Utilities.base64Encode(response.getContent());
69+
}
70+
71+
// Creates a Chat message in the configured space.
72+
function createMessage(message) {
73+
const spaceName = getConfiguredChat();
74+
console.log(`Creating message in space ${spaceName}...`);
75+
return Chat.Spaces.Messages.create(
76+
message,
77+
spaceName,
78+
{},
79+
{'Authorization': `Bearer ${getCredentials().getAccessToken()}`}
80+
).name;
81+
}
82+
83+
// Updates a Chat message in the configured space.
84+
function updateMessage(name, message) {
85+
console.log(`Updating message ${name}...`);
86+
Chat.Spaces.Messages.patch(
87+
message,
88+
name,
89+
{ updateMask: "*" },
90+
{'Authorization': `Bearer ${getCredentials().getAccessToken()}`}
91+
);
92+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright 2025 Google LLC. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the 'License');
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an 'AS IS' BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Get credentials from service account to access Vertex AI and Google Chat APIs
16+
function getCredentials() {
17+
const credentials = PropertiesService.getScriptProperties().getProperty('SERVICE_ACCOUNT_KEY');
18+
if (!credentials) {
19+
throw new Error("SERVICE_ACCOUNT_KEY script property must be set.");
20+
}
21+
const parsedCredentials = JSON.parse(credentials);
22+
return OAuth2.createService("SA")
23+
.setTokenUrl('https://oauth2.googleapis.com/token')
24+
.setPrivateKey(parsedCredentials['private_key'])
25+
.setIssuer(parsedCredentials['client_email'])
26+
.setPropertyStore(PropertiesService.getScriptProperties())
27+
.setScope([
28+
// Vertex AI scope
29+
"https://www.googleapis.com/auth/cloud-platform",
30+
// Google Chat scope
31+
// All Chat operations are taken by the Chat app itself
32+
"https://www.googleapis.com/auth/chat.bot"
33+
]);
34+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright 2025 Google LLC. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the 'License');
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an 'AS IS' BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Environment variables
16+
17+
const REASONING_ENGINE_RESOURCE_NAME = PropertiesService.getScriptProperties().getProperty('REASONING_ENGINE_RESOURCE_NAME');
18+
19+
// Get reasoning engine resource name
20+
function getReasoningEngine() {
21+
return REASONING_ENGINE_RESOURCE_NAME;
22+
}
23+
24+
const LOCATION = PropertiesService.getScriptProperties().getProperty('LOCATION');
25+
26+
// Get reasoning engine location
27+
function getLocation() {
28+
const parts = REASONING_ENGINE_RESOURCE_NAME.split('/');
29+
const locationIndex = parts.indexOf('locations') + 1;
30+
return parts[locationIndex];
31+
}
32+
33+
const DEBUG = parseInt(PropertiesService.getScriptProperties().getProperty('DEBUG')) || 0;
34+
35+
// Returns whether the application is running in debug mode.
36+
function isInDebugMode() {
37+
return DEBUG == 1
38+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Google Chat A2A AI agent as Google Workspace add-on
2+
3+
Chat app that integrates with an A2A-wrapped ADK AI agent hosted in Vertex AI Agent Engine.
4+
5+
Please see [tutorial](https://developers.google.com/workspace/add-ons/chat/quickstart-a2a-agent).

0 commit comments

Comments
 (0)