karthik.dev
Back to blogAI Agents

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:

  1. Never modify a file that another concurrent agent is already editing
  2. Ensure its changes don't invalidate imports from unchanged files
  3. 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