Agent loop defensive (#3554)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2025-07-22 18:21:47 +02:00
committed by GitHub
parent b3cd03ef61
commit 9f356e7009
19 changed files with 349 additions and 773 deletions
+7 -9
View File
@@ -2332,17 +2332,16 @@
}
},
"ResourceContents": {
"oneOf": [
"anyOf": [
{
"type": "object",
"required": [
"uri",
"text"
"text",
"uri"
],
"properties": {
"mime_type": {
"type": "string",
"nullable": true
"type": "string"
},
"text": {
"type": "string"
@@ -2355,16 +2354,15 @@
{
"type": "object",
"required": [
"uri",
"blob"
"blob",
"uri"
],
"properties": {
"blob": {
"type": "string"
},
"mime_type": {
"type": "string",
"nullable": true
"type": "string"
},
"uri": {
"type": "string"
+1 -1
View File
@@ -1174,7 +1174,7 @@ export default function App() {
}, []);
const config = window.electron.getConfig();
const STRICT_ALLOWLIST = config.GOOSE_ALLOWLIST_WARNING === true ? false : true;
const STRICT_ALLOWLIST = config.GOOSE_ALLOWLIST_WARNING !== true;
useEffect(() => {
console.log('Setting up extension handler');
+2 -2
View File
@@ -463,12 +463,12 @@ export type RedactedThinkingContent = {
};
export type ResourceContents = {
mime_type?: string | null;
mime_type?: string;
text: string;
uri: string;
} | {
blob: string;
mime_type?: string | null;
mime_type?: string;
uri: string;
};
+1 -1
View File
@@ -537,7 +537,7 @@ function BaseChatContent({
recipeDetails={{
title: recipeConfig?.title,
description: recipeConfig?.description,
instructions: recipeConfig?.instructions,
instructions: recipeConfig?.instructions || undefined,
}}
/>
@@ -126,10 +126,10 @@ const notificationToProgress = (notification: NotificationEvent): Progress =>
const getExtensionTooltip = (toolCallName: string): string | null => {
const lastIndex = toolCallName.lastIndexOf('__');
if (lastIndex === -1) return null;
const extensionName = toolCallName.substring(0, lastIndex);
if (!extensionName) return null;
return `${extensionName} extension`;
};
@@ -377,7 +377,7 @@ function ToolCallView({
// This ensures any MCP tool works without explicit handling
const toolDisplayName = snakeToTitleCase(toolName);
const entries = Object.entries(args);
if (entries.length === 0) {
return `${toolDisplayName}`;
}
@@ -413,7 +413,7 @@ function ToolCallView({
};
const toolLabel = (
<span className={cn("ml-2", extensionTooltip && "cursor-pointer hover:opacity-80")}>
<span className={cn('ml-2', extensionTooltip && 'cursor-pointer hover:opacity-80')}>
{getToolLabelContent()}
</span>
);
+30 -61
View File
@@ -1,20 +1,20 @@
import type { OpenDialogReturnValue } from 'electron';
import {
app,
session,
App,
BrowserWindow,
dialog,
Event,
globalShortcut,
ipcMain,
Menu,
MenuItem,
Notification,
powerSaveBlocker,
Tray,
App,
globalShortcut,
session,
shell,
Event,
Tray,
} from 'electron';
import type { OpenDialogReturnValue } from 'electron';
import { Buffer } from 'node:buffer';
import fs from 'node:fs/promises';
import fsSync from 'node:fs';
@@ -33,20 +33,20 @@ import {
EnvToggles,
loadSettings,
saveSettings,
SchedulingEngine,
updateEnvironmentVariables,
updateSchedulingEngineEnvironment,
SchedulingEngine,
} from './utils/settings';
import * as crypto from 'crypto';
// import electron from "electron";
import * as yaml from 'yaml';
import windowStateKeeper from 'electron-window-state';
import {
setupAutoUpdater,
getUpdateAvailable,
registerUpdateIpcHandlers,
setTrayRef,
setupAutoUpdater,
updateTrayMenu,
getUpdateAvailable,
} from './utils/autoUpdater';
import { UPDATES_ENABLED } from './updates';
import { Recipe } from './recipe';
@@ -589,7 +589,7 @@ const createChat = async (
titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default',
trafficLightPosition: process.platform === 'darwin' ? { x: 20, y: 16 } : undefined,
vibrancy: process.platform === 'darwin' ? 'window' : undefined,
frame: process.platform === 'darwin' ? false : true,
frame: process.platform !== 'darwin',
x: mainWindowState.x,
y: mainWindowState.y,
width: mainWindowState.width,
@@ -1542,15 +1542,8 @@ ipcMain.handle('show-message-box', async (_event, options) => {
return result;
});
// Handle allowed extensions list fetching
ipcMain.handle('get-allowed-extensions', async () => {
try {
const allowList = await getAllowList();
return allowList;
} catch (error) {
console.error('Error fetching allowed extensions:', error);
throw error;
}
return await getAllowList();
});
const createNewWindow = async (app: App, dir?: string | null) => {
@@ -2068,57 +2061,33 @@ app.whenReady().then(async () => {
});
});
/**
* Fetches the allowed extensions list from the remote YAML file if GOOSE_ALLOWLIST is set.
* If the ALLOWLIST is not set, any are allowed. If one is set, it will warn if the deeplink
* doesn't match a command from the list.
* If it fails to load, then it will return an empty list.
* If the format is incorrect, it will return an empty list.
* Format of yaml is:
*
```yaml:
extensions:
- id: slack
command: uvx mcp_slack
- id: knowledge_graph_memory
command: npx -y @modelcontextprotocol/server-memory
```
*
* @returns A promise that resolves to an array of extension commands that are allowed.
*/
async function getAllowList(): Promise<string[]> {
if (!process.env.GOOSE_ALLOWLIST) {
return [];
}
try {
// Fetch the YAML file
const response = await fetch(process.env.GOOSE_ALLOWLIST);
const response = await fetch(process.env.GOOSE_ALLOWLIST);
if (!response.ok) {
throw new Error(
`Failed to fetch allowed extensions: ${response.status} ${response.statusText}`
);
}
if (!response.ok) {
throw new Error(
`Failed to fetch allowed extensions: ${response.status} ${response.statusText}`
);
}
// Parse the YAML content
const yamlContent = await response.text();
const parsedYaml = yaml.parse(yamlContent);
// Parse the YAML content
const yamlContent = await response.text();
const parsedYaml = yaml.parse(yamlContent);
// Extract the commands from the extensions array
if (parsedYaml && parsedYaml.extensions && Array.isArray(parsedYaml.extensions)) {
const commands = parsedYaml.extensions.map(
(ext: { id: string; command: string }) => ext.command
);
console.log(`Fetched ${commands.length} allowed extension commands`);
return commands;
} else {
console.error('Invalid YAML structure:', parsedYaml);
return [];
}
} catch (error) {
console.error('Error in getAllowList:', error);
throw error;
// Extract the commands from the extensions array
if (parsedYaml && parsedYaml.extensions && Array.isArray(parsedYaml.extensions)) {
const commands = parsedYaml.extensions.map(
(ext: { id: string; command: string }) => ext.command
);
console.log(`Fetched ${commands.length} allowed extension commands`);
return commands;
} else {
console.error('Invalid YAML structure:', parsedYaml);
return [];
}
}
-198
View File
@@ -1,198 +0,0 @@
import { getApiUrl, getSecretKey } from '../config';
import { safeJsonParse } from './jsonUtils';
const getQuestionClassifierPrompt = (messageContent: string): string => `
You are a simple classifier that takes content and decides if it is asking for input
from a person before continuing if there is more to do, or not. These are questions
on if a course of action should proceeed or not, or approval is needed. If it is CLEARLY a
question asking if it ok to proceed or make a choice or some input is required to proceed, then, and ONLY THEN, return QUESTION, otherwise READY if not 97% sure.
### Examples message content that is classified as READY:
anything else I can do?
Could you please run the application and verify that the headlines are now visible in dark mode? You can use npm start.
Would you like me to make any adjustments to the formatting of these multiline strings?
Would you like me to show you how to ... (do something)?
Listing window titles... Is there anything specific you'd like help with using these tools?
Would you like me to demonstrate any specific capability or help you with a particular task?
Would you like me to run any tests?
Would you like me to make any adjustments or would you like to test?
Would you like me to dive deeper into any aspect?
Would you like me to make any other adjustments to this implementation?
Would you like any further information or assistance?
Would you like to me to make any changes?
Would you like me to make any adjustments to this implementation?
Would you like me to show you how to…
What would you like to do next?
### Examples that are QUESTIONS:
Should I go ahead and make the changes?
Should I Go ahead with this plan?
Should I focus on X or Y?
Provide me with the name of the package and version you would like to install.
### Message Content:
${messageContent}
You must provide a response strictly limited to one of the following two words:
QUESTION, READY. No other words, phrases, or explanations are allowed.
Response:`;
const getOptionsClassifierPrompt = (messageContent: string): string => `
You are a simple classifier that takes content and decides if it a list of options
or plans to choose from, or not a list of options to choose from. It is IMPORTANT
that you really know this is a choice, just not numbered steps. If it is a list
of options and you are 95% sure, return OPTIONS, otherwise return NO.
### Example (text -> response):
Would you like me to proceed with creating this file? Please let me know if you want any changes before I write it. -> NO
Here are some options for you to choose from: -> OPTIONS
which one do you want to choose? -> OPTIONS
Would you like me to dive deeper into any aspects of these components? -> NO
Should I focus on X or Y? -> OPTIONS
### Message Content:
${messageContent}
You must provide a response strictly limited to one of the following two words:
OPTIONS, NO. No other words, phrases, or explanations are allowed.
Response:`;
const getOptionsFormatterPrompt = (messageContent: string): string => `
If the content is list of distinct options or plans of action to choose from, and
not just a list of things, but clearly a list of things to choose one from, taking
into account the Message Content alone, try to format it in a json array, like this
JSON array of objects of the form optionTitle:string, optionDescription:string (markdown).
If is not a list of options or plans to choose from, then return empty list.
### Message Content:
${messageContent}
You must provide a response strictly as json in the format descriribed. No other
words, phrases, or explanations are allowed.
Response:`;
const getFormPrompt = (messageContent: string): string => `
When you see a request for several pieces of information, then provide a well formed JSON object like will be shown below.
The response will have:
* a title, description,
* a list of fields, each field will have a label, type, name, placeholder, and required (boolean).
(type is either text or textarea only).
If it is not requesting clearly several pieces of information, just return an empty object.
If the task could be confirmed without more information, return an empty object.
### Example Message:
I'll help you scaffold out a Python package. To create a well-structured Python package, I'll need to know a few key pieces of information:
Package name - What would you like to call your package? (This should be a valid Python package name - lowercase, no spaces, typically using underscores for separators if needed)
Brief description - What is the main purpose of the package? This helps in setting up appropriate documentation and structure.
Initial modules - Do you have specific functionality in mind that should be split into different modules?
Python version - Which Python version(s) do you want to support?
Dependencies - Are there any known external packages you'll need?
### Example JSON Response:
{
"title": "Python Package Scaffolding Form",
"description": "Provide the details below to scaffold a well-structured Python package.",
"fields": [
{
"label": "Package Name",
"type": "text",
"name": "package_name",
"placeholder": "Enter the package name (lowercase, no spaces, use underscores if needed)",
"required": true
},
{
"label": "Brief Description",
"type": "textarea",
"name": "brief_description",
"placeholder": "Enter a brief description of the package's purpose",
"required": true
},
{
"label": "Initial Modules",
"type": "textarea",
"name": "initial_modules",
"placeholder": "List the specific functionalities or modules (optional)",
"required": false
},
{
"label": "Python Version(s)",
"type": "text",
"name": "python_versions",
"placeholder": "Enter the Python version(s) to support (e.g., 3.8, 3.9, 3.10)",
"required": true
},
{
"label": "Dependencies",
"type": "textarea",
"name": "dependencies",
"placeholder": "List any known external packages you'll need (optional)",
"required": false
}
]
}
### Message Content:
${messageContent}
You must provide a response strictly as json in the format described. No other
words, phrases, or explanations are allowed.
Response:`;
/**
* Core function to ask the AI a single question and get a response
* @param prompt The prompt to send to the AI
* @returns Promise<string> The AI's response
*/
export async function ask(prompt: string): Promise<string> {
const response = await fetch(getApiUrl('/ask'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': getSecretKey(),
},
body: JSON.stringify({ prompt }),
});
if (!response.ok) {
throw new Error('Failed to get response');
}
const data = await safeJsonParse<{ response: string }>(response, 'Failed to get AI response');
return data.response;
}
/**
* Utility to ask the LLM multiple questions to clarify without wider context.
* @param messageContent The content to analyze
* @returns Promise<string[]> Array of responses from the AI for each classifier
*/
export async function askAi(messageContent: string): Promise<string[]> {
// First, check the question classifier
const questionClassification = await ask(getQuestionClassifierPrompt(messageContent));
// If READY, return early with empty responses for options
if (questionClassification === 'READY') {
return [questionClassification, 'NO', '[]', '{}'];
}
// Otherwise, proceed with all classifiers in parallel
const prompts = [
Promise.resolve(questionClassification), // Reuse the result we already have
ask(getOptionsClassifierPrompt(messageContent)),
ask(getOptionsFormatterPrompt(messageContent)),
ask(getFormPrompt(messageContent)),
];
return Promise.all(prompts);
}