@animalabs/agent-framework 0.7.4 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) 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/code-execution/index.d.ts +1 -1
  6. package/dist/src/code-execution/index.d.ts.map +1 -1
  7. package/dist/src/code-execution/index.js.map +1 -1
  8. package/dist/src/code-execution/py-runner.d.ts +23 -1
  9. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  10. package/dist/src/code-execution/py-runner.js +40 -4
  11. package/dist/src/code-execution/py-runner.js.map +1 -1
  12. package/dist/src/code-execution/runtime-py.d.ts +15 -2
  13. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  14. package/dist/src/code-execution/runtime-py.js +156 -7
  15. package/dist/src/code-execution/runtime-py.js.map +1 -1
  16. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  17. package/dist/src/code-execution/tool-definition.js +27 -2
  18. package/dist/src/code-execution/tool-definition.js.map +1 -1
  19. package/dist/src/framework.d.ts +234 -5
  20. package/dist/src/framework.d.ts.map +1 -1
  21. package/dist/src/framework.js +1818 -188
  22. package/dist/src/framework.js.map +1 -1
  23. package/dist/src/gate/event-gate.d.ts +11 -0
  24. package/dist/src/gate/event-gate.d.ts.map +1 -1
  25. package/dist/src/gate/event-gate.js +66 -0
  26. package/dist/src/gate/event-gate.js.map +1 -1
  27. package/dist/src/index.d.ts +1 -0
  28. package/dist/src/index.d.ts.map +1 -1
  29. package/dist/src/index.js +1 -0
  30. package/dist/src/index.js.map +1 -1
  31. package/dist/src/mcpl/capability-grant.d.ts +115 -0
  32. package/dist/src/mcpl/capability-grant.d.ts.map +1 -0
  33. package/dist/src/mcpl/capability-grant.js +291 -0
  34. package/dist/src/mcpl/capability-grant.js.map +1 -0
  35. package/dist/src/mcpl/capability-mask.d.ts +51 -0
  36. package/dist/src/mcpl/capability-mask.d.ts.map +1 -0
  37. package/dist/src/mcpl/capability-mask.js +190 -0
  38. package/dist/src/mcpl/capability-mask.js.map +1 -0
  39. package/dist/src/mcpl/channel-registry.d.ts +30 -2
  40. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  41. package/dist/src/mcpl/channel-registry.js +216 -43
  42. package/dist/src/mcpl/channel-registry.js.map +1 -1
  43. package/dist/src/mcpl/checkpoint-manager.d.ts +8 -3
  44. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  45. package/dist/src/mcpl/checkpoint-manager.js +28 -8
  46. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  47. package/dist/src/mcpl/errors.d.ts +6 -0
  48. package/dist/src/mcpl/errors.d.ts.map +1 -1
  49. package/dist/src/mcpl/errors.js +6 -0
  50. package/dist/src/mcpl/errors.js.map +1 -1
  51. package/dist/src/mcpl/feature-set-manager.d.ts +10 -1
  52. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  53. package/dist/src/mcpl/feature-set-manager.js +45 -4
  54. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  55. package/dist/src/mcpl/hook-orchestrator.d.ts +10 -14
  56. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  57. package/dist/src/mcpl/hook-orchestrator.js +58 -67
  58. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  59. package/dist/src/mcpl/index.d.ts +1 -1
  60. package/dist/src/mcpl/index.d.ts.map +1 -1
  61. package/dist/src/mcpl/index.js.map +1 -1
  62. package/dist/src/mcpl/prose-stream-router.d.ts +9 -0
  63. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
  64. package/dist/src/mcpl/prose-stream-router.js +16 -0
  65. package/dist/src/mcpl/prose-stream-router.js.map +1 -1
  66. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  67. package/dist/src/mcpl/push-handler.js +20 -2
  68. package/dist/src/mcpl/push-handler.js.map +1 -1
  69. package/dist/src/mcpl/server-connection.d.ts +93 -9
  70. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  71. package/dist/src/mcpl/server-connection.js +231 -32
  72. package/dist/src/mcpl/server-connection.js.map +1 -1
  73. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  74. package/dist/src/mcpl/server-registry.js +4 -3
  75. package/dist/src/mcpl/server-registry.js.map +1 -1
  76. package/dist/src/mcpl/tags.d.ts +24 -0
  77. package/dist/src/mcpl/tags.d.ts.map +1 -0
  78. package/dist/src/mcpl/tags.js +52 -0
  79. package/dist/src/mcpl/tags.js.map +1 -0
  80. package/dist/src/mcpl/transport.d.ts +1 -1
  81. package/dist/src/mcpl/transport.d.ts.map +1 -1
  82. package/dist/src/mcpl/transport.js +35 -7
  83. package/dist/src/mcpl/transport.js.map +1 -1
  84. package/dist/src/mcpl/types.d.ts +169 -64
  85. package/dist/src/mcpl/types.d.ts.map +1 -1
  86. package/dist/src/mcpl/types.js +16 -2
  87. package/dist/src/mcpl/types.js.map +1 -1
  88. package/dist/src/module-registry.d.ts +12 -0
  89. package/dist/src/module-registry.d.ts.map +1 -1
  90. package/dist/src/module-registry.js +26 -0
  91. package/dist/src/module-registry.js.map +1 -1
  92. package/dist/src/modules/workspace/index.d.ts +20 -0
  93. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  94. package/dist/src/modules/workspace/index.js +48 -15
  95. package/dist/src/modules/workspace/index.js.map +1 -1
  96. package/dist/src/modules/workspace/sync.d.ts +4 -0
  97. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  98. package/dist/src/modules/workspace/sync.js +1 -1
  99. package/dist/src/modules/workspace/sync.js.map +1 -1
  100. package/dist/src/refusal-reactions.d.ts +27 -0
  101. package/dist/src/refusal-reactions.d.ts.map +1 -0
  102. package/dist/src/refusal-reactions.js +35 -0
  103. package/dist/src/refusal-reactions.js.map +1 -0
  104. package/dist/src/tool-result-history.d.ts +10 -0
  105. package/dist/src/tool-result-history.d.ts.map +1 -1
  106. package/dist/src/tool-result-history.js +16 -5
  107. package/dist/src/tool-result-history.js.map +1 -1
  108. package/dist/src/types/agent.d.ts +35 -0
  109. package/dist/src/types/agent.d.ts.map +1 -1
  110. package/dist/src/types/framework.d.ts +26 -0
  111. package/dist/src/types/framework.d.ts.map +1 -1
  112. package/dist/src/types/module.d.ts +22 -0
  113. package/dist/src/types/module.d.ts.map +1 -1
  114. package/dist/src/types/trace.d.ts +24 -0
  115. package/dist/src/types/trace.d.ts.map +1 -1
  116. package/dist/test/busy-agent-scheduler.test.js +1 -0
  117. package/dist/test/busy-agent-scheduler.test.js.map +1 -1
  118. package/dist/test/channel-registry-routing.test.js +38 -10
  119. package/dist/test/channel-registry-routing.test.js.map +1 -1
  120. package/dist/test/channel-registry-typing.test.js +10 -1
  121. package/dist/test/channel-registry-typing.test.js.map +1 -1
  122. package/dist/test/checkpoint-attribution.test.js +168 -8
  123. package/dist/test/checkpoint-attribution.test.js.map +1 -1
  124. package/dist/test/code-execution.test.js +327 -1
  125. package/dist/test/code-execution.test.js.map +1 -1
  126. package/dist/test/conversation-fork-injection-scoping.test.js +8 -3
  127. package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
  128. package/dist/test/deferred-wake-integrity.test.d.ts +29 -0
  129. package/dist/test/deferred-wake-integrity.test.d.ts.map +1 -0
  130. package/dist/test/deferred-wake-integrity.test.js +320 -0
  131. package/dist/test/deferred-wake-integrity.test.js.map +1 -0
  132. package/dist/test/event-gate-selfwake.test.d.ts +15 -0
  133. package/dist/test/event-gate-selfwake.test.d.ts.map +1 -0
  134. package/dist/test/event-gate-selfwake.test.js +125 -0
  135. package/dist/test/event-gate-selfwake.test.js.map +1 -0
  136. package/dist/test/machine-close-provenance.test.d.ts +2 -0
  137. package/dist/test/machine-close-provenance.test.d.ts.map +1 -0
  138. package/dist/test/machine-close-provenance.test.js +116 -0
  139. package/dist/test/machine-close-provenance.test.js.map +1 -0
  140. package/dist/test/mcpl-access-provider.test.d.ts +2 -0
  141. package/dist/test/mcpl-access-provider.test.d.ts.map +1 -0
  142. package/dist/test/mcpl-access-provider.test.js +20 -0
  143. package/dist/test/mcpl-access-provider.test.js.map +1 -0
  144. package/dist/test/mcpl-awareness-barrier.test.js +10 -0
  145. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
  146. package/dist/test/mcpl-capability-grant.test.d.ts +10 -0
  147. package/dist/test/mcpl-capability-grant.test.d.ts.map +1 -0
  148. package/dist/test/mcpl-capability-grant.test.js +109 -0
  149. package/dist/test/mcpl-capability-grant.test.js.map +1 -0
  150. package/dist/test/mcpl-capability-scoping.test.d.ts +2 -0
  151. package/dist/test/mcpl-capability-scoping.test.d.ts.map +1 -0
  152. package/dist/test/mcpl-capability-scoping.test.js +266 -0
  153. package/dist/test/mcpl-capability-scoping.test.js.map +1 -0
  154. package/dist/test/mcpl-gate-roundtrip.test.js +13 -0
  155. package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
  156. package/dist/test/mcpl-list-status.test.d.ts +2 -0
  157. package/dist/test/mcpl-list-status.test.d.ts.map +1 -0
  158. package/dist/test/mcpl-list-status.test.js +78 -0
  159. package/dist/test/mcpl-list-status.test.js.map +1 -0
  160. package/dist/test/mcpl-manifest-changed.test.d.ts +2 -0
  161. package/dist/test/mcpl-manifest-changed.test.d.ts.map +1 -0
  162. package/dist/test/mcpl-manifest-changed.test.js +177 -0
  163. package/dist/test/mcpl-manifest-changed.test.js.map +1 -0
  164. package/dist/test/mcpl-reconnect-events.test.js +27 -2
  165. package/dist/test/mcpl-reconnect-events.test.js.map +1 -1
  166. package/dist/test/mcpl-reconnect-reregistration.test.js +53 -17
  167. package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
  168. package/dist/test/mcpl-request-timeout.test.js +1 -1
  169. package/dist/test/mcpl-request-timeout.test.js.map +1 -1
  170. package/dist/test/module-context-integration.test.d.ts +18 -0
  171. package/dist/test/module-context-integration.test.d.ts.map +1 -0
  172. package/dist/test/module-context-integration.test.js +181 -0
  173. package/dist/test/module-context-integration.test.js.map +1 -0
  174. package/dist/test/physical-window-restart.test.d.ts +14 -0
  175. package/dist/test/physical-window-restart.test.d.ts.map +1 -0
  176. package/dist/test/physical-window-restart.test.js +222 -0
  177. package/dist/test/physical-window-restart.test.js.map +1 -0
  178. package/dist/test/present-while-acting.test.js +270 -0
  179. package/dist/test/present-while-acting.test.js.map +1 -1
  180. package/dist/test/refusal-reaction-baseline.test.d.ts +2 -0
  181. package/dist/test/refusal-reaction-baseline.test.d.ts.map +1 -0
  182. package/dist/test/refusal-reaction-baseline.test.js +74 -0
  183. package/dist/test/refusal-reaction-baseline.test.js.map +1 -0
  184. package/dist/test/state-scaling-e2e.test.js +7 -1
  185. package/dist/test/state-scaling-e2e.test.js.map +1 -1
  186. package/dist/test/state-scaling.test.js +9 -1
  187. package/dist/test/state-scaling.test.js.map +1 -1
  188. package/dist/test/tool-result-spill.test.d.ts +13 -0
  189. package/dist/test/tool-result-spill.test.d.ts.map +1 -0
  190. package/dist/test/tool-result-spill.test.js +463 -0
  191. package/dist/test/tool-result-spill.test.js.map +1 -0
  192. package/dist/test/trunk-channel-routing.test.js +45 -0
  193. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  194. package/dist/test/utils-surface.test.d.ts +2 -0
  195. package/dist/test/utils-surface.test.d.ts.map +1 -0
  196. package/dist/test/utils-surface.test.js +145 -0
  197. package/dist/test/utils-surface.test.js.map +1 -0
  198. package/dist/test/workspace-read-image.test.js +71 -0
  199. package/dist/test/workspace-read-image.test.js.map +1 -1
  200. package/dist/test/workspace-restore-order.test.d.ts +16 -0
  201. package/dist/test/workspace-restore-order.test.d.ts.map +1 -0
  202. package/dist/test/workspace-restore-order.test.js +140 -0
  203. package/dist/test/workspace-restore-order.test.js.map +1 -0
  204. package/dist/tsconfig.tsbuildinfo +1 -1
  205. package/package.json +4 -4
  206. package/src/agent.ts +37 -3
  207. package/src/code-execution/index.ts +1 -0
  208. package/src/code-execution/py-runner.ts +56 -4
  209. package/src/code-execution/runtime-py.ts +156 -7
  210. package/src/code-execution/tool-definition.ts +27 -2
  211. package/src/framework.ts +2080 -203
  212. package/src/gate/event-gate.ts +72 -0
  213. package/src/index.ts +1 -0
  214. package/src/mcpl/capability-grant.ts +319 -0
  215. package/src/mcpl/capability-mask.ts +228 -0
  216. package/src/mcpl/channel-registry.ts +265 -43
  217. package/src/mcpl/checkpoint-manager.ts +28 -8
  218. package/src/mcpl/errors.ts +7 -0
  219. package/src/mcpl/feature-set-manager.ts +50 -5
  220. package/src/mcpl/hook-orchestrator.ts +64 -86
  221. package/src/mcpl/index.ts +0 -2
  222. package/src/mcpl/prose-stream-router.ts +17 -0
  223. package/src/mcpl/push-handler.ts +18 -2
  224. package/src/mcpl/server-connection.ts +262 -34
  225. package/src/mcpl/server-registry.ts +4 -3
  226. package/src/mcpl/tags.ts +50 -0
  227. package/src/mcpl/transport.ts +37 -7
  228. package/src/mcpl/types.ts +187 -78
  229. package/src/module-registry.ts +34 -0
  230. package/src/modules/workspace/index.ts +49 -15
  231. package/src/modules/workspace/sync.ts +1 -1
  232. package/src/refusal-reactions.ts +37 -0
  233. package/src/tool-result-history.ts +17 -5
  234. package/src/types/agent.ts +36 -0
  235. package/src/types/framework.ts +27 -0
  236. package/src/types/module.ts +24 -0
  237. 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,
@@ -157,6 +192,61 @@ const PROSE_HELP_TOOL = {
157
192
  };
158
193
  /** Strip the `server--` MCPL prefix from a tool name. */
159
194
  const bareToolName = (n) => n.split('--').pop();
195
+ /** The single slot behind which every rarely-used capability lives. Only
196
+ * advertised when at least one module contributes utilities — with none
197
+ * registered the surface is byte-identical to before this existed. */
198
+ const UTILS_TOOL = {
199
+ name: 'utils',
200
+ description: 'Rarely-used utilities, behind one slot instead of each costing a tool. ' +
201
+ '"list" names them all with one-liners; "describe" returns one utility\'s full ' +
202
+ 'input schema; "run" invokes it (args are validated against that schema — a ' +
203
+ 'miss bounces back with the schema and the specific error).',
204
+ inputSchema: {
205
+ type: 'object',
206
+ properties: {
207
+ action: { type: 'string', enum: ['list', 'describe', 'run'], description: 'What to do.' },
208
+ name: { type: 'string', description: 'Utility name from list (required for describe/run).' },
209
+ args: { type: 'object', description: "Arguments for run, per the utility's schema." },
210
+ },
211
+ required: ['action'],
212
+ },
213
+ };
214
+ /** First sentence (or line) of a description — what `utils list` shows. */
215
+ const utilityOneLiner = (description) => {
216
+ const line = description.split('\n')[0];
217
+ const dot = line.indexOf('. ');
218
+ return (dot >= 0 ? line.slice(0, dot + 1) : line).slice(0, 160);
219
+ };
220
+ /** Shallow validation of run args against a utility's declared inputSchema:
221
+ * required keys, primitive types, enums. Deliberately not a full JSON-Schema
222
+ * engine — handlers still hand-validate (they always did); this layer exists
223
+ * so an arg-shape miss teaches by bounce instead of failing deep in a handler
224
+ * with a less instructive message. Returns an error string or null. */
225
+ function validateUtilityArgs(input, schema) {
226
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
227
+ return 'args must be an object';
228
+ }
229
+ const obj = input;
230
+ for (const req of schema.required ?? []) {
231
+ if (!(req in obj) || obj[req] === undefined)
232
+ return `missing required "${req}"`;
233
+ }
234
+ for (const [key, value] of Object.entries(obj)) {
235
+ const param = schema.properties?.[key];
236
+ if (!param || value === undefined)
237
+ continue; // unknown keys: handler's business
238
+ if (param.enum && !param.enum.includes(value)) {
239
+ return `"${key}" must be one of ${JSON.stringify(param.enum)}`;
240
+ }
241
+ if (param.type) {
242
+ const actual = Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value;
243
+ const expected = param.type === 'integer' ? 'number' : param.type;
244
+ if (actual !== expected)
245
+ return `"${key}" must be ${param.type}, got ${actual}`;
246
+ }
247
+ }
248
+ return null;
249
+ }
160
250
  import { CheckpointManager } from './mcpl/checkpoint-manager.js';
161
251
  import { isToolAllowed } from './mcpl/tool-policy.js';
162
252
  import { EventGate } from './gate/event-gate.js';
@@ -314,8 +404,45 @@ export class AgentFramework {
314
404
  * plain prose lands (2026-07-21 Cairn lounge misroute). Lives here — not in
315
405
  * driveStream locals — so the pin survives a context-budget stream restart,
316
406
  * which continues the same logical turn in a fresh driveStream. Cleared or
317
- * 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. */
318
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();
319
446
  /** A tool boundary injected fresh CONVERSATIONAL input (a real message —
320
447
  * not a reaction or a system marker) into the live stream. Tells
321
448
  * driveStream to clear sticky explicit-send suppression before handling
@@ -398,6 +525,24 @@ export class AgentFramework {
398
525
  // Messages deferred while an agent is waiting_for_tools (to preserve
399
526
  // tool_use → tool_result adjacency required by the Anthropic API).
400
527
  deferredMessages = [];
528
+ // Turn-alive markers (2026-07-31 Mythos phantom-skip incident): agentName →
529
+ // token of the turn currently in progress. Set at startAgentStream ENTRY —
530
+ // before hooks, compile, and stream setup, all of which await — and cleared
531
+ // token-matched when that turn's teardown finishes (driveStream finally /
532
+ // terminal failure path), so a successor turn's marker survives a
533
+ // predecessor's late finally. A turn is alive from dequeue to settled
534
+ // teardown: strictly longer than `activeStreams` membership, which begins
535
+ // only after the stream exists. Guarding addMessage on stream-liveness
536
+ // alone left the compile window open — a deferred message flushed by the
537
+ // previous turn's finally landed in the window MID-COMPILE of the turn its
538
+ // own wake had started: positioned before the new turn's blocks yet absent
539
+ // from its wire request. The agent then "skipped" a message it never saw
540
+ // (falsified history — it later confabulated an apology for the skip), and
541
+ // every subsequent compile diverged from the live prefix at that message
542
+ // (KV bust). Invariant: nothing enters the window between a turn's dequeue
543
+ // and its settle except that turn's own blocks.
544
+ activeTurnTokens = new Map();
545
+ nextTurnToken = 1;
401
546
  // Undo/redo state
402
547
  turnCounters = new Map(); // agentName → next turnIndex
403
548
  redoStacks = new Map(); // agentName → redo entries
@@ -406,7 +551,6 @@ export class AgentFramework {
406
551
  // MCPL subsystems (null when no mcplServers configured)
407
552
  mcplServerRegistry = null;
408
553
  featureSetManager = null;
409
- scopeManager = null;
410
554
  hookOrchestrator = null;
411
555
  pushHandler = null;
412
556
  inferenceRouter = null;
@@ -438,7 +582,22 @@ export class AgentFramework {
438
582
  * deferred and applied to the final code_execution result instead of
439
583
  * cancelling the stream mid-script (which would wedge the turn). */
440
584
  scriptDeferredEndTurn = new Set();
585
+ /** Background (daemon) scripts: model-authored watchers that outlive their
586
+ * spawning turn. Each gets a DEDICATED PyRunner; wake_agent() injects a
587
+ * provenance envelope + payload and requests inference. Keyed by script id. */
588
+ backgroundScripts = new Map();
589
+ backgroundScriptCounter = 0;
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. */
594
+ toolResultInlineMaxCharsOverride = new Map();
595
+ /** Durable residence-configured inline cap from
596
+ * FrameworkConfig.toolResultInlineMaxChars; null → house default. */
597
+ toolResultInlineMaxCharsConfig = null;
441
598
  mcplTools = [];
599
+ /** Namespaced tool name → stateful feature-set attribution from tools/list. */
600
+ mcplToolFeatureSets = new Map();
442
601
  mcplToolRefreshInFlight = false;
443
602
  mcplToolRefreshPending = false;
444
603
  /** Maps tool prefix → serverId for dispatch routing. */
@@ -495,7 +654,12 @@ export class AgentFramework {
495
654
  queryMessages: (filter) => this.queryMessages(filter),
496
655
  pushEvent: (event) => this.pushEvent(event),
497
656
  onTrace: (listener) => this.onTrace(listener),
498
- callTool: (call) => this.executeToolCall(call),
657
+ // The ONLY entry that confers module origin — a private closure the
658
+ // framework hands to its own module registry. The public
659
+ // executeToolCall() is shared with model/ephemeral callers and always
660
+ // stamps agent origin (see there).
661
+ callTool: (call) => this.executeToolCallFrom(call, { kind: 'module' }),
662
+ notifyOps: (kind, agentName, message, data) => this.notifyOps(kind, agentName, message, data),
499
663
  });
500
664
  }
501
665
  /**
@@ -658,6 +822,18 @@ export class AgentFramework {
658
822
  // Client-side programmatic tool calling (code_execution). Config is only
659
823
  // retained when enabled — everything downstream gates on the field.
660
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
+ }
661
837
  // Initialize MCPL subsystems if configured
662
838
  if (config.mcplServers && config.mcplServers.length > 0) {
663
839
  // Validate tool prefixes: no collisions with module names or between servers
@@ -745,6 +921,17 @@ export class AgentFramework {
745
921
  runner.dispose();
746
922
  }
747
923
  this.codeExecutionRunners.clear();
924
+ // Background daemons die with the process (documented limitation) —
925
+ // mark cancelled FIRST so their settle path stays silent (no crash-wake
926
+ // into a framework that is shutting down).
927
+ for (const record of this.backgroundScripts.values()) {
928
+ if (record.status === 'running') {
929
+ record.cancelled = true;
930
+ record.status = 'cancelled';
931
+ }
932
+ record.runner.dispose();
933
+ }
934
+ this.backgroundScripts.clear();
748
935
  // Cancel all active streams
749
936
  for (const agent of this.agents.values()) {
750
937
  if (agent.state.status === 'streaming' ||
@@ -1003,6 +1190,8 @@ export class AgentFramework {
1003
1190
  : [];
1004
1191
  return [
1005
1192
  ...moduleTools,
1193
+ // Utilities collapse into one slot; absent entirely when none exist.
1194
+ ...(this.moduleRegistry.getAllUtilities().length > 0 ? [UTILS_TOOL] : []),
1006
1195
  ...this.mcplTools,
1007
1196
  ...channelTools,
1008
1197
  ...gateTools,
@@ -1038,6 +1227,16 @@ export class AgentFramework {
1038
1227
  collectAgentSettingsExtensions() {
1039
1228
  const result = new Map();
1040
1229
  const taken = new Set(AgentFramework.AGENT_SETTINGS_CORE_KEYS);
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.
1235
+ {
1236
+ const ext = this.spillSettingsExtension();
1237
+ ext.keys.forEach((k) => taken.add(k));
1238
+ result.set('_framework', ext);
1239
+ }
1041
1240
  for (const module of this.moduleRegistry.getAllModules()) {
1042
1241
  const ext = module.getAgentSettingsExtension?.();
1043
1242
  if (!ext)
@@ -1052,6 +1251,51 @@ export class AgentFramework {
1052
1251
  }
1053
1252
  return result;
1054
1253
  }
1254
+ /** agent_settings extension for the spill gate (see spillOrTruncate). */
1255
+ spillSettingsExtension() {
1256
+ return {
1257
+ properties: {
1258
+ tool_result_inline_max_chars: {
1259
+ type: 'number',
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.',
1268
+ },
1269
+ },
1270
+ keys: ['tool_result_inline_max_chars'],
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
+ },
1281
+ update: (agentName, patch) => {
1282
+ const n = Number(patch.tool_result_inline_max_chars);
1283
+ if (!Number.isFinite(n) || n < 1000) {
1284
+ throw new Error('tool_result_inline_max_chars must be a number >= 1000');
1285
+ }
1286
+ this.toolResultInlineMaxCharsOverride.set(agentName, Math.floor(n));
1287
+ this.persistToolResultInlineCap(agentName, Math.floor(n));
1288
+ return { tool_result_inline_max_chars: Math.floor(n) };
1289
+ },
1290
+ reset: (agentName, keys) => {
1291
+ if (!keys || keys.includes('tool_result_inline_max_chars')) {
1292
+ this.toolResultInlineMaxCharsOverride.delete(agentName);
1293
+ this.persistToolResultInlineCap(agentName, null);
1294
+ }
1295
+ return { tool_result_inline_max_chars: null };
1296
+ },
1297
+ };
1298
+ }
1055
1299
  captureInferenceToolSnapshot(agent) {
1056
1300
  return {
1057
1301
  sameRoundThinkTextPolicy: agent.getEffectiveSameRoundThinkTextPolicy(),
@@ -1402,6 +1646,44 @@ export class AgentFramework {
1402
1646
  }
1403
1647
  return overrides;
1404
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
+ }
1405
1687
  persistAgentRuntimeSettings(agentName, overrides) {
1406
1688
  const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
1407
1689
  const state = (data && typeof data === 'object' ? data : {});
@@ -1564,6 +1846,12 @@ export class AgentFramework {
1564
1846
  // ephemeral agents leave checkpoint-tree keys and diagnostics map
1565
1847
  // entries behind for the life of the store/session.
1566
1848
  this.evictTurnCheckpoints(agent.name);
1849
+ // Turn-alive marker, same spawn-and-dispose logic: a stalled ephemeral
1850
+ // (watchdog fired, driveStream's terminal event lost) would strand its
1851
+ // token forever — no wedge (ephemeral names are unique per run) but
1852
+ // unbounded map growth. Blind delete is safe: the agent is already out
1853
+ // of the map, and a late driveStream finally token-match no-ops.
1854
+ this.activeTurnTokens.delete(agent.name);
1567
1855
  }
1568
1856
  }
1569
1857
  /**
@@ -1834,14 +2122,6 @@ export class AgentFramework {
1834
2122
  'chat:group': 'Group (multi-party) conversation',
1835
2123
  'chat:thread': 'Occurred in a thread',
1836
2124
  };
1837
- /** Refusal category → Discord reaction emoji. Unknown categories get 🛑. */
1838
- static REFUSAL_REACTIONS = {
1839
- bio: '☣️',
1840
- chem: '🧪',
1841
- nuclear: '☢️',
1842
- cyber: '💻',
1843
- reasoning_extraction: '🧠',
1844
- };
1845
2125
  /**
1846
2126
  * Mark an inference refusal visibly: react on the message that holds the
1847
2127
  * conversational locus (the most recent incoming channel message) with a
@@ -1860,7 +2140,7 @@ export class AgentFramework {
1860
2140
  if (parts[0] !== 'discord')
1861
2141
  return;
1862
2142
  const channelId = parts[parts.length - 1];
1863
- const emoji = AgentFramework.REFUSAL_REACTIONS[category] ?? '🛑';
2143
+ const emoji = REFUSAL_REACTIONS[category] ?? REFUSAL_REACTION_FALLBACK;
1864
2144
  // Resolve the MCPL server that owns the locus channel and call
1865
2145
  // tools/call directly on its connection (bare tool name — no prefix
1866
2146
  // games), bypassing the agent event queue so no synthetic tool-result
@@ -2056,6 +2336,38 @@ export class AgentFramework {
2056
2336
  console.error('[unstick] report post failed:', err instanceof Error ? err.message : err);
2057
2337
  }
2058
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
+ }
2059
2371
  /**
2060
2372
  * Handle a `host/command` request from an MCPL surface server (e.g. a
2061
2373
  * Discord slash command). Currently supports:
@@ -2064,15 +2376,27 @@ export class AgentFramework {
2064
2376
  * `undoLastTurn`). The response includes the last message the agent
2065
2377
  * would see in its context after the undo, obtained via the transparent
2066
2378
  * `previewActivation` render (no inference, no Chronicle writes).
2379
+ *
2380
+ * nudge — run inference on the current context with NO new events
2381
+ * (see `nudgeAgent`).
2067
2382
  */
2068
2383
  async handleHostCommand(serverId, params) {
2069
- 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') {
2070
2388
  return { ok: false, error: `Unknown host command: ${String(params.command)}` };
2071
2389
  }
2072
2390
  const agentName = params.agentName ?? [...this.agents.keys()][0];
2073
2391
  if (!agentName || !this.agents.has(agentName)) {
2074
2392
  return { ok: false, error: `Unknown agent: ${String(agentName)}` };
2075
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
+ }
2076
2400
  // unstick: force the refusal-rewind loop on demand (even if the agent's
2077
2401
  // autoRewind toggle is off). Redacts the turn that fed the refusal and
2078
2402
  // re-runs, up to `cap` times, until the model stops refusing. Kicks the
@@ -2845,20 +3169,16 @@ export class AgentFramework {
2845
3169
  agent.addAssistantResponse(pendingBlocks);
2846
3170
  this.pendingAssistantBlocks.delete(agent.name);
2847
3171
  }
2848
- // Compute truncation limit from agent's strategy (maxMessageTokens * 4 chars)
2849
- const maxChars = this.getMaxToolResultChars(agent);
2850
- // Store tool results as a user message (tool_result blocks).
2851
- // Use the history serializer so MCP image blocks become a short
2852
- // `[image: type, size]` placeholder instead of megabytes of base64
2853
- // that would corrupt under truncation.
2854
- const toolResultContent = currentState.toolResults.map(tc => ({
2855
- type: 'tool_result',
2856
- toolUseId: tc.id,
2857
- content: tc.result.isError
2858
- ? tc.result.error ?? 'Unknown error'
2859
- : toolResultDataToHistoryString(tc.result.data, maxChars),
2860
- isError: tc.result.isError,
2861
- }));
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);
2862
3182
  agent.getContextManager().addMessage('user', toolResultContent);
2863
3183
  // Flush any messages that were deferred while this turn was in
2864
3184
  // flight. Route to the PRIMARY agent — deferred messages are
@@ -2923,6 +3243,110 @@ export class AgentFramework {
2923
3243
  const overBudget = currentState.stream
2924
3244
  && agent.lastStreamInputTokens > 0
2925
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);
3269
+ // Addressed re-pin (2026-07-31 Mythos misroutes, antra-ratified):
3270
+ // when someone ADDRESSES the agent mid-turn from another channel
3271
+ // (mention / reply / DM — the same chat:addressed signal the
3272
+ // turn-START batch policy prefers), the agent's subsequent plain
3273
+ // prose almost always answers THEM. Six recorded misroutes
3274
+ // delivered those answers to the stale frozen locus instead (the
3275
+ // LabClaude answer into #hospital_commons mid-examination; the
3276
+ // hospital scene into antra's DM). Re-pin the turn locus to the
3277
+ // LAST addressed conversational injection, at segment granularity:
3278
+ // prose routed in earlier rounds already went to the old pin,
3279
+ // which was correct when it was delivered. Ambient chatter,
3280
+ // reactions, and system markers still cannot move the pin — the
3281
+ // 2026-07-21 Cairn hijack protection is unchanged; this extends
3282
+ // the ratified addressed-outranks-ambient principle from turn
3283
+ // start to boundaries. Locus mode only (explicit mode has no pin).
3284
+ // Skipped on endTurn/overBudget: no further prose this stream, and
3285
+ // a stale "for this turn" notice would be pure noise.
3286
+ //
3287
+ // The notice rides the SAME injection batch — stored right after
3288
+ // the flushed messages and appended to midTurnInjections — so the
3289
+ // window byte-matches the wire (short event-style notice: the
3290
+ // classifier-safe D-class from the 2026-07-24 ablation). It also
3291
+ // updates lastAnnouncedLocus so the next turn's announce-on-change
3292
+ // diffs against what the agent was actually last told.
3293
+ if (agent.proseRouting !== 'explicit' &&
3294
+ !shouldEndTurn && !overBudget && currentState.stream) {
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) => {
3312
+ const m = inj.metadata;
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;
3321
+ });
3322
+ const newLocus = lastQualifying?.metadata
3323
+ ?.channelId;
3324
+ if (newLocus && this.turnLocusPins.get(agent.name) !== newLocus) {
3325
+ const prevPin = this.turnLocusPins.get(agent.name) ?? null;
3326
+ this.turnLocusPins.set(agent.name, newLocus);
3327
+ this.lastAnnouncedLocus.set(agent.name, newLocus);
3328
+ const label = this.channelRegistry?.getDescriptor(newLocus)?.label;
3329
+ const shown = label && label !== newLocus
3330
+ ? `${label.startsWith('#') ? label : `#${label}`} (${newLocus})`
3331
+ : newLocus;
3332
+ const noticeContent = [{
3333
+ type: 'text',
3334
+ text: `[routing] The conversation moved to ${shown} — your plain ` +
3335
+ 'speech now lands there for the rest of this turn. Other ' +
3336
+ 'channels need an explicit send tool.',
3337
+ }];
3338
+ const noticeMeta = { system: true, kind: 'routing-notice' };
3339
+ try {
3340
+ const id = agent.getContextManager().addMessage('user', noticeContent, noticeMeta);
3341
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
3342
+ midTurnInjections.push({ participant: 'user', content: noticeContent, metadata: noticeMeta });
3343
+ console.error(`[routing] ${agent.name}: mid-turn addressed re-pin ${prevPin ?? '(none)'} -> ${newLocus}`);
3344
+ }
3345
+ catch (err) {
3346
+ console.error('mid-turn locus re-pin: failed to record routing notice:', err);
3347
+ }
3348
+ }
3349
+ }
2926
3350
  if (shouldEndTurn) {
2927
3351
  // endTurn: messages already stored above, cancel stream, reset to idle.
2928
3352
  if (currentState.stream) {
@@ -2938,7 +3362,7 @@ export class AgentFramework {
2938
3362
  this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' });
2939
3363
  this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
2940
3364
  }
2941
- else if (overBudget) {
3365
+ else if (overBudget || overPhysical) {
2942
3366
  // Context budget exceeded: break the stream, let compile() compress.
2943
3367
  // Mark the cancel as framework-initiated BEFORE cancelling: the
2944
3368
  // membrane delivers `aborted` before the restart bumps streamId,
@@ -2948,14 +3372,27 @@ export class AgentFramework {
2948
3372
  // endTurn above).
2949
3373
  if (currentState.stream) {
2950
3374
  this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
3375
+ // Cancel the LIVE stream object directly (like the endTurn
3376
+ // branch above): at a tool boundary the state machine reads
3377
+ // 'ready', and Agent.cancelStream only signals the stream from
3378
+ // 'streaming'/'waiting_for_tools' — so the old call left the
3379
+ // provider stream parked forever awaiting tool results that
3380
+ // would never come (zombie: driveStream's finally never ran,
3381
+ // its activeStreams entry survived until the restart's stream
3382
+ // overwrote it). Harmless by accident before turn-alive
3383
+ // tracking; a genuine teardown leak after it.
3384
+ currentState.stream.cancel();
2951
3385
  }
2952
3386
  agent.cancelStream();
2953
3387
  this.emitTrace({
2954
3388
  type: 'inference:stream_restarted',
2955
3389
  agentName: agent.name,
2956
- reason: 'context_budget',
2957
- inputTokens: agent.lastStreamInputTokens,
2958
- 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),
2959
3396
  });
2960
3397
  this.pendingRequests.push({
2961
3398
  agentName: agent.name,
@@ -2969,7 +3406,7 @@ export class AgentFramework {
2969
3406
  // Mid-turn messages collected above ride along as injected user
2970
3407
  // messages (membrane ≥0.5.72) — appended after the tool_result
2971
3408
  // envelope so the next round of THIS turn hears them.
2972
- const membraneResults = currentState.toolResults.map(tc => this.toMembraneToolResult(tc.id, tc.result, maxChars));
3409
+ const membraneResults = currentState.toolResults.map(tc => this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id)));
2973
3410
  currentState.stream.provideToolResults(membraneResults, midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections } : undefined);
2974
3411
  agent.setStreaming(currentState.stream);
2975
3412
  this.emitTrace({
@@ -3123,6 +3560,12 @@ export class AgentFramework {
3123
3560
  ? event.metadata.guildName
3124
3561
  : undefined,
3125
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,
3126
3569
  });
3127
3570
  if (invitation) {
3128
3571
  incomingContent.push(invitation.block);
@@ -3393,6 +3836,17 @@ export class AgentFramework {
3393
3836
  * this should see WHO addressed it WHERE, not a wall of snowflake ids. Raw
3394
3837
  * ids remain only inside the tool-argument instructions, where they are the
3395
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.
3396
3850
  */
3397
3851
  buildClosedChannelInvitation(opts) {
3398
3852
  if (!this.channelRegistry)
@@ -3406,12 +3860,21 @@ export class AgentFramework {
3406
3860
  const label = opts.channelLabel ?? descriptor?.label;
3407
3861
  const channelLabel = label ? `#${label}` : `"${opts.channelId}"`;
3408
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
+ : '';
3409
3869
  const block = {
3410
3870
  type: 'text',
3411
3871
  text: `\n[Channel invitation] ${opts.authorName} addressed you in ${place} — ` +
3412
- `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 +
3413
3875
  `Your options:\n` +
3414
- `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` +
3415
3878
  `2. Join the channel — call channel_open with channelId "${opts.channelId}" and serverId "${opts.serverId}"` +
3416
3879
  (maxBackscroll > 0
3417
3880
  ? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${opts.messageId}".\n`
@@ -3466,6 +3929,8 @@ export class AgentFramework {
3466
3929
  authorName: typeof origin.authorName === 'string' && origin.authorName ? origin.authorName : 'Someone',
3467
3930
  guildName: typeof origin.guildName === 'string' && origin.guildName ? origin.guildName : undefined,
3468
3931
  tags: event.tags,
3932
+ missedMessages: typeof origin.missedMessages === 'number' ? origin.missedMessages : undefined,
3933
+ missedCharacters: typeof origin.missedCharacters === 'number' ? origin.missedCharacters : undefined,
3469
3934
  });
3470
3935
  if (invitation) {
3471
3936
  content.push(invitation.block);
@@ -3576,8 +4041,24 @@ export class AgentFramework {
3576
4041
  console.error(`[inference-dropped] agent=${agentName} reason=agent_not_found requests=${requests.length}`);
3577
4042
  continue;
3578
4043
  }
3579
- // Skip if agent is busy (inferring, streaming, or waiting for tools)
3580
- if (agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
4044
+ // Skip if agent is busy (inferring, streaming, or waiting for tools) —
4045
+ // or if a turn is alive at all (activeTurnTokens): the state machine
4046
+ // goes idle at endTurn's reset while driveStream teardown is still
4047
+ // pending, and a successor turn dequeued in that gap compiles while the
4048
+ // predecessor's deferred flush can still write (2026-07-31 Mythos).
4049
+ // Turn-alive spans dequeue → settled teardown; wakes wait it out.
4050
+ //
4051
+ // EXCEPT a context-budget restart: it CONTINUES the turn whose token is
4052
+ // held — queueing it behind its own turn's teardown is a deadlock when
4053
+ // that teardown lags (the cancelled stream's `aborted` event is
4054
+ // asynchronous and, historically, not even guaranteed — see the
4055
+ // overBudget branch). The restart's startAgentStream overwrites the
4056
+ // token; the predecessor frame's late finally no-ops via token-match.
4057
+ // This mirrors how the restart has always overwritten activeStreams
4058
+ // rather than waiting for the old stream's teardown.
4059
+ const isBudgetRestart = requests.some((r) => r.reason === 'context_budget_restart');
4060
+ const turnAlive = !isBudgetRestart && this.activeTurnTokens.has(agentName);
4061
+ if (turnAlive || agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
3581
4062
  // Re-queue requests, but warn if they've been pending too long
3582
4063
  const oldest = Math.min(...requests.map(r => r.timestamp));
3583
4064
  if (now - oldest > STALE_REQUEST_MS &&
@@ -3587,14 +4068,21 @@ export class AgentFramework {
3587
4068
  // (up to 100/sec after the scheduler backoff), adding avoidable work
3588
4069
  // precisely while the agent was already under pressure.
3589
4070
  this.staleWarnAt.set(agentName, now);
4071
+ // Surface turn-alive separately from the state machine: status can
4072
+ // read 'idle' while a turn's teardown is pending, and a LEAKED turn
4073
+ // token would look exactly like this — permanently requeued wakes.
4074
+ // This line is the wedge's tell (idle+turn-alive, forever).
4075
+ const shownStatus = turnAlive && agent.state.status === 'idle'
4076
+ ? 'idle+turn-alive'
4077
+ : agent.state.status;
3590
4078
  this.emitTrace({
3591
4079
  type: 'inference:request_stale',
3592
4080
  agentName,
3593
- agentStatus: agent.state.status,
4081
+ agentStatus: shownStatus,
3594
4082
  requestCount: requests.length,
3595
4083
  oldestRequestAge: now - oldest,
3596
4084
  });
3597
- console.error(`[inference-stale] agent=${agentName} busy (${agent.state.status}) — ` +
4085
+ console.error(`[inference-stale] agent=${agentName} busy (${shownStatus}) — ` +
3598
4086
  `${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`);
3599
4087
  }
3600
4088
  this.pendingRequests.push(...requests);
@@ -3640,6 +4128,68 @@ export class AgentFramework {
3640
4128
  });
3641
4129
  }
3642
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
+ }
3643
4193
  /**
3644
4194
  * Announce-on-change locus notice (turn-frozen routing). Appends a durable
3645
4195
  * `[routing]` window message when the turn's effective outbound locus
@@ -3897,6 +4447,75 @@ export class AgentFramework {
3897
4447
  }
3898
4448
  }
3899
4449
  async startAgentStream(agent, trigger, attempt = 0) {
4450
+ // Mark the turn alive before ANYTHING awaits (hooks, compile, stream
4451
+ // setup): from here until this turn's teardown, cross-turn writers defer
4452
+ // (addMessage guard) instead of appending — nothing may enter the window
4453
+ // between a turn's dequeue and its settle except the turn's own blocks.
4454
+ // A retry re-enters with a fresh token; teardown clears token-matched.
4455
+ //
4456
+ // The try/finally is the leak-proofing: a token whose owner never clears
4457
+ // it permanently requeues every wake for the agent (the 'idle+turn-alive'
4458
+ // wedge — worse than any single lost turn). Ownership is handed off
4459
+ // exactly once, to driveStream, the moment the stream handle is
4460
+ // registered; on every other exit — including a throw from the turn-start
4461
+ // flush, recordTurnCheckpoint, or locus resolution, none of which the
4462
+ // inner catch covers (ENOSPC-class store writes live there) — the finally
4463
+ // clears our own token. Token-matched, so when a retry frame has replaced
4464
+ // the token, or the terminal-failure branch already deleted it, this
4465
+ // no-ops instead of clobbering a successor's marker.
4466
+ const turnToken = this.nextTurnToken++;
4467
+ this.activeTurnTokens.set(agent.name, turnToken);
4468
+ let tokenHandedOff = false;
4469
+ try {
4470
+ tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken);
4471
+ }
4472
+ finally {
4473
+ if (!tokenHandedOff && this.activeTurnTokens.get(agent.name) === turnToken) {
4474
+ this.activeTurnTokens.delete(agent.name);
4475
+ }
4476
+ }
4477
+ }
4478
+ /**
4479
+ * Body of startAgentStream, split out so the caller's try/finally owns the
4480
+ * turn token unconditionally. Returns true iff the token was handed off to
4481
+ * driveStream (whose finally then owns clearing it).
4482
+ */
4483
+ async beginAgentTurn(agent, trigger, attempt, turnToken) {
4484
+ // Flush messages deferred during the PREVIOUS turn — before the
4485
+ // checkpoint, the locus announcement, and the compile — so a turn started
4486
+ // by a queued wake actually CONTAINS the message that woke it. (2026-07-31
4487
+ // Mythos: a DM's wake fired a turn whose compile ran before the deferred
4488
+ // DM flushed; the model saw only the routing-shift notice, reasonably
4489
+ // skipped, and the DM then landed before the skip in the window — false
4490
+ // "saw it and ignored it" history plus KV divergence. Flushed here, the
4491
+ // payload rides its own wake, and the notice/payload can never split.)
4492
+ // Primary agent only — deferred messages are framework-level, meant for
4493
+ // the main conversation (same target rule as the tool-boundary flush).
4494
+ // Fresh turns only: a context-budget restart continues the same logical
4495
+ // turn, and flushing there would insert messages between its rounds.
4496
+ // Before recordTurnCheckpoint: these are the turn's inputs, not its
4497
+ // products — undoing the turn must not destroy them.
4498
+ if (attempt === 0 &&
4499
+ trigger?.reason !== 'context_budget_restart' &&
4500
+ this.deferredMessages.length > 0) {
4501
+ const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
4502
+ if (target === agent) {
4503
+ const deferred = this.deferredMessages.splice(0);
4504
+ for (const msg of deferred) {
4505
+ // Per-message try/catch (mirrors announceLocusIfChanged): one
4506
+ // poison message — or a transient store-write failure — must not
4507
+ // abort the turn or drop the messages behind it in the queue.
4508
+ try {
4509
+ const id = agent.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
4510
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'deferred-flush:turn-start' });
4511
+ }
4512
+ catch (err) {
4513
+ console.error(`[deferred-flush] turn-start flush failed to store a message for ${agent.name} ` +
4514
+ `(participant=${msg.participant}):`, err);
4515
+ }
4516
+ }
4517
+ }
4518
+ }
3900
4519
  // Record turn checkpoint before inference (only on first attempt, not retries)
3901
4520
  if (attempt === 0) {
3902
4521
  this.recordTurnCheckpoint(agent.name);
@@ -3938,6 +4557,12 @@ export class AgentFramework {
3938
4557
  if (trigger?.reason !== 'context_budget_restart') {
3939
4558
  if (attempt === 0)
3940
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);
3941
4566
  if (agent.proseRouting === 'explicit') {
3942
4567
  // Explicit prose routing: there is no locus. The model names every
3943
4568
  // destination in-band (`>>` prefixes); the only turn state is the
@@ -4023,6 +4648,9 @@ export class AgentFramework {
4023
4648
  const { stream, request: compiledRequest } = await agent.startStreamWithInjections(tools, injections);
4024
4649
  const handle = this.driveStream(agent, stream, requestSnapshot, trigger, attempt, compiledRequest);
4025
4650
  this.activeStreams.set(agent.name, handle);
4651
+ // Handoff: driveStream captured the token in its synchronous prefix;
4652
+ // its finally now owns clearing it. The caller's finally must not.
4653
+ return true;
4026
4654
  }
4027
4655
  catch (error) {
4028
4656
  // The early typing indicator has no driveStream `finally` on this path —
@@ -4040,9 +4668,19 @@ export class AgentFramework {
4040
4668
  const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
4041
4669
  if (action.retry) {
4042
4670
  await new Promise((resolve) => setTimeout(resolve, action.delayMs));
4671
+ // The retry re-enters startAgentStream, which replaces this frame's
4672
+ // turn token with its own; cleanup belongs to the innermost frame.
4043
4673
  await this.startAgentStream(agent, trigger, attempt + 1);
4044
4674
  }
4045
4675
  else {
4676
+ // Terminal failure with no driveStream: this frame owns teardown.
4677
+ // Token-matched (like the finally in driveStream) so a stale frame
4678
+ // can never clear a successor turn's marker. A leaked token would
4679
+ // permanently requeue every wake for this agent (same wedge shape as
4680
+ // the eventGate `inferring` leak) — every exit path must clear it.
4681
+ if (this.activeTurnTokens.get(agent.name) === turnToken) {
4682
+ this.activeTurnTokens.delete(agent.name);
4683
+ }
4046
4684
  this.settleAgent(agent.name, {
4047
4685
  stopReason: 'exhausted',
4048
4686
  speech: '',
@@ -4066,12 +4704,22 @@ export class AgentFramework {
4066
4704
  this.pushEvent(action.emit);
4067
4705
  }
4068
4706
  }
4707
+ // No handoff on either failure branch: the terminal branch deleted the
4708
+ // token above; the retry branch's recursive startAgentStream replaced
4709
+ // it with the inner frame's token. Returning false lets the caller's
4710
+ // token-matched finally no-op in both cases (and actually clear it if
4711
+ // something above threw before either branch ran).
4712
+ return false;
4069
4713
  }
4070
4714
  }
4071
4715
  async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest) {
4072
4716
  const startTime = Date.now();
4073
4717
  const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
4074
4718
  const myStreamId = agent.streamId;
4719
+ // This turn's alive-marker, set at startAgentStream entry. Safe to read
4720
+ // from the map here: the turn-alive busy check in processInferenceRequests
4721
+ // means no successor turn can have replaced it while we compiled.
4722
+ const myTurnToken = this.activeTurnTokens.get(agent.name);
4075
4723
  let hadToolCalls = false;
4076
4724
  // ---- Present-while-acting turn state ---------------------------------
4077
4725
  // Output locus for the WHOLE logical turn: frozen in startAgentStream
@@ -4093,7 +4741,8 @@ export class AgentFramework {
4093
4741
  const enqueueSpeech = (text, locus) => {
4094
4742
  turnSpeechChain = turnSpeechChain
4095
4743
  .then(async () => {
4096
- 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);
4097
4746
  })
4098
4747
  .catch((err) => console.error('mid-turn speech routing failed:', err));
4099
4748
  };
@@ -4131,8 +4780,25 @@ export class AgentFramework {
4131
4780
  // streams that delivery would refuse), emission is capability-gated in
4132
4781
  // the registry (`channels.streaming`), and delivery via deliverProse
4133
4782
  // remains the sole authoritative send.
4134
- 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();
4135
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
+ });
4136
4802
  const proseStream = this.channelRegistry
4137
4803
  ? new ProseStreamRouter({
4138
4804
  mode: agent.proseRouting === 'explicit' ? 'explicit' : 'locus',
@@ -4177,6 +4843,35 @@ export class AgentFramework {
4177
4843
  emitOutgoing(proseStream.feed(event.content));
4178
4844
  }
4179
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
+ }
4180
4875
  case 'block': {
4181
4876
  const { event: phase, index, block } = event.event;
4182
4877
  this.emitTrace({
@@ -4283,6 +4978,9 @@ export class AgentFramework {
4283
4978
  }
4284
4979
  else if (turnSilenced) {
4285
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);
4286
4984
  }
4287
4985
  else if (hasSameRoundPrivateThink) {
4288
4986
  console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`);
@@ -4324,17 +5022,12 @@ export class AgentFramework {
4324
5022
  agent.addAssistantResponse(pending);
4325
5023
  this.pendingAssistantBlocks.delete(agent.name);
4326
5024
  }
4327
- // 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).
4328
5028
  const readyState = agent.state;
4329
5029
  if (readyState.status === 'ready') {
4330
- const toolResultContent = readyState.toolResults.map(tc => ({
4331
- type: 'tool_result',
4332
- toolUseId: tc.id,
4333
- content: tc.result.isError
4334
- ? (tc.result.error ?? 'Unknown error')
4335
- : toolResultDataToHistoryString(tc.result.data),
4336
- isError: tc.result.isError,
4337
- }));
5030
+ const { blocks: toolResultContent } = await this.buildStoredToolResultContent(readyState.toolResults, this.resolveToolResultInlineCap(agent).cap);
4338
5031
  agent.getContextManager().addMessage('user', toolResultContent);
4339
5032
  }
4340
5033
  }
@@ -4358,39 +5051,10 @@ export class AgentFramework {
4358
5051
  else {
4359
5052
  agent.addAssistantResponse(terminalContent);
4360
5053
  }
4361
- // Run afterInference hooks (no-op if no MCPL servers)
4362
- if (this.hookOrchestrator) {
4363
- try {
4364
- const speechText = response.content
4365
- .filter((block) => block.type === 'text')
4366
- .map((b) => b.text)
4367
- .join('\n');
4368
- const afterParams = {
4369
- inferenceId: requestId,
4370
- conversationId: agent.name,
4371
- turnIndex: 0,
4372
- userMessage: null,
4373
- assistantMessage: speechText,
4374
- model: {
4375
- id: agent.model,
4376
- vendor: 'unknown',
4377
- contextWindow: 200000,
4378
- capabilities: ['tools'],
4379
- },
4380
- usage: {
4381
- inputTokens: response.usage?.inputTokens ?? 0,
4382
- outputTokens: response.usage?.outputTokens ?? 0,
4383
- cacheCreationTokens: response.details?.usage?.cacheCreationTokens,
4384
- cacheReadTokens: response.details?.usage?.cacheReadTokens,
4385
- },
4386
- };
4387
- await this.hookOrchestrator.afterInference(afterParams);
4388
- }
4389
- catch (error) {
4390
- // Fail-open: continue with speech dispatch
4391
- console.error('afterInference hook error:', error);
4392
- }
4393
- }
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.
4394
5058
  // Separate speech from thoughts — for MODULE dispatch only
4395
5059
  // (dispatchSpeech / TUI rendering below). This split does NOT
4396
5060
  // decide what reaches the channel: mid-turn prose is routed live
@@ -4472,6 +5136,17 @@ export class AgentFramework {
4472
5136
  let handledByRewind = false;
4473
5137
  const rh = agent.refusalHandling;
4474
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.
4475
5150
  // Shed exactly one more (newest, in-sequence) turn and keep the
4476
5151
  // single episode marker current. `budgetLeft` bounds the loop.
4477
5152
  const doRewind = (budgetLeft, onStep, onGiveUp) => {
@@ -4517,8 +5192,16 @@ export class AgentFramework {
4517
5192
  this.rewindEpisode.delete(agent.name);
4518
5193
  });
4519
5194
  }
4520
- 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) {
4521
5203
  void this.reactToRefusal(agent.name, category);
5204
+ }
4522
5205
  }
4523
5206
  else {
4524
5207
  // A turn that completed WITHOUT a refusal ends the rewind episode:
@@ -4579,7 +5262,8 @@ export class AgentFramework {
4579
5262
  const locus = resolveTurnLocus();
4580
5263
  console.error(`[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ?? '(none)'}`);
4581
5264
  try {
4582
- 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);
4583
5267
  }
4584
5268
  catch (err) {
4585
5269
  console.error('speech routing failed:', err);
@@ -4630,6 +5314,9 @@ export class AgentFramework {
4630
5314
  else if (silenced || segments.length === 0) {
4631
5315
  console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> trailing prose NOT routed ` +
4632
5316
  `(${silenced ? 'silencing tool / explicit send' : 'no trailing prose'})`);
5317
+ if (silenced && segments.length > 0) {
5318
+ this.recordProseSuppression(agent.name, segments.length);
5319
+ }
4633
5320
  }
4634
5321
  else {
4635
5322
  // Reuse the locus pinned at the turn's first live-routed
@@ -4644,7 +5331,8 @@ export class AgentFramework {
4644
5331
  // Deliver sequentially (await each) so the segments land in order.
4645
5332
  for (const seg of segments) {
4646
5333
  try {
4647
- 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);
4648
5336
  }
4649
5337
  catch (err) {
4650
5338
  console.error('speech routing failed:', err);
@@ -4655,6 +5343,14 @@ export class AgentFramework {
4655
5343
  // NOTE: agent.reset() + onInferenceEnded() already ran above,
4656
5344
  // BEFORE dispatchSpeech. Locus routing is speech dispatch and
4657
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
+ }
4658
5354
  // Explicit-prose `!` continuation: a prose segment this turn asked
4659
5355
  // to keep going (`>>#x !` / `>>private !`) — start another turn
4660
5356
  // now instead of pausing until the next external event. This gives
@@ -4730,9 +5426,26 @@ export class AgentFramework {
4730
5426
  // reset, no settle, no spurious inference:exhausted (which would
4731
5427
  // reject an ephemeral's promise mid-run and bump the failure
4732
5428
  // streak). Gate release + stream teardown happen in `finally`.
4733
- if (this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`)) {
4734
- this.eventGate?.onInferenceEnded(agent.name);
4735
- 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
+ }
4736
5449
  }
4737
5450
  const reason = event.reason ?? 'unknown';
4738
5451
  // Only reset if this is still the active stream (a budget restart
@@ -4772,6 +5485,11 @@ export class AgentFramework {
4772
5485
  }
4773
5486
  case 'usage': {
4774
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;
4775
5493
  // Closed-loop estimator calibration (2026-07-12). Sample the REAL
4776
5494
  // prefix size of THIS API call (fresh + cache write + cache read)
4777
5495
  // and hand it to the context strategy, which accepts exactly one
@@ -4839,10 +5557,22 @@ export class AgentFramework {
4839
5557
  durationMs,
4840
5558
  });
4841
5559
  this.abortAgentScript(agent.name, 'stream threw');
5560
+ lifecyclePhase = 'failed'; // §10.5 — terminal emitted in finally
4842
5561
  agent.reset();
4843
5562
  this.eventGate?.onInferenceEnded(agent.name);
4844
5563
  }
4845
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
+ });
4846
5576
  // Clear the gate's inference flag on EVERY exit path (paired with the
4847
5577
  // onInferenceStarted in startAgentStream). The branch-level calls above
4848
5578
  // are kept but are best-effort; if any exit path bypassed them the agent
@@ -4872,6 +5602,16 @@ export class AgentFramework {
4872
5602
  if (this.closingConversationAgents.has(agent.name)) {
4873
5603
  this.disposeConversationAgent(agent.name);
4874
5604
  }
5605
+ // The turn is torn down — release the turn-alive marker BEFORE the
5606
+ // deferred flush below, so the flush appends at the settled tail.
5607
+ // Token-matched: if a successor turn already owns the agent (endTurn
5608
+ // reset the state machine before this finally ran — the 2026-07-31
5609
+ // interleave), leave its marker alone; the guarded addMessage below
5610
+ // then re-defers, and the successor's own turn-start flush / boundary
5611
+ // injection / teardown delivers the messages at a correct position.
5612
+ if (myTurnToken !== undefined && this.activeTurnTokens.get(agent.name) === myTurnToken) {
5613
+ this.activeTurnTokens.delete(agent.name);
5614
+ }
4875
5615
  // Flush any deferred messages (e.g. if stream failed while tools were pending)
4876
5616
  if (this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
4877
5617
  const deferred = this.deferredMessages.splice(0);
@@ -4885,8 +5625,22 @@ export class AgentFramework {
4885
5625
  * Execute a tool call and return the result.
4886
5626
  * Routes to the appropriate handler (module registry or MCPL).
4887
5627
  * Used by SubagentModule to dispatch tool calls for ephemeral agents.
5628
+ *
5629
+ * This public entry always carries AGENT origin. Ephemeral/promise-based
5630
+ * callers are models, and provenance must identify which internal path a
5631
+ * call entered through — a public method shared with model callers can
5632
+ * never confer module trust, whatever the input claims. Module origin
5633
+ * exists only through the private closure handed to ModuleRegistry
5634
+ * (ctx.callTool → executeToolCallFrom with {kind:'module'}). Callers that
5635
+ * don't identify themselves fail safe to agent semantics too.
4888
5636
  */
4889
5637
  async executeToolCall(call) {
5638
+ return this.executeToolCallFrom(call, {
5639
+ kind: 'agent',
5640
+ agentName: call.callerAgentName ?? '__ephemeral__',
5641
+ });
5642
+ }
5643
+ async executeToolCallFrom(call, origin) {
4890
5644
  // Client-side programmatic tool calling for promise-based callers
4891
5645
  // (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral
4892
5646
  // gets its own interpreter state.
@@ -4905,6 +5659,25 @@ export class AgentFramework {
4905
5659
  }
4906
5660
  }
4907
5661
  }
5662
+ // Synthesized channel tools. This route was missing: when
5663
+ // subscription-GC moved off the retired MCPL unsubscribe tool onto the
5664
+ // generic channel_close (discord-mcpl b095a9f), module-originated
5665
+ // closes started failing downstream as "Invalid tool name format" —
5666
+ // the janitor had been silently broken since. `origin` is trusted
5667
+ // dispatch context established at the call boundary above, not
5668
+ // inferred from which method was used: only the ModuleRegistry closure
5669
+ // carries module provenance (see handleToolClose). The agent-only
5670
+ // synthesized verbs (think, skip_reply) are deliberately not routed
5671
+ // here.
5672
+ if (call.name.startsWith('channel_') && this.channelRegistry) {
5673
+ return this.channelRegistry.handleChannelToolCall(call.name, call.input, origin);
5674
+ }
5675
+ // The utils meta-tool — programmatic callers (code_execution scripts,
5676
+ // ephemerals, ModuleContext.callTool) reach utilities the same way the
5677
+ // model does.
5678
+ if (call.name === 'utils') {
5679
+ return this.handleUtilsToolCall(call.callerAgentName ?? '__ephemeral__', call);
5680
+ }
4908
5681
  // Module tools
4909
5682
  return this.moduleRegistry.handleToolCall(call);
4910
5683
  }
@@ -5000,6 +5773,43 @@ export class AgentFramework {
5000
5773
  */
5001
5774
  async runCodeExecution(agentName, call) {
5002
5775
  const input = (call.input ?? {});
5776
+ // Management surface: the agent's own daemon fleet is inspectable and
5777
+ // killable through the same tool that spawns it.
5778
+ if (input.action === 'list') {
5779
+ const scripts = [...this.backgroundScripts.values()]
5780
+ .filter((s) => s.agentName === agentName)
5781
+ .map((s) => ({
5782
+ script_id: s.id,
5783
+ status: s.status,
5784
+ started_at: new Date(s.startedAt).toISOString(),
5785
+ wakes: s.wakes,
5786
+ last_wake_at: s.lastWakeAt ? new Date(s.lastWakeAt).toISOString() : null,
5787
+ log: s.logPath,
5788
+ }));
5789
+ return { success: true, data: { background_scripts: scripts } };
5790
+ }
5791
+ if (input.action === 'cancel') {
5792
+ if (typeof input.script_id !== 'string') {
5793
+ return { success: false, error: 'cancel requires `script_id`', isError: true };
5794
+ }
5795
+ const record = this.backgroundScripts.get(input.script_id);
5796
+ if (!record || record.agentName !== agentName) {
5797
+ return { success: false, error: `no background script '${input.script_id}'`, isError: true };
5798
+ }
5799
+ if (record.status === 'running') {
5800
+ record.cancelled = true;
5801
+ record.status = 'cancelled';
5802
+ record.runner.abort('cancelled by agent');
5803
+ record.runner.dispose();
5804
+ }
5805
+ return {
5806
+ success: true,
5807
+ data: { script_id: record.id, status: record.status, log: record.logPath },
5808
+ };
5809
+ }
5810
+ if (input.action !== undefined && input.action !== 'run') {
5811
+ return { success: false, error: `unknown action ${JSON.stringify(input.action)}`, isError: true };
5812
+ }
5003
5813
  if (typeof input.code !== 'string' || input.code.trim() === '') {
5004
5814
  return {
5005
5815
  success: false,
@@ -5012,6 +5822,9 @@ export class AgentFramework {
5012
5822
  ? this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name))
5013
5823
  : this.getAllTools(); // ephemeral agents: full surface, matching executeToolCall
5014
5824
  const injected = buildInjectedTools(surface.map((t) => t.name).filter((name) => name !== CODE_EXECUTION_TOOL_NAME));
5825
+ if (input.background === true) {
5826
+ return this.startBackgroundScript(agentName, input.code, injected);
5827
+ }
5015
5828
  const runner = this.getOrCreateScriptRunner(agentName);
5016
5829
  this.scriptDeferredEndTurn.delete(agentName);
5017
5830
  const exec = await runner.exec(input.code, injected);
@@ -5023,6 +5836,307 @@ export class AgentFramework {
5023
5836
  ...(endTurn ? { endTurn: true } : {}),
5024
5837
  };
5025
5838
  }
5839
+ // ---------------------------------------------------------------------------
5840
+ // Background (daemon) scripts — model-authored watchers that outlive the turn
5841
+ // ---------------------------------------------------------------------------
5842
+ /**
5843
+ * Start a background script: dedicated interpreter, output journaled to a
5844
+ * workspace file, wake_agent() available in-script. The tool result
5845
+ * returns immediately; the script runs until it ends, hits the lifetime
5846
+ * ceiling, or is cancelled. Scripts DIE WITH THE HOST PROCESS — that
5847
+ * limitation is stated in the tool description so agents can calibrate
5848
+ * how much to rely on an armed wake.
5849
+ */
5850
+ startBackgroundScript(agentName, code, injected) {
5851
+ // v1: primary-agent-only. A conversation fork's or ephemeral's daemon
5852
+ // would outlive its owner and its wake would land in the primary
5853
+ // conversation anyway — refuse loudly instead of surprising anyone.
5854
+ if (this.primaryAgentName && agentName !== this.primaryAgentName) {
5855
+ return {
5856
+ success: false,
5857
+ error: 'background scripts are available to the primary agent only',
5858
+ isError: true,
5859
+ };
5860
+ }
5861
+ const cfg = this.codeExecutionConfig;
5862
+ const maxScripts = cfg?.maxBackgroundScripts ?? 3;
5863
+ const runningCount = [...this.backgroundScripts.values()]
5864
+ .filter((s) => s.agentName === agentName && s.status === 'running').length;
5865
+ if (runningCount >= maxScripts) {
5866
+ return {
5867
+ success: false,
5868
+ error: `background script limit reached (${maxScripts} running). ` +
5869
+ 'Use code_execution {"action": "list"} to inspect and {"action": "cancel", "script_id": ...} to free a slot.',
5870
+ isError: true,
5871
+ };
5872
+ }
5873
+ const scriptId = `bg-${++this.backgroundScriptCounter}`;
5874
+ const lifetimeMs = cfg?.backgroundMaxLifetimeMs ?? 86_400_000;
5875
+ // Journal: a file under the agent's first read-write workspace mount so
5876
+ // their existing read/grep/shell tools work on it. Python appends
5877
+ // directly to the materialized path; watch-mode mounts sync it back to
5878
+ // the chronicle tree on their usual cadence.
5879
+ let logPath = null;
5880
+ let logAbsPath = null;
5881
+ const workspace = this.getWorkspaceModule();
5882
+ if (workspace) {
5883
+ const mountName = this.firstWritableMountName(workspace);
5884
+ if (mountName) {
5885
+ logPath = `${mountName}/background-scripts/${scriptId}.log`;
5886
+ logAbsPath = workspace.resolveAbsolutePath(logPath);
5887
+ }
5888
+ }
5889
+ const runner = new PyRunner({
5890
+ pythonPath: cfg?.pythonPath,
5891
+ toolCallTimeoutMs: cfg?.toolCallTimeoutMs,
5892
+ scriptTimeoutMs: cfg?.scriptTimeoutMs,
5893
+ idleReclaimMs: 0, // dedicated runner; lifetime is the exec deadline
5894
+ label: `${agentName}:${scriptId}`,
5895
+ onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args),
5896
+ });
5897
+ const record = {
5898
+ id: scriptId,
5899
+ agentName,
5900
+ runner,
5901
+ code,
5902
+ startedAt: Date.now(),
5903
+ wakes: 0,
5904
+ lastWakeAt: null,
5905
+ logPath,
5906
+ status: 'running',
5907
+ cancelled: false,
5908
+ };
5909
+ this.backgroundScripts.set(scriptId, record);
5910
+ this.emitTrace({
5911
+ type: 'tool:started', module: 'code_execution', tool: `background:${scriptId}`,
5912
+ callId: scriptId, input: { lines: code.split('\n').length },
5913
+ });
5914
+ void runner
5915
+ .exec(code, injected, {
5916
+ logPath: logAbsPath,
5917
+ lifetimeMs,
5918
+ onWake: (line, payload) => this.handleScriptWake(record, line, payload),
5919
+ })
5920
+ .then((exec) => this.settleBackgroundScript(record, exec))
5921
+ .catch((err) => {
5922
+ // exec never rejects by contract; this is the belt-and-suspenders.
5923
+ console.error(`[pytc:${agentName}:${scriptId}] background exec rejected: ${String(err)}`);
5924
+ this.settleBackgroundScript(record, {
5925
+ stdout: '', stderr: String(err), returnCode: 1, aborted: true,
5926
+ });
5927
+ });
5928
+ return {
5929
+ success: true,
5930
+ data: {
5931
+ status: 'background script started',
5932
+ script_id: scriptId,
5933
+ log: logPath ?? 'no writable workspace mount — output is not retrievable; only wake_agent() reaches you',
5934
+ lifetime_hours: Math.round(lifetimeMs / 3_600_000 * 10) / 10,
5935
+ note: 'The script dies if the host process restarts. Manage with code_execution {"action": "list"|"cancel"}.',
5936
+ },
5937
+ };
5938
+ }
5939
+ /** First read-write mount name, or null. */
5940
+ firstWritableMountName(workspace) {
5941
+ const mounts = workspace.getMounts?.();
5942
+ if (mounts) {
5943
+ const rw = mounts.find((m) => m.mode !== 'read-only');
5944
+ return rw?.name ?? null;
5945
+ }
5946
+ return null;
5947
+ }
5948
+ /**
5949
+ * wake_agent() from a background script: enforce the per-script wake cap
5950
+ * and rate floor (delay, not drop — the script awaits the ack), then
5951
+ * inject the provenance envelope + payload and request inference.
5952
+ * Resolves null on delivery; an error string refuses the wake (raises
5953
+ * RuntimeError inside the script).
5954
+ */
5955
+ async handleScriptWake(record, line, payload) {
5956
+ if (record.cancelled)
5957
+ return 'script was cancelled';
5958
+ const cfg = this.codeExecutionConfig;
5959
+ const maxWakes = cfg?.maxWakesPerScript ?? 100;
5960
+ if (record.wakes >= maxWakes) {
5961
+ return `wake limit reached (${maxWakes} per script)`;
5962
+ }
5963
+ const floorMs = cfg?.wakeMinIntervalMs ?? 60_000;
5964
+ if (record.lastWakeAt !== null) {
5965
+ const wait = record.lastWakeAt + floorMs - Date.now();
5966
+ if (wait > 0) {
5967
+ await new Promise((r) => {
5968
+ const t = setTimeout(r, wait);
5969
+ t.unref?.();
5970
+ });
5971
+ if (record.cancelled)
5972
+ return 'script was cancelled';
5973
+ }
5974
+ }
5975
+ record.wakes += 1;
5976
+ record.lastWakeAt = Date.now();
5977
+ const elapsedMin = Math.round((Date.now() - record.startedAt) / 60_000);
5978
+ const payloadJson = payload === undefined || payload === null
5979
+ ? 'null'
5980
+ : JSON.stringify(payload, null, 1) ?? String(payload);
5981
+ const agent = this.agents.get(record.agentName);
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}`);
5988
+ const envelope = `[background script ${record.id}] Woke you: wake_agent() called at line ${line} of your script, ` +
5989
+ `${elapsedMin}m after you started it (wake ${record.wakes} of ${maxWakes}).\n` +
5990
+ `Arguments:\n${payloadText}\n` +
5991
+ (record.logPath ? `Script output so far: workspace file ${record.logPath}\n` : '') +
5992
+ `Script status: still running`;
5993
+ this.injectScriptWake(record, envelope);
5994
+ return null;
5995
+ }
5996
+ /** Background script ended (clean, crash, deadline, cancel/dispose). */
5997
+ settleBackgroundScript(record, exec) {
5998
+ record.runner.dispose();
5999
+ if (record.status === 'running') {
6000
+ record.status = exec.returnCode === 0 ? 'finished' : 'died';
6001
+ }
6002
+ this.emitTrace({
6003
+ type: 'tool:completed', module: 'code_execution', tool: `background:${record.id}`,
6004
+ callId: record.id, durationMs: Date.now() - record.startedAt,
6005
+ });
6006
+ // Crash honesty: a resident sleeping on a promise must never have that
6007
+ // promise die silently. Deliberate cancels/disposes stay silent (the
6008
+ // agent or host chose them); clean exits stay silent (the null path).
6009
+ if (record.status === 'died' && !record.cancelled) {
6010
+ const tail = (exec.tail ?? exec.stderr ?? '').slice(-2000);
6011
+ const elapsedMin = Math.round((Date.now() - record.startedAt) / 60_000);
6012
+ const envelope = `[background script ${record.id}] Your background script DIED ${elapsedMin}m after start ` +
6013
+ `(it did not call wake_agent and is no longer watching).\n` +
6014
+ `Last output:\n${tail || '(none)'}\n` +
6015
+ (record.logPath ? `Full journal: workspace file ${record.logPath}` : '');
6016
+ this.injectScriptWake(record, envelope);
6017
+ }
6018
+ // Keep a short memory of settled scripts for {"action":"list"}, then drop.
6019
+ const settled = [...this.backgroundScripts.values()]
6020
+ .filter((s) => s.agentName === record.agentName && s.status !== 'running');
6021
+ for (const old of settled.slice(0, Math.max(0, settled.length - 5))) {
6022
+ this.backgroundScripts.delete(old.id);
6023
+ }
6024
+ }
6025
+ /**
6026
+ * Deliver a background-script event: provenance envelope into the agent's
6027
+ * context + an inference request. Deliberately does NOT consult the event
6028
+ * gate — the agent armed this wake themselves (wake-through-sleep
6029
+ * authority); it enters tagged so gate policies could be taught about it
6030
+ * later if that ever needs revisiting.
6031
+ */
6032
+ injectScriptWake(record, envelope) {
6033
+ try {
6034
+ const messageId = this.addMessage('user', [{ type: 'text', text: envelope }], {
6035
+ source: 'background-script',
6036
+ scriptId: record.id,
6037
+ tags: ['script:wake'],
6038
+ });
6039
+ this.pendingRequests.push({
6040
+ agentName: record.agentName,
6041
+ reason: 'script:wake',
6042
+ source: 'code_execution',
6043
+ timestamp: Date.now(),
6044
+ });
6045
+ this.emitTrace({
6046
+ type: 'message:added',
6047
+ messageId,
6048
+ source: `background-script:${record.id}`,
6049
+ });
6050
+ }
6051
+ catch (err) {
6052
+ console.error(`[pytc:${record.agentName}:${record.id}] wake injection failed: ${err instanceof Error ? err.message : String(err)}`);
6053
+ }
6054
+ }
6055
+ /**
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.
6064
+ */
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) {
6091
+ if (!cap || content.length <= cap)
6092
+ return { text: content, filePath: null };
6093
+ const workspace = this.getWorkspaceModule();
6094
+ const mountName = workspace ? this.firstWritableMountName(workspace) : null;
6095
+ if (workspace && mountName) {
6096
+ const safeLabel = label.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
6097
+ const path = `${mountName}/tool-results/${safeLabel}.txt`;
6098
+ let failure;
6099
+ try {
6100
+ const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain');
6101
+ if (result.success) {
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
+ };
6109
+ }
6110
+ failure = result.error ?? 'write refused';
6111
+ }
6112
+ catch (err) {
6113
+ failure = err instanceof Error ? err.message : String(err);
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
+ };
6133
+ }
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
+ };
6139
+ }
5026
6140
  getOrCreateScriptRunner(agentName) {
5027
6141
  let runner = this.codeExecutionRunners.get(agentName);
5028
6142
  if (!runner) {
@@ -5064,9 +6178,10 @@ export class AgentFramework {
5064
6178
  }
5065
6179
  if (result.data === undefined)
5066
6180
  return '';
5067
- const agent = this.agents.get(agentName);
5068
- const maxChars = agent ? this.getMaxToolResultChars(agent) : undefined;
5069
- return toolResultDataToHistoryString(result.data, maxChars);
6181
+ // Scripts get (nearly) full data — the script environment IS the spill:
6182
+ // filtering big results in code is the whole point. 5MB protocol safety
6183
+ // cap only; no context-cap truncation, no file spill.
6184
+ return toolResultDataToHistoryString(result.data, 5_000_000);
5070
6185
  }
5071
6186
  /**
5072
6187
  * Dispatch a script-inner tool call through the NORMAL dispatch path and
@@ -5114,18 +6229,30 @@ export class AgentFramework {
5114
6229
  runner.abort(reason);
5115
6230
  }
5116
6231
  }
5117
- toMembraneToolResult(callId, afResult, maxChars) {
6232
+ toMembraneToolResult(callId, afResult, maxChars,
6233
+ /** Pre-spilled outcome from the history path — used for the non-image
6234
+ * path so the live wire copy byte-matches what the window stored. */
6235
+ precomputed) {
5118
6236
  if (afResult.isError) {
5119
- 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
+ };
5120
6244
  }
5121
6245
  // MCPL tool results arrive as `data: McpToolResultContent[]` — preserve image
5122
6246
  // blocks natively rather than JSON-stringifying them away. Anything else
5123
6247
  // (objects, scalars) falls through to JSON. The error path was handled
5124
6248
  // above, so isError is always false on these return paths.
5125
- const blocks = this.tryNativeToolResultContent(afResult.data, maxChars);
6249
+ const blocks = this.tryNativeToolResultContent(afResult.data, maxChars, precomputed?.filePath ?? null);
5126
6250
  if (blocks) {
5127
6251
  return { toolUseId: callId, content: blocks, isError: false };
5128
6252
  }
6253
+ if (precomputed !== undefined) {
6254
+ return { toolUseId: callId, content: precomputed.text, isError: false };
6255
+ }
5129
6256
  // JSON.stringify returns the VALUE undefined (not a string) for undefined
5130
6257
  // input — a module tool returning `{ success: true }` with no data would
5131
6258
  // otherwise make `content.length` below throw a TypeError mid-turn.
@@ -5143,7 +6270,10 @@ export class AgentFramework {
5143
6270
  * `maxChars`, when provided, caps each accompanying text block so an image
5144
6271
  * inlined alongside an enormous text payload can't blow the context.
5145
6272
  */
5146
- 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) {
5147
6277
  if (!Array.isArray(data))
5148
6278
  return null;
5149
6279
  let hasImage = false;
@@ -5156,7 +6286,9 @@ export class AgentFramework {
5156
6286
  let text = b.text;
5157
6287
  if (maxChars && text.length > maxChars) {
5158
6288
  text = safeSlice(text, 0, maxChars)
5159
- + '\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
+ + ']';
5160
6292
  }
5161
6293
  blocks.push({ type: 'text', text });
5162
6294
  }
@@ -5173,13 +6305,39 @@ export class AgentFramework {
5173
6305
  }
5174
6306
  return hasImage ? blocks : null;
5175
6307
  }
5176
- getMaxToolResultChars(agent) {
6308
+ /** Strategy-derived per-message bound (maxMessageTokens * 4 chars). */
6309
+ strategyDerivedToolResultChars(agent) {
5177
6310
  const strategy = agent.getContextManager().getStrategy();
5178
6311
  const maxTokens = strategy.maxMessageTokens;
5179
6312
  if (maxTokens && maxTokens > 0)
5180
6313
  return maxTokens * 4;
5181
6314
  return undefined;
5182
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
+ }
5183
6341
  approximateDecodedBase64Bytes(base64) {
5184
6342
  const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
5185
6343
  return Math.max(0, Math.floor(base64.length * 3 / 4) - padding);
@@ -5292,6 +6450,10 @@ export class AgentFramework {
5292
6450
  return;
5293
6451
  }
5294
6452
  // Route the agent's typed, allowlisted hot-settings surface.
6453
+ if (enrichedCall.name === 'utils') {
6454
+ this.dispatchUtilsToolCall(agentName, enrichedCall);
6455
+ return;
6456
+ }
5295
6457
  if (enrichedCall.name === 'agent_settings') {
5296
6458
  this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
5297
6459
  return;
@@ -5329,6 +6491,26 @@ export class AgentFramework {
5329
6491
  // synthesized channel tools, but they aren't `channel_`-prefixed so they
5330
6492
  // need an explicit route here.
5331
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
+ }
5332
6514
  this.dispatchChannelToolCall(agentName, enrichedCall);
5333
6515
  return;
5334
6516
  }
@@ -5427,15 +6609,25 @@ export class AgentFramework {
5427
6609
  }
5428
6610
  // Defer non-tool_result messages while a tool cycle is mid-flight
5429
6611
  // (pendingAssistantBlocks: preserves tool_use → tool_result adjacency)
5430
- // OR while the target agent has a live stream at all. A message stored
5431
- // mid-stream lands BEFORE the turn's assistant blocks in the window even
5432
- // though the live conversation never saw it — so the next compile
5433
- // diverges from the live prefix (prompt-cache bust) and the message
5434
- // misses mid-turn injection. Deferred messages flush at the agent's next
5435
- // tool boundary (where they are ALSO injected into the live stream —
5436
- // hear-while-acting) or in driveStream's finally when the turn ends.
6612
+ // OR while the target agent has a turn in progress AT ALL. A message
6613
+ // stored mid-turn lands BEFORE the turn's assistant blocks in the window
6614
+ // even though the live conversation never saw it — so the next compile
6615
+ // diverges from the live prefix (prompt-cache bust), the message misses
6616
+ // mid-turn injection, and the window falsely testifies the agent saw it
6617
+ // and moved on. Turn-alive (activeTurnTokens) is the guard, not stream-
6618
+ // alive: a turn exists from dequeue through hooks + compile before its
6619
+ // stream registers in activeStreams, and the 2026-07-31 Mythos incident
6620
+ // was precisely a deferred flush landing inside that compile window.
6621
+ // (activeStreams is kept as a belt-and-suspenders backstop.) Deferred
6622
+ // messages flush at the next turn's START (before its compile — riding
6623
+ // their own wake), at the agent's next tool boundary (where they are
6624
+ // ALSO injected into the live stream — hear-while-acting), or in
6625
+ // driveStream's finally when the turn ends.
5437
6626
  const hasToolResult = content.some(b => b.type === 'tool_result');
5438
- if (!hasToolResult && (this.pendingAssistantBlocks.size > 0 || this.activeStreams.has(agent.name))) {
6627
+ if (!hasToolResult &&
6628
+ (this.pendingAssistantBlocks.size > 0 ||
6629
+ this.activeTurnTokens.has(agent.name) ||
6630
+ this.activeStreams.has(agent.name))) {
5439
6631
  this.deferredMessages.push({ participant, content, metadata });
5440
6632
  return ''; // Deferred — flushed at the next boundary
5441
6633
  }
@@ -5841,7 +7033,6 @@ export class AgentFramework {
5841
7033
  async initializeMcpl(serverConfigs, inferenceRouting) {
5842
7034
  this.mcplServerRegistry = new McplServerRegistry();
5843
7035
  this.featureSetManager = new FeatureSetManager();
5844
- this.scopeManager = new ScopeManager();
5845
7036
  this.hookOrchestrator = new HookOrchestrator(this.mcplServerRegistry, this.featureSetManager);
5846
7037
  // Build prefix map and store configs for tool routing
5847
7038
  for (const config of serverConfigs) {
@@ -5927,12 +7118,32 @@ export class AgentFramework {
5927
7118
  });
5928
7119
  // Host capabilities advertised during the MCP handshake — stored so
5929
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).
5930
7125
  this.mcplHostCapabilities = {
5931
- version: '0.4',
7126
+ version: '0.5',
5932
7127
  pushEvents: true,
5933
7128
  contextHooks: {
5934
7129
  beforeInference: true,
5935
- 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,
5936
7147
  },
5937
7148
  featureSets: true,
5938
7149
  };
@@ -6265,27 +7476,210 @@ export class AgentFramework {
6265
7476
  // Wire listeners before either startup staging or the runtime global gate
6266
7477
  // releases control traffic needed for registration and marker service.
6267
7478
  this.wireMcplEvents(connection);
6268
- // Initialize feature sets if server advertises MCPL capabilities
6269
- this.registerMcplServerFeatures(config, connection);
6270
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);
6271
7484
  this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
6272
7485
  return;
6273
7486
  }
6274
7487
  const awarenessBarrier = this.installMcplDataPlaneGate();
6275
- 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.
6276
7493
  try {
6277
7494
  await awarenessBarrier.promise;
6278
- this.completeMcplDataPlaneGate(awarenessBarrier);
6279
7495
  }
6280
7496
  catch (error) {
6281
7497
  // Startup cannot fail open on a broken awareness ledger. Disable the
6282
7498
  // reconnecting stub/connection before propagating the distinct error to
6283
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.)
6284
7506
  await connection.close().catch(() => { });
6285
7507
  throw error;
6286
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);
6287
7515
  this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
6288
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
+ }
6289
7683
  /**
6290
7684
  * (Re-)establish a server's host-side MCPL registration: feature-set state
6291
7685
  * (plus the featureSets/update sent to the server), scope patterns, and
@@ -6301,20 +7695,62 @@ export class AgentFramework {
6301
7695
  * already has a tree), so checkpoint trees preserved across a transient
6302
7696
  * disconnect are resumed, not reset.
6303
7697
  */
6304
- registerMcplServerFeatures(config, connection) {
7698
+ async registerMcplServerFeatures(config, connection) {
6305
7699
  if (!connection.capabilities)
6306
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 });
6307
7706
  const updateParams = this.featureSetManager.initializeServer(config.id, connection.capabilities, {
6308
7707
  enabledFeatureSets: config.enabledFeatureSets,
6309
7708
  disabledFeatureSets: config.disabledFeatureSets,
6310
- });
6311
- // Inform server which feature sets are enabled/disabled
6312
- if (updateParams.enabled?.length || updateParams.disabled?.length) {
6313
- 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;
6314
7751
  }
6315
- // Configure scope whitelist/blacklist patterns
6316
7752
  if (config.scopes) {
6317
- this.scopeManager.configureAll(config.scopes);
7753
+ console.error(`[mcpl] ${config.id}: config \`scopes\` is ignored — §7 Scoped Access removed in 0.5.0`);
6318
7754
  }
6319
7755
  // Register stateful feature sets with checkpoint manager (Step 8)
6320
7756
  if (this.checkpointManager) {
@@ -6411,11 +7847,21 @@ export class AgentFramework {
6411
7847
  for (const [id, config] of this.mcplServerConfigs) {
6412
7848
  const prefix = config.toolPrefix ?? `mcpl--${id}`;
6413
7849
  const connection = this.mcplServerRegistry?.getServer(id) ?? null;
7850
+ const connected = connection?.isConnected ?? false;
6414
7851
  result.push({
6415
7852
  id,
6416
- connected: connection?.isConnected ?? false,
7853
+ connected,
7854
+ retrying: !connected && (connection?.willReconnect ?? false),
6417
7855
  toolPrefix: prefix,
6418
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 },
6419
7865
  command: config.command,
6420
7866
  url: config.url,
6421
7867
  });
@@ -6432,23 +7878,14 @@ export class AgentFramework {
6432
7878
  connection.on('stderr', (params) => {
6433
7879
  this.emitTrace({ type: 'mcpl:server-stderr', serverId: connection.id, line: params.line });
6434
7880
  });
6435
- // Handle dynamic feature set changes from server
6436
- connection.on('feature-sets-changed', (params) => {
6437
- this.featureSetManager?.handleFeatureSetsChanged(connection.id, params);
6438
- });
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.
6439
7885
  // Handle scope elevation requests
6440
- connection.on('scope-elevate', async (params, responder) => {
6441
- if (this.scopeManager && responder) {
6442
- try {
6443
- const result = await this.scopeManager.handleElevation(params);
6444
- responder.respond(result);
6445
- }
6446
- catch (error) {
6447
- const err = error instanceof Error ? error : new Error(String(error));
6448
- responder.respondError(-32603, err.message);
6449
- }
6450
- }
6451
- });
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.
6452
7889
  // Handle push events (Step 6)
6453
7890
  connection.on('push-event', async (params, responder) => {
6454
7891
  const barrier = this.discordAwarenessBarrier;
@@ -6473,9 +7910,36 @@ export class AgentFramework {
6473
7910
  connection.on('channels-register', async (params, responder) => {
6474
7911
  await this.channelRegistry?.handleRegister(connection.id, params, responder);
6475
7912
  });
6476
- // Handle channel changes (Step 7)
6477
- connection.on('channels-changed', async (params) => {
6478
- 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)');
6479
7943
  });
6480
7944
  // Handle incoming channel messages (Step 7)
6481
7945
  connection.on('channels-incoming', async (params, responder) => {
@@ -6524,17 +7988,23 @@ export class AgentFramework {
6524
7988
  // reconnect observes it before doing work that could wake an agent. The
6525
7989
  // connection paused its data plane before wiring the fresh transport.
6526
7990
  const awarenessBarrier = this.installMcplDataPlaneGate();
6527
- try {
6528
- const config = this.mcplServerConfigs.get(connection.id);
6529
- if (config) {
6530
- 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
+ }
6531
8001
  }
6532
- }
6533
- catch (error) {
6534
- console.error(`MCPL server "${connection.id}" re-registration after reconnect failed:`, error instanceof Error ? error.message : error);
6535
- }
6536
- this.releaseMcplDataPlaneGate(awarenessBarrier);
6537
- 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
+ })();
6538
8008
  void awarenessBarrier.promise.then(() => {
6539
8009
  if (awarenessBarrier.requiresBarrier
6540
8010
  && !this.completeMcplDataPlaneGate(awarenessBarrier))
@@ -6630,6 +8100,7 @@ export class AgentFramework {
6630
8100
  if (!this.mcplServerRegistry)
6631
8101
  return;
6632
8102
  const tools = [];
8103
+ const toolFeatureSets = new Map();
6633
8104
  for (const server of this.mcplServerRegistry.getAllServers()) {
6634
8105
  const config = this.mcplServerConfigs.get(server.id);
6635
8106
  const prefix = config?.toolPrefix ?? `mcpl--${server.id}`;
@@ -6638,13 +8109,23 @@ export class AgentFramework {
6638
8109
  for (const tool of result.tools) {
6639
8110
  if (!isToolAllowed(tool.name, config))
6640
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;
6641
8119
  // MCP tool schemas are generic JSON Schema; cast to membrane's ToolDefinition format
6642
8120
  const schema = tool.inputSchema;
6643
8121
  tools.push({
6644
- name: `${prefix}--${tool.name}`,
8122
+ name: namespacedName,
6645
8123
  description: tool.description ?? '',
6646
8124
  inputSchema: schema,
6647
8125
  });
8126
+ if (featureSet !== undefined) {
8127
+ toolFeatureSets.set(namespacedName, featureSet);
8128
+ }
6648
8129
  }
6649
8130
  }
6650
8131
  catch {
@@ -6652,6 +8133,7 @@ export class AgentFramework {
6652
8133
  }
6653
8134
  }
6654
8135
  this.mcplTools = tools;
8136
+ this.mcplToolFeatureSets = toolFeatureSets;
6655
8137
  }
6656
8138
  /**
6657
8139
  * Handle a tools/list_changed notification with collapse logic.
@@ -6738,21 +8220,41 @@ export class AgentFramework {
6738
8220
  const startTime = Date.now();
6739
8221
  const args = (call.input && typeof call.input === 'object') ? call.input : {};
6740
8222
  // Build state params for stateful tools (Step 8).
6741
- // Host-managed state is single-valued per server, so inject the host-managed
6742
- // set's `state` for any call; otherwise fall back to a server-managed set's
6743
- // opaque `checkpoint`. Never blindly pick "first stateful" — when a server
6744
- // mixes host-managed (e.g. a feed) and server-managed (e.g. post/undo) sets,
6745
- // 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.
6746
8226
  let stateParams;
8227
+ let attributedFeatureSet = null;
6747
8228
  if (this.checkpointManager) {
6748
- const hostFs = this.checkpointManager.getHostManagedFeatureSet(serverId);
6749
- if (hostFs) {
6750
- 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
+ }
6751
8239
  }
6752
8240
  else {
6753
- const fs = this.checkpointManager.getStatefulFeatureSet(serverId);
6754
- if (fs) {
6755
- 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);
6756
8258
  if (cp)
6757
8259
  stateParams = { checkpoint: cp };
6758
8260
  }
@@ -6763,15 +8265,10 @@ export class AgentFramework {
6763
8265
  const durationMs = Date.now() - startTime;
6764
8266
  this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs });
6765
8267
  // Record checkpoint from stateful tool response (Step 8).
6766
- // Attribute to the set the server tagged (result.state.featureSet) first;
6767
- // else the host-managed set (host-managed checkpoints carry data/patch);
6768
- // else the single stateful set. Avoids recording a feed checkpoint onto a
6769
- // 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.
6770
8270
  if (result.state && this.checkpointManager) {
6771
- const tagged = result.state.featureSet;
6772
- const fs = tagged
6773
- ?? this.checkpointManager.getHostManagedFeatureSet(serverId)
6774
- ?? this.checkpointManager.getStatefulFeatureSet(serverId);
8271
+ const fs = result.state.featureSet ?? attributedFeatureSet;
6775
8272
  if (fs && this.checkpointManager.isStateful(serverId, fs)) {
6776
8273
  this.checkpointManager.recordCheckpoint(serverId, fs, result.state);
6777
8274
  }
@@ -6792,6 +8289,23 @@ export class AgentFramework {
6792
8289
  this.channelRegistry
6793
8290
  .openIfClosedForSend(target, serverId)
6794
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
+ }
6795
8309
  if (status === 'opened') {
6796
8310
  console.error(`[channel] ${agentName}: explicit ${toolName} into closed channel ${target} — opened (send implies engagement)`);
6797
8311
  this.recordChannelAutoOpenNotice(agentName, [{ channelId: channelId ?? target, label }], 'opened-by-reply');
@@ -6889,22 +8403,42 @@ export class AgentFramework {
6889
8403
  }
6890
8404
  this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input });
6891
8405
  const startTime = Date.now();
6892
- this.channelRegistry.handleChannelToolCall(call.name, call.input)
8406
+ this.channelRegistry.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName })
6893
8407
  .then((result) => {
6894
8408
  const durationMs = Date.now() - startTime;
6895
8409
  this.emitTrace({ type: 'tool:completed', module: 'channels', tool: call.name, callId: call.id, durationMs });
6896
8410
  // A successful channel_open plants the agent's feet in the opened
6897
- // channel: pin it as this turn's reply locus so plain-text speech
6898
- // written right after opening routes THERE. Found live 2026-07-21
6899
- // (Aria bring-up): open → plain-text reply → "no locus (no
6900
- // home/active channel, defaultPublishChannel null)" on a turn with
6901
- // no channel trigger (post-restart continuation). Next turn start
6902
- // 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.
6903
8423
  if (call.name === 'channel_open' && result?.success) {
6904
8424
  const opened = result.data?.channelId
6905
8425
  ?? call.input?.channelId;
6906
- if (opened)
8426
+ if (opened) {
6907
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
+ }
6908
8442
  }
6909
8443
  this.pushEvent({
6910
8444
  type: 'tool-result',
@@ -7102,6 +8636,31 @@ export class AgentFramework {
7102
8636
  if (typeof input.path !== 'string' || input.path.length === 0) {
7103
8637
  throw new Error('read_image: `path` (mount-prefixed) is required');
7104
8638
  }
8639
+ // Anthropic rejects images >5MB (and >8000px); guard the hard byte
8640
+ // limit here — resizing is out of scope without an image library.
8641
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
8642
+ // Prefer the workspace module's own read_image handler when it exists:
8643
+ // it reads tree-first with a FILESYSTEM FALLBACK, so binaries that live
8644
+ // only on disk (shell-created files, pre-fix mangled tree blobs) still
8645
+ // render. The legacy tree-only path below stays for older workspace
8646
+ // modules that don't advertise the tool.
8647
+ if (typeof workspace.handleToolCall === 'function' &&
8648
+ typeof workspace.getTools === 'function' &&
8649
+ workspace.getTools().some((tool) => tool.name === 'read_image')) {
8650
+ const result = await workspace.handleToolCall({ ...call, name: 'read_image' });
8651
+ if (result.success && Array.isArray(result.data)) {
8652
+ const image = result.data.find((block) => typeof block === 'object' && block !== null &&
8653
+ block.type === 'image' &&
8654
+ typeof block.data === 'string');
8655
+ const byteLength = image ? Math.floor((image.data.length * 3) / 4) : 0;
8656
+ if (byteLength > MAX_IMAGE_BYTES) {
8657
+ throw new Error(`read_image: "${input.path}" is ~${byteLength} bytes — ` +
8658
+ `over the ${MAX_IMAGE_BYTES}-byte model limit`);
8659
+ }
8660
+ }
8661
+ finish(result);
8662
+ return;
8663
+ }
7105
8664
  const read = await workspace.readBinary(input.path);
7106
8665
  if ('error' in read)
7107
8666
  throw new Error(read.error);
@@ -7110,9 +8669,6 @@ export class AgentFramework {
7110
8669
  throw new Error(`read_image: "${input.path}" does not look like a supported image ` +
7111
8670
  '(png/jpeg/gif/webp — checked by magic bytes, not extension)');
7112
8671
  }
7113
- // Anthropic rejects images >5MB (and >8000px); guard the hard byte
7114
- // limit here — resizing is out of scope without an image library.
7115
- const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
7116
8672
  if (read.data.byteLength > MAX_IMAGE_BYTES) {
7117
8673
  throw new Error(`read_image: "${input.path}" is ${read.data.byteLength} bytes — ` +
7118
8674
  `over the ${MAX_IMAGE_BYTES}-byte model limit`);
@@ -7274,6 +8830,81 @@ export class AgentFramework {
7274
8830
  }
7275
8831
  })();
7276
8832
  }
8833
+ /** The `utils` meta-tool: list/describe/run over module utilities
8834
+ * (Module.getUtilities). Shared by the model-facing dispatcher and the
8835
+ * programmatic path (executeToolCall), so code_execution scripts and
8836
+ * ephemerals reach utilities too. */
8837
+ async handleUtilsToolCall(agentName, call) {
8838
+ const input = (call.input ?? {});
8839
+ const utilities = this.moduleRegistry.getAllUtilities();
8840
+ const available = () => utilities.length ? utilities.map((u) => u.name).join(', ') : '(none registered)';
8841
+ switch (input.action) {
8842
+ case 'list':
8843
+ return {
8844
+ success: true,
8845
+ data: utilities.map((u) => ({ name: u.name, description: utilityOneLiner(u.description) })),
8846
+ };
8847
+ case 'describe': {
8848
+ const def = utilities.find((u) => u.name === input.name);
8849
+ if (!def) {
8850
+ return { success: false, isError: true, error: `No utility "${String(input.name)}". Available: ${available()}` };
8851
+ }
8852
+ return { success: true, data: def };
8853
+ }
8854
+ case 'run': {
8855
+ const def = utilities.find((u) => u.name === input.name);
8856
+ if (!def) {
8857
+ return { success: false, isError: true, error: `No utility "${String(input.name)}". Available: ${available()}` };
8858
+ }
8859
+ const args = input.args ?? {};
8860
+ const problem = validateUtilityArgs(args, def.inputSchema);
8861
+ if (problem) {
8862
+ // Teach by bounce: the error carries the schema, so the retry needs
8863
+ // no describe round-trip.
8864
+ return {
8865
+ success: false,
8866
+ isError: true,
8867
+ error: `${problem}. Schema for ${def.name}: ${JSON.stringify(def.inputSchema)}`,
8868
+ };
8869
+ }
8870
+ // Same dispatch as a first-class tool call — the module cannot tell
8871
+ // which surface the call came through.
8872
+ return this.moduleRegistry.handleToolCall({
8873
+ id: call.id,
8874
+ name: def.name,
8875
+ input: args,
8876
+ callerAgentName: agentName,
8877
+ });
8878
+ }
8879
+ default:
8880
+ return { success: false, isError: true, error: 'action must be "list", "describe", or "run"' };
8881
+ }
8882
+ }
8883
+ dispatchUtilsToolCall(agentName, call) {
8884
+ this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'utils', callId: call.id, input: call.input });
8885
+ const startedAt = Date.now();
8886
+ void this.handleUtilsToolCall(agentName, call)
8887
+ .catch((err) => ({
8888
+ success: false,
8889
+ error: err instanceof Error ? err.message : String(err),
8890
+ isError: true,
8891
+ }))
8892
+ .then((result) => {
8893
+ if (result.success) {
8894
+ this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'utils', callId: call.id, durationMs: Date.now() - startedAt });
8895
+ }
8896
+ else {
8897
+ this.emitTrace({ type: 'tool:failed', module: 'framework', tool: 'utils', callId: call.id, error: result.error ?? 'unknown error' });
8898
+ }
8899
+ this.pushEvent({
8900
+ type: 'tool-result',
8901
+ callId: call.id,
8902
+ agentName,
8903
+ moduleName: 'framework',
8904
+ result,
8905
+ });
8906
+ });
8907
+ }
7277
8908
  dispatchAgentSettingsToolCall(agentName, call) {
7278
8909
  this.emitTrace({
7279
8910
  type: 'tool:started',
@@ -7519,12 +9150,11 @@ export class AgentFramework {
7519
9150
  conversationId: agent.name,
7520
9151
  turnIndex: 0, // Simplified; needs per-conversation counter TODO
7521
9152
  userMessage: null, // Could extract from trigger context
7522
- model: {
7523
- id: agent.model,
7524
- vendor: 'unknown',
7525
- contextWindow: 200000,
7526
- capabilities: ['tools'],
7527
- },
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 },
7528
9158
  channels: this.channelRegistry?.buildChannelContext(agent.name),
7529
9159
  };
7530
9160
  }