@animalabs/agent-framework 0.30.0 → 0.32.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 (189) hide show
  1. package/README.md +71 -8
  2. package/dist/src/agent.d.ts +29 -9
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +82 -17
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  7. package/dist/src/api/mcp-server-core.js +10 -3
  8. package/dist/src/api/mcp-server-core.js.map +1 -1
  9. package/dist/src/code-execution/isolate/render.d.ts.map +1 -1
  10. package/dist/src/code-execution/isolate/render.js +7 -6
  11. package/dist/src/code-execution/isolate/render.js.map +1 -1
  12. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  13. package/dist/src/code-execution/isolate/scripts.js +5 -4
  14. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  15. package/dist/src/framework.d.ts +138 -102
  16. package/dist/src/framework.d.ts.map +1 -1
  17. package/dist/src/framework.js +1133 -752
  18. package/dist/src/framework.js.map +1 -1
  19. package/dist/src/gate/event-gate.d.ts +24 -17
  20. package/dist/src/gate/event-gate.d.ts.map +1 -1
  21. package/dist/src/gate/event-gate.js +198 -144
  22. package/dist/src/gate/event-gate.js.map +1 -1
  23. package/dist/src/gate/recipe-reconcile.d.ts.map +1 -1
  24. package/dist/src/gate/recipe-reconcile.js +6 -5
  25. package/dist/src/gate/recipe-reconcile.js.map +1 -1
  26. package/dist/src/host-presentation.d.ts.map +1 -1
  27. package/dist/src/host-presentation.js +5 -7
  28. package/dist/src/host-presentation.js.map +1 -1
  29. package/dist/src/index.d.ts +1 -0
  30. package/dist/src/index.d.ts.map +1 -1
  31. package/dist/src/index.js +1 -0
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/inference-bounds.d.ts +37 -0
  34. package/dist/src/inference-bounds.d.ts.map +1 -0
  35. package/dist/src/inference-bounds.js +84 -0
  36. package/dist/src/inference-bounds.js.map +1 -0
  37. package/dist/src/line-reader.d.ts +7 -1
  38. package/dist/src/line-reader.d.ts.map +1 -1
  39. package/dist/src/line-reader.js +17 -6
  40. package/dist/src/line-reader.js.map +1 -1
  41. package/dist/src/mcpl/errors.d.ts +1 -0
  42. package/dist/src/mcpl/errors.d.ts.map +1 -1
  43. package/dist/src/mcpl/errors.js +3 -0
  44. package/dist/src/mcpl/errors.js.map +1 -1
  45. package/dist/src/mcpl/inbound-source.d.ts +2 -2
  46. package/dist/src/mcpl/inbound-source.js +2 -2
  47. package/dist/src/mcpl/inference-router.d.ts +11 -1
  48. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  49. package/dist/src/mcpl/inference-router.js +65 -9
  50. package/dist/src/mcpl/inference-router.js.map +1 -1
  51. package/dist/src/mcpl/modern-connection.d.ts +10 -7
  52. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  53. package/dist/src/mcpl/modern-connection.js +85 -74
  54. package/dist/src/mcpl/modern-connection.js.map +1 -1
  55. package/dist/src/mcpl/protocol-family.d.ts +1 -1
  56. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  57. package/dist/src/mcpl/protocol-family.js +45 -3
  58. package/dist/src/mcpl/protocol-family.js.map +1 -1
  59. package/dist/src/mcpl/push-coalescer.d.ts +2 -7
  60. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  61. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  62. package/dist/src/mcpl/references.d.ts +1 -2
  63. package/dist/src/mcpl/references.d.ts.map +1 -1
  64. package/dist/src/mcpl/references.js +3 -3
  65. package/dist/src/mcpl/references.js.map +1 -1
  66. package/dist/src/mcpl/server-connection.d.ts +14 -0
  67. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  68. package/dist/src/mcpl/server-connection.js +26 -22
  69. package/dist/src/mcpl/server-connection.js.map +1 -1
  70. package/dist/src/mcpl/tool-signature.d.ts +1 -0
  71. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  72. package/dist/src/mcpl/tool-signature.js +16 -11
  73. package/dist/src/mcpl/tool-signature.js.map +1 -1
  74. package/dist/src/mcpl/transport.d.ts +44 -21
  75. package/dist/src/mcpl/transport.d.ts.map +1 -1
  76. package/dist/src/mcpl/transport.js +283 -64
  77. package/dist/src/mcpl/transport.js.map +1 -1
  78. package/dist/src/mcpl/types.d.ts +17 -1
  79. package/dist/src/mcpl/types.d.ts.map +1 -1
  80. package/dist/src/mcpl/types.js.map +1 -1
  81. package/dist/src/module-registry.d.ts +32 -4
  82. package/dist/src/module-registry.d.ts.map +1 -1
  83. package/dist/src/module-registry.js +189 -25
  84. package/dist/src/module-registry.js.map +1 -1
  85. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  86. package/dist/src/modules/discord/discord-js-client.js +24 -16
  87. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  88. package/dist/src/modules/sender-name.d.ts +5 -4
  89. package/dist/src/modules/sender-name.d.ts.map +1 -1
  90. package/dist/src/modules/sender-name.js +7 -5
  91. package/dist/src/modules/sender-name.js.map +1 -1
  92. package/dist/src/modules/workspace/disk-agreement.d.ts +16 -8
  93. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  94. package/dist/src/modules/workspace/disk-agreement.js +33 -12
  95. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  96. package/dist/src/modules/workspace/index.d.ts +6 -3
  97. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  98. package/dist/src/modules/workspace/index.js +61 -15
  99. package/dist/src/modules/workspace/index.js.map +1 -1
  100. package/dist/src/modules/workspace/observe.d.ts +9 -1
  101. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  102. package/dist/src/modules/workspace/observe.js +50 -10
  103. package/dist/src/modules/workspace/observe.js.map +1 -1
  104. package/dist/src/modules/workspace/reconcile.d.ts +11 -1
  105. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  106. package/dist/src/modules/workspace/reconcile.js +19 -8
  107. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  108. package/dist/src/modules/workspace/types.d.ts +8 -0
  109. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  110. package/dist/src/modules/workspace/types.js.map +1 -1
  111. package/dist/src/modules/workspace/watcher.d.ts +7 -0
  112. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  113. package/dist/src/modules/workspace/watcher.js +7 -5
  114. package/dist/src/modules/workspace/watcher.js.map +1 -1
  115. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  116. package/dist/src/recovery/discord-awareness-outbox.js +1 -9
  117. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  118. package/dist/src/supplied-text.d.ts +2 -0
  119. package/dist/src/supplied-text.d.ts.map +1 -1
  120. package/dist/src/supplied-text.js +16 -7
  121. package/dist/src/supplied-text.js.map +1 -1
  122. package/dist/src/tool-result-guard.js +1 -1
  123. package/dist/src/tool-result-guard.js.map +1 -1
  124. package/dist/src/tune-out/coordinator.d.ts +2 -20
  125. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  126. package/dist/src/tune-out/coordinator.js +175 -70
  127. package/dist/src/tune-out/coordinator.js.map +1 -1
  128. package/dist/src/tune-out/tools.d.ts +14 -2
  129. package/dist/src/tune-out/tools.d.ts.map +1 -1
  130. package/dist/src/tune-out/tools.js.map +1 -1
  131. package/dist/src/types/agent.d.ts +6 -12
  132. package/dist/src/types/agent.d.ts.map +1 -1
  133. package/dist/src/types/framework.d.ts +65 -24
  134. package/dist/src/types/framework.d.ts.map +1 -1
  135. package/dist/src/types/module.d.ts +42 -3
  136. package/dist/src/types/module.d.ts.map +1 -1
  137. package/dist/src/types/trace.d.ts +4 -3
  138. package/dist/src/types/trace.d.ts.map +1 -1
  139. package/package.json +3 -3
  140. package/src/agent.ts +90 -25
  141. package/src/api/mcp-server-core.ts +10 -3
  142. package/src/code-execution/isolate/render.ts +7 -6
  143. package/src/code-execution/isolate/scripts.ts +5 -4
  144. package/src/framework.ts +1163 -770
  145. package/src/gate/event-gate.ts +220 -145
  146. package/src/gate/recipe-reconcile.ts +7 -5
  147. package/src/host-presentation.ts +4 -7
  148. package/src/index.ts +1 -0
  149. package/src/inference-bounds.ts +111 -0
  150. package/src/line-reader.ts +22 -5
  151. package/src/mcpl/errors.ts +4 -0
  152. package/src/mcpl/inbound-source.ts +2 -2
  153. package/src/mcpl/inference-router.ts +67 -10
  154. package/src/mcpl/modern-connection.ts +83 -68
  155. package/src/mcpl/protocol-family.ts +37 -4
  156. package/src/mcpl/push-coalescer.ts +2 -7
  157. package/src/mcpl/references.ts +3 -4
  158. package/src/mcpl/server-connection.ts +30 -21
  159. package/src/mcpl/tool-signature.ts +18 -11
  160. package/src/mcpl/transport.ts +261 -60
  161. package/src/mcpl/types.ts +18 -1
  162. package/src/module-registry.ts +178 -25
  163. package/src/modules/discord/discord-js-client.ts +33 -29
  164. package/src/modules/sender-name.ts +8 -5
  165. package/src/modules/workspace/disk-agreement.ts +39 -15
  166. package/src/modules/workspace/index.ts +65 -14
  167. package/src/modules/workspace/observe.ts +51 -10
  168. package/src/modules/workspace/reconcile.ts +22 -8
  169. package/src/modules/workspace/types.ts +5 -0
  170. package/src/modules/workspace/watcher.ts +8 -6
  171. package/src/recovery/discord-awareness-outbox.ts +1 -11
  172. package/src/supplied-text.ts +17 -6
  173. package/src/tool-result-guard.ts +1 -1
  174. package/src/tune-out/coordinator.ts +187 -85
  175. package/src/tune-out/tools.ts +14 -2
  176. package/src/types/agent.ts +6 -12
  177. package/src/types/framework.ts +66 -24
  178. package/src/types/module.ts +42 -3
  179. package/src/types/trace.ts +4 -3
  180. package/dist/src/reasoning-loss-notice.d.ts +0 -23
  181. package/dist/src/reasoning-loss-notice.d.ts.map +0 -1
  182. package/dist/src/reasoning-loss-notice.js +0 -52
  183. package/dist/src/reasoning-loss-notice.js.map +0 -1
  184. package/dist/src/reasoning-loss.d.ts +0 -24
  185. package/dist/src/reasoning-loss.d.ts.map +0 -1
  186. package/dist/src/reasoning-loss.js +0 -102
  187. package/dist/src/reasoning-loss.js.map +0 -1
  188. package/src/reasoning-loss-notice.ts +0 -64
  189. package/src/reasoning-loss.ts +0 -120
@@ -40,6 +40,8 @@
40
40
  * rule with no name is never merged: the merge keys rules by name.
41
41
  */
42
42
 
43
+ import { quotedValue } from '../supplied-text.js';
44
+
43
45
  /** What a start changed, by rule name, for its trace and its log line. */
44
46
  export interface RecipeChanges {
45
47
  added: string[];
@@ -173,11 +175,11 @@ export function reconcileWithRecipe(
173
175
  const agree = (a: unknown, b: unknown): boolean => meaningOf(a) === meaningOf(b);
174
176
  const liveDuplicate = duplicateName(live.rules);
175
177
  if (liveDuplicate !== undefined) {
176
- throw new Error(`gate.json names the rule "${liveDuplicate}" twice`);
178
+ throw new Error(`gate.json names the rule ${quotedValue(liveDuplicate)} twice`);
177
179
  }
178
180
  const recipeDuplicate = duplicateName(recipe.rules);
179
181
  if (recipeDuplicate !== undefined) {
180
- throw new Error(`the recipe names the rule "${recipeDuplicate}" twice`);
182
+ throw new Error(`the recipe names the rule ${quotedValue(recipeDuplicate)} twice`);
181
183
  }
182
184
 
183
185
  const changes = noChanges();
@@ -322,7 +324,7 @@ export function reconcileLine(changes: RecipeChanges): string | null {
322
324
  parts.push(`the recorded recipe couldn't be read (${changes.recordUnreadable}), so this start removed and redefined nothing`);
323
325
  }
324
326
  const list = (label: string, names: string[]) => {
325
- if (names.length > 0) parts.push(`${label} ${names.join(', ')}`);
327
+ if (names.length > 0) parts.push(`${label} ${names.map((name) => quotedValue(name)).join(', ')}`);
326
328
  };
327
329
  list('added', changes.added);
328
330
  list('removed', changes.removed);
@@ -332,13 +334,13 @@ export function reconcileLine(changes: RecipeChanges): string | null {
332
334
  list('kept as edited, though the recipe changed or dropped it:', changes.keptEdited);
333
335
  if (changes.differsFromRecipe.length > 0) {
334
336
  parts.push(
335
- `kept, though the recipe defines it otherwise: ${changes.differsFromRecipe.join(', ')} `
337
+ `kept, though the recipe defines it otherwise: ${changes.differsFromRecipe.map((name) => quotedValue(name)).join(', ')} `
336
338
  + '(remove such a rule, and the next start restores it from the recipe)',
337
339
  );
338
340
  }
339
341
  if (changes.outsideRecipe.length > 0) {
340
342
  parts.push(
341
- `kept, not in the recipe: ${changes.outsideRecipe.join(', ')} `
343
+ `kept, not in the recipe: ${changes.outsideRecipe.map((name) => quotedValue(name)).join(', ')} `
342
344
  + "(a stale one can be removed, and no start brings it back)",
343
345
  );
344
346
  }
@@ -29,7 +29,7 @@
29
29
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
30
30
  import { dirname } from 'node:path';
31
31
  import type { ToolDefinition } from './types/events.js';
32
- import { checkArgs, firstSentence, renderToolDefinition, signature } from './mcpl/tool-signature.js';
32
+ import { checkArgs, renderToolDefinition, toolLine } from './mcpl/tool-signature.js';
33
33
  import { META_TOOL_NAMES } from './mcpl/toolset-host.js';
34
34
 
35
35
  /** The built-in toolset's name, as agents address it (`toolsets--open {toolset: "settings"}`). */
@@ -274,10 +274,7 @@ export class HostPresentation {
274
274
  private announce(label: string, tools: ToolDefinition[], closeHint: string): string {
275
275
  const lines = [`${label} open: call these with ${META_TOOL_NAMES.act} {tool, args}; they aren't in your tool list.`];
276
276
  if (!tools.length) lines.push(' (none right now)');
277
- for (const t of tools) {
278
- const sentence = firstSentence(t.description);
279
- lines.push(` ${signature(t)}${sentence ? ` — ${sentence}` : ''}`);
280
- }
277
+ for (const t of tools) lines.push(toolLine(t));
281
278
  lines.push(`${closeHint} closes it.`);
282
279
  return lines.join('\n');
283
280
  }
@@ -318,7 +315,7 @@ export class HostPresentation {
318
315
  const open = this.isOpen(agentName, BUILTIN_TOOLSET);
319
316
  return [
320
317
  `${this.builtinLabel} ${open ? 'open' : 'closed'}${open ? '' : ` — ${META_TOOL_NAMES.open} {toolset:"${BUILTIN_TOOLSET}"} opens it`}`,
321
- ...tools.map((t) => ` ${signature(t)}${firstSentence(t.description) ? ` — ${firstSentence(t.description)}` : ''}`),
318
+ ...tools.map((t) => toolLine(t)),
322
319
  ].join('\n');
323
320
  }
324
321
  const { serverId } = which;
@@ -329,7 +326,7 @@ export class HostPresentation {
329
326
  const head = mode === 'full'
330
327
  ? `${this.tabLabel(serverId)} in your tool list${tools.length ? `; its hidden tools (${open ? 'open' : 'closed'}):` : '.'}`
331
328
  : `${this.tabLabel(serverId)} a tab, connected, ${open ? 'open' : `hidden — ${META_TOOL_NAMES.open} {server:"${serverId}"} opens it`}:`;
332
- return [head, ...tools.map((t) => ` ${signature(t)}${firstSentence(t.description) ? ` — ${firstSentence(t.description)}` : ''}`)].join('\n');
329
+ return [head, ...tools.map((t) => toolLine(t))].join('\n');
333
330
  }
334
331
 
335
332
  /** `toolsets--look {tool}` for a host toolset's tool, or null when no host toolset has it. */
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export * from './types/index.js';
5
5
  export { AgentFramework, AgentStoppedError, BudgetPreflightError, DefaultErrorPolicy, ProviderWaitNotSleptError, QuiesceBlockedError, ResumeBlockedError } from './framework.js';
6
6
  export type { RuntimeSettingsPreview, HostModeStatus, DeriveAgentOptions } from './framework.js';
7
7
  export { Agent } from './agent.js';
8
+ export { FLAT_MINIMUM_MS, type InferenceBoundsOptions } from './inference-bounds.js';
8
9
  export { storedInjectionMarkOf, type StoredInjectionMark } from './stored-injections.js';
9
10
  export type { StartStreamResult } from './agent.js';
10
11
  export { ProcessQueueImpl } from './queue.js';
@@ -0,0 +1,111 @@
1
+ import { MembraneError } from '@animalabs/membrane';
2
+ import { PROGRESS_BOUNDS, resolveProgressBounds, type ProgressBounds } from '@animalabs/membrane/progress-bounds';
3
+
4
+ /** FrameworkConfig.inferenceBounds: how long the framework lets a model call
5
+ * go without progress. */
6
+ export interface InferenceBoundsOptions {
7
+ /** Membrane's phase bounds for a stream whose adapter reports content
8
+ * (Anthropic, Bedrock), and the values the flat and whole-call bounds
9
+ * derive from: the larger of `beforeContentMs` and `betweenEventsMs`,
10
+ * plus `max_tokens` at `thinkTokensPerSecond`. Each one given replaces
11
+ * membrane's default (PROGRESS_BOUNDS). */
12
+ progressBounds?: Partial<ProgressBounds>;
13
+ /** The least the flat bound on a stream can be, from its last provider
14
+ * event. Default FLAT_MINIMUM_MS. */
15
+ flatMinimumMs?: number;
16
+ }
17
+
18
+ export const FLAT_MINIMUM_MS = 1_862_000;
19
+
20
+ export interface InferenceBounds {
21
+ readonly progressBounds: Readonly<ProgressBounds>;
22
+ readonly flatMinimumMs: number;
23
+ }
24
+
25
+ export function resolveInferenceBounds(option: false | InferenceBoundsOptions | undefined): InferenceBounds | null {
26
+ if (option === false) return null;
27
+ if (option !== undefined && (typeof option !== 'object' || option === null)) {
28
+ throw new TypeError(`[agent-framework] inferenceBounds must be false or an object, got ${option === null ? 'null' : typeof option}`);
29
+ }
30
+ const given = option?.progressBounds;
31
+ if (given !== undefined && (typeof given !== 'object' || given === null)) {
32
+ throw new TypeError(`[agent-framework] inferenceBounds.progressBounds must be an object of bounds, got ${given === null ? 'null' : typeof given}`);
33
+ }
34
+ let progressBounds: ProgressBounds;
35
+ try {
36
+ progressBounds = resolveProgressBounds(given ?? true) ?? { ...PROGRESS_BOUNDS };
37
+ } catch (refused) {
38
+ const why = refused instanceof Error ? refused.message.replace(/^\[membrane\] /, '') : String(refused);
39
+ throw new (refused instanceof TypeError ? TypeError : RangeError)(`[agent-framework] inferenceBounds.${why}`);
40
+ }
41
+ const flatMinimumMs: unknown = option?.flatMinimumMs === undefined ? FLAT_MINIMUM_MS : option.flatMinimumMs;
42
+ if (typeof flatMinimumMs !== 'number' || !Number.isFinite(flatMinimumMs) || flatMinimumMs <= 0) {
43
+ throw new RangeError(`[agent-framework] inferenceBounds.flatMinimumMs must be a finite number above 0, got ${String(flatMinimumMs)}`);
44
+ }
45
+ return Object.freeze({ progressBounds: Object.freeze(progressBounds), flatMinimumMs });
46
+ }
47
+
48
+ function allowanceMs(bounds: Readonly<ProgressBounds>, maxTokens: number | undefined): number {
49
+ return typeof maxTokens === 'number' && Number.isFinite(maxTokens) && maxTokens > 0
50
+ ? Math.ceil(maxTokens / bounds.thinkTokensPerSecond) * 1000
51
+ : 0;
52
+ }
53
+
54
+ function setupMs(bounds: Readonly<ProgressBounds>): number {
55
+ return Math.max(bounds.beforeContentMs, bounds.betweenEventsMs);
56
+ }
57
+
58
+ export function streamBounds(
59
+ bounds: InferenceBounds | null,
60
+ maxTokens: number | undefined,
61
+ ): { progressBounds?: Readonly<ProgressBounds>; progressTimeoutMs?: number } {
62
+ if (bounds === null) return {};
63
+ const phases = bounds.progressBounds;
64
+ return {
65
+ progressBounds: phases,
66
+ progressTimeoutMs: Math.max(setupMs(phases) + allowanceMs(phases, maxTokens), bounds.flatMinimumMs),
67
+ };
68
+ }
69
+
70
+ export function wholeCallMs(bounds: InferenceBounds, maxTokens: number | undefined): number {
71
+ if (!(typeof maxTokens === 'number' && Number.isFinite(maxTokens) && maxTokens > 0)) return Math.max(setupMs(bounds.progressBounds), bounds.flatMinimumMs);
72
+ return setupMs(bounds.progressBounds) + allowanceMs(bounds.progressBounds, maxTokens);
73
+ }
74
+
75
+ // membrane 0.8.0 marks a deadline it reports as run out with this registered
76
+ // symbol (on a sent request, or the Anthropic SDK's timeout, which covers its
77
+ // connect timeout too) and exports no reader or marker for it. The one reader:
78
+ // the deadline class here and the transient-failure check of an exhausted
79
+ // turn's re-arm both use it.
80
+ const DEADLINE_EXPIRY = Symbol.for('membrane.deadlineExpiry');
81
+
82
+ export function isDeadlineExpiry(error: unknown): boolean {
83
+ return typeof error === 'object' && error !== null && (error as Record<symbol, unknown>)[DEADLINE_EXPIRY] === true;
84
+ }
85
+
86
+ /**
87
+ * Names the host setting for the bound a stream cut hit, read from the opening
88
+ * of membrane 0.8.0's message for it (its progress-bounds.ts and
89
+ * providers/utils.ts), so the reason an operator and the agent see names the
90
+ * host's setting for that bound, as the README's table maps them. A message it
91
+ * doesn't know is left as it is.
92
+ */
93
+ export function withHostSetting(message: string): string {
94
+ const setting = message.includes('of a thinking block\'s start') ? 'progressBounds.thinkTokensPerSecond'
95
+ : message.startsWith('No content within ') ? 'progressBounds.beforeContentMs'
96
+ : message.startsWith('No provider event within ') ? 'progressBounds.betweenEventsMs'
97
+ : message.startsWith('No provider progress within ') ? 'flatMinimumMs'
98
+ : undefined;
99
+ return setting === undefined ? message : `${message} (the host's setting for this bound: \`inferenceBounds.${setting}\`)`;
100
+ }
101
+
102
+ export function deadlineCut(message: string | undefined): MembraneError {
103
+ const error = new MembraneError({
104
+ type: 'timeout',
105
+ retryable: false,
106
+ message: message === undefined ? 'The provider made no progress within the call\'s bound' : withHostSetting(message),
107
+ rawError: undefined,
108
+ });
109
+ Object.defineProperty(error, DEADLINE_EXPIRY, { value: true });
110
+ return error;
111
+ }
@@ -4,12 +4,26 @@ import { StringDecoder } from 'node:string_decoder';
4
4
  const LF = 0x0a;
5
5
  const CR = 0x0d;
6
6
 
7
- export function readLines(input: Readable, onLine: (line: string) => void, onEnd?: () => void): () => void {
7
+ export const MAX_LINE_CHARS = 100 * 1024 * 1024;
8
+
9
+ export interface LineHandlers {
10
+ line: (line: string) => void;
11
+ end?: () => void;
12
+ tooLong: (maxChars: number) => void;
13
+ }
14
+
15
+ export function readLines(input: Readable, on: LineHandlers, maxChars = MAX_LINE_CHARS): () => void {
8
16
  const decoder = new StringDecoder('utf8');
9
17
  let partial = '';
10
18
  let lineEndedAtCr = false;
11
19
  let stopped = false;
12
20
 
21
+ const refuse = () => {
22
+ stop();
23
+ partial = '';
24
+ on.tooLong(maxChars);
25
+ };
26
+
13
27
  const take = (text: string) => {
14
28
  let from = 0;
15
29
  if (lineEndedAtCr && text.length > 0) {
@@ -19,6 +33,7 @@ export function readLines(input: Readable, onLine: (line: string) => void, onEnd
19
33
  for (let i = from; i < text.length && !stopped; i++) {
20
34
  const code = text.charCodeAt(i);
21
35
  if (code !== LF && code !== CR) continue;
36
+ if (partial.length + (i - from) > maxChars) return refuse();
22
37
  const line = partial + text.slice(from, i);
23
38
  partial = '';
24
39
  if (code === CR) {
@@ -26,9 +41,11 @@ export function readLines(input: Readable, onLine: (line: string) => void, onEnd
26
41
  else if (text.charCodeAt(i + 1) === LF) i++;
27
42
  }
28
43
  from = i + 1;
29
- onLine(line);
44
+ on.line(line);
30
45
  }
31
- if (!stopped) partial += text.slice(from);
46
+ if (stopped) return;
47
+ if (partial.length + (text.length - from) > maxChars) return refuse();
48
+ partial += text.slice(from);
32
49
  };
33
50
 
34
51
  const onData = (chunk: Buffer | string) => take(decoder.write(chunk));
@@ -37,11 +54,11 @@ export function readLines(input: Readable, onLine: (line: string) => void, onEnd
37
54
  if (!stopped && partial.length > 0) {
38
55
  const last = partial;
39
56
  partial = '';
40
- onLine(last);
57
+ on.line(last);
41
58
  }
42
59
  if (stopped) return;
43
60
  stop();
44
- onEnd?.();
61
+ on.end?.();
45
62
  };
46
63
  const stop = () => {
47
64
  stopped = true;
@@ -68,6 +68,10 @@ export function boundedForLog(text: string, max = 200): string {
68
68
  return points.length > max ? `${points.slice(0, max).join('')}…` : points.join('');
69
69
  }
70
70
 
71
+ export function hostStoppedWhileHandling(): JsonRpcError {
72
+ return { code: -32000, message: 'the host stopped while handling this request' };
73
+ }
74
+
71
75
  /**
72
76
  * Create a "feature set not enabled" error.
73
77
  * Returned when a server sends a message tagged with a feature set
@@ -167,8 +167,8 @@ export function conversationKey(source: InboundSource): string | undefined {
167
167
  * well-formed is dropped (the store doesn't keep it). An `undefined` array
168
168
  * element is written as `null`, as JSON writes it: the store's append
169
169
  * refuses a body holding one, so no stored copy does. So a value
170
- * hashes alike however its keys were ordered (content read back from the
171
- * store has them in a different order than it was written), and alike
170
+ * hashes alike however its keys were ordered (a store on chronicle before
171
+ * 0.5.1 hands content back with its keys sorted), and alike
172
172
  * before and after the store. editMessage writes JSON instead, so an edited
173
173
  * copy can keep what an append wouldn't; it has changed anyway.
174
174
  *
@@ -25,8 +25,10 @@ import type {
25
25
  } from './types.js';
26
26
  import type { FeatureSetManager } from './feature-set-manager.js';
27
27
  import { McplFeatureSetError } from './feature-set-manager.js';
28
+ import { hostStoppedWhileHandling } from './errors.js';
28
29
  import type { HookOrchestrator } from './hook-orchestrator.js';
29
30
  import { logValue } from './log-value.js';
31
+ import { streamBounds, wholeCallMs, withHostSetting, type InferenceBounds } from '../inference-bounds.js';
30
32
 
31
33
  // ============================================================================
32
34
  // Responder interface
@@ -41,6 +43,7 @@ interface Responder {
41
43
  /** Sends a streamed chunk on the transport the request came on, as its
42
44
  * answer is sent. */
43
45
  sendChunk?(params: InferenceChunkParams): void;
46
+ signal?: AbortSignal;
44
47
  }
45
48
 
46
49
  // ============================================================================
@@ -186,6 +189,7 @@ export class InferenceRouter {
186
189
  * server with no policy, and per server and feature set with one, so a
187
190
  * server that keeps asking doesn't flood the operator's log. */
188
191
  private loggedRefusals = new Set<string>();
192
+ private running = new Set<{ serverId: string; stop: AbortController; answered: Promise<void> }>();
189
193
 
190
194
  constructor(
191
195
  membrane: Membrane,
@@ -193,6 +197,7 @@ export class InferenceRouter {
193
197
  featureSetManager: FeatureSetManager,
194
198
  routingPolicy: InferenceRoutingPolicy | null,
195
199
  emitTraceFn: (event: { type: string; [key: string]: unknown }) => void,
200
+ private readonly inferenceBounds: () => InferenceBounds | null = () => null,
196
201
  ) {
197
202
  this.membrane = membrane;
198
203
  this.hookOrchestrator = hookOrchestrator;
@@ -216,6 +221,31 @@ export class InferenceRouter {
216
221
  serverId: string,
217
222
  params: McplInferenceRequestParams,
218
223
  responder: Responder,
224
+ ): Promise<void> {
225
+ const stop = new AbortController();
226
+ let ended!: () => void;
227
+ const entry = { serverId, stop, answered: new Promise<void>((resolve) => { ended = resolve; }) };
228
+ this.running.add(entry);
229
+ try {
230
+ await this.answer(serverId, params, responder, stop.signal);
231
+ } finally {
232
+ this.running.delete(entry);
233
+ ended();
234
+ }
235
+ }
236
+
237
+ stopAll(): Array<{ label: string; promise: Promise<void> }> {
238
+ return [...this.running].map(({ serverId, stop, answered }) => {
239
+ stop.abort();
240
+ return { label: `inference/request:${serverId}`, promise: answered };
241
+ });
242
+ }
243
+
244
+ private async answer(
245
+ serverId: string,
246
+ params: McplInferenceRequestParams,
247
+ responder: Responder,
248
+ stopped: AbortSignal,
219
249
  ): Promise<void> {
220
250
  // 1. Validate feature set
221
251
  try {
@@ -295,6 +325,24 @@ export class InferenceRouter {
295
325
  return;
296
326
  }
297
327
 
328
+ const signal = responder.signal ? AbortSignal.any([stopped, responder.signal]) : stopped;
329
+ let stopAnswered = false;
330
+ const answerStop = () => {
331
+ if (responder.signal?.aborted) return;
332
+ stopAnswered = true;
333
+ const answer = hostStoppedWhileHandling();
334
+ this.emitTraceFn({ type: 'mcpl:inference_fail', serverId, model, error: answer.message, streamed });
335
+ // A throw from an abort listener would be uncaught.
336
+ try {
337
+ responder.respondError(answer.code, answer.message);
338
+ } catch {}
339
+ };
340
+ stopped.addEventListener('abort', answerStop, { once: true });
341
+ const settled = (): boolean => {
342
+ stopped.removeEventListener('abort', answerStop);
343
+ return stopAnswered;
344
+ };
345
+
298
346
  // Emit trace: inference start
299
347
  this.emitTraceFn({
300
348
  type: 'mcpl:inference_start',
@@ -319,12 +367,14 @@ export class InferenceRouter {
319
367
  },
320
368
  };
321
369
 
370
+ if (stopAnswered) return;
322
371
  try {
323
372
  // 5. Run inference. Both modes end in membrane's response, and one
324
373
  // builder makes the result from it.
325
374
  const response = chunkRequestId != null
326
- ? await this.streamResponse(request, chunkRequestId, sendChunk!)
327
- : await this.membrane.complete(request);
375
+ ? await this.streamResponse(request, chunkRequestId, sendChunk!, signal)
376
+ : await this.membrane.complete(request, { ...this.wholeCallBound(request), signal });
377
+ if (settled()) return;
328
378
 
329
379
  // 6. Answer
330
380
  const result = resultFrom(response, model);
@@ -344,22 +394,23 @@ export class InferenceRouter {
344
394
 
345
395
  responder.respond(result);
346
396
  } catch (err) {
397
+ if (settled()) return;
347
398
  // Every ending that isn't a completion lands here, in either mode:
348
399
  // membrane's error, a stream that stopped early, a tool call, or a
349
400
  // stop FinishReason can't name. The answer is the JSON-RPC error, never
350
401
  // a result carrying what arrived.
402
+ const transportClosed = responder.signal?.aborted === true;
351
403
  this.emitTraceFn({
352
404
  type: 'mcpl:inference_fail',
353
405
  serverId,
354
406
  model,
355
- error: err instanceof Error ? err.message : String(err),
407
+ error: transportClosed ? 'the transport the request came on closed' : err instanceof Error ? err.message : String(err),
356
408
  streamed,
357
409
  });
358
410
 
359
- responder.respondError(
360
- -32603,
361
- err instanceof Error ? err.message : 'Inference failed',
362
- );
411
+ if (!transportClosed) {
412
+ responder.respondError(-32603, err instanceof Error ? err.message : 'Inference failed');
413
+ }
363
414
  }
364
415
  }
365
416
 
@@ -407,6 +458,11 @@ export class InferenceRouter {
407
458
  // Private: Streaming
408
459
  // ==========================================================================
409
460
 
461
+ private wholeCallBound(request: NormalizedRequest): { progressTimeoutMs?: number } {
462
+ const bounds = this.inferenceBounds();
463
+ return bounds === null ? {} : { progressTimeoutMs: wholeCallMs(bounds, request.config.maxTokens) };
464
+ }
465
+
410
466
  /**
411
467
  * Run the inference as a stream, sending each visible text delta to the
412
468
  * server as an inference/chunk, and return membrane's response once the
@@ -424,8 +480,9 @@ export class InferenceRouter {
424
480
  request: NormalizedRequest,
425
481
  requestId: string | number,
426
482
  sendChunk: (params: InferenceChunkParams) => void,
483
+ signal: AbortSignal,
427
484
  ): Promise<NormalizedResponse> {
428
- const stream = this.membrane.streamYielding(request);
485
+ const stream = this.membrane.streamYielding(request, { ...streamBounds(this.inferenceBounds(), request.config.maxTokens), signal });
429
486
  let index = 0;
430
487
 
431
488
  for await (const event of stream) {
@@ -437,7 +494,7 @@ export class InferenceRouter {
437
494
  // XML tool mode the chunks can miss text the content holds: prose
438
495
  // naming a tag the stream parser opens a block for, or a thinking
439
496
  // block cut off before its close tag.
440
- if (event.meta.visible) {
497
+ if (event.meta.visible && !signal.aborted) {
441
498
  sendChunk({ requestId, index: index++, delta: event.content });
442
499
  }
443
500
  break;
@@ -448,7 +505,7 @@ export class InferenceRouter {
448
505
  case 'error':
449
506
  throw event.error;
450
507
  case 'aborted':
451
- throw new Error(`The inference stopped before it finished (${event.reason})`);
508
+ throw new Error(`The inference stopped before it finished (${event.reason}${event.message ? `: ${event.reason === 'timeout' ? withHostSetting(event.message) : event.message}` : ''})`);
452
509
  }
453
510
  }
454
511
  throw new Error('The inference stream ended without a result');