Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 111 additions & 11 deletions src/orchestration/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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 }> = [
{
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -2594,6 +2631,9 @@ export class AgentOrchestrator {
changedFiles: [],
validationResults: workspace.testResults,
reviewRequired: false,
implementationAttempts: 0,
implementationStopReason: 'draft_only',
implementationContextFilesAdded: 0,
};
}

Expand All @@ -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:
Expand All @@ -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',
Expand All @@ -2656,8 +2741,11 @@ export class AgentOrchestrator {
);
return {
changedFiles: [],
validationResults: workspace.testResults,
validationResults: implementationWorkspace.testResults,
reviewRequired: false,
implementationAttempts,
implementationStopReason,
implementationContextFilesAdded,
};
}

Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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',
Expand All @@ -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({
Expand All @@ -2754,6 +2848,9 @@ export class AgentOrchestrator {
changedFiles: changedFiles.appliedFiles,
validationResults,
reviewRequired: false,
implementationAttempts,
implementationStopReason: 'applied',
implementationContextFilesAdded,
};
} catch (error) {
logger.warn(
Expand All @@ -2764,6 +2861,9 @@ export class AgentOrchestrator {
changedFiles: [],
validationResults: workspace.testResults,
reviewRequired: false,
implementationAttempts: 0,
implementationStopReason: 'generation_failed',
implementationContextFilesAdded: 0,
};
}
}
Expand Down
95 changes: 95 additions & 0 deletions src/services/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string> {
try {
const branchReference = await git.raw(['symbolic-ref', 'refs/remotes/origin/HEAD']);
Expand Down Expand Up @@ -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');
Expand Down
Loading
Loading