@animalabs/agent-framework 0.7.3 → 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/agent.d.ts.map +1 -1
- package/dist/src/agent.js +28 -1
- package/dist/src/agent.js.map +1 -1
- 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 +92 -0
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +932 -60
- 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/agent.d.ts +12 -0
- package/dist/src/types/agent.d.ts.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/agent-runtime-settings.test.js +37 -0
- package/dist/test/agent-runtime-settings.test.js.map +1 -1
- package/dist/test/assistant-persist-refusal.test.d.ts +2 -0
- package/dist/test/assistant-persist-refusal.test.d.ts.map +1 -0
- package/dist/test/assistant-persist-refusal.test.js +63 -0
- package/dist/test/assistant-persist-refusal.test.js.map +1 -0
- 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/agent.ts +31 -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 +1055 -63
- 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/agent.ts +12 -0
- package/src/types/framework.ts +10 -0
- package/src/types/module.ts +24 -0
package/dist/src/framework.js
CHANGED
|
@@ -157,6 +157,61 @@ const PROSE_HELP_TOOL = {
|
|
|
157
157
|
};
|
|
158
158
|
/** Strip the `server--` MCPL prefix from a tool name. */
|
|
159
159
|
const bareToolName = (n) => n.split('--').pop();
|
|
160
|
+
/** The single slot behind which every rarely-used capability lives. Only
|
|
161
|
+
* advertised when at least one module contributes utilities — with none
|
|
162
|
+
* registered the surface is byte-identical to before this existed. */
|
|
163
|
+
const UTILS_TOOL = {
|
|
164
|
+
name: 'utils',
|
|
165
|
+
description: 'Rarely-used utilities, behind one slot instead of each costing a tool. ' +
|
|
166
|
+
'"list" names them all with one-liners; "describe" returns one utility\'s full ' +
|
|
167
|
+
'input schema; "run" invokes it (args are validated against that schema — a ' +
|
|
168
|
+
'miss bounces back with the schema and the specific error).',
|
|
169
|
+
inputSchema: {
|
|
170
|
+
type: 'object',
|
|
171
|
+
properties: {
|
|
172
|
+
action: { type: 'string', enum: ['list', 'describe', 'run'], description: 'What to do.' },
|
|
173
|
+
name: { type: 'string', description: 'Utility name from list (required for describe/run).' },
|
|
174
|
+
args: { type: 'object', description: "Arguments for run, per the utility's schema." },
|
|
175
|
+
},
|
|
176
|
+
required: ['action'],
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
/** First sentence (or line) of a description — what `utils list` shows. */
|
|
180
|
+
const utilityOneLiner = (description) => {
|
|
181
|
+
const line = description.split('\n')[0];
|
|
182
|
+
const dot = line.indexOf('. ');
|
|
183
|
+
return (dot >= 0 ? line.slice(0, dot + 1) : line).slice(0, 160);
|
|
184
|
+
};
|
|
185
|
+
/** Shallow validation of run args against a utility's declared inputSchema:
|
|
186
|
+
* required keys, primitive types, enums. Deliberately not a full JSON-Schema
|
|
187
|
+
* engine — handlers still hand-validate (they always did); this layer exists
|
|
188
|
+
* so an arg-shape miss teaches by bounce instead of failing deep in a handler
|
|
189
|
+
* with a less instructive message. Returns an error string or null. */
|
|
190
|
+
function validateUtilityArgs(input, schema) {
|
|
191
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
192
|
+
return 'args must be an object';
|
|
193
|
+
}
|
|
194
|
+
const obj = input;
|
|
195
|
+
for (const req of schema.required ?? []) {
|
|
196
|
+
if (!(req in obj) || obj[req] === undefined)
|
|
197
|
+
return `missing required "${req}"`;
|
|
198
|
+
}
|
|
199
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
200
|
+
const param = schema.properties?.[key];
|
|
201
|
+
if (!param || value === undefined)
|
|
202
|
+
continue; // unknown keys: handler's business
|
|
203
|
+
if (param.enum && !param.enum.includes(value)) {
|
|
204
|
+
return `"${key}" must be one of ${JSON.stringify(param.enum)}`;
|
|
205
|
+
}
|
|
206
|
+
if (param.type) {
|
|
207
|
+
const actual = Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value;
|
|
208
|
+
const expected = param.type === 'integer' ? 'number' : param.type;
|
|
209
|
+
if (actual !== expected)
|
|
210
|
+
return `"${key}" must be ${param.type}, got ${actual}`;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
160
215
|
import { CheckpointManager } from './mcpl/checkpoint-manager.js';
|
|
161
216
|
import { isToolAllowed } from './mcpl/tool-policy.js';
|
|
162
217
|
import { EventGate } from './gate/event-gate.js';
|
|
@@ -398,6 +453,24 @@ export class AgentFramework {
|
|
|
398
453
|
// Messages deferred while an agent is waiting_for_tools (to preserve
|
|
399
454
|
// tool_use → tool_result adjacency required by the Anthropic API).
|
|
400
455
|
deferredMessages = [];
|
|
456
|
+
// Turn-alive markers (2026-07-31 Mythos phantom-skip incident): agentName →
|
|
457
|
+
// token of the turn currently in progress. Set at startAgentStream ENTRY —
|
|
458
|
+
// before hooks, compile, and stream setup, all of which await — and cleared
|
|
459
|
+
// token-matched when that turn's teardown finishes (driveStream finally /
|
|
460
|
+
// terminal failure path), so a successor turn's marker survives a
|
|
461
|
+
// predecessor's late finally. A turn is alive from dequeue to settled
|
|
462
|
+
// teardown: strictly longer than `activeStreams` membership, which begins
|
|
463
|
+
// only after the stream exists. Guarding addMessage on stream-liveness
|
|
464
|
+
// alone left the compile window open — a deferred message flushed by the
|
|
465
|
+
// previous turn's finally landed in the window MID-COMPILE of the turn its
|
|
466
|
+
// own wake had started: positioned before the new turn's blocks yet absent
|
|
467
|
+
// from its wire request. The agent then "skipped" a message it never saw
|
|
468
|
+
// (falsified history — it later confabulated an apology for the skip), and
|
|
469
|
+
// every subsequent compile diverged from the live prefix at that message
|
|
470
|
+
// (KV bust). Invariant: nothing enters the window between a turn's dequeue
|
|
471
|
+
// and its settle except that turn's own blocks.
|
|
472
|
+
activeTurnTokens = new Map();
|
|
473
|
+
nextTurnToken = 1;
|
|
401
474
|
// Undo/redo state
|
|
402
475
|
turnCounters = new Map(); // agentName → next turnIndex
|
|
403
476
|
redoStacks = new Map(); // agentName → redo entries
|
|
@@ -438,6 +511,15 @@ export class AgentFramework {
|
|
|
438
511
|
* deferred and applied to the final code_execution result instead of
|
|
439
512
|
* cancelling the stream mid-script (which would wedge the turn). */
|
|
440
513
|
scriptDeferredEndTurn = new Set();
|
|
514
|
+
/** Background (daemon) scripts: model-authored watchers that outlive their
|
|
515
|
+
* spawning turn. Each gets a DEDICATED PyRunner; wake_agent() injects a
|
|
516
|
+
* provenance envelope + payload and requests inference. Keyed by script id. */
|
|
517
|
+
backgroundScripts = new Map();
|
|
518
|
+
backgroundScriptCounter = 0;
|
|
519
|
+
/** Ephemeral per-agent override of the tool-result inline cap (chars).
|
|
520
|
+
* Set via agent_settings `tool_result_inline_max_chars`; NOT persisted —
|
|
521
|
+
* the lift is meant as a temporary gate, reverts on restart/reset. */
|
|
522
|
+
toolResultInlineMaxCharsOverride = new Map();
|
|
441
523
|
mcplTools = [];
|
|
442
524
|
mcplToolRefreshInFlight = false;
|
|
443
525
|
mcplToolRefreshPending = false;
|
|
@@ -495,7 +577,12 @@ export class AgentFramework {
|
|
|
495
577
|
queryMessages: (filter) => this.queryMessages(filter),
|
|
496
578
|
pushEvent: (event) => this.pushEvent(event),
|
|
497
579
|
onTrace: (listener) => this.onTrace(listener),
|
|
498
|
-
|
|
580
|
+
// The ONLY entry that confers module origin — a private closure the
|
|
581
|
+
// framework hands to its own module registry. The public
|
|
582
|
+
// executeToolCall() is shared with model/ephemeral callers and always
|
|
583
|
+
// stamps agent origin (see there).
|
|
584
|
+
callTool: (call) => this.executeToolCallFrom(call, { kind: 'module' }),
|
|
585
|
+
notifyOps: (kind, agentName, message, data) => this.notifyOps(kind, agentName, message, data),
|
|
499
586
|
});
|
|
500
587
|
}
|
|
501
588
|
/**
|
|
@@ -745,6 +832,17 @@ export class AgentFramework {
|
|
|
745
832
|
runner.dispose();
|
|
746
833
|
}
|
|
747
834
|
this.codeExecutionRunners.clear();
|
|
835
|
+
// Background daemons die with the process (documented limitation) —
|
|
836
|
+
// mark cancelled FIRST so their settle path stays silent (no crash-wake
|
|
837
|
+
// into a framework that is shutting down).
|
|
838
|
+
for (const record of this.backgroundScripts.values()) {
|
|
839
|
+
if (record.status === 'running') {
|
|
840
|
+
record.cancelled = true;
|
|
841
|
+
record.status = 'cancelled';
|
|
842
|
+
}
|
|
843
|
+
record.runner.dispose();
|
|
844
|
+
}
|
|
845
|
+
this.backgroundScripts.clear();
|
|
748
846
|
// Cancel all active streams
|
|
749
847
|
for (const agent of this.agents.values()) {
|
|
750
848
|
if (agent.state.status === 'streaming' ||
|
|
@@ -1003,6 +1101,8 @@ export class AgentFramework {
|
|
|
1003
1101
|
: [];
|
|
1004
1102
|
return [
|
|
1005
1103
|
...moduleTools,
|
|
1104
|
+
// Utilities collapse into one slot; absent entirely when none exist.
|
|
1105
|
+
...(this.moduleRegistry.getAllUtilities().length > 0 ? [UTILS_TOOL] : []),
|
|
1006
1106
|
...this.mcplTools,
|
|
1007
1107
|
...channelTools,
|
|
1008
1108
|
...gateTools,
|
|
@@ -1038,6 +1138,15 @@ export class AgentFramework {
|
|
|
1038
1138
|
collectAgentSettingsExtensions() {
|
|
1039
1139
|
const result = new Map();
|
|
1040
1140
|
const taken = new Set(AgentFramework.AGENT_SETTINGS_CORE_KEYS);
|
|
1141
|
+
// Framework-owned extension: the tool-result inline cap lift. Registered
|
|
1142
|
+
// through the same extension surface modules use so get/update/reset all
|
|
1143
|
+
// work with zero extra plumbing. EPHEMERAL by design (a temporary gate —
|
|
1144
|
+
// reverts on restart), unlike module extensions which persist their own.
|
|
1145
|
+
{
|
|
1146
|
+
const ext = this.spillSettingsExtension();
|
|
1147
|
+
ext.keys.forEach((k) => taken.add(k));
|
|
1148
|
+
result.set('_framework', ext);
|
|
1149
|
+
}
|
|
1041
1150
|
for (const module of this.moduleRegistry.getAllModules()) {
|
|
1042
1151
|
const ext = module.getAgentSettingsExtension?.();
|
|
1043
1152
|
if (!ext)
|
|
@@ -1052,6 +1161,39 @@ export class AgentFramework {
|
|
|
1052
1161
|
}
|
|
1053
1162
|
return result;
|
|
1054
1163
|
}
|
|
1164
|
+
/** agent_settings extension for the spill gate (see spillOrTruncate). */
|
|
1165
|
+
spillSettingsExtension() {
|
|
1166
|
+
return {
|
|
1167
|
+
properties: {
|
|
1168
|
+
tool_result_inline_max_chars: {
|
|
1169
|
+
type: 'number',
|
|
1170
|
+
description: 'Inline size cap (chars) for tool results and background-script wake payloads. ' +
|
|
1171
|
+
'Content over the cap is written to a workspace file under tool-results/ and ' +
|
|
1172
|
+
'replaced by a truncated preview + file reference. Raise it temporarily when you ' +
|
|
1173
|
+
'genuinely want a large result inline; reset restores the default (derived from ' +
|
|
1174
|
+
'your strategy). Ephemeral — reverts on host restart.',
|
|
1175
|
+
},
|
|
1176
|
+
},
|
|
1177
|
+
keys: ['tool_result_inline_max_chars'],
|
|
1178
|
+
get: (agentName) => ({
|
|
1179
|
+
tool_result_inline_max_chars: this.toolResultInlineMaxCharsOverride.get(agentName) ?? null,
|
|
1180
|
+
}),
|
|
1181
|
+
update: (agentName, patch) => {
|
|
1182
|
+
const n = Number(patch.tool_result_inline_max_chars);
|
|
1183
|
+
if (!Number.isFinite(n) || n < 1000) {
|
|
1184
|
+
throw new Error('tool_result_inline_max_chars must be a number >= 1000');
|
|
1185
|
+
}
|
|
1186
|
+
this.toolResultInlineMaxCharsOverride.set(agentName, Math.floor(n));
|
|
1187
|
+
return { tool_result_inline_max_chars: Math.floor(n) };
|
|
1188
|
+
},
|
|
1189
|
+
reset: (agentName, keys) => {
|
|
1190
|
+
if (!keys || keys.includes('tool_result_inline_max_chars')) {
|
|
1191
|
+
this.toolResultInlineMaxCharsOverride.delete(agentName);
|
|
1192
|
+
}
|
|
1193
|
+
return { tool_result_inline_max_chars: null };
|
|
1194
|
+
},
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1055
1197
|
captureInferenceToolSnapshot(agent) {
|
|
1056
1198
|
return {
|
|
1057
1199
|
sameRoundThinkTextPolicy: agent.getEffectiveSameRoundThinkTextPolicy(),
|
|
@@ -1564,6 +1706,12 @@ export class AgentFramework {
|
|
|
1564
1706
|
// ephemeral agents leave checkpoint-tree keys and diagnostics map
|
|
1565
1707
|
// entries behind for the life of the store/session.
|
|
1566
1708
|
this.evictTurnCheckpoints(agent.name);
|
|
1709
|
+
// Turn-alive marker, same spawn-and-dispose logic: a stalled ephemeral
|
|
1710
|
+
// (watchdog fired, driveStream's terminal event lost) would strand its
|
|
1711
|
+
// token forever — no wedge (ephemeral names are unique per run) but
|
|
1712
|
+
// unbounded map growth. Blind delete is safe: the agent is already out
|
|
1713
|
+
// of the map, and a late driveStream finally token-match no-ops.
|
|
1714
|
+
this.activeTurnTokens.delete(agent.name);
|
|
1567
1715
|
}
|
|
1568
1716
|
}
|
|
1569
1717
|
/**
|
|
@@ -1787,6 +1935,11 @@ export class AgentFramework {
|
|
|
1787
1935
|
context_budget_tokens: { type: 'number', description: 'Total input context budget, including the reserved response allowance.' },
|
|
1788
1936
|
tail_tokens: { type: 'number', description: 'Recent raw context retained verbatim.' },
|
|
1789
1937
|
transition_pace_tokens: { type: 'number', description: 'Maximum ordinary KV re-read/perturbation per compile while converging.' },
|
|
1938
|
+
immediate: {
|
|
1939
|
+
type: 'boolean',
|
|
1940
|
+
description: 'With a context_budget_tokens DECREASE: apply it immediately instead of a paced descent — ' +
|
|
1941
|
+
'the whole fold-down and its KV re-read land on the next compile, and any in-flight descent is cancelled.',
|
|
1942
|
+
},
|
|
1790
1943
|
same_round_think_text_policy: {
|
|
1791
1944
|
type: 'string',
|
|
1792
1945
|
enum: ['public', 'private'],
|
|
@@ -2842,6 +2995,18 @@ export class AgentFramework {
|
|
|
2842
2995
|
}
|
|
2843
2996
|
// Compute truncation limit from agent's strategy (maxMessageTokens * 4 chars)
|
|
2844
2997
|
const maxChars = this.getMaxToolResultChars(agent);
|
|
2998
|
+
// Oversized results spill to a workspace file (truncated preview +
|
|
2999
|
+
// file reference) instead of being blind-truncated. Computed ONCE
|
|
3000
|
+
// per call and reused for both the history copy and the live wire
|
|
3001
|
+
// copy below — the two must stay byte-matched (the window stores
|
|
3002
|
+
// what the membrane sends; divergence breaks the compile prefix).
|
|
3003
|
+
const spilled = new Map();
|
|
3004
|
+
const dateLabel = new Date().toISOString().slice(0, 10);
|
|
3005
|
+
for (const tc of currentState.toolResults) {
|
|
3006
|
+
if (tc.result.isError)
|
|
3007
|
+
continue;
|
|
3008
|
+
spilled.set(tc.id, await this.spillOrTruncate(toolResultDataToHistoryString(tc.result.data, undefined), maxChars, `${dateLabel}-${tc.id}`, agent.name));
|
|
3009
|
+
}
|
|
2845
3010
|
// Store tool results as a user message (tool_result blocks).
|
|
2846
3011
|
// Use the history serializer so MCP image blocks become a short
|
|
2847
3012
|
// `[image: type, size]` placeholder instead of megabytes of base64
|
|
@@ -2851,7 +3016,7 @@ export class AgentFramework {
|
|
|
2851
3016
|
toolUseId: tc.id,
|
|
2852
3017
|
content: tc.result.isError
|
|
2853
3018
|
? tc.result.error ?? 'Unknown error'
|
|
2854
|
-
: toolResultDataToHistoryString(tc.result.data, maxChars),
|
|
3019
|
+
: spilled.get(tc.id) ?? toolResultDataToHistoryString(tc.result.data, maxChars),
|
|
2855
3020
|
isError: tc.result.isError,
|
|
2856
3021
|
}));
|
|
2857
3022
|
agent.getContextManager().addMessage('user', toolResultContent);
|
|
@@ -2918,6 +3083,66 @@ export class AgentFramework {
|
|
|
2918
3083
|
const overBudget = currentState.stream
|
|
2919
3084
|
&& agent.lastStreamInputTokens > 0
|
|
2920
3085
|
&& agent.lastStreamInputTokens > agent.maxStreamTokens;
|
|
3086
|
+
// Addressed re-pin (2026-07-31 Mythos misroutes, antra-ratified):
|
|
3087
|
+
// when someone ADDRESSES the agent mid-turn from another channel
|
|
3088
|
+
// (mention / reply / DM — the same chat:addressed signal the
|
|
3089
|
+
// turn-START batch policy prefers), the agent's subsequent plain
|
|
3090
|
+
// prose almost always answers THEM. Six recorded misroutes
|
|
3091
|
+
// delivered those answers to the stale frozen locus instead (the
|
|
3092
|
+
// LabClaude answer into #hospital_commons mid-examination; the
|
|
3093
|
+
// hospital scene into antra's DM). Re-pin the turn locus to the
|
|
3094
|
+
// LAST addressed conversational injection, at segment granularity:
|
|
3095
|
+
// prose routed in earlier rounds already went to the old pin,
|
|
3096
|
+
// which was correct when it was delivered. Ambient chatter,
|
|
3097
|
+
// reactions, and system markers still cannot move the pin — the
|
|
3098
|
+
// 2026-07-21 Cairn hijack protection is unchanged; this extends
|
|
3099
|
+
// the ratified addressed-outranks-ambient principle from turn
|
|
3100
|
+
// start to boundaries. Locus mode only (explicit mode has no pin).
|
|
3101
|
+
// Skipped on endTurn/overBudget: no further prose this stream, and
|
|
3102
|
+
// a stale "for this turn" notice would be pure noise.
|
|
3103
|
+
//
|
|
3104
|
+
// The notice rides the SAME injection batch — stored right after
|
|
3105
|
+
// the flushed messages and appended to midTurnInjections — so the
|
|
3106
|
+
// window byte-matches the wire (short event-style notice: the
|
|
3107
|
+
// classifier-safe D-class from the 2026-07-24 ablation). It also
|
|
3108
|
+
// updates lastAnnouncedLocus so the next turn's announce-on-change
|
|
3109
|
+
// diffs against what the agent was actually last told.
|
|
3110
|
+
if (agent.proseRouting !== 'explicit' &&
|
|
3111
|
+
!shouldEndTurn && !overBudget && currentState.stream) {
|
|
3112
|
+
const lastAddressed = [...midTurnInjections].reverse().find((inj) => {
|
|
3113
|
+
const m = inj.metadata;
|
|
3114
|
+
return (isConversationalInjection(inj.metadata) &&
|
|
3115
|
+
typeof m?.channelId === 'string' &&
|
|
3116
|
+
isAddressedMessage(m.tags, m));
|
|
3117
|
+
});
|
|
3118
|
+
const newLocus = lastAddressed?.metadata
|
|
3119
|
+
?.channelId;
|
|
3120
|
+
if (newLocus && this.turnLocusPins.get(agent.name) !== newLocus) {
|
|
3121
|
+
const prevPin = this.turnLocusPins.get(agent.name) ?? null;
|
|
3122
|
+
this.turnLocusPins.set(agent.name, newLocus);
|
|
3123
|
+
this.lastAnnouncedLocus.set(agent.name, newLocus);
|
|
3124
|
+
const label = this.channelRegistry?.getDescriptor(newLocus)?.label;
|
|
3125
|
+
const shown = label && label !== newLocus
|
|
3126
|
+
? `${label.startsWith('#') ? label : `#${label}`} (${newLocus})`
|
|
3127
|
+
: newLocus;
|
|
3128
|
+
const noticeContent = [{
|
|
3129
|
+
type: 'text',
|
|
3130
|
+
text: `[routing] You were just addressed from ${shown} — your plain ` +
|
|
3131
|
+
'speech now lands there for the rest of this turn. Other ' +
|
|
3132
|
+
'channels need an explicit send tool.',
|
|
3133
|
+
}];
|
|
3134
|
+
const noticeMeta = { system: true, kind: 'routing-notice' };
|
|
3135
|
+
try {
|
|
3136
|
+
const id = agent.getContextManager().addMessage('user', noticeContent, noticeMeta);
|
|
3137
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
|
|
3138
|
+
midTurnInjections.push({ participant: 'user', content: noticeContent, metadata: noticeMeta });
|
|
3139
|
+
console.error(`[routing] ${agent.name}: mid-turn addressed re-pin ${prevPin ?? '(none)'} -> ${newLocus}`);
|
|
3140
|
+
}
|
|
3141
|
+
catch (err) {
|
|
3142
|
+
console.error('mid-turn locus re-pin: failed to record routing notice:', err);
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
2921
3146
|
if (shouldEndTurn) {
|
|
2922
3147
|
// endTurn: messages already stored above, cancel stream, reset to idle.
|
|
2923
3148
|
if (currentState.stream) {
|
|
@@ -2943,6 +3168,16 @@ export class AgentFramework {
|
|
|
2943
3168
|
// endTurn above).
|
|
2944
3169
|
if (currentState.stream) {
|
|
2945
3170
|
this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
|
|
3171
|
+
// Cancel the LIVE stream object directly (like the endTurn
|
|
3172
|
+
// branch above): at a tool boundary the state machine reads
|
|
3173
|
+
// 'ready', and Agent.cancelStream only signals the stream from
|
|
3174
|
+
// 'streaming'/'waiting_for_tools' — so the old call left the
|
|
3175
|
+
// provider stream parked forever awaiting tool results that
|
|
3176
|
+
// would never come (zombie: driveStream's finally never ran,
|
|
3177
|
+
// its activeStreams entry survived until the restart's stream
|
|
3178
|
+
// overwrote it). Harmless by accident before turn-alive
|
|
3179
|
+
// tracking; a genuine teardown leak after it.
|
|
3180
|
+
currentState.stream.cancel();
|
|
2946
3181
|
}
|
|
2947
3182
|
agent.cancelStream();
|
|
2948
3183
|
this.emitTrace({
|
|
@@ -2964,7 +3199,7 @@ export class AgentFramework {
|
|
|
2964
3199
|
// Mid-turn messages collected above ride along as injected user
|
|
2965
3200
|
// messages (membrane ≥0.5.72) — appended after the tool_result
|
|
2966
3201
|
// envelope so the next round of THIS turn hears them.
|
|
2967
|
-
const membraneResults = currentState.toolResults.map(tc => this.toMembraneToolResult(tc.id, tc.result, maxChars));
|
|
3202
|
+
const membraneResults = currentState.toolResults.map(tc => this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id)));
|
|
2968
3203
|
currentState.stream.provideToolResults(membraneResults, midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections } : undefined);
|
|
2969
3204
|
agent.setStreaming(currentState.stream);
|
|
2970
3205
|
this.emitTrace({
|
|
@@ -3105,7 +3340,26 @@ export class AgentFramework {
|
|
|
3105
3340
|
await this.routeConversationIncoming(event, metadata);
|
|
3106
3341
|
return;
|
|
3107
3342
|
}
|
|
3108
|
-
|
|
3343
|
+
// Addressed-while-closed invitation — parity with the push-event path
|
|
3344
|
+
// (channels.publish surfaces like portal-mcpl deliver mentions here).
|
|
3345
|
+
const incomingContent = [...event.content];
|
|
3346
|
+
{
|
|
3347
|
+
const invitation = this.buildClosedChannelInvitation({
|
|
3348
|
+
serverId: event.serverId,
|
|
3349
|
+
channelId: event.channelId,
|
|
3350
|
+
messageId: event.messageId,
|
|
3351
|
+
authorName: event.author?.name || 'Someone',
|
|
3352
|
+
guildName: typeof event.metadata?.guildName === 'string' && event.metadata.guildName
|
|
3353
|
+
? event.metadata.guildName
|
|
3354
|
+
: undefined,
|
|
3355
|
+
tags: event.tags,
|
|
3356
|
+
});
|
|
3357
|
+
if (invitation) {
|
|
3358
|
+
incomingContent.push(invitation.block);
|
|
3359
|
+
Object.assign(metadata, invitation.metadataPatch);
|
|
3360
|
+
}
|
|
3361
|
+
}
|
|
3362
|
+
const id = this.addMessage('user', incomingContent, metadata);
|
|
3109
3363
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
|
|
3110
3364
|
if (event.triggerInference) {
|
|
3111
3365
|
const addressed = isAddressedMessage(event.tags, event.metadata);
|
|
@@ -3352,6 +3606,60 @@ export class AgentFramework {
|
|
|
3352
3606
|
}
|
|
3353
3607
|
}
|
|
3354
3608
|
}
|
|
3609
|
+
/**
|
|
3610
|
+
* Build the addressed-while-CLOSED channel invitation block, shared by the
|
|
3611
|
+
* push-event and channels/incoming ingest paths. Until 2026-07-27 only push
|
|
3612
|
+
* events (discord-mcpl mentions) carried it; channels.publish surfaces
|
|
3613
|
+
* (portal-mcpl) delivered bare mentions with no guidance and no route — an
|
|
3614
|
+
* explicit-prose agent's natural plain reply bounced with nowhere to go
|
|
3615
|
+
* (Rhys's first portal mention: a 2,320-char reply stranded).
|
|
3616
|
+
*
|
|
3617
|
+
* Wording: option 1 teaches the `>>` destination prefix, which delivers for
|
|
3618
|
+
* BOTH legacy (host-inferred) and explicit prose-routing agents. The
|
|
3619
|
+
* previous "simply write your reply as normal text" was a false promise
|
|
3620
|
+
* under explicit routing (the metadata flags it set had no consumers).
|
|
3621
|
+
*
|
|
3622
|
+
* Human-readable names wherever the surface provided them — a mind reading
|
|
3623
|
+
* this should see WHO addressed it WHERE, not a wall of snowflake ids. Raw
|
|
3624
|
+
* ids remain only inside the tool-argument instructions, where they are the
|
|
3625
|
+
* literal values to pass.
|
|
3626
|
+
*/
|
|
3627
|
+
buildClosedChannelInvitation(opts) {
|
|
3628
|
+
if (!this.channelRegistry)
|
|
3629
|
+
return null;
|
|
3630
|
+
if (!opts.tags?.includes('chat:addressed'))
|
|
3631
|
+
return null;
|
|
3632
|
+
if (this.channelRegistry.isChannelOpen(opts.channelId))
|
|
3633
|
+
return null;
|
|
3634
|
+
const descriptor = this.channelRegistry.getDescriptor(opts.channelId);
|
|
3635
|
+
const maxBackscroll = descriptor?.capabilities?.history?.maxMessages ?? 0;
|
|
3636
|
+
const label = opts.channelLabel ?? descriptor?.label;
|
|
3637
|
+
const channelLabel = label ? `#${label}` : `"${opts.channelId}"`;
|
|
3638
|
+
const place = opts.guildName ? `${channelLabel} in "${opts.guildName}"` : channelLabel;
|
|
3639
|
+
const block = {
|
|
3640
|
+
type: 'text',
|
|
3641
|
+
text: `\n[Channel invitation] ${opts.authorName} addressed you in ${place} — ` +
|
|
3642
|
+
`a channel you haven't joined. You're seeing this one message; you won't receive more from it unless you join. ` +
|
|
3643
|
+
`Your options:\n` +
|
|
3644
|
+
`1. Reply without joining — write your reply this turn prefixed with ">>${channelLabel}"; it will be delivered there.\n` +
|
|
3645
|
+
`2. Join the channel — call channel_open with channelId "${opts.channelId}" and serverId "${opts.serverId}"` +
|
|
3646
|
+
(maxBackscroll > 0
|
|
3647
|
+
? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${opts.messageId}".\n`
|
|
3648
|
+
: `.\n`) +
|
|
3649
|
+
`3. Stay out — call channel_decline with channelId "${opts.channelId}", serverId "${opts.serverId}", ` +
|
|
3650
|
+
`and messageId "${opts.messageId}" (optionally set acknowledge to an emoji like 👀 so ${opts.authorName} isn't left hanging).\n` +
|
|
3651
|
+
`Doing nothing is also fine.`,
|
|
3652
|
+
};
|
|
3653
|
+
return {
|
|
3654
|
+
block,
|
|
3655
|
+
metadataPatch: {
|
|
3656
|
+
channelInvitation: true,
|
|
3657
|
+
channelOpen: false,
|
|
3658
|
+
channelId: opts.channelId,
|
|
3659
|
+
invitationMessageId: opts.messageId,
|
|
3660
|
+
},
|
|
3661
|
+
};
|
|
3662
|
+
}
|
|
3355
3663
|
/**
|
|
3356
3664
|
* Convert an MCPL push event to a context message.
|
|
3357
3665
|
*/
|
|
@@ -3378,44 +3686,21 @@ export class AgentFramework {
|
|
|
3378
3686
|
metadata.channelId = triggerChannel.channelId;
|
|
3379
3687
|
}
|
|
3380
3688
|
const content = [...event.content];
|
|
3381
|
-
|
|
3382
|
-
event.tags?.includes('chat:addressed') &&
|
|
3383
|
-
this.channelRegistry &&
|
|
3384
|
-
!this.channelRegistry.isChannelOpen(triggerChannel.channelId);
|
|
3385
|
-
if (addressedWhileClosed) {
|
|
3386
|
-
const descriptor = this.channelRegistry.getDescriptor(triggerChannel.channelId);
|
|
3387
|
-
const messageId = typeof event.origin?.messageId === 'string'
|
|
3388
|
-
? event.origin.messageId
|
|
3389
|
-
: event.eventId;
|
|
3390
|
-
const maxBackscroll = descriptor?.capabilities?.history?.maxMessages ?? 0;
|
|
3391
|
-
// Resolve human-readable names from the event origin wherever the
|
|
3392
|
-
// surface provided them — a mind reading this should see WHO addressed
|
|
3393
|
-
// it WHERE, not a wall of snowflake ids. Raw ids remain only inside the
|
|
3394
|
-
// tool-argument instructions, where they are the literal values to pass.
|
|
3689
|
+
if (triggerChannel) {
|
|
3395
3690
|
const origin = (event.origin ?? {});
|
|
3396
|
-
const
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
:
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
`a channel you haven't joined. You're seeing this one message; you won't receive more from it unless you join. ` +
|
|
3405
|
-
`Your options:\n` +
|
|
3406
|
-
`1. Reply without joining — simply write your reply as normal text this turn; it will be delivered to ${channelLabel}.\n` +
|
|
3407
|
-
`2. Join the channel — call channel_open with channelId "${triggerChannel.channelId}" and serverId "${event.serverId}"` +
|
|
3408
|
-
(maxBackscroll > 0
|
|
3409
|
-
? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${messageId}".\n`
|
|
3410
|
-
: `.\n`) +
|
|
3411
|
-
`3. Stay out — call channel_decline with channelId "${triggerChannel.channelId}", serverId "${event.serverId}", ` +
|
|
3412
|
-
`and messageId "${messageId}" (optionally set acknowledge to an emoji like 👀 so ${authorName} isn't left hanging).\n` +
|
|
3413
|
-
`Doing nothing is also fine.`,
|
|
3691
|
+
const invitation = this.buildClosedChannelInvitation({
|
|
3692
|
+
serverId: event.serverId,
|
|
3693
|
+
channelId: triggerChannel.channelId,
|
|
3694
|
+
channelLabel: triggerChannel.label,
|
|
3695
|
+
messageId: typeof origin.messageId === 'string' ? origin.messageId : event.eventId,
|
|
3696
|
+
authorName: typeof origin.authorName === 'string' && origin.authorName ? origin.authorName : 'Someone',
|
|
3697
|
+
guildName: typeof origin.guildName === 'string' && origin.guildName ? origin.guildName : undefined,
|
|
3698
|
+
tags: event.tags,
|
|
3414
3699
|
});
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3700
|
+
if (invitation) {
|
|
3701
|
+
content.push(invitation.block);
|
|
3702
|
+
Object.assign(metadata, invitation.metadataPatch);
|
|
3703
|
+
}
|
|
3419
3704
|
}
|
|
3420
3705
|
const id = this.addMessage('user', content, metadata);
|
|
3421
3706
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
|
|
@@ -3521,8 +3806,24 @@ export class AgentFramework {
|
|
|
3521
3806
|
console.error(`[inference-dropped] agent=${agentName} reason=agent_not_found requests=${requests.length}`);
|
|
3522
3807
|
continue;
|
|
3523
3808
|
}
|
|
3524
|
-
// Skip if agent is busy (inferring, streaming, or waiting for tools)
|
|
3525
|
-
if
|
|
3809
|
+
// Skip if agent is busy (inferring, streaming, or waiting for tools) —
|
|
3810
|
+
// or if a turn is alive at all (activeTurnTokens): the state machine
|
|
3811
|
+
// goes idle at endTurn's reset while driveStream teardown is still
|
|
3812
|
+
// pending, and a successor turn dequeued in that gap compiles while the
|
|
3813
|
+
// predecessor's deferred flush can still write (2026-07-31 Mythos).
|
|
3814
|
+
// Turn-alive spans dequeue → settled teardown; wakes wait it out.
|
|
3815
|
+
//
|
|
3816
|
+
// EXCEPT a context-budget restart: it CONTINUES the turn whose token is
|
|
3817
|
+
// held — queueing it behind its own turn's teardown is a deadlock when
|
|
3818
|
+
// that teardown lags (the cancelled stream's `aborted` event is
|
|
3819
|
+
// asynchronous and, historically, not even guaranteed — see the
|
|
3820
|
+
// overBudget branch). The restart's startAgentStream overwrites the
|
|
3821
|
+
// token; the predecessor frame's late finally no-ops via token-match.
|
|
3822
|
+
// This mirrors how the restart has always overwritten activeStreams
|
|
3823
|
+
// rather than waiting for the old stream's teardown.
|
|
3824
|
+
const isBudgetRestart = requests.some((r) => r.reason === 'context_budget_restart');
|
|
3825
|
+
const turnAlive = !isBudgetRestart && this.activeTurnTokens.has(agentName);
|
|
3826
|
+
if (turnAlive || agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
|
|
3526
3827
|
// Re-queue requests, but warn if they've been pending too long
|
|
3527
3828
|
const oldest = Math.min(...requests.map(r => r.timestamp));
|
|
3528
3829
|
if (now - oldest > STALE_REQUEST_MS &&
|
|
@@ -3532,14 +3833,21 @@ export class AgentFramework {
|
|
|
3532
3833
|
// (up to 100/sec after the scheduler backoff), adding avoidable work
|
|
3533
3834
|
// precisely while the agent was already under pressure.
|
|
3534
3835
|
this.staleWarnAt.set(agentName, now);
|
|
3836
|
+
// Surface turn-alive separately from the state machine: status can
|
|
3837
|
+
// read 'idle' while a turn's teardown is pending, and a LEAKED turn
|
|
3838
|
+
// token would look exactly like this — permanently requeued wakes.
|
|
3839
|
+
// This line is the wedge's tell (idle+turn-alive, forever).
|
|
3840
|
+
const shownStatus = turnAlive && agent.state.status === 'idle'
|
|
3841
|
+
? 'idle+turn-alive'
|
|
3842
|
+
: agent.state.status;
|
|
3535
3843
|
this.emitTrace({
|
|
3536
3844
|
type: 'inference:request_stale',
|
|
3537
3845
|
agentName,
|
|
3538
|
-
agentStatus:
|
|
3846
|
+
agentStatus: shownStatus,
|
|
3539
3847
|
requestCount: requests.length,
|
|
3540
3848
|
oldestRequestAge: now - oldest,
|
|
3541
3849
|
});
|
|
3542
|
-
console.error(`[inference-stale] agent=${agentName} busy (${
|
|
3850
|
+
console.error(`[inference-stale] agent=${agentName} busy (${shownStatus}) — ` +
|
|
3543
3851
|
`${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`);
|
|
3544
3852
|
}
|
|
3545
3853
|
this.pendingRequests.push(...requests);
|
|
@@ -3842,6 +4150,75 @@ export class AgentFramework {
|
|
|
3842
4150
|
}
|
|
3843
4151
|
}
|
|
3844
4152
|
async startAgentStream(agent, trigger, attempt = 0) {
|
|
4153
|
+
// Mark the turn alive before ANYTHING awaits (hooks, compile, stream
|
|
4154
|
+
// setup): from here until this turn's teardown, cross-turn writers defer
|
|
4155
|
+
// (addMessage guard) instead of appending — nothing may enter the window
|
|
4156
|
+
// between a turn's dequeue and its settle except the turn's own blocks.
|
|
4157
|
+
// A retry re-enters with a fresh token; teardown clears token-matched.
|
|
4158
|
+
//
|
|
4159
|
+
// The try/finally is the leak-proofing: a token whose owner never clears
|
|
4160
|
+
// it permanently requeues every wake for the agent (the 'idle+turn-alive'
|
|
4161
|
+
// wedge — worse than any single lost turn). Ownership is handed off
|
|
4162
|
+
// exactly once, to driveStream, the moment the stream handle is
|
|
4163
|
+
// registered; on every other exit — including a throw from the turn-start
|
|
4164
|
+
// flush, recordTurnCheckpoint, or locus resolution, none of which the
|
|
4165
|
+
// inner catch covers (ENOSPC-class store writes live there) — the finally
|
|
4166
|
+
// clears our own token. Token-matched, so when a retry frame has replaced
|
|
4167
|
+
// the token, or the terminal-failure branch already deleted it, this
|
|
4168
|
+
// no-ops instead of clobbering a successor's marker.
|
|
4169
|
+
const turnToken = this.nextTurnToken++;
|
|
4170
|
+
this.activeTurnTokens.set(agent.name, turnToken);
|
|
4171
|
+
let tokenHandedOff = false;
|
|
4172
|
+
try {
|
|
4173
|
+
tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken);
|
|
4174
|
+
}
|
|
4175
|
+
finally {
|
|
4176
|
+
if (!tokenHandedOff && this.activeTurnTokens.get(agent.name) === turnToken) {
|
|
4177
|
+
this.activeTurnTokens.delete(agent.name);
|
|
4178
|
+
}
|
|
4179
|
+
}
|
|
4180
|
+
}
|
|
4181
|
+
/**
|
|
4182
|
+
* Body of startAgentStream, split out so the caller's try/finally owns the
|
|
4183
|
+
* turn token unconditionally. Returns true iff the token was handed off to
|
|
4184
|
+
* driveStream (whose finally then owns clearing it).
|
|
4185
|
+
*/
|
|
4186
|
+
async beginAgentTurn(agent, trigger, attempt, turnToken) {
|
|
4187
|
+
// Flush messages deferred during the PREVIOUS turn — before the
|
|
4188
|
+
// checkpoint, the locus announcement, and the compile — so a turn started
|
|
4189
|
+
// by a queued wake actually CONTAINS the message that woke it. (2026-07-31
|
|
4190
|
+
// Mythos: a DM's wake fired a turn whose compile ran before the deferred
|
|
4191
|
+
// DM flushed; the model saw only the routing-shift notice, reasonably
|
|
4192
|
+
// skipped, and the DM then landed before the skip in the window — false
|
|
4193
|
+
// "saw it and ignored it" history plus KV divergence. Flushed here, the
|
|
4194
|
+
// payload rides its own wake, and the notice/payload can never split.)
|
|
4195
|
+
// Primary agent only — deferred messages are framework-level, meant for
|
|
4196
|
+
// the main conversation (same target rule as the tool-boundary flush).
|
|
4197
|
+
// Fresh turns only: a context-budget restart continues the same logical
|
|
4198
|
+
// turn, and flushing there would insert messages between its rounds.
|
|
4199
|
+
// Before recordTurnCheckpoint: these are the turn's inputs, not its
|
|
4200
|
+
// products — undoing the turn must not destroy them.
|
|
4201
|
+
if (attempt === 0 &&
|
|
4202
|
+
trigger?.reason !== 'context_budget_restart' &&
|
|
4203
|
+
this.deferredMessages.length > 0) {
|
|
4204
|
+
const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
|
|
4205
|
+
if (target === agent) {
|
|
4206
|
+
const deferred = this.deferredMessages.splice(0);
|
|
4207
|
+
for (const msg of deferred) {
|
|
4208
|
+
// Per-message try/catch (mirrors announceLocusIfChanged): one
|
|
4209
|
+
// poison message — or a transient store-write failure — must not
|
|
4210
|
+
// abort the turn or drop the messages behind it in the queue.
|
|
4211
|
+
try {
|
|
4212
|
+
const id = agent.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
|
|
4213
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'deferred-flush:turn-start' });
|
|
4214
|
+
}
|
|
4215
|
+
catch (err) {
|
|
4216
|
+
console.error(`[deferred-flush] turn-start flush failed to store a message for ${agent.name} ` +
|
|
4217
|
+
`(participant=${msg.participant}):`, err);
|
|
4218
|
+
}
|
|
4219
|
+
}
|
|
4220
|
+
}
|
|
4221
|
+
}
|
|
3845
4222
|
// Record turn checkpoint before inference (only on first attempt, not retries)
|
|
3846
4223
|
if (attempt === 0) {
|
|
3847
4224
|
this.recordTurnCheckpoint(agent.name);
|
|
@@ -3913,6 +4290,20 @@ export class AgentFramework {
|
|
|
3913
4290
|
});
|
|
3914
4291
|
this.eventGate?.onInferenceStarted(agent.name);
|
|
3915
4292
|
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
|
|
4293
|
+
// Typing indicator, started at TURN START. It says "attending", which is
|
|
4294
|
+
// true from the moment the turn is accepted — but it used to start inside
|
|
4295
|
+
// driveStream, i.e. only after module gatherContext + MCPL beforeInference
|
|
4296
|
+
// RPCs + the full context compile + stream initiation. All of that read as
|
|
4297
|
+
// dead air to whoever just messaged the agent (30+ s during the 2026-07
|
|
4298
|
+
// mythos compile regression; still seconds of hooks+compile after).
|
|
4299
|
+
// Same channel choice as driveStream's own startTyping (which stays, is
|
|
4300
|
+
// idempotent per channel, and owns the 7s refresh); the catch below stops
|
|
4301
|
+
// it on the no-driveStream failure paths (e.g. a compile refusal).
|
|
4302
|
+
const earlyTypingChannel = agent.proseRouting === 'explicit'
|
|
4303
|
+
? trigger?.channelId ?? null
|
|
4304
|
+
: this.turnLocusPins.get(agent.name) ?? null;
|
|
4305
|
+
if (earlyTypingChannel)
|
|
4306
|
+
this.channelRegistry?.startTyping(earlyTypingChannel);
|
|
3916
4307
|
try {
|
|
3917
4308
|
const requestSnapshot = this.captureInferenceToolSnapshot(agent);
|
|
3918
4309
|
const allTools = this.getToolsForAgent(agent.name, requestSnapshot);
|
|
@@ -3954,8 +4345,15 @@ export class AgentFramework {
|
|
|
3954
4345
|
const { stream, request: compiledRequest } = await agent.startStreamWithInjections(tools, injections);
|
|
3955
4346
|
const handle = this.driveStream(agent, stream, requestSnapshot, trigger, attempt, compiledRequest);
|
|
3956
4347
|
this.activeStreams.set(agent.name, handle);
|
|
4348
|
+
// Handoff: driveStream captured the token in its synchronous prefix;
|
|
4349
|
+
// its finally now owns clearing it. The caller's finally must not.
|
|
4350
|
+
return true;
|
|
3957
4351
|
}
|
|
3958
4352
|
catch (error) {
|
|
4353
|
+
// The early typing indicator has no driveStream `finally` on this path —
|
|
4354
|
+
// a hook/compile/stream-setup failure must not leave "typing…" stuck.
|
|
4355
|
+
// (Retries below restart it; stopTyping is idempotent.)
|
|
4356
|
+
this.channelRegistry?.stopTyping();
|
|
3959
4357
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
3960
4358
|
this.emitTrace({
|
|
3961
4359
|
type: 'inference:failed',
|
|
@@ -3967,9 +4365,19 @@ export class AgentFramework {
|
|
|
3967
4365
|
const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
|
|
3968
4366
|
if (action.retry) {
|
|
3969
4367
|
await new Promise((resolve) => setTimeout(resolve, action.delayMs));
|
|
4368
|
+
// The retry re-enters startAgentStream, which replaces this frame's
|
|
4369
|
+
// turn token with its own; cleanup belongs to the innermost frame.
|
|
3970
4370
|
await this.startAgentStream(agent, trigger, attempt + 1);
|
|
3971
4371
|
}
|
|
3972
4372
|
else {
|
|
4373
|
+
// Terminal failure with no driveStream: this frame owns teardown.
|
|
4374
|
+
// Token-matched (like the finally in driveStream) so a stale frame
|
|
4375
|
+
// can never clear a successor turn's marker. A leaked token would
|
|
4376
|
+
// permanently requeue every wake for this agent (same wedge shape as
|
|
4377
|
+
// the eventGate `inferring` leak) — every exit path must clear it.
|
|
4378
|
+
if (this.activeTurnTokens.get(agent.name) === turnToken) {
|
|
4379
|
+
this.activeTurnTokens.delete(agent.name);
|
|
4380
|
+
}
|
|
3973
4381
|
this.settleAgent(agent.name, {
|
|
3974
4382
|
stopReason: 'exhausted',
|
|
3975
4383
|
speech: '',
|
|
@@ -3993,12 +4401,22 @@ export class AgentFramework {
|
|
|
3993
4401
|
this.pushEvent(action.emit);
|
|
3994
4402
|
}
|
|
3995
4403
|
}
|
|
4404
|
+
// No handoff on either failure branch: the terminal branch deleted the
|
|
4405
|
+
// token above; the retry branch's recursive startAgentStream replaced
|
|
4406
|
+
// it with the inner frame's token. Returning false lets the caller's
|
|
4407
|
+
// token-matched finally no-op in both cases (and actually clear it if
|
|
4408
|
+
// something above threw before either branch ran).
|
|
4409
|
+
return false;
|
|
3996
4410
|
}
|
|
3997
4411
|
}
|
|
3998
4412
|
async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest) {
|
|
3999
4413
|
const startTime = Date.now();
|
|
4000
4414
|
const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
|
|
4001
4415
|
const myStreamId = agent.streamId;
|
|
4416
|
+
// This turn's alive-marker, set at startAgentStream entry. Safe to read
|
|
4417
|
+
// from the map here: the turn-alive busy check in processInferenceRequests
|
|
4418
|
+
// means no successor turn can have replaced it while we compiled.
|
|
4419
|
+
const myTurnToken = this.activeTurnTokens.get(agent.name);
|
|
4002
4420
|
let hadToolCalls = false;
|
|
4003
4421
|
// ---- Present-while-acting turn state ---------------------------------
|
|
4004
4422
|
// Output locus for the WHOLE logical turn: frozen in startAgentStream
|
|
@@ -4799,6 +5217,16 @@ export class AgentFramework {
|
|
|
4799
5217
|
if (this.closingConversationAgents.has(agent.name)) {
|
|
4800
5218
|
this.disposeConversationAgent(agent.name);
|
|
4801
5219
|
}
|
|
5220
|
+
// The turn is torn down — release the turn-alive marker BEFORE the
|
|
5221
|
+
// deferred flush below, so the flush appends at the settled tail.
|
|
5222
|
+
// Token-matched: if a successor turn already owns the agent (endTurn
|
|
5223
|
+
// reset the state machine before this finally ran — the 2026-07-31
|
|
5224
|
+
// interleave), leave its marker alone; the guarded addMessage below
|
|
5225
|
+
// then re-defers, and the successor's own turn-start flush / boundary
|
|
5226
|
+
// injection / teardown delivers the messages at a correct position.
|
|
5227
|
+
if (myTurnToken !== undefined && this.activeTurnTokens.get(agent.name) === myTurnToken) {
|
|
5228
|
+
this.activeTurnTokens.delete(agent.name);
|
|
5229
|
+
}
|
|
4802
5230
|
// Flush any deferred messages (e.g. if stream failed while tools were pending)
|
|
4803
5231
|
if (this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
|
|
4804
5232
|
const deferred = this.deferredMessages.splice(0);
|
|
@@ -4812,8 +5240,22 @@ export class AgentFramework {
|
|
|
4812
5240
|
* Execute a tool call and return the result.
|
|
4813
5241
|
* Routes to the appropriate handler (module registry or MCPL).
|
|
4814
5242
|
* Used by SubagentModule to dispatch tool calls for ephemeral agents.
|
|
5243
|
+
*
|
|
5244
|
+
* This public entry always carries AGENT origin. Ephemeral/promise-based
|
|
5245
|
+
* callers are models, and provenance must identify which internal path a
|
|
5246
|
+
* call entered through — a public method shared with model callers can
|
|
5247
|
+
* never confer module trust, whatever the input claims. Module origin
|
|
5248
|
+
* exists only through the private closure handed to ModuleRegistry
|
|
5249
|
+
* (ctx.callTool → executeToolCallFrom with {kind:'module'}). Callers that
|
|
5250
|
+
* don't identify themselves fail safe to agent semantics too.
|
|
4815
5251
|
*/
|
|
4816
5252
|
async executeToolCall(call) {
|
|
5253
|
+
return this.executeToolCallFrom(call, {
|
|
5254
|
+
kind: 'agent',
|
|
5255
|
+
agentName: call.callerAgentName ?? '__ephemeral__',
|
|
5256
|
+
});
|
|
5257
|
+
}
|
|
5258
|
+
async executeToolCallFrom(call, origin) {
|
|
4817
5259
|
// Client-side programmatic tool calling for promise-based callers
|
|
4818
5260
|
// (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral
|
|
4819
5261
|
// gets its own interpreter state.
|
|
@@ -4832,6 +5274,25 @@ export class AgentFramework {
|
|
|
4832
5274
|
}
|
|
4833
5275
|
}
|
|
4834
5276
|
}
|
|
5277
|
+
// Synthesized channel tools. This route was missing: when
|
|
5278
|
+
// subscription-GC moved off the retired MCPL unsubscribe tool onto the
|
|
5279
|
+
// generic channel_close (discord-mcpl b095a9f), module-originated
|
|
5280
|
+
// closes started failing downstream as "Invalid tool name format" —
|
|
5281
|
+
// the janitor had been silently broken since. `origin` is trusted
|
|
5282
|
+
// dispatch context established at the call boundary above, not
|
|
5283
|
+
// inferred from which method was used: only the ModuleRegistry closure
|
|
5284
|
+
// carries module provenance (see handleToolClose). The agent-only
|
|
5285
|
+
// synthesized verbs (think, skip_reply) are deliberately not routed
|
|
5286
|
+
// here.
|
|
5287
|
+
if (call.name.startsWith('channel_') && this.channelRegistry) {
|
|
5288
|
+
return this.channelRegistry.handleChannelToolCall(call.name, call.input, origin);
|
|
5289
|
+
}
|
|
5290
|
+
// The utils meta-tool — programmatic callers (code_execution scripts,
|
|
5291
|
+
// ephemerals, ModuleContext.callTool) reach utilities the same way the
|
|
5292
|
+
// model does.
|
|
5293
|
+
if (call.name === 'utils') {
|
|
5294
|
+
return this.handleUtilsToolCall(call.callerAgentName ?? '__ephemeral__', call);
|
|
5295
|
+
}
|
|
4835
5296
|
// Module tools
|
|
4836
5297
|
return this.moduleRegistry.handleToolCall(call);
|
|
4837
5298
|
}
|
|
@@ -4927,6 +5388,43 @@ export class AgentFramework {
|
|
|
4927
5388
|
*/
|
|
4928
5389
|
async runCodeExecution(agentName, call) {
|
|
4929
5390
|
const input = (call.input ?? {});
|
|
5391
|
+
// Management surface: the agent's own daemon fleet is inspectable and
|
|
5392
|
+
// killable through the same tool that spawns it.
|
|
5393
|
+
if (input.action === 'list') {
|
|
5394
|
+
const scripts = [...this.backgroundScripts.values()]
|
|
5395
|
+
.filter((s) => s.agentName === agentName)
|
|
5396
|
+
.map((s) => ({
|
|
5397
|
+
script_id: s.id,
|
|
5398
|
+
status: s.status,
|
|
5399
|
+
started_at: new Date(s.startedAt).toISOString(),
|
|
5400
|
+
wakes: s.wakes,
|
|
5401
|
+
last_wake_at: s.lastWakeAt ? new Date(s.lastWakeAt).toISOString() : null,
|
|
5402
|
+
log: s.logPath,
|
|
5403
|
+
}));
|
|
5404
|
+
return { success: true, data: { background_scripts: scripts } };
|
|
5405
|
+
}
|
|
5406
|
+
if (input.action === 'cancel') {
|
|
5407
|
+
if (typeof input.script_id !== 'string') {
|
|
5408
|
+
return { success: false, error: 'cancel requires `script_id`', isError: true };
|
|
5409
|
+
}
|
|
5410
|
+
const record = this.backgroundScripts.get(input.script_id);
|
|
5411
|
+
if (!record || record.agentName !== agentName) {
|
|
5412
|
+
return { success: false, error: `no background script '${input.script_id}'`, isError: true };
|
|
5413
|
+
}
|
|
5414
|
+
if (record.status === 'running') {
|
|
5415
|
+
record.cancelled = true;
|
|
5416
|
+
record.status = 'cancelled';
|
|
5417
|
+
record.runner.abort('cancelled by agent');
|
|
5418
|
+
record.runner.dispose();
|
|
5419
|
+
}
|
|
5420
|
+
return {
|
|
5421
|
+
success: true,
|
|
5422
|
+
data: { script_id: record.id, status: record.status, log: record.logPath },
|
|
5423
|
+
};
|
|
5424
|
+
}
|
|
5425
|
+
if (input.action !== undefined && input.action !== 'run') {
|
|
5426
|
+
return { success: false, error: `unknown action ${JSON.stringify(input.action)}`, isError: true };
|
|
5427
|
+
}
|
|
4930
5428
|
if (typeof input.code !== 'string' || input.code.trim() === '') {
|
|
4931
5429
|
return {
|
|
4932
5430
|
success: false,
|
|
@@ -4939,6 +5437,9 @@ export class AgentFramework {
|
|
|
4939
5437
|
? this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name))
|
|
4940
5438
|
: this.getAllTools(); // ephemeral agents: full surface, matching executeToolCall
|
|
4941
5439
|
const injected = buildInjectedTools(surface.map((t) => t.name).filter((name) => name !== CODE_EXECUTION_TOOL_NAME));
|
|
5440
|
+
if (input.background === true) {
|
|
5441
|
+
return this.startBackgroundScript(agentName, input.code, injected);
|
|
5442
|
+
}
|
|
4942
5443
|
const runner = this.getOrCreateScriptRunner(agentName);
|
|
4943
5444
|
this.scriptDeferredEndTurn.delete(agentName);
|
|
4944
5445
|
const exec = await runner.exec(input.code, injected);
|
|
@@ -4950,6 +5451,255 @@ export class AgentFramework {
|
|
|
4950
5451
|
...(endTurn ? { endTurn: true } : {}),
|
|
4951
5452
|
};
|
|
4952
5453
|
}
|
|
5454
|
+
// ---------------------------------------------------------------------------
|
|
5455
|
+
// Background (daemon) scripts — model-authored watchers that outlive the turn
|
|
5456
|
+
// ---------------------------------------------------------------------------
|
|
5457
|
+
/**
|
|
5458
|
+
* Start a background script: dedicated interpreter, output journaled to a
|
|
5459
|
+
* workspace file, wake_agent() available in-script. The tool result
|
|
5460
|
+
* returns immediately; the script runs until it ends, hits the lifetime
|
|
5461
|
+
* ceiling, or is cancelled. Scripts DIE WITH THE HOST PROCESS — that
|
|
5462
|
+
* limitation is stated in the tool description so agents can calibrate
|
|
5463
|
+
* how much to rely on an armed wake.
|
|
5464
|
+
*/
|
|
5465
|
+
startBackgroundScript(agentName, code, injected) {
|
|
5466
|
+
// v1: primary-agent-only. A conversation fork's or ephemeral's daemon
|
|
5467
|
+
// would outlive its owner and its wake would land in the primary
|
|
5468
|
+
// conversation anyway — refuse loudly instead of surprising anyone.
|
|
5469
|
+
if (this.primaryAgentName && agentName !== this.primaryAgentName) {
|
|
5470
|
+
return {
|
|
5471
|
+
success: false,
|
|
5472
|
+
error: 'background scripts are available to the primary agent only',
|
|
5473
|
+
isError: true,
|
|
5474
|
+
};
|
|
5475
|
+
}
|
|
5476
|
+
const cfg = this.codeExecutionConfig;
|
|
5477
|
+
const maxScripts = cfg?.maxBackgroundScripts ?? 3;
|
|
5478
|
+
const runningCount = [...this.backgroundScripts.values()]
|
|
5479
|
+
.filter((s) => s.agentName === agentName && s.status === 'running').length;
|
|
5480
|
+
if (runningCount >= maxScripts) {
|
|
5481
|
+
return {
|
|
5482
|
+
success: false,
|
|
5483
|
+
error: `background script limit reached (${maxScripts} running). ` +
|
|
5484
|
+
'Use code_execution {"action": "list"} to inspect and {"action": "cancel", "script_id": ...} to free a slot.',
|
|
5485
|
+
isError: true,
|
|
5486
|
+
};
|
|
5487
|
+
}
|
|
5488
|
+
const scriptId = `bg-${++this.backgroundScriptCounter}`;
|
|
5489
|
+
const lifetimeMs = cfg?.backgroundMaxLifetimeMs ?? 86_400_000;
|
|
5490
|
+
// Journal: a file under the agent's first read-write workspace mount so
|
|
5491
|
+
// their existing read/grep/shell tools work on it. Python appends
|
|
5492
|
+
// directly to the materialized path; watch-mode mounts sync it back to
|
|
5493
|
+
// the chronicle tree on their usual cadence.
|
|
5494
|
+
let logPath = null;
|
|
5495
|
+
let logAbsPath = null;
|
|
5496
|
+
const workspace = this.getWorkspaceModule();
|
|
5497
|
+
if (workspace) {
|
|
5498
|
+
const mountName = this.firstWritableMountName(workspace);
|
|
5499
|
+
if (mountName) {
|
|
5500
|
+
logPath = `${mountName}/background-scripts/${scriptId}.log`;
|
|
5501
|
+
logAbsPath = workspace.resolveAbsolutePath(logPath);
|
|
5502
|
+
}
|
|
5503
|
+
}
|
|
5504
|
+
const runner = new PyRunner({
|
|
5505
|
+
pythonPath: cfg?.pythonPath,
|
|
5506
|
+
toolCallTimeoutMs: cfg?.toolCallTimeoutMs,
|
|
5507
|
+
scriptTimeoutMs: cfg?.scriptTimeoutMs,
|
|
5508
|
+
idleReclaimMs: 0, // dedicated runner; lifetime is the exec deadline
|
|
5509
|
+
label: `${agentName}:${scriptId}`,
|
|
5510
|
+
onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args),
|
|
5511
|
+
});
|
|
5512
|
+
const record = {
|
|
5513
|
+
id: scriptId,
|
|
5514
|
+
agentName,
|
|
5515
|
+
runner,
|
|
5516
|
+
code,
|
|
5517
|
+
startedAt: Date.now(),
|
|
5518
|
+
wakes: 0,
|
|
5519
|
+
lastWakeAt: null,
|
|
5520
|
+
logPath,
|
|
5521
|
+
status: 'running',
|
|
5522
|
+
cancelled: false,
|
|
5523
|
+
};
|
|
5524
|
+
this.backgroundScripts.set(scriptId, record);
|
|
5525
|
+
this.emitTrace({
|
|
5526
|
+
type: 'tool:started', module: 'code_execution', tool: `background:${scriptId}`,
|
|
5527
|
+
callId: scriptId, input: { lines: code.split('\n').length },
|
|
5528
|
+
});
|
|
5529
|
+
void runner
|
|
5530
|
+
.exec(code, injected, {
|
|
5531
|
+
logPath: logAbsPath,
|
|
5532
|
+
lifetimeMs,
|
|
5533
|
+
onWake: (line, payload) => this.handleScriptWake(record, line, payload),
|
|
5534
|
+
})
|
|
5535
|
+
.then((exec) => this.settleBackgroundScript(record, exec))
|
|
5536
|
+
.catch((err) => {
|
|
5537
|
+
// exec never rejects by contract; this is the belt-and-suspenders.
|
|
5538
|
+
console.error(`[pytc:${agentName}:${scriptId}] background exec rejected: ${String(err)}`);
|
|
5539
|
+
this.settleBackgroundScript(record, {
|
|
5540
|
+
stdout: '', stderr: String(err), returnCode: 1, aborted: true,
|
|
5541
|
+
});
|
|
5542
|
+
});
|
|
5543
|
+
return {
|
|
5544
|
+
success: true,
|
|
5545
|
+
data: {
|
|
5546
|
+
status: 'background script started',
|
|
5547
|
+
script_id: scriptId,
|
|
5548
|
+
log: logPath ?? 'no writable workspace mount — output is not retrievable; only wake_agent() reaches you',
|
|
5549
|
+
lifetime_hours: Math.round(lifetimeMs / 3_600_000 * 10) / 10,
|
|
5550
|
+
note: 'The script dies if the host process restarts. Manage with code_execution {"action": "list"|"cancel"}.',
|
|
5551
|
+
},
|
|
5552
|
+
};
|
|
5553
|
+
}
|
|
5554
|
+
/** First read-write mount name, or null. */
|
|
5555
|
+
firstWritableMountName(workspace) {
|
|
5556
|
+
const mounts = workspace.getMounts?.();
|
|
5557
|
+
if (mounts) {
|
|
5558
|
+
const rw = mounts.find((m) => m.mode !== 'read-only');
|
|
5559
|
+
return rw?.name ?? null;
|
|
5560
|
+
}
|
|
5561
|
+
return null;
|
|
5562
|
+
}
|
|
5563
|
+
/**
|
|
5564
|
+
* wake_agent() from a background script: enforce the per-script wake cap
|
|
5565
|
+
* and rate floor (delay, not drop — the script awaits the ack), then
|
|
5566
|
+
* inject the provenance envelope + payload and request inference.
|
|
5567
|
+
* Resolves null on delivery; an error string refuses the wake (raises
|
|
5568
|
+
* RuntimeError inside the script).
|
|
5569
|
+
*/
|
|
5570
|
+
async handleScriptWake(record, line, payload) {
|
|
5571
|
+
if (record.cancelled)
|
|
5572
|
+
return 'script was cancelled';
|
|
5573
|
+
const cfg = this.codeExecutionConfig;
|
|
5574
|
+
const maxWakes = cfg?.maxWakesPerScript ?? 100;
|
|
5575
|
+
if (record.wakes >= maxWakes) {
|
|
5576
|
+
return `wake limit reached (${maxWakes} per script)`;
|
|
5577
|
+
}
|
|
5578
|
+
const floorMs = cfg?.wakeMinIntervalMs ?? 60_000;
|
|
5579
|
+
if (record.lastWakeAt !== null) {
|
|
5580
|
+
const wait = record.lastWakeAt + floorMs - Date.now();
|
|
5581
|
+
if (wait > 0) {
|
|
5582
|
+
await new Promise((r) => {
|
|
5583
|
+
const t = setTimeout(r, wait);
|
|
5584
|
+
t.unref?.();
|
|
5585
|
+
});
|
|
5586
|
+
if (record.cancelled)
|
|
5587
|
+
return 'script was cancelled';
|
|
5588
|
+
}
|
|
5589
|
+
}
|
|
5590
|
+
record.wakes += 1;
|
|
5591
|
+
record.lastWakeAt = Date.now();
|
|
5592
|
+
const elapsedMin = Math.round((Date.now() - record.startedAt) / 60_000);
|
|
5593
|
+
const payloadJson = payload === undefined || payload === null
|
|
5594
|
+
? 'null'
|
|
5595
|
+
: JSON.stringify(payload, null, 1) ?? String(payload);
|
|
5596
|
+
const agent = this.agents.get(record.agentName);
|
|
5597
|
+
const maxChars = agent ? this.getMaxToolResultChars(agent) : undefined;
|
|
5598
|
+
const payloadText = await this.spillOrTruncate(payloadJson, maxChars, `${record.id}-wake${record.wakes}`);
|
|
5599
|
+
const envelope = `[background script ${record.id}] Woke you: wake_agent() called at line ${line} of your script, ` +
|
|
5600
|
+
`${elapsedMin}m after you started it (wake ${record.wakes} of ${maxWakes}).\n` +
|
|
5601
|
+
`Arguments:\n${payloadText}\n` +
|
|
5602
|
+
(record.logPath ? `Script output so far: workspace file ${record.logPath}\n` : '') +
|
|
5603
|
+
`Script status: still running`;
|
|
5604
|
+
this.injectScriptWake(record, envelope);
|
|
5605
|
+
return null;
|
|
5606
|
+
}
|
|
5607
|
+
/** Background script ended (clean, crash, deadline, cancel/dispose). */
|
|
5608
|
+
settleBackgroundScript(record, exec) {
|
|
5609
|
+
record.runner.dispose();
|
|
5610
|
+
if (record.status === 'running') {
|
|
5611
|
+
record.status = exec.returnCode === 0 ? 'finished' : 'died';
|
|
5612
|
+
}
|
|
5613
|
+
this.emitTrace({
|
|
5614
|
+
type: 'tool:completed', module: 'code_execution', tool: `background:${record.id}`,
|
|
5615
|
+
callId: record.id, durationMs: Date.now() - record.startedAt,
|
|
5616
|
+
});
|
|
5617
|
+
// Crash honesty: a resident sleeping on a promise must never have that
|
|
5618
|
+
// promise die silently. Deliberate cancels/disposes stay silent (the
|
|
5619
|
+
// agent or host chose them); clean exits stay silent (the null path).
|
|
5620
|
+
if (record.status === 'died' && !record.cancelled) {
|
|
5621
|
+
const tail = (exec.tail ?? exec.stderr ?? '').slice(-2000);
|
|
5622
|
+
const elapsedMin = Math.round((Date.now() - record.startedAt) / 60_000);
|
|
5623
|
+
const envelope = `[background script ${record.id}] Your background script DIED ${elapsedMin}m after start ` +
|
|
5624
|
+
`(it did not call wake_agent and is no longer watching).\n` +
|
|
5625
|
+
`Last output:\n${tail || '(none)'}\n` +
|
|
5626
|
+
(record.logPath ? `Full journal: workspace file ${record.logPath}` : '');
|
|
5627
|
+
this.injectScriptWake(record, envelope);
|
|
5628
|
+
}
|
|
5629
|
+
// Keep a short memory of settled scripts for {"action":"list"}, then drop.
|
|
5630
|
+
const settled = [...this.backgroundScripts.values()]
|
|
5631
|
+
.filter((s) => s.agentName === record.agentName && s.status !== 'running');
|
|
5632
|
+
for (const old of settled.slice(0, Math.max(0, settled.length - 5))) {
|
|
5633
|
+
this.backgroundScripts.delete(old.id);
|
|
5634
|
+
}
|
|
5635
|
+
}
|
|
5636
|
+
/**
|
|
5637
|
+
* Deliver a background-script event: provenance envelope into the agent's
|
|
5638
|
+
* context + an inference request. Deliberately does NOT consult the event
|
|
5639
|
+
* gate — the agent armed this wake themselves (wake-through-sleep
|
|
5640
|
+
* authority); it enters tagged so gate policies could be taught about it
|
|
5641
|
+
* later if that ever needs revisiting.
|
|
5642
|
+
*/
|
|
5643
|
+
injectScriptWake(record, envelope) {
|
|
5644
|
+
try {
|
|
5645
|
+
const messageId = this.addMessage('user', [{ type: 'text', text: envelope }], {
|
|
5646
|
+
source: 'background-script',
|
|
5647
|
+
scriptId: record.id,
|
|
5648
|
+
tags: ['script:wake'],
|
|
5649
|
+
});
|
|
5650
|
+
this.pendingRequests.push({
|
|
5651
|
+
agentName: record.agentName,
|
|
5652
|
+
reason: 'script:wake',
|
|
5653
|
+
source: 'code_execution',
|
|
5654
|
+
timestamp: Date.now(),
|
|
5655
|
+
});
|
|
5656
|
+
this.emitTrace({
|
|
5657
|
+
type: 'message:added',
|
|
5658
|
+
messageId,
|
|
5659
|
+
source: `background-script:${record.id}`,
|
|
5660
|
+
});
|
|
5661
|
+
}
|
|
5662
|
+
catch (err) {
|
|
5663
|
+
console.error(`[pytc:${record.agentName}:${record.id}] wake injection failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
5664
|
+
}
|
|
5665
|
+
}
|
|
5666
|
+
/**
|
|
5667
|
+
* Oversized content policy (antra, 2026-07-31): never hit the agent in
|
|
5668
|
+
* the face with a huge blob, never silently destroy it either. Content
|
|
5669
|
+
* over the inline cap is materialized to a workspace file (readable with
|
|
5670
|
+
* the agent's own tools) and replaced by a truncated head + a trailing
|
|
5671
|
+
* file reference. Falls back to plain truncation when there is no
|
|
5672
|
+
* writable workspace. The cap can be lifted temporarily via
|
|
5673
|
+
* agent_settings `tool_result_inline_max_chars`.
|
|
5674
|
+
*/
|
|
5675
|
+
async spillOrTruncate(content, maxChars, label, agentName) {
|
|
5676
|
+
const override = agentName !== undefined
|
|
5677
|
+
? this.toolResultInlineMaxCharsOverride.get(agentName)
|
|
5678
|
+
: undefined;
|
|
5679
|
+
const cap = override ?? maxChars;
|
|
5680
|
+
if (!cap || content.length <= cap)
|
|
5681
|
+
return content;
|
|
5682
|
+
const workspace = this.getWorkspaceModule();
|
|
5683
|
+
const mountName = workspace ? this.firstWritableMountName(workspace) : null;
|
|
5684
|
+
if (workspace && mountName) {
|
|
5685
|
+
const safeLabel = label.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
|
|
5686
|
+
const path = `${mountName}/tool-results/${safeLabel}.txt`;
|
|
5687
|
+
try {
|
|
5688
|
+
const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain');
|
|
5689
|
+
if (result.success) {
|
|
5690
|
+
return safeSlice(content, 0, cap)
|
|
5691
|
+
+ `\n\n[truncated — showing ${cap} of ${content.length} chars; full content: workspace file ${path}. `
|
|
5692
|
+
+ 'Read/grep it with your file tools, or raise the inline cap temporarily via '
|
|
5693
|
+
+ 'agent_settings update tool_result_inline_max_chars.]';
|
|
5694
|
+
}
|
|
5695
|
+
}
|
|
5696
|
+
catch {
|
|
5697
|
+
// fall through to plain truncation
|
|
5698
|
+
}
|
|
5699
|
+
}
|
|
5700
|
+
return safeSlice(content, 0, cap)
|
|
5701
|
+
+ '\n\n[truncated — original was ' + content.length + ' chars]';
|
|
5702
|
+
}
|
|
4953
5703
|
getOrCreateScriptRunner(agentName) {
|
|
4954
5704
|
let runner = this.codeExecutionRunners.get(agentName);
|
|
4955
5705
|
if (!runner) {
|
|
@@ -4991,9 +5741,10 @@ export class AgentFramework {
|
|
|
4991
5741
|
}
|
|
4992
5742
|
if (result.data === undefined)
|
|
4993
5743
|
return '';
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
5744
|
+
// Scripts get (nearly) full data — the script environment IS the spill:
|
|
5745
|
+
// filtering big results in code is the whole point. 5MB protocol safety
|
|
5746
|
+
// cap only; no context-cap truncation, no file spill.
|
|
5747
|
+
return toolResultDataToHistoryString(result.data, 5_000_000);
|
|
4997
5748
|
}
|
|
4998
5749
|
/**
|
|
4999
5750
|
* Dispatch a script-inner tool call through the NORMAL dispatch path and
|
|
@@ -5041,7 +5792,10 @@ export class AgentFramework {
|
|
|
5041
5792
|
runner.abort(reason);
|
|
5042
5793
|
}
|
|
5043
5794
|
}
|
|
5044
|
-
toMembraneToolResult(callId, afResult, maxChars
|
|
5795
|
+
toMembraneToolResult(callId, afResult, maxChars,
|
|
5796
|
+
/** Pre-spilled string from the history path — used for the non-image
|
|
5797
|
+
* path so the live wire copy byte-matches what the window stored. */
|
|
5798
|
+
precomputed) {
|
|
5045
5799
|
if (afResult.isError) {
|
|
5046
5800
|
return { toolUseId: callId, content: afResult.error ?? 'Unknown error', isError: true };
|
|
5047
5801
|
}
|
|
@@ -5053,6 +5807,9 @@ export class AgentFramework {
|
|
|
5053
5807
|
if (blocks) {
|
|
5054
5808
|
return { toolUseId: callId, content: blocks, isError: false };
|
|
5055
5809
|
}
|
|
5810
|
+
if (precomputed !== undefined) {
|
|
5811
|
+
return { toolUseId: callId, content: precomputed, isError: false };
|
|
5812
|
+
}
|
|
5056
5813
|
// JSON.stringify returns the VALUE undefined (not a string) for undefined
|
|
5057
5814
|
// input — a module tool returning `{ success: true }` with no data would
|
|
5058
5815
|
// otherwise make `content.length` below throw a TypeError mid-turn.
|
|
@@ -5219,6 +5976,10 @@ export class AgentFramework {
|
|
|
5219
5976
|
return;
|
|
5220
5977
|
}
|
|
5221
5978
|
// Route the agent's typed, allowlisted hot-settings surface.
|
|
5979
|
+
if (enrichedCall.name === 'utils') {
|
|
5980
|
+
this.dispatchUtilsToolCall(agentName, enrichedCall);
|
|
5981
|
+
return;
|
|
5982
|
+
}
|
|
5222
5983
|
if (enrichedCall.name === 'agent_settings') {
|
|
5223
5984
|
this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
|
|
5224
5985
|
return;
|
|
@@ -5354,15 +6115,25 @@ export class AgentFramework {
|
|
|
5354
6115
|
}
|
|
5355
6116
|
// Defer non-tool_result messages while a tool cycle is mid-flight
|
|
5356
6117
|
// (pendingAssistantBlocks: preserves tool_use → tool_result adjacency)
|
|
5357
|
-
// OR while the target agent has a
|
|
5358
|
-
// mid-
|
|
5359
|
-
// though the live conversation never saw it — so the next compile
|
|
5360
|
-
// diverges from the live prefix (prompt-cache bust)
|
|
5361
|
-
//
|
|
5362
|
-
//
|
|
5363
|
-
//
|
|
6118
|
+
// OR while the target agent has a turn in progress AT ALL. A message
|
|
6119
|
+
// stored mid-turn lands BEFORE the turn's assistant blocks in the window
|
|
6120
|
+
// even though the live conversation never saw it — so the next compile
|
|
6121
|
+
// diverges from the live prefix (prompt-cache bust), the message misses
|
|
6122
|
+
// mid-turn injection, and the window falsely testifies the agent saw it
|
|
6123
|
+
// and moved on. Turn-alive (activeTurnTokens) is the guard, not stream-
|
|
6124
|
+
// alive: a turn exists from dequeue through hooks + compile before its
|
|
6125
|
+
// stream registers in activeStreams, and the 2026-07-31 Mythos incident
|
|
6126
|
+
// was precisely a deferred flush landing inside that compile window.
|
|
6127
|
+
// (activeStreams is kept as a belt-and-suspenders backstop.) Deferred
|
|
6128
|
+
// messages flush at the next turn's START (before its compile — riding
|
|
6129
|
+
// their own wake), at the agent's next tool boundary (where they are
|
|
6130
|
+
// ALSO injected into the live stream — hear-while-acting), or in
|
|
6131
|
+
// driveStream's finally when the turn ends.
|
|
5364
6132
|
const hasToolResult = content.some(b => b.type === 'tool_result');
|
|
5365
|
-
if (!hasToolResult &&
|
|
6133
|
+
if (!hasToolResult &&
|
|
6134
|
+
(this.pendingAssistantBlocks.size > 0 ||
|
|
6135
|
+
this.activeTurnTokens.has(agent.name) ||
|
|
6136
|
+
this.activeStreams.has(agent.name))) {
|
|
5366
6137
|
this.deferredMessages.push({ participant, content, metadata });
|
|
5367
6138
|
return ''; // Deferred — flushed at the next boundary
|
|
5368
6139
|
}
|
|
@@ -6816,7 +7587,7 @@ export class AgentFramework {
|
|
|
6816
7587
|
}
|
|
6817
7588
|
this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input });
|
|
6818
7589
|
const startTime = Date.now();
|
|
6819
|
-
this.channelRegistry.handleChannelToolCall(call.name, call.input)
|
|
7590
|
+
this.channelRegistry.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName })
|
|
6820
7591
|
.then((result) => {
|
|
6821
7592
|
const durationMs = Date.now() - startTime;
|
|
6822
7593
|
this.emitTrace({ type: 'tool:completed', module: 'channels', tool: call.name, callId: call.id, durationMs });
|
|
@@ -7029,6 +7800,31 @@ export class AgentFramework {
|
|
|
7029
7800
|
if (typeof input.path !== 'string' || input.path.length === 0) {
|
|
7030
7801
|
throw new Error('read_image: `path` (mount-prefixed) is required');
|
|
7031
7802
|
}
|
|
7803
|
+
// Anthropic rejects images >5MB (and >8000px); guard the hard byte
|
|
7804
|
+
// limit here — resizing is out of scope without an image library.
|
|
7805
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
7806
|
+
// Prefer the workspace module's own read_image handler when it exists:
|
|
7807
|
+
// it reads tree-first with a FILESYSTEM FALLBACK, so binaries that live
|
|
7808
|
+
// only on disk (shell-created files, pre-fix mangled tree blobs) still
|
|
7809
|
+
// render. The legacy tree-only path below stays for older workspace
|
|
7810
|
+
// modules that don't advertise the tool.
|
|
7811
|
+
if (typeof workspace.handleToolCall === 'function' &&
|
|
7812
|
+
typeof workspace.getTools === 'function' &&
|
|
7813
|
+
workspace.getTools().some((tool) => tool.name === 'read_image')) {
|
|
7814
|
+
const result = await workspace.handleToolCall({ ...call, name: 'read_image' });
|
|
7815
|
+
if (result.success && Array.isArray(result.data)) {
|
|
7816
|
+
const image = result.data.find((block) => typeof block === 'object' && block !== null &&
|
|
7817
|
+
block.type === 'image' &&
|
|
7818
|
+
typeof block.data === 'string');
|
|
7819
|
+
const byteLength = image ? Math.floor((image.data.length * 3) / 4) : 0;
|
|
7820
|
+
if (byteLength > MAX_IMAGE_BYTES) {
|
|
7821
|
+
throw new Error(`read_image: "${input.path}" is ~${byteLength} bytes — ` +
|
|
7822
|
+
`over the ${MAX_IMAGE_BYTES}-byte model limit`);
|
|
7823
|
+
}
|
|
7824
|
+
}
|
|
7825
|
+
finish(result);
|
|
7826
|
+
return;
|
|
7827
|
+
}
|
|
7032
7828
|
const read = await workspace.readBinary(input.path);
|
|
7033
7829
|
if ('error' in read)
|
|
7034
7830
|
throw new Error(read.error);
|
|
@@ -7037,9 +7833,6 @@ export class AgentFramework {
|
|
|
7037
7833
|
throw new Error(`read_image: "${input.path}" does not look like a supported image ` +
|
|
7038
7834
|
'(png/jpeg/gif/webp — checked by magic bytes, not extension)');
|
|
7039
7835
|
}
|
|
7040
|
-
// Anthropic rejects images >5MB (and >8000px); guard the hard byte
|
|
7041
|
-
// limit here — resizing is out of scope without an image library.
|
|
7042
|
-
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
7043
7836
|
if (read.data.byteLength > MAX_IMAGE_BYTES) {
|
|
7044
7837
|
throw new Error(`read_image: "${input.path}" is ${read.data.byteLength} bytes — ` +
|
|
7045
7838
|
`over the ${MAX_IMAGE_BYTES}-byte model limit`);
|
|
@@ -7201,6 +7994,81 @@ export class AgentFramework {
|
|
|
7201
7994
|
}
|
|
7202
7995
|
})();
|
|
7203
7996
|
}
|
|
7997
|
+
/** The `utils` meta-tool: list/describe/run over module utilities
|
|
7998
|
+
* (Module.getUtilities). Shared by the model-facing dispatcher and the
|
|
7999
|
+
* programmatic path (executeToolCall), so code_execution scripts and
|
|
8000
|
+
* ephemerals reach utilities too. */
|
|
8001
|
+
async handleUtilsToolCall(agentName, call) {
|
|
8002
|
+
const input = (call.input ?? {});
|
|
8003
|
+
const utilities = this.moduleRegistry.getAllUtilities();
|
|
8004
|
+
const available = () => utilities.length ? utilities.map((u) => u.name).join(', ') : '(none registered)';
|
|
8005
|
+
switch (input.action) {
|
|
8006
|
+
case 'list':
|
|
8007
|
+
return {
|
|
8008
|
+
success: true,
|
|
8009
|
+
data: utilities.map((u) => ({ name: u.name, description: utilityOneLiner(u.description) })),
|
|
8010
|
+
};
|
|
8011
|
+
case 'describe': {
|
|
8012
|
+
const def = utilities.find((u) => u.name === input.name);
|
|
8013
|
+
if (!def) {
|
|
8014
|
+
return { success: false, isError: true, error: `No utility "${String(input.name)}". Available: ${available()}` };
|
|
8015
|
+
}
|
|
8016
|
+
return { success: true, data: def };
|
|
8017
|
+
}
|
|
8018
|
+
case 'run': {
|
|
8019
|
+
const def = utilities.find((u) => u.name === input.name);
|
|
8020
|
+
if (!def) {
|
|
8021
|
+
return { success: false, isError: true, error: `No utility "${String(input.name)}". Available: ${available()}` };
|
|
8022
|
+
}
|
|
8023
|
+
const args = input.args ?? {};
|
|
8024
|
+
const problem = validateUtilityArgs(args, def.inputSchema);
|
|
8025
|
+
if (problem) {
|
|
8026
|
+
// Teach by bounce: the error carries the schema, so the retry needs
|
|
8027
|
+
// no describe round-trip.
|
|
8028
|
+
return {
|
|
8029
|
+
success: false,
|
|
8030
|
+
isError: true,
|
|
8031
|
+
error: `${problem}. Schema for ${def.name}: ${JSON.stringify(def.inputSchema)}`,
|
|
8032
|
+
};
|
|
8033
|
+
}
|
|
8034
|
+
// Same dispatch as a first-class tool call — the module cannot tell
|
|
8035
|
+
// which surface the call came through.
|
|
8036
|
+
return this.moduleRegistry.handleToolCall({
|
|
8037
|
+
id: call.id,
|
|
8038
|
+
name: def.name,
|
|
8039
|
+
input: args,
|
|
8040
|
+
callerAgentName: agentName,
|
|
8041
|
+
});
|
|
8042
|
+
}
|
|
8043
|
+
default:
|
|
8044
|
+
return { success: false, isError: true, error: 'action must be "list", "describe", or "run"' };
|
|
8045
|
+
}
|
|
8046
|
+
}
|
|
8047
|
+
dispatchUtilsToolCall(agentName, call) {
|
|
8048
|
+
this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'utils', callId: call.id, input: call.input });
|
|
8049
|
+
const startedAt = Date.now();
|
|
8050
|
+
void this.handleUtilsToolCall(agentName, call)
|
|
8051
|
+
.catch((err) => ({
|
|
8052
|
+
success: false,
|
|
8053
|
+
error: err instanceof Error ? err.message : String(err),
|
|
8054
|
+
isError: true,
|
|
8055
|
+
}))
|
|
8056
|
+
.then((result) => {
|
|
8057
|
+
if (result.success) {
|
|
8058
|
+
this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'utils', callId: call.id, durationMs: Date.now() - startedAt });
|
|
8059
|
+
}
|
|
8060
|
+
else {
|
|
8061
|
+
this.emitTrace({ type: 'tool:failed', module: 'framework', tool: 'utils', callId: call.id, error: result.error ?? 'unknown error' });
|
|
8062
|
+
}
|
|
8063
|
+
this.pushEvent({
|
|
8064
|
+
type: 'tool-result',
|
|
8065
|
+
callId: call.id,
|
|
8066
|
+
agentName,
|
|
8067
|
+
moduleName: 'framework',
|
|
8068
|
+
result,
|
|
8069
|
+
});
|
|
8070
|
+
});
|
|
8071
|
+
}
|
|
7204
8072
|
dispatchAgentSettingsToolCall(agentName, call) {
|
|
7205
8073
|
this.emitTrace({
|
|
7206
8074
|
type: 'tool:started',
|
|
@@ -7243,6 +8111,10 @@ export class AgentFramework {
|
|
|
7243
8111
|
if (input.same_round_think_text_policy !== undefined) {
|
|
7244
8112
|
patch.sameRoundThinkTextPolicy = input.same_round_think_text_policy;
|
|
7245
8113
|
}
|
|
8114
|
+
// Immediate budget decrease: skip the paced descent; the next
|
|
8115
|
+
// compile plans straight at the new budget (one-shot fold-down).
|
|
8116
|
+
if (input.immediate !== undefined)
|
|
8117
|
+
patch.immediate = Boolean(input.immediate);
|
|
7246
8118
|
// Route extension-owned keys to their modules; apply the core patch
|
|
7247
8119
|
// only when it touches core keys (an extension-only update must not
|
|
7248
8120
|
// disturb a converging budget transition).
|