@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
@@ -5,10 +5,14 @@
5
5
  * what changed; and the hub that holds every server's controller for the
6
6
  * meta-tools (toolsets-module.ts) and for reminders.
7
7
  *
8
- * Hint changes apply as their message is read (RFC §6.1): the connection's
9
- * read tap hands the controller every result of a hinted request (awaited or
10
- * not), every push/event and every tools/hints, of the current transport,
11
- * synchronously and in arrival order, before anything routes the message.
8
+ * Hint changes apply as their message is read (RFC §6.1); a static↔dynamic
9
+ * move takes effect when a tools/list states it (§4.1), and the model's tool
10
+ * list shows it at its next rebuild; until then the tool is called at either
11
+ * place (§4.2). The connection's read tap hands
12
+ * the controller every result of a hinted request (awaited or not, while no
13
+ * more than 1,000 hinted requests await an answer), each tools/list whole, every push/event and every tools/hints, of the
14
+ * current transport, synchronously and in arrival order, before anything
15
+ * routes the message or anything read after it is handled.
12
16
  * What a message's changes did to the model's view is kept by the object
13
17
  * that carried them (`readChanges`), so whoever later handles that object
14
18
  * (the tool result, the push, the meta-tool) renders it next to the content.
@@ -20,12 +24,14 @@
20
24
  * which the toolsets module gathers at its next start as a stored
21
25
  * `afterUser` injection.
22
26
  */
23
- import type { McplServerConnection, HintRead } from './server-connection.js';
27
+ import { McplRequestError, type McplServerConnection, type HintRead } from './server-connection.js';
24
28
  import type { McplServerConfig } from './types.js';
25
29
  import { isToolAllowed } from './tool-policy.js';
30
+ import { redactServerText } from './protocol-family.js';
31
+ import { logDetail, logValueRedacted } from './log-value.js';
26
32
  import {
27
- ToolPresence, emptyChange, isEmptyChange, joinChanges, keyOf,
28
- type ChangeSet, type MetaToolNames,
33
+ ToolPresence, emptyChange, isEmptyChange, keyOf,
34
+ type ChangeSet, type MetaToolNames, type Presence,
29
35
  } from './tool-presence.js';
30
36
  import type { HintedTool } from './tool-signature.js';
31
37
 
@@ -38,7 +44,7 @@ export const META_TOOL_NAMES: MetaToolNames = {
38
44
  close: `${TOOLSETS_MODULE}--close`,
39
45
  };
40
46
 
41
- /** How long an open, close or call is waited for. A late result still applies when it's read. */
47
+ /** How long an open or close is waited for. A late result still applies when it's read. */
42
48
  export const TOOLSET_REQUEST_TIMEOUT_MS = 60_000;
43
49
  /** A toolsets/list after a (re)connect or in a recovery. */
44
50
  const LIST_TIMEOUT_MS = 15_000;
@@ -54,16 +60,24 @@ export interface ToolsetHostDeps {
54
60
  /** A standalone notice into the conversation: a tools/hints change, a late
55
61
  * result's, a reconnect's adoption, a recovery's. `invite`: it suggests an
56
62
  * open. The host applies its usual rules for events. */
57
- notify(serverId: string, text: string, opts: { invite: boolean }): void;
63
+ notify(serverId: string, text: string, opts: { invite: boolean }): boolean;
64
+ /** Whether the server's changes may be put into the conversation unasked
65
+ * (its `pushEvents` grant): notices and reminders. */
66
+ canNotify(serverId: string): boolean;
58
67
  /** The operator's log. */
59
68
  log(line: string): void;
60
69
  /** How a static tool is named in the tool list (`prefix--tool`). */
61
70
  listedName(serverId: string, tool: string): string;
71
+ /** Where the model's published tool list last put a server's tool:
72
+ * offered in it (static), listed but not offered (dynamic), or undefined
73
+ * where it isn't published. RFC §4.2: until its move applies, a tool
74
+ * keeps working where it is. */
75
+ published?(serverId: string, tool: string): Presence | undefined;
62
76
  }
63
77
 
64
78
  /** One server's RFC-014 state and traffic. Lives as long as the server's
65
- * configuration, across reconnects and restarts (its `ended` ids and what
66
- * the model was shown carry over). */
79
+ * configuration, across reconnects and the server's restarts (its `ended`
80
+ * ids and what the model was shown carry over), in memory only. */
67
81
  export class ToolsetController {
68
82
  readonly presence: ToolPresence;
69
83
  private connection: McplServerConnection | null = null;
@@ -71,16 +85,10 @@ export class ToolsetController {
71
85
  private epoch = -1;
72
86
  /** What applying a message's hints changed, by the object that carried them. */
73
87
  private readonly readChanges = new WeakMap<object, ChangeSet>();
74
- /** Results whose change no one has rendered yet (tellUnlessClaimed). */
75
- private readonly unclaimed = new Set<object>();
88
+ /** Results whose change no one has taken yet, in the order they were read, with their method (tellUnlessClaimed). */
89
+ private readonly unclaimed = new Map<object, string>();
76
90
  /** eventIds of pushes read on this transport (bounded): a repeat's hints are ignored (RFC §6.1). */
77
91
  private seenEvents = new Set<string>();
78
- /** A paged tools/list being read. */
79
- private listPages: HintedTool[] | null = null;
80
- /** Lists this controller asked for itself, whose change it delivers. */
81
- private ownLists = 0;
82
- /** What lists read while one of its own was out changed, for it to tell. */
83
- private ownListChange: ChangeSet | null = null;
84
92
  private recoveryTimer: ReturnType<typeof setTimeout> | null = null;
85
93
  private recovering = false;
86
94
  private recoverAgain = false;
@@ -95,6 +103,7 @@ export class ToolsetController {
95
103
  META_TOOL_NAMES,
96
104
  (tool) => isToolAllowed(tool, this.config),
97
105
  (tool) => deps.listedName(serverId, tool),
106
+ (tool) => deps.published?.(serverId, tool),
98
107
  );
99
108
  }
100
109
 
@@ -102,6 +111,14 @@ export class ToolsetController {
102
111
  this.deps.log(`${this.serverId}: ${line}`);
103
112
  }
104
113
 
114
+ private field(value: unknown): string {
115
+ return logValueRedacted(value, (text) => redactServerText(text, this.config));
116
+ }
117
+
118
+ private failure(error: unknown): string {
119
+ return logDetail(redactServerText(error instanceof Error ? error.message : String(error), this.config));
120
+ }
121
+
105
122
  /** A restart can bring a new configuration under the same id. */
106
123
  setConfig(config: McplServerConfig): void {
107
124
  this.config = config;
@@ -118,6 +135,10 @@ export class ToolsetController {
118
135
  connection.setReadTap((read, epoch) => this.onRead(connection, read, epoch));
119
136
  }
120
137
 
138
+ release(): void {
139
+ if (this.connection) this.detach(this.connection);
140
+ }
141
+
121
142
  /** The server's connection is gone for good (disconnected, removed). */
122
143
  detach(connection: McplServerConnection): void {
123
144
  if (this.connection !== connection) return;
@@ -139,7 +160,7 @@ export class ToolsetController {
139
160
  /** The live connection, for a request. */
140
161
  private live(): McplServerConnection {
141
162
  const c = this.connection;
142
- if (!c || !c.isConnected) throw new Error(`${this.serverId} is not connected right now; try again later`);
163
+ if (!c || !c.isConnected) throw new McplRequestError(`${this.serverId} is not connected right now; try again later`, 'not-sent');
143
164
  this.sync(c.transportEpoch, c);
144
165
  return c;
145
166
  }
@@ -153,7 +174,6 @@ export class ToolsetController {
153
174
  if (epoch === this.epoch) return;
154
175
  this.epoch = epoch;
155
176
  this.seenEvents = new Set();
156
- this.listPages = null;
157
177
  this.presence.beginConnection(connection.toolsetDeclarations, true);
158
178
  }
159
179
 
@@ -163,7 +183,7 @@ export class ToolsetController {
163
183
  if (connection !== this.connection) return;
164
184
  this.sync(epoch, connection);
165
185
  if (read.kind === 'result') {
166
- this.readResult(read.method, read.params, read.result as Record<string, unknown>, read.awaited);
186
+ this.readResult(read.method, read.params, read.result as Record<string, unknown>, read.awaited, read.straddled === true);
167
187
  return;
168
188
  }
169
189
  if (!this.presence.active || !isObj(read.params)) return;
@@ -178,7 +198,7 @@ export class ToolsetController {
178
198
  // RFC §7: a push carrying hints is never coalesced; when its changes
179
199
  // would apply is undefined, so they're ignored and the state re-read.
180
200
  if (p.coalesce !== undefined && p.coalesce !== null) {
181
- this.log(`push ${String(p.eventId ?? '')} carries hints and coalesce (RFC-014 §7 forbids it): its hints are ignored; recovering`);
201
+ this.log(`push ${this.field(p.eventId ?? '')} carries hints and coalesce (RFC-014 §7 forbids it): its hints are ignored; recovering`);
182
202
  this.scheduleRecovery();
183
203
  return;
184
204
  }
@@ -186,32 +206,29 @@ export class ToolsetController {
186
206
  const eventId = typeof p.eventId === 'string' || typeof p.eventId === 'number' ? String(p.eventId) : '';
187
207
  if (eventId) {
188
208
  if (this.seenEvents.has(eventId)) {
189
- this.log(`push ${eventId} was read before: its hints are ignored`);
209
+ this.log(`push ${this.field(eventId)} was read before: its hints are ignored`);
190
210
  return;
191
211
  }
192
212
  this.seenEvents.add(eventId);
193
213
  if (this.seenEvents.size > 1000) this.seenEvents.delete(this.seenEvents.values().next().value!);
194
214
  }
195
215
  // Applied now, before (and whatever) the host decides about the push.
196
- this.readChanges.set(p, this.applyHints(p.hints, 'push'));
216
+ const cs = this.applyHints(p.hints, 'push');
217
+ this.readChanges.set(p, cs);
218
+ this.tellUnlessClaimed(p, cs, 'push/event');
197
219
  }
198
220
 
199
- private readResult(method: string, params: Record<string, unknown>, res: Record<string, unknown>, awaited: boolean): void {
221
+ private readResult(method: string, params: Record<string, unknown>, res: Record<string, unknown>, awaited: boolean, straddled: boolean): void {
200
222
  let cs: ChangeSet;
201
223
  switch (method) {
202
224
  case 'tools/list': {
203
- const page = Array.isArray(res.tools) ? (res.tools as HintedTool[]) : [];
204
- // A first page (no cursor) starts the list; a page with no next cursor ends it.
205
- if (params.cursor === undefined || this.listPages === null) this.listPages = [];
206
- this.listPages.push(...page);
207
- if (res.nextCursor !== undefined && res.nextCursor !== null) return;
208
- const tools = this.listPages;
209
- this.listPages = null;
210
- cs = this.readToolList(tools, res);
211
- // The framework's own refresh (a list change, a reconnect) told here;
212
- // a list this controller asked for is told by whoever asked.
213
- if (this.ownLists === 0) this.deliver(cs);
214
- else this.ownListChange = joinChanges(this.ownListChange ?? emptyChange(), cs);
225
+ if (!Array.isArray(res.tools)) return;
226
+ cs = this.readToolList(res.tools as HintedTool[], res);
227
+ if (straddled && !this.recovering) {
228
+ this.log('tools/list pages carried different revs, so its earlier pages are older than changes read between them: recovering');
229
+ this.scheduleRecovery();
230
+ }
231
+ this.deliver(cs);
215
232
  return;
216
233
  }
217
234
  case 'toolsets/list':
@@ -259,9 +276,9 @@ export class ToolsetController {
259
276
  private applyHints(h: unknown, source: string): ChangeSet {
260
277
  if (h === undefined || !this.presence.active) return emptyChange();
261
278
  const before = this.presence.view();
262
- const last = this.presence.rev;
279
+ const last = this.field(this.presence.rev);
263
280
  const r = this.presence.apply(h);
264
- const rev = String((h as { rev?: unknown } | null)?.rev);
281
+ const rev = this.field((h as { rev?: unknown } | null)?.rev);
265
282
  if (r.behind) {
266
283
  this.log(`hint change rev ${rev} (${source}) is not after rev ${last}: ignored; recovering from tools/list and toolsets/list`);
267
284
  this.scheduleRecovery();
@@ -271,6 +288,10 @@ export class ToolsetController {
271
288
  } else if (!r.applied) {
272
289
  this.log(`a malformed hint change (${source}) was ignored`);
273
290
  }
291
+ if (r.unlisted) {
292
+ this.log(`hint change rev ${rev} (${source}) names a tool tools/list hasn't listed: recovering from tools/list and toolsets/list`);
293
+ this.scheduleRecovery();
294
+ }
274
295
  const cs = this.presence.diff(before, this.presence.view());
275
296
  cs.suggest.push(...r.suggest);
276
297
  return cs;
@@ -281,7 +302,7 @@ export class ToolsetController {
281
302
  const before = this.presence.view();
282
303
  this.presence.setTools(tools);
283
304
  if (this.presence.active && this.presence.noteListRev(isObj(last._meta) ? last._meta['mcpl/rev'] : undefined)) {
284
- this.log(`tools/list was written after rev ${String(isObj(last._meta) ? last._meta['mcpl/rev'] : '')}, past the last rev read: a message was lost; recovering`);
305
+ this.log(`tools/list was written after rev ${this.field(isObj(last._meta) ? last._meta['mcpl/rev'] : '')}, past the last rev read: a message was lost; recovering`);
285
306
  this.scheduleRecovery();
286
307
  }
287
308
  return this.presence.diff(before, this.presence.view());
@@ -290,37 +311,76 @@ export class ToolsetController {
290
311
  /** toolsets/list, as read: a full statement of dynamic-tool state under its own rev (RFC §5.3, §6.1). */
291
312
  private readToolsetList(res: Record<string, unknown>): ChangeSet {
292
313
  if (!this.presence.active) return emptyChange();
314
+ if (!Array.isArray(res.open)) {
315
+ this.log('a toolsets/list result with no open list was ignored');
316
+ return emptyChange();
317
+ }
293
318
  const before = this.presence.view();
294
- const { lost, ended, suggest, skipped } = this.presence.adopt(res);
319
+ const { lost, ended, suggest, skipped, unlisted } = this.presence.adopt(res);
295
320
  if (skipped && !this.recovering) {
296
321
  // Per-instance state is restated in full; what else was lost (presence) comes from tools/list.
297
322
  this.log('toolsets/list skipped a rev: re-reading tools/list');
298
323
  this.scheduleRecovery();
299
324
  }
325
+ if (unlisted && !this.recovering) {
326
+ this.log('toolsets/list names a tool tools/list hasn\'t listed: re-reading tools/list');
327
+ this.scheduleRecovery();
328
+ }
300
329
  const cs = this.presence.diff(before, this.presence.view());
301
330
  cs.suggest.push(...suggest);
302
331
  // A lost toolset gets its note, not also a "closed" line.
303
332
  cs.changes = cs.changes.filter((c) => !lost.some((o) => keyOf(o.toolset, o.instance) === c.key));
304
- cs.notes.push(...this.presence.adoptionNotes(lost, ended));
333
+ const told = this.presence.adoptionNotes(lost, ended);
334
+ cs.notes.push(...told.notes);
335
+ if (told.ended.length) cs.ended = [...(cs.ended ?? []), ...told.ended];
305
336
  return cs;
306
337
  }
307
338
 
308
- /** A change with nothing else to ride on, told as a notice. */
339
+ /** A change with nothing else to ride on, told as a notice, after every result's change still unclaimed. */
309
340
  private deliver(cs: ChangeSet): void {
310
- if (isEmptyChange(cs)) return;
311
- const text = this.presence.render(cs);
312
- if (!text) return;
313
- this.deps.notify(this.serverId, text, { invite: cs.suggest.some((s) => s.action === 'open') });
341
+ this.tellUnclaimed();
342
+ this.notice(cs);
343
+ }
344
+
345
+ /**
346
+ * Unclaimed carriers' changes, told as notices in the order they were read,
347
+ * through `upTo` when given. When no notice can be written, a push keeps its
348
+ * change for the framework's push path, which takes it when the push gets
349
+ * there; a result's is dropped, whether or not its caller is still to
350
+ * resume.
351
+ */
352
+ private tellUnclaimed(upTo?: object): void {
353
+ for (const [res, method] of [...this.unclaimed]) {
354
+ this.unclaimed.delete(res);
355
+ const cs = this.readChanges.get(res) ?? emptyChange();
356
+ const sent = this.notice(cs);
357
+ if (sent !== 'refused' || method !== 'push/event') this.readChanges.delete(res);
358
+ if (sent === 'told') this.log(`${method}'s tool change wasn't shown with it: told as a notice`);
359
+ if (res === upTo) return;
360
+ }
361
+ }
362
+
363
+ /** Whether a notice was written, there was nothing left to tell (`empty`), or one couldn't be written (`refused`). */
364
+ private notice(cs: ChangeSet): 'told' | 'empty' | 'refused' {
365
+ if (isEmptyChange(cs)) return 'empty';
366
+ const text = this.presence.render(cs, { mark: false });
367
+ if (!text) return 'empty';
368
+ if (!this.deps.notify(this.serverId, text, { invite: cs.suggest.some((s) => s.action === 'open') })) return 'refused';
369
+ this.presence.markTold(cs.ended);
370
+ this.presence.markShown(cs);
371
+ return 'told';
314
372
  }
315
373
 
316
374
  /**
317
375
  * A dynamic tool called by its namespaced name, as if it were listed (a
318
376
  * model imitating an earlier call, a module): refused, so every call into
319
377
  * a toolset goes through act, which knows its instance and gates closed
320
- * groups (RFC §5.4, §5.5). Null for any other tool.
378
+ * groups (RFC §5.4, §5.5). Null for any other tool, including one the tool
379
+ * list last published or the server's latest tools/list puts as static
380
+ * (shownAs: RFC §4.2, until its move applies).
321
381
  */
322
382
  dynamicCallRefusal(tool: string, calledAs: string): string | null {
323
- if (!this.presence.active || !this.presence.tool(tool) || this.presence.presenceOf(tool) !== 'dynamic') return null;
383
+ if (!this.presence.active || !this.presence.tool(tool) || this.presence.shownAs(tool, 'static')) return null;
324
384
  return `There's no tool "${calledAs}" in your tool list, so nothing ran: ${tool} is a dynamic tool of ${this.serverId}, ` +
325
385
  `called with ${META_TOOL_NAMES.act} {server:"${this.serverId}", tool:"${tool}", args}. ${this.presence.toolStatus(tool)}.`;
326
386
  }
@@ -332,35 +392,56 @@ export class ToolsetController {
332
392
  * run (the next macrotask), such as the result of a call the framework makes
333
393
  * itself (a reaction, a send, an accounted side effect), is told as a notice
334
394
  * instead, as a standalone tools/hints is (RFC §6.1: every change reaches the
335
- * agent once).
395
+ * agent once), or dropped as tellUnclaimed says when none can be written.
336
396
  */
337
397
  private tellUnlessClaimed(res: object, cs: ChangeSet, method: string): void {
338
398
  if (isEmptyChange(cs)) return;
339
- this.unclaimed.add(res);
399
+ this.unclaimed.set(res, method);
340
400
  setImmediate(() => {
341
- if (!this.unclaimed.delete(res)) return;
342
- this.log(`${method} result's tool change wasn't shown with it: told as a notice`);
343
- this.deliver(cs);
401
+ if (this.unclaimed.has(res)) this.tellUnclaimed(res);
344
402
  });
345
403
  }
346
404
 
347
- /** What applying `carrier`'s hints changed (a tool result, a push's params),
348
- * rendered, for its handler to show with it; '' for nothing. Taking it
349
- * means it's told there, not as a notice. */
350
- changeText(carrier: unknown): string {
351
- if (!isObj(carrier)) return '';
352
- this.unclaimed.delete(carrier);
353
- const cs = this.readChanges.get(carrier);
354
- return cs ? this.presence.render(cs) : '';
355
- }
356
405
 
357
- /** The change a carrier's hints made, or an empty one; taken as changeText's is. */
358
- changeOf(carrier: unknown): ChangeSet {
406
+ /**
407
+ * The change a carrier's hints made (a tool result, a push's params), taken
408
+ * by a caller that shows it to an agent with the carrier: it isn't told as a
409
+ * notice, and the caller says when it has reached an agent (told). Call it
410
+ * synchronously as the carrier is read, before the next macrotask, when an
411
+ * unclaimed carrier's change goes out as a notice instead (tellUnclaimed
412
+ * says what happens when none can be written). A change already gone that
413
+ * way is empty here. Unclaimed carriers read before this one
414
+ * are told first, as notices, so no later change is shown before them.
415
+ */
416
+ takeChange(carrier: unknown): ChangeSet {
359
417
  if (!isObj(carrier)) return emptyChange();
418
+ if (this.unclaimed.has(carrier)) {
419
+ let before: object | undefined;
420
+ for (const res of this.unclaimed.keys()) {
421
+ if (res === carrier) break;
422
+ before = res;
423
+ }
424
+ if (before) this.tellUnclaimed(before);
425
+ }
360
426
  this.unclaimed.delete(carrier);
361
427
  return this.readChanges.get(carrier) ?? emptyChange();
362
428
  }
363
429
 
430
+ /** A change its taker showed to an agent: its ended records count as told, and the definitions it describes as shown, unless the taker showed it without its blocks (`blocksShown: false`). */
431
+ told(cs: ChangeSet): void {
432
+ this.presence.markTold(cs.ended);
433
+ if (cs.blocksShown !== false) this.presence.markShown(cs);
434
+ }
435
+
436
+ /** A change its taker couldn't show, told as a notice instead, or dropped when none can be written. */
437
+ tell(cs: ChangeSet): void {
438
+ this.notice(cs);
439
+ }
440
+
441
+ private changeOf(carrier: object): ChangeSet {
442
+ return this.readChanges.get(carrier) ?? emptyChange();
443
+ }
444
+
364
445
  // ── recovery (RFC §6.1) ──
365
446
 
366
447
  private scheduleRecovery(): void {
@@ -380,19 +461,23 @@ export class ToolsetController {
380
461
  /**
381
462
  * Recovering: re-read tools/list (presence, declared definitions), then
382
463
  * toolsets/list (dynamic-tool state in full), so every tool the statement
383
- * names is already known; each applies where it's read. What changed is
384
- * told. (stdio and WebSocket deliver in order, so no early rev is held.)
464
+ * names is already known, unless tools/list failed: a tool the last list
465
+ * lacks then stays unknown until a later change or list starts a recovery.
466
+ * Each applies where it's read. What
467
+ * changed is told. (stdio and WebSocket deliver in order, so no early rev is
468
+ * held.)
385
469
  */
386
470
  private async recover(): Promise<void> {
387
471
  const connection = this.connection;
388
472
  if (!connection || !connection.isConnected) return;
389
473
  this.recovering = true;
390
474
  try {
391
- const tools = await this.ownToolList(connection);
475
+ await this.ownToolList(connection);
476
+ // RFC §6.1: toolsets/list after tools/list, so every tool it names is known, unless tools/list failed.
392
477
  if (connection !== this.connection) return;
393
478
  const lists = await this.ownToolsetList(connection, LIST_TIMEOUT_MS, 'recovery');
394
479
  if (connection !== this.connection) return;
395
- this.deliver(joinChanges(tools, lists));
480
+ this.deliver(lists);
396
481
  } finally {
397
482
  this.recovering = false;
398
483
  if (this.recoverAgain) {
@@ -402,29 +487,21 @@ export class ToolsetController {
402
487
  }
403
488
  }
404
489
 
405
- private async ownToolList(connection: McplServerConnection): Promise<ChangeSet> {
406
- if (this.ownLists++ === 0) this.ownListChange = null;
490
+ /** The tap applies the list and tells its change as it's read. */
491
+ private async ownToolList(connection: McplServerConnection): Promise<void> {
407
492
  try {
408
- // The tap applied each page as it was read, and kept what the whole
409
- // list changed (ownListChange).
410
493
  await connection.sendToolsList();
411
494
  } catch (error) {
412
- this.log(`tools/list failed: ${error instanceof Error ? error.message : String(error)}`);
413
- } finally {
414
- this.ownLists--;
495
+ this.log(`tools/list failed: ${this.failure(error)}`);
415
496
  }
416
- const collected = this.ownListChange ?? emptyChange();
417
- if (this.ownLists === 0) this.ownListChange = null;
418
- else this.ownListChange = emptyChange();
419
- return collected;
420
497
  }
421
498
 
422
499
  private async ownToolsetList(connection: McplServerConnection, timeoutMs: number, why: string): Promise<ChangeSet> {
423
500
  try {
424
501
  const r = await connection.sendToolsetsList(timeoutMs);
425
- return this.changeOf(r);
502
+ return this.takeChange(r);
426
503
  } catch (error) {
427
- this.log(`toolsets/list (${why}) failed: ${error instanceof Error ? error.message : String(error)}`);
504
+ this.log(`toolsets/list (${why}) failed: ${this.failure(error)}`);
428
505
  return emptyChange();
429
506
  }
430
507
  }
@@ -441,35 +518,41 @@ export class ToolsetController {
441
518
  this.sync(connection.transportEpoch, connection);
442
519
  if (!this.presence.active) return;
443
520
  const epoch = connection.transportEpoch;
444
- const tools = await this.ownToolList(connection);
521
+ await this.ownToolList(connection);
445
522
  if (connection !== this.connection || connection.transportEpoch !== epoch) return;
446
523
  const lists = await this.ownToolsetList(connection, LIST_TIMEOUT_MS, 'after connecting');
447
524
  if (connection !== this.connection || connection.transportEpoch !== epoch) return;
448
- this.deliver(joinChanges(tools, lists));
525
+ this.deliver(lists);
449
526
  }
450
527
 
451
528
  /**
452
529
  * Re-read what the server holds open (titles, visibility) without telling
453
- * it: for look, which restates everything anyway. Returns notes for what
454
- * the server no longer holds.
530
+ * it, for look. Returns what the re-read changed, taken from the notice it
531
+ * would otherwise get when `shown` (its result reaches an agent): look then
532
+ * either shows it (told) or hands it back (tell). Otherwise the change is
533
+ * left to its notice, and returned to be shown to the caller alone. Null
534
+ * when nothing was read.
455
535
  */
456
- async refreshOpen(): Promise<string[]> {
457
- if (!this.presence.active) return [];
536
+ async refreshOpen(opts: { shown: boolean }): Promise<ChangeSet | null> {
537
+ if (!this.presence.active) return null;
458
538
  const connection = this.connection;
459
- if (!connection || !connection.isConnected) return [];
539
+ if (!connection || !connection.isConnected) return null;
460
540
  try {
461
541
  const r = await connection.sendToolsetsList(REFRESH_TIMEOUT_MS);
462
- return this.changeOf(r).notes;
542
+ if (!isObj(r)) return null;
543
+ return opts.shown ? this.takeChange(r) : this.changeOf(r);
463
544
  } catch (error) {
464
- this.log(`toolsets/list (look) failed: ${error instanceof Error ? error.message : String(error)}`);
465
- return [];
545
+ this.log(`toolsets/list (look) failed: ${this.failure(error)}`);
546
+ return null;
466
547
  }
467
548
  }
468
549
 
469
550
  // ── the host's acts ──
470
551
 
471
- /** toolsets/open (RFC §5.2). The server's refusal rejects. */
472
- async open(toolset: string, instance: string | undefined, options: Record<string, unknown> | undefined): Promise<{
552
+ /** toolsets/open (RFC §5.2). The server's refusal rejects. Its change is
553
+ * taken when `shown` (its result reaches an agent), else left to its
554
+ * notice and returned for the caller alone; close's is the same. */
555
+ async open(toolset: string, instance: string | undefined, options: Record<string, unknown> | undefined, opts: { shown: boolean }): Promise<{
473
556
  key: string; title?: string; content: unknown[]; change: ChangeSet; adopted: boolean;
474
557
  }> {
475
558
  const connection = this.live();
@@ -477,24 +560,24 @@ export class ToolsetController {
477
560
  const res = isObj(r) ? r : {};
478
561
  const inst = typeof res.instance === 'string' && res.instance ? res.instance : instance;
479
562
  // RFC §5.2: a proposed name is used as given or the open refused.
480
- if (instance && inst !== instance) this.log(`proposed instance "${instance}" came back as "${inst}" (RFC-014 §5.2 forbids renaming): using the server's`);
563
+ if (instance && inst !== instance) this.log(`proposed instance ${this.field(instance)} came back as ${this.field(inst)} (RFC-014 §5.2 forbids renaming): using the server's`);
481
564
  const key = keyOf(toolset, this.presence.declared[toolset]?.instances ? inst : undefined);
482
565
  const title = typeof res.title === 'string' && res.title ? res.title : this.presence.openRecords().find((o) => keyOf(o.toolset, o.instance) === key)?.title;
483
566
  return {
484
567
  key,
485
568
  ...(title ? { title } : {}),
486
569
  content: Array.isArray(res.content) ? res.content : [],
487
- change: this.changeOf(res),
570
+ change: opts.shown ? this.takeChange(res) : this.changeOf(res),
488
571
  adopted: res.adopted === true,
489
572
  };
490
573
  }
491
574
 
492
575
  /** toolsets/close (RFC §5.2). */
493
- async close(toolset: string, instance: string | undefined): Promise<{ content: unknown[]; change: ChangeSet }> {
576
+ async close(toolset: string, instance: string | undefined, opts: { shown: boolean }): Promise<{ content: unknown[]; change: ChangeSet }> {
494
577
  const connection = this.live();
495
578
  const r = await connection.sendToolsetsClose({ toolset, ...(instance ? { instance } : {}) }, TOOLSET_REQUEST_TIMEOUT_MS);
496
579
  const res = isObj(r) ? r : {};
497
- return { content: Array.isArray(res.content) ? res.content : [], change: this.changeOf(res) };
580
+ return { content: Array.isArray(res.content) ? res.content : [], change: opts.shown ? this.takeChange(res) : this.changeOf(res) };
498
581
  }
499
582
 
500
583
  /** Open or close an undeclared group (RFC §5.4): the host's own act; the server isn't told. */
@@ -518,7 +601,7 @@ export class ToolsetController {
518
601
  this.presence.deactivate();
519
602
  this.log('the server withdrew toolHints: every tool is static from now on');
520
603
  const cs = this.presence.diff(before, this.presence.view());
521
- cs.notes.push(`${this.serverId} no longer presents toolsets: its tools are all in your tool list now.`);
604
+ cs.notes.push(`${this.serverId} no longer presents toolsets: its tools are called directly now, by name, not through ${META_TOOL_NAMES.act}.`);
522
605
  this.deliver(cs);
523
606
  return;
524
607
  }
@@ -532,11 +615,15 @@ export class ToolsetController {
532
615
  this.deliver(this.presence.diff(before, this.presence.view()));
533
616
  }
534
617
 
535
- /** Apply pending static↔dynamic moves (RFC §4.2) at a tool-list boundary, and say where they went. */
536
- applyMoves(): string[] {
537
- return this.presence.applyMoves().map(({ name, to }) => to === 'dynamic'
538
- ? `${name} left your tool list: it's announced when available now, and called with ${META_TOOL_NAMES.act} {server:"${this.serverId}", tool:"${name}", args}.`
539
- : `${name} is in your tool list now, as ${this.deps.listedName(this.serverId, name)}.`);
618
+ /** At a rebuild of the model's tool list (RFC §4.2), tell where the tools it moved went: `moves`, those still pending, or every one pending. A move a list has undone since the rebuild listed the server isn't told; the next rebuild publishes the undoing. */
619
+ applyMoves(moves?: Array<{ name: string; to: Presence }>): void {
620
+ const moved = this.presence.applyMoves(moves);
621
+ if (!moved.length) return;
622
+ const cs = emptyChange();
623
+ cs.notes.push(...moved.map(({ name, to }) => to === 'dynamic'
624
+ ? `${name} left your tool list: it's announced when available, and called with ${META_TOOL_NAMES.act} {server:"${this.serverId}", tool:"${name}", args}.`
625
+ : `${name} is in your tool list now, as ${this.deps.listedName(this.serverId, name)}.`));
626
+ this.deliver(cs);
540
627
  }
541
628
  }
542
629
 
@@ -565,8 +652,11 @@ export class ToolsetHub {
565
652
  return this.controllers.get(serverId);
566
653
  }
567
654
 
568
- /** A server's configuration went (removed): forget its controller. */
655
+ /** A server's configuration went (removed): its controller lets go of the
656
+ * connection it holds, and is forgotten, so a server added later under
657
+ * the same id starts from nothing. */
569
658
  forget(serverId: string): void {
659
+ this.controllers.get(serverId)?.release();
570
660
  this.controllers.delete(serverId);
571
661
  }
572
662
 
@@ -615,7 +705,7 @@ export class ToolsetHub {
615
705
  /** The reminder due for `agentName`, or null when none is. */
616
706
  reminderFor(agentName: string): string | null {
617
707
  if (!this.due.has(agentName)) return null;
618
- const parts = this.presenting().map((c) => c.presence.reminder()).filter((r): r is string => !!r);
708
+ const parts = this.presenting().filter((c) => this.deps.canNotify(c.serverId)).map((c) => c.presence.reminder()).filter((r): r is string => !!r);
619
709
  if (parts.length === 0) return null;
620
710
  return `Open toolsets (a reminder: their earlier announcements have left your context):\n${parts.join('\n')}`;
621
711
  }