@inflexa-ai/harness 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/conversation-agent.js +23 -16
- package/dist/agents/sandbox/cheminformatics-agent.js +1 -1
- package/dist/agents/sandbox/chromatin-agent.js +1 -1
- package/dist/agents/sandbox/dna-methylation-agent.js +1 -1
- package/dist/agents/sandbox/drug-repurposing-agent.js +5 -10
- package/dist/agents/sandbox/enrichment-agent.js +1 -1
- package/dist/agents/sandbox/genomic-variant-agent.js +1 -11
- package/dist/agents/sandbox/immune-profiling-agent.js +1 -1
- package/dist/agents/sandbox/multi-omics-integration-agent.js +1 -1
- package/dist/agents/sandbox/network-agent.js +1 -1
- package/dist/agents/sandbox/pkpd-clinical-response-agent.js +3 -6
- package/dist/agents/sandbox/scientific-executor.js +1 -1
- package/dist/agents/sandbox/shared.js +7 -13
- package/dist/agents/sandbox/translational-safety-agent.js +4 -7
- package/dist/agents/sandbox/types.d.ts +5 -5
- package/dist/agents/sandbox/types.js +4 -4
- package/dist/app/chat-turn.js +4 -1
- package/dist/app/message-assembly.d.ts +5 -2
- package/dist/app/message-assembly.js +7 -2
- package/dist/app/run-activity.d.ts +13 -0
- package/dist/app/run-activity.js +51 -0
- package/dist/contracts/target-dossier.d.ts +10 -10
- package/dist/execution/report-runner.js +1 -1
- package/dist/execution/run-event-parts.d.ts +44 -0
- package/dist/execution/run-event-parts.js +0 -0
- package/dist/execution/run-event-stream.d.ts +91 -0
- package/dist/execution/run-event-stream.js +212 -0
- package/dist/execution/run-synthesis.js +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +20 -1
- package/dist/input-sanitization.d.ts +14 -4
- package/dist/input-sanitization.js +27 -4
- package/dist/lib/storage.js +4 -1
- package/dist/loop/budget-exceeded.d.ts +18 -2
- package/dist/loop/budget-exceeded.js +49 -2
- package/dist/loop/run-agent.d.ts +22 -1
- package/dist/loop/run-agent.js +91 -25
- package/dist/loop/run-to-terminal.d.ts +22 -4
- package/dist/loop/run-to-terminal.js +20 -4
- package/dist/memory/ai-sdk-message-storage.d.ts +37 -4
- package/dist/memory/ai-sdk-message-storage.js +45 -4
- package/dist/memory/content-to-cortex.js +19 -3
- package/dist/memory/message-backfill.js +1 -1
- package/dist/memory/thread-history.js +34 -8
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +15 -6
- package/dist/prompts/literature-reviewer.d.ts +1 -1
- package/dist/prompts/literature-reviewer.js +16 -14
- package/dist/prompts/planner.js +15 -20
- package/dist/prompts/sandbox/drug-repurposing-agent.d.ts +1 -1
- package/dist/prompts/sandbox/drug-repurposing-agent.js +31 -22
- package/dist/prompts/sandbox/multi-omics-integration-agent.d.ts +1 -1
- package/dist/prompts/sandbox/multi-omics-integration-agent.js +9 -10
- package/dist/prompts/sandbox/pkpd-clinical-response-agent.d.ts +1 -1
- package/dist/prompts/sandbox/pkpd-clinical-response-agent.js +8 -7
- package/dist/prompts/sandbox/translational-safety-agent.d.ts +1 -1
- package/dist/prompts/sandbox/translational-safety-agent.js +20 -14
- package/dist/prompts/synthesis-agent.d.ts +1 -1
- package/dist/prompts/synthesis-agent.js +7 -7
- package/dist/providers/errors.d.ts +7 -0
- package/dist/providers/errors.js +88 -3
- package/dist/runtime/pools.d.ts +15 -1
- package/dist/runtime/pools.js +15 -1
- package/dist/sandbox/sandbox-step-translate.d.ts +15 -2
- package/dist/sandbox/sandbox-step-translate.js +17 -2
- package/dist/schemas/workflow-state.d.ts +2 -2
- package/dist/schemas/workflow-state.js +2 -2
- package/dist/state/data-profile.d.ts +53 -1
- package/dist/state/data-profile.js +66 -6
- package/dist/state/index.d.ts +3 -3
- package/dist/state/index.js +2 -2
- package/dist/state/init.js +37 -2
- package/dist/state/runs.d.ts +39 -0
- package/dist/state/runs.js +103 -0
- package/dist/tasks/data-profile-activity.d.ts +87 -0
- package/dist/tasks/data-profile-activity.js +56 -0
- package/dist/tasks/data-profile.js +66 -4
- package/dist/tools/bio/chembl.d.ts +1 -1
- package/dist/tools/bio/chembl.js +51 -54
- package/dist/tools/bio/comptox.js +35 -33
- package/dist/tools/bio/drug-gene-interactions.d.ts +76 -0
- package/dist/tools/bio/drug-gene-interactions.js +175 -0
- package/dist/tools/bio/gene-disease-evidence.d.ts +61 -0
- package/dist/tools/bio/gene-disease-evidence.js +187 -0
- package/dist/tools/bio/gene-preclinical-profile.d.ts +58 -0
- package/dist/tools/bio/gene-preclinical-profile.js +125 -0
- package/dist/tools/bio/index.d.ts +23 -16
- package/dist/tools/bio/index.js +29 -22
- package/dist/tools/bio/keys.d.ts +16 -9
- package/dist/tools/bio/keys.js +22 -13
- package/dist/tools/bio/lookup-annotation.d.ts +48 -0
- package/dist/tools/bio/lookup-annotation.js +173 -0
- package/dist/tools/bio/opentargets.d.ts +7 -11
- package/dist/tools/bio/opentargets.js +24 -35
- package/dist/tools/bio/pubchem.js +6 -4
- package/dist/tools/bio/pubmed.d.ts +15 -7
- package/dist/tools/bio/pubmed.js +79 -20
- package/dist/tools/bio/search-clinical-trials.d.ts +20 -1
- package/dist/tools/bio/search-clinical-trials.js +26 -5
- package/dist/tools/bio/search-faers.js +9 -3
- package/dist/tools/bio/search-gene.d.ts +0 -1
- package/dist/tools/bio/search-gene.js +4 -5
- package/dist/tools/bio/search-geo-datasets.js +10 -4
- package/dist/tools/bio/search-interactions.d.ts +8 -4
- package/dist/tools/bio/search-interactions.js +28 -12
- package/dist/tools/bio/target-safety.d.ts +59 -0
- package/dist/tools/bio/target-safety.js +191 -0
- package/dist/tools/execute-analysis.d.ts +1 -0
- package/dist/tools/execute-analysis.js +5 -5
- package/dist/tools/lib/dgidb-client.d.ts +49 -0
- package/dist/tools/lib/dgidb-client.js +168 -0
- package/dist/tools/lib/disgenet-client.d.ts +56 -0
- package/dist/tools/lib/disgenet-client.js +59 -0
- package/dist/tools/lib/drugbank-client.d.ts +83 -0
- package/dist/tools/lib/drugbank-client.js +95 -0
- package/dist/tools/lib/ensembl-client.d.ts +4 -1
- package/dist/tools/lib/ensembl-client.js +5 -3
- package/dist/tools/lib/gwas-catalog-client.d.ts +60 -0
- package/dist/tools/lib/gwas-catalog-client.js +136 -0
- package/dist/tools/lib/pharmgkb-client.d.ts +22 -0
- package/dist/tools/lib/pharmgkb-client.js +32 -0
- package/dist/tools/lib/pubmed-client.d.ts +57 -4
- package/dist/tools/lib/pubmed-client.js +57 -3
- package/dist/tools/lib/string-client.d.ts +25 -4
- package/dist/tools/lib/string-client.js +22 -5
- package/dist/tools/research/generate-plan.d.ts +16 -9
- package/dist/tools/research/generate-plan.js +405 -85
- package/dist/tools/research/inspect-data-profile.d.ts +31 -6
- package/dist/tools/research/inspect-data-profile.js +23 -7
- package/dist/tools/research/inspect-run.d.ts +33 -15
- package/dist/tools/research/inspect-run.js +155 -66
- package/dist/tools/research/literature-reviewer.js +7 -11
- package/dist/workflows/execute-analysis.d.ts +63 -0
- package/dist/workflows/execute-analysis.js +73 -2
- package/dist/workflows/sandbox-step.js +1 -11
- package/dist/workflows/target-assessment/decisions/index.d.ts +1 -1
- package/dist/workflows/target-assessment/phase4-assemble.d.ts +1 -1
- package/dist/workflows/target-assessment/steps/phase2-aggregate.d.ts +2 -2
- package/dist/workflows/target-assessment/steps/phase3-aggregate.d.ts +1 -1
- package/package.json +1 -1
- package/dist/tools/bio/check-safety-panel.d.ts +0 -35
- package/dist/tools/bio/check-safety-panel.js +0 -76
- package/dist/tools/bio/get-impc-ko-profile.d.ts +0 -9
- package/dist/tools/bio/get-impc-ko-profile.js +0 -23
- package/dist/tools/bio/lookup-go-term.d.ts +0 -31
- package/dist/tools/bio/lookup-go-term.js +0 -116
- package/dist/tools/bio/search-bgee-expression.d.ts +0 -10
- package/dist/tools/bio/search-bgee-expression.js +0 -30
- package/dist/tools/bio/search-clinvar.d.ts +0 -13
- package/dist/tools/bio/search-clinvar.js +0 -28
- package/dist/tools/bio/search-dgidb.d.ts +0 -53
- package/dist/tools/bio/search-dgidb.js +0 -213
- package/dist/tools/bio/search-disgenet.d.ts +0 -30
- package/dist/tools/bio/search-disgenet.js +0 -84
- package/dist/tools/bio/search-drugbank.d.ts +0 -38
- package/dist/tools/bio/search-drugbank.js +0 -109
- package/dist/tools/bio/search-gwas-catalog.d.ts +0 -33
- package/dist/tools/bio/search-gwas-catalog.js +0 -172
- package/dist/tools/bio/search-pathway.d.ts +0 -12
- package/dist/tools/bio/search-pathway.js +0 -32
- package/dist/tools/bio/search-pharmgkb.d.ts +0 -17
- package/dist/tools/bio/search-pharmgkb.js +0 -52
|
@@ -11,11 +11,10 @@
|
|
|
11
11
|
* database already holds, and it would lose fidelity at every hop.
|
|
12
12
|
*
|
|
13
13
|
* The tool drives an internal "planner" agent that communicates outcomes
|
|
14
|
-
* EXCLUSIVELY via
|
|
14
|
+
* EXCLUSIVELY via three terminal tool calls:
|
|
15
15
|
*
|
|
16
|
-
* -
|
|
17
|
-
*
|
|
18
|
-
* - submit_plan(plan) → terminal success: re-validates + persists
|
|
16
|
+
* - submit_plan(plan) → terminal success: re-validates + persists;
|
|
17
|
+
* rejected candidates return structured issues
|
|
19
18
|
* - request_clarification(…) → terminal: planner needs more context
|
|
20
19
|
* - report_blocker(reason) → terminal: no viable plan
|
|
21
20
|
*
|
|
@@ -28,7 +27,7 @@ import { ok } from "neverthrow";
|
|
|
28
27
|
import { z } from "zod";
|
|
29
28
|
import { formatAgentCatalog } from "../../agents/sandbox-catalog.js";
|
|
30
29
|
import { composeSystemPrompt } from "../../agents/system-prompt.js";
|
|
31
|
-
import { runToTerminal } from "../../loop/run-to-terminal.js";
|
|
30
|
+
import { DEFAULT_SALVAGE_ITERATIONS, runToTerminal } from "../../loop/run-to-terminal.js";
|
|
32
31
|
import { passthroughStep } from "../../loop/run-step.js";
|
|
33
32
|
import { forSubAgent, scopeResource } from "../../auth/types.js";
|
|
34
33
|
import { defineTool } from "../define-tool.js";
|
|
@@ -42,6 +41,7 @@ import { plannerPrompt } from "../../prompts/planner.js";
|
|
|
42
41
|
import { hydratePlanSteps, PlannerPlanSchema } from "../../schemas/plan-schemas.js";
|
|
43
42
|
import { validatePlan } from "../../schemas/validate-plan.js";
|
|
44
43
|
import { AnalysisPlanSchema } from "../../schemas/workflow-state.js";
|
|
44
|
+
import { createNoopLogger } from "../../lib/console-logger.js";
|
|
45
45
|
import { unwrapOrThrow } from "../../lib/result.js";
|
|
46
46
|
import { hintForZodIssue } from "../../lib/zod-issues.js";
|
|
47
47
|
import { insertPlan, loadDataProfileStatus, loadPlan } from "../../state/index.js";
|
|
@@ -49,12 +49,76 @@ import { insertPlan, loadDataProfileStatus, loadPlan } from "../../state/index.j
|
|
|
49
49
|
/** Sub-agent identity for the planner — provenance only. */
|
|
50
50
|
const PLANNER_AGENT_ID = "planner";
|
|
51
51
|
/**
|
|
52
|
-
* Budget for the planner's internal loop:
|
|
53
|
-
*
|
|
52
|
+
* Budget for the planner's internal loop: one draft/submit attempt plus
|
|
53
|
+
* correction retries and headroom.
|
|
54
54
|
*/
|
|
55
55
|
const PLANNER_MAX_ITERATIONS = 13;
|
|
56
56
|
/** Wall-clock guard for a single plan-generation invocation. */
|
|
57
57
|
const PLAN_TIMEOUT_MS = 600_000;
|
|
58
|
+
// ── Diagnostic bounds ───────────────────────────────────────────────
|
|
59
|
+
//
|
|
60
|
+
// This tool runs on `passthroughStep`: it writes no ledger row and owns no
|
|
61
|
+
// durable stream, so its log records are the ONLY account of an invocation that
|
|
62
|
+
// survives the turn. That makes them worth spending detail on — and makes every
|
|
63
|
+
// one of them a place an unbounded model-authored string could reach a log file.
|
|
64
|
+
// Each bound below is what keeps a record readable and a sink affordable when the
|
|
65
|
+
// planner is behaving at its worst, which is exactly when the record gets read.
|
|
66
|
+
/** Issues kept per rejection record. A plan bounces on a handful of distinct faults; the tail repeats. */
|
|
67
|
+
const MAX_LOGGED_ISSUES = 8;
|
|
68
|
+
/** Per-issue message budget — enough to identify the fault, not to reproduce the plan. */
|
|
69
|
+
const MAX_LOGGED_ISSUE_CHARS = 240;
|
|
70
|
+
/** Rejections kept on the finish record. Enough to show whether the planner converged or thrashed. */
|
|
71
|
+
const MAX_LOGGED_REJECTIONS = 6;
|
|
72
|
+
/** Excerpt of the planner's last words — the one artifact that explains a run ending on prose. */
|
|
73
|
+
const MAX_LOGGED_PROSE_CHARS = 800;
|
|
74
|
+
/** Tool-call trace length. The planner's whole budget is ~16 turns, so this rarely truncates. */
|
|
75
|
+
const MAX_LOGGED_TOOL_CALLS = 48;
|
|
76
|
+
/** Trim to `max`, marking the cut so a truncated value never reads as a complete one. */
|
|
77
|
+
function bounded(value, max) {
|
|
78
|
+
return value.length <= max ? value : `${value.slice(0, max)}…[+${value.length - max} chars]`;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Record key under which free-form MODEL-authored text rides — never a structural
|
|
82
|
+
* field, only prose a language model wrote.
|
|
83
|
+
*
|
|
84
|
+
* The segregation exists because these two things need different handling and are
|
|
85
|
+
* otherwise indistinguishable in a flat record. A planner's own words routinely
|
|
86
|
+
* quote the analysis: "atopic dermatitis skin biopsies; sample S7 is a QC outlier"
|
|
87
|
+
* is a real example. That is the user's data. It belongs in a local diagnostic
|
|
88
|
+
* file, where the user already holds it — and an embedder shipping records off the
|
|
89
|
+
* machine needs one rule to keep it there, not a list of field names that goes
|
|
90
|
+
* stale the moment a new field is added.
|
|
91
|
+
*
|
|
92
|
+
* The harness cannot enforce that rule: it does not own the sink. It owns the
|
|
93
|
+
* declaration, so a host has something to act on. The cli drops this key at its
|
|
94
|
+
* OTLP export boundary (`cli/src/lib/otel.ts`) while keeping it in the log file.
|
|
95
|
+
*
|
|
96
|
+
* Redaction at the logger root was the alternative and is wrong here: it applies
|
|
97
|
+
* to every stream, so it would blank the field in the local file too — deleting
|
|
98
|
+
* the one artifact that explains a planner which stopped on prose.
|
|
99
|
+
*/
|
|
100
|
+
const MODEL_AUTHORED_KEY = "modelAuthored";
|
|
101
|
+
/** Nest model-authored prose under {@link MODEL_AUTHORED_KEY}, or contribute nothing when there is none. */
|
|
102
|
+
function modelAuthored(fields) {
|
|
103
|
+
const present = Object.entries(fields).filter((entry) => entry[1] !== undefined && entry[1].length > 0);
|
|
104
|
+
return present.length === 0 ? {} : { [MODEL_AUTHORED_KEY]: Object.fromEntries(present) };
|
|
105
|
+
}
|
|
106
|
+
function toRejectionRecord(attempt, issues) {
|
|
107
|
+
const kept = issues.slice(0, MAX_LOGGED_ISSUES);
|
|
108
|
+
return {
|
|
109
|
+
attempt,
|
|
110
|
+
issueCount: issues.length,
|
|
111
|
+
codes: {
|
|
112
|
+
schema: issues.filter((i) => i.code === "schema").length,
|
|
113
|
+
semantic: issues.filter((i) => i.code === "semantic").length,
|
|
114
|
+
},
|
|
115
|
+
paths: kept.map((i) => i.path),
|
|
116
|
+
messages: kept.map((i) => bounded(i.message, MAX_LOGGED_ISSUE_CHARS)),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function createPlannerTrace() {
|
|
120
|
+
return { submitAttempts: 0, rejectedAttempts: 0, rejections: [], duplicateTerminalCalls: 0 };
|
|
121
|
+
}
|
|
58
122
|
// ── Prompt / catalog ────────────────────────────────────────────────
|
|
59
123
|
/** The planner system prompt is deterministic per policy — build it once per
|
|
60
124
|
* factory (the policy is a construction-time dep, fixed for the process). */
|
|
@@ -184,8 +248,13 @@ function renderDataContext(grounding) {
|
|
|
184
248
|
/**
|
|
185
249
|
* Persist a validated plan. Returns the planId, or an outcome-ready error
|
|
186
250
|
* with a sanitized message — DB-shape errors never leak to the planner.
|
|
251
|
+
*
|
|
252
|
+
* The sanitization is for the model, not for the operator: "plan could not be
|
|
253
|
+
* saved, please try again" is all the planner may see, and it is useless to
|
|
254
|
+
* whoever has to fix the cause. The record here is the only place the real
|
|
255
|
+
* failure survives, so it is written before the message is flattened.
|
|
187
256
|
*/
|
|
188
|
-
async function persistPlan(plan, ctx, pool) {
|
|
257
|
+
async function persistPlan(plan, ctx, pool, logger) {
|
|
189
258
|
try {
|
|
190
259
|
const planId = unwrapOrThrow(await insertPlan(pool, {
|
|
191
260
|
analysisId: ctx.analysisId,
|
|
@@ -197,6 +266,12 @@ async function persistPlan(plan, ctx, pool) {
|
|
|
197
266
|
catch (err) {
|
|
198
267
|
const raw = err instanceof Error ? err.message : String(err);
|
|
199
268
|
const isParentValidation = /^parent plan .* (?:not found|belongs to a different analysis)$/i.test(raw);
|
|
269
|
+
logger.error("plan persistence failed", {
|
|
270
|
+
parentPlanId: ctx.parentPlanId,
|
|
271
|
+
stepCount: plan.steps.length,
|
|
272
|
+
classified: isParentValidation ? "invalid_parent_plan" : "write_failed",
|
|
273
|
+
...logger.errorFields(err),
|
|
274
|
+
});
|
|
200
275
|
return {
|
|
201
276
|
ok: false,
|
|
202
277
|
message: isParentValidation ? "parentPlanId is not a valid plan for this analysis" : "plan could not be saved, please try again",
|
|
@@ -251,33 +326,7 @@ function fullyValidate(candidate, resourcePolicy) {
|
|
|
251
326
|
* shared `holder` so the outer `execute` reads the terminal outcome after the
|
|
252
327
|
* loop finishes.
|
|
253
328
|
*/
|
|
254
|
-
function buildInnerTools(holder, persistCtx, pool, resourcePolicy,
|
|
255
|
-
// The two environment stores travel together as one bag rather than as a pair
|
|
256
|
-
// of adjacent optional strings, which would be silently swappable at the call site.
|
|
257
|
-
stores) {
|
|
258
|
-
const validatePlanTool = defineTool({
|
|
259
|
-
id: "validate_plan",
|
|
260
|
-
description: "Dry-run a candidate plan and get back everything that is wrong with it. " +
|
|
261
|
-
"Takes the plan in ANY shape: a malformed, partial, or wrong-typed " +
|
|
262
|
-
"candidate is REPORTED, not rejected. Returns {valid, issues[]} covering " +
|
|
263
|
-
"both schema problems (missing / wrong-typed fields) and the semantic " +
|
|
264
|
-
"checks a schema cannot express (unknown agent IDs, DAG cycles, dangling " +
|
|
265
|
-
"depends_on references, duplicate step IDs, resource ceilings). The " +
|
|
266
|
-
"authoritative field-by-field plan schema is the arg schema of " +
|
|
267
|
-
"submit_plan — this tool deliberately does not restate it. Non-terminal: " +
|
|
268
|
-
"call as often as needed to iterate toward a clean plan, then submit_plan.",
|
|
269
|
-
// Deliberately permissive: a structurally-invalid candidate must reach
|
|
270
|
-
// `execute` so the model gets a structured {valid:false, issues} result —
|
|
271
|
-
// including semantic issues — instead of a bare Zod rejection at the loop's
|
|
272
|
-
// input boundary. `execute` re-parses against PlannerPlanSchema itself.
|
|
273
|
-
inputSchema: z.object({
|
|
274
|
-
plan: z.unknown().describe("The candidate plan, in any shape. Field-by-field schema: see submit_plan."),
|
|
275
|
-
}),
|
|
276
|
-
execute: async (input) => {
|
|
277
|
-
const result = fullyValidate(input.plan, resourcePolicy);
|
|
278
|
-
return ok(result.valid ? { valid: true, issues: [] } : { valid: false, issues: result.issues });
|
|
279
|
-
},
|
|
280
|
-
});
|
|
329
|
+
function buildInnerTools(holder, trace, persistCtx, pool, resourcePolicy, logger) {
|
|
281
330
|
const submitPlanTool = defineTool({
|
|
282
331
|
id: "submit_plan",
|
|
283
332
|
description: "Submit the final plan for persistence. Re-validates the plan; on " +
|
|
@@ -285,11 +334,23 @@ stores) {
|
|
|
285
334
|
"rejection returns {accepted: false, issues} — fix and call again, " +
|
|
286
335
|
"or switch to report_blocker if the plan cannot be made valid. This " +
|
|
287
336
|
"arg schema is the authoritative plan contract.",
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
337
|
+
// Permissive on purpose: every candidate reaches `execute`, where the
|
|
338
|
+
// model gets structured schema and semantic issues in one response.
|
|
339
|
+
// The field-by-field contract remains visible in this tool's description
|
|
340
|
+
// and the validation below is authoritative.
|
|
341
|
+
inputSchema: z.object({
|
|
342
|
+
// Keep the authoritative shape visible to the model while the
|
|
343
|
+
// permissive branch lets malformed candidates reach execute for
|
|
344
|
+
// structured issue reporting.
|
|
345
|
+
plan: z.union([PlannerPlanSchema, z.unknown()]).describe("Candidate plan; malformed values are reported in issues."),
|
|
346
|
+
}),
|
|
291
347
|
execute: async (input) => {
|
|
292
348
|
if (holder.outcome !== null) {
|
|
349
|
+
trace.duplicateTerminalCalls++;
|
|
350
|
+
logger.warn("submit_plan called after a terminal outcome was recorded", {
|
|
351
|
+
recordedOutcome: holder.outcome.kind,
|
|
352
|
+
duplicateTerminalCalls: trace.duplicateTerminalCalls,
|
|
353
|
+
});
|
|
293
354
|
return ok({
|
|
294
355
|
accepted: false,
|
|
295
356
|
issues: [
|
|
@@ -301,11 +362,21 @@ stores) {
|
|
|
301
362
|
],
|
|
302
363
|
});
|
|
303
364
|
}
|
|
365
|
+
const attempt = ++trace.submitAttempts;
|
|
304
366
|
const result = fullyValidate(input.plan, resourcePolicy);
|
|
305
367
|
if (!result.valid) {
|
|
368
|
+
trace.rejectedAttempts++;
|
|
369
|
+
const rejection = toRejectionRecord(attempt, result.issues);
|
|
370
|
+
if (trace.rejections.length < MAX_LOGGED_REJECTIONS)
|
|
371
|
+
trace.rejections.push(rejection);
|
|
372
|
+
// `warn`, not `debug`: a rejection is non-terminal by design, so the model
|
|
373
|
+
// reads the issues and the invocation carries on — nothing else reports that
|
|
374
|
+
// an attempt was spent. A run that exhausts its budget on rejections ends
|
|
375
|
+
// looking identical to one that never tried, and this is the difference.
|
|
376
|
+
logger.warn("submit_plan rejected a plan", { ...rejection });
|
|
306
377
|
return ok({ accepted: false, issues: result.issues });
|
|
307
378
|
}
|
|
308
|
-
const persisted = await persistPlan(result.plan, persistCtx, pool);
|
|
379
|
+
const persisted = await persistPlan(result.plan, persistCtx, pool, logger);
|
|
309
380
|
if (!persisted.ok) {
|
|
310
381
|
holder.outcome = { kind: "persist_error", message: persisted.message };
|
|
311
382
|
return ok({
|
|
@@ -325,6 +396,14 @@ stores) {
|
|
|
325
396
|
planId: persisted.planId,
|
|
326
397
|
plan: result.plan,
|
|
327
398
|
};
|
|
399
|
+
// The attempt count is the cost of this plan, and it is only knowable here.
|
|
400
|
+
// A plan accepted on attempt 5 is a success that nearly was not one.
|
|
401
|
+
logger.info("submit_plan accepted a plan", {
|
|
402
|
+
planId: persisted.planId,
|
|
403
|
+
attempt,
|
|
404
|
+
stepCount: result.plan.steps.length,
|
|
405
|
+
agents: [...new Set(result.plan.steps.map((s) => s.agent))],
|
|
406
|
+
});
|
|
328
407
|
return ok({ accepted: true, planId: persisted.planId });
|
|
329
408
|
},
|
|
330
409
|
});
|
|
@@ -338,80 +417,223 @@ stores) {
|
|
|
338
417
|
questionContext: z.string().optional(),
|
|
339
418
|
}),
|
|
340
419
|
execute: async (input) => {
|
|
341
|
-
if (holder.outcome
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
};
|
|
420
|
+
if (holder.outcome !== null) {
|
|
421
|
+
trace.duplicateTerminalCalls++;
|
|
422
|
+
logger.warn("request_clarification called after a terminal outcome was recorded", {
|
|
423
|
+
recordedOutcome: holder.outcome.kind,
|
|
424
|
+
duplicateTerminalCalls: trace.duplicateTerminalCalls,
|
|
425
|
+
});
|
|
426
|
+
return ok({ recorded: true });
|
|
347
427
|
}
|
|
428
|
+
holder.outcome = {
|
|
429
|
+
kind: "clarification",
|
|
430
|
+
question: input.question,
|
|
431
|
+
questionContext: input.questionContext,
|
|
432
|
+
};
|
|
433
|
+
// The question is the answer to "why did planning stop?" — and it reaches the
|
|
434
|
+
// user through the conversation agent, which may paraphrase it into something
|
|
435
|
+
// that no longer identifies the missing fact.
|
|
436
|
+
logger.info("planner requested clarification", {
|
|
437
|
+
submitAttempts: trace.submitAttempts,
|
|
438
|
+
...modelAuthored({ clarificationQuestion: bounded(input.question, MAX_LOGGED_PROSE_CHARS) }),
|
|
439
|
+
});
|
|
348
440
|
return ok({ recorded: true });
|
|
349
441
|
},
|
|
350
442
|
});
|
|
351
443
|
const reportBlockerTool = createReportBlockerToolFor({
|
|
352
444
|
record: (outcome) => {
|
|
353
|
-
if (holder.outcome
|
|
354
|
-
|
|
445
|
+
if (holder.outcome !== null) {
|
|
446
|
+
trace.duplicateTerminalCalls++;
|
|
447
|
+
logger.warn("report_blocker called after a terminal outcome was recorded", {
|
|
448
|
+
recordedOutcome: holder.outcome.kind,
|
|
449
|
+
duplicateTerminalCalls: trace.duplicateTerminalCalls,
|
|
450
|
+
});
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
holder.outcome = outcome;
|
|
454
|
+
// A blocker after several rejected submits is a planner giving up on a plan it
|
|
455
|
+
// could not make valid — a validation problem wearing a blocker's clothes. The
|
|
456
|
+
// attempt count is what tells those apart, and only this record carries both.
|
|
457
|
+
logger.warn("planner reported a blocker", {
|
|
458
|
+
submitAttempts: trace.submitAttempts,
|
|
459
|
+
...modelAuthored({ blockerReason: bounded(outcome.reason, MAX_LOGGED_PROSE_CHARS) }),
|
|
460
|
+
});
|
|
355
461
|
},
|
|
356
462
|
blockedWhen: "Ends plan generation with no plan saved. Use it when no valid plan can " +
|
|
357
463
|
"be produced for this data and research question (out of scope, data " +
|
|
358
464
|
"incompatible with every available agent, etc.) — not for a plan you " +
|
|
359
465
|
"could fix and submit.",
|
|
360
466
|
});
|
|
361
|
-
// Environment discovery is non-terminal and read-only: it lets the planner check what
|
|
362
|
-
// reference data this environment holds, and what a step will actually be able to
|
|
363
|
-
// import, before committing a step to needing either. Both read host-side, so no
|
|
364
|
-
// sandbox exists or is needed at plan time — which is the whole point, since a plan
|
|
365
|
-
// that assumes an absent package fails only once the run reaches that step.
|
|
366
|
-
const listAvailableRefs = createListAvailableRefsTool(stores.refStorePath === undefined ? {} : { refStorePath: stores.refStorePath });
|
|
367
|
-
const listAvailablePackages = createListAvailablePackagesTool(stores.packagesFile === undefined ? {} : { packagesFile: stores.packagesFile });
|
|
368
467
|
const terminal = [submitPlanTool, requestClarificationTool, reportBlockerTool];
|
|
369
|
-
return {
|
|
468
|
+
return { terminal };
|
|
370
469
|
}
|
|
470
|
+
/**
|
|
471
|
+
* Render one environment inventory into the seed, reporting a lookup that failed.
|
|
472
|
+
*
|
|
473
|
+
* A failed lookup is written INTO the prompt as prose the planner then plans
|
|
474
|
+
* around — a silent degradation of its grounding that no caller is told about and
|
|
475
|
+
* no outcome distinguishes. The record is what makes "the planner produced a plan
|
|
476
|
+
* naming packages this install does not have" traceable to its cause.
|
|
477
|
+
*/
|
|
478
|
+
function inventoryContent(label, result, logger) {
|
|
479
|
+
if (result.isErr()) {
|
|
480
|
+
logger.warn("planner grounding inventory lookup failed", { inventory: label, error: result.error.error });
|
|
481
|
+
return `## ${label}\n\nInventory lookup failed: ${result.error.error}`;
|
|
482
|
+
}
|
|
483
|
+
const value = result.value;
|
|
484
|
+
if (typeof value === "object" && value !== null && "content" in value && typeof value.content === "string") {
|
|
485
|
+
return `## ${label}\n\n${value.content}`;
|
|
486
|
+
}
|
|
487
|
+
return `## ${label}\n\n${JSON.stringify(value)}`;
|
|
488
|
+
}
|
|
489
|
+
// ── Transcript reading (diagnostics only) ───────────────────────────
|
|
490
|
+
/**
|
|
491
|
+
* The planner's own account of the run, read off the message array `runToTerminal`
|
|
492
|
+
* returns. Nothing else can produce it: the transcript is ephemeral — a sub-agent
|
|
493
|
+
* loop on `passthroughStep` persists no messages anywhere — so it is read here or
|
|
494
|
+
* it is lost with the turn.
|
|
495
|
+
*
|
|
496
|
+
* `toolCalls` in order is what shows the SHAPE of a failure at a glance:
|
|
497
|
+
* `[submit_plan × 13]` is a planner thrashing on validation, `[]` is a planner
|
|
498
|
+
* that never called anything, and a trailing `report_blocker` on a run that ended
|
|
499
|
+
* with no outcome means the terminal call was cut off before it executed.
|
|
500
|
+
*/
|
|
501
|
+
function readTranscript(messages) {
|
|
502
|
+
const toolCalls = [];
|
|
503
|
+
let finalProse = "";
|
|
504
|
+
for (const message of messages) {
|
|
505
|
+
if (message.role !== "assistant" || typeof message.content === "string") {
|
|
506
|
+
if (message.role === "assistant" && typeof message.content === "string" && message.content.length > 0)
|
|
507
|
+
finalProse = message.content;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
for (const part of message.content) {
|
|
511
|
+
if (part.type === "tool-call")
|
|
512
|
+
toolCalls.push(part.toolName);
|
|
513
|
+
// Last one wins: the closing text of the run is what explains a run that
|
|
514
|
+
// stopped talking instead of calling its terminal tool.
|
|
515
|
+
else if (part.type === "text" && part.text.trim().length > 0)
|
|
516
|
+
finalProse = part.text;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return {
|
|
520
|
+
toolCalls: toolCalls.slice(0, MAX_LOGGED_TOOL_CALLS),
|
|
521
|
+
truncatedToolCalls: toolCalls.length > MAX_LOGGED_TOOL_CALLS,
|
|
522
|
+
finalProse: bounded(finalProse.trim(), MAX_LOGGED_PROSE_CHARS),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* The loop half of the finish record: how the planner's run ended, and what it
|
|
527
|
+
* did to get there.
|
|
528
|
+
*
|
|
529
|
+
* Built from the `runToTerminal` result that the tool previously discarded
|
|
530
|
+
* entirely. Without it, `no_outcome` is a single word covering three unrelated
|
|
531
|
+
* failures — a budget spent on rejected submits (`max_iterations`), a planner that
|
|
532
|
+
* answered in prose (`stop`), and a reply cut off at the output-token limit
|
|
533
|
+
* (`length`) — which is why three identical-looking retries taught nobody anything.
|
|
534
|
+
*/
|
|
535
|
+
function loopFields(run, kind) {
|
|
536
|
+
const transcript = readTranscript(run.messages);
|
|
537
|
+
return {
|
|
538
|
+
loop: {
|
|
539
|
+
finishReason: run.finish.reason,
|
|
540
|
+
cappedOut: run.finish.cappedOut,
|
|
541
|
+
truncationRecoveries: run.finish.truncationRecoveries,
|
|
542
|
+
salvaged: run.salvage !== null,
|
|
543
|
+
...(run.salvage ? { firstFinishReason: run.salvage.firstFinish.reason, salvageFinishReason: run.salvage.finish.reason } : {}),
|
|
544
|
+
toolCalls: transcript.toolCalls,
|
|
545
|
+
...(transcript.truncatedToolCalls ? { toolCallsTruncated: true } : {}),
|
|
546
|
+
},
|
|
547
|
+
...(PROSE_EXPLAINS_OUTCOME.has(kind) ? modelAuthored({ plannerFinalProse: transcript.finalProse }) : {}),
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* The endings whose only account is what the planner said last.
|
|
552
|
+
*
|
|
553
|
+
* Everywhere else something better already explains the outcome: a terminal tool
|
|
554
|
+
* recorded its own argument, or a thrown cause carries the fault. Logging the
|
|
555
|
+
* prose there would put model output describing the user's data into a record
|
|
556
|
+
* that had no use for it — including on every successful plan, where the closing
|
|
557
|
+
* text is the planner narrating work that demonstrably went fine.
|
|
558
|
+
*/
|
|
559
|
+
const PROSE_EXPLAINS_OUTCOME = new Set(["no_outcome", "loop_error", "timeout"]);
|
|
371
560
|
/**
|
|
372
561
|
* Translate the captured `PlannerOutcome` (plus any loop error) into the
|
|
373
|
-
* `PlanningAgentOutput` contract the conversation agent consumes
|
|
562
|
+
* `PlanningAgentOutput` contract the conversation agent consumes, alongside the
|
|
563
|
+
* kind the diagnostic record carries.
|
|
564
|
+
*
|
|
565
|
+
* Both come out of one branch deliberately: deriving the kind separately would
|
|
566
|
+
* be a second copy of this precedence order, free to drift from the answer the
|
|
567
|
+
* caller actually returned.
|
|
374
568
|
*/
|
|
375
569
|
function shapeOutcome(args) {
|
|
376
570
|
const { holder, runError, timedOut, outerAborted } = args;
|
|
377
571
|
const outcome = holder.outcome;
|
|
378
572
|
if (outcome?.kind === "plan_submitted") {
|
|
379
|
-
return { event: "plan_complete", planId: outcome.planId, plan: outcome.plan };
|
|
573
|
+
return { output: { event: "plan_complete", planId: outcome.planId, plan: outcome.plan }, kind: "plan_submitted" };
|
|
380
574
|
}
|
|
381
575
|
if (outcome?.kind === "clarification") {
|
|
382
576
|
return {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
577
|
+
output: {
|
|
578
|
+
event: "clarification_needed",
|
|
579
|
+
question: outcome.question,
|
|
580
|
+
...(outcome.questionContext ? { questionContext: outcome.questionContext } : {}),
|
|
581
|
+
},
|
|
582
|
+
kind: "clarification",
|
|
386
583
|
};
|
|
387
584
|
}
|
|
388
585
|
if (outcome?.kind === "blocker") {
|
|
389
|
-
return { event: "error", error: outcome.reason };
|
|
586
|
+
return { output: { event: "error", error: outcome.reason }, kind: "blocker" };
|
|
390
587
|
}
|
|
391
588
|
if (outcome?.kind === "persist_error") {
|
|
392
|
-
return { event: "error", error: `Failed to save plan: ${outcome.message}` };
|
|
589
|
+
return { output: { event: "error", error: `Failed to save plan: ${outcome.message}` }, kind: "persist_error" };
|
|
393
590
|
}
|
|
394
591
|
// No terminal outcome — something went wrong in the loop.
|
|
395
592
|
if (timedOut) {
|
|
396
593
|
return {
|
|
397
|
-
|
|
398
|
-
|
|
594
|
+
output: {
|
|
595
|
+
event: "error",
|
|
596
|
+
error: "Plan generation timed out — the model may be overloaded.",
|
|
597
|
+
},
|
|
598
|
+
kind: "timeout",
|
|
399
599
|
};
|
|
400
600
|
}
|
|
401
601
|
if (outerAborted) {
|
|
402
|
-
return { event: "error", error: "Plan generation was cancelled." };
|
|
602
|
+
return { output: { event: "error", error: "Plan generation was cancelled." }, kind: "cancelled" };
|
|
403
603
|
}
|
|
404
604
|
if (runError) {
|
|
405
605
|
const msg = runError instanceof Error ? runError.message : String(runError);
|
|
406
|
-
return { event: "error", error: `Plan generation failed: ${msg}` };
|
|
606
|
+
return { output: { event: "error", error: `Plan generation failed: ${msg}` }, kind: "loop_error" };
|
|
407
607
|
}
|
|
408
608
|
return {
|
|
409
|
-
|
|
410
|
-
|
|
609
|
+
output: {
|
|
610
|
+
event: "error",
|
|
611
|
+
error: "Plan generation completed without a terminal outcome — the planner " + "did not call submit_plan, request_clarification, or report_blocker.",
|
|
612
|
+
},
|
|
613
|
+
kind: "no_outcome",
|
|
411
614
|
};
|
|
412
615
|
}
|
|
616
|
+
/**
|
|
617
|
+
* Severity follows the ending, not the return type — which is uniformly `ok(...)`,
|
|
618
|
+
* so it distinguishes nothing. A submitted plan and a clarification request are
|
|
619
|
+
* both this tool working as designed; a blocker is a real answer that cost the
|
|
620
|
+
* user their plan; the rest are failures.
|
|
621
|
+
*/
|
|
622
|
+
const OUTCOME_LEVEL = {
|
|
623
|
+
plan_submitted: "info",
|
|
624
|
+
clarification: "info",
|
|
625
|
+
blocker: "warn",
|
|
626
|
+
persist_error: "error",
|
|
627
|
+
timeout: "error",
|
|
628
|
+
cancelled: "error",
|
|
629
|
+
loop_error: "error",
|
|
630
|
+
no_outcome: "error",
|
|
631
|
+
invalid_parent_plan: "warn",
|
|
632
|
+
parent_plan_load_failed: "error",
|
|
633
|
+
};
|
|
413
634
|
/** Build the `generate_plan` tool bound to its provider and pool. */
|
|
414
635
|
export function createGeneratePlanTool(deps) {
|
|
636
|
+
const baseLogger = (deps.logger ?? createNoopLogger()).named("generate-plan");
|
|
415
637
|
return defineTool({
|
|
416
638
|
id: "generate_plan",
|
|
417
639
|
description: "Generate an analysis plan (DAG of steps) for this analysis's data and the user's research question. " +
|
|
@@ -450,6 +672,32 @@ export function createGeneratePlanTool(deps) {
|
|
|
450
672
|
}),
|
|
451
673
|
execute: async (input, ctx) => {
|
|
452
674
|
const analysisId = scopeResource(ctx.session.scope).resourceId;
|
|
675
|
+
// Bound per invocation: `analysisId` comes from the call-time session, not from
|
|
676
|
+
// the construction-time deps the factory closes over.
|
|
677
|
+
const logger = baseLogger.with({ analysisId });
|
|
678
|
+
const startedAt = Date.now();
|
|
679
|
+
const trace = createPlannerTrace();
|
|
680
|
+
// Every exit routes through here, so "recorded exactly once per invocation" holds
|
|
681
|
+
// by construction rather than by remembering. `elapsedMs` is what separates a
|
|
682
|
+
// planner that gave up early from one still working when the guard cut it — the
|
|
683
|
+
// fixed budget above makes the number readable without any other context.
|
|
684
|
+
//
|
|
685
|
+
// `extra` carries the loop account on the paths that reached the loop. The two
|
|
686
|
+
// parent-plan early returns never do, and a record claiming `loop.finishReason`
|
|
687
|
+
// for a run that never started would be worse than one that omits the field.
|
|
688
|
+
const finish = (shaped, extra = {}) => {
|
|
689
|
+
logger[OUTCOME_LEVEL[shaped.kind]]("plan generation finished", {
|
|
690
|
+
outcome: shaped.kind,
|
|
691
|
+
elapsedMs: Date.now() - startedAt,
|
|
692
|
+
submitAttempts: trace.submitAttempts,
|
|
693
|
+
rejectedAttempts: trace.rejectedAttempts,
|
|
694
|
+
...(trace.rejections.length > 0 ? { rejections: trace.rejections } : {}),
|
|
695
|
+
...(trace.rejectedAttempts > trace.rejections.length ? { rejectionsTruncated: true } : {}),
|
|
696
|
+
...(trace.duplicateTerminalCalls > 0 ? { duplicateTerminalCalls: trace.duplicateTerminalCalls } : {}),
|
|
697
|
+
...extra,
|
|
698
|
+
});
|
|
699
|
+
return ok(shaped.output);
|
|
700
|
+
};
|
|
453
701
|
// If iterating, load the parent plan so the planner sees what it is
|
|
454
702
|
// revising. Fails fast with the sanitized message submit_plan would
|
|
455
703
|
// have surfaced — saves a wasted planner run.
|
|
@@ -460,17 +708,24 @@ export function createGeneratePlanTool(deps) {
|
|
|
460
708
|
analysisId,
|
|
461
709
|
}));
|
|
462
710
|
if (!priorPlan) {
|
|
463
|
-
return
|
|
464
|
-
event: "error",
|
|
465
|
-
|
|
711
|
+
return finish({
|
|
712
|
+
output: { event: "error", error: "parentPlanId is not a valid plan for this analysis" },
|
|
713
|
+
kind: "invalid_parent_plan",
|
|
466
714
|
});
|
|
467
715
|
}
|
|
468
716
|
priorPlanBlock = formatPriorPlan(input.parentPlanId, priorPlan);
|
|
717
|
+
// `formatPriorPlan` returns null on a stored plan that no longer parses as an
|
|
718
|
+
// `AnalysisPlan`. Iteration then silently becomes generation-from-scratch, and
|
|
719
|
+
// the user gets a plan that ignored everything they asked to keep.
|
|
720
|
+
if (priorPlanBlock === null) {
|
|
721
|
+
logger.warn("prior plan did not parse — iterating without it", { parentPlanId: input.parentPlanId });
|
|
722
|
+
}
|
|
469
723
|
}
|
|
470
|
-
catch {
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
error: "Plan iteration failed — parent plan could not be loaded.",
|
|
724
|
+
catch (err) {
|
|
725
|
+
logger.error("parent plan could not be loaded", { parentPlanId: input.parentPlanId, ...logger.errorFields(err) });
|
|
726
|
+
return finish({
|
|
727
|
+
output: { event: "error", error: "Plan iteration failed — parent plan could not be loaded." },
|
|
728
|
+
kind: "parent_plan_load_failed",
|
|
474
729
|
});
|
|
475
730
|
}
|
|
476
731
|
}
|
|
@@ -482,42 +737,100 @@ export function createGeneratePlanTool(deps) {
|
|
|
482
737
|
// without dataset facts is a real, supported state (see `renderDataContext`),
|
|
483
738
|
// so a ledger read that fails must cost the planner its grounding — never
|
|
484
739
|
// the user their plan.
|
|
485
|
-
const profileStatus = await loadDataProfileStatus(deps.pool, analysisId).
|
|
486
|
-
|
|
740
|
+
const profileStatus = await loadDataProfileStatus(deps.pool, analysisId).match((status) => status, (error) => {
|
|
741
|
+
// Degrading to "no profile" is correct behaviour and indistinguishable, from
|
|
742
|
+
// the outcome, from an analysis that genuinely has none — so the read failure
|
|
743
|
+
// is reported here or it is never known to have happened.
|
|
744
|
+
logger.warn("data profile read failed — planning without dataset facts", {
|
|
745
|
+
dbErrorType: error.type,
|
|
746
|
+
...("op" in error ? { dbOp: error.op } : {}),
|
|
747
|
+
...("cause" in error ? logger.errorFields(error.cause) : {}),
|
|
748
|
+
});
|
|
749
|
+
return null;
|
|
750
|
+
});
|
|
751
|
+
const grounding = classifyGrounding(profileStatus);
|
|
752
|
+
const dataContextBlock = renderDataContext(grounding);
|
|
753
|
+
// Reference data and package availability are mandatory grounding,
|
|
754
|
+
// not optional planner lookups. Read both inventories host-side before
|
|
755
|
+
// the loop and put their rendered content in the planner's seed.
|
|
756
|
+
const listAvailableRefs = createListAvailableRefsTool(deps.refStorePath === undefined ? {} : { refStorePath: deps.refStorePath });
|
|
757
|
+
const listAvailablePackages = createListAvailablePackagesTool(deps.packagesFile === undefined ? {} : { packagesFile: deps.packagesFile });
|
|
758
|
+
// A path-separator query matches every reference path and selects the
|
|
759
|
+
// tool's recursive leaf scan, so the seed contains usable file paths,
|
|
760
|
+
// not only top-level directory summaries.
|
|
761
|
+
const [refsResult, packagesResult] = await Promise.all([listAvailableRefs.execute({ query: "/" }, ctx), listAvailablePackages.execute({}, ctx)]);
|
|
762
|
+
const refsBlock = inventoryContent("Available Reference Data", refsResult, logger);
|
|
763
|
+
const packagesBlock = inventoryContent("Available Packages", packagesResult, logger);
|
|
764
|
+
const groundingBlock = [refsBlock, packagesBlock].join("\n\n");
|
|
487
765
|
const prompt = [
|
|
488
766
|
...(priorPlanBlock ? [priorPlanBlock, ""] : []),
|
|
489
767
|
...(dataContextBlock ? [dataContextBlock, ""] : []),
|
|
768
|
+
groundingBlock,
|
|
769
|
+
"",
|
|
490
770
|
"## Research Question",
|
|
491
771
|
input.researchQuestion,
|
|
492
772
|
...(input.analystNotes ? ["", "## Analyst Notes (from the user — facts about the data the profile does not record)", input.analystNotes] : []),
|
|
493
773
|
...(input.priorRuns ? ["", "## Prior Run Results", input.priorRuns] : []),
|
|
494
774
|
...(input.userConstraints ? ["", "## User Constraints", input.userConstraints] : []),
|
|
495
775
|
].join("\n");
|
|
776
|
+
// Opens the invocation. Two things are only knowable here and both are
|
|
777
|
+
// first-order suspects when a planner runs long and returns nothing: the size
|
|
778
|
+
// of the seed it was handed, and which of its blocks made it that size. The
|
|
779
|
+
// reference inventory in particular is a recursive leaf scan of whatever this
|
|
780
|
+
// install has staged — a quantity the harness does not control and no other
|
|
781
|
+
// record reports. The census is sizes only: the seed carries the user's
|
|
782
|
+
// research question and dataset facts, which belong in the model's context and
|
|
783
|
+
// not in a log file.
|
|
784
|
+
logger.info("plan generation started", {
|
|
785
|
+
model: deps.conversation.model,
|
|
786
|
+
maxIterations: PLANNER_MAX_ITERATIONS,
|
|
787
|
+
salvageIterations: DEFAULT_SALVAGE_ITERATIONS,
|
|
788
|
+
timeoutMs: PLAN_TIMEOUT_MS,
|
|
789
|
+
grounding: grounding.kind,
|
|
790
|
+
...(input.parentPlanId ? { parentPlanId: input.parentPlanId } : {}),
|
|
791
|
+
seedChars: {
|
|
792
|
+
total: prompt.length,
|
|
793
|
+
priorPlan: priorPlanBlock?.length ?? 0,
|
|
794
|
+
dataContext: dataContextBlock.length,
|
|
795
|
+
referenceData: refsBlock.length,
|
|
796
|
+
packages: packagesBlock.length,
|
|
797
|
+
researchQuestion: input.researchQuestion.length,
|
|
798
|
+
analystNotes: input.analystNotes?.length ?? 0,
|
|
799
|
+
priorRuns: input.priorRuns?.length ?? 0,
|
|
800
|
+
userConstraints: input.userConstraints?.length ?? 0,
|
|
801
|
+
},
|
|
802
|
+
});
|
|
496
803
|
const holder = { outcome: null };
|
|
497
804
|
const persistCtx = {
|
|
498
805
|
analysisId,
|
|
499
806
|
parentPlanId: input.parentPlanId ?? null,
|
|
500
807
|
};
|
|
501
|
-
const innerTools = buildInnerTools(holder, persistCtx, deps.pool, deps.resourcePolicy,
|
|
808
|
+
const innerTools = buildInnerTools(holder, trace, persistCtx, deps.pool, deps.resourcePolicy, logger);
|
|
502
809
|
const planner = {
|
|
503
810
|
id: PLANNER_AGENT_ID,
|
|
504
811
|
systemPrompt: composeSystemPrompt(plannerInstructions(deps.resourcePolicy)),
|
|
505
|
-
model: deps.model,
|
|
506
|
-
tools: innerTools.
|
|
812
|
+
model: deps.conversation.model,
|
|
813
|
+
tools: innerTools.terminal,
|
|
507
814
|
maxIterations: PLANNER_MAX_ITERATIONS,
|
|
508
815
|
};
|
|
509
816
|
// Merge the outer abort signal with a wall-clock timeout — either
|
|
510
817
|
// cancels the planner promptly.
|
|
511
818
|
const signal = AbortSignal.any([ctx.signal, AbortSignal.timeout(PLAN_TIMEOUT_MS)]);
|
|
512
819
|
let runError = null;
|
|
820
|
+
let run = null;
|
|
513
821
|
try {
|
|
514
|
-
await runToTerminal(planner, [{ role: "user", content: prompt }], forSubAgent(ctx.session, PLANNER_AGENT_ID), {
|
|
515
|
-
provider: deps.provider,
|
|
822
|
+
run = await runToTerminal(planner, [{ role: "user", content: prompt }], forSubAgent(ctx.session, PLANNER_AGENT_ID), {
|
|
823
|
+
provider: deps.conversation.provider,
|
|
516
824
|
signal,
|
|
517
825
|
emit: ctx.emit,
|
|
518
826
|
runStep: passthroughStep,
|
|
519
|
-
}, {
|
|
520
827
|
resolved: () => holder.outcome !== null,
|
|
828
|
+
// Planner prose is unusable: every meaningful outcome is
|
|
829
|
+
// a tool call, and the terminal predicate stops the loop
|
|
830
|
+
// as soon as one is recorded.
|
|
831
|
+
toolChoice: "required",
|
|
832
|
+
logger,
|
|
833
|
+
}, {
|
|
521
834
|
tools: innerTools.terminal,
|
|
522
835
|
nudge: "You ended without a terminal outcome. Call submit_plan with " +
|
|
523
836
|
"your final plan now, or request_clarification / report_blocker " +
|
|
@@ -527,12 +840,19 @@ export function createGeneratePlanTool(deps) {
|
|
|
527
840
|
catch (err) {
|
|
528
841
|
runError = err;
|
|
529
842
|
}
|
|
530
|
-
|
|
843
|
+
const shaped = shapeOutcome({
|
|
531
844
|
holder,
|
|
532
845
|
runError,
|
|
533
846
|
timedOut: signal.aborted && !ctx.signal.aborted,
|
|
534
847
|
outerAborted: ctx.signal.aborted,
|
|
535
|
-
})
|
|
848
|
+
});
|
|
849
|
+
return finish(shaped, {
|
|
850
|
+
// A throw leaves no result to read the transcript off, so the cause takes its
|
|
851
|
+
// place — `shapeOutcome` folds it into one prose sentence for the model, and
|
|
852
|
+
// this is the only place its type and stack survive.
|
|
853
|
+
...(run ? loopFields(run, shaped.kind) : {}),
|
|
854
|
+
...(runError ? logger.errorFields(runError) : {}),
|
|
855
|
+
});
|
|
536
856
|
},
|
|
537
857
|
});
|
|
538
858
|
}
|