@animalabs/agent-framework 0.7.4 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/code-execution/index.d.ts +1 -1
- package/dist/src/code-execution/index.d.ts.map +1 -1
- package/dist/src/code-execution/index.js.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +23 -1
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +40 -4
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/runtime-py.d.ts +15 -2
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
- package/dist/src/code-execution/runtime-py.js +156 -7
- package/dist/src/code-execution/runtime-py.js.map +1 -1
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +27 -2
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/framework.d.ts +73 -0
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +818 -23
- package/dist/src/framework.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +28 -2
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +53 -7
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/transport.d.ts +1 -1
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +25 -7
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +10 -0
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/module-registry.d.ts +12 -0
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +26 -0
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +20 -0
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +48 -15
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/sync.d.ts +4 -0
- package/dist/src/modules/workspace/sync.d.ts.map +1 -1
- package/dist/src/modules/workspace/sync.js +1 -1
- package/dist/src/modules/workspace/sync.js.map +1 -1
- package/dist/src/types/framework.d.ts +10 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +22 -0
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/test/busy-agent-scheduler.test.js +1 -0
- package/dist/test/busy-agent-scheduler.test.js.map +1 -1
- package/dist/test/code-execution.test.js +327 -1
- package/dist/test/code-execution.test.js.map +1 -1
- package/dist/test/deferred-wake-integrity.test.d.ts +29 -0
- package/dist/test/deferred-wake-integrity.test.d.ts.map +1 -0
- package/dist/test/deferred-wake-integrity.test.js +320 -0
- package/dist/test/deferred-wake-integrity.test.js.map +1 -0
- package/dist/test/machine-close-provenance.test.d.ts +2 -0
- package/dist/test/machine-close-provenance.test.d.ts.map +1 -0
- package/dist/test/machine-close-provenance.test.js +110 -0
- package/dist/test/machine-close-provenance.test.js.map +1 -0
- package/dist/test/mcpl-access-provider.test.d.ts +2 -0
- package/dist/test/mcpl-access-provider.test.d.ts.map +1 -0
- package/dist/test/mcpl-access-provider.test.js +20 -0
- package/dist/test/mcpl-access-provider.test.js.map +1 -0
- package/dist/test/module-context-integration.test.d.ts +18 -0
- package/dist/test/module-context-integration.test.d.ts.map +1 -0
- package/dist/test/module-context-integration.test.js +181 -0
- package/dist/test/module-context-integration.test.js.map +1 -0
- package/dist/test/present-while-acting.test.js +72 -0
- package/dist/test/present-while-acting.test.js.map +1 -1
- package/dist/test/utils-surface.test.d.ts +2 -0
- package/dist/test/utils-surface.test.d.ts.map +1 -0
- package/dist/test/utils-surface.test.js +145 -0
- package/dist/test/utils-surface.test.js.map +1 -0
- package/dist/test/workspace-read-image.test.js +71 -0
- package/dist/test/workspace-read-image.test.js.map +1 -1
- package/dist/test/workspace-restore-order.test.d.ts +16 -0
- package/dist/test/workspace-restore-order.test.d.ts.map +1 -0
- package/dist/test/workspace-restore-order.test.js +140 -0
- package/dist/test/workspace-restore-order.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/code-execution/index.ts +1 -0
- package/src/code-execution/py-runner.ts +56 -4
- package/src/code-execution/runtime-py.ts +156 -7
- package/src/code-execution/tool-definition.ts +27 -2
- package/src/framework.ts +931 -25
- package/src/mcpl/channel-registry.ts +98 -4
- package/src/mcpl/transport.ts +26 -7
- package/src/mcpl/types.ts +11 -0
- package/src/module-registry.ts +34 -0
- package/src/modules/workspace/index.ts +49 -15
- package/src/modules/workspace/sync.ts +1 -1
- package/src/types/framework.ts +10 -0
- package/src/types/module.ts +24 -0
package/src/framework.ts
CHANGED
|
@@ -62,7 +62,7 @@ import { PushHandler, type McplPushEvent } from './mcpl/push-handler.js';
|
|
|
62
62
|
import { parseProsePrefix } from './mcpl/prose-grammar.js';
|
|
63
63
|
import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
|
|
64
64
|
import { InferenceRouter } from './mcpl/inference-router.js';
|
|
65
|
-
import { ChannelRegistry } from './mcpl/channel-registry.js';
|
|
65
|
+
import { ChannelRegistry, type ChannelToolOrigin } from './mcpl/channel-registry.js';
|
|
66
66
|
import { ConversationRouter } from './mcpl/conversation-router.js';
|
|
67
67
|
import { safeSlice } from './safe-slice.js';
|
|
68
68
|
import type { WorkspaceModule } from './modules/workspace/index.js';
|
|
@@ -224,6 +224,65 @@ const PROSE_HELP_TOOL: import('./types/index.js').ToolDefinition = {
|
|
|
224
224
|
};
|
|
225
225
|
/** Strip the `server--` MCPL prefix from a tool name. */
|
|
226
226
|
const bareToolName = (n: string): string => n.split('--').pop()!;
|
|
227
|
+
|
|
228
|
+
/** The single slot behind which every rarely-used capability lives. Only
|
|
229
|
+
* advertised when at least one module contributes utilities — with none
|
|
230
|
+
* registered the surface is byte-identical to before this existed. */
|
|
231
|
+
const UTILS_TOOL: import('./types/index.js').ToolDefinition = {
|
|
232
|
+
name: 'utils',
|
|
233
|
+
description:
|
|
234
|
+
'Rarely-used utilities, behind one slot instead of each costing a tool. ' +
|
|
235
|
+
'"list" names them all with one-liners; "describe" returns one utility\'s full ' +
|
|
236
|
+
'input schema; "run" invokes it (args are validated against that schema — a ' +
|
|
237
|
+
'miss bounces back with the schema and the specific error).',
|
|
238
|
+
inputSchema: {
|
|
239
|
+
type: 'object' as const,
|
|
240
|
+
properties: {
|
|
241
|
+
action: { type: 'string', enum: ['list', 'describe', 'run'], description: 'What to do.' },
|
|
242
|
+
name: { type: 'string', description: 'Utility name from list (required for describe/run).' },
|
|
243
|
+
args: { type: 'object', description: "Arguments for run, per the utility's schema." },
|
|
244
|
+
},
|
|
245
|
+
required: ['action'],
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/** First sentence (or line) of a description — what `utils list` shows. */
|
|
250
|
+
const utilityOneLiner = (description: string): string => {
|
|
251
|
+
const line = description.split('\n')[0]!;
|
|
252
|
+
const dot = line.indexOf('. ');
|
|
253
|
+
return (dot >= 0 ? line.slice(0, dot + 1) : line).slice(0, 160);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/** Shallow validation of run args against a utility's declared inputSchema:
|
|
257
|
+
* required keys, primitive types, enums. Deliberately not a full JSON-Schema
|
|
258
|
+
* engine — handlers still hand-validate (they always did); this layer exists
|
|
259
|
+
* so an arg-shape miss teaches by bounce instead of failing deep in a handler
|
|
260
|
+
* with a less instructive message. Returns an error string or null. */
|
|
261
|
+
function validateUtilityArgs(
|
|
262
|
+
input: unknown,
|
|
263
|
+
schema: import('./types/index.js').ToolDefinition['inputSchema'],
|
|
264
|
+
): string | null {
|
|
265
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
266
|
+
return 'args must be an object';
|
|
267
|
+
}
|
|
268
|
+
const obj = input as Record<string, unknown>;
|
|
269
|
+
for (const req of schema.required ?? []) {
|
|
270
|
+
if (!(req in obj) || obj[req] === undefined) return `missing required "${req}"`;
|
|
271
|
+
}
|
|
272
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
273
|
+
const param = schema.properties?.[key];
|
|
274
|
+
if (!param || value === undefined) continue; // unknown keys: handler's business
|
|
275
|
+
if (param.enum && !(param.enum as unknown[]).includes(value)) {
|
|
276
|
+
return `"${key}" must be one of ${JSON.stringify(param.enum)}`;
|
|
277
|
+
}
|
|
278
|
+
if (param.type) {
|
|
279
|
+
const actual = Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value;
|
|
280
|
+
const expected = param.type === 'integer' ? 'number' : param.type;
|
|
281
|
+
if (actual !== expected) return `"${key}" must be ${param.type}, got ${actual}`;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
227
286
|
import { CheckpointManager } from './mcpl/checkpoint-manager.js';
|
|
228
287
|
import { isToolAllowed } from './mcpl/tool-policy.js';
|
|
229
288
|
import { EventGate } from './gate/event-gate.js';
|
|
@@ -451,6 +510,23 @@ interface Deferred<T> {
|
|
|
451
510
|
* the caller awaits plus the liveness/progress bookkeeping the watchdogs and
|
|
452
511
|
* the stream driver share. One map entry per run — created and torn down in
|
|
453
512
|
* runEphemeralToCompletion — so the pieces cannot desync. */
|
|
513
|
+
/** One model-authored background (daemon) script — see runCodeExecution. */
|
|
514
|
+
interface BackgroundScriptRecord {
|
|
515
|
+
id: string;
|
|
516
|
+
agentName: string;
|
|
517
|
+
runner: PyRunner;
|
|
518
|
+
/** The agent-authored source (line numbers in wake envelopes index into it). */
|
|
519
|
+
code: string;
|
|
520
|
+
startedAt: number;
|
|
521
|
+
wakes: number;
|
|
522
|
+
lastWakeAt: number | null;
|
|
523
|
+
/** Mount-prefixed workspace path of the journal, null when no workspace. */
|
|
524
|
+
logPath: string | null;
|
|
525
|
+
status: 'running' | 'finished' | 'died' | 'cancelled';
|
|
526
|
+
/** Set when cancel/dispose already settled this script (suppresses wakes). */
|
|
527
|
+
cancelled: boolean;
|
|
528
|
+
}
|
|
529
|
+
|
|
454
530
|
interface EphemeralRun {
|
|
455
531
|
settle: Deferred<AgentSettleResult>;
|
|
456
532
|
inferenceStarted: boolean;
|
|
@@ -603,6 +679,25 @@ export class AgentFramework {
|
|
|
603
679
|
// tool_use → tool_result adjacency required by the Anthropic API).
|
|
604
680
|
private deferredMessages: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata }> = [];
|
|
605
681
|
|
|
682
|
+
// Turn-alive markers (2026-07-31 Mythos phantom-skip incident): agentName →
|
|
683
|
+
// token of the turn currently in progress. Set at startAgentStream ENTRY —
|
|
684
|
+
// before hooks, compile, and stream setup, all of which await — and cleared
|
|
685
|
+
// token-matched when that turn's teardown finishes (driveStream finally /
|
|
686
|
+
// terminal failure path), so a successor turn's marker survives a
|
|
687
|
+
// predecessor's late finally. A turn is alive from dequeue to settled
|
|
688
|
+
// teardown: strictly longer than `activeStreams` membership, which begins
|
|
689
|
+
// only after the stream exists. Guarding addMessage on stream-liveness
|
|
690
|
+
// alone left the compile window open — a deferred message flushed by the
|
|
691
|
+
// previous turn's finally landed in the window MID-COMPILE of the turn its
|
|
692
|
+
// own wake had started: positioned before the new turn's blocks yet absent
|
|
693
|
+
// from its wire request. The agent then "skipped" a message it never saw
|
|
694
|
+
// (falsified history — it later confabulated an apology for the skip), and
|
|
695
|
+
// every subsequent compile diverged from the live prefix at that message
|
|
696
|
+
// (KV bust). Invariant: nothing enters the window between a turn's dequeue
|
|
697
|
+
// and its settle except that turn's own blocks.
|
|
698
|
+
private activeTurnTokens: Map<string, number> = new Map();
|
|
699
|
+
private nextTurnToken = 1;
|
|
700
|
+
|
|
606
701
|
// Undo/redo state
|
|
607
702
|
private turnCounters: Map<string, number> = new Map(); // agentName → next turnIndex
|
|
608
703
|
private redoStacks: Map<string, RedoEntry[]> = new Map(); // agentName → redo entries
|
|
@@ -646,6 +741,15 @@ export class AgentFramework {
|
|
|
646
741
|
* deferred and applied to the final code_execution result instead of
|
|
647
742
|
* cancelling the stream mid-script (which would wedge the turn). */
|
|
648
743
|
private scriptDeferredEndTurn: Set<string> = new Set();
|
|
744
|
+
/** Background (daemon) scripts: model-authored watchers that outlive their
|
|
745
|
+
* spawning turn. Each gets a DEDICATED PyRunner; wake_agent() injects a
|
|
746
|
+
* provenance envelope + payload and requests inference. Keyed by script id. */
|
|
747
|
+
private backgroundScripts: Map<string, BackgroundScriptRecord> = new Map();
|
|
748
|
+
private backgroundScriptCounter = 0;
|
|
749
|
+
/** Ephemeral per-agent override of the tool-result inline cap (chars).
|
|
750
|
+
* Set via agent_settings `tool_result_inline_max_chars`; NOT persisted —
|
|
751
|
+
* the lift is meant as a temporary gate, reverts on restart/reset. */
|
|
752
|
+
private toolResultInlineMaxCharsOverride: Map<string, number> = new Map();
|
|
649
753
|
|
|
650
754
|
private mcplTools: import('./types/index.js').ToolDefinition[] = [];
|
|
651
755
|
private mcplToolRefreshInFlight = false;
|
|
@@ -722,7 +826,12 @@ export class AgentFramework {
|
|
|
722
826
|
queryMessages: (filter) => this.queryMessages(filter),
|
|
723
827
|
pushEvent: (event) => this.pushEvent(event),
|
|
724
828
|
onTrace: (listener) => this.onTrace(listener),
|
|
725
|
-
|
|
829
|
+
// The ONLY entry that confers module origin — a private closure the
|
|
830
|
+
// framework hands to its own module registry. The public
|
|
831
|
+
// executeToolCall() is shared with model/ephemeral callers and always
|
|
832
|
+
// stamps agent origin (see there).
|
|
833
|
+
callTool: (call) => this.executeToolCallFrom(call, { kind: 'module' }),
|
|
834
|
+
notifyOps: (kind, agentName, message, data) => this.notifyOps(kind, agentName, message, data),
|
|
726
835
|
});
|
|
727
836
|
}
|
|
728
837
|
|
|
@@ -1017,6 +1126,17 @@ export class AgentFramework {
|
|
|
1017
1126
|
runner.dispose();
|
|
1018
1127
|
}
|
|
1019
1128
|
this.codeExecutionRunners.clear();
|
|
1129
|
+
// Background daemons die with the process (documented limitation) —
|
|
1130
|
+
// mark cancelled FIRST so their settle path stays silent (no crash-wake
|
|
1131
|
+
// into a framework that is shutting down).
|
|
1132
|
+
for (const record of this.backgroundScripts.values()) {
|
|
1133
|
+
if (record.status === 'running') {
|
|
1134
|
+
record.cancelled = true;
|
|
1135
|
+
record.status = 'cancelled';
|
|
1136
|
+
}
|
|
1137
|
+
record.runner.dispose();
|
|
1138
|
+
}
|
|
1139
|
+
this.backgroundScripts.clear();
|
|
1020
1140
|
|
|
1021
1141
|
// Cancel all active streams
|
|
1022
1142
|
for (const agent of this.agents.values()) {
|
|
@@ -1308,6 +1428,8 @@ export class AgentFramework {
|
|
|
1308
1428
|
: [];
|
|
1309
1429
|
return [
|
|
1310
1430
|
...moduleTools,
|
|
1431
|
+
// Utilities collapse into one slot; absent entirely when none exist.
|
|
1432
|
+
...(this.moduleRegistry.getAllUtilities().length > 0 ? [UTILS_TOOL] : []),
|
|
1311
1433
|
...this.mcplTools,
|
|
1312
1434
|
...channelTools,
|
|
1313
1435
|
...gateTools,
|
|
@@ -1346,6 +1468,15 @@ export class AgentFramework {
|
|
|
1346
1468
|
private collectAgentSettingsExtensions(): Map<string, AgentSettingsExtension> {
|
|
1347
1469
|
const result = new Map<string, AgentSettingsExtension>();
|
|
1348
1470
|
const taken = new Set<string>(AgentFramework.AGENT_SETTINGS_CORE_KEYS);
|
|
1471
|
+
// Framework-owned extension: the tool-result inline cap lift. Registered
|
|
1472
|
+
// through the same extension surface modules use so get/update/reset all
|
|
1473
|
+
// work with zero extra plumbing. EPHEMERAL by design (a temporary gate —
|
|
1474
|
+
// reverts on restart), unlike module extensions which persist their own.
|
|
1475
|
+
{
|
|
1476
|
+
const ext = this.spillSettingsExtension();
|
|
1477
|
+
ext.keys.forEach((k) => taken.add(k));
|
|
1478
|
+
result.set('_framework', ext);
|
|
1479
|
+
}
|
|
1349
1480
|
for (const module of this.moduleRegistry.getAllModules()) {
|
|
1350
1481
|
const ext = module.getAgentSettingsExtension?.();
|
|
1351
1482
|
if (!ext) continue;
|
|
@@ -1362,6 +1493,42 @@ export class AgentFramework {
|
|
|
1362
1493
|
return result;
|
|
1363
1494
|
}
|
|
1364
1495
|
|
|
1496
|
+
/** agent_settings extension for the spill gate (see spillOrTruncate). */
|
|
1497
|
+
private spillSettingsExtension(): AgentSettingsExtension {
|
|
1498
|
+
return {
|
|
1499
|
+
properties: {
|
|
1500
|
+
tool_result_inline_max_chars: {
|
|
1501
|
+
type: 'number',
|
|
1502
|
+
description:
|
|
1503
|
+
'Inline size cap (chars) for tool results and background-script wake payloads. ' +
|
|
1504
|
+
'Content over the cap is written to a workspace file under tool-results/ and ' +
|
|
1505
|
+
'replaced by a truncated preview + file reference. Raise it temporarily when you ' +
|
|
1506
|
+
'genuinely want a large result inline; reset restores the default (derived from ' +
|
|
1507
|
+
'your strategy). Ephemeral — reverts on host restart.',
|
|
1508
|
+
},
|
|
1509
|
+
},
|
|
1510
|
+
keys: ['tool_result_inline_max_chars'],
|
|
1511
|
+
get: (agentName: string) => ({
|
|
1512
|
+
tool_result_inline_max_chars:
|
|
1513
|
+
this.toolResultInlineMaxCharsOverride.get(agentName) ?? null,
|
|
1514
|
+
}),
|
|
1515
|
+
update: (agentName: string, patch: Record<string, unknown>) => {
|
|
1516
|
+
const n = Number(patch.tool_result_inline_max_chars);
|
|
1517
|
+
if (!Number.isFinite(n) || n < 1000) {
|
|
1518
|
+
throw new Error('tool_result_inline_max_chars must be a number >= 1000');
|
|
1519
|
+
}
|
|
1520
|
+
this.toolResultInlineMaxCharsOverride.set(agentName, Math.floor(n));
|
|
1521
|
+
return { tool_result_inline_max_chars: Math.floor(n) };
|
|
1522
|
+
},
|
|
1523
|
+
reset: (agentName: string, keys?: string[]) => {
|
|
1524
|
+
if (!keys || keys.includes('tool_result_inline_max_chars')) {
|
|
1525
|
+
this.toolResultInlineMaxCharsOverride.delete(agentName);
|
|
1526
|
+
}
|
|
1527
|
+
return { tool_result_inline_max_chars: null };
|
|
1528
|
+
},
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1365
1532
|
private captureInferenceToolSnapshot(agent: Agent): InferenceToolSnapshot {
|
|
1366
1533
|
return {
|
|
1367
1534
|
sameRoundThinkTextPolicy: agent.getEffectiveSameRoundThinkTextPolicy(),
|
|
@@ -1972,6 +2139,12 @@ export class AgentFramework {
|
|
|
1972
2139
|
// ephemeral agents leave checkpoint-tree keys and diagnostics map
|
|
1973
2140
|
// entries behind for the life of the store/session.
|
|
1974
2141
|
this.evictTurnCheckpoints(agent.name);
|
|
2142
|
+
// Turn-alive marker, same spawn-and-dispose logic: a stalled ephemeral
|
|
2143
|
+
// (watchdog fired, driveStream's terminal event lost) would strand its
|
|
2144
|
+
// token forever — no wedge (ephemeral names are unique per run) but
|
|
2145
|
+
// unbounded map growth. Blind delete is safe: the agent is already out
|
|
2146
|
+
// of the map, and a late driveStream finally token-match no-ops.
|
|
2147
|
+
this.activeTurnTokens.delete(agent.name);
|
|
1975
2148
|
}
|
|
1976
2149
|
}
|
|
1977
2150
|
|
|
@@ -3446,6 +3619,23 @@ export class AgentFramework {
|
|
|
3446
3619
|
// Compute truncation limit from agent's strategy (maxMessageTokens * 4 chars)
|
|
3447
3620
|
const maxChars = this.getMaxToolResultChars(agent);
|
|
3448
3621
|
|
|
3622
|
+
// Oversized results spill to a workspace file (truncated preview +
|
|
3623
|
+
// file reference) instead of being blind-truncated. Computed ONCE
|
|
3624
|
+
// per call and reused for both the history copy and the live wire
|
|
3625
|
+
// copy below — the two must stay byte-matched (the window stores
|
|
3626
|
+
// what the membrane sends; divergence breaks the compile prefix).
|
|
3627
|
+
const spilled = new Map<string, string>();
|
|
3628
|
+
const dateLabel = new Date().toISOString().slice(0, 10);
|
|
3629
|
+
for (const tc of currentState.toolResults) {
|
|
3630
|
+
if (tc.result.isError) continue;
|
|
3631
|
+
spilled.set(tc.id, await this.spillOrTruncate(
|
|
3632
|
+
toolResultDataToHistoryString(tc.result.data, undefined),
|
|
3633
|
+
maxChars,
|
|
3634
|
+
`${dateLabel}-${tc.id}`,
|
|
3635
|
+
agent.name,
|
|
3636
|
+
));
|
|
3637
|
+
}
|
|
3638
|
+
|
|
3449
3639
|
// Store tool results as a user message (tool_result blocks).
|
|
3450
3640
|
// Use the history serializer so MCP image blocks become a short
|
|
3451
3641
|
// `[image: type, size]` placeholder instead of megabytes of base64
|
|
@@ -3455,7 +3645,7 @@ export class AgentFramework {
|
|
|
3455
3645
|
toolUseId: tc.id,
|
|
3456
3646
|
content: tc.result.isError
|
|
3457
3647
|
? tc.result.error ?? 'Unknown error'
|
|
3458
|
-
: toolResultDataToHistoryString(tc.result.data, maxChars),
|
|
3648
|
+
: spilled.get(tc.id) ?? toolResultDataToHistoryString(tc.result.data, maxChars),
|
|
3459
3649
|
isError: tc.result.isError,
|
|
3460
3650
|
}));
|
|
3461
3651
|
agent.getContextManager().addMessage('user', toolResultContent);
|
|
@@ -3529,6 +3719,73 @@ export class AgentFramework {
|
|
|
3529
3719
|
&& agent.lastStreamInputTokens > 0
|
|
3530
3720
|
&& agent.lastStreamInputTokens > agent.maxStreamTokens;
|
|
3531
3721
|
|
|
3722
|
+
// Addressed re-pin (2026-07-31 Mythos misroutes, antra-ratified):
|
|
3723
|
+
// when someone ADDRESSES the agent mid-turn from another channel
|
|
3724
|
+
// (mention / reply / DM — the same chat:addressed signal the
|
|
3725
|
+
// turn-START batch policy prefers), the agent's subsequent plain
|
|
3726
|
+
// prose almost always answers THEM. Six recorded misroutes
|
|
3727
|
+
// delivered those answers to the stale frozen locus instead (the
|
|
3728
|
+
// LabClaude answer into #hospital_commons mid-examination; the
|
|
3729
|
+
// hospital scene into antra's DM). Re-pin the turn locus to the
|
|
3730
|
+
// LAST addressed conversational injection, at segment granularity:
|
|
3731
|
+
// prose routed in earlier rounds already went to the old pin,
|
|
3732
|
+
// which was correct when it was delivered. Ambient chatter,
|
|
3733
|
+
// reactions, and system markers still cannot move the pin — the
|
|
3734
|
+
// 2026-07-21 Cairn hijack protection is unchanged; this extends
|
|
3735
|
+
// the ratified addressed-outranks-ambient principle from turn
|
|
3736
|
+
// start to boundaries. Locus mode only (explicit mode has no pin).
|
|
3737
|
+
// Skipped on endTurn/overBudget: no further prose this stream, and
|
|
3738
|
+
// a stale "for this turn" notice would be pure noise.
|
|
3739
|
+
//
|
|
3740
|
+
// The notice rides the SAME injection batch — stored right after
|
|
3741
|
+
// the flushed messages and appended to midTurnInjections — so the
|
|
3742
|
+
// window byte-matches the wire (short event-style notice: the
|
|
3743
|
+
// classifier-safe D-class from the 2026-07-24 ablation). It also
|
|
3744
|
+
// updates lastAnnouncedLocus so the next turn's announce-on-change
|
|
3745
|
+
// diffs against what the agent was actually last told.
|
|
3746
|
+
if (
|
|
3747
|
+
agent.proseRouting !== 'explicit' &&
|
|
3748
|
+
!shouldEndTurn && !overBudget && currentState.stream
|
|
3749
|
+
) {
|
|
3750
|
+
const lastAddressed = [...midTurnInjections].reverse().find((inj) => {
|
|
3751
|
+
const m = inj.metadata as Record<string, unknown> | undefined;
|
|
3752
|
+
return (
|
|
3753
|
+
isConversationalInjection(inj.metadata) &&
|
|
3754
|
+
typeof m?.channelId === 'string' &&
|
|
3755
|
+
isAddressedMessage(m.tags as string[] | undefined, m)
|
|
3756
|
+
);
|
|
3757
|
+
});
|
|
3758
|
+
const newLocus = (lastAddressed?.metadata as Record<string, unknown> | undefined)
|
|
3759
|
+
?.channelId as string | undefined;
|
|
3760
|
+
if (newLocus && this.turnLocusPins.get(agent.name) !== newLocus) {
|
|
3761
|
+
const prevPin = this.turnLocusPins.get(agent.name) ?? null;
|
|
3762
|
+
this.turnLocusPins.set(agent.name, newLocus);
|
|
3763
|
+
this.lastAnnouncedLocus.set(agent.name, newLocus);
|
|
3764
|
+
const label = this.channelRegistry?.getDescriptor(newLocus)?.label;
|
|
3765
|
+
const shown = label && label !== newLocus
|
|
3766
|
+
? `${label.startsWith('#') ? label : `#${label}`} (${newLocus})`
|
|
3767
|
+
: newLocus;
|
|
3768
|
+
const noticeContent: ContentBlock[] = [{
|
|
3769
|
+
type: 'text',
|
|
3770
|
+
text:
|
|
3771
|
+
`[routing] You were just addressed from ${shown} — your plain ` +
|
|
3772
|
+
'speech now lands there for the rest of this turn. Other ' +
|
|
3773
|
+
'channels need an explicit send tool.',
|
|
3774
|
+
}];
|
|
3775
|
+
const noticeMeta = { system: true, kind: 'routing-notice' } as MessageMetadata;
|
|
3776
|
+
try {
|
|
3777
|
+
const id = agent.getContextManager().addMessage('user', noticeContent, noticeMeta);
|
|
3778
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
|
|
3779
|
+
midTurnInjections.push({ participant: 'user', content: noticeContent, metadata: noticeMeta });
|
|
3780
|
+
console.error(
|
|
3781
|
+
`[routing] ${agent.name}: mid-turn addressed re-pin ${prevPin ?? '(none)'} -> ${newLocus}`,
|
|
3782
|
+
);
|
|
3783
|
+
} catch (err) {
|
|
3784
|
+
console.error('mid-turn locus re-pin: failed to record routing notice:', err);
|
|
3785
|
+
}
|
|
3786
|
+
}
|
|
3787
|
+
}
|
|
3788
|
+
|
|
3532
3789
|
if (shouldEndTurn) {
|
|
3533
3790
|
// endTurn: messages already stored above, cancel stream, reset to idle.
|
|
3534
3791
|
if (currentState.stream) {
|
|
@@ -3553,6 +3810,16 @@ export class AgentFramework {
|
|
|
3553
3810
|
// endTurn above).
|
|
3554
3811
|
if (currentState.stream) {
|
|
3555
3812
|
this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
|
|
3813
|
+
// Cancel the LIVE stream object directly (like the endTurn
|
|
3814
|
+
// branch above): at a tool boundary the state machine reads
|
|
3815
|
+
// 'ready', and Agent.cancelStream only signals the stream from
|
|
3816
|
+
// 'streaming'/'waiting_for_tools' — so the old call left the
|
|
3817
|
+
// provider stream parked forever awaiting tool results that
|
|
3818
|
+
// would never come (zombie: driveStream's finally never ran,
|
|
3819
|
+
// its activeStreams entry survived until the restart's stream
|
|
3820
|
+
// overwrote it). Harmless by accident before turn-alive
|
|
3821
|
+
// tracking; a genuine teardown leak after it.
|
|
3822
|
+
currentState.stream.cancel();
|
|
3556
3823
|
}
|
|
3557
3824
|
agent.cancelStream();
|
|
3558
3825
|
this.emitTrace({
|
|
@@ -3574,7 +3841,7 @@ export class AgentFramework {
|
|
|
3574
3841
|
// messages (membrane ≥0.5.72) — appended after the tool_result
|
|
3575
3842
|
// envelope so the next round of THIS turn hears them.
|
|
3576
3843
|
const membraneResults = currentState.toolResults.map(tc =>
|
|
3577
|
-
this.toMembraneToolResult(tc.id, tc.result, maxChars)
|
|
3844
|
+
this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id))
|
|
3578
3845
|
);
|
|
3579
3846
|
currentState.stream.provideToolResults(
|
|
3580
3847
|
membraneResults,
|
|
@@ -4292,8 +4559,24 @@ export class AgentFramework {
|
|
|
4292
4559
|
continue;
|
|
4293
4560
|
}
|
|
4294
4561
|
|
|
4295
|
-
// Skip if agent is busy (inferring, streaming, or waiting for tools)
|
|
4296
|
-
if
|
|
4562
|
+
// Skip if agent is busy (inferring, streaming, or waiting for tools) —
|
|
4563
|
+
// or if a turn is alive at all (activeTurnTokens): the state machine
|
|
4564
|
+
// goes idle at endTurn's reset while driveStream teardown is still
|
|
4565
|
+
// pending, and a successor turn dequeued in that gap compiles while the
|
|
4566
|
+
// predecessor's deferred flush can still write (2026-07-31 Mythos).
|
|
4567
|
+
// Turn-alive spans dequeue → settled teardown; wakes wait it out.
|
|
4568
|
+
//
|
|
4569
|
+
// EXCEPT a context-budget restart: it CONTINUES the turn whose token is
|
|
4570
|
+
// held — queueing it behind its own turn's teardown is a deadlock when
|
|
4571
|
+
// that teardown lags (the cancelled stream's `aborted` event is
|
|
4572
|
+
// asynchronous and, historically, not even guaranteed — see the
|
|
4573
|
+
// overBudget branch). The restart's startAgentStream overwrites the
|
|
4574
|
+
// token; the predecessor frame's late finally no-ops via token-match.
|
|
4575
|
+
// This mirrors how the restart has always overwritten activeStreams
|
|
4576
|
+
// rather than waiting for the old stream's teardown.
|
|
4577
|
+
const isBudgetRestart = requests.some((r) => r.reason === 'context_budget_restart');
|
|
4578
|
+
const turnAlive = !isBudgetRestart && this.activeTurnTokens.has(agentName);
|
|
4579
|
+
if (turnAlive || agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
|
|
4297
4580
|
// Re-queue requests, but warn if they've been pending too long
|
|
4298
4581
|
const oldest = Math.min(...requests.map(r => r.timestamp));
|
|
4299
4582
|
if (
|
|
@@ -4305,15 +4588,22 @@ export class AgentFramework {
|
|
|
4305
4588
|
// (up to 100/sec after the scheduler backoff), adding avoidable work
|
|
4306
4589
|
// precisely while the agent was already under pressure.
|
|
4307
4590
|
this.staleWarnAt.set(agentName, now);
|
|
4591
|
+
// Surface turn-alive separately from the state machine: status can
|
|
4592
|
+
// read 'idle' while a turn's teardown is pending, and a LEAKED turn
|
|
4593
|
+
// token would look exactly like this — permanently requeued wakes.
|
|
4594
|
+
// This line is the wedge's tell (idle+turn-alive, forever).
|
|
4595
|
+
const shownStatus = turnAlive && agent.state.status === 'idle'
|
|
4596
|
+
? 'idle+turn-alive'
|
|
4597
|
+
: agent.state.status;
|
|
4308
4598
|
this.emitTrace({
|
|
4309
4599
|
type: 'inference:request_stale',
|
|
4310
4600
|
agentName,
|
|
4311
|
-
agentStatus:
|
|
4601
|
+
agentStatus: shownStatus,
|
|
4312
4602
|
requestCount: requests.length,
|
|
4313
4603
|
oldestRequestAge: now - oldest,
|
|
4314
4604
|
});
|
|
4315
4605
|
console.error(
|
|
4316
|
-
`[inference-stale] agent=${agentName} busy (${
|
|
4606
|
+
`[inference-stale] agent=${agentName} busy (${shownStatus}) — ` +
|
|
4317
4607
|
`${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`,
|
|
4318
4608
|
);
|
|
4319
4609
|
}
|
|
@@ -4637,6 +4927,85 @@ export class AgentFramework {
|
|
|
4637
4927
|
}
|
|
4638
4928
|
|
|
4639
4929
|
private async startAgentStream(agent: Agent, trigger?: InferenceRequest, attempt = 0): Promise<void> {
|
|
4930
|
+
// Mark the turn alive before ANYTHING awaits (hooks, compile, stream
|
|
4931
|
+
// setup): from here until this turn's teardown, cross-turn writers defer
|
|
4932
|
+
// (addMessage guard) instead of appending — nothing may enter the window
|
|
4933
|
+
// between a turn's dequeue and its settle except the turn's own blocks.
|
|
4934
|
+
// A retry re-enters with a fresh token; teardown clears token-matched.
|
|
4935
|
+
//
|
|
4936
|
+
// The try/finally is the leak-proofing: a token whose owner never clears
|
|
4937
|
+
// it permanently requeues every wake for the agent (the 'idle+turn-alive'
|
|
4938
|
+
// wedge — worse than any single lost turn). Ownership is handed off
|
|
4939
|
+
// exactly once, to driveStream, the moment the stream handle is
|
|
4940
|
+
// registered; on every other exit — including a throw from the turn-start
|
|
4941
|
+
// flush, recordTurnCheckpoint, or locus resolution, none of which the
|
|
4942
|
+
// inner catch covers (ENOSPC-class store writes live there) — the finally
|
|
4943
|
+
// clears our own token. Token-matched, so when a retry frame has replaced
|
|
4944
|
+
// the token, or the terminal-failure branch already deleted it, this
|
|
4945
|
+
// no-ops instead of clobbering a successor's marker.
|
|
4946
|
+
const turnToken = this.nextTurnToken++;
|
|
4947
|
+
this.activeTurnTokens.set(agent.name, turnToken);
|
|
4948
|
+
let tokenHandedOff = false;
|
|
4949
|
+
try {
|
|
4950
|
+
tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken);
|
|
4951
|
+
} finally {
|
|
4952
|
+
if (!tokenHandedOff && this.activeTurnTokens.get(agent.name) === turnToken) {
|
|
4953
|
+
this.activeTurnTokens.delete(agent.name);
|
|
4954
|
+
}
|
|
4955
|
+
}
|
|
4956
|
+
}
|
|
4957
|
+
|
|
4958
|
+
/**
|
|
4959
|
+
* Body of startAgentStream, split out so the caller's try/finally owns the
|
|
4960
|
+
* turn token unconditionally. Returns true iff the token was handed off to
|
|
4961
|
+
* driveStream (whose finally then owns clearing it).
|
|
4962
|
+
*/
|
|
4963
|
+
private async beginAgentTurn(
|
|
4964
|
+
agent: Agent,
|
|
4965
|
+
trigger: InferenceRequest | undefined,
|
|
4966
|
+
attempt: number,
|
|
4967
|
+
turnToken: number,
|
|
4968
|
+
): Promise<boolean> {
|
|
4969
|
+
// Flush messages deferred during the PREVIOUS turn — before the
|
|
4970
|
+
// checkpoint, the locus announcement, and the compile — so a turn started
|
|
4971
|
+
// by a queued wake actually CONTAINS the message that woke it. (2026-07-31
|
|
4972
|
+
// Mythos: a DM's wake fired a turn whose compile ran before the deferred
|
|
4973
|
+
// DM flushed; the model saw only the routing-shift notice, reasonably
|
|
4974
|
+
// skipped, and the DM then landed before the skip in the window — false
|
|
4975
|
+
// "saw it and ignored it" history plus KV divergence. Flushed here, the
|
|
4976
|
+
// payload rides its own wake, and the notice/payload can never split.)
|
|
4977
|
+
// Primary agent only — deferred messages are framework-level, meant for
|
|
4978
|
+
// the main conversation (same target rule as the tool-boundary flush).
|
|
4979
|
+
// Fresh turns only: a context-budget restart continues the same logical
|
|
4980
|
+
// turn, and flushing there would insert messages between its rounds.
|
|
4981
|
+
// Before recordTurnCheckpoint: these are the turn's inputs, not its
|
|
4982
|
+
// products — undoing the turn must not destroy them.
|
|
4983
|
+
if (
|
|
4984
|
+
attempt === 0 &&
|
|
4985
|
+
trigger?.reason !== 'context_budget_restart' &&
|
|
4986
|
+
this.deferredMessages.length > 0
|
|
4987
|
+
) {
|
|
4988
|
+
const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
|
|
4989
|
+
if (target === agent) {
|
|
4990
|
+
const deferred = this.deferredMessages.splice(0);
|
|
4991
|
+
for (const msg of deferred) {
|
|
4992
|
+
// Per-message try/catch (mirrors announceLocusIfChanged): one
|
|
4993
|
+
// poison message — or a transient store-write failure — must not
|
|
4994
|
+
// abort the turn or drop the messages behind it in the queue.
|
|
4995
|
+
try {
|
|
4996
|
+
const id = agent.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
|
|
4997
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'deferred-flush:turn-start' });
|
|
4998
|
+
} catch (err) {
|
|
4999
|
+
console.error(
|
|
5000
|
+
`[deferred-flush] turn-start flush failed to store a message for ${agent.name} ` +
|
|
5001
|
+
`(participant=${msg.participant}):`,
|
|
5002
|
+
err,
|
|
5003
|
+
);
|
|
5004
|
+
}
|
|
5005
|
+
}
|
|
5006
|
+
}
|
|
5007
|
+
}
|
|
5008
|
+
|
|
4640
5009
|
// Record turn checkpoint before inference (only on first attempt, not retries)
|
|
4641
5010
|
if (attempt === 0) {
|
|
4642
5011
|
this.recordTurnCheckpoint(agent.name);
|
|
@@ -4777,6 +5146,9 @@ export class AgentFramework {
|
|
|
4777
5146
|
compiledRequest,
|
|
4778
5147
|
);
|
|
4779
5148
|
this.activeStreams.set(agent.name, handle);
|
|
5149
|
+
// Handoff: driveStream captured the token in its synchronous prefix;
|
|
5150
|
+
// its finally now owns clearing it. The caller's finally must not.
|
|
5151
|
+
return true;
|
|
4780
5152
|
} catch (error) {
|
|
4781
5153
|
// The early typing indicator has no driveStream `finally` on this path —
|
|
4782
5154
|
// a hook/compile/stream-setup failure must not leave "typing…" stuck.
|
|
@@ -4794,8 +5166,18 @@ export class AgentFramework {
|
|
|
4794
5166
|
const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
|
|
4795
5167
|
if (action.retry) {
|
|
4796
5168
|
await new Promise((resolve) => setTimeout(resolve, action.delayMs));
|
|
5169
|
+
// The retry re-enters startAgentStream, which replaces this frame's
|
|
5170
|
+
// turn token with its own; cleanup belongs to the innermost frame.
|
|
4797
5171
|
await this.startAgentStream(agent, trigger, attempt + 1);
|
|
4798
5172
|
} else {
|
|
5173
|
+
// Terminal failure with no driveStream: this frame owns teardown.
|
|
5174
|
+
// Token-matched (like the finally in driveStream) so a stale frame
|
|
5175
|
+
// can never clear a successor turn's marker. A leaked token would
|
|
5176
|
+
// permanently requeue every wake for this agent (same wedge shape as
|
|
5177
|
+
// the eventGate `inferring` leak) — every exit path must clear it.
|
|
5178
|
+
if (this.activeTurnTokens.get(agent.name) === turnToken) {
|
|
5179
|
+
this.activeTurnTokens.delete(agent.name);
|
|
5180
|
+
}
|
|
4799
5181
|
this.settleAgent(agent.name, {
|
|
4800
5182
|
stopReason: 'exhausted',
|
|
4801
5183
|
speech: '',
|
|
@@ -4819,6 +5201,12 @@ export class AgentFramework {
|
|
|
4819
5201
|
this.pushEvent(action.emit);
|
|
4820
5202
|
}
|
|
4821
5203
|
}
|
|
5204
|
+
// No handoff on either failure branch: the terminal branch deleted the
|
|
5205
|
+
// token above; the retry branch's recursive startAgentStream replaced
|
|
5206
|
+
// it with the inner frame's token. Returning false lets the caller's
|
|
5207
|
+
// token-matched finally no-op in both cases (and actually clear it if
|
|
5208
|
+
// something above threw before either branch ran).
|
|
5209
|
+
return false;
|
|
4822
5210
|
}
|
|
4823
5211
|
}
|
|
4824
5212
|
|
|
@@ -4833,6 +5221,10 @@ export class AgentFramework {
|
|
|
4833
5221
|
const startTime = Date.now();
|
|
4834
5222
|
const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
|
|
4835
5223
|
const myStreamId = agent.streamId;
|
|
5224
|
+
// This turn's alive-marker, set at startAgentStream entry. Safe to read
|
|
5225
|
+
// from the map here: the turn-alive busy check in processInferenceRequests
|
|
5226
|
+
// means no successor turn can have replaced it while we compiled.
|
|
5227
|
+
const myTurnToken = this.activeTurnTokens.get(agent.name);
|
|
4836
5228
|
let hadToolCalls = false;
|
|
4837
5229
|
|
|
4838
5230
|
// ---- Present-while-acting turn state ---------------------------------
|
|
@@ -5717,6 +6109,17 @@ export class AgentFramework {
|
|
|
5717
6109
|
this.disposeConversationAgent(agent.name);
|
|
5718
6110
|
}
|
|
5719
6111
|
|
|
6112
|
+
// The turn is torn down — release the turn-alive marker BEFORE the
|
|
6113
|
+
// deferred flush below, so the flush appends at the settled tail.
|
|
6114
|
+
// Token-matched: if a successor turn already owns the agent (endTurn
|
|
6115
|
+
// reset the state machine before this finally ran — the 2026-07-31
|
|
6116
|
+
// interleave), leave its marker alone; the guarded addMessage below
|
|
6117
|
+
// then re-defers, and the successor's own turn-start flush / boundary
|
|
6118
|
+
// injection / teardown delivers the messages at a correct position.
|
|
6119
|
+
if (myTurnToken !== undefined && this.activeTurnTokens.get(agent.name) === myTurnToken) {
|
|
6120
|
+
this.activeTurnTokens.delete(agent.name);
|
|
6121
|
+
}
|
|
6122
|
+
|
|
5720
6123
|
// Flush any deferred messages (e.g. if stream failed while tools were pending)
|
|
5721
6124
|
if (this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
|
|
5722
6125
|
const deferred = this.deferredMessages.splice(0);
|
|
@@ -5731,8 +6134,23 @@ export class AgentFramework {
|
|
|
5731
6134
|
* Execute a tool call and return the result.
|
|
5732
6135
|
* Routes to the appropriate handler (module registry or MCPL).
|
|
5733
6136
|
* Used by SubagentModule to dispatch tool calls for ephemeral agents.
|
|
6137
|
+
*
|
|
6138
|
+
* This public entry always carries AGENT origin. Ephemeral/promise-based
|
|
6139
|
+
* callers are models, and provenance must identify which internal path a
|
|
6140
|
+
* call entered through — a public method shared with model callers can
|
|
6141
|
+
* never confer module trust, whatever the input claims. Module origin
|
|
6142
|
+
* exists only through the private closure handed to ModuleRegistry
|
|
6143
|
+
* (ctx.callTool → executeToolCallFrom with {kind:'module'}). Callers that
|
|
6144
|
+
* don't identify themselves fail safe to agent semantics too.
|
|
5734
6145
|
*/
|
|
5735
6146
|
async executeToolCall(call: ToolCall): Promise<ToolResult> {
|
|
6147
|
+
return this.executeToolCallFrom(call, {
|
|
6148
|
+
kind: 'agent',
|
|
6149
|
+
agentName: call.callerAgentName ?? '__ephemeral__',
|
|
6150
|
+
});
|
|
6151
|
+
}
|
|
6152
|
+
|
|
6153
|
+
private async executeToolCallFrom(call: ToolCall, origin: ChannelToolOrigin): Promise<ToolResult> {
|
|
5736
6154
|
// Client-side programmatic tool calling for promise-based callers
|
|
5737
6155
|
// (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral
|
|
5738
6156
|
// gets its own interpreter state.
|
|
@@ -5753,6 +6171,27 @@ export class AgentFramework {
|
|
|
5753
6171
|
}
|
|
5754
6172
|
}
|
|
5755
6173
|
|
|
6174
|
+
// Synthesized channel tools. This route was missing: when
|
|
6175
|
+
// subscription-GC moved off the retired MCPL unsubscribe tool onto the
|
|
6176
|
+
// generic channel_close (discord-mcpl b095a9f), module-originated
|
|
6177
|
+
// closes started failing downstream as "Invalid tool name format" —
|
|
6178
|
+
// the janitor had been silently broken since. `origin` is trusted
|
|
6179
|
+
// dispatch context established at the call boundary above, not
|
|
6180
|
+
// inferred from which method was used: only the ModuleRegistry closure
|
|
6181
|
+
// carries module provenance (see handleToolClose). The agent-only
|
|
6182
|
+
// synthesized verbs (think, skip_reply) are deliberately not routed
|
|
6183
|
+
// here.
|
|
6184
|
+
if (call.name.startsWith('channel_') && this.channelRegistry) {
|
|
6185
|
+
return this.channelRegistry.handleChannelToolCall(call.name, call.input, origin);
|
|
6186
|
+
}
|
|
6187
|
+
|
|
6188
|
+
// The utils meta-tool — programmatic callers (code_execution scripts,
|
|
6189
|
+
// ephemerals, ModuleContext.callTool) reach utilities the same way the
|
|
6190
|
+
// model does.
|
|
6191
|
+
if (call.name === 'utils') {
|
|
6192
|
+
return this.handleUtilsToolCall(call.callerAgentName ?? '__ephemeral__', call);
|
|
6193
|
+
}
|
|
6194
|
+
|
|
5756
6195
|
// Module tools
|
|
5757
6196
|
return this.moduleRegistry.handleToolCall(call);
|
|
5758
6197
|
}
|
|
@@ -5849,7 +6288,51 @@ export class AgentFramework {
|
|
|
5849
6288
|
* results reach the running script and never the model context.
|
|
5850
6289
|
*/
|
|
5851
6290
|
private async runCodeExecution(agentName: string, call: ToolCall): Promise<ToolResult> {
|
|
5852
|
-
const input = (call.input ?? {}) as {
|
|
6291
|
+
const input = (call.input ?? {}) as {
|
|
6292
|
+
code?: unknown;
|
|
6293
|
+
background?: unknown;
|
|
6294
|
+
action?: unknown;
|
|
6295
|
+
script_id?: unknown;
|
|
6296
|
+
};
|
|
6297
|
+
|
|
6298
|
+
// Management surface: the agent's own daemon fleet is inspectable and
|
|
6299
|
+
// killable through the same tool that spawns it.
|
|
6300
|
+
if (input.action === 'list') {
|
|
6301
|
+
const scripts = [...this.backgroundScripts.values()]
|
|
6302
|
+
.filter((s) => s.agentName === agentName)
|
|
6303
|
+
.map((s) => ({
|
|
6304
|
+
script_id: s.id,
|
|
6305
|
+
status: s.status,
|
|
6306
|
+
started_at: new Date(s.startedAt).toISOString(),
|
|
6307
|
+
wakes: s.wakes,
|
|
6308
|
+
last_wake_at: s.lastWakeAt ? new Date(s.lastWakeAt).toISOString() : null,
|
|
6309
|
+
log: s.logPath,
|
|
6310
|
+
}));
|
|
6311
|
+
return { success: true, data: { background_scripts: scripts } };
|
|
6312
|
+
}
|
|
6313
|
+
if (input.action === 'cancel') {
|
|
6314
|
+
if (typeof input.script_id !== 'string') {
|
|
6315
|
+
return { success: false, error: 'cancel requires `script_id`', isError: true };
|
|
6316
|
+
}
|
|
6317
|
+
const record = this.backgroundScripts.get(input.script_id);
|
|
6318
|
+
if (!record || record.agentName !== agentName) {
|
|
6319
|
+
return { success: false, error: `no background script '${input.script_id}'`, isError: true };
|
|
6320
|
+
}
|
|
6321
|
+
if (record.status === 'running') {
|
|
6322
|
+
record.cancelled = true;
|
|
6323
|
+
record.status = 'cancelled';
|
|
6324
|
+
record.runner.abort('cancelled by agent');
|
|
6325
|
+
record.runner.dispose();
|
|
6326
|
+
}
|
|
6327
|
+
return {
|
|
6328
|
+
success: true,
|
|
6329
|
+
data: { script_id: record.id, status: record.status, log: record.logPath },
|
|
6330
|
+
};
|
|
6331
|
+
}
|
|
6332
|
+
if (input.action !== undefined && input.action !== 'run') {
|
|
6333
|
+
return { success: false, error: `unknown action ${JSON.stringify(input.action)}`, isError: true };
|
|
6334
|
+
}
|
|
6335
|
+
|
|
5853
6336
|
if (typeof input.code !== 'string' || input.code.trim() === '') {
|
|
5854
6337
|
return {
|
|
5855
6338
|
success: false,
|
|
@@ -5866,6 +6349,10 @@ export class AgentFramework {
|
|
|
5866
6349
|
surface.map((t) => t.name).filter((name) => name !== CODE_EXECUTION_TOOL_NAME),
|
|
5867
6350
|
);
|
|
5868
6351
|
|
|
6352
|
+
if (input.background === true) {
|
|
6353
|
+
return this.startBackgroundScript(agentName, input.code, injected);
|
|
6354
|
+
}
|
|
6355
|
+
|
|
5869
6356
|
const runner = this.getOrCreateScriptRunner(agentName);
|
|
5870
6357
|
this.scriptDeferredEndTurn.delete(agentName);
|
|
5871
6358
|
const exec = await runner.exec(input.code, injected);
|
|
@@ -5879,6 +6366,291 @@ export class AgentFramework {
|
|
|
5879
6366
|
};
|
|
5880
6367
|
}
|
|
5881
6368
|
|
|
6369
|
+
// ---------------------------------------------------------------------------
|
|
6370
|
+
// Background (daemon) scripts — model-authored watchers that outlive the turn
|
|
6371
|
+
// ---------------------------------------------------------------------------
|
|
6372
|
+
|
|
6373
|
+
/**
|
|
6374
|
+
* Start a background script: dedicated interpreter, output journaled to a
|
|
6375
|
+
* workspace file, wake_agent() available in-script. The tool result
|
|
6376
|
+
* returns immediately; the script runs until it ends, hits the lifetime
|
|
6377
|
+
* ceiling, or is cancelled. Scripts DIE WITH THE HOST PROCESS — that
|
|
6378
|
+
* limitation is stated in the tool description so agents can calibrate
|
|
6379
|
+
* how much to rely on an armed wake.
|
|
6380
|
+
*/
|
|
6381
|
+
private startBackgroundScript(
|
|
6382
|
+
agentName: string,
|
|
6383
|
+
code: string,
|
|
6384
|
+
injected: import('./code-execution/py-runner.js').InjectedTool[],
|
|
6385
|
+
): ToolResult {
|
|
6386
|
+
// v1: primary-agent-only. A conversation fork's or ephemeral's daemon
|
|
6387
|
+
// would outlive its owner and its wake would land in the primary
|
|
6388
|
+
// conversation anyway — refuse loudly instead of surprising anyone.
|
|
6389
|
+
if (this.primaryAgentName && agentName !== this.primaryAgentName) {
|
|
6390
|
+
return {
|
|
6391
|
+
success: false,
|
|
6392
|
+
error: 'background scripts are available to the primary agent only',
|
|
6393
|
+
isError: true,
|
|
6394
|
+
};
|
|
6395
|
+
}
|
|
6396
|
+
const cfg = this.codeExecutionConfig;
|
|
6397
|
+
const maxScripts = cfg?.maxBackgroundScripts ?? 3;
|
|
6398
|
+
const runningCount = [...this.backgroundScripts.values()]
|
|
6399
|
+
.filter((s) => s.agentName === agentName && s.status === 'running').length;
|
|
6400
|
+
if (runningCount >= maxScripts) {
|
|
6401
|
+
return {
|
|
6402
|
+
success: false,
|
|
6403
|
+
error: `background script limit reached (${maxScripts} running). ` +
|
|
6404
|
+
'Use code_execution {"action": "list"} to inspect and {"action": "cancel", "script_id": ...} to free a slot.',
|
|
6405
|
+
isError: true,
|
|
6406
|
+
};
|
|
6407
|
+
}
|
|
6408
|
+
|
|
6409
|
+
const scriptId = `bg-${++this.backgroundScriptCounter}`;
|
|
6410
|
+
const lifetimeMs = cfg?.backgroundMaxLifetimeMs ?? 86_400_000;
|
|
6411
|
+
|
|
6412
|
+
// Journal: a file under the agent's first read-write workspace mount so
|
|
6413
|
+
// their existing read/grep/shell tools work on it. Python appends
|
|
6414
|
+
// directly to the materialized path; watch-mode mounts sync it back to
|
|
6415
|
+
// the chronicle tree on their usual cadence.
|
|
6416
|
+
let logPath: string | null = null;
|
|
6417
|
+
let logAbsPath: string | null = null;
|
|
6418
|
+
const workspace = this.getWorkspaceModule();
|
|
6419
|
+
if (workspace) {
|
|
6420
|
+
const mountName = this.firstWritableMountName(workspace);
|
|
6421
|
+
if (mountName) {
|
|
6422
|
+
logPath = `${mountName}/background-scripts/${scriptId}.log`;
|
|
6423
|
+
logAbsPath = workspace.resolveAbsolutePath(logPath);
|
|
6424
|
+
}
|
|
6425
|
+
}
|
|
6426
|
+
|
|
6427
|
+
const runner = new PyRunner({
|
|
6428
|
+
pythonPath: cfg?.pythonPath,
|
|
6429
|
+
toolCallTimeoutMs: cfg?.toolCallTimeoutMs,
|
|
6430
|
+
scriptTimeoutMs: cfg?.scriptTimeoutMs,
|
|
6431
|
+
idleReclaimMs: 0, // dedicated runner; lifetime is the exec deadline
|
|
6432
|
+
label: `${agentName}:${scriptId}`,
|
|
6433
|
+
onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args),
|
|
6434
|
+
});
|
|
6435
|
+
|
|
6436
|
+
const record: BackgroundScriptRecord = {
|
|
6437
|
+
id: scriptId,
|
|
6438
|
+
agentName,
|
|
6439
|
+
runner,
|
|
6440
|
+
code,
|
|
6441
|
+
startedAt: Date.now(),
|
|
6442
|
+
wakes: 0,
|
|
6443
|
+
lastWakeAt: null,
|
|
6444
|
+
logPath,
|
|
6445
|
+
status: 'running',
|
|
6446
|
+
cancelled: false,
|
|
6447
|
+
};
|
|
6448
|
+
this.backgroundScripts.set(scriptId, record);
|
|
6449
|
+
this.emitTrace({
|
|
6450
|
+
type: 'tool:started', module: 'code_execution', tool: `background:${scriptId}`,
|
|
6451
|
+
callId: scriptId, input: { lines: code.split('\n').length },
|
|
6452
|
+
});
|
|
6453
|
+
|
|
6454
|
+
void runner
|
|
6455
|
+
.exec(code, injected, {
|
|
6456
|
+
logPath: logAbsPath,
|
|
6457
|
+
lifetimeMs,
|
|
6458
|
+
onWake: (line, payload) => this.handleScriptWake(record, line, payload),
|
|
6459
|
+
})
|
|
6460
|
+
.then((exec) => this.settleBackgroundScript(record, exec))
|
|
6461
|
+
.catch((err) => {
|
|
6462
|
+
// exec never rejects by contract; this is the belt-and-suspenders.
|
|
6463
|
+
console.error(`[pytc:${agentName}:${scriptId}] background exec rejected: ${String(err)}`);
|
|
6464
|
+
this.settleBackgroundScript(record, {
|
|
6465
|
+
stdout: '', stderr: String(err), returnCode: 1, aborted: true,
|
|
6466
|
+
});
|
|
6467
|
+
});
|
|
6468
|
+
|
|
6469
|
+
return {
|
|
6470
|
+
success: true,
|
|
6471
|
+
data: {
|
|
6472
|
+
status: 'background script started',
|
|
6473
|
+
script_id: scriptId,
|
|
6474
|
+
log: logPath ?? 'no writable workspace mount — output is not retrievable; only wake_agent() reaches you',
|
|
6475
|
+
lifetime_hours: Math.round(lifetimeMs / 3_600_000 * 10) / 10,
|
|
6476
|
+
note: 'The script dies if the host process restarts. Manage with code_execution {"action": "list"|"cancel"}.',
|
|
6477
|
+
},
|
|
6478
|
+
};
|
|
6479
|
+
}
|
|
6480
|
+
|
|
6481
|
+
/** First read-write mount name, or null. */
|
|
6482
|
+
private firstWritableMountName(workspace: WorkspaceModule): string | null {
|
|
6483
|
+
const mounts = (workspace as unknown as {
|
|
6484
|
+
getMounts?: () => Array<{ name: string; mode: string }>;
|
|
6485
|
+
}).getMounts?.();
|
|
6486
|
+
if (mounts) {
|
|
6487
|
+
const rw = mounts.find((m) => m.mode !== 'read-only');
|
|
6488
|
+
return rw?.name ?? null;
|
|
6489
|
+
}
|
|
6490
|
+
return null;
|
|
6491
|
+
}
|
|
6492
|
+
|
|
6493
|
+
/**
|
|
6494
|
+
* wake_agent() from a background script: enforce the per-script wake cap
|
|
6495
|
+
* and rate floor (delay, not drop — the script awaits the ack), then
|
|
6496
|
+
* inject the provenance envelope + payload and request inference.
|
|
6497
|
+
* Resolves null on delivery; an error string refuses the wake (raises
|
|
6498
|
+
* RuntimeError inside the script).
|
|
6499
|
+
*/
|
|
6500
|
+
private async handleScriptWake(
|
|
6501
|
+
record: BackgroundScriptRecord,
|
|
6502
|
+
line: number,
|
|
6503
|
+
payload: unknown,
|
|
6504
|
+
): Promise<string | null> {
|
|
6505
|
+
if (record.cancelled) return 'script was cancelled';
|
|
6506
|
+
const cfg = this.codeExecutionConfig;
|
|
6507
|
+
const maxWakes = cfg?.maxWakesPerScript ?? 100;
|
|
6508
|
+
if (record.wakes >= maxWakes) {
|
|
6509
|
+
return `wake limit reached (${maxWakes} per script)`;
|
|
6510
|
+
}
|
|
6511
|
+
const floorMs = cfg?.wakeMinIntervalMs ?? 60_000;
|
|
6512
|
+
if (record.lastWakeAt !== null) {
|
|
6513
|
+
const wait = record.lastWakeAt + floorMs - Date.now();
|
|
6514
|
+
if (wait > 0) {
|
|
6515
|
+
await new Promise((r) => {
|
|
6516
|
+
const t = setTimeout(r, wait);
|
|
6517
|
+
(t as { unref?: () => void }).unref?.();
|
|
6518
|
+
});
|
|
6519
|
+
if (record.cancelled) return 'script was cancelled';
|
|
6520
|
+
}
|
|
6521
|
+
}
|
|
6522
|
+
record.wakes += 1;
|
|
6523
|
+
record.lastWakeAt = Date.now();
|
|
6524
|
+
|
|
6525
|
+
const elapsedMin = Math.round((Date.now() - record.startedAt) / 60_000);
|
|
6526
|
+
const payloadJson = payload === undefined || payload === null
|
|
6527
|
+
? 'null'
|
|
6528
|
+
: JSON.stringify(payload, null, 1) ?? String(payload);
|
|
6529
|
+
const agent = this.agents.get(record.agentName);
|
|
6530
|
+
const maxChars = agent ? this.getMaxToolResultChars(agent) : undefined;
|
|
6531
|
+
const payloadText = await this.spillOrTruncate(
|
|
6532
|
+
payloadJson, maxChars, `${record.id}-wake${record.wakes}`,
|
|
6533
|
+
);
|
|
6534
|
+
|
|
6535
|
+
const envelope =
|
|
6536
|
+
`[background script ${record.id}] Woke you: wake_agent() called at line ${line} of your script, ` +
|
|
6537
|
+
`${elapsedMin}m after you started it (wake ${record.wakes} of ${maxWakes}).\n` +
|
|
6538
|
+
`Arguments:\n${payloadText}\n` +
|
|
6539
|
+
(record.logPath ? `Script output so far: workspace file ${record.logPath}\n` : '') +
|
|
6540
|
+
`Script status: still running`;
|
|
6541
|
+
|
|
6542
|
+
this.injectScriptWake(record, envelope);
|
|
6543
|
+
return null;
|
|
6544
|
+
}
|
|
6545
|
+
|
|
6546
|
+
/** Background script ended (clean, crash, deadline, cancel/dispose). */
|
|
6547
|
+
private settleBackgroundScript(
|
|
6548
|
+
record: BackgroundScriptRecord,
|
|
6549
|
+
exec: import('./code-execution/py-runner.js').ExecResult,
|
|
6550
|
+
): void {
|
|
6551
|
+
record.runner.dispose();
|
|
6552
|
+
if (record.status === 'running') {
|
|
6553
|
+
record.status = exec.returnCode === 0 ? 'finished' : 'died';
|
|
6554
|
+
}
|
|
6555
|
+
this.emitTrace({
|
|
6556
|
+
type: 'tool:completed', module: 'code_execution', tool: `background:${record.id}`,
|
|
6557
|
+
callId: record.id, durationMs: Date.now() - record.startedAt,
|
|
6558
|
+
});
|
|
6559
|
+
// Crash honesty: a resident sleeping on a promise must never have that
|
|
6560
|
+
// promise die silently. Deliberate cancels/disposes stay silent (the
|
|
6561
|
+
// agent or host chose them); clean exits stay silent (the null path).
|
|
6562
|
+
if (record.status === 'died' && !record.cancelled) {
|
|
6563
|
+
const tail = (exec.tail ?? exec.stderr ?? '').slice(-2000);
|
|
6564
|
+
const elapsedMin = Math.round((Date.now() - record.startedAt) / 60_000);
|
|
6565
|
+
const envelope =
|
|
6566
|
+
`[background script ${record.id}] Your background script DIED ${elapsedMin}m after start ` +
|
|
6567
|
+
`(it did not call wake_agent and is no longer watching).\n` +
|
|
6568
|
+
`Last output:\n${tail || '(none)'}\n` +
|
|
6569
|
+
(record.logPath ? `Full journal: workspace file ${record.logPath}` : '');
|
|
6570
|
+
this.injectScriptWake(record, envelope);
|
|
6571
|
+
}
|
|
6572
|
+
// Keep a short memory of settled scripts for {"action":"list"}, then drop.
|
|
6573
|
+
const settled = [...this.backgroundScripts.values()]
|
|
6574
|
+
.filter((s) => s.agentName === record.agentName && s.status !== 'running');
|
|
6575
|
+
for (const old of settled.slice(0, Math.max(0, settled.length - 5))) {
|
|
6576
|
+
this.backgroundScripts.delete(old.id);
|
|
6577
|
+
}
|
|
6578
|
+
}
|
|
6579
|
+
|
|
6580
|
+
/**
|
|
6581
|
+
* Deliver a background-script event: provenance envelope into the agent's
|
|
6582
|
+
* context + an inference request. Deliberately does NOT consult the event
|
|
6583
|
+
* gate — the agent armed this wake themselves (wake-through-sleep
|
|
6584
|
+
* authority); it enters tagged so gate policies could be taught about it
|
|
6585
|
+
* later if that ever needs revisiting.
|
|
6586
|
+
*/
|
|
6587
|
+
private injectScriptWake(record: BackgroundScriptRecord, envelope: string): void {
|
|
6588
|
+
try {
|
|
6589
|
+
const messageId = this.addMessage('user', [{ type: 'text', text: envelope }], {
|
|
6590
|
+
source: 'background-script',
|
|
6591
|
+
scriptId: record.id,
|
|
6592
|
+
tags: ['script:wake'],
|
|
6593
|
+
});
|
|
6594
|
+
this.pendingRequests.push({
|
|
6595
|
+
agentName: record.agentName,
|
|
6596
|
+
reason: 'script:wake',
|
|
6597
|
+
source: 'code_execution',
|
|
6598
|
+
timestamp: Date.now(),
|
|
6599
|
+
});
|
|
6600
|
+
this.emitTrace({
|
|
6601
|
+
type: 'message:added',
|
|
6602
|
+
messageId,
|
|
6603
|
+
source: `background-script:${record.id}`,
|
|
6604
|
+
});
|
|
6605
|
+
} catch (err) {
|
|
6606
|
+
console.error(
|
|
6607
|
+
`[pytc:${record.agentName}:${record.id}] wake injection failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
6608
|
+
);
|
|
6609
|
+
}
|
|
6610
|
+
}
|
|
6611
|
+
|
|
6612
|
+
/**
|
|
6613
|
+
* Oversized content policy (antra, 2026-07-31): never hit the agent in
|
|
6614
|
+
* the face with a huge blob, never silently destroy it either. Content
|
|
6615
|
+
* over the inline cap is materialized to a workspace file (readable with
|
|
6616
|
+
* the agent's own tools) and replaced by a truncated head + a trailing
|
|
6617
|
+
* file reference. Falls back to plain truncation when there is no
|
|
6618
|
+
* writable workspace. The cap can be lifted temporarily via
|
|
6619
|
+
* agent_settings `tool_result_inline_max_chars`.
|
|
6620
|
+
*/
|
|
6621
|
+
private async spillOrTruncate(
|
|
6622
|
+
content: string,
|
|
6623
|
+
maxChars: number | undefined,
|
|
6624
|
+
label: string,
|
|
6625
|
+
agentName?: string,
|
|
6626
|
+
): Promise<string> {
|
|
6627
|
+
const override = agentName !== undefined
|
|
6628
|
+
? this.toolResultInlineMaxCharsOverride.get(agentName)
|
|
6629
|
+
: undefined;
|
|
6630
|
+
const cap = override ?? maxChars;
|
|
6631
|
+
if (!cap || content.length <= cap) return content;
|
|
6632
|
+
|
|
6633
|
+
const workspace = this.getWorkspaceModule();
|
|
6634
|
+
const mountName = workspace ? this.firstWritableMountName(workspace) : null;
|
|
6635
|
+
if (workspace && mountName) {
|
|
6636
|
+
const safeLabel = label.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
|
|
6637
|
+
const path = `${mountName}/tool-results/${safeLabel}.txt`;
|
|
6638
|
+
try {
|
|
6639
|
+
const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain');
|
|
6640
|
+
if (result.success) {
|
|
6641
|
+
return safeSlice(content, 0, cap)
|
|
6642
|
+
+ `\n\n[truncated — showing ${cap} of ${content.length} chars; full content: workspace file ${path}. `
|
|
6643
|
+
+ 'Read/grep it with your file tools, or raise the inline cap temporarily via '
|
|
6644
|
+
+ 'agent_settings update tool_result_inline_max_chars.]';
|
|
6645
|
+
}
|
|
6646
|
+
} catch {
|
|
6647
|
+
// fall through to plain truncation
|
|
6648
|
+
}
|
|
6649
|
+
}
|
|
6650
|
+
return safeSlice(content, 0, cap)
|
|
6651
|
+
+ '\n\n[truncated — original was ' + content.length + ' chars]';
|
|
6652
|
+
}
|
|
6653
|
+
|
|
5882
6654
|
private getOrCreateScriptRunner(agentName: string): PyRunner {
|
|
5883
6655
|
let runner = this.codeExecutionRunners.get(agentName);
|
|
5884
6656
|
if (!runner) {
|
|
@@ -5924,9 +6696,10 @@ export class AgentFramework {
|
|
|
5924
6696
|
return `Error: ${result.error ?? 'tool call failed'}`;
|
|
5925
6697
|
}
|
|
5926
6698
|
if (result.data === undefined) return '';
|
|
5927
|
-
|
|
5928
|
-
|
|
5929
|
-
|
|
6699
|
+
// Scripts get (nearly) full data — the script environment IS the spill:
|
|
6700
|
+
// filtering big results in code is the whole point. 5MB protocol safety
|
|
6701
|
+
// cap only; no context-cap truncation, no file spill.
|
|
6702
|
+
return toolResultDataToHistoryString(result.data, 5_000_000);
|
|
5930
6703
|
}
|
|
5931
6704
|
|
|
5932
6705
|
/**
|
|
@@ -5982,7 +6755,14 @@ export class AgentFramework {
|
|
|
5982
6755
|
}
|
|
5983
6756
|
}
|
|
5984
6757
|
|
|
5985
|
-
private toMembraneToolResult(
|
|
6758
|
+
private toMembraneToolResult(
|
|
6759
|
+
callId: string,
|
|
6760
|
+
afResult: ToolResult,
|
|
6761
|
+
maxChars?: number,
|
|
6762
|
+
/** Pre-spilled string from the history path — used for the non-image
|
|
6763
|
+
* path so the live wire copy byte-matches what the window stored. */
|
|
6764
|
+
precomputed?: string,
|
|
6765
|
+
): MembraneToolResult {
|
|
5986
6766
|
if (afResult.isError) {
|
|
5987
6767
|
return { toolUseId: callId, content: afResult.error ?? 'Unknown error', isError: true };
|
|
5988
6768
|
}
|
|
@@ -5994,6 +6774,9 @@ export class AgentFramework {
|
|
|
5994
6774
|
if (blocks) {
|
|
5995
6775
|
return { toolUseId: callId, content: blocks, isError: false };
|
|
5996
6776
|
}
|
|
6777
|
+
if (precomputed !== undefined) {
|
|
6778
|
+
return { toolUseId: callId, content: precomputed, isError: false };
|
|
6779
|
+
}
|
|
5997
6780
|
// JSON.stringify returns the VALUE undefined (not a string) for undefined
|
|
5998
6781
|
// input — a module tool returning `{ success: true }` with no data would
|
|
5999
6782
|
// otherwise make `content.length` below throw a TypeError mid-turn.
|
|
@@ -6173,6 +6956,11 @@ export class AgentFramework {
|
|
|
6173
6956
|
}
|
|
6174
6957
|
|
|
6175
6958
|
// Route the agent's typed, allowlisted hot-settings surface.
|
|
6959
|
+
if (enrichedCall.name === 'utils') {
|
|
6960
|
+
this.dispatchUtilsToolCall(agentName, enrichedCall);
|
|
6961
|
+
return;
|
|
6962
|
+
}
|
|
6963
|
+
|
|
6176
6964
|
if (enrichedCall.name === 'agent_settings') {
|
|
6177
6965
|
this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
|
|
6178
6966
|
return;
|
|
@@ -6331,15 +7119,27 @@ export class AgentFramework {
|
|
|
6331
7119
|
|
|
6332
7120
|
// Defer non-tool_result messages while a tool cycle is mid-flight
|
|
6333
7121
|
// (pendingAssistantBlocks: preserves tool_use → tool_result adjacency)
|
|
6334
|
-
// OR while the target agent has a
|
|
6335
|
-
// mid-
|
|
6336
|
-
// though the live conversation never saw it — so the next compile
|
|
6337
|
-
// diverges from the live prefix (prompt-cache bust)
|
|
6338
|
-
//
|
|
6339
|
-
//
|
|
6340
|
-
//
|
|
7122
|
+
// OR while the target agent has a turn in progress AT ALL. A message
|
|
7123
|
+
// stored mid-turn lands BEFORE the turn's assistant blocks in the window
|
|
7124
|
+
// even though the live conversation never saw it — so the next compile
|
|
7125
|
+
// diverges from the live prefix (prompt-cache bust), the message misses
|
|
7126
|
+
// mid-turn injection, and the window falsely testifies the agent saw it
|
|
7127
|
+
// and moved on. Turn-alive (activeTurnTokens) is the guard, not stream-
|
|
7128
|
+
// alive: a turn exists from dequeue through hooks + compile before its
|
|
7129
|
+
// stream registers in activeStreams, and the 2026-07-31 Mythos incident
|
|
7130
|
+
// was precisely a deferred flush landing inside that compile window.
|
|
7131
|
+
// (activeStreams is kept as a belt-and-suspenders backstop.) Deferred
|
|
7132
|
+
// messages flush at the next turn's START (before its compile — riding
|
|
7133
|
+
// their own wake), at the agent's next tool boundary (where they are
|
|
7134
|
+
// ALSO injected into the live stream — hear-while-acting), or in
|
|
7135
|
+
// driveStream's finally when the turn ends.
|
|
6341
7136
|
const hasToolResult = content.some(b => b.type === 'tool_result');
|
|
6342
|
-
if (
|
|
7137
|
+
if (
|
|
7138
|
+
!hasToolResult &&
|
|
7139
|
+
(this.pendingAssistantBlocks.size > 0 ||
|
|
7140
|
+
this.activeTurnTokens.has(agent.name) ||
|
|
7141
|
+
this.activeStreams.has(agent.name))
|
|
7142
|
+
) {
|
|
6343
7143
|
this.deferredMessages.push({ participant, content, metadata });
|
|
6344
7144
|
return '' as MessageId; // Deferred — flushed at the next boundary
|
|
6345
7145
|
}
|
|
@@ -8113,7 +8913,7 @@ export class AgentFramework {
|
|
|
8113
8913
|
this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input });
|
|
8114
8914
|
const startTime = Date.now();
|
|
8115
8915
|
|
|
8116
|
-
this.channelRegistry!.handleChannelToolCall(call.name, call.input)
|
|
8916
|
+
this.channelRegistry!.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName })
|
|
8117
8917
|
.then((result) => {
|
|
8118
8918
|
const durationMs = Date.now() - startTime;
|
|
8119
8919
|
this.emitTrace({ type: 'tool:completed', module: 'channels', tool: call.name, callId: call.id, durationMs });
|
|
@@ -8340,6 +9140,38 @@ export class AgentFramework {
|
|
|
8340
9140
|
if (typeof input.path !== 'string' || input.path.length === 0) {
|
|
8341
9141
|
throw new Error('read_image: `path` (mount-prefixed) is required');
|
|
8342
9142
|
}
|
|
9143
|
+
// Anthropic rejects images >5MB (and >8000px); guard the hard byte
|
|
9144
|
+
// limit here — resizing is out of scope without an image library.
|
|
9145
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
9146
|
+
// Prefer the workspace module's own read_image handler when it exists:
|
|
9147
|
+
// it reads tree-first with a FILESYSTEM FALLBACK, so binaries that live
|
|
9148
|
+
// only on disk (shell-created files, pre-fix mangled tree blobs) still
|
|
9149
|
+
// render. The legacy tree-only path below stays for older workspace
|
|
9150
|
+
// modules that don't advertise the tool.
|
|
9151
|
+
if (
|
|
9152
|
+
typeof workspace.handleToolCall === 'function' &&
|
|
9153
|
+
typeof workspace.getTools === 'function' &&
|
|
9154
|
+
workspace.getTools().some((tool) => tool.name === 'read_image')
|
|
9155
|
+
) {
|
|
9156
|
+
const result = await workspace.handleToolCall({ ...call, name: 'read_image' });
|
|
9157
|
+
if (result.success && Array.isArray(result.data)) {
|
|
9158
|
+
const image = result.data.find(
|
|
9159
|
+
(block): block is { type: 'image'; data: string; mimeType: string } =>
|
|
9160
|
+
typeof block === 'object' && block !== null &&
|
|
9161
|
+
(block as { type?: unknown }).type === 'image' &&
|
|
9162
|
+
typeof (block as { data?: unknown }).data === 'string',
|
|
9163
|
+
);
|
|
9164
|
+
const byteLength = image ? Math.floor((image.data.length * 3) / 4) : 0;
|
|
9165
|
+
if (byteLength > MAX_IMAGE_BYTES) {
|
|
9166
|
+
throw new Error(
|
|
9167
|
+
`read_image: "${input.path}" is ~${byteLength} bytes — ` +
|
|
9168
|
+
`over the ${MAX_IMAGE_BYTES}-byte model limit`,
|
|
9169
|
+
);
|
|
9170
|
+
}
|
|
9171
|
+
}
|
|
9172
|
+
finish(result);
|
|
9173
|
+
return;
|
|
9174
|
+
}
|
|
8343
9175
|
const read = await workspace.readBinary(input.path);
|
|
8344
9176
|
if ('error' in read) throw new Error(read.error);
|
|
8345
9177
|
const mediaType = sniffImageMediaType(read.data);
|
|
@@ -8349,9 +9181,6 @@ export class AgentFramework {
|
|
|
8349
9181
|
'(png/jpeg/gif/webp — checked by magic bytes, not extension)',
|
|
8350
9182
|
);
|
|
8351
9183
|
}
|
|
8352
|
-
// Anthropic rejects images >5MB (and >8000px); guard the hard byte
|
|
8353
|
-
// limit here — resizing is out of scope without an image library.
|
|
8354
|
-
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
8355
9184
|
if (read.data.byteLength > MAX_IMAGE_BYTES) {
|
|
8356
9185
|
throw new Error(
|
|
8357
9186
|
`read_image: "${input.path}" is ${read.data.byteLength} bytes — ` +
|
|
@@ -8525,6 +9354,83 @@ export class AgentFramework {
|
|
|
8525
9354
|
})();
|
|
8526
9355
|
}
|
|
8527
9356
|
|
|
9357
|
+
/** The `utils` meta-tool: list/describe/run over module utilities
|
|
9358
|
+
* (Module.getUtilities). Shared by the model-facing dispatcher and the
|
|
9359
|
+
* programmatic path (executeToolCall), so code_execution scripts and
|
|
9360
|
+
* ephemerals reach utilities too. */
|
|
9361
|
+
private async handleUtilsToolCall(agentName: string, call: ToolCall): Promise<ToolResult> {
|
|
9362
|
+
const input = (call.input ?? {}) as { action?: unknown; name?: unknown; args?: unknown };
|
|
9363
|
+
const utilities = this.moduleRegistry.getAllUtilities();
|
|
9364
|
+
const available = (): string =>
|
|
9365
|
+
utilities.length ? utilities.map((u) => u.name).join(', ') : '(none registered)';
|
|
9366
|
+
switch (input.action) {
|
|
9367
|
+
case 'list':
|
|
9368
|
+
return {
|
|
9369
|
+
success: true,
|
|
9370
|
+
data: utilities.map((u) => ({ name: u.name, description: utilityOneLiner(u.description) })),
|
|
9371
|
+
};
|
|
9372
|
+
case 'describe': {
|
|
9373
|
+
const def = utilities.find((u) => u.name === input.name);
|
|
9374
|
+
if (!def) {
|
|
9375
|
+
return { success: false, isError: true, error: `No utility "${String(input.name)}". Available: ${available()}` };
|
|
9376
|
+
}
|
|
9377
|
+
return { success: true, data: def };
|
|
9378
|
+
}
|
|
9379
|
+
case 'run': {
|
|
9380
|
+
const def = utilities.find((u) => u.name === input.name);
|
|
9381
|
+
if (!def) {
|
|
9382
|
+
return { success: false, isError: true, error: `No utility "${String(input.name)}". Available: ${available()}` };
|
|
9383
|
+
}
|
|
9384
|
+
const args = input.args ?? {};
|
|
9385
|
+
const problem = validateUtilityArgs(args, def.inputSchema);
|
|
9386
|
+
if (problem) {
|
|
9387
|
+
// Teach by bounce: the error carries the schema, so the retry needs
|
|
9388
|
+
// no describe round-trip.
|
|
9389
|
+
return {
|
|
9390
|
+
success: false,
|
|
9391
|
+
isError: true,
|
|
9392
|
+
error: `${problem}. Schema for ${def.name}: ${JSON.stringify(def.inputSchema)}`,
|
|
9393
|
+
};
|
|
9394
|
+
}
|
|
9395
|
+
// Same dispatch as a first-class tool call — the module cannot tell
|
|
9396
|
+
// which surface the call came through.
|
|
9397
|
+
return this.moduleRegistry.handleToolCall({
|
|
9398
|
+
id: call.id,
|
|
9399
|
+
name: def.name,
|
|
9400
|
+
input: args,
|
|
9401
|
+
callerAgentName: agentName,
|
|
9402
|
+
});
|
|
9403
|
+
}
|
|
9404
|
+
default:
|
|
9405
|
+
return { success: false, isError: true, error: 'action must be "list", "describe", or "run"' };
|
|
9406
|
+
}
|
|
9407
|
+
}
|
|
9408
|
+
|
|
9409
|
+
private dispatchUtilsToolCall(agentName: string, call: ToolCall): void {
|
|
9410
|
+
this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'utils', callId: call.id, input: call.input });
|
|
9411
|
+
const startedAt = Date.now();
|
|
9412
|
+
void this.handleUtilsToolCall(agentName, call)
|
|
9413
|
+
.catch((err): ToolResult => ({
|
|
9414
|
+
success: false,
|
|
9415
|
+
error: err instanceof Error ? err.message : String(err),
|
|
9416
|
+
isError: true,
|
|
9417
|
+
}))
|
|
9418
|
+
.then((result) => {
|
|
9419
|
+
if (result.success) {
|
|
9420
|
+
this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'utils', callId: call.id, durationMs: Date.now() - startedAt });
|
|
9421
|
+
} else {
|
|
9422
|
+
this.emitTrace({ type: 'tool:failed', module: 'framework', tool: 'utils', callId: call.id, error: result.error ?? 'unknown error' });
|
|
9423
|
+
}
|
|
9424
|
+
this.pushEvent({
|
|
9425
|
+
type: 'tool-result',
|
|
9426
|
+
callId: call.id,
|
|
9427
|
+
agentName,
|
|
9428
|
+
moduleName: 'framework',
|
|
9429
|
+
result,
|
|
9430
|
+
});
|
|
9431
|
+
});
|
|
9432
|
+
}
|
|
9433
|
+
|
|
8528
9434
|
private dispatchAgentSettingsToolCall(agentName: string, call: ToolCall): void {
|
|
8529
9435
|
this.emitTrace({
|
|
8530
9436
|
type: 'tool:started',
|