@sweny-ai/core 0.1.141 → 0.1.143

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.
Files changed (40) hide show
  1. package/dist/agent-env.d.ts +24 -0
  2. package/dist/agent-env.js +40 -7
  3. package/dist/cli/config-file.d.ts +1 -1
  4. package/dist/cli/config-file.js +1 -1
  5. package/dist/cli/config.d.ts +2 -0
  6. package/dist/cli/config.js +15 -7
  7. package/dist/cli/main.js +45 -15
  8. package/dist/cli/output.d.ts +2 -0
  9. package/dist/cli/output.js +11 -0
  10. package/dist/cli/run-history.d.ts +2 -0
  11. package/dist/cli/run-history.js +2 -0
  12. package/dist/cli/run-output.d.ts +13 -2
  13. package/dist/cli/run-output.js +23 -2
  14. package/dist/executor.js +30 -25
  15. package/dist/harness/abort.d.ts +17 -0
  16. package/dist/harness/abort.js +53 -0
  17. package/dist/harness/agents.d.ts +11 -0
  18. package/dist/harness/agents.js +15 -0
  19. package/dist/harness/capabilities.d.ts +23 -0
  20. package/dist/harness/capabilities.js +35 -0
  21. package/dist/harness/claude-code.d.ts +9 -40
  22. package/dist/harness/claude-code.js +21 -308
  23. package/dist/harness/codex.d.ts +175 -0
  24. package/dist/harness/codex.js +917 -0
  25. package/dist/harness/index.d.ts +11 -3
  26. package/dist/harness/index.js +8 -8
  27. package/dist/harness/parse.d.ts +49 -0
  28. package/dist/harness/parse.js +253 -0
  29. package/dist/harness/policy.d.ts +23 -1
  30. package/dist/harness/policy.js +73 -15
  31. package/dist/harness/prompts.d.ts +7 -1
  32. package/dist/harness/prompts.js +16 -0
  33. package/dist/harness/types.d.ts +13 -2
  34. package/dist/harness/types.js +2 -2
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.js +2 -2
  37. package/dist/schema.d.ts +12 -11
  38. package/dist/schema.js +7 -3
  39. package/dist/types.d.ts +8 -1
  40. package/package.json +1 -1
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Timeout + caller-signal wiring shared by every adapter. No agent SDK imports.
3
+ */
4
+ /**
5
+ * Wire an optional timeout + caller signal onto a single AbortController.
6
+ *
7
+ * Returns undefined when neither a timeout nor a signal is supplied so the
8
+ * default code path (no abortController, no timer) is byte-for-byte unchanged.
9
+ *
10
+ * `reason()` reports whether the abort came from the timeout timer or an
11
+ * external signal, so callers can log a distinct timeout message.
12
+ */
13
+ export declare function makeAbort(timeoutMs?: number, signal?: AbortSignal): {
14
+ controller: AbortController;
15
+ clear: () => void;
16
+ reason: () => "timeout" | "signal" | undefined;
17
+ } | undefined;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Timeout + caller-signal wiring shared by every adapter. No agent SDK imports.
3
+ */
4
+ /**
5
+ * Wire an optional timeout + caller signal onto a single AbortController.
6
+ *
7
+ * Returns undefined when neither a timeout nor a signal is supplied so the
8
+ * default code path (no abortController, no timer) is byte-for-byte unchanged.
9
+ *
10
+ * `reason()` reports whether the abort came from the timeout timer or an
11
+ * external signal, so callers can log a distinct timeout message.
12
+ */
13
+ export function makeAbort(timeoutMs, signal) {
14
+ if (!timeoutMs && !signal)
15
+ return undefined;
16
+ const controller = new AbortController();
17
+ let reason;
18
+ let timer;
19
+ const onSignalAbort = () => {
20
+ if (controller.signal.aborted)
21
+ return;
22
+ reason = "signal";
23
+ controller.abort();
24
+ };
25
+ if (signal) {
26
+ if (signal.aborted) {
27
+ reason = "signal";
28
+ controller.abort();
29
+ }
30
+ else {
31
+ signal.addEventListener("abort", onSignalAbort, { once: true });
32
+ }
33
+ }
34
+ if (timeoutMs && timeoutMs > 0 && !controller.signal.aborted) {
35
+ timer = setTimeout(() => {
36
+ if (controller.signal.aborted)
37
+ return;
38
+ reason = "timeout";
39
+ controller.abort();
40
+ }, timeoutMs);
41
+ // Don't keep the event loop alive just for the abort timer.
42
+ timer?.unref?.();
43
+ }
44
+ return {
45
+ controller,
46
+ reason: () => reason,
47
+ clear: () => {
48
+ if (timer)
49
+ clearTimeout(timer);
50
+ signal?.removeEventListener("abort", onSignalAbort);
51
+ },
52
+ };
53
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The agent ids `--agent` / `coding-agent-provider` accept. Each one has an
3
+ * adapter and a green contract suite; anything else is refused, so sweny never
4
+ * runs one agent under another's name. No runtime imports (the CLI config and
5
+ * the harness factory share it).
6
+ */
7
+ export declare const SUPPORTED_AGENTS: readonly ["claude", "codex"];
8
+ export type SupportedAgent = (typeof SUPPORTED_AGENTS)[number];
9
+ export declare function isSupportedAgent(id: string): id is SupportedAgent;
10
+ /** The error for an agent id with no adapter. */
11
+ export declare function unsupportedAgentError(id: string): string;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The agent ids `--agent` / `coding-agent-provider` accept. Each one has an
3
+ * adapter and a green contract suite; anything else is refused, so sweny never
4
+ * runs one agent under another's name. No runtime imports (the CLI config and
5
+ * the harness factory share it).
6
+ */
7
+ export const SUPPORTED_AGENTS = ["claude", "codex"];
8
+ export function isSupportedAgent(id) {
9
+ return SUPPORTED_AGENTS.includes(id);
10
+ }
11
+ /** The error for an agent id with no adapter. */
12
+ export function unsupportedAgentError(id) {
13
+ return (`Unsupported coding agent "${id}": supported agents are "claude" (headless Claude Code) ` +
14
+ `and "codex" (Codex CLI). Remove --agent / coding-agent-provider or set it to one of them.`);
15
+ }
@@ -5,3 +5,26 @@
5
5
  import type { HarnessCapabilities } from "./types.js";
6
6
  /** Claude Code enforces every sweny opinion natively, so `policyGate` never degrades or refuses it. */
7
7
  export declare const CLAUDE_CODE_CAPABILITIES: HarnessCapabilities;
8
+ /**
9
+ * Codex (`codex exec --json`), declared from the openai/codex source at
10
+ * rust-v0.159.2 and probed by the contract suite against the scripted fake:
11
+ * - structuredOutput native: `--output-schema <file>` (exec/src/cli.rs).
12
+ * - toolTrace full: `item.*` events for command_execution, file_change,
13
+ * mcp_tool_call and web_search, each with an id and a status
14
+ * (exec/src/exec_events.rs).
15
+ * - builtinDeny shell-only, plus net and subagent: `features.shell_tool=false`
16
+ * removes every shell tool (core/src/tools/spec_plan.rs add_shell_tools),
17
+ * `web_search="disabled"` removes web search, and `features.multi_agent=false`
18
+ * with `agents.enabled=false` removes subagents. apply_patch has no switch,
19
+ * so write and edit cannot be denied as classes.
20
+ * - mcp exclusive native: `--ignore-user-config` drops `$CODEX_HOME/config.toml`,
21
+ * and project `.codex/config.toml` layers stay disabled for a project that
22
+ * is not trusted in that ignored file (config/src/loader/mod.rs).
23
+ * - sandbox fs only: `--sandbox read-only|workspace-write`. Network is one
24
+ * on/off switch (`sandbox_workspace_write.network_access`), not a host list.
25
+ * - readOnly native: `--sandbox read-only` plus the shell, web search and
26
+ * subagent switches above.
27
+ * - turnLimit watchdog: codex exec has no turn limit; sweny counts tool calls.
28
+ * - usage tokens only: `turn.completed.usage`; Codex reports no cost.
29
+ */
30
+ export declare const CODEX_CAPABILITIES: HarnessCapabilities;
@@ -15,3 +15,38 @@ export const CLAUDE_CODE_CAPABILITIES = {
15
15
  cancel: "signal",
16
16
  resume: false,
17
17
  };
18
+ /**
19
+ * Codex (`codex exec --json`), declared from the openai/codex source at
20
+ * rust-v0.159.2 and probed by the contract suite against the scripted fake:
21
+ * - structuredOutput native: `--output-schema <file>` (exec/src/cli.rs).
22
+ * - toolTrace full: `item.*` events for command_execution, file_change,
23
+ * mcp_tool_call and web_search, each with an id and a status
24
+ * (exec/src/exec_events.rs).
25
+ * - builtinDeny shell-only, plus net and subagent: `features.shell_tool=false`
26
+ * removes every shell tool (core/src/tools/spec_plan.rs add_shell_tools),
27
+ * `web_search="disabled"` removes web search, and `features.multi_agent=false`
28
+ * with `agents.enabled=false` removes subagents. apply_patch has no switch,
29
+ * so write and edit cannot be denied as classes.
30
+ * - mcp exclusive native: `--ignore-user-config` drops `$CODEX_HOME/config.toml`,
31
+ * and project `.codex/config.toml` layers stay disabled for a project that
32
+ * is not trusted in that ignored file (config/src/loader/mod.rs).
33
+ * - sandbox fs only: `--sandbox read-only|workspace-write`. Network is one
34
+ * on/off switch (`sandbox_workspace_write.network_access`), not a host list.
35
+ * - readOnly native: `--sandbox read-only` plus the shell, web search and
36
+ * subagent switches above.
37
+ * - turnLimit watchdog: codex exec has no turn limit; sweny counts tool calls.
38
+ * - usage tokens only: `turn.completed.usage`; Codex reports no cost.
39
+ */
40
+ export const CODEX_CAPABILITIES = {
41
+ structuredOutput: "native",
42
+ toolTrace: "full",
43
+ builtinDeny: "shell-only",
44
+ denyClasses: ["shell", "net", "subagent"],
45
+ mcp: { inject: true, exclusive: "native" },
46
+ sandbox: { fs: true, network: false },
47
+ readOnly: "native",
48
+ turnLimit: "watchdog",
49
+ usage: { tokens: true, costUsd: false, live: false },
50
+ cancel: "kill",
51
+ resume: false,
52
+ };
@@ -10,10 +10,13 @@
10
10
  */
11
11
  import type { Claude, Tool, ToolContext, Logger, McpServerConfig } from "../types.js";
12
12
  import { type SandboxMode } from "../agent-env.js";
13
- import type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessInfo, HarnessRunRequest, HarnessRunResult } from "./types.js";
13
+ import type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessInfo, HarnessRunRequest, HarnessRunResult, ToolClass } from "./types.js";
14
14
  import { CLAUDE_CODE_CAPABILITIES } from "./capabilities.js";
15
15
  import { buildEvaluatePrompt } from "./prompts.js";
16
+ import { parseToolResultContent, summarizeToolError } from "./parse.js";
17
+ import { makeAbort } from "./abort.js";
16
18
  export { buildEvaluatePrompt, CLAUDE_CODE_CAPABILITIES };
19
+ export { parseToolResultContent, summarizeToolError, makeAbort };
17
20
  /**
18
21
  * Built-in tools disallowed for the route-evaluation (`evaluate`) and
19
22
  * reflection/judge (`ask`) calls.
@@ -34,6 +37,11 @@ export declare const CLASSIFICATION_DISALLOWED_TOOLS: readonly ["Bash", "Write",
34
37
  * Read, Grep, Glob and WebSearch stay available for analysis.
35
38
  */
36
39
  export declare const READ_ONLY_DISALLOWED_TOOLS: readonly ["Bash", "Write", "Edit", "MultiEdit", "NotebookEdit", "Task", "Agent", "WebFetch"];
40
+ /**
41
+ * Claude Code built-in tool names per portable tool class. A node's
42
+ * `tools.deny` classes compile to these names on `disallowedTools`.
43
+ */
44
+ export declare const CLAUDE_TOOLS_BY_CLASS: Readonly<Record<ToolClass, readonly string[]>>;
37
45
  /** How sweny resolves which credentials reach the Claude Code subprocess. */
38
46
  export type SwenyAuthMode = "auto" | "api-key" | "oauth";
39
47
  export interface ResolveAuthEnvOpts {
@@ -120,20 +128,6 @@ export interface ClaudeCodeHarnessOptions {
120
128
  args: string[];
121
129
  };
122
130
  }
123
- /**
124
- * Wire an optional timeout + caller signal onto a single AbortController.
125
- *
126
- * Returns undefined when neither a timeout nor a signal is supplied so the
127
- * default code path (no abortController, no timer) is byte-for-byte unchanged.
128
- *
129
- * `reason()` reports whether the abort came from the timeout timer or an
130
- * external signal, so callers can log a distinct timeout message.
131
- */
132
- export declare function makeAbort(timeoutMs?: number, signal?: AbortSignal): {
133
- controller: AbortController;
134
- clear: () => void;
135
- reason: () => "timeout" | "signal" | undefined;
136
- } | undefined;
137
131
  export declare class ClaudeCodeHarness implements Claude, AgentHarness {
138
132
  readonly id: "claude-code";
139
133
  readonly capabilities: HarnessCapabilities;
@@ -227,28 +221,3 @@ export type ClaudeClient = ClaudeCodeHarness;
227
221
  * @internal Exported for the tool bridge parity test (#414).
228
222
  */
229
223
  export declare function coreToolToSdkTool(coreTool: Tool, defaultCtx: ToolContext): import("@anthropic-ai/claude-agent-sdk").SdkMcpToolDefinition<Record<string, import("zod").ZodType<any, import("zod").ZodTypeDef, any>>>;
230
- /**
231
- * Best-effort recovery of typed output from a tool_result's `content`.
232
- *
233
- * The MCP protocol sends tool results as string content. Our in-process
234
- * wrapper JSON-stringifies structured output before returning; external
235
- * MCP servers generally do the same for JSON payloads. If the string
236
- * looks like a JSON object or array and parses, return the parsed value
237
- * so verify's output-path walks work against typed data.
238
- *
239
- * Raw strings are preserved verbatim. JSON-primitive strings (e.g. the
240
- * literal four characters `"42"`) are intentionally NOT parsed - we
241
- * cannot distinguish a tool that returned the number 42 (wrapper sends
242
- * `"42"`) from a tool that returned the string "42" (wrapper also sends
243
- * `"42"`). Preserving the string is safer than guessing.
244
- */
245
- export declare function parseToolResultContent(content: unknown): unknown;
246
- /**
247
- * Produce a short, single-line description of a tool-error payload suitable
248
- * for the CI log stream. The full parsed value stays on the ToolCall for
249
- * verify and downstream tooling - this is only for inline observability.
250
- *
251
- * Collapses newlines, trims whitespace, and caps to 300 chars so a huge
252
- * API response body doesn't flood the log.
253
- */
254
- export declare function summarizeToolError(parsed: unknown): string;
@@ -12,13 +12,15 @@ import { query, createSdkMcpServer, tool as sdkTool } from "@anthropic-ai/claude
12
12
  import { createRequire } from "node:module";
13
13
  import { consoleLogger } from "../types.js";
14
14
  import { reportWithheldEnv, parseList, resolveEnvScope, scopeAgentEnv, resolveAgentSandbox, } from "../agent-env.js";
15
- import { fenceUntrustedJson } from "../untrusted.js";
16
15
  import { policyGate } from "./policy.js";
17
16
  import { CLAUDE_CODE_CAPABILITIES } from "./capabilities.js";
18
- import { ask as coreAsk, evaluate as coreEvaluate, buildEvaluatePrompt } from "./prompts.js";
17
+ import { ask as coreAsk, evaluate as coreEvaluate, buildEvaluatePrompt, buildNodePrompt } from "./prompts.js";
19
18
  import { jsonSchemaToZodShape, toolErrorToMcpResult, toolOutputToMcpResult } from "./tool-bridge/protocol.js";
20
19
  import { startToolBridge } from "./tool-bridge/server.js";
20
+ import { parseToolResultContent, summarizeToolError, tryParseJSON } from "./parse.js";
21
+ import { makeAbort } from "./abort.js";
21
22
  export { buildEvaluatePrompt, CLAUDE_CODE_CAPABILITIES };
23
+ export { parseToolResultContent, summarizeToolError, makeAbort };
22
24
  let cachedSdkVersion;
23
25
  /** Version of the Claude Agent SDK this adapter drives; "unknown" when it cannot be read. */
24
26
  function claudeSdkVersion() {
@@ -72,6 +74,17 @@ export const READ_ONLY_DISALLOWED_TOOLS = [
72
74
  "Agent",
73
75
  "WebFetch",
74
76
  ];
77
+ /**
78
+ * Claude Code built-in tool names per portable tool class. A node's
79
+ * `tools.deny` classes compile to these names on `disallowedTools`.
80
+ */
81
+ export const CLAUDE_TOOLS_BY_CLASS = {
82
+ shell: ["Bash"],
83
+ write: ["Write", "NotebookEdit"],
84
+ edit: ["Edit", "MultiEdit"],
85
+ net: ["WebFetch", "WebSearch"],
86
+ subagent: ["Task", "Agent"],
87
+ };
75
88
  /**
76
89
  * Decide which auth credentials survive into the Claude Code subprocess env.
77
90
  *
@@ -131,56 +144,6 @@ export function resolveAuthEnv(env, opts = {}) {
131
144
  opts.logger?.debug?.(`[sweny] auth mode: ${mode}`);
132
145
  return out;
133
146
  }
134
- /**
135
- * Wire an optional timeout + caller signal onto a single AbortController.
136
- *
137
- * Returns undefined when neither a timeout nor a signal is supplied so the
138
- * default code path (no abortController, no timer) is byte-for-byte unchanged.
139
- *
140
- * `reason()` reports whether the abort came from the timeout timer or an
141
- * external signal, so callers can log a distinct timeout message.
142
- */
143
- export function makeAbort(timeoutMs, signal) {
144
- if (!timeoutMs && !signal)
145
- return undefined;
146
- const controller = new AbortController();
147
- let reason;
148
- let timer;
149
- const onSignalAbort = () => {
150
- if (controller.signal.aborted)
151
- return;
152
- reason = "signal";
153
- controller.abort();
154
- };
155
- if (signal) {
156
- if (signal.aborted) {
157
- reason = "signal";
158
- controller.abort();
159
- }
160
- else {
161
- signal.addEventListener("abort", onSignalAbort, { once: true });
162
- }
163
- }
164
- if (timeoutMs && timeoutMs > 0 && !controller.signal.aborted) {
165
- timer = setTimeout(() => {
166
- if (controller.signal.aborted)
167
- return;
168
- reason = "timeout";
169
- controller.abort();
170
- }, timeoutMs);
171
- // Don't keep the event loop alive just for the abort timer.
172
- timer?.unref?.();
173
- }
174
- return {
175
- controller,
176
- reason: () => reason,
177
- clear: () => {
178
- if (timer)
179
- clearTimeout(timer);
180
- signal?.removeEventListener("abort", onSignalAbort);
181
- },
182
- };
183
- }
184
147
  /**
185
148
  * Best-effort interrupt of an SDK query stream. Called from `finally` so an
186
149
  * early return / timeout / throw does not leak the underlying subprocess.
@@ -297,13 +260,16 @@ export class ClaudeCodeHarness {
297
260
  async run(req) {
298
261
  const policy = req.policy ?? {
299
262
  readOnly: !!req.readOnly,
300
- deny: [],
263
+ deny: req.deny ?? [],
301
264
  nativeDeny: req.disallowedTools,
302
265
  egress: req.agentAccess?.domains ?? [],
303
266
  strict: false,
304
267
  };
305
268
  const gate = policyGate(this.capabilities, policy);
306
- const result = await this.runQuery(req);
269
+ // Portable deny classes (`tools.deny: [write]`) compile to Claude Code tool names.
270
+ const classNames = policy.deny.flatMap((c) => CLAUDE_TOOLS_BY_CLASS[c] ?? []);
271
+ const disallowedTools = classNames.length > 0 ? [...new Set([...(req.disallowedTools ?? []), ...classNames])] : req.disallowedTools;
272
+ const result = await this.runQuery({ ...req, disallowedTools });
307
273
  return { ...result, harness: this.info(), degraded: gate.degraded };
308
274
  }
309
275
  async runQuery(opts) {
@@ -371,15 +337,7 @@ export class ClaudeCodeHarness {
371
337
  let bridge;
372
338
  // Build prompt. Context (workflow input such as issues/alerts, plus prior
373
339
  // node outputs) is fenced as untrusted data (#360).
374
- const prompt = [
375
- `## Instruction\n\n${instruction}`,
376
- `## Context\n\n${fenceUntrustedJson(context, "context")}`,
377
- outputSchema
378
- ? `## Required Output\n\nYou MUST end with a JSON object matching this schema:\n\`\`\`json\n${JSON.stringify(outputSchema, null, 2)}\n\`\`\``
379
- : "",
380
- ]
381
- .filter(Boolean)
382
- .join("\n\n");
340
+ const prompt = buildNodePrompt(instruction, context, outputSchema);
383
341
  const env = this.buildEnv(agentAccess?.envVars);
384
342
  let response = "";
385
343
  // CC-08: when the SDK populates typed structured output (because we passed
@@ -749,112 +707,6 @@ export function coreToolToSdkTool(coreTool, defaultCtx) {
749
707
  }
750
708
  });
751
709
  }
752
- /**
753
- * Best-effort recovery of typed output from a tool_result's `content`.
754
- *
755
- * The MCP protocol sends tool results as string content. Our in-process
756
- * wrapper JSON-stringifies structured output before returning; external
757
- * MCP servers generally do the same for JSON payloads. If the string
758
- * looks like a JSON object or array and parses, return the parsed value
759
- * so verify's output-path walks work against typed data.
760
- *
761
- * Raw strings are preserved verbatim. JSON-primitive strings (e.g. the
762
- * literal four characters `"42"`) are intentionally NOT parsed - we
763
- * cannot distinguish a tool that returned the number 42 (wrapper sends
764
- * `"42"`) from a tool that returned the string "42" (wrapper also sends
765
- * `"42"`). Preserving the string is safer than guessing.
766
- */
767
- export function parseToolResultContent(content) {
768
- // The Anthropic tool_result `content` field can be a block array
769
- // (e.g. [{type:"text",text:"..."}, ...]) rather than a string. When it is,
770
- // concatenate the text of every {type:"text"} block (recursing through any
771
- // nested content), then run the same object/array JSON-parse logic on the
772
- // joined string. This keeps `call.output` the actual payload instead of the
773
- // raw wrapper array that eval/route logic would otherwise have to walk.
774
- if (Array.isArray(content)) {
775
- const text = collectBlockText(content);
776
- return parseJsonObjectOrArray(text);
777
- }
778
- if (typeof content !== "string")
779
- return content;
780
- return parseJsonObjectOrArray(content);
781
- }
782
- /**
783
- * Parse a string only when it is an unambiguous JSON object or array.
784
- *
785
- * Strings, numbers, booleans, and null would all corrupt or discard
786
- * information (we cannot distinguish the literal text `"42"` from the number
787
- * 42), so non-object/array inputs are preserved verbatim.
788
- */
789
- function parseJsonObjectOrArray(content) {
790
- const trimmed = content.trim();
791
- if (trimmed.length === 0)
792
- return content;
793
- const first = trimmed[0];
794
- if (first !== "{" && first !== "[")
795
- return content;
796
- try {
797
- return JSON.parse(trimmed);
798
- }
799
- catch {
800
- return content;
801
- }
802
- }
803
- /**
804
- * Concatenate the text of an array of content blocks. Handles {type:"text"}
805
- * blocks and recurses into any nested `content` array so deeply-wrapped tool
806
- * results still flatten to their underlying text.
807
- */
808
- function collectBlockText(blocks) {
809
- const parts = [];
810
- for (const block of blocks) {
811
- if (typeof block === "string") {
812
- parts.push(block);
813
- continue;
814
- }
815
- if (!block || typeof block !== "object")
816
- continue;
817
- const b = block;
818
- if (b.type === "text" && typeof b.text === "string") {
819
- parts.push(b.text);
820
- }
821
- else if (Array.isArray(b.content)) {
822
- parts.push(collectBlockText(b.content));
823
- }
824
- else if (typeof b.text === "string") {
825
- // Some producers omit an explicit type but still carry text.
826
- parts.push(b.text);
827
- }
828
- }
829
- return parts.join("");
830
- }
831
- /**
832
- * Produce a short, single-line description of a tool-error payload suitable
833
- * for the CI log stream. The full parsed value stays on the ToolCall for
834
- * verify and downstream tooling - this is only for inline observability.
835
- *
836
- * Collapses newlines, trims whitespace, and caps to 300 chars so a huge
837
- * API response body doesn't flood the log.
838
- */
839
- export function summarizeToolError(parsed) {
840
- let raw;
841
- if (typeof parsed === "string") {
842
- raw = parsed;
843
- }
844
- else if (parsed && typeof parsed === "object") {
845
- try {
846
- raw = JSON.stringify(parsed);
847
- }
848
- catch {
849
- raw = String(parsed);
850
- }
851
- }
852
- else {
853
- raw = String(parsed);
854
- }
855
- const collapsed = raw.replace(/\s+/g, " ").trim();
856
- return collapsed.length > 300 ? collapsed.slice(0, 297) + "..." : collapsed;
857
- }
858
710
  /** Strip MCP server prefix: "mcp__server__tool" → "tool" */
859
711
  function stripMcpPrefix(name) {
860
712
  const parts = name.split("__");
@@ -862,142 +714,3 @@ function stripMcpPrefix(name) {
862
714
  return parts.slice(2).join("__");
863
715
  return name;
864
716
  }
865
- // ─── JSON extraction ────────────────────────────────────────────
866
- /**
867
- * Extract a JSON object from Claude's text response.
868
- *
869
- * Strategy (in order):
870
- * 1. LAST ```json code block``` - models put their real final answer last, so
871
- * a prompt-injected fake fenced block placed earlier must not win.
872
- * 2. Last brace-delimited `{...}` block - handles inline JSON at end of text
873
- * 3. Full text parse - for responses that are pure JSON
874
- * 4. Empty object - safe fallback
875
- *
876
- * When `outputSchema` is supplied, the parsed object is checked against it.
877
- * A mismatch is logged (warning) rather than silently accepted, so a
878
- * non-conforming model answer is at least attributable.
879
- */
880
- function tryParseJSON(text, outputSchema, logger) {
881
- if (!text)
882
- return {};
883
- const finalize = (parsed) => {
884
- if (outputSchema) {
885
- const problems = schemaMismatches(parsed, outputSchema);
886
- if (problems.length > 0) {
887
- logger?.warn?.(`Claude output did not conform to outputSchema: ${problems.join("; ")}`);
888
- }
889
- }
890
- return parsed;
891
- };
892
- // 1. Code block - scan ALL fenced blocks, prefer the LAST that parses.
893
- const fences = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/g)];
894
- for (let i = fences.length - 1; i >= 0; i--) {
895
- try {
896
- const parsed = JSON.parse(fences[i][1].trim());
897
- if (typeof parsed === "object" && parsed !== null)
898
- return finalize(parsed);
899
- }
900
- catch {
901
- /* try the next-earlier fence, then fall through to brace scan */
902
- }
903
- }
904
- // 2. Last brace-delimited block (scan backwards for matching braces)
905
- const lastBrace = text.lastIndexOf("}");
906
- if (lastBrace !== -1) {
907
- let depth = 0;
908
- for (let i = lastBrace; i >= 0; i--) {
909
- if (text[i] === "}")
910
- depth++;
911
- else if (text[i] === "{")
912
- depth--;
913
- if (depth === 0) {
914
- try {
915
- const parsed = JSON.parse(text.slice(i, lastBrace + 1));
916
- if (typeof parsed === "object" && parsed !== null)
917
- return finalize(parsed);
918
- }
919
- catch {
920
- /* try next strategy */
921
- }
922
- break;
923
- }
924
- }
925
- }
926
- // 3. Full text parse
927
- try {
928
- const parsed = JSON.parse(text.trim());
929
- if (typeof parsed === "object" && parsed !== null)
930
- return finalize(parsed);
931
- }
932
- catch {
933
- /* fall through */
934
- }
935
- return {};
936
- }
937
- /**
938
- * Lightweight structural check of a parsed object against a JSON Schema.
939
- *
940
- * Deliberately shallow: it verifies `required` keys are present and that the
941
- * top-level `type` and each declared property `type` match. It does NOT do
942
- * full JSON Schema validation (no `$ref`, `oneOf`, formats, nested arrays).
943
- * The contract here is "flag an obviously non-conforming object" so a mismatch
944
- * is logged rather than silently accepted; it is not a validation gate.
945
- *
946
- * Returns a list of human-readable problems; empty means no detected mismatch.
947
- */
948
- function schemaMismatches(value, schema) {
949
- const problems = [];
950
- const s = schema;
951
- if (s.type === "object" || s.properties || s.required) {
952
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
953
- return [`expected an object, got ${Array.isArray(value) ? "array" : typeof value}`];
954
- }
955
- const obj = value;
956
- const required = Array.isArray(s.required) ? s.required : [];
957
- for (const key of required) {
958
- if (!(key in obj) || obj[key] === undefined) {
959
- problems.push(`missing required property "${key}"`);
960
- }
961
- }
962
- const props = s.properties ?? {};
963
- for (const [key, propSchema] of Object.entries(props)) {
964
- if (!(key in obj) || obj[key] === undefined || obj[key] === null)
965
- continue;
966
- const expected = propSchema?.type;
967
- if (typeof expected === "string" && !jsonTypeMatches(obj[key], expected)) {
968
- problems.push(`property "${key}" expected ${expected}, got ${jsonTypeOf(obj[key])}`);
969
- }
970
- }
971
- }
972
- else if (typeof s.type === "string" && !jsonTypeMatches(value, s.type)) {
973
- problems.push(`expected ${s.type}, got ${jsonTypeOf(value)}`);
974
- }
975
- return problems;
976
- }
977
- function jsonTypeOf(value) {
978
- if (value === null)
979
- return "null";
980
- if (Array.isArray(value))
981
- return "array";
982
- return typeof value;
983
- }
984
- function jsonTypeMatches(value, expected) {
985
- switch (expected) {
986
- case "object":
987
- return typeof value === "object" && value !== null && !Array.isArray(value);
988
- case "array":
989
- return Array.isArray(value);
990
- case "string":
991
- return typeof value === "string";
992
- case "number":
993
- return typeof value === "number";
994
- case "integer":
995
- return typeof value === "number" && Number.isInteger(value);
996
- case "boolean":
997
- return typeof value === "boolean";
998
- case "null":
999
- return value === null;
1000
- default:
1001
- return true; // unknown/unsupported type keyword - don't flag
1002
- }
1003
- }