← back to Govarbitrage

src/agents/framework.ts

92 lines

import { prisma } from "@/lib/db";

// Minimal, dependency-free agent runner. Every agent execution is recorded as
// an AgentRun; observations stream in as AgentFindings via ctx.finding().
// Errors are caught → status FAILED with the message in summary, so a crashed
// agent still leaves an auditable row instead of vanishing.

export type Severity = "INFO" | "WARN" | "CRITICAL";

export interface FindingInput {
  kind: string;
  severity: Severity;
  title: string;
  detail?: string;
  listingId?: string;
  listingTitle?: string;
}

export interface AgentContext {
  runId: string;
  agent: string;
  /** Persist one finding against this run. */
  finding(f: FindingInput): Promise<void>;
  /** Increment the run's itemsProcessed counter (default +1). */
  count(n?: number): void;
}

export interface AgentRunResult {
  runId: string;
  agent: string;
  status: "OK" | "FAILED";
  itemsProcessed: number;
  summary: string | null;
  findings: number;
}

/**
 * Execute `fn` inside a tracked AgentRun. The function may return a summary
 * string; on throw the run is marked FAILED with the error message.
 */
export async function runAgent(
  name: string,
  fn: (ctx: AgentContext) => Promise<string | void>,
): Promise<AgentRunResult> {
  const run = await prisma.agentRun.create({
    data: { agent: name, status: "RUNNING" },
  });

  let items = 0;
  let findings = 0;

  const ctx: AgentContext = {
    runId: run.id,
    agent: name,
    async finding(f) {
      findings++;
      await prisma.agentFinding.create({
        data: {
          runId: run.id,
          agent: name,
          kind: f.kind,
          severity: f.severity,
          title: f.title,
          detail: f.detail,
          listingId: f.listingId,
          listingTitle: f.listingTitle,
        },
      });
    },
    count(n = 1) {
      items += n;
    },
  };

  try {
    const summary = (await fn(ctx)) ?? null;
    await prisma.agentRun.update({
      where: { id: run.id },
      data: { status: "OK", finishedAt: new Date(), itemsProcessed: items, summary },
    });
    return { runId: run.id, agent: name, status: "OK", itemsProcessed: items, summary, findings };
  } catch (e) {
    const message = e instanceof Error ? e.message : String(e);
    const summary = `FAILED: ${message}`.slice(0, 1000);
    await prisma.agentRun.update({
      where: { id: run.id },
      data: { status: "FAILED", finishedAt: new Date(), itemsProcessed: items, summary },
    });
    return { runId: run.id, agent: name, status: "FAILED", itemsProcessed: items, summary, findings };
  }
}