@namzu/sdk 6.2.0 → 8.0.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 (251) hide show
  1. package/CHANGELOG.md +677 -0
  2. package/dist/agents/ReactiveAgent.d.ts.map +1 -1
  3. package/dist/agents/ReactiveAgent.js +5 -0
  4. package/dist/agents/ReactiveAgent.js.map +1 -1
  5. package/dist/agents/SupervisorAgent.d.ts.map +1 -1
  6. package/dist/agents/SupervisorAgent.js +172 -158
  7. package/dist/agents/SupervisorAgent.js.map +1 -1
  8. package/dist/agents/__tests__/supervisor-inbox-scope.test.d.ts +2 -0
  9. package/dist/agents/__tests__/supervisor-inbox-scope.test.d.ts.map +1 -0
  10. package/dist/agents/__tests__/supervisor-inbox-scope.test.js +125 -0
  11. package/dist/agents/__tests__/supervisor-inbox-scope.test.js.map +1 -0
  12. package/dist/agents/runAgent.d.ts +19 -1
  13. package/dist/agents/runAgent.d.ts.map +1 -1
  14. package/dist/agents/runAgent.js +2 -0
  15. package/dist/agents/runAgent.js.map +1 -1
  16. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  17. package/dist/bridge/a2a/mapper.js +4 -0
  18. package/dist/bridge/a2a/mapper.js.map +1 -1
  19. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  20. package/dist/bridge/sse/mapper.js +24 -0
  21. package/dist/bridge/sse/mapper.js.map +1 -1
  22. package/dist/connector/mcp/__tests__/positional-arrays.test.d.ts +2 -0
  23. package/dist/connector/mcp/__tests__/positional-arrays.test.d.ts.map +1 -0
  24. package/dist/connector/mcp/__tests__/positional-arrays.test.js +142 -0
  25. package/dist/connector/mcp/__tests__/positional-arrays.test.js.map +1 -0
  26. package/dist/connector/mcp/adapter.d.ts.map +1 -1
  27. package/dist/connector/mcp/adapter.js +123 -8
  28. package/dist/connector/mcp/adapter.js.map +1 -1
  29. package/dist/constants/agent/index.d.ts +5 -0
  30. package/dist/constants/agent/index.d.ts.map +1 -1
  31. package/dist/constants/agent/index.js +5 -0
  32. package/dist/constants/agent/index.js.map +1 -1
  33. package/dist/constants/plugin/index.d.ts +15 -0
  34. package/dist/constants/plugin/index.d.ts.map +1 -1
  35. package/dist/constants/plugin/index.js +15 -0
  36. package/dist/constants/plugin/index.js.map +1 -1
  37. package/dist/contracts/api.d.ts +1 -1
  38. package/dist/contracts/api.d.ts.map +1 -1
  39. package/dist/gateway/__tests__/completion-inbox.test.js +292 -2
  40. package/dist/gateway/__tests__/completion-inbox.test.js.map +1 -1
  41. package/dist/gateway/completion-inbox.d.ts +94 -6
  42. package/dist/gateway/completion-inbox.d.ts.map +1 -1
  43. package/dist/gateway/completion-inbox.js +235 -15
  44. package/dist/gateway/completion-inbox.js.map +1 -1
  45. package/dist/gateway/local.d.ts +11 -0
  46. package/dist/gateway/local.d.ts.map +1 -1
  47. package/dist/gateway/local.js +27 -1
  48. package/dist/gateway/local.js.map +1 -1
  49. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  50. package/dist/manager/agent/lifecycle.js +6 -0
  51. package/dist/manager/agent/lifecycle.js.map +1 -1
  52. package/dist/manager/run/persistence.d.ts +8 -0
  53. package/dist/manager/run/persistence.d.ts.map +1 -1
  54. package/dist/manager/run/persistence.js +12 -0
  55. package/dist/manager/run/persistence.js.map +1 -1
  56. package/dist/provider/thinking-support.d.ts +2 -1
  57. package/dist/provider/thinking-support.d.ts.map +1 -1
  58. package/dist/provider/thinking-support.js +14 -0
  59. package/dist/provider/thinking-support.js.map +1 -1
  60. package/dist/public-runtime.d.ts +1 -1
  61. package/dist/public-runtime.d.ts.map +1 -1
  62. package/dist/public-runtime.js +9 -1
  63. package/dist/public-runtime.js.map +1 -1
  64. package/dist/run/reporter.d.ts.map +1 -1
  65. package/dist/run/reporter.js +11 -0
  66. package/dist/run/reporter.js.map +1 -1
  67. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.d.ts +2 -0
  68. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.d.ts.map +1 -0
  69. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.js +142 -0
  70. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.js.map +1 -0
  71. package/dist/runtime/query/__tests__/completion-notification.test.js +414 -32
  72. package/dist/runtime/query/__tests__/completion-notification.test.js.map +1 -1
  73. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.d.ts +2 -0
  74. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.d.ts.map +1 -0
  75. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.js +100 -0
  76. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.js.map +1 -0
  77. package/dist/runtime/query/__tests__/context.test.js +18 -0
  78. package/dist/runtime/query/__tests__/context.test.js.map +1 -1
  79. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.d.ts +2 -0
  80. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.d.ts.map +1 -0
  81. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.js +118 -0
  82. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.js.map +1 -0
  83. package/dist/runtime/query/__tests__/tool-timeout.test.js +34 -0
  84. package/dist/runtime/query/__tests__/tool-timeout.test.js.map +1 -1
  85. package/dist/runtime/query/context.d.ts.map +1 -1
  86. package/dist/runtime/query/context.js +16 -1
  87. package/dist/runtime/query/context.js.map +1 -1
  88. package/dist/runtime/query/executor.d.ts.map +1 -1
  89. package/dist/runtime/query/executor.js +11 -1
  90. package/dist/runtime/query/executor.js.map +1 -1
  91. package/dist/runtime/query/guard.d.ts +28 -0
  92. package/dist/runtime/query/guard.d.ts.map +1 -1
  93. package/dist/runtime/query/guard.js +31 -0
  94. package/dist/runtime/query/guard.js.map +1 -1
  95. package/dist/runtime/query/iteration/__tests__/settle-grace.test.d.ts +2 -0
  96. package/dist/runtime/query/iteration/__tests__/settle-grace.test.d.ts.map +1 -0
  97. package/dist/runtime/query/iteration/__tests__/settle-grace.test.js +226 -0
  98. package/dist/runtime/query/iteration/__tests__/settle-grace.test.js.map +1 -0
  99. package/dist/runtime/query/iteration/index.d.ts +92 -0
  100. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  101. package/dist/runtime/query/iteration/index.js +818 -565
  102. package/dist/runtime/query/iteration/index.js.map +1 -1
  103. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.d.ts +2 -0
  104. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.d.ts.map +1 -0
  105. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.js +95 -0
  106. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.js.map +1 -0
  107. package/dist/runtime/query/iteration/phases/compaction.d.ts +34 -0
  108. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  109. package/dist/runtime/query/iteration/phases/compaction.js +61 -4
  110. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  111. package/dist/telemetry/__tests__/model-call-span.test.js +22 -4
  112. package/dist/telemetry/__tests__/model-call-span.test.js.map +1 -1
  113. package/dist/telemetry/__tests__/span-closure.test.js +12 -5
  114. package/dist/telemetry/__tests__/span-closure.test.js.map +1 -1
  115. package/dist/tools/__tests__/untrusted-envelope.test.js +16 -0
  116. package/dist/tools/__tests__/untrusted-envelope.test.js.map +1 -1
  117. package/dist/tools/coordinator/__tests__/completion-delivery.test.js +117 -0
  118. package/dist/tools/coordinator/__tests__/completion-delivery.test.js.map +1 -1
  119. package/dist/tools/coordinator/__tests__/task-list.test.js +57 -0
  120. package/dist/tools/coordinator/__tests__/task-list.test.js.map +1 -1
  121. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.d.ts +2 -0
  122. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.d.ts.map +1 -0
  123. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.js +193 -0
  124. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.js.map +1 -0
  125. package/dist/tools/coordinator/index.d.ts +19 -0
  126. package/dist/tools/coordinator/index.d.ts.map +1 -1
  127. package/dist/tools/coordinator/index.js +191 -71
  128. package/dist/tools/coordinator/index.js.map +1 -1
  129. package/dist/tools/coordinator/wait-with-idle-bound.d.ts +66 -0
  130. package/dist/tools/coordinator/wait-with-idle-bound.d.ts.map +1 -0
  131. package/dist/tools/coordinator/wait-with-idle-bound.js +78 -0
  132. package/dist/tools/coordinator/wait-with-idle-bound.js.map +1 -0
  133. package/dist/tools/untrusted-envelope.d.ts.map +1 -1
  134. package/dist/tools/untrusted-envelope.js +9 -1
  135. package/dist/tools/untrusted-envelope.js.map +1 -1
  136. package/dist/types/agent/base.d.ts +16 -0
  137. package/dist/types/agent/base.d.ts.map +1 -1
  138. package/dist/types/agent/gateway.d.ts +41 -0
  139. package/dist/types/agent/gateway.d.ts.map +1 -1
  140. package/dist/types/agent/lifecycle-event.d.ts +9 -1
  141. package/dist/types/agent/lifecycle-event.d.ts.map +1 -1
  142. package/dist/types/agent/task.d.ts +5 -0
  143. package/dist/types/agent/task.d.ts.map +1 -1
  144. package/dist/types/hitl/index.d.ts +10 -0
  145. package/dist/types/hitl/index.d.ts.map +1 -1
  146. package/dist/types/hitl/index.js.map +1 -1
  147. package/dist/types/probe/registry.d.ts +6 -0
  148. package/dist/types/probe/registry.d.ts.map +1 -1
  149. package/dist/types/provider/interface.d.ts +35 -0
  150. package/dist/types/provider/interface.d.ts.map +1 -1
  151. package/dist/types/run/config.d.ts +25 -0
  152. package/dist/types/run/config.d.ts.map +1 -1
  153. package/dist/types/run/entity.d.ts +16 -0
  154. package/dist/types/run/entity.d.ts.map +1 -1
  155. package/dist/types/run/events.d.ts +75 -0
  156. package/dist/types/run/events.d.ts.map +1 -1
  157. package/dist/types/run/events.js.map +1 -1
  158. package/dist/types/run/prepare-step.d.ts +17 -2
  159. package/dist/types/run/prepare-step.d.ts.map +1 -1
  160. package/dist/types/verification/index.d.ts +98 -0
  161. package/dist/types/verification/index.d.ts.map +1 -1
  162. package/dist/types/verification/index.js +10 -0
  163. package/dist/types/verification/index.js.map +1 -1
  164. package/dist/utils/__tests__/abort-reason.test.d.ts +2 -0
  165. package/dist/utils/__tests__/abort-reason.test.d.ts.map +1 -0
  166. package/dist/utils/__tests__/abort-reason.test.js +48 -0
  167. package/dist/utils/__tests__/abort-reason.test.js.map +1 -0
  168. package/dist/utils/abort.d.ts +26 -0
  169. package/dist/utils/abort.d.ts.map +1 -1
  170. package/dist/utils/abort.js +34 -0
  171. package/dist/utils/abort.js.map +1 -1
  172. package/dist/verification/__tests__/argument-pattern.test.d.ts +2 -0
  173. package/dist/verification/__tests__/argument-pattern.test.d.ts.map +1 -0
  174. package/dist/verification/__tests__/argument-pattern.test.js +122 -0
  175. package/dist/verification/__tests__/argument-pattern.test.js.map +1 -0
  176. package/dist/verification/__tests__/rule-order-and-reason.test.d.ts +2 -0
  177. package/dist/verification/__tests__/rule-order-and-reason.test.d.ts.map +1 -0
  178. package/dist/verification/__tests__/rule-order-and-reason.test.js +126 -0
  179. package/dist/verification/__tests__/rule-order-and-reason.test.js.map +1 -0
  180. package/dist/verification/gate.d.ts +17 -1
  181. package/dist/verification/gate.d.ts.map +1 -1
  182. package/dist/verification/gate.js +102 -2
  183. package/dist/verification/gate.js.map +1 -1
  184. package/dist/verification/index.d.ts +1 -1
  185. package/dist/verification/index.d.ts.map +1 -1
  186. package/dist/verification/index.js +1 -1
  187. package/dist/verification/index.js.map +1 -1
  188. package/dist/verification/rules.d.ts.map +1 -1
  189. package/dist/verification/rules.js +27 -0
  190. package/dist/verification/rules.js.map +1 -1
  191. package/package.json +1 -1
  192. package/src/agents/ReactiveAgent.ts +5 -0
  193. package/src/agents/SupervisorAgent.ts +175 -162
  194. package/src/agents/__tests__/supervisor-inbox-scope.test.ts +149 -0
  195. package/src/agents/runAgent.ts +22 -1
  196. package/src/bridge/a2a/mapper.ts +4 -0
  197. package/src/bridge/sse/mapper.ts +25 -0
  198. package/src/connector/mcp/__tests__/positional-arrays.test.ts +183 -0
  199. package/src/connector/mcp/adapter.ts +131 -7
  200. package/src/constants/agent/index.ts +5 -0
  201. package/src/constants/plugin/index.ts +15 -0
  202. package/src/contracts/api.ts +1 -0
  203. package/src/gateway/__tests__/completion-inbox.test.ts +348 -2
  204. package/src/gateway/completion-inbox.ts +248 -16
  205. package/src/gateway/local.ts +26 -1
  206. package/src/manager/agent/lifecycle.ts +6 -0
  207. package/src/manager/run/persistence.ts +12 -0
  208. package/src/provider/thinking-support.ts +19 -2
  209. package/src/public-runtime.ts +9 -0
  210. package/src/run/reporter.ts +12 -0
  211. package/src/runtime/query/__tests__/completion-does-not-erase-the-answer.test.ts +163 -0
  212. package/src/runtime/query/__tests__/completion-notification.test.ts +486 -34
  213. package/src/runtime/query/__tests__/context-size-on-the-wire.test.ts +122 -0
  214. package/src/runtime/query/__tests__/context.test.ts +24 -0
  215. package/src/runtime/query/__tests__/effort-reaches-the-wire.test.ts +135 -0
  216. package/src/runtime/query/__tests__/tool-timeout.test.ts +38 -0
  217. package/src/runtime/query/context.ts +16 -1
  218. package/src/runtime/query/executor.ts +11 -1
  219. package/src/runtime/query/guard.ts +32 -0
  220. package/src/runtime/query/iteration/__tests__/settle-grace.test.ts +265 -0
  221. package/src/runtime/query/iteration/index.ts +906 -635
  222. package/src/runtime/query/iteration/phases/__tests__/compaction-declined.test.ts +124 -0
  223. package/src/runtime/query/iteration/phases/compaction.ts +83 -10
  224. package/src/telemetry/__tests__/model-call-span.test.ts +22 -5
  225. package/src/telemetry/__tests__/span-closure.test.ts +12 -5
  226. package/src/tools/__tests__/untrusted-envelope.test.ts +23 -0
  227. package/src/tools/coordinator/__tests__/completion-delivery.test.ts +147 -0
  228. package/src/tools/coordinator/__tests__/task-list.test.ts +72 -0
  229. package/src/tools/coordinator/__tests__/wait-with-idle-bound.test.ts +247 -0
  230. package/src/tools/coordinator/index.ts +205 -78
  231. package/src/tools/coordinator/wait-with-idle-bound.ts +142 -0
  232. package/src/tools/untrusted-envelope.ts +9 -1
  233. package/src/types/agent/base.ts +17 -0
  234. package/src/types/agent/gateway.ts +42 -0
  235. package/src/types/agent/lifecycle-event.ts +7 -0
  236. package/src/types/agent/task.ts +5 -0
  237. package/src/types/hitl/index.ts +10 -0
  238. package/src/types/probe/registry.ts +6 -0
  239. package/src/types/provider/interface.ts +39 -0
  240. package/src/types/run/config.ts +26 -0
  241. package/src/types/run/entity.ts +17 -0
  242. package/src/types/run/events.ts +75 -0
  243. package/src/types/run/prepare-step.ts +17 -2
  244. package/src/types/verification/index.ts +61 -0
  245. package/src/utils/__tests__/abort-reason.test.ts +56 -0
  246. package/src/utils/abort.ts +34 -0
  247. package/src/verification/__tests__/argument-pattern.test.ts +158 -0
  248. package/src/verification/__tests__/rule-order-and-reason.test.ts +149 -0
  249. package/src/verification/gate.ts +106 -3
  250. package/src/verification/index.ts +1 -1
  251. package/src/verification/rules.ts +28 -0
@@ -0,0 +1,122 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import { MockLLMProvider } from '../../../provider/mock.js'
6
+ import { ToolRegistry } from '../../../registry/tool/execute.js'
7
+ import { createUserMessage } from '../../../types/message/index.js'
8
+ import type { RunEvent } from '../../../types/run/index.js'
9
+ import { drainQuery } from '../index.js'
10
+
11
+ /**
12
+ * Cumulative spend and current context size are different quantities, and a
13
+ * host divided the first by a context window and shipped it.
14
+ *
15
+ * That indicator climbed toward full on any long run no matter how much room
16
+ * the conversation actually had — most wrong exactly when someone needed it —
17
+ * because `usage` is summed over every turn and never falls, while the context
18
+ * is what is being sent right now and falls whenever a compaction sheds.
19
+ *
20
+ * The host had no better option: the correct numerator needs internals it
21
+ * cannot see, and the correct denominator was a two-branch guess on a model
22
+ * name. So both numbers are on the event, named so that reaching for the wrong
23
+ * one is a visible mistake rather than a plausible guess.
24
+ */
25
+
26
+ let workdirs: string[] = []
27
+
28
+ afterEach(async () => {
29
+ await Promise.all(workdirs.map((d) => rm(d, { recursive: true, force: true })))
30
+ workdirs = []
31
+ })
32
+
33
+ type UsageEvent = Extract<RunEvent, { type: 'token_usage_updated' }>
34
+
35
+ async function run(
36
+ compaction: Record<string, unknown> | undefined,
37
+ turns: unknown[],
38
+ ): Promise<UsageEvent[]> {
39
+ const seen: UsageEvent[] = []
40
+ const dir = await mkdtemp(join(tmpdir(), 'namzu-ctxsize-'))
41
+ workdirs.push(dir)
42
+
43
+ await drainQuery(
44
+ {
45
+ provider: new MockLLMProvider({ turns: turns as never }),
46
+ tools: new ToolRegistry(),
47
+ runConfig: { model: 'mock-model', timeoutMs: 30_000, tokenBudget: 100_000, maxIterations: 3 },
48
+ ...(compaction ? { compactionConfig: compaction } : {}),
49
+ agentId: 'agent_ctx',
50
+ agentName: 'Context Agent',
51
+ workingDirectory: dir,
52
+ sessionId: 'ses_ctx',
53
+ threadId: 'thd_ctx',
54
+ projectId: 'prj_ctx',
55
+ tenantId: 'tnt_ctx',
56
+ messages: [createUserMessage('go')],
57
+ } as never,
58
+ (event: RunEvent) => {
59
+ if (event.type === 'token_usage_updated') seen.push(event)
60
+ },
61
+ )
62
+
63
+ return seen
64
+ }
65
+
66
+ const COMPACTION = {
67
+ strategy: 'sliding-window',
68
+ triggerThreshold: 0.9,
69
+ contextWindowTokens: 50_000,
70
+ keepRecentMessages: 4,
71
+ }
72
+
73
+ describe('a run reports how much room its context has', () => {
74
+ it('carries the context size and the window together', async () => {
75
+ const events = await run(COMPACTION, [{ text: 'done' }])
76
+
77
+ expect(events.length).toBeGreaterThan(0)
78
+ const event = events[0] as UsageEvent
79
+ expect(event.contextTokens).toBeGreaterThan(0)
80
+ expect(event.contextWindowTokens).toBe(50_000)
81
+ })
82
+
83
+ it('says whether each number was measured or guessed', async () => {
84
+ // A fraction is only as honest as the weaker of its two terms, so a
85
+ // surface rendering these owes a reader the distinction. It cannot
86
+ // pass it on if it never receives it.
87
+ const event = (await run(COMPACTION, [{ text: 'done' }]))[0] as UsageEvent
88
+
89
+ expect(['provider', 'estimate']).toContain(event.contextMeasuredBy)
90
+ // The window was stated in config here, so its provenance is not a guess.
91
+ expect(event.windowSource).toBe('config')
92
+ })
93
+
94
+ it('keeps the context size distinct from cumulative spend', async () => {
95
+ // The whole defect in one assertion, and this driver demonstrates it
96
+ // more sharply than a real one would: it reports no usage at all, so
97
+ // cumulative spend stays at zero while the context genuinely fills.
98
+ //
99
+ // A surface dividing spend by a window would show 0% here, for a
100
+ // conversation that is really there. The same surface on a long run
101
+ // shows 100% for a conversation that was compacted down to nothing.
102
+ // Both directions, same category error — which is why the two numbers
103
+ // are reported separately rather than left to be inferred from each
104
+ // other.
105
+ const events = await run(COMPACTION, [{ text: 'one' }, { text: 'two' }])
106
+ const last = events[events.length - 1] as UsageEvent
107
+
108
+ expect(last.contextTokens).toBeGreaterThan(0)
109
+ expect(last.contextTokens).not.toBe(last.usage.totalTokens)
110
+ })
111
+
112
+ it('reports nothing rather than a guess when no window is configured', async () => {
113
+ // Without a compaction config nothing resolves a window, and inventing
114
+ // one would be the guess this replaces. Absent is a fact a surface can
115
+ // act on; a fabricated default is not.
116
+ const event = (await run(undefined, [{ text: 'done' }]))[0] as UsageEvent
117
+
118
+ expect(event.contextTokens).toBeUndefined()
119
+ expect(event.contextWindowTokens).toBeUndefined()
120
+ expect(event.windowSource).toBeUndefined()
121
+ })
122
+ })
@@ -100,4 +100,28 @@ describe('RunContextFactory.build', () => {
100
100
  const runDir = builder.runDir('prj_x' as ProjectId, 'ses_y' as SessionId, 'run_z' as RunId)
101
101
  expect(posix(runDir)).toBe('/base/.namzu/projects/prj_x/sessions/ses_y/runs/run_z')
102
102
  })
103
+
104
+ it("carries the caller's stop reason across into the run", () => {
105
+ // This is the frame that was losing it. The host aborts with a
106
+ // sentence, the run re-aborts with nothing, and every layer below —
107
+ // the tool executor, the tool itself, the result the model reads —
108
+ // can only report that something stopped. The words do not survive
109
+ // the hop unless this site forwards them.
110
+ const host = new AbortController()
111
+ const ctx = RunContextFactory.build(buildConfig({ signal: host.signal }))
112
+
113
+ host.abort(new Error('nightly window closed'))
114
+
115
+ expect(ctx.abortController.signal.aborted).toBe(true)
116
+ expect((ctx.abortController.signal.reason as Error)?.message).toBe('nightly window closed')
117
+ })
118
+
119
+ it('still aborts when the caller gave no reason', () => {
120
+ const host = new AbortController()
121
+ const ctx = RunContextFactory.build(buildConfig({ signal: host.signal }))
122
+
123
+ host.abort()
124
+
125
+ expect(ctx.abortController.signal.aborted).toBe(true)
126
+ })
103
127
  })
@@ -0,0 +1,135 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import { MockLLMProvider } from '../../../provider/mock.js'
6
+ import { ToolRegistry } from '../../../registry/tool/execute.js'
7
+ import { createUserMessage } from '../../../types/message/index.js'
8
+ import type { AgentRunConfig } from '../../../types/run/index.js'
9
+ import { drainQuery } from '../index.js'
10
+
11
+ /**
12
+ * `effort` was declared on the provider params, exported, and read by a driver
13
+ * that wrote it straight to the wire — and nothing in the kernel ever set it.
14
+ * No caller could reach it, and the symptom (every request going out at the
15
+ * model's default) reads as "this model ignores effort" rather than "nobody
16
+ * plumbed it through".
17
+ *
18
+ * These drive a real run and read what the provider was actually handed, which
19
+ * is the only thing that distinguishes a wired field from a declared one. A
20
+ * test asserting the field exists on the config type would have passed against
21
+ * the broken version.
22
+ */
23
+
24
+ let workdirs: string[] = []
25
+
26
+ afterEach(async () => {
27
+ await Promise.all(workdirs.map((d) => rm(d, { recursive: true, force: true })))
28
+ workdirs = []
29
+ })
30
+
31
+ async function run(overrides: Partial<AgentRunConfig>, turns: unknown[]): Promise<MockLLMProvider> {
32
+ const provider = new MockLLMProvider({ turns: turns as never })
33
+ const dir = await mkdtemp(join(tmpdir(), 'namzu-effort-'))
34
+ workdirs.push(dir)
35
+
36
+ await drainQuery({
37
+ provider,
38
+ tools: new ToolRegistry(),
39
+ runConfig: {
40
+ model: 'mock-model',
41
+ timeoutMs: 30_000,
42
+ tokenBudget: 100_000,
43
+ maxIterations: 2,
44
+ ...overrides,
45
+ },
46
+ agentId: 'agent_effort',
47
+ agentName: 'Effort Agent',
48
+ workingDirectory: dir,
49
+ sessionId: 'ses_effort',
50
+ threadId: 'thd_effort',
51
+ projectId: 'prj_effort',
52
+ tenantId: 'tnt_effort',
53
+ messages: [createUserMessage('go')],
54
+ } as never)
55
+
56
+ return provider
57
+ }
58
+
59
+ describe('an effort level set on the run reaches the provider', () => {
60
+ it('arrives on the request', async () => {
61
+ const provider = await run({ effort: 'max' }, [{ text: 'done' }])
62
+
63
+ expect(provider.requests.length).toBeGreaterThan(0)
64
+ expect(provider.requests[0]?.effort).toBe('max')
65
+ })
66
+
67
+ it('is absent when nobody asked for one', async () => {
68
+ // Not `undefined`-valued but genuinely absent: a present key carrying
69
+ // undefined is the kind of thing that survives a spread into a request
70
+ // body and reaches a wire that did not expect the field.
71
+ const provider = await run({}, [{ text: 'done' }])
72
+
73
+ expect(provider.requests[0] && 'effort' in provider.requests[0]).toBe(false)
74
+ })
75
+
76
+ it('rides every turn of the run, not only the first', async () => {
77
+ // The value is run-level because the provider documents that changing
78
+ // it between requests invalidates the cached prefix. A run that
79
+ // forwarded it once and then stopped would pay that cost silently.
80
+ const provider = await run({ effort: 'low' }, [{ text: 'one' }, { text: 'two' }])
81
+
82
+ for (const request of provider.requests) {
83
+ expect(request.effort).toBe('low')
84
+ }
85
+ })
86
+
87
+ it('travels alongside thinking rather than inside it', async () => {
88
+ const provider = await run({ effort: 'high', thinking: { type: 'adaptive' } }, [
89
+ { text: 'done' },
90
+ ])
91
+
92
+ expect(provider.requests[0]?.effort).toBe('high')
93
+ expect(provider.requests[0]?.thinking?.type).toBe('adaptive')
94
+ })
95
+ })
96
+
97
+ describe('the front door forwards it too, not only the kernel', () => {
98
+ /**
99
+ * These exist because everything above passed while a real run put NOTHING
100
+ * on the wire.
101
+ *
102
+ * `drainQuery` takes the run config a caller hands it, so testing through
103
+ * it proves the loop forwards the field and nothing about whether a caller
104
+ * can set it. Every ergonomic entry point — this one, `ReactiveAgent`,
105
+ * `SupervisorAgent`, and the manager's bare-config branch — builds its
106
+ * `AgentRunConfig` by HAND-LISTING fields, so a field nobody remembered to
107
+ * add is dropped in silence, with no cast to blame and no error to see.
108
+ * `thinking` had been in that state since it shipped.
109
+ *
110
+ * It was found by watching an actual HTTP body, which is the only place the
111
+ * gap is visible. So the regression test drives the front door.
112
+ */
113
+ it('reaches the provider through runAgent', async () => {
114
+ const { runAgent } = await import('../../../agents/runAgent.js')
115
+ const provider = new MockLLMProvider({ turns: [{ text: 'done' }] as never })
116
+ const dir = await mkdtemp(join(tmpdir(), 'namzu-effort-door-'))
117
+ workdirs.push(dir)
118
+
119
+ await runAgent({
120
+ provider,
121
+ model: 'mock-model',
122
+ prompt: 'go',
123
+ workingDirectory: dir,
124
+ effort: 'xhigh',
125
+ thinking: { type: 'adaptive' },
126
+ timeoutMs: 30_000,
127
+ tokenBudget: 100_000,
128
+ maxIterations: 2,
129
+ })
130
+
131
+ expect(provider.requests.length).toBeGreaterThan(0)
132
+ expect(provider.requests[0]?.effort, 'the front door dropped effort').toBe('xhigh')
133
+ expect(provider.requests[0]?.thinking?.type, 'the front door dropped thinking').toBe('adaptive')
134
+ })
135
+ })
@@ -177,6 +177,44 @@ describe('ToolExecutor — per-tool deadline', () => {
177
177
  expect(batch.results[0]?.output).toContain('was cancelled')
178
178
  })
179
179
 
180
+ it('says WHICH stop it was, when the caller named one', async () => {
181
+ // The reason was on this signal all along — it is forwarded into the
182
+ // per-tool controller a few lines above the message — and the message
183
+ // threw it away. So a deadline, a budget and an operator pressing stop
184
+ // all reached the model as the same four words, and they want
185
+ // different next moves: one is worth waiting out, one is worth
186
+ // narrowing the input for, and one is worth stopping over.
187
+ const controller = new AbortController()
188
+ const h = harness({
189
+ toolTimeoutMs: 60_000,
190
+ abortSignal: controller.signal,
191
+ run: never,
192
+ })
193
+ const pending = h.exec.executeBatch(response('hang'))
194
+ controller.abort(new Error('deployment window closed'))
195
+ const batch = await pending
196
+
197
+ expect(batch.results[0]?.output).toContain('deployment window closed')
198
+ })
199
+
200
+ it('does not invent a reason when the caller gave none', async () => {
201
+ // `abort()` with no argument fills `reason` with a DOMException named
202
+ // AbortError. Rendering it would turn an honest silence into something
203
+ // that reads like an explanation, which is the worse failure.
204
+ const controller = new AbortController()
205
+ const h = harness({
206
+ toolTimeoutMs: 60_000,
207
+ abortSignal: controller.signal,
208
+ run: never,
209
+ })
210
+ const pending = h.exec.executeBatch(response('hang'))
211
+ controller.abort()
212
+ const batch = await pending
213
+
214
+ expect(batch.results[0]?.output).toContain('was cancelled.')
215
+ expect(batch.results[0]?.output).not.toContain('AbortError')
216
+ })
217
+
180
218
  it('exposes a sane default deadline', () => {
181
219
  // Documented so a change is a deliberate decision, not a drift.
182
220
  expect(DEFAULT_TOOL_TIMEOUT_MS).toBe(120_000)
@@ -134,7 +134,22 @@ export class RunContextFactory {
134
134
  static build(config: RunContextConfig): RunContext {
135
135
  const abortController = new AbortController()
136
136
  if (config.signal) {
137
- config.signal.addEventListener('abort', () => abortController.abort(), { once: true })
137
+ // Forward the caller's REASON, not just the fact of the abort.
138
+ //
139
+ // This used to be a bare `abort()`. Every word a host attached to
140
+ // its stop — a deadline name, a budget, an operator's message —
141
+ // died one frame above the executor, so the most a tool result
142
+ // could say was "was cancelled". A run that ends for a nameable
143
+ // reason is a run someone can debug; this is the frame where the
144
+ // name was being thrown away.
145
+ //
146
+ // `createChildAbortController` already does exactly this, but it
147
+ // takes an AbortController and what arrives here is a bare
148
+ // AbortSignal, so the reason is forwarded by hand rather than by
149
+ // reaching for a helper that does not fit.
150
+ config.signal.addEventListener('abort', () => abortController.abort(config.signal?.reason), {
151
+ once: true,
152
+ })
138
153
  }
139
154
 
140
155
  const cwd = config.workingDirectory ?? process.cwd()
@@ -33,6 +33,7 @@ import type {
33
33
  ToolCallRepair,
34
34
  ToolCallRepairReason,
35
35
  } from '../../types/tool/repair.js'
36
+ import { abortReasonText } from '../../utils/abort.js'
36
37
  import { toErrorMessage } from '../../utils/error.js'
37
38
  import type { Logger } from '../../utils/logger.js'
38
39
  import { compressShellOutput } from '../../utils/shell-compress.js'
@@ -859,10 +860,19 @@ export class ToolExecutor {
859
860
  }
860
861
 
861
862
  if (outcome === 'aborted') {
863
+ // Say WHY, when the caller said why. The reason has been
864
+ // available on this signal all along — it is forwarded into
865
+ // `controller` a few lines above — and the message threw it
866
+ // away, so a deadline, a budget and an operator pressing stop
867
+ // were all reported to the model with the same four words.
868
+ // Those want different next moves.
869
+ const why = abortReasonText(controller.signal.reason)
862
870
  return {
863
871
  success: false,
864
872
  output: '',
865
- error: `Tool "${toolName}" was cancelled.`,
873
+ error: why
874
+ ? `Tool "${toolName}" was cancelled: ${why}`
875
+ : `Tool "${toolName}" was cancelled.`,
866
876
  }
867
877
  }
868
878
 
@@ -55,6 +55,38 @@ export class GuardCoordinator {
55
55
  this.startTime = Date.now() - Math.max(0, elapsedMs)
56
56
  }
57
57
 
58
+ /**
59
+ * Wall-clock left before this run is asked to start finishing.
60
+ *
61
+ * NOT the time left before the deadline, and the difference is the whole
62
+ * point. The checks above run BETWEEN iterations, so anything that waits
63
+ * inside one cannot be stopped by them, and a caller sizing such a wait
64
+ * needs a number the run actually owns: a fixed two-minute hold measured
65
+ * against a run configured for twenty seconds kept it open for 120,267 ms.
66
+ *
67
+ * Measuring to the DEADLINE was the first attempt and it was wrong. The
68
+ * binding constraint is `budgetWarningThreshold`, the point at which this
69
+ * guard stops asking for more work and asks for a closing summary — that
70
+ * last slice exists so the run can produce an answer, and a wait sized
71
+ * against the deadline eats into it. Half of the time-to-deadline, started
72
+ * just under the threshold, ends at 95% of the budget: half the closing
73
+ * reserve spent waiting for a result the closing answer was supposed to
74
+ * use.
75
+ *
76
+ * Zero once the threshold has passed, which is also how a caller gets the
77
+ * re-evaluation it needs: `forceFinalize` is sampled at the top of an
78
+ * iteration and this is read when the wait is about to start, so a long
79
+ * iteration that crossed the line in between is told to wait for nothing.
80
+ *
81
+ * Reads through the same `startTime` the limit checks use, so a run
82
+ * resumed from a checkpoint (see `restoreElapsed`) reports the time left
83
+ * on the RUN rather than on the process now hosting it.
84
+ */
85
+ remainingBeforeFinalizeMs(): number {
86
+ const finalizeAt = this.limitConfig.timeoutMs * this.limitConfig.budgetWarningThreshold
87
+ return Math.max(0, finalizeAt - (Date.now() - this.startTime))
88
+ }
89
+
58
90
  beforeIteration(runMgr: RunPersistence, abortSignal: AbortSignal): GuardCheckResult {
59
91
  const limitState = {
60
92
  aborted: abortSignal.aborted,