@inflexa-ai/harness 0.32.0 → 0.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/agents/conversation-agent.d.ts +8 -0
  2. package/dist/agents/conversation-agent.js +15 -6
  3. package/dist/agents/sandbox/shared.d.ts +13 -1
  4. package/dist/agents/sandbox/shared.js +22 -5
  5. package/dist/config/environment-stores.d.ts +53 -5
  6. package/dist/index.d.ts +4 -0
  7. package/dist/index.js +3 -0
  8. package/dist/prompts/briefing.d.ts +1 -1
  9. package/dist/prompts/briefing.js +4 -0
  10. package/dist/prompts/planner.js +13 -0
  11. package/dist/prompts/sandbox/multi-omics-integration-agent.d.ts +1 -1
  12. package/dist/prompts/sandbox/multi-omics-integration-agent.js +5 -5
  13. package/dist/prompts/sandbox/network-agent.d.ts +1 -1
  14. package/dist/prompts/sandbox/network-agent.js +4 -3
  15. package/dist/prompts/sandbox-standards.d.ts +20 -0
  16. package/dist/prompts/sandbox-standards.js +65 -0
  17. package/dist/reference-data/catalog.js +11 -9
  18. package/dist/sandbox/create-sandbox.d.ts +29 -4
  19. package/dist/sandbox/create-sandbox.js +6 -0
  20. package/dist/sandbox/docker-client.d.ts +16 -6
  21. package/dist/sandbox/docker-client.js +47 -31
  22. package/dist/sandbox/farm.d.ts +95 -0
  23. package/dist/sandbox/farm.js +127 -0
  24. package/dist/sandbox/k8s-client.d.ts +24 -5
  25. package/dist/sandbox/k8s-client.js +78 -9
  26. package/dist/sandbox/mount-plan.d.ts +23 -1
  27. package/dist/sandbox/mount-plan.js +44 -7
  28. package/dist/sandbox/sandbox-error.d.ts +12 -0
  29. package/dist/sandbox/sandbox-error.js +2 -0
  30. package/dist/sandbox/types.d.ts +118 -0
  31. package/dist/schemas/plan-schemas.d.ts +3 -0
  32. package/dist/schemas/plan-schemas.js +1 -1
  33. package/dist/schemas/validate-plan.js +20 -1
  34. package/dist/schemas/workflow-state.d.ts +4 -0
  35. package/dist/schemas/workflow-state.js +10 -0
  36. package/dist/tasks/data-profile.d.ts +15 -0
  37. package/dist/tasks/data-profile.js +36 -26
  38. package/dist/tools/execute-analysis.d.ts +18 -0
  39. package/dist/tools/execute-analysis.js +87 -0
  40. package/dist/tools/research/generate-plan.js +12 -2
  41. package/dist/tools/sandbox/index.d.ts +1 -0
  42. package/dist/tools/sandbox/index.js +1 -0
  43. package/dist/tools/sandbox/link-packages.d.ts +27 -0
  44. package/dist/tools/sandbox/link-packages.js +52 -0
  45. package/dist/tools/sandbox/list-available-packages.d.ts +50 -30
  46. package/dist/tools/sandbox/list-available-packages.js +131 -40
  47. package/package.json +2 -2
@@ -8,6 +8,7 @@ import type { RunAuthorizer } from "../execution/run-authorizer.js";
8
8
  import type { RunLauncher } from "../execution/run-launcher.js";
9
9
  import type { Logger } from "../lib/logger.js";
10
10
  import type { ChatProvider } from "../providers/types.js";
11
+ import type { ExtendAnalysisFarm } from "../sandbox/types.js";
11
12
  import { type AnalysisPlan } from "../schemas/workflow-state.js";
12
13
  import type { ExecuteAnalysisInput, ExecuteAnalysisResult } from "../workflows/execute-analysis.js";
13
14
  import { type AdHocRoute } from "./ad-hoc-router.js";
@@ -21,6 +22,12 @@ export interface ExecuteAnalysisToolDeps {
21
22
  readonly utilityProvider: ChatProvider;
22
23
  readonly utilityModel: string;
23
24
  readonly logger?: Logger;
25
+ /**
26
+ * The farm-extension seam of the embedder. When it is bound, the launch
27
+ * links the packages of the plan into the farm of the analysis before the
28
+ * run reserves anything. Without it, the link pass returns at once.
29
+ */
30
+ readonly extendAnalysisFarm?: ExtendAnalysisFarm;
24
31
  }
25
32
  export declare class PlanNotFoundError extends Error {
26
33
  constructor(planId: string);
@@ -32,6 +39,17 @@ export declare class PlanValidationError extends Error {
32
39
  readonly errors: string[];
33
40
  constructor(planId: string, errors: string[]);
34
41
  }
42
+ /**
43
+ * A pool miss at the pre-launch link pass. The message names the missing
44
+ * packages and no remedy command — the remedy belongs to the embedder, and
45
+ * its wrapper appends it.
46
+ */
47
+ export declare class PlanPackagesMissingError extends Error {
48
+ readonly missing: string[];
49
+ readonly collisions: string[];
50
+ readonly unavailable?: string | undefined;
51
+ constructor(missing: string[], collisions: string[], unavailable?: string | undefined);
52
+ }
35
53
  export declare function buildAdHocPlan(request: string, route: AdHocRoute, createdAt?: string): AnalysisPlan;
36
54
  export declare function createExecuteAnalysisTool(deps: ExecuteAnalysisToolDeps): import("./define-tool.js").Tool<{
37
55
  mode: "plan" | "adhoc";
@@ -54,6 +54,90 @@ export class PlanValidationError extends Error {
54
54
  function isDedupCollision(err) {
55
55
  return err instanceof RunDedupCollisionError || (err instanceof Error && err.name === "RunDedupCollisionError");
56
56
  }
57
+ /**
58
+ * A pool miss at the pre-launch link pass. The message names the missing
59
+ * packages and no remedy command — the remedy belongs to the embedder, and
60
+ * its wrapper appends it.
61
+ */
62
+ export class PlanPackagesMissingError extends Error {
63
+ missing;
64
+ collisions;
65
+ unavailable;
66
+ constructor(missing, collisions, unavailable) {
67
+ const parts = [
68
+ // The store-level reason FIRST, because it invalidates the rest: with an
69
+ // unreadable graph, no per-package claim below it is trustworthy.
70
+ unavailable !== undefined
71
+ ? `the store cannot answer (${unavailable}) — this says nothing about the packages themselves, so do not re-request them`
72
+ : null,
73
+ missing.length > 0 ? `the pool does not hold: ${missing.join(", ")}` : null,
74
+ collisions.length > 0 ? `a collision refused: ${collisions.join(", ")}` : null,
75
+ ].filter((p) => p !== null);
76
+ super(`The packages of this plan cannot link — ${parts.join("; ")}`);
77
+ this.missing = missing;
78
+ this.collisions = collisions;
79
+ this.unavailable = unavailable;
80
+ this.name = "PlanPackagesMissingError";
81
+ }
82
+ }
83
+ /** One plan package entry, in requirement form: a bare name, or a name with one exact version. */
84
+ function parseRequirement(entry) {
85
+ const split = entry.indexOf("==");
86
+ if (split < 0)
87
+ return { name: entry.trim() };
88
+ return { name: entry.slice(0, split).trim(), version: entry.slice(split + 2).trim() };
89
+ }
90
+ /**
91
+ * Link the packages of the plan into the farm of the analysis, before the
92
+ * run reserves anything. The linked set is the union of the packages of each
93
+ * step. A pool miss and a collision refuse the launch with the names. Without
94
+ * a bound seam, the pass returns at once.
95
+ */
96
+ async function linkPlanPackages(extendAnalysisFarm, analysisId, plan) {
97
+ if (!extendAnalysisFarm)
98
+ return;
99
+ const union = new Map();
100
+ for (const step of plan.steps) {
101
+ for (const entry of step.packages ?? []) {
102
+ // The FIRST spelling of the plan rides the request. An outcome
103
+ // echoes the requested spelling, and a last-writer fold could
104
+ // echo `seurat` where the plan said `Seurat` — a remedy in that
105
+ // case names a package that no repository holds.
106
+ const key = entry.trim().toLowerCase();
107
+ if (!union.has(key))
108
+ union.set(key, parseRequirement(entry));
109
+ }
110
+ }
111
+ if (union.size === 0)
112
+ return;
113
+ let outcomes;
114
+ try {
115
+ outcomes = await extendAnalysisFarm(analysisId, [...union.values()]);
116
+ }
117
+ catch (cause) {
118
+ // A realization throw is an `unavailable` answer, not a driver error.
119
+ // The sibling resolver seam maps a throw the same way (farm.ts), and
120
+ // the planner can act on "the store cannot answer" — not on a stack.
121
+ throw new PlanPackagesMissingError([], [], cause instanceof Error ? cause.message : String(cause));
122
+ }
123
+ // An `unavailable` outcome preempts everything: the link pass could not
124
+ // answer at all (an unreadable graph, a locked farm), so a per-package
125
+ // absence list would be a fabrication. One reason covers the batch.
126
+ const unavailable = outcomes.find((o) => o.kind === "unavailable");
127
+ if (unavailable !== undefined) {
128
+ throw new PlanPackagesMissingError([], [], unavailable.reason);
129
+ }
130
+ // A seam-supplied detail classifies the miss in host terms (in flight,
131
+ // failed with its reason, never requested), and it rides beside the name
132
+ // so the agent replans from the true state instead of re-asking. A
133
+ // collision's detail names the two pins and what needs each side — the
134
+ // remedy is to drop or re-pin a DEPENDENT, so the refusal must name it.
135
+ const missing = outcomes.filter((o) => o.kind === "absent").map((o) => (o.detail === undefined ? o.name : `${o.name} — ${o.detail}`));
136
+ const collisions = outcomes.filter((o) => o.kind === "collision").map((o) => (o.detail === undefined ? o.name : `${o.name} — ${o.detail}`));
137
+ if (missing.length > 0 || collisions.length > 0) {
138
+ throw new PlanPackagesMissingError(missing, collisions);
139
+ }
140
+ }
57
141
  function planSummary(plan) {
58
142
  return plan.title?.trim() || plan.analytical_narrative.trim().slice(0, 280);
59
143
  }
@@ -183,6 +267,9 @@ export function createExecuteAnalysisTool(deps) {
183
267
  else {
184
268
  plan = await persistedAdHocPlan(deps, { analysisId, request: input.request, ctx, planId });
185
269
  }
270
+ // The link pass comes before the run reserves anything, thus a pool
271
+ // miss leaves no run row and no revoked authorization behind.
272
+ await linkPlanPackages(deps.extendAnalysisFarm, analysisId, plan);
186
273
  const runId = input.mode === "plan" ? randomUUID() : adHocRunId(analysisId, ctx.invocationId);
187
274
  if (input.mode === "plan") {
188
275
  try {
@@ -677,7 +677,13 @@ function buildPlannerSearchTools(deps) {
677
677
  // collection of the reference store, or one package name that a step is
678
678
  // about to import.
679
679
  createListAvailableRefsTool(deps.refStorePath === undefined ? {} : { refStorePath: deps.refStorePath }),
680
- createListAvailablePackagesTool(deps.packagesFile === undefined ? {} : { packagesFile: deps.packagesFile }),
680
+ // The planner is a conversation surface, thus the pool-scope reader binds
681
+ // when the embedder gives one, and the answer names what the store HOLDS.
682
+ createListAvailablePackagesTool({
683
+ ...(deps.farmLockFile === undefined ? {} : { farmLockFile: deps.farmLockFile }),
684
+ ...(deps.imagePackagesFile === undefined ? {} : { imagePackagesFile: deps.imagePackagesFile }),
685
+ ...(deps.readPoolInventory === undefined ? {} : { readPoolInventory: deps.readPoolInventory }),
686
+ }),
681
687
  ];
682
688
  }
683
689
  /** Build the `generate_plan` tool bound to its provider and pool. */
@@ -804,7 +810,11 @@ export function createGeneratePlanTool(deps) {
804
810
  // not optional planner lookups. Read both inventories host-side before
805
811
  // the loop and put their rendered content in the planner's seed.
806
812
  const listAvailableRefs = createListAvailableRefsTool(deps.refStorePath === undefined ? {} : { refStorePath: deps.refStorePath });
807
- const listAvailablePackages = createListAvailablePackagesTool(deps.packagesFile === undefined ? {} : { packagesFile: deps.packagesFile });
813
+ const listAvailablePackages = createListAvailablePackagesTool({
814
+ ...(deps.farmLockFile === undefined ? {} : { farmLockFile: deps.farmLockFile }),
815
+ ...(deps.imagePackagesFile === undefined ? {} : { imagePackagesFile: deps.imagePackagesFile }),
816
+ ...(deps.readPoolInventory === undefined ? {} : { readPoolInventory: deps.readPoolInventory }),
817
+ });
808
818
  // A path-separator query matches every reference path and selects the
809
819
  // tool's recursive leaf scan, so the seed contains usable file paths,
810
820
  // not only top-level directory summaries.
@@ -2,6 +2,7 @@
2
2
  * Sandbox barrel — sandbox-environment introspection (available lib-store
3
3
  * packages and reference datasets). Consumed by sandbox agents.
4
4
  */
5
+ export * from "./link-packages.js";
5
6
  export * from "./list-available-packages.js";
6
7
  export * from "./list-available-refs.js";
7
8
  export * from "./report-blocker.js";
@@ -2,6 +2,7 @@
2
2
  * Sandbox barrel — sandbox-environment introspection (available lib-store
3
3
  * packages and reference datasets). Consumed by sandbox agents.
4
4
  */
5
+ export * from "./link-packages.js";
5
6
  export * from "./list-available-packages.js";
6
7
  export * from "./list-available-refs.js";
7
8
  export * from "./report-blocker.js";
@@ -0,0 +1,27 @@
1
+ /**
2
+ * link_packages — extend the farm of the analysis with host-staged packages.
3
+ *
4
+ * The tool is the agent face of the `ExtendAnalysisFarm` seam. It exists only
5
+ * when the embedder binds the seam (the harness-sandbox-agents spec): the
6
+ * composition adds it to the always-on substrate, and no `meta.tools`
7
+ * allowlist names it. The realization of the embedder links what the host
8
+ * staged — the tool never installs, downloads, or acquires anything. A link
9
+ * is live in a container that already runs, because the farm rides a bind
10
+ * mount.
11
+ */
12
+ import type { ExtendAnalysisFarm, PackageRequestOutcome } from "../../sandbox/types.js";
13
+ export interface LinkPackagesDeps {
14
+ readonly extendAnalysisFarm: ExtendAnalysisFarm;
15
+ readonly analysisId: string;
16
+ }
17
+ /** The tool result: one outcome per requested package, index-aligned. */
18
+ export interface LinkPackagesResult {
19
+ readonly outcomes: readonly PackageRequestOutcome[];
20
+ }
21
+ export declare function createLinkPackagesTool(deps: LinkPackagesDeps): import("../define-tool.js").Tool<{
22
+ packages: {
23
+ name: string;
24
+ version?: string | undefined;
25
+ ecosystem?: "r" | "python" | undefined;
26
+ }[];
27
+ }, LinkPackagesResult>;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * link_packages — extend the farm of the analysis with host-staged packages.
3
+ *
4
+ * The tool is the agent face of the `ExtendAnalysisFarm` seam. It exists only
5
+ * when the embedder binds the seam (the harness-sandbox-agents spec): the
6
+ * composition adds it to the always-on substrate, and no `meta.tools`
7
+ * allowlist names it. The realization of the embedder links what the host
8
+ * staged — the tool never installs, downloads, or acquires anything. A link
9
+ * is live in a container that already runs, because the farm rides a bind
10
+ * mount.
11
+ */
12
+ import { ok } from "neverthrow";
13
+ import { z } from "zod";
14
+ import { defineTool } from "../define-tool.js";
15
+ export function createLinkPackagesTool(deps) {
16
+ return defineTool({
17
+ id: "link_packages",
18
+ description: "Link host-staged packages into the farm of this analysis, so an import that failed works without a restart. " +
19
+ "This tool links what the host already staged — it NEVER installs, downloads, or acquires anything. " +
20
+ "Pass each module name verbatim, exactly as the import names it. " +
21
+ "One outcome comes back per request: `linked` (the pool held it, and it is importable now), " +
22
+ "`present` (the farm held it already), " +
23
+ "`absent` (the pool does not hold it — a real answer, and `acquisitionPossible` states whether the host can acquire that ecosystem; report the package as missing, do not retry), " +
24
+ "`collision` (the request resolves to two store directories — terminal for that package; the `detail` names the two pins and the packages that need each; report it and continue without it), " +
25
+ "or `unavailable` (the link pass itself could not answer — the `reason` says why; it says nothing about the package's presence, so report the reason and do not re-request packages).",
26
+ inputSchema: z.object({
27
+ packages: z
28
+ .array(z.object({
29
+ name: z.string().min(1).describe("The module or package name, verbatim."),
30
+ version: z.string().optional().describe("One exact version, when the requirement pins one."),
31
+ ecosystem: z.enum(["python", "r"]).optional().describe("The ecosystem, when the bare name is ambiguous."),
32
+ }))
33
+ .min(1)
34
+ .max(50)
35
+ .describe("The packages to link into the farm."),
36
+ }),
37
+ describeCall: ({ packages }) => packages.map((p) => p.name).join(", "),
38
+ execute: async (input) => {
39
+ // A realization throw reads as `unavailable` per request. The loop
40
+ // would render the throw as a raw tool error, and a raw driver
41
+ // message teaches the agent nothing that `reason` does not.
42
+ try {
43
+ const outcomes = await deps.extendAnalysisFarm(deps.analysisId, input.packages);
44
+ return ok({ outcomes });
45
+ }
46
+ catch (cause) {
47
+ const reason = cause instanceof Error ? cause.message : String(cause);
48
+ return ok({ outcomes: input.packages.map((request) => ({ kind: "unavailable", name: request.name, reason })) });
49
+ }
50
+ },
51
+ });
52
+ }
@@ -1,40 +1,48 @@
1
1
  /**
2
- * listAvailablePackages — query the R/Python/CLI/Node packages available in the
3
- * sandbox.
2
+ * listAvailablePackages — query the R/Python/CLI/Node packages of the
3
+ * environment, from the inventory source the embedder binds.
4
4
  *
5
- * The data source is the store's `packages.txt`, assembled by the library-store
6
- * build (see the lib-store-build spec) and read from wherever the HOST can see
7
- * it — the container path when the host mounts the same store, an injected path
8
- * when it does not. Its shape is fixed by the
9
- * producers (`scripts/lib-store-common.sh`, `images/sandbox-python/inflexa-libs-refresh`):
10
- * two `#` advisory lines, then one `## <Section>` heading per language track
11
- * followed by that track's packages as a single comma-separated line.
5
+ * Two sources exist, one per vantage. A SANDBOX agent reads the
6
+ * `inflexa.lock` of the mounted farm — what a step can import right now —
7
+ * from wherever the HOST can see it: the farm container path when the host
8
+ * mounts the same farm, an injected path when it does not. A CONVERSATION or
9
+ * planning surface reads the pool-scope inventory (`readPoolInventory`) —
10
+ * everything the store holds, whether or not a farm links it yet — because
11
+ * the ask flow marks the packages the POOL does not hold, and the farm of a
12
+ * new analysis is empty. The baked image inventory fragment
13
+ * (`image-packages.txt`, at `/opt/inflexa` in the image) merges into either
14
+ * report when the host can read one. The fragment keeps the section format:
12
15
  *
13
- * # Available packages in the sandbox environment.
14
- * # Do NOT attempt to install packages — ...
16
+ * ## System tools (CLI)
17
+ * samtools, bcftools, ...
15
18
  *
16
- * ## R (CRAN)
17
- * Seurat, dplyr, ggplot2, ...
18
- *
19
- * ## Python (pip)
20
- * anndata, scanpy, ...
21
- *
22
- * The file carries **names only — there are no version strings in it**, so this
23
- * tool reports presence and language track and cannot report a version.
19
+ * Each entry renders as `name==version` where the source pins a version, and
20
+ * a targeted `names` lookup also carries the store directory and the full
21
+ * content hash where the source records them. A full listing carries no
22
+ * hashes — a thousand rows of sha256 would bury the signal.
24
23
  */
25
- import type { EnvironmentStorePaths } from "../../config/environment-stores.js";
26
- /** One language track of the store, in `packages.txt` section order. */
27
- export interface Section {
28
- /** The section heading verbatim, e.g. `R (CRAN)`, `Python (pip)`. */
29
- readonly title: string;
30
- readonly packages: readonly string[];
31
- }
32
- /** One `names` lookup: present + canonical spelling + track, or absent. */
24
+ import type { EnvironmentStorePaths, PoolInventoryPackage, PoolInventorySection } from "../../config/environment-stores.js";
25
+ import { type FarmLock } from "../../sandbox/farm.js";
26
+ /**
27
+ * Group the lock packages into sections, one per track, in a stable order:
28
+ * the known tracks first, then an unknown track at its first occurrence.
29
+ * Each entry keeps the lock's version, store directory, and hash, so the
30
+ * targeted `names` path can report the exact store identity.
31
+ */
32
+ export declare function lockSections(lock: FarmLock): Section[];
33
+ /** One package entry of a section, with the store identity where the source records it. */
34
+ export type SectionPackage = PoolInventoryPackage;
35
+ /** One language track of the store, in `packages.txt` section order. The seam shape and the render shape are one. */
36
+ export type Section = PoolInventorySection;
37
+ /** One `names` lookup: present + canonical spelling + track + store identity, or absent. */
33
38
  export type CheckedPackage = {
34
39
  readonly requested: string;
35
40
  readonly present: true;
36
41
  readonly name: string;
37
42
  readonly section: string;
43
+ readonly version?: string;
44
+ readonly storeDir?: string;
45
+ readonly hash?: string;
38
46
  } | {
39
47
  readonly requested: string;
40
48
  readonly present: false;
@@ -76,11 +84,23 @@ export interface PackagesQuery {
76
84
  * is only the file read plus this call.
77
85
  */
78
86
  export declare function queryPackages(sections: readonly Section[], { names, query, language, limit }: PackagesQuery): PackagesResult;
79
- export type ListAvailablePackagesDeps = Pick<EnvironmentStorePaths, "packagesFile">;
80
- /** Create the package inventory over a host-readable `packages.txt`. */
87
+ export type ListAvailablePackagesDeps = Pick<EnvironmentStorePaths, "farmLockFile" | "imagePackagesFile" | "readPoolInventory"> & {
88
+ /**
89
+ * True when the composition also attaches the `link_packages` tool. The
90
+ * framing sentence must not close the world on the farm then: a package
91
+ * absent from the farm lock can still link in from the pool.
92
+ */
93
+ readonly linkToolPresent?: boolean;
94
+ };
95
+ /**
96
+ * Create the package inventory over the bound source: the pool-scope reader
97
+ * when the embedder binds one (a conversation or planning surface), the
98
+ * host-readable farm `inflexa.lock` otherwise (a sandbox agent). The image
99
+ * fragment merges into either report.
100
+ */
81
101
  export declare function createListAvailablePackagesTool(deps?: ListAvailablePackagesDeps): import("../define-tool.js").Tool<{
82
102
  names?: string[] | undefined;
83
103
  query?: string | undefined;
84
- language?: "r" | "python" | "cli" | "node" | undefined;
104
+ language?: "r" | "python" | "node" | "cli" | undefined;
85
105
  limit?: number | undefined;
86
106
  }, PackagesResult>;
@@ -1,26 +1,25 @@
1
1
  /**
2
- * listAvailablePackages — query the R/Python/CLI/Node packages available in the
3
- * sandbox.
2
+ * listAvailablePackages — query the R/Python/CLI/Node packages of the
3
+ * environment, from the inventory source the embedder binds.
4
4
  *
5
- * The data source is the store's `packages.txt`, assembled by the library-store
6
- * build (see the lib-store-build spec) and read from wherever the HOST can see
7
- * it — the container path when the host mounts the same store, an injected path
8
- * when it does not. Its shape is fixed by the
9
- * producers (`scripts/lib-store-common.sh`, `images/sandbox-python/inflexa-libs-refresh`):
10
- * two `#` advisory lines, then one `## <Section>` heading per language track
11
- * followed by that track's packages as a single comma-separated line.
5
+ * Two sources exist, one per vantage. A SANDBOX agent reads the
6
+ * `inflexa.lock` of the mounted farm — what a step can import right now —
7
+ * from wherever the HOST can see it: the farm container path when the host
8
+ * mounts the same farm, an injected path when it does not. A CONVERSATION or
9
+ * planning surface reads the pool-scope inventory (`readPoolInventory`) —
10
+ * everything the store holds, whether or not a farm links it yet — because
11
+ * the ask flow marks the packages the POOL does not hold, and the farm of a
12
+ * new analysis is empty. The baked image inventory fragment
13
+ * (`image-packages.txt`, at `/opt/inflexa` in the image) merges into either
14
+ * report when the host can read one. The fragment keeps the section format:
12
15
  *
13
- * # Available packages in the sandbox environment.
14
- * # Do NOT attempt to install packages — ...
16
+ * ## System tools (CLI)
17
+ * samtools, bcftools, ...
15
18
  *
16
- * ## R (CRAN)
17
- * Seurat, dplyr, ggplot2, ...
18
- *
19
- * ## Python (pip)
20
- * anndata, scanpy, ...
21
- *
22
- * The file carries **names only — there are no version strings in it**, so this
23
- * tool reports presence and language track and cannot report a version.
19
+ * Each entry renders as `name==version` where the source pins a version, and
20
+ * a targeted `names` lookup also carries the store directory and the full
21
+ * content hash where the source records them. A full listing carries no
22
+ * hashes — a thousand rows of sha256 would bury the signal.
24
23
  */
25
24
  import { readFile } from "node:fs/promises";
26
25
  import { ok } from "neverthrow";
@@ -28,12 +27,45 @@ import { z } from "zod";
28
27
  import { defineTool } from "../define-tool.js";
29
28
  import { capCodePoints, DETAIL_NEEDLE_MAX_LENGTH } from "../../loop/tool-detail.js";
30
29
  import { LIBS_CONTAINER_PATH } from "../../sandbox/mount-plan.js";
30
+ import { readFarmLockFile } from "../../sandbox/farm.js";
31
31
  /**
32
- * Where the list lives when the host mounts the library store at the same path
33
- * the sandbox does. Hosts that do not — the store is baked into the image and
34
- * never bind-mounted — inject their own path instead.
32
+ * Where the lock lives when the host mounts the farm at the same path the
33
+ * sandbox does. Both farm container paths are tried, because the path keys on
34
+ * the declared toolchain (`/mnt/libs/farm` under `"image"`, `/mnt/libs/current`
35
+ * under `"store"`), and the tool does not carry that declaration. A host that
36
+ * reads the farm somewhere else injects its own path instead.
35
37
  */
36
- const DEFAULT_PACKAGES_FILE = `${LIBS_CONTAINER_PATH}/current/packages.txt`;
38
+ const DEFAULT_FARM_LOCK_FILES = [`${LIBS_CONTAINER_PATH}/farm/inflexa.lock`, `${LIBS_CONTAINER_PATH}/current/inflexa.lock`];
39
+ /** Where the baked image inventory fragment lives inside the image. */
40
+ const DEFAULT_IMAGE_PACKAGES_FILE = "/opt/inflexa/image-packages.txt";
41
+ /** The section title of each known lock track. An unknown track titles itself. */
42
+ const TRACK_TITLES = {
43
+ python: "Python (pip)",
44
+ cran: "R (CRAN)",
45
+ bioconductor: "R (Bioconductor)",
46
+ github: "R (GitHub)",
47
+ node: "Node (npm)",
48
+ };
49
+ /**
50
+ * Group the lock packages into sections, one per track, in a stable order:
51
+ * the known tracks first, then an unknown track at its first occurrence.
52
+ * Each entry keeps the lock's version, store directory, and hash, so the
53
+ * targeted `names` path can report the exact store identity.
54
+ */
55
+ export function lockSections(lock) {
56
+ const byTrack = new Map();
57
+ for (const track of Object.keys(TRACK_TITLES))
58
+ byTrack.set(track, []);
59
+ for (const pkg of lock.packages) {
60
+ const entry = { name: pkg.name, version: pkg.version, storeDir: pkg.store_dir, hash: pkg.hash };
61
+ const open = byTrack.get(pkg.track);
62
+ if (open)
63
+ open.push(entry);
64
+ else
65
+ byTrack.set(pkg.track, [entry]);
66
+ }
67
+ return [...byTrack.entries()].filter(([, packages]) => packages.length > 0).map(([track, packages]) => ({ title: TRACK_TITLES[track] ?? track, packages }));
68
+ }
37
69
  /**
38
70
  * Default cap on a listing — high enough that the real store is never truncated.
39
71
  * The shipped catalog is ~270 packages (~3 KB, well under a thousand tokens), so
@@ -49,11 +81,18 @@ const DEFAULT_LIMIT = 2_000;
49
81
  * check, and an agent told to "assume numpy is available" will import it and
50
82
  * fail at runtime instead of probing first.
51
83
  */
52
- const UNAVAILABLE_NOTE = "Package list not available — the library store's inventory could not be read, so what is installed is UNKNOWN. " +
84
+ const UNAVAILABLE_NOTE = "Package list not available — the inventory of the farm could not be read, so what is installed is UNKNOWN. " +
53
85
  "Do not assume any package is present, and do not infer one from the analysis you were asked to run. " +
54
86
  "Probe each package you intend to use before relying on it (`python3 -c 'import <pkg>'`, " +
55
87
  'R `requireNamespace("<pkg>", quietly = TRUE)`) and degrade gracefully when it is absent. ' +
56
88
  "Nothing can be installed at runtime.";
89
+ /**
90
+ * The pool-scope twin of {@link UNAVAILABLE_NOTE}. It names no runtime probe,
91
+ * because a conversation surface has no runtime to probe, and an unreadable
92
+ * pool must read as UNKNOWN rather than as empty.
93
+ */
94
+ const POOL_UNAVAILABLE_NOTE = "Package list not available — the package pool could not be read, so what the store holds is UNKNOWN. " +
95
+ "Do not assume a package is present, and do not report a package as absent.";
57
96
  /** The `language` filter values, mapped onto the concrete section headings. */
58
97
  const LANGUAGE_MATCHERS = {
59
98
  // The R triple: `R (CRAN)`, `R (Bioconductor)`, `R (GitHub)`.
@@ -87,11 +126,15 @@ export function parsePackagesFile(content) {
87
126
  for (const name of trimmed.split(",")) {
88
127
  const pkg = name.trim();
89
128
  if (pkg)
90
- open.packages.push(pkg);
129
+ open.packages.push({ name: pkg });
91
130
  }
92
131
  }
93
132
  return sections;
94
133
  }
134
+ /** One entry as the listing renders it: `name==version` where the source pins a version, the bare name otherwise. */
135
+ function renderPackage(entry) {
136
+ return entry.version === undefined ? entry.name : `${entry.name}==${entry.version}`;
137
+ }
95
138
  /** Render sections to the agent-facing listing, bounded at `limit` packages. */
96
139
  function renderListing(sections, limit) {
97
140
  const lines = [];
@@ -106,7 +149,7 @@ function renderListing(sections, limit) {
106
149
  }
107
150
  const shown = section.packages.slice(0, room);
108
151
  returned += shown.length;
109
- lines.push(`\n## ${section.title}`, ` ${shown.join(", ")}`);
152
+ lines.push(`\n## ${section.title}`, ` ${shown.map(renderPackage).join(", ")}`);
110
153
  const hidden = section.packages.length - shown.length;
111
154
  if (hidden > 0)
112
155
  lines.push(` … and ${hidden} more in this section (raise \`limit\` or narrow with \`query\`).`);
@@ -126,15 +169,26 @@ export function queryPackages(sections, { names, query, language, limit }) {
126
169
  for (const pkg of section.packages) {
127
170
  // First writer wins: `packages.txt` sections are ordered, so a name
128
171
  // colliding across tracks resolves to the earliest one.
129
- if (!index.has(pkg.toLowerCase()))
130
- index.set(pkg.toLowerCase(), { name: pkg, section: section.title });
172
+ if (!index.has(pkg.name.toLowerCase()))
173
+ index.set(pkg.name.toLowerCase(), { entry: pkg, section: section.title });
131
174
  }
132
175
  }
133
176
  const checked = names.map((requested) => {
134
177
  const hit = index.get(requested.trim().toLowerCase());
178
+ if (!hit)
179
+ return { requested, present: false };
135
180
  // `name` echoes the catalog's canonical spelling — R package names are
136
181
  // case-sensitive at `library()`, so the exact one is what the caller needs.
137
- return hit ? { requested, present: true, name: hit.name, section: hit.section } : { requested, present: false };
182
+ // The store identity rides only where the source records it.
183
+ return {
184
+ requested,
185
+ present: true,
186
+ name: hit.entry.name,
187
+ section: hit.section,
188
+ ...(hit.entry.version === undefined ? {} : { version: hit.entry.version }),
189
+ ...(hit.entry.storeDir === undefined ? {} : { storeDir: hit.entry.storeDir }),
190
+ ...(hit.entry.hash === undefined ? {} : { hash: hit.entry.hash }),
191
+ };
138
192
  });
139
193
  return { available: true, checked };
140
194
  }
@@ -144,7 +198,7 @@ export function queryPackages(sections, { names, query, language, limit }) {
144
198
  .filter((s) => matchesLanguage(s.title))
145
199
  .map((s) => ({
146
200
  title: s.title,
147
- packages: needle ? s.packages.filter((p) => p.toLowerCase().includes(needle)) : s.packages,
201
+ packages: needle ? s.packages.filter((p) => p.name.toLowerCase().includes(needle)) : s.packages,
148
202
  }))
149
203
  .filter((s) => s.packages.length > 0);
150
204
  const total = filtered.reduce((n, s) => n + s.packages.length, 0);
@@ -161,18 +215,36 @@ export function queryPackages(sections, { names, query, language, limit }) {
161
215
  const { content, returned } = renderListing(filtered, limit ?? DEFAULT_LIMIT);
162
216
  return { available: true, total, returned, hasMore: returned < total, content };
163
217
  }
164
- /** Create the package inventory over a host-readable `packages.txt`. */
218
+ /**
219
+ * Create the package inventory over the bound source: the pool-scope reader
220
+ * when the embedder binds one (a conversation or planning surface), the
221
+ * host-readable farm `inflexa.lock` otherwise (a sandbox agent). The image
222
+ * fragment merges into either report.
223
+ */
165
224
  export function createListAvailablePackagesTool(deps = {}) {
166
- const packagesFile = deps.packagesFile ?? DEFAULT_PACKAGES_FILE;
225
+ const lockCandidates = deps.farmLockFile ? [deps.farmLockFile] : DEFAULT_FARM_LOCK_FILES;
226
+ const imagePackagesFile = deps.imagePackagesFile ?? DEFAULT_IMAGE_PACKAGES_FILE;
227
+ const readPoolInventory = deps.readPoolInventory;
228
+ // The scope decides the framing sentence: a sandbox agent reads what it can
229
+ // import NOW, and a conversation surface reads what the store HOLDS — a
230
+ // package absent from the pool needs an acquisition, not a shrug. A sandbox
231
+ // composition with the link seam gets a third framing. There the farm is
232
+ // not the boundary of an import, and a closed-world sentence would send the
233
+ // agent to report a package that one link call supplies.
234
+ const scopeSentence = readPoolInventory
235
+ ? "Query the R, Python, CLI, and Node packages the package pool holds — the set a run can link into an analysis. A package reported present needs no acquisition; a package absent here is not in the store yet. "
236
+ : deps.linkToolPresent
237
+ ? "Query the R, Python, CLI, and Node packages importable in the sandbox right now. Nothing installs at runtime; a package absent here can still be linkable from the host's staged pool — call `link_packages` before treating it as missing. "
238
+ : "Query the R, Python, CLI, and Node packages installed in the sandbox. No packages can be installed at runtime — only what this tool reports is importable. ";
167
239
  return defineTool({
168
240
  id: "list_available_packages",
169
- description: "Query the R, Python, CLI, and Node packages installed in the sandbox. No packages can be installed at runtime — only what this tool reports is importable. " +
241
+ description: scopeSentence +
170
242
  "A full listing is small (a few hundred packages) and is returned whole by default, so a listing you get back is the complete set unless `hasMore` says otherwise. " +
171
243
  '`names`: check specific packages (e.g. ["Seurat", "scanpy"]) — returns present/absent plus the language track for each, case-insensitively; this is the cheapest call and the right one for \'is X available?\'. ' +
172
244
  "`query`: case-insensitive substring filter over package names. " +
173
245
  "`language`: restrict to one track (r | python | cli | node). " +
174
246
  "`limit`: cap the packages listed; the response always carries the true `total` and a `hasMore` flag, so truncation is never silent. " +
175
- 'The package list carries NO version numbers — this tool cannot report a package\'s version. Check a version at runtime instead (e.g. `python -c "import scanpy; print(scanpy.__version__)"`).',
247
+ "Each entry renders as `name==version` where the source pins a version, and a `names` check also reports the store directory and the content hash where the source records them.",
176
248
  inputSchema: z.object({
177
249
  names: z
178
250
  .array(z.string())
@@ -212,16 +284,35 @@ export function createListAvailablePackagesTool(deps = {}) {
212
284
  return "full package list";
213
285
  },
214
286
  execute: async (input) => {
287
+ // The baked image fragment merges in when the host can read one; a
288
+ // missing fragment merges nothing, and that is the normal state for
289
+ // a host that runs outside the image.
290
+ const fragmentSections = await readFile(imagePackagesFile, "utf-8")
291
+ .then(parsePackagesFile)
292
+ .catch(() => []);
215
293
  // An unreadable inventory is an expected environment state — model it as an
216
- // `available: false` data variant telling the caller the set is UNKNOWN.
217
- let raw;
218
- try {
219
- raw = await readFile(packagesFile, "utf-8");
294
+ // `available: false` data variant telling the caller the set is UNKNOWN,
295
+ // WITH the reason: without it, a structural fault (a damaged dependency
296
+ // graph) reads as a transient flake, and the caller retries for ever.
297
+ if (readPoolInventory) {
298
+ const pool = await readPoolInventory().catch((cause) => ({
299
+ kind: "unavailable",
300
+ reason: cause instanceof Error ? cause.message : String(cause),
301
+ }));
302
+ if (pool.kind === "unavailable")
303
+ return ok({ available: false, content: `${POOL_UNAVAILABLE_NOTE} The reason: ${pool.reason}.` });
304
+ return ok(queryPackages([...pool.sections, ...fragmentSections], input));
305
+ }
306
+ let lock = null;
307
+ for (const candidate of lockCandidates) {
308
+ lock = readFarmLockFile(candidate).unwrapOr(null);
309
+ if (lock !== null)
310
+ break;
220
311
  }
221
- catch {
312
+ if (lock === null) {
222
313
  return ok({ available: false, content: UNAVAILABLE_NOTE });
223
314
  }
224
- return ok(queryPackages(parsePackagesFile(raw), input));
315
+ return ok(queryPackages([...lockSections(lock), ...fragmentSections], input));
225
316
  },
226
317
  });
227
318
  }