@animalabs/agent-framework 0.21.0 → 0.22.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 +43 -0
- package/dist/src/agent.d.ts +17 -4
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +102 -42
- package/dist/src/agent.js.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +22 -1
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +64 -9
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/runtime-py.d.ts +4 -0
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
- package/dist/src/code-execution/runtime-py.js +215 -38
- package/dist/src/code-execution/runtime-py.js.map +1 -1
- package/dist/src/framework.d.ts +250 -39
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1602 -371
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +21 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +40 -7
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +26 -3
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +28 -1
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/conversation-router.js +17 -0
- package/dist/src/mcpl/conversation-router.js.map +1 -1
- package/dist/src/mcpl/feature-set-manager.d.ts +11 -0
- package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
- package/dist/src/mcpl/feature-set-manager.js +31 -7
- package/dist/src/mcpl/feature-set-manager.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +2 -0
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +1 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/prose-stream-router.d.ts +27 -6
- package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
- package/dist/src/mcpl/prose-stream-router.js +85 -22
- package/dist/src/mcpl/prose-stream-router.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +32 -4
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +46 -13
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +5 -3
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +41 -11
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +4 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +45 -8
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/server-registry.d.ts +5 -2
- package/dist/src/mcpl/server-registry.d.ts.map +1 -1
- package/dist/src/mcpl/server-registry.js +25 -7
- package/dist/src/mcpl/server-registry.js.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.d.ts +52 -2
- package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.js +47 -1
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
- package/dist/src/mcpl/tool-pattern-check.d.ts +51 -0
- package/dist/src/mcpl/tool-pattern-check.d.ts.map +1 -0
- package/dist/src/mcpl/tool-pattern-check.js +79 -0
- package/dist/src/mcpl/tool-pattern-check.js.map +1 -0
- package/dist/src/mcpl/types.d.ts +15 -5
- 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 +39 -0
- package/dist/src/mcpl/visible-content.d.ts.map +1 -0
- package/dist/src/mcpl/visible-content.js +54 -0
- package/dist/src/mcpl/visible-content.js.map +1 -0
- package/dist/src/module-registry.d.ts +81 -3
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +257 -16
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/workspace/branch-intent.d.ts +72 -0
- package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -0
- package/dist/src/modules/workspace/branch-intent.js +83 -0
- package/dist/src/modules/workspace/branch-intent.js.map +1 -0
- package/dist/src/modules/workspace/disk-agreement.d.ts +167 -0
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -0
- package/dist/src/modules/workspace/disk-agreement.js +259 -0
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -0
- package/dist/src/modules/workspace/effects.d.ts +84 -0
- package/dist/src/modules/workspace/effects.d.ts.map +1 -0
- package/dist/src/modules/workspace/effects.js +368 -0
- package/dist/src/modules/workspace/effects.js.map +1 -0
- package/dist/src/modules/workspace/index.d.ts +155 -17
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +1059 -424
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +278 -0
- package/dist/src/modules/workspace/observe.d.ts.map +1 -0
- package/dist/src/modules/workspace/observe.js +805 -0
- package/dist/src/modules/workspace/observe.js.map +1 -0
- package/dist/src/modules/workspace/reconcile.d.ts +224 -0
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -0
- package/dist/src/modules/workspace/reconcile.js +901 -0
- package/dist/src/modules/workspace/reconcile.js.map +1 -0
- package/dist/src/modules/workspace/sync.d.ts +6 -59
- package/dist/src/modules/workspace/sync.d.ts.map +1 -1
- package/dist/src/modules/workspace/sync.js +6 -257
- package/dist/src/modules/workspace/sync.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +77 -10
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/record-journal.d.ts +127 -0
- package/dist/src/record-journal.d.ts.map +1 -0
- package/dist/src/record-journal.js +202 -0
- package/dist/src/record-journal.js.map +1 -0
- package/dist/src/shutdown.d.ts +13 -0
- package/dist/src/shutdown.d.ts.map +1 -0
- package/dist/src/shutdown.js +46 -0
- package/dist/src/shutdown.js.map +1 -0
- package/dist/src/silent-heartbeat.d.ts +74 -0
- package/dist/src/silent-heartbeat.d.ts.map +1 -0
- package/dist/src/silent-heartbeat.js +77 -0
- package/dist/src/silent-heartbeat.js.map +1 -0
- package/dist/src/tool-presentation.d.ts +50 -0
- package/dist/src/tool-presentation.d.ts.map +1 -0
- package/dist/src/tool-presentation.js +235 -0
- package/dist/src/tool-presentation.js.map +1 -0
- package/dist/src/tool-result-guard.d.ts +125 -6
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +310 -35
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +13 -0
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +21 -1
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/types/agent.d.ts +18 -2
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +4 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +12 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +14 -0
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +73 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/batched-wake-locus.test.d.ts +2 -0
- package/dist/test/batched-wake-locus.test.d.ts.map +1 -0
- package/dist/test/batched-wake-locus.test.js +166 -0
- package/dist/test/batched-wake-locus.test.js.map +1 -0
- package/dist/test/code-execution.test.js +386 -2
- package/dist/test/code-execution.test.js.map +1 -1
- package/dist/test/component-defaults.test.d.ts +2 -0
- package/dist/test/component-defaults.test.d.ts.map +1 -0
- package/dist/test/component-defaults.test.js +54 -0
- package/dist/test/component-defaults.test.js.map +1 -0
- package/dist/test/conversation-routing.test.js +57 -1
- package/dist/test/conversation-routing.test.js.map +1 -1
- package/dist/test/ephemeral-settle.test.js +3 -0
- package/dist/test/ephemeral-settle.test.js.map +1 -1
- package/dist/test/event-gate.test.js +118 -0
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/explicit-prose-routing.test.js +977 -0
- package/dist/test/explicit-prose-routing.test.js.map +1 -1
- package/dist/test/helpers/pytc-stop-rows.d.ts +11 -0
- package/dist/test/helpers/pytc-stop-rows.d.ts.map +1 -0
- package/dist/test/helpers/pytc-stop-rows.js +143 -0
- package/dist/test/helpers/pytc-stop-rows.js.map +1 -0
- package/dist/test/mcpl-coalescing.test.js +19 -6
- package/dist/test/mcpl-coalescing.test.js.map +1 -1
- package/dist/test/mcpl-config-diagnostics.test.d.ts +2 -0
- package/dist/test/mcpl-config-diagnostics.test.d.ts.map +1 -0
- package/dist/test/mcpl-config-diagnostics.test.js +449 -0
- package/dist/test/mcpl-config-diagnostics.test.js.map +1 -0
- package/dist/test/mcpl-empty-content.test.d.ts +2 -0
- package/dist/test/mcpl-empty-content.test.d.ts.map +1 -0
- package/dist/test/mcpl-empty-content.test.js +368 -0
- package/dist/test/mcpl-empty-content.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js +106 -1
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -1
- package/dist/test/mcpl-tool-lifecycle-script.test.d.ts +2 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.js +288 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.js.map +1 -0
- package/dist/test/prose-stream-router.test.js +41 -0
- package/dist/test/prose-stream-router.test.js.map +1 -1
- package/dist/test/record-journal.test.d.ts +6 -0
- package/dist/test/record-journal.test.d.ts.map +1 -0
- package/dist/test/record-journal.test.js +269 -0
- package/dist/test/record-journal.test.js.map +1 -0
- package/dist/test/save-recent-image.test.js +102 -0
- package/dist/test/save-recent-image.test.js.map +1 -1
- package/dist/test/shutdown-errors.test.d.ts +2 -0
- package/dist/test/shutdown-errors.test.d.ts.map +1 -0
- package/dist/test/shutdown-errors.test.js +662 -0
- package/dist/test/shutdown-errors.test.js.map +1 -0
- package/dist/test/silent-heartbeat-rows.test.d.ts +2 -0
- package/dist/test/silent-heartbeat-rows.test.d.ts.map +1 -0
- package/dist/test/silent-heartbeat-rows.test.js +451 -0
- package/dist/test/silent-heartbeat-rows.test.js.map +1 -0
- package/dist/test/silent-heartbeat.test.js +49 -38
- package/dist/test/silent-heartbeat.test.js.map +1 -1
- package/dist/test/stop-with-work-in-flight.test.d.ts +23 -0
- package/dist/test/stop-with-work-in-flight.test.d.ts.map +1 -0
- package/dist/test/stop-with-work-in-flight.test.js +672 -0
- package/dist/test/stop-with-work-in-flight.test.js.map +1 -0
- package/dist/test/tool-batch-hook.test.d.ts +8 -0
- package/dist/test/tool-batch-hook.test.d.ts.map +1 -0
- package/dist/test/tool-batch-hook.test.js +275 -0
- package/dist/test/tool-batch-hook.test.js.map +1 -0
- package/dist/test/tool-presentation.test.d.ts +2 -0
- package/dist/test/tool-presentation.test.d.ts.map +1 -0
- package/dist/test/tool-presentation.test.js +268 -0
- package/dist/test/tool-presentation.test.js.map +1 -0
- package/dist/test/tool-result-guard.test.js +2330 -18
- package/dist/test/tool-result-guard.test.js.map +1 -1
- package/dist/test/tool-result-history.test.js +2 -1
- package/dist/test/tool-result-history.test.js.map +1 -1
- package/dist/test/turn-trigger-provenance.test.js +35 -0
- package/dist/test/turn-trigger-provenance.test.js.map +1 -1
- package/dist/test/unconvertible-tool-result.test.d.ts +2 -0
- package/dist/test/unconvertible-tool-result.test.d.ts.map +1 -0
- package/dist/test/unconvertible-tool-result.test.js +358 -0
- package/dist/test/unconvertible-tool-result.test.js.map +1 -0
- package/dist/test/workspace-materialize-freshness.test.d.ts +16 -0
- package/dist/test/workspace-materialize-freshness.test.d.ts.map +1 -0
- package/dist/test/workspace-materialize-freshness.test.js +252 -0
- package/dist/test/workspace-materialize-freshness.test.js.map +1 -0
- package/dist/test/workspace-three-way.test.d.ts +12 -0
- package/dist/test/workspace-three-way.test.d.ts.map +1 -0
- package/dist/test/workspace-three-way.test.js +3079 -0
- package/dist/test/workspace-three-way.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/agent.ts +120 -44
- package/src/code-execution/py-runner.ts +66 -9
- package/src/code-execution/runtime-py.ts +215 -38
- package/src/framework.ts +1661 -363
- package/src/gate/event-gate.ts +59 -7
- package/src/index.ts +11 -0
- package/src/mcpl/channel-registry.ts +24 -3
- package/src/mcpl/conversation-router.ts +34 -1
- package/src/mcpl/feature-set-manager.ts +36 -11
- package/src/mcpl/index.ts +2 -0
- package/src/mcpl/prose-stream-router.ts +79 -23
- package/src/mcpl/push-coalescer.ts +62 -13
- package/src/mcpl/push-handler.ts +41 -12
- package/src/mcpl/server-connection.ts +41 -10
- package/src/mcpl/server-registry.ts +24 -7
- package/src/mcpl/tool-lifecycle.ts +77 -2
- package/src/mcpl/tool-pattern-check.ts +106 -0
- package/src/mcpl/types.ts +15 -5
- package/src/mcpl/visible-content.ts +57 -0
- package/src/module-registry.ts +252 -16
- package/src/modules/workspace/branch-intent.ts +123 -0
- package/src/modules/workspace/disk-agreement.ts +325 -0
- package/src/modules/workspace/effects.ts +362 -0
- package/src/modules/workspace/index.ts +1058 -446
- package/src/modules/workspace/observe.ts +839 -0
- package/src/modules/workspace/reconcile.ts +1136 -0
- package/src/modules/workspace/sync.ts +6 -336
- package/src/modules/workspace/types.ts +75 -10
- package/src/record-journal.ts +246 -0
- package/src/shutdown.ts +49 -0
- package/src/silent-heartbeat.ts +141 -0
- package/src/tool-presentation.ts +188 -0
- package/src/tool-result-guard.ts +335 -33
- package/src/tool-result-history.ts +25 -1
- package/src/types/agent.ts +18 -2
- package/src/types/events.ts +4 -0
- package/src/types/framework.ts +12 -0
- package/src/types/module.ts +15 -0
- package/src/types/trace.ts +80 -0
|
@@ -9,11 +9,13 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { describe, it } from 'node:test';
|
|
11
11
|
import assert from 'node:assert';
|
|
12
|
-
import {
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
14
|
import { tmpdir } from 'node:os';
|
|
14
15
|
import { join } from 'node:path';
|
|
15
|
-
import { AgentFramework, PyRunner, buildInjectedTools } from '../src/index.js';
|
|
16
|
+
import { AgentFramework, PYTHON_RUNTIME_SOURCE, PyRunner, buildInjectedTools } from '../src/index.js';
|
|
16
17
|
import { createMockResponse, MockMembrane } from './helpers/mock-membrane.js';
|
|
18
|
+
import { PYTC_STOP_ROWS_DRIVER } from './helpers/pytc-stop-rows.js';
|
|
17
19
|
const ECHO_TOOLS = [
|
|
18
20
|
{ pyName: 'test__echo', toolName: 'test--echo' },
|
|
19
21
|
];
|
|
@@ -232,6 +234,29 @@ describe('PyRunner (real python3)', () => {
|
|
|
232
234
|
runner.dispose();
|
|
233
235
|
}
|
|
234
236
|
});
|
|
237
|
+
it('rejects a second exec issued before the first has started (same tick)', async () => {
|
|
238
|
+
// exec() awaits the interpreter before it records the running script, so
|
|
239
|
+
// a check made only after that await let two execs in at once.
|
|
240
|
+
const runner = new PyRunner({ onToolCall: async () => '' });
|
|
241
|
+
try {
|
|
242
|
+
for (const warm of [false, true]) {
|
|
243
|
+
const first = runner.exec('import asyncio\nawait asyncio.sleep(0.2)\nprint("first ran")', []);
|
|
244
|
+
const second = runner.exec('print("second ran")', []);
|
|
245
|
+
const [a, b] = await Promise.race([
|
|
246
|
+
Promise.all([first, second]),
|
|
247
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`warm=${warm}: an exec never settled`)), 10_000)),
|
|
248
|
+
]);
|
|
249
|
+
assert.strictEqual(b.returnCode, 1, `warm=${warm}`);
|
|
250
|
+
assert.match(b.stderr, /already running/);
|
|
251
|
+
assert.strictEqual(a.returnCode, 0, `warm=${warm}: ${a.stderr}`);
|
|
252
|
+
assert.match(a.stdout, /first ran/);
|
|
253
|
+
assert.ok(!a.stdout.includes('second ran') && !b.stdout.includes('second ran'));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
runner.dispose();
|
|
258
|
+
}
|
|
259
|
+
});
|
|
235
260
|
it('fails gracefully when the python binary is missing', async () => {
|
|
236
261
|
const runner = new PyRunner({
|
|
237
262
|
pythonPath: '/definitely/not/a/python',
|
|
@@ -247,6 +272,59 @@ describe('PyRunner (real python3)', () => {
|
|
|
247
272
|
}
|
|
248
273
|
});
|
|
249
274
|
});
|
|
275
|
+
// Inner-call ids restart in every interpreter: a result that outlives its
|
|
276
|
+
// interpreter must not resolve a call of the one that replaced it.
|
|
277
|
+
describe('code_execution inner-call results after the interpreter is replaced (real python3)', () => {
|
|
278
|
+
const until = async (cond, what) => {
|
|
279
|
+
const deadline = Date.now() + 10_000;
|
|
280
|
+
while (!cond()) {
|
|
281
|
+
if (Date.now() > deadline)
|
|
282
|
+
throw new Error(`timed out waiting for ${what}`);
|
|
283
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
const stops = {
|
|
287
|
+
aborted: { code: 'print(await test__echo({}))', stop: (runner) => runner.abort('turn abandoned') },
|
|
288
|
+
crashed: {
|
|
289
|
+
code: 'import asyncio, os\nasyncio.get_running_loop().call_later(0.2, os._exit, 1)\nprint(await test__echo({}))',
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
for (const [how, { code, stop }] of Object.entries(stops)) {
|
|
293
|
+
it(`a late result from a script whose interpreter ${how} does not reach the next script`, async () => {
|
|
294
|
+
const calls = [];
|
|
295
|
+
const runner = new PyRunner({
|
|
296
|
+
onToolCall: () => new Promise((resolve) => calls.push(resolve)),
|
|
297
|
+
});
|
|
298
|
+
const logged = [];
|
|
299
|
+
const consoleError = console.error;
|
|
300
|
+
console.error = (...args) => {
|
|
301
|
+
logged.push(args.map(String).join(' '));
|
|
302
|
+
consoleError(...args);
|
|
303
|
+
};
|
|
304
|
+
try {
|
|
305
|
+
const first = runner.exec(code, ECHO_TOOLS);
|
|
306
|
+
await until(() => calls.length === 1, 'the first script to call its tool');
|
|
307
|
+
stop?.(runner);
|
|
308
|
+
const stopped = await first;
|
|
309
|
+
assert.strictEqual(stopped.aborted, true, stopped.stderr);
|
|
310
|
+
const second = runner.exec('print(await test__echo({}))', ECHO_TOOLS);
|
|
311
|
+
await until(() => calls.length === 2, 'the second script to call its tool');
|
|
312
|
+
// Both calls are the first of their interpreter.
|
|
313
|
+
calls[0]('stale result of the stopped script');
|
|
314
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
315
|
+
calls[1]('result of this script');
|
|
316
|
+
const result = await second;
|
|
317
|
+
assert.strictEqual(result.returnCode, 0, result.stderr);
|
|
318
|
+
assert.strictEqual(result.stdout, 'result of this script\n');
|
|
319
|
+
assert.ok(logged.some((l) => /dropped late result of test--echo call t1: the interpreter that asked for it is gone/.test(l)), 'the dropped result is logged');
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
console.error = consoleError;
|
|
323
|
+
runner.dispose();
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
});
|
|
250
328
|
// ---------------------------------------------------------------------------
|
|
251
329
|
// Framework integration
|
|
252
330
|
// ---------------------------------------------------------------------------
|
|
@@ -392,6 +470,50 @@ describe('framework code_execution integration (real python3)', () => {
|
|
|
392
470
|
rmSync(tempDir, { recursive: true, force: true });
|
|
393
471
|
}
|
|
394
472
|
});
|
|
473
|
+
it('two code_execution calls in one response: one runs, the other is refused, the turn completes', async () => {
|
|
474
|
+
const { tempDir, storePath } = tempStorePath('pytc-two-');
|
|
475
|
+
const membrane = new MockMembrane();
|
|
476
|
+
const module = new ScriptToolModule();
|
|
477
|
+
const code = (label) => [
|
|
478
|
+
'import asyncio, json',
|
|
479
|
+
`r = json.loads(await test__echo({"message": "${label}"}))`,
|
|
480
|
+
'await asyncio.sleep(0.2)',
|
|
481
|
+
`print("ran:", r["echoed"]["message"])`,
|
|
482
|
+
].join('\n');
|
|
483
|
+
membrane.pushResponse(createMockResponse([
|
|
484
|
+
{ type: 'tool_use', id: 'call_two_a', name: 'code_execution', input: { code: code('a') } },
|
|
485
|
+
{ type: 'tool_use', id: 'call_two_b', name: 'code_execution', input: { code: code('b') } },
|
|
486
|
+
], 'tool_use'));
|
|
487
|
+
membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Both answered.' }]));
|
|
488
|
+
const framework = await createFrameworkWithCodeExecution(storePath, membrane, module);
|
|
489
|
+
try {
|
|
490
|
+
const created = await framework.createEphemeralAgent({
|
|
491
|
+
name: 'pair',
|
|
492
|
+
model: 'test-model',
|
|
493
|
+
systemPrompt: 'Run both.',
|
|
494
|
+
allowedTools: 'all',
|
|
495
|
+
});
|
|
496
|
+
created.contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]);
|
|
497
|
+
const promise = framework.runEphemeralToCompletion(created.agent, created.contextManager);
|
|
498
|
+
framework.start();
|
|
499
|
+
const result = await Promise.race([
|
|
500
|
+
promise,
|
|
501
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('the turn never completed')), 15_000)),
|
|
502
|
+
]);
|
|
503
|
+
assert.strictEqual(result.speech, 'Both answered.');
|
|
504
|
+
const [results] = membrane.lastStream.receivedToolResults;
|
|
505
|
+
const byId = new Map(results.map((r) => [r.toolUseId, r.content]));
|
|
506
|
+
assert.match(byId.get('call_two_a') ?? '', /ran: a/, 'the first script ran');
|
|
507
|
+
assert.match(byId.get('call_two_b') ?? '', /already running/, 'the second was refused');
|
|
508
|
+
assert.ok(!(byId.get('call_two_b') ?? '').includes('ran: b'));
|
|
509
|
+
const echoed = module.calls.filter((c) => c.name.endsWith('--echo') || c.name === 'echo').map((c) => c.input.message);
|
|
510
|
+
assert.deepStrictEqual(echoed, ['a']);
|
|
511
|
+
}
|
|
512
|
+
finally {
|
|
513
|
+
await framework.stop();
|
|
514
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
515
|
+
}
|
|
516
|
+
});
|
|
395
517
|
it('defers endTurn from inner calls to the code_execution result', async () => {
|
|
396
518
|
const { tempDir, storePath } = tempStorePath('pytc-endturn-');
|
|
397
519
|
const membrane = new MockMembrane();
|
|
@@ -428,6 +550,42 @@ describe('framework code_execution integration (real python3)', () => {
|
|
|
428
550
|
rmSync(tempDir, { recursive: true, force: true });
|
|
429
551
|
}
|
|
430
552
|
});
|
|
553
|
+
it("a stopped script's late end-turn request does not end the next script's turn", async () => {
|
|
554
|
+
let releaseFinish = () => { };
|
|
555
|
+
const finishReleased = new Promise((resolve) => { releaseFinish = resolve; });
|
|
556
|
+
let finishStarted = false;
|
|
557
|
+
class SlowFinishModule extends ScriptToolModule {
|
|
558
|
+
async handleToolCall(call) {
|
|
559
|
+
if (call.name.endsWith('finish')) {
|
|
560
|
+
finishStarted = true;
|
|
561
|
+
await finishReleased;
|
|
562
|
+
}
|
|
563
|
+
return super.handleToolCall(call);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const { tempDir, storePath } = tempStorePath('pytc-stale-endturn-');
|
|
567
|
+
const framework = await createFrameworkWithCodeExecution(storePath, new MockMembrane(), new SlowFinishModule());
|
|
568
|
+
const run = (input) => framework.executeToolCall({ id: `ce-${Math.random()}`, name: 'code_execution', input });
|
|
569
|
+
framework.start();
|
|
570
|
+
try {
|
|
571
|
+
// The first script is stopped at its time limit while its call that ends the turn is running.
|
|
572
|
+
const stopped = await run({ code: 'await test__finish({})', time_limit_ms: 1000 });
|
|
573
|
+
assert.ok(finishStarted);
|
|
574
|
+
assert.match(stopped.data.stderr, /reached its 1s time limit/);
|
|
575
|
+
assert.ok(!stopped.endTurn);
|
|
576
|
+
// The call finishes while the next script runs; its request belonged to the stopped script.
|
|
577
|
+
const next = run({ code: 'import asyncio\nawait asyncio.sleep(1)\nprint("next done")' });
|
|
578
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
579
|
+
releaseFinish();
|
|
580
|
+
const result = await next;
|
|
581
|
+
assert.strictEqual(result.data.stdout, 'next done\n');
|
|
582
|
+
assert.ok(!result.endTurn, "the stopped script's request ended the next script's turn");
|
|
583
|
+
}
|
|
584
|
+
finally {
|
|
585
|
+
await framework.stop();
|
|
586
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
587
|
+
}
|
|
588
|
+
});
|
|
431
589
|
});
|
|
432
590
|
// ---------------------------------------------------------------------------
|
|
433
591
|
// Background scripts (wake_agent) + spill-to-file
|
|
@@ -885,4 +1043,230 @@ describe('code_execution per-call time limit (time_limit_ms)', () => {
|
|
|
885
1043
|
});
|
|
886
1044
|
});
|
|
887
1045
|
});
|
|
1046
|
+
// The deadline's cancel op is an asyncio cancellation, which lands only when a
|
|
1047
|
+
// script awaits; the SIGINT that follows it stops blocking code (#235 F3).
|
|
1048
|
+
describe('code_execution time limit vs blocking code (real python3)', { skip: process.platform === 'win32' }, () => {
|
|
1049
|
+
const pidOf = (stdout) => /pid=(\d+)/.exec(stdout)?.[1];
|
|
1050
|
+
it('blocking code stops at the time limit, keeping its output, and the interpreter survives', async () => {
|
|
1051
|
+
const runner = new PyRunner({ onToolCall: async () => '' });
|
|
1052
|
+
try {
|
|
1053
|
+
const first = await runner.exec('import os\nprint(f"pid={os.getpid()}")', []);
|
|
1054
|
+
const pid = pidOf(first.stdout);
|
|
1055
|
+
assert.ok(pid, first.stdout);
|
|
1056
|
+
const blocking = {
|
|
1057
|
+
sleep: 'import time\nprint("before")\ntime.sleep(60)\nprint("after")',
|
|
1058
|
+
'busy loop': 'print("before")\nn = 0\nwhile True:\n n += 1\nprint("after")',
|
|
1059
|
+
'blocking read': 'import socket\nprint("before")\na, b = socket.socketpair()\na.recv(1)\nprint("after")',
|
|
1060
|
+
};
|
|
1061
|
+
for (const [shape, code] of Object.entries(blocking)) {
|
|
1062
|
+
const started = Date.now();
|
|
1063
|
+
const result = await runner.exec(code, [], undefined, { deadlineMs: 1000 });
|
|
1064
|
+
const elapsed = Date.now() - started;
|
|
1065
|
+
assert.strictEqual(result.returnCode, 1, shape);
|
|
1066
|
+
assert.strictEqual(result.aborted, undefined, `${shape}: interrupted, not killed`);
|
|
1067
|
+
assert.strictEqual(result.stdout, 'before\n', `${shape}: output before the limit is kept`);
|
|
1068
|
+
assert.match(result.stderr, /KeyboardInterrupt: script interrupted by host/, shape);
|
|
1069
|
+
assert.match(result.stderr, /script stopped: it reached its 1s time limit/, shape);
|
|
1070
|
+
assert.match(result.stderr, /File "<script>", line \d+/, `${shape}: says where the script was`);
|
|
1071
|
+
assert.doesNotMatch(result.stderr, /runtime\.py/, `${shape}: no runtime frames`);
|
|
1072
|
+
assert.ok(elapsed < 5000, `${shape}: stopped at the limit, not the kill grace (${elapsed}ms)`);
|
|
1073
|
+
}
|
|
1074
|
+
const last = await runner.exec('import os\nprint(f"pid={os.getpid()}")', []);
|
|
1075
|
+
assert.strictEqual(pidOf(last.stdout), pid, 'same interpreter: nothing was killed');
|
|
1076
|
+
}
|
|
1077
|
+
finally {
|
|
1078
|
+
runner.dispose();
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
it('variables survive an interrupted script', async () => {
|
|
1082
|
+
const runner = new PyRunner({ onToolCall: async () => '' });
|
|
1083
|
+
try {
|
|
1084
|
+
const set = await runner.exec('x = 41', []);
|
|
1085
|
+
assert.strictEqual(set.returnCode, 0, set.stderr);
|
|
1086
|
+
const stopped = await runner.exec('import time\ny = 1\ntime.sleep(60)', [], undefined, { deadlineMs: 1000 });
|
|
1087
|
+
assert.match(stopped.stderr, /reached its 1s time limit/);
|
|
1088
|
+
const after = await runner.exec('print(x + y)', []);
|
|
1089
|
+
assert.strictEqual(after.returnCode, 0, after.stderr);
|
|
1090
|
+
assert.strictEqual(after.stdout, '42\n');
|
|
1091
|
+
}
|
|
1092
|
+
finally {
|
|
1093
|
+
runner.dispose();
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
1096
|
+
it('a script waiting on an await is still stopped by the cancel', async () => {
|
|
1097
|
+
const runner = new PyRunner({
|
|
1098
|
+
onToolCall: () => new Promise((resolve) => setTimeout(() => resolve('late'), 5000)),
|
|
1099
|
+
});
|
|
1100
|
+
try {
|
|
1101
|
+
const started = Date.now();
|
|
1102
|
+
const result = await runner.exec('print("before")\nawait test__echo({})\nprint("after")', ECHO_TOOLS, undefined, { deadlineMs: 1000 });
|
|
1103
|
+
assert.strictEqual(result.returnCode, 1);
|
|
1104
|
+
assert.strictEqual(result.stdout, 'before\n');
|
|
1105
|
+
assert.match(result.stderr, /KeyboardInterrupt: script cancelled by host/);
|
|
1106
|
+
assert.doesNotMatch(result.stderr, /interrupted by host/);
|
|
1107
|
+
assert.match(result.stderr, /script stopped: it reached its 1s time limit/);
|
|
1108
|
+
assert.ok(Date.now() - started < 5000, 'stopped at the limit');
|
|
1109
|
+
}
|
|
1110
|
+
finally {
|
|
1111
|
+
runner.dispose();
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
it('SIGINT between scripts does nothing', async () => {
|
|
1115
|
+
const runner = new PyRunner({ onToolCall: async () => '' });
|
|
1116
|
+
try {
|
|
1117
|
+
const first = await runner.exec('import os\nx = 1\nprint(f"pid={os.getpid()}")', []);
|
|
1118
|
+
const pid = Number(pidOf(first.stdout));
|
|
1119
|
+
assert.ok(pid > 0, first.stdout);
|
|
1120
|
+
process.kill(pid, 'SIGINT');
|
|
1121
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1122
|
+
const result = await runner.exec('print(x)\nprint(f"pid={os.getpid()}")', []);
|
|
1123
|
+
assert.strictEqual(result.returnCode, 0, result.stderr);
|
|
1124
|
+
assert.strictEqual(result.stdout, `1\npid=${pid}\n`);
|
|
1125
|
+
}
|
|
1126
|
+
finally {
|
|
1127
|
+
runner.dispose();
|
|
1128
|
+
}
|
|
1129
|
+
});
|
|
1130
|
+
it('SIGINT while a script waits cancels it before it can resume into blocking code', async () => {
|
|
1131
|
+
let pid = 0;
|
|
1132
|
+
const runner = new PyRunner({
|
|
1133
|
+
onToolCall: async () => {
|
|
1134
|
+
// The script is suspended on this call. The signal arrives with no cancel op behind it;
|
|
1135
|
+
// the result does, and the script would then block.
|
|
1136
|
+
process.kill(pid, 'SIGINT');
|
|
1137
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1138
|
+
return 'tool ok';
|
|
1139
|
+
},
|
|
1140
|
+
});
|
|
1141
|
+
try {
|
|
1142
|
+
const first = await runner.exec('import os\nprint(f"pid={os.getpid()}")', []);
|
|
1143
|
+
pid = Number(pidOf(first.stdout));
|
|
1144
|
+
assert.ok(pid > 0, first.stdout);
|
|
1145
|
+
const started = Date.now();
|
|
1146
|
+
const result = await runner.exec('import time\nprint(await test__echo({}))\ntime.sleep(30)', ECHO_TOOLS);
|
|
1147
|
+
assert.strictEqual(result.returnCode, 1);
|
|
1148
|
+
assert.strictEqual(result.stdout, '');
|
|
1149
|
+
// Cancelled at its await (or, if the signal beat the script to it, interrupted there).
|
|
1150
|
+
assert.match(result.stderr, /KeyboardInterrupt: script (cancelled|interrupted) by host/);
|
|
1151
|
+
assert.ok(Date.now() - started < 5000, 'stopped at once, not after the blocking call');
|
|
1152
|
+
const after = await runner.exec('print(f"pid={os.getpid()}")', []);
|
|
1153
|
+
assert.strictEqual(after.stdout, `pid=${pid}\n`, 'same interpreter');
|
|
1154
|
+
}
|
|
1155
|
+
finally {
|
|
1156
|
+
runner.dispose();
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
1159
|
+
it('a script that resumes from an await into blocking code at its limit is stopped', async () => {
|
|
1160
|
+
const runner = new PyRunner({ onToolCall: async () => '' });
|
|
1161
|
+
try {
|
|
1162
|
+
for (let i = 0; i < 3; i++) {
|
|
1163
|
+
const started = Date.now();
|
|
1164
|
+
const result = await runner.exec('import asyncio, time\nawait asyncio.sleep(1)\ntime.sleep(5)\nprint("after")', [], undefined, { deadlineMs: 1000 });
|
|
1165
|
+
assert.strictEqual(result.returnCode, 1, result.stderr);
|
|
1166
|
+
assert.strictEqual(result.stdout, '');
|
|
1167
|
+
assert.match(result.stderr, /script stopped: it reached its 1s time limit/);
|
|
1168
|
+
assert.ok(Date.now() - started < 3000, `stopped at the limit (${Date.now() - started}ms)`);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
finally {
|
|
1172
|
+
runner.dispose();
|
|
1173
|
+
}
|
|
1174
|
+
});
|
|
1175
|
+
it("the runtime's stop table: each place a signal or cancel op can land stops the script once", () => {
|
|
1176
|
+
const dir = mkdtempSync(join(tmpdir(), 'pytc-stop-rows-'));
|
|
1177
|
+
try {
|
|
1178
|
+
writeFileSync(join(dir, 'runtime.py'), PYTHON_RUNTIME_SOURCE);
|
|
1179
|
+
writeFileSync(join(dir, 'driver.py'), PYTC_STOP_ROWS_DRIVER);
|
|
1180
|
+
const rows = JSON.parse(execFileSync('python3', [join(dir, 'driver.py'), join(dir, 'runtime.py')], { encoding: 'utf8', timeout: 60_000 }));
|
|
1181
|
+
const printed = (row, line) => row.tail.split('\n').includes(line);
|
|
1182
|
+
const interrupted = /KeyboardInterrupt: script interrupted by host/;
|
|
1183
|
+
const cancelled = /KeyboardInterrupt: script cancelled by host/;
|
|
1184
|
+
const stopped = (name, how, has = [], hasNot = []) => {
|
|
1185
|
+
const row = rows[name];
|
|
1186
|
+
assert.ok(row, name);
|
|
1187
|
+
assert.strictEqual(row.rc, 1, `${name}: ${row.tail}`);
|
|
1188
|
+
assert.match(row.tail, how, name);
|
|
1189
|
+
for (const w of has)
|
|
1190
|
+
assert.ok(printed(row, w), `${name}: printed ${w}`);
|
|
1191
|
+
for (const w of hasNot)
|
|
1192
|
+
assert.ok(!printed(row, w), `${name}: did not print ${w}`);
|
|
1193
|
+
assert.ok(row.secs < 2, `${name}: stopped at once (${row.secs}s)`);
|
|
1194
|
+
};
|
|
1195
|
+
const finished = (name, has) => {
|
|
1196
|
+
const row = rows[name];
|
|
1197
|
+
assert.ok(row, name);
|
|
1198
|
+
assert.strictEqual(row.rc, 0, `${name}: ${row.tail}`);
|
|
1199
|
+
for (const w of has)
|
|
1200
|
+
assert.ok(printed(row, w), `${name}: printed ${w}`);
|
|
1201
|
+
assert.doesNotMatch(row.tail, /by host/, `${name}: never stopped (again) by us`);
|
|
1202
|
+
};
|
|
1203
|
+
stopped('1 own code', interrupted, ['a'], ['b']);
|
|
1204
|
+
stopped('2 waiting on an await', cancelled, [], ['b']);
|
|
1205
|
+
// Cancelling inside the handler here raised InvalidStateError out of main().
|
|
1206
|
+
stopped('3 mid-dispatch, reply behind', cancelled, [], ['woke']);
|
|
1207
|
+
stopped('4 protocol write', cancelled, [], ['b']);
|
|
1208
|
+
assert.ok(rows['4 protocol write'].ops.includes('tool_call'), 'the protocol line stayed whole');
|
|
1209
|
+
stopped('5 reply ahead, then await', cancelled, ['woke'], ['b']);
|
|
1210
|
+
// Interrupted in its blocking code; the queued cancellation must not cut its cleanup short.
|
|
1211
|
+
finished('6 reply ahead, then block', ['woke', 'cleaned']);
|
|
1212
|
+
finished('7 reply ahead, then finish', ['woke']);
|
|
1213
|
+
finished('7 next script', ['ok']);
|
|
1214
|
+
stopped('8 before start, signal', cancelled, [], ['ran']);
|
|
1215
|
+
// The cancel op before the script started used to leave it reporting nothing.
|
|
1216
|
+
stopped('8 before start, cancel op', cancelled, [], ['ran']);
|
|
1217
|
+
finished('9 cancel op first', ['cleaned']);
|
|
1218
|
+
finished('10 after delivery', ['cleaned']);
|
|
1219
|
+
finished('11 reporting', ['done']);
|
|
1220
|
+
finished('12 between scripts', ['ok']);
|
|
1221
|
+
if (rows['13 next script blocks']) {
|
|
1222
|
+
// Python 3.12+: an eager task factory left installed by an earlier script.
|
|
1223
|
+
finished('13 eager task factory installed', ['installed']);
|
|
1224
|
+
stopped('13 next script blocks', interrupted, ['a'], ['b']);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
finally {
|
|
1228
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1229
|
+
}
|
|
1230
|
+
});
|
|
1231
|
+
it('an eager task factory installed by an earlier script does not hide the next one from the deadline', async (t) => {
|
|
1232
|
+
const runner = new PyRunner({ onToolCall: async () => '', cancelGraceMs: 3000 });
|
|
1233
|
+
try {
|
|
1234
|
+
const install = await runner.exec('import asyncio, sys\nif sys.version_info >= (3, 12):\n asyncio.get_running_loop().set_task_factory(asyncio.eager_task_factory)\n print("installed")', []);
|
|
1235
|
+
assert.strictEqual(install.returnCode, 0, install.stderr);
|
|
1236
|
+
if (install.stdout !== 'installed\n') {
|
|
1237
|
+
t.skip('asyncio.eager_task_factory needs Python 3.12+');
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
const started = Date.now();
|
|
1241
|
+
const result = await runner.exec('import time\nprint("before")\ntime.sleep(30)', [], undefined, { deadlineMs: 1000 });
|
|
1242
|
+
assert.strictEqual(result.aborted, undefined, 'interrupted, not killed');
|
|
1243
|
+
assert.strictEqual(result.stdout, 'before\n');
|
|
1244
|
+
assert.match(result.stderr, /KeyboardInterrupt: script interrupted by host/);
|
|
1245
|
+
assert.match(result.stderr, /script stopped: it reached its 1s time limit/);
|
|
1246
|
+
assert.ok(Date.now() - started < 2500, `stopped at the limit (${Date.now() - started}ms)`);
|
|
1247
|
+
}
|
|
1248
|
+
finally {
|
|
1249
|
+
runner.dispose();
|
|
1250
|
+
}
|
|
1251
|
+
});
|
|
1252
|
+
it('a script that ignores the interrupt is killed after the grace, and its interpreter state is gone', async () => {
|
|
1253
|
+
const runner = new PyRunner({ onToolCall: async () => '', cancelGraceMs: 500 });
|
|
1254
|
+
try {
|
|
1255
|
+
assert.strictEqual((await runner.exec('x = 41', [])).returnCode, 0);
|
|
1256
|
+
const code = 'import time\ntry:\n time.sleep(60)\nexcept KeyboardInterrupt:\n time.sleep(60)';
|
|
1257
|
+
const started = Date.now();
|
|
1258
|
+
const killed = await runner.exec(code, [], undefined, { deadlineMs: 1000 });
|
|
1259
|
+
assert.strictEqual(killed.aborted, true);
|
|
1260
|
+
assert.strictEqual(killed.returnCode, 1);
|
|
1261
|
+
assert.match(killed.stderr, /script stopped: it reached its 1s time limit and did not respond, so it was killed and the interpreter restarted \(variables from earlier scripts are gone\)/);
|
|
1262
|
+
assert.ok(Date.now() - started < 5000, `killed after the grace (${Date.now() - started}ms)`);
|
|
1263
|
+
const after = await runner.exec('print(x)', []);
|
|
1264
|
+
assert.strictEqual(after.returnCode, 1);
|
|
1265
|
+
assert.match(after.stderr, /NameError: name 'x' is not defined/);
|
|
1266
|
+
}
|
|
1267
|
+
finally {
|
|
1268
|
+
runner.dispose();
|
|
1269
|
+
}
|
|
1270
|
+
});
|
|
1271
|
+
});
|
|
888
1272
|
//# sourceMappingURL=code-execution.test.js.map
|