@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/src/framework.ts
CHANGED
|
@@ -37,6 +37,7 @@ import type {
|
|
|
37
37
|
ToolCall,
|
|
38
38
|
ToolCallEvent,
|
|
39
39
|
ToolResult,
|
|
40
|
+
CompletedToolCall,
|
|
40
41
|
AgentConfig,
|
|
41
42
|
InferenceRequest,
|
|
42
43
|
AgentState,
|
|
@@ -52,21 +53,27 @@ import type {
|
|
|
52
53
|
SameRoundThinkTextPolicy,
|
|
53
54
|
} from './types/index.js';
|
|
54
55
|
import { ProcessQueueImpl } from './queue.js';
|
|
56
|
+
import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK } from './refusal-reactions.js';
|
|
55
57
|
import { Agent } from './agent.js';
|
|
56
58
|
import { ModuleRegistry, isStateExistsError } from './module-registry.js';
|
|
57
59
|
import { McplServerRegistry } from './mcpl/server-registry.js';
|
|
58
60
|
import { FeatureSetManager } from './mcpl/feature-set-manager.js';
|
|
59
|
-
import {
|
|
61
|
+
import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './mcpl/capability-grant.js';
|
|
62
|
+
import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js';
|
|
60
63
|
import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
|
|
61
64
|
import { PushHandler, type McplPushEvent } from './mcpl/push-handler.js';
|
|
62
65
|
import { parseProsePrefix } from './mcpl/prose-grammar.js';
|
|
63
66
|
import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
|
|
64
67
|
import { InferenceRouter } from './mcpl/inference-router.js';
|
|
65
|
-
import { ChannelRegistry } from './mcpl/channel-registry.js';
|
|
68
|
+
import { ChannelRegistry, type ChannelToolOrigin } from './mcpl/channel-registry.js';
|
|
66
69
|
import { ConversationRouter } from './mcpl/conversation-router.js';
|
|
67
70
|
import { safeSlice } from './safe-slice.js';
|
|
68
71
|
import type { WorkspaceModule } from './modules/workspace/index.js';
|
|
69
|
-
import {
|
|
72
|
+
import {
|
|
73
|
+
toolResultDataToHistoryString,
|
|
74
|
+
truncateForHistory,
|
|
75
|
+
DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS,
|
|
76
|
+
} from './tool-result-history.js';
|
|
70
77
|
import { randomUUID } from 'node:crypto';
|
|
71
78
|
import { PyRunner, buildInjectedTools } from './code-execution/py-runner.js';
|
|
72
79
|
import {
|
|
@@ -134,6 +141,43 @@ const isConversationalInjection = (metadata?: MessageMetadata): boolean => {
|
|
|
134
141
|
return true;
|
|
135
142
|
};
|
|
136
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Rough token estimate for the blocks a continuation round is about to
|
|
146
|
+
* append to the live wire (issue #92 physical-window projection): the
|
|
147
|
+
* spilled tool-result strings at chars/4, a flat per-image cost for native
|
|
148
|
+
* image blocks the wire path preserves (provider vision tokens are
|
|
149
|
+
* resolution-dependent; 1600 ≈ a max-size Anthropic tile), mid-turn injected
|
|
150
|
+
* text at chars/4, and a small per-block envelope allowance. Deliberately a
|
|
151
|
+
* ceiling-ish heuristic — the cost of overestimating is one early recompile;
|
|
152
|
+
* the cost of underestimating is the 400 this projection exists to prevent.
|
|
153
|
+
*/
|
|
154
|
+
function estimateAppendedRoundTokens(
|
|
155
|
+
toolResults: CompletedToolCall[],
|
|
156
|
+
spilled: Map<string, { text: string; filePath: string | null }>,
|
|
157
|
+
injections: Array<{ content: ContentBlock[] }>,
|
|
158
|
+
): number {
|
|
159
|
+
let chars = 0;
|
|
160
|
+
let images = 0;
|
|
161
|
+
for (const tc of toolResults) {
|
|
162
|
+
chars += spilled.get(tc.id)?.text.length ?? 0;
|
|
163
|
+
if (Array.isArray(tc.result.data)) {
|
|
164
|
+
for (const raw of tc.result.data) {
|
|
165
|
+
const b = raw as { type?: unknown };
|
|
166
|
+
if (b && typeof b === 'object' && b.type === 'image') images += 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
for (const inj of injections) {
|
|
171
|
+
for (const block of inj.content) {
|
|
172
|
+
if (block.type === 'text' && typeof (block as { text?: unknown }).text === 'string') {
|
|
173
|
+
chars += (block as { text: string }).text.length;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const blockCount = toolResults.length + injections.length;
|
|
178
|
+
return Math.ceil(chars / 4) + images * 1600 + blockCount * 20;
|
|
179
|
+
}
|
|
180
|
+
|
|
137
181
|
/**
|
|
138
182
|
* Explicit delivery tools whose successful use into a closed channel OPENS
|
|
139
183
|
* it (send implies engagement — see openIfClosedForSend). Bare tool names,
|
|
@@ -224,6 +268,65 @@ const PROSE_HELP_TOOL: import('./types/index.js').ToolDefinition = {
|
|
|
224
268
|
};
|
|
225
269
|
/** Strip the `server--` MCPL prefix from a tool name. */
|
|
226
270
|
const bareToolName = (n: string): string => n.split('--').pop()!;
|
|
271
|
+
|
|
272
|
+
/** The single slot behind which every rarely-used capability lives. Only
|
|
273
|
+
* advertised when at least one module contributes utilities — with none
|
|
274
|
+
* registered the surface is byte-identical to before this existed. */
|
|
275
|
+
const UTILS_TOOL: import('./types/index.js').ToolDefinition = {
|
|
276
|
+
name: 'utils',
|
|
277
|
+
description:
|
|
278
|
+
'Rarely-used utilities, behind one slot instead of each costing a tool. ' +
|
|
279
|
+
'"list" names them all with one-liners; "describe" returns one utility\'s full ' +
|
|
280
|
+
'input schema; "run" invokes it (args are validated against that schema — a ' +
|
|
281
|
+
'miss bounces back with the schema and the specific error).',
|
|
282
|
+
inputSchema: {
|
|
283
|
+
type: 'object' as const,
|
|
284
|
+
properties: {
|
|
285
|
+
action: { type: 'string', enum: ['list', 'describe', 'run'], description: 'What to do.' },
|
|
286
|
+
name: { type: 'string', description: 'Utility name from list (required for describe/run).' },
|
|
287
|
+
args: { type: 'object', description: "Arguments for run, per the utility's schema." },
|
|
288
|
+
},
|
|
289
|
+
required: ['action'],
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
/** First sentence (or line) of a description — what `utils list` shows. */
|
|
294
|
+
const utilityOneLiner = (description: string): string => {
|
|
295
|
+
const line = description.split('\n')[0]!;
|
|
296
|
+
const dot = line.indexOf('. ');
|
|
297
|
+
return (dot >= 0 ? line.slice(0, dot + 1) : line).slice(0, 160);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/** Shallow validation of run args against a utility's declared inputSchema:
|
|
301
|
+
* required keys, primitive types, enums. Deliberately not a full JSON-Schema
|
|
302
|
+
* engine — handlers still hand-validate (they always did); this layer exists
|
|
303
|
+
* so an arg-shape miss teaches by bounce instead of failing deep in a handler
|
|
304
|
+
* with a less instructive message. Returns an error string or null. */
|
|
305
|
+
function validateUtilityArgs(
|
|
306
|
+
input: unknown,
|
|
307
|
+
schema: import('./types/index.js').ToolDefinition['inputSchema'],
|
|
308
|
+
): string | null {
|
|
309
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
310
|
+
return 'args must be an object';
|
|
311
|
+
}
|
|
312
|
+
const obj = input as Record<string, unknown>;
|
|
313
|
+
for (const req of schema.required ?? []) {
|
|
314
|
+
if (!(req in obj) || obj[req] === undefined) return `missing required "${req}"`;
|
|
315
|
+
}
|
|
316
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
317
|
+
const param = schema.properties?.[key];
|
|
318
|
+
if (!param || value === undefined) continue; // unknown keys: handler's business
|
|
319
|
+
if (param.enum && !(param.enum as unknown[]).includes(value)) {
|
|
320
|
+
return `"${key}" must be one of ${JSON.stringify(param.enum)}`;
|
|
321
|
+
}
|
|
322
|
+
if (param.type) {
|
|
323
|
+
const actual = Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value;
|
|
324
|
+
const expected = param.type === 'integer' ? 'number' : param.type;
|
|
325
|
+
if (actual !== expected) return `"${key}" must be ${param.type}, got ${actual}`;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
227
330
|
import { CheckpointManager } from './mcpl/checkpoint-manager.js';
|
|
228
331
|
import { isToolAllowed } from './mcpl/tool-policy.js';
|
|
229
332
|
import { EventGate } from './gate/event-gate.js';
|
|
@@ -234,10 +337,7 @@ import type {
|
|
|
234
337
|
McplServerConfig,
|
|
235
338
|
McplHostCapabilities,
|
|
236
339
|
FeatureSetsChangedParams,
|
|
237
|
-
ScopeElevateParams,
|
|
238
|
-
ScopeElevateResult,
|
|
239
340
|
BeforeInferenceParams,
|
|
240
|
-
AfterInferenceParams,
|
|
241
341
|
PushEventParams,
|
|
242
342
|
McplInferenceRequestParams,
|
|
243
343
|
ChannelsRegisterParams,
|
|
@@ -451,6 +551,23 @@ interface Deferred<T> {
|
|
|
451
551
|
* the caller awaits plus the liveness/progress bookkeeping the watchdogs and
|
|
452
552
|
* the stream driver share. One map entry per run — created and torn down in
|
|
453
553
|
* runEphemeralToCompletion — so the pieces cannot desync. */
|
|
554
|
+
/** One model-authored background (daemon) script — see runCodeExecution. */
|
|
555
|
+
interface BackgroundScriptRecord {
|
|
556
|
+
id: string;
|
|
557
|
+
agentName: string;
|
|
558
|
+
runner: PyRunner;
|
|
559
|
+
/** The agent-authored source (line numbers in wake envelopes index into it). */
|
|
560
|
+
code: string;
|
|
561
|
+
startedAt: number;
|
|
562
|
+
wakes: number;
|
|
563
|
+
lastWakeAt: number | null;
|
|
564
|
+
/** Mount-prefixed workspace path of the journal, null when no workspace. */
|
|
565
|
+
logPath: string | null;
|
|
566
|
+
status: 'running' | 'finished' | 'died' | 'cancelled';
|
|
567
|
+
/** Set when cancel/dispose already settled this script (suppresses wakes). */
|
|
568
|
+
cancelled: boolean;
|
|
569
|
+
}
|
|
570
|
+
|
|
454
571
|
interface EphemeralRun {
|
|
455
572
|
settle: Deferred<AgentSettleResult>;
|
|
456
573
|
inferenceStarted: boolean;
|
|
@@ -511,8 +628,45 @@ export class AgentFramework {
|
|
|
511
628
|
* plain prose lands (2026-07-21 Cairn lounge misroute). Lives here — not in
|
|
512
629
|
* driveStream locals — so the pin survives a context-budget stream restart,
|
|
513
630
|
* which continues the same logical turn in a fresh driveStream. Cleared or
|
|
514
|
-
* re-set at the start of every non-restart turn.
|
|
631
|
+
* re-set at the start of every non-restart turn. Since 2026-07-31 the pin
|
|
632
|
+
* is no longer fully frozen: an ADDRESSED conversational injection — or a
|
|
633
|
+
* conversational follow-up in a channel the agent itself explicitly sent
|
|
634
|
+
* into this turn (turnEngagedChannels) — re-pins it at the boundary (see
|
|
635
|
+
* the addressed re-pin block in the tool-result handler). Ambient chatter
|
|
636
|
+
* elsewhere, reactions, and system markers still cannot move it. */
|
|
515
637
|
private turnLocusPins: Map<string, string> = new Map();
|
|
638
|
+
/** Channels the agent EXPLICITLY sent into during the CURRENT turn
|
|
639
|
+
* (SEND_ENGAGEMENT_TOOLS, successful calls), per agent. Second re-pin
|
|
640
|
+
* signal: a conversational reply in a channel the agent just engaged is
|
|
641
|
+
* addressed by context even without a mention (2026-07-31 n=7: q's
|
|
642
|
+
* #portables follow-up — no @mention — was answered in trailing prose
|
|
643
|
+
* that followed the stale pin into repligate's DM). No author-kind
|
|
644
|
+
* filter: agent-residents are full participants, and the bot flag
|
|
645
|
+
* tracks nothing that matters (antra). Reactions/system markers are
|
|
646
|
+
* excluded by isConversationalInjection; the engaged-this-turn scope
|
|
647
|
+
* keeps unrelated channels from moving the pin. Cleared at every fresh
|
|
648
|
+
* turn's start; budget restarts keep it. */
|
|
649
|
+
private turnEngagedChannels: Map<string, Set<string>> = new Map();
|
|
650
|
+
/** Channels this turn's PLAIN PROSE was actually delivered to, in delivery
|
|
651
|
+
* order (deduped at render). Feeds the `[delivered]` receipt appended at
|
|
652
|
+
* logical turn end: explicit sends receipt themselves via their
|
|
653
|
+
* tool_result, but auto-routed prose previously vanished into the router
|
|
654
|
+
* with no in-window record — the agent could never see where its own
|
|
655
|
+
* words landed (2026-07-31 misroute series; antra: minimal receipts).
|
|
656
|
+
* Cleared at every fresh turn's start; budget restarts keep it (same
|
|
657
|
+
* logical turn, receipt covers the whole turn). */
|
|
658
|
+
private turnProseDeliveries: Map<string, string[]> = new Map();
|
|
659
|
+
/** Count of plain-prose segments SUPPRESSED this turn by sticky
|
|
660
|
+
* explicit-send silencing. The suppression rule itself is untouched
|
|
661
|
+
* (antra, 2026-07-31: visibility is enough) — but it must never be a
|
|
662
|
+
* silent black hole: Mythos multiplexed two threads in one round (prose
|
|
663
|
+
* for the lane + explicit send for another channel, textbook per the
|
|
664
|
+
* routing doc), the silencing ate the prose reply, and his own record
|
|
665
|
+
* ("scene delivered to #mythos via plain speech — routing confirmed")
|
|
666
|
+
* believed it delivered. The receipt now reports suppression, so the
|
|
667
|
+
* author sees the segment's fate one turn later. Cleared each fresh
|
|
668
|
+
* turn; budget restarts keep it. */
|
|
669
|
+
private turnProseSuppressed: Map<string, number> = new Map();
|
|
516
670
|
/** A tool boundary injected fresh CONVERSATIONAL input (a real message —
|
|
517
671
|
* not a reaction or a system marker) into the live stream. Tells
|
|
518
672
|
* driveStream to clear sticky explicit-send suppression before handling
|
|
@@ -603,6 +757,25 @@ export class AgentFramework {
|
|
|
603
757
|
// tool_use → tool_result adjacency required by the Anthropic API).
|
|
604
758
|
private deferredMessages: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata }> = [];
|
|
605
759
|
|
|
760
|
+
// Turn-alive markers (2026-07-31 Mythos phantom-skip incident): agentName →
|
|
761
|
+
// token of the turn currently in progress. Set at startAgentStream ENTRY —
|
|
762
|
+
// before hooks, compile, and stream setup, all of which await — and cleared
|
|
763
|
+
// token-matched when that turn's teardown finishes (driveStream finally /
|
|
764
|
+
// terminal failure path), so a successor turn's marker survives a
|
|
765
|
+
// predecessor's late finally. A turn is alive from dequeue to settled
|
|
766
|
+
// teardown: strictly longer than `activeStreams` membership, which begins
|
|
767
|
+
// only after the stream exists. Guarding addMessage on stream-liveness
|
|
768
|
+
// alone left the compile window open — a deferred message flushed by the
|
|
769
|
+
// previous turn's finally landed in the window MID-COMPILE of the turn its
|
|
770
|
+
// own wake had started: positioned before the new turn's blocks yet absent
|
|
771
|
+
// from its wire request. The agent then "skipped" a message it never saw
|
|
772
|
+
// (falsified history — it later confabulated an apology for the skip), and
|
|
773
|
+
// every subsequent compile diverged from the live prefix at that message
|
|
774
|
+
// (KV bust). Invariant: nothing enters the window between a turn's dequeue
|
|
775
|
+
// and its settle except that turn's own blocks.
|
|
776
|
+
private activeTurnTokens: Map<string, number> = new Map();
|
|
777
|
+
private nextTurnToken = 1;
|
|
778
|
+
|
|
606
779
|
// Undo/redo state
|
|
607
780
|
private turnCounters: Map<string, number> = new Map(); // agentName → next turnIndex
|
|
608
781
|
private redoStacks: Map<string, RedoEntry[]> = new Map(); // agentName → redo entries
|
|
@@ -613,7 +786,6 @@ export class AgentFramework {
|
|
|
613
786
|
// MCPL subsystems (null when no mcplServers configured)
|
|
614
787
|
private mcplServerRegistry: McplServerRegistry | null = null;
|
|
615
788
|
private featureSetManager: FeatureSetManager | null = null;
|
|
616
|
-
private scopeManager: ScopeManager | null = null;
|
|
617
789
|
private hookOrchestrator: HookOrchestrator | null = null;
|
|
618
790
|
private pushHandler: PushHandler | null = null;
|
|
619
791
|
private inferenceRouter: InferenceRouter | null = null;
|
|
@@ -646,8 +818,23 @@ export class AgentFramework {
|
|
|
646
818
|
* deferred and applied to the final code_execution result instead of
|
|
647
819
|
* cancelling the stream mid-script (which would wedge the turn). */
|
|
648
820
|
private scriptDeferredEndTurn: Set<string> = new Set();
|
|
821
|
+
/** Background (daemon) scripts: model-authored watchers that outlive their
|
|
822
|
+
* spawning turn. Each gets a DEDICATED PyRunner; wake_agent() injects a
|
|
823
|
+
* provenance envelope + payload and requests inference. Keyed by script id. */
|
|
824
|
+
private backgroundScripts: Map<string, BackgroundScriptRecord> = new Map();
|
|
825
|
+
private backgroundScriptCounter = 0;
|
|
826
|
+
/** Resident-set per-agent tool-result inline cap (chars), via
|
|
827
|
+
* agent_settings `tool_result_inline_max_chars`. DURABLE: persisted in
|
|
828
|
+
* framework state like the core runtime settings and restored at create
|
|
829
|
+
* (antra + Sol, 08-06); reset clears it back to the residence default. */
|
|
830
|
+
private toolResultInlineMaxCharsOverride: Map<string, number> = new Map();
|
|
831
|
+
/** Durable residence-configured inline cap from
|
|
832
|
+
* FrameworkConfig.toolResultInlineMaxChars; null → house default. */
|
|
833
|
+
private toolResultInlineMaxCharsConfig: number | null = null;
|
|
649
834
|
|
|
650
835
|
private mcplTools: import('./types/index.js').ToolDefinition[] = [];
|
|
836
|
+
/** Namespaced tool name → stateful feature-set attribution from tools/list. */
|
|
837
|
+
private mcplToolFeatureSets: Map<string, string> = new Map();
|
|
651
838
|
private mcplToolRefreshInFlight = false;
|
|
652
839
|
private mcplToolRefreshPending = false;
|
|
653
840
|
/** Maps tool prefix → serverId for dispatch routing. */
|
|
@@ -722,7 +909,12 @@ export class AgentFramework {
|
|
|
722
909
|
queryMessages: (filter) => this.queryMessages(filter),
|
|
723
910
|
pushEvent: (event) => this.pushEvent(event),
|
|
724
911
|
onTrace: (listener) => this.onTrace(listener),
|
|
725
|
-
|
|
912
|
+
// The ONLY entry that confers module origin — a private closure the
|
|
913
|
+
// framework hands to its own module registry. The public
|
|
914
|
+
// executeToolCall() is shared with model/ephemeral callers and always
|
|
915
|
+
// stamps agent origin (see there).
|
|
916
|
+
callTool: (call) => this.executeToolCallFrom(call, { kind: 'module' }),
|
|
917
|
+
notifyOps: (kind, agentName, message, data) => this.notifyOps(kind, agentName, message, data),
|
|
726
918
|
});
|
|
727
919
|
}
|
|
728
920
|
|
|
@@ -918,6 +1110,20 @@ export class AgentFramework {
|
|
|
918
1110
|
// retained when enabled — everything downstream gates on the field.
|
|
919
1111
|
framework.codeExecutionConfig = config.codeExecution?.enabled ? config.codeExecution : null;
|
|
920
1112
|
|
|
1113
|
+
if (config.toolResultInlineMaxChars !== undefined) {
|
|
1114
|
+
const cap = config.toolResultInlineMaxChars;
|
|
1115
|
+
if (!Number.isFinite(cap) || cap < 1000) {
|
|
1116
|
+
throw new Error('FrameworkConfig.toolResultInlineMaxChars must be a number >= 1000');
|
|
1117
|
+
}
|
|
1118
|
+
framework.toolResultInlineMaxCharsConfig = Math.floor(cap);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// Restore resident-set inline caps (agent_settings) — durable like the
|
|
1122
|
+
// core runtime settings, same framework/state slot (antra + Sol, 08-06).
|
|
1123
|
+
for (const [agentName, cap] of Object.entries(framework.readPersistedToolResultInlineCaps())) {
|
|
1124
|
+
framework.toolResultInlineMaxCharsOverride.set(agentName, cap);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
921
1127
|
// Initialize MCPL subsystems if configured
|
|
922
1128
|
if (config.mcplServers && config.mcplServers.length > 0) {
|
|
923
1129
|
// Validate tool prefixes: no collisions with module names or between servers
|
|
@@ -1017,6 +1223,17 @@ export class AgentFramework {
|
|
|
1017
1223
|
runner.dispose();
|
|
1018
1224
|
}
|
|
1019
1225
|
this.codeExecutionRunners.clear();
|
|
1226
|
+
// Background daemons die with the process (documented limitation) —
|
|
1227
|
+
// mark cancelled FIRST so their settle path stays silent (no crash-wake
|
|
1228
|
+
// into a framework that is shutting down).
|
|
1229
|
+
for (const record of this.backgroundScripts.values()) {
|
|
1230
|
+
if (record.status === 'running') {
|
|
1231
|
+
record.cancelled = true;
|
|
1232
|
+
record.status = 'cancelled';
|
|
1233
|
+
}
|
|
1234
|
+
record.runner.dispose();
|
|
1235
|
+
}
|
|
1236
|
+
this.backgroundScripts.clear();
|
|
1020
1237
|
|
|
1021
1238
|
// Cancel all active streams
|
|
1022
1239
|
for (const agent of this.agents.values()) {
|
|
@@ -1308,6 +1525,8 @@ export class AgentFramework {
|
|
|
1308
1525
|
: [];
|
|
1309
1526
|
return [
|
|
1310
1527
|
...moduleTools,
|
|
1528
|
+
// Utilities collapse into one slot; absent entirely when none exist.
|
|
1529
|
+
...(this.moduleRegistry.getAllUtilities().length > 0 ? [UTILS_TOOL] : []),
|
|
1311
1530
|
...this.mcplTools,
|
|
1312
1531
|
...channelTools,
|
|
1313
1532
|
...gateTools,
|
|
@@ -1346,6 +1565,16 @@ export class AgentFramework {
|
|
|
1346
1565
|
private collectAgentSettingsExtensions(): Map<string, AgentSettingsExtension> {
|
|
1347
1566
|
const result = new Map<string, AgentSettingsExtension>();
|
|
1348
1567
|
const taken = new Set<string>(AgentFramework.AGENT_SETTINGS_CORE_KEYS);
|
|
1568
|
+
// Framework-owned extension: the resident's tool-result inline cap.
|
|
1569
|
+
// Registered through the same extension surface modules use so
|
|
1570
|
+
// get/update/reset all work with zero extra plumbing. DURABLE: persisted
|
|
1571
|
+
// in framework state and restored at create, like the core runtime
|
|
1572
|
+
// settings (antra + Sol, 08-06); reset returns to the residence default.
|
|
1573
|
+
{
|
|
1574
|
+
const ext = this.spillSettingsExtension();
|
|
1575
|
+
ext.keys.forEach((k) => taken.add(k));
|
|
1576
|
+
result.set('_framework', ext);
|
|
1577
|
+
}
|
|
1349
1578
|
for (const module of this.moduleRegistry.getAllModules()) {
|
|
1350
1579
|
const ext = module.getAgentSettingsExtension?.();
|
|
1351
1580
|
if (!ext) continue;
|
|
@@ -1362,6 +1591,54 @@ export class AgentFramework {
|
|
|
1362
1591
|
return result;
|
|
1363
1592
|
}
|
|
1364
1593
|
|
|
1594
|
+
/** agent_settings extension for the spill gate (see spillOrTruncate). */
|
|
1595
|
+
private spillSettingsExtension(): AgentSettingsExtension {
|
|
1596
|
+
return {
|
|
1597
|
+
properties: {
|
|
1598
|
+
tool_result_inline_max_chars: {
|
|
1599
|
+
type: 'number',
|
|
1600
|
+
description:
|
|
1601
|
+
'Inline size cap (chars) for tool results (successes and errors) and ' +
|
|
1602
|
+
'background-script wake payloads. Content over the cap is written to a workspace ' +
|
|
1603
|
+
'file under tool-results/ and replaced by a truncated preview + file reference. ' +
|
|
1604
|
+
'This is YOUR durable setting: it persists across restarts, like your other ' +
|
|
1605
|
+
'agent_settings. Update it when you want a different inline size; reset restores ' +
|
|
1606
|
+
'the residence default. The effective cap is min(your value, your strategy\'s ' +
|
|
1607
|
+
'per-message bound) — the bound is a safety ceiling, and the full content is ' +
|
|
1608
|
+
'always in the spill file. get reports desired/_effective/_source/_clamped_by.',
|
|
1609
|
+
},
|
|
1610
|
+
},
|
|
1611
|
+
keys: ['tool_result_inline_max_chars'],
|
|
1612
|
+
get: (agentName: string) => {
|
|
1613
|
+
const agent = this.agents.get(agentName);
|
|
1614
|
+
const resolved = agent ? this.resolveToolResultInlineCap(agent) : null;
|
|
1615
|
+
return {
|
|
1616
|
+
tool_result_inline_max_chars:
|
|
1617
|
+
this.toolResultInlineMaxCharsOverride.get(agentName) ?? null,
|
|
1618
|
+
tool_result_inline_max_chars_effective: resolved?.cap ?? null,
|
|
1619
|
+
tool_result_inline_max_chars_source: resolved?.source ?? null,
|
|
1620
|
+
tool_result_inline_max_chars_clamped_by: resolved?.clampedBy ?? null,
|
|
1621
|
+
};
|
|
1622
|
+
},
|
|
1623
|
+
update: (agentName: string, patch: Record<string, unknown>) => {
|
|
1624
|
+
const n = Number(patch.tool_result_inline_max_chars);
|
|
1625
|
+
if (!Number.isFinite(n) || n < 1000) {
|
|
1626
|
+
throw new Error('tool_result_inline_max_chars must be a number >= 1000');
|
|
1627
|
+
}
|
|
1628
|
+
this.toolResultInlineMaxCharsOverride.set(agentName, Math.floor(n));
|
|
1629
|
+
this.persistToolResultInlineCap(agentName, Math.floor(n));
|
|
1630
|
+
return { tool_result_inline_max_chars: Math.floor(n) };
|
|
1631
|
+
},
|
|
1632
|
+
reset: (agentName: string, keys?: string[]) => {
|
|
1633
|
+
if (!keys || keys.includes('tool_result_inline_max_chars')) {
|
|
1634
|
+
this.toolResultInlineMaxCharsOverride.delete(agentName);
|
|
1635
|
+
this.persistToolResultInlineCap(agentName, null);
|
|
1636
|
+
}
|
|
1637
|
+
return { tool_result_inline_max_chars: null };
|
|
1638
|
+
},
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1365
1642
|
private captureInferenceToolSnapshot(agent: Agent): InferenceToolSnapshot {
|
|
1366
1643
|
return {
|
|
1367
1644
|
sameRoundThinkTextPolicy: agent.getEffectiveSameRoundThinkTextPolicy(),
|
|
@@ -1785,6 +2062,44 @@ export class AgentFramework {
|
|
|
1785
2062
|
return overrides;
|
|
1786
2063
|
}
|
|
1787
2064
|
|
|
2065
|
+
/** Load resident-set inline caps from framework state, dropping (loudly)
|
|
2066
|
+
* anything that fails the same validation the live update enforces. */
|
|
2067
|
+
private readPersistedToolResultInlineCaps(): Record<string, number> {
|
|
2068
|
+
try {
|
|
2069
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID) as {
|
|
2070
|
+
toolResultInlineCaps?: Record<string, unknown>;
|
|
2071
|
+
} | null;
|
|
2072
|
+
const out: Record<string, number> = {};
|
|
2073
|
+
for (const [name, value] of Object.entries(data?.toolResultInlineCaps ?? {})) {
|
|
2074
|
+
const n = Number(value);
|
|
2075
|
+
if (Number.isFinite(n) && n >= 1000) {
|
|
2076
|
+
out[name] = Math.floor(n);
|
|
2077
|
+
} else {
|
|
2078
|
+
console.error(
|
|
2079
|
+
`[agent-settings] dropping invalid persisted tool_result_inline_max_chars for '${name}': ${JSON.stringify(value)}`,
|
|
2080
|
+
);
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
return out;
|
|
2084
|
+
} catch {
|
|
2085
|
+
return {};
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
/** Write (cap) or clear (null) one agent's resident-set inline cap. */
|
|
2090
|
+
private persistToolResultInlineCap(agentName: string, cap: number | null): void {
|
|
2091
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
2092
|
+
const state = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>;
|
|
2093
|
+
const all = {
|
|
2094
|
+
...((state.toolResultInlineCaps as Record<string, number> | undefined) ?? {}),
|
|
2095
|
+
};
|
|
2096
|
+
if (cap === null) delete all[agentName];
|
|
2097
|
+
else all[agentName] = cap;
|
|
2098
|
+
if (Object.keys(all).length === 0) delete state.toolResultInlineCaps;
|
|
2099
|
+
else state.toolResultInlineCaps = all;
|
|
2100
|
+
this.store.setStateJson(FRAMEWORK_STATE_ID, state);
|
|
2101
|
+
}
|
|
2102
|
+
|
|
1788
2103
|
private persistAgentRuntimeSettings(
|
|
1789
2104
|
agentName: string,
|
|
1790
2105
|
overrides: AgentRuntimeSettingsOverrides,
|
|
@@ -1972,6 +2287,12 @@ export class AgentFramework {
|
|
|
1972
2287
|
// ephemeral agents leave checkpoint-tree keys and diagnostics map
|
|
1973
2288
|
// entries behind for the life of the store/session.
|
|
1974
2289
|
this.evictTurnCheckpoints(agent.name);
|
|
2290
|
+
// Turn-alive marker, same spawn-and-dispose logic: a stalled ephemeral
|
|
2291
|
+
// (watchdog fired, driveStream's terminal event lost) would strand its
|
|
2292
|
+
// token forever — no wedge (ephemeral names are unique per run) but
|
|
2293
|
+
// unbounded map growth. Blind delete is safe: the agent is already out
|
|
2294
|
+
// of the map, and a late driveStream finally token-match no-ops.
|
|
2295
|
+
this.activeTurnTokens.delete(agent.name);
|
|
1975
2296
|
}
|
|
1976
2297
|
}
|
|
1977
2298
|
|
|
@@ -2261,15 +2582,6 @@ export class AgentFramework {
|
|
|
2261
2582
|
'chat:thread': 'Occurred in a thread',
|
|
2262
2583
|
};
|
|
2263
2584
|
|
|
2264
|
-
/** Refusal category → Discord reaction emoji. Unknown categories get 🛑. */
|
|
2265
|
-
private static readonly REFUSAL_REACTIONS: Record<string, string> = {
|
|
2266
|
-
bio: '☣️',
|
|
2267
|
-
chem: '🧪',
|
|
2268
|
-
nuclear: '☢️',
|
|
2269
|
-
cyber: '💻',
|
|
2270
|
-
reasoning_extraction: '🧠',
|
|
2271
|
-
};
|
|
2272
|
-
|
|
2273
2585
|
/**
|
|
2274
2586
|
* Mark an inference refusal visibly: react on the message that holds the
|
|
2275
2587
|
* conversational locus (the most recent incoming channel message) with a
|
|
@@ -2286,7 +2598,7 @@ export class AgentFramework {
|
|
|
2286
2598
|
const parts = incoming.channelId.split(':');
|
|
2287
2599
|
if (parts[0] !== 'discord') return;
|
|
2288
2600
|
const channelId = parts[parts.length - 1];
|
|
2289
|
-
const emoji =
|
|
2601
|
+
const emoji = REFUSAL_REACTIONS[category] ?? REFUSAL_REACTION_FALLBACK;
|
|
2290
2602
|
// Resolve the MCPL server that owns the locus channel and call
|
|
2291
2603
|
// tools/call directly on its connection (bare tool name — no prefix
|
|
2292
2604
|
// games), bypassing the agent event queue so no synthetic tool-result
|
|
@@ -2500,6 +2812,46 @@ export class AgentFramework {
|
|
|
2500
2812
|
}
|
|
2501
2813
|
}
|
|
2502
2814
|
|
|
2815
|
+
/**
|
|
2816
|
+
* Admin-level nudge: run an inference turn on the agent's CURRENT context
|
|
2817
|
+
* without adding any message, event, or other context mutation. The turn
|
|
2818
|
+
* compiles exactly what the agent already sees — useful after an /undo,
|
|
2819
|
+
* a /hide, or whenever an operator wants the agent to take another look
|
|
2820
|
+
* (or another swing) at where it stands, with zero context pollution.
|
|
2821
|
+
*
|
|
2822
|
+
* Queues a normal inference request (same path as gate wakes): if the
|
|
2823
|
+
* agent is mid-turn it runs when the turn settles; a pending skip_reply
|
|
2824
|
+
* self-wake is superseded like any other wake. Deliberately NOT gated on
|
|
2825
|
+
* idle — "run when you're free" is the useful semantic. No suppression
|
|
2826
|
+
* interplay: gate sleep filters events, not queued requests, so a nudge
|
|
2827
|
+
* wakes even a sleeping agent (it's an admin override, like /unstick).
|
|
2828
|
+
*/
|
|
2829
|
+
nudgeAgent(agentName?: string, requestedBy?: string): {
|
|
2830
|
+
ok: boolean;
|
|
2831
|
+
error?: string;
|
|
2832
|
+
agentName?: string;
|
|
2833
|
+
/** Agent state at queue time — 'idle' means the turn starts on the next
|
|
2834
|
+
* scheduler pass; anything else means it runs after the current turn. */
|
|
2835
|
+
agentStatus?: string;
|
|
2836
|
+
} {
|
|
2837
|
+
const name = agentName ?? [...this.agents.keys()][0];
|
|
2838
|
+
const agent = name ? this.agents.get(name) : undefined;
|
|
2839
|
+
if (!name || !agent) {
|
|
2840
|
+
return { ok: false, error: `Unknown agent: ${String(agentName ?? '(none registered)')}` };
|
|
2841
|
+
}
|
|
2842
|
+
const agentStatus = this.activeTurnTokens.has(name) && agent.state.status === 'idle'
|
|
2843
|
+
? 'idle+turn-alive'
|
|
2844
|
+
: agent.state.status;
|
|
2845
|
+
this.pendingRequests.push({
|
|
2846
|
+
agentName: name,
|
|
2847
|
+
reason: requestedBy ? `admin-nudge (${requestedBy})` : 'admin-nudge',
|
|
2848
|
+
source: 'admin',
|
|
2849
|
+
timestamp: Date.now(),
|
|
2850
|
+
});
|
|
2851
|
+
console.error(`[nudge] agent=${name} queued by=${requestedBy ?? 'unknown'} status=${agentStatus}`);
|
|
2852
|
+
return { ok: true, agentName: name, agentStatus };
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2503
2855
|
/**
|
|
2504
2856
|
* Handle a `host/command` request from an MCPL surface server (e.g. a
|
|
2505
2857
|
* Discord slash command). Currently supports:
|
|
@@ -2508,6 +2860,9 @@ export class AgentFramework {
|
|
|
2508
2860
|
* `undoLastTurn`). The response includes the last message the agent
|
|
2509
2861
|
* would see in its context after the undo, obtained via the transparent
|
|
2510
2862
|
* `previewActivation` render (no inference, no Chronicle writes).
|
|
2863
|
+
*
|
|
2864
|
+
* nudge — run inference on the current context with NO new events
|
|
2865
|
+
* (see `nudgeAgent`).
|
|
2511
2866
|
*/
|
|
2512
2867
|
private async handleHostCommand(
|
|
2513
2868
|
serverId: string,
|
|
@@ -2530,8 +2885,16 @@ export class AgentFramework {
|
|
|
2530
2885
|
* outcome report is posted to the channel asynchronously. */
|
|
2531
2886
|
started?: boolean;
|
|
2532
2887
|
cap?: number;
|
|
2888
|
+
/** For `nudge`: agent state at queue time ('idle' = runs immediately,
|
|
2889
|
+
* else it runs once the current turn settles). */
|
|
2890
|
+
agentStatus?: string;
|
|
2533
2891
|
}> {
|
|
2534
|
-
if (
|
|
2892
|
+
if (
|
|
2893
|
+
params.command !== 'undo' &&
|
|
2894
|
+
params.command !== 'hide' &&
|
|
2895
|
+
params.command !== 'unstick' &&
|
|
2896
|
+
params.command !== 'nudge'
|
|
2897
|
+
) {
|
|
2535
2898
|
return { ok: false, error: `Unknown host command: ${String(params.command)}` };
|
|
2536
2899
|
}
|
|
2537
2900
|
|
|
@@ -2540,6 +2903,16 @@ export class AgentFramework {
|
|
|
2540
2903
|
return { ok: false, error: `Unknown agent: ${String(agentName)}` };
|
|
2541
2904
|
}
|
|
2542
2905
|
|
|
2906
|
+
// nudge: queue an inference on the current context — no message, no
|
|
2907
|
+
// event, no context mutation (see nudgeAgent).
|
|
2908
|
+
if (params.command === 'nudge') {
|
|
2909
|
+
const r = this.nudgeAgent(
|
|
2910
|
+
agentName,
|
|
2911
|
+
params.requesterName ?? params.requesterId ?? `mcpl:${serverId}`,
|
|
2912
|
+
);
|
|
2913
|
+
return { ok: r.ok, error: r.error, agentStatus: r.agentStatus };
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2543
2916
|
// unstick: force the refusal-rewind loop on demand (even if the agent's
|
|
2544
2917
|
// autoRewind toggle is off). Redacts the turn that fed the refusal and
|
|
2545
2918
|
// re-runs, up to `cap` times, until the model stops refusing. Kicks the
|
|
@@ -3443,21 +3816,18 @@ export class AgentFramework {
|
|
|
3443
3816
|
this.pendingAssistantBlocks.delete(agent.name);
|
|
3444
3817
|
}
|
|
3445
3818
|
|
|
3446
|
-
//
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
//
|
|
3451
|
-
//
|
|
3452
|
-
//
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
: toolResultDataToHistoryString(tc.result.data, maxChars),
|
|
3459
|
-
isError: tc.result.isError,
|
|
3460
|
-
}));
|
|
3819
|
+
// Effective inline cap: agent_settings override → durable config →
|
|
3820
|
+
// house default, clamped to the strategy bound (see resolver).
|
|
3821
|
+
const maxChars = this.resolveToolResultInlineCap(agent).cap;
|
|
3822
|
+
|
|
3823
|
+
// Oversized results — successes AND errors — spill to a workspace
|
|
3824
|
+
// file (truncated preview + file reference) instead of being
|
|
3825
|
+
// blind-truncated. Computed ONCE per call and reused for both the
|
|
3826
|
+
// history copy and the live wire copy below — the two must stay
|
|
3827
|
+
// byte-matched (the window stores what the membrane sends;
|
|
3828
|
+
// divergence breaks the compile prefix).
|
|
3829
|
+
const { blocks: toolResultContent, spilled } =
|
|
3830
|
+
await this.buildStoredToolResultContent(currentState.toolResults, maxChars);
|
|
3461
3831
|
agent.getContextManager().addMessage('user', toolResultContent);
|
|
3462
3832
|
|
|
3463
3833
|
// Flush any messages that were deferred while this turn was in
|
|
@@ -3529,6 +3899,113 @@ export class AgentFramework {
|
|
|
3529
3899
|
&& agent.lastStreamInputTokens > 0
|
|
3530
3900
|
&& agent.lastStreamInputTokens > agent.maxStreamTokens;
|
|
3531
3901
|
|
|
3902
|
+
// Physical-window projection (issue #92): a turn's continuation
|
|
3903
|
+
// rounds append to the compiled request without recompiling, so a
|
|
3904
|
+
// turn that starts near the compile target can walk past the
|
|
3905
|
+
// provider's HARD cap mid-turn and take a wire 400 — the compile
|
|
3906
|
+
// was legal, the growth wasn't. Project the next round's real size
|
|
3907
|
+
// (cache-inclusive input of the prior round + the blocks about to
|
|
3908
|
+
// be appended + reserve for the response) and, when it would cross
|
|
3909
|
+
// the physical window, break the stream through the same
|
|
3910
|
+
// budget-restart path — a fresh compile folds the history back
|
|
3911
|
+
// under budget instead of dispatching a doomed request.
|
|
3912
|
+
// Projection terms: prior round's real input + prior round's
|
|
3913
|
+
// OUTPUT (the just-generated thinking/text/tool_use joins the next
|
|
3914
|
+
// round's input — omitting it is a systematic underestimate) +
|
|
3915
|
+
// blocks about to be appended + reserve for the next response.
|
|
3916
|
+
const projectedRealTokens = currentState.stream
|
|
3917
|
+
&& agent.physicalWindowTokens !== undefined
|
|
3918
|
+
&& agent.lastStreamRealInputTokens > 0
|
|
3919
|
+
? agent.lastStreamRealInputTokens
|
|
3920
|
+
+ agent.lastStreamOutputTokens
|
|
3921
|
+
+ estimateAppendedRoundTokens(currentState.toolResults, spilled, midTurnInjections)
|
|
3922
|
+
+ agent.maxTokens
|
|
3923
|
+
: 0;
|
|
3924
|
+
const overPhysical = projectedRealTokens > (agent.physicalWindowTokens ?? Infinity);
|
|
3925
|
+
|
|
3926
|
+
// Addressed re-pin (2026-07-31 Mythos misroutes, antra-ratified):
|
|
3927
|
+
// when someone ADDRESSES the agent mid-turn from another channel
|
|
3928
|
+
// (mention / reply / DM — the same chat:addressed signal the
|
|
3929
|
+
// turn-START batch policy prefers), the agent's subsequent plain
|
|
3930
|
+
// prose almost always answers THEM. Six recorded misroutes
|
|
3931
|
+
// delivered those answers to the stale frozen locus instead (the
|
|
3932
|
+
// LabClaude answer into #hospital_commons mid-examination; the
|
|
3933
|
+
// hospital scene into antra's DM). Re-pin the turn locus to the
|
|
3934
|
+
// LAST addressed conversational injection, at segment granularity:
|
|
3935
|
+
// prose routed in earlier rounds already went to the old pin,
|
|
3936
|
+
// which was correct when it was delivered. Ambient chatter,
|
|
3937
|
+
// reactions, and system markers still cannot move the pin — the
|
|
3938
|
+
// 2026-07-21 Cairn hijack protection is unchanged; this extends
|
|
3939
|
+
// the ratified addressed-outranks-ambient principle from turn
|
|
3940
|
+
// start to boundaries. Locus mode only (explicit mode has no pin).
|
|
3941
|
+
// Skipped on endTurn/overBudget: no further prose this stream, and
|
|
3942
|
+
// a stale "for this turn" notice would be pure noise.
|
|
3943
|
+
//
|
|
3944
|
+
// The notice rides the SAME injection batch — stored right after
|
|
3945
|
+
// the flushed messages and appended to midTurnInjections — so the
|
|
3946
|
+
// window byte-matches the wire (short event-style notice: the
|
|
3947
|
+
// classifier-safe D-class from the 2026-07-24 ablation). It also
|
|
3948
|
+
// updates lastAnnouncedLocus so the next turn's announce-on-change
|
|
3949
|
+
// diffs against what the agent was actually last told.
|
|
3950
|
+
if (
|
|
3951
|
+
agent.proseRouting !== 'explicit' &&
|
|
3952
|
+
!shouldEndTurn && !overBudget && currentState.stream
|
|
3953
|
+
) {
|
|
3954
|
+
// Two signals qualify an injection to move the pin (n=6 + n=7):
|
|
3955
|
+
// - chat:addressed — someone explicitly spoke TO the agent
|
|
3956
|
+
// (mention / reply / DM);
|
|
3957
|
+
// - a conversational follow-up in a channel the agent itself
|
|
3958
|
+
// explicitly sent into THIS turn (turnEngagedChannels) —
|
|
3959
|
+
// conversation the agent just engaged continues without
|
|
3960
|
+
// re-mentioning it (2026-07-31: q's #portables reply, no @,
|
|
3961
|
+
// answered in prose that followed the stale pin into
|
|
3962
|
+
// repligate's DM). Deliberately NO author-kind filter: this
|
|
3963
|
+
// fleet's participants include agent-residents whose speech
|
|
3964
|
+
// is as conversational as anyone's — the Discord bot flag
|
|
3965
|
+
// tracks nothing that matters here (antra, 2026-07-31).
|
|
3966
|
+
// Reactions and system markers are already excluded by
|
|
3967
|
+
// isConversationalInjection, and the engaged-this-turn scope
|
|
3968
|
+
// keeps unrelated channels from moving the pin.
|
|
3969
|
+
const engaged = this.turnEngagedChannels.get(agent.name);
|
|
3970
|
+
const lastQualifying = [...midTurnInjections].reverse().find((inj) => {
|
|
3971
|
+
const m = inj.metadata as Record<string, unknown> | undefined;
|
|
3972
|
+
if (!isConversationalInjection(inj.metadata)) return false;
|
|
3973
|
+
if (typeof m?.channelId !== 'string') return false;
|
|
3974
|
+
const tags = m.tags as string[] | undefined;
|
|
3975
|
+
if (isAddressedMessage(tags, m)) return true;
|
|
3976
|
+
return engaged?.has(m.channelId as string) === true;
|
|
3977
|
+
});
|
|
3978
|
+
const newLocus = (lastQualifying?.metadata as Record<string, unknown> | undefined)
|
|
3979
|
+
?.channelId as string | undefined;
|
|
3980
|
+
if (newLocus && this.turnLocusPins.get(agent.name) !== newLocus) {
|
|
3981
|
+
const prevPin = this.turnLocusPins.get(agent.name) ?? null;
|
|
3982
|
+
this.turnLocusPins.set(agent.name, newLocus);
|
|
3983
|
+
this.lastAnnouncedLocus.set(agent.name, newLocus);
|
|
3984
|
+
const label = this.channelRegistry?.getDescriptor(newLocus)?.label;
|
|
3985
|
+
const shown = label && label !== newLocus
|
|
3986
|
+
? `${label.startsWith('#') ? label : `#${label}`} (${newLocus})`
|
|
3987
|
+
: newLocus;
|
|
3988
|
+
const noticeContent: ContentBlock[] = [{
|
|
3989
|
+
type: 'text',
|
|
3990
|
+
text:
|
|
3991
|
+
`[routing] The conversation moved to ${shown} — your plain ` +
|
|
3992
|
+
'speech now lands there for the rest of this turn. Other ' +
|
|
3993
|
+
'channels need an explicit send tool.',
|
|
3994
|
+
}];
|
|
3995
|
+
const noticeMeta = { system: true, kind: 'routing-notice' } as MessageMetadata;
|
|
3996
|
+
try {
|
|
3997
|
+
const id = agent.getContextManager().addMessage('user', noticeContent, noticeMeta);
|
|
3998
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
|
|
3999
|
+
midTurnInjections.push({ participant: 'user', content: noticeContent, metadata: noticeMeta });
|
|
4000
|
+
console.error(
|
|
4001
|
+
`[routing] ${agent.name}: mid-turn addressed re-pin ${prevPin ?? '(none)'} -> ${newLocus}`,
|
|
4002
|
+
);
|
|
4003
|
+
} catch (err) {
|
|
4004
|
+
console.error('mid-turn locus re-pin: failed to record routing notice:', err);
|
|
4005
|
+
}
|
|
4006
|
+
}
|
|
4007
|
+
}
|
|
4008
|
+
|
|
3532
4009
|
if (shouldEndTurn) {
|
|
3533
4010
|
// endTurn: messages already stored above, cancel stream, reset to idle.
|
|
3534
4011
|
if (currentState.stream) {
|
|
@@ -3543,7 +4020,7 @@ export class AgentFramework {
|
|
|
3543
4020
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
3544
4021
|
this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' });
|
|
3545
4022
|
this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
|
|
3546
|
-
} else if (overBudget) {
|
|
4023
|
+
} else if (overBudget || overPhysical) {
|
|
3547
4024
|
// Context budget exceeded: break the stream, let compile() compress.
|
|
3548
4025
|
// Mark the cancel as framework-initiated BEFORE cancelling: the
|
|
3549
4026
|
// membrane delivers `aborted` before the restart bumps streamId,
|
|
@@ -3553,14 +4030,27 @@ export class AgentFramework {
|
|
|
3553
4030
|
// endTurn above).
|
|
3554
4031
|
if (currentState.stream) {
|
|
3555
4032
|
this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
|
|
4033
|
+
// Cancel the LIVE stream object directly (like the endTurn
|
|
4034
|
+
// branch above): at a tool boundary the state machine reads
|
|
4035
|
+
// 'ready', and Agent.cancelStream only signals the stream from
|
|
4036
|
+
// 'streaming'/'waiting_for_tools' — so the old call left the
|
|
4037
|
+
// provider stream parked forever awaiting tool results that
|
|
4038
|
+
// would never come (zombie: driveStream's finally never ran,
|
|
4039
|
+
// its activeStreams entry survived until the restart's stream
|
|
4040
|
+
// overwrote it). Harmless by accident before turn-alive
|
|
4041
|
+
// tracking; a genuine teardown leak after it.
|
|
4042
|
+
currentState.stream.cancel();
|
|
3556
4043
|
}
|
|
3557
4044
|
agent.cancelStream();
|
|
3558
4045
|
this.emitTrace({
|
|
3559
4046
|
type: 'inference:stream_restarted',
|
|
3560
4047
|
agentName: agent.name,
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
4048
|
+
// 'physical_window' only when the budget check alone would
|
|
4049
|
+
// have let the doomed request through — overBudget keeps its
|
|
4050
|
+
// established label for existing consumers.
|
|
4051
|
+
reason: overBudget ? 'context_budget' : 'physical_window',
|
|
4052
|
+
inputTokens: overBudget ? agent.lastStreamInputTokens : projectedRealTokens,
|
|
4053
|
+
budget: overBudget ? agent.maxStreamTokens : (agent.physicalWindowTokens ?? 0),
|
|
3564
4054
|
});
|
|
3565
4055
|
this.pendingRequests.push({
|
|
3566
4056
|
agentName: agent.name,
|
|
@@ -3574,7 +4064,7 @@ export class AgentFramework {
|
|
|
3574
4064
|
// messages (membrane ≥0.5.72) — appended after the tool_result
|
|
3575
4065
|
// envelope so the next round of THIS turn hears them.
|
|
3576
4066
|
const membraneResults = currentState.toolResults.map(tc =>
|
|
3577
|
-
this.toMembraneToolResult(tc.id, tc.result, maxChars)
|
|
4067
|
+
this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id))
|
|
3578
4068
|
);
|
|
3579
4069
|
currentState.stream.provideToolResults(
|
|
3580
4070
|
membraneResults,
|
|
@@ -3773,6 +4263,12 @@ export class AgentFramework {
|
|
|
3773
4263
|
? (event.metadata.guildName as string)
|
|
3774
4264
|
: undefined,
|
|
3775
4265
|
tags: event.tags,
|
|
4266
|
+
missedMessages: typeof event.metadata?.missedMessages === 'number'
|
|
4267
|
+
? event.metadata.missedMessages
|
|
4268
|
+
: undefined,
|
|
4269
|
+
missedCharacters: typeof event.metadata?.missedCharacters === 'number'
|
|
4270
|
+
? event.metadata.missedCharacters
|
|
4271
|
+
: undefined,
|
|
3776
4272
|
});
|
|
3777
4273
|
if (invitation) {
|
|
3778
4274
|
incomingContent.push(invitation.block);
|
|
@@ -4080,6 +4576,17 @@ export class AgentFramework {
|
|
|
4080
4576
|
* this should see WHO addressed it WHERE, not a wall of snowflake ids. Raw
|
|
4081
4577
|
* ids remain only inside the tool-argument instructions, where they are the
|
|
4082
4578
|
* literal values to pass.
|
|
4579
|
+
*
|
|
4580
|
+
* 2026-08-05 accuracy pass: the old "you won't receive more from it unless
|
|
4581
|
+
* you join" was wrong in both directions — direct addresses (mentions,
|
|
4582
|
+
* replies to the agent) DO still arrive, and nothing warned that answering
|
|
4583
|
+
* via option 1 leaves the human's natural un-@'d follow-up invisible (Sol
|
|
4584
|
+
* answered four #architecture mentions over four days while the follow-ups
|
|
4585
|
+
* to her own replies were silently dropped). The wording now states the
|
|
4586
|
+
* actual delivery model, and option 1 carries the consequence inline. When
|
|
4587
|
+
* the surface tracks a missed-ambient tally (discord-mcpl sends
|
|
4588
|
+
* `missedMessages`/`missedCharacters` in push origin), the invitation shows
|
|
4589
|
+
* the count — the cost of staying out, as a number, at decision time.
|
|
4083
4590
|
*/
|
|
4084
4591
|
private buildClosedChannelInvitation(opts: {
|
|
4085
4592
|
serverId: string;
|
|
@@ -4089,6 +4596,10 @@ export class AgentFramework {
|
|
|
4089
4596
|
authorName: string;
|
|
4090
4597
|
guildName?: string;
|
|
4091
4598
|
tags?: string[];
|
|
4599
|
+
/** Ambient messages dropped in this channel while closed (surface-tracked). */
|
|
4600
|
+
missedMessages?: number;
|
|
4601
|
+
/** Characters across those dropped messages. */
|
|
4602
|
+
missedCharacters?: number;
|
|
4092
4603
|
}): { block: ContentBlock; metadataPatch: Record<string, unknown> } | null {
|
|
4093
4604
|
if (!this.channelRegistry) return null;
|
|
4094
4605
|
if (!opts.tags?.includes('chat:addressed')) return null;
|
|
@@ -4098,13 +4609,23 @@ export class AgentFramework {
|
|
|
4098
4609
|
const label = opts.channelLabel ?? descriptor?.label;
|
|
4099
4610
|
const channelLabel = label ? `#${label}` : `"${opts.channelId}"`;
|
|
4100
4611
|
const place = opts.guildName ? `${channelLabel} in "${opts.guildName}"` : channelLabel;
|
|
4612
|
+
const missedCount = opts.missedMessages ?? 0;
|
|
4613
|
+
const missedNote =
|
|
4614
|
+
missedCount > 0
|
|
4615
|
+
? `While closed, ${missedCount} message${missedCount === 1 ? '' : 's'}` +
|
|
4616
|
+
(opts.missedCharacters ? ` (~${opts.missedCharacters} chars)` : '') +
|
|
4617
|
+
` have passed there without you. `
|
|
4618
|
+
: '';
|
|
4101
4619
|
const block: ContentBlock = {
|
|
4102
4620
|
type: 'text',
|
|
4103
4621
|
text:
|
|
4104
4622
|
`\n[Channel invitation] ${opts.authorName} addressed you in ${place} — ` +
|
|
4105
|
-
`a channel you haven't joined.
|
|
4623
|
+
`a channel you haven't joined. Only messages that address you directly (an @-mention or ` +
|
|
4624
|
+
`a reply to one of your messages) reach you from it; the rest of its traffic is invisible to you. ` +
|
|
4625
|
+
missedNote +
|
|
4106
4626
|
`Your options:\n` +
|
|
4107
|
-
`1. Reply without joining — write your reply this turn prefixed with ">>${channelLabel}"; it will be delivered there
|
|
4627
|
+
`1. Reply without joining — write your reply this turn prefixed with ">>${channelLabel}"; it will be delivered there. ` +
|
|
4628
|
+
`Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n` +
|
|
4108
4629
|
`2. Join the channel — call channel_open with channelId "${opts.channelId}" and serverId "${opts.serverId}"` +
|
|
4109
4630
|
(maxBackscroll > 0
|
|
4110
4631
|
? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${opts.messageId}".\n`
|
|
@@ -4167,6 +4688,9 @@ export class AgentFramework {
|
|
|
4167
4688
|
authorName: typeof origin.authorName === 'string' && origin.authorName ? origin.authorName : 'Someone',
|
|
4168
4689
|
guildName: typeof origin.guildName === 'string' && origin.guildName ? origin.guildName : undefined,
|
|
4169
4690
|
tags: event.tags,
|
|
4691
|
+
missedMessages: typeof origin.missedMessages === 'number' ? origin.missedMessages : undefined,
|
|
4692
|
+
missedCharacters:
|
|
4693
|
+
typeof origin.missedCharacters === 'number' ? origin.missedCharacters : undefined,
|
|
4170
4694
|
});
|
|
4171
4695
|
if (invitation) {
|
|
4172
4696
|
content.push(invitation.block);
|
|
@@ -4292,8 +4816,24 @@ export class AgentFramework {
|
|
|
4292
4816
|
continue;
|
|
4293
4817
|
}
|
|
4294
4818
|
|
|
4295
|
-
// Skip if agent is busy (inferring, streaming, or waiting for tools)
|
|
4296
|
-
if
|
|
4819
|
+
// Skip if agent is busy (inferring, streaming, or waiting for tools) —
|
|
4820
|
+
// or if a turn is alive at all (activeTurnTokens): the state machine
|
|
4821
|
+
// goes idle at endTurn's reset while driveStream teardown is still
|
|
4822
|
+
// pending, and a successor turn dequeued in that gap compiles while the
|
|
4823
|
+
// predecessor's deferred flush can still write (2026-07-31 Mythos).
|
|
4824
|
+
// Turn-alive spans dequeue → settled teardown; wakes wait it out.
|
|
4825
|
+
//
|
|
4826
|
+
// EXCEPT a context-budget restart: it CONTINUES the turn whose token is
|
|
4827
|
+
// held — queueing it behind its own turn's teardown is a deadlock when
|
|
4828
|
+
// that teardown lags (the cancelled stream's `aborted` event is
|
|
4829
|
+
// asynchronous and, historically, not even guaranteed — see the
|
|
4830
|
+
// overBudget branch). The restart's startAgentStream overwrites the
|
|
4831
|
+
// token; the predecessor frame's late finally no-ops via token-match.
|
|
4832
|
+
// This mirrors how the restart has always overwritten activeStreams
|
|
4833
|
+
// rather than waiting for the old stream's teardown.
|
|
4834
|
+
const isBudgetRestart = requests.some((r) => r.reason === 'context_budget_restart');
|
|
4835
|
+
const turnAlive = !isBudgetRestart && this.activeTurnTokens.has(agentName);
|
|
4836
|
+
if (turnAlive || agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
|
|
4297
4837
|
// Re-queue requests, but warn if they've been pending too long
|
|
4298
4838
|
const oldest = Math.min(...requests.map(r => r.timestamp));
|
|
4299
4839
|
if (
|
|
@@ -4305,15 +4845,22 @@ export class AgentFramework {
|
|
|
4305
4845
|
// (up to 100/sec after the scheduler backoff), adding avoidable work
|
|
4306
4846
|
// precisely while the agent was already under pressure.
|
|
4307
4847
|
this.staleWarnAt.set(agentName, now);
|
|
4848
|
+
// Surface turn-alive separately from the state machine: status can
|
|
4849
|
+
// read 'idle' while a turn's teardown is pending, and a LEAKED turn
|
|
4850
|
+
// token would look exactly like this — permanently requeued wakes.
|
|
4851
|
+
// This line is the wedge's tell (idle+turn-alive, forever).
|
|
4852
|
+
const shownStatus = turnAlive && agent.state.status === 'idle'
|
|
4853
|
+
? 'idle+turn-alive'
|
|
4854
|
+
: agent.state.status;
|
|
4308
4855
|
this.emitTrace({
|
|
4309
4856
|
type: 'inference:request_stale',
|
|
4310
4857
|
agentName,
|
|
4311
|
-
agentStatus:
|
|
4858
|
+
agentStatus: shownStatus,
|
|
4312
4859
|
requestCount: requests.length,
|
|
4313
4860
|
oldestRequestAge: now - oldest,
|
|
4314
4861
|
});
|
|
4315
4862
|
console.error(
|
|
4316
|
-
`[inference-stale] agent=${agentName} busy (${
|
|
4863
|
+
`[inference-stale] agent=${agentName} busy (${shownStatus}) — ` +
|
|
4317
4864
|
`${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`,
|
|
4318
4865
|
);
|
|
4319
4866
|
}
|
|
@@ -4363,6 +4910,77 @@ export class AgentFramework {
|
|
|
4363
4910
|
}
|
|
4364
4911
|
}
|
|
4365
4912
|
|
|
4913
|
+
/** Record prose segments suppressed by explicit-send silencing. */
|
|
4914
|
+
private recordProseSuppression(agentName: string, count: number): void {
|
|
4915
|
+
if (count <= 0) return;
|
|
4916
|
+
this.turnProseSuppressed.set(agentName, (this.turnProseSuppressed.get(agentName) ?? 0) + count);
|
|
4917
|
+
}
|
|
4918
|
+
|
|
4919
|
+
/** Record a successful plain-prose delivery for this turn's receipt. */
|
|
4920
|
+
private recordProseDelivery(
|
|
4921
|
+
agentName: string,
|
|
4922
|
+
outcome: { delivered: boolean; channelId: string } | null | undefined,
|
|
4923
|
+
): void {
|
|
4924
|
+
if (!outcome?.delivered) return;
|
|
4925
|
+
let list = this.turnProseDeliveries.get(agentName);
|
|
4926
|
+
if (!list) {
|
|
4927
|
+
list = [];
|
|
4928
|
+
this.turnProseDeliveries.set(agentName, list);
|
|
4929
|
+
}
|
|
4930
|
+
list.push(outcome.channelId);
|
|
4931
|
+
}
|
|
4932
|
+
|
|
4933
|
+
/**
|
|
4934
|
+
* Append the turn's `[delivered]` receipt: one compact system message
|
|
4935
|
+
* naming where this turn's plain prose actually landed (channels in
|
|
4936
|
+
* delivery order, deduped). Explicit sends receipt themselves via their
|
|
4937
|
+
* tool_result; auto-routed prose previously left no in-window trace, so
|
|
4938
|
+
* the agent could never see where its own words went — misroutes were
|
|
4939
|
+
* invisible to it until a human reported them (2026-07-31 series).
|
|
4940
|
+
* Called ONLY at logical turn end (stream 'complete', or the endTurn
|
|
4941
|
+
* cancel path after the speech chain settles) — never mid-stream, where a
|
|
4942
|
+
* window-only insert would diverge from the wire. No-op when the turn
|
|
4943
|
+
* delivered nothing. Failures are already marked separately
|
|
4944
|
+
* ([discord-send-failed]); this is the success half.
|
|
4945
|
+
*/
|
|
4946
|
+
private appendProseDeliveryReceipt(agent: Agent): void {
|
|
4947
|
+
const list = this.turnProseDeliveries.get(agent.name);
|
|
4948
|
+
const suppressed = this.turnProseSuppressed.get(agent.name) ?? 0;
|
|
4949
|
+
if ((!list || list.length === 0) && suppressed === 0) return;
|
|
4950
|
+
this.turnProseDeliveries.delete(agent.name);
|
|
4951
|
+
this.turnProseSuppressed.delete(agent.name);
|
|
4952
|
+
const seen = new Set<string>();
|
|
4953
|
+
const shown: string[] = [];
|
|
4954
|
+
for (const id of list ?? []) {
|
|
4955
|
+
if (seen.has(id)) continue;
|
|
4956
|
+
seen.add(id);
|
|
4957
|
+
const label = this.channelRegistry?.getDescriptor(id)?.label;
|
|
4958
|
+
shown.push(
|
|
4959
|
+
label && label !== id
|
|
4960
|
+
? `${label.startsWith('#') ? label : `#${label}`} (${id})`
|
|
4961
|
+
: id,
|
|
4962
|
+
);
|
|
4963
|
+
}
|
|
4964
|
+
const suppressedNote =
|
|
4965
|
+
suppressed > 0
|
|
4966
|
+
? `${suppressed} plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)`
|
|
4967
|
+
: '';
|
|
4968
|
+
const text =
|
|
4969
|
+
shown.length > 0
|
|
4970
|
+
? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}`
|
|
4971
|
+
: `[delivered] nothing — ${suppressedNote}`;
|
|
4972
|
+
try {
|
|
4973
|
+
const mid = agent.getContextManager().addMessage(
|
|
4974
|
+
'user',
|
|
4975
|
+
[{ type: 'text', text }],
|
|
4976
|
+
{ system: true, kind: 'delivery-receipt' } as MessageMetadata,
|
|
4977
|
+
);
|
|
4978
|
+
this.emitTrace({ type: 'message:added', messageId: mid, source: 'delivery-receipt' });
|
|
4979
|
+
} catch (err) {
|
|
4980
|
+
console.error('delivery receipt append failed:', err);
|
|
4981
|
+
}
|
|
4982
|
+
}
|
|
4983
|
+
|
|
4366
4984
|
/**
|
|
4367
4985
|
* Announce-on-change locus notice (turn-frozen routing). Appends a durable
|
|
4368
4986
|
* `[routing]` window message when the turn's effective outbound locus
|
|
@@ -4637,6 +5255,85 @@ export class AgentFramework {
|
|
|
4637
5255
|
}
|
|
4638
5256
|
|
|
4639
5257
|
private async startAgentStream(agent: Agent, trigger?: InferenceRequest, attempt = 0): Promise<void> {
|
|
5258
|
+
// Mark the turn alive before ANYTHING awaits (hooks, compile, stream
|
|
5259
|
+
// setup): from here until this turn's teardown, cross-turn writers defer
|
|
5260
|
+
// (addMessage guard) instead of appending — nothing may enter the window
|
|
5261
|
+
// between a turn's dequeue and its settle except the turn's own blocks.
|
|
5262
|
+
// A retry re-enters with a fresh token; teardown clears token-matched.
|
|
5263
|
+
//
|
|
5264
|
+
// The try/finally is the leak-proofing: a token whose owner never clears
|
|
5265
|
+
// it permanently requeues every wake for the agent (the 'idle+turn-alive'
|
|
5266
|
+
// wedge — worse than any single lost turn). Ownership is handed off
|
|
5267
|
+
// exactly once, to driveStream, the moment the stream handle is
|
|
5268
|
+
// registered; on every other exit — including a throw from the turn-start
|
|
5269
|
+
// flush, recordTurnCheckpoint, or locus resolution, none of which the
|
|
5270
|
+
// inner catch covers (ENOSPC-class store writes live there) — the finally
|
|
5271
|
+
// clears our own token. Token-matched, so when a retry frame has replaced
|
|
5272
|
+
// the token, or the terminal-failure branch already deleted it, this
|
|
5273
|
+
// no-ops instead of clobbering a successor's marker.
|
|
5274
|
+
const turnToken = this.nextTurnToken++;
|
|
5275
|
+
this.activeTurnTokens.set(agent.name, turnToken);
|
|
5276
|
+
let tokenHandedOff = false;
|
|
5277
|
+
try {
|
|
5278
|
+
tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken);
|
|
5279
|
+
} finally {
|
|
5280
|
+
if (!tokenHandedOff && this.activeTurnTokens.get(agent.name) === turnToken) {
|
|
5281
|
+
this.activeTurnTokens.delete(agent.name);
|
|
5282
|
+
}
|
|
5283
|
+
}
|
|
5284
|
+
}
|
|
5285
|
+
|
|
5286
|
+
/**
|
|
5287
|
+
* Body of startAgentStream, split out so the caller's try/finally owns the
|
|
5288
|
+
* turn token unconditionally. Returns true iff the token was handed off to
|
|
5289
|
+
* driveStream (whose finally then owns clearing it).
|
|
5290
|
+
*/
|
|
5291
|
+
private async beginAgentTurn(
|
|
5292
|
+
agent: Agent,
|
|
5293
|
+
trigger: InferenceRequest | undefined,
|
|
5294
|
+
attempt: number,
|
|
5295
|
+
turnToken: number,
|
|
5296
|
+
): Promise<boolean> {
|
|
5297
|
+
// Flush messages deferred during the PREVIOUS turn — before the
|
|
5298
|
+
// checkpoint, the locus announcement, and the compile — so a turn started
|
|
5299
|
+
// by a queued wake actually CONTAINS the message that woke it. (2026-07-31
|
|
5300
|
+
// Mythos: a DM's wake fired a turn whose compile ran before the deferred
|
|
5301
|
+
// DM flushed; the model saw only the routing-shift notice, reasonably
|
|
5302
|
+
// skipped, and the DM then landed before the skip in the window — false
|
|
5303
|
+
// "saw it and ignored it" history plus KV divergence. Flushed here, the
|
|
5304
|
+
// payload rides its own wake, and the notice/payload can never split.)
|
|
5305
|
+
// Primary agent only — deferred messages are framework-level, meant for
|
|
5306
|
+
// the main conversation (same target rule as the tool-boundary flush).
|
|
5307
|
+
// Fresh turns only: a context-budget restart continues the same logical
|
|
5308
|
+
// turn, and flushing there would insert messages between its rounds.
|
|
5309
|
+
// Before recordTurnCheckpoint: these are the turn's inputs, not its
|
|
5310
|
+
// products — undoing the turn must not destroy them.
|
|
5311
|
+
if (
|
|
5312
|
+
attempt === 0 &&
|
|
5313
|
+
trigger?.reason !== 'context_budget_restart' &&
|
|
5314
|
+
this.deferredMessages.length > 0
|
|
5315
|
+
) {
|
|
5316
|
+
const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
|
|
5317
|
+
if (target === agent) {
|
|
5318
|
+
const deferred = this.deferredMessages.splice(0);
|
|
5319
|
+
for (const msg of deferred) {
|
|
5320
|
+
// Per-message try/catch (mirrors announceLocusIfChanged): one
|
|
5321
|
+
// poison message — or a transient store-write failure — must not
|
|
5322
|
+
// abort the turn or drop the messages behind it in the queue.
|
|
5323
|
+
try {
|
|
5324
|
+
const id = agent.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
|
|
5325
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'deferred-flush:turn-start' });
|
|
5326
|
+
} catch (err) {
|
|
5327
|
+
console.error(
|
|
5328
|
+
`[deferred-flush] turn-start flush failed to store a message for ${agent.name} ` +
|
|
5329
|
+
`(participant=${msg.participant}):`,
|
|
5330
|
+
err,
|
|
5331
|
+
);
|
|
5332
|
+
}
|
|
5333
|
+
}
|
|
5334
|
+
}
|
|
5335
|
+
}
|
|
5336
|
+
|
|
4640
5337
|
// Record turn checkpoint before inference (only on first attempt, not retries)
|
|
4641
5338
|
if (attempt === 0) {
|
|
4642
5339
|
this.recordTurnCheckpoint(agent.name);
|
|
@@ -4678,6 +5375,12 @@ export class AgentFramework {
|
|
|
4678
5375
|
// KV stability).
|
|
4679
5376
|
if (trigger?.reason !== 'context_budget_restart') {
|
|
4680
5377
|
if (attempt === 0) this.maybePrimeProseMode(agent);
|
|
5378
|
+
// Fresh turn: forget the previous turn's explicit-send engagements and
|
|
5379
|
+
// prose deliveries — both are strictly turn-scoped (a restart continues
|
|
5380
|
+
// the same logical turn and keeps them).
|
|
5381
|
+
this.turnEngagedChannels.delete(agent.name);
|
|
5382
|
+
this.turnProseDeliveries.delete(agent.name);
|
|
5383
|
+
this.turnProseSuppressed.delete(agent.name);
|
|
4681
5384
|
if (agent.proseRouting === 'explicit') {
|
|
4682
5385
|
// Explicit prose routing: there is no locus. The model names every
|
|
4683
5386
|
// destination in-band (`>>` prefixes); the only turn state is the
|
|
@@ -4777,6 +5480,9 @@ export class AgentFramework {
|
|
|
4777
5480
|
compiledRequest,
|
|
4778
5481
|
);
|
|
4779
5482
|
this.activeStreams.set(agent.name, handle);
|
|
5483
|
+
// Handoff: driveStream captured the token in its synchronous prefix;
|
|
5484
|
+
// its finally now owns clearing it. The caller's finally must not.
|
|
5485
|
+
return true;
|
|
4780
5486
|
} catch (error) {
|
|
4781
5487
|
// The early typing indicator has no driveStream `finally` on this path —
|
|
4782
5488
|
// a hook/compile/stream-setup failure must not leave "typing…" stuck.
|
|
@@ -4794,8 +5500,18 @@ export class AgentFramework {
|
|
|
4794
5500
|
const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
|
|
4795
5501
|
if (action.retry) {
|
|
4796
5502
|
await new Promise((resolve) => setTimeout(resolve, action.delayMs));
|
|
5503
|
+
// The retry re-enters startAgentStream, which replaces this frame's
|
|
5504
|
+
// turn token with its own; cleanup belongs to the innermost frame.
|
|
4797
5505
|
await this.startAgentStream(agent, trigger, attempt + 1);
|
|
4798
5506
|
} else {
|
|
5507
|
+
// Terminal failure with no driveStream: this frame owns teardown.
|
|
5508
|
+
// Token-matched (like the finally in driveStream) so a stale frame
|
|
5509
|
+
// can never clear a successor turn's marker. A leaked token would
|
|
5510
|
+
// permanently requeue every wake for this agent (same wedge shape as
|
|
5511
|
+
// the eventGate `inferring` leak) — every exit path must clear it.
|
|
5512
|
+
if (this.activeTurnTokens.get(agent.name) === turnToken) {
|
|
5513
|
+
this.activeTurnTokens.delete(agent.name);
|
|
5514
|
+
}
|
|
4799
5515
|
this.settleAgent(agent.name, {
|
|
4800
5516
|
stopReason: 'exhausted',
|
|
4801
5517
|
speech: '',
|
|
@@ -4819,6 +5535,12 @@ export class AgentFramework {
|
|
|
4819
5535
|
this.pushEvent(action.emit);
|
|
4820
5536
|
}
|
|
4821
5537
|
}
|
|
5538
|
+
// No handoff on either failure branch: the terminal branch deleted the
|
|
5539
|
+
// token above; the retry branch's recursive startAgentStream replaced
|
|
5540
|
+
// it with the inner frame's token. Returning false lets the caller's
|
|
5541
|
+
// token-matched finally no-op in both cases (and actually clear it if
|
|
5542
|
+
// something above threw before either branch ran).
|
|
5543
|
+
return false;
|
|
4822
5544
|
}
|
|
4823
5545
|
}
|
|
4824
5546
|
|
|
@@ -4833,6 +5555,10 @@ export class AgentFramework {
|
|
|
4833
5555
|
const startTime = Date.now();
|
|
4834
5556
|
const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
|
|
4835
5557
|
const myStreamId = agent.streamId;
|
|
5558
|
+
// This turn's alive-marker, set at startAgentStream entry. Safe to read
|
|
5559
|
+
// from the map here: the turn-alive busy check in processInferenceRequests
|
|
5560
|
+
// means no successor turn can have replaced it while we compiled.
|
|
5561
|
+
const myTurnToken = this.activeTurnTokens.get(agent.name);
|
|
4836
5562
|
let hadToolCalls = false;
|
|
4837
5563
|
|
|
4838
5564
|
// ---- Present-while-acting turn state ---------------------------------
|
|
@@ -4857,7 +5583,8 @@ export class AgentFramework {
|
|
|
4857
5583
|
const enqueueSpeech = (text: string, locus: string | null): void => {
|
|
4858
5584
|
turnSpeechChain = turnSpeechChain
|
|
4859
5585
|
.then(async () => {
|
|
4860
|
-
await this.channelRegistry!.routeSpeech(agent.name, text, locus);
|
|
5586
|
+
const outcome = await this.channelRegistry!.routeSpeech(agent.name, text, locus);
|
|
5587
|
+
this.recordProseDelivery(agent.name, outcome);
|
|
4861
5588
|
})
|
|
4862
5589
|
.catch((err) => console.error('mid-turn speech routing failed:', err));
|
|
4863
5590
|
};
|
|
@@ -4899,8 +5626,27 @@ export class AgentFramework {
|
|
|
4899
5626
|
// streams that delivery would refuse), emission is capability-gated in
|
|
4900
5627
|
// the registry (`channels.streaming`), and delivery via deliverProse
|
|
4901
5628
|
// remains the sole authoritative send.
|
|
4902
|
-
const
|
|
5629
|
+
const newOutgoingInferenceId = (): string =>
|
|
5630
|
+
`inf_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
5631
|
+
// NOT const: a membrane refusal retry abandons everything streamed so
|
|
5632
|
+
// far, and the surface must not concatenate two attempts. Re-minting the
|
|
5633
|
+
// id makes the discarded attempt's chunk buffer orphaned (never
|
|
5634
|
+
// finalized) instead of being appended to — see the 'retrying' case.
|
|
5635
|
+
let outgoingInferenceId = newOutgoingInferenceId();
|
|
4903
5636
|
let outgoingIndex = 0;
|
|
5637
|
+
|
|
5638
|
+
// §10.5 inference/lifecycle: `started` now, exactly one terminal in the
|
|
5639
|
+
// finally below (the one block every exit path shares). Which terminal
|
|
5640
|
+
// is decided by the paths themselves: default completed, catch sets
|
|
5641
|
+
// failed, a framework-cancelled stream sets aborted. Best-effort
|
|
5642
|
+
// notifications — consumers dedupe by inferenceId and keep a timeout.
|
|
5643
|
+
let lifecyclePhase: 'completed' | 'aborted' | 'failed' = 'completed';
|
|
5644
|
+
this.hookOrchestrator?.emitLifecycle({
|
|
5645
|
+
inferenceId: outgoingInferenceId,
|
|
5646
|
+
conversationId: agent.name,
|
|
5647
|
+
turnIndex: 0,
|
|
5648
|
+
phase: 'started',
|
|
5649
|
+
});
|
|
4904
5650
|
const proseStream = this.channelRegistry
|
|
4905
5651
|
? new ProseStreamRouter({
|
|
4906
5652
|
mode: agent.proseRouting === 'explicit' ? 'explicit' : 'locus',
|
|
@@ -4950,6 +5696,38 @@ export class AgentFramework {
|
|
|
4950
5696
|
}
|
|
4951
5697
|
break;
|
|
4952
5698
|
|
|
5699
|
+
case 'retrying': {
|
|
5700
|
+
// Membrane is re-issuing after a content-policy refusal. Per the
|
|
5701
|
+
// RetryingEvent contract we must DISCARD everything this call has
|
|
5702
|
+
// emitted: the tokens above were already streamed to the surface
|
|
5703
|
+
// as outgoing chunks, and appending a second attempt to them
|
|
5704
|
+
// would show the human two half-answers spliced together.
|
|
5705
|
+
//
|
|
5706
|
+
// Re-mint the outgoing id so the partial buffer is orphaned
|
|
5707
|
+
// rather than continued (delivery is authoritative via
|
|
5708
|
+
// deliverProse and has not happened yet — nothing has been
|
|
5709
|
+
// *sent*, only previewed), and reset the prose router so its
|
|
5710
|
+
// destination bookkeeping starts clean for the new attempt.
|
|
5711
|
+
console.error(
|
|
5712
|
+
`[refusal-retry] agent=${agent.name} membrane retry ` +
|
|
5713
|
+
`${event.attempt}/${event.maxAttempts}` +
|
|
5714
|
+
(event.category ? ` category=${event.category}` : '') +
|
|
5715
|
+
' — discarding the refused attempt',
|
|
5716
|
+
);
|
|
5717
|
+
outgoingInferenceId = newOutgoingInferenceId();
|
|
5718
|
+
outgoingIndex = 0;
|
|
5719
|
+
proseStream?.reset();
|
|
5720
|
+
this.emitTrace({
|
|
5721
|
+
type: 'inference:tokens',
|
|
5722
|
+
agentName: agent.name,
|
|
5723
|
+
content: '',
|
|
5724
|
+
blockType: 'text',
|
|
5725
|
+
blockIndex: 0,
|
|
5726
|
+
channelId: typingChannel ?? undefined,
|
|
5727
|
+
});
|
|
5728
|
+
break;
|
|
5729
|
+
}
|
|
5730
|
+
|
|
4953
5731
|
case 'block': {
|
|
4954
5732
|
const { event: phase, index, block } = event.event;
|
|
4955
5733
|
this.emitTrace({
|
|
@@ -5067,6 +5845,9 @@ export class AgentFramework {
|
|
|
5067
5845
|
console.error(
|
|
5068
5846
|
`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`,
|
|
5069
5847
|
);
|
|
5848
|
+
// Visible in the turn-end receipt — silencing must never
|
|
5849
|
+
// be a silent black hole (n=8: the flying-scene reply).
|
|
5850
|
+
this.recordProseSuppression(agent.name, roundSegments.length);
|
|
5070
5851
|
} else if (hasSameRoundPrivateThink) {
|
|
5071
5852
|
console.error(
|
|
5072
5853
|
`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`,
|
|
@@ -5114,17 +5895,15 @@ export class AgentFramework {
|
|
|
5114
5895
|
agent.addAssistantResponse(pending);
|
|
5115
5896
|
this.pendingAssistantBlocks.delete(agent.name);
|
|
5116
5897
|
}
|
|
5117
|
-
// Store tool results
|
|
5898
|
+
// Store tool results — same bounded spill policy as the
|
|
5899
|
+
// ordinary path (issue #89: this guard path must not become the
|
|
5900
|
+
// one door a giant blob still walks through).
|
|
5118
5901
|
const readyState = agent.state as AgentState;
|
|
5119
5902
|
if (readyState.status === 'ready') {
|
|
5120
|
-
const
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
? (tc.result.error ?? 'Unknown error')
|
|
5125
|
-
: toolResultDataToHistoryString(tc.result.data),
|
|
5126
|
-
isError: tc.result.isError,
|
|
5127
|
-
}));
|
|
5903
|
+
const { blocks: toolResultContent } = await this.buildStoredToolResultContent(
|
|
5904
|
+
readyState.toolResults,
|
|
5905
|
+
this.resolveToolResultInlineCap(agent).cap,
|
|
5906
|
+
);
|
|
5128
5907
|
agent.getContextManager().addMessage('user', toolResultContent);
|
|
5129
5908
|
}
|
|
5130
5909
|
}
|
|
@@ -5153,40 +5932,10 @@ export class AgentFramework {
|
|
|
5153
5932
|
agent.addAssistantResponse(terminalContent);
|
|
5154
5933
|
}
|
|
5155
5934
|
|
|
5156
|
-
//
|
|
5157
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
.filter((block: ContentBlock): block is ContentBlock & { type: 'text' } => block.type === 'text')
|
|
5161
|
-
.map((b) => b.text)
|
|
5162
|
-
.join('\n');
|
|
5163
|
-
|
|
5164
|
-
const afterParams: AfterInferenceParams = {
|
|
5165
|
-
inferenceId: requestId,
|
|
5166
|
-
conversationId: agent.name,
|
|
5167
|
-
turnIndex: 0,
|
|
5168
|
-
userMessage: null,
|
|
5169
|
-
assistantMessage: speechText,
|
|
5170
|
-
model: {
|
|
5171
|
-
id: agent.model,
|
|
5172
|
-
vendor: 'unknown',
|
|
5173
|
-
contextWindow: 200000,
|
|
5174
|
-
capabilities: ['tools'],
|
|
5175
|
-
},
|
|
5176
|
-
usage: {
|
|
5177
|
-
inputTokens: response.usage?.inputTokens ?? 0,
|
|
5178
|
-
outputTokens: response.usage?.outputTokens ?? 0,
|
|
5179
|
-
cacheCreationTokens: response.details?.usage?.cacheCreationTokens,
|
|
5180
|
-
cacheReadTokens: response.details?.usage?.cacheReadTokens,
|
|
5181
|
-
},
|
|
5182
|
-
};
|
|
5183
|
-
|
|
5184
|
-
await this.hookOrchestrator.afterInference(afterParams);
|
|
5185
|
-
} catch (error) {
|
|
5186
|
-
// Fail-open: continue with speech dispatch
|
|
5187
|
-
console.error('afterInference hook error:', error);
|
|
5188
|
-
}
|
|
5189
|
-
}
|
|
5935
|
+
// §10.5: context/afterInference is removed in 0.5.0 — no
|
|
5936
|
+
// content leaves the host here. Turn boundaries are announced
|
|
5937
|
+
// by the metadata-only inference/lifecycle notifications
|
|
5938
|
+
// emitted in driveStream's start/exit paths.
|
|
5190
5939
|
|
|
5191
5940
|
// Separate speech from thoughts — for MODULE dispatch only
|
|
5192
5941
|
// (dispatchSpeech / TUI rendering below). This split does NOT
|
|
@@ -5283,6 +6032,18 @@ export class AgentFramework {
|
|
|
5283
6032
|
let handledByRewind = false;
|
|
5284
6033
|
const rh = agent.refusalHandling;
|
|
5285
6034
|
const forced = this.forcedRewind.get(agent.name);
|
|
6035
|
+
|
|
6036
|
+
// NOTE: plain retries are NOT done here. `refusalHandling.retries`
|
|
6037
|
+
// is handed to membrane (see Agent.startActivation), which
|
|
6038
|
+
// replays the SAME request at the provider seam — immediately,
|
|
6039
|
+
// cache-warm, and without recompiling into a different window.
|
|
6040
|
+
// A framework-level requeue was tried first and is strictly
|
|
6041
|
+
// worse: it recompiles, so attempts are correlated rather than
|
|
6042
|
+
// fresh draws against a probabilistic threshold, and it cannot
|
|
6043
|
+
// tell a streaming consumer to discard the abandoned attempt.
|
|
6044
|
+
// By the time a refusal reaches here, membrane's retries are
|
|
6045
|
+
// already spent — so this path is the escalation: rewind (if
|
|
6046
|
+
// enabled), then the reaction.
|
|
5286
6047
|
// Shed exactly one more (newest, in-sequence) turn and keep the
|
|
5287
6048
|
// single episode marker current. `budgetLeft` bounds the loop.
|
|
5288
6049
|
const doRewind = (
|
|
@@ -5340,7 +6101,16 @@ export class AgentFramework {
|
|
|
5340
6101
|
);
|
|
5341
6102
|
}
|
|
5342
6103
|
|
|
5343
|
-
|
|
6104
|
+
// The reaction is the LAST resort: retries spent, rewind not
|
|
6105
|
+
// applicable. That makes it an honest signal to humans that the
|
|
6106
|
+
// border is close, rather than noise on every near-threshold
|
|
6107
|
+
// flip. (It must not re-enter any agent's context — the emoji
|
|
6108
|
+
// is suppressed at the surface via
|
|
6109
|
+
// DISCORD_SUPPRESS_REACTION_EMOJIS; without that, marking a
|
|
6110
|
+
// refusal feeds the next one.)
|
|
6111
|
+
if (!handledByRewind) {
|
|
6112
|
+
void this.reactToRefusal(agent.name, category);
|
|
6113
|
+
}
|
|
5344
6114
|
} else {
|
|
5345
6115
|
// A turn that completed WITHOUT a refusal ends the rewind episode:
|
|
5346
6116
|
// the model responded. Leave the consolidated marker in place as
|
|
@@ -5406,7 +6176,8 @@ export class AgentFramework {
|
|
|
5406
6176
|
`[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ?? '(none)'}`,
|
|
5407
6177
|
);
|
|
5408
6178
|
try {
|
|
5409
|
-
await this.channelRegistry.routeSpeech(agent.name, speechText, locus);
|
|
6179
|
+
const outcome = await this.channelRegistry.routeSpeech(agent.name, speechText, locus);
|
|
6180
|
+
this.recordProseDelivery(agent.name, outcome);
|
|
5410
6181
|
} catch (err) {
|
|
5411
6182
|
console.error('speech routing failed:', err);
|
|
5412
6183
|
}
|
|
@@ -5460,6 +6231,9 @@ export class AgentFramework {
|
|
|
5460
6231
|
`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> trailing prose NOT routed ` +
|
|
5461
6232
|
`(${silenced ? 'silencing tool / explicit send' : 'no trailing prose'})`,
|
|
5462
6233
|
);
|
|
6234
|
+
if (silenced && segments.length > 0) {
|
|
6235
|
+
this.recordProseSuppression(agent.name, segments.length);
|
|
6236
|
+
}
|
|
5463
6237
|
} else {
|
|
5464
6238
|
// Reuse the locus pinned at the turn's first live-routed
|
|
5465
6239
|
// segment (or resolve it now for a turn whose only prose is
|
|
@@ -5475,7 +6249,8 @@ export class AgentFramework {
|
|
|
5475
6249
|
// Deliver sequentially (await each) so the segments land in order.
|
|
5476
6250
|
for (const seg of segments) {
|
|
5477
6251
|
try {
|
|
5478
|
-
await this.channelRegistry.routeSpeech(agent.name, seg, locus);
|
|
6252
|
+
const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus);
|
|
6253
|
+
this.recordProseDelivery(agent.name, outcome);
|
|
5479
6254
|
} catch (err) {
|
|
5480
6255
|
console.error('speech routing failed:', err);
|
|
5481
6256
|
}
|
|
@@ -5486,6 +6261,15 @@ export class AgentFramework {
|
|
|
5486
6261
|
// BEFORE dispatchSpeech. Locus routing is speech dispatch and
|
|
5487
6262
|
// doesn't depend on the status field, so it correctly runs after.
|
|
5488
6263
|
|
|
6264
|
+
// Logical turn end (normal completion): drop the `[delivered]`
|
|
6265
|
+
// receipt. All deliveries are settled — the live chain was
|
|
6266
|
+
// awaited before trailing dispatch, and trailing/text-only
|
|
6267
|
+
// segments were awaited in-loop. Locus mode only; explicit-mode
|
|
6268
|
+
// envelopes acknowledge themselves through the prose gateway.
|
|
6269
|
+
if (agent.proseRouting !== 'explicit') {
|
|
6270
|
+
this.appendProseDeliveryReceipt(agent);
|
|
6271
|
+
}
|
|
6272
|
+
|
|
5489
6273
|
// Explicit-prose `!` continuation: a prose segment this turn asked
|
|
5490
6274
|
// to keep going (`>>#x !` / `>>private !`) — start another turn
|
|
5491
6275
|
// now instead of pausing until the next external event. This gives
|
|
@@ -5566,9 +6350,26 @@ export class AgentFramework {
|
|
|
5566
6350
|
// reset, no settle, no spurious inference:exhausted (which would
|
|
5567
6351
|
// reject an ephemeral's promise mid-run and bump the failure
|
|
5568
6352
|
// streak). Gate release + stream teardown happen in `finally`.
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
6353
|
+
{
|
|
6354
|
+
const cancelKey = `${agent.name}:${myStreamId}`;
|
|
6355
|
+
const cancelKind = this.frameworkCancelledStreams.get(cancelKey);
|
|
6356
|
+
if (cancelKind !== undefined) {
|
|
6357
|
+
this.frameworkCancelledStreams.delete(cancelKey);
|
|
6358
|
+
lifecyclePhase = 'aborted'; // §10.5 — terminal emitted in finally
|
|
6359
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6360
|
+
// endTurn IS a logical turn end — earlier rounds may have
|
|
6361
|
+
// live-routed prose (narrate → skip_reply is a real shape),
|
|
6362
|
+
// so settle the delivery chain and drop the receipt. A
|
|
6363
|
+
// budget restart continues the same logical turn: the
|
|
6364
|
+
// deliveries map persists (cleared only at fresh turn
|
|
6365
|
+
// start) and the continuation stream's end writes ONE
|
|
6366
|
+
// receipt for the whole turn.
|
|
6367
|
+
if (cancelKind === 'turn_ended' && agent.proseRouting !== 'explicit') {
|
|
6368
|
+
await turnSpeechChain;
|
|
6369
|
+
this.appendProseDeliveryReceipt(agent);
|
|
6370
|
+
}
|
|
6371
|
+
return;
|
|
6372
|
+
}
|
|
5572
6373
|
}
|
|
5573
6374
|
const reason = event.reason ?? 'unknown';
|
|
5574
6375
|
// Only reset if this is still the active stream (a budget restart
|
|
@@ -5609,6 +6410,11 @@ export class AgentFramework {
|
|
|
5609
6410
|
|
|
5610
6411
|
case 'usage': {
|
|
5611
6412
|
agent.lastStreamInputTokens = event.usage.inputTokens;
|
|
6413
|
+
agent.lastStreamRealInputTokens =
|
|
6414
|
+
(event.usage.inputTokens ?? 0) +
|
|
6415
|
+
(event.usage.cacheCreationTokens ?? 0) +
|
|
6416
|
+
(event.usage.cacheReadTokens ?? 0);
|
|
6417
|
+
agent.lastStreamOutputTokens = event.usage.outputTokens ?? 0;
|
|
5612
6418
|
|
|
5613
6419
|
// Closed-loop estimator calibration (2026-07-12). Sample the REAL
|
|
5614
6420
|
// prefix size of THIS API call (fresh + cache write + cache read)
|
|
@@ -5681,9 +6487,21 @@ export class AgentFramework {
|
|
|
5681
6487
|
durationMs,
|
|
5682
6488
|
});
|
|
5683
6489
|
this.abortAgentScript(agent.name, 'stream threw');
|
|
6490
|
+
lifecyclePhase = 'failed'; // §10.5 — terminal emitted in finally
|
|
5684
6491
|
agent.reset();
|
|
5685
6492
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
5686
6493
|
} finally {
|
|
6494
|
+
// §10.5: exactly one terminal per `started`, on every exit path the
|
|
6495
|
+
// host controls. Which one was decided by the path taken (default
|
|
6496
|
+
// completed; catch → failed; framework-cancel → aborted). A host
|
|
6497
|
+
// crash emits nothing — that is the documented best-effort limit, and
|
|
6498
|
+
// why consumers keep their safety timeout.
|
|
6499
|
+
this.hookOrchestrator?.emitLifecycle({
|
|
6500
|
+
inferenceId: outgoingInferenceId,
|
|
6501
|
+
conversationId: agent.name,
|
|
6502
|
+
turnIndex: 0,
|
|
6503
|
+
phase: lifecyclePhase,
|
|
6504
|
+
});
|
|
5687
6505
|
// Clear the gate's inference flag on EVERY exit path (paired with the
|
|
5688
6506
|
// onInferenceStarted in startAgentStream). The branch-level calls above
|
|
5689
6507
|
// are kept but are best-effort; if any exit path bypassed them the agent
|
|
@@ -5717,6 +6535,17 @@ export class AgentFramework {
|
|
|
5717
6535
|
this.disposeConversationAgent(agent.name);
|
|
5718
6536
|
}
|
|
5719
6537
|
|
|
6538
|
+
// The turn is torn down — release the turn-alive marker BEFORE the
|
|
6539
|
+
// deferred flush below, so the flush appends at the settled tail.
|
|
6540
|
+
// Token-matched: if a successor turn already owns the agent (endTurn
|
|
6541
|
+
// reset the state machine before this finally ran — the 2026-07-31
|
|
6542
|
+
// interleave), leave its marker alone; the guarded addMessage below
|
|
6543
|
+
// then re-defers, and the successor's own turn-start flush / boundary
|
|
6544
|
+
// injection / teardown delivers the messages at a correct position.
|
|
6545
|
+
if (myTurnToken !== undefined && this.activeTurnTokens.get(agent.name) === myTurnToken) {
|
|
6546
|
+
this.activeTurnTokens.delete(agent.name);
|
|
6547
|
+
}
|
|
6548
|
+
|
|
5720
6549
|
// Flush any deferred messages (e.g. if stream failed while tools were pending)
|
|
5721
6550
|
if (this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
|
|
5722
6551
|
const deferred = this.deferredMessages.splice(0);
|
|
@@ -5731,8 +6560,23 @@ export class AgentFramework {
|
|
|
5731
6560
|
* Execute a tool call and return the result.
|
|
5732
6561
|
* Routes to the appropriate handler (module registry or MCPL).
|
|
5733
6562
|
* Used by SubagentModule to dispatch tool calls for ephemeral agents.
|
|
6563
|
+
*
|
|
6564
|
+
* This public entry always carries AGENT origin. Ephemeral/promise-based
|
|
6565
|
+
* callers are models, and provenance must identify which internal path a
|
|
6566
|
+
* call entered through — a public method shared with model callers can
|
|
6567
|
+
* never confer module trust, whatever the input claims. Module origin
|
|
6568
|
+
* exists only through the private closure handed to ModuleRegistry
|
|
6569
|
+
* (ctx.callTool → executeToolCallFrom with {kind:'module'}). Callers that
|
|
6570
|
+
* don't identify themselves fail safe to agent semantics too.
|
|
5734
6571
|
*/
|
|
5735
6572
|
async executeToolCall(call: ToolCall): Promise<ToolResult> {
|
|
6573
|
+
return this.executeToolCallFrom(call, {
|
|
6574
|
+
kind: 'agent',
|
|
6575
|
+
agentName: call.callerAgentName ?? '__ephemeral__',
|
|
6576
|
+
});
|
|
6577
|
+
}
|
|
6578
|
+
|
|
6579
|
+
private async executeToolCallFrom(call: ToolCall, origin: ChannelToolOrigin): Promise<ToolResult> {
|
|
5736
6580
|
// Client-side programmatic tool calling for promise-based callers
|
|
5737
6581
|
// (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral
|
|
5738
6582
|
// gets its own interpreter state.
|
|
@@ -5753,6 +6597,27 @@ export class AgentFramework {
|
|
|
5753
6597
|
}
|
|
5754
6598
|
}
|
|
5755
6599
|
|
|
6600
|
+
// Synthesized channel tools. This route was missing: when
|
|
6601
|
+
// subscription-GC moved off the retired MCPL unsubscribe tool onto the
|
|
6602
|
+
// generic channel_close (discord-mcpl b095a9f), module-originated
|
|
6603
|
+
// closes started failing downstream as "Invalid tool name format" —
|
|
6604
|
+
// the janitor had been silently broken since. `origin` is trusted
|
|
6605
|
+
// dispatch context established at the call boundary above, not
|
|
6606
|
+
// inferred from which method was used: only the ModuleRegistry closure
|
|
6607
|
+
// carries module provenance (see handleToolClose). The agent-only
|
|
6608
|
+
// synthesized verbs (think, skip_reply) are deliberately not routed
|
|
6609
|
+
// here.
|
|
6610
|
+
if (call.name.startsWith('channel_') && this.channelRegistry) {
|
|
6611
|
+
return this.channelRegistry.handleChannelToolCall(call.name, call.input, origin);
|
|
6612
|
+
}
|
|
6613
|
+
|
|
6614
|
+
// The utils meta-tool — programmatic callers (code_execution scripts,
|
|
6615
|
+
// ephemerals, ModuleContext.callTool) reach utilities the same way the
|
|
6616
|
+
// model does.
|
|
6617
|
+
if (call.name === 'utils') {
|
|
6618
|
+
return this.handleUtilsToolCall(call.callerAgentName ?? '__ephemeral__', call);
|
|
6619
|
+
}
|
|
6620
|
+
|
|
5756
6621
|
// Module tools
|
|
5757
6622
|
return this.moduleRegistry.handleToolCall(call);
|
|
5758
6623
|
}
|
|
@@ -5849,7 +6714,51 @@ export class AgentFramework {
|
|
|
5849
6714
|
* results reach the running script and never the model context.
|
|
5850
6715
|
*/
|
|
5851
6716
|
private async runCodeExecution(agentName: string, call: ToolCall): Promise<ToolResult> {
|
|
5852
|
-
const input = (call.input ?? {}) as {
|
|
6717
|
+
const input = (call.input ?? {}) as {
|
|
6718
|
+
code?: unknown;
|
|
6719
|
+
background?: unknown;
|
|
6720
|
+
action?: unknown;
|
|
6721
|
+
script_id?: unknown;
|
|
6722
|
+
};
|
|
6723
|
+
|
|
6724
|
+
// Management surface: the agent's own daemon fleet is inspectable and
|
|
6725
|
+
// killable through the same tool that spawns it.
|
|
6726
|
+
if (input.action === 'list') {
|
|
6727
|
+
const scripts = [...this.backgroundScripts.values()]
|
|
6728
|
+
.filter((s) => s.agentName === agentName)
|
|
6729
|
+
.map((s) => ({
|
|
6730
|
+
script_id: s.id,
|
|
6731
|
+
status: s.status,
|
|
6732
|
+
started_at: new Date(s.startedAt).toISOString(),
|
|
6733
|
+
wakes: s.wakes,
|
|
6734
|
+
last_wake_at: s.lastWakeAt ? new Date(s.lastWakeAt).toISOString() : null,
|
|
6735
|
+
log: s.logPath,
|
|
6736
|
+
}));
|
|
6737
|
+
return { success: true, data: { background_scripts: scripts } };
|
|
6738
|
+
}
|
|
6739
|
+
if (input.action === 'cancel') {
|
|
6740
|
+
if (typeof input.script_id !== 'string') {
|
|
6741
|
+
return { success: false, error: 'cancel requires `script_id`', isError: true };
|
|
6742
|
+
}
|
|
6743
|
+
const record = this.backgroundScripts.get(input.script_id);
|
|
6744
|
+
if (!record || record.agentName !== agentName) {
|
|
6745
|
+
return { success: false, error: `no background script '${input.script_id}'`, isError: true };
|
|
6746
|
+
}
|
|
6747
|
+
if (record.status === 'running') {
|
|
6748
|
+
record.cancelled = true;
|
|
6749
|
+
record.status = 'cancelled';
|
|
6750
|
+
record.runner.abort('cancelled by agent');
|
|
6751
|
+
record.runner.dispose();
|
|
6752
|
+
}
|
|
6753
|
+
return {
|
|
6754
|
+
success: true,
|
|
6755
|
+
data: { script_id: record.id, status: record.status, log: record.logPath },
|
|
6756
|
+
};
|
|
6757
|
+
}
|
|
6758
|
+
if (input.action !== undefined && input.action !== 'run') {
|
|
6759
|
+
return { success: false, error: `unknown action ${JSON.stringify(input.action)}`, isError: true };
|
|
6760
|
+
}
|
|
6761
|
+
|
|
5853
6762
|
if (typeof input.code !== 'string' || input.code.trim() === '') {
|
|
5854
6763
|
return {
|
|
5855
6764
|
success: false,
|
|
@@ -5866,6 +6775,10 @@ export class AgentFramework {
|
|
|
5866
6775
|
surface.map((t) => t.name).filter((name) => name !== CODE_EXECUTION_TOOL_NAME),
|
|
5867
6776
|
);
|
|
5868
6777
|
|
|
6778
|
+
if (input.background === true) {
|
|
6779
|
+
return this.startBackgroundScript(agentName, input.code, injected);
|
|
6780
|
+
}
|
|
6781
|
+
|
|
5869
6782
|
const runner = this.getOrCreateScriptRunner(agentName);
|
|
5870
6783
|
this.scriptDeferredEndTurn.delete(agentName);
|
|
5871
6784
|
const exec = await runner.exec(input.code, injected);
|
|
@@ -5879,6 +6792,351 @@ export class AgentFramework {
|
|
|
5879
6792
|
};
|
|
5880
6793
|
}
|
|
5881
6794
|
|
|
6795
|
+
// ---------------------------------------------------------------------------
|
|
6796
|
+
// Background (daemon) scripts — model-authored watchers that outlive the turn
|
|
6797
|
+
// ---------------------------------------------------------------------------
|
|
6798
|
+
|
|
6799
|
+
/**
|
|
6800
|
+
* Start a background script: dedicated interpreter, output journaled to a
|
|
6801
|
+
* workspace file, wake_agent() available in-script. The tool result
|
|
6802
|
+
* returns immediately; the script runs until it ends, hits the lifetime
|
|
6803
|
+
* ceiling, or is cancelled. Scripts DIE WITH THE HOST PROCESS — that
|
|
6804
|
+
* limitation is stated in the tool description so agents can calibrate
|
|
6805
|
+
* how much to rely on an armed wake.
|
|
6806
|
+
*/
|
|
6807
|
+
private startBackgroundScript(
|
|
6808
|
+
agentName: string,
|
|
6809
|
+
code: string,
|
|
6810
|
+
injected: import('./code-execution/py-runner.js').InjectedTool[],
|
|
6811
|
+
): ToolResult {
|
|
6812
|
+
// v1: primary-agent-only. A conversation fork's or ephemeral's daemon
|
|
6813
|
+
// would outlive its owner and its wake would land in the primary
|
|
6814
|
+
// conversation anyway — refuse loudly instead of surprising anyone.
|
|
6815
|
+
if (this.primaryAgentName && agentName !== this.primaryAgentName) {
|
|
6816
|
+
return {
|
|
6817
|
+
success: false,
|
|
6818
|
+
error: 'background scripts are available to the primary agent only',
|
|
6819
|
+
isError: true,
|
|
6820
|
+
};
|
|
6821
|
+
}
|
|
6822
|
+
const cfg = this.codeExecutionConfig;
|
|
6823
|
+
const maxScripts = cfg?.maxBackgroundScripts ?? 3;
|
|
6824
|
+
const runningCount = [...this.backgroundScripts.values()]
|
|
6825
|
+
.filter((s) => s.agentName === agentName && s.status === 'running').length;
|
|
6826
|
+
if (runningCount >= maxScripts) {
|
|
6827
|
+
return {
|
|
6828
|
+
success: false,
|
|
6829
|
+
error: `background script limit reached (${maxScripts} running). ` +
|
|
6830
|
+
'Use code_execution {"action": "list"} to inspect and {"action": "cancel", "script_id": ...} to free a slot.',
|
|
6831
|
+
isError: true,
|
|
6832
|
+
};
|
|
6833
|
+
}
|
|
6834
|
+
|
|
6835
|
+
const scriptId = `bg-${++this.backgroundScriptCounter}`;
|
|
6836
|
+
const lifetimeMs = cfg?.backgroundMaxLifetimeMs ?? 86_400_000;
|
|
6837
|
+
|
|
6838
|
+
// Journal: a file under the agent's first read-write workspace mount so
|
|
6839
|
+
// their existing read/grep/shell tools work on it. Python appends
|
|
6840
|
+
// directly to the materialized path; watch-mode mounts sync it back to
|
|
6841
|
+
// the chronicle tree on their usual cadence.
|
|
6842
|
+
let logPath: string | null = null;
|
|
6843
|
+
let logAbsPath: string | null = null;
|
|
6844
|
+
const workspace = this.getWorkspaceModule();
|
|
6845
|
+
if (workspace) {
|
|
6846
|
+
const mountName = this.firstWritableMountName(workspace);
|
|
6847
|
+
if (mountName) {
|
|
6848
|
+
logPath = `${mountName}/background-scripts/${scriptId}.log`;
|
|
6849
|
+
logAbsPath = workspace.resolveAbsolutePath(logPath);
|
|
6850
|
+
}
|
|
6851
|
+
}
|
|
6852
|
+
|
|
6853
|
+
const runner = new PyRunner({
|
|
6854
|
+
pythonPath: cfg?.pythonPath,
|
|
6855
|
+
toolCallTimeoutMs: cfg?.toolCallTimeoutMs,
|
|
6856
|
+
scriptTimeoutMs: cfg?.scriptTimeoutMs,
|
|
6857
|
+
idleReclaimMs: 0, // dedicated runner; lifetime is the exec deadline
|
|
6858
|
+
label: `${agentName}:${scriptId}`,
|
|
6859
|
+
onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args),
|
|
6860
|
+
});
|
|
6861
|
+
|
|
6862
|
+
const record: BackgroundScriptRecord = {
|
|
6863
|
+
id: scriptId,
|
|
6864
|
+
agentName,
|
|
6865
|
+
runner,
|
|
6866
|
+
code,
|
|
6867
|
+
startedAt: Date.now(),
|
|
6868
|
+
wakes: 0,
|
|
6869
|
+
lastWakeAt: null,
|
|
6870
|
+
logPath,
|
|
6871
|
+
status: 'running',
|
|
6872
|
+
cancelled: false,
|
|
6873
|
+
};
|
|
6874
|
+
this.backgroundScripts.set(scriptId, record);
|
|
6875
|
+
this.emitTrace({
|
|
6876
|
+
type: 'tool:started', module: 'code_execution', tool: `background:${scriptId}`,
|
|
6877
|
+
callId: scriptId, input: { lines: code.split('\n').length },
|
|
6878
|
+
});
|
|
6879
|
+
|
|
6880
|
+
void runner
|
|
6881
|
+
.exec(code, injected, {
|
|
6882
|
+
logPath: logAbsPath,
|
|
6883
|
+
lifetimeMs,
|
|
6884
|
+
onWake: (line, payload) => this.handleScriptWake(record, line, payload),
|
|
6885
|
+
})
|
|
6886
|
+
.then((exec) => this.settleBackgroundScript(record, exec))
|
|
6887
|
+
.catch((err) => {
|
|
6888
|
+
// exec never rejects by contract; this is the belt-and-suspenders.
|
|
6889
|
+
console.error(`[pytc:${agentName}:${scriptId}] background exec rejected: ${String(err)}`);
|
|
6890
|
+
this.settleBackgroundScript(record, {
|
|
6891
|
+
stdout: '', stderr: String(err), returnCode: 1, aborted: true,
|
|
6892
|
+
});
|
|
6893
|
+
});
|
|
6894
|
+
|
|
6895
|
+
return {
|
|
6896
|
+
success: true,
|
|
6897
|
+
data: {
|
|
6898
|
+
status: 'background script started',
|
|
6899
|
+
script_id: scriptId,
|
|
6900
|
+
log: logPath ?? 'no writable workspace mount — output is not retrievable; only wake_agent() reaches you',
|
|
6901
|
+
lifetime_hours: Math.round(lifetimeMs / 3_600_000 * 10) / 10,
|
|
6902
|
+
note: 'The script dies if the host process restarts. Manage with code_execution {"action": "list"|"cancel"}.',
|
|
6903
|
+
},
|
|
6904
|
+
};
|
|
6905
|
+
}
|
|
6906
|
+
|
|
6907
|
+
/** First read-write mount name, or null. */
|
|
6908
|
+
private firstWritableMountName(workspace: WorkspaceModule): string | null {
|
|
6909
|
+
const mounts = (workspace as unknown as {
|
|
6910
|
+
getMounts?: () => Array<{ name: string; mode: string }>;
|
|
6911
|
+
}).getMounts?.();
|
|
6912
|
+
if (mounts) {
|
|
6913
|
+
const rw = mounts.find((m) => m.mode !== 'read-only');
|
|
6914
|
+
return rw?.name ?? null;
|
|
6915
|
+
}
|
|
6916
|
+
return null;
|
|
6917
|
+
}
|
|
6918
|
+
|
|
6919
|
+
/**
|
|
6920
|
+
* wake_agent() from a background script: enforce the per-script wake cap
|
|
6921
|
+
* and rate floor (delay, not drop — the script awaits the ack), then
|
|
6922
|
+
* inject the provenance envelope + payload and request inference.
|
|
6923
|
+
* Resolves null on delivery; an error string refuses the wake (raises
|
|
6924
|
+
* RuntimeError inside the script).
|
|
6925
|
+
*/
|
|
6926
|
+
private async handleScriptWake(
|
|
6927
|
+
record: BackgroundScriptRecord,
|
|
6928
|
+
line: number,
|
|
6929
|
+
payload: unknown,
|
|
6930
|
+
): Promise<string | null> {
|
|
6931
|
+
if (record.cancelled) return 'script was cancelled';
|
|
6932
|
+
const cfg = this.codeExecutionConfig;
|
|
6933
|
+
const maxWakes = cfg?.maxWakesPerScript ?? 100;
|
|
6934
|
+
if (record.wakes >= maxWakes) {
|
|
6935
|
+
return `wake limit reached (${maxWakes} per script)`;
|
|
6936
|
+
}
|
|
6937
|
+
const floorMs = cfg?.wakeMinIntervalMs ?? 60_000;
|
|
6938
|
+
if (record.lastWakeAt !== null) {
|
|
6939
|
+
const wait = record.lastWakeAt + floorMs - Date.now();
|
|
6940
|
+
if (wait > 0) {
|
|
6941
|
+
await new Promise((r) => {
|
|
6942
|
+
const t = setTimeout(r, wait);
|
|
6943
|
+
(t as { unref?: () => void }).unref?.();
|
|
6944
|
+
});
|
|
6945
|
+
if (record.cancelled) return 'script was cancelled';
|
|
6946
|
+
}
|
|
6947
|
+
}
|
|
6948
|
+
record.wakes += 1;
|
|
6949
|
+
record.lastWakeAt = Date.now();
|
|
6950
|
+
|
|
6951
|
+
const elapsedMin = Math.round((Date.now() - record.startedAt) / 60_000);
|
|
6952
|
+
const payloadJson = payload === undefined || payload === null
|
|
6953
|
+
? 'null'
|
|
6954
|
+
: JSON.stringify(payload, null, 1) ?? String(payload);
|
|
6955
|
+
const agent = this.agents.get(record.agentName);
|
|
6956
|
+
// Same resolved cap as tool results (override applies here too); an
|
|
6957
|
+
// unknown agent still gets the bounded house default, never unbounded.
|
|
6958
|
+
const maxChars = agent
|
|
6959
|
+
? this.resolveToolResultInlineCap(agent).cap
|
|
6960
|
+
: DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS;
|
|
6961
|
+
const { text: payloadText } = await this.spillOrTruncate(
|
|
6962
|
+
payloadJson, maxChars, `${record.id}-wake${record.wakes}`,
|
|
6963
|
+
);
|
|
6964
|
+
|
|
6965
|
+
const envelope =
|
|
6966
|
+
`[background script ${record.id}] Woke you: wake_agent() called at line ${line} of your script, ` +
|
|
6967
|
+
`${elapsedMin}m after you started it (wake ${record.wakes} of ${maxWakes}).\n` +
|
|
6968
|
+
`Arguments:\n${payloadText}\n` +
|
|
6969
|
+
(record.logPath ? `Script output so far: workspace file ${record.logPath}\n` : '') +
|
|
6970
|
+
`Script status: still running`;
|
|
6971
|
+
|
|
6972
|
+
this.injectScriptWake(record, envelope);
|
|
6973
|
+
return null;
|
|
6974
|
+
}
|
|
6975
|
+
|
|
6976
|
+
/** Background script ended (clean, crash, deadline, cancel/dispose). */
|
|
6977
|
+
private settleBackgroundScript(
|
|
6978
|
+
record: BackgroundScriptRecord,
|
|
6979
|
+
exec: import('./code-execution/py-runner.js').ExecResult,
|
|
6980
|
+
): void {
|
|
6981
|
+
record.runner.dispose();
|
|
6982
|
+
if (record.status === 'running') {
|
|
6983
|
+
record.status = exec.returnCode === 0 ? 'finished' : 'died';
|
|
6984
|
+
}
|
|
6985
|
+
this.emitTrace({
|
|
6986
|
+
type: 'tool:completed', module: 'code_execution', tool: `background:${record.id}`,
|
|
6987
|
+
callId: record.id, durationMs: Date.now() - record.startedAt,
|
|
6988
|
+
});
|
|
6989
|
+
// Crash honesty: a resident sleeping on a promise must never have that
|
|
6990
|
+
// promise die silently. Deliberate cancels/disposes stay silent (the
|
|
6991
|
+
// agent or host chose them); clean exits stay silent (the null path).
|
|
6992
|
+
if (record.status === 'died' && !record.cancelled) {
|
|
6993
|
+
const tail = (exec.tail ?? exec.stderr ?? '').slice(-2000);
|
|
6994
|
+
const elapsedMin = Math.round((Date.now() - record.startedAt) / 60_000);
|
|
6995
|
+
const envelope =
|
|
6996
|
+
`[background script ${record.id}] Your background script DIED ${elapsedMin}m after start ` +
|
|
6997
|
+
`(it did not call wake_agent and is no longer watching).\n` +
|
|
6998
|
+
`Last output:\n${tail || '(none)'}\n` +
|
|
6999
|
+
(record.logPath ? `Full journal: workspace file ${record.logPath}` : '');
|
|
7000
|
+
this.injectScriptWake(record, envelope);
|
|
7001
|
+
}
|
|
7002
|
+
// Keep a short memory of settled scripts for {"action":"list"}, then drop.
|
|
7003
|
+
const settled = [...this.backgroundScripts.values()]
|
|
7004
|
+
.filter((s) => s.agentName === record.agentName && s.status !== 'running');
|
|
7005
|
+
for (const old of settled.slice(0, Math.max(0, settled.length - 5))) {
|
|
7006
|
+
this.backgroundScripts.delete(old.id);
|
|
7007
|
+
}
|
|
7008
|
+
}
|
|
7009
|
+
|
|
7010
|
+
/**
|
|
7011
|
+
* Deliver a background-script event: provenance envelope into the agent's
|
|
7012
|
+
* context + an inference request. Deliberately does NOT consult the event
|
|
7013
|
+
* gate — the agent armed this wake themselves (wake-through-sleep
|
|
7014
|
+
* authority); it enters tagged so gate policies could be taught about it
|
|
7015
|
+
* later if that ever needs revisiting.
|
|
7016
|
+
*/
|
|
7017
|
+
private injectScriptWake(record: BackgroundScriptRecord, envelope: string): void {
|
|
7018
|
+
try {
|
|
7019
|
+
const messageId = this.addMessage('user', [{ type: 'text', text: envelope }], {
|
|
7020
|
+
source: 'background-script',
|
|
7021
|
+
scriptId: record.id,
|
|
7022
|
+
tags: ['script:wake'],
|
|
7023
|
+
});
|
|
7024
|
+
this.pendingRequests.push({
|
|
7025
|
+
agentName: record.agentName,
|
|
7026
|
+
reason: 'script:wake',
|
|
7027
|
+
source: 'code_execution',
|
|
7028
|
+
timestamp: Date.now(),
|
|
7029
|
+
});
|
|
7030
|
+
this.emitTrace({
|
|
7031
|
+
type: 'message:added',
|
|
7032
|
+
messageId,
|
|
7033
|
+
source: `background-script:${record.id}`,
|
|
7034
|
+
});
|
|
7035
|
+
} catch (err) {
|
|
7036
|
+
console.error(
|
|
7037
|
+
`[pytc:${record.agentName}:${record.id}] wake injection failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
7038
|
+
);
|
|
7039
|
+
}
|
|
7040
|
+
}
|
|
7041
|
+
|
|
7042
|
+
/**
|
|
7043
|
+
* Oversized content policy (antra, 2026-07-31; completed for issue #89):
|
|
7044
|
+
* never hit the agent in the face with a huge blob, never silently destroy
|
|
7045
|
+
* it either. Content over the inline cap is materialized to a workspace
|
|
7046
|
+
* file (readable with the agent's own tools — deterministic name, kept
|
|
7047
|
+
* until the workspace owner deletes it) and replaced by a truncated head +
|
|
7048
|
+
* a trailing file reference. Falls back to EXPLICIT plain truncation when
|
|
7049
|
+
* there is no writable workspace. Cap resolution lives in
|
|
7050
|
+
* resolveToolResultInlineCap; callers pass the resolved cap.
|
|
7051
|
+
*/
|
|
7052
|
+
/**
|
|
7053
|
+
* One spill policy for every stored tool_result block: serialize (history
|
|
7054
|
+
* serializer — image blocks become placeholders), then spill/truncate at
|
|
7055
|
+
* the resolved cap. Errors follow the same policy as successes — a giant
|
|
7056
|
+
* error string is exactly as context-hostile as a giant success (issue
|
|
7057
|
+
* #89). Returns the stored blocks plus the per-call spill outcomes so the
|
|
7058
|
+
* live wire copy can reuse the identical strings.
|
|
7059
|
+
*/
|
|
7060
|
+
private async buildStoredToolResultContent(
|
|
7061
|
+
toolResults: CompletedToolCall[],
|
|
7062
|
+
maxChars: number | undefined,
|
|
7063
|
+
): Promise<{
|
|
7064
|
+
blocks: ContentBlock[];
|
|
7065
|
+
spilled: Map<string, { text: string; filePath: string | null }>;
|
|
7066
|
+
}> {
|
|
7067
|
+
const spilled = new Map<string, { text: string; filePath: string | null }>();
|
|
7068
|
+
const dateLabel = new Date().toISOString().slice(0, 10);
|
|
7069
|
+
for (const tc of toolResults) {
|
|
7070
|
+
const raw = tc.result.isError
|
|
7071
|
+
? tc.result.error ?? 'Unknown error'
|
|
7072
|
+
: toolResultDataToHistoryString(tc.result.data, undefined);
|
|
7073
|
+
spilled.set(tc.id, await this.spillOrTruncate(raw, maxChars, `${dateLabel}-${tc.id}`));
|
|
7074
|
+
}
|
|
7075
|
+
const blocks: ContentBlock[] = toolResults.map(tc => ({
|
|
7076
|
+
type: 'tool_result' as const,
|
|
7077
|
+
toolUseId: tc.id,
|
|
7078
|
+
content: spilled.get(tc.id)!.text,
|
|
7079
|
+
isError: tc.result.isError,
|
|
7080
|
+
}));
|
|
7081
|
+
return { blocks, spilled };
|
|
7082
|
+
}
|
|
7083
|
+
|
|
7084
|
+
private async spillOrTruncate(
|
|
7085
|
+
content: string,
|
|
7086
|
+
cap: number | undefined,
|
|
7087
|
+
label: string,
|
|
7088
|
+
): Promise<{ text: string; filePath: string | null }> {
|
|
7089
|
+
if (!cap || content.length <= cap) return { text: content, filePath: null };
|
|
7090
|
+
|
|
7091
|
+
const workspace = this.getWorkspaceModule();
|
|
7092
|
+
const mountName = workspace ? this.firstWritableMountName(workspace) : null;
|
|
7093
|
+
if (workspace && mountName) {
|
|
7094
|
+
const safeLabel = label.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
|
|
7095
|
+
const path = `${mountName}/tool-results/${safeLabel}.txt`;
|
|
7096
|
+
let failure: string;
|
|
7097
|
+
try {
|
|
7098
|
+
const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain');
|
|
7099
|
+
if (result.success) {
|
|
7100
|
+
return {
|
|
7101
|
+
text: safeSlice(content, 0, cap)
|
|
7102
|
+
+ `\n\n[truncated — showing ${cap} of ${content.length} chars; full content: workspace file ${path}. `
|
|
7103
|
+
+ 'Read/grep it with your file tools, or raise the inline cap via '
|
|
7104
|
+
+ 'agent_settings update tool_result_inline_max_chars.]',
|
|
7105
|
+
filePath: path,
|
|
7106
|
+
};
|
|
7107
|
+
}
|
|
7108
|
+
failure = result.error ?? 'write refused';
|
|
7109
|
+
} catch (err) {
|
|
7110
|
+
failure = err instanceof Error ? err.message : String(err);
|
|
7111
|
+
}
|
|
7112
|
+
// A workspace was there and the write FAILED — say that, loudly and
|
|
7113
|
+
// distinctly. Telling the agent "no writable workspace" would teach
|
|
7114
|
+
// them their residence lacks a capability it actually has.
|
|
7115
|
+
failure = failure.slice(0, 200);
|
|
7116
|
+
this.emitTrace({
|
|
7117
|
+
type: 'tool:spill_failed',
|
|
7118
|
+
label: safeLabel,
|
|
7119
|
+
path,
|
|
7120
|
+
contentLength: content.length,
|
|
7121
|
+
error: failure,
|
|
7122
|
+
});
|
|
7123
|
+
console.error(
|
|
7124
|
+
`[spill] workspace write failed for ${path} (${content.length} chars): ${failure}`,
|
|
7125
|
+
);
|
|
7126
|
+
return {
|
|
7127
|
+
text: safeSlice(content, 0, cap)
|
|
7128
|
+
+ `\n\n[truncated — showing ${cap} of ${content.length} chars; spill to workspace file ${path} FAILED`
|
|
7129
|
+
+ ` (${failure}); content over the cap was not retained]`,
|
|
7130
|
+
filePath: null,
|
|
7131
|
+
};
|
|
7132
|
+
}
|
|
7133
|
+
return {
|
|
7134
|
+
text: safeSlice(content, 0, cap)
|
|
7135
|
+
+ '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace, full content not retained]',
|
|
7136
|
+
filePath: null,
|
|
7137
|
+
};
|
|
7138
|
+
}
|
|
7139
|
+
|
|
5882
7140
|
private getOrCreateScriptRunner(agentName: string): PyRunner {
|
|
5883
7141
|
let runner = this.codeExecutionRunners.get(agentName);
|
|
5884
7142
|
if (!runner) {
|
|
@@ -5924,9 +7182,10 @@ export class AgentFramework {
|
|
|
5924
7182
|
return `Error: ${result.error ?? 'tool call failed'}`;
|
|
5925
7183
|
}
|
|
5926
7184
|
if (result.data === undefined) return '';
|
|
5927
|
-
|
|
5928
|
-
|
|
5929
|
-
|
|
7185
|
+
// Scripts get (nearly) full data — the script environment IS the spill:
|
|
7186
|
+
// filtering big results in code is the whole point. 5MB protocol safety
|
|
7187
|
+
// cap only; no context-cap truncation, no file spill.
|
|
7188
|
+
return toolResultDataToHistoryString(result.data, 5_000_000);
|
|
5930
7189
|
}
|
|
5931
7190
|
|
|
5932
7191
|
/**
|
|
@@ -5982,18 +7241,35 @@ export class AgentFramework {
|
|
|
5982
7241
|
}
|
|
5983
7242
|
}
|
|
5984
7243
|
|
|
5985
|
-
private toMembraneToolResult(
|
|
7244
|
+
private toMembraneToolResult(
|
|
7245
|
+
callId: string,
|
|
7246
|
+
afResult: ToolResult,
|
|
7247
|
+
maxChars?: number,
|
|
7248
|
+
/** Pre-spilled outcome from the history path — used for the non-image
|
|
7249
|
+
* path so the live wire copy byte-matches what the window stored. */
|
|
7250
|
+
precomputed?: { text: string; filePath: string | null },
|
|
7251
|
+
): MembraneToolResult {
|
|
5986
7252
|
if (afResult.isError) {
|
|
5987
|
-
|
|
7253
|
+
// Same spill policy as successes: reuse the history copy so a giant
|
|
7254
|
+
// error string stays bounded on the wire too (byte-matched).
|
|
7255
|
+
return {
|
|
7256
|
+
toolUseId: callId,
|
|
7257
|
+
content: precomputed?.text ?? truncateForHistory(
|
|
7258
|
+
afResult.error ?? 'Unknown error', DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS),
|
|
7259
|
+
isError: true,
|
|
7260
|
+
};
|
|
5988
7261
|
}
|
|
5989
7262
|
// MCPL tool results arrive as `data: McpToolResultContent[]` — preserve image
|
|
5990
7263
|
// blocks natively rather than JSON-stringifying them away. Anything else
|
|
5991
7264
|
// (objects, scalars) falls through to JSON. The error path was handled
|
|
5992
7265
|
// above, so isError is always false on these return paths.
|
|
5993
|
-
const blocks = this.tryNativeToolResultContent(afResult.data, maxChars);
|
|
7266
|
+
const blocks = this.tryNativeToolResultContent(afResult.data, maxChars, precomputed?.filePath ?? null);
|
|
5994
7267
|
if (blocks) {
|
|
5995
7268
|
return { toolUseId: callId, content: blocks, isError: false };
|
|
5996
7269
|
}
|
|
7270
|
+
if (precomputed !== undefined) {
|
|
7271
|
+
return { toolUseId: callId, content: precomputed.text, isError: false };
|
|
7272
|
+
}
|
|
5997
7273
|
// JSON.stringify returns the VALUE undefined (not a string) for undefined
|
|
5998
7274
|
// input — a module tool returning `{ success: true }` with no data would
|
|
5999
7275
|
// otherwise make `content.length` below throw a TypeError mid-turn.
|
|
@@ -6012,7 +7288,13 @@ export class AgentFramework {
|
|
|
6012
7288
|
* `maxChars`, when provided, caps each accompanying text block so an image
|
|
6013
7289
|
* inlined alongside an enormous text payload can't blow the context.
|
|
6014
7290
|
*/
|
|
6015
|
-
private tryNativeToolResultContent(
|
|
7291
|
+
private tryNativeToolResultContent(
|
|
7292
|
+
data: unknown,
|
|
7293
|
+
maxChars?: number,
|
|
7294
|
+
/** Spill file written by the history path — referenced from truncation
|
|
7295
|
+
* notices so an image-adjacent giant text block stays recoverable. */
|
|
7296
|
+
spillPath: string | null = null,
|
|
7297
|
+
): ToolResultContentBlock[] | null {
|
|
6016
7298
|
if (!Array.isArray(data)) return null;
|
|
6017
7299
|
let hasImage = false;
|
|
6018
7300
|
const blocks: ToolResultContentBlock[] = [];
|
|
@@ -6023,7 +7305,9 @@ export class AgentFramework {
|
|
|
6023
7305
|
let text = b.text;
|
|
6024
7306
|
if (maxChars && text.length > maxChars) {
|
|
6025
7307
|
text = safeSlice(text, 0, maxChars)
|
|
6026
|
-
+ '\n\n[truncated — original was ' + text.length + ' chars
|
|
7308
|
+
+ '\n\n[truncated — original was ' + text.length + ' chars'
|
|
7309
|
+
+ (spillPath ? `; full serialized result: workspace file ${spillPath}` : '')
|
|
7310
|
+
+ ']';
|
|
6027
7311
|
}
|
|
6028
7312
|
blocks.push({ type: 'text', text });
|
|
6029
7313
|
} else if (b.type === 'image' && typeof b.data === 'string' && typeof b.mimeType === 'string') {
|
|
@@ -6039,13 +7323,45 @@ export class AgentFramework {
|
|
|
6039
7323
|
return hasImage ? blocks : null;
|
|
6040
7324
|
}
|
|
6041
7325
|
|
|
6042
|
-
|
|
7326
|
+
/** Strategy-derived per-message bound (maxMessageTokens * 4 chars). */
|
|
7327
|
+
private strategyDerivedToolResultChars(agent: Agent): number | undefined {
|
|
6043
7328
|
const strategy = agent.getContextManager().getStrategy();
|
|
6044
7329
|
const maxTokens = strategy.maxMessageTokens;
|
|
6045
7330
|
if (maxTokens && maxTokens > 0) return maxTokens * 4;
|
|
6046
7331
|
return undefined;
|
|
6047
7332
|
}
|
|
6048
7333
|
|
|
7334
|
+
/**
|
|
7335
|
+
* Effective tool-result inline cap for an agent, with provenance. Desired
|
|
7336
|
+
* value: resident's durable agent_settings value → residence
|
|
7337
|
+
* FrameworkConfig.toolResultInlineMaxChars → house default (5000).
|
|
7338
|
+
* Effective value: min(desired, strategy bound) for EVERY source — the
|
|
7339
|
+
* strategy's per-message safety limit is a ceiling, not a suggestion
|
|
7340
|
+
* (Sol's #94 ruling: a durable preference must not be a durable path for
|
|
7341
|
+
* one tool result to exceed maxMessageTokens; the way to see the whole
|
|
7342
|
+
* result is the spill file, not an over-bound blob in live context).
|
|
7343
|
+
*/
|
|
7344
|
+
private resolveToolResultInlineCap(agent: Agent): {
|
|
7345
|
+
cap: number;
|
|
7346
|
+
source: 'agent-settings-override' | 'framework-config' | 'default';
|
|
7347
|
+
/** Set when the desired value was reduced to the strategy bound. */
|
|
7348
|
+
clampedBy: 'strategy-bound' | null;
|
|
7349
|
+
} {
|
|
7350
|
+
const override = this.toolResultInlineMaxCharsOverride.get(agent.name);
|
|
7351
|
+
const configured = this.toolResultInlineMaxCharsConfig;
|
|
7352
|
+
const desired = override ?? configured ?? DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS;
|
|
7353
|
+
const source = override !== undefined
|
|
7354
|
+
? 'agent-settings-override' as const
|
|
7355
|
+
: configured !== null ? 'framework-config' as const : 'default' as const;
|
|
7356
|
+
const strategyBound = this.strategyDerivedToolResultChars(agent);
|
|
7357
|
+
const clamped = strategyBound !== undefined && strategyBound < desired;
|
|
7358
|
+
return {
|
|
7359
|
+
cap: clamped ? strategyBound : desired,
|
|
7360
|
+
source,
|
|
7361
|
+
clampedBy: clamped ? 'strategy-bound' : null,
|
|
7362
|
+
};
|
|
7363
|
+
}
|
|
7364
|
+
|
|
6049
7365
|
private approximateDecodedBase64Bytes(base64: string): number {
|
|
6050
7366
|
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
|
|
6051
7367
|
return Math.max(0, Math.floor(base64.length * 3 / 4) - padding);
|
|
@@ -6173,6 +7489,11 @@ export class AgentFramework {
|
|
|
6173
7489
|
}
|
|
6174
7490
|
|
|
6175
7491
|
// Route the agent's typed, allowlisted hot-settings surface.
|
|
7492
|
+
if (enrichedCall.name === 'utils') {
|
|
7493
|
+
this.dispatchUtilsToolCall(agentName, enrichedCall);
|
|
7494
|
+
return;
|
|
7495
|
+
}
|
|
7496
|
+
|
|
6176
7497
|
if (enrichedCall.name === 'agent_settings') {
|
|
6177
7498
|
this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
|
|
6178
7499
|
return;
|
|
@@ -6217,6 +7538,25 @@ export class AgentFramework {
|
|
|
6217
7538
|
// synthesized channel tools, but they aren't `channel_`-prefixed so they
|
|
6218
7539
|
// need an explicit route here.
|
|
6219
7540
|
if ((enrichedCall.name === 'think' || enrichedCall.name === 'skip_reply') && this.channelRegistry) {
|
|
7541
|
+
// skip_reply(wake_in_seconds): arm a gate self-wake so "not replying
|
|
7542
|
+
// NOW" can also mean "back in a moment" — ends the turn, then wakes
|
|
7543
|
+
// the agent after N seconds unless something else wakes it first
|
|
7544
|
+
// (any turn start cancels the pending self-wake). Armed here because
|
|
7545
|
+
// the framework owns the EventGate; the registry only words the
|
|
7546
|
+
// confirmation. Without a gate the field is stripped so that
|
|
7547
|
+
// confirmation never claims a wake that can't happen.
|
|
7548
|
+
if (enrichedCall.name === 'skip_reply') {
|
|
7549
|
+
const inputObj = (enrichedCall.input ?? {}) as Record<string, unknown>;
|
|
7550
|
+
const secs = Number(inputObj.wake_in_seconds);
|
|
7551
|
+
const wants = inputObj.wake_in_seconds !== undefined && Number.isFinite(secs) && secs > 0;
|
|
7552
|
+
if (wants && this.eventGate) {
|
|
7553
|
+
const { inMs } = this.eventGate.armSelfWake(agentName, secs, 'skip_reply');
|
|
7554
|
+
console.error(`[self-wake] agent=${agentName} armed in ${Math.round(inMs / 1000)}s (skip_reply)`);
|
|
7555
|
+
} else if (inputObj.wake_in_seconds !== undefined && !this.eventGate) {
|
|
7556
|
+
delete inputObj.wake_in_seconds;
|
|
7557
|
+
console.error(`[self-wake] agent=${agentName} wake_in_seconds requested but no EventGate — ignored`);
|
|
7558
|
+
}
|
|
7559
|
+
}
|
|
6220
7560
|
this.dispatchChannelToolCall(agentName, enrichedCall);
|
|
6221
7561
|
return;
|
|
6222
7562
|
}
|
|
@@ -6331,15 +7671,27 @@ export class AgentFramework {
|
|
|
6331
7671
|
|
|
6332
7672
|
// Defer non-tool_result messages while a tool cycle is mid-flight
|
|
6333
7673
|
// (pendingAssistantBlocks: preserves tool_use → tool_result adjacency)
|
|
6334
|
-
// OR while the target agent has a
|
|
6335
|
-
// mid-
|
|
6336
|
-
// though the live conversation never saw it — so the next compile
|
|
6337
|
-
// diverges from the live prefix (prompt-cache bust)
|
|
6338
|
-
//
|
|
6339
|
-
//
|
|
6340
|
-
//
|
|
7674
|
+
// OR while the target agent has a turn in progress AT ALL. A message
|
|
7675
|
+
// stored mid-turn lands BEFORE the turn's assistant blocks in the window
|
|
7676
|
+
// even though the live conversation never saw it — so the next compile
|
|
7677
|
+
// diverges from the live prefix (prompt-cache bust), the message misses
|
|
7678
|
+
// mid-turn injection, and the window falsely testifies the agent saw it
|
|
7679
|
+
// and moved on. Turn-alive (activeTurnTokens) is the guard, not stream-
|
|
7680
|
+
// alive: a turn exists from dequeue through hooks + compile before its
|
|
7681
|
+
// stream registers in activeStreams, and the 2026-07-31 Mythos incident
|
|
7682
|
+
// was precisely a deferred flush landing inside that compile window.
|
|
7683
|
+
// (activeStreams is kept as a belt-and-suspenders backstop.) Deferred
|
|
7684
|
+
// messages flush at the next turn's START (before its compile — riding
|
|
7685
|
+
// their own wake), at the agent's next tool boundary (where they are
|
|
7686
|
+
// ALSO injected into the live stream — hear-while-acting), or in
|
|
7687
|
+
// driveStream's finally when the turn ends.
|
|
6341
7688
|
const hasToolResult = content.some(b => b.type === 'tool_result');
|
|
6342
|
-
if (
|
|
7689
|
+
if (
|
|
7690
|
+
!hasToolResult &&
|
|
7691
|
+
(this.pendingAssistantBlocks.size > 0 ||
|
|
7692
|
+
this.activeTurnTokens.has(agent.name) ||
|
|
7693
|
+
this.activeStreams.has(agent.name))
|
|
7694
|
+
) {
|
|
6343
7695
|
this.deferredMessages.push({ participant, content, metadata });
|
|
6344
7696
|
return '' as MessageId; // Deferred — flushed at the next boundary
|
|
6345
7697
|
}
|
|
@@ -6832,7 +8184,6 @@ export class AgentFramework {
|
|
|
6832
8184
|
): Promise<void> {
|
|
6833
8185
|
this.mcplServerRegistry = new McplServerRegistry();
|
|
6834
8186
|
this.featureSetManager = new FeatureSetManager();
|
|
6835
|
-
this.scopeManager = new ScopeManager();
|
|
6836
8187
|
this.hookOrchestrator = new HookOrchestrator(this.mcplServerRegistry, this.featureSetManager);
|
|
6837
8188
|
|
|
6838
8189
|
// Build prefix map and store configs for tool routing
|
|
@@ -6950,15 +8301,35 @@ export class AgentFramework {
|
|
|
6950
8301
|
|
|
6951
8302
|
// Host capabilities advertised during the MCP handshake — stored so
|
|
6952
8303
|
// servers can also be connected later at runtime (connectMcplServer).
|
|
8304
|
+
// §5.2: the host advertises what it actually implements. 0.5.0-draft:
|
|
8305
|
+
// afterInference is GONE (replaced by inference/lifecycle, §10.5), and
|
|
8306
|
+
// the previously under-advertised inferenceRequest + channels — both
|
|
8307
|
+
// implemented for years — are declared (AUDIT-001/issue #76 item 12).
|
|
6953
8308
|
this.mcplHostCapabilities = {
|
|
6954
|
-
version: '0.
|
|
8309
|
+
version: '0.5',
|
|
6955
8310
|
pushEvents: true,
|
|
6956
8311
|
contextHooks: {
|
|
6957
8312
|
beforeInference: true,
|
|
6958
|
-
|
|
8313
|
+
},
|
|
8314
|
+
inferenceLifecycle: true,
|
|
8315
|
+
// modelInfo is NOT advertised: §12.2's result requires all four of
|
|
8316
|
+
// {id, vendor, contextWindow, capabilities} and the host has no
|
|
8317
|
+
// truthful source for contextWindow/capabilities (grep: none exists).
|
|
8318
|
+
// Advertising a capability answered with a malformed partial response
|
|
8319
|
+
// is worse than honest unsupported (Sol, PR #79 re-review). The
|
|
8320
|
+
// handler below answers an explicit error rather than hanging (§6.6).
|
|
8321
|
+
inferenceRequest: { streaming: true },
|
|
8322
|
+
channels: {
|
|
8323
|
+
register: true,
|
|
8324
|
+
lifecycle: true,
|
|
8325
|
+
publish: true,
|
|
8326
|
+
incoming: true,
|
|
8327
|
+
streaming: true,
|
|
8328
|
+
acknowledge: true,
|
|
8329
|
+
typing: true,
|
|
6959
8330
|
},
|
|
6960
8331
|
featureSets: true,
|
|
6961
|
-
};
|
|
8332
|
+
} as McplHostCapabilities;
|
|
6962
8333
|
|
|
6963
8334
|
for (const config of serverConfigs) {
|
|
6964
8335
|
try {
|
|
@@ -7371,30 +8742,228 @@ export class AgentFramework {
|
|
|
7371
8742
|
// releases control traffic needed for registration and marker service.
|
|
7372
8743
|
this.wireMcplEvents(connection);
|
|
7373
8744
|
|
|
7374
|
-
// Initialize feature sets if server advertises MCPL capabilities
|
|
7375
|
-
this.registerMcplServerFeatures(config, connection);
|
|
7376
|
-
|
|
7377
8745
|
if (deferAwareness) {
|
|
8746
|
+
// Staged startup: both planes are closed, so the §5.3 policy
|
|
8747
|
+
// round-trip cannot let any buffered traffic slip — establish the
|
|
8748
|
+
// grant now, before staging ever releases.
|
|
8749
|
+
await this.registerMcplServerFeatures(config, connection);
|
|
7378
8750
|
this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
|
|
7379
8751
|
return;
|
|
7380
8752
|
}
|
|
7381
8753
|
|
|
7382
8754
|
const awarenessBarrier = this.installMcplDataPlaneGate();
|
|
7383
|
-
|
|
8755
|
+
// NO early release: both planes stay closed through accounting AND the
|
|
8756
|
+
// §5.3 policy round-trip below (PR #79 review blocker 3 — the early
|
|
8757
|
+
// control flush released channels-register/changed before any grant
|
|
8758
|
+
// existed). The awareness drain rides tools/call RESPONSES, which are
|
|
8759
|
+
// never event-buffered, so it needs no plane release to make progress.
|
|
7384
8760
|
try {
|
|
7385
8761
|
await awarenessBarrier.promise;
|
|
7386
|
-
this.completeMcplDataPlaneGate(awarenessBarrier);
|
|
7387
8762
|
} catch (error) {
|
|
7388
8763
|
// Startup cannot fail open on a broken awareness ledger. Disable the
|
|
7389
8764
|
// reconnecting stub/connection before propagating the distinct error to
|
|
7390
8765
|
// initializeMcpl, which tears down any other servers and aborts create().
|
|
8766
|
+
// Ordering matters: awareness accounting settles BEFORE the §5.3
|
|
8767
|
+
// policy round-trip below, so an accounting failure aborts with the
|
|
8768
|
+
// grant still empty and every buffered event still behind the gate —
|
|
8769
|
+
// a failed startup never reports a released data plane. (The reverse
|
|
8770
|
+
// order let a push arrive during the policy await and get flushed by
|
|
8771
|
+
// teardown.)
|
|
7391
8772
|
await connection.close().catch(() => {});
|
|
7392
8773
|
throw error;
|
|
7393
8774
|
}
|
|
7394
8775
|
|
|
8776
|
+
// §5.3 initial policy handshake — after accounting, before the gate
|
|
8777
|
+
// completes: the grant is established (or knowingly left empty) before
|
|
8778
|
+
// ANY buffered traffic (control or data) flows. complete() then
|
|
8779
|
+
// ready()s both planes through the admission gate.
|
|
8780
|
+
await this.registerMcplServerFeatures(config, connection);
|
|
8781
|
+
this.completeMcplDataPlaneGate(awarenessBarrier);
|
|
8782
|
+
|
|
7395
8783
|
this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
|
|
7396
8784
|
}
|
|
7397
8785
|
|
|
8786
|
+
/** Per-connection §17.8 fetch limiter: at most one in-flight fetch, at
|
|
8787
|
+
* most one queued behind it (announcements coalesce), floor between
|
|
8788
|
+
* completed fetches. Exceeding the floor drops the excess with a
|
|
8789
|
+
* diagnostic — the host bounds the rate; it never depends on server
|
|
8790
|
+
* coalescing. */
|
|
8791
|
+
private manifestRefreshState: Map<string, { inFlight: boolean; queued: boolean; lastCompletedAt: number }> = new Map();
|
|
8792
|
+
private static readonly MANIFEST_FETCH_FLOOR_MS = 5_000;
|
|
8793
|
+
|
|
8794
|
+
/**
|
|
8795
|
+
* §17.5 host processing: fetch the complete manifest, validate exactly as
|
|
8796
|
+
* at initialize, diff, apply §6.7 consequences (removals eagerly even
|
|
8797
|
+
* when other declarations are invalid; additions only through
|
|
8798
|
+
* tell→receipt→activate), emit ONE change receipt (§17.6).
|
|
8799
|
+
*/
|
|
8800
|
+
private async handleManifestChanged(
|
|
8801
|
+
connection: McplServerConnection,
|
|
8802
|
+
announce: { revision?: string; domains?: string[] },
|
|
8803
|
+
): Promise<void> {
|
|
8804
|
+
const st = this.manifestRefreshState.get(connection.id)
|
|
8805
|
+
?? { inFlight: false, queued: false, lastCompletedAt: 0 };
|
|
8806
|
+
this.manifestRefreshState.set(connection.id, st);
|
|
8807
|
+
if (st.inFlight) {
|
|
8808
|
+
// Coalesce: one queued refresh re-runs after the current fetch — the
|
|
8809
|
+
// fetch is always of the CURRENT manifest, so N announcements need at
|
|
8810
|
+
// most one trailing fetch (§17.8).
|
|
8811
|
+
st.queued = true;
|
|
8812
|
+
return;
|
|
8813
|
+
}
|
|
8814
|
+
const sinceLast = Date.now() - st.lastCompletedAt;
|
|
8815
|
+
if (sinceLast < AgentFramework.MANIFEST_FETCH_FLOOR_MS) {
|
|
8816
|
+
if (!st.queued) {
|
|
8817
|
+
st.queued = true;
|
|
8818
|
+
setTimeout(() => {
|
|
8819
|
+
const cur = this.manifestRefreshState.get(connection.id);
|
|
8820
|
+
if (cur?.queued && !cur.inFlight) {
|
|
8821
|
+
cur.queued = false;
|
|
8822
|
+
void this.handleManifestChanged(connection, announce).catch(() => {});
|
|
8823
|
+
}
|
|
8824
|
+
}, AgentFramework.MANIFEST_FETCH_FLOOR_MS - sinceLast).unref?.();
|
|
8825
|
+
}
|
|
8826
|
+
return;
|
|
8827
|
+
}
|
|
8828
|
+
|
|
8829
|
+
st.inFlight = true;
|
|
8830
|
+
try {
|
|
8831
|
+
const config = this.mcplServerConfigs.get(connection.id);
|
|
8832
|
+
if (!config) return;
|
|
8833
|
+
|
|
8834
|
+
// §17.4 fetch. A server that cannot answer keeps its previous
|
|
8835
|
+
// manifest in force — an unparseable/failed fetch teaches nothing and
|
|
8836
|
+
// narrows nothing (§17.5 step 1).
|
|
8837
|
+
let fetched: import('./mcpl/types.js').McplCapabilities | null;
|
|
8838
|
+
try {
|
|
8839
|
+
fetched = await connection.sendManifestRequest();
|
|
8840
|
+
} catch (err) {
|
|
8841
|
+
console.error(`[mcpl] ${connection.id}: mcpl/manifest fetch failed — previous manifest stands (§17.5): ${err instanceof Error ? err.message : err}`);
|
|
8842
|
+
return;
|
|
8843
|
+
}
|
|
8844
|
+
if (!fetched || typeof fetched !== 'object') {
|
|
8845
|
+
console.error(`[mcpl] ${connection.id}: mcpl/manifest returned no object — previous manifest stands (§17.5)`);
|
|
8846
|
+
return;
|
|
8847
|
+
}
|
|
8848
|
+
|
|
8849
|
+
// Validate exactly as at initialize: expand §5.1 shorthand, apply the
|
|
8850
|
+
// config mask, recompute the grant. Unknown names mint nothing;
|
|
8851
|
+
// invalid declarations fail closed in derivation — and REMOVALS still
|
|
8852
|
+
// apply, because the new grant/derivation simply lacks whatever no
|
|
8853
|
+
// longer validates (§17.5 step 1's partial-invalid rule falls out of
|
|
8854
|
+
// computing forward from the fetched content).
|
|
8855
|
+
const expanded = expandAdvertisementShorthand(fetched);
|
|
8856
|
+
const { capabilities: masked, dropped } = maskNegotiatedCapabilities(expanded, config);
|
|
8857
|
+
const newGrant = computeGrant(masked, config, { mcpToolsAdvertised: connection.mcpToolsAdvertised });
|
|
8858
|
+
|
|
8859
|
+
const oldEffective = new Set(connection.grant.effectiveList());
|
|
8860
|
+
const newEffective = new Set(newGrant.effectiveList());
|
|
8861
|
+
const revoked = [...oldEffective].filter((p) => !newEffective.has(p)).sort();
|
|
8862
|
+
const added = [...newEffective].filter((p) => !oldEffective.has(p)).sort();
|
|
8863
|
+
|
|
8864
|
+
const oldDeclared = Object.keys(this.featureSetManager?.getDeclaredFeatureSets(connection.id) ?? {});
|
|
8865
|
+
const oldEnabled = oldDeclared.filter((n) => this.featureSetManager?.isEnabled(connection.id, n));
|
|
8866
|
+
|
|
8867
|
+
// §6.7 REDUCTION FIRST, atomically, before the server is told:
|
|
8868
|
+
// security cannot wait on consent. The interim grant is new∩old — no
|
|
8869
|
+
// revoked path survives, no added path activates yet.
|
|
8870
|
+
const hadReduction = revoked.length > 0;
|
|
8871
|
+
if (hadReduction) {
|
|
8872
|
+
const interim = new CapabilityGrant(
|
|
8873
|
+
new Set([...newEffective].filter((p) => oldEffective.has(p))),
|
|
8874
|
+
[...newGrant.deniedPaths],
|
|
8875
|
+
);
|
|
8876
|
+
connection.establishGrant(interim);
|
|
8877
|
+
}
|
|
8878
|
+
|
|
8879
|
+
// PHASE 1 — pre-receipt: live feature state derives from ACTIVE
|
|
8880
|
+
// authority (the interim grant when reduced, the standing grant
|
|
8881
|
+
// otherwise), never from the unactivated full grant. Feature state
|
|
8882
|
+
// must obey the same §6.7 ordering the grant does: a newly added
|
|
8883
|
+
// capability-backed feature set cannot be host-enabled while its
|
|
8884
|
+
// capability is still absent from the active grant (Sol, PR #84
|
|
8885
|
+
// review). Declarations come from the fetched manifest either way —
|
|
8886
|
+
// a set absent from the new manifest is gone regardless.
|
|
8887
|
+
const cfg = { enabledFeatureSets: config.enabledFeatureSets, disabledFeatureSets: config.disabledFeatureSets };
|
|
8888
|
+
const maskedOrEmpty = masked ?? ({ version: '0.5' } as import('./mcpl/types.js').McplCapabilities);
|
|
8889
|
+
this.featureSetManager!.initializeServer(connection.id, maskedOrEmpty, cfg, connection.grant);
|
|
8890
|
+
|
|
8891
|
+
// The Request DESCRIBES the proposed new policy — derived from the
|
|
8892
|
+
// full new grant on a scratch manager, installing nothing.
|
|
8893
|
+
const updateParams = new FeatureSetManager().initializeServer(connection.id, maskedOrEmpty, cfg, newGrant);
|
|
8894
|
+
updateParams.effectiveCapabilities = newGrant.effectiveList();
|
|
8895
|
+
if (newGrant.deniedPaths.length > 0) updateParams.deniedCapabilities = [...newGrant.deniedPaths];
|
|
8896
|
+
|
|
8897
|
+
// Tell → receipt → activate (§6.7). PHASE 2 by outcome:
|
|
8898
|
+
// accepted → full grant + full-derived feature state;
|
|
8899
|
+
// refused → honor fallback: 'close' closes the transport;
|
|
8900
|
+
// 'mcp-only' (or absent) = empty grant + empty-derived
|
|
8901
|
+
// feature state, mirroring the §5.3 initial path;
|
|
8902
|
+
// unanswered → interim grant and interim-derived state stand.
|
|
8903
|
+
let negotiated = false;
|
|
8904
|
+
try {
|
|
8905
|
+
const receipt = await connection.sendFeatureSetsUpdateRequest(updateParams);
|
|
8906
|
+
if (receipt && receipt.accepted === false) {
|
|
8907
|
+
const fallback = receipt.fallback ?? 'mcp-only';
|
|
8908
|
+
console.error(`[mcpl] ${connection.id} refused post-manifest policy — fallback: ${fallback}`);
|
|
8909
|
+
if (fallback === 'close') {
|
|
8910
|
+
await connection.close();
|
|
8911
|
+
return;
|
|
8912
|
+
}
|
|
8913
|
+
connection.establishGrant(CapabilityGrant.empty());
|
|
8914
|
+
this.featureSetManager!.initializeServer(connection.id, maskedOrEmpty, cfg, CapabilityGrant.empty());
|
|
8915
|
+
} else {
|
|
8916
|
+
connection.establishGrant(newGrant);
|
|
8917
|
+
this.featureSetManager!.initializeServer(connection.id, maskedOrEmpty, cfg, newGrant);
|
|
8918
|
+
connection.manifestState.lastNegotiatedAt = Date.now();
|
|
8919
|
+
negotiated = true;
|
|
8920
|
+
}
|
|
8921
|
+
} catch {
|
|
8922
|
+
console.error(`[mcpl] ${connection.id} did not answer post-manifest policy — expansion does not activate (§6.7); interim state stands`);
|
|
8923
|
+
}
|
|
8924
|
+
|
|
8925
|
+
// Adopt the validated manifest + tracking. droppedCapabilities is
|
|
8926
|
+
// REPLACED unconditionally — an empty new mask must clear stale paths
|
|
8927
|
+
// (Sol, PR #84 review).
|
|
8928
|
+
connection.capabilities = masked;
|
|
8929
|
+
connection.manifestState.lastValidatedRevision =
|
|
8930
|
+
typeof (fetched as { revision?: unknown }).revision === 'string'
|
|
8931
|
+
? (fetched as { revision: string }).revision : null;
|
|
8932
|
+
connection.manifestState.lastFetchedAt = Date.now();
|
|
8933
|
+
connection.droppedCapabilities = new Set(dropped);
|
|
8934
|
+
|
|
8935
|
+
const newDeclared = Object.keys(this.featureSetManager?.getDeclaredFeatureSets(connection.id) ?? {});
|
|
8936
|
+
const newEnabled = newDeclared.filter((n) => this.featureSetManager?.isEnabled(connection.id, n));
|
|
8937
|
+
const degraded = oldEnabled.filter((n) => !newEnabled.includes(n)).sort();
|
|
8938
|
+
const restored = newEnabled.filter((n) => !oldEnabled.includes(n)).sort();
|
|
8939
|
+
|
|
8940
|
+
// §17.6: ONE receipt per manifest change, closed host-derived impact
|
|
8941
|
+
// vocabulary, never a server-authored flag.
|
|
8942
|
+
const impacts: Array<{ impact: string; subject: string; disposition: string }> = [
|
|
8943
|
+
...revoked.map((p) => ({ impact: 'capability-revoked', subject: p, disposition: 'applied' })),
|
|
8944
|
+
...added.map((p) => ({ impact: 'capability-expansion-pending', subject: p, disposition: negotiated ? 'applied' : 'decision-needed' })),
|
|
8945
|
+
...degraded.map((n) => ({ impact: 'feature-degraded', subject: n, disposition: 'applied' })),
|
|
8946
|
+
...restored.map((n) => ({ impact: 'feature-restored', subject: n, disposition: negotiated ? 'applied' : 'decision-needed' })),
|
|
8947
|
+
];
|
|
8948
|
+
this.emitTrace({
|
|
8949
|
+
type: 'mcpl:manifest-change-receipt',
|
|
8950
|
+
serverId: connection.id,
|
|
8951
|
+
revision: connection.manifestState.lastValidatedRevision,
|
|
8952
|
+
announcedDomains: announce.domains ?? [],
|
|
8953
|
+
impacts,
|
|
8954
|
+
});
|
|
8955
|
+
this.handleToolsListChanged(connection.id);
|
|
8956
|
+
} finally {
|
|
8957
|
+
st.inFlight = false;
|
|
8958
|
+
st.lastCompletedAt = Date.now();
|
|
8959
|
+
if (st.queued) {
|
|
8960
|
+
st.queued = false;
|
|
8961
|
+
setTimeout(() => { void this.handleManifestChanged(connection, announce).catch(() => {}); },
|
|
8962
|
+
AgentFramework.MANIFEST_FETCH_FLOOR_MS).unref?.();
|
|
8963
|
+
}
|
|
8964
|
+
}
|
|
8965
|
+
}
|
|
8966
|
+
|
|
7398
8967
|
/**
|
|
7399
8968
|
* (Re-)establish a server's host-side MCPL registration: feature-set state
|
|
7400
8969
|
* (plus the featureSets/update sent to the server), scope patterns, and
|
|
@@ -7410,12 +8979,18 @@ export class AgentFramework {
|
|
|
7410
8979
|
* already has a tree), so checkpoint trees preserved across a transient
|
|
7411
8980
|
* disconnect are resumed, not reset.
|
|
7412
8981
|
*/
|
|
7413
|
-
private registerMcplServerFeatures(
|
|
8982
|
+
private async registerMcplServerFeatures(
|
|
7414
8983
|
config: import('./mcpl/types.js').McplServerConfig,
|
|
7415
8984
|
connection: McplServerConnection,
|
|
7416
|
-
): void {
|
|
8985
|
+
): Promise<void> {
|
|
7417
8986
|
if (!connection.capabilities) return;
|
|
7418
8987
|
|
|
8988
|
+
// §5.4: compute the effective grant from the (config-masked)
|
|
8989
|
+
// advertisement. Not yet active — expansion activates only after the
|
|
8990
|
+
// receipt (§6.7); until then the connection's grant is empty and every
|
|
8991
|
+
// privileged inbound method is rejected.
|
|
8992
|
+
const grant = computeGrant(connection.capabilities, config, { mcpToolsAdvertised: connection.mcpToolsAdvertised });
|
|
8993
|
+
|
|
7419
8994
|
const updateParams = this.featureSetManager!.initializeServer(
|
|
7420
8995
|
config.id,
|
|
7421
8996
|
connection.capabilities,
|
|
@@ -7423,16 +8998,60 @@ export class AgentFramework {
|
|
|
7423
8998
|
enabledFeatureSets: config.enabledFeatureSets,
|
|
7424
8999
|
disabledFeatureSets: config.disabledFeatureSets,
|
|
7425
9000
|
},
|
|
9001
|
+
grant,
|
|
7426
9002
|
);
|
|
7427
9003
|
|
|
7428
|
-
//
|
|
7429
|
-
|
|
7430
|
-
|
|
9004
|
+
// §5.3: initial policy MUST be a Request, MUST precede first fan-out,
|
|
9005
|
+
// and MUST be sent even when nothing is enabled or disabled — a server
|
|
9006
|
+
// defaulted to fully disabled has to be told. The response is a
|
|
9007
|
+
// degradation receipt (§6.7): consequence testimony, never policy
|
|
9008
|
+
// authority — nothing in it may widen the grant.
|
|
9009
|
+
updateParams.effectiveCapabilities = grant.effectiveList();
|
|
9010
|
+
if (grant.deniedPaths.length > 0) {
|
|
9011
|
+
updateParams.deniedCapabilities = [...grant.deniedPaths];
|
|
9012
|
+
}
|
|
9013
|
+
|
|
9014
|
+
try {
|
|
9015
|
+
const receipt = await connection.sendFeatureSetsUpdateRequest(updateParams);
|
|
9016
|
+
if (receipt && receipt.accepted === false) {
|
|
9017
|
+
const fallback = receipt.fallback ?? 'mcp-only';
|
|
9018
|
+
console.error(
|
|
9019
|
+
`[mcpl] ${config.id} refused initial policy (${receipt.reason ?? 'no reason'}) — fallback: ${fallback}`,
|
|
9020
|
+
);
|
|
9021
|
+
if (fallback === 'close') {
|
|
9022
|
+
await connection.close();
|
|
9023
|
+
return;
|
|
9024
|
+
}
|
|
9025
|
+
// mcp-only (§3.2): grant stays empty, MCPL privileged surface stays
|
|
9026
|
+
// dark, plain MCP tools keep working. MUST NOT widen in response to
|
|
9027
|
+
// the refusal — a human can change config; the policy engine cannot.
|
|
9028
|
+
return;
|
|
9029
|
+
}
|
|
9030
|
+
// Expansion activates on receipt (§6.7).
|
|
9031
|
+
connection.establishGrant(grant);
|
|
9032
|
+
if (receipt?.accepted === true && receipt.mode === 'degraded') {
|
|
9033
|
+
for (const uf of receipt.unavailableFeatures ?? []) {
|
|
9034
|
+
console.error(
|
|
9035
|
+
`[mcpl] ${config.id} degraded: ${uf.featureSet} (${uf.effect}; missing ${uf.missingCapabilities.join(', ')})`,
|
|
9036
|
+
);
|
|
9037
|
+
}
|
|
9038
|
+
}
|
|
9039
|
+
} catch (err) {
|
|
9040
|
+
// A pre-0.5 server treats the Request as a notification and never
|
|
9041
|
+
// answers. §6.7: an unanswered expansion simply does not activate —
|
|
9042
|
+
// grant stays empty, connection is effectively MCP-only. This WILL
|
|
9043
|
+
// dark every un-migrated MCPL server's privileged surface; accepted
|
|
9044
|
+
// deliberately for the single-release rollout (issue #76 item 9).
|
|
9045
|
+
console.error(
|
|
9046
|
+
`[mcpl] ${config.id} did not answer the initial policy Request — ` +
|
|
9047
|
+
`MCPL privileged surface stays disabled (§6.7 unanswered expansion): ` +
|
|
9048
|
+
(err instanceof Error ? err.message : String(err)),
|
|
9049
|
+
);
|
|
9050
|
+
return;
|
|
7431
9051
|
}
|
|
7432
9052
|
|
|
7433
|
-
// Configure scope whitelist/blacklist patterns
|
|
7434
9053
|
if (config.scopes) {
|
|
7435
|
-
|
|
9054
|
+
console.error(`[mcpl] ${config.id}: config \`scopes\` is ignored — §7 Scoped Access removed in 0.5.0`);
|
|
7436
9055
|
}
|
|
7437
9056
|
|
|
7438
9057
|
// Register stateful feature sets with checkpoint manager (Step 8)
|
|
@@ -7548,23 +9167,62 @@ export class AgentFramework {
|
|
|
7548
9167
|
listMcplServers(): Array<{
|
|
7549
9168
|
id: string;
|
|
7550
9169
|
connected: boolean;
|
|
9170
|
+
retrying: boolean;
|
|
7551
9171
|
toolPrefix: string;
|
|
7552
9172
|
toolCount: number;
|
|
9173
|
+
/** True only after the §5.3 policy receipt activated the grant. */
|
|
9174
|
+
policyEstablished: boolean;
|
|
9175
|
+
/** Sole normative authorization allowlist for the connection (§5.4). */
|
|
9176
|
+
effectiveGrant: string[];
|
|
9177
|
+
/** Advertised paths removed by host enabled/disabled capability config. */
|
|
9178
|
+
maskedCapabilities: string[];
|
|
9179
|
+
/** Advertised paths removed by a host deny-by-default rule (§13.4). */
|
|
9180
|
+
deniedCapabilities: string[];
|
|
9181
|
+
/** Host-owned authority; deliberately separate from the portable grant. */
|
|
9182
|
+
allowHostCommands: boolean;
|
|
9183
|
+
/**
|
|
9184
|
+
* Per-transport §17 facts about the last manifest this host fetched and
|
|
9185
|
+
* acted on. The revision is server-authored and equality-only; these are
|
|
9186
|
+
* not the server's manifestChanged announcements.
|
|
9187
|
+
*/
|
|
9188
|
+
manifestState: {
|
|
9189
|
+
lastValidatedRevision: string | null;
|
|
9190
|
+
lastFetchedAt: number | null;
|
|
9191
|
+
lastNegotiatedAt: number | null;
|
|
9192
|
+
};
|
|
7553
9193
|
command?: string;
|
|
7554
9194
|
url?: string;
|
|
7555
9195
|
}> {
|
|
7556
9196
|
const result: Array<{
|
|
7557
|
-
id: string; connected: boolean;
|
|
9197
|
+
id: string; connected: boolean; retrying: boolean;
|
|
9198
|
+
toolPrefix: string; toolCount: number; policyEstablished: boolean;
|
|
9199
|
+
effectiveGrant: string[]; maskedCapabilities: string[];
|
|
9200
|
+
deniedCapabilities: string[]; allowHostCommands: boolean;
|
|
9201
|
+
manifestState: {
|
|
9202
|
+
lastValidatedRevision: string | null;
|
|
9203
|
+
lastFetchedAt: number | null;
|
|
9204
|
+
lastNegotiatedAt: number | null;
|
|
9205
|
+
};
|
|
7558
9206
|
command?: string; url?: string;
|
|
7559
9207
|
}> = [];
|
|
7560
9208
|
for (const [id, config] of this.mcplServerConfigs) {
|
|
7561
9209
|
const prefix = config.toolPrefix ?? `mcpl--${id}`;
|
|
7562
9210
|
const connection = this.mcplServerRegistry?.getServer(id) ?? null;
|
|
9211
|
+
const connected = connection?.isConnected ?? false;
|
|
7563
9212
|
result.push({
|
|
7564
9213
|
id,
|
|
7565
|
-
connected
|
|
9214
|
+
connected,
|
|
9215
|
+
retrying: !connected && (connection?.willReconnect ?? false),
|
|
7566
9216
|
toolPrefix: prefix,
|
|
7567
9217
|
toolCount: this.mcplTools.filter(t => t.name.startsWith(`${prefix}--`)).length,
|
|
9218
|
+
policyEstablished: connection?.policyEstablished ?? false,
|
|
9219
|
+
effectiveGrant: connection?.grant.effectiveList() ?? [],
|
|
9220
|
+
maskedCapabilities: [...(connection?.droppedCapabilities ?? [])].sort(),
|
|
9221
|
+
deniedCapabilities: [...(connection?.grant.deniedPaths ?? [])].sort(),
|
|
9222
|
+
allowHostCommands: config.allowHostCommands === true,
|
|
9223
|
+
manifestState: connection
|
|
9224
|
+
? { ...connection.manifestState }
|
|
9225
|
+
: { lastValidatedRevision: null, lastFetchedAt: null, lastNegotiatedAt: null },
|
|
7568
9226
|
command: config.command,
|
|
7569
9227
|
url: config.url,
|
|
7570
9228
|
});
|
|
@@ -7583,26 +9241,15 @@ export class AgentFramework {
|
|
|
7583
9241
|
this.emitTrace({ type: 'mcpl:server-stderr', serverId: connection.id, line: params.line });
|
|
7584
9242
|
});
|
|
7585
9243
|
|
|
7586
|
-
//
|
|
7587
|
-
|
|
7588
|
-
|
|
7589
|
-
|
|
9244
|
+
// featureSets/changed is REMOVED in 0.5.0 — the connection answers it
|
|
9245
|
+
// -32601 (superseded by SPEC §17's host-diffed manifest fetch); no
|
|
9246
|
+
// listener, and nothing mutates declarations off a server-authored
|
|
9247
|
+
// change payload.
|
|
7590
9248
|
|
|
7591
9249
|
// Handle scope elevation requests
|
|
7592
|
-
|
|
7593
|
-
|
|
7594
|
-
|
|
7595
|
-
) => {
|
|
7596
|
-
if (this.scopeManager && responder) {
|
|
7597
|
-
try {
|
|
7598
|
-
const result: ScopeElevateResult = await this.scopeManager.handleElevation(params);
|
|
7599
|
-
responder.respond(result);
|
|
7600
|
-
} catch (error) {
|
|
7601
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
7602
|
-
responder.respondError(-32603, err.message);
|
|
7603
|
-
}
|
|
7604
|
-
}
|
|
7605
|
-
});
|
|
9250
|
+
// §7 Scoped Access is removed in 0.5.0. scope/elevate is answered
|
|
9251
|
+
// -32601 at the connection (server-connection.ts) and never reaches the
|
|
9252
|
+
// framework; the ScopeManager and config `scopes` wiring went with it.
|
|
7606
9253
|
|
|
7607
9254
|
// Handle push events (Step 6)
|
|
7608
9255
|
connection.on('push-event', async (
|
|
@@ -7638,9 +9285,51 @@ export class AgentFramework {
|
|
|
7638
9285
|
await this.channelRegistry?.handleRegister(connection.id, params, responder as never);
|
|
7639
9286
|
});
|
|
7640
9287
|
|
|
7641
|
-
// Handle channel changes (Step 7)
|
|
7642
|
-
|
|
7643
|
-
|
|
9288
|
+
// Handle channel changes (Step 7) — §14.5 dual-mode: the responder is
|
|
9289
|
+
// present for the Request form and carries itemized added-descriptor
|
|
9290
|
+
// results; absent for Notifications, where rejection is itemwise
|
|
9291
|
+
// filtering plus a diagnostic trace.
|
|
9292
|
+
connection.on('channels-changed', async (
|
|
9293
|
+
params: ChannelsChangedParams,
|
|
9294
|
+
responder?: { respond: (result: unknown) => void },
|
|
9295
|
+
) => {
|
|
9296
|
+
await this.channelRegistry?.handleChanged(connection.id, params, responder as never);
|
|
9297
|
+
});
|
|
9298
|
+
|
|
9299
|
+
// §6.6/§12: requests that previously had no handler and hung forever
|
|
9300
|
+
// (AUDIT-001 item 4). The connection-level grant gate has already run.
|
|
9301
|
+
// §17.3: a manifest-change announcement. Rate-limited re-fetch → diff
|
|
9302
|
+
// → §6.7 consequences → one receipt. The announcement itself carries no
|
|
9303
|
+
// payload and no authority; everything below acts on FETCHED content.
|
|
9304
|
+
connection.on('manifest-changed', (params: { revision?: string; domains?: string[] }) => {
|
|
9305
|
+
void this.handleManifestChanged(connection, params).catch((err) => {
|
|
9306
|
+
console.error(`[mcpl] ${connection.id} manifest refresh failed:`, err instanceof Error ? err.message : err);
|
|
9307
|
+
});
|
|
9308
|
+
});
|
|
9309
|
+
|
|
9310
|
+
connection.on('channels-list', (
|
|
9311
|
+
_params: unknown,
|
|
9312
|
+
responder?: { respond: (result: unknown) => void },
|
|
9313
|
+
) => {
|
|
9314
|
+
responder?.respond({
|
|
9315
|
+
channels: this.channelRegistry?.descriptorsForServer(connection.id) ?? [],
|
|
9316
|
+
});
|
|
9317
|
+
});
|
|
9318
|
+
|
|
9319
|
+
connection.on('model-info', (
|
|
9320
|
+
_params: unknown,
|
|
9321
|
+
responder?: { respond: (result: unknown) => void; respondError?: (code: number, message: string, data?: unknown) => void },
|
|
9322
|
+
) => {
|
|
9323
|
+
// §12.2 requires ALL FOUR of {id, vendor, contextWindow, capabilities}
|
|
9324
|
+
// in the DIRECT result shape, and numeric contextWindow cannot express
|
|
9325
|
+
// "unknown". The host has no truthful source for it, so modelInfo is
|
|
9326
|
+
// not advertised and this answers an explicit error instead of a
|
|
9327
|
+
// malformed partial (§6.6: a method that will never be answered
|
|
9328
|
+
// truthfully MUST error, not fabricate).
|
|
9329
|
+
responder?.respondError?.(
|
|
9330
|
+
-32601,
|
|
9331
|
+
'model/info unavailable: host does not advertise modelInfo (no truthful contextWindow source)',
|
|
9332
|
+
);
|
|
7644
9333
|
});
|
|
7645
9334
|
|
|
7646
9335
|
// Handle incoming channel messages (Step 7)
|
|
@@ -7699,19 +9388,25 @@ export class AgentFramework {
|
|
|
7699
9388
|
// reconnect observes it before doing work that could wake an agent. The
|
|
7700
9389
|
// connection paused its data plane before wiring the fresh transport.
|
|
7701
9390
|
const awarenessBarrier = this.installMcplDataPlaneGate();
|
|
7702
|
-
|
|
7703
|
-
|
|
7704
|
-
|
|
7705
|
-
|
|
9391
|
+
// Async: the §5.3 policy Request must be answered (re-establishing the
|
|
9392
|
+
// grant) before the data-plane barrier releases — otherwise the first
|
|
9393
|
+
// post-reconnect events land while the grant is still empty and are
|
|
9394
|
+
// rejected fail-closed instead of delivered.
|
|
9395
|
+
void (async () => {
|
|
9396
|
+
try {
|
|
9397
|
+
const config = this.mcplServerConfigs.get(connection.id);
|
|
9398
|
+
if (config) {
|
|
9399
|
+
await this.registerMcplServerFeatures(config, connection);
|
|
9400
|
+
}
|
|
9401
|
+
} catch (error) {
|
|
9402
|
+
console.error(
|
|
9403
|
+
`MCPL server "${connection.id}" re-registration after reconnect failed:`,
|
|
9404
|
+
error instanceof Error ? error.message : error,
|
|
9405
|
+
);
|
|
7706
9406
|
}
|
|
7707
|
-
|
|
7708
|
-
|
|
7709
|
-
|
|
7710
|
-
error instanceof Error ? error.message : error,
|
|
7711
|
-
);
|
|
7712
|
-
}
|
|
7713
|
-
this.releaseMcplDataPlaneGate(awarenessBarrier);
|
|
7714
|
-
this.handleToolsListChanged(connection.id);
|
|
9407
|
+
this.releaseMcplDataPlaneGate(awarenessBarrier);
|
|
9408
|
+
this.handleToolsListChanged(connection.id);
|
|
9409
|
+
})();
|
|
7715
9410
|
void awarenessBarrier.promise.then(() => {
|
|
7716
9411
|
if (
|
|
7717
9412
|
awarenessBarrier.requiresBarrier
|
|
@@ -7826,6 +9521,7 @@ export class AgentFramework {
|
|
|
7826
9521
|
if (!this.mcplServerRegistry) return;
|
|
7827
9522
|
|
|
7828
9523
|
const tools: import('./types/index.js').ToolDefinition[] = [];
|
|
9524
|
+
const toolFeatureSets = new Map<string, string>();
|
|
7829
9525
|
|
|
7830
9526
|
for (const server of this.mcplServerRegistry.getAllServers()) {
|
|
7831
9527
|
const config = this.mcplServerConfigs.get(server.id);
|
|
@@ -7834,13 +9530,26 @@ export class AgentFramework {
|
|
|
7834
9530
|
const result = await server.sendToolsList();
|
|
7835
9531
|
for (const tool of result.tools) {
|
|
7836
9532
|
if (!isToolAllowed(tool.name, config)) continue;
|
|
9533
|
+
const namespacedName = `${prefix}--${tool.name}`;
|
|
9534
|
+
const attributedTool = tool as typeof tool & {
|
|
9535
|
+
featureSet?: unknown;
|
|
9536
|
+
_meta?: { featureSet?: unknown };
|
|
9537
|
+
};
|
|
9538
|
+
const featureSet = typeof attributedTool.featureSet === 'string'
|
|
9539
|
+
? attributedTool.featureSet
|
|
9540
|
+
: typeof attributedTool._meta?.featureSet === 'string'
|
|
9541
|
+
? attributedTool._meta.featureSet
|
|
9542
|
+
: undefined;
|
|
7837
9543
|
// MCP tool schemas are generic JSON Schema; cast to membrane's ToolDefinition format
|
|
7838
9544
|
const schema = tool.inputSchema as import('./types/index.js').ToolDefinition['inputSchema'];
|
|
7839
9545
|
tools.push({
|
|
7840
|
-
name:
|
|
9546
|
+
name: namespacedName,
|
|
7841
9547
|
description: tool.description ?? '',
|
|
7842
9548
|
inputSchema: schema,
|
|
7843
9549
|
});
|
|
9550
|
+
if (featureSet !== undefined) {
|
|
9551
|
+
toolFeatureSets.set(namespacedName, featureSet);
|
|
9552
|
+
}
|
|
7844
9553
|
}
|
|
7845
9554
|
} catch {
|
|
7846
9555
|
// Server may not support tools/list — skip silently
|
|
@@ -7848,6 +9557,7 @@ export class AgentFramework {
|
|
|
7848
9557
|
}
|
|
7849
9558
|
|
|
7850
9559
|
this.mcplTools = tools;
|
|
9560
|
+
this.mcplToolFeatureSets = toolFeatureSets;
|
|
7851
9561
|
}
|
|
7852
9562
|
|
|
7853
9563
|
/**
|
|
@@ -7945,20 +9655,47 @@ export class AgentFramework {
|
|
|
7945
9655
|
const args = (call.input && typeof call.input === 'object') ? call.input as Record<string, unknown> : {};
|
|
7946
9656
|
|
|
7947
9657
|
// Build state params for stateful tools (Step 8).
|
|
7948
|
-
//
|
|
7949
|
-
//
|
|
7950
|
-
//
|
|
7951
|
-
// mixes host-managed (e.g. a feed) and server-managed (e.g. post/undo) sets,
|
|
7952
|
-
// that misattributes the feed's state.
|
|
9658
|
+
// A tools/list attribution selects that exact stateful set. Untagged tools
|
|
9659
|
+
// retain the host-managed or unique server-managed fallback, but never guess
|
|
9660
|
+
// between multiple server-managed sets.
|
|
7953
9661
|
let stateParams: { state?: unknown; checkpoint?: string } | undefined;
|
|
9662
|
+
let attributedFeatureSet: string | null = null;
|
|
7954
9663
|
if (this.checkpointManager) {
|
|
7955
|
-
const
|
|
7956
|
-
if (
|
|
7957
|
-
|
|
9664
|
+
const toolFeatureSet = this.mcplToolFeatureSets.get(call.name);
|
|
9665
|
+
if (toolFeatureSet !== undefined) {
|
|
9666
|
+
if (
|
|
9667
|
+
this.checkpointManager.isHostManaged(serverId, toolFeatureSet)
|
|
9668
|
+
|| this.checkpointManager.getStatefulFeatureSet(serverId, toolFeatureSet) !== null
|
|
9669
|
+
) {
|
|
9670
|
+
attributedFeatureSet = toolFeatureSet;
|
|
9671
|
+
} else {
|
|
9672
|
+
console.warn(
|
|
9673
|
+
`[mcpl] Tool "${call.name}" declares unknown or non-stateful feature set ` +
|
|
9674
|
+
`"${toolFeatureSet}"; skipping state exchange.`,
|
|
9675
|
+
);
|
|
9676
|
+
}
|
|
7958
9677
|
} else {
|
|
7959
|
-
|
|
7960
|
-
|
|
7961
|
-
|
|
9678
|
+
attributedFeatureSet = this.checkpointManager.getHostManagedFeatureSet(serverId)
|
|
9679
|
+
?? this.checkpointManager.getStatefulFeatureSet(serverId);
|
|
9680
|
+
if (
|
|
9681
|
+
attributedFeatureSet === null
|
|
9682
|
+
&& this.checkpointManager.hasAmbiguousServerManagedFeatureSets(serverId)
|
|
9683
|
+
) {
|
|
9684
|
+
console.warn(
|
|
9685
|
+
`[mcpl] Tool "${call.name}" has no feature-set attribution and server ` +
|
|
9686
|
+
`"${serverId}" has multiple server-managed stateful feature sets; ` +
|
|
9687
|
+
'skipping state exchange.',
|
|
9688
|
+
);
|
|
9689
|
+
}
|
|
9690
|
+
}
|
|
9691
|
+
|
|
9692
|
+
if (attributedFeatureSet !== null) {
|
|
9693
|
+
if (this.checkpointManager.isHostManaged(serverId, attributedFeatureSet)) {
|
|
9694
|
+
stateParams = {
|
|
9695
|
+
state: this.checkpointManager.getCurrentState(serverId, attributedFeatureSet),
|
|
9696
|
+
};
|
|
9697
|
+
} else {
|
|
9698
|
+
const cp = this.checkpointManager.getCurrentCheckpoint(serverId, attributedFeatureSet);
|
|
7962
9699
|
if (cp) stateParams = { checkpoint: cp };
|
|
7963
9700
|
}
|
|
7964
9701
|
}
|
|
@@ -7970,15 +9707,10 @@ export class AgentFramework {
|
|
|
7970
9707
|
this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs });
|
|
7971
9708
|
|
|
7972
9709
|
// Record checkpoint from stateful tool response (Step 8).
|
|
7973
|
-
//
|
|
7974
|
-
//
|
|
7975
|
-
// else the single stateful set. Avoids recording a feed checkpoint onto a
|
|
7976
|
-
// server-managed set just because it registered first.
|
|
9710
|
+
// Explicit response attribution is authoritative. Otherwise use the
|
|
9711
|
+
// tool-derived (or safe single-set) attribution chosen before the call.
|
|
7977
9712
|
if (result.state && this.checkpointManager) {
|
|
7978
|
-
const
|
|
7979
|
-
const fs = tagged
|
|
7980
|
-
?? this.checkpointManager.getHostManagedFeatureSet(serverId)
|
|
7981
|
-
?? this.checkpointManager.getStatefulFeatureSet(serverId);
|
|
9713
|
+
const fs = result.state.featureSet ?? attributedFeatureSet;
|
|
7982
9714
|
if (fs && this.checkpointManager.isStateful(serverId, fs)) {
|
|
7983
9715
|
this.checkpointManager.recordCheckpoint(serverId, fs, result.state);
|
|
7984
9716
|
}
|
|
@@ -8003,6 +9735,23 @@ export class AgentFramework {
|
|
|
8003
9735
|
this.channelRegistry
|
|
8004
9736
|
.openIfClosedForSend(target, serverId)
|
|
8005
9737
|
.then(({ status, channelId, label }) => {
|
|
9738
|
+
// Turn-scoped engagement record (mid-turn re-pin, 2026-07-31
|
|
9739
|
+
// n=7): a human follow-up in a channel the agent explicitly
|
|
9740
|
+
// sent into this turn counts as conversationally addressed.
|
|
9741
|
+
// Recorded here — not eagerly — because injection metadata
|
|
9742
|
+
// carries the CANONICAL channel id (descriptor form), while
|
|
9743
|
+
// send args often carry the raw provider id; this callback
|
|
9744
|
+
// has the resolved form. The already-open path resolves on
|
|
9745
|
+
// the microtask queue, i.e. before the tool-result event
|
|
9746
|
+
// that evaluates injections.
|
|
9747
|
+
if (status !== 'unknown-channel' && status !== 'ambiguous') {
|
|
9748
|
+
let engaged = this.turnEngagedChannels.get(agentName);
|
|
9749
|
+
if (!engaged) {
|
|
9750
|
+
engaged = new Set();
|
|
9751
|
+
this.turnEngagedChannels.set(agentName, engaged);
|
|
9752
|
+
}
|
|
9753
|
+
engaged.add(channelId ?? target);
|
|
9754
|
+
}
|
|
8006
9755
|
if (status === 'opened') {
|
|
8007
9756
|
console.error(
|
|
8008
9757
|
`[channel] ${agentName}: explicit ${toolName} into closed channel ${target} — opened (send implies engagement)`,
|
|
@@ -8113,22 +9862,45 @@ export class AgentFramework {
|
|
|
8113
9862
|
this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input });
|
|
8114
9863
|
const startTime = Date.now();
|
|
8115
9864
|
|
|
8116
|
-
this.channelRegistry!.handleChannelToolCall(call.name, call.input)
|
|
9865
|
+
this.channelRegistry!.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName })
|
|
8117
9866
|
.then((result) => {
|
|
8118
9867
|
const durationMs = Date.now() - startTime;
|
|
8119
9868
|
this.emitTrace({ type: 'tool:completed', module: 'channels', tool: call.name, callId: call.id, durationMs });
|
|
8120
9869
|
// A successful channel_open plants the agent's feet in the opened
|
|
8121
|
-
// channel
|
|
8122
|
-
//
|
|
8123
|
-
//
|
|
8124
|
-
//
|
|
8125
|
-
//
|
|
8126
|
-
//
|
|
9870
|
+
// channel. The agent's own deliberate open is the strongest "my next
|
|
9871
|
+
// words go here" signal in the system — stronger than any injection —
|
|
9872
|
+
// so it MOVES the current turn's prose pin (turnLocusPins), not just
|
|
9873
|
+
// the next turn's trigger state. (The original 2026-07-21 Aria fix
|
|
9874
|
+
// set only activeTriggerChannels, which resolveLocus reads at the
|
|
9875
|
+
// NEXT turn's freeze — the turn-frozen refactor had silently
|
|
9876
|
+
// regressed the "reply right after opening" case; found while
|
|
9877
|
+
// mapping the routing logic 2026-07-31, antra-ratified fix.) The
|
|
9878
|
+
// announcement rides THIS TOOL RESULT — model-requested content,
|
|
9879
|
+
// distance zero, the safest role there is — instead of a separate
|
|
9880
|
+
// window notice. lastAnnouncedLocus tracks it so announce-on-change
|
|
9881
|
+
// stays coherent. Locus-mode agents only: explicit mode has no pin.
|
|
8127
9882
|
if (call.name === 'channel_open' && result?.success) {
|
|
8128
9883
|
const opened =
|
|
8129
9884
|
(result.data as { channelId?: string } | undefined)?.channelId
|
|
8130
9885
|
?? (call.input as { channelId?: string } | undefined)?.channelId;
|
|
8131
|
-
if (opened)
|
|
9886
|
+
if (opened) {
|
|
9887
|
+
this.activeTriggerChannels.set(agentName, opened);
|
|
9888
|
+
const openerAgent = this.agents.get(agentName);
|
|
9889
|
+
if (openerAgent && openerAgent.proseRouting !== 'explicit') {
|
|
9890
|
+
this.turnLocusPins.set(agentName, opened);
|
|
9891
|
+
this.lastAnnouncedLocus.set(agentName, opened);
|
|
9892
|
+
result = {
|
|
9893
|
+
...result,
|
|
9894
|
+
data: {
|
|
9895
|
+
...(result.data as Record<string, unknown> | undefined),
|
|
9896
|
+
routing: 'Your plain speech now lands in this channel. Other channels need an explicit send tool.',
|
|
9897
|
+
},
|
|
9898
|
+
};
|
|
9899
|
+
console.error(
|
|
9900
|
+
`[routing] ${agentName}: channel_open -> pin moved to ${opened} (announced in tool result)`,
|
|
9901
|
+
);
|
|
9902
|
+
}
|
|
9903
|
+
}
|
|
8132
9904
|
}
|
|
8133
9905
|
this.pushEvent({
|
|
8134
9906
|
type: 'tool-result',
|
|
@@ -8340,6 +10112,38 @@ export class AgentFramework {
|
|
|
8340
10112
|
if (typeof input.path !== 'string' || input.path.length === 0) {
|
|
8341
10113
|
throw new Error('read_image: `path` (mount-prefixed) is required');
|
|
8342
10114
|
}
|
|
10115
|
+
// Anthropic rejects images >5MB (and >8000px); guard the hard byte
|
|
10116
|
+
// limit here — resizing is out of scope without an image library.
|
|
10117
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
10118
|
+
// Prefer the workspace module's own read_image handler when it exists:
|
|
10119
|
+
// it reads tree-first with a FILESYSTEM FALLBACK, so binaries that live
|
|
10120
|
+
// only on disk (shell-created files, pre-fix mangled tree blobs) still
|
|
10121
|
+
// render. The legacy tree-only path below stays for older workspace
|
|
10122
|
+
// modules that don't advertise the tool.
|
|
10123
|
+
if (
|
|
10124
|
+
typeof workspace.handleToolCall === 'function' &&
|
|
10125
|
+
typeof workspace.getTools === 'function' &&
|
|
10126
|
+
workspace.getTools().some((tool) => tool.name === 'read_image')
|
|
10127
|
+
) {
|
|
10128
|
+
const result = await workspace.handleToolCall({ ...call, name: 'read_image' });
|
|
10129
|
+
if (result.success && Array.isArray(result.data)) {
|
|
10130
|
+
const image = result.data.find(
|
|
10131
|
+
(block): block is { type: 'image'; data: string; mimeType: string } =>
|
|
10132
|
+
typeof block === 'object' && block !== null &&
|
|
10133
|
+
(block as { type?: unknown }).type === 'image' &&
|
|
10134
|
+
typeof (block as { data?: unknown }).data === 'string',
|
|
10135
|
+
);
|
|
10136
|
+
const byteLength = image ? Math.floor((image.data.length * 3) / 4) : 0;
|
|
10137
|
+
if (byteLength > MAX_IMAGE_BYTES) {
|
|
10138
|
+
throw new Error(
|
|
10139
|
+
`read_image: "${input.path}" is ~${byteLength} bytes — ` +
|
|
10140
|
+
`over the ${MAX_IMAGE_BYTES}-byte model limit`,
|
|
10141
|
+
);
|
|
10142
|
+
}
|
|
10143
|
+
}
|
|
10144
|
+
finish(result);
|
|
10145
|
+
return;
|
|
10146
|
+
}
|
|
8343
10147
|
const read = await workspace.readBinary(input.path);
|
|
8344
10148
|
if ('error' in read) throw new Error(read.error);
|
|
8345
10149
|
const mediaType = sniffImageMediaType(read.data);
|
|
@@ -8349,9 +10153,6 @@ export class AgentFramework {
|
|
|
8349
10153
|
'(png/jpeg/gif/webp — checked by magic bytes, not extension)',
|
|
8350
10154
|
);
|
|
8351
10155
|
}
|
|
8352
|
-
// Anthropic rejects images >5MB (and >8000px); guard the hard byte
|
|
8353
|
-
// limit here — resizing is out of scope without an image library.
|
|
8354
|
-
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
8355
10156
|
if (read.data.byteLength > MAX_IMAGE_BYTES) {
|
|
8356
10157
|
throw new Error(
|
|
8357
10158
|
`read_image: "${input.path}" is ${read.data.byteLength} bytes — ` +
|
|
@@ -8525,6 +10326,83 @@ export class AgentFramework {
|
|
|
8525
10326
|
})();
|
|
8526
10327
|
}
|
|
8527
10328
|
|
|
10329
|
+
/** The `utils` meta-tool: list/describe/run over module utilities
|
|
10330
|
+
* (Module.getUtilities). Shared by the model-facing dispatcher and the
|
|
10331
|
+
* programmatic path (executeToolCall), so code_execution scripts and
|
|
10332
|
+
* ephemerals reach utilities too. */
|
|
10333
|
+
private async handleUtilsToolCall(agentName: string, call: ToolCall): Promise<ToolResult> {
|
|
10334
|
+
const input = (call.input ?? {}) as { action?: unknown; name?: unknown; args?: unknown };
|
|
10335
|
+
const utilities = this.moduleRegistry.getAllUtilities();
|
|
10336
|
+
const available = (): string =>
|
|
10337
|
+
utilities.length ? utilities.map((u) => u.name).join(', ') : '(none registered)';
|
|
10338
|
+
switch (input.action) {
|
|
10339
|
+
case 'list':
|
|
10340
|
+
return {
|
|
10341
|
+
success: true,
|
|
10342
|
+
data: utilities.map((u) => ({ name: u.name, description: utilityOneLiner(u.description) })),
|
|
10343
|
+
};
|
|
10344
|
+
case 'describe': {
|
|
10345
|
+
const def = utilities.find((u) => u.name === input.name);
|
|
10346
|
+
if (!def) {
|
|
10347
|
+
return { success: false, isError: true, error: `No utility "${String(input.name)}". Available: ${available()}` };
|
|
10348
|
+
}
|
|
10349
|
+
return { success: true, data: def };
|
|
10350
|
+
}
|
|
10351
|
+
case 'run': {
|
|
10352
|
+
const def = utilities.find((u) => u.name === input.name);
|
|
10353
|
+
if (!def) {
|
|
10354
|
+
return { success: false, isError: true, error: `No utility "${String(input.name)}". Available: ${available()}` };
|
|
10355
|
+
}
|
|
10356
|
+
const args = input.args ?? {};
|
|
10357
|
+
const problem = validateUtilityArgs(args, def.inputSchema);
|
|
10358
|
+
if (problem) {
|
|
10359
|
+
// Teach by bounce: the error carries the schema, so the retry needs
|
|
10360
|
+
// no describe round-trip.
|
|
10361
|
+
return {
|
|
10362
|
+
success: false,
|
|
10363
|
+
isError: true,
|
|
10364
|
+
error: `${problem}. Schema for ${def.name}: ${JSON.stringify(def.inputSchema)}`,
|
|
10365
|
+
};
|
|
10366
|
+
}
|
|
10367
|
+
// Same dispatch as a first-class tool call — the module cannot tell
|
|
10368
|
+
// which surface the call came through.
|
|
10369
|
+
return this.moduleRegistry.handleToolCall({
|
|
10370
|
+
id: call.id,
|
|
10371
|
+
name: def.name,
|
|
10372
|
+
input: args,
|
|
10373
|
+
callerAgentName: agentName,
|
|
10374
|
+
});
|
|
10375
|
+
}
|
|
10376
|
+
default:
|
|
10377
|
+
return { success: false, isError: true, error: 'action must be "list", "describe", or "run"' };
|
|
10378
|
+
}
|
|
10379
|
+
}
|
|
10380
|
+
|
|
10381
|
+
private dispatchUtilsToolCall(agentName: string, call: ToolCall): void {
|
|
10382
|
+
this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'utils', callId: call.id, input: call.input });
|
|
10383
|
+
const startedAt = Date.now();
|
|
10384
|
+
void this.handleUtilsToolCall(agentName, call)
|
|
10385
|
+
.catch((err): ToolResult => ({
|
|
10386
|
+
success: false,
|
|
10387
|
+
error: err instanceof Error ? err.message : String(err),
|
|
10388
|
+
isError: true,
|
|
10389
|
+
}))
|
|
10390
|
+
.then((result) => {
|
|
10391
|
+
if (result.success) {
|
|
10392
|
+
this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'utils', callId: call.id, durationMs: Date.now() - startedAt });
|
|
10393
|
+
} else {
|
|
10394
|
+
this.emitTrace({ type: 'tool:failed', module: 'framework', tool: 'utils', callId: call.id, error: result.error ?? 'unknown error' });
|
|
10395
|
+
}
|
|
10396
|
+
this.pushEvent({
|
|
10397
|
+
type: 'tool-result',
|
|
10398
|
+
callId: call.id,
|
|
10399
|
+
agentName,
|
|
10400
|
+
moduleName: 'framework',
|
|
10401
|
+
result,
|
|
10402
|
+
});
|
|
10403
|
+
});
|
|
10404
|
+
}
|
|
10405
|
+
|
|
8528
10406
|
private dispatchAgentSettingsToolCall(agentName: string, call: ToolCall): void {
|
|
8529
10407
|
this.emitTrace({
|
|
8530
10408
|
type: 'tool:started',
|
|
@@ -8775,12 +10653,11 @@ export class AgentFramework {
|
|
|
8775
10653
|
conversationId: agent.name,
|
|
8776
10654
|
turnIndex: 0, // Simplified; needs per-conversation counter TODO
|
|
8777
10655
|
userMessage: null, // Could extract from trigger context
|
|
8778
|
-
|
|
8779
|
-
|
|
8780
|
-
|
|
8781
|
-
|
|
8782
|
-
|
|
8783
|
-
},
|
|
10656
|
+
// Truthful fields only (§10.1 honesty over completeness): id is real,
|
|
10657
|
+
// vendor/contextWindow/capabilities have no source in this host and
|
|
10658
|
+
// are omitted rather than fabricated — the hardcoded 200000 was false
|
|
10659
|
+
// for residents configured at 300k/600k.
|
|
10660
|
+
model: { id: agent.model },
|
|
8784
10661
|
channels: this.channelRegistry?.buildChannelContext(agent.name),
|
|
8785
10662
|
};
|
|
8786
10663
|
}
|