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/ge-ai-agent/.clasp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"scriptId": "1YeDvZtq5c5TU2DXFVCNGjA6RCVkUn5x0ip5QVksmSwgsy7gwpI7izPLt"}

Choose a reason for hiding this comment

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

high

Committing the .clasp.json file with a hardcoded scriptId is not a recommended practice for collaborative projects. If another developer clones this repository and pushes changes, they could accidentally overwrite the shared Apps Script project. To prevent this, this file should be added to .gitignore, and a template file (e.g., .clasp.json.example) should be provided instead, with instructions for developers to create their own local .clasp.json.

136 changes: 136 additions & 0 deletions apps-script/chat/ge-ai-agent/AgentHandler.gs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Copyright 2026 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.

///////////////////////////////////////////////////////
// --- Gemini Enterprise AI Agent handling logic ---
///////////////////////////////////////////////////////

// Sends a request to the AI agent and processes the response
function requestAgent(input) {
// Sync call that gets all events from agent response
const responseContentText = UrlFetchApp.fetch(
`https://${getLocation()}-discoveryengine.googleapis.com/v1/${getReasoningEngine()}/assistants/default_assistant:streamAssist?alt=sse`,
{
method: 'post',
headers: { 'Authorization': `Bearer ${getCredentials().getAccessToken()}` },
contentType: 'application/json',
payload: JSON.stringify({
// Always use a new session
"session" : null,
// Only use the message text
"query": { "text": input.text },
"agentsSpec": { "agentSpecs": [{
"agentId": getAgentId()
}]}
}),
muteHttpExceptions: true
}
).getContentText();
Comment on lines +22 to +39

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 is immediately chained with .getContentText(), which prevents you from checking the HTTP response status code. If the API request fails (e.g., due to an authentication error, invalid input, or server-side issue), the script will attempt to parse the error response body as a successful SSE stream. This will lead to subsequent failures or unpredictable behavior. It's critical to check the response code and handle any non-successful statuses gracefully.

  const response = UrlFetchApp.fetch(
    `https://${getLocation()}-discoveryengine.googleapis.com/v1/${getReasoningEngine()}/assistants/default_assistant:streamAssist?alt=sse`,
    {
      method: 'post',
      headers: { 'Authorization': `Bearer ${getCredentials().getAccessToken()}` },
      contentType: 'application/json',
      payload: JSON.stringify({
        // Always use a new session
        "session" : null,
        // Only use the message text
        "query": { "text": input.text },
        "agentsSpec": { "agentSpecs": [{
          "agentId": getAgentId()
        }]}
      }),
      muteHttpExceptions: true
    }
  );

  if (response.getResponseCode() < 200 || response.getResponseCode() >= 300) {
    console.error(`Agent request failed with status ${response.getResponseCode()}: ${response.getContentText()}`);
    answer(getAgentId(), "Something went wrong, check the Apps Script logs for more info.", false);
    return;
  }
  const responseContentText = response.getContentText();


// Process the SSE response (one line per event)
const events = responseContentText.split('\n').map(s => s.replace(/^data:\s*/, '')).filter(s => s.trim().length > 0);
console.log(`Received ${events.length} agent events.`);
var answerText = "";
for (const eventJson of events) {
if (isInDebugMode()) {
console.log("Event: " + eventJson);
}
const event = JSON.parse(eventJson);

Choose a reason for hiding this comment

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

high

The call to JSON.parse(eventJson) is not wrapped in a try...catch block. Since the eventJson string comes from an external API response, it might not always be valid JSON. A malformed event would cause JSON.parse to throw an unhandled exception, terminating the script execution abruptly. To make the agent more robust, you should handle potential parsing errors.

    let event;
    try {
      event = JSON.parse(eventJson);
    } catch (e) {
      console.error(`Failed to parse agent event: ${eventJson}`, e);
      continue; // Skip malformed event and proceed to the next one.
    }


// Ignore internal events
if (!event.answer) {
console.log(`Ignored: internal event`);
continue;
}

// Handle text replies
const replies = event.answer.replies || [];
for (const reply of replies) {
const content = reply.groundedContent.content;
// Process content if any
if (content) {
if (isInDebugMode()) {
console.log(`Processing content: ${JSON.stringify(content)}`);
}
// Ignore thought events
if (content.thought) {
console.log(`Ignored: thought event`);
continue;
}
answerText += content.text;
}
}

// Send Chat message to answer user
if (event.answer.state === "SUCCEEDED") {
console.log(`Answer text: ${answerText}`);
answer(getAgentId(), answerText);
} else if (event.answer.state !== "IN_PROGRESS") {
answer(getAgentId(), "Something went wrong, check the Apps Script logs for more info.", false);
}
}
}

// --- Utility functions ---

// Sends an answer as a Chat message.
function answer(author, text, success) {
const widgets = createMarkdownWidgets(text);
createMessage(buildMessage(author, [wrapWidgetsInCardsV2(widgets)], success));
}

// Builds a Chat message for the given author, state, and cards_v2.
function buildMessage(author, cardsV2, success=true) {
const messageBuilder = CardService.newChatResponseBuilder();
messageBuilder.setText(`${getAuthorEmoji(author)} *${snakeToUserReadable(author)}* ${success ? '✅' : '❌'}`);
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());
}

// Returns an emoji representing the author.
function getAuthorEmoji(author) {
switch (author) {
case "default_idea_generation": return "ℹ️";
default: return "🤖";
}
}

// 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];
}
68 changes: 68 additions & 0 deletions apps-script/chat/ge-ai-agent/Chat.gs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright 2026 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.messagePayload.message)
// Respond with an empty response to the Google Chat platform to acknowledge execution
return null;
Comment on lines +27 to +29

Choose a reason for hiding this comment

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

high

The onMessage function, which serves as the Chat webhook handler, calls requestAgent synchronously. Since requestAgent performs a network request to an AI service, it can be a long-running operation. Google Chat webhooks have a 30-second timeout. If the agent's response time exceeds this limit, the webhook will time out, and the user will see an error. The comment on line 28 suggests an asynchronous acknowledgement was intended, but the implementation is blocking. For a production-ready bot, consider an asynchronous architecture to handle long-running tasks without blocking the initial response to Google Chat.

}

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

// 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;
}
34 changes: 34 additions & 0 deletions apps-script/chat/ge-ai-agent/Credentials.gs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright 2026 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 Gemini Enterprise 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.

high

The call to JSON.parse(credentials) assumes the SERVICE_ACCOUNT_KEY script property is always valid JSON. If the property is missing, empty, or malformed, this will throw an unhandled exception and crash the script. To improve robustness against configuration errors, you should wrap this call in a try...catch block and provide a more informative error message.

  let parsedCredentials;
  try {
    parsedCredentials = JSON.parse(credentials);
  } catch (e) {
    throw new Error('Failed to parse SERVICE_ACCOUNT_KEY. Please ensure it is valid JSON.');
  }

return OAuth2.createService("SA")
.setTokenUrl('https://oauth2.googleapis.com/token')
.setPrivateKey(parsedCredentials['private_key'])
.setIssuer(parsedCredentials['client_email'])
.setPropertyStore(PropertiesService.getScriptProperties())
.setScope([
// Gemini Enterprise Assitant scope
"https://www.googleapis.com/auth/discoveryengine.assist.readwrite",
// Google Chat scope
// All Chat operations are taken by the Chat app itself
"https://www.googleapis.com/auth/chat.bot"
]);
}
45 changes: 45 additions & 0 deletions apps-script/chat/ge-ai-agent/Env.gs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright 2026 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('/');

Choose a reason for hiding this comment

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

critical

The REASONING_ENGINE_RESOURCE_NAME constant is read from script properties. If this property is not set, its value will be null. This line then attempts to call .split('/') on null, which will throw a TypeError and crash the script. It's critical to validate that this required property exists before attempting to use it.

  if (!REASONING_ENGINE_RESOURCE_NAME) {
    throw new Error('Script property "REASONING_ENGINE_RESOURCE_NAME" must be set.');
  }
  const parts = REASONING_ENGINE_RESOURCE_NAME.split('/'); 

const locationIndex = parts.indexOf('locations') + 1;
return parts[locationIndex];
Comment on lines +29 to +30

Choose a reason for hiding this comment

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

critical

The logic to extract the location assumes that the substring 'locations' is always present in the resource name. If parts.indexOf('locations') returns -1, locationIndex will become 0, and the function will incorrectly return the first part of the resource name (e.g., 'projects') as the location. You should add validation to ensure 'locations' is found and handle the case where it's not.

  const locationIndex = parts.indexOf('locations');
  if (locationIndex === -1 || locationIndex + 1 >= parts.length) {
    throw new Error(`Could not determine location from resource name: ${REASONING_ENGINE_RESOURCE_NAME}`);
  }
  return parts[locationIndex + 1];

}

const DEBUG = parseInt(PropertiesService.getScriptProperties().getProperty('DEBUG')) || 0;

// Returns whether the application is running in debug mode.
function isInDebugMode() {
return DEBUG == 1
}

const AGENT_ID = PropertiesService.getScriptProperties().getProperty('AGENT_ID') || "default_idea_generation";

// Get Gemini Enterprise agent ID
function getAgentId() {
return AGENT_ID;
}
5 changes: 5 additions & 0 deletions apps-script/chat/ge-ai-agent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Google Chat Gemini Enterprise AI agent as Google Workspace add-on

Chat app that integrates with a Gemini Enterprise AI agent.

Please see [tutorial](https://developers.google.com/workspace/add-ons/chat/quickstart-ge-agent).
31 changes: 31 additions & 0 deletions apps-script/chat/ge-ai-agent/appsscript.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"timeZone": "America/New_York",
"dependencies": {
"libraries": [
{
"userSymbol": "OAuth2",
"version": "43",
"libraryId": "1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF"
}
],
"enabledAdvancedServices": [
{
"userSymbol": "Chat",
"version": "v1",
"serviceId": "chat"
}
]
},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"addOns": {
"common": {
"name": "GE AI Agent Quickstart",
"logoUrl": "https://developers.google.com/workspace/add-ons/images/quickstart-app-avatar.png"
},
"chat": {}
},
"oauthScopes": [
"https://www.googleapis.com/auth/script.external_request"
]
}
Loading
Loading