@inflexa-ai/harness 0.45.0 → 0.45.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 (122) hide show
  1. package/dist/agents/conversation-agent.d.ts +10 -0
  2. package/dist/agents/conversation-agent.js +19 -1
  3. package/dist/agents/sandbox/shared.d.ts +23 -2
  4. package/dist/agents/sandbox/shared.js +51 -18
  5. package/dist/agents/sandbox/translational-safety-agent.js +26 -0
  6. package/dist/agents/sandbox/types.d.ts +24 -1
  7. package/dist/agents/sandbox-catalog.d.ts +14 -0
  8. package/dist/agents/sandbox-catalog.js +19 -0
  9. package/dist/app/approve-plan.d.ts +5 -2
  10. package/dist/app/approve-plan.js +1 -1
  11. package/dist/app/chat-turn.js +6 -3
  12. package/dist/config/environment-stores.d.ts +4 -2
  13. package/dist/contracts/chat-events.d.ts +12 -2
  14. package/dist/contracts/chat-events.js +1 -1
  15. package/dist/contracts/chat-frame.js +6 -2
  16. package/dist/contracts/index.d.ts +1 -1
  17. package/dist/contracts/schemas/chat-events.d.ts +25 -0
  18. package/dist/contracts/schemas/chat-events.js +7 -0
  19. package/dist/contracts/schemas/chat-parts.d.ts +39 -0
  20. package/dist/contracts/schemas/chat-parts.js +17 -0
  21. package/dist/contracts/schemas/index.d.ts +1 -1
  22. package/dist/contracts/schemas/index.js +1 -1
  23. package/dist/execution/dbos-workflow-purger.d.ts +2 -0
  24. package/dist/execution/dbos-workflow-purger.js +21 -14
  25. package/dist/execution/run-canceler.d.ts +33 -11
  26. package/dist/execution/run-canceler.js +129 -27
  27. package/dist/execution/run-end-record.d.ts +30 -0
  28. package/dist/execution/run-end-record.js +27 -0
  29. package/dist/index.d.ts +9 -5
  30. package/dist/index.js +11 -1
  31. package/dist/loop/call-guard.d.ts +50 -0
  32. package/dist/loop/call-guard.js +90 -0
  33. package/dist/loop/compaction.d.ts +5 -0
  34. package/dist/loop/compaction.js +17 -0
  35. package/dist/loop/run-agent.d.ts +9 -0
  36. package/dist/loop/run-agent.js +15 -1
  37. package/dist/loop/run-to-terminal.js +4 -1
  38. package/dist/loop/types.d.ts +6 -0
  39. package/dist/memory/conversation-display-recorder.d.ts +0 -2
  40. package/dist/memory/conversation-display-recorder.js +28 -3
  41. package/dist/memory/thread-history.d.ts +34 -7
  42. package/dist/memory/thread-history.js +151 -42
  43. package/dist/prompts/briefing.d.ts +57 -3
  44. package/dist/prompts/briefing.js +129 -2
  45. package/dist/prompts/planner.js +13 -2
  46. package/dist/provenance/seam.d.ts +23 -15
  47. package/dist/provenance/seam.js +7 -5
  48. package/dist/providers/ai-sdk.js +87 -7
  49. package/dist/providers/types.d.ts +6 -0
  50. package/dist/reference-data/catalog.js +141 -2
  51. package/dist/report-model/authoring-grammar.d.ts +26 -26
  52. package/dist/sandbox/image-packages.d.ts +26 -4
  53. package/dist/sandbox/image-packages.js +18 -4
  54. package/dist/schemas/data-profile-schemas.d.ts +12 -12
  55. package/dist/schemas/plan-schemas.d.ts +57 -3
  56. package/dist/schemas/plan-schemas.js +1 -1
  57. package/dist/schemas/workflow-state.d.ts +113 -0
  58. package/dist/schemas/workflow-state.js +54 -0
  59. package/dist/state/artifacts.d.ts +17 -1
  60. package/dist/state/artifacts.js +21 -3
  61. package/dist/state/index.d.ts +2 -2
  62. package/dist/state/index.js +1 -1
  63. package/dist/state/runs.d.ts +43 -9
  64. package/dist/state/runs.js +85 -14
  65. package/dist/state/step-executions.d.ts +9 -0
  66. package/dist/state/step-executions.js +22 -0
  67. package/dist/tools/ad-hoc-router.d.ts +2 -0
  68. package/dist/tools/ad-hoc-router.js +18 -2
  69. package/dist/tools/capability-data.d.ts +36 -0
  70. package/dist/tools/capability-data.js +73 -0
  71. package/dist/tools/execute-analysis.d.ts +37 -9
  72. package/dist/tools/execute-analysis.js +131 -21
  73. package/dist/tools/knowledge/check.d.ts +60 -0
  74. package/dist/tools/knowledge/check.js +92 -0
  75. package/dist/tools/knowledge/client.d.ts +382 -0
  76. package/dist/tools/knowledge/client.js +302 -0
  77. package/dist/tools/knowledge/environment.d.ts +78 -0
  78. package/dist/tools/knowledge/environment.js +105 -0
  79. package/dist/tools/knowledge/index.d.ts +49 -0
  80. package/dist/tools/knowledge/index.js +70 -0
  81. package/dist/tools/knowledge/local-slots.d.ts +62 -0
  82. package/dist/tools/knowledge/local-slots.js +177 -0
  83. package/dist/tools/knowledge/pin.d.ts +14 -0
  84. package/dist/tools/knowledge/pin.js +17 -0
  85. package/dist/tools/knowledge/recommend.d.ts +92 -0
  86. package/dist/tools/knowledge/recommend.js +98 -0
  87. package/dist/tools/knowledge/situation.d.ts +143 -0
  88. package/dist/tools/knowledge/situation.js +164 -0
  89. package/dist/tools/knowledge/skeleton.d.ts +69 -0
  90. package/dist/tools/knowledge/skeleton.js +202 -0
  91. package/dist/tools/knowledge/template.d.ts +137 -0
  92. package/dist/tools/knowledge/template.js +267 -0
  93. package/dist/tools/research/generate-plan.d.ts +23 -0
  94. package/dist/tools/research/generate-plan.js +203 -12
  95. package/dist/tools/research/guarded-address.d.ts +20 -0
  96. package/dist/tools/research/guarded-address.js +148 -0
  97. package/dist/tools/research/searxng-engine.d.ts +40 -0
  98. package/dist/tools/research/searxng-engine.js +120 -0
  99. package/dist/tools/research/web-fetch.d.ts +124 -0
  100. package/dist/tools/research/web-fetch.js +627 -0
  101. package/dist/tools/research/web-search.d.ts +75 -0
  102. package/dist/tools/research/web-search.js +112 -0
  103. package/dist/tools/sandbox/list-available-packages.d.ts +7 -4
  104. package/dist/tools/sandbox/list-available-packages.js +12 -4
  105. package/dist/tools/sandbox/list-available-refs.d.ts +21 -0
  106. package/dist/tools/sandbox/list-available-refs.js +60 -3
  107. package/dist/tools/sandbox/report-blocker.js +5 -1
  108. package/dist/tools/terminal-text.d.ts +11 -0
  109. package/dist/tools/terminal-text.js +19 -0
  110. package/dist/tools/workspace/decision-record.d.ts +81 -0
  111. package/dist/tools/workspace/decision-record.js +117 -0
  112. package/dist/tools/workspace/edit-file.d.ts +3 -1
  113. package/dist/tools/workspace/edit-file.js +20 -2
  114. package/dist/tools/workspace/execute-command.d.ts +17 -0
  115. package/dist/tools/workspace/execute-command.js +28 -1
  116. package/dist/tools/workspace/index.d.ts +1 -0
  117. package/dist/tools/workspace/index.js +1 -0
  118. package/dist/tools/workspace/mutator.d.ts +1 -1
  119. package/dist/workflows/execute-analysis.d.ts +58 -18
  120. package/dist/workflows/execute-analysis.js +273 -58
  121. package/dist/workflows/sandbox-step.d.ts +12 -0
  122. package/package.json +5 -1
@@ -3,18 +3,21 @@
3
3
  * hoc analyses. Both modes launch the ordinary executeAnalysis workflow.
4
4
  */
5
5
  import { randomUUID } from "node:crypto";
6
- import { err, ok, ResultAsync } from "neverthrow";
6
+ import { err, ok, okAsync, ResultAsync } from "neverthrow";
7
7
  import { z } from "zod";
8
+ import { capabilityDataRequirement } from "../agents/sandbox-catalog.js";
8
9
  import { SANDBOX_AGENT_META } from "../agents/sandbox/index.js";
9
10
  import { DEFAULT_SANDBOX_MAX_STEPS } from "../config/resource-limits.js";
10
11
  import { createNoopLogger } from "../lib/console-logger.js";
12
+ import { readInstalledDatasetIds } from "./sandbox/list-available-refs.js";
13
+ import { tryQuery, withTransaction } from "../lib/db-result.js";
11
14
  import { deliverNotice, passGate } from "../lib/hooks.js";
12
15
  import { unwrapOrThrow } from "../lib/result.js";
13
16
  import { buildRunCardData } from "../memory/card-builders.js";
14
17
  import { formatQuery, parseQuery } from "../sandbox/package-identity.js";
15
18
  import { AnalysisPlanSchema } from "../schemas/workflow-state.js";
16
19
  import { validatePlan } from "../schemas/validate-plan.js";
17
- import { RunDedupCollisionError, insertRun, loadPlan, queryActiveRun, reserveRunById, suspendAnalysis, updateRunStatus, upsertPlan } from "../state/index.js";
20
+ import { RunDedupCollisionError, insertRun, loadPlan, queryActiveRun, queryNewestPlanRun, reserveRunById, suspendAnalysis, updateRunStatus, upsertPlan, } from "../state/index.js";
18
21
  import { routeAdHocRequest } from "./ad-hoc-router.js";
19
22
  import { adHocPlanId, adHocRunId } from "./analysis-invocation.js";
20
23
  import { defineTool } from "./define-tool.js";
@@ -92,6 +95,66 @@ export class PlanPackagesMissingError extends Error {
92
95
  this.name = "PlanPackagesMissingError";
93
96
  }
94
97
  }
98
+ /**
99
+ * A declared dataset that the reference store does not hold, found at the pre-launch pass. The
100
+ * message names the datasets and no remedy command — the remedy belongs to the embedder, and its
101
+ * wrapper appends it.
102
+ */
103
+ export class PlanReferencesMissingError extends Error {
104
+ missing;
105
+ unavailable;
106
+ constructor(missing, unavailable) {
107
+ const parts = [
108
+ // The store-level reason FIRST, for the same reason the package error leads with it:
109
+ // with an unanswerable store, no per-dataset claim below it is trustworthy.
110
+ unavailable !== undefined
111
+ ? `the store cannot answer (${unavailable}) — this says nothing about the datasets themselves, so do not re-request them`
112
+ : null,
113
+ missing.length > 0 ? `the store does not hold: ${missing.join(", ")}` : null,
114
+ ].filter((part) => part !== null);
115
+ super(`The reference data of this plan is not installed — ${parts.join(" ")}`);
116
+ this.missing = missing;
117
+ this.unavailable = unavailable;
118
+ this.name = "PlanReferencesMissingError";
119
+ }
120
+ }
121
+ /**
122
+ * Resolve the reference data that the capabilities of a plan declare, before the run reserves
123
+ * anything. A step names its capability, the agent of the step holds the declaration, and the
124
+ * declaration names catalog ids. An absent dataset refuses the launch with the ids named.
125
+ *
126
+ * Nothing is read when no step declares a requirement: the common plan touches no store at all.
127
+ *
128
+ * @returns How many datasets reached the store. Zero says that nothing was checked — a plan that
129
+ * declares none, or a store the embedder bound nowhere — which a launch record must be able to tell
130
+ * from a checked launch.
131
+ */
132
+ async function resolvePlanReferences(refStorePath, plan) {
133
+ const declared = new Set();
134
+ for (const step of plan.steps) {
135
+ if (!step.agent || !step.capability)
136
+ continue;
137
+ for (const dataset of capabilityDataRequirement(step.agent, step.capability))
138
+ declared.add(dataset);
139
+ }
140
+ if (declared.size === 0)
141
+ return ok(0);
142
+ // An unbound store is an embedder that provisions reference data some other way, exactly as an
143
+ // unbound farm seam leaves the package pass at once. Reading the container path here instead
144
+ // would fail on every host and refuse every launch that declares a capability.
145
+ if (refStorePath === undefined)
146
+ return ok(0);
147
+ const installed = await readInstalledDatasetIds({ refStorePath });
148
+ if (installed === undefined) {
149
+ // The path is named, because the reason is a fault of the environment and the reader has to
150
+ // know WHICH store did not answer. A bare "could not be read" sends a caller round in circles.
151
+ return err({ type: "references_missing", missing: [], unavailable: `the reference store at ${refStorePath} could not be read` });
152
+ }
153
+ const missing = [...declared].filter((dataset) => !installed.has(dataset)).sort();
154
+ if (missing.length > 0)
155
+ return err({ type: "references_missing", missing });
156
+ return ok(declared.size);
157
+ }
95
158
  /**
96
159
  * Link the packages of the plan into the farm of the analysis, before the
97
160
  * run reserves anything. The linked set is the union of the queries of each
@@ -226,7 +289,7 @@ function parseStoredPlan(raw, planId) {
226
289
  function validateStoredPlan(raw, planId) {
227
290
  const parsed = parseStoredPlan(raw, planId);
228
291
  if (parsed.isErr())
229
- return throwLaunchRefusal(parsed.error);
292
+ return throwLaunchRefusal(parsed.error, planId);
230
293
  return parsed.value;
231
294
  }
232
295
  function adHocTitle(request) {
@@ -286,6 +349,7 @@ export function buildAdHocPlan(request, route, createdAt = new Date().toISOStrin
286
349
  status: "pending",
287
350
  resources: route.resources,
288
351
  agent: route.agentId,
352
+ ...(route.capability ? { capability: route.capability } : {}),
289
353
  maxSteps: meta?.defaultMaxSteps ?? DEFAULT_SANDBOX_MAX_STEPS,
290
354
  },
291
355
  ],
@@ -359,6 +423,12 @@ async function launchReservedRun(deps, args) {
359
423
  const resources = stepResources(planId, plan);
360
424
  if (resources.isErr())
361
425
  return err(resources.error);
426
+ // Reference data first: it is a read, so it costs nothing to learn before the farm link
427
+ // writes. Both passes come before the run reserves anything, thus a miss of either leaves no
428
+ // run row and no revoked authorization behind.
429
+ const references = await resolvePlanReferences(deps.refStorePath, plan);
430
+ if (references.isErr())
431
+ return err(references.error);
362
432
  // The link pass comes before the run reserves anything, thus a pool
363
433
  // miss leaves no run row and no revoked authorization behind.
364
434
  const linked = await linkPlanPackages(deps.extendAnalysisFarm, analysisId, planId, plan);
@@ -413,26 +483,52 @@ async function launchReservedRun(deps, args) {
413
483
  logger.info("analysis launched", { analysisId, runId, planId, mode: args.mode, packageQueries });
414
484
  return ok({ runId, started: true });
415
485
  }
486
+ function staleViewOf(newest, seen) {
487
+ if (newest !== null)
488
+ return newest === seen ? null : { type: "newer", runId: newest };
489
+ return seen === null ? null : { type: "missing", runId: seen };
490
+ }
491
+ /** A stale view starts nothing: it gives the newest run with `started: false`, or `run_not_found` when the plan has no run. */
492
+ function refuseStaleView(view) {
493
+ return view.type === "newer" ? ok({ runId: view.runId, started: false }) : err({ type: "run_not_found", runId: view.runId });
494
+ }
495
+ function lockPlanLaunch(client, planId) {
496
+ return tryQuery("execute-analysis.lockPlanLaunch", () => client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [`plan-launch:${planId}`])).map(() => undefined);
497
+ }
416
498
  /**
417
- * Reserve a fresh run for a plan. A concurrent launch of the same plan holds the active-plan slot,
418
- * and its run is the answer.
499
+ * Reserve a fresh run for a plan under the lock of the plan, thus the reservations of one plan occur one
500
+ * after the other. A caller with a `newestRunId` reserves nothing when the newest run of the plan is a
501
+ * different run. The new row starts after the newest start, thus it is the newest run of the plan. A
502
+ * concurrent launch of the same plan can hold the active-plan slot, and its run is the answer.
419
503
  */
420
504
  async function reservePlanRun(deps, args) {
421
505
  const runId = randomUUID();
422
506
  try {
423
- const inserted = await insertRun(deps.pool, {
424
- runId,
425
- analysisId: args.analysisId,
426
- threadId: args.threadId,
427
- workflowName: "executeAnalysis",
428
- planId: args.planId,
429
- });
430
- if (inserted.isErr())
431
- return err({ type: "store_failed", cause: inserted.error });
507
+ const reserved = await withTransaction(deps.pool, "execute-analysis.reservePlanRun", (client) => lockPlanLaunch(client, args.planId)
508
+ .andThen(() => queryNewestPlanRun(client, { analysisId: args.analysisId, planId: args.planId }))
509
+ .andThen((newest) => {
510
+ const stale = args.newestRunId === undefined ? null : staleViewOf(newest?.runId ?? null, args.newestRunId);
511
+ if (stale)
512
+ return okAsync({ type: "stale", view: stale });
513
+ const startedAt = newest === null ? undefined : new Date(Math.max(Date.now(), Date.parse(newest.startedAt) + 1)).toISOString();
514
+ return insertRun(client, {
515
+ runId,
516
+ analysisId: args.analysisId,
517
+ threadId: args.threadId,
518
+ workflowName: "executeAnalysis",
519
+ planId: args.planId,
520
+ ...(startedAt === undefined ? {} : { startedAt }),
521
+ }).map(() => ({ type: "inserted" }));
522
+ }));
523
+ if (reserved.isErr())
524
+ return err({ type: "store_failed", cause: reserved.error });
525
+ if (reserved.value.type === "stale")
526
+ return refuseStaleView(reserved.value.view);
432
527
  return ok({ runId, started: true });
433
528
  }
434
529
  catch (cause) {
435
- // `insertRun` rejects its promise on a collision of the active-plan index, and on nothing else.
530
+ // `insertRun` rejects its promise on a collision of the active-plan index, and on nothing else. The
531
+ // transaction is rolled back and released by now, so the read goes to the pool.
436
532
  if (!isDedupCollision(cause))
437
533
  return err({ type: "store_failed", cause });
438
534
  const active = await queryActiveRun(deps.pool, args.analysisId, args.planId);
@@ -467,7 +563,7 @@ async function reserveAdHocRun(deps, args) {
467
563
  }
468
564
  }
469
565
  async function launchStoredPlan(deps, input) {
470
- const { session, planId } = input;
566
+ const { session, planId, newestRunId } = input;
471
567
  const analysisId = session.scope.analysisId;
472
568
  const raw = await loadPlan(deps.pool, planId, { analysisId });
473
569
  if (raw.isErr())
@@ -477,6 +573,14 @@ async function launchStoredPlan(deps, input) {
477
573
  const plan = parseStoredPlan(raw.value, planId);
478
574
  if (plan.isErr())
479
575
  return err(plan.error);
576
+ if (newestRunId !== undefined) {
577
+ const newest = await queryNewestPlanRun(deps.pool, { analysisId, planId });
578
+ if (newest.isErr())
579
+ return err({ type: "store_failed", cause: newest.error });
580
+ const stale = staleViewOf(newest.value?.runId ?? null, newestRunId);
581
+ if (stale)
582
+ return refuseStaleView(stale);
583
+ }
480
584
  const active = await queryActiveRun(deps.pool, analysisId, planId);
481
585
  if (active.isErr())
482
586
  return err({ type: "store_failed", cause: active.error });
@@ -487,13 +591,15 @@ async function launchStoredPlan(deps, input) {
487
591
  planId,
488
592
  plan: plan.value,
489
593
  mode: "plan",
490
- reserve: (frame) => reservePlanRun(deps, { ...frame, planId }),
594
+ reserve: (frame) => reservePlanRun(deps, { ...frame, planId, ...(newestRunId === undefined ? {} : { newestRunId }) }),
491
595
  });
492
596
  }
493
597
  /**
494
598
  * Start the run of a stored plan. It reads the plan under the analysis of the session, validates it,
495
- * links its packages, reserves a run, authorizes it, and starts the workflow. It gives the run id, or
496
- * the refusal, and it emits no chat part. `started` is false when a run of the plan was already active.
599
+ * compares the newest run of the plan with `newestRunId`, links its packages, reserves a run,
600
+ * authorizes it, and starts the workflow. It gives the run id, or the refusal, and it emits no chat
601
+ * part. `started` is false when this call started nothing: a run of the plan was already active, or the
602
+ * newest run of the plan is not the run that the caller saw. `runId` then names that run.
497
603
  *
498
604
  * This is an API boundary: a host calls it outside a tool body, thus no outcome above rejects the
499
605
  * promise.
@@ -502,14 +608,18 @@ export function launchPlanRun(deps, input) {
502
608
  return new ResultAsync(launchStoredPlan(deps, input));
503
609
  }
504
610
  /** Throw the error that the tool body threw before the launch gave a `Result`. */
505
- function throwLaunchRefusal(refusal) {
611
+ function throwLaunchRefusal(refusal, planId) {
506
612
  switch (refusal.type) {
613
+ case "run_not_found":
614
+ throw new Error(`Plan ${planId} has no run ${refusal.runId}`);
507
615
  case "plan_not_found":
508
616
  throw new PlanNotFoundError(refusal.planId);
509
617
  case "plan_invalid":
510
618
  throw refusal.malformed ? new PlanMalformedError(refusal.planId) : new PlanValidationError(refusal.planId, refusal.errors);
511
619
  case "packages_missing":
512
620
  throw new PlanPackagesMissingError(refusal.missing, refusal.collisions, refusal.unavailable);
621
+ case "references_missing":
622
+ throw new PlanReferencesMissingError(refusal.missing, refusal.unavailable);
513
623
  case "store_failed":
514
624
  return unwrapOrThrow(err(refusal.cause));
515
625
  case "launch_failed":
@@ -566,7 +676,7 @@ export function createExecuteAnalysisTool(deps) {
566
676
  return err({ error: `The analysis is suspended: ${refusal.reason}`, retryable: false });
567
677
  if (refusal.type === "not_authorized")
568
678
  return err({ error: `The run was not authorized: ${refusal.reason}`, retryable: false });
569
- return throwLaunchRefusal(refusal);
679
+ return throwLaunchRefusal(refusal, planId);
570
680
  }
571
681
  await emitRunCard(launched.value.runId);
572
682
  return ok({ runId: launched.value.runId, status: "in_progress" });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `knowledge_check` — the drafted steps against the rules, once, before
3
+ * `submit_plan`. A violation names the rule and the permitted alternatives.
4
+ * The planner revises once. There is no loop: the host counts the calls of
5
+ * one plan generation, and past `CHECK_CALL_LIMIT` the tool answers with a
6
+ * typed refusal and never reaches the service. A small model that cannot
7
+ * satisfy a finding otherwise rephrases the step until the plan times out.
8
+ */
9
+ import type { CheckResponse, KnowledgeClient, KnowledgeRejected, KnowledgeSnapshotMismatch, KnowledgeUnavailable } from "./client.js";
10
+ import type { SnapshotPin } from "./pin.js";
11
+ export interface KnowledgeCheckDeps {
12
+ readonly client: KnowledgeClient;
13
+ /** The release the plan pinned on its first recommend answer. The check sends it, thus another release refuses the call. */
14
+ readonly pin?: SnapshotPin;
15
+ }
16
+ export type KnowledgeCheckOutput = CheckResponse | KnowledgeUnavailable | KnowledgeRejected | KnowledgeSnapshotMismatch;
17
+ /** The checks one plan generation may run. Two is a draft and one revision; the third is slack. */
18
+ export declare const CHECK_CALL_LIMIT = 3;
19
+ export declare function createKnowledgeCheckTool(deps: KnowledgeCheckDeps): import("../define-tool.js").Tool<{
20
+ question: "enrichment" | "differential_expression" | "qc" | "full_plan" | "tf_activity" | "deconvolution" | "coexpression" | "clustering" | "survival" | "signature_scoring";
21
+ modality: "bulk_rna_seq";
22
+ data_state: "fastq" | "counts" | "tpm_or_fpkm" | "log_normalized";
23
+ organism: "other" | "human" | "mouse";
24
+ n_groups: number;
25
+ n_per_group_min: number;
26
+ n_per_group_max: number;
27
+ paired: boolean;
28
+ batch: "none" | "known_balanced" | "known_confounded" | "known_unbalanced" | "suspected";
29
+ steps: {
30
+ step_type: "normalize" | "enrichment" | "differential_expression" | "report" | "tf_activity" | "deconvolution" | "coexpression" | "clustering" | "survival" | "signature_scoring" | "variance_partition" | "pathway_activity" | "transcript_level" | "annotation" | "cohort_assembly" | "qc_sample_structure" | "filter_low_counts" | "model_design" | "shrink_lfc" | "multiple_testing";
31
+ method: string;
32
+ method_id?: string | undefined;
33
+ package?: string | undefined;
34
+ parameters?: {
35
+ name: string;
36
+ value: string | number | boolean;
37
+ }[] | undefined;
38
+ outcome?: string | undefined;
39
+ }[];
40
+ count_source?: "unknown" | "salmon" | "kallisto" | "star_featurecounts" | "rsem" | undefined;
41
+ import_state?: "unknown" | "quantifications" | "estimated_counts_with_lengths" | "corrected_counts" | "integer_counts" | undefined;
42
+ n_samples?: number | undefined;
43
+ n_events?: number | undefined;
44
+ blocking_factor?: "other" | "individual" | "cell_line" | "litter" | "site" | "pair" | null | undefined;
45
+ block_structure?: "crossed" | "nested" | undefined;
46
+ covariates?: ("other" | "technical" | "sex" | "age" | "clinical")[] | undefined;
47
+ n_timepoints?: number | null | undefined;
48
+ continuous_predictor?: "other" | "score" | "time" | "age" | "dose" | null | undefined;
49
+ technical_replicates?: boolean | undefined;
50
+ sample_exclusions?: boolean | undefined;
51
+ feature_level?: "gene" | "exon" | "transcript" | undefined;
52
+ library_type?: "unknown" | "total" | "polyA" | "three_prime" | undefined;
53
+ strandedness?: "unknown" | "verified" | "declared_unverified" | undefined;
54
+ interaction?: boolean | undefined;
55
+ classifier?: boolean | undefined;
56
+ extra_analyses?: ("tf_activity" | "deconvolution" | "coexpression" | "clustering" | "survival" | "signature_scoring" | "variance_partition" | "pathway_activity" | "transcript_level" | "annotation")[] | undefined;
57
+ preferred_language?: "R" | "python" | undefined;
58
+ enrichment_input?: "ranked_list" | "gene_list" | "sample_scores" | undefined;
59
+ quality_flags?: ("low_depth_sample" | "outlier_sample" | "sample_identity_doubt" | "high_duplication" | "degraded_rna")[] | undefined;
60
+ }, KnowledgeCheckOutput>;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * `knowledge_check` — the drafted steps against the rules, once, before
3
+ * `submit_plan`. A violation names the rule and the permitted alternatives.
4
+ * The planner revises once. There is no loop: the host counts the calls of
5
+ * one plan generation, and past `CHECK_CALL_LIMIT` the tool answers with a
6
+ * typed refusal and never reaches the service. A small model that cannot
7
+ * satisfy a finding otherwise rephrases the step until the plan times out.
8
+ */
9
+ import { ok } from "neverthrow";
10
+ import { z } from "zod";
11
+ import { defineTool } from "../define-tool.js";
12
+ import { SituationFieldsSchema, toSituation } from "./situation.js";
13
+ /** The checks one plan generation may run. Two is a draft and one revision; the third is slack. */
14
+ export const CHECK_CALL_LIMIT = 3;
15
+ const DraftedStepSchema = z.object({
16
+ step_type: z
17
+ .enum([
18
+ "cohort_assembly",
19
+ "qc_sample_structure",
20
+ "filter_low_counts",
21
+ "normalize",
22
+ "model_design",
23
+ "differential_expression",
24
+ "shrink_lfc",
25
+ "multiple_testing",
26
+ "enrichment",
27
+ "variance_partition",
28
+ "tf_activity",
29
+ "pathway_activity",
30
+ "signature_scoring",
31
+ "deconvolution",
32
+ "coexpression",
33
+ "clustering",
34
+ "survival",
35
+ "transcript_level",
36
+ "annotation",
37
+ "report",
38
+ ])
39
+ .describe("The kind of work the drafted step does."),
40
+ method: z.string().min(1).describe("The method as the step names it, for example `DESeq2 Wald test` or `edgeR quasi-likelihood`."),
41
+ method_id: z
42
+ .string()
43
+ .regex(/^M-\d{4}$/)
44
+ .optional()
45
+ .describe("The `method.id` of the step in the `knowledge_recommend` answer (for example `M-0001`), copied when the step uses that method. With it the check matches the method exactly and ignores the wording."),
46
+ package: z.string().optional().describe("The main package of the step, for example `DESeq2`."),
47
+ parameters: z
48
+ .array(z.object({ name: z.string().min(1), value: z.union([z.string(), z.number(), z.boolean()]) }))
49
+ .optional()
50
+ .describe("The key parameters of the step, for example `alpha`, `min_count`, `lfc_shrink`, `rank_metric`."),
51
+ outcome: z
52
+ .string()
53
+ .optional()
54
+ .describe("The outcome the step states when a flag of `knowledge_recommend` removes inference, copied from the flag, for example `descriptive_only`."),
55
+ });
56
+ /** `ok`, or the counts of the findings, plus the steps the check did not assess when there are any. */
57
+ function describeFindings(result) {
58
+ const findings = result.ok ? "ok" : `${result.violations.length} violation(s), ${result.warnings.length} warning(s)`;
59
+ const notAssessed = result.not_assessed?.length ?? 0;
60
+ return notAssessed > 0 ? `${findings}, ${notAssessed} not assessed` : findings;
61
+ }
62
+ export function createKnowledgeCheckTool(deps) {
63
+ let calls = 0;
64
+ return defineTool({
65
+ id: "knowledge_check",
66
+ description: "Check the method steps you drafted for a bulk RNA-seq analysis against the rules of the Inflexa knowledge service, once, after the draft and before `submit_plan`. " +
67
+ "Send the same situation you sent to `knowledge_recommend` and the drafted steps: the step type, the method as the step names it, its package, and its key parameters. " +
68
+ "The answer lists `violations` (a forbidden method, or an inferential test on a design that a flag limits) and `warnings` (a method outside the set the rules name, a parameter that differs from a sourced default, or a required parameter that the step does not state), each with the rule id and the permitted alternatives. " +
69
+ "Revise a violated step once, then submit. A warning is advice: keep the step when the data gives a reason, and state the reason in the step. `ok: true` means no finding on the assessed steps. " +
70
+ "`not_assessed` lists the steps that no rule covers; the check neither passed nor failed them, so submit them as drafted. " +
71
+ "`match: unavailable` means the service did not answer, and `match: snapshot_mismatch` means the service moved to another release than the one your recommend answer came from; in both cases submit the draft as it is. " +
72
+ `The host accepts ${CHECK_CALL_LIMIT} checks per plan; after that the tool refuses and you submit with the findings you have.`,
73
+ inputSchema: SituationFieldsSchema.extend({
74
+ steps: z.array(DraftedStepSchema).min(1).describe("The drafted method steps, one entry per step type."),
75
+ }),
76
+ describeCall: ({ steps }) => `${steps.length} drafted step(s)`,
77
+ describeResult: (_input, result) => ("ok" in result ? describeFindings(result) : result.match),
78
+ execute: async (input) => {
79
+ calls += 1;
80
+ if (calls > CHECK_CALL_LIMIT) {
81
+ return ok({
82
+ match: "rejected",
83
+ message: `The host accepts ${CHECK_CALL_LIMIT} checks per plan, and this is call ${calls}. Submit the plan with the findings of the last check.`,
84
+ issues: [],
85
+ });
86
+ }
87
+ const { steps, ...fields } = input;
88
+ const answer = await deps.client.check(toSituation(fields), steps, deps.pin?.get());
89
+ return ok(answer);
90
+ },
91
+ });
92
+ }