@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.
- package/dist/src/code-execution/index.d.ts +14 -0
- package/dist/src/code-execution/index.d.ts.map +1 -0
- package/dist/src/code-execution/index.js +13 -0
- package/dist/src/code-execution/index.js.map +1 -0
- package/dist/src/code-execution/py-runner.d.ts +96 -0
- package/dist/src/code-execution/py-runner.d.ts.map +1 -0
- package/dist/src/code-execution/py-runner.js +371 -0
- package/dist/src/code-execution/py-runner.js.map +1 -0
- package/dist/src/code-execution/runtime-py.d.ts +32 -0
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -0
- package/dist/src/code-execution/runtime-py.js +265 -0
- package/dist/src/code-execution/runtime-py.js.map +1 -0
- package/dist/src/code-execution/tool-definition.d.ts +16 -0
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -0
- package/dist/src/code-execution/tool-definition.js +47 -0
- package/dist/src/code-execution/tool-definition.js.map +1 -0
- package/dist/src/framework.d.ts +43 -1
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +274 -26
- package/dist/src/framework.js.map +1 -1
- package/dist/src/index.d.ts +2 -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 +12 -0
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +45 -0
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/prose-grammar.d.ts +24 -0
- package/dist/src/mcpl/prose-grammar.d.ts.map +1 -0
- package/dist/src/mcpl/prose-grammar.js +33 -0
- package/dist/src/mcpl/prose-grammar.js.map +1 -0
- package/dist/src/mcpl/prose-stream-router.d.ts +58 -0
- package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -0
- package/dist/src/mcpl/prose-stream-router.js +168 -0
- package/dist/src/mcpl/prose-stream-router.js.map +1 -0
- package/dist/src/types/framework.d.ts +33 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/test/code-execution.test.d.ts +11 -0
- package/dist/test/code-execution.test.d.ts.map +1 -0
- package/dist/test/code-execution.test.js +423 -0
- package/dist/test/code-execution.test.js.map +1 -0
- package/dist/test/prose-stream-router.test.d.ts +2 -0
- package/dist/test/prose-stream-router.test.d.ts.map +1 -0
- package/dist/test/prose-stream-router.test.js +87 -0
- package/dist/test/prose-stream-router.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/code-execution/index.ts +19 -0
- package/src/code-execution/py-runner.ts +439 -0
- package/src/code-execution/runtime-py.ts +265 -0
- package/src/code-execution/tool-definition.ts +54 -0
- package/src/framework.ts +307 -28
- package/src/index.ts +17 -0
- package/src/mcpl/channel-registry.ts +53 -0
- package/src/mcpl/prose-grammar.ts +40 -0
- package/src/mcpl/prose-stream-router.ts +161 -0
- 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
|
-
|
|
173
|
-
|
|
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
|
+
}
|