@inflexa-ai/harness 0.18.0 → 0.19.1

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 (83) hide show
  1. package/dist/agents/report-session-agent.d.ts +72 -0
  2. package/dist/agents/report-session-agent.js +87 -0
  3. package/dist/app/report-session-runtime.d.ts +72 -0
  4. package/dist/app/report-session-runtime.js +183 -0
  5. package/dist/app/spawn-report-session.d.ts +32 -5
  6. package/dist/app/spawn-report-session.js +19 -0
  7. package/dist/config/environment-stores.d.ts +12 -7
  8. package/dist/config/resource-limits.d.ts +6 -10
  9. package/dist/config/resource-limits.js +6 -44
  10. package/dist/contracts/report-reference.d.ts +4 -0
  11. package/dist/contracts/report-reference.js +3 -0
  12. package/dist/index.d.ts +2 -1
  13. package/dist/lib/chrome.d.ts +14 -0
  14. package/dist/lib/chrome.js +7 -0
  15. package/dist/lib/page-capture.d.ts +47 -0
  16. package/dist/lib/page-capture.js +83 -0
  17. package/dist/loop/run-agent.js +104 -15
  18. package/dist/memory/ai-sdk-message-storage.d.ts +9 -4
  19. package/dist/memory/ai-sdk-message-storage.js +9 -4
  20. package/dist/memory/count-tokens.js +34 -5
  21. package/dist/memory/thread-store.d.ts +4 -4
  22. package/dist/memory/thread-store.js +18 -9
  23. package/dist/prompts/report-session.d.ts +1 -0
  24. package/dist/prompts/report-session.js +110 -0
  25. package/dist/providers/ai-sdk.d.ts +158 -3
  26. package/dist/providers/ai-sdk.js +234 -23
  27. package/dist/providers/errors.d.ts +14 -0
  28. package/dist/providers/errors.js +103 -18
  29. package/dist/providers/types.d.ts +33 -0
  30. package/dist/providers/types.js +10 -1
  31. package/dist/report-model/assert-rules.d.ts +44 -0
  32. package/dist/report-model/assert-rules.js +97 -0
  33. package/dist/report-model/draft-hash.d.ts +14 -0
  34. package/dist/report-model/draft-hash.js +36 -0
  35. package/dist/report-model/fixture-resolver.js +2 -71
  36. package/dist/report-model/{mint-snapshot.d.ts → pin-snapshot.d.ts} +7 -7
  37. package/dist/report-model/{mint-snapshot.js → pin-snapshot.js} +7 -7
  38. package/dist/report-model/production-resolver.d.ts +124 -0
  39. package/dist/report-model/production-resolver.js +801 -0
  40. package/dist/report-model/reference-resolver.d.ts +7 -1
  41. package/dist/report-model/validate.d.ts +34 -2
  42. package/dist/report-model/validate.js +37 -17
  43. package/dist/report-render/page.d.ts +23 -0
  44. package/dist/report-render/page.js +36 -3
  45. package/dist/report-render/value-bridge.d.ts +62 -0
  46. package/dist/report-render/value-bridge.js +82 -0
  47. package/dist/runtime/assemble.d.ts +39 -0
  48. package/dist/runtime/assemble.js +67 -6
  49. package/dist/sandbox/mount-plan.d.ts +7 -0
  50. package/dist/sandbox/mount-plan.js +7 -2
  51. package/dist/state/init.js +36 -0
  52. package/dist/state/purge-analysis.js +1 -0
  53. package/dist/state/report-session-state.d.ts +139 -0
  54. package/dist/state/report-session-state.js +177 -0
  55. package/dist/state/report-versions.d.ts +3 -6
  56. package/dist/state/report-versions.js +11 -38
  57. package/dist/state/snapshot-parse.d.ts +34 -0
  58. package/dist/state/snapshot-parse.js +82 -0
  59. package/dist/tasks/extract-values-script.d.ts +64 -0
  60. package/dist/tasks/extract-values-script.js +180 -0
  61. package/dist/tasks/extract-values.d.ts +124 -0
  62. package/dist/tasks/extract-values.js +193 -0
  63. package/dist/tools/ad-hoc-router.d.ts +7 -2
  64. package/dist/tools/ad-hoc-router.js +2 -1
  65. package/dist/tools/define-tool.d.ts +29 -0
  66. package/dist/tools/define-tool.js +26 -0
  67. package/dist/tools/report/preview-snapshot.d.ts +1 -1
  68. package/dist/tools/report/preview-snapshot.js +11 -50
  69. package/dist/tools/report-authoring/authoring-tools.d.ts +181 -24
  70. package/dist/tools/report-authoring/authoring-tools.js +152 -44
  71. package/dist/tools/report-session/examine-page.d.ts +90 -0
  72. package/dist/tools/report-session/examine-page.js +136 -0
  73. package/dist/tools/report-session/index.d.ts +10 -0
  74. package/dist/tools/report-session/index.js +10 -0
  75. package/dist/tools/report-session/preview-report.d.ts +105 -0
  76. package/dist/tools/report-session/preview-report.js +269 -0
  77. package/dist/tools/report-session/record-version.d.ts +102 -0
  78. package/dist/tools/report-session/record-version.js +139 -0
  79. package/dist/tools/research/generate-plan.js +4 -2
  80. package/dist/tools/sandbox/list-available-packages.js +2 -1
  81. package/dist/tools/sandbox/list-available-refs.d.ts +5 -1
  82. package/dist/tools/sandbox/list-available-refs.js +13 -6
  83. package/package.json +10 -8
@@ -1,19 +1,24 @@
1
1
  /**
2
2
  * The authoring tool surface over the draft operations.
3
3
  *
4
- * The factory closes over a private draft holder and the pinned snapshot. It gives eight tools and a
5
- * read of the current draft. The tools read no ambient state, thus two factories hold two independent
6
- * drafts.
4
+ * The factory takes a session-state gateway, and it holds no draft. Each tool reads the thread id from the
5
+ * scope of the call, loads the state of that thread, applies the pure operation, and persists the new
6
+ * document. Thus two threads never share one draft, and a landed document outlives a host restart.
7
7
  *
8
- * Each tool maps its flat input onto a core operation, and it applies the pure operation. The holder
9
- * swaps only on a landed operation. A refused operation returns typed data in the ok channel, and the
10
- * error channel stays for an unexpected failure.
8
+ * Each tool maps its flat input onto a core operation, and it applies the pure operation. A landed
9
+ * document persists before the tool reports `applied: true`. A refused operation returns typed data in the
10
+ * ok channel, and the error channel stays for an unexpected failure.
11
+ *
12
+ * A call with no report thread in its scope, a thread with no stored state, and a gateway fault each
13
+ * refuse as typed data too. That refusal sits beside the core refusal, and it names the absent thread
14
+ * scope or the absent state.
11
15
  *
12
16
  * The primitive admits a flat object input only. Thus a destination is four optional fields, and not a
13
17
  * nested union. A conflict between the flat fields refuses before the core runs.
14
18
  */
15
19
  import { err, ok } from "neverthrow";
16
20
  import { z } from "zod";
21
+ import { isSafeId } from "../../workspace/paths.js";
17
22
  import { addBlock, changeBlock, locate, moveBlock, removeBlock, setTitle, } from "../../report-model/draft-operations.js";
18
23
  import { buildOutline, childOutline, readBlock } from "../../report-model/draft-read.js";
19
24
  import { finishDraft } from "../../report-model/draft-finish.js";
@@ -78,6 +83,44 @@ const setTitleInput = z.object({
78
83
  title: z.string().min(1),
79
84
  });
80
85
  const finishDraftInput = z.object({});
86
+ /**
87
+ * Resolve the report thread of a call, and load its state through the gateway.
88
+ *
89
+ * The report thread and its analysis ride on the analysis scope. The thread id becomes one segment of a
90
+ * session directory, thus the safe-id check sits here beside the shape check: a scope whose id carries a
91
+ * separator or a traversal segment names no thread that a tool can write under.
92
+ *
93
+ * The stored analysis of the thread must match the scope. A mismatch reads one analysis's draft and writes
94
+ * into another analysis's workspace, thus the resolution refuses it with a permanent reason that names the
95
+ * two ids. A scope of a different kind, an unsafe id, an absent thread, a wrong thread type, and a gateway
96
+ * fault each refuse as a typed `SessionRefusal` too.
97
+ *
98
+ * The authoring tools and the preview tool share this one resolution, thus the two surfaces accept the
99
+ * same thread id and map a load outcome onto a refusal the same way.
100
+ */
101
+ export async function openReportThread(gateway, scope) {
102
+ if (scope.kind !== "analysis" || scope.threadId === undefined || !isSafeId(scope.threadId)) {
103
+ return err({ reason: "no-thread-scope", detail: "the scope of the call names no usable report thread id" });
104
+ }
105
+ const { threadId, analysisId } = scope;
106
+ const loaded = await gateway.load(threadId);
107
+ if (loaded.outcome === "absent") {
108
+ return err({ reason: "absent-state", detail: `no report session exists for the thread ${threadId}, and this condition is permanent` });
109
+ }
110
+ if (loaded.outcome === "wrong-type") {
111
+ return err({ reason: "wrong-thread-type", detail: `${loaded.detail}, and this condition is permanent` });
112
+ }
113
+ if (loaded.outcome === "failed") {
114
+ return err({ reason: "state-unavailable", detail: loaded.detail });
115
+ }
116
+ if (loaded.analysisId !== analysisId) {
117
+ return err({
118
+ reason: "scope-analysis-mismatch",
119
+ detail: `the scope names the analysis ${analysisId}, but the thread ${threadId} belongs to the analysis ${loaded.analysisId}`,
120
+ });
121
+ }
122
+ return ok({ threadId, analysisId, state: loaded.state, token: loaded.token, seenDocumentHash: loaded.seenDocumentHash });
123
+ }
81
124
  /** Read an absentable field. An explicit `null` and an omitted key both mean that the model gave no value. */
82
125
  function given(value) {
83
126
  return value ?? undefined;
@@ -197,37 +240,59 @@ function containerIn(document, parentId) {
197
240
  /**
198
241
  * Every tool of this surface runs inline.
199
242
  *
200
- * The draft is closure memory with no durable backing. A step-mode tool goes through `DBOS.runStep`, which
201
- * caches the result of a step and replays it without running the body. A replay after a host restart would
202
- * hand the agent the cached `applied: true` outline of each landed operation while the rebuilt closure
203
- * holds an empty draft, and the finish would then report an empty document for a report that the
204
- * transcript shows as complete. Inline mode keeps the tool result and the draft in one lifetime.
243
+ * A tool loads the state of its thread, applies the pure operation, and persists the result in one body.
244
+ * The reported outline must agree with the row that the body just wrote. A step-mode tool goes through
245
+ * `DBOS.runStep`, which caches the result of a step and replays it without a fresh load. A replay could
246
+ * then report an outline that the current row no longer holds. Inline mode reads and writes the row in one
247
+ * lifetime, thus the outline never drifts from the state.
205
248
  */
206
249
  const AUTHORING_EXECUTION_MODE = "inline";
207
250
  /**
208
- * Make the eight authoring tools over one draft.
251
+ * Make the eight authoring tools over a session-state gateway.
209
252
  *
210
- * The holder is a private `let`. Each tool reads the holder at call time, thus a landing through one tool
211
- * shows in the next call of every tool. The holder swaps only inside `land`, thus a refused operation
212
- * leaves the draft as it was.
253
+ * Each tool reads the thread id from the scope of the call, and it loads the state of that thread through
254
+ * the gateway. Thus one factory serves every thread, and two threads never share one draft. A mutation
255
+ * persists the new document before it reports `applied: true`, thus a reported landing is never lost.
213
256
  */
214
- export function createReportAuthoringTools(state) {
215
- const snapshot = state.snapshot;
216
- let draft = state.initialDraft ?? { title: "", sections: [] };
257
+ export function createReportAuthoringTools(gateway) {
258
+ /**
259
+ * Resolve the thread of the call, and load its state.
260
+ *
261
+ * The shared `openReportThread` holds the one resolution. A scope of a different kind, an unsafe or
262
+ * absent thread id, an absent row, and a gateway fault each refuse before the call reaches a core
263
+ * operation.
264
+ */
265
+ const openThread = (ctx) => openReportThread(gateway, ctx.session.scope);
217
266
  /**
218
- * Swap the holder on a landing, and report the containers that the operation changed.
267
+ * Persist a landing, and report the containers that the operation changed.
219
268
  *
220
- * `holders` names them by id, and it reads the draft as it was and the draft as it becomes, because a
221
- * removed block has a holder only in the first and an added block has one only in the second. The
222
- * child order comes out of `next` in either case.
269
+ * `holders` names them by id, and it reads the previous document and the next document, because a
270
+ * removed block has a holder only in the first and an added block has one only in the second. The child
271
+ * order comes out of `next` in either case. A persist fault refuses with `state-unavailable`, thus the
272
+ * tool reports `applied: true` only after the row holds the new document.
273
+ *
274
+ * The persist is a compare-and-swap against the token that the load read. A concurrent turn that landed
275
+ * first turns the persist into a conflict, and the tool refuses with `stale-state`.
223
276
  */
224
- const land = (result, holders) => result.match((next) => {
225
- const previous = draft;
226
- draft = next;
277
+ const land = async (threadId, token, previous, result, holders) => {
278
+ if (result.isErr()) {
279
+ return { applied: false, refusal: result.error };
280
+ }
281
+ const next = result.value;
282
+ const persisted = await gateway.persist(threadId, next, token);
283
+ if (persisted.outcome === "conflict") {
284
+ return {
285
+ applied: false,
286
+ refusal: { reason: "stale-state", detail: "another turn changed the report since this turn read it, thus read the state again" },
287
+ };
288
+ }
289
+ if (persisted.outcome === "failed") {
290
+ return { applied: false, refusal: { reason: "state-unavailable", detail: persisted.detail } };
291
+ }
227
292
  // A move inside one container names the same holder two times.
228
293
  const unique = [...new Set(holders(previous, next))];
229
294
  return { applied: true, changed: unique.map((parentId) => containerIn(next, parentId)) };
230
- }, (refusal) => ({ applied: false, refusal }));
295
+ };
231
296
  const refuse = (refusal) => ({ applied: false, refusal });
232
297
  const add_block = defineTool({
233
298
  id: "add_block",
@@ -241,16 +306,19 @@ export function createReportAuthoringTools(state) {
241
306
  const kind = readKind(decodeBlockPayload(input.block));
242
307
  return kind !== undefined ? `add ${kind}` : "add a block";
243
308
  },
244
- execute: async (input) => {
309
+ execute: async (input, ctx) => {
310
+ const opened = await openThread(ctx);
311
+ if (opened.isErr()) {
312
+ return ok(refuse(opened.error));
313
+ }
314
+ const { threadId, state, token } = opened.value;
245
315
  const destination = toDestination(input);
246
316
  if (destination.isErr()) {
247
317
  return ok(refuse(destination.error));
248
318
  }
249
319
  const block = decodeBlockPayload(input.block);
250
320
  const addedId = readId(block);
251
- return ok(land(addBlock(draft, { block, destination: destination.value }, snapshot), (_previous, next) => [
252
- addedId === undefined ? undefined : holderIdOf(next, addedId),
253
- ]));
321
+ return ok(await land(threadId, token, state.document, addBlock(state.document, { block, destination: destination.value }, state.snapshot), (_previous, next) => [addedId === undefined ? undefined : holderIdOf(next, addedId)]));
254
322
  },
255
323
  });
256
324
  const change_block = defineTool({
@@ -260,12 +328,19 @@ export function createReportAuthoringTools(state) {
260
328
  inputSchema: changeBlockInput,
261
329
  executionMode: AUTHORING_EXECUTION_MODE,
262
330
  describeCall: (input) => `change ${input.targetId}`,
263
- execute: async (input) => {
331
+ execute: async (input, ctx) => {
332
+ const opened = await openThread(ctx);
333
+ if (opened.isErr()) {
334
+ return ok(refuse(opened.error));
335
+ }
336
+ const { threadId, state, token } = opened.value;
264
337
  const operation = toChangeOperation(input);
265
338
  if (operation.isErr()) {
266
339
  return ok(refuse(operation.error));
267
340
  }
268
- return ok(land(changeBlock(draft, operation.value, snapshot), (_previous, next) => [holderIdOf(next, input.targetId)]));
341
+ return ok(await land(threadId, token, state.document, changeBlock(state.document, operation.value, state.snapshot), (_previous, next) => [
342
+ holderIdOf(next, input.targetId),
343
+ ]));
269
344
  },
270
345
  });
271
346
  const remove_block = defineTool({
@@ -274,7 +349,16 @@ export function createReportAuthoringTools(state) {
274
349
  inputSchema: removeBlockInput,
275
350
  executionMode: AUTHORING_EXECUTION_MODE,
276
351
  describeCall: (input) => `remove ${input.targetId}`,
277
- execute: async (input) => ok(land(removeBlock(draft, { targetId: input.targetId }, snapshot), (previous) => [holderIdOf(previous, input.targetId)])),
352
+ execute: async (input, ctx) => {
353
+ const opened = await openThread(ctx);
354
+ if (opened.isErr()) {
355
+ return ok(refuse(opened.error));
356
+ }
357
+ const { threadId, state, token } = opened.value;
358
+ return ok(await land(threadId, token, state.document, removeBlock(state.document, { targetId: input.targetId }, state.snapshot), (previous) => [
359
+ holderIdOf(previous, input.targetId),
360
+ ]));
361
+ },
278
362
  });
279
363
  const move_block = defineTool({
280
364
  id: "move_block",
@@ -283,15 +367,17 @@ export function createReportAuthoringTools(state) {
283
367
  inputSchema: moveBlockInput,
284
368
  executionMode: AUTHORING_EXECUTION_MODE,
285
369
  describeCall: (input) => `move ${input.targetId}`,
286
- execute: async (input) => {
370
+ execute: async (input, ctx) => {
371
+ const opened = await openThread(ctx);
372
+ if (opened.isErr()) {
373
+ return ok(refuse(opened.error));
374
+ }
375
+ const { threadId, state, token } = opened.value;
287
376
  const destination = toDestination(input);
288
377
  if (destination.isErr()) {
289
378
  return ok(refuse(destination.error));
290
379
  }
291
- return ok(land(moveBlock(draft, { targetId: input.targetId, destination: destination.value }, snapshot), (previous, next) => [
292
- holderIdOf(previous, input.targetId),
293
- holderIdOf(next, input.targetId),
294
- ]));
380
+ return ok(await land(threadId, token, state.document, moveBlock(state.document, { targetId: input.targetId, destination: destination.value }, state.snapshot), (previous, next) => [holderIdOf(previous, input.targetId), holderIdOf(next, input.targetId)]));
295
381
  },
296
382
  });
297
383
  const set_title = defineTool({
@@ -302,7 +388,14 @@ export function createReportAuthoringTools(state) {
302
388
  executionMode: AUTHORING_EXECUTION_MODE,
303
389
  describeCall: (input) => `title the report ${input.title}`,
304
390
  // The title sits on the document, thus no container changes its child order.
305
- execute: async (input) => ok(land(ok(setTitle(draft, input.title)), () => [])),
391
+ execute: async (input, ctx) => {
392
+ const opened = await openThread(ctx);
393
+ if (opened.isErr()) {
394
+ return ok(refuse(opened.error));
395
+ }
396
+ const { threadId, state, token } = opened.value;
397
+ return ok(await land(threadId, token, state.document, ok(setTitle(state.document, input.title)), () => []));
398
+ },
306
399
  });
307
400
  const read_outline = defineTool({
308
401
  id: "read_outline",
@@ -313,7 +406,13 @@ export function createReportAuthoringTools(state) {
313
406
  executionMode: AUTHORING_EXECUTION_MODE,
314
407
  // The input carries no field, thus a hook can only restate the name of the tool.
315
408
  describeCall: "none",
316
- execute: async () => ok({ outline: buildOutline(draft) }),
409
+ execute: async (_input, ctx) => {
410
+ const opened = await openThread(ctx);
411
+ if (opened.isErr()) {
412
+ return ok({ refused: opened.error });
413
+ }
414
+ return ok({ outline: buildOutline(opened.value.state.document) });
415
+ },
317
416
  });
318
417
  const read_block = defineTool({
319
418
  id: "read_block",
@@ -322,8 +421,12 @@ export function createReportAuthoringTools(state) {
322
421
  inputSchema: readBlockInput,
323
422
  executionMode: AUTHORING_EXECUTION_MODE,
324
423
  describeCall: (input) => `read ${input.targetId}`,
325
- execute: async (input) => {
326
- const block = readBlock(draft, input.targetId);
424
+ execute: async (input, ctx) => {
425
+ const opened = await openThread(ctx);
426
+ if (opened.isErr()) {
427
+ return ok({ refused: opened.error });
428
+ }
429
+ const block = readBlock(opened.value.state.document, input.targetId);
327
430
  const result = block !== undefined ? { found: true, block } : { found: false };
328
431
  return ok(result);
329
432
  },
@@ -336,7 +439,13 @@ export function createReportAuthoringTools(state) {
336
439
  executionMode: AUTHORING_EXECUTION_MODE,
337
440
  // The input carries no field, thus a hook can only restate the name of the tool.
338
441
  describeCall: "none",
339
- execute: async () => ok(finishDraft(draft, snapshot)),
442
+ execute: async (_input, ctx) => {
443
+ const opened = await openThread(ctx);
444
+ if (opened.isErr()) {
445
+ return ok({ refused: opened.error });
446
+ }
447
+ return ok(finishDraft(opened.value.state.document, opened.value.state.snapshot));
448
+ },
340
449
  });
341
450
  return {
342
451
  add_block,
@@ -347,6 +456,5 @@ export function createReportAuthoringTools(state) {
347
456
  read_outline,
348
457
  read_block,
349
458
  finish_draft,
350
- currentDraft: () => draft,
351
459
  };
352
460
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The eyes tool of a report session.
3
+ *
4
+ * The tool opens the rendered page of a thread in headless Chrome, and it gives back what a reviewer would
5
+ * see: the screenshot, the console errors, and the failed requests. The agent reads the picture and the
6
+ * faults, decides, and repairs. The tool never judges, thus it never blocks the loop.
7
+ *
8
+ * The session tree has no URL space, thus the tool navigates to the page file through a `file://` URL. The
9
+ * page lives at `report-sessions/{threadId}/index.html` under the workspace root, which the preview writes.
10
+ * A missed page means that no preview ran, and it is a typed outcome, not a throw.
11
+ *
12
+ * The `file://` URL resolves on the filesystem of the Chrome sidecar, because the connection is out of
13
+ * process. Thus the sidecar must mount the workspace tree of the harness host at the same path. A sidecar
14
+ * with no such mount reports the page as an unreachable request, and the tool gives back that fault.
15
+ *
16
+ * A composition that names no browser and injects no capture seam has no eyes at all. The tool reports that
17
+ * condition once, up front, and it stamps nothing. A per-attempt capture failure would instead read as a
18
+ * transient fault and invite a repeat of a call that can never pass.
19
+ *
20
+ * On a capture the tool copies the rendered hash onto the seen hash through the gateway. Thus the look
21
+ * counts, and the record tool lets the current draft record. The copy takes the rendered hash and never the
22
+ * current one, thus a later edit makes the look stale and the record refuses.
23
+ *
24
+ * The gateway reports whether a rendered hash existed to copy. When the row holds none, no preview stamped
25
+ * one and the look cannot count. The tool then gives a missed-stamp outcome that directs a new preview,
26
+ * because a repeated look never stamps a marker that no preview wrote.
27
+ *
28
+ * The chrome navigation and the workspace-root seam each speak the throw protocol. The tool runs the capture
29
+ * inside a guard, thus a genuine fault becomes a typed outcome and a control-flow exception propagates.
30
+ */
31
+ import { z } from "zod";
32
+ import { type ChromeConfig } from "../../lib/chrome.js";
33
+ import { type Logger } from "../../lib/logger.js";
34
+ import { type CapturePage, type FailedRequest, type PageCapture } from "../../lib/page-capture.js";
35
+ import type { ResolveWorkspaceRoot } from "../../workspace/paths.js";
36
+ import { type Tool } from "../define-tool.js";
37
+ import { type ReportSessionStateGateway, type SessionRefusal } from "../report-authoring/authoring-tools.js";
38
+ export type { CapturePage, FailedRequest, PageCapture };
39
+ /** The empty input. The tool examines the current page of the thread, thus it needs no field. */
40
+ declare const examinePageInput: z.ZodObject<{}, z.core.$strip>;
41
+ export type ExaminePageInput = z.infer<typeof examinePageInput>;
42
+ /**
43
+ * The typed outcome of the eyes tool. Each arm is ok-channel data, thus the tool never throws for a
44
+ * degraded condition. `examined` carries the console errors, the failed requests, and the page path. The
45
+ * screenshot does not ride the JSON. It rides the image path of the tool result, thus the model sees the
46
+ * picture and the JSON text holds no bytes. `missed-stamp` means that the row holds no rendered hash, thus
47
+ * no preview stamped one and the agent must run a new preview before the next look. `no-browser` means that
48
+ * the composition gives no browser, thus no look is possible at all and a repeat gives the same answer.
49
+ */
50
+ export type ExaminePageResult = {
51
+ outcome: "refused";
52
+ refusal: SessionRefusal;
53
+ } | {
54
+ outcome: "no-browser";
55
+ detail: string;
56
+ } | {
57
+ outcome: "no-page";
58
+ } | {
59
+ outcome: "missed-stamp";
60
+ } | {
61
+ outcome: "capture-failed";
62
+ detail: string;
63
+ } | {
64
+ outcome: "examined";
65
+ consoleErrors: string[];
66
+ failedRequests: FailedRequest[];
67
+ pagePath: string;
68
+ };
69
+ /**
70
+ * The construction deps of the eyes tool.
71
+ *
72
+ * `resolveWorkspaceRoot` maps the analysis of the call onto its workspace root, thus one singleton tool
73
+ * serves every analysis. `chrome` configures the headless browser, and the sidecar that it names must mount
74
+ * the workspace tree at the same path, because the tool navigates to a `file://` URL. `capture` is optional
75
+ * and defaults to the shared capture, thus a test injects a seam that reads no browser.
76
+ */
77
+ export interface ExaminePageToolDeps {
78
+ readonly gateway: ReportSessionStateGateway;
79
+ readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
80
+ readonly chrome: ChromeConfig;
81
+ readonly capture?: CapturePage;
82
+ readonly logger?: Logger;
83
+ }
84
+ /**
85
+ * Make the eyes tool over the session-state gateway, the workspace-root seam, and the chrome config.
86
+ *
87
+ * The tool reads the thread id from the scope of the call, and it resolves the page path under the workspace
88
+ * root. Thus one factory serves every thread. The tool holds no per-session value.
89
+ */
90
+ export declare function createExaminePageTool(deps: ExaminePageToolDeps): Tool<ExaminePageInput, ExaminePageResult>;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The eyes tool of a report session.
3
+ *
4
+ * The tool opens the rendered page of a thread in headless Chrome, and it gives back what a reviewer would
5
+ * see: the screenshot, the console errors, and the failed requests. The agent reads the picture and the
6
+ * faults, decides, and repairs. The tool never judges, thus it never blocks the loop.
7
+ *
8
+ * The session tree has no URL space, thus the tool navigates to the page file through a `file://` URL. The
9
+ * page lives at `report-sessions/{threadId}/index.html` under the workspace root, which the preview writes.
10
+ * A missed page means that no preview ran, and it is a typed outcome, not a throw.
11
+ *
12
+ * The `file://` URL resolves on the filesystem of the Chrome sidecar, because the connection is out of
13
+ * process. Thus the sidecar must mount the workspace tree of the harness host at the same path. A sidecar
14
+ * with no such mount reports the page as an unreachable request, and the tool gives back that fault.
15
+ *
16
+ * A composition that names no browser and injects no capture seam has no eyes at all. The tool reports that
17
+ * condition once, up front, and it stamps nothing. A per-attempt capture failure would instead read as a
18
+ * transient fault and invite a repeat of a call that can never pass.
19
+ *
20
+ * On a capture the tool copies the rendered hash onto the seen hash through the gateway. Thus the look
21
+ * counts, and the record tool lets the current draft record. The copy takes the rendered hash and never the
22
+ * current one, thus a later edit makes the look stale and the record refuses.
23
+ *
24
+ * The gateway reports whether a rendered hash existed to copy. When the row holds none, no preview stamped
25
+ * one and the look cannot count. The tool then gives a missed-stamp outcome that directs a new preview,
26
+ * because a repeated look never stamps a marker that no preview wrote.
27
+ *
28
+ * The chrome navigation and the workspace-root seam each speak the throw protocol. The tool runs the capture
29
+ * inside a guard, thus a genuine fault becomes a typed outcome and a control-flow exception propagates.
30
+ */
31
+ import { ok } from "neverthrow";
32
+ import { access } from "node:fs/promises";
33
+ import { join } from "node:path";
34
+ import { pathToFileURL } from "node:url";
35
+ import { z } from "zod";
36
+ import { hasBrowserUrl } from "../../lib/chrome.js";
37
+ import { createNoopLogger } from "../../lib/console-logger.js";
38
+ import { defaultErrorFields } from "../../lib/logger.js";
39
+ import { capturePage } from "../../lib/page-capture.js";
40
+ import { defineTool, withToolResultImage } from "../define-tool.js";
41
+ import { openReportThread } from "../report-authoring/authoring-tools.js";
42
+ /** The empty input. The tool examines the current page of the thread, thus it needs no field. */
43
+ const examinePageInput = z.object({});
44
+ /** The IANA media type of the screenshot. `page.screenshot` gives PNG bytes. */
45
+ const SCREENSHOT_MEDIA_TYPE = "image/png";
46
+ /** The line that the agent reads when the composition gives no browser. */
47
+ const NO_BROWSER_DETAIL = "the composition gives no browser, thus this session cannot look at its page";
48
+ /**
49
+ * Make the eyes tool over the session-state gateway, the workspace-root seam, and the chrome config.
50
+ *
51
+ * The tool reads the thread id from the scope of the call, and it resolves the page path under the workspace
52
+ * root. Thus one factory serves every thread. The tool holds no per-session value.
53
+ */
54
+ export function createExaminePageTool(deps) {
55
+ const logger = (deps.logger ?? createNoopLogger()).named("examine-page");
56
+ // An injected seam is the eyes of a test. A named browser is the eyes of a deployment. With neither, the
57
+ // composition has no eyes, and that answer is fixed at construction.
58
+ const eyesAvailable = deps.capture !== undefined || hasBrowserUrl(deps.chrome);
59
+ const capture = deps.capture ?? ((url) => capturePage(deps.chrome, url));
60
+ return defineTool({
61
+ id: "examine_page",
62
+ description: "Open the rendered report page in a real headless browser, and report what you see. " +
63
+ "Give back a screenshot, the console errors, and the failed requests. " +
64
+ "Run it after the preview to look at the current page, and to confirm that the layout and the charts read clean. " +
65
+ "The report tool records a version only after you look at the current page. " +
66
+ "If the page has no confirmed render, run the preview again first.",
67
+ inputSchema: examinePageInput,
68
+ executionMode: "inline",
69
+ describeCall: "none",
70
+ execute: async (_input, ctx) => {
71
+ // The check runs before every read, thus one clear signal replaces a capture failure for each
72
+ // page. The arm stamps no seen hash, because no eyes saw the page.
73
+ if (!eyesAvailable) {
74
+ logger.warn("the composition gives no browser, thus no look can run");
75
+ return ok({ outcome: "no-browser", detail: NO_BROWSER_DETAIL });
76
+ }
77
+ const opened = await openReportThread(deps.gateway, ctx.session.scope);
78
+ if (opened.isErr()) {
79
+ return ok({ outcome: "refused", refusal: opened.error });
80
+ }
81
+ const { threadId, analysisId } = opened.value;
82
+ let root;
83
+ try {
84
+ root = deps.resolveWorkspaceRoot(analysisId);
85
+ }
86
+ catch (cause) {
87
+ logger.warn("the workspace root did not resolve", { threadId, analysisId, ...defaultErrorFields(cause) });
88
+ return ok({ outcome: "capture-failed", detail: "the workspace root did not resolve" });
89
+ }
90
+ const relativePagePath = join("report-sessions", threadId, "index.html");
91
+ const pagePath = join(root, relativePagePath);
92
+ try {
93
+ await access(pagePath);
94
+ }
95
+ catch (cause) {
96
+ if (cause.code === "ENOENT") {
97
+ // No preview wrote the page yet. The agent runs the preview first.
98
+ return ok({ outcome: "no-page" });
99
+ }
100
+ logger.warn("the session page could not be read", { threadId, analysisId, ...defaultErrorFields(cause) });
101
+ return ok({ outcome: "capture-failed", detail: "the session page could not be read" });
102
+ }
103
+ let captured;
104
+ try {
105
+ captured = await capture(pathToFileURL(pagePath).href);
106
+ }
107
+ catch (cause) {
108
+ logger.warn("the page capture failed", { threadId, analysisId, ...defaultErrorFields(cause) });
109
+ return ok({ outcome: "capture-failed", detail: cause instanceof Error ? cause.message : String(cause) });
110
+ }
111
+ const stamped = await deps.gateway.stampSeen(threadId);
112
+ if (stamped.outcome === "no-rendered") {
113
+ // The row holds no rendered hash, thus no preview stamped one and the look cannot count. A
114
+ // repeated look never fixes this, because it copies a marker that no preview wrote. The agent
115
+ // runs a new preview, which stamps the rendered hash for the next look.
116
+ logger.warn("the seen stamp found no rendered hash", { threadId, analysisId });
117
+ return ok({ outcome: "missed-stamp" });
118
+ }
119
+ // A transient stamp fault and an absent row each leave the seen hash short, and the record gate
120
+ // then refuses a never-seen page. A later look re-stamps the marker, thus the picture stays valid
121
+ // and the tool gives it back.
122
+ if (stamped.outcome !== "stamped") {
123
+ const detail = stamped.outcome === "failed" ? stamped.detail : "the session state row is absent";
124
+ logger.warn("the seen hash did not stamp", { threadId, analysisId, detail });
125
+ }
126
+ // The screenshot rides the image path of the tool result, thus the model sees the picture. The
127
+ // JSON keeps the faults and the page path only, thus the JSON text holds no bytes.
128
+ return ok(withToolResultImage({
129
+ outcome: "examined",
130
+ consoleErrors: captured.consoleErrors,
131
+ failedRequests: captured.failedRequests,
132
+ pagePath: relativePagePath,
133
+ }, { base64: captured.screenshotBase64, mediaType: SCREENSHOT_MEDIA_TYPE }));
134
+ },
135
+ });
136
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The report-session tool barrel.
3
+ *
4
+ * These tools serve a `report` thread. The runtime constructs them over the session-state gateway and the
5
+ * render seams, thus each tool binds the per-session state to the thread of the call. The barrel exports
6
+ * the factories and their public types for the composition root.
7
+ */
8
+ export { createPreviewReportTool, type PreviewReportInput, type PreviewReportResult, type PreviewReportToolDeps } from "./preview-report.js";
9
+ export { createExaminePageTool, type CapturePage, type ExaminePageInput, type ExaminePageResult, type ExaminePageToolDeps, type FailedRequest, type PageCapture, } from "./examine-page.js";
10
+ export { createRecordVersionTool, type RecordVersionInput, type RecordVersionResult, type RecordVersionToolDeps } from "./record-version.js";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The report-session tool barrel.
3
+ *
4
+ * These tools serve a `report` thread. The runtime constructs them over the session-state gateway and the
5
+ * render seams, thus each tool binds the per-session state to the thread of the call. The barrel exports
6
+ * the factories and their public types for the composition root.
7
+ */
8
+ export { createPreviewReportTool } from "./preview-report.js";
9
+ export { createExaminePageTool, } from "./examine-page.js";
10
+ export { createRecordVersionTool } from "./record-version.js";