@stigmer/runner 3.40.0 → 3.41.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 (278) hide show
  1. package/README.md +3 -1
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/__test-utils__/execution-record-fixture.d.ts +2 -1
  4. package/dist/__test-utils__/execution-record-fixture.js.map +1 -1
  5. package/dist/activities/call-agent.js +1 -1
  6. package/dist/activities/discover-mcp-server.d.ts +7 -1
  7. package/dist/activities/discover-mcp-server.js +13 -29
  8. package/dist/activities/discover-mcp-server.js.map +1 -1
  9. package/dist/activities/execute-cursor/__test-utils__/fold.d.ts +4 -2
  10. package/dist/activities/execute-cursor/__test-utils__/fold.js +3 -4
  11. package/dist/activities/execute-cursor/__test-utils__/fold.js.map +1 -1
  12. package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js +1 -0
  13. package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js.map +1 -1
  14. package/dist/activities/execute-cursor/approval-policy.d.ts +1 -29
  15. package/dist/activities/execute-cursor/approval-policy.js +11 -35
  16. package/dist/activities/execute-cursor/approval-policy.js.map +1 -1
  17. package/dist/activities/execute-cursor/boundary-rows.js +4 -4
  18. package/dist/activities/execute-cursor/boundary-rows.js.map +1 -1
  19. package/dist/activities/execute-cursor/translator.d.ts +12 -17
  20. package/dist/activities/execute-cursor/translator.js +13 -30
  21. package/dist/activities/execute-cursor/translator.js.map +1 -1
  22. package/dist/activities/execute-cursor/turn-stream.js +4 -2
  23. package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
  24. package/dist/activities/execute-deep-agent/__test-utils__/hermetic-deep-agent.d.ts +11 -1
  25. package/dist/activities/execute-deep-agent/__test-utils__/hermetic-deep-agent.js +22 -3
  26. package/dist/activities/execute-deep-agent/__test-utils__/hermetic-deep-agent.js.map +1 -1
  27. package/dist/activities/execute-deep-agent/inline-publisher.d.ts +33 -2
  28. package/dist/activities/execute-deep-agent/inline-publisher.js +33 -7
  29. package/dist/activities/execute-deep-agent/inline-publisher.js.map +1 -1
  30. package/dist/activities/execute-deep-agent/platform-route.d.ts +48 -13
  31. package/dist/activities/execute-deep-agent/platform-route.js +87 -19
  32. package/dist/activities/execute-deep-agent/platform-route.js.map +1 -1
  33. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +70 -35
  34. package/dist/activities/execute-deep-agent/prompt-builder.js +70 -55
  35. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  36. package/dist/activities/execute-deep-agent/streaming-side-effects.d.ts +20 -3
  37. package/dist/activities/execute-deep-agent/streaming-side-effects.js +26 -4
  38. package/dist/activities/execute-deep-agent/streaming-side-effects.js.map +1 -1
  39. package/dist/activities/execute-deep-agent/translator.d.ts +5 -4
  40. package/dist/activities/execute-deep-agent/translator.js +5 -4
  41. package/dist/activities/execute-deep-agent/translator.js.map +1 -1
  42. package/dist/activities/execute-deep-agent/turn-settle.js +6 -6
  43. package/dist/activities/execute-deep-agent/turn-settle.js.map +1 -1
  44. package/dist/activities/execute-deep-agent/turn-setup.d.ts +18 -13
  45. package/dist/activities/execute-deep-agent/turn-setup.js +42 -36
  46. package/dist/activities/execute-deep-agent/turn-setup.js.map +1 -1
  47. package/dist/activities/execute-deep-agent/turn-stream.d.ts +10 -6
  48. package/dist/activities/execute-deep-agent/turn-stream.js +6 -16
  49. package/dist/activities/execute-deep-agent/turn-stream.js.map +1 -1
  50. package/dist/activities/execute-deep-agent/turn.d.ts +4 -1
  51. package/dist/activities/execute-deep-agent/turn.js +15 -6
  52. package/dist/activities/execute-deep-agent/turn.js.map +1 -1
  53. package/dist/activities/workflow-event-activities.js +2 -0
  54. package/dist/activities/workflow-event-activities.js.map +1 -1
  55. package/dist/config.d.ts +9 -0
  56. package/dist/config.js +2 -0
  57. package/dist/config.js.map +1 -1
  58. package/dist/exit-backstop.d.ts +64 -0
  59. package/dist/exit-backstop.js +79 -0
  60. package/dist/exit-backstop.js.map +1 -0
  61. package/dist/harness/terminal-table.d.ts +12 -5
  62. package/dist/harness/terminal-table.js +13 -13
  63. package/dist/harness/terminal-table.js.map +1 -1
  64. package/dist/harness/transcript/builder.d.ts +21 -0
  65. package/dist/harness/transcript/builder.js +53 -31
  66. package/dist/harness/transcript/builder.js.map +1 -1
  67. package/dist/harness/transcript/events.d.ts +22 -26
  68. package/dist/harness/transcript/events.js +6 -5
  69. package/dist/harness/transcript/events.js.map +1 -1
  70. package/dist/harness/turn-context.d.ts +3 -1
  71. package/dist/harness/turn-context.js +5 -3
  72. package/dist/harness/turn-context.js.map +1 -1
  73. package/dist/main.js +10 -4
  74. package/dist/main.js.map +1 -1
  75. package/dist/middleware/cost-advisory.d.ts +6 -3
  76. package/dist/middleware/cost-advisory.js +6 -3
  77. package/dist/middleware/cost-advisory.js.map +1 -1
  78. package/dist/middleware/execution-budget.d.ts +22 -7
  79. package/dist/middleware/execution-budget.js +57 -35
  80. package/dist/middleware/execution-budget.js.map +1 -1
  81. package/dist/middleware/index.d.ts +10 -7
  82. package/dist/middleware/index.js +10 -7
  83. package/dist/middleware/index.js.map +1 -1
  84. package/dist/middleware/types.d.ts +6 -1
  85. package/dist/runner-manager.js +3 -2
  86. package/dist/runner-manager.js.map +1 -1
  87. package/dist/runner.d.ts +25 -0
  88. package/dist/runner.js +41 -14
  89. package/dist/runner.js.map +1 -1
  90. package/dist/shared/approval-policy.d.ts +23 -13
  91. package/dist/shared/approval-policy.js +47 -14
  92. package/dist/shared/approval-policy.js.map +1 -1
  93. package/dist/shared/caller-identity.d.ts +4 -5
  94. package/dist/shared/caller-identity.js +4 -5
  95. package/dist/shared/caller-identity.js.map +1 -1
  96. package/dist/shared/channel-attachment.js +3 -2
  97. package/dist/shared/channel-attachment.js.map +1 -1
  98. package/dist/shared/connect-backfill.d.ts +2 -1
  99. package/dist/shared/connect-backfill.js +2 -2
  100. package/dist/shared/connect-backfill.js.map +1 -1
  101. package/dist/shared/mcp-resolver.d.ts +10 -1
  102. package/dist/shared/mcp-resolver.js +11 -2
  103. package/dist/shared/mcp-resolver.js.map +1 -1
  104. package/dist/shared/memory-attachment.js +3 -2
  105. package/dist/shared/memory-attachment.js.map +1 -1
  106. package/dist/shared/model-error.d.ts +25 -3
  107. package/dist/shared/model-error.js +78 -10
  108. package/dist/shared/model-error.js.map +1 -1
  109. package/dist/shared/platform-server-address.d.ts +71 -0
  110. package/dist/shared/platform-server-address.js +103 -0
  111. package/dist/shared/platform-server-address.js.map +1 -0
  112. package/dist/shared/prompt-sections.d.ts +6 -28
  113. package/dist/shared/prompt-sections.js +6 -35
  114. package/dist/shared/prompt-sections.js.map +1 -1
  115. package/dist/shared/skill-resolver.d.ts +2 -3
  116. package/dist/shared/skill-resolver.js +2 -3
  117. package/dist/shared/skill-resolver.js.map +1 -1
  118. package/dist/shared/synthesized-attachment.d.ts +0 -5
  119. package/dist/shared/synthesized-attachment.js +0 -12
  120. package/dist/shared/synthesized-attachment.js.map +1 -1
  121. package/dist/shared/tool-rounds.d.ts +51 -23
  122. package/dist/shared/tool-rounds.js +67 -24
  123. package/dist/shared/tool-rounds.js.map +1 -1
  124. package/dist/shared/workspace/platform-dir.d.ts +15 -0
  125. package/dist/shared/workspace/platform-dir.js +18 -1
  126. package/dist/shared/workspace/platform-dir.js.map +1 -1
  127. package/dist/shared/workspace/sources/git.js +13 -6
  128. package/dist/shared/workspace/sources/git.js.map +1 -1
  129. package/dist/shared/workspace/stigmer-link.d.ts +21 -14
  130. package/dist/shared/workspace/stigmer-link.js +91 -29
  131. package/dist/shared/workspace/stigmer-link.js.map +1 -1
  132. package/dist/tools/url-guard.d.ts +5 -1
  133. package/dist/tools/url-guard.js +9 -2
  134. package/dist/tools/url-guard.js.map +1 -1
  135. package/dist/worker.d.ts +11 -2
  136. package/dist/worker.js +1 -1
  137. package/dist/worker.js.map +1 -1
  138. package/dist/workflow-engine/tasks/human-input.d.ts +16 -0
  139. package/dist/workflow-engine/tasks/human-input.js +79 -37
  140. package/dist/workflow-engine/tasks/human-input.js.map +1 -1
  141. package/dist/workflow-engine/types.d.ts +14 -4
  142. package/dist/workflow-engine/types.js.map +1 -1
  143. package/dist/workflows/call-agent-orchestrator.d.ts +13 -0
  144. package/dist/workflows/call-agent-orchestrator.js.map +1 -1
  145. package/dist/workflows/engine-core.d.ts +25 -0
  146. package/dist/workflows/engine-core.js.map +1 -1
  147. package/dist/workflows/execute-from-execution.d.ts +3 -0
  148. package/dist/workflows/execute-from-execution.js.map +1 -1
  149. package/dist/workflows/human-input-orchestrator.d.ts +9 -12
  150. package/dist/workflows/human-input-orchestrator.js +12 -44
  151. package/dist/workflows/human-input-orchestrator.js.map +1 -1
  152. package/package.json +5 -5
  153. package/src/__test-utils__/config-fixture.ts +1 -0
  154. package/src/__test-utils__/execution-record-fixture.ts +2 -1
  155. package/src/__test-utils__/harness-contract/runtime-contract.ts +5 -3
  156. package/src/__test-utils__/test-gate.ts +8 -0
  157. package/src/__tests__/exit-backstop.test.ts +91 -0
  158. package/src/__tests__/golden-e2e.test.ts +80 -62
  159. package/src/__tests__/history-encryption-e2e.test.ts +51 -27
  160. package/src/__tests__/runner.test.ts +60 -2
  161. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  162. package/src/activities/__tests__/discover-mcp-server.hang.test.ts +5 -1
  163. package/src/activities/__tests__/discover-mcp-server.test.ts +90 -49
  164. package/src/activities/__tests__/workflow-event-activities.test.ts +28 -7
  165. package/src/activities/call-agent.ts +1 -1
  166. package/src/activities/discover-mcp-server.ts +27 -37
  167. package/src/activities/execute-cursor/__test-utils__/fold.ts +6 -4
  168. package/src/activities/execute-cursor/__test-utils__/hermetic-cursor.ts +1 -0
  169. package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +4 -29
  170. package/src/activities/execute-cursor/__tests__/hermetic/bypassed-tool-call.test.ts +120 -0
  171. package/src/activities/execute-cursor/__tests__/hermetic/file-review-capture.test.ts +5 -5
  172. package/src/activities/execute-cursor/__tests__/hermetic/goldens/file-review-capture.status.json +0 -2
  173. package/src/activities/execute-cursor/__tests__/hermetic/goldens/resolution-error.status.json +1 -1
  174. package/src/activities/execute-cursor/__tests__/hermetic/goldens/shell-output-delta.error-delta.status.json +0 -3
  175. package/src/activities/execute-cursor/__tests__/hermetic/goldens/shell-output-delta.output-first.status.json +0 -2
  176. package/src/activities/execute-cursor/__tests__/hermetic/goldens/shell-output-delta.status.json +0 -2
  177. package/src/activities/execute-cursor/__tests__/hermetic/goldens/stall.status.json +0 -2
  178. package/src/activities/execute-cursor/__tests__/hermetic/goldens/unattributed-hook-block.status.json +0 -3
  179. package/src/activities/execute-cursor/__tests__/hermetic/shell-output-delta.test.ts +9 -7
  180. package/src/activities/execute-cursor/__tests__/hermetic/thrown-error-arms.test.ts +6 -1
  181. package/src/activities/execute-cursor/__tests__/hitl-resume-history.test.ts +8 -11
  182. package/src/activities/execute-cursor/__tests__/sequential-gate-resume.test.ts +1 -3
  183. package/src/activities/execute-cursor/__tests__/translator.test.ts +68 -15
  184. package/src/activities/execute-cursor/approval-policy.ts +11 -36
  185. package/src/activities/execute-cursor/boundary-rows.ts +3 -3
  186. package/src/activities/execute-cursor/translator.ts +15 -32
  187. package/src/activities/execute-cursor/turn-stream.ts +4 -1
  188. package/src/activities/execute-deep-agent/__test-utils__/hermetic-deep-agent.ts +33 -2
  189. package/src/activities/execute-deep-agent/__tests__/adapter.test.ts +1 -0
  190. package/src/activities/execute-deep-agent/__tests__/goldens/system-prompt.build-from-plan.prompt.md +1 -27
  191. package/src/activities/execute-deep-agent/__tests__/goldens/system-prompt.everything.prompt.md +26 -30
  192. package/src/activities/execute-deep-agent/__tests__/goldens/system-prompt.plan-mode.prompt.md +1 -26
  193. package/src/activities/execute-deep-agent/__tests__/goldens/user-message.everything.md +36 -0
  194. package/src/activities/execute-deep-agent/__tests__/hermetic/file-review-capture.test.ts +4 -0
  195. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/file-review-capture.status.json +1 -1
  196. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/inline-artifact.turn1.status.json +1 -1
  197. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/inline-artifact.turn2.status.json +1 -1
  198. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/recursion-limit.persisted.status.json +8 -73
  199. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/resolution-error.status.json +1 -1
  200. package/src/activities/execute-deep-agent/__tests__/hermetic/inline-artifact-across-gate.test.ts +17 -0
  201. package/src/activities/execute-deep-agent/__tests__/hermetic/recursion-limit.test.ts +86 -26
  202. package/src/activities/execute-deep-agent/__tests__/hermetic/resolution-error.test.ts +9 -5
  203. package/src/activities/execute-deep-agent/__tests__/hermetic/thrown-turn-drains-publishes.test.ts +159 -0
  204. package/src/activities/execute-deep-agent/__tests__/inline-publisher.test.ts +84 -0
  205. package/src/activities/execute-deep-agent/__tests__/platform-route.test.ts +47 -10
  206. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +127 -199
  207. package/src/activities/execute-deep-agent/__tests__/prompt-goldens.test.ts +31 -26
  208. package/src/activities/execute-deep-agent/__tests__/streaming-side-effects.test.ts +34 -0
  209. package/src/activities/execute-deep-agent/__tests__/turn-stream.test.ts +13 -5
  210. package/src/activities/execute-deep-agent/inline-publisher.ts +47 -7
  211. package/src/activities/execute-deep-agent/platform-route.ts +109 -20
  212. package/src/activities/execute-deep-agent/prompt-builder.ts +104 -92
  213. package/src/activities/execute-deep-agent/streaming-side-effects.ts +27 -4
  214. package/src/activities/execute-deep-agent/translator.ts +5 -4
  215. package/src/activities/execute-deep-agent/turn-settle.ts +6 -6
  216. package/src/activities/execute-deep-agent/turn-setup.ts +43 -38
  217. package/src/activities/execute-deep-agent/turn-stream.ts +15 -22
  218. package/src/activities/execute-deep-agent/turn.ts +16 -7
  219. package/src/activities/workflow-event-activities.ts +2 -0
  220. package/src/config.ts +11 -0
  221. package/src/exit-backstop.ts +97 -0
  222. package/src/harness/README.md +5 -5
  223. package/src/harness/terminal-table.ts +13 -13
  224. package/src/harness/transcript/__tests__/builder.test.ts +93 -21
  225. package/src/harness/transcript/builder.ts +53 -30
  226. package/src/harness/transcript/events.ts +22 -27
  227. package/src/harness/turn-context.ts +5 -3
  228. package/src/main.ts +9 -5
  229. package/src/middleware/__tests__/execution-budget.test.ts +181 -103
  230. package/src/middleware/cost-advisory.ts +6 -3
  231. package/src/middleware/execution-budget.ts +60 -34
  232. package/src/middleware/index.ts +10 -7
  233. package/src/middleware/types.ts +6 -1
  234. package/src/runner-manager.ts +3 -2
  235. package/src/runner.ts +57 -15
  236. package/src/shared/__tests__/approval-policy.test.ts +41 -0
  237. package/src/shared/__tests__/artifact-storage.test.ts +1 -0
  238. package/src/shared/__tests__/connect-backfill.test.ts +15 -11
  239. package/src/shared/__tests__/mcp-resolver.test.ts +72 -10
  240. package/src/shared/__tests__/model-error.test.ts +71 -3
  241. package/src/shared/__tests__/platform-server-address.test.ts +122 -0
  242. package/src/shared/__tests__/prompt-sections.test.ts +2 -54
  243. package/src/shared/__tests__/tool-rounds.test.ts +64 -14
  244. package/src/shared/approval-policy.ts +57 -14
  245. package/src/shared/caller-identity.ts +4 -5
  246. package/src/shared/channel-attachment.ts +2 -2
  247. package/src/shared/connect-backfill.ts +5 -1
  248. package/src/shared/mcp-resolver.ts +11 -1
  249. package/src/shared/memory-attachment.ts +2 -2
  250. package/src/shared/model-error.ts +87 -9
  251. package/src/shared/platform-server-address.ts +123 -0
  252. package/src/shared/prompt-sections.ts +6 -55
  253. package/src/shared/skill-resolver.ts +2 -3
  254. package/src/shared/synthesized-attachment.ts +0 -12
  255. package/src/shared/tool-rounds.ts +70 -24
  256. package/src/shared/workspace/__tests__/git-source.test.ts +11 -5
  257. package/src/shared/workspace/__tests__/stigmer-link.test.ts +107 -10
  258. package/src/shared/workspace/platform-dir.ts +19 -1
  259. package/src/shared/workspace/sources/git.ts +14 -8
  260. package/src/shared/workspace/stigmer-link.ts +99 -29
  261. package/src/tools/__tests__/url-guard.test.ts +19 -1
  262. package/src/tools/url-guard.ts +9 -1
  263. package/src/worker.ts +12 -2
  264. package/src/workflow-engine/__tests__/golden-execution.test.ts +2 -1
  265. package/src/workflow-engine/__tests__/loader.test.ts +1 -1
  266. package/src/workflow-engine/__tests__/tasks/human-input.test.ts +133 -57
  267. package/src/workflow-engine/tasks/human-input.ts +92 -42
  268. package/src/workflow-engine/types.ts +16 -4
  269. package/src/workflows/call-agent-orchestrator.ts +7 -0
  270. package/src/workflows/engine-core.ts +18 -0
  271. package/src/workflows/execute-from-execution.ts +2 -1
  272. package/src/workflows/human-input-orchestrator.ts +15 -56
  273. package/dist/shared/skill-relevance.d.ts +0 -65
  274. package/dist/shared/skill-relevance.js +0 -175
  275. package/dist/shared/skill-relevance.js.map +0 -1
  276. package/src/activities/execute-deep-agent/__tests__/goldens/system-prompt.skills-below-threshold.prompt.md +0 -146
  277. package/src/shared/__tests__/skill-relevance.test.ts +0 -292
  278. package/src/shared/skill-relevance.ts +0 -222
@@ -1,130 +1,208 @@
1
+ /**
2
+ * ExecutionBudgetMiddleware: the round count, the stop it makes, and the
3
+ * advisories before it.
4
+ *
5
+ * Pins what `max_tool_rounds = N` means (#1113): exactly N model responses
6
+ * that propose tools, whatever the middleware stack; parallel calls in one
7
+ * response are one round; a response without tool calls, or a `Command`, is
8
+ * not a round; the stop replaces round N+1's model call (the handler is never
9
+ * called for it) and is the typed `ToolRoundLimitError` the native turn reads
10
+ * as `tool_call_limit`. The threshold advisory counts the same rounds, so it
11
+ * lands at its percentage of the budget the stop enforces. Periodic mode (the
12
+ * sub-agent stacks) keeps counting model calls and never stops.
13
+ */
14
+
1
15
  import { describe, it, expect, vi } from "vitest";
2
16
  import { AIMessage } from "@langchain/core/messages";
17
+ import { Command } from "@langchain/langgraph";
3
18
  import { createExecutionBudgetMiddleware } from "../execution-budget.js";
19
+ import type { ModelCallRequest, StigmerMiddleware } from "../types.js";
20
+ import { ToolRoundLimitError, UNBOUNDED_ADVISORY_TOOL_ROUNDS } from "../../shared/tool-rounds.js";
21
+
22
+ type ModelResponse = AIMessage | Command;
4
23
 
5
- function makeAIMessage(content = "hello"): AIMessage {
24
+ const REQUEST: ModelCallRequest = { model: {}, messages: [], state: {}, runtime: {} };
25
+
26
+ function textResponse(content = "hello"): AIMessage {
6
27
  return new AIMessage({ content });
7
28
  }
8
29
 
30
+ /** A response proposing `calls` tool calls: one round, however many calls. */
31
+ function toolResponse(round: number, calls = 1): AIMessage {
32
+ return new AIMessage({
33
+ content: `round ${round}`,
34
+ tool_calls: Array.from({ length: calls }, (_, i) => ({ id: `call-${round}-${i}`, name: "read_file", args: { file_path: `/f${round}-${i}.md` } })),
35
+ });
36
+ }
37
+
38
+ function handlerOf(respond: (call: number) => ModelResponse) {
39
+ let calls = 0;
40
+ return vi.fn(async (_request: ModelCallRequest): Promise<ModelResponse> => respond(calls++));
41
+ }
42
+
43
+ async function callModel(mw: StigmerMiddleware, handler: ReturnType<typeof handlerOf>): Promise<ModelResponse> {
44
+ return mw.wrapModelCall!(REQUEST, handler);
45
+ }
46
+
47
+ /** The requests the handler saw that carried an advisory (the base request has no messages). */
48
+ function advisedCalls(handler: ReturnType<typeof handlerOf>): number[] {
49
+ return handler.mock.calls.flatMap((call, index) => (call[0].messages.length > 0 ? [index] : []));
50
+ }
51
+
9
52
  describe("ExecutionBudgetMiddleware", () => {
10
- describe("threshold mode", () => {
11
- it("does not warn before threshold", async () => {
12
- const mw = createExecutionBudgetMiddleware({
13
- recursionLimit: 60,
14
- warningPct: 80,
15
- });
16
- // 60/6 = 10 estimated rounds, 80% = round 8
17
- const handler = vi.fn().mockResolvedValue(makeAIMessage());
18
- const request = { model: {}, messages: [], state: {}, runtime: {} } as any;
19
-
20
- for (let i = 0; i < 7; i++) {
21
- await mw.wrapModelCall!(request, handler);
22
- }
23
-
24
- // All calls should pass through without modification
25
- expect(handler).toHaveBeenCalledTimes(7);
26
- for (const call of handler.mock.calls) {
27
- expect(call[0]).toBe(request);
28
- }
53
+ describe("the round limit", () => {
54
+ it("runs exactly N tool rounds, then throws ToolRoundLimitError in place of the next model call", async () => {
55
+ const mw = createExecutionBudgetMiddleware({ maxToolRounds: 10, warningPct: 80 });
56
+ const handler = handlerOf((call) => toolResponse(call));
57
+
58
+ for (let i = 0; i < 10; i++) await callModel(mw, handler);
59
+ const stop = callModel(mw, handler);
60
+
61
+ await expect(stop).rejects.toBeInstanceOf(ToolRoundLimitError);
62
+ await expect(stop).rejects.toMatchObject({ toolRoundLimit: 10 });
63
+ expect(handler, "round 11's model call never happens").toHaveBeenCalledTimes(10);
29
64
  });
30
65
 
31
- it("injects warning at threshold", async () => {
32
- const mw = createExecutionBudgetMiddleware({
33
- recursionLimit: 60,
34
- warningPct: 80,
35
- });
36
- const handler = vi.fn().mockResolvedValue(makeAIMessage());
37
- const request = { model: {}, messages: [], state: {}, runtime: {} } as any;
38
-
39
- // Round 8 should trigger warning for round 9
40
- for (let i = 0; i < 8; i++) {
41
- await mw.wrapModelCall!(request, handler);
42
- }
43
-
44
- // Round 9 — handler should receive the advisory in messages
45
- await mw.wrapModelCall!(request, handler);
46
- const lastCall = handler.mock.calls[8][0];
47
- expect(lastCall.messages.length).toBeGreaterThan(0);
66
+ it("counts parallel tool calls in one response as one round", async () => {
67
+ const mw = createExecutionBudgetMiddleware({ maxToolRounds: 10, warningPct: 80 });
68
+ const handler = handlerOf((call) => toolResponse(call, 3));
69
+
70
+ for (let i = 0; i < 10; i++) await callModel(mw, handler);
71
+
72
+ await expect(callModel(mw, handler)).rejects.toBeInstanceOf(ToolRoundLimitError);
73
+ expect(handler).toHaveBeenCalledTimes(10);
74
+ });
75
+
76
+ it("does not count a response without tool calls, nor a Command, as a round", async () => {
77
+ const mw = createExecutionBudgetMiddleware({ maxToolRounds: 10, warningPct: 80 });
78
+ const handler = handlerOf((call) => (call % 3 === 0 ? toolResponse(call) : call % 3 === 1 ? textResponse() : new Command({ goto: "tools" })));
79
+
80
+ // Calls 0, 3, …, 27 are the 10 tool rounds; the 18 between them are text
81
+ // responses and Commands. Call 28 is the first after round 10.
82
+ for (let i = 0; i < 28; i++) await callModel(mw, handler);
83
+
84
+ await expect(callModel(mw, handler)).rejects.toBeInstanceOf(ToolRoundLimitError);
85
+ expect(handler).toHaveBeenCalledTimes(28);
48
86
  });
49
87
 
50
- it("fires warning only once in threshold mode", async () => {
51
- const mw = createExecutionBudgetMiddleware({
52
- recursionLimit: 60,
53
- warningPct: 80,
54
- });
55
- const handler = vi.fn().mockResolvedValue(makeAIMessage());
56
- const request = { model: {}, messages: [], state: {}, runtime: {} } as any;
57
-
58
- for (let i = 0; i < 12; i++) {
59
- await mw.wrapModelCall!(request, handler);
60
- }
61
-
62
- // Only round 9 should have the advisory
63
- const callsWithAdvisory = handler.mock.calls.filter(
64
- (call: unknown[]) => (call[0] as { messages: unknown[] }).messages.length > 0,
65
- );
66
- expect(callsWithAdvisory.length).toBe(1);
88
+ it("never stops an unlimited turn (maxToolRounds null, the proto's 0/unset)", async () => {
89
+ const mw = createExecutionBudgetMiddleware({ maxToolRounds: null, warningPct: 80 });
90
+ const handler = handlerOf((call) => toolResponse(call));
91
+
92
+ for (let i = 0; i < UNBOUNDED_ADVISORY_TOOL_ROUNDS + 50; i++) await callModel(mw, handler);
93
+
94
+ expect(handler).toHaveBeenCalledTimes(UNBOUNDED_ADVISORY_TOOL_ROUNDS + 50);
95
+ });
96
+
97
+ it("gives a new message a fresh budget: beforeAgent resets the count", async () => {
98
+ const mw = createExecutionBudgetMiddleware({ maxToolRounds: 10, warningPct: 80 });
99
+ const handler = handlerOf((call) => toolResponse(call));
100
+
101
+ for (let i = 0; i < 10; i++) await callModel(mw, handler);
102
+ await expect(callModel(mw, handler)).rejects.toBeInstanceOf(ToolRoundLimitError);
103
+
104
+ mw.beforeAgent!({}, {});
105
+ await callModel(mw, handler);
106
+ expect(handler).toHaveBeenCalledTimes(11);
107
+ });
108
+
109
+ it("rejects a non-positive limit (unlimited is null, never 0)", () => {
110
+ expect(() => createExecutionBudgetMiddleware({ maxToolRounds: 0 })).toThrow("maxToolRounds");
67
111
  });
68
112
  });
69
113
 
70
- describe("periodic mode", () => {
71
- it("injects advisory every N rounds", async () => {
72
- const mw = createExecutionBudgetMiddleware({
73
- warningInterval: 3,
74
- maxWarnings: 4,
75
- });
76
- const handler = vi.fn().mockResolvedValue(makeAIMessage());
77
- const request = { model: {}, messages: [], state: {}, runtime: {} } as any;
78
-
79
- for (let i = 0; i < 12; i++) {
80
- await mw.wrapModelCall!(request, handler);
81
- }
82
-
83
- // Advisories queued at rounds 3, 6, 9 (delivered at rounds 4, 7, 10)
84
- const callsWithAdvisory = handler.mock.calls.filter(
85
- (call: unknown[]) => (call[0] as { messages: unknown[] }).messages.length > 0,
86
- );
87
- expect(callsWithAdvisory.length).toBeGreaterThanOrEqual(3);
114
+ describe("threshold mode", () => {
115
+ it("does not warn before the threshold", async () => {
116
+ const mw = createExecutionBudgetMiddleware({ maxToolRounds: 10, warningPct: 80 });
117
+ const handler = handlerOf((call) => toolResponse(call));
118
+
119
+ for (let i = 0; i < 8; i++) await callModel(mw, handler);
120
+
121
+ expect(advisedCalls(handler)).toEqual([]);
122
+ for (const call of handler.mock.calls) expect(call[0]).toBe(REQUEST);
88
123
  });
89
124
 
90
- it("stops after maxWarnings", async () => {
91
- const mw = createExecutionBudgetMiddleware({
92
- warningInterval: 2,
93
- maxWarnings: 2,
94
- });
95
- const handler = vi.fn().mockResolvedValue(makeAIMessage());
96
- const request = { model: {}, messages: [], state: {}, runtime: {} } as any;
97
-
98
- for (let i = 0; i < 20; i++) {
99
- await mw.wrapModelCall!(request, handler);
100
- }
101
-
102
- const callsWithAdvisory = handler.mock.calls.filter(
103
- (call: unknown[]) => (call[0] as { messages: unknown[] }).messages.length > 0,
104
- );
105
- expect(callsWithAdvisory.length).toBe(2);
125
+ it("warns on the model call after the threshold's tool round, with the exact rounds left", async () => {
126
+ const mw = createExecutionBudgetMiddleware({ maxToolRounds: 10, warningPct: 80 });
127
+ const handler = handlerOf((call) => toolResponse(call));
128
+
129
+ for (let i = 0; i < 9; i++) await callModel(mw, handler);
130
+
131
+ // 80% of 10 = round 8, so the 9th call (index 8) carries the advisory.
132
+ expect(advisedCalls(handler)).toEqual([8]);
133
+ const advisory = handler.mock.calls[8][0].messages[0];
134
+ expect(advisory).toMatchObject({ content: expect.stringContaining("8 of 10 tool rounds used, 2 remaining") });
135
+ });
136
+
137
+ it("counts tool rounds, not model calls, toward the threshold", async () => {
138
+ const mw = createExecutionBudgetMiddleware({ maxToolRounds: 10, warningPct: 80 });
139
+ const handler = handlerOf(() => textResponse());
140
+
141
+ for (let i = 0; i < 12; i++) await callModel(mw, handler);
142
+
143
+ expect(advisedCalls(handler)).toEqual([]);
144
+ });
145
+
146
+ it("fires the warning only once", async () => {
147
+ const mw = createExecutionBudgetMiddleware({ maxToolRounds: 20, warningPct: 80 });
148
+ const handler = handlerOf((call) => toolResponse(call));
149
+
150
+ for (let i = 0; i < 20; i++) await callModel(mw, handler);
151
+
152
+ expect(advisedCalls(handler)).toEqual([16]);
153
+ });
154
+
155
+ it("warns toward the unbounded advisory figure when the turn has no limit", async () => {
156
+ const mw = createExecutionBudgetMiddleware({ maxToolRounds: null, warningPct: 80 });
157
+ const handler = handlerOf((call) => toolResponse(call));
158
+ const threshold = Math.floor(UNBOUNDED_ADVISORY_TOOL_ROUNDS * 0.8);
159
+
160
+ for (let i = 0; i < threshold + 1; i++) await callModel(mw, handler);
161
+
162
+ expect(advisedCalls(handler)).toEqual([threshold]);
163
+ });
164
+
165
+ it("resets on beforeAgent", async () => {
166
+ const mw = createExecutionBudgetMiddleware({ maxToolRounds: 10, warningPct: 50 });
167
+ const handler = handlerOf((call) => toolResponse(call));
168
+
169
+ for (let i = 0; i < 6; i++) await callModel(mw, handler);
170
+ mw.beforeAgent!({}, {});
171
+ handler.mockClear();
172
+
173
+ await callModel(mw, handler);
174
+ expect(handler.mock.calls[0][0]).toBe(REQUEST);
106
175
  });
107
176
  });
108
177
 
109
- it("resets on beforeAgent", async () => {
110
- const mw = createExecutionBudgetMiddleware({
111
- recursionLimit: 18,
112
- warningPct: 50,
178
+ describe("periodic mode", () => {
179
+ it("injects advisory every N model calls", async () => {
180
+ const mw = createExecutionBudgetMiddleware({ warningInterval: 3, maxWarnings: 4 });
181
+ const handler = handlerOf(() => textResponse());
182
+
183
+ for (let i = 0; i < 12; i++) await callModel(mw, handler);
184
+
185
+ // Advisories queued at calls 3, 6, 9, 12 (delivered at calls 4, 7, 10).
186
+ expect(advisedCalls(handler)).toEqual([3, 6, 9]);
113
187
  });
114
- // 18/6 = 3 rounds, 50% = round 1 (min 3)
115
- const handler = vi.fn().mockResolvedValue(makeAIMessage());
116
- const request = { model: {}, messages: [], state: {}, runtime: {} } as any;
117
188
 
118
- for (let i = 0; i < 5; i++) {
119
- await mw.wrapModelCall!(request, handler);
120
- }
189
+ it("stops advising after maxWarnings", async () => {
190
+ const mw = createExecutionBudgetMiddleware({ warningInterval: 2, maxWarnings: 2 });
191
+ const handler = handlerOf(() => textResponse());
121
192
 
122
- mw.beforeAgent!({}, {});
123
- handler.mockClear();
193
+ for (let i = 0; i < 20; i++) await callModel(mw, handler);
194
+
195
+ expect(advisedCalls(handler)).toHaveLength(2);
196
+ });
124
197
 
125
- // After reset, first calls should not have advisory
126
- await mw.wrapModelCall!(request, handler);
127
- expect(handler.mock.calls[0][0]).toBe(request);
198
+ it("never stops a turn when no limit is configured (the sub-agent stacks)", async () => {
199
+ const mw = createExecutionBudgetMiddleware({ warningInterval: 30, maxWarnings: 4 });
200
+ const handler = handlerOf((call) => toolResponse(call));
201
+
202
+ for (let i = 0; i < 200; i++) await callModel(mw, handler);
203
+
204
+ expect(handler).toHaveBeenCalledTimes(200);
205
+ });
128
206
  });
129
207
 
130
208
  it("rejects invalid warningPct", () => {
@@ -7,9 +7,12 @@
7
7
  * usage delta the adapter reports moves the runtime's estimate, and when the
8
8
  * estimate crosses the cap the runtime stops the turn and settles TERMINATED
9
9
  * with the cost-limit copy. The graph learns of the stop through its abort
10
- * signal, the same way it learns of a platform STOP or a stall. This is the
11
- * shape `execution-budget.ts` already has beside LangGraph's `recursionLimit`:
12
- * the middleware warns inside the graph, the engine's own limit enforces.
10
+ * signal, the same way it learns of a platform STOP or a stall. The
11
+ * middleware warns inside the graph; the runtime enforces, because the
12
+ * running estimate advances only in its usage accumulator. The round budget
13
+ * is the one budget enforced where it is counted (`execution-budget.ts`): a
14
+ * round is visible only at the model call, and stopping there is what makes
15
+ * it exact.
13
16
  *
14
17
  * Until #1096 this module was `cost-cap.ts` and did both: it also
15
18
  * blocked every tool call once the running cost passed the cap and gave the
@@ -1,26 +1,41 @@
1
1
  /**
2
- * Execution budget middleware for autonomous agents.
2
+ * Execution budget middleware for autonomous agents: `max_tool_rounds`' own
3
+ * enforcement, and the advisories that warn the model before it.
3
4
  *
4
- * Two operating modes:
5
+ * The stop (whenever `maxToolRounds` is set):
6
+ * A round is one model response that proposes one or more tool calls
7
+ * (parallel calls are one round), counted here as each response returns.
8
+ * Once N rounds have been proposed, the next model call does not happen:
9
+ * `wrapModelCall` throws `ToolRoundLimitError` in its place, the graph
10
+ * ends on round N's tool results, and the native turn reads the error as
11
+ * the `tool_call_limit` outcome (`shared/tool-rounds.ts`
12
+ * `isToolCallBudgetStop`). Stopping before a model call, never between a
13
+ * model call and its tools, is what makes N exact and leaves no proposed
14
+ * call unexecuted in the checkpoint. It is the one stop a middleware makes
15
+ * (`middleware/index.ts`): the runtime still sees it, as an outcome.
16
+ *
17
+ * Two advisory modes:
5
18
  *
6
19
  * Threshold mode (default, warningInterval=null):
7
- * Fires a single SystemMessage at a computed percentage of the
8
- * LangGraph recursion limit.
20
+ * Fires a single SystemMessage at a percentage of the round budget
21
+ * (`maxToolRounds`, or the unbounded advisory figure when unset).
9
22
  *
10
23
  * Periodic mode (warningInterval set):
11
- * Fires a SystemMessage every N model rounds with escalating urgency,
12
- * up to maxWarnings times.
24
+ * Fires a SystemMessage every N model calls with escalating urgency,
25
+ * up to maxWarnings times. Sub-agent stacks use it, with no stop.
13
26
  *
14
27
  * Uses wrapModelCall (not afterModel) to safely prepend advisory
15
28
  * messages to the model's input, avoiding the AIMessage/ToolMessage
16
29
  * ordering violation that would occur if we injected between a
17
- * tool_use AIMessage and its corresponding ToolMessage.
30
+ * tool_use AIMessage and its corresponding ToolMessage, and so that the
31
+ * count and the stop add no graph node of their own: a node per round is
32
+ * exactly the cost that made the old super-step budget drift.
18
33
  */
19
34
 
20
- import { SystemMessage } from "@langchain/core/messages";
35
+ import { AIMessage, SystemMessage } from "@langchain/core/messages";
21
36
  import type { StigmerMiddleware, ExecutionBudgetConfig } from "./types.js";
37
+ import { ToolRoundLimitError, UNBOUNDED_ADVISORY_TOOL_ROUNDS } from "../shared/tool-rounds.js";
22
38
 
23
- const DEFAULT_RECURSION_LIMIT = 6000;
24
39
  const DEFAULT_WARNING_PCT = 80;
25
40
  const MIN_WARNING_PCT = 50;
26
41
  const MAX_WARNING_PCT = 95;
@@ -46,46 +61,51 @@ const PERIODIC_MESSAGES: readonly string[] = [
46
61
  "unless absolutely essential to your conclusion.",
47
62
  ];
48
63
 
49
- function computeWarningRound(recursionLimit: number, warningPct: number): number {
50
- const estimatedTotalRounds = Math.floor(recursionLimit / 6);
51
- const threshold = Math.floor(estimatedTotalRounds * warningPct / 100);
64
+ function computeWarningRound(roundBudget: number, warningPct: number): number {
65
+ const threshold = Math.floor(roundBudget * warningPct / 100);
52
66
  return Math.max(threshold, MIN_ROUNDS_BEFORE_WARNING);
53
67
  }
54
68
 
69
+ /** Whether a model call's response proposed tools: the round definition (a `Command` response is not one). */
70
+ function isToolRound(response: unknown): boolean {
71
+ return AIMessage.isInstance(response) && (response.tool_calls?.length ?? 0) > 0;
72
+ }
73
+
55
74
  export function createExecutionBudgetMiddleware(
56
75
  config: Partial<ExecutionBudgetConfig> = {},
57
76
  ): StigmerMiddleware {
58
- const recursionLimit = config.recursionLimit ?? DEFAULT_RECURSION_LIMIT;
77
+ const maxToolRounds = config.maxToolRounds ?? null;
78
+ const roundBudget = maxToolRounds ?? UNBOUNDED_ADVISORY_TOOL_ROUNDS;
59
79
  const warningPct = config.warningPct ?? DEFAULT_WARNING_PCT;
60
80
  const warningInterval = config.warningInterval ?? null;
61
81
  const maxWarnings = config.maxWarnings ?? 4;
62
82
 
63
83
  const isPeriodic = warningInterval !== null;
64
84
 
85
+ if (maxToolRounds !== null && maxToolRounds <= 0) {
86
+ throw new Error(`maxToolRounds must be positive or null, got ${maxToolRounds}`);
87
+ }
65
88
  if (isPeriodic) {
66
89
  if (warningInterval <= 0) throw new Error(`warningInterval must be positive, got ${warningInterval}`);
67
90
  if (maxWarnings <= 0) throw new Error(`maxWarnings must be positive, got ${maxWarnings}`);
68
- } else {
69
- if (warningPct < MIN_WARNING_PCT || warningPct > MAX_WARNING_PCT) {
70
- throw new Error(`warningPct must be between ${MIN_WARNING_PCT} and ${MAX_WARNING_PCT}, got ${warningPct}`);
71
- }
72
- if (recursionLimit <= 0) throw new Error(`recursionLimit must be positive, got ${recursionLimit}`);
91
+ } else if (warningPct < MIN_WARNING_PCT || warningPct > MAX_WARNING_PCT) {
92
+ throw new Error(`warningPct must be between ${MIN_WARNING_PCT} and ${MAX_WARNING_PCT}, got ${warningPct}`);
73
93
  }
74
94
 
75
95
  let modelRoundCount = 0;
96
+ let toolRoundCount = 0;
76
97
  let warningCount = 0;
77
98
  let nextWarningRound = isPeriodic
78
99
  ? warningInterval!
79
- : computeWarningRound(recursionLimit, warningPct);
100
+ : computeWarningRound(roundBudget, warningPct);
80
101
  let pendingAdvisory: SystemMessage | null = null;
81
102
 
82
103
  function createThresholdWarning(): SystemMessage {
83
- const estimatedTotal = Math.floor(recursionLimit / 6);
84
- const remaining = Math.max(estimatedTotal - modelRoundCount, 0);
104
+ const remaining = Math.max(roundBudget - toolRoundCount, 0);
85
105
  return new SystemMessage({
86
106
  content:
87
- `You are approaching the step limit for this message ` +
88
- `(approximately ${warningPct}% used, ~${remaining} rounds remaining). ` +
107
+ `You are approaching the tool-round limit for this message ` +
108
+ `(${toolRoundCount} of ${roundBudget} tool rounds used, ${remaining} remaining). ` +
89
109
  `Prioritize completing your current task. Summarize results ` +
90
110
  `and any remaining work so the user can continue in the next message.`,
91
111
  });
@@ -113,12 +133,11 @@ export function createExecutionBudgetMiddleware(
113
133
  }
114
134
  } else {
115
135
  if (warningCount > 0) return;
116
- if (modelRoundCount >= nextWarningRound) {
136
+ if (toolRoundCount >= nextWarningRound) {
117
137
  warningCount = 1;
118
- const estimatedTotal = Math.floor(recursionLimit / 6);
119
138
  console.warn(
120
- `[ExecutionBudget] WARNING: round ${modelRoundCount} of ~${estimatedTotal} ` +
121
- `(~${warningPct}% of recursion_limit=${recursionLimit})`,
139
+ `[ExecutionBudget] WARNING: tool round ${toolRoundCount} of ${roundBudget} ` +
140
+ `(${warningPct}% threshold)`,
122
141
  );
123
142
  pendingAdvisory = createThresholdWarning();
124
143
  }
@@ -130,14 +149,23 @@ export function createExecutionBudgetMiddleware(
130
149
 
131
150
  beforeAgent() {
132
151
  modelRoundCount = 0;
152
+ toolRoundCount = 0;
133
153
  warningCount = 0;
134
154
  pendingAdvisory = null;
135
155
  nextWarningRound = isPeriodic
136
156
  ? warningInterval!
137
- : computeWarningRound(recursionLimit, warningPct);
157
+ : computeWarningRound(roundBudget, warningPct);
138
158
  },
139
159
 
140
160
  async wrapModelCall(request, handler) {
161
+ if (maxToolRounds !== null && toolRoundCount >= maxToolRounds) {
162
+ console.log(
163
+ `[ExecutionBudget] Tool-round limit reached: ${toolRoundCount}/${maxToolRounds}; ` +
164
+ `ending the turn before the next model call`,
165
+ );
166
+ throw new ToolRoundLimitError(maxToolRounds);
167
+ }
168
+
141
169
  let effectiveRequest = request;
142
170
 
143
171
  if (pendingAdvisory !== null) {
@@ -150,6 +178,7 @@ export function createExecutionBudgetMiddleware(
150
178
  const response = await handler(effectiveRequest);
151
179
 
152
180
  modelRoundCount++;
181
+ if (isToolRound(response)) toolRoundCount++;
153
182
  evaluateBudget();
154
183
 
155
184
  return response;
@@ -162,13 +191,10 @@ export function createExecutionBudgetMiddleware(
162
191
  `${warningCount}/${maxWarnings} advisories (interval=${warningInterval})`,
163
192
  );
164
193
  } else {
165
- const estimatedTotal = Math.floor(recursionLimit / 6);
166
- const pctUsed = estimatedTotal > 0
167
- ? Math.floor(modelRoundCount * 100 / estimatedTotal)
168
- : 0;
194
+ const pctUsed = Math.floor(toolRoundCount * 100 / roundBudget);
169
195
  console.log(
170
- `[ExecutionBudget] Summary (threshold): ${modelRoundCount} rounds ` +
171
- `of ~${estimatedTotal} (~${pctUsed}% used), warnings=${warningCount}`,
196
+ `[ExecutionBudget] Summary (threshold): ${toolRoundCount} tool rounds ` +
197
+ `of ${roundBudget} (${pctUsed}% used), warnings=${warningCount}`,
172
198
  );
173
199
  }
174
200
  },
@@ -8,8 +8,8 @@
8
8
  * since issue #754 — FIRST, so every downstream middleware observes
9
9
  * canonical workspace-absolute paths)
10
10
  * 1. Loop detection (always)
11
- * 2. Execution budget (always — advises at ~80% of the tool-round budget
12
- * that LangGraph's `recursionLimit` enforces)
11
+ * 2. Execution budget (always — counts tool rounds, advises at ~80% of
12
+ * `max_tool_rounds` and ends the turn at it)
13
13
  * 3. Tool intent (always — bind-time shell schema extension, issue #276)
14
14
  * 4. Tool truncation (always)
15
15
  * 5. Approval gate (conditional: absent under the global bypass)
@@ -19,11 +19,14 @@
19
19
  * 8. OTel spans (always, no-op when OTel not configured)
20
20
  *
21
21
  * Every middleware here either shapes the graph's tool surface or ADVISES
22
- * the model; none of them stops the run. A stop — the platform's, the
23
- * cost cap's, a stall's — is the turn runtime's, delivered to the graph
24
- * through its abort signal (since #1096; until then a
25
- * graceful-stop middleware and the cost cap's tool block were second stops
26
- * inside the graph that the runtime could not see).
22
+ * the model; none of them stops the run, except the execution budget, which
23
+ * is `max_tool_rounds`' own enforcement: it ends the turn as the adapter's
24
+ * `tool_call_limit` outcome, the job LangGraph's recursion limit held until
25
+ * #1113, so the runtime sees it as an outcome and never as a completion.
26
+ * Every other stop — the platform's, the cost cap's, a stall's — is the turn
27
+ * runtime's, delivered to the graph through its abort signal (since #1096;
28
+ * until then a graceful-stop middleware and the cost cap's tool block were
29
+ * second stops inside the graph that the runtime could not see).
27
30
  *
28
31
  * Ahead of this stack the parent graph adds the framework's to-do list
29
32
  * middleware (`execute-deep-agent/turn-setup.ts`): it is langchain's, not
@@ -71,7 +71,12 @@ export interface LoopDetectionConfig {
71
71
  }
72
72
 
73
73
  export interface ExecutionBudgetConfig {
74
- readonly recursionLimit: number;
74
+ /**
75
+ * The turn's round budget, already clamped (`shared/tool-rounds.ts`
76
+ * `resolveToolRoundLimit`); the middleware ends the turn when it is spent.
77
+ * `null` is unlimited: the advisory still fires, nothing is stopped.
78
+ */
79
+ readonly maxToolRounds: number | null;
75
80
  readonly warningPct: number;
76
81
  readonly warningInterval: number | null;
77
82
  readonly maxWarnings: number;
@@ -722,9 +722,10 @@ export function mapManagerOptionsToConfig(
722
722
  temporalConnection:
723
723
  options.temporalConnection ?? loadTemporalConnectionConfig(getRunnerSecret),
724
724
  stigmerBackendEndpoint: normalizeEndpoint(options.stigmerEndpoint),
725
- // Env-only like the env-loaded path (loadConfig): the bridge endpoint
726
- // is deployment topology, not per-session state.
725
+ // Env-only like the env-loaded path (loadConfig): the bridge and public
726
+ // endpoints are deployment topology, not per-session state.
727
727
  mcpBridgeEndpoint: process.env.STIGMER_MCP_BRIDGE_ENDPOINT ?? null,
728
+ mcpPublicEndpoint: process.env.STIGMER_MCP_PUBLIC_ENDPOINT || null,
728
729
  stigmerTokenRef: tokenRef,
729
730
  stigmerRunnerTokenRef: runnerTokenRef,
730
731
  // The manager's proxy credential is the minted runner token once adopted