@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
@@ -26,8 +26,9 @@ import type { EventResponse, Module, ModuleContext, ModuleContextInjection, Proc
26
26
  import type { ProcessEvent, ToolCall, ToolDefinition, ToolResult } from '../types/events.js';
27
27
  import { unknownToolMessage } from '../modules/unknown-tool.js';
28
28
  import { META_TOOL_NAMES, TOOLSETS_MODULE, type ToolsetController, type ToolsetHub } from './toolset-host.js';
29
- import { keyOf, type ChangeSet } from './tool-presence.js';
30
- import { checkArgs, renderToolDefinition, type HintedTool } from './tool-signature.js';
29
+ import { keyOf, type ChangeSet, type ToolPresence } from './tool-presence.js';
30
+ import { checkArgs, jsonText, renderToolDefinition, type HintedTool } from './tool-signature.js';
31
+ import { McplRequestError } from './server-connection.js';
31
32
 
32
33
  /** The namespace a reminder's stored injection carries. */
33
34
  export const TOOLSETS_REMINDER_NAMESPACE = 'mcpl-toolsets';
@@ -55,7 +56,7 @@ const META_TOOLS: ToolDefinition[] = [
55
56
  description:
56
57
  'Call a tool that was announced in the conversation (a tool of an open toolset, or another dynamic tool); they are not in your tool list. ' +
57
58
  'args are the arguments its signature shows; instance picks the open copy when several are open; server is needed only when two servers have the tool. ' +
58
- 'Returns the server\'s result, then any change in which tools are available.',
59
+ 'Returns the server\'s result, then any change it made in which tools are available, unless that change already went out as a notice. toolsets--look shows what is available now.',
59
60
  inputSchema: {
60
61
  type: 'object',
61
62
  properties: {
@@ -84,7 +85,7 @@ const META_TOOLS: ToolDefinition[] = [
84
85
  },
85
86
  {
86
87
  name: 'close',
87
- description: 'Close an open toolset or instance: its tools and its events stop. Closing a game in progress may count as leaving it.',
88
+ description: 'Close an open toolset or instance: its tools stop, and for a toolset the server declares, the server is told and stops its events too. Closing a game in progress may count as leaving it.',
88
89
  inputSchema: {
89
90
  type: 'object',
90
91
  properties: {
@@ -97,6 +98,33 @@ const META_TOOLS: ToolDefinition[] = [
97
98
  ];
98
99
 
99
100
  const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
101
+
102
+ const SELECTORS = ['server', 'toolset', 'instance', 'tool'] as const;
103
+
104
+ function checkSelectors(args: Record<string, unknown>): void {
105
+ for (const key of SELECTORS) {
106
+ const v = args[key];
107
+ if (v === undefined || v === null || str(v) !== undefined) continue;
108
+ const got = typeof v === 'string' ? 'a blank string' : Array.isArray(v) ? 'a list' : typeof v === 'object' ? 'an object' : `a ${typeof v}`;
109
+ fail(`${key} must be a name, or left out; got ${got}, so nothing was done`);
110
+ }
111
+ }
112
+
113
+ /** A failure whose outcome is unknown, with how to find it out. */
114
+ export function withLookHint(serverId: string, text: string): string {
115
+ const said = text.trimEnd();
116
+ return `${said}${/[.!?]$/.test(said) ? '' : '.'} ${META_TOOL_NAMES.look} {server:"${serverId}"} asks it what's open, or says it couldn't.`;
117
+ }
118
+
119
+ function requestFailure(serverId: string, did: string, unconfirmed: string, error: unknown): never {
120
+ const message = error instanceof Error ? error.message : String(error);
121
+ if (error instanceof McplRequestError && error.outcome === 'not-sent') return fail(`${serverId} ${did}: ${message}`);
122
+ return fail(withLookHint(serverId, `${serverId} ${unconfirmed}: ${message}`));
123
+ }
124
+
125
+ /** A result's tool change, for whoever shows it with the result (ToolsetsModule's takerFor). */
126
+ export type TakenChange = { toolsets: ToolsetController; cs: ChangeSet };
127
+
100
128
  const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
101
129
 
102
130
  type Block = { type: string; text?: string; [k: string]: unknown };
@@ -142,11 +170,11 @@ function pick(hub: ToolsetHub, server: unknown): ToolsetController {
142
170
  const ids = hub.presenting().map((c) => c.serverId);
143
171
  if (!id) {
144
172
  if (ids.length === 1) return hub.get(ids[0]!)!;
145
- return fail(`name a server: ${ids.join(', ') || '(no MCPL server presents toolsets now)'}`);
173
+ return fail(ids.length ? `name a server: ${ids.join(', ')}` : NONE_PRESENTS);
146
174
  }
147
175
  const c = hub.get(id);
148
176
  if (!c) return fail(`no server "${id}" presents toolsets; the ones that do: ${ids.join(', ') || 'none'}`);
149
- if (!c.presence.active) return fail(`${id} sends no tool hints now: its tools are in your tool list`);
177
+ if (!c.presence.active) return fail(`${id} sends no tool hints now: its tools are called directly, by name, not through ${META_TOOL_NAMES.act}`);
150
178
  return c;
151
179
  }
152
180
 
@@ -158,9 +186,15 @@ function owner(hub: ToolsetHub, server: unknown, has: (c: ToolsetController) =>
158
186
  if (str(server)) return pick(hub, server);
159
187
  const owners = hub.presenting().filter(has);
160
188
  if (owners.length === 1) return owners[0]!;
161
- return fail(owners.length
162
- ? `${what} is on several servers (${owners.map((c) => c.serverId).join(', ')}): add server`
163
- : `no server that presents toolsets has ${what}`);
189
+ if (owners.length) return fail(`${what} is on several servers (${owners.map((c) => c.serverId).join(', ')}): add server`);
190
+ return fail(hub.presenting().length ? `no server that presents toolsets has ${what}: ${META_TOOL_NAMES.look} shows what they have` : NONE_PRESENTS);
191
+ }
192
+
193
+ const NONE_PRESENTS = 'no MCPL server presents toolsets now: MCPL tools are called directly, by name';
194
+
195
+ function noToolset(c: ToolsetController, toolset: string): string {
196
+ const names = [...Object.keys(c.presence.declared), ...c.presence.undeclaredGroups];
197
+ return `${c.serverId} has no toolset "${toolset}"; ${names.length ? `it has ${names.join(', ')}` : 'it has none'}`;
164
198
  }
165
199
 
166
200
  /** What a `toolsets--act` call resolves to: the server's tool, its instance and arguments. */
@@ -171,24 +205,33 @@ export type ActTarget = { ok: true; serverId: string; tool: string; instance?: s
171
205
  * or the reason it can't be sent. The framework then dispatches it exactly as
172
206
  * a direct call of that tool, with `_meta["mcpl/instance"]` added (RFC §5.5).
173
207
  * Refused here, before anything is sent: a tool no presenting server has, a
174
- * static tool (it's in the tool list), an instance that isn't named where
208
+ * tool neither the published list nor the server's latest tools/list puts as dynamic (RFC §4.2), an instance that isn't named where
175
209
  * several are open, a group the host has closed (RFC §5.4), and arguments
176
210
  * that don't fit the definition in force for that instance (RFC §6.1).
177
211
  */
212
+ const hasInstances = (p: ToolPresence, ts: string | undefined): boolean => ts !== undefined && p.isDeclared(ts) && !!p.declared[ts]!.instances;
213
+
214
+ /** The key whose definition act checks a call of `tool` against (RFC §6.1): an instance's own, else the toolset's, else the listed one. */
215
+ function actKey(p: ToolPresence, tool: string, instance: string | undefined): string {
216
+ const ts = p.toolsetOf(tool);
217
+ return ts ? keyOf(ts, hasInstances(p, ts) ? instance : undefined) : keyOf('');
218
+ }
219
+
178
220
  export function resolveAct(hub: ToolsetHub, input: unknown): ActTarget {
179
221
  try {
180
222
  const args = isObj(input) ? input : {};
223
+ checkSelectors(args);
181
224
  const tool = str(args.tool) ?? fail('name the tool');
182
225
  const c = owner(hub, args.server, (x) => !!x.presence.tool(tool), `a tool "${tool}"`);
183
226
  const p = c.presence;
184
227
  if (!p.tool(tool)) fail(`${c.serverId} has no tool "${tool}": ${META_TOOL_NAMES.look} {server:"${c.serverId}"} shows what's available`);
185
- if (p.presenceOf(tool) === 'static') fail(`${tool} is not a dynamic tool: ${p.toolStatus(tool)}. Call it there.`);
228
+ if (!p.shownAs(tool, 'dynamic')) fail(`${tool} is not a dynamic tool: ${p.toolStatus(tool)}. Call it there.`);
186
229
  if (args.args !== undefined && !isObj(args.args)) fail('args must be an object of the tool\'s arguments');
187
230
  const callArgs = (args.args ?? {}) as Record<string, unknown>;
188
231
  let instance = str(args.instance);
189
232
  const ts = p.toolsetOf(tool);
190
233
  const declared = ts !== undefined && p.isDeclared(ts);
191
- const withInstances = declared && !!p.declared[ts!]!.instances;
234
+ const withInstances = hasInstances(p, ts);
192
235
  if (withInstances && !instance) {
193
236
  // RFC §5.5: the instance rides in _meta; with exactly one open, it's that one.
194
237
  const open = p.openRecords(ts);
@@ -202,7 +245,7 @@ export function resolveAct(hub: ToolsetHub, input: unknown): ActTarget {
202
245
  // RFC §6.1: arguments are checked against the definition in force for
203
246
  // that instance: its own from hints (the legal moves of one game), else
204
247
  // the toolset's, else the listed one.
205
- const key = ts ? keyOf(ts, withInstances ? instance : undefined) : keyOf('');
248
+ const key = actKey(p, tool, instance);
206
249
  const problem = checkArgs(p.defOf(key, tool).inputSchema, callArgs);
207
250
  if (problem) {
208
251
  fail(`Invalid arguments for ${tool} in ${p.label(key)}: ${problem}. Nothing was sent; ` +
@@ -218,7 +261,13 @@ export function resolveAct(hub: ToolsetHub, input: unknown): ActTarget {
218
261
  export class ToolsetsModule implements Module {
219
262
  readonly name = TOOLSETS_MODULE;
220
263
 
221
- constructor(private readonly hub: ToolsetHub) {}
264
+ /** `takerFor`: who shows a call's result to an agent, taking the tool
265
+ * change rendered into it; null when the result may reach none, and its
266
+ * change is left to a notice (RFC-014 §6.1: told once). */
267
+ constructor(
268
+ private readonly hub: ToolsetHub,
269
+ private readonly takerFor: (call: ToolCall) => ((taken: TakenChange) => void) | null,
270
+ ) {}
222
271
 
223
272
  async start(_ctx: ModuleContext): Promise<void> {}
224
273
  async stop(): Promise<void> {}
@@ -241,11 +290,13 @@ export class ToolsetsModule implements Module {
241
290
  async handleToolCall(call: ToolCall): Promise<ToolResult> {
242
291
  const args = isObj(call.input) ? call.input : {};
243
292
  try {
293
+ checkSelectors(args);
294
+ const take = this.takerFor(call);
244
295
  switch (call.name) {
245
- case 'open': return await this.open(args);
296
+ case 'open': return await this.open(args, take);
246
297
  case 'act': return this.act(args);
247
- case 'look': return await this.look(args);
248
- case 'close': return await this.close(args);
298
+ case 'look': return await this.look(args, take);
299
+ case 'close': return await this.close(args, take);
249
300
  default: return { success: false, isError: true, error: unknownToolMessage(this.name, call.name, META_TOOLS) };
250
301
  }
251
302
  } catch (error) {
@@ -261,13 +312,14 @@ export class ToolsetsModule implements Module {
261
312
  }
262
313
 
263
314
  /** A change set rendered without the key the caller already showed whole. */
264
- private renderOthers(c: ToolsetController, cs: ChangeSet, key: string): string {
265
- return c.presence.render({ ...cs, changes: cs.changes.filter((ch) => ch.key !== key) });
315
+ private renderOthers(c: ToolsetController, cs: ChangeSet, key: string, shown: boolean): string {
316
+ return c.presence.render({ ...cs, changes: cs.changes.filter((ch) => ch.key !== key) }, { mark: shown, describeAll: !shown });
266
317
  }
267
318
 
268
319
  // ── the tools ──
269
320
 
270
- private async open(args: Record<string, unknown>): Promise<ToolResult> {
321
+ private async open(args: Record<string, unknown>, take: ((taken: TakenChange) => void) | null): Promise<ToolResult> {
322
+ const shown = take !== null;
271
323
  let toolset = str(args.toolset);
272
324
  const c = toolset && !str(args.server)
273
325
  ? owner(this.hub, undefined, (x) => x.presence.isDeclared(toolset!) || x.presence.undeclaredGroups.includes(toolset!), `a toolset "${toolset}"`)
@@ -286,23 +338,24 @@ export class ToolsetsModule implements Module {
286
338
  if (instance && !p.declared[toolset]!.instances) fail(`${c.serverId}/${toolset} has no instances: open it without one`);
287
339
  let opened: Awaited<ReturnType<ToolsetController['open']>>;
288
340
  try {
289
- opened = await c.open(toolset, instance, options);
341
+ opened = await c.open(toolset, instance, options, { shown });
290
342
  } catch (error) {
291
- return fail(`${c.serverId} did not open ${toolset}: ${error instanceof Error ? error.message : String(error)}`);
343
+ return requestFailure(c.serverId, `did not open ${toolset}`, `didn't confirm opening ${toolset}`, error);
292
344
  }
293
345
  // RFC §5.2: opening what is open adopts it; its content says which options weren't applied.
294
346
  const blocks: Block[] = [
295
347
  { type: 'text', text: `${p.label(opened.key)}${opened.title ? ` ${opened.title}` : ''}${opened.adopted ? ' (already open)' : ''}` },
296
348
  ...asBlocks(opened.content),
297
349
  ];
298
- const tail = [...p.toolBlock(opened.key, this.toolsAt(c, opened.key), { headed: false })];
350
+ const tail = [...p.toolBlock(opened.key, this.toolsAt(c, opened.key), { headed: false, describeAll: !shown, seen: shown ? undefined : new Set<string>() })];
299
351
  const gone = opened.change.changes.find((ch) => ch.key === opened.key)?.gone ?? [];
300
352
  if (gone.length) tail.push(`now unavailable: ${gone.join(', ')}`);
301
- const others = this.renderOthers(c, opened.change, opened.key);
353
+ const others = this.renderOthers(c, opened.change, opened.key, shown);
302
354
  if (others) tail.push(others);
303
355
  const close = p.closeHint(opened.key);
304
356
  if (close) tail.push(`${close} closes it.`);
305
357
  blocks.push({ type: 'text', text: tail.join('\n') });
358
+ take?.({ toolsets: c, cs: opened.change });
306
359
  return blocksResult(blocks);
307
360
  }
308
361
  if (p.undeclaredGroups.includes(toolset)) {
@@ -310,13 +363,15 @@ export class ToolsetsModule implements Module {
310
363
  if (instance || options) fail(`${c.serverId}/${toolset} takes no instance or options (the server doesn't declare it)`);
311
364
  const cs = c.setGroupOpen(toolset, true);
312
365
  const key = keyOf(toolset);
313
- const lines = [`${p.label(key)} open.`, ...p.toolBlock(key, this.toolsAt(c, key), { headed: false })];
314
- const others = this.renderOthers(c, cs, key);
366
+ const lines = [`${p.label(key)} open.`, ...p.toolBlock(key, this.toolsAt(c, key), { headed: false, describeAll: !shown, seen: shown ? undefined : new Set<string>() })];
367
+ const others = this.renderOthers(c, cs, key, shown);
315
368
  if (others) lines.push(others);
316
369
  lines.push(`${p.closeHint(key)} closes it.`);
370
+ if (take) take({ toolsets: c, cs });
371
+ else c.tell(cs);
317
372
  return textResult(lines.join('\n'));
318
373
  }
319
- return fail(`${c.serverId} has no toolset "${toolset}"${openable.length ? `; it has ${openable.join(', ')}` : ''}`);
374
+ return fail(noToolset(c, toolset));
320
375
  }
321
376
 
322
377
  /**
@@ -333,15 +388,43 @@ export class ToolsetsModule implements Module {
333
388
  }
334
389
 
335
390
  /** look restates the present: titles and visibility are re-read from the servers first (toolsets/list). */
336
- private async look(args: Record<string, unknown>): Promise<ToolResult> {
391
+ private async look(args: Record<string, unknown>, take: ((taken: TakenChange) => void) | null): Promise<ToolResult> {
337
392
  const server = str(args.server);
338
393
  const targets = server ? [this.hub.get(server)].filter((c): c is ToolsetController => !!c && c.presents) : this.hub.presenting();
339
- const notes = (await Promise.all(targets.map((c) => c.refreshOpen().catch(() => [] as string[])))).flat();
340
- const result = this.lookNow(args, server, str(args.tool));
341
- return notes.length && !result.isError && typeof result.data === 'string' ? textResult(`${result.data}\n${notes.join('\n')}`) : result;
394
+ const shown = take !== null;
395
+ const reads = await Promise.all(targets.map(async (c) => ({ c, cs: await c.refreshOpen({ shown }).catch(() => null) })));
396
+ const read = reads.filter((r): r is { c: ToolsetController; cs: ChangeSet } => r.cs !== null);
397
+ const unread = reads.filter((r) => r.cs === null).map(({ c }) => `${c.serverId} couldn't be asked just now: this is what the host last knew of it.`);
398
+ const giveBack = () => {
399
+ if (shown) for (const { c, cs } of read) c.tell(cs);
400
+ };
401
+ let result: ToolResult;
402
+ try {
403
+ result = this.lookNow(args, server, str(args.tool), shown);
404
+ } catch (error) {
405
+ giveBack();
406
+ if (error instanceof MetaError && unread.length) fail([error.message, ...unread].join('\n'));
407
+ throw error;
408
+ }
409
+ if (result.isError || typeof result.data !== 'string') {
410
+ giveBack();
411
+ return result;
412
+ }
413
+ const restates = !str(args.tool) && !str(args.toolset) && !str(args.instance);
414
+ if (!restates) giveBack();
415
+ const lines = restates
416
+ ? read.flatMap(({ c, cs }) => {
417
+ const said = [...cs.suggest.map((s) => c.presence.renderSuggestion(s)), ...c.presence.untoldNotes(cs), ...cs.notes];
418
+ // The catalog above counted what it showed; the change's own blocks weren't shown, but stay for a notice if its taker can't show the result.
419
+ take?.({ toolsets: c, cs: { ...cs, blocksShown: false } });
420
+ return said;
421
+ })
422
+ : [];
423
+ lines.push(...unread);
424
+ return lines.length ? textResult(`${result.data}\n${lines.join('\n')}`) : result;
342
425
  }
343
426
 
344
- private lookNow(args: Record<string, unknown>, server: string | undefined, tool: string | undefined): ToolResult {
427
+ private lookNow(args: Record<string, unknown>, server: string | undefined, tool: string | undefined, mark: boolean): ToolResult {
345
428
  if (!server) {
346
429
  if (tool) return this.lookTool(owner(this.hub, undefined, (c) => !!c.presence.tool(tool), `a tool "${tool}"`), tool, str(args.instance));
347
430
  const ts = str(args.toolset);
@@ -352,23 +435,27 @@ export class ToolsetsModule implements Module {
352
435
  (x) => (ts ? x.presence.isDeclared(ts) || x.presence.undeclaredGroups.includes(ts) : true) && (!inst || x.presence.openRecords().some((o) => o.instance === inst)),
353
436
  ts ? `a toolset "${ts}"${inst ? ` with instance "${inst}"` : ''}` : `an open instance "${inst}"`,
354
437
  );
355
- return this.lookNow(args, c.serverId, tool);
438
+ return this.lookNow(args, c.serverId, tool, mark);
356
439
  }
357
440
  const all = this.hub.presenting();
358
- if (!all.length) return textResult('No MCPL server presents toolsets or dynamic tools now; every tool is in your tool list.');
359
- return textResult(all.map((c) => c.presence.catalog()).join('\n\n'));
441
+ if (!all.length) return textResult('No MCPL server presents toolsets or dynamic tools now; MCPL tools are called directly, by name.');
442
+ return textResult(all.map((c) => c.presence.catalog({ mark })).join('\n\n'));
360
443
  }
361
444
  const c = pick(this.hub, server);
362
445
  if (tool) return this.lookTool(c, tool, str(args.instance));
363
446
  const p = c.presence;
364
447
  let toolset = str(args.toolset);
365
448
  const instance = str(args.instance);
366
- if (!toolset && instance) toolset = p.openRecords().find((o) => o.instance === instance)?.toolset ?? fail(`no open instance "${instance}" on ${c.serverId}`);
367
- if (!toolset) return textResult(p.catalog());
449
+ if (!toolset && instance) {
450
+ const open = p.openRecords().filter((o) => o.instance !== undefined);
451
+ toolset = open.find((o) => o.instance === instance)?.toolset
452
+ ?? fail(`no open instance "${instance}" on ${c.serverId}; ${open.length ? `open: ${open.map((o) => `${o.toolset}:${o.instance}`).join(', ')}` : 'no instance is open there'}`);
453
+ }
454
+ if (!toolset) return textResult(p.catalog({ mark }));
368
455
  if (p.isOpen(toolset, instance) || (!instance && p.openRecords(toolset).length === 1)) {
369
456
  const rec = p.isDeclared(toolset) ? (instance ? p.openRecords(toolset).find((o) => o.instance === instance) : p.openRecords(toolset)[0]) : undefined;
370
457
  const key = keyOf(toolset, rec?.instance);
371
- const lines = p.toolBlock(key, this.toolsAt(c, key), { describeAll: true, title: rec?.title });
458
+ const lines = p.toolBlock(key, this.toolsAt(c, key), { describeAll: true, title: rec?.title, seen: mark ? undefined : new Set<string>() });
372
459
  const close = p.closeHint(key);
373
460
  if (close) lines.push(`${close} closes it.`);
374
461
  return textResult(lines.join('\n'));
@@ -380,7 +467,7 @@ export class ToolsetsModule implements Module {
380
467
  `${c.serverId}/${toolset}${d.title ? ` — ${d.title}` : ''}${d.summary ? `: ${d.summary}` : ''}`,
381
468
  open.length ? `open: ${open.map((o) => o.instance ?? toolset).join(', ')}` : 'not open',
382
469
  ...(d.instances ? ['several can be open at once; name each with instance'] : []),
383
- ...(d.options ? [`options: ${JSON.stringify(d.options)}`] : []),
470
+ ...(d.options ? [`options: ${jsonText(d.options)}`] : []),
384
471
  `${p.openHint(toolset)} opens it.`,
385
472
  ].join('\n'));
386
473
  }
@@ -388,20 +475,22 @@ export class ToolsetsModule implements Module {
388
475
  const names = p.allTools.filter((t) => p.toolsetOf(t.name) === toolset).map((t) => t.name);
389
476
  return textResult(`${c.serverId}/${toolset}: ${names.join(', ')}; not open: ${p.openHint(toolset)} opens it.`);
390
477
  }
391
- return fail(`${c.serverId} has no toolset "${toolset}"`);
478
+ return fail(noToolset(c, toolset!));
392
479
  }
393
480
 
394
481
  private lookTool(c: ToolsetController, tool: string, instance: string | undefined): ToolResult {
395
482
  const p = c.presence;
396
- if (!p.tool(tool)) fail(`${c.serverId} has no tool "${tool}"`);
483
+ if (!p.tool(tool)) fail(`${c.serverId} has no tool "${tool}": ${META_TOOL_NAMES.look} {server:"${c.serverId}"} shows what's available`);
397
484
  const ts = p.toolsetOf(tool);
398
- const where = [...p.view().entries()].filter(([, e]) => e.tools.has(tool)).map(([k]) => k);
399
- const key = instance && ts ? keyOf(ts, instance) : where[0];
485
+ // The definition act would check, wherever act would take the tool.
486
+ const open = !instance && hasInstances(p, ts) ? p.openRecords(ts) : [];
487
+ const key = !p.shownAs(tool, 'dynamic') ? undefined : actKey(p, tool, instance ?? (open.length === 1 ? open[0]!.instance : undefined));
400
488
  const def = key ? p.defOf(key, tool) : p.tool(tool)!;
401
489
  return textResult(`${renderToolDefinition(def)}\n${p.toolStatus(tool)}`);
402
490
  }
403
491
 
404
- private async close(args: Record<string, unknown>): Promise<ToolResult> {
492
+ private async close(args: Record<string, unknown>, take: ((taken: TakenChange) => void) | null): Promise<ToolResult> {
493
+ const shown = take !== null;
405
494
  const named = str(args.toolset);
406
495
  const c = named && !str(args.server)
407
496
  ? owner(this.hub, undefined, (x) => x.presence.isOpen(named, str(args.instance)) || x.presence.openRecords(named).length > 0, `an open toolset "${named}"`)
@@ -432,20 +521,25 @@ export class ToolsetsModule implements Module {
432
521
  }
433
522
  let closed: Awaited<ReturnType<ToolsetController['close']>>;
434
523
  try {
435
- closed = await c.close(toolset!, instance);
524
+ closed = await c.close(toolset!, instance, { shown });
436
525
  } catch (error) {
437
- return fail(`${c.serverId} did not close ${toolset}${instance ? `:${instance}` : ''}: ${error instanceof Error ? error.message : String(error)}`);
526
+ const what = `${toolset}${instance ? `:${instance}` : ''}`;
527
+ return requestFailure(c.serverId, `did not close ${what}`, `didn't confirm closing ${what}`, error);
438
528
  }
439
529
  const key = keyOf(toolset!, instance);
440
- const change = p.render(closed.change) || `${p.label(key)} closed.`;
530
+ const change = p.render(closed.change, { mark: shown, describeAll: !shown }) || `${p.label(key)} closed.`;
531
+ take?.({ toolsets: c, cs: closed.change });
441
532
  return blocksResult([...asBlocks(closed.content), { type: 'text', text: change }]);
442
533
  }
443
534
  if (p.undeclaredGroups.includes(toolset!)) {
444
535
  if (instance) fail(`${c.serverId}/${toolset} takes no instance (the server doesn't declare it)`);
445
536
  if (!p.isOpen(toolset!)) fail(`${c.serverId}/${toolset} is not open`);
446
537
  const cs = c.setGroupOpen(toolset!, false);
447
- return textResult(p.render(cs) || `${p.label(keyOf(toolset!))} closed.`);
538
+ const text = p.render(cs, { mark: shown, describeAll: !shown }) || `${p.label(keyOf(toolset!))} closed.`;
539
+ if (take) take({ toolsets: c, cs });
540
+ else c.tell(cs);
541
+ return textResult(text);
448
542
  }
449
- return fail(`${c.serverId} has no toolset "${toolset}"`);
543
+ return fail(noToolset(c, toolset!));
450
544
  }
451
545
  }
package/src/mcpl/types.ts CHANGED
@@ -384,7 +384,9 @@ export interface McplServerConfig {
384
384
  *
385
385
  * `disabledCapabilities` takes precedence on conflict. `version` and
386
386
  * `featureSets` are not maskable here — feature sets have their own
387
- * enable/disable knobs above.
387
+ * enable/disable knobs above — and neither are RFC-014's `toolHints`,
388
+ * `toolsets` and `surface`, which a server's own `toolHints` config turns
389
+ * off.
388
390
  */
389
391
  enabledCapabilities?: string[];
390
392
 
@@ -28,7 +28,7 @@ import { heldWriteId } from './held-writes.js';
28
28
  import type { Agent } from './agent.js';
29
29
  import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, waitForShutdown } from './shutdown.js';
30
30
  import { logValue } from './mcpl/log-value.js';
31
- import { toolArgsProblems } from './tool-args.js';
31
+ import { asCalled, markedInputRefusal, toolArgsProblems } from './tool-args.js';
32
32
 
33
33
  /** How the registry queues a module's event: its push names the module. A
34
34
  * `ProcessQueue` serves too, ignoring the name. */
@@ -317,11 +317,17 @@ export class ModuleRegistry {
317
317
  * A call whose input isn't an object, or lacks a key the tool's schema
318
318
  * requires, is answered before the module runs, with an error result that
319
319
  * opens "Nothing was done:" and names what to send (toolArgsProblems).
320
+ * So is a call membrane marked `unparseableInput`, unless its text is JSON
321
+ * `null`, which reaches the module as a call with no arguments
322
+ * (markedInputRefusal).
320
323
  * A caller that has already resolved the tool's definition, as the `utils`
321
324
  * tool has a utility's, passes it as `definition`, and it's checked against
322
325
  * that one.
323
326
  */
324
- async handleToolCall(call: ToolCall, options: { definition?: ToolDefinition } = {}): Promise<ToolResult> {
327
+ async handleToolCall(given: ToolCall, options: { definition?: ToolDefinition } = {}): Promise<ToolResult> {
328
+ const marked = markedInputRefusal(given);
329
+ if (marked !== undefined) return { success: false, error: marked, isError: true };
330
+ const call = asCalled(given);
325
331
  // Parse module--tool name
326
332
  const sepIndex = call.name.indexOf('--');
327
333
  if (sepIndex === -1) {
@@ -535,7 +535,7 @@ export class HistoryModule implements Module {
535
535
  channelId?: string;
536
536
  n: number;
537
537
  side: 'newest' | 'oldest';
538
- }): { messages: StoredMessage[]; more: boolean } {
538
+ }): { messages: StoredMessage[]; more: boolean; total?: number } {
539
539
  const cm = this.cm as ContextManager;
540
540
  const { fromMs, toMs, channelId, n, side } = opts;
541
541
  const newest = side === 'newest';
@@ -553,8 +553,8 @@ export class HistoryModule implements Module {
553
553
  const fetch = (f: number | undefined, t: number | undefined, limit: number, offset = 0) =>
554
554
  limit <= 0 ? [] : cm.queryMessagesByTimeAndChannel({ fromMs: f, toMs: t, channelId, limit, offset }).messages;
555
555
  const total = count(fromMs, toMs);
556
- if (n === 0) return { messages: [], more: total > 0 };
557
- if (total <= n) return { messages: dirSort(fetch(fromMs, toMs, total)), more: false };
556
+ if (n === 0) return { messages: [], more: total > 0, total };
557
+ if (total <= n) return { messages: dirSort(fetch(fromMs, toMs, total)), more: false, total };
558
558
 
559
559
  // The window is parameterised by its FAR bound `e`: [e, toMs] for the
560
560
  // newest side, [fromMs, e] for the oldest; its count is monotone in e.
@@ -580,7 +580,7 @@ export class HistoryModule implements Module {
580
580
  }
581
581
  const fetchCap = Math.max(4 * n, n + 1000);
582
582
  if (inCount <= fetchCap) {
583
- return { messages: dirSort(fetch(...win(inside), inCount)).slice(0, n), more: true };
583
+ return { messages: dirSort(fetch(...win(inside), inCount)).slice(0, n), more: true, total };
584
584
  }
585
585
  // Overshoot: bisect the far bound. The empty side starts one past the
586
586
  // range's near end (newest: past toMs / any date; oldest: before lo).
@@ -595,7 +595,7 @@ export class HistoryModule implements Module {
595
595
  inside = mid;
596
596
  inCount = c;
597
597
  if (c <= fetchCap) {
598
- return { messages: dirSort(fetch(...win(mid), c)).slice(0, n), more: true };
598
+ return { messages: dirSort(fetch(...win(mid), c)).slice(0, n), more: true, total };
599
599
  }
600
600
  }
601
601
  }
@@ -607,7 +607,7 @@ export class HistoryModule implements Module {
607
607
  const need = n - near.length;
608
608
  const ties = count(inside, inside);
609
609
  const edgeMs = fetch(inside, inside, need, newest ? Math.max(0, ties - need) : 0);
610
- return { messages: dirSort([...near, ...edgeMs]).slice(0, n), more: true };
610
+ return { messages: dirSort([...near, ...edgeMs]).slice(0, n), more: true, total };
611
611
  }
612
612
 
613
613
  async start(ctx: ModuleContext): Promise<void> {
@@ -1150,7 +1150,8 @@ export class HistoryModule implements Module {
1150
1150
  hint:
1151
1151
  (pageFull
1152
1152
  ? 'More matching messages exist. '
1153
- : `Stopped after scanning ${scanned} messages of the window without reaching its end. `) +
1153
+ : `Stopped after scanning ${scanned} messages of the window without reaching its end` +
1154
+ (page.length === 0 ? ', so returning none is not a negative result for the window. ' : '. ')) +
1154
1155
  `Continue by repeating this call with the same from/to/channelId/author plus ` +
1155
1156
  `the fields of \`resume\` (windowOffset:${resume.windowOffset}, offset:${resume.offset}, afterId).`,
1156
1157
  };
@@ -1373,6 +1374,7 @@ export class HistoryModule implements Module {
1373
1374
  !!skipRange && m.timestamp.getTime() === edgeMs && m.sequence >= skipRange[0] && m.sequence <= skipRange[1];
1374
1375
  let windowMessages: StoredMessage[];
1375
1376
  let truncated: boolean;
1377
+ let unscanned: number | undefined;
1376
1378
  {
1377
1379
  // Over-fetch by the number of messages at the edge millisecond (the
1378
1380
  // only ones that can be skipped), then drop the skipped ones.
@@ -1381,6 +1383,7 @@ export class HistoryModule implements Module {
1381
1383
  const pool = skipRange ? w.messages.filter((m) => !isSkipped(m)) : w.messages;
1382
1384
  truncated = w.more || pool.length > maxScan;
1383
1385
  windowMessages = pool.slice(0, maxScan);
1386
+ if (w.total !== undefined) unscanned = w.total - (w.messages.length - pool.length) - windowMessages.length;
1384
1387
  }
1385
1388
  const candidatePoolSize = windowMessages.length;
1386
1389
  const candidates = authorFilter ? windowMessages.filter(authorFilter) : windowMessages;
@@ -1394,7 +1397,7 @@ export class HistoryModule implements Module {
1394
1397
  // the pool was never looked at) or the pool itself was truncated. The
1395
1398
  // timestamp is the continuation point — from: (oldest) / to: (newest).
1396
1399
  // Boundary is inclusive, so a message at exactly that instant may repeat.
1397
- const resumeInfo = (scanned: number): Record<string, unknown> => {
1400
+ const resumeInfo = (scanned: number, matched: number): Record<string, unknown> => {
1398
1401
  const stoppedEarly = scanned < candidates.length;
1399
1402
  if (!stoppedEarly && !truncated) return {};
1400
1403
  // Stopped at limit: the last candidate actually looked at. Scanned the
@@ -1432,9 +1435,16 @@ export class HistoryModule implements Module {
1432
1435
  hint:
1433
1436
  (stoppedEarly
1434
1437
  ? 'Stopped at limit; more candidates remain. '
1435
- : `Only the ${order} ${candidatePoolSize} messages of this range were scanned (maxScan). `) +
1438
+ : matched === 0
1439
+ ? `No match among the ${order} ${candidatePoolSize} ${candidatePoolSize === 1 ? 'message' : 'messages'} ` +
1440
+ 'scanned (maxScan); ' +
1441
+ (unscanned === undefined
1442
+ ? 'the rest of this range was not searched'
1443
+ : `${unscanned} more in this range ${unscanned === 1 ? 'was' : 'were'} not searched`) +
1444
+ ', so this is not a negative result for the range. '
1445
+ : `Only the ${order} ${candidatePoolSize} messages of this range were scanned (maxScan). `) +
1436
1446
  `Continue by repeating the call with the fields of \`resume\` (${bound}:"${at}" and skipSequences), ` +
1437
- 'or raise limit/maxScan, or narrow with channelId/author/dates.',
1447
+ `or raise limit/maxScan, or narrow with ${[channelId === undefined ? 'channelId' : null, stoppedEarly && input.author === undefined ? 'author' : null, 'dates'].filter((w) => w !== null).join('/')}.`,
1438
1448
  };
1439
1449
  };
1440
1450
 
@@ -1449,7 +1459,7 @@ export class HistoryModule implements Module {
1449
1459
  // on a deadline. See search-regex-worker.ts's header for the full
1450
1460
  // rationale.
1451
1461
  const { matches, scanned } = await this.searchWithRegexWorker(candidates, query, flags, limit);
1452
- return { success: true, data: { scanned, ...poolInfo, truncated, ...resumeInfo(scanned), matches } };
1462
+ return { success: true, data: { scanned, ...poolInfo, truncated, ...resumeInfo(scanned, matches.length), matches } };
1453
1463
  }
1454
1464
 
1455
1465
  // Plain substring search (String.prototype.indexOf) is inherently
@@ -1481,7 +1491,7 @@ export class HistoryModule implements Module {
1481
1491
 
1482
1492
  return {
1483
1493
  success: true,
1484
- data: { scanned, ...poolInfo, truncated, ...resumeInfo(scanned), matches },
1494
+ data: { scanned, ...poolInfo, truncated, ...resumeInfo(scanned, matches.length), matches },
1485
1495
  };
1486
1496
  }
1487
1497