@sweny-ai/core 0.1.132 → 0.1.134

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1020 @@
1
+ /**
2
+ * Claude Client - Headless Claude Code backend
3
+ *
4
+ * Uses the @anthropic-ai/claude-agent-sdk to run headless Claude Code
5
+ * as the LLM backend. Tools are exposed via an in-process MCP server
6
+ * that Claude Code calls during execution.
7
+ *
8
+ * This is the ONLY supported LLM backend. The whole point of sweny
9
+ * is to use headless Claude Code - never the raw Anthropic API.
10
+ */
11
+ import { query, createSdkMcpServer, tool as sdkTool } from "@anthropic-ai/claude-agent-sdk";
12
+ import { createRequire } from "node:module";
13
+ import { z } from "zod";
14
+ import { consoleLogger } from "../types.js";
15
+ import { reportWithheldEnv, parseList, resolveEnvScope, scopeAgentEnv, resolveAgentSandbox, } from "../agent-env.js";
16
+ import { fenceUntrustedJson } from "../untrusted.js";
17
+ import { policyGate } from "./policy.js";
18
+ import { CLAUDE_CODE_CAPABILITIES } from "./capabilities.js";
19
+ import { ask as coreAsk, evaluate as coreEvaluate, buildEvaluatePrompt } from "./prompts.js";
20
+ export { buildEvaluatePrompt, CLAUDE_CODE_CAPABILITIES };
21
+ let cachedSdkVersion;
22
+ /** Version of the Claude Agent SDK this adapter drives; "unknown" when it cannot be read. */
23
+ function claudeSdkVersion() {
24
+ if (cachedSdkVersion !== undefined)
25
+ return cachedSdkVersion;
26
+ try {
27
+ const req = createRequire(import.meta.url);
28
+ const pkg = req("@anthropic-ai/claude-agent-sdk/package.json");
29
+ cachedSdkVersion = typeof pkg.version === "string" ? pkg.version : "unknown";
30
+ }
31
+ catch {
32
+ cachedSdkVersion = "unknown";
33
+ }
34
+ return cachedSdkVersion;
35
+ }
36
+ const SYSTEM_PROMPT = `You are a step in an automated workflow. Execute the instruction precisely using the tools available to you. Be thorough but concise. When you're done, summarize your findings and results.`;
37
+ /**
38
+ * Built-in tools disallowed for the route-evaluation (`evaluate`) and
39
+ * reflection/judge (`ask`) calls.
40
+ *
41
+ * These are pure classification calls over prior-node data that can be
42
+ * attacker-influenceable (a prior node summarizes an untrusted issue body,
43
+ * PR diff, log line, etc.). They must never be able to mutate the workspace
44
+ * or shell out, regardless of any node's own `disallowed_tools` policy.
45
+ * `maxTurns: 1` already bounds them; this removes the powerful built-ins
46
+ * from the model's context entirely as structural defense-in-depth.
47
+ */
48
+ export const CLASSIFICATION_DISALLOWED_TOOLS = [
49
+ "Bash",
50
+ "Write",
51
+ "Edit",
52
+ "MultiEdit",
53
+ "NotebookEdit",
54
+ "WebFetch",
55
+ "WebSearch",
56
+ ];
57
+ /**
58
+ * Built-in tools disallowed for a node run with `readOnly: true` (dry run,
59
+ * #380): everything that can edit the workspace, shell out (and so reach
60
+ * `gh`, `curl`, `git push`), spawn a subagent that could, or fetch an
61
+ * arbitrary URL (a GET can still trigger a webhook or exfiltrate data).
62
+ * Read, Grep, Glob and WebSearch stay available for analysis.
63
+ */
64
+ export const READ_ONLY_DISALLOWED_TOOLS = [
65
+ "Bash",
66
+ "Write",
67
+ "Edit",
68
+ "MultiEdit",
69
+ "NotebookEdit",
70
+ "Task",
71
+ "Agent",
72
+ "WebFetch",
73
+ ];
74
+ /**
75
+ * Decide which auth credentials survive into the Claude Code subprocess env.
76
+ *
77
+ * sweny does not select the winning credential (the spawned agent and any
78
+ * on-disk `~/.claude/.credentials.json` do that). This function only controls
79
+ * what is *present* in the env we hand the agent.
80
+ *
81
+ * Modes (`SWENY_AUTH`, default `auto`):
82
+ * - `auto` - today's protective behavior: when an OAuth token is present,
83
+ * strip `ANTHROPIC_API_KEY` so a stray `.env` key cannot
84
+ * silently bill a metered API account. `ANTHROPIC_AUTH_TOKEN`
85
+ * (a bearer token, not a console key) is never touched.
86
+ * - `api-key` - user explicitly authenticates a gateway with a key/bearer:
87
+ * preserve both even when an OAuth token is also present.
88
+ * - `oauth` - user explicitly wants subscription/OAuth: strip both
89
+ * `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN` so a leftover
90
+ * credential cannot win at the agent. Fails closed.
91
+ *
92
+ * Base-URL presence is deliberately NOT a signal here: pass-through proxies
93
+ * (corporate egress, observability) set `ANTHROPIC_BASE_URL` while still
94
+ * billing the real key, so inferring "use the key" from a base URL would
95
+ * re-introduce a surprise-billing path. Intent must be explicit.
96
+ *
97
+ * An unrecognized `SWENY_AUTH` value falls back to `auto` with a warning
98
+ * (never throws - an env typo must not crash a CI run).
99
+ *
100
+ * Pure: returns a copy, never mutates the input.
101
+ */
102
+ export function resolveAuthEnv(env, opts = {}) {
103
+ const out = { ...env };
104
+ const raw = opts.mode ?? env.SWENY_AUTH;
105
+ let mode = "auto";
106
+ if (raw === "auto" || raw === "api-key" || raw === "oauth") {
107
+ mode = raw;
108
+ }
109
+ else if (raw) {
110
+ // truthy but unrecognized - empty string falls through silently to auto
111
+ opts.logger?.warn?.(`SWENY_AUTH="${raw}" is not one of auto|api-key|oauth; falling back to auto`);
112
+ }
113
+ // Truthy checks throughout: action.yml sets these to "" when an input is
114
+ // omitted, and empty string must read as unset.
115
+ const hasOauth = !!out.CLAUDE_CODE_OAUTH_TOKEN;
116
+ if (mode === "oauth") {
117
+ delete out.ANTHROPIC_API_KEY;
118
+ delete out.ANTHROPIC_AUTH_TOKEN;
119
+ }
120
+ else if (mode === "api-key") {
121
+ // preserve key + bearer; do not strip even if an OAuth token is present
122
+ }
123
+ else {
124
+ // auto - byte-for-byte the historical behavior
125
+ if (hasOauth) {
126
+ delete out.ANTHROPIC_API_KEY;
127
+ }
128
+ }
129
+ // Mode only, never credential values.
130
+ opts.logger?.debug?.(`[sweny] auth mode: ${mode}`);
131
+ return out;
132
+ }
133
+ /**
134
+ * Wire an optional timeout + caller signal onto a single AbortController.
135
+ *
136
+ * Returns undefined when neither a timeout nor a signal is supplied so the
137
+ * default code path (no abortController, no timer) is byte-for-byte unchanged.
138
+ *
139
+ * `reason()` reports whether the abort came from the timeout timer or an
140
+ * external signal, so callers can log a distinct timeout message.
141
+ */
142
+ export function makeAbort(timeoutMs, signal) {
143
+ if (!timeoutMs && !signal)
144
+ return undefined;
145
+ const controller = new AbortController();
146
+ let reason;
147
+ let timer;
148
+ const onSignalAbort = () => {
149
+ if (controller.signal.aborted)
150
+ return;
151
+ reason = "signal";
152
+ controller.abort();
153
+ };
154
+ if (signal) {
155
+ if (signal.aborted) {
156
+ reason = "signal";
157
+ controller.abort();
158
+ }
159
+ else {
160
+ signal.addEventListener("abort", onSignalAbort, { once: true });
161
+ }
162
+ }
163
+ if (timeoutMs && timeoutMs > 0 && !controller.signal.aborted) {
164
+ timer = setTimeout(() => {
165
+ if (controller.signal.aborted)
166
+ return;
167
+ reason = "timeout";
168
+ controller.abort();
169
+ }, timeoutMs);
170
+ // Don't keep the event loop alive just for the abort timer.
171
+ timer?.unref?.();
172
+ }
173
+ return {
174
+ controller,
175
+ reason: () => reason,
176
+ clear: () => {
177
+ if (timer)
178
+ clearTimeout(timer);
179
+ signal?.removeEventListener("abort", onSignalAbort);
180
+ },
181
+ };
182
+ }
183
+ /**
184
+ * Best-effort interrupt of an SDK query stream. Called from `finally` so an
185
+ * early return / timeout / throw does not leak the underlying subprocess.
186
+ * Swallows errors: the stream may already be done, and interrupt is only
187
+ * supported in streaming-input mode.
188
+ */
189
+ // The return type is deliberately `Promise<unknown>`, not `Promise<void>`:
190
+ // agent-sdk 0.3 changed `Query.interrupt()` to resolve with an
191
+ // SDKControlInterruptResponse. We discard the value either way, and a `void`
192
+ // parameter type would reject any SDK that resolves with something.
193
+ async function interruptStream(stream) {
194
+ if (!stream || typeof stream.interrupt !== "function")
195
+ return;
196
+ try {
197
+ await stream.interrupt();
198
+ }
199
+ catch {
200
+ /* already finished or not interruptible - nothing to clean up */
201
+ }
202
+ }
203
+ /**
204
+ * Extract shape-only usage/cost from an SDK `result` message.
205
+ *
206
+ * Reads `total_cost_usd`, `usage` (in/out/cache tokens), and `num_turns` off
207
+ * the terminal result. Returns undefined when the message carries none of
208
+ * them (mocks, older SDKs) so callers leave `NodeResult.usage` absent rather
209
+ * than shipping an all-zero object. NEVER touches `result`/prompt text.
210
+ */
211
+ function extractUsage(resultMsg) {
212
+ const m = resultMsg;
213
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
214
+ const u = m.usage;
215
+ const usage = {
216
+ costUsd: num(m.total_cost_usd),
217
+ inputTokens: num(u?.input_tokens),
218
+ outputTokens: num(u?.output_tokens),
219
+ cacheReadTokens: num(u?.cache_read_input_tokens),
220
+ cacheCreationTokens: num(u?.cache_creation_input_tokens),
221
+ numTurns: num(m.num_turns),
222
+ };
223
+ // Drop the object entirely when the SDK gave us nothing usable, so a
224
+ // mocked/legacy run reports no usage instead of a misleading zero-cost run.
225
+ return Object.values(usage).some((v) => v !== undefined) ? usage : undefined;
226
+ }
227
+ export class ClaudeCodeHarness {
228
+ id = "claude-code";
229
+ capabilities = CLAUDE_CODE_CAPABILITIES;
230
+ defaultJudgeModel = "claude-haiku-4-5";
231
+ model;
232
+ maxTurns;
233
+ cwd;
234
+ logger;
235
+ defaultContext;
236
+ mcpServers;
237
+ defaultMcpServers;
238
+ envPassthrough;
239
+ sandboxMode;
240
+ sandboxAllowedDomains;
241
+ sandboxProbe;
242
+ sandboxWarned = false;
243
+ envScope;
244
+ constructor(opts = {}) {
245
+ this.model = opts.model;
246
+ this.maxTurns = opts.maxTurns ?? 20;
247
+ this.cwd = opts.cwd ?? process.cwd();
248
+ this.logger = opts.logger ?? consoleLogger;
249
+ this.defaultContext = opts.defaultContext ?? { config: {}, logger: this.logger };
250
+ this.mcpServers = opts.mcpServers ?? {};
251
+ this.defaultMcpServers = opts.defaultMcpServers ?? {};
252
+ this.envPassthrough = opts.envPassthrough;
253
+ this.envScope = opts.envScope;
254
+ this.sandboxMode = opts.sandbox;
255
+ this.sandboxAllowedDomains = opts.sandboxAllowedDomains;
256
+ this.sandboxProbe = opts.sandboxProbe;
257
+ }
258
+ /**
259
+ * Build env for the Claude Code subprocess (#360). Applies auth precedence
260
+ * via {@link resolveAuthEnv} on the full process env (it reads
261
+ * `SWENY_AUTH`). When env scoping is on (default in CI, see
262
+ * {@link resolveEnvScope}) the result is narrowed to the allowlist plus
263
+ * `extraVars` (the node's declared skill env vars) and the operator
264
+ * passthrough list, and the withheld names (never values) are reported once
265
+ * per process ({@link reportWithheldEnv}). When off, the full env passes through as before.
266
+ */
267
+ buildEnv(extraVars = []) {
268
+ const full = Object.fromEntries(Object.entries(process.env).filter((e) => e[1] != null));
269
+ const authed = resolveAuthEnv(full, { logger: this.logger });
270
+ if (!resolveEnvScope(process.env, this.envScope, this.logger))
271
+ return authed;
272
+ const { env, withheld } = scopeAgentEnv(authed, {
273
+ extraVars,
274
+ passthrough: this.envPassthrough ?? parseList(process.env.SWENY_ENV_PASSTHROUGH),
275
+ logger: this.logger,
276
+ });
277
+ reportWithheldEnv(withheld, this.logger);
278
+ return env;
279
+ }
280
+ /** Which harness (and SDK version) produced a result. */
281
+ info() {
282
+ return { id: this.id, version: claudeSdkVersion() };
283
+ }
284
+ async preflight() {
285
+ return { ok: true, version: claudeSdkVersion() };
286
+ }
287
+ /**
288
+ * Run a node. The query itself is unchanged by the harness seam; this only
289
+ * runs {@link policyGate} (always `degraded: []` for Claude Code) and tags
290
+ * the result with the harness id and version.
291
+ */
292
+ async run(req) {
293
+ const policy = req.policy ?? {
294
+ readOnly: !!req.readOnly,
295
+ deny: [],
296
+ nativeDeny: req.disallowedTools,
297
+ egress: req.agentAccess?.domains ?? [],
298
+ strict: false,
299
+ };
300
+ const gate = policyGate(this.capabilities, policy);
301
+ const result = await this.runQuery(req);
302
+ return { ...result, harness: this.info(), degraded: gate.degraded };
303
+ }
304
+ async runQuery(opts) {
305
+ const { instruction, context, tools, outputSchema, onProgress, maxTurns, model, timeoutMs, signal, readOnly, agentAccess, } = opts;
306
+ // Dry run (#380): external MCP servers cannot be classified per tool, so
307
+ // they are unknown, and unknown means write. Drop them, and disallow the
308
+ // built-ins that can change the workspace or shell out.
309
+ const disallowedTools = readOnly
310
+ ? [...new Set([...(opts.disallowedTools ?? []), ...READ_ONLY_DISALLOWED_TOOLS])]
311
+ : opts.disallowedTools;
312
+ const effectiveModel = model ?? this.model;
313
+ // #360: run agent commands in the SDK sandbox. `auto` (default in CI) falls
314
+ // back to unsandboxed with one loud warning when the host cannot sandbox;
315
+ // `strict` fails the node here instead.
316
+ const sandbox = resolveAgentSandbox({
317
+ env: process.env,
318
+ mode: this.sandboxMode,
319
+ allowedDomains: this.sandboxAllowedDomains,
320
+ nodeDomains: agentAccess?.domains,
321
+ probe: this.sandboxProbe,
322
+ logger: this.logger,
323
+ });
324
+ if (sandbox.error) {
325
+ this.logger.error(sandbox.error);
326
+ return { status: "failed", data: { error: sandbox.error }, toolCalls: [] };
327
+ }
328
+ if (sandbox.warning && !this.sandboxWarned) {
329
+ this.sandboxWarned = true;
330
+ // GitHub Actions renders `::warning::` as a run annotation.
331
+ const prefix = process.env.GITHUB_ACTIONS === "true" ? "::warning title=SWEny agent sandbox::" : "";
332
+ this.logger.warn(`${prefix}${sandbox.warning}`);
333
+ }
334
+ // Tool-call accounting (Fix #1).
335
+ //
336
+ // Stream-driven recording, correlated by tool_use_id:
337
+ // - `assistant` message with tool_use block → create a pending ToolCall
338
+ // and register it in `pendingByUseId` under its tool_use_id.
339
+ // - `user` message with tool_result block → look up by tool_use_id, set
340
+ // `status` from `is_error`, and recover the typed output by parsing
341
+ // the stringified `content` field (our wrapper JSON-stringifies it
342
+ // before returning; external MCP servers also return structured
343
+ // content).
344
+ //
345
+ // Keying by tool_use_id (not by tool name) avoids mis-pairing when
346
+ // Claude invokes the same tool in parallel - the self-review test
347
+ // `pairs parallel same-named in-process tool outputs correctly` shows
348
+ // why FIFO-by-name is wrong.
349
+ const toolCalls = [];
350
+ const pendingByUseId = new Map();
351
+ // Convert core tools to SDK MCP tools. The wrapper invokes the user's
352
+ // handler and returns `content` via the MCP transport. We do not push
353
+ // to toolCalls from the wrapper - the stream's tool_result is the sole
354
+ // signal of completion, which keeps pairing correct under parallelism.
355
+ const sdkTools = tools.map((t) => coreToolToSdkTool(t, this.defaultContext));
356
+ // Create in-process MCP server
357
+ const mcpServer = createSdkMcpServer({
358
+ name: "sweny-core",
359
+ tools: sdkTools,
360
+ });
361
+ // Build prompt. Context (workflow input such as issues/alerts, plus prior
362
+ // node outputs) is fenced as untrusted data (#360).
363
+ const prompt = [
364
+ `## Instruction\n\n${instruction}`,
365
+ `## Context\n\n${fenceUntrustedJson(context, "context")}`,
366
+ outputSchema
367
+ ? `## Required Output\n\nYou MUST end with a JSON object matching this schema:\n\`\`\`json\n${JSON.stringify(outputSchema, null, 2)}\n\`\`\``
368
+ : "",
369
+ ]
370
+ .filter(Boolean)
371
+ .join("\n\n");
372
+ const env = this.buildEnv(agentAccess?.envVars);
373
+ let response = "";
374
+ // CC-08: when the SDK populates typed structured output (because we passed
375
+ // an `outputFormat`), prefer it over re-parsing JSON out of the free-text
376
+ // result. Left undefined when the SDK didn't provide it, so the
377
+ // tryParseJSON heuristic remains the fallback (e.g. mocks, older results).
378
+ let structuredOutput;
379
+ // Token + cost accounting. Read off the SDK's terminal `result` message
380
+ // (present on both success and error subtypes). Shape-only: counts and
381
+ // cost, never any prompt/response text. Left undefined when the SDK
382
+ // emitted no usage (mocks, older SDKs), so `usage` stays absent rather
383
+ // than shipping zeroes that read as a real (free) run.
384
+ let usage;
385
+ // Timeout / abort wiring (back-compat: only armed when requested).
386
+ // A single AbortController drives both an optional caller signal and an
387
+ // optional timeout timer. The SDK aborts the subprocess when this fires.
388
+ const abort = makeAbort(timeoutMs, signal);
389
+ let stream;
390
+ try {
391
+ const allMcpServers = readOnly
392
+ ? {}
393
+ : { ...this.defaultMcpServers, ...opts.mcpServers, ...this.mcpServers };
394
+ if (sdkTools.length > 0)
395
+ allMcpServers["sweny-core"] = mcpServer;
396
+ stream = query({
397
+ prompt,
398
+ options: {
399
+ maxTurns: maxTurns ?? this.maxTurns,
400
+ systemPrompt: SYSTEM_PROMPT,
401
+ cwd: this.cwd,
402
+ env,
403
+ permissionMode: "bypassPermissions",
404
+ allowDangerouslySkipPermissions: true,
405
+ ...(sandbox.settings ? { sandbox: sandbox.settings } : {}),
406
+ stderr: (data) => this.logger.debug(`[claude-code] ${data}`),
407
+ ...(abort ? { abortController: abort.controller } : {}),
408
+ ...(effectiveModel ? { model: effectiveModel } : {}),
409
+ ...(Object.keys(allMcpServers).length > 0 ? { mcpServers: allMcpServers } : {}),
410
+ // Dry run (#380): settingSources is omitted, so the SDK loads user,
411
+ // project and local settings, including their MCP servers (and
412
+ // project .mcp.json, plugins). strictMcpConfig limits MCP to the
413
+ // servers passed above, which under readOnly is only sweny-core.
414
+ ...(readOnly ? { strictMcpConfig: true } : {}),
415
+ ...(disallowedTools && disallowedTools.length > 0 ? { disallowedTools } : {}),
416
+ // CC-08: ask the SDK to produce validated structured output when the
417
+ // node declares an output schema. The SDK then returns the parsed
418
+ // value on `result.structured_output`, which we prefer over the
419
+ // text-scan heuristic below.
420
+ ...(outputSchema ? { outputFormat: { type: "json_schema", schema: outputSchema } } : {}),
421
+ },
422
+ });
423
+ for await (const message of stream) {
424
+ if (message.type === "tool_progress") {
425
+ const tp = message;
426
+ if (tp.tool_name && typeof tp.elapsed_time_seconds === "number") {
427
+ const name = stripMcpPrefix(tp.tool_name);
428
+ const secs = Math.round(tp.elapsed_time_seconds);
429
+ onProgress?.(`${name} (${secs}s)`);
430
+ }
431
+ }
432
+ else if (message.type === "tool_use_summary") {
433
+ const ts = message;
434
+ if (ts.summary) {
435
+ const clean = ts.summary.replace(/\n/g, " ").trim();
436
+ onProgress?.(clean.length > 80 ? clean.slice(0, 79) + "\u2026" : clean);
437
+ }
438
+ }
439
+ else if (message.type === "assistant") {
440
+ // Tool_use blocks start a ToolCall record. Status + output are
441
+ // filled in when the matching user tool_result arrives.
442
+ const am = message;
443
+ if (am.message?.content && Array.isArray(am.message.content)) {
444
+ for (const block of am.message.content) {
445
+ if (block.type === "tool_use") {
446
+ const call = {
447
+ tool: stripMcpPrefix(block.name ?? ""),
448
+ input: block.input,
449
+ };
450
+ toolCalls.push(call);
451
+ if (typeof block.id === "string")
452
+ pendingByUseId.set(block.id, call);
453
+ }
454
+ }
455
+ }
456
+ }
457
+ else if (message.type === "user") {
458
+ // Tool_result blocks close the loop. Pair to the pending ToolCall
459
+ // by tool_use_id - NOT by name, which would break parallel calls
460
+ // of the same tool (see regression test).
461
+ //
462
+ // Output handling is unified across in-process and external tools:
463
+ // our wrapper JSON-stringifies structured output before returning,
464
+ // and external MCP servers also send structured content as string.
465
+ // We parse the content back into a value so verify's output-path
466
+ // checks work against typed data.
467
+ const um = message;
468
+ if (um.message?.content && Array.isArray(um.message.content)) {
469
+ for (const block of um.message.content) {
470
+ if (block.type !== "tool_result" || typeof block.tool_use_id !== "string")
471
+ continue;
472
+ const call = pendingByUseId.get(block.tool_use_id);
473
+ if (!call)
474
+ continue;
475
+ // Delete after first match so a duplicate tool_result (SDK
476
+ // retry, malformed stream) cannot overwrite a completed call.
477
+ pendingByUseId.delete(block.tool_use_id);
478
+ const isError = block.is_error === true;
479
+ call.status = isError ? "error" : "success";
480
+ const parsed = parseToolResultContent(block.content);
481
+ call.output = isError ? { error: parsed } : parsed;
482
+ // Surface tool errors in the CI log stream at warn level so
483
+ // postmortems don't have to reconstruct them from eval-time
484
+ // tool-call summaries. Without this, "eval failed: tool X
485
+ // did not succeed" never answers the WHY question, because
486
+ // the error body is buried in the per-call output captured
487
+ // only for in-memory verify evaluation.
488
+ if (isError) {
489
+ this.logger.warn(` tool ${call.tool} failed: ${summarizeToolError(parsed)}`);
490
+ }
491
+ }
492
+ }
493
+ }
494
+ else if (message.type === "result") {
495
+ const resultMsg = message;
496
+ // Capture token/cost accounting off the terminal result. Present on
497
+ // both success and error subtypes; shape-only. Attached to every
498
+ // return below (including early-termination and error paths).
499
+ usage = extractUsage(resultMsg);
500
+ if (resultMsg.subtype === "success" && "result" in resultMsg) {
501
+ // terminal_reason was added in @anthropic-ai/claude-agent-sdk v0.2.91.
502
+ // When the turn budget is exhausted the SDK still emits subtype='success'
503
+ // but sets terminal_reason='max_turns'. Treat anything other than
504
+ // 'completed' (or absent) as a failure so callers are not silently fed
505
+ // incomplete output.
506
+ const terminalReason = resultMsg.terminal_reason;
507
+ if (terminalReason && terminalReason !== "completed") {
508
+ // Preserve whatever partial text the agent produced before the
509
+ // early termination. Callers that fail hard ignore it; a node
510
+ // with `fail_soft: true` passes it downstream as the partial
511
+ // gather/work product instead of dropping it on the floor.
512
+ const partial = typeof resultMsg.result === "string" ? resultMsg.result.trim() : "";
513
+ return {
514
+ status: "failed",
515
+ data: {
516
+ error: `Claude query terminated early: ${terminalReason}`,
517
+ ...(partial !== "" ? { summary: partial } : {}),
518
+ },
519
+ toolCalls,
520
+ ...(usage ? { usage } : {}),
521
+ };
522
+ }
523
+ response = resultMsg.result;
524
+ // CC-08: capture the SDK's typed structured output when present.
525
+ if ("structured_output" in resultMsg && resultMsg.structured_output !== undefined) {
526
+ structuredOutput = resultMsg.structured_output;
527
+ }
528
+ }
529
+ else if ("errors" in resultMsg) {
530
+ const errors = resultMsg.errors;
531
+ // CC-08: surface the structured-output failure subtype explicitly
532
+ // so a schema the model can't satisfy fails loudly instead of
533
+ // silently producing empty data.
534
+ const prefix = resultMsg.subtype === "error_max_structured_output_retries"
535
+ ? "Claude could not produce output matching the schema: "
536
+ : "";
537
+ return {
538
+ status: "failed",
539
+ data: { error: prefix + (errors?.join("\n") ?? "Execution failed") },
540
+ toolCalls,
541
+ ...(usage ? { usage } : {}),
542
+ };
543
+ }
544
+ }
545
+ }
546
+ }
547
+ catch (err) {
548
+ if (abort?.reason() === "timeout") {
549
+ const msg = `Claude query timed out after ${timeoutMs}ms`;
550
+ this.logger.error(msg);
551
+ return { status: "failed", data: { error: msg }, toolCalls };
552
+ }
553
+ this.logger.error(`Claude Code query failed: ${err.message}`);
554
+ return {
555
+ status: "failed",
556
+ data: { error: err.message },
557
+ toolCalls,
558
+ };
559
+ }
560
+ finally {
561
+ // Interrupt the subprocess on any exit (early return, throw, or normal
562
+ // completion) so a wedged agent does not leak, and clear the timer so
563
+ // it cannot fire after we are done.
564
+ abort?.clear();
565
+ await interruptStream(stream);
566
+ }
567
+ // CC-08: prefer the SDK's validated structured output when it gave us an
568
+ // object. Fall back to the free-text JSON heuristic when it's absent
569
+ // (no schema requested, mocked result, or an older SDK that didn't set it).
570
+ const parsedData = structuredOutput && typeof structuredOutput === "object" && !Array.isArray(structuredOutput)
571
+ ? structuredOutput
572
+ : tryParseJSON(response, outputSchema, this.logger);
573
+ return {
574
+ status: "success",
575
+ data: { summary: response, ...parsedData },
576
+ toolCalls,
577
+ ...(usage ? { usage } : {}),
578
+ };
579
+ }
580
+ /**
581
+ * Single completion: no tools, no MCP, one turn. Shared by core `ask()`
582
+ * (retry reflection, judge scoring) and `evaluate()` (route choice); the
583
+ * query options are the ones `ask` and `evaluate` used before the seam.
584
+ * Returns null when the query failed (SDK error, timeout, non-success
585
+ * subtype); callers fail closed.
586
+ */
587
+ async complete(req) {
588
+ const { prompt, model, timeoutMs, signal } = req;
589
+ const purpose = req.purpose ?? "ask";
590
+ const DASH = String.fromCharCode(0x2014);
591
+ const env = this.buildEnv();
592
+ let response = "";
593
+ const effectiveModel = model ?? this.model;
594
+ const abort = makeAbort(timeoutMs, signal);
595
+ let failed = false;
596
+ let stream;
597
+ try {
598
+ stream = query({
599
+ prompt,
600
+ options: {
601
+ maxTurns: 1,
602
+ cwd: this.cwd,
603
+ env,
604
+ permissionMode: "bypassPermissions",
605
+ allowDangerouslySkipPermissions: true,
606
+ // Route evaluation, reflection and judge scoring are pure
607
+ // classification calls over possibly-attacker-influenceable
608
+ // prior-node data. Never let them shell out or mutate, regardless
609
+ // of the node's own policy.
610
+ // Disable ALL built-in tools (SDK `tools: []`); no MCP servers are passed.
611
+ // The disallow list below stays as a second layer.
612
+ tools: [],
613
+ mcpServers: {},
614
+ strictMcpConfig: true,
615
+ disallowedTools: [...CLASSIFICATION_DISALLOWED_TOOLS],
616
+ stderr: (data) => this.logger.debug(`[claude-code] ${data}`),
617
+ ...(abort ? { abortController: abort.controller } : {}),
618
+ ...(effectiveModel ? { model: effectiveModel } : {}),
619
+ },
620
+ });
621
+ for await (const message of stream) {
622
+ if (message.type === "result") {
623
+ const resultMsg = message;
624
+ if (resultMsg.subtype === "success" && "result" in resultMsg) {
625
+ response = resultMsg.result;
626
+ }
627
+ else {
628
+ // Fail closed. An SDK-level failure (non-success subtype) is NOT a
629
+ // routing decision or an answer. Signal it to the caller (null) so
630
+ // the executor takes an explicit default edge or terminates, never
631
+ // the old fall-through to the first choice.
632
+ failed = true;
633
+ if (purpose === "evaluate") {
634
+ this.logger.warn(`claude.evaluate: SDK returned non-success subtype "${resultMsg.subtype}" ${DASH} failing closed (no route decision).`);
635
+ }
636
+ else {
637
+ this.logger.warn(`claude.ask: SDK returned non-success subtype "${resultMsg.subtype}" ${DASH} returning empty string`);
638
+ }
639
+ }
640
+ }
641
+ }
642
+ }
643
+ catch (err) {
644
+ if (purpose === "evaluate") {
645
+ if (abort?.reason() === "timeout") {
646
+ this.logger.warn(`Evaluate query timed out after ${timeoutMs}ms. Failing closed (no route decision).`);
647
+ }
648
+ else {
649
+ this.logger.warn(`Evaluate query failed: ${err.message}. Failing closed (no route decision).`);
650
+ }
651
+ }
652
+ else if (abort?.reason() === "timeout") {
653
+ this.logger.warn(`Ask query timed out after ${timeoutMs}ms ${DASH} returning empty string`);
654
+ }
655
+ else {
656
+ this.logger.warn(`Ask query failed: ${err.message}`);
657
+ }
658
+ return null;
659
+ }
660
+ finally {
661
+ abort?.clear();
662
+ await interruptStream(stream);
663
+ }
664
+ return failed ? null : response;
665
+ }
666
+ /** Route evaluation. Delegates to core {@link coreEvaluate} over {@link complete}. */
667
+ evaluate(opts) {
668
+ return coreEvaluate(this, opts, this.logger);
669
+ }
670
+ /** Free-text completion. Delegates to core {@link coreAsk} over {@link complete}. */
671
+ ask(opts) {
672
+ return coreAsk(this, opts);
673
+ }
674
+ }
675
+ /** @deprecated Use {@link ClaudeCodeHarness} (or `createHarness("claude-code")`). */
676
+ export const ClaudeClient = ClaudeCodeHarness;
677
+ // ─── Tool conversion ────────────────────────────────────────────
678
+ /**
679
+ * Convert a core Tool to an SDK MCP tool definition.
680
+ *
681
+ * The wrapper runs the user's handler and returns the MCP `CallToolResult`.
682
+ * Output bookkeeping happens on the stream side (see the tool_result
683
+ * handler in `run()`), keyed by `tool_use_id` - never from inside the
684
+ * wrapper. Keeping the wrapper stateless means parallel calls to the same
685
+ * tool cannot mis-pair outputs.
686
+ */
687
+ function coreToolToSdkTool(coreTool, defaultCtx) {
688
+ const zodShape = jsonSchemaToZodShape(coreTool.input_schema);
689
+ return sdkTool(coreTool.name, coreTool.description, zodShape, async (args) => {
690
+ try {
691
+ // The executor wraps handlers to inject ToolContext.
692
+ // When used standalone, defaultCtx is the fallback.
693
+ const output = await coreTool.handler(args, defaultCtx);
694
+ // JSON-stringify structured output so the tool_result handler can
695
+ // recover it via parseToolResultContent. Strings pass through.
696
+ return {
697
+ content: [{ type: "text", text: typeof output === "string" ? output : JSON.stringify(output) }],
698
+ };
699
+ }
700
+ catch (err) {
701
+ return {
702
+ content: [{ type: "text", text: `Error: ${err.message}` }],
703
+ isError: true,
704
+ };
705
+ }
706
+ });
707
+ }
708
+ /**
709
+ * Best-effort recovery of typed output from a tool_result's `content`.
710
+ *
711
+ * The MCP protocol sends tool results as string content. Our in-process
712
+ * wrapper JSON-stringifies structured output before returning; external
713
+ * MCP servers generally do the same for JSON payloads. If the string
714
+ * looks like a JSON object or array and parses, return the parsed value
715
+ * so verify's output-path walks work against typed data.
716
+ *
717
+ * Raw strings are preserved verbatim. JSON-primitive strings (e.g. the
718
+ * literal four characters `"42"`) are intentionally NOT parsed - we
719
+ * cannot distinguish a tool that returned the number 42 (wrapper sends
720
+ * `"42"`) from a tool that returned the string "42" (wrapper also sends
721
+ * `"42"`). Preserving the string is safer than guessing.
722
+ */
723
+ export function parseToolResultContent(content) {
724
+ // The Anthropic tool_result `content` field can be a block array
725
+ // (e.g. [{type:"text",text:"..."}, ...]) rather than a string. When it is,
726
+ // concatenate the text of every {type:"text"} block (recursing through any
727
+ // nested content), then run the same object/array JSON-parse logic on the
728
+ // joined string. This keeps `call.output` the actual payload instead of the
729
+ // raw wrapper array that eval/route logic would otherwise have to walk.
730
+ if (Array.isArray(content)) {
731
+ const text = collectBlockText(content);
732
+ return parseJsonObjectOrArray(text);
733
+ }
734
+ if (typeof content !== "string")
735
+ return content;
736
+ return parseJsonObjectOrArray(content);
737
+ }
738
+ /**
739
+ * Parse a string only when it is an unambiguous JSON object or array.
740
+ *
741
+ * Strings, numbers, booleans, and null would all corrupt or discard
742
+ * information (we cannot distinguish the literal text `"42"` from the number
743
+ * 42), so non-object/array inputs are preserved verbatim.
744
+ */
745
+ function parseJsonObjectOrArray(content) {
746
+ const trimmed = content.trim();
747
+ if (trimmed.length === 0)
748
+ return content;
749
+ const first = trimmed[0];
750
+ if (first !== "{" && first !== "[")
751
+ return content;
752
+ try {
753
+ return JSON.parse(trimmed);
754
+ }
755
+ catch {
756
+ return content;
757
+ }
758
+ }
759
+ /**
760
+ * Concatenate the text of an array of content blocks. Handles {type:"text"}
761
+ * blocks and recurses into any nested `content` array so deeply-wrapped tool
762
+ * results still flatten to their underlying text.
763
+ */
764
+ function collectBlockText(blocks) {
765
+ const parts = [];
766
+ for (const block of blocks) {
767
+ if (typeof block === "string") {
768
+ parts.push(block);
769
+ continue;
770
+ }
771
+ if (!block || typeof block !== "object")
772
+ continue;
773
+ const b = block;
774
+ if (b.type === "text" && typeof b.text === "string") {
775
+ parts.push(b.text);
776
+ }
777
+ else if (Array.isArray(b.content)) {
778
+ parts.push(collectBlockText(b.content));
779
+ }
780
+ else if (typeof b.text === "string") {
781
+ // Some producers omit an explicit type but still carry text.
782
+ parts.push(b.text);
783
+ }
784
+ }
785
+ return parts.join("");
786
+ }
787
+ /**
788
+ * Produce a short, single-line description of a tool-error payload suitable
789
+ * for the CI log stream. The full parsed value stays on the ToolCall for
790
+ * verify and downstream tooling - this is only for inline observability.
791
+ *
792
+ * Collapses newlines, trims whitespace, and caps to 300 chars so a huge
793
+ * API response body doesn't flood the log.
794
+ */
795
+ export function summarizeToolError(parsed) {
796
+ let raw;
797
+ if (typeof parsed === "string") {
798
+ raw = parsed;
799
+ }
800
+ else if (parsed && typeof parsed === "object") {
801
+ try {
802
+ raw = JSON.stringify(parsed);
803
+ }
804
+ catch {
805
+ raw = String(parsed);
806
+ }
807
+ }
808
+ else {
809
+ raw = String(parsed);
810
+ }
811
+ const collapsed = raw.replace(/\s+/g, " ").trim();
812
+ return collapsed.length > 300 ? collapsed.slice(0, 297) + "..." : collapsed;
813
+ }
814
+ // ─── JSON Schema → Zod conversion ───────────────────────────────
815
+ /**
816
+ * Convert a JSON Schema object to a Zod raw shape for the agent SDK.
817
+ * Preserves property names, types, and descriptions so Claude sees
818
+ * accurate tool parameters through the MCP protocol.
819
+ */
820
+ function jsonSchemaToZodShape(schema) {
821
+ const props = schema?.properties ?? {};
822
+ const required = new Set(schema?.required ?? []);
823
+ const shape = {};
824
+ for (const [key, prop] of Object.entries(props)) {
825
+ let zodType = jsonPropertyToZod(prop);
826
+ if (!required.has(key)) {
827
+ zodType = zodType.optional();
828
+ }
829
+ shape[key] = zodType;
830
+ }
831
+ return shape;
832
+ }
833
+ function jsonPropertyToZod(prop) {
834
+ if (!prop || typeof prop !== "object")
835
+ return z.unknown();
836
+ const desc = typeof prop.description === "string" ? prop.description : undefined;
837
+ switch (prop.type) {
838
+ case "string": {
839
+ if (prop.enum && Array.isArray(prop.enum)) {
840
+ const e = z.enum(prop.enum);
841
+ return desc ? e.describe(desc) : e;
842
+ }
843
+ const s = z.string();
844
+ return desc ? s.describe(desc) : s;
845
+ }
846
+ case "number":
847
+ case "integer": {
848
+ const n = z.number();
849
+ return desc ? n.describe(desc) : n;
850
+ }
851
+ case "boolean": {
852
+ const b = z.boolean();
853
+ return desc ? b.describe(desc) : b;
854
+ }
855
+ case "array": {
856
+ const items = prop.items ? jsonPropertyToZod(prop.items) : z.unknown();
857
+ const a = z.array(items);
858
+ return desc ? a.describe(desc) : a;
859
+ }
860
+ case "object": {
861
+ if (prop.properties && typeof prop.properties === "object") {
862
+ const nested = jsonSchemaToZodShape(prop);
863
+ const o = z.object(nested);
864
+ return desc ? o.describe(desc) : o;
865
+ }
866
+ const r = z.record(z.string(), z.unknown());
867
+ return desc ? r.describe(desc) : r;
868
+ }
869
+ default: {
870
+ const u = z.unknown();
871
+ return desc ? u.describe(desc) : u;
872
+ }
873
+ }
874
+ }
875
+ /** Strip MCP server prefix: "mcp__server__tool" → "tool" */
876
+ function stripMcpPrefix(name) {
877
+ const parts = name.split("__");
878
+ if (parts.length >= 3 && parts[0] === "mcp")
879
+ return parts.slice(2).join("__");
880
+ return name;
881
+ }
882
+ // ─── JSON extraction ────────────────────────────────────────────
883
+ /**
884
+ * Extract a JSON object from Claude's text response.
885
+ *
886
+ * Strategy (in order):
887
+ * 1. LAST ```json code block``` - models put their real final answer last, so
888
+ * a prompt-injected fake fenced block placed earlier must not win.
889
+ * 2. Last brace-delimited `{...}` block - handles inline JSON at end of text
890
+ * 3. Full text parse - for responses that are pure JSON
891
+ * 4. Empty object - safe fallback
892
+ *
893
+ * When `outputSchema` is supplied, the parsed object is checked against it.
894
+ * A mismatch is logged (warning) rather than silently accepted, so a
895
+ * non-conforming model answer is at least attributable.
896
+ */
897
+ function tryParseJSON(text, outputSchema, logger) {
898
+ if (!text)
899
+ return {};
900
+ const finalize = (parsed) => {
901
+ if (outputSchema) {
902
+ const problems = schemaMismatches(parsed, outputSchema);
903
+ if (problems.length > 0) {
904
+ logger?.warn?.(`Claude output did not conform to outputSchema: ${problems.join("; ")}`);
905
+ }
906
+ }
907
+ return parsed;
908
+ };
909
+ // 1. Code block - scan ALL fenced blocks, prefer the LAST that parses.
910
+ const fences = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/g)];
911
+ for (let i = fences.length - 1; i >= 0; i--) {
912
+ try {
913
+ const parsed = JSON.parse(fences[i][1].trim());
914
+ if (typeof parsed === "object" && parsed !== null)
915
+ return finalize(parsed);
916
+ }
917
+ catch {
918
+ /* try the next-earlier fence, then fall through to brace scan */
919
+ }
920
+ }
921
+ // 2. Last brace-delimited block (scan backwards for matching braces)
922
+ const lastBrace = text.lastIndexOf("}");
923
+ if (lastBrace !== -1) {
924
+ let depth = 0;
925
+ for (let i = lastBrace; i >= 0; i--) {
926
+ if (text[i] === "}")
927
+ depth++;
928
+ else if (text[i] === "{")
929
+ depth--;
930
+ if (depth === 0) {
931
+ try {
932
+ const parsed = JSON.parse(text.slice(i, lastBrace + 1));
933
+ if (typeof parsed === "object" && parsed !== null)
934
+ return finalize(parsed);
935
+ }
936
+ catch {
937
+ /* try next strategy */
938
+ }
939
+ break;
940
+ }
941
+ }
942
+ }
943
+ // 3. Full text parse
944
+ try {
945
+ const parsed = JSON.parse(text.trim());
946
+ if (typeof parsed === "object" && parsed !== null)
947
+ return finalize(parsed);
948
+ }
949
+ catch {
950
+ /* fall through */
951
+ }
952
+ return {};
953
+ }
954
+ /**
955
+ * Lightweight structural check of a parsed object against a JSON Schema.
956
+ *
957
+ * Deliberately shallow: it verifies `required` keys are present and that the
958
+ * top-level `type` and each declared property `type` match. It does NOT do
959
+ * full JSON Schema validation (no `$ref`, `oneOf`, formats, nested arrays).
960
+ * The contract here is "flag an obviously non-conforming object" so a mismatch
961
+ * is logged rather than silently accepted; it is not a validation gate.
962
+ *
963
+ * Returns a list of human-readable problems; empty means no detected mismatch.
964
+ */
965
+ function schemaMismatches(value, schema) {
966
+ const problems = [];
967
+ const s = schema;
968
+ if (s.type === "object" || s.properties || s.required) {
969
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
970
+ return [`expected an object, got ${Array.isArray(value) ? "array" : typeof value}`];
971
+ }
972
+ const obj = value;
973
+ const required = Array.isArray(s.required) ? s.required : [];
974
+ for (const key of required) {
975
+ if (!(key in obj) || obj[key] === undefined) {
976
+ problems.push(`missing required property "${key}"`);
977
+ }
978
+ }
979
+ const props = s.properties ?? {};
980
+ for (const [key, propSchema] of Object.entries(props)) {
981
+ if (!(key in obj) || obj[key] === undefined || obj[key] === null)
982
+ continue;
983
+ const expected = propSchema?.type;
984
+ if (typeof expected === "string" && !jsonTypeMatches(obj[key], expected)) {
985
+ problems.push(`property "${key}" expected ${expected}, got ${jsonTypeOf(obj[key])}`);
986
+ }
987
+ }
988
+ }
989
+ else if (typeof s.type === "string" && !jsonTypeMatches(value, s.type)) {
990
+ problems.push(`expected ${s.type}, got ${jsonTypeOf(value)}`);
991
+ }
992
+ return problems;
993
+ }
994
+ function jsonTypeOf(value) {
995
+ if (value === null)
996
+ return "null";
997
+ if (Array.isArray(value))
998
+ return "array";
999
+ return typeof value;
1000
+ }
1001
+ function jsonTypeMatches(value, expected) {
1002
+ switch (expected) {
1003
+ case "object":
1004
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1005
+ case "array":
1006
+ return Array.isArray(value);
1007
+ case "string":
1008
+ return typeof value === "string";
1009
+ case "number":
1010
+ return typeof value === "number";
1011
+ case "integer":
1012
+ return typeof value === "number" && Number.isInteger(value);
1013
+ case "boolean":
1014
+ return typeof value === "boolean";
1015
+ case "null":
1016
+ return value === null;
1017
+ default:
1018
+ return true; // unknown/unsupported type keyword - don't flag
1019
+ }
1020
+ }