@inflexa-ai/harness 0.20.0 → 0.21.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.d.ts +26 -14
- package/dist/agents/conversation-agent.js +12 -23
- package/dist/agents/report-session-agent.d.ts +31 -5
- package/dist/agents/report-session-agent.js +31 -6
- package/dist/app/chat-turn.js +3 -1
- package/dist/app/message-assembly.d.ts +15 -1
- package/dist/app/message-assembly.js +22 -8
- package/dist/app/report-session-runtime.d.ts +21 -4
- package/dist/app/report-session-runtime.js +53 -6
- package/dist/app/spawn-report-session.d.ts +174 -17
- package/dist/app/spawn-report-session.js +233 -21
- package/dist/contracts/chat-events.d.ts +7 -2
- package/dist/contracts/message.d.ts +10 -0
- package/dist/contracts/report-blocks.d.ts +743 -19
- package/dist/contracts/report-blocks.js +186 -11
- package/dist/contracts/report-reference.d.ts +88 -0
- package/dist/contracts/report-reference.js +48 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +20 -0
- package/dist/lib/chrome.d.ts +26 -6
- package/dist/lib/chrome.js +99 -32
- package/dist/lib/eyes.d.ts +57 -0
- package/dist/lib/eyes.js +45 -0
- package/dist/lib/page-capture.d.ts +14 -0
- package/dist/lib/page-capture.js +56 -2
- package/dist/loop/run-agent.js +58 -23
- package/dist/loop/tool-detail.d.ts +19 -2
- package/dist/loop/tool-detail.js +35 -2
- package/dist/memory/content-to-cortex.d.ts +3 -2
- package/dist/memory/content-to-cortex.js +21 -12
- package/dist/memory/conversation-display-replay.js +14 -7
- package/dist/memory/thread-history.d.ts +60 -3
- package/dist/memory/thread-history.js +55 -16
- package/dist/memory/thread-store.d.ts +11 -1
- package/dist/memory/thread-store.js +12 -2
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +23 -6
- package/dist/prompts/report-session.d.ts +1 -1
- package/dist/prompts/report-session.js +183 -9
- package/dist/report-model/authoring-grammar.d.ts +463 -0
- package/dist/report-model/authoring-grammar.js +77 -0
- package/dist/report-model/block-walk.d.ts +57 -14
- package/dist/report-model/block-walk.js +116 -11
- package/dist/report-model/draft-finish.d.ts +22 -4
- package/dist/report-model/draft-finish.js +39 -7
- package/dist/report-model/draft-operations.d.ts +3 -0
- package/dist/report-model/draft-operations.js +19 -5
- package/dist/report-model/draft-read.d.ts +8 -5
- package/dist/report-model/draft-read.js +21 -5
- package/dist/report-model/fixture-resolver.js +15 -4
- package/dist/report-model/pin-snapshot.d.ts +24 -6
- package/dist/report-model/pin-snapshot.js +204 -6
- package/dist/report-model/production-resolver.js +15 -4
- package/dist/report-model/reference-resolver.d.ts +50 -1
- package/dist/report-model/reference-resolver.js +94 -1
- package/dist/report-model/reference-stamp.d.ts +45 -0
- package/dist/report-model/reference-stamp.js +173 -0
- package/dist/report-model/structural-validation.d.ts +4 -1
- package/dist/report-model/structural-validation.js +48 -3
- package/dist/report-model/validate.d.ts +1 -1
- package/dist/report-model/validate.js +1 -1
- package/dist/report-render/asset-lookup.d.ts +19 -0
- package/dist/report-render/asset-lookup.js +37 -0
- package/dist/report-render/assets.d.ts +45 -1
- package/dist/report-render/assets.js +65 -6
- package/dist/report-render/chart-presets.d.ts +102 -0
- package/dist/report-render/chart-presets.js +205 -0
- package/dist/report-render/chart.d.ts +112 -10
- package/dist/report-render/chart.js +1164 -41
- package/dist/report-render/design.d.ts +99 -0
- package/dist/report-render/design.js +289 -107
- package/dist/report-render/number-format.d.ts +117 -0
- package/dist/report-render/number-format.js +479 -0
- package/dist/report-render/page.d.ts +121 -5
- package/dist/report-render/page.js +689 -7
- package/dist/report-render/references.d.ts +70 -16
- package/dist/report-render/references.js +54 -22
- package/dist/report-render/render.d.ts +23 -5
- package/dist/report-render/render.js +81 -22
- package/dist/report-render/table-data.d.ts +92 -0
- package/dist/report-render/table-data.js +129 -0
- package/dist/report-render/types.d.ts +15 -0
- package/dist/report-render/value-bridge.js +6 -2
- package/dist/report-render/views/chart-view.d.ts +12 -5
- package/dist/report-render/views/chart-view.js +14 -8
- package/dist/report-render/views/page-view.d.ts +36 -15
- package/dist/report-render/views/page-view.js +45 -23
- package/dist/report-render/views/prose.d.ts +12 -2
- package/dist/report-render/views/prose.js +29 -7
- package/dist/report-render/views/references-view.d.ts +22 -8
- package/dist/report-render/views/references-view.js +101 -13
- package/dist/report-render/views/values.d.ts +128 -13
- package/dist/report-render/views/values.js +199 -18
- package/dist/runtime/assemble.d.ts +39 -5
- package/dist/runtime/assemble.js +47 -6
- package/dist/sandbox/client.d.ts +10 -1
- package/dist/sandbox/create-sandbox.d.ts +9 -4
- package/dist/sandbox/create-sandbox.js +22 -16
- package/dist/sandbox/docker-client.js +10 -7
- package/dist/sandbox/mount-plan.d.ts +45 -7
- package/dist/sandbox/mount-plan.js +43 -5
- package/dist/sandbox/types.d.ts +6 -0
- package/dist/state/init.js +38 -9
- package/dist/state/report-session-state.d.ts +42 -1
- package/dist/state/report-session-state.js +70 -2
- package/dist/state/report-versions.d.ts +22 -17
- package/dist/state/report-versions.js +48 -35
- package/dist/state/snapshot-parse.js +16 -1
- package/dist/tools/define-tool.d.ts +32 -0
- package/dist/tools/define-tool.js +4 -0
- package/dist/tools/detail-resolver.d.ts +7 -2
- package/dist/tools/detail-resolver.js +7 -2
- package/dist/tools/iterate-report.d.ts +1 -1
- package/dist/tools/report/preview-snapshot.js +1 -0
- package/dist/tools/report-authoring/authoring-tools.d.ts +15 -6
- package/dist/tools/report-authoring/authoring-tools.js +99 -18
- package/dist/tools/report-session/derive-table.d.ts +162 -0
- package/dist/tools/report-session/derive-table.js +459 -0
- package/dist/tools/report-session/examine-page.d.ts +48 -18
- package/dist/tools/report-session/examine-page.js +204 -24
- package/dist/tools/report-session/index.d.ts +2 -0
- package/dist/tools/report-session/index.js +2 -0
- package/dist/tools/report-session/list-artifacts.d.ts +97 -0
- package/dist/tools/report-session/list-artifacts.js +197 -0
- package/dist/tools/report-session/preview-report.d.ts +6 -0
- package/dist/tools/report-session/preview-report.js +180 -28
- package/dist/tools/report-session/record-version.d.ts +22 -10
- package/dist/tools/report-session/record-version.js +99 -14
- package/dist/tools/start-report-session.d.ts +115 -0
- package/dist/tools/start-report-session.js +179 -0
- package/dist/workspace/paths.d.ts +50 -0
- package/dist/workspace/paths.js +63 -0
- package/package.json +2 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The record tool of a report session.
|
|
3
3
|
*
|
|
4
|
-
* The tool records one version of the report.
|
|
5
|
-
* the
|
|
6
|
-
*
|
|
4
|
+
* The tool records one version of the report. A version that the gate did not accept must never stand, thus
|
|
5
|
+
* the tool runs the whole gate first, and only a pass reaches the store. The gate and the look rule run on
|
|
6
|
+
* every record, the first and each later one alike.
|
|
7
7
|
*
|
|
8
8
|
* The gate has three parts, in order of cost. The finish gates the schema, the unique ids, and the
|
|
9
9
|
* structural tier, and a gap list returns as data. The look-before-record rule then compares the seen hash
|
|
@@ -18,16 +18,28 @@
|
|
|
18
18
|
* before the resolver constructs.
|
|
19
19
|
*
|
|
20
20
|
* On a pass the store records the document that the gate validated, the pinned snapshot, and the anchor from
|
|
21
|
-
* the thread row.
|
|
22
|
-
*
|
|
21
|
+
* the thread row. A record on a thread that holds a version replaces that version whole, under the same
|
|
22
|
+
* version id. Thus the loop is unbounded: the agent amends, looks again, and records again, and the stored
|
|
23
|
+
* version equals the page that the last look accepted. A refused record leaves the stored version as it was.
|
|
24
|
+
* The outcome of a pass carries the version id, and it names a replacement.
|
|
25
|
+
*
|
|
26
|
+
* The prune runs after the version lands. It removes the output file of each derivation that the recorded
|
|
27
|
+
* document does not reference, under the `derived/` directory of the session alone. The records stay
|
|
28
|
+
* append-only, because the bytes are reproducible from the script and the sources. A failed removal logs and
|
|
29
|
+
* changes no outcome.
|
|
23
30
|
*/
|
|
24
31
|
import { ok } from "neverthrow";
|
|
32
|
+
import { rm } from "node:fs/promises";
|
|
33
|
+
import { resolve as resolvePath, sep } from "node:path";
|
|
25
34
|
import { z } from "zod";
|
|
26
35
|
import { createNoopLogger } from "../../lib/console-logger.js";
|
|
27
36
|
import { describeDbError } from "../../lib/db-result.js";
|
|
37
|
+
import { describeFsError, tryFsWrite } from "../../lib/fs-result.js";
|
|
38
|
+
import { referencedPaths, walkBlocks } from "../../report-model/block-walk.js";
|
|
28
39
|
import { finishDraft } from "../../report-model/draft-finish.js";
|
|
29
40
|
import { computeDraftHash } from "../../report-model/draft-hash.js";
|
|
30
41
|
import { validateReport } from "../../report-model/validate.js";
|
|
42
|
+
import { reportSessionDerivedDir, resolveWorkspacePath } from "../../workspace/paths.js";
|
|
31
43
|
import { defineTool } from "../define-tool.js";
|
|
32
44
|
import { openReportThread } from "../report-authoring/authoring-tools.js";
|
|
33
45
|
/** The empty input. The tool records the current draft of the thread, thus it needs no field. */
|
|
@@ -41,12 +53,66 @@ function describeRecordFailure(error) {
|
|
|
41
53
|
return "the pinned snapshot is malformed";
|
|
42
54
|
case "parent_analysis_mismatch":
|
|
43
55
|
return "the parent version belongs to a different analysis";
|
|
44
|
-
case "thread_already_holds_version":
|
|
45
|
-
return "the thread already holds a recorded version";
|
|
46
56
|
default:
|
|
47
57
|
return describeDbError(error);
|
|
48
58
|
}
|
|
49
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Remove the output file of each derivation that the recorded document does not reference.
|
|
62
|
+
*
|
|
63
|
+
* The unused set is a set difference: a derivation is used when a binding of the recorded document names its
|
|
64
|
+
* output path. The document is the one that the gate validated, thus the prune reads what the version holds
|
|
65
|
+
* and never the draft.
|
|
66
|
+
*
|
|
67
|
+
* The prune reaches the `derived/` directory of the session alone. A record whose path resolves outside that
|
|
68
|
+
* directory is skipped, because this tool never removes a file that it does not own. A stale record from an
|
|
69
|
+
* earlier layout and a crafted path both land in that arm.
|
|
70
|
+
*
|
|
71
|
+
* The version already stands at this point. Thus each failure costs the cleanup alone: an unresolvable root,
|
|
72
|
+
* a path that escapes, and a failed removal each log and change no outcome. The records stay, and the bytes
|
|
73
|
+
* are reproducible from the script and the sources. The scope of the prune resolves inside the same guard as
|
|
74
|
+
* the root, because both builders throw and a throw here would read as a failure of a call that succeeded.
|
|
75
|
+
*/
|
|
76
|
+
async function pruneUnusedDerivations(args) {
|
|
77
|
+
if (args.derivations.length === 0) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const named = referencedPaths(walkBlocks(args.document.sections).references);
|
|
81
|
+
const unused = args.derivations.filter((record) => !named.has(record.outputPath));
|
|
82
|
+
if (unused.length === 0) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
let root;
|
|
86
|
+
let derivedDir;
|
|
87
|
+
try {
|
|
88
|
+
root = args.resolveWorkspaceRoot(args.analysisId);
|
|
89
|
+
derivedDir = resolvePath(root, reportSessionDerivedDir(args.threadId));
|
|
90
|
+
}
|
|
91
|
+
catch (cause) {
|
|
92
|
+
args.logger.warn("the unused derivations did not prune", {
|
|
93
|
+
threadId: args.threadId,
|
|
94
|
+
analysisId: args.analysisId,
|
|
95
|
+
...args.logger.errorFields(cause),
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
for (const record of unused) {
|
|
100
|
+
const resolved = resolveWorkspacePath({ workspaceRoot: root, analysisId: args.analysisId, path: record.outputPath });
|
|
101
|
+
if (resolved.kind !== "ok" || !resolved.absolute.startsWith(derivedDir + sep)) {
|
|
102
|
+
args.logger.warn("an unused derivation sits outside the derived directory of the session", {
|
|
103
|
+
threadId: args.threadId,
|
|
104
|
+
analysisId: args.analysisId,
|
|
105
|
+
path: record.outputPath,
|
|
106
|
+
});
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
// An absent file is the normal condition of a prune that ran before, thus `force` treats it as done.
|
|
110
|
+
const removed = await tryFsWrite("record.rm", () => rm(resolved.absolute, { force: true }), { path: resolved.absolute });
|
|
111
|
+
if (removed.isErr()) {
|
|
112
|
+
args.logger.warn("an unused derivation did not go", { path: resolved.absolute, detail: describeFsError(removed.error) });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
50
116
|
/**
|
|
51
117
|
* Make the record tool over the session-state gateway, the version store, the thread store, and the
|
|
52
118
|
* resolver.
|
|
@@ -61,18 +127,27 @@ export function createRecordVersionTool(deps) {
|
|
|
61
127
|
description: "Record the current draft as one report version. The tool runs the whole gate first: it finishes the draft, " +
|
|
62
128
|
"resolves each reference, matches each chart encoding, and matches each assert. An incomplete draft gives back the gap list, " +
|
|
63
129
|
"and a failed reference gives back the block that broke. The tool records a version only after the eyes look at the current page. " +
|
|
64
|
-
"A thread holds one version, thus a
|
|
130
|
+
"A thread holds one version, thus a later record replaces it whole. Amend the draft, look at the page again, and record again. " +
|
|
131
|
+
"After the version lands, the tool removes the file of each derived table that no block of the recorded report binds.",
|
|
65
132
|
inputSchema: recordVersionInput,
|
|
66
133
|
executionMode: "inline",
|
|
67
134
|
describeCall: "none",
|
|
135
|
+
// The version id is the one durable product of the first record, thus the created arm names it. A
|
|
136
|
+
// later record writes over that same id, thus the line names the update and never reads as a
|
|
137
|
+
// refusal. Each other arm is a gate that refused, and the kind of the arm is what a watcher reads.
|
|
138
|
+
describeResult: (_input, result) => {
|
|
139
|
+
if (result.outcome !== "recorded")
|
|
140
|
+
return result.outcome;
|
|
141
|
+
return result.replaced ? "version updated" : `version ${result.versionId}`;
|
|
142
|
+
},
|
|
68
143
|
execute: async (_input, ctx) => {
|
|
69
144
|
const opened = await openReportThread(deps.gateway, ctx.session.scope);
|
|
70
145
|
if (opened.isErr()) {
|
|
71
146
|
return ok({ outcome: "refused", refusal: opened.error });
|
|
72
147
|
}
|
|
73
|
-
const { threadId, analysisId, state, seenDocumentHash } = opened.value;
|
|
148
|
+
const { threadId, analysisId, state, seenDocumentHash, derivations } = opened.value;
|
|
74
149
|
const { document: draft, snapshot } = state;
|
|
75
|
-
const finished = finishDraft(draft, snapshot);
|
|
150
|
+
const finished = finishDraft(draft, snapshot, derivations);
|
|
76
151
|
if (!finished.valid) {
|
|
77
152
|
return ok({ outcome: "gaps", gaps: finished.gaps });
|
|
78
153
|
}
|
|
@@ -127,10 +202,20 @@ export function createRecordVersionTool(deps) {
|
|
|
127
202
|
parentThreadId: thread.value.parentThreadId,
|
|
128
203
|
parentSeq: thread.value.parentSeq,
|
|
129
204
|
});
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
205
|
+
if (recorded.isOk()) {
|
|
206
|
+
// The version stands from here. The prune reclaims the bytes of each derivation that the
|
|
207
|
+
// recorded document ignores, and it decides nothing about the outcome. It runs on each
|
|
208
|
+
// record, thus an output that an amend unbound goes at the next record.
|
|
209
|
+
await pruneUnusedDerivations({
|
|
210
|
+
resolveWorkspaceRoot: deps.resolveWorkspaceRoot,
|
|
211
|
+
analysisId,
|
|
212
|
+
threadId,
|
|
213
|
+
document: finished.document,
|
|
214
|
+
derivations,
|
|
215
|
+
logger,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
return recorded.match((ref) => ok({ outcome: "recorded", versionId: ref.versionId, replaced: ref.outcome === "replaced" }), (error) => {
|
|
134
219
|
logger.warn("the version did not record", { threadId, analysisId, reason: error.type });
|
|
135
220
|
return ok({ outcome: "record-failed", detail: describeRecordFailure(error) });
|
|
136
221
|
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tool that starts a report session from a conversation.
|
|
3
|
+
*
|
|
4
|
+
* The tool is the one report path of the conversation agent. It reads the parent
|
|
5
|
+
* thread from the scope of the call, thus the agent cannot name a different
|
|
6
|
+
* conversation. It carries the intent brief of the ask, and the spawn seeds the
|
|
7
|
+
* context of the child with that brief.
|
|
8
|
+
*
|
|
9
|
+
* The order of the checks is fixed. The eyes gate runs first. A composition
|
|
10
|
+
* that names no route to a look is a permanent condition, and the advice is
|
|
11
|
+
* transient state. The advice runs next. At one user turn or less of the parent
|
|
12
|
+
* past the anchor of the newest report child, the tool starts nothing and it
|
|
13
|
+
* names that child. The spawn runs last.
|
|
14
|
+
*
|
|
15
|
+
* Each degraded condition is a typed outcome in the ok channel: a scope with no
|
|
16
|
+
* thread id, a composition with no eyes, the advice, each refusal of the spawn,
|
|
17
|
+
* and a store fault. The tool never throws for one of them.
|
|
18
|
+
*/
|
|
19
|
+
import type { Pool } from "pg";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import type { EnsureSessionStateResult } from "../app/report-session-runtime.js";
|
|
22
|
+
import type { ChromeConfig } from "../lib/chrome.js";
|
|
23
|
+
import type { AcquireEyes } from "../lib/eyes.js";
|
|
24
|
+
import type { Logger } from "../lib/logger.js";
|
|
25
|
+
import type { ThreadType } from "../memory/thread-store.js";
|
|
26
|
+
import { type Tool } from "./define-tool.js";
|
|
27
|
+
/**
|
|
28
|
+
* The brief of the ask, plus the one override of the advice.
|
|
29
|
+
*
|
|
30
|
+
* Each brief field is short prose. No field names a path, a dataset, or a
|
|
31
|
+
* format, because the report session reads those from the workspace itself.
|
|
32
|
+
*/
|
|
33
|
+
declare const startReportSessionInput: z.ZodObject<{
|
|
34
|
+
objective: z.ZodString;
|
|
35
|
+
audience: z.ZodString;
|
|
36
|
+
angle: z.ZodString;
|
|
37
|
+
exclusions: z.ZodOptional<z.ZodString>;
|
|
38
|
+
openQuestions: z.ZodOptional<z.ZodString>;
|
|
39
|
+
newSessionAnyway: z.ZodOptional<z.ZodBoolean>;
|
|
40
|
+
}, z.core.$strip>;
|
|
41
|
+
export type StartReportSessionInput = z.infer<typeof startReportSessionInput>;
|
|
42
|
+
/**
|
|
43
|
+
* The typed outcome of the tool. Each arm is ok-channel data, thus the tool
|
|
44
|
+
* never throws for a degraded condition.
|
|
45
|
+
*
|
|
46
|
+
* `started` and `existing-session` name a report thread. `refused` names a call
|
|
47
|
+
* whose scope carries no conversation thread. The four refusals of the spawn
|
|
48
|
+
* keep their own names, thus the agent reads one reason from the spawn and from
|
|
49
|
+
* the tool alike. `failed` is a store fault, and it carries a short line.
|
|
50
|
+
*/
|
|
51
|
+
export type StartReportSessionResult = {
|
|
52
|
+
outcome: "started";
|
|
53
|
+
threadId: string;
|
|
54
|
+
title: string | null;
|
|
55
|
+
} | {
|
|
56
|
+
outcome: "existing-session";
|
|
57
|
+
threadId: string;
|
|
58
|
+
title: string | null;
|
|
59
|
+
} | {
|
|
60
|
+
outcome: "refused";
|
|
61
|
+
detail: string;
|
|
62
|
+
} | {
|
|
63
|
+
outcome: "no_browser";
|
|
64
|
+
detail: string;
|
|
65
|
+
} | {
|
|
66
|
+
outcome: "parent_not_found";
|
|
67
|
+
} | {
|
|
68
|
+
outcome: "parent_not_a_conversation";
|
|
69
|
+
threadType: ThreadType;
|
|
70
|
+
} | {
|
|
71
|
+
outcome: "empty_parent_transcript";
|
|
72
|
+
} | {
|
|
73
|
+
outcome: "failed";
|
|
74
|
+
detail: string;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* The construction deps of the tool.
|
|
78
|
+
*
|
|
79
|
+
* The fields build the spawn operation inside the factory. Thus one singleton
|
|
80
|
+
* tool serves every conversation, and it reads the parent from the scope of each
|
|
81
|
+
* call.
|
|
82
|
+
*
|
|
83
|
+
* Two of the fields name a route to a look at the rendered page: `eyes` and a
|
|
84
|
+
* `chrome` config that names a browser. The tool binds no capture seam. That
|
|
85
|
+
* seam replaces the transport of the eyes tool, and the tool here never looks at
|
|
86
|
+
* a page. A composition that binds a capture seam straight to the spawn still
|
|
87
|
+
* opens the gate of that spawn.
|
|
88
|
+
*/
|
|
89
|
+
export interface StartReportSessionToolDeps {
|
|
90
|
+
readonly pool: Pool;
|
|
91
|
+
readonly chrome: ChromeConfig;
|
|
92
|
+
/**
|
|
93
|
+
* The eyes seam of the composition. A bound seam gives a browser for one
|
|
94
|
+
* look, thus the spawn accepts a session with no configured endpoint. The
|
|
95
|
+
* tool passes the seam to the spawn, and it acquires no lease.
|
|
96
|
+
*/
|
|
97
|
+
readonly eyes?: AcquireEyes;
|
|
98
|
+
/**
|
|
99
|
+
* The anchor operation of the report session runtime, which the spawn runs
|
|
100
|
+
* after the seed of the child lands. The composition binds it, thus the tool
|
|
101
|
+
* carries no stale value of its own.
|
|
102
|
+
*/
|
|
103
|
+
readonly anchorSession?: (threadId: string) => Promise<EnsureSessionStateResult>;
|
|
104
|
+
readonly logger?: Logger;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Make the tool that starts a report session, over one Postgres pool and the
|
|
108
|
+
* eyes of the composition.
|
|
109
|
+
*
|
|
110
|
+
* The factory builds the deps of the spawn one time. The spawn and the gate of
|
|
111
|
+
* the tool read that one value, thus the two gates cannot disagree. The tool
|
|
112
|
+
* holds no per-conversation value.
|
|
113
|
+
*/
|
|
114
|
+
export declare function createStartReportSessionTool(deps: StartReportSessionToolDeps): Tool<StartReportSessionInput, StartReportSessionResult>;
|
|
115
|
+
export {};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tool that starts a report session from a conversation.
|
|
3
|
+
*
|
|
4
|
+
* The tool is the one report path of the conversation agent. It reads the parent
|
|
5
|
+
* thread from the scope of the call, thus the agent cannot name a different
|
|
6
|
+
* conversation. It carries the intent brief of the ask, and the spawn seeds the
|
|
7
|
+
* context of the child with that brief.
|
|
8
|
+
*
|
|
9
|
+
* The order of the checks is fixed. The eyes gate runs first. A composition
|
|
10
|
+
* that names no route to a look is a permanent condition, and the advice is
|
|
11
|
+
* transient state. The advice runs next. At one user turn or less of the parent
|
|
12
|
+
* past the anchor of the newest report child, the tool starts nothing and it
|
|
13
|
+
* names that child. The spawn runs last.
|
|
14
|
+
*
|
|
15
|
+
* Each degraded condition is a typed outcome in the ok channel: a scope with no
|
|
16
|
+
* thread id, a composition with no eyes, the advice, each refusal of the spawn,
|
|
17
|
+
* and a store fault. The tool never throws for one of them.
|
|
18
|
+
*/
|
|
19
|
+
import { ok } from "neverthrow";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { compositionHasEyes, createReportSessionSpawn } from "../app/spawn-report-session.js";
|
|
22
|
+
import { createNoopLogger } from "../lib/console-logger.js";
|
|
23
|
+
import { describeDbError } from "../lib/db-result.js";
|
|
24
|
+
import { defineTool } from "./define-tool.js";
|
|
25
|
+
/**
|
|
26
|
+
* The character bound of each brief field. The whole brief lands in one durable
|
|
27
|
+
* message row, thus the schema holds no unbounded string out of the store.
|
|
28
|
+
*
|
|
29
|
+
* The description caps the brief at approximately 2000 tokens, and a token is
|
|
30
|
+
* about four characters. Thus the five bounds sum to 8000 characters, and the
|
|
31
|
+
* widest brief that the schema admits still meets the cap that the agent reads.
|
|
32
|
+
*
|
|
33
|
+
* The share of each field follows its role. `audience` names one reader, thus a
|
|
34
|
+
* phrase is enough. `objective` and `angle` carry a sentence or two. Each
|
|
35
|
+
* optional field carries a list of items, thus it takes the largest share.
|
|
36
|
+
*/
|
|
37
|
+
const BRIEF_MAX = {
|
|
38
|
+
objective: 1500,
|
|
39
|
+
audience: 200,
|
|
40
|
+
angle: 1500,
|
|
41
|
+
exclusions: 2400,
|
|
42
|
+
openQuestions: 2400,
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* The brief of the ask, plus the one override of the advice.
|
|
46
|
+
*
|
|
47
|
+
* Each brief field is short prose. No field names a path, a dataset, or a
|
|
48
|
+
* format, because the report session reads those from the workspace itself.
|
|
49
|
+
*/
|
|
50
|
+
const startReportSessionInput = z.object({
|
|
51
|
+
objective: z.string().min(1).max(BRIEF_MAX.objective).describe("The question that the report must answer."),
|
|
52
|
+
audience: z.string().min(1).max(BRIEF_MAX.audience).describe("The reader of the report."),
|
|
53
|
+
angle: z.string().min(1).max(BRIEF_MAX.angle).describe("The line that the report takes through the evidence."),
|
|
54
|
+
exclusions: z.string().max(BRIEF_MAX.exclusions).optional().describe("The material that the report must leave out."),
|
|
55
|
+
openQuestions: z.string().max(BRIEF_MAX.openQuestions).optional().describe("The points that the user did not decide yet."),
|
|
56
|
+
newSessionAnyway: z
|
|
57
|
+
.boolean()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe("Set it to true to start a new session when the conversation holds no user turn of new work after the last report session. The default is false."),
|
|
60
|
+
});
|
|
61
|
+
/** The line that a call reads when the scope names no conversation thread. */
|
|
62
|
+
const NO_THREAD_SCOPE_DETAIL = "the scope of the call names no conversation thread, thus the tool cannot start a child session";
|
|
63
|
+
/** The line that a call reads when the thread store refuses the shape of the child. */
|
|
64
|
+
const STORE_REFUSED_DETAIL = "the thread store refused the shape of the child session";
|
|
65
|
+
/**
|
|
66
|
+
* Map a fault of the spawn onto its arm. The switch is exhaustive over the three
|
|
67
|
+
* error unions, thus a new variant fails the build instead of falling into one
|
|
68
|
+
* arm in silence.
|
|
69
|
+
*/
|
|
70
|
+
function toOutcome(fault) {
|
|
71
|
+
switch (fault.type) {
|
|
72
|
+
case "no_browser":
|
|
73
|
+
return { outcome: "no_browser", detail: fault.detail };
|
|
74
|
+
case "parent_not_found":
|
|
75
|
+
return { outcome: "parent_not_found" };
|
|
76
|
+
case "parent_not_a_conversation":
|
|
77
|
+
return { outcome: "parent_not_a_conversation", threadType: fault.threadType };
|
|
78
|
+
case "empty_parent_transcript":
|
|
79
|
+
return { outcome: "empty_parent_transcript" };
|
|
80
|
+
case "query_failed":
|
|
81
|
+
case "mutation_failed":
|
|
82
|
+
case "connection_failed":
|
|
83
|
+
case "constraint_violation":
|
|
84
|
+
return { outcome: "failed", detail: describeDbError(fault) };
|
|
85
|
+
case "parent_analysis_mismatch":
|
|
86
|
+
case "parent_anchor_unpaired":
|
|
87
|
+
case "unknown_thread_type":
|
|
88
|
+
return { outcome: "failed", detail: STORE_REFUSED_DETAIL };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** The cause of a fault, or `undefined` for a fault that carries none. */
|
|
92
|
+
function causeOf(fault) {
|
|
93
|
+
return "cause" in fault ? fault.cause : undefined;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Make the tool that starts a report session, over one Postgres pool and the
|
|
97
|
+
* eyes of the composition.
|
|
98
|
+
*
|
|
99
|
+
* The factory builds the deps of the spawn one time. The spawn and the gate of
|
|
100
|
+
* the tool read that one value, thus the two gates cannot disagree. The tool
|
|
101
|
+
* holds no per-conversation value.
|
|
102
|
+
*/
|
|
103
|
+
export function createStartReportSessionTool(deps) {
|
|
104
|
+
const logger = (deps.logger ?? createNoopLogger()).named("start-report-session");
|
|
105
|
+
const spawnDeps = {
|
|
106
|
+
pool: deps.pool,
|
|
107
|
+
chrome: deps.chrome,
|
|
108
|
+
...(deps.eyes ? { eyes: deps.eyes } : {}),
|
|
109
|
+
...(deps.anchorSession ? { anchorSession: deps.anchorSession } : {}),
|
|
110
|
+
...(deps.logger ? { logger: deps.logger } : {}),
|
|
111
|
+
};
|
|
112
|
+
const spawn = createReportSessionSpawn(spawnDeps);
|
|
113
|
+
// The gate reads the routes of the very spawn below, thus the two answers
|
|
114
|
+
// come from one value. The routes are fixed at construction, thus the gate
|
|
115
|
+
// reads one boolean and never a live probe of the sidecar.
|
|
116
|
+
const eyesAvailable = compositionHasEyes(spawnDeps);
|
|
117
|
+
return defineTool({
|
|
118
|
+
id: "start_report_session",
|
|
119
|
+
description: "Start an interactive report session as a child of this conversation. " +
|
|
120
|
+
"The user composes the report in that chat, with the report agent. " +
|
|
121
|
+
"Give the intent brief: the objective, the audience, and the angle of the report. " +
|
|
122
|
+
"The brief carries intent only. Do not name a path, a dataset, or a format in it, " +
|
|
123
|
+
"because the session reads those from the workspace. " +
|
|
124
|
+
"Keep the whole brief under approximately 2000 tokens. " +
|
|
125
|
+
"When the conversation holds no user turn of new work after the last report session, the tool " +
|
|
126
|
+
"starts nothing and it names that session. The ask that started that session is not new work. " +
|
|
127
|
+
"Then tell the user to continue in that chat.",
|
|
128
|
+
inputSchema: startReportSessionInput,
|
|
129
|
+
executionMode: "inline",
|
|
130
|
+
describeCall: "none",
|
|
131
|
+
execute: async (input, ctx) => {
|
|
132
|
+
const brief = {
|
|
133
|
+
objective: input.objective,
|
|
134
|
+
audience: input.audience,
|
|
135
|
+
angle: input.angle,
|
|
136
|
+
exclusions: input.exclusions,
|
|
137
|
+
openQuestions: input.openQuestions,
|
|
138
|
+
};
|
|
139
|
+
const { scope } = ctx.session;
|
|
140
|
+
if (scope.kind !== "analysis" || scope.threadId === undefined || scope.threadId.length === 0) {
|
|
141
|
+
return ok({ outcome: "refused", detail: NO_THREAD_SCOPE_DETAIL });
|
|
142
|
+
}
|
|
143
|
+
const parentThreadId = scope.threadId;
|
|
144
|
+
// The eyes gate has priority over the advice, and the advice costs two
|
|
145
|
+
// reads. The spawn holds the same gate before each read of its own,
|
|
146
|
+
// thus a blind composition reaches the `no_browser` refusal of the
|
|
147
|
+
// spawn with no read and no write. As a result the tool copies no
|
|
148
|
+
// detail line of the spawn.
|
|
149
|
+
if (eyesAvailable) {
|
|
150
|
+
const delta = await spawn.reportSessionDelta(parentThreadId);
|
|
151
|
+
if (delta.isErr()) {
|
|
152
|
+
logger.warn("the report session delta did not read", {
|
|
153
|
+
parentThreadId,
|
|
154
|
+
reason: delta.error.type,
|
|
155
|
+
...logger.errorFields(delta.error.cause),
|
|
156
|
+
});
|
|
157
|
+
return ok(toOutcome(delta.error));
|
|
158
|
+
}
|
|
159
|
+
const { newestChild, userTurnsSinceAnchor } = delta.value;
|
|
160
|
+
// The rule admits one turn. A turn appends after its own loop
|
|
161
|
+
// runs, thus the ask that made the child is itself one user turn
|
|
162
|
+
// past the anchor. A rule at zero would never advise again after
|
|
163
|
+
// that turn commits. A second user turn is real work, and a new
|
|
164
|
+
// session can report on it.
|
|
165
|
+
if (input.newSessionAnyway !== true && newestChild !== null && userTurnsSinceAnchor !== null && userTurnsSinceAnchor <= 1) {
|
|
166
|
+
return ok({ outcome: "existing-session", threadId: newestChild.threadId, title: newestChild.title });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const spawned = await spawn.spawnReportSession(parentThreadId, brief);
|
|
170
|
+
return ok(spawned.match((child) => ({ outcome: "started", threadId: child.threadId, title: child.title }), (fault) => {
|
|
171
|
+
const outcome = toOutcome(fault);
|
|
172
|
+
if (outcome.outcome === "failed") {
|
|
173
|
+
logger.warn("the report session did not start", { parentThreadId, reason: fault.type, ...logger.errorFields(causeOf(fault)) });
|
|
174
|
+
}
|
|
175
|
+
return outcome;
|
|
176
|
+
}));
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}
|
|
@@ -122,6 +122,34 @@ export declare function stepWritePrefix(args: {
|
|
|
122
122
|
readonly runId: string;
|
|
123
123
|
readonly stepId: string;
|
|
124
124
|
}): string;
|
|
125
|
+
/**
|
|
126
|
+
* Split a declared sandbox write tail into its segments, and refuse a tail that is
|
|
127
|
+
* not one workspace-relative path.
|
|
128
|
+
*
|
|
129
|
+
* A tail names the one writable mount of a sandbox in place of the step directory.
|
|
130
|
+
* It reaches the mount builders from a caller, thus it takes the same discipline as
|
|
131
|
+
* a `stepId`: each segment passes {@link assertSafeId}, which refuses a separator,
|
|
132
|
+
* a NUL, a shell-hostile char, and a pure-dot segment. An empty tail names the
|
|
133
|
+
* workspace root itself, and an absolute tail names a host location. Both are
|
|
134
|
+
* refused, because a write mount must be a directory inside the tree.
|
|
135
|
+
*
|
|
136
|
+
* The segments come back so a host-path builder joins the validated parts, and it
|
|
137
|
+
* never re-splits the raw text.
|
|
138
|
+
*/
|
|
139
|
+
export declare function assertSafeTail(tail: string, label?: string): string[];
|
|
140
|
+
/**
|
|
141
|
+
* Derive the host path of a declared write tail under a workspace root.
|
|
142
|
+
*
|
|
143
|
+
* The counterpart of {@link stepWritePrefix} for a sandbox that declares its own
|
|
144
|
+
* write tail. The tail passes the same validation before it becomes a host
|
|
145
|
+
* directory path, thus a crafted segment cannot widen or escape the mount. This is
|
|
146
|
+
* the one builder of that path: the host makes the directory with it, and the
|
|
147
|
+
* Docker bind source comes from it.
|
|
148
|
+
*/
|
|
149
|
+
export declare function tailWritePrefix(args: {
|
|
150
|
+
readonly workspaceRoot: string;
|
|
151
|
+
readonly tail: string;
|
|
152
|
+
}): string;
|
|
125
153
|
/**
|
|
126
154
|
* Map a host-side absolute path within an analysis's workspace tree to its
|
|
127
155
|
* in-sandbox absolute path. The tree is bind-mounted at `/{resourceId}`, so
|
|
@@ -152,6 +180,28 @@ export declare function previewDir(previewId: string): string;
|
|
|
152
180
|
* Expects names like "v1", "v2", etc. Returns 0 if no versions found.
|
|
153
181
|
*/
|
|
154
182
|
export declare function latestPreviewVersion(dirNames: string[]): number;
|
|
183
|
+
/**
|
|
184
|
+
* Parent of every report session, workspace-root-relative. Callers that
|
|
185
|
+
* enumerate the sessions join this. Callers that address one join
|
|
186
|
+
* {@link reportSessionDir}. Both spell the segment through this constant, thus
|
|
187
|
+
* the tree has one owner.
|
|
188
|
+
*/
|
|
189
|
+
export declare const REPORT_SESSIONS_ROOT = "report-sessions";
|
|
190
|
+
/**
|
|
191
|
+
* Directory of one report session, workspace-root-relative. The page of the
|
|
192
|
+
* session and its staged assets live under this one directory. Thus a host that
|
|
193
|
+
* reclaims the files of a purged session names each directory through this
|
|
194
|
+
* helper. The host restates no layout of the harness.
|
|
195
|
+
*/
|
|
196
|
+
export declare function reportSessionDir(threadId: string): string;
|
|
197
|
+
/**
|
|
198
|
+
* Directory of the derived tables of one report session, workspace-root-relative.
|
|
199
|
+
*
|
|
200
|
+
* The derivation writes its output here, and the record prunes an output that the recorded document does
|
|
201
|
+
* not name. Both spell the segment through this helper, thus the write mount and the prune scope cannot
|
|
202
|
+
* disagree.
|
|
203
|
+
*/
|
|
204
|
+
export declare function reportSessionDerivedDir(threadId: string): string;
|
|
155
205
|
/**
|
|
156
206
|
* True when `value` is a safe single path segment: the `SAFE_ID` charset (word
|
|
157
207
|
* chars, `.`, `-` — no slash, NUL, or shell-hostile char) AND not a pure-dot
|
package/dist/workspace/paths.js
CHANGED
|
@@ -96,6 +96,42 @@ export function stepWritePrefix(args) {
|
|
|
96
96
|
assertSafeId(args.stepId, "stepId");
|
|
97
97
|
return resolvePath(args.workspaceRoot, "runs", args.runId, args.stepId);
|
|
98
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Split a declared sandbox write tail into its segments, and refuse a tail that is
|
|
101
|
+
* not one workspace-relative path.
|
|
102
|
+
*
|
|
103
|
+
* A tail names the one writable mount of a sandbox in place of the step directory.
|
|
104
|
+
* It reaches the mount builders from a caller, thus it takes the same discipline as
|
|
105
|
+
* a `stepId`: each segment passes {@link assertSafeId}, which refuses a separator,
|
|
106
|
+
* a NUL, a shell-hostile char, and a pure-dot segment. An empty tail names the
|
|
107
|
+
* workspace root itself, and an absolute tail names a host location. Both are
|
|
108
|
+
* refused, because a write mount must be a directory inside the tree.
|
|
109
|
+
*
|
|
110
|
+
* The segments come back so a host-path builder joins the validated parts, and it
|
|
111
|
+
* never re-splits the raw text.
|
|
112
|
+
*/
|
|
113
|
+
export function assertSafeTail(tail, label = "writableTail") {
|
|
114
|
+
if (tail.length === 0 || tail.startsWith("/")) {
|
|
115
|
+
throw new Error(`Invalid ${label}: ${tail}`);
|
|
116
|
+
}
|
|
117
|
+
const segments = tail.split("/");
|
|
118
|
+
for (const segment of segments) {
|
|
119
|
+
assertSafeId(segment, label);
|
|
120
|
+
}
|
|
121
|
+
return segments;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Derive the host path of a declared write tail under a workspace root.
|
|
125
|
+
*
|
|
126
|
+
* The counterpart of {@link stepWritePrefix} for a sandbox that declares its own
|
|
127
|
+
* write tail. The tail passes the same validation before it becomes a host
|
|
128
|
+
* directory path, thus a crafted segment cannot widen or escape the mount. This is
|
|
129
|
+
* the one builder of that path: the host makes the directory with it, and the
|
|
130
|
+
* Docker bind source comes from it.
|
|
131
|
+
*/
|
|
132
|
+
export function tailWritePrefix(args) {
|
|
133
|
+
return resolvePath(args.workspaceRoot, ...assertSafeTail(args.tail));
|
|
134
|
+
}
|
|
99
135
|
/**
|
|
100
136
|
* Map a host-side absolute path within an analysis's workspace tree to its
|
|
101
137
|
* in-sandbox absolute path. The tree is bind-mounted at `/{resourceId}`, so
|
|
@@ -152,6 +188,33 @@ export function latestPreviewVersion(dirNames) {
|
|
|
152
188
|
}
|
|
153
189
|
return max;
|
|
154
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* Parent of every report session, workspace-root-relative. Callers that
|
|
193
|
+
* enumerate the sessions join this. Callers that address one join
|
|
194
|
+
* {@link reportSessionDir}. Both spell the segment through this constant, thus
|
|
195
|
+
* the tree has one owner.
|
|
196
|
+
*/
|
|
197
|
+
export const REPORT_SESSIONS_ROOT = "report-sessions";
|
|
198
|
+
/**
|
|
199
|
+
* Directory of one report session, workspace-root-relative. The page of the
|
|
200
|
+
* session and its staged assets live under this one directory. Thus a host that
|
|
201
|
+
* reclaims the files of a purged session names each directory through this
|
|
202
|
+
* helper. The host restates no layout of the harness.
|
|
203
|
+
*/
|
|
204
|
+
export function reportSessionDir(threadId) {
|
|
205
|
+
assertSafeId(threadId, "threadId");
|
|
206
|
+
return `${REPORT_SESSIONS_ROOT}/${threadId}`;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Directory of the derived tables of one report session, workspace-root-relative.
|
|
210
|
+
*
|
|
211
|
+
* The derivation writes its output here, and the record prunes an output that the recorded document does
|
|
212
|
+
* not name. Both spell the segment through this helper, thus the write mount and the prune scope cannot
|
|
213
|
+
* disagree.
|
|
214
|
+
*/
|
|
215
|
+
export function reportSessionDerivedDir(threadId) {
|
|
216
|
+
return `${reportSessionDir(threadId)}/derived`;
|
|
217
|
+
}
|
|
155
218
|
/**
|
|
156
219
|
* For an analysis-rooted input (`/{analysisId}/...`), strip the leading
|
|
157
220
|
* `/{analysisId}/` segment so it can be resolved relative to the analysis
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inflexa-ai/harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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",
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
|
65
65
|
"@opentelemetry/sdk-trace-node": "^2.9.0",
|
|
66
66
|
"@opentelemetry/semantic-conventions": "^1.43.0",
|
|
67
|
+
"ag-grid-community": "36.1.0",
|
|
67
68
|
"ai": "^7.0.61",
|
|
68
69
|
"cheerio": "^1.2.0",
|
|
69
70
|
"dockerode": "^4.0.12",
|