@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.
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +28 -1
- 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 +23 -1
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +40 -4
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/runtime-py.d.ts +15 -2
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
- package/dist/src/code-execution/runtime-py.js +156 -7
- package/dist/src/code-execution/runtime-py.js.map +1 -1
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +27 -2
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/framework.d.ts +92 -0
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +932 -60
- package/dist/src/framework.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +28 -2
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +53 -7
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/transport.d.ts +1 -1
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +25 -7
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +10 -0
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/module-registry.d.ts +12 -0
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +26 -0
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +20 -0
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +48 -15
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/sync.d.ts +4 -0
- package/dist/src/modules/workspace/sync.d.ts.map +1 -1
- package/dist/src/modules/workspace/sync.js +1 -1
- package/dist/src/modules/workspace/sync.js.map +1 -1
- package/dist/src/types/agent.d.ts +12 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +10 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +22 -0
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/test/agent-runtime-settings.test.js +37 -0
- package/dist/test/agent-runtime-settings.test.js.map +1 -1
- package/dist/test/assistant-persist-refusal.test.d.ts +2 -0
- package/dist/test/assistant-persist-refusal.test.d.ts.map +1 -0
- package/dist/test/assistant-persist-refusal.test.js +63 -0
- package/dist/test/assistant-persist-refusal.test.js.map +1 -0
- package/dist/test/busy-agent-scheduler.test.js +1 -0
- package/dist/test/busy-agent-scheduler.test.js.map +1 -1
- package/dist/test/code-execution.test.js +327 -1
- package/dist/test/code-execution.test.js.map +1 -1
- package/dist/test/deferred-wake-integrity.test.d.ts +29 -0
- package/dist/test/deferred-wake-integrity.test.d.ts.map +1 -0
- package/dist/test/deferred-wake-integrity.test.js +320 -0
- package/dist/test/deferred-wake-integrity.test.js.map +1 -0
- package/dist/test/machine-close-provenance.test.d.ts +2 -0
- package/dist/test/machine-close-provenance.test.d.ts.map +1 -0
- package/dist/test/machine-close-provenance.test.js +110 -0
- package/dist/test/machine-close-provenance.test.js.map +1 -0
- package/dist/test/mcpl-access-provider.test.d.ts +2 -0
- package/dist/test/mcpl-access-provider.test.d.ts.map +1 -0
- package/dist/test/mcpl-access-provider.test.js +20 -0
- package/dist/test/mcpl-access-provider.test.js.map +1 -0
- package/dist/test/module-context-integration.test.d.ts +18 -0
- package/dist/test/module-context-integration.test.d.ts.map +1 -0
- package/dist/test/module-context-integration.test.js +181 -0
- package/dist/test/module-context-integration.test.js.map +1 -0
- package/dist/test/present-while-acting.test.js +72 -0
- package/dist/test/present-while-acting.test.js.map +1 -1
- package/dist/test/utils-surface.test.d.ts +2 -0
- package/dist/test/utils-surface.test.d.ts.map +1 -0
- package/dist/test/utils-surface.test.js +145 -0
- package/dist/test/utils-surface.test.js.map +1 -0
- package/dist/test/workspace-read-image.test.js +71 -0
- package/dist/test/workspace-read-image.test.js.map +1 -1
- package/dist/test/workspace-restore-order.test.d.ts +16 -0
- package/dist/test/workspace-restore-order.test.d.ts.map +1 -0
- package/dist/test/workspace-restore-order.test.js +140 -0
- package/dist/test/workspace-restore-order.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/agent.ts +31 -1
- package/src/code-execution/index.ts +1 -0
- package/src/code-execution/py-runner.ts +56 -4
- package/src/code-execution/runtime-py.ts +156 -7
- package/src/code-execution/tool-definition.ts +27 -2
- package/src/framework.ts +1055 -63
- package/src/mcpl/channel-registry.ts +98 -4
- package/src/mcpl/transport.ts +26 -7
- package/src/mcpl/types.ts +11 -0
- package/src/module-registry.ts +34 -0
- package/src/modules/workspace/index.ts +49 -15
- package/src/modules/workspace/sync.ts +1 -1
- package/src/types/agent.ts +12 -0
- package/src/types/framework.ts +10 -0
- package/src/types/module.ts +24 -0
package/package.json
CHANGED
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(
|
|
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
|
-
},
|
|
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.
|
|
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:"
|
|
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
|
-
|
|
160
|
-
|
|
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: [
|
|
76
|
+
required: [],
|
|
52
77
|
},
|
|
53
78
|
};
|
|
54
79
|
}
|