Lifei/UI parameter input (#5222)

This commit is contained in:
Lifei Zhou
2025-10-20 10:05:54 +11:00
committed by GitHub
parent 6c3e07e9c7
commit 8b53a5696e
27 changed files with 717 additions and 1250 deletions
@@ -1,361 +1,7 @@
import { describe, it, expect } from 'vitest';
import {
extractTemplateVariables,
filterValidUsedParameters,
substituteParameters,
} from '../providerUtils';
import type { RecipeParameter } from '../../api';
import { substituteParameters } from '../providerUtils';
describe('providerUtils', () => {
describe('extractTemplateVariables', () => {
it('should extract simple template variables', () => {
const content = 'Hello {{name}}, welcome to {{app}}!';
const result = extractTemplateVariables(content);
expect(result).toEqual(['name', 'app']);
});
it('should extract variables with underscores', () => {
const content = 'User: {{user_name}}, ID: {{user_id}}';
const result = extractTemplateVariables(content);
expect(result).toEqual(['user_name', 'user_id']);
});
it('should extract variables that start with underscore', () => {
const content = 'Private: {{_private}}, Internal: {{__internal}}';
const result = extractTemplateVariables(content);
expect(result).toEqual(['_private', '__internal']);
});
it('should handle variables with numbers', () => {
const content = 'Item {{item1}}, Version {{version2_0}}';
const result = extractTemplateVariables(content);
expect(result).toEqual(['item1', 'version2_0']);
});
it('should trim whitespace from variables', () => {
const content = 'Hello {{ name }}, welcome to {{ app }}!';
const result = extractTemplateVariables(content);
expect(result).toEqual(['name', 'app']);
});
it('should ignore invalid variable names with spaces', () => {
const content = 'Invalid: {{user name}}, Valid: {{username}}';
const result = extractTemplateVariables(content);
expect(result).toEqual(['username']);
});
it('should ignore invalid variable names with dots', () => {
const content = 'Invalid: {{user.name}}, Valid: {{user_name}}';
const result = extractTemplateVariables(content);
expect(result).toEqual(['user_name']);
});
it('should ignore invalid variable names with pipes', () => {
const content = 'Invalid: {{name|upper}}, Valid: {{name}}';
const result = extractTemplateVariables(content);
expect(result).toEqual(['name']);
});
it('should ignore invalid variable names with special characters', () => {
const content = 'Invalid: {{user@name}}, {{user-name}}, {{user$name}}, Valid: {{username}}';
const result = extractTemplateVariables(content);
expect(result).toEqual(['username']);
});
it('should ignore variables starting with numbers', () => {
const content = 'Invalid: {{1name}}, {{2user}}, Valid: {{name1}}';
const result = extractTemplateVariables(content);
expect(result).toEqual(['name1']);
});
it('should remove duplicates', () => {
const content = 'Hello {{name}}, goodbye {{name}}, welcome {{app}}, use {{app}}';
const result = extractTemplateVariables(content);
expect(result).toEqual(['name', 'app']);
});
it('should handle empty content', () => {
const content = '';
const result = extractTemplateVariables(content);
expect(result).toEqual([]);
});
it('should handle content with no variables', () => {
const content = 'This is just plain text with no variables.';
const result = extractTemplateVariables(content);
expect(result).toEqual([]);
});
it('should handle single braces (not template variables)', () => {
const content = 'This {is} not a {template} variable but {{this}} is.';
const result = extractTemplateVariables(content);
expect(result).toEqual(['this']);
});
it('should handle malformed template syntax', () => {
const content = 'Malformed: {{{name}}}, {{name}}, {name}}';
const result = extractTemplateVariables(content);
expect(result).toEqual(['name']);
});
it('should handle empty variable names', () => {
const content = 'Empty: {{}}, Valid: {{name}}';
const result = extractTemplateVariables(content);
expect(result).toEqual(['name']);
});
it('should handle variables with only whitespace', () => {
const content = 'Whitespace: {{ }}, Valid: {{name}}';
const result = extractTemplateVariables(content);
expect(result).toEqual(['name']);
});
it('should ignore complex template expressions with dots and pipes', () => {
const content =
'Complex: {{steps.fetch_payment_data.data.payments.totalEdgeCount | number_format}}, Valid: {{simple_param}}';
const result = extractTemplateVariables(content);
expect(result).toEqual(['simple_param']);
});
it('should handle complex mixed content', () => {
const content = `
Welcome {{user_name}}!
Your account details:
- ID: {{user_id}}
- Email: {{email_address}}
- Invalid: {{user.email}}
- Invalid: {{user name}}
- Invalid: {{1invalid}}
Thank you for using {{app_name}}!
`;
const result = extractTemplateVariables(content);
expect(result).toEqual(['user_name', 'user_id', 'email_address', 'app_name']);
});
});
describe('filterValidUsedParameters', () => {
const createParameter = (
key: string,
description = '',
requirement: 'required' | 'optional' | 'user_prompt' = 'optional'
): RecipeParameter => ({
key,
description,
input_type: 'string',
requirement,
});
it('should filter parameters to only include valid ones used in content', () => {
const parameters = [
createParameter('valid_param'),
createParameter('invalid param'), // has space
createParameter('unused_param'),
createParameter('used_param'),
];
const recipeContent = {
prompt: 'Use {{valid_param}} and {{used_param}}',
instructions: 'Additional {{valid_param}} usage',
};
const result = filterValidUsedParameters(parameters, recipeContent);
expect(result).toEqual([createParameter('valid_param'), createParameter('used_param')]);
});
it('should handle parameters used only in prompt', () => {
const parameters = [createParameter('prompt_param'), createParameter('unused_param')];
const recipeContent = {
prompt: 'Use {{prompt_param}}',
instructions: 'No parameters here',
};
const result = filterValidUsedParameters(parameters, recipeContent);
expect(result).toEqual([createParameter('prompt_param')]);
});
it('should handle parameters used only in instructions', () => {
const parameters = [createParameter('instruction_param'), createParameter('unused_param')];
const recipeContent = {
prompt: 'No parameters here',
instructions: 'Use {{instruction_param}}',
};
const result = filterValidUsedParameters(parameters, recipeContent);
expect(result).toEqual([createParameter('instruction_param')]);
});
it('should remove duplicate parameters (keep first occurrence)', () => {
const parameters = [
createParameter('duplicate_param', 'First occurrence'),
createParameter('duplicate_param', 'Second occurrence'),
createParameter('unique_param'),
];
const recipeContent = {
prompt: 'Use {{duplicate_param}} and {{unique_param}}',
};
const result = filterValidUsedParameters(parameters, recipeContent);
expect(result).toEqual([
createParameter('duplicate_param', 'First occurrence'),
createParameter('unique_param'),
]);
});
it('should filter out parameters with invalid names', () => {
const parameters = [
createParameter('valid_param'),
createParameter('invalid param'), // space
createParameter('invalid.param'), // dot
createParameter('invalid|param'), // pipe
createParameter('invalid-param'), // dash
createParameter('invalid@param'), // at symbol
createParameter('1invalid'), // starts with number
createParameter('_valid_param'), // starts with underscore (valid)
];
const recipeContent = {
prompt:
'Use all: {{valid_param}} {{invalid param}} {{invalid.param}} {{invalid|param}} {{invalid-param}} {{invalid@param}} {{1invalid}} {{_valid_param}}',
};
const result = filterValidUsedParameters(parameters, recipeContent);
expect(result).toEqual([createParameter('valid_param'), createParameter('_valid_param')]);
});
it('should handle empty parameters array', () => {
const parameters: RecipeParameter[] = [];
const recipeContent = {
prompt: 'Use {{some_param}}',
};
const result = filterValidUsedParameters(parameters, recipeContent);
expect(result).toEqual([]);
});
it('should handle undefined parameters', () => {
const parameters = undefined;
const recipeContent = {
prompt: 'Use {{some_param}}',
};
const result = filterValidUsedParameters(parameters, recipeContent);
expect(result).toEqual([]);
});
it('should handle empty recipe content', () => {
const parameters = [createParameter('param1'), createParameter('param2')];
const recipeContent = {};
const result = filterValidUsedParameters(parameters, recipeContent);
expect(result).toEqual([]);
});
it('should handle recipe content with empty strings', () => {
const parameters = [createParameter('param1'), createParameter('param2')];
const recipeContent = {
prompt: '',
instructions: '',
};
const result = filterValidUsedParameters(parameters, recipeContent);
expect(result).toEqual([]);
});
it('should handle recipe content with undefined values', () => {
const parameters = [createParameter('param1'), createParameter('param2')];
const recipeContent = {
prompt: undefined,
instructions: undefined,
};
const result = filterValidUsedParameters(parameters, recipeContent);
expect(result).toEqual([]);
});
it('should preserve parameter properties', () => {
const parameters = [
{
key: 'test_param',
description: 'A test parameter',
input_type: 'string' as const,
requirement: 'required' as const,
},
];
const recipeContent = {
prompt: 'Use {{test_param}}',
};
const result = filterValidUsedParameters(parameters, recipeContent);
expect(result).toEqual([
{
key: 'test_param',
description: 'A test parameter',
input_type: 'string',
requirement: 'required',
},
]);
});
it('should filter out complex template expressions with dots and pipes', () => {
const parameters = [
createParameter('steps.fetch_payment_data.data.payments.totalEdgeCount | number_format'), // complex invalid
createParameter('simple_param'), // valid
createParameter('another.invalid.param'), // invalid with dots
createParameter('valid_param'), // valid
];
const recipeContent = {
prompt:
'Use {{steps.fetch_payment_data.data.payments.totalEdgeCount | number_format}} and {{simple_param}}',
instructions: 'Also use {{another.invalid.param}} and {{valid_param}}',
};
const result = filterValidUsedParameters(parameters, recipeContent);
expect(result).toEqual([createParameter('simple_param'), createParameter('valid_param')]);
});
it('should handle complex recipe content with multiple parameter usages', () => {
const parameters = [
createParameter('user_name'),
createParameter('user_email'),
createParameter('app_name'),
createParameter('invalid param'),
createParameter('unused_param'),
createParameter('version_number'),
];
const recipeContent = {
prompt: `
Welcome {{user_name}}!
Your details:
- Name: {{user_name}}
- Email: {{user_email}}
`,
instructions: `
Please use {{app_name}} version {{version_number}}.
Contact {{user_email}} for support.
Invalid usage: {{invalid param}}
`,
};
const result = filterValidUsedParameters(parameters, recipeContent);
expect(result).toEqual([
createParameter('user_name'),
createParameter('user_email'),
createParameter('app_name'),
createParameter('version_number'),
]);
});
});
describe('substituteParameters', () => {
it('should substitute simple parameters', () => {
const text = 'Hello {{name}}, welcome to {{app}}!';
+3 -230
View File
@@ -4,130 +4,7 @@ import {
addToAgentOnStartup,
} from '../components/settings/extensions';
import type { ExtensionConfig, FixedExtensionEntry } from '../components/ConfigContext';
import { addSubRecipesToAgent } from '../recipe/add_sub_recipe_on_agent';
import {
extendPrompt,
Recipe,
RecipeParameter,
SubRecipe,
updateAgentProvider,
updateSessionConfig,
} from '../api';
// Desktop-specific system prompt extension
const desktopPrompt = `You are being accessed through the Goose Desktop application.
The user is interacting with you through a graphical user interface with the following features:
- A chat interface where messages are displayed in a conversation format
- Support for markdown formatting in your responses
- Support for code blocks with syntax highlighting
- Tool use messages are included in the chat but outputs may need to be expanded
The user can add extensions for you through the "Settings" page, which is available in the menu
on the top right of the window. There is a section on that page for extensions, and it links to
the registry.
Some extensions are builtin, such as Developer and Memory, while
3rd party extensions can be browsed at https://block.github.io/goose/v1/extensions/.
`;
// Desktop-specific system prompt extension when a bot is in play
const desktopPromptBot = `You are a helpful agent.
You are being accessed through the Goose Desktop application, pre configured with instructions as requested by a human.
The user is interacting with you through a graphical user interface with the following features:
- A chat interface where messages are displayed in a conversation format
- Support for markdown formatting in your responses
- Support for code blocks with syntax highlighting
- Tool use messages are included in the chat but outputs may need to be expanded
It is VERY IMPORTANT that you take note of the provided instructions, also check if a style of output is requested and always do your best to adhere to it.
You can also validate your output after you have generated it to ensure it meets the requirements of the user.
There may be (but not always) some tools mentioned in the instructions which you can check are available to this instance of goose (and try to help the user if they are not or find alternatives).
`;
// Helper function to extract template variables from text (matches backend logic)
export const extractTemplateVariables = (content: string): string[] => {
const templateVarRegex = /\{\{(.*?)\}\}/g;
const variables: string[] = [];
let match;
while ((match = templateVarRegex.exec(content)) !== null) {
const variable = match[1].trim();
if (variable && !variables.includes(variable)) {
// Filter out complex variables that aren't valid parameter names
// This matches the backend logic in filter_complex_variables()
const isValid = isValidParameterName(variable);
if (isValid) {
variables.push(variable);
}
}
}
return variables;
};
// Helper function to check if a variable name is valid for parameters
// Matches backend regex: r"^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*$"
const isValidParameterName = (variable: string): boolean => {
const validVarRegex = /^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*$/;
return validVarRegex.test(variable);
};
// Helper function to filter recipe parameters to only show valid ones that are actually used
export const filterValidUsedParameters = (
parameters: RecipeParameter[] | undefined,
recipeContent: { prompt?: string; instructions?: string; activities?: string[] }
): RecipeParameter[] => {
if (!parameters) {
return [];
}
// Extract all template variables used in the recipe content
const promptVariables = recipeContent.prompt
? extractTemplateVariables(recipeContent.prompt)
: [];
const instructionVariables = recipeContent.instructions
? extractTemplateVariables(recipeContent.instructions)
: [];
// Extract variables from activities using flatMap
const activityVariables = recipeContent.activities?.flatMap(extractTemplateVariables) ?? [];
const allUsedVariables = [
...new Set([...promptVariables, ...instructionVariables, ...activityVariables]),
];
// Filter parameters to only include:
// 1. Parameters with valid names (no spaces, dots, pipes, etc.)
// 2. Parameters that are actually used in the recipe content
// 3. Remove duplicates (keep first occurrence)
const seenKeys = new Set<string>();
return parameters.filter((param) => {
// Check if parameter key is valid (no spaces, special characters)
const isValid = isValidParameterName(param.key);
if (!isValid) {
return false;
}
// Check if parameter is actually used in the recipe content
const isUsed = allUsedVariables.includes(param.key);
if (!isUsed) {
return false;
}
// Remove duplicates (keep first occurrence)
if (seenKeys.has(param.key)) {
return false;
}
seenKeys.add(param.key);
return true;
});
};
import { Recipe, updateAgentProvider, updateFromSession } from '../api';
// Helper function to substitute parameters in text
export const substituteParameters = (text: string, params: Record<string, string>): string => {
@@ -142,54 +19,6 @@ export const substituteParameters = (text: string, params: Record<string, string
return substitutedText;
};
/**
* Updates the system prompt with parameter-substituted instructions
* This should be called after recipe parameters are collected
*/
export const updateSystemPromptWithParameters = async (
sessionId: string,
recipeParameters: Record<string, string>,
recipe?: {
instructions?: string | null;
sub_recipes?: SubRecipe[] | null;
parameters?: RecipeParameter[] | null;
}
): Promise<void> => {
const subRecipes = recipe?.sub_recipes;
try {
const originalInstructions = recipe?.instructions;
if (!originalInstructions) {
return;
}
// Substitute parameters in the instructions
const substitutedInstructions = substituteParameters(originalInstructions, recipeParameters);
// Update the system prompt with substituted instructions
const response = await extendPrompt({
body: {
session_id: sessionId,
extension: `${desktopPromptBot}\nIMPORTANT instructions for you to operate as agent:\n${substitutedInstructions}`,
},
});
if (response.error) {
console.warn(`Failed to update system prompt with parameters: ${response.error}`);
}
} catch (error) {
console.error('Error updating system prompt with parameters:', error);
}
if (subRecipes && subRecipes?.length > 0) {
for (const subRecipe of subRecipes) {
if (subRecipe.values) {
for (const key in subRecipe.values) {
subRecipe.values[key] = substituteParameters(subRecipe.values[key], recipeParameters);
}
}
}
await addSubRecipesToAgent(sessionId, subRecipes);
}
};
export const initializeSystem = async (
sessionId: string,
provider: string,
@@ -223,69 +52,13 @@ export const initializeSystem = async (
if (!sessionId) {
console.log('This will not end well');
}
// Get recipe - prefer from options (session metadata) over app config
const recipe = options?.recipe;
const recipe_instructions = (recipe as { instructions?: string })?.instructions;
const responseConfig = (recipe as { response?: { json_schema?: unknown } })?.response;
const subRecipes = (recipe as { sub_recipes?: SubRecipe[] })?.sub_recipes;
const hasSubRecipes = subRecipes && subRecipes?.length > 0;
const recipeParameters = options?.recipeParameters;
// Determine the system prompt
let prompt = desktopPrompt;
// If we have recipe instructions, add them to the system prompt with parameter substitution
if (recipe_instructions) {
const substitutedInstructions = recipeParameters
? substituteParameters(recipe_instructions, recipeParameters)
: recipe_instructions;
prompt = `${desktopPromptBot}\nIMPORTANT instructions for you to operate as agent:\n${substitutedInstructions}`;
}
// Extend the system prompt with desktop-specific information
await extendPrompt({
await updateFromSession({
body: {
session_id: sessionId,
extension: prompt,
},
throwOnError: true,
});
if (hasSubRecipes) {
let finalSubRecipes = subRecipes;
// If we have parameters, substitute them in sub-recipe values
if (recipeParameters) {
finalSubRecipes = subRecipes.map((subRecipe) => ({
...subRecipe,
values: subRecipe.values
? Object.fromEntries(
Object.entries(subRecipe.values).map(([key, value]) => [
key,
substituteParameters(value, recipeParameters),
])
)
: subRecipe.values,
}));
}
await addSubRecipesToAgent(sessionId, finalSubRecipes);
}
// Configure session with response config if present
if (responseConfig?.json_schema) {
const sessionConfigResponse = await updateSessionConfig({
body: {
session_id: sessionId,
response: responseConfig,
},
});
if (sessionConfigResponse.error) {
console.warn(`Failed to configure session: ${sessionConfigResponse.error}`);
}
}
if (!options?.getExtensions || !options?.addExtension) {
console.warn('Extension helpers not provided in alpha mode');
return;