Kestrel: Building an Autonomous Software Engineering Platform
2026-07-08 · 4 min read
The Vision: Issue to Pull Request, Autonomously
Software engineering is full of tasks that follow predictable patterns. Given a GitHub issue that says "Add input validation to the user registration endpoint," an experienced engineer knows what to do: find the endpoint, understand the current validation, add the missing checks, write tests, open a PR.
Kestrel automates this pattern with a pipeline of cooperating AI agents.
The Agent Pipeline
GitHub Issue
↓
Repository Agent → indexes codebase, builds file dependency graph
↓
Issue Analyst Agent → decomposes issue into implementation steps
↓
Code Agent → implements changes with repository-aware edits
↓
Test Agent → runs existing tests, writes new ones for changed paths
↓
PR Agent → opens pull request with coherent description
Each agent has a single, well-scoped responsibility. No agent tries to do everything.
Repository Agent: Understanding the Codebase
The first challenge: before any code agent can make changes, it needs to understand the repository. Not just the files — the dependency graph.
// src/agents/repository-agent.ts
export class RepositoryAgent {
async indexRepository(repoPath: string): Promise<RepositoryContext> {
const files = await this.walkDirectory(repoPath);
const importGraph = await this.buildImportGraph(files);
const entryPoints = this.detectEntryPoints(importGraph);
return {
files: files.map(f => ({
path: f.path,
language: detectLanguage(f.path),
size: f.size,
imports: importGraph.get(f.path) ?? [],
importedBy: importGraph.reverseGet(f.path) ?? [],
})),
entryPoints,
summary: await this.summarizeRepository(files),
};
}
private async buildImportGraph(files: FileInfo[]): Promise<ImportGraph> {
const graph = new ImportGraph();
for (const file of files) {
const imports = await extractImports(file.path, file.content);
for (const imp of imports) {
graph.addEdge(file.path, resolveImport(imp, file.path));
}
}
return graph;
}
}
The import graph tells us: if we change src/auth/session.ts, which other files import it and might break?
Issue Analyst Agent: Decomposition
// src/agents/issue-analyst.ts
export class IssueAnalystAgent {
async analyzeIssue(
issue: GitHubIssue,
repoContext: RepositoryContext,
): Promise<ImplementationPlan> {
const prompt = `
Repository structure:
${JSON.stringify(repoContext.files.slice(0, 50), null, 2)}
GitHub Issue:
Title: ${issue.title}
Body: ${issue.body}
Break this issue into specific implementation steps. For each step:
- Identify the specific file(s) to modify
- Describe the exact change needed
- Estimate complexity (low/medium/high)
Return as JSON: { steps: [{ file, change, complexity }] }
`;
const response = await this.llm.invoke(prompt, {
response_format: { type: "json_object" },
});
return JSON.parse(response.content);
}
}
Distributed Orchestration with BullMQ
The orchestration engine uses BullMQ for distributed job processing. Each agent step is a job:
// src/orchestration/job-queue.ts
const orchestrationQueue = new Queue("kestrel-orchestration", {
connection: redisClient,
defaultJobOptions: {
attempts: 3,
backoff: { type: "exponential", delay: 2000 },
removeOnComplete: 100,
removeOnFail: 50,
},
});
// When a new issue arrives:
await orchestrationQueue.add("process-issue", {
issueId: issue.id,
repoId: repo.id,
step: "repository-indexing",
});
// Worker picks up the job:
const worker = new Worker("kestrel-orchestration", async (job) => {
switch (job.data.step) {
case "repository-indexing":
const context = await repositoryAgent.indexRepository(job.data.repoId);
await orchestrationQueue.add("process-issue", {
...job.data,
step: "issue-analysis",
repositoryContext: context,
});
break;
case "issue-analysis":
const plan = await issueAnalystAgent.analyzeIssue(
job.data.issueId,
job.data.repositoryContext,
);
await orchestrationQueue.add("process-issue", {
...job.data,
step: "code-implementation",
implementationPlan: plan,
});
break;
// ... etc
}
});
BullMQ's retry-with-backoff handles the inevitable LLM timeout or API failure without losing work.
The Hardest Part: Conflict-Free Code Changes
When the Code Agent modifies files, it must:
- Never modify a file that another concurrent agent is already editing
- Ensure its changes don't invalidate imports from unchanged files
- Produce syntactically valid code (not LLM-hallucinated nonsense)
My solution: the Code Agent operates on a git branch, makes changes file-by-file using the import graph to understand impact, and runs a syntax check before committing anything.
async function validateCodeChange(filePath: string, newContent: string): Promise<boolean> {
const lang = detectLanguage(filePath);
if (lang === "typescript") {
// TypeScript compiler check — no emit, just type-check
const result = await runTypeScriptCheck(newContent, filePath);
return result.errors.length === 0;
}
if (lang === "python") {
// Python AST parse check
const result = await runPythonSyntaxCheck(newContent);
return result.valid;
}
return true; // fallback for unsupported languages
}
Next.js Dashboard
The dashboard shows the full orchestration state in real time: which issues are being processed, which agent step each is in, PR links for completed issues, and failure details for stuck jobs.
GitHub: github.com/karthikrshet/kestrel