Platform extensions sketch (#4868)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
Douwe Osinga
2025-10-03 13:45:37 -04:00
committed by GitHub
parent 5b8efb5b9a
commit 96ded37e15
36 changed files with 742 additions and 847 deletions
+49 -17
View File
@@ -2313,6 +2313,7 @@
"description": "Server-sent events client with a URI endpoint",
"required": [
"name",
"description",
"uri",
"type"
],
@@ -2325,12 +2326,10 @@
},
"bundled": {
"type": "boolean",
"description": "Whether this extension is bundled with goose",
"nullable": true
},
"description": {
"type": "string",
"nullable": true
"type": "string"
},
"env_keys": {
"type": "array",
@@ -2367,6 +2366,7 @@
"description": "Standard I/O client with command and arguments",
"required": [
"name",
"description",
"cmd",
"args",
"type"
@@ -2386,15 +2386,13 @@
},
"bundled": {
"type": "boolean",
"description": "Whether this extension is bundled with goose",
"nullable": true
},
"cmd": {
"type": "string"
},
"description": {
"type": "string",
"nullable": true
"type": "string"
},
"env_keys": {
"type": "array",
@@ -2425,9 +2423,10 @@
},
{
"type": "object",
"description": "Built-in extension that is part of the goose binary",
"description": "Built-in extension that is part of the bundled goose MCP server",
"required": [
"name",
"description",
"type"
],
"properties": {
@@ -2439,12 +2438,10 @@
},
"bundled": {
"type": "boolean",
"description": "Whether this extension is bundled with goose",
"nullable": true
},
"description": {
"type": "string",
"nullable": true
"type": "string"
},
"display_name": {
"type": "string",
@@ -2468,11 +2465,46 @@
}
}
},
{
"type": "object",
"description": "Platform extensions that have direct access to the agent etc and run in the agent process",
"required": [
"name",
"description",
"type"
],
"properties": {
"available_tools": {
"type": "array",
"items": {
"type": "string"
}
},
"bundled": {
"type": "boolean",
"nullable": true
},
"description": {
"type": "string"
},
"name": {
"type": "string",
"description": "The name used to identify this extension"
},
"type": {
"type": "string",
"enum": [
"platform"
]
}
}
},
{
"type": "object",
"description": "Streamable HTTP client with a URI endpoint using MCP Streamable HTTP specification",
"required": [
"name",
"description",
"uri",
"type"
],
@@ -2485,12 +2517,10 @@
},
"bundled": {
"type": "boolean",
"description": "Whether this extension is bundled with goose",
"nullable": true
},
"description": {
"type": "string",
"nullable": true
"type": "string"
},
"env_keys": {
"type": "array",
@@ -2533,6 +2563,7 @@
"description": "Frontend-provided tools that will be called through the frontend",
"required": [
"name",
"description",
"tools",
"type"
],
@@ -2545,9 +2576,11 @@
},
"bundled": {
"type": "boolean",
"description": "Whether this extension is bundled with goose",
"nullable": true
},
"description": {
"type": "string"
},
"instructions": {
"type": "string",
"description": "Instructions for how to use these tools",
@@ -2577,6 +2610,7 @@
"description": "Inline Python code that will be executed using uvx",
"required": [
"name",
"description",
"code",
"type"
],
@@ -2600,9 +2634,7 @@
"nullable": true
},
"description": {
"type": "string",
"description": "Description of what the extension does",
"nullable": true
"type": "string"
},
"name": {
"type": "string",
+15 -23
View File
@@ -192,11 +192,8 @@ export type ExtendPromptResponse = {
*/
export type ExtensionConfig = {
available_tools?: Array<string>;
/**
* Whether this extension is bundled with goose
*/
bundled?: boolean | null;
description?: string | null;
description: string;
env_keys?: Array<string>;
envs?: Envs;
/**
@@ -209,12 +206,9 @@ export type ExtensionConfig = {
} | {
args: Array<string>;
available_tools?: Array<string>;
/**
* Whether this extension is bundled with goose
*/
bundled?: boolean | null;
cmd: string;
description?: string | null;
description: string;
env_keys?: Array<string>;
envs?: Envs;
/**
@@ -225,11 +219,8 @@ export type ExtensionConfig = {
type: 'stdio';
} | {
available_tools?: Array<string>;
/**
* Whether this extension is bundled with goose
*/
bundled?: boolean | null;
description?: string | null;
description: string;
display_name?: string | null;
/**
* The name used to identify this extension
@@ -239,11 +230,17 @@ export type ExtensionConfig = {
type: 'builtin';
} | {
available_tools?: Array<string>;
/**
* Whether this extension is bundled with goose
*/
bundled?: boolean | null;
description?: string | null;
description: string;
/**
* The name used to identify this extension
*/
name: string;
type: 'platform';
} | {
available_tools?: Array<string>;
bundled?: boolean | null;
description: string;
env_keys?: Array<string>;
envs?: Envs;
headers?: {
@@ -258,10 +255,8 @@ export type ExtensionConfig = {
uri: string;
} | {
available_tools?: Array<string>;
/**
* Whether this extension is bundled with goose
*/
bundled?: boolean | null;
description: string;
/**
* Instructions for how to use these tools
*/
@@ -285,10 +280,7 @@ export type ExtensionConfig = {
* Python package dependencies required by this extension
*/
dependencies?: Array<string> | null;
/**
* Description of what the extension does
*/
description?: string | null;
description: string;
/**
* The name used to identify this extension
*/
@@ -38,11 +38,6 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro
// Effect to handle message content changes and ensure persistence
useEffect(() => {
// Log content display for debugging
window.electron.logInfo(
`Displaying content for message: ${message.id} content: ${displayText}`
);
// If we're not editing, update the edit content to match the current message
if (!isEditing) {
setEditContent(displayText);
@@ -9,7 +9,6 @@ import {
createExtensionConfig,
ExtensionFormData,
extensionToFormData,
extractExtensionConfig,
getDefaultFormData,
} from './utils';
@@ -84,33 +83,25 @@ export default function ExtensionsSection({
await getExtensions(true); // Force refresh - this will update the context
}, [getExtensions]);
const handleExtensionToggle = async (extension: FixedExtensionEntry) => {
const handleExtensionToggle = async (extensionConfig: FixedExtensionEntry) => {
if (customToggle) {
await customToggle(extension);
await customToggle(extensionConfig);
return true;
}
// If extension is enabled, we are trying to toggle if off, otherwise on
const toggleDirection = extension.enabled ? 'toggleOff' : 'toggleOn';
const extensionConfig = extractExtensionConfig(extension);
const toggleDirection = extensionConfig.enabled ? 'toggleOff' : 'toggleOn';
// eslint-disable-next-line no-useless-catch
try {
await toggleExtension({
toggle: toggleDirection,
extensionConfig: extensionConfig,
addToConfig: addExtension,
toastOptions: { silent: false },
sessionId: sessionId,
});
await toggleExtension({
toggle: toggleDirection,
extensionConfig: extensionConfig,
addToConfig: addExtension,
toastOptions: { silent: false },
sessionId: sessionId,
});
await fetchExtensions(); // Refresh the list after successful toggle
return true; // Indicate success
} catch (error) {
// Don't refresh the extension list on failure - this allows our visual state rollback to work
// The actual state in the config hasn't changed anyway
throw error; // Re-throw to let the ExtensionItem component know it failed
}
await fetchExtensions();
return true;
};
const handleConfigureClick = (extension: FixedExtensionEntry) => {
@@ -49,6 +49,7 @@ describe('Agent API', () => {
describe('extensionApiCall', () => {
const mockExtensionConfig: ExtensionConfig = {
type: 'stdio',
description: 'description',
name: 'test-extension',
cmd: 'python',
args: ['script.py'],
@@ -237,6 +238,7 @@ describe('Agent API', () => {
const mockExtensionConfig: ExtensionConfig = {
type: 'stdio',
name: 'Test Extension',
description: 'Test description',
cmd: 'python',
args: ['script.py'],
};
@@ -286,6 +288,7 @@ describe('Agent API', () => {
const sseConfig: ExtensionConfig = {
type: 'sse',
name: 'SSE Extension',
description: 'Test description',
uri: 'http://localhost:8080/events',
};
@@ -8,7 +8,7 @@ type BundledExtension = {
id: string;
name: string;
display_name?: string;
description?: string;
description: string;
enabled: boolean;
type: 'builtin' | 'stdio' | 'sse';
cmd?: string;
@@ -59,18 +59,19 @@ export async function syncBundledExtensions(
switch (bundledExt.type) {
case 'builtin':
extConfig = {
name: bundledExt.name,
display_name: bundledExt.display_name,
type: bundledExt.type,
name: bundledExt.name,
description: bundledExt.description,
display_name: bundledExt.display_name,
timeout: bundledExt.timeout ?? 300,
bundled: true,
};
break;
case 'stdio':
extConfig = {
type: bundledExt.type,
name: bundledExt.name,
description: bundledExt.description,
type: bundledExt.type,
timeout: bundledExt.timeout,
cmd: bundledExt.cmd || '',
args: bundledExt.args || [],
@@ -81,9 +82,9 @@ export async function syncBundledExtensions(
break;
case 'sse':
extConfig = {
type: bundledExt.type,
name: bundledExt.name,
description: bundledExt.description,
type: bundledExt.type,
timeout: bundledExt.timeout,
uri: bundledExt.uri || '',
bundled: true,
@@ -25,6 +25,7 @@ describe('Extension Manager', () => {
const mockExtensionConfig = {
type: 'stdio' as const,
name: 'test-extension',
description: 'test-extension',
cmd: 'python',
args: ['script.py'],
timeout: 300,
@@ -80,79 +80,42 @@ export default function ExtensionList({
}
// Helper functions
// Helper function to get a friendly title from extension name
export function getFriendlyTitle(extension: FixedExtensionEntry): string {
let name = '';
// if it's a builtin, check if there's a display_name (old configs didn't have this field)
if (
'bundled' in extension &&
extension.bundled === true &&
'display_name' in extension &&
extension.display_name
) {
// If we have a display_name for a builtin, use it directly
return extension.display_name;
} else {
// For non-builtins or builtins without display_name
name = extension.name;
}
// Format the name to be more readable
const name = (extension.type === 'builtin' && extension.display_name) || extension.name;
return name
.split(/[-_]/) // Split on hyphens and underscores
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
export interface SubtitleParts {
description: string | null;
command: string | null;
function normalizeExtensionName(name: string): string {
return name.toLowerCase().replace(/\s+/g, '');
}
// Helper function to get a subtitle based on extension type and configuration
export function getSubtitle(config: ExtensionConfig): SubtitleParts {
if (config.type === 'builtin') {
// Find matching extension in the data
const extensionData = builtInExtensionsData.find(
(ext) =>
ext.name.toLowerCase().replace(/\s+/g, '') === config.name.toLowerCase().replace(/\s+/g, '')
);
return {
description: extensionData?.description || 'Built-in extension',
command: null,
};
}
export function getSubtitle(config: ExtensionConfig) {
switch (config.type) {
case 'builtin': {
const extensionData = builtInExtensionsData.find(
(ext) => normalizeExtensionName(ext.name) === normalizeExtensionName(config.name)
);
return {
description: extensionData?.description || config.description || 'Built-in extension',
command: null,
};
}
case 'sse':
case 'streamable_http': {
const prefix = `${config.type.toUpperCase().replace('_', ' ')} extension`;
return {
description: `${prefix}${config.description ? ': ' + config.description : ''}`,
command: config.uri || null,
};
}
if (config.type === 'stdio') {
// Only include command if it exists
const full_command = config.cmd
? combineCmdAndArgs(removeShims(config.cmd), config.args)
: null;
return {
description: config.description || null,
command: full_command,
};
default:
return {
description: config.description || null,
command: 'cmd' in config ? combineCmdAndArgs(removeShims(config.cmd), config.args) : null,
};
}
if (config.type === 'sse') {
const description = config.description
? `SSE extension: ${config.description}`
: 'SSE extension';
const command = config.uri || null;
return { description, command };
}
if (config.type === 'streamable_http') {
const description = config.description
? `Streamable HTTP extension: ${config.description}`
: 'Streamable HTTP extension';
const command = config.uri || null;
return { description, command };
}
return {
description: 'Unknown type of extension',
command: null,
};
}
@@ -6,7 +6,6 @@ import {
createExtensionConfig,
splitCmdAndArgs,
combineCmdAndArgs,
extractExtensionConfig,
replaceWithShims,
removeShims,
extractCommand,
@@ -153,6 +152,7 @@ describe('Extension Utils', () => {
const extension: FixedExtensionEntry = {
type: 'stdio',
name: 'legacy-extension',
description: 'legacy',
cmd: 'node',
args: ['app.js'],
enabled: true,
@@ -176,6 +176,7 @@ describe('Extension Utils', () => {
const extension: FixedExtensionEntry = {
type: 'builtin',
name: 'developer',
description: 'developer',
enabled: true,
};
@@ -183,7 +184,7 @@ describe('Extension Utils', () => {
expect(formData).toEqual({
name: 'developer',
description: '',
description: 'developer',
type: 'builtin',
cmd: undefined,
endpoint: undefined,
@@ -284,7 +285,7 @@ describe('Extension Utils', () => {
it('should create builtin extension config', () => {
const formData = {
name: 'developer',
description: '',
description: 'developer',
type: 'builtin' as const,
cmd: '',
endpoint: '',
@@ -299,6 +300,7 @@ describe('Extension Utils', () => {
expect(config).toEqual({
type: 'builtin',
name: 'developer',
description: 'developer',
timeout: 300,
});
});
@@ -340,30 +342,6 @@ describe('Extension Utils', () => {
});
});
describe('extractExtensionConfig', () => {
it('should extract extension config from fixed entry', () => {
const fixedEntry: FixedExtensionEntry = {
type: 'stdio',
name: 'test-extension',
cmd: 'python',
args: ['script.py'],
enabled: true,
timeout: 300,
};
const config = extractExtensionConfig(fixedEntry);
expect(config).toEqual({
type: 'stdio',
name: 'test-extension',
cmd: 'python',
args: ['script.py'],
enabled: true,
timeout: 300,
});
});
});
describe('replaceWithShims', () => {
beforeEach(() => {
mockElectron.getBinaryPath.mockImplementation((binary: string) => {
@@ -96,12 +96,11 @@ export function extensionToFormData(extension: FixedExtensionEntry): ExtensionFo
return {
name: extension.name || '',
description:
extension.type === 'stdio' || extension.type === 'sse' || extension.type === 'streamable_http'
? extension.description || ''
: '',
description: extension.description || '',
type:
extension.type === 'frontend' || extension.type === 'inline_python'
extension.type === 'frontend' ||
extension.type === 'inline_python' ||
extension.type === 'platform'
? 'stdio'
: extension.type,
cmd: extension.type === 'stdio' ? combineCmdAndArgs(extension.cmd, extension.args) : undefined,
@@ -166,6 +165,7 @@ export function createExtensionConfig(formData: ExtensionFormData): ExtensionCon
return {
type: formData.type,
name: formData.name,
description: formData.description,
timeout: formData.timeout,
};
}
@@ -186,17 +186,6 @@ export function combineCmdAndArgs(cmd: string, args: string[]): string {
return [cmd, ...args].join(' ');
}
/**
* Extracts the ExtensionConfig from a FixedExtensionEntry object
* @param fixedEntry - The FixedExtensionEntry object
* @returns The ExtensionConfig portion of the object
*/
export function extractExtensionConfig(fixedEntry: FixedExtensionEntry): ExtensionConfig {
// todo: enabled not used?
const { ...extensionConfig } = fixedEntry;
return extensionConfig;
}
export async function replaceWithShims(cmd: string) {
const binaryPathMap: Record<string, string> = {
goosed: await window.electron.getBinaryPath('goosed'),
@@ -56,6 +56,7 @@ export default function PermissionRulesModal({ isOpen, onClose }: PermissionRule
enabledExtensions.push({
name: 'platform',
type: 'builtin',
description: 'platform',
enabled: true,
});
// Sort extensions by name to maintain consistent order
@@ -50,9 +50,11 @@ export default function PermissionSettingsView({ onClose }: { onClose: () => voi
const extensionsList = await getExtensions(true); // Force refresh
// Filter out disabled extensions
const enabledExtensions = extensionsList.filter((extension) => extension.enabled);
// TODO(Douwe): this should really be a real extension:
enabledExtensions.push({
name: 'platform',
type: 'builtin',
description: 'platform',
enabled: true,
});
// Sort extensions by name to maintain consistent order
+7 -1
View File
@@ -20,6 +20,7 @@ describe('Recipe Validation', () => {
type: 'builtin',
name: 'developer',
display_name: 'Developer',
description: 'Developer',
timeout: 300,
bundled: true,
},
@@ -36,6 +37,7 @@ describe('Recipe Validation', () => {
{
type: 'builtin',
name: 'developer',
description: 'Developer',
},
],
};
@@ -58,6 +60,7 @@ describe('Recipe Validation', () => {
{
type: 'builtin',
name: 'developer',
description: 'developer',
},
],
};
@@ -75,6 +78,7 @@ describe('Recipe Validation', () => {
{
type: 'builtin',
name: 'developer',
description: 'developer',
},
],
};
@@ -180,9 +184,9 @@ describe('Recipe Validation', () => {
...validRecipe,
extensions: [
{
// Only required fields for builtin extension
type: 'builtin',
name: 'developer',
description: 'description',
},
],
};
@@ -550,10 +554,12 @@ describe('Recipe Validation', () => {
{
type: 'builtin',
name: 'developer',
description: 'developer',
},
{
type: 'builtin',
name: 'computercontroller',
description: 'computercontroller',
},
],
};
+1 -3
View File
@@ -3,7 +3,6 @@ import {
syncBundledExtensions,
addToAgentOnStartup,
} from '../components/settings/extensions';
import { extractExtensionConfig } from '../components/settings/extensions/utils';
import type { ExtensionConfig, FixedExtensionEntry } from '../components/ConfigContext';
import { addSubRecipesToAgent } from '../recipe/add_sub_recipe_on_agent';
import {
@@ -272,8 +271,7 @@ export const initializeSystem = async (
options?.setIsExtensionsLoading?.(true);
const extensionLoadingPromises = enabledExtensions.map(async (extensionEntry) => {
const extensionConfig = extractExtensionConfig(extensionEntry);
const extensionLoadingPromises = enabledExtensions.map(async (extensionConfig) => {
const extensionName = extensionConfig.name;
try {