@animalabs/agent-framework 0.19.0 → 0.20.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 (193) hide show
  1. package/README.md +29 -0
  2. package/dist/src/agent.d.ts +22 -0
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +128 -10
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/types.d.ts +1 -1
  7. package/dist/src/api/types.d.ts.map +1 -1
  8. package/dist/src/code-execution/py-runner.d.ts +10 -1
  9. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  10. package/dist/src/code-execution/py-runner.js +27 -2
  11. package/dist/src/code-execution/py-runner.js.map +1 -1
  12. package/dist/src/code-execution/tool-definition.d.ts +21 -0
  13. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  14. package/dist/src/code-execution/tool-definition.js +34 -0
  15. package/dist/src/code-execution/tool-definition.js.map +1 -1
  16. package/dist/src/framework.d.ts +153 -2
  17. package/dist/src/framework.d.ts.map +1 -1
  18. package/dist/src/framework.js +1169 -76
  19. package/dist/src/framework.js.map +1 -1
  20. package/dist/src/index.d.ts +2 -0
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/index.js +4 -0
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/mcpl/capability-grant.d.ts +1 -1
  25. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  26. package/dist/src/mcpl/capability-grant.js +49 -5
  27. package/dist/src/mcpl/capability-grant.js.map +1 -1
  28. package/dist/src/mcpl/channel-registry.d.ts +59 -2
  29. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  30. package/dist/src/mcpl/channel-registry.js +106 -6
  31. package/dist/src/mcpl/channel-registry.js.map +1 -1
  32. package/dist/src/mcpl/index.d.ts +3 -0
  33. package/dist/src/mcpl/index.d.ts.map +1 -1
  34. package/dist/src/mcpl/index.js +3 -0
  35. package/dist/src/mcpl/index.js.map +1 -1
  36. package/dist/src/mcpl/push-coalescer.d.ts +318 -0
  37. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -0
  38. package/dist/src/mcpl/push-coalescer.js +709 -0
  39. package/dist/src/mcpl/push-coalescer.js.map +1 -0
  40. package/dist/src/mcpl/push-handler.d.ts +7 -2
  41. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  42. package/dist/src/mcpl/push-handler.js +43 -4
  43. package/dist/src/mcpl/push-handler.js.map +1 -1
  44. package/dist/src/mcpl/server-connection.d.ts +18 -0
  45. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  46. package/dist/src/mcpl/server-connection.js +44 -1
  47. package/dist/src/mcpl/server-connection.js.map +1 -1
  48. package/dist/src/mcpl/tool-classes.d.ts +71 -0
  49. package/dist/src/mcpl/tool-classes.d.ts.map +1 -0
  50. package/dist/src/mcpl/tool-classes.js +170 -0
  51. package/dist/src/mcpl/tool-classes.js.map +1 -0
  52. package/dist/src/mcpl/tool-glob.d.ts +16 -0
  53. package/dist/src/mcpl/tool-glob.d.ts.map +1 -0
  54. package/dist/src/mcpl/tool-glob.js +41 -0
  55. package/dist/src/mcpl/tool-glob.js.map +1 -0
  56. package/dist/src/mcpl/tool-lifecycle.d.ts +253 -0
  57. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -0
  58. package/dist/src/mcpl/tool-lifecycle.js +592 -0
  59. package/dist/src/mcpl/tool-lifecycle.js.map +1 -0
  60. package/dist/src/mcpl/transport.d.ts +12 -0
  61. package/dist/src/mcpl/transport.d.ts.map +1 -1
  62. package/dist/src/mcpl/transport.js +33 -1
  63. package/dist/src/mcpl/transport.js.map +1 -1
  64. package/dist/src/mcpl/types.d.ts +66 -1
  65. package/dist/src/mcpl/types.d.ts.map +1 -1
  66. package/dist/src/mcpl/types.js +6 -0
  67. package/dist/src/mcpl/types.js.map +1 -1
  68. package/dist/src/modules/discord/index.js +1 -1
  69. package/dist/src/modules/discord/index.js.map +1 -1
  70. package/dist/src/modules/history/index.d.ts +85 -0
  71. package/dist/src/modules/history/index.d.ts.map +1 -1
  72. package/dist/src/modules/history/index.js +1019 -24
  73. package/dist/src/modules/history/index.js.map +1 -1
  74. package/dist/src/modules/history/semantic.d.ts +202 -0
  75. package/dist/src/modules/history/semantic.d.ts.map +1 -0
  76. package/dist/src/modules/history/semantic.js +418 -0
  77. package/dist/src/modules/history/semantic.js.map +1 -0
  78. package/dist/src/modules/index.d.ts +2 -1
  79. package/dist/src/modules/index.d.ts.map +1 -1
  80. package/dist/src/modules/index.js.map +1 -1
  81. package/dist/src/tool-result-guard.d.ts +82 -0
  82. package/dist/src/tool-result-guard.d.ts.map +1 -0
  83. package/dist/src/tool-result-guard.js +306 -0
  84. package/dist/src/tool-result-guard.js.map +1 -0
  85. package/dist/src/types/agent.d.ts +14 -0
  86. package/dist/src/types/agent.d.ts.map +1 -1
  87. package/dist/src/types/framework.d.ts +21 -0
  88. package/dist/src/types/framework.d.ts.map +1 -1
  89. package/dist/src/types/trace.d.ts +17 -0
  90. package/dist/src/types/trace.d.ts.map +1 -1
  91. package/dist/test/channel-registry-routing.test.js +59 -0
  92. package/dist/test/channel-registry-routing.test.js.map +1 -1
  93. package/dist/test/code-execution.test.js +139 -0
  94. package/dist/test/code-execution.test.js.map +1 -1
  95. package/dist/test/helpers/coalescing-fixture.d.ts +72 -0
  96. package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -0
  97. package/dist/test/helpers/coalescing-fixture.js +145 -0
  98. package/dist/test/helpers/coalescing-fixture.js.map +1 -0
  99. package/dist/test/helpers/coalescing-kill-child.d.ts +2 -0
  100. package/dist/test/helpers/coalescing-kill-child.d.ts.map +1 -0
  101. package/dist/test/helpers/coalescing-kill-child.js +24 -0
  102. package/dist/test/helpers/coalescing-kill-child.js.map +1 -0
  103. package/dist/test/history-module-realstore.test.d.ts +2 -0
  104. package/dist/test/history-module-realstore.test.d.ts.map +1 -0
  105. package/dist/test/history-module-realstore.test.js +363 -0
  106. package/dist/test/history-module-realstore.test.js.map +1 -0
  107. package/dist/test/history-module-semantic.test.d.ts +2 -0
  108. package/dist/test/history-module-semantic.test.d.ts.map +1 -0
  109. package/dist/test/history-module-semantic.test.js +213 -0
  110. package/dist/test/history-module-semantic.test.js.map +1 -0
  111. package/dist/test/history-module-ux.test.d.ts +2 -0
  112. package/dist/test/history-module-ux.test.d.ts.map +1 -0
  113. package/dist/test/history-module-ux.test.js +304 -0
  114. package/dist/test/history-module-ux.test.js.map +1 -0
  115. package/dist/test/history-module.test.js +25 -1
  116. package/dist/test/history-module.test.js.map +1 -1
  117. package/dist/test/history-semantic-review-findings.test.d.ts +2 -0
  118. package/dist/test/history-semantic-review-findings.test.d.ts.map +1 -0
  119. package/dist/test/history-semantic-review-findings.test.js +338 -0
  120. package/dist/test/history-semantic-review-findings.test.js.map +1 -0
  121. package/dist/test/history-semantic-sync-converges.test.d.ts +2 -0
  122. package/dist/test/history-semantic-sync-converges.test.d.ts.map +1 -0
  123. package/dist/test/history-semantic-sync-converges.test.js +109 -0
  124. package/dist/test/history-semantic-sync-converges.test.js.map +1 -0
  125. package/dist/test/mcpl-capability-grant.test.js +3 -1
  126. package/dist/test/mcpl-capability-grant.test.js.map +1 -1
  127. package/dist/test/mcpl-child-env.test.d.ts +2 -0
  128. package/dist/test/mcpl-child-env.test.d.ts.map +1 -0
  129. package/dist/test/mcpl-child-env.test.js +69 -0
  130. package/dist/test/mcpl-child-env.test.js.map +1 -0
  131. package/dist/test/mcpl-coalescing.test.d.ts +2 -0
  132. package/dist/test/mcpl-coalescing.test.d.ts.map +1 -0
  133. package/dist/test/mcpl-coalescing.test.js +1050 -0
  134. package/dist/test/mcpl-coalescing.test.js.map +1 -0
  135. package/dist/test/mcpl-list-status.test.js +4 -0
  136. package/dist/test/mcpl-list-status.test.js.map +1 -1
  137. package/dist/test/mcpl-tool-class-listing.test.d.ts +2 -0
  138. package/dist/test/mcpl-tool-class-listing.test.d.ts.map +1 -0
  139. package/dist/test/mcpl-tool-class-listing.test.js +147 -0
  140. package/dist/test/mcpl-tool-class-listing.test.js.map +1 -0
  141. package/dist/test/mcpl-tool-classes.test.d.ts +7 -0
  142. package/dist/test/mcpl-tool-classes.test.d.ts.map +1 -0
  143. package/dist/test/mcpl-tool-classes.test.js +80 -0
  144. package/dist/test/mcpl-tool-classes.test.js.map +1 -0
  145. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts +2 -0
  146. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts.map +1 -0
  147. package/dist/test/mcpl-tool-lifecycle-e2e.test.js +237 -0
  148. package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -0
  149. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts +11 -0
  150. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts.map +1 -0
  151. package/dist/test/mcpl-tool-lifecycle-emitter.test.js +307 -0
  152. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -0
  153. package/dist/test/mcpl-tool-lifecycle.test.d.ts +11 -0
  154. package/dist/test/mcpl-tool-lifecycle.test.d.ts.map +1 -0
  155. package/dist/test/mcpl-tool-lifecycle.test.js +270 -0
  156. package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -0
  157. package/dist/test/silent-heartbeat.test.d.ts +2 -0
  158. package/dist/test/silent-heartbeat.test.d.ts.map +1 -0
  159. package/dist/test/silent-heartbeat.test.js +154 -0
  160. package/dist/test/silent-heartbeat.test.js.map +1 -0
  161. package/dist/test/tool-result-guard.test.d.ts +2 -0
  162. package/dist/test/tool-result-guard.test.d.ts.map +1 -0
  163. package/dist/test/tool-result-guard.test.js +1081 -0
  164. package/dist/test/tool-result-guard.test.js.map +1 -0
  165. package/dist/test/trunk-channel-routing.test.js +67 -0
  166. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  167. package/dist/tsconfig.tsbuildinfo +1 -1
  168. package/package.json +2 -2
  169. package/src/agent.ts +128 -10
  170. package/src/api/types.ts +2 -0
  171. package/src/code-execution/py-runner.ts +34 -2
  172. package/src/code-execution/tool-definition.ts +46 -0
  173. package/src/framework.ts +1201 -80
  174. package/src/index.ts +13 -0
  175. package/src/mcpl/capability-grant.ts +54 -6
  176. package/src/mcpl/channel-registry.ts +115 -7
  177. package/src/mcpl/index.ts +29 -0
  178. package/src/mcpl/push-coalescer.ts +821 -0
  179. package/src/mcpl/push-handler.ts +40 -4
  180. package/src/mcpl/server-connection.ts +48 -2
  181. package/src/mcpl/tool-classes.ts +188 -0
  182. package/src/mcpl/tool-glob.ts +36 -0
  183. package/src/mcpl/tool-lifecycle.ts +730 -0
  184. package/src/mcpl/transport.ts +35 -1
  185. package/src/mcpl/types.ts +69 -1
  186. package/src/modules/discord/index.ts +1 -1
  187. package/src/modules/history/index.ts +1089 -24
  188. package/src/modules/history/semantic.ts +473 -0
  189. package/src/modules/index.ts +2 -1
  190. package/src/tool-result-guard.ts +318 -0
  191. package/src/types/agent.ts +15 -0
  192. package/src/types/framework.ts +23 -0
  193. package/src/types/trace.ts +17 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/agent-framework",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Multi-agent framework with pluggable modules, persistent state, and concurrent tool execution",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,7 +47,7 @@
47
47
  "license": "MIT",
48
48
  "dependencies": {
49
49
  "@animalabs/chronicle": "^0.4.0",
50
- "@animalabs/context-manager": "^0.11.0",
50
+ "@animalabs/context-manager": "^0.12.0",
51
51
  "@animalabs/membrane": "^0.5.78",
52
52
  "chokidar": "^4.0.3",
53
53
  "discord.js": "^14.25.1",
package/src/agent.ts CHANGED
@@ -2,6 +2,7 @@ import type { Membrane, NormalizedMessage, NormalizedRequest, ContentBlock, Yiel
2
2
  import { isAbortedResponse } from '@animalabs/membrane';
3
3
  import { createHash } from 'node:crypto';
4
4
  import type { CacheWireReceipt, KvUnifiedRequestHooks } from './kv-unified-wire.js';
5
+ import { ToolResultGuard, TOOL_RESULT_GUARD_NOTICE } from './tool-result-guard.js';
5
6
  import {
6
7
  toolResultDataToHistoryString,
7
8
  truncateForHistory,
@@ -82,6 +83,7 @@ export class Agent {
82
83
  readonly thinking: AgentConfig['thinking'];
83
84
  /** Refusal auto-rewind policy (see AgentConfig.refusalHandling). */
84
85
  readonly refusalHandling: AgentConfig['refusalHandling'];
86
+ readonly toolResultGuard: ToolResultGuard;
85
87
  /** Prose delivery mode (see AgentConfig.proseRouting). Default 'locus'. */
86
88
  readonly proseRouting: 'locus' | 'explicit' | 'hybrid' | 'disabled';
87
89
  /** Exact whole-response known-tool wrapper containment (default off). */
@@ -143,6 +145,7 @@ export class Agent {
143
145
  this.temperature = config.temperature;
144
146
  this.thinking = config.thinking;
145
147
  this.refusalHandling = config.refusalHandling;
148
+ this.toolResultGuard = new ToolResultGuard(config.name, contextManager, config.toolResultGuard);
146
149
  this.proseRouting = config.proseRouting ?? 'locus';
147
150
  this.toolWrapperProseGuard = config.toolWrapperProseGuard ?? false;
148
151
  this.cacheTtl = config.cacheTtl ?? '1h';
@@ -225,6 +228,20 @@ export class Agent {
225
228
  return { maxTokens: this.contextBudgetTokens, reserveForResponse: this.maxTokens };
226
229
  }
227
230
 
231
+ /** Compile budget with the tool-result guard's reservation applied. While
232
+ * a batch is pending the strategy selects against short placeholders, but
233
+ * prepareRequest substitutes the originals on the wire; reserve their real
234
+ * cost so the final request still fits (a budget restart that recompiles
235
+ * small must not re-inflate past the same window). */
236
+ private compileBudget(budget?: TokenBudget): TokenBudget | undefined {
237
+ const resolved = this.resolveBudget(budget);
238
+ const reserve = this.toolResultGuard.pendingWireReserveTokens;
239
+ if (reserve === 0) return resolved;
240
+ // Mirrors ContextManager.compile's own default when no budget is set.
241
+ const base = resolved ?? { maxTokens: 100_000, reserveForResponse: 4_000 };
242
+ return { ...base, reserveForResponse: base.reserveForResponse + reserve };
243
+ }
244
+
228
245
  /** Structural compatibility keeps lightweight test/host ContextManager
229
246
  * doubles working while making the new capability optional at runtime. */
230
247
  private getHotContextSettings(): HotContextSettingsStatus | null {
@@ -522,8 +539,50 @@ export class Agent {
522
539
  this.updateHotContextSettings({ preparedWindowTokens: null });
523
540
  }
524
541
 
542
+ /**
543
+ * RFC-006 consumed watermark: the newest stored message at the last compile
544
+ * of a model request, per branch. Everything above it has never been in a
545
+ * request and may still be replaced or withdrawn in place by its sender.
546
+ */
547
+ private consumedWatermark: { branch: string; sequence: number } | null = null;
548
+
549
+ getConsumedWatermark(): { branch: string; sequence: number } | null {
550
+ return this.consumedWatermark;
551
+ }
552
+
553
+ /** Mark everything currently stored as consumed (boot, branch switch). */
554
+ markContextConsumed(): void {
555
+ // Tolerates partial context-manager doubles (tests build Agents over
556
+ // stubs): without a watermark nothing is ever treated as unread.
557
+ this.consumedWatermark = this.pendingWatermark();
558
+ }
559
+
560
+ /** The watermark a successful compile will establish (taken before, applied after). */
561
+ private pendingWatermark(): { branch: string; sequence: number } | null {
562
+ const cm = this.contextManager as Partial<ContextManager>;
563
+ try {
564
+ if (typeof cm.currentBranch !== 'function' || typeof cm.getAllMessages !== 'function') return null;
565
+ // The chronicle head, not this slot's last message: a compile reads
566
+ // everything stored on the branch so far, including messages merged in
567
+ // from another slot (the subconscious reads the residents' shared slot).
568
+ const store = typeof cm.getStore === 'function' ? cm.getStore() as { currentSequence?: () => number } : undefined;
569
+ let sequence = typeof store?.currentSequence === 'function' ? store.currentSequence() : undefined;
570
+ if (sequence === undefined) {
571
+ const count = typeof cm.getMessageCount === 'function' ? cm.getMessageCount() : cm.getAllMessages().length;
572
+ sequence = count > 0 ? cm.getAllMessages().at(-1)?.sequence ?? 0 : 0;
573
+ }
574
+ return { branch: cm.currentBranch().name, sequence };
575
+ } catch {
576
+ return null;
577
+ }
578
+ }
579
+
525
580
  async compileContext(budget?: TokenBudget): Promise<CompileResult> {
526
- const result = await this.contextManager.compile(this.resolveBudget(budget));
581
+ // RFC-006 §3.3: consumption is determined at assembly — a compile that
582
+ // fails assembled nothing, so the watermark moves only on success.
583
+ const watermark = this.pendingWatermark();
584
+ const result = await this.contextManager.compile(this.compileBudget(budget));
585
+ if (watermark) this.consumedWatermark = watermark;
527
586
  if (!budget) this.settleRuntimeSettingsTransition();
528
587
  return result;
529
588
  }
@@ -538,9 +597,11 @@ export class Agent {
538
597
  injections?: ContextInjection[],
539
598
  opts?: { kvUnifiedImmutablePrefixHash?: string },
540
599
  ): Promise<CompileResult> {
600
+ const watermark = this.pendingWatermark();
541
601
  const result = await this.contextManager.compile(
542
- this.resolveBudget(budget), injections, opts as never,
602
+ this.compileBudget(budget), injections, opts as never,
543
603
  );
604
+ if (watermark) this.consumedWatermark = watermark;
544
605
  if (!budget) this.settleRuntimeSettingsTransition();
545
606
  return result;
546
607
  }
@@ -579,20 +640,33 @@ export class Agent {
579
640
  throw new Error(`Agent ${this.name} is waiting for tool results`);
580
641
  }
581
642
 
643
+ this.toolResultGuard.flushUnrecorded();
644
+
582
645
  // Filter tools to only allowed ones
583
646
  const tools = availableTools.filter((t) => this.canUseTool(t.name));
584
647
 
648
+ // The direct Agent API uses the same admission rules as framework-driven
649
+ // streams. Stage before compiling so compression only sees placeholders.
650
+ const guardedResults = this._state.status === 'ready' && this.toolResultGuard.enabled;
651
+ if (guardedResults && this._state.status === 'ready') {
652
+ const content = this.buildToolResultMessages(this._state.toolResults)[0].content;
653
+ const wireResults = content.flatMap((block) => block.type === 'tool_result'
654
+ // buildToolResultMessages serializes every payload to a string.
655
+ ? [{ toolUseId: block.toolUseId, content: block.content as string, isError: block.isError }] : []);
656
+ this.toolResultGuard.storeResults(content, wireResults, this._state.toolResults);
657
+ }
658
+
585
659
  // Compile context (with optional injections)
586
660
  const { messages, systemInjections } = await this.compileWithInjections(budget, injections);
587
661
 
588
662
  // If we have pending tool results, add them
589
- if (this._state.status === 'ready') {
663
+ if (this._state.status === 'ready' && !guardedResults) {
590
664
  const toolResultMessages = this.buildToolResultMessages(this._state.toolResults);
591
665
  messages.push(...toolResultMessages);
592
666
  }
593
667
 
594
668
  const request: NormalizedRequest = {
595
- messages,
669
+ messages: this.toolResultGuard.prepareRequest(messages, true),
596
670
  system: this.buildSystemPrompt(systemInjections),
597
671
  config: {
598
672
  model: this.model,
@@ -648,6 +722,7 @@ export class Agent {
648
722
  } catch (error) {
649
723
  // On error, go back to idle
650
724
  this._state = { status: 'idle' };
725
+ this.toolResultGuard.recovering = false;
651
726
  throw error;
652
727
  }
653
728
  }
@@ -773,7 +848,7 @@ export class Agent {
773
848
  }
774
849
 
775
850
  return {
776
- messages,
851
+ messages: this.toolResultGuard.prepareRequest(messages),
777
852
  system: this.buildSystemPrompt(systemInjections),
778
853
  config: {
779
854
  model: this.model,
@@ -804,6 +879,10 @@ export class Agent {
804
879
  throw new Error(`Agent ${this.name} cannot start stream in state ${this._state.status}`);
805
880
  }
806
881
 
882
+ // Retry accepted history edits before assembling a real request. Pure
883
+ // preview/compile entry points must not perform these writes.
884
+ this.toolResultGuard.flushUnrecorded();
885
+
807
886
  // A new activation supersedes every receipt flight the previous one left
808
887
  // open. Membrane fires the kv-unified wire receipt per provider attempt,
809
888
  // BEFORE the adapter call; only that attempt's usage event settles it.
@@ -830,6 +909,7 @@ export class Agent {
830
909
  this.lastStreamOutputTokens = 0;
831
910
 
832
911
  const request = await this.buildActivationRequest(availableTools, injections, budget);
912
+ request.messages = this.toolResultGuard.prepareRequest(request.messages, true);
833
913
 
834
914
  const receiptAware = (this.contextManager as unknown as { getStrategy?: () => unknown })
835
915
  .getStrategy?.() as {
@@ -854,6 +934,7 @@ export class Agent {
854
934
  };
855
935
  }
856
936
 
937
+ const agent = this;
857
938
  const stream = this.membrane.streamYielding(request, {
858
939
  emitTokens: true,
859
940
  emitBlocks: false,
@@ -863,7 +944,13 @@ export class Agent {
863
944
  // (cache-warm), where a framework-level requeue would recompile and
864
945
  // land a different window. The framework's driveStream handles the
865
946
  // resulting `retrying` event by discarding the abandoned attempt.
866
- ...(this.refusalHandling?.retries ? { refusalRetries: this.refusalHandling.retries } : {}),
947
+ // Membrane reads this per physical round, including after settings
948
+ // tools run. A guarded result must reach the host on its FIRST refusal;
949
+ // never spend retries resubmitting unchanged guarded content.
950
+ get refusalRetries() {
951
+ return agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering
952
+ ? 0 : agent.refusalHandling?.retries ?? 0;
953
+ },
867
954
  });
868
955
 
869
956
  this._state = { status: 'streaming', stream };
@@ -1041,9 +1128,32 @@ export class Agent {
1041
1128
  request: NormalizedRequest,
1042
1129
  signal?: AbortSignal
1043
1130
  ): Promise<InferenceResult> {
1044
- const response = await this.membrane.stream(request, { signal });
1131
+ let response = await this.membrane.stream(request, { signal });
1132
+ // Usage of a refused attempt abandoned by the guard; it was billed and
1133
+ // is added to the retry's usage below rather than overwritten.
1134
+ let abandonedUsage: { inputTokens: number; outputTokens: number } | undefined;
1135
+ if (!isAbortedResponse(response) && response.stopReason === 'refusal') {
1136
+ // A staged batch never on the wire cannot have caused the refusal.
1137
+ this.toolResultGuard.settleUnsubmitted('unknown');
1138
+ const ids = this.toolResultGuard.withhold('unknown');
1139
+ if (ids) {
1140
+ abandonedUsage = response.usage;
1141
+ const withheld = new Set(ids);
1142
+ request = { ...request, messages: request.messages.map((message) => ({
1143
+ ...message, content: message.content.map((block) => block.type === 'tool_result' && withheld.has(block.toolUseId)
1144
+ ? { type: 'tool_result', toolUseId: block.toolUseId, content: TOOL_RESULT_GUARD_NOTICE, isError: block.isError } : block),
1145
+ })) };
1146
+ response = await this.membrane.stream(request, { signal });
1147
+ }
1148
+ }
1045
1149
 
1046
1150
  if (isAbortedResponse(response)) {
1151
+ // Interrupted before a clean response: settle, never leave pending.
1152
+ try {
1153
+ this.toolResultGuard.abandon('aborted');
1154
+ } catch (error) {
1155
+ console.error(`[tool-result-guard] agent=${this.name} failed to record aborted batch:`, error);
1156
+ }
1047
1157
  const partialContent = response.partialContent ?? [];
1048
1158
  const { toolCalls, speechContent } = this.extractToolCallsAndSpeech(partialContent);
1049
1159
  return {
@@ -1056,16 +1166,24 @@ export class Agent {
1056
1166
  };
1057
1167
  }
1058
1168
 
1059
- const { toolCalls, speechContent } = this.extractToolCallsAndSpeech(response.content);
1169
+ const guardedRefusal = response.stopReason === 'refusal' && this.toolResultGuard.recovering;
1170
+ if (response.stopReason !== 'refusal') this.toolResultGuard.accept();
1171
+ this.toolResultGuard.recovering = false;
1172
+ const content = guardedRefusal ? [] : response.content;
1173
+ const { toolCalls, speechContent } = this.extractToolCallsAndSpeech(content);
1060
1174
 
1061
1175
  // Add assistant response to context
1062
- this.contextManager.addMessage(this.name, response.content);
1176
+ if (!guardedRefusal) this.contextManager.addMessage(this.name, content);
1063
1177
 
1064
1178
  return {
1065
1179
  toolCalls,
1066
1180
  speechContent,
1067
1181
  raw: response.raw,
1068
- usage: response.usage,
1182
+ usage: abandonedUsage && response.usage ? {
1183
+ ...response.usage,
1184
+ inputTokens: response.usage.inputTokens + abandonedUsage.inputTokens,
1185
+ outputTokens: response.usage.outputTokens + abandonedUsage.outputTokens,
1186
+ } : response.usage,
1069
1187
  stopReason: response.stopReason,
1070
1188
  };
1071
1189
  }
package/src/api/types.ts CHANGED
@@ -369,6 +369,8 @@ export type ApiEventType =
369
369
  | 'module:added'
370
370
  | 'module:removed'
371
371
  | 'message:added'
372
+ | 'message:removed'
373
+ | 'mcpl:coalescing'
372
374
  // Server-specific events
373
375
  | 'connected'
374
376
  | 'speech'
@@ -77,12 +77,24 @@ const DEFAULT_SCRIPT_TIMEOUT_MS = 600_000;
77
77
  const DEFAULT_IDLE_RECLAIM_MS = 300_000;
78
78
  const CANCEL_GRACE_MS = 10_000;
79
79
 
80
+ /** The longest delay Node's timers honour (~24.8 days); a longer one fires after ~1 ms. */
81
+ export const MAX_TIMER_MS = 2_147_483_647;
82
+
83
+ /** A time limit for a message: "45s", "10 min", "2.5 h". */
84
+ export function formatLimit(ms: number): string {
85
+ if (ms < 120_000) return `${Math.round(ms / 1000)}s`;
86
+ if (ms < 7_200_000) return `${Math.round(ms / 6_000) / 10} min`;
87
+ return `${Math.round(ms / 360_000) / 10} h`;
88
+ }
89
+
80
90
  interface PendingExec {
81
91
  id: string;
82
92
  resolve: (result: ExecResult) => void;
83
93
  deadlineTimer: ReturnType<typeof setTimeout> | null;
84
94
  killTimer: ReturnType<typeof setTimeout> | null;
85
95
  settled: boolean;
96
+ /** Set once the deadline fired: the result then says the script ran out of time. */
97
+ deadlineMs: number | null;
86
98
  }
87
99
 
88
100
  export class PyRunner {
@@ -125,8 +137,16 @@ export class PyRunner {
125
137
  * log file, wake_agent() is available in-script, and the deadline is the
126
138
  * background lifetime. A background runner should be DEDICATED to that one
127
139
  * script (the framework creates one per background script).
140
+ *
141
+ * `opts.deadlineMs` replaces the runner's scriptTimeoutMs for this exec (a
142
+ * per-call time limit); a background exec uses its `lifetimeMs` instead.
128
143
  */
129
- async exec(code: string, tools: InjectedTool[], background?: BackgroundExecOptions): Promise<ExecResult> {
144
+ async exec(
145
+ code: string,
146
+ tools: InjectedTool[],
147
+ background?: BackgroundExecOptions,
148
+ opts?: { deadlineMs?: number },
149
+ ): Promise<ExecResult> {
130
150
  if (this.disposed) {
131
151
  return { stdout: '', stderr: 'code_execution runner disposed', returnCode: 1, aborted: true };
132
152
  }
@@ -154,7 +174,8 @@ export class PyRunner {
154
174
  }
155
175
 
156
176
  const execId = `e${++this.execCounter}`;
157
- const deadlineMs = background?.lifetimeMs ?? this.scriptTimeoutMs;
177
+ // Clamped: a deadline past Node's timer range would fire at once instead of never.
178
+ const deadlineMs = Math.min(MAX_TIMER_MS, background?.lifetimeMs ?? opts?.deadlineMs ?? this.scriptTimeoutMs);
158
179
  this.onWake = background?.onWake ?? null;
159
180
  const result = await new Promise<ExecResult>((resolve) => {
160
181
  const pending: PendingExec = {
@@ -163,14 +184,17 @@ export class PyRunner {
163
184
  deadlineTimer: null,
164
185
  killTimer: null,
165
186
  settled: false,
187
+ deadlineMs: null,
166
188
  };
167
189
  this.pending = pending;
168
190
 
169
191
  pending.deadlineTimer = setTimeout(() => {
192
+ pending.deadlineMs = deadlineMs;
170
193
  // Deadline: ask politely first (script sees CancelledError and its
171
194
  // exec_result still flows back), then kill on unresponsiveness.
172
195
  this.send({ op: 'cancel', id: execId, reason: 'deadline' });
173
196
  pending.killTimer = setTimeout(() => {
197
+ pending.deadlineMs = null; // this message already says why
174
198
  this.settlePending({
175
199
  stdout: '',
176
200
  stderr: `script killed after exceeding ${Math.round(deadlineMs / 1000)}s deadline`,
@@ -380,6 +404,14 @@ export class PyRunner {
380
404
  const pending = this.pending;
381
405
  if (!pending || pending.settled) return;
382
406
  pending.settled = true;
407
+ // Say why the script stopped: the in-script cancellation only reads "cancelled by host".
408
+ // Only a script that ended unsuccessfully was stopped: one that caught the cancellation
409
+ // and finished its work returns 0, and must not read as stopped.
410
+ if (pending.deadlineMs !== null && result.returnCode !== 0) {
411
+ const note = `script stopped: it reached its ${formatLimit(pending.deadlineMs)} time limit\n`;
412
+ // A background script reports its output tail, so the note goes there too.
413
+ result = { ...result, stderr: result.stderr + note, ...(result.tail !== undefined ? { tail: result.tail + note } : {}) };
414
+ }
383
415
  if (pending.deadlineTimer) clearTimeout(pending.deadlineTimer);
384
416
  if (pending.killTimer) clearTimeout(pending.killTimer);
385
417
  this.pending = null;
@@ -9,15 +9,20 @@
9
9
  */
10
10
 
11
11
  import type { ToolDefinition } from '../types/index.js';
12
+ import { formatLimit, MAX_TIMER_MS } from './py-runner.js';
12
13
 
13
14
  export const CODE_EXECUTION_TOOL_NAME = 'code_execution';
14
15
 
15
16
  export function buildCodeExecutionToolDefinition(opts?: {
16
17
  idleReclaimMs?: number;
17
18
  toolCallTimeoutMs?: number;
19
+ scriptTimeoutMs?: number;
20
+ maxScriptTimeoutMs?: number;
21
+ backgroundMaxLifetimeMs?: number;
18
22
  }): ToolDefinition {
19
23
  const idleMinutes = Math.max(1, Math.round((opts?.idleReclaimMs ?? 300_000) / 60_000));
20
24
  const callTimeoutSeconds = Math.round((opts?.toolCallTimeoutMs ?? 270_000) / 1000);
25
+ const limits = scriptTimeLimits(opts);
21
26
  return {
22
27
  name: CODE_EXECUTION_TOOL_NAME,
23
28
  description:
@@ -36,6 +41,9 @@ export function buildCodeExecutionToolDefinition(opts?: {
36
41
  'what you need. Interpreter state (variables, imports) persists across code_execution ' +
37
42
  `calls but is reclaimed after ~${idleMinutes} minutes idle. A tool call that receives no ` +
38
43
  `response within ~${callTimeoutSeconds}s raises TimeoutError inside the script. ` +
44
+ `A foreground script is stopped after ${formatLimit(limits.defaultMs)}; pass time_limit_ms to set this call's limit ` +
45
+ `(at most ${formatLimit(limits.maxMs)}; a background script runs up to ${formatLimit(limits.backgroundMaxMs)}). ` +
46
+ 'Stopping a script does not stop tools it already called. ' +
39
47
  'Use this when fanning out across many items, looping over tool calls, or when tool ' +
40
48
  'results are large and you only need a slice or summary. Call tools directly (not via ' +
41
49
  'code) when a single call answers the question or when you need to reason about each ' +
@@ -72,8 +80,46 @@ export function buildCodeExecutionToolDefinition(opts?: {
72
80
  type: 'string',
73
81
  description: 'Background script id (for action: cancel).',
74
82
  },
83
+ time_limit_ms: {
84
+ type: 'integer',
85
+ description:
86
+ `Time limit for this script in milliseconds; it is stopped when the limit is reached. ` +
87
+ `Default ${limits.defaultMs}, at most ${limits.maxMs}; a background script defaults to and is capped at ` +
88
+ `its ${formatLimit(limits.backgroundMaxMs)} lifetime.`,
89
+ },
75
90
  },
76
91
  required: [],
77
92
  },
78
93
  };
79
94
  }
95
+
96
+ /**
97
+ * A script's time limits: the default, the most an agent may ask for with
98
+ * `time_limit_ms`, and the background lifetime (default and ceiling at once).
99
+ */
100
+ export function scriptTimeLimits(opts?: {
101
+ scriptTimeoutMs?: number;
102
+ maxScriptTimeoutMs?: number;
103
+ backgroundMaxLifetimeMs?: number;
104
+ }): { defaultMs: number; maxMs: number; backgroundMaxMs: number } {
105
+ // Every limit fits Node's timer range (MAX_TIMER_MS, ~24.8 days).
106
+ const defaultMs = Math.min(MAX_TIMER_MS, opts?.scriptTimeoutMs ?? 600_000);
107
+ return {
108
+ defaultMs,
109
+ // A ceiling below the default is refused when the framework is created
110
+ // (validateCodeExecutionConfig); never below the default here either.
111
+ maxMs: Math.min(MAX_TIMER_MS, Math.max(defaultMs, opts?.maxScriptTimeoutMs ?? defaultMs)),
112
+ backgroundMaxMs: Math.min(MAX_TIMER_MS, opts?.backgroundMaxLifetimeMs ?? 86_400_000),
113
+ };
114
+ }
115
+
116
+ /** Refuse a configuration whose time limits contradict each other. */
117
+ export function validateCodeExecutionConfig(cfg: { scriptTimeoutMs?: number; maxScriptTimeoutMs?: number }): void {
118
+ const defaultMs = cfg.scriptTimeoutMs ?? 600_000;
119
+ if (cfg.maxScriptTimeoutMs !== undefined && !(cfg.maxScriptTimeoutMs >= defaultMs)) {
120
+ throw new Error(
121
+ `codeExecution.maxScriptTimeoutMs (${cfg.maxScriptTimeoutMs}) must be at least scriptTimeoutMs (${defaultMs}): ` +
122
+ 'it is the most an agent may ask for, and the default is always allowed',
123
+ );
124
+ }
125
+ }