Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps-script/chat/a2a-ai-agent/.clasp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"scriptId": "1e8Fa-KxSSdMAKa8vAvOOg6jBrpOU_ygtAheMkktCHU4d5M_M6lJk_uy6"}
213 changes: 213 additions & 0 deletions apps-script/chat/a2a-ai-agent/AgentHandler.gs
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);
}
Comment on lines +22 to +40

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The UrlFetchApp.fetch call has muteHttpExceptions: true, but there is no subsequent check on the HTTP response code. If the API call fails, getContentText() will return an error payload, and JSON.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.

  const sendHttpResponse = 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": "ROLE_USER",
          "content": extractContentFromInput(input)
        }
      }),
      muteHttpExceptions: true
    }
  );

  const sendResponseContentText = sendHttpResponse.getContentText();
  if (sendHttpResponse.getResponseCode() >= 400) {
    console.error(`Error sending message to agent: ${sendResponseContentText}`);
    // TODO: Inform the user about the error.
    return;
  }

  if (isInDebugMode()) {
    console.log("Send response: " + sendResponseContentText);
  }


// Retrieve the ID of the resulting task
const sendResponse = JSON.parse(sendResponseContentText);
taskId = sendResponse.task.id;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The variable taskId is assigned without being declared using let, const, or var. This creates an implicit global variable, which is a dangerous practice in JavaScript and Apps Script as it can lead to unpredictable behavior and makes the code harder to debug. Please declare taskId with const.

  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`,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The history_length=1 query parameter will cause the API to return at most one message from the task's history. However, the logic in this loop is designed to process multiple new messages from sub-agents. If the agent produces multiple messages between polling intervals, this implementation will only ever see the most recent one, causing previous messages to be missed. This will lead to incomplete or incorrect agent responses. To fetch all new messages, you should remove this parameter to use the API's default.

        `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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

This UrlFetchApp.fetch call to get the task status has muteHttpExceptions: true but lacks error handling. If this API call fails, JSON.parse on line 64 will likely throw an unhandled exception. You should check the response code and handle any potential errors, for instance by breaking the polling loop.

      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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The do-while loop for polling the task status lacks a timeout or a maximum retry mechanism. If the task gets stuck in a TASK_STATE_SUBMITTED or TASK_STATE_WORKING state, this loop could run until the Apps Script execution timeout is reached (typically 6 minutes). This can lead to resource exhaustion and a poor user experience. Consider adding a counter to limit the number of polling attempts and handle the timeout gracefully, for example by notifying the user.

}

// 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, ''))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The regular expression .replace(/Reference(s)?:[\s\S]*/i, '') is too greedy. It will remove everything from the first occurrence of "Reference:" or "References:" to the end of the string. If this pattern appears unexpectedly in the middle of the agent's response, it will cause the message to be truncated. To make this safer, consider anchoring the pattern to only match if it appears at the end of the text, for example, as its own paragraph.

  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];
}
92 changes: 92 additions & 0 deletions apps-script/chat/a2a-ai-agent/Chat.gs
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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The onMessage function directly calls requestAgent, which is a long-running synchronous operation due to its polling mechanism. Google Chat webhooks require a response within 30 seconds. If requestAgent takes longer, the request will time out, and the user will see an "App is not responding" error. To fix this, you should make the agent processing asynchronous. The onMessage function should return immediately after triggering the asynchronous task, for example by using ScriptApp.newTrigger(). The triggered function can then perform the long-running work and post messages back to the chat space.

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The UrlFetchApp.fetch call to download an attachment has muteHttpExceptions: true but does not check the response code. If the media download fails, response.getContent() could return an error message or be empty, which would then be passed to Utilities.base64Encode(). This could lead to corrupted data or script errors. You should check the HTTP response code and handle failures appropriately.

  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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The calls to Chat.Spaces.Messages.create and Chat.Spaces.Messages.patch do not have any error handling. If an API call fails (e.g., due to permission issues or an invalid message format), it will throw an unhandled exception and terminate the script. It's important to wrap these calls in try...catch blocks to handle potential API errors gracefully and log them for debugging.

34 changes: 34 additions & 0 deletions apps-script/chat/a2a-ai-agent/Credentials.gs
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);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The JSON.parse(credentials) call can throw an exception if the SERVICE_ACCOUNT_KEY script property contains a string that is not valid JSON. This would result in an unhandled exception and crash the script with a generic error. You should wrap this call in a try...catch block to provide a more informative error message about the malformed service account key.

  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"
]);
}
38 changes: 38 additions & 0 deletions apps-script/chat/a2a-ai-agent/Env.gs
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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The logic to extract the location from REASONING_ENGINE_RESOURCE_NAME is brittle. It relies on splitting the string by / and assuming locations is always present and followed by the location string. If the resource name format is invalid, this could return an incorrect value (like the project ID) and cause API calls to fail. A regular expression would be a more robust way to extract the location.

Suggested change
function getLocation() {
const parts = REASONING_ENGINE_RESOURCE_NAME.split('/');
const locationIndex = parts.indexOf('locations') + 1;
return parts[locationIndex];
}
function getLocation() {
const match = REASONING_ENGINE_RESOURCE_NAME.match(/locations\/([^\/]+)/);
if (!match || !match[1]) {
throw new Error('Could not parse location from REASONING_ENGINE_RESOURCE_NAME: ' + REASONING_ENGINE_RESOURCE_NAME);
}
return match[1];
}


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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The logic for parsing the DEBUG script property is buggy. parseInt() returns NaN for non-numeric strings like "true", and NaN || 0 evaluates to 0. This means isInDebugMode() will incorrectly return false if the DEBUG property is set to "true". To correctly handle boolean-like strings (e.g., "true") and the number 1, you should check for these values explicitly.

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

5 changes: 5 additions & 0 deletions apps-script/chat/a2a-ai-agent/README.md
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).
Loading
Loading