@cat-factory/executor-harness 1.110.0 → 1.112.0
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/README.md +48 -0
- package/dist/agent-capabilities.js +6 -1
- package/dist/agent-runner.d.ts +15 -0
- package/dist/agent-runner.js +14 -51
- package/dist/agent-shared.d.ts +1 -0
- package/dist/agent-shared.js +1 -0
- package/dist/agent.js +6 -17
- package/dist/artifact-upload.d.ts +38 -0
- package/dist/artifact-upload.js +39 -0
- package/dist/codex-home.d.ts +79 -0
- package/dist/codex-home.js +109 -0
- package/dist/codex-images.d.ts +52 -0
- package/dist/codex-images.js +157 -0
- package/dist/job.d.ts +23 -0
- package/dist/job.js +5 -1
- package/dist/json-reply.d.ts +12 -0
- package/dist/json-reply.js +114 -0
- package/dist/pi-workspace.d.ts +9 -0
- package/dist/pi-workspace.js +5 -0
- package/dist/pi.d.ts +6 -0
- package/dist/pi.js +13 -2
- package/package.json +4 -4
- package/src/agent-capabilities.ts +6 -1
- package/src/agent-runner.ts +30 -54
- package/src/agent-shared.ts +2 -0
- package/src/agent.ts +6 -17
- package/src/artifact-upload.ts +71 -0
- package/src/codex-home.ts +187 -0
- package/src/codex-images.ts +167 -0
- package/src/job.ts +30 -0
- package/src/json-reply.ts +112 -0
- package/src/pi-workspace.ts +14 -0
- package/src/pi.ts +12 -2
|
@@ -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
|
+
}
|
package/src/pi-workspace.ts
CHANGED
|
@@ -241,6 +241,15 @@ export interface AgentRunSpec {
|
|
|
241
241
|
* re-deciding. Absent ⇒ the CLI's built-in tools only.
|
|
242
242
|
*/
|
|
243
243
|
mcpServers?: McpServerSpec[]
|
|
244
|
+
/**
|
|
245
|
+
* Enable the codex CLI's built-in `image_gen` tool and stage its output into the checkout.
|
|
246
|
+
*
|
|
247
|
+
* Forwarded rather than decided here, exactly like {@link mcpServers}: the BACKEND is the half
|
|
248
|
+
* that resolved a harness-served binary generator for this step and knows the run is meant to
|
|
249
|
+
* generate. A run that simply asks nicely gets nothing, which is the point — the tool bills the
|
|
250
|
+
* leased plan at several times an ordinary turn.
|
|
251
|
+
*/
|
|
252
|
+
generateImages?: boolean
|
|
244
253
|
/**
|
|
245
254
|
* Enable proxy-backed web search: point the rpiv-web-tools SearXNG provider at the
|
|
246
255
|
* backend's search proxy (`${proxyBaseUrl}/web-search`) with the session token as
|
|
@@ -342,6 +351,11 @@ export async function runAgentInWorkspace(
|
|
|
342
351
|
...(spec.ambientAuth ? { ambientAuth: true } : {}),
|
|
343
352
|
...(spec.skills?.length ? { skills: spec.skills } : {}),
|
|
344
353
|
...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
|
|
354
|
+
// Codex's own image tool. Passed for both subscription harnesses because the option lives on
|
|
355
|
+
// the shared run options; `runClaudeCode` ignores it, since claude-code has no such tool and
|
|
356
|
+
// (unlike an MCP server) there is nothing to report as unservable — the backend never
|
|
357
|
+
// resolves a codex-served generator onto a claude-code step, because admission refuses it.
|
|
358
|
+
...(spec.generateImages ? { generateImages: true } : {}),
|
|
345
359
|
...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
|
|
346
360
|
signal: opts.signal,
|
|
347
361
|
// Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
|
package/src/pi.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
|
-
import { appendFile, mkdir, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import { dirname, join } from 'node:path'
|
|
5
5
|
import { killChildProcess, spawnDetached } from './process.js'
|
|
@@ -337,12 +337,22 @@ export async function materializeContextFiles(
|
|
|
337
337
|
* One helper rather than a copy per materialiser: every writer into that directory owes the same
|
|
338
338
|
* exclude, and a new one that forgot it would leak the platform's own files into a customer's
|
|
339
339
|
* repository with nothing failing.
|
|
340
|
+
*
|
|
341
|
+
* IDEMPOTENT BY CONTENT, because that is the only shape that survives its own design: several
|
|
342
|
+
* materialisers legitimately run in one job (context files, skill resources, the codex image
|
|
343
|
+
* staging), so a blind append writes the same line two or three times per run, and on a persistent
|
|
344
|
+
* checkout it accretes one copy per run forever. Re-read and skip rather than tracking who called
|
|
345
|
+
* first, which would be a second piece of state to keep right.
|
|
340
346
|
*/
|
|
341
347
|
export async function excludeContextDir(cwd: string): Promise<void> {
|
|
342
348
|
const gitRoot = await findGitRoot(cwd)
|
|
343
349
|
if (!gitRoot) return
|
|
350
|
+
const excludeFile = join(gitRoot, '.git', 'info', 'exclude')
|
|
351
|
+
const entry = `${CONTEXT_DIR}/`
|
|
344
352
|
try {
|
|
345
|
-
await
|
|
353
|
+
const current = await readFile(excludeFile, 'utf8').catch(() => '')
|
|
354
|
+
if (current.split('\n').some((line) => line.trim() === entry)) return
|
|
355
|
+
await appendFile(excludeFile, `\n${entry}\n`, 'utf8')
|
|
346
356
|
} catch {
|
|
347
357
|
// No writable .git/info; the files simply stay untracked (still not auto-added on most flows).
|
|
348
358
|
}
|