@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.
- package/dist/contracts/message.d.ts +6 -0
- package/dist/execution/report-runner.d.ts +6 -1
- package/dist/execution/report-runner.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/loop/run-agent.d.ts +1 -1
- package/dist/loop/run-agent.js +55 -2
- package/dist/memory/ai-sdk-message-storage.d.ts +42 -0
- package/dist/memory/ai-sdk-message-storage.js +62 -0
- package/dist/memory/content-to-cortex.d.ts +8 -1
- package/dist/memory/content-to-cortex.js +36 -4
- package/dist/memory/thread-history.d.ts +37 -0
- package/dist/memory/thread-history.js +74 -7
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +3 -3
- package/dist/prompts/report-builder.d.ts +1 -1
- package/dist/prompts/report-builder.js +1 -1
- package/dist/providers/ai-sdk.d.ts +23 -0
- package/dist/providers/ai-sdk.js +254 -34
- package/dist/providers/anthropic.d.ts +2 -0
- package/dist/providers/anthropic.js +1 -0
- package/dist/providers/streaming-chat.d.ts +11 -2
- package/dist/providers/streaming-chat.js +31 -6
- package/dist/providers/types.d.ts +10 -1
- package/dist/tools/iterate-report.d.ts +0 -1
- package/dist/tools/iterate-report.js +125 -11
- package/dist/tools/report/mint-preview-url.d.ts +1 -1
- package/dist/tools/report/mint-preview-url.js +7 -5
- package/dist/tools/report/preview-publisher.d.ts +16 -0
- package/dist/tools/report/preview-publisher.js +20 -0
- package/dist/tools/report/preview-snapshot.d.ts +9 -1
- package/dist/tools/report/preview-snapshot.js +15 -1
- package/dist/tools/report/submit-report.d.ts +1 -0
- package/dist/tools/report/submit-report.js +32 -11
- package/dist/tools/report/version-fs.d.ts +4 -4
- package/dist/tools/report/version-fs.js +24 -17
- 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"
|
|
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
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
19
|
-
"URL with embedded auth token
|
|
20
|
-
"
|
|
21
|
-
"the common case
|
|
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:
|
|
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 {
|
|
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:
|
|
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
|
|
27
|
-
"unrendered Jinja, and that all
|
|
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.
|
|
31
|
-
"
|
|
32
|
-
"
|
|
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: '
|
|
40
|
-
"'
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
6
|
-
* escapes is rejected as `out_of_scope`. Constructed
|
|
7
|
-
* iterate-report runner so it always shares the same `versionDir`
|
|
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
|
|
6
|
-
* escapes is rejected as `out_of_scope`. Constructed
|
|
7
|
-
* iterate-report runner so it always shares the same `versionDir`
|
|
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
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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,
|
|
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.
|
|
38
|
-
"relative
|
|
39
|
-
"
|
|
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
|
-
"
|
|
66
|
-
"`
|
|
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.
|
|
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.
|
|
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",
|