@inflexa-ai/harness 0.8.0 → 0.10.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/agents/conversation-agent.js +3 -1
- 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/execution/run-synthesis.d.ts +7 -0
- package/dist/execution/run-synthesis.js +8 -2
- 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/streaming-chat.d.ts +11 -2
- package/dist/providers/streaming-chat.js +31 -6
- package/dist/providers/types.d.ts +10 -1
- package/dist/schemas/validate-plan.js +10 -5
- 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/dist/tools/research/inspect-run.js +10 -3
- package/dist/tools/workspace/read-file.d.ts +5 -0
- package/dist/tools/workspace/read-file.js +20 -2
- package/dist/workflows/execute-analysis.d.ts +14 -1
- package/dist/workflows/execute-analysis.js +105 -8
- package/dist/workspace/paths.d.ts +10 -0
- package/dist/workspace/paths.js +10 -0
- package/package.json +1 -1
|
@@ -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
|
}),
|
|
@@ -16,6 +16,7 @@ import { ok } from "neverthrow";
|
|
|
16
16
|
import { z } from "zod";
|
|
17
17
|
import { queryRun, queryRunsByAnalysis, queryStepsByRun } from "../../state/index.js";
|
|
18
18
|
import { scopeResource } from "../../auth/types.js";
|
|
19
|
+
import { SYNTHESIS_STEP_ID } from "../../workspace/paths.js";
|
|
19
20
|
import { unwrapOrThrow } from "../../lib/result.js";
|
|
20
21
|
import { defineTool } from "../define-tool.js";
|
|
21
22
|
function formatRun(r, verbose) {
|
|
@@ -73,9 +74,15 @@ export function createInspectRunTool(pool) {
|
|
|
73
74
|
agentId: s.agentId,
|
|
74
75
|
wave: s.wave,
|
|
75
76
|
status: s.status,
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
77
|
+
// No per-step `summaryPath` when the file cannot exist: a seeded row
|
|
78
|
+
// (`pending`/`skipped`) never produced output, and the reserved
|
|
79
|
+
// `synthesis` phase is not a sandbox step — it writes the run-level
|
|
80
|
+
// `synthesis.json`, surfaced as the run's `synthesisPath`, not a
|
|
81
|
+
// `{stepId}/output/summary.md`. Emitting the per-step path for it would
|
|
82
|
+
// point the agent at a file that never exists.
|
|
83
|
+
...(s.status === "pending" || s.status === "skipped" || s.stepId === SYNTHESIS_STEP_ID
|
|
84
|
+
? {}
|
|
85
|
+
: { summaryPath: `runs/${input.runId}/${s.stepId}/output/summary.md` }),
|
|
79
86
|
...(verbose
|
|
80
87
|
? {
|
|
81
88
|
durationMs: s.durationMs,
|
|
@@ -30,6 +30,11 @@ type ReadFileOutput = {
|
|
|
30
30
|
} | {
|
|
31
31
|
status: "out_of_scope";
|
|
32
32
|
path: string;
|
|
33
|
+
} | {
|
|
34
|
+
status: "binary";
|
|
35
|
+
path: string;
|
|
36
|
+
mode: "head" | "tail" | "full";
|
|
37
|
+
totalSize?: number;
|
|
33
38
|
};
|
|
34
39
|
export declare function createReadFileTool(fs: WorkspaceFilesystem, workingDir?: string): import("../define-tool.js").Tool<{
|
|
35
40
|
path: string;
|
|
@@ -13,6 +13,18 @@ import { defineTool } from "../define-tool.js";
|
|
|
13
13
|
import { unwrapOrThrow } from "../../lib/result.js";
|
|
14
14
|
/** Default cap — large enough for typical CSV/MD/JSON, small enough to keep tool results out of context-blowing territory. */
|
|
15
15
|
const DEFAULT_MAX_BYTES = 256 * 1024;
|
|
16
|
+
/**
|
|
17
|
+
* Treat returned content as binary when it contains a NUL byte — `grep`'s
|
|
18
|
+
* `looksBinary` heuristic, applied here to the whole returned window rather than
|
|
19
|
+
* a 1 KiB probe. These exact bytes are what would be UTF-8-decoded into the
|
|
20
|
+
* transcript, and a NUL both renders that decode as lossy garbage (useless to
|
|
21
|
+
* the model) and cannot be persisted into the JSONB message store — Postgres
|
|
22
|
+
* rejects U+0000 with 22P05, failing the whole turn's append. Reporting the
|
|
23
|
+
* read as `binary` keeps a NUL out of both.
|
|
24
|
+
*/
|
|
25
|
+
function looksBinary(content) {
|
|
26
|
+
return content.includes(0);
|
|
27
|
+
}
|
|
16
28
|
const ReadFileInputSchema = z.object({
|
|
17
29
|
path: z
|
|
18
30
|
.string()
|
|
@@ -45,8 +57,10 @@ export function createReadFileTool(fs, workingDir) {
|
|
|
45
57
|
"as UTF-8 text. Output is capped at " +
|
|
46
58
|
`${DEFAULT_MAX_BYTES} bytes; pass headLines or tailLines to read a ` +
|
|
47
59
|
"specific window of a large file (typical for bio CSV/TSV/log files). " +
|
|
48
|
-
"Oversize reads come back truncated with a marker.
|
|
49
|
-
"
|
|
60
|
+
"Oversize reads come back truncated with a marker. This tool is for " +
|
|
61
|
+
"text; a binary file (one holding NUL bytes, e.g. a .gz or image) comes " +
|
|
62
|
+
"back as a `binary` data variant rather than decoded garbage. Missing " +
|
|
63
|
+
"paths and paths outside the analysis tree return a data variant — not an error.",
|
|
50
64
|
inputSchema: ReadFileInputSchema,
|
|
51
65
|
execute: async ({ path, headLines, tailLines }, ctx) => {
|
|
52
66
|
if (headLines !== undefined && tailLines !== undefined) {
|
|
@@ -67,6 +81,8 @@ export function createReadFileTool(fs, workingDir) {
|
|
|
67
81
|
const mode = headLines !== undefined ? "head" : tailLines !== undefined ? "tail" : "full";
|
|
68
82
|
switch (result.kind) {
|
|
69
83
|
case "ok":
|
|
84
|
+
if (looksBinary(result.content))
|
|
85
|
+
return ok({ status: "binary", path, mode });
|
|
70
86
|
return ok({
|
|
71
87
|
status: "ok",
|
|
72
88
|
path,
|
|
@@ -74,6 +90,8 @@ export function createReadFileTool(fs, workingDir) {
|
|
|
74
90
|
content: result.content.toString("utf8"),
|
|
75
91
|
});
|
|
76
92
|
case "truncated":
|
|
93
|
+
if (looksBinary(result.content))
|
|
94
|
+
return ok({ status: "binary", path, mode, totalSize: result.totalSize });
|
|
77
95
|
return ok({
|
|
78
96
|
status: "truncated",
|
|
79
97
|
path,
|
|
@@ -63,7 +63,7 @@ import type { MachineBudget, ResourceSpec } from "../config/resource-limits.js";
|
|
|
63
63
|
import type { RunSession } from "../auth/types.js";
|
|
64
64
|
import type { RunAuthorizer } from "../execution/run-authorizer.js";
|
|
65
65
|
import type { Logger } from "../lib/logger.js";
|
|
66
|
-
import type { SynthesisStatus } from "../state/index.js";
|
|
66
|
+
import type { SynthesisStatus, UpdateStepExecutionInput } from "../state/index.js";
|
|
67
67
|
import type { AnalysisStep } from "../schemas/workflow-state.js";
|
|
68
68
|
import type { ChatProvider, EmbeddingProvider } from "../providers/types.js";
|
|
69
69
|
import type { BioToolKeys } from "../tools/bio/keys.js";
|
|
@@ -288,6 +288,19 @@ export declare function composeStepSeed(args: {
|
|
|
288
288
|
runId: string;
|
|
289
289
|
deps: ExecuteAnalysisDeps;
|
|
290
290
|
}): Promise<string>;
|
|
291
|
+
/**
|
|
292
|
+
* Project a classified synthesis outcome onto the run-phase `synthesis` ledger
|
|
293
|
+
* row's terminal update (see the run-synthesis-outcome spec). The blocker keeps
|
|
294
|
+
* its reason in `blockedReason` — an honest agent-declared blocker, not an
|
|
295
|
+
* error — and a failure carries the same reason string
|
|
296
|
+
* `persist-synthesis-outcome` writes to `cortex_runs.synthesis_reason`, so the
|
|
297
|
+
* two ledgers cannot disagree about why synthesis died. Exported for the
|
|
298
|
+
* projection tests (the `buildChildInput` pattern).
|
|
299
|
+
*/
|
|
300
|
+
export declare function synthesisRowUpdate(outcome: {
|
|
301
|
+
status: SynthesisStatus;
|
|
302
|
+
reason: string | null;
|
|
303
|
+
}, durationMs: number): UpdateStepExecutionInput;
|
|
291
304
|
/**
|
|
292
305
|
* Run-level literature-grounded synthesis (no sandbox). Returns the
|
|
293
306
|
* quick-display findings for the run-completed card; empty when synthesis is
|
|
@@ -58,17 +58,17 @@
|
|
|
58
58
|
* - WRITES NOTHING to the conversation thread (results are pull-only
|
|
59
59
|
* via `inspectRun`)
|
|
60
60
|
*/
|
|
61
|
-
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
61
|
+
import { DBOS, Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
|
|
62
62
|
import { mkdir } from "node:fs/promises";
|
|
63
63
|
import { join } from "node:path";
|
|
64
64
|
import { forStep } from "../auth/types.js";
|
|
65
65
|
import { createNoopLogger } from "../lib/console-logger.js";
|
|
66
66
|
import { unwrapOrThrow } from "../lib/result.js";
|
|
67
|
-
import { RunDedupCollisionError, countArtifactsForRun, loadDataProfileStatus, queryActiveRun, queryStepArtifactPaths, seedStepExecutions, setRunSynthesisOutcome, suspendAnalysis as suspendAnalysisQuery, sweepPendingStepExecutions, updateRunStatus, } from "../state/index.js";
|
|
67
|
+
import { RunDedupCollisionError, countArtifactsForRun, insertStepExecution, loadDataProfileStatus, queryActiveRun, queryStepArtifactPaths, seedStepExecutions, setRunSynthesisOutcome, suspendAnalysis as suspendAnalysisQuery, sweepPendingStepExecutions, updateRunStatus, updateStepExecution, } from "../state/index.js";
|
|
68
68
|
import { isBudgetExceeded } from "../loop/budget-exceeded.js";
|
|
69
|
-
import { loadStepSummariesFromDisk } from "../execution/run-synthesis.js";
|
|
69
|
+
import { SYNTHESIS_AGENT_ID, loadStepSummariesFromDisk } from "../execution/run-synthesis.js";
|
|
70
70
|
import { MAX_UPSTREAM_ARTIFACTS, composeStepBriefing } from "../prompts/briefing.js";
|
|
71
|
-
import { runDir, runStepDir, stepSubdir, stepWritePrefix, toSandboxPath } from "../workspace/paths.js";
|
|
71
|
+
import { SYNTHESIS_STEP_ID, runDir, runStepDir, stepSubdir, stepWritePrefix, toSandboxPath } from "../workspace/paths.js";
|
|
72
72
|
import { isChatDataPart } from "../sandbox/sandbox-step-translate.js";
|
|
73
73
|
import { synthesizeRun } from "../app/synthesize-run.js";
|
|
74
74
|
import { computeTopologicalLevels, scheduleReady, validatePlanDag } from "./execute-analysis-scheduler.js";
|
|
@@ -222,6 +222,31 @@ async function collectUpstreamHandoffs(args) {
|
|
|
222
222
|
}
|
|
223
223
|
return handoffs;
|
|
224
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* Project a classified synthesis outcome onto the run-phase `synthesis` ledger
|
|
227
|
+
* row's terminal update (see the run-synthesis-outcome spec). The blocker keeps
|
|
228
|
+
* its reason in `blockedReason` — an honest agent-declared blocker, not an
|
|
229
|
+
* error — and a failure carries the same reason string
|
|
230
|
+
* `persist-synthesis-outcome` writes to `cortex_runs.synthesis_reason`, so the
|
|
231
|
+
* two ledgers cannot disagree about why synthesis died. Exported for the
|
|
232
|
+
* projection tests (the `buildChildInput` pattern).
|
|
233
|
+
*/
|
|
234
|
+
export function synthesisRowUpdate(outcome, durationMs) {
|
|
235
|
+
switch (outcome.status) {
|
|
236
|
+
case "produced":
|
|
237
|
+
return { status: "completed", durationMs };
|
|
238
|
+
case "skipped_no_summaries":
|
|
239
|
+
return { status: "skipped", durationMs };
|
|
240
|
+
case "skipped_blocker":
|
|
241
|
+
return { status: "blocked", durationMs, blockedReason: outcome.reason };
|
|
242
|
+
case "failed":
|
|
243
|
+
return { status: "failed", durationMs, error: outcome.reason };
|
|
244
|
+
default: {
|
|
245
|
+
const _exhaustive = outcome.status;
|
|
246
|
+
throw new Error(`unhandled synthesis status: ${String(_exhaustive)}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
225
250
|
/**
|
|
226
251
|
* Run-level literature-grounded synthesis (no sandbox). Returns the
|
|
227
252
|
* quick-display findings for the run-completed card; empty when synthesis is
|
|
@@ -309,14 +334,32 @@ export async function runExecuteAnalysisBody(input, deps) {
|
|
|
309
334
|
// The seed's DO NOTHING conflict action makes this replay-safe; the agent
|
|
310
335
|
// fallback mirrors `buildChildInput` so seed and mark-running agree on the
|
|
311
336
|
// row a step's child later upserts.
|
|
337
|
+
//
|
|
338
|
+
// The run-phase `synthesis` row is seeded alongside the DAG — gated on
|
|
339
|
+
// enablement ONLY, because the other half of the run gate for actually
|
|
340
|
+
// executing synthesis (`final.completed.size > 0`) is unknowable until the
|
|
341
|
+
// scheduler returns. A seeded row whose gate later fails simply stays
|
|
342
|
+
// `pending` and the terminal sweep finalizes it to `skipped` — honest
|
|
343
|
+
// ("never ran, never will"), like any never-dispatched DAG step. Its wave
|
|
344
|
+
// sits after every DAG level so ledger-ordered readers render it last.
|
|
345
|
+
const synthesisEnabled = deps.synthesisEnabled ?? true;
|
|
346
|
+
const synthesisWave = Math.max(-1, ...levels.values()) + 1;
|
|
312
347
|
await DBOS.runStep(async () => {
|
|
313
|
-
|
|
348
|
+
const dagRows = input.steps.map((step) => ({
|
|
314
349
|
runId,
|
|
315
350
|
stepId: step.id,
|
|
316
351
|
analysisId: input.analysisId,
|
|
317
352
|
wave: levels.get(step.id) ?? 0,
|
|
318
353
|
agentId: input.agentByStepId[step.id] ?? "scientific-executor",
|
|
319
|
-
}))
|
|
354
|
+
}));
|
|
355
|
+
const synthesisRow = {
|
|
356
|
+
runId,
|
|
357
|
+
stepId: SYNTHESIS_STEP_ID,
|
|
358
|
+
analysisId: input.analysisId,
|
|
359
|
+
wave: synthesisWave,
|
|
360
|
+
agentId: SYNTHESIS_AGENT_ID,
|
|
361
|
+
};
|
|
362
|
+
unwrapOrThrow(await seedStepExecutions(deps.pool, synthesisEnabled ? [...dagRows, synthesisRow] : dagRows));
|
|
320
363
|
}, { name: "seed-step-executions" });
|
|
321
364
|
// One checkpointed clock read stamps the run's start; the terminal read in
|
|
322
365
|
// `collectAndComplete` subtracts it for the true run span. Checkpointed →
|
|
@@ -352,10 +395,37 @@ export async function runExecuteAnalysisBody(input, deps) {
|
|
|
352
395
|
let synthesisError = null;
|
|
353
396
|
let synthesisFindings = [];
|
|
354
397
|
// Null when synthesis never ran (disabled or no completed steps): the ledger
|
|
355
|
-
// columns stay NULL — "unknown", not a recorded skip
|
|
398
|
+
// columns stay NULL — "unknown", not a recorded skip — and the seeded
|
|
399
|
+
// `synthesis` row (if any) stays `pending` for the terminal sweep.
|
|
356
400
|
let synthesisOutcome = null;
|
|
357
|
-
const synthesisEnabled = deps.synthesisEnabled ?? true;
|
|
358
401
|
if (synthesisEnabled && final.completed.size > 0) {
|
|
402
|
+
// Bracketing clock reads for the row's duration_ms — checkpointed, so a
|
|
403
|
+
// recovered replay reuses the original instants (same rule as `startedAtMs`).
|
|
404
|
+
const synthStartedAtMs = await DBOS.now();
|
|
405
|
+
// Mark the seeded `synthesis` row running. The same upsert the DAG children
|
|
406
|
+
// use: it stamps started_at, and it CREATES the row outright when the seed
|
|
407
|
+
// never carried it (synthesisEnabled flipped true across a recovery
|
|
408
|
+
// redeploy) — the mark self-heals rather than trusting the seed's snapshot
|
|
409
|
+
// of the config. Log-don't-fail: a progress row must never fail an
|
|
410
|
+
// otherwise-healthy run. Cancellation still re-propagates — it is not a
|
|
411
|
+
// write failure, and swallowing it here would only defer it one operation.
|
|
412
|
+
try {
|
|
413
|
+
await DBOS.runStep(async () => {
|
|
414
|
+
unwrapOrThrow(await insertStepExecution(deps.pool, {
|
|
415
|
+
runId,
|
|
416
|
+
stepId: SYNTHESIS_STEP_ID,
|
|
417
|
+
analysisId: input.analysisId,
|
|
418
|
+
wave: synthesisWave,
|
|
419
|
+
agentId: SYNTHESIS_AGENT_ID,
|
|
420
|
+
childWorkflowId: null,
|
|
421
|
+
}));
|
|
422
|
+
}, { name: "mark-synthesis-running" });
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
if (err instanceof DBOSErrors.DBOSWorkflowCancelledError)
|
|
426
|
+
throw err;
|
|
427
|
+
logger.error("mark-synthesis-running failed", { runId, ...logger.errorFields(err) });
|
|
428
|
+
}
|
|
359
429
|
try {
|
|
360
430
|
const synthOut = await DBOS.runStep(() => synthesizeFindings({
|
|
361
431
|
analysisId: input.analysisId,
|
|
@@ -368,10 +438,37 @@ export async function runExecuteAnalysisBody(input, deps) {
|
|
|
368
438
|
synthesisOutcome = { status: synthOut.synthesisStatus, reason: synthOut.synthesisReason };
|
|
369
439
|
}
|
|
370
440
|
catch (err) {
|
|
441
|
+
// Cancellation is not a synthesis outcome: re-propagate unchanged (the
|
|
442
|
+
// sandbox-step rule), so a cancelled workflow is never recorded as a
|
|
443
|
+
// synthesis failure — neither on the row nor in cortex_runs. The row is
|
|
444
|
+
// deliberately left `running`: `collectAndComplete` never executes on
|
|
445
|
+
// this path either, so the run row is equally non-terminal and the pair
|
|
446
|
+
// stays consistent — the pre-existing cancelled-mid-flight wedge shape
|
|
447
|
+
// `inflexa run` already detects via its DBOS-status cross-check.
|
|
448
|
+
if (err instanceof DBOSErrors.DBOSWorkflowCancelledError)
|
|
449
|
+
throw err;
|
|
371
450
|
synthesisError = err;
|
|
372
451
|
synthesisOutcome = { status: "failed", reason: err instanceof Error ? err.message : String(err) };
|
|
373
452
|
logger.error("synthesizeFindings failed — failing the run", { runId, ...logger.errorFields(err) });
|
|
374
453
|
}
|
|
454
|
+
// Terminal stamp for the `synthesis` row — deliberately BEFORE
|
|
455
|
+
// `collectAndComplete` writes the run row's terminal status, so no reader
|
|
456
|
+
// can observe a terminal run beside a still-`running` synthesis row. Both
|
|
457
|
+
// branches above set `synthesisOutcome` (or threw), so it is non-null here
|
|
458
|
+
// and the compiler narrows it — no guard needed. Log-don't-fail, like the
|
|
459
|
+
// sibling finalisation writes.
|
|
460
|
+
const rowOutcome = synthesisOutcome;
|
|
461
|
+
try {
|
|
462
|
+
const synthEndedAtMs = await DBOS.now();
|
|
463
|
+
await DBOS.runStep(async () => {
|
|
464
|
+
unwrapOrThrow(await updateStepExecution(deps.pool, runId, SYNTHESIS_STEP_ID, synthesisRowUpdate(rowOutcome, synthEndedAtMs - synthStartedAtMs)));
|
|
465
|
+
}, { name: "persist-synthesis-step" });
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
if (err instanceof DBOSErrors.DBOSWorkflowCancelledError)
|
|
469
|
+
throw err;
|
|
470
|
+
logger.error("persist-synthesis-step failed", { runId, ...logger.errorFields(err) });
|
|
471
|
+
}
|
|
375
472
|
}
|
|
376
473
|
// (6) collectAndComplete — terminal block. Runs on EVERY path. When synthesis
|
|
377
474
|
// failed, force a failed terminal status so the charge/run-authorization close correctly
|
|
@@ -181,5 +181,15 @@ export declare function stepSubdir(stepBase: string, type: StepSubdirType): stri
|
|
|
181
181
|
export declare function reportDir(reportId: string): string;
|
|
182
182
|
/** All standard subdirectory types for a step. */
|
|
183
183
|
export declare const STEP_SUBDIRS: readonly StepSubdirType[];
|
|
184
|
+
/**
|
|
185
|
+
* Reserved id of the run-phase synthesis row in `cortex_step_executions` — the
|
|
186
|
+
* ledger row `executeAnalysis` writes for run-level synthesis so progress
|
|
187
|
+
* readers see the phase. Reserved here beside {@link STEP_SUBDIRS} because the
|
|
188
|
+
* reservation is a layout fact as much as a ledger one: a plan step with this
|
|
189
|
+
* id would collide with the row's primary key AND put its step directory
|
|
190
|
+
* `runs/{runId}/synthesis/` beside the run-level `runs/{runId}/synthesis.json`.
|
|
191
|
+
* Plan validation rejects it (see validate-plan).
|
|
192
|
+
*/
|
|
193
|
+
export declare const SYNTHESIS_STEP_ID = "synthesis";
|
|
184
194
|
/** Get all subdirectory paths for a step. */
|
|
185
195
|
export declare function allStepSubdirs(stepBase: string): string[];
|
package/dist/workspace/paths.js
CHANGED
|
@@ -213,6 +213,16 @@ export function reportDir(reportId) {
|
|
|
213
213
|
}
|
|
214
214
|
/** All standard subdirectory types for a step. */
|
|
215
215
|
export const STEP_SUBDIRS = ["scripts", "output", "figures", "logs", "notebooks"];
|
|
216
|
+
/**
|
|
217
|
+
* Reserved id of the run-phase synthesis row in `cortex_step_executions` — the
|
|
218
|
+
* ledger row `executeAnalysis` writes for run-level synthesis so progress
|
|
219
|
+
* readers see the phase. Reserved here beside {@link STEP_SUBDIRS} because the
|
|
220
|
+
* reservation is a layout fact as much as a ledger one: a plan step with this
|
|
221
|
+
* id would collide with the row's primary key AND put its step directory
|
|
222
|
+
* `runs/{runId}/synthesis/` beside the run-level `runs/{runId}/synthesis.json`.
|
|
223
|
+
* Plan validation rejects it (see validate-plan).
|
|
224
|
+
*/
|
|
225
|
+
export const SYNTHESIS_STEP_ID = "synthesis";
|
|
216
226
|
/** Get all subdirectory paths for a step. */
|
|
217
227
|
export function allStepSubdirs(stepBase) {
|
|
218
228
|
return STEP_SUBDIRS.map((type) => stepSubdir(stepBase, type));
|
package/package.json
CHANGED