@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
@@ -1,5 +1,34 @@
1
+ /**
2
+ * MCPL RFC-014, host side: per server, the controller that feeds the
3
+ * server's tool presence (tool-presence.ts) from what its connection reads,
4
+ * sends toolsets/open, close and list, recovers from a lost rev, and renders
5
+ * what changed; and the hub that holds every server's controller for the
6
+ * meta-tools (toolsets-module.ts) and for reminders.
7
+ *
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.
16
+ * What a message's changes did to the model's view is kept by the object
17
+ * that carried them (`readChanges`), so whoever later handles that object
18
+ * (the tool result, the push, the meta-tool) renders it next to the content.
19
+ *
20
+ * Reminders (RFC §8.3): after each compile built to send, the hub reads
21
+ * which open toolsets' markers (`[server/toolset:instance]`) the request
22
+ * carried. A marker an agent has had in view, gone from a later request
23
+ * (compressed, or out of the window), makes a reminder due for that agent,
24
+ * which the toolsets module gathers at its next start as a stored
25
+ * `afterUser` injection.
26
+ */
27
+ import { McplRequestError } from './server-connection.js';
1
28
  import { isToolAllowed } from './tool-policy.js';
2
- import { ToolPresence, emptyChange, isEmptyChange, joinChanges, keyOf, } from './tool-presence.js';
29
+ import { redactServerText } from './protocol-family.js';
30
+ import { logDetail, logValueRedacted } from './log-value.js';
31
+ import { ToolPresence, emptyChange, isEmptyChange, keyOf, } from './tool-presence.js';
3
32
  /** The meta-tools' module, whose tools are `toolsets--open` and so on. */
4
33
  export const TOOLSETS_MODULE = 'toolsets';
5
34
  export const META_TOOL_NAMES = {
@@ -8,7 +37,7 @@ export const META_TOOL_NAMES = {
8
37
  look: `${TOOLSETS_MODULE}--look`,
9
38
  close: `${TOOLSETS_MODULE}--close`,
10
39
  };
11
- /** How long an open, close or call is waited for. A late result still applies when it's read. */
40
+ /** How long an open or close is waited for. A late result still applies when it's read. */
12
41
  export const TOOLSET_REQUEST_TIMEOUT_MS = 60_000;
13
42
  /** A toolsets/list after a (re)connect or in a recovery. */
14
43
  const LIST_TIMEOUT_MS = 15_000;
@@ -19,8 +48,8 @@ const RECOVERY_DEBOUNCE_MS = 200;
19
48
  const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
20
49
  const metaHints = (res) => (isObj(res._meta) ? res._meta['mcpl/hints'] : undefined);
21
50
  /** One server's RFC-014 state and traffic. Lives as long as the server's
22
- * configuration, across reconnects and restarts (its `ended` ids and what
23
- * the model was shown carry over). */
51
+ * configuration, across reconnects and the server's restarts (its `ended`
52
+ * ids and what the model was shown carry over), in memory only. */
24
53
  export class ToolsetController {
25
54
  serverId;
26
55
  config;
@@ -31,16 +60,10 @@ export class ToolsetController {
31
60
  epoch = -1;
32
61
  /** What applying a message's hints changed, by the object that carried them. */
33
62
  readChanges = new WeakMap();
34
- /** Results whose change no one has rendered yet (tellUnlessClaimed). */
35
- unclaimed = new Set();
63
+ /** Results whose change no one has taken yet, in the order they were read, with their method (tellUnlessClaimed). */
64
+ unclaimed = new Map();
36
65
  /** eventIds of pushes read on this transport (bounded): a repeat's hints are ignored (RFC §6.1). */
37
66
  seenEvents = new Set();
38
- /** A paged tools/list being read. */
39
- listPages = null;
40
- /** Lists this controller asked for itself, whose change it delivers. */
41
- ownLists = 0;
42
- /** What lists read while one of its own was out changed, for it to tell. */
43
- ownListChange = null;
44
67
  recoveryTimer = null;
45
68
  recovering = false;
46
69
  recoverAgain = false;
@@ -48,11 +71,17 @@ export class ToolsetController {
48
71
  this.serverId = serverId;
49
72
  this.config = config;
50
73
  this.deps = deps;
51
- this.presence = new ToolPresence(serverId, META_TOOL_NAMES, (tool) => isToolAllowed(tool, this.config), (tool) => deps.listedName(serverId, tool));
74
+ this.presence = new ToolPresence(serverId, META_TOOL_NAMES, (tool) => isToolAllowed(tool, this.config), (tool) => deps.listedName(serverId, tool), (tool) => deps.published?.(serverId, tool));
52
75
  }
53
76
  log(line) {
54
77
  this.deps.log(`${this.serverId}: ${line}`);
55
78
  }
79
+ field(value) {
80
+ return logValueRedacted(value, (text) => redactServerText(text, this.config));
81
+ }
82
+ failure(error) {
83
+ return logDetail(redactServerText(error instanceof Error ? error.message : String(error), this.config));
84
+ }
56
85
  /** A restart can bring a new configuration under the same id. */
57
86
  setConfig(config) {
58
87
  this.config = config;
@@ -67,6 +96,10 @@ export class ToolsetController {
67
96
  this.connection = connection;
68
97
  connection.setReadTap((read, epoch) => this.onRead(connection, read, epoch));
69
98
  }
99
+ release() {
100
+ if (this.connection)
101
+ this.detach(this.connection);
102
+ }
70
103
  /** The server's connection is gone for good (disconnected, removed). */
71
104
  detach(connection) {
72
105
  if (this.connection !== connection)
@@ -89,7 +122,7 @@ export class ToolsetController {
89
122
  live() {
90
123
  const c = this.connection;
91
124
  if (!c || !c.isConnected)
92
- throw new Error(`${this.serverId} is not connected right now; try again later`);
125
+ throw new McplRequestError(`${this.serverId} is not connected right now; try again later`, 'not-sent');
93
126
  this.sync(c.transportEpoch, c);
94
127
  return c;
95
128
  }
@@ -103,7 +136,6 @@ export class ToolsetController {
103
136
  return;
104
137
  this.epoch = epoch;
105
138
  this.seenEvents = new Set();
106
- this.listPages = null;
107
139
  this.presence.beginConnection(connection.toolsetDeclarations, true);
108
140
  }
109
141
  // ── RFC §6.1: apply on arrival ──
@@ -112,7 +144,7 @@ export class ToolsetController {
112
144
  return;
113
145
  this.sync(epoch, connection);
114
146
  if (read.kind === 'result') {
115
- this.readResult(read.method, read.params, read.result, read.awaited);
147
+ this.readResult(read.method, read.params, read.result, read.awaited, read.straddled === true);
116
148
  return;
117
149
  }
118
150
  if (!this.presence.active || !isObj(read.params))
@@ -129,7 +161,7 @@ export class ToolsetController {
129
161
  // RFC §7: a push carrying hints is never coalesced; when its changes
130
162
  // would apply is undefined, so they're ignored and the state re-read.
131
163
  if (p.coalesce !== undefined && p.coalesce !== null) {
132
- this.log(`push ${String(p.eventId ?? '')} carries hints and coalesce (RFC-014 §7 forbids it): its hints are ignored; recovering`);
164
+ this.log(`push ${this.field(p.eventId ?? '')} carries hints and coalesce (RFC-014 §7 forbids it): its hints are ignored; recovering`);
133
165
  this.scheduleRecovery();
134
166
  return;
135
167
  }
@@ -137,7 +169,7 @@ export class ToolsetController {
137
169
  const eventId = typeof p.eventId === 'string' || typeof p.eventId === 'number' ? String(p.eventId) : '';
138
170
  if (eventId) {
139
171
  if (this.seenEvents.has(eventId)) {
140
- this.log(`push ${eventId} was read before: its hints are ignored`);
172
+ this.log(`push ${this.field(eventId)} was read before: its hints are ignored`);
141
173
  return;
142
174
  }
143
175
  this.seenEvents.add(eventId);
@@ -145,28 +177,22 @@ export class ToolsetController {
145
177
  this.seenEvents.delete(this.seenEvents.values().next().value);
146
178
  }
147
179
  // Applied now, before (and whatever) the host decides about the push.
148
- this.readChanges.set(p, this.applyHints(p.hints, 'push'));
180
+ const cs = this.applyHints(p.hints, 'push');
181
+ this.readChanges.set(p, cs);
182
+ this.tellUnlessClaimed(p, cs, 'push/event');
149
183
  }
150
- readResult(method, params, res, awaited) {
184
+ readResult(method, params, res, awaited, straddled) {
151
185
  let cs;
152
186
  switch (method) {
153
187
  case 'tools/list': {
154
- const page = Array.isArray(res.tools) ? res.tools : [];
155
- // A first page (no cursor) starts the list; a page with no next cursor ends it.
156
- if (params.cursor === undefined || this.listPages === null)
157
- this.listPages = [];
158
- this.listPages.push(...page);
159
- if (res.nextCursor !== undefined && res.nextCursor !== null)
188
+ if (!Array.isArray(res.tools))
160
189
  return;
161
- const tools = this.listPages;
162
- this.listPages = null;
163
- cs = this.readToolList(tools, res);
164
- // The framework's own refresh (a list change, a reconnect) told here;
165
- // a list this controller asked for is told by whoever asked.
166
- if (this.ownLists === 0)
167
- this.deliver(cs);
168
- else
169
- this.ownListChange = joinChanges(this.ownListChange ?? emptyChange(), cs);
190
+ cs = this.readToolList(res.tools, res);
191
+ if (straddled && !this.recovering) {
192
+ this.log('tools/list pages carried different revs, so its earlier pages are older than changes read between them: recovering');
193
+ this.scheduleRecovery();
194
+ }
195
+ this.deliver(cs);
170
196
  return;
171
197
  }
172
198
  case 'toolsets/list':
@@ -218,9 +244,9 @@ export class ToolsetController {
218
244
  if (h === undefined || !this.presence.active)
219
245
  return emptyChange();
220
246
  const before = this.presence.view();
221
- const last = this.presence.rev;
247
+ const last = this.field(this.presence.rev);
222
248
  const r = this.presence.apply(h);
223
- const rev = String(h?.rev);
249
+ const rev = this.field(h?.rev);
224
250
  if (r.behind) {
225
251
  this.log(`hint change rev ${rev} (${source}) is not after rev ${last}: ignored; recovering from tools/list and toolsets/list`);
226
252
  this.scheduleRecovery();
@@ -232,6 +258,10 @@ export class ToolsetController {
232
258
  else if (!r.applied) {
233
259
  this.log(`a malformed hint change (${source}) was ignored`);
234
260
  }
261
+ if (r.unlisted) {
262
+ this.log(`hint change rev ${rev} (${source}) names a tool tools/list hasn't listed: recovering from tools/list and toolsets/list`);
263
+ this.scheduleRecovery();
264
+ }
235
265
  const cs = this.presence.diff(before, this.presence.view());
236
266
  cs.suggest.push(...r.suggest);
237
267
  return cs;
@@ -241,7 +271,7 @@ export class ToolsetController {
241
271
  const before = this.presence.view();
242
272
  this.presence.setTools(tools);
243
273
  if (this.presence.active && this.presence.noteListRev(isObj(last._meta) ? last._meta['mcpl/rev'] : undefined)) {
244
- 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`);
274
+ 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`);
245
275
  this.scheduleRecovery();
246
276
  }
247
277
  return this.presence.diff(before, this.presence.view());
@@ -250,37 +280,79 @@ export class ToolsetController {
250
280
  readToolsetList(res) {
251
281
  if (!this.presence.active)
252
282
  return emptyChange();
283
+ if (!Array.isArray(res.open)) {
284
+ this.log('a toolsets/list result with no open list was ignored');
285
+ return emptyChange();
286
+ }
253
287
  const before = this.presence.view();
254
- const { lost, ended, suggest, skipped } = this.presence.adopt(res);
288
+ const { lost, ended, suggest, skipped, unlisted } = this.presence.adopt(res);
255
289
  if (skipped && !this.recovering) {
256
290
  // Per-instance state is restated in full; what else was lost (presence) comes from tools/list.
257
291
  this.log('toolsets/list skipped a rev: re-reading tools/list');
258
292
  this.scheduleRecovery();
259
293
  }
294
+ if (unlisted && !this.recovering) {
295
+ this.log('toolsets/list names a tool tools/list hasn\'t listed: re-reading tools/list');
296
+ this.scheduleRecovery();
297
+ }
260
298
  const cs = this.presence.diff(before, this.presence.view());
261
299
  cs.suggest.push(...suggest);
262
300
  // A lost toolset gets its note, not also a "closed" line.
263
301
  cs.changes = cs.changes.filter((c) => !lost.some((o) => keyOf(o.toolset, o.instance) === c.key));
264
- cs.notes.push(...this.presence.adoptionNotes(lost, ended));
302
+ const told = this.presence.adoptionNotes(lost, ended);
303
+ cs.notes.push(...told.notes);
304
+ if (told.ended.length)
305
+ cs.ended = [...(cs.ended ?? []), ...told.ended];
265
306
  return cs;
266
307
  }
267
- /** A change with nothing else to ride on, told as a notice. */
308
+ /** A change with nothing else to ride on, told as a notice, after every result's change still unclaimed. */
268
309
  deliver(cs) {
310
+ this.tellUnclaimed();
311
+ this.notice(cs);
312
+ }
313
+ /**
314
+ * Unclaimed carriers' changes, told as notices in the order they were read,
315
+ * through `upTo` when given. When no notice can be written, a push keeps its
316
+ * change for the framework's push path, which takes it when the push gets
317
+ * there; a result's is dropped, whether or not its caller is still to
318
+ * resume.
319
+ */
320
+ tellUnclaimed(upTo) {
321
+ for (const [res, method] of [...this.unclaimed]) {
322
+ this.unclaimed.delete(res);
323
+ const cs = this.readChanges.get(res) ?? emptyChange();
324
+ const sent = this.notice(cs);
325
+ if (sent !== 'refused' || method !== 'push/event')
326
+ this.readChanges.delete(res);
327
+ if (sent === 'told')
328
+ this.log(`${method}'s tool change wasn't shown with it: told as a notice`);
329
+ if (res === upTo)
330
+ return;
331
+ }
332
+ }
333
+ /** Whether a notice was written, there was nothing left to tell (`empty`), or one couldn't be written (`refused`). */
334
+ notice(cs) {
269
335
  if (isEmptyChange(cs))
270
- return;
271
- const text = this.presence.render(cs);
336
+ return 'empty';
337
+ const text = this.presence.render(cs, { mark: false });
272
338
  if (!text)
273
- return;
274
- this.deps.notify(this.serverId, text, { invite: cs.suggest.some((s) => s.action === 'open') });
339
+ return 'empty';
340
+ if (!this.deps.notify(this.serverId, text, { invite: cs.suggest.some((s) => s.action === 'open') }))
341
+ return 'refused';
342
+ this.presence.markTold(cs.ended);
343
+ this.presence.markShown(cs);
344
+ return 'told';
275
345
  }
276
346
  /**
277
347
  * A dynamic tool called by its namespaced name, as if it were listed (a
278
348
  * model imitating an earlier call, a module): refused, so every call into
279
349
  * a toolset goes through act, which knows its instance and gates closed
280
- * groups (RFC §5.4, §5.5). Null for any other tool.
350
+ * groups (RFC §5.4, §5.5). Null for any other tool, including one the tool
351
+ * list last published or the server's latest tools/list puts as static
352
+ * (shownAs: RFC §4.2, until its move applies).
281
353
  */
282
354
  dynamicCallRefusal(tool, calledAs) {
283
- if (!this.presence.active || !this.presence.tool(tool) || this.presence.presenceOf(tool) !== 'dynamic')
355
+ if (!this.presence.active || !this.presence.tool(tool) || this.presence.shownAs(tool, 'static'))
284
356
  return null;
285
357
  return `There's no tool "${calledAs}" in your tool list, so nothing ran: ${tool} is a dynamic tool of ${this.serverId}, ` +
286
358
  `called with ${META_TOOL_NAMES.act} {server:"${this.serverId}", tool:"${tool}", args}. ${this.presence.toolStatus(tool)}.`;
@@ -292,34 +364,54 @@ export class ToolsetController {
292
364
  * run (the next macrotask), such as the result of a call the framework makes
293
365
  * itself (a reaction, a send, an accounted side effect), is told as a notice
294
366
  * instead, as a standalone tools/hints is (RFC §6.1: every change reaches the
295
- * agent once).
367
+ * agent once), or dropped as tellUnclaimed says when none can be written.
296
368
  */
297
369
  tellUnlessClaimed(res, cs, method) {
298
370
  if (isEmptyChange(cs))
299
371
  return;
300
- this.unclaimed.add(res);
372
+ this.unclaimed.set(res, method);
301
373
  setImmediate(() => {
302
- if (!this.unclaimed.delete(res))
303
- return;
304
- this.log(`${method} result's tool change wasn't shown with it: told as a notice`);
305
- this.deliver(cs);
374
+ if (this.unclaimed.has(res))
375
+ this.tellUnclaimed(res);
306
376
  });
307
377
  }
308
- /** What applying `carrier`'s hints changed (a tool result, a push's params),
309
- * rendered, for its handler to show with it; '' for nothing. Taking it
310
- * means it's told there, not as a notice. */
311
- changeText(carrier) {
378
+ /**
379
+ * The change a carrier's hints made (a tool result, a push's params), taken
380
+ * by a caller that shows it to an agent with the carrier: it isn't told as a
381
+ * notice, and the caller says when it has reached an agent (told). Call it
382
+ * synchronously as the carrier is read, before the next macrotask, when an
383
+ * unclaimed carrier's change goes out as a notice instead (tellUnclaimed
384
+ * says what happens when none can be written). A change already gone that
385
+ * way is empty here. Unclaimed carriers read before this one
386
+ * are told first, as notices, so no later change is shown before them.
387
+ */
388
+ takeChange(carrier) {
312
389
  if (!isObj(carrier))
313
- return '';
390
+ return emptyChange();
391
+ if (this.unclaimed.has(carrier)) {
392
+ let before;
393
+ for (const res of this.unclaimed.keys()) {
394
+ if (res === carrier)
395
+ break;
396
+ before = res;
397
+ }
398
+ if (before)
399
+ this.tellUnclaimed(before);
400
+ }
314
401
  this.unclaimed.delete(carrier);
315
- const cs = this.readChanges.get(carrier);
316
- return cs ? this.presence.render(cs) : '';
402
+ return this.readChanges.get(carrier) ?? emptyChange();
403
+ }
404
+ /** 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`). */
405
+ told(cs) {
406
+ this.presence.markTold(cs.ended);
407
+ if (cs.blocksShown !== false)
408
+ this.presence.markShown(cs);
409
+ }
410
+ /** A change its taker couldn't show, told as a notice instead, or dropped when none can be written. */
411
+ tell(cs) {
412
+ this.notice(cs);
317
413
  }
318
- /** The change a carrier's hints made, or an empty one; taken as changeText's is. */
319
414
  changeOf(carrier) {
320
- if (!isObj(carrier))
321
- return emptyChange();
322
- this.unclaimed.delete(carrier);
323
415
  return this.readChanges.get(carrier) ?? emptyChange();
324
416
  }
325
417
  // ── recovery (RFC §6.1) ──
@@ -341,8 +433,11 @@ export class ToolsetController {
341
433
  /**
342
434
  * Recovering: re-read tools/list (presence, declared definitions), then
343
435
  * toolsets/list (dynamic-tool state in full), so every tool the statement
344
- * names is already known; each applies where it's read. What changed is
345
- * told. (stdio and WebSocket deliver in order, so no early rev is held.)
436
+ * names is already known, unless tools/list failed: a tool the last list
437
+ * lacks then stays unknown until a later change or list starts a recovery.
438
+ * Each applies where it's read. What
439
+ * changed is told. (stdio and WebSocket deliver in order, so no early rev is
440
+ * held.)
346
441
  */
347
442
  async recover() {
348
443
  const connection = this.connection;
@@ -350,13 +445,14 @@ export class ToolsetController {
350
445
  return;
351
446
  this.recovering = true;
352
447
  try {
353
- const tools = await this.ownToolList(connection);
448
+ await this.ownToolList(connection);
449
+ // RFC §6.1: toolsets/list after tools/list, so every tool it names is known, unless tools/list failed.
354
450
  if (connection !== this.connection)
355
451
  return;
356
452
  const lists = await this.ownToolsetList(connection, LIST_TIMEOUT_MS, 'recovery');
357
453
  if (connection !== this.connection)
358
454
  return;
359
- this.deliver(joinChanges(tools, lists));
455
+ this.deliver(lists);
360
456
  }
361
457
  finally {
362
458
  this.recovering = false;
@@ -366,34 +462,22 @@ export class ToolsetController {
366
462
  }
367
463
  }
368
464
  }
465
+ /** The tap applies the list and tells its change as it's read. */
369
466
  async ownToolList(connection) {
370
- if (this.ownLists++ === 0)
371
- this.ownListChange = null;
372
467
  try {
373
- // The tap applied each page as it was read, and kept what the whole
374
- // list changed (ownListChange).
375
468
  await connection.sendToolsList();
376
469
  }
377
470
  catch (error) {
378
- this.log(`tools/list failed: ${error instanceof Error ? error.message : String(error)}`);
471
+ this.log(`tools/list failed: ${this.failure(error)}`);
379
472
  }
380
- finally {
381
- this.ownLists--;
382
- }
383
- const collected = this.ownListChange ?? emptyChange();
384
- if (this.ownLists === 0)
385
- this.ownListChange = null;
386
- else
387
- this.ownListChange = emptyChange();
388
- return collected;
389
473
  }
390
474
  async ownToolsetList(connection, timeoutMs, why) {
391
475
  try {
392
476
  const r = await connection.sendToolsetsList(timeoutMs);
393
- return this.changeOf(r);
477
+ return this.takeChange(r);
394
478
  }
395
479
  catch (error) {
396
- this.log(`toolsets/list (${why}) failed: ${error instanceof Error ? error.message : String(error)}`);
480
+ this.log(`toolsets/list (${why}) failed: ${this.failure(error)}`);
397
481
  return emptyChange();
398
482
  }
399
483
  }
@@ -411,60 +495,67 @@ export class ToolsetController {
411
495
  if (!this.presence.active)
412
496
  return;
413
497
  const epoch = connection.transportEpoch;
414
- const tools = await this.ownToolList(connection);
498
+ await this.ownToolList(connection);
415
499
  if (connection !== this.connection || connection.transportEpoch !== epoch)
416
500
  return;
417
501
  const lists = await this.ownToolsetList(connection, LIST_TIMEOUT_MS, 'after connecting');
418
502
  if (connection !== this.connection || connection.transportEpoch !== epoch)
419
503
  return;
420
- this.deliver(joinChanges(tools, lists));
504
+ this.deliver(lists);
421
505
  }
422
506
  /**
423
507
  * Re-read what the server holds open (titles, visibility) without telling
424
- * it: for look, which restates everything anyway. Returns notes for what
425
- * the server no longer holds.
508
+ * it, for look. Returns what the re-read changed, taken from the notice it
509
+ * would otherwise get when `shown` (its result reaches an agent): look then
510
+ * either shows it (told) or hands it back (tell). Otherwise the change is
511
+ * left to its notice, and returned to be shown to the caller alone. Null
512
+ * when nothing was read.
426
513
  */
427
- async refreshOpen() {
514
+ async refreshOpen(opts) {
428
515
  if (!this.presence.active)
429
- return [];
516
+ return null;
430
517
  const connection = this.connection;
431
518
  if (!connection || !connection.isConnected)
432
- return [];
519
+ return null;
433
520
  try {
434
521
  const r = await connection.sendToolsetsList(REFRESH_TIMEOUT_MS);
435
- return this.changeOf(r).notes;
522
+ if (!isObj(r))
523
+ return null;
524
+ return opts.shown ? this.takeChange(r) : this.changeOf(r);
436
525
  }
437
526
  catch (error) {
438
- this.log(`toolsets/list (look) failed: ${error instanceof Error ? error.message : String(error)}`);
439
- return [];
527
+ this.log(`toolsets/list (look) failed: ${this.failure(error)}`);
528
+ return null;
440
529
  }
441
530
  }
442
531
  // ── the host's acts ──
443
- /** toolsets/open (RFC §5.2). The server's refusal rejects. */
444
- async open(toolset, instance, options) {
532
+ /** toolsets/open (RFC §5.2). The server's refusal rejects. Its change is
533
+ * taken when `shown` (its result reaches an agent), else left to its
534
+ * notice and returned for the caller alone; close's is the same. */
535
+ async open(toolset, instance, options, opts) {
445
536
  const connection = this.live();
446
537
  const r = await connection.sendToolsetsOpen({ toolset, ...(instance ? { instance } : {}), ...(options ? { options } : {}) }, TOOLSET_REQUEST_TIMEOUT_MS);
447
538
  const res = isObj(r) ? r : {};
448
539
  const inst = typeof res.instance === 'string' && res.instance ? res.instance : instance;
449
540
  // RFC §5.2: a proposed name is used as given or the open refused.
450
541
  if (instance && inst !== instance)
451
- this.log(`proposed instance "${instance}" came back as "${inst}" (RFC-014 §5.2 forbids renaming): using the server's`);
542
+ this.log(`proposed instance ${this.field(instance)} came back as ${this.field(inst)} (RFC-014 §5.2 forbids renaming): using the server's`);
452
543
  const key = keyOf(toolset, this.presence.declared[toolset]?.instances ? inst : undefined);
453
544
  const title = typeof res.title === 'string' && res.title ? res.title : this.presence.openRecords().find((o) => keyOf(o.toolset, o.instance) === key)?.title;
454
545
  return {
455
546
  key,
456
547
  ...(title ? { title } : {}),
457
548
  content: Array.isArray(res.content) ? res.content : [],
458
- change: this.changeOf(res),
549
+ change: opts.shown ? this.takeChange(res) : this.changeOf(res),
459
550
  adopted: res.adopted === true,
460
551
  };
461
552
  }
462
553
  /** toolsets/close (RFC §5.2). */
463
- async close(toolset, instance) {
554
+ async close(toolset, instance, opts) {
464
555
  const connection = this.live();
465
556
  const r = await connection.sendToolsetsClose({ toolset, ...(instance ? { instance } : {}) }, TOOLSET_REQUEST_TIMEOUT_MS);
466
557
  const res = isObj(r) ? r : {};
467
- return { content: Array.isArray(res.content) ? res.content : [], change: this.changeOf(res) };
558
+ return { content: Array.isArray(res.content) ? res.content : [], change: opts.shown ? this.takeChange(res) : this.changeOf(res) };
468
559
  }
469
560
  /** Open or close an undeclared group (RFC §5.4): the host's own act; the server isn't told. */
470
561
  setGroupOpen(group, open) {
@@ -489,7 +580,7 @@ export class ToolsetController {
489
580
  this.presence.deactivate();
490
581
  this.log('the server withdrew toolHints: every tool is static from now on');
491
582
  const cs = this.presence.diff(before, this.presence.view());
492
- cs.notes.push(`${this.serverId} no longer presents toolsets: its tools are all in your tool list now.`);
583
+ cs.notes.push(`${this.serverId} no longer presents toolsets: its tools are called directly now, by name, not through ${META_TOOL_NAMES.act}.`);
493
584
  this.deliver(cs);
494
585
  return;
495
586
  }
@@ -502,11 +593,16 @@ export class ToolsetController {
502
593
  this.presence.setManifest(declarations);
503
594
  this.deliver(this.presence.diff(before, this.presence.view()));
504
595
  }
505
- /** Apply pending static↔dynamic moves (RFC §4.2) at a tool-list boundary, and say where they went. */
506
- applyMoves() {
507
- return this.presence.applyMoves().map(({ name, to }) => to === 'dynamic'
508
- ? `${name} left your tool list: it's announced when available now, and called with ${META_TOOL_NAMES.act} {server:"${this.serverId}", tool:"${name}", args}.`
509
- : `${name} is in your tool list now, as ${this.deps.listedName(this.serverId, name)}.`);
596
+ /** 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. */
597
+ applyMoves(moves) {
598
+ const moved = this.presence.applyMoves(moves);
599
+ if (!moved.length)
600
+ return;
601
+ const cs = emptyChange();
602
+ cs.notes.push(...moved.map(({ name, to }) => to === 'dynamic'
603
+ ? `${name} left your tool list: it's announced when available, and called with ${META_TOOL_NAMES.act} {server:"${this.serverId}", tool:"${name}", args}.`
604
+ : `${name} is in your tool list now, as ${this.deps.listedName(this.serverId, name)}.`));
605
+ this.deliver(cs);
510
606
  }
511
607
  }
512
608
  /** Every server's controller, for the meta-tools and reminders. */
@@ -534,8 +630,11 @@ export class ToolsetHub {
534
630
  get(serverId) {
535
631
  return this.controllers.get(serverId);
536
632
  }
537
- /** A server's configuration went (removed): forget its controller. */
633
+ /** A server's configuration went (removed): its controller lets go of the
634
+ * connection it holds, and is forgotten, so a server added later under
635
+ * the same id starts from nothing. */
538
636
  forget(serverId) {
637
+ this.controllers.get(serverId)?.release();
539
638
  this.controllers.delete(serverId);
540
639
  }
541
640
  all() {
@@ -586,7 +685,7 @@ export class ToolsetHub {
586
685
  reminderFor(agentName) {
587
686
  if (!this.due.has(agentName))
588
687
  return null;
589
- const parts = this.presenting().map((c) => c.presence.reminder()).filter((r) => !!r);
688
+ const parts = this.presenting().filter((c) => this.deps.canNotify(c.serverId)).map((c) => c.presence.reminder()).filter((r) => !!r);
590
689
  if (parts.length === 0)
591
690
  return null;
592
691
  return `Open toolsets (a reminder: their earlier announcements have left your context):\n${parts.join('\n')}`;