@animalabs/agent-framework 0.7.0 → 0.7.3

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 (58) hide show
  1. package/dist/src/code-execution/index.d.ts +14 -0
  2. package/dist/src/code-execution/index.d.ts.map +1 -0
  3. package/dist/src/code-execution/index.js +13 -0
  4. package/dist/src/code-execution/index.js.map +1 -0
  5. package/dist/src/code-execution/py-runner.d.ts +96 -0
  6. package/dist/src/code-execution/py-runner.d.ts.map +1 -0
  7. package/dist/src/code-execution/py-runner.js +371 -0
  8. package/dist/src/code-execution/py-runner.js.map +1 -0
  9. package/dist/src/code-execution/runtime-py.d.ts +32 -0
  10. package/dist/src/code-execution/runtime-py.d.ts.map +1 -0
  11. package/dist/src/code-execution/runtime-py.js +265 -0
  12. package/dist/src/code-execution/runtime-py.js.map +1 -0
  13. package/dist/src/code-execution/tool-definition.d.ts +16 -0
  14. package/dist/src/code-execution/tool-definition.d.ts.map +1 -0
  15. package/dist/src/code-execution/tool-definition.js +47 -0
  16. package/dist/src/code-execution/tool-definition.js.map +1 -0
  17. package/dist/src/framework.d.ts +43 -1
  18. package/dist/src/framework.d.ts.map +1 -1
  19. package/dist/src/framework.js +274 -26
  20. package/dist/src/framework.js.map +1 -1
  21. package/dist/src/index.d.ts +2 -0
  22. package/dist/src/index.d.ts.map +1 -1
  23. package/dist/src/index.js +4 -0
  24. package/dist/src/index.js.map +1 -1
  25. package/dist/src/mcpl/channel-registry.d.ts +12 -0
  26. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  27. package/dist/src/mcpl/channel-registry.js +45 -0
  28. package/dist/src/mcpl/channel-registry.js.map +1 -1
  29. package/dist/src/mcpl/prose-grammar.d.ts +24 -0
  30. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -0
  31. package/dist/src/mcpl/prose-grammar.js +33 -0
  32. package/dist/src/mcpl/prose-grammar.js.map +1 -0
  33. package/dist/src/mcpl/prose-stream-router.d.ts +58 -0
  34. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -0
  35. package/dist/src/mcpl/prose-stream-router.js +168 -0
  36. package/dist/src/mcpl/prose-stream-router.js.map +1 -0
  37. package/dist/src/types/framework.d.ts +33 -0
  38. package/dist/src/types/framework.d.ts.map +1 -1
  39. package/dist/test/code-execution.test.d.ts +11 -0
  40. package/dist/test/code-execution.test.d.ts.map +1 -0
  41. package/dist/test/code-execution.test.js +423 -0
  42. package/dist/test/code-execution.test.js.map +1 -0
  43. package/dist/test/prose-stream-router.test.d.ts +2 -0
  44. package/dist/test/prose-stream-router.test.d.ts.map +1 -0
  45. package/dist/test/prose-stream-router.test.js +87 -0
  46. package/dist/test/prose-stream-router.test.js.map +1 -0
  47. package/dist/tsconfig.tsbuildinfo +1 -1
  48. package/package.json +1 -1
  49. package/src/code-execution/index.ts +19 -0
  50. package/src/code-execution/py-runner.ts +439 -0
  51. package/src/code-execution/runtime-py.ts +265 -0
  52. package/src/code-execution/tool-definition.ts +54 -0
  53. package/src/framework.ts +307 -28
  54. package/src/index.ts +17 -0
  55. package/src/mcpl/channel-registry.ts +53 -0
  56. package/src/mcpl/prose-grammar.ts +40 -0
  57. package/src/mcpl/prose-stream-router.ts +161 -0
  58. package/src/types/framework.ts +35 -0
@@ -11,11 +11,16 @@ import { FeatureSetManager } from './mcpl/feature-set-manager.js';
11
11
  import { ScopeManager } from './mcpl/scope-manager.js';
12
12
  import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
13
13
  import { PushHandler } from './mcpl/push-handler.js';
14
+ import { parseProsePrefix } from './mcpl/prose-grammar.js';
15
+ import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
14
16
  import { InferenceRouter } from './mcpl/inference-router.js';
15
17
  import { ChannelRegistry } from './mcpl/channel-registry.js';
16
18
  import { ConversationRouter } from './mcpl/conversation-router.js';
17
19
  import { safeSlice } from './safe-slice.js';
18
20
  import { toolResultDataToHistoryString } from './tool-result-history.js';
21
+ import { randomUUID } from 'node:crypto';
22
+ import { PyRunner, buildInjectedTools } from './code-execution/py-runner.js';
23
+ import { buildCodeExecutionToolDefinition, CODE_EXECUTION_TOOL_NAME, } from './code-execution/tool-definition.js';
19
24
  import { splitProseSegments } from './prose-segments.js';
20
25
  /** Detect a supported image media type from magic bytes (the model API
21
26
  * rejects mislabeled media types, so trust bytes over extensions).
@@ -110,29 +115,9 @@ function extractChannelIdFromToolResult(content) {
110
115
  * when picking a turn's frozen speech locus.
111
116
  */
112
117
  const isAddressedMessage = (tags, _metadata) => Array.isArray(tags) && tags.includes('chat:addressed');
113
- /**
114
- * Parse a prose segment's routing prefix (explicit prose routing —
115
- * docs/explicit-prose-routing.md). The FIRST line may be:
116
- * `>>target [!] [body…]` — target = channel spec or `private`
117
- * The ` !` continuation modifier must immediately follow the target,
118
- * whitespace-separated. Body may start on the same line or the next.
119
- */
120
- function parseProsePrefix(text) {
121
- const m = /^>>(\S+)([ \t]+!)?[ \t]*\n?/.exec(text);
122
- if (!m)
123
- return { kind: 'none', continueTurn: false, body: text };
124
- const target = m[1];
125
- const continueTurn = m[2] !== undefined;
126
- const body = text.slice(m[0].length);
127
- // `skip_reply` mirrors the tool the model already knows: text stays in
128
- // context, nothing is delivered. (Deliberately NOT called "private"/"note" —
129
- // that vocabulary sat adjacent to signed thinking in a live window and drew
130
- // a reasoning_extraction classifier hit, 2026-07-24 Fable.)
131
- if (target === 'skip_reply') {
132
- return { kind: 'private', continueTurn, body };
133
- }
134
- return { kind: 'target', target, continueTurn, body };
135
- }
118
+ // parseProsePrefix moved to ./mcpl/prose-grammar.ts — ONE grammar shared with
119
+ // the outgoing-stream router (Spec 14.3), so streamed chunks and delivered
120
+ // envelopes can never disagree on what is a prefix.
136
121
  /** One-time primer appended when an agent's proseRouting mode changes. */
137
122
  function proseModePrimer(mode) {
138
123
  if (mode === 'locus') {
@@ -442,6 +427,17 @@ export class AgentFramework {
442
427
  /** Forks whose TTL closure turn has been queued — disposed (removed from
443
428
  * agents/agentConfigs/conversationAgentHomes) when their stream ends. */
444
429
  closingConversationAgents = new Set();
430
+ // Client-side programmatic tool calling (`code_execution`). Null unless
431
+ // config.codeExecution.enabled. One PyRunner per agent (interpreter state
432
+ // is per-agent, like a per-agent container); waiters resolve script-inner
433
+ // tool calls dispatched through the normal dispatchToolCall path.
434
+ codeExecutionConfig = null;
435
+ codeExecutionRunners = new Map();
436
+ scriptToolWaiters = new Map();
437
+ /** Agents whose running script hit an endTurn-carrying inner result —
438
+ * deferred and applied to the final code_execution result instead of
439
+ * cancelling the stream mid-script (which would wedge the turn). */
440
+ scriptDeferredEndTurn = new Set();
445
441
  mcplTools = [];
446
442
  mcplToolRefreshInFlight = false;
447
443
  mcplToolRefreshPending = false;
@@ -659,6 +655,9 @@ export class AgentFramework {
659
655
  // Stored for lazy MCPL initialization (connectMcplServer on a framework
660
656
  // that started with zero configured servers).
661
657
  framework.mcplInferenceRoutingConfig = config.inferenceRouting ?? null;
658
+ // Client-side programmatic tool calling (code_execution). Config is only
659
+ // retained when enabled — everything downstream gates on the field.
660
+ framework.codeExecutionConfig = config.codeExecution?.enabled ? config.codeExecution : null;
662
661
  // Initialize MCPL subsystems if configured
663
662
  if (config.mcplServers && config.mcplServers.length > 0) {
664
663
  // Validate tool prefixes: no collisions with module names or between servers
@@ -739,6 +738,13 @@ export class AgentFramework {
739
738
  async stop() {
740
739
  this.running = false;
741
740
  this.queue.close();
741
+ // Kill running code_execution scripts before cancelling streams: a
742
+ // zombie script must not keep firing side-effectful tool calls into a
743
+ // framework that is shutting down.
744
+ for (const runner of this.codeExecutionRunners.values()) {
745
+ runner.dispose();
746
+ }
747
+ this.codeExecutionRunners.clear();
742
748
  // Cancel all active streams
743
749
  for (const agent of this.agents.values()) {
744
750
  if (agent.state.status === 'streaming' ||
@@ -1004,6 +1010,9 @@ export class AgentFramework {
1004
1010
  ...(this.getWorkspaceModule()
1005
1011
  ? [AgentFramework.SAVE_IMAGE_TOOL, AgentFramework.READ_IMAGE_TOOL]
1006
1012
  : []),
1013
+ ...(this.codeExecutionConfig
1014
+ ? [buildCodeExecutionToolDefinition(this.codeExecutionConfig)]
1015
+ : []),
1007
1016
  ];
1008
1017
  }
1009
1018
  /** The registered workspace module, if any (used by save_image). */
@@ -1274,7 +1283,7 @@ export class AgentFramework {
1274
1283
  *
1275
1284
  * Returns null when the agent's strategy has no fold plan (non-adaptive).
1276
1285
  */
1277
- previewContextSettings(agentName, budgetTokens, overrides) {
1286
+ previewContextSettings(agentName, budgetTokens, overrides, opts) {
1278
1287
  const agent = this.agents.get(agentName);
1279
1288
  if (!agent)
1280
1289
  throw new Error(`Unknown agent: ${agentName}`);
@@ -1282,8 +1291,10 @@ export class AgentFramework {
1282
1291
  if (typeof cm.previewContext !== 'function')
1283
1292
  return null;
1284
1293
  // reserveForResponse mirrors the live compile so the previewed middle is
1285
- // comparable to what the agent actually gets.
1286
- return cm.previewContext({ maxTokens: budgetTokens, reserveForResponse: agent.maxTokens }, overrides);
1294
+ // comparable to what the agent actually gets. `opts.render` additionally
1295
+ // returns the rendered entries — opt-in, since they are megabytes on a
1296
+ // large store, and older context-manager builds ignore the argument.
1297
+ return cm.previewContext({ maxTokens: budgetTokens, reserveForResponse: agent.maxTokens }, overrides, opts);
1287
1298
  }
1288
1299
  /**
1289
1300
  * Check if process logging is enabled.
@@ -2785,6 +2796,18 @@ export class AgentFramework {
2785
2796
  // Anthropic API. If we let modules or MCPL handlers run first they may add
2786
2797
  // messages between tool_use and tool_result, causing a 400 error.
2787
2798
  if (event.type === 'tool-result') {
2799
+ // Script-inner tool calls (client-side programmatic tool calling):
2800
+ // resolve the waiter and stop. These calls belong to a RUNNING
2801
+ // code_execution script, not to the agent's pending tool round — they
2802
+ // must not touch agent state (and must resolve even for ephemeral
2803
+ // agents that aren't in this.agents, or after the agent's own round
2804
+ // has already settled).
2805
+ const scriptWaiter = this.scriptToolWaiters.get(event.callId);
2806
+ if (scriptWaiter) {
2807
+ this.scriptToolWaiters.delete(event.callId);
2808
+ scriptWaiter(event.result);
2809
+ return;
2810
+ }
2788
2811
  const agent = this.agents.get(event.agentName);
2789
2812
  if (agent) {
2790
2813
  this.touchEphemeralRun(event.agentName, true);
@@ -4029,6 +4052,29 @@ export class AgentFramework {
4029
4052
  : resolveTurnLocus();
4030
4053
  if (typingChannel)
4031
4054
  this.channelRegistry.startTyping(typingChannel);
4055
+ // MCPL Spec 14.3 outgoing streaming: route text deltas to their
4056
+ // destination server AS THEY GENERATE (voice synthesis, live message
4057
+ // rendering). Pure observer surface — suppression is fail-closed (nothing
4058
+ // streams that delivery would refuse), emission is capability-gated in
4059
+ // the registry (`channels.streaming`), and delivery via deliverProse
4060
+ // remains the sole authoritative send.
4061
+ const outgoingInferenceId = `inf_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
4062
+ let outgoingIndex = 0;
4063
+ const proseStream = this.channelRegistry
4064
+ ? new ProseStreamRouter({
4065
+ mode: agent.proseRouting === 'explicit' ? 'explicit' : 'locus',
4066
+ initialTarget: typingChannel,
4067
+ resolve: (spec) => {
4068
+ const r = this.channelRegistry.resolveProseTarget(spec);
4069
+ return 'channelId' in r ? r.channelId : null;
4070
+ },
4071
+ })
4072
+ : null;
4073
+ const emitOutgoing = (deltas) => {
4074
+ for (const rd of deltas) {
4075
+ this.channelRegistry.sendOutgoingChunk(rd.channelId, agent.name, outgoingInferenceId, outgoingIndex++, rd.delta);
4076
+ }
4077
+ };
4032
4078
  const adoptInjectedRound = () => {
4033
4079
  if (!this.midTurnInputSignals.has(agent.name))
4034
4080
  return;
@@ -4054,6 +4100,9 @@ export class AgentFramework {
4054
4100
  // turn's prose is bound for, without re-deriving routing.
4055
4101
  channelId: typingChannel ?? undefined,
4056
4102
  });
4103
+ if (proseStream && event.meta.type === 'text') {
4104
+ emitOutgoing(proseStream.feed(event.content));
4105
+ }
4057
4106
  break;
4058
4107
  case 'block': {
4059
4108
  const { event: phase, index, block } = event.event;
@@ -4570,6 +4619,7 @@ export class AgentFramework {
4570
4619
  // Only reset + retry if this is still the active stream
4571
4620
  if (agent.streamId !== myStreamId)
4572
4621
  break;
4622
+ this.abortAgentScript(agent.name, 'stream error');
4573
4623
  agent.reset();
4574
4624
  const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
4575
4625
  if (action.retry) {
@@ -4616,6 +4666,7 @@ export class AgentFramework {
4616
4666
  // may have already started a new stream, bumping streamId)
4617
4667
  if (agent.streamId === myStreamId) {
4618
4668
  const durationMs = Date.now() - startTime;
4669
+ this.abortAgentScript(agent.name, `stream aborted (${reason})`);
4619
4670
  agent.reset();
4620
4671
  this.settleAgent(agent.name, {
4621
4672
  stopReason: 'exhausted',
@@ -4714,6 +4765,7 @@ export class AgentFramework {
4714
4765
  request: compiledRequest ?? { note: 'streaming request threw' },
4715
4766
  durationMs,
4716
4767
  });
4768
+ this.abortAgentScript(agent.name, 'stream threw');
4717
4769
  agent.reset();
4718
4770
  this.eventGate?.onInferenceEnded(agent.name);
4719
4771
  }
@@ -4730,6 +4782,15 @@ export class AgentFramework {
4730
4782
  // Stop the typing indicator on every exit path (complete, error,
4731
4783
  // exhausted, abort) so it never sticks after the turn ends.
4732
4784
  this.channelRegistry?.stopTyping();
4785
+ // Spec 14.3: flush any held line-start text, then close each streamed
4786
+ // channel with its final moderated content — the consumer's signal to
4787
+ // finalize (end the TTS utterance, settle the rendered message).
4788
+ if (proseStream) {
4789
+ emitOutgoing(proseStream.finish());
4790
+ for (const [channelId, text] of proseStream.byChannel()) {
4791
+ this.channelRegistry.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text);
4792
+ }
4793
+ }
4733
4794
  this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
4734
4795
  this.activeStreams.delete(agent.name);
4735
4796
  this.pendingAssistantBlocks.delete(agent.name);
@@ -4753,6 +4814,12 @@ export class AgentFramework {
4753
4814
  * Used by SubagentModule to dispatch tool calls for ephemeral agents.
4754
4815
  */
4755
4816
  async executeToolCall(call) {
4817
+ // Client-side programmatic tool calling for promise-based callers
4818
+ // (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral
4819
+ // gets its own interpreter state.
4820
+ if (call.name === CODE_EXECUTION_TOOL_NAME && this.codeExecutionConfig) {
4821
+ return this.runCodeExecution(call.callerAgentName ?? '__ephemeral__', call);
4822
+ }
4756
4823
  // MCPL tools are dispatched via the MCPL subsystem
4757
4824
  if (this.mcplServerRegistry) {
4758
4825
  // Check if this is an MCPL-prefixed tool
@@ -4800,6 +4867,180 @@ export class AgentFramework {
4800
4867
  };
4801
4868
  }
4802
4869
  }
4870
+ // ---------------------------------------------------------------------------
4871
+ // Client-side programmatic tool calling (code_execution)
4872
+ // ---------------------------------------------------------------------------
4873
+ /** Event-pushing wrapper for the model-driven dispatch path. */
4874
+ dispatchCodeExecutionToolCall(agentName, call) {
4875
+ this.emitTrace({
4876
+ type: 'tool:started',
4877
+ module: 'code_execution',
4878
+ tool: call.name,
4879
+ callId: call.id,
4880
+ input: call.input,
4881
+ });
4882
+ const startTime = Date.now();
4883
+ this.runCodeExecution(agentName, call)
4884
+ .catch((error) => {
4885
+ // runCodeExecution is designed not to reject; this is the last-resort
4886
+ // guard so a bug here can never strand the agent in waiting_for_tools.
4887
+ const err = error instanceof Error ? error : new Error(String(error));
4888
+ return { success: false, error: `code_execution failed: ${err.message}`, isError: true };
4889
+ })
4890
+ .then((result) => {
4891
+ if (result.isError) {
4892
+ this.emitTrace({
4893
+ type: 'tool:failed',
4894
+ module: 'code_execution',
4895
+ tool: call.name,
4896
+ callId: call.id,
4897
+ error: result.error ?? 'unknown error',
4898
+ });
4899
+ }
4900
+ else {
4901
+ this.emitTrace({
4902
+ type: 'tool:completed',
4903
+ module: 'code_execution',
4904
+ tool: call.name,
4905
+ callId: call.id,
4906
+ durationMs: Date.now() - startTime,
4907
+ });
4908
+ }
4909
+ this.pushEvent({
4910
+ type: 'tool-result',
4911
+ callId: call.id,
4912
+ agentName,
4913
+ moduleName: 'code_execution',
4914
+ result,
4915
+ });
4916
+ });
4917
+ }
4918
+ /**
4919
+ * Run one code_execution script for an agent. Never rejects.
4920
+ *
4921
+ * The script runs in the agent's persistent python interpreter with every
4922
+ * tool on the agent's CURRENT surface injected (minus code_execution
4923
+ * itself). Inner tool calls are dispatched through dispatchToolCall — the
4924
+ * exact model path, including MCPL state/checkpoint handling, channel
4925
+ * routing, and traces — and resolved via scriptToolWaiters, so their
4926
+ * results reach the running script and never the model context.
4927
+ */
4928
+ async runCodeExecution(agentName, call) {
4929
+ const input = (call.input ?? {});
4930
+ if (typeof input.code !== 'string' || input.code.trim() === '') {
4931
+ return {
4932
+ success: false,
4933
+ error: 'code_execution requires a non-empty `code` string',
4934
+ isError: true,
4935
+ };
4936
+ }
4937
+ const agent = this.agents.get(agentName);
4938
+ const surface = agent
4939
+ ? this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name))
4940
+ : this.getAllTools(); // ephemeral agents: full surface, matching executeToolCall
4941
+ const injected = buildInjectedTools(surface.map((t) => t.name).filter((name) => name !== CODE_EXECUTION_TOOL_NAME));
4942
+ const runner = this.getOrCreateScriptRunner(agentName);
4943
+ this.scriptDeferredEndTurn.delete(agentName);
4944
+ const exec = await runner.exec(input.code, injected);
4945
+ const endTurn = this.scriptDeferredEndTurn.delete(agentName);
4946
+ return {
4947
+ success: true,
4948
+ data: { stdout: exec.stdout, stderr: exec.stderr, return_code: exec.returnCode },
4949
+ isError: false,
4950
+ ...(endTurn ? { endTurn: true } : {}),
4951
+ };
4952
+ }
4953
+ getOrCreateScriptRunner(agentName) {
4954
+ let runner = this.codeExecutionRunners.get(agentName);
4955
+ if (!runner) {
4956
+ const cfg = this.codeExecutionConfig;
4957
+ runner = new PyRunner({
4958
+ pythonPath: cfg?.pythonPath,
4959
+ toolCallTimeoutMs: cfg?.toolCallTimeoutMs,
4960
+ scriptTimeoutMs: cfg?.scriptTimeoutMs,
4961
+ idleReclaimMs: cfg?.idleReclaimMs,
4962
+ label: agentName,
4963
+ onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args),
4964
+ });
4965
+ this.codeExecutionRunners.set(agentName, runner);
4966
+ }
4967
+ return runner;
4968
+ }
4969
+ /**
4970
+ * Resolve one script-inner tool call to the string the script's tool
4971
+ * function returns. Never rejects — errors become "Error: ..." strings,
4972
+ * matching the managed PTC runtime ("Claude's code receives this error").
4973
+ *
4974
+ * Results are serialized with the HISTORY serializer (images become
4975
+ * placeholders): programmatic tool results are text-only by contract, and
4976
+ * a script variable holding megabytes of base64 helps nobody.
4977
+ */
4978
+ async handleScriptToolCall(agentName, toolName, args) {
4979
+ if (toolName === CODE_EXECUTION_TOOL_NAME) {
4980
+ return 'Error: code_execution cannot be called from within a script';
4981
+ }
4982
+ const result = await this.dispatchScriptToolCall(agentName, toolName, args);
4983
+ if (result.endTurn) {
4984
+ // Deferred: applied to the final code_execution result (see
4985
+ // scriptDeferredEndTurn) — ending the turn mid-script would cancel the
4986
+ // stream while the script still runs and wedge the tool round.
4987
+ this.scriptDeferredEndTurn.add(agentName);
4988
+ }
4989
+ if (result.isError) {
4990
+ return `Error: ${result.error ?? 'tool call failed'}`;
4991
+ }
4992
+ if (result.data === undefined)
4993
+ return '';
4994
+ const agent = this.agents.get(agentName);
4995
+ const maxChars = agent ? this.getMaxToolResultChars(agent) : undefined;
4996
+ return toolResultDataToHistoryString(result.data, maxChars);
4997
+ }
4998
+ /**
4999
+ * Dispatch a script-inner tool call through the NORMAL dispatch path and
5000
+ * resolve with its result. The waiter intercept in handleProcessEvent
5001
+ * (keyed by the pytc- call ID) routes the tool-result event here instead
5002
+ * of into the agent's pending tool round.
5003
+ */
5004
+ dispatchScriptToolCall(agentName, toolName, args) {
5005
+ return new Promise((resolve) => {
5006
+ const callId = `pytc-${randomUUID()}`;
5007
+ // Host-side leak guard: the python runtime already raises TimeoutError
5008
+ // at toolCallTimeoutMs; this only reclaims the waiter if a dispatch path
5009
+ // loses the result entirely (so the map cannot grow unboundedly).
5010
+ const safetyMs = (this.codeExecutionConfig?.toolCallTimeoutMs ?? 270_000) + 30_000;
5011
+ const safety = setTimeout(() => {
5012
+ if (this.scriptToolWaiters.delete(callId)) {
5013
+ resolve({
5014
+ success: false,
5015
+ error: `tool '${toolName}' produced no result within ${Math.round(safetyMs / 1000)}s`,
5016
+ isError: true,
5017
+ });
5018
+ }
5019
+ }, safetyMs);
5020
+ safety.unref?.();
5021
+ this.scriptToolWaiters.set(callId, (result) => {
5022
+ clearTimeout(safety);
5023
+ resolve(result);
5024
+ });
5025
+ try {
5026
+ this.dispatchToolCall(agentName, { id: callId, name: toolName, input: args });
5027
+ }
5028
+ catch (error) {
5029
+ if (this.scriptToolWaiters.delete(callId)) {
5030
+ clearTimeout(safety);
5031
+ const err = error instanceof Error ? error : new Error(String(error));
5032
+ resolve({ success: false, error: err.message, isError: true });
5033
+ }
5034
+ }
5035
+ });
5036
+ }
5037
+ /** Abort a running script when the agent's turn dies underneath it. */
5038
+ abortAgentScript(agentName, reason) {
5039
+ const runner = this.codeExecutionRunners.get(agentName);
5040
+ if (runner?.busy) {
5041
+ runner.abort(reason);
5042
+ }
5043
+ }
4803
5044
  toMembraneToolResult(callId, afResult, maxChars) {
4804
5045
  if (afResult.isError) {
4805
5046
  return { toolUseId: callId, content: afResult.error ?? 'Unknown error', isError: true };
@@ -5018,6 +5259,13 @@ export class AgentFramework {
5018
5259
  this.dispatchChannelToolCall(agentName, enrichedCall);
5019
5260
  return;
5020
5261
  }
5262
+ // Client-side programmatic tool calling: run a python script that calls
5263
+ // the agent's other tools in-process (script-inner calls come back through
5264
+ // this very dispatcher with waiter-tracked call IDs).
5265
+ if (enrichedCall.name === CODE_EXECUTION_TOOL_NAME && this.codeExecutionConfig) {
5266
+ this.dispatchCodeExecutionToolCall(agentName, enrichedCall);
5267
+ return;
5268
+ }
5021
5269
  // prose_help: on-demand routing reference for explicit-prose agents.
5022
5270
  // Answered inline — the grammar is a tool RESULT (model-requested), never
5023
5271
  // ambient context (see PROSE_ROUTING_HELP / teach-by-bounce).