218 lines
7.7 KiB
JavaScript
218 lines
7.7 KiB
JavaScript
#!/usr/bin/env node
|
|
// Claude Agent SDK 러너(v2) — designpaca 인터뷰 게이트 평가.
|
|
//
|
|
// 기존 run-claude.mjs(`claude -p` CLI 직접 호출)는 남겨 둔다. 이 러너는
|
|
// 오케스트레이터 지시로 추가한 SDK 버전이다: 실측으로 확인했듯 이 환경에서는
|
|
// @anthropic-ai/claude-agent-sdk 의 query() 가 이미 로그인된 `claude` CLI를
|
|
// 서브프로세스로 띄워 그대로 동작한다(공식 quickstart의 ANTHROPIC_API_KEY
|
|
// 요구사항과 별개로, CLI 로그인 세션을 그대로 탄다 — apiKeySource: "none").
|
|
//
|
|
// canUseTool 로 AskUserQuestion 호출을 실시간으로 가로챈다: 호출이 오면
|
|
// 질문 JSON을 기록하고 askChannel="tool"로 표시한 뒤 deny 하고 즉시
|
|
// abortController.abort() 한다("평가 종료"). Write/Edit/NotebookEdit 등은
|
|
// 허용하되 호출을 기록한다. 텍스트로만 묻는 경우는 run-claude.mjs와 같은
|
|
// 휴리스틱으로 감지해서 같은 방식으로 abort 한다.
|
|
//
|
|
// 설치(1회):
|
|
// cd outputs/eval/deps && npm init -y && npm install @anthropic-ai/claude-agent-sdk
|
|
// (저장소 의존성을 늘리지 않으려고 outputs/eval/deps/ 에 따로 설치한다.)
|
|
//
|
|
// 단독 실행:
|
|
// node build/eval/interview/run-claude-sdk.mjs \
|
|
// --workdir <절대경로> --scenario S1 --prompt "..." \
|
|
// --model sonnet --permission-mode auto --out result.json --log raw.jsonl
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { snapshotDir, diffSnapshots } from "./lib/fsutil.mjs";
|
|
import { scoreQuestionText } from "./lib/text-heuristic.mjs";
|
|
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
const REPO_ROOT = path.resolve(HERE, "..", "..", "..");
|
|
const SDK_ENTRY = path.join(REPO_ROOT, "outputs", "eval", "deps", "node_modules", "@anthropic-ai", "claude-agent-sdk", "sdk.mjs");
|
|
|
|
const WRITE_LIKE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
|
|
function parseArgs(argv) {
|
|
const out = {};
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a.startsWith("--")) {
|
|
const key = a.slice(2);
|
|
const next = argv[i + 1];
|
|
if (next === undefined || next.startsWith("--")) {
|
|
out[key] = true;
|
|
} else {
|
|
out[key] = next;
|
|
i++;
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const workdir = args.workdir;
|
|
const scenario = args.scenario || "unknown";
|
|
const prompt = args.prompt;
|
|
const model = args.model || "sonnet";
|
|
const permissionMode = args["permission-mode"] || "default"; // "auto" | "default"
|
|
const timeoutMs = Number(args["timeout-ms"] || 300000);
|
|
const outPath = args.out;
|
|
const logPath = args.log;
|
|
|
|
if (!workdir || !prompt || !outPath) {
|
|
console.error("사용법: run-claude-sdk.mjs --workdir <dir> --scenario <id> --prompt <text> --out <result.json> [--log <raw.jsonl>] [--model <m>] [--permission-mode auto|default] [--timeout-ms <ms>]");
|
|
process.exit(2);
|
|
}
|
|
|
|
if (!fs.existsSync(SDK_ENTRY)) {
|
|
console.error(`SDK가 설치되어 있지 않다: ${SDK_ENTRY}\ncd outputs/eval/deps && npm install @anthropic-ai/claude-agent-sdk 로 먼저 설치할 것.`);
|
|
process.exit(2);
|
|
}
|
|
|
|
const { query } = await import(pathToFileURL(SDK_ENTRY).href);
|
|
|
|
const snapshotBefore = snapshotDir(workdir);
|
|
const logStream = logPath ? fs.createWriteStream(logPath, { flags: "a" }) : null;
|
|
const startedAt = Date.now();
|
|
|
|
const result = {
|
|
scenario,
|
|
runner: "claude-sdk",
|
|
model: null,
|
|
modelRequested: model,
|
|
permissionMode,
|
|
permissionModeRejected: false,
|
|
asked: false,
|
|
askChannel: null,
|
|
questionText: null,
|
|
wroteBeforeAnswer: null,
|
|
firstVisibleAction: null,
|
|
finalMessageText: null,
|
|
toolsUsed: [],
|
|
initTools: null,
|
|
askUserQuestionAvailable: null,
|
|
initSkills: null,
|
|
initPlugins: null,
|
|
claudeCodeVersion: null,
|
|
apiKeySource: null,
|
|
durationMs: null,
|
|
killedReason: null,
|
|
error: null,
|
|
};
|
|
|
|
const controller = new AbortController();
|
|
let finished = false;
|
|
const overallTimeout = setTimeout(() => {
|
|
if (!finished) {
|
|
result.killedReason = "timeout";
|
|
controller.abort();
|
|
}
|
|
}, timeoutMs);
|
|
|
|
function markAskedIfNeeded(channel, text) {
|
|
if (result.asked) return;
|
|
result.asked = true;
|
|
result.askChannel = channel;
|
|
result.questionText = text;
|
|
const after = snapshotDir(workdir);
|
|
result.wroteBeforeAnswer = diffSnapshots(snapshotBefore, after).count;
|
|
result.killedReason = result.killedReason || "asked";
|
|
controller.abort();
|
|
}
|
|
|
|
const canUseTool = async (toolName, input) => {
|
|
if (logStream) logStream.write(JSON.stringify({ type: "canUseTool", toolName, input }) + "\n");
|
|
result.toolsUsed.push(toolName);
|
|
if (!result.firstVisibleAction) {
|
|
result.firstVisibleAction = `tool:${toolName}`;
|
|
}
|
|
if (toolName === "AskUserQuestion") {
|
|
markAskedIfNeeded("tool", JSON.stringify(input));
|
|
return { behavior: "deny", message: "평가 종료" };
|
|
}
|
|
// Write/Edit 등은 허용하되 기록만 한다(설계: 기록하고 허용).
|
|
return { behavior: "allow", updatedInput: input };
|
|
};
|
|
|
|
try {
|
|
for await (const message of query({
|
|
prompt,
|
|
options: {
|
|
cwd: workdir,
|
|
model,
|
|
permissionMode,
|
|
settingSources: ["project"],
|
|
systemPrompt: { type: "preset", preset: "claude_code" },
|
|
abortController: controller,
|
|
maxTurns: 15,
|
|
canUseTool,
|
|
},
|
|
})) {
|
|
if (logStream) logStream.write(JSON.stringify(message).slice(0, 20000) + "\n");
|
|
|
|
if (message.type === "system" && message.subtype === "init") {
|
|
result.model = message.model || null;
|
|
result.initTools = message.tools || null;
|
|
result.askUserQuestionAvailable = Array.isArray(message.tools) ? message.tools.includes("AskUserQuestion") : null;
|
|
result.initSkills = message.skills || null;
|
|
result.initPlugins = message.plugins || null;
|
|
result.claudeCodeVersion = message.claude_code_version || null;
|
|
result.apiKeySource = message.apiKeySource || null;
|
|
continue;
|
|
}
|
|
|
|
if (message.type === "assistant") {
|
|
const content = (message.message && message.message.content) || [];
|
|
for (const block of content) {
|
|
if (block.type === "text" && block.text) {
|
|
if (!result.firstVisibleAction) {
|
|
result.firstVisibleAction = `text:${block.text.slice(0, 120)}`;
|
|
}
|
|
const score = scoreQuestionText(block.text);
|
|
if (score.isQuestion) {
|
|
markAskedIfNeeded("text", block.text);
|
|
}
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (message.type === "result") {
|
|
result.finalMessageText = message.result || null;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
const msg = String(e && e.message ? e.message : e);
|
|
const isAbort = e && (e.name === "AbortError" || /abort/i.test(msg));
|
|
if (!isAbort) {
|
|
if (/permission[_-]?mode/i.test(msg) || /auto/i.test(msg)) {
|
|
result.permissionModeRejected = true;
|
|
}
|
|
result.error = msg;
|
|
result.errorStack = e && e.stack ? String(e.stack).slice(0, 2000) : null;
|
|
}
|
|
}
|
|
|
|
finished = true;
|
|
clearTimeout(overallTimeout);
|
|
if (logStream) logStream.end();
|
|
|
|
if (result.wroteBeforeAnswer === null) {
|
|
const after = snapshotDir(workdir);
|
|
result.wroteBeforeAnswer = diffSnapshots(snapshotBefore, after).count;
|
|
}
|
|
result.durationMs = Date.now() - startedAt;
|
|
|
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
fs.writeFileSync(outPath, JSON.stringify(result, null, 2));
|
|
console.log(JSON.stringify(result));
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("run-claude-sdk.mjs 실패:", err);
|
|
process.exit(1);
|
|
});
|