Files
curriculum-project-hub/hub/src/agent/runner.ts
T

244 lines
8.6 KiB
TypeScript

/**
* 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 are confined to
* the workspace (`sandbox.filesystem.allowWrite`), sensitive host paths are
* denied reads (`denyRead`), and `failIfUnavailable` hard-fails if the sandbox
* can't start (never run unsandboxed). 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 { homedir } from "node:os";
import { query, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import type { PrismaClient } from "@prisma/client";
export interface ProjectContext {
readonly projectId: string;
readonly boundChatId: string;
readonly workspaceDir: string;
}
export type StreamEvent =
| { readonly type: "text-delta"; readonly text: string }
| { readonly type: "tool-start"; readonly toolName: string }
| { readonly type: "tool-end"; readonly toolName: string }
| { 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;
readonly resumeSessionId?: string | undefined;
readonly mcpServers?: Record<string, McpServerConfig> | undefined;
readonly maxTurns?: number;
readonly runId: string;
readonly sessionId: string;
readonly prisma: PrismaClient;
readonly onStream?: StreamCallback;
}
export type RunStatus = "completed" | "length" | "failed";
export interface RunResult {
readonly status: RunStatus;
readonly text: string;
readonly usage: { inputTokens: number; outputTokens: number };
readonly numTurns: number;
readonly sdkSessionId?: string | undefined;
readonly error?: string;
}
const DEFAULT_MAX_TURNS = 25;
/**
* Host paths the agent may not read (ADR-0018 defense-in-depth). The sandbox
* confines writes to the workspace; these deny reads of credentials/secrets
* the process could otherwise see (read-only mounts are still readable). The
* workspace itself is never in this list — it's writable by `allowWrite`.
* Extend via `CPH_SANDBOX_EXTRA_DENY_READ` (colon-separated) for deployments
* with additional secret locations.
*/
const SENSITIVE_READ_PATHS: readonly string[] = (() => {
const home = homedir();
const base = [
"/etc",
"/root",
"/var/log",
"/proc",
"/sys",
`${home}/.ssh`,
`${home}/.aws`,
`${home}/.config/gcloud`,
`${home}/.gnupg`,
];
const extra = process.env["CPH_SANDBOX_EXTRA_DENY_READ"];
return extra !== undefined && extra !== ""
? [...base, ...extra.split(":").filter((p) => p !== "")]
: base;
})();
export async function runAgent(req: RunRequest): Promise<RunResult> {
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 numTurns = 0;
let sdkSessionId: string | undefined;
let error: string | undefined;
try {
await persistAgentMessage(req, "user", req.prompt);
const options: Parameters<typeof query>[0]["options"] = {
cwd: req.project.workspaceDir,
allowedTools: ["Read", "Write", "Bash", "Glob", "Grep"],
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 confined to the workspace; sensitive host paths
// denied reads; network open. failIfUnavailable hard-fails if the
// sandbox can't start — never run unsandboxed.
sandbox: {
enabled: true,
failIfUnavailable: true,
autoAllowBashIfSandboxed: true,
filesystem: {
allowWrite: [req.project.workspaceDir],
denyRead: [...SENSITIVE_READ_PATHS],
},
},
};
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;
const conversation = query({
prompt: req.prompt,
options,
});
for await (const message of conversation) {
switch (message.type) {
case "stream_event": {
// Partial messages — real-time text deltas + tool call chunks.
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_start" && evt.content_block.type === "tool_use") {
onStream?.({ type: "tool-start", toolName: evt.content_block.name });
}
if (evt.type === "content_block_stop") {
// Tool end is hard to attribute from stream events alone;
// the assistant message below carries tool_use blocks.
}
break;
}
case "assistant": {
// Complete assistant message (one turn done). Extract text + usage.
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") {
onStream?.({ type: "tool-end", toolName: block.name });
}
}
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 "result": {
const result = message as SDKResultMessage;
sdkSessionId = result.session_id;
if (result.subtype !== "success") {
error = `result_${result.subtype}`;
}
break;
}
}
}
onStream?.({ type: "finish" });
return {
status: error !== undefined ? "failed" : "completed",
text: fullText,
usage,
numTurns,
sdkSessionId,
...(error !== undefined ? { error } : {}),
};
} catch (e) {
return {
status: "failed",
text: fullText,
usage,
numTurns,
sdkSessionId,
error: e instanceof Error ? e.message : String(e),
};
}
}
async function persistAgentMessage(req: RunRequest, role: string, content: string): Promise<void> {
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.
}
}