@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/dist/src/framework.js
CHANGED
|
@@ -4,20 +4,22 @@ import { JsStore } from '@animalabs/chronicle';
|
|
|
4
4
|
import { MembraneError } from '@animalabs/membrane';
|
|
5
5
|
import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager';
|
|
6
6
|
import { ProcessQueueImpl } from './queue.js';
|
|
7
|
+
import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK } from './refusal-reactions.js';
|
|
7
8
|
import { Agent } from './agent.js';
|
|
8
9
|
import { ModuleRegistry, isStateExistsError } from './module-registry.js';
|
|
9
10
|
import { McplServerRegistry } from './mcpl/server-registry.js';
|
|
10
11
|
import { FeatureSetManager } from './mcpl/feature-set-manager.js';
|
|
11
|
-
import {
|
|
12
|
+
import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './mcpl/capability-grant.js';
|
|
13
|
+
import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js';
|
|
12
14
|
import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
|
|
13
15
|
import { PushHandler } from './mcpl/push-handler.js';
|
|
14
|
-
import { parseProsePrefix } from './mcpl/prose-grammar.js';
|
|
16
|
+
import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
|
|
15
17
|
import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
|
|
16
18
|
import { InferenceRouter } from './mcpl/inference-router.js';
|
|
17
19
|
import { ChannelRegistry } from './mcpl/channel-registry.js';
|
|
18
20
|
import { ConversationRouter } from './mcpl/conversation-router.js';
|
|
19
21
|
import { safeSlice } from './safe-slice.js';
|
|
20
|
-
import { toolResultDataToHistoryString } from './tool-result-history.js';
|
|
22
|
+
import { toolResultDataToHistoryString, truncateForHistory, DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, } from './tool-result-history.js';
|
|
21
23
|
import { randomUUID } from 'node:crypto';
|
|
22
24
|
import { PyRunner, buildInjectedTools } from './code-execution/py-runner.js';
|
|
23
25
|
import { buildCodeExecutionToolDefinition, CODE_EXECUTION_TOOL_NAME, } from './code-execution/tool-definition.js';
|
|
@@ -62,6 +64,8 @@ function sniffImageMediaType(data) {
|
|
|
62
64
|
const SILENCING_TOOLS = new Set([
|
|
63
65
|
'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
|
|
64
66
|
]);
|
|
67
|
+
/** World-surface publication names that outrank hybrid prose envelopes. */
|
|
68
|
+
const HYBRID_PUBLICATION_TOOLS = new Set(['say', 'whisper']);
|
|
65
69
|
/**
|
|
66
70
|
* True when an injected message is real conversational input — something the
|
|
67
71
|
* agent might actually be replying to — rather than ambient machinery.
|
|
@@ -79,6 +83,39 @@ const isConversationalInjection = (metadata) => {
|
|
|
79
83
|
return false;
|
|
80
84
|
return true;
|
|
81
85
|
};
|
|
86
|
+
/**
|
|
87
|
+
* Rough token estimate for the blocks a continuation round is about to
|
|
88
|
+
* append to the live wire (issue #92 physical-window projection): the
|
|
89
|
+
* spilled tool-result strings at chars/4, a flat per-image cost for native
|
|
90
|
+
* image blocks the wire path preserves (provider vision tokens are
|
|
91
|
+
* resolution-dependent; 1600 ≈ a max-size Anthropic tile), mid-turn injected
|
|
92
|
+
* text at chars/4, and a small per-block envelope allowance. Deliberately a
|
|
93
|
+
* ceiling-ish heuristic — the cost of overestimating is one early recompile;
|
|
94
|
+
* the cost of underestimating is the 400 this projection exists to prevent.
|
|
95
|
+
*/
|
|
96
|
+
function estimateAppendedRoundTokens(toolResults, spilled, injections) {
|
|
97
|
+
let chars = 0;
|
|
98
|
+
let images = 0;
|
|
99
|
+
for (const tc of toolResults) {
|
|
100
|
+
chars += spilled.get(tc.id)?.text.length ?? 0;
|
|
101
|
+
if (Array.isArray(tc.result.data)) {
|
|
102
|
+
for (const raw of tc.result.data) {
|
|
103
|
+
const b = raw;
|
|
104
|
+
if (b && typeof b === 'object' && b.type === 'image')
|
|
105
|
+
images += 1;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const inj of injections) {
|
|
110
|
+
for (const block of inj.content) {
|
|
111
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
112
|
+
chars += block.text.length;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const blockCount = toolResults.length + injections.length;
|
|
117
|
+
return Math.ceil(chars / 4) + images * 1600 + blockCount * 20;
|
|
118
|
+
}
|
|
82
119
|
/**
|
|
83
120
|
* Explicit delivery tools whose successful use into a closed channel OPENS
|
|
84
121
|
* it (send implies engagement — see openIfClosedForSend). Bare tool names,
|
|
@@ -124,6 +161,13 @@ function proseModePrimer(mode) {
|
|
|
124
161
|
return '[prose-routing] Mode change: your plain text auto-routes to the ' +
|
|
125
162
|
'conversational locus again. `>>` destination prefixes are no longer needed.';
|
|
126
163
|
}
|
|
164
|
+
if (mode === 'hybrid') {
|
|
165
|
+
return '[prose-routing] Hybrid routing enabled: unprefixed text still lands in the ' +
|
|
166
|
+
'current conversational locus. A leading `>>>destination` envelope instead routes ' +
|
|
167
|
+
'that prose to one uniquely resolved authorized Discord or Eidoverse channel. The ' +
|
|
168
|
+
'envelope remains in your memory but recipients see only its body; delivery or failure ' +
|
|
169
|
+
'is reported back to you.';
|
|
170
|
+
}
|
|
127
171
|
// Deliberately terse: a short event-style notice is classifier-safe from
|
|
128
172
|
// the user role (ablation D, 2026-07-24), while the full grammar as a user
|
|
129
173
|
// message drew a deterministic reasoning_extraction refusal (ablation A/B).
|
|
@@ -214,7 +258,7 @@ function validateUtilityArgs(input, schema) {
|
|
|
214
258
|
}
|
|
215
259
|
import { CheckpointManager } from './mcpl/checkpoint-manager.js';
|
|
216
260
|
import { isToolAllowed } from './mcpl/tool-policy.js';
|
|
217
|
-
import { EventGate } from './gate/event-gate.js';
|
|
261
|
+
import { EventGate, formatShadowWarning } from './gate/event-gate.js';
|
|
218
262
|
import { UsageTracker } from './usage/usage-tracker.js';
|
|
219
263
|
import { formatZonedDateTime, resolveTimeZone } from './timezone.js';
|
|
220
264
|
import { DEFAULT_DISCORD_AWARENESS_EMOJI, DiscordAwarenessOutbox, defaultDiscordAwarenessOutboxPath, extractDiscordAwarenessRefs, } from './recovery/discord-awareness-outbox.js';
|
|
@@ -369,8 +413,45 @@ export class AgentFramework {
|
|
|
369
413
|
* plain prose lands (2026-07-21 Cairn lounge misroute). Lives here — not in
|
|
370
414
|
* driveStream locals — so the pin survives a context-budget stream restart,
|
|
371
415
|
* which continues the same logical turn in a fresh driveStream. Cleared or
|
|
372
|
-
* re-set at the start of every non-restart turn.
|
|
416
|
+
* re-set at the start of every non-restart turn. Since 2026-07-31 the pin
|
|
417
|
+
* is no longer fully frozen: an ADDRESSED conversational injection — or a
|
|
418
|
+
* conversational follow-up in a channel the agent itself explicitly sent
|
|
419
|
+
* into this turn (turnEngagedChannels) — re-pins it at the boundary (see
|
|
420
|
+
* the addressed re-pin block in the tool-result handler). Ambient chatter
|
|
421
|
+
* elsewhere, reactions, and system markers still cannot move it. */
|
|
373
422
|
turnLocusPins = new Map();
|
|
423
|
+
/** Channels the agent EXPLICITLY sent into during the CURRENT turn
|
|
424
|
+
* (SEND_ENGAGEMENT_TOOLS, successful calls), per agent. Second re-pin
|
|
425
|
+
* signal: a conversational reply in a channel the agent just engaged is
|
|
426
|
+
* addressed by context even without a mention (2026-07-31 n=7: q's
|
|
427
|
+
* #portables follow-up — no @mention — was answered in trailing prose
|
|
428
|
+
* that followed the stale pin into repligate's DM). No author-kind
|
|
429
|
+
* filter: agent-residents are full participants, and the bot flag
|
|
430
|
+
* tracks nothing that matters (antra). Reactions/system markers are
|
|
431
|
+
* excluded by isConversationalInjection; the engaged-this-turn scope
|
|
432
|
+
* keeps unrelated channels from moving the pin. Cleared at every fresh
|
|
433
|
+
* turn's start; budget restarts keep it. */
|
|
434
|
+
turnEngagedChannels = new Map();
|
|
435
|
+
/** Channels this turn's PLAIN PROSE was actually delivered to, in delivery
|
|
436
|
+
* order (deduped at render). Feeds the `[delivered]` receipt appended at
|
|
437
|
+
* logical turn end: explicit sends receipt themselves via their
|
|
438
|
+
* tool_result, but auto-routed prose previously vanished into the router
|
|
439
|
+
* with no in-window record — the agent could never see where its own
|
|
440
|
+
* words landed (2026-07-31 misroute series; antra: minimal receipts).
|
|
441
|
+
* Cleared at every fresh turn's start; budget restarts keep it (same
|
|
442
|
+
* logical turn, receipt covers the whole turn). */
|
|
443
|
+
turnProseDeliveries = new Map();
|
|
444
|
+
/** Count of plain-prose segments SUPPRESSED this turn by sticky
|
|
445
|
+
* explicit-send silencing. The suppression rule itself is untouched
|
|
446
|
+
* (antra, 2026-07-31: visibility is enough) — but it must never be a
|
|
447
|
+
* silent black hole: Mythos multiplexed two threads in one round (prose
|
|
448
|
+
* for the lane + explicit send for another channel, textbook per the
|
|
449
|
+
* routing doc), the silencing ate the prose reply, and his own record
|
|
450
|
+
* ("scene delivered to #mythos via plain speech — routing confirmed")
|
|
451
|
+
* believed it delivered. The receipt now reports suppression, so the
|
|
452
|
+
* author sees the segment's fate one turn later. Cleared each fresh
|
|
453
|
+
* turn; budget restarts keep it. */
|
|
454
|
+
turnProseSuppressed = new Map();
|
|
374
455
|
/** A tool boundary injected fresh CONVERSATIONAL input (a real message —
|
|
375
456
|
* not a reaction or a system marker) into the live stream. Tells
|
|
376
457
|
* driveStream to clear sticky explicit-send suppression before handling
|
|
@@ -387,6 +468,8 @@ export class AgentFramework {
|
|
|
387
468
|
/** Sticky per-TURN delivery target set by the turn's first `>>` prefix.
|
|
388
469
|
* Cleared at every non-restart turn start; survives context restarts. */
|
|
389
470
|
proseTargetPins = new Map();
|
|
471
|
+
/** Hybrid router is fail-closed after a malformed/unresolved envelope until a new valid target. */
|
|
472
|
+
proseHybridSuppressed = new Set();
|
|
390
473
|
/** Agents whose current turn requested `!` continuation — re-woken when the
|
|
391
474
|
* turn completes instead of pausing until the next external event. */
|
|
392
475
|
proseContinuations = new Set();
|
|
@@ -479,7 +562,6 @@ export class AgentFramework {
|
|
|
479
562
|
// MCPL subsystems (null when no mcplServers configured)
|
|
480
563
|
mcplServerRegistry = null;
|
|
481
564
|
featureSetManager = null;
|
|
482
|
-
scopeManager = null;
|
|
483
565
|
hookOrchestrator = null;
|
|
484
566
|
pushHandler = null;
|
|
485
567
|
inferenceRouter = null;
|
|
@@ -516,11 +598,17 @@ export class AgentFramework {
|
|
|
516
598
|
* provenance envelope + payload and requests inference. Keyed by script id. */
|
|
517
599
|
backgroundScripts = new Map();
|
|
518
600
|
backgroundScriptCounter = 0;
|
|
519
|
-
/**
|
|
520
|
-
*
|
|
521
|
-
*
|
|
601
|
+
/** Resident-set per-agent tool-result inline cap (chars), via
|
|
602
|
+
* agent_settings `tool_result_inline_max_chars`. DURABLE: persisted in
|
|
603
|
+
* framework state like the core runtime settings and restored at create
|
|
604
|
+
* (antra + Sol, 08-06); reset clears it back to the residence default. */
|
|
522
605
|
toolResultInlineMaxCharsOverride = new Map();
|
|
606
|
+
/** Durable residence-configured inline cap from
|
|
607
|
+
* FrameworkConfig.toolResultInlineMaxChars; null → house default. */
|
|
608
|
+
toolResultInlineMaxCharsConfig = null;
|
|
523
609
|
mcplTools = [];
|
|
610
|
+
/** Namespaced tool name → stateful feature-set attribution from tools/list. */
|
|
611
|
+
mcplToolFeatureSets = new Map();
|
|
524
612
|
mcplToolRefreshInFlight = false;
|
|
525
613
|
mcplToolRefreshPending = false;
|
|
526
614
|
/** Maps tool prefix → serverId for dispatch routing. */
|
|
@@ -745,6 +833,18 @@ export class AgentFramework {
|
|
|
745
833
|
// Client-side programmatic tool calling (code_execution). Config is only
|
|
746
834
|
// retained when enabled — everything downstream gates on the field.
|
|
747
835
|
framework.codeExecutionConfig = config.codeExecution?.enabled ? config.codeExecution : null;
|
|
836
|
+
if (config.toolResultInlineMaxChars !== undefined) {
|
|
837
|
+
const cap = config.toolResultInlineMaxChars;
|
|
838
|
+
if (!Number.isFinite(cap) || cap < 1000) {
|
|
839
|
+
throw new Error('FrameworkConfig.toolResultInlineMaxChars must be a number >= 1000');
|
|
840
|
+
}
|
|
841
|
+
framework.toolResultInlineMaxCharsConfig = Math.floor(cap);
|
|
842
|
+
}
|
|
843
|
+
// Restore resident-set inline caps (agent_settings) — durable like the
|
|
844
|
+
// core runtime settings, same framework/state slot (antra + Sol, 08-06).
|
|
845
|
+
for (const [agentName, cap] of Object.entries(framework.readPersistedToolResultInlineCaps())) {
|
|
846
|
+
framework.toolResultInlineMaxCharsOverride.set(agentName, cap);
|
|
847
|
+
}
|
|
748
848
|
// Initialize MCPL subsystems if configured
|
|
749
849
|
if (config.mcplServers && config.mcplServers.length > 0) {
|
|
750
850
|
// Validate tool prefixes: no collisions with module names or between servers
|
|
@@ -1138,10 +1238,11 @@ export class AgentFramework {
|
|
|
1138
1238
|
collectAgentSettingsExtensions() {
|
|
1139
1239
|
const result = new Map();
|
|
1140
1240
|
const taken = new Set(AgentFramework.AGENT_SETTINGS_CORE_KEYS);
|
|
1141
|
-
// Framework-owned extension: the tool-result inline cap
|
|
1142
|
-
// through the same extension surface modules use so
|
|
1143
|
-
// work with zero extra plumbing.
|
|
1144
|
-
//
|
|
1241
|
+
// Framework-owned extension: the resident's tool-result inline cap.
|
|
1242
|
+
// Registered through the same extension surface modules use so
|
|
1243
|
+
// get/update/reset all work with zero extra plumbing. DURABLE: persisted
|
|
1244
|
+
// in framework state and restored at create, like the core runtime
|
|
1245
|
+
// settings (antra + Sol, 08-06); reset returns to the residence default.
|
|
1145
1246
|
{
|
|
1146
1247
|
const ext = this.spillSettingsExtension();
|
|
1147
1248
|
ext.keys.forEach((k) => taken.add(k));
|
|
@@ -1167,28 +1268,40 @@ export class AgentFramework {
|
|
|
1167
1268
|
properties: {
|
|
1168
1269
|
tool_result_inline_max_chars: {
|
|
1169
1270
|
type: 'number',
|
|
1170
|
-
description: 'Inline size cap (chars) for tool results and
|
|
1171
|
-
'Content over the cap is written to a workspace
|
|
1172
|
-
'replaced by a truncated preview + file reference.
|
|
1173
|
-
'
|
|
1174
|
-
'
|
|
1271
|
+
description: 'Inline size cap (chars) for tool results (successes and errors) and ' +
|
|
1272
|
+
'background-script wake payloads. Content over the cap is written to a workspace ' +
|
|
1273
|
+
'file under tool-results/ and replaced by a truncated preview + file reference. ' +
|
|
1274
|
+
'This is YOUR durable setting: it persists across restarts, like your other ' +
|
|
1275
|
+
'agent_settings. Update it when you want a different inline size; reset restores ' +
|
|
1276
|
+
'the residence default. The effective cap is min(your value, your strategy\'s ' +
|
|
1277
|
+
'per-message bound) — the bound is a safety ceiling, and the full content is ' +
|
|
1278
|
+
'always in the spill file. get reports desired/_effective/_source/_clamped_by.',
|
|
1175
1279
|
},
|
|
1176
1280
|
},
|
|
1177
1281
|
keys: ['tool_result_inline_max_chars'],
|
|
1178
|
-
get: (agentName) =>
|
|
1179
|
-
|
|
1180
|
-
|
|
1282
|
+
get: (agentName) => {
|
|
1283
|
+
const agent = this.agents.get(agentName);
|
|
1284
|
+
const resolved = agent ? this.resolveToolResultInlineCap(agent) : null;
|
|
1285
|
+
return {
|
|
1286
|
+
tool_result_inline_max_chars: this.toolResultInlineMaxCharsOverride.get(agentName) ?? null,
|
|
1287
|
+
tool_result_inline_max_chars_effective: resolved?.cap ?? null,
|
|
1288
|
+
tool_result_inline_max_chars_source: resolved?.source ?? null,
|
|
1289
|
+
tool_result_inline_max_chars_clamped_by: resolved?.clampedBy ?? null,
|
|
1290
|
+
};
|
|
1291
|
+
},
|
|
1181
1292
|
update: (agentName, patch) => {
|
|
1182
1293
|
const n = Number(patch.tool_result_inline_max_chars);
|
|
1183
1294
|
if (!Number.isFinite(n) || n < 1000) {
|
|
1184
1295
|
throw new Error('tool_result_inline_max_chars must be a number >= 1000');
|
|
1185
1296
|
}
|
|
1186
1297
|
this.toolResultInlineMaxCharsOverride.set(agentName, Math.floor(n));
|
|
1298
|
+
this.persistToolResultInlineCap(agentName, Math.floor(n));
|
|
1187
1299
|
return { tool_result_inline_max_chars: Math.floor(n) };
|
|
1188
1300
|
},
|
|
1189
1301
|
reset: (agentName, keys) => {
|
|
1190
1302
|
if (!keys || keys.includes('tool_result_inline_max_chars')) {
|
|
1191
1303
|
this.toolResultInlineMaxCharsOverride.delete(agentName);
|
|
1304
|
+
this.persistToolResultInlineCap(agentName, null);
|
|
1192
1305
|
}
|
|
1193
1306
|
return { tool_result_inline_max_chars: null };
|
|
1194
1307
|
},
|
|
@@ -1468,8 +1581,10 @@ export class AgentFramework {
|
|
|
1468
1581
|
/**
|
|
1469
1582
|
* Add or replace (upsert) a gate policy at runtime. Validated and persisted
|
|
1470
1583
|
* to gate.json; hot-applied in memory. Throws if no gate is configured or the
|
|
1471
|
-
* policy is invalid.
|
|
1472
|
-
*
|
|
1584
|
+
* policy is invalid. Placement: `'prepend'` puts a rule ahead of EVERYTHING
|
|
1585
|
+
* (including addressed-message wake rules — see EventGate.lintShadows);
|
|
1586
|
+
* `{before: name}` / `{after: name}` anchor it against a specific rule,
|
|
1587
|
+
* which is usually what a sampler or debounce actually wants.
|
|
1473
1588
|
*/
|
|
1474
1589
|
addGatePolicy(rawPolicy, options) {
|
|
1475
1590
|
if (!this.eventGate) {
|
|
@@ -1544,6 +1659,44 @@ export class AgentFramework {
|
|
|
1544
1659
|
}
|
|
1545
1660
|
return overrides;
|
|
1546
1661
|
}
|
|
1662
|
+
/** Load resident-set inline caps from framework state, dropping (loudly)
|
|
1663
|
+
* anything that fails the same validation the live update enforces. */
|
|
1664
|
+
readPersistedToolResultInlineCaps() {
|
|
1665
|
+
try {
|
|
1666
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
1667
|
+
const out = {};
|
|
1668
|
+
for (const [name, value] of Object.entries(data?.toolResultInlineCaps ?? {})) {
|
|
1669
|
+
const n = Number(value);
|
|
1670
|
+
if (Number.isFinite(n) && n >= 1000) {
|
|
1671
|
+
out[name] = Math.floor(n);
|
|
1672
|
+
}
|
|
1673
|
+
else {
|
|
1674
|
+
console.error(`[agent-settings] dropping invalid persisted tool_result_inline_max_chars for '${name}': ${JSON.stringify(value)}`);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
return out;
|
|
1678
|
+
}
|
|
1679
|
+
catch {
|
|
1680
|
+
return {};
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
/** Write (cap) or clear (null) one agent's resident-set inline cap. */
|
|
1684
|
+
persistToolResultInlineCap(agentName, cap) {
|
|
1685
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
1686
|
+
const state = (data && typeof data === 'object' ? data : {});
|
|
1687
|
+
const all = {
|
|
1688
|
+
...(state.toolResultInlineCaps ?? {}),
|
|
1689
|
+
};
|
|
1690
|
+
if (cap === null)
|
|
1691
|
+
delete all[agentName];
|
|
1692
|
+
else
|
|
1693
|
+
all[agentName] = cap;
|
|
1694
|
+
if (Object.keys(all).length === 0)
|
|
1695
|
+
delete state.toolResultInlineCaps;
|
|
1696
|
+
else
|
|
1697
|
+
state.toolResultInlineCaps = all;
|
|
1698
|
+
this.store.setStateJson(FRAMEWORK_STATE_ID, state);
|
|
1699
|
+
}
|
|
1547
1700
|
persistAgentRuntimeSettings(agentName, overrides) {
|
|
1548
1701
|
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
1549
1702
|
const state = (data && typeof data === 'object' ? data : {});
|
|
@@ -1764,9 +1917,17 @@ export class AgentFramework {
|
|
|
1764
1917
|
name: 'wake_add_rule',
|
|
1765
1918
|
description: 'Add or replace a wake rule (a gate.json policy) at runtime — no need to ' +
|
|
1766
1919
|
'hand-edit the file. The rule is validated and hot-applied immediately. ' +
|
|
1767
|
-
'A rule with the same `name` replaces the existing one in place
|
|
1768
|
-
'
|
|
1769
|
-
'
|
|
1920
|
+
'A rule with the same `name` replaces the existing one in place (add a ' +
|
|
1921
|
+
'position to move it). ⚠️ ORDERING IS SEMANTICS: first match wins, and a ' +
|
|
1922
|
+
'matched rule CONSUMES the event — a broad rule placed early makes every ' +
|
|
1923
|
+
'later rule unreachable for its scope, including your addressed-message ' +
|
|
1924
|
+
'(DM/mention) wake rules. Prefer `insertBefore`/`insertAfter` to place a ' +
|
|
1925
|
+
'rule relative to a specific existing rule; use `position: "prepend"` only ' +
|
|
1926
|
+
'when the rule really must beat EVERYTHING. For "additionally wake me ' +
|
|
1927
|
+
'every Nth event / at most N per hour" governors, set `passthrough: true` ' +
|
|
1928
|
+
'so non-firing matches fall through to your normal rules instead of ' +
|
|
1929
|
+
'swallowing them. The result includes a before/after probe table and any ' +
|
|
1930
|
+
'shadow warnings — READ THEM before ending the turn. Two common shapes:\n' +
|
|
1770
1931
|
'• Watch a FILE/workspace path: match on `mount` + `pathGlob`, e.g. ' +
|
|
1771
1932
|
'`{ name: "watch-notes", match: { scope: ["workspace:modified"], mount: "project", pathGlob: "notes/*.md" }, behavior: "always" }`.\n' +
|
|
1772
1933
|
'• Watch a CHANNEL: match on `source` + `channel` (and/or `tagsAny: ["chat:ambient"]`), ' +
|
|
@@ -1833,7 +1994,27 @@ export class AgentFramework {
|
|
|
1833
1994
|
position: {
|
|
1834
1995
|
type: 'string',
|
|
1835
1996
|
enum: ['append', 'prepend'],
|
|
1836
|
-
description: '
|
|
1997
|
+
description: 'Coarse placement. "append" (default) adds at the end; "prepend" puts the rule ' +
|
|
1998
|
+
'ahead of EVERYTHING — including addressed-message wake rules — so prefer ' +
|
|
1999
|
+
'insertBefore/insertAfter unless the rule really must beat every other rule. ' +
|
|
2000
|
+
'When replacing an existing name, giving any placement MOVES the rule there; ' +
|
|
2001
|
+
'omit all placement fields to update it in place.',
|
|
2002
|
+
},
|
|
2003
|
+
insertBefore: {
|
|
2004
|
+
type: 'string',
|
|
2005
|
+
description: 'Insert the rule immediately BEFORE the named existing rule (error if absent). ' +
|
|
2006
|
+
'The safe way to order against a specific catch-all, e.g. insertBefore: "discord-ambient".',
|
|
2007
|
+
},
|
|
2008
|
+
insertAfter: {
|
|
2009
|
+
type: 'string',
|
|
2010
|
+
description: 'Insert the rule immediately AFTER the named existing rule (error if absent).',
|
|
2011
|
+
},
|
|
2012
|
+
passthrough: {
|
|
2013
|
+
type: 'boolean',
|
|
2014
|
+
description: 'Observer semantics for rateLimit/passiveSample rules: when the rule matches but ' +
|
|
2015
|
+
'does not fire (below N, bucket empty), the event FALLS THROUGH to later rules ' +
|
|
2016
|
+
'instead of being consumed. Use for "additionally wake me every Nth event" ' +
|
|
2017
|
+
'governors so they never swallow DM/mention wakes.',
|
|
1837
2018
|
},
|
|
1838
2019
|
resets: {
|
|
1839
2020
|
type: 'array',
|
|
@@ -1982,14 +2163,6 @@ export class AgentFramework {
|
|
|
1982
2163
|
'chat:group': 'Group (multi-party) conversation',
|
|
1983
2164
|
'chat:thread': 'Occurred in a thread',
|
|
1984
2165
|
};
|
|
1985
|
-
/** Refusal category → Discord reaction emoji. Unknown categories get 🛑. */
|
|
1986
|
-
static REFUSAL_REACTIONS = {
|
|
1987
|
-
bio: '☣️',
|
|
1988
|
-
chem: '🧪',
|
|
1989
|
-
nuclear: '☢️',
|
|
1990
|
-
cyber: '💻',
|
|
1991
|
-
reasoning_extraction: '🧠',
|
|
1992
|
-
};
|
|
1993
2166
|
/**
|
|
1994
2167
|
* Mark an inference refusal visibly: react on the message that holds the
|
|
1995
2168
|
* conversational locus (the most recent incoming channel message) with a
|
|
@@ -2008,7 +2181,7 @@ export class AgentFramework {
|
|
|
2008
2181
|
if (parts[0] !== 'discord')
|
|
2009
2182
|
return;
|
|
2010
2183
|
const channelId = parts[parts.length - 1];
|
|
2011
|
-
const emoji =
|
|
2184
|
+
const emoji = REFUSAL_REACTIONS[category] ?? REFUSAL_REACTION_FALLBACK;
|
|
2012
2185
|
// Resolve the MCPL server that owns the locus channel and call
|
|
2013
2186
|
// tools/call directly on its connection (bare tool name — no prefix
|
|
2014
2187
|
// games), bypassing the agent event queue so no synthetic tool-result
|
|
@@ -2204,6 +2377,38 @@ export class AgentFramework {
|
|
|
2204
2377
|
console.error('[unstick] report post failed:', err instanceof Error ? err.message : err);
|
|
2205
2378
|
}
|
|
2206
2379
|
}
|
|
2380
|
+
/**
|
|
2381
|
+
* Admin-level nudge: run an inference turn on the agent's CURRENT context
|
|
2382
|
+
* without adding any message, event, or other context mutation. The turn
|
|
2383
|
+
* compiles exactly what the agent already sees — useful after an /undo,
|
|
2384
|
+
* a /hide, or whenever an operator wants the agent to take another look
|
|
2385
|
+
* (or another swing) at where it stands, with zero context pollution.
|
|
2386
|
+
*
|
|
2387
|
+
* Queues a normal inference request (same path as gate wakes): if the
|
|
2388
|
+
* agent is mid-turn it runs when the turn settles; a pending skip_reply
|
|
2389
|
+
* self-wake is superseded like any other wake. Deliberately NOT gated on
|
|
2390
|
+
* idle — "run when you're free" is the useful semantic. No suppression
|
|
2391
|
+
* interplay: gate sleep filters events, not queued requests, so a nudge
|
|
2392
|
+
* wakes even a sleeping agent (it's an admin override, like /unstick).
|
|
2393
|
+
*/
|
|
2394
|
+
nudgeAgent(agentName, requestedBy) {
|
|
2395
|
+
const name = agentName ?? [...this.agents.keys()][0];
|
|
2396
|
+
const agent = name ? this.agents.get(name) : undefined;
|
|
2397
|
+
if (!name || !agent) {
|
|
2398
|
+
return { ok: false, error: `Unknown agent: ${String(agentName ?? '(none registered)')}` };
|
|
2399
|
+
}
|
|
2400
|
+
const agentStatus = this.activeTurnTokens.has(name) && agent.state.status === 'idle'
|
|
2401
|
+
? 'idle+turn-alive'
|
|
2402
|
+
: agent.state.status;
|
|
2403
|
+
this.pendingRequests.push({
|
|
2404
|
+
agentName: name,
|
|
2405
|
+
reason: requestedBy ? `admin-nudge (${requestedBy})` : 'admin-nudge',
|
|
2406
|
+
source: 'admin',
|
|
2407
|
+
timestamp: Date.now(),
|
|
2408
|
+
});
|
|
2409
|
+
console.error(`[nudge] agent=${name} queued by=${requestedBy ?? 'unknown'} status=${agentStatus}`);
|
|
2410
|
+
return { ok: true, agentName: name, agentStatus };
|
|
2411
|
+
}
|
|
2207
2412
|
/**
|
|
2208
2413
|
* Handle a `host/command` request from an MCPL surface server (e.g. a
|
|
2209
2414
|
* Discord slash command). Currently supports:
|
|
@@ -2212,15 +2417,27 @@ export class AgentFramework {
|
|
|
2212
2417
|
* `undoLastTurn`). The response includes the last message the agent
|
|
2213
2418
|
* would see in its context after the undo, obtained via the transparent
|
|
2214
2419
|
* `previewActivation` render (no inference, no Chronicle writes).
|
|
2420
|
+
*
|
|
2421
|
+
* nudge — run inference on the current context with NO new events
|
|
2422
|
+
* (see `nudgeAgent`).
|
|
2215
2423
|
*/
|
|
2216
2424
|
async handleHostCommand(serverId, params) {
|
|
2217
|
-
if (params.command !== 'undo' &&
|
|
2425
|
+
if (params.command !== 'undo' &&
|
|
2426
|
+
params.command !== 'hide' &&
|
|
2427
|
+
params.command !== 'unstick' &&
|
|
2428
|
+
params.command !== 'nudge') {
|
|
2218
2429
|
return { ok: false, error: `Unknown host command: ${String(params.command)}` };
|
|
2219
2430
|
}
|
|
2220
2431
|
const agentName = params.agentName ?? [...this.agents.keys()][0];
|
|
2221
2432
|
if (!agentName || !this.agents.has(agentName)) {
|
|
2222
2433
|
return { ok: false, error: `Unknown agent: ${String(agentName)}` };
|
|
2223
2434
|
}
|
|
2435
|
+
// nudge: queue an inference on the current context — no message, no
|
|
2436
|
+
// event, no context mutation (see nudgeAgent).
|
|
2437
|
+
if (params.command === 'nudge') {
|
|
2438
|
+
const r = this.nudgeAgent(agentName, params.requesterName ?? params.requesterId ?? `mcpl:${serverId}`);
|
|
2439
|
+
return { ok: r.ok, error: r.error, agentStatus: r.agentStatus };
|
|
2440
|
+
}
|
|
2224
2441
|
// unstick: force the refusal-rewind loop on demand (even if the agent's
|
|
2225
2442
|
// autoRewind toggle is off). Redacts the turn that fed the refusal and
|
|
2226
2443
|
// re-runs, up to `cap` times, until the model stops refusing. Kicks the
|
|
@@ -2993,32 +3210,16 @@ export class AgentFramework {
|
|
|
2993
3210
|
agent.addAssistantResponse(pendingBlocks);
|
|
2994
3211
|
this.pendingAssistantBlocks.delete(agent.name);
|
|
2995
3212
|
}
|
|
2996
|
-
//
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
//
|
|
3000
|
-
//
|
|
3001
|
-
//
|
|
3002
|
-
//
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
if (tc.result.isError)
|
|
3007
|
-
continue;
|
|
3008
|
-
spilled.set(tc.id, await this.spillOrTruncate(toolResultDataToHistoryString(tc.result.data, undefined), maxChars, `${dateLabel}-${tc.id}`, agent.name));
|
|
3009
|
-
}
|
|
3010
|
-
// Store tool results as a user message (tool_result blocks).
|
|
3011
|
-
// Use the history serializer so MCP image blocks become a short
|
|
3012
|
-
// `[image: type, size]` placeholder instead of megabytes of base64
|
|
3013
|
-
// that would corrupt under truncation.
|
|
3014
|
-
const toolResultContent = currentState.toolResults.map(tc => ({
|
|
3015
|
-
type: 'tool_result',
|
|
3016
|
-
toolUseId: tc.id,
|
|
3017
|
-
content: tc.result.isError
|
|
3018
|
-
? tc.result.error ?? 'Unknown error'
|
|
3019
|
-
: spilled.get(tc.id) ?? toolResultDataToHistoryString(tc.result.data, maxChars),
|
|
3020
|
-
isError: tc.result.isError,
|
|
3021
|
-
}));
|
|
3213
|
+
// Effective inline cap: agent_settings override → durable config →
|
|
3214
|
+
// house default, clamped to the strategy bound (see resolver).
|
|
3215
|
+
const maxChars = this.resolveToolResultInlineCap(agent).cap;
|
|
3216
|
+
// Oversized results — successes AND errors — spill to a workspace
|
|
3217
|
+
// file (truncated preview + file reference) instead of being
|
|
3218
|
+
// blind-truncated. Computed ONCE per call and reused for both the
|
|
3219
|
+
// history copy and the live wire copy below — the two must stay
|
|
3220
|
+
// byte-matched (the window stores what the membrane sends;
|
|
3221
|
+
// divergence breaks the compile prefix).
|
|
3222
|
+
const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(currentState.toolResults, maxChars);
|
|
3022
3223
|
agent.getContextManager().addMessage('user', toolResultContent);
|
|
3023
3224
|
// Flush any messages that were deferred while this turn was in
|
|
3024
3225
|
// flight. Route to the PRIMARY agent — deferred messages are
|
|
@@ -3083,6 +3284,29 @@ export class AgentFramework {
|
|
|
3083
3284
|
const overBudget = currentState.stream
|
|
3084
3285
|
&& agent.lastStreamInputTokens > 0
|
|
3085
3286
|
&& agent.lastStreamInputTokens > agent.maxStreamTokens;
|
|
3287
|
+
// Physical-window projection (issue #92): a turn's continuation
|
|
3288
|
+
// rounds append to the compiled request without recompiling, so a
|
|
3289
|
+
// turn that starts near the compile target can walk past the
|
|
3290
|
+
// provider's HARD cap mid-turn and take a wire 400 — the compile
|
|
3291
|
+
// was legal, the growth wasn't. Project the next round's real size
|
|
3292
|
+
// (cache-inclusive input of the prior round + the blocks about to
|
|
3293
|
+
// be appended + reserve for the response) and, when it would cross
|
|
3294
|
+
// the physical window, break the stream through the same
|
|
3295
|
+
// budget-restart path — a fresh compile folds the history back
|
|
3296
|
+
// under budget instead of dispatching a doomed request.
|
|
3297
|
+
// Projection terms: prior round's real input + prior round's
|
|
3298
|
+
// OUTPUT (the just-generated thinking/text/tool_use joins the next
|
|
3299
|
+
// round's input — omitting it is a systematic underestimate) +
|
|
3300
|
+
// blocks about to be appended + reserve for the next response.
|
|
3301
|
+
const projectedRealTokens = currentState.stream
|
|
3302
|
+
&& agent.physicalWindowTokens !== undefined
|
|
3303
|
+
&& agent.lastStreamRealInputTokens > 0
|
|
3304
|
+
? agent.lastStreamRealInputTokens
|
|
3305
|
+
+ agent.lastStreamOutputTokens
|
|
3306
|
+
+ estimateAppendedRoundTokens(currentState.toolResults, spilled, midTurnInjections)
|
|
3307
|
+
+ agent.maxTokens
|
|
3308
|
+
: 0;
|
|
3309
|
+
const overPhysical = projectedRealTokens > (agent.physicalWindowTokens ?? Infinity);
|
|
3086
3310
|
// Addressed re-pin (2026-07-31 Mythos misroutes, antra-ratified):
|
|
3087
3311
|
// when someone ADDRESSES the agent mid-turn from another channel
|
|
3088
3312
|
// (mention / reply / DM — the same chat:addressed signal the
|
|
@@ -3109,13 +3333,34 @@ export class AgentFramework {
|
|
|
3109
3333
|
// diffs against what the agent was actually last told.
|
|
3110
3334
|
if (agent.proseRouting !== 'explicit' &&
|
|
3111
3335
|
!shouldEndTurn && !overBudget && currentState.stream) {
|
|
3112
|
-
|
|
3336
|
+
// Two signals qualify an injection to move the pin (n=6 + n=7):
|
|
3337
|
+
// - chat:addressed — someone explicitly spoke TO the agent
|
|
3338
|
+
// (mention / reply / DM);
|
|
3339
|
+
// - a conversational follow-up in a channel the agent itself
|
|
3340
|
+
// explicitly sent into THIS turn (turnEngagedChannels) —
|
|
3341
|
+
// conversation the agent just engaged continues without
|
|
3342
|
+
// re-mentioning it (2026-07-31: q's #portables reply, no @,
|
|
3343
|
+
// answered in prose that followed the stale pin into
|
|
3344
|
+
// repligate's DM). Deliberately NO author-kind filter: this
|
|
3345
|
+
// fleet's participants include agent-residents whose speech
|
|
3346
|
+
// is as conversational as anyone's — the Discord bot flag
|
|
3347
|
+
// tracks nothing that matters here (antra, 2026-07-31).
|
|
3348
|
+
// Reactions and system markers are already excluded by
|
|
3349
|
+
// isConversationalInjection, and the engaged-this-turn scope
|
|
3350
|
+
// keeps unrelated channels from moving the pin.
|
|
3351
|
+
const engaged = this.turnEngagedChannels.get(agent.name);
|
|
3352
|
+
const lastQualifying = [...midTurnInjections].reverse().find((inj) => {
|
|
3113
3353
|
const m = inj.metadata;
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3354
|
+
if (!isConversationalInjection(inj.metadata))
|
|
3355
|
+
return false;
|
|
3356
|
+
if (typeof m?.channelId !== 'string')
|
|
3357
|
+
return false;
|
|
3358
|
+
const tags = m.tags;
|
|
3359
|
+
if (isAddressedMessage(tags, m))
|
|
3360
|
+
return true;
|
|
3361
|
+
return engaged?.has(m.channelId) === true;
|
|
3117
3362
|
});
|
|
3118
|
-
const newLocus =
|
|
3363
|
+
const newLocus = lastQualifying?.metadata
|
|
3119
3364
|
?.channelId;
|
|
3120
3365
|
if (newLocus && this.turnLocusPins.get(agent.name) !== newLocus) {
|
|
3121
3366
|
const prevPin = this.turnLocusPins.get(agent.name) ?? null;
|
|
@@ -3127,7 +3372,7 @@ export class AgentFramework {
|
|
|
3127
3372
|
: newLocus;
|
|
3128
3373
|
const noticeContent = [{
|
|
3129
3374
|
type: 'text',
|
|
3130
|
-
text: `[routing]
|
|
3375
|
+
text: `[routing] The conversation moved to ${shown} — your plain ` +
|
|
3131
3376
|
'speech now lands there for the rest of this turn. Other ' +
|
|
3132
3377
|
'channels need an explicit send tool.',
|
|
3133
3378
|
}];
|
|
@@ -3158,7 +3403,7 @@ export class AgentFramework {
|
|
|
3158
3403
|
this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' });
|
|
3159
3404
|
this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
|
|
3160
3405
|
}
|
|
3161
|
-
else if (overBudget) {
|
|
3406
|
+
else if (overBudget || overPhysical) {
|
|
3162
3407
|
// Context budget exceeded: break the stream, let compile() compress.
|
|
3163
3408
|
// Mark the cancel as framework-initiated BEFORE cancelling: the
|
|
3164
3409
|
// membrane delivers `aborted` before the restart bumps streamId,
|
|
@@ -3183,9 +3428,12 @@ export class AgentFramework {
|
|
|
3183
3428
|
this.emitTrace({
|
|
3184
3429
|
type: 'inference:stream_restarted',
|
|
3185
3430
|
agentName: agent.name,
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3431
|
+
// 'physical_window' only when the budget check alone would
|
|
3432
|
+
// have let the doomed request through — overBudget keeps its
|
|
3433
|
+
// established label for existing consumers.
|
|
3434
|
+
reason: overBudget ? 'context_budget' : 'physical_window',
|
|
3435
|
+
inputTokens: overBudget ? agent.lastStreamInputTokens : projectedRealTokens,
|
|
3436
|
+
budget: overBudget ? agent.maxStreamTokens : (agent.physicalWindowTokens ?? 0),
|
|
3189
3437
|
});
|
|
3190
3438
|
this.pendingRequests.push({
|
|
3191
3439
|
agentName: agent.name,
|
|
@@ -3353,6 +3601,12 @@ export class AgentFramework {
|
|
|
3353
3601
|
? event.metadata.guildName
|
|
3354
3602
|
: undefined,
|
|
3355
3603
|
tags: event.tags,
|
|
3604
|
+
missedMessages: typeof event.metadata?.missedMessages === 'number'
|
|
3605
|
+
? event.metadata.missedMessages
|
|
3606
|
+
: undefined,
|
|
3607
|
+
missedCharacters: typeof event.metadata?.missedCharacters === 'number'
|
|
3608
|
+
? event.metadata.missedCharacters
|
|
3609
|
+
: undefined,
|
|
3356
3610
|
});
|
|
3357
3611
|
if (invitation) {
|
|
3358
3612
|
incomingContent.push(invitation.block);
|
|
@@ -3623,6 +3877,17 @@ export class AgentFramework {
|
|
|
3623
3877
|
* this should see WHO addressed it WHERE, not a wall of snowflake ids. Raw
|
|
3624
3878
|
* ids remain only inside the tool-argument instructions, where they are the
|
|
3625
3879
|
* literal values to pass.
|
|
3880
|
+
*
|
|
3881
|
+
* 2026-08-05 accuracy pass: the old "you won't receive more from it unless
|
|
3882
|
+
* you join" was wrong in both directions — direct addresses (mentions,
|
|
3883
|
+
* replies to the agent) DO still arrive, and nothing warned that answering
|
|
3884
|
+
* via option 1 leaves the human's natural un-@'d follow-up invisible (Sol
|
|
3885
|
+
* answered four #architecture mentions over four days while the follow-ups
|
|
3886
|
+
* to her own replies were silently dropped). The wording now states the
|
|
3887
|
+
* actual delivery model, and option 1 carries the consequence inline. When
|
|
3888
|
+
* the surface tracks a missed-ambient tally (discord-mcpl sends
|
|
3889
|
+
* `missedMessages`/`missedCharacters` in push origin), the invitation shows
|
|
3890
|
+
* the count — the cost of staying out, as a number, at decision time.
|
|
3626
3891
|
*/
|
|
3627
3892
|
buildClosedChannelInvitation(opts) {
|
|
3628
3893
|
if (!this.channelRegistry)
|
|
@@ -3636,12 +3901,21 @@ export class AgentFramework {
|
|
|
3636
3901
|
const label = opts.channelLabel ?? descriptor?.label;
|
|
3637
3902
|
const channelLabel = label ? `#${label}` : `"${opts.channelId}"`;
|
|
3638
3903
|
const place = opts.guildName ? `${channelLabel} in "${opts.guildName}"` : channelLabel;
|
|
3904
|
+
const missedCount = opts.missedMessages ?? 0;
|
|
3905
|
+
const missedNote = missedCount > 0
|
|
3906
|
+
? `While closed, ${missedCount} message${missedCount === 1 ? '' : 's'}` +
|
|
3907
|
+
(opts.missedCharacters ? ` (~${opts.missedCharacters} chars)` : '') +
|
|
3908
|
+
` have passed there without you. `
|
|
3909
|
+
: '';
|
|
3639
3910
|
const block = {
|
|
3640
3911
|
type: 'text',
|
|
3641
3912
|
text: `\n[Channel invitation] ${opts.authorName} addressed you in ${place} — ` +
|
|
3642
|
-
`a channel you haven't joined.
|
|
3913
|
+
`a channel you haven't joined. Only messages that address you directly (an @-mention or ` +
|
|
3914
|
+
`a reply to one of your messages) reach you from it; the rest of its traffic is invisible to you. ` +
|
|
3915
|
+
missedNote +
|
|
3643
3916
|
`Your options:\n` +
|
|
3644
|
-
`1. Reply without joining — write your reply this turn prefixed with ">>${channelLabel}"; it will be delivered there
|
|
3917
|
+
`1. Reply without joining — write your reply this turn prefixed with ">>${channelLabel}"; it will be delivered there. ` +
|
|
3918
|
+
`Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n` +
|
|
3645
3919
|
`2. Join the channel — call channel_open with channelId "${opts.channelId}" and serverId "${opts.serverId}"` +
|
|
3646
3920
|
(maxBackscroll > 0
|
|
3647
3921
|
? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${opts.messageId}".\n`
|
|
@@ -3696,6 +3970,8 @@ export class AgentFramework {
|
|
|
3696
3970
|
authorName: typeof origin.authorName === 'string' && origin.authorName ? origin.authorName : 'Someone',
|
|
3697
3971
|
guildName: typeof origin.guildName === 'string' && origin.guildName ? origin.guildName : undefined,
|
|
3698
3972
|
tags: event.tags,
|
|
3973
|
+
missedMessages: typeof origin.missedMessages === 'number' ? origin.missedMessages : undefined,
|
|
3974
|
+
missedCharacters: typeof origin.missedCharacters === 'number' ? origin.missedCharacters : undefined,
|
|
3699
3975
|
});
|
|
3700
3976
|
if (invitation) {
|
|
3701
3977
|
content.push(invitation.block);
|
|
@@ -3893,6 +4169,68 @@ export class AgentFramework {
|
|
|
3893
4169
|
});
|
|
3894
4170
|
}
|
|
3895
4171
|
}
|
|
4172
|
+
/** Record prose segments suppressed by explicit-send silencing. */
|
|
4173
|
+
recordProseSuppression(agentName, count) {
|
|
4174
|
+
if (count <= 0)
|
|
4175
|
+
return;
|
|
4176
|
+
this.turnProseSuppressed.set(agentName, (this.turnProseSuppressed.get(agentName) ?? 0) + count);
|
|
4177
|
+
}
|
|
4178
|
+
/** Record a successful plain-prose delivery for this turn's receipt. */
|
|
4179
|
+
recordProseDelivery(agentName, outcome) {
|
|
4180
|
+
if (!outcome?.delivered)
|
|
4181
|
+
return;
|
|
4182
|
+
let list = this.turnProseDeliveries.get(agentName);
|
|
4183
|
+
if (!list) {
|
|
4184
|
+
list = [];
|
|
4185
|
+
this.turnProseDeliveries.set(agentName, list);
|
|
4186
|
+
}
|
|
4187
|
+
list.push(outcome.channelId);
|
|
4188
|
+
}
|
|
4189
|
+
/**
|
|
4190
|
+
* Append the turn's `[delivered]` receipt: one compact system message
|
|
4191
|
+
* naming where this turn's plain prose actually landed (channels in
|
|
4192
|
+
* delivery order, deduped). Explicit sends receipt themselves via their
|
|
4193
|
+
* tool_result; auto-routed prose previously left no in-window trace, so
|
|
4194
|
+
* the agent could never see where its own words went — misroutes were
|
|
4195
|
+
* invisible to it until a human reported them (2026-07-31 series).
|
|
4196
|
+
* Called ONLY at logical turn end (stream 'complete', or the endTurn
|
|
4197
|
+
* cancel path after the speech chain settles) — never mid-stream, where a
|
|
4198
|
+
* window-only insert would diverge from the wire. No-op when the turn
|
|
4199
|
+
* delivered nothing. Failures are already marked separately
|
|
4200
|
+
* ([discord-send-failed]); this is the success half.
|
|
4201
|
+
*/
|
|
4202
|
+
appendProseDeliveryReceipt(agent) {
|
|
4203
|
+
const list = this.turnProseDeliveries.get(agent.name);
|
|
4204
|
+
const suppressed = this.turnProseSuppressed.get(agent.name) ?? 0;
|
|
4205
|
+
if ((!list || list.length === 0) && suppressed === 0)
|
|
4206
|
+
return;
|
|
4207
|
+
this.turnProseDeliveries.delete(agent.name);
|
|
4208
|
+
this.turnProseSuppressed.delete(agent.name);
|
|
4209
|
+
const seen = new Set();
|
|
4210
|
+
const shown = [];
|
|
4211
|
+
for (const id of list ?? []) {
|
|
4212
|
+
if (seen.has(id))
|
|
4213
|
+
continue;
|
|
4214
|
+
seen.add(id);
|
|
4215
|
+
const label = this.channelRegistry?.getDescriptor(id)?.label;
|
|
4216
|
+
shown.push(label && label !== id
|
|
4217
|
+
? `${label.startsWith('#') ? label : `#${label}`} (${id})`
|
|
4218
|
+
: id);
|
|
4219
|
+
}
|
|
4220
|
+
const suppressedNote = suppressed > 0
|
|
4221
|
+
? `${suppressed} plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)`
|
|
4222
|
+
: '';
|
|
4223
|
+
const text = shown.length > 0
|
|
4224
|
+
? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}`
|
|
4225
|
+
: `[delivered] nothing — ${suppressedNote}`;
|
|
4226
|
+
try {
|
|
4227
|
+
const mid = agent.getContextManager().addMessage('user', [{ type: 'text', text }], { system: true, kind: 'delivery-receipt' });
|
|
4228
|
+
this.emitTrace({ type: 'message:added', messageId: mid, source: 'delivery-receipt' });
|
|
4229
|
+
}
|
|
4230
|
+
catch (err) {
|
|
4231
|
+
console.error('delivery receipt append failed:', err);
|
|
4232
|
+
}
|
|
4233
|
+
}
|
|
3896
4234
|
/**
|
|
3897
4235
|
* Announce-on-change locus notice (turn-frozen routing). Appends a durable
|
|
3898
4236
|
* `[routing]` window message when the turn's effective outbound locus
|
|
@@ -4010,6 +4348,98 @@ export class AgentFramework {
|
|
|
4010
4348
|
await this.deliverProseEnvelope(agent, envelope.replace(/^\s+(?=>>)/, ''));
|
|
4011
4349
|
}
|
|
4012
4350
|
}
|
|
4351
|
+
/** Locus-preserving explicit publication. Source remains byte-identical in Chronicle. */
|
|
4352
|
+
async deliverHybridProse(agent, rawText, locus, allowLocus) {
|
|
4353
|
+
const lines = rawText.split('\n');
|
|
4354
|
+
const envelopes = [];
|
|
4355
|
+
let current = [];
|
|
4356
|
+
for (const line of lines) {
|
|
4357
|
+
if (line.trimStart().startsWith('>>>') && current.length > 0) {
|
|
4358
|
+
envelopes.push(current.join('\n'));
|
|
4359
|
+
current = [];
|
|
4360
|
+
}
|
|
4361
|
+
current.push(line);
|
|
4362
|
+
}
|
|
4363
|
+
if (current.length > 0)
|
|
4364
|
+
envelopes.push(current.join('\n'));
|
|
4365
|
+
for (const envelope of envelopes) {
|
|
4366
|
+
if (!envelope.trim())
|
|
4367
|
+
continue;
|
|
4368
|
+
const normalized = envelope.replace(/^\s+(?=>>>)/, '');
|
|
4369
|
+
const attempted = normalized.startsWith('>>>');
|
|
4370
|
+
const parsed = parseHybridProsePrefix(normalized);
|
|
4371
|
+
if (parsed.continueTurn)
|
|
4372
|
+
this.proseContinuations.add(agent.name);
|
|
4373
|
+
if (parsed.kind === 'private') {
|
|
4374
|
+
this.proseTargetPins.delete(agent.name);
|
|
4375
|
+
this.proseHybridSuppressed.add(agent.name);
|
|
4376
|
+
console.error(`[prose] ${agent.name}: >>>skip_reply — ${parsed.body.length} chars kept in context (not sent)`);
|
|
4377
|
+
continue;
|
|
4378
|
+
}
|
|
4379
|
+
if (parsed.kind === 'target') {
|
|
4380
|
+
const resolved = this.channelRegistry.resolveProseTarget(parsed.target);
|
|
4381
|
+
if ('error' in resolved) {
|
|
4382
|
+
this.proseTargetPins.delete(agent.name);
|
|
4383
|
+
this.proseHybridSuppressed.add(agent.name);
|
|
4384
|
+
this.bounceProse(agent, parsed.body, resolved.error, resolved.candidates, '>>>');
|
|
4385
|
+
continue;
|
|
4386
|
+
}
|
|
4387
|
+
let body = parsed.body;
|
|
4388
|
+
const usedClipboard = body.includes('{{unsent}}');
|
|
4389
|
+
if (usedClipboard)
|
|
4390
|
+
body = body.replaceAll('{{unsent}}', this.proseClipboards.get(agent.name) ?? '');
|
|
4391
|
+
this.proseTargetPins.set(agent.name, resolved.channelId);
|
|
4392
|
+
this.proseHybridSuppressed.delete(agent.name);
|
|
4393
|
+
if (!body.trim()) {
|
|
4394
|
+
console.error(`[prose] ${agent.name}: >>>${parsed.target} established target; empty body — nothing sent yet`);
|
|
4395
|
+
continue;
|
|
4396
|
+
}
|
|
4397
|
+
try {
|
|
4398
|
+
const outcome = await this.channelRegistry.routeSpeech(agent.name, body, resolved.channelId);
|
|
4399
|
+
this.recordProseDelivery(agent.name, outcome);
|
|
4400
|
+
if (outcome?.delivered) {
|
|
4401
|
+
this.proseBounceStreaks.delete(agent.name);
|
|
4402
|
+
if (usedClipboard)
|
|
4403
|
+
this.proseClipboards.delete(agent.name);
|
|
4404
|
+
}
|
|
4405
|
+
else {
|
|
4406
|
+
this.proseTargetPins.delete(agent.name);
|
|
4407
|
+
this.proseHybridSuppressed.add(agent.name);
|
|
4408
|
+
this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} was not confirmed`, undefined, '>>>');
|
|
4409
|
+
}
|
|
4410
|
+
}
|
|
4411
|
+
catch (err) {
|
|
4412
|
+
this.proseTargetPins.delete(agent.name);
|
|
4413
|
+
this.proseHybridSuppressed.add(agent.name);
|
|
4414
|
+
this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} failed: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>');
|
|
4415
|
+
}
|
|
4416
|
+
continue;
|
|
4417
|
+
}
|
|
4418
|
+
if (attempted) {
|
|
4419
|
+
this.proseTargetPins.delete(agent.name);
|
|
4420
|
+
this.proseHybridSuppressed.add(agent.name);
|
|
4421
|
+
this.bounceProse(agent, normalized, 'malformed >>> routing envelope: destination is missing', undefined, '>>>');
|
|
4422
|
+
continue;
|
|
4423
|
+
}
|
|
4424
|
+
if (this.proseHybridSuppressed.has(agent.name)) {
|
|
4425
|
+
this.recordProseSuppression(agent.name, 1);
|
|
4426
|
+
continue;
|
|
4427
|
+
}
|
|
4428
|
+
const sticky = this.proseTargetPins.get(agent.name);
|
|
4429
|
+
if (!allowLocus && !sticky) {
|
|
4430
|
+
this.recordProseSuppression(agent.name, 1);
|
|
4431
|
+
continue;
|
|
4432
|
+
}
|
|
4433
|
+
const target = sticky ?? locus;
|
|
4434
|
+
try {
|
|
4435
|
+
const outcome = await this.channelRegistry.routeSpeech(agent.name, envelope, target);
|
|
4436
|
+
this.recordProseDelivery(agent.name, outcome);
|
|
4437
|
+
}
|
|
4438
|
+
catch (err) {
|
|
4439
|
+
console.error('hybrid locus delivery failed:', err);
|
|
4440
|
+
}
|
|
4441
|
+
}
|
|
4442
|
+
}
|
|
4013
4443
|
async deliverProseEnvelope(agent, rawText) {
|
|
4014
4444
|
const name = agent.name;
|
|
4015
4445
|
const parsed = parseProsePrefix(rawText);
|
|
@@ -4068,7 +4498,7 @@ export class AgentFramework {
|
|
|
4068
4498
|
}
|
|
4069
4499
|
/** Cap on consecutive bounce-triggered wakes (notices still append after). */
|
|
4070
4500
|
static PROSE_BOUNCE_WAKE_CAP = 2;
|
|
4071
|
-
bounceProse(agent, text, reason, candidates) {
|
|
4501
|
+
bounceProse(agent, text, reason, candidates, prefix = '>>') {
|
|
4072
4502
|
const name = agent.name;
|
|
4073
4503
|
this.proseClipboards.set(name, text);
|
|
4074
4504
|
const streak = (this.proseBounceStreaks.get(name) ?? 0) + 1;
|
|
@@ -4076,8 +4506,9 @@ export class AgentFramework {
|
|
|
4076
4506
|
const cand = candidates?.length ? ` Known channels it could mean: ${candidates.join(', ')}.` : '';
|
|
4077
4507
|
const notice = `[prose-routing] Your text (${text.length} chars) was not delivered — ${reason}.${cand} ` +
|
|
4078
4508
|
'The text is retained; nothing is lost. To deliver it unchanged, reply with a ' +
|
|
4079
|
-
|
|
4080
|
-
|
|
4509
|
+
`destination plus the token {{unsent}}, e.g. "${prefix}#channel {{unsent}}" or "${prefix}@person {{unsent}}". ` +
|
|
4510
|
+
`"${prefix}skip_reply {{unsent}}" keeps it in context only.` +
|
|
4511
|
+
(prefix === '>>' ? ' The prose_help tool shows the full syntax.' : '');
|
|
4081
4512
|
try {
|
|
4082
4513
|
// Through framework.addMessage, NOT the context manager directly: while
|
|
4083
4514
|
// the turn is still streaming this defers the notice to the next tool
|
|
@@ -4260,6 +4691,15 @@ export class AgentFramework {
|
|
|
4260
4691
|
if (trigger?.reason !== 'context_budget_restart') {
|
|
4261
4692
|
if (attempt === 0)
|
|
4262
4693
|
this.maybePrimeProseMode(agent);
|
|
4694
|
+
// Fresh turn: forget the previous turn's explicit-send engagements and
|
|
4695
|
+
// prose deliveries — both are strictly turn-scoped (a restart continues
|
|
4696
|
+
// the same logical turn and keeps them).
|
|
4697
|
+
this.turnEngagedChannels.delete(agent.name);
|
|
4698
|
+
this.turnProseDeliveries.delete(agent.name);
|
|
4699
|
+
this.turnProseSuppressed.delete(agent.name);
|
|
4700
|
+
this.proseHybridSuppressed.delete(agent.name);
|
|
4701
|
+
if (agent.proseRouting === 'hybrid')
|
|
4702
|
+
this.proseTargetPins.delete(agent.name);
|
|
4263
4703
|
if (agent.proseRouting === 'explicit') {
|
|
4264
4704
|
// Explicit prose routing: there is no locus. The model names every
|
|
4265
4705
|
// destination in-band (`>>` prefixes); the only turn state is the
|
|
@@ -4438,7 +4878,8 @@ export class AgentFramework {
|
|
|
4438
4878
|
const enqueueSpeech = (text, locus) => {
|
|
4439
4879
|
turnSpeechChain = turnSpeechChain
|
|
4440
4880
|
.then(async () => {
|
|
4441
|
-
await this.channelRegistry.routeSpeech(agent.name, text, locus);
|
|
4881
|
+
const outcome = await this.channelRegistry.routeSpeech(agent.name, text, locus);
|
|
4882
|
+
this.recordProseDelivery(agent.name, outcome);
|
|
4442
4883
|
})
|
|
4443
4884
|
.catch((err) => console.error('mid-turn speech routing failed:', err));
|
|
4444
4885
|
};
|
|
@@ -4476,11 +4917,28 @@ export class AgentFramework {
|
|
|
4476
4917
|
// streams that delivery would refuse), emission is capability-gated in
|
|
4477
4918
|
// the registry (`channels.streaming`), and delivery via deliverProse
|
|
4478
4919
|
// remains the sole authoritative send.
|
|
4479
|
-
const
|
|
4920
|
+
const newOutgoingInferenceId = () => `inf_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
4921
|
+
// NOT const: a membrane refusal retry abandons everything streamed so
|
|
4922
|
+
// far, and the surface must not concatenate two attempts. Re-minting the
|
|
4923
|
+
// id makes the discarded attempt's chunk buffer orphaned (never
|
|
4924
|
+
// finalized) instead of being appended to — see the 'retrying' case.
|
|
4925
|
+
let outgoingInferenceId = newOutgoingInferenceId();
|
|
4480
4926
|
let outgoingIndex = 0;
|
|
4927
|
+
// §10.5 inference/lifecycle: `started` now, exactly one terminal in the
|
|
4928
|
+
// finally below (the one block every exit path shares). Which terminal
|
|
4929
|
+
// is decided by the paths themselves: default completed, catch sets
|
|
4930
|
+
// failed, a framework-cancelled stream sets aborted. Best-effort
|
|
4931
|
+
// notifications — consumers dedupe by inferenceId and keep a timeout.
|
|
4932
|
+
let lifecyclePhase = 'completed';
|
|
4933
|
+
this.hookOrchestrator?.emitLifecycle({
|
|
4934
|
+
inferenceId: outgoingInferenceId,
|
|
4935
|
+
conversationId: agent.name,
|
|
4936
|
+
turnIndex: 0,
|
|
4937
|
+
phase: 'started',
|
|
4938
|
+
});
|
|
4481
4939
|
const proseStream = this.channelRegistry
|
|
4482
4940
|
? new ProseStreamRouter({
|
|
4483
|
-
mode: agent.proseRouting === 'explicit' ? 'explicit' : 'locus',
|
|
4941
|
+
mode: agent.proseRouting === 'explicit' ? 'explicit' : agent.proseRouting === 'hybrid' ? 'hybrid' : 'locus',
|
|
4484
4942
|
initialTarget: typingChannel,
|
|
4485
4943
|
resolve: (spec) => {
|
|
4486
4944
|
const r = this.channelRegistry.resolveProseTarget(spec);
|
|
@@ -4522,6 +4980,35 @@ export class AgentFramework {
|
|
|
4522
4980
|
emitOutgoing(proseStream.feed(event.content));
|
|
4523
4981
|
}
|
|
4524
4982
|
break;
|
|
4983
|
+
case 'retrying': {
|
|
4984
|
+
// Membrane is re-issuing after a content-policy refusal. Per the
|
|
4985
|
+
// RetryingEvent contract we must DISCARD everything this call has
|
|
4986
|
+
// emitted: the tokens above were already streamed to the surface
|
|
4987
|
+
// as outgoing chunks, and appending a second attempt to them
|
|
4988
|
+
// would show the human two half-answers spliced together.
|
|
4989
|
+
//
|
|
4990
|
+
// Re-mint the outgoing id so the partial buffer is orphaned
|
|
4991
|
+
// rather than continued (delivery is authoritative via
|
|
4992
|
+
// deliverProse and has not happened yet — nothing has been
|
|
4993
|
+
// *sent*, only previewed), and reset the prose router so its
|
|
4994
|
+
// destination bookkeeping starts clean for the new attempt.
|
|
4995
|
+
console.error(`[refusal-retry] agent=${agent.name} membrane retry ` +
|
|
4996
|
+
`${event.attempt}/${event.maxAttempts}` +
|
|
4997
|
+
(event.category ? ` category=${event.category}` : '') +
|
|
4998
|
+
' — discarding the refused attempt');
|
|
4999
|
+
outgoingInferenceId = newOutgoingInferenceId();
|
|
5000
|
+
outgoingIndex = 0;
|
|
5001
|
+
proseStream?.reset();
|
|
5002
|
+
this.emitTrace({
|
|
5003
|
+
type: 'inference:tokens',
|
|
5004
|
+
agentName: agent.name,
|
|
5005
|
+
content: '',
|
|
5006
|
+
blockType: 'text',
|
|
5007
|
+
blockIndex: 0,
|
|
5008
|
+
channelId: typingChannel ?? undefined,
|
|
5009
|
+
});
|
|
5010
|
+
break;
|
|
5011
|
+
}
|
|
4525
5012
|
case 'block': {
|
|
4526
5013
|
const { event: phase, index, block } = event.event;
|
|
4527
5014
|
this.emitTrace({
|
|
@@ -4567,9 +5054,17 @@ export class AgentFramework {
|
|
|
4567
5054
|
const rawXml = rawText && /^<(antml:)?function_calls>/.test(rawText)
|
|
4568
5055
|
? rawText
|
|
4569
5056
|
: undefined;
|
|
5057
|
+
// Persist THIS round's text only. `roundPreamble` (membrane
|
|
5058
|
+
// ≥0.5.79) is the per-round delta; the legacy `preamble` is
|
|
5059
|
+
// CUMULATIVE in XML mode — persisting it per round stored each
|
|
5060
|
+
// round's text N times and re-persisted injected
|
|
5061
|
+
// <function_results> as the agent's own words (the Evander
|
|
5062
|
+
// 2026-08-08 scaffold-leak pyramid: r1 ×3, r2 ×2).
|
|
5063
|
+
const roundText = event.context.roundPreamble ??
|
|
5064
|
+
event.context.preamble;
|
|
4570
5065
|
assistantBlocks = [];
|
|
4571
|
-
if (
|
|
4572
|
-
assistantBlocks.push({ type: 'text', text:
|
|
5066
|
+
if (roundText) {
|
|
5067
|
+
assistantBlocks.push({ type: 'text', text: roundText });
|
|
4573
5068
|
}
|
|
4574
5069
|
for (const c of event.calls) {
|
|
4575
5070
|
assistantBlocks.push({
|
|
@@ -4606,14 +5101,28 @@ export class AgentFramework {
|
|
|
4606
5101
|
const roundToolNames = event.calls.map((c) => c.name);
|
|
4607
5102
|
const hasSameRoundPrivateThink = roundToolNames.includes('think') &&
|
|
4608
5103
|
requestSnapshot.sameRoundThinkTextPolicy === 'private';
|
|
4609
|
-
if (roundToolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n))
|
|
5104
|
+
if (roundToolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)) ||
|
|
5105
|
+
(agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n))))) {
|
|
4610
5106
|
turnSilenced = true;
|
|
4611
5107
|
}
|
|
4612
5108
|
if (roundContent && roundContent.length > 0) {
|
|
4613
5109
|
liveProseRouting = true;
|
|
4614
5110
|
const roundSegments = splitProseSegments(assistantBlocks);
|
|
4615
5111
|
if (roundSegments.length > 0) {
|
|
4616
|
-
if (agent.proseRouting === '
|
|
5112
|
+
if (agent.proseRouting === 'hybrid') {
|
|
5113
|
+
if (turnSilenced) {
|
|
5114
|
+
this.recordProseSuppression(agent.name, roundSegments.length);
|
|
5115
|
+
}
|
|
5116
|
+
else if (!hasSameRoundPrivateThink) {
|
|
5117
|
+
const locus = resolveTurnLocus();
|
|
5118
|
+
for (const seg of roundSegments) {
|
|
5119
|
+
turnSpeechChain = turnSpeechChain
|
|
5120
|
+
.then(() => this.deliverHybridProse(agent, seg, locus, true))
|
|
5121
|
+
.catch((err) => console.error('mid-turn hybrid prose delivery failed:', err));
|
|
5122
|
+
}
|
|
5123
|
+
}
|
|
5124
|
+
}
|
|
5125
|
+
else if (agent.proseRouting === 'explicit') {
|
|
4617
5126
|
// Explicit mode: every segment through the prose gateway.
|
|
4618
5127
|
// Silencing does not apply — unprefixed prose bounces and
|
|
4619
5128
|
// prefixed prose is deliberate; think-privacy still holds.
|
|
@@ -4628,6 +5137,9 @@ export class AgentFramework {
|
|
|
4628
5137
|
}
|
|
4629
5138
|
else if (turnSilenced) {
|
|
4630
5139
|
console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`);
|
|
5140
|
+
// Visible in the turn-end receipt — silencing must never
|
|
5141
|
+
// be a silent black hole (n=8: the flying-scene reply).
|
|
5142
|
+
this.recordProseSuppression(agent.name, roundSegments.length);
|
|
4631
5143
|
}
|
|
4632
5144
|
else if (hasSameRoundPrivateThink) {
|
|
4633
5145
|
console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`);
|
|
@@ -4669,17 +5181,12 @@ export class AgentFramework {
|
|
|
4669
5181
|
agent.addAssistantResponse(pending);
|
|
4670
5182
|
this.pendingAssistantBlocks.delete(agent.name);
|
|
4671
5183
|
}
|
|
4672
|
-
// Store tool results
|
|
5184
|
+
// Store tool results — same bounded spill policy as the
|
|
5185
|
+
// ordinary path (issue #89: this guard path must not become the
|
|
5186
|
+
// one door a giant blob still walks through).
|
|
4673
5187
|
const readyState = agent.state;
|
|
4674
5188
|
if (readyState.status === 'ready') {
|
|
4675
|
-
const toolResultContent = readyState.toolResults.
|
|
4676
|
-
type: 'tool_result',
|
|
4677
|
-
toolUseId: tc.id,
|
|
4678
|
-
content: tc.result.isError
|
|
4679
|
-
? (tc.result.error ?? 'Unknown error')
|
|
4680
|
-
: toolResultDataToHistoryString(tc.result.data),
|
|
4681
|
-
isError: tc.result.isError,
|
|
4682
|
-
}));
|
|
5189
|
+
const { blocks: toolResultContent } = await this.buildStoredToolResultContent(readyState.toolResults, this.resolveToolResultInlineCap(agent).cap);
|
|
4683
5190
|
agent.getContextManager().addMessage('user', toolResultContent);
|
|
4684
5191
|
}
|
|
4685
5192
|
}
|
|
@@ -4703,39 +5210,10 @@ export class AgentFramework {
|
|
|
4703
5210
|
else {
|
|
4704
5211
|
agent.addAssistantResponse(terminalContent);
|
|
4705
5212
|
}
|
|
4706
|
-
//
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
.filter((block) => block.type === 'text')
|
|
4711
|
-
.map((b) => b.text)
|
|
4712
|
-
.join('\n');
|
|
4713
|
-
const afterParams = {
|
|
4714
|
-
inferenceId: requestId,
|
|
4715
|
-
conversationId: agent.name,
|
|
4716
|
-
turnIndex: 0,
|
|
4717
|
-
userMessage: null,
|
|
4718
|
-
assistantMessage: speechText,
|
|
4719
|
-
model: {
|
|
4720
|
-
id: agent.model,
|
|
4721
|
-
vendor: 'unknown',
|
|
4722
|
-
contextWindow: 200000,
|
|
4723
|
-
capabilities: ['tools'],
|
|
4724
|
-
},
|
|
4725
|
-
usage: {
|
|
4726
|
-
inputTokens: response.usage?.inputTokens ?? 0,
|
|
4727
|
-
outputTokens: response.usage?.outputTokens ?? 0,
|
|
4728
|
-
cacheCreationTokens: response.details?.usage?.cacheCreationTokens,
|
|
4729
|
-
cacheReadTokens: response.details?.usage?.cacheReadTokens,
|
|
4730
|
-
},
|
|
4731
|
-
};
|
|
4732
|
-
await this.hookOrchestrator.afterInference(afterParams);
|
|
4733
|
-
}
|
|
4734
|
-
catch (error) {
|
|
4735
|
-
// Fail-open: continue with speech dispatch
|
|
4736
|
-
console.error('afterInference hook error:', error);
|
|
4737
|
-
}
|
|
4738
|
-
}
|
|
5213
|
+
// §10.5: context/afterInference is removed in 0.5.0 — no
|
|
5214
|
+
// content leaves the host here. Turn boundaries are announced
|
|
5215
|
+
// by the metadata-only inference/lifecycle notifications
|
|
5216
|
+
// emitted in driveStream's start/exit paths.
|
|
4739
5217
|
// Separate speech from thoughts — for MODULE dispatch only
|
|
4740
5218
|
// (dispatchSpeech / TUI rendering below). This split does NOT
|
|
4741
5219
|
// decide what reaches the channel: mid-turn prose is routed live
|
|
@@ -4817,6 +5295,17 @@ export class AgentFramework {
|
|
|
4817
5295
|
let handledByRewind = false;
|
|
4818
5296
|
const rh = agent.refusalHandling;
|
|
4819
5297
|
const forced = this.forcedRewind.get(agent.name);
|
|
5298
|
+
// NOTE: plain retries are NOT done here. `refusalHandling.retries`
|
|
5299
|
+
// is handed to membrane (see Agent.startActivation), which
|
|
5300
|
+
// replays the SAME request at the provider seam — immediately,
|
|
5301
|
+
// cache-warm, and without recompiling into a different window.
|
|
5302
|
+
// A framework-level requeue was tried first and is strictly
|
|
5303
|
+
// worse: it recompiles, so attempts are correlated rather than
|
|
5304
|
+
// fresh draws against a probabilistic threshold, and it cannot
|
|
5305
|
+
// tell a streaming consumer to discard the abandoned attempt.
|
|
5306
|
+
// By the time a refusal reaches here, membrane's retries are
|
|
5307
|
+
// already spent — so this path is the escalation: rewind (if
|
|
5308
|
+
// enabled), then the reaction.
|
|
4820
5309
|
// Shed exactly one more (newest, in-sequence) turn and keep the
|
|
4821
5310
|
// single episode marker current. `budgetLeft` bounds the loop.
|
|
4822
5311
|
const doRewind = (budgetLeft, onStep, onGiveUp) => {
|
|
@@ -4862,8 +5351,16 @@ export class AgentFramework {
|
|
|
4862
5351
|
this.rewindEpisode.delete(agent.name);
|
|
4863
5352
|
});
|
|
4864
5353
|
}
|
|
4865
|
-
|
|
5354
|
+
// The reaction is the LAST resort: retries spent, rewind not
|
|
5355
|
+
// applicable. That makes it an honest signal to humans that the
|
|
5356
|
+
// border is close, rather than noise on every near-threshold
|
|
5357
|
+
// flip. (It must not re-enter any agent's context — the emoji
|
|
5358
|
+
// is suppressed at the surface via
|
|
5359
|
+
// DISCORD_SUPPRESS_REACTION_EMOJIS; without that, marking a
|
|
5360
|
+
// refusal feeds the next one.)
|
|
5361
|
+
if (!handledByRewind) {
|
|
4866
5362
|
void this.reactToRefusal(agent.name, category);
|
|
5363
|
+
}
|
|
4867
5364
|
}
|
|
4868
5365
|
else {
|
|
4869
5366
|
// A turn that completed WITHOUT a refusal ends the rewind episode:
|
|
@@ -4905,7 +5402,17 @@ export class AgentFramework {
|
|
|
4905
5402
|
.join('\n')
|
|
4906
5403
|
.trim();
|
|
4907
5404
|
if (speechText) {
|
|
4908
|
-
if (agent.proseRouting === '
|
|
5405
|
+
if (agent.proseRouting === 'hybrid') {
|
|
5406
|
+
const locus = resolveTurnLocus();
|
|
5407
|
+
console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`);
|
|
5408
|
+
try {
|
|
5409
|
+
await this.deliverHybridProse(agent, speechText, locus, true);
|
|
5410
|
+
}
|
|
5411
|
+
catch (err) {
|
|
5412
|
+
console.error('text-only hybrid prose delivery failed:', err);
|
|
5413
|
+
}
|
|
5414
|
+
}
|
|
5415
|
+
else if (agent.proseRouting === 'explicit') {
|
|
4909
5416
|
console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`);
|
|
4910
5417
|
try {
|
|
4911
5418
|
await this.deliverProse(agent, speechText);
|
|
@@ -4924,7 +5431,8 @@ export class AgentFramework {
|
|
|
4924
5431
|
const locus = resolveTurnLocus();
|
|
4925
5432
|
console.error(`[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ?? '(none)'}`);
|
|
4926
5433
|
try {
|
|
4927
|
-
await this.channelRegistry.routeSpeech(agent.name, speechText, locus);
|
|
5434
|
+
const outcome = await this.channelRegistry.routeSpeech(agent.name, speechText, locus);
|
|
5435
|
+
this.recordProseDelivery(agent.name, outcome);
|
|
4928
5436
|
}
|
|
4929
5437
|
catch (err) {
|
|
4930
5438
|
console.error('speech routing failed:', err);
|
|
@@ -4953,13 +5461,30 @@ export class AgentFramework {
|
|
|
4953
5461
|
.filter((n) => typeof n === 'string');
|
|
4954
5462
|
const silenced = liveProseRouting
|
|
4955
5463
|
? turnSilenced
|
|
4956
|
-
: turnSilenced || toolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n))
|
|
5464
|
+
: turnSilenced || toolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)) ||
|
|
5465
|
+
(agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n))));
|
|
4957
5466
|
const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);
|
|
4958
5467
|
// Preserve in-channel ordering: everything enqueued live must
|
|
4959
5468
|
// land before the trailing prose. Awaited even when silenced —
|
|
4960
5469
|
// the chain may still be flushing earlier rounds' posts.
|
|
4961
5470
|
await turnSpeechChain;
|
|
4962
|
-
if (agent.proseRouting === '
|
|
5471
|
+
if (agent.proseRouting === 'hybrid') {
|
|
5472
|
+
if (silenced && segments.length > 0) {
|
|
5473
|
+
this.recordProseSuppression(agent.name, segments.length);
|
|
5474
|
+
}
|
|
5475
|
+
else if (segments.length > 0) {
|
|
5476
|
+
const locus = resolveTurnLocus();
|
|
5477
|
+
for (const seg of segments) {
|
|
5478
|
+
try {
|
|
5479
|
+
await this.deliverHybridProse(agent, seg, locus, true);
|
|
5480
|
+
}
|
|
5481
|
+
catch (err) {
|
|
5482
|
+
console.error('trailing hybrid prose delivery failed:', err);
|
|
5483
|
+
}
|
|
5484
|
+
}
|
|
5485
|
+
}
|
|
5486
|
+
}
|
|
5487
|
+
else if (agent.proseRouting === 'explicit') {
|
|
4963
5488
|
if (segments.length > 0) {
|
|
4964
5489
|
console.error(`[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`);
|
|
4965
5490
|
for (const seg of segments) {
|
|
@@ -4975,6 +5500,9 @@ export class AgentFramework {
|
|
|
4975
5500
|
else if (silenced || segments.length === 0) {
|
|
4976
5501
|
console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> trailing prose NOT routed ` +
|
|
4977
5502
|
`(${silenced ? 'silencing tool / explicit send' : 'no trailing prose'})`);
|
|
5503
|
+
if (silenced && segments.length > 0) {
|
|
5504
|
+
this.recordProseSuppression(agent.name, segments.length);
|
|
5505
|
+
}
|
|
4978
5506
|
}
|
|
4979
5507
|
else {
|
|
4980
5508
|
// Reuse the locus pinned at the turn's first live-routed
|
|
@@ -4989,7 +5517,8 @@ export class AgentFramework {
|
|
|
4989
5517
|
// Deliver sequentially (await each) so the segments land in order.
|
|
4990
5518
|
for (const seg of segments) {
|
|
4991
5519
|
try {
|
|
4992
|
-
await this.channelRegistry.routeSpeech(agent.name, seg, locus);
|
|
5520
|
+
const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus);
|
|
5521
|
+
this.recordProseDelivery(agent.name, outcome);
|
|
4993
5522
|
}
|
|
4994
5523
|
catch (err) {
|
|
4995
5524
|
console.error('speech routing failed:', err);
|
|
@@ -5000,6 +5529,14 @@ export class AgentFramework {
|
|
|
5000
5529
|
// NOTE: agent.reset() + onInferenceEnded() already ran above,
|
|
5001
5530
|
// BEFORE dispatchSpeech. Locus routing is speech dispatch and
|
|
5002
5531
|
// doesn't depend on the status field, so it correctly runs after.
|
|
5532
|
+
// Logical turn end (normal completion): drop the `[delivered]`
|
|
5533
|
+
// receipt. All deliveries are settled — the live chain was
|
|
5534
|
+
// awaited before trailing dispatch, and trailing/text-only
|
|
5535
|
+
// segments were awaited in-loop. Locus mode only; explicit-mode
|
|
5536
|
+
// envelopes acknowledge themselves through the prose gateway.
|
|
5537
|
+
if (agent.proseRouting !== 'explicit') {
|
|
5538
|
+
this.appendProseDeliveryReceipt(agent);
|
|
5539
|
+
}
|
|
5003
5540
|
// Explicit-prose `!` continuation: a prose segment this turn asked
|
|
5004
5541
|
// to keep going (`>>#x !` / `>>private !`) — start another turn
|
|
5005
5542
|
// now instead of pausing until the next external event. This gives
|
|
@@ -5075,9 +5612,26 @@ export class AgentFramework {
|
|
|
5075
5612
|
// reset, no settle, no spurious inference:exhausted (which would
|
|
5076
5613
|
// reject an ephemeral's promise mid-run and bump the failure
|
|
5077
5614
|
// streak). Gate release + stream teardown happen in `finally`.
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5615
|
+
{
|
|
5616
|
+
const cancelKey = `${agent.name}:${myStreamId}`;
|
|
5617
|
+
const cancelKind = this.frameworkCancelledStreams.get(cancelKey);
|
|
5618
|
+
if (cancelKind !== undefined) {
|
|
5619
|
+
this.frameworkCancelledStreams.delete(cancelKey);
|
|
5620
|
+
lifecyclePhase = 'aborted'; // §10.5 — terminal emitted in finally
|
|
5621
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5622
|
+
// endTurn IS a logical turn end — earlier rounds may have
|
|
5623
|
+
// live-routed prose (narrate → skip_reply is a real shape),
|
|
5624
|
+
// so settle the delivery chain and drop the receipt. A
|
|
5625
|
+
// budget restart continues the same logical turn: the
|
|
5626
|
+
// deliveries map persists (cleared only at fresh turn
|
|
5627
|
+
// start) and the continuation stream's end writes ONE
|
|
5628
|
+
// receipt for the whole turn.
|
|
5629
|
+
if (cancelKind === 'turn_ended' && agent.proseRouting !== 'explicit') {
|
|
5630
|
+
await turnSpeechChain;
|
|
5631
|
+
this.appendProseDeliveryReceipt(agent);
|
|
5632
|
+
}
|
|
5633
|
+
return;
|
|
5634
|
+
}
|
|
5081
5635
|
}
|
|
5082
5636
|
const reason = event.reason ?? 'unknown';
|
|
5083
5637
|
// Only reset if this is still the active stream (a budget restart
|
|
@@ -5117,6 +5671,11 @@ export class AgentFramework {
|
|
|
5117
5671
|
}
|
|
5118
5672
|
case 'usage': {
|
|
5119
5673
|
agent.lastStreamInputTokens = event.usage.inputTokens;
|
|
5674
|
+
agent.lastStreamRealInputTokens =
|
|
5675
|
+
(event.usage.inputTokens ?? 0) +
|
|
5676
|
+
(event.usage.cacheCreationTokens ?? 0) +
|
|
5677
|
+
(event.usage.cacheReadTokens ?? 0);
|
|
5678
|
+
agent.lastStreamOutputTokens = event.usage.outputTokens ?? 0;
|
|
5120
5679
|
// Closed-loop estimator calibration (2026-07-12). Sample the REAL
|
|
5121
5680
|
// prefix size of THIS API call (fresh + cache write + cache read)
|
|
5122
5681
|
// and hand it to the context strategy, which accepts exactly one
|
|
@@ -5184,10 +5743,22 @@ export class AgentFramework {
|
|
|
5184
5743
|
durationMs,
|
|
5185
5744
|
});
|
|
5186
5745
|
this.abortAgentScript(agent.name, 'stream threw');
|
|
5746
|
+
lifecyclePhase = 'failed'; // §10.5 — terminal emitted in finally
|
|
5187
5747
|
agent.reset();
|
|
5188
5748
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
5189
5749
|
}
|
|
5190
5750
|
finally {
|
|
5751
|
+
// §10.5: exactly one terminal per `started`, on every exit path the
|
|
5752
|
+
// host controls. Which one was decided by the path taken (default
|
|
5753
|
+
// completed; catch → failed; framework-cancel → aborted). A host
|
|
5754
|
+
// crash emits nothing — that is the documented best-effort limit, and
|
|
5755
|
+
// why consumers keep their safety timeout.
|
|
5756
|
+
this.hookOrchestrator?.emitLifecycle({
|
|
5757
|
+
inferenceId: outgoingInferenceId,
|
|
5758
|
+
conversationId: agent.name,
|
|
5759
|
+
turnIndex: 0,
|
|
5760
|
+
phase: lifecyclePhase,
|
|
5761
|
+
});
|
|
5191
5762
|
// Clear the gate's inference flag on EVERY exit path (paired with the
|
|
5192
5763
|
// onInferenceStarted in startAgentStream). The branch-level calls above
|
|
5193
5764
|
// are kept but are best-effort; if any exit path bypassed them the agent
|
|
@@ -5594,8 +6165,12 @@ export class AgentFramework {
|
|
|
5594
6165
|
? 'null'
|
|
5595
6166
|
: JSON.stringify(payload, null, 1) ?? String(payload);
|
|
5596
6167
|
const agent = this.agents.get(record.agentName);
|
|
5597
|
-
|
|
5598
|
-
|
|
6168
|
+
// Same resolved cap as tool results (override applies here too); an
|
|
6169
|
+
// unknown agent still gets the bounded house default, never unbounded.
|
|
6170
|
+
const maxChars = agent
|
|
6171
|
+
? this.resolveToolResultInlineCap(agent).cap
|
|
6172
|
+
: DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS;
|
|
6173
|
+
const { text: payloadText } = await this.spillOrTruncate(payloadJson, maxChars, `${record.id}-wake${record.wakes}`);
|
|
5599
6174
|
const envelope = `[background script ${record.id}] Woke you: wake_agent() called at line ${line} of your script, ` +
|
|
5600
6175
|
`${elapsedMin}m after you started it (wake ${record.wakes} of ${maxWakes}).\n` +
|
|
5601
6176
|
`Arguments:\n${payloadText}\n` +
|
|
@@ -5664,41 +6239,92 @@ export class AgentFramework {
|
|
|
5664
6239
|
}
|
|
5665
6240
|
}
|
|
5666
6241
|
/**
|
|
5667
|
-
* Oversized content policy (antra, 2026-07-31
|
|
5668
|
-
* the face with a huge blob, never silently destroy
|
|
5669
|
-
* over the inline cap is materialized to a workspace
|
|
5670
|
-
* the agent's own tools
|
|
5671
|
-
*
|
|
5672
|
-
*
|
|
5673
|
-
*
|
|
6242
|
+
* Oversized content policy (antra, 2026-07-31; completed for issue #89):
|
|
6243
|
+
* never hit the agent in the face with a huge blob, never silently destroy
|
|
6244
|
+
* it either. Content over the inline cap is materialized to a workspace
|
|
6245
|
+
* file (readable with the agent's own tools — deterministic name, kept
|
|
6246
|
+
* until the workspace owner deletes it) and replaced by a truncated head +
|
|
6247
|
+
* a trailing file reference. Falls back to EXPLICIT plain truncation when
|
|
6248
|
+
* there is no writable workspace. Cap resolution lives in
|
|
6249
|
+
* resolveToolResultInlineCap; callers pass the resolved cap.
|
|
5674
6250
|
*/
|
|
5675
|
-
|
|
5676
|
-
|
|
5677
|
-
|
|
5678
|
-
|
|
5679
|
-
|
|
6251
|
+
/**
|
|
6252
|
+
* One spill policy for every stored tool_result block: serialize (history
|
|
6253
|
+
* serializer — image blocks become placeholders), then spill/truncate at
|
|
6254
|
+
* the resolved cap. Errors follow the same policy as successes — a giant
|
|
6255
|
+
* error string is exactly as context-hostile as a giant success (issue
|
|
6256
|
+
* #89). Returns the stored blocks plus the per-call spill outcomes so the
|
|
6257
|
+
* live wire copy can reuse the identical strings.
|
|
6258
|
+
*/
|
|
6259
|
+
async buildStoredToolResultContent(toolResults, maxChars) {
|
|
6260
|
+
const spilled = new Map();
|
|
6261
|
+
const dateLabel = new Date().toISOString().slice(0, 10);
|
|
6262
|
+
for (const tc of toolResults) {
|
|
6263
|
+
const raw = tc.result.isError
|
|
6264
|
+
? tc.result.error ?? 'Unknown error'
|
|
6265
|
+
: toolResultDataToHistoryString(tc.result.data, undefined);
|
|
6266
|
+
spilled.set(tc.id, await this.spillOrTruncate(raw, maxChars, `${dateLabel}-${tc.id}`));
|
|
6267
|
+
}
|
|
6268
|
+
const blocks = toolResults.map(tc => ({
|
|
6269
|
+
type: 'tool_result',
|
|
6270
|
+
toolUseId: tc.id,
|
|
6271
|
+
// Persisted so XML replay can reconstruct the legacy <tool_name>
|
|
6272
|
+
// element byte-identically to the live injection.
|
|
6273
|
+
toolName: tc.name,
|
|
6274
|
+
content: spilled.get(tc.id).text,
|
|
6275
|
+
isError: tc.result.isError,
|
|
6276
|
+
}));
|
|
6277
|
+
return { blocks, spilled };
|
|
6278
|
+
}
|
|
6279
|
+
async spillOrTruncate(content, cap, label) {
|
|
5680
6280
|
if (!cap || content.length <= cap)
|
|
5681
|
-
return content;
|
|
6281
|
+
return { text: content, filePath: null };
|
|
5682
6282
|
const workspace = this.getWorkspaceModule();
|
|
5683
6283
|
const mountName = workspace ? this.firstWritableMountName(workspace) : null;
|
|
5684
6284
|
if (workspace && mountName) {
|
|
5685
6285
|
const safeLabel = label.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
|
|
5686
6286
|
const path = `${mountName}/tool-results/${safeLabel}.txt`;
|
|
6287
|
+
let failure;
|
|
5687
6288
|
try {
|
|
5688
6289
|
const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain');
|
|
5689
6290
|
if (result.success) {
|
|
5690
|
-
return
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
6291
|
+
return {
|
|
6292
|
+
text: safeSlice(content, 0, cap)
|
|
6293
|
+
+ `\n\n[truncated — showing ${cap} of ${content.length} chars; full content: workspace file ${path}. `
|
|
6294
|
+
+ 'Read/grep it with your file tools, or raise the inline cap via '
|
|
6295
|
+
+ 'agent_settings update tool_result_inline_max_chars.]',
|
|
6296
|
+
filePath: path,
|
|
6297
|
+
};
|
|
5694
6298
|
}
|
|
6299
|
+
failure = result.error ?? 'write refused';
|
|
5695
6300
|
}
|
|
5696
|
-
catch {
|
|
5697
|
-
|
|
6301
|
+
catch (err) {
|
|
6302
|
+
failure = err instanceof Error ? err.message : String(err);
|
|
5698
6303
|
}
|
|
6304
|
+
// A workspace was there and the write FAILED — say that, loudly and
|
|
6305
|
+
// distinctly. Telling the agent "no writable workspace" would teach
|
|
6306
|
+
// them their residence lacks a capability it actually has.
|
|
6307
|
+
failure = failure.slice(0, 200);
|
|
6308
|
+
this.emitTrace({
|
|
6309
|
+
type: 'tool:spill_failed',
|
|
6310
|
+
label: safeLabel,
|
|
6311
|
+
path,
|
|
6312
|
+
contentLength: content.length,
|
|
6313
|
+
error: failure,
|
|
6314
|
+
});
|
|
6315
|
+
console.error(`[spill] workspace write failed for ${path} (${content.length} chars): ${failure}`);
|
|
6316
|
+
return {
|
|
6317
|
+
text: safeSlice(content, 0, cap)
|
|
6318
|
+
+ `\n\n[truncated — showing ${cap} of ${content.length} chars; spill to workspace file ${path} FAILED`
|
|
6319
|
+
+ ` (${failure}); content over the cap was not retained]`,
|
|
6320
|
+
filePath: null,
|
|
6321
|
+
};
|
|
5699
6322
|
}
|
|
5700
|
-
return
|
|
5701
|
-
|
|
6323
|
+
return {
|
|
6324
|
+
text: safeSlice(content, 0, cap)
|
|
6325
|
+
+ '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace, full content not retained]',
|
|
6326
|
+
filePath: null,
|
|
6327
|
+
};
|
|
5702
6328
|
}
|
|
5703
6329
|
getOrCreateScriptRunner(agentName) {
|
|
5704
6330
|
let runner = this.codeExecutionRunners.get(agentName);
|
|
@@ -5793,22 +6419,28 @@ export class AgentFramework {
|
|
|
5793
6419
|
}
|
|
5794
6420
|
}
|
|
5795
6421
|
toMembraneToolResult(callId, afResult, maxChars,
|
|
5796
|
-
/** Pre-spilled
|
|
6422
|
+
/** Pre-spilled outcome from the history path — used for the non-image
|
|
5797
6423
|
* path so the live wire copy byte-matches what the window stored. */
|
|
5798
6424
|
precomputed) {
|
|
5799
6425
|
if (afResult.isError) {
|
|
5800
|
-
|
|
6426
|
+
// Same spill policy as successes: reuse the history copy so a giant
|
|
6427
|
+
// error string stays bounded on the wire too (byte-matched).
|
|
6428
|
+
return {
|
|
6429
|
+
toolUseId: callId,
|
|
6430
|
+
content: precomputed?.text ?? truncateForHistory(afResult.error ?? 'Unknown error', DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS),
|
|
6431
|
+
isError: true,
|
|
6432
|
+
};
|
|
5801
6433
|
}
|
|
5802
6434
|
// MCPL tool results arrive as `data: McpToolResultContent[]` — preserve image
|
|
5803
6435
|
// blocks natively rather than JSON-stringifying them away. Anything else
|
|
5804
6436
|
// (objects, scalars) falls through to JSON. The error path was handled
|
|
5805
6437
|
// above, so isError is always false on these return paths.
|
|
5806
|
-
const blocks = this.tryNativeToolResultContent(afResult.data, maxChars);
|
|
6438
|
+
const blocks = this.tryNativeToolResultContent(afResult.data, maxChars, precomputed?.filePath ?? null);
|
|
5807
6439
|
if (blocks) {
|
|
5808
6440
|
return { toolUseId: callId, content: blocks, isError: false };
|
|
5809
6441
|
}
|
|
5810
6442
|
if (precomputed !== undefined) {
|
|
5811
|
-
return { toolUseId: callId, content: precomputed, isError: false };
|
|
6443
|
+
return { toolUseId: callId, content: precomputed.text, isError: false };
|
|
5812
6444
|
}
|
|
5813
6445
|
// JSON.stringify returns the VALUE undefined (not a string) for undefined
|
|
5814
6446
|
// input — a module tool returning `{ success: true }` with no data would
|
|
@@ -5827,7 +6459,10 @@ export class AgentFramework {
|
|
|
5827
6459
|
* `maxChars`, when provided, caps each accompanying text block so an image
|
|
5828
6460
|
* inlined alongside an enormous text payload can't blow the context.
|
|
5829
6461
|
*/
|
|
5830
|
-
tryNativeToolResultContent(data, maxChars
|
|
6462
|
+
tryNativeToolResultContent(data, maxChars,
|
|
6463
|
+
/** Spill file written by the history path — referenced from truncation
|
|
6464
|
+
* notices so an image-adjacent giant text block stays recoverable. */
|
|
6465
|
+
spillPath = null) {
|
|
5831
6466
|
if (!Array.isArray(data))
|
|
5832
6467
|
return null;
|
|
5833
6468
|
let hasImage = false;
|
|
@@ -5840,7 +6475,9 @@ export class AgentFramework {
|
|
|
5840
6475
|
let text = b.text;
|
|
5841
6476
|
if (maxChars && text.length > maxChars) {
|
|
5842
6477
|
text = safeSlice(text, 0, maxChars)
|
|
5843
|
-
+ '\n\n[truncated — original was ' + text.length + ' chars
|
|
6478
|
+
+ '\n\n[truncated — original was ' + text.length + ' chars'
|
|
6479
|
+
+ (spillPath ? `; full serialized result: workspace file ${spillPath}` : '')
|
|
6480
|
+
+ ']';
|
|
5844
6481
|
}
|
|
5845
6482
|
blocks.push({ type: 'text', text });
|
|
5846
6483
|
}
|
|
@@ -5857,13 +6494,39 @@ export class AgentFramework {
|
|
|
5857
6494
|
}
|
|
5858
6495
|
return hasImage ? blocks : null;
|
|
5859
6496
|
}
|
|
5860
|
-
|
|
6497
|
+
/** Strategy-derived per-message bound (maxMessageTokens * 4 chars). */
|
|
6498
|
+
strategyDerivedToolResultChars(agent) {
|
|
5861
6499
|
const strategy = agent.getContextManager().getStrategy();
|
|
5862
6500
|
const maxTokens = strategy.maxMessageTokens;
|
|
5863
6501
|
if (maxTokens && maxTokens > 0)
|
|
5864
6502
|
return maxTokens * 4;
|
|
5865
6503
|
return undefined;
|
|
5866
6504
|
}
|
|
6505
|
+
/**
|
|
6506
|
+
* Effective tool-result inline cap for an agent, with provenance. Desired
|
|
6507
|
+
* value: resident's durable agent_settings value → residence
|
|
6508
|
+
* FrameworkConfig.toolResultInlineMaxChars → house default (24000).
|
|
6509
|
+
* Effective value: min(desired, strategy bound) for EVERY source — the
|
|
6510
|
+
* strategy's per-message safety limit is a ceiling, not a suggestion
|
|
6511
|
+
* (Sol's #94 ruling: a durable preference must not be a durable path for
|
|
6512
|
+
* one tool result to exceed maxMessageTokens; the way to see the whole
|
|
6513
|
+
* result is the spill file, not an over-bound blob in live context).
|
|
6514
|
+
*/
|
|
6515
|
+
resolveToolResultInlineCap(agent) {
|
|
6516
|
+
const override = this.toolResultInlineMaxCharsOverride.get(agent.name);
|
|
6517
|
+
const configured = this.toolResultInlineMaxCharsConfig;
|
|
6518
|
+
const desired = override ?? configured ?? DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS;
|
|
6519
|
+
const source = override !== undefined
|
|
6520
|
+
? 'agent-settings-override'
|
|
6521
|
+
: configured !== null ? 'framework-config' : 'default';
|
|
6522
|
+
const strategyBound = this.strategyDerivedToolResultChars(agent);
|
|
6523
|
+
const clamped = strategyBound !== undefined && strategyBound < desired;
|
|
6524
|
+
return {
|
|
6525
|
+
cap: clamped ? strategyBound : desired,
|
|
6526
|
+
source,
|
|
6527
|
+
clampedBy: clamped ? 'strategy-bound' : null,
|
|
6528
|
+
};
|
|
6529
|
+
}
|
|
5867
6530
|
approximateDecodedBase64Bytes(base64) {
|
|
5868
6531
|
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
|
|
5869
6532
|
return Math.max(0, Math.floor(base64.length * 3 / 4) - padding);
|
|
@@ -6017,6 +6680,26 @@ export class AgentFramework {
|
|
|
6017
6680
|
// synthesized channel tools, but they aren't `channel_`-prefixed so they
|
|
6018
6681
|
// need an explicit route here.
|
|
6019
6682
|
if ((enrichedCall.name === 'think' || enrichedCall.name === 'skip_reply') && this.channelRegistry) {
|
|
6683
|
+
// skip_reply(wake_in_seconds): arm a gate self-wake so "not replying
|
|
6684
|
+
// NOW" can also mean "back in a moment" — ends the turn, then wakes
|
|
6685
|
+
// the agent after N seconds unless something else wakes it first
|
|
6686
|
+
// (any turn start cancels the pending self-wake). Armed here because
|
|
6687
|
+
// the framework owns the EventGate; the registry only words the
|
|
6688
|
+
// confirmation. Without a gate the field is stripped so that
|
|
6689
|
+
// confirmation never claims a wake that can't happen.
|
|
6690
|
+
if (enrichedCall.name === 'skip_reply') {
|
|
6691
|
+
const inputObj = (enrichedCall.input ?? {});
|
|
6692
|
+
const secs = Number(inputObj.wake_in_seconds);
|
|
6693
|
+
const wants = inputObj.wake_in_seconds !== undefined && Number.isFinite(secs) && secs > 0;
|
|
6694
|
+
if (wants && this.eventGate) {
|
|
6695
|
+
const { inMs } = this.eventGate.armSelfWake(agentName, secs, 'skip_reply');
|
|
6696
|
+
console.error(`[self-wake] agent=${agentName} armed in ${Math.round(inMs / 1000)}s (skip_reply)`);
|
|
6697
|
+
}
|
|
6698
|
+
else if (inputObj.wake_in_seconds !== undefined && !this.eventGate) {
|
|
6699
|
+
delete inputObj.wake_in_seconds;
|
|
6700
|
+
console.error(`[self-wake] agent=${agentName} wake_in_seconds requested but no EventGate — ignored`);
|
|
6701
|
+
}
|
|
6702
|
+
}
|
|
6020
6703
|
this.dispatchChannelToolCall(agentName, enrichedCall);
|
|
6021
6704
|
return;
|
|
6022
6705
|
}
|
|
@@ -6539,7 +7222,6 @@ export class AgentFramework {
|
|
|
6539
7222
|
async initializeMcpl(serverConfigs, inferenceRouting) {
|
|
6540
7223
|
this.mcplServerRegistry = new McplServerRegistry();
|
|
6541
7224
|
this.featureSetManager = new FeatureSetManager();
|
|
6542
|
-
this.scopeManager = new ScopeManager();
|
|
6543
7225
|
this.hookOrchestrator = new HookOrchestrator(this.mcplServerRegistry, this.featureSetManager);
|
|
6544
7226
|
// Build prefix map and store configs for tool routing
|
|
6545
7227
|
for (const config of serverConfigs) {
|
|
@@ -6625,12 +7307,32 @@ export class AgentFramework {
|
|
|
6625
7307
|
});
|
|
6626
7308
|
// Host capabilities advertised during the MCP handshake — stored so
|
|
6627
7309
|
// servers can also be connected later at runtime (connectMcplServer).
|
|
7310
|
+
// §5.2: the host advertises what it actually implements. 0.5.0-draft:
|
|
7311
|
+
// afterInference is GONE (replaced by inference/lifecycle, §10.5), and
|
|
7312
|
+
// the previously under-advertised inferenceRequest + channels — both
|
|
7313
|
+
// implemented for years — are declared (AUDIT-001/issue #76 item 12).
|
|
6628
7314
|
this.mcplHostCapabilities = {
|
|
6629
|
-
version: '0.
|
|
7315
|
+
version: '0.5',
|
|
6630
7316
|
pushEvents: true,
|
|
6631
7317
|
contextHooks: {
|
|
6632
7318
|
beforeInference: true,
|
|
6633
|
-
|
|
7319
|
+
},
|
|
7320
|
+
inferenceLifecycle: true,
|
|
7321
|
+
// modelInfo is NOT advertised: §12.2's result requires all four of
|
|
7322
|
+
// {id, vendor, contextWindow, capabilities} and the host has no
|
|
7323
|
+
// truthful source for contextWindow/capabilities (grep: none exists).
|
|
7324
|
+
// Advertising a capability answered with a malformed partial response
|
|
7325
|
+
// is worse than honest unsupported (Sol, PR #79 re-review). The
|
|
7326
|
+
// handler below answers an explicit error rather than hanging (§6.6).
|
|
7327
|
+
inferenceRequest: { streaming: true },
|
|
7328
|
+
channels: {
|
|
7329
|
+
register: true,
|
|
7330
|
+
lifecycle: true,
|
|
7331
|
+
publish: true,
|
|
7332
|
+
incoming: true,
|
|
7333
|
+
streaming: true,
|
|
7334
|
+
acknowledge: true,
|
|
7335
|
+
typing: true,
|
|
6634
7336
|
},
|
|
6635
7337
|
featureSets: true,
|
|
6636
7338
|
};
|
|
@@ -6963,27 +7665,210 @@ export class AgentFramework {
|
|
|
6963
7665
|
// Wire listeners before either startup staging or the runtime global gate
|
|
6964
7666
|
// releases control traffic needed for registration and marker service.
|
|
6965
7667
|
this.wireMcplEvents(connection);
|
|
6966
|
-
// Initialize feature sets if server advertises MCPL capabilities
|
|
6967
|
-
this.registerMcplServerFeatures(config, connection);
|
|
6968
7668
|
if (deferAwareness) {
|
|
7669
|
+
// Staged startup: both planes are closed, so the §5.3 policy
|
|
7670
|
+
// round-trip cannot let any buffered traffic slip — establish the
|
|
7671
|
+
// grant now, before staging ever releases.
|
|
7672
|
+
await this.registerMcplServerFeatures(config, connection);
|
|
6969
7673
|
this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
|
|
6970
7674
|
return;
|
|
6971
7675
|
}
|
|
6972
7676
|
const awarenessBarrier = this.installMcplDataPlaneGate();
|
|
6973
|
-
|
|
7677
|
+
// NO early release: both planes stay closed through accounting AND the
|
|
7678
|
+
// §5.3 policy round-trip below (PR #79 review blocker 3 — the early
|
|
7679
|
+
// control flush released channels-register/changed before any grant
|
|
7680
|
+
// existed). The awareness drain rides tools/call RESPONSES, which are
|
|
7681
|
+
// never event-buffered, so it needs no plane release to make progress.
|
|
6974
7682
|
try {
|
|
6975
7683
|
await awarenessBarrier.promise;
|
|
6976
|
-
this.completeMcplDataPlaneGate(awarenessBarrier);
|
|
6977
7684
|
}
|
|
6978
7685
|
catch (error) {
|
|
6979
7686
|
// Startup cannot fail open on a broken awareness ledger. Disable the
|
|
6980
7687
|
// reconnecting stub/connection before propagating the distinct error to
|
|
6981
7688
|
// initializeMcpl, which tears down any other servers and aborts create().
|
|
7689
|
+
// Ordering matters: awareness accounting settles BEFORE the §5.3
|
|
7690
|
+
// policy round-trip below, so an accounting failure aborts with the
|
|
7691
|
+
// grant still empty and every buffered event still behind the gate —
|
|
7692
|
+
// a failed startup never reports a released data plane. (The reverse
|
|
7693
|
+
// order let a push arrive during the policy await and get flushed by
|
|
7694
|
+
// teardown.)
|
|
6982
7695
|
await connection.close().catch(() => { });
|
|
6983
7696
|
throw error;
|
|
6984
7697
|
}
|
|
7698
|
+
// §5.3 initial policy handshake — after accounting, before the gate
|
|
7699
|
+
// completes: the grant is established (or knowingly left empty) before
|
|
7700
|
+
// ANY buffered traffic (control or data) flows. complete() then
|
|
7701
|
+
// ready()s both planes through the admission gate.
|
|
7702
|
+
await this.registerMcplServerFeatures(config, connection);
|
|
7703
|
+
this.completeMcplDataPlaneGate(awarenessBarrier);
|
|
6985
7704
|
this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
|
|
6986
7705
|
}
|
|
7706
|
+
/** Per-connection §17.8 fetch limiter: at most one in-flight fetch, at
|
|
7707
|
+
* most one queued behind it (announcements coalesce), floor between
|
|
7708
|
+
* completed fetches. Exceeding the floor drops the excess with a
|
|
7709
|
+
* diagnostic — the host bounds the rate; it never depends on server
|
|
7710
|
+
* coalescing. */
|
|
7711
|
+
manifestRefreshState = new Map();
|
|
7712
|
+
static MANIFEST_FETCH_FLOOR_MS = 5_000;
|
|
7713
|
+
/**
|
|
7714
|
+
* §17.5 host processing: fetch the complete manifest, validate exactly as
|
|
7715
|
+
* at initialize, diff, apply §6.7 consequences (removals eagerly even
|
|
7716
|
+
* when other declarations are invalid; additions only through
|
|
7717
|
+
* tell→receipt→activate), emit ONE change receipt (§17.6).
|
|
7718
|
+
*/
|
|
7719
|
+
async handleManifestChanged(connection, announce) {
|
|
7720
|
+
const st = this.manifestRefreshState.get(connection.id)
|
|
7721
|
+
?? { inFlight: false, queued: false, lastCompletedAt: 0 };
|
|
7722
|
+
this.manifestRefreshState.set(connection.id, st);
|
|
7723
|
+
if (st.inFlight) {
|
|
7724
|
+
// Coalesce: one queued refresh re-runs after the current fetch — the
|
|
7725
|
+
// fetch is always of the CURRENT manifest, so N announcements need at
|
|
7726
|
+
// most one trailing fetch (§17.8).
|
|
7727
|
+
st.queued = true;
|
|
7728
|
+
return;
|
|
7729
|
+
}
|
|
7730
|
+
const sinceLast = Date.now() - st.lastCompletedAt;
|
|
7731
|
+
if (sinceLast < AgentFramework.MANIFEST_FETCH_FLOOR_MS) {
|
|
7732
|
+
if (!st.queued) {
|
|
7733
|
+
st.queued = true;
|
|
7734
|
+
setTimeout(() => {
|
|
7735
|
+
const cur = this.manifestRefreshState.get(connection.id);
|
|
7736
|
+
if (cur?.queued && !cur.inFlight) {
|
|
7737
|
+
cur.queued = false;
|
|
7738
|
+
void this.handleManifestChanged(connection, announce).catch(() => { });
|
|
7739
|
+
}
|
|
7740
|
+
}, AgentFramework.MANIFEST_FETCH_FLOOR_MS - sinceLast).unref?.();
|
|
7741
|
+
}
|
|
7742
|
+
return;
|
|
7743
|
+
}
|
|
7744
|
+
st.inFlight = true;
|
|
7745
|
+
try {
|
|
7746
|
+
const config = this.mcplServerConfigs.get(connection.id);
|
|
7747
|
+
if (!config)
|
|
7748
|
+
return;
|
|
7749
|
+
// §17.4 fetch. A server that cannot answer keeps its previous
|
|
7750
|
+
// manifest in force — an unparseable/failed fetch teaches nothing and
|
|
7751
|
+
// narrows nothing (§17.5 step 1).
|
|
7752
|
+
let fetched;
|
|
7753
|
+
try {
|
|
7754
|
+
fetched = await connection.sendManifestRequest();
|
|
7755
|
+
}
|
|
7756
|
+
catch (err) {
|
|
7757
|
+
console.error(`[mcpl] ${connection.id}: mcpl/manifest fetch failed — previous manifest stands (§17.5): ${err instanceof Error ? err.message : err}`);
|
|
7758
|
+
return;
|
|
7759
|
+
}
|
|
7760
|
+
if (!fetched || typeof fetched !== 'object') {
|
|
7761
|
+
console.error(`[mcpl] ${connection.id}: mcpl/manifest returned no object — previous manifest stands (§17.5)`);
|
|
7762
|
+
return;
|
|
7763
|
+
}
|
|
7764
|
+
// Validate exactly as at initialize: expand §5.1 shorthand, apply the
|
|
7765
|
+
// config mask, recompute the grant. Unknown names mint nothing;
|
|
7766
|
+
// invalid declarations fail closed in derivation — and REMOVALS still
|
|
7767
|
+
// apply, because the new grant/derivation simply lacks whatever no
|
|
7768
|
+
// longer validates (§17.5 step 1's partial-invalid rule falls out of
|
|
7769
|
+
// computing forward from the fetched content).
|
|
7770
|
+
const expanded = expandAdvertisementShorthand(fetched);
|
|
7771
|
+
const { capabilities: masked, dropped } = maskNegotiatedCapabilities(expanded, config);
|
|
7772
|
+
const newGrant = computeGrant(masked, config, { mcpToolsAdvertised: connection.mcpToolsAdvertised });
|
|
7773
|
+
const oldEffective = new Set(connection.grant.effectiveList());
|
|
7774
|
+
const newEffective = new Set(newGrant.effectiveList());
|
|
7775
|
+
const revoked = [...oldEffective].filter((p) => !newEffective.has(p)).sort();
|
|
7776
|
+
const added = [...newEffective].filter((p) => !oldEffective.has(p)).sort();
|
|
7777
|
+
const oldDeclared = Object.keys(this.featureSetManager?.getDeclaredFeatureSets(connection.id) ?? {});
|
|
7778
|
+
const oldEnabled = oldDeclared.filter((n) => this.featureSetManager?.isEnabled(connection.id, n));
|
|
7779
|
+
// §6.7 REDUCTION FIRST, atomically, before the server is told:
|
|
7780
|
+
// security cannot wait on consent. The interim grant is new∩old — no
|
|
7781
|
+
// revoked path survives, no added path activates yet.
|
|
7782
|
+
const hadReduction = revoked.length > 0;
|
|
7783
|
+
if (hadReduction) {
|
|
7784
|
+
const interim = new CapabilityGrant(new Set([...newEffective].filter((p) => oldEffective.has(p))), [...newGrant.deniedPaths]);
|
|
7785
|
+
connection.establishGrant(interim);
|
|
7786
|
+
}
|
|
7787
|
+
// PHASE 1 — pre-receipt: live feature state derives from ACTIVE
|
|
7788
|
+
// authority (the interim grant when reduced, the standing grant
|
|
7789
|
+
// otherwise), never from the unactivated full grant. Feature state
|
|
7790
|
+
// must obey the same §6.7 ordering the grant does: a newly added
|
|
7791
|
+
// capability-backed feature set cannot be host-enabled while its
|
|
7792
|
+
// capability is still absent from the active grant (Sol, PR #84
|
|
7793
|
+
// review). Declarations come from the fetched manifest either way —
|
|
7794
|
+
// a set absent from the new manifest is gone regardless.
|
|
7795
|
+
const cfg = { enabledFeatureSets: config.enabledFeatureSets, disabledFeatureSets: config.disabledFeatureSets };
|
|
7796
|
+
const maskedOrEmpty = masked ?? { version: '0.5' };
|
|
7797
|
+
this.featureSetManager.initializeServer(connection.id, maskedOrEmpty, cfg, connection.grant);
|
|
7798
|
+
// The Request DESCRIBES the proposed new policy — derived from the
|
|
7799
|
+
// full new grant on a scratch manager, installing nothing.
|
|
7800
|
+
const updateParams = new FeatureSetManager().initializeServer(connection.id, maskedOrEmpty, cfg, newGrant);
|
|
7801
|
+
updateParams.effectiveCapabilities = newGrant.effectiveList();
|
|
7802
|
+
if (newGrant.deniedPaths.length > 0)
|
|
7803
|
+
updateParams.deniedCapabilities = [...newGrant.deniedPaths];
|
|
7804
|
+
// Tell → receipt → activate (§6.7). PHASE 2 by outcome:
|
|
7805
|
+
// accepted → full grant + full-derived feature state;
|
|
7806
|
+
// refused → honor fallback: 'close' closes the transport;
|
|
7807
|
+
// 'mcp-only' (or absent) = empty grant + empty-derived
|
|
7808
|
+
// feature state, mirroring the §5.3 initial path;
|
|
7809
|
+
// unanswered → interim grant and interim-derived state stand.
|
|
7810
|
+
let negotiated = false;
|
|
7811
|
+
try {
|
|
7812
|
+
const receipt = await connection.sendFeatureSetsUpdateRequest(updateParams);
|
|
7813
|
+
if (receipt && receipt.accepted === false) {
|
|
7814
|
+
const fallback = receipt.fallback ?? 'mcp-only';
|
|
7815
|
+
console.error(`[mcpl] ${connection.id} refused post-manifest policy — fallback: ${fallback}`);
|
|
7816
|
+
if (fallback === 'close') {
|
|
7817
|
+
await connection.close();
|
|
7818
|
+
return;
|
|
7819
|
+
}
|
|
7820
|
+
connection.establishGrant(CapabilityGrant.empty());
|
|
7821
|
+
this.featureSetManager.initializeServer(connection.id, maskedOrEmpty, cfg, CapabilityGrant.empty());
|
|
7822
|
+
}
|
|
7823
|
+
else {
|
|
7824
|
+
connection.establishGrant(newGrant);
|
|
7825
|
+
this.featureSetManager.initializeServer(connection.id, maskedOrEmpty, cfg, newGrant);
|
|
7826
|
+
connection.manifestState.lastNegotiatedAt = Date.now();
|
|
7827
|
+
negotiated = true;
|
|
7828
|
+
}
|
|
7829
|
+
}
|
|
7830
|
+
catch {
|
|
7831
|
+
console.error(`[mcpl] ${connection.id} did not answer post-manifest policy — expansion does not activate (§6.7); interim state stands`);
|
|
7832
|
+
}
|
|
7833
|
+
// Adopt the validated manifest + tracking. droppedCapabilities is
|
|
7834
|
+
// REPLACED unconditionally — an empty new mask must clear stale paths
|
|
7835
|
+
// (Sol, PR #84 review).
|
|
7836
|
+
connection.capabilities = masked;
|
|
7837
|
+
connection.manifestState.lastValidatedRevision =
|
|
7838
|
+
typeof fetched.revision === 'string'
|
|
7839
|
+
? fetched.revision : null;
|
|
7840
|
+
connection.manifestState.lastFetchedAt = Date.now();
|
|
7841
|
+
connection.droppedCapabilities = new Set(dropped);
|
|
7842
|
+
const newDeclared = Object.keys(this.featureSetManager?.getDeclaredFeatureSets(connection.id) ?? {});
|
|
7843
|
+
const newEnabled = newDeclared.filter((n) => this.featureSetManager?.isEnabled(connection.id, n));
|
|
7844
|
+
const degraded = oldEnabled.filter((n) => !newEnabled.includes(n)).sort();
|
|
7845
|
+
const restored = newEnabled.filter((n) => !oldEnabled.includes(n)).sort();
|
|
7846
|
+
// §17.6: ONE receipt per manifest change, closed host-derived impact
|
|
7847
|
+
// vocabulary, never a server-authored flag.
|
|
7848
|
+
const impacts = [
|
|
7849
|
+
...revoked.map((p) => ({ impact: 'capability-revoked', subject: p, disposition: 'applied' })),
|
|
7850
|
+
...added.map((p) => ({ impact: 'capability-expansion-pending', subject: p, disposition: negotiated ? 'applied' : 'decision-needed' })),
|
|
7851
|
+
...degraded.map((n) => ({ impact: 'feature-degraded', subject: n, disposition: 'applied' })),
|
|
7852
|
+
...restored.map((n) => ({ impact: 'feature-restored', subject: n, disposition: negotiated ? 'applied' : 'decision-needed' })),
|
|
7853
|
+
];
|
|
7854
|
+
this.emitTrace({
|
|
7855
|
+
type: 'mcpl:manifest-change-receipt',
|
|
7856
|
+
serverId: connection.id,
|
|
7857
|
+
revision: connection.manifestState.lastValidatedRevision,
|
|
7858
|
+
announcedDomains: announce.domains ?? [],
|
|
7859
|
+
impacts,
|
|
7860
|
+
});
|
|
7861
|
+
this.handleToolsListChanged(connection.id);
|
|
7862
|
+
}
|
|
7863
|
+
finally {
|
|
7864
|
+
st.inFlight = false;
|
|
7865
|
+
st.lastCompletedAt = Date.now();
|
|
7866
|
+
if (st.queued) {
|
|
7867
|
+
st.queued = false;
|
|
7868
|
+
setTimeout(() => { void this.handleManifestChanged(connection, announce).catch(() => { }); }, AgentFramework.MANIFEST_FETCH_FLOOR_MS).unref?.();
|
|
7869
|
+
}
|
|
7870
|
+
}
|
|
7871
|
+
}
|
|
6987
7872
|
/**
|
|
6988
7873
|
* (Re-)establish a server's host-side MCPL registration: feature-set state
|
|
6989
7874
|
* (plus the featureSets/update sent to the server), scope patterns, and
|
|
@@ -6999,20 +7884,62 @@ export class AgentFramework {
|
|
|
6999
7884
|
* already has a tree), so checkpoint trees preserved across a transient
|
|
7000
7885
|
* disconnect are resumed, not reset.
|
|
7001
7886
|
*/
|
|
7002
|
-
registerMcplServerFeatures(config, connection) {
|
|
7887
|
+
async registerMcplServerFeatures(config, connection) {
|
|
7003
7888
|
if (!connection.capabilities)
|
|
7004
7889
|
return;
|
|
7890
|
+
// §5.4: compute the effective grant from the (config-masked)
|
|
7891
|
+
// advertisement. Not yet active — expansion activates only after the
|
|
7892
|
+
// receipt (§6.7); until then the connection's grant is empty and every
|
|
7893
|
+
// privileged inbound method is rejected.
|
|
7894
|
+
const grant = computeGrant(connection.capabilities, config, { mcpToolsAdvertised: connection.mcpToolsAdvertised });
|
|
7005
7895
|
const updateParams = this.featureSetManager.initializeServer(config.id, connection.capabilities, {
|
|
7006
7896
|
enabledFeatureSets: config.enabledFeatureSets,
|
|
7007
7897
|
disabledFeatureSets: config.disabledFeatureSets,
|
|
7008
|
-
});
|
|
7009
|
-
//
|
|
7010
|
-
|
|
7011
|
-
|
|
7898
|
+
}, grant);
|
|
7899
|
+
// §5.3: initial policy MUST be a Request, MUST precede first fan-out,
|
|
7900
|
+
// and MUST be sent even when nothing is enabled or disabled — a server
|
|
7901
|
+
// defaulted to fully disabled has to be told. The response is a
|
|
7902
|
+
// degradation receipt (§6.7): consequence testimony, never policy
|
|
7903
|
+
// authority — nothing in it may widen the grant.
|
|
7904
|
+
updateParams.effectiveCapabilities = grant.effectiveList();
|
|
7905
|
+
if (grant.deniedPaths.length > 0) {
|
|
7906
|
+
updateParams.deniedCapabilities = [...grant.deniedPaths];
|
|
7907
|
+
}
|
|
7908
|
+
try {
|
|
7909
|
+
const receipt = await connection.sendFeatureSetsUpdateRequest(updateParams);
|
|
7910
|
+
if (receipt && receipt.accepted === false) {
|
|
7911
|
+
const fallback = receipt.fallback ?? 'mcp-only';
|
|
7912
|
+
console.error(`[mcpl] ${config.id} refused initial policy (${receipt.reason ?? 'no reason'}) — fallback: ${fallback}`);
|
|
7913
|
+
if (fallback === 'close') {
|
|
7914
|
+
await connection.close();
|
|
7915
|
+
return;
|
|
7916
|
+
}
|
|
7917
|
+
// mcp-only (§3.2): grant stays empty, MCPL privileged surface stays
|
|
7918
|
+
// dark, plain MCP tools keep working. MUST NOT widen in response to
|
|
7919
|
+
// the refusal — a human can change config; the policy engine cannot.
|
|
7920
|
+
return;
|
|
7921
|
+
}
|
|
7922
|
+
// Expansion activates on receipt (§6.7).
|
|
7923
|
+
connection.establishGrant(grant);
|
|
7924
|
+
if (receipt?.accepted === true && receipt.mode === 'degraded') {
|
|
7925
|
+
for (const uf of receipt.unavailableFeatures ?? []) {
|
|
7926
|
+
console.error(`[mcpl] ${config.id} degraded: ${uf.featureSet} (${uf.effect}; missing ${uf.missingCapabilities.join(', ')})`);
|
|
7927
|
+
}
|
|
7928
|
+
}
|
|
7929
|
+
}
|
|
7930
|
+
catch (err) {
|
|
7931
|
+
// A pre-0.5 server treats the Request as a notification and never
|
|
7932
|
+
// answers. §6.7: an unanswered expansion simply does not activate —
|
|
7933
|
+
// grant stays empty, connection is effectively MCP-only. This WILL
|
|
7934
|
+
// dark every un-migrated MCPL server's privileged surface; accepted
|
|
7935
|
+
// deliberately for the single-release rollout (issue #76 item 9).
|
|
7936
|
+
console.error(`[mcpl] ${config.id} did not answer the initial policy Request — ` +
|
|
7937
|
+
`MCPL privileged surface stays disabled (§6.7 unanswered expansion): ` +
|
|
7938
|
+
(err instanceof Error ? err.message : String(err)));
|
|
7939
|
+
return;
|
|
7012
7940
|
}
|
|
7013
|
-
// Configure scope whitelist/blacklist patterns
|
|
7014
7941
|
if (config.scopes) {
|
|
7015
|
-
|
|
7942
|
+
console.error(`[mcpl] ${config.id}: config \`scopes\` is ignored — §7 Scoped Access removed in 0.5.0`);
|
|
7016
7943
|
}
|
|
7017
7944
|
// Register stateful feature sets with checkpoint manager (Step 8)
|
|
7018
7945
|
if (this.checkpointManager) {
|
|
@@ -7109,11 +8036,21 @@ export class AgentFramework {
|
|
|
7109
8036
|
for (const [id, config] of this.mcplServerConfigs) {
|
|
7110
8037
|
const prefix = config.toolPrefix ?? `mcpl--${id}`;
|
|
7111
8038
|
const connection = this.mcplServerRegistry?.getServer(id) ?? null;
|
|
8039
|
+
const connected = connection?.isConnected ?? false;
|
|
7112
8040
|
result.push({
|
|
7113
8041
|
id,
|
|
7114
|
-
connected
|
|
8042
|
+
connected,
|
|
8043
|
+
retrying: !connected && (connection?.willReconnect ?? false),
|
|
7115
8044
|
toolPrefix: prefix,
|
|
7116
8045
|
toolCount: this.mcplTools.filter(t => t.name.startsWith(`${prefix}--`)).length,
|
|
8046
|
+
policyEstablished: connection?.policyEstablished ?? false,
|
|
8047
|
+
effectiveGrant: connection?.grant.effectiveList() ?? [],
|
|
8048
|
+
maskedCapabilities: [...(connection?.droppedCapabilities ?? [])].sort(),
|
|
8049
|
+
deniedCapabilities: [...(connection?.grant.deniedPaths ?? [])].sort(),
|
|
8050
|
+
allowHostCommands: config.allowHostCommands === true,
|
|
8051
|
+
manifestState: connection
|
|
8052
|
+
? { ...connection.manifestState }
|
|
8053
|
+
: { lastValidatedRevision: null, lastFetchedAt: null, lastNegotiatedAt: null },
|
|
7117
8054
|
command: config.command,
|
|
7118
8055
|
url: config.url,
|
|
7119
8056
|
});
|
|
@@ -7130,23 +8067,14 @@ export class AgentFramework {
|
|
|
7130
8067
|
connection.on('stderr', (params) => {
|
|
7131
8068
|
this.emitTrace({ type: 'mcpl:server-stderr', serverId: connection.id, line: params.line });
|
|
7132
8069
|
});
|
|
7133
|
-
//
|
|
7134
|
-
|
|
7135
|
-
|
|
7136
|
-
|
|
8070
|
+
// featureSets/changed is REMOVED in 0.5.0 — the connection answers it
|
|
8071
|
+
// -32601 (superseded by SPEC §17's host-diffed manifest fetch); no
|
|
8072
|
+
// listener, and nothing mutates declarations off a server-authored
|
|
8073
|
+
// change payload.
|
|
7137
8074
|
// Handle scope elevation requests
|
|
7138
|
-
|
|
7139
|
-
|
|
7140
|
-
|
|
7141
|
-
const result = await this.scopeManager.handleElevation(params);
|
|
7142
|
-
responder.respond(result);
|
|
7143
|
-
}
|
|
7144
|
-
catch (error) {
|
|
7145
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
7146
|
-
responder.respondError(-32603, err.message);
|
|
7147
|
-
}
|
|
7148
|
-
}
|
|
7149
|
-
});
|
|
8075
|
+
// §7 Scoped Access is removed in 0.5.0. scope/elevate is answered
|
|
8076
|
+
// -32601 at the connection (server-connection.ts) and never reaches the
|
|
8077
|
+
// framework; the ScopeManager and config `scopes` wiring went with it.
|
|
7150
8078
|
// Handle push events (Step 6)
|
|
7151
8079
|
connection.on('push-event', async (params, responder) => {
|
|
7152
8080
|
const barrier = this.discordAwarenessBarrier;
|
|
@@ -7171,9 +8099,36 @@ export class AgentFramework {
|
|
|
7171
8099
|
connection.on('channels-register', async (params, responder) => {
|
|
7172
8100
|
await this.channelRegistry?.handleRegister(connection.id, params, responder);
|
|
7173
8101
|
});
|
|
7174
|
-
// Handle channel changes (Step 7)
|
|
7175
|
-
|
|
7176
|
-
|
|
8102
|
+
// Handle channel changes (Step 7) — §14.5 dual-mode: the responder is
|
|
8103
|
+
// present for the Request form and carries itemized added-descriptor
|
|
8104
|
+
// results; absent for Notifications, where rejection is itemwise
|
|
8105
|
+
// filtering plus a diagnostic trace.
|
|
8106
|
+
connection.on('channels-changed', async (params, responder) => {
|
|
8107
|
+
await this.channelRegistry?.handleChanged(connection.id, params, responder);
|
|
8108
|
+
});
|
|
8109
|
+
// §6.6/§12: requests that previously had no handler and hung forever
|
|
8110
|
+
// (AUDIT-001 item 4). The connection-level grant gate has already run.
|
|
8111
|
+
// §17.3: a manifest-change announcement. Rate-limited re-fetch → diff
|
|
8112
|
+
// → §6.7 consequences → one receipt. The announcement itself carries no
|
|
8113
|
+
// payload and no authority; everything below acts on FETCHED content.
|
|
8114
|
+
connection.on('manifest-changed', (params) => {
|
|
8115
|
+
void this.handleManifestChanged(connection, params).catch((err) => {
|
|
8116
|
+
console.error(`[mcpl] ${connection.id} manifest refresh failed:`, err instanceof Error ? err.message : err);
|
|
8117
|
+
});
|
|
8118
|
+
});
|
|
8119
|
+
connection.on('channels-list', (_params, responder) => {
|
|
8120
|
+
responder?.respond({
|
|
8121
|
+
channels: this.channelRegistry?.descriptorsForServer(connection.id) ?? [],
|
|
8122
|
+
});
|
|
8123
|
+
});
|
|
8124
|
+
connection.on('model-info', (_params, responder) => {
|
|
8125
|
+
// §12.2 requires ALL FOUR of {id, vendor, contextWindow, capabilities}
|
|
8126
|
+
// in the DIRECT result shape, and numeric contextWindow cannot express
|
|
8127
|
+
// "unknown". The host has no truthful source for it, so modelInfo is
|
|
8128
|
+
// not advertised and this answers an explicit error instead of a
|
|
8129
|
+
// malformed partial (§6.6: a method that will never be answered
|
|
8130
|
+
// truthfully MUST error, not fabricate).
|
|
8131
|
+
responder?.respondError?.(-32601, 'model/info unavailable: host does not advertise modelInfo (no truthful contextWindow source)');
|
|
7177
8132
|
});
|
|
7178
8133
|
// Handle incoming channel messages (Step 7)
|
|
7179
8134
|
connection.on('channels-incoming', async (params, responder) => {
|
|
@@ -7222,17 +8177,23 @@ export class AgentFramework {
|
|
|
7222
8177
|
// reconnect observes it before doing work that could wake an agent. The
|
|
7223
8178
|
// connection paused its data plane before wiring the fresh transport.
|
|
7224
8179
|
const awarenessBarrier = this.installMcplDataPlaneGate();
|
|
7225
|
-
|
|
7226
|
-
|
|
7227
|
-
|
|
7228
|
-
|
|
8180
|
+
// Async: the §5.3 policy Request must be answered (re-establishing the
|
|
8181
|
+
// grant) before the data-plane barrier releases — otherwise the first
|
|
8182
|
+
// post-reconnect events land while the grant is still empty and are
|
|
8183
|
+
// rejected fail-closed instead of delivered.
|
|
8184
|
+
void (async () => {
|
|
8185
|
+
try {
|
|
8186
|
+
const config = this.mcplServerConfigs.get(connection.id);
|
|
8187
|
+
if (config) {
|
|
8188
|
+
await this.registerMcplServerFeatures(config, connection);
|
|
8189
|
+
}
|
|
7229
8190
|
}
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
|
|
7233
|
-
|
|
7234
|
-
|
|
7235
|
-
|
|
8191
|
+
catch (error) {
|
|
8192
|
+
console.error(`MCPL server "${connection.id}" re-registration after reconnect failed:`, error instanceof Error ? error.message : error);
|
|
8193
|
+
}
|
|
8194
|
+
this.releaseMcplDataPlaneGate(awarenessBarrier);
|
|
8195
|
+
this.handleToolsListChanged(connection.id);
|
|
8196
|
+
})();
|
|
7236
8197
|
void awarenessBarrier.promise.then(() => {
|
|
7237
8198
|
if (awarenessBarrier.requiresBarrier
|
|
7238
8199
|
&& !this.completeMcplDataPlaneGate(awarenessBarrier))
|
|
@@ -7328,6 +8289,7 @@ export class AgentFramework {
|
|
|
7328
8289
|
if (!this.mcplServerRegistry)
|
|
7329
8290
|
return;
|
|
7330
8291
|
const tools = [];
|
|
8292
|
+
const toolFeatureSets = new Map();
|
|
7331
8293
|
for (const server of this.mcplServerRegistry.getAllServers()) {
|
|
7332
8294
|
const config = this.mcplServerConfigs.get(server.id);
|
|
7333
8295
|
const prefix = config?.toolPrefix ?? `mcpl--${server.id}`;
|
|
@@ -7336,13 +8298,23 @@ export class AgentFramework {
|
|
|
7336
8298
|
for (const tool of result.tools) {
|
|
7337
8299
|
if (!isToolAllowed(tool.name, config))
|
|
7338
8300
|
continue;
|
|
8301
|
+
const namespacedName = `${prefix}--${tool.name}`;
|
|
8302
|
+
const attributedTool = tool;
|
|
8303
|
+
const featureSet = typeof attributedTool.featureSet === 'string'
|
|
8304
|
+
? attributedTool.featureSet
|
|
8305
|
+
: typeof attributedTool._meta?.featureSet === 'string'
|
|
8306
|
+
? attributedTool._meta.featureSet
|
|
8307
|
+
: undefined;
|
|
7339
8308
|
// MCP tool schemas are generic JSON Schema; cast to membrane's ToolDefinition format
|
|
7340
8309
|
const schema = tool.inputSchema;
|
|
7341
8310
|
tools.push({
|
|
7342
|
-
name:
|
|
8311
|
+
name: namespacedName,
|
|
7343
8312
|
description: tool.description ?? '',
|
|
7344
8313
|
inputSchema: schema,
|
|
7345
8314
|
});
|
|
8315
|
+
if (featureSet !== undefined) {
|
|
8316
|
+
toolFeatureSets.set(namespacedName, featureSet);
|
|
8317
|
+
}
|
|
7346
8318
|
}
|
|
7347
8319
|
}
|
|
7348
8320
|
catch {
|
|
@@ -7350,6 +8322,7 @@ export class AgentFramework {
|
|
|
7350
8322
|
}
|
|
7351
8323
|
}
|
|
7352
8324
|
this.mcplTools = tools;
|
|
8325
|
+
this.mcplToolFeatureSets = toolFeatureSets;
|
|
7353
8326
|
}
|
|
7354
8327
|
/**
|
|
7355
8328
|
* Handle a tools/list_changed notification with collapse logic.
|
|
@@ -7436,21 +8409,41 @@ export class AgentFramework {
|
|
|
7436
8409
|
const startTime = Date.now();
|
|
7437
8410
|
const args = (call.input && typeof call.input === 'object') ? call.input : {};
|
|
7438
8411
|
// Build state params for stateful tools (Step 8).
|
|
7439
|
-
//
|
|
7440
|
-
//
|
|
7441
|
-
//
|
|
7442
|
-
// mixes host-managed (e.g. a feed) and server-managed (e.g. post/undo) sets,
|
|
7443
|
-
// that misattributes the feed's state.
|
|
8412
|
+
// A tools/list attribution selects that exact stateful set. Untagged tools
|
|
8413
|
+
// retain the host-managed or unique server-managed fallback, but never guess
|
|
8414
|
+
// between multiple server-managed sets.
|
|
7444
8415
|
let stateParams;
|
|
8416
|
+
let attributedFeatureSet = null;
|
|
7445
8417
|
if (this.checkpointManager) {
|
|
7446
|
-
const
|
|
7447
|
-
if (
|
|
7448
|
-
|
|
8418
|
+
const toolFeatureSet = this.mcplToolFeatureSets.get(call.name);
|
|
8419
|
+
if (toolFeatureSet !== undefined) {
|
|
8420
|
+
if (this.checkpointManager.isHostManaged(serverId, toolFeatureSet)
|
|
8421
|
+
|| this.checkpointManager.getStatefulFeatureSet(serverId, toolFeatureSet) !== null) {
|
|
8422
|
+
attributedFeatureSet = toolFeatureSet;
|
|
8423
|
+
}
|
|
8424
|
+
else {
|
|
8425
|
+
console.warn(`[mcpl] Tool "${call.name}" declares unknown or non-stateful feature set ` +
|
|
8426
|
+
`"${toolFeatureSet}"; skipping state exchange.`);
|
|
8427
|
+
}
|
|
7449
8428
|
}
|
|
7450
8429
|
else {
|
|
7451
|
-
|
|
7452
|
-
|
|
7453
|
-
|
|
8430
|
+
attributedFeatureSet = this.checkpointManager.getHostManagedFeatureSet(serverId)
|
|
8431
|
+
?? this.checkpointManager.getStatefulFeatureSet(serverId);
|
|
8432
|
+
if (attributedFeatureSet === null
|
|
8433
|
+
&& this.checkpointManager.hasAmbiguousServerManagedFeatureSets(serverId)) {
|
|
8434
|
+
console.warn(`[mcpl] Tool "${call.name}" has no feature-set attribution and server ` +
|
|
8435
|
+
`"${serverId}" has multiple server-managed stateful feature sets; ` +
|
|
8436
|
+
'skipping state exchange.');
|
|
8437
|
+
}
|
|
8438
|
+
}
|
|
8439
|
+
if (attributedFeatureSet !== null) {
|
|
8440
|
+
if (this.checkpointManager.isHostManaged(serverId, attributedFeatureSet)) {
|
|
8441
|
+
stateParams = {
|
|
8442
|
+
state: this.checkpointManager.getCurrentState(serverId, attributedFeatureSet),
|
|
8443
|
+
};
|
|
8444
|
+
}
|
|
8445
|
+
else {
|
|
8446
|
+
const cp = this.checkpointManager.getCurrentCheckpoint(serverId, attributedFeatureSet);
|
|
7454
8447
|
if (cp)
|
|
7455
8448
|
stateParams = { checkpoint: cp };
|
|
7456
8449
|
}
|
|
@@ -7461,15 +8454,10 @@ export class AgentFramework {
|
|
|
7461
8454
|
const durationMs = Date.now() - startTime;
|
|
7462
8455
|
this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs });
|
|
7463
8456
|
// Record checkpoint from stateful tool response (Step 8).
|
|
7464
|
-
//
|
|
7465
|
-
//
|
|
7466
|
-
// else the single stateful set. Avoids recording a feed checkpoint onto a
|
|
7467
|
-
// server-managed set just because it registered first.
|
|
8457
|
+
// Explicit response attribution is authoritative. Otherwise use the
|
|
8458
|
+
// tool-derived (or safe single-set) attribution chosen before the call.
|
|
7468
8459
|
if (result.state && this.checkpointManager) {
|
|
7469
|
-
const
|
|
7470
|
-
const fs = tagged
|
|
7471
|
-
?? this.checkpointManager.getHostManagedFeatureSet(serverId)
|
|
7472
|
-
?? this.checkpointManager.getStatefulFeatureSet(serverId);
|
|
8460
|
+
const fs = result.state.featureSet ?? attributedFeatureSet;
|
|
7473
8461
|
if (fs && this.checkpointManager.isStateful(serverId, fs)) {
|
|
7474
8462
|
this.checkpointManager.recordCheckpoint(serverId, fs, result.state);
|
|
7475
8463
|
}
|
|
@@ -7490,6 +8478,23 @@ export class AgentFramework {
|
|
|
7490
8478
|
this.channelRegistry
|
|
7491
8479
|
.openIfClosedForSend(target, serverId)
|
|
7492
8480
|
.then(({ status, channelId, label }) => {
|
|
8481
|
+
// Turn-scoped engagement record (mid-turn re-pin, 2026-07-31
|
|
8482
|
+
// n=7): a human follow-up in a channel the agent explicitly
|
|
8483
|
+
// sent into this turn counts as conversationally addressed.
|
|
8484
|
+
// Recorded here — not eagerly — because injection metadata
|
|
8485
|
+
// carries the CANONICAL channel id (descriptor form), while
|
|
8486
|
+
// send args often carry the raw provider id; this callback
|
|
8487
|
+
// has the resolved form. The already-open path resolves on
|
|
8488
|
+
// the microtask queue, i.e. before the tool-result event
|
|
8489
|
+
// that evaluates injections.
|
|
8490
|
+
if (status !== 'unknown-channel' && status !== 'ambiguous') {
|
|
8491
|
+
let engaged = this.turnEngagedChannels.get(agentName);
|
|
8492
|
+
if (!engaged) {
|
|
8493
|
+
engaged = new Set();
|
|
8494
|
+
this.turnEngagedChannels.set(agentName, engaged);
|
|
8495
|
+
}
|
|
8496
|
+
engaged.add(channelId ?? target);
|
|
8497
|
+
}
|
|
7493
8498
|
if (status === 'opened') {
|
|
7494
8499
|
console.error(`[channel] ${agentName}: explicit ${toolName} into closed channel ${target} — opened (send implies engagement)`);
|
|
7495
8500
|
this.recordChannelAutoOpenNotice(agentName, [{ channelId: channelId ?? target, label }], 'opened-by-reply');
|
|
@@ -7592,17 +8597,37 @@ export class AgentFramework {
|
|
|
7592
8597
|
const durationMs = Date.now() - startTime;
|
|
7593
8598
|
this.emitTrace({ type: 'tool:completed', module: 'channels', tool: call.name, callId: call.id, durationMs });
|
|
7594
8599
|
// A successful channel_open plants the agent's feet in the opened
|
|
7595
|
-
// channel
|
|
7596
|
-
//
|
|
7597
|
-
//
|
|
7598
|
-
//
|
|
7599
|
-
//
|
|
7600
|
-
//
|
|
8600
|
+
// channel. The agent's own deliberate open is the strongest "my next
|
|
8601
|
+
// words go here" signal in the system — stronger than any injection —
|
|
8602
|
+
// so it MOVES the current turn's prose pin (turnLocusPins), not just
|
|
8603
|
+
// the next turn's trigger state. (The original 2026-07-21 Aria fix
|
|
8604
|
+
// set only activeTriggerChannels, which resolveLocus reads at the
|
|
8605
|
+
// NEXT turn's freeze — the turn-frozen refactor had silently
|
|
8606
|
+
// regressed the "reply right after opening" case; found while
|
|
8607
|
+
// mapping the routing logic 2026-07-31, antra-ratified fix.) The
|
|
8608
|
+
// announcement rides THIS TOOL RESULT — model-requested content,
|
|
8609
|
+
// distance zero, the safest role there is — instead of a separate
|
|
8610
|
+
// window notice. lastAnnouncedLocus tracks it so announce-on-change
|
|
8611
|
+
// stays coherent. Locus-mode agents only: explicit mode has no pin.
|
|
7601
8612
|
if (call.name === 'channel_open' && result?.success) {
|
|
7602
8613
|
const opened = result.data?.channelId
|
|
7603
8614
|
?? call.input?.channelId;
|
|
7604
|
-
if (opened)
|
|
8615
|
+
if (opened) {
|
|
7605
8616
|
this.activeTriggerChannels.set(agentName, opened);
|
|
8617
|
+
const openerAgent = this.agents.get(agentName);
|
|
8618
|
+
if (openerAgent && openerAgent.proseRouting !== 'explicit') {
|
|
8619
|
+
this.turnLocusPins.set(agentName, opened);
|
|
8620
|
+
this.lastAnnouncedLocus.set(agentName, opened);
|
|
8621
|
+
result = {
|
|
8622
|
+
...result,
|
|
8623
|
+
data: {
|
|
8624
|
+
...result.data,
|
|
8625
|
+
routing: 'Your plain speech now lands in this channel. Other channels need an explicit send tool.',
|
|
8626
|
+
},
|
|
8627
|
+
};
|
|
8628
|
+
console.error(`[routing] ${agentName}: channel_open -> pin moved to ${opened} (announced in tool result)`);
|
|
8629
|
+
}
|
|
8630
|
+
}
|
|
7606
8631
|
}
|
|
7607
8632
|
this.pushEvent({
|
|
7608
8633
|
type: 'tool-result',
|
|
@@ -7684,17 +8709,70 @@ export class AgentFramework {
|
|
|
7684
8709
|
: input.rateLimit !== undefined ? { rate_limit: input.rateLimit }
|
|
7685
8710
|
: input.passiveSample !== undefined ? { passive_sample: input.passiveSample }
|
|
7686
8711
|
: input.behavior;
|
|
7687
|
-
|
|
8712
|
+
// Placement: at most one of position / insertBefore / insertAfter.
|
|
8713
|
+
// With none given, a replaced rule stays where it was and a new rule
|
|
8714
|
+
// appends (addPolicy's contract).
|
|
8715
|
+
const placementSources = [
|
|
8716
|
+
input.position !== undefined ? 'position' : null,
|
|
8717
|
+
input.insertBefore !== undefined ? 'insertBefore' : null,
|
|
8718
|
+
input.insertAfter !== undefined ? 'insertAfter' : null,
|
|
8719
|
+
].filter((s) => s !== null);
|
|
8720
|
+
if (placementSources.length > 1) {
|
|
8721
|
+
finish({
|
|
8722
|
+
success: false,
|
|
8723
|
+
error: `wake_add_rule: give at most one placement, got [${placementSources.join(', ')}].`,
|
|
8724
|
+
isError: true,
|
|
8725
|
+
});
|
|
8726
|
+
return;
|
|
8727
|
+
}
|
|
8728
|
+
let position;
|
|
8729
|
+
if (typeof input.insertBefore === 'string' && input.insertBefore) {
|
|
8730
|
+
position = { before: input.insertBefore };
|
|
8731
|
+
}
|
|
8732
|
+
else if (typeof input.insertAfter === 'string' && input.insertAfter) {
|
|
8733
|
+
position = { after: input.insertAfter };
|
|
8734
|
+
}
|
|
8735
|
+
else if (input.position === 'prepend' || input.position === 'append') {
|
|
8736
|
+
position = input.position;
|
|
8737
|
+
}
|
|
7688
8738
|
const rawPolicy = {
|
|
7689
8739
|
name: input.name,
|
|
7690
8740
|
match: input.match ?? {},
|
|
7691
8741
|
behavior,
|
|
8742
|
+
...(input.passthrough !== undefined ? { passthrough: input.passthrough } : {}),
|
|
7692
8743
|
...(input.resets !== undefined ? { resets: input.resets } : {}),
|
|
7693
8744
|
};
|
|
7694
|
-
|
|
8745
|
+
// Before/after probe table + shadow lint: the moment a rule changes the
|
|
8746
|
+
// evaluation order is the moment its real semantics must be legible
|
|
8747
|
+
// (af#105 — a prepended catch-all sampler silently disabled the
|
|
8748
|
+
// addressed-message wakes for 12h before anyone could see why).
|
|
8749
|
+
const before = new Map(this.eventGate.probeTable().map((r) => [r.probe, r]));
|
|
8750
|
+
const policy = this.addGatePolicy(rawPolicy, position ? { position } : undefined);
|
|
8751
|
+
const after = this.eventGate.probeTable();
|
|
8752
|
+
const probes = after.map((r) => {
|
|
8753
|
+
const prev = before.get(r.probe);
|
|
8754
|
+
const changed = !!prev && (prev.policy !== r.policy || prev.wouldWake !== r.wouldWake);
|
|
8755
|
+
return {
|
|
8756
|
+
probe: r.probe,
|
|
8757
|
+
rule: r.policy ?? '(default)',
|
|
8758
|
+
behavior: r.behavior,
|
|
8759
|
+
wouldWake: r.wouldWake,
|
|
8760
|
+
...(changed ? { changed: true, was: `${prev.policy ?? '(default)'} (wouldWake=${prev.wouldWake})` } : {}),
|
|
8761
|
+
};
|
|
8762
|
+
});
|
|
8763
|
+
const shadowWarnings = this.eventGate.lintShadows()
|
|
8764
|
+
.map((w) => formatShadowWarning(w));
|
|
7695
8765
|
finish({
|
|
7696
8766
|
success: true,
|
|
7697
|
-
data: {
|
|
8767
|
+
data: {
|
|
8768
|
+
added: policy.name,
|
|
8769
|
+
policies: this.getGatePolicyNames(),
|
|
8770
|
+
...(shadowWarnings.length > 0 ? { shadowWarnings } : {}),
|
|
8771
|
+
probes,
|
|
8772
|
+
...(probes.some((p) => 'changed' in p)
|
|
8773
|
+
? {}
|
|
8774
|
+
: { 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.' }),
|
|
8775
|
+
},
|
|
7698
8776
|
});
|
|
7699
8777
|
}
|
|
7700
8778
|
catch (err) {
|
|
@@ -8314,12 +9392,11 @@ export class AgentFramework {
|
|
|
8314
9392
|
conversationId: agent.name,
|
|
8315
9393
|
turnIndex: 0, // Simplified; needs per-conversation counter TODO
|
|
8316
9394
|
userMessage: null, // Could extract from trigger context
|
|
8317
|
-
|
|
8318
|
-
|
|
8319
|
-
|
|
8320
|
-
|
|
8321
|
-
|
|
8322
|
-
},
|
|
9395
|
+
// Truthful fields only (§10.1 honesty over completeness): id is real,
|
|
9396
|
+
// vendor/contextWindow/capabilities have no source in this host and
|
|
9397
|
+
// are omitted rather than fabricated — the hardcoded 200000 was false
|
|
9398
|
+
// for residents configured at 300k/600k.
|
|
9399
|
+
model: { id: agent.model },
|
|
8323
9400
|
channels: this.channelRegistry?.buildChannelContext(agent.name),
|
|
8324
9401
|
};
|
|
8325
9402
|
}
|