@animalabs/agent-framework 0.7.4 → 0.9.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 +10 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +33 -3
- 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 +234 -5
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1818 -188
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +11 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +66 -0
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-grant.d.ts +115 -0
- package/dist/src/mcpl/capability-grant.d.ts.map +1 -0
- package/dist/src/mcpl/capability-grant.js +291 -0
- package/dist/src/mcpl/capability-grant.js.map +1 -0
- package/dist/src/mcpl/capability-mask.d.ts +51 -0
- package/dist/src/mcpl/capability-mask.d.ts.map +1 -0
- package/dist/src/mcpl/capability-mask.js +190 -0
- package/dist/src/mcpl/capability-mask.js.map +1 -0
- package/dist/src/mcpl/channel-registry.d.ts +30 -2
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +216 -43
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/checkpoint-manager.d.ts +8 -3
- package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
- package/dist/src/mcpl/checkpoint-manager.js +28 -8
- package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
- package/dist/src/mcpl/errors.d.ts +6 -0
- package/dist/src/mcpl/errors.d.ts.map +1 -1
- package/dist/src/mcpl/errors.js +6 -0
- package/dist/src/mcpl/errors.js.map +1 -1
- package/dist/src/mcpl/feature-set-manager.d.ts +10 -1
- package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
- package/dist/src/mcpl/feature-set-manager.js +45 -4
- package/dist/src/mcpl/feature-set-manager.js.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.d.ts +10 -14
- package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.js +58 -67
- package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +1 -1
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/prose-stream-router.d.ts +9 -0
- package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
- package/dist/src/mcpl/prose-stream-router.js +16 -0
- package/dist/src/mcpl/prose-stream-router.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +20 -2
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +93 -9
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +231 -32
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/server-registry.d.ts.map +1 -1
- package/dist/src/mcpl/server-registry.js +4 -3
- package/dist/src/mcpl/server-registry.js.map +1 -1
- package/dist/src/mcpl/tags.d.ts +24 -0
- package/dist/src/mcpl/tags.d.ts.map +1 -0
- package/dist/src/mcpl/tags.js +52 -0
- package/dist/src/mcpl/tags.js.map +1 -0
- 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 +35 -7
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +169 -64
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js +16 -2
- 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/refusal-reactions.d.ts +27 -0
- package/dist/src/refusal-reactions.d.ts.map +1 -0
- package/dist/src/refusal-reactions.js +35 -0
- package/dist/src/refusal-reactions.js.map +1 -0
- package/dist/src/tool-result-history.d.ts +10 -0
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +16 -5
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/types/agent.d.ts +35 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +26 -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/src/types/trace.d.ts +24 -0
- package/dist/src/types/trace.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/channel-registry-routing.test.js +38 -10
- package/dist/test/channel-registry-routing.test.js.map +1 -1
- package/dist/test/channel-registry-typing.test.js +10 -1
- package/dist/test/channel-registry-typing.test.js.map +1 -1
- package/dist/test/checkpoint-attribution.test.js +168 -8
- package/dist/test/checkpoint-attribution.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/conversation-fork-injection-scoping.test.js +8 -3
- package/dist/test/conversation-fork-injection-scoping.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/event-gate-selfwake.test.d.ts +15 -0
- package/dist/test/event-gate-selfwake.test.d.ts.map +1 -0
- package/dist/test/event-gate-selfwake.test.js +125 -0
- package/dist/test/event-gate-selfwake.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 +116 -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/mcpl-awareness-barrier.test.js +10 -0
- package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
- package/dist/test/mcpl-capability-grant.test.d.ts +10 -0
- package/dist/test/mcpl-capability-grant.test.d.ts.map +1 -0
- package/dist/test/mcpl-capability-grant.test.js +109 -0
- package/dist/test/mcpl-capability-grant.test.js.map +1 -0
- package/dist/test/mcpl-capability-scoping.test.d.ts +2 -0
- package/dist/test/mcpl-capability-scoping.test.d.ts.map +1 -0
- package/dist/test/mcpl-capability-scoping.test.js +266 -0
- package/dist/test/mcpl-capability-scoping.test.js.map +1 -0
- package/dist/test/mcpl-gate-roundtrip.test.js +13 -0
- package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
- package/dist/test/mcpl-list-status.test.d.ts +2 -0
- package/dist/test/mcpl-list-status.test.d.ts.map +1 -0
- package/dist/test/mcpl-list-status.test.js +78 -0
- package/dist/test/mcpl-list-status.test.js.map +1 -0
- package/dist/test/mcpl-manifest-changed.test.d.ts +2 -0
- package/dist/test/mcpl-manifest-changed.test.d.ts.map +1 -0
- package/dist/test/mcpl-manifest-changed.test.js +177 -0
- package/dist/test/mcpl-manifest-changed.test.js.map +1 -0
- package/dist/test/mcpl-reconnect-events.test.js +27 -2
- package/dist/test/mcpl-reconnect-events.test.js.map +1 -1
- package/dist/test/mcpl-reconnect-reregistration.test.js +53 -17
- package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
- package/dist/test/mcpl-request-timeout.test.js +1 -1
- package/dist/test/mcpl-request-timeout.test.js.map +1 -1
- 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/physical-window-restart.test.d.ts +14 -0
- package/dist/test/physical-window-restart.test.d.ts.map +1 -0
- package/dist/test/physical-window-restart.test.js +222 -0
- package/dist/test/physical-window-restart.test.js.map +1 -0
- package/dist/test/present-while-acting.test.js +270 -0
- package/dist/test/present-while-acting.test.js.map +1 -1
- package/dist/test/refusal-reaction-baseline.test.d.ts +2 -0
- package/dist/test/refusal-reaction-baseline.test.d.ts.map +1 -0
- package/dist/test/refusal-reaction-baseline.test.js +74 -0
- package/dist/test/refusal-reaction-baseline.test.js.map +1 -0
- package/dist/test/state-scaling-e2e.test.js +7 -1
- package/dist/test/state-scaling-e2e.test.js.map +1 -1
- package/dist/test/state-scaling.test.js +9 -1
- package/dist/test/state-scaling.test.js.map +1 -1
- package/dist/test/tool-result-spill.test.d.ts +13 -0
- package/dist/test/tool-result-spill.test.d.ts.map +1 -0
- package/dist/test/tool-result-spill.test.js +463 -0
- package/dist/test/tool-result-spill.test.js.map +1 -0
- package/dist/test/trunk-channel-routing.test.js +45 -0
- package/dist/test/trunk-channel-routing.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 +4 -4
- package/src/agent.ts +37 -3
- 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 +2080 -203
- package/src/gate/event-gate.ts +72 -0
- package/src/index.ts +1 -0
- package/src/mcpl/capability-grant.ts +319 -0
- package/src/mcpl/capability-mask.ts +228 -0
- package/src/mcpl/channel-registry.ts +265 -43
- package/src/mcpl/checkpoint-manager.ts +28 -8
- package/src/mcpl/errors.ts +7 -0
- package/src/mcpl/feature-set-manager.ts +50 -5
- package/src/mcpl/hook-orchestrator.ts +64 -86
- package/src/mcpl/index.ts +0 -2
- package/src/mcpl/prose-stream-router.ts +17 -0
- package/src/mcpl/push-handler.ts +18 -2
- package/src/mcpl/server-connection.ts +262 -34
- package/src/mcpl/server-registry.ts +4 -3
- package/src/mcpl/tags.ts +50 -0
- package/src/mcpl/transport.ts +37 -7
- package/src/mcpl/types.ts +187 -78
- 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/refusal-reactions.ts +37 -0
- package/src/tool-result-history.ts +17 -5
- package/src/types/agent.ts +36 -0
- package/src/types/framework.ts +27 -0
- package/src/types/module.ts +24 -0
- package/src/types/trace.ts +22 -0
package/dist/src/framework.js
CHANGED
|
@@ -4,11 +4,13 @@ import { JsStore } from '@animalabs/chronicle';
|
|
|
4
4
|
import { MembraneError } from '@animalabs/membrane';
|
|
5
5
|
import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager';
|
|
6
6
|
import { ProcessQueueImpl } from './queue.js';
|
|
7
|
+
import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK } from './refusal-reactions.js';
|
|
7
8
|
import { Agent } from './agent.js';
|
|
8
9
|
import { ModuleRegistry, isStateExistsError } from './module-registry.js';
|
|
9
10
|
import { McplServerRegistry } from './mcpl/server-registry.js';
|
|
10
11
|
import { FeatureSetManager } from './mcpl/feature-set-manager.js';
|
|
11
|
-
import {
|
|
12
|
+
import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './mcpl/capability-grant.js';
|
|
13
|
+
import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js';
|
|
12
14
|
import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
|
|
13
15
|
import { PushHandler } from './mcpl/push-handler.js';
|
|
14
16
|
import { parseProsePrefix } from './mcpl/prose-grammar.js';
|
|
@@ -17,7 +19,7 @@ import { InferenceRouter } from './mcpl/inference-router.js';
|
|
|
17
19
|
import { ChannelRegistry } from './mcpl/channel-registry.js';
|
|
18
20
|
import { ConversationRouter } from './mcpl/conversation-router.js';
|
|
19
21
|
import { safeSlice } from './safe-slice.js';
|
|
20
|
-
import { toolResultDataToHistoryString } from './tool-result-history.js';
|
|
22
|
+
import { toolResultDataToHistoryString, truncateForHistory, DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, } from './tool-result-history.js';
|
|
21
23
|
import { randomUUID } from 'node:crypto';
|
|
22
24
|
import { PyRunner, buildInjectedTools } from './code-execution/py-runner.js';
|
|
23
25
|
import { buildCodeExecutionToolDefinition, CODE_EXECUTION_TOOL_NAME, } from './code-execution/tool-definition.js';
|
|
@@ -79,6 +81,39 @@ const isConversationalInjection = (metadata) => {
|
|
|
79
81
|
return false;
|
|
80
82
|
return true;
|
|
81
83
|
};
|
|
84
|
+
/**
|
|
85
|
+
* Rough token estimate for the blocks a continuation round is about to
|
|
86
|
+
* append to the live wire (issue #92 physical-window projection): the
|
|
87
|
+
* spilled tool-result strings at chars/4, a flat per-image cost for native
|
|
88
|
+
* image blocks the wire path preserves (provider vision tokens are
|
|
89
|
+
* resolution-dependent; 1600 ≈ a max-size Anthropic tile), mid-turn injected
|
|
90
|
+
* text at chars/4, and a small per-block envelope allowance. Deliberately a
|
|
91
|
+
* ceiling-ish heuristic — the cost of overestimating is one early recompile;
|
|
92
|
+
* the cost of underestimating is the 400 this projection exists to prevent.
|
|
93
|
+
*/
|
|
94
|
+
function estimateAppendedRoundTokens(toolResults, spilled, injections) {
|
|
95
|
+
let chars = 0;
|
|
96
|
+
let images = 0;
|
|
97
|
+
for (const tc of toolResults) {
|
|
98
|
+
chars += spilled.get(tc.id)?.text.length ?? 0;
|
|
99
|
+
if (Array.isArray(tc.result.data)) {
|
|
100
|
+
for (const raw of tc.result.data) {
|
|
101
|
+
const b = raw;
|
|
102
|
+
if (b && typeof b === 'object' && b.type === 'image')
|
|
103
|
+
images += 1;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
for (const inj of injections) {
|
|
108
|
+
for (const block of inj.content) {
|
|
109
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
110
|
+
chars += block.text.length;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const blockCount = toolResults.length + injections.length;
|
|
115
|
+
return Math.ceil(chars / 4) + images * 1600 + blockCount * 20;
|
|
116
|
+
}
|
|
82
117
|
/**
|
|
83
118
|
* Explicit delivery tools whose successful use into a closed channel OPENS
|
|
84
119
|
* it (send implies engagement — see openIfClosedForSend). Bare tool names,
|
|
@@ -157,6 +192,61 @@ const PROSE_HELP_TOOL = {
|
|
|
157
192
|
};
|
|
158
193
|
/** Strip the `server--` MCPL prefix from a tool name. */
|
|
159
194
|
const bareToolName = (n) => n.split('--').pop();
|
|
195
|
+
/** The single slot behind which every rarely-used capability lives. Only
|
|
196
|
+
* advertised when at least one module contributes utilities — with none
|
|
197
|
+
* registered the surface is byte-identical to before this existed. */
|
|
198
|
+
const UTILS_TOOL = {
|
|
199
|
+
name: 'utils',
|
|
200
|
+
description: 'Rarely-used utilities, behind one slot instead of each costing a tool. ' +
|
|
201
|
+
'"list" names them all with one-liners; "describe" returns one utility\'s full ' +
|
|
202
|
+
'input schema; "run" invokes it (args are validated against that schema — a ' +
|
|
203
|
+
'miss bounces back with the schema and the specific error).',
|
|
204
|
+
inputSchema: {
|
|
205
|
+
type: 'object',
|
|
206
|
+
properties: {
|
|
207
|
+
action: { type: 'string', enum: ['list', 'describe', 'run'], description: 'What to do.' },
|
|
208
|
+
name: { type: 'string', description: 'Utility name from list (required for describe/run).' },
|
|
209
|
+
args: { type: 'object', description: "Arguments for run, per the utility's schema." },
|
|
210
|
+
},
|
|
211
|
+
required: ['action'],
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
/** First sentence (or line) of a description — what `utils list` shows. */
|
|
215
|
+
const utilityOneLiner = (description) => {
|
|
216
|
+
const line = description.split('\n')[0];
|
|
217
|
+
const dot = line.indexOf('. ');
|
|
218
|
+
return (dot >= 0 ? line.slice(0, dot + 1) : line).slice(0, 160);
|
|
219
|
+
};
|
|
220
|
+
/** Shallow validation of run args against a utility's declared inputSchema:
|
|
221
|
+
* required keys, primitive types, enums. Deliberately not a full JSON-Schema
|
|
222
|
+
* engine — handlers still hand-validate (they always did); this layer exists
|
|
223
|
+
* so an arg-shape miss teaches by bounce instead of failing deep in a handler
|
|
224
|
+
* with a less instructive message. Returns an error string or null. */
|
|
225
|
+
function validateUtilityArgs(input, schema) {
|
|
226
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
227
|
+
return 'args must be an object';
|
|
228
|
+
}
|
|
229
|
+
const obj = input;
|
|
230
|
+
for (const req of schema.required ?? []) {
|
|
231
|
+
if (!(req in obj) || obj[req] === undefined)
|
|
232
|
+
return `missing required "${req}"`;
|
|
233
|
+
}
|
|
234
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
235
|
+
const param = schema.properties?.[key];
|
|
236
|
+
if (!param || value === undefined)
|
|
237
|
+
continue; // unknown keys: handler's business
|
|
238
|
+
if (param.enum && !param.enum.includes(value)) {
|
|
239
|
+
return `"${key}" must be one of ${JSON.stringify(param.enum)}`;
|
|
240
|
+
}
|
|
241
|
+
if (param.type) {
|
|
242
|
+
const actual = Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value;
|
|
243
|
+
const expected = param.type === 'integer' ? 'number' : param.type;
|
|
244
|
+
if (actual !== expected)
|
|
245
|
+
return `"${key}" must be ${param.type}, got ${actual}`;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
160
250
|
import { CheckpointManager } from './mcpl/checkpoint-manager.js';
|
|
161
251
|
import { isToolAllowed } from './mcpl/tool-policy.js';
|
|
162
252
|
import { EventGate } from './gate/event-gate.js';
|
|
@@ -314,8 +404,45 @@ export class AgentFramework {
|
|
|
314
404
|
* plain prose lands (2026-07-21 Cairn lounge misroute). Lives here — not in
|
|
315
405
|
* driveStream locals — so the pin survives a context-budget stream restart,
|
|
316
406
|
* which continues the same logical turn in a fresh driveStream. Cleared or
|
|
317
|
-
* re-set at the start of every non-restart turn.
|
|
407
|
+
* re-set at the start of every non-restart turn. Since 2026-07-31 the pin
|
|
408
|
+
* is no longer fully frozen: an ADDRESSED conversational injection — or a
|
|
409
|
+
* conversational follow-up in a channel the agent itself explicitly sent
|
|
410
|
+
* into this turn (turnEngagedChannels) — re-pins it at the boundary (see
|
|
411
|
+
* the addressed re-pin block in the tool-result handler). Ambient chatter
|
|
412
|
+
* elsewhere, reactions, and system markers still cannot move it. */
|
|
318
413
|
turnLocusPins = new Map();
|
|
414
|
+
/** Channels the agent EXPLICITLY sent into during the CURRENT turn
|
|
415
|
+
* (SEND_ENGAGEMENT_TOOLS, successful calls), per agent. Second re-pin
|
|
416
|
+
* signal: a conversational reply in a channel the agent just engaged is
|
|
417
|
+
* addressed by context even without a mention (2026-07-31 n=7: q's
|
|
418
|
+
* #portables follow-up — no @mention — was answered in trailing prose
|
|
419
|
+
* that followed the stale pin into repligate's DM). No author-kind
|
|
420
|
+
* filter: agent-residents are full participants, and the bot flag
|
|
421
|
+
* tracks nothing that matters (antra). Reactions/system markers are
|
|
422
|
+
* excluded by isConversationalInjection; the engaged-this-turn scope
|
|
423
|
+
* keeps unrelated channels from moving the pin. Cleared at every fresh
|
|
424
|
+
* turn's start; budget restarts keep it. */
|
|
425
|
+
turnEngagedChannels = new Map();
|
|
426
|
+
/** Channels this turn's PLAIN PROSE was actually delivered to, in delivery
|
|
427
|
+
* order (deduped at render). Feeds the `[delivered]` receipt appended at
|
|
428
|
+
* logical turn end: explicit sends receipt themselves via their
|
|
429
|
+
* tool_result, but auto-routed prose previously vanished into the router
|
|
430
|
+
* with no in-window record — the agent could never see where its own
|
|
431
|
+
* words landed (2026-07-31 misroute series; antra: minimal receipts).
|
|
432
|
+
* Cleared at every fresh turn's start; budget restarts keep it (same
|
|
433
|
+
* logical turn, receipt covers the whole turn). */
|
|
434
|
+
turnProseDeliveries = new Map();
|
|
435
|
+
/** Count of plain-prose segments SUPPRESSED this turn by sticky
|
|
436
|
+
* explicit-send silencing. The suppression rule itself is untouched
|
|
437
|
+
* (antra, 2026-07-31: visibility is enough) — but it must never be a
|
|
438
|
+
* silent black hole: Mythos multiplexed two threads in one round (prose
|
|
439
|
+
* for the lane + explicit send for another channel, textbook per the
|
|
440
|
+
* routing doc), the silencing ate the prose reply, and his own record
|
|
441
|
+
* ("scene delivered to #mythos via plain speech — routing confirmed")
|
|
442
|
+
* believed it delivered. The receipt now reports suppression, so the
|
|
443
|
+
* author sees the segment's fate one turn later. Cleared each fresh
|
|
444
|
+
* turn; budget restarts keep it. */
|
|
445
|
+
turnProseSuppressed = new Map();
|
|
319
446
|
/** A tool boundary injected fresh CONVERSATIONAL input (a real message —
|
|
320
447
|
* not a reaction or a system marker) into the live stream. Tells
|
|
321
448
|
* driveStream to clear sticky explicit-send suppression before handling
|
|
@@ -398,6 +525,24 @@ export class AgentFramework {
|
|
|
398
525
|
// Messages deferred while an agent is waiting_for_tools (to preserve
|
|
399
526
|
// tool_use → tool_result adjacency required by the Anthropic API).
|
|
400
527
|
deferredMessages = [];
|
|
528
|
+
// Turn-alive markers (2026-07-31 Mythos phantom-skip incident): agentName →
|
|
529
|
+
// token of the turn currently in progress. Set at startAgentStream ENTRY —
|
|
530
|
+
// before hooks, compile, and stream setup, all of which await — and cleared
|
|
531
|
+
// token-matched when that turn's teardown finishes (driveStream finally /
|
|
532
|
+
// terminal failure path), so a successor turn's marker survives a
|
|
533
|
+
// predecessor's late finally. A turn is alive from dequeue to settled
|
|
534
|
+
// teardown: strictly longer than `activeStreams` membership, which begins
|
|
535
|
+
// only after the stream exists. Guarding addMessage on stream-liveness
|
|
536
|
+
// alone left the compile window open — a deferred message flushed by the
|
|
537
|
+
// previous turn's finally landed in the window MID-COMPILE of the turn its
|
|
538
|
+
// own wake had started: positioned before the new turn's blocks yet absent
|
|
539
|
+
// from its wire request. The agent then "skipped" a message it never saw
|
|
540
|
+
// (falsified history — it later confabulated an apology for the skip), and
|
|
541
|
+
// every subsequent compile diverged from the live prefix at that message
|
|
542
|
+
// (KV bust). Invariant: nothing enters the window between a turn's dequeue
|
|
543
|
+
// and its settle except that turn's own blocks.
|
|
544
|
+
activeTurnTokens = new Map();
|
|
545
|
+
nextTurnToken = 1;
|
|
401
546
|
// Undo/redo state
|
|
402
547
|
turnCounters = new Map(); // agentName → next turnIndex
|
|
403
548
|
redoStacks = new Map(); // agentName → redo entries
|
|
@@ -406,7 +551,6 @@ export class AgentFramework {
|
|
|
406
551
|
// MCPL subsystems (null when no mcplServers configured)
|
|
407
552
|
mcplServerRegistry = null;
|
|
408
553
|
featureSetManager = null;
|
|
409
|
-
scopeManager = null;
|
|
410
554
|
hookOrchestrator = null;
|
|
411
555
|
pushHandler = null;
|
|
412
556
|
inferenceRouter = null;
|
|
@@ -438,7 +582,22 @@ export class AgentFramework {
|
|
|
438
582
|
* deferred and applied to the final code_execution result instead of
|
|
439
583
|
* cancelling the stream mid-script (which would wedge the turn). */
|
|
440
584
|
scriptDeferredEndTurn = new Set();
|
|
585
|
+
/** Background (daemon) scripts: model-authored watchers that outlive their
|
|
586
|
+
* spawning turn. Each gets a DEDICATED PyRunner; wake_agent() injects a
|
|
587
|
+
* provenance envelope + payload and requests inference. Keyed by script id. */
|
|
588
|
+
backgroundScripts = new Map();
|
|
589
|
+
backgroundScriptCounter = 0;
|
|
590
|
+
/** Resident-set per-agent tool-result inline cap (chars), via
|
|
591
|
+
* agent_settings `tool_result_inline_max_chars`. DURABLE: persisted in
|
|
592
|
+
* framework state like the core runtime settings and restored at create
|
|
593
|
+
* (antra + Sol, 08-06); reset clears it back to the residence default. */
|
|
594
|
+
toolResultInlineMaxCharsOverride = new Map();
|
|
595
|
+
/** Durable residence-configured inline cap from
|
|
596
|
+
* FrameworkConfig.toolResultInlineMaxChars; null → house default. */
|
|
597
|
+
toolResultInlineMaxCharsConfig = null;
|
|
441
598
|
mcplTools = [];
|
|
599
|
+
/** Namespaced tool name → stateful feature-set attribution from tools/list. */
|
|
600
|
+
mcplToolFeatureSets = new Map();
|
|
442
601
|
mcplToolRefreshInFlight = false;
|
|
443
602
|
mcplToolRefreshPending = false;
|
|
444
603
|
/** Maps tool prefix → serverId for dispatch routing. */
|
|
@@ -495,7 +654,12 @@ export class AgentFramework {
|
|
|
495
654
|
queryMessages: (filter) => this.queryMessages(filter),
|
|
496
655
|
pushEvent: (event) => this.pushEvent(event),
|
|
497
656
|
onTrace: (listener) => this.onTrace(listener),
|
|
498
|
-
|
|
657
|
+
// The ONLY entry that confers module origin — a private closure the
|
|
658
|
+
// framework hands to its own module registry. The public
|
|
659
|
+
// executeToolCall() is shared with model/ephemeral callers and always
|
|
660
|
+
// stamps agent origin (see there).
|
|
661
|
+
callTool: (call) => this.executeToolCallFrom(call, { kind: 'module' }),
|
|
662
|
+
notifyOps: (kind, agentName, message, data) => this.notifyOps(kind, agentName, message, data),
|
|
499
663
|
});
|
|
500
664
|
}
|
|
501
665
|
/**
|
|
@@ -658,6 +822,18 @@ export class AgentFramework {
|
|
|
658
822
|
// Client-side programmatic tool calling (code_execution). Config is only
|
|
659
823
|
// retained when enabled — everything downstream gates on the field.
|
|
660
824
|
framework.codeExecutionConfig = config.codeExecution?.enabled ? config.codeExecution : null;
|
|
825
|
+
if (config.toolResultInlineMaxChars !== undefined) {
|
|
826
|
+
const cap = config.toolResultInlineMaxChars;
|
|
827
|
+
if (!Number.isFinite(cap) || cap < 1000) {
|
|
828
|
+
throw new Error('FrameworkConfig.toolResultInlineMaxChars must be a number >= 1000');
|
|
829
|
+
}
|
|
830
|
+
framework.toolResultInlineMaxCharsConfig = Math.floor(cap);
|
|
831
|
+
}
|
|
832
|
+
// Restore resident-set inline caps (agent_settings) — durable like the
|
|
833
|
+
// core runtime settings, same framework/state slot (antra + Sol, 08-06).
|
|
834
|
+
for (const [agentName, cap] of Object.entries(framework.readPersistedToolResultInlineCaps())) {
|
|
835
|
+
framework.toolResultInlineMaxCharsOverride.set(agentName, cap);
|
|
836
|
+
}
|
|
661
837
|
// Initialize MCPL subsystems if configured
|
|
662
838
|
if (config.mcplServers && config.mcplServers.length > 0) {
|
|
663
839
|
// Validate tool prefixes: no collisions with module names or between servers
|
|
@@ -745,6 +921,17 @@ export class AgentFramework {
|
|
|
745
921
|
runner.dispose();
|
|
746
922
|
}
|
|
747
923
|
this.codeExecutionRunners.clear();
|
|
924
|
+
// Background daemons die with the process (documented limitation) —
|
|
925
|
+
// mark cancelled FIRST so their settle path stays silent (no crash-wake
|
|
926
|
+
// into a framework that is shutting down).
|
|
927
|
+
for (const record of this.backgroundScripts.values()) {
|
|
928
|
+
if (record.status === 'running') {
|
|
929
|
+
record.cancelled = true;
|
|
930
|
+
record.status = 'cancelled';
|
|
931
|
+
}
|
|
932
|
+
record.runner.dispose();
|
|
933
|
+
}
|
|
934
|
+
this.backgroundScripts.clear();
|
|
748
935
|
// Cancel all active streams
|
|
749
936
|
for (const agent of this.agents.values()) {
|
|
750
937
|
if (agent.state.status === 'streaming' ||
|
|
@@ -1003,6 +1190,8 @@ export class AgentFramework {
|
|
|
1003
1190
|
: [];
|
|
1004
1191
|
return [
|
|
1005
1192
|
...moduleTools,
|
|
1193
|
+
// Utilities collapse into one slot; absent entirely when none exist.
|
|
1194
|
+
...(this.moduleRegistry.getAllUtilities().length > 0 ? [UTILS_TOOL] : []),
|
|
1006
1195
|
...this.mcplTools,
|
|
1007
1196
|
...channelTools,
|
|
1008
1197
|
...gateTools,
|
|
@@ -1038,6 +1227,16 @@ export class AgentFramework {
|
|
|
1038
1227
|
collectAgentSettingsExtensions() {
|
|
1039
1228
|
const result = new Map();
|
|
1040
1229
|
const taken = new Set(AgentFramework.AGENT_SETTINGS_CORE_KEYS);
|
|
1230
|
+
// Framework-owned extension: the resident's tool-result inline cap.
|
|
1231
|
+
// Registered through the same extension surface modules use so
|
|
1232
|
+
// get/update/reset all work with zero extra plumbing. DURABLE: persisted
|
|
1233
|
+
// in framework state and restored at create, like the core runtime
|
|
1234
|
+
// settings (antra + Sol, 08-06); reset returns to the residence default.
|
|
1235
|
+
{
|
|
1236
|
+
const ext = this.spillSettingsExtension();
|
|
1237
|
+
ext.keys.forEach((k) => taken.add(k));
|
|
1238
|
+
result.set('_framework', ext);
|
|
1239
|
+
}
|
|
1041
1240
|
for (const module of this.moduleRegistry.getAllModules()) {
|
|
1042
1241
|
const ext = module.getAgentSettingsExtension?.();
|
|
1043
1242
|
if (!ext)
|
|
@@ -1052,6 +1251,51 @@ export class AgentFramework {
|
|
|
1052
1251
|
}
|
|
1053
1252
|
return result;
|
|
1054
1253
|
}
|
|
1254
|
+
/** agent_settings extension for the spill gate (see spillOrTruncate). */
|
|
1255
|
+
spillSettingsExtension() {
|
|
1256
|
+
return {
|
|
1257
|
+
properties: {
|
|
1258
|
+
tool_result_inline_max_chars: {
|
|
1259
|
+
type: 'number',
|
|
1260
|
+
description: 'Inline size cap (chars) for tool results (successes and errors) and ' +
|
|
1261
|
+
'background-script wake payloads. Content over the cap is written to a workspace ' +
|
|
1262
|
+
'file under tool-results/ and replaced by a truncated preview + file reference. ' +
|
|
1263
|
+
'This is YOUR durable setting: it persists across restarts, like your other ' +
|
|
1264
|
+
'agent_settings. Update it when you want a different inline size; reset restores ' +
|
|
1265
|
+
'the residence default. The effective cap is min(your value, your strategy\'s ' +
|
|
1266
|
+
'per-message bound) — the bound is a safety ceiling, and the full content is ' +
|
|
1267
|
+
'always in the spill file. get reports desired/_effective/_source/_clamped_by.',
|
|
1268
|
+
},
|
|
1269
|
+
},
|
|
1270
|
+
keys: ['tool_result_inline_max_chars'],
|
|
1271
|
+
get: (agentName) => {
|
|
1272
|
+
const agent = this.agents.get(agentName);
|
|
1273
|
+
const resolved = agent ? this.resolveToolResultInlineCap(agent) : null;
|
|
1274
|
+
return {
|
|
1275
|
+
tool_result_inline_max_chars: this.toolResultInlineMaxCharsOverride.get(agentName) ?? null,
|
|
1276
|
+
tool_result_inline_max_chars_effective: resolved?.cap ?? null,
|
|
1277
|
+
tool_result_inline_max_chars_source: resolved?.source ?? null,
|
|
1278
|
+
tool_result_inline_max_chars_clamped_by: resolved?.clampedBy ?? null,
|
|
1279
|
+
};
|
|
1280
|
+
},
|
|
1281
|
+
update: (agentName, patch) => {
|
|
1282
|
+
const n = Number(patch.tool_result_inline_max_chars);
|
|
1283
|
+
if (!Number.isFinite(n) || n < 1000) {
|
|
1284
|
+
throw new Error('tool_result_inline_max_chars must be a number >= 1000');
|
|
1285
|
+
}
|
|
1286
|
+
this.toolResultInlineMaxCharsOverride.set(agentName, Math.floor(n));
|
|
1287
|
+
this.persistToolResultInlineCap(agentName, Math.floor(n));
|
|
1288
|
+
return { tool_result_inline_max_chars: Math.floor(n) };
|
|
1289
|
+
},
|
|
1290
|
+
reset: (agentName, keys) => {
|
|
1291
|
+
if (!keys || keys.includes('tool_result_inline_max_chars')) {
|
|
1292
|
+
this.toolResultInlineMaxCharsOverride.delete(agentName);
|
|
1293
|
+
this.persistToolResultInlineCap(agentName, null);
|
|
1294
|
+
}
|
|
1295
|
+
return { tool_result_inline_max_chars: null };
|
|
1296
|
+
},
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1055
1299
|
captureInferenceToolSnapshot(agent) {
|
|
1056
1300
|
return {
|
|
1057
1301
|
sameRoundThinkTextPolicy: agent.getEffectiveSameRoundThinkTextPolicy(),
|
|
@@ -1402,6 +1646,44 @@ export class AgentFramework {
|
|
|
1402
1646
|
}
|
|
1403
1647
|
return overrides;
|
|
1404
1648
|
}
|
|
1649
|
+
/** Load resident-set inline caps from framework state, dropping (loudly)
|
|
1650
|
+
* anything that fails the same validation the live update enforces. */
|
|
1651
|
+
readPersistedToolResultInlineCaps() {
|
|
1652
|
+
try {
|
|
1653
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
1654
|
+
const out = {};
|
|
1655
|
+
for (const [name, value] of Object.entries(data?.toolResultInlineCaps ?? {})) {
|
|
1656
|
+
const n = Number(value);
|
|
1657
|
+
if (Number.isFinite(n) && n >= 1000) {
|
|
1658
|
+
out[name] = Math.floor(n);
|
|
1659
|
+
}
|
|
1660
|
+
else {
|
|
1661
|
+
console.error(`[agent-settings] dropping invalid persisted tool_result_inline_max_chars for '${name}': ${JSON.stringify(value)}`);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
return out;
|
|
1665
|
+
}
|
|
1666
|
+
catch {
|
|
1667
|
+
return {};
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
/** Write (cap) or clear (null) one agent's resident-set inline cap. */
|
|
1671
|
+
persistToolResultInlineCap(agentName, cap) {
|
|
1672
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
1673
|
+
const state = (data && typeof data === 'object' ? data : {});
|
|
1674
|
+
const all = {
|
|
1675
|
+
...(state.toolResultInlineCaps ?? {}),
|
|
1676
|
+
};
|
|
1677
|
+
if (cap === null)
|
|
1678
|
+
delete all[agentName];
|
|
1679
|
+
else
|
|
1680
|
+
all[agentName] = cap;
|
|
1681
|
+
if (Object.keys(all).length === 0)
|
|
1682
|
+
delete state.toolResultInlineCaps;
|
|
1683
|
+
else
|
|
1684
|
+
state.toolResultInlineCaps = all;
|
|
1685
|
+
this.store.setStateJson(FRAMEWORK_STATE_ID, state);
|
|
1686
|
+
}
|
|
1405
1687
|
persistAgentRuntimeSettings(agentName, overrides) {
|
|
1406
1688
|
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
1407
1689
|
const state = (data && typeof data === 'object' ? data : {});
|
|
@@ -1564,6 +1846,12 @@ export class AgentFramework {
|
|
|
1564
1846
|
// ephemeral agents leave checkpoint-tree keys and diagnostics map
|
|
1565
1847
|
// entries behind for the life of the store/session.
|
|
1566
1848
|
this.evictTurnCheckpoints(agent.name);
|
|
1849
|
+
// Turn-alive marker, same spawn-and-dispose logic: a stalled ephemeral
|
|
1850
|
+
// (watchdog fired, driveStream's terminal event lost) would strand its
|
|
1851
|
+
// token forever — no wedge (ephemeral names are unique per run) but
|
|
1852
|
+
// unbounded map growth. Blind delete is safe: the agent is already out
|
|
1853
|
+
// of the map, and a late driveStream finally token-match no-ops.
|
|
1854
|
+
this.activeTurnTokens.delete(agent.name);
|
|
1567
1855
|
}
|
|
1568
1856
|
}
|
|
1569
1857
|
/**
|
|
@@ -1834,14 +2122,6 @@ export class AgentFramework {
|
|
|
1834
2122
|
'chat:group': 'Group (multi-party) conversation',
|
|
1835
2123
|
'chat:thread': 'Occurred in a thread',
|
|
1836
2124
|
};
|
|
1837
|
-
/** Refusal category → Discord reaction emoji. Unknown categories get 🛑. */
|
|
1838
|
-
static REFUSAL_REACTIONS = {
|
|
1839
|
-
bio: '☣️',
|
|
1840
|
-
chem: '🧪',
|
|
1841
|
-
nuclear: '☢️',
|
|
1842
|
-
cyber: '💻',
|
|
1843
|
-
reasoning_extraction: '🧠',
|
|
1844
|
-
};
|
|
1845
2125
|
/**
|
|
1846
2126
|
* Mark an inference refusal visibly: react on the message that holds the
|
|
1847
2127
|
* conversational locus (the most recent incoming channel message) with a
|
|
@@ -1860,7 +2140,7 @@ export class AgentFramework {
|
|
|
1860
2140
|
if (parts[0] !== 'discord')
|
|
1861
2141
|
return;
|
|
1862
2142
|
const channelId = parts[parts.length - 1];
|
|
1863
|
-
const emoji =
|
|
2143
|
+
const emoji = REFUSAL_REACTIONS[category] ?? REFUSAL_REACTION_FALLBACK;
|
|
1864
2144
|
// Resolve the MCPL server that owns the locus channel and call
|
|
1865
2145
|
// tools/call directly on its connection (bare tool name — no prefix
|
|
1866
2146
|
// games), bypassing the agent event queue so no synthetic tool-result
|
|
@@ -2056,6 +2336,38 @@ export class AgentFramework {
|
|
|
2056
2336
|
console.error('[unstick] report post failed:', err instanceof Error ? err.message : err);
|
|
2057
2337
|
}
|
|
2058
2338
|
}
|
|
2339
|
+
/**
|
|
2340
|
+
* Admin-level nudge: run an inference turn on the agent's CURRENT context
|
|
2341
|
+
* without adding any message, event, or other context mutation. The turn
|
|
2342
|
+
* compiles exactly what the agent already sees — useful after an /undo,
|
|
2343
|
+
* a /hide, or whenever an operator wants the agent to take another look
|
|
2344
|
+
* (or another swing) at where it stands, with zero context pollution.
|
|
2345
|
+
*
|
|
2346
|
+
* Queues a normal inference request (same path as gate wakes): if the
|
|
2347
|
+
* agent is mid-turn it runs when the turn settles; a pending skip_reply
|
|
2348
|
+
* self-wake is superseded like any other wake. Deliberately NOT gated on
|
|
2349
|
+
* idle — "run when you're free" is the useful semantic. No suppression
|
|
2350
|
+
* interplay: gate sleep filters events, not queued requests, so a nudge
|
|
2351
|
+
* wakes even a sleeping agent (it's an admin override, like /unstick).
|
|
2352
|
+
*/
|
|
2353
|
+
nudgeAgent(agentName, requestedBy) {
|
|
2354
|
+
const name = agentName ?? [...this.agents.keys()][0];
|
|
2355
|
+
const agent = name ? this.agents.get(name) : undefined;
|
|
2356
|
+
if (!name || !agent) {
|
|
2357
|
+
return { ok: false, error: `Unknown agent: ${String(agentName ?? '(none registered)')}` };
|
|
2358
|
+
}
|
|
2359
|
+
const agentStatus = this.activeTurnTokens.has(name) && agent.state.status === 'idle'
|
|
2360
|
+
? 'idle+turn-alive'
|
|
2361
|
+
: agent.state.status;
|
|
2362
|
+
this.pendingRequests.push({
|
|
2363
|
+
agentName: name,
|
|
2364
|
+
reason: requestedBy ? `admin-nudge (${requestedBy})` : 'admin-nudge',
|
|
2365
|
+
source: 'admin',
|
|
2366
|
+
timestamp: Date.now(),
|
|
2367
|
+
});
|
|
2368
|
+
console.error(`[nudge] agent=${name} queued by=${requestedBy ?? 'unknown'} status=${agentStatus}`);
|
|
2369
|
+
return { ok: true, agentName: name, agentStatus };
|
|
2370
|
+
}
|
|
2059
2371
|
/**
|
|
2060
2372
|
* Handle a `host/command` request from an MCPL surface server (e.g. a
|
|
2061
2373
|
* Discord slash command). Currently supports:
|
|
@@ -2064,15 +2376,27 @@ export class AgentFramework {
|
|
|
2064
2376
|
* `undoLastTurn`). The response includes the last message the agent
|
|
2065
2377
|
* would see in its context after the undo, obtained via the transparent
|
|
2066
2378
|
* `previewActivation` render (no inference, no Chronicle writes).
|
|
2379
|
+
*
|
|
2380
|
+
* nudge — run inference on the current context with NO new events
|
|
2381
|
+
* (see `nudgeAgent`).
|
|
2067
2382
|
*/
|
|
2068
2383
|
async handleHostCommand(serverId, params) {
|
|
2069
|
-
if (params.command !== 'undo' &&
|
|
2384
|
+
if (params.command !== 'undo' &&
|
|
2385
|
+
params.command !== 'hide' &&
|
|
2386
|
+
params.command !== 'unstick' &&
|
|
2387
|
+
params.command !== 'nudge') {
|
|
2070
2388
|
return { ok: false, error: `Unknown host command: ${String(params.command)}` };
|
|
2071
2389
|
}
|
|
2072
2390
|
const agentName = params.agentName ?? [...this.agents.keys()][0];
|
|
2073
2391
|
if (!agentName || !this.agents.has(agentName)) {
|
|
2074
2392
|
return { ok: false, error: `Unknown agent: ${String(agentName)}` };
|
|
2075
2393
|
}
|
|
2394
|
+
// nudge: queue an inference on the current context — no message, no
|
|
2395
|
+
// event, no context mutation (see nudgeAgent).
|
|
2396
|
+
if (params.command === 'nudge') {
|
|
2397
|
+
const r = this.nudgeAgent(agentName, params.requesterName ?? params.requesterId ?? `mcpl:${serverId}`);
|
|
2398
|
+
return { ok: r.ok, error: r.error, agentStatus: r.agentStatus };
|
|
2399
|
+
}
|
|
2076
2400
|
// unstick: force the refusal-rewind loop on demand (even if the agent's
|
|
2077
2401
|
// autoRewind toggle is off). Redacts the turn that fed the refusal and
|
|
2078
2402
|
// re-runs, up to `cap` times, until the model stops refusing. Kicks the
|
|
@@ -2845,20 +3169,16 @@ export class AgentFramework {
|
|
|
2845
3169
|
agent.addAssistantResponse(pendingBlocks);
|
|
2846
3170
|
this.pendingAssistantBlocks.delete(agent.name);
|
|
2847
3171
|
}
|
|
2848
|
-
//
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
//
|
|
2852
|
-
//
|
|
2853
|
-
//
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
? tc.result.error ?? 'Unknown error'
|
|
2859
|
-
: toolResultDataToHistoryString(tc.result.data, maxChars),
|
|
2860
|
-
isError: tc.result.isError,
|
|
2861
|
-
}));
|
|
3172
|
+
// Effective inline cap: agent_settings override → durable config →
|
|
3173
|
+
// house default, clamped to the strategy bound (see resolver).
|
|
3174
|
+
const maxChars = this.resolveToolResultInlineCap(agent).cap;
|
|
3175
|
+
// Oversized results — successes AND errors — spill to a workspace
|
|
3176
|
+
// file (truncated preview + file reference) instead of being
|
|
3177
|
+
// blind-truncated. Computed ONCE per call and reused for both the
|
|
3178
|
+
// history copy and the live wire copy below — the two must stay
|
|
3179
|
+
// byte-matched (the window stores what the membrane sends;
|
|
3180
|
+
// divergence breaks the compile prefix).
|
|
3181
|
+
const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(currentState.toolResults, maxChars);
|
|
2862
3182
|
agent.getContextManager().addMessage('user', toolResultContent);
|
|
2863
3183
|
// Flush any messages that were deferred while this turn was in
|
|
2864
3184
|
// flight. Route to the PRIMARY agent — deferred messages are
|
|
@@ -2923,6 +3243,110 @@ export class AgentFramework {
|
|
|
2923
3243
|
const overBudget = currentState.stream
|
|
2924
3244
|
&& agent.lastStreamInputTokens > 0
|
|
2925
3245
|
&& agent.lastStreamInputTokens > agent.maxStreamTokens;
|
|
3246
|
+
// Physical-window projection (issue #92): a turn's continuation
|
|
3247
|
+
// rounds append to the compiled request without recompiling, so a
|
|
3248
|
+
// turn that starts near the compile target can walk past the
|
|
3249
|
+
// provider's HARD cap mid-turn and take a wire 400 — the compile
|
|
3250
|
+
// was legal, the growth wasn't. Project the next round's real size
|
|
3251
|
+
// (cache-inclusive input of the prior round + the blocks about to
|
|
3252
|
+
// be appended + reserve for the response) and, when it would cross
|
|
3253
|
+
// the physical window, break the stream through the same
|
|
3254
|
+
// budget-restart path — a fresh compile folds the history back
|
|
3255
|
+
// under budget instead of dispatching a doomed request.
|
|
3256
|
+
// Projection terms: prior round's real input + prior round's
|
|
3257
|
+
// OUTPUT (the just-generated thinking/text/tool_use joins the next
|
|
3258
|
+
// round's input — omitting it is a systematic underestimate) +
|
|
3259
|
+
// blocks about to be appended + reserve for the next response.
|
|
3260
|
+
const projectedRealTokens = currentState.stream
|
|
3261
|
+
&& agent.physicalWindowTokens !== undefined
|
|
3262
|
+
&& agent.lastStreamRealInputTokens > 0
|
|
3263
|
+
? agent.lastStreamRealInputTokens
|
|
3264
|
+
+ agent.lastStreamOutputTokens
|
|
3265
|
+
+ estimateAppendedRoundTokens(currentState.toolResults, spilled, midTurnInjections)
|
|
3266
|
+
+ agent.maxTokens
|
|
3267
|
+
: 0;
|
|
3268
|
+
const overPhysical = projectedRealTokens > (agent.physicalWindowTokens ?? Infinity);
|
|
3269
|
+
// Addressed re-pin (2026-07-31 Mythos misroutes, antra-ratified):
|
|
3270
|
+
// when someone ADDRESSES the agent mid-turn from another channel
|
|
3271
|
+
// (mention / reply / DM — the same chat:addressed signal the
|
|
3272
|
+
// turn-START batch policy prefers), the agent's subsequent plain
|
|
3273
|
+
// prose almost always answers THEM. Six recorded misroutes
|
|
3274
|
+
// delivered those answers to the stale frozen locus instead (the
|
|
3275
|
+
// LabClaude answer into #hospital_commons mid-examination; the
|
|
3276
|
+
// hospital scene into antra's DM). Re-pin the turn locus to the
|
|
3277
|
+
// LAST addressed conversational injection, at segment granularity:
|
|
3278
|
+
// prose routed in earlier rounds already went to the old pin,
|
|
3279
|
+
// which was correct when it was delivered. Ambient chatter,
|
|
3280
|
+
// reactions, and system markers still cannot move the pin — the
|
|
3281
|
+
// 2026-07-21 Cairn hijack protection is unchanged; this extends
|
|
3282
|
+
// the ratified addressed-outranks-ambient principle from turn
|
|
3283
|
+
// start to boundaries. Locus mode only (explicit mode has no pin).
|
|
3284
|
+
// Skipped on endTurn/overBudget: no further prose this stream, and
|
|
3285
|
+
// a stale "for this turn" notice would be pure noise.
|
|
3286
|
+
//
|
|
3287
|
+
// The notice rides the SAME injection batch — stored right after
|
|
3288
|
+
// the flushed messages and appended to midTurnInjections — so the
|
|
3289
|
+
// window byte-matches the wire (short event-style notice: the
|
|
3290
|
+
// classifier-safe D-class from the 2026-07-24 ablation). It also
|
|
3291
|
+
// updates lastAnnouncedLocus so the next turn's announce-on-change
|
|
3292
|
+
// diffs against what the agent was actually last told.
|
|
3293
|
+
if (agent.proseRouting !== 'explicit' &&
|
|
3294
|
+
!shouldEndTurn && !overBudget && currentState.stream) {
|
|
3295
|
+
// Two signals qualify an injection to move the pin (n=6 + n=7):
|
|
3296
|
+
// - chat:addressed — someone explicitly spoke TO the agent
|
|
3297
|
+
// (mention / reply / DM);
|
|
3298
|
+
// - a conversational follow-up in a channel the agent itself
|
|
3299
|
+
// explicitly sent into THIS turn (turnEngagedChannels) —
|
|
3300
|
+
// conversation the agent just engaged continues without
|
|
3301
|
+
// re-mentioning it (2026-07-31: q's #portables reply, no @,
|
|
3302
|
+
// answered in prose that followed the stale pin into
|
|
3303
|
+
// repligate's DM). Deliberately NO author-kind filter: this
|
|
3304
|
+
// fleet's participants include agent-residents whose speech
|
|
3305
|
+
// is as conversational as anyone's — the Discord bot flag
|
|
3306
|
+
// tracks nothing that matters here (antra, 2026-07-31).
|
|
3307
|
+
// Reactions and system markers are already excluded by
|
|
3308
|
+
// isConversationalInjection, and the engaged-this-turn scope
|
|
3309
|
+
// keeps unrelated channels from moving the pin.
|
|
3310
|
+
const engaged = this.turnEngagedChannels.get(agent.name);
|
|
3311
|
+
const lastQualifying = [...midTurnInjections].reverse().find((inj) => {
|
|
3312
|
+
const m = inj.metadata;
|
|
3313
|
+
if (!isConversationalInjection(inj.metadata))
|
|
3314
|
+
return false;
|
|
3315
|
+
if (typeof m?.channelId !== 'string')
|
|
3316
|
+
return false;
|
|
3317
|
+
const tags = m.tags;
|
|
3318
|
+
if (isAddressedMessage(tags, m))
|
|
3319
|
+
return true;
|
|
3320
|
+
return engaged?.has(m.channelId) === true;
|
|
3321
|
+
});
|
|
3322
|
+
const newLocus = lastQualifying?.metadata
|
|
3323
|
+
?.channelId;
|
|
3324
|
+
if (newLocus && this.turnLocusPins.get(agent.name) !== newLocus) {
|
|
3325
|
+
const prevPin = this.turnLocusPins.get(agent.name) ?? null;
|
|
3326
|
+
this.turnLocusPins.set(agent.name, newLocus);
|
|
3327
|
+
this.lastAnnouncedLocus.set(agent.name, newLocus);
|
|
3328
|
+
const label = this.channelRegistry?.getDescriptor(newLocus)?.label;
|
|
3329
|
+
const shown = label && label !== newLocus
|
|
3330
|
+
? `${label.startsWith('#') ? label : `#${label}`} (${newLocus})`
|
|
3331
|
+
: newLocus;
|
|
3332
|
+
const noticeContent = [{
|
|
3333
|
+
type: 'text',
|
|
3334
|
+
text: `[routing] The conversation moved to ${shown} — your plain ` +
|
|
3335
|
+
'speech now lands there for the rest of this turn. Other ' +
|
|
3336
|
+
'channels need an explicit send tool.',
|
|
3337
|
+
}];
|
|
3338
|
+
const noticeMeta = { system: true, kind: 'routing-notice' };
|
|
3339
|
+
try {
|
|
3340
|
+
const id = agent.getContextManager().addMessage('user', noticeContent, noticeMeta);
|
|
3341
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
|
|
3342
|
+
midTurnInjections.push({ participant: 'user', content: noticeContent, metadata: noticeMeta });
|
|
3343
|
+
console.error(`[routing] ${agent.name}: mid-turn addressed re-pin ${prevPin ?? '(none)'} -> ${newLocus}`);
|
|
3344
|
+
}
|
|
3345
|
+
catch (err) {
|
|
3346
|
+
console.error('mid-turn locus re-pin: failed to record routing notice:', err);
|
|
3347
|
+
}
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
2926
3350
|
if (shouldEndTurn) {
|
|
2927
3351
|
// endTurn: messages already stored above, cancel stream, reset to idle.
|
|
2928
3352
|
if (currentState.stream) {
|
|
@@ -2938,7 +3362,7 @@ export class AgentFramework {
|
|
|
2938
3362
|
this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' });
|
|
2939
3363
|
this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
|
|
2940
3364
|
}
|
|
2941
|
-
else if (overBudget) {
|
|
3365
|
+
else if (overBudget || overPhysical) {
|
|
2942
3366
|
// Context budget exceeded: break the stream, let compile() compress.
|
|
2943
3367
|
// Mark the cancel as framework-initiated BEFORE cancelling: the
|
|
2944
3368
|
// membrane delivers `aborted` before the restart bumps streamId,
|
|
@@ -2948,14 +3372,27 @@ export class AgentFramework {
|
|
|
2948
3372
|
// endTurn above).
|
|
2949
3373
|
if (currentState.stream) {
|
|
2950
3374
|
this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
|
|
3375
|
+
// Cancel the LIVE stream object directly (like the endTurn
|
|
3376
|
+
// branch above): at a tool boundary the state machine reads
|
|
3377
|
+
// 'ready', and Agent.cancelStream only signals the stream from
|
|
3378
|
+
// 'streaming'/'waiting_for_tools' — so the old call left the
|
|
3379
|
+
// provider stream parked forever awaiting tool results that
|
|
3380
|
+
// would never come (zombie: driveStream's finally never ran,
|
|
3381
|
+
// its activeStreams entry survived until the restart's stream
|
|
3382
|
+
// overwrote it). Harmless by accident before turn-alive
|
|
3383
|
+
// tracking; a genuine teardown leak after it.
|
|
3384
|
+
currentState.stream.cancel();
|
|
2951
3385
|
}
|
|
2952
3386
|
agent.cancelStream();
|
|
2953
3387
|
this.emitTrace({
|
|
2954
3388
|
type: 'inference:stream_restarted',
|
|
2955
3389
|
agentName: agent.name,
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
3390
|
+
// 'physical_window' only when the budget check alone would
|
|
3391
|
+
// have let the doomed request through — overBudget keeps its
|
|
3392
|
+
// established label for existing consumers.
|
|
3393
|
+
reason: overBudget ? 'context_budget' : 'physical_window',
|
|
3394
|
+
inputTokens: overBudget ? agent.lastStreamInputTokens : projectedRealTokens,
|
|
3395
|
+
budget: overBudget ? agent.maxStreamTokens : (agent.physicalWindowTokens ?? 0),
|
|
2959
3396
|
});
|
|
2960
3397
|
this.pendingRequests.push({
|
|
2961
3398
|
agentName: agent.name,
|
|
@@ -2969,7 +3406,7 @@ export class AgentFramework {
|
|
|
2969
3406
|
// Mid-turn messages collected above ride along as injected user
|
|
2970
3407
|
// messages (membrane ≥0.5.72) — appended after the tool_result
|
|
2971
3408
|
// envelope so the next round of THIS turn hears them.
|
|
2972
|
-
const membraneResults = currentState.toolResults.map(tc => this.toMembraneToolResult(tc.id, tc.result, maxChars));
|
|
3409
|
+
const membraneResults = currentState.toolResults.map(tc => this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id)));
|
|
2973
3410
|
currentState.stream.provideToolResults(membraneResults, midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections } : undefined);
|
|
2974
3411
|
agent.setStreaming(currentState.stream);
|
|
2975
3412
|
this.emitTrace({
|
|
@@ -3123,6 +3560,12 @@ export class AgentFramework {
|
|
|
3123
3560
|
? event.metadata.guildName
|
|
3124
3561
|
: undefined,
|
|
3125
3562
|
tags: event.tags,
|
|
3563
|
+
missedMessages: typeof event.metadata?.missedMessages === 'number'
|
|
3564
|
+
? event.metadata.missedMessages
|
|
3565
|
+
: undefined,
|
|
3566
|
+
missedCharacters: typeof event.metadata?.missedCharacters === 'number'
|
|
3567
|
+
? event.metadata.missedCharacters
|
|
3568
|
+
: undefined,
|
|
3126
3569
|
});
|
|
3127
3570
|
if (invitation) {
|
|
3128
3571
|
incomingContent.push(invitation.block);
|
|
@@ -3393,6 +3836,17 @@ export class AgentFramework {
|
|
|
3393
3836
|
* this should see WHO addressed it WHERE, not a wall of snowflake ids. Raw
|
|
3394
3837
|
* ids remain only inside the tool-argument instructions, where they are the
|
|
3395
3838
|
* literal values to pass.
|
|
3839
|
+
*
|
|
3840
|
+
* 2026-08-05 accuracy pass: the old "you won't receive more from it unless
|
|
3841
|
+
* you join" was wrong in both directions — direct addresses (mentions,
|
|
3842
|
+
* replies to the agent) DO still arrive, and nothing warned that answering
|
|
3843
|
+
* via option 1 leaves the human's natural un-@'d follow-up invisible (Sol
|
|
3844
|
+
* answered four #architecture mentions over four days while the follow-ups
|
|
3845
|
+
* to her own replies were silently dropped). The wording now states the
|
|
3846
|
+
* actual delivery model, and option 1 carries the consequence inline. When
|
|
3847
|
+
* the surface tracks a missed-ambient tally (discord-mcpl sends
|
|
3848
|
+
* `missedMessages`/`missedCharacters` in push origin), the invitation shows
|
|
3849
|
+
* the count — the cost of staying out, as a number, at decision time.
|
|
3396
3850
|
*/
|
|
3397
3851
|
buildClosedChannelInvitation(opts) {
|
|
3398
3852
|
if (!this.channelRegistry)
|
|
@@ -3406,12 +3860,21 @@ export class AgentFramework {
|
|
|
3406
3860
|
const label = opts.channelLabel ?? descriptor?.label;
|
|
3407
3861
|
const channelLabel = label ? `#${label}` : `"${opts.channelId}"`;
|
|
3408
3862
|
const place = opts.guildName ? `${channelLabel} in "${opts.guildName}"` : channelLabel;
|
|
3863
|
+
const missedCount = opts.missedMessages ?? 0;
|
|
3864
|
+
const missedNote = missedCount > 0
|
|
3865
|
+
? `While closed, ${missedCount} message${missedCount === 1 ? '' : 's'}` +
|
|
3866
|
+
(opts.missedCharacters ? ` (~${opts.missedCharacters} chars)` : '') +
|
|
3867
|
+
` have passed there without you. `
|
|
3868
|
+
: '';
|
|
3409
3869
|
const block = {
|
|
3410
3870
|
type: 'text',
|
|
3411
3871
|
text: `\n[Channel invitation] ${opts.authorName} addressed you in ${place} — ` +
|
|
3412
|
-
`a channel you haven't joined.
|
|
3872
|
+
`a channel you haven't joined. Only messages that address you directly (an @-mention or ` +
|
|
3873
|
+
`a reply to one of your messages) reach you from it; the rest of its traffic is invisible to you. ` +
|
|
3874
|
+
missedNote +
|
|
3413
3875
|
`Your options:\n` +
|
|
3414
|
-
`1. Reply without joining — write your reply this turn prefixed with ">>${channelLabel}"; it will be delivered there
|
|
3876
|
+
`1. Reply without joining — write your reply this turn prefixed with ">>${channelLabel}"; it will be delivered there. ` +
|
|
3877
|
+
`Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n` +
|
|
3415
3878
|
`2. Join the channel — call channel_open with channelId "${opts.channelId}" and serverId "${opts.serverId}"` +
|
|
3416
3879
|
(maxBackscroll > 0
|
|
3417
3880
|
? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${opts.messageId}".\n`
|
|
@@ -3466,6 +3929,8 @@ export class AgentFramework {
|
|
|
3466
3929
|
authorName: typeof origin.authorName === 'string' && origin.authorName ? origin.authorName : 'Someone',
|
|
3467
3930
|
guildName: typeof origin.guildName === 'string' && origin.guildName ? origin.guildName : undefined,
|
|
3468
3931
|
tags: event.tags,
|
|
3932
|
+
missedMessages: typeof origin.missedMessages === 'number' ? origin.missedMessages : undefined,
|
|
3933
|
+
missedCharacters: typeof origin.missedCharacters === 'number' ? origin.missedCharacters : undefined,
|
|
3469
3934
|
});
|
|
3470
3935
|
if (invitation) {
|
|
3471
3936
|
content.push(invitation.block);
|
|
@@ -3576,8 +4041,24 @@ export class AgentFramework {
|
|
|
3576
4041
|
console.error(`[inference-dropped] agent=${agentName} reason=agent_not_found requests=${requests.length}`);
|
|
3577
4042
|
continue;
|
|
3578
4043
|
}
|
|
3579
|
-
// Skip if agent is busy (inferring, streaming, or waiting for tools)
|
|
3580
|
-
if
|
|
4044
|
+
// Skip if agent is busy (inferring, streaming, or waiting for tools) —
|
|
4045
|
+
// or if a turn is alive at all (activeTurnTokens): the state machine
|
|
4046
|
+
// goes idle at endTurn's reset while driveStream teardown is still
|
|
4047
|
+
// pending, and a successor turn dequeued in that gap compiles while the
|
|
4048
|
+
// predecessor's deferred flush can still write (2026-07-31 Mythos).
|
|
4049
|
+
// Turn-alive spans dequeue → settled teardown; wakes wait it out.
|
|
4050
|
+
//
|
|
4051
|
+
// EXCEPT a context-budget restart: it CONTINUES the turn whose token is
|
|
4052
|
+
// held — queueing it behind its own turn's teardown is a deadlock when
|
|
4053
|
+
// that teardown lags (the cancelled stream's `aborted` event is
|
|
4054
|
+
// asynchronous and, historically, not even guaranteed — see the
|
|
4055
|
+
// overBudget branch). The restart's startAgentStream overwrites the
|
|
4056
|
+
// token; the predecessor frame's late finally no-ops via token-match.
|
|
4057
|
+
// This mirrors how the restart has always overwritten activeStreams
|
|
4058
|
+
// rather than waiting for the old stream's teardown.
|
|
4059
|
+
const isBudgetRestart = requests.some((r) => r.reason === 'context_budget_restart');
|
|
4060
|
+
const turnAlive = !isBudgetRestart && this.activeTurnTokens.has(agentName);
|
|
4061
|
+
if (turnAlive || agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
|
|
3581
4062
|
// Re-queue requests, but warn if they've been pending too long
|
|
3582
4063
|
const oldest = Math.min(...requests.map(r => r.timestamp));
|
|
3583
4064
|
if (now - oldest > STALE_REQUEST_MS &&
|
|
@@ -3587,14 +4068,21 @@ export class AgentFramework {
|
|
|
3587
4068
|
// (up to 100/sec after the scheduler backoff), adding avoidable work
|
|
3588
4069
|
// precisely while the agent was already under pressure.
|
|
3589
4070
|
this.staleWarnAt.set(agentName, now);
|
|
4071
|
+
// Surface turn-alive separately from the state machine: status can
|
|
4072
|
+
// read 'idle' while a turn's teardown is pending, and a LEAKED turn
|
|
4073
|
+
// token would look exactly like this — permanently requeued wakes.
|
|
4074
|
+
// This line is the wedge's tell (idle+turn-alive, forever).
|
|
4075
|
+
const shownStatus = turnAlive && agent.state.status === 'idle'
|
|
4076
|
+
? 'idle+turn-alive'
|
|
4077
|
+
: agent.state.status;
|
|
3590
4078
|
this.emitTrace({
|
|
3591
4079
|
type: 'inference:request_stale',
|
|
3592
4080
|
agentName,
|
|
3593
|
-
agentStatus:
|
|
4081
|
+
agentStatus: shownStatus,
|
|
3594
4082
|
requestCount: requests.length,
|
|
3595
4083
|
oldestRequestAge: now - oldest,
|
|
3596
4084
|
});
|
|
3597
|
-
console.error(`[inference-stale] agent=${agentName} busy (${
|
|
4085
|
+
console.error(`[inference-stale] agent=${agentName} busy (${shownStatus}) — ` +
|
|
3598
4086
|
`${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`);
|
|
3599
4087
|
}
|
|
3600
4088
|
this.pendingRequests.push(...requests);
|
|
@@ -3640,6 +4128,68 @@ export class AgentFramework {
|
|
|
3640
4128
|
});
|
|
3641
4129
|
}
|
|
3642
4130
|
}
|
|
4131
|
+
/** Record prose segments suppressed by explicit-send silencing. */
|
|
4132
|
+
recordProseSuppression(agentName, count) {
|
|
4133
|
+
if (count <= 0)
|
|
4134
|
+
return;
|
|
4135
|
+
this.turnProseSuppressed.set(agentName, (this.turnProseSuppressed.get(agentName) ?? 0) + count);
|
|
4136
|
+
}
|
|
4137
|
+
/** Record a successful plain-prose delivery for this turn's receipt. */
|
|
4138
|
+
recordProseDelivery(agentName, outcome) {
|
|
4139
|
+
if (!outcome?.delivered)
|
|
4140
|
+
return;
|
|
4141
|
+
let list = this.turnProseDeliveries.get(agentName);
|
|
4142
|
+
if (!list) {
|
|
4143
|
+
list = [];
|
|
4144
|
+
this.turnProseDeliveries.set(agentName, list);
|
|
4145
|
+
}
|
|
4146
|
+
list.push(outcome.channelId);
|
|
4147
|
+
}
|
|
4148
|
+
/**
|
|
4149
|
+
* Append the turn's `[delivered]` receipt: one compact system message
|
|
4150
|
+
* naming where this turn's plain prose actually landed (channels in
|
|
4151
|
+
* delivery order, deduped). Explicit sends receipt themselves via their
|
|
4152
|
+
* tool_result; auto-routed prose previously left no in-window trace, so
|
|
4153
|
+
* the agent could never see where its own words went — misroutes were
|
|
4154
|
+
* invisible to it until a human reported them (2026-07-31 series).
|
|
4155
|
+
* Called ONLY at logical turn end (stream 'complete', or the endTurn
|
|
4156
|
+
* cancel path after the speech chain settles) — never mid-stream, where a
|
|
4157
|
+
* window-only insert would diverge from the wire. No-op when the turn
|
|
4158
|
+
* delivered nothing. Failures are already marked separately
|
|
4159
|
+
* ([discord-send-failed]); this is the success half.
|
|
4160
|
+
*/
|
|
4161
|
+
appendProseDeliveryReceipt(agent) {
|
|
4162
|
+
const list = this.turnProseDeliveries.get(agent.name);
|
|
4163
|
+
const suppressed = this.turnProseSuppressed.get(agent.name) ?? 0;
|
|
4164
|
+
if ((!list || list.length === 0) && suppressed === 0)
|
|
4165
|
+
return;
|
|
4166
|
+
this.turnProseDeliveries.delete(agent.name);
|
|
4167
|
+
this.turnProseSuppressed.delete(agent.name);
|
|
4168
|
+
const seen = new Set();
|
|
4169
|
+
const shown = [];
|
|
4170
|
+
for (const id of list ?? []) {
|
|
4171
|
+
if (seen.has(id))
|
|
4172
|
+
continue;
|
|
4173
|
+
seen.add(id);
|
|
4174
|
+
const label = this.channelRegistry?.getDescriptor(id)?.label;
|
|
4175
|
+
shown.push(label && label !== id
|
|
4176
|
+
? `${label.startsWith('#') ? label : `#${label}`} (${id})`
|
|
4177
|
+
: id);
|
|
4178
|
+
}
|
|
4179
|
+
const suppressedNote = suppressed > 0
|
|
4180
|
+
? `${suppressed} plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)`
|
|
4181
|
+
: '';
|
|
4182
|
+
const text = shown.length > 0
|
|
4183
|
+
? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}`
|
|
4184
|
+
: `[delivered] nothing — ${suppressedNote}`;
|
|
4185
|
+
try {
|
|
4186
|
+
const mid = agent.getContextManager().addMessage('user', [{ type: 'text', text }], { system: true, kind: 'delivery-receipt' });
|
|
4187
|
+
this.emitTrace({ type: 'message:added', messageId: mid, source: 'delivery-receipt' });
|
|
4188
|
+
}
|
|
4189
|
+
catch (err) {
|
|
4190
|
+
console.error('delivery receipt append failed:', err);
|
|
4191
|
+
}
|
|
4192
|
+
}
|
|
3643
4193
|
/**
|
|
3644
4194
|
* Announce-on-change locus notice (turn-frozen routing). Appends a durable
|
|
3645
4195
|
* `[routing]` window message when the turn's effective outbound locus
|
|
@@ -3897,6 +4447,75 @@ export class AgentFramework {
|
|
|
3897
4447
|
}
|
|
3898
4448
|
}
|
|
3899
4449
|
async startAgentStream(agent, trigger, attempt = 0) {
|
|
4450
|
+
// Mark the turn alive before ANYTHING awaits (hooks, compile, stream
|
|
4451
|
+
// setup): from here until this turn's teardown, cross-turn writers defer
|
|
4452
|
+
// (addMessage guard) instead of appending — nothing may enter the window
|
|
4453
|
+
// between a turn's dequeue and its settle except the turn's own blocks.
|
|
4454
|
+
// A retry re-enters with a fresh token; teardown clears token-matched.
|
|
4455
|
+
//
|
|
4456
|
+
// The try/finally is the leak-proofing: a token whose owner never clears
|
|
4457
|
+
// it permanently requeues every wake for the agent (the 'idle+turn-alive'
|
|
4458
|
+
// wedge — worse than any single lost turn). Ownership is handed off
|
|
4459
|
+
// exactly once, to driveStream, the moment the stream handle is
|
|
4460
|
+
// registered; on every other exit — including a throw from the turn-start
|
|
4461
|
+
// flush, recordTurnCheckpoint, or locus resolution, none of which the
|
|
4462
|
+
// inner catch covers (ENOSPC-class store writes live there) — the finally
|
|
4463
|
+
// clears our own token. Token-matched, so when a retry frame has replaced
|
|
4464
|
+
// the token, or the terminal-failure branch already deleted it, this
|
|
4465
|
+
// no-ops instead of clobbering a successor's marker.
|
|
4466
|
+
const turnToken = this.nextTurnToken++;
|
|
4467
|
+
this.activeTurnTokens.set(agent.name, turnToken);
|
|
4468
|
+
let tokenHandedOff = false;
|
|
4469
|
+
try {
|
|
4470
|
+
tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken);
|
|
4471
|
+
}
|
|
4472
|
+
finally {
|
|
4473
|
+
if (!tokenHandedOff && this.activeTurnTokens.get(agent.name) === turnToken) {
|
|
4474
|
+
this.activeTurnTokens.delete(agent.name);
|
|
4475
|
+
}
|
|
4476
|
+
}
|
|
4477
|
+
}
|
|
4478
|
+
/**
|
|
4479
|
+
* Body of startAgentStream, split out so the caller's try/finally owns the
|
|
4480
|
+
* turn token unconditionally. Returns true iff the token was handed off to
|
|
4481
|
+
* driveStream (whose finally then owns clearing it).
|
|
4482
|
+
*/
|
|
4483
|
+
async beginAgentTurn(agent, trigger, attempt, turnToken) {
|
|
4484
|
+
// Flush messages deferred during the PREVIOUS turn — before the
|
|
4485
|
+
// checkpoint, the locus announcement, and the compile — so a turn started
|
|
4486
|
+
// by a queued wake actually CONTAINS the message that woke it. (2026-07-31
|
|
4487
|
+
// Mythos: a DM's wake fired a turn whose compile ran before the deferred
|
|
4488
|
+
// DM flushed; the model saw only the routing-shift notice, reasonably
|
|
4489
|
+
// skipped, and the DM then landed before the skip in the window — false
|
|
4490
|
+
// "saw it and ignored it" history plus KV divergence. Flushed here, the
|
|
4491
|
+
// payload rides its own wake, and the notice/payload can never split.)
|
|
4492
|
+
// Primary agent only — deferred messages are framework-level, meant for
|
|
4493
|
+
// the main conversation (same target rule as the tool-boundary flush).
|
|
4494
|
+
// Fresh turns only: a context-budget restart continues the same logical
|
|
4495
|
+
// turn, and flushing there would insert messages between its rounds.
|
|
4496
|
+
// Before recordTurnCheckpoint: these are the turn's inputs, not its
|
|
4497
|
+
// products — undoing the turn must not destroy them.
|
|
4498
|
+
if (attempt === 0 &&
|
|
4499
|
+
trigger?.reason !== 'context_budget_restart' &&
|
|
4500
|
+
this.deferredMessages.length > 0) {
|
|
4501
|
+
const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
|
|
4502
|
+
if (target === agent) {
|
|
4503
|
+
const deferred = this.deferredMessages.splice(0);
|
|
4504
|
+
for (const msg of deferred) {
|
|
4505
|
+
// Per-message try/catch (mirrors announceLocusIfChanged): one
|
|
4506
|
+
// poison message — or a transient store-write failure — must not
|
|
4507
|
+
// abort the turn or drop the messages behind it in the queue.
|
|
4508
|
+
try {
|
|
4509
|
+
const id = agent.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
|
|
4510
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'deferred-flush:turn-start' });
|
|
4511
|
+
}
|
|
4512
|
+
catch (err) {
|
|
4513
|
+
console.error(`[deferred-flush] turn-start flush failed to store a message for ${agent.name} ` +
|
|
4514
|
+
`(participant=${msg.participant}):`, err);
|
|
4515
|
+
}
|
|
4516
|
+
}
|
|
4517
|
+
}
|
|
4518
|
+
}
|
|
3900
4519
|
// Record turn checkpoint before inference (only on first attempt, not retries)
|
|
3901
4520
|
if (attempt === 0) {
|
|
3902
4521
|
this.recordTurnCheckpoint(agent.name);
|
|
@@ -3938,6 +4557,12 @@ export class AgentFramework {
|
|
|
3938
4557
|
if (trigger?.reason !== 'context_budget_restart') {
|
|
3939
4558
|
if (attempt === 0)
|
|
3940
4559
|
this.maybePrimeProseMode(agent);
|
|
4560
|
+
// Fresh turn: forget the previous turn's explicit-send engagements and
|
|
4561
|
+
// prose deliveries — both are strictly turn-scoped (a restart continues
|
|
4562
|
+
// the same logical turn and keeps them).
|
|
4563
|
+
this.turnEngagedChannels.delete(agent.name);
|
|
4564
|
+
this.turnProseDeliveries.delete(agent.name);
|
|
4565
|
+
this.turnProseSuppressed.delete(agent.name);
|
|
3941
4566
|
if (agent.proseRouting === 'explicit') {
|
|
3942
4567
|
// Explicit prose routing: there is no locus. The model names every
|
|
3943
4568
|
// destination in-band (`>>` prefixes); the only turn state is the
|
|
@@ -4023,6 +4648,9 @@ export class AgentFramework {
|
|
|
4023
4648
|
const { stream, request: compiledRequest } = await agent.startStreamWithInjections(tools, injections);
|
|
4024
4649
|
const handle = this.driveStream(agent, stream, requestSnapshot, trigger, attempt, compiledRequest);
|
|
4025
4650
|
this.activeStreams.set(agent.name, handle);
|
|
4651
|
+
// Handoff: driveStream captured the token in its synchronous prefix;
|
|
4652
|
+
// its finally now owns clearing it. The caller's finally must not.
|
|
4653
|
+
return true;
|
|
4026
4654
|
}
|
|
4027
4655
|
catch (error) {
|
|
4028
4656
|
// The early typing indicator has no driveStream `finally` on this path —
|
|
@@ -4040,9 +4668,19 @@ export class AgentFramework {
|
|
|
4040
4668
|
const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
|
|
4041
4669
|
if (action.retry) {
|
|
4042
4670
|
await new Promise((resolve) => setTimeout(resolve, action.delayMs));
|
|
4671
|
+
// The retry re-enters startAgentStream, which replaces this frame's
|
|
4672
|
+
// turn token with its own; cleanup belongs to the innermost frame.
|
|
4043
4673
|
await this.startAgentStream(agent, trigger, attempt + 1);
|
|
4044
4674
|
}
|
|
4045
4675
|
else {
|
|
4676
|
+
// Terminal failure with no driveStream: this frame owns teardown.
|
|
4677
|
+
// Token-matched (like the finally in driveStream) so a stale frame
|
|
4678
|
+
// can never clear a successor turn's marker. A leaked token would
|
|
4679
|
+
// permanently requeue every wake for this agent (same wedge shape as
|
|
4680
|
+
// the eventGate `inferring` leak) — every exit path must clear it.
|
|
4681
|
+
if (this.activeTurnTokens.get(agent.name) === turnToken) {
|
|
4682
|
+
this.activeTurnTokens.delete(agent.name);
|
|
4683
|
+
}
|
|
4046
4684
|
this.settleAgent(agent.name, {
|
|
4047
4685
|
stopReason: 'exhausted',
|
|
4048
4686
|
speech: '',
|
|
@@ -4066,12 +4704,22 @@ export class AgentFramework {
|
|
|
4066
4704
|
this.pushEvent(action.emit);
|
|
4067
4705
|
}
|
|
4068
4706
|
}
|
|
4707
|
+
// No handoff on either failure branch: the terminal branch deleted the
|
|
4708
|
+
// token above; the retry branch's recursive startAgentStream replaced
|
|
4709
|
+
// it with the inner frame's token. Returning false lets the caller's
|
|
4710
|
+
// token-matched finally no-op in both cases (and actually clear it if
|
|
4711
|
+
// something above threw before either branch ran).
|
|
4712
|
+
return false;
|
|
4069
4713
|
}
|
|
4070
4714
|
}
|
|
4071
4715
|
async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest) {
|
|
4072
4716
|
const startTime = Date.now();
|
|
4073
4717
|
const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
|
|
4074
4718
|
const myStreamId = agent.streamId;
|
|
4719
|
+
// This turn's alive-marker, set at startAgentStream entry. Safe to read
|
|
4720
|
+
// from the map here: the turn-alive busy check in processInferenceRequests
|
|
4721
|
+
// means no successor turn can have replaced it while we compiled.
|
|
4722
|
+
const myTurnToken = this.activeTurnTokens.get(agent.name);
|
|
4075
4723
|
let hadToolCalls = false;
|
|
4076
4724
|
// ---- Present-while-acting turn state ---------------------------------
|
|
4077
4725
|
// Output locus for the WHOLE logical turn: frozen in startAgentStream
|
|
@@ -4093,7 +4741,8 @@ export class AgentFramework {
|
|
|
4093
4741
|
const enqueueSpeech = (text, locus) => {
|
|
4094
4742
|
turnSpeechChain = turnSpeechChain
|
|
4095
4743
|
.then(async () => {
|
|
4096
|
-
await this.channelRegistry.routeSpeech(agent.name, text, locus);
|
|
4744
|
+
const outcome = await this.channelRegistry.routeSpeech(agent.name, text, locus);
|
|
4745
|
+
this.recordProseDelivery(agent.name, outcome);
|
|
4097
4746
|
})
|
|
4098
4747
|
.catch((err) => console.error('mid-turn speech routing failed:', err));
|
|
4099
4748
|
};
|
|
@@ -4131,8 +4780,25 @@ export class AgentFramework {
|
|
|
4131
4780
|
// streams that delivery would refuse), emission is capability-gated in
|
|
4132
4781
|
// the registry (`channels.streaming`), and delivery via deliverProse
|
|
4133
4782
|
// remains the sole authoritative send.
|
|
4134
|
-
const
|
|
4783
|
+
const newOutgoingInferenceId = () => `inf_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
4784
|
+
// NOT const: a membrane refusal retry abandons everything streamed so
|
|
4785
|
+
// far, and the surface must not concatenate two attempts. Re-minting the
|
|
4786
|
+
// id makes the discarded attempt's chunk buffer orphaned (never
|
|
4787
|
+
// finalized) instead of being appended to — see the 'retrying' case.
|
|
4788
|
+
let outgoingInferenceId = newOutgoingInferenceId();
|
|
4135
4789
|
let outgoingIndex = 0;
|
|
4790
|
+
// §10.5 inference/lifecycle: `started` now, exactly one terminal in the
|
|
4791
|
+
// finally below (the one block every exit path shares). Which terminal
|
|
4792
|
+
// is decided by the paths themselves: default completed, catch sets
|
|
4793
|
+
// failed, a framework-cancelled stream sets aborted. Best-effort
|
|
4794
|
+
// notifications — consumers dedupe by inferenceId and keep a timeout.
|
|
4795
|
+
let lifecyclePhase = 'completed';
|
|
4796
|
+
this.hookOrchestrator?.emitLifecycle({
|
|
4797
|
+
inferenceId: outgoingInferenceId,
|
|
4798
|
+
conversationId: agent.name,
|
|
4799
|
+
turnIndex: 0,
|
|
4800
|
+
phase: 'started',
|
|
4801
|
+
});
|
|
4136
4802
|
const proseStream = this.channelRegistry
|
|
4137
4803
|
? new ProseStreamRouter({
|
|
4138
4804
|
mode: agent.proseRouting === 'explicit' ? 'explicit' : 'locus',
|
|
@@ -4177,6 +4843,35 @@ export class AgentFramework {
|
|
|
4177
4843
|
emitOutgoing(proseStream.feed(event.content));
|
|
4178
4844
|
}
|
|
4179
4845
|
break;
|
|
4846
|
+
case 'retrying': {
|
|
4847
|
+
// Membrane is re-issuing after a content-policy refusal. Per the
|
|
4848
|
+
// RetryingEvent contract we must DISCARD everything this call has
|
|
4849
|
+
// emitted: the tokens above were already streamed to the surface
|
|
4850
|
+
// as outgoing chunks, and appending a second attempt to them
|
|
4851
|
+
// would show the human two half-answers spliced together.
|
|
4852
|
+
//
|
|
4853
|
+
// Re-mint the outgoing id so the partial buffer is orphaned
|
|
4854
|
+
// rather than continued (delivery is authoritative via
|
|
4855
|
+
// deliverProse and has not happened yet — nothing has been
|
|
4856
|
+
// *sent*, only previewed), and reset the prose router so its
|
|
4857
|
+
// destination bookkeeping starts clean for the new attempt.
|
|
4858
|
+
console.error(`[refusal-retry] agent=${agent.name} membrane retry ` +
|
|
4859
|
+
`${event.attempt}/${event.maxAttempts}` +
|
|
4860
|
+
(event.category ? ` category=${event.category}` : '') +
|
|
4861
|
+
' — discarding the refused attempt');
|
|
4862
|
+
outgoingInferenceId = newOutgoingInferenceId();
|
|
4863
|
+
outgoingIndex = 0;
|
|
4864
|
+
proseStream?.reset();
|
|
4865
|
+
this.emitTrace({
|
|
4866
|
+
type: 'inference:tokens',
|
|
4867
|
+
agentName: agent.name,
|
|
4868
|
+
content: '',
|
|
4869
|
+
blockType: 'text',
|
|
4870
|
+
blockIndex: 0,
|
|
4871
|
+
channelId: typingChannel ?? undefined,
|
|
4872
|
+
});
|
|
4873
|
+
break;
|
|
4874
|
+
}
|
|
4180
4875
|
case 'block': {
|
|
4181
4876
|
const { event: phase, index, block } = event.event;
|
|
4182
4877
|
this.emitTrace({
|
|
@@ -4283,6 +4978,9 @@ export class AgentFramework {
|
|
|
4283
4978
|
}
|
|
4284
4979
|
else if (turnSilenced) {
|
|
4285
4980
|
console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`);
|
|
4981
|
+
// Visible in the turn-end receipt — silencing must never
|
|
4982
|
+
// be a silent black hole (n=8: the flying-scene reply).
|
|
4983
|
+
this.recordProseSuppression(agent.name, roundSegments.length);
|
|
4286
4984
|
}
|
|
4287
4985
|
else if (hasSameRoundPrivateThink) {
|
|
4288
4986
|
console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`);
|
|
@@ -4324,17 +5022,12 @@ export class AgentFramework {
|
|
|
4324
5022
|
agent.addAssistantResponse(pending);
|
|
4325
5023
|
this.pendingAssistantBlocks.delete(agent.name);
|
|
4326
5024
|
}
|
|
4327
|
-
// Store tool results
|
|
5025
|
+
// Store tool results — same bounded spill policy as the
|
|
5026
|
+
// ordinary path (issue #89: this guard path must not become the
|
|
5027
|
+
// one door a giant blob still walks through).
|
|
4328
5028
|
const readyState = agent.state;
|
|
4329
5029
|
if (readyState.status === 'ready') {
|
|
4330
|
-
const toolResultContent = readyState.toolResults.
|
|
4331
|
-
type: 'tool_result',
|
|
4332
|
-
toolUseId: tc.id,
|
|
4333
|
-
content: tc.result.isError
|
|
4334
|
-
? (tc.result.error ?? 'Unknown error')
|
|
4335
|
-
: toolResultDataToHistoryString(tc.result.data),
|
|
4336
|
-
isError: tc.result.isError,
|
|
4337
|
-
}));
|
|
5030
|
+
const { blocks: toolResultContent } = await this.buildStoredToolResultContent(readyState.toolResults, this.resolveToolResultInlineCap(agent).cap);
|
|
4338
5031
|
agent.getContextManager().addMessage('user', toolResultContent);
|
|
4339
5032
|
}
|
|
4340
5033
|
}
|
|
@@ -4358,39 +5051,10 @@ export class AgentFramework {
|
|
|
4358
5051
|
else {
|
|
4359
5052
|
agent.addAssistantResponse(terminalContent);
|
|
4360
5053
|
}
|
|
4361
|
-
//
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
.filter((block) => block.type === 'text')
|
|
4366
|
-
.map((b) => b.text)
|
|
4367
|
-
.join('\n');
|
|
4368
|
-
const afterParams = {
|
|
4369
|
-
inferenceId: requestId,
|
|
4370
|
-
conversationId: agent.name,
|
|
4371
|
-
turnIndex: 0,
|
|
4372
|
-
userMessage: null,
|
|
4373
|
-
assistantMessage: speechText,
|
|
4374
|
-
model: {
|
|
4375
|
-
id: agent.model,
|
|
4376
|
-
vendor: 'unknown',
|
|
4377
|
-
contextWindow: 200000,
|
|
4378
|
-
capabilities: ['tools'],
|
|
4379
|
-
},
|
|
4380
|
-
usage: {
|
|
4381
|
-
inputTokens: response.usage?.inputTokens ?? 0,
|
|
4382
|
-
outputTokens: response.usage?.outputTokens ?? 0,
|
|
4383
|
-
cacheCreationTokens: response.details?.usage?.cacheCreationTokens,
|
|
4384
|
-
cacheReadTokens: response.details?.usage?.cacheReadTokens,
|
|
4385
|
-
},
|
|
4386
|
-
};
|
|
4387
|
-
await this.hookOrchestrator.afterInference(afterParams);
|
|
4388
|
-
}
|
|
4389
|
-
catch (error) {
|
|
4390
|
-
// Fail-open: continue with speech dispatch
|
|
4391
|
-
console.error('afterInference hook error:', error);
|
|
4392
|
-
}
|
|
4393
|
-
}
|
|
5054
|
+
// §10.5: context/afterInference is removed in 0.5.0 — no
|
|
5055
|
+
// content leaves the host here. Turn boundaries are announced
|
|
5056
|
+
// by the metadata-only inference/lifecycle notifications
|
|
5057
|
+
// emitted in driveStream's start/exit paths.
|
|
4394
5058
|
// Separate speech from thoughts — for MODULE dispatch only
|
|
4395
5059
|
// (dispatchSpeech / TUI rendering below). This split does NOT
|
|
4396
5060
|
// decide what reaches the channel: mid-turn prose is routed live
|
|
@@ -4472,6 +5136,17 @@ export class AgentFramework {
|
|
|
4472
5136
|
let handledByRewind = false;
|
|
4473
5137
|
const rh = agent.refusalHandling;
|
|
4474
5138
|
const forced = this.forcedRewind.get(agent.name);
|
|
5139
|
+
// NOTE: plain retries are NOT done here. `refusalHandling.retries`
|
|
5140
|
+
// is handed to membrane (see Agent.startActivation), which
|
|
5141
|
+
// replays the SAME request at the provider seam — immediately,
|
|
5142
|
+
// cache-warm, and without recompiling into a different window.
|
|
5143
|
+
// A framework-level requeue was tried first and is strictly
|
|
5144
|
+
// worse: it recompiles, so attempts are correlated rather than
|
|
5145
|
+
// fresh draws against a probabilistic threshold, and it cannot
|
|
5146
|
+
// tell a streaming consumer to discard the abandoned attempt.
|
|
5147
|
+
// By the time a refusal reaches here, membrane's retries are
|
|
5148
|
+
// already spent — so this path is the escalation: rewind (if
|
|
5149
|
+
// enabled), then the reaction.
|
|
4475
5150
|
// Shed exactly one more (newest, in-sequence) turn and keep the
|
|
4476
5151
|
// single episode marker current. `budgetLeft` bounds the loop.
|
|
4477
5152
|
const doRewind = (budgetLeft, onStep, onGiveUp) => {
|
|
@@ -4517,8 +5192,16 @@ export class AgentFramework {
|
|
|
4517
5192
|
this.rewindEpisode.delete(agent.name);
|
|
4518
5193
|
});
|
|
4519
5194
|
}
|
|
4520
|
-
|
|
5195
|
+
// The reaction is the LAST resort: retries spent, rewind not
|
|
5196
|
+
// applicable. That makes it an honest signal to humans that the
|
|
5197
|
+
// border is close, rather than noise on every near-threshold
|
|
5198
|
+
// flip. (It must not re-enter any agent's context — the emoji
|
|
5199
|
+
// is suppressed at the surface via
|
|
5200
|
+
// DISCORD_SUPPRESS_REACTION_EMOJIS; without that, marking a
|
|
5201
|
+
// refusal feeds the next one.)
|
|
5202
|
+
if (!handledByRewind) {
|
|
4521
5203
|
void this.reactToRefusal(agent.name, category);
|
|
5204
|
+
}
|
|
4522
5205
|
}
|
|
4523
5206
|
else {
|
|
4524
5207
|
// A turn that completed WITHOUT a refusal ends the rewind episode:
|
|
@@ -4579,7 +5262,8 @@ export class AgentFramework {
|
|
|
4579
5262
|
const locus = resolveTurnLocus();
|
|
4580
5263
|
console.error(`[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ?? '(none)'}`);
|
|
4581
5264
|
try {
|
|
4582
|
-
await this.channelRegistry.routeSpeech(agent.name, speechText, locus);
|
|
5265
|
+
const outcome = await this.channelRegistry.routeSpeech(agent.name, speechText, locus);
|
|
5266
|
+
this.recordProseDelivery(agent.name, outcome);
|
|
4583
5267
|
}
|
|
4584
5268
|
catch (err) {
|
|
4585
5269
|
console.error('speech routing failed:', err);
|
|
@@ -4630,6 +5314,9 @@ export class AgentFramework {
|
|
|
4630
5314
|
else if (silenced || segments.length === 0) {
|
|
4631
5315
|
console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> trailing prose NOT routed ` +
|
|
4632
5316
|
`(${silenced ? 'silencing tool / explicit send' : 'no trailing prose'})`);
|
|
5317
|
+
if (silenced && segments.length > 0) {
|
|
5318
|
+
this.recordProseSuppression(agent.name, segments.length);
|
|
5319
|
+
}
|
|
4633
5320
|
}
|
|
4634
5321
|
else {
|
|
4635
5322
|
// Reuse the locus pinned at the turn's first live-routed
|
|
@@ -4644,7 +5331,8 @@ export class AgentFramework {
|
|
|
4644
5331
|
// Deliver sequentially (await each) so the segments land in order.
|
|
4645
5332
|
for (const seg of segments) {
|
|
4646
5333
|
try {
|
|
4647
|
-
await this.channelRegistry.routeSpeech(agent.name, seg, locus);
|
|
5334
|
+
const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus);
|
|
5335
|
+
this.recordProseDelivery(agent.name, outcome);
|
|
4648
5336
|
}
|
|
4649
5337
|
catch (err) {
|
|
4650
5338
|
console.error('speech routing failed:', err);
|
|
@@ -4655,6 +5343,14 @@ export class AgentFramework {
|
|
|
4655
5343
|
// NOTE: agent.reset() + onInferenceEnded() already ran above,
|
|
4656
5344
|
// BEFORE dispatchSpeech. Locus routing is speech dispatch and
|
|
4657
5345
|
// doesn't depend on the status field, so it correctly runs after.
|
|
5346
|
+
// Logical turn end (normal completion): drop the `[delivered]`
|
|
5347
|
+
// receipt. All deliveries are settled — the live chain was
|
|
5348
|
+
// awaited before trailing dispatch, and trailing/text-only
|
|
5349
|
+
// segments were awaited in-loop. Locus mode only; explicit-mode
|
|
5350
|
+
// envelopes acknowledge themselves through the prose gateway.
|
|
5351
|
+
if (agent.proseRouting !== 'explicit') {
|
|
5352
|
+
this.appendProseDeliveryReceipt(agent);
|
|
5353
|
+
}
|
|
4658
5354
|
// Explicit-prose `!` continuation: a prose segment this turn asked
|
|
4659
5355
|
// to keep going (`>>#x !` / `>>private !`) — start another turn
|
|
4660
5356
|
// now instead of pausing until the next external event. This gives
|
|
@@ -4730,9 +5426,26 @@ export class AgentFramework {
|
|
|
4730
5426
|
// reset, no settle, no spurious inference:exhausted (which would
|
|
4731
5427
|
// reject an ephemeral's promise mid-run and bump the failure
|
|
4732
5428
|
// streak). Gate release + stream teardown happen in `finally`.
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
5429
|
+
{
|
|
5430
|
+
const cancelKey = `${agent.name}:${myStreamId}`;
|
|
5431
|
+
const cancelKind = this.frameworkCancelledStreams.get(cancelKey);
|
|
5432
|
+
if (cancelKind !== undefined) {
|
|
5433
|
+
this.frameworkCancelledStreams.delete(cancelKey);
|
|
5434
|
+
lifecyclePhase = 'aborted'; // §10.5 — terminal emitted in finally
|
|
5435
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5436
|
+
// endTurn IS a logical turn end — earlier rounds may have
|
|
5437
|
+
// live-routed prose (narrate → skip_reply is a real shape),
|
|
5438
|
+
// so settle the delivery chain and drop the receipt. A
|
|
5439
|
+
// budget restart continues the same logical turn: the
|
|
5440
|
+
// deliveries map persists (cleared only at fresh turn
|
|
5441
|
+
// start) and the continuation stream's end writes ONE
|
|
5442
|
+
// receipt for the whole turn.
|
|
5443
|
+
if (cancelKind === 'turn_ended' && agent.proseRouting !== 'explicit') {
|
|
5444
|
+
await turnSpeechChain;
|
|
5445
|
+
this.appendProseDeliveryReceipt(agent);
|
|
5446
|
+
}
|
|
5447
|
+
return;
|
|
5448
|
+
}
|
|
4736
5449
|
}
|
|
4737
5450
|
const reason = event.reason ?? 'unknown';
|
|
4738
5451
|
// Only reset if this is still the active stream (a budget restart
|
|
@@ -4772,6 +5485,11 @@ export class AgentFramework {
|
|
|
4772
5485
|
}
|
|
4773
5486
|
case 'usage': {
|
|
4774
5487
|
agent.lastStreamInputTokens = event.usage.inputTokens;
|
|
5488
|
+
agent.lastStreamRealInputTokens =
|
|
5489
|
+
(event.usage.inputTokens ?? 0) +
|
|
5490
|
+
(event.usage.cacheCreationTokens ?? 0) +
|
|
5491
|
+
(event.usage.cacheReadTokens ?? 0);
|
|
5492
|
+
agent.lastStreamOutputTokens = event.usage.outputTokens ?? 0;
|
|
4775
5493
|
// Closed-loop estimator calibration (2026-07-12). Sample the REAL
|
|
4776
5494
|
// prefix size of THIS API call (fresh + cache write + cache read)
|
|
4777
5495
|
// and hand it to the context strategy, which accepts exactly one
|
|
@@ -4839,10 +5557,22 @@ export class AgentFramework {
|
|
|
4839
5557
|
durationMs,
|
|
4840
5558
|
});
|
|
4841
5559
|
this.abortAgentScript(agent.name, 'stream threw');
|
|
5560
|
+
lifecyclePhase = 'failed'; // §10.5 — terminal emitted in finally
|
|
4842
5561
|
agent.reset();
|
|
4843
5562
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
4844
5563
|
}
|
|
4845
5564
|
finally {
|
|
5565
|
+
// §10.5: exactly one terminal per `started`, on every exit path the
|
|
5566
|
+
// host controls. Which one was decided by the path taken (default
|
|
5567
|
+
// completed; catch → failed; framework-cancel → aborted). A host
|
|
5568
|
+
// crash emits nothing — that is the documented best-effort limit, and
|
|
5569
|
+
// why consumers keep their safety timeout.
|
|
5570
|
+
this.hookOrchestrator?.emitLifecycle({
|
|
5571
|
+
inferenceId: outgoingInferenceId,
|
|
5572
|
+
conversationId: agent.name,
|
|
5573
|
+
turnIndex: 0,
|
|
5574
|
+
phase: lifecyclePhase,
|
|
5575
|
+
});
|
|
4846
5576
|
// Clear the gate's inference flag on EVERY exit path (paired with the
|
|
4847
5577
|
// onInferenceStarted in startAgentStream). The branch-level calls above
|
|
4848
5578
|
// are kept but are best-effort; if any exit path bypassed them the agent
|
|
@@ -4872,6 +5602,16 @@ export class AgentFramework {
|
|
|
4872
5602
|
if (this.closingConversationAgents.has(agent.name)) {
|
|
4873
5603
|
this.disposeConversationAgent(agent.name);
|
|
4874
5604
|
}
|
|
5605
|
+
// The turn is torn down — release the turn-alive marker BEFORE the
|
|
5606
|
+
// deferred flush below, so the flush appends at the settled tail.
|
|
5607
|
+
// Token-matched: if a successor turn already owns the agent (endTurn
|
|
5608
|
+
// reset the state machine before this finally ran — the 2026-07-31
|
|
5609
|
+
// interleave), leave its marker alone; the guarded addMessage below
|
|
5610
|
+
// then re-defers, and the successor's own turn-start flush / boundary
|
|
5611
|
+
// injection / teardown delivers the messages at a correct position.
|
|
5612
|
+
if (myTurnToken !== undefined && this.activeTurnTokens.get(agent.name) === myTurnToken) {
|
|
5613
|
+
this.activeTurnTokens.delete(agent.name);
|
|
5614
|
+
}
|
|
4875
5615
|
// Flush any deferred messages (e.g. if stream failed while tools were pending)
|
|
4876
5616
|
if (this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
|
|
4877
5617
|
const deferred = this.deferredMessages.splice(0);
|
|
@@ -4885,8 +5625,22 @@ export class AgentFramework {
|
|
|
4885
5625
|
* Execute a tool call and return the result.
|
|
4886
5626
|
* Routes to the appropriate handler (module registry or MCPL).
|
|
4887
5627
|
* Used by SubagentModule to dispatch tool calls for ephemeral agents.
|
|
5628
|
+
*
|
|
5629
|
+
* This public entry always carries AGENT origin. Ephemeral/promise-based
|
|
5630
|
+
* callers are models, and provenance must identify which internal path a
|
|
5631
|
+
* call entered through — a public method shared with model callers can
|
|
5632
|
+
* never confer module trust, whatever the input claims. Module origin
|
|
5633
|
+
* exists only through the private closure handed to ModuleRegistry
|
|
5634
|
+
* (ctx.callTool → executeToolCallFrom with {kind:'module'}). Callers that
|
|
5635
|
+
* don't identify themselves fail safe to agent semantics too.
|
|
4888
5636
|
*/
|
|
4889
5637
|
async executeToolCall(call) {
|
|
5638
|
+
return this.executeToolCallFrom(call, {
|
|
5639
|
+
kind: 'agent',
|
|
5640
|
+
agentName: call.callerAgentName ?? '__ephemeral__',
|
|
5641
|
+
});
|
|
5642
|
+
}
|
|
5643
|
+
async executeToolCallFrom(call, origin) {
|
|
4890
5644
|
// Client-side programmatic tool calling for promise-based callers
|
|
4891
5645
|
// (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral
|
|
4892
5646
|
// gets its own interpreter state.
|
|
@@ -4905,6 +5659,25 @@ export class AgentFramework {
|
|
|
4905
5659
|
}
|
|
4906
5660
|
}
|
|
4907
5661
|
}
|
|
5662
|
+
// Synthesized channel tools. This route was missing: when
|
|
5663
|
+
// subscription-GC moved off the retired MCPL unsubscribe tool onto the
|
|
5664
|
+
// generic channel_close (discord-mcpl b095a9f), module-originated
|
|
5665
|
+
// closes started failing downstream as "Invalid tool name format" —
|
|
5666
|
+
// the janitor had been silently broken since. `origin` is trusted
|
|
5667
|
+
// dispatch context established at the call boundary above, not
|
|
5668
|
+
// inferred from which method was used: only the ModuleRegistry closure
|
|
5669
|
+
// carries module provenance (see handleToolClose). The agent-only
|
|
5670
|
+
// synthesized verbs (think, skip_reply) are deliberately not routed
|
|
5671
|
+
// here.
|
|
5672
|
+
if (call.name.startsWith('channel_') && this.channelRegistry) {
|
|
5673
|
+
return this.channelRegistry.handleChannelToolCall(call.name, call.input, origin);
|
|
5674
|
+
}
|
|
5675
|
+
// The utils meta-tool — programmatic callers (code_execution scripts,
|
|
5676
|
+
// ephemerals, ModuleContext.callTool) reach utilities the same way the
|
|
5677
|
+
// model does.
|
|
5678
|
+
if (call.name === 'utils') {
|
|
5679
|
+
return this.handleUtilsToolCall(call.callerAgentName ?? '__ephemeral__', call);
|
|
5680
|
+
}
|
|
4908
5681
|
// Module tools
|
|
4909
5682
|
return this.moduleRegistry.handleToolCall(call);
|
|
4910
5683
|
}
|
|
@@ -5000,6 +5773,43 @@ export class AgentFramework {
|
|
|
5000
5773
|
*/
|
|
5001
5774
|
async runCodeExecution(agentName, call) {
|
|
5002
5775
|
const input = (call.input ?? {});
|
|
5776
|
+
// Management surface: the agent's own daemon fleet is inspectable and
|
|
5777
|
+
// killable through the same tool that spawns it.
|
|
5778
|
+
if (input.action === 'list') {
|
|
5779
|
+
const scripts = [...this.backgroundScripts.values()]
|
|
5780
|
+
.filter((s) => s.agentName === agentName)
|
|
5781
|
+
.map((s) => ({
|
|
5782
|
+
script_id: s.id,
|
|
5783
|
+
status: s.status,
|
|
5784
|
+
started_at: new Date(s.startedAt).toISOString(),
|
|
5785
|
+
wakes: s.wakes,
|
|
5786
|
+
last_wake_at: s.lastWakeAt ? new Date(s.lastWakeAt).toISOString() : null,
|
|
5787
|
+
log: s.logPath,
|
|
5788
|
+
}));
|
|
5789
|
+
return { success: true, data: { background_scripts: scripts } };
|
|
5790
|
+
}
|
|
5791
|
+
if (input.action === 'cancel') {
|
|
5792
|
+
if (typeof input.script_id !== 'string') {
|
|
5793
|
+
return { success: false, error: 'cancel requires `script_id`', isError: true };
|
|
5794
|
+
}
|
|
5795
|
+
const record = this.backgroundScripts.get(input.script_id);
|
|
5796
|
+
if (!record || record.agentName !== agentName) {
|
|
5797
|
+
return { success: false, error: `no background script '${input.script_id}'`, isError: true };
|
|
5798
|
+
}
|
|
5799
|
+
if (record.status === 'running') {
|
|
5800
|
+
record.cancelled = true;
|
|
5801
|
+
record.status = 'cancelled';
|
|
5802
|
+
record.runner.abort('cancelled by agent');
|
|
5803
|
+
record.runner.dispose();
|
|
5804
|
+
}
|
|
5805
|
+
return {
|
|
5806
|
+
success: true,
|
|
5807
|
+
data: { script_id: record.id, status: record.status, log: record.logPath },
|
|
5808
|
+
};
|
|
5809
|
+
}
|
|
5810
|
+
if (input.action !== undefined && input.action !== 'run') {
|
|
5811
|
+
return { success: false, error: `unknown action ${JSON.stringify(input.action)}`, isError: true };
|
|
5812
|
+
}
|
|
5003
5813
|
if (typeof input.code !== 'string' || input.code.trim() === '') {
|
|
5004
5814
|
return {
|
|
5005
5815
|
success: false,
|
|
@@ -5012,6 +5822,9 @@ export class AgentFramework {
|
|
|
5012
5822
|
? this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name))
|
|
5013
5823
|
: this.getAllTools(); // ephemeral agents: full surface, matching executeToolCall
|
|
5014
5824
|
const injected = buildInjectedTools(surface.map((t) => t.name).filter((name) => name !== CODE_EXECUTION_TOOL_NAME));
|
|
5825
|
+
if (input.background === true) {
|
|
5826
|
+
return this.startBackgroundScript(agentName, input.code, injected);
|
|
5827
|
+
}
|
|
5015
5828
|
const runner = this.getOrCreateScriptRunner(agentName);
|
|
5016
5829
|
this.scriptDeferredEndTurn.delete(agentName);
|
|
5017
5830
|
const exec = await runner.exec(input.code, injected);
|
|
@@ -5023,6 +5836,307 @@ export class AgentFramework {
|
|
|
5023
5836
|
...(endTurn ? { endTurn: true } : {}),
|
|
5024
5837
|
};
|
|
5025
5838
|
}
|
|
5839
|
+
// ---------------------------------------------------------------------------
|
|
5840
|
+
// Background (daemon) scripts — model-authored watchers that outlive the turn
|
|
5841
|
+
// ---------------------------------------------------------------------------
|
|
5842
|
+
/**
|
|
5843
|
+
* Start a background script: dedicated interpreter, output journaled to a
|
|
5844
|
+
* workspace file, wake_agent() available in-script. The tool result
|
|
5845
|
+
* returns immediately; the script runs until it ends, hits the lifetime
|
|
5846
|
+
* ceiling, or is cancelled. Scripts DIE WITH THE HOST PROCESS — that
|
|
5847
|
+
* limitation is stated in the tool description so agents can calibrate
|
|
5848
|
+
* how much to rely on an armed wake.
|
|
5849
|
+
*/
|
|
5850
|
+
startBackgroundScript(agentName, code, injected) {
|
|
5851
|
+
// v1: primary-agent-only. A conversation fork's or ephemeral's daemon
|
|
5852
|
+
// would outlive its owner and its wake would land in the primary
|
|
5853
|
+
// conversation anyway — refuse loudly instead of surprising anyone.
|
|
5854
|
+
if (this.primaryAgentName && agentName !== this.primaryAgentName) {
|
|
5855
|
+
return {
|
|
5856
|
+
success: false,
|
|
5857
|
+
error: 'background scripts are available to the primary agent only',
|
|
5858
|
+
isError: true,
|
|
5859
|
+
};
|
|
5860
|
+
}
|
|
5861
|
+
const cfg = this.codeExecutionConfig;
|
|
5862
|
+
const maxScripts = cfg?.maxBackgroundScripts ?? 3;
|
|
5863
|
+
const runningCount = [...this.backgroundScripts.values()]
|
|
5864
|
+
.filter((s) => s.agentName === agentName && s.status === 'running').length;
|
|
5865
|
+
if (runningCount >= maxScripts) {
|
|
5866
|
+
return {
|
|
5867
|
+
success: false,
|
|
5868
|
+
error: `background script limit reached (${maxScripts} running). ` +
|
|
5869
|
+
'Use code_execution {"action": "list"} to inspect and {"action": "cancel", "script_id": ...} to free a slot.',
|
|
5870
|
+
isError: true,
|
|
5871
|
+
};
|
|
5872
|
+
}
|
|
5873
|
+
const scriptId = `bg-${++this.backgroundScriptCounter}`;
|
|
5874
|
+
const lifetimeMs = cfg?.backgroundMaxLifetimeMs ?? 86_400_000;
|
|
5875
|
+
// Journal: a file under the agent's first read-write workspace mount so
|
|
5876
|
+
// their existing read/grep/shell tools work on it. Python appends
|
|
5877
|
+
// directly to the materialized path; watch-mode mounts sync it back to
|
|
5878
|
+
// the chronicle tree on their usual cadence.
|
|
5879
|
+
let logPath = null;
|
|
5880
|
+
let logAbsPath = null;
|
|
5881
|
+
const workspace = this.getWorkspaceModule();
|
|
5882
|
+
if (workspace) {
|
|
5883
|
+
const mountName = this.firstWritableMountName(workspace);
|
|
5884
|
+
if (mountName) {
|
|
5885
|
+
logPath = `${mountName}/background-scripts/${scriptId}.log`;
|
|
5886
|
+
logAbsPath = workspace.resolveAbsolutePath(logPath);
|
|
5887
|
+
}
|
|
5888
|
+
}
|
|
5889
|
+
const runner = new PyRunner({
|
|
5890
|
+
pythonPath: cfg?.pythonPath,
|
|
5891
|
+
toolCallTimeoutMs: cfg?.toolCallTimeoutMs,
|
|
5892
|
+
scriptTimeoutMs: cfg?.scriptTimeoutMs,
|
|
5893
|
+
idleReclaimMs: 0, // dedicated runner; lifetime is the exec deadline
|
|
5894
|
+
label: `${agentName}:${scriptId}`,
|
|
5895
|
+
onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args),
|
|
5896
|
+
});
|
|
5897
|
+
const record = {
|
|
5898
|
+
id: scriptId,
|
|
5899
|
+
agentName,
|
|
5900
|
+
runner,
|
|
5901
|
+
code,
|
|
5902
|
+
startedAt: Date.now(),
|
|
5903
|
+
wakes: 0,
|
|
5904
|
+
lastWakeAt: null,
|
|
5905
|
+
logPath,
|
|
5906
|
+
status: 'running',
|
|
5907
|
+
cancelled: false,
|
|
5908
|
+
};
|
|
5909
|
+
this.backgroundScripts.set(scriptId, record);
|
|
5910
|
+
this.emitTrace({
|
|
5911
|
+
type: 'tool:started', module: 'code_execution', tool: `background:${scriptId}`,
|
|
5912
|
+
callId: scriptId, input: { lines: code.split('\n').length },
|
|
5913
|
+
});
|
|
5914
|
+
void runner
|
|
5915
|
+
.exec(code, injected, {
|
|
5916
|
+
logPath: logAbsPath,
|
|
5917
|
+
lifetimeMs,
|
|
5918
|
+
onWake: (line, payload) => this.handleScriptWake(record, line, payload),
|
|
5919
|
+
})
|
|
5920
|
+
.then((exec) => this.settleBackgroundScript(record, exec))
|
|
5921
|
+
.catch((err) => {
|
|
5922
|
+
// exec never rejects by contract; this is the belt-and-suspenders.
|
|
5923
|
+
console.error(`[pytc:${agentName}:${scriptId}] background exec rejected: ${String(err)}`);
|
|
5924
|
+
this.settleBackgroundScript(record, {
|
|
5925
|
+
stdout: '', stderr: String(err), returnCode: 1, aborted: true,
|
|
5926
|
+
});
|
|
5927
|
+
});
|
|
5928
|
+
return {
|
|
5929
|
+
success: true,
|
|
5930
|
+
data: {
|
|
5931
|
+
status: 'background script started',
|
|
5932
|
+
script_id: scriptId,
|
|
5933
|
+
log: logPath ?? 'no writable workspace mount — output is not retrievable; only wake_agent() reaches you',
|
|
5934
|
+
lifetime_hours: Math.round(lifetimeMs / 3_600_000 * 10) / 10,
|
|
5935
|
+
note: 'The script dies if the host process restarts. Manage with code_execution {"action": "list"|"cancel"}.',
|
|
5936
|
+
},
|
|
5937
|
+
};
|
|
5938
|
+
}
|
|
5939
|
+
/** First read-write mount name, or null. */
|
|
5940
|
+
firstWritableMountName(workspace) {
|
|
5941
|
+
const mounts = workspace.getMounts?.();
|
|
5942
|
+
if (mounts) {
|
|
5943
|
+
const rw = mounts.find((m) => m.mode !== 'read-only');
|
|
5944
|
+
return rw?.name ?? null;
|
|
5945
|
+
}
|
|
5946
|
+
return null;
|
|
5947
|
+
}
|
|
5948
|
+
/**
|
|
5949
|
+
* wake_agent() from a background script: enforce the per-script wake cap
|
|
5950
|
+
* and rate floor (delay, not drop — the script awaits the ack), then
|
|
5951
|
+
* inject the provenance envelope + payload and request inference.
|
|
5952
|
+
* Resolves null on delivery; an error string refuses the wake (raises
|
|
5953
|
+
* RuntimeError inside the script).
|
|
5954
|
+
*/
|
|
5955
|
+
async handleScriptWake(record, line, payload) {
|
|
5956
|
+
if (record.cancelled)
|
|
5957
|
+
return 'script was cancelled';
|
|
5958
|
+
const cfg = this.codeExecutionConfig;
|
|
5959
|
+
const maxWakes = cfg?.maxWakesPerScript ?? 100;
|
|
5960
|
+
if (record.wakes >= maxWakes) {
|
|
5961
|
+
return `wake limit reached (${maxWakes} per script)`;
|
|
5962
|
+
}
|
|
5963
|
+
const floorMs = cfg?.wakeMinIntervalMs ?? 60_000;
|
|
5964
|
+
if (record.lastWakeAt !== null) {
|
|
5965
|
+
const wait = record.lastWakeAt + floorMs - Date.now();
|
|
5966
|
+
if (wait > 0) {
|
|
5967
|
+
await new Promise((r) => {
|
|
5968
|
+
const t = setTimeout(r, wait);
|
|
5969
|
+
t.unref?.();
|
|
5970
|
+
});
|
|
5971
|
+
if (record.cancelled)
|
|
5972
|
+
return 'script was cancelled';
|
|
5973
|
+
}
|
|
5974
|
+
}
|
|
5975
|
+
record.wakes += 1;
|
|
5976
|
+
record.lastWakeAt = Date.now();
|
|
5977
|
+
const elapsedMin = Math.round((Date.now() - record.startedAt) / 60_000);
|
|
5978
|
+
const payloadJson = payload === undefined || payload === null
|
|
5979
|
+
? 'null'
|
|
5980
|
+
: JSON.stringify(payload, null, 1) ?? String(payload);
|
|
5981
|
+
const agent = this.agents.get(record.agentName);
|
|
5982
|
+
// Same resolved cap as tool results (override applies here too); an
|
|
5983
|
+
// unknown agent still gets the bounded house default, never unbounded.
|
|
5984
|
+
const maxChars = agent
|
|
5985
|
+
? this.resolveToolResultInlineCap(agent).cap
|
|
5986
|
+
: DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS;
|
|
5987
|
+
const { text: payloadText } = await this.spillOrTruncate(payloadJson, maxChars, `${record.id}-wake${record.wakes}`);
|
|
5988
|
+
const envelope = `[background script ${record.id}] Woke you: wake_agent() called at line ${line} of your script, ` +
|
|
5989
|
+
`${elapsedMin}m after you started it (wake ${record.wakes} of ${maxWakes}).\n` +
|
|
5990
|
+
`Arguments:\n${payloadText}\n` +
|
|
5991
|
+
(record.logPath ? `Script output so far: workspace file ${record.logPath}\n` : '') +
|
|
5992
|
+
`Script status: still running`;
|
|
5993
|
+
this.injectScriptWake(record, envelope);
|
|
5994
|
+
return null;
|
|
5995
|
+
}
|
|
5996
|
+
/** Background script ended (clean, crash, deadline, cancel/dispose). */
|
|
5997
|
+
settleBackgroundScript(record, exec) {
|
|
5998
|
+
record.runner.dispose();
|
|
5999
|
+
if (record.status === 'running') {
|
|
6000
|
+
record.status = exec.returnCode === 0 ? 'finished' : 'died';
|
|
6001
|
+
}
|
|
6002
|
+
this.emitTrace({
|
|
6003
|
+
type: 'tool:completed', module: 'code_execution', tool: `background:${record.id}`,
|
|
6004
|
+
callId: record.id, durationMs: Date.now() - record.startedAt,
|
|
6005
|
+
});
|
|
6006
|
+
// Crash honesty: a resident sleeping on a promise must never have that
|
|
6007
|
+
// promise die silently. Deliberate cancels/disposes stay silent (the
|
|
6008
|
+
// agent or host chose them); clean exits stay silent (the null path).
|
|
6009
|
+
if (record.status === 'died' && !record.cancelled) {
|
|
6010
|
+
const tail = (exec.tail ?? exec.stderr ?? '').slice(-2000);
|
|
6011
|
+
const elapsedMin = Math.round((Date.now() - record.startedAt) / 60_000);
|
|
6012
|
+
const envelope = `[background script ${record.id}] Your background script DIED ${elapsedMin}m after start ` +
|
|
6013
|
+
`(it did not call wake_agent and is no longer watching).\n` +
|
|
6014
|
+
`Last output:\n${tail || '(none)'}\n` +
|
|
6015
|
+
(record.logPath ? `Full journal: workspace file ${record.logPath}` : '');
|
|
6016
|
+
this.injectScriptWake(record, envelope);
|
|
6017
|
+
}
|
|
6018
|
+
// Keep a short memory of settled scripts for {"action":"list"}, then drop.
|
|
6019
|
+
const settled = [...this.backgroundScripts.values()]
|
|
6020
|
+
.filter((s) => s.agentName === record.agentName && s.status !== 'running');
|
|
6021
|
+
for (const old of settled.slice(0, Math.max(0, settled.length - 5))) {
|
|
6022
|
+
this.backgroundScripts.delete(old.id);
|
|
6023
|
+
}
|
|
6024
|
+
}
|
|
6025
|
+
/**
|
|
6026
|
+
* Deliver a background-script event: provenance envelope into the agent's
|
|
6027
|
+
* context + an inference request. Deliberately does NOT consult the event
|
|
6028
|
+
* gate — the agent armed this wake themselves (wake-through-sleep
|
|
6029
|
+
* authority); it enters tagged so gate policies could be taught about it
|
|
6030
|
+
* later if that ever needs revisiting.
|
|
6031
|
+
*/
|
|
6032
|
+
injectScriptWake(record, envelope) {
|
|
6033
|
+
try {
|
|
6034
|
+
const messageId = this.addMessage('user', [{ type: 'text', text: envelope }], {
|
|
6035
|
+
source: 'background-script',
|
|
6036
|
+
scriptId: record.id,
|
|
6037
|
+
tags: ['script:wake'],
|
|
6038
|
+
});
|
|
6039
|
+
this.pendingRequests.push({
|
|
6040
|
+
agentName: record.agentName,
|
|
6041
|
+
reason: 'script:wake',
|
|
6042
|
+
source: 'code_execution',
|
|
6043
|
+
timestamp: Date.now(),
|
|
6044
|
+
});
|
|
6045
|
+
this.emitTrace({
|
|
6046
|
+
type: 'message:added',
|
|
6047
|
+
messageId,
|
|
6048
|
+
source: `background-script:${record.id}`,
|
|
6049
|
+
});
|
|
6050
|
+
}
|
|
6051
|
+
catch (err) {
|
|
6052
|
+
console.error(`[pytc:${record.agentName}:${record.id}] wake injection failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
6053
|
+
}
|
|
6054
|
+
}
|
|
6055
|
+
/**
|
|
6056
|
+
* Oversized content policy (antra, 2026-07-31; completed for issue #89):
|
|
6057
|
+
* never hit the agent in the face with a huge blob, never silently destroy
|
|
6058
|
+
* it either. Content over the inline cap is materialized to a workspace
|
|
6059
|
+
* file (readable with the agent's own tools — deterministic name, kept
|
|
6060
|
+
* until the workspace owner deletes it) and replaced by a truncated head +
|
|
6061
|
+
* a trailing file reference. Falls back to EXPLICIT plain truncation when
|
|
6062
|
+
* there is no writable workspace. Cap resolution lives in
|
|
6063
|
+
* resolveToolResultInlineCap; callers pass the resolved cap.
|
|
6064
|
+
*/
|
|
6065
|
+
/**
|
|
6066
|
+
* One spill policy for every stored tool_result block: serialize (history
|
|
6067
|
+
* serializer — image blocks become placeholders), then spill/truncate at
|
|
6068
|
+
* the resolved cap. Errors follow the same policy as successes — a giant
|
|
6069
|
+
* error string is exactly as context-hostile as a giant success (issue
|
|
6070
|
+
* #89). Returns the stored blocks plus the per-call spill outcomes so the
|
|
6071
|
+
* live wire copy can reuse the identical strings.
|
|
6072
|
+
*/
|
|
6073
|
+
async buildStoredToolResultContent(toolResults, maxChars) {
|
|
6074
|
+
const spilled = new Map();
|
|
6075
|
+
const dateLabel = new Date().toISOString().slice(0, 10);
|
|
6076
|
+
for (const tc of toolResults) {
|
|
6077
|
+
const raw = tc.result.isError
|
|
6078
|
+
? tc.result.error ?? 'Unknown error'
|
|
6079
|
+
: toolResultDataToHistoryString(tc.result.data, undefined);
|
|
6080
|
+
spilled.set(tc.id, await this.spillOrTruncate(raw, maxChars, `${dateLabel}-${tc.id}`));
|
|
6081
|
+
}
|
|
6082
|
+
const blocks = toolResults.map(tc => ({
|
|
6083
|
+
type: 'tool_result',
|
|
6084
|
+
toolUseId: tc.id,
|
|
6085
|
+
content: spilled.get(tc.id).text,
|
|
6086
|
+
isError: tc.result.isError,
|
|
6087
|
+
}));
|
|
6088
|
+
return { blocks, spilled };
|
|
6089
|
+
}
|
|
6090
|
+
async spillOrTruncate(content, cap, label) {
|
|
6091
|
+
if (!cap || content.length <= cap)
|
|
6092
|
+
return { text: content, filePath: null };
|
|
6093
|
+
const workspace = this.getWorkspaceModule();
|
|
6094
|
+
const mountName = workspace ? this.firstWritableMountName(workspace) : null;
|
|
6095
|
+
if (workspace && mountName) {
|
|
6096
|
+
const safeLabel = label.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
|
|
6097
|
+
const path = `${mountName}/tool-results/${safeLabel}.txt`;
|
|
6098
|
+
let failure;
|
|
6099
|
+
try {
|
|
6100
|
+
const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain');
|
|
6101
|
+
if (result.success) {
|
|
6102
|
+
return {
|
|
6103
|
+
text: safeSlice(content, 0, cap)
|
|
6104
|
+
+ `\n\n[truncated — showing ${cap} of ${content.length} chars; full content: workspace file ${path}. `
|
|
6105
|
+
+ 'Read/grep it with your file tools, or raise the inline cap via '
|
|
6106
|
+
+ 'agent_settings update tool_result_inline_max_chars.]',
|
|
6107
|
+
filePath: path,
|
|
6108
|
+
};
|
|
6109
|
+
}
|
|
6110
|
+
failure = result.error ?? 'write refused';
|
|
6111
|
+
}
|
|
6112
|
+
catch (err) {
|
|
6113
|
+
failure = err instanceof Error ? err.message : String(err);
|
|
6114
|
+
}
|
|
6115
|
+
// A workspace was there and the write FAILED — say that, loudly and
|
|
6116
|
+
// distinctly. Telling the agent "no writable workspace" would teach
|
|
6117
|
+
// them their residence lacks a capability it actually has.
|
|
6118
|
+
failure = failure.slice(0, 200);
|
|
6119
|
+
this.emitTrace({
|
|
6120
|
+
type: 'tool:spill_failed',
|
|
6121
|
+
label: safeLabel,
|
|
6122
|
+
path,
|
|
6123
|
+
contentLength: content.length,
|
|
6124
|
+
error: failure,
|
|
6125
|
+
});
|
|
6126
|
+
console.error(`[spill] workspace write failed for ${path} (${content.length} chars): ${failure}`);
|
|
6127
|
+
return {
|
|
6128
|
+
text: safeSlice(content, 0, cap)
|
|
6129
|
+
+ `\n\n[truncated — showing ${cap} of ${content.length} chars; spill to workspace file ${path} FAILED`
|
|
6130
|
+
+ ` (${failure}); content over the cap was not retained]`,
|
|
6131
|
+
filePath: null,
|
|
6132
|
+
};
|
|
6133
|
+
}
|
|
6134
|
+
return {
|
|
6135
|
+
text: safeSlice(content, 0, cap)
|
|
6136
|
+
+ '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace, full content not retained]',
|
|
6137
|
+
filePath: null,
|
|
6138
|
+
};
|
|
6139
|
+
}
|
|
5026
6140
|
getOrCreateScriptRunner(agentName) {
|
|
5027
6141
|
let runner = this.codeExecutionRunners.get(agentName);
|
|
5028
6142
|
if (!runner) {
|
|
@@ -5064,9 +6178,10 @@ export class AgentFramework {
|
|
|
5064
6178
|
}
|
|
5065
6179
|
if (result.data === undefined)
|
|
5066
6180
|
return '';
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
6181
|
+
// Scripts get (nearly) full data — the script environment IS the spill:
|
|
6182
|
+
// filtering big results in code is the whole point. 5MB protocol safety
|
|
6183
|
+
// cap only; no context-cap truncation, no file spill.
|
|
6184
|
+
return toolResultDataToHistoryString(result.data, 5_000_000);
|
|
5070
6185
|
}
|
|
5071
6186
|
/**
|
|
5072
6187
|
* Dispatch a script-inner tool call through the NORMAL dispatch path and
|
|
@@ -5114,18 +6229,30 @@ export class AgentFramework {
|
|
|
5114
6229
|
runner.abort(reason);
|
|
5115
6230
|
}
|
|
5116
6231
|
}
|
|
5117
|
-
toMembraneToolResult(callId, afResult, maxChars
|
|
6232
|
+
toMembraneToolResult(callId, afResult, maxChars,
|
|
6233
|
+
/** Pre-spilled outcome from the history path — used for the non-image
|
|
6234
|
+
* path so the live wire copy byte-matches what the window stored. */
|
|
6235
|
+
precomputed) {
|
|
5118
6236
|
if (afResult.isError) {
|
|
5119
|
-
|
|
6237
|
+
// Same spill policy as successes: reuse the history copy so a giant
|
|
6238
|
+
// error string stays bounded on the wire too (byte-matched).
|
|
6239
|
+
return {
|
|
6240
|
+
toolUseId: callId,
|
|
6241
|
+
content: precomputed?.text ?? truncateForHistory(afResult.error ?? 'Unknown error', DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS),
|
|
6242
|
+
isError: true,
|
|
6243
|
+
};
|
|
5120
6244
|
}
|
|
5121
6245
|
// MCPL tool results arrive as `data: McpToolResultContent[]` — preserve image
|
|
5122
6246
|
// blocks natively rather than JSON-stringifying them away. Anything else
|
|
5123
6247
|
// (objects, scalars) falls through to JSON. The error path was handled
|
|
5124
6248
|
// above, so isError is always false on these return paths.
|
|
5125
|
-
const blocks = this.tryNativeToolResultContent(afResult.data, maxChars);
|
|
6249
|
+
const blocks = this.tryNativeToolResultContent(afResult.data, maxChars, precomputed?.filePath ?? null);
|
|
5126
6250
|
if (blocks) {
|
|
5127
6251
|
return { toolUseId: callId, content: blocks, isError: false };
|
|
5128
6252
|
}
|
|
6253
|
+
if (precomputed !== undefined) {
|
|
6254
|
+
return { toolUseId: callId, content: precomputed.text, isError: false };
|
|
6255
|
+
}
|
|
5129
6256
|
// JSON.stringify returns the VALUE undefined (not a string) for undefined
|
|
5130
6257
|
// input — a module tool returning `{ success: true }` with no data would
|
|
5131
6258
|
// otherwise make `content.length` below throw a TypeError mid-turn.
|
|
@@ -5143,7 +6270,10 @@ export class AgentFramework {
|
|
|
5143
6270
|
* `maxChars`, when provided, caps each accompanying text block so an image
|
|
5144
6271
|
* inlined alongside an enormous text payload can't blow the context.
|
|
5145
6272
|
*/
|
|
5146
|
-
tryNativeToolResultContent(data, maxChars
|
|
6273
|
+
tryNativeToolResultContent(data, maxChars,
|
|
6274
|
+
/** Spill file written by the history path — referenced from truncation
|
|
6275
|
+
* notices so an image-adjacent giant text block stays recoverable. */
|
|
6276
|
+
spillPath = null) {
|
|
5147
6277
|
if (!Array.isArray(data))
|
|
5148
6278
|
return null;
|
|
5149
6279
|
let hasImage = false;
|
|
@@ -5156,7 +6286,9 @@ export class AgentFramework {
|
|
|
5156
6286
|
let text = b.text;
|
|
5157
6287
|
if (maxChars && text.length > maxChars) {
|
|
5158
6288
|
text = safeSlice(text, 0, maxChars)
|
|
5159
|
-
+ '\n\n[truncated — original was ' + text.length + ' chars
|
|
6289
|
+
+ '\n\n[truncated — original was ' + text.length + ' chars'
|
|
6290
|
+
+ (spillPath ? `; full serialized result: workspace file ${spillPath}` : '')
|
|
6291
|
+
+ ']';
|
|
5160
6292
|
}
|
|
5161
6293
|
blocks.push({ type: 'text', text });
|
|
5162
6294
|
}
|
|
@@ -5173,13 +6305,39 @@ export class AgentFramework {
|
|
|
5173
6305
|
}
|
|
5174
6306
|
return hasImage ? blocks : null;
|
|
5175
6307
|
}
|
|
5176
|
-
|
|
6308
|
+
/** Strategy-derived per-message bound (maxMessageTokens * 4 chars). */
|
|
6309
|
+
strategyDerivedToolResultChars(agent) {
|
|
5177
6310
|
const strategy = agent.getContextManager().getStrategy();
|
|
5178
6311
|
const maxTokens = strategy.maxMessageTokens;
|
|
5179
6312
|
if (maxTokens && maxTokens > 0)
|
|
5180
6313
|
return maxTokens * 4;
|
|
5181
6314
|
return undefined;
|
|
5182
6315
|
}
|
|
6316
|
+
/**
|
|
6317
|
+
* Effective tool-result inline cap for an agent, with provenance. Desired
|
|
6318
|
+
* value: resident's durable agent_settings value → residence
|
|
6319
|
+
* FrameworkConfig.toolResultInlineMaxChars → house default (5000).
|
|
6320
|
+
* Effective value: min(desired, strategy bound) for EVERY source — the
|
|
6321
|
+
* strategy's per-message safety limit is a ceiling, not a suggestion
|
|
6322
|
+
* (Sol's #94 ruling: a durable preference must not be a durable path for
|
|
6323
|
+
* one tool result to exceed maxMessageTokens; the way to see the whole
|
|
6324
|
+
* result is the spill file, not an over-bound blob in live context).
|
|
6325
|
+
*/
|
|
6326
|
+
resolveToolResultInlineCap(agent) {
|
|
6327
|
+
const override = this.toolResultInlineMaxCharsOverride.get(agent.name);
|
|
6328
|
+
const configured = this.toolResultInlineMaxCharsConfig;
|
|
6329
|
+
const desired = override ?? configured ?? DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS;
|
|
6330
|
+
const source = override !== undefined
|
|
6331
|
+
? 'agent-settings-override'
|
|
6332
|
+
: configured !== null ? 'framework-config' : 'default';
|
|
6333
|
+
const strategyBound = this.strategyDerivedToolResultChars(agent);
|
|
6334
|
+
const clamped = strategyBound !== undefined && strategyBound < desired;
|
|
6335
|
+
return {
|
|
6336
|
+
cap: clamped ? strategyBound : desired,
|
|
6337
|
+
source,
|
|
6338
|
+
clampedBy: clamped ? 'strategy-bound' : null,
|
|
6339
|
+
};
|
|
6340
|
+
}
|
|
5183
6341
|
approximateDecodedBase64Bytes(base64) {
|
|
5184
6342
|
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
|
|
5185
6343
|
return Math.max(0, Math.floor(base64.length * 3 / 4) - padding);
|
|
@@ -5292,6 +6450,10 @@ export class AgentFramework {
|
|
|
5292
6450
|
return;
|
|
5293
6451
|
}
|
|
5294
6452
|
// Route the agent's typed, allowlisted hot-settings surface.
|
|
6453
|
+
if (enrichedCall.name === 'utils') {
|
|
6454
|
+
this.dispatchUtilsToolCall(agentName, enrichedCall);
|
|
6455
|
+
return;
|
|
6456
|
+
}
|
|
5295
6457
|
if (enrichedCall.name === 'agent_settings') {
|
|
5296
6458
|
this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
|
|
5297
6459
|
return;
|
|
@@ -5329,6 +6491,26 @@ export class AgentFramework {
|
|
|
5329
6491
|
// synthesized channel tools, but they aren't `channel_`-prefixed so they
|
|
5330
6492
|
// need an explicit route here.
|
|
5331
6493
|
if ((enrichedCall.name === 'think' || enrichedCall.name === 'skip_reply') && this.channelRegistry) {
|
|
6494
|
+
// skip_reply(wake_in_seconds): arm a gate self-wake so "not replying
|
|
6495
|
+
// NOW" can also mean "back in a moment" — ends the turn, then wakes
|
|
6496
|
+
// the agent after N seconds unless something else wakes it first
|
|
6497
|
+
// (any turn start cancels the pending self-wake). Armed here because
|
|
6498
|
+
// the framework owns the EventGate; the registry only words the
|
|
6499
|
+
// confirmation. Without a gate the field is stripped so that
|
|
6500
|
+
// confirmation never claims a wake that can't happen.
|
|
6501
|
+
if (enrichedCall.name === 'skip_reply') {
|
|
6502
|
+
const inputObj = (enrichedCall.input ?? {});
|
|
6503
|
+
const secs = Number(inputObj.wake_in_seconds);
|
|
6504
|
+
const wants = inputObj.wake_in_seconds !== undefined && Number.isFinite(secs) && secs > 0;
|
|
6505
|
+
if (wants && this.eventGate) {
|
|
6506
|
+
const { inMs } = this.eventGate.armSelfWake(agentName, secs, 'skip_reply');
|
|
6507
|
+
console.error(`[self-wake] agent=${agentName} armed in ${Math.round(inMs / 1000)}s (skip_reply)`);
|
|
6508
|
+
}
|
|
6509
|
+
else if (inputObj.wake_in_seconds !== undefined && !this.eventGate) {
|
|
6510
|
+
delete inputObj.wake_in_seconds;
|
|
6511
|
+
console.error(`[self-wake] agent=${agentName} wake_in_seconds requested but no EventGate — ignored`);
|
|
6512
|
+
}
|
|
6513
|
+
}
|
|
5332
6514
|
this.dispatchChannelToolCall(agentName, enrichedCall);
|
|
5333
6515
|
return;
|
|
5334
6516
|
}
|
|
@@ -5427,15 +6609,25 @@ export class AgentFramework {
|
|
|
5427
6609
|
}
|
|
5428
6610
|
// Defer non-tool_result messages while a tool cycle is mid-flight
|
|
5429
6611
|
// (pendingAssistantBlocks: preserves tool_use → tool_result adjacency)
|
|
5430
|
-
// OR while the target agent has a
|
|
5431
|
-
// mid-
|
|
5432
|
-
// though the live conversation never saw it — so the next compile
|
|
5433
|
-
// diverges from the live prefix (prompt-cache bust)
|
|
5434
|
-
//
|
|
5435
|
-
//
|
|
5436
|
-
//
|
|
6612
|
+
// OR while the target agent has a turn in progress AT ALL. A message
|
|
6613
|
+
// stored mid-turn lands BEFORE the turn's assistant blocks in the window
|
|
6614
|
+
// even though the live conversation never saw it — so the next compile
|
|
6615
|
+
// diverges from the live prefix (prompt-cache bust), the message misses
|
|
6616
|
+
// mid-turn injection, and the window falsely testifies the agent saw it
|
|
6617
|
+
// and moved on. Turn-alive (activeTurnTokens) is the guard, not stream-
|
|
6618
|
+
// alive: a turn exists from dequeue through hooks + compile before its
|
|
6619
|
+
// stream registers in activeStreams, and the 2026-07-31 Mythos incident
|
|
6620
|
+
// was precisely a deferred flush landing inside that compile window.
|
|
6621
|
+
// (activeStreams is kept as a belt-and-suspenders backstop.) Deferred
|
|
6622
|
+
// messages flush at the next turn's START (before its compile — riding
|
|
6623
|
+
// their own wake), at the agent's next tool boundary (where they are
|
|
6624
|
+
// ALSO injected into the live stream — hear-while-acting), or in
|
|
6625
|
+
// driveStream's finally when the turn ends.
|
|
5437
6626
|
const hasToolResult = content.some(b => b.type === 'tool_result');
|
|
5438
|
-
if (!hasToolResult &&
|
|
6627
|
+
if (!hasToolResult &&
|
|
6628
|
+
(this.pendingAssistantBlocks.size > 0 ||
|
|
6629
|
+
this.activeTurnTokens.has(agent.name) ||
|
|
6630
|
+
this.activeStreams.has(agent.name))) {
|
|
5439
6631
|
this.deferredMessages.push({ participant, content, metadata });
|
|
5440
6632
|
return ''; // Deferred — flushed at the next boundary
|
|
5441
6633
|
}
|
|
@@ -5841,7 +7033,6 @@ export class AgentFramework {
|
|
|
5841
7033
|
async initializeMcpl(serverConfigs, inferenceRouting) {
|
|
5842
7034
|
this.mcplServerRegistry = new McplServerRegistry();
|
|
5843
7035
|
this.featureSetManager = new FeatureSetManager();
|
|
5844
|
-
this.scopeManager = new ScopeManager();
|
|
5845
7036
|
this.hookOrchestrator = new HookOrchestrator(this.mcplServerRegistry, this.featureSetManager);
|
|
5846
7037
|
// Build prefix map and store configs for tool routing
|
|
5847
7038
|
for (const config of serverConfigs) {
|
|
@@ -5927,12 +7118,32 @@ export class AgentFramework {
|
|
|
5927
7118
|
});
|
|
5928
7119
|
// Host capabilities advertised during the MCP handshake — stored so
|
|
5929
7120
|
// servers can also be connected later at runtime (connectMcplServer).
|
|
7121
|
+
// §5.2: the host advertises what it actually implements. 0.5.0-draft:
|
|
7122
|
+
// afterInference is GONE (replaced by inference/lifecycle, §10.5), and
|
|
7123
|
+
// the previously under-advertised inferenceRequest + channels — both
|
|
7124
|
+
// implemented for years — are declared (AUDIT-001/issue #76 item 12).
|
|
5930
7125
|
this.mcplHostCapabilities = {
|
|
5931
|
-
version: '0.
|
|
7126
|
+
version: '0.5',
|
|
5932
7127
|
pushEvents: true,
|
|
5933
7128
|
contextHooks: {
|
|
5934
7129
|
beforeInference: true,
|
|
5935
|
-
|
|
7130
|
+
},
|
|
7131
|
+
inferenceLifecycle: true,
|
|
7132
|
+
// modelInfo is NOT advertised: §12.2's result requires all four of
|
|
7133
|
+
// {id, vendor, contextWindow, capabilities} and the host has no
|
|
7134
|
+
// truthful source for contextWindow/capabilities (grep: none exists).
|
|
7135
|
+
// Advertising a capability answered with a malformed partial response
|
|
7136
|
+
// is worse than honest unsupported (Sol, PR #79 re-review). The
|
|
7137
|
+
// handler below answers an explicit error rather than hanging (§6.6).
|
|
7138
|
+
inferenceRequest: { streaming: true },
|
|
7139
|
+
channels: {
|
|
7140
|
+
register: true,
|
|
7141
|
+
lifecycle: true,
|
|
7142
|
+
publish: true,
|
|
7143
|
+
incoming: true,
|
|
7144
|
+
streaming: true,
|
|
7145
|
+
acknowledge: true,
|
|
7146
|
+
typing: true,
|
|
5936
7147
|
},
|
|
5937
7148
|
featureSets: true,
|
|
5938
7149
|
};
|
|
@@ -6265,27 +7476,210 @@ export class AgentFramework {
|
|
|
6265
7476
|
// Wire listeners before either startup staging or the runtime global gate
|
|
6266
7477
|
// releases control traffic needed for registration and marker service.
|
|
6267
7478
|
this.wireMcplEvents(connection);
|
|
6268
|
-
// Initialize feature sets if server advertises MCPL capabilities
|
|
6269
|
-
this.registerMcplServerFeatures(config, connection);
|
|
6270
7479
|
if (deferAwareness) {
|
|
7480
|
+
// Staged startup: both planes are closed, so the §5.3 policy
|
|
7481
|
+
// round-trip cannot let any buffered traffic slip — establish the
|
|
7482
|
+
// grant now, before staging ever releases.
|
|
7483
|
+
await this.registerMcplServerFeatures(config, connection);
|
|
6271
7484
|
this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
|
|
6272
7485
|
return;
|
|
6273
7486
|
}
|
|
6274
7487
|
const awarenessBarrier = this.installMcplDataPlaneGate();
|
|
6275
|
-
|
|
7488
|
+
// NO early release: both planes stay closed through accounting AND the
|
|
7489
|
+
// §5.3 policy round-trip below (PR #79 review blocker 3 — the early
|
|
7490
|
+
// control flush released channels-register/changed before any grant
|
|
7491
|
+
// existed). The awareness drain rides tools/call RESPONSES, which are
|
|
7492
|
+
// never event-buffered, so it needs no plane release to make progress.
|
|
6276
7493
|
try {
|
|
6277
7494
|
await awarenessBarrier.promise;
|
|
6278
|
-
this.completeMcplDataPlaneGate(awarenessBarrier);
|
|
6279
7495
|
}
|
|
6280
7496
|
catch (error) {
|
|
6281
7497
|
// Startup cannot fail open on a broken awareness ledger. Disable the
|
|
6282
7498
|
// reconnecting stub/connection before propagating the distinct error to
|
|
6283
7499
|
// initializeMcpl, which tears down any other servers and aborts create().
|
|
7500
|
+
// Ordering matters: awareness accounting settles BEFORE the §5.3
|
|
7501
|
+
// policy round-trip below, so an accounting failure aborts with the
|
|
7502
|
+
// grant still empty and every buffered event still behind the gate —
|
|
7503
|
+
// a failed startup never reports a released data plane. (The reverse
|
|
7504
|
+
// order let a push arrive during the policy await and get flushed by
|
|
7505
|
+
// teardown.)
|
|
6284
7506
|
await connection.close().catch(() => { });
|
|
6285
7507
|
throw error;
|
|
6286
7508
|
}
|
|
7509
|
+
// §5.3 initial policy handshake — after accounting, before the gate
|
|
7510
|
+
// completes: the grant is established (or knowingly left empty) before
|
|
7511
|
+
// ANY buffered traffic (control or data) flows. complete() then
|
|
7512
|
+
// ready()s both planes through the admission gate.
|
|
7513
|
+
await this.registerMcplServerFeatures(config, connection);
|
|
7514
|
+
this.completeMcplDataPlaneGate(awarenessBarrier);
|
|
6287
7515
|
this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
|
|
6288
7516
|
}
|
|
7517
|
+
/** Per-connection §17.8 fetch limiter: at most one in-flight fetch, at
|
|
7518
|
+
* most one queued behind it (announcements coalesce), floor between
|
|
7519
|
+
* completed fetches. Exceeding the floor drops the excess with a
|
|
7520
|
+
* diagnostic — the host bounds the rate; it never depends on server
|
|
7521
|
+
* coalescing. */
|
|
7522
|
+
manifestRefreshState = new Map();
|
|
7523
|
+
static MANIFEST_FETCH_FLOOR_MS = 5_000;
|
|
7524
|
+
/**
|
|
7525
|
+
* §17.5 host processing: fetch the complete manifest, validate exactly as
|
|
7526
|
+
* at initialize, diff, apply §6.7 consequences (removals eagerly even
|
|
7527
|
+
* when other declarations are invalid; additions only through
|
|
7528
|
+
* tell→receipt→activate), emit ONE change receipt (§17.6).
|
|
7529
|
+
*/
|
|
7530
|
+
async handleManifestChanged(connection, announce) {
|
|
7531
|
+
const st = this.manifestRefreshState.get(connection.id)
|
|
7532
|
+
?? { inFlight: false, queued: false, lastCompletedAt: 0 };
|
|
7533
|
+
this.manifestRefreshState.set(connection.id, st);
|
|
7534
|
+
if (st.inFlight) {
|
|
7535
|
+
// Coalesce: one queued refresh re-runs after the current fetch — the
|
|
7536
|
+
// fetch is always of the CURRENT manifest, so N announcements need at
|
|
7537
|
+
// most one trailing fetch (§17.8).
|
|
7538
|
+
st.queued = true;
|
|
7539
|
+
return;
|
|
7540
|
+
}
|
|
7541
|
+
const sinceLast = Date.now() - st.lastCompletedAt;
|
|
7542
|
+
if (sinceLast < AgentFramework.MANIFEST_FETCH_FLOOR_MS) {
|
|
7543
|
+
if (!st.queued) {
|
|
7544
|
+
st.queued = true;
|
|
7545
|
+
setTimeout(() => {
|
|
7546
|
+
const cur = this.manifestRefreshState.get(connection.id);
|
|
7547
|
+
if (cur?.queued && !cur.inFlight) {
|
|
7548
|
+
cur.queued = false;
|
|
7549
|
+
void this.handleManifestChanged(connection, announce).catch(() => { });
|
|
7550
|
+
}
|
|
7551
|
+
}, AgentFramework.MANIFEST_FETCH_FLOOR_MS - sinceLast).unref?.();
|
|
7552
|
+
}
|
|
7553
|
+
return;
|
|
7554
|
+
}
|
|
7555
|
+
st.inFlight = true;
|
|
7556
|
+
try {
|
|
7557
|
+
const config = this.mcplServerConfigs.get(connection.id);
|
|
7558
|
+
if (!config)
|
|
7559
|
+
return;
|
|
7560
|
+
// §17.4 fetch. A server that cannot answer keeps its previous
|
|
7561
|
+
// manifest in force — an unparseable/failed fetch teaches nothing and
|
|
7562
|
+
// narrows nothing (§17.5 step 1).
|
|
7563
|
+
let fetched;
|
|
7564
|
+
try {
|
|
7565
|
+
fetched = await connection.sendManifestRequest();
|
|
7566
|
+
}
|
|
7567
|
+
catch (err) {
|
|
7568
|
+
console.error(`[mcpl] ${connection.id}: mcpl/manifest fetch failed — previous manifest stands (§17.5): ${err instanceof Error ? err.message : err}`);
|
|
7569
|
+
return;
|
|
7570
|
+
}
|
|
7571
|
+
if (!fetched || typeof fetched !== 'object') {
|
|
7572
|
+
console.error(`[mcpl] ${connection.id}: mcpl/manifest returned no object — previous manifest stands (§17.5)`);
|
|
7573
|
+
return;
|
|
7574
|
+
}
|
|
7575
|
+
// Validate exactly as at initialize: expand §5.1 shorthand, apply the
|
|
7576
|
+
// config mask, recompute the grant. Unknown names mint nothing;
|
|
7577
|
+
// invalid declarations fail closed in derivation — and REMOVALS still
|
|
7578
|
+
// apply, because the new grant/derivation simply lacks whatever no
|
|
7579
|
+
// longer validates (§17.5 step 1's partial-invalid rule falls out of
|
|
7580
|
+
// computing forward from the fetched content).
|
|
7581
|
+
const expanded = expandAdvertisementShorthand(fetched);
|
|
7582
|
+
const { capabilities: masked, dropped } = maskNegotiatedCapabilities(expanded, config);
|
|
7583
|
+
const newGrant = computeGrant(masked, config, { mcpToolsAdvertised: connection.mcpToolsAdvertised });
|
|
7584
|
+
const oldEffective = new Set(connection.grant.effectiveList());
|
|
7585
|
+
const newEffective = new Set(newGrant.effectiveList());
|
|
7586
|
+
const revoked = [...oldEffective].filter((p) => !newEffective.has(p)).sort();
|
|
7587
|
+
const added = [...newEffective].filter((p) => !oldEffective.has(p)).sort();
|
|
7588
|
+
const oldDeclared = Object.keys(this.featureSetManager?.getDeclaredFeatureSets(connection.id) ?? {});
|
|
7589
|
+
const oldEnabled = oldDeclared.filter((n) => this.featureSetManager?.isEnabled(connection.id, n));
|
|
7590
|
+
// §6.7 REDUCTION FIRST, atomically, before the server is told:
|
|
7591
|
+
// security cannot wait on consent. The interim grant is new∩old — no
|
|
7592
|
+
// revoked path survives, no added path activates yet.
|
|
7593
|
+
const hadReduction = revoked.length > 0;
|
|
7594
|
+
if (hadReduction) {
|
|
7595
|
+
const interim = new CapabilityGrant(new Set([...newEffective].filter((p) => oldEffective.has(p))), [...newGrant.deniedPaths]);
|
|
7596
|
+
connection.establishGrant(interim);
|
|
7597
|
+
}
|
|
7598
|
+
// PHASE 1 — pre-receipt: live feature state derives from ACTIVE
|
|
7599
|
+
// authority (the interim grant when reduced, the standing grant
|
|
7600
|
+
// otherwise), never from the unactivated full grant. Feature state
|
|
7601
|
+
// must obey the same §6.7 ordering the grant does: a newly added
|
|
7602
|
+
// capability-backed feature set cannot be host-enabled while its
|
|
7603
|
+
// capability is still absent from the active grant (Sol, PR #84
|
|
7604
|
+
// review). Declarations come from the fetched manifest either way —
|
|
7605
|
+
// a set absent from the new manifest is gone regardless.
|
|
7606
|
+
const cfg = { enabledFeatureSets: config.enabledFeatureSets, disabledFeatureSets: config.disabledFeatureSets };
|
|
7607
|
+
const maskedOrEmpty = masked ?? { version: '0.5' };
|
|
7608
|
+
this.featureSetManager.initializeServer(connection.id, maskedOrEmpty, cfg, connection.grant);
|
|
7609
|
+
// The Request DESCRIBES the proposed new policy — derived from the
|
|
7610
|
+
// full new grant on a scratch manager, installing nothing.
|
|
7611
|
+
const updateParams = new FeatureSetManager().initializeServer(connection.id, maskedOrEmpty, cfg, newGrant);
|
|
7612
|
+
updateParams.effectiveCapabilities = newGrant.effectiveList();
|
|
7613
|
+
if (newGrant.deniedPaths.length > 0)
|
|
7614
|
+
updateParams.deniedCapabilities = [...newGrant.deniedPaths];
|
|
7615
|
+
// Tell → receipt → activate (§6.7). PHASE 2 by outcome:
|
|
7616
|
+
// accepted → full grant + full-derived feature state;
|
|
7617
|
+
// refused → honor fallback: 'close' closes the transport;
|
|
7618
|
+
// 'mcp-only' (or absent) = empty grant + empty-derived
|
|
7619
|
+
// feature state, mirroring the §5.3 initial path;
|
|
7620
|
+
// unanswered → interim grant and interim-derived state stand.
|
|
7621
|
+
let negotiated = false;
|
|
7622
|
+
try {
|
|
7623
|
+
const receipt = await connection.sendFeatureSetsUpdateRequest(updateParams);
|
|
7624
|
+
if (receipt && receipt.accepted === false) {
|
|
7625
|
+
const fallback = receipt.fallback ?? 'mcp-only';
|
|
7626
|
+
console.error(`[mcpl] ${connection.id} refused post-manifest policy — fallback: ${fallback}`);
|
|
7627
|
+
if (fallback === 'close') {
|
|
7628
|
+
await connection.close();
|
|
7629
|
+
return;
|
|
7630
|
+
}
|
|
7631
|
+
connection.establishGrant(CapabilityGrant.empty());
|
|
7632
|
+
this.featureSetManager.initializeServer(connection.id, maskedOrEmpty, cfg, CapabilityGrant.empty());
|
|
7633
|
+
}
|
|
7634
|
+
else {
|
|
7635
|
+
connection.establishGrant(newGrant);
|
|
7636
|
+
this.featureSetManager.initializeServer(connection.id, maskedOrEmpty, cfg, newGrant);
|
|
7637
|
+
connection.manifestState.lastNegotiatedAt = Date.now();
|
|
7638
|
+
negotiated = true;
|
|
7639
|
+
}
|
|
7640
|
+
}
|
|
7641
|
+
catch {
|
|
7642
|
+
console.error(`[mcpl] ${connection.id} did not answer post-manifest policy — expansion does not activate (§6.7); interim state stands`);
|
|
7643
|
+
}
|
|
7644
|
+
// Adopt the validated manifest + tracking. droppedCapabilities is
|
|
7645
|
+
// REPLACED unconditionally — an empty new mask must clear stale paths
|
|
7646
|
+
// (Sol, PR #84 review).
|
|
7647
|
+
connection.capabilities = masked;
|
|
7648
|
+
connection.manifestState.lastValidatedRevision =
|
|
7649
|
+
typeof fetched.revision === 'string'
|
|
7650
|
+
? fetched.revision : null;
|
|
7651
|
+
connection.manifestState.lastFetchedAt = Date.now();
|
|
7652
|
+
connection.droppedCapabilities = new Set(dropped);
|
|
7653
|
+
const newDeclared = Object.keys(this.featureSetManager?.getDeclaredFeatureSets(connection.id) ?? {});
|
|
7654
|
+
const newEnabled = newDeclared.filter((n) => this.featureSetManager?.isEnabled(connection.id, n));
|
|
7655
|
+
const degraded = oldEnabled.filter((n) => !newEnabled.includes(n)).sort();
|
|
7656
|
+
const restored = newEnabled.filter((n) => !oldEnabled.includes(n)).sort();
|
|
7657
|
+
// §17.6: ONE receipt per manifest change, closed host-derived impact
|
|
7658
|
+
// vocabulary, never a server-authored flag.
|
|
7659
|
+
const impacts = [
|
|
7660
|
+
...revoked.map((p) => ({ impact: 'capability-revoked', subject: p, disposition: 'applied' })),
|
|
7661
|
+
...added.map((p) => ({ impact: 'capability-expansion-pending', subject: p, disposition: negotiated ? 'applied' : 'decision-needed' })),
|
|
7662
|
+
...degraded.map((n) => ({ impact: 'feature-degraded', subject: n, disposition: 'applied' })),
|
|
7663
|
+
...restored.map((n) => ({ impact: 'feature-restored', subject: n, disposition: negotiated ? 'applied' : 'decision-needed' })),
|
|
7664
|
+
];
|
|
7665
|
+
this.emitTrace({
|
|
7666
|
+
type: 'mcpl:manifest-change-receipt',
|
|
7667
|
+
serverId: connection.id,
|
|
7668
|
+
revision: connection.manifestState.lastValidatedRevision,
|
|
7669
|
+
announcedDomains: announce.domains ?? [],
|
|
7670
|
+
impacts,
|
|
7671
|
+
});
|
|
7672
|
+
this.handleToolsListChanged(connection.id);
|
|
7673
|
+
}
|
|
7674
|
+
finally {
|
|
7675
|
+
st.inFlight = false;
|
|
7676
|
+
st.lastCompletedAt = Date.now();
|
|
7677
|
+
if (st.queued) {
|
|
7678
|
+
st.queued = false;
|
|
7679
|
+
setTimeout(() => { void this.handleManifestChanged(connection, announce).catch(() => { }); }, AgentFramework.MANIFEST_FETCH_FLOOR_MS).unref?.();
|
|
7680
|
+
}
|
|
7681
|
+
}
|
|
7682
|
+
}
|
|
6289
7683
|
/**
|
|
6290
7684
|
* (Re-)establish a server's host-side MCPL registration: feature-set state
|
|
6291
7685
|
* (plus the featureSets/update sent to the server), scope patterns, and
|
|
@@ -6301,20 +7695,62 @@ export class AgentFramework {
|
|
|
6301
7695
|
* already has a tree), so checkpoint trees preserved across a transient
|
|
6302
7696
|
* disconnect are resumed, not reset.
|
|
6303
7697
|
*/
|
|
6304
|
-
registerMcplServerFeatures(config, connection) {
|
|
7698
|
+
async registerMcplServerFeatures(config, connection) {
|
|
6305
7699
|
if (!connection.capabilities)
|
|
6306
7700
|
return;
|
|
7701
|
+
// §5.4: compute the effective grant from the (config-masked)
|
|
7702
|
+
// advertisement. Not yet active — expansion activates only after the
|
|
7703
|
+
// receipt (§6.7); until then the connection's grant is empty and every
|
|
7704
|
+
// privileged inbound method is rejected.
|
|
7705
|
+
const grant = computeGrant(connection.capabilities, config, { mcpToolsAdvertised: connection.mcpToolsAdvertised });
|
|
6307
7706
|
const updateParams = this.featureSetManager.initializeServer(config.id, connection.capabilities, {
|
|
6308
7707
|
enabledFeatureSets: config.enabledFeatureSets,
|
|
6309
7708
|
disabledFeatureSets: config.disabledFeatureSets,
|
|
6310
|
-
});
|
|
6311
|
-
//
|
|
6312
|
-
|
|
6313
|
-
|
|
7709
|
+
}, grant);
|
|
7710
|
+
// §5.3: initial policy MUST be a Request, MUST precede first fan-out,
|
|
7711
|
+
// and MUST be sent even when nothing is enabled or disabled — a server
|
|
7712
|
+
// defaulted to fully disabled has to be told. The response is a
|
|
7713
|
+
// degradation receipt (§6.7): consequence testimony, never policy
|
|
7714
|
+
// authority — nothing in it may widen the grant.
|
|
7715
|
+
updateParams.effectiveCapabilities = grant.effectiveList();
|
|
7716
|
+
if (grant.deniedPaths.length > 0) {
|
|
7717
|
+
updateParams.deniedCapabilities = [...grant.deniedPaths];
|
|
7718
|
+
}
|
|
7719
|
+
try {
|
|
7720
|
+
const receipt = await connection.sendFeatureSetsUpdateRequest(updateParams);
|
|
7721
|
+
if (receipt && receipt.accepted === false) {
|
|
7722
|
+
const fallback = receipt.fallback ?? 'mcp-only';
|
|
7723
|
+
console.error(`[mcpl] ${config.id} refused initial policy (${receipt.reason ?? 'no reason'}) — fallback: ${fallback}`);
|
|
7724
|
+
if (fallback === 'close') {
|
|
7725
|
+
await connection.close();
|
|
7726
|
+
return;
|
|
7727
|
+
}
|
|
7728
|
+
// mcp-only (§3.2): grant stays empty, MCPL privileged surface stays
|
|
7729
|
+
// dark, plain MCP tools keep working. MUST NOT widen in response to
|
|
7730
|
+
// the refusal — a human can change config; the policy engine cannot.
|
|
7731
|
+
return;
|
|
7732
|
+
}
|
|
7733
|
+
// Expansion activates on receipt (§6.7).
|
|
7734
|
+
connection.establishGrant(grant);
|
|
7735
|
+
if (receipt?.accepted === true && receipt.mode === 'degraded') {
|
|
7736
|
+
for (const uf of receipt.unavailableFeatures ?? []) {
|
|
7737
|
+
console.error(`[mcpl] ${config.id} degraded: ${uf.featureSet} (${uf.effect}; missing ${uf.missingCapabilities.join(', ')})`);
|
|
7738
|
+
}
|
|
7739
|
+
}
|
|
7740
|
+
}
|
|
7741
|
+
catch (err) {
|
|
7742
|
+
// A pre-0.5 server treats the Request as a notification and never
|
|
7743
|
+
// answers. §6.7: an unanswered expansion simply does not activate —
|
|
7744
|
+
// grant stays empty, connection is effectively MCP-only. This WILL
|
|
7745
|
+
// dark every un-migrated MCPL server's privileged surface; accepted
|
|
7746
|
+
// deliberately for the single-release rollout (issue #76 item 9).
|
|
7747
|
+
console.error(`[mcpl] ${config.id} did not answer the initial policy Request — ` +
|
|
7748
|
+
`MCPL privileged surface stays disabled (§6.7 unanswered expansion): ` +
|
|
7749
|
+
(err instanceof Error ? err.message : String(err)));
|
|
7750
|
+
return;
|
|
6314
7751
|
}
|
|
6315
|
-
// Configure scope whitelist/blacklist patterns
|
|
6316
7752
|
if (config.scopes) {
|
|
6317
|
-
|
|
7753
|
+
console.error(`[mcpl] ${config.id}: config \`scopes\` is ignored — §7 Scoped Access removed in 0.5.0`);
|
|
6318
7754
|
}
|
|
6319
7755
|
// Register stateful feature sets with checkpoint manager (Step 8)
|
|
6320
7756
|
if (this.checkpointManager) {
|
|
@@ -6411,11 +7847,21 @@ export class AgentFramework {
|
|
|
6411
7847
|
for (const [id, config] of this.mcplServerConfigs) {
|
|
6412
7848
|
const prefix = config.toolPrefix ?? `mcpl--${id}`;
|
|
6413
7849
|
const connection = this.mcplServerRegistry?.getServer(id) ?? null;
|
|
7850
|
+
const connected = connection?.isConnected ?? false;
|
|
6414
7851
|
result.push({
|
|
6415
7852
|
id,
|
|
6416
|
-
connected
|
|
7853
|
+
connected,
|
|
7854
|
+
retrying: !connected && (connection?.willReconnect ?? false),
|
|
6417
7855
|
toolPrefix: prefix,
|
|
6418
7856
|
toolCount: this.mcplTools.filter(t => t.name.startsWith(`${prefix}--`)).length,
|
|
7857
|
+
policyEstablished: connection?.policyEstablished ?? false,
|
|
7858
|
+
effectiveGrant: connection?.grant.effectiveList() ?? [],
|
|
7859
|
+
maskedCapabilities: [...(connection?.droppedCapabilities ?? [])].sort(),
|
|
7860
|
+
deniedCapabilities: [...(connection?.grant.deniedPaths ?? [])].sort(),
|
|
7861
|
+
allowHostCommands: config.allowHostCommands === true,
|
|
7862
|
+
manifestState: connection
|
|
7863
|
+
? { ...connection.manifestState }
|
|
7864
|
+
: { lastValidatedRevision: null, lastFetchedAt: null, lastNegotiatedAt: null },
|
|
6419
7865
|
command: config.command,
|
|
6420
7866
|
url: config.url,
|
|
6421
7867
|
});
|
|
@@ -6432,23 +7878,14 @@ export class AgentFramework {
|
|
|
6432
7878
|
connection.on('stderr', (params) => {
|
|
6433
7879
|
this.emitTrace({ type: 'mcpl:server-stderr', serverId: connection.id, line: params.line });
|
|
6434
7880
|
});
|
|
6435
|
-
//
|
|
6436
|
-
|
|
6437
|
-
|
|
6438
|
-
|
|
7881
|
+
// featureSets/changed is REMOVED in 0.5.0 — the connection answers it
|
|
7882
|
+
// -32601 (superseded by SPEC §17's host-diffed manifest fetch); no
|
|
7883
|
+
// listener, and nothing mutates declarations off a server-authored
|
|
7884
|
+
// change payload.
|
|
6439
7885
|
// Handle scope elevation requests
|
|
6440
|
-
|
|
6441
|
-
|
|
6442
|
-
|
|
6443
|
-
const result = await this.scopeManager.handleElevation(params);
|
|
6444
|
-
responder.respond(result);
|
|
6445
|
-
}
|
|
6446
|
-
catch (error) {
|
|
6447
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
6448
|
-
responder.respondError(-32603, err.message);
|
|
6449
|
-
}
|
|
6450
|
-
}
|
|
6451
|
-
});
|
|
7886
|
+
// §7 Scoped Access is removed in 0.5.0. scope/elevate is answered
|
|
7887
|
+
// -32601 at the connection (server-connection.ts) and never reaches the
|
|
7888
|
+
// framework; the ScopeManager and config `scopes` wiring went with it.
|
|
6452
7889
|
// Handle push events (Step 6)
|
|
6453
7890
|
connection.on('push-event', async (params, responder) => {
|
|
6454
7891
|
const barrier = this.discordAwarenessBarrier;
|
|
@@ -6473,9 +7910,36 @@ export class AgentFramework {
|
|
|
6473
7910
|
connection.on('channels-register', async (params, responder) => {
|
|
6474
7911
|
await this.channelRegistry?.handleRegister(connection.id, params, responder);
|
|
6475
7912
|
});
|
|
6476
|
-
// Handle channel changes (Step 7)
|
|
6477
|
-
|
|
6478
|
-
|
|
7913
|
+
// Handle channel changes (Step 7) — §14.5 dual-mode: the responder is
|
|
7914
|
+
// present for the Request form and carries itemized added-descriptor
|
|
7915
|
+
// results; absent for Notifications, where rejection is itemwise
|
|
7916
|
+
// filtering plus a diagnostic trace.
|
|
7917
|
+
connection.on('channels-changed', async (params, responder) => {
|
|
7918
|
+
await this.channelRegistry?.handleChanged(connection.id, params, responder);
|
|
7919
|
+
});
|
|
7920
|
+
// §6.6/§12: requests that previously had no handler and hung forever
|
|
7921
|
+
// (AUDIT-001 item 4). The connection-level grant gate has already run.
|
|
7922
|
+
// §17.3: a manifest-change announcement. Rate-limited re-fetch → diff
|
|
7923
|
+
// → §6.7 consequences → one receipt. The announcement itself carries no
|
|
7924
|
+
// payload and no authority; everything below acts on FETCHED content.
|
|
7925
|
+
connection.on('manifest-changed', (params) => {
|
|
7926
|
+
void this.handleManifestChanged(connection, params).catch((err) => {
|
|
7927
|
+
console.error(`[mcpl] ${connection.id} manifest refresh failed:`, err instanceof Error ? err.message : err);
|
|
7928
|
+
});
|
|
7929
|
+
});
|
|
7930
|
+
connection.on('channels-list', (_params, responder) => {
|
|
7931
|
+
responder?.respond({
|
|
7932
|
+
channels: this.channelRegistry?.descriptorsForServer(connection.id) ?? [],
|
|
7933
|
+
});
|
|
7934
|
+
});
|
|
7935
|
+
connection.on('model-info', (_params, responder) => {
|
|
7936
|
+
// §12.2 requires ALL FOUR of {id, vendor, contextWindow, capabilities}
|
|
7937
|
+
// in the DIRECT result shape, and numeric contextWindow cannot express
|
|
7938
|
+
// "unknown". The host has no truthful source for it, so modelInfo is
|
|
7939
|
+
// not advertised and this answers an explicit error instead of a
|
|
7940
|
+
// malformed partial (§6.6: a method that will never be answered
|
|
7941
|
+
// truthfully MUST error, not fabricate).
|
|
7942
|
+
responder?.respondError?.(-32601, 'model/info unavailable: host does not advertise modelInfo (no truthful contextWindow source)');
|
|
6479
7943
|
});
|
|
6480
7944
|
// Handle incoming channel messages (Step 7)
|
|
6481
7945
|
connection.on('channels-incoming', async (params, responder) => {
|
|
@@ -6524,17 +7988,23 @@ export class AgentFramework {
|
|
|
6524
7988
|
// reconnect observes it before doing work that could wake an agent. The
|
|
6525
7989
|
// connection paused its data plane before wiring the fresh transport.
|
|
6526
7990
|
const awarenessBarrier = this.installMcplDataPlaneGate();
|
|
6527
|
-
|
|
6528
|
-
|
|
6529
|
-
|
|
6530
|
-
|
|
7991
|
+
// Async: the §5.3 policy Request must be answered (re-establishing the
|
|
7992
|
+
// grant) before the data-plane barrier releases — otherwise the first
|
|
7993
|
+
// post-reconnect events land while the grant is still empty and are
|
|
7994
|
+
// rejected fail-closed instead of delivered.
|
|
7995
|
+
void (async () => {
|
|
7996
|
+
try {
|
|
7997
|
+
const config = this.mcplServerConfigs.get(connection.id);
|
|
7998
|
+
if (config) {
|
|
7999
|
+
await this.registerMcplServerFeatures(config, connection);
|
|
8000
|
+
}
|
|
6531
8001
|
}
|
|
6532
|
-
|
|
6533
|
-
|
|
6534
|
-
|
|
6535
|
-
|
|
6536
|
-
|
|
6537
|
-
|
|
8002
|
+
catch (error) {
|
|
8003
|
+
console.error(`MCPL server "${connection.id}" re-registration after reconnect failed:`, error instanceof Error ? error.message : error);
|
|
8004
|
+
}
|
|
8005
|
+
this.releaseMcplDataPlaneGate(awarenessBarrier);
|
|
8006
|
+
this.handleToolsListChanged(connection.id);
|
|
8007
|
+
})();
|
|
6538
8008
|
void awarenessBarrier.promise.then(() => {
|
|
6539
8009
|
if (awarenessBarrier.requiresBarrier
|
|
6540
8010
|
&& !this.completeMcplDataPlaneGate(awarenessBarrier))
|
|
@@ -6630,6 +8100,7 @@ export class AgentFramework {
|
|
|
6630
8100
|
if (!this.mcplServerRegistry)
|
|
6631
8101
|
return;
|
|
6632
8102
|
const tools = [];
|
|
8103
|
+
const toolFeatureSets = new Map();
|
|
6633
8104
|
for (const server of this.mcplServerRegistry.getAllServers()) {
|
|
6634
8105
|
const config = this.mcplServerConfigs.get(server.id);
|
|
6635
8106
|
const prefix = config?.toolPrefix ?? `mcpl--${server.id}`;
|
|
@@ -6638,13 +8109,23 @@ export class AgentFramework {
|
|
|
6638
8109
|
for (const tool of result.tools) {
|
|
6639
8110
|
if (!isToolAllowed(tool.name, config))
|
|
6640
8111
|
continue;
|
|
8112
|
+
const namespacedName = `${prefix}--${tool.name}`;
|
|
8113
|
+
const attributedTool = tool;
|
|
8114
|
+
const featureSet = typeof attributedTool.featureSet === 'string'
|
|
8115
|
+
? attributedTool.featureSet
|
|
8116
|
+
: typeof attributedTool._meta?.featureSet === 'string'
|
|
8117
|
+
? attributedTool._meta.featureSet
|
|
8118
|
+
: undefined;
|
|
6641
8119
|
// MCP tool schemas are generic JSON Schema; cast to membrane's ToolDefinition format
|
|
6642
8120
|
const schema = tool.inputSchema;
|
|
6643
8121
|
tools.push({
|
|
6644
|
-
name:
|
|
8122
|
+
name: namespacedName,
|
|
6645
8123
|
description: tool.description ?? '',
|
|
6646
8124
|
inputSchema: schema,
|
|
6647
8125
|
});
|
|
8126
|
+
if (featureSet !== undefined) {
|
|
8127
|
+
toolFeatureSets.set(namespacedName, featureSet);
|
|
8128
|
+
}
|
|
6648
8129
|
}
|
|
6649
8130
|
}
|
|
6650
8131
|
catch {
|
|
@@ -6652,6 +8133,7 @@ export class AgentFramework {
|
|
|
6652
8133
|
}
|
|
6653
8134
|
}
|
|
6654
8135
|
this.mcplTools = tools;
|
|
8136
|
+
this.mcplToolFeatureSets = toolFeatureSets;
|
|
6655
8137
|
}
|
|
6656
8138
|
/**
|
|
6657
8139
|
* Handle a tools/list_changed notification with collapse logic.
|
|
@@ -6738,21 +8220,41 @@ export class AgentFramework {
|
|
|
6738
8220
|
const startTime = Date.now();
|
|
6739
8221
|
const args = (call.input && typeof call.input === 'object') ? call.input : {};
|
|
6740
8222
|
// Build state params for stateful tools (Step 8).
|
|
6741
|
-
//
|
|
6742
|
-
//
|
|
6743
|
-
//
|
|
6744
|
-
// mixes host-managed (e.g. a feed) and server-managed (e.g. post/undo) sets,
|
|
6745
|
-
// that misattributes the feed's state.
|
|
8223
|
+
// A tools/list attribution selects that exact stateful set. Untagged tools
|
|
8224
|
+
// retain the host-managed or unique server-managed fallback, but never guess
|
|
8225
|
+
// between multiple server-managed sets.
|
|
6746
8226
|
let stateParams;
|
|
8227
|
+
let attributedFeatureSet = null;
|
|
6747
8228
|
if (this.checkpointManager) {
|
|
6748
|
-
const
|
|
6749
|
-
if (
|
|
6750
|
-
|
|
8229
|
+
const toolFeatureSet = this.mcplToolFeatureSets.get(call.name);
|
|
8230
|
+
if (toolFeatureSet !== undefined) {
|
|
8231
|
+
if (this.checkpointManager.isHostManaged(serverId, toolFeatureSet)
|
|
8232
|
+
|| this.checkpointManager.getStatefulFeatureSet(serverId, toolFeatureSet) !== null) {
|
|
8233
|
+
attributedFeatureSet = toolFeatureSet;
|
|
8234
|
+
}
|
|
8235
|
+
else {
|
|
8236
|
+
console.warn(`[mcpl] Tool "${call.name}" declares unknown or non-stateful feature set ` +
|
|
8237
|
+
`"${toolFeatureSet}"; skipping state exchange.`);
|
|
8238
|
+
}
|
|
6751
8239
|
}
|
|
6752
8240
|
else {
|
|
6753
|
-
|
|
6754
|
-
|
|
6755
|
-
|
|
8241
|
+
attributedFeatureSet = this.checkpointManager.getHostManagedFeatureSet(serverId)
|
|
8242
|
+
?? this.checkpointManager.getStatefulFeatureSet(serverId);
|
|
8243
|
+
if (attributedFeatureSet === null
|
|
8244
|
+
&& this.checkpointManager.hasAmbiguousServerManagedFeatureSets(serverId)) {
|
|
8245
|
+
console.warn(`[mcpl] Tool "${call.name}" has no feature-set attribution and server ` +
|
|
8246
|
+
`"${serverId}" has multiple server-managed stateful feature sets; ` +
|
|
8247
|
+
'skipping state exchange.');
|
|
8248
|
+
}
|
|
8249
|
+
}
|
|
8250
|
+
if (attributedFeatureSet !== null) {
|
|
8251
|
+
if (this.checkpointManager.isHostManaged(serverId, attributedFeatureSet)) {
|
|
8252
|
+
stateParams = {
|
|
8253
|
+
state: this.checkpointManager.getCurrentState(serverId, attributedFeatureSet),
|
|
8254
|
+
};
|
|
8255
|
+
}
|
|
8256
|
+
else {
|
|
8257
|
+
const cp = this.checkpointManager.getCurrentCheckpoint(serverId, attributedFeatureSet);
|
|
6756
8258
|
if (cp)
|
|
6757
8259
|
stateParams = { checkpoint: cp };
|
|
6758
8260
|
}
|
|
@@ -6763,15 +8265,10 @@ export class AgentFramework {
|
|
|
6763
8265
|
const durationMs = Date.now() - startTime;
|
|
6764
8266
|
this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs });
|
|
6765
8267
|
// Record checkpoint from stateful tool response (Step 8).
|
|
6766
|
-
//
|
|
6767
|
-
//
|
|
6768
|
-
// else the single stateful set. Avoids recording a feed checkpoint onto a
|
|
6769
|
-
// server-managed set just because it registered first.
|
|
8268
|
+
// Explicit response attribution is authoritative. Otherwise use the
|
|
8269
|
+
// tool-derived (or safe single-set) attribution chosen before the call.
|
|
6770
8270
|
if (result.state && this.checkpointManager) {
|
|
6771
|
-
const
|
|
6772
|
-
const fs = tagged
|
|
6773
|
-
?? this.checkpointManager.getHostManagedFeatureSet(serverId)
|
|
6774
|
-
?? this.checkpointManager.getStatefulFeatureSet(serverId);
|
|
8271
|
+
const fs = result.state.featureSet ?? attributedFeatureSet;
|
|
6775
8272
|
if (fs && this.checkpointManager.isStateful(serverId, fs)) {
|
|
6776
8273
|
this.checkpointManager.recordCheckpoint(serverId, fs, result.state);
|
|
6777
8274
|
}
|
|
@@ -6792,6 +8289,23 @@ export class AgentFramework {
|
|
|
6792
8289
|
this.channelRegistry
|
|
6793
8290
|
.openIfClosedForSend(target, serverId)
|
|
6794
8291
|
.then(({ status, channelId, label }) => {
|
|
8292
|
+
// Turn-scoped engagement record (mid-turn re-pin, 2026-07-31
|
|
8293
|
+
// n=7): a human follow-up in a channel the agent explicitly
|
|
8294
|
+
// sent into this turn counts as conversationally addressed.
|
|
8295
|
+
// Recorded here — not eagerly — because injection metadata
|
|
8296
|
+
// carries the CANONICAL channel id (descriptor form), while
|
|
8297
|
+
// send args often carry the raw provider id; this callback
|
|
8298
|
+
// has the resolved form. The already-open path resolves on
|
|
8299
|
+
// the microtask queue, i.e. before the tool-result event
|
|
8300
|
+
// that evaluates injections.
|
|
8301
|
+
if (status !== 'unknown-channel' && status !== 'ambiguous') {
|
|
8302
|
+
let engaged = this.turnEngagedChannels.get(agentName);
|
|
8303
|
+
if (!engaged) {
|
|
8304
|
+
engaged = new Set();
|
|
8305
|
+
this.turnEngagedChannels.set(agentName, engaged);
|
|
8306
|
+
}
|
|
8307
|
+
engaged.add(channelId ?? target);
|
|
8308
|
+
}
|
|
6795
8309
|
if (status === 'opened') {
|
|
6796
8310
|
console.error(`[channel] ${agentName}: explicit ${toolName} into closed channel ${target} — opened (send implies engagement)`);
|
|
6797
8311
|
this.recordChannelAutoOpenNotice(agentName, [{ channelId: channelId ?? target, label }], 'opened-by-reply');
|
|
@@ -6889,22 +8403,42 @@ export class AgentFramework {
|
|
|
6889
8403
|
}
|
|
6890
8404
|
this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input });
|
|
6891
8405
|
const startTime = Date.now();
|
|
6892
|
-
this.channelRegistry.handleChannelToolCall(call.name, call.input)
|
|
8406
|
+
this.channelRegistry.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName })
|
|
6893
8407
|
.then((result) => {
|
|
6894
8408
|
const durationMs = Date.now() - startTime;
|
|
6895
8409
|
this.emitTrace({ type: 'tool:completed', module: 'channels', tool: call.name, callId: call.id, durationMs });
|
|
6896
8410
|
// A successful channel_open plants the agent's feet in the opened
|
|
6897
|
-
// channel
|
|
6898
|
-
//
|
|
6899
|
-
//
|
|
6900
|
-
//
|
|
6901
|
-
//
|
|
6902
|
-
//
|
|
8411
|
+
// channel. The agent's own deliberate open is the strongest "my next
|
|
8412
|
+
// words go here" signal in the system — stronger than any injection —
|
|
8413
|
+
// so it MOVES the current turn's prose pin (turnLocusPins), not just
|
|
8414
|
+
// the next turn's trigger state. (The original 2026-07-21 Aria fix
|
|
8415
|
+
// set only activeTriggerChannels, which resolveLocus reads at the
|
|
8416
|
+
// NEXT turn's freeze — the turn-frozen refactor had silently
|
|
8417
|
+
// regressed the "reply right after opening" case; found while
|
|
8418
|
+
// mapping the routing logic 2026-07-31, antra-ratified fix.) The
|
|
8419
|
+
// announcement rides THIS TOOL RESULT — model-requested content,
|
|
8420
|
+
// distance zero, the safest role there is — instead of a separate
|
|
8421
|
+
// window notice. lastAnnouncedLocus tracks it so announce-on-change
|
|
8422
|
+
// stays coherent. Locus-mode agents only: explicit mode has no pin.
|
|
6903
8423
|
if (call.name === 'channel_open' && result?.success) {
|
|
6904
8424
|
const opened = result.data?.channelId
|
|
6905
8425
|
?? call.input?.channelId;
|
|
6906
|
-
if (opened)
|
|
8426
|
+
if (opened) {
|
|
6907
8427
|
this.activeTriggerChannels.set(agentName, opened);
|
|
8428
|
+
const openerAgent = this.agents.get(agentName);
|
|
8429
|
+
if (openerAgent && openerAgent.proseRouting !== 'explicit') {
|
|
8430
|
+
this.turnLocusPins.set(agentName, opened);
|
|
8431
|
+
this.lastAnnouncedLocus.set(agentName, opened);
|
|
8432
|
+
result = {
|
|
8433
|
+
...result,
|
|
8434
|
+
data: {
|
|
8435
|
+
...result.data,
|
|
8436
|
+
routing: 'Your plain speech now lands in this channel. Other channels need an explicit send tool.',
|
|
8437
|
+
},
|
|
8438
|
+
};
|
|
8439
|
+
console.error(`[routing] ${agentName}: channel_open -> pin moved to ${opened} (announced in tool result)`);
|
|
8440
|
+
}
|
|
8441
|
+
}
|
|
6908
8442
|
}
|
|
6909
8443
|
this.pushEvent({
|
|
6910
8444
|
type: 'tool-result',
|
|
@@ -7102,6 +8636,31 @@ export class AgentFramework {
|
|
|
7102
8636
|
if (typeof input.path !== 'string' || input.path.length === 0) {
|
|
7103
8637
|
throw new Error('read_image: `path` (mount-prefixed) is required');
|
|
7104
8638
|
}
|
|
8639
|
+
// Anthropic rejects images >5MB (and >8000px); guard the hard byte
|
|
8640
|
+
// limit here — resizing is out of scope without an image library.
|
|
8641
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
8642
|
+
// Prefer the workspace module's own read_image handler when it exists:
|
|
8643
|
+
// it reads tree-first with a FILESYSTEM FALLBACK, so binaries that live
|
|
8644
|
+
// only on disk (shell-created files, pre-fix mangled tree blobs) still
|
|
8645
|
+
// render. The legacy tree-only path below stays for older workspace
|
|
8646
|
+
// modules that don't advertise the tool.
|
|
8647
|
+
if (typeof workspace.handleToolCall === 'function' &&
|
|
8648
|
+
typeof workspace.getTools === 'function' &&
|
|
8649
|
+
workspace.getTools().some((tool) => tool.name === 'read_image')) {
|
|
8650
|
+
const result = await workspace.handleToolCall({ ...call, name: 'read_image' });
|
|
8651
|
+
if (result.success && Array.isArray(result.data)) {
|
|
8652
|
+
const image = result.data.find((block) => typeof block === 'object' && block !== null &&
|
|
8653
|
+
block.type === 'image' &&
|
|
8654
|
+
typeof block.data === 'string');
|
|
8655
|
+
const byteLength = image ? Math.floor((image.data.length * 3) / 4) : 0;
|
|
8656
|
+
if (byteLength > MAX_IMAGE_BYTES) {
|
|
8657
|
+
throw new Error(`read_image: "${input.path}" is ~${byteLength} bytes — ` +
|
|
8658
|
+
`over the ${MAX_IMAGE_BYTES}-byte model limit`);
|
|
8659
|
+
}
|
|
8660
|
+
}
|
|
8661
|
+
finish(result);
|
|
8662
|
+
return;
|
|
8663
|
+
}
|
|
7105
8664
|
const read = await workspace.readBinary(input.path);
|
|
7106
8665
|
if ('error' in read)
|
|
7107
8666
|
throw new Error(read.error);
|
|
@@ -7110,9 +8669,6 @@ export class AgentFramework {
|
|
|
7110
8669
|
throw new Error(`read_image: "${input.path}" does not look like a supported image ` +
|
|
7111
8670
|
'(png/jpeg/gif/webp — checked by magic bytes, not extension)');
|
|
7112
8671
|
}
|
|
7113
|
-
// Anthropic rejects images >5MB (and >8000px); guard the hard byte
|
|
7114
|
-
// limit here — resizing is out of scope without an image library.
|
|
7115
|
-
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
7116
8672
|
if (read.data.byteLength > MAX_IMAGE_BYTES) {
|
|
7117
8673
|
throw new Error(`read_image: "${input.path}" is ${read.data.byteLength} bytes — ` +
|
|
7118
8674
|
`over the ${MAX_IMAGE_BYTES}-byte model limit`);
|
|
@@ -7274,6 +8830,81 @@ export class AgentFramework {
|
|
|
7274
8830
|
}
|
|
7275
8831
|
})();
|
|
7276
8832
|
}
|
|
8833
|
+
/** The `utils` meta-tool: list/describe/run over module utilities
|
|
8834
|
+
* (Module.getUtilities). Shared by the model-facing dispatcher and the
|
|
8835
|
+
* programmatic path (executeToolCall), so code_execution scripts and
|
|
8836
|
+
* ephemerals reach utilities too. */
|
|
8837
|
+
async handleUtilsToolCall(agentName, call) {
|
|
8838
|
+
const input = (call.input ?? {});
|
|
8839
|
+
const utilities = this.moduleRegistry.getAllUtilities();
|
|
8840
|
+
const available = () => utilities.length ? utilities.map((u) => u.name).join(', ') : '(none registered)';
|
|
8841
|
+
switch (input.action) {
|
|
8842
|
+
case 'list':
|
|
8843
|
+
return {
|
|
8844
|
+
success: true,
|
|
8845
|
+
data: utilities.map((u) => ({ name: u.name, description: utilityOneLiner(u.description) })),
|
|
8846
|
+
};
|
|
8847
|
+
case 'describe': {
|
|
8848
|
+
const def = utilities.find((u) => u.name === input.name);
|
|
8849
|
+
if (!def) {
|
|
8850
|
+
return { success: false, isError: true, error: `No utility "${String(input.name)}". Available: ${available()}` };
|
|
8851
|
+
}
|
|
8852
|
+
return { success: true, data: def };
|
|
8853
|
+
}
|
|
8854
|
+
case 'run': {
|
|
8855
|
+
const def = utilities.find((u) => u.name === input.name);
|
|
8856
|
+
if (!def) {
|
|
8857
|
+
return { success: false, isError: true, error: `No utility "${String(input.name)}". Available: ${available()}` };
|
|
8858
|
+
}
|
|
8859
|
+
const args = input.args ?? {};
|
|
8860
|
+
const problem = validateUtilityArgs(args, def.inputSchema);
|
|
8861
|
+
if (problem) {
|
|
8862
|
+
// Teach by bounce: the error carries the schema, so the retry needs
|
|
8863
|
+
// no describe round-trip.
|
|
8864
|
+
return {
|
|
8865
|
+
success: false,
|
|
8866
|
+
isError: true,
|
|
8867
|
+
error: `${problem}. Schema for ${def.name}: ${JSON.stringify(def.inputSchema)}`,
|
|
8868
|
+
};
|
|
8869
|
+
}
|
|
8870
|
+
// Same dispatch as a first-class tool call — the module cannot tell
|
|
8871
|
+
// which surface the call came through.
|
|
8872
|
+
return this.moduleRegistry.handleToolCall({
|
|
8873
|
+
id: call.id,
|
|
8874
|
+
name: def.name,
|
|
8875
|
+
input: args,
|
|
8876
|
+
callerAgentName: agentName,
|
|
8877
|
+
});
|
|
8878
|
+
}
|
|
8879
|
+
default:
|
|
8880
|
+
return { success: false, isError: true, error: 'action must be "list", "describe", or "run"' };
|
|
8881
|
+
}
|
|
8882
|
+
}
|
|
8883
|
+
dispatchUtilsToolCall(agentName, call) {
|
|
8884
|
+
this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'utils', callId: call.id, input: call.input });
|
|
8885
|
+
const startedAt = Date.now();
|
|
8886
|
+
void this.handleUtilsToolCall(agentName, call)
|
|
8887
|
+
.catch((err) => ({
|
|
8888
|
+
success: false,
|
|
8889
|
+
error: err instanceof Error ? err.message : String(err),
|
|
8890
|
+
isError: true,
|
|
8891
|
+
}))
|
|
8892
|
+
.then((result) => {
|
|
8893
|
+
if (result.success) {
|
|
8894
|
+
this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'utils', callId: call.id, durationMs: Date.now() - startedAt });
|
|
8895
|
+
}
|
|
8896
|
+
else {
|
|
8897
|
+
this.emitTrace({ type: 'tool:failed', module: 'framework', tool: 'utils', callId: call.id, error: result.error ?? 'unknown error' });
|
|
8898
|
+
}
|
|
8899
|
+
this.pushEvent({
|
|
8900
|
+
type: 'tool-result',
|
|
8901
|
+
callId: call.id,
|
|
8902
|
+
agentName,
|
|
8903
|
+
moduleName: 'framework',
|
|
8904
|
+
result,
|
|
8905
|
+
});
|
|
8906
|
+
});
|
|
8907
|
+
}
|
|
7277
8908
|
dispatchAgentSettingsToolCall(agentName, call) {
|
|
7278
8909
|
this.emitTrace({
|
|
7279
8910
|
type: 'tool:started',
|
|
@@ -7519,12 +9150,11 @@ export class AgentFramework {
|
|
|
7519
9150
|
conversationId: agent.name,
|
|
7520
9151
|
turnIndex: 0, // Simplified; needs per-conversation counter TODO
|
|
7521
9152
|
userMessage: null, // Could extract from trigger context
|
|
7522
|
-
|
|
7523
|
-
|
|
7524
|
-
|
|
7525
|
-
|
|
7526
|
-
|
|
7527
|
-
},
|
|
9153
|
+
// Truthful fields only (§10.1 honesty over completeness): id is real,
|
|
9154
|
+
// vendor/contextWindow/capabilities have no source in this host and
|
|
9155
|
+
// are omitted rather than fabricated — the hardcoded 200000 was false
|
|
9156
|
+
// for residents configured at 300k/600k.
|
|
9157
|
+
model: { id: agent.model },
|
|
7528
9158
|
channels: this.channelRegistry?.buildChannelContext(agent.name),
|
|
7529
9159
|
};
|
|
7530
9160
|
}
|