@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
@@ -4,11 +4,13 @@ import { JsStore } from '@animalabs/chronicle';
4
4
  import { MembraneError } from '@animalabs/membrane';
5
5
  import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager';
6
6
  import { ProcessQueueImpl } from './queue.js';
7
+ import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK } from './refusal-reactions.js';
7
8
  import { Agent } from './agent.js';
8
9
  import { ModuleRegistry, isStateExistsError } from './module-registry.js';
9
10
  import { McplServerRegistry } from './mcpl/server-registry.js';
10
11
  import { FeatureSetManager } from './mcpl/feature-set-manager.js';
11
- import { ScopeManager } from './mcpl/scope-manager.js';
12
+ import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './mcpl/capability-grant.js';
13
+ import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js';
12
14
  import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
13
15
  import { PushHandler } from './mcpl/push-handler.js';
14
16
  import { parseProsePrefix } from './mcpl/prose-grammar.js';
@@ -17,7 +19,7 @@ import { InferenceRouter } from './mcpl/inference-router.js';
17
19
  import { ChannelRegistry } from './mcpl/channel-registry.js';
18
20
  import { ConversationRouter } from './mcpl/conversation-router.js';
19
21
  import { safeSlice } from './safe-slice.js';
20
- import { toolResultDataToHistoryString } from './tool-result-history.js';
22
+ import { toolResultDataToHistoryString, truncateForHistory, DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, } from './tool-result-history.js';
21
23
  import { randomUUID } from 'node:crypto';
22
24
  import { PyRunner, buildInjectedTools } from './code-execution/py-runner.js';
23
25
  import { buildCodeExecutionToolDefinition, CODE_EXECUTION_TOOL_NAME, } from './code-execution/tool-definition.js';
@@ -79,6 +81,39 @@ const isConversationalInjection = (metadata) => {
79
81
  return false;
80
82
  return true;
81
83
  };
84
+ /**
85
+ * Rough token estimate for the blocks a continuation round is about to
86
+ * append to the live wire (issue #92 physical-window projection): the
87
+ * spilled tool-result strings at chars/4, a flat per-image cost for native
88
+ * image blocks the wire path preserves (provider vision tokens are
89
+ * resolution-dependent; 1600 ≈ a max-size Anthropic tile), mid-turn injected
90
+ * text at chars/4, and a small per-block envelope allowance. Deliberately a
91
+ * ceiling-ish heuristic — the cost of overestimating is one early recompile;
92
+ * the cost of underestimating is the 400 this projection exists to prevent.
93
+ */
94
+ function estimateAppendedRoundTokens(toolResults, spilled, injections) {
95
+ let chars = 0;
96
+ let images = 0;
97
+ for (const tc of toolResults) {
98
+ chars += spilled.get(tc.id)?.text.length ?? 0;
99
+ if (Array.isArray(tc.result.data)) {
100
+ for (const raw of tc.result.data) {
101
+ const b = raw;
102
+ if (b && typeof b === 'object' && b.type === 'image')
103
+ images += 1;
104
+ }
105
+ }
106
+ }
107
+ for (const inj of injections) {
108
+ for (const block of inj.content) {
109
+ if (block.type === 'text' && typeof block.text === 'string') {
110
+ chars += block.text.length;
111
+ }
112
+ }
113
+ }
114
+ const blockCount = toolResults.length + injections.length;
115
+ return Math.ceil(chars / 4) + images * 1600 + blockCount * 20;
116
+ }
82
117
  /**
83
118
  * Explicit delivery tools whose successful use into a closed channel OPENS
84
119
  * it (send implies engagement — see openIfClosedForSend). Bare tool names,
@@ -369,8 +404,45 @@ export class AgentFramework {
369
404
  * plain prose lands (2026-07-21 Cairn lounge misroute). Lives here — not in
370
405
  * driveStream locals — so the pin survives a context-budget stream restart,
371
406
  * which continues the same logical turn in a fresh driveStream. Cleared or
372
- * re-set at the start of every non-restart turn. */
407
+ * re-set at the start of every non-restart turn. Since 2026-07-31 the pin
408
+ * is no longer fully frozen: an ADDRESSED conversational injection — or a
409
+ * conversational follow-up in a channel the agent itself explicitly sent
410
+ * into this turn (turnEngagedChannels) — re-pins it at the boundary (see
411
+ * the addressed re-pin block in the tool-result handler). Ambient chatter
412
+ * elsewhere, reactions, and system markers still cannot move it. */
373
413
  turnLocusPins = new Map();
414
+ /** Channels the agent EXPLICITLY sent into during the CURRENT turn
415
+ * (SEND_ENGAGEMENT_TOOLS, successful calls), per agent. Second re-pin
416
+ * signal: a conversational reply in a channel the agent just engaged is
417
+ * addressed by context even without a mention (2026-07-31 n=7: q's
418
+ * #portables follow-up — no @mention — was answered in trailing prose
419
+ * that followed the stale pin into repligate's DM). No author-kind
420
+ * filter: agent-residents are full participants, and the bot flag
421
+ * tracks nothing that matters (antra). Reactions/system markers are
422
+ * excluded by isConversationalInjection; the engaged-this-turn scope
423
+ * keeps unrelated channels from moving the pin. Cleared at every fresh
424
+ * turn's start; budget restarts keep it. */
425
+ turnEngagedChannels = new Map();
426
+ /** Channels this turn's PLAIN PROSE was actually delivered to, in delivery
427
+ * order (deduped at render). Feeds the `[delivered]` receipt appended at
428
+ * logical turn end: explicit sends receipt themselves via their
429
+ * tool_result, but auto-routed prose previously vanished into the router
430
+ * with no in-window record — the agent could never see where its own
431
+ * words landed (2026-07-31 misroute series; antra: minimal receipts).
432
+ * Cleared at every fresh turn's start; budget restarts keep it (same
433
+ * logical turn, receipt covers the whole turn). */
434
+ turnProseDeliveries = new Map();
435
+ /** Count of plain-prose segments SUPPRESSED this turn by sticky
436
+ * explicit-send silencing. The suppression rule itself is untouched
437
+ * (antra, 2026-07-31: visibility is enough) — but it must never be a
438
+ * silent black hole: Mythos multiplexed two threads in one round (prose
439
+ * for the lane + explicit send for another channel, textbook per the
440
+ * routing doc), the silencing ate the prose reply, and his own record
441
+ * ("scene delivered to #mythos via plain speech — routing confirmed")
442
+ * believed it delivered. The receipt now reports suppression, so the
443
+ * author sees the segment's fate one turn later. Cleared each fresh
444
+ * turn; budget restarts keep it. */
445
+ turnProseSuppressed = new Map();
374
446
  /** A tool boundary injected fresh CONVERSATIONAL input (a real message —
375
447
  * not a reaction or a system marker) into the live stream. Tells
376
448
  * driveStream to clear sticky explicit-send suppression before handling
@@ -479,7 +551,6 @@ export class AgentFramework {
479
551
  // MCPL subsystems (null when no mcplServers configured)
480
552
  mcplServerRegistry = null;
481
553
  featureSetManager = null;
482
- scopeManager = null;
483
554
  hookOrchestrator = null;
484
555
  pushHandler = null;
485
556
  inferenceRouter = null;
@@ -516,11 +587,17 @@ export class AgentFramework {
516
587
  * provenance envelope + payload and requests inference. Keyed by script id. */
517
588
  backgroundScripts = new Map();
518
589
  backgroundScriptCounter = 0;
519
- /** Ephemeral per-agent override of the tool-result inline cap (chars).
520
- * Set via agent_settings `tool_result_inline_max_chars`; NOT persisted —
521
- * the lift is meant as a temporary gate, reverts on restart/reset. */
590
+ /** Resident-set per-agent tool-result inline cap (chars), via
591
+ * agent_settings `tool_result_inline_max_chars`. DURABLE: persisted in
592
+ * framework state like the core runtime settings and restored at create
593
+ * (antra + Sol, 08-06); reset clears it back to the residence default. */
522
594
  toolResultInlineMaxCharsOverride = new Map();
595
+ /** Durable residence-configured inline cap from
596
+ * FrameworkConfig.toolResultInlineMaxChars; null → house default. */
597
+ toolResultInlineMaxCharsConfig = null;
523
598
  mcplTools = [];
599
+ /** Namespaced tool name → stateful feature-set attribution from tools/list. */
600
+ mcplToolFeatureSets = new Map();
524
601
  mcplToolRefreshInFlight = false;
525
602
  mcplToolRefreshPending = false;
526
603
  /** Maps tool prefix → serverId for dispatch routing. */
@@ -745,6 +822,18 @@ export class AgentFramework {
745
822
  // Client-side programmatic tool calling (code_execution). Config is only
746
823
  // retained when enabled — everything downstream gates on the field.
747
824
  framework.codeExecutionConfig = config.codeExecution?.enabled ? config.codeExecution : null;
825
+ if (config.toolResultInlineMaxChars !== undefined) {
826
+ const cap = config.toolResultInlineMaxChars;
827
+ if (!Number.isFinite(cap) || cap < 1000) {
828
+ throw new Error('FrameworkConfig.toolResultInlineMaxChars must be a number >= 1000');
829
+ }
830
+ framework.toolResultInlineMaxCharsConfig = Math.floor(cap);
831
+ }
832
+ // Restore resident-set inline caps (agent_settings) — durable like the
833
+ // core runtime settings, same framework/state slot (antra + Sol, 08-06).
834
+ for (const [agentName, cap] of Object.entries(framework.readPersistedToolResultInlineCaps())) {
835
+ framework.toolResultInlineMaxCharsOverride.set(agentName, cap);
836
+ }
748
837
  // Initialize MCPL subsystems if configured
749
838
  if (config.mcplServers && config.mcplServers.length > 0) {
750
839
  // Validate tool prefixes: no collisions with module names or between servers
@@ -1138,10 +1227,11 @@ export class AgentFramework {
1138
1227
  collectAgentSettingsExtensions() {
1139
1228
  const result = new Map();
1140
1229
  const taken = new Set(AgentFramework.AGENT_SETTINGS_CORE_KEYS);
1141
- // Framework-owned extension: the tool-result inline cap lift. Registered
1142
- // through the same extension surface modules use so get/update/reset all
1143
- // work with zero extra plumbing. EPHEMERAL by design (a temporary gate —
1144
- // reverts on restart), unlike module extensions which persist their own.
1230
+ // Framework-owned extension: the resident's tool-result inline cap.
1231
+ // Registered through the same extension surface modules use so
1232
+ // get/update/reset all work with zero extra plumbing. DURABLE: persisted
1233
+ // in framework state and restored at create, like the core runtime
1234
+ // settings (antra + Sol, 08-06); reset returns to the residence default.
1145
1235
  {
1146
1236
  const ext = this.spillSettingsExtension();
1147
1237
  ext.keys.forEach((k) => taken.add(k));
@@ -1167,28 +1257,40 @@ export class AgentFramework {
1167
1257
  properties: {
1168
1258
  tool_result_inline_max_chars: {
1169
1259
  type: 'number',
1170
- description: 'Inline size cap (chars) for tool results and background-script wake payloads. ' +
1171
- 'Content over the cap is written to a workspace file under tool-results/ and ' +
1172
- 'replaced by a truncated preview + file reference. Raise it temporarily when you ' +
1173
- 'genuinely want a large result inline; reset restores the default (derived from ' +
1174
- 'your strategy). Ephemeral — reverts on host restart.',
1260
+ description: 'Inline size cap (chars) for tool results (successes and errors) and ' +
1261
+ 'background-script wake payloads. Content over the cap is written to a workspace ' +
1262
+ 'file under tool-results/ and replaced by a truncated preview + file reference. ' +
1263
+ 'This is YOUR durable setting: it persists across restarts, like your other ' +
1264
+ 'agent_settings. Update it when you want a different inline size; reset restores ' +
1265
+ 'the residence default. The effective cap is min(your value, your strategy\'s ' +
1266
+ 'per-message bound) — the bound is a safety ceiling, and the full content is ' +
1267
+ 'always in the spill file. get reports desired/_effective/_source/_clamped_by.',
1175
1268
  },
1176
1269
  },
1177
1270
  keys: ['tool_result_inline_max_chars'],
1178
- get: (agentName) => ({
1179
- tool_result_inline_max_chars: this.toolResultInlineMaxCharsOverride.get(agentName) ?? null,
1180
- }),
1271
+ get: (agentName) => {
1272
+ const agent = this.agents.get(agentName);
1273
+ const resolved = agent ? this.resolveToolResultInlineCap(agent) : null;
1274
+ return {
1275
+ tool_result_inline_max_chars: this.toolResultInlineMaxCharsOverride.get(agentName) ?? null,
1276
+ tool_result_inline_max_chars_effective: resolved?.cap ?? null,
1277
+ tool_result_inline_max_chars_source: resolved?.source ?? null,
1278
+ tool_result_inline_max_chars_clamped_by: resolved?.clampedBy ?? null,
1279
+ };
1280
+ },
1181
1281
  update: (agentName, patch) => {
1182
1282
  const n = Number(patch.tool_result_inline_max_chars);
1183
1283
  if (!Number.isFinite(n) || n < 1000) {
1184
1284
  throw new Error('tool_result_inline_max_chars must be a number >= 1000');
1185
1285
  }
1186
1286
  this.toolResultInlineMaxCharsOverride.set(agentName, Math.floor(n));
1287
+ this.persistToolResultInlineCap(agentName, Math.floor(n));
1187
1288
  return { tool_result_inline_max_chars: Math.floor(n) };
1188
1289
  },
1189
1290
  reset: (agentName, keys) => {
1190
1291
  if (!keys || keys.includes('tool_result_inline_max_chars')) {
1191
1292
  this.toolResultInlineMaxCharsOverride.delete(agentName);
1293
+ this.persistToolResultInlineCap(agentName, null);
1192
1294
  }
1193
1295
  return { tool_result_inline_max_chars: null };
1194
1296
  },
@@ -1544,6 +1646,44 @@ export class AgentFramework {
1544
1646
  }
1545
1647
  return overrides;
1546
1648
  }
1649
+ /** Load resident-set inline caps from framework state, dropping (loudly)
1650
+ * anything that fails the same validation the live update enforces. */
1651
+ readPersistedToolResultInlineCaps() {
1652
+ try {
1653
+ const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
1654
+ const out = {};
1655
+ for (const [name, value] of Object.entries(data?.toolResultInlineCaps ?? {})) {
1656
+ const n = Number(value);
1657
+ if (Number.isFinite(n) && n >= 1000) {
1658
+ out[name] = Math.floor(n);
1659
+ }
1660
+ else {
1661
+ console.error(`[agent-settings] dropping invalid persisted tool_result_inline_max_chars for '${name}': ${JSON.stringify(value)}`);
1662
+ }
1663
+ }
1664
+ return out;
1665
+ }
1666
+ catch {
1667
+ return {};
1668
+ }
1669
+ }
1670
+ /** Write (cap) or clear (null) one agent's resident-set inline cap. */
1671
+ persistToolResultInlineCap(agentName, cap) {
1672
+ const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
1673
+ const state = (data && typeof data === 'object' ? data : {});
1674
+ const all = {
1675
+ ...(state.toolResultInlineCaps ?? {}),
1676
+ };
1677
+ if (cap === null)
1678
+ delete all[agentName];
1679
+ else
1680
+ all[agentName] = cap;
1681
+ if (Object.keys(all).length === 0)
1682
+ delete state.toolResultInlineCaps;
1683
+ else
1684
+ state.toolResultInlineCaps = all;
1685
+ this.store.setStateJson(FRAMEWORK_STATE_ID, state);
1686
+ }
1547
1687
  persistAgentRuntimeSettings(agentName, overrides) {
1548
1688
  const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
1549
1689
  const state = (data && typeof data === 'object' ? data : {});
@@ -1982,14 +2122,6 @@ export class AgentFramework {
1982
2122
  'chat:group': 'Group (multi-party) conversation',
1983
2123
  'chat:thread': 'Occurred in a thread',
1984
2124
  };
1985
- /** Refusal category → Discord reaction emoji. Unknown categories get 🛑. */
1986
- static REFUSAL_REACTIONS = {
1987
- bio: '☣️',
1988
- chem: '🧪',
1989
- nuclear: '☢️',
1990
- cyber: '💻',
1991
- reasoning_extraction: '🧠',
1992
- };
1993
2125
  /**
1994
2126
  * Mark an inference refusal visibly: react on the message that holds the
1995
2127
  * conversational locus (the most recent incoming channel message) with a
@@ -2008,7 +2140,7 @@ export class AgentFramework {
2008
2140
  if (parts[0] !== 'discord')
2009
2141
  return;
2010
2142
  const channelId = parts[parts.length - 1];
2011
- const emoji = AgentFramework.REFUSAL_REACTIONS[category] ?? '🛑';
2143
+ const emoji = REFUSAL_REACTIONS[category] ?? REFUSAL_REACTION_FALLBACK;
2012
2144
  // Resolve the MCPL server that owns the locus channel and call
2013
2145
  // tools/call directly on its connection (bare tool name — no prefix
2014
2146
  // games), bypassing the agent event queue so no synthetic tool-result
@@ -2204,6 +2336,38 @@ export class AgentFramework {
2204
2336
  console.error('[unstick] report post failed:', err instanceof Error ? err.message : err);
2205
2337
  }
2206
2338
  }
2339
+ /**
2340
+ * Admin-level nudge: run an inference turn on the agent's CURRENT context
2341
+ * without adding any message, event, or other context mutation. The turn
2342
+ * compiles exactly what the agent already sees — useful after an /undo,
2343
+ * a /hide, or whenever an operator wants the agent to take another look
2344
+ * (or another swing) at where it stands, with zero context pollution.
2345
+ *
2346
+ * Queues a normal inference request (same path as gate wakes): if the
2347
+ * agent is mid-turn it runs when the turn settles; a pending skip_reply
2348
+ * self-wake is superseded like any other wake. Deliberately NOT gated on
2349
+ * idle — "run when you're free" is the useful semantic. No suppression
2350
+ * interplay: gate sleep filters events, not queued requests, so a nudge
2351
+ * wakes even a sleeping agent (it's an admin override, like /unstick).
2352
+ */
2353
+ nudgeAgent(agentName, requestedBy) {
2354
+ const name = agentName ?? [...this.agents.keys()][0];
2355
+ const agent = name ? this.agents.get(name) : undefined;
2356
+ if (!name || !agent) {
2357
+ return { ok: false, error: `Unknown agent: ${String(agentName ?? '(none registered)')}` };
2358
+ }
2359
+ const agentStatus = this.activeTurnTokens.has(name) && agent.state.status === 'idle'
2360
+ ? 'idle+turn-alive'
2361
+ : agent.state.status;
2362
+ this.pendingRequests.push({
2363
+ agentName: name,
2364
+ reason: requestedBy ? `admin-nudge (${requestedBy})` : 'admin-nudge',
2365
+ source: 'admin',
2366
+ timestamp: Date.now(),
2367
+ });
2368
+ console.error(`[nudge] agent=${name} queued by=${requestedBy ?? 'unknown'} status=${agentStatus}`);
2369
+ return { ok: true, agentName: name, agentStatus };
2370
+ }
2207
2371
  /**
2208
2372
  * Handle a `host/command` request from an MCPL surface server (e.g. a
2209
2373
  * Discord slash command). Currently supports:
@@ -2212,15 +2376,27 @@ export class AgentFramework {
2212
2376
  * `undoLastTurn`). The response includes the last message the agent
2213
2377
  * would see in its context after the undo, obtained via the transparent
2214
2378
  * `previewActivation` render (no inference, no Chronicle writes).
2379
+ *
2380
+ * nudge — run inference on the current context with NO new events
2381
+ * (see `nudgeAgent`).
2215
2382
  */
2216
2383
  async handleHostCommand(serverId, params) {
2217
- if (params.command !== 'undo' && params.command !== 'hide' && params.command !== 'unstick') {
2384
+ if (params.command !== 'undo' &&
2385
+ params.command !== 'hide' &&
2386
+ params.command !== 'unstick' &&
2387
+ params.command !== 'nudge') {
2218
2388
  return { ok: false, error: `Unknown host command: ${String(params.command)}` };
2219
2389
  }
2220
2390
  const agentName = params.agentName ?? [...this.agents.keys()][0];
2221
2391
  if (!agentName || !this.agents.has(agentName)) {
2222
2392
  return { ok: false, error: `Unknown agent: ${String(agentName)}` };
2223
2393
  }
2394
+ // nudge: queue an inference on the current context — no message, no
2395
+ // event, no context mutation (see nudgeAgent).
2396
+ if (params.command === 'nudge') {
2397
+ const r = this.nudgeAgent(agentName, params.requesterName ?? params.requesterId ?? `mcpl:${serverId}`);
2398
+ return { ok: r.ok, error: r.error, agentStatus: r.agentStatus };
2399
+ }
2224
2400
  // unstick: force the refusal-rewind loop on demand (even if the agent's
2225
2401
  // autoRewind toggle is off). Redacts the turn that fed the refusal and
2226
2402
  // re-runs, up to `cap` times, until the model stops refusing. Kicks the
@@ -2993,32 +3169,16 @@ export class AgentFramework {
2993
3169
  agent.addAssistantResponse(pendingBlocks);
2994
3170
  this.pendingAssistantBlocks.delete(agent.name);
2995
3171
  }
2996
- // Compute truncation limit from agent's strategy (maxMessageTokens * 4 chars)
2997
- const maxChars = this.getMaxToolResultChars(agent);
2998
- // Oversized results spill to a workspace file (truncated preview +
2999
- // file reference) instead of being blind-truncated. Computed ONCE
3000
- // per call and reused for both the history copy and the live wire
3001
- // copy below — the two must stay byte-matched (the window stores
3002
- // what the membrane sends; divergence breaks the compile prefix).
3003
- const spilled = new Map();
3004
- const dateLabel = new Date().toISOString().slice(0, 10);
3005
- for (const tc of currentState.toolResults) {
3006
- if (tc.result.isError)
3007
- continue;
3008
- spilled.set(tc.id, await this.spillOrTruncate(toolResultDataToHistoryString(tc.result.data, undefined), maxChars, `${dateLabel}-${tc.id}`, agent.name));
3009
- }
3010
- // Store tool results as a user message (tool_result blocks).
3011
- // Use the history serializer so MCP image blocks become a short
3012
- // `[image: type, size]` placeholder instead of megabytes of base64
3013
- // that would corrupt under truncation.
3014
- const toolResultContent = currentState.toolResults.map(tc => ({
3015
- type: 'tool_result',
3016
- toolUseId: tc.id,
3017
- content: tc.result.isError
3018
- ? tc.result.error ?? 'Unknown error'
3019
- : spilled.get(tc.id) ?? toolResultDataToHistoryString(tc.result.data, maxChars),
3020
- isError: tc.result.isError,
3021
- }));
3172
+ // Effective inline cap: agent_settings override → durable config →
3173
+ // house default, clamped to the strategy bound (see resolver).
3174
+ const maxChars = this.resolveToolResultInlineCap(agent).cap;
3175
+ // Oversized results — successes AND errors — spill to a workspace
3176
+ // file (truncated preview + file reference) instead of being
3177
+ // blind-truncated. Computed ONCE per call and reused for both the
3178
+ // history copy and the live wire copy below — the two must stay
3179
+ // byte-matched (the window stores what the membrane sends;
3180
+ // divergence breaks the compile prefix).
3181
+ const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(currentState.toolResults, maxChars);
3022
3182
  agent.getContextManager().addMessage('user', toolResultContent);
3023
3183
  // Flush any messages that were deferred while this turn was in
3024
3184
  // flight. Route to the PRIMARY agent — deferred messages are
@@ -3083,6 +3243,29 @@ export class AgentFramework {
3083
3243
  const overBudget = currentState.stream
3084
3244
  && agent.lastStreamInputTokens > 0
3085
3245
  && agent.lastStreamInputTokens > agent.maxStreamTokens;
3246
+ // Physical-window projection (issue #92): a turn's continuation
3247
+ // rounds append to the compiled request without recompiling, so a
3248
+ // turn that starts near the compile target can walk past the
3249
+ // provider's HARD cap mid-turn and take a wire 400 — the compile
3250
+ // was legal, the growth wasn't. Project the next round's real size
3251
+ // (cache-inclusive input of the prior round + the blocks about to
3252
+ // be appended + reserve for the response) and, when it would cross
3253
+ // the physical window, break the stream through the same
3254
+ // budget-restart path — a fresh compile folds the history back
3255
+ // under budget instead of dispatching a doomed request.
3256
+ // Projection terms: prior round's real input + prior round's
3257
+ // OUTPUT (the just-generated thinking/text/tool_use joins the next
3258
+ // round's input — omitting it is a systematic underestimate) +
3259
+ // blocks about to be appended + reserve for the next response.
3260
+ const projectedRealTokens = currentState.stream
3261
+ && agent.physicalWindowTokens !== undefined
3262
+ && agent.lastStreamRealInputTokens > 0
3263
+ ? agent.lastStreamRealInputTokens
3264
+ + agent.lastStreamOutputTokens
3265
+ + estimateAppendedRoundTokens(currentState.toolResults, spilled, midTurnInjections)
3266
+ + agent.maxTokens
3267
+ : 0;
3268
+ const overPhysical = projectedRealTokens > (agent.physicalWindowTokens ?? Infinity);
3086
3269
  // Addressed re-pin (2026-07-31 Mythos misroutes, antra-ratified):
3087
3270
  // when someone ADDRESSES the agent mid-turn from another channel
3088
3271
  // (mention / reply / DM — the same chat:addressed signal the
@@ -3109,13 +3292,34 @@ export class AgentFramework {
3109
3292
  // diffs against what the agent was actually last told.
3110
3293
  if (agent.proseRouting !== 'explicit' &&
3111
3294
  !shouldEndTurn && !overBudget && currentState.stream) {
3112
- const lastAddressed = [...midTurnInjections].reverse().find((inj) => {
3295
+ // Two signals qualify an injection to move the pin (n=6 + n=7):
3296
+ // - chat:addressed — someone explicitly spoke TO the agent
3297
+ // (mention / reply / DM);
3298
+ // - a conversational follow-up in a channel the agent itself
3299
+ // explicitly sent into THIS turn (turnEngagedChannels) —
3300
+ // conversation the agent just engaged continues without
3301
+ // re-mentioning it (2026-07-31: q's #portables reply, no @,
3302
+ // answered in prose that followed the stale pin into
3303
+ // repligate's DM). Deliberately NO author-kind filter: this
3304
+ // fleet's participants include agent-residents whose speech
3305
+ // is as conversational as anyone's — the Discord bot flag
3306
+ // tracks nothing that matters here (antra, 2026-07-31).
3307
+ // Reactions and system markers are already excluded by
3308
+ // isConversationalInjection, and the engaged-this-turn scope
3309
+ // keeps unrelated channels from moving the pin.
3310
+ const engaged = this.turnEngagedChannels.get(agent.name);
3311
+ const lastQualifying = [...midTurnInjections].reverse().find((inj) => {
3113
3312
  const m = inj.metadata;
3114
- return (isConversationalInjection(inj.metadata) &&
3115
- typeof m?.channelId === 'string' &&
3116
- isAddressedMessage(m.tags, m));
3313
+ if (!isConversationalInjection(inj.metadata))
3314
+ return false;
3315
+ if (typeof m?.channelId !== 'string')
3316
+ return false;
3317
+ const tags = m.tags;
3318
+ if (isAddressedMessage(tags, m))
3319
+ return true;
3320
+ return engaged?.has(m.channelId) === true;
3117
3321
  });
3118
- const newLocus = lastAddressed?.metadata
3322
+ const newLocus = lastQualifying?.metadata
3119
3323
  ?.channelId;
3120
3324
  if (newLocus && this.turnLocusPins.get(agent.name) !== newLocus) {
3121
3325
  const prevPin = this.turnLocusPins.get(agent.name) ?? null;
@@ -3127,7 +3331,7 @@ export class AgentFramework {
3127
3331
  : newLocus;
3128
3332
  const noticeContent = [{
3129
3333
  type: 'text',
3130
- text: `[routing] You were just addressed from ${shown} — your plain ` +
3334
+ text: `[routing] The conversation moved to ${shown} — your plain ` +
3131
3335
  'speech now lands there for the rest of this turn. Other ' +
3132
3336
  'channels need an explicit send tool.',
3133
3337
  }];
@@ -3158,7 +3362,7 @@ export class AgentFramework {
3158
3362
  this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' });
3159
3363
  this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
3160
3364
  }
3161
- else if (overBudget) {
3365
+ else if (overBudget || overPhysical) {
3162
3366
  // Context budget exceeded: break the stream, let compile() compress.
3163
3367
  // Mark the cancel as framework-initiated BEFORE cancelling: the
3164
3368
  // membrane delivers `aborted` before the restart bumps streamId,
@@ -3183,9 +3387,12 @@ export class AgentFramework {
3183
3387
  this.emitTrace({
3184
3388
  type: 'inference:stream_restarted',
3185
3389
  agentName: agent.name,
3186
- reason: 'context_budget',
3187
- inputTokens: agent.lastStreamInputTokens,
3188
- budget: agent.maxStreamTokens,
3390
+ // 'physical_window' only when the budget check alone would
3391
+ // have let the doomed request through — overBudget keeps its
3392
+ // established label for existing consumers.
3393
+ reason: overBudget ? 'context_budget' : 'physical_window',
3394
+ inputTokens: overBudget ? agent.lastStreamInputTokens : projectedRealTokens,
3395
+ budget: overBudget ? agent.maxStreamTokens : (agent.physicalWindowTokens ?? 0),
3189
3396
  });
3190
3397
  this.pendingRequests.push({
3191
3398
  agentName: agent.name,
@@ -3353,6 +3560,12 @@ export class AgentFramework {
3353
3560
  ? event.metadata.guildName
3354
3561
  : undefined,
3355
3562
  tags: event.tags,
3563
+ missedMessages: typeof event.metadata?.missedMessages === 'number'
3564
+ ? event.metadata.missedMessages
3565
+ : undefined,
3566
+ missedCharacters: typeof event.metadata?.missedCharacters === 'number'
3567
+ ? event.metadata.missedCharacters
3568
+ : undefined,
3356
3569
  });
3357
3570
  if (invitation) {
3358
3571
  incomingContent.push(invitation.block);
@@ -3623,6 +3836,17 @@ export class AgentFramework {
3623
3836
  * this should see WHO addressed it WHERE, not a wall of snowflake ids. Raw
3624
3837
  * ids remain only inside the tool-argument instructions, where they are the
3625
3838
  * literal values to pass.
3839
+ *
3840
+ * 2026-08-05 accuracy pass: the old "you won't receive more from it unless
3841
+ * you join" was wrong in both directions — direct addresses (mentions,
3842
+ * replies to the agent) DO still arrive, and nothing warned that answering
3843
+ * via option 1 leaves the human's natural un-@'d follow-up invisible (Sol
3844
+ * answered four #architecture mentions over four days while the follow-ups
3845
+ * to her own replies were silently dropped). The wording now states the
3846
+ * actual delivery model, and option 1 carries the consequence inline. When
3847
+ * the surface tracks a missed-ambient tally (discord-mcpl sends
3848
+ * `missedMessages`/`missedCharacters` in push origin), the invitation shows
3849
+ * the count — the cost of staying out, as a number, at decision time.
3626
3850
  */
3627
3851
  buildClosedChannelInvitation(opts) {
3628
3852
  if (!this.channelRegistry)
@@ -3636,12 +3860,21 @@ export class AgentFramework {
3636
3860
  const label = opts.channelLabel ?? descriptor?.label;
3637
3861
  const channelLabel = label ? `#${label}` : `"${opts.channelId}"`;
3638
3862
  const place = opts.guildName ? `${channelLabel} in "${opts.guildName}"` : channelLabel;
3863
+ const missedCount = opts.missedMessages ?? 0;
3864
+ const missedNote = missedCount > 0
3865
+ ? `While closed, ${missedCount} message${missedCount === 1 ? '' : 's'}` +
3866
+ (opts.missedCharacters ? ` (~${opts.missedCharacters} chars)` : '') +
3867
+ ` have passed there without you. `
3868
+ : '';
3639
3869
  const block = {
3640
3870
  type: 'text',
3641
3871
  text: `\n[Channel invitation] ${opts.authorName} addressed you in ${place} — ` +
3642
- `a channel you haven't joined. You're seeing this one message; you won't receive more from it unless you join. ` +
3872
+ `a channel you haven't joined. Only messages that address you directly (an @-mention or ` +
3873
+ `a reply to one of your messages) reach you from it; the rest of its traffic is invisible to you. ` +
3874
+ missedNote +
3643
3875
  `Your options:\n` +
3644
- `1. Reply without joining — write your reply this turn prefixed with ">>${channelLabel}"; it will be delivered there.\n` +
3876
+ `1. Reply without joining — write your reply this turn prefixed with ">>${channelLabel}"; it will be delivered there. ` +
3877
+ `Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n` +
3645
3878
  `2. Join the channel — call channel_open with channelId "${opts.channelId}" and serverId "${opts.serverId}"` +
3646
3879
  (maxBackscroll > 0
3647
3880
  ? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${opts.messageId}".\n`
@@ -3696,6 +3929,8 @@ export class AgentFramework {
3696
3929
  authorName: typeof origin.authorName === 'string' && origin.authorName ? origin.authorName : 'Someone',
3697
3930
  guildName: typeof origin.guildName === 'string' && origin.guildName ? origin.guildName : undefined,
3698
3931
  tags: event.tags,
3932
+ missedMessages: typeof origin.missedMessages === 'number' ? origin.missedMessages : undefined,
3933
+ missedCharacters: typeof origin.missedCharacters === 'number' ? origin.missedCharacters : undefined,
3699
3934
  });
3700
3935
  if (invitation) {
3701
3936
  content.push(invitation.block);
@@ -3893,6 +4128,68 @@ export class AgentFramework {
3893
4128
  });
3894
4129
  }
3895
4130
  }
4131
+ /** Record prose segments suppressed by explicit-send silencing. */
4132
+ recordProseSuppression(agentName, count) {
4133
+ if (count <= 0)
4134
+ return;
4135
+ this.turnProseSuppressed.set(agentName, (this.turnProseSuppressed.get(agentName) ?? 0) + count);
4136
+ }
4137
+ /** Record a successful plain-prose delivery for this turn's receipt. */
4138
+ recordProseDelivery(agentName, outcome) {
4139
+ if (!outcome?.delivered)
4140
+ return;
4141
+ let list = this.turnProseDeliveries.get(agentName);
4142
+ if (!list) {
4143
+ list = [];
4144
+ this.turnProseDeliveries.set(agentName, list);
4145
+ }
4146
+ list.push(outcome.channelId);
4147
+ }
4148
+ /**
4149
+ * Append the turn's `[delivered]` receipt: one compact system message
4150
+ * naming where this turn's plain prose actually landed (channels in
4151
+ * delivery order, deduped). Explicit sends receipt themselves via their
4152
+ * tool_result; auto-routed prose previously left no in-window trace, so
4153
+ * the agent could never see where its own words went — misroutes were
4154
+ * invisible to it until a human reported them (2026-07-31 series).
4155
+ * Called ONLY at logical turn end (stream 'complete', or the endTurn
4156
+ * cancel path after the speech chain settles) — never mid-stream, where a
4157
+ * window-only insert would diverge from the wire. No-op when the turn
4158
+ * delivered nothing. Failures are already marked separately
4159
+ * ([discord-send-failed]); this is the success half.
4160
+ */
4161
+ appendProseDeliveryReceipt(agent) {
4162
+ const list = this.turnProseDeliveries.get(agent.name);
4163
+ const suppressed = this.turnProseSuppressed.get(agent.name) ?? 0;
4164
+ if ((!list || list.length === 0) && suppressed === 0)
4165
+ return;
4166
+ this.turnProseDeliveries.delete(agent.name);
4167
+ this.turnProseSuppressed.delete(agent.name);
4168
+ const seen = new Set();
4169
+ const shown = [];
4170
+ for (const id of list ?? []) {
4171
+ if (seen.has(id))
4172
+ continue;
4173
+ seen.add(id);
4174
+ const label = this.channelRegistry?.getDescriptor(id)?.label;
4175
+ shown.push(label && label !== id
4176
+ ? `${label.startsWith('#') ? label : `#${label}`} (${id})`
4177
+ : id);
4178
+ }
4179
+ const suppressedNote = suppressed > 0
4180
+ ? `${suppressed} plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)`
4181
+ : '';
4182
+ const text = shown.length > 0
4183
+ ? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}`
4184
+ : `[delivered] nothing — ${suppressedNote}`;
4185
+ try {
4186
+ const mid = agent.getContextManager().addMessage('user', [{ type: 'text', text }], { system: true, kind: 'delivery-receipt' });
4187
+ this.emitTrace({ type: 'message:added', messageId: mid, source: 'delivery-receipt' });
4188
+ }
4189
+ catch (err) {
4190
+ console.error('delivery receipt append failed:', err);
4191
+ }
4192
+ }
3896
4193
  /**
3897
4194
  * Announce-on-change locus notice (turn-frozen routing). Appends a durable
3898
4195
  * `[routing]` window message when the turn's effective outbound locus
@@ -4260,6 +4557,12 @@ export class AgentFramework {
4260
4557
  if (trigger?.reason !== 'context_budget_restart') {
4261
4558
  if (attempt === 0)
4262
4559
  this.maybePrimeProseMode(agent);
4560
+ // Fresh turn: forget the previous turn's explicit-send engagements and
4561
+ // prose deliveries — both are strictly turn-scoped (a restart continues
4562
+ // the same logical turn and keeps them).
4563
+ this.turnEngagedChannels.delete(agent.name);
4564
+ this.turnProseDeliveries.delete(agent.name);
4565
+ this.turnProseSuppressed.delete(agent.name);
4263
4566
  if (agent.proseRouting === 'explicit') {
4264
4567
  // Explicit prose routing: there is no locus. The model names every
4265
4568
  // destination in-band (`>>` prefixes); the only turn state is the
@@ -4438,7 +4741,8 @@ export class AgentFramework {
4438
4741
  const enqueueSpeech = (text, locus) => {
4439
4742
  turnSpeechChain = turnSpeechChain
4440
4743
  .then(async () => {
4441
- await this.channelRegistry.routeSpeech(agent.name, text, locus);
4744
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, text, locus);
4745
+ this.recordProseDelivery(agent.name, outcome);
4442
4746
  })
4443
4747
  .catch((err) => console.error('mid-turn speech routing failed:', err));
4444
4748
  };
@@ -4476,8 +4780,25 @@ export class AgentFramework {
4476
4780
  // streams that delivery would refuse), emission is capability-gated in
4477
4781
  // the registry (`channels.streaming`), and delivery via deliverProse
4478
4782
  // remains the sole authoritative send.
4479
- const outgoingInferenceId = `inf_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
4783
+ const newOutgoingInferenceId = () => `inf_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
4784
+ // NOT const: a membrane refusal retry abandons everything streamed so
4785
+ // far, and the surface must not concatenate two attempts. Re-minting the
4786
+ // id makes the discarded attempt's chunk buffer orphaned (never
4787
+ // finalized) instead of being appended to — see the 'retrying' case.
4788
+ let outgoingInferenceId = newOutgoingInferenceId();
4480
4789
  let outgoingIndex = 0;
4790
+ // §10.5 inference/lifecycle: `started` now, exactly one terminal in the
4791
+ // finally below (the one block every exit path shares). Which terminal
4792
+ // is decided by the paths themselves: default completed, catch sets
4793
+ // failed, a framework-cancelled stream sets aborted. Best-effort
4794
+ // notifications — consumers dedupe by inferenceId and keep a timeout.
4795
+ let lifecyclePhase = 'completed';
4796
+ this.hookOrchestrator?.emitLifecycle({
4797
+ inferenceId: outgoingInferenceId,
4798
+ conversationId: agent.name,
4799
+ turnIndex: 0,
4800
+ phase: 'started',
4801
+ });
4481
4802
  const proseStream = this.channelRegistry
4482
4803
  ? new ProseStreamRouter({
4483
4804
  mode: agent.proseRouting === 'explicit' ? 'explicit' : 'locus',
@@ -4522,6 +4843,35 @@ export class AgentFramework {
4522
4843
  emitOutgoing(proseStream.feed(event.content));
4523
4844
  }
4524
4845
  break;
4846
+ case 'retrying': {
4847
+ // Membrane is re-issuing after a content-policy refusal. Per the
4848
+ // RetryingEvent contract we must DISCARD everything this call has
4849
+ // emitted: the tokens above were already streamed to the surface
4850
+ // as outgoing chunks, and appending a second attempt to them
4851
+ // would show the human two half-answers spliced together.
4852
+ //
4853
+ // Re-mint the outgoing id so the partial buffer is orphaned
4854
+ // rather than continued (delivery is authoritative via
4855
+ // deliverProse and has not happened yet — nothing has been
4856
+ // *sent*, only previewed), and reset the prose router so its
4857
+ // destination bookkeeping starts clean for the new attempt.
4858
+ console.error(`[refusal-retry] agent=${agent.name} membrane retry ` +
4859
+ `${event.attempt}/${event.maxAttempts}` +
4860
+ (event.category ? ` category=${event.category}` : '') +
4861
+ ' — discarding the refused attempt');
4862
+ outgoingInferenceId = newOutgoingInferenceId();
4863
+ outgoingIndex = 0;
4864
+ proseStream?.reset();
4865
+ this.emitTrace({
4866
+ type: 'inference:tokens',
4867
+ agentName: agent.name,
4868
+ content: '',
4869
+ blockType: 'text',
4870
+ blockIndex: 0,
4871
+ channelId: typingChannel ?? undefined,
4872
+ });
4873
+ break;
4874
+ }
4525
4875
  case 'block': {
4526
4876
  const { event: phase, index, block } = event.event;
4527
4877
  this.emitTrace({
@@ -4628,6 +4978,9 @@ export class AgentFramework {
4628
4978
  }
4629
4979
  else if (turnSilenced) {
4630
4980
  console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`);
4981
+ // Visible in the turn-end receipt — silencing must never
4982
+ // be a silent black hole (n=8: the flying-scene reply).
4983
+ this.recordProseSuppression(agent.name, roundSegments.length);
4631
4984
  }
4632
4985
  else if (hasSameRoundPrivateThink) {
4633
4986
  console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`);
@@ -4669,17 +5022,12 @@ export class AgentFramework {
4669
5022
  agent.addAssistantResponse(pending);
4670
5023
  this.pendingAssistantBlocks.delete(agent.name);
4671
5024
  }
4672
- // Store tool results
5025
+ // Store tool results — same bounded spill policy as the
5026
+ // ordinary path (issue #89: this guard path must not become the
5027
+ // one door a giant blob still walks through).
4673
5028
  const readyState = agent.state;
4674
5029
  if (readyState.status === 'ready') {
4675
- const toolResultContent = readyState.toolResults.map(tc => ({
4676
- type: 'tool_result',
4677
- toolUseId: tc.id,
4678
- content: tc.result.isError
4679
- ? (tc.result.error ?? 'Unknown error')
4680
- : toolResultDataToHistoryString(tc.result.data),
4681
- isError: tc.result.isError,
4682
- }));
5030
+ const { blocks: toolResultContent } = await this.buildStoredToolResultContent(readyState.toolResults, this.resolveToolResultInlineCap(agent).cap);
4683
5031
  agent.getContextManager().addMessage('user', toolResultContent);
4684
5032
  }
4685
5033
  }
@@ -4703,39 +5051,10 @@ export class AgentFramework {
4703
5051
  else {
4704
5052
  agent.addAssistantResponse(terminalContent);
4705
5053
  }
4706
- // Run afterInference hooks (no-op if no MCPL servers)
4707
- if (this.hookOrchestrator) {
4708
- try {
4709
- const speechText = response.content
4710
- .filter((block) => block.type === 'text')
4711
- .map((b) => b.text)
4712
- .join('\n');
4713
- const afterParams = {
4714
- inferenceId: requestId,
4715
- conversationId: agent.name,
4716
- turnIndex: 0,
4717
- userMessage: null,
4718
- assistantMessage: speechText,
4719
- model: {
4720
- id: agent.model,
4721
- vendor: 'unknown',
4722
- contextWindow: 200000,
4723
- capabilities: ['tools'],
4724
- },
4725
- usage: {
4726
- inputTokens: response.usage?.inputTokens ?? 0,
4727
- outputTokens: response.usage?.outputTokens ?? 0,
4728
- cacheCreationTokens: response.details?.usage?.cacheCreationTokens,
4729
- cacheReadTokens: response.details?.usage?.cacheReadTokens,
4730
- },
4731
- };
4732
- await this.hookOrchestrator.afterInference(afterParams);
4733
- }
4734
- catch (error) {
4735
- // Fail-open: continue with speech dispatch
4736
- console.error('afterInference hook error:', error);
4737
- }
4738
- }
5054
+ // §10.5: context/afterInference is removed in 0.5.0 — no
5055
+ // content leaves the host here. Turn boundaries are announced
5056
+ // by the metadata-only inference/lifecycle notifications
5057
+ // emitted in driveStream's start/exit paths.
4739
5058
  // Separate speech from thoughts — for MODULE dispatch only
4740
5059
  // (dispatchSpeech / TUI rendering below). This split does NOT
4741
5060
  // decide what reaches the channel: mid-turn prose is routed live
@@ -4817,6 +5136,17 @@ export class AgentFramework {
4817
5136
  let handledByRewind = false;
4818
5137
  const rh = agent.refusalHandling;
4819
5138
  const forced = this.forcedRewind.get(agent.name);
5139
+ // NOTE: plain retries are NOT done here. `refusalHandling.retries`
5140
+ // is handed to membrane (see Agent.startActivation), which
5141
+ // replays the SAME request at the provider seam — immediately,
5142
+ // cache-warm, and without recompiling into a different window.
5143
+ // A framework-level requeue was tried first and is strictly
5144
+ // worse: it recompiles, so attempts are correlated rather than
5145
+ // fresh draws against a probabilistic threshold, and it cannot
5146
+ // tell a streaming consumer to discard the abandoned attempt.
5147
+ // By the time a refusal reaches here, membrane's retries are
5148
+ // already spent — so this path is the escalation: rewind (if
5149
+ // enabled), then the reaction.
4820
5150
  // Shed exactly one more (newest, in-sequence) turn and keep the
4821
5151
  // single episode marker current. `budgetLeft` bounds the loop.
4822
5152
  const doRewind = (budgetLeft, onStep, onGiveUp) => {
@@ -4862,8 +5192,16 @@ export class AgentFramework {
4862
5192
  this.rewindEpisode.delete(agent.name);
4863
5193
  });
4864
5194
  }
4865
- if (!handledByRewind)
5195
+ // The reaction is the LAST resort: retries spent, rewind not
5196
+ // applicable. That makes it an honest signal to humans that the
5197
+ // border is close, rather than noise on every near-threshold
5198
+ // flip. (It must not re-enter any agent's context — the emoji
5199
+ // is suppressed at the surface via
5200
+ // DISCORD_SUPPRESS_REACTION_EMOJIS; without that, marking a
5201
+ // refusal feeds the next one.)
5202
+ if (!handledByRewind) {
4866
5203
  void this.reactToRefusal(agent.name, category);
5204
+ }
4867
5205
  }
4868
5206
  else {
4869
5207
  // A turn that completed WITHOUT a refusal ends the rewind episode:
@@ -4924,7 +5262,8 @@ export class AgentFramework {
4924
5262
  const locus = resolveTurnLocus();
4925
5263
  console.error(`[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ?? '(none)'}`);
4926
5264
  try {
4927
- await this.channelRegistry.routeSpeech(agent.name, speechText, locus);
5265
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, speechText, locus);
5266
+ this.recordProseDelivery(agent.name, outcome);
4928
5267
  }
4929
5268
  catch (err) {
4930
5269
  console.error('speech routing failed:', err);
@@ -4975,6 +5314,9 @@ export class AgentFramework {
4975
5314
  else if (silenced || segments.length === 0) {
4976
5315
  console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> trailing prose NOT routed ` +
4977
5316
  `(${silenced ? 'silencing tool / explicit send' : 'no trailing prose'})`);
5317
+ if (silenced && segments.length > 0) {
5318
+ this.recordProseSuppression(agent.name, segments.length);
5319
+ }
4978
5320
  }
4979
5321
  else {
4980
5322
  // Reuse the locus pinned at the turn's first live-routed
@@ -4989,7 +5331,8 @@ export class AgentFramework {
4989
5331
  // Deliver sequentially (await each) so the segments land in order.
4990
5332
  for (const seg of segments) {
4991
5333
  try {
4992
- await this.channelRegistry.routeSpeech(agent.name, seg, locus);
5334
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus);
5335
+ this.recordProseDelivery(agent.name, outcome);
4993
5336
  }
4994
5337
  catch (err) {
4995
5338
  console.error('speech routing failed:', err);
@@ -5000,6 +5343,14 @@ export class AgentFramework {
5000
5343
  // NOTE: agent.reset() + onInferenceEnded() already ran above,
5001
5344
  // BEFORE dispatchSpeech. Locus routing is speech dispatch and
5002
5345
  // doesn't depend on the status field, so it correctly runs after.
5346
+ // Logical turn end (normal completion): drop the `[delivered]`
5347
+ // receipt. All deliveries are settled — the live chain was
5348
+ // awaited before trailing dispatch, and trailing/text-only
5349
+ // segments were awaited in-loop. Locus mode only; explicit-mode
5350
+ // envelopes acknowledge themselves through the prose gateway.
5351
+ if (agent.proseRouting !== 'explicit') {
5352
+ this.appendProseDeliveryReceipt(agent);
5353
+ }
5003
5354
  // Explicit-prose `!` continuation: a prose segment this turn asked
5004
5355
  // to keep going (`>>#x !` / `>>private !`) — start another turn
5005
5356
  // now instead of pausing until the next external event. This gives
@@ -5075,9 +5426,26 @@ export class AgentFramework {
5075
5426
  // reset, no settle, no spurious inference:exhausted (which would
5076
5427
  // reject an ephemeral's promise mid-run and bump the failure
5077
5428
  // streak). Gate release + stream teardown happen in `finally`.
5078
- if (this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`)) {
5079
- this.eventGate?.onInferenceEnded(agent.name);
5080
- return;
5429
+ {
5430
+ const cancelKey = `${agent.name}:${myStreamId}`;
5431
+ const cancelKind = this.frameworkCancelledStreams.get(cancelKey);
5432
+ if (cancelKind !== undefined) {
5433
+ this.frameworkCancelledStreams.delete(cancelKey);
5434
+ lifecyclePhase = 'aborted'; // §10.5 — terminal emitted in finally
5435
+ this.eventGate?.onInferenceEnded(agent.name);
5436
+ // endTurn IS a logical turn end — earlier rounds may have
5437
+ // live-routed prose (narrate → skip_reply is a real shape),
5438
+ // so settle the delivery chain and drop the receipt. A
5439
+ // budget restart continues the same logical turn: the
5440
+ // deliveries map persists (cleared only at fresh turn
5441
+ // start) and the continuation stream's end writes ONE
5442
+ // receipt for the whole turn.
5443
+ if (cancelKind === 'turn_ended' && agent.proseRouting !== 'explicit') {
5444
+ await turnSpeechChain;
5445
+ this.appendProseDeliveryReceipt(agent);
5446
+ }
5447
+ return;
5448
+ }
5081
5449
  }
5082
5450
  const reason = event.reason ?? 'unknown';
5083
5451
  // Only reset if this is still the active stream (a budget restart
@@ -5117,6 +5485,11 @@ export class AgentFramework {
5117
5485
  }
5118
5486
  case 'usage': {
5119
5487
  agent.lastStreamInputTokens = event.usage.inputTokens;
5488
+ agent.lastStreamRealInputTokens =
5489
+ (event.usage.inputTokens ?? 0) +
5490
+ (event.usage.cacheCreationTokens ?? 0) +
5491
+ (event.usage.cacheReadTokens ?? 0);
5492
+ agent.lastStreamOutputTokens = event.usage.outputTokens ?? 0;
5120
5493
  // Closed-loop estimator calibration (2026-07-12). Sample the REAL
5121
5494
  // prefix size of THIS API call (fresh + cache write + cache read)
5122
5495
  // and hand it to the context strategy, which accepts exactly one
@@ -5184,10 +5557,22 @@ export class AgentFramework {
5184
5557
  durationMs,
5185
5558
  });
5186
5559
  this.abortAgentScript(agent.name, 'stream threw');
5560
+ lifecyclePhase = 'failed'; // §10.5 — terminal emitted in finally
5187
5561
  agent.reset();
5188
5562
  this.eventGate?.onInferenceEnded(agent.name);
5189
5563
  }
5190
5564
  finally {
5565
+ // §10.5: exactly one terminal per `started`, on every exit path the
5566
+ // host controls. Which one was decided by the path taken (default
5567
+ // completed; catch → failed; framework-cancel → aborted). A host
5568
+ // crash emits nothing — that is the documented best-effort limit, and
5569
+ // why consumers keep their safety timeout.
5570
+ this.hookOrchestrator?.emitLifecycle({
5571
+ inferenceId: outgoingInferenceId,
5572
+ conversationId: agent.name,
5573
+ turnIndex: 0,
5574
+ phase: lifecyclePhase,
5575
+ });
5191
5576
  // Clear the gate's inference flag on EVERY exit path (paired with the
5192
5577
  // onInferenceStarted in startAgentStream). The branch-level calls above
5193
5578
  // are kept but are best-effort; if any exit path bypassed them the agent
@@ -5594,8 +5979,12 @@ export class AgentFramework {
5594
5979
  ? 'null'
5595
5980
  : JSON.stringify(payload, null, 1) ?? String(payload);
5596
5981
  const agent = this.agents.get(record.agentName);
5597
- const maxChars = agent ? this.getMaxToolResultChars(agent) : undefined;
5598
- const payloadText = await this.spillOrTruncate(payloadJson, maxChars, `${record.id}-wake${record.wakes}`);
5982
+ // Same resolved cap as tool results (override applies here too); an
5983
+ // unknown agent still gets the bounded house default, never unbounded.
5984
+ const maxChars = agent
5985
+ ? this.resolveToolResultInlineCap(agent).cap
5986
+ : DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS;
5987
+ const { text: payloadText } = await this.spillOrTruncate(payloadJson, maxChars, `${record.id}-wake${record.wakes}`);
5599
5988
  const envelope = `[background script ${record.id}] Woke you: wake_agent() called at line ${line} of your script, ` +
5600
5989
  `${elapsedMin}m after you started it (wake ${record.wakes} of ${maxWakes}).\n` +
5601
5990
  `Arguments:\n${payloadText}\n` +
@@ -5664,41 +6053,89 @@ export class AgentFramework {
5664
6053
  }
5665
6054
  }
5666
6055
  /**
5667
- * Oversized content policy (antra, 2026-07-31): never hit the agent in
5668
- * the face with a huge blob, never silently destroy it either. Content
5669
- * over the inline cap is materialized to a workspace file (readable with
5670
- * the agent's own tools) and replaced by a truncated head + a trailing
5671
- * file reference. Falls back to plain truncation when there is no
5672
- * writable workspace. The cap can be lifted temporarily via
5673
- * agent_settings `tool_result_inline_max_chars`.
6056
+ * Oversized content policy (antra, 2026-07-31; completed for issue #89):
6057
+ * never hit the agent in the face with a huge blob, never silently destroy
6058
+ * it either. Content over the inline cap is materialized to a workspace
6059
+ * file (readable with the agent's own tools — deterministic name, kept
6060
+ * until the workspace owner deletes it) and replaced by a truncated head +
6061
+ * a trailing file reference. Falls back to EXPLICIT plain truncation when
6062
+ * there is no writable workspace. Cap resolution lives in
6063
+ * resolveToolResultInlineCap; callers pass the resolved cap.
5674
6064
  */
5675
- async spillOrTruncate(content, maxChars, label, agentName) {
5676
- const override = agentName !== undefined
5677
- ? this.toolResultInlineMaxCharsOverride.get(agentName)
5678
- : undefined;
5679
- const cap = override ?? maxChars;
6065
+ /**
6066
+ * One spill policy for every stored tool_result block: serialize (history
6067
+ * serializer — image blocks become placeholders), then spill/truncate at
6068
+ * the resolved cap. Errors follow the same policy as successes — a giant
6069
+ * error string is exactly as context-hostile as a giant success (issue
6070
+ * #89). Returns the stored blocks plus the per-call spill outcomes so the
6071
+ * live wire copy can reuse the identical strings.
6072
+ */
6073
+ async buildStoredToolResultContent(toolResults, maxChars) {
6074
+ const spilled = new Map();
6075
+ const dateLabel = new Date().toISOString().slice(0, 10);
6076
+ for (const tc of toolResults) {
6077
+ const raw = tc.result.isError
6078
+ ? tc.result.error ?? 'Unknown error'
6079
+ : toolResultDataToHistoryString(tc.result.data, undefined);
6080
+ spilled.set(tc.id, await this.spillOrTruncate(raw, maxChars, `${dateLabel}-${tc.id}`));
6081
+ }
6082
+ const blocks = toolResults.map(tc => ({
6083
+ type: 'tool_result',
6084
+ toolUseId: tc.id,
6085
+ content: spilled.get(tc.id).text,
6086
+ isError: tc.result.isError,
6087
+ }));
6088
+ return { blocks, spilled };
6089
+ }
6090
+ async spillOrTruncate(content, cap, label) {
5680
6091
  if (!cap || content.length <= cap)
5681
- return content;
6092
+ return { text: content, filePath: null };
5682
6093
  const workspace = this.getWorkspaceModule();
5683
6094
  const mountName = workspace ? this.firstWritableMountName(workspace) : null;
5684
6095
  if (workspace && mountName) {
5685
6096
  const safeLabel = label.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
5686
6097
  const path = `${mountName}/tool-results/${safeLabel}.txt`;
6098
+ let failure;
5687
6099
  try {
5688
6100
  const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain');
5689
6101
  if (result.success) {
5690
- return safeSlice(content, 0, cap)
5691
- + `\n\n[truncated — showing ${cap} of ${content.length} chars; full content: workspace file ${path}. `
5692
- + 'Read/grep it with your file tools, or raise the inline cap temporarily via '
5693
- + 'agent_settings update tool_result_inline_max_chars.]';
6102
+ return {
6103
+ text: safeSlice(content, 0, cap)
6104
+ + `\n\n[truncated — showing ${cap} of ${content.length} chars; full content: workspace file ${path}. `
6105
+ + 'Read/grep it with your file tools, or raise the inline cap via '
6106
+ + 'agent_settings update tool_result_inline_max_chars.]',
6107
+ filePath: path,
6108
+ };
5694
6109
  }
6110
+ failure = result.error ?? 'write refused';
5695
6111
  }
5696
- catch {
5697
- // fall through to plain truncation
6112
+ catch (err) {
6113
+ failure = err instanceof Error ? err.message : String(err);
5698
6114
  }
6115
+ // A workspace was there and the write FAILED — say that, loudly and
6116
+ // distinctly. Telling the agent "no writable workspace" would teach
6117
+ // them their residence lacks a capability it actually has.
6118
+ failure = failure.slice(0, 200);
6119
+ this.emitTrace({
6120
+ type: 'tool:spill_failed',
6121
+ label: safeLabel,
6122
+ path,
6123
+ contentLength: content.length,
6124
+ error: failure,
6125
+ });
6126
+ console.error(`[spill] workspace write failed for ${path} (${content.length} chars): ${failure}`);
6127
+ return {
6128
+ text: safeSlice(content, 0, cap)
6129
+ + `\n\n[truncated — showing ${cap} of ${content.length} chars; spill to workspace file ${path} FAILED`
6130
+ + ` (${failure}); content over the cap was not retained]`,
6131
+ filePath: null,
6132
+ };
5699
6133
  }
5700
- return safeSlice(content, 0, cap)
5701
- + '\n\n[truncated — original was ' + content.length + ' chars]';
6134
+ return {
6135
+ text: safeSlice(content, 0, cap)
6136
+ + '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace, full content not retained]',
6137
+ filePath: null,
6138
+ };
5702
6139
  }
5703
6140
  getOrCreateScriptRunner(agentName) {
5704
6141
  let runner = this.codeExecutionRunners.get(agentName);
@@ -5793,22 +6230,28 @@ export class AgentFramework {
5793
6230
  }
5794
6231
  }
5795
6232
  toMembraneToolResult(callId, afResult, maxChars,
5796
- /** Pre-spilled string from the history path — used for the non-image
6233
+ /** Pre-spilled outcome from the history path — used for the non-image
5797
6234
  * path so the live wire copy byte-matches what the window stored. */
5798
6235
  precomputed) {
5799
6236
  if (afResult.isError) {
5800
- return { toolUseId: callId, content: afResult.error ?? 'Unknown error', isError: true };
6237
+ // Same spill policy as successes: reuse the history copy so a giant
6238
+ // error string stays bounded on the wire too (byte-matched).
6239
+ return {
6240
+ toolUseId: callId,
6241
+ content: precomputed?.text ?? truncateForHistory(afResult.error ?? 'Unknown error', DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS),
6242
+ isError: true,
6243
+ };
5801
6244
  }
5802
6245
  // MCPL tool results arrive as `data: McpToolResultContent[]` — preserve image
5803
6246
  // blocks natively rather than JSON-stringifying them away. Anything else
5804
6247
  // (objects, scalars) falls through to JSON. The error path was handled
5805
6248
  // above, so isError is always false on these return paths.
5806
- const blocks = this.tryNativeToolResultContent(afResult.data, maxChars);
6249
+ const blocks = this.tryNativeToolResultContent(afResult.data, maxChars, precomputed?.filePath ?? null);
5807
6250
  if (blocks) {
5808
6251
  return { toolUseId: callId, content: blocks, isError: false };
5809
6252
  }
5810
6253
  if (precomputed !== undefined) {
5811
- return { toolUseId: callId, content: precomputed, isError: false };
6254
+ return { toolUseId: callId, content: precomputed.text, isError: false };
5812
6255
  }
5813
6256
  // JSON.stringify returns the VALUE undefined (not a string) for undefined
5814
6257
  // input — a module tool returning `{ success: true }` with no data would
@@ -5827,7 +6270,10 @@ export class AgentFramework {
5827
6270
  * `maxChars`, when provided, caps each accompanying text block so an image
5828
6271
  * inlined alongside an enormous text payload can't blow the context.
5829
6272
  */
5830
- tryNativeToolResultContent(data, maxChars) {
6273
+ tryNativeToolResultContent(data, maxChars,
6274
+ /** Spill file written by the history path — referenced from truncation
6275
+ * notices so an image-adjacent giant text block stays recoverable. */
6276
+ spillPath = null) {
5831
6277
  if (!Array.isArray(data))
5832
6278
  return null;
5833
6279
  let hasImage = false;
@@ -5840,7 +6286,9 @@ export class AgentFramework {
5840
6286
  let text = b.text;
5841
6287
  if (maxChars && text.length > maxChars) {
5842
6288
  text = safeSlice(text, 0, maxChars)
5843
- + '\n\n[truncated — original was ' + text.length + ' chars]';
6289
+ + '\n\n[truncated — original was ' + text.length + ' chars'
6290
+ + (spillPath ? `; full serialized result: workspace file ${spillPath}` : '')
6291
+ + ']';
5844
6292
  }
5845
6293
  blocks.push({ type: 'text', text });
5846
6294
  }
@@ -5857,13 +6305,39 @@ export class AgentFramework {
5857
6305
  }
5858
6306
  return hasImage ? blocks : null;
5859
6307
  }
5860
- getMaxToolResultChars(agent) {
6308
+ /** Strategy-derived per-message bound (maxMessageTokens * 4 chars). */
6309
+ strategyDerivedToolResultChars(agent) {
5861
6310
  const strategy = agent.getContextManager().getStrategy();
5862
6311
  const maxTokens = strategy.maxMessageTokens;
5863
6312
  if (maxTokens && maxTokens > 0)
5864
6313
  return maxTokens * 4;
5865
6314
  return undefined;
5866
6315
  }
6316
+ /**
6317
+ * Effective tool-result inline cap for an agent, with provenance. Desired
6318
+ * value: resident's durable agent_settings value → residence
6319
+ * FrameworkConfig.toolResultInlineMaxChars → house default (5000).
6320
+ * Effective value: min(desired, strategy bound) for EVERY source — the
6321
+ * strategy's per-message safety limit is a ceiling, not a suggestion
6322
+ * (Sol's #94 ruling: a durable preference must not be a durable path for
6323
+ * one tool result to exceed maxMessageTokens; the way to see the whole
6324
+ * result is the spill file, not an over-bound blob in live context).
6325
+ */
6326
+ resolveToolResultInlineCap(agent) {
6327
+ const override = this.toolResultInlineMaxCharsOverride.get(agent.name);
6328
+ const configured = this.toolResultInlineMaxCharsConfig;
6329
+ const desired = override ?? configured ?? DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS;
6330
+ const source = override !== undefined
6331
+ ? 'agent-settings-override'
6332
+ : configured !== null ? 'framework-config' : 'default';
6333
+ const strategyBound = this.strategyDerivedToolResultChars(agent);
6334
+ const clamped = strategyBound !== undefined && strategyBound < desired;
6335
+ return {
6336
+ cap: clamped ? strategyBound : desired,
6337
+ source,
6338
+ clampedBy: clamped ? 'strategy-bound' : null,
6339
+ };
6340
+ }
5867
6341
  approximateDecodedBase64Bytes(base64) {
5868
6342
  const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
5869
6343
  return Math.max(0, Math.floor(base64.length * 3 / 4) - padding);
@@ -6017,6 +6491,26 @@ export class AgentFramework {
6017
6491
  // synthesized channel tools, but they aren't `channel_`-prefixed so they
6018
6492
  // need an explicit route here.
6019
6493
  if ((enrichedCall.name === 'think' || enrichedCall.name === 'skip_reply') && this.channelRegistry) {
6494
+ // skip_reply(wake_in_seconds): arm a gate self-wake so "not replying
6495
+ // NOW" can also mean "back in a moment" — ends the turn, then wakes
6496
+ // the agent after N seconds unless something else wakes it first
6497
+ // (any turn start cancels the pending self-wake). Armed here because
6498
+ // the framework owns the EventGate; the registry only words the
6499
+ // confirmation. Without a gate the field is stripped so that
6500
+ // confirmation never claims a wake that can't happen.
6501
+ if (enrichedCall.name === 'skip_reply') {
6502
+ const inputObj = (enrichedCall.input ?? {});
6503
+ const secs = Number(inputObj.wake_in_seconds);
6504
+ const wants = inputObj.wake_in_seconds !== undefined && Number.isFinite(secs) && secs > 0;
6505
+ if (wants && this.eventGate) {
6506
+ const { inMs } = this.eventGate.armSelfWake(agentName, secs, 'skip_reply');
6507
+ console.error(`[self-wake] agent=${agentName} armed in ${Math.round(inMs / 1000)}s (skip_reply)`);
6508
+ }
6509
+ else if (inputObj.wake_in_seconds !== undefined && !this.eventGate) {
6510
+ delete inputObj.wake_in_seconds;
6511
+ console.error(`[self-wake] agent=${agentName} wake_in_seconds requested but no EventGate — ignored`);
6512
+ }
6513
+ }
6020
6514
  this.dispatchChannelToolCall(agentName, enrichedCall);
6021
6515
  return;
6022
6516
  }
@@ -6539,7 +7033,6 @@ export class AgentFramework {
6539
7033
  async initializeMcpl(serverConfigs, inferenceRouting) {
6540
7034
  this.mcplServerRegistry = new McplServerRegistry();
6541
7035
  this.featureSetManager = new FeatureSetManager();
6542
- this.scopeManager = new ScopeManager();
6543
7036
  this.hookOrchestrator = new HookOrchestrator(this.mcplServerRegistry, this.featureSetManager);
6544
7037
  // Build prefix map and store configs for tool routing
6545
7038
  for (const config of serverConfigs) {
@@ -6625,12 +7118,32 @@ export class AgentFramework {
6625
7118
  });
6626
7119
  // Host capabilities advertised during the MCP handshake — stored so
6627
7120
  // servers can also be connected later at runtime (connectMcplServer).
7121
+ // §5.2: the host advertises what it actually implements. 0.5.0-draft:
7122
+ // afterInference is GONE (replaced by inference/lifecycle, §10.5), and
7123
+ // the previously under-advertised inferenceRequest + channels — both
7124
+ // implemented for years — are declared (AUDIT-001/issue #76 item 12).
6628
7125
  this.mcplHostCapabilities = {
6629
- version: '0.4',
7126
+ version: '0.5',
6630
7127
  pushEvents: true,
6631
7128
  contextHooks: {
6632
7129
  beforeInference: true,
6633
- afterInference: { blocking: true },
7130
+ },
7131
+ inferenceLifecycle: true,
7132
+ // modelInfo is NOT advertised: §12.2's result requires all four of
7133
+ // {id, vendor, contextWindow, capabilities} and the host has no
7134
+ // truthful source for contextWindow/capabilities (grep: none exists).
7135
+ // Advertising a capability answered with a malformed partial response
7136
+ // is worse than honest unsupported (Sol, PR #79 re-review). The
7137
+ // handler below answers an explicit error rather than hanging (§6.6).
7138
+ inferenceRequest: { streaming: true },
7139
+ channels: {
7140
+ register: true,
7141
+ lifecycle: true,
7142
+ publish: true,
7143
+ incoming: true,
7144
+ streaming: true,
7145
+ acknowledge: true,
7146
+ typing: true,
6634
7147
  },
6635
7148
  featureSets: true,
6636
7149
  };
@@ -6963,27 +7476,210 @@ export class AgentFramework {
6963
7476
  // Wire listeners before either startup staging or the runtime global gate
6964
7477
  // releases control traffic needed for registration and marker service.
6965
7478
  this.wireMcplEvents(connection);
6966
- // Initialize feature sets if server advertises MCPL capabilities
6967
- this.registerMcplServerFeatures(config, connection);
6968
7479
  if (deferAwareness) {
7480
+ // Staged startup: both planes are closed, so the §5.3 policy
7481
+ // round-trip cannot let any buffered traffic slip — establish the
7482
+ // grant now, before staging ever releases.
7483
+ await this.registerMcplServerFeatures(config, connection);
6969
7484
  this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
6970
7485
  return;
6971
7486
  }
6972
7487
  const awarenessBarrier = this.installMcplDataPlaneGate();
6973
- this.releaseMcplDataPlaneGate(awarenessBarrier);
7488
+ // NO early release: both planes stay closed through accounting AND the
7489
+ // §5.3 policy round-trip below (PR #79 review blocker 3 — the early
7490
+ // control flush released channels-register/changed before any grant
7491
+ // existed). The awareness drain rides tools/call RESPONSES, which are
7492
+ // never event-buffered, so it needs no plane release to make progress.
6974
7493
  try {
6975
7494
  await awarenessBarrier.promise;
6976
- this.completeMcplDataPlaneGate(awarenessBarrier);
6977
7495
  }
6978
7496
  catch (error) {
6979
7497
  // Startup cannot fail open on a broken awareness ledger. Disable the
6980
7498
  // reconnecting stub/connection before propagating the distinct error to
6981
7499
  // initializeMcpl, which tears down any other servers and aborts create().
7500
+ // Ordering matters: awareness accounting settles BEFORE the §5.3
7501
+ // policy round-trip below, so an accounting failure aborts with the
7502
+ // grant still empty and every buffered event still behind the gate —
7503
+ // a failed startup never reports a released data plane. (The reverse
7504
+ // order let a push arrive during the policy await and get flushed by
7505
+ // teardown.)
6982
7506
  await connection.close().catch(() => { });
6983
7507
  throw error;
6984
7508
  }
7509
+ // §5.3 initial policy handshake — after accounting, before the gate
7510
+ // completes: the grant is established (or knowingly left empty) before
7511
+ // ANY buffered traffic (control or data) flows. complete() then
7512
+ // ready()s both planes through the admission gate.
7513
+ await this.registerMcplServerFeatures(config, connection);
7514
+ this.completeMcplDataPlaneGate(awarenessBarrier);
6985
7515
  this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
6986
7516
  }
7517
+ /** Per-connection §17.8 fetch limiter: at most one in-flight fetch, at
7518
+ * most one queued behind it (announcements coalesce), floor between
7519
+ * completed fetches. Exceeding the floor drops the excess with a
7520
+ * diagnostic — the host bounds the rate; it never depends on server
7521
+ * coalescing. */
7522
+ manifestRefreshState = new Map();
7523
+ static MANIFEST_FETCH_FLOOR_MS = 5_000;
7524
+ /**
7525
+ * §17.5 host processing: fetch the complete manifest, validate exactly as
7526
+ * at initialize, diff, apply §6.7 consequences (removals eagerly even
7527
+ * when other declarations are invalid; additions only through
7528
+ * tell→receipt→activate), emit ONE change receipt (§17.6).
7529
+ */
7530
+ async handleManifestChanged(connection, announce) {
7531
+ const st = this.manifestRefreshState.get(connection.id)
7532
+ ?? { inFlight: false, queued: false, lastCompletedAt: 0 };
7533
+ this.manifestRefreshState.set(connection.id, st);
7534
+ if (st.inFlight) {
7535
+ // Coalesce: one queued refresh re-runs after the current fetch — the
7536
+ // fetch is always of the CURRENT manifest, so N announcements need at
7537
+ // most one trailing fetch (§17.8).
7538
+ st.queued = true;
7539
+ return;
7540
+ }
7541
+ const sinceLast = Date.now() - st.lastCompletedAt;
7542
+ if (sinceLast < AgentFramework.MANIFEST_FETCH_FLOOR_MS) {
7543
+ if (!st.queued) {
7544
+ st.queued = true;
7545
+ setTimeout(() => {
7546
+ const cur = this.manifestRefreshState.get(connection.id);
7547
+ if (cur?.queued && !cur.inFlight) {
7548
+ cur.queued = false;
7549
+ void this.handleManifestChanged(connection, announce).catch(() => { });
7550
+ }
7551
+ }, AgentFramework.MANIFEST_FETCH_FLOOR_MS - sinceLast).unref?.();
7552
+ }
7553
+ return;
7554
+ }
7555
+ st.inFlight = true;
7556
+ try {
7557
+ const config = this.mcplServerConfigs.get(connection.id);
7558
+ if (!config)
7559
+ return;
7560
+ // §17.4 fetch. A server that cannot answer keeps its previous
7561
+ // manifest in force — an unparseable/failed fetch teaches nothing and
7562
+ // narrows nothing (§17.5 step 1).
7563
+ let fetched;
7564
+ try {
7565
+ fetched = await connection.sendManifestRequest();
7566
+ }
7567
+ catch (err) {
7568
+ console.error(`[mcpl] ${connection.id}: mcpl/manifest fetch failed — previous manifest stands (§17.5): ${err instanceof Error ? err.message : err}`);
7569
+ return;
7570
+ }
7571
+ if (!fetched || typeof fetched !== 'object') {
7572
+ console.error(`[mcpl] ${connection.id}: mcpl/manifest returned no object — previous manifest stands (§17.5)`);
7573
+ return;
7574
+ }
7575
+ // Validate exactly as at initialize: expand §5.1 shorthand, apply the
7576
+ // config mask, recompute the grant. Unknown names mint nothing;
7577
+ // invalid declarations fail closed in derivation — and REMOVALS still
7578
+ // apply, because the new grant/derivation simply lacks whatever no
7579
+ // longer validates (§17.5 step 1's partial-invalid rule falls out of
7580
+ // computing forward from the fetched content).
7581
+ const expanded = expandAdvertisementShorthand(fetched);
7582
+ const { capabilities: masked, dropped } = maskNegotiatedCapabilities(expanded, config);
7583
+ const newGrant = computeGrant(masked, config, { mcpToolsAdvertised: connection.mcpToolsAdvertised });
7584
+ const oldEffective = new Set(connection.grant.effectiveList());
7585
+ const newEffective = new Set(newGrant.effectiveList());
7586
+ const revoked = [...oldEffective].filter((p) => !newEffective.has(p)).sort();
7587
+ const added = [...newEffective].filter((p) => !oldEffective.has(p)).sort();
7588
+ const oldDeclared = Object.keys(this.featureSetManager?.getDeclaredFeatureSets(connection.id) ?? {});
7589
+ const oldEnabled = oldDeclared.filter((n) => this.featureSetManager?.isEnabled(connection.id, n));
7590
+ // §6.7 REDUCTION FIRST, atomically, before the server is told:
7591
+ // security cannot wait on consent. The interim grant is new∩old — no
7592
+ // revoked path survives, no added path activates yet.
7593
+ const hadReduction = revoked.length > 0;
7594
+ if (hadReduction) {
7595
+ const interim = new CapabilityGrant(new Set([...newEffective].filter((p) => oldEffective.has(p))), [...newGrant.deniedPaths]);
7596
+ connection.establishGrant(interim);
7597
+ }
7598
+ // PHASE 1 — pre-receipt: live feature state derives from ACTIVE
7599
+ // authority (the interim grant when reduced, the standing grant
7600
+ // otherwise), never from the unactivated full grant. Feature state
7601
+ // must obey the same §6.7 ordering the grant does: a newly added
7602
+ // capability-backed feature set cannot be host-enabled while its
7603
+ // capability is still absent from the active grant (Sol, PR #84
7604
+ // review). Declarations come from the fetched manifest either way —
7605
+ // a set absent from the new manifest is gone regardless.
7606
+ const cfg = { enabledFeatureSets: config.enabledFeatureSets, disabledFeatureSets: config.disabledFeatureSets };
7607
+ const maskedOrEmpty = masked ?? { version: '0.5' };
7608
+ this.featureSetManager.initializeServer(connection.id, maskedOrEmpty, cfg, connection.grant);
7609
+ // The Request DESCRIBES the proposed new policy — derived from the
7610
+ // full new grant on a scratch manager, installing nothing.
7611
+ const updateParams = new FeatureSetManager().initializeServer(connection.id, maskedOrEmpty, cfg, newGrant);
7612
+ updateParams.effectiveCapabilities = newGrant.effectiveList();
7613
+ if (newGrant.deniedPaths.length > 0)
7614
+ updateParams.deniedCapabilities = [...newGrant.deniedPaths];
7615
+ // Tell → receipt → activate (§6.7). PHASE 2 by outcome:
7616
+ // accepted → full grant + full-derived feature state;
7617
+ // refused → honor fallback: 'close' closes the transport;
7618
+ // 'mcp-only' (or absent) = empty grant + empty-derived
7619
+ // feature state, mirroring the §5.3 initial path;
7620
+ // unanswered → interim grant and interim-derived state stand.
7621
+ let negotiated = false;
7622
+ try {
7623
+ const receipt = await connection.sendFeatureSetsUpdateRequest(updateParams);
7624
+ if (receipt && receipt.accepted === false) {
7625
+ const fallback = receipt.fallback ?? 'mcp-only';
7626
+ console.error(`[mcpl] ${connection.id} refused post-manifest policy — fallback: ${fallback}`);
7627
+ if (fallback === 'close') {
7628
+ await connection.close();
7629
+ return;
7630
+ }
7631
+ connection.establishGrant(CapabilityGrant.empty());
7632
+ this.featureSetManager.initializeServer(connection.id, maskedOrEmpty, cfg, CapabilityGrant.empty());
7633
+ }
7634
+ else {
7635
+ connection.establishGrant(newGrant);
7636
+ this.featureSetManager.initializeServer(connection.id, maskedOrEmpty, cfg, newGrant);
7637
+ connection.manifestState.lastNegotiatedAt = Date.now();
7638
+ negotiated = true;
7639
+ }
7640
+ }
7641
+ catch {
7642
+ console.error(`[mcpl] ${connection.id} did not answer post-manifest policy — expansion does not activate (§6.7); interim state stands`);
7643
+ }
7644
+ // Adopt the validated manifest + tracking. droppedCapabilities is
7645
+ // REPLACED unconditionally — an empty new mask must clear stale paths
7646
+ // (Sol, PR #84 review).
7647
+ connection.capabilities = masked;
7648
+ connection.manifestState.lastValidatedRevision =
7649
+ typeof fetched.revision === 'string'
7650
+ ? fetched.revision : null;
7651
+ connection.manifestState.lastFetchedAt = Date.now();
7652
+ connection.droppedCapabilities = new Set(dropped);
7653
+ const newDeclared = Object.keys(this.featureSetManager?.getDeclaredFeatureSets(connection.id) ?? {});
7654
+ const newEnabled = newDeclared.filter((n) => this.featureSetManager?.isEnabled(connection.id, n));
7655
+ const degraded = oldEnabled.filter((n) => !newEnabled.includes(n)).sort();
7656
+ const restored = newEnabled.filter((n) => !oldEnabled.includes(n)).sort();
7657
+ // §17.6: ONE receipt per manifest change, closed host-derived impact
7658
+ // vocabulary, never a server-authored flag.
7659
+ const impacts = [
7660
+ ...revoked.map((p) => ({ impact: 'capability-revoked', subject: p, disposition: 'applied' })),
7661
+ ...added.map((p) => ({ impact: 'capability-expansion-pending', subject: p, disposition: negotiated ? 'applied' : 'decision-needed' })),
7662
+ ...degraded.map((n) => ({ impact: 'feature-degraded', subject: n, disposition: 'applied' })),
7663
+ ...restored.map((n) => ({ impact: 'feature-restored', subject: n, disposition: negotiated ? 'applied' : 'decision-needed' })),
7664
+ ];
7665
+ this.emitTrace({
7666
+ type: 'mcpl:manifest-change-receipt',
7667
+ serverId: connection.id,
7668
+ revision: connection.manifestState.lastValidatedRevision,
7669
+ announcedDomains: announce.domains ?? [],
7670
+ impacts,
7671
+ });
7672
+ this.handleToolsListChanged(connection.id);
7673
+ }
7674
+ finally {
7675
+ st.inFlight = false;
7676
+ st.lastCompletedAt = Date.now();
7677
+ if (st.queued) {
7678
+ st.queued = false;
7679
+ setTimeout(() => { void this.handleManifestChanged(connection, announce).catch(() => { }); }, AgentFramework.MANIFEST_FETCH_FLOOR_MS).unref?.();
7680
+ }
7681
+ }
7682
+ }
6987
7683
  /**
6988
7684
  * (Re-)establish a server's host-side MCPL registration: feature-set state
6989
7685
  * (plus the featureSets/update sent to the server), scope patterns, and
@@ -6999,20 +7695,62 @@ export class AgentFramework {
6999
7695
  * already has a tree), so checkpoint trees preserved across a transient
7000
7696
  * disconnect are resumed, not reset.
7001
7697
  */
7002
- registerMcplServerFeatures(config, connection) {
7698
+ async registerMcplServerFeatures(config, connection) {
7003
7699
  if (!connection.capabilities)
7004
7700
  return;
7701
+ // §5.4: compute the effective grant from the (config-masked)
7702
+ // advertisement. Not yet active — expansion activates only after the
7703
+ // receipt (§6.7); until then the connection's grant is empty and every
7704
+ // privileged inbound method is rejected.
7705
+ const grant = computeGrant(connection.capabilities, config, { mcpToolsAdvertised: connection.mcpToolsAdvertised });
7005
7706
  const updateParams = this.featureSetManager.initializeServer(config.id, connection.capabilities, {
7006
7707
  enabledFeatureSets: config.enabledFeatureSets,
7007
7708
  disabledFeatureSets: config.disabledFeatureSets,
7008
- });
7009
- // Inform server which feature sets are enabled/disabled
7010
- if (updateParams.enabled?.length || updateParams.disabled?.length) {
7011
- connection.sendFeatureSetsUpdate(updateParams);
7709
+ }, grant);
7710
+ // §5.3: initial policy MUST be a Request, MUST precede first fan-out,
7711
+ // and MUST be sent even when nothing is enabled or disabled — a server
7712
+ // defaulted to fully disabled has to be told. The response is a
7713
+ // degradation receipt (§6.7): consequence testimony, never policy
7714
+ // authority — nothing in it may widen the grant.
7715
+ updateParams.effectiveCapabilities = grant.effectiveList();
7716
+ if (grant.deniedPaths.length > 0) {
7717
+ updateParams.deniedCapabilities = [...grant.deniedPaths];
7718
+ }
7719
+ try {
7720
+ const receipt = await connection.sendFeatureSetsUpdateRequest(updateParams);
7721
+ if (receipt && receipt.accepted === false) {
7722
+ const fallback = receipt.fallback ?? 'mcp-only';
7723
+ console.error(`[mcpl] ${config.id} refused initial policy (${receipt.reason ?? 'no reason'}) — fallback: ${fallback}`);
7724
+ if (fallback === 'close') {
7725
+ await connection.close();
7726
+ return;
7727
+ }
7728
+ // mcp-only (§3.2): grant stays empty, MCPL privileged surface stays
7729
+ // dark, plain MCP tools keep working. MUST NOT widen in response to
7730
+ // the refusal — a human can change config; the policy engine cannot.
7731
+ return;
7732
+ }
7733
+ // Expansion activates on receipt (§6.7).
7734
+ connection.establishGrant(grant);
7735
+ if (receipt?.accepted === true && receipt.mode === 'degraded') {
7736
+ for (const uf of receipt.unavailableFeatures ?? []) {
7737
+ console.error(`[mcpl] ${config.id} degraded: ${uf.featureSet} (${uf.effect}; missing ${uf.missingCapabilities.join(', ')})`);
7738
+ }
7739
+ }
7740
+ }
7741
+ catch (err) {
7742
+ // A pre-0.5 server treats the Request as a notification and never
7743
+ // answers. §6.7: an unanswered expansion simply does not activate —
7744
+ // grant stays empty, connection is effectively MCP-only. This WILL
7745
+ // dark every un-migrated MCPL server's privileged surface; accepted
7746
+ // deliberately for the single-release rollout (issue #76 item 9).
7747
+ console.error(`[mcpl] ${config.id} did not answer the initial policy Request — ` +
7748
+ `MCPL privileged surface stays disabled (§6.7 unanswered expansion): ` +
7749
+ (err instanceof Error ? err.message : String(err)));
7750
+ return;
7012
7751
  }
7013
- // Configure scope whitelist/blacklist patterns
7014
7752
  if (config.scopes) {
7015
- this.scopeManager.configureAll(config.scopes);
7753
+ console.error(`[mcpl] ${config.id}: config \`scopes\` is ignored — §7 Scoped Access removed in 0.5.0`);
7016
7754
  }
7017
7755
  // Register stateful feature sets with checkpoint manager (Step 8)
7018
7756
  if (this.checkpointManager) {
@@ -7109,11 +7847,21 @@ export class AgentFramework {
7109
7847
  for (const [id, config] of this.mcplServerConfigs) {
7110
7848
  const prefix = config.toolPrefix ?? `mcpl--${id}`;
7111
7849
  const connection = this.mcplServerRegistry?.getServer(id) ?? null;
7850
+ const connected = connection?.isConnected ?? false;
7112
7851
  result.push({
7113
7852
  id,
7114
- connected: connection?.isConnected ?? false,
7853
+ connected,
7854
+ retrying: !connected && (connection?.willReconnect ?? false),
7115
7855
  toolPrefix: prefix,
7116
7856
  toolCount: this.mcplTools.filter(t => t.name.startsWith(`${prefix}--`)).length,
7857
+ policyEstablished: connection?.policyEstablished ?? false,
7858
+ effectiveGrant: connection?.grant.effectiveList() ?? [],
7859
+ maskedCapabilities: [...(connection?.droppedCapabilities ?? [])].sort(),
7860
+ deniedCapabilities: [...(connection?.grant.deniedPaths ?? [])].sort(),
7861
+ allowHostCommands: config.allowHostCommands === true,
7862
+ manifestState: connection
7863
+ ? { ...connection.manifestState }
7864
+ : { lastValidatedRevision: null, lastFetchedAt: null, lastNegotiatedAt: null },
7117
7865
  command: config.command,
7118
7866
  url: config.url,
7119
7867
  });
@@ -7130,23 +7878,14 @@ export class AgentFramework {
7130
7878
  connection.on('stderr', (params) => {
7131
7879
  this.emitTrace({ type: 'mcpl:server-stderr', serverId: connection.id, line: params.line });
7132
7880
  });
7133
- // Handle dynamic feature set changes from server
7134
- connection.on('feature-sets-changed', (params) => {
7135
- this.featureSetManager?.handleFeatureSetsChanged(connection.id, params);
7136
- });
7881
+ // featureSets/changed is REMOVED in 0.5.0 — the connection answers it
7882
+ // -32601 (superseded by SPEC §17's host-diffed manifest fetch); no
7883
+ // listener, and nothing mutates declarations off a server-authored
7884
+ // change payload.
7137
7885
  // Handle scope elevation requests
7138
- connection.on('scope-elevate', async (params, responder) => {
7139
- if (this.scopeManager && responder) {
7140
- try {
7141
- const result = await this.scopeManager.handleElevation(params);
7142
- responder.respond(result);
7143
- }
7144
- catch (error) {
7145
- const err = error instanceof Error ? error : new Error(String(error));
7146
- responder.respondError(-32603, err.message);
7147
- }
7148
- }
7149
- });
7886
+ // §7 Scoped Access is removed in 0.5.0. scope/elevate is answered
7887
+ // -32601 at the connection (server-connection.ts) and never reaches the
7888
+ // framework; the ScopeManager and config `scopes` wiring went with it.
7150
7889
  // Handle push events (Step 6)
7151
7890
  connection.on('push-event', async (params, responder) => {
7152
7891
  const barrier = this.discordAwarenessBarrier;
@@ -7171,9 +7910,36 @@ export class AgentFramework {
7171
7910
  connection.on('channels-register', async (params, responder) => {
7172
7911
  await this.channelRegistry?.handleRegister(connection.id, params, responder);
7173
7912
  });
7174
- // Handle channel changes (Step 7)
7175
- connection.on('channels-changed', async (params) => {
7176
- await this.channelRegistry?.handleChanged(connection.id, params);
7913
+ // Handle channel changes (Step 7) — §14.5 dual-mode: the responder is
7914
+ // present for the Request form and carries itemized added-descriptor
7915
+ // results; absent for Notifications, where rejection is itemwise
7916
+ // filtering plus a diagnostic trace.
7917
+ connection.on('channels-changed', async (params, responder) => {
7918
+ await this.channelRegistry?.handleChanged(connection.id, params, responder);
7919
+ });
7920
+ // §6.6/§12: requests that previously had no handler and hung forever
7921
+ // (AUDIT-001 item 4). The connection-level grant gate has already run.
7922
+ // §17.3: a manifest-change announcement. Rate-limited re-fetch → diff
7923
+ // → §6.7 consequences → one receipt. The announcement itself carries no
7924
+ // payload and no authority; everything below acts on FETCHED content.
7925
+ connection.on('manifest-changed', (params) => {
7926
+ void this.handleManifestChanged(connection, params).catch((err) => {
7927
+ console.error(`[mcpl] ${connection.id} manifest refresh failed:`, err instanceof Error ? err.message : err);
7928
+ });
7929
+ });
7930
+ connection.on('channels-list', (_params, responder) => {
7931
+ responder?.respond({
7932
+ channels: this.channelRegistry?.descriptorsForServer(connection.id) ?? [],
7933
+ });
7934
+ });
7935
+ connection.on('model-info', (_params, responder) => {
7936
+ // §12.2 requires ALL FOUR of {id, vendor, contextWindow, capabilities}
7937
+ // in the DIRECT result shape, and numeric contextWindow cannot express
7938
+ // "unknown". The host has no truthful source for it, so modelInfo is
7939
+ // not advertised and this answers an explicit error instead of a
7940
+ // malformed partial (§6.6: a method that will never be answered
7941
+ // truthfully MUST error, not fabricate).
7942
+ responder?.respondError?.(-32601, 'model/info unavailable: host does not advertise modelInfo (no truthful contextWindow source)');
7177
7943
  });
7178
7944
  // Handle incoming channel messages (Step 7)
7179
7945
  connection.on('channels-incoming', async (params, responder) => {
@@ -7222,17 +7988,23 @@ export class AgentFramework {
7222
7988
  // reconnect observes it before doing work that could wake an agent. The
7223
7989
  // connection paused its data plane before wiring the fresh transport.
7224
7990
  const awarenessBarrier = this.installMcplDataPlaneGate();
7225
- try {
7226
- const config = this.mcplServerConfigs.get(connection.id);
7227
- if (config) {
7228
- this.registerMcplServerFeatures(config, connection);
7991
+ // Async: the §5.3 policy Request must be answered (re-establishing the
7992
+ // grant) before the data-plane barrier releases — otherwise the first
7993
+ // post-reconnect events land while the grant is still empty and are
7994
+ // rejected fail-closed instead of delivered.
7995
+ void (async () => {
7996
+ try {
7997
+ const config = this.mcplServerConfigs.get(connection.id);
7998
+ if (config) {
7999
+ await this.registerMcplServerFeatures(config, connection);
8000
+ }
7229
8001
  }
7230
- }
7231
- catch (error) {
7232
- console.error(`MCPL server "${connection.id}" re-registration after reconnect failed:`, error instanceof Error ? error.message : error);
7233
- }
7234
- this.releaseMcplDataPlaneGate(awarenessBarrier);
7235
- this.handleToolsListChanged(connection.id);
8002
+ catch (error) {
8003
+ console.error(`MCPL server "${connection.id}" re-registration after reconnect failed:`, error instanceof Error ? error.message : error);
8004
+ }
8005
+ this.releaseMcplDataPlaneGate(awarenessBarrier);
8006
+ this.handleToolsListChanged(connection.id);
8007
+ })();
7236
8008
  void awarenessBarrier.promise.then(() => {
7237
8009
  if (awarenessBarrier.requiresBarrier
7238
8010
  && !this.completeMcplDataPlaneGate(awarenessBarrier))
@@ -7328,6 +8100,7 @@ export class AgentFramework {
7328
8100
  if (!this.mcplServerRegistry)
7329
8101
  return;
7330
8102
  const tools = [];
8103
+ const toolFeatureSets = new Map();
7331
8104
  for (const server of this.mcplServerRegistry.getAllServers()) {
7332
8105
  const config = this.mcplServerConfigs.get(server.id);
7333
8106
  const prefix = config?.toolPrefix ?? `mcpl--${server.id}`;
@@ -7336,13 +8109,23 @@ export class AgentFramework {
7336
8109
  for (const tool of result.tools) {
7337
8110
  if (!isToolAllowed(tool.name, config))
7338
8111
  continue;
8112
+ const namespacedName = `${prefix}--${tool.name}`;
8113
+ const attributedTool = tool;
8114
+ const featureSet = typeof attributedTool.featureSet === 'string'
8115
+ ? attributedTool.featureSet
8116
+ : typeof attributedTool._meta?.featureSet === 'string'
8117
+ ? attributedTool._meta.featureSet
8118
+ : undefined;
7339
8119
  // MCP tool schemas are generic JSON Schema; cast to membrane's ToolDefinition format
7340
8120
  const schema = tool.inputSchema;
7341
8121
  tools.push({
7342
- name: `${prefix}--${tool.name}`,
8122
+ name: namespacedName,
7343
8123
  description: tool.description ?? '',
7344
8124
  inputSchema: schema,
7345
8125
  });
8126
+ if (featureSet !== undefined) {
8127
+ toolFeatureSets.set(namespacedName, featureSet);
8128
+ }
7346
8129
  }
7347
8130
  }
7348
8131
  catch {
@@ -7350,6 +8133,7 @@ export class AgentFramework {
7350
8133
  }
7351
8134
  }
7352
8135
  this.mcplTools = tools;
8136
+ this.mcplToolFeatureSets = toolFeatureSets;
7353
8137
  }
7354
8138
  /**
7355
8139
  * Handle a tools/list_changed notification with collapse logic.
@@ -7436,21 +8220,41 @@ export class AgentFramework {
7436
8220
  const startTime = Date.now();
7437
8221
  const args = (call.input && typeof call.input === 'object') ? call.input : {};
7438
8222
  // Build state params for stateful tools (Step 8).
7439
- // Host-managed state is single-valued per server, so inject the host-managed
7440
- // set's `state` for any call; otherwise fall back to a server-managed set's
7441
- // opaque `checkpoint`. Never blindly pick "first stateful" — when a server
7442
- // mixes host-managed (e.g. a feed) and server-managed (e.g. post/undo) sets,
7443
- // that misattributes the feed's state.
8223
+ // A tools/list attribution selects that exact stateful set. Untagged tools
8224
+ // retain the host-managed or unique server-managed fallback, but never guess
8225
+ // between multiple server-managed sets.
7444
8226
  let stateParams;
8227
+ let attributedFeatureSet = null;
7445
8228
  if (this.checkpointManager) {
7446
- const hostFs = this.checkpointManager.getHostManagedFeatureSet(serverId);
7447
- if (hostFs) {
7448
- stateParams = { state: this.checkpointManager.getCurrentState(serverId, hostFs) };
8229
+ const toolFeatureSet = this.mcplToolFeatureSets.get(call.name);
8230
+ if (toolFeatureSet !== undefined) {
8231
+ if (this.checkpointManager.isHostManaged(serverId, toolFeatureSet)
8232
+ || this.checkpointManager.getStatefulFeatureSet(serverId, toolFeatureSet) !== null) {
8233
+ attributedFeatureSet = toolFeatureSet;
8234
+ }
8235
+ else {
8236
+ console.warn(`[mcpl] Tool "${call.name}" declares unknown or non-stateful feature set ` +
8237
+ `"${toolFeatureSet}"; skipping state exchange.`);
8238
+ }
7449
8239
  }
7450
8240
  else {
7451
- const fs = this.checkpointManager.getStatefulFeatureSet(serverId);
7452
- if (fs) {
7453
- const cp = this.checkpointManager.getCurrentCheckpoint(serverId, fs);
8241
+ attributedFeatureSet = this.checkpointManager.getHostManagedFeatureSet(serverId)
8242
+ ?? this.checkpointManager.getStatefulFeatureSet(serverId);
8243
+ if (attributedFeatureSet === null
8244
+ && this.checkpointManager.hasAmbiguousServerManagedFeatureSets(serverId)) {
8245
+ console.warn(`[mcpl] Tool "${call.name}" has no feature-set attribution and server ` +
8246
+ `"${serverId}" has multiple server-managed stateful feature sets; ` +
8247
+ 'skipping state exchange.');
8248
+ }
8249
+ }
8250
+ if (attributedFeatureSet !== null) {
8251
+ if (this.checkpointManager.isHostManaged(serverId, attributedFeatureSet)) {
8252
+ stateParams = {
8253
+ state: this.checkpointManager.getCurrentState(serverId, attributedFeatureSet),
8254
+ };
8255
+ }
8256
+ else {
8257
+ const cp = this.checkpointManager.getCurrentCheckpoint(serverId, attributedFeatureSet);
7454
8258
  if (cp)
7455
8259
  stateParams = { checkpoint: cp };
7456
8260
  }
@@ -7461,15 +8265,10 @@ export class AgentFramework {
7461
8265
  const durationMs = Date.now() - startTime;
7462
8266
  this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs });
7463
8267
  // Record checkpoint from stateful tool response (Step 8).
7464
- // Attribute to the set the server tagged (result.state.featureSet) first;
7465
- // else the host-managed set (host-managed checkpoints carry data/patch);
7466
- // else the single stateful set. Avoids recording a feed checkpoint onto a
7467
- // server-managed set just because it registered first.
8268
+ // Explicit response attribution is authoritative. Otherwise use the
8269
+ // tool-derived (or safe single-set) attribution chosen before the call.
7468
8270
  if (result.state && this.checkpointManager) {
7469
- const tagged = result.state.featureSet;
7470
- const fs = tagged
7471
- ?? this.checkpointManager.getHostManagedFeatureSet(serverId)
7472
- ?? this.checkpointManager.getStatefulFeatureSet(serverId);
8271
+ const fs = result.state.featureSet ?? attributedFeatureSet;
7473
8272
  if (fs && this.checkpointManager.isStateful(serverId, fs)) {
7474
8273
  this.checkpointManager.recordCheckpoint(serverId, fs, result.state);
7475
8274
  }
@@ -7490,6 +8289,23 @@ export class AgentFramework {
7490
8289
  this.channelRegistry
7491
8290
  .openIfClosedForSend(target, serverId)
7492
8291
  .then(({ status, channelId, label }) => {
8292
+ // Turn-scoped engagement record (mid-turn re-pin, 2026-07-31
8293
+ // n=7): a human follow-up in a channel the agent explicitly
8294
+ // sent into this turn counts as conversationally addressed.
8295
+ // Recorded here — not eagerly — because injection metadata
8296
+ // carries the CANONICAL channel id (descriptor form), while
8297
+ // send args often carry the raw provider id; this callback
8298
+ // has the resolved form. The already-open path resolves on
8299
+ // the microtask queue, i.e. before the tool-result event
8300
+ // that evaluates injections.
8301
+ if (status !== 'unknown-channel' && status !== 'ambiguous') {
8302
+ let engaged = this.turnEngagedChannels.get(agentName);
8303
+ if (!engaged) {
8304
+ engaged = new Set();
8305
+ this.turnEngagedChannels.set(agentName, engaged);
8306
+ }
8307
+ engaged.add(channelId ?? target);
8308
+ }
7493
8309
  if (status === 'opened') {
7494
8310
  console.error(`[channel] ${agentName}: explicit ${toolName} into closed channel ${target} — opened (send implies engagement)`);
7495
8311
  this.recordChannelAutoOpenNotice(agentName, [{ channelId: channelId ?? target, label }], 'opened-by-reply');
@@ -7592,17 +8408,37 @@ export class AgentFramework {
7592
8408
  const durationMs = Date.now() - startTime;
7593
8409
  this.emitTrace({ type: 'tool:completed', module: 'channels', tool: call.name, callId: call.id, durationMs });
7594
8410
  // A successful channel_open plants the agent's feet in the opened
7595
- // channel: pin it as this turn's reply locus so plain-text speech
7596
- // written right after opening routes THERE. Found live 2026-07-21
7597
- // (Aria bring-up): open → plain-text reply → "no locus (no
7598
- // home/active channel, defaultPublishChannel null)" on a turn with
7599
- // no channel trigger (post-restart continuation). Next turn start
7600
- // re-resolves the locus as usual (startAgentStream set/delete).
8411
+ // channel. The agent's own deliberate open is the strongest "my next
8412
+ // words go here" signal in the system — stronger than any injection —
8413
+ // so it MOVES the current turn's prose pin (turnLocusPins), not just
8414
+ // the next turn's trigger state. (The original 2026-07-21 Aria fix
8415
+ // set only activeTriggerChannels, which resolveLocus reads at the
8416
+ // NEXT turn's freeze — the turn-frozen refactor had silently
8417
+ // regressed the "reply right after opening" case; found while
8418
+ // mapping the routing logic 2026-07-31, antra-ratified fix.) The
8419
+ // announcement rides THIS TOOL RESULT — model-requested content,
8420
+ // distance zero, the safest role there is — instead of a separate
8421
+ // window notice. lastAnnouncedLocus tracks it so announce-on-change
8422
+ // stays coherent. Locus-mode agents only: explicit mode has no pin.
7601
8423
  if (call.name === 'channel_open' && result?.success) {
7602
8424
  const opened = result.data?.channelId
7603
8425
  ?? call.input?.channelId;
7604
- if (opened)
8426
+ if (opened) {
7605
8427
  this.activeTriggerChannels.set(agentName, opened);
8428
+ const openerAgent = this.agents.get(agentName);
8429
+ if (openerAgent && openerAgent.proseRouting !== 'explicit') {
8430
+ this.turnLocusPins.set(agentName, opened);
8431
+ this.lastAnnouncedLocus.set(agentName, opened);
8432
+ result = {
8433
+ ...result,
8434
+ data: {
8435
+ ...result.data,
8436
+ routing: 'Your plain speech now lands in this channel. Other channels need an explicit send tool.',
8437
+ },
8438
+ };
8439
+ console.error(`[routing] ${agentName}: channel_open -> pin moved to ${opened} (announced in tool result)`);
8440
+ }
8441
+ }
7606
8442
  }
7607
8443
  this.pushEvent({
7608
8444
  type: 'tool-result',
@@ -8314,12 +9150,11 @@ export class AgentFramework {
8314
9150
  conversationId: agent.name,
8315
9151
  turnIndex: 0, // Simplified; needs per-conversation counter TODO
8316
9152
  userMessage: null, // Could extract from trigger context
8317
- model: {
8318
- id: agent.model,
8319
- vendor: 'unknown',
8320
- contextWindow: 200000,
8321
- capabilities: ['tools'],
8322
- },
9153
+ // Truthful fields only (§10.1 honesty over completeness): id is real,
9154
+ // vendor/contextWindow/capabilities have no source in this host and
9155
+ // are omitted rather than fabricated — the hardcoded 200000 was false
9156
+ // for residents configured at 300k/600k.
9157
+ model: { id: agent.model },
8323
9158
  channels: this.channelRegistry?.buildChannelContext(agent.name),
8324
9159
  };
8325
9160
  }