diff --git a/src/orchestration/agent.ts b/src/orchestration/agent.ts index 240bfa2..0015eff 100644 --- a/src/orchestration/agent.ts +++ b/src/orchestration/agent.ts @@ -93,6 +93,19 @@ export interface MachineAgentResult { changedFiles: string[]; validationResults: TestResult[]; reviewRequired: boolean; + implementationAttempts?: number; + implementationStopReason?: + | 'draft_only' + | 'dirty_workspace' + | 'no_new_context' + | 'max_attempts' + | 'implementation_requires_review' + | 'no_changes' + | 'apply_review_required' + | 'no_effective_change' + | 'applied' + | 'generation_failed'; + implementationContextFilesAdded?: number; published: boolean; prCreated: boolean; repoMutated: boolean; @@ -131,6 +144,19 @@ interface ConcretePatchResult { changedFiles: string[]; validationResults: TestResult[]; reviewRequired: boolean; + implementationAttempts?: number; + implementationStopReason?: + | 'draft_only' + | 'dirty_workspace' + | 'no_new_context' + | 'max_attempts' + | 'implementation_requires_review' + | 'no_changes' + | 'apply_review_required' + | 'no_effective_change' + | 'applied' + | 'generation_failed'; + implementationContextFilesAdded?: number; } interface FeasibilityPolicy { @@ -151,6 +177,8 @@ interface PresetAnalyzeCandidate { type AgentStageId = 'scout' | 'select' | 'prepare' | 'draft' | 'validate' | 'pr' | 'publish'; const ARTIFACT_PUBLISH_BRANCH = 'openmeta-artifacts'; +const MAX_IMPLEMENTATION_ATTEMPTS = 3; +const MAX_IMPLEMENTATION_EXPANSION_FILES = 8; const AGENT_STAGES: Array<{ id: AgentStageId; label: string; description: string }> = [ { @@ -537,6 +565,9 @@ export class AgentOrchestrator { changedFiles: implementation.changedFiles, validationResults: implementation.validationResults, reviewRequired, + implementationAttempts: implementation.implementationAttempts, + implementationStopReason: implementation.implementationStopReason, + implementationContextFilesAdded: implementation.implementationContextFilesAdded, published: false, prCreated: Boolean(contributionPullRequest.url), repoMutated: implementation.changedFiles.length > 0, @@ -635,6 +666,9 @@ export class AgentOrchestrator { changedFiles: [], validationResults: implementationWorkspace.testResults, reviewRequired: true, + implementationAttempts: 0, + implementationStopReason: 'implementation_requires_review' as const, + implementationContextFilesAdded: 0, }; const workspaceForArtifacts: RepoWorkspaceContext = { @@ -841,6 +875,9 @@ export class AgentOrchestrator { : ['inspect_artifact_paths'], pullRequestUrl: contributionPullRequest.url, pullRequestNumber: contributionPullRequest.number, + implementationAttempts: implementation.implementationAttempts, + implementationStopReason: implementation.implementationStopReason, + implementationContextFilesAdded: implementation.implementationContextFilesAdded, }; } @@ -2594,6 +2631,9 @@ export class AgentOrchestrator { changedFiles: [], validationResults: workspace.testResults, reviewRequired: false, + implementationAttempts: 0, + implementationStopReason: 'draft_only', + implementationContextFilesAdded: 0, }; } @@ -2611,20 +2651,60 @@ export class AgentOrchestrator { changedFiles: [], validationResults: workspace.testResults, reviewRequired: true, + implementationAttempts: 0, + implementationStopReason: 'dirty_workspace', + implementationContextFilesAdded: 0, }; } try { - const implementation = await ui.task( + let implementationWorkspace = workspace; + let implementationContextFilesAdded = 0; + let implementationStopReason: ConcretePatchResult['implementationStopReason'] = 'max_attempts'; + let implementation = await ui.task( { title: 'Generating concrete patch', doneMessage: 'Concrete patch generated', failedMessage: 'Concrete patch generation failed', tone: 'info', }, - async () => llmService.generateImplementationDraft(issue, workspace, patchDraft), + async () => llmService.generateImplementationDraft(issue, implementationWorkspace, patchDraft), ); + let implementationAttempts = 1; + + while ( + implementationAttempts < MAX_IMPLEMENTATION_ATTEMPTS && + (implementation.status !== 'success' || implementation.data.fileChanges.length === 0) + ) { + const expansion = workspaceService.expandImplementationContext(implementationWorkspace, { + maxFiles: MAX_IMPLEMENTATION_EXPANSION_FILES, + }); + + if (expansion.addedFiles.length === 0) { + implementationStopReason = 'no_new_context'; + break; + } + + implementationContextFilesAdded += expansion.addedFiles.length; + logger.info( + `Implementation context expansion round ${implementationAttempts} loaded ${expansion.addedFiles.length} additional file(s).`, + ); + implementationWorkspace = expansion.workspace; + implementationAttempts += 1; + implementation = await ui.task( + { + title: `Retrying concrete patch with expanded context (${implementationAttempts}/${MAX_IMPLEMENTATION_ATTEMPTS})`, + doneMessage: 'Concrete patch retry generated', + failedMessage: 'Concrete patch retry failed', + tone: 'info', + }, + async () => llmService.generateImplementationDraft(issue, implementationWorkspace, patchDraft), + ); + } + if (implementation.status !== 'success') { + implementationStopReason = + implementationStopReason === 'max_attempts' ? 'implementation_requires_review' : implementationStopReason; this.showStructuredReviewNotice({ title: 'Concrete patch requires review', subtitle: @@ -2637,12 +2717,17 @@ export class AgentOrchestrator { logger.warn('Skipping automatic file edits because the implementation draft requires review.'); return { changedFiles: [], - validationResults: workspace.testResults, + validationResults: implementationWorkspace.testResults, reviewRequired: true, + implementationAttempts, + implementationStopReason, + implementationContextFilesAdded, }; } if (implementation.data.fileChanges.length === 0) { + implementationStopReason = + implementationStopReason === 'max_attempts' ? 'no_changes' : implementationStopReason; ui.callout({ label: 'OpenMeta Agent', title: 'Concrete patch not produced', @@ -2656,8 +2741,11 @@ export class AgentOrchestrator { ); return { changedFiles: [], - validationResults: workspace.testResults, + validationResults: implementationWorkspace.testResults, reviewRequired: false, + implementationAttempts, + implementationStopReason, + implementationContextFilesAdded, }; } @@ -2679,7 +2767,7 @@ export class AgentOrchestrator { }, async () => workspaceService.applyGeneratedChanges(workspace.workspacePath, implementation.data.fileChanges, { - allowedPaths: workspace.snippets.map((snippet) => snippet.path), + allowedPaths: implementationWorkspace.snippets.map((snippet) => snippet.path), }), ); if (changedFiles.reviewRequired) { @@ -2692,8 +2780,11 @@ export class AgentOrchestrator { logger.warn(`Generated patch requires review: ${changedFiles.reviewReason || 'unspecified reason'}`); return { changedFiles: changedFiles.appliedFiles, - validationResults: workspace.testResults, + validationResults: implementationWorkspace.testResults, reviewRequired: true, + implementationAttempts, + implementationStopReason: 'apply_review_required', + implementationContextFilesAdded, }; } if (changedFiles.appliedFiles.length === 0) { @@ -2712,15 +2803,18 @@ export class AgentOrchestrator { ); return { changedFiles: [], - validationResults: workspace.testResults, + validationResults: implementationWorkspace.testResults, reviewRequired: false, + implementationAttempts, + implementationStopReason: 'no_effective_change', + implementationContextFilesAdded, }; } logger.success(`Applied ${changedFiles.appliedFiles.length} workspace file updates`); const validationResults = - runChecks && workspace.validationCommands.length > 0 + runChecks && implementationWorkspace.validationCommands.length > 0 ? await ui.task( { title: 'Running baseline validation commands', @@ -2730,11 +2824,11 @@ export class AgentOrchestrator { }, async () => workspaceService.runValidationCommands( - workspace.workspacePath, - workspace.validationCommands.slice(0, 3), + implementationWorkspace.workspacePath, + implementationWorkspace.validationCommands.slice(0, 3), ), ) - : workspace.testResults; + : implementationWorkspace.testResults; if (runChecks && changedFiles.appliedFiles.length > 0 && this.hasBlockingValidationFailures(validationResults)) { const repaired = await this.attemptValidationRepair({ @@ -2754,6 +2848,9 @@ export class AgentOrchestrator { changedFiles: changedFiles.appliedFiles, validationResults, reviewRequired: false, + implementationAttempts, + implementationStopReason: 'applied', + implementationContextFilesAdded, }; } catch (error) { logger.warn( @@ -2764,6 +2861,9 @@ export class AgentOrchestrator { changedFiles: [], validationResults: workspace.testResults, reviewRequired: false, + implementationAttempts: 0, + implementationStopReason: 'generation_failed', + implementationContextFilesAdded: 0, }; } } diff --git a/src/services/workspace.ts b/src/services/workspace.ts index a979395..ad548d5 100644 --- a/src/services/workspace.ts +++ b/src/services/workspace.ts @@ -20,6 +20,7 @@ const MAX_DISCOVERED_FILES = 250; const MAX_SNIPPET_CHARS = 8000; const MAX_GENERATED_FILES = 6; const MAX_GENERATED_FILE_CHARS = 60_000; +const DEFAULT_EXPANSION_LIMIT = 8; type ExecutionMode = 'interactive' | 'headless'; function normalizeRepoRelativePath(path: string): string { @@ -220,6 +221,43 @@ export class WorkspaceService { }); } + expandImplementationContext( + workspace: RepoWorkspaceContext, + options: { maxFiles?: number } = {}, + ): { workspace: RepoWorkspaceContext; addedFiles: string[] } { + const existingPaths = new Set([ + ...workspace.candidateFiles.map((path) => normalizeRepoRelativePath(path)), + ...workspace.snippets.map((snippet) => normalizeRepoRelativePath(snippet.path)), + ]); + const currentSnippetPaths = workspace.snippets.map((snippet) => normalizeRepoRelativePath(snippet.path)); + const discoveredFiles = this.discoverFiles(workspace.workspacePath); + const maxFiles = options.maxFiles ?? DEFAULT_EXPANSION_LIMIT; + const candidates = discoveredFiles + .filter((path) => !existingPaths.has(path)) + .map((path) => ({ path, score: this.scoreImplementationExpansionPath(path, currentSnippetPaths) })) + .filter((candidate) => candidate.score > 0) + .sort((left, right) => right.score - left.score || left.path.localeCompare(right.path)) + .slice(0, maxFiles) + .map((candidate) => candidate.path); + const snippets = this.readWorkspaceFiles(workspace.workspacePath, candidates).filter( + (snippet) => snippet.content.trim().length > 0, + ); + const addedFiles = snippets.map((snippet) => snippet.path); + + if (addedFiles.length === 0) { + return { workspace, addedFiles: [] }; + } + + return { + workspace: { + ...workspace, + candidateFiles: [...new Set([...workspace.candidateFiles, ...addedFiles])], + snippets: [...workspace.snippets, ...snippets], + }, + addedFiles, + }; + } + private async detectDefaultBranch(git: SimpleGit): Promise { try { const branchReference = await git.raw(['symbolic-ref', 'refs/remotes/origin/HEAD']); @@ -660,6 +698,63 @@ export class WorkspaceService { return score; } + private scoreImplementationExpansionPath(path: string, currentSnippetPaths: string[]): number { + const lowerPath = path.toLowerCase(); + const fileName = basename(lowerPath); + let score = 0; + + if (this.isLowSignalImplementationPath(lowerPath)) { + score -= 50; + } + + if (/(^|\/)(__tests__|test|tests)\/|\.test\.|\.spec\./.test(lowerPath)) { + score += 16; + } + + if (/(^|\/)(src|api|services?)\//.test(lowerPath)) { + score += 8; + } + + if (/(route|service|validator|query-config)\.(ts|tsx|js|jsx|py|go|rs|java|kt)$/.test(fileName)) { + score += 8; + } + + for (const currentPath of currentSnippetPaths) { + const currentLowerPath = currentPath.toLowerCase(); + const currentDir = dirname(currentLowerPath).replace(/\\/g, '/'); + const currentBase = basename(currentLowerPath).replace(/\.(test|spec)?\.(ts|tsx|js|jsx|py|go|rs|java|kt)$/, ''); + + if (dirname(lowerPath).replace(/\\/g, '/') === currentDir) { + score += 24; + } + + if (basename(lowerPath).startsWith(currentBase)) { + score += 18; + } + + if (currentDir !== '.' && lowerPath.startsWith(`${currentDir}/`)) { + score += 10; + } + } + + if (!/\.(ts|tsx|js|jsx|py|go|rs|java|kt)$/.test(fileName)) { + score -= 20; + } + + return score; + } + + private isLowSignalImplementationPath(lowerPath: string): boolean { + const fileName = basename(lowerPath); + return ( + lowerPath.startsWith('.github/workflows/') || + fileName === 'readme.md' || + fileName.endsWith('.config.dev.ts') || + fileName.startsWith('ormconfig') || + lowerPath.includes('typedoc-config/') + ); + } + private readSnippet(path: string): string { try { const content = readFileSync(path, 'utf-8'); diff --git a/test/agent.test.ts b/test/agent.test.ts index 50a8331..ad1abbd 100644 --- a/test/agent.test.ts +++ b/test/agent.test.ts @@ -42,6 +42,9 @@ interface AgentInternals { output: string; }>; reviewRequired: boolean; + implementationAttempts?: number; + implementationStopReason?: string; + implementationContextFilesAdded?: number; }>; } @@ -284,6 +287,107 @@ describe('AgentOrchestrator patch workflow', () => { } }); + test('retries concrete patch generation after expanding implementation context', async () => { + const workspacePath = mkdtempSync(join(tmpdir(), 'openmeta-agent-retry-')); + tempDirs.push(workspacePath); + mkdirSync(join(workspacePath, 'src', 'components'), { recursive: true }); + writeFileSync( + join(workspacePath, 'src', 'components', 'IconButton.tsx'), + 'export function IconButton() { return