@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
@@ -12,11 +12,9 @@
12
12
  * and it is why the record is served in full rather than summarized: an agent that
13
13
  * cannot pull a fact here has no fallback but to re-derive it from the raw bytes.
14
14
  *
15
- * Bounded by construction. `inspect_run` is the cautionary counter-example — it caps
16
- * its query at 50 rows with no `limit` parameter and no signal to the caller, so a
17
- * 51st run is simply invisible. Here the per-file scope is explicitly paged and always
18
- * reports `total` and `hasMore`: an elided tail is a fact the model can see and act on,
19
- * never a silent truncation.
15
+ * Bounded by construction. Like `inspect_run`, the per-file scope is explicitly
16
+ * paged and always reports `total` and `hasMore`: an elided tail is a fact the
17
+ * model can see and act on, never a silent truncation.
20
18
  *
21
19
  * Lifecycle states are data variants, not errors (the `defineTool` contract): a missing,
22
20
  * in-flight, failed, or stale profile is an ordinary, expected outcome the model must be
@@ -61,7 +59,17 @@ interface FilesOutput extends ProfileEnvelope {
61
59
  readonly hasMore: boolean;
62
60
  readonly files: DataProfileFile[];
63
61
  }
64
- /** No profile to serve — the honest lifecycle states, in the ok channel. */
62
+ /**
63
+ * No profile to serve — the honest lifecycle states, in the ok channel.
64
+ *
65
+ * Deliberately carries no staleness verdict. None of this variant's three
66
+ * producers retains a comparand: an analysis that was never profiled and one
67
+ * with no input files never had one, and `clearDataProfile` nulls
68
+ * `seed_input_file_ids` along with every other profile column, so a cleared row
69
+ * keeps no record of what it once covered. Such a field could only ever be
70
+ * constant here, and a constant field carries no information while implying
71
+ * that it does. `FailedOutput` omits one for the same reason — see its note.
72
+ */
65
73
  interface AbsentOutput {
66
74
  readonly state: "absent";
67
75
  readonly message: string;
@@ -71,9 +79,26 @@ interface PendingOutput {
71
79
  readonly status: "pending" | "running";
72
80
  readonly message: string;
73
81
  }
82
+ /**
83
+ * A recorded failure with no profile to fall back on.
84
+ *
85
+ * Carries no staleness verdict, deliberately. Whether the failed attempt covered
86
+ * the files the analysis holds NOW is not derivable here: this tool reads one
87
+ * `cortex_analysis_state` row, the current input set is the embedder's knowledge,
88
+ * and `seed_input_file_ids` is the most recently seeded set rather than a
89
+ * per-attempt snapshot (`upsertAnalysis` overwrites it). A field reporting that
90
+ * verdict could only ever hold one value, which carries no information while
91
+ * implying that it does — and the tool description, being the whole of what an
92
+ * agent knows about this tool, must not advertise a distinction it cannot make.
93
+ *
94
+ * `failedAt` is reported instead: a fact the row actually holds, and one an agent
95
+ * that knows when the input set last changed can act on.
96
+ */
74
97
  interface FailedOutput {
75
98
  readonly state: "failed";
76
99
  readonly error: string | null;
100
+ /** When the failure was recorded; `null` when the row holds no time. */
101
+ readonly failedAt: string | null;
77
102
  readonly message: string;
78
103
  }
79
104
  export type InspectDataProfileOutput = OverviewOutput | FilesOutput | AbsentOutput | PendingOutput | FailedOutput;
@@ -12,11 +12,9 @@
12
12
  * and it is why the record is served in full rather than summarized: an agent that
13
13
  * cannot pull a fact here has no fallback but to re-derive it from the raw bytes.
14
14
  *
15
- * Bounded by construction. `inspect_run` is the cautionary counter-example — it caps
16
- * its query at 50 rows with no `limit` parameter and no signal to the caller, so a
17
- * 51st run is simply invisible. Here the per-file scope is explicitly paged and always
18
- * reports `total` and `hasMore`: an elided tail is a fact the model can see and act on,
19
- * never a silent truncation.
15
+ * Bounded by construction. Like `inspect_run`, the per-file scope is explicitly
16
+ * paged and always reports `total` and `hasMore`: an elided tail is a fact the
17
+ * model can see and act on, never a silent truncation.
20
18
  *
21
19
  * Lifecycle states are data variants, not errors (the `defineTool` contract): a missing,
22
20
  * in-flight, failed, or stale profile is an ordinary, expected outcome the model must be
@@ -57,6 +55,15 @@ function stalenessReasons(status, result) {
57
55
  }
58
56
  return reasons;
59
57
  }
58
+ /**
59
+ * The qualification the `failed` message must carry. A bare `failed: <error>`
60
+ * reads as a verdict on whatever files the agent is currently holding, and
61
+ * `message` is what an agent quotes back to the user — so the limit of what this
62
+ * row can establish belongs in the prose channel, not only in the field set.
63
+ */
64
+ const FAILURE_SCOPE_NOTE = "This records an earlier attempt; this row cannot establish whether it covered the input files the analysis " +
65
+ "holds now, so do not read it as a verdict on the current data. Compare failedAt against when the input set " +
66
+ "last changed, and re-profile if they have moved apart.";
60
67
  export function createInspectDataProfileTool(pool) {
61
68
  return defineTool({
62
69
  id: "inspect_data_profile",
@@ -72,7 +79,11 @@ export function createInspectDataProfileTool(pool) {
72
79
  "always with the true total and hasMore, so you can see exactly what you have not read yet. " +
73
80
  "The state field says what you got: 'ready'; 'stale' (a profile is returned but may not describe the current " +
74
81
  "inputs — staleReason says why); 'pending' (profiling is still running); 'failed'; or 'absent' (never profiled, " +
75
- "or the analysis has no input files).",
82
+ "or the analysis has no input files). " +
83
+ "A 'failed' state reports a PAST attempt, with failedAt naming when it was recorded — it is not a verdict on the " +
84
+ "input files you are holding now, and this tool cannot tell you whether that attempt covered them. Compare failedAt " +
85
+ "against when the input set last changed before you conclude anything: if the data moved since, say the profile " +
86
+ "needs re-running rather than diagnosing the data itself.",
76
87
  inputSchema: z.object({
77
88
  scope: z
78
89
  .enum(["overview", "files"])
@@ -101,10 +112,15 @@ export function createInspectDataProfileTool(pool) {
101
112
  const result = status.result;
102
113
  if (!result) {
103
114
  if (status.status === "failed") {
115
+ // A failure that DID leave a prior profile on the row never reaches
116
+ // here — it is served as `stale`, where `stalenessReasons` names the
117
+ // failed re-profile and compares the input sets through the single
118
+ // shared predicate. This branch is the case with nothing to compare.
104
119
  return ok({
105
120
  state: "failed",
106
121
  error: status.error,
107
- message: `Data profiling failed and no earlier profile exists: ${status.error ?? "no reason recorded"}.`,
122
+ failedAt: status.completedAt,
123
+ message: `Data profiling failed and no earlier profile exists: ${status.error ?? "no reason recorded"}. ${FAILURE_SCOPE_NOTE}`,
108
124
  });
109
125
  }
110
126
  if (status.status === "completed") {
@@ -1,16 +1,9 @@
1
1
  /**
2
2
  * inspectRun — inspect workflow runs for the current analysis.
3
3
  *
4
- * Dependency-bearing: the database `Pool` is captured by the factory
5
- * (see the harness-durable-runtime spec). The analysis id is read from the request-scoped `Session`,
6
- * not an ambient request context.
7
- *
8
- * Results are pull-only via this tool — the workflow does NOT write to the
9
- * conversation thread on completion. The retired `run-result-writer` push
10
- * payload (run status, step summaries, run synthesis, artifact paths) is
11
- * surfaced here on demand: the run row + step `summaryPath` references +
12
- * the `synthesisPath` for runs whose synthesis was produced let the
13
- * conversation agent `read_file` the artefacts it needs.
4
+ * Results are pull-only: workflows never append completion messages to a
5
+ * conversation. List mode is active-first and paged. Targeted mode makes
6
+ * readiness explicit and can perform one bounded, abort-aware wait.
14
7
  */
15
8
  import type { Pool } from "pg";
16
9
  import type { CortexRunRow } from "../../state/schema.js";
@@ -20,7 +13,6 @@ interface FormattedStep {
20
13
  agentId: string;
21
14
  wave: number;
22
15
  status: string;
23
- /** Absent for steps that never ran (`pending`/`skipped`) — no output tree exists to point at. */
24
16
  summaryPath?: string;
25
17
  durationMs?: number | null;
26
18
  error?: string | null;
@@ -29,15 +21,34 @@ interface FormattedStep {
29
21
  finishReason?: string | null;
30
22
  hitMaxSteps?: boolean | null;
31
23
  }
24
+ interface WaitMetadata {
25
+ readonly requestedSeconds: number;
26
+ readonly cutoffReached: boolean;
27
+ }
32
28
  type InspectRunOutput = {
33
- message: string;
34
- } | {
35
29
  runs: FormattedRun[];
30
+ total: number;
31
+ page: number;
32
+ pageSize: number;
33
+ hasMore: boolean;
34
+ } | {
35
+ inspectionState: "not_found";
36
+ message: string;
37
+ wait?: WaitMetadata;
36
38
  } | {
39
+ inspectionState: "in_progress" | "suspended" | "terminal";
40
+ message: string;
37
41
  run: FormattedRun;
38
42
  steps: FormattedStep[];
43
+ elapsedMs?: number;
44
+ wait?: WaitMetadata;
45
+ selfWaitPrevented?: boolean;
39
46
  };
40
- declare function formatRun(r: CortexRunRow, verbose: boolean): {
47
+ interface InspectRunClock {
48
+ now(): number;
49
+ wait(ms: number, signal: AbortSignal): Promise<void>;
50
+ }
51
+ declare function formatRun(r: CortexRunRow, verbose: boolean, exposePaths?: boolean): {
41
52
  error?: string | null | undefined;
42
53
  synthesisReason?: string | undefined;
43
54
  runId: string;
@@ -49,8 +60,15 @@ declare function formatRun(r: CortexRunRow, verbose: boolean): {
49
60
  synthesisPath: string | null;
50
61
  synthesisStatus: "failed" | "produced" | "skipped_no_summaries" | "skipped_blocker" | null;
51
62
  };
52
- export declare function createInspectRunTool(pool: Pool): import("../define-tool.js").Tool<{
63
+ /**
64
+ * Build the run-inspection tool. The optional clock is an internal testing
65
+ * seam for deterministic cutoff and transition coverage.
66
+ */
67
+ export declare function createInspectRunTool(pool: Pool, clock?: InspectRunClock): import("../define-tool.js").Tool<{
53
68
  runId?: string | undefined;
69
+ page?: number | undefined;
70
+ pageSize?: number | undefined;
71
+ waitForTerminalSeconds?: number | undefined;
54
72
  includeDiagnostics?: boolean | undefined;
55
73
  }, InspectRunOutput>;
56
74
  export {};
@@ -1,25 +1,28 @@
1
1
  /**
2
2
  * inspectRun — inspect workflow runs for the current analysis.
3
3
  *
4
- * Dependency-bearing: the database `Pool` is captured by the factory
5
- * (see the harness-durable-runtime spec). The analysis id is read from the request-scoped `Session`,
6
- * not an ambient request context.
7
- *
8
- * Results are pull-only via this tool — the workflow does NOT write to the
9
- * conversation thread on completion. The retired `run-result-writer` push
10
- * payload (run status, step summaries, run synthesis, artifact paths) is
11
- * surfaced here on demand: the run row + step `summaryPath` references +
12
- * the `synthesisPath` for runs whose synthesis was produced let the
13
- * conversation agent `read_file` the artefacts it needs.
4
+ * Results are pull-only: workflows never append completion messages to a
5
+ * conversation. List mode is active-first and paged. Targeted mode makes
6
+ * readiness explicit and can perform one bounded, abort-aware wait.
14
7
  */
8
+ import { setTimeout as sleep } from "node:timers/promises";
15
9
  import { ok } from "neverthrow";
16
10
  import { z } from "zod";
17
- import { queryRun, queryRunsByAnalysis, queryStepsByRun } from "../../state/index.js";
18
11
  import { scopeResource } from "../../auth/types.js";
19
- import { SYNTHESIS_STEP_ID } from "../../workspace/paths.js";
20
12
  import { unwrapOrThrow } from "../../lib/result.js";
13
+ import { queryRun, queryRunsForInspection, queryStepsByRun } from "../../state/index.js";
14
+ import { SYNTHESIS_STEP_ID } from "../../workspace/paths.js";
21
15
  import { defineTool } from "../define-tool.js";
22
- function formatRun(r, verbose) {
16
+ const DEFAULT_PAGE_SIZE = 50;
17
+ const POLL_INTERVAL_MS = 1_000;
18
+ const systemClock = {
19
+ now: Date.now,
20
+ wait: (ms, signal) => sleep(ms, undefined, { signal }),
21
+ };
22
+ function isTerminal(status) {
23
+ return status === "completed" || status === "partial" || status === "failed" || status === "canceled";
24
+ }
25
+ function formatRun(r, verbose, exposePaths = isTerminal(r.status)) {
23
26
  return {
24
27
  runId: r.runId,
25
28
  workflowName: r.workflowName,
@@ -27,74 +30,160 @@ function formatRun(r, verbose) {
27
30
  startedAt: r.startedAt,
28
31
  completedAt: r.completedAt,
29
32
  planId: r.planId,
30
- // synthesis.json is written only when synthesis actually produced a
31
- // result; a skipped/failed synthesis leaves no file, so pointing at a
32
- // path would send the agent to a stale or absent artifact. The recorded
33
- // outcome — surfaced as synthesisStatus (and synthesisReason when set) —
34
- // lets a consumer tell "produced" from "skipped/failed/unknown" without
35
- // reading a file.
36
- synthesisPath: r.synthesisStatus === "produced" ? `runs/${r.runId}/synthesis.json` : null,
33
+ synthesisPath: exposePaths && r.synthesisStatus === "produced" ? `runs/${r.runId}/synthesis.json` : null,
37
34
  synthesisStatus: r.synthesisStatus,
38
35
  ...(r.synthesisReason !== null ? { synthesisReason: r.synthesisReason } : {}),
39
36
  ...(verbose ? { error: r.error } : {}),
40
37
  };
41
38
  }
42
- export function createInspectRunTool(pool) {
39
+ function formatSteps(runId, stepRows, verbose, exposePaths) {
40
+ return stepRows.map((s) => ({
41
+ stepId: s.stepId,
42
+ agentId: s.agentId,
43
+ wave: s.wave,
44
+ status: s.status,
45
+ ...(exposePaths && s.status !== "pending" && s.status !== "skipped" && s.stepId !== SYNTHESIS_STEP_ID
46
+ ? { summaryPath: `runs/${runId}/${s.stepId}/output/summary.md` }
47
+ : {}),
48
+ ...(verbose
49
+ ? {
50
+ durationMs: s.durationMs,
51
+ error: s.error,
52
+ attempts: s.attempts,
53
+ lastErrorClass: s.lastErrorClass,
54
+ finishReason: s.finishReason,
55
+ hitMaxSteps: s.hitMaxSteps,
56
+ }
57
+ : {}),
58
+ }));
59
+ }
60
+ const inputSchema = z
61
+ .object({
62
+ runId: z.string().optional().describe("Specific run to inspect. Omit to list runs."),
63
+ page: z.number().int().min(1).optional().describe("List page, starting at 1. Valid only without runId."),
64
+ pageSize: z.number().int().min(1).max(100).optional().describe("Rows per list page (1-100). Valid only without runId."),
65
+ waitForTerminalSeconds: z
66
+ .number()
67
+ .int()
68
+ .min(1)
69
+ .max(30)
70
+ .optional()
71
+ .describe("Wait up to this many seconds for a targeted running run to leave running state."),
72
+ includeDiagnostics: z
73
+ .boolean()
74
+ .optional()
75
+ .describe("Include failure/retry telemetry: run.error and step.{durationMs,error,attempts,lastErrorClass,finishReason,hitMaxSteps}. Default false."),
76
+ })
77
+ .superRefine((input, refinement) => {
78
+ if (input.runId && (input.page !== undefined || input.pageSize !== undefined)) {
79
+ refinement.addIssue({
80
+ code: z.ZodIssueCode.custom,
81
+ message: "page and pageSize are valid only when runId is omitted",
82
+ path: input.page !== undefined ? ["page"] : ["pageSize"],
83
+ });
84
+ }
85
+ if (!input.runId && input.waitForTerminalSeconds !== undefined) {
86
+ refinement.addIssue({
87
+ code: z.ZodIssueCode.custom,
88
+ message: "waitForTerminalSeconds requires runId",
89
+ path: ["waitForTerminalSeconds"],
90
+ });
91
+ }
92
+ });
93
+ /**
94
+ * Build the run-inspection tool. The optional clock is an internal testing
95
+ * seam for deterministic cutoff and transition coverage.
96
+ */
97
+ export function createInspectRunTool(pool, clock = systemClock) {
43
98
  return defineTool({
44
99
  id: "inspect_run",
45
- description: "Inspect workflow runs for this analysis. " +
46
- "Without a runId: lists all runs. " +
47
- "With a runId: returns step-level details with paths to summary.md, plus each run's synthesisStatus and a synthesisPath to synthesis.json only when synthesis was produced. " +
48
- "Default response is lightweight — pass includeDiagnostics:true to add error/duration/retry telemetry. " +
49
- "Read summary.md or synthesis.json for findings, or use workspace search with type:summary or type:synthesis.",
50
- inputSchema: z.object({
51
- runId: z.string().optional().describe("Specific run to inspect. Omit to list all runs."),
52
- includeDiagnostics: z
53
- .boolean()
54
- .optional()
55
- .describe("Include failure/retry telemetry: run.error and step.{durationMs,error,attempts,lastErrorClass,finishReason,hitMaxSteps}. Default false."),
56
- }),
100
+ description: "Inspect workflow runs for this analysis. Without a runId, returns an active-first paged list (running, suspended, then terminal history). " +
101
+ "With a runId, returns an explicit inspectionState and advertises output paths only after the run is terminal. " +
102
+ "For a user-directed check, waitForTerminalSeconds can wait once for 1-30 seconds; if the cutoff returns in_progress, stop polling for that turn. " +
103
+ "Default response is lightweight — pass includeDiagnostics:true to add error/duration/retry telemetry.",
104
+ inputSchema,
57
105
  execute: async (input, ctx) => {
58
106
  const resourceId = scopeResource(ctx.session.scope).resourceId;
59
107
  const verbose = input.includeDiagnostics === true;
60
108
  if (!input.runId) {
61
- const runs = unwrapOrThrow(await queryRunsByAnalysis(pool, resourceId));
62
- if (runs.length === 0) {
63
- return ok({ message: "No runs found for this analysis." });
64
- }
65
- return ok({ runs: runs.map((r) => formatRun(r, verbose)) });
109
+ const page = input.page ?? 1;
110
+ const pageSize = input.pageSize ?? DEFAULT_PAGE_SIZE;
111
+ const offset = (page - 1) * pageSize;
112
+ const result = unwrapOrThrow(await queryRunsForInspection(pool, resourceId, { limit: pageSize, offset }));
113
+ return ok({
114
+ runs: result.runs.map((run) => formatRun(run, verbose)),
115
+ total: result.total,
116
+ page,
117
+ pageSize,
118
+ hasMore: offset + result.runs.length < result.total,
119
+ });
66
120
  }
67
- const run = unwrapOrThrow(await queryRun(pool, input.runId));
121
+ const waitSeconds = input.waitForTerminalSeconds;
122
+ let run = unwrapOrThrow(await queryRun(pool, input.runId));
68
123
  if (!run || run.analysisId !== resourceId) {
69
- return ok({ message: `Run ${input.runId} not found.` });
124
+ return ok({
125
+ inspectionState: "not_found",
126
+ message: `Run ${input.runId} was not found in this analysis.`,
127
+ ...(waitSeconds === undefined ? {} : { wait: { requestedSeconds: waitSeconds, cutoffReached: false } }),
128
+ });
70
129
  }
71
- const stepRows = unwrapOrThrow(await queryStepsByRun(pool, input.runId));
72
- const steps = stepRows.map((s) => ({
73
- stepId: s.stepId,
74
- agentId: s.agentId,
75
- wave: s.wave,
76
- status: s.status,
77
- // No per-step `summaryPath` when the file cannot exist: a seeded row
78
- // (`pending`/`skipped`) never produced output, and the reserved
79
- // `synthesis` phase is not a sandbox step — it writes the run-level
80
- // `synthesis.json`, surfaced as the run's `synthesisPath`, not a
81
- // `{stepId}/output/summary.md`. Emitting the per-step path for it would
82
- // point the agent at a file that never exists.
83
- ...(s.status === "pending" || s.status === "skipped" || s.stepId === SYNTHESIS_STEP_ID
84
- ? {}
85
- : { summaryPath: `runs/${input.runId}/${s.stepId}/output/summary.md` }),
86
- ...(verbose
87
- ? {
88
- durationMs: s.durationMs,
89
- error: s.error,
90
- attempts: s.attempts,
91
- lastErrorClass: s.lastErrorClass,
92
- finishReason: s.finishReason,
93
- hitMaxSteps: s.hitMaxSteps,
130
+ const selfWait = waitSeconds !== undefined && ctx.session.runFrame?.runId === input.runId;
131
+ let cutoffReached = false;
132
+ if (waitSeconds !== undefined && run.status === "running" && !selfWait) {
133
+ const deadline = clock.now() + waitSeconds * 1_000;
134
+ while (run.status === "running") {
135
+ const remaining = deadline - clock.now();
136
+ if (remaining <= 0) {
137
+ cutoffReached = true;
138
+ break;
94
139
  }
95
- : {}),
96
- }));
97
- return ok({ run: formatRun(run, verbose), steps });
140
+ await clock.wait(Math.min(POLL_INTERVAL_MS, remaining), ctx.signal);
141
+ run = unwrapOrThrow(await queryRun(pool, input.runId));
142
+ if (!run || run.analysisId !== resourceId) {
143
+ return ok({
144
+ inspectionState: "not_found",
145
+ message: `Run ${input.runId} was not found in this analysis.`,
146
+ wait: { requestedSeconds: waitSeconds, cutoffReached: false },
147
+ });
148
+ }
149
+ }
150
+ }
151
+ const terminal = isTerminal(run.status);
152
+ const stepRows = unwrapOrThrow(await queryStepsByRun(pool, input.runId));
153
+ const steps = formatSteps(input.runId, stepRows, verbose, terminal);
154
+ const wait = waitSeconds === undefined ? {} : { wait: { requestedSeconds: waitSeconds, cutoffReached } };
155
+ if (run.status === "running") {
156
+ const message = selfWait
157
+ ? "This run is still in progress. Results are not ready, and the enclosing workflow cannot finish while its current step waits for itself."
158
+ : cutoffReached
159
+ ? "This run is still in progress after the requested wait cutoff. Results are not ready; stop polling for this turn."
160
+ : "This run is in progress. Results are not ready.";
161
+ return ok({
162
+ inspectionState: "in_progress",
163
+ message,
164
+ run: formatRun(run, verbose, false),
165
+ steps,
166
+ elapsedMs: Math.max(0, clock.now() - Date.parse(run.startedAt)),
167
+ ...wait,
168
+ ...(selfWait ? { selfWaitPrevented: true } : {}),
169
+ });
170
+ }
171
+ if (run.status === "suspended_insufficient_funds") {
172
+ return ok({
173
+ inspectionState: "suspended",
174
+ message: "This run is suspended and will not progress until it is resumed.",
175
+ run: formatRun(run, verbose, false),
176
+ steps,
177
+ ...wait,
178
+ });
179
+ }
180
+ return ok({
181
+ inspectionState: "terminal",
182
+ message: `This run is terminal with status ${run.status}.`,
183
+ run: formatRun(run, verbose, true),
184
+ steps,
185
+ ...wait,
186
+ });
98
187
  },
99
188
  });
100
189
  }
@@ -22,14 +22,11 @@ import { literatureReviewerPrompt } from "../../prompts/literature-reviewer.js";
22
22
  import { composeSystemPrompt } from "../../agents/system-prompt.js";
23
23
  import { forSubAgent } from "../../auth/types.js";
24
24
  import { defineTool } from "../define-tool.js";
25
- import { createNcbiTools } from "../bio/keys.js";
26
- import { getImpcKoProfileTool } from "../bio/get-impc-ko-profile.js";
27
- import { lookupGoTermTool } from "../bio/lookup-go-term.js";
28
- import { searchBgeeExpressionTool } from "../bio/search-bgee-expression.js";
29
- import { createSearchDgidbTool } from "../bio/search-dgidb.js";
25
+ import { createChemDbTools, createNcbiTools } from "../bio/keys.js";
26
+ import { genePreclinicalProfileTool } from "../bio/gene-preclinical-profile.js";
27
+ import { lookupAnnotationTool } from "../bio/lookup-annotation.js";
30
28
  import { searchGeneTool } from "../bio/search-gene.js";
31
29
  import { searchInteractionsTool } from "../bio/search-interactions.js";
32
- import { searchPathwayTool } from "../bio/search-pathway.js";
33
30
  /** Sub-agent identity — appended to `callPath`, set as `agentId`. */
34
31
  const AGENT_ID = "literature-reviewer";
35
32
  /** Multi-tool research budget — search → read → synthesize over many genes. */
@@ -37,15 +34,14 @@ const REVIEWER_MAX_ITERATIONS = 30;
37
34
  /** Build the `literature_reviewer` delegation tool bound to its provider. */
38
35
  export function createLiteratureReviewerTool(deps) {
39
36
  const ncbi = createNcbiTools(deps.bioKeys);
37
+ const chemDb = createChemDbTools(deps.bioKeys, { ...(deps.logger ? { logger: deps.logger } : {}) });
40
38
  const reviewerTools = [
41
39
  searchGeneTool,
42
- searchPathwayTool,
43
- lookupGoTermTool,
40
+ lookupAnnotationTool,
44
41
  searchInteractionsTool,
45
42
  ncbi.pubmed,
46
- createSearchDgidbTool({ ...(deps.logger ? { logger: deps.logger } : {}) }),
47
- searchBgeeExpressionTool,
48
- getImpcKoProfileTool,
43
+ chemDb.drugGeneInteractions,
44
+ genePreclinicalProfileTool,
49
45
  ];
50
46
  const agent = {
51
47
  id: AGENT_ID,
@@ -209,6 +209,49 @@ export type RunProvenanceEvent = {
209
209
  /** `atMs − run_started.atMs`: the workflow-observed run span in ms. */
210
210
  durationMs: number;
211
211
  };
212
+ /**
213
+ * A step's in-run display state. Wider than the ledger's `StepExecutionStatus`: `queued` is a
214
+ * scheduler-only condition (dependency-satisfied but held for budget capacity) that never reaches a
215
+ * database row, and `skipped` is asserted mid-run for a doomed dependent whose ledger row stays
216
+ * `pending` until the terminal sweep.
217
+ */
218
+ export type StepRuntimeStatus = "pending" | "queued" | "running" | "completed" | "failed" | "skipped";
219
+ /**
220
+ * One step's state inside a {@link RunObservation}. `id` is the plan slug the ledger and the
221
+ * dependency edges use; `name` is the human phrase the plan author wrote, which is the only field
222
+ * that says what the step is FOR. `durationMs` and `error` appear only once the step has settled
223
+ * in a way that produced them.
224
+ */
225
+ export interface RunStepObservation {
226
+ readonly id: string;
227
+ readonly name: string;
228
+ readonly agent: string;
229
+ readonly status: StepRuntimeStatus;
230
+ readonly durationMs?: number;
231
+ readonly error?: string;
232
+ }
233
+ /**
234
+ * A whole-run snapshot handed to {@link ExecuteAnalysisDeps.observeRun}.
235
+ *
236
+ * Deliberately a SNAPSHOT rather than a transition event. The observer is invoked from the
237
+ * workflow body, so DBOS re-execution on recovery re-fires the whole sequence; a re-fired snapshot
238
+ * is idempotent for any consumer that renders from the newest one it has seen, whereas re-fired
239
+ * granular events would force every consumer to rebuild ordering and dedupe by identity. A
240
+ * consumer that misses an invocation loses nothing permanent — the next transition restates the
241
+ * full truth.
242
+ *
243
+ * Derived aggregates (completion counts, run duration) are deliberately ABSENT. They are
244
+ * computable from `steps` and recorded authoritatively on the run's ledger row; carrying them here
245
+ * would mint a second source that can disagree with the ledger a host also reads.
246
+ */
247
+ export interface RunObservation {
248
+ readonly runId: string;
249
+ readonly analysisId: string;
250
+ /** `running` for every mid-flight snapshot; the derived terminal status on the last one. */
251
+ readonly status: ExecuteAnalysisFinalStatus;
252
+ /** Every plan step, including ones that have not started — never a delta. */
253
+ readonly steps: readonly RunStepObservation[];
254
+ }
212
255
  /**
213
256
  * Construction-time deps for the parent workflow. The order-of-operations is
214
257
  * the body's own: it brackets the run, dispatches children, and synthesizes
@@ -247,6 +290,26 @@ export interface ExecuteAnalysisDeps {
247
290
  * guarded so a throwing observer never fails the run.
248
291
  */
249
292
  readonly emitProvenance?: (event: RunProvenanceEvent) => void;
293
+ /**
294
+ * Optional, fire-and-forget observation of the run's live shape, for a host driving a UI.
295
+ *
296
+ * Independent of {@link emitProvenance} in every direction: neither is implemented in terms of
297
+ * the other, they share no payload, and supplying one does not imply the other. They exist
298
+ * separately because provenance closes a signed, hash-chained record under a single-writer
299
+ * lock, while this is a lossy-tolerant display channel — coupling them would put a UI repaint
300
+ * behind the chain's write discipline and force provenance to carry step names and agent ids it
301
+ * has no reason to record.
302
+ *
303
+ * Invoked directly in the workflow body (NOT wrapped in `DBOS.runStep`) for the same reason
304
+ * `emitProvenance` is: body re-execution on recovery must re-fire it, and a cached step would
305
+ * suppress that. Idempotency comes free for rendering because the payload is a whole-run
306
+ * snapshot; a host taking a DURABLE side effect on one must key it by run id and status itself.
307
+ *
308
+ * Synchronous by signature (`void`, never a promise) so a host needing I/O has to dispatch it
309
+ * rather than await it inside the body's critical path. Every call site is guarded, so a
310
+ * throwing observer never fails the run.
311
+ */
312
+ readonly observeRun?: (observation: RunObservation) => void;
250
313
  }
251
314
  /**
252
315
  * Project the sandbox-step input handed to each child. The parent owns the