@inflexa-ai/harness 0.18.0 → 0.19.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 (41) hide show
  1. package/dist/agents/report-session-agent.d.ts +56 -0
  2. package/dist/agents/report-session-agent.js +72 -0
  3. package/dist/app/report-session-runtime.d.ts +72 -0
  4. package/dist/app/report-session-runtime.js +157 -0
  5. package/dist/memory/thread-store.d.ts +4 -4
  6. package/dist/memory/thread-store.js +18 -9
  7. package/dist/prompts/report-session.d.ts +1 -0
  8. package/dist/prompts/report-session.js +89 -0
  9. package/dist/providers/ai-sdk.d.ts +77 -0
  10. package/dist/providers/ai-sdk.js +149 -19
  11. package/dist/providers/errors.d.ts +14 -0
  12. package/dist/providers/errors.js +103 -18
  13. package/dist/providers/types.d.ts +16 -0
  14. package/dist/providers/types.js +10 -1
  15. package/dist/report-model/{mint-snapshot.d.ts → pin-snapshot.d.ts} +7 -7
  16. package/dist/report-model/{mint-snapshot.js → pin-snapshot.js} +7 -7
  17. package/dist/report-model/reference-resolver.d.ts +1 -1
  18. package/dist/report-model/validate.d.ts +34 -2
  19. package/dist/report-model/validate.js +32 -17
  20. package/dist/report-render/value-bridge.d.ts +62 -0
  21. package/dist/report-render/value-bridge.js +82 -0
  22. package/dist/runtime/assemble.d.ts +15 -0
  23. package/dist/runtime/assemble.js +28 -6
  24. package/dist/state/init.js +23 -0
  25. package/dist/state/purge-analysis.js +1 -0
  26. package/dist/state/report-session-state.d.ts +112 -0
  27. package/dist/state/report-session-state.js +150 -0
  28. package/dist/state/report-versions.d.ts +3 -6
  29. package/dist/state/report-versions.js +11 -38
  30. package/dist/state/snapshot-parse.d.ts +34 -0
  31. package/dist/state/snapshot-parse.js +82 -0
  32. package/dist/tools/ad-hoc-router.d.ts +7 -2
  33. package/dist/tools/ad-hoc-router.js +2 -1
  34. package/dist/tools/report-authoring/authoring-tools.d.ts +131 -24
  35. package/dist/tools/report-authoring/authoring-tools.js +152 -44
  36. package/dist/tools/report-session/index.d.ts +8 -0
  37. package/dist/tools/report-session/index.js +8 -0
  38. package/dist/tools/report-session/preview-report.d.ts +90 -0
  39. package/dist/tools/report-session/preview-report.js +245 -0
  40. package/dist/tools/research/generate-plan.js +4 -2
  41. package/package.json +1 -1
@@ -0,0 +1,245 @@
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 unresolved reference, a bridge mismatch, a render problem, a figure that escapes the
19
+ * workspace root, and a write failure. The tool never throws for one of them. The filesystem speaks the
20
+ * throw protocol, and the workspace-root seam signals an unresolvable resource the same way. Thus the
21
+ * write runs through the `tryFs` glue, which turns a genuine fault into the ok-channel outcome and lets a
22
+ * control-flow exception propagate.
23
+ */
24
+ import { err, ok } from "neverthrow";
25
+ import { copyFile, mkdir, writeFile } from "node:fs/promises";
26
+ import { extname, join } from "node:path";
27
+ import { z } from "zod";
28
+ import { resolveWorkspacePath } from "../../workspace/paths.js";
29
+ import { createNoopLogger } from "../../lib/console-logger.js";
30
+ import { describeFsError, tryFsWrite } from "../../lib/fs-result.js";
31
+ import { defaultErrorFields } from "../../lib/logger.js";
32
+ import { finishDraft } from "../../report-model/draft-finish.js";
33
+ import { resolveDocumentReferences } from "../../report-model/validate.js";
34
+ import { bridgeValues } from "../../report-render/value-bridge.js";
35
+ import { renderReportPage } from "../../report-render/render.js";
36
+ import { defineTool } from "../define-tool.js";
37
+ import { openReportThread } 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
+ const previewReportInput = z.object({});
40
+ /**
41
+ * The staged file name of one bound image: its content hash with the original extension.
42
+ *
43
+ * The colon of the `algorithm:hex` hash is not safe in a file name and in a relative source, thus the slug
44
+ * replaces each non-word run with one dash. The hash is unique, thus two figures with one basename cannot
45
+ * collide on the staged name.
46
+ */
47
+ function assetFileName(file) {
48
+ const slug = file.hash.replace(/[^a-z0-9]+/gi, "-");
49
+ return slug + extname(file.path);
50
+ }
51
+ /** The relative source of a staged figure. The page directory is self-contained, thus the source is `assets/<name>`. */
52
+ function figureSourcePolicy(file) {
53
+ return `assets/${assetFileName(file)}`;
54
+ }
55
+ /**
56
+ * Pair each block with the resolved value of its binding, in document order.
57
+ *
58
+ * A value-bearing kind carries the resolved value of its one reference, looked up by the block id from
59
+ * the map that the resolution pass filled. A no-value kind carries none. Where a reference sits in a
60
+ * block is the knowledge of `block-walk.ts`, thus this walk reads the block id alone and never a binding
61
+ * field.
62
+ *
63
+ * The switch is exhaustive over the eight block kinds. A ninth kind reaches the end with no return, and
64
+ * the declared return type fails the build. Thus the walk cannot drop a kind in silence. A value-bearing
65
+ * block reaches here only when its reference resolved, because an unresolved reference short-circuits
66
+ * before this walk.
67
+ */
68
+ function collectResolutions(blocks, resolvedByBlock) {
69
+ const resolutions = [];
70
+ const visit = (block) => {
71
+ switch (block.kind) {
72
+ case "section":
73
+ resolutions.push({ blockId: block.id, kind: "section" });
74
+ for (const child of block.blocks) {
75
+ visit(child);
76
+ }
77
+ return;
78
+ case "text":
79
+ case "claim":
80
+ case "citation":
81
+ resolutions.push({ blockId: block.id, kind: block.kind });
82
+ return;
83
+ case "metric":
84
+ case "table":
85
+ case "chart":
86
+ case "figure": {
87
+ const resolved = resolvedByBlock.get(block.id);
88
+ if (resolved !== undefined) {
89
+ resolutions.push({ blockId: block.id, kind: block.kind, resolved });
90
+ }
91
+ return;
92
+ }
93
+ }
94
+ };
95
+ for (const block of blocks) {
96
+ visit(block);
97
+ }
98
+ return resolutions;
99
+ }
100
+ /**
101
+ * Resolve each reference of the document, and split the resolutions from the unresolved references.
102
+ *
103
+ * The shared `resolveDocumentReferences` walks the tree, resolves each reference under the concurrency
104
+ * bound, and runs the chart-encoding match, thus the preview and the record gate refuse the same
105
+ * references. On a clean pass the resolutions map onto the render model in document order.
106
+ */
107
+ async function resolveDocument(document, resolver, snapshot) {
108
+ const { resolvedByBlock, failures } = await resolveDocumentReferences(document.sections, snapshot, resolver);
109
+ if (failures.length > 0) {
110
+ return { resolutions: [], unresolved: failures };
111
+ }
112
+ return { resolutions: collectResolutions(document.sections, resolvedByBlock), unresolved: [] };
113
+ }
114
+ /**
115
+ * Resolve the page root, stage each bound image, and write the page.
116
+ *
117
+ * The workspace-root seam signals an unresolvable resource by a throw, thus the resolution sits inside
118
+ * the protection and its throw becomes a value. Each `fs` call runs through `tryFsWrite`, the one
119
+ * sanctioned `fs` guard, thus a genuine I/O fault becomes a value and a control-flow exception such as a
120
+ * cancellation propagates.
121
+ *
122
+ * A bound figure names a snapshot path, which is untrusted. A registered `../../` path escapes the root,
123
+ * thus the containment test runs before any copy and reuses the one workspace-path resolver. A source
124
+ * outside the root refuses and names the block, and no copy runs.
125
+ */
126
+ async function renderToWorkspace(args) {
127
+ let root;
128
+ try {
129
+ root = args.resolveWorkspaceRoot(args.analysisId);
130
+ }
131
+ catch (cause) {
132
+ return err({ kind: "fs", error: { type: "read_failed", op: "preview.resolveWorkspaceRoot", path: args.analysisId, cause } });
133
+ }
134
+ const sessionDir = join(root, "report-sessions", args.threadId);
135
+ const assetsDir = join(sessionDir, "assets");
136
+ const pagePath = join(sessionDir, "index.html");
137
+ // Each bound figure, contained and deduplicated by its staged name, mapped to its host source.
138
+ const sources = new Map();
139
+ for (const resolution of args.resolutions) {
140
+ if (resolution.kind !== "figure" || resolution.resolved.type !== "file") {
141
+ continue;
142
+ }
143
+ const file = resolution.resolved;
144
+ const name = assetFileName(file);
145
+ if (sources.has(name)) {
146
+ continue;
147
+ }
148
+ const resolved = resolveWorkspacePath({ workspaceRoot: root, analysisId: args.analysisId, path: file.path });
149
+ if (resolved.kind !== "ok") {
150
+ return err({ kind: "figure-out-of-scope", blockId: resolution.blockId, path: file.path });
151
+ }
152
+ sources.set(name, resolved.absolute);
153
+ }
154
+ const madeSession = await tryFsWrite("preview.mkdir", () => mkdir(sessionDir, { recursive: true }), { path: sessionDir });
155
+ if (madeSession.isErr()) {
156
+ return err({ kind: "fs", error: madeSession.error });
157
+ }
158
+ if (sources.size > 0) {
159
+ const madeAssets = await tryFsWrite("preview.mkdir", () => mkdir(assetsDir, { recursive: true }), { path: assetsDir });
160
+ if (madeAssets.isErr()) {
161
+ return err({ kind: "fs", error: madeAssets.error });
162
+ }
163
+ for (const [name, source] of sources) {
164
+ const copied = await tryFsWrite("preview.copyFile", () => copyFile(source, join(assetsDir, name)), { path: source });
165
+ if (copied.isErr()) {
166
+ return err({ kind: "fs", error: copied.error });
167
+ }
168
+ }
169
+ }
170
+ const wrote = await tryFsWrite("preview.writeFile", () => writeFile(pagePath, args.page, "utf8"), { path: pagePath });
171
+ if (wrote.isErr()) {
172
+ return err({ kind: "fs", error: wrote.error });
173
+ }
174
+ return ok(pagePath);
175
+ }
176
+ /**
177
+ * Make the render-and-preview tool over the session-state gateway and the render seams.
178
+ *
179
+ * The tool reads the thread id from the scope of the call, and it loads the state through the gateway. Thus
180
+ * one factory serves every thread. The tool holds no per-session value, and it resolves the root of the
181
+ * analysis of the call.
182
+ */
183
+ export function createPreviewReportTool(deps) {
184
+ const logger = (deps.logger ?? createNoopLogger()).named("preview-report");
185
+ return defineTool({
186
+ id: "preview_report",
187
+ description: "Render the current draft to a self-contained HTML page, and give back the page path. " +
188
+ "The tool finishes the draft first: an incomplete draft gives back the gap list, and no page renders. " +
189
+ "On a pass it resolves each reference, stages each bound image beside the page, and writes the page. " +
190
+ "Use it to see the report, and to confirm that each reference resolves.",
191
+ inputSchema: previewReportInput,
192
+ executionMode: "inline",
193
+ describeCall: "none",
194
+ execute: async (_input, ctx) => {
195
+ const opened = await openReportThread(deps.gateway, ctx.session.scope);
196
+ if (opened.isErr()) {
197
+ return ok({ outcome: "refused", refusal: opened.error });
198
+ }
199
+ const { threadId, analysisId, state } = opened.value;
200
+ const { document: draft, snapshot } = state;
201
+ const finished = finishDraft(draft, snapshot);
202
+ if (!finished.valid) {
203
+ return ok({ outcome: "gaps", gaps: finished.gaps });
204
+ }
205
+ const document = finished.document;
206
+ if (deps.resolver === undefined) {
207
+ return ok({ outcome: "resolver-unavailable" });
208
+ }
209
+ const { resolutions, unresolved } = await resolveDocument(document, deps.resolver, snapshot);
210
+ if (unresolved.length > 0) {
211
+ return ok({ outcome: "unresolved-references", unresolved });
212
+ }
213
+ const bridged = bridgeValues(resolutions, figureSourcePolicy);
214
+ if (bridged.isErr()) {
215
+ return ok({ outcome: "bridge-mismatch", mismatches: bridged.error });
216
+ }
217
+ const rendered = renderReportPage(document, bridged.value);
218
+ if (rendered.isErr()) {
219
+ return ok({ outcome: "render-problems", problems: rendered.error });
220
+ }
221
+ const written = await renderToWorkspace({
222
+ resolveWorkspaceRoot: deps.resolveWorkspaceRoot,
223
+ analysisId,
224
+ threadId,
225
+ resolutions,
226
+ page: rendered.value,
227
+ });
228
+ if (written.isErr()) {
229
+ const failure = written.error;
230
+ if (failure.kind === "figure-out-of-scope") {
231
+ // A bound figure names a path outside the workspace root. The record gate refuses the
232
+ // same figure, thus the preview refuses it too and names the block.
233
+ logger.warn("a bound figure escapes the workspace root", { threadId, analysisId, blockId: failure.blockId, path: failure.path });
234
+ return ok({ outcome: "figure-out-of-scope", blockId: failure.blockId, path: failure.path });
235
+ }
236
+ // An unresolvable root, a full volume, and a denied write each arrive here as a value. The
237
+ // tool contract is ok-channel data for each degraded condition, thus the fault becomes an
238
+ // outcome. The log keeps the full fault, because the outcome carries the description alone.
239
+ logger.warn("the page did not land", { threadId, analysisId, ...defaultErrorFields(failure.error.cause) });
240
+ return ok({ outcome: "write-failed", detail: describeFsError(failure.error) });
241
+ }
242
+ return ok({ outcome: "rendered", pagePath: written.value });
243
+ },
244
+ });
245
+ }
@@ -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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Host-agnostic bioinformatics agent harness \u2014 hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",