@inflexa-ai/harness 0.48.3 → 0.48.5

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 (98) hide show
  1. package/dist/agents/conversation-agent.js +5 -1
  2. package/dist/agents/sandbox/index.js +3 -0
  3. package/dist/agents/sandbox/mdv-preparation-agent.d.ts +9 -0
  4. package/dist/agents/sandbox/mdv-preparation-agent.js +37 -0
  5. package/dist/agents/sandbox/network-agent.js +1 -1
  6. package/dist/agents/sandbox/shared.js +3 -1
  7. package/dist/agents/sandbox/types.d.ts +1 -1
  8. package/dist/app/chat-turn-workflow.js +1 -0
  9. package/dist/app/chat-turn.d.ts +4 -2
  10. package/dist/app/chat-turn.js +46 -46
  11. package/dist/app/work.d.ts +7 -3
  12. package/dist/app/work.js +17 -16
  13. package/dist/contracts/chat-parts.d.ts +46 -5
  14. package/dist/contracts/chat-parts.js +2 -1
  15. package/dist/contracts/index.d.ts +1 -1
  16. package/dist/contracts/part-registry.js +1 -0
  17. package/dist/contracts/schemas/chat-parts.d.ts +168 -4
  18. package/dist/contracts/schemas/chat-parts.js +21 -1
  19. package/dist/execution/run-event-stream.d.ts +2 -2
  20. package/dist/index.d.ts +1 -1
  21. package/dist/lib/regulatory-corpus/fetch-ich.js +1 -16
  22. package/dist/lib/slug.d.ts +18 -0
  23. package/dist/lib/slug.js +29 -0
  24. package/dist/lib/test/pdf-fixture.d.ts +5 -0
  25. package/dist/lib/test/pdf-fixture.js +14 -0
  26. package/dist/loop/chat-stream.d.ts +4 -1
  27. package/dist/loop/chat-stream.js +30 -13
  28. package/dist/loop/run-loop.js +23 -35
  29. package/dist/loop/sub-agent.d.ts +2 -2
  30. package/dist/loop/sub-agent.js +1 -1
  31. package/dist/loop/test/metric-reader.d.ts +9 -0
  32. package/dist/loop/test/metric-reader.js +33 -0
  33. package/dist/loop/test/unnamed-model.d.ts +6 -0
  34. package/dist/loop/test/unnamed-model.js +24 -0
  35. package/dist/memory/conversation-display-recorder.d.ts +2 -0
  36. package/dist/memory/conversation-display-recorder.js +6 -2
  37. package/dist/memory/conversation-display-storage.d.ts +4 -2
  38. package/dist/memory/conversation-display-storage.js +19 -1
  39. package/dist/prompts/conversation.d.ts +1 -1
  40. package/dist/prompts/conversation.js +6 -4
  41. package/dist/prompts/sandbox/mdv-preparation-agent.d.ts +1 -0
  42. package/dist/prompts/sandbox/mdv-preparation-agent.js +56 -0
  43. package/dist/prompts/sandbox/network-agent.d.ts +1 -1
  44. package/dist/prompts/sandbox/network-agent.js +4 -0
  45. package/dist/providers/errors.js +2 -1
  46. package/dist/report-model/fixture-resolver.js +9 -135
  47. package/dist/report-model/production-resolver.d.ts +3 -3
  48. package/dist/report-model/production-resolver.js +12 -127
  49. package/dist/report-model/reference-read.d.ts +38 -0
  50. package/dist/report-model/reference-read.js +149 -0
  51. package/dist/report-model/reference-resolver.d.ts +7 -0
  52. package/dist/report-model/reference-resolver.js +1 -1
  53. package/dist/report-render/chart.js +48 -46
  54. package/dist/report-render/figures/common.d.ts +34 -0
  55. package/dist/report-render/figures/common.js +4 -1
  56. package/dist/report-render/figures/equal-units.js +2 -4
  57. package/dist/report-render/figures/sankey.js +1 -3
  58. package/dist/report-render/figures/upset.js +2 -5
  59. package/dist/runtime/assemble.js +2 -1
  60. package/dist/sandbox/execution-id.js +2 -14
  61. package/dist/sandbox/leases.js +46 -9
  62. package/dist/schemas/validate-plan.js +2 -14
  63. package/dist/state/active-turns.d.ts +6 -2
  64. package/dist/state/active-turns.js +11 -7
  65. package/dist/state/data-profile.d.ts +8 -0
  66. package/dist/state/data-profile.js +18 -0
  67. package/dist/state/index.d.ts +1 -1
  68. package/dist/state/index.js +1 -1
  69. package/dist/state/migrations/20261102120000_record_the_waiter_of_a_pause.d.ts +6 -0
  70. package/dist/state/migrations/20261102120000_record_the_waiter_of_a_pause.js +10 -0
  71. package/dist/state/migrations/index.js +2 -0
  72. package/dist/state/paused-work.d.ts +5 -3
  73. package/dist/state/paused-work.js +11 -7
  74. package/dist/state/runs.d.ts +3 -2
  75. package/dist/state/runs.js +4 -3
  76. package/dist/tasks/data-profile-index.js +3 -17
  77. package/dist/tasks/data-profile-resolve.js +3 -17
  78. package/dist/tasks/data-profile.js +1 -0
  79. package/dist/tools/bio/humanbase.d.ts +32 -0
  80. package/dist/tools/bio/humanbase.js +180 -0
  81. package/dist/tools/bio/index.d.ts +3 -1
  82. package/dist/tools/bio/index.js +3 -1
  83. package/dist/tools/bio/resolve-gene-ids.d.ts +13 -0
  84. package/dist/tools/bio/resolve-gene-ids.js +67 -0
  85. package/dist/tools/lib/humanbase-client.d.ts +215 -0
  86. package/dist/tools/lib/humanbase-client.js +187 -0
  87. package/dist/tools/lib/mygene-client.d.ts +150 -0
  88. package/dist/tools/lib/mygene-client.js +109 -0
  89. package/dist/tools/lib/opentargets-client.js +15 -25
  90. package/dist/tools/report-authoring/authoring-tools.d.ts +3 -3
  91. package/dist/tools/report-authoring/authoring-tools.js +7 -6
  92. package/dist/tools/research/generate-analogy-report.js +10 -2
  93. package/dist/tools/script/run-script.js +48 -51
  94. package/dist/workflows/execute-analysis.js +4 -3
  95. package/dist/workflows/pause.d.ts +3 -3
  96. package/dist/workflows/pause.js +15 -9
  97. package/dist/workflows/sandbox-step.js +1 -0
  98. package/package.json +7 -4
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Pure async client functions for HumanBase (Flatiron Institute), which serves
3
+ * the GIANT functional networks: one network for each tissue, cell type, or
4
+ * biological-process context (Greene et al., Nature Genetics 2015).
5
+ *
6
+ * Absence policy: HumanBase keeps the key of an absent value and sends an
7
+ * explicit `null` — `mincut` of a v3 network, and `term` and `version` of a
8
+ * context. Thus a maybe-absent field carries `.nullable()`, not `.optional()`.
9
+ *
10
+ * HumanBase asks for no key and no caller identity. The shared
11
+ * `HARNESS_USER_AGENT` of `apiFetch` names the caller.
12
+ *
13
+ * The GIANT version is a property of the slug: each v3 slug ends in `-v3`, and
14
+ * no v1 slug does. The network endpoint refuses a `giant_version` that disagrees
15
+ * with the slug (a 400 for a v3 slug under v1, a 404 for a v1 slug under v3),
16
+ * thus the client derives the version from the slug and no caller states it.
17
+ *
18
+ * Only an HTTP 404 is an expected absence (an unknown slug, or no queried gene in
19
+ * the network). A 400 carries the message of a refused request, thus it is not
20
+ * read as "nothing there" — `isUnexpectedApiError` would read every 4xx that way.
21
+ */
22
+ import { z } from "zod";
23
+ import { apiFetchValidated, describeApiError } from "./api-utils.js";
24
+ const HUMANBASE_BASE = "https://hb.flatironinstitute.org/api/integrations";
25
+ /** The network endpoint refuses a larger query gene set with a 400. */
26
+ export const HUMANBASE_MAX_NETWORK_GENES = 30;
27
+ const V3_SLUG_SUFFIX = "-v3";
28
+ /** The GIANT version of a context slug. */
29
+ export function giantVersionOf(slug) {
30
+ return slug.endsWith(V3_SLUG_SUFFIX) ? "v3" : "v1";
31
+ }
32
+ const ContextSchema = z.object({
33
+ title: z.string(),
34
+ term: z
35
+ .object({
36
+ identifier: z.string(),
37
+ database: z.object({ name: z.string() }),
38
+ })
39
+ .nullable(),
40
+ version: z.object({ identifier: z.string() }).nullable(),
41
+ });
42
+ function toContext(slug, context) {
43
+ return {
44
+ slug,
45
+ title: context.title,
46
+ giantVersion: giantVersionOf(slug),
47
+ term: context.term === null ? null : { id: context.term.identifier, ontology: context.term.database.name },
48
+ };
49
+ }
50
+ /** One row of `GET /api/integrations/`. */
51
+ export const HumanBaseIntegrationSchema = z
52
+ .object({
53
+ slug: z.string(),
54
+ context: ContextSchema,
55
+ })
56
+ .transform((d) => toContext(d.slug, d.context));
57
+ /** One row of `POST /api/integrations/relevant/`. */
58
+ export const HumanBaseRelevantSchema = z
59
+ .object({
60
+ slug: z.string(),
61
+ context: ContextSchema,
62
+ score: z.number(),
63
+ })
64
+ .transform((d) => ({ ...toContext(d.slug, d.context), score: d.score }));
65
+ const XrefSchema = z.object({
66
+ xrid: z.string(),
67
+ crossrefdb: z.object({ name: z.string() }),
68
+ });
69
+ const NetworkGeneSchema = z.object({
70
+ entrez: z.number().int(),
71
+ standard_name: z.string(),
72
+ description: z.string(),
73
+ xrefs: z.array(XrefSchema),
74
+ query: z.boolean(),
75
+ });
76
+ /** An edge names its two genes by their index in `genes`, not by an Entrez ID. */
77
+ const NetworkEdgeSchema = z.object({
78
+ source: z.number().int().nonnegative(),
79
+ target: z.number().int().nonnegative(),
80
+ weight: z.number(),
81
+ });
82
+ function xrefOf(xrefs, database) {
83
+ return xrefs.find((xref) => xref.crossrefdb.name === database)?.xrid ?? null;
84
+ }
85
+ /** The body of `GET /api/integrations/{slug}/network/`, with each edge resolved to the symbols of its genes. */
86
+ export const HumanBaseNetworkSchema = z
87
+ .object({
88
+ mincut: z.number().nullable(),
89
+ genes: z.array(NetworkGeneSchema),
90
+ edges: z.array(NetworkEdgeSchema),
91
+ })
92
+ .superRefine((d, ctx) => {
93
+ for (const [index, edge] of d.edges.entries()) {
94
+ if (edge.source >= d.genes.length || edge.target >= d.genes.length) {
95
+ ctx.addIssue({ code: "custom", path: ["edges", index], message: "an edge names a gene index outside genes" });
96
+ }
97
+ }
98
+ })
99
+ .transform((d) => {
100
+ const genes = d.genes.map((gene) => ({
101
+ entrez: gene.entrez,
102
+ symbol: gene.standard_name,
103
+ description: gene.description,
104
+ ensembl: xrefOf(gene.xrefs, "Ensembl"),
105
+ hgnc: xrefOf(gene.xrefs, "HGNC"),
106
+ query: gene.query,
107
+ }));
108
+ const symbolAt = (index) => genes[index]?.symbol ?? "";
109
+ const edges = d.edges.map((edge) => ({ geneA: symbolAt(edge.source), geneB: symbolAt(edge.target), weight: edge.weight }));
110
+ return { mincut: d.mincut, genes, edges };
111
+ });
112
+ function isNotFound(error) {
113
+ return error.type === "http_status" && error.status === 404;
114
+ }
115
+ /**
116
+ * Each context that HumanBase serves, filtered by a case-insensitive substring of
117
+ * the slug or the title. The list holds about 600 rows, thus `limit` trims it and
118
+ * `totalFound` reports the count of the filtered list.
119
+ */
120
+ export async function listContexts(options = {}) {
121
+ const res = await apiFetchValidated(`${HUMANBASE_BASE}/`, z.array(HumanBaseIntegrationSchema));
122
+ if (res.isErr())
123
+ throw new Error(describeApiError(res.error));
124
+ const needle = options.query?.trim().toLowerCase() ?? "";
125
+ const matched = needle === "" ? res.value : res.value.filter((c) => c.slug.toLowerCase().includes(needle) || c.title.toLowerCase().includes(needle));
126
+ const limit = options.limit ?? matched.length;
127
+ return { contexts: matched.slice(0, limit), totalFound: matched.length };
128
+ }
129
+ /**
130
+ * The contexts ranked by their relevance to a gene set, best first.
131
+ *
132
+ * HumanBase ranks the v1 contexts only, and it gives an empty list when it knows
133
+ * none of the genes. The endpoint takes each Entrez ID as a JSON string and
134
+ * answers a JSON number with a 500.
135
+ */
136
+ export async function getRelevantContexts(entrez, options = {}) {
137
+ const res = await apiFetchValidated(`${HUMANBASE_BASE}/relevant/`, z.array(HumanBaseRelevantSchema), {
138
+ method: "POST",
139
+ headers: { "content-type": "application/json" },
140
+ body: JSON.stringify({ entrez: entrez.map(String) }),
141
+ });
142
+ if (res.isErr()) {
143
+ // The published schema of the endpoint documents a 404 for an unknown gene.
144
+ if (isNotFound(res.error))
145
+ return { contexts: [], totalFound: 0 };
146
+ throw new Error(describeApiError(res.error));
147
+ }
148
+ const sorted = res.value.sort((a, b) => b.score - a.score);
149
+ const limit = options.limit ?? sorted.length;
150
+ return { contexts: sorted.slice(0, limit), totalFound: sorted.length };
151
+ }
152
+ /**
153
+ * The functional subnetwork of the query genes in one context, plus `nodeSize`
154
+ * neighbor genes that HumanBase adds. The answer holds an edge for each gene
155
+ * pair, thus the edge count grows with the square of the gene count: `limit`
156
+ * trims the weight-sorted edges, and `totalEdges` reports the true count.
157
+ *
158
+ * Gives `null` on an HTTP 404: the slug is unknown, or no query gene is in the
159
+ * network.
160
+ */
161
+ export async function getNetwork(slug, entrez, options = {}) {
162
+ const giantVersion = giantVersionOf(slug);
163
+ const params = new URLSearchParams();
164
+ for (const id of entrez)
165
+ params.append("entrez", String(id));
166
+ if (options.nodeSize !== undefined)
167
+ params.set("node_size", String(options.nodeSize));
168
+ params.set("giant_version", giantVersion);
169
+ const res = await apiFetchValidated(`${HUMANBASE_BASE}/${encodeURIComponent(slug)}/network/?${params}`, HumanBaseNetworkSchema);
170
+ if (res.isErr()) {
171
+ if (isNotFound(res.error))
172
+ return null;
173
+ throw new Error(describeApiError(res.error));
174
+ }
175
+ const returned = new Set(res.value.genes.map((gene) => gene.entrez));
176
+ const edges = res.value.edges.sort((a, b) => b.weight - a.weight);
177
+ const limit = options.limit ?? edges.length;
178
+ return {
179
+ context: slug,
180
+ giantVersion,
181
+ mincut: res.value.mincut,
182
+ genes: res.value.genes,
183
+ edges: edges.slice(0, limit),
184
+ totalEdges: edges.length,
185
+ notFound: entrez.filter((id) => !returned.has(id)),
186
+ };
187
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Pure async client functions for MyGene.info (BioThings, Scripps), which maps a
3
+ * gene across the identifier spaces of its sources: the HGNC symbol, the NCBI
4
+ * Entrez Gene ID, the Ensembl gene ID, and the UniProt accession (Xin et al.,
5
+ * Genome Biology 2016).
6
+ *
7
+ * Absence policy: MyGene omits the key of an absent value and never sends an
8
+ * explicit `null`. Every field of a hit can be absent — an Ensembl-only gene has
9
+ * no `symbol`, `name`, or `entrezgene`, and a noncoding gene has no `uniprot` —
10
+ * thus each field carries `.optional()`.
11
+ *
12
+ * Two fields change their shape with their count: `ensembl` is one object or an
13
+ * array of objects (a gene on alternate haplotypes, or a mouse gene with strain
14
+ * copies), and `uniprot.Swiss-Prot` is one string or an array. The client
15
+ * normalizes both to an array, in the wire order, which carries no rank.
16
+ *
17
+ * A miss is no error: the batch answers HTTP 200, and it marks the miss with
18
+ * `notfound: true`.
19
+ *
20
+ * MyGene cuts the hits of each identifier at `size`, 10 by default, and the plain
21
+ * batch answer gives no sign of the cut. Thus the client sends `size` and
22
+ * `with_total`, and the answer is then an object: `max_total`, the largest hit
23
+ * count of one identifier before the cut, and `hits`, the rows. An unknown species answers HTTP 400 with a message, which the
24
+ * caller sees as an error.
25
+ */
26
+ import { z } from "zod";
27
+ /** The most hits that one identifier gives. A UniProt accession that a gene family shares gives a hit for each member. */
28
+ export declare const MYGENE_HITS_PER_ID = 100;
29
+ export type MyGeneHit = {
30
+ /** The identifier of the request that this hit answers. */
31
+ query: string;
32
+ symbol: string | null;
33
+ /** The full name of the gene. */
34
+ name: string | null;
35
+ entrez: number | null;
36
+ ensembl: string[];
37
+ /** The reviewed (Swiss-Prot) UniProt accessions. */
38
+ uniprot: string[];
39
+ };
40
+ export type MyGeneRow = {
41
+ kind: "hit";
42
+ hit: MyGeneHit;
43
+ } | {
44
+ kind: "miss";
45
+ query: string;
46
+ };
47
+ export type GeneIdResolution = {
48
+ genes: MyGeneHit[];
49
+ /** Each identifier that matched no gene. */
50
+ notFound: string[];
51
+ /** Each identifier that matched more than one gene. Each of its genes is in `genes`. */
52
+ ambiguous: string[];
53
+ /** Each identifier whose hits were cut at `MYGENE_HITS_PER_ID`. `genes` holds the first of them only. */
54
+ truncated: string[];
55
+ };
56
+ /** One row of the batch answer of `POST /v3/query`: a hit, or the miss of one query. */
57
+ export declare const MyGeneBatchRowSchema: z.ZodPipe<z.ZodUnion<readonly [z.ZodObject<{
58
+ query: z.ZodString;
59
+ notfound: z.ZodLiteral<true>;
60
+ }, z.core.$strip>, z.ZodObject<{
61
+ query: z.ZodString;
62
+ _id: z.ZodString;
63
+ symbol: z.ZodOptional<z.ZodString>;
64
+ name: z.ZodOptional<z.ZodString>;
65
+ entrezgene: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<number | null, string | number>>>;
66
+ ensembl: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
67
+ gene: z.ZodString;
68
+ }, z.core.$strip>, z.ZodArray<z.ZodObject<{
69
+ gene: z.ZodString;
70
+ }, z.core.$strip>>]>>;
71
+ uniprot: z.ZodOptional<z.ZodObject<{
72
+ "Swiss-Prot": z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
73
+ }, z.core.$strip>>;
74
+ }, z.core.$strip>]>, z.ZodTransform<{
75
+ kind: "hit";
76
+ hit: MyGeneHit;
77
+ } | {
78
+ kind: "miss";
79
+ query: string;
80
+ }, {
81
+ query: string;
82
+ notfound: true;
83
+ } | {
84
+ query: string;
85
+ _id: string;
86
+ symbol?: string | undefined;
87
+ name?: string | undefined;
88
+ entrezgene?: number | null | undefined;
89
+ ensembl?: {
90
+ gene: string;
91
+ }[] | {
92
+ gene: string;
93
+ } | undefined;
94
+ uniprot?: {
95
+ "Swiss-Prot"?: string | string[] | undefined;
96
+ } | undefined;
97
+ }>>;
98
+ /** The batch answer of `POST /v3/query` with `with_total`. */
99
+ export declare const MyGeneBatchAnswerSchema: z.ZodObject<{
100
+ max_total: z.ZodNumber;
101
+ hits: z.ZodArray<z.ZodPipe<z.ZodUnion<readonly [z.ZodObject<{
102
+ query: z.ZodString;
103
+ notfound: z.ZodLiteral<true>;
104
+ }, z.core.$strip>, z.ZodObject<{
105
+ query: z.ZodString;
106
+ _id: z.ZodString;
107
+ symbol: z.ZodOptional<z.ZodString>;
108
+ name: z.ZodOptional<z.ZodString>;
109
+ entrezgene: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<number | null, string | number>>>;
110
+ ensembl: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
111
+ gene: z.ZodString;
112
+ }, z.core.$strip>, z.ZodArray<z.ZodObject<{
113
+ gene: z.ZodString;
114
+ }, z.core.$strip>>]>>;
115
+ uniprot: z.ZodOptional<z.ZodObject<{
116
+ "Swiss-Prot": z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
117
+ }, z.core.$strip>>;
118
+ }, z.core.$strip>]>, z.ZodTransform<{
119
+ kind: "hit";
120
+ hit: MyGeneHit;
121
+ } | {
122
+ kind: "miss";
123
+ query: string;
124
+ }, {
125
+ query: string;
126
+ notfound: true;
127
+ } | {
128
+ query: string;
129
+ _id: string;
130
+ symbol?: string | undefined;
131
+ name?: string | undefined;
132
+ entrezgene?: number | null | undefined;
133
+ ensembl?: {
134
+ gene: string;
135
+ }[] | {
136
+ gene: string;
137
+ } | undefined;
138
+ uniprot?: {
139
+ "Swiss-Prot"?: string | string[] | undefined;
140
+ } | undefined;
141
+ }>>>;
142
+ }, z.core.$strip>;
143
+ /**
144
+ * Resolve a batch of gene identifiers, mixed freely, in one request. A duplicate
145
+ * identifier is sent one time, and the order of the input is kept.
146
+ *
147
+ * `species` is a common name that MyGene knows ('human', 'mouse', 'rat',
148
+ * 'zebrafish', …) or an NCBI Taxonomy ID as a string ('9606').
149
+ */
150
+ export declare function resolveGeneIds(identifiers: readonly string[], species: string): Promise<GeneIdResolution>;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Pure async client functions for MyGene.info (BioThings, Scripps), which maps a
3
+ * gene across the identifier spaces of its sources: the HGNC symbol, the NCBI
4
+ * Entrez Gene ID, the Ensembl gene ID, and the UniProt accession (Xin et al.,
5
+ * Genome Biology 2016).
6
+ *
7
+ * Absence policy: MyGene omits the key of an absent value and never sends an
8
+ * explicit `null`. Every field of a hit can be absent — an Ensembl-only gene has
9
+ * no `symbol`, `name`, or `entrezgene`, and a noncoding gene has no `uniprot` —
10
+ * thus each field carries `.optional()`.
11
+ *
12
+ * Two fields change their shape with their count: `ensembl` is one object or an
13
+ * array of objects (a gene on alternate haplotypes, or a mouse gene with strain
14
+ * copies), and `uniprot.Swiss-Prot` is one string or an array. The client
15
+ * normalizes both to an array, in the wire order, which carries no rank.
16
+ *
17
+ * A miss is no error: the batch answers HTTP 200, and it marks the miss with
18
+ * `notfound: true`.
19
+ *
20
+ * MyGene cuts the hits of each identifier at `size`, 10 by default, and the plain
21
+ * batch answer gives no sign of the cut. Thus the client sends `size` and
22
+ * `with_total`, and the answer is then an object: `max_total`, the largest hit
23
+ * count of one identifier before the cut, and `hits`, the rows. An unknown species answers HTTP 400 with a message, which the
24
+ * caller sees as an error.
25
+ */
26
+ import { z } from "zod";
27
+ import { apiFetchValidated, describeApiError, zWireNumber } from "./api-utils.js";
28
+ const MYGENE_QUERY_URL = "https://mygene.info/v3/query";
29
+ /**
30
+ * The identifier spaces that a query matches. `alias` is not one of them: an old
31
+ * or informal symbol matches several current genes with an equal score, and no
32
+ * rule picks one.
33
+ */
34
+ const SCOPES = "symbol,entrezgene,ensembl.gene,uniprot";
35
+ const FIELDS = "symbol,name,entrezgene,ensembl.gene,uniprot.Swiss-Prot";
36
+ /** The most hits that one identifier gives. A UniProt accession that a gene family shares gives a hit for each member. */
37
+ export const MYGENE_HITS_PER_ID = 100;
38
+ function asArray(value) {
39
+ if (value === undefined)
40
+ return [];
41
+ return Array.isArray(value) ? value : [value];
42
+ }
43
+ const EnsemblRefSchema = z.object({ gene: z.string() });
44
+ const MissRowSchema = z.object({ query: z.string(), notfound: z.literal(true) });
45
+ const HitRowSchema = z.object({
46
+ query: z.string(),
47
+ _id: z.string(),
48
+ symbol: z.string().optional(),
49
+ name: z.string().optional(),
50
+ entrezgene: zWireNumber.optional(),
51
+ ensembl: z.union([EnsemblRefSchema, z.array(EnsemblRefSchema)]).optional(),
52
+ uniprot: z.object({ "Swiss-Prot": z.union([z.string(), z.array(z.string())]).optional() }).optional(),
53
+ });
54
+ /** One row of the batch answer of `POST /v3/query`: a hit, or the miss of one query. */
55
+ export const MyGeneBatchRowSchema = z.union([MissRowSchema, HitRowSchema]).transform((row) => {
56
+ if ("notfound" in row)
57
+ return { kind: "miss", query: row.query };
58
+ return {
59
+ kind: "hit",
60
+ hit: {
61
+ query: row.query,
62
+ symbol: row.symbol ?? null,
63
+ name: row.name ?? null,
64
+ entrez: row.entrezgene ?? null,
65
+ ensembl: asArray(row.ensembl).map((ref) => ref.gene),
66
+ uniprot: asArray(row.uniprot?.["Swiss-Prot"]),
67
+ },
68
+ };
69
+ });
70
+ /** The batch answer of `POST /v3/query` with `with_total`. */
71
+ export const MyGeneBatchAnswerSchema = z.object({
72
+ max_total: z.number(),
73
+ hits: z.array(MyGeneBatchRowSchema),
74
+ });
75
+ /**
76
+ * Resolve a batch of gene identifiers, mixed freely, in one request. A duplicate
77
+ * identifier is sent one time, and the order of the input is kept.
78
+ *
79
+ * `species` is a common name that MyGene knows ('human', 'mouse', 'rat',
80
+ * 'zebrafish', …) or an NCBI Taxonomy ID as a string ('9606').
81
+ */
82
+ export async function resolveGeneIds(identifiers, species) {
83
+ const queries = [...new Set(identifiers.map((id) => id.trim()).filter((id) => id !== ""))];
84
+ if (queries.length === 0)
85
+ return { genes: [], notFound: [], ambiguous: [], truncated: [] };
86
+ const res = await apiFetchValidated(MYGENE_QUERY_URL, MyGeneBatchAnswerSchema, {
87
+ method: "POST",
88
+ headers: { "content-type": "application/json" },
89
+ body: JSON.stringify({ q: queries, scopes: SCOPES, fields: FIELDS, species, size: MYGENE_HITS_PER_ID, with_total: true }),
90
+ });
91
+ if (res.isErr())
92
+ throw new Error(describeApiError(res.error));
93
+ const genes = [];
94
+ const notFound = [];
95
+ const hitCount = new Map();
96
+ for (const row of res.value.hits) {
97
+ if (row.kind === "miss") {
98
+ notFound.push(row.query);
99
+ continue;
100
+ }
101
+ genes.push(row.hit);
102
+ hitCount.set(row.hit.query, (hitCount.get(row.hit.query) ?? 0) + 1);
103
+ }
104
+ const ambiguous = [...hitCount].filter(([, count]) => count > 1).map(([query]) => query);
105
+ // `max_total` is one number for the whole batch. Past the cap, each identifier that filled the cap can be the one
106
+ // that was cut, thus each such identifier counts as cut.
107
+ const truncated = res.value.max_total > MYGENE_HITS_PER_ID ? [...hitCount].filter(([, count]) => count >= MYGENE_HITS_PER_ID).map(([query]) => query) : [];
108
+ return { genes, notFound, ambiguous, truncated };
109
+ }
@@ -122,6 +122,10 @@ const BASELINE_EXPRESSION_PAGE_SIZE = 3000;
122
122
  // that resolves empty — an unknown/retired id yields `{"target": null}`, which a
123
123
  // bare `.optional()` rejects, turning a clean "not found" into a thrown error.
124
124
  const DatatypeScoreSchema = z.object({ id: z.string().optional(), score: z.number().optional() });
125
+ /** The datatype-scores list follows the same nullable-optional reading rule as its row, and may be absent or null. */
126
+ const scoresField = z.array(DatatypeScoreSchema).nullable().optional();
127
+ /** The paged rows list of an association query: the API may omit or null the whole envelope. */
128
+ const rowsEnvelope = (item) => z.object({ rows: z.array(item).nullable().optional() });
125
129
  export const TargetAssociationsDataSchema = z.object({
126
130
  target: z
127
131
  .object({
@@ -137,18 +141,11 @@ export const TargetAssociationsDataSchema = z.object({
137
141
  .optional(),
138
142
  associations: z
139
143
  .object({
140
- associatedDiseases: z
141
- .object({
142
- rows: z
143
- .array(z.object({
144
- disease: z.object({ id: z.string(), name: z.string() }),
145
- score: z.number(),
146
- datatypeScores: z.array(DatatypeScoreSchema).nullable().optional(),
147
- }))
148
- .nullable()
149
- .optional(),
150
- })
151
- .optional(),
144
+ associatedDiseases: rowsEnvelope(z.object({
145
+ disease: z.object({ id: z.string(), name: z.string() }),
146
+ score: z.number(),
147
+ datatypeScores: scoresField,
148
+ })).optional(),
152
149
  })
153
150
  .nullable()
154
151
  .optional(),
@@ -158,21 +155,14 @@ const DiseaseAssociationsDataSchema = z.object({
158
155
  .object({
159
156
  id: z.string().optional(),
160
157
  name: z.string().optional(),
161
- associatedTargets: z
162
- .object({
163
- rows: z
164
- .array(z.object({
165
- target: z
166
- .object({ id: z.string().optional(), approvedSymbol: z.string().optional(), approvedName: z.string().optional() })
167
- .nullable()
168
- .optional(),
169
- score: z.number(),
170
- datatypeScores: z.array(DatatypeScoreSchema).nullable().optional(),
171
- }))
158
+ associatedTargets: rowsEnvelope(z.object({
159
+ target: z
160
+ .object({ id: z.string().optional(), approvedSymbol: z.string().optional(), approvedName: z.string().optional() })
172
161
  .nullable()
173
162
  .optional(),
174
- })
175
- .optional(),
163
+ score: z.number(),
164
+ datatypeScores: scoresField,
165
+ })).optional(),
176
166
  })
177
167
  .nullable()
178
168
  .optional(),
@@ -34,9 +34,8 @@ import type { DraftDocument } from "../../report-model/draft.js";
34
34
  import type { ReportSnapshot } from "../../report-model/reference-resolver.js";
35
35
  import type { DerivationRecord } from "../../state/report-session-state.js";
36
36
  import { type Tool } from "../define-tool.js";
37
- /** The flat destination fields. An anchor is `before` or `after`, and a place is `start` or `end`. */
37
+ /** The flat destination fields of the add, plus the payload of the new block. */
38
38
  declare const addBlockInput: z.ZodObject<{
39
- block: z.ZodUnion<readonly [z.ZodType<import("../../report-model/authoring-grammar.js").AuthoringBlock, unknown, z.core.$ZodTypeInternals<import("../../report-model/authoring-grammar.js").AuthoringBlock, unknown>>, z.ZodUnknown]>;
40
39
  parentId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
41
40
  place: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
42
41
  end: "end";
@@ -44,6 +43,7 @@ declare const addBlockInput: z.ZodObject<{
44
43
  }>>>;
45
44
  before: z.ZodOptional<z.ZodNullable<z.ZodString>>;
46
45
  after: z.ZodOptional<z.ZodNullable<z.ZodString>>;
46
+ block: z.ZodUnion<readonly [z.ZodType<import("../../report-model/authoring-grammar.js").AuthoringBlock, unknown, z.core.$ZodTypeInternals<import("../../report-model/authoring-grammar.js").AuthoringBlock, unknown>>, z.ZodUnknown]>;
47
47
  }, z.core.$strip>;
48
48
  /**
49
49
  * The change payload is a title for a section target, or a block for an atom target, and exactly one of
@@ -59,7 +59,6 @@ declare const removeBlockInput: z.ZodObject<{
59
59
  targetId: z.ZodString;
60
60
  }, z.core.$strip>;
61
61
  declare const moveBlockInput: z.ZodObject<{
62
- targetId: z.ZodString;
63
62
  parentId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
64
63
  place: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
65
64
  end: "end";
@@ -67,6 +66,7 @@ declare const moveBlockInput: z.ZodObject<{
67
66
  }>>>;
68
67
  before: z.ZodOptional<z.ZodNullable<z.ZodString>>;
69
68
  after: z.ZodOptional<z.ZodNullable<z.ZodString>>;
69
+ targetId: z.ZodString;
70
70
  }, z.core.$strip>;
71
71
  declare const readOutlineInput: z.ZodObject<{}, z.core.$strip>;
72
72
  /** The read names its target with the same field name as each mutation, thus one id key serves them all. */
@@ -70,12 +70,16 @@ const DESTINATION_NOTES = {
70
70
  after: "The id of a sibling block. The block lands directly after it.",
71
71
  };
72
72
  /** The flat destination fields. An anchor is `before` or `after`, and a place is `start` or `end`. */
73
- const addBlockInput = z.object({
74
- block: blockPayload,
73
+ const destinationFields = {
75
74
  parentId: absentable(z.string()).describe(DESTINATION_NOTES.parentId),
76
75
  place: absentable(z.enum(["start", "end"])).describe(DESTINATION_NOTES.place),
77
76
  before: absentable(z.string()).describe(DESTINATION_NOTES.before),
78
77
  after: absentable(z.string()).describe(DESTINATION_NOTES.after),
78
+ };
79
+ /** The flat destination fields of the add, plus the payload of the new block. */
80
+ const addBlockInput = z.object({
81
+ block: blockPayload,
82
+ ...destinationFields,
79
83
  });
80
84
  /**
81
85
  * The change payload is a title for a section target, or a block for an atom target, and exactly one of
@@ -92,10 +96,7 @@ const removeBlockInput = z.object({
92
96
  });
93
97
  const moveBlockInput = z.object({
94
98
  targetId: z.string().describe("The id of the block to move, as the outline gives it."),
95
- parentId: absentable(z.string()).describe(DESTINATION_NOTES.parentId),
96
- place: absentable(z.enum(["start", "end"])).describe(DESTINATION_NOTES.place),
97
- before: absentable(z.string()).describe(DESTINATION_NOTES.before),
98
- after: absentable(z.string()).describe(DESTINATION_NOTES.after),
99
+ ...destinationFields,
99
100
  });
100
101
  const readOutlineInput = z.object({});
101
102
  /** The read names its target with the same field name as each mutation, thus one id key serves them all. */
@@ -260,7 +260,9 @@ export function createGenerateAnalogyReportTool(deps, registerSubAgent) {
260
260
  "drives a multi-step research sub-agent (up to 40 model turns) over live " +
261
261
  "literature search, so it is slow; a fact that one lookup answers does " +
262
262
  "not need it.\n" +
263
- "RETURNS an AnalogyReport envelope the UI renders as an inline card. An " +
263
+ "RETURNS an AnalogyReport envelope. The analogy block shows the full " +
264
+ "report to the user, thus your reply names the analogies that matter by " +
265
+ "their `analogyTitle` and does not copy the report. An " +
264
266
  "analogy the search could not fill carries a `coverage` tag " +
265
267
  "(`queried_no_data` | `search_failed` | `not_loaded`) with an empty " +
266
268
  "`solutions` array — a partial result, not an error. A top-level `error` " +
@@ -273,7 +275,13 @@ export function createGenerateAnalogyReportTool(deps, registerSubAgent) {
273
275
  view: "none",
274
276
  describeCall: "none",
275
277
  startsWorkflow: true,
276
- execute: async (input, ctx) => ok(await research(input, ctx)),
278
+ execute: async (input, ctx) => {
279
+ const report = await research(input, ctx);
280
+ // The execute runs in the body of the turn, where a stream write is checkpointed, thus a replay gives no second
281
+ // part. The root session keeps the part in the stored display, which drops each frame of a sub-agent.
282
+ await ctx.emit({ type: "data-analogy-report", source: ctx.session.provenance, data: { id: ctx.invocationId, report } });
283
+ return ok(report);
284
+ },
277
285
  });
278
286
  }
279
287
  /** The envelope of a run that recorded no outcome, also after its salvage. */