@inflexa-ai/harness 0.35.5 → 0.35.7

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 (41) hide show
  1. package/dist/agents/conversation-agent.js +10 -2
  2. package/dist/index.d.ts +6 -0
  3. package/dist/index.js +17 -0
  4. package/dist/lib/metrics.d.ts +55 -4
  5. package/dist/lib/metrics.js +101 -4
  6. package/dist/prompts/briefing.js +6 -3
  7. package/dist/prompts/literature-reviewer.d.ts +1 -1
  8. package/dist/prompts/literature-reviewer.js +16 -8
  9. package/dist/prompts/planner.d.ts +4 -0
  10. package/dist/prompts/planner.js +22 -16
  11. package/dist/sandbox/create-sandbox.js +23 -1
  12. package/dist/sandbox/exec-outcome.d.ts +45 -0
  13. package/dist/sandbox/exec-outcome.js +65 -0
  14. package/dist/sandbox/k8s-client.d.ts +10 -0
  15. package/dist/sandbox/k8s-client.js +40 -5
  16. package/dist/sandbox/liveness.d.ts +3 -1
  17. package/dist/sandbox/types.d.ts +24 -0
  18. package/dist/sandbox/types.js +24 -0
  19. package/dist/tools/ad-hoc-router.d.ts +23 -0
  20. package/dist/tools/ad-hoc-router.js +93 -5
  21. package/dist/tools/bio/alphafold-prediction.d.ts +19 -0
  22. package/dist/tools/bio/alphafold-prediction.js +40 -0
  23. package/dist/tools/bio/index.d.ts +2 -0
  24. package/dist/tools/bio/index.js +3 -0
  25. package/dist/tools/bio/search-protein.d.ts +20 -0
  26. package/dist/tools/bio/search-protein.js +61 -0
  27. package/dist/tools/execute-analysis.d.ts +2 -1
  28. package/dist/tools/execute-analysis.js +66 -5
  29. package/dist/tools/lib/alphafold-client.d.ts +55 -0
  30. package/dist/tools/lib/alphafold-client.js +72 -0
  31. package/dist/tools/lib/api-utils.d.ts +25 -0
  32. package/dist/tools/lib/api-utils.js +45 -3
  33. package/dist/tools/lib/uniprot-client.d.ts +131 -0
  34. package/dist/tools/lib/uniprot-client.js +190 -0
  35. package/dist/tools/research/literature-reviewer.js +2 -0
  36. package/dist/tools/sandbox/list-available-packages.d.ts +25 -0
  37. package/dist/tools/sandbox/list-available-packages.js +51 -29
  38. package/dist/workflows/sandbox-step.js +10 -1
  39. package/dist/workflows/target-assessment/progress-stream.d.ts +75 -0
  40. package/dist/workflows/target-assessment/progress-stream.js +110 -0
  41. package/package.json +1 -1
@@ -0,0 +1,65 @@
1
+ /**
2
+ * What one settled exec is worth to an operator: the bounded outcome that
3
+ * `cortex.sandbox.execs` counts, and the summary that the `step failure`
4
+ * record of `workflows/sandbox-step.ts` carries.
5
+ *
6
+ * No stream text crosses this module. Sandbox output is generated code and the
7
+ * output of that code, thus it is untrusted, and the sandbox-exec-logging spec
8
+ * keeps that text on the sandbox side of the boundary. A summary gives the
9
+ * SIZE of stderr and never a byte of it.
10
+ *
11
+ * The summary of the last exec of a sandbox lives in one process-local cell,
12
+ * keyed by sandbox id. A sandbox belongs to one step (see the teardown of
13
+ * `create-sandbox.ts`), thus the cell is step-scoped in effect and the step
14
+ * body reads it without a new dependency through the agent factory. A replay
15
+ * re-runs the await loop and writes the cell again, thus a recovered body
16
+ * reads the summary of the same exec. The map is capped, so a sandbox whose
17
+ * step never reaches the read site cannot hold memory.
18
+ */
19
+ /** The reason that names a machine the kernel killed for its memory limit. */
20
+ const OOM_KILLED = "sandbox-oom-killed";
21
+ /**
22
+ * The outcome one result carries. A synthetic failure names a dead machine and
23
+ * not a command, thus it wins over every other field. A timeout wins over the
24
+ * exit code that the kill produced. `ok` is exit code 0 and nothing else, thus
25
+ * a result with no code counts as `nonzero`.
26
+ */
27
+ export function sandboxExecOutcomeOf(result) {
28
+ if (result.syntheticFailure)
29
+ return result.syntheticFailure.reason === OOM_KILLED ? "synthetic-oom" : "synthetic-dead";
30
+ if (result.timedOut)
31
+ return "timeout";
32
+ return result.exitCode === 0 ? "ok" : "nonzero";
33
+ }
34
+ /** Reduce a result to its summary. */
35
+ export function summarizeExec(result) {
36
+ return {
37
+ exitCode: result.exitCode,
38
+ timedOut: result.timedOut,
39
+ stderrTotalBytes: result.stderrTotalBytes ?? Buffer.byteLength(result.stderr, "utf8"),
40
+ ...(result.syntheticFailure ? { syntheticFailureReason: result.syntheticFailure.reason } : {}),
41
+ };
42
+ }
43
+ /**
44
+ * How many sandboxes keep a summary. A host runs far fewer sandboxes at once
45
+ * than this, thus the cap only drops the summary of a step that ended long ago.
46
+ */
47
+ const MAX_TRACKED_SANDBOXES = 256;
48
+ const lastExecBySandbox = new Map();
49
+ /** Keep `summary` as the last known exec of `sandboxId`. */
50
+ export function noteExecOutcome(sandboxId, summary) {
51
+ // Delete before set so the key moves to the end of the insertion order and
52
+ // the eviction below drops the least recently written sandbox.
53
+ lastExecBySandbox.delete(sandboxId);
54
+ lastExecBySandbox.set(sandboxId, summary);
55
+ while (lastExecBySandbox.size > MAX_TRACKED_SANDBOXES) {
56
+ const oldest = lastExecBySandbox.keys().next();
57
+ if (oldest.done)
58
+ break;
59
+ lastExecBySandbox.delete(oldest.value);
60
+ }
61
+ }
62
+ /** The last known exec of `sandboxId`, or `undefined` when it ran none. */
63
+ export function lastExecOutcome(sandboxId) {
64
+ return lastExecBySandbox.get(sandboxId);
65
+ }
@@ -95,6 +95,16 @@ export interface K8sClientConfig {
95
95
  coreApi?: CoreV1Api;
96
96
  registerSandbox: (meta: CreateSandboxMeta, ref: SandboxRef) => Promise<void>;
97
97
  }
98
+ /** Clock and sleep seams, so a test can drive the slow-pod warning without waiting a minute. */
99
+ export interface WaitForPodReadyDeps {
100
+ readonly logger?: Logger;
101
+ readonly now?: () => number;
102
+ readonly sleep?: (ms: number) => Promise<void>;
103
+ }
104
+ export declare function waitForPodReady(coreApi: CoreV1Api, namespace: string, sandboxId: string, deps?: WaitForPodReadyDeps): ResultAsync<{
105
+ podIP: string;
106
+ podName: string;
107
+ }, SandboxError>;
98
108
  export declare function createK8sSandboxOps(config: K8sClientConfig): {
99
109
  createSandbox(meta: CreateSandboxMeta, identity: SandboxIdentity): ResultAsync<SandboxRef, SandboxError>;
100
110
  teardown(ref: SandboxRef): ResultAsync<void, SandboxError>;
@@ -30,6 +30,13 @@ const REFS_VOLUME_NAME = "refs";
30
30
  const SANDBOX_SERVER_PORT = 8765;
31
31
  const POD_READY_TIMEOUT_MS = 5 * 60_000;
32
32
  const POD_POLL_INTERVAL_MS = 1_000;
33
+ /**
34
+ * How long a pod can take to reach Running before the wait is worth a warning.
35
+ * A sandbox pod on a warm agent node starts in seconds; a minute means the
36
+ * scheduler is waiting for something — a node to come up, a quota, a volume —
37
+ * and the run pays that minute before any command executes.
38
+ */
39
+ const POD_READY_SLOW_MS = 60_000;
33
40
  const MANAGED_BY_LABEL = "app.kubernetes.io/managed-by";
34
41
  const MANAGED_BY_VALUE = "cortex";
35
42
  /**
@@ -311,7 +318,15 @@ function buildJobSpec(meta, config, identity, farm, libsMounted) {
311
318
  },
312
319
  };
313
320
  }
314
- function waitForPodReady(coreApi, namespace, sandboxId) {
321
+ /**
322
+ * The reason of the first condition the pod does not meet. `PodScheduled` and
323
+ * `Ready` both carry one, and it names what the wait is actually on.
324
+ */
325
+ function pendingConditionReason(pod) {
326
+ const unmet = pod?.status?.conditions?.find((condition) => condition.status !== "True" && (condition.reason ?? "") !== "");
327
+ return unmet?.reason;
328
+ }
329
+ export function waitForPodReady(coreApi, namespace, sandboxId, deps = {}) {
315
330
  const createFailed = (status, cause) => ({
316
331
  type: "container_create_failed",
317
332
  op: "k8s.waitForPodReady",
@@ -319,10 +334,15 @@ function waitForPodReady(coreApi, namespace, sandboxId) {
319
334
  status,
320
335
  cause,
321
336
  });
337
+ const now = deps.now ?? (() => Date.now());
338
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
339
+ const logger = (deps.logger ?? createNoopLogger()).named("k8s-client");
322
340
  return new ResultAsync((async () => {
323
- const deadline = Date.now() + POD_READY_TIMEOUT_MS;
341
+ const startedAt = now();
342
+ const deadline = startedAt + POD_READY_TIMEOUT_MS;
343
+ let warnedSlow = false;
324
344
  let lastSnapshot = {};
325
- while (Date.now() < deadline) {
345
+ while (now() < deadline) {
326
346
  const listed = await trySandbox(() => coreApi.listNamespacedPod({
327
347
  namespace,
328
348
  labelSelector: `cortex/sandbox-id=${sandboxId}`,
@@ -334,6 +354,7 @@ function waitForPodReady(coreApi, namespace, sandboxId) {
334
354
  phase: pod?.status?.phase,
335
355
  podIP: pod?.status?.podIP,
336
356
  podName: pod?.metadata?.name,
357
+ reason: pendingConditionReason(pod),
337
358
  };
338
359
  if (pod && pod.status?.phase === "Running" && typeof pod.status.podIP === "string" && pod.metadata?.name) {
339
360
  return ok({ podIP: pod.status.podIP, podName: pod.metadata.name });
@@ -341,7 +362,21 @@ function waitForPodReady(coreApi, namespace, sandboxId) {
341
362
  if (pod?.status?.phase === "Failed") {
342
363
  return err(createFailed(undefined, new Error(`K8sSandbox ${sandboxId}: pod phase=Failed before becoming ready`)));
343
364
  }
344
- await new Promise((r) => setTimeout(r, POD_POLL_INTERVAL_MS));
365
+ // One warning for one wait: the pod is still pending a minute in, and
366
+ // the condition reason says what it waits on. The timeout error
367
+ // below reports the five-minute give-up.
368
+ const waitedMs = now() - startedAt;
369
+ if (!warnedSlow && waitedMs >= POD_READY_SLOW_MS) {
370
+ warnedSlow = true;
371
+ logger.warn("sandbox pod is slow to reach Running", {
372
+ sandboxId,
373
+ namespace,
374
+ waitedMs,
375
+ phase: lastSnapshot.phase ?? null,
376
+ conditionReason: lastSnapshot.reason ?? null,
377
+ });
378
+ }
379
+ await sleep(POD_POLL_INTERVAL_MS);
345
380
  }
346
381
  return err(createFailed(undefined, new Error(`K8sSandbox ${sandboxId}: pod did not reach Running within ` + `${POD_READY_TIMEOUT_MS}ms (last: ${JSON.stringify(lastSnapshot)})`)));
347
382
  })());
@@ -505,7 +540,7 @@ export function createK8sSandboxOps(config) {
505
540
  // finishes) — the Job controller retries pod creation forever. Delete
506
541
  // the Job on any startup failure so a failed create can't leak a
507
542
  // zombie that floods k8s events indefinitely.
508
- const ready = await waitForPodReady(coreApi, config.namespace, sandboxId);
543
+ const ready = await waitForPodReady(coreApi, config.namespace, sandboxId, { ...(config.logger ? { logger: config.logger } : {}) });
509
544
  if (ready.isOk()) {
510
545
  const ref = {
511
546
  sandboxId,
@@ -51,10 +51,12 @@ export declare function createEscalationPolicy(threshold?: number): {
51
51
  * caller resumes polling.
52
52
  */
53
53
  export declare function probeLiveness(isAlive: (ref: SandboxRef) => Promise<SandboxLiveness>, ref: SandboxRef): Promise<ProbeVerdict>;
54
+ /** The closed set of reasons a synthetic failure carries. */
55
+ export type SyntheticFailureReason = "sandbox-oom-killed" | "sandbox-dead";
54
56
  /**
55
57
  * An OOM-killed machine gets a distinguishable reason so the step failure
56
58
  * reads "exceeded its memory limit", not a mystery death.
57
59
  */
58
- export declare function syntheticFailureReason(liveness: Pick<SandboxLiveness, "oomKilled">): "sandbox-oom-killed" | "sandbox-dead";
60
+ export declare function syntheticFailureReason(liveness: Pick<SandboxLiveness, "oomKilled">): SyntheticFailureReason;
59
61
  /** The synthetic-failure `ExecResult` for an exec whose machine died under it. */
60
62
  export declare function syntheticFailureResult(execId: string, reason: string): ExecResult;
@@ -204,6 +204,22 @@ export declare const ProvenanceFrameSchema: z.ZodObject<{
204
204
  }, z.core.$strip>>>;
205
205
  }, z.core.$strip>;
206
206
  export type ProvenanceFrame = z.infer<typeof ProvenanceFrameSchema>;
207
+ /**
208
+ * What one exec cost the sandbox, as the kernel accounted it at reap time.
209
+ * Mirrors Go's `resourceUsage` (`images/sandbox-base/server/executor.go`):
210
+ * the peak resident set of the command and of every descendant it waited for,
211
+ * and the CPU that same tree burned.
212
+ *
213
+ * Every member is tolerant. A sandbox image that predates the frame sends no
214
+ * `usage` at all, an older or newer one can send a subset, and a malformed
215
+ * frame falls back to absent rather than failing the parse — telemetry never
216
+ * costs a caller its exec result.
217
+ */
218
+ export declare const ExecUsageSchema: z.ZodCatch<z.ZodOptional<z.ZodObject<{
219
+ peakMemoryBytes: z.ZodOptional<z.ZodNumber>;
220
+ cpuMillis: z.ZodOptional<z.ZodNumber>;
221
+ }, z.core.$strip>>>;
222
+ export type ExecUsage = z.infer<typeof ExecUsageSchema>;
207
223
  /**
208
224
  * Final outcome of a single exec, returned by `awaitExec`. Mirrors the
209
225
  * sandbox-server completion payload plus a discriminant for synthetic
@@ -238,6 +254,10 @@ export declare const ExecResultSchema: z.ZodObject<{
238
254
  layers: z.ZodDefault<z.ZodArray<z.ZodString>>;
239
255
  }, z.core.$strip>>>;
240
256
  }, z.core.$strip>>;
257
+ usage: z.ZodCatch<z.ZodOptional<z.ZodObject<{
258
+ peakMemoryBytes: z.ZodOptional<z.ZodNumber>;
259
+ cpuMillis: z.ZodOptional<z.ZodNumber>;
260
+ }, z.core.$strip>>>;
241
261
  }, z.core.$strip>;
242
262
  export type ExecResult = z.infer<typeof ExecResultSchema>;
243
263
  /**
@@ -320,6 +340,10 @@ export declare const PollResponseSchema: z.ZodObject<{
320
340
  layers: z.ZodDefault<z.ZodArray<z.ZodString>>;
321
341
  }, z.core.$strip>>>;
322
342
  }, z.core.$strip>>;
343
+ usage: z.ZodCatch<z.ZodOptional<z.ZodObject<{
344
+ peakMemoryBytes: z.ZodOptional<z.ZodNumber>;
345
+ cpuMillis: z.ZodOptional<z.ZodNumber>;
346
+ }, z.core.$strip>>>;
323
347
  }, z.core.$strip>>;
324
348
  }, z.core.$strip>;
325
349
  export type PollResponse = z.infer<typeof PollResponseSchema>;
@@ -67,6 +67,24 @@ export const ProvenanceFrameSchema = z.object({
67
67
  */
68
68
  deletes: z.array(ProvenanceFrameEntrySchema).default([]),
69
69
  });
70
+ /**
71
+ * What one exec cost the sandbox, as the kernel accounted it at reap time.
72
+ * Mirrors Go's `resourceUsage` (`images/sandbox-base/server/executor.go`):
73
+ * the peak resident set of the command and of every descendant it waited for,
74
+ * and the CPU that same tree burned.
75
+ *
76
+ * Every member is tolerant. A sandbox image that predates the frame sends no
77
+ * `usage` at all, an older or newer one can send a subset, and a malformed
78
+ * frame falls back to absent rather than failing the parse — telemetry never
79
+ * costs a caller its exec result.
80
+ */
81
+ export const ExecUsageSchema = z
82
+ .object({
83
+ peakMemoryBytes: z.number().nonnegative().optional(),
84
+ cpuMillis: z.number().nonnegative().optional(),
85
+ })
86
+ .optional()
87
+ .catch(undefined);
70
88
  /**
71
89
  * Final outcome of a single exec, returned by `awaitExec`. Mirrors the
72
90
  * sandbox-server completion payload plus a discriminant for synthetic
@@ -104,6 +122,12 @@ export const ExecResultSchema = z.object({
104
122
  * the recv payload into the durable DBOS step output.
105
123
  */
106
124
  provenance: ProvenanceFrameSchema.optional(),
125
+ /**
126
+ * Kernel accounting of the exec, for the sandbox-sizing histograms. Absent
127
+ * from a synthetic watchdog failure, from a sandbox image that predates the
128
+ * frame, and from any exec whose command never spawned.
129
+ */
130
+ usage: ExecUsageSchema,
107
131
  });
108
132
  /**
109
133
  * Per-message envelope on the per-exec DBOS topic. Real callbacks carry
@@ -3,13 +3,29 @@ import { type AgentSession } from "../auth/types.js";
3
3
  import type { ResourcePolicy, ResourceSpec } from "../config/resource-limits.js";
4
4
  import type { Logger } from "../lib/logger.js";
5
5
  import { type ChatProvider } from "../providers/types.js";
6
+ import type { CheckedPackage } from "./sandbox/list-available-packages.js";
6
7
  export declare const AD_HOC_ROUTER_AGENT_ID = "adhoc-router";
7
8
  export declare const AD_HOC_ROUTER_TIMEOUT_MS = 10000;
8
9
  export declare const AD_HOC_FALLBACK_AGENT_ID = "scientific-executor";
10
+ /**
11
+ * A package entry that the step does not carry.
12
+ *
13
+ * - `unparsable` — the entry is not a query of the one grammar.
14
+ * - `absent` — the bound resolution answered that the pool holds no such
15
+ * package, and it offered no other spelling.
16
+ */
17
+ export interface DroppedPackage {
18
+ readonly entry: string;
19
+ readonly reason: "unparsable" | "absent";
20
+ }
9
21
  export interface AdHocRoute {
10
22
  readonly agentId: string;
11
23
  readonly resources: ResourceSpec;
12
24
  readonly rationale: string;
25
+ /** The validated package entries of the step. Empty is a normal answer. */
26
+ readonly packages: readonly string[];
27
+ /** Every entry that the validation removed, with the reason for the caveat. */
28
+ readonly droppedPackages: readonly DroppedPackage[];
13
29
  readonly fallbackClass?: "timeout" | "provider_error" | "malformed" | "no_match" | "invalid_agent" | "invalid_resources";
14
30
  }
15
31
  export interface AdHocRouterDeps {
@@ -25,6 +41,13 @@ export interface AdHocRouterDeps {
25
41
  * provider advertises.
26
42
  */
27
43
  readonly timeoutMs?: number;
44
+ /**
45
+ * A targeted presence check of the names that the route emits. `null` means
46
+ * that the inventory could not answer at all, and every parsed entry then
47
+ * stays — an unreadable inventory says nothing about a package. Unbound,
48
+ * the entries reach the link pass as the model wrote them.
49
+ */
50
+ readonly resolvePackages?: (names: readonly string[]) => Promise<readonly CheckedPackage[] | null>;
28
51
  }
29
52
  export declare function defaultAdHocResources(policy?: ResourcePolicy): ResourceSpec;
30
53
  export declare function validAdHocResources(value: unknown, policy?: ResourcePolicy): ResourceSpec | null;
@@ -5,8 +5,9 @@ import { forSubAgent } from "../auth/types.js";
5
5
  import { DATA_PROFILE_ORIENTATION_MAX_CHARS, buildDataProfileOrientation } from "../app/data-profile-orientation.js";
6
6
  import { createNoopLogger } from "../lib/console-logger.js";
7
7
  import { unwrapOrThrow } from "../lib/result.js";
8
- import { resourceEstimationSection } from "../prompts/planner.js";
8
+ import { packagesSection, resourceEstimationSection } from "../prompts/planner.js";
9
9
  import { effectiveDeadlineMs } from "../providers/types.js";
10
+ import { formatQuery, parseQuery } from "../sandbox/package-identity.js";
10
11
  import { loadDataProfileStatus } from "../state/index.js";
11
12
  export const AD_HOC_ROUTER_AGENT_ID = "adhoc-router";
12
13
  export const AD_HOC_ROUTER_TIMEOUT_MS = 10_000;
@@ -20,6 +21,7 @@ const routeSchema = z.object({
20
21
  agentId: z.string().optional(),
21
22
  resources: resourcesSchema.optional(),
22
23
  rationale: z.string().optional(),
24
+ packages: z.array(z.string()).optional(),
23
25
  });
24
26
  export function defaultAdHocResources(policy) {
25
27
  return policy ? { cpu: Math.min(4, policy.perStep.maxCpu), memoryGb: Math.min(8, policy.perStep.maxMemoryGb) } : { cpu: 4, memoryGb: 8 };
@@ -60,10 +62,90 @@ async function profileOrientation(pool, analysisId, logger) {
60
62
  return "The persisted data profile could not be loaded; route from the request alone.";
61
63
  }
62
64
  }
65
+ /** The spelling that the pool holds for a missed entry, where the resolution offers one. */
66
+ function suggestedSpelling(rows) {
67
+ for (const row of rows) {
68
+ if (!row.present && row.suggestion !== undefined)
69
+ return row.suggestion;
70
+ }
71
+ return undefined;
72
+ }
73
+ /**
74
+ * Validate the package entries of a route on their own axis: a bad entry costs
75
+ * that entry and nothing else, because the specialist and the resources are
76
+ * sound without it.
77
+ *
78
+ * A guess of a model reaches here, and no person reviewed it. Thus a name that
79
+ * the pool does not hold leaves the step with a caveat, where the same name in
80
+ * a plan step — which a person approved — refuses the launch instead.
81
+ */
82
+ async function routePackages(raw, resolve, logger) {
83
+ const entries = Array.isArray(raw) ? raw : [];
84
+ const droppedPackages = [];
85
+ const kept = [];
86
+ const seen = new Set();
87
+ for (const element of entries) {
88
+ if (typeof element !== "string") {
89
+ droppedPackages.push({ entry: String(element), reason: "unparsable" });
90
+ continue;
91
+ }
92
+ const entry = element.trim();
93
+ const parsed = parseQuery(entry);
94
+ if (parsed.isErr()) {
95
+ droppedPackages.push({ entry, reason: "unparsable" });
96
+ continue;
97
+ }
98
+ // Two equal entries are one ask, and the link pass unions them anyway.
99
+ if (seen.has(entry))
100
+ continue;
101
+ seen.add(entry);
102
+ kept.push({ entry, query: parsed.value });
103
+ }
104
+ const asWritten = () => kept.map((candidate) => candidate.entry);
105
+ if (!resolve || kept.length === 0)
106
+ return { packages: asWritten(), droppedPackages };
107
+ let rows;
108
+ try {
109
+ rows = await resolve(asWritten());
110
+ }
111
+ catch (error) {
112
+ // A resolution that throws is the same condition as an inventory that
113
+ // reports itself unavailable: it says nothing about the packages.
114
+ logger.warn("ad hoc package resolution failed", { error: error instanceof Error ? error.message : String(error) });
115
+ rows = null;
116
+ }
117
+ if (rows === null)
118
+ return { packages: asWritten(), droppedPackages };
119
+ const packages = [];
120
+ const emitted = new Set();
121
+ for (const { entry, query } of kept) {
122
+ // One name that both tracks hold answers with one row per track, thus a
123
+ // present row anywhere in the answer keeps the entry as it was written.
124
+ // The link pass then refuses the bare form with the two prefixed ones.
125
+ const answer = rows.filter((row) => row.requested === entry);
126
+ const held = answer.length === 0 || answer.some((row) => row.present);
127
+ const suggestion = held ? undefined : suggestedSpelling(answer);
128
+ if (!held && suggestion === undefined) {
129
+ droppedPackages.push({ entry, reason: "absent" });
130
+ continue;
131
+ }
132
+ // The track and the version of the ask survive the rewrite: only the
133
+ // spelling missed, and the entry asked for that exact pin.
134
+ const resolved = suggestion === undefined ? entry : formatQuery({ ...query, spelling: suggestion });
135
+ // A rewrite can land on the spelling that another entry already carries,
136
+ // thus the answer dedupes as well as the input: `r:seurat` and
137
+ // `r:SEURAT` are one ask once both take the spelling that the pool holds.
138
+ if (emitted.has(resolved))
139
+ continue;
140
+ emitted.add(resolved);
141
+ packages.push(resolved);
142
+ }
143
+ return { packages, droppedPackages };
144
+ }
63
145
  function routeTool() {
64
146
  return {
65
147
  submit_route: aiTool({
66
- description: "Submit the single specialist and resource recommendation. This is the only valid response.",
148
+ description: "Submit the single specialist, the resource recommendation, and the packages of the step. This is the only valid response.",
67
149
  inputSchema: jsonSchema(z.toJSONSchema(routeSchema)),
68
150
  }),
69
151
  };
@@ -80,10 +162,12 @@ export async function routeAdHocRequest(deps, input) {
80
162
  try {
81
163
  const response = unwrapOrThrow(await deps.provider.chat({
82
164
  system: [
83
- "Select exactly one specialist for a targeted one-step analysis and estimate its sandbox resources.",
165
+ "Select exactly one specialist for a targeted one-step analysis, estimate its sandbox resources, and name the packages that the step imports.",
84
166
  "Choose only from the supplied catalog. Do not create a plan, reject the request, or select scientific-executor.",
85
167
  resourceEstimationSection(deps.resourcePolicy),
86
168
  resourceBounds(deps.resourcePolicy),
169
+ packagesSection(),
170
+ "You hold no package census. For a name that both ecosystems hold, the language of the specialist you select and the wording of the request decide the track.",
87
171
  "Respond only by calling submit_route.",
88
172
  ].join("\n\n"),
89
173
  messages: [
@@ -128,6 +212,10 @@ export async function routeAdHocRequest(deps, input) {
128
212
  : failure
129
213
  ? `Deterministic fallback: ${failure}`
130
214
  : `Selected ${agentId}`;
131
- logger.info("ad hoc route selected", { agentId, resources, rationale, failure });
132
- return { agentId, resources, rationale, ...(failure ? { fallbackClass: failure } : {}) };
215
+ // The packages validate last and on their own axis: a package failure
216
+ // never becomes a `fallbackClass`, because that class describes the route
217
+ // as a whole and a step agent reads it as "the routing itself fell back".
218
+ const { packages, droppedPackages } = await routePackages(candidate.packages, deps.resolvePackages, logger);
219
+ logger.info("ad hoc route selected", { agentId, resources, rationale, failure, packages, droppedPackages });
220
+ return { agentId, resources, rationale, packages, droppedPackages, ...(failure ? { fallbackClass: failure } : {}) };
133
221
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * alphafoldPrediction — the AlphaFold DB predicted 3-D structure of one
3
+ * UniProt accession.
4
+ *
5
+ * Returns model metadata, confidence, and artifact URLs only — never file
6
+ * contents. A predicted `.pdb` or `.cif` file holds megabytes of coordinates
7
+ * that give the model no benefit in a chat turn. A download of one belongs in
8
+ * the sandbox, where a step can fetch a URL that this tool returns.
9
+ */
10
+ import { type AlphaFoldPrediction } from "../lib/alphafold-client.js";
11
+ export type AlphaFoldPredictionOutput = {
12
+ readonly found: false;
13
+ readonly uniprotAccession: string;
14
+ } | ({
15
+ readonly found: true;
16
+ } & AlphaFoldPrediction);
17
+ export declare const alphafoldPredictionTool: import("../define-tool.js").Tool<{
18
+ uniprotAccession: string;
19
+ }, AlphaFoldPredictionOutput>;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * alphafoldPrediction — the AlphaFold DB predicted 3-D structure of one
3
+ * UniProt accession.
4
+ *
5
+ * Returns model metadata, confidence, and artifact URLs only — never file
6
+ * contents. A predicted `.pdb` or `.cif` file holds megabytes of coordinates
7
+ * that give the model no benefit in a chat turn. A download of one belongs in
8
+ * the sandbox, where a step can fetch a URL that this tool returns.
9
+ */
10
+ import { ok } from "neverthrow";
11
+ import { z } from "zod";
12
+ import { defineTool } from "../define-tool.js";
13
+ import { fetchAlphaFoldPrediction } from "../lib/alphafold-client.js";
14
+ export const alphafoldPredictionTool = defineTool({
15
+ id: "alphafold_prediction",
16
+ description: "AlphaFold DB — the predicted 3-D protein structure of EMBL-EBI and DeepMind, keyed by a UniProt accession ('P38398'). Predicted confidence " +
17
+ "(pLDDT), not experimental certainty: it tells apart a folded domain from a disordered loop, it does not replace a solved structure.\n" +
18
+ "`globalMetricValue` is the mean pLDDT over the whole chain, and each `fractionPlddt*` is the fraction of residues in one band: above 90 is " +
19
+ "very high, 70 to 90 is confident, 50 to 70 is low, and below 50 is very low or disordered. A LOW globalMetricValue is not a failed " +
20
+ "prediction — BRCA1 scores about 41 because BRCA1 is largely disordered, and AlphaFold reports that correctly. Both values describe the whole " +
21
+ "chain and neither locates a region: `plddtDocUrl` holds the per-residue pLDDT that does.\n" +
22
+ "Returns URLs only (`pdbUrl`, `cifUrl`, `paeImageUrl`, `plddtDocUrl` — the per-residue confidence, `paeDocUrl` — the predicted aligned error " +
23
+ "matrix, `amAnnotationsUrl` — AlphaMissense pathogenicity annotations, present for a HUMAN canonical accession only, thus absent on a " +
24
+ "non-human protein and on an isoform): fetch one from the sandbox to inspect the coordinates, the per-residue confidence, or the annotation " +
25
+ "table, do not expect the file contents here.\n" +
26
+ "found: false means AlphaFold holds no model for the accession (not every UniProt entry has one) — report it and continue, do not retry.",
27
+ inputSchema: z.object({
28
+ uniprotAccession: z.string().min(1).describe("A UniProt accession, for example 'P38398' (BRCA1) or 'P69905' (hemoglobin subunit alpha)."),
29
+ }),
30
+ describeCall: ({ uniprotAccession }) => uniprotAccession,
31
+ execute: async ({ uniprotAccession }) => {
32
+ // The client trims before it builds the URL, thus a miss echoes the same
33
+ // accession that the request carried and not the padded argument.
34
+ const accession = uniprotAccession.trim();
35
+ const prediction = await fetchAlphaFoldPrediction(accession);
36
+ if (!prediction)
37
+ return ok({ found: false, uniprotAccession: accession });
38
+ return ok({ found: true, ...prediction });
39
+ },
40
+ });
@@ -20,8 +20,10 @@
20
20
  * sparse one and widen deliberately.
21
21
  */
22
22
  export * from "./search-gene.js";
23
+ export * from "./search-protein.js";
23
24
  export * from "./lookup-annotation.js";
24
25
  export * from "./search-interactions.js";
26
+ export * from "./alphafold-prediction.js";
25
27
  export * from "./pubmed.js";
26
28
  export * from "./chembl.js";
27
29
  export * from "./pubchem.js";
@@ -21,9 +21,12 @@
21
21
  */
22
22
  // Identifier resolution
23
23
  export * from "./search-gene.js";
24
+ export * from "./search-protein.js";
24
25
  // Functional annotation / networks
25
26
  export * from "./lookup-annotation.js";
26
27
  export * from "./search-interactions.js";
28
+ // Structural biology
29
+ export * from "./alphafold-prediction.js";
27
30
  // Literature
28
31
  export * from "./pubmed.js";
29
32
  // Cheminformatics
@@ -0,0 +1,20 @@
1
+ /**
2
+ * searchProtein — resolve a protein against UniProtKB.
3
+ *
4
+ * `search_gene` accepts a UniProt accession, but it answers with Ensembl gene
5
+ * records, thus nothing gave an agent the protein itself: the name, the length,
6
+ * the curated function, and where in the cell it acts. This tool answers that,
7
+ * and its accession is the key that the structural tools take.
8
+ */
9
+ import { type UniProtProtein } from "../lib/uniprot-client.js";
10
+ export interface SearchProteinOutput {
11
+ readonly proteins: UniProtProtein[];
12
+ /** True when UniProt held at least one row beyond `limit`. */
13
+ readonly hasMore: boolean;
14
+ }
15
+ export declare const searchProteinTool: import("../define-tool.js").Tool<{
16
+ query: string;
17
+ organismId: number | null;
18
+ reviewedOnly: boolean;
19
+ limit: number;
20
+ }, SearchProteinOutput>;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * searchProtein — resolve a protein against UniProtKB.
3
+ *
4
+ * `search_gene` accepts a UniProt accession, but it answers with Ensembl gene
5
+ * records, thus nothing gave an agent the protein itself: the name, the length,
6
+ * the curated function, and where in the cell it acts. This tool answers that,
7
+ * and its accession is the key that the structural tools take.
8
+ */
9
+ import { ok } from "neverthrow";
10
+ import { z } from "zod";
11
+ import { defineTool } from "../define-tool.js";
12
+ import { searchProteins } from "../lib/uniprot-client.js";
13
+ /** Rows returned by default. An exact gene symbol resolves to one reviewed protein. */
14
+ const DEFAULT_LIMIT = 10;
15
+ const MAX_LIMIT = 50;
16
+ export const searchProteinTool = defineTool({
17
+ id: "search_protein",
18
+ description: "UniProtKB — the curated protein knowledgebase of EMBL-EBI, SIB and PIR. Resolves a protein and answers with its accession, its UniProtKB ID " +
19
+ "('BRCA1_HUMAN'), its recommended name, its gene names, its sequence length, its curated FUNCTION summary, and its subcellular locations.\n" +
20
+ "ACCEPTED IDENTIFIERS: a HUGO gene symbol ('BRCA1'), matched exactly, or a UniProt accession ('P38398', 'A0A0B4J1Y9'). The two spaces overlap — " +
21
+ "'P2RY12' is both a real symbol and a valid accession shape — so an accession-shaped input searches BOTH, and either kind resolves. A protein " +
22
+ "NAME is not accepted; resolve the symbol first.\n" +
23
+ "This is the tool that produces a UniProt accession, which is the key that the structure tools take. Prefer it over search_gene when the " +
24
+ "question is about the PROTEIN — what it does, how long it is, where it acts — and search_gene when the question is about the gene locus, its " +
25
+ "coordinates or its Ensembl ID.\n" +
26
+ "`reviewedOnly` defaults to true, thus an answer holds Swiss-Prot entries only: manually curated, one entry per gene, and the right default for " +
27
+ "a human question. Set it false to reach TrEMBL as well, which is machine-annotated and holds many isoform-level rows per gene — needed for a " +
28
+ "protein that Swiss-Prot has not curated.\n" +
29
+ "`organismId` takes an NCBI Taxonomy ID and defaults to 9606 (human); pass 10090 for mouse, or null to search every organism. Both this and " +
30
+ "`reviewedOnly` narrow a SYMBOL match only — an accession is a unique key, so it resolves whatever its organism or review status.\n" +
31
+ "An empty `proteins` array is valid no-data (an unrecognized symbol, or one this organism has no entry for) — report it and continue, do not " +
32
+ "retry the same call. `hasMore` true means the answer was trimmed to `limit`.",
33
+ inputSchema: z.object({
34
+ query: z.string().min(1).describe("A gene symbol ('BRCA1') or a UniProt accession ('P38398')."),
35
+ organismId: z
36
+ .number()
37
+ .int()
38
+ .positive()
39
+ .nullable()
40
+ .default(9606)
41
+ .describe("NCBI Taxonomy ID (9606 = human, 10090 = mouse). Null searches every organism, which is how to reach a non-model species."),
42
+ reviewedOnly: z.boolean().default(true).describe("True (default) keeps Swiss-Prot entries only. False also returns machine-annotated TrEMBL entries."),
43
+ limit: z
44
+ .number()
45
+ .int()
46
+ .min(1)
47
+ .max(MAX_LIMIT)
48
+ .default(DEFAULT_LIMIT)
49
+ .describe(`Max proteins returned (default ${DEFAULT_LIMIT}, max ${MAX_LIMIT}). An exact symbol under reviewedOnly resolves to one entry, so raise ` +
50
+ "this only for a reviewedOnly=false survey, where a gene carries many TrEMBL rows."),
51
+ }),
52
+ describeCall: ({ query }) => query,
53
+ execute: async ({ query, organismId, reviewedOnly, limit }) => {
54
+ const result = await searchProteins(query, {
55
+ ...(organismId === null ? {} : { organismId }),
56
+ reviewedOnly,
57
+ limit,
58
+ });
59
+ return ok(result);
60
+ },
61
+ });
@@ -3,6 +3,7 @@
3
3
  * hoc analyses. Both modes launch the ordinary executeAnalysis workflow.
4
4
  */
5
5
  import type { Pool } from "pg";
6
+ import type { EnvironmentStorePaths } from "../config/environment-stores.js";
6
7
  import type { ResourcePolicy } from "../config/resource-limits.js";
7
8
  import type { RunAuthorizer } from "../execution/run-authorizer.js";
8
9
  import type { RunLauncher } from "../execution/run-launcher.js";
@@ -13,7 +14,7 @@ import { type AnalysisPlan } from "../schemas/workflow-state.js";
13
14
  import type { ExecuteAnalysisInput, ExecuteAnalysisResult } from "../workflows/execute-analysis.js";
14
15
  import { type AdHocRoute } from "./ad-hoc-router.js";
15
16
  type ExecuteAnalysisWorkflow = (input: ExecuteAnalysisInput) => Promise<ExecuteAnalysisResult>;
16
- export interface ExecuteAnalysisToolDeps {
17
+ export interface ExecuteAnalysisToolDeps extends Pick<EnvironmentStorePaths, "farmLockFile" | "imagePackagesFile" | "readPoolInventory"> {
17
18
  readonly pool: Pool;
18
19
  readonly executeAnalysisWorkflow: ExecuteAnalysisWorkflow;
19
20
  readonly runAuthorizer: RunAuthorizer;