@sweny-ai/core 0.1.101 → 0.1.103

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/browser.d.ts CHANGED
@@ -13,11 +13,12 @@
13
13
  * - `buildWorkflow` / `refineWorkflow` — depend on the Claude interface.
14
14
  * - Testing helpers — `MockClaude`, `createFileSkill` use `node:fs`.
15
15
  */
16
- export type { Skill, SkillCategory, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, } from "./types.js";
16
+ export type { Skill, SkillCategory, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, Evaluator, EvaluatorRule, EvalResult, EvaluatorKind, NodeRequires, NodeRetry, OutputMatch, NodeSources, EvalPolicy, RequiresOnFail, McpTransport, WorkflowType, SkillHarnessKey, } from "./types.js";
17
17
  export { consoleLogger } from "./types.js";
18
+ export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, MCP_TRANSPORTS, SKILL_CATEGORIES, SKILL_HARNESSES, SKILL_ID_PATTERN, SKILL_ID_MAX_LENGTH, isValidSkillId, } from "./types.js";
18
19
  export { github, linear, slack, sentry, datadog, notification, builtinSkills, createSkillMap, allSkills, isSkillConfigured, configuredBuiltinSkills, validateWorkflowSkills, } from "./skills/index.js";
19
20
  export type { SkillValidationResult } from "./skills/index.js";
20
- export { workflowZ, nodeZ, edgeZ, skillZ, parseWorkflow, validateWorkflow, workflowJsonSchema } from "./schema.js";
21
+ export { workflowZ, nodeZ, edgeZ, skillZ, parseWorkflow, validateWorkflow, workflowJsonSchema, skillJsonSchema, } from "./schema.js";
21
22
  export type { WorkflowError } from "./schema.js";
22
23
  export { workflowToFlow, flowToWorkflow, applyExecutionEvent, exportAsTypescript, getSkillCatalog } from "./studio.js";
23
24
  export type { FlowNode, SkillNodeData, FlowEdge } from "./studio.js";
package/dist/browser.js CHANGED
@@ -14,9 +14,14 @@
14
14
  * - Testing helpers — `MockClaude`, `createFileSkill` use `node:fs`.
15
15
  */
16
16
  export { consoleLogger } from "./types.js";
17
+ // Runtime enum constants + skill-id helpers. Pure data / regex / pure
18
+ // function from types.ts, which is already proven browser-safe (schema.ts
19
+ // imports it). A Studio dropdown rendering evaluator kinds, or validating a
20
+ // skill id, needs these without hardcoding the members.
21
+ export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, MCP_TRANSPORTS, SKILL_CATEGORIES, SKILL_HARNESSES, SKILL_ID_PATTERN, SKILL_ID_MAX_LENGTH, isValidSkillId, } from "./types.js";
17
22
  // Skills (browser-safe — no filesystem access, no `process.env` reads)
18
23
  export { github, linear, slack, sentry, datadog, notification, builtinSkills, createSkillMap, allSkills, isSkillConfigured, configuredBuiltinSkills, validateWorkflowSkills, } from "./skills/index.js";
19
24
  // Schema & validation
20
- export { workflowZ, nodeZ, edgeZ, skillZ, parseWorkflow, validateWorkflow, workflowJsonSchema } from "./schema.js";
25
+ export { workflowZ, nodeZ, edgeZ, skillZ, parseWorkflow, validateWorkflow, workflowJsonSchema, skillJsonSchema, } from "./schema.js";
21
26
  // Studio adapter
22
27
  export { workflowToFlow, flowToWorkflow, applyExecutionEvent, exportAsTypescript, getSkillCatalog } from "./studio.js";
package/dist/claude.d.ts CHANGED
@@ -9,6 +9,43 @@
9
9
  * is to use headless Claude Code — never the raw Anthropic API.
10
10
  */
11
11
  import type { Claude, Tool, ToolContext, NodeResult, JSONSchema, Logger, McpServerConfig } from "./types.js";
12
+ /** How sweny resolves which credentials reach the Claude Code subprocess. */
13
+ export type SwenyAuthMode = "auto" | "api-key" | "oauth";
14
+ export interface ResolveAuthEnvOpts {
15
+ /** Explicit override; when absent, read from `env.SWENY_AUTH`. Test seam. */
16
+ mode?: SwenyAuthMode;
17
+ /** Logger for the debug line + invalid-value warning. */
18
+ logger?: Pick<Logger, "debug" | "warn">;
19
+ }
20
+ /**
21
+ * Decide which auth credentials survive into the Claude Code subprocess env.
22
+ *
23
+ * sweny does not select the winning credential (the spawned agent and any
24
+ * on-disk `~/.claude/.credentials.json` do that). This function only controls
25
+ * what is *present* in the env we hand the agent.
26
+ *
27
+ * Modes (`SWENY_AUTH`, default `auto`):
28
+ * - `auto` — today's protective behavior: when an OAuth token is present,
29
+ * strip `ANTHROPIC_API_KEY` so a stray `.env` key cannot
30
+ * silently bill a metered API account. `ANTHROPIC_AUTH_TOKEN`
31
+ * (a bearer token, not a console key) is never touched.
32
+ * - `api-key` — user explicitly authenticates a gateway with a key/bearer:
33
+ * preserve both even when an OAuth token is also present.
34
+ * - `oauth` — user explicitly wants subscription/OAuth: strip both
35
+ * `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN` so a leftover
36
+ * credential cannot win at the agent. Fails closed.
37
+ *
38
+ * Base-URL presence is deliberately NOT a signal here: pass-through proxies
39
+ * (corporate egress, observability) set `ANTHROPIC_BASE_URL` while still
40
+ * billing the real key, so inferring "use the key" from a base URL would
41
+ * re-introduce a surprise-billing path. Intent must be explicit.
42
+ *
43
+ * An unrecognized `SWENY_AUTH` value falls back to `auto` with a warning
44
+ * (never throws — an env typo must not crash a CI run).
45
+ *
46
+ * Pure: returns a copy, never mutates the input.
47
+ */
48
+ export declare function resolveAuthEnv(env: Record<string, string>, opts?: ResolveAuthEnvOpts): Record<string, string>;
12
49
  export interface ClaudeClientOptions {
13
50
  /** Model override */
14
51
  model?: string;
@@ -23,6 +60,20 @@ export interface ClaudeClientOptions {
23
60
  /** External MCP servers (GitHub, Linear, Sentry, etc.) — merged with core skill tools */
24
61
  mcpServers?: Record<string, McpServerConfig>;
25
62
  }
63
+ /**
64
+ * Wire an optional timeout + caller signal onto a single AbortController.
65
+ *
66
+ * Returns undefined when neither a timeout nor a signal is supplied so the
67
+ * default code path (no abortController, no timer) is byte-for-byte unchanged.
68
+ *
69
+ * `reason()` reports whether the abort came from the timeout timer or an
70
+ * external signal, so callers can log a distinct timeout message.
71
+ */
72
+ export declare function makeAbort(timeoutMs?: number, signal?: AbortSignal): {
73
+ controller: AbortController;
74
+ clear: () => void;
75
+ reason: () => "timeout" | "signal" | undefined;
76
+ } | undefined;
26
77
  export declare class ClaudeClient implements Claude {
27
78
  private model;
28
79
  private maxTurns;
@@ -32,9 +83,8 @@ export declare class ClaudeClient implements Claude {
32
83
  private mcpServers;
33
84
  constructor(opts?: ClaudeClientOptions);
34
85
  /**
35
- * Build env for the Claude Code subprocess.
36
- * OAuth token takes priority over API key to prevent .env files from
37
- * overriding the user's subscription-based auth.
86
+ * Build env for the Claude Code subprocess. Snapshots process.env (dropping
87
+ * nullish values) and applies auth precedence via {@link resolveAuthEnv}.
38
88
  */
39
89
  private buildEnv;
40
90
  run(opts: {
@@ -45,6 +95,11 @@ export declare class ClaudeClient implements Claude {
45
95
  onProgress?: (message: string) => void;
46
96
  maxTurns?: number;
47
97
  disallowedTools?: string[];
98
+ model?: string;
99
+ /** Abort the query after this many ms. Default: no timeout (back-compat). */
100
+ timeoutMs?: number;
101
+ /** Caller-supplied abort signal. Aborting it interrupts the query. */
102
+ signal?: AbortSignal;
48
103
  }): Promise<NodeResult>;
49
104
  evaluate(opts: {
50
105
  question: string;
@@ -53,11 +108,19 @@ export declare class ClaudeClient implements Claude {
53
108
  id: string;
54
109
  description: string;
55
110
  }[];
111
+ /** Abort the query after this many ms. Default: no timeout (back-compat). */
112
+ timeoutMs?: number;
113
+ /** Caller-supplied abort signal. Aborting it interrupts the query. */
114
+ signal?: AbortSignal;
56
115
  }): Promise<string>;
57
116
  ask(opts: {
58
117
  instruction: string;
59
118
  context: Record<string, unknown>;
60
119
  model?: string;
120
+ /** Abort the query after this many ms. Default: no timeout (back-compat). */
121
+ timeoutMs?: number;
122
+ /** Caller-supplied abort signal. Aborting it interrupts the query. */
123
+ signal?: AbortSignal;
61
124
  }): Promise<string>;
62
125
  }
63
126
  /**
package/dist/claude.js CHANGED
@@ -12,6 +12,131 @@ import { query, createSdkMcpServer, tool as sdkTool } from "@anthropic-ai/claude
12
12
  import { z } from "zod";
13
13
  import { consoleLogger } from "./types.js";
14
14
  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.`;
15
+ /**
16
+ * Decide which auth credentials survive into the Claude Code subprocess env.
17
+ *
18
+ * sweny does not select the winning credential (the spawned agent and any
19
+ * on-disk `~/.claude/.credentials.json` do that). This function only controls
20
+ * what is *present* in the env we hand the agent.
21
+ *
22
+ * Modes (`SWENY_AUTH`, default `auto`):
23
+ * - `auto` — today's protective behavior: when an OAuth token is present,
24
+ * strip `ANTHROPIC_API_KEY` so a stray `.env` key cannot
25
+ * silently bill a metered API account. `ANTHROPIC_AUTH_TOKEN`
26
+ * (a bearer token, not a console key) is never touched.
27
+ * - `api-key` — user explicitly authenticates a gateway with a key/bearer:
28
+ * preserve both even when an OAuth token is also present.
29
+ * - `oauth` — user explicitly wants subscription/OAuth: strip both
30
+ * `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN` so a leftover
31
+ * credential cannot win at the agent. Fails closed.
32
+ *
33
+ * Base-URL presence is deliberately NOT a signal here: pass-through proxies
34
+ * (corporate egress, observability) set `ANTHROPIC_BASE_URL` while still
35
+ * billing the real key, so inferring "use the key" from a base URL would
36
+ * re-introduce a surprise-billing path. Intent must be explicit.
37
+ *
38
+ * An unrecognized `SWENY_AUTH` value falls back to `auto` with a warning
39
+ * (never throws — an env typo must not crash a CI run).
40
+ *
41
+ * Pure: returns a copy, never mutates the input.
42
+ */
43
+ export function resolveAuthEnv(env, opts = {}) {
44
+ const out = { ...env };
45
+ const raw = opts.mode ?? env.SWENY_AUTH;
46
+ let mode = "auto";
47
+ if (raw === "auto" || raw === "api-key" || raw === "oauth") {
48
+ mode = raw;
49
+ }
50
+ else if (raw) {
51
+ // truthy but unrecognized — empty string falls through silently to auto
52
+ opts.logger?.warn?.(`SWENY_AUTH="${raw}" is not one of auto|api-key|oauth; falling back to auto`);
53
+ }
54
+ // Truthy checks throughout: action.yml sets these to "" when an input is
55
+ // omitted, and empty string must read as unset.
56
+ const hasOauth = !!out.CLAUDE_CODE_OAUTH_TOKEN;
57
+ if (mode === "oauth") {
58
+ delete out.ANTHROPIC_API_KEY;
59
+ delete out.ANTHROPIC_AUTH_TOKEN;
60
+ }
61
+ else if (mode === "api-key") {
62
+ // preserve key + bearer; do not strip even if an OAuth token is present
63
+ }
64
+ else {
65
+ // auto — byte-for-byte the historical behavior
66
+ if (hasOauth) {
67
+ delete out.ANTHROPIC_API_KEY;
68
+ }
69
+ }
70
+ // Mode only, never credential values.
71
+ opts.logger?.debug?.(`[sweny] auth mode: ${mode}`);
72
+ return out;
73
+ }
74
+ /**
75
+ * Wire an optional timeout + caller signal onto a single AbortController.
76
+ *
77
+ * Returns undefined when neither a timeout nor a signal is supplied so the
78
+ * default code path (no abortController, no timer) is byte-for-byte unchanged.
79
+ *
80
+ * `reason()` reports whether the abort came from the timeout timer or an
81
+ * external signal, so callers can log a distinct timeout message.
82
+ */
83
+ export function makeAbort(timeoutMs, signal) {
84
+ if (!timeoutMs && !signal)
85
+ return undefined;
86
+ const controller = new AbortController();
87
+ let reason;
88
+ let timer;
89
+ const onSignalAbort = () => {
90
+ if (controller.signal.aborted)
91
+ return;
92
+ reason = "signal";
93
+ controller.abort();
94
+ };
95
+ if (signal) {
96
+ if (signal.aborted) {
97
+ reason = "signal";
98
+ controller.abort();
99
+ }
100
+ else {
101
+ signal.addEventListener("abort", onSignalAbort, { once: true });
102
+ }
103
+ }
104
+ if (timeoutMs && timeoutMs > 0 && !controller.signal.aborted) {
105
+ timer = setTimeout(() => {
106
+ if (controller.signal.aborted)
107
+ return;
108
+ reason = "timeout";
109
+ controller.abort();
110
+ }, timeoutMs);
111
+ // Don't keep the event loop alive just for the abort timer.
112
+ timer?.unref?.();
113
+ }
114
+ return {
115
+ controller,
116
+ reason: () => reason,
117
+ clear: () => {
118
+ if (timer)
119
+ clearTimeout(timer);
120
+ signal?.removeEventListener("abort", onSignalAbort);
121
+ },
122
+ };
123
+ }
124
+ /**
125
+ * Best-effort interrupt of an SDK query stream. Called from `finally` so an
126
+ * early return / timeout / throw does not leak the underlying subprocess.
127
+ * Swallows errors: the stream may already be done, and interrupt is only
128
+ * supported in streaming-input mode.
129
+ */
130
+ async function interruptStream(stream) {
131
+ if (!stream || typeof stream.interrupt !== "function")
132
+ return;
133
+ try {
134
+ await stream.interrupt();
135
+ }
136
+ catch {
137
+ /* already finished or not interruptible — nothing to clean up */
138
+ }
139
+ }
15
140
  export class ClaudeClient {
16
141
  model;
17
142
  maxTurns;
@@ -28,19 +153,16 @@ export class ClaudeClient {
28
153
  this.mcpServers = opts.mcpServers ?? {};
29
154
  }
30
155
  /**
31
- * Build env for the Claude Code subprocess.
32
- * OAuth token takes priority over API key to prevent .env files from
33
- * overriding the user's subscription-based auth.
156
+ * Build env for the Claude Code subprocess. Snapshots process.env (dropping
157
+ * nullish values) and applies auth precedence via {@link resolveAuthEnv}.
34
158
  */
35
159
  buildEnv() {
36
160
  const env = Object.fromEntries(Object.entries(process.env).filter((e) => e[1] != null));
37
- if (env.CLAUDE_CODE_OAUTH_TOKEN) {
38
- delete env.ANTHROPIC_API_KEY;
39
- }
40
- return env;
161
+ return resolveAuthEnv(env, { logger: this.logger });
41
162
  }
42
163
  async run(opts) {
43
- const { instruction, context, tools, outputSchema, onProgress, maxTurns, disallowedTools } = opts;
164
+ const { instruction, context, tools, outputSchema, onProgress, maxTurns, disallowedTools, model, timeoutMs, signal, } = opts;
165
+ const effectiveModel = model ?? this.model;
44
166
  // Tool-call accounting (Fix #1).
45
167
  //
46
168
  // Stream-driven recording, correlated by tool_use_id:
@@ -80,11 +202,16 @@ export class ClaudeClient {
80
202
  .join("\n\n");
81
203
  const env = this.buildEnv();
82
204
  let response = "";
205
+ // Timeout / abort wiring (back-compat: only armed when requested).
206
+ // A single AbortController drives both an optional caller signal and an
207
+ // optional timeout timer. The SDK aborts the subprocess when this fires.
208
+ const abort = makeAbort(timeoutMs, signal);
209
+ let stream;
83
210
  try {
84
211
  const allMcpServers = { ...this.mcpServers };
85
212
  if (sdkTools.length > 0)
86
213
  allMcpServers["sweny-core"] = mcpServer;
87
- const stream = query({
214
+ stream = query({
88
215
  prompt,
89
216
  options: {
90
217
  maxTurns: maxTurns ?? this.maxTurns,
@@ -94,7 +221,8 @@ export class ClaudeClient {
94
221
  permissionMode: "bypassPermissions",
95
222
  allowDangerouslySkipPermissions: true,
96
223
  stderr: (data) => this.logger.debug(`[claude-code] ${data}`),
97
- ...(this.model ? { model: this.model } : {}),
224
+ ...(abort ? { abortController: abort.controller } : {}),
225
+ ...(effectiveModel ? { model: effectiveModel } : {}),
98
226
  ...(Object.keys(allMcpServers).length > 0 ? { mcpServers: allMcpServers } : {}),
99
227
  ...(disallowedTools && disallowedTools.length > 0 ? { disallowedTools } : {}),
100
228
  },
@@ -200,6 +328,11 @@ export class ClaudeClient {
200
328
  }
201
329
  }
202
330
  catch (err) {
331
+ if (abort?.reason() === "timeout") {
332
+ const msg = `Claude query timed out after ${timeoutMs}ms`;
333
+ this.logger.error(msg);
334
+ return { status: "failed", data: { error: msg }, toolCalls };
335
+ }
203
336
  this.logger.error(`Claude Code query failed: ${err.message}`);
204
337
  return {
205
338
  status: "failed",
@@ -207,19 +340,29 @@ export class ClaudeClient {
207
340
  toolCalls,
208
341
  };
209
342
  }
343
+ finally {
344
+ // Interrupt the subprocess on any exit (early return, throw, or normal
345
+ // completion) so a wedged agent does not leak, and clear the timer so
346
+ // it cannot fire after we are done.
347
+ abort?.clear();
348
+ await interruptStream(stream);
349
+ }
210
350
  return {
211
351
  status: "success",
212
- data: { summary: response, ...tryParseJSON(response) },
352
+ data: { summary: response, ...tryParseJSON(response, outputSchema, this.logger) },
213
353
  toolCalls,
214
354
  };
215
355
  }
216
356
  async evaluate(opts) {
217
- const { question, context, choices } = opts;
357
+ const { question, context, choices, timeoutMs, signal } = opts;
218
358
  const prompt = buildEvaluatePrompt(question, context, choices);
219
359
  const env = this.buildEnv();
220
360
  let response = "";
361
+ const abort = makeAbort(timeoutMs, signal);
362
+ let sdkFailed = false;
363
+ let stream;
221
364
  try {
222
- const stream = query({
365
+ stream = query({
223
366
  prompt,
224
367
  options: {
225
368
  maxTurns: 1,
@@ -228,6 +371,7 @@ export class ClaudeClient {
228
371
  permissionMode: "bypassPermissions",
229
372
  allowDangerouslySkipPermissions: true,
230
373
  stderr: (data) => this.logger.debug(`[claude-code] ${data}`),
374
+ ...(abort ? { abortController: abort.controller } : {}),
231
375
  ...(this.model ? { model: this.model } : {}),
232
376
  },
233
377
  });
@@ -237,13 +381,35 @@ export class ClaudeClient {
237
381
  if (resultMsg.subtype === "success" && "result" in resultMsg) {
238
382
  response = resultMsg.result;
239
383
  }
384
+ else {
385
+ // Distinguish an SDK-level failure from a genuinely ambiguous
386
+ // model answer. Without this, both fall through to validIds[0]
387
+ // and log the same "Could not parse route choice" warning, so an
388
+ // operator cannot tell them apart. Short-circuit below so the
389
+ // ambiguous-answer warning never fires on top of this one.
390
+ sdkFailed = true;
391
+ this.logger.warn(`claude.evaluate: SDK returned non-success subtype "${resultMsg.subtype}" — falling back to first choice.`);
392
+ }
240
393
  }
241
394
  }
242
395
  }
243
396
  catch (err) {
244
- this.logger.warn(`Evaluate query failed: ${err.message}. Falling back to first choice.`);
397
+ if (abort?.reason() === "timeout") {
398
+ this.logger.warn(`Evaluate query timed out after ${timeoutMs}ms. Falling back to first choice.`);
399
+ }
400
+ else {
401
+ this.logger.warn(`Evaluate query failed: ${err.message}. Falling back to first choice.`);
402
+ }
245
403
  return choices[0].id;
246
404
  }
405
+ finally {
406
+ abort?.clear();
407
+ await interruptStream(stream);
408
+ }
409
+ // An SDK-level failure already logged a distinct message; do not also emit
410
+ // the ambiguous-answer warning, which would misattribute the cause.
411
+ if (sdkFailed)
412
+ return choices[0].id;
247
413
  const text = response.trim().replace(/^["']|["']$/g, "");
248
414
  const validIds = choices.map((c) => c.id);
249
415
  // Exact match
@@ -258,7 +424,7 @@ export class ClaudeClient {
258
424
  return validIds[0];
259
425
  }
260
426
  async ask(opts) {
261
- const { instruction, context, model } = opts;
427
+ const { instruction, context, model, timeoutMs, signal } = opts;
262
428
  const prompt = [
263
429
  instruction,
264
430
  Object.keys(context).length > 0 ? `\nContext:\n\`\`\`json\n${JSON.stringify(context, null, 2)}\n\`\`\`` : "",
@@ -268,8 +434,10 @@ export class ClaudeClient {
268
434
  const env = this.buildEnv();
269
435
  let response = "";
270
436
  const effectiveModel = model ?? this.model;
437
+ const abort = makeAbort(timeoutMs, signal);
438
+ let stream;
271
439
  try {
272
- const stream = query({
440
+ stream = query({
273
441
  prompt,
274
442
  options: {
275
443
  maxTurns: 1,
@@ -278,6 +446,7 @@ export class ClaudeClient {
278
446
  permissionMode: "bypassPermissions",
279
447
  allowDangerouslySkipPermissions: true,
280
448
  stderr: (data) => this.logger.debug(`[claude-code] ${data}`),
449
+ ...(abort ? { abortController: abort.controller } : {}),
281
450
  ...(effectiveModel ? { model: effectiveModel } : {}),
282
451
  },
283
452
  });
@@ -294,9 +463,18 @@ export class ClaudeClient {
294
463
  }
295
464
  }
296
465
  catch (err) {
297
- this.logger.warn(`Ask query failed: ${err.message}`);
466
+ if (abort?.reason() === "timeout") {
467
+ this.logger.warn(`Ask query timed out after ${timeoutMs}ms — returning empty string`);
468
+ }
469
+ else {
470
+ this.logger.warn(`Ask query failed: ${err.message}`);
471
+ }
298
472
  return "";
299
473
  }
474
+ finally {
475
+ abort?.clear();
476
+ await interruptStream(stream);
477
+ }
300
478
  return response.trim();
301
479
  }
302
480
  }
@@ -372,18 +550,37 @@ export function buildEvaluatePrompt(question, context, choices) {
372
550
  `1. Read each choice's condition literally and match against the structured fields in the context (e.g. status, counts, enum values, boolean flags).`,
373
551
  `2. Ignore prose narrative fields ("summary", free-form rationale, conversational commentary). They are not the contract.`,
374
552
  `3. When a field's value contradicts what a prose field claims, trust the field's value.`,
553
+ `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.`,
375
554
  `\nRespond with ONLY the choice ID, nothing else.`,
376
555
  ].join("\n");
377
556
  }
378
557
  export function parseToolResultContent(content) {
558
+ // The Anthropic tool_result `content` field can be a block array
559
+ // (e.g. [{type:"text",text:"..."}, ...]) rather than a string. When it is,
560
+ // concatenate the text of every {type:"text"} block (recursing through any
561
+ // nested content), then run the same object/array JSON-parse logic on the
562
+ // joined string. This keeps `call.output` the actual payload instead of the
563
+ // raw wrapper array that eval/route logic would otherwise have to walk.
564
+ if (Array.isArray(content)) {
565
+ const text = collectBlockText(content);
566
+ return parseJsonObjectOrArray(text);
567
+ }
379
568
  if (typeof content !== "string")
380
569
  return content;
570
+ return parseJsonObjectOrArray(content);
571
+ }
572
+ /**
573
+ * Parse a string only when it is an unambiguous JSON object or array.
574
+ *
575
+ * Strings, numbers, booleans, and null would all corrupt or discard
576
+ * information (we cannot distinguish the literal text `"42"` from the number
577
+ * 42), so non-object/array inputs are preserved verbatim.
578
+ */
579
+ function parseJsonObjectOrArray(content) {
381
580
  const trimmed = content.trim();
382
581
  if (trimmed.length === 0)
383
582
  return content;
384
583
  const first = trimmed[0];
385
- // Only objects and arrays are unambiguous to parse. Strings, numbers,
386
- // booleans, and null would all corrupt or discard information.
387
584
  if (first !== "{" && first !== "[")
388
585
  return content;
389
586
  try {
@@ -393,6 +590,34 @@ export function parseToolResultContent(content) {
393
590
  return content;
394
591
  }
395
592
  }
593
+ /**
594
+ * Concatenate the text of an array of content blocks. Handles {type:"text"}
595
+ * blocks and recurses into any nested `content` array so deeply-wrapped tool
596
+ * results still flatten to their underlying text.
597
+ */
598
+ function collectBlockText(blocks) {
599
+ const parts = [];
600
+ for (const block of blocks) {
601
+ if (typeof block === "string") {
602
+ parts.push(block);
603
+ continue;
604
+ }
605
+ if (!block || typeof block !== "object")
606
+ continue;
607
+ const b = block;
608
+ if (b.type === "text" && typeof b.text === "string") {
609
+ parts.push(b.text);
610
+ }
611
+ else if (Array.isArray(b.content)) {
612
+ parts.push(collectBlockText(b.content));
613
+ }
614
+ else if (typeof b.text === "string") {
615
+ // Some producers omit an explicit type but still carry text.
616
+ parts.push(b.text);
617
+ }
618
+ }
619
+ return parts.join("");
620
+ }
396
621
  /**
397
622
  * Produce a short, single-line description of a tool-error payload suitable
398
623
  * for the CI log stream. The full parsed value stays on the ToolCall for
@@ -493,24 +718,38 @@ function stripMcpPrefix(name) {
493
718
  * Extract a JSON object from Claude's text response.
494
719
  *
495
720
  * Strategy (in order):
496
- * 1. ```json code block``` — most reliable, Claude often wraps JSON this way
721
+ * 1. LAST ```json code block``` — models put their real final answer last, so
722
+ * a prompt-injected fake fenced block placed earlier must not win.
497
723
  * 2. Last brace-delimited `{...}` block — handles inline JSON at end of text
498
724
  * 3. Full text parse — for responses that are pure JSON
499
725
  * 4. Empty object — safe fallback
726
+ *
727
+ * When `outputSchema` is supplied, the parsed object is checked against it.
728
+ * A mismatch is logged (warning) rather than silently accepted, so a
729
+ * non-conforming model answer is at least attributable.
500
730
  */
501
- function tryParseJSON(text) {
731
+ function tryParseJSON(text, outputSchema, logger) {
502
732
  if (!text)
503
733
  return {};
504
- // 1. Code block
505
- const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/);
506
- if (codeBlockMatch) {
734
+ const finalize = (parsed) => {
735
+ if (outputSchema) {
736
+ const problems = schemaMismatches(parsed, outputSchema);
737
+ if (problems.length > 0) {
738
+ logger?.warn?.(`Claude output did not conform to outputSchema: ${problems.join("; ")}`);
739
+ }
740
+ }
741
+ return parsed;
742
+ };
743
+ // 1. Code block — scan ALL fenced blocks, prefer the LAST that parses.
744
+ const fences = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/g)];
745
+ for (let i = fences.length - 1; i >= 0; i--) {
507
746
  try {
508
- const parsed = JSON.parse(codeBlockMatch[1].trim());
747
+ const parsed = JSON.parse(fences[i][1].trim());
509
748
  if (typeof parsed === "object" && parsed !== null)
510
- return parsed;
749
+ return finalize(parsed);
511
750
  }
512
751
  catch {
513
- /* try next strategy */
752
+ /* try the next-earlier fence, then fall through to brace scan */
514
753
  }
515
754
  }
516
755
  // 2. Last brace-delimited block (scan backwards for matching braces)
@@ -526,7 +765,7 @@ function tryParseJSON(text) {
526
765
  try {
527
766
  const parsed = JSON.parse(text.slice(i, lastBrace + 1));
528
767
  if (typeof parsed === "object" && parsed !== null)
529
- return parsed;
768
+ return finalize(parsed);
530
769
  }
531
770
  catch {
532
771
  /* try next strategy */
@@ -539,10 +778,77 @@ function tryParseJSON(text) {
539
778
  try {
540
779
  const parsed = JSON.parse(text.trim());
541
780
  if (typeof parsed === "object" && parsed !== null)
542
- return parsed;
781
+ return finalize(parsed);
543
782
  }
544
783
  catch {
545
784
  /* fall through */
546
785
  }
547
786
  return {};
548
787
  }
788
+ /**
789
+ * Lightweight structural check of a parsed object against a JSON Schema.
790
+ *
791
+ * Deliberately shallow: it verifies `required` keys are present and that the
792
+ * top-level `type` and each declared property `type` match. It does NOT do
793
+ * full JSON Schema validation (no `$ref`, `oneOf`, formats, nested arrays).
794
+ * The contract here is "flag an obviously non-conforming object" so a mismatch
795
+ * is logged rather than silently accepted; it is not a validation gate.
796
+ *
797
+ * Returns a list of human-readable problems; empty means no detected mismatch.
798
+ */
799
+ function schemaMismatches(value, schema) {
800
+ const problems = [];
801
+ const s = schema;
802
+ if (s.type === "object" || s.properties || s.required) {
803
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
804
+ return [`expected an object, got ${Array.isArray(value) ? "array" : typeof value}`];
805
+ }
806
+ const obj = value;
807
+ const required = Array.isArray(s.required) ? s.required : [];
808
+ for (const key of required) {
809
+ if (!(key in obj) || obj[key] === undefined) {
810
+ problems.push(`missing required property "${key}"`);
811
+ }
812
+ }
813
+ const props = s.properties ?? {};
814
+ for (const [key, propSchema] of Object.entries(props)) {
815
+ if (!(key in obj) || obj[key] === undefined || obj[key] === null)
816
+ continue;
817
+ const expected = propSchema?.type;
818
+ if (typeof expected === "string" && !jsonTypeMatches(obj[key], expected)) {
819
+ problems.push(`property "${key}" expected ${expected}, got ${jsonTypeOf(obj[key])}`);
820
+ }
821
+ }
822
+ }
823
+ else if (typeof s.type === "string" && !jsonTypeMatches(value, s.type)) {
824
+ problems.push(`expected ${s.type}, got ${jsonTypeOf(value)}`);
825
+ }
826
+ return problems;
827
+ }
828
+ function jsonTypeOf(value) {
829
+ if (value === null)
830
+ return "null";
831
+ if (Array.isArray(value))
832
+ return "array";
833
+ return typeof value;
834
+ }
835
+ function jsonTypeMatches(value, expected) {
836
+ switch (expected) {
837
+ case "object":
838
+ return typeof value === "object" && value !== null && !Array.isArray(value);
839
+ case "array":
840
+ return Array.isArray(value);
841
+ case "string":
842
+ return typeof value === "string";
843
+ case "number":
844
+ return typeof value === "number";
845
+ case "integer":
846
+ return typeof value === "number" && Number.isInteger(value);
847
+ case "boolean":
848
+ return typeof value === "boolean";
849
+ case "null":
850
+ return value === null;
851
+ default:
852
+ return true; // unknown/unsupported type keyword — don't flag
853
+ }
854
+ }