@animalabs/agent-framework 0.22.3 → 0.23.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/README.md +105 -3
- package/dist/src/agent.d.ts +36 -10
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +76 -32
- package/dist/src/agent.js.map +1 -1
- package/dist/src/code-execution/index.d.ts +1 -1
- package/dist/src/code-execution/index.d.ts.map +1 -1
- package/dist/src/code-execution/index.js.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +34 -2
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +72 -16
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/runtime-py.d.ts +3 -2
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
- package/dist/src/code-execution/runtime-py.js +47 -7
- package/dist/src/code-execution/runtime-py.js.map +1 -1
- package/dist/src/code-execution/tool-definition.d.ts +12 -7
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +59 -11
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/config-checks.d.ts +2 -2
- package/dist/src/config-checks.d.ts.map +1 -1
- package/dist/src/config-checks.js +6 -2
- package/dist/src/config-checks.js.map +1 -1
- package/dist/src/framework.d.ts +744 -120
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +3546 -906
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +13 -6
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/held-writes.d.ts +16 -0
- package/dist/src/held-writes.d.ts.map +1 -0
- package/dist/src/held-writes.js +56 -0
- package/dist/src/held-writes.js.map +1 -0
- package/dist/src/index.d.ts +7 -3
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +8 -4
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-grant.d.ts +4 -0
- package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
- package/dist/src/mcpl/capability-grant.js +1 -1
- package/dist/src/mcpl/capability-grant.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +95 -18
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +578 -150
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/convert-block.d.ts +2 -2
- package/dist/src/mcpl/convert-block.d.ts.map +1 -1
- package/dist/src/mcpl/convert-block.js +6 -7
- package/dist/src/mcpl/convert-block.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +5 -1
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +3 -1
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/inference-router.d.ts +4 -2
- package/dist/src/mcpl/inference-router.d.ts.map +1 -1
- package/dist/src/mcpl/inference-router.js +6 -7
- package/dist/src/mcpl/inference-router.js.map +1 -1
- package/dist/src/mcpl/log-value.d.ts +7 -0
- package/dist/src/mcpl/log-value.d.ts.map +1 -1
- package/dist/src/mcpl/log-value.js +9 -0
- package/dist/src/mcpl/log-value.js.map +1 -1
- package/dist/src/mcpl/modern-connection.d.ts +227 -0
- package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
- package/dist/src/mcpl/modern-connection.js +1088 -0
- package/dist/src/mcpl/modern-connection.js.map +1 -0
- package/dist/src/mcpl/prefix-rule.d.ts +18 -0
- package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
- package/dist/src/mcpl/prefix-rule.js +25 -0
- package/dist/src/mcpl/prefix-rule.js.map +1 -0
- package/dist/src/mcpl/protocol-family.d.ts +179 -0
- package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
- package/dist/src/mcpl/protocol-family.js +697 -0
- package/dist/src/mcpl/protocol-family.js.map +1 -0
- package/dist/src/mcpl/push-coalescer.d.ts +38 -19
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +52 -23
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +66 -15
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +243 -76
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +63 -18
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +117 -32
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +326 -30
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +995 -212
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/server-registry.d.ts +25 -5
- package/dist/src/mcpl/server-registry.d.ts.map +1 -1
- package/dist/src/mcpl/server-registry.js +51 -12
- package/dist/src/mcpl/server-registry.js.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
- package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
- package/dist/src/mcpl/tool-result-normalize.js +232 -0
- package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +56 -13
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +206 -70
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +66 -20
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/mcpl/visible-content.d.ts +2 -1
- package/dist/src/mcpl/visible-content.d.ts.map +1 -1
- package/dist/src/mcpl/visible-content.js +2 -1
- package/dist/src/mcpl/visible-content.js.map +1 -1
- package/dist/src/module-registry.d.ts +16 -6
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +65 -6
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/api/index.d.ts +1 -1
- package/dist/src/modules/api/index.d.ts.map +1 -1
- package/dist/src/modules/api/index.js +3 -2
- package/dist/src/modules/api/index.js.map +1 -1
- package/dist/src/modules/discord/discord-js-client.d.ts +42 -2
- package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
- package/dist/src/modules/discord/discord-js-client.js +268 -65
- package/dist/src/modules/discord/discord-js-client.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts +27 -5
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +366 -76
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/discord/refusal.d.ts +14 -0
- package/dist/src/modules/discord/refusal.d.ts.map +1 -0
- package/dist/src/modules/discord/refusal.js +14 -0
- package/dist/src/modules/discord/refusal.js.map +1 -0
- package/dist/src/modules/discord/types.d.ts +17 -7
- package/dist/src/modules/discord/types.d.ts.map +1 -1
- package/dist/src/modules/health/index.d.ts.map +1 -1
- package/dist/src/modules/health/index.js +6 -2
- package/dist/src/modules/health/index.js.map +1 -1
- package/dist/src/modules/index.d.ts +1 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.d.ts +25 -2
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.js +45 -5
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
- package/dist/src/modules/workspace/effects.d.ts +2 -1
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/effects.js +19 -9
- package/dist/src/modules/workspace/effects.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +214 -42
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +667 -113
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +240 -8
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +554 -24
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +10 -1
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts +42 -0
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js +130 -25
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/silent-heartbeat.d.ts +112 -39
- package/dist/src/silent-heartbeat.d.ts.map +1 -1
- package/dist/src/silent-heartbeat.js +100 -29
- package/dist/src/silent-heartbeat.js.map +1 -1
- package/dist/src/tool-args.d.ts +40 -0
- package/dist/src/tool-args.d.ts.map +1 -0
- package/dist/src/tool-args.js +207 -0
- package/dist/src/tool-args.js.map +1 -0
- package/dist/src/tool-presentation.d.ts.map +1 -1
- package/dist/src/tool-presentation.js +9 -2
- package/dist/src/tool-presentation.js.map +1 -1
- package/dist/src/types/agent.d.ts +27 -17
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +8 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +17 -2
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +54 -10
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +2 -2
- package/dist/src/types/trace.d.ts.map +1 -1
- package/package.json +3 -1
- package/src/agent.ts +99 -36
- package/src/code-execution/index.ts +1 -0
- package/src/code-execution/py-runner.ts +104 -17
- package/src/code-execution/runtime-py.ts +47 -7
- package/src/code-execution/tool-definition.ts +65 -11
- package/src/config-checks.ts +4 -2
- package/src/framework.ts +3633 -956
- package/src/gate/event-gate.ts +14 -6
- package/src/held-writes.ts +59 -0
- package/src/index.ts +19 -4
- package/src/mcpl/capability-grant.ts +1 -1
- package/src/mcpl/channel-registry.ts +613 -160
- package/src/mcpl/convert-block.ts +15 -7
- package/src/mcpl/index.ts +14 -1
- package/src/mcpl/inference-router.ts +8 -7
- package/src/mcpl/log-value.ts +10 -0
- package/src/mcpl/modern-connection.ts +1182 -0
- package/src/mcpl/prefix-rule.ts +32 -0
- package/src/mcpl/protocol-family.ts +691 -0
- package/src/mcpl/push-coalescer.ts +67 -29
- package/src/mcpl/reference-fetcher.ts +288 -82
- package/src/mcpl/references.ts +123 -33
- package/src/mcpl/server-connection.ts +1050 -221
- package/src/mcpl/server-registry.ts +50 -12
- package/src/mcpl/tool-result-normalize.ts +294 -0
- package/src/mcpl/transport.ts +204 -62
- package/src/mcpl/types.ts +67 -20
- package/src/mcpl/visible-content.ts +2 -1
- package/src/module-registry.ts +68 -13
- package/src/modules/api/index.ts +3 -2
- package/src/modules/discord/discord-js-client.ts +288 -72
- package/src/modules/discord/index.ts +380 -77
- package/src/modules/discord/refusal.ts +13 -0
- package/src/modules/discord/types.ts +17 -9
- package/src/modules/health/index.ts +6 -2
- package/src/modules/index.ts +1 -1
- package/src/modules/workspace/disk-agreement.ts +46 -5
- package/src/modules/workspace/effects.ts +18 -7
- package/src/modules/workspace/index.ts +722 -124
- package/src/modules/workspace/observe.ts +591 -22
- package/src/modules/workspace/types.ts +10 -1
- package/src/modules/workspace/watcher.ts +126 -25
- package/src/silent-heartbeat.ts +184 -59
- package/src/tool-args.ts +213 -0
- package/src/tool-presentation.ts +9 -2
- package/src/types/agent.ts +27 -17
- package/src/types/events.ts +8 -0
- package/src/types/framework.ts +17 -2
- package/src/types/module.ts +55 -10
- package/src/types/trace.ts +2 -2
|
@@ -15,9 +15,10 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
18
|
+
import { buildChildEnv } from '../mcpl/transport.js';
|
|
18
19
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
20
|
import { tmpdir } from 'node:os';
|
|
20
|
-
import { join } from 'node:path';
|
|
21
|
+
import { join, resolve } from 'node:path';
|
|
21
22
|
import { createInterface, type Interface } from 'node:readline';
|
|
22
23
|
import { boundedErrorMessage } from '../error-message.js';
|
|
23
24
|
import { PYTHON_RUNTIME_SOURCE } from './runtime-py.js';
|
|
@@ -54,11 +55,30 @@ export interface BackgroundExecOptions {
|
|
|
54
55
|
lifetimeMs: number;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* An inner tool call's result as the script gets it. `text` is the string
|
|
60
|
+
* its await returns, and the runner sets `.is_error` on that string from
|
|
61
|
+
* `isError`. With `structuredJson`, `text` is the JSON object a structured
|
|
62
|
+
* result reaches a script as, in any of its forms, and `.structured` reads
|
|
63
|
+
* its `structuredContent`, None for a form that holds none, such as one past
|
|
64
|
+
* the size cap; without it, `.structured` is None.
|
|
65
|
+
*/
|
|
66
|
+
export interface ScriptToolResult {
|
|
67
|
+
text: string;
|
|
68
|
+
isError: boolean;
|
|
69
|
+
structuredJson?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolves inner tool calls. Must never reject — map errors to results; a
|
|
74
|
+
* rejection reaches the script as an `Error: …` result. A plain string is
|
|
75
|
+
* the result's text with nothing said about it, so the script's
|
|
76
|
+
* `.is_error` and `.structured` are None.
|
|
77
|
+
*/
|
|
58
78
|
export type ScriptToolCallHandler = (
|
|
59
79
|
toolName: string,
|
|
60
80
|
args: Record<string, unknown>,
|
|
61
|
-
) => Promise<string>;
|
|
81
|
+
) => Promise<string | ScriptToolResult>;
|
|
62
82
|
|
|
63
83
|
export interface PyRunnerOptions {
|
|
64
84
|
pythonPath?: string;
|
|
@@ -73,6 +93,19 @@ export interface PyRunnerOptions {
|
|
|
73
93
|
onToolCall: ScriptToolCallHandler;
|
|
74
94
|
/** Log prefix, typically the agent name. */
|
|
75
95
|
label?: string;
|
|
96
|
+
/** Variables for the interpreter beyond what it inherits, such as
|
|
97
|
+
* PYTHONPATH or a venv's. It inherits only what stdio MCPL children do
|
|
98
|
+
* (CHILD_ENV_ALLOWLIST and LC_*, CA bundles and proxy variables among
|
|
99
|
+
* them, and a proxy URL can carry credentials), then these, then
|
|
100
|
+
* PYTHONUNBUFFERED and PYTHONIOENCODING, which the runner sets last.
|
|
101
|
+
* Like the allowlist, this limits what the model's code inherits, not
|
|
102
|
+
* what a process running as the same user can read. */
|
|
103
|
+
env?: Record<string, string>;
|
|
104
|
+
/** Pass the host's whole environment instead of the allowlist, as before
|
|
105
|
+
* `env` existed. That hands every host variable, provider keys included,
|
|
106
|
+
* to the model's code and to every subprocess a script starts; listing
|
|
107
|
+
* what scripts need in `env` is the safer route. */
|
|
108
|
+
inheritEnv?: boolean;
|
|
76
109
|
}
|
|
77
110
|
|
|
78
111
|
const DEFAULT_TOOL_CALL_TIMEOUT_MS = 270_000;
|
|
@@ -164,6 +197,17 @@ interface PendingExec {
|
|
|
164
197
|
sent: boolean;
|
|
165
198
|
}
|
|
166
199
|
|
|
200
|
+
/**
|
|
201
|
+
* The interpreter to spawn. A bare name is looked up on PATH. A path with a
|
|
202
|
+
* separator is resolved against the host's working directory, where it
|
|
203
|
+
* resolved before the interpreter got a working directory of its own: spawn
|
|
204
|
+
* would resolve it against the runtime directory instead.
|
|
205
|
+
*/
|
|
206
|
+
function spawnablePath(pythonPath: string): string {
|
|
207
|
+
const hasSeparator = pythonPath.includes('/') || (process.platform === 'win32' && pythonPath.includes('\\'));
|
|
208
|
+
return hasSeparator ? resolve(pythonPath) : pythonPath;
|
|
209
|
+
}
|
|
210
|
+
|
|
167
211
|
export class PyRunner {
|
|
168
212
|
private readonly pythonPath: string;
|
|
169
213
|
private readonly toolCallTimeoutMs: number;
|
|
@@ -172,6 +216,7 @@ export class PyRunner {
|
|
|
172
216
|
private readonly idleReclaimMs: number;
|
|
173
217
|
private readonly onToolCall: ScriptToolCallHandler;
|
|
174
218
|
private readonly label: string;
|
|
219
|
+
private readonly envOptions: { env?: Record<string, string>; inheritEnv?: boolean };
|
|
175
220
|
|
|
176
221
|
private onWake: ((line: number, payload: unknown) => Promise<string | null>) | null = null;
|
|
177
222
|
private child: ChildProcessWithoutNullStreams | null = null;
|
|
@@ -191,6 +236,12 @@ export class PyRunner {
|
|
|
191
236
|
this.idleReclaimMs = options.idleReclaimMs ?? DEFAULT_IDLE_RECLAIM_MS;
|
|
192
237
|
this.onToolCall = options.onToolCall;
|
|
193
238
|
this.label = options.label ?? 'pytc';
|
|
239
|
+
// The interpreter runs model-authored code: the same env discipline as an
|
|
240
|
+
// MCPL connector child (#175), for a child that deserves it more. Stored
|
|
241
|
+
// as options, not a snapshot — the env is rebuilt at every spawn so a
|
|
242
|
+
// respawn after idle reclaim sees current host values, as the old
|
|
243
|
+
// per-spawn `{ ...process.env }` did.
|
|
244
|
+
this.envOptions = { env: options.env, inheritEnv: options.inheritEnv };
|
|
194
245
|
}
|
|
195
246
|
|
|
196
247
|
get busy(): boolean {
|
|
@@ -368,13 +419,23 @@ export class PyRunner {
|
|
|
368
419
|
}
|
|
369
420
|
this.teardownChild();
|
|
370
421
|
|
|
371
|
-
|
|
422
|
+
// Absolute, since the interpreter runs in it: from a relative TMPDIR,
|
|
423
|
+
// runtime.py's path would otherwise resolve against the directory itself.
|
|
424
|
+
this.runtimeDir = resolve(mkdtempSync(join(tmpdir(), 'af-pytc-')));
|
|
372
425
|
const runtimePath = join(this.runtimeDir, 'runtime.py');
|
|
373
426
|
writeFileSync(runtimePath, PYTHON_RUNTIME_SOURCE, 'utf8');
|
|
374
427
|
|
|
375
|
-
const child = spawn(this.pythonPath, [runtimePath], {
|
|
428
|
+
const child = spawn(spawnablePath(this.pythonPath), [runtimePath], {
|
|
376
429
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
377
|
-
|
|
430
|
+
// The runtime directory, not the host's working directory: a script's
|
|
431
|
+
// relative paths land here, and go at teardown, rather than beside the
|
|
432
|
+
// host's .env and data. An absolute path still reaches the host's files.
|
|
433
|
+
cwd: this.runtimeDir,
|
|
434
|
+
// Set last, over a declared env: the protocol needs both. It writes
|
|
435
|
+
// non-ASCII JSON on piped stdio, which Python before 3.15 on Windows
|
|
436
|
+
// encodes in the ANSI code page unless told otherwise, and a host's
|
|
437
|
+
// own PYTHONUTF8 no longer reaches the interpreter.
|
|
438
|
+
env: { ...buildChildEnv(this.envOptions), PYTHONUNBUFFERED: '1', PYTHONIOENCODING: 'utf-8' },
|
|
378
439
|
});
|
|
379
440
|
this.child = child;
|
|
380
441
|
|
|
@@ -488,9 +549,24 @@ export class PyRunner {
|
|
|
488
549
|
? (msg.args as Record<string, unknown>)
|
|
489
550
|
: {};
|
|
490
551
|
this.onToolCall(toolName, args)
|
|
491
|
-
.catch((err)
|
|
552
|
+
.catch((err): ScriptToolResult => ({
|
|
553
|
+
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
554
|
+
isError: true,
|
|
555
|
+
}))
|
|
492
556
|
.then((result) => {
|
|
493
|
-
|
|
557
|
+
// Only an object is a ScriptToolResult. Anything else a handler
|
|
558
|
+
// resolves with, a string or a value its type rules out, is sent
|
|
559
|
+
// as it is, so the call is still answered.
|
|
560
|
+
const reply = typeof result === 'object' && result !== null
|
|
561
|
+
? {
|
|
562
|
+
op: 'tool_result',
|
|
563
|
+
id: callId,
|
|
564
|
+
result: result.text,
|
|
565
|
+
is_error: result.isError,
|
|
566
|
+
...(result.structuredJson ? { structured_json: true } : {}),
|
|
567
|
+
}
|
|
568
|
+
: { op: 'tool_result', id: callId, result };
|
|
569
|
+
this.reply(child, reply, `result of ${toolName} call ${callId}`);
|
|
494
570
|
});
|
|
495
571
|
return;
|
|
496
572
|
}
|
|
@@ -619,8 +695,8 @@ export class PyRunner {
|
|
|
619
695
|
this.reader.close();
|
|
620
696
|
this.reader = null;
|
|
621
697
|
}
|
|
622
|
-
|
|
623
|
-
|
|
698
|
+
const child = this.child;
|
|
699
|
+
if (child) {
|
|
624
700
|
this.child = null;
|
|
625
701
|
this.childReady = null;
|
|
626
702
|
this.readyResolver = null;
|
|
@@ -640,13 +716,24 @@ export class PyRunner {
|
|
|
640
716
|
child.kill('SIGKILL');
|
|
641
717
|
}
|
|
642
718
|
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
719
|
+
const dir = this.runtimeDir;
|
|
720
|
+
this.runtimeDir = null;
|
|
721
|
+
if (dir) {
|
|
722
|
+
const remove = (): void => {
|
|
723
|
+
try {
|
|
724
|
+
rmSync(dir, { recursive: true, force: true });
|
|
725
|
+
} catch {
|
|
726
|
+
// temp cleanup is best-effort
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
// The directory is the interpreter's working directory, which Windows
|
|
730
|
+
// won't remove while the process lives, so there it goes once the
|
|
731
|
+
// process has. Elsewhere it goes now, so a host that exits right after
|
|
732
|
+
// this doesn't leave it behind. A child that never started has no exit
|
|
733
|
+
// to wait for.
|
|
734
|
+
const running = child !== null && child.pid !== undefined && child.exitCode === null && child.signalCode === null;
|
|
735
|
+
if (running && process.platform === 'win32') child.once('exit', remove);
|
|
736
|
+
else remove();
|
|
650
737
|
}
|
|
651
738
|
}
|
|
652
739
|
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
*
|
|
8
8
|
* - scripts run with top-level `await` (PyCF_ALLOW_TOP_LEVEL_AWAIT)
|
|
9
9
|
* - host tools are injected as async functions taking a single dict and
|
|
10
|
-
* returning a
|
|
10
|
+
* returning the tool_result text as a str (ToolResult), which also
|
|
11
|
+
* carries .is_error and .structured
|
|
11
12
|
* - a pending tool call that gets no response raises TimeoutError with the
|
|
12
13
|
* same message shape the managed runtime produces
|
|
13
14
|
* - interpreter globals persist across execs (container-reuse semantics)
|
|
@@ -29,7 +30,7 @@
|
|
|
29
30
|
* host -> py : {op:"init", tools:[{py_name,tool_name}], call_timeout_s,
|
|
30
31
|
* background?, log_path?, tail_chars?}
|
|
31
32
|
* {op:"exec", id, code}
|
|
32
|
-
* {op:"tool_result", id, result}
|
|
33
|
+
* {op:"tool_result", id, result, is_error?, structured_json?}
|
|
33
34
|
* {op:"wake_ack", id, error?}
|
|
34
35
|
* {op:"cancel", id, reason?}
|
|
35
36
|
* py -> host : {op:"ready"}
|
|
@@ -49,10 +50,12 @@
|
|
|
49
50
|
export const PYTHON_RUNTIME_SOURCE: string = String.raw`
|
|
50
51
|
import ast
|
|
51
52
|
import asyncio
|
|
53
|
+
import functools
|
|
52
54
|
import inspect
|
|
53
55
|
import io
|
|
54
56
|
import json
|
|
55
57
|
import os
|
|
58
|
+
import re
|
|
56
59
|
import signal
|
|
57
60
|
import sys
|
|
58
61
|
import threading
|
|
@@ -70,9 +73,16 @@ TAIL_CHARS = 4000
|
|
|
70
73
|
|
|
71
74
|
_send_lock = threading.Lock()
|
|
72
75
|
|
|
76
|
+
# A str can hold a lone surrogate, which UTF-8 can't encode: on POSIX,
|
|
77
|
+
# os.fsdecode makes one of each byte in a filename that doesn't decode as
|
|
78
|
+
# UTF-8, and json.loads makes one of a "\ud800" escape. PYTHONIOENCODING makes
|
|
79
|
+
# the protocol channel strict UTF-8, so writing one would fail the whole
|
|
80
|
+
# message, and a script's result would never arrive. Each goes as U+FFFD.
|
|
81
|
+
_LONE_SURROGATE = re.compile(r"[\ud800-\udfff]")
|
|
82
|
+
|
|
73
83
|
|
|
74
84
|
def send(obj):
|
|
75
|
-
line = json.dumps(obj, ensure_ascii=False, default=str)
|
|
85
|
+
line = _LONE_SURROGATE.sub("\ufffd", json.dumps(obj, ensure_ascii=False, default=str))
|
|
76
86
|
with _send_lock:
|
|
77
87
|
PROTO_OUT.write(line + "\n")
|
|
78
88
|
PROTO_OUT.flush()
|
|
@@ -250,6 +260,31 @@ def _deliver_cancel(st):
|
|
|
250
260
|
st.task.cancel()
|
|
251
261
|
|
|
252
262
|
|
|
263
|
+
class ToolResult(str):
|
|
264
|
+
"""A tool's result: the string the tool gave, with two attributes.
|
|
265
|
+
|
|
266
|
+
is_error: True if the result is an error, False if not, None if the
|
|
267
|
+
host didn't say.
|
|
268
|
+
structured: the parsed structuredContent of a structured result's JSON
|
|
269
|
+
object, or None.
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
# Defaults, since pickle and copy rebuild a str subclass from its text
|
|
273
|
+
# alone and then restore the attributes.
|
|
274
|
+
def __new__(cls, text="", is_error=None, structured_json=False):
|
|
275
|
+
self = str.__new__(cls, text)
|
|
276
|
+
self.is_error = is_error
|
|
277
|
+
self._structured_json = structured_json
|
|
278
|
+
return self
|
|
279
|
+
|
|
280
|
+
# Read from the string itself, the one copy sent, on first use only.
|
|
281
|
+
@functools.cached_property
|
|
282
|
+
def structured(self):
|
|
283
|
+
if not self._structured_json:
|
|
284
|
+
return None
|
|
285
|
+
return json.loads(self).get("structuredContent")
|
|
286
|
+
|
|
287
|
+
|
|
253
288
|
def _resolve(fut, value):
|
|
254
289
|
# Defense in depth: the script may have been cancelled since the lookup.
|
|
255
290
|
if fut is not None and not fut.done():
|
|
@@ -299,8 +334,9 @@ def _make_tool_fn(tool_name, py_name):
|
|
|
299
334
|
meant.update(kwargs)
|
|
300
335
|
# A key is what the agent has to write, so a str key is shown
|
|
301
336
|
# whole up to 60 characters, as names are elsewhere; a longer one,
|
|
302
|
-
# or any other key, is shown as a value is.
|
|
303
|
-
|
|
337
|
+
# or any other key, is shown as a value is. A tool's result counts
|
|
338
|
+
# as a str: ToolResult keeps str's repr, so no script code runs.
|
|
339
|
+
shown = ", ".join((repr(k) if (type(k) is str or type(k) is ToolResult) and len(k) <= 60 else _brief(k)) + ": " + _brief(v)
|
|
304
340
|
for k, v in meant.items())
|
|
305
341
|
raise TypeError(
|
|
306
342
|
py_name + " wasn't called: a tool function takes one dict of "
|
|
@@ -339,7 +375,8 @@ def _make_tool_fn(tool_name, py_name):
|
|
|
339
375
|
tool_fn.__qualname__ = py_name
|
|
340
376
|
tool_fn.__doc__ = (
|
|
341
377
|
"Host tool '" + tool_name + "'. Takes a single dict of arguments and "
|
|
342
|
-
"returns the tool result as a string."
|
|
378
|
+
"returns the tool result as a string, with .is_error and .structured "
|
|
379
|
+
"beside it."
|
|
343
380
|
)
|
|
344
381
|
return tool_fn
|
|
345
382
|
|
|
@@ -529,7 +566,10 @@ def _dispatch(msg):
|
|
|
529
566
|
)
|
|
530
567
|
st.task = _current_exec_task
|
|
531
568
|
elif op == "tool_result":
|
|
532
|
-
_resolve(
|
|
569
|
+
_resolve(
|
|
570
|
+
_pending_tool_futures.get(msg.get("id")),
|
|
571
|
+
ToolResult(str(msg.get("result", "")), msg.get("is_error"), msg.get("structured_json") is True),
|
|
572
|
+
)
|
|
533
573
|
elif op == "wake_ack":
|
|
534
574
|
_resolve(_pending_wake_futures.get(msg.get("id")), msg.get("error") or None)
|
|
535
575
|
elif op == "cancel":
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { ToolDefinition } from '../types/index.js';
|
|
12
|
-
import { assertBooleanSetting, holdsSettings } from '../config-checks.js';
|
|
12
|
+
import { assertBooleanSetting, holdsSettings, kindOf } from '../config-checks.js';
|
|
13
13
|
import { formatLimit, MAX_TIMER_MS } from './py-runner.js';
|
|
14
14
|
|
|
15
15
|
export const CODE_EXECUTION_TOOL_NAME = 'code_execution';
|
|
@@ -33,14 +33,26 @@ export function buildCodeExecutionToolDefinition(opts?: {
|
|
|
33
33
|
"non-identifier character replaced by '_' " +
|
|
34
34
|
"(e.g. tool 'mcpl--discord--fetch_history' is the function mcpl__discord__fetch_history, " +
|
|
35
35
|
"and tool 'mcpl--dog-events--status' is mcpl__dog_events__status). " +
|
|
36
|
-
'Each function takes a single dict of arguments and returns a string
|
|
37
|
-
'
|
|
36
|
+
'Each function takes a single dict of arguments and returns a string: the text the tool ' +
|
|
37
|
+
'would have returned to you directly, or "Error: ..." if the call failed. For a result ' +
|
|
38
|
+
'with structured content, the string is a JSON object to parse with json.loads: ' +
|
|
39
|
+
'{"content": [...], "structuredContent": ..., "isError": false}, with "error" added when ' +
|
|
40
|
+
`"isError" is true. "content" holds the server's content blocks. A reference to a payload ` +
|
|
41
|
+
'appears there as {"type": ..., "refId": ..., "savedTo": ...}, where "savedTo" is the ' +
|
|
42
|
+
`workspace path the payload was saved to (null if it wasn't) and "refId" is fetch_reference's ` +
|
|
43
|
+
'ref_id. A structured result over 5 MB is saved to the workspace instead, and the string is ' +
|
|
44
|
+
'{"isError": ..., "oversized": {"chars": ..., "savedTo": ...}}. ' +
|
|
45
|
+
'Each string also carries .is_error (True if the result is an error, else False; test it ' +
|
|
46
|
+
'rather than an "Error:" prefix) and .structured (the parsed "structuredContent" of the ' +
|
|
47
|
+
'JSON object above, or None). ' +
|
|
38
48
|
"An exact-name lookup dict is also available: tools['mcpl--discord--fetch_history']({...}). " +
|
|
39
49
|
'Use top-level await; run independent calls in parallel with asyncio.gather. ' +
|
|
40
50
|
'Only what you print() (plus stderr and the exit code) comes back to you — intermediate ' +
|
|
41
51
|
'tool results stay in the script, so filter or aggregate large data there and print only ' +
|
|
42
52
|
'what you need. Interpreter state (variables, imports) persists across code_execution ' +
|
|
43
|
-
`calls but is reclaimed after ~${idleMinutes} minutes idle.
|
|
53
|
+
`calls but is reclaimed after ~${idleMinutes} minutes idle. Relative paths resolve in a ` +
|
|
54
|
+
'scratch directory of the interpreter\'s, which goes whenever it stops or is reclaimed, so a file meant to ' +
|
|
55
|
+
'outlast the interpreter needs an absolute path. A tool call that receives no ' +
|
|
44
56
|
`response within ~${callTimeoutSeconds}s raises TimeoutError inside the script. ` +
|
|
45
57
|
`A foreground script is stopped after ${formatLimit(limits.defaultMs)}; pass time_limit_ms to set this call's limit ` +
|
|
46
58
|
`(at most ${formatLimit(limits.maxMs)}; a background script runs up to ${formatLimit(limits.backgroundMaxMs)}). ` +
|
|
@@ -118,18 +130,23 @@ export function scriptTimeLimits(opts?: {
|
|
|
118
130
|
/**
|
|
119
131
|
* Refuse a codeExecution that isn't an object, or whose switch is set to
|
|
120
132
|
* anything but true or false, and, once it's on, one whose time limits
|
|
121
|
-
* contradict each other
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
133
|
+
* contradict each other or whose interpreter environment would be misread. A
|
|
134
|
+
* JSON config, or a `${VAR}` substituted as a string, can carry a quoted
|
|
135
|
+
* "false" where the type says boolean, and only `true` turns code execution
|
|
136
|
+
* on, so the quotes would hide what the value does; a bare
|
|
137
|
+
* `codeExecution: true` would be read as off. Likewise the runner passes the
|
|
138
|
+
* host's environment only for an `inheritEnv` of `true`, so a quoted "true"
|
|
139
|
+
* would quietly keep the allowlist. A null or false codeExecution, and a null
|
|
140
|
+
* setting, count as not set, as left-out ones do. The refusal names the
|
|
141
|
+
* value's kind, not the value. A configuration that isn't on has nothing else
|
|
142
|
+
* read.
|
|
128
143
|
*/
|
|
129
144
|
export function validateCodeExecutionConfig(cfg: {
|
|
130
145
|
enabled?: unknown;
|
|
131
146
|
scriptTimeoutMs?: number;
|
|
132
147
|
maxScriptTimeoutMs?: number;
|
|
148
|
+
env?: unknown;
|
|
149
|
+
inheritEnv?: unknown;
|
|
133
150
|
}): void {
|
|
134
151
|
if (!holdsSettings('codeExecution', cfg, '{ enabled: true }')) return;
|
|
135
152
|
assertBooleanSetting(
|
|
@@ -146,4 +163,41 @@ export function validateCodeExecutionConfig(cfg: {
|
|
|
146
163
|
'it is the most an agent may ask for, and the default is always allowed',
|
|
147
164
|
);
|
|
148
165
|
}
|
|
166
|
+
// None of the environment's refusals below shows the value it refuses,
|
|
167
|
+
// at most its kind: a substituted value can be a credential. null counts
|
|
168
|
+
// as not set, so a generated config that writes an unset setting as null
|
|
169
|
+
// keeps working.
|
|
170
|
+
assertBooleanSetting(
|
|
171
|
+
'codeExecution.inheritEnv',
|
|
172
|
+
cfg.inheritEnv,
|
|
173
|
+
"true hands the interpreter the host's whole environment, and false or leaving it out keeps the allowlist",
|
|
174
|
+
);
|
|
175
|
+
if (cfg.env !== undefined && cfg.env !== null) {
|
|
176
|
+
if (!isPlainObject(cfg.env)) {
|
|
177
|
+
throw new Error(`codeExecution.env must be an object of variable names and string values, not ${kindOf(cfg.env)}`);
|
|
178
|
+
}
|
|
179
|
+
for (const [name, value] of Object.entries(cfg.env)) {
|
|
180
|
+
// spawn writes each variable as name=value: a name that is empty or
|
|
181
|
+
// holds '=' doesn't reach the interpreter as written, and a NUL in a
|
|
182
|
+
// name or a value makes spawn throw at every start.
|
|
183
|
+
// Only what comes before an '=' or NUL is shown: what follows can be a
|
|
184
|
+
// value pasted into the name.
|
|
185
|
+
if (name === '' || name.includes('=') || name.includes('\0')) {
|
|
186
|
+
const shown = name === '' ? 'that is empty' : `starting ${JSON.stringify(name.split(/[=\0]/, 1)[0])} that holds '=' or NUL`;
|
|
187
|
+
throw new Error(`codeExecution.env has a variable name ${shown}, and a name can't be empty or hold '=' or NUL`);
|
|
188
|
+
}
|
|
189
|
+
if (typeof value !== 'string') {
|
|
190
|
+
throw new Error(`codeExecution.env.${name} must be a string, not ${kindOf(value)}: give its value as a string, or leave ${name} out`);
|
|
191
|
+
}
|
|
192
|
+
if (value.includes('\0')) {
|
|
193
|
+
throw new Error(`codeExecution.env.${name} holds a NUL character, which no environment variable can carry`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
200
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
201
|
+
const proto = Object.getPrototypeOf(value);
|
|
202
|
+
return proto === Object.prototype || proto === null;
|
|
149
203
|
}
|
package/src/config-checks.ts
CHANGED
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
* pasted into the wrong field.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
/** The kind of a value
|
|
12
|
-
*
|
|
11
|
+
/** The kind of a value, as a refusal names it in place of the value, which
|
|
12
|
+
* can be a credential. */
|
|
13
13
|
export function kindOf(value: unknown): string {
|
|
14
|
+
if (value === null) return 'null';
|
|
15
|
+
if (value === undefined) return 'undefined';
|
|
14
16
|
if (Array.isArray(value)) return 'an array';
|
|
15
17
|
if (typeof value !== 'object') return `a ${typeof value}`;
|
|
16
18
|
const prototype = Object.getPrototypeOf(value) as { constructor?: { name?: string } } | null;
|