@inflexa-ai/harness 0.45.0 → 0.45.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 (122) hide show
  1. package/dist/agents/conversation-agent.d.ts +10 -0
  2. package/dist/agents/conversation-agent.js +19 -1
  3. package/dist/agents/sandbox/shared.d.ts +23 -2
  4. package/dist/agents/sandbox/shared.js +51 -18
  5. package/dist/agents/sandbox/translational-safety-agent.js +26 -0
  6. package/dist/agents/sandbox/types.d.ts +24 -1
  7. package/dist/agents/sandbox-catalog.d.ts +14 -0
  8. package/dist/agents/sandbox-catalog.js +19 -0
  9. package/dist/app/approve-plan.d.ts +5 -2
  10. package/dist/app/approve-plan.js +1 -1
  11. package/dist/app/chat-turn.js +6 -3
  12. package/dist/config/environment-stores.d.ts +4 -2
  13. package/dist/contracts/chat-events.d.ts +12 -2
  14. package/dist/contracts/chat-events.js +1 -1
  15. package/dist/contracts/chat-frame.js +6 -2
  16. package/dist/contracts/index.d.ts +1 -1
  17. package/dist/contracts/schemas/chat-events.d.ts +25 -0
  18. package/dist/contracts/schemas/chat-events.js +7 -0
  19. package/dist/contracts/schemas/chat-parts.d.ts +39 -0
  20. package/dist/contracts/schemas/chat-parts.js +17 -0
  21. package/dist/contracts/schemas/index.d.ts +1 -1
  22. package/dist/contracts/schemas/index.js +1 -1
  23. package/dist/execution/dbos-workflow-purger.d.ts +2 -0
  24. package/dist/execution/dbos-workflow-purger.js +21 -14
  25. package/dist/execution/run-canceler.d.ts +33 -11
  26. package/dist/execution/run-canceler.js +129 -27
  27. package/dist/execution/run-end-record.d.ts +30 -0
  28. package/dist/execution/run-end-record.js +27 -0
  29. package/dist/index.d.ts +9 -5
  30. package/dist/index.js +11 -1
  31. package/dist/loop/call-guard.d.ts +50 -0
  32. package/dist/loop/call-guard.js +90 -0
  33. package/dist/loop/compaction.d.ts +5 -0
  34. package/dist/loop/compaction.js +17 -0
  35. package/dist/loop/run-agent.d.ts +9 -0
  36. package/dist/loop/run-agent.js +15 -1
  37. package/dist/loop/run-to-terminal.js +4 -1
  38. package/dist/loop/types.d.ts +6 -0
  39. package/dist/memory/conversation-display-recorder.d.ts +0 -2
  40. package/dist/memory/conversation-display-recorder.js +28 -3
  41. package/dist/memory/thread-history.d.ts +34 -7
  42. package/dist/memory/thread-history.js +151 -42
  43. package/dist/prompts/briefing.d.ts +57 -3
  44. package/dist/prompts/briefing.js +129 -2
  45. package/dist/prompts/planner.js +13 -2
  46. package/dist/provenance/seam.d.ts +23 -15
  47. package/dist/provenance/seam.js +7 -5
  48. package/dist/providers/ai-sdk.js +87 -7
  49. package/dist/providers/types.d.ts +6 -0
  50. package/dist/reference-data/catalog.js +141 -2
  51. package/dist/report-model/authoring-grammar.d.ts +26 -26
  52. package/dist/sandbox/image-packages.d.ts +26 -4
  53. package/dist/sandbox/image-packages.js +18 -4
  54. package/dist/schemas/data-profile-schemas.d.ts +12 -12
  55. package/dist/schemas/plan-schemas.d.ts +57 -3
  56. package/dist/schemas/plan-schemas.js +1 -1
  57. package/dist/schemas/workflow-state.d.ts +113 -0
  58. package/dist/schemas/workflow-state.js +54 -0
  59. package/dist/state/artifacts.d.ts +17 -1
  60. package/dist/state/artifacts.js +21 -3
  61. package/dist/state/index.d.ts +2 -2
  62. package/dist/state/index.js +1 -1
  63. package/dist/state/runs.d.ts +43 -9
  64. package/dist/state/runs.js +85 -14
  65. package/dist/state/step-executions.d.ts +9 -0
  66. package/dist/state/step-executions.js +22 -0
  67. package/dist/tools/ad-hoc-router.d.ts +2 -0
  68. package/dist/tools/ad-hoc-router.js +18 -2
  69. package/dist/tools/capability-data.d.ts +36 -0
  70. package/dist/tools/capability-data.js +73 -0
  71. package/dist/tools/execute-analysis.d.ts +37 -9
  72. package/dist/tools/execute-analysis.js +131 -21
  73. package/dist/tools/knowledge/check.d.ts +60 -0
  74. package/dist/tools/knowledge/check.js +92 -0
  75. package/dist/tools/knowledge/client.d.ts +382 -0
  76. package/dist/tools/knowledge/client.js +302 -0
  77. package/dist/tools/knowledge/environment.d.ts +78 -0
  78. package/dist/tools/knowledge/environment.js +105 -0
  79. package/dist/tools/knowledge/index.d.ts +49 -0
  80. package/dist/tools/knowledge/index.js +70 -0
  81. package/dist/tools/knowledge/local-slots.d.ts +62 -0
  82. package/dist/tools/knowledge/local-slots.js +177 -0
  83. package/dist/tools/knowledge/pin.d.ts +14 -0
  84. package/dist/tools/knowledge/pin.js +17 -0
  85. package/dist/tools/knowledge/recommend.d.ts +92 -0
  86. package/dist/tools/knowledge/recommend.js +98 -0
  87. package/dist/tools/knowledge/situation.d.ts +143 -0
  88. package/dist/tools/knowledge/situation.js +164 -0
  89. package/dist/tools/knowledge/skeleton.d.ts +69 -0
  90. package/dist/tools/knowledge/skeleton.js +202 -0
  91. package/dist/tools/knowledge/template.d.ts +137 -0
  92. package/dist/tools/knowledge/template.js +267 -0
  93. package/dist/tools/research/generate-plan.d.ts +23 -0
  94. package/dist/tools/research/generate-plan.js +203 -12
  95. package/dist/tools/research/guarded-address.d.ts +20 -0
  96. package/dist/tools/research/guarded-address.js +148 -0
  97. package/dist/tools/research/searxng-engine.d.ts +40 -0
  98. package/dist/tools/research/searxng-engine.js +120 -0
  99. package/dist/tools/research/web-fetch.d.ts +124 -0
  100. package/dist/tools/research/web-fetch.js +627 -0
  101. package/dist/tools/research/web-search.d.ts +75 -0
  102. package/dist/tools/research/web-search.js +112 -0
  103. package/dist/tools/sandbox/list-available-packages.d.ts +7 -4
  104. package/dist/tools/sandbox/list-available-packages.js +12 -4
  105. package/dist/tools/sandbox/list-available-refs.d.ts +21 -0
  106. package/dist/tools/sandbox/list-available-refs.js +60 -3
  107. package/dist/tools/sandbox/report-blocker.js +5 -1
  108. package/dist/tools/terminal-text.d.ts +11 -0
  109. package/dist/tools/terminal-text.js +19 -0
  110. package/dist/tools/workspace/decision-record.d.ts +81 -0
  111. package/dist/tools/workspace/decision-record.js +117 -0
  112. package/dist/tools/workspace/edit-file.d.ts +3 -1
  113. package/dist/tools/workspace/edit-file.js +20 -2
  114. package/dist/tools/workspace/execute-command.d.ts +17 -0
  115. package/dist/tools/workspace/execute-command.js +28 -1
  116. package/dist/tools/workspace/index.d.ts +1 -0
  117. package/dist/tools/workspace/index.js +1 -0
  118. package/dist/tools/workspace/mutator.d.ts +1 -1
  119. package/dist/workflows/execute-analysis.d.ts +58 -18
  120. package/dist/workflows/execute-analysis.js +273 -58
  121. package/dist/workflows/sandbox-step.d.ts +12 -0
  122. package/package.json +5 -1
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `web_search` — the web step of the conversation agent's research.
3
+ *
4
+ * The tool queries a `WebSearchEngine` the embedder injects at the composition
5
+ * root; the SearXNG realization ships beside it in `searxng-engine.ts`. The
6
+ * tool owns the input contract and the search semantics the spec pins — the
7
+ * domain-filter terms and the host re-filter, the queries merge, the bound —
8
+ * and every engine failure rides the ok channel as a data-status variant,
9
+ * never as an error of the turn.
10
+ */
11
+ import { type Result } from "neverthrow";
12
+ export interface WebSearchAnswer {
13
+ readonly answer?: string;
14
+ readonly url?: string;
15
+ }
16
+ export interface WebSearchResult {
17
+ readonly title: string;
18
+ readonly url: string;
19
+ readonly snippet?: string;
20
+ }
21
+ export type WebSearchOutput = {
22
+ readonly status: "ok";
23
+ readonly answers: readonly WebSearchAnswer[];
24
+ readonly results: readonly WebSearchResult[];
25
+ } | {
26
+ readonly status: "http_error";
27
+ readonly httpStatus: number;
28
+ readonly text: string;
29
+ } | {
30
+ readonly status: "retryable_status";
31
+ readonly httpStatus: number;
32
+ readonly text: string;
33
+ } | {
34
+ readonly status: "schema_mismatch";
35
+ readonly text: string;
36
+ } | {
37
+ readonly status: "not_json";
38
+ readonly text: string;
39
+ readonly detail?: string;
40
+ };
41
+ /** One query with its terms already composed: the engine adds transport, not semantics. */
42
+ export interface WebSearchRequest {
43
+ /** The final query text, `site:` / `-site:` terms included. */
44
+ readonly query: string;
45
+ readonly recencyFilter?: "day" | "week" | "month" | "year";
46
+ }
47
+ /** What one query returned: the mapped answers and the mapped result rows. */
48
+ export type WebSearchPage = {
49
+ readonly answers: readonly WebSearchAnswer[];
50
+ readonly results: readonly WebSearchResult[];
51
+ };
52
+ /** The error side of the engine result: the data-status variants the tool reports as normal results. */
53
+ export type WebSearchEngineError = Exclude<WebSearchOutput, {
54
+ readonly status: "ok";
55
+ }>;
56
+ /**
57
+ * The injected web-search engine: an embedder supplies one realization, and the
58
+ * conversation agent attaches `web_search` iff it did. `search` gets the final
59
+ * query terms and the optional recency filter, and resolves to the page or to
60
+ * the error variant that names the failure.
61
+ */
62
+ export interface WebSearchEngine {
63
+ search(request: WebSearchRequest): Promise<Result<WebSearchPage, WebSearchEngineError>>;
64
+ }
65
+ export interface WebSearchToolDeps {
66
+ /** The web-search engine the embedder injected. */
67
+ readonly engine: WebSearchEngine;
68
+ }
69
+ export declare function createWebSearchTool(deps: WebSearchToolDeps): import("../define-tool.js").Tool<{
70
+ numResults: number;
71
+ query?: string | undefined;
72
+ queries?: string[] | undefined;
73
+ recencyFilter?: "year" | "day" | "week" | "month" | undefined;
74
+ domainFilter?: string | undefined;
75
+ }, WebSearchOutput>;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `web_search` — the web step of the conversation agent's research.
3
+ *
4
+ * The tool queries a `WebSearchEngine` the embedder injects at the composition
5
+ * root; the SearXNG realization ships beside it in `searxng-engine.ts`. The
6
+ * tool owns the input contract and the search semantics the spec pins — the
7
+ * domain-filter terms and the host re-filter, the queries merge, the bound —
8
+ * and every engine failure rides the ok channel as a data-status variant,
9
+ * never as an error of the turn.
10
+ */
11
+ import { ok } from "neverthrow";
12
+ import { z } from "zod";
13
+ import { defineTool } from "../define-tool.js";
14
+ function parseDomainFilter(raw) {
15
+ const trimmed = (raw ?? "").trim().toLowerCase();
16
+ if (trimmed === "" || trimmed === "-")
17
+ return null;
18
+ if (trimmed.startsWith("-")) {
19
+ const domain = trimmed.slice(1).trim();
20
+ return domain === "" ? null : { include: false, domain };
21
+ }
22
+ return { include: true, domain: trimmed };
23
+ }
24
+ function queryTerms(query, filter) {
25
+ if (!filter)
26
+ return query;
27
+ return `${query} ${filter.include ? "" : "-"}site:${filter.domain}`;
28
+ }
29
+ /** A domain matches a host and each subdomain of the domain. */
30
+ function hostMatchesDomain(host, domain) {
31
+ return host === domain || host.endsWith(`.${domain}`);
32
+ }
33
+ function hostOf(url) {
34
+ try {
35
+ return new URL(url).hostname;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ /** Filter the merged rows by host again, because an engine can ignore `site:`. */
42
+ function applyDomainFilter(results, filter) {
43
+ if (!filter)
44
+ return [...results];
45
+ return results.filter((result) => {
46
+ const host = hostOf(result.url);
47
+ if (host === null)
48
+ return !filter.include;
49
+ const matches = hostMatchesDomain(host, filter.domain);
50
+ return filter.include ? matches : !matches;
51
+ });
52
+ }
53
+ export function createWebSearchTool(deps) {
54
+ return defineTool({
55
+ id: "web_search",
56
+ description: "Search the web for pages: titles, URLs, and short snippets, plus any direct answers the engines returned. " +
57
+ "Give `query` for one search, or `queries` (at most 5) for one pass whose answers and results merge into a single " +
58
+ "list of at most `numResults` results, duplicates by URL removed. `domainFilter` restricts the search to one domain " +
59
+ 'and a leading "-" excludes it, for example "-pinterest.com". `recencyFilter` keeps only results from the last day, ' +
60
+ "week, month, or year. Read one of the returned pages with `web_fetch`.\n" +
61
+ "ABSENCE IS NORMAL: an empty results array means no page matched, and a status of http_error, retryable_status, " +
62
+ "schema_mismatch, or not_json names a search that did not answer. Report either one and continue, do not retry unchanged.",
63
+ inputSchema: z
64
+ .object({
65
+ query: z.string().min(1).optional().describe("One free-text search, for example a method name with its domain."),
66
+ queries: z
67
+ .array(z.string().min(1))
68
+ .min(1)
69
+ .max(5)
70
+ .optional()
71
+ .describe("Up to 5 searches that run in one pass. Answers and results merge into one bounded list."),
72
+ numResults: z.number().int().min(1).max(20).default(5).describe("Maximum merged results (1–20, default 5)."),
73
+ recencyFilter: z.enum(["day", "week", "month", "year"]).optional().describe("Keep only results from this recency."),
74
+ domainFilter: z
75
+ .string()
76
+ .optional()
77
+ .describe('One domain the search stays in, for example "nih.gov". A leading "-" excludes the domain instead.'),
78
+ })
79
+ .refine((input) => (input.query !== undefined) !== (input.queries !== undefined), {
80
+ message: "Give exactly one of `query` or `queries`.",
81
+ }),
82
+ describeCall: "none",
83
+ execute: async ({ query, queries, numResults, recencyFilter, domainFilter }) => {
84
+ const filter = parseDomainFilter(domainFilter);
85
+ // The input refine guarantees exactly one of `query` and `queries`.
86
+ const queryList = queries ?? [query];
87
+ const requests = queryList.map((raw) => deps.engine.search({ query: queryTerms(raw, filter), ...(recencyFilter ? { recencyFilter } : {}) }));
88
+ const responses = [];
89
+ for (const response of await Promise.all(requests)) {
90
+ // An engine failure is the data-status variant the tool reports, not a turn error.
91
+ if (response.isErr())
92
+ return ok(response.error);
93
+ responses.push(response.value);
94
+ }
95
+ const answers = [];
96
+ const seen = new Set();
97
+ const results = [];
98
+ for (const response of responses) {
99
+ answers.push(...response.answers);
100
+ for (const result of applyDomainFilter(response.results, filter)) {
101
+ if (results.length >= numResults)
102
+ break;
103
+ if (seen.has(result.url))
104
+ continue;
105
+ seen.add(result.url);
106
+ results.push(result);
107
+ }
108
+ }
109
+ return ok({ status: "ok", answers, results });
110
+ },
111
+ });
112
+ }
@@ -41,13 +41,16 @@ export declare function trackOfLockSubtree(subtree: string): Track | undefined;
41
41
  */
42
42
  export declare function lockSections(lock: FarmLock): Section[];
43
43
  /**
44
- * Group the image record into its two sections, in the order of the record.
45
- * An empty track yields no section, so the report never carries a heading
46
- * with nothing under it.
44
+ * Group the image record into its sections, in the order of the record. An
45
+ * empty or absent track yields no section, so the report never carries a
46
+ * heading with nothing under it.
47
47
  *
48
48
  * A `system_tools` row renders its `executable` name where the record gives
49
49
  * one, because an agent invokes the binary rather than the conda package
50
- * (the manifest `binaries:` map holds the pairs that differ).
50
+ * (the manifest `binaries:` map holds the pairs that differ). The `r_base`
51
+ * rows are the R track: a `names` lookup of `survival` or `MASS` resolves
52
+ * there, with the version the runtime ships, and the `language: "r"` filter
53
+ * lists them beside the farm packages.
51
54
  */
52
55
  export declare function imageSections(record: ImagePackages): Section[];
53
56
  /** One package entry of a section, with the store identity where the source records it. */
@@ -53,6 +53,8 @@ const DEFAULT_IMAGE_PACKAGES_FILE = `${LIBS_CONTAINER_PATH}/${IMAGE_PACKAGES_FIL
53
53
  */
54
54
  const CLI_TITLE = "System tools (CLI)";
55
55
  const NODE_TITLE = "Node (npm)";
56
+ /** The R packages that ship with the R runtime of the image: the base and the recommended packages. */
57
+ const R_BASE_TITLE = "R (ships with R)";
56
58
  /** The section title of each known lock track. An unknown track titles itself. */
57
59
  const TRACK_TITLES = {
58
60
  python: "Python (pip)",
@@ -101,16 +103,22 @@ export function lockSections(lock) {
101
103
  });
102
104
  }
103
105
  /**
104
- * Group the image record into its two sections, in the order of the record.
105
- * An empty track yields no section, so the report never carries a heading
106
- * with nothing under it.
106
+ * Group the image record into its sections, in the order of the record. An
107
+ * empty or absent track yields no section, so the report never carries a
108
+ * heading with nothing under it.
107
109
  *
108
110
  * A `system_tools` row renders its `executable` name where the record gives
109
111
  * one, because an agent invokes the binary rather than the conda package
110
- * (the manifest `binaries:` map holds the pairs that differ).
112
+ * (the manifest `binaries:` map holds the pairs that differ). The `r_base`
113
+ * rows are the R track: a `names` lookup of `survival` or `MASS` resolves
114
+ * there, with the version the runtime ships, and the `language: "r"` filter
115
+ * lists them beside the farm packages.
111
116
  */
112
117
  export function imageSections(record) {
113
118
  const sections = [];
119
+ if (record.r_base !== undefined && record.r_base.length > 0) {
120
+ sections.push({ title: R_BASE_TITLE, track: "r", packages: record.r_base.map((pkg) => ({ name: pkg.name, version: pkg.version })) });
121
+ }
114
122
  if (record.system_tools.length > 0) {
115
123
  sections.push({
116
124
  title: CLI_TITLE,
@@ -90,7 +90,28 @@ interface RawScan {
90
90
  readonly receipts?: unknown[];
91
91
  readonly legacyEntries?: unknown[];
92
92
  }
93
+ /**
94
+ * The dataset ids the install receipts of the store report, for a caller that must know whether a
95
+ * declared dataset is present BEFORE a run starts.
96
+ *
97
+ * `undefined` means the store itself could not be answered for: an unreadable root, a `.inflexa`
98
+ * that crosses a symlink, or a receipts directory that exists and cannot be listed. That is NOT an
99
+ * empty install, and a caller must never collapse the two — every dataset would read as absent, and
100
+ * a launch would be refused over data that is sitting in the store.
101
+ *
102
+ * An empty set is a real answer: the store is readable and it holds no receipt.
103
+ */
104
+ export declare function readInstalledDatasetIds(deps: ListAvailableRefsDeps): Promise<ReadonlySet<string> | undefined>;
93
105
  /** Create reference discovery over the host-visible reference store. */
106
+ /**
107
+ * The whole enriched inventory of the store, for a host-side join: which
108
+ * collection a procedure names is present, and where. No envelope bound,
109
+ * because the caller keeps the join and drops the listing.
110
+ */
111
+ export declare function readReferenceInventory(storeRoot: string): Promise<{
112
+ readonly available: boolean;
113
+ readonly entries: readonly ReferenceInventoryEntry[];
114
+ }>;
94
115
  export declare function createListAvailableRefsTool(deps: ListAvailableRefsDeps): import("../define-tool.js").Tool<{
95
116
  path?: string | undefined;
96
117
  category?: string | undefined;
@@ -205,6 +205,54 @@ async function readReceipts(root) {
205
205
  }
206
206
  return receipts;
207
207
  }
208
+ /**
209
+ * The dataset ids the install receipts of the store report, for a caller that must know whether a
210
+ * declared dataset is present BEFORE a run starts.
211
+ *
212
+ * `undefined` means the store itself could not be answered for: an unreadable root, a `.inflexa`
213
+ * that crosses a symlink, or a receipts directory that exists and cannot be listed. That is NOT an
214
+ * empty install, and a caller must never collapse the two — every dataset would read as absent, and
215
+ * a launch would be refused over data that is sitting in the store.
216
+ *
217
+ * An empty set is a real answer: the store is readable and it holds no receipt.
218
+ */
219
+ export async function readInstalledDatasetIds(deps) {
220
+ const storeRoot = deps.refStorePath ?? REFS_ROOT;
221
+ try {
222
+ await lstat(storeRoot);
223
+ }
224
+ catch {
225
+ return undefined;
226
+ }
227
+ if (await crossesSymlink(storeRoot, ".inflexa"))
228
+ return undefined;
229
+ const receiptsDirectory = hostJoin(storeRoot, ".inflexa", "receipts");
230
+ let names;
231
+ try {
232
+ names = (await readdir(receiptsDirectory)).slice(0, MAX_RECEIPT_FILES);
233
+ }
234
+ catch (error) {
235
+ // An absent directory is a readable store with nothing installed. Any other failure is the
236
+ // store declining to answer, which is a different fact and gets the honest `undefined`.
237
+ if (error.code === "ENOENT")
238
+ return new Set();
239
+ return undefined;
240
+ }
241
+ const ids = new Set();
242
+ for (const name of names) {
243
+ // Per-file, so one malformed receipt costs only its own dataset. A shared try would drop
244
+ // every receipt after the bad one and read as a store that holds almost nothing.
245
+ try {
246
+ const receipt = parseReferenceInstallReceipt(JSON.parse(await readFile(hostJoin(receiptsDirectory, name), "utf-8")));
247
+ if (receipt)
248
+ ids.add(receipt.datasetId);
249
+ }
250
+ catch {
251
+ continue;
252
+ }
253
+ }
254
+ return ids;
255
+ }
208
256
  /** Labels from a `registry.json` an externally-provisioned store may carry. */
209
257
  async function readLegacyEntries(root) {
210
258
  const legacyEntries = [];
@@ -465,8 +513,8 @@ function collectCategories(entries) {
465
513
  }
466
514
  function renderContent(path, scan, entries) {
467
515
  if (scan.state === "unavailable")
468
- return ("No reference store is provisioned. Nothing in the analysis environment can fetch reference data — the host stages it, " +
469
- "so a store provisioned through the host's own path will show up on a later call.");
516
+ return ("No reference store is provisioned for this session. Nothing in the analysis environment can fetch reference data, and a later call " +
517
+ "in this run gives the same answer. Plan with the data at hand, or ask for the reference by what it is.");
470
518
  if (scan.state === "not_found")
471
519
  return `Reference path does not exist: ${path}`;
472
520
  if (scan.state === "not_a_directory")
@@ -499,6 +547,15 @@ function renderContent(path, scan, entries) {
499
547
  return lines.join("\n");
500
548
  }
501
549
  /** Create reference discovery over the host-visible reference store. */
550
+ /**
551
+ * The whole enriched inventory of the store, for a host-side join: which
552
+ * collection a procedure names is present, and where. No envelope bound,
553
+ * because the caller keeps the join and drops the listing.
554
+ */
555
+ export async function readReferenceInventory(storeRoot) {
556
+ const raw = await scanStore(storeRoot, ".", true);
557
+ return { available: raw.state !== "unavailable", entries: enrichEntries(raw.entries, raw) };
558
+ }
502
559
  export function createListAvailableRefsTool(deps) {
503
560
  const storeRoot = deps.refStorePath ?? REFS_ROOT;
504
561
  return defineTool({
@@ -516,7 +573,7 @@ export function createListAvailableRefsTool(deps) {
516
573
  "pass a group label as `query`, because `category` inspects a top-level directory by name and a label that is not a directory reads as `not_found`; " +
517
574
  "`limit` caps the entries returned. The response carries `returned`, `total`, and `hasMore`, so truncation is never silent. " +
518
575
  "An empty result means the dataset is absent right now — not that it is unobtainable. Say so; never guess a path, and never silently substitute a different dataset. " +
519
- "The store is provisioned by the host and is NOT frozen: datasets can be added through the host's own provisioning path, and whatever is added shows up on a later call.",
576
+ "The store is provisioned by the host and is NOT frozen: datasets can be added through the host's own provisioning path between runs, and whatever is added shows up in a later run. Within one run the listing does not change, thus one call per question is enough.",
520
577
  inputSchema: ListAvailableRefsInputSchema,
521
578
  // `execute` resolves `path ?? category` for the subtree it inspects, and
522
579
  // it ignores `category` when `path` is present. `query` is an ADDITIVE
@@ -18,10 +18,11 @@
18
18
  * runs in durable steps, so it reads the blocker back from the transcript
19
19
  * (`recordedBlocker`) on replay, not only from its cell.
20
20
  */
21
- import { ok } from "neverthrow";
21
+ import { err, ok } from "neverthrow";
22
22
  import { z } from "zod";
23
23
  import { repairToolInput } from "../../lib/zod-issues.js";
24
24
  import { toolOutcomeForOutputType } from "../../loop/tool-outcome.js";
25
+ import { degenerateTerminalText } from "../terminal-text.js";
25
26
  import { defineTool } from "../define-tool.js";
26
27
  export const REPORT_BLOCKER_TOOL_ID = "report_blocker";
27
28
  export function createBlockerHolder() {
@@ -46,6 +47,9 @@ export function createReportBlockerToolFor(deps) {
46
47
  inputSchema: ReportBlockerInputSchema,
47
48
  describeCall: "none",
48
49
  execute: async (input) => {
50
+ const degenerate = degenerateTerminalText(input.reason);
51
+ if (degenerate)
52
+ return err({ error: `${degenerate} Give the real reason, or continue the work.`, retryable: true });
49
53
  deps.record({ kind: "blocker", reason: input.reason });
50
54
  return ok({
51
55
  recorded: true,
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The text of a terminal call: a clarification question or a blocker reason.
3
+ *
4
+ * A terminal call whose text is a placeholder ends a run with nothing. A small
5
+ * model emits such a call when it drafts several calls at once and fills the
6
+ * text later, or never. The guard names the fault, the tool refuses the call,
7
+ * and the loop continues with the real work.
8
+ */
9
+ export declare const MIN_TERMINAL_TEXT = 4;
10
+ /** The reason a text cannot end a run, or undefined when it can. */
11
+ export declare function degenerateTerminalText(text: string): string | undefined;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The text of a terminal call: a clarification question or a blocker reason.
3
+ *
4
+ * A terminal call whose text is a placeholder ends a run with nothing. A small
5
+ * model emits such a call when it drafts several calls at once and fills the
6
+ * text later, or never. The guard names the fault, the tool refuses the call,
7
+ * and the loop continues with the real work.
8
+ */
9
+ const PLACEHOLDER_TEXT = /^\s*(?:placeholder|todo|tbd|skip|n\/?a|none|null|test|x+|\.+|-+)\b/i;
10
+ export const MIN_TERMINAL_TEXT = 4;
11
+ /** The reason a text cannot end a run, or undefined when it can. */
12
+ export function degenerateTerminalText(text) {
13
+ const trimmed = text.trim();
14
+ if (trimmed.length < MIN_TERMINAL_TEXT)
15
+ return `The text has ${trimmed.length} characters, and a real question or reason has at least ${MIN_TERMINAL_TEXT}.`;
16
+ if (PLACEHOLDER_TEXT.test(trimmed) || /\bplaceholder\b/i.test(trimmed))
17
+ return "The text is a placeholder.";
18
+ return undefined;
19
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The decision record of a rendered script, as the file tools keep it true
3
+ * after the render. `knowledge_template` writes the record beside the
4
+ * script with the digest of the bytes it wrote. A later change of the
5
+ * script through `edit_file` appends an entry to `unvetted_edits` with the
6
+ * digest after the change, and `execute_command` compares the script it is
7
+ * about to run with the last recorded digest, thus a change by any other
8
+ * path is recorded before the script runs. The record then connects the
9
+ * cited decision to the bytes that ran, and an empty `unvetted_edits` is a
10
+ * fact and not an assumption.
11
+ */
12
+ import type { AgentSession } from "../../auth/types.js";
13
+ import type { WorkspaceFilesystem } from "../../workspace/filesystem.js";
14
+ import type { MutateToolName, WorkspaceMutator, WriteRecord } from "./mutator.js";
15
+ /**
16
+ * The path of the decision record of one rendered script inside the step:
17
+ * `output/decision_record_<script stem>.json`. One record per render, thus a
18
+ * step that renders two scripts keeps both records.
19
+ */
20
+ export declare function decisionRecordPath(scriptFile: string): string;
21
+ /**
22
+ * The record path of a script path, or `undefined` when the path is not a
23
+ * script under `scripts/`. A relative `scripts/x.R` gives the relative
24
+ * record path, and an absolute `/<analysisId>/.../scripts/x.R` gives the
25
+ * absolute one, beside it in the same step.
26
+ */
27
+ export declare function decisionRecordPathFor(scriptPath: string): string | undefined;
28
+ export declare function scriptSha256(content: string | Uint8Array): string;
29
+ /** One change of a script after its render, as the record keeps it. */
30
+ export interface UnvettedEdit {
31
+ readonly path: string;
32
+ readonly note: string;
33
+ /** The digest of the script after the change. */
34
+ readonly sha256: string;
35
+ }
36
+ /** The digest the record expects of the script now: the last recorded change, else the bytes the render wrote. */
37
+ export declare function expectedScriptSha256(record: Readonly<Record<string, unknown>>): string | undefined;
38
+ export interface NoteScriptChangeArgs {
39
+ readonly filesystem: WorkspaceFilesystem;
40
+ readonly mutator: WorkspaceMutator;
41
+ readonly session: AgentSession;
42
+ /** Absolute host base for a relative path; the analysis root when absent. */
43
+ readonly workingDir?: string;
44
+ readonly invocationId: string;
45
+ /** The tool that made the change, for the provenance of the record write. */
46
+ readonly toolName: MutateToolName;
47
+ /** The script path as the tool received it, relative or absolute. */
48
+ readonly scriptPath: string;
49
+ readonly note: string;
50
+ /** The digest of the script after the change. */
51
+ readonly sha256: string;
52
+ }
53
+ /**
54
+ * The outcome of a change note. `noted` carries the record of the write,
55
+ * thus the calling tool carries it as its call record, and its fold hands it
56
+ * to `recordWrite`.
57
+ */
58
+ export type ScriptChangeNote = {
59
+ readonly status: "noted";
60
+ readonly write: WriteRecord;
61
+ } | {
62
+ readonly status: "no_record" | "refused";
63
+ };
64
+ /**
65
+ * Append a change to the record of a script. `no_record` when the path is
66
+ * not a script under `scripts/`, or when no record exists for it, which is
67
+ * the state of every script an agent wrote itself. `refused` when the
68
+ * mutator refused the write of the record, for example a record of another
69
+ * step, outside the writable prefix of this one.
70
+ */
71
+ export declare function noteScriptChange(args: NoteScriptChangeArgs): Promise<ScriptChangeNote>;
72
+ export type ReconcileScriptRecordArgs = Omit<NoteScriptChangeArgs, "note" | "sha256">;
73
+ /**
74
+ * Compare a script with the digest its record expects, before the script
75
+ * runs. A difference is a change that no tool of the host recorded, and it
76
+ * is noted with the digest of the bytes that are about to run. `unchanged`
77
+ * when the digests agree, `no_record` when the script has no record.
78
+ */
79
+ export declare function reconcileScriptRecord(args: ReconcileScriptRecordArgs): Promise<ScriptChangeNote | {
80
+ readonly status: "unchanged";
81
+ }>;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The decision record of a rendered script, as the file tools keep it true
3
+ * after the render. `knowledge_template` writes the record beside the
4
+ * script with the digest of the bytes it wrote. A later change of the
5
+ * script through `edit_file` appends an entry to `unvetted_edits` with the
6
+ * digest after the change, and `execute_command` compares the script it is
7
+ * about to run with the last recorded digest, thus a change by any other
8
+ * path is recorded before the script runs. The record then connects the
9
+ * cited decision to the bytes that ran, and an empty `unvetted_edits` is a
10
+ * fact and not an assumption.
11
+ */
12
+ import { createHash } from "node:crypto";
13
+ import { posix as posixPath } from "node:path";
14
+ import { unwrapOrThrow } from "../../lib/result.js";
15
+ /**
16
+ * The path of the decision record of one rendered script inside the step:
17
+ * `output/decision_record_<script stem>.json`. One record per render, thus a
18
+ * step that renders two scripts keeps both records.
19
+ */
20
+ export function decisionRecordPath(scriptFile) {
21
+ const stem = scriptFile.replace(/\.[A-Za-z0-9]+$/, "");
22
+ return `output/decision_record_${stem}.json`;
23
+ }
24
+ /**
25
+ * The record path of a script path, or `undefined` when the path is not a
26
+ * script under `scripts/`. A relative `scripts/x.R` gives the relative
27
+ * record path, and an absolute `/<analysisId>/.../scripts/x.R` gives the
28
+ * absolute one, beside it in the same step.
29
+ */
30
+ export function decisionRecordPathFor(scriptPath) {
31
+ const dir = posixPath.dirname(scriptPath);
32
+ if (posixPath.basename(dir) !== "scripts")
33
+ return undefined;
34
+ const record = decisionRecordPath(posixPath.basename(scriptPath));
35
+ return dir === "scripts" ? record : posixPath.join(posixPath.dirname(dir), record);
36
+ }
37
+ export function scriptSha256(content) {
38
+ return `sha256:${createHash("sha256").update(content).digest("hex")}`;
39
+ }
40
+ /** The digest the record expects of the script now: the last recorded change, else the bytes the render wrote. */
41
+ export function expectedScriptSha256(record) {
42
+ const edits = Array.isArray(record.unvetted_edits) ? record.unvetted_edits : [];
43
+ const last = edits[edits.length - 1]?.sha256;
44
+ if (typeof last === "string")
45
+ return last;
46
+ return typeof record.written_sha256 === "string" ? record.written_sha256 : undefined;
47
+ }
48
+ /**
49
+ * Append a change to the record of a script. `no_record` when the path is
50
+ * not a script under `scripts/`, or when no record exists for it, which is
51
+ * the state of every script an agent wrote itself. `refused` when the
52
+ * mutator refused the write of the record, for example a record of another
53
+ * step, outside the writable prefix of this one.
54
+ */
55
+ export async function noteScriptChange(args) {
56
+ const recordPath = decisionRecordPathFor(args.scriptPath);
57
+ if (!recordPath)
58
+ return { status: "no_record" };
59
+ const read = unwrapOrThrow(await args.filesystem.readFile({ session: args.session, path: recordPath, ...(args.workingDir !== undefined ? { workingDir: args.workingDir } : {}) }));
60
+ if (read.kind !== "ok")
61
+ return { status: "no_record" };
62
+ let record;
63
+ try {
64
+ const parsed = JSON.parse(read.content.toString("utf8"));
65
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
66
+ return { status: "no_record" };
67
+ record = parsed;
68
+ }
69
+ catch {
70
+ return { status: "no_record" };
71
+ }
72
+ const edits = Array.isArray(record.unvetted_edits) ? record.unvetted_edits : [];
73
+ const entry = { path: args.scriptPath, note: args.note, sha256: args.sha256 };
74
+ const next = { ...record, unvetted_edits: [...edits, entry] };
75
+ const written = await args.mutator.writeFile({
76
+ path: recordPath,
77
+ content: `${JSON.stringify(next, null, 2)}\n`,
78
+ toolName: args.toolName,
79
+ invocationId: args.invocationId,
80
+ session: args.session,
81
+ });
82
+ return written.status === "ok" ? { status: "noted", write: written.record } : { status: "refused" };
83
+ }
84
+ /**
85
+ * Compare a script with the digest its record expects, before the script
86
+ * runs. A difference is a change that no tool of the host recorded, and it
87
+ * is noted with the digest of the bytes that are about to run. `unchanged`
88
+ * when the digests agree, `no_record` when the script has no record.
89
+ */
90
+ export async function reconcileScriptRecord(args) {
91
+ const recordPath = decisionRecordPathFor(args.scriptPath);
92
+ if (!recordPath)
93
+ return { status: "no_record" };
94
+ const base = args.workingDir !== undefined ? { workingDir: args.workingDir } : {};
95
+ const record = unwrapOrThrow(await args.filesystem.readFile({ session: args.session, path: recordPath, ...base }));
96
+ if (record.kind !== "ok")
97
+ return { status: "no_record" };
98
+ let expected;
99
+ try {
100
+ const parsed = JSON.parse(record.content.toString("utf8"));
101
+ if (parsed === null || typeof parsed !== "object")
102
+ return { status: "no_record" };
103
+ expected = expectedScriptSha256(parsed);
104
+ }
105
+ catch {
106
+ return { status: "no_record" };
107
+ }
108
+ if (expected === undefined)
109
+ return { status: "no_record" };
110
+ const script = unwrapOrThrow(await args.filesystem.readFile({ session: args.session, path: args.scriptPath, ...base }));
111
+ if (script.kind !== "ok")
112
+ return { status: "no_record" };
113
+ const actual = scriptSha256(script.content);
114
+ if (actual === expected)
115
+ return { status: "unchanged" };
116
+ return noteScriptChange({ ...args, note: "the script changed by a path no file tool recorded, before it ran", sha256: actual });
117
+ }
@@ -51,7 +51,9 @@ export type EditFileResult = {
51
51
  readonly bytesWritten: number;
52
52
  /** Regex mode only: 1-based line numbers where the matches started. */
53
53
  readonly lines?: readonly number[];
54
- }, WriteRecord>;
54
+ /** True when the file is a rendered script and its decision record now lists this edit. */
55
+ readonly decision_record_noted?: true;
56
+ }, readonly WriteRecord[]>;
55
57
  export interface EditFileDeps {
56
58
  readonly mutator: WorkspaceMutator;
57
59
  /** Read seam used to fetch current file content for search/replace. */