@animalabs/agent-framework 0.26.1 → 0.27.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 (276) hide show
  1. package/README.md +57 -7
  2. package/dist/src/agent.d.ts +263 -22
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +531 -54
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/server.d.ts.map +1 -1
  7. package/dist/src/api/server.js +1 -2
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/auto-continue.d.ts +33 -0
  10. package/dist/src/auto-continue.d.ts.map +1 -0
  11. package/dist/src/auto-continue.js +35 -0
  12. package/dist/src/auto-continue.js.map +1 -0
  13. package/dist/src/code-execution/isolate/scripts.d.ts +11 -0
  14. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  15. package/dist/src/code-execution/isolate/scripts.js +5 -1
  16. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  17. package/dist/src/framework.d.ts +829 -156
  18. package/dist/src/framework.d.ts.map +1 -1
  19. package/dist/src/framework.js +3906 -1008
  20. package/dist/src/framework.js.map +1 -1
  21. package/dist/src/gate/event-gate.d.ts +309 -46
  22. package/dist/src/gate/event-gate.d.ts.map +1 -1
  23. package/dist/src/gate/event-gate.js +2117 -395
  24. package/dist/src/gate/event-gate.js.map +1 -1
  25. package/dist/src/gate/index.d.ts +2 -2
  26. package/dist/src/gate/index.d.ts.map +1 -1
  27. package/dist/src/gate/index.js +1 -1
  28. package/dist/src/gate/index.js.map +1 -1
  29. package/dist/src/gate/recipe-reconcile.d.ts +103 -0
  30. package/dist/src/gate/recipe-reconcile.d.ts.map +1 -0
  31. package/dist/src/gate/recipe-reconcile.js +325 -0
  32. package/dist/src/gate/recipe-reconcile.js.map +1 -0
  33. package/dist/src/gate/types.d.ts +68 -8
  34. package/dist/src/gate/types.d.ts.map +1 -1
  35. package/dist/src/index.d.ts +6 -4
  36. package/dist/src/index.d.ts.map +1 -1
  37. package/dist/src/index.js +5 -2
  38. package/dist/src/index.js.map +1 -1
  39. package/dist/src/mcpl/channel-registry.d.ts +433 -113
  40. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  41. package/dist/src/mcpl/channel-registry.js +1152 -434
  42. package/dist/src/mcpl/channel-registry.js.map +1 -1
  43. package/dist/src/mcpl/conversation-router.d.ts +16 -8
  44. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  45. package/dist/src/mcpl/conversation-router.js +29 -15
  46. package/dist/src/mcpl/conversation-router.js.map +1 -1
  47. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  48. package/dist/src/mcpl/convert-block.js +14 -2
  49. package/dist/src/mcpl/convert-block.js.map +1 -1
  50. package/dist/src/mcpl/hook-orchestrator.d.ts +7 -1
  51. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  52. package/dist/src/mcpl/hook-orchestrator.js +32 -7
  53. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  54. package/dist/src/mcpl/index.d.ts +5 -1
  55. package/dist/src/mcpl/index.d.ts.map +1 -1
  56. package/dist/src/mcpl/index.js +4 -0
  57. package/dist/src/mcpl/index.js.map +1 -1
  58. package/dist/src/mcpl/inference-router.d.ts +14 -2
  59. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  60. package/dist/src/mcpl/inference-router.js +73 -28
  61. package/dist/src/mcpl/inference-router.js.map +1 -1
  62. package/dist/src/mcpl/prose-grammar.d.ts +4 -0
  63. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
  64. package/dist/src/mcpl/prose-grammar.js +6 -0
  65. package/dist/src/mcpl/prose-grammar.js.map +1 -1
  66. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  67. package/dist/src/mcpl/protocol-family.js +3 -0
  68. package/dist/src/mcpl/protocol-family.js.map +1 -1
  69. package/dist/src/mcpl/push-coalescer.d.ts +20 -2
  70. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  71. package/dist/src/mcpl/push-coalescer.js +33 -11
  72. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  73. package/dist/src/mcpl/reference-fetcher.d.ts +63 -42
  74. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  75. package/dist/src/mcpl/reference-fetcher.js +177 -145
  76. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  77. package/dist/src/mcpl/references.d.ts +380 -46
  78. package/dist/src/mcpl/references.d.ts.map +1 -1
  79. package/dist/src/mcpl/references.js +737 -78
  80. package/dist/src/mcpl/references.js.map +1 -1
  81. package/dist/src/mcpl/server-connection.d.ts +82 -3
  82. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  83. package/dist/src/mcpl/server-connection.js +132 -7
  84. package/dist/src/mcpl/server-connection.js.map +1 -1
  85. package/dist/src/mcpl/tool-lifecycle.d.ts +11 -0
  86. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
  87. package/dist/src/mcpl/tool-lifecycle.js +21 -0
  88. package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
  89. package/dist/src/mcpl/tool-presence.d.ts +259 -0
  90. package/dist/src/mcpl/tool-presence.d.ts.map +1 -0
  91. package/dist/src/mcpl/tool-presence.js +628 -0
  92. package/dist/src/mcpl/tool-presence.js.map +1 -0
  93. package/dist/src/mcpl/tool-result-normalize.d.ts +9 -3
  94. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
  95. package/dist/src/mcpl/tool-result-normalize.js +14 -6
  96. package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
  97. package/dist/src/mcpl/tool-signature.d.ts +39 -0
  98. package/dist/src/mcpl/tool-signature.d.ts.map +1 -0
  99. package/dist/src/mcpl/tool-signature.js +292 -0
  100. package/dist/src/mcpl/tool-signature.js.map +1 -0
  101. package/dist/src/mcpl/toolset-host.d.ts +200 -0
  102. package/dist/src/mcpl/toolset-host.d.ts.map +1 -0
  103. package/dist/src/mcpl/toolset-host.js +621 -0
  104. package/dist/src/mcpl/toolset-host.js.map +1 -0
  105. package/dist/src/mcpl/toolsets-module.d.ts +80 -0
  106. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -0
  107. package/dist/src/mcpl/toolsets-module.js +431 -0
  108. package/dist/src/mcpl/toolsets-module.js.map +1 -0
  109. package/dist/src/mcpl/transport.d.ts +2 -2
  110. package/dist/src/mcpl/transport.d.ts.map +1 -1
  111. package/dist/src/mcpl/transport.js +3 -2
  112. package/dist/src/mcpl/transport.js.map +1 -1
  113. package/dist/src/mcpl/types.d.ts +21 -1
  114. package/dist/src/mcpl/types.d.ts.map +1 -1
  115. package/dist/src/mcpl/types.js.map +1 -1
  116. package/dist/src/mcpl/visible-content.d.ts +7 -9
  117. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  118. package/dist/src/mcpl/visible-content.js +21 -9
  119. package/dist/src/mcpl/visible-content.js.map +1 -1
  120. package/dist/src/module-registry.d.ts +23 -8
  121. package/dist/src/module-registry.d.ts.map +1 -1
  122. package/dist/src/module-registry.js +68 -15
  123. package/dist/src/module-registry.js.map +1 -1
  124. package/dist/src/modules/discord/index.js +1 -1
  125. package/dist/src/modules/discord/index.js.map +1 -1
  126. package/dist/src/modules/history/index.d.ts +2 -1
  127. package/dist/src/modules/history/index.d.ts.map +1 -1
  128. package/dist/src/modules/history/index.js +51 -17
  129. package/dist/src/modules/history/index.js.map +1 -1
  130. package/dist/src/modules/history/semantic.d.ts +108 -6
  131. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  132. package/dist/src/modules/history/semantic.js +273 -57
  133. package/dist/src/modules/history/semantic.js.map +1 -1
  134. package/dist/src/modules/workspace/direct-write.d.ts +181 -0
  135. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -0
  136. package/dist/src/modules/workspace/direct-write.js +384 -0
  137. package/dist/src/modules/workspace/direct-write.js.map +1 -0
  138. package/dist/src/modules/workspace/edit-text.d.ts +26 -12
  139. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
  140. package/dist/src/modules/workspace/edit-text.js +58 -28
  141. package/dist/src/modules/workspace/edit-text.js.map +1 -1
  142. package/dist/src/modules/workspace/effects.d.ts +10 -0
  143. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  144. package/dist/src/modules/workspace/effects.js +1 -1
  145. package/dist/src/modules/workspace/effects.js.map +1 -1
  146. package/dist/src/modules/workspace/index.d.ts +246 -27
  147. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  148. package/dist/src/modules/workspace/index.js +1216 -236
  149. package/dist/src/modules/workspace/index.js.map +1 -1
  150. package/dist/src/modules/workspace/read-page.d.ts +41 -0
  151. package/dist/src/modules/workspace/read-page.d.ts.map +1 -0
  152. package/dist/src/modules/workspace/read-page.js +213 -0
  153. package/dist/src/modules/workspace/read-page.js.map +1 -0
  154. package/dist/src/modules/workspace/result-text.d.ts +67 -0
  155. package/dist/src/modules/workspace/result-text.d.ts.map +1 -0
  156. package/dist/src/modules/workspace/result-text.js +94 -0
  157. package/dist/src/modules/workspace/result-text.js.map +1 -0
  158. package/dist/src/modules/workspace/text.d.ts +29 -12
  159. package/dist/src/modules/workspace/text.d.ts.map +1 -1
  160. package/dist/src/modules/workspace/text.js +35 -33
  161. package/dist/src/modules/workspace/text.js.map +1 -1
  162. package/dist/src/modules/workspace/types.d.ts +41 -17
  163. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  164. package/dist/src/modules/workspace/types.js.map +1 -1
  165. package/dist/src/sent-rows.d.ts +59 -0
  166. package/dist/src/sent-rows.d.ts.map +1 -0
  167. package/dist/src/sent-rows.js +145 -0
  168. package/dist/src/sent-rows.js.map +1 -0
  169. package/dist/src/silent-heartbeat.d.ts +17 -10
  170. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  171. package/dist/src/silent-heartbeat.js.map +1 -1
  172. package/dist/src/stable-hash.d.ts +3 -0
  173. package/dist/src/stable-hash.d.ts.map +1 -0
  174. package/dist/src/stable-hash.js +15 -0
  175. package/dist/src/stable-hash.js.map +1 -0
  176. package/dist/src/stored-injections.d.ts +99 -0
  177. package/dist/src/stored-injections.d.ts.map +1 -0
  178. package/dist/src/stored-injections.js +106 -0
  179. package/dist/src/stored-injections.js.map +1 -0
  180. package/dist/src/text-as-stored.d.ts +4 -3
  181. package/dist/src/text-as-stored.d.ts.map +1 -1
  182. package/dist/src/text-as-stored.js +4 -3
  183. package/dist/src/text-as-stored.js.map +1 -1
  184. package/dist/src/thinking-drops.d.ts +81 -0
  185. package/dist/src/thinking-drops.d.ts.map +1 -0
  186. package/dist/src/thinking-drops.js +103 -0
  187. package/dist/src/thinking-drops.js.map +1 -0
  188. package/dist/src/tool-image-ledger.d.ts +9 -8
  189. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  190. package/dist/src/tool-image-ledger.js +9 -8
  191. package/dist/src/tool-image-ledger.js.map +1 -1
  192. package/dist/src/tool-presentation.d.ts.map +1 -1
  193. package/dist/src/tool-presentation.js +3 -1
  194. package/dist/src/tool-presentation.js.map +1 -1
  195. package/dist/src/tool-result-guard.d.ts +4 -2
  196. package/dist/src/tool-result-guard.d.ts.map +1 -1
  197. package/dist/src/tool-result-guard.js +21 -6
  198. package/dist/src/tool-result-guard.js.map +1 -1
  199. package/dist/src/tool-result-history.d.ts +186 -21
  200. package/dist/src/tool-result-history.d.ts.map +1 -1
  201. package/dist/src/tool-result-history.js +428 -89
  202. package/dist/src/tool-result-history.js.map +1 -1
  203. package/dist/src/tune-out/coordinator.d.ts +17 -9
  204. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  205. package/dist/src/tune-out/coordinator.js +84 -37
  206. package/dist/src/tune-out/coordinator.js.map +1 -1
  207. package/dist/src/types/agent.d.ts +186 -7
  208. package/dist/src/types/agent.d.ts.map +1 -1
  209. package/dist/src/types/events.d.ts +19 -2
  210. package/dist/src/types/events.d.ts.map +1 -1
  211. package/dist/src/types/framework.d.ts +24 -3
  212. package/dist/src/types/framework.d.ts.map +1 -1
  213. package/dist/src/types/module.d.ts +90 -7
  214. package/dist/src/types/module.d.ts.map +1 -1
  215. package/dist/src/types/trace.d.ts +57 -2
  216. package/dist/src/types/trace.d.ts.map +1 -1
  217. package/package.json +4 -3
  218. package/src/agent.ts +593 -60
  219. package/src/api/server.ts +1 -2
  220. package/src/auto-continue.ts +42 -0
  221. package/src/code-execution/isolate/scripts.ts +11 -3
  222. package/src/framework.ts +4004 -1026
  223. package/src/gate/event-gate.ts +2315 -389
  224. package/src/gate/index.ts +2 -1
  225. package/src/gate/recipe-reconcile.ts +355 -0
  226. package/src/gate/types.ts +70 -8
  227. package/src/index.ts +7 -4
  228. package/src/mcpl/channel-registry.ts +1220 -467
  229. package/src/mcpl/conversation-router.ts +34 -16
  230. package/src/mcpl/convert-block.ts +14 -1
  231. package/src/mcpl/hook-orchestrator.ts +33 -6
  232. package/src/mcpl/index.ts +6 -1
  233. package/src/mcpl/inference-router.ts +79 -34
  234. package/src/mcpl/prose-grammar.ts +7 -0
  235. package/src/mcpl/protocol-family.ts +2 -0
  236. package/src/mcpl/push-coalescer.ts +51 -13
  237. package/src/mcpl/reference-fetcher.ts +201 -162
  238. package/src/mcpl/references.ts +852 -78
  239. package/src/mcpl/server-connection.ts +145 -7
  240. package/src/mcpl/tool-lifecycle.ts +21 -0
  241. package/src/mcpl/tool-presence.ts +650 -0
  242. package/src/mcpl/tool-result-normalize.ts +18 -6
  243. package/src/mcpl/tool-signature.ts +285 -0
  244. package/src/mcpl/toolset-host.ts +637 -0
  245. package/src/mcpl/toolsets-module.ts +451 -0
  246. package/src/mcpl/transport.ts +3 -2
  247. package/src/mcpl/types.ts +22 -1
  248. package/src/mcpl/visible-content.ts +22 -9
  249. package/src/module-registry.ts +77 -24
  250. package/src/modules/discord/index.ts +1 -1
  251. package/src/modules/history/index.ts +48 -18
  252. package/src/modules/history/semantic.ts +308 -54
  253. package/src/modules/workspace/direct-write.ts +425 -0
  254. package/src/modules/workspace/edit-text.ts +82 -35
  255. package/src/modules/workspace/effects.ts +1 -1
  256. package/src/modules/workspace/index.ts +1274 -241
  257. package/src/modules/workspace/read-page.ts +222 -0
  258. package/src/modules/workspace/result-text.ts +119 -0
  259. package/src/modules/workspace/text.ts +58 -32
  260. package/src/modules/workspace/types.ts +41 -17
  261. package/src/sent-rows.ts +162 -0
  262. package/src/silent-heartbeat.ts +17 -10
  263. package/src/stable-hash.ts +14 -0
  264. package/src/stored-injections.ts +131 -0
  265. package/src/text-as-stored.ts +4 -3
  266. package/src/thinking-drops.ts +141 -0
  267. package/src/tool-image-ledger.ts +9 -8
  268. package/src/tool-presentation.ts +3 -1
  269. package/src/tool-result-guard.ts +19 -6
  270. package/src/tool-result-history.ts +488 -88
  271. package/src/tune-out/coordinator.ts +80 -37
  272. package/src/types/agent.ts +189 -7
  273. package/src/types/events.ts +19 -2
  274. package/src/types/framework.ts +21 -3
  275. package/src/types/module.ts +86 -7
  276. package/src/types/trace.ts +58 -2
@@ -0,0 +1,162 @@
1
+ import type { ContentBlock, ToolResult as MembraneToolResult } from '@animalabs/membrane';
2
+ import type { MessageId } from '@animalabs/context-manager';
3
+ import { isDeepStrictEqual } from 'node:util';
4
+
5
+ import { withoutTokenEstimates } from './thinking-token-stamp.js';
6
+
7
+ /**
8
+ * A row a stream's later round carried after its compiled messages, as a context manager that keeps a
9
+ * thinking-binding record is told of it (`noteRequestSent`): the id of a stored body the next request carries as
10
+ * the stream sent it, or the marker for one it doesn't, after which nothing keeps its thinking.
11
+ */
12
+ export type SentRow = MessageId | { unstored: true };
13
+
14
+ const UNSTORED: SentRow = Object.freeze({ unstored: true as const });
15
+
16
+ /**
17
+ * Whether a request carries a block: the sanitize pass leaves out an empty or whitespace text block, which the
18
+ * Anthropic API refuses (assembleActivationRequest). The `typeof` guard is runtime defense: history loaded from
19
+ * disk can carry a non-string `text` despite what the types claim.
20
+ */
21
+ export function carriedBlock(block: ContentBlock): boolean {
22
+ return !(block.type === 'text' && (typeof block.text !== 'string' || block.text.trim() === ''));
23
+ }
24
+
25
+ /**
26
+ * A stored body's content as the agent's next request carries it: what the sanitize pass leaves of it, without
27
+ * the framework's pricing stamp. Null when the pass leaves nothing, so the request carries no message there.
28
+ */
29
+ export function asNextRequestCarries(content: readonly ContentBlock[]): ContentBlock[] | null {
30
+ const kept = content.filter(carriedBlock);
31
+ if (kept.length === 0) return null;
32
+ return withoutTokenEstimates([{ content: kept }])[0]!.content;
33
+ }
34
+
35
+ /**
36
+ * Whether a block of what a stream sent counts in the comparison. membrane's native formatter leaves out an exactly
37
+ * empty text block, which the API refuses. An empty text block carrying a provider-native item (`rawItem`) it sends
38
+ * as that item where it's a server-tool block it knows, in an assistant turn, and leaves out otherwise; whitespace
39
+ * text it sends, behind the participant's name where a multiuser conversation shows names. The next request's
40
+ * sanitize pass leaves both out, so both count as differences. Where membrane, or the adapter's cleanup (whitespace in
41
+ * an assistant turn), leaves one out of both requests, that's a needless strip rather than a row named for bytes the
42
+ * provider didn't see.
43
+ */
44
+ function sentBlock(block: ContentBlock): boolean {
45
+ if (block.type !== 'text' || block.text !== '') return true;
46
+ const raw = (block as { rawItem?: unknown }).rawItem;
47
+ return raw !== null && typeof raw === 'object';
48
+ }
49
+
50
+ /** A block's own cache marker left out: the provider's thinking binding doesn't count it, nor does the context
51
+ * manager's record, and stored history can carry a stale one. */
52
+ function withoutCacheMarker(block: ContentBlock): unknown {
53
+ if (!('cache_control' in block) && !('cacheControl' in block)) return block;
54
+ const { cache_control: _snake, cacheControl: _camel, ...rest } = block as ContentBlock & { cache_control?: unknown; cacheControl?: unknown };
55
+ return rest;
56
+ }
57
+
58
+ /** A value as JSON writes it: a non-finite number as null, and undefined, a function or a symbol as nothing
59
+ * (undefined here), which an object leaves out and an array writes as null. */
60
+ function asJson(value: unknown): unknown {
61
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
62
+ return value === undefined || typeof value === 'function' || typeof value === 'symbol' ? undefined : value;
63
+ }
64
+
65
+ /** Whether JSON writes an object by walking it: an array, or a plain object without its own `toJSON`. */
66
+ function walkable(value: object): boolean {
67
+ if (Array.isArray(value)) return true;
68
+ const proto = Object.getPrototypeOf(value);
69
+ return (proto === Object.prototype || proto === null) && typeof (value as { toJSON?: unknown }).toJSON !== 'function';
70
+ }
71
+
72
+ /**
73
+ * Blocks equal as JSON data, as the store keeps them and a request sends them: equal where `JSON.stringify` writes
74
+ * them alike, key order aside. So a non-finite number is null; a field whose value is undefined, a function or a
75
+ * symbol is absent; such an array element, or a hole, is null; and anything else an object can be (a Date, a Buffer)
76
+ * is compared as JSON writes it. It walks plain objects and arrays rather than serializing them, since a note compares
77
+ * every row of the stream so far, base64 images and long tool results included, at each boundary.
78
+ */
79
+ export function sameData(a: unknown, b: unknown): boolean {
80
+ const left = asJson(a);
81
+ const right = asJson(b);
82
+ if (left === right) return true;
83
+ const unwalked = (value: unknown) => typeof value === 'object' && value !== null && !walkable(value);
84
+ if (unwalked(left) || unwalked(right)) {
85
+ return isDeepStrictEqual(JSON.parse(JSON.stringify(left) ?? 'null'), JSON.parse(JSON.stringify(right) ?? 'null'));
86
+ }
87
+ if (typeof left !== 'object' || typeof right !== 'object' || left === null || right === null) return false;
88
+ if (Array.isArray(left) || Array.isArray(right)) {
89
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
90
+ for (let i = 0; i < left.length; i++) {
91
+ if (!sameData(asJson(left[i]) ?? null, asJson(right[i]) ?? null)) return false;
92
+ }
93
+ return true;
94
+ }
95
+ const l = left as Record<string, unknown>;
96
+ const r = right as Record<string, unknown>;
97
+ const keys = Object.keys(l).filter((key) => asJson(l[key]) !== undefined);
98
+ return keys.length === Object.keys(r).filter((key) => asJson(r[key]) !== undefined).length
99
+ && keys.every((key) => Object.hasOwn(r, key) && asJson(r[key]) !== undefined && sameData(l[key], r[key]));
100
+ }
101
+
102
+ /** What membrane builds a round's results message from: each submitted result as a `tool_result` block. */
103
+ function submittedBlocks(results: readonly MembraneToolResult[]): ContentBlock[] {
104
+ return results.map((result) => ({
105
+ type: 'tool_result',
106
+ toolUseId: result.toolUseId,
107
+ content: result.content,
108
+ isError: result.isError === true,
109
+ }) as ContentBlock);
110
+ }
111
+
112
+ /** One stored row of a round, beside what the stream sent in its place. */
113
+ export type RoundRow =
114
+ | { kind: 'reply'; id: MessageId | undefined; sent: readonly ContentBlock[] }
115
+ | { kind: 'results'; id: MessageId | undefined; sent: readonly MembraneToolResult[] }
116
+ | { kind: 'injected'; id: MessageId | undefined; participant: string; sent: readonly ContentBlock[] };
117
+
118
+ /**
119
+ * A round's rows as noted: each stored row's id where the next request carries it as the stream sent it, the
120
+ * marker where it doesn't. `carried` gives each id's stored body as the next compile renders it (participant and
121
+ * content, or null where no compile can send it), so the comparison is the one the context manager's record
122
+ * makes, after the agent's own request passes (asNextRequestCarries). A row the store doesn't hold (no id), a reply
123
+ * that differs from the round's content as membrane held it, results that differ from those submitted (as while the
124
+ * tool-result guard holds a batch, storing placeholders and submitting the originals), or an injected message sent
125
+ * otherwise than its stored body renders, is the marker. Called again over the same rows once the store has
126
+ * changed, as when the guard's batch settles to the originals, it gives each row's verdict as of then.
127
+ */
128
+ export function roundRows(
129
+ rows: readonly RoundRow[],
130
+ carried: (id: MessageId) => { participant: string; content: ContentBlock[] } | null,
131
+ ): SentRow[] {
132
+ return rows.map((row): SentRow => {
133
+ if (row.id === undefined) return UNSTORED;
134
+ const body = carried(row.id);
135
+ const content = body ? asNextRequestCarries(body.content) : null;
136
+ if (!body || !content) return UNSTORED;
137
+ // What the stream sent, as membrane's native formatter sends it (sentBlock): an exactly empty text block is in
138
+ // neither request.
139
+ const sent = row.kind === 'results' ? submittedBlocks(row.sent) : row.sent.filter(sentBlock);
140
+ const sameParticipant = row.kind !== 'injected' || body.participant === row.participant;
141
+ const sameContent = row.kind === 'results'
142
+ ? sameData(content.map(normalizedResult), sent.map(normalizedResult))
143
+ : sameData(content.map(withoutCacheMarker), sent.map(withoutCacheMarker));
144
+ return sameParticipant && sameContent ? row.id : UNSTORED;
145
+ });
146
+ }
147
+
148
+ /**
149
+ * A results block as compared: what a request sends of it, its id, content and error flag, with `isError` as a
150
+ * boolean, where a stored block may leave a false one out. A stream's envelope sends a submitted result's
151
+ * `is_error: false` and the next request, from the stored block, sends none; the provider's thinking binding takes
152
+ * the two alike (read from the provider on 2026-10-23: Haiku 5.5 with the drop control, on an enforcing API-key
153
+ * organization and on a subscription account, reported no drop for either, and dropped both replies' blocks when an
154
+ * earlier user message changed). The store's own fields beside them, such as
155
+ * `toolName`, don't reach the provider on membrane's native path, the only one noted (membrane 0.7.1's XML
156
+ * formatter reads `toolName`, and an XML stream is never noted).
157
+ */
158
+ function normalizedResult(block: ContentBlock): unknown {
159
+ if (block.type !== 'tool_result') return block;
160
+ const { toolUseId, content, isError } = block as { toolUseId?: unknown; content?: unknown; isError?: unknown };
161
+ return { type: 'tool_result', toolUseId, content, isError: isError === true };
162
+ }
@@ -7,19 +7,22 @@
7
7
  * - A silent heartbeat tick stores no prompt row (#216): its reply is written
8
8
  * straight after whatever the window held before it — often the previous
9
9
  * assistant message, since a suppressed tick leaves no delivery receipt
10
- * behind. Every row a tick writes is stamped with `metadata.silentHeartbeat`,
11
- * and a separator carrying the tick's instruction is rendered before the
12
- * first of the tick's rows that survives compilation.
10
+ * behind. Every row the tick's reply writes is stamped with
11
+ * `metadata.silentHeartbeat`, and a separator carrying the tick's
12
+ * instruction is rendered before the first of those rows that survives
13
+ * compilation. A context injection its start stores (stored-injections.ts)
14
+ * is unstamped: it's history the agent saw, not the tick's speech, so a
15
+ * policy that drops a tick's rows leaves it.
13
16
  * - A compile that ends on the agent's own message gets a trailing
14
17
  * `[Continue]` turn (some models reject a trailing assistant message). The
15
18
  * reply it prompts is stored straight after that message, so the reply's
16
19
  * rows are stamped `metadata.promptedBy` (`RequestOnlyPrompt`), and the turn
17
20
  * is rendered before the first of them that survives. A reply follows the
18
21
  * prompt when what it was sent ends on that `[Continue]`, or on the tool
19
- * results of a reply that followed it (`toolLoopPrompt`): a stream start
20
- * and each round of a stream alike. A reply sent anything else after the
21
- * results (a message that arrived meanwhile) follows that, and carries no
22
- * prompt.
22
+ * results of a reply that followed it (`toolLoopPrompt`), stored context
23
+ * injections after those results aside: a stream start and each round of
24
+ * a stream alike. A reply sent anything else after the results (a message
25
+ * that arrived meanwhile) follows that, and carries no prompt.
23
26
  *
24
27
  * Without the row, the wire formatter merges consecutive same-role messages:
25
28
  * the reply would read as an unprompted continuation of the earlier response,
@@ -75,9 +78,13 @@ export interface SilentHeartbeatTick {
75
78
  serverId: string;
76
79
  }
77
80
 
78
- /** `metadata.silentHeartbeat` on every row a tick's turn writes. Residents
79
- * share one message slot, so the stamp names the agent whose turn wrote it
80
- * (tool_result rows are stored as `user`). */
81
+ /** `metadata.silentHeartbeat` on the rows of a tick's own turn (its reply,
82
+ * tool results and their wrappers). What the framework stores of its own
83
+ * accord is left unstamped, a clock row (at the turn's start or a tool
84
+ * boundary) or a context injection, so a retention policy that drops a
85
+ * tick's rows keeps it. Residents share one message
86
+ * slot, so the stamp names the agent whose turn wrote it (tool_result rows
87
+ * are stored as `user`). */
81
88
  export interface SilentHeartbeatStamp extends SilentHeartbeatTick {
82
89
  agentName: string;
83
90
  /** The separator text the tick's request rendered, which later compiles
@@ -0,0 +1,14 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ /** A SHA-256 of `value` that doesn't depend on its objects' key order. */
4
+ export function stableHash(value: unknown): string {
5
+ const encode = (item: unknown): string => {
6
+ if (Array.isArray(item)) return `[${item.map(encode).join(',')}]`;
7
+ if (item && typeof item === 'object') {
8
+ const record = item as Record<string, unknown>;
9
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${encode(record[key])}`).join(',')}}`;
10
+ }
11
+ return JSON.stringify(item) ?? 'null';
12
+ };
13
+ return createHash('sha256').update(encode(value)).digest('hex');
14
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Context injections kept where they were placed (context-manager #155,
3
+ * part 5).
4
+ *
5
+ * `compile()` places an `afterUser` injection after the last user message
6
+ * each time it runs, and nothing stores it. So at the next start, a new turn
7
+ * or a restart after a tool result, it's gone from where the agent's replies
8
+ * saw it, and on a model that binds a reply's thinking to everything before
9
+ * it, every reply after the old place loses its thinking. A moved injection
10
+ * also rewrites the previous turn's cached prefix. So at each start that
11
+ * sends, the framework appends each `afterUser` injection to the agent's own
12
+ * window, as the message `compile()` would have placed, unless the copy the
13
+ * agent will read as current (the one its last request held, or a newer one
14
+ * a start stored whose compile then didn't record) has the same content,
15
+ * every part of it, and the compile
16
+ * then renders
17
+ * it from the store like any message, where it stays. While the window is
18
+ * held (AgentFramework's slotHeldFrom), `compile()` places it instead. Being a
19
+ * message, it's history to the strategy: capped by its per-message bound,
20
+ * sharded, selected by the budget and folded like any other.
21
+ *
22
+ * A stored injection's row carries `system: true`, `kind:
23
+ * 'context-injection'` and `contextInjection` in its metadata (the mark),
24
+ * naming the agent whose context it is and its source's namespace, so a
25
+ * reader can tell it from conversation, and so another agent reading the
26
+ * same slot leaves it out (AgentFramework's view filters). All three are
27
+ * keys only the host writes.
28
+ */
29
+
30
+ import type { ContextInjection } from '@animalabs/context-manager';
31
+ import { stableHash } from './stable-hash.js';
32
+ import { injectionsAsStored } from './text-as-stored.js';
33
+
34
+ /** The metadata key a stored injection's row is marked with. */
35
+ export const STORED_INJECTION_KEY = 'contextInjection';
36
+
37
+ /** The `kind` a stored injection's row carries beside `system: true`, as the
38
+ * framework's other rows of its own do (a clock row, a notice). */
39
+ export const STORED_INJECTION_KIND = 'context-injection';
40
+
41
+ /**
42
+ * The mark: whose context the row is, its source's namespace, a hash of the
43
+ * content its source gave at that start (the unit), in its stored form
44
+ * (unitHash), and which of the unit's `parts` injections the row holds
45
+ * (`part`, from 0). Every row of one injection, a sharded one's too, carries
46
+ * the same `part`.
47
+ */
48
+ export interface StoredInjectionMark {
49
+ agent: string;
50
+ namespace: string;
51
+ contentHash: string;
52
+ part: number;
53
+ parts: number;
54
+ }
55
+
56
+ /** The participant `compile()` gives an injection it places, which a stored
57
+ * one keeps, so it renders as the placed one did. */
58
+ export function injectionParticipant(namespace: string): string {
59
+ return `system_context:${namespace}`;
60
+ }
61
+
62
+ /**
63
+ * A stored injection's mark, read from a row's metadata, so a host's reader
64
+ * tells a stored injection from conversation by the rule the framework's own
65
+ * views use. Undefined for a row with no mark, and for one whose mark isn't
66
+ * whole (a string `agent`, `namespace` and `contentHash`, and integer
67
+ * `part` and `parts` with 0 <= part < parts): no view of the framework's
68
+ * treats such a row as a stored injection, so neither should a host's.
69
+ */
70
+ export function storedInjectionMarkOf(metadata: unknown): StoredInjectionMark | undefined {
71
+ const mark = (metadata as Record<string, unknown> | undefined)?.[STORED_INJECTION_KEY];
72
+ if (mark === null || typeof mark !== 'object') return undefined;
73
+ const { agent, namespace, contentHash, part, parts } = mark as Record<string, unknown>;
74
+ return typeof agent === 'string' && typeof namespace === 'string' && typeof contentHash === 'string'
75
+ && Number.isInteger(part) && Number.isInteger(parts) && (part as number) >= 0 && (part as number) < (parts as number)
76
+ ? { agent, namespace, contentHash, part: part as number, parts: parts as number }
77
+ : undefined;
78
+ }
79
+
80
+ /** Whether a row is another agent's stored injection, which `agentName`'s
81
+ * view leaves out: it's that agent's context, not conversation. */
82
+ export function isAnotherAgentsInjection(metadata: unknown, agentName: string): boolean {
83
+ const mark = storedInjectionMarkOf(metadata);
84
+ return mark !== undefined && mark.agent !== agentName;
85
+ }
86
+
87
+ /**
88
+ * Whether an injection is stored, rather than placed by `compile()` each
89
+ * time: an `afterUser` one, unless it opts out
90
+ * (ModuleContextInjection.placedEachCompile). Only a module's can: an MCPL
91
+ * server's is built from four fields alone (convertMcplInjection), so it
92
+ * never carries the field.
93
+ */
94
+ export function isStoredKind(injection: ContextInjection): boolean {
95
+ return injection.position === 'afterUser'
96
+ && (injection as { placedEachCompile?: unknown }).placedEachCompile !== true;
97
+ }
98
+
99
+ /**
100
+ * Whether content leaves anything in a request: a block that isn't a text
101
+ * block, or a text block with non-blank text, as the request's sanitizer
102
+ * keeps (Agent's assembleActivationRequest drops blank text blocks, and a
103
+ * message left with none). An injection with none isn't stored: its row
104
+ * would vanish from every request, and the count of stored rows the
105
+ * request ends on, which keeps [Continue] last, would then be wrong.
106
+ * compile() places it, and the sanitizer drops it, as before.
107
+ */
108
+ export function hasRenderedContent(content: ContextInjection['content']): boolean {
109
+ return Array.isArray(content) && content.some((block) =>
110
+ !((block as { type?: unknown }).type === 'text'
111
+ && (typeof (block as { text?: unknown }).text !== 'string' || ((block as { text: string }).text).trim() === '')));
112
+ }
113
+
114
+ /** One injection as its row would store it: its namespace as text (as
115
+ * compile() writes it into the participant's name, so one that isn't a
116
+ * string, an MCPL server's number or none, is read so), that participant,
117
+ * and its content in the stored form (injectionsAsStored), as the compile
118
+ * would have taken it. The store's write would mend the content the same
119
+ * way; the namespace, which the mark carries too, it wouldn't. */
120
+ export function asStoredRow(injection: ContextInjection): { namespace: string; participant: string; content: ContextInjection['content'] } {
121
+ const [stored] = injectionsAsStored([injection])!;
122
+ const namespace = `${stored!.namespace}`;
123
+ return { namespace, participant: injectionParticipant(namespace), content: stored!.content };
124
+ }
125
+
126
+ /** The hash of a unit's content in its stored form, which the same-content
127
+ * skip compares: the mark's, from the gather that stored the unit, with
128
+ * this gather's. */
129
+ export function unitHash(rows: ReadonlyArray<{ content: ContextInjection['content'] }>): string {
130
+ return stableHash(rows.map((r) => r.content));
131
+ }
@@ -4,9 +4,10 @@
4
4
  * request compiles comes from the store, which writes a lone UTF-16
5
5
  * surrogate as U+FFFD, and a provider refuses a request carrying one (#695).
6
6
  * Two things reach a request without the store, and each is made well formed
7
- * where it leaves for it: context injections, as an Agent compiles them into
8
- * a request, and the copies a tool boundary injects into the live round, as
9
- * they go to the stream.
7
+ * where it leaves for it: context injections compile() places (those not
8
+ * stored, stored-injections.ts), as an Agent compiles them into a request,
9
+ * and the copies a tool boundary injects into the live round, as they go to
10
+ * the stream. A stored injection takes the same form before it's stored.
10
11
  */
11
12
 
12
13
  import type { ContentBlock } from '@animalabs/membrane';
@@ -0,0 +1,141 @@
1
+ import type { ContentBlock, NormalizedMessage, ThinkingBindingEntry } from '@animalabs/membrane';
2
+ import type { CompileProvenance, MessageId } from '@animalabs/context-manager';
3
+
4
+ /**
5
+ * Where a block the provider dropped (membrane's `RoundReport.thinking.dropped`) came from.
6
+ *
7
+ * - `reply`: a block of a stored reply the compile rendered. `bodies` are the stored bodies that compiled message
8
+ * was rendered from (`CompileProvenance`'s raw bodies), each by the id `addMessage` returned for it and its
9
+ * sequence (a body kept in shards is one, by its first shard): usually one. Where the block's signature is in
10
+ * more than one compiled message, it's every one of theirs, since the block alone can't tell them apart.
11
+ * - `round`: a block of a round this stream appended itself (the entry's `round`), which no compile holds.
12
+ * - `unplaced`: membrane couldn't place it, or it names a block this request didn't send from a stored reply:
13
+ * an injected message's, one whose compiled message has no raw body (a summary), or one whose signature no
14
+ * compiled block carries.
15
+ */
16
+ export type PlacedDrop =
17
+ | { kind: 'reply'; bodies: Array<{ messageId: MessageId; sequence: number }>; entry: ThinkingBindingEntry }
18
+ | { kind: 'round'; round: number; entry: ThinkingBindingEntry }
19
+ | { kind: 'unplaced'; entry: ThinkingBindingEntry };
20
+
21
+ /**
22
+ * What a request was assembled from: the compile's messages as it returned them, its provenance, and, when the
23
+ * compile gave one, the sequence at or below which a reply was stored before the branch's thinking-binding record
24
+ * began (context-manager's `CompileResult.thinkingRecordedAfter`, where it keeps a thinking-binding record).
25
+ */
26
+ export interface CompiledForRequest {
27
+ messages: readonly NormalizedMessage[];
28
+ provenance?: CompileProvenance;
29
+ recordedAfter?: number;
30
+ }
31
+
32
+ /** What a stream's driver reads a call's dropped blocks against (`StartStreamResult`'s two fields, together). */
33
+ export interface StreamThinkingDrops {
34
+ place: (entries: readonly ThinkingBindingEntry[]) => PlacedDrop[];
35
+ recordedAfter?: number;
36
+ }
37
+
38
+ /** A signed block's identity on the wire: a thinking block's signature, a redacted block's data. */
39
+ function bindingKey(block: ContentBlock | undefined): string | undefined {
40
+ const b = block as { type?: unknown; signature?: unknown; data?: unknown } | undefined;
41
+ if (b?.type === 'thinking' && typeof b.signature === 'string' && b.signature.length > 0) return `s:${b.signature}`;
42
+ if (b?.type === 'redacted_thinking' && typeof b.data === 'string' && b.data.length > 0) return `d:${b.data}`;
43
+ return undefined;
44
+ }
45
+
46
+ /**
47
+ * Place each dropped entry of one call. membrane places a block by the request's message and the block's
48
+ * index in it; the request holds copies of the compile's messages (the sanitize pass, `withoutTokenEstimates`,
49
+ * the request-only rows and `prepareRequest` each copy or move them), so the block is found again in the
50
+ * compile by its signature (or a redacted block's data), which every copy keeps, and the compiled message's
51
+ * provenance names the stored reply.
52
+ */
53
+ export function placeThinkingDrops(
54
+ entries: readonly ThinkingBindingEntry[],
55
+ sent: readonly NormalizedMessage[],
56
+ compiled: CompiledForRequest,
57
+ ): PlacedDrop[] {
58
+ let byKey: Map<string, number[]> | undefined;
59
+ const compiledIndexes = (key: string): number[] => {
60
+ if (!byKey) {
61
+ byKey = new Map();
62
+ compiled.messages.forEach((message, i) => {
63
+ for (const block of message.content) {
64
+ const k = bindingKey(block);
65
+ if (k === undefined) continue;
66
+ const at = byKey!.get(k);
67
+ if (!at) byKey!.set(k, [i]);
68
+ else if (at.at(-1) !== i) at.push(i);
69
+ }
70
+ });
71
+ }
72
+ return byKey.get(key) ?? [];
73
+ };
74
+ return entries.map((entry): PlacedDrop => {
75
+ if (entry.round !== undefined) return { kind: 'round', round: entry.round, entry };
76
+ if (entry.message === undefined || entry.block === undefined) return { kind: 'unplaced', entry };
77
+ const key = bindingKey(sent[entry.message]?.content[entry.block]);
78
+ const bodies: Array<{ messageId: MessageId; sequence: number }> = [];
79
+ for (const at of key === undefined ? [] : compiledIndexes(key)) {
80
+ const source = compiled.provenance?.messages[at];
81
+ // A candidate with no stored body (a summary's) can't be placed, so neither can the block.
82
+ if (source?.kind !== 'raw' || source.bodies.length === 0) return { kind: 'unplaced', entry };
83
+ for (const body of source.bodies) bodies.push({ messageId: body.messageId, sequence: body.sequence });
84
+ }
85
+ if (bodies.length === 0) return { kind: 'unplaced', entry };
86
+ return { kind: 'reply', bodies, entry };
87
+ });
88
+ }
89
+
90
+ /**
91
+ * A call's placed drops against the branch's binding-record start (`thinkingRecordedAfter`). Expected: a block of a
92
+ * stored reply whose every body sits at or below it, minted before the context manager recorded what the branch
93
+ * sent, so the provider may drop its thinking with no strip having missed; and a drop for a binding the record
94
+ * doesn't track, the account's organization or end user (`organization_binding_mismatch`,
95
+ * `end_user_binding_mismatch`). Unexpected: everything else, which the record doesn't account for: a reply above
96
+ * the start, a block of the stream's own rounds, or one that couldn't be placed, for a conversation or model
97
+ * mismatch or a reason not known here.
98
+ */
99
+ export function splitThinkingDrops(
100
+ placed: readonly PlacedDrop[],
101
+ recordedAfter: number,
102
+ ): { expected: PlacedDrop[]; unexpected: PlacedDrop[] } {
103
+ const expected: PlacedDrop[] = [];
104
+ const unexpected: PlacedDrop[] = [];
105
+ for (const drop of placed) {
106
+ const before = drop.kind === 'reply' && drop.bodies.every((body) => body.sequence <= recordedAfter);
107
+ (before || UNTRACKED_REASONS.has(drop.entry.reason) ? expected : unexpected).push(drop);
108
+ }
109
+ return { expected, unexpected };
110
+ }
111
+
112
+ /** Binding failures the context manager's record doesn't track: the account's, not the conversation's. */
113
+ const UNTRACKED_REASONS = new Set(['organization_binding_mismatch', 'end_user_binding_mismatch']);
114
+
115
+ /**
116
+ * When an agent's unexpected drops call for an operator alert: at the first call that shows any, and again only
117
+ * when a call shows more than the alert last counted. Each call of a stream, and each stream until a strip takes
118
+ * them out, drops the same blocks again, so an unchanged count isn't news. A call that shows no unexpected drop, its
119
+ * report empty, absent or naming only expected ones, ends the incident: the blocks it counted no longer drop. The next
120
+ * one alerts afresh. It's held in memory, so a restart mid-incident alerts once more, and an agent's removal ends its
121
+ * incident (`forget`).
122
+ */
123
+ export class ThinkingLossIncidents {
124
+ private readonly alerted = new Map<string, number>();
125
+
126
+ /** A call's report of `unexpected` drops for `agent`: the count to alert with, or undefined for no alert. */
127
+ observe(agent: string, unexpected: number): number | undefined {
128
+ if (unexpected === 0) {
129
+ this.alerted.delete(agent);
130
+ return undefined;
131
+ }
132
+ if (unexpected <= (this.alerted.get(agent) ?? 0)) return undefined;
133
+ this.alerted.set(agent, unexpected);
134
+ return unexpected;
135
+ }
136
+
137
+ /** The agent is gone: its incident, if one is open, ends with it. */
138
+ forget(agent: string): void {
139
+ this.alerted.delete(agent);
140
+ }
141
+ }
@@ -327,10 +327,10 @@ export interface StoredCut {
327
327
  * Where a tool result's text longer than `cap` is cut: by its stored copy
328
328
  * (cutStoredToolResult), by history's own truncation (truncateForHistory),
329
329
  * and by its live copy, for each text block beside an image
330
- * (tryNativeToolResultContent, framework.ts), so at one cap the stored copy
331
- * and the live copy cut alike. Under a readers' bound the stored copy's cut
332
- * can come earlier (cutStoredToolResultToFit). The text's length when it
333
- * isn't longer than `cap`.
330
+ * (toMembraneToolResult's nativeContent, framework.ts), so at one cap the
331
+ * stored copy and the live copy cut alike. Under a readers' bound the stored
332
+ * copy's cut can come earlier (cutStoredToolResultToFit). The text's length
333
+ * when it isn't longer than `cap`.
334
334
  *
335
335
  * The cut is made at the cap, or before it when the cap would split a
336
336
  * surrogate pair (safeSlice's rule), and that is where the moves back are
@@ -367,10 +367,11 @@ export function toolResultCutAt(text: string, cap: number): number {
367
367
  *
368
368
  * `shown` is what the live copy showed beside the stored text: null when the
369
369
  * live copy was the text itself, as for every result that carried no inline
370
- * image. Then what the cut keeps is exactly what the resident saw, and
371
- * nothing past it is kept (#403). A result that carried an inline image went
372
- * to the model as native blocks (tryNativeToolResultContent, framework.ts),
373
- * every image whatever the text's length, so its stored text is the only
370
+ * image, or whose links and markers came to more than the cap together, so
371
+ * goesNative said no.
372
+ * Then what the cut keeps is exactly what the resident saw, and nothing past
373
+ * it is kept (#403). A result whose live copy went to the model as native
374
+ * blocks (framework.ts) sent every image whatever the text's length, so its stored text is the only
374
375
  * place save_recent_image's inventory finds them, and a slot dropped by the
375
376
  * cut would be an image the resident saw that the index slides past (#104).
376
377
  * The image slots past the cut that lie wholly within what the live copy
@@ -124,7 +124,9 @@ function documentProblems(doc: unknown, descriptionsOnly: boolean): string[] {
124
124
 
125
125
  function parseDocument(raw: string, descriptionsOnly: boolean): PresentationDocument {
126
126
  let doc: unknown;
127
- try { doc = JSON.parse(raw); }
127
+ // Some Windows editors save UTF-8 with a leading U+FEFF, which read() keeps
128
+ // and JSON.parse refuses; drop exactly one, as parseGateJson does (#758).
129
+ try { doc = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw); }
128
130
  catch (error) { throw new PresentationFileError([`it isn't valid JSON: ${errorMessage(error)}`]); }
129
131
  // An object with no keys carries no settings, so it's the empty document,
130
132
  // as a missing resident file is: it's what an operator writes by hand to
@@ -4,7 +4,8 @@ import type { ContextManager, MessageId, MessageMetadata } from '@animalabs/cont
4
4
  import type { ContentBlock, NormalizedMessage, ToolResult } from '@animalabs/membrane';
5
5
  import type { CompletedToolCall } from './types/index.js';
6
6
  import { ensureStateRegistered } from './module-registry.js';
7
- import { rfc005TextOf, toolResultDataToHistoryString } from './tool-result-history.js';
7
+ import { classifyBlock } from './mcpl/references.js';
8
+ import { resultClaimsOf, rfc005TextOf, toolResultDataToHistoryString } from './tool-result-history.js';
8
9
  import { safeSlice } from './safe-slice.js';
9
10
 
10
11
  export const TOOL_RESULT_GUARD_NOTICE = 'Tool result withheld by the guard. The tool has already executed.';
@@ -90,7 +91,9 @@ export function withheldResultNotice(spill: WithheldSpill, auditDurable: boolean
90
91
  * inline cap or less (storeResults' keptCap), they're cut, with a note that
91
92
  * says so and where the whole result is, since a later field can then be cut
92
93
  * away entirely. Undefined when it keeps nothing, as for a failed result
93
- * (agent-framework #277). A write's file
94
+ * (agent-framework #277), one whose claims can't be read whole
95
+ * (resultClaimsOf), or one whose data is MCP's embedded resource, whose
96
+ * fields can't be kept apart from its contents or its uri. A write's file
94
97
  * list is evidence of what it did, rarely what drew a refusal, and keeping it
95
98
  * is what shows a clobbered file when the receipt is withheld.
96
99
  */
@@ -99,13 +102,23 @@ export function keptWhenWithheld(result: CompletedToolCall['result'], maxChars:
99
102
  if (!result.success || result.isError || !keys?.length || !data || typeof data !== 'object' || Array.isArray(data)) {
100
103
  return undefined;
101
104
  }
105
+ // MCP's embedded resource is its contents: shown as text, it's the very
106
+ // body the guard withheld, and a field picked out of it, such as
107
+ // `resource`, is data that shows its uri. So it keeps nothing.
108
+ if (classifyBlock(data).kind === 'embedded') return undefined;
102
109
  const fields = data as Record<string, unknown>;
103
110
  const kept = Object.fromEntries(keys.filter((key) => Object.hasOwn(fields, key)).map((key) => [key, fields[key]]));
104
111
  if (Object.keys(kept).length === 0) return undefined;
105
112
  // Data that RFC-005 shows as text, such as a reference's stub, keeps that
106
113
  // text: picked out of it, a kept uri would lose the type that makes it a
107
114
  // reference, and be written out of the stub that hides it.
108
- const whole = rfc005TextOf(fields) ?? toolResultDataToHistoryString(kept);
115
+ // The kept fields are shown as the whole result claims (resultClaimsOf), so
116
+ // a never elsewhere in it still withholds a kept link's uri. Where a value
117
+ // stops that walk, such as a getter that throws, which the audit's JSON
118
+ // doesn't read, what the rest claims is unknown, and nothing is kept.
119
+ const claims = resultClaimsOf(fields);
120
+ if (claims === null) return undefined;
121
+ const whole = rfc005TextOf(fields) ?? toolResultDataToHistoryString(kept, undefined, { claims });
109
122
  if (!maxChars || whole.length <= maxChars) return whole;
110
123
  // Never half a surrogate pair: the stub goes to the provider as text, so
111
124
  // the cut can be one character short of the cap.
@@ -500,7 +513,7 @@ export class ToolResultGuard {
500
513
  /**
501
514
  * Turn a withheld batch's notices into stubs that say where each original
502
515
  * is (agent-framework #277): written to the workspace when it has a
503
- * writable mount without an include to take it, else kept in the audit
516
+ * writable mount without an include, not configured direct, to take it, else kept in the audit
504
517
  * record. A stub also keeps the file
505
518
  * lists its tool marked. Its own unedited placeholder is
506
519
  * the only thing it edits: an operator's later edit, removal or rollback
@@ -599,8 +612,8 @@ export class ToolResultGuard {
599
612
  if (block.type !== 'tool_result') return [];
600
613
  if (thrown.has(index)) return [{ toolUseId: block.toolUseId, error: thrown.get(index)! }];
601
614
  const spill = spills.get(index) ?? null;
602
- // No file was written: no writable mount without an include,
603
- // or the framework was stopping.
615
+ // No file was written: no writable mount without an include
616
+ // that isn't configured direct, or the framework was stopping.
604
617
  if (spill === null) return [{ toolUseId: block.toolUseId, skipped: 'no_workspace_file' }];
605
618
  if ('skipped' in spill) return [{ toolUseId: block.toolUseId, skipped: spill.skipped }];
606
619
  return [{ toolUseId: block.toolUseId, path: spill.path,