@inflexa-ai/harness 0.35.4 → 0.35.6

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 (37) hide show
  1. package/dist/agents/conversation-agent.js +10 -2
  2. package/dist/lib/metrics.d.ts +57 -6
  3. package/dist/lib/metrics.js +105 -8
  4. package/dist/prompts/briefing.js +6 -3
  5. package/dist/prompts/literature-reviewer.d.ts +1 -1
  6. package/dist/prompts/literature-reviewer.js +16 -8
  7. package/dist/prompts/planner.d.ts +4 -0
  8. package/dist/prompts/planner.js +22 -16
  9. package/dist/sandbox/create-sandbox.js +23 -1
  10. package/dist/sandbox/exec-outcome.d.ts +45 -0
  11. package/dist/sandbox/exec-outcome.js +65 -0
  12. package/dist/sandbox/k8s-client.d.ts +10 -0
  13. package/dist/sandbox/k8s-client.js +40 -5
  14. package/dist/sandbox/liveness.d.ts +3 -1
  15. package/dist/sandbox/types.d.ts +24 -0
  16. package/dist/sandbox/types.js +24 -0
  17. package/dist/tools/ad-hoc-router.d.ts +23 -0
  18. package/dist/tools/ad-hoc-router.js +93 -5
  19. package/dist/tools/bio/alphafold-prediction.d.ts +19 -0
  20. package/dist/tools/bio/alphafold-prediction.js +40 -0
  21. package/dist/tools/bio/index.d.ts +2 -0
  22. package/dist/tools/bio/index.js +3 -0
  23. package/dist/tools/bio/search-protein.d.ts +20 -0
  24. package/dist/tools/bio/search-protein.js +61 -0
  25. package/dist/tools/execute-analysis.d.ts +2 -1
  26. package/dist/tools/execute-analysis.js +66 -5
  27. package/dist/tools/lib/alphafold-client.d.ts +55 -0
  28. package/dist/tools/lib/alphafold-client.js +72 -0
  29. package/dist/tools/lib/api-utils.d.ts +25 -0
  30. package/dist/tools/lib/api-utils.js +45 -3
  31. package/dist/tools/lib/uniprot-client.d.ts +131 -0
  32. package/dist/tools/lib/uniprot-client.js +190 -0
  33. package/dist/tools/research/literature-reviewer.js +2 -0
  34. package/dist/tools/sandbox/list-available-packages.d.ts +25 -0
  35. package/dist/tools/sandbox/list-available-packages.js +51 -29
  36. package/dist/workflows/sandbox-step.js +10 -1
  37. package/package.json +1 -1
@@ -105,3 +105,193 @@ export async function getReactomePathwaysByUniProt(accession) {
105
105
  const rec = await getUniProtRecord(accession);
106
106
  return rec?.reactomePathwayIds ?? [];
107
107
  }
108
+ /**
109
+ * The field list of the search. It is separate from `FIELDS` on purpose:
110
+ * `FIELDS` feeds `getUniProtRecord` and the target-assessment dossier, thus a
111
+ * widening of it would change what that workflow reads.
112
+ */
113
+ const SEARCH_FIELDS = ["accession", "id", "protein_name", "gene_names", "length", "cc_function", "cc_subcellular_location"].join(",");
114
+ /** A FUNCTION comment runs to several hundred words; this is what one answer carries. */
115
+ const MAX_FUNCTION_CHARS = 1200;
116
+ /**
117
+ * `entryType` reads `UniProtKB reviewed (Swiss-Prot)` or
118
+ * `UniProtKB unreviewed (TrEMBL)`. The unreviewed value HOLDS the reviewed one
119
+ * as a substring, thus the negative test must run first. A test for `reviewed`
120
+ * alone answers true for every entry of either kind.
121
+ */
122
+ const REVIEWED_ENTRY_TYPE = "reviewed";
123
+ const UNREVIEWED_ENTRY_TYPE = "unreviewed";
124
+ /** Is the entry a curated Swiss-Prot record, as opposed to a machine-annotated TrEMBL one? */
125
+ function isReviewedEntry(entryType) {
126
+ const value = (entryType ?? "").toLowerCase();
127
+ if (value.includes(UNREVIEWED_ENTRY_TYPE))
128
+ return false;
129
+ return value.includes(REVIEWED_ENTRY_TYPE);
130
+ }
131
+ const SearchCommentSchema = z.object({
132
+ commentType: z.string().optional(),
133
+ texts: z.array(z.object({ value: z.string().optional() })).optional(),
134
+ subcellularLocations: z.array(z.object({ location: z.object({ value: z.string().optional() }).optional() })).optional(),
135
+ });
136
+ const ProteinNameSchema = z.object({ fullName: z.object({ value: z.string().optional() }).optional() });
137
+ /**
138
+ * One result row of `uniprotkb/search`, with the same all-optional discipline as
139
+ * `UniProtRecordSchema`: UniProt omits the key of an absent value, thus every
140
+ * field carries `.optional()` and none carries `.nullable()`.
141
+ *
142
+ * A TrEMBL row can carry `submissionNames` in place of `recommendedName`
143
+ * (`X5D778` is one), thus the name reader falls back rather than reporting the
144
+ * protein as unnamed.
145
+ */
146
+ export const UniProtSearchResultSchema = z.object({
147
+ primaryAccession: z.string().optional(),
148
+ uniProtkbId: z.string().optional(),
149
+ entryType: z.string().optional(),
150
+ proteinDescription: z
151
+ .object({
152
+ recommendedName: ProteinNameSchema.optional(),
153
+ submissionNames: z.array(ProteinNameSchema).optional(),
154
+ })
155
+ .optional(),
156
+ genes: z.array(z.object({ geneName: z.object({ value: z.string().optional() }).optional() })).optional(),
157
+ sequence: z.object({ length: z.number().optional() }).optional(),
158
+ comments: z.array(SearchCommentSchema).optional(),
159
+ });
160
+ /** The search envelope. It is exported so that the golden-fixture table drives it. */
161
+ export const UniProtSearchResponseSchema = z.object({
162
+ results: z.array(UniProtSearchResultSchema).optional(),
163
+ });
164
+ /** Read the protein name, preferring the recommended name over a submitted one. */
165
+ function extractProteinName(raw) {
166
+ const description = raw.proteinDescription;
167
+ const recommended = description?.recommendedName?.fullName?.value?.trim();
168
+ if (recommended)
169
+ return recommended;
170
+ for (const submitted of description?.submissionNames ?? []) {
171
+ const value = submitted.fullName?.value?.trim();
172
+ if (value)
173
+ return value;
174
+ }
175
+ return null;
176
+ }
177
+ /** Read the first FUNCTION comment, capped at {@link MAX_FUNCTION_CHARS}. */
178
+ function extractFunctionText(raw) {
179
+ for (const comment of raw.comments ?? []) {
180
+ if (comment.commentType !== "FUNCTION")
181
+ continue;
182
+ for (const text of comment.texts ?? []) {
183
+ const value = text.value?.trim();
184
+ if (value)
185
+ return value.length > MAX_FUNCTION_CHARS ? `${value.slice(0, MAX_FUNCTION_CHARS)}…` : value;
186
+ }
187
+ }
188
+ return null;
189
+ }
190
+ /**
191
+ * Read the distinct subcellular locations. UniProt splits them over more than
192
+ * one SUBCELLULAR LOCATION comment, and it repeats a location across them, thus
193
+ * the reader collects across every comment and keeps the first occurrence only.
194
+ */
195
+ function extractSubcellularLocations(raw) {
196
+ const locations = [];
197
+ const seen = new Set();
198
+ for (const comment of raw.comments ?? []) {
199
+ if (comment.commentType !== "SUBCELLULAR LOCATION")
200
+ continue;
201
+ for (const entry of comment.subcellularLocations ?? []) {
202
+ const value = entry.location?.value?.trim();
203
+ if (!value || seen.has(value))
204
+ continue;
205
+ seen.add(value);
206
+ locations.push(value);
207
+ }
208
+ }
209
+ return locations;
210
+ }
211
+ function toProtein(raw) {
212
+ return {
213
+ accession: raw.primaryAccession ?? "",
214
+ uniProtkbId: raw.uniProtkbId ?? null,
215
+ proteinName: extractProteinName(raw),
216
+ geneNames: (raw.genes ?? []).map((gene) => gene.geneName?.value).filter((name) => Boolean(name)),
217
+ sequenceLength: raw.sequence?.length ?? null,
218
+ function: extractFunctionText(raw),
219
+ subcellularLocations: extractSubcellularLocations(raw),
220
+ reviewed: isReviewedEntry(raw.entryType),
221
+ };
222
+ }
223
+ /**
224
+ * The accession forms that UniProt itself documents: the six-character form and
225
+ * the ten-character form, with an optional isoform suffix.
226
+ *
227
+ * The shape does NOT tell an accession from a gene symbol, because the two
228
+ * spaces overlap: `P2RY12`, `B3GAT1`, and their siblings are real gene symbols
229
+ * that match this form. Thus the match decides only whether the `accession:`
230
+ * clause is SAFE to send, never which space the query names.
231
+ */
232
+ const ACCESSION_RE = /^(?:[OPQ][0-9][A-Z0-9]{3}[0-9]|[A-NR-Z][0-9](?:[A-Z][A-Z0-9]{2}[0-9]){1,2})(?:-\d+)?$/i;
233
+ /**
234
+ * Can the query ride in an `accession:` clause?
235
+ *
236
+ * UniProt validates the value of that clause, and it answers HTTP 400 for a
237
+ * value that is not accession-shaped. Thus a plain symbol such as `BRCA1` must
238
+ * never reach one, because the 400 fails the whole request.
239
+ */
240
+ export function isUniProtAccession(query) {
241
+ return ACCESSION_RE.test(query.trim());
242
+ }
243
+ /**
244
+ * Build the UniProtKB query.
245
+ *
246
+ * The organism filter and the reviewed filter narrow an ambiguous SYMBOL
247
+ * search. An accession is a unique key, thus the filters must not reach it: a
248
+ * lookup of `P02769` under the human default would otherwise answer nothing,
249
+ * although the accession names bovine serum albumin.
250
+ *
251
+ * An accession-shaped input is ambiguous, because the two identifier spaces
252
+ * overlap. Such an input searches both, and the unfiltered accession clause is
253
+ * OR-ed with the filtered symbol clause. An input that is not
254
+ * accession-shaped searches the symbol space alone.
255
+ *
256
+ * The accession side carries `active:true`, and that is the one filter it
257
+ * takes. A deleted accession still answers an `accession:` query, as an
258
+ * `Inactive` row with no name and no function, and `UniProtProtein` has no
259
+ * field for the deletion reason. `B3GAT1` is such a case: the gene symbol also
260
+ * names a deleted TrEMBL accession, thus without the filter the answer holds
261
+ * the human protein and a hollow second row.
262
+ */
263
+ function buildSearchQuery(query, organismId, reviewedOnly) {
264
+ const symbolClauses = [`gene_exact:${query}`];
265
+ if (organismId !== undefined)
266
+ symbolClauses.push(`organism_id:${organismId}`);
267
+ if (reviewedOnly)
268
+ symbolClauses.push("reviewed:true");
269
+ const symbolQuery = symbolClauses.join(" AND ");
270
+ if (!isUniProtAccession(query))
271
+ return symbolQuery;
272
+ return `((accession:${query} AND active:true) OR (${symbolQuery}))`;
273
+ }
274
+ /**
275
+ * Search UniProtKB for a protein by gene symbol or by accession.
276
+ *
277
+ * An unknown query is HTTP 200 with `{"results":[]}`, never a 404, thus the
278
+ * empty result is an ordinary answer and never an error. The request asks for
279
+ * one row beyond `limit`, because UniProt reports its match count in the
280
+ * `x-total-results` header and `apiFetch` exposes no header. The extra row is
281
+ * what separates a trimmed answer from a complete one.
282
+ */
283
+ export async function searchProteins(query, opts = {}) {
284
+ const { organismId, reviewedOnly = true, limit = 10 } = opts;
285
+ const trimmed = query.trim();
286
+ const params = new URLSearchParams({
287
+ query: buildSearchQuery(trimmed, organismId, reviewedOnly),
288
+ fields: SEARCH_FIELDS,
289
+ format: "json",
290
+ size: String(limit + 1),
291
+ });
292
+ const res = await apiFetchValidated(`${UNIPROT_BASE}/uniprotkb/search?${params.toString()}`, UniProtSearchResponseSchema, { headers: UNIPROT_HEADERS });
293
+ if (res.isErr())
294
+ throw new Error(describeApiError(res.error));
295
+ const rows = res.value.results ?? [];
296
+ return { proteins: rows.slice(0, limit).map(toProtein), hasMore: rows.length > limit };
297
+ }
@@ -26,6 +26,7 @@ import { createChemDbTools, createNcbiTools } from "../bio/keys.js";
26
26
  import { genePreclinicalProfileTool } from "../bio/gene-preclinical-profile.js";
27
27
  import { lookupAnnotationTool } from "../bio/lookup-annotation.js";
28
28
  import { searchGeneTool } from "../bio/search-gene.js";
29
+ import { searchProteinTool } from "../bio/search-protein.js";
29
30
  import { searchInteractionsTool } from "../bio/search-interactions.js";
30
31
  import { createResolveCitationTool } from "./resolve-citation.js";
31
32
  /** Sub-agent identity — appended to `callPath`, set as `agentId`. */
@@ -38,6 +39,7 @@ export function createLiteratureReviewerTool(deps) {
38
39
  const chemDb = createChemDbTools(deps.bioKeys, { ...(deps.logger ? { logger: deps.logger } : {}) });
39
40
  const reviewerTools = [
40
41
  searchGeneTool,
42
+ searchProteinTool,
41
43
  lookupAnnotationTool,
42
44
  searchInteractionsTool,
43
45
  ncbi.pubmed,
@@ -116,6 +116,31 @@ export type ListAvailablePackagesDeps = Pick<EnvironmentStorePaths, "farmLockFil
116
116
  */
117
117
  readonly linkToolPresent?: boolean;
118
118
  };
119
+ /**
120
+ * What one inventory read produced: the sections of the bound source, or the
121
+ * fact that the source cannot answer, with the reason where the source gives
122
+ * one. There is no empty-on-error shape — an unreadable inventory must stay
123
+ * distinguishable from an inventory that holds nothing, and a caller that
124
+ * cannot tell them apart reports a present package as absent.
125
+ */
126
+ export type InventoryRead = {
127
+ readonly kind: "sections";
128
+ readonly sections: readonly Section[];
129
+ } | {
130
+ readonly kind: "unavailable";
131
+ readonly reason?: string;
132
+ };
133
+ /**
134
+ * Read the inventory of the bound source: the pool-scope reader when the
135
+ * embedder binds one (a conversation or planning surface), the first readable
136
+ * farm `inflexa.lock` otherwise (a sandbox agent). The image record merges
137
+ * into either report.
138
+ *
139
+ * Every caller that resolves a name against the environment reads through
140
+ * here, thus the census a model sees and the check a launch runs never
141
+ * disagree about what the source holds.
142
+ */
143
+ export declare function readInventorySections(deps: ListAvailablePackagesDeps): Promise<InventoryRead>;
119
144
  /**
120
145
  * Create the package inventory over the bound source: the pool-scope reader
121
146
  * when the embedder binds one (a conversation or planning surface), the
@@ -390,6 +390,50 @@ export function queryPackages(rawSections, { names, query, language, limit }) {
390
390
  const { content, returned } = renderListing(filtered, limit ?? DEFAULT_LIMIT, bothTrackNames(sections));
391
391
  return { available: true, total, returned, hasMore: returned < total, content };
392
392
  }
393
+ /**
394
+ * Read the inventory of the bound source: the pool-scope reader when the
395
+ * embedder binds one (a conversation or planning surface), the first readable
396
+ * farm `inflexa.lock` otherwise (a sandbox agent). The image record merges
397
+ * into either report.
398
+ *
399
+ * Every caller that resolves a name against the environment reads through
400
+ * here, thus the census a model sees and the check a launch runs never
401
+ * disagree about what the source holds.
402
+ */
403
+ export async function readInventorySections(deps) {
404
+ // The image record merges in when the host can read a valid one. An absent
405
+ // record and an invalid record both merge nothing, and neither is an
406
+ // error: the record is an enrichment, and the farm or pool inventory stays
407
+ // whole without it. A store packed before the record existed carries none.
408
+ // There is no logger here, thus an invalid record cannot be reported — the
409
+ // schema of the harness refuses it, and the report degrades to the tracks
410
+ // that it could read.
411
+ const recordSections = readImagePackagesFile(deps.imagePackagesFile ?? DEFAULT_IMAGE_PACKAGES_FILE)
412
+ .map(imageSections)
413
+ .unwrapOr([]);
414
+ if (deps.readPoolInventory) {
415
+ const pool = await deps.readPoolInventory().catch((cause) => ({
416
+ kind: "unavailable",
417
+ reason: cause instanceof Error ? cause.message : String(cause),
418
+ }));
419
+ if (pool.kind === "unavailable")
420
+ return { kind: "unavailable", reason: pool.reason };
421
+ return { kind: "sections", sections: [...pool.sections, ...recordSections] };
422
+ }
423
+ // Both farm container paths are tried when the host injects none, because
424
+ // the path keys on the declared toolchain and this read carries none.
425
+ let lock = null;
426
+ for (const candidate of deps.farmLockFile ? [deps.farmLockFile] : DEFAULT_FARM_LOCK_FILES) {
427
+ lock = readFarmLockFile(candidate).unwrapOr(null);
428
+ if (lock !== null)
429
+ break;
430
+ }
431
+ // A lock that no candidate path holds is an absence with no reason to
432
+ // report: the read never got far enough to learn one.
433
+ if (lock === null)
434
+ return { kind: "unavailable" };
435
+ return { kind: "sections", sections: [...lockSections(lock), ...recordSections] };
436
+ }
393
437
  /**
394
438
  * Create the package inventory over the bound source: the pool-scope reader
395
439
  * when the embedder binds one (a conversation or planning surface), the
@@ -397,8 +441,6 @@ export function queryPackages(rawSections, { names, query, language, limit }) {
397
441
  * record merges into either report.
398
442
  */
399
443
  export function createListAvailablePackagesTool(deps = {}) {
400
- const lockCandidates = deps.farmLockFile ? [deps.farmLockFile] : DEFAULT_FARM_LOCK_FILES;
401
- const imagePackagesFile = deps.imagePackagesFile ?? DEFAULT_IMAGE_PACKAGES_FILE;
402
444
  const readPoolInventory = deps.readPoolInventory;
403
445
  // The scope decides the framing sentence: a sandbox agent reads what it can
404
446
  // import NOW, and a conversation surface reads what the store HOLDS — a
@@ -461,38 +503,18 @@ export function createListAvailablePackagesTool(deps = {}) {
461
503
  return "full package list";
462
504
  },
463
505
  execute: async (input) => {
464
- // The image record merges in when the host can read a valid one.
465
- // An absent record and an invalid record both merge nothing, and
466
- // neither is an error: the record is an enrichment, and the farm
467
- // or pool inventory stays whole without it. A store packed before
468
- // the record existed carries none. The tool holds no logger, thus
469
- // an invalid record cannot be reported here — the schema of the
470
- // harness refuses it, and the report degrades to the tracks it
471
- // could read.
472
- const recordSections = readImagePackagesFile(imagePackagesFile).map(imageSections).unwrapOr([]);
506
+ const read = await readInventorySections(deps);
473
507
  // An unreadable inventory is an expected environment state — model it as an
474
508
  // `available: false` data variant telling the caller the set is UNKNOWN,
475
509
  // WITH the reason: without it, a structural fault (a damaged dependency
476
510
  // graph) reads as a transient flake, and the caller retries for ever.
477
- if (readPoolInventory) {
478
- const pool = await readPoolInventory().catch((cause) => ({
479
- kind: "unavailable",
480
- reason: cause instanceof Error ? cause.message : String(cause),
481
- }));
482
- if (pool.kind === "unavailable")
483
- return ok({ available: false, content: `${POOL_UNAVAILABLE_NOTE} The reason: ${pool.reason}.` });
484
- return ok(queryPackages([...pool.sections, ...recordSections], input));
485
- }
486
- let lock = null;
487
- for (const candidate of lockCandidates) {
488
- lock = readFarmLockFile(candidate).unwrapOr(null);
489
- if (lock !== null)
490
- break;
491
- }
492
- if (lock === null) {
493
- return ok({ available: false, content: UNAVAILABLE_NOTE });
511
+ // The note follows the vantage of the bound source, because a
512
+ // conversation surface has no runtime to probe.
513
+ if (read.kind === "unavailable") {
514
+ const note = readPoolInventory ? POOL_UNAVAILABLE_NOTE : UNAVAILABLE_NOTE;
515
+ return ok({ available: false, content: read.reason === undefined ? note : `${note} The reason: ${read.reason}.` });
494
516
  }
495
- return ok(queryPackages([...lockSections(lock), ...recordSections], input));
517
+ return ok(queryPackages(read.sections, input));
496
518
  },
497
519
  });
498
520
  }
@@ -34,6 +34,7 @@ import { unwrapOrThrow } from "../lib/result.js";
34
34
  import { isBudgetExceeded } from "../loop/budget-exceeded.js";
35
35
  import { runAgent } from "../loop/run-agent.js";
36
36
  import { durableStep } from "../loop/run-step.js";
37
+ import { lastExecOutcome } from "../sandbox/exec-outcome.js";
37
38
  import { activityForTool, applyTreeDelta, isChatDataPart, sandboxTreeDelta, stepPartId } from "../sandbox/sandbox-step-translate.js";
38
39
  import { createDetailResolver } from "../tools/detail-resolver.js";
39
40
  import { forSubAgent } from "../auth/types.js";
@@ -274,8 +275,15 @@ export async function runSandboxStepBody(input, deps) {
274
275
  // The scrub below is deliberate and load-bearing, which makes this record
275
276
  // the ONLY account of why the step died — the thrown error carries just the
276
277
  // generic phrase, so nothing downstream can reconstruct the cause.
278
+ // `durationMs`, the iteration cap, and the last exec of the sandbox are
279
+ // what separate a step that died in the model from one that died in the
280
+ // machine. `lastExec` holds no stream text (see `exec-outcome.ts`).
281
+ const lastExec = lastExecOutcome(sandbox.sandboxId);
277
282
  logger.error("step failure", {
278
283
  errorClass,
284
+ durationMs,
285
+ hitMaxSteps,
286
+ ...(lastExec ? { lastExec } : {}),
279
287
  ...logger.errorFields(err),
280
288
  });
281
289
  const safe = userFacingStepFailure(errorClass);
@@ -341,7 +349,8 @@ export async function runSandboxStepBody(input, deps) {
341
349
  const neverAbort = new AbortController().signal;
342
350
  let transcript;
343
351
  let finishReason;
344
- let hitMaxSteps;
352
+ // `false` until the loop reports otherwise: a loop that throws never reached its cap.
353
+ let hitMaxSteps = false;
345
354
  let stepUsage;
346
355
  try {
347
356
  const agentResult = await runAgent(agent, initial, session, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.35.4",
3
+ "version": "0.35.6",
4
4
  "description": "Host-agnostic bioinformatics agent harness — hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",