@mono-agent/agent-runtime 0.11.5 → 0.12.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.
- package/ARCHITECTURE.md +9 -8
- package/MIGRATION.md +10 -5
- package/README.md +31 -10
- package/package.json +1 -1
- package/src/agent/compaction.js +28 -10
- package/src/agent/tools/node-repl.js +406 -0
- package/src/agent/tools/pi-bridge.js +13 -2
- package/src/ai/failure.js +28 -3
- package/src/ai/providers/opencode-app.js +2 -1
- package/src/ai/providers/pi-errors.js +6 -11
- package/src/ai/providers/pi-native/compaction-driver.js +304 -52
- package/src/ai/providers/pi-native/result-builder.js +5 -5
- package/src/ai/providers/pi-native/turn-runner.js +20 -3
- package/src/ai/providers/pi-native.js +14 -3
- package/src/ai/types.js +5 -4
- package/types/agent/tools/node-repl.d.ts +19 -0
- package/types/agent/tools/pi-bridge.d.ts +3 -2
- package/types/ai/failure.d.ts +11 -2
- package/types/ai/providers/opencode-app.d.ts +1 -1
- package/types/ai/providers/pi-native/compaction-driver.d.ts +20 -6
- package/types/ai/providers/pi-native/result-builder.d.ts +3 -2
- package/types/ai/providers/pi-native/turn-runner.d.ts +4 -2
- package/types/ai/types.d.ts +10 -8
package/ARCHITECTURE.md
CHANGED
|
@@ -40,7 +40,7 @@ flowchart TB
|
|
|
40
40
|
Registry --> PiSDK["Pi SDK bridge<br/>OpenAI, Codex, Gemini, OpenRouter,<br/>Ollama, custom providers"]
|
|
41
41
|
Registry --> CodexApp["Codex app-server CLI bridge"]
|
|
42
42
|
|
|
43
|
-
AgentKernel --> Builtins["Read / Write / Edit / Glob / Grep / Bash<br/>WebFetch / WebSearch"]
|
|
43
|
+
AgentKernel --> Builtins["Read / Write / Edit / Glob / Grep / Bash<br/>NodeRepl / WebFetch / WebSearch"]
|
|
44
44
|
AgentKernel --> MCP["MCP stdio / SSE / HTTP tools"]
|
|
45
45
|
AgentKernel --> Sandbox["Sandbox policy<br/>path/network checks + stdio command wrapping"]
|
|
46
46
|
AgentKernel --> Artifacts["Tool-output bloat guard<br/>host artifact persistence"]
|
|
@@ -180,8 +180,9 @@ Key responsibilities by subsystem:
|
|
|
180
180
|
Real hosts inject `@mono-agent/runtime-adapter`'s sandbox implementation.
|
|
181
181
|
- `agent/compaction.js`: pure helpers consumed by the pi bridge —
|
|
182
182
|
`resolveAgentCompactionPolicy` (derives the context-window compaction trigger +
|
|
183
|
-
tool-output payload limits from
|
|
184
|
-
model
|
|
183
|
+
adaptive budgets and tool-output payload limits from the typed compaction
|
|
184
|
+
policy and running model; deprecated `agent_compaction_*` settings remain a
|
|
185
|
+
compatibility input), `estimateFixedOverheadTokens` (the proactive fixed-overhead correction:
|
|
185
186
|
system prompt + tool schemas + per-turn message), `isLikelyContextTermination`
|
|
186
187
|
(classifies a context-pressure error), and the typed-policy/`settings`-bag shim
|
|
187
188
|
helpers (`resolveRuntimePolicyInputs`, `deprecatedSettingsWarning`) that let a
|
|
@@ -255,12 +256,12 @@ provider exposes queue-after-turn), not durability/cost:
|
|
|
255
256
|
The pi runtime is built on pi-agent-core's native `AgentHarness` (the hand-rolled
|
|
256
257
|
bridge was removed once native reached parity); it owns the session and
|
|
257
258
|
pi-ai-managed retry. `AgentHarness` itself has **no** automatic compaction, so
|
|
258
|
-
the pi bridge drives it
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
259
|
+
the pi bridge drives it through a one-shot `session_before_compact` hook: before
|
|
260
|
+
each turn it compares the full request estimate with an adaptive trigger, and if
|
|
261
|
+
a turn still overflows it retries exactly once only after a preview verifies a
|
|
262
|
+
positive reduction. Runs report `context_compaction_applied` as `true` (a
|
|
262
263
|
compaction fired), `false` (enabled but not needed), or `null` (disabled via
|
|
263
|
-
`
|
|
264
|
+
`runtime.compaction.enabled: false`).
|
|
264
265
|
|
|
265
266
|
| Provider | Warm session | Resume across turns | Survives process restart |
|
|
266
267
|
|---|---|---|---|
|
package/MIGRATION.md
CHANGED
|
@@ -54,14 +54,16 @@ These were Pi-bridge knobs the native path does not consume.
|
|
|
54
54
|
overflows the bridge compacts once and re-prompts (reactive recovery).
|
|
55
55
|
- Runs report **`capabilitiesUsed.context_compaction_applied`** as `true` (a
|
|
56
56
|
compaction fired), `false` (enabled but not needed), or `null` (disabled via
|
|
57
|
-
`
|
|
57
|
+
`runtime.compaction.enabled: false`). If you assert on this value, expect this
|
|
58
58
|
tristate on the Pi path.
|
|
59
59
|
- The host **`onCompactionRecorded`** callback now **fires on each automatic
|
|
60
60
|
compaction** on the Pi path (previously inert).
|
|
61
|
-
- The trigger
|
|
62
|
-
(`harness.getModel()`)
|
|
63
|
-
|
|
64
|
-
`
|
|
61
|
+
- The trigger and omitted budgets adapt to the model actually serving the
|
|
62
|
+
request (`harness.getModel()`). Numeric overflow limits and generic failed
|
|
63
|
+
request estimates lower a learned process-local ceiling; use
|
|
64
|
+
`runtime.compaction.contextWindowOverride` for a persistent metadata
|
|
65
|
+
correction. Deprecated programmatic `agent_compaction_*` settings and
|
|
66
|
+
`resolveAgentCompactionPolicy` remain compatibility surfaces.
|
|
65
67
|
|
|
66
68
|
## 4. Durable Pi session resume: create-on-miss semantics
|
|
67
69
|
|
|
@@ -160,6 +162,9 @@ pass it, so this is a no-op there).
|
|
|
160
162
|
`resolveAgentCompactionPolicy(settings, model)` stays exported (the canonical
|
|
161
163
|
clamp/mapper both paths route through), and `@mono-agent/runtime-adapter` exposes
|
|
162
164
|
`resolveRuntimePolicies(settings)` to map a legacy bag to the typed objects.
|
|
165
|
+
The migration helper preserves omitted legacy compaction values so adaptive
|
|
166
|
+
defaults are resolved later against the live model rather than frozen at the
|
|
167
|
+
mapper's fallback window.
|
|
163
168
|
**Action:** migrate `settings` → `toolLimits` / `compaction`; until then the shim
|
|
164
169
|
keeps working with one deprecation warning per run.
|
|
165
170
|
|
package/README.md
CHANGED
|
@@ -275,6 +275,7 @@ PROVIDER_ABORT_RE
|
|
|
275
275
|
RetryableProviderFailureInfo
|
|
276
276
|
classifyFailure
|
|
277
277
|
createStderrTail
|
|
278
|
+
isContextLimitFailureText
|
|
278
279
|
isProviderAuthFailureText
|
|
279
280
|
retryableProviderFailureInfo
|
|
280
281
|
```
|
|
@@ -478,7 +479,7 @@ console.log(result.text);
|
|
|
478
479
|
`@mono-agent/agent-runtime` is purpose-built for **autonomous, long-running agent work** with provider portability and operational resilience as first-class concerns. It is *not* a streaming-chat UI kit. Where each peer fits:
|
|
479
480
|
|
|
480
481
|
- **Vercel AI SDK** — best when you're building a chat / generative-UI experience inside a React or Next.js app. `useChat`, `useCompletion`, streaming server components, and edge-runtime compatibility are their strengths. Their provider list is curated (Anthropic, OpenAI, Google, etc., via `@ai-sdk/*` packages); there's no Pi gateway, no Claude Code CLI, no Codex CLI app-server, and no per-call provider fallback. If you're rendering a streaming chat into a browser, use them. If you're orchestrating multi-turn autonomous work that must survive a rate-limited primary provider, use us.
|
|
481
|
-
- **Claude Agent SDK** (`@anthropic-ai/claude-agent-sdk`) — first-party Anthropic SDK. Tight integration with Claude features (canUseTool, sub-agents, hooks, MCP). We *wrap* it as one of our five bridges and add transcript-resume across provider drops, a
|
|
482
|
+
- **Claude Agent SDK** (`@anthropic-ai/claude-agent-sdk`) — first-party Anthropic SDK. Tight integration with Claude features (canUseTool, sub-agents, hooks, MCP). We *wrap* it as one of our five bridges and add transcript-resume across provider drops, a structured failure taxonomy, a tool-bloat guard with artifact persistence, and a provider fallback router. Context/window handling remains bridge-specific; the pi-native bridge drives its own compaction recovery. Reach for the bare Anthropic SDK when you only ever talk to Claude and don't need cross-provider portability or resume.
|
|
482
483
|
- **Mastra** — a workflow engine + memory + RAG stack. Different category: it's the layer *above* a runtime. You can layer Mastra workflows on top of `@mono-agent/agent-runtime` if you want both.
|
|
483
484
|
- **OpenAI Agents SDK** — first-party OpenAI SDK. Same trade-off as the Claude Agent SDK: tight integration with OpenAI, no other providers. Pi providers in our runtime cover OpenAI plus a dozen others through a single API.
|
|
484
485
|
- **LangChain.js** — kitchen sink with deep abstraction stacks. We're deliberately lean; if you want chains, agents, vector stores, and parsers under one umbrella, LangChain is built for that. If you want a focused runtime kernel, use us.
|
|
@@ -674,12 +675,14 @@ Returns:
|
|
|
674
675
|
|
|
675
676
|
## Built-in tools
|
|
676
677
|
|
|
677
|
-
The agent kernel
|
|
678
|
+
The agent kernel's managed tools are `Read`, `Write`, `Edit`, `Glob`, `Grep`, `Bash`, `NodeRepl`, `WebFetch`, and `WebSearch`. `NodeRepl({ code })` is backed by one lazily started Node.js REPL child per run. You select them via `allowedTools`. Tool implementations honor:
|
|
678
679
|
|
|
679
680
|
- `cwd` (required for path-based tools)
|
|
680
681
|
- The runtime context's `workspace` / `repoRoot` allow-list (paths outside both, plus `/tmp` and `process.cwd()`, are rejected)
|
|
681
682
|
- Output truncation with optional artifact persistence (`{toolArtifactDir}/tool-output/{runId}/...` when `toolArtifactDir` is configured)
|
|
682
683
|
|
|
684
|
+
`NodeRepl` uses Node's default `node:repl` evaluator, so variables, `_`, `_error`, and loaded modules persist across calls in the same run. It supports multiline input and top-level `await`, resolves workspace-installed packages, and is closed with the run. Its child is prepared through the same sandbox seam as `Bash`; abort, the fixed 120-second timeout, child exit, or hard output overflow resets the session. It deliberately has no session ids, persistent history, terminal commands, or package-install surface.
|
|
685
|
+
|
|
683
686
|
Override or extend the tool surface by passing `mcpServers` for MCP-backed tools.
|
|
684
687
|
|
|
685
688
|
## Structured output
|
|
@@ -713,6 +716,7 @@ Behaviour:
|
|
|
713
716
|
|
|
714
717
|
- Successful run on entry N → returns the result with `failoverHistory` set to attempts 0..N-1.
|
|
715
718
|
- Retryable provider failure → emits `provider_failover_started`, builds a transcript snapshot, and retries on the next entry.
|
|
719
|
+
- Context-window failure after bridge compaction recovery → preserves `failureKind: "context_limit"` in `failoverHistory` and tries the next entry; quota/output/max-turn `usage_limit` remains terminal.
|
|
716
720
|
- Provider auth failure → retries the next chain entry and preserves `failureKind: "provider_auth"` in `failoverHistory` for the failed attempt.
|
|
717
721
|
- Malformed request/config/billing-type non-retryable failure → returns immediately with `failoverHistory` containing the one attempt.
|
|
718
722
|
- Cancellation → returns immediately.
|
|
@@ -823,17 +827,34 @@ Hosts that don't supply `persistArtifact` get the truncation summary but no on-d
|
|
|
823
827
|
The sole pi bridge runs on pi-agent-core's native `AgentHarness`. pi performs **no**
|
|
824
828
|
automatic in-loop compaction, so the bridge drives it: before each turn it estimates the
|
|
825
829
|
running model's context usage and calls `AgentHarness.compact()` when near the window
|
|
826
|
-
(proactive), and if a turn still overflows it compacts once and re-prompts
|
|
827
|
-
|
|
828
|
-
|
|
830
|
+
(proactive), and if a turn still overflows it compacts once and re-prompts exactly once
|
|
831
|
+
only after a rebuilt-context preview proves positive reduction (reactive recovery).
|
|
832
|
+
The bridge installs a one-shot `session_before_compact` hook around the public harness
|
|
833
|
+
operation, using Pi's public `prepareCompaction()` and `compact()` primitives so the
|
|
834
|
+
harness still owns phase changes, persistence, and events. Non-reducing previews and
|
|
835
|
+
proactive savings below policy are cancelled before persistence.
|
|
836
|
+
|
|
837
|
+
The context window auto-tracks the model actually serving the request
|
|
838
|
+
(`harness.getModel()`). Numeric provider limits become learned ceilings; generic
|
|
839
|
+
overflow temporarily lowers the process-local ceiling to 90% of the failed request
|
|
840
|
+
estimate. A configured `contextWindowOverride` provides a persistent metadata
|
|
841
|
+
correction, while learned evidence may still lower it.
|
|
829
842
|
Runs report `context_compaction_applied: true` (fired), `false` (enabled but not needed),
|
|
830
|
-
or `null` (disabled)
|
|
843
|
+
or `null` (disabled), plus request-estimate, fixed-overhead, reactive-attempted,
|
|
844
|
+
tokens-after, and reduced diagnostics.
|
|
845
|
+
Persistent overflow is classified as `context_limit`, allowing the fallback router to
|
|
846
|
+
try the next configured model. The other backends manage their windows per their own behavior.
|
|
831
847
|
(`docs/reference/feature-registry.md` is the source of truth for this row.)
|
|
832
848
|
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
849
|
+
Hosts pass the typed `RuntimeCompactionPolicy` through `runOptions.compaction`; the
|
|
850
|
+
deprecated programmatic `agent_compaction_*` settings remain a compatibility fallback.
|
|
851
|
+
The config-first host exposes this as `runtime.compaction.*` plus matching
|
|
852
|
+
`MONO_AGENT_COMPACTION_*` variables. Omitted values resolve against effective window
|
|
853
|
+
`W`: trigger ratio `0.70`; safety headroom `clamp(floor(W × 0.25), 16000, 96000)`;
|
|
854
|
+
retained context and minimum proactive savings `clamp(floor(W × 0.10), 4000, 20000)`;
|
|
855
|
+
summary output `clamp(floor(W × 0.04), 2000, 12000)`. Explicit values retain their
|
|
856
|
+
scalar validation bounds. `onCompactionRecorded(record)` fires only for accepted,
|
|
857
|
+
persisted automatic compactions.
|
|
837
858
|
|
|
838
859
|
## Advanced exports
|
|
839
860
|
|
package/package.json
CHANGED
package/src/agent/compaction.js
CHANGED
|
@@ -35,10 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
const DEFAULT_CONTEXT_WINDOW = 128000;
|
|
38
|
-
const DEFAULT_TRIGGER_RATIO = 0.
|
|
39
|
-
const DEFAULT_KEEP_RECENT_TOKENS = 24000;
|
|
40
|
-
const DEFAULT_SUMMARY_MAX_TOKENS = 16000;
|
|
41
|
-
const DEFAULT_MIN_SAVINGS_TOKENS = 20000;
|
|
38
|
+
const DEFAULT_TRIGGER_RATIO = 0.70;
|
|
42
39
|
const DEFAULT_TOOL_PAYLOAD_COMPACTION_TRIGGER_CHARS = 0;
|
|
43
40
|
const DEFAULT_TOOL_PRUNE_TRIGGER_TOKENS = 40000;
|
|
44
41
|
// intelligence-ramp Phase 3: lifted from 16K/20K/12K. Mid-task tool reads
|
|
@@ -100,22 +97,43 @@ export function resolveAgentCompactionPolicy(settings = {}, model = {}) {
|
|
|
100
97
|
0.2,
|
|
101
98
|
0.95,
|
|
102
99
|
);
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
100
|
+
const safetyHeadroom = clampInteger(contextWindow * 0.25, 16000, 16000, 96000);
|
|
101
|
+
// Add a tiny scale-aware epsilon before flooring so decimal ratios such as
|
|
102
|
+
// 0.70 do not lose a token to IEEE-754 representation (372000 * 0.70 is
|
|
103
|
+
// otherwise 260399.99999999997 in JavaScript).
|
|
104
|
+
const ratioTrigger = Math.floor((contextWindow * triggerRatio) + (Number.EPSILON * contextWindow));
|
|
105
|
+
const reserveTrigger = Math.max(1, contextWindow - safetyHeadroom);
|
|
106
|
+
const adaptiveKeepRecentTokens = clampInteger(contextWindow * 0.10, 4000, 4000, 20000);
|
|
107
|
+
const adaptiveSummaryMaxTokens = clampInteger(contextWindow * 0.04, 2000, 2000, 12000);
|
|
108
|
+
const adaptiveMinSavingsTokens = clampInteger(contextWindow * 0.10, 4000, 4000, 20000);
|
|
106
109
|
return {
|
|
107
110
|
enabled: settings.agent_compaction_enabled !== false,
|
|
108
111
|
contextWindow,
|
|
109
112
|
triggerRatio,
|
|
110
113
|
triggerTokens: Math.min(ratioTrigger, reserveTrigger),
|
|
111
|
-
keepRecentTokens: clampInteger(
|
|
112
|
-
|
|
114
|
+
keepRecentTokens: clampInteger(
|
|
115
|
+
settings.agent_compaction_keep_recent_tokens,
|
|
116
|
+
adaptiveKeepRecentTokens,
|
|
117
|
+
4000,
|
|
118
|
+
200000,
|
|
119
|
+
),
|
|
120
|
+
summaryMaxTokens: clampInteger(
|
|
121
|
+
settings.agent_compaction_summary_max_tokens,
|
|
122
|
+
adaptiveSummaryMaxTokens,
|
|
123
|
+
1000,
|
|
124
|
+
64000,
|
|
125
|
+
),
|
|
113
126
|
// ON by default; the proactive fixed-overhead correction (system prompt +
|
|
114
127
|
// tool schemas + per-turn message) is disabled only when explicitly false.
|
|
115
128
|
// Read by the compaction driver off the resolved policy so it never has to
|
|
116
129
|
// re-sniff the raw settings/policy inputs.
|
|
117
130
|
fixedOverheadEnabled: settings.agent_compaction_fixed_overhead_enabled !== false,
|
|
118
|
-
compactionMinSavingsTokens: clampInteger(
|
|
131
|
+
compactionMinSavingsTokens: clampInteger(
|
|
132
|
+
settings.agent_compaction_min_savings_tokens,
|
|
133
|
+
adaptiveMinSavingsTokens,
|
|
134
|
+
0,
|
|
135
|
+
500000,
|
|
136
|
+
),
|
|
119
137
|
toolPayloadCompactionTriggerChars: clampInteger(
|
|
120
138
|
settings.agent_tool_payload_compaction_trigger_chars,
|
|
121
139
|
DEFAULT_TOOL_PAYLOAD_COMPACTION_TRIGGER_CHARS,
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { passthroughSandbox } from "../sandbox-seam.js";
|
|
6
|
+
import { capChars } from "./shared/output-truncation.js";
|
|
7
|
+
import { readToolRuntime } from "./shared/runtime-context.js";
|
|
8
|
+
import { resolveSandboxPolicy } from "./shared/tool-context.js";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_NODE_REPL_TIMEOUT_MS = 120_000;
|
|
11
|
+
const NODE_REPL_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
12
|
+
const KILL_GRACE_MS = 1_000;
|
|
13
|
+
|
|
14
|
+
// Kept self-contained so the sandboxed child can start from `node --eval`
|
|
15
|
+
// without needing read access to agent-runtime's installed package directory.
|
|
16
|
+
function nodeReplWorkerMain() {
|
|
17
|
+
const repl = require("node:repl");
|
|
18
|
+
const { PassThrough } = require("node:stream");
|
|
19
|
+
const MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
20
|
+
const input = new PassThrough();
|
|
21
|
+
const output = new PassThrough();
|
|
22
|
+
const server = repl.start({
|
|
23
|
+
input,
|
|
24
|
+
output,
|
|
25
|
+
prompt: "",
|
|
26
|
+
terminal: false,
|
|
27
|
+
useGlobal: false,
|
|
28
|
+
});
|
|
29
|
+
const replServer = /** @type {any} */ (server);
|
|
30
|
+
let active = null;
|
|
31
|
+
|
|
32
|
+
function send(message) {
|
|
33
|
+
try {
|
|
34
|
+
process.send?.(message);
|
|
35
|
+
} catch {
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function errorText(error) {
|
|
41
|
+
const cause = error?.err ?? error;
|
|
42
|
+
return String(cause?.stack || cause?.message || cause || "Node REPL evaluation failed.");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function finish(ok, text, reset = false) {
|
|
46
|
+
const request = active;
|
|
47
|
+
if (!request) return;
|
|
48
|
+
active = null;
|
|
49
|
+
const value = String(text || "");
|
|
50
|
+
const responseBytes = Buffer.byteLength(value, "utf8")
|
|
51
|
+
+ Buffer.byteLength(request.stdout, "utf8")
|
|
52
|
+
+ Buffer.byteLength(request.stderr, "utf8");
|
|
53
|
+
if (responseBytes > MAX_BUFFER_BYTES) {
|
|
54
|
+
send({
|
|
55
|
+
type: "result",
|
|
56
|
+
id: request.id,
|
|
57
|
+
ok: false,
|
|
58
|
+
reset: true,
|
|
59
|
+
text: `Node REPL output exceeded ${MAX_BUFFER_BYTES} bytes.`,
|
|
60
|
+
stdout: request.stdout,
|
|
61
|
+
stderr: request.stderr,
|
|
62
|
+
});
|
|
63
|
+
setImmediate(() => process.exit(1));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
send({
|
|
67
|
+
type: "result",
|
|
68
|
+
id: request.id,
|
|
69
|
+
ok,
|
|
70
|
+
reset,
|
|
71
|
+
text: value,
|
|
72
|
+
stdout: request.stdout,
|
|
73
|
+
stderr: request.stderr,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function captureProcessWrite(field, originalWrite) {
|
|
78
|
+
return function capturedWrite(chunk, encoding, callback) {
|
|
79
|
+
if (!active) return originalWrite(chunk, encoding, callback);
|
|
80
|
+
const resolvedEncoding = typeof encoding === "string" ? encoding : "utf8";
|
|
81
|
+
const resolvedCallback = typeof encoding === "function" ? encoding : callback;
|
|
82
|
+
const buffer = typeof chunk === "string"
|
|
83
|
+
? Buffer.from(chunk, /** @type {any} */ (resolvedEncoding))
|
|
84
|
+
: Buffer.from(/** @type {any} */ (chunk));
|
|
85
|
+
active.bytes += buffer.length;
|
|
86
|
+
if (active.bytes > MAX_BUFFER_BYTES) {
|
|
87
|
+
finish(false, `Node REPL output exceeded ${MAX_BUFFER_BYTES} bytes.`, true);
|
|
88
|
+
setImmediate(() => process.exit(1));
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
active[field] += buffer.toString("utf8");
|
|
92
|
+
if (typeof resolvedCallback === "function") queueMicrotask(resolvedCallback);
|
|
93
|
+
return true;
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
process.stdout.write = captureProcessWrite("stdout", process.stdout.write.bind(process.stdout));
|
|
98
|
+
process.stderr.write = captureProcessWrite("stderr", process.stderr.write.bind(process.stderr));
|
|
99
|
+
|
|
100
|
+
output.on("data", (chunk) => {
|
|
101
|
+
if (!active) return;
|
|
102
|
+
active.bytes += chunk.length;
|
|
103
|
+
if (active.bytes > MAX_BUFFER_BYTES) {
|
|
104
|
+
finish(false, `Node REPL output exceeded ${MAX_BUFFER_BYTES} bytes.`, true);
|
|
105
|
+
setImmediate(() => process.exit(1));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
active.output += chunk.toString("utf8");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Runtime exceptions from the default evaluator are printed through the
|
|
112
|
+
// REPL output stream and completed by displayPrompt(), rather than passed to
|
|
113
|
+
// eval's callback. Intercept that public completion point while retaining the
|
|
114
|
+
// default evaluator's `_error` behavior.
|
|
115
|
+
replServer.displayPrompt = () => {
|
|
116
|
+
if (!active) return;
|
|
117
|
+
finish(false, active.output.trimEnd() || "Node REPL evaluation failed.");
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
process.on("message", (message) => {
|
|
121
|
+
const requestMessage = /** @type {any} */ (message);
|
|
122
|
+
if (!requestMessage || requestMessage.type !== "evaluate") return;
|
|
123
|
+
if (active) {
|
|
124
|
+
send({ type: "result", id: requestMessage.id, ok: false, text: "Node REPL is already evaluating code." });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (typeof requestMessage.code !== "string" || requestMessage.code.trim().length === 0) {
|
|
128
|
+
send({ type: "result", id: requestMessage.id, ok: false, text: "Node REPL code must not be empty." });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
active = { id: requestMessage.id, output: "", stdout: "", stderr: "", bytes: 0 };
|
|
132
|
+
try {
|
|
133
|
+
replServer.eval(requestMessage.code, replServer.context, "<mono-agent-node-repl>", (error, value) => {
|
|
134
|
+
if (!active || active.id !== requestMessage.id) return;
|
|
135
|
+
if (error) {
|
|
136
|
+
if (!replServer.underscoreErrAssigned) replServer.lastError = error;
|
|
137
|
+
finish(false, [active.output.trimEnd(), errorText(error)].filter(Boolean).join("\n"));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (!replServer.underscoreAssigned) replServer.last = value;
|
|
141
|
+
let rendered;
|
|
142
|
+
try {
|
|
143
|
+
rendered = replServer.writer(value);
|
|
144
|
+
} catch (writerError) {
|
|
145
|
+
finish(false, [active.output.trimEnd(), errorText(writerError)].filter(Boolean).join("\n"));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
finish(true, `${active.output}${rendered}`.trimEnd());
|
|
149
|
+
});
|
|
150
|
+
} catch (error) {
|
|
151
|
+
finish(false, [active.output.trimEnd(), errorText(error)].filter(Boolean).join("\n"));
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
process.on("disconnect", () => {
|
|
156
|
+
server.close();
|
|
157
|
+
process.exit(0);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const NODE_REPL_WORKER_SOURCE = `(${nodeReplWorkerMain.toString()})();`;
|
|
162
|
+
|
|
163
|
+
function killProcessGroup(child, signal) {
|
|
164
|
+
if (!child?.pid) return;
|
|
165
|
+
try {
|
|
166
|
+
process.kill(process.platform === "win32" ? child.pid : -child.pid, signal);
|
|
167
|
+
} catch {
|
|
168
|
+
try { process.kill(child.pid, signal); } catch { /* already gone */ }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function errorMessage(error) {
|
|
173
|
+
return error instanceof Error ? error.message : String(error);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function appendChunk(record, target, chunk) {
|
|
177
|
+
record.directOutputBytes += chunk.length;
|
|
178
|
+
if (record.directOutputBytes > NODE_REPL_MAX_BUFFER_BYTES) {
|
|
179
|
+
record.failureReason = `Node REPL output exceeded ${NODE_REPL_MAX_BUFFER_BYTES} bytes.`;
|
|
180
|
+
void terminateRecord(record);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
target.push(chunk);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function directOutput(record, capturedStdout = "", capturedStderr = "") {
|
|
187
|
+
const sections = [];
|
|
188
|
+
const stdout = `${capturedStdout}${Buffer.concat(record.stdout).toString("utf8")}`.trimEnd();
|
|
189
|
+
const stderr = `${capturedStderr}${Buffer.concat(record.stderr).toString("utf8")}`.trimEnd();
|
|
190
|
+
if (stdout) sections.push(`STDOUT:\n${stdout}`);
|
|
191
|
+
if (stderr) sections.push(`STDERR:\n${stderr}`);
|
|
192
|
+
return sections.join("\n");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function clearRequestOutput(record) {
|
|
196
|
+
record.stdout = [];
|
|
197
|
+
record.stderr = [];
|
|
198
|
+
record.directOutputBytes = 0;
|
|
199
|
+
record.failureReason = null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function resultText(record, text, stdout, stderr) {
|
|
203
|
+
return [directOutput(record, stdout, stderr), String(text || "").trimEnd()].filter(Boolean).join("\n");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function cleanupPrepared(record) {
|
|
207
|
+
if (record.cleaned) return;
|
|
208
|
+
record.cleaned = true;
|
|
209
|
+
try { await record.prepared.cleanup?.(); } catch { /* best-effort teardown */ }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function terminateRecord(record) {
|
|
213
|
+
if (record.closed) {
|
|
214
|
+
await record.done;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
killProcessGroup(record.child, "SIGTERM");
|
|
218
|
+
if (!record.killTimer) {
|
|
219
|
+
record.killTimer = setTimeout(() => killProcessGroup(record.child, "SIGKILL"), KILL_GRACE_MS);
|
|
220
|
+
record.killTimer.unref?.();
|
|
221
|
+
}
|
|
222
|
+
await record.done;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* One lazy Node REPL process owned by a single Pi run.
|
|
227
|
+
* @param {{cwd?: string, maxOutputChars?: number, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
|
|
228
|
+
*/
|
|
229
|
+
export function createNodeReplController({
|
|
230
|
+
cwd,
|
|
231
|
+
maxOutputChars,
|
|
232
|
+
sandboxPolicy,
|
|
233
|
+
sandboxEngine,
|
|
234
|
+
ctx,
|
|
235
|
+
} = {}) {
|
|
236
|
+
const resolvedCtx = ctx ?? readToolRuntime();
|
|
237
|
+
const sandbox = resolvedCtx.sandbox ?? passthroughSandbox;
|
|
238
|
+
const policy = resolveSandboxPolicy(resolvedCtx, sandboxPolicy);
|
|
239
|
+
const workdir = resolve(cwd || resolvedCtx.workspace || process.cwd());
|
|
240
|
+
let current = null;
|
|
241
|
+
let starting = null;
|
|
242
|
+
let permanentlyClosed = false;
|
|
243
|
+
let nextRequestId = 0;
|
|
244
|
+
|
|
245
|
+
async function startChild() {
|
|
246
|
+
const prepared = await sandbox.prepareCommand({
|
|
247
|
+
policy,
|
|
248
|
+
engine: sandboxEngine ?? resolvedCtx.sandboxEngine ?? undefined,
|
|
249
|
+
command: {
|
|
250
|
+
command: process.execPath,
|
|
251
|
+
args: ["--eval", NODE_REPL_WORKER_SOURCE],
|
|
252
|
+
cwd: workdir,
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
if (permanentlyClosed) {
|
|
256
|
+
await prepared.cleanup?.();
|
|
257
|
+
throw new Error("Node REPL run has already ended.");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let child;
|
|
261
|
+
try {
|
|
262
|
+
child = spawn(prepared.command, prepared.args || [], {
|
|
263
|
+
cwd: prepared.cwd,
|
|
264
|
+
detached: true,
|
|
265
|
+
env: prepared.env ? { ...process.env, ...prepared.env } : process.env,
|
|
266
|
+
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
|
267
|
+
});
|
|
268
|
+
} catch (error) {
|
|
269
|
+
await prepared.cleanup?.();
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let resolveDone = () => {};
|
|
274
|
+
const done = new Promise((resolveDonePromise) => { resolveDone = () => resolveDonePromise(); });
|
|
275
|
+
const record = {
|
|
276
|
+
child,
|
|
277
|
+
prepared,
|
|
278
|
+
done,
|
|
279
|
+
resolveDone,
|
|
280
|
+
closed: false,
|
|
281
|
+
cleaned: false,
|
|
282
|
+
killTimer: null,
|
|
283
|
+
spawnError: null,
|
|
284
|
+
failureReason: null,
|
|
285
|
+
pending: null,
|
|
286
|
+
stdout: [],
|
|
287
|
+
stderr: [],
|
|
288
|
+
directOutputBytes: 0,
|
|
289
|
+
};
|
|
290
|
+
current = record;
|
|
291
|
+
|
|
292
|
+
child.stdout?.on("data", (chunk) => appendChunk(record, record.stdout, chunk));
|
|
293
|
+
child.stderr?.on("data", (chunk) => appendChunk(record, record.stderr, chunk));
|
|
294
|
+
child.once("error", (error) => { record.spawnError = error; });
|
|
295
|
+
child.on("message", (message) => {
|
|
296
|
+
const result = /** @type {any} */ (message);
|
|
297
|
+
const pending = record.pending;
|
|
298
|
+
if (!pending || !result || result.type !== "result" || result.id !== pending.id) return;
|
|
299
|
+
record.pending = null;
|
|
300
|
+
clearTimeout(pending.timeoutTimer);
|
|
301
|
+
pending.signal?.removeEventListener?.("abort", pending.onAbort);
|
|
302
|
+
setImmediate(async () => {
|
|
303
|
+
const text = resultText(record, result.text, result.stdout, result.stderr);
|
|
304
|
+
if (result.reset) await terminateRecord(record);
|
|
305
|
+
if (result.ok) {
|
|
306
|
+
pending.resolve(capChars(text || "(no output)", {
|
|
307
|
+
label: "NodeRepl",
|
|
308
|
+
maxChars: maxOutputChars,
|
|
309
|
+
strategy: "head_tail",
|
|
310
|
+
ctx: resolvedCtx,
|
|
311
|
+
}));
|
|
312
|
+
} else {
|
|
313
|
+
pending.reject(new Error(text || "Node REPL evaluation failed."));
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
child.once("close", (code, closeSignal) => {
|
|
318
|
+
record.closed = true;
|
|
319
|
+
if (record.killTimer) clearTimeout(record.killTimer);
|
|
320
|
+
if (current === record) current = null;
|
|
321
|
+
const pending = record.pending;
|
|
322
|
+
record.pending = null;
|
|
323
|
+
if (pending) {
|
|
324
|
+
clearTimeout(pending.timeoutTimer);
|
|
325
|
+
pending.signal?.removeEventListener?.("abort", pending.onAbort);
|
|
326
|
+
const reason = record.failureReason
|
|
327
|
+
|| (record.spawnError ? errorMessage(record.spawnError) : null)
|
|
328
|
+
|| `Node REPL process exited before evaluation completed${closeSignal ? ` (${closeSignal})` : ` (code ${code ?? "unknown"})`}.`;
|
|
329
|
+
pending.reject(new Error(`${reason} Session state was reset.`));
|
|
330
|
+
}
|
|
331
|
+
void cleanupPrepared(record).finally(() => record.resolveDone());
|
|
332
|
+
});
|
|
333
|
+
return record;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function ensureChild() {
|
|
337
|
+
if (permanentlyClosed) throw new Error("Node REPL run has already ended.");
|
|
338
|
+
if (current && !current.closed) return current;
|
|
339
|
+
starting ??= startChild().finally(() => { starting = null; });
|
|
340
|
+
return await starting;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function resetForFailure(record, pending, message) {
|
|
344
|
+
if (record.pending === pending) record.pending = null;
|
|
345
|
+
clearTimeout(pending.timeoutTimer);
|
|
346
|
+
pending.signal?.removeEventListener?.("abort", pending.onAbort);
|
|
347
|
+
await terminateRecord(record);
|
|
348
|
+
pending.reject(new Error(`${message} Session state was reset.`));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
/** @param {{code: string}} params @param {{signal?: AbortSignal}} [execution] */
|
|
353
|
+
async execute({ code }, { signal } = {}) {
|
|
354
|
+
if (typeof code !== "string" || code.trim().length === 0) {
|
|
355
|
+
throw new Error("Node REPL code must not be empty.");
|
|
356
|
+
}
|
|
357
|
+
if (signal?.aborted) throw new Error("Node REPL execution aborted.");
|
|
358
|
+
const record = await ensureChild();
|
|
359
|
+
if (signal?.aborted) {
|
|
360
|
+
await terminateRecord(record);
|
|
361
|
+
throw new Error("Node REPL execution aborted. Session state was reset.");
|
|
362
|
+
}
|
|
363
|
+
if (record.pending) throw new Error("Node REPL is already evaluating code.");
|
|
364
|
+
clearRequestOutput(record);
|
|
365
|
+
const id = `node-repl-${++nextRequestId}`;
|
|
366
|
+
|
|
367
|
+
return await new Promise((resolveResult, rejectResult) => {
|
|
368
|
+
const pending = {
|
|
369
|
+
id,
|
|
370
|
+
resolve: resolveResult,
|
|
371
|
+
reject: rejectResult,
|
|
372
|
+
signal,
|
|
373
|
+
onAbort: null,
|
|
374
|
+
timeoutTimer: null,
|
|
375
|
+
};
|
|
376
|
+
pending.onAbort = () => {
|
|
377
|
+
void resetForFailure(record, pending, "Node REPL execution aborted.");
|
|
378
|
+
};
|
|
379
|
+
pending.timeoutTimer = setTimeout(() => {
|
|
380
|
+
void resetForFailure(
|
|
381
|
+
record,
|
|
382
|
+
pending,
|
|
383
|
+
`Node REPL execution timed out after ${DEFAULT_NODE_REPL_TIMEOUT_MS}ms.`,
|
|
384
|
+
);
|
|
385
|
+
}, DEFAULT_NODE_REPL_TIMEOUT_MS);
|
|
386
|
+
pending.timeoutTimer.unref?.();
|
|
387
|
+
record.pending = pending;
|
|
388
|
+
signal?.addEventListener?.("abort", pending.onAbort, { once: true });
|
|
389
|
+
record.child.send({ type: "evaluate", id, code }, (error) => {
|
|
390
|
+
if (error && record.pending === pending) {
|
|
391
|
+
void resetForFailure(record, pending, `Node REPL IPC failed: ${errorMessage(error)}.`);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
async close() {
|
|
398
|
+
if (permanentlyClosed) return;
|
|
399
|
+
permanentlyClosed = true;
|
|
400
|
+
if (starting) {
|
|
401
|
+
try { await starting; } catch { /* start failure already surfaced */ }
|
|
402
|
+
}
|
|
403
|
+
if (current) await terminateRecord(current);
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
}
|