@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.
@@ -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
+ }
@@ -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 appendFile(join(gitRoot, '.git', 'info', 'exclude'), `\n${CONTEXT_DIR}/\n`, 'utf8')
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
  }