@cat-factory/executor-harness 1.106.0 → 1.108.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 +10 -0
- package/dist/agent-capabilities.js +1 -1
- package/dist/agent-shared.d.ts +3 -2
- package/dist/agent-shared.js +1 -0
- package/dist/coding-agent.d.ts +9 -2
- package/dist/coding-agent.js +2 -0
- package/dist/context-images.d.ts +38 -0
- package/dist/context-images.js +207 -0
- package/dist/context-manifests.d.ts +13 -11
- package/dist/context-manifests.js +21 -14
- package/dist/design-images.d.ts +40 -0
- package/dist/design-images.js +68 -0
- package/dist/job-images.d.ts +28 -0
- package/dist/job-images.js +21 -0
- package/dist/job.d.ts +16 -4
- package/dist/job.js +5 -3
- package/dist/pi-workspace.d.ts +9 -2
- package/dist/pi-workspace.js +4 -4
- package/dist/reference-screenshots.d.ts +7 -39
- package/dist/reference-screenshots.js +12 -198
- package/package.json +4 -4
- package/src/agent-capabilities.ts +1 -1
- package/src/agent-shared.ts +4 -8
- package/src/coding-agent.ts +11 -2
- package/src/context-images.ts +235 -0
- package/src/context-manifests.ts +26 -19
- package/src/design-images.ts +82 -0
- package/src/job-images.ts +43 -0
- package/src/job.ts +24 -8
- package/src/pi-workspace.ts +13 -6
- package/src/reference-screenshots.ts +17 -223
package/README.md
CHANGED
|
@@ -128,6 +128,14 @@ from the backend over the SAME container session token the run already holds for
|
|
|
128
128
|
this needs no extra credential. The FILE NAMES are the backend's, never derived here: the name is
|
|
129
129
|
how the agent learns the view name, and the platform pairs its capture against that name later.
|
|
130
130
|
|
|
131
|
+
A job for a kind that BUILDS a screen carries the same wire shape under `designImages`, downloaded
|
|
132
|
+
into `.cat-context/design-renders/` instead. Same transfer, opposite instruction: those are the
|
|
133
|
+
design to build, not the views to capture, which is why they get their own directory (a tester
|
|
134
|
+
reading the builder's handful would take it for the complete list of views to capture). The prompt
|
|
135
|
+
naming them is composed by the BACKEND, since only it knows whether this harness/model pair can be
|
|
136
|
+
shown an image at all and which views the run was not sent, so the harness speaks up only to
|
|
137
|
+
CORRECT that list when a picture did not land.
|
|
138
|
+
|
|
131
139
|
`omitted` carries the views the backend's cap dropped. They are stated to the agent beside the
|
|
132
140
|
transfers that failed, since from where it stands both are a view to capture with nothing to compare
|
|
133
141
|
against. This parser keeps a higher backstop of its own against a body claiming more files than any
|
|
@@ -255,6 +263,8 @@ Kimi / DeepSeek) and meters spend. The provider key never enters the container.
|
|
|
255
263
|
| `src/validation-checks.ts` | Pre-PR validation: runs the job's check commands in the checkout (bounded, secret-scrubbed capture, per-command watchdog) and drives the retry-until-green loop that gates the PR. Generic: keyed off the job body, never the agent kind. |
|
|
256
264
|
| `src/reproduction-proof.ts` | Bugfix reproduction proof: runs the job's declared reproduction command against two symmetric fresh worktrees (the pre-fix tree and the final tree) and computes red-then-green from the exit codes, with a repair loop that never fails the run. Generic: keyed off the job body, never the agent kind. |
|
|
257
265
|
| `src/agent-capabilities.ts` | The agent CAPABILITIES a job body carries: the run's `skills` (a `SKILL.md` payload + resources) and its `mcpServers` (tool servers): with their defensive parsing and the per-CLI config writers (`--mcp-config` JSON for claude-code, `[mcp_servers.*]` TOML for Codex). Backend-authored data the harness only MATERIALISES: adding a skill or a tool server is a backend registration, never a harness change. |
|
|
266
|
+
| `src/context-images.ts` | The TRANSFER half of both image manifests: downloads a manifest's images into a subdirectory of `.cat-context/` on the run's own container session token, bounded per image and per pass, and reports what did not land. Best-effort, time-bounded and IDEMPOTENT over the checkout, so a repair round re-costs a stat rather than a transfer. Shared, because the transfer is identical for both; what differs is what the files MEAN, which is each caller's own module below. |
|
|
267
|
+
| `src/design-images.ts` | The task's DESIGN PICTURES: downloads the manifest a building job body carries into `.cat-context/design-renders/`, for an agent CLI that can read an image into its turn. Says NOTHING on success (the backend's prompt already names every file and its view) and speaks only to correct that list when a picture is not here, because an agent told to open a file that is absent goes looking for the design rather than for the transfer. |
|
|
258
268
|
| `src/reference-screenshots.ts` | The task's REFERENCE DESIGN images: downloads the manifest a capturing job body carries into `.cat-context/reference-screenshots/` (on the run's own container session token) and composes the prompt block naming each file's view. Best-effort, time-bounded and IDEMPOTENT over the checkout, so a repair round re-costs a stat rather than a transfer. A reference that is not on disk is NAMED to the agent, whether a transfer failed or the backend's cap dropped the view, because on disk an absent file and a screen the design does not have are the same thing. Backend-authored throughout, including the file names. |
|
|
259
269
|
| `src/bootstrap-mode.ts` | The repo-bootstrap MODE: clone-a-reference-or-scaffold → run the agent → refuse to push an empty tree → reinit + force-push to the pre-created target repo. |
|
|
260
270
|
| `src/agent-shared.ts` | The few helpers every agent MODE shares (effort-report folding, the capability fields forwarded to `runAgentInWorkspace`). |
|
|
@@ -241,7 +241,7 @@ export function parseSkillSpecs(value) {
|
|
|
241
241
|
* A member is added here in the SAME change that teaches the parser the field, never ahead of it:
|
|
242
242
|
* the whole value of the list is that it is the image's own honest answer.
|
|
243
243
|
*/
|
|
244
|
-
export const HARNESS_BODY_CAPABILITIES = ['mcpServers', 'skills'];
|
|
244
|
+
export const HARNESS_BODY_CAPABILITIES = ['mcpServers', 'skills', 'designImages'];
|
|
245
245
|
/**
|
|
246
246
|
* A safe MCP server id: it becomes a tool-name fragment AND a TOML table key.
|
|
247
247
|
*
|
package/dist/agent-shared.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentJob, AgentResult, McpServerSpec,
|
|
1
|
+
import type { AgentJob, AgentResult, McpServerSpec, ImageManifestSpec, SkillSpec } from './job.js';
|
|
2
2
|
import type { EffortReport } from './effort.js';
|
|
3
3
|
/**
|
|
4
4
|
* Fold an agent's effort self-assessment (lifted from its sentinel file by `runAgentInWorkspace`)
|
|
@@ -16,5 +16,6 @@ export declare function mergeEffort(result: AgentResult, effortReport: EffortRep
|
|
|
16
16
|
export declare function agentCapabilities(job: AgentJob): {
|
|
17
17
|
skills?: SkillSpec[];
|
|
18
18
|
mcpServers?: McpServerSpec[];
|
|
19
|
-
referenceScreenshots?:
|
|
19
|
+
referenceScreenshots?: ImageManifestSpec;
|
|
20
|
+
designImages?: ImageManifestSpec;
|
|
20
21
|
};
|
package/dist/agent-shared.js
CHANGED
|
@@ -21,5 +21,6 @@ export function agentCapabilities(job) {
|
|
|
21
21
|
...(job.skills?.length ? { skills: job.skills } : {}),
|
|
22
22
|
...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
|
|
23
23
|
...(job.referenceScreenshots ? { referenceScreenshots: job.referenceScreenshots } : {}),
|
|
24
|
+
...(job.designImages ? { designImages: job.designImages } : {}),
|
|
24
25
|
};
|
|
25
26
|
}
|
package/dist/coding-agent.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentJob, AgentResult, HarnessAuthFields,
|
|
1
|
+
import type { AgentJob, AgentResult, HarnessAuthFields, ImageManifestSpec, RepoSpec, SkillSpec, McpServerSpec } from './job.js';
|
|
2
2
|
import type { HarnessCallMetric } from './pi.js';
|
|
3
3
|
import type { PiRunStats } from './pi-reduction.js';
|
|
4
4
|
import { type EffortReport } from './effort.js';
|
|
@@ -119,7 +119,14 @@ export interface CodingAgentSpec extends HarnessAuthFields {
|
|
|
119
119
|
* UI-facing kind may well be a coding one, and nothing here switches on which built-in it is.
|
|
120
120
|
* Absent ⇒ none (the normal case).
|
|
121
121
|
*/
|
|
122
|
-
referenceScreenshots?:
|
|
122
|
+
referenceScreenshots?: ImageManifestSpec;
|
|
123
|
+
/**
|
|
124
|
+
* The PICTURES of the task's designs, downloaded into `.cat-context/design-renders/` before the
|
|
125
|
+
* agent's first turn. Carried here for the same reason the capture set is: what earns a run its
|
|
126
|
+
* pictures is the KIND's declared trait plus a harness that can read an image, and a coding kind
|
|
127
|
+
* is the commonest holder of both. Absent ⇒ none (the normal case).
|
|
128
|
+
*/
|
|
129
|
+
designImages?: ImageManifestSpec;
|
|
123
130
|
}
|
|
124
131
|
/** The outcome of a coding agent run, before each caller maps it to its own result shape. */
|
|
125
132
|
export interface CodingAgentOutcome {
|
package/dist/coding-agent.js
CHANGED
|
@@ -212,6 +212,7 @@ export async function runCodingAgent(spec, opts = {}) {
|
|
|
212
212
|
...(spec.referenceScreenshots
|
|
213
213
|
? { referenceScreenshots: spec.referenceScreenshots }
|
|
214
214
|
: {}),
|
|
215
|
+
...(spec.designImages ? { designImages: spec.designImages } : {}),
|
|
215
216
|
}, opts);
|
|
216
217
|
let outcome;
|
|
217
218
|
try {
|
|
@@ -812,6 +813,7 @@ export async function runMultiRepoCoding(job, opts = {}) {
|
|
|
812
813
|
...(job.skills?.length ? { skills: job.skills } : {}),
|
|
813
814
|
...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
|
|
814
815
|
...(job.referenceScreenshots ? { referenceScreenshots: job.referenceScreenshots } : {}),
|
|
816
|
+
...(job.designImages ? { designImages: job.designImages } : {}),
|
|
815
817
|
multiRepo: true,
|
|
816
818
|
}, opts);
|
|
817
819
|
// Commit forgotten tracked edits, then push + open a PR for each repo the run actually changed.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ImageManifestSpec } from './job.js';
|
|
2
|
+
/** What a transfer pass has on disk, and what it does not. */
|
|
3
|
+
export interface ContextImageOutcome {
|
|
4
|
+
written: {
|
|
5
|
+
fileName: string;
|
|
6
|
+
view: string;
|
|
7
|
+
}[];
|
|
8
|
+
/**
|
|
9
|
+
* One entry per image that is NOT on disk, with the cause stated in `reason`. Covers both halves
|
|
10
|
+
* of that absence, because the agent's position is the same either way (this view exists and
|
|
11
|
+
* there is no picture of it here): a transfer that failed, and a view the backend's own cap
|
|
12
|
+
* dropped before this container was ever asked to fetch it.
|
|
13
|
+
*/
|
|
14
|
+
missing: {
|
|
15
|
+
view: string;
|
|
16
|
+
reason: string;
|
|
17
|
+
}[];
|
|
18
|
+
/** Where the written files live, relative to the checkout root. */
|
|
19
|
+
dir: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Download a manifest's images into `<checkout>/.cat-context/<subdir>/` and report what landed.
|
|
23
|
+
*
|
|
24
|
+
* IDEMPOTENT, and that is load-bearing rather than an optimisation: an agent flow re-enters its
|
|
25
|
+
* workspace once per repair round, so this pass runs several times over one checkout. A file
|
|
26
|
+
* already on disk is counted and never re-fetched, which keeps a later round from spending the
|
|
27
|
+
* budget again AND from reporting an image as absent that pass 1 successfully delivered. A view
|
|
28
|
+
* that MISSED is retried, since the next round is a fresh chance at whatever was transiently down.
|
|
29
|
+
*
|
|
30
|
+
* Never throws: images are an aid, not a precondition for running, so a backend outage degrades the
|
|
31
|
+
* run to its textual context rather than failing it. Every miss is carried out on
|
|
32
|
+
* {@link ContextImageOutcome.missing} so the caller can say so in the prompt, which is the
|
|
33
|
+
* difference between an image the platform failed to hand over and a screen that does not exist.
|
|
34
|
+
*/
|
|
35
|
+
export declare function materializeContextImages(cwd: string, subdir: string, spec: ImageManifestSpec, options?: {
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
fetchImpl?: typeof fetch;
|
|
38
|
+
}): Promise<ContextImageOutcome>;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { mkdir, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { CONTEXT_DIR, excludeContextDir } from './pi.js';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// The TRANSFER half of every image manifest: download the images the backend resolved for this job
|
|
6
|
+
// into a subdirectory of `.cat-context/`, and report what did not land.
|
|
7
|
+
//
|
|
8
|
+
// Shared by both manifests (the capture references and the design pictures) because the transfer is
|
|
9
|
+
// the same in every respect that matters here: the same download seam, the same per-image and
|
|
10
|
+
// whole-pass budgets, the same idempotence over a checkout an agent flow re-enters once per repair
|
|
11
|
+
// round, and the same rule that a miss is NAMED rather than silently absent. What differs is what
|
|
12
|
+
// the files mean, which is why each caller owns its own directory and its own prompt block.
|
|
13
|
+
//
|
|
14
|
+
// The harness MATERIALISES and never decides: which artifact belongs to which view, and what each
|
|
15
|
+
// file is called, are backend answers that ride the job body.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
/** Per-image ceiling, matching the platform's own upload ceiling (16 MiB). */
|
|
18
|
+
const MAX_IMAGE_BYTES = 16 * 1024 * 1024;
|
|
19
|
+
/** Per-image request timeout. */
|
|
20
|
+
const REQUEST_TIMEOUT_MS = 20_000;
|
|
21
|
+
/**
|
|
22
|
+
* Wall-clock ceiling on the WHOLE pass.
|
|
23
|
+
*
|
|
24
|
+
* Downloading is activity-silent from the watchdog's point of view (no agent stream, no output),
|
|
25
|
+
* and `JOB_INACTIVITY_MS` (10 min) is what kills a job that stops producing. Rather than heartbeat
|
|
26
|
+
* a transfer that should take seconds, the pass is bounded far below that: a slow or wedged blob
|
|
27
|
+
* backend costs the run its images (stated to the agent) instead of costing it the run.
|
|
28
|
+
*
|
|
29
|
+
* Bounds ONE pass, and a job with both manifests runs two. That is deliberate: the alternative is a
|
|
30
|
+
* shared budget in which whichever manifest is delivered first can starve the other, which would
|
|
31
|
+
* make a capture's references depend on how many design pictures the same task happens to hold.
|
|
32
|
+
*/
|
|
33
|
+
const TOTAL_BUDGET_MS = 90_000;
|
|
34
|
+
/** How many images are fetched at once. Small on purpose: this is a shared blob backend. */
|
|
35
|
+
const CONCURRENCY = 4;
|
|
36
|
+
/** The cause reported for a view the backend resolved but never sent this job a file for. */
|
|
37
|
+
const OMITTED_REASON = 'not sent to this container (image limit)';
|
|
38
|
+
/**
|
|
39
|
+
* Download a manifest's images into `<checkout>/.cat-context/<subdir>/` and report what landed.
|
|
40
|
+
*
|
|
41
|
+
* IDEMPOTENT, and that is load-bearing rather than an optimisation: an agent flow re-enters its
|
|
42
|
+
* workspace once per repair round, so this pass runs several times over one checkout. A file
|
|
43
|
+
* already on disk is counted and never re-fetched, which keeps a later round from spending the
|
|
44
|
+
* budget again AND from reporting an image as absent that pass 1 successfully delivered. A view
|
|
45
|
+
* that MISSED is retried, since the next round is a fresh chance at whatever was transiently down.
|
|
46
|
+
*
|
|
47
|
+
* Never throws: images are an aid, not a precondition for running, so a backend outage degrades the
|
|
48
|
+
* run to its textual context rather than failing it. Every miss is carried out on
|
|
49
|
+
* {@link ContextImageOutcome.missing} so the caller can say so in the prompt, which is the
|
|
50
|
+
* difference between an image the platform failed to hand over and a screen that does not exist.
|
|
51
|
+
*/
|
|
52
|
+
export async function materializeContextImages(cwd, subdir, spec, options = {}) {
|
|
53
|
+
const dir = join(cwd, CONTEXT_DIR, subdir);
|
|
54
|
+
const outcome = {
|
|
55
|
+
written: [],
|
|
56
|
+
// The backend's own dropped views are missing before a single byte is fetched, and for a cause
|
|
57
|
+
// no transfer could have changed.
|
|
58
|
+
missing: spec.omitted.map((view) => ({ view, reason: OMITTED_REASON })),
|
|
59
|
+
dir: `${CONTEXT_DIR}/${subdir}`,
|
|
60
|
+
};
|
|
61
|
+
try {
|
|
62
|
+
await mkdir(dir, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
// Nowhere to write: report every image as missed rather than half of them, since none of them
|
|
66
|
+
// can land and the cause is the same for all.
|
|
67
|
+
for (const file of spec.files)
|
|
68
|
+
outcome.missing.push({ view: file.view, reason: describe(error) });
|
|
69
|
+
return sortByManifest(outcome, spec);
|
|
70
|
+
}
|
|
71
|
+
const deadline = Date.now() + TOTAL_BUDGET_MS;
|
|
72
|
+
const queue = [...spec.files];
|
|
73
|
+
const workers = Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
|
|
74
|
+
for (;;) {
|
|
75
|
+
const file = queue.shift();
|
|
76
|
+
if (!file)
|
|
77
|
+
return;
|
|
78
|
+
// An earlier pass over this same checkout already delivered it. Checked before the budget so
|
|
79
|
+
// a fully-delivered set costs one stat per file and no network at all, however long an
|
|
80
|
+
// earlier round took.
|
|
81
|
+
if (await alreadyOnDisk(dir, file.fileName)) {
|
|
82
|
+
outcome.written.push({ fileName: file.fileName, view: file.view });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (Date.now() >= deadline) {
|
|
86
|
+
outcome.missing.push({ view: file.view, reason: 'image download budget exhausted' });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const failure = await downloadOne(dir, spec, file, options);
|
|
90
|
+
if (failure)
|
|
91
|
+
outcome.missing.push({ view: file.view, reason: failure });
|
|
92
|
+
else
|
|
93
|
+
outcome.written.push({ fileName: file.fileName, view: file.view });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
await Promise.all(workers);
|
|
97
|
+
// Even a partial set must not reach the agent's PR (same rule as every other context file).
|
|
98
|
+
await excludeContextDir(cwd);
|
|
99
|
+
return sortByManifest(outcome, spec);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Order both lists the way the BACKEND composed the set (its own gallery order) rather than the
|
|
103
|
+
* order the transfers happened to finish in, so the list the agent reads is stable across rounds.
|
|
104
|
+
* The dropped views trail the sent ones, having no position in the manifest to sort by.
|
|
105
|
+
*/
|
|
106
|
+
function sortByManifest(outcome, spec) {
|
|
107
|
+
const rank = new Map(spec.files.map((file, index) => [file.view, index]));
|
|
108
|
+
const at = (view) => rank.get(view) ?? Number.MAX_SAFE_INTEGER;
|
|
109
|
+
outcome.written.sort((a, b) => at(a.view) - at(b.view));
|
|
110
|
+
outcome.missing.sort((a, b) => at(a.view) - at(b.view));
|
|
111
|
+
return outcome;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Whether a previous pass over this checkout already wrote this image.
|
|
115
|
+
*
|
|
116
|
+
* Non-empty is the test, not mere existence: a zero-length file is what a half-written transfer
|
|
117
|
+
* leaves behind, and treating it as delivered would hand the agent a blank image it reads as a
|
|
118
|
+
* design with nothing on the screen (the same case {@link downloadOne} refuses to write).
|
|
119
|
+
*/
|
|
120
|
+
async function alreadyOnDisk(dir, fileName) {
|
|
121
|
+
try {
|
|
122
|
+
return (await stat(join(dir, fileName))).size > 0;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// silent-catch-ok: absence is the ordinary answer here (first pass over the checkout), and any
|
|
126
|
+
// other stat failure is answered the same way — by attempting the download.
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Fetch and write one image, answering a failure reason or undefined on success. */
|
|
131
|
+
async function downloadOne(dir, spec, file, options) {
|
|
132
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
133
|
+
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
134
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
|
|
135
|
+
try {
|
|
136
|
+
const response = await fetchImpl(`${spec.url}/${encodeURIComponent(file.artifactId)}`, {
|
|
137
|
+
headers: { authorization: `Bearer ${spec.token}` },
|
|
138
|
+
signal,
|
|
139
|
+
});
|
|
140
|
+
if (!response.ok)
|
|
141
|
+
return `HTTP ${response.status}`;
|
|
142
|
+
const bytes = await readBounded(response, MAX_IMAGE_BYTES);
|
|
143
|
+
if (bytes === 'too-large')
|
|
144
|
+
return 'image exceeds size limit';
|
|
145
|
+
// A zero-length body is a miss, not a file: written out it would be an image the agent opens,
|
|
146
|
+
// finds empty, and reads as a design with nothing on the screen.
|
|
147
|
+
if (!bytes.byteLength)
|
|
148
|
+
return 'empty response';
|
|
149
|
+
await writeFile(join(dir, file.fileName), bytes);
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
return describe(error);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Read a response body, refusing one that goes past `limit` WITHOUT buffering all of it first.
|
|
158
|
+
*
|
|
159
|
+
* The ceiling has to bound the transfer and not just the write. Buffering the whole body and then
|
|
160
|
+
* measuring it means an oversized (or endless) response is already resident, times the pass's
|
|
161
|
+
* concurrency, by the time it is rejected — which is the container's memory, in a run whose whole
|
|
162
|
+
* point is that it has not started working yet. So the declared length is refused up front where
|
|
163
|
+
* it is honest, and the stream is counted as it arrives and cancelled the moment it crosses the
|
|
164
|
+
* line, which is what makes a chunked or lying body cost no more than a truthful one.
|
|
165
|
+
*/
|
|
166
|
+
async function readBounded(response, limit) {
|
|
167
|
+
const declared = Number(response.headers.get('content-length'));
|
|
168
|
+
if (Number.isFinite(declared) && declared > limit)
|
|
169
|
+
return 'too-large';
|
|
170
|
+
const body = response.body;
|
|
171
|
+
if (!body) {
|
|
172
|
+
// No stream to count (a mocked or already-buffered response): fall back to measuring after the
|
|
173
|
+
// fact, which is sound because there is nothing left to stop arriving.
|
|
174
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
175
|
+
return bytes.byteLength > limit ? 'too-large' : bytes;
|
|
176
|
+
}
|
|
177
|
+
const reader = body.getReader();
|
|
178
|
+
const chunks = [];
|
|
179
|
+
let total = 0;
|
|
180
|
+
try {
|
|
181
|
+
for (;;) {
|
|
182
|
+
const { done, value } = await reader.read();
|
|
183
|
+
if (done)
|
|
184
|
+
break;
|
|
185
|
+
total += value.byteLength;
|
|
186
|
+
if (total > limit) {
|
|
187
|
+
await reader.cancel();
|
|
188
|
+
return 'too-large';
|
|
189
|
+
}
|
|
190
|
+
chunks.push(value);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
reader.releaseLock();
|
|
195
|
+
}
|
|
196
|
+
const bytes = new Uint8Array(total);
|
|
197
|
+
let offset = 0;
|
|
198
|
+
for (const chunk of chunks) {
|
|
199
|
+
bytes.set(chunk, offset);
|
|
200
|
+
offset += chunk.byteLength;
|
|
201
|
+
}
|
|
202
|
+
return bytes;
|
|
203
|
+
}
|
|
204
|
+
/** A one-line cause for a failed transfer (never the token, which only rides a header). */
|
|
205
|
+
function describe(error) {
|
|
206
|
+
return error instanceof Error ? error.message : String(error);
|
|
207
|
+
}
|
|
@@ -25,12 +25,12 @@ export interface ContextFileSpec {
|
|
|
25
25
|
* container would let a harness image the deployment has not rolled out yet rename every view a
|
|
26
26
|
* run reports, and the pairing would come apart with nothing failing.
|
|
27
27
|
*/
|
|
28
|
-
export interface
|
|
28
|
+
export interface ImageManifestSpec {
|
|
29
29
|
/** Base URL of the reference download route; the artifact id is appended as a path segment. */
|
|
30
30
|
url: string;
|
|
31
31
|
/** The run's container session token (the same one the LLM proxy is called with). */
|
|
32
32
|
token: string;
|
|
33
|
-
files:
|
|
33
|
+
files: ImageFileSpec[];
|
|
34
34
|
/**
|
|
35
35
|
* View names the task holds a reference for that this job was NOT sent a file for, because the
|
|
36
36
|
* set was capped. Stated to the agent beside the transfers that failed: from where it stands
|
|
@@ -43,8 +43,8 @@ export interface ReferenceScreenshotsSpec {
|
|
|
43
43
|
*/
|
|
44
44
|
omitted: string[];
|
|
45
45
|
}
|
|
46
|
-
/** One reference image in a {@link
|
|
47
|
-
export interface
|
|
46
|
+
/** One reference image in a {@link ImageManifestSpec}. `fileName` is sanitised on parse. */
|
|
47
|
+
export interface ImageFileSpec {
|
|
48
48
|
artifactId: string;
|
|
49
49
|
fileName: string;
|
|
50
50
|
view: string;
|
|
@@ -58,12 +58,14 @@ export declare function sanitizeContextFileName(value: unknown): string | undefi
|
|
|
58
58
|
/** Parse the linked-context files, dropping any malformed/unsafe entry. */
|
|
59
59
|
export declare function parseContextFiles(value: unknown): ContextFileSpec[];
|
|
60
60
|
/**
|
|
61
|
-
* Parse the
|
|
61
|
+
* Parse one image manifest (the capture set or the design pictures), or undefined when
|
|
62
|
+
* absent/unusable.
|
|
62
63
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
64
|
+
* ONE parser for both, because the wire shape and every rule over it are the same: only the job
|
|
65
|
+
* body FIELD and what the harness does with the files afterwards differ. The whole manifest is
|
|
66
|
+
* dropped when its transport half is unusable (no absolute http(s) URL, no token): every file
|
|
67
|
+
* would fail the same way, and one stated cause beats N identical ones. An individual entry is
|
|
68
|
+
* dropped only when it cannot name a file safely: the same basename sanitisation every context
|
|
69
|
+
* file gets, so a hostile `fileName` can neither escape the directory nor clobber a repo file.
|
|
68
70
|
*/
|
|
69
|
-
export declare function
|
|
71
|
+
export declare function parseImageManifest(value: unknown): ImageManifestSpec | undefined;
|
|
@@ -48,26 +48,33 @@ export function parseContextFiles(value) {
|
|
|
48
48
|
return files;
|
|
49
49
|
}
|
|
50
50
|
/**
|
|
51
|
-
* How many
|
|
52
|
-
* this, so this is the harness's own backstop against a malformed or hostile body turning
|
|
53
|
-
* pre-run setup into an unbounded download, never the ceiling a real run meets.
|
|
51
|
+
* How many images one job may be handed PER MANIFEST. The backend caps each set it sends well
|
|
52
|
+
* below this, so this is the harness's own backstop against a malformed or hostile body turning
|
|
53
|
+
* the pre-run setup into an unbounded download, never the ceiling a real run meets.
|
|
54
|
+
*
|
|
55
|
+
* One number for both manifests on purpose: the two real ceilings are the BACKEND's, chosen where
|
|
56
|
+
* the reason for each is known (transfer time for a capture, input tokens for an attachment). A
|
|
57
|
+
* second backstop here would only encode those reasons a second time, in the one place that
|
|
58
|
+
* cannot see either.
|
|
54
59
|
*
|
|
55
60
|
* Hitting it is REPORTED rather than silently obeyed: an entry past the ceiling is dropped from
|
|
56
61
|
* `files` and its view named on `omitted`, so an agent facing a truncated set is still told which
|
|
57
|
-
* views
|
|
58
|
-
* disk and in the prompt, from a design that simply has no such screen.
|
|
62
|
+
* views it is not being shown. A cap that shortened the list and said nothing would be
|
|
63
|
+
* indistinguishable, on disk and in the prompt, from a design that simply has no such screen.
|
|
59
64
|
*/
|
|
60
|
-
const
|
|
65
|
+
const MAX_MANIFEST_IMAGES = 40;
|
|
61
66
|
/**
|
|
62
|
-
* Parse the
|
|
67
|
+
* Parse one image manifest (the capture set or the design pictures), or undefined when
|
|
68
|
+
* absent/unusable.
|
|
63
69
|
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
70
|
+
* ONE parser for both, because the wire shape and every rule over it are the same: only the job
|
|
71
|
+
* body FIELD and what the harness does with the files afterwards differ. The whole manifest is
|
|
72
|
+
* dropped when its transport half is unusable (no absolute http(s) URL, no token): every file
|
|
73
|
+
* would fail the same way, and one stated cause beats N identical ones. An individual entry is
|
|
74
|
+
* dropped only when it cannot name a file safely: the same basename sanitisation every context
|
|
75
|
+
* file gets, so a hostile `fileName` can neither escape the directory nor clobber a repo file.
|
|
69
76
|
*/
|
|
70
|
-
export function
|
|
77
|
+
export function parseImageManifest(value) {
|
|
71
78
|
if (typeof value !== 'object' || value === null)
|
|
72
79
|
return undefined;
|
|
73
80
|
const o = value;
|
|
@@ -97,7 +104,7 @@ export function parseReferenceScreenshots(value) {
|
|
|
97
104
|
// Past the backstop the entry is NAMED, not dropped: it stays a view the agent must capture.
|
|
98
105
|
// Checked here rather than at the top of the loop so a malformed entry is refused on its own
|
|
99
106
|
// terms (it names no usable view to report) instead of being counted against the ceiling.
|
|
100
|
-
if (files.length >=
|
|
107
|
+
if (files.length >= MAX_MANIFEST_IMAGES) {
|
|
101
108
|
omitted.push(view);
|
|
102
109
|
continue;
|
|
103
110
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ImageManifestSpec } from './job.js';
|
|
2
|
+
import type { Logger } from './logger.js';
|
|
3
|
+
import { type ContextImageOutcome } from './context-images.js';
|
|
4
|
+
/** Subdirectory of {@link CONTEXT_DIR} the design pictures are written to. */
|
|
5
|
+
export declare const DESIGN_RENDER_SUBDIR = "design-renders";
|
|
6
|
+
/** The relative directory the design pictures are written to (what the prompt names). */
|
|
7
|
+
export declare const DESIGN_RENDER_DIR = ".cat-context/design-renders";
|
|
8
|
+
/** Download this job's design pictures. See {@link materializeContextImages}. */
|
|
9
|
+
export declare function materializeDesignImages(cwd: string, spec: ImageManifestSpec, options?: {
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
fetchImpl?: typeof fetch;
|
|
12
|
+
}): Promise<ContextImageOutcome>;
|
|
13
|
+
/**
|
|
14
|
+
* Download the design pictures and answer the CORRECTION to the prompt's own list, or '' when
|
|
15
|
+
* everything the backend named is on disk.
|
|
16
|
+
*
|
|
17
|
+
* Silence on success is the difference from the capture delivery, and it is deliberate. The
|
|
18
|
+
* backend's prompt already names every picture and its view, because it is the side that knows
|
|
19
|
+
* which views exist and how they were delivered; repeating the list here would give the agent two
|
|
20
|
+
* lists of the same files, differing only when something went wrong, with nothing saying which one
|
|
21
|
+
* is current.
|
|
22
|
+
*
|
|
23
|
+
* So this speaks only when the container's truth DIVERGES from what the prompt promised. That
|
|
24
|
+
* divergence has to be stated: an agent told to open a file that is not there re-reads the path,
|
|
25
|
+
* lists the directory and eventually decides the design is missing something, when the honest
|
|
26
|
+
* answer is that this one picture did not transfer and the rest are exactly as described.
|
|
27
|
+
*/
|
|
28
|
+
export declare function deliverDesignImages(cwd: string, spec: ImageManifestSpec | undefined, options: {
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
log: Logger;
|
|
31
|
+
fetchImpl?: typeof fetch;
|
|
32
|
+
}): Promise<string>;
|
|
33
|
+
/**
|
|
34
|
+
* The prompt correction: the views whose picture is NOT in this container, with the cause.
|
|
35
|
+
*
|
|
36
|
+
* Empty whenever the transfer matched the prompt, including the case where the manifest was empty
|
|
37
|
+
* to begin with. The agent is told to carry on from the textual design description rather than to
|
|
38
|
+
* ask for the file, because nothing in the run can deliver it after this point.
|
|
39
|
+
*/
|
|
40
|
+
export declare function designImageGuidance(outcome: ContextImageOutcome): string;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { CONTEXT_DIR } from './pi.js';
|
|
2
|
+
import { materializeContextImages } from './context-images.js';
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// DESIGN PICTURES on disk for a BUILDING kind: what the screen is supposed to look like, in
|
|
5
|
+
// `.cat-context/design-renders/`, for an agent CLI that can read an image into its turn.
|
|
6
|
+
//
|
|
7
|
+
// The other use of the same artifacts the capture path delivers, and the reason each has its own
|
|
8
|
+
// directory: a tester reading these six would take them for the complete list of views to capture,
|
|
9
|
+
// and a builder reading the tester's twenty-four would spend its context on screens it was never
|
|
10
|
+
// asked to touch.
|
|
11
|
+
//
|
|
12
|
+
// The agent is told about these files by the BACKEND's prompt, which is the half that knows which
|
|
13
|
+
// views the platform holds and whether this model can be shown them at all. What this module adds
|
|
14
|
+
// is the container's own half of the truth: which of those files actually landed here.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
/** Subdirectory of {@link CONTEXT_DIR} the design pictures are written to. */
|
|
17
|
+
export const DESIGN_RENDER_SUBDIR = 'design-renders';
|
|
18
|
+
/** The relative directory the design pictures are written to (what the prompt names). */
|
|
19
|
+
export const DESIGN_RENDER_DIR = `${CONTEXT_DIR}/${DESIGN_RENDER_SUBDIR}`;
|
|
20
|
+
/** Download this job's design pictures. See {@link materializeContextImages}. */
|
|
21
|
+
export function materializeDesignImages(cwd, spec, options = {}) {
|
|
22
|
+
return materializeContextImages(cwd, DESIGN_RENDER_SUBDIR, spec, options);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Download the design pictures and answer the CORRECTION to the prompt's own list, or '' when
|
|
26
|
+
* everything the backend named is on disk.
|
|
27
|
+
*
|
|
28
|
+
* Silence on success is the difference from the capture delivery, and it is deliberate. The
|
|
29
|
+
* backend's prompt already names every picture and its view, because it is the side that knows
|
|
30
|
+
* which views exist and how they were delivered; repeating the list here would give the agent two
|
|
31
|
+
* lists of the same files, differing only when something went wrong, with nothing saying which one
|
|
32
|
+
* is current.
|
|
33
|
+
*
|
|
34
|
+
* So this speaks only when the container's truth DIVERGES from what the prompt promised. That
|
|
35
|
+
* divergence has to be stated: an agent told to open a file that is not there re-reads the path,
|
|
36
|
+
* lists the directory and eventually decides the design is missing something, when the honest
|
|
37
|
+
* answer is that this one picture did not transfer and the rest are exactly as described.
|
|
38
|
+
*/
|
|
39
|
+
export async function deliverDesignImages(cwd, spec, options) {
|
|
40
|
+
if (!spec)
|
|
41
|
+
return '';
|
|
42
|
+
const outcome = await materializeDesignImages(cwd, spec, options);
|
|
43
|
+
if (outcome.missing.length) {
|
|
44
|
+
options.log.warn('agent: some design pictures are not on disk', {
|
|
45
|
+
written: outcome.written.length,
|
|
46
|
+
missing: outcome.missing.length,
|
|
47
|
+
reasons: outcome.missing.map((file) => file.reason).slice(0, 5),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return designImageGuidance(outcome);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The prompt correction: the views whose picture is NOT in this container, with the cause.
|
|
54
|
+
*
|
|
55
|
+
* Empty whenever the transfer matched the prompt, including the case where the manifest was empty
|
|
56
|
+
* to begin with. The agent is told to carry on from the textual design description rather than to
|
|
57
|
+
* ask for the file, because nothing in the run can deliver it after this point.
|
|
58
|
+
*/
|
|
59
|
+
export function designImageGuidance(outcome) {
|
|
60
|
+
if (!outcome.missing.length)
|
|
61
|
+
return '';
|
|
62
|
+
return `
|
|
63
|
+
|
|
64
|
+
## Design pictures: correction
|
|
65
|
+
These views were listed above as pictures, and are NOT in this container. Work from the textual
|
|
66
|
+
design description for them; there is nothing to open and nothing that can fetch them now:
|
|
67
|
+
${outcome.missing.map((file) => `- ${file.view}: NOT on disk (${file.reason})`).join('\n')}`;
|
|
68
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ImageManifestSpec } from './job.js';
|
|
2
|
+
import type { Logger } from './logger.js';
|
|
3
|
+
/** The manifests a job may carry, as they sit on any agent-running spec. */
|
|
4
|
+
export interface JobImageSpecs {
|
|
5
|
+
referenceScreenshots?: ImageManifestSpec;
|
|
6
|
+
designImages?: ImageManifestSpec;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Deliver both manifests into the checkout and answer the prompt text they contribute.
|
|
10
|
+
*
|
|
11
|
+
* The two blocks are CONCATENATED rather than kept apart because they say the same kind of thing
|
|
12
|
+
* (what this container actually holds, against what the run was told it would), and the agent reads
|
|
13
|
+
* one context. Either half is empty when its manifest is absent or when nothing needs saying, so a
|
|
14
|
+
* job with one manifest is byte-identical to what it produced before the other existed.
|
|
15
|
+
*
|
|
16
|
+
* Runs once per PASS, not once per job: a coding flow re-enters its workspace for every repair
|
|
17
|
+
* round. That is safe because each delivery is idempotent over the checkout (a file already on disk
|
|
18
|
+
* is counted, never re-fetched), so a later round costs a stat per image and cannot report a view an
|
|
19
|
+
* earlier round successfully delivered as absent. A view that MISSED is retried, which is the
|
|
20
|
+
* behaviour worth having: the next round is a fresh chance at a blob backend that was briefly down.
|
|
21
|
+
*/
|
|
22
|
+
export declare function deliverJobImages(spec: JobImageSpecs & {
|
|
23
|
+
dir: string;
|
|
24
|
+
}, options: {
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
log: Logger;
|
|
27
|
+
fetchImpl?: typeof fetch;
|
|
28
|
+
}): Promise<string>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { deliverDesignImages } from './design-images.js';
|
|
2
|
+
import { deliverReferenceScreenshots } from './reference-screenshots.js';
|
|
3
|
+
/**
|
|
4
|
+
* Deliver both manifests into the checkout and answer the prompt text they contribute.
|
|
5
|
+
*
|
|
6
|
+
* The two blocks are CONCATENATED rather than kept apart because they say the same kind of thing
|
|
7
|
+
* (what this container actually holds, against what the run was told it would), and the agent reads
|
|
8
|
+
* one context. Either half is empty when its manifest is absent or when nothing needs saying, so a
|
|
9
|
+
* job with one manifest is byte-identical to what it produced before the other existed.
|
|
10
|
+
*
|
|
11
|
+
* Runs once per PASS, not once per job: a coding flow re-enters its workspace for every repair
|
|
12
|
+
* round. That is safe because each delivery is idempotent over the checkout (a file already on disk
|
|
13
|
+
* is counted, never re-fetched), so a later round costs a stat per image and cannot report a view an
|
|
14
|
+
* earlier round successfully delivered as absent. A view that MISSED is retried, which is the
|
|
15
|
+
* behaviour worth having: the next round is a fresh chance at a blob backend that was briefly down.
|
|
16
|
+
*/
|
|
17
|
+
export async function deliverJobImages(spec, options) {
|
|
18
|
+
const references = await deliverReferenceScreenshots(spec.dir, spec.referenceScreenshots, options);
|
|
19
|
+
const designs = await deliverDesignImages(spec.dir, spec.designImages, options);
|
|
20
|
+
return `${references}${designs}`;
|
|
21
|
+
}
|