@inflexa-ai/harness 0.13.0 → 0.14.0

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 +50 -5
  38. package/dist/loop/run-to-terminal.d.ts +0 -2
  39. package/dist/loop/run-to-terminal.js +8 -1
  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 +115 -69
  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
@@ -139,6 +139,38 @@ export function queryActiveRun(pool, analysisId, planId) {
139
139
  return row ? mapRunRow(row) : null;
140
140
  });
141
141
  }
142
+ /**
143
+ * Every NON-TERMINAL run of an analysis, newest-first and deliberately unbounded.
144
+ *
145
+ * The counterpart to {@link queryRunsByAnalysis}, which answers "the newest N runs" — a windowed
146
+ * question, and the wrong one for a host driving live surfaces. Ordering by `started_at DESC` and
147
+ * capping means the run most worth watching is the first to fall out: a long analysis still going
148
+ * while ten short ones start and finish after it leaves the window entirely, so a host reading only
149
+ * that window loses its progress AND never learns it terminated.
150
+ *
151
+ * Unbounded is safe here in a way it is not for the general listing: this result is capped by the
152
+ * analysis's live concurrency, not by its history, and a run leaves the set permanently when it
153
+ * finishes.
154
+ *
155
+ * The status set is the one {@link queryActiveRun} uses — non-terminal means still running, or
156
+ * suspended awaiting funds. Keep the two in step: a new non-terminal status added to one and not the
157
+ * other makes a live run invisible to exactly one of its readers.
158
+ */
159
+ export function queryActiveRunsByAnalysis(pool, analysisId) {
160
+ return tryQuery("runs.queryActiveRunsByAnalysis", async () => {
161
+ const result = await pool.query({
162
+ text: `SELECT run_id, analysis_id, thread_id, workflow_name,
163
+ status, started_at, completed_at, error, synthesis_status, synthesis_reason, parts,
164
+ mandate_jti, mandate_expires_at, plan_id -- oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
165
+ FROM cortex_runs
166
+ WHERE analysis_id = $1
167
+ AND status IN ('running','suspended_insufficient_funds')
168
+ ORDER BY started_at DESC`,
169
+ values: [analysisId],
170
+ });
171
+ return result.rows.map(mapRunRow);
172
+ });
173
+ }
142
174
  export function updateRunStatus(pool, runId, status, error) {
143
175
  const now = new Date().toISOString();
144
176
  const isTerminal = status === "completed" || status === "partial" || status === "failed" || status === "canceled";
@@ -189,6 +221,77 @@ export function queryRunsByAnalysis(pool, analysisId, { limit = 50, offset = 0 }
189
221
  return result.rows.map(mapRunRow);
190
222
  });
191
223
  }
224
+ /**
225
+ * Read the analysis's non-terminal run activity without coupling it to a
226
+ * conversation thread. The total is counted before the detail limit so a
227
+ * bounded prompt projection can disclose omitted rows.
228
+ */
229
+ export function queryNonTerminalRunsByAnalysis(pool, analysisId, limit = 20) {
230
+ return tryQuery("runs.queryNonTerminalRunsByAnalysis", async () => {
231
+ const countResult = await pool.query({
232
+ text: `SELECT COUNT(*)::int AS total
233
+ FROM cortex_runs
234
+ WHERE analysis_id = $1
235
+ AND status IN ('running','suspended_insufficient_funds')`,
236
+ values: [analysisId],
237
+ });
238
+ const rowsResult = await pool.query({
239
+ text: `SELECT run_id, analysis_id, thread_id, workflow_name,
240
+ status, started_at, completed_at, error, synthesis_status, synthesis_reason, parts,
241
+ mandate_jti, mandate_expires_at, plan_id
242
+ FROM cortex_runs
243
+ WHERE analysis_id = $1
244
+ AND status IN ('running','suspended_insufficient_funds')
245
+ ORDER BY CASE status
246
+ WHEN 'running' THEN 0
247
+ WHEN 'suspended_insufficient_funds' THEN 1
248
+ ELSE 2
249
+ END,
250
+ started_at DESC,
251
+ run_id DESC
252
+ LIMIT $2`,
253
+ values: [analysisId, limit],
254
+ });
255
+ return {
256
+ runs: rowsResult.rows.map(mapRunRow),
257
+ total: Number(countResult.rows[0]?.total ?? 0),
258
+ };
259
+ });
260
+ }
261
+ /**
262
+ * Read one stable inspection page: running first, then suspended, then
263
+ * terminal history. This is deliberately separate from
264
+ * {@link queryRunsByAnalysis}, whose chronological order is an embedder/UI
265
+ * contract.
266
+ */
267
+ export function queryRunsForInspection(pool, analysisId, { limit = 50, offset = 0 } = {}) {
268
+ return tryQuery("runs.queryRunsForInspection", async () => {
269
+ const countResult = await pool.query({
270
+ text: "SELECT COUNT(*)::int AS total FROM cortex_runs WHERE analysis_id = $1",
271
+ values: [analysisId],
272
+ });
273
+ const rowsResult = await pool.query({
274
+ text: `SELECT run_id, analysis_id, thread_id, workflow_name,
275
+ status, started_at, completed_at, error, synthesis_status, synthesis_reason, parts,
276
+ mandate_jti, mandate_expires_at, plan_id
277
+ FROM cortex_runs
278
+ WHERE analysis_id = $1
279
+ ORDER BY CASE status
280
+ WHEN 'running' THEN 0
281
+ WHEN 'suspended_insufficient_funds' THEN 1
282
+ ELSE 2
283
+ END,
284
+ started_at DESC,
285
+ run_id DESC
286
+ LIMIT $2 OFFSET $3`,
287
+ values: [analysisId, limit, offset],
288
+ });
289
+ return {
290
+ runs: rowsResult.rows.map(mapRunRow),
291
+ total: Number(countResult.rows[0]?.total ?? 0),
292
+ };
293
+ });
294
+ }
192
295
  export function queryRunsByThread(pool, analysisId, threadId, { limit = 50, offset = 0 } = {}) {
193
296
  return tryQuery("runs.queryRunsByThread", async () => {
194
297
  const result = await pool.query({
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The data-profile workflow's activity emissions, as a pure factory over a raw
3
+ * stream write.
4
+ *
5
+ * Extracted from the workflow body for the same reason `createLineageCollector`
6
+ * and `buildDriftSignature` are: a DBOS workflow body is not reachable from a
7
+ * unit test — it needs a launched engine, a sandbox, a scripted model, and an
8
+ * embedder — so the decidable part is lifted out and tested on its own. What is
9
+ * decidable here is the whole observable contract of this capability: which
10
+ * phase each moment of a profile reports, and in what words. Those phrases are
11
+ * what a user reads, so they are the one part of the work that must not be left
12
+ * to drift.
13
+ *
14
+ * What stays in the body is only control flow — where each call sits relative to
15
+ * the ledger writes. That is not extractable, and the ordering property it
16
+ * carries (exactly one terminal emission) is guaranteed structurally instead:
17
+ * there is exactly one `complete` call site and it is the last statement of the
18
+ * success path, after the terminal ledger write and the authorization revoke, so
19
+ * anything that throws on the way there reaches the failure path having emitted
20
+ * no terminal at all.
21
+ */
22
+ import type { Logger } from "../lib/logger.js";
23
+ /**
24
+ * Writes one part to the run-event stream. Injected rather than imported so this
25
+ * module stays free of the durability engine and so a test can record what a
26
+ * profile would have reported without launching one.
27
+ */
28
+ export type ActivityWrite = (part: unknown) => Promise<void>;
29
+ /**
30
+ * The profile's activity channel. One method per moment the body reports, rather
31
+ * than a generic `emit(phase, text)`: the phrases are normative, and a call site
32
+ * that passes its own string is a call site that can drift from the spec.
33
+ *
34
+ * Every method resolves even when the underlying write fails — see
35
+ * {@link createProfileActivityEmitter}.
36
+ *
37
+ * Five of the contract's phases have no method here, and their absence is a
38
+ * decision rather than an oversight: `generating-metadata` and
39
+ * `generating-summary` describe the sandbox-step's post-agent pipeline, which a
40
+ * profile does not run (its agent delivers everything through `submit_profile`);
41
+ * `persisting` is defined as step bytes uploading to an artifact store, and a
42
+ * profile's durable products are the vector index and the ledger row, so it would
43
+ * describe nothing; `retrying` needs a retry loop, and a profile failure is
44
+ * terminal with recovery being a fresh attempt; `warning` needs a non-fatal
45
+ * user-facing warning channel, and the body's two soft conditions (an empty
46
+ * manifest, a fallback file description) are logged because neither warrants
47
+ * interrupting the activity line.
48
+ */
49
+ export interface ProfileActivityEmitter {
50
+ /** Reported before the sandbox is created — the longest wait in a profile, and the one that precedes the agent entirely. */
51
+ sandboxInit(): Promise<void>;
52
+ /** Reported once the sandbox is ready, covering the gap before the agent's first tool call. */
53
+ agentStarting(): Promise<void>;
54
+ /** Reported per tool call the profiler agent makes, phrased by the shared translator. */
55
+ forTool(name: string, input: unknown): Promise<void>;
56
+ /** Reported for the vector-store pass that indexes per-file descriptions. */
57
+ indexing(): Promise<void>;
58
+ /** The success terminal. Emitted only after the terminal ledger write has landed. */
59
+ complete(): Promise<void>;
60
+ /** The failure terminal, carrying the same user-safe reason the ledger receives. */
61
+ failed(reason: string): Promise<void>;
62
+ }
63
+ /** Identity the emitted parts carry — the workflow's synthetic frame, not a real run. */
64
+ export interface ProfileActivityFrame {
65
+ /**
66
+ * The constant literal the data-profile workflow uses as its run id. It is the
67
+ * same string for every analysis, so it identifies nothing; consumers are
68
+ * required not to key on it (see the data-profile-observation spec). It is
69
+ * carried because the part contract requires the field and because this is the
70
+ * frame the workflow already uses for its sandbox identity and scratch path.
71
+ */
72
+ readonly runId: string;
73
+ /** The constant literal step id of the profile's single synthetic step. */
74
+ readonly stepId: string;
75
+ }
76
+ /**
77
+ * Build the profile's activity channel over `write`.
78
+ *
79
+ * Every emission is awaited by its caller in body order, because `DBOS.writeStream`
80
+ * allocates a function id and an unawaited write would race the next real operation
81
+ * for the counter, desynchronising the recorded sequence on replay.
82
+ *
83
+ * A failing write is swallowed and logged, never propagated: observation is a
84
+ * diagnostic channel, and a dropped frame must not fail a profile that is otherwise
85
+ * succeeding. This mirrors the sandbox-step producer's stance on the same write.
86
+ */
87
+ export declare function createProfileActivityEmitter(write: ActivityWrite, frame: ProfileActivityFrame, logger: Logger): ProfileActivityEmitter;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The data-profile workflow's activity emissions, as a pure factory over a raw
3
+ * stream write.
4
+ *
5
+ * Extracted from the workflow body for the same reason `createLineageCollector`
6
+ * and `buildDriftSignature` are: a DBOS workflow body is not reachable from a
7
+ * unit test — it needs a launched engine, a sandbox, a scripted model, and an
8
+ * embedder — so the decidable part is lifted out and tested on its own. What is
9
+ * decidable here is the whole observable contract of this capability: which
10
+ * phase each moment of a profile reports, and in what words. Those phrases are
11
+ * what a user reads, so they are the one part of the work that must not be left
12
+ * to drift.
13
+ *
14
+ * What stays in the body is only control flow — where each call sits relative to
15
+ * the ledger writes. That is not extractable, and the ordering property it
16
+ * carries (exactly one terminal emission) is guaranteed structurally instead:
17
+ * there is exactly one `complete` call site and it is the last statement of the
18
+ * success path, after the terminal ledger write and the authorization revoke, so
19
+ * anything that throws on the way there reaches the failure path having emitted
20
+ * no terminal at all.
21
+ */
22
+ import { activityForTool, stepPartId } from "../sandbox/sandbox-step-translate.js";
23
+ /**
24
+ * Build the profile's activity channel over `write`.
25
+ *
26
+ * Every emission is awaited by its caller in body order, because `DBOS.writeStream`
27
+ * allocates a function id and an unawaited write would race the next real operation
28
+ * for the counter, desynchronising the recorded sequence on replay.
29
+ *
30
+ * A failing write is swallowed and logged, never propagated: observation is a
31
+ * diagnostic channel, and a dropped frame must not fail a profile that is otherwise
32
+ * succeeding. This mirrors the sandbox-step producer's stance on the same write.
33
+ */
34
+ export function createProfileActivityEmitter(write, frame, logger) {
35
+ // One reconciling id for the profile's single step, so the run-stream fold
36
+ // collapses every phase transition latest-wins onto one frame rather than
37
+ // accumulating them. Minted through the shared construction because that
38
+ // sameness across producers is what makes the fold correct.
39
+ const id = stepPartId("step-activity", frame.runId, frame.stepId);
40
+ const emit = async (phase, activity) => {
41
+ try {
42
+ await write({ type: "data-step-activity", id, runId: frame.runId, stepId: frame.stepId, phase, activity });
43
+ }
44
+ catch (err) {
45
+ logger.warn("run-event write failed (non-fatal)", { phase, ...logger.errorFields(err) });
46
+ }
47
+ };
48
+ return {
49
+ sandboxInit: () => emit("sandbox-init", "Starting sandbox"),
50
+ agentStarting: () => emit("executing", "Running data-profiler"),
51
+ forTool: (name, input) => emit("executing", activityForTool(name, input)),
52
+ indexing: () => emit("indexing", "Indexing input descriptions for search"),
53
+ complete: () => emit("complete", "Profile complete"),
54
+ failed: (reason) => emit("failed", reason),
55
+ };
56
+ }
@@ -33,7 +33,8 @@ import { generateExecutionId } from "../sandbox/execution-id.js";
33
33
  import { mintSandboxIdentity } from "../sandbox/identity.js";
34
34
  import { createVectorStore } from "../state/vector-store.js";
35
35
  import { ProfilerOutputSchema } from "../schemas/data-profile-schemas.js";
36
- import { completeDataProfile, failDataProfile, loadDataProfileStatus, loadSeedInputFileIds, tryRerunDataProfile, tryStartDataProfile, upsertArtifacts, } from "../state/index.js";
36
+ import { completeDataProfile, failDataProfile, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, tryRerunDataProfile, tryStartDataProfile, upsertArtifacts, } from "../state/index.js";
37
+ import { createProfileActivityEmitter } from "./data-profile-activity.js";
37
38
  import { ensureSearchIndex, searchIndexName } from "../workspace/search-config.js";
38
39
  /** Synthetic run/step literal for the data-profile workflow. */
39
40
  const DATA_PROFILE_RUN_LITERAL = "data-profile";
@@ -135,7 +136,29 @@ export async function runDataProfileBody(input, deps) {
135
136
  // Cortex-owned and must be revoked here.
136
137
  const { analysisId, runSession, ownsMandate = true, stagedInputs } = input; // oss-core-managed-ok
137
138
  const authorization = { runSession, ownsMandate }; // oss-core-managed-ok
139
+ // The profile's activity channel. `DBOS.writeStream` is body-only, so the write is bound here
140
+ // while every phase and phrase lives in the emitter — the phrases are the observable contract of
141
+ // this capability, and a body that composed its own strings is a body they can drift from.
142
+ // The frame is the workflow's synthetic one: both values are constants shared by every analysis,
143
+ // so they identify nothing and consumers are required not to key on them.
144
+ const activity = createProfileActivityEmitter((part) => DBOS.writeStream("events", part), { runId: DATA_PROFILE_RUN_LITERAL, stepId: DATA_PROFILE_STEP_LITERAL }, logger);
138
145
  try {
146
+ // Record which workflow owns this profile before anything else, so a consumer that resolves
147
+ // the id from the ledger can subscribe from the earliest possible moment. Guarded on a real
148
+ // ambient id: this body is also driven directly (outside a workflow), where the synthetic
149
+ // fallback used further down names no workflow at all and must never reach the ledger.
150
+ const ownWorkflowId = DBOS.workflowID;
151
+ if (ownWorkflowId !== undefined) {
152
+ await DBOS.runStep(async () => {
153
+ // A refused CAS is a normal in-band outcome, not a failure: the row left
154
+ // `running` underneath us (cleared, or stale-expired and re-claimed), which is
155
+ // the ledger correctly moving on. There is nothing to repair, and the profile's
156
+ // own work is unaffected — only its observability is.
157
+ if (!unwrapOrThrow(await recordDataProfileWorkflowId(deps.pool, analysisId, ownWorkflowId))) {
158
+ logger.warn("workflow-id record skipped: ledger row not running (cleared or re-claimed concurrently)");
159
+ }
160
+ }, { name: "record-workflow-id" });
161
+ }
139
162
  // 1. Input files are already staged under data/inputs/ (see the data-profile-init spec); the
140
163
  // embedder populated the tree and handed us this manifest in the workflow
141
164
  // input. The body never downloads.
@@ -144,6 +167,9 @@ export async function runDataProfileBody(input, deps) {
144
167
  if (!unwrapOrThrow(await completeDataProfile(deps.pool, analysisId)))
145
168
  logTerminalNoop(logger, analysisId, "completion");
146
169
  await deps.runAuthorizer.revoke(authorization, "data-profile-completed");
170
+ // This is a terminal completion like any other, so it reports one — a consumer watching
171
+ // an empty-manifest profile sees it settle rather than seeing the stream simply stop.
172
+ await activity.complete();
147
173
  return;
148
174
  }
149
175
  // 2. Register staged files in cortex_artifacts
@@ -193,6 +219,11 @@ export async function runDataProfileBody(input, deps) {
193
219
  `All file paths in your response must be relative to the analysis root and must EXACTLY match the paths listed above.`,
194
220
  ].join("\n");
195
221
  logger.info("starting sandbox", { executionId });
222
+ // Reported BEFORE the container exists, deliberately: provisioning is the longest single
223
+ // operation in a profile and it precedes the agent entirely, so a body that reported only
224
+ // from the agent loop would leave the longest wait of all unreported — the same defect the
225
+ // run panel's activity readout was built to remove.
226
+ await activity.sandboxInit();
196
227
  const sandbox = await deps.sandboxClient.createSandbox({
197
228
  runId: DATA_PROFILE_RUN_LITERAL,
198
229
  stepId: DATA_PROFILE_STEP_LITERAL,
@@ -247,13 +278,27 @@ export async function runDataProfileBody(input, deps) {
247
278
  const baseAgent = createDataProfilerAgent(sandboxAgentDeps);
248
279
  const agentDef = { ...baseAgent, tools: [...baseAgent.tools, submitProfileTool] };
249
280
  const signal = new AbortController().signal;
281
+ // Covers the gap between a ready sandbox and the agent's first tool call, which would
282
+ // otherwise still read as `Starting sandbox` long after the container was up. The run
283
+ // path's `Running ${agentId}` serves the same purpose at the same point.
284
+ await activity.agentStarting();
250
285
  await runToTerminal(agentDef, [{ role: "user", content: prompt }], childSession, {
251
286
  provider: deps.provider,
252
287
  signal,
253
- emit: () => { },
288
+ // Every tool call the profiler makes becomes live activity. Only `tool-started`
289
+ // is forwarded: the loop's other events (iteration boundaries, model deltas,
290
+ // tool completions) describe machinery rather than work, and the panel this
291
+ // feeds carries a single line whose value is that it always names what is
292
+ // happening now. Awaited in body order, because `DBOS.writeStream` allocates a
293
+ // function id and an unawaited write would race the next operation for the
294
+ // counter and desynchronise the recorded sequence on replay.
295
+ emit: async (event) => {
296
+ if (event.type === "tool-started")
297
+ await activity.forTool(event.name, event.input);
298
+ },
254
299
  runStep: durableStep,
255
- }, {
256
300
  resolved: () => capturedProfile !== null,
301
+ }, {
257
302
  tools: [submitProfileTool],
258
303
  nudge: "You stopped without calling submit_profile, so no profile was " +
259
304
  "recorded. Call submit_profile now with structured metadata for " +
@@ -264,6 +309,11 @@ export async function runDataProfileBody(input, deps) {
264
309
  }
265
310
  const profilerData = capturedProfile;
266
311
  // 4. Index into vector store — real files only, consistent type metadata
312
+ //
313
+ // Reported as `indexing`, not `persisting`: the contract defines `persisting` as step
314
+ // bytes uploading to an artifact store, and a profile uploads nothing — its durable
315
+ // products are this vector index and the ledger row.
316
+ await activity.indexing();
267
317
  const profilerByPath = new Map(profilerData.files.map((f) => [f.path, f]));
268
318
  const normPath = (p) => p
269
319
  .replace(/\/+/g, "/")
@@ -326,6 +376,13 @@ export async function runDataProfileBody(input, deps) {
326
376
  logTerminalNoop(logger, analysisId, "completion");
327
377
  }
328
378
  await deps.runAuthorizer.revoke(authorization, "data-profile-completed");
379
+ // LAST statement of the success path, and that placement is the guarantee that exactly
380
+ // one terminal activity is ever emitted. Everything that could still throw — the ledger
381
+ // write above, the revoke — is already behind us, so any failure on the way here reaches
382
+ // the catch having emitted no terminal at all and reports `failed` alone. Emitted any
383
+ // earlier, a failing write would produce BOTH terminals and leave the fold's winner
384
+ // decided by arrival order. The sandbox teardown below emits nothing for the same reason.
385
+ await activity.complete();
329
386
  }
330
387
  finally {
331
388
  try {
@@ -338,9 +395,14 @@ export async function runDataProfileBody(input, deps) {
338
395
  }
339
396
  catch (err) {
340
397
  logger.error("profile failed", logger.errorFields(err));
341
- if (!unwrapOrThrow(await failDataProfile(deps.pool, analysisId, profileFailureReason(err))))
398
+ const reason = profileFailureReason(err);
399
+ if (!unwrapOrThrow(await failDataProfile(deps.pool, analysisId, reason)))
342
400
  logTerminalNoop(logger, analysisId, "failure");
343
401
  await deps.runAuthorizer.revoke(authorization, "data-profile-failed");
402
+ // The same bounded, user-safe line the ledger receives — never the raw error, whose paths and
403
+ // stack frames stay in the log record above. This is the only other terminal emission, so
404
+ // reaching it means the success path did not emit one.
405
+ await activity.failed(reason);
344
406
  }
345
407
  }
346
408
  /**
@@ -28,7 +28,7 @@ export type ChemblOutput = {
28
28
  readonly targets: ChemblTarget[];
29
29
  };
30
30
  export declare const chemblTool: import("../define-tool.js").Tool<{
31
- action: "targets" | "drug" | "compounds" | "mechanism" | "bioactivity";
31
+ action: "drug" | "targets" | "compounds" | "mechanism" | "bioactivity";
32
32
  query?: string | undefined;
33
33
  searchType?: "target" | "smiles" | "compound" | undefined;
34
34
  chemblId?: string | undefined;
@@ -18,74 +18,74 @@ import { ok } from "neverthrow";
18
18
  import { z } from "zod";
19
19
  import { defineTool } from "../define-tool.js";
20
20
  import { getBioactivity, getDrugInfo, getMechanism, searchCompounds, searchTargets } from "../lib/chembl-client.js";
21
- /** Per-action record caps, preserved from the five tools this one replaces. */
21
+ /**
22
+ * Per-action record caps. The ceilings are what the endpoints will serve; the
23
+ * defaults are deliberately far below them — 500 activity rows is ~125k
24
+ * characters, which answers no question that the top 25 by potency does not.
25
+ */
22
26
  const LIMITS = {
23
27
  /** `compounds` / `bioactivity` — activity-scale reads. */
24
- wide: 500,
28
+ wideMax: 500,
29
+ wideDefault: 25,
25
30
  /** `drug` / `targets` — resolution reads, where a long list is noise. */
26
- narrow: 25,
31
+ narrowMax: 25,
32
+ narrowDefault: 10,
27
33
  };
28
34
  const inputSchema = z
29
35
  .object({
30
36
  action: z
31
37
  .enum(["compounds", "drug", "mechanism", "bioactivity", "targets"])
32
- .describe("Which ChEMBL lookup to run — each names the params it needs and the fields it returns.\n" +
33
- "'compounds' (query + searchType) — molecules by target, name, or SMILES; resolves a named compound to its ChEMBL ID " +
34
- "and structure, or lists the compounds assayed against a target. Returns chemblId, preferredCompoundName, " +
35
- "canonicalSmiles, molecularWeight, alogp, molecularFormula.\n" +
36
- "'drug' (query) — the drug registry by drug name or disease indication: 'what drugs treat X?', 'is Y approved, and " +
37
- "since when?'. Returns moleculeChemblId, preferredName, maxPhase (4 = approved), moleculeType (small molecule, " +
38
- "antibody, …), firstApproval year, indication. When the drug endpoint is empty it falls back to a molecule search " +
39
- "filtered to max_phase >= 4; rows from that fallback carry indication: null.\n" +
40
- "'mechanism' (chemblId) — the curated mechanism of action of ONE molecule: 'how does drug X work?'. Returns " +
41
- "mechanismOfAction (prose), actionType (INHIBITOR, AGONIST, ANTAGONIST, …), targetChemblId + the resolved targetName, " +
42
- "moleculeChemblId. ChEMBL curates mechanisms mainly for clinical/approved molecules, so tool compounds often have none.\n" +
43
- "'bioactivity' (chemblId + idType) — measured activity rows (IC50, EC50, Ki, Kd, …): the curated, quotable potency " +
44
- "data for a compound or a target. Returns standardType, standardValue + standardUnits, pchemblValue (normalized " +
45
- "-log10 potency), assayChemblId, assayType, compoundChemblId, targetChemblId.\n" +
46
- "'targets' (query) — resolve a gene symbol or protein name to a ChEMBL target: the step that produces the target ID " +
47
- "used by bioactivity (idType='target') and compounds (searchType='target'). Returns targetChemblId, preferredName, " +
48
- "targetType (SINGLE PROTEIN, PROTEIN COMPLEX, …), organism, geneNames. Results span organisms — check `organism` " +
49
- "before using an ID, since the top hit for a human gene symbol may be a non-human ortholog."),
38
+ .describe("Which ChEMBL lookup to run; each names its params and return fields.\n" +
39
+ "'compounds' (query + searchType) — molecules by target, name or SMILES: resolve a named compound to its ID and structure, or list " +
40
+ "what was assayed against a target. → chemblId, preferredCompoundName, canonicalSmiles, molecularWeight, alogp, molecularFormula.\n" +
41
+ "'drug' (query) — the drug registry by drug name or indication: 'what treats X?', 'is Y approved, since when?'. → moleculeChemblId, " +
42
+ "preferredName, maxPhase (4 = approved), moleculeType, firstApproval, indication. If the drug endpoint is empty it falls back to a " +
43
+ "max_phase >= 4 molecule search, whose rows carry indication: null.\n" +
44
+ "'mechanism' (chemblId, molecule only) — curated mechanism of ONE molecule: 'how does X work?'. → mechanismOfAction, actionType " +
45
+ "(INHIBITOR, AGONIST, …), targetChemblId + targetName, moleculeChemblId. Curated mainly for clinical/approved molecules, so tool " +
46
+ "compounds often have none.\n" +
47
+ "'bioactivity' (chemblId + idType) — measured activity rows: the curated, QUOTABLE potency. → standardType, standardValue + " +
48
+ "standardUnits, pchemblValue (normalized -log10), assayChemblId, assayType, compoundChemblId, targetChemblId.\n" +
49
+ "'targets' (query) — resolve a gene symbol or protein name to a ChEMBL target, producing the ID that bioactivity (idType='target') " +
50
+ "and compounds (searchType='target') need. → targetChemblId, preferredName, targetType, organism, geneNames. Check `organism`: the " +
51
+ "top hit for a human symbol may be a non-human ortholog."),
50
52
  query: z
51
53
  .string()
52
54
  .min(1)
53
55
  .optional()
54
- .describe("Required for 'compounds', 'drug', 'targets'. compounds: must match searchType — a target name/gene symbol or target " +
55
- "ChEMBL ID, a compound name, or a SMILES string. drug: a drug name (e.g. 'imatinib') or a disease indication " +
56
- "(e.g. 'melanoma'). targets: a gene symbol (e.g. 'EGFR', 'ABL1'), a protein name, or a ChEMBL target ID."),
56
+ .describe("Required for 'compounds', 'drug', 'targets'. compounds: must match searchType — a target name/gene symbol or target ChEMBL ID, a " +
57
+ "compound name, or a SMILES. drug: a drug name ('imatinib') or an indication ('melanoma'). targets: a gene symbol ('EGFR'), a " +
58
+ "protein name, or a ChEMBL target ID."),
57
59
  searchType: z
58
60
  .enum(["target", "compound", "smiles"])
59
61
  .optional()
60
- .describe("Required for 'compounds' — how to read `query`. 'target': resolve it to a ChEMBL target, then return the compounds " +
61
- "assayed against that target. 'compound': free-text search over molecule names. 'smiles': flexible (flexmatch) " +
62
- "structure search on canonical SMILES."),
62
+ .describe("Required for 'compounds' — how to read `query`. 'target': resolve to a ChEMBL target, then return what was assayed against it. " +
63
+ "'compound': free-text over molecule names. 'smiles': flexmatch structure search."),
63
64
  chemblId: z
64
65
  .string()
65
66
  .min(1)
66
67
  .optional()
67
- .describe("Required for 'mechanism' and 'bioactivity'. A ChEMBL molecule ID (e.g. 'CHEMBL25' = aspirin) or — for bioactivity " +
68
- "with idType='target' — a target ID (e.g. 'CHEMBL203' = EGFR). 'mechanism' takes a molecule ID only, never a target ID."),
68
+ .describe("Required for 'mechanism' and 'bioactivity'. A molecule ID ('CHEMBL25' = aspirin), or — for bioactivity with idType='target' — a target " +
69
+ "ID ('CHEMBL203' = EGFR). 'mechanism' takes a molecule ID only."),
69
70
  idType: z
70
71
  .enum(["compound", "target"])
71
72
  .optional()
72
- .describe("Required for 'bioactivity' — which side of the activity table `chemblId` indexes. 'compound': chemblId is a molecule " +
73
- "ID; returns everything that molecule was assayed against. 'target': chemblId is a target ID; returns every compound " +
74
- "assayed against that target."),
73
+ .describe("Required for 'bioactivity' — which side of the activity table `chemblId` indexes. 'compound': everything that molecule was assayed " +
74
+ "against. 'target': every compound assayed against that target."),
75
75
  activityType: z
76
76
  .string()
77
77
  .optional()
78
- .describe("'bioactivity' only. Exact ChEMBL standard_type filter, e.g. 'IC50', 'EC50', 'Ki', 'Kd'. Matched exactly " +
79
- "(case-sensitive); omit to get all activity types."),
78
+ .describe("'bioactivity' only. Exact, case-sensitive standard_type filter — 'IC50', 'EC50', 'Ki', 'Kd'. Omit for all types."),
80
79
  limit: z
81
80
  .number()
82
81
  .int()
83
82
  .min(1)
84
- .max(LIMITS.wide)
83
+ .max(LIMITS.wideMax)
85
84
  .optional()
86
- .describe(`Max records. 'compounds'/'bioactivity': 1–${LIMITS.wide} (default ${LIMITS.wide}). 'drug'/'targets': 1–${LIMITS.narrow} ` +
87
- `(default ${LIMITS.narrow}). Ignored by 'mechanism'. With searchType='target' it caps the activity rows scanned, so ` +
88
- `fewer unique compounds usually come back.`),
85
+ .describe(`Max records. 'compounds'/'bioactivity': 1–${LIMITS.wideMax} (default ${LIMITS.wideDefault}). 'drug'/'targets': 1–${LIMITS.narrowMax} ` +
86
+ `(default ${LIMITS.narrowDefault}). Ignored by 'mechanism'. Rows are UNSORTED, so raising this is the only way to widen the window — ` +
87
+ `do it to survey an SAR series or a full assay panel, not to browse. With searchType='target' it caps the activity rows scanned, so ` +
88
+ `fewer unique compounds come back.`),
89
89
  })
90
90
  .refine((d) => !(d.action === "compounds" || d.action === "drug" || d.action === "targets") || (d.query !== undefined && d.query.length > 0), {
91
91
  message: "query is required when action is 'compounds', 'drug', or 'targets'",
@@ -104,35 +104,32 @@ const inputSchema = z
104
104
  message: "idType is required when action is 'bioactivity' — 'compound' if chemblId is a molecule ID, 'target' if it is a target ID",
105
105
  path: ["idType"],
106
106
  })
107
- .refine((d) => !(d.action === "drug" || d.action === "targets") || d.limit === undefined || d.limit <= LIMITS.narrow, {
108
- message: `limit is capped at ${LIMITS.narrow} for action 'drug' and 'targets' (only 'compounds' and 'bioactivity' reach ${LIMITS.wide})`,
107
+ .refine((d) => !(d.action === "drug" || d.action === "targets") || d.limit === undefined || d.limit <= LIMITS.narrowMax, {
108
+ message: `limit is capped at ${LIMITS.narrowMax} for action 'drug' and 'targets' (only 'compounds' and 'bioactivity' reach ${LIMITS.wideMax})`,
109
109
  path: ["limit"],
110
110
  });
111
111
  export const chemblTool = defineTool({
112
112
  id: "chembl",
113
- description: "ChEMBL — the manually curated database of drug-like bioactives (~2.4M compounds), the targets they were measured " +
114
- "against, their mechanisms, and their approval status. One tool, five lookups; pick with `action` (its description gives " +
115
- "each action's params and return fields).\n" +
116
- "IDs are resolved, never guessed: a `chemblId` comes from a prior action='compounds'/'drug' (molecule IDs) or action='targets' " +
117
- "(target IDs) call, or from pubchem action='crossrefs' — a compound or gene name is not a ChEMBL ID.\n" +
118
- "ChEMBL is curated, so prefer it over PubChem for anything you will quote or build on: use action='bioactivity' rather than " +
119
- "pubchem action='assays' whenever you will quote a number (PubChem assay summaries are broader HTS screening outcomes), and resolve " +
120
- "compounds here first. If ChEMBL does not find the compound, resolve it in PubChem with pubchem action='compound' and bridge back " +
121
- "to a ChEMBL ID via pubchem action='crossrefs'.\n" +
122
- "An empty array is a valid 'no match' / no-data answer, not an error — do not retry the same call.",
113
+ description: "ChEMBL — the manually curated database of drug-like bioactives (~2.4M compounds), the targets they were measured against, their mechanisms and " +
114
+ "their approval status. Five lookups; pick with `action`, which gives each one's params and return fields.\n" +
115
+ "IDs are resolved, never guessed: a `chemblId` comes from a prior action='compounds'/'drug' (molecules) or action='targets' (targets), or from " +
116
+ "pubchem action='crossrefs'. A compound or gene name is not a ChEMBL ID.\n" +
117
+ "Being curated, ChEMBL is what you quote from: prefer action='bioactivity' over pubchem action='assays' for any number you will cite, and resolve " +
118
+ "compounds here first. If ChEMBL misses the compound, resolve it via pubchem action='compound' and bridge back with pubchem action='crossrefs'.\n" +
119
+ "An empty array is valid no-data, not an error — do not retry the same call.",
123
120
  inputSchema,
124
121
  execute: async ({ action, query, searchType, chemblId, idType, activityType, limit }) => {
125
122
  switch (action) {
126
123
  case "compounds":
127
- return ok({ compounds: await searchCompounds(query, searchType, limit ?? LIMITS.wide) });
124
+ return ok({ compounds: await searchCompounds(query, searchType, limit ?? LIMITS.wideDefault) });
128
125
  case "drug":
129
- return ok({ drugs: await getDrugInfo(query, limit ?? LIMITS.narrow) });
126
+ return ok({ drugs: await getDrugInfo(query, limit ?? LIMITS.narrowDefault) });
130
127
  case "mechanism":
131
128
  return ok({ mechanisms: await getMechanism(chemblId) });
132
129
  case "bioactivity":
133
- return ok({ activities: await getBioactivity(chemblId, idType, { activityType, limit: limit ?? LIMITS.wide }) });
130
+ return ok({ activities: await getBioactivity(chemblId, idType, { activityType, limit: limit ?? LIMITS.wideDefault }) });
134
131
  case "targets":
135
- return ok({ targets: await searchTargets(query, limit ?? LIMITS.narrow) });
132
+ return ok({ targets: await searchTargets(query, limit ?? LIMITS.narrowDefault) });
136
133
  }
137
134
  },
138
135
  });