forked from bai/curriculum-project-hub
364 lines
15 KiB
TypeScript
364 lines
15 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, 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 HookCallback, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage, type SDKSystemMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
import type { PrismaClient } from "@prisma/client";
|
|
import { claudeSdkToolConfigForRole } from "./roleTools.js";
|
|
import { createAgentSecurityPolicy } from "./security.js";
|
|
import type { RoleSkillEntry } from "./models.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<string, string | undefined> | 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 skills?: readonly RoleSkillEntry[] | undefined;
|
|
readonly mcpServers?: Record<string, McpServerConfig> | 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;
|
|
/** Skill ids reported by the SDK init event, not merely requested options. */
|
|
readonly initializedSkillIds?: readonly string[] | undefined;
|
|
readonly error?: string;
|
|
}
|
|
|
|
const DEFAULT_MAX_TURNS = 25;
|
|
|
|
const denyUnsandboxedBash: HookCallback = async (input) => {
|
|
if (input.hook_event_name !== "PreToolUse" || input.tool_name !== "Bash") return {};
|
|
const toolInput = input.tool_input;
|
|
if (
|
|
typeof toolInput !== "object" || toolInput === null ||
|
|
!("dangerouslyDisableSandbox" in toolInput) || toolInput.dangerouslyDisableSandbox !== true
|
|
) return {};
|
|
return {
|
|
hookSpecificOutput: {
|
|
hookEventName: "PreToolUse",
|
|
permissionDecision: "deny",
|
|
permissionDecisionReason: "This deployment requires every Bash command to remain sandboxed.",
|
|
},
|
|
};
|
|
};
|
|
|
|
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 costUsd: number | undefined;
|
|
let numTurns = 0;
|
|
let sdkSessionId: string | undefined;
|
|
let initializedSkillIds: readonly string[] | undefined;
|
|
let error: string | undefined;
|
|
let cleanupSecurity = async (): Promise<void> => {};
|
|
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({
|
|
runId: req.runId,
|
|
workspaceRoot,
|
|
workspaceDir: req.project.workspaceDir,
|
|
skills: req.skills,
|
|
providerProxyEnv: req.providerProxyEnv,
|
|
});
|
|
cleanupSecurity = security.cleanup;
|
|
const hasSkills = security.skillIds.length > 0;
|
|
|
|
type QueryOptions = NonNullable<Parameters<typeof query>[0]["options"]>;
|
|
const options: QueryOptions = {
|
|
cwd: security.cwd,
|
|
// `skills` controls discovery/allowlisting, but an explicit `tools`
|
|
// list still has to expose the Skill dispatcher itself.
|
|
tools: [...toolConfig.tools, ...(hasSkills ? ["Skill"] : [])],
|
|
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: [],
|
|
settings: { disableBundledSkills: true },
|
|
...(hasSkills && security.skillPluginRoot !== undefined
|
|
? { plugins: [{ type: "local" as const, path: security.skillPluginRoot, skipMcpDiscovery: true }] }
|
|
: {}),
|
|
skills: [...security.skillIds],
|
|
strictMcpConfig: true,
|
|
// Claude Code 2.1.202 can honor the per-call opt-out despite
|
|
// sandbox.allowUnsandboxedCommands=false. Enforce the invariant again at
|
|
// the PreToolUse boundary, before the Bash process can be spawned.
|
|
hooks: {
|
|
PreToolUse: [{ matcher: "Bash", hooks: [denyUnsandboxedBash] }],
|
|
},
|
|
};
|
|
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<string, number>();
|
|
|
|
for await (const message of conversation) {
|
|
switch (message.type) {
|
|
case "system": {
|
|
if (message.subtype === "init") {
|
|
initializedSkillIds = [...(message as SDKSystemMessage).skills];
|
|
}
|
|
break;
|
|
}
|
|
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;
|
|
if (result.subtype === "success" && fullText === "" && typeof result.result === "string") {
|
|
fullText = result.result;
|
|
}
|
|
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,
|
|
...(initializedSkillIds !== undefined ? { initializedSkillIds } : {}),
|
|
...(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,
|
|
...(initializedSkillIds !== undefined ? { initializedSkillIds } : {}),
|
|
...(aborted ? {} : { error: e instanceof Error ? e.message : String(e) }),
|
|
};
|
|
} finally {
|
|
await cleanupSecurity();
|
|
}
|
|
}
|
|
|
|
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.
|
|
}
|
|
}
|
|
|
|
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");
|
|
}
|