agent.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. import {
  2. HarnessAgent,
  3. type HarnessAgentAdapter,
  4. type HarnessAgentSession,
  5. } from "@ai-sdk/harness/agent";
  6. import { createClaudeCode } from "@ai-sdk/harness-claude-code";
  7. import { createCodex } from "@ai-sdk/harness-codex";
  8. import { createPi } from "@ai-sdk/harness-pi";
  9. import { createVercelSandbox } from "@ai-sdk/sandbox-vercel";
  10. import { agentReportCatalog } from "./render/catalog";
  11. import { type AgentId } from "./agents";
  12. const AGENT_INSTRUCTIONS = `You are a coding agent running inside a fresh Linux sandbox with Node.js available. The user gives you software tasks; you do the work with your tools (bash, file edits, web search), then report back.
  13. REPORTING:
  14. Your chat output is rendered in a web UI that understands a JSON component spec. After finishing the work for a turn:
  15. 1. Write one or two short conversational sentences about the outcome.
  16. 2. Then output a UI report as a JSONL spec wrapped in a \`\`\`spec fence.
  17. Make the report reflect what actually happened, drawing from your real session:
  18. - Steps for the plan you executed (statuses: done/error; use active/pending only if work remains).
  19. - FileChange entries for files you created, modified, or deleted.
  20. - Terminal for important commands you ran and their real output (trim long output).
  21. - TestResults when you ran a test suite.
  22. - Metric for headline numbers (files changed, tests passed, duration).
  23. - BarChart to compare numbers across labeled categories (e.g. bundle size per module, benchmark per case).
  24. - LineChart for a number that changes across an ordered sequence (e.g. coverage per commit, latency over runs).
  25. - CodeBlock for the key snippet worth showing, with the file path as title.
  26. - Callout for risks, caveats, or suggested follow-ups.
  27. - Group sections with Card; never nest Cards.
  28. Never invent results. If something failed, show it (error step, non-zero exit, failed tests) and say what you would try next.
  29. Never use emojis -- not in prose, headings, labels, callouts, or any text field. The UI components supply their own icons.
  30. ${agentReportCatalog.prompt({
  31. mode: "inline",
  32. customRules: [
  33. "Keep reports compact and information-dense; the UI renders inside a chat thread.",
  34. "Prefer Grid with columns='2' or '3' for Metric rows.",
  35. "Use real command output captured during the session in Terminal components.",
  36. "Never put emojis in any text field; the components already provide icons.",
  37. ],
  38. })}`;
  39. const gatewayKey = process.env.AI_GATEWAY_API_KEY;
  40. // Each agent runs in its own fresh Node sandbox.
  41. const sandbox = () => createVercelSandbox({ runtime: "node24", ports: [3000] });
  42. /**
  43. * Build the HarnessAgent for an agent id. Both adapters need a `as unknown`
  44. * cast on current canaries: they pin zod@3 while the rest of the tree resolves
  45. * zod@4, so their HarnessV1 type carries a different provider-utils instance.
  46. * Type-level only.
  47. */
  48. function createAgent(id: AgentId): HarnessAgent {
  49. if (id === "codex") {
  50. const auth = gatewayKey
  51. ? { gateway: { apiKey: gatewayKey } }
  52. : process.env.OPENAI_API_KEY
  53. ? { openai: { apiKey: process.env.OPENAI_API_KEY } }
  54. : undefined;
  55. return new HarnessAgent({
  56. harness: createCodex({
  57. auth,
  58. model: process.env.CODEX_MODEL,
  59. }) as unknown as HarnessAgentAdapter,
  60. sandbox: sandbox(),
  61. instructions: AGENT_INSTRUCTIONS,
  62. });
  63. }
  64. if (id === "pi") {
  65. // Pi reads gateway credentials from process.env when auth is omitted; we
  66. // pass it explicitly when set for parity with the other agents.
  67. return new HarnessAgent({
  68. harness: createPi({
  69. auth: gatewayKey ? { gateway: { apiKey: gatewayKey } } : undefined,
  70. model: process.env.PI_MODEL,
  71. }) as unknown as HarnessAgentAdapter,
  72. sandbox: sandbox(),
  73. instructions: AGENT_INSTRUCTIONS,
  74. });
  75. }
  76. const auth = gatewayKey
  77. ? { gateway: { apiKey: gatewayKey } }
  78. : process.env.ANTHROPIC_API_KEY
  79. ? { anthropic: { apiKey: process.env.ANTHROPIC_API_KEY } }
  80. : undefined;
  81. return new HarnessAgent({
  82. harness: createClaudeCode({
  83. auth,
  84. model: process.env.CLAUDE_CODE_MODEL,
  85. }) as unknown as HarnessAgentAdapter,
  86. sandbox: sandbox(),
  87. instructions: AGENT_INSTRUCTIONS,
  88. });
  89. }
  90. // One HarnessAgent instance per agent id, built lazily and reused.
  91. const agents = new Map<AgentId, HarnessAgent>();
  92. function getAgent(id: AgentId): HarnessAgent {
  93. let agent = agents.get(id);
  94. if (!agent) {
  95. agent = createAgent(id);
  96. agents.set(id, agent);
  97. }
  98. return agent;
  99. }
  100. /**
  101. * One live harness session per chat. A session owns the sandbox and the
  102. * runtime's own conversation history, so follow-up messages in the same chat
  103. * keep working against the same workspace. The session is bound to the agent
  104. * that created it, so the chosen agent is locked for the life of the chat.
  105. *
  106. * In-memory only -- fine for a dev-server example. A production app would
  107. * persist `session.detach()` state and resume by sessionId instead.
  108. */
  109. type SessionEntry = {
  110. session: HarnessAgentSession;
  111. agent: HarnessAgent;
  112. agentId: AgentId;
  113. expireTimer: NodeJS.Timeout;
  114. };
  115. const sessions = new Map<string, SessionEntry>();
  116. const SESSION_IDLE_MS = 10 * 60 * 1000;
  117. export async function getSession(
  118. chatId: string,
  119. agentId: AgentId,
  120. ): Promise<SessionEntry> {
  121. const existing = sessions.get(chatId);
  122. if (existing) {
  123. existing.expireTimer.refresh();
  124. return existing;
  125. }
  126. const agent = getAgent(agentId);
  127. const session = await agent.createSession();
  128. const expireTimer = setTimeout(() => {
  129. sessions.delete(chatId);
  130. session.destroy().catch(() => {});
  131. }, SESSION_IDLE_MS);
  132. expireTimer.unref?.();
  133. const entry: SessionEntry = { session, agent, agentId, expireTimer };
  134. sessions.set(chatId, entry);
  135. return entry;
  136. }
  137. export function dropSession(chatId: string): void {
  138. const entry = sessions.get(chatId);
  139. if (!entry) return;
  140. clearTimeout(entry.expireTimer);
  141. sessions.delete(chatId);
  142. entry.session.destroy().catch(() => {});
  143. }