@animalabs/agent-framework 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/dist/src/agent.d.ts +10 -0
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +33 -3
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +171 -15
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +1059 -224
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/gate/event-gate.d.ts +11 -0
  10. package/dist/src/gate/event-gate.d.ts.map +1 -1
  11. package/dist/src/gate/event-gate.js +66 -0
  12. package/dist/src/gate/event-gate.js.map +1 -1
  13. package/dist/src/index.d.ts +1 -0
  14. package/dist/src/index.d.ts.map +1 -1
  15. package/dist/src/index.js +1 -0
  16. package/dist/src/index.js.map +1 -1
  17. package/dist/src/mcpl/capability-grant.d.ts +115 -0
  18. package/dist/src/mcpl/capability-grant.d.ts.map +1 -0
  19. package/dist/src/mcpl/capability-grant.js +291 -0
  20. package/dist/src/mcpl/capability-grant.js.map +1 -0
  21. package/dist/src/mcpl/capability-mask.d.ts +51 -0
  22. package/dist/src/mcpl/capability-mask.d.ts.map +1 -0
  23. package/dist/src/mcpl/capability-mask.js +190 -0
  24. package/dist/src/mcpl/capability-mask.js.map +1 -0
  25. package/dist/src/mcpl/channel-registry.d.ts +3 -1
  26. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  27. package/dist/src/mcpl/channel-registry.js +163 -36
  28. package/dist/src/mcpl/channel-registry.js.map +1 -1
  29. package/dist/src/mcpl/checkpoint-manager.d.ts +8 -3
  30. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  31. package/dist/src/mcpl/checkpoint-manager.js +28 -8
  32. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  33. package/dist/src/mcpl/errors.d.ts +6 -0
  34. package/dist/src/mcpl/errors.d.ts.map +1 -1
  35. package/dist/src/mcpl/errors.js +6 -0
  36. package/dist/src/mcpl/errors.js.map +1 -1
  37. package/dist/src/mcpl/feature-set-manager.d.ts +10 -1
  38. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  39. package/dist/src/mcpl/feature-set-manager.js +45 -4
  40. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  41. package/dist/src/mcpl/hook-orchestrator.d.ts +10 -14
  42. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  43. package/dist/src/mcpl/hook-orchestrator.js +58 -67
  44. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  45. package/dist/src/mcpl/index.d.ts +1 -1
  46. package/dist/src/mcpl/index.d.ts.map +1 -1
  47. package/dist/src/mcpl/index.js.map +1 -1
  48. package/dist/src/mcpl/prose-stream-router.d.ts +9 -0
  49. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
  50. package/dist/src/mcpl/prose-stream-router.js +16 -0
  51. package/dist/src/mcpl/prose-stream-router.js.map +1 -1
  52. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  53. package/dist/src/mcpl/push-handler.js +20 -2
  54. package/dist/src/mcpl/push-handler.js.map +1 -1
  55. package/dist/src/mcpl/server-connection.d.ts +93 -9
  56. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  57. package/dist/src/mcpl/server-connection.js +231 -32
  58. package/dist/src/mcpl/server-connection.js.map +1 -1
  59. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  60. package/dist/src/mcpl/server-registry.js +4 -3
  61. package/dist/src/mcpl/server-registry.js.map +1 -1
  62. package/dist/src/mcpl/tags.d.ts +24 -0
  63. package/dist/src/mcpl/tags.d.ts.map +1 -0
  64. package/dist/src/mcpl/tags.js +52 -0
  65. package/dist/src/mcpl/tags.js.map +1 -0
  66. package/dist/src/mcpl/transport.d.ts.map +1 -1
  67. package/dist/src/mcpl/transport.js +10 -0
  68. package/dist/src/mcpl/transport.js.map +1 -1
  69. package/dist/src/mcpl/types.d.ts +159 -64
  70. package/dist/src/mcpl/types.d.ts.map +1 -1
  71. package/dist/src/mcpl/types.js +16 -2
  72. package/dist/src/mcpl/types.js.map +1 -1
  73. package/dist/src/refusal-reactions.d.ts +27 -0
  74. package/dist/src/refusal-reactions.d.ts.map +1 -0
  75. package/dist/src/refusal-reactions.js +35 -0
  76. package/dist/src/refusal-reactions.js.map +1 -0
  77. package/dist/src/tool-result-history.d.ts +10 -0
  78. package/dist/src/tool-result-history.d.ts.map +1 -1
  79. package/dist/src/tool-result-history.js +16 -5
  80. package/dist/src/tool-result-history.js.map +1 -1
  81. package/dist/src/types/agent.d.ts +35 -0
  82. package/dist/src/types/agent.d.ts.map +1 -1
  83. package/dist/src/types/framework.d.ts +16 -0
  84. package/dist/src/types/framework.d.ts.map +1 -1
  85. package/dist/src/types/trace.d.ts +24 -0
  86. package/dist/src/types/trace.d.ts.map +1 -1
  87. package/dist/test/channel-registry-routing.test.js +38 -10
  88. package/dist/test/channel-registry-routing.test.js.map +1 -1
  89. package/dist/test/channel-registry-typing.test.js +10 -1
  90. package/dist/test/channel-registry-typing.test.js.map +1 -1
  91. package/dist/test/checkpoint-attribution.test.js +168 -8
  92. package/dist/test/checkpoint-attribution.test.js.map +1 -1
  93. package/dist/test/conversation-fork-injection-scoping.test.js +8 -3
  94. package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
  95. package/dist/test/event-gate-selfwake.test.d.ts +15 -0
  96. package/dist/test/event-gate-selfwake.test.d.ts.map +1 -0
  97. package/dist/test/event-gate-selfwake.test.js +125 -0
  98. package/dist/test/event-gate-selfwake.test.js.map +1 -0
  99. package/dist/test/machine-close-provenance.test.js +6 -0
  100. package/dist/test/machine-close-provenance.test.js.map +1 -1
  101. package/dist/test/mcpl-awareness-barrier.test.js +10 -0
  102. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
  103. package/dist/test/mcpl-capability-grant.test.d.ts +10 -0
  104. package/dist/test/mcpl-capability-grant.test.d.ts.map +1 -0
  105. package/dist/test/mcpl-capability-grant.test.js +109 -0
  106. package/dist/test/mcpl-capability-grant.test.js.map +1 -0
  107. package/dist/test/mcpl-capability-scoping.test.d.ts +2 -0
  108. package/dist/test/mcpl-capability-scoping.test.d.ts.map +1 -0
  109. package/dist/test/mcpl-capability-scoping.test.js +266 -0
  110. package/dist/test/mcpl-capability-scoping.test.js.map +1 -0
  111. package/dist/test/mcpl-gate-roundtrip.test.js +13 -0
  112. package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
  113. package/dist/test/mcpl-list-status.test.d.ts +2 -0
  114. package/dist/test/mcpl-list-status.test.d.ts.map +1 -0
  115. package/dist/test/mcpl-list-status.test.js +78 -0
  116. package/dist/test/mcpl-list-status.test.js.map +1 -0
  117. package/dist/test/mcpl-manifest-changed.test.d.ts +2 -0
  118. package/dist/test/mcpl-manifest-changed.test.d.ts.map +1 -0
  119. package/dist/test/mcpl-manifest-changed.test.js +177 -0
  120. package/dist/test/mcpl-manifest-changed.test.js.map +1 -0
  121. package/dist/test/mcpl-reconnect-events.test.js +27 -2
  122. package/dist/test/mcpl-reconnect-events.test.js.map +1 -1
  123. package/dist/test/mcpl-reconnect-reregistration.test.js +53 -17
  124. package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
  125. package/dist/test/mcpl-request-timeout.test.js +1 -1
  126. package/dist/test/mcpl-request-timeout.test.js.map +1 -1
  127. package/dist/test/physical-window-restart.test.d.ts +14 -0
  128. package/dist/test/physical-window-restart.test.d.ts.map +1 -0
  129. package/dist/test/physical-window-restart.test.js +222 -0
  130. package/dist/test/physical-window-restart.test.js.map +1 -0
  131. package/dist/test/present-while-acting.test.js +199 -1
  132. package/dist/test/present-while-acting.test.js.map +1 -1
  133. package/dist/test/refusal-reaction-baseline.test.d.ts +2 -0
  134. package/dist/test/refusal-reaction-baseline.test.d.ts.map +1 -0
  135. package/dist/test/refusal-reaction-baseline.test.js +74 -0
  136. package/dist/test/refusal-reaction-baseline.test.js.map +1 -0
  137. package/dist/test/state-scaling-e2e.test.js +7 -1
  138. package/dist/test/state-scaling-e2e.test.js.map +1 -1
  139. package/dist/test/state-scaling.test.js +9 -1
  140. package/dist/test/state-scaling.test.js.map +1 -1
  141. package/dist/test/tool-result-spill.test.d.ts +13 -0
  142. package/dist/test/tool-result-spill.test.d.ts.map +1 -0
  143. package/dist/test/tool-result-spill.test.js +463 -0
  144. package/dist/test/tool-result-spill.test.js.map +1 -0
  145. package/dist/test/trunk-channel-routing.test.js +45 -0
  146. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  147. package/dist/tsconfig.tsbuildinfo +1 -1
  148. package/package.json +4 -4
  149. package/src/agent.ts +37 -3
  150. package/src/framework.ts +1219 -248
  151. package/src/gate/event-gate.ts +72 -0
  152. package/src/index.ts +1 -0
  153. package/src/mcpl/capability-grant.ts +319 -0
  154. package/src/mcpl/capability-mask.ts +228 -0
  155. package/src/mcpl/channel-registry.ts +167 -39
  156. package/src/mcpl/checkpoint-manager.ts +28 -8
  157. package/src/mcpl/errors.ts +7 -0
  158. package/src/mcpl/feature-set-manager.ts +50 -5
  159. package/src/mcpl/hook-orchestrator.ts +64 -86
  160. package/src/mcpl/index.ts +0 -2
  161. package/src/mcpl/prose-stream-router.ts +17 -0
  162. package/src/mcpl/push-handler.ts +18 -2
  163. package/src/mcpl/server-connection.ts +262 -34
  164. package/src/mcpl/server-registry.ts +4 -3
  165. package/src/mcpl/tags.ts +50 -0
  166. package/src/mcpl/transport.ts +11 -0
  167. package/src/mcpl/types.ts +176 -78
  168. package/src/refusal-reactions.ts +37 -0
  169. package/src/tool-result-history.ts +17 -5
  170. package/src/types/agent.ts +36 -0
  171. package/src/types/framework.ts +17 -0
  172. package/src/types/trace.ts +22 -0
package/src/framework.ts CHANGED
@@ -37,6 +37,7 @@ import type {
37
37
  ToolCall,
38
38
  ToolCallEvent,
39
39
  ToolResult,
40
+ CompletedToolCall,
40
41
  AgentConfig,
41
42
  InferenceRequest,
42
43
  AgentState,
@@ -52,11 +53,13 @@ import type {
52
53
  SameRoundThinkTextPolicy,
53
54
  } from './types/index.js';
54
55
  import { ProcessQueueImpl } from './queue.js';
56
+ import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK } from './refusal-reactions.js';
55
57
  import { Agent } from './agent.js';
56
58
  import { ModuleRegistry, isStateExistsError } from './module-registry.js';
57
59
  import { McplServerRegistry } from './mcpl/server-registry.js';
58
60
  import { FeatureSetManager } from './mcpl/feature-set-manager.js';
59
- import { ScopeManager } from './mcpl/scope-manager.js';
61
+ import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './mcpl/capability-grant.js';
62
+ import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js';
60
63
  import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
61
64
  import { PushHandler, type McplPushEvent } from './mcpl/push-handler.js';
62
65
  import { parseProsePrefix } from './mcpl/prose-grammar.js';
@@ -66,7 +69,11 @@ import { ChannelRegistry, type ChannelToolOrigin } from './mcpl/channel-registry
66
69
  import { ConversationRouter } from './mcpl/conversation-router.js';
67
70
  import { safeSlice } from './safe-slice.js';
68
71
  import type { WorkspaceModule } from './modules/workspace/index.js';
69
- import { toolResultDataToHistoryString } from './tool-result-history.js';
72
+ import {
73
+ toolResultDataToHistoryString,
74
+ truncateForHistory,
75
+ DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS,
76
+ } from './tool-result-history.js';
70
77
  import { randomUUID } from 'node:crypto';
71
78
  import { PyRunner, buildInjectedTools } from './code-execution/py-runner.js';
72
79
  import {
@@ -134,6 +141,43 @@ const isConversationalInjection = (metadata?: MessageMetadata): boolean => {
134
141
  return true;
135
142
  };
136
143
 
144
+ /**
145
+ * Rough token estimate for the blocks a continuation round is about to
146
+ * append to the live wire (issue #92 physical-window projection): the
147
+ * spilled tool-result strings at chars/4, a flat per-image cost for native
148
+ * image blocks the wire path preserves (provider vision tokens are
149
+ * resolution-dependent; 1600 ≈ a max-size Anthropic tile), mid-turn injected
150
+ * text at chars/4, and a small per-block envelope allowance. Deliberately a
151
+ * ceiling-ish heuristic — the cost of overestimating is one early recompile;
152
+ * the cost of underestimating is the 400 this projection exists to prevent.
153
+ */
154
+ function estimateAppendedRoundTokens(
155
+ toolResults: CompletedToolCall[],
156
+ spilled: Map<string, { text: string; filePath: string | null }>,
157
+ injections: Array<{ content: ContentBlock[] }>,
158
+ ): number {
159
+ let chars = 0;
160
+ let images = 0;
161
+ for (const tc of toolResults) {
162
+ chars += spilled.get(tc.id)?.text.length ?? 0;
163
+ if (Array.isArray(tc.result.data)) {
164
+ for (const raw of tc.result.data) {
165
+ const b = raw as { type?: unknown };
166
+ if (b && typeof b === 'object' && b.type === 'image') images += 1;
167
+ }
168
+ }
169
+ }
170
+ for (const inj of injections) {
171
+ for (const block of inj.content) {
172
+ if (block.type === 'text' && typeof (block as { text?: unknown }).text === 'string') {
173
+ chars += (block as { text: string }).text.length;
174
+ }
175
+ }
176
+ }
177
+ const blockCount = toolResults.length + injections.length;
178
+ return Math.ceil(chars / 4) + images * 1600 + blockCount * 20;
179
+ }
180
+
137
181
  /**
138
182
  * Explicit delivery tools whose successful use into a closed channel OPENS
139
183
  * it (send implies engagement — see openIfClosedForSend). Bare tool names,
@@ -293,10 +337,7 @@ import type {
293
337
  McplServerConfig,
294
338
  McplHostCapabilities,
295
339
  FeatureSetsChangedParams,
296
- ScopeElevateParams,
297
- ScopeElevateResult,
298
340
  BeforeInferenceParams,
299
- AfterInferenceParams,
300
341
  PushEventParams,
301
342
  McplInferenceRequestParams,
302
343
  ChannelsRegisterParams,
@@ -587,8 +628,45 @@ export class AgentFramework {
587
628
  * plain prose lands (2026-07-21 Cairn lounge misroute). Lives here — not in
588
629
  * driveStream locals — so the pin survives a context-budget stream restart,
589
630
  * which continues the same logical turn in a fresh driveStream. Cleared or
590
- * re-set at the start of every non-restart turn. */
631
+ * re-set at the start of every non-restart turn. Since 2026-07-31 the pin
632
+ * is no longer fully frozen: an ADDRESSED conversational injection — or a
633
+ * conversational follow-up in a channel the agent itself explicitly sent
634
+ * into this turn (turnEngagedChannels) — re-pins it at the boundary (see
635
+ * the addressed re-pin block in the tool-result handler). Ambient chatter
636
+ * elsewhere, reactions, and system markers still cannot move it. */
591
637
  private turnLocusPins: Map<string, string> = new Map();
638
+ /** Channels the agent EXPLICITLY sent into during the CURRENT turn
639
+ * (SEND_ENGAGEMENT_TOOLS, successful calls), per agent. Second re-pin
640
+ * signal: a conversational reply in a channel the agent just engaged is
641
+ * addressed by context even without a mention (2026-07-31 n=7: q's
642
+ * #portables follow-up — no @mention — was answered in trailing prose
643
+ * that followed the stale pin into repligate's DM). No author-kind
644
+ * filter: agent-residents are full participants, and the bot flag
645
+ * tracks nothing that matters (antra). Reactions/system markers are
646
+ * excluded by isConversationalInjection; the engaged-this-turn scope
647
+ * keeps unrelated channels from moving the pin. Cleared at every fresh
648
+ * turn's start; budget restarts keep it. */
649
+ private turnEngagedChannels: Map<string, Set<string>> = new Map();
650
+ /** Channels this turn's PLAIN PROSE was actually delivered to, in delivery
651
+ * order (deduped at render). Feeds the `[delivered]` receipt appended at
652
+ * logical turn end: explicit sends receipt themselves via their
653
+ * tool_result, but auto-routed prose previously vanished into the router
654
+ * with no in-window record — the agent could never see where its own
655
+ * words landed (2026-07-31 misroute series; antra: minimal receipts).
656
+ * Cleared at every fresh turn's start; budget restarts keep it (same
657
+ * logical turn, receipt covers the whole turn). */
658
+ private turnProseDeliveries: Map<string, string[]> = new Map();
659
+ /** Count of plain-prose segments SUPPRESSED this turn by sticky
660
+ * explicit-send silencing. The suppression rule itself is untouched
661
+ * (antra, 2026-07-31: visibility is enough) — but it must never be a
662
+ * silent black hole: Mythos multiplexed two threads in one round (prose
663
+ * for the lane + explicit send for another channel, textbook per the
664
+ * routing doc), the silencing ate the prose reply, and his own record
665
+ * ("scene delivered to #mythos via plain speech — routing confirmed")
666
+ * believed it delivered. The receipt now reports suppression, so the
667
+ * author sees the segment's fate one turn later. Cleared each fresh
668
+ * turn; budget restarts keep it. */
669
+ private turnProseSuppressed: Map<string, number> = new Map();
592
670
  /** A tool boundary injected fresh CONVERSATIONAL input (a real message —
593
671
  * not a reaction or a system marker) into the live stream. Tells
594
672
  * driveStream to clear sticky explicit-send suppression before handling
@@ -708,7 +786,6 @@ export class AgentFramework {
708
786
  // MCPL subsystems (null when no mcplServers configured)
709
787
  private mcplServerRegistry: McplServerRegistry | null = null;
710
788
  private featureSetManager: FeatureSetManager | null = null;
711
- private scopeManager: ScopeManager | null = null;
712
789
  private hookOrchestrator: HookOrchestrator | null = null;
713
790
  private pushHandler: PushHandler | null = null;
714
791
  private inferenceRouter: InferenceRouter | null = null;
@@ -746,12 +823,18 @@ export class AgentFramework {
746
823
  * provenance envelope + payload and requests inference. Keyed by script id. */
747
824
  private backgroundScripts: Map<string, BackgroundScriptRecord> = new Map();
748
825
  private backgroundScriptCounter = 0;
749
- /** Ephemeral per-agent override of the tool-result inline cap (chars).
750
- * Set via agent_settings `tool_result_inline_max_chars`; NOT persisted —
751
- * the lift is meant as a temporary gate, reverts on restart/reset. */
826
+ /** Resident-set per-agent tool-result inline cap (chars), via
827
+ * agent_settings `tool_result_inline_max_chars`. DURABLE: persisted in
828
+ * framework state like the core runtime settings and restored at create
829
+ * (antra + Sol, 08-06); reset clears it back to the residence default. */
752
830
  private toolResultInlineMaxCharsOverride: Map<string, number> = new Map();
831
+ /** Durable residence-configured inline cap from
832
+ * FrameworkConfig.toolResultInlineMaxChars; null → house default. */
833
+ private toolResultInlineMaxCharsConfig: number | null = null;
753
834
 
754
835
  private mcplTools: import('./types/index.js').ToolDefinition[] = [];
836
+ /** Namespaced tool name → stateful feature-set attribution from tools/list. */
837
+ private mcplToolFeatureSets: Map<string, string> = new Map();
755
838
  private mcplToolRefreshInFlight = false;
756
839
  private mcplToolRefreshPending = false;
757
840
  /** Maps tool prefix → serverId for dispatch routing. */
@@ -1027,6 +1110,20 @@ export class AgentFramework {
1027
1110
  // retained when enabled — everything downstream gates on the field.
1028
1111
  framework.codeExecutionConfig = config.codeExecution?.enabled ? config.codeExecution : null;
1029
1112
 
1113
+ if (config.toolResultInlineMaxChars !== undefined) {
1114
+ const cap = config.toolResultInlineMaxChars;
1115
+ if (!Number.isFinite(cap) || cap < 1000) {
1116
+ throw new Error('FrameworkConfig.toolResultInlineMaxChars must be a number >= 1000');
1117
+ }
1118
+ framework.toolResultInlineMaxCharsConfig = Math.floor(cap);
1119
+ }
1120
+
1121
+ // Restore resident-set inline caps (agent_settings) — durable like the
1122
+ // core runtime settings, same framework/state slot (antra + Sol, 08-06).
1123
+ for (const [agentName, cap] of Object.entries(framework.readPersistedToolResultInlineCaps())) {
1124
+ framework.toolResultInlineMaxCharsOverride.set(agentName, cap);
1125
+ }
1126
+
1030
1127
  // Initialize MCPL subsystems if configured
1031
1128
  if (config.mcplServers && config.mcplServers.length > 0) {
1032
1129
  // Validate tool prefixes: no collisions with module names or between servers
@@ -1468,10 +1565,11 @@ export class AgentFramework {
1468
1565
  private collectAgentSettingsExtensions(): Map<string, AgentSettingsExtension> {
1469
1566
  const result = new Map<string, AgentSettingsExtension>();
1470
1567
  const taken = new Set<string>(AgentFramework.AGENT_SETTINGS_CORE_KEYS);
1471
- // Framework-owned extension: the tool-result inline cap lift. Registered
1472
- // through the same extension surface modules use so get/update/reset all
1473
- // work with zero extra plumbing. EPHEMERAL by design (a temporary gate —
1474
- // reverts on restart), unlike module extensions which persist their own.
1568
+ // Framework-owned extension: the resident's tool-result inline cap.
1569
+ // Registered through the same extension surface modules use so
1570
+ // get/update/reset all work with zero extra plumbing. DURABLE: persisted
1571
+ // in framework state and restored at create, like the core runtime
1572
+ // settings (antra + Sol, 08-06); reset returns to the residence default.
1475
1573
  {
1476
1574
  const ext = this.spillSettingsExtension();
1477
1575
  ext.keys.forEach((k) => taken.add(k));
@@ -1500,29 +1598,41 @@ export class AgentFramework {
1500
1598
  tool_result_inline_max_chars: {
1501
1599
  type: 'number',
1502
1600
  description:
1503
- 'Inline size cap (chars) for tool results and background-script wake payloads. ' +
1504
- 'Content over the cap is written to a workspace file under tool-results/ and ' +
1505
- 'replaced by a truncated preview + file reference. Raise it temporarily when you ' +
1506
- 'genuinely want a large result inline; reset restores the default (derived from ' +
1507
- 'your strategy). Ephemeral — reverts on host restart.',
1601
+ 'Inline size cap (chars) for tool results (successes and errors) and ' +
1602
+ 'background-script wake payloads. Content over the cap is written to a workspace ' +
1603
+ 'file under tool-results/ and replaced by a truncated preview + file reference. ' +
1604
+ 'This is YOUR durable setting: it persists across restarts, like your other ' +
1605
+ 'agent_settings. Update it when you want a different inline size; reset restores ' +
1606
+ 'the residence default. The effective cap is min(your value, your strategy\'s ' +
1607
+ 'per-message bound) — the bound is a safety ceiling, and the full content is ' +
1608
+ 'always in the spill file. get reports desired/_effective/_source/_clamped_by.',
1508
1609
  },
1509
1610
  },
1510
1611
  keys: ['tool_result_inline_max_chars'],
1511
- get: (agentName: string) => ({
1512
- tool_result_inline_max_chars:
1513
- this.toolResultInlineMaxCharsOverride.get(agentName) ?? null,
1514
- }),
1612
+ get: (agentName: string) => {
1613
+ const agent = this.agents.get(agentName);
1614
+ const resolved = agent ? this.resolveToolResultInlineCap(agent) : null;
1615
+ return {
1616
+ tool_result_inline_max_chars:
1617
+ this.toolResultInlineMaxCharsOverride.get(agentName) ?? null,
1618
+ tool_result_inline_max_chars_effective: resolved?.cap ?? null,
1619
+ tool_result_inline_max_chars_source: resolved?.source ?? null,
1620
+ tool_result_inline_max_chars_clamped_by: resolved?.clampedBy ?? null,
1621
+ };
1622
+ },
1515
1623
  update: (agentName: string, patch: Record<string, unknown>) => {
1516
1624
  const n = Number(patch.tool_result_inline_max_chars);
1517
1625
  if (!Number.isFinite(n) || n < 1000) {
1518
1626
  throw new Error('tool_result_inline_max_chars must be a number >= 1000');
1519
1627
  }
1520
1628
  this.toolResultInlineMaxCharsOverride.set(agentName, Math.floor(n));
1629
+ this.persistToolResultInlineCap(agentName, Math.floor(n));
1521
1630
  return { tool_result_inline_max_chars: Math.floor(n) };
1522
1631
  },
1523
1632
  reset: (agentName: string, keys?: string[]) => {
1524
1633
  if (!keys || keys.includes('tool_result_inline_max_chars')) {
1525
1634
  this.toolResultInlineMaxCharsOverride.delete(agentName);
1635
+ this.persistToolResultInlineCap(agentName, null);
1526
1636
  }
1527
1637
  return { tool_result_inline_max_chars: null };
1528
1638
  },
@@ -1952,6 +2062,44 @@ export class AgentFramework {
1952
2062
  return overrides;
1953
2063
  }
1954
2064
 
2065
+ /** Load resident-set inline caps from framework state, dropping (loudly)
2066
+ * anything that fails the same validation the live update enforces. */
2067
+ private readPersistedToolResultInlineCaps(): Record<string, number> {
2068
+ try {
2069
+ const data = this.store.getStateJson(FRAMEWORK_STATE_ID) as {
2070
+ toolResultInlineCaps?: Record<string, unknown>;
2071
+ } | null;
2072
+ const out: Record<string, number> = {};
2073
+ for (const [name, value] of Object.entries(data?.toolResultInlineCaps ?? {})) {
2074
+ const n = Number(value);
2075
+ if (Number.isFinite(n) && n >= 1000) {
2076
+ out[name] = Math.floor(n);
2077
+ } else {
2078
+ console.error(
2079
+ `[agent-settings] dropping invalid persisted tool_result_inline_max_chars for '${name}': ${JSON.stringify(value)}`,
2080
+ );
2081
+ }
2082
+ }
2083
+ return out;
2084
+ } catch {
2085
+ return {};
2086
+ }
2087
+ }
2088
+
2089
+ /** Write (cap) or clear (null) one agent's resident-set inline cap. */
2090
+ private persistToolResultInlineCap(agentName: string, cap: number | null): void {
2091
+ const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
2092
+ const state = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>;
2093
+ const all = {
2094
+ ...((state.toolResultInlineCaps as Record<string, number> | undefined) ?? {}),
2095
+ };
2096
+ if (cap === null) delete all[agentName];
2097
+ else all[agentName] = cap;
2098
+ if (Object.keys(all).length === 0) delete state.toolResultInlineCaps;
2099
+ else state.toolResultInlineCaps = all;
2100
+ this.store.setStateJson(FRAMEWORK_STATE_ID, state);
2101
+ }
2102
+
1955
2103
  private persistAgentRuntimeSettings(
1956
2104
  agentName: string,
1957
2105
  overrides: AgentRuntimeSettingsOverrides,
@@ -2434,15 +2582,6 @@ export class AgentFramework {
2434
2582
  'chat:thread': 'Occurred in a thread',
2435
2583
  };
2436
2584
 
2437
- /** Refusal category → Discord reaction emoji. Unknown categories get 🛑. */
2438
- private static readonly REFUSAL_REACTIONS: Record<string, string> = {
2439
- bio: '☣️',
2440
- chem: '🧪',
2441
- nuclear: '☢️',
2442
- cyber: '💻',
2443
- reasoning_extraction: '🧠',
2444
- };
2445
-
2446
2585
  /**
2447
2586
  * Mark an inference refusal visibly: react on the message that holds the
2448
2587
  * conversational locus (the most recent incoming channel message) with a
@@ -2459,7 +2598,7 @@ export class AgentFramework {
2459
2598
  const parts = incoming.channelId.split(':');
2460
2599
  if (parts[0] !== 'discord') return;
2461
2600
  const channelId = parts[parts.length - 1];
2462
- const emoji = AgentFramework.REFUSAL_REACTIONS[category] ?? '🛑';
2601
+ const emoji = REFUSAL_REACTIONS[category] ?? REFUSAL_REACTION_FALLBACK;
2463
2602
  // Resolve the MCPL server that owns the locus channel and call
2464
2603
  // tools/call directly on its connection (bare tool name — no prefix
2465
2604
  // games), bypassing the agent event queue so no synthetic tool-result
@@ -2673,6 +2812,46 @@ export class AgentFramework {
2673
2812
  }
2674
2813
  }
2675
2814
 
2815
+ /**
2816
+ * Admin-level nudge: run an inference turn on the agent's CURRENT context
2817
+ * without adding any message, event, or other context mutation. The turn
2818
+ * compiles exactly what the agent already sees — useful after an /undo,
2819
+ * a /hide, or whenever an operator wants the agent to take another look
2820
+ * (or another swing) at where it stands, with zero context pollution.
2821
+ *
2822
+ * Queues a normal inference request (same path as gate wakes): if the
2823
+ * agent is mid-turn it runs when the turn settles; a pending skip_reply
2824
+ * self-wake is superseded like any other wake. Deliberately NOT gated on
2825
+ * idle — "run when you're free" is the useful semantic. No suppression
2826
+ * interplay: gate sleep filters events, not queued requests, so a nudge
2827
+ * wakes even a sleeping agent (it's an admin override, like /unstick).
2828
+ */
2829
+ nudgeAgent(agentName?: string, requestedBy?: string): {
2830
+ ok: boolean;
2831
+ error?: string;
2832
+ agentName?: string;
2833
+ /** Agent state at queue time — 'idle' means the turn starts on the next
2834
+ * scheduler pass; anything else means it runs after the current turn. */
2835
+ agentStatus?: string;
2836
+ } {
2837
+ const name = agentName ?? [...this.agents.keys()][0];
2838
+ const agent = name ? this.agents.get(name) : undefined;
2839
+ if (!name || !agent) {
2840
+ return { ok: false, error: `Unknown agent: ${String(agentName ?? '(none registered)')}` };
2841
+ }
2842
+ const agentStatus = this.activeTurnTokens.has(name) && agent.state.status === 'idle'
2843
+ ? 'idle+turn-alive'
2844
+ : agent.state.status;
2845
+ this.pendingRequests.push({
2846
+ agentName: name,
2847
+ reason: requestedBy ? `admin-nudge (${requestedBy})` : 'admin-nudge',
2848
+ source: 'admin',
2849
+ timestamp: Date.now(),
2850
+ });
2851
+ console.error(`[nudge] agent=${name} queued by=${requestedBy ?? 'unknown'} status=${agentStatus}`);
2852
+ return { ok: true, agentName: name, agentStatus };
2853
+ }
2854
+
2676
2855
  /**
2677
2856
  * Handle a `host/command` request from an MCPL surface server (e.g. a
2678
2857
  * Discord slash command). Currently supports:
@@ -2681,6 +2860,9 @@ export class AgentFramework {
2681
2860
  * `undoLastTurn`). The response includes the last message the agent
2682
2861
  * would see in its context after the undo, obtained via the transparent
2683
2862
  * `previewActivation` render (no inference, no Chronicle writes).
2863
+ *
2864
+ * nudge — run inference on the current context with NO new events
2865
+ * (see `nudgeAgent`).
2684
2866
  */
2685
2867
  private async handleHostCommand(
2686
2868
  serverId: string,
@@ -2703,8 +2885,16 @@ export class AgentFramework {
2703
2885
  * outcome report is posted to the channel asynchronously. */
2704
2886
  started?: boolean;
2705
2887
  cap?: number;
2888
+ /** For `nudge`: agent state at queue time ('idle' = runs immediately,
2889
+ * else it runs once the current turn settles). */
2890
+ agentStatus?: string;
2706
2891
  }> {
2707
- if (params.command !== 'undo' && params.command !== 'hide' && params.command !== 'unstick') {
2892
+ if (
2893
+ params.command !== 'undo' &&
2894
+ params.command !== 'hide' &&
2895
+ params.command !== 'unstick' &&
2896
+ params.command !== 'nudge'
2897
+ ) {
2708
2898
  return { ok: false, error: `Unknown host command: ${String(params.command)}` };
2709
2899
  }
2710
2900
 
@@ -2713,6 +2903,16 @@ export class AgentFramework {
2713
2903
  return { ok: false, error: `Unknown agent: ${String(agentName)}` };
2714
2904
  }
2715
2905
 
2906
+ // nudge: queue an inference on the current context — no message, no
2907
+ // event, no context mutation (see nudgeAgent).
2908
+ if (params.command === 'nudge') {
2909
+ const r = this.nudgeAgent(
2910
+ agentName,
2911
+ params.requesterName ?? params.requesterId ?? `mcpl:${serverId}`,
2912
+ );
2913
+ return { ok: r.ok, error: r.error, agentStatus: r.agentStatus };
2914
+ }
2915
+
2716
2916
  // unstick: force the refusal-rewind loop on demand (even if the agent's
2717
2917
  // autoRewind toggle is off). Redacts the turn that fed the refusal and
2718
2918
  // re-runs, up to `cap` times, until the model stops refusing. Kicks the
@@ -3616,38 +3816,18 @@ export class AgentFramework {
3616
3816
  this.pendingAssistantBlocks.delete(agent.name);
3617
3817
  }
3618
3818
 
3619
- // Compute truncation limit from agent's strategy (maxMessageTokens * 4 chars)
3620
- const maxChars = this.getMaxToolResultChars(agent);
3621
-
3622
- // Oversized results spill to a workspace file (truncated preview +
3623
- // file reference) instead of being blind-truncated. Computed ONCE
3624
- // per call and reused for both the history copy and the live wire
3625
- // copy below — the two must stay byte-matched (the window stores
3626
- // what the membrane sends; divergence breaks the compile prefix).
3627
- const spilled = new Map<string, string>();
3628
- const dateLabel = new Date().toISOString().slice(0, 10);
3629
- for (const tc of currentState.toolResults) {
3630
- if (tc.result.isError) continue;
3631
- spilled.set(tc.id, await this.spillOrTruncate(
3632
- toolResultDataToHistoryString(tc.result.data, undefined),
3633
- maxChars,
3634
- `${dateLabel}-${tc.id}`,
3635
- agent.name,
3636
- ));
3637
- }
3638
-
3639
- // Store tool results as a user message (tool_result blocks).
3640
- // Use the history serializer so MCP image blocks become a short
3641
- // `[image: type, size]` placeholder instead of megabytes of base64
3642
- // that would corrupt under truncation.
3643
- const toolResultContent: ContentBlock[] = currentState.toolResults.map(tc => ({
3644
- type: 'tool_result' as const,
3645
- toolUseId: tc.id,
3646
- content: tc.result.isError
3647
- ? tc.result.error ?? 'Unknown error'
3648
- : spilled.get(tc.id) ?? toolResultDataToHistoryString(tc.result.data, maxChars),
3649
- isError: tc.result.isError,
3650
- }));
3819
+ // Effective inline cap: agent_settings override → durable config →
3820
+ // house default, clamped to the strategy bound (see resolver).
3821
+ const maxChars = this.resolveToolResultInlineCap(agent).cap;
3822
+
3823
+ // Oversized results — successes AND errors — spill to a workspace
3824
+ // file (truncated preview + file reference) instead of being
3825
+ // blind-truncated. Computed ONCE per call and reused for both the
3826
+ // history copy and the live wire copy below — the two must stay
3827
+ // byte-matched (the window stores what the membrane sends;
3828
+ // divergence breaks the compile prefix).
3829
+ const { blocks: toolResultContent, spilled } =
3830
+ await this.buildStoredToolResultContent(currentState.toolResults, maxChars);
3651
3831
  agent.getContextManager().addMessage('user', toolResultContent);
3652
3832
 
3653
3833
  // Flush any messages that were deferred while this turn was in
@@ -3719,6 +3899,30 @@ export class AgentFramework {
3719
3899
  && agent.lastStreamInputTokens > 0
3720
3900
  && agent.lastStreamInputTokens > agent.maxStreamTokens;
3721
3901
 
3902
+ // Physical-window projection (issue #92): a turn's continuation
3903
+ // rounds append to the compiled request without recompiling, so a
3904
+ // turn that starts near the compile target can walk past the
3905
+ // provider's HARD cap mid-turn and take a wire 400 — the compile
3906
+ // was legal, the growth wasn't. Project the next round's real size
3907
+ // (cache-inclusive input of the prior round + the blocks about to
3908
+ // be appended + reserve for the response) and, when it would cross
3909
+ // the physical window, break the stream through the same
3910
+ // budget-restart path — a fresh compile folds the history back
3911
+ // under budget instead of dispatching a doomed request.
3912
+ // Projection terms: prior round's real input + prior round's
3913
+ // OUTPUT (the just-generated thinking/text/tool_use joins the next
3914
+ // round's input — omitting it is a systematic underestimate) +
3915
+ // blocks about to be appended + reserve for the next response.
3916
+ const projectedRealTokens = currentState.stream
3917
+ && agent.physicalWindowTokens !== undefined
3918
+ && agent.lastStreamRealInputTokens > 0
3919
+ ? agent.lastStreamRealInputTokens
3920
+ + agent.lastStreamOutputTokens
3921
+ + estimateAppendedRoundTokens(currentState.toolResults, spilled, midTurnInjections)
3922
+ + agent.maxTokens
3923
+ : 0;
3924
+ const overPhysical = projectedRealTokens > (agent.physicalWindowTokens ?? Infinity);
3925
+
3722
3926
  // Addressed re-pin (2026-07-31 Mythos misroutes, antra-ratified):
3723
3927
  // when someone ADDRESSES the agent mid-turn from another channel
3724
3928
  // (mention / reply / DM — the same chat:addressed signal the
@@ -3747,15 +3951,31 @@ export class AgentFramework {
3747
3951
  agent.proseRouting !== 'explicit' &&
3748
3952
  !shouldEndTurn && !overBudget && currentState.stream
3749
3953
  ) {
3750
- const lastAddressed = [...midTurnInjections].reverse().find((inj) => {
3954
+ // Two signals qualify an injection to move the pin (n=6 + n=7):
3955
+ // - chat:addressed — someone explicitly spoke TO the agent
3956
+ // (mention / reply / DM);
3957
+ // - a conversational follow-up in a channel the agent itself
3958
+ // explicitly sent into THIS turn (turnEngagedChannels) —
3959
+ // conversation the agent just engaged continues without
3960
+ // re-mentioning it (2026-07-31: q's #portables reply, no @,
3961
+ // answered in prose that followed the stale pin into
3962
+ // repligate's DM). Deliberately NO author-kind filter: this
3963
+ // fleet's participants include agent-residents whose speech
3964
+ // is as conversational as anyone's — the Discord bot flag
3965
+ // tracks nothing that matters here (antra, 2026-07-31).
3966
+ // Reactions and system markers are already excluded by
3967
+ // isConversationalInjection, and the engaged-this-turn scope
3968
+ // keeps unrelated channels from moving the pin.
3969
+ const engaged = this.turnEngagedChannels.get(agent.name);
3970
+ const lastQualifying = [...midTurnInjections].reverse().find((inj) => {
3751
3971
  const m = inj.metadata as Record<string, unknown> | undefined;
3752
- return (
3753
- isConversationalInjection(inj.metadata) &&
3754
- typeof m?.channelId === 'string' &&
3755
- isAddressedMessage(m.tags as string[] | undefined, m)
3756
- );
3972
+ if (!isConversationalInjection(inj.metadata)) return false;
3973
+ if (typeof m?.channelId !== 'string') return false;
3974
+ const tags = m.tags as string[] | undefined;
3975
+ if (isAddressedMessage(tags, m)) return true;
3976
+ return engaged?.has(m.channelId as string) === true;
3757
3977
  });
3758
- const newLocus = (lastAddressed?.metadata as Record<string, unknown> | undefined)
3978
+ const newLocus = (lastQualifying?.metadata as Record<string, unknown> | undefined)
3759
3979
  ?.channelId as string | undefined;
3760
3980
  if (newLocus && this.turnLocusPins.get(agent.name) !== newLocus) {
3761
3981
  const prevPin = this.turnLocusPins.get(agent.name) ?? null;
@@ -3768,7 +3988,7 @@ export class AgentFramework {
3768
3988
  const noticeContent: ContentBlock[] = [{
3769
3989
  type: 'text',
3770
3990
  text:
3771
- `[routing] You were just addressed from ${shown} — your plain ` +
3991
+ `[routing] The conversation moved to ${shown} — your plain ` +
3772
3992
  'speech now lands there for the rest of this turn. Other ' +
3773
3993
  'channels need an explicit send tool.',
3774
3994
  }];
@@ -3800,7 +4020,7 @@ export class AgentFramework {
3800
4020
  this.eventGate?.onInferenceEnded(agent.name);
3801
4021
  this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' });
3802
4022
  this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
3803
- } else if (overBudget) {
4023
+ } else if (overBudget || overPhysical) {
3804
4024
  // Context budget exceeded: break the stream, let compile() compress.
3805
4025
  // Mark the cancel as framework-initiated BEFORE cancelling: the
3806
4026
  // membrane delivers `aborted` before the restart bumps streamId,
@@ -3825,9 +4045,12 @@ export class AgentFramework {
3825
4045
  this.emitTrace({
3826
4046
  type: 'inference:stream_restarted',
3827
4047
  agentName: agent.name,
3828
- reason: 'context_budget',
3829
- inputTokens: agent.lastStreamInputTokens,
3830
- budget: agent.maxStreamTokens,
4048
+ // 'physical_window' only when the budget check alone would
4049
+ // have let the doomed request through — overBudget keeps its
4050
+ // established label for existing consumers.
4051
+ reason: overBudget ? 'context_budget' : 'physical_window',
4052
+ inputTokens: overBudget ? agent.lastStreamInputTokens : projectedRealTokens,
4053
+ budget: overBudget ? agent.maxStreamTokens : (agent.physicalWindowTokens ?? 0),
3831
4054
  });
3832
4055
  this.pendingRequests.push({
3833
4056
  agentName: agent.name,
@@ -4040,6 +4263,12 @@ export class AgentFramework {
4040
4263
  ? (event.metadata.guildName as string)
4041
4264
  : undefined,
4042
4265
  tags: event.tags,
4266
+ missedMessages: typeof event.metadata?.missedMessages === 'number'
4267
+ ? event.metadata.missedMessages
4268
+ : undefined,
4269
+ missedCharacters: typeof event.metadata?.missedCharacters === 'number'
4270
+ ? event.metadata.missedCharacters
4271
+ : undefined,
4043
4272
  });
4044
4273
  if (invitation) {
4045
4274
  incomingContent.push(invitation.block);
@@ -4347,6 +4576,17 @@ export class AgentFramework {
4347
4576
  * this should see WHO addressed it WHERE, not a wall of snowflake ids. Raw
4348
4577
  * ids remain only inside the tool-argument instructions, where they are the
4349
4578
  * literal values to pass.
4579
+ *
4580
+ * 2026-08-05 accuracy pass: the old "you won't receive more from it unless
4581
+ * you join" was wrong in both directions — direct addresses (mentions,
4582
+ * replies to the agent) DO still arrive, and nothing warned that answering
4583
+ * via option 1 leaves the human's natural un-@'d follow-up invisible (Sol
4584
+ * answered four #architecture mentions over four days while the follow-ups
4585
+ * to her own replies were silently dropped). The wording now states the
4586
+ * actual delivery model, and option 1 carries the consequence inline. When
4587
+ * the surface tracks a missed-ambient tally (discord-mcpl sends
4588
+ * `missedMessages`/`missedCharacters` in push origin), the invitation shows
4589
+ * the count — the cost of staying out, as a number, at decision time.
4350
4590
  */
4351
4591
  private buildClosedChannelInvitation(opts: {
4352
4592
  serverId: string;
@@ -4356,6 +4596,10 @@ export class AgentFramework {
4356
4596
  authorName: string;
4357
4597
  guildName?: string;
4358
4598
  tags?: string[];
4599
+ /** Ambient messages dropped in this channel while closed (surface-tracked). */
4600
+ missedMessages?: number;
4601
+ /** Characters across those dropped messages. */
4602
+ missedCharacters?: number;
4359
4603
  }): { block: ContentBlock; metadataPatch: Record<string, unknown> } | null {
4360
4604
  if (!this.channelRegistry) return null;
4361
4605
  if (!opts.tags?.includes('chat:addressed')) return null;
@@ -4365,13 +4609,23 @@ export class AgentFramework {
4365
4609
  const label = opts.channelLabel ?? descriptor?.label;
4366
4610
  const channelLabel = label ? `#${label}` : `"${opts.channelId}"`;
4367
4611
  const place = opts.guildName ? `${channelLabel} in "${opts.guildName}"` : channelLabel;
4612
+ const missedCount = opts.missedMessages ?? 0;
4613
+ const missedNote =
4614
+ missedCount > 0
4615
+ ? `While closed, ${missedCount} message${missedCount === 1 ? '' : 's'}` +
4616
+ (opts.missedCharacters ? ` (~${opts.missedCharacters} chars)` : '') +
4617
+ ` have passed there without you. `
4618
+ : '';
4368
4619
  const block: ContentBlock = {
4369
4620
  type: 'text',
4370
4621
  text:
4371
4622
  `\n[Channel invitation] ${opts.authorName} addressed you in ${place} — ` +
4372
- `a channel you haven't joined. You're seeing this one message; you won't receive more from it unless you join. ` +
4623
+ `a channel you haven't joined. Only messages that address you directly (an @-mention or ` +
4624
+ `a reply to one of your messages) reach you from it; the rest of its traffic is invisible to you. ` +
4625
+ missedNote +
4373
4626
  `Your options:\n` +
4374
- `1. Reply without joining — write your reply this turn prefixed with ">>${channelLabel}"; it will be delivered there.\n` +
4627
+ `1. Reply without joining — write your reply this turn prefixed with ">>${channelLabel}"; it will be delivered there. ` +
4628
+ `Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n` +
4375
4629
  `2. Join the channel — call channel_open with channelId "${opts.channelId}" and serverId "${opts.serverId}"` +
4376
4630
  (maxBackscroll > 0
4377
4631
  ? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${opts.messageId}".\n`
@@ -4434,6 +4688,9 @@ export class AgentFramework {
4434
4688
  authorName: typeof origin.authorName === 'string' && origin.authorName ? origin.authorName : 'Someone',
4435
4689
  guildName: typeof origin.guildName === 'string' && origin.guildName ? origin.guildName : undefined,
4436
4690
  tags: event.tags,
4691
+ missedMessages: typeof origin.missedMessages === 'number' ? origin.missedMessages : undefined,
4692
+ missedCharacters:
4693
+ typeof origin.missedCharacters === 'number' ? origin.missedCharacters : undefined,
4437
4694
  });
4438
4695
  if (invitation) {
4439
4696
  content.push(invitation.block);
@@ -4653,6 +4910,77 @@ export class AgentFramework {
4653
4910
  }
4654
4911
  }
4655
4912
 
4913
+ /** Record prose segments suppressed by explicit-send silencing. */
4914
+ private recordProseSuppression(agentName: string, count: number): void {
4915
+ if (count <= 0) return;
4916
+ this.turnProseSuppressed.set(agentName, (this.turnProseSuppressed.get(agentName) ?? 0) + count);
4917
+ }
4918
+
4919
+ /** Record a successful plain-prose delivery for this turn's receipt. */
4920
+ private recordProseDelivery(
4921
+ agentName: string,
4922
+ outcome: { delivered: boolean; channelId: string } | null | undefined,
4923
+ ): void {
4924
+ if (!outcome?.delivered) return;
4925
+ let list = this.turnProseDeliveries.get(agentName);
4926
+ if (!list) {
4927
+ list = [];
4928
+ this.turnProseDeliveries.set(agentName, list);
4929
+ }
4930
+ list.push(outcome.channelId);
4931
+ }
4932
+
4933
+ /**
4934
+ * Append the turn's `[delivered]` receipt: one compact system message
4935
+ * naming where this turn's plain prose actually landed (channels in
4936
+ * delivery order, deduped). Explicit sends receipt themselves via their
4937
+ * tool_result; auto-routed prose previously left no in-window trace, so
4938
+ * the agent could never see where its own words went — misroutes were
4939
+ * invisible to it until a human reported them (2026-07-31 series).
4940
+ * Called ONLY at logical turn end (stream 'complete', or the endTurn
4941
+ * cancel path after the speech chain settles) — never mid-stream, where a
4942
+ * window-only insert would diverge from the wire. No-op when the turn
4943
+ * delivered nothing. Failures are already marked separately
4944
+ * ([discord-send-failed]); this is the success half.
4945
+ */
4946
+ private appendProseDeliveryReceipt(agent: Agent): void {
4947
+ const list = this.turnProseDeliveries.get(agent.name);
4948
+ const suppressed = this.turnProseSuppressed.get(agent.name) ?? 0;
4949
+ if ((!list || list.length === 0) && suppressed === 0) return;
4950
+ this.turnProseDeliveries.delete(agent.name);
4951
+ this.turnProseSuppressed.delete(agent.name);
4952
+ const seen = new Set<string>();
4953
+ const shown: string[] = [];
4954
+ for (const id of list ?? []) {
4955
+ if (seen.has(id)) continue;
4956
+ seen.add(id);
4957
+ const label = this.channelRegistry?.getDescriptor(id)?.label;
4958
+ shown.push(
4959
+ label && label !== id
4960
+ ? `${label.startsWith('#') ? label : `#${label}`} (${id})`
4961
+ : id,
4962
+ );
4963
+ }
4964
+ const suppressedNote =
4965
+ suppressed > 0
4966
+ ? `${suppressed} plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)`
4967
+ : '';
4968
+ const text =
4969
+ shown.length > 0
4970
+ ? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}`
4971
+ : `[delivered] nothing — ${suppressedNote}`;
4972
+ try {
4973
+ const mid = agent.getContextManager().addMessage(
4974
+ 'user',
4975
+ [{ type: 'text', text }],
4976
+ { system: true, kind: 'delivery-receipt' } as MessageMetadata,
4977
+ );
4978
+ this.emitTrace({ type: 'message:added', messageId: mid, source: 'delivery-receipt' });
4979
+ } catch (err) {
4980
+ console.error('delivery receipt append failed:', err);
4981
+ }
4982
+ }
4983
+
4656
4984
  /**
4657
4985
  * Announce-on-change locus notice (turn-frozen routing). Appends a durable
4658
4986
  * `[routing]` window message when the turn's effective outbound locus
@@ -5047,6 +5375,12 @@ export class AgentFramework {
5047
5375
  // KV stability).
5048
5376
  if (trigger?.reason !== 'context_budget_restart') {
5049
5377
  if (attempt === 0) this.maybePrimeProseMode(agent);
5378
+ // Fresh turn: forget the previous turn's explicit-send engagements and
5379
+ // prose deliveries — both are strictly turn-scoped (a restart continues
5380
+ // the same logical turn and keeps them).
5381
+ this.turnEngagedChannels.delete(agent.name);
5382
+ this.turnProseDeliveries.delete(agent.name);
5383
+ this.turnProseSuppressed.delete(agent.name);
5050
5384
  if (agent.proseRouting === 'explicit') {
5051
5385
  // Explicit prose routing: there is no locus. The model names every
5052
5386
  // destination in-band (`>>` prefixes); the only turn state is the
@@ -5249,7 +5583,8 @@ export class AgentFramework {
5249
5583
  const enqueueSpeech = (text: string, locus: string | null): void => {
5250
5584
  turnSpeechChain = turnSpeechChain
5251
5585
  .then(async () => {
5252
- await this.channelRegistry!.routeSpeech(agent.name, text, locus);
5586
+ const outcome = await this.channelRegistry!.routeSpeech(agent.name, text, locus);
5587
+ this.recordProseDelivery(agent.name, outcome);
5253
5588
  })
5254
5589
  .catch((err) => console.error('mid-turn speech routing failed:', err));
5255
5590
  };
@@ -5291,8 +5626,27 @@ export class AgentFramework {
5291
5626
  // streams that delivery would refuse), emission is capability-gated in
5292
5627
  // the registry (`channels.streaming`), and delivery via deliverProse
5293
5628
  // remains the sole authoritative send.
5294
- const outgoingInferenceId = `inf_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
5629
+ const newOutgoingInferenceId = (): string =>
5630
+ `inf_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
5631
+ // NOT const: a membrane refusal retry abandons everything streamed so
5632
+ // far, and the surface must not concatenate two attempts. Re-minting the
5633
+ // id makes the discarded attempt's chunk buffer orphaned (never
5634
+ // finalized) instead of being appended to — see the 'retrying' case.
5635
+ let outgoingInferenceId = newOutgoingInferenceId();
5295
5636
  let outgoingIndex = 0;
5637
+
5638
+ // §10.5 inference/lifecycle: `started` now, exactly one terminal in the
5639
+ // finally below (the one block every exit path shares). Which terminal
5640
+ // is decided by the paths themselves: default completed, catch sets
5641
+ // failed, a framework-cancelled stream sets aborted. Best-effort
5642
+ // notifications — consumers dedupe by inferenceId and keep a timeout.
5643
+ let lifecyclePhase: 'completed' | 'aborted' | 'failed' = 'completed';
5644
+ this.hookOrchestrator?.emitLifecycle({
5645
+ inferenceId: outgoingInferenceId,
5646
+ conversationId: agent.name,
5647
+ turnIndex: 0,
5648
+ phase: 'started',
5649
+ });
5296
5650
  const proseStream = this.channelRegistry
5297
5651
  ? new ProseStreamRouter({
5298
5652
  mode: agent.proseRouting === 'explicit' ? 'explicit' : 'locus',
@@ -5342,6 +5696,38 @@ export class AgentFramework {
5342
5696
  }
5343
5697
  break;
5344
5698
 
5699
+ case 'retrying': {
5700
+ // Membrane is re-issuing after a content-policy refusal. Per the
5701
+ // RetryingEvent contract we must DISCARD everything this call has
5702
+ // emitted: the tokens above were already streamed to the surface
5703
+ // as outgoing chunks, and appending a second attempt to them
5704
+ // would show the human two half-answers spliced together.
5705
+ //
5706
+ // Re-mint the outgoing id so the partial buffer is orphaned
5707
+ // rather than continued (delivery is authoritative via
5708
+ // deliverProse and has not happened yet — nothing has been
5709
+ // *sent*, only previewed), and reset the prose router so its
5710
+ // destination bookkeeping starts clean for the new attempt.
5711
+ console.error(
5712
+ `[refusal-retry] agent=${agent.name} membrane retry ` +
5713
+ `${event.attempt}/${event.maxAttempts}` +
5714
+ (event.category ? ` category=${event.category}` : '') +
5715
+ ' — discarding the refused attempt',
5716
+ );
5717
+ outgoingInferenceId = newOutgoingInferenceId();
5718
+ outgoingIndex = 0;
5719
+ proseStream?.reset();
5720
+ this.emitTrace({
5721
+ type: 'inference:tokens',
5722
+ agentName: agent.name,
5723
+ content: '',
5724
+ blockType: 'text',
5725
+ blockIndex: 0,
5726
+ channelId: typingChannel ?? undefined,
5727
+ });
5728
+ break;
5729
+ }
5730
+
5345
5731
  case 'block': {
5346
5732
  const { event: phase, index, block } = event.event;
5347
5733
  this.emitTrace({
@@ -5459,6 +5845,9 @@ export class AgentFramework {
5459
5845
  console.error(
5460
5846
  `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`,
5461
5847
  );
5848
+ // Visible in the turn-end receipt — silencing must never
5849
+ // be a silent black hole (n=8: the flying-scene reply).
5850
+ this.recordProseSuppression(agent.name, roundSegments.length);
5462
5851
  } else if (hasSameRoundPrivateThink) {
5463
5852
  console.error(
5464
5853
  `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`,
@@ -5506,17 +5895,15 @@ export class AgentFramework {
5506
5895
  agent.addAssistantResponse(pending);
5507
5896
  this.pendingAssistantBlocks.delete(agent.name);
5508
5897
  }
5509
- // Store tool results
5898
+ // Store tool results — same bounded spill policy as the
5899
+ // ordinary path (issue #89: this guard path must not become the
5900
+ // one door a giant blob still walks through).
5510
5901
  const readyState = agent.state as AgentState;
5511
5902
  if (readyState.status === 'ready') {
5512
- const toolResultContent: ContentBlock[] = readyState.toolResults.map(tc => ({
5513
- type: 'tool_result' as const,
5514
- toolUseId: tc.id,
5515
- content: tc.result.isError
5516
- ? (tc.result.error ?? 'Unknown error')
5517
- : toolResultDataToHistoryString(tc.result.data),
5518
- isError: tc.result.isError,
5519
- }));
5903
+ const { blocks: toolResultContent } = await this.buildStoredToolResultContent(
5904
+ readyState.toolResults,
5905
+ this.resolveToolResultInlineCap(agent).cap,
5906
+ );
5520
5907
  agent.getContextManager().addMessage('user', toolResultContent);
5521
5908
  }
5522
5909
  }
@@ -5545,40 +5932,10 @@ export class AgentFramework {
5545
5932
  agent.addAssistantResponse(terminalContent);
5546
5933
  }
5547
5934
 
5548
- // Run afterInference hooks (no-op if no MCPL servers)
5549
- if (this.hookOrchestrator) {
5550
- try {
5551
- const speechText = response.content
5552
- .filter((block: ContentBlock): block is ContentBlock & { type: 'text' } => block.type === 'text')
5553
- .map((b) => b.text)
5554
- .join('\n');
5555
-
5556
- const afterParams: AfterInferenceParams = {
5557
- inferenceId: requestId,
5558
- conversationId: agent.name,
5559
- turnIndex: 0,
5560
- userMessage: null,
5561
- assistantMessage: speechText,
5562
- model: {
5563
- id: agent.model,
5564
- vendor: 'unknown',
5565
- contextWindow: 200000,
5566
- capabilities: ['tools'],
5567
- },
5568
- usage: {
5569
- inputTokens: response.usage?.inputTokens ?? 0,
5570
- outputTokens: response.usage?.outputTokens ?? 0,
5571
- cacheCreationTokens: response.details?.usage?.cacheCreationTokens,
5572
- cacheReadTokens: response.details?.usage?.cacheReadTokens,
5573
- },
5574
- };
5575
-
5576
- await this.hookOrchestrator.afterInference(afterParams);
5577
- } catch (error) {
5578
- // Fail-open: continue with speech dispatch
5579
- console.error('afterInference hook error:', error);
5580
- }
5581
- }
5935
+ // §10.5: context/afterInference is removed in 0.5.0 — no
5936
+ // content leaves the host here. Turn boundaries are announced
5937
+ // by the metadata-only inference/lifecycle notifications
5938
+ // emitted in driveStream's start/exit paths.
5582
5939
 
5583
5940
  // Separate speech from thoughts — for MODULE dispatch only
5584
5941
  // (dispatchSpeech / TUI rendering below). This split does NOT
@@ -5675,6 +6032,18 @@ export class AgentFramework {
5675
6032
  let handledByRewind = false;
5676
6033
  const rh = agent.refusalHandling;
5677
6034
  const forced = this.forcedRewind.get(agent.name);
6035
+
6036
+ // NOTE: plain retries are NOT done here. `refusalHandling.retries`
6037
+ // is handed to membrane (see Agent.startActivation), which
6038
+ // replays the SAME request at the provider seam — immediately,
6039
+ // cache-warm, and without recompiling into a different window.
6040
+ // A framework-level requeue was tried first and is strictly
6041
+ // worse: it recompiles, so attempts are correlated rather than
6042
+ // fresh draws against a probabilistic threshold, and it cannot
6043
+ // tell a streaming consumer to discard the abandoned attempt.
6044
+ // By the time a refusal reaches here, membrane's retries are
6045
+ // already spent — so this path is the escalation: rewind (if
6046
+ // enabled), then the reaction.
5678
6047
  // Shed exactly one more (newest, in-sequence) turn and keep the
5679
6048
  // single episode marker current. `budgetLeft` bounds the loop.
5680
6049
  const doRewind = (
@@ -5732,7 +6101,16 @@ export class AgentFramework {
5732
6101
  );
5733
6102
  }
5734
6103
 
5735
- if (!handledByRewind) void this.reactToRefusal(agent.name, category);
6104
+ // The reaction is the LAST resort: retries spent, rewind not
6105
+ // applicable. That makes it an honest signal to humans that the
6106
+ // border is close, rather than noise on every near-threshold
6107
+ // flip. (It must not re-enter any agent's context — the emoji
6108
+ // is suppressed at the surface via
6109
+ // DISCORD_SUPPRESS_REACTION_EMOJIS; without that, marking a
6110
+ // refusal feeds the next one.)
6111
+ if (!handledByRewind) {
6112
+ void this.reactToRefusal(agent.name, category);
6113
+ }
5736
6114
  } else {
5737
6115
  // A turn that completed WITHOUT a refusal ends the rewind episode:
5738
6116
  // the model responded. Leave the consolidated marker in place as
@@ -5798,7 +6176,8 @@ export class AgentFramework {
5798
6176
  `[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ?? '(none)'}`,
5799
6177
  );
5800
6178
  try {
5801
- await this.channelRegistry.routeSpeech(agent.name, speechText, locus);
6179
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, speechText, locus);
6180
+ this.recordProseDelivery(agent.name, outcome);
5802
6181
  } catch (err) {
5803
6182
  console.error('speech routing failed:', err);
5804
6183
  }
@@ -5852,6 +6231,9 @@ export class AgentFramework {
5852
6231
  `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> trailing prose NOT routed ` +
5853
6232
  `(${silenced ? 'silencing tool / explicit send' : 'no trailing prose'})`,
5854
6233
  );
6234
+ if (silenced && segments.length > 0) {
6235
+ this.recordProseSuppression(agent.name, segments.length);
6236
+ }
5855
6237
  } else {
5856
6238
  // Reuse the locus pinned at the turn's first live-routed
5857
6239
  // segment (or resolve it now for a turn whose only prose is
@@ -5867,7 +6249,8 @@ export class AgentFramework {
5867
6249
  // Deliver sequentially (await each) so the segments land in order.
5868
6250
  for (const seg of segments) {
5869
6251
  try {
5870
- await this.channelRegistry.routeSpeech(agent.name, seg, locus);
6252
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus);
6253
+ this.recordProseDelivery(agent.name, outcome);
5871
6254
  } catch (err) {
5872
6255
  console.error('speech routing failed:', err);
5873
6256
  }
@@ -5878,6 +6261,15 @@ export class AgentFramework {
5878
6261
  // BEFORE dispatchSpeech. Locus routing is speech dispatch and
5879
6262
  // doesn't depend on the status field, so it correctly runs after.
5880
6263
 
6264
+ // Logical turn end (normal completion): drop the `[delivered]`
6265
+ // receipt. All deliveries are settled — the live chain was
6266
+ // awaited before trailing dispatch, and trailing/text-only
6267
+ // segments were awaited in-loop. Locus mode only; explicit-mode
6268
+ // envelopes acknowledge themselves through the prose gateway.
6269
+ if (agent.proseRouting !== 'explicit') {
6270
+ this.appendProseDeliveryReceipt(agent);
6271
+ }
6272
+
5881
6273
  // Explicit-prose `!` continuation: a prose segment this turn asked
5882
6274
  // to keep going (`>>#x !` / `>>private !`) — start another turn
5883
6275
  // now instead of pausing until the next external event. This gives
@@ -5958,9 +6350,26 @@ export class AgentFramework {
5958
6350
  // reset, no settle, no spurious inference:exhausted (which would
5959
6351
  // reject an ephemeral's promise mid-run and bump the failure
5960
6352
  // streak). Gate release + stream teardown happen in `finally`.
5961
- if (this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`)) {
5962
- this.eventGate?.onInferenceEnded(agent.name);
5963
- return;
6353
+ {
6354
+ const cancelKey = `${agent.name}:${myStreamId}`;
6355
+ const cancelKind = this.frameworkCancelledStreams.get(cancelKey);
6356
+ if (cancelKind !== undefined) {
6357
+ this.frameworkCancelledStreams.delete(cancelKey);
6358
+ lifecyclePhase = 'aborted'; // §10.5 — terminal emitted in finally
6359
+ this.eventGate?.onInferenceEnded(agent.name);
6360
+ // endTurn IS a logical turn end — earlier rounds may have
6361
+ // live-routed prose (narrate → skip_reply is a real shape),
6362
+ // so settle the delivery chain and drop the receipt. A
6363
+ // budget restart continues the same logical turn: the
6364
+ // deliveries map persists (cleared only at fresh turn
6365
+ // start) and the continuation stream's end writes ONE
6366
+ // receipt for the whole turn.
6367
+ if (cancelKind === 'turn_ended' && agent.proseRouting !== 'explicit') {
6368
+ await turnSpeechChain;
6369
+ this.appendProseDeliveryReceipt(agent);
6370
+ }
6371
+ return;
6372
+ }
5964
6373
  }
5965
6374
  const reason = event.reason ?? 'unknown';
5966
6375
  // Only reset if this is still the active stream (a budget restart
@@ -6001,6 +6410,11 @@ export class AgentFramework {
6001
6410
 
6002
6411
  case 'usage': {
6003
6412
  agent.lastStreamInputTokens = event.usage.inputTokens;
6413
+ agent.lastStreamRealInputTokens =
6414
+ (event.usage.inputTokens ?? 0) +
6415
+ (event.usage.cacheCreationTokens ?? 0) +
6416
+ (event.usage.cacheReadTokens ?? 0);
6417
+ agent.lastStreamOutputTokens = event.usage.outputTokens ?? 0;
6004
6418
 
6005
6419
  // Closed-loop estimator calibration (2026-07-12). Sample the REAL
6006
6420
  // prefix size of THIS API call (fresh + cache write + cache read)
@@ -6073,9 +6487,21 @@ export class AgentFramework {
6073
6487
  durationMs,
6074
6488
  });
6075
6489
  this.abortAgentScript(agent.name, 'stream threw');
6490
+ lifecyclePhase = 'failed'; // §10.5 — terminal emitted in finally
6076
6491
  agent.reset();
6077
6492
  this.eventGate?.onInferenceEnded(agent.name);
6078
6493
  } finally {
6494
+ // §10.5: exactly one terminal per `started`, on every exit path the
6495
+ // host controls. Which one was decided by the path taken (default
6496
+ // completed; catch → failed; framework-cancel → aborted). A host
6497
+ // crash emits nothing — that is the documented best-effort limit, and
6498
+ // why consumers keep their safety timeout.
6499
+ this.hookOrchestrator?.emitLifecycle({
6500
+ inferenceId: outgoingInferenceId,
6501
+ conversationId: agent.name,
6502
+ turnIndex: 0,
6503
+ phase: lifecyclePhase,
6504
+ });
6079
6505
  // Clear the gate's inference flag on EVERY exit path (paired with the
6080
6506
  // onInferenceStarted in startAgentStream). The branch-level calls above
6081
6507
  // are kept but are best-effort; if any exit path bypassed them the agent
@@ -6527,8 +6953,12 @@ export class AgentFramework {
6527
6953
  ? 'null'
6528
6954
  : JSON.stringify(payload, null, 1) ?? String(payload);
6529
6955
  const agent = this.agents.get(record.agentName);
6530
- const maxChars = agent ? this.getMaxToolResultChars(agent) : undefined;
6531
- const payloadText = await this.spillOrTruncate(
6956
+ // Same resolved cap as tool results (override applies here too); an
6957
+ // unknown agent still gets the bounded house default, never unbounded.
6958
+ const maxChars = agent
6959
+ ? this.resolveToolResultInlineCap(agent).cap
6960
+ : DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS;
6961
+ const { text: payloadText } = await this.spillOrTruncate(
6532
6962
  payloadJson, maxChars, `${record.id}-wake${record.wakes}`,
6533
6963
  );
6534
6964
 
@@ -6610,45 +7040,101 @@ export class AgentFramework {
6610
7040
  }
6611
7041
 
6612
7042
  /**
6613
- * Oversized content policy (antra, 2026-07-31): never hit the agent in
6614
- * the face with a huge blob, never silently destroy it either. Content
6615
- * over the inline cap is materialized to a workspace file (readable with
6616
- * the agent's own tools) and replaced by a truncated head + a trailing
6617
- * file reference. Falls back to plain truncation when there is no
6618
- * writable workspace. The cap can be lifted temporarily via
6619
- * agent_settings `tool_result_inline_max_chars`.
7043
+ * Oversized content policy (antra, 2026-07-31; completed for issue #89):
7044
+ * never hit the agent in the face with a huge blob, never silently destroy
7045
+ * it either. Content over the inline cap is materialized to a workspace
7046
+ * file (readable with the agent's own tools — deterministic name, kept
7047
+ * until the workspace owner deletes it) and replaced by a truncated head +
7048
+ * a trailing file reference. Falls back to EXPLICIT plain truncation when
7049
+ * there is no writable workspace. Cap resolution lives in
7050
+ * resolveToolResultInlineCap; callers pass the resolved cap.
6620
7051
  */
7052
+ /**
7053
+ * One spill policy for every stored tool_result block: serialize (history
7054
+ * serializer — image blocks become placeholders), then spill/truncate at
7055
+ * the resolved cap. Errors follow the same policy as successes — a giant
7056
+ * error string is exactly as context-hostile as a giant success (issue
7057
+ * #89). Returns the stored blocks plus the per-call spill outcomes so the
7058
+ * live wire copy can reuse the identical strings.
7059
+ */
7060
+ private async buildStoredToolResultContent(
7061
+ toolResults: CompletedToolCall[],
7062
+ maxChars: number | undefined,
7063
+ ): Promise<{
7064
+ blocks: ContentBlock[];
7065
+ spilled: Map<string, { text: string; filePath: string | null }>;
7066
+ }> {
7067
+ const spilled = new Map<string, { text: string; filePath: string | null }>();
7068
+ const dateLabel = new Date().toISOString().slice(0, 10);
7069
+ for (const tc of toolResults) {
7070
+ const raw = tc.result.isError
7071
+ ? tc.result.error ?? 'Unknown error'
7072
+ : toolResultDataToHistoryString(tc.result.data, undefined);
7073
+ spilled.set(tc.id, await this.spillOrTruncate(raw, maxChars, `${dateLabel}-${tc.id}`));
7074
+ }
7075
+ const blocks: ContentBlock[] = toolResults.map(tc => ({
7076
+ type: 'tool_result' as const,
7077
+ toolUseId: tc.id,
7078
+ content: spilled.get(tc.id)!.text,
7079
+ isError: tc.result.isError,
7080
+ }));
7081
+ return { blocks, spilled };
7082
+ }
7083
+
6621
7084
  private async spillOrTruncate(
6622
7085
  content: string,
6623
- maxChars: number | undefined,
7086
+ cap: number | undefined,
6624
7087
  label: string,
6625
- agentName?: string,
6626
- ): Promise<string> {
6627
- const override = agentName !== undefined
6628
- ? this.toolResultInlineMaxCharsOverride.get(agentName)
6629
- : undefined;
6630
- const cap = override ?? maxChars;
6631
- if (!cap || content.length <= cap) return content;
7088
+ ): Promise<{ text: string; filePath: string | null }> {
7089
+ if (!cap || content.length <= cap) return { text: content, filePath: null };
6632
7090
 
6633
7091
  const workspace = this.getWorkspaceModule();
6634
7092
  const mountName = workspace ? this.firstWritableMountName(workspace) : null;
6635
7093
  if (workspace && mountName) {
6636
7094
  const safeLabel = label.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
6637
7095
  const path = `${mountName}/tool-results/${safeLabel}.txt`;
7096
+ let failure: string;
6638
7097
  try {
6639
7098
  const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain');
6640
7099
  if (result.success) {
6641
- return safeSlice(content, 0, cap)
6642
- + `\n\n[truncated — showing ${cap} of ${content.length} chars; full content: workspace file ${path}. `
6643
- + 'Read/grep it with your file tools, or raise the inline cap temporarily via '
6644
- + 'agent_settings update tool_result_inline_max_chars.]';
7100
+ return {
7101
+ text: safeSlice(content, 0, cap)
7102
+ + `\n\n[truncated — showing ${cap} of ${content.length} chars; full content: workspace file ${path}. `
7103
+ + 'Read/grep it with your file tools, or raise the inline cap via '
7104
+ + 'agent_settings update tool_result_inline_max_chars.]',
7105
+ filePath: path,
7106
+ };
6645
7107
  }
6646
- } catch {
6647
- // fall through to plain truncation
7108
+ failure = result.error ?? 'write refused';
7109
+ } catch (err) {
7110
+ failure = err instanceof Error ? err.message : String(err);
6648
7111
  }
7112
+ // A workspace was there and the write FAILED — say that, loudly and
7113
+ // distinctly. Telling the agent "no writable workspace" would teach
7114
+ // them their residence lacks a capability it actually has.
7115
+ failure = failure.slice(0, 200);
7116
+ this.emitTrace({
7117
+ type: 'tool:spill_failed',
7118
+ label: safeLabel,
7119
+ path,
7120
+ contentLength: content.length,
7121
+ error: failure,
7122
+ });
7123
+ console.error(
7124
+ `[spill] workspace write failed for ${path} (${content.length} chars): ${failure}`,
7125
+ );
7126
+ return {
7127
+ text: safeSlice(content, 0, cap)
7128
+ + `\n\n[truncated — showing ${cap} of ${content.length} chars; spill to workspace file ${path} FAILED`
7129
+ + ` (${failure}); content over the cap was not retained]`,
7130
+ filePath: null,
7131
+ };
6649
7132
  }
6650
- return safeSlice(content, 0, cap)
6651
- + '\n\n[truncated — original was ' + content.length + ' chars]';
7133
+ return {
7134
+ text: safeSlice(content, 0, cap)
7135
+ + '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace, full content not retained]',
7136
+ filePath: null,
7137
+ };
6652
7138
  }
6653
7139
 
6654
7140
  private getOrCreateScriptRunner(agentName: string): PyRunner {
@@ -6759,23 +7245,30 @@ export class AgentFramework {
6759
7245
  callId: string,
6760
7246
  afResult: ToolResult,
6761
7247
  maxChars?: number,
6762
- /** Pre-spilled string from the history path — used for the non-image
7248
+ /** Pre-spilled outcome from the history path — used for the non-image
6763
7249
  * path so the live wire copy byte-matches what the window stored. */
6764
- precomputed?: string,
7250
+ precomputed?: { text: string; filePath: string | null },
6765
7251
  ): MembraneToolResult {
6766
7252
  if (afResult.isError) {
6767
- return { toolUseId: callId, content: afResult.error ?? 'Unknown error', isError: true };
7253
+ // Same spill policy as successes: reuse the history copy so a giant
7254
+ // error string stays bounded on the wire too (byte-matched).
7255
+ return {
7256
+ toolUseId: callId,
7257
+ content: precomputed?.text ?? truncateForHistory(
7258
+ afResult.error ?? 'Unknown error', DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS),
7259
+ isError: true,
7260
+ };
6768
7261
  }
6769
7262
  // MCPL tool results arrive as `data: McpToolResultContent[]` — preserve image
6770
7263
  // blocks natively rather than JSON-stringifying them away. Anything else
6771
7264
  // (objects, scalars) falls through to JSON. The error path was handled
6772
7265
  // above, so isError is always false on these return paths.
6773
- const blocks = this.tryNativeToolResultContent(afResult.data, maxChars);
7266
+ const blocks = this.tryNativeToolResultContent(afResult.data, maxChars, precomputed?.filePath ?? null);
6774
7267
  if (blocks) {
6775
7268
  return { toolUseId: callId, content: blocks, isError: false };
6776
7269
  }
6777
7270
  if (precomputed !== undefined) {
6778
- return { toolUseId: callId, content: precomputed, isError: false };
7271
+ return { toolUseId: callId, content: precomputed.text, isError: false };
6779
7272
  }
6780
7273
  // JSON.stringify returns the VALUE undefined (not a string) for undefined
6781
7274
  // input — a module tool returning `{ success: true }` with no data would
@@ -6795,7 +7288,13 @@ export class AgentFramework {
6795
7288
  * `maxChars`, when provided, caps each accompanying text block so an image
6796
7289
  * inlined alongside an enormous text payload can't blow the context.
6797
7290
  */
6798
- private tryNativeToolResultContent(data: unknown, maxChars?: number): ToolResultContentBlock[] | null {
7291
+ private tryNativeToolResultContent(
7292
+ data: unknown,
7293
+ maxChars?: number,
7294
+ /** Spill file written by the history path — referenced from truncation
7295
+ * notices so an image-adjacent giant text block stays recoverable. */
7296
+ spillPath: string | null = null,
7297
+ ): ToolResultContentBlock[] | null {
6799
7298
  if (!Array.isArray(data)) return null;
6800
7299
  let hasImage = false;
6801
7300
  const blocks: ToolResultContentBlock[] = [];
@@ -6806,7 +7305,9 @@ export class AgentFramework {
6806
7305
  let text = b.text;
6807
7306
  if (maxChars && text.length > maxChars) {
6808
7307
  text = safeSlice(text, 0, maxChars)
6809
- + '\n\n[truncated — original was ' + text.length + ' chars]';
7308
+ + '\n\n[truncated — original was ' + text.length + ' chars'
7309
+ + (spillPath ? `; full serialized result: workspace file ${spillPath}` : '')
7310
+ + ']';
6810
7311
  }
6811
7312
  blocks.push({ type: 'text', text });
6812
7313
  } else if (b.type === 'image' && typeof b.data === 'string' && typeof b.mimeType === 'string') {
@@ -6822,13 +7323,45 @@ export class AgentFramework {
6822
7323
  return hasImage ? blocks : null;
6823
7324
  }
6824
7325
 
6825
- private getMaxToolResultChars(agent: Agent): number | undefined {
7326
+ /** Strategy-derived per-message bound (maxMessageTokens * 4 chars). */
7327
+ private strategyDerivedToolResultChars(agent: Agent): number | undefined {
6826
7328
  const strategy = agent.getContextManager().getStrategy();
6827
7329
  const maxTokens = strategy.maxMessageTokens;
6828
7330
  if (maxTokens && maxTokens > 0) return maxTokens * 4;
6829
7331
  return undefined;
6830
7332
  }
6831
7333
 
7334
+ /**
7335
+ * Effective tool-result inline cap for an agent, with provenance. Desired
7336
+ * value: resident's durable agent_settings value → residence
7337
+ * FrameworkConfig.toolResultInlineMaxChars → house default (5000).
7338
+ * Effective value: min(desired, strategy bound) for EVERY source — the
7339
+ * strategy's per-message safety limit is a ceiling, not a suggestion
7340
+ * (Sol's #94 ruling: a durable preference must not be a durable path for
7341
+ * one tool result to exceed maxMessageTokens; the way to see the whole
7342
+ * result is the spill file, not an over-bound blob in live context).
7343
+ */
7344
+ private resolveToolResultInlineCap(agent: Agent): {
7345
+ cap: number;
7346
+ source: 'agent-settings-override' | 'framework-config' | 'default';
7347
+ /** Set when the desired value was reduced to the strategy bound. */
7348
+ clampedBy: 'strategy-bound' | null;
7349
+ } {
7350
+ const override = this.toolResultInlineMaxCharsOverride.get(agent.name);
7351
+ const configured = this.toolResultInlineMaxCharsConfig;
7352
+ const desired = override ?? configured ?? DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS;
7353
+ const source = override !== undefined
7354
+ ? 'agent-settings-override' as const
7355
+ : configured !== null ? 'framework-config' as const : 'default' as const;
7356
+ const strategyBound = this.strategyDerivedToolResultChars(agent);
7357
+ const clamped = strategyBound !== undefined && strategyBound < desired;
7358
+ return {
7359
+ cap: clamped ? strategyBound : desired,
7360
+ source,
7361
+ clampedBy: clamped ? 'strategy-bound' : null,
7362
+ };
7363
+ }
7364
+
6832
7365
  private approximateDecodedBase64Bytes(base64: string): number {
6833
7366
  const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
6834
7367
  return Math.max(0, Math.floor(base64.length * 3 / 4) - padding);
@@ -7005,6 +7538,25 @@ export class AgentFramework {
7005
7538
  // synthesized channel tools, but they aren't `channel_`-prefixed so they
7006
7539
  // need an explicit route here.
7007
7540
  if ((enrichedCall.name === 'think' || enrichedCall.name === 'skip_reply') && this.channelRegistry) {
7541
+ // skip_reply(wake_in_seconds): arm a gate self-wake so "not replying
7542
+ // NOW" can also mean "back in a moment" — ends the turn, then wakes
7543
+ // the agent after N seconds unless something else wakes it first
7544
+ // (any turn start cancels the pending self-wake). Armed here because
7545
+ // the framework owns the EventGate; the registry only words the
7546
+ // confirmation. Without a gate the field is stripped so that
7547
+ // confirmation never claims a wake that can't happen.
7548
+ if (enrichedCall.name === 'skip_reply') {
7549
+ const inputObj = (enrichedCall.input ?? {}) as Record<string, unknown>;
7550
+ const secs = Number(inputObj.wake_in_seconds);
7551
+ const wants = inputObj.wake_in_seconds !== undefined && Number.isFinite(secs) && secs > 0;
7552
+ if (wants && this.eventGate) {
7553
+ const { inMs } = this.eventGate.armSelfWake(agentName, secs, 'skip_reply');
7554
+ console.error(`[self-wake] agent=${agentName} armed in ${Math.round(inMs / 1000)}s (skip_reply)`);
7555
+ } else if (inputObj.wake_in_seconds !== undefined && !this.eventGate) {
7556
+ delete inputObj.wake_in_seconds;
7557
+ console.error(`[self-wake] agent=${agentName} wake_in_seconds requested but no EventGate — ignored`);
7558
+ }
7559
+ }
7008
7560
  this.dispatchChannelToolCall(agentName, enrichedCall);
7009
7561
  return;
7010
7562
  }
@@ -7632,7 +8184,6 @@ export class AgentFramework {
7632
8184
  ): Promise<void> {
7633
8185
  this.mcplServerRegistry = new McplServerRegistry();
7634
8186
  this.featureSetManager = new FeatureSetManager();
7635
- this.scopeManager = new ScopeManager();
7636
8187
  this.hookOrchestrator = new HookOrchestrator(this.mcplServerRegistry, this.featureSetManager);
7637
8188
 
7638
8189
  // Build prefix map and store configs for tool routing
@@ -7750,15 +8301,35 @@ export class AgentFramework {
7750
8301
 
7751
8302
  // Host capabilities advertised during the MCP handshake — stored so
7752
8303
  // servers can also be connected later at runtime (connectMcplServer).
8304
+ // §5.2: the host advertises what it actually implements. 0.5.0-draft:
8305
+ // afterInference is GONE (replaced by inference/lifecycle, §10.5), and
8306
+ // the previously under-advertised inferenceRequest + channels — both
8307
+ // implemented for years — are declared (AUDIT-001/issue #76 item 12).
7753
8308
  this.mcplHostCapabilities = {
7754
- version: '0.4',
8309
+ version: '0.5',
7755
8310
  pushEvents: true,
7756
8311
  contextHooks: {
7757
8312
  beforeInference: true,
7758
- afterInference: { blocking: true },
8313
+ },
8314
+ inferenceLifecycle: true,
8315
+ // modelInfo is NOT advertised: §12.2's result requires all four of
8316
+ // {id, vendor, contextWindow, capabilities} and the host has no
8317
+ // truthful source for contextWindow/capabilities (grep: none exists).
8318
+ // Advertising a capability answered with a malformed partial response
8319
+ // is worse than honest unsupported (Sol, PR #79 re-review). The
8320
+ // handler below answers an explicit error rather than hanging (§6.6).
8321
+ inferenceRequest: { streaming: true },
8322
+ channels: {
8323
+ register: true,
8324
+ lifecycle: true,
8325
+ publish: true,
8326
+ incoming: true,
8327
+ streaming: true,
8328
+ acknowledge: true,
8329
+ typing: true,
7759
8330
  },
7760
8331
  featureSets: true,
7761
- };
8332
+ } as McplHostCapabilities;
7762
8333
 
7763
8334
  for (const config of serverConfigs) {
7764
8335
  try {
@@ -8171,30 +8742,228 @@ export class AgentFramework {
8171
8742
  // releases control traffic needed for registration and marker service.
8172
8743
  this.wireMcplEvents(connection);
8173
8744
 
8174
- // Initialize feature sets if server advertises MCPL capabilities
8175
- this.registerMcplServerFeatures(config, connection);
8176
-
8177
8745
  if (deferAwareness) {
8746
+ // Staged startup: both planes are closed, so the §5.3 policy
8747
+ // round-trip cannot let any buffered traffic slip — establish the
8748
+ // grant now, before staging ever releases.
8749
+ await this.registerMcplServerFeatures(config, connection);
8178
8750
  this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
8179
8751
  return;
8180
8752
  }
8181
8753
 
8182
8754
  const awarenessBarrier = this.installMcplDataPlaneGate();
8183
- this.releaseMcplDataPlaneGate(awarenessBarrier);
8755
+ // NO early release: both planes stay closed through accounting AND the
8756
+ // §5.3 policy round-trip below (PR #79 review blocker 3 — the early
8757
+ // control flush released channels-register/changed before any grant
8758
+ // existed). The awareness drain rides tools/call RESPONSES, which are
8759
+ // never event-buffered, so it needs no plane release to make progress.
8184
8760
  try {
8185
8761
  await awarenessBarrier.promise;
8186
- this.completeMcplDataPlaneGate(awarenessBarrier);
8187
8762
  } catch (error) {
8188
8763
  // Startup cannot fail open on a broken awareness ledger. Disable the
8189
8764
  // reconnecting stub/connection before propagating the distinct error to
8190
8765
  // initializeMcpl, which tears down any other servers and aborts create().
8766
+ // Ordering matters: awareness accounting settles BEFORE the §5.3
8767
+ // policy round-trip below, so an accounting failure aborts with the
8768
+ // grant still empty and every buffered event still behind the gate —
8769
+ // a failed startup never reports a released data plane. (The reverse
8770
+ // order let a push arrive during the policy await and get flushed by
8771
+ // teardown.)
8191
8772
  await connection.close().catch(() => {});
8192
8773
  throw error;
8193
8774
  }
8194
8775
 
8776
+ // §5.3 initial policy handshake — after accounting, before the gate
8777
+ // completes: the grant is established (or knowingly left empty) before
8778
+ // ANY buffered traffic (control or data) flows. complete() then
8779
+ // ready()s both planes through the admission gate.
8780
+ await this.registerMcplServerFeatures(config, connection);
8781
+ this.completeMcplDataPlaneGate(awarenessBarrier);
8782
+
8195
8783
  this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
8196
8784
  }
8197
8785
 
8786
+ /** Per-connection §17.8 fetch limiter: at most one in-flight fetch, at
8787
+ * most one queued behind it (announcements coalesce), floor between
8788
+ * completed fetches. Exceeding the floor drops the excess with a
8789
+ * diagnostic — the host bounds the rate; it never depends on server
8790
+ * coalescing. */
8791
+ private manifestRefreshState: Map<string, { inFlight: boolean; queued: boolean; lastCompletedAt: number }> = new Map();
8792
+ private static readonly MANIFEST_FETCH_FLOOR_MS = 5_000;
8793
+
8794
+ /**
8795
+ * §17.5 host processing: fetch the complete manifest, validate exactly as
8796
+ * at initialize, diff, apply §6.7 consequences (removals eagerly even
8797
+ * when other declarations are invalid; additions only through
8798
+ * tell→receipt→activate), emit ONE change receipt (§17.6).
8799
+ */
8800
+ private async handleManifestChanged(
8801
+ connection: McplServerConnection,
8802
+ announce: { revision?: string; domains?: string[] },
8803
+ ): Promise<void> {
8804
+ const st = this.manifestRefreshState.get(connection.id)
8805
+ ?? { inFlight: false, queued: false, lastCompletedAt: 0 };
8806
+ this.manifestRefreshState.set(connection.id, st);
8807
+ if (st.inFlight) {
8808
+ // Coalesce: one queued refresh re-runs after the current fetch — the
8809
+ // fetch is always of the CURRENT manifest, so N announcements need at
8810
+ // most one trailing fetch (§17.8).
8811
+ st.queued = true;
8812
+ return;
8813
+ }
8814
+ const sinceLast = Date.now() - st.lastCompletedAt;
8815
+ if (sinceLast < AgentFramework.MANIFEST_FETCH_FLOOR_MS) {
8816
+ if (!st.queued) {
8817
+ st.queued = true;
8818
+ setTimeout(() => {
8819
+ const cur = this.manifestRefreshState.get(connection.id);
8820
+ if (cur?.queued && !cur.inFlight) {
8821
+ cur.queued = false;
8822
+ void this.handleManifestChanged(connection, announce).catch(() => {});
8823
+ }
8824
+ }, AgentFramework.MANIFEST_FETCH_FLOOR_MS - sinceLast).unref?.();
8825
+ }
8826
+ return;
8827
+ }
8828
+
8829
+ st.inFlight = true;
8830
+ try {
8831
+ const config = this.mcplServerConfigs.get(connection.id);
8832
+ if (!config) return;
8833
+
8834
+ // §17.4 fetch. A server that cannot answer keeps its previous
8835
+ // manifest in force — an unparseable/failed fetch teaches nothing and
8836
+ // narrows nothing (§17.5 step 1).
8837
+ let fetched: import('./mcpl/types.js').McplCapabilities | null;
8838
+ try {
8839
+ fetched = await connection.sendManifestRequest();
8840
+ } catch (err) {
8841
+ console.error(`[mcpl] ${connection.id}: mcpl/manifest fetch failed — previous manifest stands (§17.5): ${err instanceof Error ? err.message : err}`);
8842
+ return;
8843
+ }
8844
+ if (!fetched || typeof fetched !== 'object') {
8845
+ console.error(`[mcpl] ${connection.id}: mcpl/manifest returned no object — previous manifest stands (§17.5)`);
8846
+ return;
8847
+ }
8848
+
8849
+ // Validate exactly as at initialize: expand §5.1 shorthand, apply the
8850
+ // config mask, recompute the grant. Unknown names mint nothing;
8851
+ // invalid declarations fail closed in derivation — and REMOVALS still
8852
+ // apply, because the new grant/derivation simply lacks whatever no
8853
+ // longer validates (§17.5 step 1's partial-invalid rule falls out of
8854
+ // computing forward from the fetched content).
8855
+ const expanded = expandAdvertisementShorthand(fetched);
8856
+ const { capabilities: masked, dropped } = maskNegotiatedCapabilities(expanded, config);
8857
+ const newGrant = computeGrant(masked, config, { mcpToolsAdvertised: connection.mcpToolsAdvertised });
8858
+
8859
+ const oldEffective = new Set(connection.grant.effectiveList());
8860
+ const newEffective = new Set(newGrant.effectiveList());
8861
+ const revoked = [...oldEffective].filter((p) => !newEffective.has(p)).sort();
8862
+ const added = [...newEffective].filter((p) => !oldEffective.has(p)).sort();
8863
+
8864
+ const oldDeclared = Object.keys(this.featureSetManager?.getDeclaredFeatureSets(connection.id) ?? {});
8865
+ const oldEnabled = oldDeclared.filter((n) => this.featureSetManager?.isEnabled(connection.id, n));
8866
+
8867
+ // §6.7 REDUCTION FIRST, atomically, before the server is told:
8868
+ // security cannot wait on consent. The interim grant is new∩old — no
8869
+ // revoked path survives, no added path activates yet.
8870
+ const hadReduction = revoked.length > 0;
8871
+ if (hadReduction) {
8872
+ const interim = new CapabilityGrant(
8873
+ new Set([...newEffective].filter((p) => oldEffective.has(p))),
8874
+ [...newGrant.deniedPaths],
8875
+ );
8876
+ connection.establishGrant(interim);
8877
+ }
8878
+
8879
+ // PHASE 1 — pre-receipt: live feature state derives from ACTIVE
8880
+ // authority (the interim grant when reduced, the standing grant
8881
+ // otherwise), never from the unactivated full grant. Feature state
8882
+ // must obey the same §6.7 ordering the grant does: a newly added
8883
+ // capability-backed feature set cannot be host-enabled while its
8884
+ // capability is still absent from the active grant (Sol, PR #84
8885
+ // review). Declarations come from the fetched manifest either way —
8886
+ // a set absent from the new manifest is gone regardless.
8887
+ const cfg = { enabledFeatureSets: config.enabledFeatureSets, disabledFeatureSets: config.disabledFeatureSets };
8888
+ const maskedOrEmpty = masked ?? ({ version: '0.5' } as import('./mcpl/types.js').McplCapabilities);
8889
+ this.featureSetManager!.initializeServer(connection.id, maskedOrEmpty, cfg, connection.grant);
8890
+
8891
+ // The Request DESCRIBES the proposed new policy — derived from the
8892
+ // full new grant on a scratch manager, installing nothing.
8893
+ const updateParams = new FeatureSetManager().initializeServer(connection.id, maskedOrEmpty, cfg, newGrant);
8894
+ updateParams.effectiveCapabilities = newGrant.effectiveList();
8895
+ if (newGrant.deniedPaths.length > 0) updateParams.deniedCapabilities = [...newGrant.deniedPaths];
8896
+
8897
+ // Tell → receipt → activate (§6.7). PHASE 2 by outcome:
8898
+ // accepted → full grant + full-derived feature state;
8899
+ // refused → honor fallback: 'close' closes the transport;
8900
+ // 'mcp-only' (or absent) = empty grant + empty-derived
8901
+ // feature state, mirroring the §5.3 initial path;
8902
+ // unanswered → interim grant and interim-derived state stand.
8903
+ let negotiated = false;
8904
+ try {
8905
+ const receipt = await connection.sendFeatureSetsUpdateRequest(updateParams);
8906
+ if (receipt && receipt.accepted === false) {
8907
+ const fallback = receipt.fallback ?? 'mcp-only';
8908
+ console.error(`[mcpl] ${connection.id} refused post-manifest policy — fallback: ${fallback}`);
8909
+ if (fallback === 'close') {
8910
+ await connection.close();
8911
+ return;
8912
+ }
8913
+ connection.establishGrant(CapabilityGrant.empty());
8914
+ this.featureSetManager!.initializeServer(connection.id, maskedOrEmpty, cfg, CapabilityGrant.empty());
8915
+ } else {
8916
+ connection.establishGrant(newGrant);
8917
+ this.featureSetManager!.initializeServer(connection.id, maskedOrEmpty, cfg, newGrant);
8918
+ connection.manifestState.lastNegotiatedAt = Date.now();
8919
+ negotiated = true;
8920
+ }
8921
+ } catch {
8922
+ console.error(`[mcpl] ${connection.id} did not answer post-manifest policy — expansion does not activate (§6.7); interim state stands`);
8923
+ }
8924
+
8925
+ // Adopt the validated manifest + tracking. droppedCapabilities is
8926
+ // REPLACED unconditionally — an empty new mask must clear stale paths
8927
+ // (Sol, PR #84 review).
8928
+ connection.capabilities = masked;
8929
+ connection.manifestState.lastValidatedRevision =
8930
+ typeof (fetched as { revision?: unknown }).revision === 'string'
8931
+ ? (fetched as { revision: string }).revision : null;
8932
+ connection.manifestState.lastFetchedAt = Date.now();
8933
+ connection.droppedCapabilities = new Set(dropped);
8934
+
8935
+ const newDeclared = Object.keys(this.featureSetManager?.getDeclaredFeatureSets(connection.id) ?? {});
8936
+ const newEnabled = newDeclared.filter((n) => this.featureSetManager?.isEnabled(connection.id, n));
8937
+ const degraded = oldEnabled.filter((n) => !newEnabled.includes(n)).sort();
8938
+ const restored = newEnabled.filter((n) => !oldEnabled.includes(n)).sort();
8939
+
8940
+ // §17.6: ONE receipt per manifest change, closed host-derived impact
8941
+ // vocabulary, never a server-authored flag.
8942
+ const impacts: Array<{ impact: string; subject: string; disposition: string }> = [
8943
+ ...revoked.map((p) => ({ impact: 'capability-revoked', subject: p, disposition: 'applied' })),
8944
+ ...added.map((p) => ({ impact: 'capability-expansion-pending', subject: p, disposition: negotiated ? 'applied' : 'decision-needed' })),
8945
+ ...degraded.map((n) => ({ impact: 'feature-degraded', subject: n, disposition: 'applied' })),
8946
+ ...restored.map((n) => ({ impact: 'feature-restored', subject: n, disposition: negotiated ? 'applied' : 'decision-needed' })),
8947
+ ];
8948
+ this.emitTrace({
8949
+ type: 'mcpl:manifest-change-receipt',
8950
+ serverId: connection.id,
8951
+ revision: connection.manifestState.lastValidatedRevision,
8952
+ announcedDomains: announce.domains ?? [],
8953
+ impacts,
8954
+ });
8955
+ this.handleToolsListChanged(connection.id);
8956
+ } finally {
8957
+ st.inFlight = false;
8958
+ st.lastCompletedAt = Date.now();
8959
+ if (st.queued) {
8960
+ st.queued = false;
8961
+ setTimeout(() => { void this.handleManifestChanged(connection, announce).catch(() => {}); },
8962
+ AgentFramework.MANIFEST_FETCH_FLOOR_MS).unref?.();
8963
+ }
8964
+ }
8965
+ }
8966
+
8198
8967
  /**
8199
8968
  * (Re-)establish a server's host-side MCPL registration: feature-set state
8200
8969
  * (plus the featureSets/update sent to the server), scope patterns, and
@@ -8210,12 +8979,18 @@ export class AgentFramework {
8210
8979
  * already has a tree), so checkpoint trees preserved across a transient
8211
8980
  * disconnect are resumed, not reset.
8212
8981
  */
8213
- private registerMcplServerFeatures(
8982
+ private async registerMcplServerFeatures(
8214
8983
  config: import('./mcpl/types.js').McplServerConfig,
8215
8984
  connection: McplServerConnection,
8216
- ): void {
8985
+ ): Promise<void> {
8217
8986
  if (!connection.capabilities) return;
8218
8987
 
8988
+ // §5.4: compute the effective grant from the (config-masked)
8989
+ // advertisement. Not yet active — expansion activates only after the
8990
+ // receipt (§6.7); until then the connection's grant is empty and every
8991
+ // privileged inbound method is rejected.
8992
+ const grant = computeGrant(connection.capabilities, config, { mcpToolsAdvertised: connection.mcpToolsAdvertised });
8993
+
8219
8994
  const updateParams = this.featureSetManager!.initializeServer(
8220
8995
  config.id,
8221
8996
  connection.capabilities,
@@ -8223,16 +8998,60 @@ export class AgentFramework {
8223
8998
  enabledFeatureSets: config.enabledFeatureSets,
8224
8999
  disabledFeatureSets: config.disabledFeatureSets,
8225
9000
  },
9001
+ grant,
8226
9002
  );
8227
9003
 
8228
- // Inform server which feature sets are enabled/disabled
8229
- if (updateParams.enabled?.length || updateParams.disabled?.length) {
8230
- connection.sendFeatureSetsUpdate(updateParams);
9004
+ // §5.3: initial policy MUST be a Request, MUST precede first fan-out,
9005
+ // and MUST be sent even when nothing is enabled or disabled — a server
9006
+ // defaulted to fully disabled has to be told. The response is a
9007
+ // degradation receipt (§6.7): consequence testimony, never policy
9008
+ // authority — nothing in it may widen the grant.
9009
+ updateParams.effectiveCapabilities = grant.effectiveList();
9010
+ if (grant.deniedPaths.length > 0) {
9011
+ updateParams.deniedCapabilities = [...grant.deniedPaths];
9012
+ }
9013
+
9014
+ try {
9015
+ const receipt = await connection.sendFeatureSetsUpdateRequest(updateParams);
9016
+ if (receipt && receipt.accepted === false) {
9017
+ const fallback = receipt.fallback ?? 'mcp-only';
9018
+ console.error(
9019
+ `[mcpl] ${config.id} refused initial policy (${receipt.reason ?? 'no reason'}) — fallback: ${fallback}`,
9020
+ );
9021
+ if (fallback === 'close') {
9022
+ await connection.close();
9023
+ return;
9024
+ }
9025
+ // mcp-only (§3.2): grant stays empty, MCPL privileged surface stays
9026
+ // dark, plain MCP tools keep working. MUST NOT widen in response to
9027
+ // the refusal — a human can change config; the policy engine cannot.
9028
+ return;
9029
+ }
9030
+ // Expansion activates on receipt (§6.7).
9031
+ connection.establishGrant(grant);
9032
+ if (receipt?.accepted === true && receipt.mode === 'degraded') {
9033
+ for (const uf of receipt.unavailableFeatures ?? []) {
9034
+ console.error(
9035
+ `[mcpl] ${config.id} degraded: ${uf.featureSet} (${uf.effect}; missing ${uf.missingCapabilities.join(', ')})`,
9036
+ );
9037
+ }
9038
+ }
9039
+ } catch (err) {
9040
+ // A pre-0.5 server treats the Request as a notification and never
9041
+ // answers. §6.7: an unanswered expansion simply does not activate —
9042
+ // grant stays empty, connection is effectively MCP-only. This WILL
9043
+ // dark every un-migrated MCPL server's privileged surface; accepted
9044
+ // deliberately for the single-release rollout (issue #76 item 9).
9045
+ console.error(
9046
+ `[mcpl] ${config.id} did not answer the initial policy Request — ` +
9047
+ `MCPL privileged surface stays disabled (§6.7 unanswered expansion): ` +
9048
+ (err instanceof Error ? err.message : String(err)),
9049
+ );
9050
+ return;
8231
9051
  }
8232
9052
 
8233
- // Configure scope whitelist/blacklist patterns
8234
9053
  if (config.scopes) {
8235
- this.scopeManager!.configureAll(config.scopes);
9054
+ console.error(`[mcpl] ${config.id}: config \`scopes\` is ignored — §7 Scoped Access removed in 0.5.0`);
8236
9055
  }
8237
9056
 
8238
9057
  // Register stateful feature sets with checkpoint manager (Step 8)
@@ -8348,23 +9167,62 @@ export class AgentFramework {
8348
9167
  listMcplServers(): Array<{
8349
9168
  id: string;
8350
9169
  connected: boolean;
9170
+ retrying: boolean;
8351
9171
  toolPrefix: string;
8352
9172
  toolCount: number;
9173
+ /** True only after the §5.3 policy receipt activated the grant. */
9174
+ policyEstablished: boolean;
9175
+ /** Sole normative authorization allowlist for the connection (§5.4). */
9176
+ effectiveGrant: string[];
9177
+ /** Advertised paths removed by host enabled/disabled capability config. */
9178
+ maskedCapabilities: string[];
9179
+ /** Advertised paths removed by a host deny-by-default rule (§13.4). */
9180
+ deniedCapabilities: string[];
9181
+ /** Host-owned authority; deliberately separate from the portable grant. */
9182
+ allowHostCommands: boolean;
9183
+ /**
9184
+ * Per-transport §17 facts about the last manifest this host fetched and
9185
+ * acted on. The revision is server-authored and equality-only; these are
9186
+ * not the server's manifestChanged announcements.
9187
+ */
9188
+ manifestState: {
9189
+ lastValidatedRevision: string | null;
9190
+ lastFetchedAt: number | null;
9191
+ lastNegotiatedAt: number | null;
9192
+ };
8353
9193
  command?: string;
8354
9194
  url?: string;
8355
9195
  }> {
8356
9196
  const result: Array<{
8357
- id: string; connected: boolean; toolPrefix: string; toolCount: number;
9197
+ id: string; connected: boolean; retrying: boolean;
9198
+ toolPrefix: string; toolCount: number; policyEstablished: boolean;
9199
+ effectiveGrant: string[]; maskedCapabilities: string[];
9200
+ deniedCapabilities: string[]; allowHostCommands: boolean;
9201
+ manifestState: {
9202
+ lastValidatedRevision: string | null;
9203
+ lastFetchedAt: number | null;
9204
+ lastNegotiatedAt: number | null;
9205
+ };
8358
9206
  command?: string; url?: string;
8359
9207
  }> = [];
8360
9208
  for (const [id, config] of this.mcplServerConfigs) {
8361
9209
  const prefix = config.toolPrefix ?? `mcpl--${id}`;
8362
9210
  const connection = this.mcplServerRegistry?.getServer(id) ?? null;
9211
+ const connected = connection?.isConnected ?? false;
8363
9212
  result.push({
8364
9213
  id,
8365
- connected: connection?.isConnected ?? false,
9214
+ connected,
9215
+ retrying: !connected && (connection?.willReconnect ?? false),
8366
9216
  toolPrefix: prefix,
8367
9217
  toolCount: this.mcplTools.filter(t => t.name.startsWith(`${prefix}--`)).length,
9218
+ policyEstablished: connection?.policyEstablished ?? false,
9219
+ effectiveGrant: connection?.grant.effectiveList() ?? [],
9220
+ maskedCapabilities: [...(connection?.droppedCapabilities ?? [])].sort(),
9221
+ deniedCapabilities: [...(connection?.grant.deniedPaths ?? [])].sort(),
9222
+ allowHostCommands: config.allowHostCommands === true,
9223
+ manifestState: connection
9224
+ ? { ...connection.manifestState }
9225
+ : { lastValidatedRevision: null, lastFetchedAt: null, lastNegotiatedAt: null },
8368
9226
  command: config.command,
8369
9227
  url: config.url,
8370
9228
  });
@@ -8383,26 +9241,15 @@ export class AgentFramework {
8383
9241
  this.emitTrace({ type: 'mcpl:server-stderr', serverId: connection.id, line: params.line });
8384
9242
  });
8385
9243
 
8386
- // Handle dynamic feature set changes from server
8387
- connection.on('feature-sets-changed', (params: FeatureSetsChangedParams) => {
8388
- this.featureSetManager?.handleFeatureSetsChanged(connection.id, params);
8389
- });
9244
+ // featureSets/changed is REMOVED in 0.5.0 — the connection answers it
9245
+ // -32601 (superseded by SPEC §17's host-diffed manifest fetch); no
9246
+ // listener, and nothing mutates declarations off a server-authored
9247
+ // change payload.
8390
9248
 
8391
9249
  // Handle scope elevation requests
8392
- connection.on('scope-elevate', async (
8393
- params: ScopeElevateParams,
8394
- responder?: { respond: (result: unknown) => void; respondError: (code: number, message: string) => void },
8395
- ) => {
8396
- if (this.scopeManager && responder) {
8397
- try {
8398
- const result: ScopeElevateResult = await this.scopeManager.handleElevation(params);
8399
- responder.respond(result);
8400
- } catch (error) {
8401
- const err = error instanceof Error ? error : new Error(String(error));
8402
- responder.respondError(-32603, err.message);
8403
- }
8404
- }
8405
- });
9250
+ // §7 Scoped Access is removed in 0.5.0. scope/elevate is answered
9251
+ // -32601 at the connection (server-connection.ts) and never reaches the
9252
+ // framework; the ScopeManager and config `scopes` wiring went with it.
8406
9253
 
8407
9254
  // Handle push events (Step 6)
8408
9255
  connection.on('push-event', async (
@@ -8438,9 +9285,51 @@ export class AgentFramework {
8438
9285
  await this.channelRegistry?.handleRegister(connection.id, params, responder as never);
8439
9286
  });
8440
9287
 
8441
- // Handle channel changes (Step 7)
8442
- connection.on('channels-changed', async (params: ChannelsChangedParams) => {
8443
- await this.channelRegistry?.handleChanged(connection.id, params);
9288
+ // Handle channel changes (Step 7) — §14.5 dual-mode: the responder is
9289
+ // present for the Request form and carries itemized added-descriptor
9290
+ // results; absent for Notifications, where rejection is itemwise
9291
+ // filtering plus a diagnostic trace.
9292
+ connection.on('channels-changed', async (
9293
+ params: ChannelsChangedParams,
9294
+ responder?: { respond: (result: unknown) => void },
9295
+ ) => {
9296
+ await this.channelRegistry?.handleChanged(connection.id, params, responder as never);
9297
+ });
9298
+
9299
+ // §6.6/§12: requests that previously had no handler and hung forever
9300
+ // (AUDIT-001 item 4). The connection-level grant gate has already run.
9301
+ // §17.3: a manifest-change announcement. Rate-limited re-fetch → diff
9302
+ // → §6.7 consequences → one receipt. The announcement itself carries no
9303
+ // payload and no authority; everything below acts on FETCHED content.
9304
+ connection.on('manifest-changed', (params: { revision?: string; domains?: string[] }) => {
9305
+ void this.handleManifestChanged(connection, params).catch((err) => {
9306
+ console.error(`[mcpl] ${connection.id} manifest refresh failed:`, err instanceof Error ? err.message : err);
9307
+ });
9308
+ });
9309
+
9310
+ connection.on('channels-list', (
9311
+ _params: unknown,
9312
+ responder?: { respond: (result: unknown) => void },
9313
+ ) => {
9314
+ responder?.respond({
9315
+ channels: this.channelRegistry?.descriptorsForServer(connection.id) ?? [],
9316
+ });
9317
+ });
9318
+
9319
+ connection.on('model-info', (
9320
+ _params: unknown,
9321
+ responder?: { respond: (result: unknown) => void; respondError?: (code: number, message: string, data?: unknown) => void },
9322
+ ) => {
9323
+ // §12.2 requires ALL FOUR of {id, vendor, contextWindow, capabilities}
9324
+ // in the DIRECT result shape, and numeric contextWindow cannot express
9325
+ // "unknown". The host has no truthful source for it, so modelInfo is
9326
+ // not advertised and this answers an explicit error instead of a
9327
+ // malformed partial (§6.6: a method that will never be answered
9328
+ // truthfully MUST error, not fabricate).
9329
+ responder?.respondError?.(
9330
+ -32601,
9331
+ 'model/info unavailable: host does not advertise modelInfo (no truthful contextWindow source)',
9332
+ );
8444
9333
  });
8445
9334
 
8446
9335
  // Handle incoming channel messages (Step 7)
@@ -8499,19 +9388,25 @@ export class AgentFramework {
8499
9388
  // reconnect observes it before doing work that could wake an agent. The
8500
9389
  // connection paused its data plane before wiring the fresh transport.
8501
9390
  const awarenessBarrier = this.installMcplDataPlaneGate();
8502
- try {
8503
- const config = this.mcplServerConfigs.get(connection.id);
8504
- if (config) {
8505
- this.registerMcplServerFeatures(config, connection);
9391
+ // Async: the §5.3 policy Request must be answered (re-establishing the
9392
+ // grant) before the data-plane barrier releases — otherwise the first
9393
+ // post-reconnect events land while the grant is still empty and are
9394
+ // rejected fail-closed instead of delivered.
9395
+ void (async () => {
9396
+ try {
9397
+ const config = this.mcplServerConfigs.get(connection.id);
9398
+ if (config) {
9399
+ await this.registerMcplServerFeatures(config, connection);
9400
+ }
9401
+ } catch (error) {
9402
+ console.error(
9403
+ `MCPL server "${connection.id}" re-registration after reconnect failed:`,
9404
+ error instanceof Error ? error.message : error,
9405
+ );
8506
9406
  }
8507
- } catch (error) {
8508
- console.error(
8509
- `MCPL server "${connection.id}" re-registration after reconnect failed:`,
8510
- error instanceof Error ? error.message : error,
8511
- );
8512
- }
8513
- this.releaseMcplDataPlaneGate(awarenessBarrier);
8514
- this.handleToolsListChanged(connection.id);
9407
+ this.releaseMcplDataPlaneGate(awarenessBarrier);
9408
+ this.handleToolsListChanged(connection.id);
9409
+ })();
8515
9410
  void awarenessBarrier.promise.then(() => {
8516
9411
  if (
8517
9412
  awarenessBarrier.requiresBarrier
@@ -8626,6 +9521,7 @@ export class AgentFramework {
8626
9521
  if (!this.mcplServerRegistry) return;
8627
9522
 
8628
9523
  const tools: import('./types/index.js').ToolDefinition[] = [];
9524
+ const toolFeatureSets = new Map<string, string>();
8629
9525
 
8630
9526
  for (const server of this.mcplServerRegistry.getAllServers()) {
8631
9527
  const config = this.mcplServerConfigs.get(server.id);
@@ -8634,13 +9530,26 @@ export class AgentFramework {
8634
9530
  const result = await server.sendToolsList();
8635
9531
  for (const tool of result.tools) {
8636
9532
  if (!isToolAllowed(tool.name, config)) continue;
9533
+ const namespacedName = `${prefix}--${tool.name}`;
9534
+ const attributedTool = tool as typeof tool & {
9535
+ featureSet?: unknown;
9536
+ _meta?: { featureSet?: unknown };
9537
+ };
9538
+ const featureSet = typeof attributedTool.featureSet === 'string'
9539
+ ? attributedTool.featureSet
9540
+ : typeof attributedTool._meta?.featureSet === 'string'
9541
+ ? attributedTool._meta.featureSet
9542
+ : undefined;
8637
9543
  // MCP tool schemas are generic JSON Schema; cast to membrane's ToolDefinition format
8638
9544
  const schema = tool.inputSchema as import('./types/index.js').ToolDefinition['inputSchema'];
8639
9545
  tools.push({
8640
- name: `${prefix}--${tool.name}`,
9546
+ name: namespacedName,
8641
9547
  description: tool.description ?? '',
8642
9548
  inputSchema: schema,
8643
9549
  });
9550
+ if (featureSet !== undefined) {
9551
+ toolFeatureSets.set(namespacedName, featureSet);
9552
+ }
8644
9553
  }
8645
9554
  } catch {
8646
9555
  // Server may not support tools/list — skip silently
@@ -8648,6 +9557,7 @@ export class AgentFramework {
8648
9557
  }
8649
9558
 
8650
9559
  this.mcplTools = tools;
9560
+ this.mcplToolFeatureSets = toolFeatureSets;
8651
9561
  }
8652
9562
 
8653
9563
  /**
@@ -8745,20 +9655,47 @@ export class AgentFramework {
8745
9655
  const args = (call.input && typeof call.input === 'object') ? call.input as Record<string, unknown> : {};
8746
9656
 
8747
9657
  // Build state params for stateful tools (Step 8).
8748
- // Host-managed state is single-valued per server, so inject the host-managed
8749
- // set's `state` for any call; otherwise fall back to a server-managed set's
8750
- // opaque `checkpoint`. Never blindly pick "first stateful" — when a server
8751
- // mixes host-managed (e.g. a feed) and server-managed (e.g. post/undo) sets,
8752
- // that misattributes the feed's state.
9658
+ // A tools/list attribution selects that exact stateful set. Untagged tools
9659
+ // retain the host-managed or unique server-managed fallback, but never guess
9660
+ // between multiple server-managed sets.
8753
9661
  let stateParams: { state?: unknown; checkpoint?: string } | undefined;
9662
+ let attributedFeatureSet: string | null = null;
8754
9663
  if (this.checkpointManager) {
8755
- const hostFs = this.checkpointManager.getHostManagedFeatureSet(serverId);
8756
- if (hostFs) {
8757
- stateParams = { state: this.checkpointManager.getCurrentState(serverId, hostFs) };
9664
+ const toolFeatureSet = this.mcplToolFeatureSets.get(call.name);
9665
+ if (toolFeatureSet !== undefined) {
9666
+ if (
9667
+ this.checkpointManager.isHostManaged(serverId, toolFeatureSet)
9668
+ || this.checkpointManager.getStatefulFeatureSet(serverId, toolFeatureSet) !== null
9669
+ ) {
9670
+ attributedFeatureSet = toolFeatureSet;
9671
+ } else {
9672
+ console.warn(
9673
+ `[mcpl] Tool "${call.name}" declares unknown or non-stateful feature set ` +
9674
+ `"${toolFeatureSet}"; skipping state exchange.`,
9675
+ );
9676
+ }
8758
9677
  } else {
8759
- const fs = this.checkpointManager.getStatefulFeatureSet(serverId);
8760
- if (fs) {
8761
- const cp = this.checkpointManager.getCurrentCheckpoint(serverId, fs);
9678
+ attributedFeatureSet = this.checkpointManager.getHostManagedFeatureSet(serverId)
9679
+ ?? this.checkpointManager.getStatefulFeatureSet(serverId);
9680
+ if (
9681
+ attributedFeatureSet === null
9682
+ && this.checkpointManager.hasAmbiguousServerManagedFeatureSets(serverId)
9683
+ ) {
9684
+ console.warn(
9685
+ `[mcpl] Tool "${call.name}" has no feature-set attribution and server ` +
9686
+ `"${serverId}" has multiple server-managed stateful feature sets; ` +
9687
+ 'skipping state exchange.',
9688
+ );
9689
+ }
9690
+ }
9691
+
9692
+ if (attributedFeatureSet !== null) {
9693
+ if (this.checkpointManager.isHostManaged(serverId, attributedFeatureSet)) {
9694
+ stateParams = {
9695
+ state: this.checkpointManager.getCurrentState(serverId, attributedFeatureSet),
9696
+ };
9697
+ } else {
9698
+ const cp = this.checkpointManager.getCurrentCheckpoint(serverId, attributedFeatureSet);
8762
9699
  if (cp) stateParams = { checkpoint: cp };
8763
9700
  }
8764
9701
  }
@@ -8770,15 +9707,10 @@ export class AgentFramework {
8770
9707
  this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs });
8771
9708
 
8772
9709
  // Record checkpoint from stateful tool response (Step 8).
8773
- // Attribute to the set the server tagged (result.state.featureSet) first;
8774
- // else the host-managed set (host-managed checkpoints carry data/patch);
8775
- // else the single stateful set. Avoids recording a feed checkpoint onto a
8776
- // server-managed set just because it registered first.
9710
+ // Explicit response attribution is authoritative. Otherwise use the
9711
+ // tool-derived (or safe single-set) attribution chosen before the call.
8777
9712
  if (result.state && this.checkpointManager) {
8778
- const tagged = result.state.featureSet;
8779
- const fs = tagged
8780
- ?? this.checkpointManager.getHostManagedFeatureSet(serverId)
8781
- ?? this.checkpointManager.getStatefulFeatureSet(serverId);
9713
+ const fs = result.state.featureSet ?? attributedFeatureSet;
8782
9714
  if (fs && this.checkpointManager.isStateful(serverId, fs)) {
8783
9715
  this.checkpointManager.recordCheckpoint(serverId, fs, result.state);
8784
9716
  }
@@ -8803,6 +9735,23 @@ export class AgentFramework {
8803
9735
  this.channelRegistry
8804
9736
  .openIfClosedForSend(target, serverId)
8805
9737
  .then(({ status, channelId, label }) => {
9738
+ // Turn-scoped engagement record (mid-turn re-pin, 2026-07-31
9739
+ // n=7): a human follow-up in a channel the agent explicitly
9740
+ // sent into this turn counts as conversationally addressed.
9741
+ // Recorded here — not eagerly — because injection metadata
9742
+ // carries the CANONICAL channel id (descriptor form), while
9743
+ // send args often carry the raw provider id; this callback
9744
+ // has the resolved form. The already-open path resolves on
9745
+ // the microtask queue, i.e. before the tool-result event
9746
+ // that evaluates injections.
9747
+ if (status !== 'unknown-channel' && status !== 'ambiguous') {
9748
+ let engaged = this.turnEngagedChannels.get(agentName);
9749
+ if (!engaged) {
9750
+ engaged = new Set();
9751
+ this.turnEngagedChannels.set(agentName, engaged);
9752
+ }
9753
+ engaged.add(channelId ?? target);
9754
+ }
8806
9755
  if (status === 'opened') {
8807
9756
  console.error(
8808
9757
  `[channel] ${agentName}: explicit ${toolName} into closed channel ${target} — opened (send implies engagement)`,
@@ -8918,17 +9867,40 @@ export class AgentFramework {
8918
9867
  const durationMs = Date.now() - startTime;
8919
9868
  this.emitTrace({ type: 'tool:completed', module: 'channels', tool: call.name, callId: call.id, durationMs });
8920
9869
  // A successful channel_open plants the agent's feet in the opened
8921
- // channel: pin it as this turn's reply locus so plain-text speech
8922
- // written right after opening routes THERE. Found live 2026-07-21
8923
- // (Aria bring-up): open → plain-text reply → "no locus (no
8924
- // home/active channel, defaultPublishChannel null)" on a turn with
8925
- // no channel trigger (post-restart continuation). Next turn start
8926
- // re-resolves the locus as usual (startAgentStream set/delete).
9870
+ // channel. The agent's own deliberate open is the strongest "my next
9871
+ // words go here" signal in the system — stronger than any injection —
9872
+ // so it MOVES the current turn's prose pin (turnLocusPins), not just
9873
+ // the next turn's trigger state. (The original 2026-07-21 Aria fix
9874
+ // set only activeTriggerChannels, which resolveLocus reads at the
9875
+ // NEXT turn's freeze — the turn-frozen refactor had silently
9876
+ // regressed the "reply right after opening" case; found while
9877
+ // mapping the routing logic 2026-07-31, antra-ratified fix.) The
9878
+ // announcement rides THIS TOOL RESULT — model-requested content,
9879
+ // distance zero, the safest role there is — instead of a separate
9880
+ // window notice. lastAnnouncedLocus tracks it so announce-on-change
9881
+ // stays coherent. Locus-mode agents only: explicit mode has no pin.
8927
9882
  if (call.name === 'channel_open' && result?.success) {
8928
9883
  const opened =
8929
9884
  (result.data as { channelId?: string } | undefined)?.channelId
8930
9885
  ?? (call.input as { channelId?: string } | undefined)?.channelId;
8931
- if (opened) this.activeTriggerChannels.set(agentName, opened);
9886
+ if (opened) {
9887
+ this.activeTriggerChannels.set(agentName, opened);
9888
+ const openerAgent = this.agents.get(agentName);
9889
+ if (openerAgent && openerAgent.proseRouting !== 'explicit') {
9890
+ this.turnLocusPins.set(agentName, opened);
9891
+ this.lastAnnouncedLocus.set(agentName, opened);
9892
+ result = {
9893
+ ...result,
9894
+ data: {
9895
+ ...(result.data as Record<string, unknown> | undefined),
9896
+ routing: 'Your plain speech now lands in this channel. Other channels need an explicit send tool.',
9897
+ },
9898
+ };
9899
+ console.error(
9900
+ `[routing] ${agentName}: channel_open -> pin moved to ${opened} (announced in tool result)`,
9901
+ );
9902
+ }
9903
+ }
8932
9904
  }
8933
9905
  this.pushEvent({
8934
9906
  type: 'tool-result',
@@ -9681,12 +10653,11 @@ export class AgentFramework {
9681
10653
  conversationId: agent.name,
9682
10654
  turnIndex: 0, // Simplified; needs per-conversation counter TODO
9683
10655
  userMessage: null, // Could extract from trigger context
9684
- model: {
9685
- id: agent.model,
9686
- vendor: 'unknown',
9687
- contextWindow: 200000,
9688
- capabilities: ['tools'],
9689
- },
10656
+ // Truthful fields only (§10.1 honesty over completeness): id is real,
10657
+ // vendor/contextWindow/capabilities have no source in this host and
10658
+ // are omitted rather than fabricated — the hardcoded 200000 was false
10659
+ // for residents configured at 300k/600k.
10660
+ model: { id: agent.model },
9690
10661
  channels: this.channelRegistry?.buildChannelContext(agent.name),
9691
10662
  };
9692
10663
  }