@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
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The render-and-preview tool of a report session.
3
+ *
4
+ * The tool renders the current draft of a thread. It runs the finish first, because a mid-composition
5
+ * draft is not a `ReportDocument`. A gap list returns as data, and no render runs. On a pass the tool
6
+ * resolves each reference through the injected resolver, bridges the resolved values into the render model,
7
+ * and renders the page.
8
+ *
9
+ * The page and its staged image assets land in `report-sessions/{threadId}/` under the workspace root. That
10
+ * namespace belongs to this path alone, thus the tool never writes under the old `previews/` or `reports/`
11
+ * trees. The result carries the absolute page path, thus a local host shows the page with no seam.
12
+ *
13
+ * The result carries no access grant. `PreviewPublisher` authorizes the URL space
14
+ * `previews/{analysisId}/{previewId}` (`contracts/content-url.ts`), and that space cannot name a page of
15
+ * this tree. A hosted view of a session page is a later capability, with a URL space of its own.
16
+ *
17
+ * Each degraded condition is a typed outcome in the ok channel: a session refusal, a gap list, a resolver
18
+ * absence, an unresolvable root at resolver construction, an unresolved reference, a bridge mismatch, a
19
+ * render problem, a figure that escapes the workspace root, a write failure, and a stamp failure. The tool
20
+ * never throws for one of them. When the page
21
+ * lands, the tool stamps the hash of the rendered draft on the session state, thus the eyes and the record
22
+ * know which draft the page shows. The filesystem speaks the
23
+ * throw protocol, and the workspace-root seam signals an unresolvable resource the same way. Thus the
24
+ * write runs through the `tryFs` glue, which turns a genuine fault into the ok-channel outcome and lets a
25
+ * control-flow exception propagate.
26
+ */
27
+ import { z } from "zod";
28
+ import { type ResolveWorkspaceRoot } from "../../workspace/paths.js";
29
+ import type { AuthContext } from "../../auth/types.js";
30
+ import { type Logger } from "../../lib/logger.js";
31
+ import { type FinishGap } from "../../report-model/draft-finish.js";
32
+ import type { ReferenceResolver } from "../../report-model/reference-resolver.js";
33
+ import { type ResolutionFailure } from "../../report-model/validate.js";
34
+ import { type BridgeMismatch } from "../../report-render/value-bridge.js";
35
+ import type { RenderProblem } from "../../report-render/types.js";
36
+ import { type Tool } from "../define-tool.js";
37
+ import { type ReportSessionStateGateway, type SessionRefusal } from "../report-authoring/authoring-tools.js";
38
+ /** The empty input. The tool renders the current draft of the thread, thus it needs no field. */
39
+ declare const previewReportInput: z.ZodObject<{}, z.core.$strip>;
40
+ export type PreviewReportInput = z.infer<typeof previewReportInput>;
41
+ /**
42
+ * The typed outcome of the preview tool. Each arm is ok-channel data, thus the tool never throws for a
43
+ * degraded condition. `rendered` carries the absolute page path.
44
+ */
45
+ export type PreviewReportResult = {
46
+ outcome: "refused";
47
+ refusal: SessionRefusal;
48
+ } | {
49
+ outcome: "gaps";
50
+ gaps: FinishGap[];
51
+ } | {
52
+ outcome: "resolver-unavailable";
53
+ } | {
54
+ outcome: "root-unresolvable";
55
+ detail: string;
56
+ } | {
57
+ outcome: "unresolved-references";
58
+ unresolved: ResolutionFailure[];
59
+ } | {
60
+ outcome: "bridge-mismatch";
61
+ mismatches: BridgeMismatch[];
62
+ } | {
63
+ outcome: "render-problems";
64
+ problems: RenderProblem[];
65
+ } | {
66
+ outcome: "figure-out-of-scope";
67
+ blockId: string;
68
+ path: string;
69
+ } | {
70
+ outcome: "write-failed";
71
+ detail: string;
72
+ } | {
73
+ outcome: "stamp-failed";
74
+ pagePath: string;
75
+ detail: string;
76
+ } | {
77
+ outcome: "rendered";
78
+ pagePath: string;
79
+ };
80
+ /**
81
+ * The construction deps of the preview tool.
82
+ *
83
+ * `resolveWorkspaceRoot` maps the analysis of the call onto its workspace root, thus one singleton tool
84
+ * serves every analysis and it resolves the root per call from the scope. `makeResolver` is optional, because
85
+ * a resolver realization can be absent. It binds one analysis, thus the tool makes the resolver over the
86
+ * scope of the call.
87
+ */
88
+ export interface PreviewReportToolDeps {
89
+ readonly gateway: ReportSessionStateGateway;
90
+ readonly makeResolver?: (scope: {
91
+ analysisId: string;
92
+ auth: AuthContext;
93
+ }) => ReferenceResolver;
94
+ readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
95
+ readonly logger?: Logger;
96
+ }
97
+ /**
98
+ * Make the render-and-preview tool over the session-state gateway and the render seams.
99
+ *
100
+ * The tool reads the thread id from the scope of the call, and it loads the state through the gateway. Thus
101
+ * one factory serves every thread. The tool holds no per-session value, and it resolves the root of the
102
+ * analysis of the call.
103
+ */
104
+ export declare function createPreviewReportTool(deps: PreviewReportToolDeps): Tool<PreviewReportInput, PreviewReportResult>;
105
+ export {};
@@ -0,0 +1,269 @@
1
+ /**
2
+ * The render-and-preview tool of a report session.
3
+ *
4
+ * The tool renders the current draft of a thread. It runs the finish first, because a mid-composition
5
+ * draft is not a `ReportDocument`. A gap list returns as data, and no render runs. On a pass the tool
6
+ * resolves each reference through the injected resolver, bridges the resolved values into the render model,
7
+ * and renders the page.
8
+ *
9
+ * The page and its staged image assets land in `report-sessions/{threadId}/` under the workspace root. That
10
+ * namespace belongs to this path alone, thus the tool never writes under the old `previews/` or `reports/`
11
+ * trees. The result carries the absolute page path, thus a local host shows the page with no seam.
12
+ *
13
+ * The result carries no access grant. `PreviewPublisher` authorizes the URL space
14
+ * `previews/{analysisId}/{previewId}` (`contracts/content-url.ts`), and that space cannot name a page of
15
+ * this tree. A hosted view of a session page is a later capability, with a URL space of its own.
16
+ *
17
+ * Each degraded condition is a typed outcome in the ok channel: a session refusal, a gap list, a resolver
18
+ * absence, an unresolvable root at resolver construction, an unresolved reference, a bridge mismatch, a
19
+ * render problem, a figure that escapes the workspace root, a write failure, and a stamp failure. The tool
20
+ * never throws for one of them. When the page
21
+ * lands, the tool stamps the hash of the rendered draft on the session state, thus the eyes and the record
22
+ * know which draft the page shows. The filesystem speaks the
23
+ * throw protocol, and the workspace-root seam signals an unresolvable resource the same way. Thus the
24
+ * write runs through the `tryFs` glue, which turns a genuine fault into the ok-channel outcome and lets a
25
+ * control-flow exception propagate.
26
+ */
27
+ import { err, ok } from "neverthrow";
28
+ import { copyFile, mkdir, writeFile } from "node:fs/promises";
29
+ import { extname, join } from "node:path";
30
+ import { z } from "zod";
31
+ import { resolveWorkspacePath } from "../../workspace/paths.js";
32
+ import { createNoopLogger } from "../../lib/console-logger.js";
33
+ import { describeFsError, tryFsWrite } from "../../lib/fs-result.js";
34
+ import { defaultErrorFields } from "../../lib/logger.js";
35
+ import { computeDraftHash } from "../../report-model/draft-hash.js";
36
+ import { finishDraft } from "../../report-model/draft-finish.js";
37
+ import { resolveDocumentReferences } from "../../report-model/validate.js";
38
+ import { bridgeValues } from "../../report-render/value-bridge.js";
39
+ import { renderReportPage } from "../../report-render/render.js";
40
+ import { defineTool } from "../define-tool.js";
41
+ import { openReportThread } from "../report-authoring/authoring-tools.js";
42
+ /** The empty input. The tool renders the current draft of the thread, thus it needs no field. */
43
+ const previewReportInput = z.object({});
44
+ /**
45
+ * The staged file name of one bound image: its content hash with the original extension.
46
+ *
47
+ * The colon of the `algorithm:hex` hash is not safe in a file name and in a relative source, thus the slug
48
+ * replaces each non-word run with one dash. The hash is unique, thus two figures with one basename cannot
49
+ * collide on the staged name.
50
+ */
51
+ function assetFileName(file) {
52
+ const slug = file.hash.replace(/[^a-z0-9]+/gi, "-");
53
+ return slug + extname(file.path);
54
+ }
55
+ /** The relative source of a staged figure. The page directory is self-contained, thus the source is `assets/<name>`. */
56
+ function figureSourcePolicy(file) {
57
+ return `assets/${assetFileName(file)}`;
58
+ }
59
+ /**
60
+ * Pair each block with the resolved value of its binding, in document order.
61
+ *
62
+ * A value-bearing kind carries the resolved value of its one reference, looked up by the block id from
63
+ * the map that the resolution pass filled. A no-value kind carries none. Where a reference sits in a
64
+ * block is the knowledge of `block-walk.ts`, thus this walk reads the block id alone and never a binding
65
+ * field.
66
+ *
67
+ * The switch is exhaustive over the eight block kinds. A ninth kind reaches the end with no return, and
68
+ * the declared return type fails the build. Thus the walk cannot drop a kind in silence. A value-bearing
69
+ * block reaches here only when its reference resolved, because an unresolved reference short-circuits
70
+ * before this walk.
71
+ */
72
+ function collectResolutions(blocks, resolvedByBlock) {
73
+ const resolutions = [];
74
+ const visit = (block) => {
75
+ switch (block.kind) {
76
+ case "section":
77
+ resolutions.push({ blockId: block.id, kind: "section" });
78
+ for (const child of block.blocks) {
79
+ visit(child);
80
+ }
81
+ return;
82
+ case "text":
83
+ case "claim":
84
+ case "citation":
85
+ resolutions.push({ blockId: block.id, kind: block.kind });
86
+ return;
87
+ case "metric":
88
+ case "table":
89
+ case "chart":
90
+ case "figure": {
91
+ const resolved = resolvedByBlock.get(block.id);
92
+ if (resolved !== undefined) {
93
+ resolutions.push({ blockId: block.id, kind: block.kind, resolved });
94
+ }
95
+ return;
96
+ }
97
+ }
98
+ };
99
+ for (const block of blocks) {
100
+ visit(block);
101
+ }
102
+ return resolutions;
103
+ }
104
+ /**
105
+ * Resolve each reference of the document, and split the resolutions from the unresolved references.
106
+ *
107
+ * The shared `resolveDocumentReferences` walks the tree, resolves each reference under the concurrency
108
+ * bound, and runs the chart-encoding match, thus the preview and the record gate refuse the same
109
+ * references. On a clean pass the resolutions map onto the render model in document order.
110
+ */
111
+ async function resolveDocument(document, resolver, snapshot) {
112
+ const { resolvedByBlock, failures } = await resolveDocumentReferences(document.sections, snapshot, resolver);
113
+ if (failures.length > 0) {
114
+ return { resolutions: [], unresolved: failures };
115
+ }
116
+ return { resolutions: collectResolutions(document.sections, resolvedByBlock), unresolved: [] };
117
+ }
118
+ /**
119
+ * Resolve the page root, stage each bound image, and write the page.
120
+ *
121
+ * The workspace-root seam signals an unresolvable resource by a throw, thus the resolution sits inside
122
+ * the protection and its throw becomes a value. Each `fs` call runs through `tryFsWrite`, the one
123
+ * sanctioned `fs` guard, thus a genuine I/O fault becomes a value and a control-flow exception such as a
124
+ * cancellation propagates.
125
+ *
126
+ * A bound figure names a snapshot path, which is untrusted. A registered `../../` path escapes the root,
127
+ * thus the containment test runs before any copy and reuses the one workspace-path resolver. A source
128
+ * outside the root refuses and names the block, and no copy runs.
129
+ */
130
+ async function renderToWorkspace(args) {
131
+ let root;
132
+ try {
133
+ root = args.resolveWorkspaceRoot(args.analysisId);
134
+ }
135
+ catch (cause) {
136
+ return err({ kind: "fs", error: { type: "read_failed", op: "preview.resolveWorkspaceRoot", path: args.analysisId, cause } });
137
+ }
138
+ const sessionDir = join(root, "report-sessions", args.threadId);
139
+ const assetsDir = join(sessionDir, "assets");
140
+ const pagePath = join(sessionDir, "index.html");
141
+ // Each bound figure, contained and deduplicated by its staged name, mapped to its host source.
142
+ const sources = new Map();
143
+ for (const resolution of args.resolutions) {
144
+ if (resolution.kind !== "figure" || resolution.resolved.type !== "file") {
145
+ continue;
146
+ }
147
+ const file = resolution.resolved;
148
+ const name = assetFileName(file);
149
+ if (sources.has(name)) {
150
+ continue;
151
+ }
152
+ const resolved = resolveWorkspacePath({ workspaceRoot: root, analysisId: args.analysisId, path: file.path });
153
+ if (resolved.kind !== "ok") {
154
+ return err({ kind: "figure-out-of-scope", blockId: resolution.blockId, path: file.path });
155
+ }
156
+ sources.set(name, resolved.absolute);
157
+ }
158
+ const madeSession = await tryFsWrite("preview.mkdir", () => mkdir(sessionDir, { recursive: true }), { path: sessionDir });
159
+ if (madeSession.isErr()) {
160
+ return err({ kind: "fs", error: madeSession.error });
161
+ }
162
+ if (sources.size > 0) {
163
+ const madeAssets = await tryFsWrite("preview.mkdir", () => mkdir(assetsDir, { recursive: true }), { path: assetsDir });
164
+ if (madeAssets.isErr()) {
165
+ return err({ kind: "fs", error: madeAssets.error });
166
+ }
167
+ for (const [name, source] of sources) {
168
+ const copied = await tryFsWrite("preview.copyFile", () => copyFile(source, join(assetsDir, name)), { path: source });
169
+ if (copied.isErr()) {
170
+ return err({ kind: "fs", error: copied.error });
171
+ }
172
+ }
173
+ }
174
+ const wrote = await tryFsWrite("preview.writeFile", () => writeFile(pagePath, args.page, "utf8"), { path: pagePath });
175
+ if (wrote.isErr()) {
176
+ return err({ kind: "fs", error: wrote.error });
177
+ }
178
+ return ok(pagePath);
179
+ }
180
+ /**
181
+ * Make the render-and-preview tool over the session-state gateway and the render seams.
182
+ *
183
+ * The tool reads the thread id from the scope of the call, and it loads the state through the gateway. Thus
184
+ * one factory serves every thread. The tool holds no per-session value, and it resolves the root of the
185
+ * analysis of the call.
186
+ */
187
+ export function createPreviewReportTool(deps) {
188
+ const logger = (deps.logger ?? createNoopLogger()).named("preview-report");
189
+ return defineTool({
190
+ id: "preview_report",
191
+ description: "Render the current draft to a self-contained HTML page, and give back the page path. " +
192
+ "The tool finishes the draft first: an incomplete draft gives back the gap list, and no page renders. " +
193
+ "On a pass it resolves each reference, stages each bound image beside the page, and writes the page. " +
194
+ "Use it to see the report, and to confirm that each reference resolves.",
195
+ inputSchema: previewReportInput,
196
+ executionMode: "inline",
197
+ describeCall: "none",
198
+ execute: async (_input, ctx) => {
199
+ const opened = await openReportThread(deps.gateway, ctx.session.scope);
200
+ if (opened.isErr()) {
201
+ return ok({ outcome: "refused", refusal: opened.error });
202
+ }
203
+ const { threadId, analysisId, state } = opened.value;
204
+ const { document: draft, snapshot } = state;
205
+ const finished = finishDraft(draft, snapshot);
206
+ if (!finished.valid) {
207
+ return ok({ outcome: "gaps", gaps: finished.gaps });
208
+ }
209
+ const document = finished.document;
210
+ if (deps.makeResolver === undefined) {
211
+ return ok({ outcome: "resolver-unavailable" });
212
+ }
213
+ // The resolver construction resolves the workspace root inside, and that seam throws on an
214
+ // unresolvable root. The guard turns the throw into a typed outcome that names the fault, thus a
215
+ // control-flow exception propagates and the unresolvable root does not.
216
+ let resolver;
217
+ try {
218
+ resolver = deps.makeResolver({ analysisId, auth: ctx.session.auth });
219
+ }
220
+ catch (cause) {
221
+ logger.warn("the workspace root did not resolve", { threadId, analysisId, ...defaultErrorFields(cause) });
222
+ return ok({ outcome: "root-unresolvable", detail: "the workspace root did not resolve" });
223
+ }
224
+ const { resolutions, unresolved } = await resolveDocument(document, resolver, snapshot);
225
+ if (unresolved.length > 0) {
226
+ return ok({ outcome: "unresolved-references", unresolved });
227
+ }
228
+ const bridged = bridgeValues(resolutions, figureSourcePolicy);
229
+ if (bridged.isErr()) {
230
+ return ok({ outcome: "bridge-mismatch", mismatches: bridged.error });
231
+ }
232
+ const rendered = renderReportPage(document, bridged.value);
233
+ if (rendered.isErr()) {
234
+ return ok({ outcome: "render-problems", problems: rendered.error });
235
+ }
236
+ const written = await renderToWorkspace({
237
+ resolveWorkspaceRoot: deps.resolveWorkspaceRoot,
238
+ analysisId,
239
+ threadId,
240
+ resolutions,
241
+ page: rendered.value,
242
+ });
243
+ if (written.isErr()) {
244
+ const failure = written.error;
245
+ if (failure.kind === "figure-out-of-scope") {
246
+ // A bound figure names a path outside the workspace root. The record gate refuses the
247
+ // same figure, thus the preview refuses it too and names the block.
248
+ logger.warn("a bound figure escapes the workspace root", { threadId, analysisId, blockId: failure.blockId, path: failure.path });
249
+ return ok({ outcome: "figure-out-of-scope", blockId: failure.blockId, path: failure.path });
250
+ }
251
+ // An unresolvable root, a full volume, and a denied write each arrive here as a value. The
252
+ // tool contract is ok-channel data for each degraded condition, thus the fault becomes an
253
+ // outcome. The log keeps the full fault, because the outcome carries the description alone.
254
+ logger.warn("the page did not land", { threadId, analysisId, ...defaultErrorFields(failure.error.cause) });
255
+ return ok({ outcome: "write-failed", detail: describeFsError(failure.error) });
256
+ }
257
+ // The page shows the draft that the finish read. The stamp records that draft, thus the eyes
258
+ // copy this hash and the record compares against it. A failed stamp is a transient store fault,
259
+ // and the page stays on disk. The agent runs the preview again to stamp the marker.
260
+ const stamped = await deps.gateway.stampRendered(threadId, computeDraftHash(draft));
261
+ if (stamped.outcome !== "stamped") {
262
+ const detail = stamped.outcome === "failed" ? stamped.detail : "the session state row is absent";
263
+ logger.warn("the rendered hash did not stamp", { threadId, analysisId, detail });
264
+ return ok({ outcome: "stamp-failed", pagePath: written.value, detail });
265
+ }
266
+ return ok({ outcome: "rendered", pagePath: written.value });
267
+ },
268
+ });
269
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The record tool of a report session.
3
+ *
4
+ * The tool records one version of the report. The store is append-only with no delete, thus a version that
5
+ * the gate did not accept must never exist. The tool runs the whole gate first, and only a pass reaches the
6
+ * store.
7
+ *
8
+ * The gate has three parts, in order of cost. The finish gates the schema, the unique ids, and the
9
+ * structural tier, and a gap list returns as data. The look-before-record rule then compares the seen hash
10
+ * against the hash of the current draft, on the state that the load already read. A never-seen page and a
11
+ * stale look each refuse with a distinct reason, and the rule reads no model judgment. The full validation
12
+ * resolves every reference through the injected resolver, matches each chart encoding, and matches each
13
+ * assert. A failure names the block that holds the failed part, thus the agent repairs one block and not the
14
+ * report at large.
15
+ *
16
+ * The look runs before the validation on purpose. The validation builds the resolver and its prepare pass
17
+ * can start a container, thus the cheap look guards the expensive pass. A never-seen or stale draft refuses
18
+ * before the resolver constructs.
19
+ *
20
+ * On a pass the store records the document that the gate validated, the pinned snapshot, and the anchor from
21
+ * the thread row. The one-per-thread rule of the store bounds a concurrent race, and that refusal returns as
22
+ * data. The outcome of a pass carries the version id.
23
+ */
24
+ import { z } from "zod";
25
+ import type { AuthContext } from "../../auth/types.js";
26
+ import type { Logger } from "../../lib/logger.js";
27
+ import type { ThreadStore } from "../../memory/thread-store.js";
28
+ import { type FinishGap } from "../../report-model/draft-finish.js";
29
+ import type { ReferenceResolver } from "../../report-model/reference-resolver.js";
30
+ import { type ResolutionFailure, type SchemaIssue } from "../../report-model/validate.js";
31
+ import type { ReportVersionStore } from "../../state/report-versions.js";
32
+ import { type Tool } from "../define-tool.js";
33
+ import { type ReportSessionStateGateway, type SessionRefusal } from "../report-authoring/authoring-tools.js";
34
+ /** The empty input. The tool records the current draft of the thread, thus it needs no field. */
35
+ declare const recordVersionInput: z.ZodObject<{}, z.core.$strip>;
36
+ export type RecordVersionInput = z.infer<typeof recordVersionInput>;
37
+ /**
38
+ * The typed outcome of the record tool. Each arm is ok-channel data, thus the tool never throws for a
39
+ * degraded condition.
40
+ *
41
+ * `gaps` names each completeness gap of the draft. `never-seen` means that no eyes ran on the current draft.
42
+ * `stale-look` means that the eyes ran, and the agent then changed the draft. `root-unresolvable` means that
43
+ * the resolver construction cannot resolve the workspace root. `invalid` names each gate failure, and a
44
+ * resolution failure carries the block that holds it. `already-recorded` means that the thread already holds
45
+ * a version. `recorded` carries the version id.
46
+ */
47
+ export type RecordVersionResult = {
48
+ outcome: "refused";
49
+ refusal: SessionRefusal;
50
+ } | {
51
+ outcome: "gaps";
52
+ gaps: FinishGap[];
53
+ } | {
54
+ outcome: "never-seen";
55
+ } | {
56
+ outcome: "stale-look";
57
+ } | {
58
+ outcome: "resolver-unavailable";
59
+ } | {
60
+ outcome: "root-unresolvable";
61
+ detail: string;
62
+ } | {
63
+ outcome: "invalid";
64
+ schemaIssues?: SchemaIssue[];
65
+ duplicateIds?: string[];
66
+ resolutionFailures?: ResolutionFailure[];
67
+ } | {
68
+ outcome: "already-recorded";
69
+ } | {
70
+ outcome: "record-failed";
71
+ detail: string;
72
+ } | {
73
+ outcome: "recorded";
74
+ versionId: string;
75
+ };
76
+ /**
77
+ * The construction deps of the record tool.
78
+ *
79
+ * `store` is the append-only version store. `threads` reads the anchor of the report thread, thus the
80
+ * version carries the parent conversation and the transcript position. `makeResolver` is optional, because a
81
+ * resolver realization can be absent, and the gate needs one. It binds one analysis, thus the tool makes the
82
+ * resolver over the scope of the call.
83
+ */
84
+ export interface RecordVersionToolDeps {
85
+ readonly gateway: ReportSessionStateGateway;
86
+ readonly store: ReportVersionStore;
87
+ readonly threads: Pick<ThreadStore, "getThread">;
88
+ readonly makeResolver?: (scope: {
89
+ analysisId: string;
90
+ auth: AuthContext;
91
+ }) => ReferenceResolver;
92
+ readonly logger?: Logger;
93
+ }
94
+ /**
95
+ * Make the record tool over the session-state gateway, the version store, the thread store, and the
96
+ * resolver.
97
+ *
98
+ * The tool reads the thread id from the scope of the call, and it loads the state through the gateway. Thus
99
+ * one factory serves every thread. The tool holds no per-session value.
100
+ */
101
+ export declare function createRecordVersionTool(deps: RecordVersionToolDeps): Tool<RecordVersionInput, RecordVersionResult>;
102
+ export {};
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The record tool of a report session.
3
+ *
4
+ * The tool records one version of the report. The store is append-only with no delete, thus a version that
5
+ * the gate did not accept must never exist. The tool runs the whole gate first, and only a pass reaches the
6
+ * store.
7
+ *
8
+ * The gate has three parts, in order of cost. The finish gates the schema, the unique ids, and the
9
+ * structural tier, and a gap list returns as data. The look-before-record rule then compares the seen hash
10
+ * against the hash of the current draft, on the state that the load already read. A never-seen page and a
11
+ * stale look each refuse with a distinct reason, and the rule reads no model judgment. The full validation
12
+ * resolves every reference through the injected resolver, matches each chart encoding, and matches each
13
+ * assert. A failure names the block that holds the failed part, thus the agent repairs one block and not the
14
+ * report at large.
15
+ *
16
+ * The look runs before the validation on purpose. The validation builds the resolver and its prepare pass
17
+ * can start a container, thus the cheap look guards the expensive pass. A never-seen or stale draft refuses
18
+ * before the resolver constructs.
19
+ *
20
+ * On a pass the store records the document that the gate validated, the pinned snapshot, and the anchor from
21
+ * the thread row. The one-per-thread rule of the store bounds a concurrent race, and that refusal returns as
22
+ * data. The outcome of a pass carries the version id.
23
+ */
24
+ import { ok } from "neverthrow";
25
+ import { z } from "zod";
26
+ import { createNoopLogger } from "../../lib/console-logger.js";
27
+ import { describeDbError } from "../../lib/db-result.js";
28
+ import { finishDraft } from "../../report-model/draft-finish.js";
29
+ import { computeDraftHash } from "../../report-model/draft-hash.js";
30
+ import { validateReport } from "../../report-model/validate.js";
31
+ import { defineTool } from "../define-tool.js";
32
+ import { openReportThread } from "../report-authoring/authoring-tools.js";
33
+ /** The empty input. The tool records the current draft of the thread, thus it needs no field. */
34
+ const recordVersionInput = z.object({});
35
+ /** A short account of a record refusal, for the detail of a transient failure. */
36
+ function describeRecordFailure(error) {
37
+ switch (error.type) {
38
+ case "malformed_document":
39
+ return "the gate accepted a document that the store read as malformed";
40
+ case "malformed_snapshot":
41
+ return "the pinned snapshot is malformed";
42
+ case "parent_analysis_mismatch":
43
+ return "the parent version belongs to a different analysis";
44
+ case "thread_already_holds_version":
45
+ return "the thread already holds a recorded version";
46
+ default:
47
+ return describeDbError(error);
48
+ }
49
+ }
50
+ /**
51
+ * Make the record tool over the session-state gateway, the version store, the thread store, and the
52
+ * resolver.
53
+ *
54
+ * The tool reads the thread id from the scope of the call, and it loads the state through the gateway. Thus
55
+ * one factory serves every thread. The tool holds no per-session value.
56
+ */
57
+ export function createRecordVersionTool(deps) {
58
+ const logger = (deps.logger ?? createNoopLogger()).named("record-report-version");
59
+ return defineTool({
60
+ id: "record_report_version",
61
+ description: "Record the current draft as one report version. The tool runs the whole gate first: it finishes the draft, " +
62
+ "resolves each reference, matches each chart encoding, and matches each assert. An incomplete draft gives back the gap list, " +
63
+ "and a failed reference gives back the block that broke. The tool records a version only after the eyes look at the current page. " +
64
+ "A thread holds one version, thus a second record gives back that the thread already holds one.",
65
+ inputSchema: recordVersionInput,
66
+ executionMode: "inline",
67
+ describeCall: "none",
68
+ execute: async (_input, ctx) => {
69
+ const opened = await openReportThread(deps.gateway, ctx.session.scope);
70
+ if (opened.isErr()) {
71
+ return ok({ outcome: "refused", refusal: opened.error });
72
+ }
73
+ const { threadId, analysisId, state, seenDocumentHash } = opened.value;
74
+ const { document: draft, snapshot } = state;
75
+ const finished = finishDraft(draft, snapshot);
76
+ if (!finished.valid) {
77
+ return ok({ outcome: "gaps", gaps: finished.gaps });
78
+ }
79
+ // The look-before-record rule runs before the resolver and the validation, on the state that the
80
+ // load already read. The preview stamps the rendered hash, and the eyes copy it onto the seen
81
+ // hash. A never-seen page carries no seen hash. A stale look carries the hash of an earlier draft,
82
+ // thus a mismatch against the current draft refuses. Each refusal skips the expensive validation.
83
+ const currentHash = computeDraftHash(draft);
84
+ if (seenDocumentHash === null) {
85
+ return ok({ outcome: "never-seen" });
86
+ }
87
+ if (seenDocumentHash !== currentHash) {
88
+ return ok({ outcome: "stale-look" });
89
+ }
90
+ if (deps.makeResolver === undefined) {
91
+ return ok({ outcome: "resolver-unavailable" });
92
+ }
93
+ // The resolver construction resolves the workspace root inside, and that seam throws on an
94
+ // unresolvable root. The guard turns the throw into a typed outcome that names the fault, thus a
95
+ // control-flow exception propagates and the unresolvable root does not.
96
+ let resolver;
97
+ try {
98
+ resolver = deps.makeResolver({ analysisId, auth: ctx.session.auth });
99
+ }
100
+ catch (cause) {
101
+ logger.warn("the workspace root did not resolve", { threadId, analysisId, ...logger.errorFields(cause) });
102
+ return ok({ outcome: "root-unresolvable", detail: "the workspace root did not resolve" });
103
+ }
104
+ const validation = await validateReport(finished.document, snapshot, resolver);
105
+ if (!validation.valid) {
106
+ return ok({
107
+ outcome: "invalid",
108
+ ...(validation.schemaIssues ? { schemaIssues: validation.schemaIssues } : {}),
109
+ ...(validation.duplicateIds ? { duplicateIds: validation.duplicateIds } : {}),
110
+ ...(validation.resolutionFailures ? { resolutionFailures: validation.resolutionFailures } : {}),
111
+ });
112
+ }
113
+ const thread = await deps.threads.getThread(threadId);
114
+ if (thread.isErr()) {
115
+ logger.warn("the thread row read failed", { threadId, analysisId, ...logger.errorFields(thread.error.cause) });
116
+ return ok({ outcome: "record-failed", detail: describeDbError(thread.error) });
117
+ }
118
+ if (thread.value === null) {
119
+ logger.warn("the report thread row is absent at record", { threadId, analysisId });
120
+ return ok({ outcome: "record-failed", detail: "the report thread row is absent" });
121
+ }
122
+ const recorded = await deps.store.record({
123
+ document: finished.document,
124
+ snapshot,
125
+ analysisId,
126
+ threadId,
127
+ parentThreadId: thread.value.parentThreadId,
128
+ parentSeq: thread.value.parentSeq,
129
+ });
130
+ return recorded.match((ref) => ok({ outcome: "recorded", versionId: ref.versionId }), (error) => {
131
+ if (error.type === "thread_already_holds_version") {
132
+ return ok({ outcome: "already-recorded" });
133
+ }
134
+ logger.warn("the version did not record", { threadId, analysisId, reason: error.type });
135
+ return ok({ outcome: "record-failed", detail: describeRecordFailure(error) });
136
+ });
137
+ },
138
+ });
139
+ }
@@ -30,6 +30,7 @@ import { composeSystemPrompt } from "../../agents/system-prompt.js";
30
30
  import { DEFAULT_SALVAGE_ITERATIONS, runToTerminal } from "../../loop/run-to-terminal.js";
31
31
  import { passthroughStep } from "../../loop/run-step.js";
32
32
  import { forSubAgent, scopeResource } from "../../auth/types.js";
33
+ import { effectiveDeadlineMs } from "../../providers/types.js";
33
34
  import { defineTool } from "../define-tool.js";
34
35
  import { createListAvailablePackagesTool } from "../sandbox/list-available-packages.js";
35
36
  import { createListAvailableRefsTool } from "../sandbox/list-available-refs.js";
@@ -776,6 +777,7 @@ export function createGeneratePlanTool(deps) {
776
777
  ...(input.priorRuns ? ["", "## Prior Run Results", input.priorRuns] : []),
777
778
  ...(input.userConstraints ? ["", "## User Constraints", input.userConstraints] : []),
778
779
  ].join("\n");
780
+ const planTimeoutMs = effectiveDeadlineMs(deps.conversation.provider, PLAN_TIMEOUT_MS);
779
781
  // Opens the invocation. Two things are only knowable here and both are
780
782
  // first-order suspects when a planner runs long and returns nothing: the size
781
783
  // of the seed it was handed, and which of its blocks made it that size. The
@@ -788,7 +790,7 @@ export function createGeneratePlanTool(deps) {
788
790
  model: deps.conversation.model,
789
791
  maxIterations: PLANNER_MAX_ITERATIONS,
790
792
  salvageIterations: DEFAULT_SALVAGE_ITERATIONS,
791
- timeoutMs: PLAN_TIMEOUT_MS,
793
+ timeoutMs: planTimeoutMs,
792
794
  grounding: grounding.kind,
793
795
  ...(input.parentPlanId ? { parentPlanId: input.parentPlanId } : {}),
794
796
  seedChars: {
@@ -818,7 +820,7 @@ export function createGeneratePlanTool(deps) {
818
820
  };
819
821
  // Merge the outer abort signal with a wall-clock timeout — either
820
822
  // cancels the planner promptly.
821
- const signal = AbortSignal.any([ctx.signal, AbortSignal.timeout(PLAN_TIMEOUT_MS)]);
823
+ const signal = AbortSignal.any([ctx.signal, AbortSignal.timeout(planTimeoutMs)]);
822
824
  let runError = null;
823
825
  let run = null;
824
826
  try {
@@ -27,12 +27,13 @@ import { ok } from "neverthrow";
27
27
  import { z } from "zod";
28
28
  import { defineTool } from "../define-tool.js";
29
29
  import { capCodePoints, DETAIL_NEEDLE_MAX_LENGTH } from "../../loop/tool-detail.js";
30
+ import { LIBS_CONTAINER_PATH } from "../../sandbox/mount-plan.js";
30
31
  /**
31
32
  * Where the list lives when the host mounts the library store at the same path
32
33
  * the sandbox does. Hosts that do not — the store is baked into the image and
33
34
  * never bind-mounted — inject their own path instead.
34
35
  */
35
- const DEFAULT_PACKAGES_FILE = "/mnt/libs/current/packages.txt";
36
+ const DEFAULT_PACKAGES_FILE = `${LIBS_CONTAINER_PATH}/current/packages.txt`;
36
37
  /**
37
38
  * Default cap on a listing — high enough that the real store is never truncated.
38
39
  * The shipped catalog is ~270 packages (~3 KB, well under a thousand tokens), so