@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.
Files changed (234) hide show
  1. package/README.md +105 -3
  2. package/dist/src/agent.d.ts +36 -10
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +76 -32
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/code-execution/index.d.ts +1 -1
  7. package/dist/src/code-execution/index.d.ts.map +1 -1
  8. package/dist/src/code-execution/index.js.map +1 -1
  9. package/dist/src/code-execution/py-runner.d.ts +34 -2
  10. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  11. package/dist/src/code-execution/py-runner.js +72 -16
  12. package/dist/src/code-execution/py-runner.js.map +1 -1
  13. package/dist/src/code-execution/runtime-py.d.ts +3 -2
  14. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  15. package/dist/src/code-execution/runtime-py.js +47 -7
  16. package/dist/src/code-execution/runtime-py.js.map +1 -1
  17. package/dist/src/code-execution/tool-definition.d.ts +12 -7
  18. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  19. package/dist/src/code-execution/tool-definition.js +59 -11
  20. package/dist/src/code-execution/tool-definition.js.map +1 -1
  21. package/dist/src/config-checks.d.ts +2 -2
  22. package/dist/src/config-checks.d.ts.map +1 -1
  23. package/dist/src/config-checks.js +6 -2
  24. package/dist/src/config-checks.js.map +1 -1
  25. package/dist/src/framework.d.ts +744 -120
  26. package/dist/src/framework.d.ts.map +1 -1
  27. package/dist/src/framework.js +3546 -906
  28. package/dist/src/framework.js.map +1 -1
  29. package/dist/src/gate/event-gate.d.ts.map +1 -1
  30. package/dist/src/gate/event-gate.js +13 -6
  31. package/dist/src/gate/event-gate.js.map +1 -1
  32. package/dist/src/held-writes.d.ts +16 -0
  33. package/dist/src/held-writes.d.ts.map +1 -0
  34. package/dist/src/held-writes.js +56 -0
  35. package/dist/src/held-writes.js.map +1 -0
  36. package/dist/src/index.d.ts +7 -3
  37. package/dist/src/index.d.ts.map +1 -1
  38. package/dist/src/index.js +8 -4
  39. package/dist/src/index.js.map +1 -1
  40. package/dist/src/mcpl/capability-grant.d.ts +4 -0
  41. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  42. package/dist/src/mcpl/capability-grant.js +1 -1
  43. package/dist/src/mcpl/capability-grant.js.map +1 -1
  44. package/dist/src/mcpl/channel-registry.d.ts +95 -18
  45. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  46. package/dist/src/mcpl/channel-registry.js +578 -150
  47. package/dist/src/mcpl/channel-registry.js.map +1 -1
  48. package/dist/src/mcpl/convert-block.d.ts +2 -2
  49. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  50. package/dist/src/mcpl/convert-block.js +6 -7
  51. package/dist/src/mcpl/convert-block.js.map +1 -1
  52. package/dist/src/mcpl/index.d.ts +5 -1
  53. package/dist/src/mcpl/index.d.ts.map +1 -1
  54. package/dist/src/mcpl/index.js +3 -1
  55. package/dist/src/mcpl/index.js.map +1 -1
  56. package/dist/src/mcpl/inference-router.d.ts +4 -2
  57. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  58. package/dist/src/mcpl/inference-router.js +6 -7
  59. package/dist/src/mcpl/inference-router.js.map +1 -1
  60. package/dist/src/mcpl/log-value.d.ts +7 -0
  61. package/dist/src/mcpl/log-value.d.ts.map +1 -1
  62. package/dist/src/mcpl/log-value.js +9 -0
  63. package/dist/src/mcpl/log-value.js.map +1 -1
  64. package/dist/src/mcpl/modern-connection.d.ts +227 -0
  65. package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
  66. package/dist/src/mcpl/modern-connection.js +1088 -0
  67. package/dist/src/mcpl/modern-connection.js.map +1 -0
  68. package/dist/src/mcpl/prefix-rule.d.ts +18 -0
  69. package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
  70. package/dist/src/mcpl/prefix-rule.js +25 -0
  71. package/dist/src/mcpl/prefix-rule.js.map +1 -0
  72. package/dist/src/mcpl/protocol-family.d.ts +179 -0
  73. package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
  74. package/dist/src/mcpl/protocol-family.js +697 -0
  75. package/dist/src/mcpl/protocol-family.js.map +1 -0
  76. package/dist/src/mcpl/push-coalescer.d.ts +38 -19
  77. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  78. package/dist/src/mcpl/push-coalescer.js +52 -23
  79. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  80. package/dist/src/mcpl/reference-fetcher.d.ts +66 -15
  81. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  82. package/dist/src/mcpl/reference-fetcher.js +243 -76
  83. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  84. package/dist/src/mcpl/references.d.ts +63 -18
  85. package/dist/src/mcpl/references.d.ts.map +1 -1
  86. package/dist/src/mcpl/references.js +117 -32
  87. package/dist/src/mcpl/references.js.map +1 -1
  88. package/dist/src/mcpl/server-connection.d.ts +326 -30
  89. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  90. package/dist/src/mcpl/server-connection.js +995 -212
  91. package/dist/src/mcpl/server-connection.js.map +1 -1
  92. package/dist/src/mcpl/server-registry.d.ts +25 -5
  93. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  94. package/dist/src/mcpl/server-registry.js +51 -12
  95. package/dist/src/mcpl/server-registry.js.map +1 -1
  96. package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
  97. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
  98. package/dist/src/mcpl/tool-result-normalize.js +232 -0
  99. package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
  100. package/dist/src/mcpl/transport.d.ts +56 -13
  101. package/dist/src/mcpl/transport.d.ts.map +1 -1
  102. package/dist/src/mcpl/transport.js +206 -70
  103. package/dist/src/mcpl/transport.js.map +1 -1
  104. package/dist/src/mcpl/types.d.ts +66 -20
  105. package/dist/src/mcpl/types.d.ts.map +1 -1
  106. package/dist/src/mcpl/types.js.map +1 -1
  107. package/dist/src/mcpl/visible-content.d.ts +2 -1
  108. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  109. package/dist/src/mcpl/visible-content.js +2 -1
  110. package/dist/src/mcpl/visible-content.js.map +1 -1
  111. package/dist/src/module-registry.d.ts +16 -6
  112. package/dist/src/module-registry.d.ts.map +1 -1
  113. package/dist/src/module-registry.js +65 -6
  114. package/dist/src/module-registry.js.map +1 -1
  115. package/dist/src/modules/api/index.d.ts +1 -1
  116. package/dist/src/modules/api/index.d.ts.map +1 -1
  117. package/dist/src/modules/api/index.js +3 -2
  118. package/dist/src/modules/api/index.js.map +1 -1
  119. package/dist/src/modules/discord/discord-js-client.d.ts +42 -2
  120. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  121. package/dist/src/modules/discord/discord-js-client.js +268 -65
  122. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  123. package/dist/src/modules/discord/index.d.ts +27 -5
  124. package/dist/src/modules/discord/index.d.ts.map +1 -1
  125. package/dist/src/modules/discord/index.js +366 -76
  126. package/dist/src/modules/discord/index.js.map +1 -1
  127. package/dist/src/modules/discord/refusal.d.ts +14 -0
  128. package/dist/src/modules/discord/refusal.d.ts.map +1 -0
  129. package/dist/src/modules/discord/refusal.js +14 -0
  130. package/dist/src/modules/discord/refusal.js.map +1 -0
  131. package/dist/src/modules/discord/types.d.ts +17 -7
  132. package/dist/src/modules/discord/types.d.ts.map +1 -1
  133. package/dist/src/modules/health/index.d.ts.map +1 -1
  134. package/dist/src/modules/health/index.js +6 -2
  135. package/dist/src/modules/health/index.js.map +1 -1
  136. package/dist/src/modules/index.d.ts +1 -1
  137. package/dist/src/modules/index.d.ts.map +1 -1
  138. package/dist/src/modules/index.js +1 -1
  139. package/dist/src/modules/index.js.map +1 -1
  140. package/dist/src/modules/workspace/disk-agreement.d.ts +25 -2
  141. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  142. package/dist/src/modules/workspace/disk-agreement.js +45 -5
  143. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  144. package/dist/src/modules/workspace/effects.d.ts +2 -1
  145. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  146. package/dist/src/modules/workspace/effects.js +19 -9
  147. package/dist/src/modules/workspace/effects.js.map +1 -1
  148. package/dist/src/modules/workspace/index.d.ts +214 -42
  149. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  150. package/dist/src/modules/workspace/index.js +667 -113
  151. package/dist/src/modules/workspace/index.js.map +1 -1
  152. package/dist/src/modules/workspace/observe.d.ts +240 -8
  153. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  154. package/dist/src/modules/workspace/observe.js +554 -24
  155. package/dist/src/modules/workspace/observe.js.map +1 -1
  156. package/dist/src/modules/workspace/types.d.ts +10 -1
  157. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  158. package/dist/src/modules/workspace/types.js.map +1 -1
  159. package/dist/src/modules/workspace/watcher.d.ts +42 -0
  160. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  161. package/dist/src/modules/workspace/watcher.js +130 -25
  162. package/dist/src/modules/workspace/watcher.js.map +1 -1
  163. package/dist/src/silent-heartbeat.d.ts +112 -39
  164. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  165. package/dist/src/silent-heartbeat.js +100 -29
  166. package/dist/src/silent-heartbeat.js.map +1 -1
  167. package/dist/src/tool-args.d.ts +40 -0
  168. package/dist/src/tool-args.d.ts.map +1 -0
  169. package/dist/src/tool-args.js +207 -0
  170. package/dist/src/tool-args.js.map +1 -0
  171. package/dist/src/tool-presentation.d.ts.map +1 -1
  172. package/dist/src/tool-presentation.js +9 -2
  173. package/dist/src/tool-presentation.js.map +1 -1
  174. package/dist/src/types/agent.d.ts +27 -17
  175. package/dist/src/types/agent.d.ts.map +1 -1
  176. package/dist/src/types/events.d.ts +8 -0
  177. package/dist/src/types/events.d.ts.map +1 -1
  178. package/dist/src/types/framework.d.ts +17 -2
  179. package/dist/src/types/framework.d.ts.map +1 -1
  180. package/dist/src/types/module.d.ts +54 -10
  181. package/dist/src/types/module.d.ts.map +1 -1
  182. package/dist/src/types/trace.d.ts +2 -2
  183. package/dist/src/types/trace.d.ts.map +1 -1
  184. package/package.json +3 -1
  185. package/src/agent.ts +99 -36
  186. package/src/code-execution/index.ts +1 -0
  187. package/src/code-execution/py-runner.ts +104 -17
  188. package/src/code-execution/runtime-py.ts +47 -7
  189. package/src/code-execution/tool-definition.ts +65 -11
  190. package/src/config-checks.ts +4 -2
  191. package/src/framework.ts +3633 -956
  192. package/src/gate/event-gate.ts +14 -6
  193. package/src/held-writes.ts +59 -0
  194. package/src/index.ts +19 -4
  195. package/src/mcpl/capability-grant.ts +1 -1
  196. package/src/mcpl/channel-registry.ts +613 -160
  197. package/src/mcpl/convert-block.ts +15 -7
  198. package/src/mcpl/index.ts +14 -1
  199. package/src/mcpl/inference-router.ts +8 -7
  200. package/src/mcpl/log-value.ts +10 -0
  201. package/src/mcpl/modern-connection.ts +1182 -0
  202. package/src/mcpl/prefix-rule.ts +32 -0
  203. package/src/mcpl/protocol-family.ts +691 -0
  204. package/src/mcpl/push-coalescer.ts +67 -29
  205. package/src/mcpl/reference-fetcher.ts +288 -82
  206. package/src/mcpl/references.ts +123 -33
  207. package/src/mcpl/server-connection.ts +1050 -221
  208. package/src/mcpl/server-registry.ts +50 -12
  209. package/src/mcpl/tool-result-normalize.ts +294 -0
  210. package/src/mcpl/transport.ts +204 -62
  211. package/src/mcpl/types.ts +67 -20
  212. package/src/mcpl/visible-content.ts +2 -1
  213. package/src/module-registry.ts +68 -13
  214. package/src/modules/api/index.ts +3 -2
  215. package/src/modules/discord/discord-js-client.ts +288 -72
  216. package/src/modules/discord/index.ts +380 -77
  217. package/src/modules/discord/refusal.ts +13 -0
  218. package/src/modules/discord/types.ts +17 -9
  219. package/src/modules/health/index.ts +6 -2
  220. package/src/modules/index.ts +1 -1
  221. package/src/modules/workspace/disk-agreement.ts +46 -5
  222. package/src/modules/workspace/effects.ts +18 -7
  223. package/src/modules/workspace/index.ts +722 -124
  224. package/src/modules/workspace/observe.ts +591 -22
  225. package/src/modules/workspace/types.ts +10 -1
  226. package/src/modules/workspace/watcher.ts +126 -25
  227. package/src/silent-heartbeat.ts +184 -59
  228. package/src/tool-args.ts +213 -0
  229. package/src/tool-presentation.ts +9 -2
  230. package/src/types/agent.ts +27 -17
  231. package/src/types/events.ts +8 -0
  232. package/src/types/framework.ts +17 -2
  233. package/src/types/module.ts +55 -10
  234. 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
- /** Resolves inner tool calls. Must never reject — map errors to strings. */
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
- this.runtimeDir = mkdtempSync(join(tmpdir(), 'af-pytc-'));
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
- env: { ...process.env, PYTHONUNBUFFERED: '1' },
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) => `Error: ${err instanceof Error ? err.message : String(err)}`)
552
+ .catch((err): ScriptToolResult => ({
553
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`,
554
+ isError: true,
555
+ }))
492
556
  .then((result) => {
493
- this.reply(child, { op: 'tool_result', id: callId, result }, `result of ${toolName} call ${callId}`);
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
- if (this.child) {
623
- const child = this.child;
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
- if (this.runtimeDir) {
644
- try {
645
- rmSync(this.runtimeDir, { recursive: true, force: true });
646
- } catch {
647
- // temp cleanup is best-effort
648
- }
649
- this.runtimeDir = null;
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 string (the tool_result text)
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
- shown = ", ".join((repr(k) if type(k) is str and len(k) <= 60 else _brief(k)) + ": " + _brief(v)
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(_pending_tool_futures.get(msg.get("id")), str(msg.get("result", "")))
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 — the same text ' +
37
- 'the tool would have returned to you directly; parse structured results with json.loads. ' +
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. A tool call that receives no ` +
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. A JSON config, or a `${VAR}` substituted as a string,
122
- * can carry a quoted "false" where the type says boolean, and only `true` turns
123
- * code execution on, so the quotes would hide what the value does; a bare
124
- * `codeExecution: true` would be read as off. A null or false codeExecution,
125
- * and a null switch, count as not set, as left-out ones do. The refusal names
126
- * the value's kind, not the value. A configuration that isn't on has nothing
127
- * else read.
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
  }
@@ -8,9 +8,11 @@
8
8
  * pasted into the wrong field.
9
9
  */
10
10
 
11
- /** The kind of a value that isn't undefined, null or a boolean, as a refusal
12
- * names it in place of the value. */
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;