@animalabs/agent-framework 0.7.3 → 0.8.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 (105) hide show
  1. package/dist/src/agent.d.ts.map +1 -1
  2. package/dist/src/agent.js +28 -1
  3. package/dist/src/agent.js.map +1 -1
  4. package/dist/src/code-execution/index.d.ts +1 -1
  5. package/dist/src/code-execution/index.d.ts.map +1 -1
  6. package/dist/src/code-execution/index.js.map +1 -1
  7. package/dist/src/code-execution/py-runner.d.ts +23 -1
  8. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  9. package/dist/src/code-execution/py-runner.js +40 -4
  10. package/dist/src/code-execution/py-runner.js.map +1 -1
  11. package/dist/src/code-execution/runtime-py.d.ts +15 -2
  12. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  13. package/dist/src/code-execution/runtime-py.js +156 -7
  14. package/dist/src/code-execution/runtime-py.js.map +1 -1
  15. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  16. package/dist/src/code-execution/tool-definition.js +27 -2
  17. package/dist/src/code-execution/tool-definition.js.map +1 -1
  18. package/dist/src/framework.d.ts +92 -0
  19. package/dist/src/framework.d.ts.map +1 -1
  20. package/dist/src/framework.js +932 -60
  21. package/dist/src/framework.js.map +1 -1
  22. package/dist/src/mcpl/channel-registry.d.ts +28 -2
  23. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  24. package/dist/src/mcpl/channel-registry.js +53 -7
  25. package/dist/src/mcpl/channel-registry.js.map +1 -1
  26. package/dist/src/mcpl/transport.d.ts +1 -1
  27. package/dist/src/mcpl/transport.d.ts.map +1 -1
  28. package/dist/src/mcpl/transport.js +25 -7
  29. package/dist/src/mcpl/transport.js.map +1 -1
  30. package/dist/src/mcpl/types.d.ts +10 -0
  31. package/dist/src/mcpl/types.d.ts.map +1 -1
  32. package/dist/src/mcpl/types.js.map +1 -1
  33. package/dist/src/module-registry.d.ts +12 -0
  34. package/dist/src/module-registry.d.ts.map +1 -1
  35. package/dist/src/module-registry.js +26 -0
  36. package/dist/src/module-registry.js.map +1 -1
  37. package/dist/src/modules/workspace/index.d.ts +20 -0
  38. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  39. package/dist/src/modules/workspace/index.js +48 -15
  40. package/dist/src/modules/workspace/index.js.map +1 -1
  41. package/dist/src/modules/workspace/sync.d.ts +4 -0
  42. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  43. package/dist/src/modules/workspace/sync.js +1 -1
  44. package/dist/src/modules/workspace/sync.js.map +1 -1
  45. package/dist/src/types/agent.d.ts +12 -0
  46. package/dist/src/types/agent.d.ts.map +1 -1
  47. package/dist/src/types/framework.d.ts +10 -0
  48. package/dist/src/types/framework.d.ts.map +1 -1
  49. package/dist/src/types/module.d.ts +22 -0
  50. package/dist/src/types/module.d.ts.map +1 -1
  51. package/dist/test/agent-runtime-settings.test.js +37 -0
  52. package/dist/test/agent-runtime-settings.test.js.map +1 -1
  53. package/dist/test/assistant-persist-refusal.test.d.ts +2 -0
  54. package/dist/test/assistant-persist-refusal.test.d.ts.map +1 -0
  55. package/dist/test/assistant-persist-refusal.test.js +63 -0
  56. package/dist/test/assistant-persist-refusal.test.js.map +1 -0
  57. package/dist/test/busy-agent-scheduler.test.js +1 -0
  58. package/dist/test/busy-agent-scheduler.test.js.map +1 -1
  59. package/dist/test/code-execution.test.js +327 -1
  60. package/dist/test/code-execution.test.js.map +1 -1
  61. package/dist/test/deferred-wake-integrity.test.d.ts +29 -0
  62. package/dist/test/deferred-wake-integrity.test.d.ts.map +1 -0
  63. package/dist/test/deferred-wake-integrity.test.js +320 -0
  64. package/dist/test/deferred-wake-integrity.test.js.map +1 -0
  65. package/dist/test/machine-close-provenance.test.d.ts +2 -0
  66. package/dist/test/machine-close-provenance.test.d.ts.map +1 -0
  67. package/dist/test/machine-close-provenance.test.js +110 -0
  68. package/dist/test/machine-close-provenance.test.js.map +1 -0
  69. package/dist/test/mcpl-access-provider.test.d.ts +2 -0
  70. package/dist/test/mcpl-access-provider.test.d.ts.map +1 -0
  71. package/dist/test/mcpl-access-provider.test.js +20 -0
  72. package/dist/test/mcpl-access-provider.test.js.map +1 -0
  73. package/dist/test/module-context-integration.test.d.ts +18 -0
  74. package/dist/test/module-context-integration.test.d.ts.map +1 -0
  75. package/dist/test/module-context-integration.test.js +181 -0
  76. package/dist/test/module-context-integration.test.js.map +1 -0
  77. package/dist/test/present-while-acting.test.js +72 -0
  78. package/dist/test/present-while-acting.test.js.map +1 -1
  79. package/dist/test/utils-surface.test.d.ts +2 -0
  80. package/dist/test/utils-surface.test.d.ts.map +1 -0
  81. package/dist/test/utils-surface.test.js +145 -0
  82. package/dist/test/utils-surface.test.js.map +1 -0
  83. package/dist/test/workspace-read-image.test.js +71 -0
  84. package/dist/test/workspace-read-image.test.js.map +1 -1
  85. package/dist/test/workspace-restore-order.test.d.ts +16 -0
  86. package/dist/test/workspace-restore-order.test.d.ts.map +1 -0
  87. package/dist/test/workspace-restore-order.test.js +140 -0
  88. package/dist/test/workspace-restore-order.test.js.map +1 -0
  89. package/dist/tsconfig.tsbuildinfo +1 -1
  90. package/package.json +1 -1
  91. package/src/agent.ts +31 -1
  92. package/src/code-execution/index.ts +1 -0
  93. package/src/code-execution/py-runner.ts +56 -4
  94. package/src/code-execution/runtime-py.ts +156 -7
  95. package/src/code-execution/tool-definition.ts +27 -2
  96. package/src/framework.ts +1055 -63
  97. package/src/mcpl/channel-registry.ts +98 -4
  98. package/src/mcpl/transport.ts +26 -7
  99. package/src/mcpl/types.ts +11 -0
  100. package/src/module-registry.ts +34 -0
  101. package/src/modules/workspace/index.ts +49 -15
  102. package/src/modules/workspace/sync.ts +1 -1
  103. package/src/types/agent.ts +12 -0
  104. package/src/types/framework.ts +10 -0
  105. package/src/types/module.ts +24 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/agent-framework",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "description": "Multi-agent framework with pluggable modules, persistent state, and concurrent tool execution",
5
5
  "repository": {
6
6
  "type": "git",
package/src/agent.ts CHANGED
@@ -270,7 +270,11 @@ export class Agent {
270
270
 
271
271
  if (patch.contextBudgetTokens !== undefined) {
272
272
  const live = this.contextBudgetTokens ?? DEFAULT_CONTEXT_BUDGET_TOKENS;
273
- if (patch.contextBudgetTokens >= live) {
273
+ if (patch.contextBudgetTokens >= live || patch.immediate) {
274
+ // Increases are always immediate. Decreases take this path only with
275
+ // the explicit `immediate` flag: the next compile plans straight at
276
+ // the new budget — one-shot fold-down, KV invalidation paid this
277
+ // turn — and any in-flight descent is cancelled.
274
278
  nextContextBudget = patch.contextBudgetTokens;
275
279
  nextContextTarget = undefined;
276
280
  if (this.getHotContextSettings()) hotPatch.preparedWindowTokens = null;
@@ -298,6 +302,7 @@ export class Agent {
298
302
  }
299
303
  for (const [key, value] of Object.entries(patch)) {
300
304
  if (key === 'sameRoundThinkTextPolicy') continue;
305
+ if (key === 'immediate') continue; // a mode for this apply, never a persisted setting
301
306
  if (value !== undefined) (this.runtimeSettingsOverrides as Record<string, unknown>)[key] = value;
302
307
  }
303
308
  if (patch.sameRoundThinkTextPolicy !== undefined) {
@@ -706,6 +711,31 @@ export class Agent {
706
711
  * Called by framework when stream completes.
707
712
  */
708
713
  addAssistantResponse(content: ContentBlock[]): void {
714
+ // A turn whose entire output is thinking blocks produced NOTHING: no
715
+ // speech, no tool call. That is what a refusal looks like on the wire —
716
+ // the provider returns signed thinking (often with empty text, the
717
+ // fable-5 hidden-CoT packaging) and no content. Persisting it records an
718
+ // action that never happened, and two such records landing adjacent are
719
+ // actively toxic: formatters merge consecutive same-role messages
720
+ // (membrane `mergeConsecutiveRoles`), producing one assistant message
721
+ // carrying signed thinking from two different responses, which the
722
+ // provider cannot verify — 400 "`thinking` or `redacted_thinking` blocks
723
+ // in the latest assistant message cannot be modified", unrecoverable by
724
+ // retry (labclaude, 2026-07-27: hard down ~4h on exactly this pair).
725
+ //
726
+ // Refused turns are surfaced by the refusal path (stderr, failures.log,
727
+ // the [inference-failed] notice); the store does not also need a
728
+ // content-free artifact of them. Loud, never silent.
729
+ if (content.length > 0 && content.every(
730
+ (b) => b.type === 'thinking' || b.type === 'redacted_thinking',
731
+ )) {
732
+ console.error(
733
+ `[assistant-persist] agent=${this.name}: refusing to store a thinking-only assistant ` +
734
+ `message (${content.length} block(s), no text and no tool_use) — the turn produced no ` +
735
+ `output. See the refusal/inference-failed record for why.`,
736
+ );
737
+ return;
738
+ }
709
739
  this.contextManager.addMessage(this.name, content);
710
740
  }
711
741
 
@@ -14,6 +14,7 @@ export type {
14
14
  ExecResult,
15
15
  PyRunnerOptions,
16
16
  ScriptToolCallHandler,
17
+ BackgroundExecOptions,
17
18
  } from './py-runner.js';
18
19
  export { PYTHON_RUNTIME_SOURCE } from './runtime-py.js';
19
20
  export { buildCodeExecutionToolDefinition, CODE_EXECUTION_TOOL_NAME } from './tool-definition.js';
@@ -34,6 +34,23 @@ export interface ExecResult {
34
34
  returnCode: number;
35
35
  /** Set when the host killed the script (deadline / abort / crash). */
36
36
  aborted?: boolean;
37
+ /** Background execs: rolling tail of interleaved stdout+stderr (for the
38
+ * crash/finish envelope; the full journal is in the log file). */
39
+ tail?: string;
40
+ }
41
+
42
+ /** Per-exec options for background (daemon) scripts. */
43
+ export interface BackgroundExecOptions {
44
+ /** Absolute path for the line-buffered stdout+stderr journal (null: tail only). */
45
+ logPath: string | null;
46
+ /**
47
+ * Called on each wake_agent() from the script. Resolve with null to ack
48
+ * (deliver), or an error string to refuse (raises RuntimeError in-script).
49
+ * May resolve late — rate limiting backpressures inside wake_agent().
50
+ */
51
+ onWake: (line: number, payload: unknown) => Promise<string | null>;
52
+ /** Overrides the runner's scriptTimeoutMs (background lifetime). */
53
+ lifetimeMs: number;
37
54
  }
38
55
 
39
56
  /** Resolves inner tool calls. Must never reject — map errors to strings. */
@@ -76,6 +93,7 @@ export class PyRunner {
76
93
  private readonly onToolCall: ScriptToolCallHandler;
77
94
  private readonly label: string;
78
95
 
96
+ private onWake: ((line: number, payload: unknown) => Promise<string | null>) | null = null;
79
97
  private child: ChildProcessWithoutNullStreams | null = null;
80
98
  private childReady: Promise<void> | null = null;
81
99
  private reader: Interface | null = null;
@@ -102,8 +120,13 @@ export class PyRunner {
102
120
  * Run one script. Tools are (re-)injected before every exec so the
103
121
  * interpreter always reflects the current tool surface (list_changed etc.).
104
122
  * Never rejects: every failure mode resolves to an ExecResult.
123
+ *
124
+ * With `background` options the exec is a daemon: output journals to the
125
+ * log file, wake_agent() is available in-script, and the deadline is the
126
+ * background lifetime. A background runner should be DEDICATED to that one
127
+ * script (the framework creates one per background script).
105
128
  */
106
- async exec(code: string, tools: InjectedTool[]): Promise<ExecResult> {
129
+ async exec(code: string, tools: InjectedTool[], background?: BackgroundExecOptions): Promise<ExecResult> {
107
130
  if (this.disposed) {
108
131
  return { stdout: '', stderr: 'code_execution runner disposed', returnCode: 1, aborted: true };
109
132
  }
@@ -131,6 +154,8 @@ export class PyRunner {
131
154
  }
132
155
 
133
156
  const execId = `e${++this.execCounter}`;
157
+ const deadlineMs = background?.lifetimeMs ?? this.scriptTimeoutMs;
158
+ this.onWake = background?.onWake ?? null;
134
159
  const result = await new Promise<ExecResult>((resolve) => {
135
160
  const pending: PendingExec = {
136
161
  id: execId,
@@ -148,23 +173,29 @@ export class PyRunner {
148
173
  pending.killTimer = setTimeout(() => {
149
174
  this.settlePending({
150
175
  stdout: '',
151
- stderr: `script killed after exceeding ${Math.round(this.scriptTimeoutMs / 1000)}s deadline`,
176
+ stderr: `script killed after exceeding ${Math.round(deadlineMs / 1000)}s deadline`,
152
177
  returnCode: 1,
153
178
  aborted: true,
154
179
  });
155
180
  this.reclaim('deadline-kill');
156
181
  }, CANCEL_GRACE_MS);
157
- }, this.scriptTimeoutMs);
182
+ }, deadlineMs);
183
+ // A day-scale background deadline must not hold the process open.
184
+ if (background) pending.deadlineTimer.unref?.();
158
185
 
159
186
  this.send({
160
187
  op: 'init',
161
188
  tools: tools.map((t) => ({ py_name: t.pyName, tool_name: t.toolName })),
162
189
  call_timeout_s: Math.round(this.toolCallTimeoutMs / 1000),
190
+ ...(background
191
+ ? { background: true, log_path: background.logPath ?? null }
192
+ : {}),
163
193
  });
164
194
  this.send({ op: 'exec', id: execId, code });
165
195
  });
166
196
 
167
- this.armIdleTimer();
197
+ this.onWake = null;
198
+ if (!background) this.armIdleTimer();
168
199
  return result;
169
200
  }
170
201
 
@@ -308,12 +339,33 @@ export class PyRunner {
308
339
  return;
309
340
  }
310
341
 
342
+ case 'wake': {
343
+ const wakeId = msg.id;
344
+ if (!wakeId) return;
345
+ const line = typeof (msg as { line?: unknown }).line === 'number'
346
+ ? (msg as { line: number }).line
347
+ : -1;
348
+ const payload = (msg as { payload?: unknown }).payload;
349
+ const handler = this.onWake;
350
+ const refuse = handler
351
+ ? handler(line, payload).catch((err: unknown) =>
352
+ `wake handler failed: ${err instanceof Error ? err.message : String(err)}`)
353
+ : Promise.resolve('this script is not allowed to wake the agent');
354
+ void refuse.then((error) => {
355
+ this.send({ op: 'wake_ack', id: wakeId, ...(error ? { error } : {}) });
356
+ });
357
+ return;
358
+ }
359
+
311
360
  case 'exec_result': {
312
361
  if (this.pending && msg.id === this.pending.id) {
313
362
  this.settlePending({
314
363
  stdout: msg.stdout ?? '',
315
364
  stderr: msg.stderr ?? '',
316
365
  returnCode: typeof msg.return_code === 'number' ? msg.return_code : 1,
366
+ ...(typeof (msg as { tail?: unknown }).tail === 'string'
367
+ ? { tail: (msg as { tail: string }).tail }
368
+ : {}),
317
369
  });
318
370
  }
319
371
  return;
@@ -12,17 +12,30 @@
12
12
  * same message shape the managed runtime produces
13
13
  * - interpreter globals persist across execs (container-reuse semantics)
14
14
  *
15
+ * BACKGROUND MODE (model-authored daemons): when init carries
16
+ * `background: true`, the exec additionally gets:
17
+ * - `wake_agent(payload)` — async; reports the caller's line number (which
18
+ * indexes into the agent-authored script — self-correlation) and delivers
19
+ * the payload to the host, which injects it into the agent's context and
20
+ * triggers inference. Awaits a host ack so rate limiting backpressures
21
+ * inside the script instead of dropping wakes.
22
+ * - stdout/stderr tee'd line-buffered to `log_path` (agent-readable via
23
+ * their file tools) with an in-memory tail kept for the crash envelope.
24
+ *
15
25
  * Embedded as a TS string because tsc does not copy non-TS assets to dist/;
16
26
  * PyRunner materializes it to a temp file at spawn.
17
27
  *
18
28
  * Protocol (JSON lines):
19
- * host -> py : {op:"init", tools:[{py_name,tool_name}], call_timeout_s}
29
+ * host -> py : {op:"init", tools:[{py_name,tool_name}], call_timeout_s,
30
+ * background?, log_path?, tail_chars?}
20
31
  * {op:"exec", id, code}
21
32
  * {op:"tool_result", id, result}
33
+ * {op:"wake_ack", id, error?}
22
34
  * {op:"cancel", id, reason?}
23
35
  * py -> host : {op:"ready"}
24
36
  * {op:"tool_call", id, exec_id, name, args}
25
- * {op:"exec_result", id, stdout, stderr, return_code}
37
+ * {op:"wake", id, exec_id, line, payload}
38
+ * {op:"exec_result", id, stdout, stderr, return_code, tail?}
26
39
  *
27
40
  * IMPORTANT: the python source below must not contain backticks or the
28
41
  * sequence dollar+brace (TS template literal syntax). String.raw preserves
@@ -35,6 +48,7 @@ import asyncio
35
48
  import inspect
36
49
  import io
37
50
  import json
51
+ import os
38
52
  import sys
39
53
  import threading
40
54
  import traceback
@@ -43,6 +57,11 @@ PROTO_OUT = sys.stdout # protocol channel (real stdout)
43
57
  REAL_STDERR = sys.stderr # runtime diagnostics outside execs
44
58
  CALL_TIMEOUT_S = 270.0
45
59
  OUTPUT_CAP_CHARS = 2_000_000
60
+ LOG_FILE_CAP_BYTES = 50_000_000
61
+
62
+ BACKGROUND = False
63
+ LOG_PATH = None
64
+ TAIL_CHARS = 4000
46
65
 
47
66
  _send_lock = threading.Lock()
48
67
 
@@ -75,13 +94,78 @@ class CappedIO(io.StringIO):
75
94
  return super().write(s)
76
95
 
77
96
 
97
+ class LogTee:
98
+ """Background-mode writer: line-buffered file append + rolling tail.
99
+
100
+ The file is the agent-readable journal (their read/grep/shell tools work
101
+ on it); the tail rides the exec_result so the host can build a crash
102
+ envelope without re-reading the file.
103
+ """
104
+
105
+ def __init__(self, path, tail_chars):
106
+ self._fh = None
107
+ self._written = 0
108
+ self._tail = ""
109
+ self._tail_chars = tail_chars
110
+ self.truncated = False
111
+ if path:
112
+ try:
113
+ os.makedirs(os.path.dirname(path), exist_ok=True)
114
+ self._fh = open(path, "a", encoding="utf-8", errors="replace")
115
+ except Exception as exc:
116
+ REAL_STDERR.write("[pytc-runtime] cannot open log file: " + str(exc) + "\n")
117
+ REAL_STDERR.flush()
118
+
119
+ def write(self, s):
120
+ if not isinstance(s, str):
121
+ s = str(s)
122
+ self._tail = (self._tail + s)[-self._tail_chars:]
123
+ if self._fh is not None and not self.truncated:
124
+ if self._written + len(s) > LOG_FILE_CAP_BYTES:
125
+ self.truncated = True
126
+ try:
127
+ self._fh.write("\n[log capped at " + str(LOG_FILE_CAP_BYTES) + " bytes]\n")
128
+ self._fh.flush()
129
+ except Exception:
130
+ pass
131
+ else:
132
+ try:
133
+ self._fh.write(s)
134
+ if "\n" in s:
135
+ self._fh.flush()
136
+ self._written += len(s)
137
+ except Exception:
138
+ pass
139
+ return len(s)
140
+
141
+ def flush(self):
142
+ if self._fh is not None:
143
+ try:
144
+ self._fh.flush()
145
+ except Exception:
146
+ pass
147
+
148
+ def close(self):
149
+ if self._fh is not None:
150
+ try:
151
+ self._fh.close()
152
+ except Exception:
153
+ pass
154
+ self._fh = None
155
+
156
+ def tail(self):
157
+ return self._tail
158
+
159
+
78
160
  # Persistent interpreter state (mirrors container reuse: variables survive
79
161
  # between exec calls until the host reclaims the interpreter).
80
162
  SCRIPT_GLOBALS = {"__name__": "__main__", "__builtins__": __builtins__}
81
163
  _injected_py_names = set()
82
164
 
83
165
  _pending_tool_futures = {}
166
+ _pending_wake_futures = {}
84
167
  _next_tool_call_id = 0
168
+ _next_wake_id = 0
85
169
  _current_exec_id = None
86
170
  _current_exec_task = None
87
171
 
@@ -116,7 +200,6 @@ def _make_tool_fn(tool_name, py_name):
116
200
  )
117
201
  finally:
118
202
  _pending_tool_futures.pop(call_id, None)
119
-
120
203
  tool_fn.__name__ = py_name
121
204
  tool_fn.__qualname__ = py_name
122
205
  tool_fn.__doc__ = (
@@ -126,11 +209,47 @@ def _make_tool_fn(tool_name, py_name):
126
209
  return tool_fn
127
210
 
128
211
 
212
+ async def wake_agent(payload=None):
213
+ """Wake the agent: deliver payload into their context and trigger a turn.
214
+
215
+ Reports the caller's line number (indexes into the script the agent
216
+ wrote). Awaits the host ack — rate limiting backpressures here rather
217
+ than dropping wakes. Raises RuntimeError when the host refuses (wake cap
218
+ exceeded, script cancelled).
219
+ """
220
+ global _next_wake_id
221
+ frame = inspect.currentframe()
222
+ line = frame.f_back.f_lineno if frame is not None and frame.f_back is not None else -1
223
+ _next_wake_id += 1
224
+ wake_id = "w" + str(_next_wake_id)
225
+ fut = asyncio.get_running_loop().create_future()
226
+ _pending_wake_futures[wake_id] = fut
227
+ send({
228
+ "op": "wake",
229
+ "id": wake_id,
230
+ "exec_id": _current_exec_id,
231
+ "line": line,
232
+ "payload": payload,
233
+ })
234
+ try:
235
+ error = await fut
236
+ if error:
237
+ raise RuntimeError("wake_agent refused by host: " + str(error))
238
+ return None
239
+ finally:
240
+ _pending_wake_futures.pop(wake_id, None)
241
+
242
+
129
243
  def handle_init(msg):
130
- global CALL_TIMEOUT_S
244
+ global CALL_TIMEOUT_S, BACKGROUND, LOG_PATH, TAIL_CHARS
131
245
  timeout = msg.get("call_timeout_s")
132
246
  if isinstance(timeout, (int, float)) and timeout > 0:
133
247
  CALL_TIMEOUT_S = float(timeout)
248
+ BACKGROUND = bool(msg.get("background"))
249
+ LOG_PATH = msg.get("log_path") or None
250
+ tail = msg.get("tail_chars")
251
+ if isinstance(tail, int) and tail > 0:
252
+ TAIL_CHARS = tail
134
253
 
135
254
  # Remove functions injected by a previous init that are no longer present
136
255
  # (tool list changed) without touching user-defined globals.
@@ -152,12 +271,22 @@ def handle_init(msg):
152
271
  _injected_py_names.update(new_py_names)
153
272
  # Exact-name escape hatch: tools["mcpl--server--tool"]({...})
154
273
  SCRIPT_GLOBALS["tools"] = tools_dict
274
+ # The doorbell exists only in background mode — a foreground script's
275
+ # agent is already awake (mid-turn).
276
+ if BACKGROUND:
277
+ SCRIPT_GLOBALS["wake_agent"] = wake_agent
278
+ else:
279
+ SCRIPT_GLOBALS.pop("wake_agent", None)
155
280
 
156
281
 
157
282
  async def _run_script(exec_id, code):
158
283
  global _current_exec_id, _current_exec_task
159
- out = CappedIO(OUTPUT_CAP_CHARS)
160
- err = CappedIO(OUTPUT_CAP_CHARS)
284
+ if BACKGROUND:
285
+ out = LogTee(LOG_PATH, TAIL_CHARS)
286
+ err = out # interleave, terminal-style; tail is shared
287
+ else:
288
+ out = CappedIO(OUTPUT_CAP_CHARS)
289
+ err = CappedIO(OUTPUT_CAP_CHARS)
161
290
  old_out, old_err, old_in = sys.stdout, sys.stderr, sys.stdin
162
291
  sys.stdout, sys.stderr = out, err
163
292
  sys.stdin = io.StringIO("") # input() must not steal the protocol channel
@@ -177,11 +306,27 @@ async def _run_script(exec_id, code):
177
306
  sys.stdout, sys.stderr, sys.stdin = old_out, old_err, old_in
178
307
  _current_exec_id = None
179
308
  _current_exec_task = None
180
- # Fail any tool futures still pending: their exec is gone.
181
309
  for fut in list(_pending_tool_futures.values()):
182
310
  if not fut.done():
183
311
  fut.cancel()
184
312
  _pending_tool_futures.clear()
313
+ for fut in list(_pending_wake_futures.values()):
314
+ if not fut.done():
315
+ fut.cancel()
316
+ _pending_wake_futures.clear()
317
+
318
+ if BACKGROUND:
319
+ out.flush()
320
+ out.close()
321
+ send({
322
+ "op": "exec_result",
323
+ "id": exec_id,
324
+ "stdout": "",
325
+ "stderr": "",
326
+ "tail": out.tail(),
327
+ "return_code": return_code,
328
+ })
329
+ return
185
330
 
186
331
  stdout_text = out.getvalue()
187
332
  if out.truncated:
@@ -245,6 +390,10 @@ async def main():
245
390
  fut = _pending_tool_futures.get(msg.get("id"))
246
391
  if fut is not None and not fut.done():
247
392
  fut.set_result(str(msg.get("result", "")))
393
+ elif op == "wake_ack":
394
+ fut = _pending_wake_futures.get(msg.get("id"))
395
+ if fut is not None and not fut.done():
396
+ fut.set_result(msg.get("error") or None)
248
397
  elif op == "cancel":
249
398
  if _current_exec_task is not None and not _current_exec_task.done():
250
399
  _current_exec_task.cancel()
@@ -39,7 +39,19 @@ export function buildCodeExecutionToolDefinition(opts?: {
39
39
  'Use this when fanning out across many items, looping over tool calls, or when tool ' +
40
40
  'results are large and you only need a slice or summary. Call tools directly (not via ' +
41
41
  'code) when a single call answers the question or when you need to reason about each ' +
42
- 'result before deciding the next step.',
42
+ 'result before deciding the next step. ' +
43
+ 'BACKGROUND MODE: pass background=true to run the script as a detached watcher that ' +
44
+ 'outlives this turn — the tool returns immediately with a script_id and you can end ' +
45
+ 'your turn (e.g. sleep). Inside a background script, await wake_agent(payload) wakes ' +
46
+ 'you: the payload plus provenance (script id, the line number in your script, elapsed ' +
47
+ 'time) is delivered into your context and starts a turn for you. A script that ends ' +
48
+ 'without calling wake_agent wakes nobody — that silence is the point (poll cheaply, ' +
49
+ 'wake only on signal). If your background script CRASHES you are woken with the error. ' +
50
+ 'Its print() output streams to a workspace log file you can read any time. Wakes are ' +
51
+ 'rate-limited (early wakes are delayed, not dropped) and capped per script. ' +
52
+ 'CAUTION: background scripts die silently if the host process restarts — for a wake ' +
53
+ 'you absolutely must not miss, also arm a wake rule as backup. ' +
54
+ 'Manage your scripts with {"action": "list"} and {"action": "cancel", "script_id": "..."}.',
43
55
  inputSchema: {
44
56
  type: 'object' as const,
45
57
  properties: {
@@ -47,8 +59,21 @@ export function buildCodeExecutionToolDefinition(opts?: {
47
59
  type: 'string',
48
60
  description: 'Python code to execute. Top-level await is allowed.',
49
61
  },
62
+ background: {
63
+ type: 'boolean',
64
+ description: 'Run detached as a background watcher with wake_agent() available (default false).',
65
+ },
66
+ action: {
67
+ type: 'string',
68
+ enum: ['run', 'list', 'cancel'],
69
+ description: 'run (default) executes `code`; list shows your background scripts; cancel stops one.',
70
+ },
71
+ script_id: {
72
+ type: 'string',
73
+ description: 'Background script id (for action: cancel).',
74
+ },
50
75
  },
51
- required: ['code'],
76
+ required: [],
52
77
  },
53
78
  };
54
79
  }