@mono-agent/agent-runtime 0.3.0 → 0.4.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 (123) hide show
  1. package/ARCHITECTURE.md +75 -9
  2. package/MIGRATION.md +293 -0
  3. package/README.md +46 -20
  4. package/package.json +104 -10
  5. package/src/agent/allowlists.js +3 -0
  6. package/src/agent/approval.js +3 -0
  7. package/src/agent/compaction.js +231 -654
  8. package/src/agent/index.js +1 -1
  9. package/src/agent/prompt/skill-index.js +6 -0
  10. package/src/agent/sandbox-seam.js +227 -0
  11. package/src/agent/tool-bloat.js +8 -2
  12. package/src/agent/tools/bash.js +16 -8
  13. package/src/agent/tools/edit.js +7 -3
  14. package/src/agent/tools/glob.js +11 -5
  15. package/src/agent/tools/grep.js +11 -5
  16. package/src/agent/tools/pi-bridge.js +272 -54
  17. package/src/agent/tools/read.js +28 -3
  18. package/src/agent/tools/shared/output-truncation.js +8 -3
  19. package/src/agent/tools/shared/path-resolver.js +24 -18
  20. package/src/agent/tools/shared/ripgrep.js +33 -6
  21. package/src/agent/tools/shared/runtime-context.js +60 -50
  22. package/src/agent/tools/shared/tool-context.js +157 -0
  23. package/src/agent/tools/web-fetch.js +65 -18
  24. package/src/agent/tools/web-search.js +11 -4
  25. package/src/agent/tools/write.js +7 -3
  26. package/src/agent/transcript.js +16 -1
  27. package/src/ai/cost.js +128 -3
  28. package/src/ai/failure.js +96 -4
  29. package/src/ai/index.js +1 -0
  30. package/src/ai/live-input-prompt.js +11 -1
  31. package/src/ai/providers/claude-cli.js +6 -2
  32. package/src/ai/providers/claude-sdk.js +55 -12
  33. package/src/ai/providers/codex-app.js +36 -23
  34. package/src/ai/providers/opencode-app.js +2 -2
  35. package/src/ai/providers/opencode-discovery.js +2 -2
  36. package/src/ai/providers/pi-errors.js +65 -0
  37. package/src/ai/providers/pi-events.js +5 -0
  38. package/src/ai/providers/pi-models.js +21 -4
  39. package/src/ai/providers/pi-native/compaction-driver.js +394 -0
  40. package/src/ai/providers/pi-native/result-builder.js +312 -0
  41. package/src/ai/providers/pi-native/session-lifecycle.js +438 -0
  42. package/src/ai/providers/pi-native/stream-subscriber.js +148 -0
  43. package/src/ai/providers/pi-native/structured-output.js +130 -0
  44. package/src/ai/providers/pi-native/turn-runner.js +278 -0
  45. package/src/ai/providers/pi-native.js +754 -0
  46. package/src/ai/runtime/capabilities.js +3 -0
  47. package/src/ai/runtime/model-refs.js +30 -0
  48. package/src/ai/runtime/registry.js +33 -2
  49. package/src/ai/runtime/router.js +119 -19
  50. package/src/ai/runtime/session-liveness.js +110 -0
  51. package/src/ai/runtime/sessions.js +19 -2
  52. package/src/ai/types.js +252 -20
  53. package/src/index.js +1 -1
  54. package/src/pi-auth.js +147 -16
  55. package/src/runtime-brand.js +21 -1
  56. package/src/runtime.js +75 -10
  57. package/types/agent/allowlists.d.ts +25 -0
  58. package/types/agent/approval.d.ts +30 -0
  59. package/types/agent/compaction.d.ts +97 -0
  60. package/types/agent/index.d.ts +5 -0
  61. package/types/agent/prompt/skill-index.d.ts +19 -0
  62. package/types/agent/sandbox-seam.d.ts +148 -0
  63. package/types/agent/tool-bloat.d.ts +22 -0
  64. package/types/agent/tools/bash.d.ts +16 -0
  65. package/types/agent/tools/edit.d.ts +14 -0
  66. package/types/agent/tools/glob.d.ts +16 -0
  67. package/types/agent/tools/grep.d.ts +22 -0
  68. package/types/agent/tools/index.d.ts +10 -0
  69. package/types/agent/tools/pi-bridge.d.ts +144 -0
  70. package/types/agent/tools/read.d.ts +19 -0
  71. package/types/agent/tools/shared/constants.d.ts +13 -0
  72. package/types/agent/tools/shared/dedup.d.ts +8 -0
  73. package/types/agent/tools/shared/output-truncation.d.ts +22 -0
  74. package/types/agent/tools/shared/path-resolver.d.ts +6 -0
  75. package/types/agent/tools/shared/ripgrep.d.ts +38 -0
  76. package/types/agent/tools/shared/runtime-context.d.ts +27 -0
  77. package/types/agent/tools/shared/tool-context.d.ts +48 -0
  78. package/types/agent/tools/web-fetch.d.ts +13 -0
  79. package/types/agent/tools/web-search.d.ts +11 -0
  80. package/types/agent/tools/write.d.ts +12 -0
  81. package/types/agent/transcript.d.ts +45 -0
  82. package/types/ai/backend.d.ts +41 -0
  83. package/types/ai/cost.d.ts +96 -0
  84. package/types/ai/failure.d.ts +117 -0
  85. package/types/ai/file-change-stats.d.ts +75 -0
  86. package/types/ai/index.d.ts +7 -0
  87. package/types/ai/live-input-prompt.d.ts +6 -0
  88. package/types/ai/observer.d.ts +68 -0
  89. package/types/ai/providers/claude-cli.d.ts +211 -0
  90. package/types/ai/providers/claude-sdk.d.ts +66 -0
  91. package/types/ai/providers/claude-subagents.d.ts +2 -0
  92. package/types/ai/providers/codex-app.d.ts +151 -0
  93. package/types/ai/providers/opencode-app.d.ts +95 -0
  94. package/types/ai/providers/opencode-discovery.d.ts +4 -0
  95. package/types/ai/providers/pi-errors.d.ts +3 -0
  96. package/types/ai/providers/pi-events.d.ts +24 -0
  97. package/types/ai/providers/pi-messages.d.ts +5 -0
  98. package/types/ai/providers/pi-models.d.ts +57 -0
  99. package/types/ai/providers/pi-native/compaction-driver.d.ts +86 -0
  100. package/types/ai/providers/pi-native/result-builder.d.ts +168 -0
  101. package/types/ai/providers/pi-native/session-lifecycle.d.ts +62 -0
  102. package/types/ai/providers/pi-native/stream-subscriber.d.ts +50 -0
  103. package/types/ai/providers/pi-native/structured-output.d.ts +69 -0
  104. package/types/ai/providers/pi-native/turn-runner.d.ts +60 -0
  105. package/types/ai/providers/pi-native.d.ts +18 -0
  106. package/types/ai/registry.d.ts +1 -0
  107. package/types/ai/runtime/capabilities-used.d.ts +21 -0
  108. package/types/ai/runtime/capabilities.d.ts +33 -0
  109. package/types/ai/runtime/context-windows.d.ts +8 -0
  110. package/types/ai/runtime/fast-mode.d.ts +2 -0
  111. package/types/ai/runtime/model-refs.d.ts +35 -0
  112. package/types/ai/runtime/registry.d.ts +38 -0
  113. package/types/ai/runtime/router.d.ts +56 -0
  114. package/types/ai/runtime/session-liveness.d.ts +55 -0
  115. package/types/ai/runtime/sessions.d.ts +38 -0
  116. package/types/ai/streaming/codex-events.d.ts +30 -0
  117. package/types/ai/streaming/opencode-events.d.ts +41 -0
  118. package/types/ai/types.d.ts +702 -0
  119. package/types/index.d.ts +7 -0
  120. package/types/pi-auth.d.ts +28 -0
  121. package/types/runtime-brand.d.ts +30 -0
  122. package/types/runtime.d.ts +10 -0
  123. package/src/ai/providers/pi-sdk.js +0 -1310
@@ -1,20 +1,24 @@
1
1
  import { existsSync, realpathSync } from "node:fs";
2
2
  import { dirname, isAbsolute, relative, resolve } from "node:path";
3
- import { readToolRuntime, resolveSandboxPolicy } from "./runtime-context.js";
4
-
5
- function configured() {
6
- const { workspace, repoRoot } = readToolRuntime();
3
+ import { readToolRuntime } from "./runtime-context.js";
4
+ import { resolveSandboxPolicy } from "./tool-context.js";
5
+
6
+ // Every read here falls back to the module-default context when no per-instance
7
+ // ToolContext is threaded (`ctx ?? readToolRuntime()`), so hosts that only call
8
+ // the deep-path configureToolRuntime keep their historical behavior.
9
+ function configured(ctx) {
10
+ const { workspace, repoRoot } = ctx ?? readToolRuntime();
7
11
  return { workspace, repoRoot };
8
12
  }
9
13
 
10
- export function workspaceRoot(workdir) {
11
- const { workspace, repoRoot } = configured();
14
+ export function workspaceRoot(workdir, ctx) {
15
+ const { workspace, repoRoot } = configured(ctx);
12
16
  return resolve(workdir || workspace || repoRoot || process.cwd());
13
17
  }
14
18
 
15
- export function resolveToolPath(path, workdir) {
19
+ export function resolveToolPath(path, workdir, ctx) {
16
20
  if (!path || typeof path !== "string") return path;
17
- return resolve(isAbsolute(path) ? path : resolve(workspaceRoot(workdir), path));
21
+ return resolve(isAbsolute(path) ? path : resolve(workspaceRoot(workdir, ctx), path));
18
22
  }
19
23
 
20
24
  export function isPathAllowed(path, workdir, options = {}) {
@@ -26,25 +30,27 @@ export function isWritablePathAllowed(path, workdir, options = {}) {
26
30
  }
27
31
 
28
32
  function isPathAllowedFor(path, workdir, access, options) {
29
- const r = resolveToolPath(path, workdir);
30
- const policy = resolveSandboxPolicy(options.sandboxPolicy);
33
+ const ctx = options.ctx;
34
+ const r = resolveToolPath(path, workdir, ctx);
35
+ const policy = resolveSandboxPolicy(ctx ?? readToolRuntime(), options.sandboxPolicy);
31
36
  if (policy) {
32
37
  const field = access === "write" ? policy.writableRoots : policy.readableRoots;
33
38
  return insideSandboxRoots(Array.isArray(field) ? field : [], r)
34
- && (access !== "write" || !sandboxDeniesWrite(policy, r));
39
+ && (access !== "write" || !sandboxDeniesWrite(policy, r, ctx));
35
40
  }
36
- const { workspace, repoRoot } = configured();
41
+ const { workspace, repoRoot } = configured(ctx);
37
42
  return insideLegacyRoots([workdir, workspace, repoRoot, process.cwd(), "/tmp"], r);
38
43
  }
39
44
 
40
45
  export function isWorkdirAllowed(workdir, options = {}) {
41
46
  if (!workdir) return true;
47
+ const ctx = options.ctx;
42
48
  const r = resolve(workdir);
43
- const policy = resolveSandboxPolicy(options.sandboxPolicy);
49
+ const policy = resolveSandboxPolicy(ctx ?? readToolRuntime(), options.sandboxPolicy);
44
50
  if (policy) {
45
51
  return insideSandboxRoots(Array.isArray(policy.readableRoots) ? policy.readableRoots : [], r);
46
52
  }
47
- const { workspace, repoRoot } = configured();
53
+ const { workspace, repoRoot } = configured(ctx);
48
54
  return insideLegacyRoots([workspace, repoRoot, process.cwd(), "/tmp"], r);
49
55
  }
50
56
 
@@ -95,21 +101,21 @@ function realTargetPath(target) {
95
101
  return resolved;
96
102
  }
97
103
 
98
- function sandboxDeniesWrite(policy, target) {
104
+ function sandboxDeniesWrite(policy, target, ctx) {
99
105
  const patterns = Array.isArray(policy.denyWrite) ? policy.denyWrite : [];
100
106
  if (patterns.length === 0) return false;
101
107
  const candidates = [...new Set([resolve(target), realTargetPath(target)])];
102
108
  return candidates.some((candidate) =>
103
- patterns.some((pattern) => denyWritePatternMatches(policy, pattern, candidate)));
109
+ patterns.some((pattern) => denyWritePatternMatches(policy, pattern, candidate, ctx)));
104
110
  }
105
111
 
106
- function denyWritePatternMatches(policy, pattern, target) {
112
+ function denyWritePatternMatches(policy, pattern, target, ctx) {
107
113
  if (!pattern || typeof pattern !== "string") return false;
108
114
  const normalizedPattern = normalizeMatchPath(pattern);
109
115
  if (isAbsolute(pattern)) {
110
116
  return globPatternMatches(normalizedPattern, normalizeMatchPath(target));
111
117
  }
112
- const root = resolve(policy.root || workspaceRoot());
118
+ const root = resolve(policy.root || workspaceRoot(undefined, ctx));
113
119
  const rel = relative(root, resolve(target));
114
120
  if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return false;
115
121
  return globPatternMatches(stripDotSlash(normalizedPattern), normalizeMatchPath(rel));
@@ -9,18 +9,31 @@ import {
9
9
  } from "./constants.js";
10
10
  import { boundedInt } from "./dedup.js";
11
11
  import { writeToolArtifact } from "./output-truncation.js";
12
- import { readRuntimeBrand, readToolRuntime } from "./runtime-context.js";
12
+ import { readToolRuntime } from "./runtime-context.js";
13
13
 
14
14
  const requireFromHere = createRequire(import.meta.url);
15
15
 
16
16
  // Lazy so the message respects whatever runtimeBrand the host configured.
17
- export function ripgrepMissingMessage() {
18
- const brand = readRuntimeBrand();
17
+ export function ripgrepMissingMessage(ctx) {
18
+ const brand = (ctx ?? readToolRuntime()).runtimeBrand;
19
19
  return `Error: ripgrep (rg) is not available. Configure ripgrepPath via configureToolRuntime() or install ripgrep on PATH; run \`${brand.doctorCommand}\` for details.`;
20
20
  }
21
21
 
22
22
  // Mutable cache of the resolved ripgrep binary path. Stored on an object so
23
23
  // callers can read the latest value without re-importing the module.
24
+ //
25
+ // KNOWN cross-runtime sharing (pre-existing, out of scope for the per-instance
26
+ // ToolContext work): this cache is MODULE-LEVEL, not per-ToolContext. The first
27
+ // resolveRgPath call to resolve a non-undefined value wins process-wide, so two
28
+ // createRuntime instances configured with DIFFERENT ripgrepPath values share
29
+ // whichever binary was resolved first (a later instance's ripgrepPath is
30
+ // ignored unless it passes `{refresh: true}`). Unlike workspace/repoRoot/
31
+ // sandbox/brand — which are fully isolated per instance via ToolContext — the
32
+ // ripgrep binary path is effectively global. In practice every runtime in a
33
+ // process resolves the same vendored/PATH binary, so this is benign; it is
34
+ // documented (and asserted as a known-shared cache in the two-runtimes
35
+ // isolation test) rather than fixed, since a per-instance rg cache would be a
36
+ // larger change with no real-world payoff today.
24
37
  export const cachedRgPath = { value: undefined };
25
38
 
26
39
  function vendoredRgPath() {
@@ -51,9 +64,12 @@ function rgFromPath() {
51
64
  return null;
52
65
  }
53
66
 
54
- export function resolveRgPath({ refresh = false } = {}) {
67
+ /**
68
+ * @param {{refresh?: boolean, ctx?: any}} [options]
69
+ */
70
+ export function resolveRgPath({ refresh = false, ctx } = {}) {
55
71
  if (!refresh && cachedRgPath.value !== undefined) return cachedRgPath.value;
56
- const { ripgrepPath } = readToolRuntime();
72
+ const { ripgrepPath } = ctx ?? readToolRuntime();
57
73
  if (ripgrepPath) {
58
74
  cachedRgPath.value = existsSync(ripgrepPath) ? ripgrepPath : null;
59
75
  } else {
@@ -76,12 +92,17 @@ export function normalizeGlobPattern(pattern) {
76
92
  return raw || "**/*";
77
93
  }
78
94
 
95
+ /**
96
+ * @param {any} rawLines
97
+ * @param {{label?: string, noMatches?: string, maxLines?: number, maxChars?: number, offset?: number, ctx?: any}} [options]
98
+ */
79
99
  export function formatSearchLines(rawLines, {
80
100
  label,
81
101
  noMatches,
82
102
  maxLines = DEFAULT_MAX_SEARCH_LINES,
83
103
  maxChars = DEFAULT_MAX_SEARCH_CHARS,
84
104
  offset = 0,
105
+ ctx,
85
106
  } = {}) {
86
107
  const lines = Array.isArray(rawLines) ? rawLines.filter(Boolean) : String(rawLines || "").trim().split("\n").filter(Boolean);
87
108
  if (!lines.length) return noMatches;
@@ -99,7 +120,7 @@ export function formatSearchLines(rawLines, {
99
120
  }
100
121
  if (kept.length === slice.length) return kept.join("\n");
101
122
  const fullText = lines.join("\n");
102
- const artifact = writeToolArtifact(label, fullText);
123
+ const artifact = writeToolArtifact(label, fullText, ctx);
103
124
  const suffix = [
104
125
  `[truncated ${label || "search"} result: showing ${kept.length} of ${total} lines after excluding generated/vendor paths.`,
105
126
  start ? `Offset ${start} was applied.` : null,
@@ -109,12 +130,17 @@ export function formatSearchLines(rawLines, {
109
130
  return `${kept.join("\n")}\n\n${suffix}`;
110
131
  }
111
132
 
133
+ /**
134
+ * @param {string} text
135
+ * @param {{label?: string, noMatches?: string, maxLines?: number, maxChars?: number, offset?: number, ctx?: any}} [options]
136
+ */
112
137
  export function capLines(text, {
113
138
  label,
114
139
  noMatches,
115
140
  maxLines = DEFAULT_MAX_SEARCH_LINES,
116
141
  maxChars = DEFAULT_MAX_SEARCH_CHARS,
117
142
  offset = 0,
143
+ ctx,
118
144
  } = {}) {
119
145
  return formatSearchLines(String(text || "").trim().split("\n").filter(Boolean), {
120
146
  label,
@@ -122,6 +148,7 @@ export function capLines(text, {
122
148
  maxLines,
123
149
  maxChars,
124
150
  offset,
151
+ ctx,
125
152
  });
126
153
  }
127
154
 
@@ -1,69 +1,79 @@
1
- // Process-level configuration for the agent kernel's internal tool helpers.
2
- // The host configures this once at worker boot; internal
3
- // modules (output-truncation, ripgrep, path-resolver, pi-bridge) read from
4
- // it instead of reaching into process.env.
1
+ // Module-level DEFAULT tool context — the back-compat layer over tool-context.js.
5
2
  //
6
- // Single shared object is acceptable because the worker is one-task-per-process.
3
+ // Historically this file held the one-per-process tool-runtime singleton. That
4
+ // assumed one task per process, which is false in a long-lived multi-channel
5
+ // host: two `createRuntime` instances clobbered each other's workspace /
6
+ // repoRoot / sandboxPolicy / brand. The per-instance ToolContext (tool-context.js)
7
+ // fixes that — `createRuntime` now builds its own context and threads it to
8
+ // bridges as `options.toolContext`.
7
9
  //
8
- // Recognized keys:
9
- // workspace — fallback for tool workdir resolution. Default: process.cwd().
10
- // repoRoot — secondary allowed root (the host's installation root).
11
- // Tool path-allowlist checks accept this in addition to workspace.
12
- // runId — used as the subdirectory under toolArtifactDir for tool output.
13
- // toolArtifactDir — root for {dir}/tool-output/{runId}/{file} artifact writes
14
- // from capChars/formatSearchLines. Null = no persistence.
15
- // ripgrepPath — absolute path to the ripgrep binary. When unset, falls
16
- // back to vendored binary, then PATH lookup.
17
- // qaOutputDir — fallback for normalizeMcpToolParams when the per-call
18
- // runArtifactDir isn't supplied.
19
- // sandboxPolicy — optional strict filesystem/process/network sandbox policy.
20
- // runtimeBrand — resolved RuntimeBrand object (see runtime-brand.js).
21
- // Internal helpers read it to stamp host-specific names
22
- // (MCP client name, transcript schema id, doctor command).
10
+ // This module survives as the DEFAULT context every internal read site falls
11
+ // back to when no per-instance context was threaded (`ctx ?? readToolRuntime()`).
12
+ // Hosts that only call the deep-path `configureToolRuntime` (e.g. worklab's
13
+ // worker.js / doctor.js) get byte-for-byte identical behavior to before: they
14
+ // configure this default context and the tools read it.
15
+ //
16
+ // The public exports are unchanged: configureToolRuntime / readToolRuntime /
17
+ // readRuntimeBrand / resetToolRuntime (plus the internal resolveSandboxPolicy
18
+ // default-context convenience). See tool-context.js for the recognized keys.
19
+
20
+ // @ts-check
21
+
22
+ import {
23
+ createToolContext,
24
+ resetToolContext,
25
+ updateToolContext,
26
+ resolveSandboxPolicy as resolveContextSandboxPolicy,
27
+ } from "./tool-context.js";
28
+ import { DEFAULT_RUNTIME_BRAND } from "../../../runtime-brand.js";
23
29
 
24
- import { mergeSandboxPolicies } from "@mono-agent/sandbox";
25
- import { DEFAULT_RUNTIME_BRAND, resolveRuntimeBrand } from "../../../runtime-brand.js";
30
+ /** @typedef {import('./tool-context.js').ToolContext} ToolContext */
31
+ /**
32
+ * @typedef {ToolContext} ToolRuntimeContext
33
+ * Back-compat alias for the historical name.
34
+ */
26
35
 
27
- const context = {
28
- workspace: undefined,
29
- repoRoot: undefined,
30
- runId: undefined,
31
- toolArtifactDir: undefined,
32
- ripgrepPath: undefined,
33
- qaOutputDir: undefined,
34
- sandboxPolicy: undefined,
35
- runtimeBrand: { ...DEFAULT_RUNTIME_BRAND },
36
- };
36
+ /** @type {ToolContext} */
37
+ const defaultContext = createToolContext({});
37
38
 
39
+ /**
40
+ * @param {Partial<ToolContext>} [next]
41
+ * @returns {void}
42
+ */
38
43
  export function configureToolRuntime(next = {}) {
39
- for (const key of Object.keys(context)) {
40
- if (key === "runtimeBrand") continue;
41
- if (key in next) context[key] = next[key];
42
- }
43
- if (next.runtimeBrand !== undefined) {
44
- context.runtimeBrand = resolveRuntimeBrand(next.runtimeBrand);
45
- }
44
+ updateToolContext(defaultContext, next);
46
45
  }
47
46
 
47
+ /**
48
+ * @returns {ToolContext}
49
+ */
48
50
  export function readToolRuntime() {
49
- return context;
51
+ return defaultContext;
50
52
  }
51
53
 
52
- // Single source of truth for the sandbox policy a tool call runs under.
53
- // Merging (rather than letting the per-call option shadow the context policy)
54
- // keeps the guarantee monotonic: a request-scoped policy can tighten the
55
- // host-configured policy but never weaken or disable it.
54
+ // Default-context convenience: resolves a request policy against the module
55
+ // default context. Internal read sites that carry a per-instance context call
56
+ // tool-context.js's `resolveSandboxPolicy(ctx, requestPolicy)` directly with
57
+ // `ctx ?? readToolRuntime()`; this wrapper preserves the historical
58
+ // single-argument signature for the default path.
59
+ /**
60
+ * @param {import('../../sandbox-seam.js').SandboxPolicy} [requestPolicy]
61
+ * @returns {import('../../sandbox-seam.js').SandboxPolicy|undefined}
62
+ */
56
63
  export function resolveSandboxPolicy(requestPolicy = undefined) {
57
- const merged = mergeSandboxPolicies(context.sandboxPolicy ?? undefined, requestPolicy ?? undefined);
58
- return merged && merged.mode !== "off" ? merged : undefined;
64
+ return resolveContextSandboxPolicy(defaultContext, requestPolicy);
59
65
  }
60
66
 
67
+ /**
68
+ * @returns {import('../../../runtime-brand.js').RuntimeBrand}
69
+ */
61
70
  export function readRuntimeBrand() {
62
- return context.runtimeBrand || { ...DEFAULT_RUNTIME_BRAND };
71
+ return defaultContext.runtimeBrand || { ...DEFAULT_RUNTIME_BRAND };
63
72
  }
64
73
 
74
+ /**
75
+ * @returns {void}
76
+ */
65
77
  export function resetToolRuntime() {
66
- for (const key of Object.keys(context)) {
67
- context[key] = key === "runtimeBrand" ? { ...DEFAULT_RUNTIME_BRAND } : undefined;
68
- }
78
+ resetToolContext(defaultContext);
69
79
  }
@@ -0,0 +1,157 @@
1
+ // Per-runtime-instance tool context.
2
+ //
3
+ // A ToolContext carries the configuration the kernel's internal tool helpers
4
+ // (output-truncation, ripgrep, path-resolver, pi-bridge, the tool impls) need
5
+ // to resolve workdirs, artifact paths, the ripgrep binary, sandbox policy, and
6
+ // host-specific brand strings. `createRuntime` builds ONE context per runtime
7
+ // instance and threads it to every bridge via `options.toolContext`; tools read
8
+ // from it instead of a process-global. This replaces the former one-per-process
9
+ // singleton (see runtime-context.js) that clobbered its fields when a
10
+ // long-lived host created more than one runtime.
11
+ //
12
+ // This module is pure: it owns no module-level state. The default/back-compat
13
+ // singleton lives in runtime-context.js, which builds its own ToolContext with
14
+ // createToolContext and mutates it with updateToolContext. Every internal read
15
+ // site falls back to that default via `ctx ?? readToolRuntime()`.
16
+ //
17
+ // Recognized keys:
18
+ // workspace — fallback for tool workdir resolution. Default: process.cwd().
19
+ // repoRoot — secondary allowed root (the host's installation root).
20
+ // Tool path-allowlist checks accept this in addition to workspace.
21
+ // runId — used as the subdirectory under toolArtifactDir for tool output.
22
+ // toolArtifactDir — root for {dir}/tool-output/{runId}/{file} artifact writes
23
+ // from capChars/formatSearchLines. Null = no persistence.
24
+ // ripgrepPath — absolute path to the ripgrep binary. When unset, falls
25
+ // back to vendored binary, then PATH lookup.
26
+ // qaOutputDir — fallback for normalizeMcpToolParams when the per-call
27
+ // runArtifactDir isn't supplied.
28
+ // sandboxPolicy — optional strict filesystem/process/network sandbox policy.
29
+ // sandboxEngine — optional concrete engine handed to the injected sandbox
30
+ // implementation when it prepares subprocess commands.
31
+ // sandbox — RuntimeSandbox implementation the policy is enforced
32
+ // through (see ../../sandbox-seam.js). Always resolved,
33
+ // like runtimeBrand: defaults to passthroughSandbox when
34
+ // a host doesn't inject a real one.
35
+ // runtimeBrand — resolved RuntimeBrand object (see runtime-brand.js).
36
+ // Internal helpers read it to stamp host-specific names
37
+ // (MCP client name, transcript schema id, doctor command).
38
+
39
+ // @ts-check
40
+
41
+ import { passthroughSandbox } from "../../sandbox-seam.js";
42
+ import { DEFAULT_RUNTIME_BRAND, resolveRuntimeBrand } from "../../../runtime-brand.js";
43
+
44
+ /** @typedef {import('../../../runtime-brand.js').RuntimeBrand} RuntimeBrand */
45
+ /** @typedef {import('../../sandbox-seam.js').SandboxPolicy} SandboxPolicy */
46
+ /** @typedef {import('../../sandbox-seam.js').RuntimeSandboxEngine} RuntimeSandboxEngine */
47
+ /** @typedef {import('../../sandbox-seam.js').RuntimeSandbox} RuntimeSandbox */
48
+
49
+ /**
50
+ * @typedef {Object} ToolContext
51
+ * @property {string} [workspace]
52
+ * @property {string} [repoRoot]
53
+ * @property {string} [runId]
54
+ * @property {string} [toolArtifactDir]
55
+ * @property {string} [ripgrepPath]
56
+ * @property {string} [qaOutputDir]
57
+ * @property {SandboxPolicy} [sandboxPolicy]
58
+ * @property {RuntimeSandboxEngine} [sandboxEngine]
59
+ * @property {RuntimeSandbox} sandbox
60
+ * @property {RuntimeBrand} runtimeBrand
61
+ */
62
+
63
+ // The data keys (everything except the always-resolved runtimeBrand). A fixed
64
+ // list — not `Object.keys(ctx)` — so an unknown key on `next` is ignored, matching
65
+ // the historical configureToolRuntime contract.
66
+ const TOOL_CONTEXT_KEYS = /** @type {const} */ ([
67
+ "workspace",
68
+ "repoRoot",
69
+ "runId",
70
+ "toolArtifactDir",
71
+ "ripgrepPath",
72
+ "qaOutputDir",
73
+ "sandboxPolicy",
74
+ "sandboxEngine",
75
+ ]);
76
+
77
+ /**
78
+ * Build a fresh ToolContext from a partial input. `runtimeBrand` is always
79
+ * resolved (to the defaults when absent); the remaining keys default to
80
+ * undefined and are copied from `input` when present.
81
+ * @param {Partial<ToolContext>} [input]
82
+ * @returns {ToolContext}
83
+ */
84
+ export function createToolContext(input = {}) {
85
+ /** @type {ToolContext} */
86
+ const ctx = {
87
+ workspace: undefined,
88
+ repoRoot: undefined,
89
+ runId: undefined,
90
+ toolArtifactDir: undefined,
91
+ ripgrepPath: undefined,
92
+ qaOutputDir: undefined,
93
+ sandboxPolicy: undefined,
94
+ sandboxEngine: undefined,
95
+ sandbox: passthroughSandbox,
96
+ runtimeBrand: { ...DEFAULT_RUNTIME_BRAND },
97
+ };
98
+ return updateToolContext(ctx, input);
99
+ }
100
+
101
+ /**
102
+ * Mutate an existing ToolContext in place with the keys present on `next`,
103
+ * leaving untouched keys as-is. Mutating in place (rather than returning a
104
+ * copy) is deliberate: `createRuntime` threads a single context object to its
105
+ * bridges and `configureTools` updates that same reference so subsequent runs
106
+ * observe the change — the instance-scoped equivalent of the old global
107
+ * configureToolRuntime.
108
+ * @param {ToolContext} ctx
109
+ * @param {Partial<ToolContext>} [next]
110
+ * @returns {ToolContext}
111
+ */
112
+ export function updateToolContext(ctx, next = {}) {
113
+ for (const key of TOOL_CONTEXT_KEYS) {
114
+ // Per-key assignment across a heterogeneous record: TS can't correlate the
115
+ // looked-up value type with `key` across two independent indexed accesses
116
+ // (a known structural limitation, not a real type hazard here).
117
+ if (key in next) ctx[key] = /** @type {any} */ (next)[key];
118
+ }
119
+ if (next.sandbox !== undefined) {
120
+ ctx.sandbox = next.sandbox;
121
+ }
122
+ if (next.runtimeBrand !== undefined) {
123
+ ctx.runtimeBrand = resolveRuntimeBrand(next.runtimeBrand);
124
+ }
125
+ return ctx;
126
+ }
127
+
128
+ /**
129
+ * Reset every data key to undefined and the brand back to the defaults.
130
+ * @param {ToolContext} ctx
131
+ * @returns {ToolContext}
132
+ */
133
+ export function resetToolContext(ctx) {
134
+ for (const key of TOOL_CONTEXT_KEYS) {
135
+ ctx[key] = /** @type {any} */ (undefined);
136
+ }
137
+ ctx.sandbox = passthroughSandbox;
138
+ ctx.runtimeBrand = { ...DEFAULT_RUNTIME_BRAND };
139
+ return ctx;
140
+ }
141
+
142
+ // Single source of truth for the sandbox policy a tool call runs under.
143
+ // Merging (rather than letting the per-call option shadow the context policy)
144
+ // keeps the guarantee monotonic (I13): a request-scoped policy can tighten the
145
+ // host-configured policy but never weaken or disable it. The merge itself is
146
+ // delegated to `ctx.sandbox.mergePolicies` (defaulting to passthroughSandbox)
147
+ // so the actual algorithm is host-injected, not hard-coded here.
148
+ /**
149
+ * @param {ToolContext|undefined} ctx
150
+ * @param {SandboxPolicy} [requestPolicy]
151
+ * @returns {SandboxPolicy|undefined}
152
+ */
153
+ export function resolveSandboxPolicy(ctx, requestPolicy = undefined) {
154
+ const sandbox = ctx?.sandbox ?? passthroughSandbox;
155
+ const merged = sandbox.mergePolicies(ctx?.sandboxPolicy ?? undefined, requestPolicy ?? undefined);
156
+ return merged && merged.mode !== "off" ? merged : undefined;
157
+ }
@@ -1,39 +1,86 @@
1
1
  import { DEFAULT_MAX_TOOL_OUTPUT_CHARS } from "./shared/constants.js";
2
2
  import { capChars } from "./shared/output-truncation.js";
3
- import { networkPolicyAllowsUrl } from "@mono-agent/sandbox";
4
- import { resolveSandboxPolicy } from "./shared/runtime-context.js";
3
+ import { passthroughSandbox } from "../sandbox-seam.js";
4
+ import { readToolRuntime } from "./shared/runtime-context.js";
5
+ import { resolveSandboxPolicy } from "./shared/tool-context.js";
5
6
 
6
7
  const FETCH_TIMEOUT_MS = 15000;
7
8
  const MAX_REDIRECTS = 5;
9
+ // Backoff delays between retry attempts (length = number of retries). Retrying
10
+ // transient failures in-tool stops the model from burning whole reasoning rounds
11
+ // re-issuing the fetch (or falling back to Bash curl) on a momentary network blip.
12
+ const DEFAULT_FETCH_RETRY_DELAYS_MS = [1000, 2000];
8
13
 
9
- export async function webFetchToolImpl({ url, headers = {}, max_output_chars }, { sandboxPolicy } = {}) {
14
+ function isTransientFetchError(err) {
15
+ if (err === undefined || err === null) return false;
16
+ if (err.name === "AbortError" || err.name === "TimeoutError") return true; // timeout
17
+ const code = err.code ?? err.cause?.code;
18
+ return code === "ECONNRESET" || code === "ECONNREFUSED" || code === "ETIMEDOUT" || code === "EAI_AGAIN";
19
+ }
20
+
21
+ function fetchRetryDelay(ms) {
22
+ return new Promise((resolve) => { setTimeout(resolve, ms); });
23
+ }
24
+
25
+ /**
26
+ * @param {{url: string, headers?: Record<string, string>, max_output_chars?: number}} params
27
+ * @param {{sandboxPolicy?: any, ctx?: any, retryDelaysMs?: number[]}} [options]
28
+ */
29
+ export async function webFetchToolImpl(
30
+ { url, headers = {}, max_output_chars },
31
+ { sandboxPolicy, ctx, retryDelaysMs = DEFAULT_FETCH_RETRY_DELAYS_MS } = {},
32
+ ) {
10
33
  const maxChars = Number(max_output_chars) || DEFAULT_MAX_TOOL_OUTPUT_CHARS;
11
34
  let parsed;
12
35
  try { parsed = new URL(url); } catch { return "Error: Invalid URL"; }
13
36
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
14
37
  return "Error: WebFetch only supports http(s) URLs.";
15
38
  }
16
- const policy = resolveSandboxPolicy(sandboxPolicy);
17
- if (!networkPolicyAllowsUrl(policy, parsed.href)) return "Error: Network access denied by sandbox policy.";
39
+ const resolvedCtx = ctx ?? readToolRuntime();
40
+ const sandbox = resolvedCtx.sandbox ?? passthroughSandbox;
41
+ const policy = resolveSandboxPolicy(resolvedCtx, sandboxPolicy);
42
+ if (!sandbox.networkAllowsUrl(policy, parsed.href)) return "Error: Network access denied by sandbox policy.";
18
43
  const requestHeaders = { "User-Agent": "AgentRuntime/0.1", ...headers };
19
- try {
20
- const restricted = policy !== undefined && policy.network.mode !== "all";
21
- const resp = restricted
22
- ? await fetchCheckingRedirects(parsed, requestHeaders, policy)
23
- : await fetch(url, { headers: requestHeaders, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
24
- if (typeof resp === "string") return resp;
25
- const text = await resp.text();
26
- if (!resp.ok) return `HTTP ${resp.status}: ${text.slice(0, 500)}`;
27
- return capChars(text, { label: "WebFetch", maxChars });
28
- } catch (err) {
29
- return `Error fetching URL: ${err.message}`;
44
+ // `policy.network` can be absent (a hand-built, non-real-mode policy — the
45
+ // networkAllowsUrl gate above already denied any real-mode policy missing
46
+ // it); treat that as unrestricted rather than dereferencing `.mode` on
47
+ // undefined.
48
+ const restricted = policy !== undefined && policy.network !== undefined && policy.network.mode !== "all";
49
+ const maxRetries = Array.isArray(retryDelaysMs) ? retryDelaysMs.length : 0;
50
+
51
+ let lastErrorMessage = "request failed";
52
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
53
+ try {
54
+ const resp = restricted
55
+ ? await fetchCheckingRedirects(parsed, requestHeaders, policy, sandbox)
56
+ : await fetch(url, { headers: requestHeaders, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
57
+ if (typeof resp === "string") return resp; // policy/redirect error — not retryable
58
+ // Transient server errors (5xx) are worth one more attempt.
59
+ if (resp.status >= 500 && resp.status < 600 && attempt < maxRetries) {
60
+ try { await resp.body?.cancel(); } catch { /* best-effort */ }
61
+ lastErrorMessage = `HTTP ${resp.status}`;
62
+ await fetchRetryDelay(retryDelaysMs[attempt]);
63
+ continue;
64
+ }
65
+ const text = await resp.text();
66
+ if (!resp.ok) return `HTTP ${resp.status}: ${text.slice(0, 500)}`;
67
+ return capChars(text, { label: "WebFetch", maxChars, ctx });
68
+ } catch (err) {
69
+ lastErrorMessage = err.message;
70
+ if (isTransientFetchError(err) && attempt < maxRetries) {
71
+ await fetchRetryDelay(retryDelaysMs[attempt]);
72
+ continue;
73
+ }
74
+ return `Error fetching URL: ${err.message}`;
75
+ }
30
76
  }
77
+ return `Error fetching URL: ${lastErrorMessage}`;
31
78
  }
32
79
 
33
80
  // fetch() follows redirects transparently, which would let an allowed host
34
81
  // bounce the request to a denied one — follow them manually and re-check the
35
82
  // policy on every hop. Custom headers only travel to the original origin.
36
- async function fetchCheckingRedirects(initialUrl, headers, policy) {
83
+ async function fetchCheckingRedirects(initialUrl, headers, policy, sandbox) {
37
84
  let current = initialUrl;
38
85
  for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
39
86
  const sameOrigin = current.origin === initialUrl.origin;
@@ -49,7 +96,7 @@ async function fetchCheckingRedirects(initialUrl, headers, policy) {
49
96
  if (next.protocol !== "http:" && next.protocol !== "https:") {
50
97
  return "Error: WebFetch only supports http(s) URLs.";
51
98
  }
52
- if (!networkPolicyAllowsUrl(policy, next.href)) {
99
+ if (!sandbox.networkAllowsUrl(policy, next.href)) {
53
100
  return "Error: Network access denied by sandbox policy (redirect).";
54
101
  }
55
102
  try { await resp.body?.cancel(); } catch { /* best-effort */ }
@@ -1,10 +1,17 @@
1
- import { networkPolicyAllowsUrl } from "@mono-agent/sandbox";
2
- import { resolveSandboxPolicy } from "./shared/runtime-context.js";
1
+ import { passthroughSandbox } from "../sandbox-seam.js";
2
+ import { readToolRuntime } from "./shared/runtime-context.js";
3
+ import { resolveSandboxPolicy } from "./shared/tool-context.js";
3
4
 
4
- export async function webSearchToolImpl({ query, limit = 5 }, { sandboxPolicy } = {}) {
5
+ /**
6
+ * @param {{query: string, limit?: number}} params
7
+ * @param {{sandboxPolicy?: any, ctx?: any}} [options]
8
+ */
9
+ export async function webSearchToolImpl({ query, limit = 5 }, { sandboxPolicy, ctx } = {}) {
5
10
  const max = Math.min(Math.max(Number(limit) || 5, 1), 10);
6
11
  const url = `https://duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
7
- if (!networkPolicyAllowsUrl(resolveSandboxPolicy(sandboxPolicy), url)) return "Error: Network access denied by sandbox policy.";
12
+ const resolvedCtx = ctx ?? readToolRuntime();
13
+ const sandbox = resolvedCtx.sandbox ?? passthroughSandbox;
14
+ if (!sandbox.networkAllowsUrl(resolveSandboxPolicy(resolvedCtx, sandboxPolicy), url)) return "Error: Network access denied by sandbox policy.";
8
15
  const resp = await fetch(url, {
9
16
  headers: { "User-Agent": "Mozilla/5.0 AgentRuntime/0.1" },
10
17
  signal: AbortSignal.timeout(15000),
@@ -3,9 +3,13 @@ import { dirname } from "node:path";
3
3
  import { MAX_WRITE_BYTES } from "./shared/constants.js";
4
4
  import { isWritablePathAllowed, resolveToolPath } from "./shared/path-resolver.js";
5
5
 
6
- export async function writeToolImpl({ file_path, content, workdir }, { sandboxPolicy } = {}) {
7
- const target = resolveToolPath(file_path, workdir);
8
- if (!isWritablePathAllowed(target, workdir, { sandboxPolicy })) return `Error: Path not allowed: ${file_path}`;
6
+ /**
7
+ * @param {{file_path: string, content?: string, workdir?: string}} params
8
+ * @param {{sandboxPolicy?: any, ctx?: any}} [options]
9
+ */
10
+ export async function writeToolImpl({ file_path, content, workdir }, { sandboxPolicy, ctx } = {}) {
11
+ const target = resolveToolPath(file_path, workdir, ctx);
12
+ if (!isWritablePathAllowed(target, workdir, { sandboxPolicy, ctx })) return `Error: Path not allowed: ${file_path}`;
9
13
  const bytes = Buffer.byteLength(content || "", "utf8");
10
14
  if (bytes > MAX_WRITE_BYTES) return `Error: Content too large (${bytes} bytes)`;
11
15
  mkdirSync(dirname(target), { recursive: true });