@animalabs/agent-framework 0.27.0 → 0.28.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 (234) hide show
  1. package/README.md +56 -15
  2. package/dist/src/agent.d.ts +29 -2
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +69 -11
  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 +4 -2
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/auto-continue.d.ts +3 -2
  10. package/dist/src/auto-continue.d.ts.map +1 -1
  11. package/dist/src/auto-continue.js +4 -3
  12. package/dist/src/auto-continue.js.map +1 -1
  13. package/dist/src/cut-off-round.d.ts +24 -0
  14. package/dist/src/cut-off-round.d.ts.map +1 -0
  15. package/dist/src/cut-off-round.js +87 -0
  16. package/dist/src/cut-off-round.js.map +1 -0
  17. package/dist/src/framework.d.ts +304 -89
  18. package/dist/src/framework.d.ts.map +1 -1
  19. package/dist/src/framework.js +2577 -678
  20. package/dist/src/framework.js.map +1 -1
  21. package/dist/src/gate/event-gate.d.ts +99 -3
  22. package/dist/src/gate/event-gate.d.ts.map +1 -1
  23. package/dist/src/gate/event-gate.js +538 -13
  24. package/dist/src/gate/event-gate.js.map +1 -1
  25. package/dist/src/gate/index.d.ts +2 -1
  26. package/dist/src/gate/index.d.ts.map +1 -1
  27. package/dist/src/gate/index.js +1 -0
  28. package/dist/src/gate/index.js.map +1 -1
  29. package/dist/src/gate/reply-judge.d.ts +116 -0
  30. package/dist/src/gate/reply-judge.d.ts.map +1 -0
  31. package/dist/src/gate/reply-judge.js +467 -0
  32. package/dist/src/gate/reply-judge.js.map +1 -0
  33. package/dist/src/gate/types.d.ts +161 -0
  34. package/dist/src/gate/types.d.ts.map +1 -1
  35. package/dist/src/heartbeat/heartbeat.d.ts +184 -0
  36. package/dist/src/heartbeat/heartbeat.d.ts.map +1 -0
  37. package/dist/src/heartbeat/heartbeat.js +752 -0
  38. package/dist/src/heartbeat/heartbeat.js.map +1 -0
  39. package/dist/src/heartbeat/reminder-time.d.ts +5 -0
  40. package/dist/src/heartbeat/reminder-time.d.ts.map +1 -0
  41. package/dist/src/heartbeat/reminder-time.js +107 -0
  42. package/dist/src/heartbeat/reminder-time.js.map +1 -0
  43. package/dist/src/index.d.ts +4 -0
  44. package/dist/src/index.d.ts.map +1 -1
  45. package/dist/src/index.js +2 -0
  46. package/dist/src/index.js.map +1 -1
  47. package/dist/src/mcpl/capability-mask.d.ts.map +1 -1
  48. package/dist/src/mcpl/capability-mask.js +3 -2
  49. package/dist/src/mcpl/capability-mask.js.map +1 -1
  50. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  51. package/dist/src/mcpl/channel-registry.js +2 -1
  52. package/dist/src/mcpl/channel-registry.js.map +1 -1
  53. package/dist/src/mcpl/inbound-source.d.ts +26 -10
  54. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  55. package/dist/src/mcpl/inbound-source.js +40 -69
  56. package/dist/src/mcpl/inbound-source.js.map +1 -1
  57. package/dist/src/mcpl/server-connection.d.ts +18 -8
  58. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  59. package/dist/src/mcpl/server-connection.js +50 -10
  60. package/dist/src/mcpl/server-connection.js.map +1 -1
  61. package/dist/src/mcpl/tags.d.ts +9 -2
  62. package/dist/src/mcpl/tags.d.ts.map +1 -1
  63. package/dist/src/mcpl/tags.js +12 -2
  64. package/dist/src/mcpl/tags.js.map +1 -1
  65. package/dist/src/mcpl/tool-presence.d.ts +66 -14
  66. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  67. package/dist/src/mcpl/tool-presence.js +134 -65
  68. package/dist/src/mcpl/tool-presence.js.map +1 -1
  69. package/dist/src/mcpl/tool-signature.d.ts +10 -3
  70. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  71. package/dist/src/mcpl/tool-signature.js +47 -18
  72. package/dist/src/mcpl/tool-signature.js.map +1 -1
  73. package/dist/src/mcpl/toolset-host.d.ts +88 -37
  74. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  75. package/dist/src/mcpl/toolset-host.js +207 -108
  76. package/dist/src/mcpl/toolset-host.js.map +1 -1
  77. package/dist/src/mcpl/toolsets-module.d.ts +14 -11
  78. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  79. package/dist/src/mcpl/toolsets-module.js +141 -49
  80. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  81. package/dist/src/mcpl/types.d.ts +3 -1
  82. package/dist/src/mcpl/types.d.ts.map +1 -1
  83. package/dist/src/mcpl/types.js.map +1 -1
  84. package/dist/src/module-registry.d.ts +4 -1
  85. package/dist/src/module-registry.d.ts.map +1 -1
  86. package/dist/src/module-registry.js +9 -2
  87. package/dist/src/module-registry.js.map +1 -1
  88. package/dist/src/modules/history/index.d.ts.map +1 -1
  89. package/dist/src/modules/history/index.js +22 -11
  90. package/dist/src/modules/history/index.js.map +1 -1
  91. package/dist/src/modules/workspace/answers.d.ts +22 -0
  92. package/dist/src/modules/workspace/answers.d.ts.map +1 -0
  93. package/dist/src/modules/workspace/answers.js +123 -0
  94. package/dist/src/modules/workspace/answers.js.map +1 -0
  95. package/dist/src/modules/workspace/branch-intent.d.ts +5 -3
  96. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  97. package/dist/src/modules/workspace/branch-intent.js +6 -2
  98. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  99. package/dist/src/modules/workspace/direct-write.d.ts +3 -0
  100. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -1
  101. package/dist/src/modules/workspace/direct-write.js +57 -24
  102. package/dist/src/modules/workspace/direct-write.js.map +1 -1
  103. package/dist/src/modules/workspace/disk-agreement.d.ts +33 -11
  104. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  105. package/dist/src/modules/workspace/disk-agreement.js +48 -17
  106. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  107. package/dist/src/modules/workspace/edit-text.d.ts +2 -1
  108. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
  109. package/dist/src/modules/workspace/edit-text.js +4 -2
  110. package/dist/src/modules/workspace/edit-text.js.map +1 -1
  111. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  112. package/dist/src/modules/workspace/effects.js +20 -13
  113. package/dist/src/modules/workspace/effects.js.map +1 -1
  114. package/dist/src/modules/workspace/index.d.ts +62 -51
  115. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  116. package/dist/src/modules/workspace/index.js +934 -531
  117. package/dist/src/modules/workspace/index.js.map +1 -1
  118. package/dist/src/modules/workspace/observe.d.ts +75 -63
  119. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  120. package/dist/src/modules/workspace/observe.js +366 -180
  121. package/dist/src/modules/workspace/observe.js.map +1 -1
  122. package/dist/src/modules/workspace/read-page.d.ts.map +1 -1
  123. package/dist/src/modules/workspace/read-page.js +7 -1
  124. package/dist/src/modules/workspace/read-page.js.map +1 -1
  125. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  126. package/dist/src/modules/workspace/reconcile.js +26 -16
  127. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  128. package/dist/src/modules/workspace/result-text.d.ts +1 -0
  129. package/dist/src/modules/workspace/result-text.d.ts.map +1 -1
  130. package/dist/src/modules/workspace/result-text.js.map +1 -1
  131. package/dist/src/modules/workspace/sync.d.ts +9 -0
  132. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  133. package/dist/src/modules/workspace/sync.js +14 -0
  134. package/dist/src/modules/workspace/sync.js.map +1 -1
  135. package/dist/src/modules/workspace/types.d.ts +96 -43
  136. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  137. package/dist/src/modules/workspace/types.js.map +1 -1
  138. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  139. package/dist/src/modules/workspace/watcher.js +8 -3
  140. package/dist/src/modules/workspace/watcher.js.map +1 -1
  141. package/dist/src/prose-drafts.d.ts +4 -3
  142. package/dist/src/prose-drafts.d.ts.map +1 -1
  143. package/dist/src/prose-drafts.js +3 -2
  144. package/dist/src/prose-drafts.js.map +1 -1
  145. package/dist/src/reasoning-loss-notice.d.ts +23 -0
  146. package/dist/src/reasoning-loss-notice.d.ts.map +1 -0
  147. package/dist/src/reasoning-loss-notice.js +50 -0
  148. package/dist/src/reasoning-loss-notice.js.map +1 -0
  149. package/dist/src/reasoning-loss.d.ts +24 -0
  150. package/dist/src/reasoning-loss.d.ts.map +1 -0
  151. package/dist/src/reasoning-loss.js +102 -0
  152. package/dist/src/reasoning-loss.js.map +1 -0
  153. package/dist/src/send-tools.d.ts +3 -2
  154. package/dist/src/send-tools.d.ts.map +1 -1
  155. package/dist/src/send-tools.js +3 -2
  156. package/dist/src/send-tools.js.map +1 -1
  157. package/dist/src/sent-rows.js +1 -1
  158. package/dist/src/thinking-drops.d.ts +4 -11
  159. package/dist/src/thinking-drops.d.ts.map +1 -1
  160. package/dist/src/thinking-drops.js +7 -14
  161. package/dist/src/thinking-drops.js.map +1 -1
  162. package/dist/src/tool-args.d.ts +12 -0
  163. package/dist/src/tool-args.d.ts.map +1 -1
  164. package/dist/src/tool-args.js +35 -1
  165. package/dist/src/tool-args.js.map +1 -1
  166. package/dist/src/tool-result-guard.d.ts +8 -4
  167. package/dist/src/tool-result-guard.d.ts.map +1 -1
  168. package/dist/src/tool-result-guard.js +23 -11
  169. package/dist/src/tool-result-guard.js.map +1 -1
  170. package/dist/src/tune-out/tools.d.ts +4 -1
  171. package/dist/src/tune-out/tools.d.ts.map +1 -1
  172. package/dist/src/types/agent.d.ts +40 -9
  173. package/dist/src/types/agent.d.ts.map +1 -1
  174. package/dist/src/types/events.d.ts +18 -8
  175. package/dist/src/types/events.d.ts.map +1 -1
  176. package/dist/src/types/framework.d.ts +15 -0
  177. package/dist/src/types/framework.d.ts.map +1 -1
  178. package/dist/src/types/module.d.ts +5 -2
  179. package/dist/src/types/module.d.ts.map +1 -1
  180. package/dist/src/types/trace.d.ts +34 -1
  181. package/dist/src/types/trace.d.ts.map +1 -1
  182. package/package.json +3 -3
  183. package/src/agent.ts +72 -11
  184. package/src/api/server.ts +4 -2
  185. package/src/auto-continue.ts +4 -3
  186. package/src/cut-off-round.ts +98 -0
  187. package/src/framework.ts +2694 -700
  188. package/src/gate/event-gate.ts +579 -15
  189. package/src/gate/index.ts +8 -0
  190. package/src/gate/reply-judge.ts +512 -0
  191. package/src/gate/types.ts +164 -1
  192. package/src/heartbeat/heartbeat.ts +804 -0
  193. package/src/heartbeat/reminder-time.ts +107 -0
  194. package/src/index.ts +4 -0
  195. package/src/mcpl/capability-mask.ts +3 -2
  196. package/src/mcpl/channel-registry.ts +2 -1
  197. package/src/mcpl/inbound-source.ts +42 -69
  198. package/src/mcpl/server-connection.ts +56 -16
  199. package/src/mcpl/tags.ts +13 -2
  200. package/src/mcpl/tool-presence.ts +133 -67
  201. package/src/mcpl/tool-signature.ts +51 -20
  202. package/src/mcpl/toolset-host.ts +197 -107
  203. package/src/mcpl/toolsets-module.ts +143 -49
  204. package/src/mcpl/types.ts +3 -1
  205. package/src/module-registry.ts +8 -2
  206. package/src/modules/history/index.ts +22 -12
  207. package/src/modules/workspace/answers.ts +133 -0
  208. package/src/modules/workspace/branch-intent.ts +10 -5
  209. package/src/modules/workspace/direct-write.ts +58 -23
  210. package/src/modules/workspace/disk-agreement.ts +52 -21
  211. package/src/modules/workspace/edit-text.ts +7 -5
  212. package/src/modules/workspace/effects.ts +21 -12
  213. package/src/modules/workspace/index.ts +927 -541
  214. package/src/modules/workspace/observe.ts +381 -177
  215. package/src/modules/workspace/read-page.ts +7 -1
  216. package/src/modules/workspace/reconcile.ts +27 -15
  217. package/src/modules/workspace/result-text.ts +2 -0
  218. package/src/modules/workspace/sync.ts +16 -0
  219. package/src/modules/workspace/types.ts +96 -43
  220. package/src/modules/workspace/watcher.ts +8 -3
  221. package/src/prose-drafts.ts +4 -3
  222. package/src/reasoning-loss-notice.ts +62 -0
  223. package/src/reasoning-loss.ts +120 -0
  224. package/src/send-tools.ts +3 -2
  225. package/src/sent-rows.ts +1 -1
  226. package/src/thinking-drops.ts +10 -16
  227. package/src/tool-args.ts +34 -1
  228. package/src/tool-result-guard.ts +26 -11
  229. package/src/tune-out/tools.ts +4 -1
  230. package/src/types/agent.ts +40 -9
  231. package/src/types/events.ts +18 -8
  232. package/src/types/framework.ts +15 -0
  233. package/src/types/module.ts +5 -2
  234. package/src/types/trace.ts +36 -1
@@ -25,7 +25,7 @@
25
25
  *
26
26
  * Ported from mcpl-cc-bridge's presence.ts, the reference host.
27
27
  */
28
- import { objectSignature, renderToolLines, toolDigest, type HintedTool } from './tool-signature.js';
28
+ import { objectSignature, renderToolLines, toolDigest, type HintedTool, type Shown } from './tool-signature.js';
29
29
 
30
30
  export interface ToolsetDecl {
31
31
  title?: string;
@@ -69,15 +69,20 @@ export interface Change {
69
69
  /** The visible tools after the change. */
70
70
  now: HintedTool[];
71
71
  }
72
- export interface ChangeSet { changes: Change[]; suggest: Suggestion[]; notes: string[] }
72
+ export interface ChangeSet {
73
+ changes: Change[];
74
+ suggest: Suggestion[];
75
+ notes: string[];
76
+ /** Ended records' notes, by id: rendered while the id is untold, told once the set reaches an agent (markTold). */
77
+ ended?: Array<{ id: string; note: string }>;
78
+ /** false when its taker showed the change without its blocks (a look that restates the present shows the live catalog instead): told() then counts none of its definitions shown, while a notice that tells it still shows them. */
79
+ blocksShown?: false;
80
+ }
73
81
 
74
82
  export const emptyChange = (): ChangeSet => ({ changes: [], suggest: [], notes: [] });
75
- export const isEmptyChange = (c: ChangeSet): boolean => !c.changes.length && !c.suggest.length && !c.notes.length;
76
- export const joinChanges = (...sets: ChangeSet[]): ChangeSet => ({
77
- changes: sets.flatMap((s) => s.changes),
78
- suggest: sets.flatMap((s) => s.suggest),
79
- notes: sets.flatMap((s) => s.notes),
80
- });
83
+ export const isEmptyChange = (c: ChangeSet): boolean => !c.changes.length && !c.suggest.length && !c.notes.length && !c.ended?.length;
84
+ /** Whether a change to one key shows that key's tools in a block: what render prints and markShown counts as shown. */
85
+ const showsBlock = (c: Change): boolean => !c.closed && (c.opened || c.appeared.length > 0 || c.redefined.length > 0 || c.gone.length > 0);
81
86
 
82
87
  const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
83
88
  const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
@@ -90,7 +95,7 @@ const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v
90
95
  * the last + 1 (a message was lost unread): recover.
91
96
  * behind: at or below the last, not applied: recover.
92
97
  */
93
- export interface ApplyOutcome { applied: boolean; skipped: boolean; behind: boolean; suggest: Suggestion[] }
98
+ export interface ApplyOutcome { applied: boolean; skipped: boolean; behind: boolean; suggest: Suggestion[]; unlisted: boolean }
94
99
 
95
100
  /** What a hint `definition` may change (RFC §6.1): everything else stays the listed tool's. */
96
101
  interface DefOverride { description?: string; inputSchema?: Record<string, unknown>; annotations?: Record<string, unknown> }
@@ -117,10 +122,10 @@ export class ToolPresence {
117
122
  private visible = new Map<string, Set<string>>();
118
123
  /** Definition overrides from hints: per key (an instance's own, or a toolset's), per tool. */
119
124
  private defs = new Map<string, Map<string, DefOverride>>();
120
- /** `ended` ids told to the agent: once per id, across reconnects (RFC §5.3). */
125
+ /** `ended` ids told to the agent, kept while the server still reports them (RFC §5.3). */
121
126
  private toldEnded = new Set<string>();
127
+ /** Moves a tools/list made that the model's tool list doesn't show yet: told once a rebuild has published them. */
122
128
  private pendingMoves = new Map<string, Presence>();
123
- private moved = new Map<string, Presence>();
124
129
  /** Digests of definitions the model has been shown with their first sentence. */
125
130
  readonly shown = new Set<string>();
126
131
 
@@ -132,6 +137,8 @@ export class ToolPresence {
132
137
  private readonly allowed: (tool: string) => boolean = () => true,
133
138
  /** How a static tool is named in the API tool list, for `look`. */
134
139
  private readonly listedName: (tool: string) => string = (tool) => tool,
140
+ /** Where the model's published tool list last put a tool, if it's there (shownAs). */
141
+ private readonly published: (tool: string) => Presence | undefined = () => undefined,
135
142
  ) {}
136
143
 
137
144
  // ── connection lifecycle ──
@@ -166,6 +173,7 @@ export class ToolPresence {
166
173
  }
167
174
  }
168
175
  this.declared = decl;
176
+ for (const name of Object.keys(decl)) this.groups.delete(name);
169
177
  this.surface = mcpl && isObj(mcpl.surface) ? { title: str(mcpl.surface.title), summary: str(mcpl.surface.summary) } : null;
170
178
  // RFC §5.1: removing a toolset, or turning its instances on or off, ends
171
179
  // its open instances; one that becomes undeclared starts closed under the
@@ -190,21 +198,35 @@ export class ToolPresence {
190
198
  this.visible.clear();
191
199
  this.defs.clear();
192
200
  this.pendingMoves.clear();
193
- this.moved.clear();
194
201
  }
195
202
 
196
203
  /** tools/list: every definition, visible or not (RFC §4.1). A tool the host's policy refuses is left out. */
197
204
  setTools(list: HintedTool[]): void {
198
- this.tools = new Map(list
199
- .filter((t) => isObj(t) && typeof t.name === 'string' && this.allowed(t.name))
200
- .map((t) => [t.name, t]));
205
+ const seen = new Map([...this.tools.keys()].map((name) => [name, this.presenceOf(name)]));
206
+ const named = list.filter((t) => isObj(t) && typeof t.name === 'string');
207
+ this.tools = new Map();
208
+ for (const t of named) if (this.allowed(t.name) && !this.tools.has(t.name)) this.tools.set(t.name, t);
201
209
  for (const set of this.visible.values()) for (const n of [...set]) if (!this.tools.has(n)) set.delete(n);
210
+ // RFC §4.1: the list states each tool's presence, and presence follows
211
+ // it. A changed presence is a move the model's tool list shows at its
212
+ // next rebuild (§4.2; applyMoves); one the list reverses before then
213
+ // never reached that list.
214
+ for (const name of [...this.pendingMoves.keys()]) if (!this.tools.has(name)) this.pendingMoves.delete(name);
215
+ for (const name of this.active ? this.tools.keys() : []) {
216
+ const was = seen.get(name);
217
+ const listed = this.listedPresence(name);
218
+ if (this.pendingMoves.has(name)) {
219
+ if (this.pendingMoves.get(name) !== listed) this.pendingMoves.delete(name);
220
+ } else if (was !== undefined && was !== listed) {
221
+ this.pendingMoves.set(name, listed);
222
+ }
223
+ }
202
224
  // The list's `mcpl/visible` is the server's word only where nothing else
203
225
  // speaks: toolset-less dynamic tools and undeclared groups. For a declared
204
226
  // toolset it's an aggregate ("visible in some open instance"); per-instance
205
227
  // state comes from toolsets/list and hint changes (RFC §4.1, §5.3).
206
228
  for (const t of this.tools.values()) {
207
- if (this.presenceOf(t.name) !== 'dynamic') continue;
229
+ if (this.listedPresence(t.name) !== 'dynamic') continue;
208
230
  const ts = this.toolsetOf(t.name);
209
231
  if (ts && this.isDeclared(ts)) continue;
210
232
  this.setVisible(keyOf(ts ?? NO_TOOLSET), t.name, t._meta?.['mcpl/visible'] !== false);
@@ -234,12 +256,23 @@ export class ToolPresence {
234
256
  return m && typeof m['mcpl/toolset'] === 'string' && m['mcpl/toolset'] ? (m['mcpl/toolset'] as string) : undefined;
235
257
  }
236
258
 
237
- /** static unless hinted dynamic; a toolset wins over any "static" (RFC §4.1); a move applies only at a boundary (applyMoves). */
259
+ /** As the list states it: a toolset's tool is dynamic whatever a hint says (RFC §4.1). */
238
260
  presenceOf(name: string): Presence {
239
261
  if (!this.active) return 'static';
262
+ return this.listedPresence(name);
263
+ }
264
+
265
+ /** RFC §4.2: "Until it applies the move, the tool keeps working where it
266
+ * is." Whether a tool is at `place` in the tool list last published, or
267
+ * in the server's latest tools/list (its presence). Until a rebuild
268
+ * applies a move the two differ, and a call is taken at either. */
269
+ shownAs(name: string, place: Presence): boolean {
270
+ if (!this.active) return place === 'static';
271
+ return this.published(name) === place || this.listedPresence(name) === place;
272
+ }
273
+
274
+ private listedPresence(name: string): Presence {
240
275
  if (this.toolsetOf(name)) return 'dynamic';
241
- const moved = this.moved.get(name);
242
- if (moved) return moved;
243
276
  return this.tools.get(name)?._meta?.['mcpl/presence'] === 'dynamic' ? 'dynamic' : 'static';
244
277
  }
245
278
 
@@ -332,12 +365,12 @@ export class ToolPresence {
332
365
 
333
366
  /** Apply a HintChanges (RFC §6.1) from any of its carriers, as it is read (see ApplyOutcome). */
334
367
  apply(h: unknown): ApplyOutcome {
335
- const none: ApplyOutcome = { applied: false, skipped: false, behind: false, suggest: [] };
368
+ const none: ApplyOutcome = { applied: false, skipped: false, behind: false, suggest: [], unlisted: false };
336
369
  if (!isObj(h) || typeof h.rev !== 'number' || !Number.isFinite(h.rev)) return none;
337
370
  if (h.rev <= this.rev) return { ...none, behind: true };
338
371
  const skipped = h.rev !== this.rev + 1;
339
372
  this.rev = h.rev;
340
- return { ...none, applied: true, skipped, suggest: this.applyBody(h) };
373
+ return { ...none, applied: true, skipped, ...this.applyBody(h) };
341
374
  }
342
375
 
343
376
  /**
@@ -352,7 +385,8 @@ export class ToolPresence {
352
385
  return lost;
353
386
  }
354
387
 
355
- private applyBody(h: Record<string, unknown>): Suggestion[] {
388
+ private applyBody(h: Record<string, unknown>): { suggest: Suggestion[]; unlisted: boolean } {
389
+ let unlisted = false;
356
390
  for (const e of arr(h.toolsets)) {
357
391
  if (!isObj(e) || !str(e.toolset) || !this.isDeclared(e.toolset as string)) continue; // undeclared toolsets are host-local
358
392
  const ts = e.toolset as string;
@@ -366,20 +400,20 @@ export class ToolPresence {
366
400
  else if (e.state === 'closed') this.dropKey(key);
367
401
  }
368
402
  for (const e of arr(h.tools)) {
369
- if (!isObj(e) || !str(e.name) || !this.tools.has(e.name as string)) continue;
403
+ if (!isObj(e) || !str(e.name)) continue;
370
404
  const name = e.name as string;
371
- // A toolset's tool is dynamic whatever a hint says (RFC §4.1).
372
- if ((e.presence === 'static' || e.presence === 'dynamic') && !this.toolsetOf(name)) {
373
- // RFC §4.2: a move applies at a boundary the host chooses; until then the tool stays where it is.
374
- if (e.presence !== this.presenceOf(name)) this.pendingMoves.set(name, e.presence);
375
- else this.pendingMoves.delete(name);
405
+ if (!this.tools.has(name)) {
406
+ if (this.allowed(name)) unlisted = true;
407
+ continue;
376
408
  }
377
- if (isObj(e.definition) && this.presenceOf(name) === 'dynamic') {
409
+ // A hint's presence stages nothing: a hinted move takes effect when
410
+ // the server's tools/list states it (RFC §4.1), as the rebuild's own
411
+ // list does. A definition it gives is the tool's current one, kept
412
+ // whatever its presence now, for when it's shown dynamic (§6.1).
413
+ if (isObj(e.definition)) {
378
414
  const key = this.defKeyFor(name, str(e.instance));
379
- if (key !== null) {
380
- if (!this.defs.has(key)) this.defs.set(key, new Map());
381
- this.defs.get(key)!.set(name, overrideOf(e.definition));
382
- }
415
+ if (!this.defs.has(key)) this.defs.set(key, new Map());
416
+ this.defs.get(key)!.set(name, overrideOf(e.definition));
383
417
  }
384
418
  if (typeof e.visible === 'boolean') for (const key of this.keysFor(name, str(e.instance))) this.setVisible(key, name, e.visible);
385
419
  }
@@ -388,14 +422,14 @@ export class ToolPresence {
388
422
  if (!isObj(s) || !str(s.toolset) || (s.action !== 'open' && s.action !== 'close')) continue;
389
423
  suggest.push({ toolset: s.toolset as string, ...(str(s.instance) ? { instance: str(s.instance) } : {}), action: s.action, reason: str(s.reason) ?? '' });
390
424
  }
391
- return suggest;
425
+ return { suggest, unlisted };
392
426
  }
393
427
 
394
428
  /**
395
429
  * Where a definition lands (RFC §6.1): with `instance`, that instance's own;
396
430
  * without, the toolset's (for a toolset without instances, its only one's).
397
431
  */
398
- private defKeyFor(name: string, instance: string | undefined): string | null {
432
+ private defKeyFor(name: string, instance: string | undefined): string {
399
433
  const ts = this.toolsetOf(name);
400
434
  if (!ts) return keyOf(NO_TOOLSET);
401
435
  return keyOf(ts, this.declared[ts]?.instances ? instance : undefined);
@@ -445,7 +479,7 @@ export class ToolPresence {
445
479
  * Returns what was lost, what the server says ended while no connection was
446
480
  * told, its suggestions, and whether its rev skipped one.
447
481
  */
448
- adopt(result: unknown): { lost: OpenRecord[]; ended: Ended[]; suggest: Suggestion[]; skipped: boolean } {
482
+ adopt(result: unknown): { lost: OpenRecord[]; ended: Ended[]; suggest: Suggestion[]; skipped: boolean; unlisted: boolean } {
449
483
  const had = [...this.open.values()];
450
484
  // A full statement: a dynamic tool not stated visible is invisible.
451
485
  // Undeclared groups stay as the host has them open or closed.
@@ -463,9 +497,10 @@ export class ToolPresence {
463
497
  }
464
498
  let suggest: Suggestion[] = [];
465
499
  let skipped = false;
500
+ let unlisted = false;
466
501
  const h = isObj(r._meta) ? r._meta['mcpl/hints'] : undefined;
467
502
  if (isObj(h)) {
468
- suggest = this.applyBody(h);
503
+ ({ suggest, unlisted } = this.applyBody(h));
469
504
  // Its own rev, at its place in order (RFC §6.1).
470
505
  if (typeof h.rev === 'number' && Number.isFinite(h.rev)) {
471
506
  skipped = h.rev !== this.rev + 1;
@@ -477,29 +512,51 @@ export class ToolPresence {
477
512
  if (!isObj(e) || !str(e.toolset)) continue;
478
513
  ended.push({ ...(str(e.id) ? { id: str(e.id) } : {}), toolset: e.toolset as string, ...(str(e.instance) ? { instance: str(e.instance) } : {}), reason: str(e.reason) ?? '' });
479
514
  }
480
- return { lost: had.filter((o) => !this.open.has(keyOf(o.toolset, o.instance))), ended, suggest, skipped };
515
+ // RFC §5.3: a record is reported on every toolsets/list until it expires, and its id is never
516
+ // reused, so an id the server no longer reports needn't be remembered.
517
+ const reported = new Set(ended.flatMap((e) => (e.id ? [e.id] : [])));
518
+ for (const id of [...this.toldEnded]) if (!reported.has(id)) this.toldEnded.delete(id);
519
+ return { lost: had.filter((o) => !this.open.has(keyOf(o.toolset, o.instance))), ended, suggest, skipped, unlisted };
481
520
  }
482
521
 
483
522
  /**
484
523
  * What the agent should hear about an adoption: what ended while it was
485
- * away, each `ended` id once however often the server repeats it (RFC
486
- * §5.3), and what is closed now.
524
+ * away, each `ended` id until it has been told (RFC §5.3; markTold), and
525
+ * what is closed now. An ended record with an id is in `ended`, rendered
526
+ * while its id is untold.
487
527
  */
488
- adoptionNotes(lost: OpenRecord[], ended: Ended[]): string[] {
528
+ adoptionNotes(lost: OpenRecord[], ended: Ended[]): { notes: string[]; ended: Array<{ id: string; note: string }> } {
489
529
  const notes: string[] = [];
490
- const endedKeys = new Set(ended.map((e) => keyOf(e.toolset, e.instance)));
530
+ const told: Array<{ id: string; note: string }> = [];
531
+ const endedKeys = new Set<string>();
491
532
  for (const e of ended) {
533
+ if (!e.id || !this.toldEnded.has(e.id)) endedKeys.add(keyOf(e.toolset, e.instance));
534
+ const note = `${this.label(keyOf(e.toolset, e.instance))} ended${e.reason ? `: ${e.reason}` : ''}.`;
492
535
  if (e.id) {
493
- if (this.toldEnded.has(e.id)) continue;
494
- this.toldEnded.add(e.id);
495
- }
496
- notes.push(`${this.label(keyOf(e.toolset, e.instance))} ended${e.reason ? `: ${e.reason}` : ''}.`);
536
+ if (!this.toldEnded.has(e.id)) told.push({ id: e.id, note });
537
+ } else notes.push(note);
497
538
  }
498
539
  for (const o of lost) {
499
540
  if (endedKeys.has(keyOf(o.toolset, o.instance))) continue;
500
541
  notes.push(`${this.label(keyOf(o.toolset, o.instance))} is closed: ${this.server} no longer has it open. ${this.openHint(o.toolset)} starts a new one.`);
501
542
  }
502
- return notes;
543
+ return { notes, ended: told };
544
+ }
545
+
546
+ /** The agent has been told these ended records (a notice written, a result it reads); the next statement forgets any it no longer reports. */
547
+ markTold(ended: ChangeSet['ended']): void {
548
+ for (const { id } of ended ?? []) this.toldEnded.add(id);
549
+ }
550
+
551
+ /** The definitions a change set's tool blocks describe have reached the model. */
552
+ markShown(cs: ChangeSet): void {
553
+ for (const c of cs.changes) if (showsBlock(c)) for (const t of c.now) this.shown.add(toolDigest(t));
554
+ }
555
+
556
+ /** A set's ended notes whose ids are still untold, each once. */
557
+ untoldNotes(cs: ChangeSet): string[] {
558
+ const seen = new Set<string>();
559
+ return (cs.ended ?? []).filter(({ id }) => !this.toldEnded.has(id) && !seen.has(id) && !!seen.add(id)).map(({ note }) => note);
503
560
  }
504
561
 
505
562
  // ── host-made opens of undeclared groups (RFC §5.4) ──
@@ -514,15 +571,15 @@ export class ToolPresence {
514
571
 
515
572
  // ── boundaries ──
516
573
 
517
- /** Apply pending static↔dynamic moves (RFC §4.2) at a boundary the host chooses. Returns what moved. */
518
- applyMoves(): Array<{ name: string; to: Presence }> {
519
- const out: Array<{ name: string; to: Presence }> = [];
520
- for (const [name, to] of this.pendingMoves) {
521
- if (!this.tools.has(name)) continue;
522
- this.moved.set(name, to);
523
- out.push({ name, to });
524
- }
525
- this.pendingMoves.clear();
574
+ /** The static↔dynamic moves the lists made since the last rebuild, still to be shown. */
575
+ moves(): Array<{ name: string; to: Presence }> {
576
+ return [...this.pendingMoves].map(([name, to]) => ({ name, to }));
577
+ }
578
+
579
+ /** At a rebuild of the model's tool list (RFC §4.2): those of `moves` still pending, now shown. */
580
+ applyMoves(moves = this.moves()): Array<{ name: string; to: Presence }> {
581
+ const out = moves.filter(({ name, to }) => this.pendingMoves.get(name) === to);
582
+ for (const { name } of out) this.pendingMoves.delete(name);
526
583
  return out;
527
584
  }
528
585
 
@@ -550,14 +607,22 @@ export class ToolPresence {
550
607
  }
551
608
 
552
609
  /** The tool block for one key: its label, how to call, and the visible tools (new or changed ones described). */
553
- toolBlock(key: string, tools: HintedTool[], opts: { describeAll?: boolean; headed?: boolean; title?: string } = {}): string[] {
610
+ toolBlock(key: string, tools: HintedTool[], opts: { describeAll?: boolean; headed?: boolean; title?: string; seen?: Shown } = {}): string[] {
554
611
  const head = opts.headed === false ? '' : `${this.label(key)}${opts.title ? ` ${opts.title}` : ''} `;
555
612
  if (!tools.length) return [`${head}no tools available right now.`];
556
- return [`${head}tools now — ${this.callHint(key)}:`, ...renderToolLines(tools, this.shown, { describeAll: opts.describeAll })];
613
+ return [`${head}tools now — ${this.callHint(key)}:`, ...renderToolLines(tools, opts.seen ?? this.shown, { describeAll: opts.describeAll })];
557
614
  }
558
615
 
559
- /** What the model reads for a change set. `bare`: a key whose header the caller already printed. */
560
- render(cs: ChangeSet, opts: { bare?: string } = {}): string {
616
+ /**
617
+ * What the model reads for a change set. `bare`: a key whose header the
618
+ * caller already printed. `mark: false` renders without counting the
619
+ * definitions it describes as shown, for text that may not reach the model
620
+ * (markShown counts them once it has). `describeAll`: every tool with its
621
+ * first sentence, as for a result that may reach no agent.
622
+ */
623
+ render(cs: ChangeSet, opts: { bare?: string; mark?: boolean; describeAll?: boolean } = {}): string {
624
+ const local = new Set<string>();
625
+ const seen: Shown = opts.mark === false ? { has: (d) => this.shown.has(d) || local.has(d), add: (d) => { local.add(d); } } : this.shown;
561
626
  const lines: string[] = [];
562
627
  for (const c of cs.changes) {
563
628
  const bare = opts.bare === c.key;
@@ -566,8 +631,7 @@ export class ToolPresence {
566
631
  continue;
567
632
  }
568
633
  const title = (c.opened || c.retitled) && !bare ? c.title : undefined;
569
- const setChanged = c.opened || c.appeared.length > 0 || c.redefined.length > 0 || c.gone.length > 0;
570
- if (setChanged) lines.push(...this.toolBlock(c.key, c.now, { headed: !bare, title }));
634
+ if (showsBlock(c)) lines.push(...this.toolBlock(c.key, c.now, { headed: !bare, title, seen, describeAll: opts.describeAll }));
571
635
  else if (title) lines.push(`${this.label(c.key)} ${title}`);
572
636
  if (c.gone.length) lines.push(`now unavailable: ${c.gone.join(', ')}`);
573
637
  if (c.opened && !bare) {
@@ -576,7 +640,7 @@ export class ToolPresence {
576
640
  }
577
641
  }
578
642
  for (const s of cs.suggest) lines.push(this.renderSuggestion(s));
579
- lines.push(...cs.notes);
643
+ lines.push(...this.untoldNotes(cs), ...cs.notes);
580
644
  return lines.join('\n');
581
645
  }
582
646
 
@@ -605,14 +669,16 @@ export class ToolPresence {
605
669
  return lines.join('\n');
606
670
  }
607
671
 
608
- /** look naming this server: what is open (with tools) and what can be opened. */
609
- catalog(): string {
672
+ /** look naming this server: what is open (with tools) and what can be
673
+ * opened. `mark: false`, as for render, for a look whose result may
674
+ * reach no agent. */
675
+ catalog(opts: { mark?: boolean } = {}): string {
610
676
  const lines: string[] = [];
611
677
  lines.push(this.surface ? `${this.server} — ${[this.surface.title, this.surface.summary].filter(Boolean).join(': ')}` : this.server);
612
678
  const v = this.view();
613
679
  if (v.size) {
614
680
  for (const [key, e] of v) {
615
- lines.push(...this.toolBlock(key, [...e.tools.values()], { describeAll: true, title: e.title }).map((l) => ` ${l}`));
681
+ lines.push(...this.toolBlock(key, [...e.tools.values()], { describeAll: true, title: e.title, seen: opts.mark === false ? new Set<string>() : undefined }).map((l) => ` ${l}`));
616
682
  const close = this.closeHint(key);
617
683
  if (close) lines.push(` ${close} closes it.`);
618
684
  }
@@ -639,7 +705,7 @@ export class ToolPresence {
639
705
 
640
706
  /** Where a tool can be called from now, for `look` naming it. */
641
707
  toolStatus(tool: string): string {
642
- if (this.presenceOf(tool) === 'static') return `static — in your tool list as ${this.listedName(tool)}`;
708
+ if (this.presenceOf(tool) === 'static') return `static — called by name, as ${this.listedName(tool)}`;
643
709
  const where = [...this.view().entries()].filter(([, e]) => e.tools.has(tool)).map(([k]) => k);
644
710
  const ts = this.toolsetOf(tool);
645
711
  return where.length
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * How a dynamic tool reads to a model when it reaches the conversation
3
3
  * instead of the tool list (MCPL RFC-014 §8.3): a compact signature, its
4
- * first sentence the first time a definition is shown, the whole definition
5
- * on request. Also the host's own argument check against a tool's current
6
- * definition (RFC-014 §6.1: "the host checks arguments for that instance
7
- * against it").
4
+ * first sentence the first time a definition is shown, and on request its
5
+ * description, input schema and class. Also the host's own argument check
6
+ * against a tool's current definition (RFC-014 §6.1: "the host checks
7
+ * arguments for that instance against it").
8
8
  *
9
9
  * Ported from mcpl-cc-bridge's tool-render.ts and schema-check.ts, the
10
10
  * reference host of RFC-014. Dependency-free.
@@ -24,11 +24,25 @@ export interface HintedTool {
24
24
 
25
25
  const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
26
26
 
27
+ /** How deep a server's value may nest and still be written out, the same on every runtime. */
28
+ const DEEPEST = 64;
29
+
30
+ function nestsWithin(value: unknown, depth = 0): boolean {
31
+ if (value === null || typeof value !== 'object') return true;
32
+ return depth <= DEEPEST && Object.values(value).every((v) => nestsWithin(v, depth + 1));
33
+ }
34
+
35
+ /** A schema's literal value (an enum's, a const or a default) as JSON, or … where it's nested too deeply to write. */
36
+ function literal(value: unknown): string {
37
+ return nestsWithin(value) ? JSON.stringify(value) ?? 'null' : '…';
38
+ }
39
+
27
40
  function typeSig(schema: JsonSchema | undefined, depth = 0): string {
28
41
  if (!schema || typeof schema !== 'object') return 'any';
42
+ if (depth > 8) return '…';
29
43
  if (Array.isArray(schema.enum)) {
30
44
  // The enum is often the menu (a decision's options): all of it unless it is long.
31
- const vals = (schema.enum as unknown[]).map((v) => JSON.stringify(v));
45
+ const vals = (schema.enum as unknown[]).map(literal);
32
46
  if (vals.length <= 8 || vals.join('|').length <= 400) return vals.join('|');
33
47
  let shown = 0;
34
48
  let len = 0;
@@ -38,10 +52,12 @@ function typeSig(schema: JsonSchema | undefined, depth = 0): string {
38
52
  }
39
53
  return `${vals.slice(0, Math.max(shown, 4)).join('|')}|…(${vals.length} in all)`;
40
54
  }
41
- if (schema.const !== undefined) return JSON.stringify(schema.const);
55
+ if (schema.const !== undefined) return literal(schema.const);
42
56
  const alts = (schema.oneOf ?? schema.anyOf) as JsonSchema[] | undefined;
43
57
  if (Array.isArray(alts)) return alts.map((s) => typeSig(s, depth + 1)).join(' | ');
44
- const t = Array.isArray(schema.type) ? (schema.type as string[]).join('|') : (schema.type as string | undefined);
58
+ // Only text names a type: entries that aren't text are left out, and a type with none reads as no type.
59
+ const types = (Array.isArray(schema.type) ? schema.type : [schema.type]).filter((x): x is string => typeof x === 'string');
60
+ const t = types.length ? types.join('|') : undefined;
45
61
  switch (t) {
46
62
  case 'integer':
47
63
  case 'number': {
@@ -81,7 +97,7 @@ export function signature(tool: HintedTool): string {
81
97
  const props = (isObj(s.properties) ? s.properties : {}) as Record<string, JsonSchema>;
82
98
  const req = new Set(Array.isArray(s.required) ? (s.required as string[]) : []);
83
99
  const params = Object.entries(props).map(([k, v]) => {
84
- const def = isObj(v) && v.default !== undefined ? ` =${JSON.stringify(v.default)}` : '';
100
+ const def = isObj(v) && v.default !== undefined ? ` =${literal(v.default)}` : '';
85
101
  return `${k}${req.has(k) ? '' : '?'}: ${typeSig(v, 1)}${def}`;
86
102
  });
87
103
  return `${tool.name}(${params.join(', ')})`;
@@ -94,22 +110,23 @@ export function objectSignature(schema: unknown): string {
94
110
  }
95
111
 
96
112
  /** First sentence of a description, bounded. */
97
- export function firstSentence(text: string | undefined, max = 140): string {
98
- const t = (text ?? '').trim().replace(/\s+/g, ' ');
113
+ export function firstSentence(text: unknown, max = 140): string {
114
+ const t = (typeof text === 'string' ? text : '').trim().replace(/\s+/g, ' ');
99
115
  // A sentence ends at . ! ? followed by a capital, a bracket, or the end, so "e.g. \"D4\"" does not end it.
100
116
  const m = t.match(/^(.+?[.!?])(?=\s+[A-Z([]|$)/);
101
- const s = m ? m[1]! : t;
102
- return s.length > max ? s.slice(0, max - 1) + '…' : s;
117
+ const s = [...(m ? m[1]! : t)];
118
+ return s.length > max ? s.slice(0, max - 1).join('') + '…' : s.join('');
103
119
  }
104
120
 
105
- function canonicalJson(value: unknown): string {
121
+ function canonicalJson(value: unknown, depth = 0): string {
106
122
  if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
107
- if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
123
+ if (depth > DEEPEST) return '"…"';
124
+ if (Array.isArray(value)) return `[${value.map((v) => canonicalJson(v, depth + 1)).join(',')}]`;
108
125
  const obj = value as Record<string, unknown>;
109
126
  return `{${Object.keys(obj)
110
127
  .sort()
111
128
  .filter((k) => obj[k] !== undefined)
112
- .map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`)
129
+ .map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k], depth + 1)}`)
113
130
  .join(',')}}`;
114
131
  }
115
132
 
@@ -121,12 +138,15 @@ export function toolDigest(tool: HintedTool): string {
121
138
  .slice(0, 22);
122
139
  }
123
140
 
141
+ /** The definitions a model has been shown, by digest: has and add are all rendering needs. */
142
+ export type Shown = { has(digest: string): boolean; add(digest: string): void };
143
+
124
144
  /**
125
145
  * Tool lines as the model reads them. A definition the model has been shown
126
146
  * gets its signature only; a new or changed one also gets its first
127
147
  * sentence. `shown` is updated.
128
148
  */
129
- export function renderToolLines(tools: HintedTool[], shown: Set<string>, opts: { describeAll?: boolean; indent?: string } = {}): string[] {
149
+ export function renderToolLines(tools: HintedTool[], shown: Shown, opts: { describeAll?: boolean; indent?: string } = {}): string[] {
130
150
  const indent = opts.indent ?? ' ';
131
151
  return tools.map((t) => {
132
152
  const d = toolDigest(t);
@@ -137,15 +157,26 @@ export function renderToolLines(tools: HintedTool[], shown: Set<string>, opts: {
137
157
  });
138
158
  }
139
159
 
140
- /** The whole definition, for `look` naming a tool. */
160
+ /** A server's value as JSON, or a note where it's nested too deeply, or too long, to show. */
161
+ export function jsonText(value: unknown, space?: number): string {
162
+ if (!nestsWithin(value)) return '(nested too deeply to show)';
163
+ try {
164
+ return JSON.stringify(value, null, space) ?? '';
165
+ } catch {
166
+ return '(too long to show)';
167
+ }
168
+ }
169
+
170
+ /** A tool's signature, description, class and input schema, for `look` naming a tool. */
141
171
  export function renderToolDefinition(tool: HintedTool): string {
142
172
  const cls = tool._meta?.['mcpl/class'];
173
+ const classes = Array.isArray(cls) ? cls.filter((c): c is string => typeof c === 'string') : [];
143
174
  return [
144
175
  signature(tool),
145
- tool.description ?? '',
146
- ...(Array.isArray(cls) ? [`class: ${cls.join(', ')}`] : []),
176
+ typeof tool.description === 'string' ? tool.description : '',
177
+ ...(classes.length ? [`class: ${classes.join(', ')}`] : []),
147
178
  'inputSchema:',
148
- JSON.stringify(tool.inputSchema ?? {}, null, 2),
179
+ jsonText(tool.inputSchema ?? {}, 2),
149
180
  ]
150
181
  .filter((l) => l !== '')
151
182
  .join('\n');