@animalabs/agent-framework 0.8.0 → 0.10.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 +11 -1
- 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/framework.d.ts +184 -18
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1323 -246
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +73 -7
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +399 -22
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/types.d.ts +56 -0
- package/dist/src/gate/types.d.ts.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 +3 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +163 -36
- 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-grammar.d.ts +11 -0
- package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
- package/dist/src/mcpl/prose-grammar.js +21 -0
- package/dist/src/mcpl/prose-grammar.js.map +1 -1
- package/dist/src/mcpl/prose-stream-router.d.ts +11 -2
- package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
- package/dist/src/mcpl/prose-stream-router.js +73 -13
- 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.map +1 -1
- package/dist/src/mcpl/transport.js +10 -0
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +159 -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/modules/workspace/index.d.ts +23 -0
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +48 -0
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/sync.d.ts +11 -2
- package/dist/src/modules/workspace/sync.d.ts.map +1 -1
- package/dist/src/modules/workspace/sync.js +18 -8
- 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 +20 -0
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +26 -5
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/types/agent.d.ts +39 -1
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +16 -0
- package/dist/src/types/framework.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/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/conversation-fork-injection-scoping.test.js +8 -3
- package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
- package/dist/test/event-gate-passthrough.test.d.ts +15 -0
- package/dist/test/event-gate-passthrough.test.d.ts.map +1 -0
- package/dist/test/event-gate-passthrough.test.js +289 -0
- package/dist/test/event-gate-passthrough.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/explicit-prose-routing.test.js +137 -7
- package/dist/test/explicit-prose-routing.test.js.map +1 -1
- package/dist/test/fixtures/awareness-barrier-mcpl-server.mjs +276 -0
- package/dist/test/fixtures/flaky-mcpl-server.mjs +41 -0
- package/dist/test/machine-close-provenance.test.js +6 -0
- package/dist/test/machine-close-provenance.test.js.map +1 -1
- 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/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 +199 -1
- package/dist/test/present-while-acting.test.js.map +1 -1
- package/dist/test/prose-stream-router.test.js +43 -0
- package/dist/test/prose-stream-router.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 +472 -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/xml-round-persist.test.d.ts +19 -0
- package/dist/test/xml-round-persist.test.d.ts.map +1 -0
- package/dist/test/xml-round-persist.test.js +235 -0
- package/dist/test/xml-round-persist.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -4
- package/src/agent.ts +38 -4
- package/src/framework.ts +1488 -272
- package/src/gate/event-gate.ts +427 -24
- package/src/gate/types.ts +58 -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 +167 -39
- 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-grammar.ts +21 -0
- package/src/mcpl/prose-stream-router.ts +65 -14
- 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 +11 -0
- package/src/mcpl/types.ts +176 -78
- package/src/modules/workspace/index.ts +48 -0
- package/src/modules/workspace/sync.ts +30 -7
- package/src/refusal-reactions.ts +37 -0
- package/src/tool-result-history.ts +27 -5
- package/src/types/agent.ts +40 -1
- package/src/types/framework.ts +17 -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
|
-
import { parseProsePrefix } from './mcpl/prose-grammar.js';
|
|
65
|
+
import { parseProsePrefix, parseHybridProsePrefix } 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
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 {
|
|
@@ -119,6 +126,9 @@ const SILENCING_TOOLS = new Set([
|
|
|
119
126
|
'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
|
|
120
127
|
]);
|
|
121
128
|
|
|
129
|
+
/** World-surface publication names that outrank hybrid prose envelopes. */
|
|
130
|
+
const HYBRID_PUBLICATION_TOOLS = new Set(['say', 'whisper']);
|
|
131
|
+
|
|
122
132
|
/**
|
|
123
133
|
* True when an injected message is real conversational input — something the
|
|
124
134
|
* agent might actually be replying to — rather than ambient machinery.
|
|
@@ -134,6 +144,43 @@ const isConversationalInjection = (metadata?: MessageMetadata): boolean => {
|
|
|
134
144
|
return true;
|
|
135
145
|
};
|
|
136
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Rough token estimate for the blocks a continuation round is about to
|
|
149
|
+
* append to the live wire (issue #92 physical-window projection): the
|
|
150
|
+
* spilled tool-result strings at chars/4, a flat per-image cost for native
|
|
151
|
+
* image blocks the wire path preserves (provider vision tokens are
|
|
152
|
+
* resolution-dependent; 1600 ≈ a max-size Anthropic tile), mid-turn injected
|
|
153
|
+
* text at chars/4, and a small per-block envelope allowance. Deliberately a
|
|
154
|
+
* ceiling-ish heuristic — the cost of overestimating is one early recompile;
|
|
155
|
+
* the cost of underestimating is the 400 this projection exists to prevent.
|
|
156
|
+
*/
|
|
157
|
+
function estimateAppendedRoundTokens(
|
|
158
|
+
toolResults: CompletedToolCall[],
|
|
159
|
+
spilled: Map<string, { text: string; filePath: string | null }>,
|
|
160
|
+
injections: Array<{ content: ContentBlock[] }>,
|
|
161
|
+
): number {
|
|
162
|
+
let chars = 0;
|
|
163
|
+
let images = 0;
|
|
164
|
+
for (const tc of toolResults) {
|
|
165
|
+
chars += spilled.get(tc.id)?.text.length ?? 0;
|
|
166
|
+
if (Array.isArray(tc.result.data)) {
|
|
167
|
+
for (const raw of tc.result.data) {
|
|
168
|
+
const b = raw as { type?: unknown };
|
|
169
|
+
if (b && typeof b === 'object' && b.type === 'image') images += 1;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const inj of injections) {
|
|
174
|
+
for (const block of inj.content) {
|
|
175
|
+
if (block.type === 'text' && typeof (block as { text?: unknown }).text === 'string') {
|
|
176
|
+
chars += (block as { text: string }).text.length;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const blockCount = toolResults.length + injections.length;
|
|
181
|
+
return Math.ceil(chars / 4) + images * 1600 + blockCount * 20;
|
|
182
|
+
}
|
|
183
|
+
|
|
137
184
|
/**
|
|
138
185
|
* Explicit delivery tools whose successful use into a closed channel OPENS
|
|
139
186
|
* it (send implies engagement — see openIfClosedForSend). Bare tool names,
|
|
@@ -181,11 +228,18 @@ const isAddressedMessage = (
|
|
|
181
228
|
// envelopes can never disagree on what is a prefix.
|
|
182
229
|
|
|
183
230
|
/** One-time primer appended when an agent's proseRouting mode changes. */
|
|
184
|
-
function proseModePrimer(mode: 'explicit' | 'locus'): string {
|
|
231
|
+
function proseModePrimer(mode: 'explicit' | 'hybrid' | 'locus'): string {
|
|
185
232
|
if (mode === 'locus') {
|
|
186
233
|
return '[prose-routing] Mode change: your plain text auto-routes to the ' +
|
|
187
234
|
'conversational locus again. `>>` destination prefixes are no longer needed.';
|
|
188
235
|
}
|
|
236
|
+
if (mode === 'hybrid') {
|
|
237
|
+
return '[prose-routing] Hybrid routing enabled: unprefixed text still lands in the ' +
|
|
238
|
+
'current conversational locus. A leading `>>>destination` envelope instead routes ' +
|
|
239
|
+
'that prose to one uniquely resolved authorized Discord or Eidoverse channel. The ' +
|
|
240
|
+
'envelope remains in your memory but recipients see only its body; delivery or failure ' +
|
|
241
|
+
'is reported back to you.';
|
|
242
|
+
}
|
|
189
243
|
// Deliberately terse: a short event-style notice is classifier-safe from
|
|
190
244
|
// the user role (ablation D, 2026-07-24), while the full grammar as a user
|
|
191
245
|
// message drew a deterministic reasoning_extraction refusal (ablation A/B).
|
|
@@ -285,7 +339,7 @@ function validateUtilityArgs(
|
|
|
285
339
|
}
|
|
286
340
|
import { CheckpointManager } from './mcpl/checkpoint-manager.js';
|
|
287
341
|
import { isToolAllowed } from './mcpl/tool-policy.js';
|
|
288
|
-
import { EventGate } from './gate/event-gate.js';
|
|
342
|
+
import { EventGate, formatShadowWarning } from './gate/event-gate.js';
|
|
289
343
|
import { UsageTracker, type PersistedUsageState } from './usage/usage-tracker.js';
|
|
290
344
|
import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js';
|
|
291
345
|
import type { McplServerConnection } from './mcpl/server-connection.js';
|
|
@@ -293,10 +347,7 @@ import type {
|
|
|
293
347
|
McplServerConfig,
|
|
294
348
|
McplHostCapabilities,
|
|
295
349
|
FeatureSetsChangedParams,
|
|
296
|
-
ScopeElevateParams,
|
|
297
|
-
ScopeElevateResult,
|
|
298
350
|
BeforeInferenceParams,
|
|
299
|
-
AfterInferenceParams,
|
|
300
351
|
PushEventParams,
|
|
301
352
|
McplInferenceRequestParams,
|
|
302
353
|
ChannelsRegisterParams,
|
|
@@ -587,8 +638,45 @@ export class AgentFramework {
|
|
|
587
638
|
* plain prose lands (2026-07-21 Cairn lounge misroute). Lives here — not in
|
|
588
639
|
* driveStream locals — so the pin survives a context-budget stream restart,
|
|
589
640
|
* which continues the same logical turn in a fresh driveStream. Cleared or
|
|
590
|
-
* re-set at the start of every non-restart turn.
|
|
641
|
+
* re-set at the start of every non-restart turn. Since 2026-07-31 the pin
|
|
642
|
+
* is no longer fully frozen: an ADDRESSED conversational injection — or a
|
|
643
|
+
* conversational follow-up in a channel the agent itself explicitly sent
|
|
644
|
+
* into this turn (turnEngagedChannels) — re-pins it at the boundary (see
|
|
645
|
+
* the addressed re-pin block in the tool-result handler). Ambient chatter
|
|
646
|
+
* elsewhere, reactions, and system markers still cannot move it. */
|
|
591
647
|
private turnLocusPins: Map<string, string> = new Map();
|
|
648
|
+
/** Channels the agent EXPLICITLY sent into during the CURRENT turn
|
|
649
|
+
* (SEND_ENGAGEMENT_TOOLS, successful calls), per agent. Second re-pin
|
|
650
|
+
* signal: a conversational reply in a channel the agent just engaged is
|
|
651
|
+
* addressed by context even without a mention (2026-07-31 n=7: q's
|
|
652
|
+
* #portables follow-up — no @mention — was answered in trailing prose
|
|
653
|
+
* that followed the stale pin into repligate's DM). No author-kind
|
|
654
|
+
* filter: agent-residents are full participants, and the bot flag
|
|
655
|
+
* tracks nothing that matters (antra). Reactions/system markers are
|
|
656
|
+
* excluded by isConversationalInjection; the engaged-this-turn scope
|
|
657
|
+
* keeps unrelated channels from moving the pin. Cleared at every fresh
|
|
658
|
+
* turn's start; budget restarts keep it. */
|
|
659
|
+
private turnEngagedChannels: Map<string, Set<string>> = new Map();
|
|
660
|
+
/** Channels this turn's PLAIN PROSE was actually delivered to, in delivery
|
|
661
|
+
* order (deduped at render). Feeds the `[delivered]` receipt appended at
|
|
662
|
+
* logical turn end: explicit sends receipt themselves via their
|
|
663
|
+
* tool_result, but auto-routed prose previously vanished into the router
|
|
664
|
+
* with no in-window record — the agent could never see where its own
|
|
665
|
+
* words landed (2026-07-31 misroute series; antra: minimal receipts).
|
|
666
|
+
* Cleared at every fresh turn's start; budget restarts keep it (same
|
|
667
|
+
* logical turn, receipt covers the whole turn). */
|
|
668
|
+
private turnProseDeliveries: Map<string, string[]> = new Map();
|
|
669
|
+
/** Count of plain-prose segments SUPPRESSED this turn by sticky
|
|
670
|
+
* explicit-send silencing. The suppression rule itself is untouched
|
|
671
|
+
* (antra, 2026-07-31: visibility is enough) — but it must never be a
|
|
672
|
+
* silent black hole: Mythos multiplexed two threads in one round (prose
|
|
673
|
+
* for the lane + explicit send for another channel, textbook per the
|
|
674
|
+
* routing doc), the silencing ate the prose reply, and his own record
|
|
675
|
+
* ("scene delivered to #mythos via plain speech — routing confirmed")
|
|
676
|
+
* believed it delivered. The receipt now reports suppression, so the
|
|
677
|
+
* author sees the segment's fate one turn later. Cleared each fresh
|
|
678
|
+
* turn; budget restarts keep it. */
|
|
679
|
+
private turnProseSuppressed: Map<string, number> = new Map();
|
|
592
680
|
/** A tool boundary injected fresh CONVERSATIONAL input (a real message —
|
|
593
681
|
* not a reaction or a system marker) into the live stream. Tells
|
|
594
682
|
* driveStream to clear sticky explicit-send suppression before handling
|
|
@@ -606,6 +694,8 @@ export class AgentFramework {
|
|
|
606
694
|
/** Sticky per-TURN delivery target set by the turn's first `>>` prefix.
|
|
607
695
|
* Cleared at every non-restart turn start; survives context restarts. */
|
|
608
696
|
private proseTargetPins: Map<string, string> = new Map();
|
|
697
|
+
/** Hybrid router is fail-closed after a malformed/unresolved envelope until a new valid target. */
|
|
698
|
+
private proseHybridSuppressed: Set<string> = new Set();
|
|
609
699
|
/** Agents whose current turn requested `!` continuation — re-woken when the
|
|
610
700
|
* turn completes instead of pausing until the next external event. */
|
|
611
701
|
private proseContinuations: Set<string> = new Set();
|
|
@@ -708,7 +798,6 @@ export class AgentFramework {
|
|
|
708
798
|
// MCPL subsystems (null when no mcplServers configured)
|
|
709
799
|
private mcplServerRegistry: McplServerRegistry | null = null;
|
|
710
800
|
private featureSetManager: FeatureSetManager | null = null;
|
|
711
|
-
private scopeManager: ScopeManager | null = null;
|
|
712
801
|
private hookOrchestrator: HookOrchestrator | null = null;
|
|
713
802
|
private pushHandler: PushHandler | null = null;
|
|
714
803
|
private inferenceRouter: InferenceRouter | null = null;
|
|
@@ -746,12 +835,18 @@ export class AgentFramework {
|
|
|
746
835
|
* provenance envelope + payload and requests inference. Keyed by script id. */
|
|
747
836
|
private backgroundScripts: Map<string, BackgroundScriptRecord> = new Map();
|
|
748
837
|
private backgroundScriptCounter = 0;
|
|
749
|
-
/**
|
|
750
|
-
*
|
|
751
|
-
*
|
|
838
|
+
/** Resident-set per-agent tool-result inline cap (chars), via
|
|
839
|
+
* agent_settings `tool_result_inline_max_chars`. DURABLE: persisted in
|
|
840
|
+
* framework state like the core runtime settings and restored at create
|
|
841
|
+
* (antra + Sol, 08-06); reset clears it back to the residence default. */
|
|
752
842
|
private toolResultInlineMaxCharsOverride: Map<string, number> = new Map();
|
|
843
|
+
/** Durable residence-configured inline cap from
|
|
844
|
+
* FrameworkConfig.toolResultInlineMaxChars; null → house default. */
|
|
845
|
+
private toolResultInlineMaxCharsConfig: number | null = null;
|
|
753
846
|
|
|
754
847
|
private mcplTools: import('./types/index.js').ToolDefinition[] = [];
|
|
848
|
+
/** Namespaced tool name → stateful feature-set attribution from tools/list. */
|
|
849
|
+
private mcplToolFeatureSets: Map<string, string> = new Map();
|
|
755
850
|
private mcplToolRefreshInFlight = false;
|
|
756
851
|
private mcplToolRefreshPending = false;
|
|
757
852
|
/** Maps tool prefix → serverId for dispatch routing. */
|
|
@@ -1027,6 +1122,20 @@ export class AgentFramework {
|
|
|
1027
1122
|
// retained when enabled — everything downstream gates on the field.
|
|
1028
1123
|
framework.codeExecutionConfig = config.codeExecution?.enabled ? config.codeExecution : null;
|
|
1029
1124
|
|
|
1125
|
+
if (config.toolResultInlineMaxChars !== undefined) {
|
|
1126
|
+
const cap = config.toolResultInlineMaxChars;
|
|
1127
|
+
if (!Number.isFinite(cap) || cap < 1000) {
|
|
1128
|
+
throw new Error('FrameworkConfig.toolResultInlineMaxChars must be a number >= 1000');
|
|
1129
|
+
}
|
|
1130
|
+
framework.toolResultInlineMaxCharsConfig = Math.floor(cap);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Restore resident-set inline caps (agent_settings) — durable like the
|
|
1134
|
+
// core runtime settings, same framework/state slot (antra + Sol, 08-06).
|
|
1135
|
+
for (const [agentName, cap] of Object.entries(framework.readPersistedToolResultInlineCaps())) {
|
|
1136
|
+
framework.toolResultInlineMaxCharsOverride.set(agentName, cap);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1030
1139
|
// Initialize MCPL subsystems if configured
|
|
1031
1140
|
if (config.mcplServers && config.mcplServers.length > 0) {
|
|
1032
1141
|
// Validate tool prefixes: no collisions with module names or between servers
|
|
@@ -1468,10 +1577,11 @@ export class AgentFramework {
|
|
|
1468
1577
|
private collectAgentSettingsExtensions(): Map<string, AgentSettingsExtension> {
|
|
1469
1578
|
const result = new Map<string, AgentSettingsExtension>();
|
|
1470
1579
|
const taken = new Set<string>(AgentFramework.AGENT_SETTINGS_CORE_KEYS);
|
|
1471
|
-
// Framework-owned extension: the tool-result inline cap
|
|
1472
|
-
// through the same extension surface modules use so
|
|
1473
|
-
// work with zero extra plumbing.
|
|
1474
|
-
//
|
|
1580
|
+
// Framework-owned extension: the resident's tool-result inline cap.
|
|
1581
|
+
// Registered through the same extension surface modules use so
|
|
1582
|
+
// get/update/reset all work with zero extra plumbing. DURABLE: persisted
|
|
1583
|
+
// in framework state and restored at create, like the core runtime
|
|
1584
|
+
// settings (antra + Sol, 08-06); reset returns to the residence default.
|
|
1475
1585
|
{
|
|
1476
1586
|
const ext = this.spillSettingsExtension();
|
|
1477
1587
|
ext.keys.forEach((k) => taken.add(k));
|
|
@@ -1500,29 +1610,41 @@ export class AgentFramework {
|
|
|
1500
1610
|
tool_result_inline_max_chars: {
|
|
1501
1611
|
type: 'number',
|
|
1502
1612
|
description:
|
|
1503
|
-
'Inline size cap (chars) for tool results and
|
|
1504
|
-
'Content over the cap is written to a workspace
|
|
1505
|
-
'replaced by a truncated preview + file reference.
|
|
1506
|
-
'
|
|
1507
|
-
'
|
|
1613
|
+
'Inline size cap (chars) for tool results (successes and errors) and ' +
|
|
1614
|
+
'background-script wake payloads. Content over the cap is written to a workspace ' +
|
|
1615
|
+
'file under tool-results/ and replaced by a truncated preview + file reference. ' +
|
|
1616
|
+
'This is YOUR durable setting: it persists across restarts, like your other ' +
|
|
1617
|
+
'agent_settings. Update it when you want a different inline size; reset restores ' +
|
|
1618
|
+
'the residence default. The effective cap is min(your value, your strategy\'s ' +
|
|
1619
|
+
'per-message bound) — the bound is a safety ceiling, and the full content is ' +
|
|
1620
|
+
'always in the spill file. get reports desired/_effective/_source/_clamped_by.',
|
|
1508
1621
|
},
|
|
1509
1622
|
},
|
|
1510
1623
|
keys: ['tool_result_inline_max_chars'],
|
|
1511
|
-
get: (agentName: string) =>
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1624
|
+
get: (agentName: string) => {
|
|
1625
|
+
const agent = this.agents.get(agentName);
|
|
1626
|
+
const resolved = agent ? this.resolveToolResultInlineCap(agent) : null;
|
|
1627
|
+
return {
|
|
1628
|
+
tool_result_inline_max_chars:
|
|
1629
|
+
this.toolResultInlineMaxCharsOverride.get(agentName) ?? null,
|
|
1630
|
+
tool_result_inline_max_chars_effective: resolved?.cap ?? null,
|
|
1631
|
+
tool_result_inline_max_chars_source: resolved?.source ?? null,
|
|
1632
|
+
tool_result_inline_max_chars_clamped_by: resolved?.clampedBy ?? null,
|
|
1633
|
+
};
|
|
1634
|
+
},
|
|
1515
1635
|
update: (agentName: string, patch: Record<string, unknown>) => {
|
|
1516
1636
|
const n = Number(patch.tool_result_inline_max_chars);
|
|
1517
1637
|
if (!Number.isFinite(n) || n < 1000) {
|
|
1518
1638
|
throw new Error('tool_result_inline_max_chars must be a number >= 1000');
|
|
1519
1639
|
}
|
|
1520
1640
|
this.toolResultInlineMaxCharsOverride.set(agentName, Math.floor(n));
|
|
1641
|
+
this.persistToolResultInlineCap(agentName, Math.floor(n));
|
|
1521
1642
|
return { tool_result_inline_max_chars: Math.floor(n) };
|
|
1522
1643
|
},
|
|
1523
1644
|
reset: (agentName: string, keys?: string[]) => {
|
|
1524
1645
|
if (!keys || keys.includes('tool_result_inline_max_chars')) {
|
|
1525
1646
|
this.toolResultInlineMaxCharsOverride.delete(agentName);
|
|
1647
|
+
this.persistToolResultInlineCap(agentName, null);
|
|
1526
1648
|
}
|
|
1527
1649
|
return { tool_result_inline_max_chars: null };
|
|
1528
1650
|
},
|
|
@@ -1858,12 +1980,14 @@ export class AgentFramework {
|
|
|
1858
1980
|
/**
|
|
1859
1981
|
* Add or replace (upsert) a gate policy at runtime. Validated and persisted
|
|
1860
1982
|
* to gate.json; hot-applied in memory. Throws if no gate is configured or the
|
|
1861
|
-
* policy is invalid.
|
|
1862
|
-
*
|
|
1983
|
+
* policy is invalid. Placement: `'prepend'` puts a rule ahead of EVERYTHING
|
|
1984
|
+
* (including addressed-message wake rules — see EventGate.lintShadows);
|
|
1985
|
+
* `{before: name}` / `{after: name}` anchor it against a specific rule,
|
|
1986
|
+
* which is usually what a sampler or debounce actually wants.
|
|
1863
1987
|
*/
|
|
1864
1988
|
addGatePolicy(
|
|
1865
1989
|
rawPolicy: unknown,
|
|
1866
|
-
options?: { position?: 'append' | 'prepend' },
|
|
1990
|
+
options?: { position?: 'append' | 'prepend' | { before: string } | { after: string } },
|
|
1867
1991
|
): import('./gate/types.js').GatePolicy {
|
|
1868
1992
|
if (!this.eventGate) {
|
|
1869
1993
|
throw new Error('No EventGate configured (FrameworkConfig.gate is unset).');
|
|
@@ -1952,6 +2076,44 @@ export class AgentFramework {
|
|
|
1952
2076
|
return overrides;
|
|
1953
2077
|
}
|
|
1954
2078
|
|
|
2079
|
+
/** Load resident-set inline caps from framework state, dropping (loudly)
|
|
2080
|
+
* anything that fails the same validation the live update enforces. */
|
|
2081
|
+
private readPersistedToolResultInlineCaps(): Record<string, number> {
|
|
2082
|
+
try {
|
|
2083
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID) as {
|
|
2084
|
+
toolResultInlineCaps?: Record<string, unknown>;
|
|
2085
|
+
} | null;
|
|
2086
|
+
const out: Record<string, number> = {};
|
|
2087
|
+
for (const [name, value] of Object.entries(data?.toolResultInlineCaps ?? {})) {
|
|
2088
|
+
const n = Number(value);
|
|
2089
|
+
if (Number.isFinite(n) && n >= 1000) {
|
|
2090
|
+
out[name] = Math.floor(n);
|
|
2091
|
+
} else {
|
|
2092
|
+
console.error(
|
|
2093
|
+
`[agent-settings] dropping invalid persisted tool_result_inline_max_chars for '${name}': ${JSON.stringify(value)}`,
|
|
2094
|
+
);
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
return out;
|
|
2098
|
+
} catch {
|
|
2099
|
+
return {};
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
/** Write (cap) or clear (null) one agent's resident-set inline cap. */
|
|
2104
|
+
private persistToolResultInlineCap(agentName: string, cap: number | null): void {
|
|
2105
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
2106
|
+
const state = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>;
|
|
2107
|
+
const all = {
|
|
2108
|
+
...((state.toolResultInlineCaps as Record<string, number> | undefined) ?? {}),
|
|
2109
|
+
};
|
|
2110
|
+
if (cap === null) delete all[agentName];
|
|
2111
|
+
else all[agentName] = cap;
|
|
2112
|
+
if (Object.keys(all).length === 0) delete state.toolResultInlineCaps;
|
|
2113
|
+
else state.toolResultInlineCaps = all;
|
|
2114
|
+
this.store.setStateJson(FRAMEWORK_STATE_ID, state);
|
|
2115
|
+
}
|
|
2116
|
+
|
|
1955
2117
|
private persistAgentRuntimeSettings(
|
|
1956
2118
|
agentName: string,
|
|
1957
2119
|
overrides: AgentRuntimeSettingsOverrides,
|
|
@@ -2202,9 +2364,17 @@ export class AgentFramework {
|
|
|
2202
2364
|
description:
|
|
2203
2365
|
'Add or replace a wake rule (a gate.json policy) at runtime — no need to ' +
|
|
2204
2366
|
'hand-edit the file. The rule is validated and hot-applied immediately. ' +
|
|
2205
|
-
'A rule with the same `name` replaces the existing one in place
|
|
2206
|
-
'
|
|
2207
|
-
'
|
|
2367
|
+
'A rule with the same `name` replaces the existing one in place (add a ' +
|
|
2368
|
+
'position to move it). ⚠️ ORDERING IS SEMANTICS: first match wins, and a ' +
|
|
2369
|
+
'matched rule CONSUMES the event — a broad rule placed early makes every ' +
|
|
2370
|
+
'later rule unreachable for its scope, including your addressed-message ' +
|
|
2371
|
+
'(DM/mention) wake rules. Prefer `insertBefore`/`insertAfter` to place a ' +
|
|
2372
|
+
'rule relative to a specific existing rule; use `position: "prepend"` only ' +
|
|
2373
|
+
'when the rule really must beat EVERYTHING. For "additionally wake me ' +
|
|
2374
|
+
'every Nth event / at most N per hour" governors, set `passthrough: true` ' +
|
|
2375
|
+
'so non-firing matches fall through to your normal rules instead of ' +
|
|
2376
|
+
'swallowing them. The result includes a before/after probe table and any ' +
|
|
2377
|
+
'shadow warnings — READ THEM before ending the turn. Two common shapes:\n' +
|
|
2208
2378
|
'• Watch a FILE/workspace path: match on `mount` + `pathGlob`, e.g. ' +
|
|
2209
2379
|
'`{ name: "watch-notes", match: { scope: ["workspace:modified"], mount: "project", pathGlob: "notes/*.md" }, behavior: "always" }`.\n' +
|
|
2210
2380
|
'• Watch a CHANNEL: match on `source` + `channel` (and/or `tagsAny: ["chat:ambient"]`), ' +
|
|
@@ -2272,7 +2442,30 @@ export class AgentFramework {
|
|
|
2272
2442
|
position: {
|
|
2273
2443
|
type: 'string',
|
|
2274
2444
|
enum: ['append', 'prepend'],
|
|
2275
|
-
description:
|
|
2445
|
+
description:
|
|
2446
|
+
'Coarse placement. "append" (default) adds at the end; "prepend" puts the rule ' +
|
|
2447
|
+
'ahead of EVERYTHING — including addressed-message wake rules — so prefer ' +
|
|
2448
|
+
'insertBefore/insertAfter unless the rule really must beat every other rule. ' +
|
|
2449
|
+
'When replacing an existing name, giving any placement MOVES the rule there; ' +
|
|
2450
|
+
'omit all placement fields to update it in place.',
|
|
2451
|
+
},
|
|
2452
|
+
insertBefore: {
|
|
2453
|
+
type: 'string',
|
|
2454
|
+
description:
|
|
2455
|
+
'Insert the rule immediately BEFORE the named existing rule (error if absent). ' +
|
|
2456
|
+
'The safe way to order against a specific catch-all, e.g. insertBefore: "discord-ambient".',
|
|
2457
|
+
},
|
|
2458
|
+
insertAfter: {
|
|
2459
|
+
type: 'string',
|
|
2460
|
+
description: 'Insert the rule immediately AFTER the named existing rule (error if absent).',
|
|
2461
|
+
},
|
|
2462
|
+
passthrough: {
|
|
2463
|
+
type: 'boolean',
|
|
2464
|
+
description:
|
|
2465
|
+
'Observer semantics for rateLimit/passiveSample rules: when the rule matches but ' +
|
|
2466
|
+
'does not fire (below N, bucket empty), the event FALLS THROUGH to later rules ' +
|
|
2467
|
+
'instead of being consumed. Use for "additionally wake me every Nth event" ' +
|
|
2468
|
+
'governors so they never swallow DM/mention wakes.',
|
|
2276
2469
|
},
|
|
2277
2470
|
resets: {
|
|
2278
2471
|
type: 'array',
|
|
@@ -2434,15 +2627,6 @@ export class AgentFramework {
|
|
|
2434
2627
|
'chat:thread': 'Occurred in a thread',
|
|
2435
2628
|
};
|
|
2436
2629
|
|
|
2437
|
-
/** Refusal category → Discord reaction emoji. Unknown categories get 🛑. */
|
|
2438
|
-
private static readonly REFUSAL_REACTIONS: Record<string, string> = {
|
|
2439
|
-
bio: '☣️',
|
|
2440
|
-
chem: '🧪',
|
|
2441
|
-
nuclear: '☢️',
|
|
2442
|
-
cyber: '💻',
|
|
2443
|
-
reasoning_extraction: '🧠',
|
|
2444
|
-
};
|
|
2445
|
-
|
|
2446
2630
|
/**
|
|
2447
2631
|
* Mark an inference refusal visibly: react on the message that holds the
|
|
2448
2632
|
* conversational locus (the most recent incoming channel message) with a
|
|
@@ -2459,7 +2643,7 @@ export class AgentFramework {
|
|
|
2459
2643
|
const parts = incoming.channelId.split(':');
|
|
2460
2644
|
if (parts[0] !== 'discord') return;
|
|
2461
2645
|
const channelId = parts[parts.length - 1];
|
|
2462
|
-
const emoji =
|
|
2646
|
+
const emoji = REFUSAL_REACTIONS[category] ?? REFUSAL_REACTION_FALLBACK;
|
|
2463
2647
|
// Resolve the MCPL server that owns the locus channel and call
|
|
2464
2648
|
// tools/call directly on its connection (bare tool name — no prefix
|
|
2465
2649
|
// games), bypassing the agent event queue so no synthetic tool-result
|
|
@@ -2673,6 +2857,46 @@ export class AgentFramework {
|
|
|
2673
2857
|
}
|
|
2674
2858
|
}
|
|
2675
2859
|
|
|
2860
|
+
/**
|
|
2861
|
+
* Admin-level nudge: run an inference turn on the agent's CURRENT context
|
|
2862
|
+
* without adding any message, event, or other context mutation. The turn
|
|
2863
|
+
* compiles exactly what the agent already sees — useful after an /undo,
|
|
2864
|
+
* a /hide, or whenever an operator wants the agent to take another look
|
|
2865
|
+
* (or another swing) at where it stands, with zero context pollution.
|
|
2866
|
+
*
|
|
2867
|
+
* Queues a normal inference request (same path as gate wakes): if the
|
|
2868
|
+
* agent is mid-turn it runs when the turn settles; a pending skip_reply
|
|
2869
|
+
* self-wake is superseded like any other wake. Deliberately NOT gated on
|
|
2870
|
+
* idle — "run when you're free" is the useful semantic. No suppression
|
|
2871
|
+
* interplay: gate sleep filters events, not queued requests, so a nudge
|
|
2872
|
+
* wakes even a sleeping agent (it's an admin override, like /unstick).
|
|
2873
|
+
*/
|
|
2874
|
+
nudgeAgent(agentName?: string, requestedBy?: string): {
|
|
2875
|
+
ok: boolean;
|
|
2876
|
+
error?: string;
|
|
2877
|
+
agentName?: string;
|
|
2878
|
+
/** Agent state at queue time — 'idle' means the turn starts on the next
|
|
2879
|
+
* scheduler pass; anything else means it runs after the current turn. */
|
|
2880
|
+
agentStatus?: string;
|
|
2881
|
+
} {
|
|
2882
|
+
const name = agentName ?? [...this.agents.keys()][0];
|
|
2883
|
+
const agent = name ? this.agents.get(name) : undefined;
|
|
2884
|
+
if (!name || !agent) {
|
|
2885
|
+
return { ok: false, error: `Unknown agent: ${String(agentName ?? '(none registered)')}` };
|
|
2886
|
+
}
|
|
2887
|
+
const agentStatus = this.activeTurnTokens.has(name) && agent.state.status === 'idle'
|
|
2888
|
+
? 'idle+turn-alive'
|
|
2889
|
+
: agent.state.status;
|
|
2890
|
+
this.pendingRequests.push({
|
|
2891
|
+
agentName: name,
|
|
2892
|
+
reason: requestedBy ? `admin-nudge (${requestedBy})` : 'admin-nudge',
|
|
2893
|
+
source: 'admin',
|
|
2894
|
+
timestamp: Date.now(),
|
|
2895
|
+
});
|
|
2896
|
+
console.error(`[nudge] agent=${name} queued by=${requestedBy ?? 'unknown'} status=${agentStatus}`);
|
|
2897
|
+
return { ok: true, agentName: name, agentStatus };
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2676
2900
|
/**
|
|
2677
2901
|
* Handle a `host/command` request from an MCPL surface server (e.g. a
|
|
2678
2902
|
* Discord slash command). Currently supports:
|
|
@@ -2681,6 +2905,9 @@ export class AgentFramework {
|
|
|
2681
2905
|
* `undoLastTurn`). The response includes the last message the agent
|
|
2682
2906
|
* would see in its context after the undo, obtained via the transparent
|
|
2683
2907
|
* `previewActivation` render (no inference, no Chronicle writes).
|
|
2908
|
+
*
|
|
2909
|
+
* nudge — run inference on the current context with NO new events
|
|
2910
|
+
* (see `nudgeAgent`).
|
|
2684
2911
|
*/
|
|
2685
2912
|
private async handleHostCommand(
|
|
2686
2913
|
serverId: string,
|
|
@@ -2703,8 +2930,16 @@ export class AgentFramework {
|
|
|
2703
2930
|
* outcome report is posted to the channel asynchronously. */
|
|
2704
2931
|
started?: boolean;
|
|
2705
2932
|
cap?: number;
|
|
2933
|
+
/** For `nudge`: agent state at queue time ('idle' = runs immediately,
|
|
2934
|
+
* else it runs once the current turn settles). */
|
|
2935
|
+
agentStatus?: string;
|
|
2706
2936
|
}> {
|
|
2707
|
-
if (
|
|
2937
|
+
if (
|
|
2938
|
+
params.command !== 'undo' &&
|
|
2939
|
+
params.command !== 'hide' &&
|
|
2940
|
+
params.command !== 'unstick' &&
|
|
2941
|
+
params.command !== 'nudge'
|
|
2942
|
+
) {
|
|
2708
2943
|
return { ok: false, error: `Unknown host command: ${String(params.command)}` };
|
|
2709
2944
|
}
|
|
2710
2945
|
|
|
@@ -2713,6 +2948,16 @@ export class AgentFramework {
|
|
|
2713
2948
|
return { ok: false, error: `Unknown agent: ${String(agentName)}` };
|
|
2714
2949
|
}
|
|
2715
2950
|
|
|
2951
|
+
// nudge: queue an inference on the current context — no message, no
|
|
2952
|
+
// event, no context mutation (see nudgeAgent).
|
|
2953
|
+
if (params.command === 'nudge') {
|
|
2954
|
+
const r = this.nudgeAgent(
|
|
2955
|
+
agentName,
|
|
2956
|
+
params.requesterName ?? params.requesterId ?? `mcpl:${serverId}`,
|
|
2957
|
+
);
|
|
2958
|
+
return { ok: r.ok, error: r.error, agentStatus: r.agentStatus };
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2716
2961
|
// unstick: force the refusal-rewind loop on demand (even if the agent's
|
|
2717
2962
|
// autoRewind toggle is off). Redacts the turn that fed the refusal and
|
|
2718
2963
|
// re-runs, up to `cap` times, until the model stops refusing. Kicks the
|
|
@@ -3616,38 +3861,18 @@ export class AgentFramework {
|
|
|
3616
3861
|
this.pendingAssistantBlocks.delete(agent.name);
|
|
3617
3862
|
}
|
|
3618
3863
|
|
|
3619
|
-
//
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
//
|
|
3624
|
-
//
|
|
3625
|
-
//
|
|
3626
|
-
//
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
spilled.set(tc.id, await this.spillOrTruncate(
|
|
3632
|
-
toolResultDataToHistoryString(tc.result.data, undefined),
|
|
3633
|
-
maxChars,
|
|
3634
|
-
`${dateLabel}-${tc.id}`,
|
|
3635
|
-
agent.name,
|
|
3636
|
-
));
|
|
3637
|
-
}
|
|
3638
|
-
|
|
3639
|
-
// Store tool results as a user message (tool_result blocks).
|
|
3640
|
-
// Use the history serializer so MCP image blocks become a short
|
|
3641
|
-
// `[image: type, size]` placeholder instead of megabytes of base64
|
|
3642
|
-
// that would corrupt under truncation.
|
|
3643
|
-
const toolResultContent: ContentBlock[] = currentState.toolResults.map(tc => ({
|
|
3644
|
-
type: 'tool_result' as const,
|
|
3645
|
-
toolUseId: tc.id,
|
|
3646
|
-
content: tc.result.isError
|
|
3647
|
-
? tc.result.error ?? 'Unknown error'
|
|
3648
|
-
: spilled.get(tc.id) ?? toolResultDataToHistoryString(tc.result.data, maxChars),
|
|
3649
|
-
isError: tc.result.isError,
|
|
3650
|
-
}));
|
|
3864
|
+
// Effective inline cap: agent_settings override → durable config →
|
|
3865
|
+
// house default, clamped to the strategy bound (see resolver).
|
|
3866
|
+
const maxChars = this.resolveToolResultInlineCap(agent).cap;
|
|
3867
|
+
|
|
3868
|
+
// Oversized results — successes AND errors — spill to a workspace
|
|
3869
|
+
// file (truncated preview + file reference) instead of being
|
|
3870
|
+
// blind-truncated. Computed ONCE per call and reused for both the
|
|
3871
|
+
// history copy and the live wire copy below — the two must stay
|
|
3872
|
+
// byte-matched (the window stores what the membrane sends;
|
|
3873
|
+
// divergence breaks the compile prefix).
|
|
3874
|
+
const { blocks: toolResultContent, spilled } =
|
|
3875
|
+
await this.buildStoredToolResultContent(currentState.toolResults, maxChars);
|
|
3651
3876
|
agent.getContextManager().addMessage('user', toolResultContent);
|
|
3652
3877
|
|
|
3653
3878
|
// Flush any messages that were deferred while this turn was in
|
|
@@ -3719,6 +3944,30 @@ export class AgentFramework {
|
|
|
3719
3944
|
&& agent.lastStreamInputTokens > 0
|
|
3720
3945
|
&& agent.lastStreamInputTokens > agent.maxStreamTokens;
|
|
3721
3946
|
|
|
3947
|
+
// Physical-window projection (issue #92): a turn's continuation
|
|
3948
|
+
// rounds append to the compiled request without recompiling, so a
|
|
3949
|
+
// turn that starts near the compile target can walk past the
|
|
3950
|
+
// provider's HARD cap mid-turn and take a wire 400 — the compile
|
|
3951
|
+
// was legal, the growth wasn't. Project the next round's real size
|
|
3952
|
+
// (cache-inclusive input of the prior round + the blocks about to
|
|
3953
|
+
// be appended + reserve for the response) and, when it would cross
|
|
3954
|
+
// the physical window, break the stream through the same
|
|
3955
|
+
// budget-restart path — a fresh compile folds the history back
|
|
3956
|
+
// under budget instead of dispatching a doomed request.
|
|
3957
|
+
// Projection terms: prior round's real input + prior round's
|
|
3958
|
+
// OUTPUT (the just-generated thinking/text/tool_use joins the next
|
|
3959
|
+
// round's input — omitting it is a systematic underestimate) +
|
|
3960
|
+
// blocks about to be appended + reserve for the next response.
|
|
3961
|
+
const projectedRealTokens = currentState.stream
|
|
3962
|
+
&& agent.physicalWindowTokens !== undefined
|
|
3963
|
+
&& agent.lastStreamRealInputTokens > 0
|
|
3964
|
+
? agent.lastStreamRealInputTokens
|
|
3965
|
+
+ agent.lastStreamOutputTokens
|
|
3966
|
+
+ estimateAppendedRoundTokens(currentState.toolResults, spilled, midTurnInjections)
|
|
3967
|
+
+ agent.maxTokens
|
|
3968
|
+
: 0;
|
|
3969
|
+
const overPhysical = projectedRealTokens > (agent.physicalWindowTokens ?? Infinity);
|
|
3970
|
+
|
|
3722
3971
|
// Addressed re-pin (2026-07-31 Mythos misroutes, antra-ratified):
|
|
3723
3972
|
// when someone ADDRESSES the agent mid-turn from another channel
|
|
3724
3973
|
// (mention / reply / DM — the same chat:addressed signal the
|
|
@@ -3747,15 +3996,31 @@ export class AgentFramework {
|
|
|
3747
3996
|
agent.proseRouting !== 'explicit' &&
|
|
3748
3997
|
!shouldEndTurn && !overBudget && currentState.stream
|
|
3749
3998
|
) {
|
|
3750
|
-
|
|
3999
|
+
// Two signals qualify an injection to move the pin (n=6 + n=7):
|
|
4000
|
+
// - chat:addressed — someone explicitly spoke TO the agent
|
|
4001
|
+
// (mention / reply / DM);
|
|
4002
|
+
// - a conversational follow-up in a channel the agent itself
|
|
4003
|
+
// explicitly sent into THIS turn (turnEngagedChannels) —
|
|
4004
|
+
// conversation the agent just engaged continues without
|
|
4005
|
+
// re-mentioning it (2026-07-31: q's #portables reply, no @,
|
|
4006
|
+
// answered in prose that followed the stale pin into
|
|
4007
|
+
// repligate's DM). Deliberately NO author-kind filter: this
|
|
4008
|
+
// fleet's participants include agent-residents whose speech
|
|
4009
|
+
// is as conversational as anyone's — the Discord bot flag
|
|
4010
|
+
// tracks nothing that matters here (antra, 2026-07-31).
|
|
4011
|
+
// Reactions and system markers are already excluded by
|
|
4012
|
+
// isConversationalInjection, and the engaged-this-turn scope
|
|
4013
|
+
// keeps unrelated channels from moving the pin.
|
|
4014
|
+
const engaged = this.turnEngagedChannels.get(agent.name);
|
|
4015
|
+
const lastQualifying = [...midTurnInjections].reverse().find((inj) => {
|
|
3751
4016
|
const m = inj.metadata as Record<string, unknown> | undefined;
|
|
3752
|
-
return
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
);
|
|
4017
|
+
if (!isConversationalInjection(inj.metadata)) return false;
|
|
4018
|
+
if (typeof m?.channelId !== 'string') return false;
|
|
4019
|
+
const tags = m.tags as string[] | undefined;
|
|
4020
|
+
if (isAddressedMessage(tags, m)) return true;
|
|
4021
|
+
return engaged?.has(m.channelId as string) === true;
|
|
3757
4022
|
});
|
|
3758
|
-
const newLocus = (
|
|
4023
|
+
const newLocus = (lastQualifying?.metadata as Record<string, unknown> | undefined)
|
|
3759
4024
|
?.channelId as string | undefined;
|
|
3760
4025
|
if (newLocus && this.turnLocusPins.get(agent.name) !== newLocus) {
|
|
3761
4026
|
const prevPin = this.turnLocusPins.get(agent.name) ?? null;
|
|
@@ -3768,7 +4033,7 @@ export class AgentFramework {
|
|
|
3768
4033
|
const noticeContent: ContentBlock[] = [{
|
|
3769
4034
|
type: 'text',
|
|
3770
4035
|
text:
|
|
3771
|
-
`[routing]
|
|
4036
|
+
`[routing] The conversation moved to ${shown} — your plain ` +
|
|
3772
4037
|
'speech now lands there for the rest of this turn. Other ' +
|
|
3773
4038
|
'channels need an explicit send tool.',
|
|
3774
4039
|
}];
|
|
@@ -3800,7 +4065,7 @@ export class AgentFramework {
|
|
|
3800
4065
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
3801
4066
|
this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' });
|
|
3802
4067
|
this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
|
|
3803
|
-
} else if (overBudget) {
|
|
4068
|
+
} else if (overBudget || overPhysical) {
|
|
3804
4069
|
// Context budget exceeded: break the stream, let compile() compress.
|
|
3805
4070
|
// Mark the cancel as framework-initiated BEFORE cancelling: the
|
|
3806
4071
|
// membrane delivers `aborted` before the restart bumps streamId,
|
|
@@ -3825,9 +4090,12 @@ export class AgentFramework {
|
|
|
3825
4090
|
this.emitTrace({
|
|
3826
4091
|
type: 'inference:stream_restarted',
|
|
3827
4092
|
agentName: agent.name,
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
4093
|
+
// 'physical_window' only when the budget check alone would
|
|
4094
|
+
// have let the doomed request through — overBudget keeps its
|
|
4095
|
+
// established label for existing consumers.
|
|
4096
|
+
reason: overBudget ? 'context_budget' : 'physical_window',
|
|
4097
|
+
inputTokens: overBudget ? agent.lastStreamInputTokens : projectedRealTokens,
|
|
4098
|
+
budget: overBudget ? agent.maxStreamTokens : (agent.physicalWindowTokens ?? 0),
|
|
3831
4099
|
});
|
|
3832
4100
|
this.pendingRequests.push({
|
|
3833
4101
|
agentName: agent.name,
|
|
@@ -4040,6 +4308,12 @@ export class AgentFramework {
|
|
|
4040
4308
|
? (event.metadata.guildName as string)
|
|
4041
4309
|
: undefined,
|
|
4042
4310
|
tags: event.tags,
|
|
4311
|
+
missedMessages: typeof event.metadata?.missedMessages === 'number'
|
|
4312
|
+
? event.metadata.missedMessages
|
|
4313
|
+
: undefined,
|
|
4314
|
+
missedCharacters: typeof event.metadata?.missedCharacters === 'number'
|
|
4315
|
+
? event.metadata.missedCharacters
|
|
4316
|
+
: undefined,
|
|
4043
4317
|
});
|
|
4044
4318
|
if (invitation) {
|
|
4045
4319
|
incomingContent.push(invitation.block);
|
|
@@ -4347,6 +4621,17 @@ export class AgentFramework {
|
|
|
4347
4621
|
* this should see WHO addressed it WHERE, not a wall of snowflake ids. Raw
|
|
4348
4622
|
* ids remain only inside the tool-argument instructions, where they are the
|
|
4349
4623
|
* literal values to pass.
|
|
4624
|
+
*
|
|
4625
|
+
* 2026-08-05 accuracy pass: the old "you won't receive more from it unless
|
|
4626
|
+
* you join" was wrong in both directions — direct addresses (mentions,
|
|
4627
|
+
* replies to the agent) DO still arrive, and nothing warned that answering
|
|
4628
|
+
* via option 1 leaves the human's natural un-@'d follow-up invisible (Sol
|
|
4629
|
+
* answered four #architecture mentions over four days while the follow-ups
|
|
4630
|
+
* to her own replies were silently dropped). The wording now states the
|
|
4631
|
+
* actual delivery model, and option 1 carries the consequence inline. When
|
|
4632
|
+
* the surface tracks a missed-ambient tally (discord-mcpl sends
|
|
4633
|
+
* `missedMessages`/`missedCharacters` in push origin), the invitation shows
|
|
4634
|
+
* the count — the cost of staying out, as a number, at decision time.
|
|
4350
4635
|
*/
|
|
4351
4636
|
private buildClosedChannelInvitation(opts: {
|
|
4352
4637
|
serverId: string;
|
|
@@ -4356,6 +4641,10 @@ export class AgentFramework {
|
|
|
4356
4641
|
authorName: string;
|
|
4357
4642
|
guildName?: string;
|
|
4358
4643
|
tags?: string[];
|
|
4644
|
+
/** Ambient messages dropped in this channel while closed (surface-tracked). */
|
|
4645
|
+
missedMessages?: number;
|
|
4646
|
+
/** Characters across those dropped messages. */
|
|
4647
|
+
missedCharacters?: number;
|
|
4359
4648
|
}): { block: ContentBlock; metadataPatch: Record<string, unknown> } | null {
|
|
4360
4649
|
if (!this.channelRegistry) return null;
|
|
4361
4650
|
if (!opts.tags?.includes('chat:addressed')) return null;
|
|
@@ -4365,13 +4654,23 @@ export class AgentFramework {
|
|
|
4365
4654
|
const label = opts.channelLabel ?? descriptor?.label;
|
|
4366
4655
|
const channelLabel = label ? `#${label}` : `"${opts.channelId}"`;
|
|
4367
4656
|
const place = opts.guildName ? `${channelLabel} in "${opts.guildName}"` : channelLabel;
|
|
4657
|
+
const missedCount = opts.missedMessages ?? 0;
|
|
4658
|
+
const missedNote =
|
|
4659
|
+
missedCount > 0
|
|
4660
|
+
? `While closed, ${missedCount} message${missedCount === 1 ? '' : 's'}` +
|
|
4661
|
+
(opts.missedCharacters ? ` (~${opts.missedCharacters} chars)` : '') +
|
|
4662
|
+
` have passed there without you. `
|
|
4663
|
+
: '';
|
|
4368
4664
|
const block: ContentBlock = {
|
|
4369
4665
|
type: 'text',
|
|
4370
4666
|
text:
|
|
4371
4667
|
`\n[Channel invitation] ${opts.authorName} addressed you in ${place} — ` +
|
|
4372
|
-
`a channel you haven't joined.
|
|
4668
|
+
`a channel you haven't joined. Only messages that address you directly (an @-mention or ` +
|
|
4669
|
+
`a reply to one of your messages) reach you from it; the rest of its traffic is invisible to you. ` +
|
|
4670
|
+
missedNote +
|
|
4373
4671
|
`Your options:\n` +
|
|
4374
|
-
`1. Reply without joining — write your reply this turn prefixed with ">>${channelLabel}"; it will be delivered there
|
|
4672
|
+
`1. Reply without joining — write your reply this turn prefixed with ">>${channelLabel}"; it will be delivered there. ` +
|
|
4673
|
+
`Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n` +
|
|
4375
4674
|
`2. Join the channel — call channel_open with channelId "${opts.channelId}" and serverId "${opts.serverId}"` +
|
|
4376
4675
|
(maxBackscroll > 0
|
|
4377
4676
|
? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${opts.messageId}".\n`
|
|
@@ -4434,6 +4733,9 @@ export class AgentFramework {
|
|
|
4434
4733
|
authorName: typeof origin.authorName === 'string' && origin.authorName ? origin.authorName : 'Someone',
|
|
4435
4734
|
guildName: typeof origin.guildName === 'string' && origin.guildName ? origin.guildName : undefined,
|
|
4436
4735
|
tags: event.tags,
|
|
4736
|
+
missedMessages: typeof origin.missedMessages === 'number' ? origin.missedMessages : undefined,
|
|
4737
|
+
missedCharacters:
|
|
4738
|
+
typeof origin.missedCharacters === 'number' ? origin.missedCharacters : undefined,
|
|
4437
4739
|
});
|
|
4438
4740
|
if (invitation) {
|
|
4439
4741
|
content.push(invitation.block);
|
|
@@ -4653,6 +4955,77 @@ export class AgentFramework {
|
|
|
4653
4955
|
}
|
|
4654
4956
|
}
|
|
4655
4957
|
|
|
4958
|
+
/** Record prose segments suppressed by explicit-send silencing. */
|
|
4959
|
+
private recordProseSuppression(agentName: string, count: number): void {
|
|
4960
|
+
if (count <= 0) return;
|
|
4961
|
+
this.turnProseSuppressed.set(agentName, (this.turnProseSuppressed.get(agentName) ?? 0) + count);
|
|
4962
|
+
}
|
|
4963
|
+
|
|
4964
|
+
/** Record a successful plain-prose delivery for this turn's receipt. */
|
|
4965
|
+
private recordProseDelivery(
|
|
4966
|
+
agentName: string,
|
|
4967
|
+
outcome: { delivered: boolean; channelId: string } | null | undefined,
|
|
4968
|
+
): void {
|
|
4969
|
+
if (!outcome?.delivered) return;
|
|
4970
|
+
let list = this.turnProseDeliveries.get(agentName);
|
|
4971
|
+
if (!list) {
|
|
4972
|
+
list = [];
|
|
4973
|
+
this.turnProseDeliveries.set(agentName, list);
|
|
4974
|
+
}
|
|
4975
|
+
list.push(outcome.channelId);
|
|
4976
|
+
}
|
|
4977
|
+
|
|
4978
|
+
/**
|
|
4979
|
+
* Append the turn's `[delivered]` receipt: one compact system message
|
|
4980
|
+
* naming where this turn's plain prose actually landed (channels in
|
|
4981
|
+
* delivery order, deduped). Explicit sends receipt themselves via their
|
|
4982
|
+
* tool_result; auto-routed prose previously left no in-window trace, so
|
|
4983
|
+
* the agent could never see where its own words went — misroutes were
|
|
4984
|
+
* invisible to it until a human reported them (2026-07-31 series).
|
|
4985
|
+
* Called ONLY at logical turn end (stream 'complete', or the endTurn
|
|
4986
|
+
* cancel path after the speech chain settles) — never mid-stream, where a
|
|
4987
|
+
* window-only insert would diverge from the wire. No-op when the turn
|
|
4988
|
+
* delivered nothing. Failures are already marked separately
|
|
4989
|
+
* ([discord-send-failed]); this is the success half.
|
|
4990
|
+
*/
|
|
4991
|
+
private appendProseDeliveryReceipt(agent: Agent): void {
|
|
4992
|
+
const list = this.turnProseDeliveries.get(agent.name);
|
|
4993
|
+
const suppressed = this.turnProseSuppressed.get(agent.name) ?? 0;
|
|
4994
|
+
if ((!list || list.length === 0) && suppressed === 0) return;
|
|
4995
|
+
this.turnProseDeliveries.delete(agent.name);
|
|
4996
|
+
this.turnProseSuppressed.delete(agent.name);
|
|
4997
|
+
const seen = new Set<string>();
|
|
4998
|
+
const shown: string[] = [];
|
|
4999
|
+
for (const id of list ?? []) {
|
|
5000
|
+
if (seen.has(id)) continue;
|
|
5001
|
+
seen.add(id);
|
|
5002
|
+
const label = this.channelRegistry?.getDescriptor(id)?.label;
|
|
5003
|
+
shown.push(
|
|
5004
|
+
label && label !== id
|
|
5005
|
+
? `${label.startsWith('#') ? label : `#${label}`} (${id})`
|
|
5006
|
+
: id,
|
|
5007
|
+
);
|
|
5008
|
+
}
|
|
5009
|
+
const suppressedNote =
|
|
5010
|
+
suppressed > 0
|
|
5011
|
+
? `${suppressed} plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)`
|
|
5012
|
+
: '';
|
|
5013
|
+
const text =
|
|
5014
|
+
shown.length > 0
|
|
5015
|
+
? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}`
|
|
5016
|
+
: `[delivered] nothing — ${suppressedNote}`;
|
|
5017
|
+
try {
|
|
5018
|
+
const mid = agent.getContextManager().addMessage(
|
|
5019
|
+
'user',
|
|
5020
|
+
[{ type: 'text', text }],
|
|
5021
|
+
{ system: true, kind: 'delivery-receipt' } as MessageMetadata,
|
|
5022
|
+
);
|
|
5023
|
+
this.emitTrace({ type: 'message:added', messageId: mid, source: 'delivery-receipt' });
|
|
5024
|
+
} catch (err) {
|
|
5025
|
+
console.error('delivery receipt append failed:', err);
|
|
5026
|
+
}
|
|
5027
|
+
}
|
|
5028
|
+
|
|
4656
5029
|
/**
|
|
4657
5030
|
* Announce-on-change locus notice (turn-frozen routing). Appends a durable
|
|
4658
5031
|
* `[routing]` window message when the turn's effective outbound locus
|
|
@@ -4782,6 +5155,97 @@ export class AgentFramework {
|
|
|
4782
5155
|
}
|
|
4783
5156
|
}
|
|
4784
5157
|
|
|
5158
|
+
/** Locus-preserving explicit publication. Source remains byte-identical in Chronicle. */
|
|
5159
|
+
private async deliverHybridProse(
|
|
5160
|
+
agent: Agent,
|
|
5161
|
+
rawText: string,
|
|
5162
|
+
locus: string | null,
|
|
5163
|
+
allowLocus: boolean,
|
|
5164
|
+
): Promise<void> {
|
|
5165
|
+
const lines = rawText.split('\n');
|
|
5166
|
+
const envelopes: string[] = [];
|
|
5167
|
+
let current: string[] = [];
|
|
5168
|
+
for (const line of lines) {
|
|
5169
|
+
if (line.trimStart().startsWith('>>>') && current.length > 0) {
|
|
5170
|
+
envelopes.push(current.join('\n'));
|
|
5171
|
+
current = [];
|
|
5172
|
+
}
|
|
5173
|
+
current.push(line);
|
|
5174
|
+
}
|
|
5175
|
+
if (current.length > 0) envelopes.push(current.join('\n'));
|
|
5176
|
+
|
|
5177
|
+
for (const envelope of envelopes) {
|
|
5178
|
+
if (!envelope.trim()) continue;
|
|
5179
|
+
const normalized = envelope.replace(/^\s+(?=>>>)/, '');
|
|
5180
|
+
const attempted = normalized.startsWith('>>>');
|
|
5181
|
+
const parsed = parseHybridProsePrefix(normalized);
|
|
5182
|
+
if (parsed.continueTurn) this.proseContinuations.add(agent.name);
|
|
5183
|
+
if (parsed.kind === 'private') {
|
|
5184
|
+
this.proseTargetPins.delete(agent.name);
|
|
5185
|
+
this.proseHybridSuppressed.add(agent.name);
|
|
5186
|
+
console.error(`[prose] ${agent.name}: >>>skip_reply — ${parsed.body.length} chars kept in context (not sent)`);
|
|
5187
|
+
continue;
|
|
5188
|
+
}
|
|
5189
|
+
if (parsed.kind === 'target') {
|
|
5190
|
+
const resolved = this.channelRegistry!.resolveProseTarget(parsed.target!);
|
|
5191
|
+
if ('error' in resolved) {
|
|
5192
|
+
this.proseTargetPins.delete(agent.name);
|
|
5193
|
+
this.proseHybridSuppressed.add(agent.name);
|
|
5194
|
+
this.bounceProse(agent, parsed.body, resolved.error, resolved.candidates, '>>>');
|
|
5195
|
+
continue;
|
|
5196
|
+
}
|
|
5197
|
+
let body = parsed.body;
|
|
5198
|
+
const usedClipboard = body.includes('{{unsent}}');
|
|
5199
|
+
if (usedClipboard) body = body.replaceAll('{{unsent}}', this.proseClipboards.get(agent.name) ?? '');
|
|
5200
|
+
this.proseTargetPins.set(agent.name, resolved.channelId);
|
|
5201
|
+
this.proseHybridSuppressed.delete(agent.name);
|
|
5202
|
+
if (!body.trim()) {
|
|
5203
|
+
console.error(`[prose] ${agent.name}: >>>${parsed.target} established target; empty body — nothing sent yet`);
|
|
5204
|
+
continue;
|
|
5205
|
+
}
|
|
5206
|
+
try {
|
|
5207
|
+
const outcome = await this.channelRegistry!.routeSpeech(agent.name, body, resolved.channelId);
|
|
5208
|
+
this.recordProseDelivery(agent.name, outcome);
|
|
5209
|
+
if (outcome?.delivered) {
|
|
5210
|
+
this.proseBounceStreaks.delete(agent.name);
|
|
5211
|
+
if (usedClipboard) this.proseClipboards.delete(agent.name);
|
|
5212
|
+
} else {
|
|
5213
|
+
this.proseTargetPins.delete(agent.name);
|
|
5214
|
+
this.proseHybridSuppressed.add(agent.name);
|
|
5215
|
+
this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} was not confirmed`, undefined, '>>>');
|
|
5216
|
+
}
|
|
5217
|
+
} catch (err) {
|
|
5218
|
+
this.proseTargetPins.delete(agent.name);
|
|
5219
|
+
this.proseHybridSuppressed.add(agent.name);
|
|
5220
|
+
this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} failed: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>');
|
|
5221
|
+
}
|
|
5222
|
+
continue;
|
|
5223
|
+
}
|
|
5224
|
+
if (attempted) {
|
|
5225
|
+
this.proseTargetPins.delete(agent.name);
|
|
5226
|
+
this.proseHybridSuppressed.add(agent.name);
|
|
5227
|
+
this.bounceProse(agent, normalized, 'malformed >>> routing envelope: destination is missing', undefined, '>>>');
|
|
5228
|
+
continue;
|
|
5229
|
+
}
|
|
5230
|
+
if (this.proseHybridSuppressed.has(agent.name)) {
|
|
5231
|
+
this.recordProseSuppression(agent.name, 1);
|
|
5232
|
+
continue;
|
|
5233
|
+
}
|
|
5234
|
+
const sticky = this.proseTargetPins.get(agent.name);
|
|
5235
|
+
if (!allowLocus && !sticky) {
|
|
5236
|
+
this.recordProseSuppression(agent.name, 1);
|
|
5237
|
+
continue;
|
|
5238
|
+
}
|
|
5239
|
+
const target = sticky ?? locus;
|
|
5240
|
+
try {
|
|
5241
|
+
const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, target);
|
|
5242
|
+
this.recordProseDelivery(agent.name, outcome);
|
|
5243
|
+
} catch (err) {
|
|
5244
|
+
console.error('hybrid locus delivery failed:', err);
|
|
5245
|
+
}
|
|
5246
|
+
}
|
|
5247
|
+
}
|
|
5248
|
+
|
|
4785
5249
|
private async deliverProseEnvelope(agent: Agent, rawText: string): Promise<void> {
|
|
4786
5250
|
const name = agent.name;
|
|
4787
5251
|
const parsed = parseProsePrefix(rawText);
|
|
@@ -4841,7 +5305,7 @@ export class AgentFramework {
|
|
|
4841
5305
|
/** Cap on consecutive bounce-triggered wakes (notices still append after). */
|
|
4842
5306
|
private static readonly PROSE_BOUNCE_WAKE_CAP = 2;
|
|
4843
5307
|
|
|
4844
|
-
private bounceProse(agent: Agent, text: string, reason: string, candidates?: string[]): void {
|
|
5308
|
+
private bounceProse(agent: Agent, text: string, reason: string, candidates?: string[], prefix: '>>' | '>>>' = '>>'): void {
|
|
4845
5309
|
const name = agent.name;
|
|
4846
5310
|
this.proseClipboards.set(name, text);
|
|
4847
5311
|
const streak = (this.proseBounceStreaks.get(name) ?? 0) + 1;
|
|
@@ -4850,8 +5314,9 @@ export class AgentFramework {
|
|
|
4850
5314
|
const notice =
|
|
4851
5315
|
`[prose-routing] Your text (${text.length} chars) was not delivered — ${reason}.${cand} ` +
|
|
4852
5316
|
'The text is retained; nothing is lost. To deliver it unchanged, reply with a ' +
|
|
4853
|
-
|
|
4854
|
-
|
|
5317
|
+
`destination plus the token {{unsent}}, e.g. "${prefix}#channel {{unsent}}" or "${prefix}@person {{unsent}}". ` +
|
|
5318
|
+
`"${prefix}skip_reply {{unsent}}" keeps it in context only.` +
|
|
5319
|
+
(prefix === '>>' ? ' The prose_help tool shows the full syntax.' : '');
|
|
4855
5320
|
try {
|
|
4856
5321
|
// Through framework.addMessage, NOT the context manager directly: while
|
|
4857
5322
|
// the turn is still streaming this defers the notice to the next tool
|
|
@@ -5047,6 +5512,14 @@ export class AgentFramework {
|
|
|
5047
5512
|
// KV stability).
|
|
5048
5513
|
if (trigger?.reason !== 'context_budget_restart') {
|
|
5049
5514
|
if (attempt === 0) this.maybePrimeProseMode(agent);
|
|
5515
|
+
// Fresh turn: forget the previous turn's explicit-send engagements and
|
|
5516
|
+
// prose deliveries — both are strictly turn-scoped (a restart continues
|
|
5517
|
+
// the same logical turn and keeps them).
|
|
5518
|
+
this.turnEngagedChannels.delete(agent.name);
|
|
5519
|
+
this.turnProseDeliveries.delete(agent.name);
|
|
5520
|
+
this.turnProseSuppressed.delete(agent.name);
|
|
5521
|
+
this.proseHybridSuppressed.delete(agent.name);
|
|
5522
|
+
if (agent.proseRouting === 'hybrid') this.proseTargetPins.delete(agent.name);
|
|
5050
5523
|
if (agent.proseRouting === 'explicit') {
|
|
5051
5524
|
// Explicit prose routing: there is no locus. The model names every
|
|
5052
5525
|
// destination in-band (`>>` prefixes); the only turn state is the
|
|
@@ -5249,7 +5722,8 @@ export class AgentFramework {
|
|
|
5249
5722
|
const enqueueSpeech = (text: string, locus: string | null): void => {
|
|
5250
5723
|
turnSpeechChain = turnSpeechChain
|
|
5251
5724
|
.then(async () => {
|
|
5252
|
-
await this.channelRegistry!.routeSpeech(agent.name, text, locus);
|
|
5725
|
+
const outcome = await this.channelRegistry!.routeSpeech(agent.name, text, locus);
|
|
5726
|
+
this.recordProseDelivery(agent.name, outcome);
|
|
5253
5727
|
})
|
|
5254
5728
|
.catch((err) => console.error('mid-turn speech routing failed:', err));
|
|
5255
5729
|
};
|
|
@@ -5291,11 +5765,30 @@ export class AgentFramework {
|
|
|
5291
5765
|
// streams that delivery would refuse), emission is capability-gated in
|
|
5292
5766
|
// the registry (`channels.streaming`), and delivery via deliverProse
|
|
5293
5767
|
// remains the sole authoritative send.
|
|
5294
|
-
const
|
|
5768
|
+
const newOutgoingInferenceId = (): string =>
|
|
5769
|
+
`inf_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
5770
|
+
// NOT const: a membrane refusal retry abandons everything streamed so
|
|
5771
|
+
// far, and the surface must not concatenate two attempts. Re-minting the
|
|
5772
|
+
// id makes the discarded attempt's chunk buffer orphaned (never
|
|
5773
|
+
// finalized) instead of being appended to — see the 'retrying' case.
|
|
5774
|
+
let outgoingInferenceId = newOutgoingInferenceId();
|
|
5295
5775
|
let outgoingIndex = 0;
|
|
5776
|
+
|
|
5777
|
+
// §10.5 inference/lifecycle: `started` now, exactly one terminal in the
|
|
5778
|
+
// finally below (the one block every exit path shares). Which terminal
|
|
5779
|
+
// is decided by the paths themselves: default completed, catch sets
|
|
5780
|
+
// failed, a framework-cancelled stream sets aborted. Best-effort
|
|
5781
|
+
// notifications — consumers dedupe by inferenceId and keep a timeout.
|
|
5782
|
+
let lifecyclePhase: 'completed' | 'aborted' | 'failed' = 'completed';
|
|
5783
|
+
this.hookOrchestrator?.emitLifecycle({
|
|
5784
|
+
inferenceId: outgoingInferenceId,
|
|
5785
|
+
conversationId: agent.name,
|
|
5786
|
+
turnIndex: 0,
|
|
5787
|
+
phase: 'started',
|
|
5788
|
+
});
|
|
5296
5789
|
const proseStream = this.channelRegistry
|
|
5297
5790
|
? new ProseStreamRouter({
|
|
5298
|
-
mode: agent.proseRouting === 'explicit' ? 'explicit' : 'locus',
|
|
5791
|
+
mode: agent.proseRouting === 'explicit' ? 'explicit' : agent.proseRouting === 'hybrid' ? 'hybrid' : 'locus',
|
|
5299
5792
|
initialTarget: typingChannel,
|
|
5300
5793
|
resolve: (spec) => {
|
|
5301
5794
|
const r = this.channelRegistry!.resolveProseTarget(spec);
|
|
@@ -5342,6 +5835,38 @@ export class AgentFramework {
|
|
|
5342
5835
|
}
|
|
5343
5836
|
break;
|
|
5344
5837
|
|
|
5838
|
+
case 'retrying': {
|
|
5839
|
+
// Membrane is re-issuing after a content-policy refusal. Per the
|
|
5840
|
+
// RetryingEvent contract we must DISCARD everything this call has
|
|
5841
|
+
// emitted: the tokens above were already streamed to the surface
|
|
5842
|
+
// as outgoing chunks, and appending a second attempt to them
|
|
5843
|
+
// would show the human two half-answers spliced together.
|
|
5844
|
+
//
|
|
5845
|
+
// Re-mint the outgoing id so the partial buffer is orphaned
|
|
5846
|
+
// rather than continued (delivery is authoritative via
|
|
5847
|
+
// deliverProse and has not happened yet — nothing has been
|
|
5848
|
+
// *sent*, only previewed), and reset the prose router so its
|
|
5849
|
+
// destination bookkeeping starts clean for the new attempt.
|
|
5850
|
+
console.error(
|
|
5851
|
+
`[refusal-retry] agent=${agent.name} membrane retry ` +
|
|
5852
|
+
`${event.attempt}/${event.maxAttempts}` +
|
|
5853
|
+
(event.category ? ` category=${event.category}` : '') +
|
|
5854
|
+
' — discarding the refused attempt',
|
|
5855
|
+
);
|
|
5856
|
+
outgoingInferenceId = newOutgoingInferenceId();
|
|
5857
|
+
outgoingIndex = 0;
|
|
5858
|
+
proseStream?.reset();
|
|
5859
|
+
this.emitTrace({
|
|
5860
|
+
type: 'inference:tokens',
|
|
5861
|
+
agentName: agent.name,
|
|
5862
|
+
content: '',
|
|
5863
|
+
blockType: 'text',
|
|
5864
|
+
blockIndex: 0,
|
|
5865
|
+
channelId: typingChannel ?? undefined,
|
|
5866
|
+
});
|
|
5867
|
+
break;
|
|
5868
|
+
}
|
|
5869
|
+
|
|
5345
5870
|
case 'block': {
|
|
5346
5871
|
const { event: phase, index, block } = event.event;
|
|
5347
5872
|
this.emitTrace({
|
|
@@ -5390,9 +5915,18 @@ export class AgentFramework {
|
|
|
5390
5915
|
const rawXml = rawText && /^<(antml:)?function_calls>/.test(rawText)
|
|
5391
5916
|
? rawText
|
|
5392
5917
|
: undefined;
|
|
5918
|
+
// Persist THIS round's text only. `roundPreamble` (membrane
|
|
5919
|
+
// ≥0.5.79) is the per-round delta; the legacy `preamble` is
|
|
5920
|
+
// CUMULATIVE in XML mode — persisting it per round stored each
|
|
5921
|
+
// round's text N times and re-persisted injected
|
|
5922
|
+
// <function_results> as the agent's own words (the Evander
|
|
5923
|
+
// 2026-08-08 scaffold-leak pyramid: r1 ×3, r2 ×2).
|
|
5924
|
+
const roundText =
|
|
5925
|
+
(event.context as { roundPreamble?: string }).roundPreamble ??
|
|
5926
|
+
event.context.preamble;
|
|
5393
5927
|
assistantBlocks = [];
|
|
5394
|
-
if (
|
|
5395
|
-
assistantBlocks.push({ type: 'text', text:
|
|
5928
|
+
if (roundText) {
|
|
5929
|
+
assistantBlocks.push({ type: 'text', text: roundText });
|
|
5396
5930
|
}
|
|
5397
5931
|
for (const c of event.calls) {
|
|
5398
5932
|
assistantBlocks.push({
|
|
@@ -5434,14 +5968,28 @@ export class AgentFramework {
|
|
|
5434
5968
|
const hasSameRoundPrivateThink =
|
|
5435
5969
|
roundToolNames.includes('think') &&
|
|
5436
5970
|
requestSnapshot.sameRoundThinkTextPolicy === 'private';
|
|
5437
|
-
if (roundToolNames.some((n) =>
|
|
5971
|
+
if (roundToolNames.some((n) =>
|
|
5972
|
+
SILENCING_TOOLS.has(bareToolName(n)) ||
|
|
5973
|
+
(agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n)))
|
|
5974
|
+
)) {
|
|
5438
5975
|
turnSilenced = true;
|
|
5439
5976
|
}
|
|
5440
5977
|
if (roundContent && roundContent.length > 0) {
|
|
5441
5978
|
liveProseRouting = true;
|
|
5442
5979
|
const roundSegments = splitProseSegments(assistantBlocks);
|
|
5443
5980
|
if (roundSegments.length > 0) {
|
|
5444
|
-
if (agent.proseRouting === '
|
|
5981
|
+
if (agent.proseRouting === 'hybrid') {
|
|
5982
|
+
if (turnSilenced) {
|
|
5983
|
+
this.recordProseSuppression(agent.name, roundSegments.length);
|
|
5984
|
+
} else if (!hasSameRoundPrivateThink) {
|
|
5985
|
+
const locus = resolveTurnLocus();
|
|
5986
|
+
for (const seg of roundSegments) {
|
|
5987
|
+
turnSpeechChain = turnSpeechChain
|
|
5988
|
+
.then(() => this.deliverHybridProse(agent, seg, locus, true))
|
|
5989
|
+
.catch((err) => console.error('mid-turn hybrid prose delivery failed:', err));
|
|
5990
|
+
}
|
|
5991
|
+
}
|
|
5992
|
+
} else if (agent.proseRouting === 'explicit') {
|
|
5445
5993
|
// Explicit mode: every segment through the prose gateway.
|
|
5446
5994
|
// Silencing does not apply — unprefixed prose bounces and
|
|
5447
5995
|
// prefixed prose is deliberate; think-privacy still holds.
|
|
@@ -5459,6 +6007,9 @@ export class AgentFramework {
|
|
|
5459
6007
|
console.error(
|
|
5460
6008
|
`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`,
|
|
5461
6009
|
);
|
|
6010
|
+
// Visible in the turn-end receipt — silencing must never
|
|
6011
|
+
// be a silent black hole (n=8: the flying-scene reply).
|
|
6012
|
+
this.recordProseSuppression(agent.name, roundSegments.length);
|
|
5462
6013
|
} else if (hasSameRoundPrivateThink) {
|
|
5463
6014
|
console.error(
|
|
5464
6015
|
`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`,
|
|
@@ -5506,17 +6057,15 @@ export class AgentFramework {
|
|
|
5506
6057
|
agent.addAssistantResponse(pending);
|
|
5507
6058
|
this.pendingAssistantBlocks.delete(agent.name);
|
|
5508
6059
|
}
|
|
5509
|
-
// Store tool results
|
|
6060
|
+
// Store tool results — same bounded spill policy as the
|
|
6061
|
+
// ordinary path (issue #89: this guard path must not become the
|
|
6062
|
+
// one door a giant blob still walks through).
|
|
5510
6063
|
const readyState = agent.state as AgentState;
|
|
5511
6064
|
if (readyState.status === 'ready') {
|
|
5512
|
-
const
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
? (tc.result.error ?? 'Unknown error')
|
|
5517
|
-
: toolResultDataToHistoryString(tc.result.data),
|
|
5518
|
-
isError: tc.result.isError,
|
|
5519
|
-
}));
|
|
6065
|
+
const { blocks: toolResultContent } = await this.buildStoredToolResultContent(
|
|
6066
|
+
readyState.toolResults,
|
|
6067
|
+
this.resolveToolResultInlineCap(agent).cap,
|
|
6068
|
+
);
|
|
5520
6069
|
agent.getContextManager().addMessage('user', toolResultContent);
|
|
5521
6070
|
}
|
|
5522
6071
|
}
|
|
@@ -5545,40 +6094,10 @@ export class AgentFramework {
|
|
|
5545
6094
|
agent.addAssistantResponse(terminalContent);
|
|
5546
6095
|
}
|
|
5547
6096
|
|
|
5548
|
-
//
|
|
5549
|
-
|
|
5550
|
-
|
|
5551
|
-
|
|
5552
|
-
.filter((block: ContentBlock): block is ContentBlock & { type: 'text' } => block.type === 'text')
|
|
5553
|
-
.map((b) => b.text)
|
|
5554
|
-
.join('\n');
|
|
5555
|
-
|
|
5556
|
-
const afterParams: AfterInferenceParams = {
|
|
5557
|
-
inferenceId: requestId,
|
|
5558
|
-
conversationId: agent.name,
|
|
5559
|
-
turnIndex: 0,
|
|
5560
|
-
userMessage: null,
|
|
5561
|
-
assistantMessage: speechText,
|
|
5562
|
-
model: {
|
|
5563
|
-
id: agent.model,
|
|
5564
|
-
vendor: 'unknown',
|
|
5565
|
-
contextWindow: 200000,
|
|
5566
|
-
capabilities: ['tools'],
|
|
5567
|
-
},
|
|
5568
|
-
usage: {
|
|
5569
|
-
inputTokens: response.usage?.inputTokens ?? 0,
|
|
5570
|
-
outputTokens: response.usage?.outputTokens ?? 0,
|
|
5571
|
-
cacheCreationTokens: response.details?.usage?.cacheCreationTokens,
|
|
5572
|
-
cacheReadTokens: response.details?.usage?.cacheReadTokens,
|
|
5573
|
-
},
|
|
5574
|
-
};
|
|
5575
|
-
|
|
5576
|
-
await this.hookOrchestrator.afterInference(afterParams);
|
|
5577
|
-
} catch (error) {
|
|
5578
|
-
// Fail-open: continue with speech dispatch
|
|
5579
|
-
console.error('afterInference hook error:', error);
|
|
5580
|
-
}
|
|
5581
|
-
}
|
|
6097
|
+
// §10.5: context/afterInference is removed in 0.5.0 — no
|
|
6098
|
+
// content leaves the host here. Turn boundaries are announced
|
|
6099
|
+
// by the metadata-only inference/lifecycle notifications
|
|
6100
|
+
// emitted in driveStream's start/exit paths.
|
|
5582
6101
|
|
|
5583
6102
|
// Separate speech from thoughts — for MODULE dispatch only
|
|
5584
6103
|
// (dispatchSpeech / TUI rendering below). This split does NOT
|
|
@@ -5675,6 +6194,18 @@ export class AgentFramework {
|
|
|
5675
6194
|
let handledByRewind = false;
|
|
5676
6195
|
const rh = agent.refusalHandling;
|
|
5677
6196
|
const forced = this.forcedRewind.get(agent.name);
|
|
6197
|
+
|
|
6198
|
+
// NOTE: plain retries are NOT done here. `refusalHandling.retries`
|
|
6199
|
+
// is handed to membrane (see Agent.startActivation), which
|
|
6200
|
+
// replays the SAME request at the provider seam — immediately,
|
|
6201
|
+
// cache-warm, and without recompiling into a different window.
|
|
6202
|
+
// A framework-level requeue was tried first and is strictly
|
|
6203
|
+
// worse: it recompiles, so attempts are correlated rather than
|
|
6204
|
+
// fresh draws against a probabilistic threshold, and it cannot
|
|
6205
|
+
// tell a streaming consumer to discard the abandoned attempt.
|
|
6206
|
+
// By the time a refusal reaches here, membrane's retries are
|
|
6207
|
+
// already spent — so this path is the escalation: rewind (if
|
|
6208
|
+
// enabled), then the reaction.
|
|
5678
6209
|
// Shed exactly one more (newest, in-sequence) turn and keep the
|
|
5679
6210
|
// single episode marker current. `budgetLeft` bounds the loop.
|
|
5680
6211
|
const doRewind = (
|
|
@@ -5732,7 +6263,16 @@ export class AgentFramework {
|
|
|
5732
6263
|
);
|
|
5733
6264
|
}
|
|
5734
6265
|
|
|
5735
|
-
|
|
6266
|
+
// The reaction is the LAST resort: retries spent, rewind not
|
|
6267
|
+
// applicable. That makes it an honest signal to humans that the
|
|
6268
|
+
// border is close, rather than noise on every near-threshold
|
|
6269
|
+
// flip. (It must not re-enter any agent's context — the emoji
|
|
6270
|
+
// is suppressed at the surface via
|
|
6271
|
+
// DISCORD_SUPPRESS_REACTION_EMOJIS; without that, marking a
|
|
6272
|
+
// refusal feeds the next one.)
|
|
6273
|
+
if (!handledByRewind) {
|
|
6274
|
+
void this.reactToRefusal(agent.name, category);
|
|
6275
|
+
}
|
|
5736
6276
|
} else {
|
|
5737
6277
|
// A turn that completed WITHOUT a refusal ends the rewind episode:
|
|
5738
6278
|
// the model responded. Leave the consolidated marker in place as
|
|
@@ -5779,7 +6319,15 @@ export class AgentFramework {
|
|
|
5779
6319
|
.join('\n')
|
|
5780
6320
|
.trim();
|
|
5781
6321
|
if (speechText) {
|
|
5782
|
-
if (agent.proseRouting === '
|
|
6322
|
+
if (agent.proseRouting === 'hybrid') {
|
|
6323
|
+
const locus = resolveTurnLocus();
|
|
6324
|
+
console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`);
|
|
6325
|
+
try {
|
|
6326
|
+
await this.deliverHybridProse(agent, speechText, locus, true);
|
|
6327
|
+
} catch (err) {
|
|
6328
|
+
console.error('text-only hybrid prose delivery failed:', err);
|
|
6329
|
+
}
|
|
6330
|
+
} else if (agent.proseRouting === 'explicit') {
|
|
5783
6331
|
console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`);
|
|
5784
6332
|
try {
|
|
5785
6333
|
await this.deliverProse(agent, speechText);
|
|
@@ -5798,7 +6346,8 @@ export class AgentFramework {
|
|
|
5798
6346
|
`[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ?? '(none)'}`,
|
|
5799
6347
|
);
|
|
5800
6348
|
try {
|
|
5801
|
-
await this.channelRegistry.routeSpeech(agent.name, speechText, locus);
|
|
6349
|
+
const outcome = await this.channelRegistry.routeSpeech(agent.name, speechText, locus);
|
|
6350
|
+
this.recordProseDelivery(agent.name, outcome);
|
|
5802
6351
|
} catch (err) {
|
|
5803
6352
|
console.error('speech routing failed:', err);
|
|
5804
6353
|
}
|
|
@@ -5825,7 +6374,10 @@ export class AgentFramework {
|
|
|
5825
6374
|
.filter((n): n is string => typeof n === 'string');
|
|
5826
6375
|
const silenced = liveProseRouting
|
|
5827
6376
|
? turnSilenced
|
|
5828
|
-
: turnSilenced || toolNames.some((n) =>
|
|
6377
|
+
: turnSilenced || toolNames.some((n) =>
|
|
6378
|
+
SILENCING_TOOLS.has(bareToolName(n)) ||
|
|
6379
|
+
(agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n)))
|
|
6380
|
+
);
|
|
5829
6381
|
|
|
5830
6382
|
const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);
|
|
5831
6383
|
|
|
@@ -5834,7 +6386,20 @@ export class AgentFramework {
|
|
|
5834
6386
|
// the chain may still be flushing earlier rounds' posts.
|
|
5835
6387
|
await turnSpeechChain;
|
|
5836
6388
|
|
|
5837
|
-
if (agent.proseRouting === '
|
|
6389
|
+
if (agent.proseRouting === 'hybrid') {
|
|
6390
|
+
if (silenced && segments.length > 0) {
|
|
6391
|
+
this.recordProseSuppression(agent.name, segments.length);
|
|
6392
|
+
} else if (segments.length > 0) {
|
|
6393
|
+
const locus = resolveTurnLocus();
|
|
6394
|
+
for (const seg of segments) {
|
|
6395
|
+
try {
|
|
6396
|
+
await this.deliverHybridProse(agent, seg, locus, true);
|
|
6397
|
+
} catch (err) {
|
|
6398
|
+
console.error('trailing hybrid prose delivery failed:', err);
|
|
6399
|
+
}
|
|
6400
|
+
}
|
|
6401
|
+
}
|
|
6402
|
+
} else if (agent.proseRouting === 'explicit') {
|
|
5838
6403
|
if (segments.length > 0) {
|
|
5839
6404
|
console.error(
|
|
5840
6405
|
`[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`,
|
|
@@ -5852,6 +6417,9 @@ export class AgentFramework {
|
|
|
5852
6417
|
`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> trailing prose NOT routed ` +
|
|
5853
6418
|
`(${silenced ? 'silencing tool / explicit send' : 'no trailing prose'})`,
|
|
5854
6419
|
);
|
|
6420
|
+
if (silenced && segments.length > 0) {
|
|
6421
|
+
this.recordProseSuppression(agent.name, segments.length);
|
|
6422
|
+
}
|
|
5855
6423
|
} else {
|
|
5856
6424
|
// Reuse the locus pinned at the turn's first live-routed
|
|
5857
6425
|
// segment (or resolve it now for a turn whose only prose is
|
|
@@ -5867,7 +6435,8 @@ export class AgentFramework {
|
|
|
5867
6435
|
// Deliver sequentially (await each) so the segments land in order.
|
|
5868
6436
|
for (const seg of segments) {
|
|
5869
6437
|
try {
|
|
5870
|
-
await this.channelRegistry.routeSpeech(agent.name, seg, locus);
|
|
6438
|
+
const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus);
|
|
6439
|
+
this.recordProseDelivery(agent.name, outcome);
|
|
5871
6440
|
} catch (err) {
|
|
5872
6441
|
console.error('speech routing failed:', err);
|
|
5873
6442
|
}
|
|
@@ -5878,6 +6447,15 @@ export class AgentFramework {
|
|
|
5878
6447
|
// BEFORE dispatchSpeech. Locus routing is speech dispatch and
|
|
5879
6448
|
// doesn't depend on the status field, so it correctly runs after.
|
|
5880
6449
|
|
|
6450
|
+
// Logical turn end (normal completion): drop the `[delivered]`
|
|
6451
|
+
// receipt. All deliveries are settled — the live chain was
|
|
6452
|
+
// awaited before trailing dispatch, and trailing/text-only
|
|
6453
|
+
// segments were awaited in-loop. Locus mode only; explicit-mode
|
|
6454
|
+
// envelopes acknowledge themselves through the prose gateway.
|
|
6455
|
+
if (agent.proseRouting !== 'explicit') {
|
|
6456
|
+
this.appendProseDeliveryReceipt(agent);
|
|
6457
|
+
}
|
|
6458
|
+
|
|
5881
6459
|
// Explicit-prose `!` continuation: a prose segment this turn asked
|
|
5882
6460
|
// to keep going (`>>#x !` / `>>private !`) — start another turn
|
|
5883
6461
|
// now instead of pausing until the next external event. This gives
|
|
@@ -5958,9 +6536,26 @@ export class AgentFramework {
|
|
|
5958
6536
|
// reset, no settle, no spurious inference:exhausted (which would
|
|
5959
6537
|
// reject an ephemeral's promise mid-run and bump the failure
|
|
5960
6538
|
// streak). Gate release + stream teardown happen in `finally`.
|
|
5961
|
-
|
|
5962
|
-
|
|
5963
|
-
|
|
6539
|
+
{
|
|
6540
|
+
const cancelKey = `${agent.name}:${myStreamId}`;
|
|
6541
|
+
const cancelKind = this.frameworkCancelledStreams.get(cancelKey);
|
|
6542
|
+
if (cancelKind !== undefined) {
|
|
6543
|
+
this.frameworkCancelledStreams.delete(cancelKey);
|
|
6544
|
+
lifecyclePhase = 'aborted'; // §10.5 — terminal emitted in finally
|
|
6545
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6546
|
+
// endTurn IS a logical turn end — earlier rounds may have
|
|
6547
|
+
// live-routed prose (narrate → skip_reply is a real shape),
|
|
6548
|
+
// so settle the delivery chain and drop the receipt. A
|
|
6549
|
+
// budget restart continues the same logical turn: the
|
|
6550
|
+
// deliveries map persists (cleared only at fresh turn
|
|
6551
|
+
// start) and the continuation stream's end writes ONE
|
|
6552
|
+
// receipt for the whole turn.
|
|
6553
|
+
if (cancelKind === 'turn_ended' && agent.proseRouting !== 'explicit') {
|
|
6554
|
+
await turnSpeechChain;
|
|
6555
|
+
this.appendProseDeliveryReceipt(agent);
|
|
6556
|
+
}
|
|
6557
|
+
return;
|
|
6558
|
+
}
|
|
5964
6559
|
}
|
|
5965
6560
|
const reason = event.reason ?? 'unknown';
|
|
5966
6561
|
// Only reset if this is still the active stream (a budget restart
|
|
@@ -6001,6 +6596,11 @@ export class AgentFramework {
|
|
|
6001
6596
|
|
|
6002
6597
|
case 'usage': {
|
|
6003
6598
|
agent.lastStreamInputTokens = event.usage.inputTokens;
|
|
6599
|
+
agent.lastStreamRealInputTokens =
|
|
6600
|
+
(event.usage.inputTokens ?? 0) +
|
|
6601
|
+
(event.usage.cacheCreationTokens ?? 0) +
|
|
6602
|
+
(event.usage.cacheReadTokens ?? 0);
|
|
6603
|
+
agent.lastStreamOutputTokens = event.usage.outputTokens ?? 0;
|
|
6004
6604
|
|
|
6005
6605
|
// Closed-loop estimator calibration (2026-07-12). Sample the REAL
|
|
6006
6606
|
// prefix size of THIS API call (fresh + cache write + cache read)
|
|
@@ -6073,9 +6673,21 @@ export class AgentFramework {
|
|
|
6073
6673
|
durationMs,
|
|
6074
6674
|
});
|
|
6075
6675
|
this.abortAgentScript(agent.name, 'stream threw');
|
|
6676
|
+
lifecyclePhase = 'failed'; // §10.5 — terminal emitted in finally
|
|
6076
6677
|
agent.reset();
|
|
6077
6678
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
6078
6679
|
} finally {
|
|
6680
|
+
// §10.5: exactly one terminal per `started`, on every exit path the
|
|
6681
|
+
// host controls. Which one was decided by the path taken (default
|
|
6682
|
+
// completed; catch → failed; framework-cancel → aborted). A host
|
|
6683
|
+
// crash emits nothing — that is the documented best-effort limit, and
|
|
6684
|
+
// why consumers keep their safety timeout.
|
|
6685
|
+
this.hookOrchestrator?.emitLifecycle({
|
|
6686
|
+
inferenceId: outgoingInferenceId,
|
|
6687
|
+
conversationId: agent.name,
|
|
6688
|
+
turnIndex: 0,
|
|
6689
|
+
phase: lifecyclePhase,
|
|
6690
|
+
});
|
|
6079
6691
|
// Clear the gate's inference flag on EVERY exit path (paired with the
|
|
6080
6692
|
// onInferenceStarted in startAgentStream). The branch-level calls above
|
|
6081
6693
|
// are kept but are best-effort; if any exit path bypassed them the agent
|
|
@@ -6527,8 +7139,12 @@ export class AgentFramework {
|
|
|
6527
7139
|
? 'null'
|
|
6528
7140
|
: JSON.stringify(payload, null, 1) ?? String(payload);
|
|
6529
7141
|
const agent = this.agents.get(record.agentName);
|
|
6530
|
-
|
|
6531
|
-
|
|
7142
|
+
// Same resolved cap as tool results (override applies here too); an
|
|
7143
|
+
// unknown agent still gets the bounded house default, never unbounded.
|
|
7144
|
+
const maxChars = agent
|
|
7145
|
+
? this.resolveToolResultInlineCap(agent).cap
|
|
7146
|
+
: DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS;
|
|
7147
|
+
const { text: payloadText } = await this.spillOrTruncate(
|
|
6532
7148
|
payloadJson, maxChars, `${record.id}-wake${record.wakes}`,
|
|
6533
7149
|
);
|
|
6534
7150
|
|
|
@@ -6610,45 +7226,104 @@ export class AgentFramework {
|
|
|
6610
7226
|
}
|
|
6611
7227
|
|
|
6612
7228
|
/**
|
|
6613
|
-
* Oversized content policy (antra, 2026-07-31
|
|
6614
|
-
* the face with a huge blob, never silently destroy
|
|
6615
|
-
* over the inline cap is materialized to a workspace
|
|
6616
|
-
* the agent's own tools
|
|
6617
|
-
*
|
|
6618
|
-
*
|
|
6619
|
-
*
|
|
7229
|
+
* Oversized content policy (antra, 2026-07-31; completed for issue #89):
|
|
7230
|
+
* never hit the agent in the face with a huge blob, never silently destroy
|
|
7231
|
+
* it either. Content over the inline cap is materialized to a workspace
|
|
7232
|
+
* file (readable with the agent's own tools — deterministic name, kept
|
|
7233
|
+
* until the workspace owner deletes it) and replaced by a truncated head +
|
|
7234
|
+
* a trailing file reference. Falls back to EXPLICIT plain truncation when
|
|
7235
|
+
* there is no writable workspace. Cap resolution lives in
|
|
7236
|
+
* resolveToolResultInlineCap; callers pass the resolved cap.
|
|
7237
|
+
*/
|
|
7238
|
+
/**
|
|
7239
|
+
* One spill policy for every stored tool_result block: serialize (history
|
|
7240
|
+
* serializer — image blocks become placeholders), then spill/truncate at
|
|
7241
|
+
* the resolved cap. Errors follow the same policy as successes — a giant
|
|
7242
|
+
* error string is exactly as context-hostile as a giant success (issue
|
|
7243
|
+
* #89). Returns the stored blocks plus the per-call spill outcomes so the
|
|
7244
|
+
* live wire copy can reuse the identical strings.
|
|
6620
7245
|
*/
|
|
7246
|
+
private async buildStoredToolResultContent(
|
|
7247
|
+
toolResults: CompletedToolCall[],
|
|
7248
|
+
maxChars: number | undefined,
|
|
7249
|
+
): Promise<{
|
|
7250
|
+
blocks: ContentBlock[];
|
|
7251
|
+
spilled: Map<string, { text: string; filePath: string | null }>;
|
|
7252
|
+
}> {
|
|
7253
|
+
const spilled = new Map<string, { text: string; filePath: string | null }>();
|
|
7254
|
+
const dateLabel = new Date().toISOString().slice(0, 10);
|
|
7255
|
+
for (const tc of toolResults) {
|
|
7256
|
+
const raw = tc.result.isError
|
|
7257
|
+
? tc.result.error ?? 'Unknown error'
|
|
7258
|
+
: toolResultDataToHistoryString(tc.result.data, undefined);
|
|
7259
|
+
spilled.set(tc.id, await this.spillOrTruncate(raw, maxChars, `${dateLabel}-${tc.id}`));
|
|
7260
|
+
}
|
|
7261
|
+
const blocks: ContentBlock[] = toolResults.map(tc => ({
|
|
7262
|
+
type: 'tool_result' as const,
|
|
7263
|
+
toolUseId: tc.id,
|
|
7264
|
+
// Persisted so XML replay can reconstruct the legacy <tool_name>
|
|
7265
|
+
// element byte-identically to the live injection.
|
|
7266
|
+
toolName: tc.name,
|
|
7267
|
+
content: spilled.get(tc.id)!.text,
|
|
7268
|
+
isError: tc.result.isError,
|
|
7269
|
+
}));
|
|
7270
|
+
return { blocks, spilled };
|
|
7271
|
+
}
|
|
7272
|
+
|
|
6621
7273
|
private async spillOrTruncate(
|
|
6622
7274
|
content: string,
|
|
6623
|
-
|
|
7275
|
+
cap: number | undefined,
|
|
6624
7276
|
label: string,
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
const override = agentName !== undefined
|
|
6628
|
-
? this.toolResultInlineMaxCharsOverride.get(agentName)
|
|
6629
|
-
: undefined;
|
|
6630
|
-
const cap = override ?? maxChars;
|
|
6631
|
-
if (!cap || content.length <= cap) return content;
|
|
7277
|
+
): Promise<{ text: string; filePath: string | null }> {
|
|
7278
|
+
if (!cap || content.length <= cap) return { text: content, filePath: null };
|
|
6632
7279
|
|
|
6633
7280
|
const workspace = this.getWorkspaceModule();
|
|
6634
7281
|
const mountName = workspace ? this.firstWritableMountName(workspace) : null;
|
|
6635
7282
|
if (workspace && mountName) {
|
|
6636
7283
|
const safeLabel = label.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
|
|
6637
7284
|
const path = `${mountName}/tool-results/${safeLabel}.txt`;
|
|
7285
|
+
let failure: string;
|
|
6638
7286
|
try {
|
|
6639
7287
|
const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain');
|
|
6640
7288
|
if (result.success) {
|
|
6641
|
-
return
|
|
6642
|
-
|
|
6643
|
-
|
|
6644
|
-
|
|
7289
|
+
return {
|
|
7290
|
+
text: safeSlice(content, 0, cap)
|
|
7291
|
+
+ `\n\n[truncated — showing ${cap} of ${content.length} chars; full content: workspace file ${path}. `
|
|
7292
|
+
+ 'Read/grep it with your file tools, or raise the inline cap via '
|
|
7293
|
+
+ 'agent_settings update tool_result_inline_max_chars.]',
|
|
7294
|
+
filePath: path,
|
|
7295
|
+
};
|
|
6645
7296
|
}
|
|
6646
|
-
|
|
6647
|
-
|
|
7297
|
+
failure = result.error ?? 'write refused';
|
|
7298
|
+
} catch (err) {
|
|
7299
|
+
failure = err instanceof Error ? err.message : String(err);
|
|
6648
7300
|
}
|
|
7301
|
+
// A workspace was there and the write FAILED — say that, loudly and
|
|
7302
|
+
// distinctly. Telling the agent "no writable workspace" would teach
|
|
7303
|
+
// them their residence lacks a capability it actually has.
|
|
7304
|
+
failure = failure.slice(0, 200);
|
|
7305
|
+
this.emitTrace({
|
|
7306
|
+
type: 'tool:spill_failed',
|
|
7307
|
+
label: safeLabel,
|
|
7308
|
+
path,
|
|
7309
|
+
contentLength: content.length,
|
|
7310
|
+
error: failure,
|
|
7311
|
+
});
|
|
7312
|
+
console.error(
|
|
7313
|
+
`[spill] workspace write failed for ${path} (${content.length} chars): ${failure}`,
|
|
7314
|
+
);
|
|
7315
|
+
return {
|
|
7316
|
+
text: safeSlice(content, 0, cap)
|
|
7317
|
+
+ `\n\n[truncated — showing ${cap} of ${content.length} chars; spill to workspace file ${path} FAILED`
|
|
7318
|
+
+ ` (${failure}); content over the cap was not retained]`,
|
|
7319
|
+
filePath: null,
|
|
7320
|
+
};
|
|
6649
7321
|
}
|
|
6650
|
-
return
|
|
6651
|
-
|
|
7322
|
+
return {
|
|
7323
|
+
text: safeSlice(content, 0, cap)
|
|
7324
|
+
+ '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace, full content not retained]',
|
|
7325
|
+
filePath: null,
|
|
7326
|
+
};
|
|
6652
7327
|
}
|
|
6653
7328
|
|
|
6654
7329
|
private getOrCreateScriptRunner(agentName: string): PyRunner {
|
|
@@ -6759,23 +7434,30 @@ export class AgentFramework {
|
|
|
6759
7434
|
callId: string,
|
|
6760
7435
|
afResult: ToolResult,
|
|
6761
7436
|
maxChars?: number,
|
|
6762
|
-
/** Pre-spilled
|
|
7437
|
+
/** Pre-spilled outcome from the history path — used for the non-image
|
|
6763
7438
|
* path so the live wire copy byte-matches what the window stored. */
|
|
6764
|
-
precomputed?: string,
|
|
7439
|
+
precomputed?: { text: string; filePath: string | null },
|
|
6765
7440
|
): MembraneToolResult {
|
|
6766
7441
|
if (afResult.isError) {
|
|
6767
|
-
|
|
7442
|
+
// Same spill policy as successes: reuse the history copy so a giant
|
|
7443
|
+
// error string stays bounded on the wire too (byte-matched).
|
|
7444
|
+
return {
|
|
7445
|
+
toolUseId: callId,
|
|
7446
|
+
content: precomputed?.text ?? truncateForHistory(
|
|
7447
|
+
afResult.error ?? 'Unknown error', DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS),
|
|
7448
|
+
isError: true,
|
|
7449
|
+
};
|
|
6768
7450
|
}
|
|
6769
7451
|
// MCPL tool results arrive as `data: McpToolResultContent[]` — preserve image
|
|
6770
7452
|
// blocks natively rather than JSON-stringifying them away. Anything else
|
|
6771
7453
|
// (objects, scalars) falls through to JSON. The error path was handled
|
|
6772
7454
|
// above, so isError is always false on these return paths.
|
|
6773
|
-
const blocks = this.tryNativeToolResultContent(afResult.data, maxChars);
|
|
7455
|
+
const blocks = this.tryNativeToolResultContent(afResult.data, maxChars, precomputed?.filePath ?? null);
|
|
6774
7456
|
if (blocks) {
|
|
6775
7457
|
return { toolUseId: callId, content: blocks, isError: false };
|
|
6776
7458
|
}
|
|
6777
7459
|
if (precomputed !== undefined) {
|
|
6778
|
-
return { toolUseId: callId, content: precomputed, isError: false };
|
|
7460
|
+
return { toolUseId: callId, content: precomputed.text, isError: false };
|
|
6779
7461
|
}
|
|
6780
7462
|
// JSON.stringify returns the VALUE undefined (not a string) for undefined
|
|
6781
7463
|
// input — a module tool returning `{ success: true }` with no data would
|
|
@@ -6795,7 +7477,13 @@ export class AgentFramework {
|
|
|
6795
7477
|
* `maxChars`, when provided, caps each accompanying text block so an image
|
|
6796
7478
|
* inlined alongside an enormous text payload can't blow the context.
|
|
6797
7479
|
*/
|
|
6798
|
-
private tryNativeToolResultContent(
|
|
7480
|
+
private tryNativeToolResultContent(
|
|
7481
|
+
data: unknown,
|
|
7482
|
+
maxChars?: number,
|
|
7483
|
+
/** Spill file written by the history path — referenced from truncation
|
|
7484
|
+
* notices so an image-adjacent giant text block stays recoverable. */
|
|
7485
|
+
spillPath: string | null = null,
|
|
7486
|
+
): ToolResultContentBlock[] | null {
|
|
6799
7487
|
if (!Array.isArray(data)) return null;
|
|
6800
7488
|
let hasImage = false;
|
|
6801
7489
|
const blocks: ToolResultContentBlock[] = [];
|
|
@@ -6806,7 +7494,9 @@ export class AgentFramework {
|
|
|
6806
7494
|
let text = b.text;
|
|
6807
7495
|
if (maxChars && text.length > maxChars) {
|
|
6808
7496
|
text = safeSlice(text, 0, maxChars)
|
|
6809
|
-
+ '\n\n[truncated — original was ' + text.length + ' chars
|
|
7497
|
+
+ '\n\n[truncated — original was ' + text.length + ' chars'
|
|
7498
|
+
+ (spillPath ? `; full serialized result: workspace file ${spillPath}` : '')
|
|
7499
|
+
+ ']';
|
|
6810
7500
|
}
|
|
6811
7501
|
blocks.push({ type: 'text', text });
|
|
6812
7502
|
} else if (b.type === 'image' && typeof b.data === 'string' && typeof b.mimeType === 'string') {
|
|
@@ -6822,13 +7512,45 @@ export class AgentFramework {
|
|
|
6822
7512
|
return hasImage ? blocks : null;
|
|
6823
7513
|
}
|
|
6824
7514
|
|
|
6825
|
-
|
|
7515
|
+
/** Strategy-derived per-message bound (maxMessageTokens * 4 chars). */
|
|
7516
|
+
private strategyDerivedToolResultChars(agent: Agent): number | undefined {
|
|
6826
7517
|
const strategy = agent.getContextManager().getStrategy();
|
|
6827
7518
|
const maxTokens = strategy.maxMessageTokens;
|
|
6828
7519
|
if (maxTokens && maxTokens > 0) return maxTokens * 4;
|
|
6829
7520
|
return undefined;
|
|
6830
7521
|
}
|
|
6831
7522
|
|
|
7523
|
+
/**
|
|
7524
|
+
* Effective tool-result inline cap for an agent, with provenance. Desired
|
|
7525
|
+
* value: resident's durable agent_settings value → residence
|
|
7526
|
+
* FrameworkConfig.toolResultInlineMaxChars → house default (24000).
|
|
7527
|
+
* Effective value: min(desired, strategy bound) for EVERY source — the
|
|
7528
|
+
* strategy's per-message safety limit is a ceiling, not a suggestion
|
|
7529
|
+
* (Sol's #94 ruling: a durable preference must not be a durable path for
|
|
7530
|
+
* one tool result to exceed maxMessageTokens; the way to see the whole
|
|
7531
|
+
* result is the spill file, not an over-bound blob in live context).
|
|
7532
|
+
*/
|
|
7533
|
+
private resolveToolResultInlineCap(agent: Agent): {
|
|
7534
|
+
cap: number;
|
|
7535
|
+
source: 'agent-settings-override' | 'framework-config' | 'default';
|
|
7536
|
+
/** Set when the desired value was reduced to the strategy bound. */
|
|
7537
|
+
clampedBy: 'strategy-bound' | null;
|
|
7538
|
+
} {
|
|
7539
|
+
const override = this.toolResultInlineMaxCharsOverride.get(agent.name);
|
|
7540
|
+
const configured = this.toolResultInlineMaxCharsConfig;
|
|
7541
|
+
const desired = override ?? configured ?? DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS;
|
|
7542
|
+
const source = override !== undefined
|
|
7543
|
+
? 'agent-settings-override' as const
|
|
7544
|
+
: configured !== null ? 'framework-config' as const : 'default' as const;
|
|
7545
|
+
const strategyBound = this.strategyDerivedToolResultChars(agent);
|
|
7546
|
+
const clamped = strategyBound !== undefined && strategyBound < desired;
|
|
7547
|
+
return {
|
|
7548
|
+
cap: clamped ? strategyBound : desired,
|
|
7549
|
+
source,
|
|
7550
|
+
clampedBy: clamped ? 'strategy-bound' : null,
|
|
7551
|
+
};
|
|
7552
|
+
}
|
|
7553
|
+
|
|
6832
7554
|
private approximateDecodedBase64Bytes(base64: string): number {
|
|
6833
7555
|
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
|
|
6834
7556
|
return Math.max(0, Math.floor(base64.length * 3 / 4) - padding);
|
|
@@ -7005,6 +7727,25 @@ export class AgentFramework {
|
|
|
7005
7727
|
// synthesized channel tools, but they aren't `channel_`-prefixed so they
|
|
7006
7728
|
// need an explicit route here.
|
|
7007
7729
|
if ((enrichedCall.name === 'think' || enrichedCall.name === 'skip_reply') && this.channelRegistry) {
|
|
7730
|
+
// skip_reply(wake_in_seconds): arm a gate self-wake so "not replying
|
|
7731
|
+
// NOW" can also mean "back in a moment" — ends the turn, then wakes
|
|
7732
|
+
// the agent after N seconds unless something else wakes it first
|
|
7733
|
+
// (any turn start cancels the pending self-wake). Armed here because
|
|
7734
|
+
// the framework owns the EventGate; the registry only words the
|
|
7735
|
+
// confirmation. Without a gate the field is stripped so that
|
|
7736
|
+
// confirmation never claims a wake that can't happen.
|
|
7737
|
+
if (enrichedCall.name === 'skip_reply') {
|
|
7738
|
+
const inputObj = (enrichedCall.input ?? {}) as Record<string, unknown>;
|
|
7739
|
+
const secs = Number(inputObj.wake_in_seconds);
|
|
7740
|
+
const wants = inputObj.wake_in_seconds !== undefined && Number.isFinite(secs) && secs > 0;
|
|
7741
|
+
if (wants && this.eventGate) {
|
|
7742
|
+
const { inMs } = this.eventGate.armSelfWake(agentName, secs, 'skip_reply');
|
|
7743
|
+
console.error(`[self-wake] agent=${agentName} armed in ${Math.round(inMs / 1000)}s (skip_reply)`);
|
|
7744
|
+
} else if (inputObj.wake_in_seconds !== undefined && !this.eventGate) {
|
|
7745
|
+
delete inputObj.wake_in_seconds;
|
|
7746
|
+
console.error(`[self-wake] agent=${agentName} wake_in_seconds requested but no EventGate — ignored`);
|
|
7747
|
+
}
|
|
7748
|
+
}
|
|
7008
7749
|
this.dispatchChannelToolCall(agentName, enrichedCall);
|
|
7009
7750
|
return;
|
|
7010
7751
|
}
|
|
@@ -7632,7 +8373,6 @@ export class AgentFramework {
|
|
|
7632
8373
|
): Promise<void> {
|
|
7633
8374
|
this.mcplServerRegistry = new McplServerRegistry();
|
|
7634
8375
|
this.featureSetManager = new FeatureSetManager();
|
|
7635
|
-
this.scopeManager = new ScopeManager();
|
|
7636
8376
|
this.hookOrchestrator = new HookOrchestrator(this.mcplServerRegistry, this.featureSetManager);
|
|
7637
8377
|
|
|
7638
8378
|
// Build prefix map and store configs for tool routing
|
|
@@ -7750,15 +8490,35 @@ export class AgentFramework {
|
|
|
7750
8490
|
|
|
7751
8491
|
// Host capabilities advertised during the MCP handshake — stored so
|
|
7752
8492
|
// servers can also be connected later at runtime (connectMcplServer).
|
|
8493
|
+
// §5.2: the host advertises what it actually implements. 0.5.0-draft:
|
|
8494
|
+
// afterInference is GONE (replaced by inference/lifecycle, §10.5), and
|
|
8495
|
+
// the previously under-advertised inferenceRequest + channels — both
|
|
8496
|
+
// implemented for years — are declared (AUDIT-001/issue #76 item 12).
|
|
7753
8497
|
this.mcplHostCapabilities = {
|
|
7754
|
-
version: '0.
|
|
8498
|
+
version: '0.5',
|
|
7755
8499
|
pushEvents: true,
|
|
7756
8500
|
contextHooks: {
|
|
7757
8501
|
beforeInference: true,
|
|
7758
|
-
|
|
8502
|
+
},
|
|
8503
|
+
inferenceLifecycle: true,
|
|
8504
|
+
// modelInfo is NOT advertised: §12.2's result requires all four of
|
|
8505
|
+
// {id, vendor, contextWindow, capabilities} and the host has no
|
|
8506
|
+
// truthful source for contextWindow/capabilities (grep: none exists).
|
|
8507
|
+
// Advertising a capability answered with a malformed partial response
|
|
8508
|
+
// is worse than honest unsupported (Sol, PR #79 re-review). The
|
|
8509
|
+
// handler below answers an explicit error rather than hanging (§6.6).
|
|
8510
|
+
inferenceRequest: { streaming: true },
|
|
8511
|
+
channels: {
|
|
8512
|
+
register: true,
|
|
8513
|
+
lifecycle: true,
|
|
8514
|
+
publish: true,
|
|
8515
|
+
incoming: true,
|
|
8516
|
+
streaming: true,
|
|
8517
|
+
acknowledge: true,
|
|
8518
|
+
typing: true,
|
|
7759
8519
|
},
|
|
7760
8520
|
featureSets: true,
|
|
7761
|
-
};
|
|
8521
|
+
} as McplHostCapabilities;
|
|
7762
8522
|
|
|
7763
8523
|
for (const config of serverConfigs) {
|
|
7764
8524
|
try {
|
|
@@ -8171,30 +8931,228 @@ export class AgentFramework {
|
|
|
8171
8931
|
// releases control traffic needed for registration and marker service.
|
|
8172
8932
|
this.wireMcplEvents(connection);
|
|
8173
8933
|
|
|
8174
|
-
// Initialize feature sets if server advertises MCPL capabilities
|
|
8175
|
-
this.registerMcplServerFeatures(config, connection);
|
|
8176
|
-
|
|
8177
8934
|
if (deferAwareness) {
|
|
8935
|
+
// Staged startup: both planes are closed, so the §5.3 policy
|
|
8936
|
+
// round-trip cannot let any buffered traffic slip — establish the
|
|
8937
|
+
// grant now, before staging ever releases.
|
|
8938
|
+
await this.registerMcplServerFeatures(config, connection);
|
|
8178
8939
|
this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
|
|
8179
8940
|
return;
|
|
8180
8941
|
}
|
|
8181
8942
|
|
|
8182
8943
|
const awarenessBarrier = this.installMcplDataPlaneGate();
|
|
8183
|
-
|
|
8944
|
+
// NO early release: both planes stay closed through accounting AND the
|
|
8945
|
+
// §5.3 policy round-trip below (PR #79 review blocker 3 — the early
|
|
8946
|
+
// control flush released channels-register/changed before any grant
|
|
8947
|
+
// existed). The awareness drain rides tools/call RESPONSES, which are
|
|
8948
|
+
// never event-buffered, so it needs no plane release to make progress.
|
|
8184
8949
|
try {
|
|
8185
8950
|
await awarenessBarrier.promise;
|
|
8186
|
-
this.completeMcplDataPlaneGate(awarenessBarrier);
|
|
8187
8951
|
} catch (error) {
|
|
8188
8952
|
// Startup cannot fail open on a broken awareness ledger. Disable the
|
|
8189
8953
|
// reconnecting stub/connection before propagating the distinct error to
|
|
8190
8954
|
// initializeMcpl, which tears down any other servers and aborts create().
|
|
8955
|
+
// Ordering matters: awareness accounting settles BEFORE the §5.3
|
|
8956
|
+
// policy round-trip below, so an accounting failure aborts with the
|
|
8957
|
+
// grant still empty and every buffered event still behind the gate —
|
|
8958
|
+
// a failed startup never reports a released data plane. (The reverse
|
|
8959
|
+
// order let a push arrive during the policy await and get flushed by
|
|
8960
|
+
// teardown.)
|
|
8191
8961
|
await connection.close().catch(() => {});
|
|
8192
8962
|
throw error;
|
|
8193
8963
|
}
|
|
8194
8964
|
|
|
8965
|
+
// §5.3 initial policy handshake — after accounting, before the gate
|
|
8966
|
+
// completes: the grant is established (or knowingly left empty) before
|
|
8967
|
+
// ANY buffered traffic (control or data) flows. complete() then
|
|
8968
|
+
// ready()s both planes through the admission gate.
|
|
8969
|
+
await this.registerMcplServerFeatures(config, connection);
|
|
8970
|
+
this.completeMcplDataPlaneGate(awarenessBarrier);
|
|
8971
|
+
|
|
8195
8972
|
this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
|
|
8196
8973
|
}
|
|
8197
8974
|
|
|
8975
|
+
/** Per-connection §17.8 fetch limiter: at most one in-flight fetch, at
|
|
8976
|
+
* most one queued behind it (announcements coalesce), floor between
|
|
8977
|
+
* completed fetches. Exceeding the floor drops the excess with a
|
|
8978
|
+
* diagnostic — the host bounds the rate; it never depends on server
|
|
8979
|
+
* coalescing. */
|
|
8980
|
+
private manifestRefreshState: Map<string, { inFlight: boolean; queued: boolean; lastCompletedAt: number }> = new Map();
|
|
8981
|
+
private static readonly MANIFEST_FETCH_FLOOR_MS = 5_000;
|
|
8982
|
+
|
|
8983
|
+
/**
|
|
8984
|
+
* §17.5 host processing: fetch the complete manifest, validate exactly as
|
|
8985
|
+
* at initialize, diff, apply §6.7 consequences (removals eagerly even
|
|
8986
|
+
* when other declarations are invalid; additions only through
|
|
8987
|
+
* tell→receipt→activate), emit ONE change receipt (§17.6).
|
|
8988
|
+
*/
|
|
8989
|
+
private async handleManifestChanged(
|
|
8990
|
+
connection: McplServerConnection,
|
|
8991
|
+
announce: { revision?: string; domains?: string[] },
|
|
8992
|
+
): Promise<void> {
|
|
8993
|
+
const st = this.manifestRefreshState.get(connection.id)
|
|
8994
|
+
?? { inFlight: false, queued: false, lastCompletedAt: 0 };
|
|
8995
|
+
this.manifestRefreshState.set(connection.id, st);
|
|
8996
|
+
if (st.inFlight) {
|
|
8997
|
+
// Coalesce: one queued refresh re-runs after the current fetch — the
|
|
8998
|
+
// fetch is always of the CURRENT manifest, so N announcements need at
|
|
8999
|
+
// most one trailing fetch (§17.8).
|
|
9000
|
+
st.queued = true;
|
|
9001
|
+
return;
|
|
9002
|
+
}
|
|
9003
|
+
const sinceLast = Date.now() - st.lastCompletedAt;
|
|
9004
|
+
if (sinceLast < AgentFramework.MANIFEST_FETCH_FLOOR_MS) {
|
|
9005
|
+
if (!st.queued) {
|
|
9006
|
+
st.queued = true;
|
|
9007
|
+
setTimeout(() => {
|
|
9008
|
+
const cur = this.manifestRefreshState.get(connection.id);
|
|
9009
|
+
if (cur?.queued && !cur.inFlight) {
|
|
9010
|
+
cur.queued = false;
|
|
9011
|
+
void this.handleManifestChanged(connection, announce).catch(() => {});
|
|
9012
|
+
}
|
|
9013
|
+
}, AgentFramework.MANIFEST_FETCH_FLOOR_MS - sinceLast).unref?.();
|
|
9014
|
+
}
|
|
9015
|
+
return;
|
|
9016
|
+
}
|
|
9017
|
+
|
|
9018
|
+
st.inFlight = true;
|
|
9019
|
+
try {
|
|
9020
|
+
const config = this.mcplServerConfigs.get(connection.id);
|
|
9021
|
+
if (!config) return;
|
|
9022
|
+
|
|
9023
|
+
// §17.4 fetch. A server that cannot answer keeps its previous
|
|
9024
|
+
// manifest in force — an unparseable/failed fetch teaches nothing and
|
|
9025
|
+
// narrows nothing (§17.5 step 1).
|
|
9026
|
+
let fetched: import('./mcpl/types.js').McplCapabilities | null;
|
|
9027
|
+
try {
|
|
9028
|
+
fetched = await connection.sendManifestRequest();
|
|
9029
|
+
} catch (err) {
|
|
9030
|
+
console.error(`[mcpl] ${connection.id}: mcpl/manifest fetch failed — previous manifest stands (§17.5): ${err instanceof Error ? err.message : err}`);
|
|
9031
|
+
return;
|
|
9032
|
+
}
|
|
9033
|
+
if (!fetched || typeof fetched !== 'object') {
|
|
9034
|
+
console.error(`[mcpl] ${connection.id}: mcpl/manifest returned no object — previous manifest stands (§17.5)`);
|
|
9035
|
+
return;
|
|
9036
|
+
}
|
|
9037
|
+
|
|
9038
|
+
// Validate exactly as at initialize: expand §5.1 shorthand, apply the
|
|
9039
|
+
// config mask, recompute the grant. Unknown names mint nothing;
|
|
9040
|
+
// invalid declarations fail closed in derivation — and REMOVALS still
|
|
9041
|
+
// apply, because the new grant/derivation simply lacks whatever no
|
|
9042
|
+
// longer validates (§17.5 step 1's partial-invalid rule falls out of
|
|
9043
|
+
// computing forward from the fetched content).
|
|
9044
|
+
const expanded = expandAdvertisementShorthand(fetched);
|
|
9045
|
+
const { capabilities: masked, dropped } = maskNegotiatedCapabilities(expanded, config);
|
|
9046
|
+
const newGrant = computeGrant(masked, config, { mcpToolsAdvertised: connection.mcpToolsAdvertised });
|
|
9047
|
+
|
|
9048
|
+
const oldEffective = new Set(connection.grant.effectiveList());
|
|
9049
|
+
const newEffective = new Set(newGrant.effectiveList());
|
|
9050
|
+
const revoked = [...oldEffective].filter((p) => !newEffective.has(p)).sort();
|
|
9051
|
+
const added = [...newEffective].filter((p) => !oldEffective.has(p)).sort();
|
|
9052
|
+
|
|
9053
|
+
const oldDeclared = Object.keys(this.featureSetManager?.getDeclaredFeatureSets(connection.id) ?? {});
|
|
9054
|
+
const oldEnabled = oldDeclared.filter((n) => this.featureSetManager?.isEnabled(connection.id, n));
|
|
9055
|
+
|
|
9056
|
+
// §6.7 REDUCTION FIRST, atomically, before the server is told:
|
|
9057
|
+
// security cannot wait on consent. The interim grant is new∩old — no
|
|
9058
|
+
// revoked path survives, no added path activates yet.
|
|
9059
|
+
const hadReduction = revoked.length > 0;
|
|
9060
|
+
if (hadReduction) {
|
|
9061
|
+
const interim = new CapabilityGrant(
|
|
9062
|
+
new Set([...newEffective].filter((p) => oldEffective.has(p))),
|
|
9063
|
+
[...newGrant.deniedPaths],
|
|
9064
|
+
);
|
|
9065
|
+
connection.establishGrant(interim);
|
|
9066
|
+
}
|
|
9067
|
+
|
|
9068
|
+
// PHASE 1 — pre-receipt: live feature state derives from ACTIVE
|
|
9069
|
+
// authority (the interim grant when reduced, the standing grant
|
|
9070
|
+
// otherwise), never from the unactivated full grant. Feature state
|
|
9071
|
+
// must obey the same §6.7 ordering the grant does: a newly added
|
|
9072
|
+
// capability-backed feature set cannot be host-enabled while its
|
|
9073
|
+
// capability is still absent from the active grant (Sol, PR #84
|
|
9074
|
+
// review). Declarations come from the fetched manifest either way —
|
|
9075
|
+
// a set absent from the new manifest is gone regardless.
|
|
9076
|
+
const cfg = { enabledFeatureSets: config.enabledFeatureSets, disabledFeatureSets: config.disabledFeatureSets };
|
|
9077
|
+
const maskedOrEmpty = masked ?? ({ version: '0.5' } as import('./mcpl/types.js').McplCapabilities);
|
|
9078
|
+
this.featureSetManager!.initializeServer(connection.id, maskedOrEmpty, cfg, connection.grant);
|
|
9079
|
+
|
|
9080
|
+
// The Request DESCRIBES the proposed new policy — derived from the
|
|
9081
|
+
// full new grant on a scratch manager, installing nothing.
|
|
9082
|
+
const updateParams = new FeatureSetManager().initializeServer(connection.id, maskedOrEmpty, cfg, newGrant);
|
|
9083
|
+
updateParams.effectiveCapabilities = newGrant.effectiveList();
|
|
9084
|
+
if (newGrant.deniedPaths.length > 0) updateParams.deniedCapabilities = [...newGrant.deniedPaths];
|
|
9085
|
+
|
|
9086
|
+
// Tell → receipt → activate (§6.7). PHASE 2 by outcome:
|
|
9087
|
+
// accepted → full grant + full-derived feature state;
|
|
9088
|
+
// refused → honor fallback: 'close' closes the transport;
|
|
9089
|
+
// 'mcp-only' (or absent) = empty grant + empty-derived
|
|
9090
|
+
// feature state, mirroring the §5.3 initial path;
|
|
9091
|
+
// unanswered → interim grant and interim-derived state stand.
|
|
9092
|
+
let negotiated = false;
|
|
9093
|
+
try {
|
|
9094
|
+
const receipt = await connection.sendFeatureSetsUpdateRequest(updateParams);
|
|
9095
|
+
if (receipt && receipt.accepted === false) {
|
|
9096
|
+
const fallback = receipt.fallback ?? 'mcp-only';
|
|
9097
|
+
console.error(`[mcpl] ${connection.id} refused post-manifest policy — fallback: ${fallback}`);
|
|
9098
|
+
if (fallback === 'close') {
|
|
9099
|
+
await connection.close();
|
|
9100
|
+
return;
|
|
9101
|
+
}
|
|
9102
|
+
connection.establishGrant(CapabilityGrant.empty());
|
|
9103
|
+
this.featureSetManager!.initializeServer(connection.id, maskedOrEmpty, cfg, CapabilityGrant.empty());
|
|
9104
|
+
} else {
|
|
9105
|
+
connection.establishGrant(newGrant);
|
|
9106
|
+
this.featureSetManager!.initializeServer(connection.id, maskedOrEmpty, cfg, newGrant);
|
|
9107
|
+
connection.manifestState.lastNegotiatedAt = Date.now();
|
|
9108
|
+
negotiated = true;
|
|
9109
|
+
}
|
|
9110
|
+
} catch {
|
|
9111
|
+
console.error(`[mcpl] ${connection.id} did not answer post-manifest policy — expansion does not activate (§6.7); interim state stands`);
|
|
9112
|
+
}
|
|
9113
|
+
|
|
9114
|
+
// Adopt the validated manifest + tracking. droppedCapabilities is
|
|
9115
|
+
// REPLACED unconditionally — an empty new mask must clear stale paths
|
|
9116
|
+
// (Sol, PR #84 review).
|
|
9117
|
+
connection.capabilities = masked;
|
|
9118
|
+
connection.manifestState.lastValidatedRevision =
|
|
9119
|
+
typeof (fetched as { revision?: unknown }).revision === 'string'
|
|
9120
|
+
? (fetched as { revision: string }).revision : null;
|
|
9121
|
+
connection.manifestState.lastFetchedAt = Date.now();
|
|
9122
|
+
connection.droppedCapabilities = new Set(dropped);
|
|
9123
|
+
|
|
9124
|
+
const newDeclared = Object.keys(this.featureSetManager?.getDeclaredFeatureSets(connection.id) ?? {});
|
|
9125
|
+
const newEnabled = newDeclared.filter((n) => this.featureSetManager?.isEnabled(connection.id, n));
|
|
9126
|
+
const degraded = oldEnabled.filter((n) => !newEnabled.includes(n)).sort();
|
|
9127
|
+
const restored = newEnabled.filter((n) => !oldEnabled.includes(n)).sort();
|
|
9128
|
+
|
|
9129
|
+
// §17.6: ONE receipt per manifest change, closed host-derived impact
|
|
9130
|
+
// vocabulary, never a server-authored flag.
|
|
9131
|
+
const impacts: Array<{ impact: string; subject: string; disposition: string }> = [
|
|
9132
|
+
...revoked.map((p) => ({ impact: 'capability-revoked', subject: p, disposition: 'applied' })),
|
|
9133
|
+
...added.map((p) => ({ impact: 'capability-expansion-pending', subject: p, disposition: negotiated ? 'applied' : 'decision-needed' })),
|
|
9134
|
+
...degraded.map((n) => ({ impact: 'feature-degraded', subject: n, disposition: 'applied' })),
|
|
9135
|
+
...restored.map((n) => ({ impact: 'feature-restored', subject: n, disposition: negotiated ? 'applied' : 'decision-needed' })),
|
|
9136
|
+
];
|
|
9137
|
+
this.emitTrace({
|
|
9138
|
+
type: 'mcpl:manifest-change-receipt',
|
|
9139
|
+
serverId: connection.id,
|
|
9140
|
+
revision: connection.manifestState.lastValidatedRevision,
|
|
9141
|
+
announcedDomains: announce.domains ?? [],
|
|
9142
|
+
impacts,
|
|
9143
|
+
});
|
|
9144
|
+
this.handleToolsListChanged(connection.id);
|
|
9145
|
+
} finally {
|
|
9146
|
+
st.inFlight = false;
|
|
9147
|
+
st.lastCompletedAt = Date.now();
|
|
9148
|
+
if (st.queued) {
|
|
9149
|
+
st.queued = false;
|
|
9150
|
+
setTimeout(() => { void this.handleManifestChanged(connection, announce).catch(() => {}); },
|
|
9151
|
+
AgentFramework.MANIFEST_FETCH_FLOOR_MS).unref?.();
|
|
9152
|
+
}
|
|
9153
|
+
}
|
|
9154
|
+
}
|
|
9155
|
+
|
|
8198
9156
|
/**
|
|
8199
9157
|
* (Re-)establish a server's host-side MCPL registration: feature-set state
|
|
8200
9158
|
* (plus the featureSets/update sent to the server), scope patterns, and
|
|
@@ -8210,12 +9168,18 @@ export class AgentFramework {
|
|
|
8210
9168
|
* already has a tree), so checkpoint trees preserved across a transient
|
|
8211
9169
|
* disconnect are resumed, not reset.
|
|
8212
9170
|
*/
|
|
8213
|
-
private registerMcplServerFeatures(
|
|
9171
|
+
private async registerMcplServerFeatures(
|
|
8214
9172
|
config: import('./mcpl/types.js').McplServerConfig,
|
|
8215
9173
|
connection: McplServerConnection,
|
|
8216
|
-
): void {
|
|
9174
|
+
): Promise<void> {
|
|
8217
9175
|
if (!connection.capabilities) return;
|
|
8218
9176
|
|
|
9177
|
+
// §5.4: compute the effective grant from the (config-masked)
|
|
9178
|
+
// advertisement. Not yet active — expansion activates only after the
|
|
9179
|
+
// receipt (§6.7); until then the connection's grant is empty and every
|
|
9180
|
+
// privileged inbound method is rejected.
|
|
9181
|
+
const grant = computeGrant(connection.capabilities, config, { mcpToolsAdvertised: connection.mcpToolsAdvertised });
|
|
9182
|
+
|
|
8219
9183
|
const updateParams = this.featureSetManager!.initializeServer(
|
|
8220
9184
|
config.id,
|
|
8221
9185
|
connection.capabilities,
|
|
@@ -8223,16 +9187,60 @@ export class AgentFramework {
|
|
|
8223
9187
|
enabledFeatureSets: config.enabledFeatureSets,
|
|
8224
9188
|
disabledFeatureSets: config.disabledFeatureSets,
|
|
8225
9189
|
},
|
|
9190
|
+
grant,
|
|
8226
9191
|
);
|
|
8227
9192
|
|
|
8228
|
-
//
|
|
8229
|
-
|
|
8230
|
-
|
|
9193
|
+
// §5.3: initial policy MUST be a Request, MUST precede first fan-out,
|
|
9194
|
+
// and MUST be sent even when nothing is enabled or disabled — a server
|
|
9195
|
+
// defaulted to fully disabled has to be told. The response is a
|
|
9196
|
+
// degradation receipt (§6.7): consequence testimony, never policy
|
|
9197
|
+
// authority — nothing in it may widen the grant.
|
|
9198
|
+
updateParams.effectiveCapabilities = grant.effectiveList();
|
|
9199
|
+
if (grant.deniedPaths.length > 0) {
|
|
9200
|
+
updateParams.deniedCapabilities = [...grant.deniedPaths];
|
|
9201
|
+
}
|
|
9202
|
+
|
|
9203
|
+
try {
|
|
9204
|
+
const receipt = await connection.sendFeatureSetsUpdateRequest(updateParams);
|
|
9205
|
+
if (receipt && receipt.accepted === false) {
|
|
9206
|
+
const fallback = receipt.fallback ?? 'mcp-only';
|
|
9207
|
+
console.error(
|
|
9208
|
+
`[mcpl] ${config.id} refused initial policy (${receipt.reason ?? 'no reason'}) — fallback: ${fallback}`,
|
|
9209
|
+
);
|
|
9210
|
+
if (fallback === 'close') {
|
|
9211
|
+
await connection.close();
|
|
9212
|
+
return;
|
|
9213
|
+
}
|
|
9214
|
+
// mcp-only (§3.2): grant stays empty, MCPL privileged surface stays
|
|
9215
|
+
// dark, plain MCP tools keep working. MUST NOT widen in response to
|
|
9216
|
+
// the refusal — a human can change config; the policy engine cannot.
|
|
9217
|
+
return;
|
|
9218
|
+
}
|
|
9219
|
+
// Expansion activates on receipt (§6.7).
|
|
9220
|
+
connection.establishGrant(grant);
|
|
9221
|
+
if (receipt?.accepted === true && receipt.mode === 'degraded') {
|
|
9222
|
+
for (const uf of receipt.unavailableFeatures ?? []) {
|
|
9223
|
+
console.error(
|
|
9224
|
+
`[mcpl] ${config.id} degraded: ${uf.featureSet} (${uf.effect}; missing ${uf.missingCapabilities.join(', ')})`,
|
|
9225
|
+
);
|
|
9226
|
+
}
|
|
9227
|
+
}
|
|
9228
|
+
} catch (err) {
|
|
9229
|
+
// A pre-0.5 server treats the Request as a notification and never
|
|
9230
|
+
// answers. §6.7: an unanswered expansion simply does not activate —
|
|
9231
|
+
// grant stays empty, connection is effectively MCP-only. This WILL
|
|
9232
|
+
// dark every un-migrated MCPL server's privileged surface; accepted
|
|
9233
|
+
// deliberately for the single-release rollout (issue #76 item 9).
|
|
9234
|
+
console.error(
|
|
9235
|
+
`[mcpl] ${config.id} did not answer the initial policy Request — ` +
|
|
9236
|
+
`MCPL privileged surface stays disabled (§6.7 unanswered expansion): ` +
|
|
9237
|
+
(err instanceof Error ? err.message : String(err)),
|
|
9238
|
+
);
|
|
9239
|
+
return;
|
|
8231
9240
|
}
|
|
8232
9241
|
|
|
8233
|
-
// Configure scope whitelist/blacklist patterns
|
|
8234
9242
|
if (config.scopes) {
|
|
8235
|
-
|
|
9243
|
+
console.error(`[mcpl] ${config.id}: config \`scopes\` is ignored — §7 Scoped Access removed in 0.5.0`);
|
|
8236
9244
|
}
|
|
8237
9245
|
|
|
8238
9246
|
// Register stateful feature sets with checkpoint manager (Step 8)
|
|
@@ -8348,23 +9356,62 @@ export class AgentFramework {
|
|
|
8348
9356
|
listMcplServers(): Array<{
|
|
8349
9357
|
id: string;
|
|
8350
9358
|
connected: boolean;
|
|
9359
|
+
retrying: boolean;
|
|
8351
9360
|
toolPrefix: string;
|
|
8352
9361
|
toolCount: number;
|
|
9362
|
+
/** True only after the §5.3 policy receipt activated the grant. */
|
|
9363
|
+
policyEstablished: boolean;
|
|
9364
|
+
/** Sole normative authorization allowlist for the connection (§5.4). */
|
|
9365
|
+
effectiveGrant: string[];
|
|
9366
|
+
/** Advertised paths removed by host enabled/disabled capability config. */
|
|
9367
|
+
maskedCapabilities: string[];
|
|
9368
|
+
/** Advertised paths removed by a host deny-by-default rule (§13.4). */
|
|
9369
|
+
deniedCapabilities: string[];
|
|
9370
|
+
/** Host-owned authority; deliberately separate from the portable grant. */
|
|
9371
|
+
allowHostCommands: boolean;
|
|
9372
|
+
/**
|
|
9373
|
+
* Per-transport §17 facts about the last manifest this host fetched and
|
|
9374
|
+
* acted on. The revision is server-authored and equality-only; these are
|
|
9375
|
+
* not the server's manifestChanged announcements.
|
|
9376
|
+
*/
|
|
9377
|
+
manifestState: {
|
|
9378
|
+
lastValidatedRevision: string | null;
|
|
9379
|
+
lastFetchedAt: number | null;
|
|
9380
|
+
lastNegotiatedAt: number | null;
|
|
9381
|
+
};
|
|
8353
9382
|
command?: string;
|
|
8354
9383
|
url?: string;
|
|
8355
9384
|
}> {
|
|
8356
9385
|
const result: Array<{
|
|
8357
|
-
id: string; connected: boolean;
|
|
9386
|
+
id: string; connected: boolean; retrying: boolean;
|
|
9387
|
+
toolPrefix: string; toolCount: number; policyEstablished: boolean;
|
|
9388
|
+
effectiveGrant: string[]; maskedCapabilities: string[];
|
|
9389
|
+
deniedCapabilities: string[]; allowHostCommands: boolean;
|
|
9390
|
+
manifestState: {
|
|
9391
|
+
lastValidatedRevision: string | null;
|
|
9392
|
+
lastFetchedAt: number | null;
|
|
9393
|
+
lastNegotiatedAt: number | null;
|
|
9394
|
+
};
|
|
8358
9395
|
command?: string; url?: string;
|
|
8359
9396
|
}> = [];
|
|
8360
9397
|
for (const [id, config] of this.mcplServerConfigs) {
|
|
8361
9398
|
const prefix = config.toolPrefix ?? `mcpl--${id}`;
|
|
8362
9399
|
const connection = this.mcplServerRegistry?.getServer(id) ?? null;
|
|
9400
|
+
const connected = connection?.isConnected ?? false;
|
|
8363
9401
|
result.push({
|
|
8364
9402
|
id,
|
|
8365
|
-
connected
|
|
9403
|
+
connected,
|
|
9404
|
+
retrying: !connected && (connection?.willReconnect ?? false),
|
|
8366
9405
|
toolPrefix: prefix,
|
|
8367
9406
|
toolCount: this.mcplTools.filter(t => t.name.startsWith(`${prefix}--`)).length,
|
|
9407
|
+
policyEstablished: connection?.policyEstablished ?? false,
|
|
9408
|
+
effectiveGrant: connection?.grant.effectiveList() ?? [],
|
|
9409
|
+
maskedCapabilities: [...(connection?.droppedCapabilities ?? [])].sort(),
|
|
9410
|
+
deniedCapabilities: [...(connection?.grant.deniedPaths ?? [])].sort(),
|
|
9411
|
+
allowHostCommands: config.allowHostCommands === true,
|
|
9412
|
+
manifestState: connection
|
|
9413
|
+
? { ...connection.manifestState }
|
|
9414
|
+
: { lastValidatedRevision: null, lastFetchedAt: null, lastNegotiatedAt: null },
|
|
8368
9415
|
command: config.command,
|
|
8369
9416
|
url: config.url,
|
|
8370
9417
|
});
|
|
@@ -8383,26 +9430,15 @@ export class AgentFramework {
|
|
|
8383
9430
|
this.emitTrace({ type: 'mcpl:server-stderr', serverId: connection.id, line: params.line });
|
|
8384
9431
|
});
|
|
8385
9432
|
|
|
8386
|
-
//
|
|
8387
|
-
|
|
8388
|
-
|
|
8389
|
-
|
|
9433
|
+
// featureSets/changed is REMOVED in 0.5.0 — the connection answers it
|
|
9434
|
+
// -32601 (superseded by SPEC §17's host-diffed manifest fetch); no
|
|
9435
|
+
// listener, and nothing mutates declarations off a server-authored
|
|
9436
|
+
// change payload.
|
|
8390
9437
|
|
|
8391
9438
|
// Handle scope elevation requests
|
|
8392
|
-
|
|
8393
|
-
|
|
8394
|
-
|
|
8395
|
-
) => {
|
|
8396
|
-
if (this.scopeManager && responder) {
|
|
8397
|
-
try {
|
|
8398
|
-
const result: ScopeElevateResult = await this.scopeManager.handleElevation(params);
|
|
8399
|
-
responder.respond(result);
|
|
8400
|
-
} catch (error) {
|
|
8401
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
8402
|
-
responder.respondError(-32603, err.message);
|
|
8403
|
-
}
|
|
8404
|
-
}
|
|
8405
|
-
});
|
|
9439
|
+
// §7 Scoped Access is removed in 0.5.0. scope/elevate is answered
|
|
9440
|
+
// -32601 at the connection (server-connection.ts) and never reaches the
|
|
9441
|
+
// framework; the ScopeManager and config `scopes` wiring went with it.
|
|
8406
9442
|
|
|
8407
9443
|
// Handle push events (Step 6)
|
|
8408
9444
|
connection.on('push-event', async (
|
|
@@ -8438,9 +9474,51 @@ export class AgentFramework {
|
|
|
8438
9474
|
await this.channelRegistry?.handleRegister(connection.id, params, responder as never);
|
|
8439
9475
|
});
|
|
8440
9476
|
|
|
8441
|
-
// Handle channel changes (Step 7)
|
|
8442
|
-
|
|
8443
|
-
|
|
9477
|
+
// Handle channel changes (Step 7) — §14.5 dual-mode: the responder is
|
|
9478
|
+
// present for the Request form and carries itemized added-descriptor
|
|
9479
|
+
// results; absent for Notifications, where rejection is itemwise
|
|
9480
|
+
// filtering plus a diagnostic trace.
|
|
9481
|
+
connection.on('channels-changed', async (
|
|
9482
|
+
params: ChannelsChangedParams,
|
|
9483
|
+
responder?: { respond: (result: unknown) => void },
|
|
9484
|
+
) => {
|
|
9485
|
+
await this.channelRegistry?.handleChanged(connection.id, params, responder as never);
|
|
9486
|
+
});
|
|
9487
|
+
|
|
9488
|
+
// §6.6/§12: requests that previously had no handler and hung forever
|
|
9489
|
+
// (AUDIT-001 item 4). The connection-level grant gate has already run.
|
|
9490
|
+
// §17.3: a manifest-change announcement. Rate-limited re-fetch → diff
|
|
9491
|
+
// → §6.7 consequences → one receipt. The announcement itself carries no
|
|
9492
|
+
// payload and no authority; everything below acts on FETCHED content.
|
|
9493
|
+
connection.on('manifest-changed', (params: { revision?: string; domains?: string[] }) => {
|
|
9494
|
+
void this.handleManifestChanged(connection, params).catch((err) => {
|
|
9495
|
+
console.error(`[mcpl] ${connection.id} manifest refresh failed:`, err instanceof Error ? err.message : err);
|
|
9496
|
+
});
|
|
9497
|
+
});
|
|
9498
|
+
|
|
9499
|
+
connection.on('channels-list', (
|
|
9500
|
+
_params: unknown,
|
|
9501
|
+
responder?: { respond: (result: unknown) => void },
|
|
9502
|
+
) => {
|
|
9503
|
+
responder?.respond({
|
|
9504
|
+
channels: this.channelRegistry?.descriptorsForServer(connection.id) ?? [],
|
|
9505
|
+
});
|
|
9506
|
+
});
|
|
9507
|
+
|
|
9508
|
+
connection.on('model-info', (
|
|
9509
|
+
_params: unknown,
|
|
9510
|
+
responder?: { respond: (result: unknown) => void; respondError?: (code: number, message: string, data?: unknown) => void },
|
|
9511
|
+
) => {
|
|
9512
|
+
// §12.2 requires ALL FOUR of {id, vendor, contextWindow, capabilities}
|
|
9513
|
+
// in the DIRECT result shape, and numeric contextWindow cannot express
|
|
9514
|
+
// "unknown". The host has no truthful source for it, so modelInfo is
|
|
9515
|
+
// not advertised and this answers an explicit error instead of a
|
|
9516
|
+
// malformed partial (§6.6: a method that will never be answered
|
|
9517
|
+
// truthfully MUST error, not fabricate).
|
|
9518
|
+
responder?.respondError?.(
|
|
9519
|
+
-32601,
|
|
9520
|
+
'model/info unavailable: host does not advertise modelInfo (no truthful contextWindow source)',
|
|
9521
|
+
);
|
|
8444
9522
|
});
|
|
8445
9523
|
|
|
8446
9524
|
// Handle incoming channel messages (Step 7)
|
|
@@ -8499,19 +9577,25 @@ export class AgentFramework {
|
|
|
8499
9577
|
// reconnect observes it before doing work that could wake an agent. The
|
|
8500
9578
|
// connection paused its data plane before wiring the fresh transport.
|
|
8501
9579
|
const awarenessBarrier = this.installMcplDataPlaneGate();
|
|
8502
|
-
|
|
8503
|
-
|
|
8504
|
-
|
|
8505
|
-
|
|
9580
|
+
// Async: the §5.3 policy Request must be answered (re-establishing the
|
|
9581
|
+
// grant) before the data-plane barrier releases — otherwise the first
|
|
9582
|
+
// post-reconnect events land while the grant is still empty and are
|
|
9583
|
+
// rejected fail-closed instead of delivered.
|
|
9584
|
+
void (async () => {
|
|
9585
|
+
try {
|
|
9586
|
+
const config = this.mcplServerConfigs.get(connection.id);
|
|
9587
|
+
if (config) {
|
|
9588
|
+
await this.registerMcplServerFeatures(config, connection);
|
|
9589
|
+
}
|
|
9590
|
+
} catch (error) {
|
|
9591
|
+
console.error(
|
|
9592
|
+
`MCPL server "${connection.id}" re-registration after reconnect failed:`,
|
|
9593
|
+
error instanceof Error ? error.message : error,
|
|
9594
|
+
);
|
|
8506
9595
|
}
|
|
8507
|
-
|
|
8508
|
-
|
|
8509
|
-
|
|
8510
|
-
error instanceof Error ? error.message : error,
|
|
8511
|
-
);
|
|
8512
|
-
}
|
|
8513
|
-
this.releaseMcplDataPlaneGate(awarenessBarrier);
|
|
8514
|
-
this.handleToolsListChanged(connection.id);
|
|
9596
|
+
this.releaseMcplDataPlaneGate(awarenessBarrier);
|
|
9597
|
+
this.handleToolsListChanged(connection.id);
|
|
9598
|
+
})();
|
|
8515
9599
|
void awarenessBarrier.promise.then(() => {
|
|
8516
9600
|
if (
|
|
8517
9601
|
awarenessBarrier.requiresBarrier
|
|
@@ -8626,6 +9710,7 @@ export class AgentFramework {
|
|
|
8626
9710
|
if (!this.mcplServerRegistry) return;
|
|
8627
9711
|
|
|
8628
9712
|
const tools: import('./types/index.js').ToolDefinition[] = [];
|
|
9713
|
+
const toolFeatureSets = new Map<string, string>();
|
|
8629
9714
|
|
|
8630
9715
|
for (const server of this.mcplServerRegistry.getAllServers()) {
|
|
8631
9716
|
const config = this.mcplServerConfigs.get(server.id);
|
|
@@ -8634,13 +9719,26 @@ export class AgentFramework {
|
|
|
8634
9719
|
const result = await server.sendToolsList();
|
|
8635
9720
|
for (const tool of result.tools) {
|
|
8636
9721
|
if (!isToolAllowed(tool.name, config)) continue;
|
|
9722
|
+
const namespacedName = `${prefix}--${tool.name}`;
|
|
9723
|
+
const attributedTool = tool as typeof tool & {
|
|
9724
|
+
featureSet?: unknown;
|
|
9725
|
+
_meta?: { featureSet?: unknown };
|
|
9726
|
+
};
|
|
9727
|
+
const featureSet = typeof attributedTool.featureSet === 'string'
|
|
9728
|
+
? attributedTool.featureSet
|
|
9729
|
+
: typeof attributedTool._meta?.featureSet === 'string'
|
|
9730
|
+
? attributedTool._meta.featureSet
|
|
9731
|
+
: undefined;
|
|
8637
9732
|
// MCP tool schemas are generic JSON Schema; cast to membrane's ToolDefinition format
|
|
8638
9733
|
const schema = tool.inputSchema as import('./types/index.js').ToolDefinition['inputSchema'];
|
|
8639
9734
|
tools.push({
|
|
8640
|
-
name:
|
|
9735
|
+
name: namespacedName,
|
|
8641
9736
|
description: tool.description ?? '',
|
|
8642
9737
|
inputSchema: schema,
|
|
8643
9738
|
});
|
|
9739
|
+
if (featureSet !== undefined) {
|
|
9740
|
+
toolFeatureSets.set(namespacedName, featureSet);
|
|
9741
|
+
}
|
|
8644
9742
|
}
|
|
8645
9743
|
} catch {
|
|
8646
9744
|
// Server may not support tools/list — skip silently
|
|
@@ -8648,6 +9746,7 @@ export class AgentFramework {
|
|
|
8648
9746
|
}
|
|
8649
9747
|
|
|
8650
9748
|
this.mcplTools = tools;
|
|
9749
|
+
this.mcplToolFeatureSets = toolFeatureSets;
|
|
8651
9750
|
}
|
|
8652
9751
|
|
|
8653
9752
|
/**
|
|
@@ -8745,20 +9844,47 @@ export class AgentFramework {
|
|
|
8745
9844
|
const args = (call.input && typeof call.input === 'object') ? call.input as Record<string, unknown> : {};
|
|
8746
9845
|
|
|
8747
9846
|
// Build state params for stateful tools (Step 8).
|
|
8748
|
-
//
|
|
8749
|
-
//
|
|
8750
|
-
//
|
|
8751
|
-
// mixes host-managed (e.g. a feed) and server-managed (e.g. post/undo) sets,
|
|
8752
|
-
// that misattributes the feed's state.
|
|
9847
|
+
// A tools/list attribution selects that exact stateful set. Untagged tools
|
|
9848
|
+
// retain the host-managed or unique server-managed fallback, but never guess
|
|
9849
|
+
// between multiple server-managed sets.
|
|
8753
9850
|
let stateParams: { state?: unknown; checkpoint?: string } | undefined;
|
|
9851
|
+
let attributedFeatureSet: string | null = null;
|
|
8754
9852
|
if (this.checkpointManager) {
|
|
8755
|
-
const
|
|
8756
|
-
if (
|
|
8757
|
-
|
|
9853
|
+
const toolFeatureSet = this.mcplToolFeatureSets.get(call.name);
|
|
9854
|
+
if (toolFeatureSet !== undefined) {
|
|
9855
|
+
if (
|
|
9856
|
+
this.checkpointManager.isHostManaged(serverId, toolFeatureSet)
|
|
9857
|
+
|| this.checkpointManager.getStatefulFeatureSet(serverId, toolFeatureSet) !== null
|
|
9858
|
+
) {
|
|
9859
|
+
attributedFeatureSet = toolFeatureSet;
|
|
9860
|
+
} else {
|
|
9861
|
+
console.warn(
|
|
9862
|
+
`[mcpl] Tool "${call.name}" declares unknown or non-stateful feature set ` +
|
|
9863
|
+
`"${toolFeatureSet}"; skipping state exchange.`,
|
|
9864
|
+
);
|
|
9865
|
+
}
|
|
8758
9866
|
} else {
|
|
8759
|
-
|
|
8760
|
-
|
|
8761
|
-
|
|
9867
|
+
attributedFeatureSet = this.checkpointManager.getHostManagedFeatureSet(serverId)
|
|
9868
|
+
?? this.checkpointManager.getStatefulFeatureSet(serverId);
|
|
9869
|
+
if (
|
|
9870
|
+
attributedFeatureSet === null
|
|
9871
|
+
&& this.checkpointManager.hasAmbiguousServerManagedFeatureSets(serverId)
|
|
9872
|
+
) {
|
|
9873
|
+
console.warn(
|
|
9874
|
+
`[mcpl] Tool "${call.name}" has no feature-set attribution and server ` +
|
|
9875
|
+
`"${serverId}" has multiple server-managed stateful feature sets; ` +
|
|
9876
|
+
'skipping state exchange.',
|
|
9877
|
+
);
|
|
9878
|
+
}
|
|
9879
|
+
}
|
|
9880
|
+
|
|
9881
|
+
if (attributedFeatureSet !== null) {
|
|
9882
|
+
if (this.checkpointManager.isHostManaged(serverId, attributedFeatureSet)) {
|
|
9883
|
+
stateParams = {
|
|
9884
|
+
state: this.checkpointManager.getCurrentState(serverId, attributedFeatureSet),
|
|
9885
|
+
};
|
|
9886
|
+
} else {
|
|
9887
|
+
const cp = this.checkpointManager.getCurrentCheckpoint(serverId, attributedFeatureSet);
|
|
8762
9888
|
if (cp) stateParams = { checkpoint: cp };
|
|
8763
9889
|
}
|
|
8764
9890
|
}
|
|
@@ -8770,15 +9896,10 @@ export class AgentFramework {
|
|
|
8770
9896
|
this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs });
|
|
8771
9897
|
|
|
8772
9898
|
// Record checkpoint from stateful tool response (Step 8).
|
|
8773
|
-
//
|
|
8774
|
-
//
|
|
8775
|
-
// else the single stateful set. Avoids recording a feed checkpoint onto a
|
|
8776
|
-
// server-managed set just because it registered first.
|
|
9899
|
+
// Explicit response attribution is authoritative. Otherwise use the
|
|
9900
|
+
// tool-derived (or safe single-set) attribution chosen before the call.
|
|
8777
9901
|
if (result.state && this.checkpointManager) {
|
|
8778
|
-
const
|
|
8779
|
-
const fs = tagged
|
|
8780
|
-
?? this.checkpointManager.getHostManagedFeatureSet(serverId)
|
|
8781
|
-
?? this.checkpointManager.getStatefulFeatureSet(serverId);
|
|
9902
|
+
const fs = result.state.featureSet ?? attributedFeatureSet;
|
|
8782
9903
|
if (fs && this.checkpointManager.isStateful(serverId, fs)) {
|
|
8783
9904
|
this.checkpointManager.recordCheckpoint(serverId, fs, result.state);
|
|
8784
9905
|
}
|
|
@@ -8803,6 +9924,23 @@ export class AgentFramework {
|
|
|
8803
9924
|
this.channelRegistry
|
|
8804
9925
|
.openIfClosedForSend(target, serverId)
|
|
8805
9926
|
.then(({ status, channelId, label }) => {
|
|
9927
|
+
// Turn-scoped engagement record (mid-turn re-pin, 2026-07-31
|
|
9928
|
+
// n=7): a human follow-up in a channel the agent explicitly
|
|
9929
|
+
// sent into this turn counts as conversationally addressed.
|
|
9930
|
+
// Recorded here — not eagerly — because injection metadata
|
|
9931
|
+
// carries the CANONICAL channel id (descriptor form), while
|
|
9932
|
+
// send args often carry the raw provider id; this callback
|
|
9933
|
+
// has the resolved form. The already-open path resolves on
|
|
9934
|
+
// the microtask queue, i.e. before the tool-result event
|
|
9935
|
+
// that evaluates injections.
|
|
9936
|
+
if (status !== 'unknown-channel' && status !== 'ambiguous') {
|
|
9937
|
+
let engaged = this.turnEngagedChannels.get(agentName);
|
|
9938
|
+
if (!engaged) {
|
|
9939
|
+
engaged = new Set();
|
|
9940
|
+
this.turnEngagedChannels.set(agentName, engaged);
|
|
9941
|
+
}
|
|
9942
|
+
engaged.add(channelId ?? target);
|
|
9943
|
+
}
|
|
8806
9944
|
if (status === 'opened') {
|
|
8807
9945
|
console.error(
|
|
8808
9946
|
`[channel] ${agentName}: explicit ${toolName} into closed channel ${target} — opened (send implies engagement)`,
|
|
@@ -8918,17 +10056,40 @@ export class AgentFramework {
|
|
|
8918
10056
|
const durationMs = Date.now() - startTime;
|
|
8919
10057
|
this.emitTrace({ type: 'tool:completed', module: 'channels', tool: call.name, callId: call.id, durationMs });
|
|
8920
10058
|
// A successful channel_open plants the agent's feet in the opened
|
|
8921
|
-
// channel
|
|
8922
|
-
//
|
|
8923
|
-
//
|
|
8924
|
-
//
|
|
8925
|
-
//
|
|
8926
|
-
//
|
|
10059
|
+
// channel. The agent's own deliberate open is the strongest "my next
|
|
10060
|
+
// words go here" signal in the system — stronger than any injection —
|
|
10061
|
+
// so it MOVES the current turn's prose pin (turnLocusPins), not just
|
|
10062
|
+
// the next turn's trigger state. (The original 2026-07-21 Aria fix
|
|
10063
|
+
// set only activeTriggerChannels, which resolveLocus reads at the
|
|
10064
|
+
// NEXT turn's freeze — the turn-frozen refactor had silently
|
|
10065
|
+
// regressed the "reply right after opening" case; found while
|
|
10066
|
+
// mapping the routing logic 2026-07-31, antra-ratified fix.) The
|
|
10067
|
+
// announcement rides THIS TOOL RESULT — model-requested content,
|
|
10068
|
+
// distance zero, the safest role there is — instead of a separate
|
|
10069
|
+
// window notice. lastAnnouncedLocus tracks it so announce-on-change
|
|
10070
|
+
// stays coherent. Locus-mode agents only: explicit mode has no pin.
|
|
8927
10071
|
if (call.name === 'channel_open' && result?.success) {
|
|
8928
10072
|
const opened =
|
|
8929
10073
|
(result.data as { channelId?: string } | undefined)?.channelId
|
|
8930
10074
|
?? (call.input as { channelId?: string } | undefined)?.channelId;
|
|
8931
|
-
if (opened)
|
|
10075
|
+
if (opened) {
|
|
10076
|
+
this.activeTriggerChannels.set(agentName, opened);
|
|
10077
|
+
const openerAgent = this.agents.get(agentName);
|
|
10078
|
+
if (openerAgent && openerAgent.proseRouting !== 'explicit') {
|
|
10079
|
+
this.turnLocusPins.set(agentName, opened);
|
|
10080
|
+
this.lastAnnouncedLocus.set(agentName, opened);
|
|
10081
|
+
result = {
|
|
10082
|
+
...result,
|
|
10083
|
+
data: {
|
|
10084
|
+
...(result.data as Record<string, unknown> | undefined),
|
|
10085
|
+
routing: 'Your plain speech now lands in this channel. Other channels need an explicit send tool.',
|
|
10086
|
+
},
|
|
10087
|
+
};
|
|
10088
|
+
console.error(
|
|
10089
|
+
`[routing] ${agentName}: channel_open -> pin moved to ${opened} (announced in tool result)`,
|
|
10090
|
+
);
|
|
10091
|
+
}
|
|
10092
|
+
}
|
|
8932
10093
|
}
|
|
8933
10094
|
this.pushEvent({
|
|
8934
10095
|
type: 'tool-result',
|
|
@@ -8988,6 +10149,7 @@ export class AgentFramework {
|
|
|
8988
10149
|
// range/shape checks.
|
|
8989
10150
|
const input = (call.input ?? {}) as {
|
|
8990
10151
|
name?: unknown; match?: unknown; resets?: unknown; position?: unknown;
|
|
10152
|
+
insertBefore?: unknown; insertAfter?: unknown; passthrough?: unknown;
|
|
8991
10153
|
behavior?: unknown; debounceMs?: unknown; rateLimit?: unknown; passiveSample?: unknown;
|
|
8992
10154
|
};
|
|
8993
10155
|
const behaviorSources = [
|
|
@@ -9018,17 +10180,72 @@ export class AgentFramework {
|
|
|
9018
10180
|
: input.passiveSample !== undefined ? { passive_sample: input.passiveSample }
|
|
9019
10181
|
: input.behavior;
|
|
9020
10182
|
|
|
9021
|
-
|
|
10183
|
+
// Placement: at most one of position / insertBefore / insertAfter.
|
|
10184
|
+
// With none given, a replaced rule stays where it was and a new rule
|
|
10185
|
+
// appends (addPolicy's contract).
|
|
10186
|
+
const placementSources = [
|
|
10187
|
+
input.position !== undefined ? 'position' : null,
|
|
10188
|
+
input.insertBefore !== undefined ? 'insertBefore' : null,
|
|
10189
|
+
input.insertAfter !== undefined ? 'insertAfter' : null,
|
|
10190
|
+
].filter((s): s is string => s !== null);
|
|
10191
|
+
if (placementSources.length > 1) {
|
|
10192
|
+
finish({
|
|
10193
|
+
success: false,
|
|
10194
|
+
error: `wake_add_rule: give at most one placement, got [${placementSources.join(', ')}].`,
|
|
10195
|
+
isError: true,
|
|
10196
|
+
});
|
|
10197
|
+
return;
|
|
10198
|
+
}
|
|
10199
|
+
let position: 'append' | 'prepend' | { before: string } | { after: string } | undefined;
|
|
10200
|
+
if (typeof input.insertBefore === 'string' && input.insertBefore) {
|
|
10201
|
+
position = { before: input.insertBefore };
|
|
10202
|
+
} else if (typeof input.insertAfter === 'string' && input.insertAfter) {
|
|
10203
|
+
position = { after: input.insertAfter };
|
|
10204
|
+
} else if (input.position === 'prepend' || input.position === 'append') {
|
|
10205
|
+
position = input.position;
|
|
10206
|
+
}
|
|
10207
|
+
|
|
9022
10208
|
const rawPolicy = {
|
|
9023
10209
|
name: input.name,
|
|
9024
10210
|
match: input.match ?? {},
|
|
9025
10211
|
behavior,
|
|
10212
|
+
...(input.passthrough !== undefined ? { passthrough: input.passthrough } : {}),
|
|
9026
10213
|
...(input.resets !== undefined ? { resets: input.resets } : {}),
|
|
9027
10214
|
};
|
|
9028
|
-
|
|
10215
|
+
|
|
10216
|
+
// Before/after probe table + shadow lint: the moment a rule changes the
|
|
10217
|
+
// evaluation order is the moment its real semantics must be legible
|
|
10218
|
+
// (af#105 — a prepended catch-all sampler silently disabled the
|
|
10219
|
+
// addressed-message wakes for 12h before anyone could see why).
|
|
10220
|
+
const before = new Map(
|
|
10221
|
+
this.eventGate!.probeTable().map((r) => [r.probe, r]),
|
|
10222
|
+
);
|
|
10223
|
+
const policy = this.addGatePolicy(rawPolicy, position ? { position } : undefined);
|
|
10224
|
+
const after = this.eventGate!.probeTable();
|
|
10225
|
+
const probes = after.map((r) => {
|
|
10226
|
+
const prev = before.get(r.probe);
|
|
10227
|
+
const changed = !!prev && (prev.policy !== r.policy || prev.wouldWake !== r.wouldWake);
|
|
10228
|
+
return {
|
|
10229
|
+
probe: r.probe,
|
|
10230
|
+
rule: r.policy ?? '(default)',
|
|
10231
|
+
behavior: r.behavior,
|
|
10232
|
+
wouldWake: r.wouldWake,
|
|
10233
|
+
...(changed ? { changed: true, was: `${prev!.policy ?? '(default)'} (wouldWake=${prev!.wouldWake})` } : {}),
|
|
10234
|
+
};
|
|
10235
|
+
});
|
|
10236
|
+
const shadowWarnings = this.eventGate!.lintShadows()
|
|
10237
|
+
.map((w) => formatShadowWarning(w));
|
|
9029
10238
|
finish({
|
|
9030
10239
|
success: true,
|
|
9031
|
-
data: {
|
|
10240
|
+
data: {
|
|
10241
|
+
added: policy.name,
|
|
10242
|
+
policies: this.getGatePolicyNames(),
|
|
10243
|
+
...(shadowWarnings.length > 0 ? { shadowWarnings } : {}),
|
|
10244
|
+
probes,
|
|
10245
|
+
...(probes.some((p) => 'changed' in p)
|
|
10246
|
+
? {}
|
|
10247
|
+
: { note: 'No canonical probe changed its winning rule — the new rule affects none of the common chat/heartbeat shapes, or only channel/path-specific events the probes do not model.' }),
|
|
10248
|
+
},
|
|
9032
10249
|
});
|
|
9033
10250
|
} catch (err) {
|
|
9034
10251
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -9681,12 +10898,11 @@ export class AgentFramework {
|
|
|
9681
10898
|
conversationId: agent.name,
|
|
9682
10899
|
turnIndex: 0, // Simplified; needs per-conversation counter TODO
|
|
9683
10900
|
userMessage: null, // Could extract from trigger context
|
|
9684
|
-
|
|
9685
|
-
|
|
9686
|
-
|
|
9687
|
-
|
|
9688
|
-
|
|
9689
|
-
},
|
|
10901
|
+
// Truthful fields only (§10.1 honesty over completeness): id is real,
|
|
10902
|
+
// vendor/contextWindow/capabilities have no source in this host and
|
|
10903
|
+
// are omitted rather than fabricated — the hardcoded 200000 was false
|
|
10904
|
+
// for residents configured at 300k/600k.
|
|
10905
|
+
model: { id: agent.model },
|
|
9690
10906
|
channels: this.channelRegistry?.buildChannelContext(agent.name),
|
|
9691
10907
|
};
|
|
9692
10908
|
}
|