@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
package/src/framework.ts CHANGED
@@ -59,12 +59,20 @@ import { FeatureSetManager } from './mcpl/feature-set-manager.js';
59
59
  import { ScopeManager } from './mcpl/scope-manager.js';
60
60
  import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
61
61
  import { PushHandler, type McplPushEvent } from './mcpl/push-handler.js';
62
+ import { parseProsePrefix } from './mcpl/prose-grammar.js';
63
+ import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
62
64
  import { InferenceRouter } from './mcpl/inference-router.js';
63
65
  import { ChannelRegistry } from './mcpl/channel-registry.js';
64
66
  import { ConversationRouter } from './mcpl/conversation-router.js';
65
67
  import { safeSlice } from './safe-slice.js';
66
68
  import type { WorkspaceModule } from './modules/workspace/index.js';
67
69
  import { toolResultDataToHistoryString } from './tool-result-history.js';
70
+ import { randomUUID } from 'node:crypto';
71
+ import { PyRunner, buildInjectedTools } from './code-execution/py-runner.js';
72
+ import {
73
+ buildCodeExecutionToolDefinition,
74
+ CODE_EXECUTION_TOOL_NAME,
75
+ } from './code-execution/tool-definition.js';
68
76
  import { splitProseSegments } from './prose-segments.js';
69
77
 
70
78
  /** Detect a supported image media type from magic bytes (the model API
@@ -168,33 +176,9 @@ const isAddressedMessage = (
168
176
  _metadata?: Record<string, unknown>,
169
177
  ): boolean => Array.isArray(tags) && tags.includes('chat:addressed');
170
178
 
171
- /**
172
- * Parse a prose segment's routing prefix (explicit prose routing —
173
- * docs/explicit-prose-routing.md). The FIRST line may be:
174
- * `>>target [!] [body…]` — target = channel spec or `private`
175
- * The ` !` continuation modifier must immediately follow the target,
176
- * whitespace-separated. Body may start on the same line or the next.
177
- */
178
- function parseProsePrefix(text: string): {
179
- kind: 'target' | 'private' | 'none';
180
- target?: string;
181
- continueTurn: boolean;
182
- body: string;
183
- } {
184
- const m = /^>>(\S+)([ \t]+!)?[ \t]*\n?/.exec(text);
185
- if (!m) return { kind: 'none', continueTurn: false, body: text };
186
- const target = m[1]!;
187
- const continueTurn = m[2] !== undefined;
188
- const body = text.slice(m[0].length);
189
- // `skip_reply` mirrors the tool the model already knows: text stays in
190
- // context, nothing is delivered. (Deliberately NOT called "private"/"note" —
191
- // that vocabulary sat adjacent to signed thinking in a live window and drew
192
- // a reasoning_extraction classifier hit, 2026-07-24 Fable.)
193
- if (target === 'skip_reply') {
194
- return { kind: 'private', continueTurn, body };
195
- }
196
- return { kind: 'target', target, continueTurn, body };
197
- }
179
+ // parseProsePrefix moved to ./mcpl/prose-grammar.ts — ONE grammar shared with
180
+ // the outgoing-stream router (Spec 14.3), so streamed chunks and delivered
181
+ // envelopes can never disagree on what is a prefix.
198
182
 
199
183
  /** One-time primer appended when an agent's proseRouting mode changes. */
200
184
  function proseModePrimer(mode: 'explicit' | 'locus'): string {
@@ -651,6 +635,18 @@ export class AgentFramework {
651
635
  /** Forks whose TTL closure turn has been queued — disposed (removed from
652
636
  * agents/agentConfigs/conversationAgentHomes) when their stream ends. */
653
637
  private closingConversationAgents: Set<string> = new Set();
638
+ // Client-side programmatic tool calling (`code_execution`). Null unless
639
+ // config.codeExecution.enabled. One PyRunner per agent (interpreter state
640
+ // is per-agent, like a per-agent container); waiters resolve script-inner
641
+ // tool calls dispatched through the normal dispatchToolCall path.
642
+ private codeExecutionConfig: import('./types/index.js').CodeExecutionConfig | null = null;
643
+ private codeExecutionRunners: Map<string, PyRunner> = new Map();
644
+ private scriptToolWaiters: Map<string, (result: ToolResult) => void> = new Map();
645
+ /** Agents whose running script hit an endTurn-carrying inner result —
646
+ * deferred and applied to the final code_execution result instead of
647
+ * cancelling the stream mid-script (which would wedge the turn). */
648
+ private scriptDeferredEndTurn: Set<string> = new Set();
649
+
654
650
  private mcplTools: import('./types/index.js').ToolDefinition[] = [];
655
651
  private mcplToolRefreshInFlight = false;
656
652
  private mcplToolRefreshPending = false;
@@ -918,6 +914,10 @@ export class AgentFramework {
918
914
  // that started with zero configured servers).
919
915
  framework.mcplInferenceRoutingConfig = config.inferenceRouting ?? null;
920
916
 
917
+ // Client-side programmatic tool calling (code_execution). Config is only
918
+ // retained when enabled — everything downstream gates on the field.
919
+ framework.codeExecutionConfig = config.codeExecution?.enabled ? config.codeExecution : null;
920
+
921
921
  // Initialize MCPL subsystems if configured
922
922
  if (config.mcplServers && config.mcplServers.length > 0) {
923
923
  // Validate tool prefixes: no collisions with module names or between servers
@@ -1010,6 +1010,14 @@ export class AgentFramework {
1010
1010
  this.running = false;
1011
1011
  this.queue.close();
1012
1012
 
1013
+ // Kill running code_execution scripts before cancelling streams: a
1014
+ // zombie script must not keep firing side-effectful tool calls into a
1015
+ // framework that is shutting down.
1016
+ for (const runner of this.codeExecutionRunners.values()) {
1017
+ runner.dispose();
1018
+ }
1019
+ this.codeExecutionRunners.clear();
1020
+
1013
1021
  // Cancel all active streams
1014
1022
  for (const agent of this.agents.values()) {
1015
1023
  if (agent.state.status === 'streaming' ||
@@ -1307,6 +1315,9 @@ export class AgentFramework {
1307
1315
  ...(this.getWorkspaceModule()
1308
1316
  ? [AgentFramework.SAVE_IMAGE_TOOL, AgentFramework.READ_IMAGE_TOOL]
1309
1317
  : []),
1318
+ ...(this.codeExecutionConfig
1319
+ ? [buildCodeExecutionToolDefinition(this.codeExecutionConfig)]
1320
+ : []),
1310
1321
  ];
1311
1322
  }
1312
1323
 
@@ -1623,6 +1634,7 @@ export class AgentFramework {
1623
1634
  agentName: string,
1624
1635
  budgetTokens: number,
1625
1636
  overrides?: Record<string, unknown>,
1637
+ opts?: { render?: boolean },
1626
1638
  ): unknown {
1627
1639
  const agent = this.agents.get(agentName);
1628
1640
  if (!agent) throw new Error(`Unknown agent: ${agentName}`);
@@ -1630,14 +1642,18 @@ export class AgentFramework {
1630
1642
  previewContext?: (
1631
1643
  budget: TokenBudget,
1632
1644
  overrides?: Record<string, unknown>,
1645
+ opts?: { render?: boolean },
1633
1646
  ) => unknown;
1634
1647
  };
1635
1648
  if (typeof cm.previewContext !== 'function') return null;
1636
1649
  // reserveForResponse mirrors the live compile so the previewed middle is
1637
- // comparable to what the agent actually gets.
1650
+ // comparable to what the agent actually gets. `opts.render` additionally
1651
+ // returns the rendered entries — opt-in, since they are megabytes on a
1652
+ // large store, and older context-manager builds ignore the argument.
1638
1653
  return cm.previewContext(
1639
1654
  { maxTokens: budgetTokens, reserveForResponse: agent.maxTokens },
1640
1655
  overrides,
1656
+ opts,
1641
1657
  );
1642
1658
  }
1643
1659
 
@@ -3371,6 +3387,19 @@ export class AgentFramework {
3371
3387
  // Anthropic API. If we let modules or MCPL handlers run first they may add
3372
3388
  // messages between tool_use and tool_result, causing a 400 error.
3373
3389
  if (event.type === 'tool-result') {
3390
+ // Script-inner tool calls (client-side programmatic tool calling):
3391
+ // resolve the waiter and stop. These calls belong to a RUNNING
3392
+ // code_execution script, not to the agent's pending tool round — they
3393
+ // must not touch agent state (and must resolve even for ephemeral
3394
+ // agents that aren't in this.agents, or after the agent's own round
3395
+ // has already settled).
3396
+ const scriptWaiter = this.scriptToolWaiters.get(event.callId);
3397
+ if (scriptWaiter) {
3398
+ this.scriptToolWaiters.delete(event.callId);
3399
+ scriptWaiter(event.result);
3400
+ return;
3401
+ }
3402
+
3374
3403
  const agent = this.agents.get(event.agentName);
3375
3404
  if (agent) {
3376
3405
  this.touchEphemeralRun(event.agentName, true);
@@ -4782,6 +4811,32 @@ export class AgentFramework {
4782
4811
  : resolveTurnLocus();
4783
4812
  if (typingChannel) this.channelRegistry!.startTyping(typingChannel);
4784
4813
 
4814
+ // MCPL Spec 14.3 outgoing streaming: route text deltas to their
4815
+ // destination server AS THEY GENERATE (voice synthesis, live message
4816
+ // rendering). Pure observer surface — suppression is fail-closed (nothing
4817
+ // streams that delivery would refuse), emission is capability-gated in
4818
+ // the registry (`channels.streaming`), and delivery via deliverProse
4819
+ // remains the sole authoritative send.
4820
+ const outgoingInferenceId = `inf_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
4821
+ let outgoingIndex = 0;
4822
+ const proseStream = this.channelRegistry
4823
+ ? new ProseStreamRouter({
4824
+ mode: agent.proseRouting === 'explicit' ? 'explicit' : 'locus',
4825
+ initialTarget: typingChannel,
4826
+ resolve: (spec) => {
4827
+ const r = this.channelRegistry!.resolveProseTarget(spec);
4828
+ return 'channelId' in r ? r.channelId : null;
4829
+ },
4830
+ })
4831
+ : null;
4832
+ const emitOutgoing = (deltas: { channelId: string; delta: string }[]): void => {
4833
+ for (const rd of deltas) {
4834
+ this.channelRegistry!.sendOutgoingChunk(
4835
+ rd.channelId, agent.name, outgoingInferenceId, outgoingIndex++, rd.delta,
4836
+ );
4837
+ }
4838
+ };
4839
+
4785
4840
  const adoptInjectedRound = (): void => {
4786
4841
  if (!this.midTurnInputSignals.has(agent.name)) return;
4787
4842
  this.midTurnInputSignals.delete(agent.name);
@@ -4808,6 +4863,9 @@ export class AgentFramework {
4808
4863
  // turn's prose is bound for, without re-deriving routing.
4809
4864
  channelId: typingChannel ?? undefined,
4810
4865
  });
4866
+ if (proseStream && event.meta.type === 'text') {
4867
+ emitOutgoing(proseStream.feed(event.content));
4868
+ }
4811
4869
  break;
4812
4870
 
4813
4871
  case 'block': {
@@ -5387,6 +5445,7 @@ export class AgentFramework {
5387
5445
  // Only reset + retry if this is still the active stream
5388
5446
  if (agent.streamId !== myStreamId) break;
5389
5447
 
5448
+ this.abortAgentScript(agent.name, 'stream error');
5390
5449
  agent.reset();
5391
5450
 
5392
5451
  const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
@@ -5434,6 +5493,7 @@ export class AgentFramework {
5434
5493
  // may have already started a new stream, bumping streamId)
5435
5494
  if (agent.streamId === myStreamId) {
5436
5495
  const durationMs = Date.now() - startTime;
5496
+ this.abortAgentScript(agent.name, `stream aborted (${reason})`);
5437
5497
  agent.reset();
5438
5498
  this.settleAgent(agent.name, {
5439
5499
  stopReason: 'exhausted',
@@ -5538,6 +5598,7 @@ export class AgentFramework {
5538
5598
  request: compiledRequest ?? { note: 'streaming request threw' },
5539
5599
  durationMs,
5540
5600
  });
5601
+ this.abortAgentScript(agent.name, 'stream threw');
5541
5602
  agent.reset();
5542
5603
  this.eventGate?.onInferenceEnded(agent.name);
5543
5604
  } finally {
@@ -5554,6 +5615,16 @@ export class AgentFramework {
5554
5615
  // Stop the typing indicator on every exit path (complete, error,
5555
5616
  // exhausted, abort) so it never sticks after the turn ends.
5556
5617
  this.channelRegistry?.stopTyping();
5618
+
5619
+ // Spec 14.3: flush any held line-start text, then close each streamed
5620
+ // channel with its final moderated content — the consumer's signal to
5621
+ // finalize (end the TTS utterance, settle the rendered message).
5622
+ if (proseStream) {
5623
+ emitOutgoing(proseStream.finish());
5624
+ for (const [channelId, text] of proseStream.byChannel()) {
5625
+ this.channelRegistry!.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text);
5626
+ }
5627
+ }
5557
5628
  this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
5558
5629
  this.activeStreams.delete(agent.name);
5559
5630
  this.pendingAssistantBlocks.delete(agent.name);
@@ -5580,6 +5651,13 @@ export class AgentFramework {
5580
5651
  * Used by SubagentModule to dispatch tool calls for ephemeral agents.
5581
5652
  */
5582
5653
  async executeToolCall(call: ToolCall): Promise<ToolResult> {
5654
+ // Client-side programmatic tool calling for promise-based callers
5655
+ // (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral
5656
+ // gets its own interpreter state.
5657
+ if (call.name === CODE_EXECUTION_TOOL_NAME && this.codeExecutionConfig) {
5658
+ return this.runCodeExecution(call.callerAgentName ?? '__ephemeral__', call);
5659
+ }
5660
+
5583
5661
  // MCPL tools are dispatched via the MCPL subsystem
5584
5662
  if (this.mcplServerRegistry) {
5585
5663
  // Check if this is an MCPL-prefixed tool
@@ -5629,6 +5707,199 @@ export class AgentFramework {
5629
5707
  }
5630
5708
  }
5631
5709
 
5710
+ // ---------------------------------------------------------------------------
5711
+ // Client-side programmatic tool calling (code_execution)
5712
+ // ---------------------------------------------------------------------------
5713
+
5714
+ /** Event-pushing wrapper for the model-driven dispatch path. */
5715
+ private dispatchCodeExecutionToolCall(agentName: string, call: ToolCall): void {
5716
+ this.emitTrace({
5717
+ type: 'tool:started',
5718
+ module: 'code_execution',
5719
+ tool: call.name,
5720
+ callId: call.id,
5721
+ input: call.input,
5722
+ });
5723
+ const startTime = Date.now();
5724
+ this.runCodeExecution(agentName, call)
5725
+ .catch((error): ToolResult => {
5726
+ // runCodeExecution is designed not to reject; this is the last-resort
5727
+ // guard so a bug here can never strand the agent in waiting_for_tools.
5728
+ const err = error instanceof Error ? error : new Error(String(error));
5729
+ return { success: false, error: `code_execution failed: ${err.message}`, isError: true };
5730
+ })
5731
+ .then((result) => {
5732
+ if (result.isError) {
5733
+ this.emitTrace({
5734
+ type: 'tool:failed',
5735
+ module: 'code_execution',
5736
+ tool: call.name,
5737
+ callId: call.id,
5738
+ error: result.error ?? 'unknown error',
5739
+ });
5740
+ } else {
5741
+ this.emitTrace({
5742
+ type: 'tool:completed',
5743
+ module: 'code_execution',
5744
+ tool: call.name,
5745
+ callId: call.id,
5746
+ durationMs: Date.now() - startTime,
5747
+ });
5748
+ }
5749
+ this.pushEvent({
5750
+ type: 'tool-result',
5751
+ callId: call.id,
5752
+ agentName,
5753
+ moduleName: 'code_execution',
5754
+ result,
5755
+ });
5756
+ });
5757
+ }
5758
+
5759
+ /**
5760
+ * Run one code_execution script for an agent. Never rejects.
5761
+ *
5762
+ * The script runs in the agent's persistent python interpreter with every
5763
+ * tool on the agent's CURRENT surface injected (minus code_execution
5764
+ * itself). Inner tool calls are dispatched through dispatchToolCall — the
5765
+ * exact model path, including MCPL state/checkpoint handling, channel
5766
+ * routing, and traces — and resolved via scriptToolWaiters, so their
5767
+ * results reach the running script and never the model context.
5768
+ */
5769
+ private async runCodeExecution(agentName: string, call: ToolCall): Promise<ToolResult> {
5770
+ const input = (call.input ?? {}) as { code?: unknown };
5771
+ if (typeof input.code !== 'string' || input.code.trim() === '') {
5772
+ return {
5773
+ success: false,
5774
+ error: 'code_execution requires a non-empty `code` string',
5775
+ isError: true,
5776
+ };
5777
+ }
5778
+
5779
+ const agent = this.agents.get(agentName);
5780
+ const surface = agent
5781
+ ? this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name))
5782
+ : this.getAllTools(); // ephemeral agents: full surface, matching executeToolCall
5783
+ const injected = buildInjectedTools(
5784
+ surface.map((t) => t.name).filter((name) => name !== CODE_EXECUTION_TOOL_NAME),
5785
+ );
5786
+
5787
+ const runner = this.getOrCreateScriptRunner(agentName);
5788
+ this.scriptDeferredEndTurn.delete(agentName);
5789
+ const exec = await runner.exec(input.code, injected);
5790
+ const endTurn = this.scriptDeferredEndTurn.delete(agentName);
5791
+
5792
+ return {
5793
+ success: true,
5794
+ data: { stdout: exec.stdout, stderr: exec.stderr, return_code: exec.returnCode },
5795
+ isError: false,
5796
+ ...(endTurn ? { endTurn: true } : {}),
5797
+ };
5798
+ }
5799
+
5800
+ private getOrCreateScriptRunner(agentName: string): PyRunner {
5801
+ let runner = this.codeExecutionRunners.get(agentName);
5802
+ if (!runner) {
5803
+ const cfg = this.codeExecutionConfig;
5804
+ runner = new PyRunner({
5805
+ pythonPath: cfg?.pythonPath,
5806
+ toolCallTimeoutMs: cfg?.toolCallTimeoutMs,
5807
+ scriptTimeoutMs: cfg?.scriptTimeoutMs,
5808
+ idleReclaimMs: cfg?.idleReclaimMs,
5809
+ label: agentName,
5810
+ onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args),
5811
+ });
5812
+ this.codeExecutionRunners.set(agentName, runner);
5813
+ }
5814
+ return runner;
5815
+ }
5816
+
5817
+ /**
5818
+ * Resolve one script-inner tool call to the string the script's tool
5819
+ * function returns. Never rejects — errors become "Error: ..." strings,
5820
+ * matching the managed PTC runtime ("Claude's code receives this error").
5821
+ *
5822
+ * Results are serialized with the HISTORY serializer (images become
5823
+ * placeholders): programmatic tool results are text-only by contract, and
5824
+ * a script variable holding megabytes of base64 helps nobody.
5825
+ */
5826
+ private async handleScriptToolCall(
5827
+ agentName: string,
5828
+ toolName: string,
5829
+ args: Record<string, unknown>,
5830
+ ): Promise<string> {
5831
+ if (toolName === CODE_EXECUTION_TOOL_NAME) {
5832
+ return 'Error: code_execution cannot be called from within a script';
5833
+ }
5834
+ const result = await this.dispatchScriptToolCall(agentName, toolName, args);
5835
+ if (result.endTurn) {
5836
+ // Deferred: applied to the final code_execution result (see
5837
+ // scriptDeferredEndTurn) — ending the turn mid-script would cancel the
5838
+ // stream while the script still runs and wedge the tool round.
5839
+ this.scriptDeferredEndTurn.add(agentName);
5840
+ }
5841
+ if (result.isError) {
5842
+ return `Error: ${result.error ?? 'tool call failed'}`;
5843
+ }
5844
+ if (result.data === undefined) return '';
5845
+ const agent = this.agents.get(agentName);
5846
+ const maxChars = agent ? this.getMaxToolResultChars(agent) : undefined;
5847
+ return toolResultDataToHistoryString(result.data, maxChars);
5848
+ }
5849
+
5850
+ /**
5851
+ * Dispatch a script-inner tool call through the NORMAL dispatch path and
5852
+ * resolve with its result. The waiter intercept in handleProcessEvent
5853
+ * (keyed by the pytc- call ID) routes the tool-result event here instead
5854
+ * of into the agent's pending tool round.
5855
+ */
5856
+ private dispatchScriptToolCall(
5857
+ agentName: string,
5858
+ toolName: string,
5859
+ args: Record<string, unknown>,
5860
+ ): Promise<ToolResult> {
5861
+ return new Promise<ToolResult>((resolve) => {
5862
+ const callId = `pytc-${randomUUID()}`;
5863
+ // Host-side leak guard: the python runtime already raises TimeoutError
5864
+ // at toolCallTimeoutMs; this only reclaims the waiter if a dispatch path
5865
+ // loses the result entirely (so the map cannot grow unboundedly).
5866
+ const safetyMs = (this.codeExecutionConfig?.toolCallTimeoutMs ?? 270_000) + 30_000;
5867
+ const safety = setTimeout(() => {
5868
+ if (this.scriptToolWaiters.delete(callId)) {
5869
+ resolve({
5870
+ success: false,
5871
+ error: `tool '${toolName}' produced no result within ${Math.round(safetyMs / 1000)}s`,
5872
+ isError: true,
5873
+ });
5874
+ }
5875
+ }, safetyMs);
5876
+ safety.unref?.();
5877
+
5878
+ this.scriptToolWaiters.set(callId, (result) => {
5879
+ clearTimeout(safety);
5880
+ resolve(result);
5881
+ });
5882
+
5883
+ try {
5884
+ this.dispatchToolCall(agentName, { id: callId, name: toolName, input: args });
5885
+ } catch (error) {
5886
+ if (this.scriptToolWaiters.delete(callId)) {
5887
+ clearTimeout(safety);
5888
+ const err = error instanceof Error ? error : new Error(String(error));
5889
+ resolve({ success: false, error: err.message, isError: true });
5890
+ }
5891
+ }
5892
+ });
5893
+ }
5894
+
5895
+ /** Abort a running script when the agent's turn dies underneath it. */
5896
+ private abortAgentScript(agentName: string, reason: string): void {
5897
+ const runner = this.codeExecutionRunners.get(agentName);
5898
+ if (runner?.busy) {
5899
+ runner.abort(reason);
5900
+ }
5901
+ }
5902
+
5632
5903
  private toMembraneToolResult(callId: string, afResult: ToolResult, maxChars?: number): MembraneToolResult {
5633
5904
  if (afResult.isError) {
5634
5905
  return { toolUseId: callId, content: afResult.error ?? 'Unknown error', isError: true };
@@ -5868,6 +6139,14 @@ export class AgentFramework {
5868
6139
  return;
5869
6140
  }
5870
6141
 
6142
+ // Client-side programmatic tool calling: run a python script that calls
6143
+ // the agent's other tools in-process (script-inner calls come back through
6144
+ // this very dispatcher with waiter-tracked call IDs).
6145
+ if (enrichedCall.name === CODE_EXECUTION_TOOL_NAME && this.codeExecutionConfig) {
6146
+ this.dispatchCodeExecutionToolCall(agentName, enrichedCall);
6147
+ return;
6148
+ }
6149
+
5871
6150
  // prose_help: on-demand routing reference for explicit-prose agents.
5872
6151
  // Answered inline — the grammar is a tool RESULT (model-requested), never
5873
6152
  // ambient context (see PROSE_ROUTING_HELP / teach-by-bounce).
package/src/index.ts CHANGED
@@ -55,6 +55,23 @@ export type {
55
55
 
56
56
  // Usage tracking
57
57
  export { UsageTracker } from './usage/index.js';
58
+
59
+ // Client-side programmatic tool calling (code_execution): exposed so external
60
+ // tooling can drive the python runtime the way the framework does (protocol
61
+ // harnesses, runtime smoke tests).
62
+ export {
63
+ PyRunner,
64
+ buildInjectedTools,
65
+ buildCodeExecutionToolDefinition,
66
+ CODE_EXECUTION_TOOL_NAME,
67
+ PYTHON_RUNTIME_SOURCE,
68
+ } from './code-execution/index.js';
69
+ export type {
70
+ InjectedTool,
71
+ ExecResult,
72
+ PyRunnerOptions,
73
+ ScriptToolCallHandler,
74
+ } from './code-execution/index.js';
58
75
  export type { SessionUsage, AgentUsage, SessionUsageSnapshot, UsageUpdatedEvent } from './usage/index.js';
59
76
 
60
77
  // EventGate
@@ -1764,6 +1764,59 @@ export class ChannelRegistry {
1764
1764
  /** Resolve the outbound locus (fork HOME → this-turn's TRIGGERING channel →
1765
1765
  * process-global default). Public so a multi-segment caller can snapshot it
1766
1766
  * ONCE and pin every segment to it via routeSpeech's `overrideChannelId`. */
1767
+ /**
1768
+ * MCPL Spec 14.3 outgoing streaming: forward a moderated text delta to the
1769
+ * server owning the channel, AS THE MODEL GENERATES. Emitted only when that
1770
+ * server declared `channels.streaming` in its initialize capabilities —
1771
+ * servers that never opted in (the whole existing fleet) receive nothing.
1772
+ * Fire-and-forget and never throws: streaming is an observer surface; the
1773
+ * authoritative delivery is the eventual channels/publish.
1774
+ */
1775
+ sendOutgoingChunk(
1776
+ channelId: string,
1777
+ conversationId: string,
1778
+ inferenceId: string,
1779
+ index: number,
1780
+ delta: string,
1781
+ ): void {
1782
+ const server = this.streamingServerFor(channelId);
1783
+ if (!server) return;
1784
+ try {
1785
+ server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId, index, delta });
1786
+ } catch { /* observer surface — never disturb the turn */ }
1787
+ }
1788
+
1789
+ /** Spec 14.3 companion: final moderated content per channel at stream end. */
1790
+ sendOutgoingComplete(
1791
+ channelId: string,
1792
+ conversationId: string,
1793
+ inferenceId: string,
1794
+ text: string,
1795
+ ): void {
1796
+ const server = this.streamingServerFor(channelId);
1797
+ if (!server) return;
1798
+ try {
1799
+ server.sendChannelsOutgoingComplete({
1800
+ inferenceId,
1801
+ conversationId,
1802
+ channelId,
1803
+ content: [{ type: 'text', text }],
1804
+ });
1805
+ } catch { /* observer surface — never disturb the turn */ }
1806
+ }
1807
+
1808
+ private streamingServerFor(channelId: string) {
1809
+ // The map is keyed `${serverId}:${channelId}`; stream targets arrive as
1810
+ // bare descriptor ids (what resolveProseTarget returns). Scan like the
1811
+ // resolver does, and fail closed on cross-server ambiguity — the same
1812
+ // never-guess rule that governs delivery.
1813
+ const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
1814
+ if (matches.length !== 1) return null;
1815
+ const server = this.serverRegistry.getServer(matches[0]!.serverId);
1816
+ if (!server?.capabilities?.channels?.streaming) return null;
1817
+ return server;
1818
+ }
1819
+
1767
1820
  resolveLocus(conversationId: string): string | null {
1768
1821
  const home = this.homeChannelResolver?.(conversationId);
1769
1822
  return home ?? this.activeChannelResolver?.(conversationId) ?? this.defaultPublishChannel ?? null;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Explicit-prose routing grammar — the SINGLE definition shared by the
3
+ * delivery path (framework.deliverProse*) and the outgoing-stream router
4
+ * (prose-stream-router.ts). Extracted from framework.ts verbatim so the two
5
+ * parsers cannot drift: a streamed chunk and its eventual delivery must agree
6
+ * on what is a prefix, what is a modifier, and what is body.
7
+ *
8
+ * Grammar (docs/explicit-prose-routing.md):
9
+ * `>>target [!] [body…]` — target = channel spec or `skip_reply`
10
+ * The ` !` continuation modifier must immediately follow the target,
11
+ * whitespace-separated. Body may start on the same line or the next.
12
+ * A line's `>>` counts only when immediately followed by a target token
13
+ * (`>> quoted arrow` stays body text).
14
+ */
15
+
16
+ export interface ProsePrefix {
17
+ kind: 'target' | 'private' | 'none';
18
+ target?: string;
19
+ continueTurn: boolean;
20
+ body: string;
21
+ }
22
+
23
+ /** A REAL prefix line: `>>` immediately followed by a target token. */
24
+ export const PROSE_PREFIX_LINE = /^>>\S/;
25
+
26
+ export function parseProsePrefix(text: string): ProsePrefix {
27
+ const m = /^>>(\S+)([ \t]+!)?[ \t]*\n?/.exec(text);
28
+ if (!m) return { kind: 'none', continueTurn: false, body: text };
29
+ const target = m[1]!;
30
+ const continueTurn = m[2] !== undefined;
31
+ const body = text.slice(m[0].length);
32
+ // `skip_reply` mirrors the tool the model already knows: text stays in
33
+ // context, nothing is delivered. (Deliberately NOT called "private"/"note" —
34
+ // that vocabulary sat adjacent to signed thinking in a live window and drew
35
+ // a reasoning_extraction classifier hit, 2026-07-24 Fable.)
36
+ if (target === 'skip_reply') {
37
+ return { kind: 'private', continueTurn, body };
38
+ }
39
+ return { kind: 'target', target, continueTurn, body };
40
+ }