-
Notifications
You must be signed in to change notification settings - Fork 107
feat: add A2a agent quickstart sample #277
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {"scriptId": "1e8Fa-KxSSdMAKa8vAvOOg6jBrpOU_ygtAheMkktCHU4d5M_M6lJk_uy6"} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,213 @@ | ||
| // Copyright 2025 Google LLC. All Rights Reserved. | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the 'License'); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // https://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an 'AS IS' BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| //////////////////////////////////////////////////// | ||
| // --- A2A-wrapped ADK AI Agent handling logic --- | ||
| //////////////////////////////////////////////////// | ||
|
|
||
| // Sends a request to the AI agent and processes the response using the agent | ||
| function requestAgent(userName, input) { | ||
| // Sync call that sends the message to the agent | ||
| const sendResponseContentText = UrlFetchApp.fetch( | ||
| `https://${getLocation()}-aiplatform.googleapis.com/v1beta1/${getReasoningEngine()}/a2a/v1/message:send`, | ||
| { | ||
| method: 'post', | ||
| headers: { 'Authorization': `Bearer ${getCredentials().getAccessToken()}` }, | ||
| contentType: 'application/json', | ||
| payload: JSON.stringify({ | ||
| "message": { | ||
| "messageId": Utilities.getUuid(), | ||
| "role": "1", | ||
| "content": extractContentFromInput(input) | ||
| } | ||
| }), | ||
| muteHttpExceptions: true | ||
| } | ||
| ).getContentText(); | ||
| if (isInDebugMode()) { | ||
| console.log("Send response: " + sendResponseContentText); | ||
| } | ||
|
|
||
| // Retrieve the ID of the resulting task | ||
| const sendResponse = JSON.parse(sendResponseContentText); | ||
| taskId = sendResponse.task.id; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The variable const taskId = sendResponse.task.id; |
||
| console.log(`The agent started the task ${taskId}.`); | ||
|
|
||
| // Poll task status until it's in a final state | ||
| let processedMessageIds = []; | ||
| let taskResponseStatus = null; | ||
| do { | ||
| Utilities.sleep(1000); // Wait a bit before polling | ||
| const taskResponseContentText = UrlFetchApp.fetch( | ||
| `https://${getLocation()}-aiplatform.googleapis.com/v1beta1/${getReasoningEngine()}/a2a/v1/tasks/${taskId}?history_length=1`, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The `https://${getLocation()}-aiplatform.googleapis.com/v1beta1/${getReasoningEngine()}/a2a/v1/tasks/${taskId}`, |
||
| { | ||
| method: 'get', | ||
| headers: { 'Authorization': `Bearer ${getCredentials().getAccessToken()}` }, | ||
| contentType: 'application/json', | ||
| muteHttpExceptions: true | ||
| } | ||
| ).getContentText(); | ||
| if (isInDebugMode()) { | ||
| console.log("Get task response: " + taskResponseContentText); | ||
| } | ||
|
Comment on lines
+52
to
+63
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This const taskHttpResponse = UrlFetchApp.fetch(
`https://${getLocation()}-aiplatform.googleapis.com/v1beta1/${getReasoningEngine()}/a2a/v1/tasks/${taskId}?history_length=1`,
{
method: 'get',
headers: { 'Authorization': `Bearer ${getCredentials().getAccessToken()}` },
contentType: 'application/json',
muteHttpExceptions: true
}
);
const taskResponseContentText = taskHttpResponse.getContentText();
if (taskHttpResponse.getResponseCode() >= 400) {
console.error(`Error fetching task status: ${taskResponseContentText}`);
break;
}
if (isInDebugMode()) {
console.log("Get task response: " + taskResponseContentText);
} |
||
| const taskResponse = JSON.parse(taskResponseContentText); | ||
|
|
||
| // Retrieve messages already processed | ||
| const history = taskResponse.history || []; | ||
| const pastMessages = history.filter(entry => { | ||
| return entry.role === "ROLE_AGENT" && processedMessageIds.includes(entry.messageId); | ||
| }); | ||
|
|
||
| // Retrieve new messages to process | ||
| const newMessages = history.filter(entry => { | ||
| return entry.role === "ROLE_AGENT" && !processedMessageIds.includes(entry.messageId); | ||
| }); | ||
|
|
||
| // Process new messages | ||
| let nextSubAgentSeqIndex = pastMessages.length; | ||
| for (const newMessage of newMessages) { | ||
| if (isInDebugMode()) { | ||
| console.log("Processing new message: " + JSON.stringify(newMessage)); | ||
| } | ||
|
|
||
| // Retrieve the agent responsible for generating this message | ||
| const author = SUBAGENT_SEQ[nextSubAgentSeqIndex]; | ||
|
|
||
| // Handle text answers | ||
| const text = newMessage.content[0].text; | ||
| if (text) { | ||
| console.log(`${author}: ${text}`); | ||
| answer(author, text, taskResponse.metadata.adk_grounding_metadata); | ||
| } | ||
|
|
||
| // Update client processing status | ||
| processedMessageIds.push(newMessage.messageId); | ||
| nextSubAgentSeqIndex++; | ||
| } | ||
| taskResponseStatus = taskResponse.status.state; | ||
| // See https://agent2agent.info/docs/concepts/task/#task-state-taskstate | ||
| } while(['TASK_STATE_SUBMITTED', 'TASK_STATE_WORKING'].includes(taskResponseStatus)); | ||
|
Comment on lines
+50
to
+100
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| } | ||
|
|
||
| // Transforms the user input to AI message with contents. | ||
| function extractContentFromInput(input) { | ||
| // For Chat host apps, the input can contain text and attachments | ||
| const parts = [{ "text": input.text }]; | ||
| if (input.attachment && Array.isArray(input.attachment)) { | ||
| for (const attachment of input.attachment) { | ||
| parts.push({ "file": { | ||
| "mime_type": attachment.contentType, | ||
| "file_with_bytes": Utilities.base64Encode(downloadChatAttachment( | ||
| attachment.attachmentDataRef.resourceName | ||
| )) | ||
| }}); | ||
| } | ||
| } | ||
| return parts; | ||
| } | ||
|
|
||
| // Sends an answer as a Chat message. | ||
| function answer(author, text, groundingMetadata) { | ||
| const widgets = getAgentResponseWidgets(author, text, groundingMetadata); | ||
| createMessage(buildMessage(author, [wrapWidgetsInCardsV2(widgets)])); | ||
| } | ||
|
|
||
| // --- Utility functions --- | ||
|
|
||
| // Builds a Chat message for the given author, text, and cards_v2. | ||
| function buildMessage(author, cardsV2) { | ||
| const messageBuilder = CardService.newChatResponseBuilder(); | ||
| messageBuilder.setText(`${getAuthorEmoji(author)} *${snakeToUserReadable(author)}* ✅`); | ||
| cardsV2.forEach(cardV2 => { messageBuilder.addCardsV2(cardV2) }); | ||
| let message = JSON.parse(messageBuilder.build().printJson()); | ||
|
|
||
| if(isInDebugMode()) { | ||
| console.log(`Built message: ${JSON.stringify(message)}`); | ||
| } | ||
|
|
||
| return message; | ||
| } | ||
|
|
||
| // Converts a snake_case_string to a user-readable Title Case string. | ||
| function snakeToUserReadable(snakeCaseString = "") { | ||
| return snakeCaseString.replace(/_/g, ' ').split(' ').map(word => { | ||
| if (!word) return ''; | ||
| return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); | ||
| }).join(' '); | ||
| } | ||
|
|
||
| // Wraps the given widgets in Chat cards_v2 structure. | ||
| function wrapWidgetsInCardsV2(widgets = []) { | ||
| const section = CardService.newCardSection(); | ||
| widgets.forEach(widget => { section.addWidget(widget) }); | ||
| return CardService.newCardWithId().setCard(CardService.newCardBuilder().addSection(section).build()); | ||
| } | ||
|
|
||
| /////////////////////////////////////////////////////////////// | ||
| // --- UI rendering logic for the LLM Auditor AI Agent. --- | ||
| /////////////////////////////////////////////////////////////// | ||
|
|
||
| // The sub-agent sequence | ||
| const SUBAGENT_SEQ = ["critic_agent", "reviser_agent"]; | ||
|
|
||
| // Returns an emoji representing the author. | ||
| function getAuthorEmoji(author) { | ||
| switch (author) { | ||
| case "critic_agent": return "ℹ️"; | ||
| case "reviser_agent": return "✏️"; | ||
| default: return "🤖"; | ||
| } | ||
| } | ||
|
|
||
| // Returns the widgets to render for a given agent response. | ||
| function getAgentResponseWidgets(name, text, groundingMetadata) { | ||
| let widgets = []; | ||
| switch (name) { | ||
| case "critic_agent": | ||
| widgets = createMarkdownAndGroundingWidgets(text, groundingMetadata); | ||
| break; | ||
| case "reviser_agent": | ||
| widgets = createMarkdownWidgets(text); | ||
| break; | ||
| default: | ||
| } | ||
| return widgets; | ||
| } | ||
|
|
||
| // --- Utility functions --- | ||
|
|
||
| // Creates widgets for the markdown text and grounding response. | ||
| function createMarkdownAndGroundingWidgets(text, groundingMetadata) { | ||
| // Remove the references from text | ||
| let widgets = createMarkdownWidgets(text.replace(/^\s*```(json)?[\s\S]*?```\s*/i, '').replace(/Reference(s)?:[\s\S]*/i, '')) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The regular expression let widgets = createMarkdownWidgets(text.replace(/^\s*```(json)?[\s\S]*?```\s*/i, '').replace(/\n\nReference(s)?:[\s\S]*$/i, '')) |
||
| // Add sources from grounding data | ||
| if (groundingMetadata.groundingChunks) { | ||
| const sourceButtons = CardService.newButtonSet(); | ||
| for (const groundingChunk of groundingMetadata.groundingChunks) { | ||
| sourceButtons.addButton(CardService.newTextButton() | ||
| .setText(groundingChunk.web.domain) | ||
| .setOpenLink(CardService.newOpenLink().setUrl(groundingChunk.web.uri))); | ||
| } | ||
| widgets.push(sourceButtons); | ||
| } | ||
| return widgets; | ||
| } | ||
|
|
||
| // Creates widgets for markdown text response. | ||
| function createMarkdownWidgets(markdown) { | ||
| if (!markdown) return []; | ||
| const textParagraph = CardService.newTextParagraph(); | ||
| textParagraph.setText(new showdown.Converter().makeHtml(markdown)); | ||
| return [textParagraph]; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| // Copyright 2025 Google LLC. All Rights Reserved. | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the 'License'); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // https://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an 'AS IS' BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| // Service that handles Google Chat operations. | ||
|
|
||
| // Handle incoming Google Chat message events, actions will be taken via Google Chat API calls | ||
| function onMessage(event) { | ||
| if (isInDebugMode()) { | ||
| console.log(`Message event received (Chat): ${JSON.stringify(event)}`); | ||
| } | ||
| // Extract data from the event. | ||
| const chatEvent = event.chat; | ||
| setChatConfig(chatEvent.messagePayload.space.name); | ||
|
|
||
| // Request AI agent to answer the message | ||
| requestAgent(chatEvent.user.name, chatEvent.messagePayload.message) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| // Respond with an empty response to the Google Chat platform to acknowledge execution | ||
| return null; | ||
| } | ||
|
|
||
| // --- Utility functions --- | ||
|
|
||
| // The Chat direct message (DM) space associated with the user | ||
| const SPACE_NAME_PROPERTY = "DM_SPACE_NAME" | ||
|
|
||
| // Sets the Chat DM space name for subsequent operations. | ||
| function setChatConfig(spaceName) { | ||
| const userProperties = PropertiesService.getUserProperties(); | ||
| userProperties.setProperty(SPACE_NAME_PROPERTY, spaceName); | ||
| console.log(`Space is set to ${spaceName}`); | ||
| } | ||
|
|
||
| // Retrieved the Chat DM space name to sent messages to. | ||
| function getConfiguredChat() { | ||
| const userProperties = PropertiesService.getUserProperties(); | ||
| return userProperties.getProperty(SPACE_NAME_PROPERTY); | ||
| } | ||
|
|
||
| // Finds the Chat DM space name between the Chat app and the given user. | ||
| function findChatAppDm(userName) { | ||
| return Chat.Spaces.findDirectMessage( | ||
| { 'name': userName }, | ||
| {'Authorization': `Bearer ${getCredentials().getAccessToken()}`} | ||
| ).name; | ||
| } | ||
|
|
||
| // Downloads a Chat message attachment and returns its content as a base64 encoded string. | ||
| function downloadChatAttachment(attachmentName) { | ||
| const response = UrlFetchApp.fetch( | ||
| `https://chat.googleapis.com/v1/media/${attachmentName}?alt=media`, | ||
| { | ||
| method: 'get', | ||
| headers: { 'Authorization': `Bearer ${getCredentials().getAccessToken()}` }, | ||
| muteHttpExceptions: true | ||
| } | ||
| ); | ||
|
Comment on lines
+60
to
+67
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The const response = UrlFetchApp.fetch(
`https://chat.googleapis.com/v1/media/${attachmentName}?alt=media`,
{
method: 'get',
headers: { 'Authorization': `Bearer ${getCredentials().getAccessToken()}` },
muteHttpExceptions: true
}
);
if (response.getResponseCode() >= 400) {
console.error('Failed to download attachment %s: %s', attachmentName, response.getContentText());
throw new Error('Attachment download failed.');
} |
||
| return Utilities.base64Encode(response.getContent()); | ||
| } | ||
|
|
||
| // Creates a Chat message in the configured space. | ||
| function createMessage(message) { | ||
| const spaceName = getConfiguredChat(); | ||
| console.log(`Creating message in space ${spaceName}...`); | ||
| return Chat.Spaces.Messages.create( | ||
| message, | ||
| spaceName, | ||
| {}, | ||
| {'Authorization': `Bearer ${getCredentials().getAccessToken()}`} | ||
| ).name; | ||
| } | ||
|
|
||
| // Updates a Chat message in the configured space. | ||
| function updateMessage(name, message) { | ||
| console.log(`Updating message ${name}...`); | ||
| Chat.Spaces.Messages.patch( | ||
| message, | ||
| name, | ||
| { updateMask: "*" }, | ||
| {'Authorization': `Bearer ${getCredentials().getAccessToken()}`} | ||
| ); | ||
| } | ||
|
Comment on lines
+72
to
+92
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The calls to |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| // Copyright 2025 Google LLC. All Rights Reserved. | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the 'License'); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // https://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an 'AS IS' BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| // Get credentials from service account to access Vertex AI and Google Chat APIs | ||
| function getCredentials() { | ||
| const credentials = PropertiesService.getScriptProperties().getProperty('SERVICE_ACCOUNT_KEY'); | ||
| if (!credentials) { | ||
| throw new Error("SERVICE_ACCOUNT_KEY script property must be set."); | ||
| } | ||
| const parsedCredentials = JSON.parse(credentials); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The let parsedCredentials;
try {
parsedCredentials = JSON.parse(credentials);
} catch (e) {
throw new Error('Failed to parse SERVICE_ACCOUNT_KEY. Please ensure it is valid JSON. Error: ' + e);
} |
||
| return OAuth2.createService("SA") | ||
| .setTokenUrl('https://oauth2.googleapis.com/token') | ||
| .setPrivateKey(parsedCredentials['private_key']) | ||
| .setIssuer(parsedCredentials['client_email']) | ||
| .setPropertyStore(PropertiesService.getScriptProperties()) | ||
| .setScope([ | ||
| // Vertex AI scope | ||
| "https://www.googleapis.com/auth/cloud-platform", | ||
| // Google Chat scope | ||
| // All Chat operations are taken by the Chat app itself | ||
| "https://www.googleapis.com/auth/chat.bot" | ||
| ]); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,38 @@ | ||||||||||||||||||||||||||
| // Copyright 2025 Google LLC. All Rights Reserved. | ||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||
| // Licensed under the Apache License, Version 2.0 (the 'License'); | ||||||||||||||||||||||||||
| // you may not use this file except in compliance with the License. | ||||||||||||||||||||||||||
| // You may obtain a copy of the License at | ||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||
| // https://www.apache.org/licenses/LICENSE-2.0 | ||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||
| // Unless required by applicable law or agreed to in writing, software | ||||||||||||||||||||||||||
| // distributed under the License is distributed on an 'AS IS' BASIS, | ||||||||||||||||||||||||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||||||||||||||||||||||
| // See the License for the specific language governing permissions and | ||||||||||||||||||||||||||
| // limitations under the License. | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Environment variables | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const REASONING_ENGINE_RESOURCE_NAME = PropertiesService.getScriptProperties().getProperty('REASONING_ENGINE_RESOURCE_NAME'); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Get reasoning engine resource name | ||||||||||||||||||||||||||
| function getReasoningEngine() { | ||||||||||||||||||||||||||
| return REASONING_ENGINE_RESOURCE_NAME; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const LOCATION = PropertiesService.getScriptProperties().getProperty('LOCATION'); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Get reasoning engine location | ||||||||||||||||||||||||||
| function getLocation() { | ||||||||||||||||||||||||||
| const parts = REASONING_ENGINE_RESOURCE_NAME.split('/'); | ||||||||||||||||||||||||||
| const locationIndex = parts.indexOf('locations') + 1; | ||||||||||||||||||||||||||
| return parts[locationIndex]; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Comment on lines
+27
to
+31
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The logic to extract the location from
Suggested change
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const DEBUG = parseInt(PropertiesService.getScriptProperties().getProperty('DEBUG')) || 0; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Returns whether the application is running in debug mode. | ||||||||||||||||||||||||||
| function isInDebugMode() { | ||||||||||||||||||||||||||
| return DEBUG == 1 | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Comment on lines
+33
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The logic for parsing the const DEBUG_PROPERTY = PropertiesService.getScriptProperties().getProperty('DEBUG');
const DEBUG = DEBUG_PROPERTY === '1' || (DEBUG_PROPERTY && DEBUG_PROPERTY.toLowerCase() === 'true');
// Returns whether the application is running in debug mode.
function isInDebugMode() {
return DEBUG;
} |
||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| # Google Chat A2A AI agent as Google Workspace add-on | ||
|
|
||
| Chat app that integrates with an A2A-wrapped ADK AI agent hosted in Vertex AI Agent Engine. | ||
|
|
||
| Please see [tutorial](https://developers.google.com/workspace/add-ons/chat/quickstart-a2a-agent). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
UrlFetchApp.fetchcall hasmuteHttpExceptions: true, but there is no subsequent check on the HTTP response code. If the API call fails,getContentText()will return an error payload, andJSON.parse()on line 43 will likely throw an unhandled exception, crashing the script. You should check the response code and handle errors gracefully before parsing the response.