@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 +1 -17
- package/dist/json-reply.d.ts +12 -0
- package/dist/json-reply.js +114 -0
- package/package.json +4 -4
- package/src/agent.ts +1 -17
- package/src/json-reply.ts +112 -0
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.
|
|
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.
|
|
34
|
-
"@cat-factory/server": "0.
|
|
35
|
-
"@cat-factory/spend": "0.15.
|
|
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
|
+
}
|