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