cowork-harness 0.6.0 → 0.7.1
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/.claude/skills/cowork-harness/scripts/assertion-keys.json +2 -0
- package/.claude/skills/cowork-harness/scripts/scenario.py +2 -0
- package/CHANGELOG.md +65 -0
- package/README.md +12 -0
- package/dist/agent/session.js +127 -18
- package/dist/assert.js +202 -88
- package/dist/cli.js +144 -47
- package/dist/decide/decider.js +95 -25
- package/dist/egress/proxy.js +10 -15
- package/dist/egress/sidecar.js +5 -1
- package/dist/hostloop/workspace-handler.js +40 -7
- package/dist/run/cassette.js +452 -48
- package/dist/run/chat.js +24 -6
- package/dist/run/execute.js +41 -6
- package/dist/run/run.js +20 -5
- package/dist/run/runs-gc.js +99 -0
- package/dist/run/skill-hash.js +104 -11
- package/dist/run/verdict.js +7 -0
- package/dist/runtime/hostloop.js +7 -2
- package/dist/runtime/microvm.js +14 -1
- package/dist/runtime/protocol.js +2 -1
- package/dist/runtime/stage.js +6 -3
- package/dist/scan.js +1 -1
- package/dist/secrets.js +40 -0
- package/dist/session.js +21 -2
- package/dist/types.js +23 -0
- package/docs/cassette.md +73 -0
- package/docs/chat.md +26 -4
- package/package.json +1 -1
- package/schema/cassette.v2.json +19 -0
- package/schema/cassette.v3.json +30 -0
- package/schema/run-result.json +57 -0
- package/schema/scenario.schema.json +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,71 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.7.1] — 2026-06-20
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **`file_exists` and `user_visible_artifact` now pass for truncated (large) cassette artifacts.**
|
|
14
|
+
A truncated manifest entry (`truncated: true`) carries `path`, `bytes`, and `sha256` — positive
|
|
15
|
+
proof the file existed at record time. 0.7.0 incorrectly failed these existence/promotion
|
|
16
|
+
assertions with `"was truncated in the cassette — content was not committed; assertion cannot
|
|
17
|
+
pass"`, producing false-REDs for any cassette whose artifacts exceeded the 64 KiB inline cap.
|
|
18
|
+
Only **content** assertions (`artifact_json`) require the inlined body; existence assertions
|
|
19
|
+
now correctly pass from the manifest. `artifact_json` on a truncated artifact continues to fail
|
|
20
|
+
(the 0-byte placeholder is not valid JSON). Regression test added.
|
|
21
|
+
|
|
22
|
+
## [0.7.0] — 2026-06-19
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **`chat --plugin <dir>` (repeatable)** — load additional local plugins into a `chat` session alongside
|
|
27
|
+
the primary skill folder. Each `--plugin <dir>` is appended to `plugins.local_plugins` so multi-plugin
|
|
28
|
+
interactive debugging no longer requires a custom session YAML. In `--raw` mode (native Docker), `--plugin`
|
|
29
|
+
flags are silently ignored with a warning: `chat --raw: --plugin flags are ignored in --raw mode`.
|
|
30
|
+
- **`/help` in the `chat` REPL** — typing `/help` in an interactive session now prints
|
|
31
|
+
`Commands: /exit /quit /help` and continues rather than forwarding the text to the model. The startup
|
|
32
|
+
prompt was updated from "type your message, /exit to quit" to "type your message (/help for commands)".
|
|
33
|
+
- **`scrubField(value, secrets)` exported from `src/secrets.ts`** — a new multi-pass field-level scrubber
|
|
34
|
+
that covers token appearances beyond direct substring matches:
|
|
35
|
+
- Pass 1: direct `scrub()` — catches literal, base64-encoded, and `encodeURIComponent`-encoded tokens.
|
|
36
|
+
- Pass 2: whole-field base64 decode (≥20-char pure base64 strings) — if the decoded form contains a
|
|
37
|
+
secret hit, returns `[REDACTED:base64]`.
|
|
38
|
+
- Pass 3: whole-field URI decode (values containing `%`) — if the decoded form contains a secret hit,
|
|
39
|
+
returns `[REDACTED:uri]`.
|
|
40
|
+
Applied in `cassette.ts` artifact scrubbing: base64 artifacts (`encoding === "base64"`) are replaced
|
|
41
|
+
wholesale with `[REDACTED:base64]`, the encoding marker is cleared (so replay decodes as UTF-8), and the
|
|
42
|
+
sha256 is recomputed over the marker bytes (with a `::warning::` that artifact assertions will fail at
|
|
43
|
+
replay). UTF-8 artifacts pass through `scrubField` safely.
|
|
44
|
+
- **`prompt_asset_missing` VerdictSignal** — `computeVerdict()` now pushes a `{ code: "prompt_asset_missing",
|
|
45
|
+
severity: "warn" }` signal when `result.fidelityWarnings` contains a "referenced asset not found" entry,
|
|
46
|
+
making a missing prompt asset visible in the verdict output rather than buried in the run log.
|
|
47
|
+
- **`onInfraError` callback in `makeWorkspaceHandler`** — an optional sixth parameter
|
|
48
|
+
`onInfraError?: (message: string) => void` lets callers intercept infrastructure errors
|
|
49
|
+
(ETIMEDOUT / killed / no code+stdout+stderr) separately from model-visible error text.
|
|
50
|
+
`spawnHostLoop()` wires this to append `{ type: "infra_error", ts, message }` to `events.jsonl` so
|
|
51
|
+
infrastructure failures are structured and queryable rather than only appearing in the model-visible
|
|
52
|
+
response string.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- **`ExternalDecider` no longer coerces `"first"` to option 1.** `coerceLabel` gained an
|
|
57
|
+
`enableFirstShorthand` parameter (default `true`). External deciders (`--decider-cmd`,
|
|
58
|
+
`--decider-dir` helpers) now call `coerceLabel(raw, labels, false)`, so a helper script that
|
|
59
|
+
accidentally returns the string `"first"` must match an actual label named `"first"` — it is no
|
|
60
|
+
longer silently promoted to the first option. The shorthand remains active for internal (scripted)
|
|
61
|
+
use.
|
|
62
|
+
- **`flagValue` and `chat --model` reject empty strings.** `flagValue()` in `src/cli.ts` now exits `2`
|
|
63
|
+
with a clear message when the supplied value is blank or whitespace-only. `chat` additionally guards
|
|
64
|
+
the `--model` value inline after parsing, so `--model ""` and `--model $UNSET_VAR` both fail loudly
|
|
65
|
+
instead of passing an empty model ID to the runtime.
|
|
66
|
+
- **`redactCassette` skips `[REDACTED*]` marker bodies.** The per-line JSON redaction pass
|
|
67
|
+
(`redactJsonLine`) is now bypassed for artifact bodies that already start with `[REDACTED` — preventing
|
|
68
|
+
the sha256 from being corrupted by a second redaction pass over the marker string.
|
|
69
|
+
- **TLD list extended from 22 to 51 entries.** The domain scanner in `src/scan.ts` now recognises
|
|
70
|
+
major European, Asian, and Latin American ccTLDs:
|
|
71
|
+
`ch|nl|se|no|it|jp|br|nz|in|sg|kr|mx|es|pt|pl|be|at|dk|fi|ie|ru|cn|tw|hu|cz|ro|il|za|ar|cl|pe|tr`.
|
|
72
|
+
Domain findings that were previously missed on these TLDs now fire correctly.
|
|
73
|
+
|
|
9
74
|
## [0.6.0] — 2026-06-19
|
|
10
75
|
|
|
11
76
|
### Breaking changes
|
package/README.md
CHANGED
|
@@ -24,6 +24,15 @@ Scriptable, CI-friendly test harness that reproduces **Claude Cowork's observabl
|
|
|
24
24
|
|
|
25
25
|
> **What this is and isn't.** This is an *emulator of the contract*, not the Desktop runtime. Cowork's real session control plane lives behind the Desktop renderer's IPC (per-build UUID + `senderFrame` origin checks) and the app ships with remote debugging disabled (verified: `--remote-debugging-port` opens no listener; Electron `EnableNodeCliInspectArguments` fuse is OFF). So you **cannot** drive the real Apple Virtualization.framework microVM from a script. What you *can* faithfully reproduce is everything that actually changes how a **skill** behaves: the same agent binary in **cowork mode** (`CLAUDE_CODE_IS_COWORK=1` — there is no `--cowork` flag), the same mount layout, the same egress allowlist, and the same permission/question protocol. That's what this project does.
|
|
26
26
|
|
|
27
|
+
**Zero-friction preview — no token, no Docker.** A committed cassette replays on a fresh clone:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install -g cowork-harness
|
|
31
|
+
cowork-harness replay examples/replays/example-pdf-skill.cassette.json
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Full setup → [Quick start](#quick-start).
|
|
35
|
+
|
|
27
36
|
---
|
|
28
37
|
|
|
29
38
|
## Why this works for skill testing
|
|
@@ -226,6 +235,9 @@ cowork-harness replay --cassette examples/replays/example-pdf-skill.cassette.jso
|
|
|
226
235
|
|
|
227
236
|
# Cassettes are COMMITTED fixtures — record against synthetic data, and gate them in CI:
|
|
228
237
|
cowork-harness verify-cassettes examples/replays/ # privacy scan (email/currency/domain) + staleness; exit 1 on a finding
|
|
238
|
+
|
|
239
|
+
# 5. Lint scenarios before committing (catches silent false-greens in assertion placement)
|
|
240
|
+
cowork-harness lint scenarios/*.yaml
|
|
229
241
|
```
|
|
230
242
|
|
|
231
243
|
> **Privacy:** a cassette snapshots the transcript and the `outputs/` JSON bodies, so it's committed PII
|
package/dist/agent/session.js
CHANGED
|
@@ -162,6 +162,10 @@ export class LiveAgentSession {
|
|
|
162
162
|
rejectError;
|
|
163
163
|
/** Bounded tail of the child's stderr, for the nonzero-exit error message. */
|
|
164
164
|
stderrTail = "";
|
|
165
|
+
writeQueue = [];
|
|
166
|
+
pumping = false;
|
|
167
|
+
queueIdle;
|
|
168
|
+
closing = false;
|
|
165
169
|
constructor(proc, outDir) {
|
|
166
170
|
this.proc = proc;
|
|
167
171
|
this.outDir = outDir;
|
|
@@ -180,7 +184,7 @@ export class LiveAgentSession {
|
|
|
180
184
|
if (this.rejectError)
|
|
181
185
|
this.rejectError(e);
|
|
182
186
|
// else: the error fired before/after the generator — log it but don't throw
|
|
183
|
-
else
|
|
187
|
+
else if (!this.closing)
|
|
184
188
|
this.events.write(JSON.stringify({ _emu: "stdin_error", message: String(e) }) + "\n");
|
|
185
189
|
});
|
|
186
190
|
}
|
|
@@ -214,7 +218,8 @@ export class LiveAgentSession {
|
|
|
214
218
|
// below rejects and the generator yields a typed {type:"error"} event instead of hanging on
|
|
215
219
|
// stdout that will never arrive.
|
|
216
220
|
this.proc.on("error", (e) => {
|
|
217
|
-
this.
|
|
221
|
+
if (!this.closing)
|
|
222
|
+
this.events.write(JSON.stringify({ _emu: "spawn_error", message: String(e) }) + "\n");
|
|
218
223
|
if (this.rejectError)
|
|
219
224
|
this.rejectError(e instanceof Error ? e : new Error(String(e)));
|
|
220
225
|
});
|
|
@@ -238,7 +243,8 @@ export class LiveAgentSession {
|
|
|
238
243
|
const line = next.value;
|
|
239
244
|
if (!line.trim())
|
|
240
245
|
continue;
|
|
241
|
-
this.
|
|
246
|
+
if (!this.closing)
|
|
247
|
+
this.events.write(line + "\n");
|
|
242
248
|
let msg;
|
|
243
249
|
try {
|
|
244
250
|
msg = JSON.parse(line);
|
|
@@ -266,6 +272,8 @@ export class LiveAgentSession {
|
|
|
266
272
|
}
|
|
267
273
|
finally {
|
|
268
274
|
this.rejectError = undefined; // generator is done; stop routing errors here
|
|
275
|
+
this.closing = true; // discard any late write() from translate()'s async hook/mcp paths
|
|
276
|
+
await this.drainAll(); // queue empty + all callbacks confirmed before ending either stream
|
|
269
277
|
// #46: AWAIT the stream flush before the generator resolves. executeScenario reads/scans/scrubs
|
|
270
278
|
// events.jsonl + control-out.jsonl immediately after `drive()` returns; a fire-and-forget end()
|
|
271
279
|
// races the final buffered writes. end(cb) fires the callback on 'finish' (fully flushed).
|
|
@@ -344,6 +352,9 @@ export class LiveAgentSession {
|
|
|
344
352
|
if (req.kind !== r.kind)
|
|
345
353
|
warn(`::warning:: decider returned kind "${r.kind}" for a "${req.kind}" request (id ${decisionId}) → sending a safe deny/cancel; the agent did NOT receive an answer\n`);
|
|
346
354
|
this.write(serializeDecision(req, r));
|
|
355
|
+
// Invariant: each decision id is answered at most once. Delete after a successful write so
|
|
356
|
+
// stale entries don't accumulate (live sessions may process thousands of decisions per run).
|
|
357
|
+
this.reqById.delete(decisionId);
|
|
347
358
|
}
|
|
348
359
|
close() {
|
|
349
360
|
try {
|
|
@@ -355,26 +366,99 @@ export class LiveAgentSession {
|
|
|
355
366
|
}
|
|
356
367
|
write(obj) {
|
|
357
368
|
const line = JSON.stringify(obj);
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
// hard-FAIL rather than risk a partially-buffered write that silently corrupts the protocol stream.
|
|
361
|
-
// (If large control frames ever become legitimate, switch to a drain-aware queue, making writes async.)
|
|
362
|
-
if (line.length > 256 * 1024)
|
|
363
|
-
throw new Error(`control frame is ${line.length} bytes (> 256 KiB safe limit) — refusing to write to avoid partial stdin buffering; this indicates an unexpectedly large control payload`);
|
|
369
|
+
if (this.closing)
|
|
370
|
+
return; // session draining — discard silently
|
|
364
371
|
// Bug 34: check stream writability before writing — a closed/destroyed stdin after a child crash
|
|
365
372
|
// loses decision frames silently. Throw immediately so callers surface the failure rather than
|
|
366
373
|
// hanging or silently dropping frames.
|
|
367
374
|
if (this.proc.stdin.destroyed || !this.proc.stdin.writable)
|
|
368
375
|
throw new Error("control-protocol write failed: child stdin is no longer writable (process may have crashed)");
|
|
369
|
-
this.
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
if (
|
|
376
|
+
this.writeQueue.push(line + "\n");
|
|
377
|
+
void this.pump().catch((err) => {
|
|
378
|
+
// Route unexpected pump errors through rejectError so they surface as a typed error + clean
|
|
379
|
+
// teardown instead of a silent hang. Known failure modes call rejectError themselves before
|
|
380
|
+
// the pump returns, so this only fires on unanticipated throws.
|
|
381
|
+
if (this.rejectError)
|
|
375
382
|
this.rejectError(err instanceof Error ? err : new Error(String(err)));
|
|
376
383
|
});
|
|
377
384
|
}
|
|
385
|
+
async pump() {
|
|
386
|
+
if (this.pumping)
|
|
387
|
+
return;
|
|
388
|
+
this.pumping = true;
|
|
389
|
+
try {
|
|
390
|
+
while (this.writeQueue.length) {
|
|
391
|
+
const frame = this.writeQueue.shift();
|
|
392
|
+
if (this.proc.stdin.destroyed || !this.proc.stdin.writable) {
|
|
393
|
+
this.events.write(JSON.stringify({ _emu: "control_undelivered", frame }) + "\n");
|
|
394
|
+
if (this.rejectError)
|
|
395
|
+
this.rejectError(new Error("stdin no longer writable while draining queue"));
|
|
396
|
+
while (this.writeQueue.length) {
|
|
397
|
+
const remaining = this.writeQueue.shift();
|
|
398
|
+
this.events.write(JSON.stringify({ _emu: "control_undelivered", frame: remaining }) + "\n");
|
|
399
|
+
}
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
// Await the write callback directly — the ONLY reliable signal for child-process stdin
|
|
403
|
+
// pipes (EPIPE goes to the callback, not an 'error' event; 'drain'/'close' are unreliable).
|
|
404
|
+
// Awaiting inline also bounds in-flight depth to 1, giving deterministic replay ordering.
|
|
405
|
+
const writeErr = await new Promise((resolve) => {
|
|
406
|
+
try {
|
|
407
|
+
this.proc.stdin.write(frame, (err) => {
|
|
408
|
+
resolve(err ?? null);
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
catch (e) {
|
|
412
|
+
resolve(e instanceof Error ? e : new Error(String(e)));
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
if (writeErr) {
|
|
416
|
+
this.events.write(JSON.stringify({ _emu: "control_undelivered", frame }) + "\n");
|
|
417
|
+
if (this.rejectError)
|
|
418
|
+
this.rejectError(writeErr);
|
|
419
|
+
while (this.writeQueue.length) {
|
|
420
|
+
const remaining = this.writeQueue.shift();
|
|
421
|
+
this.events.write(JSON.stringify({ _emu: "control_undelivered", frame: remaining }) + "\n");
|
|
422
|
+
}
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
else {
|
|
426
|
+
if (frame.length > 256 * 1024) {
|
|
427
|
+
// Frame too large to mirror verbatim — record a truncation marker instead.
|
|
428
|
+
this.controlOut.write(JSON.stringify({ _emu: "control_out_truncated", bytes: frame.length, head: frame.slice(0, 4096) }) + "\n");
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
this.controlOut.write(frame);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
finally {
|
|
437
|
+
this.pumping = false;
|
|
438
|
+
// Unconditionally drain any remaining frames — handles a synchronous throw from
|
|
439
|
+
// controlOut.write/events.write inside the try body, which would otherwise leave
|
|
440
|
+
// writeQueue non-empty and queueIdle unfired, hanging drainAll().
|
|
441
|
+
while (this.writeQueue.length) {
|
|
442
|
+
const remaining = this.writeQueue.shift();
|
|
443
|
+
try {
|
|
444
|
+
this.events.write(JSON.stringify({ _emu: "control_undelivered", frame: remaining }) + "\n");
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
/* events may already be ended; discard silently */
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const idle = this.queueIdle;
|
|
451
|
+
this.queueIdle = undefined;
|
|
452
|
+
idle?.();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
drainAll() {
|
|
456
|
+
if (!this.pumping && this.writeQueue.length === 0)
|
|
457
|
+
return Promise.resolve();
|
|
458
|
+
return new Promise((resolve) => {
|
|
459
|
+
this.queueIdle = resolve;
|
|
460
|
+
});
|
|
461
|
+
}
|
|
378
462
|
}
|
|
379
463
|
/** Pure translation of one parsed stream-json message → AgentEvents (no side-effects). Shared by
|
|
380
464
|
* LiveAgentSession and CassetteAgentSession. mcp_message is handled by Live before this is called. */
|
|
@@ -396,8 +480,13 @@ export function parseMessage(msg) {
|
|
|
396
480
|
break;
|
|
397
481
|
}
|
|
398
482
|
case "assistant": {
|
|
399
|
-
|
|
483
|
+
// Protocol v1: parentToolUseId is message-level. Block-level parent_tool_use_id (if present)
|
|
484
|
+
// is canonical — prefer it when both exist (block-level is more precise for nested dispatches).
|
|
485
|
+
const msgParentToolUseId = msg.parent_tool_use_id ? String(msg.parent_tool_use_id) : undefined;
|
|
486
|
+
let blockIndex = 0;
|
|
400
487
|
for (const block of msg.message?.content ?? []) {
|
|
488
|
+
// Block-level parent wins over message-level when both are present (see comment above).
|
|
489
|
+
const parentToolUseId = block.parent_tool_use_id ? String(block.parent_tool_use_id) : msgParentToolUseId;
|
|
401
490
|
if (block.type === "text")
|
|
402
491
|
ev.push({ type: "assistant_text", text: block.text, parentToolUseId });
|
|
403
492
|
else if (block.type === "thinking")
|
|
@@ -422,9 +511,12 @@ export function parseMessage(msg) {
|
|
|
422
511
|
: Array.isArray(inp.allowedTools)
|
|
423
512
|
? inp.allowedTools.map(String)
|
|
424
513
|
: []; // the `Agent` tool declares no tools list → []; declared-but-unused is legacy-`Task`-only
|
|
514
|
+
// When block.id is absent, synthesize a fallback to avoid collapsing all anonymous
|
|
515
|
+
// dispatches into the empty-string identity, which breaks dispatch tracking.
|
|
516
|
+
const toolUseId = block.id ? String(block.id) : `unpaired-${blockIndex}`;
|
|
425
517
|
ev.push({
|
|
426
518
|
type: "subagent_dispatch",
|
|
427
|
-
toolUseId
|
|
519
|
+
toolUseId,
|
|
428
520
|
parentToolUseId,
|
|
429
521
|
// Skills often dispatch with only {description, prompt} (no subagent_type) → agentType is
|
|
430
522
|
// "unknown" but the description still identifies the dispatch (e.g. "TOP_DOWN market sizing").
|
|
@@ -434,6 +526,7 @@ export function parseMessage(msg) {
|
|
|
434
526
|
});
|
|
435
527
|
}
|
|
436
528
|
}
|
|
529
|
+
blockIndex++;
|
|
437
530
|
}
|
|
438
531
|
break;
|
|
439
532
|
}
|
|
@@ -449,6 +542,7 @@ export function parseMessage(msg) {
|
|
|
449
542
|
isError: !!block.is_error,
|
|
450
543
|
text: toolResultText(block.content),
|
|
451
544
|
provenanceText: toolResultRaw(block.content),
|
|
545
|
+
assertText: toolResultAssertText(block.content),
|
|
452
546
|
});
|
|
453
547
|
}
|
|
454
548
|
break;
|
|
@@ -472,7 +566,11 @@ function flattenToolResult(content, max) {
|
|
|
472
566
|
if (b && typeof b === "object" && "text" in b)
|
|
473
567
|
return String(b.text);
|
|
474
568
|
// Non-text blocks (json, resource, link, unknown): JSON-stringify to preserve all content.
|
|
475
|
-
|
|
569
|
+
// JSON.stringify returns `undefined` for blocks that contain functions or other
|
|
570
|
+
// non-serializable values; wrap those in an explicit marker so downstream code can identify
|
|
571
|
+
// and skip them rather than treating the literal string "undefined" as block content.
|
|
572
|
+
const serialized = JSON.stringify(b);
|
|
573
|
+
return serialized !== undefined ? serialized : JSON.stringify({ type: "unsupported", raw: "[unserializable]" });
|
|
476
574
|
})
|
|
477
575
|
.join(" ")
|
|
478
576
|
.slice(0, max);
|
|
@@ -486,8 +584,19 @@ function toolResultText(content) {
|
|
|
486
584
|
function toolResultRaw(content) {
|
|
487
585
|
return flattenToolResult(content, 200_000);
|
|
488
586
|
}
|
|
587
|
+
/** Assertion-fidelity cap — enough to cover realistic tool outputs for content assertions without
|
|
588
|
+
* blowing up result.json; deliberately larger than the 500-char display cap so text past the display
|
|
589
|
+
* truncation is still assertable. */
|
|
590
|
+
function toolResultAssertText(content) {
|
|
591
|
+
return flattenToolResult(content, 10_240);
|
|
592
|
+
}
|
|
489
593
|
export function toDecisionRequest(msg) {
|
|
490
594
|
const sub = msg.request?.subtype;
|
|
595
|
+
// Validate request_id early — every DecisionRequest.id is a non-empty string. A missing or
|
|
596
|
+
// non-string request_id means the agent sent a malformed control_request; reject loudly rather
|
|
597
|
+
// than silently building a DecisionRequest with an unusable id (respond() would always miss it).
|
|
598
|
+
if (typeof msg.request_id !== "string" || msg.request_id === "")
|
|
599
|
+
throw new Error(`control-in: malformed request_id: ${JSON.stringify(msg.request_id)}`);
|
|
491
600
|
const id = msg.request_id;
|
|
492
601
|
if (sub === "can_use_tool") {
|
|
493
602
|
const tool = msg.request.tool_name ?? "";
|