@sweny-ai/core 0.1.133 → 0.1.135
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.d.ts +3 -230
- package/dist/claude.js +3 -1049
- package/dist/cli/e2e.js +2 -2
- package/dist/cli/main.js +9 -9
- package/dist/cli/new.js +3 -3
- package/dist/cli/run-history.d.ts +5 -0
- package/dist/cli/run-history.js +2 -0
- package/dist/eval/index.js +7 -4
- package/dist/executor.d.ts +11 -2
- package/dist/executor.js +7 -1
- package/dist/harness/capabilities.d.ts +7 -0
- package/dist/harness/capabilities.js +17 -0
- package/dist/harness/claude-code.d.ts +228 -0
- package/dist/harness/claude-code.js +1020 -0
- package/dist/harness/compat.d.ts +18 -0
- package/dist/harness/compat.js +62 -0
- package/dist/harness/index.d.ts +17 -0
- package/dist/harness/index.js +24 -0
- package/dist/harness/policy.d.ts +8 -0
- package/dist/harness/policy.js +33 -0
- package/dist/harness/prompts.d.ts +59 -0
- package/dist/harness/prompts.js +107 -0
- package/dist/harness/types.d.ts +111 -0
- package/dist/harness/types.js +11 -0
- package/dist/index.d.ts +7 -5
- package/dist/index.js +7 -5
- package/dist/testing.d.ts +23 -5
- package/dist/testing.js +29 -1
- package/dist/types.d.ts +14 -0
- package/package.json +1 -1
package/dist/claude.js
CHANGED
|
@@ -1,1051 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
|
|
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";
|