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