@inflexa-ai/harness 0.24.0 → 0.26.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.
Files changed (85) hide show
  1. package/dist/agents/sandbox/__fixtures__/deps.js +6 -0
  2. package/dist/agents/sandbox/data-profiler.js +5 -4
  3. package/dist/agents/sandbox/shared.js +11 -0
  4. package/dist/agents/sandbox/types.d.ts +1 -1
  5. package/dist/app/data-profile-orientation.js +30 -1
  6. package/dist/contracts/data-profile.d.ts +166 -0
  7. package/dist/contracts/index.d.ts +1 -0
  8. package/dist/execution/input-signature.d.ts +33 -0
  9. package/dist/execution/input-signature.js +0 -0
  10. package/dist/index.d.ts +5 -2
  11. package/dist/index.js +10 -3
  12. package/dist/input-scan/coverage.d.ts +22 -0
  13. package/dist/input-scan/coverage.js +85 -0
  14. package/dist/input-scan/enrich.d.ts +41 -0
  15. package/dist/input-scan/enrich.js +227 -0
  16. package/dist/input-scan/formats.d.ts +41 -0
  17. package/dist/input-scan/formats.js +279 -0
  18. package/dist/input-scan/index.d.ts +12 -0
  19. package/dist/input-scan/index.js +11 -0
  20. package/dist/input-scan/scan.d.ts +50 -0
  21. package/dist/input-scan/scan.js +182 -0
  22. package/dist/input-scan/shapes.d.ts +46 -0
  23. package/dist/input-scan/shapes.js +0 -0
  24. package/dist/input-scan/types.d.ts +172 -0
  25. package/dist/input-scan/types.js +12 -0
  26. package/dist/lib/page-capture.d.ts +44 -15
  27. package/dist/lib/page-capture.js +78 -19
  28. package/dist/loop/run-agent.js +36 -19
  29. package/dist/memory/card-builders.d.ts +4 -6
  30. package/dist/memory/card-builders.js +4 -6
  31. package/dist/memory/conversation-display-replay.d.ts +7 -6
  32. package/dist/memory/conversation-display-replay.js +7 -6
  33. package/dist/memory/conversation-display-storage.d.ts +2 -10
  34. package/dist/memory/conversation-display-storage.js +79 -32
  35. package/dist/memory/thread-history.d.ts +31 -20
  36. package/dist/memory/thread-history.js +45 -45
  37. package/dist/prompts/report-session.d.ts +1 -1
  38. package/dist/prompts/report-session.js +22 -13
  39. package/dist/prompts/sandbox/data-profiler.d.ts +1 -1
  40. package/dist/prompts/sandbox/data-profiler.js +159 -275
  41. package/dist/provenance/exec-frame.js +9 -3
  42. package/dist/runtime/boot.d.ts +7 -8
  43. package/dist/runtime/boot.js +7 -19
  44. package/dist/sandbox/client.d.ts +7 -0
  45. package/dist/sandbox/create-sandbox.js +1 -0
  46. package/dist/sandbox/docker-client.d.ts +1 -0
  47. package/dist/sandbox/docker-client.js +20 -15
  48. package/dist/sandbox/k8s-client.d.ts +6 -5
  49. package/dist/sandbox/k8s-client.js +55 -39
  50. package/dist/sandbox/reaper.d.ts +24 -13
  51. package/dist/sandbox/reaper.js +47 -14
  52. package/dist/sandbox/types.d.ts +6 -5
  53. package/dist/schemas/data-profile-schemas.d.ts +62 -0
  54. package/dist/schemas/data-profile-schemas.js +68 -2
  55. package/dist/state/data-profile.d.ts +7 -88
  56. package/dist/state/index.d.ts +1 -1
  57. package/dist/tasks/data-profile-activity.d.ts +8 -0
  58. package/dist/tasks/data-profile-activity.js +1 -0
  59. package/dist/tasks/data-profile-index.d.ts +45 -0
  60. package/dist/tasks/data-profile-index.js +134 -0
  61. package/dist/tasks/data-profile.d.ts +8 -23
  62. package/dist/tasks/data-profile.js +150 -117
  63. package/dist/tools/define-tool.d.ts +19 -8
  64. package/dist/tools/define-tool.js +28 -12
  65. package/dist/tools/report-session/examine-page.d.ts +22 -13
  66. package/dist/tools/report-session/examine-page.js +15 -10
  67. package/dist/tools/report-session/index.d.ts +1 -1
  68. package/dist/tools/research/generate-plan.js +3 -8
  69. package/dist/tools/research/inspect-data-profile.d.ts +30 -4
  70. package/dist/tools/research/inspect-data-profile.js +75 -20
  71. package/dist/tools/workspace/scan-inputs.d.ts +38 -0
  72. package/dist/tools/workspace/scan-inputs.js +61 -0
  73. package/dist/tools/workspace/workspace-search.d.ts +1 -1
  74. package/dist/tools/workspace/workspace-search.js +12 -9
  75. package/dist/workspace/filesystem.d.ts +27 -0
  76. package/dist/workspace/filesystem.js +27 -0
  77. package/package.json +1 -1
  78. package/dist/app/data-profile-policy.d.ts +0 -36
  79. package/dist/app/data-profile-policy.js +0 -37
  80. package/dist/memory/content-to-cortex.d.ts +0 -75
  81. package/dist/memory/content-to-cortex.js +0 -201
  82. package/dist/memory/conversation-display-backfill.d.ts +0 -23
  83. package/dist/memory/conversation-display-backfill.js +0 -91
  84. package/dist/memory/reconstruct-cards.d.ts +0 -27
  85. package/dist/memory/reconstruct-cards.js +0 -57
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Data-profile DBOS workflow — profiles the already-staged input files, runs
3
- * the data-profiler sandbox agent, writes per-file metadata to
4
- * cortex_artifacts, and indexes descriptions into the pgvector store. Inputs
5
- * are staged under `data/inputs/` by the embedder before the run (see the data-profile-init spec);
6
- * the body assumes a populated tree and never downloads.
2
+ * Data-profile DBOS workflow — scans the already-staged input tree, runs the
3
+ * data-profiler sandbox agent over the resulting manifest, registers each staged
4
+ * file in cortex_artifacts, and indexes the profile's kinds and entities into the
5
+ * pgvector store. Inputs are staged under `data/inputs/` by the embedder before the
6
+ * run (see the data-profile-init spec); the body assumes a populated tree and never
7
+ * downloads.
7
8
  *
8
9
  * Recoverable: a crashed Cortex pod resumes the workflow from the DBOS step
9
10
  * cache. The run authorization is minted at the async edge (`triggerDataProfile`)
@@ -34,6 +35,11 @@ import { generateExecutionId } from "../sandbox/execution-id.js";
34
35
  import { mintSandboxIdentity } from "../sandbox/identity.js";
35
36
  import { createVectorStore } from "../state/vector-store.js";
36
37
  import { ProfilerOutputSchema } from "../schemas/data-profile-schemas.js";
38
+ import { computeInputSignature } from "../execution/input-signature.js";
39
+ import { computeCoverage } from "../input-scan/coverage.js";
40
+ import { enrichShapes } from "../input-scan/enrich.js";
41
+ import { renderInputScanManifest, scanInputTree } from "../input-scan/scan.js";
42
+ import { buildProfileIndexEntries } from "./data-profile-index.js";
37
43
  import { completeDataProfile, failDataProfile, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, tryRerunDataProfile, tryStartDataProfile, upsertArtifacts, } from "../state/index.js";
38
44
  import { createProfileActivityEmitter } from "./data-profile-activity.js";
39
45
  import { ensureSearchIndex, searchIndexName } from "../workspace/search-config.js";
@@ -42,39 +48,20 @@ import { ensureSearchIndex, searchIndexName } from "../workspace/search-config.j
42
48
  import { DATA_PROFILE_RUN_LITERAL } from "../contracts/data-profile.js";
43
49
  const DATA_PROFILE_STEP_LITERAL = "profile";
44
50
  const DATA_PROFILE_AGENT_ID = "data-profiler";
45
- /** Sandbox-server exec budget for the profile run. */
46
- const DEFAULT_DEADLINE_MS = 300_000;
51
+ /**
52
+ * Sandbox-server exec budget for the profile run. Twenty minutes: the deterministic
53
+ * scan bounds the discovery half, and what remains is the agent's own reading of a
54
+ * bounded number of example files.
55
+ */
56
+ const DEFAULT_DEADLINE_MS = 1_200_000;
57
+ /** Index entries embedded and upserted per round trip. */
58
+ const INDEX_BATCH_SIZE = 256;
59
+ /** Where the embedder stages this analysis's inputs (see the data-profile-init spec). */
60
+ const STAGED_INPUT_ROOT = "data/inputs";
47
61
  /** Build the canonical artifact path for a staged input file. */
48
62
  function inputArtifactPath(f) {
49
63
  return `data/${f.relativePath}`;
50
64
  }
51
- /** Lowercased extension of a path, or `"unknown"` when none. */
52
- function fileExtension(p) {
53
- const base = p.slice(p.lastIndexOf("/") + 1);
54
- const dot = base.lastIndexOf(".");
55
- return dot > 0 ? base.slice(dot + 1).toLowerCase() : "unknown";
56
- }
57
- /**
58
- * Build the drift-signature comparand a completed profile persists, or `undefined`
59
- * to omit it. `inputFiles` records whether the profiled files still hold the same
60
- * bytes; it sits beside the `inputFileIds` audit record (WHICH files) in the stored
61
- * `DataProfileResult`.
62
- *
63
- * A workflow input persisted before `StagedInput.mtimeMs` existed (recovered across
64
- * the deploy that added it) carries entries with NO `mtimeMs` despite the compile-time
65
- * type — the same recovered-legacy-input case `ownsMandate` handles. A per-entry
66
- * `{ …, mtimeMs: undefined }` would JSON.stringify to `{ fileId, size }` and violate
67
- * `DataProfileInputFile`, so the WHOLE signature is omitted when ANY entry lacks
68
- * `mtimeMs`: an absent comparand reads back as drift and buys one clean re-profile,
69
- * exactly as a wholly-absent `result` already does (see `DataProfileResult.inputFiles`).
70
- * The cast defeats the required-`number` type precisely to observe that legacy gap.
71
- */
72
- export function buildDriftSignature(stagedInputs) {
73
- const mtimeOf = (f) => f.mtimeMs;
74
- if (!stagedInputs.every((f) => mtimeOf(f) !== undefined))
75
- return undefined;
76
- return stagedInputs.map((f) => ({ fileId: f.fileId, size: f.size, mtimeMs: mtimeOf(f) }));
77
- }
78
65
  /**
79
66
  * Project the profiler's structured output plus the staged manifest into the record
80
67
  * persisted on `cortex_analysis_state`.
@@ -91,7 +78,7 @@ export function buildDriftSignature(stagedInputs) {
91
78
  * indistinguishable from a legacy snapshot that predates the field, which is exactly
92
79
  * the reading a consumer must already tolerate.
93
80
  */
94
- export function buildDataProfileResult(profile, stagedInputs, profiledAt) {
81
+ export function buildDataProfileResult(profile, stagedInputs, profiledAt, coverage) {
95
82
  return {
96
83
  summary: profile.analysisSummary,
97
84
  files: profile.files.map((f) => ({
@@ -105,8 +92,23 @@ export function buildDataProfileResult(profile, stagedInputs, profiledAt) {
105
92
  warnings: f.warnings,
106
93
  metrics: f.metrics,
107
94
  })),
108
- inputFileIds: stagedInputs.map((f) => f.fileId),
109
- inputFiles: buildDriftSignature(stagedInputs),
95
+ kinds: profile.kinds.map((k) => ({
96
+ name: k.name,
97
+ memberRepresents: k.memberRepresents,
98
+ description: k.description,
99
+ count: k.count,
100
+ pathPattern: k.pathPattern,
101
+ format: k.format,
102
+ axisLabels: k.axisLabels,
103
+ })),
104
+ axes: profile.axes?.map((a) => ({
105
+ label: a.label,
106
+ cardinality: a.cardinality,
107
+ exampleValues: a.exampleValues,
108
+ description: a.description,
109
+ })),
110
+ inputSignature: computeInputSignature(stagedInputs),
111
+ coverage,
110
112
  profiledAt,
111
113
  domain: profile.domain,
112
114
  subtype: profile.subtype,
@@ -119,6 +121,37 @@ export function buildDataProfileResult(profile, stagedInputs, profiledAt) {
119
121
  qualityAssessment: profile.qualityAssessment,
120
122
  };
121
123
  }
124
+ /**
125
+ * Walk the staged tree and enrich its shapes with a header readout.
126
+ *
127
+ * The walk and the shape observation run in this process over the workspace read seam;
128
+ * only the readout reaches into the sandbox, and only once per shape. A readout that
129
+ * fails is logged and dropped: it is enrichment, and a manifest without it still
130
+ * carries every structural observation the agent's grouping rests on, so failing the
131
+ * profile over it would trade the whole capability for a nicety.
132
+ */
133
+ async function runInputScan(args) {
134
+ const { session, deps, analysisId, sandbox, execId, deadlineMs, logger } = args;
135
+ const scan = await scanInputTree({ session, fs: deps.workspaceFs, root: STAGED_INPUT_ROOT });
136
+ if (scan.manifest.shapes.length === 0)
137
+ return scan;
138
+ try {
139
+ const shapes = await enrichShapes({
140
+ shapes: scan.manifest.shapes,
141
+ sandboxClient: deps.sandboxClient,
142
+ sandbox,
143
+ mountRoot: `/${analysisId}`,
144
+ execId,
145
+ deadlineMs,
146
+ emit: async () => { },
147
+ });
148
+ return { ...scan, manifest: { ...scan.manifest, shapes } };
149
+ }
150
+ catch (err) {
151
+ logger.warn("input-scan header readout failed (non-fatal)", logger.errorFields(err));
152
+ return scan;
153
+ }
154
+ }
122
155
  /**
123
156
  * Register the data-profile workflow with DBOS. Returns the registered
124
157
  * callable so `triggerDataProfile` can dispatch via `DBOS.startWorkflow`.
@@ -192,34 +225,6 @@ export async function runDataProfileBody(input, deps) {
192
225
  // meaningless, so no softer handling is warranted.
193
226
  const workspaceRoot = deps.resolveWorkspaceRoot(analysisId);
194
227
  const profileWritePrefix = `${workspaceRoot}/runs/${DATA_PROFILE_RUN_LITERAL}/${DATA_PROFILE_STEP_LITERAL}`;
195
- const fileList = stagedInputs.map((f) => inputArtifactPath(f));
196
- // The profiler runs outside `executeAnalysis`, so no scheduler composes a
197
- // briefing for it — this prompt IS its briefing, and the sandbox system
198
- // prompt names no paths (it is static per agent type, for the prompt cache).
199
- // Its workspace frame therefore has to be stated here, in the same words the
200
- // step briefing uses.
201
- const prompt = [
202
- `Profile all input data files for this analysis.`,
203
- ``,
204
- renderWorkspace({
205
- analysisRoot: `/${analysisId}`,
206
- workingDir: toSandboxPath(workspaceRoot, analysisId, profileWritePrefix),
207
- }),
208
- ``,
209
- `IMPORTANT: You MUST use execute_command to profile files before submitting results. Do NOT submit empty files or placeholder text.`,
210
- ``,
211
- `CRITICAL: File and directory paths may contain spaces. You MUST always double-quote paths in shell commands (e.g. head "/${analysisId}/data/inputs/My Folder/file.csv"). Unquoted paths with spaces will silently break commands.`,
212
- ``,
213
- `The following input files are available (paths relative to the analysis root):`,
214
- ...fileList.map((f) => `- ${f}`),
215
- ``,
216
- `Steps:`,
217
- `1. For each file, use head and wc to inspect structure (ALWAYS double-quote file paths)`,
218
- `2. Write a Python script for detailed profiling — use pathlib or os.path for path handling (handles spaces natively)`,
219
- `3. Call the \`submit_profile\` tool with structured metadata for EVERY file listed above`,
220
- ``,
221
- `All file paths in your response must be relative to the analysis root and must EXACTLY match the paths listed above.`,
222
- ].join("\n");
223
228
  logger.info("starting sandbox", { executionId });
224
229
  // Reported BEFORE the container exists, deliberately: provisioning is the longest single
225
230
  // operation in a profile and it precedes the agent entirely, so a body that reported only
@@ -255,6 +260,48 @@ export async function runDataProfileBody(input, deps) {
255
260
  // which captures its step deadline the same way).
256
261
  const deadlineAbs = (await DBOS.now()) + DEFAULT_DEADLINE_MS;
257
262
  const nextFunctionId = makeNextFunctionId();
263
+ // The deterministic scan, before the agent's first turn. It is always needed
264
+ // and its result does not depend on agent judgement, so spending an agent turn
265
+ // to request it would be waste — and a briefing carrying one line per input
266
+ // file consumes context that carries no structure (~270 KB of bare paths for
267
+ // the tree that motivated this, against ~2 KB of shapes).
268
+ await activity.scanning();
269
+ const scan = await runInputScan({
270
+ session: childSession,
271
+ deps,
272
+ analysisId,
273
+ sandbox,
274
+ execId: `${workflowId}:${DATA_PROFILE_STEP_LITERAL}:${nextFunctionId()}`,
275
+ deadlineMs: deadlineAbs,
276
+ logger,
277
+ });
278
+ logger.info("input scan complete", {
279
+ files: scan.manifest.fileCount,
280
+ shapes: scan.manifest.shapes.length,
281
+ unstructured: scan.manifest.unstructured.count,
282
+ truncated: scan.manifest.truncated,
283
+ });
284
+ // The profiler runs outside `executeAnalysis`, so no scheduler composes a
285
+ // briefing for it — this prompt IS its briefing, and the sandbox system
286
+ // prompt names no paths (it is static per agent type, for the prompt cache).
287
+ // Its workspace frame therefore has to be stated here, in the same words the
288
+ // step briefing uses.
289
+ const prompt = [
290
+ `Profile the input data for this analysis: say what the dataset IS, so planning can proceed.`,
291
+ ``,
292
+ renderWorkspace({
293
+ analysisRoot: `/${analysisId}`,
294
+ workingDir: toSandboxPath(workspaceRoot, analysisId, profileWritePrefix),
295
+ }),
296
+ ``,
297
+ `CRITICAL: File and directory paths may contain spaces. You MUST always double-quote paths in shell commands (e.g. head "/${analysisId}/data/inputs/My Folder/file.csv"). Unquoted paths with spaces will silently break commands.`,
298
+ ``,
299
+ renderInputScanManifest(scan.manifest),
300
+ ``,
301
+ `Decide the dataset's kinds from these observations — you may split a shape, span several shapes, or`,
302
+ `label an axis the scan did not observe. Inspect ONE example file per kind where a description needs`,
303
+ `content the scan did not capture, then call \`submit_profile\` once.`,
304
+ ].join("\n");
258
305
  const sandboxAgentDeps = {
259
306
  provider: deps.provider,
260
307
  pool: deps.pool,
@@ -322,78 +369,64 @@ export async function runDataProfileBody(input, deps) {
322
369
  }, {
323
370
  tools: [submitProfileTool],
324
371
  nudge: "You stopped without calling submit_profile, so no profile was " +
325
- "recorded. Call submit_profile now with structured metadata for " +
326
- "every input file — base it on the profiling you already did.",
372
+ "recorded. Call submit_profile now with the kinds this dataset is " +
373
+ "made of, the axes that vary across them, and any individually " +
374
+ "notable files — base it on the scan and the work you already did.",
327
375
  });
328
376
  if (!capturedProfile) {
329
377
  throw new Error("Data profiling failed: agent did not call submit_profile");
330
378
  }
331
379
  const profilerData = capturedProfile;
332
- // 4. Index into vector store — real files only, consistent type metadata
380
+ // 4. Index into vector store — one entry per kind, one per entity, none per file.
333
381
  //
334
382
  // Reported as `indexing`, not `persisting`: the contract defines `persisting` as step
335
383
  // bytes uploading to an artifact store, and a profile uploads nothing — its durable
336
384
  // products are this vector index and the ledger row.
337
385
  await activity.indexing();
338
- const profilerByPath = new Map(profilerData.files.map((f) => [f.path, f]));
339
- const normPath = (p) => p
340
- .replace(/\/+/g, "/")
341
- .replace(/^\/|\/$/g, "")
342
- .trim();
343
- const profilerByNorm = new Map(profilerData.files.map((f) => [normPath(f.path), f]));
386
+ const profileRecord = buildDataProfileResult(profilerData, stagedInputs, new Date().toISOString(),
387
+ // Coverage measures the PROFILE, not the scan: the agent's own patterns are
388
+ // matched against the scanned tree, so a profile whose kinds leave most of
389
+ // the tree unmatched is visibly incomplete instead of reading as fresh.
390
+ computeCoverage(scan.files.map((f) => f.path), profilerData.kinds.map((kind) => kind.pathPattern)));
344
391
  await ensureSearchIndex(deps.pool, analysisId, deps.embedding.dimensions);
345
392
  const vectorStore = createVectorStore(deps.pool);
346
- const embedOne = async (text, session) => {
347
- const [vec] = unwrapOrThrow(await deps.embedding.embed([text], session));
348
- if (!vec)
349
- throw new Error("data-profile: empty embedding response");
350
- return vec;
351
- };
352
393
  const indexName = searchIndexName(analysisId);
353
- let indexed = 0;
354
- let fallbackCount = 0;
355
- for (const matFile of stagedInputs) {
356
- const dbPath = inputArtifactPath(matFile);
357
- const desc = profilerByPath.get(dbPath) ?? profilerByNorm.get(normPath(dbPath));
358
- // Lossless: a materialized input file the profiler agent omitted
359
- // still gets a deterministic, no-LLM description so it stays
360
- // discoverable via search. The agent's profile is enrichment, not a
361
- // gate on indexing — mirrors the step-output metadata fallback.
362
- const searchMeta = desc
363
- ? {
364
- text: desc.description,
365
- type: "input",
366
- dataType: desc.dataType,
367
- format: desc.format,
368
- ...(desc.tags ? { tags: desc.tags } : {}),
369
- }
370
- : {
371
- text: `${dbPath} — input file (${fileExtension(dbPath)}, ${matFile.size} bytes); automated profile unavailable.`,
372
- type: "input",
373
- dataType: "unknown",
374
- format: fileExtension(dbPath),
375
- };
376
- if (!desc)
377
- fallbackCount++;
378
- const embedding = await embedOne(searchMeta.text, runSession);
394
+ const entries = buildProfileIndexEntries({
395
+ analysisId,
396
+ kinds: profileRecord.kinds ?? [],
397
+ ...(profileRecord.axes ? { axes: profileRecord.axes } : {}),
398
+ scan,
399
+ files: profileRecord.files,
400
+ });
401
+ // Batched: both interfaces already take arrays, and one round trip per entry is
402
+ // what made indexing 12 of the 39 minutes the motivating profile took.
403
+ for (let start = 0; start < entries.length; start += INDEX_BATCH_SIZE) {
404
+ const batch = entries.slice(start, start + INDEX_BATCH_SIZE);
405
+ const vectors = unwrapOrThrow(await deps.embedding.embed(batch.map((entry) => entry.text), runSession));
406
+ if (vectors.length !== batch.length)
407
+ throw new Error(`data-profile: embedding returned ${vectors.length} vectors for ${batch.length} entries`);
379
408
  unwrapOrThrow(await vectorStore.upsert({
380
409
  indexName,
381
- vectors: [embedding],
382
- metadata: [searchMeta],
383
- ids: [`/${analysisId}/${dbPath}`],
410
+ vectors,
411
+ metadata: batch.map((entry) => ({ ...entry.metadata, text: entry.text })),
412
+ ids: batch.map((entry) => entry.id),
384
413
  }));
385
- indexed++;
386
414
  }
387
- if (fallbackCount > 0) {
388
- logger.warn("input file(s) used a deterministic fallback description — profiler omitted them", {
389
- fallbackCount,
390
- stagedCount: stagedInputs.length,
415
+ logger.info("indexed profile", {
416
+ entries: entries.length,
417
+ kinds: profileRecord.kinds?.length ?? 0,
418
+ stagedCount: stagedInputs.length,
419
+ });
420
+ if (profileRecord.coverage && profileRecord.coverage.unmatched > 0) {
421
+ logger.warn("profile kinds do not cover the scanned tree", {
422
+ matched: profileRecord.coverage.matched,
423
+ unmatched: profileRecord.coverage.unmatched,
424
+ total: profileRecord.coverage.total,
391
425
  });
392
426
  }
393
- logger.info("indexed file(s)", { indexed });
394
- // 5. Complete — store the FULL profiler finding plus the input snapshot for
427
+ // 5. Complete — store the FULL profiler finding plus the input signature for
395
428
  // staleness detection. The scratch tree is gone; this row is all that survives.
396
- if (!unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, buildDataProfileResult(profilerData, stagedInputs, new Date().toISOString())))) {
429
+ if (!unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, profileRecord))) {
397
430
  logTerminalNoop(logger, analysisId, "completion");
398
431
  }
399
432
  await deps.runAuthorizer.revoke(authorization, "data-profile-completed");
@@ -39,9 +39,9 @@ export type ToolExecutionMode = "step" | "workflow" | "inline";
39
39
  /** Runtime guard — does a `Result`'s error value carry the `ToolError` shape? */
40
40
  export declare function isToolError(value: unknown): value is ToolError;
41
41
  /**
42
- * The picture that a tool ok value carries beside its JSON data. `base64` holds
42
+ * One picture that a tool ok value carries beside its JSON data. `base64` holds
43
43
  * the bytes, and `mediaType` names the IANA type, for example "image/png". The
44
- * loop splits the picture out of the JSON text, and it rides the tool result as
44
+ * loop splits each picture out of the JSON text, and it rides the tool result as
45
45
  * an image content block. Thus the model sees the picture, and the JSON text
46
46
  * holds no bytes.
47
47
  */
@@ -50,23 +50,34 @@ export interface ToolResultImage {
50
50
  readonly mediaType: string;
51
51
  }
52
52
  /**
53
- * The reserved key that carries a picture on a tool ok value. It is a symbol,
53
+ * The reserved key that carries the pictures on a tool ok value. It is a symbol,
54
54
  * thus it never collides with a data field. `JSON.stringify` also omits a symbol
55
55
  * key, thus the bytes never reach the JSON text by accident.
56
56
  */
57
57
  export declare const toolResultImageKey: unique symbol;
58
- /** A tool ok value that can carry a picture under the reserved key. */
58
+ /** A tool ok value that can carry one picture or an ordered list of them under the reserved key. */
59
59
  export type WithToolResultImage<T> = T & {
60
- readonly [toolResultImageKey]?: ToolResultImage;
60
+ readonly [toolResultImageKey]?: ToolResultImage | readonly ToolResultImage[];
61
61
  };
62
62
  /**
63
- * Attach a picture to a tool ok value. The value keeps its own fields, and the
63
+ * Attach one picture to a tool ok value. The value keeps its own fields, and the
64
64
  * picture rides under the reserved key. The loop reads the key, and it moves the
65
65
  * bytes into an image content block on the tool result.
66
66
  */
67
67
  export declare function withToolResultImage<T extends object>(value: T, image: ToolResultImage): WithToolResultImage<T>;
68
- /** Read the picture that a tool ok value carries, or undefined when it carries none. */
69
- export declare function readToolResultImage(value: unknown): ToolResultImage | undefined;
68
+ /**
69
+ * Attach an ordered list of pictures to a tool ok value. The order is the order
70
+ * that the model reads, and the loop keeps it on the wire. A tool that slices a
71
+ * page attaches the slices in document order.
72
+ */
73
+ export declare function withToolResultImages<T extends object>(value: T, images: readonly ToolResultImage[]): WithToolResultImage<T>;
74
+ /**
75
+ * Read the pictures that a tool ok value carries, in order. A value with none
76
+ * gives the empty list. The one-picture shape reads as a list of one, thus a
77
+ * tool that attaches one picture and a tool that attaches a list meet the loop
78
+ * in the same shape.
79
+ */
80
+ export declare function readToolResultImages(value: unknown): ToolResultImage[];
70
81
  /**
71
82
  * The request-scoped values passed to every tool's `execute`. No injected
72
83
  * dependencies (see the harness-durable-runtime spec) — invocation identity,
@@ -21,30 +21,46 @@ export function isToolError(value) {
21
21
  return typeof value === "object" && value !== null && typeof value.error === "string" && typeof value.retryable === "boolean";
22
22
  }
23
23
  /**
24
- * The reserved key that carries a picture on a tool ok value. It is a symbol,
24
+ * The reserved key that carries the pictures on a tool ok value. It is a symbol,
25
25
  * thus it never collides with a data field. `JSON.stringify` also omits a symbol
26
26
  * key, thus the bytes never reach the JSON text by accident.
27
27
  */
28
28
  export const toolResultImageKey = Symbol("toolResultImage");
29
29
  /**
30
- * Attach a picture to a tool ok value. The value keeps its own fields, and the
30
+ * Attach one picture to a tool ok value. The value keeps its own fields, and the
31
31
  * picture rides under the reserved key. The loop reads the key, and it moves the
32
32
  * bytes into an image content block on the tool result.
33
33
  */
34
34
  export function withToolResultImage(value, image) {
35
35
  return { ...value, [toolResultImageKey]: image };
36
36
  }
37
- /** Read the picture that a tool ok value carries, or undefined when it carries none. */
38
- export function readToolResultImage(value) {
37
+ /**
38
+ * Attach an ordered list of pictures to a tool ok value. The order is the order
39
+ * that the model reads, and the loop keeps it on the wire. A tool that slices a
40
+ * page attaches the slices in document order.
41
+ */
42
+ export function withToolResultImages(value, images) {
43
+ return { ...value, [toolResultImageKey]: images };
44
+ }
45
+ /** Does one entry under the reserved key carry the picture shape? */
46
+ function isToolResultImage(entry) {
47
+ if (typeof entry !== "object" || entry === null)
48
+ return false;
49
+ const { base64, mediaType } = entry;
50
+ return typeof base64 === "string" && typeof mediaType === "string";
51
+ }
52
+ /**
53
+ * Read the pictures that a tool ok value carries, in order. A value with none
54
+ * gives the empty list. The one-picture shape reads as a list of one, thus a
55
+ * tool that attaches one picture and a tool that attaches a list meet the loop
56
+ * in the same shape.
57
+ */
58
+ export function readToolResultImages(value) {
39
59
  if (typeof value !== "object" || value === null)
40
- return undefined;
41
- const image = value[toolResultImageKey];
42
- if (typeof image !== "object" || image === null)
43
- return undefined;
44
- const { base64, mediaType } = image;
45
- if (typeof base64 !== "string" || typeof mediaType !== "string")
46
- return undefined;
47
- return { base64, mediaType };
60
+ return [];
61
+ const carried = value[toolResultImageKey];
62
+ const entries = Array.isArray(carried) ? carried : [carried];
63
+ return entries.filter(isToolResultImage);
48
64
  }
49
65
  /**
50
66
  * Package a `Tool`. The emitted JSON Schema must have a top-level
@@ -29,9 +29,9 @@
29
29
  *
30
30
  * On a capture the tool copies the rendered hash onto the seen hash through the gateway. Thus the look
31
31
  * counts, and the record tool lets the current draft record. The copy takes the rendered hash and never the
32
- * current one, thus a later edit makes the look stale and the record refuses. A picture of the window alone
33
- * is a true picture of the current document, thus it stamps the same and the coverage of the result is what
34
- * tells the agent what it saw.
32
+ * current one, thus a later edit makes the look stale and the record refuses. A partial look — the window
33
+ * alone, or slices that a budget truncated — is a true look at the current document, thus it stamps the same
34
+ * and the coverage of the result is what tells the agent what it saw.
35
35
  *
36
36
  * The gateway reports whether a rendered hash existed to copy. When the row holds none, no preview stamped
37
37
  * one and the look cannot count. The tool then gives a missed-stamp outcome that directs a new preview,
@@ -49,11 +49,11 @@ import type { AuthContext } from "../../auth/types.js";
49
49
  import { type ChromeConfig } from "../../lib/chrome.js";
50
50
  import { type AcquireEyes } from "../../lib/eyes.js";
51
51
  import { type Logger } from "../../lib/logger.js";
52
- import { type CapturePage, type FailedRequest, type PageCapture } from "../../lib/page-capture.js";
52
+ import { type CaptureCoverage, type CapturePage, type FailedRequest, type PageCapture } from "../../lib/page-capture.js";
53
53
  import { type ResolveWorkspaceRoot } from "../../workspace/paths.js";
54
54
  import { type Tool } from "../define-tool.js";
55
55
  import { type ReportSessionStateGateway, type SessionRefusal } from "../report-authoring/authoring-tools.js";
56
- export type { CapturePage, FailedRequest, PageCapture };
56
+ export type { CaptureCoverage, CapturePage, FailedRequest, PageCapture };
57
57
  /**
58
58
  * The URL formation of one look. It maps the page of the thread onto the URL that the browser opens.
59
59
  * A composition whose browser cannot reach the workspace tree binds a resolver that names a served URL.
@@ -69,15 +69,23 @@ export type ResolvePageUrl = (args: {
69
69
  /** The empty input. The tool examines the current page of the thread, thus it needs no field. */
70
70
  declare const examinePageInput: z.ZodObject<{}, z.core.$strip>;
71
71
  export type ExaminePageInput = z.infer<typeof examinePageInput>;
72
+ /** The document range of one slice of a tiled look, in the order that the pictures ride. */
73
+ export interface ExaminedTile {
74
+ index: number;
75
+ fromY: number;
76
+ toY: number;
77
+ }
72
78
  /**
73
79
  * The typed outcome of the eyes tool. Each arm is ok-channel data, thus the tool never throws for a
74
- * degraded condition. `examined` carries the coverage of the picture, the console errors, the failed
75
- * requests, and the page path. The coverage names what the agent saw: the whole document, or the window
76
- * alone when the browser refused a full-page bitmap. The screenshot does not ride the JSON. It rides the
77
- * image path of the tool result, thus the model sees the picture and the JSON text holds no bytes.
78
- * `missed-stamp` means that the row holds no rendered hash, thus no preview stamped one and the agent must
79
- * run a new preview before the next look. `no-browser` means that the composition gives no browser, thus no
80
- * look is possible at all and a repeat gives the same answer.
80
+ * degraded condition. `examined` carries the coverage of the look, the console errors, the failed
81
+ * requests, and the page path. The coverage names what the agent saw: the whole document in one shot,
82
+ * consecutive slices with the captured and the total pixels, or the window alone when the browser refused
83
+ * the bitmap. A tiled look carries `tiles` — the document range of each slice, in the order of the
84
+ * pictures — thus the model reads which rows each picture holds. The screenshots do not ride the JSON.
85
+ * They ride the image path of the tool result in document order, thus the model sees the pictures and the
86
+ * JSON text holds no bytes. `missed-stamp` means that the row holds no rendered hash, thus no preview
87
+ * stamped one and the agent must run a new preview before the next look. `no-browser` means that the
88
+ * composition gives no browser, thus no look is possible at all and a repeat gives the same answer.
81
89
  */
82
90
  export type ExaminePageResult = {
83
91
  outcome: "refused";
@@ -94,7 +102,8 @@ export type ExaminePageResult = {
94
102
  detail: string;
95
103
  } | {
96
104
  outcome: "examined";
97
- coverage: PageCapture["coverage"];
105
+ coverage: CaptureCoverage;
106
+ tiles?: ExaminedTile[];
98
107
  consoleErrors: string[];
99
108
  failedRequests: FailedRequest[];
100
109
  pagePath: string;
@@ -29,9 +29,9 @@
29
29
  *
30
30
  * On a capture the tool copies the rendered hash onto the seen hash through the gateway. Thus the look
31
31
  * counts, and the record tool lets the current draft record. The copy takes the rendered hash and never the
32
- * current one, thus a later edit makes the look stale and the record refuses. A picture of the window alone
33
- * is a true picture of the current document, thus it stamps the same and the coverage of the result is what
34
- * tells the agent what it saw.
32
+ * current one, thus a later edit makes the look stale and the record refuses. A partial look — the window
33
+ * alone, or slices that a budget truncated — is a true look at the current document, thus it stamps the same
34
+ * and the coverage of the result is what tells the agent what it saw.
35
35
  *
36
36
  * The gateway reports whether a rendered hash existed to copy. When the row holds none, no preview stamped
37
37
  * one and the look cannot count. The tool then gives a missed-stamp outcome that directs a new preview,
@@ -55,7 +55,7 @@ import { createStaticEyes } from "../../lib/eyes.js";
55
55
  import { defaultErrorFields } from "../../lib/logger.js";
56
56
  import { capturePage } from "../../lib/page-capture.js";
57
57
  import { reportSessionDir } from "../../workspace/paths.js";
58
- import { defineTool, withToolResultImage } from "../define-tool.js";
58
+ import { defineTool, withToolResultImages } from "../define-tool.js";
59
59
  import { openReportThread } from "../report-authoring/authoring-tools.js";
60
60
  /** The empty input. The tool examines the current page of the thread, thus it needs no field. */
61
61
  const examinePageInput = z.object({});
@@ -226,8 +226,9 @@ export function createExaminePageTool(deps) {
226
226
  return defineTool({
227
227
  id: "examine_page",
228
228
  description: "Open the rendered report page in a real headless browser, and report what you see. " +
229
- "Give back a screenshot, the coverage of that screenshot, the console errors, and the failed requests. " +
230
- "The coverage says whether the picture holds the whole page, or the top window alone. " +
229
+ "Give back one or more screenshots, the coverage of the look, the console errors, and the failed requests. " +
230
+ "A tall page arrives as consecutive top-to-bottom slices in document order, and the tiles list names the rows of each slice. " +
231
+ "The coverage says whether the pictures hold the whole page, a truncated top portion of it, or the top window alone. " +
231
232
  "Run it after the preview to look at the current page, and to confirm that the layout and the charts read clean. " +
232
233
  "The report tool records a version only after you look at the current page. " +
233
234
  "If the page has no confirmed render, run the preview again first.",
@@ -317,15 +318,19 @@ export function createExaminePageTool(deps) {
317
318
  const detail = stamped.outcome === "failed" ? stamped.detail : "the session state row is absent";
318
319
  logger.warn("the seen hash did not stamp", { threadId, analysisId, detail });
319
320
  }
320
- // The screenshot rides the image path of the tool result, thus the model sees the picture. The
321
- // JSON keeps the faults and the page path only, thus the JSON text holds no bytes.
322
- return ok(withToolResultImage({
321
+ // The screenshots ride the image path of the tool result in document order, thus the model sees
322
+ // the pictures. The JSON keeps the faults, the tile ranges, and the page path only, thus the
323
+ // JSON text holds no bytes. The tiles list mirrors the picture order, thus the model reads which
324
+ // rows each picture holds.
325
+ const tiles = captured.screenshots.flatMap((shot, index) => shot.range === undefined ? [] : [{ index, fromY: shot.range.fromY, toY: shot.range.toY }]);
326
+ return ok(withToolResultImages({
323
327
  outcome: "examined",
324
328
  coverage: captured.coverage,
329
+ ...(tiles.length === 0 ? {} : { tiles }),
325
330
  consoleErrors: captured.consoleErrors,
326
331
  failedRequests: captured.failedRequests,
327
332
  pagePath: relativePagePath,
328
- }, { base64: captured.screenshotBase64, mediaType: SCREENSHOT_MEDIA_TYPE }));
333
+ }, captured.screenshots.map((shot) => ({ base64: shot.base64, mediaType: SCREENSHOT_MEDIA_TYPE }))));
329
334
  },
330
335
  });
331
336
  }
@@ -6,7 +6,7 @@
6
6
  * the factories and their public types for the composition root.
7
7
  */
8
8
  export { createPreviewReportTool, type PreviewReportInput, type PreviewReportResult, type PreviewReportToolDeps, type ResolvePageAsset, type SessionPageAccess, } from "./preview-report.js";
9
- export { createExaminePageTool, type CapturePage, type ExaminePageInput, type ExaminePageResult, type ExaminePageToolDeps, type FailedRequest, type PageCapture, type ResolvePageUrl, } from "./examine-page.js";
9
+ export { createExaminePageTool, type CaptureCoverage, type CapturePage, type ExaminedTile, type ExaminePageInput, type ExaminePageResult, type ExaminePageToolDeps, type FailedRequest, type PageCapture, type ResolvePageUrl, } from "./examine-page.js";
10
10
  export { describeSessionPageMintFailure, UnavailableSessionPagePublisher, type MakeSessionPagePublisher, type SessionPageMintFailure, type SessionPageMintResult, type SessionPagePublisher, } from "./session-page-publisher.js";
11
11
  export { createRecordVersionTool, type RecordVersionInput, type RecordVersionResult, type RecordVersionToolDeps } from "./record-version.js";
12
12
  export { createDeriveTableTool, type DeriveTableInput, type DeriveTableResult, type DeriveTableToolDeps } from "./derive-table.js";
@@ -36,7 +36,6 @@ import { createListAvailablePackagesTool } from "../sandbox/list-available-packa
36
36
  import { createListAvailableRefsTool } from "../sandbox/list-available-refs.js";
37
37
  import { createReportBlockerToolFor } from "../sandbox/report-blocker.js";
38
38
  import { DATA_PROFILE_ORIENTATION_MAX_CHARS, buildDataProfileOrientation } from "../../app/data-profile-orientation.js";
39
- import { isDataProfileStale } from "../../app/data-profile-policy.js";
40
39
  import { DEFAULT_SANDBOX_MAX_STEPS } from "../../config/resource-limits.js";
41
40
  import { plannerPrompt } from "../../prompts/planner.js";
42
41
  import { hydratePlanSteps, PlannerPlanSchema } from "../../schemas/plan-schemas.js";
@@ -157,10 +156,9 @@ function formatPriorPlan(parentPlanId, plan) {
157
156
  /**
158
157
  * Read the profile ledger row into a grounding variant.
159
158
  *
160
- * Staleness is `isDataProfileStale` — the single definition in
161
- * `app/data-profile-policy.ts` that the embedder's re-trigger policy and
162
- * `inspect_data_profile` also use, so the planner and the conversation agent can
163
- * never disagree about whether a profile still describes the data.
159
+ * A profile is re-invoked by the embedder when the input set changes, never derived
160
+ * as stale on read, so the only qualification this can raise is one the ledger row
161
+ * states outright: an attempt that superseded this result is running, or failed.
164
162
  */
165
163
  function classifyGrounding(status) {
166
164
  // `loadDataProfileStatus` collapses "no analysis row" and "profile cleared"
@@ -182,9 +180,6 @@ function classifyGrounding(status) {
182
180
  // preserve `data_profile_result` on purpose, so a non-`completed` status
183
181
  // carrying a result means what is on the row is the PREVIOUS profile.
184
182
  const reasons = [];
185
- if (isDataProfileStale(status.seedInputFileIds ?? [], result.inputFileIds)) {
186
- reasons.push("the analysis's input file set changed after this profile was taken");
187
- }
188
183
  if (status.status === "pending" || status.status === "running") {
189
184
  reasons.push("a re-profile is in progress — this is the previous profile");
190
185
  }