@namzu/sdk 1.0.0 → 1.1.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 (215) hide show
  1. package/CHANGELOG.md +167 -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 +22 -1
  6. package/dist/agents/SupervisorAgent.d.ts.map +1 -1
  7. package/dist/agents/SupervisorAgent.js +47 -14
  8. package/dist/agents/SupervisorAgent.js.map +1 -1
  9. package/dist/agents/__tests__/supervisor-ledger.test.d.ts +2 -0
  10. package/dist/agents/__tests__/supervisor-ledger.test.d.ts.map +1 -0
  11. package/dist/agents/__tests__/supervisor-ledger.test.js +131 -0
  12. package/dist/agents/__tests__/supervisor-ledger.test.js.map +1 -0
  13. package/dist/config/runtime.d.ts +24 -0
  14. package/dist/config/runtime.d.ts.map +1 -1
  15. package/dist/config/runtime.js +9 -0
  16. package/dist/config/runtime.js.map +1 -1
  17. package/dist/connector/builtins/webhook.test.js +17 -0
  18. package/dist/connector/builtins/webhook.test.js.map +1 -1
  19. package/dist/connector/index.d.ts +1 -0
  20. package/dist/connector/index.d.ts.map +1 -1
  21. package/dist/connector/index.js +1 -0
  22. package/dist/connector/index.js.map +1 -1
  23. package/dist/connector/mcp/client.d.ts.map +1 -1
  24. package/dist/connector/mcp/client.js +4 -0
  25. package/dist/connector/mcp/client.js.map +1 -1
  26. package/dist/connector/mcp/index.d.ts +1 -0
  27. package/dist/connector/mcp/index.d.ts.map +1 -1
  28. package/dist/connector/mcp/index.js +1 -0
  29. package/dist/connector/mcp/index.js.map +1 -1
  30. package/dist/connector/mcp/streamable-http.d.ts +22 -0
  31. package/dist/connector/mcp/streamable-http.d.ts.map +1 -0
  32. package/dist/connector/mcp/streamable-http.js +128 -0
  33. package/dist/connector/mcp/streamable-http.js.map +1 -0
  34. package/dist/connector/mcp/streamable-http.test.d.ts +2 -0
  35. package/dist/connector/mcp/streamable-http.test.d.ts.map +1 -0
  36. package/dist/connector/mcp/streamable-http.test.js +132 -0
  37. package/dist/connector/mcp/streamable-http.test.js.map +1 -0
  38. package/dist/persona/assembler.d.ts.map +1 -1
  39. package/dist/persona/assembler.js +18 -9
  40. package/dist/persona/assembler.js.map +1 -1
  41. package/dist/provider/collect.d.ts.map +1 -1
  42. package/dist/provider/collect.js +4 -1
  43. package/dist/provider/collect.js.map +1 -1
  44. package/dist/public-runtime.d.ts +1 -1
  45. package/dist/public-runtime.d.ts.map +1 -1
  46. package/dist/public-runtime.js +1 -1
  47. package/dist/public-runtime.js.map +1 -1
  48. package/dist/registry/tool/execute.d.ts +27 -0
  49. package/dist/registry/tool/execute.d.ts.map +1 -1
  50. package/dist/registry/tool/execute.js +124 -21
  51. package/dist/registry/tool/execute.js.map +1 -1
  52. package/dist/registry/tool/execute.test.d.ts +9 -6
  53. package/dist/registry/tool/execute.test.d.ts.map +1 -1
  54. package/dist/registry/tool/execute.test.js +72 -10
  55. package/dist/registry/tool/execute.test.js.map +1 -1
  56. package/dist/registry/toolset/catalog.test.js +62 -0
  57. package/dist/registry/toolset/catalog.test.js.map +1 -1
  58. package/dist/runtime/query/__tests__/deferred-tools.test.js +43 -5
  59. package/dist/runtime/query/__tests__/deferred-tools.test.js.map +1 -1
  60. package/dist/runtime/query/__tests__/long-document-flow.test.d.ts +2 -0
  61. package/dist/runtime/query/__tests__/long-document-flow.test.d.ts.map +1 -0
  62. package/dist/runtime/query/__tests__/long-document-flow.test.js +154 -0
  63. package/dist/runtime/query/__tests__/long-document-flow.test.js.map +1 -0
  64. package/dist/runtime/query/__tests__/prompt.test.js +15 -11
  65. package/dist/runtime/query/__tests__/prompt.test.js.map +1 -1
  66. package/dist/runtime/query/index.d.ts +9 -0
  67. package/dist/runtime/query/index.d.ts.map +1 -1
  68. package/dist/runtime/query/index.js +23 -2
  69. package/dist/runtime/query/index.js.map +1 -1
  70. package/dist/runtime/query/iteration/index.d.ts +2 -0
  71. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  72. package/dist/runtime/query/iteration/index.js +99 -6
  73. package/dist/runtime/query/iteration/index.js.map +1 -1
  74. package/dist/runtime/query/iteration/phases/checkpoint.d.ts.map +1 -1
  75. package/dist/runtime/query/iteration/phases/checkpoint.js +2 -2
  76. package/dist/runtime/query/iteration/phases/checkpoint.js.map +1 -1
  77. package/dist/runtime/query/iteration/phases/compaction.d.ts +8 -0
  78. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  79. package/dist/runtime/query/iteration/phases/compaction.js +84 -3
  80. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  81. package/dist/runtime/query/iteration/phases/context.d.ts +12 -0
  82. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  83. package/dist/runtime/query/iteration/phases/context.js +53 -0
  84. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  85. package/dist/runtime/query/iteration/phases/hitl-abort-race.test.d.ts +2 -0
  86. package/dist/runtime/query/iteration/phases/hitl-abort-race.test.d.ts.map +1 -0
  87. package/dist/runtime/query/iteration/phases/hitl-abort-race.test.js +73 -0
  88. package/dist/runtime/query/iteration/phases/hitl-abort-race.test.js.map +1 -0
  89. package/dist/runtime/query/iteration/phases/hitl-answer-question.test.d.ts +2 -0
  90. package/dist/runtime/query/iteration/phases/hitl-answer-question.test.d.ts.map +1 -0
  91. package/dist/runtime/query/iteration/phases/hitl-answer-question.test.js +120 -0
  92. package/dist/runtime/query/iteration/phases/hitl-answer-question.test.js.map +1 -0
  93. package/dist/runtime/query/iteration/phases/tool-review.d.ts +1 -1
  94. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  95. package/dist/runtime/query/iteration/phases/tool-review.js +7 -2
  96. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  97. package/dist/runtime/query/iteration/phases/working-memory-compaction.test.d.ts +19 -0
  98. package/dist/runtime/query/iteration/phases/working-memory-compaction.test.d.ts.map +1 -0
  99. package/dist/runtime/query/iteration/phases/working-memory-compaction.test.js +177 -0
  100. package/dist/runtime/query/iteration/phases/working-memory-compaction.test.js.map +1 -0
  101. package/dist/runtime/query/iteration/phases/working-memory.d.ts +31 -0
  102. package/dist/runtime/query/iteration/phases/working-memory.d.ts.map +1 -0
  103. package/dist/runtime/query/iteration/phases/working-memory.js +81 -0
  104. package/dist/runtime/query/iteration/phases/working-memory.js.map +1 -0
  105. package/dist/session/workspace/__tests__/shared-run.test.js +1 -1
  106. package/dist/session/workspace/__tests__/shared-run.test.js.map +1 -1
  107. package/dist/session/workspace/shared-run.js +1 -0
  108. package/dist/session/workspace/shared-run.js.map +1 -1
  109. package/dist/skills/loader.test.js +8 -8
  110. package/dist/skills/loader.test.js.map +1 -1
  111. package/dist/streaming/coalesce.test.js.map +1 -1
  112. package/dist/tools/builtins/__tests__/payload-budget.test.js +48 -0
  113. package/dist/tools/builtins/__tests__/payload-budget.test.js.map +1 -1
  114. package/dist/tools/builtins/__tests__/read-file.test.js +9 -0
  115. package/dist/tools/builtins/__tests__/read-file.test.js.map +1 -1
  116. package/dist/tools/builtins/read-file.d.ts.map +1 -1
  117. package/dist/tools/builtins/read-file.js +50 -2
  118. package/dist/tools/builtins/read-file.js.map +1 -1
  119. package/dist/tools/builtins/search-tools.d.ts.map +1 -1
  120. package/dist/tools/builtins/search-tools.js +32 -7
  121. package/dist/tools/builtins/search-tools.js.map +1 -1
  122. package/dist/tools/coordinator/__tests__/approve-plan.test.d.ts +2 -0
  123. package/dist/tools/coordinator/__tests__/approve-plan.test.d.ts.map +1 -0
  124. package/dist/tools/coordinator/__tests__/approve-plan.test.js +114 -0
  125. package/dist/tools/coordinator/__tests__/approve-plan.test.js.map +1 -0
  126. package/dist/tools/coordinator/__tests__/ask-user-question.test.d.ts +2 -0
  127. package/dist/tools/coordinator/__tests__/ask-user-question.test.d.ts.map +1 -0
  128. package/dist/tools/coordinator/__tests__/ask-user-question.test.js +309 -0
  129. package/dist/tools/coordinator/__tests__/ask-user-question.test.js.map +1 -0
  130. package/dist/tools/coordinator/index.d.ts +8 -0
  131. package/dist/tools/coordinator/index.d.ts.map +1 -1
  132. package/dist/tools/coordinator/index.js +195 -15
  133. package/dist/tools/coordinator/index.js.map +1 -1
  134. package/dist/types/agent/index.d.ts +1 -0
  135. package/dist/types/agent/index.d.ts.map +1 -1
  136. package/dist/types/agent/index.js +1 -0
  137. package/dist/types/agent/index.js.map +1 -1
  138. package/dist/types/agent/reactive.d.ts +15 -0
  139. package/dist/types/agent/reactive.d.ts.map +1 -1
  140. package/dist/types/agent/supervisor.d.ts +18 -0
  141. package/dist/types/agent/supervisor.d.ts.map +1 -1
  142. package/dist/types/agent/working-memory.d.ts +29 -0
  143. package/dist/types/agent/working-memory.d.ts.map +1 -0
  144. package/dist/types/agent/working-memory.js +2 -0
  145. package/dist/types/agent/working-memory.js.map +1 -0
  146. package/dist/types/common/index.d.ts +10 -0
  147. package/dist/types/common/index.d.ts.map +1 -1
  148. package/dist/types/common/index.js +18 -0
  149. package/dist/types/common/index.js.map +1 -1
  150. package/dist/types/common/usage-merge.test.d.ts +2 -0
  151. package/dist/types/common/usage-merge.test.d.ts.map +1 -0
  152. package/dist/types/common/usage-merge.test.js +48 -0
  153. package/dist/types/common/usage-merge.test.js.map +1 -0
  154. package/dist/types/connector/mcp.d.ts +9 -2
  155. package/dist/types/connector/mcp.d.ts.map +1 -1
  156. package/dist/types/hitl/index.d.ts +42 -0
  157. package/dist/types/hitl/index.d.ts.map +1 -1
  158. package/dist/types/hitl/index.js +11 -0
  159. package/dist/types/hitl/index.js.map +1 -1
  160. package/dist/types/provider/chat.d.ts +9 -0
  161. package/dist/types/provider/chat.d.ts.map +1 -1
  162. package/dist/version.d.ts.map +1 -1
  163. package/dist/version.js +25 -4
  164. package/dist/version.js.map +1 -1
  165. package/package.json +1 -1
  166. package/src/agents/ReactiveAgent.ts +5 -0
  167. package/src/agents/SupervisorAgent.ts +56 -16
  168. package/src/agents/__tests__/supervisor-ledger.test.ts +185 -0
  169. package/src/config/runtime.ts +9 -0
  170. package/src/connector/builtins/webhook.test.ts +21 -0
  171. package/src/connector/index.ts +1 -0
  172. package/src/connector/mcp/client.ts +4 -0
  173. package/src/connector/mcp/index.ts +1 -0
  174. package/src/connector/mcp/streamable-http.test.ts +169 -0
  175. package/src/connector/mcp/streamable-http.ts +152 -0
  176. package/src/persona/assembler.ts +18 -6
  177. package/src/provider/collect.ts +4 -1
  178. package/src/public-runtime.ts +1 -0
  179. package/src/registry/tool/execute.test.ts +85 -10
  180. package/src/registry/tool/execute.ts +124 -21
  181. package/src/registry/toolset/catalog.test.ts +68 -0
  182. package/src/runtime/query/__tests__/deferred-tools.test.ts +52 -5
  183. package/src/runtime/query/__tests__/long-document-flow.test.ts +180 -0
  184. package/src/runtime/query/__tests__/prompt.test.ts +15 -11
  185. package/src/runtime/query/index.ts +34 -2
  186. package/src/runtime/query/iteration/index.ts +98 -6
  187. package/src/runtime/query/iteration/phases/checkpoint.ts +7 -2
  188. package/src/runtime/query/iteration/phases/compaction.ts +89 -3
  189. package/src/runtime/query/iteration/phases/context.ts +58 -0
  190. package/src/runtime/query/iteration/phases/hitl-abort-race.test.ts +86 -0
  191. package/src/runtime/query/iteration/phases/hitl-answer-question.test.ts +139 -0
  192. package/src/runtime/query/iteration/phases/tool-review.ts +7 -3
  193. package/src/runtime/query/iteration/phases/working-memory-compaction.test.ts +215 -0
  194. package/src/runtime/query/iteration/phases/working-memory.ts +86 -0
  195. package/src/session/workspace/__tests__/shared-run.test.ts +1 -1
  196. package/src/session/workspace/shared-run.ts +1 -0
  197. package/src/skills/loader.test.ts +8 -8
  198. package/src/streaming/coalesce.test.ts +3 -3
  199. package/src/tools/builtins/__tests__/payload-budget.test.ts +63 -0
  200. package/src/tools/builtins/__tests__/read-file.test.ts +12 -0
  201. package/src/tools/builtins/read-file.ts +50 -2
  202. package/src/tools/builtins/search-tools.ts +38 -7
  203. package/src/tools/coordinator/__tests__/approve-plan.test.ts +139 -0
  204. package/src/tools/coordinator/__tests__/ask-user-question.test.ts +370 -0
  205. package/src/tools/coordinator/index.ts +227 -17
  206. package/src/types/agent/index.ts +1 -0
  207. package/src/types/agent/reactive.ts +17 -0
  208. package/src/types/agent/supervisor.ts +20 -0
  209. package/src/types/agent/working-memory.ts +29 -0
  210. package/src/types/common/index.ts +19 -0
  211. package/src/types/common/usage-merge.test.ts +53 -0
  212. package/src/types/connector/mcp.ts +14 -2
  213. package/src/types/hitl/index.ts +52 -1
  214. package/src/types/provider/chat.ts +10 -0
  215. package/src/version.ts +26 -4
@@ -9,6 +9,8 @@ import { collect } from '../../../provider/collect.js'
9
9
  import type { ActivityStore } from '../../../store/activity/memory.js'
10
10
  import { GENAI, NAMZU, agentIterationSpanName } from '../../../telemetry/attributes.js'
11
11
  import { getTracer } from '../../../telemetry/runtime-accessors.js'
12
+ import type { WorkingMemoryProvider } from '../../../types/agent/working-memory.js'
13
+ import { mergeTokenUsage } from '../../../types/common/index.js'
12
14
  import type { ResumeHandler } from '../../../types/hitl/index.js'
13
15
  import type { ToolUseId } from '../../../types/ids/index.js'
14
16
  import { createAssistantMessage, createUserMessage } from '../../../types/message/index.js'
@@ -35,6 +37,7 @@ import { runCompactionCheck } from './phases/compaction.js'
35
37
  import type { IterationContext } from './phases/index.js'
36
38
  import { runPlanGate } from './phases/plan.js'
37
39
  import { runToolReview } from './phases/tool-review.js'
40
+ import { refreshWorkingMemory } from './phases/working-memory.js'
38
41
 
39
42
  export type { IterationContext } from './phases/index.js'
40
43
  export type { PhaseSignal } from './phases/index.js'
@@ -53,6 +56,7 @@ export interface IterationConfig {
53
56
  >
54
57
  compactionConfig?: CompactionConfig
55
58
  workingStateManager?: WorkingStateManager
59
+ workingMemoryProvider?: WorkingMemoryProvider
56
60
  advisoryCtx?: AdvisoryContext
57
61
  agentBus?: import('../../../bus/index.js').AgentBus
58
62
  verificationGate?: import('../../../verification/gate.js').VerificationGate
@@ -192,8 +196,35 @@ async function* streamProviderTurn(
192
196
 
193
197
  const stream = provider.chatStream({ ...params, stream: true }) as AsyncIterable<StreamChunk>
194
198
 
199
+ // Drive the stream manually so each `.next()` can be RACED against the run
200
+ // abort: a Stop tears the in-flight model request down (the provider got
201
+ // `params.signal`), and we ALSO stop pulling within a tick even if a
202
+ // transport buffers or ignores the signal. The abort rejection propagates
203
+ // out of this generator so the run loop settles the turn as cancelled.
204
+ // `{ once: true }` keeps a multi-iteration run from leaking a listener/turn.
205
+ const it = stream[Symbol.asyncIterator]()
206
+ const signal = params.signal
207
+ let onAbort: (() => void) | undefined
208
+ const aborted: Promise<never> | undefined = signal
209
+ ? new Promise<never>((_resolve, reject) => {
210
+ if (signal.aborted) {
211
+ reject(signal.reason)
212
+ return
213
+ }
214
+ onAbort = () => reject(signal.reason)
215
+ signal.addEventListener('abort', onAbort, { once: true })
216
+ })
217
+ : undefined
218
+
195
219
  try {
196
- for await (const chunk of stream) {
220
+ for (;;) {
221
+ const next = it.next()
222
+ // Neutralize the dangling loser so an eventual rejection of the
223
+ // un-awaited `next` is never an unhandled rejection.
224
+ if (aborted) next.catch(() => {})
225
+ const res = await (aborted ? Promise.race([next, aborted]) : next)
226
+ if (res.done) break
227
+ const chunk = res.value
197
228
  if (chunk.error) {
198
229
  streamError = chunk.error
199
230
  break
@@ -292,10 +323,24 @@ async function* streamProviderTurn(
292
323
  }
293
324
 
294
325
  if (chunk.finishReason) finishReason = chunk.finishReason
295
- if (chunk.usage) usage = chunk.usage
326
+ // Merge (per-field max), not last-write-wins: a late usage frame that
327
+ // omits input/cache tokens must not zero the counts seen earlier in the
328
+ // stream, which would under-report this turn's accumulated usage.
329
+ if (chunk.usage) usage = mergeTokenUsage(usage, chunk.usage)
296
330
  }
297
331
  } catch (err) {
332
+ // An abort tears the turn down: propagate it so the run loop settles the
333
+ // run as cancelled rather than recording a normal (errored) turn. Any
334
+ // other stream error is captured into the synthesized response as before.
335
+ if (signal?.aborted) throw err
298
336
  streamError = err instanceof Error ? err.message : String(err)
337
+ } finally {
338
+ if (onAbort) signal?.removeEventListener('abort', onAbort)
339
+ // Release the underlying connection on every exit (natural end, error,
340
+ // or abort). `for await` did this implicitly on natural completion; the
341
+ // manual drive must do it explicitly. `.return()` on an already-finished
342
+ // provider generator is a no-op.
343
+ await it.return?.().catch(() => {})
299
344
  }
300
345
 
301
346
  // Flush any tool buckets the provider failed to close (no toolCallEnd
@@ -470,6 +515,7 @@ export class IterationOrchestrator {
470
515
  launchedTasks: config.launchedTasks ?? new Map(),
471
516
  compactionConfig: config.compactionConfig,
472
517
  workingStateManager: config.workingStateManager,
518
+ workingMemoryProvider: config.workingMemoryProvider,
473
519
  advisoryCtx: config.advisoryCtx,
474
520
  agentBus: config.agentBus,
475
521
  verificationGate: config.verificationGate,
@@ -572,11 +618,19 @@ export class IterationOrchestrator {
572
618
  await this.injectOneTaskNotification()
573
619
  }
574
620
 
621
+ // Re-pin the working-memory block from ground truth at the primacy
622
+ // edge BEFORE compaction runs (so the refreshed slot is what
623
+ // compaction preserves). No-op when no provider is configured.
624
+ await refreshWorkingMemory(this.ctx)
575
625
  await runCompactionCheck(this.ctx)
576
626
 
577
- const openAITools = forceFinalize
578
- ? undefined
579
- : this.ctx.tools.toLLMTools(this.ctx.allowedTools)
627
+ // Cache discipline: keep the tools param byte-stable even on the
628
+ // forced-final iteration and forbid tool use via tool_choice
629
+ // 'none' instead. Dropping the tools array would invalidate the
630
+ // entire prompt-cache prefix (tools render at position 0) and
631
+ // risks a 400 because the history still carries
632
+ // tool_use/tool_result blocks.
633
+ const openAITools = this.ctx.tools.toLLMTools(this.ctx.allowedTools)
580
634
 
581
635
  const messages = forceFinalize
582
636
  ? [
@@ -610,10 +664,15 @@ export class IterationOrchestrator {
610
664
  {
611
665
  model,
612
666
  messages,
613
- tools: openAITools && openAITools.length > 0 ? openAITools : undefined,
667
+ tools: openAITools.length > 0 ? openAITools : undefined,
668
+ toolChoice: forceFinalize && openAITools.length > 0 ? 'none' : undefined,
614
669
  temperature: runConfig.temperature,
615
670
  maxTokens: runConfig.maxResponseTokens,
616
671
  cacheControl: { type: 'auto' },
672
+ // Thread the run abort into the model call so a Stop tears the
673
+ // in-flight turn down (provider passes it to fetch; the consumer
674
+ // also races it). Inert when never aborted.
675
+ signal: this.ctx.abortController.signal,
617
676
  },
618
677
  this.ctx.emitEvent,
619
678
  this.ctx.drainPending,
@@ -771,6 +830,16 @@ export class IterationOrchestrator {
771
830
  hasToolCalls: false,
772
831
  })
773
832
  yield* this.ctx.drainPending()
833
+ // A Stop that lands AFTER the final turn streamed but before
834
+ // this break must settle the run as cancelled, not end_turn —
835
+ // otherwise the just-produced answer is recorded as a clean
836
+ // completion. Mirrors the between-iteration cancel at :511.
837
+ if (this.ctx.abortController.signal.aborted) {
838
+ runMgr.setStopReason('cancelled')
839
+ runMgr.markCancelled()
840
+ iterSpan.end()
841
+ break
842
+ }
774
843
  runMgr.setStopReason('end_turn')
775
844
  iterSpan.end()
776
845
  break
@@ -815,6 +884,20 @@ export class IterationOrchestrator {
815
884
  yield* this.ctx.drainPending()
816
885
  iterSpan.end()
817
886
  } catch (err) {
887
+ // A Stop that aborted the in-flight turn surfaces here as a
888
+ // thrown abort (the provider stream was raced against the run
889
+ // signal). Settle it as a CANCELLATION — mirroring the
890
+ // between-iteration cancel at the top of the loop — rather than
891
+ // recording it as an SDK failure (error span + failed activity)
892
+ // and re-throwing. The run then returns cleanly with a
893
+ // 'cancelled' stop reason instead of propagating an error.
894
+ if (this.ctx.abortController.signal.aborted) {
895
+ runMgr.setStopReason('cancelled')
896
+ runMgr.markCancelled()
897
+ iterSpan.end()
898
+ break
899
+ }
900
+
818
901
  if (iterationActivity) {
819
902
  this.ctx.activityStore.fail(iterationActivity.id, toErrorMessage(err))
820
903
  }
@@ -948,13 +1031,22 @@ export class IterationOrchestrator {
948
1031
  ),
949
1032
  ]
950
1033
 
1034
+ // Same cache discipline as the forced-final iteration: keep the
1035
+ // tools param identical to prior iterations (cache prefix intact,
1036
+ // no 400 on tool blocks in history) and forbid use via tool_choice.
1037
+ const finalTools = this.ctx.tools.toLLMTools(this.ctx.allowedTools)
951
1038
  const response = await collect(
952
1039
  this.ctx.provider.chatStream({
953
1040
  model,
954
1041
  messages: finalMessages,
1042
+ tools: finalTools.length > 0 ? finalTools : undefined,
1043
+ toolChoice: finalTools.length > 0 ? 'none' : undefined,
955
1044
  temperature: this.ctx.runConfig.temperature,
956
1045
  maxTokens: this.ctx.runConfig.maxResponseTokens,
957
1046
  cacheControl: { type: 'auto' },
1047
+ // Cancellable too: a Stop during the closing summary must not
1048
+ // stream to completion.
1049
+ signal: this.ctx.abortController.signal,
958
1050
  }),
959
1051
  )
960
1052
 
@@ -1,6 +1,11 @@
1
1
  import type { RunEvent } from '../../../../types/run/index.js'
2
2
  import { CheckpointManager } from '../../checkpoint.js'
3
- import { type IterationContext, type PhaseSignal, handleHITLDecision } from './context.js'
3
+ import {
4
+ type IterationContext,
5
+ type PhaseSignal,
6
+ awaitDecisionOrAbort,
7
+ handleHITLDecision,
8
+ } from './context.js'
4
9
 
5
10
  export async function* runIterationCheckpoint(
6
11
  ctx: IterationContext,
@@ -17,7 +22,7 @@ export async function* runIterationCheckpoint(
17
22
  yield* ctx.drainPending()
18
23
 
19
24
  const summary = CheckpointManager.buildSummary(ctx.runMgr, iterationNum)
20
- const iterDecision = await ctx.resumeHandler({
25
+ const iterDecision = await awaitDecisionOrAbort(ctx, {
21
26
  type: 'iteration_checkpoint',
22
27
  runId: ctx.runMgr.id,
23
28
  checkpointId: iterCheckpoint.id,
@@ -1,12 +1,37 @@
1
+ import { findSafeTrimIndex } from '../../../../compaction/dangling.js'
1
2
  import { serializeState } from '../../../../compaction/serializer.js'
2
3
  import { buildVerifiedSummary } from '../../../../compaction/verifier.js'
3
4
  import { CHARS_PER_TOKEN } from '../../../../constants/limits.js'
4
5
  import { createSystemMessage } from '../../../../types/message/index.js'
5
6
  import type { IterationContext } from './context.js'
7
+ import { isWorkingMemoryMessage } from './working-memory.js'
6
8
 
7
9
  const COMPACTION_HEADER =
8
10
  '[COMPACTED CONTEXT] The following is a structured summary of the conversation so far.'
9
11
 
12
+ /**
13
+ * Identity check for a prior compaction summary in the leading floor. Used to
14
+ * REPLACE one in place on the per-iteration (contextWindowTokens) path so at
15
+ * most one `[COMPACTED CONTEXT]` block ever lives in the never-trimmed floor,
16
+ * and by the checkpoint-restore path to PRESERVE the summary across a resume
17
+ * (it is the only surviving record of the older history the run compacted away).
18
+ */
19
+ export function isCompactionMessage(content: string | null | undefined): boolean {
20
+ return typeof content === 'string' && content.startsWith(COMPACTION_HEADER)
21
+ }
22
+
23
+ /**
24
+ * Minimum number of compactable (older) messages required before a compaction
25
+ * pass is worth running. When there is NOTHING between the never-trimmed
26
+ * leading floor and the recent window, a pass would only replace nothing with a
27
+ * `[COMPACTED CONTEXT]` summary that joins the floor — pure overhead that fires
28
+ * again next iteration (the permanent-floor thrash, ses_055 D7). Set to the
29
+ * literal EMPTY guard so any existing compaction consumer with ≥1 older message
30
+ * stays byte-identical; the per-iteration Vandal path additionally defangs the
31
+ * cost with `llmVerification:false`.
32
+ */
33
+ const MIN_OLDER_MESSAGES_TO_COMPACT = 1
34
+
10
35
  function estimateTokens(ctx: IterationContext): number {
11
36
  let chars = 0
12
37
  for (const msg of ctx.runMgr.messages) {
@@ -31,7 +56,13 @@ export async function runCompactionCheck(ctx: IterationContext): Promise<void> {
31
56
  if (!manager) return
32
57
 
33
58
  const estimatedTokens = estimateTokens(ctx)
34
- const budget = ctx.runConfig.tokenBudget
59
+ // F1: measure the CURRENT window against the model context window when the
60
+ // host supplies one, falling back to the run-level cumulative `tokenBudget`
61
+ // otherwise. Repointing the SINGLE `budget` assignment moves BOTH the
62
+ // `<= 0` guard AND the divisor at once — so "compaction on + tokenBudget=0"
63
+ // is no longer a silent no-op. Existing consumers (no contextWindowTokens)
64
+ // stay byte-identical.
65
+ const budget = config.contextWindowTokens ?? ctx.runConfig.tokenBudget
35
66
  if (budget <= 0) return
36
67
 
37
68
  const usage = estimatedTokens / budget
@@ -63,10 +94,34 @@ export async function runCompactionCheck(ctx: IterationContext): Promise<void> {
63
94
  }
64
95
  if (systemMessages.length === 0) return
65
96
 
66
- const keepStart = messages.length - config.keepRecentMessages
97
+ // Tool-pair atomicity guard. A naive cut at `length - keepRecentMessages`
98
+ // can land BETWEEN an assistant-with-toolCalls (which would be dropped into
99
+ // `olderMessages`) and its `tool` results (kept in `recentMessages`),
100
+ // leaving orphaned `tool_result` blocks at the head of the recent window.
101
+ // The Anthropic provider then emits a `tool_result` with no matching
102
+ // `tool_use` and the API rejects the next turn with a 400 — so compaction,
103
+ // whose whole job is to keep a long run alive, instead kills it. Snap the
104
+ // boundary FORWARD to a safe point (existing `findSafeTrimIndex`, previously
105
+ // only wired to the unused ConversationManager strategy classes) so no pair
106
+ // is split. Any message this moves out of the recent window is already
107
+ // represented in the extracted WorkingState the summary is built from.
108
+ const keepStart = findSafeTrimIndex(messages, messages.length - config.keepRecentMessages)
67
109
  const recentMessages = messages.slice(keepStart)
68
110
  const olderMessages = messages.slice(systemMessages.length, keepStart)
69
111
 
112
+ // D7: nothing meaningful to compact — skip instead of thrashing the
113
+ // permanent leading floor every iteration (and avoid an LLM verification
114
+ // call when there is no older history to summarize). Scoped to the new
115
+ // contextWindowTokens path so any existing consumer (tokenBudget-only) keeps
116
+ // its exact prior behavior — byte-identical (ses_055 verify).
117
+ if (config.contextWindowTokens != null && olderMessages.length < MIN_OLDER_MESSAGES_TO_COMPACT) {
118
+ ctx.log.debug('Skipping compaction — too few older messages', {
119
+ runId: ctx.runMgr.id,
120
+ olderMessages: olderMessages.length,
121
+ })
122
+ return
123
+ }
124
+
70
125
  let compactedContent: string
71
126
 
72
127
  if (config.llmVerification && manager.slotCount() < config.richStateThreshold) {
@@ -77,7 +132,38 @@ export async function runCompactionCheck(ctx: IterationContext): Promise<void> {
77
132
 
78
133
  const compactionMessage = createSystemMessage(`${COMPACTION_HEADER}\n\n${compactedContent}`)
79
134
 
80
- const newMessages = [...systemMessages, compactionMessage, ...recentMessages]
135
+ // On the new per-iteration (contextWindowTokens) path, drop any PRIOR
136
+ // `[COMPACTED CONTEXT]` summary from the leading floor — `serializeState` is
137
+ // cumulative, so the new summary supersedes it. Without this the never-trimmed
138
+ // floor accumulates one redundant summary per pass and the frequent
139
+ // per-iteration trigger makes that bloat unbounded (ses_055 verify, MEDIUM).
140
+ // Legacy consumers (no contextWindowTokens) keep their exact prior
141
+ // accumulation behavior — byte-identical.
142
+ const preservedSystem =
143
+ config.contextWindowTokens != null
144
+ ? systemMessages.filter((m) => !isCompactionMessage(m.content))
145
+ : systemMessages
146
+ const newMessages = [...preservedSystem, compactionMessage, ...recentMessages]
147
+
148
+ // OPAQUE survival guard (ses_055 D1): the pinned working-memory slot is a
149
+ // leading system message, so it is kept in `preservedSystem` (the compaction
150
+ // filter only drops prior `[COMPACTED CONTEXT]` summaries, never the WM slot)
151
+ // and survives for free — this branch is DEFENSIVE-ONLY, exercised only if a
152
+ // future change drops the slot from the rebuilt set. It re-pins the block
153
+ // already present in `messages` (the one `refreshWorkingMemory` placed).
154
+ // Identity is the sentinel HEADER only — no path parsing, no second provider
155
+ // call, no host format knowledge in the SDK.
156
+ const survives = newMessages.some((m) => m.role === 'system' && isWorkingMemoryMessage(m.content))
157
+ if (!survives) {
158
+ const priorSlot = messages.find((m) => m.role === 'system' && isWorkingMemoryMessage(m.content))
159
+ if (priorSlot) {
160
+ // Re-pin as the last leading system message, before the summary.
161
+ newMessages.splice(preservedSystem.length, 0, priorSlot)
162
+ ctx.log.warn('Re-pinned working-memory slot dropped by compaction', {
163
+ runId: ctx.runMgr.id,
164
+ })
165
+ }
166
+ }
81
167
 
82
168
  const oldCount = messages.length
83
169
  messages.length = 0
@@ -6,6 +6,7 @@ import type { PlanManager } from '../../../../manager/plan/lifecycle.js'
6
6
  import type { RunPersistence } from '../../../../manager/run/persistence.js'
7
7
  import type { ActivityStore } from '../../../../store/activity/memory.js'
8
8
  import type { TaskGateway, TaskHandle } from '../../../../types/agent/gateway.js'
9
+ import type { WorkingMemoryProvider } from '../../../../types/agent/working-memory.js'
9
10
  import type { HITLResumeDecision, ResumeHandler } from '../../../../types/hitl/index.js'
10
11
  import type { TaskId } from '../../../../types/ids/index.js'
11
12
  import type { LLMProvider } from '../../../../types/provider/index.js'
@@ -63,6 +64,8 @@ export interface IterationContext {
63
64
 
64
65
  readonly workingStateManager?: WorkingStateManager
65
66
 
67
+ readonly workingMemoryProvider?: WorkingMemoryProvider
68
+
66
69
  readonly advisoryCtx?: AdvisoryContext
67
70
 
68
71
  readonly agentBus?: AgentBus
@@ -74,6 +77,57 @@ export interface IterationContext {
74
77
 
75
78
  export type PhaseSignal = 'continue' | 'stop'
76
79
 
80
+ /**
81
+ * Await a HITL `resumeHandler` decision, but RACE it against the run's abort
82
+ * signal. A Stop that arrives while the run is parked on a tool-review or
83
+ * iteration checkpoint used to do nothing until the host eventually answered
84
+ * (the park await was not cancellable). Racing the signal lets a Stop resolve
85
+ * the park immediately as an `abort` decision, which `handleHITLDecision`
86
+ * turns into `setStopReason('cancelled') + markCancelled + stop`. Fails closed:
87
+ * a resume-handler rejection also resolves to `abort` rather than hanging.
88
+ */
89
+ export async function awaitDecisionOrAbort(
90
+ ctx: IterationContext,
91
+ request: Parameters<ResumeHandler>[0],
92
+ ): Promise<HITLResumeDecision> {
93
+ const signal = ctx.abortController?.signal
94
+ // No abort signal wired (e.g. a minimal test harness) → behave exactly as a
95
+ // direct resumeHandler await, no race. In production RunContextFactory always
96
+ // provides the controller, so the race below is live.
97
+ if (!signal) return ctx.resumeHandler(request)
98
+ const abortDecision: HITLResumeDecision = {
99
+ action: 'abort',
100
+ reason: 'run aborted while parked for HITL',
101
+ }
102
+ if (signal.aborted) return abortDecision
103
+ return new Promise<HITLResumeDecision>((resolve) => {
104
+ let settled = false
105
+ const onAbort = (): void => {
106
+ if (settled) return
107
+ settled = true
108
+ resolve(abortDecision)
109
+ }
110
+ signal.addEventListener('abort', onAbort, { once: true })
111
+ Promise.resolve(ctx.resumeHandler(request)).then(
112
+ (decision) => {
113
+ if (settled) return
114
+ settled = true
115
+ signal.removeEventListener('abort', onAbort)
116
+ resolve(decision)
117
+ },
118
+ (err) => {
119
+ if (settled) return
120
+ settled = true
121
+ signal.removeEventListener('abort', onAbort)
122
+ resolve({
123
+ action: 'abort',
124
+ reason: err instanceof Error ? err.message : 'resume handler failed',
125
+ })
126
+ },
127
+ )
128
+ })
129
+ }
130
+
77
131
  export async function* handleHITLDecision(
78
132
  ctx: IterationContext,
79
133
  decision: HITLResumeDecision,
@@ -125,6 +179,10 @@ export async function* handleHITLDecision(
125
179
  case 'approve_tools':
126
180
  case 'modify_tools':
127
181
  case 'reject_tools':
182
+ // 'answer_question' can only arrive misdirected at an iteration
183
+ // checkpoint (answers are consumed inside the ask_user_question
184
+ // tool's own park); treat it as a plain continue.
185
+ case 'answer_question':
128
186
  return 'continue'
129
187
  default: {
130
188
  const _exhaustive: never = decision
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { HITLResumeDecision } from '../../../../types/hitl/index.js'
4
+ import type { RunId } from '../../../../types/ids/index.js'
5
+ import { type IterationContext, awaitDecisionOrAbort } from './context.js'
6
+
7
+ /**
8
+ * C12: a Stop that arrives while the run is parked on a HITL decision must
9
+ * interrupt the park (resolve as `abort`) instead of hanging until the host
10
+ * eventually answers. `awaitDecisionOrAbort` races `resumeHandler` against
11
+ * `ctx.abortController.signal`.
12
+ */
13
+
14
+ function ctxWith(opts: {
15
+ controller?: AbortController
16
+ resumeHandler: IterationContext['resumeHandler']
17
+ }): IterationContext {
18
+ return {
19
+ abortController: opts.controller,
20
+ resumeHandler: opts.resumeHandler,
21
+ runMgr: { id: 'run_abort_test' as RunId },
22
+ } as unknown as IterationContext
23
+ }
24
+
25
+ const REVIEW_REQUEST = {
26
+ type: 'tool_review' as const,
27
+ runId: 'run_abort_test' as RunId,
28
+ checkpointId: 'cp_1' as `cp_${string}`,
29
+ toolCalls: [],
30
+ }
31
+
32
+ describe('awaitDecisionOrAbort (C12 — cancellable HITL park)', () => {
33
+ it('returns abort immediately when the signal is already aborted', async () => {
34
+ const controller = new AbortController()
35
+ controller.abort()
36
+ const ctx = ctxWith({
37
+ controller,
38
+ // A handler that never resolves — proving we did not wait on it.
39
+ resumeHandler: () => new Promise<HITLResumeDecision>(() => {}),
40
+ })
41
+ const decision = await awaitDecisionOrAbort(ctx, REVIEW_REQUEST)
42
+ expect(decision.action).toBe('abort')
43
+ })
44
+
45
+ it('resolves as abort when the signal fires DURING the park', async () => {
46
+ const controller = new AbortController()
47
+ const ctx = ctxWith({
48
+ controller,
49
+ resumeHandler: () => new Promise<HITLResumeDecision>(() => {}), // never answers
50
+ })
51
+ const pending = awaitDecisionOrAbort(ctx, REVIEW_REQUEST)
52
+ controller.abort()
53
+ const decision = await pending
54
+ expect(decision.action).toBe('abort')
55
+ })
56
+
57
+ it('returns the real decision when the handler answers before any abort', async () => {
58
+ const controller = new AbortController()
59
+ const ctx = ctxWith({
60
+ controller,
61
+ resumeHandler: async () => ({ action: 'approve_tools' }) as HITLResumeDecision,
62
+ })
63
+ const decision = await awaitDecisionOrAbort(ctx, REVIEW_REQUEST)
64
+ expect(decision.action).toBe('approve_tools')
65
+ })
66
+
67
+ it('degrades to a direct resumeHandler await when no controller is wired', async () => {
68
+ const ctx = ctxWith({
69
+ resumeHandler: async () => ({ action: 'reject_tools' }) as HITLResumeDecision,
70
+ })
71
+ const decision = await awaitDecisionOrAbort(ctx, REVIEW_REQUEST)
72
+ expect(decision.action).toBe('reject_tools')
73
+ })
74
+
75
+ it('fails closed to abort when the resume handler rejects', async () => {
76
+ const controller = new AbortController()
77
+ const ctx = ctxWith({
78
+ controller,
79
+ resumeHandler: async () => {
80
+ throw new Error('handler boom')
81
+ },
82
+ })
83
+ const decision = await awaitDecisionOrAbort(ctx, REVIEW_REQUEST)
84
+ expect(decision.action).toBe('abort')
85
+ })
86
+ })
@@ -0,0 +1,139 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { type HITLResumeDecision, autoApproveHandler } from '../../../../types/hitl/index.js'
4
+ import type { CheckpointId, RunId } from '../../../../types/ids/index.js'
5
+ import type { ChatCompletionResponse } from '../../../../types/provider/index.js'
6
+ import type { RunEvent } from '../../../../types/run/index.js'
7
+ import { type IterationContext, handleHITLDecision } from './context.js'
8
+ import { runToolReview } from './tool-review.js'
9
+
10
+ async function drainGenerator<TReturn>(
11
+ gen: AsyncGenerator<RunEvent, TReturn>,
12
+ ): Promise<{ events: RunEvent[]; value: TReturn }> {
13
+ const events: RunEvent[] = []
14
+ let next = await gen.next()
15
+ while (!next.done) {
16
+ events.push(next.value)
17
+ next = await gen.next()
18
+ }
19
+ return { events, value: next.value }
20
+ }
21
+
22
+ describe('autoApproveHandler user_question case', () => {
23
+ it('returns the non-fabricating no-answer sentinel with the questionId echoed', async () => {
24
+ const decision = await autoApproveHandler({
25
+ type: 'user_question',
26
+ runId: 'run_headless_test' as RunId,
27
+ checkpointId: 'cp_question_toolu_1' as CheckpointId,
28
+ question: {
29
+ questionId: 'toolu_1',
30
+ question: 'Pick one?',
31
+ options: [
32
+ { id: 'opt_1', label: 'A' },
33
+ { id: 'opt_2', label: 'B' },
34
+ ],
35
+ multiSelect: false,
36
+ allowFreeText: true,
37
+ },
38
+ })
39
+
40
+ expect(decision).toEqual({
41
+ action: 'answer_question',
42
+ selectedOptionIds: [],
43
+ freeText: 'No user is available to answer. Proceed using your best judgment.',
44
+ questionId: 'toolu_1',
45
+ })
46
+ })
47
+ })
48
+
49
+ describe('handleHITLDecision answer_question case', () => {
50
+ it("treats a misdirected 'answer_question' at an iteration checkpoint as continue", async () => {
51
+ // The branch returns without touching the context — answers are
52
+ // consumed inside the ask_user_question tool's own park, so an
53
+ // answer arriving here can only be misdirected.
54
+ const ctx = {} as IterationContext
55
+ const { events, value } = await drainGenerator(
56
+ handleHITLDecision(
57
+ ctx,
58
+ { action: 'answer_question', selectedOptionIds: ['opt_1'] },
59
+ 'cp_checkpoint_1',
60
+ 'iteration checkpoint',
61
+ ),
62
+ )
63
+ expect(value).toBe('continue')
64
+ expect(events).toEqual([])
65
+ })
66
+ })
67
+
68
+ describe('runToolReview answer_question case', () => {
69
+ function reviewContext(decision: HITLResumeDecision) {
70
+ const warns: string[] = []
71
+ const pushed: unknown[] = []
72
+ let batches = 0
73
+ const ctx = {
74
+ tools: { get: () => undefined },
75
+ checkpointMgr: {
76
+ create: async () => ({ id: 'cp_review_1' as CheckpointId }),
77
+ },
78
+ emitEvent: async () => {},
79
+ drainPending: (): Generator<RunEvent> => [][Symbol.iterator]() as Generator<RunEvent>,
80
+ resumeHandler: async () => decision,
81
+ log: {
82
+ debug: () => {},
83
+ info: () => {},
84
+ warn: (message: string) => {
85
+ warns.push(message)
86
+ },
87
+ error: () => {},
88
+ },
89
+ runMgr: {
90
+ id: 'run_tool_review_test' as RunId,
91
+ pushMessage: (message: unknown) => {
92
+ pushed.push(message)
93
+ },
94
+ },
95
+ toolExecutor: {
96
+ executeBatch: async () => {
97
+ batches += 1
98
+ return { messages: [], results: [] }
99
+ },
100
+ },
101
+ } as unknown as IterationContext
102
+ return { ctx, warns, pushed, batchCount: () => batches }
103
+ }
104
+
105
+ const response: ChatCompletionResponse = {
106
+ id: 'resp_1',
107
+ model: 'test-model',
108
+ message: {
109
+ role: 'assistant',
110
+ content: null,
111
+ toolCalls: [
112
+ {
113
+ id: 'toolu_review_1',
114
+ type: 'function',
115
+ function: { name: 'ask_user_question', arguments: '{}' },
116
+ },
117
+ ],
118
+ },
119
+ finishReason: 'tool_calls',
120
+ usage: {
121
+ promptTokens: 0,
122
+ completionTokens: 0,
123
+ totalTokens: 0,
124
+ cachedTokens: 0,
125
+ cacheWriteTokens: 0,
126
+ },
127
+ }
128
+
129
+ it("warns and executes the batch on a misdirected 'answer_question' instead of stalling", async () => {
130
+ const { ctx, warns, batchCount } = reviewContext({
131
+ action: 'answer_question',
132
+ selectedOptionIds: [],
133
+ })
134
+ const { value } = await drainGenerator(runToolReview(ctx, response, 1))
135
+ expect(value).toBe('executed')
136
+ expect(batchCount()).toBe(1)
137
+ expect(warns).toHaveLength(1)
138
+ })
139
+ })