/** * Claude Code SDK agent runner. * * Uses `@anthropic-ai/claude-agent-sdk`'s `query()` as the agent loop. The SDK * owns tool dispatch, context compaction, streaming — we just map its events * to the Feishu streaming callback. * * Built-in tools (Read, Write, Bash, Glob, Grep) cover our entire tool surface: * - read_file → Read * - write_file → Write * - list_files → Glob * - cph_check / cph_build → Bash (`cph check .`, `cph build . --target student`) * No custom tools needed — the SDK's Bash tool runs cph directly. * * ADR-0017 update: this drops provider-agnosticism. Claude Code SDK is * Anthropic-specific. The tradeoff: we get compaction, tool approval, partial * message streaming, and a battle-tested agent loop — capabilities we'd have * to build ourselves with a generic provider. * * ADR-0018: the agent execution surface is bounded by the run's workspace. * The SDK sandbox (bubblewrap on Linux, seatbelt on macOS) confines the Claude * Code process and its subprocesses at the OS level — writes, including the * SDK's Unix-socket scratch, are confined to the workspace. Host reads are * denied by default and re-opened only for the workspace plus named system * runtimes, and `failIfUnavailable` hard-fails if the sandbox can't start. The * subprocess gets a minimal environment and SDK credential protection removes * provider secrets from Bash. This upholds `AgentFileOp.Authorized` * (ADR-0018 / `Spec.System.AgentSurface`) without re-implementing the * `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox * is the mechanism, the contract pins the invariant. */ import { query, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk"; import type { PrismaClient } from "@prisma/client"; import { claudeSdkToolConfigForRole } from "./roleTools.js"; import { createAgentSecurityPolicy } from "./security.js"; export interface ProjectContext { readonly projectId: string; readonly boundChatId: string; readonly workspaceRoot?: string | undefined; readonly workspaceDir: string; } export type StreamEvent = | { readonly type: "text-delta"; readonly text: string } | { readonly type: "thinking-delta"; readonly text: string } | { readonly type: "tool-start"; readonly toolName: string; readonly toolUseId: string } | { readonly type: "tool-end"; readonly toolName: string; readonly toolUseId: string; readonly input: unknown; readonly durationMs?: number } | { readonly type: "tool-result"; readonly toolUseId: string; readonly toolName: string; readonly result: string; readonly isError: boolean; readonly durationMs?: number } | { readonly type: "finish" }; export type StreamCallback = (event: StreamEvent) => void; export interface RunRequest { readonly prompt: string; readonly model: string | undefined; readonly project: ProjectContext; readonly systemPrompt: string | undefined; /** Run-scoped loopback proxy capability; never an Organization provider credential. */ readonly providerProxyEnv?: Record | undefined; readonly resumeSessionId?: string | undefined; /** * RoleEntry.tools from admin/runtime settings. Values are Hub role tool ids * (for example read_file/cph_check/send_file), mapped to Claude SDK tool * names at run setup. `undefined` means the full supported tool surface; [] * means no tools. */ readonly tools?: readonly string[] | undefined; readonly mcpServers?: Record | undefined; readonly maxTurns?: number; readonly runId: string; readonly sessionId: string; readonly prisma: PrismaClient; readonly onStream?: StreamCallback; /** Optional diagnostic sink for Claude SDK subprocess stderr. */ readonly onSdkStderr?: ((data: string) => void) | undefined; /** * Abort controller for the run. Aborting the signal interrupts the SDK query * (it stops the agent loop and cleans up); the run resolves with status * "interrupted". The caller owns the controller and triggers abort. */ readonly abortController?: AbortController | undefined; } export type RunStatus = "completed" | "length" | "failed" | "interrupted"; export interface RunResult { readonly status: RunStatus; readonly text: string; readonly usage: { inputTokens: number; outputTokens: number }; /** Provider- or gateway-reported cost for this run, in USD. */ readonly costUsd?: number | undefined; readonly numTurns: number; readonly sdkSessionId?: string | undefined; readonly error?: string; } const DEFAULT_MAX_TURNS = 25; export async function runAgent(req: RunRequest): Promise { const onStream = req.onStream; const cap = req.maxTurns ?? DEFAULT_MAX_TURNS; // Lock heartbeat: refresh on each step. Best-effort. const heartbeat = async () => { try { await req.prisma.projectAgentLock.update({ where: { runId: req.runId }, data: { heartbeatAt: new Date() }, }); } catch { /* best-effort */ } }; let fullText = ""; let usage = { inputTokens: 0, outputTokens: 0 }; let costUsd: number | undefined; let numTurns = 0; let sdkSessionId: string | undefined; let error: string | undefined; try { await persistAgentMessage(req, "user", req.prompt); const toolConfig = claudeSdkToolConfigForRole(req.tools); const workspaceRoot = req.project.workspaceRoot?.trim(); if (workspaceRoot === undefined || workspaceRoot === "") { throw new Error("Agent run requires the configured workspace root"); } const security = await createAgentSecurityPolicy({ workspaceRoot, workspaceDir: req.project.workspaceDir, providerProxyEnv: req.providerProxyEnv, }); type QueryOptions = NonNullable[0]["options"]>; const options: QueryOptions = { cwd: security.cwd, tools: [...toolConfig.tools], allowedTools: [...toolConfig.allowedTools], maxTurns: cap, includePartialMessages: true, // ADR-0018: bypass interactive prompts (headless server); the sandbox // below is the hard boundary, not the permission-prompt layer. permissionMode: "bypassPermissions" as const, allowDangerouslySkipPermissions: true, // ADR-0018: OS-level sandbox confines the agent process + its Bash // subprocesses. Writes and ordinary reads are confined to the workspace; // named system runtime files remain readable so cph can execute. Network // stays open. failIfUnavailable and allowUnsandboxedCommands=false make // sandbox loss a hard failure. sandbox: { ...security.sandbox }, env: security.env, // The project workspace is untrusted input. Do not load user/project // settings that could widen tools, hooks, MCP servers, or sandbox paths. settingSources: [], strictMcpConfig: true, }; if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt; if (req.model !== undefined) options.model = req.model; if (req.resumeSessionId !== undefined) options.resume = req.resumeSessionId; if (req.mcpServers !== undefined) options.mcpServers = req.mcpServers; if (req.abortController !== undefined) options.abortController = req.abortController; if (req.onSdkStderr !== undefined) options.stderr = req.onSdkStderr; const conversation = query({ prompt: req.prompt, options, }); // Track tool start timestamps for duration calculation const toolStartTimestamps = new Map(); for await (const message of conversation) { switch (message.type) { case "stream_event": { const evt = (message as SDKPartialAssistantMessage).event; if (evt.type === "content_block_delta" && evt.delta.type === "text_delta") { onStream?.({ type: "text-delta", text: evt.delta.text }); } if (evt.type === "content_block_delta" && evt.delta.type === "thinking_delta") { onStream?.({ type: "thinking-delta", text: evt.delta.thinking }); } if (evt.type === "content_block_start" && evt.content_block.type === "tool_use") { const toolUseId = evt.content_block.id; toolStartTimestamps.set(toolUseId, Date.now()); onStream?.({ type: "tool-start", toolName: evt.content_block.name, toolUseId }); } if (evt.type === "content_block_stop") { // Tool end is attributed from the assistant message below. } break; } case "assistant": { const msg = (message as SDKAssistantMessage).message; await heartbeat(); numTurns++; let assistantText = ""; for (const block of msg.content) { if (block.type === "text" && typeof block.text === "string") { fullText += block.text; assistantText += block.text; } if (block.type === "tool_use") { const durationMs = toolStartTimestamps.has(block.id) ? Date.now() - (toolStartTimestamps.get(block.id) ?? 0) : undefined; onStream?.({ type: "tool-end", toolName: block.name, toolUseId: block.id, input: block.input, ...(durationMs !== undefined ? { durationMs } : {}), }); } if (block.type === "thinking" && typeof block.thinking === "string") { // Complete thinking block from the assistant message. // Stream deltas already delivered this; no separate event needed. } } await persistAgentMessage(req, "assistant", assistantText); if (msg.usage !== undefined) { usage.inputTokens += msg.usage.input_tokens ?? 0; usage.outputTokens += msg.usage.output_tokens ?? 0; } break; } case "user": { // User messages carry tool_result blocks — what each tool returned. const msg = (message as SDKUserMessage).message; const content = msg.content; if (!Array.isArray(content)) break; for (const block of content) { if (typeof block !== "object" || block === null || block.type !== "tool_result") continue; // Narrowed to BetaToolResultBlockParam by discriminant + field presence if (!("tool_use_id" in block)) continue; const toolUseId = block.tool_use_id; const isError = block.is_error === true; const resultText = extractToolResultText(block.content); const durationMs = toolStartTimestamps.get(toolUseId); onStream?.({ type: "tool-result", toolUseId, toolName: toolUseId, result: resultText, isError, ...(durationMs !== undefined ? { durationMs: Date.now() - durationMs } : {}), }); } break; } case "result": { const result = message as SDKResultMessage; sdkSessionId = result.session_id; costUsd = Number.isFinite(result.total_cost_usd) ? result.total_cost_usd : undefined; if (result.subtype !== "success") { error = `result_${result.subtype}`; } break; } } } onStream?.({ type: "finish" }); return { status: error !== undefined ? "failed" : "completed", text: fullText, usage, ...(costUsd !== undefined ? { costUsd } : {}), numTurns, sdkSessionId, ...(error !== undefined ? { error } : {}), }; } catch (e) { const aborted = req.abortController?.signal.aborted === true; return { status: aborted ? "interrupted" : "failed", text: fullText, usage, ...(costUsd !== undefined ? { costUsd } : {}), numTurns, sdkSessionId, ...(aborted ? {} : { error: e instanceof Error ? e.message : String(e) }), }; } } async function persistAgentMessage(req: RunRequest, role: string, content: string): Promise { if (content === "") return; try { await req.prisma.agentMessage.create({ data: { sessionId: req.sessionId, runId: req.runId, role, content, attachments: [], }, }); } catch { // Best-effort projection: a history write failure must not break the run. } } function extractToolResultText(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; const parts: string[] = []; for (const part of content) { if (typeof part === "object" && part !== null && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string") { parts.push(part.text); } } return parts.join("\n"); }