@inflexa-ai/harness 0.6.0 → 0.7.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.
@@ -32,6 +32,8 @@ import { runToTerminal } from "../../loop/run-to-terminal.js";
32
32
  import { passthroughStep } from "../../loop/run-step.js";
33
33
  import { forSubAgent, scopeResource } from "../../auth/types.js";
34
34
  import { defineTool } from "../define-tool.js";
35
+ import { createListAvailablePackagesTool } from "../sandbox/list-available-packages.js";
36
+ import { createListAvailableRefsTool } from "../sandbox/list-available-refs.js";
35
37
  import { createReportBlockerToolFor } from "../sandbox/report-blocker.js";
36
38
  import { DATA_PROFILE_ORIENTATION_MAX_CHARS, buildDataProfileOrientation } from "../../app/data-profile-orientation.js";
37
39
  import { isDataProfileStale } from "../../app/data-profile-policy.js";
@@ -249,7 +251,10 @@ function fullyValidate(candidate, resourcePolicy) {
249
251
  * shared `holder` so the outer `execute` reads the terminal outcome after the
250
252
  * loop finishes.
251
253
  */
252
- function buildInnerTools(holder, persistCtx, pool, resourcePolicy) {
254
+ function buildInnerTools(holder, persistCtx, pool, resourcePolicy,
255
+ // The two environment stores travel together as one bag rather than as a pair
256
+ // of adjacent optional strings, which would be silently swappable at the call site.
257
+ stores) {
253
258
  const validatePlanTool = defineTool({
254
259
  id: "validate_plan",
255
260
  description: "Dry-run a candidate plan and get back everything that is wrong with it. " +
@@ -353,8 +358,15 @@ function buildInnerTools(holder, persistCtx, pool, resourcePolicy) {
353
358
  "incompatible with every available agent, etc.) — not for a plan you " +
354
359
  "could fix and submit.",
355
360
  });
361
+ // Environment discovery is non-terminal and read-only: it lets the planner check what
362
+ // reference data this environment holds, and what a step will actually be able to
363
+ // import, before committing a step to needing either. Both read host-side, so no
364
+ // sandbox exists or is needed at plan time — which is the whole point, since a plan
365
+ // that assumes an absent package fails only once the run reaches that step.
366
+ const listAvailableRefs = createListAvailableRefsTool(stores.refStorePath === undefined ? {} : { refStorePath: stores.refStorePath });
367
+ const listAvailablePackages = createListAvailablePackagesTool(stores.packagesFile === undefined ? {} : { packagesFile: stores.packagesFile });
356
368
  const terminal = [submitPlanTool, requestClarificationTool, reportBlockerTool];
357
- return { all: [validatePlanTool, ...terminal], terminal };
369
+ return { all: [validatePlanTool, listAvailableRefs, listAvailablePackages, ...terminal], terminal };
358
370
  }
359
371
  /**
360
372
  * Translate the captured `PlannerOutcome` (plus any loop error) into the
@@ -486,7 +498,7 @@ export function createGeneratePlanTool(deps) {
486
498
  analysisId,
487
499
  parentPlanId: input.parentPlanId ?? null,
488
500
  };
489
- const innerTools = buildInnerTools(holder, persistCtx, deps.pool, deps.resourcePolicy);
501
+ const innerTools = buildInnerTools(holder, persistCtx, deps.pool, deps.resourcePolicy, deps);
490
502
  const planner = {
491
503
  id: PLANNER_AGENT_ID,
492
504
  systemPrompt: composeSystemPrompt(plannerInstructions(deps.resourcePolicy)),
@@ -2,8 +2,10 @@
2
2
  * listAvailablePackages — query the R/Python/CLI/Node packages available in the
3
3
  * sandbox.
4
4
  *
5
- * The data source is `/mnt/libs/current/packages.txt`, assembled by the
6
- * library-store build (see the lib-store-build spec). Its shape is fixed by the
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
7
9
  * producers (`scripts/lib-store-common.sh`, `images/sandbox-python/inflexa-libs-refresh`):
8
10
  * two `#` advisory lines, then one `## <Section>` heading per language track
9
11
  * followed by that track's packages as a single comma-separated line.
@@ -20,6 +22,7 @@
20
22
  * The file carries **names only — there are no version strings in it**, so this
21
23
  * tool reports presence and language track and cannot report a version.
22
24
  */
25
+ import type { EnvironmentStorePaths } from "../../config/environment-stores.js";
23
26
  /** One language track of the store, in `packages.txt` section order. */
24
27
  export interface Section {
25
28
  /** The section heading verbatim, e.g. `R (CRAN)`, `Python (pip)`. */
@@ -73,7 +76,9 @@ export interface PackagesQuery {
73
76
  * is only the file read plus this call.
74
77
  */
75
78
  export declare function queryPackages(sections: readonly Section[], { names, query, language, limit }: PackagesQuery): PackagesResult;
76
- export declare const listAvailablePackagesTool: import("../define-tool.js").Tool<{
79
+ export type ListAvailablePackagesDeps = Pick<EnvironmentStorePaths, "packagesFile">;
80
+ /** Create the package inventory over a host-readable `packages.txt`. */
81
+ export declare function createListAvailablePackagesTool(deps?: ListAvailablePackagesDeps): import("../define-tool.js").Tool<{
77
82
  names?: string[] | undefined;
78
83
  query?: string | undefined;
79
84
  language?: "r" | "python" | "cli" | "node" | undefined;
@@ -2,8 +2,10 @@
2
2
  * listAvailablePackages — query the R/Python/CLI/Node packages available in the
3
3
  * sandbox.
4
4
  *
5
- * The data source is `/mnt/libs/current/packages.txt`, assembled by the
6
- * library-store build (see the lib-store-build spec). Its shape is fixed by the
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
7
9
  * producers (`scripts/lib-store-common.sh`, `images/sandbox-python/inflexa-libs-refresh`):
8
10
  * two `#` advisory lines, then one `## <Section>` heading per language track
9
11
  * followed by that track's packages as a single comma-separated line.
@@ -24,10 +26,32 @@ import { readFile } from "node:fs/promises";
24
26
  import { ok } from "neverthrow";
25
27
  import { z } from "zod";
26
28
  import { defineTool } from "../define-tool.js";
27
- const PACKAGES_FILE = "/mnt/libs/current/packages.txt";
28
- /** Default number of packages a filtered/unfiltered listing returns. */
29
- const DEFAULT_LIMIT = 100;
30
- const UNAVAILABLE_NOTE = "Package list not available. The library store may not be mounted. Assume standard bioinformatics packages (numpy, pandas, scanpy, DESeq2, etc.) are available, but do not attempt to install anything.";
29
+ /**
30
+ * Where the list lives when the host mounts the library store at the same path
31
+ * the sandbox does. Hosts that do not — the store is baked into the image and
32
+ * never bind-mounted — inject their own path instead.
33
+ */
34
+ const DEFAULT_PACKAGES_FILE = "/mnt/libs/current/packages.txt";
35
+ /**
36
+ * Default cap on a listing — high enough that the real store is never truncated.
37
+ * The shipped catalog is ~270 packages (~3 KB, well under a thousand tokens), so
38
+ * a low default bought nothing and cost correctness: a partial listing reads as a
39
+ * complete one, and an agent concludes a package is absent when it was merely not
40
+ * rendered. The cap survives only as a backstop for a downstream `FROM` image
41
+ * that adds a pathological number of packages.
42
+ */
43
+ const DEFAULT_LIMIT = 2_000;
44
+ /**
45
+ * Naming packages here would be worse than saying nothing: this state means the
46
+ * list could not be read, so any roll-call is a guess the agent has no way to
47
+ * check, and an agent told to "assume numpy is available" will import it and
48
+ * fail at runtime instead of probing first.
49
+ */
50
+ const UNAVAILABLE_NOTE = "Package list not available — the library store's inventory could not be read, so what is installed is UNKNOWN. " +
51
+ "Do not assume any package is present, and do not infer one from the analysis you were asked to run. " +
52
+ "Probe each package you intend to use before relying on it (`python3 -c 'import <pkg>'`, " +
53
+ 'R `requireNamespace("<pkg>", quietly = TRUE)`) and degrade gracefully when it is absent. ' +
54
+ "Nothing can be installed at runtime.";
31
55
  /** The `language` filter values, mapped onto the concrete section headings. */
32
56
  const LANGUAGE_MATCHERS = {
33
57
  // The R triple: `R (CRAN)`, `R (Bioconductor)`, `R (GitHub)`.
@@ -135,35 +159,45 @@ export function queryPackages(sections, { names, query, language, limit }) {
135
159
  const { content, returned } = renderListing(filtered, limit ?? DEFAULT_LIMIT);
136
160
  return { available: true, total, returned, hasMore: returned < total, content };
137
161
  }
138
- export const listAvailablePackagesTool = defineTool({
139
- id: "list_available_packages",
140
- 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. " +
141
- "Results are ALWAYS bounded; prefer a targeted query over a full listing. " +
142
- '`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?\'. ' +
143
- "`query`: case-insensitive substring filter over package names. " +
144
- "`language`: restrict to one track (r | python | cli | node). " +
145
- "`limit`: max packages listed (default 100); the response always carries the true `total` and a `hasMore` flag, so truncation is never silent. " +
146
- '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__)"`).',
147
- inputSchema: z.object({
148
- names: z
149
- .array(z.string())
150
- .max(100)
151
- .optional()
152
- .describe("Check these exact package names for presence (case-insensitive). Returns one entry per name: present/absent + the language track it lives in."),
153
- query: z.string().optional().describe("Case-insensitive substring filter over package names."),
154
- language: z.enum(["r", "python", "cli", "node"]).optional().describe("Restrict results to one language track."),
155
- limit: z.number().int().min(1).max(2000).optional().describe(`Max packages to list (default ${DEFAULT_LIMIT}). Ignored when \`names\` is given.`),
156
- }),
157
- execute: async (input) => {
158
- // A missing library-store mount is an expected environment state — model
159
- // it as an `available: false` data variant with a helpful fallback note.
160
- let raw;
161
- try {
162
- raw = await readFile(PACKAGES_FILE, "utf-8");
163
- }
164
- catch {
165
- return ok({ available: false, content: UNAVAILABLE_NOTE });
166
- }
167
- return ok(queryPackages(parsePackagesFile(raw), input));
168
- },
169
- });
162
+ /** Create the package inventory over a host-readable `packages.txt`. */
163
+ export function createListAvailablePackagesTool(deps = {}) {
164
+ const packagesFile = deps.packagesFile ?? DEFAULT_PACKAGES_FILE;
165
+ return defineTool({
166
+ id: "list_available_packages",
167
+ 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. " +
168
+ "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. " +
169
+ '`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?\'. ' +
170
+ "`query`: case-insensitive substring filter over package names. " +
171
+ "`language`: restrict to one track (r | python | cli | node). " +
172
+ "`limit`: cap the packages listed; the response always carries the true `total` and a `hasMore` flag, so truncation is never silent. " +
173
+ '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__)"`).',
174
+ inputSchema: z.object({
175
+ names: z
176
+ .array(z.string())
177
+ .max(100)
178
+ .optional()
179
+ .describe("Check these exact package names for presence (case-insensitive). Returns one entry per name: present/absent + the language track it lives in."),
180
+ query: z.string().optional().describe("Case-insensitive substring filter over package names."),
181
+ language: z.enum(["r", "python", "cli", "node"]).optional().describe("Restrict results to one language track."),
182
+ limit: z
183
+ .number()
184
+ .int()
185
+ .min(1)
186
+ .max(DEFAULT_LIMIT)
187
+ .optional()
188
+ .describe(`Cap the packages listed. Omit to get the whole set — the store fits well within the ${DEFAULT_LIMIT} ceiling. Ignored when \`names\` is given.`),
189
+ }),
190
+ execute: async (input) => {
191
+ // An unreadable inventory is an expected environment state — model it as an
192
+ // `available: false` data variant telling the caller the set is UNKNOWN.
193
+ let raw;
194
+ try {
195
+ raw = await readFile(packagesFile, "utf-8");
196
+ }
197
+ catch {
198
+ return ok({ available: false, content: UNAVAILABLE_NOTE });
199
+ }
200
+ return ok(queryPackages(parsePackagesFile(raw), input));
201
+ },
202
+ });
203
+ }
@@ -1,30 +1,30 @@
1
1
  /**
2
- * Reference-store discovery — two coordinated surfaces over the read-only
3
- * store at /mnt/refs:
2
+ * Reference-store discovery: `createListAvailableRefsTool`, a bounded, no-follow
3
+ * inventory of what is ACTUALLY provisioned in the reference store (managed
4
+ * installs and user-added files alike). Narrow with `path`/`category`, filter
5
+ * with `query`, cap with `limit`; every response carries `returned`/`total`/
6
+ * `hasMore` and the `categories` present, so a listing is never an unbounded
7
+ * dump. Symlinks are never followed. This tool only ever READS the store: the
8
+ * analysis environment cannot fetch reference data, and staging more is the
9
+ * host's job — so an absent dataset is a fact about this moment, not a verdict
10
+ * that it is unobtainable.
4
11
  *
5
- * - `createListAvailableRefsTool` — the wired sandbox tool. A bounded, no-follow
6
- * inventory of what is ACTUALLY mounted (managed installs, user-added files),
7
- * scanned live inside the sandbox. Narrow with `path`/`category`, filter with
8
- * `query`, cap with `limit`; every response carries `returned`/`total`/
9
- * `hasMore` and the `categories` present, so a listing is never an unbounded
10
- * dump. Symlinks are never followed; nothing can be downloaded at runtime.
11
- * - `queryRefs` — a pure query over a parsed `/mnt/refs/registry.json` manifest
12
- * (`files.by_category`), filtering on fields the registry carries: `local_path`,
13
- * `category`, `subtype`, `organism`, `tax_id`, `dataset`, `rows`.
12
+ * The store is read from the host filesystem at the embedder-supplied
13
+ * `refStorePath` and reported at `/mnt/refs`, the container path sandboxes mount
14
+ * it at — read where it lives, render where the caller will open it. Reading it
15
+ * host-side is what lets a planner ask what reference data exists before any
16
+ * sandbox is created; a step agent asking mid-run sees the same store, because
17
+ * the mount and this path are the same bytes.
18
+ *
19
+ * The scan reports paths; meaning is joined on afterwards from the install
20
+ * receipt and the catalog it names (organism, format, and each file's internal
21
+ * shape). Callers search by what the data IS, so the store's directory layout
22
+ * stays an installer detail rather than an interface anything encodes. Metadata
23
+ * only ever ENRICHES an entry — a file with no receipt and no catalog match is
24
+ * still returned, because a user-provided store is as valid as a managed one.
14
25
  */
15
- import type { SandboxClient } from "../../sandbox/client.js";
16
- import type { SandboxRef } from "../../sandbox/types.js";
17
- export interface ListAvailableRefsDeps {
18
- readonly sandboxClient: SandboxClient;
19
- readonly sandbox: SandboxRef;
20
- readonly workflowId: string;
21
- readonly stepId: string;
22
- readonly nextFunctionId: () => string;
23
- readonly deadlineMs: () => number;
24
- readonly markExecActive?: (execId: string) => Promise<void>;
25
- /** Filesystem root used by the scanner; production callers omit this. */
26
- readonly scanRoot?: string;
27
- }
26
+ import type { EnvironmentStorePaths } from "../../config/environment-stores.js";
27
+ export type ListAvailableRefsDeps = Pick<EnvironmentStorePaths, "refStorePath">;
28
28
  export interface ReferenceInventoryMetadata {
29
29
  readonly datasetId?: string;
30
30
  readonly version?: string;
@@ -35,6 +35,10 @@ export interface ReferenceInventoryMetadata {
35
35
  readonly category?: string;
36
36
  readonly subtype?: string;
37
37
  readonly organism?: string;
38
+ /** Logical content format, independent of compression — which reader to reach for. */
39
+ readonly format?: string;
40
+ /** The file's internal shape (key columns, identifier space), so a caller can use it without opening it first. */
41
+ readonly contents?: string;
38
42
  readonly rows?: number;
39
43
  readonly endpoint?: string;
40
44
  }
@@ -42,7 +46,7 @@ interface ReferenceInventoryEntryBase {
42
46
  readonly path: string;
43
47
  readonly metadata?: ReferenceInventoryMetadata;
44
48
  }
45
- /** A bounded, no-follow observation from the live reference mount. */
49
+ /** A bounded, no-follow observation of the reference store. */
46
50
  export type ReferenceInventoryEntry = (ReferenceInventoryEntryBase & {
47
51
  readonly kind: "file";
48
52
  readonly bytes: number;
@@ -82,62 +86,11 @@ interface RawScan {
82
86
  readonly receipts?: unknown[];
83
87
  readonly legacyEntries?: unknown[];
84
88
  }
85
- /** Create replay-safe reference discovery bound to the active sandbox. */
89
+ /** Create reference discovery over the host-visible reference store. */
86
90
  export declare function createListAvailableRefsTool(deps: ListAvailableRefsDeps): import("../define-tool.js").Tool<{
87
91
  path?: string | undefined;
88
92
  category?: string | undefined;
89
93
  query?: string | undefined;
90
94
  limit?: number | undefined;
91
95
  }, ReferenceInventoryResult>;
92
- export interface RegistryEntry {
93
- local_path: string;
94
- sha256: string;
95
- bytes: number;
96
- rows: number | null;
97
- category: string | null;
98
- subtype: string | null;
99
- organism: string | null;
100
- tax_id: string | null;
101
- dataset: string | null;
102
- endpoint: string | null;
103
- }
104
- export interface Registry {
105
- registry_version: string;
106
- build_id: string;
107
- generated_at: string;
108
- files: {
109
- by_category: Record<string, RegistryEntry[]>;
110
- };
111
- summary: {
112
- total_output_files: number;
113
- categories: string[];
114
- };
115
- }
116
- /**
117
- * Either the store is unmounted, or a bounded listing: `categories` always names
118
- * the full valid set (the drill-in index), and `total`/`hasMore` make truncation
119
- * explicit.
120
- */
121
- export type RefsResult = {
122
- readonly available: false;
123
- readonly content: string;
124
- } | {
125
- readonly available: true;
126
- readonly total: number;
127
- readonly returned: number;
128
- readonly hasMore: boolean;
129
- readonly categories: readonly string[];
130
- readonly content: string;
131
- };
132
- /** The tool's input, as parsed from `inputSchema`. */
133
- export interface RefsQuery {
134
- readonly category?: string;
135
- readonly query?: string;
136
- readonly limit?: number;
137
- }
138
- /**
139
- * Answer a refs query against a parsed registry. Pure — the tool's `execute` is
140
- * only the file read plus this call.
141
- */
142
- export declare function queryRefs(registry: Registry, { category, query, limit }: RefsQuery): RefsResult;
143
96
  export {};