@inflexa-ai/harness 0.4.3 → 0.4.5
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/app/synthesize-run.d.ts +0 -1
- package/dist/app/synthesize-run.js +0 -3
- package/dist/contracts/chat-parts.d.ts +0 -2
- package/dist/contracts/schemas/chat-parts.d.ts +3 -5
- package/dist/contracts/schemas/chat-parts.js +0 -1
- package/dist/execution/run-synthesis.d.ts +7 -19
- package/dist/execution/run-synthesis.js +180 -53
- package/dist/lib/zod-issues.d.ts +80 -0
- package/dist/lib/zod-issues.js +227 -0
- package/dist/loop/run-agent.js +18 -6
- package/dist/prompts/synthesis-agent.d.ts +1 -1
- package/dist/prompts/synthesis-agent.js +25 -5
- package/dist/sandbox/docker-client.d.ts +6 -1
- package/dist/sandbox/docker-client.js +34 -3
- package/dist/schemas/run-synthesis.d.ts +8 -2
- package/dist/schemas/run-synthesis.js +6 -5
- package/dist/tools/iterate-report.js +2 -0
- package/dist/tools/research/generate-plan.js +4 -2
- package/package.json +1 -1
|
@@ -41,7 +41,6 @@ export interface SynthesizeRunDeps {
|
|
|
41
41
|
/** Optional fields for a progress update, mirroring the wire part. */
|
|
42
42
|
export interface SynthesisProgressExtra {
|
|
43
43
|
readonly delegationCount?: number;
|
|
44
|
-
readonly validationAttempts?: number;
|
|
45
44
|
readonly reason?: string;
|
|
46
45
|
readonly error?: string;
|
|
47
46
|
}
|
|
@@ -57,12 +57,9 @@ export async function synthesizeRun(deps, params) {
|
|
|
57
57
|
logger.warn("synthesis skipped — synthesizer reported a blocker", {
|
|
58
58
|
runId,
|
|
59
59
|
reason: result.reason,
|
|
60
|
-
validationRejections: result.validationRejections,
|
|
61
|
-
rejectedIssuePaths: result.rejectedIssuePaths,
|
|
62
60
|
});
|
|
63
61
|
await onProgress("skipped", "Synthesis skipped — run did not produce synthesizable content", {
|
|
64
62
|
reason: result.reason,
|
|
65
|
-
validationAttempts: result.validationRejections,
|
|
66
63
|
});
|
|
67
64
|
return { findings: [], synthesisStatus: "skipped_blocker", synthesisReason: result.reason };
|
|
68
65
|
}
|
|
@@ -262,8 +262,6 @@ export interface SynthesisProgressPart {
|
|
|
262
262
|
activity: string;
|
|
263
263
|
/** Number of literature-reviewer delegations started so far. */
|
|
264
264
|
delegationCount?: number;
|
|
265
|
-
/** Number of times the synthesizer's submit_synthesis call was rejected. */
|
|
266
|
-
validationAttempts?: number;
|
|
267
265
|
/** Agent-provided reason — set when phase === "skipped". */
|
|
268
266
|
reason?: string;
|
|
269
267
|
/** Error message — set when phase === "failed". */
|
|
@@ -298,11 +298,11 @@ export declare const SynthesizedFindingSchema: z.ZodObject<{
|
|
|
298
298
|
low: "low";
|
|
299
299
|
}>;
|
|
300
300
|
noveltyStatus: z.ZodEnum<{
|
|
301
|
+
expected: "expected";
|
|
301
302
|
novel: "novel";
|
|
302
303
|
confirmed: "confirmed";
|
|
303
304
|
partially_confirmed: "partially_confirmed";
|
|
304
305
|
contradicted: "contradicted";
|
|
305
|
-
expected: "expected";
|
|
306
306
|
}>;
|
|
307
307
|
literatureInterpretation: z.ZodString;
|
|
308
308
|
}, z.core.$strip>;
|
|
@@ -330,11 +330,11 @@ export declare const RunSynthesisPartSchema: z.ZodObject<{
|
|
|
330
330
|
low: "low";
|
|
331
331
|
}>;
|
|
332
332
|
noveltyStatus: z.ZodEnum<{
|
|
333
|
+
expected: "expected";
|
|
333
334
|
novel: "novel";
|
|
334
335
|
confirmed: "confirmed";
|
|
335
336
|
partially_confirmed: "partially_confirmed";
|
|
336
337
|
contradicted: "contradicted";
|
|
337
|
-
expected: "expected";
|
|
338
338
|
}>;
|
|
339
339
|
literatureInterpretation: z.ZodString;
|
|
340
340
|
}, z.core.$strip>>;
|
|
@@ -383,7 +383,6 @@ export declare const SynthesisProgressPartSchema: z.ZodObject<{
|
|
|
383
383
|
}>;
|
|
384
384
|
activity: z.ZodString;
|
|
385
385
|
delegationCount: z.ZodOptional<z.ZodNumber>;
|
|
386
|
-
validationAttempts: z.ZodOptional<z.ZodNumber>;
|
|
387
386
|
reason: z.ZodOptional<z.ZodString>;
|
|
388
387
|
error: z.ZodOptional<z.ZodString>;
|
|
389
388
|
}, z.core.$strip>;
|
|
@@ -589,11 +588,11 @@ export declare const CortexChatPartSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
|
|
|
589
588
|
low: "low";
|
|
590
589
|
}>;
|
|
591
590
|
noveltyStatus: z.ZodEnum<{
|
|
591
|
+
expected: "expected";
|
|
592
592
|
novel: "novel";
|
|
593
593
|
confirmed: "confirmed";
|
|
594
594
|
partially_confirmed: "partially_confirmed";
|
|
595
595
|
contradicted: "contradicted";
|
|
596
|
-
expected: "expected";
|
|
597
596
|
}>;
|
|
598
597
|
literatureInterpretation: z.ZodString;
|
|
599
598
|
}, z.core.$strip>>;
|
|
@@ -629,7 +628,6 @@ export declare const CortexChatPartSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
|
|
|
629
628
|
}>;
|
|
630
629
|
activity: z.ZodString;
|
|
631
630
|
delegationCount: z.ZodOptional<z.ZodNumber>;
|
|
632
|
-
validationAttempts: z.ZodOptional<z.ZodNumber>;
|
|
633
631
|
reason: z.ZodOptional<z.ZodString>;
|
|
634
632
|
error: z.ZodOptional<z.ZodString>;
|
|
635
633
|
}, z.core.$strip>, z.ZodObject<{
|
|
@@ -243,7 +243,6 @@ export const SynthesisProgressPartSchema = z.object({
|
|
|
243
243
|
phase: SynthesisPhaseSchema,
|
|
244
244
|
activity: z.string(),
|
|
245
245
|
delegationCount: z.number().int().nonnegative().optional(),
|
|
246
|
-
validationAttempts: z.number().int().nonnegative().optional(),
|
|
247
246
|
reason: z.string().optional(),
|
|
248
247
|
error: z.string().optional(),
|
|
249
248
|
});
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Run synthesizer — a single-shot agent loop driven through `runAgent`:
|
|
3
3
|
*
|
|
4
|
+
* - `validate_synthesis` — non-terminal: dry-run schema + semantic validation
|
|
5
|
+
* of a candidate in any shape. Never records.
|
|
4
6
|
* - `submit_synthesis` — terminal: schema + semantic validation, captures
|
|
5
|
-
* the outcome. Re-callable
|
|
7
|
+
* the outcome. Re-callable; a later valid submission
|
|
8
|
+
* supersedes an earlier outcome (last-valid-wins).
|
|
6
9
|
* - `report_blocker` — terminal: the run produced nothing synthesizable.
|
|
10
|
+
* Only records when no outcome exists yet, so a
|
|
11
|
+
* blocker can never clobber a real synthesis.
|
|
7
12
|
* - `literature_reviewer` — research sub-agent exposed as a tool. The tool
|
|
8
13
|
* re-uses `createLiteratureReviewerTool`; the
|
|
9
14
|
* sub-agent loop's brief is the only context handed
|
|
@@ -35,20 +40,6 @@ type SynthesizerOutcome = {
|
|
|
35
40
|
interface OutcomeHolder {
|
|
36
41
|
outcome: SynthesizerOutcome | null;
|
|
37
42
|
}
|
|
38
|
-
/**
|
|
39
|
-
* Mutable cell the submit tool writes on every rejection from its semantic
|
|
40
|
-
* re-validation — the stepId/theme-ref/PMID checks the arg schema cannot express.
|
|
41
|
-
* A schema-malformed payload is rejected at the agent-loop boundary before the
|
|
42
|
-
* tool runs, so it never reaches here; this counts the semantic-check give-up
|
|
43
|
-
* that this synthesizer is prone to. Read after the loop to diagnose a blocker's
|
|
44
|
-
* cause: zero here points to LLM misjudgment, repeated rejections to a defensive
|
|
45
|
-
* give-up against the semantic checks. The deduped `issuePaths` name which fields
|
|
46
|
-
* the model could not satisfy.
|
|
47
|
-
*/
|
|
48
|
-
interface RejectionTelemetry {
|
|
49
|
-
rejections: number;
|
|
50
|
-
readonly issuePaths: Set<string>;
|
|
51
|
-
}
|
|
52
43
|
/**
|
|
53
44
|
* Test-only — expose the inner-tool factory so unit tests can assert on
|
|
54
45
|
* validation + outcome capture without driving the whole agent loop.
|
|
@@ -57,10 +48,10 @@ export declare function __buildInnerToolsForTest(args: {
|
|
|
57
48
|
knownStepIds: ReadonlySet<string>;
|
|
58
49
|
runId: string;
|
|
59
50
|
}): {
|
|
51
|
+
validate: Tool;
|
|
60
52
|
submit: Tool;
|
|
61
53
|
blocker: Tool;
|
|
62
54
|
holder: OutcomeHolder;
|
|
63
|
-
telemetry: RejectionTelemetry;
|
|
64
55
|
};
|
|
65
56
|
export interface GenerateRunSynthesisInput {
|
|
66
57
|
readonly provider: ChatProvider;
|
|
@@ -82,12 +73,9 @@ export interface GenerateRunSynthesisInput {
|
|
|
82
73
|
export type GenerateRunSynthesisResult = {
|
|
83
74
|
kind: "synthesis";
|
|
84
75
|
synthesis: RunSynthesis;
|
|
85
|
-
validationRejections: number;
|
|
86
76
|
} | {
|
|
87
77
|
kind: "skipped";
|
|
88
78
|
reason: string;
|
|
89
|
-
validationRejections: number;
|
|
90
|
-
rejectedIssuePaths: readonly string[];
|
|
91
79
|
};
|
|
92
80
|
/**
|
|
93
81
|
* Drive the synthesizer agent loop to a terminal outcome — either a
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Run synthesizer — a single-shot agent loop driven through `runAgent`:
|
|
3
3
|
*
|
|
4
|
+
* - `validate_synthesis` — non-terminal: dry-run schema + semantic validation
|
|
5
|
+
* of a candidate in any shape. Never records.
|
|
4
6
|
* - `submit_synthesis` — terminal: schema + semantic validation, captures
|
|
5
|
-
* the outcome. Re-callable
|
|
7
|
+
* the outcome. Re-callable; a later valid submission
|
|
8
|
+
* supersedes an earlier outcome (last-valid-wins).
|
|
6
9
|
* - `report_blocker` — terminal: the run produced nothing synthesizable.
|
|
10
|
+
* Only records when no outcome exists yet, so a
|
|
11
|
+
* blocker can never clobber a real synthesis.
|
|
7
12
|
* - `literature_reviewer` — research sub-agent exposed as a tool. The tool
|
|
8
13
|
* re-uses `createLiteratureReviewerTool`; the
|
|
9
14
|
* sub-agent loop's brief is the only context handed
|
|
@@ -23,7 +28,8 @@ import { join } from "node:path";
|
|
|
23
28
|
import { ok } from "neverthrow";
|
|
24
29
|
import { z } from "zod";
|
|
25
30
|
import { writeFileWithinRoot } from "../lib/fs-helpers.js";
|
|
26
|
-
import {
|
|
31
|
+
import { hintForZodIssue } from "../lib/zod-issues.js";
|
|
32
|
+
import { BiologicalThemeSchema, KeyReferenceSchema, RunSynthesisSchema, SynthesizedFindingSchema, } from "../schemas/run-synthesis.js";
|
|
27
33
|
import { synthesisAgentPrompt } from "../prompts/synthesis-agent.js";
|
|
28
34
|
import { composeSystemPrompt } from "../agents/system-prompt.js";
|
|
29
35
|
import { runDir } from "../workspace/paths.js";
|
|
@@ -40,23 +46,37 @@ const AGENT_ID = "run-synthesizer";
|
|
|
40
46
|
*/
|
|
41
47
|
const SYNTHESIZER_MAX_ITERATIONS = 25;
|
|
42
48
|
// ── Validation ──────────────────────────────────────────────────────
|
|
43
|
-
function zodIssuesToValidationIssues(error, rootPath = "synthesis") {
|
|
49
|
+
function zodIssuesToValidationIssues(error, input, rootPath = "synthesis") {
|
|
44
50
|
return error.issues.map((i) => ({
|
|
45
51
|
path: [rootPath, ...i.path.map((p) => String(p))].join("."),
|
|
46
52
|
code: "schema",
|
|
47
53
|
message: i.message,
|
|
54
|
+
hint: hintForZodIssue(i, input),
|
|
48
55
|
}));
|
|
49
56
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
57
|
+
const PMID_PATTERN = /^\d+$/;
|
|
58
|
+
// ── Semantic checks ─────────────────────────────────────────────────
|
|
59
|
+
//
|
|
60
|
+
// Each check takes only the fields it reads, never the whole synthesis. That is
|
|
61
|
+
// what lets a candidate which failed whole-payload schema validation still be
|
|
62
|
+
// checked: whichever fields parsed on their own get their checks run, and only
|
|
63
|
+
// the checks whose inputs are genuinely missing are deferred. `semanticCheck`
|
|
64
|
+
// composes them for the schema-valid path — it is the one definition of "all
|
|
65
|
+
// semantic checks, in report order".
|
|
66
|
+
function checkRunId(runId, ctx) {
|
|
67
|
+
if (runId === ctx.runId)
|
|
68
|
+
return [];
|
|
69
|
+
return [
|
|
70
|
+
{
|
|
54
71
|
path: "synthesis.runId",
|
|
55
72
|
code: "semantic",
|
|
56
73
|
message: `runId must be "${ctx.runId}"`,
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
function checkFindingStepIds(findings, ctx) {
|
|
78
|
+
const issues = [];
|
|
79
|
+
for (const [i, f] of findings.entries()) {
|
|
60
80
|
if (!ctx.knownStepIds.has(f.stepId)) {
|
|
61
81
|
issues.push({
|
|
62
82
|
path: `synthesis.findings[${i}].stepId`,
|
|
@@ -66,8 +86,12 @@ function semanticCheck(synthesis, ctx) {
|
|
|
66
86
|
});
|
|
67
87
|
}
|
|
68
88
|
}
|
|
69
|
-
|
|
70
|
-
|
|
89
|
+
return issues;
|
|
90
|
+
}
|
|
91
|
+
function checkThemeReferences(themes, findings) {
|
|
92
|
+
const issues = [];
|
|
93
|
+
const findingKeys = new Set(findings.map((f) => `${f.stepId}::${f.title}`));
|
|
94
|
+
for (const [ti, t] of themes.entries()) {
|
|
71
95
|
for (const [fi, ref] of t.findings.entries()) {
|
|
72
96
|
if (!findingKeys.has(`${ref.stepId}::${ref.title}`)) {
|
|
73
97
|
issues.push({
|
|
@@ -78,8 +102,12 @@ function semanticCheck(synthesis, ctx) {
|
|
|
78
102
|
}
|
|
79
103
|
}
|
|
80
104
|
}
|
|
81
|
-
|
|
82
|
-
|
|
105
|
+
return issues;
|
|
106
|
+
}
|
|
107
|
+
function checkKeyReferencesCited(keyReferences, findings) {
|
|
108
|
+
const issues = [];
|
|
109
|
+
const citedPmids = new Set(findings.flatMap((f) => f.references.map((r) => r.pmid)));
|
|
110
|
+
for (const [i, r] of keyReferences.entries()) {
|
|
83
111
|
if (!citedPmids.has(r.pmid)) {
|
|
84
112
|
issues.push({
|
|
85
113
|
path: `synthesis.keyReferences[${i}].pmid`,
|
|
@@ -89,10 +117,13 @@ function semanticCheck(synthesis, ctx) {
|
|
|
89
117
|
});
|
|
90
118
|
}
|
|
91
119
|
}
|
|
92
|
-
|
|
93
|
-
|
|
120
|
+
return issues;
|
|
121
|
+
}
|
|
122
|
+
function checkFindingPmidFormat(findings) {
|
|
123
|
+
const issues = [];
|
|
124
|
+
for (const [fi, f] of findings.entries()) {
|
|
94
125
|
for (const [ri, r] of f.references.entries()) {
|
|
95
|
-
if (!
|
|
126
|
+
if (!PMID_PATTERN.test(r.pmid)) {
|
|
96
127
|
issues.push({
|
|
97
128
|
path: `synthesis.findings[${fi}].references[${ri}].pmid`,
|
|
98
129
|
code: "semantic",
|
|
@@ -101,8 +132,12 @@ function semanticCheck(synthesis, ctx) {
|
|
|
101
132
|
}
|
|
102
133
|
}
|
|
103
134
|
}
|
|
104
|
-
|
|
105
|
-
|
|
135
|
+
return issues;
|
|
136
|
+
}
|
|
137
|
+
function checkKeyReferencePmidFormat(keyReferences) {
|
|
138
|
+
const issues = [];
|
|
139
|
+
for (const [i, r] of keyReferences.entries()) {
|
|
140
|
+
if (!PMID_PATTERN.test(r.pmid)) {
|
|
106
141
|
issues.push({
|
|
107
142
|
path: `synthesis.keyReferences[${i}].pmid`,
|
|
108
143
|
code: "semantic",
|
|
@@ -112,10 +147,91 @@ function semanticCheck(synthesis, ctx) {
|
|
|
112
147
|
}
|
|
113
148
|
return issues;
|
|
114
149
|
}
|
|
150
|
+
function semanticCheck(synthesis, ctx) {
|
|
151
|
+
return [
|
|
152
|
+
...checkRunId(synthesis.runId, ctx),
|
|
153
|
+
...checkFindingStepIds(synthesis.findings, ctx),
|
|
154
|
+
...checkThemeReferences(synthesis.themes, synthesis.findings),
|
|
155
|
+
...checkKeyReferencesCited(synthesis.keyReferences, synthesis.findings),
|
|
156
|
+
...checkFindingPmidFormat(synthesis.findings),
|
|
157
|
+
...checkKeyReferencePmidFormat(synthesis.keyReferences),
|
|
158
|
+
];
|
|
159
|
+
}
|
|
160
|
+
function salvageInputs(candidate) {
|
|
161
|
+
const record = typeof candidate === "object" && candidate !== null ? candidate : {};
|
|
162
|
+
const runId = z.string().safeParse(record.runId);
|
|
163
|
+
const findings = z.array(SynthesizedFindingSchema).safeParse(record.findings);
|
|
164
|
+
const themes = z.array(BiologicalThemeSchema).safeParse(record.themes);
|
|
165
|
+
const keyReferences = z.array(KeyReferenceSchema).safeParse(record.keyReferences);
|
|
166
|
+
return {
|
|
167
|
+
runId: runId.success ? runId.data : undefined,
|
|
168
|
+
findings: findings.success ? findings.data : undefined,
|
|
169
|
+
themes: themes.success ? themes.data : undefined,
|
|
170
|
+
keyReferences: keyReferences.success ? keyReferences.data : undefined,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* A short issue list must never read as "everything else is fine". When the
|
|
175
|
+
* payload failed schema validation and some checks could not run, the deferred
|
|
176
|
+
* ones are named back to the model as an issue of their own.
|
|
177
|
+
*/
|
|
178
|
+
function deferredChecksNotice(deferred) {
|
|
179
|
+
return {
|
|
180
|
+
path: "synthesis",
|
|
181
|
+
code: "semantic",
|
|
182
|
+
message: `This issue list is INCOMPLETE: ${deferred.length} semantic check(s) could not run because the fields ` +
|
|
183
|
+
`they read did not survive schema validation — ${deferred.join("; ")}.`,
|
|
184
|
+
hint: "Fix the schema issues above and call validate_synthesis again; the deferred checks will run then and may report further problems.",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Semantic checks over a candidate that failed `RunSynthesisSchema`. Every check
|
|
189
|
+
* whose own inputs parsed is run; the rest are reported as deferred.
|
|
190
|
+
*/
|
|
191
|
+
function salvagedSemanticCheck(candidate, ctx) {
|
|
192
|
+
const { runId, findings, themes, keyReferences } = salvageInputs(candidate);
|
|
193
|
+
const issues = [];
|
|
194
|
+
const deferred = [];
|
|
195
|
+
if (runId !== undefined)
|
|
196
|
+
issues.push(...checkRunId(runId, ctx));
|
|
197
|
+
else
|
|
198
|
+
deferred.push("runId matches the run");
|
|
199
|
+
if (findings !== undefined)
|
|
200
|
+
issues.push(...checkFindingStepIds(findings, ctx));
|
|
201
|
+
else
|
|
202
|
+
deferred.push("finding stepIds are present in the input summaries");
|
|
203
|
+
if (themes !== undefined && findings !== undefined)
|
|
204
|
+
issues.push(...checkThemeReferences(themes, findings));
|
|
205
|
+
else
|
|
206
|
+
deferred.push("theme references resolve to entries in findings[]");
|
|
207
|
+
if (keyReferences !== undefined && findings !== undefined)
|
|
208
|
+
issues.push(...checkKeyReferencesCited(keyReferences, findings));
|
|
209
|
+
else
|
|
210
|
+
deferred.push("keyReferences PMIDs are cited by some finding");
|
|
211
|
+
if (findings !== undefined)
|
|
212
|
+
issues.push(...checkFindingPmidFormat(findings));
|
|
213
|
+
else
|
|
214
|
+
deferred.push("finding reference PMIDs are numeric");
|
|
215
|
+
if (keyReferences !== undefined)
|
|
216
|
+
issues.push(...checkKeyReferencePmidFormat(keyReferences));
|
|
217
|
+
else
|
|
218
|
+
deferred.push("keyReferences PMIDs are numeric");
|
|
219
|
+
if (deferred.length > 0)
|
|
220
|
+
issues.push(deferredChecksNotice(deferred));
|
|
221
|
+
return issues;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Validate a candidate in one pass. A schema failure no longer hides the
|
|
225
|
+
* semantic problems behind it: structure issues come first, then every semantic
|
|
226
|
+
* check whose inputs survived, then an explicit note of what was deferred.
|
|
227
|
+
*/
|
|
115
228
|
function fullyValidate(candidate, ctx) {
|
|
116
229
|
const parsed = RunSynthesisSchema.safeParse(candidate);
|
|
117
230
|
if (!parsed.success) {
|
|
118
|
-
return {
|
|
231
|
+
return {
|
|
232
|
+
valid: false,
|
|
233
|
+
issues: [...zodIssuesToValidationIssues(parsed.error, candidate), ...salvagedSemanticCheck(candidate, ctx)],
|
|
234
|
+
};
|
|
119
235
|
}
|
|
120
236
|
const semanticIssues = semanticCheck(parsed.data, ctx);
|
|
121
237
|
if (semanticIssues.length > 0) {
|
|
@@ -124,7 +240,37 @@ function fullyValidate(candidate, ctx) {
|
|
|
124
240
|
return { valid: true, synthesis: parsed.data };
|
|
125
241
|
}
|
|
126
242
|
// ── Inner tools ─────────────────────────────────────────────────────
|
|
127
|
-
function
|
|
243
|
+
function buildValidateTool(ctx) {
|
|
244
|
+
return defineTool({
|
|
245
|
+
id: "validate_synthesis",
|
|
246
|
+
description: "Dry-run a candidate synthesis and get back everything that is wrong " +
|
|
247
|
+
"with it. Takes the synthesis in ANY shape: a malformed, partial, or " +
|
|
248
|
+
"wrong-typed candidate is REPORTED, not rejected. Returns " +
|
|
249
|
+
"{valid, issues[]} covering both schema problems (missing / " +
|
|
250
|
+
"wrong-typed fields) and the semantic checks a schema cannot express " +
|
|
251
|
+
"(unknown stepId refs, theme→finding refs, keyReferences PMIDs not " +
|
|
252
|
+
"cited by any finding, non-numeric PMIDs) in a SINGLE pass — a " +
|
|
253
|
+
"schema problem does not hide the semantic problems behind it. If a " +
|
|
254
|
+
"field is too malformed for a semantic check to read, that check is " +
|
|
255
|
+
"reported as deferred rather than silently skipped. The authoritative " +
|
|
256
|
+
"field-by-field synthesis schema is the arg schema of " +
|
|
257
|
+
"submit_synthesis — this tool deliberately does not restate it. " +
|
|
258
|
+
"Non-terminal: call as often as needed to iterate toward a clean " +
|
|
259
|
+
"synthesis, then submit_synthesis.",
|
|
260
|
+
// Deliberately permissive: a structurally-invalid candidate must reach
|
|
261
|
+
// `execute` so the model gets a structured {valid:false, issues} result —
|
|
262
|
+
// including semantic issues — instead of a bare Zod rejection at the loop's
|
|
263
|
+
// input boundary. `execute` re-parses against RunSynthesisSchema itself.
|
|
264
|
+
inputSchema: z.object({
|
|
265
|
+
synthesis: z.unknown().describe("The candidate synthesis, in any shape. Field-by-field schema: see submit_synthesis."),
|
|
266
|
+
}),
|
|
267
|
+
execute: async (input) => {
|
|
268
|
+
const result = fullyValidate(input.synthesis, ctx);
|
|
269
|
+
return ok(result.valid ? { valid: true, issues: [] } : { valid: false, issues: result.issues });
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
function buildSubmitTool(holder, ctx) {
|
|
128
274
|
return defineTool({
|
|
129
275
|
id: "submit_synthesis",
|
|
130
276
|
description: "Submit the final synthesis. Re-validates the payload against the " +
|
|
@@ -133,29 +279,16 @@ function buildSubmitTool(holder, ctx, telemetry) {
|
|
|
133
279
|
"success returns {accepted: true} — STOP after this. On rejection " +
|
|
134
280
|
"returns {accepted: false, issues} — fix the specific fields at each " +
|
|
135
281
|
"issue path and call again, or switch to report_blocker if the " +
|
|
136
|
-
"synthesis cannot be made valid."
|
|
282
|
+
"synthesis cannot be made valid. A rejected submission leaves any " +
|
|
283
|
+
"already-accepted synthesis untouched; a later accepted submission " +
|
|
284
|
+
"supersedes an earlier one.",
|
|
137
285
|
inputSchema: z.object({ synthesis: RunSynthesisSchema }),
|
|
138
286
|
execute: async (input) => {
|
|
139
|
-
if (holder.outcome !== null) {
|
|
140
|
-
return ok({
|
|
141
|
-
accepted: false,
|
|
142
|
-
issues: [
|
|
143
|
-
{
|
|
144
|
-
path: "synthesis",
|
|
145
|
-
code: "semantic",
|
|
146
|
-
message: "A terminal outcome has already been recorded; submit_synthesis " + "can only be called once per invocation.",
|
|
147
|
-
},
|
|
148
|
-
],
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
287
|
const result = fullyValidate(input.synthesis, ctx);
|
|
152
288
|
if (!result.valid) {
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
telemetry.rejections += 1;
|
|
157
|
-
for (const issue of result.issues)
|
|
158
|
-
telemetry.issuePaths.add(issue.path);
|
|
289
|
+
// A rejection is not terminal — submission is last-valid-wins, so this
|
|
290
|
+
// leaves any already-recorded outcome intact and the model may fix the
|
|
291
|
+
// cited paths and submit again.
|
|
159
292
|
return ok({ accepted: false, issues: result.issues });
|
|
160
293
|
}
|
|
161
294
|
holder.outcome = { kind: "submitted", synthesis: result.synthesis };
|
|
@@ -183,16 +316,15 @@ function buildBlockerTool(holder) {
|
|
|
183
316
|
*/
|
|
184
317
|
export function __buildInnerToolsForTest(args) {
|
|
185
318
|
const holder = { outcome: null };
|
|
186
|
-
const telemetry = { rejections: 0, issuePaths: new Set() };
|
|
187
319
|
const ctx = {
|
|
188
320
|
knownStepIds: args.knownStepIds,
|
|
189
321
|
runId: args.runId,
|
|
190
322
|
};
|
|
191
323
|
return {
|
|
192
|
-
|
|
324
|
+
validate: buildValidateTool(ctx),
|
|
325
|
+
submit: buildSubmitTool(holder, ctx),
|
|
193
326
|
blocker: buildBlockerTool(holder),
|
|
194
327
|
holder,
|
|
195
|
-
telemetry,
|
|
196
328
|
};
|
|
197
329
|
}
|
|
198
330
|
/**
|
|
@@ -206,16 +338,16 @@ export async function generateRunSynthesis(input) {
|
|
|
206
338
|
}
|
|
207
339
|
const knownStepIds = new Set(input.summaries.map((s) => s.stepId));
|
|
208
340
|
const holder = { outcome: null };
|
|
209
|
-
const telemetry = { rejections: 0, issuePaths: new Set() };
|
|
210
341
|
const innerCtx = { knownStepIds, runId: input.runId };
|
|
211
342
|
const reviewer = createLiteratureReviewerTool({
|
|
212
343
|
provider: input.provider,
|
|
213
344
|
model: input.model,
|
|
214
345
|
bioKeys: input.bioKeys,
|
|
215
346
|
});
|
|
216
|
-
const
|
|
347
|
+
const validateTool = buildValidateTool(innerCtx);
|
|
348
|
+
const submitTool = buildSubmitTool(holder, innerCtx);
|
|
217
349
|
const blockerTool = buildBlockerTool(holder);
|
|
218
|
-
const tools = [submitTool, blockerTool, reviewer];
|
|
350
|
+
const tools = [validateTool, submitTool, blockerTool, reviewer];
|
|
219
351
|
const agent = {
|
|
220
352
|
id: AGENT_ID,
|
|
221
353
|
systemPrompt: composeSystemPrompt(synthesisAgentPrompt),
|
|
@@ -241,15 +373,10 @@ export async function generateRunSynthesis(input) {
|
|
|
241
373
|
});
|
|
242
374
|
const outcome = holder.outcome;
|
|
243
375
|
if (outcome?.kind === "submitted") {
|
|
244
|
-
return { kind: "synthesis", synthesis: outcome.synthesis
|
|
376
|
+
return { kind: "synthesis", synthesis: outcome.synthesis };
|
|
245
377
|
}
|
|
246
378
|
if (outcome?.kind === "blocker") {
|
|
247
|
-
return {
|
|
248
|
-
kind: "skipped",
|
|
249
|
-
reason: outcome.reason,
|
|
250
|
-
validationRejections: telemetry.rejections,
|
|
251
|
-
rejectedIssuePaths: [...telemetry.issuePaths].sort(),
|
|
252
|
-
};
|
|
379
|
+
return { kind: "skipped", reason: outcome.reason };
|
|
253
380
|
}
|
|
254
381
|
if (signal.aborted) {
|
|
255
382
|
throw new Error("Run synthesis was cancelled.");
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnosis and repair of model-authored arguments that failed Zod validation.
|
|
3
|
+
*
|
|
4
|
+
* A Zod issue message is accurate but inert: "Invalid input: expected object,
|
|
5
|
+
* received string" tells a model what the schema wanted, not what it did wrong
|
|
6
|
+
* or how to change the next call. Two failure modes put a whole, well-formed
|
|
7
|
+
* JSON value behind that message:
|
|
8
|
+
*
|
|
9
|
+
* - **Double encoding** — the model serialized a nested object or array with
|
|
10
|
+
* `JSON.stringify` and passed the resulting string as the argument value.
|
|
11
|
+
* - **Wrapper artifacts** — the JSON value arrived intact but with function-call
|
|
12
|
+
* markup or a markdown fence stuck to it (`{…}</parameter>\n</invoke>`,
|
|
13
|
+
* ` ```json {…}``` `). The payload is complete; only the wrapper makes it a
|
|
14
|
+
* string.
|
|
15
|
+
*
|
|
16
|
+
* This module owns both halves of the response for the four model-facing
|
|
17
|
+
* validation sites (`loop/run-agent.ts`'s tool-input boundary,
|
|
18
|
+
* `execution/run-synthesis.ts`, `tools/research/generate-plan.ts`,
|
|
19
|
+
* `tools/iterate-report.ts`):
|
|
20
|
+
*
|
|
21
|
+
* - `hintForZodIssue` — a **diagnostic only**. It parses nothing into the
|
|
22
|
+
* caller's data; it distinguishes the two modes so the message the model
|
|
23
|
+
* reads matches the mistake it actually made. When neither mode explains the
|
|
24
|
+
* string it stays silent, because speculative advice to a model that is
|
|
25
|
+
* already misdiagnosing is worse than no advice at all.
|
|
26
|
+
* - `repairToolInput` — an **issue-guided** repair: only a path where the
|
|
27
|
+
* schema wanted an `object` or `array` and found a string is even considered,
|
|
28
|
+
* so a tool that legitimately declares a `z.string()` field is never touched.
|
|
29
|
+
* It makes validation *reachable*; it never substitutes for it. The caller
|
|
30
|
+
* re-runs the full schema over the repaired value, and the tool's own
|
|
31
|
+
* semantic checks still run unchanged.
|
|
32
|
+
*
|
|
33
|
+
* Neither is used by `tools/lib/api-utils.ts`'s `summarizeZodIssues`, whose
|
|
34
|
+
* audience is a developer reading a log about a third-party API response shape —
|
|
35
|
+
* "pass the object directly" is meaningless advice for a payload no one here
|
|
36
|
+
* constructed.
|
|
37
|
+
*
|
|
38
|
+
* Detection walks `issue.path` into the raw input rather than reading the
|
|
39
|
+
* received type off the issue. Zod 4's `invalid_type` issue exposes `expected`
|
|
40
|
+
* but carries no `received` field — the received type appears only inside the
|
|
41
|
+
* human-readable `message`, which is version- and locale-dependent and must
|
|
42
|
+
* not be pattern-matched.
|
|
43
|
+
*/
|
|
44
|
+
import type { z } from "zod";
|
|
45
|
+
/**
|
|
46
|
+
* A hint for a single Zod issue, or `undefined` when nothing actionable can be
|
|
47
|
+
* said. `input` is the raw value that failed validation — the same value handed
|
|
48
|
+
* to `safeParse` — because the issue alone does not carry the offending value.
|
|
49
|
+
*
|
|
50
|
+
* Fires only for an `invalid_type` issue wanting an `object` or an `array` whose
|
|
51
|
+
* path resolves to a string, and only when that string demonstrably *is* the
|
|
52
|
+
* expected value:
|
|
53
|
+
*
|
|
54
|
+
* 1. it parses in full as JSON of the expected type → double encoding, and
|
|
55
|
+
* 2. it parses only once a wrapper artifact is stripped → the value is intact
|
|
56
|
+
* but surrounded by content that does not belong.
|
|
57
|
+
*
|
|
58
|
+
* Anything else — a plain string, a truncated fragment, a stray
|
|
59
|
+
* `<parameter name=…>` opener — yields `undefined`.
|
|
60
|
+
*
|
|
61
|
+
* A path that does not resolve — a mismatched shape, a pruned branch — yields
|
|
62
|
+
* `undefined` rather than throwing.
|
|
63
|
+
*/
|
|
64
|
+
export declare function hintForZodIssue(issue: z.core.$ZodIssue, input: unknown): string | undefined;
|
|
65
|
+
/**
|
|
66
|
+
* Repair model-authored tool input whose JSON values arrived as strings behind a
|
|
67
|
+
* wrapper artifact, or double-encoded. Returns a **new** input value with the
|
|
68
|
+
* repairs applied, or `undefined` when nothing was repaired.
|
|
69
|
+
*
|
|
70
|
+
* The repair is guided entirely by `error`: a path is considered only when the
|
|
71
|
+
* schema reported an `invalid_type` there wanting an `object` or `array` and the
|
|
72
|
+
* value sitting at that path is a string. The parsed result is accepted only if
|
|
73
|
+
* its own type matches what the schema expected, so a string field that happens
|
|
74
|
+
* to hold JSON-looking text is never rewritten.
|
|
75
|
+
*
|
|
76
|
+
* The input object graph is never mutated: the repaired copy shares every
|
|
77
|
+
* untouched branch and clones only along the paths it patches. Repaired content
|
|
78
|
+
* is not re-examined — one pass, no recursion — and no input causes a throw.
|
|
79
|
+
*/
|
|
80
|
+
export declare function repairToolInput(input: unknown, error: z.ZodError): unknown | undefined;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnosis and repair of model-authored arguments that failed Zod validation.
|
|
3
|
+
*
|
|
4
|
+
* A Zod issue message is accurate but inert: "Invalid input: expected object,
|
|
5
|
+
* received string" tells a model what the schema wanted, not what it did wrong
|
|
6
|
+
* or how to change the next call. Two failure modes put a whole, well-formed
|
|
7
|
+
* JSON value behind that message:
|
|
8
|
+
*
|
|
9
|
+
* - **Double encoding** — the model serialized a nested object or array with
|
|
10
|
+
* `JSON.stringify` and passed the resulting string as the argument value.
|
|
11
|
+
* - **Wrapper artifacts** — the JSON value arrived intact but with function-call
|
|
12
|
+
* markup or a markdown fence stuck to it (`{…}</parameter>\n</invoke>`,
|
|
13
|
+
* ` ```json {…}``` `). The payload is complete; only the wrapper makes it a
|
|
14
|
+
* string.
|
|
15
|
+
*
|
|
16
|
+
* This module owns both halves of the response for the four model-facing
|
|
17
|
+
* validation sites (`loop/run-agent.ts`'s tool-input boundary,
|
|
18
|
+
* `execution/run-synthesis.ts`, `tools/research/generate-plan.ts`,
|
|
19
|
+
* `tools/iterate-report.ts`):
|
|
20
|
+
*
|
|
21
|
+
* - `hintForZodIssue` — a **diagnostic only**. It parses nothing into the
|
|
22
|
+
* caller's data; it distinguishes the two modes so the message the model
|
|
23
|
+
* reads matches the mistake it actually made. When neither mode explains the
|
|
24
|
+
* string it stays silent, because speculative advice to a model that is
|
|
25
|
+
* already misdiagnosing is worse than no advice at all.
|
|
26
|
+
* - `repairToolInput` — an **issue-guided** repair: only a path where the
|
|
27
|
+
* schema wanted an `object` or `array` and found a string is even considered,
|
|
28
|
+
* so a tool that legitimately declares a `z.string()` field is never touched.
|
|
29
|
+
* It makes validation *reachable*; it never substitutes for it. The caller
|
|
30
|
+
* re-runs the full schema over the repaired value, and the tool's own
|
|
31
|
+
* semantic checks still run unchanged.
|
|
32
|
+
*
|
|
33
|
+
* Neither is used by `tools/lib/api-utils.ts`'s `summarizeZodIssues`, whose
|
|
34
|
+
* audience is a developer reading a log about a third-party API response shape —
|
|
35
|
+
* "pass the object directly" is meaningless advice for a payload no one here
|
|
36
|
+
* constructed.
|
|
37
|
+
*
|
|
38
|
+
* Detection walks `issue.path` into the raw input rather than reading the
|
|
39
|
+
* received type off the issue. Zod 4's `invalid_type` issue exposes `expected`
|
|
40
|
+
* but carries no `received` field — the received type appears only inside the
|
|
41
|
+
* human-readable `message`, which is version- and locale-dependent and must
|
|
42
|
+
* not be pattern-matched.
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* Strings longer than this are neither parsed nor repaired. A real argument
|
|
46
|
+
* payload is tens of kilobytes; the ceiling exists so a pathological input
|
|
47
|
+
* cannot turn a failed validation into an expensive parse.
|
|
48
|
+
*/
|
|
49
|
+
const MAX_REPAIRABLE_STRING_LENGTH = 1_000_000;
|
|
50
|
+
/** Longest run of offending wrapper text quoted back to the model in a hint. */
|
|
51
|
+
const HINT_SNIPPET_LENGTH = 40;
|
|
52
|
+
/** A trailing `</parameter>`, optionally followed by `</invoke>`. */
|
|
53
|
+
const TRAILING_CALL_TAGS = /<\/parameter>\s*(?:<\/invoke>\s*)?$/;
|
|
54
|
+
/**
|
|
55
|
+
* A hint for a single Zod issue, or `undefined` when nothing actionable can be
|
|
56
|
+
* said. `input` is the raw value that failed validation — the same value handed
|
|
57
|
+
* to `safeParse` — because the issue alone does not carry the offending value.
|
|
58
|
+
*
|
|
59
|
+
* Fires only for an `invalid_type` issue wanting an `object` or an `array` whose
|
|
60
|
+
* path resolves to a string, and only when that string demonstrably *is* the
|
|
61
|
+
* expected value:
|
|
62
|
+
*
|
|
63
|
+
* 1. it parses in full as JSON of the expected type → double encoding, and
|
|
64
|
+
* 2. it parses only once a wrapper artifact is stripped → the value is intact
|
|
65
|
+
* but surrounded by content that does not belong.
|
|
66
|
+
*
|
|
67
|
+
* Anything else — a plain string, a truncated fragment, a stray
|
|
68
|
+
* `<parameter name=…>` opener — yields `undefined`.
|
|
69
|
+
*
|
|
70
|
+
* A path that does not resolve — a mismatched shape, a pruned branch — yields
|
|
71
|
+
* `undefined` rather than throwing.
|
|
72
|
+
*/
|
|
73
|
+
export function hintForZodIssue(issue, input) {
|
|
74
|
+
const expected = repairableExpectation(issue);
|
|
75
|
+
if (expected === undefined)
|
|
76
|
+
return undefined;
|
|
77
|
+
const value = resolvePath(input, issue.path);
|
|
78
|
+
if (typeof value !== "string" || value.length > MAX_REPAIRABLE_STRING_LENGTH)
|
|
79
|
+
return undefined;
|
|
80
|
+
if (parseAs(value, expected) !== undefined) {
|
|
81
|
+
return `This argument arrived as a JSON-encoded string. Pass the ${expected} directly as the argument value — not a JSON string containing it.`;
|
|
82
|
+
}
|
|
83
|
+
const { text, removed } = stripWrappers(value);
|
|
84
|
+
if (removed !== "" && parseAs(text, expected) !== undefined) {
|
|
85
|
+
return `This argument arrived with extra content wrapped around the JSON ${expected} (${quoteSnippet(removed)}). Emit only the JSON ${expected} itself as the argument value — no surrounding tags, fences, or prose.`;
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Repair model-authored tool input whose JSON values arrived as strings behind a
|
|
91
|
+
* wrapper artifact, or double-encoded. Returns a **new** input value with the
|
|
92
|
+
* repairs applied, or `undefined` when nothing was repaired.
|
|
93
|
+
*
|
|
94
|
+
* The repair is guided entirely by `error`: a path is considered only when the
|
|
95
|
+
* schema reported an `invalid_type` there wanting an `object` or `array` and the
|
|
96
|
+
* value sitting at that path is a string. The parsed result is accepted only if
|
|
97
|
+
* its own type matches what the schema expected, so a string field that happens
|
|
98
|
+
* to hold JSON-looking text is never rewritten.
|
|
99
|
+
*
|
|
100
|
+
* The input object graph is never mutated: the repaired copy shares every
|
|
101
|
+
* untouched branch and clones only along the paths it patches. Repaired content
|
|
102
|
+
* is not re-examined — one pass, no recursion — and no input causes a throw.
|
|
103
|
+
*/
|
|
104
|
+
export function repairToolInput(input, error) {
|
|
105
|
+
try {
|
|
106
|
+
let repaired = input;
|
|
107
|
+
let didRepair = false;
|
|
108
|
+
for (const issue of error.issues) {
|
|
109
|
+
const expected = repairableExpectation(issue);
|
|
110
|
+
if (expected === undefined)
|
|
111
|
+
continue;
|
|
112
|
+
const value = resolvePath(repaired, issue.path);
|
|
113
|
+
if (typeof value !== "string" || value.length > MAX_REPAIRABLE_STRING_LENGTH)
|
|
114
|
+
continue;
|
|
115
|
+
const parsed = parseAs(value, expected) ?? parseAs(stripWrappers(value).text, expected);
|
|
116
|
+
if (parsed === undefined)
|
|
117
|
+
continue;
|
|
118
|
+
repaired = setAtPath(repaired, issue.path, parsed.value);
|
|
119
|
+
didRepair = true;
|
|
120
|
+
}
|
|
121
|
+
return didRepair ? repaired : undefined;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** The `object`/`array` expectation of an `invalid_type` issue, if it has one. */
|
|
128
|
+
function repairableExpectation(issue) {
|
|
129
|
+
if (issue.code !== "invalid_type")
|
|
130
|
+
return undefined;
|
|
131
|
+
if (issue.expected === "object")
|
|
132
|
+
return "object";
|
|
133
|
+
if (issue.expected === "array")
|
|
134
|
+
return "array";
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Parse `raw` as JSON and accept it only when the result's own type matches
|
|
139
|
+
* `expected`. Boxed in a `{ value }` cell so a legitimately parsed `null` or
|
|
140
|
+
* `undefined` stays distinguishable from "did not parse" — a `null` never
|
|
141
|
+
* matches either expectation, but the cell keeps the caller's checks honest.
|
|
142
|
+
*/
|
|
143
|
+
function parseAs(raw, expected) {
|
|
144
|
+
let parsed;
|
|
145
|
+
try {
|
|
146
|
+
parsed = JSON.parse(raw);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
if (expected === "array")
|
|
152
|
+
return Array.isArray(parsed) ? { value: parsed } : undefined;
|
|
153
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? { value: parsed } : undefined;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Strip the wrapper artifacts a model sticks to an otherwise complete JSON
|
|
157
|
+
* value: trailing function-call markup and a markdown code fence. `removed`
|
|
158
|
+
* collects what came off, for the hint to quote back; it is empty when the
|
|
159
|
+
* string carried no recognized wrapper.
|
|
160
|
+
*
|
|
161
|
+
* Deliberately narrow, mirroring `tools/research/generate-analogy-report.ts`'s
|
|
162
|
+
* `stripFence`: these two shapes and nothing else. Beyond that, no salvaging —
|
|
163
|
+
* a fragment with a *leading* `<parameter name=…>` opener is not a whole value
|
|
164
|
+
* with decoration on it, and pretending otherwise would invent content.
|
|
165
|
+
*/
|
|
166
|
+
function stripWrappers(raw) {
|
|
167
|
+
const removed = [];
|
|
168
|
+
let text = raw.trim();
|
|
169
|
+
const tags = TRAILING_CALL_TAGS.exec(text);
|
|
170
|
+
if (tags !== null) {
|
|
171
|
+
removed.push(tags[0].trim());
|
|
172
|
+
text = text.slice(0, tags.index).trim();
|
|
173
|
+
}
|
|
174
|
+
if (text.startsWith("```")) {
|
|
175
|
+
const open = /^```(?:json)?\s*\n/i.exec(text);
|
|
176
|
+
if (open !== null) {
|
|
177
|
+
removed.push(open[0].trim());
|
|
178
|
+
text = text.slice(open[0].length);
|
|
179
|
+
}
|
|
180
|
+
const close = /\n```\s*$/.exec(text);
|
|
181
|
+
if (close !== null) {
|
|
182
|
+
removed.push("```");
|
|
183
|
+
text = text.slice(0, close.index);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { text: text.trim(), removed: removed.join(" ") };
|
|
187
|
+
}
|
|
188
|
+
/** Quote wrapper text back to the model, bounded so a hint stays a hint. */
|
|
189
|
+
function quoteSnippet(removed) {
|
|
190
|
+
const snippet = removed.length > HINT_SNIPPET_LENGTH ? `${removed.slice(0, HINT_SNIPPET_LENGTH)}…` : removed;
|
|
191
|
+
return `found: ${JSON.stringify(snippet)}`;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Walk a Zod issue path into the raw input. Returns `undefined` for any key
|
|
195
|
+
* that cannot be traversed, which is indistinguishable from a genuine
|
|
196
|
+
* `undefined` at that path — acceptable here, since the caller only acts on a
|
|
197
|
+
* resolved `string`.
|
|
198
|
+
*/
|
|
199
|
+
function resolvePath(input, path) {
|
|
200
|
+
let current = input;
|
|
201
|
+
for (const key of path) {
|
|
202
|
+
if (current === null || (typeof current !== "object" && typeof current !== "function")) {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
current = current[key];
|
|
206
|
+
}
|
|
207
|
+
return current;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Return a copy of `root` with `value` at `path`, cloning only the containers
|
|
211
|
+
* along that path and sharing every other branch. `root` is left untouched.
|
|
212
|
+
*/
|
|
213
|
+
function setAtPath(root, path, value) {
|
|
214
|
+
if (path.length === 0)
|
|
215
|
+
return value;
|
|
216
|
+
const [key, ...rest] = path;
|
|
217
|
+
if (root === null || typeof root !== "object")
|
|
218
|
+
return root;
|
|
219
|
+
const container = root;
|
|
220
|
+
const child = setAtPath(container[key], rest, value);
|
|
221
|
+
if (Array.isArray(root)) {
|
|
222
|
+
const copy = [...root];
|
|
223
|
+
copy[key] = child;
|
|
224
|
+
return copy;
|
|
225
|
+
}
|
|
226
|
+
return { ...container, [key]: child };
|
|
227
|
+
}
|
package/dist/loop/run-agent.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsonSchema, tool as aiTool } from "ai";
|
|
2
|
+
import { hintForZodIssue, repairToolInput } from "../lib/zod-issues.js";
|
|
2
3
|
import { classifyProviderError } from "../providers/errors.js";
|
|
3
4
|
import { DEFAULT_PROMPT_CACHE, promptCacheProviderOptions } from "../providers/prompt-cache.js";
|
|
4
5
|
import { resultStep } from "./run-step.js";
|
|
@@ -153,11 +154,21 @@ async function dispatchTool(tu, toolsById, ctx, isFatalLoopError) {
|
|
|
153
154
|
return errorResult(tu, `unknown tool: ${tu.toolName}`);
|
|
154
155
|
}
|
|
155
156
|
const parsed = tool.inputSchema.safeParse(tu.input);
|
|
156
|
-
if (
|
|
157
|
-
return
|
|
158
|
-
|
|
157
|
+
if (parsed.success)
|
|
158
|
+
return execute(tu, tool, parsed.data, ctx, isFatalLoopError);
|
|
159
|
+
// A complete JSON argument can arrive as a string behind function-call
|
|
160
|
+
// markup or a code fence. Repair only makes the schema reachable — the
|
|
161
|
+
// repaired value is validated in full, and the tool's own semantic checks
|
|
162
|
+
// still run, so nothing here weakens validation.
|
|
163
|
+
const repairedInput = repairToolInput(tu.input, parsed.error);
|
|
164
|
+
const repaired = repairedInput === undefined ? undefined : tool.inputSchema.safeParse(repairedInput);
|
|
165
|
+
if (repaired?.success === true)
|
|
166
|
+
return execute(tu, tool, repaired.data, ctx, isFatalLoopError);
|
|
167
|
+
return errorResult(tu, `input validation failed: ${formatZodIssues(parsed.error, tu.input)}`);
|
|
168
|
+
}
|
|
169
|
+
async function execute(tu, tool, input, ctx, isFatalLoopError) {
|
|
159
170
|
try {
|
|
160
|
-
const output = await tool.execute(
|
|
171
|
+
const output = await tool.execute(input, ctx);
|
|
161
172
|
if (output.isErr())
|
|
162
173
|
return errorResult(tu, toolErrorContent(output.error));
|
|
163
174
|
return successResult(tu, output.value);
|
|
@@ -209,11 +220,12 @@ export function finalText(messages) {
|
|
|
209
220
|
.map((block) => block.text)
|
|
210
221
|
.join("");
|
|
211
222
|
}
|
|
212
|
-
function formatZodIssues(error) {
|
|
223
|
+
function formatZodIssues(error, input) {
|
|
213
224
|
return error.issues
|
|
214
225
|
.map((issue) => {
|
|
215
226
|
const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
|
|
216
|
-
|
|
227
|
+
const hint = hintForZodIssue(issue, input);
|
|
228
|
+
return hint === undefined ? `${path}: ${issue.message}` : `${path}: ${issue.message} — ${hint}`;
|
|
217
229
|
})
|
|
218
230
|
.join("; ");
|
|
219
231
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const synthesisAgentPrompt = "# Run Synthesizer\n\nYou are a cross-step synthesizer for a completed bioinformatics analysis\nrun. You receive the plan's analytical narrative and a block of per-step\nsummaries (markdown). Your job is to distill them into a concise,\nliterature-grounded **interpretation** \u2014 not a catalog of what each step\nproduced.\n\nYou are task-oriented \u2014 you do NOT interact with the user. You receive\nthe run's context, investigate what you need via your research sub-agent,\nand return a structured synthesis.\n\n## CRITICAL \u2014 Read This First\n\n**You communicate results EXCLUSIVELY by calling tools. This is not optional.**\n\nA response that contains text, prose, JSON, markdown, code blocks, or any\nother non-tool-call content is a **failure** \u2014 the orchestrator does not\nread your text and has no way to use it. The only way a synthesis reaches\nthe user is via `submit_synthesis`. The only way a blocker reaches the\nuser is via `report_blocker`.\n\nEvery session MUST end with exactly one call to `submit_synthesis` or\n`report_blocker`. Do NOT respond with text before any tool call.\n\n## Your Mission\n\nThe reader of your synthesis is a scientist who ALREADY has the step\nsummaries. They do not need you to repeat what each step found \u2014 they\ncan read that themselves. What they need from you is:\n\n1. **Interpretation** \u2014 what do the results mean taken together?\n2. **Conclusions** \u2014 what can we confidently conclude? What is novel vs\n confirmatory? What is actionable from a translational perspective?\n3. **Limitations** \u2014 what caveats apply? Where should the reader be\n skeptical? What confounds exist?\n4. **Evidence** \u2014 key literature references that support, challenge, or\n contextualize the findings.\n\nThink like a senior reviewer writing the Discussion section of a paper,\nnot like a scribe summarizing the Results section.\n\n## Your Tools\n\n### literature_reviewer (research sub-agent \u2014 delegate focused research)\nA task-oriented sub-agent with access to bio-lookup tools (search_gene,\nsearch_pathway, lookup_go_term, search_interactions, pubmed,\nsearch_bgee_expression, get_impc_ko_profile). Delegate to it when you need\nevidence \u2014 you have no bio-lookup tools of your own.\n\nDelegations must be **focused**:\n- ONE well-scoped research brief per call (e.g., \"Investigate FOXM1 and\n MKI67 in the context of proliferation signatures in bulk RNA-seq of\n breast tumor vs normal. Assess novelty and find supporting PMIDs.\")\n- Include the experimental context the sub-agent needs (conditions, modality,\n what the computational finding showed).\n- Do NOT send the whole summary block and ask for \"a literature review\".\n The sub-agent will produce shallow results on diffuse briefs.\n\nTypical usage: 1-3 delegations per run. Multiple small focused briefs\nbeat one giant one. You may delegate again after an initial pass if a\ntheme becomes apparent and needs its own evidence sweep.\n\n### submit_synthesis (terminal \u2014 success path)\nPersist the synthesis. Re-validates the payload; returns either\n`{accepted: true}` (DONE \u2014 stop) or `{accepted: false, issues}` (fix\nspecific fields per issue path and call again, OR switch to\n`report_blocker` if the synthesis cannot be made valid).\n\n### report_blocker (terminal \u2014 cannot produce a synthesis)\nCall ONLY when the run produced no synthesizable content \u2014 every summary is\nempty, or the summaries are contradictory to the point of incoherence.\n\"No findings worth surfacing\" is NOT a blocker: with non-empty summaries you\ncan always submit a synthesis (overview + conclusions) with an empty\n`findings[]`. Pass a short reason. STOP after calling.\n\n## Canonical Flow\n\n1. Read the analytical narrative + all step summaries end-to-end.\n Identify the 3-5 most important conclusions, any novel or\n contradicted findings, and cross-step themes.\n2. Delegate 1-3 focused research briefs to `literature_reviewer` \u2014\n targeting novel findings, contradictions, and themes that need\n literature grounding.\n3. Draft the synthesis payload with an interpretive focus.\n4. Call `submit_synthesis`. If rejected, fix the cited issue paths\n and call again. If unfixable, call `report_blocker`.\n\n## Writing the Synthesis\n\nThe `submit_synthesis` arg schema defines every field, its enum values,\nand its size expectations \u2014 read it, and follow it. This section is the\ncraft the schema cannot carry.\n\n**An empty `findings[]` is a valid submission.** `findings` is SELECTIVE,\nnot required \u2014 a run whose summaries yield nothing individually notable is\nstill synthesized: write the `overview` and `conclusions` from the\nsummaries and submit with `findings: []`. \"Nothing worth a finding\" is a\nreason to submit a lean synthesis, never a reason to `report_blocker`.\n\n**`conclusions` is the \"Discussion\" of the synthesis** \u2014 the part the\nreader will actually want to read. Be opinionated and interpretive, not\ndescriptive. Say where computational predictions agree or disagree with\nclinical evidence, and be honest about the translational distance: is this\ninteresting biology, or a validated target with a measurable biomarker and\na known drug?\n\n**Assigning `preclinical-validation`** \u2014 cite IMPC mouse-KO phenotypes\n(`get_impc_ko_profile`) for loss-of-function consequences and\ncross-species baseline expression (`search_bgee_expression`) for\ntissue-of-action context, when the literature reviewer's report includes\nthem.\n\n**What a good `limitation` looks like** \u2014 it names what it affects and\nwhy it matters:\n- \"Signatures derive from a single cohort (n=57); cross-validation in\n independent NASH datasets was not performed.\"\n- \"CMap-style scoring cannot distinguish targeted pathway reversal from\n non-specific cytotoxic transcriptional disruption.\"\n- \"Biomarker candidates identified computationally; analytical validation\n (assay performance, reproducibility) and clinical validation (outcome\n association in independent cohorts) not performed.\"\n- \"Target druggability not assessed \u2014 pathway importance does not imply\n tractability.\"\n\n## Cross-Field Invariants (enforced at submit_synthesis)\n\n- `finding.stepId` must match a stepId present in the input summaries.\n- Every `theme.findings[]` entry must reference a finding that exists\n in `findings[]` (same stepId + title).\n- Every `keyReferences[].pmid` must appear in at least one\n `finding.references[]` entry \u2014 the cross-run list summarizes what's\n already cited per finding.\n- Every `pmid` you write must come from a `literature_reviewer` response.\n Inventing PMIDs is a terminal failure \u2014 the synthesis is discarded on audit.\n\n## Investigation Strategy\n\n**Which findings need delegation?**\n- Findings labeled high-confidence in the summary but potentially novel \u2014\n needs literature check to confirm or challenge novelty.\n- Findings that could map to a known disease or pathway context \u2014 needs\n a PubMed sweep for the gene+condition combination.\n- Candidate themes spanning 2+ steps \u2014 delegate a theme-level brief to\n get references that tie the findings together.\n\n**Which findings do NOT need delegation?**\n- Trivial / expected results (QC passed, dimensions as expected).\n- Technical findings with no biological claim (batch effect detected,\n normalization applied).\n\n**Composing a delegation brief** \u2014 include:\n1. The specific genes/pathways/features to investigate (from the summary).\n2. The experimental context (conditions, modality, organism if known).\n3. What the computational finding showed (one sentence).\n4. What you want back (novelty assessment? supporting PMIDs? pathway\n context? interaction partners?).\n\nExample:\n> \"Investigate SPP1, TREM2, and APOE in the context of microglia\n> activation in Alzheimer's disease single-cell RNA-seq. Finding:\n> these three genes form a co-expression module enriched in\n> disease-associated microglia. Return: novelty assessment, 3-5 key\n> PMIDs supporting or challenging the DAM signature, and any known\n> interactions among the three.\"\n\n## Do NOT\n\n- Respond with plain text (even to explain your reasoning \u2014 nothing is read).\n- Invent PMIDs, citations, or gene facts. Only report what the reviewer returned.\n- Delegate a blanket brief (\"review all the findings\") \u2014 delegate targeted briefs.\n- **Reiterate step results.** The reader has the step summaries. Do not\n repeat gene lists, sample counts, table dimensions, method parameters,\n or per-step results in the synthesis. Refer to findings by their\n biological meaning, not their technical details.\n- **Create one finding per step.** That is a catalog, not a synthesis.\n Cross-reference across steps and surface only what matters.\n- **Write a long overview.** The conclusions field is where interpretation goes.\n- **Include methodology findings.** Gene intersection sizes, normalization\n choices, platform coverage \u2014 these are technical housekeeping, not\n synthesis findings.\n- **Include expected or confirmatory findings.** Canonical pathway\n enrichments, QC metrics, and well-established biology do not warrant a\n finding \u2014 unless the confirmation itself is noteworthy (e.g., a new\n dataset independently confirming a contested result).\n- **Inflate translational relevance.** A differentially expressed gene is\n NOT automatically a therapeutic target. A survival association is NOT\n automatically a predictive biomarker (it may be prognostic only) \u2014\n predictive requires evidence of treatment x marker interaction,\n prognostic only requires association with outcome.\n- Force findings into themes. If findings don't converge, return fewer (or zero) themes.\n- **Call `report_blocker` because no finding seems worth surfacing.** With\n non-empty summaries, submit a synthesis with an empty `findings[]` instead \u2014\n a blocker is only for empty or incoherent summaries.\n- Call `submit_synthesis` before delegating at least once when findings need\n literature grounding. (A run with only technical/QC findings may skip delegation\n and submit directly, but state that clearly in the overview.)\n- Reference stepIds that aren't in the summaries, or theme-findings that\n aren't in `findings[]`.\n- Call `submit_synthesis` twice after `accepted: true`. Stop immediately.\n- End the session without a terminal tool call. That is a failure mode.\n";
|
|
1
|
+
export declare const synthesisAgentPrompt = "# Run Synthesizer\n\nYou are a cross-step synthesizer for a completed bioinformatics analysis\nrun. You receive the plan's analytical narrative and a block of per-step\nsummaries (markdown). Your job is to distill them into a concise,\nliterature-grounded **interpretation** \u2014 not a catalog of what each step\nproduced.\n\nYou are task-oriented \u2014 you do NOT interact with the user. You receive\nthe run's context, investigate what you need via your research sub-agent,\nand return a structured synthesis.\n\n## CRITICAL \u2014 Read This First\n\n**You communicate results EXCLUSIVELY by calling tools. This is not optional.**\n\nA response that contains text, prose, JSON, markdown, code blocks, or any\nother non-tool-call content is a **failure** \u2014 the orchestrator does not\nread your text and has no way to use it. The only way a synthesis reaches\nthe user is via `submit_synthesis`. The only way a blocker reaches the\nuser is via `report_blocker`.\n\nEvery session MUST end with exactly one call to `submit_synthesis` or\n`report_blocker`. Do NOT respond with text before any tool call.\n\n## Your Mission\n\nThe reader of your synthesis is a scientist who ALREADY has the step\nsummaries. They do not need you to repeat what each step found \u2014 they\ncan read that themselves. What they need from you is:\n\n1. **Interpretation** \u2014 what do the results mean taken together?\n2. **Conclusions** \u2014 what can we confidently conclude? What is novel vs\n confirmatory? What is actionable from a translational perspective?\n3. **Limitations** \u2014 what caveats apply? Where should the reader be\n skeptical? What confounds exist?\n4. **Evidence** \u2014 key literature references that support, challenge, or\n contextualize the findings.\n\nThink like a senior reviewer writing the Discussion section of a paper,\nnot like a scribe summarizing the Results section.\n\n## Your Tools\n\n### literature_reviewer (research sub-agent \u2014 delegate focused research)\nA task-oriented sub-agent with access to bio-lookup tools (search_gene,\nsearch_pathway, lookup_go_term, search_interactions, pubmed,\nsearch_bgee_expression, get_impc_ko_profile). Delegate to it when you need\nevidence \u2014 you have no bio-lookup tools of your own.\n\nDelegations must be **focused**:\n- ONE well-scoped research brief per call (e.g., \"Investigate FOXM1 and\n MKI67 in the context of proliferation signatures in bulk RNA-seq of\n breast tumor vs normal. Assess novelty and find supporting PMIDs.\")\n- Include the experimental context the sub-agent needs (conditions, modality,\n what the computational finding showed).\n- Do NOT send the whole summary block and ask for \"a literature review\".\n The sub-agent will produce shallow results on diffuse briefs.\n\nTypical usage: 1-3 delegations per run. Multiple small focused briefs\nbeat one giant one. You may delegate again after an initial pass if a\ntheme becomes apparent and needs its own evidence sweep.\n\n### validate_synthesis (non-terminal \u2014 dry run)\nCheck a candidate synthesis without submitting it. Takes the payload in\nANY shape \u2014 a partial, malformed, or wrong-typed draft is reported, not\nrejected \u2014 and returns `{valid, issues}` covering both schema problems\nand the cross-field invariants below. Call it as often as you need: it\nrecords nothing and never ends the session. Use it before every\n`submit_synthesis` so you spend your terminal call on a payload you\nalready know is clean.\n\n### submit_synthesis (terminal \u2014 success path)\nPersist the synthesis. Re-validates the payload; returns either\n`{accepted: true}` (DONE \u2014 stop) or `{accepted: false, issues}` (fix\nspecific fields per issue path and call again, OR switch to\n`report_blocker` if the synthesis cannot be made valid). A rejected\nsubmission leaves an already-accepted synthesis untouched; a later\naccepted submission supersedes the earlier one.\n\n### report_blocker (terminal \u2014 cannot produce a synthesis)\nCall ONLY when the run produced no synthesizable content \u2014 every summary is\nempty, or the summaries are contradictory to the point of incoherence.\n\"No findings worth surfacing\" is NOT a blocker: with non-empty summaries you\ncan always submit a synthesis (overview + conclusions) with an empty\n`findings[]`. Pass a short reason. STOP after calling.\n\n## Canonical Flow\n\n1. Read the analytical narrative + all step summaries end-to-end.\n Identify the 3-5 most important conclusions, any novel or\n contradicted findings, and cross-step themes.\n2. Delegate 1-3 focused research briefs to `literature_reviewer` \u2014\n targeting novel findings, contradictions, and themes that need\n literature grounding.\n3. Draft the synthesis payload with an interpretive focus.\n4. Call `validate_synthesis` on the draft. Fix the cited issue paths\n and re-validate until it comes back `{valid: true}`.\n5. Call `submit_synthesis` with the validated payload. If it is still\n rejected, fix the cited issue paths and call again. If unfixable,\n call `report_blocker`.\n\n## Writing the Synthesis\n\nThe `submit_synthesis` arg schema defines every field, its enum values,\nand its size expectations \u2014 read it, and follow it. This section is the\ncraft the schema cannot carry.\n\n**An empty `findings[]` is a valid submission.** `findings` is SELECTIVE,\nnot required \u2014 a run whose summaries yield nothing individually notable is\nstill synthesized: write the `overview` and `conclusions` from the\nsummaries and submit with `findings: []`. \"Nothing worth a finding\" is a\nreason to submit a lean synthesis, never a reason to `report_blocker`.\n\n**`conclusions` is the \"Discussion\" of the synthesis** \u2014 the part the\nreader will actually want to read. Be opinionated and interpretive, not\ndescriptive. Say where computational predictions agree or disagree with\nclinical evidence, and be honest about the translational distance: is this\ninteresting biology, or a validated target with a measurable biomarker and\na known drug?\n\n**Assigning `preclinical-validation`** \u2014 cite IMPC mouse-KO phenotypes\n(`get_impc_ko_profile`) for loss-of-function consequences and\ncross-species baseline expression (`search_bgee_expression`) for\ntissue-of-action context, when the literature reviewer's report includes\nthem.\n\n**What a good `limitation` looks like** \u2014 it names what it affects and\nwhy it matters:\n- \"Signatures derive from a single cohort (n=57); cross-validation in\n independent NASH datasets was not performed.\"\n- \"CMap-style scoring cannot distinguish targeted pathway reversal from\n non-specific cytotoxic transcriptional disruption.\"\n- \"Biomarker candidates identified computationally; analytical validation\n (assay performance, reproducibility) and clinical validation (outcome\n association in independent cohorts) not performed.\"\n- \"Target druggability not assessed \u2014 pathway importance does not imply\n tractability.\"\n\n## Cross-Field Invariants (reported by validate_synthesis, enforced at submit_synthesis)\n\n- `finding.stepId` must match a stepId present in the input summaries.\n- Every `theme.findings[]` entry must reference a finding that exists\n in `findings[]` (same stepId + title).\n- Every `keyReferences[].pmid` must appear in at least one\n `finding.references[]` entry \u2014 the cross-run list summarizes what's\n already cited per finding.\n- Every `pmid` you write must come from a `literature_reviewer` response.\n Inventing PMIDs is a terminal failure \u2014 the synthesis is discarded on audit.\n\n## Investigation Strategy\n\n**Which findings need delegation?**\n- Findings labeled high-confidence in the summary but potentially novel \u2014\n needs literature check to confirm or challenge novelty.\n- Findings that could map to a known disease or pathway context \u2014 needs\n a PubMed sweep for the gene+condition combination.\n- Candidate themes spanning 2+ steps \u2014 delegate a theme-level brief to\n get references that tie the findings together.\n\n**Which findings do NOT need delegation?**\n- Trivial / expected results (QC passed, dimensions as expected).\n- Technical findings with no biological claim (batch effect detected,\n normalization applied).\n\n**Composing a delegation brief** \u2014 include:\n1. The specific genes/pathways/features to investigate (from the summary).\n2. The experimental context (conditions, modality, organism if known).\n3. What the computational finding showed (one sentence).\n4. What you want back (novelty assessment? supporting PMIDs? pathway\n context? interaction partners?).\n\nExample:\n> \"Investigate SPP1, TREM2, and APOE in the context of microglia\n> activation in Alzheimer's disease single-cell RNA-seq. Finding:\n> these three genes form a co-expression module enriched in\n> disease-associated microglia. Return: novelty assessment, 3-5 key\n> PMIDs supporting or challenging the DAM signature, and any known\n> interactions among the three.\"\n\n## Do NOT\n\n- Respond with plain text (even to explain your reasoning \u2014 nothing is read).\n- Invent PMIDs, citations, or gene facts. Only report what the reviewer returned.\n- Delegate a blanket brief (\"review all the findings\") \u2014 delegate targeted briefs.\n- **Reiterate step results.** The reader has the step summaries. Do not\n repeat gene lists, sample counts, table dimensions, method parameters,\n or per-step results in the synthesis. Refer to findings by their\n biological meaning, not their technical details.\n- **Create one finding per step.** That is a catalog, not a synthesis.\n Cross-reference across steps and surface only what matters.\n- **Write a long overview.** The conclusions field is where interpretation goes.\n- **Include methodology findings.** Gene intersection sizes, normalization\n choices, platform coverage \u2014 these are technical housekeeping, not\n synthesis findings.\n- **Include expected or confirmatory findings.** Canonical pathway\n enrichments, QC metrics, and well-established biology do not warrant a\n finding \u2014 unless the confirmation itself is noteworthy (e.g., a new\n dataset independently confirming a contested result).\n- **Inflate translational relevance.** A differentially expressed gene is\n NOT automatically a therapeutic target. A survival association is NOT\n automatically a predictive biomarker (it may be prognostic only) \u2014\n predictive requires evidence of treatment x marker interaction,\n prognostic only requires association with outcome.\n- Force findings into themes. If findings don't converge, return fewer (or zero) themes.\n- **Call `report_blocker` because no finding seems worth surfacing.** With\n non-empty summaries, submit a synthesis with an empty `findings[]` instead \u2014\n a blocker is only for empty or incoherent summaries.\n- Call `submit_synthesis` before delegating at least once when findings need\n literature grounding. (A run with only technical/QC findings may skip delegation\n and submit directly, but state that clearly in the overview.)\n- Reference stepIds that aren't in the summaries, or theme-findings that\n aren't in `findings[]`.\n- Keep calling `submit_synthesis` after `accepted: true`. It is not an\n error \u2014 a later accepted submission simply supersedes the earlier one \u2014\n but the synthesis is already recorded, so stop immediately.\n- Use `submit_synthesis` as a validation probe. Probing is what\n `validate_synthesis` is for; submit only what you already validated.\n- Submit a placeholder or stub payload to \"see what happens\". A degenerate\n payload that passes the schema is recorded as the run's result.\n- End the session without a terminal tool call. That is a failure mode.\n";
|
|
@@ -61,11 +61,22 @@ Typical usage: 1-3 delegations per run. Multiple small focused briefs
|
|
|
61
61
|
beat one giant one. You may delegate again after an initial pass if a
|
|
62
62
|
theme becomes apparent and needs its own evidence sweep.
|
|
63
63
|
|
|
64
|
+
### validate_synthesis (non-terminal — dry run)
|
|
65
|
+
Check a candidate synthesis without submitting it. Takes the payload in
|
|
66
|
+
ANY shape — a partial, malformed, or wrong-typed draft is reported, not
|
|
67
|
+
rejected — and returns \`{valid, issues}\` covering both schema problems
|
|
68
|
+
and the cross-field invariants below. Call it as often as you need: it
|
|
69
|
+
records nothing and never ends the session. Use it before every
|
|
70
|
+
\`submit_synthesis\` so you spend your terminal call on a payload you
|
|
71
|
+
already know is clean.
|
|
72
|
+
|
|
64
73
|
### submit_synthesis (terminal — success path)
|
|
65
74
|
Persist the synthesis. Re-validates the payload; returns either
|
|
66
75
|
\`{accepted: true}\` (DONE — stop) or \`{accepted: false, issues}\` (fix
|
|
67
76
|
specific fields per issue path and call again, OR switch to
|
|
68
|
-
\`report_blocker\` if the synthesis cannot be made valid).
|
|
77
|
+
\`report_blocker\` if the synthesis cannot be made valid). A rejected
|
|
78
|
+
submission leaves an already-accepted synthesis untouched; a later
|
|
79
|
+
accepted submission supersedes the earlier one.
|
|
69
80
|
|
|
70
81
|
### report_blocker (terminal — cannot produce a synthesis)
|
|
71
82
|
Call ONLY when the run produced no synthesizable content — every summary is
|
|
@@ -83,8 +94,11 @@ can always submit a synthesis (overview + conclusions) with an empty
|
|
|
83
94
|
targeting novel findings, contradictions, and themes that need
|
|
84
95
|
literature grounding.
|
|
85
96
|
3. Draft the synthesis payload with an interpretive focus.
|
|
86
|
-
4. Call \`
|
|
87
|
-
and
|
|
97
|
+
4. Call \`validate_synthesis\` on the draft. Fix the cited issue paths
|
|
98
|
+
and re-validate until it comes back \`{valid: true}\`.
|
|
99
|
+
5. Call \`submit_synthesis\` with the validated payload. If it is still
|
|
100
|
+
rejected, fix the cited issue paths and call again. If unfixable,
|
|
101
|
+
call \`report_blocker\`.
|
|
88
102
|
|
|
89
103
|
## Writing the Synthesis
|
|
90
104
|
|
|
@@ -123,7 +137,7 @@ why it matters:
|
|
|
123
137
|
- "Target druggability not assessed — pathway importance does not imply
|
|
124
138
|
tractability."
|
|
125
139
|
|
|
126
|
-
## Cross-Field Invariants (enforced at submit_synthesis)
|
|
140
|
+
## Cross-Field Invariants (reported by validate_synthesis, enforced at submit_synthesis)
|
|
127
141
|
|
|
128
142
|
- \`finding.stepId\` must match a stepId present in the input summaries.
|
|
129
143
|
- Every \`theme.findings[]\` entry must reference a finding that exists
|
|
@@ -197,6 +211,12 @@ Example:
|
|
|
197
211
|
and submit directly, but state that clearly in the overview.)
|
|
198
212
|
- Reference stepIds that aren't in the summaries, or theme-findings that
|
|
199
213
|
aren't in \`findings[]\`.
|
|
200
|
-
-
|
|
214
|
+
- Keep calling \`submit_synthesis\` after \`accepted: true\`. It is not an
|
|
215
|
+
error — a later accepted submission simply supersedes the earlier one —
|
|
216
|
+
but the synthesis is already recorded, so stop immediately.
|
|
217
|
+
- Use \`submit_synthesis\` as a validation probe. Probing is what
|
|
218
|
+
\`validate_synthesis\` is for; submit only what you already validated.
|
|
219
|
+
- Submit a placeholder or stub payload to "see what happens". A degenerate
|
|
220
|
+
payload that passes the schema is recorded as the run's result.
|
|
201
221
|
- End the session without a terminal tool call. That is a failure mode.
|
|
202
222
|
`;
|
|
@@ -44,7 +44,12 @@ export interface DockerClientConfig {
|
|
|
44
44
|
resolveWorkspaceRoot: ResolveWorkspaceRoot;
|
|
45
45
|
/** Host lib store; bind-mounted read-only at `/mnt/libs` when set. */
|
|
46
46
|
libStorePath?: string;
|
|
47
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Host ref store; bind-mounted read-only at `/mnt/refs`. Embedders pass the
|
|
49
|
+
* configured store location unconditionally — existence is (re-)checked at each
|
|
50
|
+
* sandbox creation, so a store installed mid-session is mounted into subsequent
|
|
51
|
+
* sandboxes without a runtime restart.
|
|
52
|
+
*/
|
|
48
53
|
refStorePath?: string;
|
|
49
54
|
/**
|
|
50
55
|
* Force the container platform (e.g. `linux/amd64`). Set by hosts that mount
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* Either way the workload ends as uid 1000 with `no-new-privileges` and no
|
|
29
29
|
* effective capabilities.
|
|
30
30
|
*/
|
|
31
|
-
import { existsSync, statSync } from "node:fs";
|
|
31
|
+
import { existsSync, lstatSync, statSync } from "node:fs";
|
|
32
32
|
import { join } from "node:path";
|
|
33
33
|
import Docker from "dockerode";
|
|
34
34
|
import { ResultAsync, err, ok } from "neverthrow";
|
|
@@ -80,6 +80,30 @@ function libStoreUsable(libStorePath) {
|
|
|
80
80
|
}
|
|
81
81
|
return existsSync(join(current, "packages.txt")) && existsSync(join(current, "meta.json"));
|
|
82
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether `refStorePath` is, right now, a real directory fit to be a bind authority.
|
|
85
|
+
* Deliberately shallow — it checks existence-as-a-directory only, never the store's
|
|
86
|
+
* interior: the harness knows nothing of the ref store's layout, and dataset
|
|
87
|
+
* completeness/receipts are the embedder's concern, so (unlike `libStoreUsable`, which
|
|
88
|
+
* validates harness-known `current` layout) there is nothing here to validate beyond
|
|
89
|
+
* "a directory is present."
|
|
90
|
+
*
|
|
91
|
+
* `lstatSync` (not `statSync`) does NOT follow symlinks, so a symlinked path is rejected
|
|
92
|
+
* even when it resolves to a directory: the bind authority must itself be the store
|
|
93
|
+
* directory, not an indirection that may later point elsewhere.
|
|
94
|
+
*
|
|
95
|
+
* Binding a missing source makes Docker auto-create a root-owned directory at the path —
|
|
96
|
+
* the same hazard `libStoreUsable` documents — which would also brick the embedder's
|
|
97
|
+
* later store install (its store tool cannot write into a root-owned dir).
|
|
98
|
+
*/
|
|
99
|
+
function refStoreUsable(refStorePath) {
|
|
100
|
+
try {
|
|
101
|
+
return lstatSync(refStorePath).isDirectory();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
83
107
|
async function pollHealth(fetchImpl, url, timeoutMs) {
|
|
84
108
|
const deadline = Date.now() + timeoutMs;
|
|
85
109
|
let lastErr;
|
|
@@ -187,9 +211,16 @@ export function createDockerSandboxOps(config) {
|
|
|
187
211
|
if (config.libStorePath && !libsMounted) {
|
|
188
212
|
logger.warn("lib store configured but `current` is missing or incomplete at sandbox creation — mounting no library store (sandbox degrades to available:false)", { libStorePath: config.libStorePath, sandboxId });
|
|
189
213
|
}
|
|
214
|
+
// Re-checked here too, so a ref store installed after boot is mounted into
|
|
215
|
+
// the next sandbox. Unlike libs, a missing ref store is NOT warned: a missing
|
|
216
|
+
// lib store is a degradation (it was configured and working, then broke),
|
|
217
|
+
// whereas a missing ref store is the normal cold state — the user simply has
|
|
218
|
+
// not installed reference data yet — and warning on every create would be
|
|
219
|
+
// noise. A store that appears mid-session is picked up by the next create.
|
|
220
|
+
const refsMounted = !!config.refStorePath && refStoreUsable(config.refStorePath);
|
|
190
221
|
const plan = buildMountPlan(meta, {
|
|
191
222
|
libs: libsMounted,
|
|
192
|
-
refs:
|
|
223
|
+
refs: refsMounted,
|
|
193
224
|
});
|
|
194
225
|
const hostTreePath = config.resolveWorkspaceRoot(meta.analysisId);
|
|
195
226
|
// `stepWritePrefix` (not a raw `join`) so the RW bind source runs through
|
|
@@ -200,7 +231,7 @@ export function createDockerSandboxOps(config) {
|
|
|
200
231
|
`${hostTreePath}:${plan.readonlyTreePath}:ro`,
|
|
201
232
|
...(plan.writableStepPath ? [`${hostStepPath}:${plan.writableStepPath}:rw`] : []),
|
|
202
233
|
...(libsMounted && config.libStorePath ? [`${config.libStorePath}:${plan.libsPath}:ro`] : []),
|
|
203
|
-
...(config.refStorePath ? [`${config.refStorePath}:${plan.refsPath}:ro`] : []),
|
|
234
|
+
...(refsMounted && config.refStorePath ? [`${config.refStorePath}:${plan.refsPath}:ro`] : []),
|
|
204
235
|
];
|
|
205
236
|
const createFailed = (status, cause) => ({
|
|
206
237
|
type: "container_create_failed",
|
|
@@ -31,11 +31,11 @@ export declare const SynthesizedFindingSchema: z.ZodObject<{
|
|
|
31
31
|
low: "low";
|
|
32
32
|
}>;
|
|
33
33
|
noveltyStatus: z.ZodEnum<{
|
|
34
|
+
expected: "expected";
|
|
34
35
|
novel: "novel";
|
|
35
36
|
confirmed: "confirmed";
|
|
36
37
|
partially_confirmed: "partially_confirmed";
|
|
37
38
|
contradicted: "contradicted";
|
|
38
|
-
expected: "expected";
|
|
39
39
|
}>;
|
|
40
40
|
literatureInterpretation: z.ZodString;
|
|
41
41
|
references: z.ZodArray<z.ZodObject<{
|
|
@@ -78,6 +78,11 @@ export declare const BiologicalThemeSchema: z.ZodObject<{
|
|
|
78
78
|
}, z.core.$strip>>;
|
|
79
79
|
narrative: z.ZodString;
|
|
80
80
|
}, z.core.$strip>;
|
|
81
|
+
export declare const KeyReferenceSchema: z.ZodObject<{
|
|
82
|
+
pmid: z.ZodString;
|
|
83
|
+
citation: z.ZodString;
|
|
84
|
+
description: z.ZodString;
|
|
85
|
+
}, z.core.$strip>;
|
|
81
86
|
export declare const RunSynthesisSchema: z.ZodObject<{
|
|
82
87
|
runId: z.ZodString;
|
|
83
88
|
overview: z.ZodString;
|
|
@@ -92,11 +97,11 @@ export declare const RunSynthesisSchema: z.ZodObject<{
|
|
|
92
97
|
low: "low";
|
|
93
98
|
}>;
|
|
94
99
|
noveltyStatus: z.ZodEnum<{
|
|
100
|
+
expected: "expected";
|
|
95
101
|
novel: "novel";
|
|
96
102
|
confirmed: "confirmed";
|
|
97
103
|
partially_confirmed: "partially_confirmed";
|
|
98
104
|
contradicted: "contradicted";
|
|
99
|
-
expected: "expected";
|
|
100
105
|
}>;
|
|
101
106
|
literatureInterpretation: z.ZodString;
|
|
102
107
|
references: z.ZodArray<z.ZodObject<{
|
|
@@ -150,3 +155,4 @@ export type RunSynthesis = z.infer<typeof RunSynthesisSchema>;
|
|
|
150
155
|
export type SynthesizedFinding = z.infer<typeof SynthesizedFindingSchema>;
|
|
151
156
|
export type BiologicalTheme = z.infer<typeof BiologicalThemeSchema>;
|
|
152
157
|
export type LiteratureReference = z.infer<typeof LiteratureReferenceSchema>;
|
|
158
|
+
export type KeyReference = z.infer<typeof KeyReferenceSchema>;
|
|
@@ -91,6 +91,11 @@ export const BiologicalThemeSchema = z.object({
|
|
|
91
91
|
.describe("Findings that contribute to this theme. Each entry must match an entry in findings[] by stepId + title."),
|
|
92
92
|
narrative: z.string().describe("How these findings connect into a coherent biological story — 3-5 sentences connecting them, not restating them."),
|
|
93
93
|
});
|
|
94
|
+
export const KeyReferenceSchema = z.object({
|
|
95
|
+
pmid: z.string().describe("Numeric PubMed ID. Must already appear in at least one finding's references[]."),
|
|
96
|
+
citation: z.string().describe('Short citation for the paper, e.g. "Smith 2020".'),
|
|
97
|
+
description: z.string().describe("What the paper establishes and why it is key to this run."),
|
|
98
|
+
});
|
|
94
99
|
export const RunSynthesisSchema = z.object({
|
|
95
100
|
runId: z.string().describe("The run being synthesized. Must equal the runId given in the prompt."),
|
|
96
101
|
overview: z
|
|
@@ -114,10 +119,6 @@ export const RunSynthesisSchema = z.object({
|
|
|
114
119
|
.array(z.string())
|
|
115
120
|
.describe("Concrete methodological and translational caveats — not generic disclaimers. " + "Each names what it affects and why it matters. Target 3-6."),
|
|
116
121
|
keyReferences: z
|
|
117
|
-
.array(
|
|
118
|
-
pmid: z.string().describe("Numeric PubMed ID. Must already appear in at least one finding's references[]."),
|
|
119
|
-
citation: z.string().describe('Short citation for the paper, e.g. "Smith 2020".'),
|
|
120
|
-
description: z.string().describe("What the paper establishes and why it is key to this run."),
|
|
121
|
-
}))
|
|
122
|
+
.array(KeyReferenceSchema)
|
|
122
123
|
.describe("The 5-10 most important papers across all findings. Prioritize those that directly validate, contradict, or extend the findings — do not pad with background citations."),
|
|
123
124
|
});
|
|
@@ -50,6 +50,7 @@ import { 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
54
|
const REPORT_TOOL_ACCESS_TTL_SECONDS = 3600;
|
|
54
55
|
const PREVIEW_META_FILE = "preview-meta.json";
|
|
55
56
|
async function readPreviewMeta(metaPath) {
|
|
@@ -435,6 +436,7 @@ export function createReportSubmitTool(deps) {
|
|
|
435
436
|
issues: parsed.error.issues.map((i) => ({
|
|
436
437
|
path: i.path.join(".") || "(root)",
|
|
437
438
|
message: i.message,
|
|
439
|
+
hint: hintForZodIssue(i, input.report),
|
|
438
440
|
})),
|
|
439
441
|
hint: "The `report` brief did not match the schema. Call `plan_report` for the full schema, fix the fields named in `issues`, and resubmit.",
|
|
440
442
|
});
|
|
@@ -41,6 +41,7 @@ import { hydratePlanSteps, PlannerPlanSchema } from "../../schemas/plan-schemas.
|
|
|
41
41
|
import { validatePlan } from "../../schemas/validate-plan.js";
|
|
42
42
|
import { AnalysisPlanSchema } from "../../schemas/workflow-state.js";
|
|
43
43
|
import { unwrapOrThrow } from "../../lib/result.js";
|
|
44
|
+
import { hintForZodIssue } from "../../lib/zod-issues.js";
|
|
44
45
|
import { insertPlan, loadDataProfileStatus, loadPlan } from "../../state/index.js";
|
|
45
46
|
// ── Tool-level config ──────────────────────────────────────────────
|
|
46
47
|
/** Sub-agent identity for the planner — provenance only. */
|
|
@@ -201,11 +202,12 @@ async function persistPlan(plan, ctx, pool) {
|
|
|
201
202
|
}
|
|
202
203
|
}
|
|
203
204
|
// ── Validation ──────────────────────────────────────────────────────
|
|
204
|
-
function zodIssuesToValidationIssues(error, rootPath = "plan") {
|
|
205
|
+
function zodIssuesToValidationIssues(error, input, rootPath = "plan") {
|
|
205
206
|
return error.issues.map((i) => ({
|
|
206
207
|
path: [rootPath, ...i.path.map((p) => String(p))].join("."),
|
|
207
208
|
code: "schema",
|
|
208
209
|
message: i.message,
|
|
210
|
+
hint: hintForZodIssue(i, input),
|
|
209
211
|
}));
|
|
210
212
|
}
|
|
211
213
|
/**
|
|
@@ -215,7 +217,7 @@ function zodIssuesToValidationIssues(error, rootPath = "plan") {
|
|
|
215
217
|
function fullyValidate(candidate, resourcePolicy) {
|
|
216
218
|
const parsed = PlannerPlanSchema.safeParse(candidate);
|
|
217
219
|
if (!parsed.success) {
|
|
218
|
-
return { valid: false, issues: zodIssuesToValidationIssues(parsed.error) };
|
|
220
|
+
return { valid: false, issues: zodIssuesToValidationIssues(parsed.error, candidate) };
|
|
219
221
|
}
|
|
220
222
|
// Semantic checks operate on the AnalysisPlan shape — PlannerPlan omits
|
|
221
223
|
// maxSteps, so inject the execution-time default to satisfy the validator.
|
package/package.json
CHANGED