@inflexa-ai/harness 0.7.2 → 0.9.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 (36) hide show
  1. package/dist/contracts/message.d.ts +6 -0
  2. package/dist/execution/report-runner.d.ts +6 -1
  3. package/dist/execution/report-runner.js +1 -0
  4. package/dist/index.d.ts +1 -1
  5. package/dist/loop/run-agent.d.ts +1 -1
  6. package/dist/loop/run-agent.js +55 -2
  7. package/dist/memory/ai-sdk-message-storage.d.ts +42 -0
  8. package/dist/memory/ai-sdk-message-storage.js +62 -0
  9. package/dist/memory/content-to-cortex.d.ts +8 -1
  10. package/dist/memory/content-to-cortex.js +36 -4
  11. package/dist/memory/thread-history.d.ts +37 -0
  12. package/dist/memory/thread-history.js +74 -7
  13. package/dist/prompts/conversation.d.ts +1 -1
  14. package/dist/prompts/conversation.js +3 -3
  15. package/dist/prompts/report-builder.d.ts +1 -1
  16. package/dist/prompts/report-builder.js +1 -1
  17. package/dist/providers/ai-sdk.d.ts +23 -0
  18. package/dist/providers/ai-sdk.js +254 -34
  19. package/dist/providers/anthropic.d.ts +2 -0
  20. package/dist/providers/anthropic.js +1 -0
  21. package/dist/providers/streaming-chat.d.ts +11 -2
  22. package/dist/providers/streaming-chat.js +31 -6
  23. package/dist/providers/types.d.ts +10 -1
  24. package/dist/tools/iterate-report.d.ts +0 -1
  25. package/dist/tools/iterate-report.js +125 -11
  26. package/dist/tools/report/mint-preview-url.d.ts +1 -1
  27. package/dist/tools/report/mint-preview-url.js +7 -5
  28. package/dist/tools/report/preview-publisher.d.ts +16 -0
  29. package/dist/tools/report/preview-publisher.js +20 -0
  30. package/dist/tools/report/preview-snapshot.d.ts +9 -1
  31. package/dist/tools/report/preview-snapshot.js +15 -1
  32. package/dist/tools/report/submit-report.d.ts +1 -0
  33. package/dist/tools/report/submit-report.js +32 -11
  34. package/dist/tools/report/version-fs.d.ts +4 -4
  35. package/dist/tools/report/version-fs.js +24 -17
  36. package/package.json +2 -1
@@ -42,15 +42,15 @@
42
42
  import { ok } from "neverthrow";
43
43
  import { z } from "zod";
44
44
  import { randomUUID } from "node:crypto";
45
- import { mkdir, readFile, writeFile } from "node:fs/promises";
45
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
46
46
  import { join } from "node:path";
47
47
  import { scopeResource } from "../auth/types.js";
48
48
  import { defineTool } from "./define-tool.js";
49
- import { previewDir } from "../workspace/paths.js";
49
+ import { latestPreviewVersion, previewDir } from "../workspace/paths.js";
50
50
  import { runReportIteration } from "../execution/report-runner.js";
51
51
  import { formatBytes, stageReportAssets } from "./lib/report-preflight.js";
52
52
  import { createNoopLogger } from "../lib/console-logger.js";
53
- import { hintForZodIssue } from "../lib/zod-issues.js";
53
+ import { hintForZodIssue, repairToolInput } from "../lib/zod-issues.js";
54
54
  const REPORT_TOOL_ACCESS_TTL_SECONDS = 3600;
55
55
  const PREVIEW_META_FILE = "preview-meta.json";
56
56
  async function readPreviewMeta(metaPath) {
@@ -285,7 +285,7 @@ export const submitReportInputSchema = z
285
285
  .min(1)
286
286
  .optional()
287
287
  .describe("Version to branch from (1-based) — use when the user prefers an earlier version. Defaults to the latest."),
288
- format: z.enum(["html", "pdf"]).default("html").describe("Output format. Defaults to 'html' — only set explicitly for PDF."),
288
+ format: z.enum(["html"]).default("html").describe("Output format. HTML is the only format the report path produces; you can leave this unset."),
289
289
  report: z
290
290
  .unknown()
291
291
  .optional()
@@ -309,6 +309,22 @@ export const submitReportInputSchema = z
309
309
  })
310
310
  .refine((data) => !(data.report !== undefined && data.sources), {
311
311
  message: "Top-level `sources` is for iteration mode. For creation, put sources inside the brief's `sources`.",
312
+ })
313
+ // `baseVersion` names a version to branch from, and a creation call has
314
+ // none. Left admissible it is not inert: the base-existence guard below is
315
+ // scoped to iteration, so the value reaches the runner unchecked and numbers
316
+ // a brand-new report `v{baseVersion + 1}`.
317
+ .refine((data) => !(data.report !== undefined && data.baseVersion !== undefined), {
318
+ message: "`baseVersion` is for iteration mode — a new report has no earlier version to branch from. " +
319
+ "To build on an existing version, pass `modifications` plus that report's `previewId` instead of a `report` brief.",
320
+ })
321
+ // `previewId` names the report the change text applies to. Without one there
322
+ // is nothing to iterate, and the tool would mint a fresh id — an id the
323
+ // caller never chose, which its own refusal would then quote back as though
324
+ // it were a real report worth retrying against.
325
+ .refine((data) => !(data.modifications !== undefined && data.previewId === undefined), {
326
+ message: "Iterating with `modifications` requires the `previewId` of the report to change — take it from that report's preview card. " +
327
+ "To build a new report instead, pass a `report` brief and no `previewId`.",
312
328
  });
313
329
  // ── plan_report: the just-in-time authoring contract ────────────────
314
330
  /**
@@ -402,6 +418,38 @@ export const planReportTool = defineTool({
402
418
  rules: REPORT_AUTHORING_RULES,
403
419
  }),
404
420
  });
421
+ /**
422
+ * Validate a submitted brief, first repairing one that arrived double-encoded.
423
+ *
424
+ * Every other committing tool (`submit_plan`, `submit_synthesis`) puts its real
425
+ * schema on the wire, so a JSON-encoded argument fails the tool's input schema and
426
+ * the agent loop's issue-guided repair pass catches it before `execute` ever runs.
427
+ * `report` rides as `z.unknown()` to keep the ~12k brief schema off the always-on
428
+ * tool surface — which also means a double-encoded brief *satisfies* the input
429
+ * schema, the loop sees no failure to act on, and the repair never fires. That
430
+ * makes this the one committing path where the brief must be repaired locally.
431
+ *
432
+ * When the brief decodes but is then invalid, the issues reported are the decoded
433
+ * value's, not the encoding error's — once the string has been parsed, "expected
434
+ * object, received string" describes a problem this function already solved, and
435
+ * would send the model chasing it instead of the fields actually missing. A brief
436
+ * that does not decode at all keeps its original failure.
437
+ *
438
+ * `validated` is the value the returned issues are addressed to, which is the
439
+ * repaired brief whenever repair happened. An issue's `path` indexes into that
440
+ * value alone, so a diagnostic that walks the path — `hintForZodIssue` — must be
441
+ * handed the same value; walking a decoded field's path into the still-encoded
442
+ * original resolves nothing and silently yields no hint.
443
+ */
444
+ function acceptBrief(report) {
445
+ const direct = ReportBriefSchema.safeParse(report);
446
+ if (direct.success)
447
+ return { parsed: direct, validated: report };
448
+ const repaired = repairToolInput(report, direct.error);
449
+ if (repaired === undefined)
450
+ return { parsed: direct, validated: report };
451
+ return { parsed: ReportBriefSchema.safeParse(repaired), validated: repaired };
452
+ }
405
453
  /**
406
454
  * `submit_report` — takes the composed brief and drives the report iteration.
407
455
  * On a valid brief its behaviour is byte-for-byte the former `iterate_report`:
@@ -412,7 +460,7 @@ export const planReportTool = defineTool({
412
460
  export function createReportSubmitTool(deps) {
413
461
  return defineTool({
414
462
  id: "submit_report",
415
- description: "Submit a composed report brief to build or iterate an HTML/PDF report " +
463
+ description: "Submit a composed report brief to build or iterate an HTML report " +
416
464
  "(the report-builder renders it; it never sees the analysis tree). Call " +
417
465
  "`plan_report` FIRST to get the brief schema + authoring rules, compose " +
418
466
  "the brief, then call this. CREATE: pass `report` (the composed brief), " +
@@ -429,14 +477,14 @@ export function createReportSubmitTool(deps) {
429
477
  // (the validate_plan trade) — never a thrown error.
430
478
  let brief;
431
479
  if (input.report !== undefined) {
432
- const parsed = ReportBriefSchema.safeParse(input.report);
480
+ const { parsed, validated } = acceptBrief(input.report);
433
481
  if (!parsed.success) {
434
482
  return ok({
435
483
  ok: false,
436
484
  issues: parsed.error.issues.map((i) => ({
437
485
  path: i.path.join(".") || "(root)",
438
486
  message: i.message,
439
- hint: hintForZodIssue(i, input.report),
487
+ hint: hintForZodIssue(i, validated),
440
488
  })),
441
489
  hint: "The `report` brief did not match the schema. Call `plan_report` for the full schema, fix the fields named in `issues`, and resubmit.",
442
490
  });
@@ -445,13 +493,79 @@ export function createReportSubmitTool(deps) {
445
493
  }
446
494
  const { resourceId } = scopeResource(ctx.session.scope);
447
495
  const analysisRoot = deps.resolveWorkspaceRoot(resourceId);
448
- // Zod fills the default at parse time but the static type keeps it
449
- // optional — re-apply so the runner sees a concrete value.
450
- const format = input.format ?? "html";
496
+ const format = input.format;
451
497
  const previewId = input.previewId ?? `prv-${randomUUID().slice(0, 8)}`;
452
498
  const previewRootAbs = join(analysisRoot, previewDir(previewId));
453
499
  const assetsDirAbs = join(previewRootAbs, "assets");
454
500
  const metaPathAbs = join(previewRootAbs, PREVIEW_META_FILE);
501
+ // Iteration carries no brief: the builder's entire input is the change
502
+ // text plus the base version's template. A base that is not on disk
503
+ // therefore hands it a change request and nothing to change, and it
504
+ // authors a fresh version while being told the previous template is
505
+ // already in its working directory. Two ways to reach that state — a
506
+ // preview holding no version at all, and a `baseVersion` naming one
507
+ // that was never written — so the base is resolved here exactly as the
508
+ // runner resolves it (`baseVersion ?? latest`) and both are refused.
509
+ // Scoping to iteration is exact, not incidental: the input schema
510
+ // refuses `baseVersion` alongside a brief, so a creation call carries
511
+ // no base to resolve.
512
+ // The check is a directory read and precedes access minting, asset
513
+ // staging, and the builder, so a bad id costs no model turns and
514
+ // leaves no partial state.
515
+ //
516
+ // A version directory implies its `report.html.j2`, so the directory
517
+ // read is the whole check and no template stat is needed. What holds
518
+ // that up is the builder's own finalize gate
519
+ // (`tools/report/submit-report.ts`), which refuses a version whose
520
+ // `report.html.j2` is absent; a version the gate never accepted is
521
+ // never recorded, and the runner removes the directory it created.
522
+ // Relaxing that gate is what would make this check insufficient.
523
+ //
524
+ // The read may also sit outside `withPreviewLock`: nothing in the
525
+ // report path removes an existing version, so a concurrent iteration
526
+ // can only add, and a base seen here is still there when the runner
527
+ // takes the lock. Introducing version deletion makes that ordering
528
+ // load-bearing.
529
+ if (!brief) {
530
+ let versionDirs = [];
531
+ try {
532
+ versionDirs = (await readdir(previewRootAbs, { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name);
533
+ }
534
+ catch {
535
+ /* no preview root — indistinguishable from one holding no versions */
536
+ }
537
+ const latestVersion = latestPreviewVersion(versionDirs);
538
+ let reason;
539
+ if (latestVersion === 0) {
540
+ reason =
541
+ `No report exists under previewId '${previewId}', so there is nothing to modify. ` +
542
+ "Create the report first by calling `submit_report` with a `report` brief and no `previewId`, " +
543
+ "or retry with the `previewId` shown on the preview card of the report you meant to change.";
544
+ }
545
+ else if (input.baseVersion !== undefined && !versionDirs.includes(`v${input.baseVersion}`)) {
546
+ reason =
547
+ `Preview '${previewId}' has no version ${input.baseVersion} to branch from — its latest version is ${latestVersion}. ` +
548
+ "Retry with a `baseVersion` that exists, or omit `baseVersion` to branch from the latest version.";
549
+ }
550
+ if (reason !== undefined) {
551
+ await ctx.emit({
552
+ type: "data-report-preview-failed",
553
+ data: {
554
+ id: randomUUID(),
555
+ previewId,
556
+ version: 0,
557
+ reason,
558
+ errorKind: "build",
559
+ },
560
+ });
561
+ return ok({
562
+ previewId,
563
+ version: 0,
564
+ previewPath: "",
565
+ error: reason,
566
+ });
567
+ }
568
+ }
455
569
  // Iteration mode: recover the title from the creation-time meta file
456
570
  // so the data-report-preview part keeps the original title across versions.
457
571
  const existingMeta = brief ? null : await readPreviewMeta(metaPathAbs);
@@ -636,7 +750,7 @@ function buildCreationPrompt(report, format, staged) {
636
750
  out.push("Each section below carries `intent` (why it exists, what to emphasize) " +
637
751
  "and `content` (the data shape to render). You choose the design-system " +
638
752
  "components, layout, alternation, and visual treatment. Stay in the " +
639
- "Inflexa Design Blueprint — base.html.j2, components, theme.css.");
753
+ "Inflexa Design Blueprint — base.html.j2, components.");
640
754
  out.push("");
641
755
  for (let i = 0; i < report.sections.length; i++) {
642
756
  out.push(`### ${i + 1}. ${report.sections[i].title} — type: \`${report.sections[i].type}\``);
@@ -10,7 +10,7 @@
10
10
  * - inspecting an older version (pass `version`)
11
11
  */
12
12
  import { type Tool } from "../define-tool.js";
13
- import type { PreviewPublisher } from "./preview-publisher.js";
13
+ import { type PreviewPublisher } from "./preview-publisher.js";
14
14
  /** Shared, mutable URL cell so mint and preview-snapshot share a cached URL. */
15
15
  export interface PreviewUrlCell {
16
16
  url: string | undefined;
@@ -12,13 +12,15 @@
12
12
  import { ok } from "neverthrow";
13
13
  import { z } from "zod";
14
14
  import { defineTool } from "../define-tool.js";
15
+ import { describeMintFailure } from "./preview-publisher.js";
15
16
  export function createMintPreviewUrlTool(state) {
16
17
  return defineTool({
17
18
  id: "mint_preview_url",
18
- description: "Mint a fresh preview URL for the report. Returns a content-server " +
19
- "URL with embedded auth token (15-min TTL). Use this only when you " +
20
- "need a URL for direct browser navigation — preview_snapshot handles " +
21
- "the common case automatically.",
19
+ description: "Mint a fresh preview URL for the report. Returns a time-limited " +
20
+ "content-server URL with an embedded auth token, alongside the " +
21
+ "`expiresAt` it is good until. Use this only when you need a URL for " +
22
+ "direct browser navigation — preview_snapshot handles the common case " +
23
+ "automatically.",
22
24
  inputSchema: z.object({
23
25
  version: z.number().optional().describe("Version to mint for. Defaults to the iteration's current version."),
24
26
  }),
@@ -28,7 +30,7 @@ export function createMintPreviewUrlTool(state) {
28
30
  if (!result.ok) {
29
31
  return ok({
30
32
  ok: false,
31
- error: `preview-access mint failed: status=${result.status} ${result.error.message ?? ""}`.trim(),
33
+ error: describeMintFailure(result),
32
34
  });
33
35
  }
34
36
  const url = `${result.data.baseUrl.replace(/\/?$/, "/")}v${version}/index.html?t=${result.data.token}`;
@@ -24,6 +24,22 @@ export type PreviewMintResult = {
24
24
  message?: string;
25
25
  };
26
26
  };
27
+ /** The not-ok arm of the seam's result — what a failed mint is allowed to carry. */
28
+ export type PreviewMintFailure = Extract<PreviewMintResult, {
29
+ ok: false;
30
+ }>;
31
+ /**
32
+ * Renders a failed mint as the line an agent reads back from a preview tool.
33
+ *
34
+ * The failure shape is sparse by design: a realization with no HTTP transport
35
+ * behind it supplies neither a status nor, necessarily, a message. Naming a
36
+ * field the seam left unset would put the literal `status=undefined` in front
37
+ * of the model, so the message carries only what actually arrived. This lives
38
+ * beside the type because its whole job is to honour that type's optionality —
39
+ * every tool holding the seam owes the same message, and one of them owning
40
+ * the wording would make the others depend on a peer for a seam-wide concern.
41
+ */
42
+ export declare function describeMintFailure(failure: PreviewMintFailure): string;
27
43
  export interface PreviewPublisher {
28
44
  mintPreviewAccess(resourceId: string, previewId: string): Promise<PreviewMintResult>;
29
45
  }
@@ -10,6 +10,26 @@
10
10
  * returns not-ok, so `preview_snapshot` short-circuits before touching
11
11
  * Chrome and the report still builds (submit_report is the only gate).
12
12
  */
13
+ /**
14
+ * Renders a failed mint as the line an agent reads back from a preview tool.
15
+ *
16
+ * The failure shape is sparse by design: a realization with no HTTP transport
17
+ * behind it supplies neither a status nor, necessarily, a message. Naming a
18
+ * field the seam left unset would put the literal `status=undefined` in front
19
+ * of the model, so the message carries only what actually arrived. This lives
20
+ * beside the type because its whole job is to honour that type's optionality —
21
+ * every tool holding the seam owes the same message, and one of them owning
22
+ * the wording would make the others depend on a peer for a seam-wide concern.
23
+ */
24
+ export function describeMintFailure(failure) {
25
+ const detail = [];
26
+ if (failure.status !== undefined)
27
+ detail.push(`status=${failure.status}`);
28
+ const message = failure.error.message?.trim();
29
+ if (message)
30
+ detail.push(message);
31
+ return detail.length > 0 ? `preview-access mint failed: ${detail.join(" ")}` : "preview-access mint failed";
32
+ }
13
33
  /**
14
34
  * Local default — no hosted preview surface. Returns not-ok so the preview
15
35
  * tools surface a clear "unavailable" message and short-circuit before any
@@ -10,10 +10,16 @@
10
10
  * Returned screenshot is base64-encoded PNG bytes; the agent can inspect
11
11
  * console errors / failed requests to decide whether to fix the template
12
12
  * or move on to submit_report.
13
+ *
14
+ * A seam that cannot mint is also reported through the injected `Logger`:
15
+ * this tool is the build's only visual verification, so an unavailable seam
16
+ * means that verification silently did not happen — a condition an operator
17
+ * has to be able to see without reading the model transcript.
13
18
  */
14
19
  import { type Tool } from "../define-tool.js";
15
20
  import { type ChromeConfig } from "../../lib/chrome.js";
16
- import type { PreviewPublisher } from "./preview-publisher.js";
21
+ import type { Logger } from "../../lib/logger.js";
22
+ import { type PreviewPublisher } from "./preview-publisher.js";
17
23
  import type { PreviewUrlCell } from "./mint-preview-url.js";
18
24
  export interface PreviewSnapshotToolState {
19
25
  readonly resourceId: string;
@@ -22,5 +28,7 @@ export interface PreviewSnapshotToolState {
22
28
  readonly previews: PreviewPublisher;
23
29
  readonly urlCell: PreviewUrlCell;
24
30
  readonly chrome: ChromeConfig;
31
+ /** Operational logging seam; omitted falls back to no-op. */
32
+ readonly logger?: Logger;
25
33
  }
26
34
  export declare function createPreviewSnapshotTool(state: PreviewSnapshotToolState): Tool;
@@ -10,15 +10,23 @@
10
10
  * Returned screenshot is base64-encoded PNG bytes; the agent can inspect
11
11
  * console errors / failed requests to decide whether to fix the template
12
12
  * or move on to submit_report.
13
+ *
14
+ * A seam that cannot mint is also reported through the injected `Logger`:
15
+ * this tool is the build's only visual verification, so an unavailable seam
16
+ * means that verification silently did not happen — a condition an operator
17
+ * has to be able to see without reading the model transcript.
13
18
  */
14
19
  import { ok } from "neverthrow";
15
20
  import { z } from "zod";
16
21
  import { defineTool } from "../define-tool.js";
17
22
  import { withPage } from "../../lib/chrome.js";
23
+ import { createNoopLogger } from "../../lib/console-logger.js";
24
+ import { describeMintFailure } from "./preview-publisher.js";
18
25
  const NAV_TIMEOUT_MS = 20_000;
19
26
  const READY_TIMEOUT_MS = 8_000;
20
27
  const WAIT_MS_CAP = 30_000;
21
28
  export function createPreviewSnapshotTool(state) {
29
+ const logger = (state.logger ?? createNoopLogger()).named("preview-snapshot").with({ previewId: state.previewId });
22
30
  return defineTool({
23
31
  id: "preview_snapshot",
24
32
  description: "Render the report in a real headless browser and report what you " +
@@ -48,11 +56,17 @@ export function createPreviewSnapshotTool(state) {
48
56
  if (!url) {
49
57
  const result = await state.previews.mintPreviewAccess(state.resourceId, state.previewId);
50
58
  if (!result.ok) {
59
+ const reason = result.error.message?.trim();
60
+ logger.warn("preview access unavailable — visual verification did not run", {
61
+ version: state.currentVersion,
62
+ ...(result.status !== undefined ? { status: result.status } : {}),
63
+ ...(reason ? { reason } : {}),
64
+ });
51
65
  return ok({
52
66
  ok: false,
53
67
  consoleErrors: [],
54
68
  failedRequests: [],
55
- error: `preview-access mint failed: status=${result.status} ${result.error.message ?? ""}`.trim(),
69
+ error: describeMintFailure(result),
56
70
  });
57
71
  }
58
72
  url = `${result.data.baseUrl.replace(/\/?$/, "/")}v${state.currentVersion}/index.html?t=${result.data.token}`;
@@ -3,6 +3,7 @@
3
3
  * `data-report-preview` until this tool writes the success outcome.
4
4
  *
5
5
  * Checks:
6
+ * - report.html.j2 exists at versionDir/ — the report itself
6
7
  * - index.html exists at versionDir/ with size > 0
7
8
  * - no unrendered `{{ … }}` or `{% … %}` left in the output
8
9
  * - all referenced asset paths resolve to files on disk
@@ -3,6 +3,7 @@
3
3
  * `data-report-preview` until this tool writes the success outcome.
4
4
  *
5
5
  * Checks:
6
+ * - report.html.j2 exists at versionDir/ — the report itself
6
7
  * - index.html exists at versionDir/ with size > 0
7
8
  * - no unrendered `{{ … }}` or `{% … %}` left in the output
8
9
  * - all referenced asset paths resolve to files on disk
@@ -23,26 +24,46 @@ const UNRENDERED_JINJA = /\{\{[^{}]*\}\}|\{%-?\s*\w/;
23
24
  export function createSubmitReportTool(state) {
24
25
  return defineTool({
25
26
  id: "submit_report",
26
- description: "Declare the report ready. Validates index.html exists, has no " +
27
- "unrendered Jinja, and that all referenced asset paths resolve. " +
27
+ description: "Declare the report ready. Validates report.html.j2 and index.html " +
28
+ "exist, that the output has no unrendered Jinja, and that all " +
29
+ "referenced asset paths resolve. " +
28
30
  "You MUST call this to finish — the runner ignores any other claim " +
29
31
  "of success. Returns problems[] when checks fail; fix and re-submit. " +
30
- "Use `notes` to surface caveats to the caller (e.g. files skipped due " +
31
- "to the 50MB asset size limit, sections rendered with partial data, " +
32
- "missing inputs that forced a fallback) — these reach the conversation " +
33
- "agent and the user in the iteration result.",
32
+ "Use `notes` to surface caveats to the caller (e.g. sections rendered " +
33
+ "with partial data, missing inputs that forced a fallback) — these " +
34
+ "reach the conversation agent and the user in the iteration result.",
34
35
  inputSchema: z.object({
35
36
  notes: z
36
37
  .array(z.string())
37
38
  .optional()
38
39
  .describe("Caveats / issues to surface to the caller. One short sentence " +
39
- "per item. Examples: 'skipped raw_counts.h5ad (1.2GB > 50MB cap)', " +
40
- "'pathway-enrichment section omitted — no GSEA output found'. " +
40
+ "per item. Examples: 'pathway-enrichment section omitted — no " +
41
+ "GSEA output found', 'cohort table rendered from 8 of 12 " +
42
+ "samples — the rest had no QC row'. " +
41
43
  "Leave empty when the report shipped cleanly."),
42
44
  }),
43
45
  execute: async (input) => {
44
46
  const problems = [];
45
- // 1. index.html exists and is non-empty
47
+ // 1. report.html.j2 exists — the report itself, as opposed to the
48
+ // artifact rendered from it. Nothing downstream re-derives this: a
49
+ // version that is finalized without a template persists as one, and
50
+ // the next iteration copies a template that isn't there, then tells
51
+ // the builder to edit it in place. Checked first because an absent
52
+ // template is the cause the checks below can only report symptoms of.
53
+ const templatePath = join(state.versionDir, "report.html.j2");
54
+ let hasTemplate;
55
+ try {
56
+ hasTemplate = (await stat(templatePath)).isFile();
57
+ }
58
+ catch {
59
+ hasTemplate = false;
60
+ }
61
+ if (!hasTemplate) {
62
+ problems.push("report.html.j2 is missing — a report is authored as report.html.j2 and " +
63
+ "rendered into index.html by build_report; a hand-written index.html is " +
64
+ "not a report. Write the template, then call build_report.");
65
+ }
66
+ // 2. index.html exists and is non-empty
46
67
  const indexPath = join(state.versionDir, "index.html");
47
68
  let html;
48
69
  try {
@@ -57,13 +78,13 @@ export function createSubmitReportTool(state) {
57
78
  problems.push("index.html does not exist — call build_report first");
58
79
  return ok({ ok: false, problems });
59
80
  }
60
- // 2. No unrendered Jinja markers
81
+ // 3. No unrendered Jinja markers
61
82
  if (UNRENDERED_JINJA.test(html)) {
62
83
  const match = html.match(UNRENDERED_JINJA);
63
84
  problems.push(`unrendered Jinja in output (e.g. ${JSON.stringify(match?.[0] ?? "")}); ` +
64
85
  "build_report likely failed silently or report.html.j2 was edited after build");
65
86
  }
66
- // 3. Asset reference resolution
87
+ // 4. Asset reference resolution
67
88
  const refs = extractLocalRefs(html);
68
89
  for (const ref of refs) {
69
90
  const resolved = resolveLocalRef(ref, state.versionDir, state.assetsDir);
@@ -2,10 +2,10 @@
2
2
  * In-process workspace surface for the report-builder agent.
3
3
  *
4
4
  * Tight read/write/mkdir surface scoped to the version directory.
5
- * Agent-supplied paths are resolved against `versionDir`; any path that
6
- * escapes is rejected as `out_of_scope`. Constructed inside the
7
- * iterate-report runner so it always shares the same `versionDir` as the
8
- * rest of the report tools.
5
+ * Agent-supplied paths are relative to `versionDir`; an absolute path, or one
6
+ * that escapes the directory, is rejected as `out_of_scope`. Constructed
7
+ * inside the iterate-report runner so it always shares the same `versionDir`
8
+ * as the rest of the report tools.
9
9
  */
10
10
  import { type Tool } from "../define-tool.js";
11
11
  export interface VersionFsToolsState {
@@ -2,10 +2,10 @@
2
2
  * In-process workspace surface for the report-builder agent.
3
3
  *
4
4
  * Tight read/write/mkdir surface scoped to the version directory.
5
- * Agent-supplied paths are resolved against `versionDir`; any path that
6
- * escapes is rejected as `out_of_scope`. Constructed inside the
7
- * iterate-report runner so it always shares the same `versionDir` as the
8
- * rest of the report tools.
5
+ * Agent-supplied paths are relative to `versionDir`; an absolute path, or one
6
+ * that escapes the directory, is rejected as `out_of_scope`. Constructed
7
+ * inside the iterate-report runner so it always shares the same `versionDir`
8
+ * as the rest of the report tools.
9
9
  */
10
10
  import { mkdir as fsMkdir, readFile as fsReadFile, stat as fsStat, writeFile as fsWriteFile } from "node:fs/promises";
11
11
  import { dirname, resolve as resolvePath, sep } from "node:path";
@@ -19,13 +19,15 @@ function resolveAgentPath(agentPath, versionDir) {
19
19
  if (agentPath.length === 0 || agentPath.includes("\0")) {
20
20
  return { kind: "out_of_scope", reason: "empty or null-byte path" };
21
21
  }
22
- // Strip a leading slash so an agent typing "/index.html" is treated as
23
- // "index.html" relative to versionDir (the version dir is the root).
24
- const stripped = agentPath.replace(/^\/+/, "");
25
- if (!stripped) {
26
- return { kind: "out_of_scope", reason: "path resolves to the version dir itself" };
22
+ // A leading slash is refused rather than trimmed. Trimming it makes the
23
+ // containment check below unreachable for absolute paths: a deep one such
24
+ // as "/previews/abc/v1/report.html.j2" would land a real file several
25
+ // directories below versionDir and report success, and the builder — which
26
+ // has no directory-listing tool — cannot find where its template went.
27
+ if (agentPath.startsWith("/")) {
28
+ return { kind: "out_of_scope", reason: `absolute path rejected, use one relative to the version dir: ${agentPath}` };
27
29
  }
28
- const absolute = resolvePath(versionDir, stripped);
30
+ const absolute = resolvePath(versionDir, agentPath);
29
31
  if (absolute !== versionDir && !absolute.startsWith(versionDir + sep)) {
30
32
  return { kind: "out_of_scope", reason: `path escapes version dir: ${agentPath}` };
31
33
  }
@@ -34,9 +36,10 @@ function resolveAgentPath(agentPath, versionDir) {
34
36
  export function createVersionFsTools(state) {
35
37
  const writeFile = defineTool({
36
38
  id: "write_file",
37
- description: "Write a UTF-8 text file under the report's version directory. Use " +
38
- "relative paths (e.g. `report.html.j2`, `assets/foo.csv`). Returns " +
39
- "`out_of_scope` for any path that escapes the version dir.",
39
+ description: "Write a UTF-8 text file under the report's version directory. Paths " +
40
+ "are relative to it (e.g. `report.html.j2`, `assets/foo.csv`); an " +
41
+ "absolute path, or one that escapes the version dir, returns " +
42
+ "`out_of_scope` and writes nothing.",
40
43
  inputSchema: z.object({
41
44
  path: z.string().min(1),
42
45
  content: z.string(),
@@ -62,8 +65,9 @@ export function createVersionFsTools(state) {
62
65
  id: "edit_file",
63
66
  description: "Surgical edit of an existing UTF-8 file inside the report's version " +
64
67
  "directory: replace the first occurrence of `oldText` with `newText`. " +
65
- "Returns `not_found` if the file doesn't exist, `no_match` if " +
66
- "`oldText` isn't present.",
68
+ "Paths are relative to that directory; an absolute or escaping path " +
69
+ "returns `out_of_scope`. Returns `not_found` if the file doesn't " +
70
+ "exist, `no_match` if `oldText` isn't present.",
67
71
  inputSchema: z.object({
68
72
  path: z.string().min(1),
69
73
  oldText: z.string().min(1),
@@ -102,7 +106,9 @@ export function createVersionFsTools(state) {
102
106
  });
103
107
  const readFile = defineTool({
104
108
  id: "read_file",
105
- description: "Read a UTF-8 file inside the report's version directory. Returns " + "truncated content (capped at 256 KiB) for large files.",
109
+ description: "Read a UTF-8 file inside the report's version directory. Paths are " +
110
+ "relative to it; an absolute or escaping path returns `out_of_scope`. " +
111
+ "Returns truncated content (capped at 256 KiB) for large files.",
106
112
  inputSchema: z.object({
107
113
  path: z.string().min(1),
108
114
  }),
@@ -137,7 +143,8 @@ export function createVersionFsTools(state) {
137
143
  });
138
144
  const mkdir = defineTool({
139
145
  id: "mkdir",
140
- description: "Create a directory (recursive) inside the report's version directory.",
146
+ description: "Create a directory (recursive) inside the report's version directory. " +
147
+ "Paths are relative to it; an absolute or escaping path returns `out_of_scope`.",
141
148
  inputSchema: z.object({
142
149
  path: z.string().min(1),
143
150
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.7.2",
3
+ "version": "0.9.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",
@@ -47,6 +47,7 @@
47
47
  "@ai-sdk/anthropic": "^4.0.15",
48
48
  "@ai-sdk/openai-compatible": "^3.0.10",
49
49
  "@ai-sdk/provider": "^4.0.3",
50
+ "@ai-sdk/provider-utils": "5.0.10",
50
51
  "@anthropic-ai/sdk": "^0.107.0",
51
52
  "@dbos-inc/dbos-sdk": "^4.23.6",
52
53
  "@kubernetes/client-node": "^1.4.0",