runner.ts

  1import { Agent, ProviderTransport, type AgentEvent } from "@mariozechner/pi-agent";
  2import { getApiKey, type AgentTool, type AssistantMessage } from "@mariozechner/pi-ai";
  3import type { RumiloConfig } from "../config/schema.js";
  4import { resolveModel } from "./model-resolver.js";
  5import { AgentError } from "../util/errors.js";
  6import { expandEnvVars } from "../util/env.js";
  7
  8export interface AgentRunOptions {
  9  model: string;
 10  systemPrompt: string;
 11  tools: AgentTool[];
 12  onEvent?: (event: AgentEvent) => void;
 13  config: RumiloConfig;
 14}
 15
 16export interface AgentRunResult {
 17  message: string;
 18  usage?: unknown;
 19}
 20
 21/**
 22 * Build a getApiKey callback that checks custom model headers first,
 23 * then falls back to pi-ai's built-in env-var lookup.
 24 *
 25 * Custom models may specify `Authorization: "Bearer $SOME_ENV_VAR"` in their
 26 * headers config. We extract the bearer token and expand env var references
 27 * so the value reaches the OpenAI-compatible SDK as a real API key.
 28 */
 29export function buildGetApiKey(config: RumiloConfig): (provider: string) => string | undefined {
 30  return (provider: string) => {
 31    if (config.custom_models) {
 32      for (const model of Object.values(config.custom_models)) {
 33        if (model.provider === provider && model.headers) {
 34          const authHeader = model.headers["Authorization"] ?? model.headers["authorization"];
 35          if (authHeader) {
 36            const expanded = expandEnvVars(authHeader);
 37            const match = expanded.match(/^Bearer\s+(.+)$/i);
 38            if (match) {
 39              return match[1];
 40            }
 41            return expanded;
 42          }
 43        }
 44      }
 45    }
 46
 47    // Fall back to pi-ai's built-in env var resolution
 48    // (handles anthropic → ANTHROPIC_API_KEY, openai → OPENAI_API_KEY, etc.)
 49    return getApiKey(provider);
 50  };
 51}
 52
 53export async function runAgent(query: string, options: AgentRunOptions): Promise<AgentRunResult> {
 54  const agent = new Agent({
 55    initialState: {
 56      systemPrompt: options.systemPrompt,
 57      model: resolveModel(options.model, options.config),
 58      tools: options.tools,
 59    },
 60    transport: new ProviderTransport({
 61      getApiKey: buildGetApiKey(options.config),
 62    }),
 63  });
 64
 65  if (options.onEvent) {
 66    agent.subscribe(options.onEvent);
 67  }
 68
 69  await agent.prompt(query);
 70
 71  // Check for errors in agent state
 72  if (agent.state.error) {
 73    throw new AgentError(agent.state.error);
 74  }
 75
 76  const last = agent.state.messages
 77    .slice()
 78    .reverse()
 79    .find((msg): msg is AssistantMessage => msg.role === "assistant");
 80
 81  // Check if the last assistant message indicates an error
 82  if (last?.stopReason === "error") {
 83    throw new AgentError(last.errorMessage ?? "Agent stopped with an unknown error");
 84  }
 85
 86  const text = last?.content
 87    ?.filter((content): content is Extract<typeof content, { type: "text" }> => content.type === "text")
 88    .map((content) => content.text)
 89    .join("")
 90    .trim();
 91
 92  if (text === undefined || text === "") {
 93    throw new AgentError("Agent returned no text response");
 94  }
 95
 96  return {
 97    message: text,
 98    usage: last?.usage,
 99  };
100}