Release v1.01 Enhanced: Vi Control, TUI Gen5, Core Stability
This commit is contained in:
466
bin/goose-ultra-final/src/services/viAgentPlanner.ts
Normal file
466
bin/goose-ultra-final/src/services/viAgentPlanner.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
// Vi Agent Planner - Hierarchical Task Planning
|
||||
// Converts user requests into structured TaskPlans with phases
|
||||
// Implements guard rails to prevent typing instructions
|
||||
|
||||
export interface TaskPhase {
|
||||
name: string;
|
||||
description: string;
|
||||
steps: TaskStep[];
|
||||
status: 'pending' | 'active' | 'completed' | 'failed';
|
||||
successCriteria: string[];
|
||||
}
|
||||
|
||||
export interface TaskStep {
|
||||
id: string;
|
||||
type: StepType;
|
||||
params: Record<string, any>;
|
||||
description: string;
|
||||
status: 'pending' | 'executing' | 'verifying' | 'completed' | 'failed' | 'retry';
|
||||
successCriteria: string[];
|
||||
retryCount: number;
|
||||
maxRetries: number;
|
||||
result?: StepResult;
|
||||
}
|
||||
|
||||
export type StepType =
|
||||
| 'OPEN_BROWSER'
|
||||
| 'NAVIGATE_URL'
|
||||
| 'WAIT_FOR_LOAD'
|
||||
| 'FOCUS_ELEMENT'
|
||||
| 'TYPE_TEXT'
|
||||
| 'PRESS_KEY'
|
||||
| 'CLICK_ELEMENT'
|
||||
| 'CLICK_COORDINATES'
|
||||
| 'EXTRACT_RESULTS'
|
||||
| 'RANK_RESULTS'
|
||||
| 'OPEN_RESULT'
|
||||
| 'VERIFY_STATE'
|
||||
| 'SCREENSHOT'
|
||||
| 'ASK_USER';
|
||||
|
||||
export interface StepResult {
|
||||
success: boolean;
|
||||
output?: any;
|
||||
error?: string;
|
||||
verificationPassed: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface TaskPlan {
|
||||
taskId: string;
|
||||
objective: string;
|
||||
originalInput: string;
|
||||
phases: TaskPhase[];
|
||||
status: 'planning' | 'executing' | 'completed' | 'failed' | 'needs_user';
|
||||
constraints: string[];
|
||||
createdAt: number;
|
||||
completedAt?: number;
|
||||
}
|
||||
|
||||
export interface ParsedIntent {
|
||||
searchQuery?: string; // EXACT query text only
|
||||
targetUrl?: string; // URL to navigate to
|
||||
applicationToOpen?: string; // App to launch
|
||||
browsingObjective?: string; // What to do after search (e.g., "find most interesting")
|
||||
selectionCriteria?: string[]; // How to choose results
|
||||
hasFollowUpAction: boolean;
|
||||
}
|
||||
|
||||
// === INTENT PARSER ===
|
||||
// Strictly separates query text from follow-up actions
|
||||
|
||||
const FOLLOW_UP_PATTERNS = [
|
||||
/,?\s*then\s+(.+)/i,
|
||||
/,?\s*and\s+then\s+(.+)/i,
|
||||
/,?\s*after\s+that\s+(.+)/i,
|
||||
/,?\s*and\s+(?:go\s+through|look\s+at|browse|analyze|find|choose|select|pick|open\s+the)\s+(.+)/i,
|
||||
];
|
||||
|
||||
const INSTRUCTION_POISON_PATTERNS = [
|
||||
/then\s+go\s+through/i,
|
||||
/then\s+open\s+the/i,
|
||||
/and\s+open\s+the\s+one/i,
|
||||
/go\s+through\s+results/i,
|
||||
/open\s+the\s+most/i,
|
||||
/find\s+the\s+most/i,
|
||||
/choose\s+the\s+best/i,
|
||||
/pick\s+one/i,
|
||||
/select\s+the/i,
|
||||
];
|
||||
|
||||
export function parseUserIntent(input: string): ParsedIntent {
|
||||
const intent: ParsedIntent = {
|
||||
hasFollowUpAction: false
|
||||
};
|
||||
|
||||
let remaining = input.trim();
|
||||
|
||||
// Step 1: Extract follow-up actions FIRST
|
||||
for (const pattern of FOLLOW_UP_PATTERNS) {
|
||||
const match = remaining.match(pattern);
|
||||
if (match) {
|
||||
intent.browsingObjective = match[1].trim();
|
||||
intent.hasFollowUpAction = true;
|
||||
remaining = remaining.replace(pattern, '').trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Extract search query - be VERY strict about what goes in
|
||||
const searchPatterns = [
|
||||
/search\s+(?:for\s+)?["']([^"']+)["']/i, // search for "query"
|
||||
/search\s+(?:for\s+)?(\w+)(?:\s|$|,)/i, // search for WORD (single word only)
|
||||
/search\s+(?:for\s+)?([^,]+?)(?:,|then|and\s+then|$)/i, // search for query, then...
|
||||
];
|
||||
|
||||
for (const pattern of searchPatterns) {
|
||||
const match = remaining.match(pattern);
|
||||
if (match) {
|
||||
let query = match[1].trim();
|
||||
|
||||
// GUARD: Remove any instruction poison from query
|
||||
for (const poison of INSTRUCTION_POISON_PATTERNS) {
|
||||
if (poison.test(query)) {
|
||||
// Truncate at the poison pattern
|
||||
query = query.replace(poison, '').trim();
|
||||
intent.hasFollowUpAction = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up trailing conjunctions
|
||||
query = query.replace(/,?\s*(then|and)?\s*$/i, '').trim();
|
||||
|
||||
if (query.length > 0 && query.length < 100) {
|
||||
intent.searchQuery = query;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Extract URL
|
||||
const urlMatch = remaining.match(/(?:go\s+to|open|navigate\s+to|visit)\s+(\S+\.(?:com|org|net|io|dev|ai|gov|edu)\S*)/i);
|
||||
if (urlMatch) {
|
||||
let url = urlMatch[1];
|
||||
if (!url.startsWith('http')) url = 'https://' + url;
|
||||
intent.targetUrl = url;
|
||||
}
|
||||
|
||||
// Step 4: Extract application
|
||||
const appPatterns: { pattern: RegExp; app: string }[] = [
|
||||
{ pattern: /open\s+edge/i, app: 'msedge' },
|
||||
{ pattern: /open\s+chrome/i, app: 'chrome' },
|
||||
{ pattern: /open\s+firefox/i, app: 'firefox' },
|
||||
{ pattern: /open\s+notepad/i, app: 'notepad' },
|
||||
];
|
||||
|
||||
for (const { pattern, app } of appPatterns) {
|
||||
if (pattern.test(remaining)) {
|
||||
intent.applicationToOpen = app;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Extract selection criteria
|
||||
if (intent.browsingObjective) {
|
||||
const criteriaPatterns = [
|
||||
{ pattern: /most\s+interesting/i, criteria: 'interesting' },
|
||||
{ pattern: /most\s+relevant/i, criteria: 'relevant' },
|
||||
{ pattern: /best/i, criteria: 'best' },
|
||||
{ pattern: /first/i, criteria: 'first' },
|
||||
{ pattern: /official/i, criteria: 'official' },
|
||||
{ pattern: /wikipedia/i, criteria: 'wikipedia' },
|
||||
];
|
||||
|
||||
intent.selectionCriteria = [];
|
||||
for (const { pattern, criteria } of criteriaPatterns) {
|
||||
if (pattern.test(intent.browsingObjective)) {
|
||||
intent.selectionCriteria.push(criteria);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intent;
|
||||
}
|
||||
|
||||
// === PLAN GENERATOR ===
|
||||
// Creates hierarchical TaskPlan from ParsedIntent
|
||||
|
||||
export function generateTaskPlan(input: string): TaskPlan {
|
||||
const intent = parseUserIntent(input);
|
||||
const taskId = `task-${Date.now()}`;
|
||||
|
||||
const plan: TaskPlan = {
|
||||
taskId,
|
||||
objective: input,
|
||||
originalInput: input,
|
||||
phases: [],
|
||||
status: 'planning',
|
||||
constraints: [
|
||||
'TypedText must be EXACT query only - never include instructions',
|
||||
'Each phase must verify success before proceeding',
|
||||
'Browsing requires extracting and ranking results'
|
||||
],
|
||||
createdAt: Date.now()
|
||||
};
|
||||
|
||||
// Phase 1: Navigate (if URL or browser needed)
|
||||
if (intent.applicationToOpen || intent.targetUrl) {
|
||||
const navigatePhase: TaskPhase = {
|
||||
name: 'Navigate',
|
||||
description: 'Open browser and navigate to target',
|
||||
status: 'pending',
|
||||
successCriteria: ['Browser window is open', 'Target page is loaded'],
|
||||
steps: []
|
||||
};
|
||||
|
||||
if (intent.applicationToOpen) {
|
||||
navigatePhase.steps.push({
|
||||
id: `${taskId}-nav-1`,
|
||||
type: 'OPEN_BROWSER',
|
||||
params: { browser: intent.applicationToOpen },
|
||||
description: `Open ${intent.applicationToOpen}`,
|
||||
status: 'pending',
|
||||
successCriteria: ['Browser process started'],
|
||||
retryCount: 0,
|
||||
maxRetries: 2
|
||||
});
|
||||
}
|
||||
|
||||
if (intent.targetUrl) {
|
||||
navigatePhase.steps.push({
|
||||
id: `${taskId}-nav-2`,
|
||||
type: 'NAVIGATE_URL',
|
||||
params: { url: intent.targetUrl },
|
||||
description: `Navigate to ${intent.targetUrl}`,
|
||||
status: 'pending',
|
||||
successCriteria: ['URL matches target', 'Page content loaded'],
|
||||
retryCount: 0,
|
||||
maxRetries: 2
|
||||
});
|
||||
|
||||
navigatePhase.steps.push({
|
||||
id: `${taskId}-nav-3`,
|
||||
type: 'WAIT_FOR_LOAD',
|
||||
params: { ms: 2000 },
|
||||
description: 'Wait for page to fully load',
|
||||
status: 'pending',
|
||||
successCriteria: ['Page is interactive'],
|
||||
retryCount: 0,
|
||||
maxRetries: 1
|
||||
});
|
||||
}
|
||||
|
||||
plan.phases.push(navigatePhase);
|
||||
}
|
||||
|
||||
// Phase 2: Search (if query exists)
|
||||
if (intent.searchQuery) {
|
||||
const searchPhase: TaskPhase = {
|
||||
name: 'Search',
|
||||
description: `Search for: "${intent.searchQuery}"`,
|
||||
status: 'pending',
|
||||
successCriteria: ['Search query entered', 'Results page loaded'],
|
||||
steps: [
|
||||
{
|
||||
id: `${taskId}-search-1`,
|
||||
type: 'FOCUS_ELEMENT',
|
||||
params: { selector: 'input[name="q"], input[type="search"], textarea[name="q"]' },
|
||||
description: 'Focus search input field',
|
||||
status: 'pending',
|
||||
successCriteria: ['Search input is focused'],
|
||||
retryCount: 0,
|
||||
maxRetries: 2
|
||||
},
|
||||
{
|
||||
id: `${taskId}-search-2`,
|
||||
type: 'TYPE_TEXT',
|
||||
params: {
|
||||
text: intent.searchQuery, // ONLY the query, never instructions!
|
||||
verify: true
|
||||
},
|
||||
description: `Type search query: "${intent.searchQuery}"`,
|
||||
status: 'pending',
|
||||
successCriteria: [`Input contains: ${intent.searchQuery}`],
|
||||
retryCount: 0,
|
||||
maxRetries: 1
|
||||
},
|
||||
{
|
||||
id: `${taskId}-search-3`,
|
||||
type: 'PRESS_KEY',
|
||||
params: { key: 'enter' },
|
||||
description: 'Submit search',
|
||||
status: 'pending',
|
||||
successCriteria: ['Page navigation occurred'],
|
||||
retryCount: 0,
|
||||
maxRetries: 1
|
||||
},
|
||||
{
|
||||
id: `${taskId}-search-4`,
|
||||
type: 'WAIT_FOR_LOAD',
|
||||
params: { ms: 2000 },
|
||||
description: 'Wait for search results',
|
||||
status: 'pending',
|
||||
successCriteria: ['Results container visible'],
|
||||
retryCount: 0,
|
||||
maxRetries: 1
|
||||
},
|
||||
{
|
||||
id: `${taskId}-search-5`,
|
||||
type: 'VERIFY_STATE',
|
||||
params: {
|
||||
expected: 'search_results_page',
|
||||
indicators: ['Results count', 'Result links present']
|
||||
},
|
||||
description: 'Verify search results loaded',
|
||||
status: 'pending',
|
||||
successCriteria: ['Search results are visible'],
|
||||
retryCount: 0,
|
||||
maxRetries: 2
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
plan.phases.push(searchPhase);
|
||||
}
|
||||
|
||||
// Phase 3: Browse Results (if follow-up action exists)
|
||||
if (intent.hasFollowUpAction && intent.browsingObjective) {
|
||||
const browsePhase: TaskPhase = {
|
||||
name: 'BrowseResults',
|
||||
description: intent.browsingObjective,
|
||||
status: 'pending',
|
||||
successCriteria: ['Results extracted', 'Best result identified', 'Result page opened'],
|
||||
steps: [
|
||||
{
|
||||
id: `${taskId}-browse-1`,
|
||||
type: 'EXTRACT_RESULTS',
|
||||
params: {
|
||||
maxResults: 10,
|
||||
extractFields: ['title', 'url', 'snippet', 'domain']
|
||||
},
|
||||
description: 'Extract search results list',
|
||||
status: 'pending',
|
||||
successCriteria: ['At least 3 results extracted'],
|
||||
retryCount: 0,
|
||||
maxRetries: 2
|
||||
},
|
||||
{
|
||||
id: `${taskId}-browse-2`,
|
||||
type: 'RANK_RESULTS',
|
||||
params: {
|
||||
criteria: intent.selectionCriteria || ['interesting', 'authoritative'],
|
||||
rubric: [
|
||||
'Prefer Wikipedia, reputable news, official docs',
|
||||
'Prefer unique angle over generic',
|
||||
'Avoid ads and low-quality domains',
|
||||
'Match relevance to query'
|
||||
]
|
||||
},
|
||||
description: 'Rank results and select best',
|
||||
status: 'pending',
|
||||
successCriteria: ['Result selected with explanation'],
|
||||
retryCount: 0,
|
||||
maxRetries: 1
|
||||
},
|
||||
{
|
||||
id: `${taskId}-browse-3`,
|
||||
type: 'OPEN_RESULT',
|
||||
params: { resultIndex: 0 }, // Will be updated after ranking
|
||||
description: 'Open selected result',
|
||||
status: 'pending',
|
||||
successCriteria: ['New page loaded', 'URL changed from search page'],
|
||||
retryCount: 0,
|
||||
maxRetries: 2
|
||||
},
|
||||
{
|
||||
id: `${taskId}-browse-4`,
|
||||
type: 'VERIFY_STATE',
|
||||
params: {
|
||||
expected: 'result_page',
|
||||
indicators: ['URL is not Google', 'Page content loaded']
|
||||
},
|
||||
description: 'Verify result page opened',
|
||||
status: 'pending',
|
||||
successCriteria: ['Successfully navigated to result'],
|
||||
retryCount: 0,
|
||||
maxRetries: 1
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
plan.phases.push(browsePhase);
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
// === PLAN VALIDATOR ===
|
||||
// Ensures plan doesn't violate constraints
|
||||
|
||||
export function validatePlan(plan: TaskPlan): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const phase of plan.phases) {
|
||||
for (const step of phase.steps) {
|
||||
// Check TYPE_TEXT steps for instruction poisoning
|
||||
if (step.type === 'TYPE_TEXT') {
|
||||
const text = step.params.text || '';
|
||||
|
||||
for (const poison of INSTRUCTION_POISON_PATTERNS) {
|
||||
if (poison.test(text)) {
|
||||
errors.push(`TYPE_TEXT contains instruction: "${text}" - this should only be the search query`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for suspicious length (query shouldn't be a paragraph)
|
||||
if (text.length > 50) {
|
||||
errors.push(`TYPE_TEXT suspiciously long (${text.length} chars) - may contain instructions`);
|
||||
}
|
||||
|
||||
// Check for commas followed by words (likely instructions)
|
||||
if (/,\s*\w+\s+\w+/.test(text) && text.split(',').length > 2) {
|
||||
errors.push(`TYPE_TEXT contains multiple comma-separated clauses - may contain instructions`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// === PLAN PRETTY PRINTER ===
|
||||
|
||||
export function formatPlanForDisplay(plan: TaskPlan): string {
|
||||
let output = `📋 Task Plan: ${plan.taskId}\n`;
|
||||
output += `🎯 Objective: ${plan.objective}\n`;
|
||||
output += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n`;
|
||||
|
||||
for (let i = 0; i < plan.phases.length; i++) {
|
||||
const phase = plan.phases[i];
|
||||
const phaseIcon = phase.status === 'completed' ? '✅' :
|
||||
phase.status === 'active' ? '🔄' :
|
||||
phase.status === 'failed' ? '❌' : '⏳';
|
||||
|
||||
output += `${phaseIcon} Phase ${i + 1}: ${phase.name}\n`;
|
||||
output += ` ${phase.description}\n`;
|
||||
|
||||
for (let j = 0; j < phase.steps.length; j++) {
|
||||
const step = phase.steps[j];
|
||||
const stepIcon = step.status === 'completed' ? '✓' :
|
||||
step.status === 'executing' ? '►' :
|
||||
step.status === 'failed' ? '✗' : '○';
|
||||
|
||||
output += ` ${stepIcon} ${j + 1}. ${step.description}\n`;
|
||||
}
|
||||
output += '\n';
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
export default {
|
||||
parseUserIntent,
|
||||
generateTaskPlan,
|
||||
validatePlan,
|
||||
formatPlanForDisplay
|
||||
};
|
||||
Reference in New Issue
Block a user