@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.
Files changed (162) hide show
  1. package/dist/agents/conversation-agent.js +23 -16
  2. package/dist/agents/sandbox/cheminformatics-agent.js +1 -1
  3. package/dist/agents/sandbox/chromatin-agent.js +1 -1
  4. package/dist/agents/sandbox/dna-methylation-agent.js +1 -1
  5. package/dist/agents/sandbox/drug-repurposing-agent.js +5 -10
  6. package/dist/agents/sandbox/enrichment-agent.js +1 -1
  7. package/dist/agents/sandbox/genomic-variant-agent.js +1 -11
  8. package/dist/agents/sandbox/immune-profiling-agent.js +1 -1
  9. package/dist/agents/sandbox/multi-omics-integration-agent.js +1 -1
  10. package/dist/agents/sandbox/network-agent.js +1 -1
  11. package/dist/agents/sandbox/pkpd-clinical-response-agent.js +3 -6
  12. package/dist/agents/sandbox/scientific-executor.js +1 -1
  13. package/dist/agents/sandbox/shared.js +7 -13
  14. package/dist/agents/sandbox/translational-safety-agent.js +4 -7
  15. package/dist/agents/sandbox/types.d.ts +5 -5
  16. package/dist/agents/sandbox/types.js +4 -4
  17. package/dist/app/chat-turn.js +4 -1
  18. package/dist/app/message-assembly.d.ts +5 -2
  19. package/dist/app/message-assembly.js +7 -2
  20. package/dist/app/run-activity.d.ts +13 -0
  21. package/dist/app/run-activity.js +51 -0
  22. package/dist/contracts/target-dossier.d.ts +10 -10
  23. package/dist/execution/report-runner.js +1 -1
  24. package/dist/execution/run-event-parts.d.ts +44 -0
  25. package/dist/execution/run-event-parts.js +0 -0
  26. package/dist/execution/run-event-stream.d.ts +91 -0
  27. package/dist/execution/run-event-stream.js +212 -0
  28. package/dist/execution/run-synthesis.js +1 -1
  29. package/dist/index.d.ts +5 -2
  30. package/dist/index.js +20 -1
  31. package/dist/input-sanitization.d.ts +14 -4
  32. package/dist/input-sanitization.js +27 -4
  33. package/dist/lib/storage.js +4 -1
  34. package/dist/loop/budget-exceeded.d.ts +18 -2
  35. package/dist/loop/budget-exceeded.js +49 -2
  36. package/dist/loop/run-agent.d.ts +22 -1
  37. package/dist/loop/run-agent.js +91 -25
  38. package/dist/loop/run-to-terminal.d.ts +22 -4
  39. package/dist/loop/run-to-terminal.js +20 -4
  40. package/dist/memory/ai-sdk-message-storage.d.ts +37 -4
  41. package/dist/memory/ai-sdk-message-storage.js +45 -4
  42. package/dist/memory/content-to-cortex.js +19 -3
  43. package/dist/memory/message-backfill.js +1 -1
  44. package/dist/memory/thread-history.js +34 -8
  45. package/dist/prompts/conversation.d.ts +1 -1
  46. package/dist/prompts/conversation.js +15 -6
  47. package/dist/prompts/literature-reviewer.d.ts +1 -1
  48. package/dist/prompts/literature-reviewer.js +16 -14
  49. package/dist/prompts/planner.js +15 -20
  50. package/dist/prompts/sandbox/drug-repurposing-agent.d.ts +1 -1
  51. package/dist/prompts/sandbox/drug-repurposing-agent.js +31 -22
  52. package/dist/prompts/sandbox/multi-omics-integration-agent.d.ts +1 -1
  53. package/dist/prompts/sandbox/multi-omics-integration-agent.js +9 -10
  54. package/dist/prompts/sandbox/pkpd-clinical-response-agent.d.ts +1 -1
  55. package/dist/prompts/sandbox/pkpd-clinical-response-agent.js +8 -7
  56. package/dist/prompts/sandbox/translational-safety-agent.d.ts +1 -1
  57. package/dist/prompts/sandbox/translational-safety-agent.js +20 -14
  58. package/dist/prompts/synthesis-agent.d.ts +1 -1
  59. package/dist/prompts/synthesis-agent.js +7 -7
  60. package/dist/providers/errors.d.ts +7 -0
  61. package/dist/providers/errors.js +88 -3
  62. package/dist/runtime/pools.d.ts +15 -1
  63. package/dist/runtime/pools.js +15 -1
  64. package/dist/sandbox/sandbox-step-translate.d.ts +15 -2
  65. package/dist/sandbox/sandbox-step-translate.js +17 -2
  66. package/dist/schemas/workflow-state.d.ts +2 -2
  67. package/dist/schemas/workflow-state.js +2 -2
  68. package/dist/state/data-profile.d.ts +53 -1
  69. package/dist/state/data-profile.js +66 -6
  70. package/dist/state/index.d.ts +3 -3
  71. package/dist/state/index.js +2 -2
  72. package/dist/state/init.js +37 -2
  73. package/dist/state/runs.d.ts +39 -0
  74. package/dist/state/runs.js +103 -0
  75. package/dist/tasks/data-profile-activity.d.ts +87 -0
  76. package/dist/tasks/data-profile-activity.js +56 -0
  77. package/dist/tasks/data-profile.js +66 -4
  78. package/dist/tools/bio/chembl.d.ts +1 -1
  79. package/dist/tools/bio/chembl.js +51 -54
  80. package/dist/tools/bio/comptox.js +35 -33
  81. package/dist/tools/bio/drug-gene-interactions.d.ts +76 -0
  82. package/dist/tools/bio/drug-gene-interactions.js +175 -0
  83. package/dist/tools/bio/gene-disease-evidence.d.ts +61 -0
  84. package/dist/tools/bio/gene-disease-evidence.js +187 -0
  85. package/dist/tools/bio/gene-preclinical-profile.d.ts +58 -0
  86. package/dist/tools/bio/gene-preclinical-profile.js +125 -0
  87. package/dist/tools/bio/index.d.ts +23 -16
  88. package/dist/tools/bio/index.js +29 -22
  89. package/dist/tools/bio/keys.d.ts +16 -9
  90. package/dist/tools/bio/keys.js +22 -13
  91. package/dist/tools/bio/lookup-annotation.d.ts +48 -0
  92. package/dist/tools/bio/lookup-annotation.js +173 -0
  93. package/dist/tools/bio/opentargets.d.ts +7 -11
  94. package/dist/tools/bio/opentargets.js +24 -35
  95. package/dist/tools/bio/pubchem.js +6 -4
  96. package/dist/tools/bio/pubmed.d.ts +15 -7
  97. package/dist/tools/bio/pubmed.js +79 -20
  98. package/dist/tools/bio/search-clinical-trials.d.ts +20 -1
  99. package/dist/tools/bio/search-clinical-trials.js +26 -5
  100. package/dist/tools/bio/search-faers.js +9 -3
  101. package/dist/tools/bio/search-gene.d.ts +0 -1
  102. package/dist/tools/bio/search-gene.js +4 -5
  103. package/dist/tools/bio/search-geo-datasets.js +10 -4
  104. package/dist/tools/bio/search-interactions.d.ts +8 -4
  105. package/dist/tools/bio/search-interactions.js +28 -12
  106. package/dist/tools/bio/target-safety.d.ts +59 -0
  107. package/dist/tools/bio/target-safety.js +191 -0
  108. package/dist/tools/execute-analysis.d.ts +1 -0
  109. package/dist/tools/execute-analysis.js +5 -5
  110. package/dist/tools/lib/dgidb-client.d.ts +49 -0
  111. package/dist/tools/lib/dgidb-client.js +168 -0
  112. package/dist/tools/lib/disgenet-client.d.ts +56 -0
  113. package/dist/tools/lib/disgenet-client.js +59 -0
  114. package/dist/tools/lib/drugbank-client.d.ts +83 -0
  115. package/dist/tools/lib/drugbank-client.js +95 -0
  116. package/dist/tools/lib/ensembl-client.d.ts +4 -1
  117. package/dist/tools/lib/ensembl-client.js +5 -3
  118. package/dist/tools/lib/gwas-catalog-client.d.ts +60 -0
  119. package/dist/tools/lib/gwas-catalog-client.js +136 -0
  120. package/dist/tools/lib/pharmgkb-client.d.ts +22 -0
  121. package/dist/tools/lib/pharmgkb-client.js +32 -0
  122. package/dist/tools/lib/pubmed-client.d.ts +57 -4
  123. package/dist/tools/lib/pubmed-client.js +57 -3
  124. package/dist/tools/lib/string-client.d.ts +25 -4
  125. package/dist/tools/lib/string-client.js +22 -5
  126. package/dist/tools/research/generate-plan.d.ts +16 -9
  127. package/dist/tools/research/generate-plan.js +405 -85
  128. package/dist/tools/research/inspect-data-profile.d.ts +31 -6
  129. package/dist/tools/research/inspect-data-profile.js +23 -7
  130. package/dist/tools/research/inspect-run.d.ts +33 -15
  131. package/dist/tools/research/inspect-run.js +155 -66
  132. package/dist/tools/research/literature-reviewer.js +7 -11
  133. package/dist/workflows/execute-analysis.d.ts +63 -0
  134. package/dist/workflows/execute-analysis.js +73 -2
  135. package/dist/workflows/sandbox-step.js +1 -11
  136. package/dist/workflows/target-assessment/decisions/index.d.ts +1 -1
  137. package/dist/workflows/target-assessment/phase4-assemble.d.ts +1 -1
  138. package/dist/workflows/target-assessment/steps/phase2-aggregate.d.ts +2 -2
  139. package/dist/workflows/target-assessment/steps/phase3-aggregate.d.ts +1 -1
  140. package/package.json +1 -1
  141. package/dist/tools/bio/check-safety-panel.d.ts +0 -35
  142. package/dist/tools/bio/check-safety-panel.js +0 -76
  143. package/dist/tools/bio/get-impc-ko-profile.d.ts +0 -9
  144. package/dist/tools/bio/get-impc-ko-profile.js +0 -23
  145. package/dist/tools/bio/lookup-go-term.d.ts +0 -31
  146. package/dist/tools/bio/lookup-go-term.js +0 -116
  147. package/dist/tools/bio/search-bgee-expression.d.ts +0 -10
  148. package/dist/tools/bio/search-bgee-expression.js +0 -30
  149. package/dist/tools/bio/search-clinvar.d.ts +0 -13
  150. package/dist/tools/bio/search-clinvar.js +0 -28
  151. package/dist/tools/bio/search-dgidb.d.ts +0 -53
  152. package/dist/tools/bio/search-dgidb.js +0 -213
  153. package/dist/tools/bio/search-disgenet.d.ts +0 -30
  154. package/dist/tools/bio/search-disgenet.js +0 -84
  155. package/dist/tools/bio/search-drugbank.d.ts +0 -38
  156. package/dist/tools/bio/search-drugbank.js +0 -109
  157. package/dist/tools/bio/search-gwas-catalog.d.ts +0 -33
  158. package/dist/tools/bio/search-gwas-catalog.js +0 -172
  159. package/dist/tools/bio/search-pathway.d.ts +0 -12
  160. package/dist/tools/bio/search-pathway.js +0 -32
  161. package/dist/tools/bio/search-pharmgkb.d.ts +0 -17
  162. 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 four terminal tool calls:
14
+ * EXCLUSIVELY via three terminal tool calls:
15
15
  *
16
- * - validate_plan(plan) → non-terminal dry-run: an any-shape candidate
17
- * in, {valid, issues} out (schema + semantic)
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: 1 draft + ~3 validate/fix
53
- * cycles + 1 submit + headroom.
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
- // Strict: a malformed plan must not reach submission. `execute` still
289
- // re-validates (schema + semantic) — it does not lean on this boundary.
290
- inputSchema: z.object({ plan: PlannerPlanSchema }),
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 === null) {
342
- holder.outcome = {
343
- kind: "clarification",
344
- question: input.question,
345
- questionContext: input.questionContext,
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 === null)
354
- holder.outcome = outcome;
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 { all: [validatePlanTool, listAvailableRefs, listAvailablePackages, ...terminal], terminal };
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
- event: "clarification_needed",
384
- question: outcome.question,
385
- ...(outcome.questionContext ? { questionContext: outcome.questionContext } : {}),
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
- event: "error",
398
- error: "Plan generation timed out — the model may be overloaded.",
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
- event: "error",
410
- error: "Plan generation completed without a terminal outcome — the planner " + "did not call submit_plan, request_clarification, or report_blocker.",
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 ok({
464
- event: "error",
465
- error: "parentPlanId is not a valid plan for this analysis",
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
- return ok({
472
- event: "error",
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).unwrapOr(null);
486
- const dataContextBlock = renderDataContext(classifyGrounding(profileStatus));
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, deps);
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.all,
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
- return ok(shapeOutcome({
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
  }