@inflexa-ai/harness 0.6.0 → 0.7.0

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,7 @@ 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 { createListAvailableRefsTool } from "../sandbox/list-available-refs.js";
35
36
  import { createReportBlockerToolFor } from "../sandbox/report-blocker.js";
36
37
  import { DATA_PROFILE_ORIENTATION_MAX_CHARS, buildDataProfileOrientation } from "../../app/data-profile-orientation.js";
37
38
  import { isDataProfileStale } from "../../app/data-profile-policy.js";
@@ -249,7 +250,7 @@ function fullyValidate(candidate, resourcePolicy) {
249
250
  * shared `holder` so the outer `execute` reads the terminal outcome after the
250
251
  * loop finishes.
251
252
  */
252
- function buildInnerTools(holder, persistCtx, pool, resourcePolicy) {
253
+ function buildInnerTools(holder, persistCtx, pool, resourcePolicy, refStorePath) {
253
254
  const validatePlanTool = defineTool({
254
255
  id: "validate_plan",
255
256
  description: "Dry-run a candidate plan and get back everything that is wrong with it. " +
@@ -353,8 +354,12 @@ function buildInnerTools(holder, persistCtx, pool, resourcePolicy) {
353
354
  "incompatible with every available agent, etc.) — not for a plan you " +
354
355
  "could fix and submit.",
355
356
  });
357
+ // Reference discovery is non-terminal and read-only: it lets the planner check what
358
+ // reference data this environment actually holds before committing a step to needing
359
+ // it. It reads the store host-side, so no sandbox exists or is needed at plan time.
360
+ const listAvailableRefs = createListAvailableRefsTool(refStorePath === undefined ? {} : { refStorePath });
356
361
  const terminal = [submitPlanTool, requestClarificationTool, reportBlockerTool];
357
- return { all: [validatePlanTool, ...terminal], terminal };
362
+ return { all: [validatePlanTool, listAvailableRefs, ...terminal], terminal };
358
363
  }
359
364
  /**
360
365
  * Translate the captured `PlannerOutcome` (plus any loop error) into the
@@ -486,7 +491,7 @@ export function createGeneratePlanTool(deps) {
486
491
  analysisId,
487
492
  parentPlanId: input.parentPlanId ?? null,
488
493
  };
489
- const innerTools = buildInnerTools(holder, persistCtx, deps.pool, deps.resourcePolicy);
494
+ const innerTools = buildInnerTools(holder, persistCtx, deps.pool, deps.resourcePolicy, deps.refStorePath);
490
495
  const planner = {
491
496
  id: PLANNER_AGENT_ID,
492
497
  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.
@@ -73,7 +75,18 @@ export interface PackagesQuery {
73
75
  * is only the file read plus this call.
74
76
  */
75
77
  export declare function queryPackages(sections: readonly Section[], { names, query, language, limit }: PackagesQuery): PackagesResult;
76
- export declare const listAvailablePackagesTool: import("../define-tool.js").Tool<{
78
+ export interface ListAvailablePackagesDeps {
79
+ /**
80
+ * Path to the store's `packages.txt` AS THE HOST SEES IT. Defaults to the
81
+ * container path, which is correct only when the host mounts the same store.
82
+ * A host that does not — one whose store is baked into the sandbox image and
83
+ * never bind-mounted — must inject the path to its own extracted copy, or the
84
+ * read fails and every caller is told the inventory is unknown.
85
+ */
86
+ readonly packagesFile?: string;
87
+ }
88
+ /** Create the package inventory over a host-readable `packages.txt`. */
89
+ export declare function createListAvailablePackagesTool(deps?: ListAvailablePackagesDeps): import("../define-tool.js").Tool<{
77
90
  names?: string[] | undefined;
78
91
  query?: string | undefined;
79
92
  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,29 +1,32 @@
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; nothing can be downloaded at runtime.
4
8
  *
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`.
9
+ * The store is read from the host filesystem at the embedder-supplied
10
+ * `refStorePath` and reported at `/mnt/refs`, the container path sandboxes mount
11
+ * it at — read where it lives, render where the caller will open it. Reading it
12
+ * host-side is what lets a planner ask what reference data exists before any
13
+ * sandbox is created; a step agent asking mid-run sees the same store, because
14
+ * the mount and this path are the same bytes.
15
+ *
16
+ * The scan reports paths; meaning is joined on afterwards from the install
17
+ * receipt and the catalog it names (organism, format, and each file's internal
18
+ * shape). Callers search by what the data IS, so the store's directory layout
19
+ * stays an installer detail rather than an interface anything encodes. Metadata
20
+ * only ever ENRICHES an entry — a file with no receipt and no catalog match is
21
+ * still returned, because a user-provided store is as valid as a managed one.
14
22
  */
15
- import type { SandboxClient } from "../../sandbox/client.js";
16
- import type { SandboxRef } from "../../sandbox/types.js";
17
23
  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;
24
+ /**
25
+ * Host path of the reference store — the same bytes sandboxes see at
26
+ * `/mnt/refs`. Omitted (or absent on disk) means no store is provisioned,
27
+ * which is a normal state reported as `unavailable`, not an error.
28
+ */
29
+ readonly refStorePath?: string;
27
30
  }
28
31
  export interface ReferenceInventoryMetadata {
29
32
  readonly datasetId?: string;
@@ -35,6 +38,10 @@ export interface ReferenceInventoryMetadata {
35
38
  readonly category?: string;
36
39
  readonly subtype?: string;
37
40
  readonly organism?: string;
41
+ /** Logical content format, independent of compression — which reader to reach for. */
42
+ readonly format?: string;
43
+ /** The file's internal shape (key columns, identifier space), so a caller can use it without opening it first. */
44
+ readonly contents?: string;
38
45
  readonly rows?: number;
39
46
  readonly endpoint?: string;
40
47
  }
@@ -42,7 +49,7 @@ interface ReferenceInventoryEntryBase {
42
49
  readonly path: string;
43
50
  readonly metadata?: ReferenceInventoryMetadata;
44
51
  }
45
- /** A bounded, no-follow observation from the live reference mount. */
52
+ /** A bounded, no-follow observation of the reference store. */
46
53
  export type ReferenceInventoryEntry = (ReferenceInventoryEntryBase & {
47
54
  readonly kind: "file";
48
55
  readonly bytes: number;
@@ -82,62 +89,11 @@ interface RawScan {
82
89
  readonly receipts?: unknown[];
83
90
  readonly legacyEntries?: unknown[];
84
91
  }
85
- /** Create replay-safe reference discovery bound to the active sandbox. */
92
+ /** Create reference discovery over the host-visible reference store. */
86
93
  export declare function createListAvailableRefsTool(deps: ListAvailableRefsDeps): import("../define-tool.js").Tool<{
87
94
  path?: string | undefined;
88
95
  category?: string | undefined;
89
96
  query?: string | undefined;
90
97
  limit?: number | undefined;
91
98
  }, 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
99
  export {};