@cat-factory/executor-harness 1.110.0 → 1.110.2

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/agent.js CHANGED
@@ -18,6 +18,7 @@ import { agentCapabilities, mergeEffort } from './agent-shared.js';
18
18
  import { runBootstrap } from './bootstrap-mode.js';
19
19
  import { acquireRepoCheckout, agentNeverActed, agentOutputTail, NEVER_ACTED_CAUSE, runAgentInWorkspace, unusableFinalAnswerCause, withWorkspace, } from './pi-workspace.js';
20
20
  import { diagnosticsSuffix, resolveStructuredOutput, } from './structured-output.js';
21
+ import { extractJsonObject } from './json-reply.js';
21
22
  import { log } from './logger.js';
22
23
  // The single generic agent handler — the manifest-driven replacement for the bespoke
23
24
  // per-kind handlers. It runs an LLM over an optional checkout and returns text/JSON
@@ -194,23 +195,6 @@ async function resolveReplyCustom(job, summary, signal) {
194
195
  });
195
196
  return { value: resolved.value, diagnostics: resolved.diagnostics };
196
197
  }
197
- /** Extract the first JSON object from an agent's final message (tolerating fences/prose). */
198
- function extractJsonObject(text) {
199
- const trimmed = text.trim();
200
- const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(trimmed);
201
- const body = fenced ? (fenced[1] ?? '') : trimmed;
202
- try {
203
- return JSON.parse(body);
204
- }
205
- catch {
206
- const start = body.indexOf('{');
207
- const end = body.lastIndexOf('}');
208
- if (start === -1 || end === -1 || end <= start) {
209
- throw new Error('agent did not return a JSON object');
210
- }
211
- return JSON.parse(body.slice(start, end + 1));
212
- }
213
- }
214
198
  /**
215
199
  * The service work directory for a checkout at `dir`: the monorepo service subtree
216
200
  * (`repo.serviceDirectory`, created if missing) when the job is service-scoped, else the clone
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Extract the JSON object from an agent's final message, tolerating a fence and surrounding prose.
3
+ * Throws when the reply holds no readable JSON.
4
+ *
5
+ * A reply that is valid JSON except for RAW control characters inside a string literal is REPAIRED
6
+ * rather than refused, and — as in kernel — only in a SECOND pass, after the reply has been tried
7
+ * as written. A model asked to lay a field out over several lines (a review verdict written as
8
+ * blocks) writes the layout and drops the `\n` escape, which is worth recovering; recovering it
9
+ * before the reply has been read as written is not, because a repair makes text parse that was
10
+ * meant to be skipped.
11
+ */
12
+ export declare function extractJsonObject(text: string): unknown;
@@ -0,0 +1,114 @@
1
+ // Reading a JSON object out of an agent's final message.
2
+ //
3
+ // This is the harness half of a pair: the engine reads the SAME reply again with kernel's
4
+ // `extractJson` (see `CompanionController.parseContainerVerdict`). The harness reads it FIRST, and
5
+ // what it fails to read costs a real, billed repair completion (`resolveStructuredOutput`), so the
6
+ // two must agree about which replies are READABLE AT ALL — a shape only kernel accepts is a model
7
+ // call the run pays for and nobody needed. The container image is built from `src/` plus typescript
8
+ // alone, so that agreement cannot be had by importing kernel: the control-character repair below is
9
+ // a deliberate COPY, pinned by `test/json-reply.conformity.test.ts` exactly like `host-markdown.ts`.
10
+ //
11
+ // WHICH object each half picks can still differ (kernel scans forward from every bracket; this half
12
+ // takes the outermost `{…}` span, which is what its caller's one-object contract wants), so the
13
+ // conformity suite pins readability, not identity.
14
+ /**
15
+ * Extract the JSON object from an agent's final message, tolerating a fence and surrounding prose.
16
+ * Throws when the reply holds no readable JSON.
17
+ *
18
+ * A reply that is valid JSON except for RAW control characters inside a string literal is REPAIRED
19
+ * rather than refused, and — as in kernel — only in a SECOND pass, after the reply has been tried
20
+ * as written. A model asked to lay a field out over several lines (a review verdict written as
21
+ * blocks) writes the layout and drops the `\n` escape, which is worth recovering; recovering it
22
+ * before the reply has been read as written is not, because a repair makes text parse that was
23
+ * meant to be skipped.
24
+ */
25
+ export function extractJsonObject(text) {
26
+ const trimmed = text.trim();
27
+ const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(trimmed);
28
+ const body = fenced ? (fenced[1] ?? '') : trimmed;
29
+ const asWritten = parseWholeOrSpan(body);
30
+ if (asWritten !== undefined)
31
+ return asWritten;
32
+ // No raw control character ⇒ the repair pass would hand `JSON.parse` the same bytes again.
33
+ if (hasRawControlChar(body)) {
34
+ const repaired = parseWholeOrSpan(escapeControlCharsInStrings(body));
35
+ if (repaired !== undefined)
36
+ return repaired;
37
+ }
38
+ throw new Error('agent did not return a JSON object');
39
+ }
40
+ /**
41
+ * Parse `source`, else its outermost `{…}` span (the object inside the model's prose). Undefined
42
+ * when neither parses — a value `JSON.parse` itself can never return, so `null` stays a result.
43
+ */
44
+ function parseWholeOrSpan(source) {
45
+ const whole = parseOrUndefined(source);
46
+ if (whole !== undefined)
47
+ return whole;
48
+ const start = source.indexOf('{');
49
+ const end = source.lastIndexOf('}');
50
+ if (start === -1 || end === -1 || end <= start)
51
+ return undefined;
52
+ return parseOrUndefined(source.slice(start, end + 1));
53
+ }
54
+ function parseOrUndefined(json) {
55
+ try {
56
+ return JSON.parse(json);
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ }
62
+ /** Whether `text` holds any raw control character: the cheap gate on attempting a repair at all. */
63
+ function hasRawControlChar(text) {
64
+ for (let i = 0; i < text.length; i++) {
65
+ if (text.charCodeAt(i) < 0x20)
66
+ return true;
67
+ }
68
+ return false;
69
+ }
70
+ /** The control characters JSON gives a short escape; the rest go to `\uXXXX`. */
71
+ const CONTROL_ESCAPES = {
72
+ '\n': '\\n',
73
+ '\r': '\\r',
74
+ '\t': '\\t',
75
+ '\b': '\\b',
76
+ '\f': '\\f',
77
+ };
78
+ /**
79
+ * Re-escape raw control characters that sit INSIDE a JSON string literal. Only characters inside a
80
+ * string are rewritten, so the structural whitespace between tokens keeps its meaning and a
81
+ * genuinely broken reply still fails to parse. Copied from kernel's `llm-output.ts`.
82
+ */
83
+ function escapeControlCharsInStrings(json) {
84
+ let out = '';
85
+ let copiedTo = 0;
86
+ let inString = false;
87
+ let escaped = false;
88
+ for (let i = 0; i < json.length; i++) {
89
+ const ch = json[i];
90
+ if (!inString) {
91
+ if (ch === '"')
92
+ inString = true;
93
+ continue;
94
+ }
95
+ if (escaped) {
96
+ escaped = false;
97
+ continue;
98
+ }
99
+ if (ch === '\\') {
100
+ escaped = true;
101
+ continue;
102
+ }
103
+ if (ch === '"') {
104
+ inString = false;
105
+ continue;
106
+ }
107
+ if (json.charCodeAt(i) >= 0x20)
108
+ continue;
109
+ const escape = CONTROL_ESCAPES[ch] ?? `\\u${json.charCodeAt(i).toString(16).padStart(4, '0')}`;
110
+ out += json.slice(copiedTo, i) + escape;
111
+ copiedTo = i + 1;
112
+ }
113
+ return out + json.slice(copiedTo);
114
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.110.0",
3
+ "version": "1.110.2",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,9 +30,9 @@
30
30
  "hono": "^4.13.1",
31
31
  "typescript": "7.0.2",
32
32
  "vitest": "^4.1.10",
33
- "@cat-factory/kernel": "0.285.0",
34
- "@cat-factory/server": "0.268.0",
35
- "@cat-factory/spend": "0.15.64"
33
+ "@cat-factory/kernel": "0.292.1",
34
+ "@cat-factory/server": "0.278.1",
35
+ "@cat-factory/spend": "0.15.80"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsc -p tsconfig.json",
package/src/agent.ts CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  diagnosticsSuffix,
51
51
  resolveStructuredOutput,
52
52
  } from './structured-output.js'
53
+ import { extractJsonObject } from './json-reply.js'
53
54
  import type { RunOptions } from './runner.js'
54
55
  import { log, type Logger } from './logger.js'
55
56
 
@@ -263,23 +264,6 @@ async function resolveReplyCustom(
263
264
  return { value: resolved.value, diagnostics: resolved.diagnostics }
264
265
  }
265
266
 
266
- /** Extract the first JSON object from an agent's final message (tolerating fences/prose). */
267
- function extractJsonObject(text: string): unknown {
268
- const trimmed = text.trim()
269
- const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(trimmed)
270
- const body = fenced ? (fenced[1] ?? '') : trimmed
271
- try {
272
- return JSON.parse(body)
273
- } catch {
274
- const start = body.indexOf('{')
275
- const end = body.lastIndexOf('}')
276
- if (start === -1 || end === -1 || end <= start) {
277
- throw new Error('agent did not return a JSON object')
278
- }
279
- return JSON.parse(body.slice(start, end + 1))
280
- }
281
- }
282
-
283
267
  /**
284
268
  * The service work directory for a checkout at `dir`: the monorepo service subtree
285
269
  * (`repo.serviceDirectory`, created if missing) when the job is service-scoped, else the clone
@@ -0,0 +1,112 @@
1
+ // Reading a JSON object out of an agent's final message.
2
+ //
3
+ // This is the harness half of a pair: the engine reads the SAME reply again with kernel's
4
+ // `extractJson` (see `CompanionController.parseContainerVerdict`). The harness reads it FIRST, and
5
+ // what it fails to read costs a real, billed repair completion (`resolveStructuredOutput`), so the
6
+ // two must agree about which replies are READABLE AT ALL — a shape only kernel accepts is a model
7
+ // call the run pays for and nobody needed. The container image is built from `src/` plus typescript
8
+ // alone, so that agreement cannot be had by importing kernel: the control-character repair below is
9
+ // a deliberate COPY, pinned by `test/json-reply.conformity.test.ts` exactly like `host-markdown.ts`.
10
+ //
11
+ // WHICH object each half picks can still differ (kernel scans forward from every bracket; this half
12
+ // takes the outermost `{…}` span, which is what its caller's one-object contract wants), so the
13
+ // conformity suite pins readability, not identity.
14
+
15
+ /**
16
+ * Extract the JSON object from an agent's final message, tolerating a fence and surrounding prose.
17
+ * Throws when the reply holds no readable JSON.
18
+ *
19
+ * A reply that is valid JSON except for RAW control characters inside a string literal is REPAIRED
20
+ * rather than refused, and — as in kernel — only in a SECOND pass, after the reply has been tried
21
+ * as written. A model asked to lay a field out over several lines (a review verdict written as
22
+ * blocks) writes the layout and drops the `\n` escape, which is worth recovering; recovering it
23
+ * before the reply has been read as written is not, because a repair makes text parse that was
24
+ * meant to be skipped.
25
+ */
26
+ export function extractJsonObject(text: string): unknown {
27
+ const trimmed = text.trim()
28
+ const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(trimmed)
29
+ const body = fenced ? (fenced[1] ?? '') : trimmed
30
+ const asWritten = parseWholeOrSpan(body)
31
+ if (asWritten !== undefined) return asWritten
32
+ // No raw control character ⇒ the repair pass would hand `JSON.parse` the same bytes again.
33
+ if (hasRawControlChar(body)) {
34
+ const repaired = parseWholeOrSpan(escapeControlCharsInStrings(body))
35
+ if (repaired !== undefined) return repaired
36
+ }
37
+ throw new Error('agent did not return a JSON object')
38
+ }
39
+
40
+ /**
41
+ * Parse `source`, else its outermost `{…}` span (the object inside the model's prose). Undefined
42
+ * when neither parses — a value `JSON.parse` itself can never return, so `null` stays a result.
43
+ */
44
+ function parseWholeOrSpan(source: string): unknown {
45
+ const whole = parseOrUndefined(source)
46
+ if (whole !== undefined) return whole
47
+ const start = source.indexOf('{')
48
+ const end = source.lastIndexOf('}')
49
+ if (start === -1 || end === -1 || end <= start) return undefined
50
+ return parseOrUndefined(source.slice(start, end + 1))
51
+ }
52
+
53
+ function parseOrUndefined(json: string): unknown {
54
+ try {
55
+ return JSON.parse(json)
56
+ } catch {
57
+ return undefined
58
+ }
59
+ }
60
+
61
+ /** Whether `text` holds any raw control character: the cheap gate on attempting a repair at all. */
62
+ function hasRawControlChar(text: string): boolean {
63
+ for (let i = 0; i < text.length; i++) {
64
+ if (text.charCodeAt(i) < 0x20) return true
65
+ }
66
+ return false
67
+ }
68
+
69
+ /** The control characters JSON gives a short escape; the rest go to `\uXXXX`. */
70
+ const CONTROL_ESCAPES: Record<string, string> = {
71
+ '\n': '\\n',
72
+ '\r': '\\r',
73
+ '\t': '\\t',
74
+ '\b': '\\b',
75
+ '\f': '\\f',
76
+ }
77
+
78
+ /**
79
+ * Re-escape raw control characters that sit INSIDE a JSON string literal. Only characters inside a
80
+ * string are rewritten, so the structural whitespace between tokens keeps its meaning and a
81
+ * genuinely broken reply still fails to parse. Copied from kernel's `llm-output.ts`.
82
+ */
83
+ function escapeControlCharsInStrings(json: string): string {
84
+ let out = ''
85
+ let copiedTo = 0
86
+ let inString = false
87
+ let escaped = false
88
+ for (let i = 0; i < json.length; i++) {
89
+ const ch = json[i]!
90
+ if (!inString) {
91
+ if (ch === '"') inString = true
92
+ continue
93
+ }
94
+ if (escaped) {
95
+ escaped = false
96
+ continue
97
+ }
98
+ if (ch === '\\') {
99
+ escaped = true
100
+ continue
101
+ }
102
+ if (ch === '"') {
103
+ inString = false
104
+ continue
105
+ }
106
+ if (json.charCodeAt(i) >= 0x20) continue
107
+ const escape = CONTROL_ESCAPES[ch] ?? `\\u${json.charCodeAt(i).toString(16).padStart(4, '0')}`
108
+ out += json.slice(copiedTo, i) + escape
109
+ copiedTo = i + 1
110
+ }
111
+ return out + json.slice(copiedTo)
112
+ }