forked from EduCraft/curriculum-project-hub
244 lines
8.6 KiB
TypeScript
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.
|
|
}
|
|
}
|