forked from EduCraft/curriculum-project-hub
295f07d111
Sessions resume across runs (ADR-0017). Without auto-compact the SDK jsonl grows unboundedly — a 7-day / 26-run session hit 31 MB / 1995 lines, making every API call resend the full history and inflating a trivial "change a title" task to 22 minutes. Enable autoCompactEnabled in the SDK settings so the SDK compacts automatically when the context window fills.
463 lines
20 KiB
TypeScript
463 lines
20 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 the workspace-bounded file-op
|
|
* invariant (ADR-0018) without re-implementing the
|
|
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
|
|
* is the mechanism, the ADR 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 input?: unknown; 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);
|
|
// Role tools JSON null means the default single-agent tool set, not "deny all".
|
|
const roleToolIds = req.tools === null ? undefined : req.tools;
|
|
const toolConfig = claudeSdkToolConfigForRole(roleToolIds);
|
|
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"]>;
|
|
// Always use an explicit tool list — never the claude_code preset.
|
|
// The preset registers Agent/SendMessage/Task multi-agent machinery.
|
|
// Concurrent background agents abort with reason "background", and the
|
|
// Claude Agent SDK maps that to toolDenialKind "cancelled" with:
|
|
// "The user doesn't want to take this action right now..."
|
|
// which freezes Bash mid-run while Read/Glob continue to work.
|
|
// Hub "unrestricted" means the default single-agent built-ins + MCP, not
|
|
// the full interactive Claude product surface.
|
|
const skillExtras = hasSkills ? (["Skill"] as const) : ([] as const);
|
|
const toolsOption: QueryOptions["tools"] = uniqueTools([
|
|
...toolConfig.tools,
|
|
"TodoWrite",
|
|
...skillExtras,
|
|
]);
|
|
const allowedToolsOption = uniqueTools([
|
|
...toolConfig.allowedTools,
|
|
"TodoWrite",
|
|
"mcp__cph_hub__todo_write",
|
|
...skillExtras,
|
|
]);
|
|
// Hard deny multi-agent orchestration even if a future preset/skills path
|
|
// reintroduces them — bypassPermissions would otherwise auto-allow them.
|
|
const disallowedToolsOption = [
|
|
"Agent",
|
|
"SendMessage",
|
|
"TeamCreate",
|
|
"Task",
|
|
"ScheduleWakeup",
|
|
] as const;
|
|
|
|
const options: QueryOptions = {
|
|
cwd: security.cwd,
|
|
tools: toolsOption,
|
|
allowedTools: allowedToolsOption,
|
|
disallowedTools: [...disallowedToolsOption],
|
|
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,
|
|
todoFeatureEnabled: true,
|
|
// Sessions are resumed across runs (ADR-0017). Without auto-compact
|
|
// the SDK jsonl grows unboundedly — a long-lived project session hit
|
|
// 31 MB / 1995 lines, making every API call resend the entire history
|
|
// and inflating a "change a title" task to 22 minutes. Let the SDK
|
|
// compact automatically when the context window fills.
|
|
autoCompactEnabled: 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;
|
|
|
|
// When there is no provider session cursor to resume (first run, or after a
|
|
// role/skill/model config change invalidated claudeSessionId), re-seed the
|
|
// conversation from this Hub session's prior AgentMessage rows. This keeps
|
|
// the logical session continuous across config changes and restarts — the
|
|
// agent still "remembers" the earlier turns even though the SDK starts a
|
|
// fresh provider session. The resume path above is preferred when available
|
|
// (it carries tool calls/results natively and avoids re-sending tokens).
|
|
const promptForAgent = req.resumeSessionId === undefined
|
|
? await withSessionHistory(req, req.prompt)
|
|
: req.prompt;
|
|
|
|
const conversation = query({
|
|
prompt: promptForAgent,
|
|
options,
|
|
});
|
|
|
|
// Track tool start timestamps and names/inputs for duration + tool-result attribution.
|
|
const toolStartTimestamps = new Map<string, number>();
|
|
const toolMetaByUseId = new Map<string, { readonly name: string; readonly input: unknown }>();
|
|
|
|
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());
|
|
toolMetaByUseId.set(toolUseId, {
|
|
name: evt.content_block.name,
|
|
input: undefined,
|
|
});
|
|
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;
|
|
toolMetaByUseId.set(block.id, { name: block.name, input: block.input });
|
|
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);
|
|
const meta = toolMetaByUseId.get(toolUseId);
|
|
onStream?.({
|
|
type: "tool-result",
|
|
toolUseId,
|
|
toolName: meta?.name ?? toolUseId,
|
|
result: resultText,
|
|
isError,
|
|
...(meta?.input !== undefined ? { input: meta.input } : {}),
|
|
...(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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Re-seed a run's prompt with this Hub session's prior conversation when the
|
|
* SDK cannot resume a provider session (no `resumeSessionId`). Pulls prior
|
|
* `AgentMessage` rows for this session — excluding the current run's own user
|
|
* message, which was already persisted before `runAgent` called this — and
|
|
* frames them as `<session_history>` so the model treats them as prior turns,
|
|
* not new instructions. The current run's prompt follows as the live request.
|
|
*
|
|
* Best-effort: if the history query fails, the run proceeds with the bare
|
|
* prompt rather than aborting. A cap (`MAX_HISTORY_TURNS`) bounds token cost;
|
|
* older turns beyond the cap are dropped, preserving the most recent context.
|
|
*/
|
|
async function withSessionHistory(req: RunRequest, prompt: string): Promise<string> {
|
|
const MAX_HISTORY_TURNS = 40;
|
|
try {
|
|
const messages = await req.prisma.agentMessage.findMany({
|
|
where: { sessionId: req.sessionId, runId: { not: req.runId } },
|
|
orderBy: { createdAt: "asc" },
|
|
select: { role: true, content: true },
|
|
take: MAX_HISTORY_TURNS * 2, // user+assistant per turn
|
|
});
|
|
if (messages.length === 0) return prompt;
|
|
const turns: string[] = [];
|
|
for (const message of messages) {
|
|
const label = message.role === "assistant" ? "Assistant" : "User";
|
|
turns.push(`${label}: ${message.content}`);
|
|
}
|
|
return `<session_history>\nThis is the prior conversation in this session, replayed because the provider session could not be resumed. Treat these as earlier turns you produced or received.\n\n${turns.join("\n\n")}\n</session_history>\n\n${prompt}`;
|
|
} catch {
|
|
return prompt;
|
|
}
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
function uniqueTools(tools: readonly string[]): string[] {
|
|
const out: string[] = [];
|
|
for (const tool of tools) {
|
|
if (!out.includes(tool)) out.push(tool);
|
|
}
|
|
return out;
|
|
}
|