@namzu/sdk 3.1.0 → 3.2.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 (257) hide show
  1. package/CHANGELOG.md +177 -0
  2. package/dist/advisory/__tests__/consultation-context.test.d.ts +2 -0
  3. package/dist/advisory/__tests__/consultation-context.test.d.ts.map +1 -0
  4. package/dist/advisory/__tests__/consultation-context.test.js +124 -0
  5. package/dist/advisory/__tests__/consultation-context.test.js.map +1 -0
  6. package/dist/advisory/context.d.ts +25 -0
  7. package/dist/advisory/context.d.ts.map +1 -1
  8. package/dist/advisory/context.js +18 -0
  9. package/dist/advisory/context.js.map +1 -1
  10. package/dist/advisory/executor.d.ts.map +1 -1
  11. package/dist/advisory/executor.js +25 -3
  12. package/dist/advisory/executor.js.map +1 -1
  13. package/dist/compaction/__tests__/context-reducer.test.d.ts +2 -0
  14. package/dist/compaction/__tests__/context-reducer.test.d.ts.map +1 -0
  15. package/dist/compaction/__tests__/context-reducer.test.js +197 -0
  16. package/dist/compaction/__tests__/context-reducer.test.js.map +1 -0
  17. package/dist/compaction/factory.d.ts +7 -0
  18. package/dist/compaction/factory.d.ts.map +1 -1
  19. package/dist/compaction/factory.js +7 -0
  20. package/dist/compaction/factory.js.map +1 -1
  21. package/dist/compaction/index.d.ts +2 -0
  22. package/dist/compaction/index.d.ts.map +1 -1
  23. package/dist/compaction/index.js +1 -0
  24. package/dist/compaction/index.js.map +1 -1
  25. package/dist/compaction/interface.d.ts +13 -0
  26. package/dist/compaction/interface.d.ts.map +1 -1
  27. package/dist/compaction/managers/null.d.ts +3 -0
  28. package/dist/compaction/managers/null.d.ts.map +1 -1
  29. package/dist/compaction/managers/null.js +3 -0
  30. package/dist/compaction/managers/null.js.map +1 -1
  31. package/dist/compaction/managers/slidingWindow.d.ts +6 -0
  32. package/dist/compaction/managers/slidingWindow.d.ts.map +1 -1
  33. package/dist/compaction/managers/slidingWindow.js +6 -0
  34. package/dist/compaction/managers/slidingWindow.js.map +1 -1
  35. package/dist/compaction/managers/structured.d.ts +10 -0
  36. package/dist/compaction/managers/structured.d.ts.map +1 -1
  37. package/dist/compaction/managers/structured.js +10 -0
  38. package/dist/compaction/managers/structured.js.map +1 -1
  39. package/dist/compaction/reducer.d.ts +86 -0
  40. package/dist/compaction/reducer.d.ts.map +1 -0
  41. package/dist/compaction/reducer.js +77 -0
  42. package/dist/compaction/reducer.js.map +1 -0
  43. package/dist/connector/builtins/__tests__/oauth2-auth.test.d.ts +2 -0
  44. package/dist/connector/builtins/__tests__/oauth2-auth.test.d.ts.map +1 -0
  45. package/dist/connector/builtins/__tests__/oauth2-auth.test.js +54 -0
  46. package/dist/connector/builtins/__tests__/oauth2-auth.test.js.map +1 -0
  47. package/dist/connector/builtins/http.d.ts.map +1 -1
  48. package/dist/connector/builtins/http.js +24 -2
  49. package/dist/connector/builtins/http.js.map +1 -1
  50. package/dist/connector/builtins/http.test.js +18 -2
  51. package/dist/connector/builtins/http.test.js.map +1 -1
  52. package/dist/connector/index.d.ts +2 -2
  53. package/dist/connector/index.d.ts.map +1 -1
  54. package/dist/connector/index.js +1 -1
  55. package/dist/connector/index.js.map +1 -1
  56. package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.d.ts +2 -0
  57. package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.d.ts.map +1 -0
  58. package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.js +214 -0
  59. package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.js.map +1 -0
  60. package/dist/connector/mcp/client.d.ts +52 -1
  61. package/dist/connector/mcp/client.d.ts.map +1 -1
  62. package/dist/connector/mcp/client.js +86 -0
  63. package/dist/connector/mcp/client.js.map +1 -1
  64. package/dist/connector/mcp/discovery.d.ts +12 -1
  65. package/dist/connector/mcp/discovery.d.ts.map +1 -1
  66. package/dist/connector/mcp/discovery.js +19 -4
  67. package/dist/connector/mcp/discovery.js.map +1 -1
  68. package/dist/connector/mcp/index.d.ts +2 -2
  69. package/dist/connector/mcp/index.d.ts.map +1 -1
  70. package/dist/connector/mcp/index.js +1 -1
  71. package/dist/connector/mcp/index.js.map +1 -1
  72. package/dist/connector/mcp/server.d.ts +42 -1
  73. package/dist/connector/mcp/server.d.ts.map +1 -1
  74. package/dist/connector/mcp/server.js +77 -4
  75. package/dist/connector/mcp/server.js.map +1 -1
  76. package/dist/manager/agent/__tests__/depth-limit-authority.test.d.ts +2 -0
  77. package/dist/manager/agent/__tests__/depth-limit-authority.test.d.ts.map +1 -0
  78. package/dist/manager/agent/__tests__/depth-limit-authority.test.js +58 -0
  79. package/dist/manager/agent/__tests__/depth-limit-authority.test.js.map +1 -0
  80. package/dist/plugin/__tests__/discovery-scopes.test.d.ts +2 -0
  81. package/dist/plugin/__tests__/discovery-scopes.test.d.ts.map +1 -0
  82. package/dist/plugin/__tests__/discovery-scopes.test.js +97 -0
  83. package/dist/plugin/__tests__/discovery-scopes.test.js.map +1 -0
  84. package/dist/plugin/__tests__/enable-contributions.test.js +5 -1
  85. package/dist/plugin/__tests__/enable-contributions.test.js.map +1 -1
  86. package/dist/plugin/__tests__/mcp-admission.test.d.ts +2 -0
  87. package/dist/plugin/__tests__/mcp-admission.test.d.ts.map +1 -0
  88. package/dist/plugin/__tests__/mcp-admission.test.js +192 -0
  89. package/dist/plugin/__tests__/mcp-admission.test.js.map +1 -0
  90. package/dist/plugin/lifecycle.d.ts +41 -0
  91. package/dist/plugin/lifecycle.d.ts.map +1 -1
  92. package/dist/plugin/lifecycle.js +29 -1
  93. package/dist/plugin/lifecycle.js.map +1 -1
  94. package/dist/plugin/loader.d.ts +39 -3
  95. package/dist/plugin/loader.d.ts.map +1 -1
  96. package/dist/plugin/loader.js +37 -4
  97. package/dist/plugin/loader.js.map +1 -1
  98. package/dist/public-runtime.d.ts +4 -2
  99. package/dist/public-runtime.d.ts.map +1 -1
  100. package/dist/public-runtime.js +5 -2
  101. package/dist/public-runtime.js.map +1 -1
  102. package/dist/public-types.d.ts +2 -2
  103. package/dist/public-types.d.ts.map +1 -1
  104. package/dist/rag/__tests__/namespace-isolation.test.d.ts +2 -0
  105. package/dist/rag/__tests__/namespace-isolation.test.d.ts.map +1 -0
  106. package/dist/rag/__tests__/namespace-isolation.test.js +80 -0
  107. package/dist/rag/__tests__/namespace-isolation.test.js.map +1 -0
  108. package/dist/rag/ingestion.d.ts.map +1 -1
  109. package/dist/rag/ingestion.js +1 -0
  110. package/dist/rag/ingestion.js.map +1 -1
  111. package/dist/rag/retriever.d.ts.map +1 -1
  112. package/dist/rag/retriever.js +2 -0
  113. package/dist/rag/retriever.js.map +1 -1
  114. package/dist/rag/vector-store.d.ts.map +1 -1
  115. package/dist/rag/vector-store.js +6 -0
  116. package/dist/rag/vector-store.js.map +1 -1
  117. package/dist/registry/tool/execute.d.ts.map +1 -1
  118. package/dist/registry/tool/execute.js +113 -109
  119. package/dist/registry/tool/execute.js.map +1 -1
  120. package/dist/runtime/query/__tests__/per-step-skills.test.d.ts +10 -0
  121. package/dist/runtime/query/__tests__/per-step-skills.test.d.ts.map +1 -0
  122. package/dist/runtime/query/__tests__/per-step-skills.test.js +122 -0
  123. package/dist/runtime/query/__tests__/per-step-skills.test.js.map +1 -0
  124. package/dist/runtime/query/__tests__/per-step-tool-choice.test.d.ts +2 -0
  125. package/dist/runtime/query/__tests__/per-step-tool-choice.test.d.ts.map +1 -0
  126. package/dist/runtime/query/__tests__/per-step-tool-choice.test.js +153 -0
  127. package/dist/runtime/query/__tests__/per-step-tool-choice.test.js.map +1 -0
  128. package/dist/runtime/query/__tests__/resume-run.test.d.ts +2 -0
  129. package/dist/runtime/query/__tests__/resume-run.test.d.ts.map +1 -0
  130. package/dist/runtime/query/__tests__/resume-run.test.js +211 -0
  131. package/dist/runtime/query/__tests__/resume-run.test.js.map +1 -0
  132. package/dist/runtime/query/index.d.ts +10 -0
  133. package/dist/runtime/query/index.d.ts.map +1 -1
  134. package/dist/runtime/query/index.js +26 -0
  135. package/dist/runtime/query/index.js.map +1 -1
  136. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  137. package/dist/runtime/query/iteration/index.js +96 -34
  138. package/dist/runtime/query/iteration/index.js.map +1 -1
  139. package/dist/runtime/query/iteration/phases/compaction-model-routing.test.d.ts +2 -0
  140. package/dist/runtime/query/iteration/phases/compaction-model-routing.test.d.ts.map +1 -0
  141. package/dist/runtime/query/iteration/phases/compaction-model-routing.test.js +96 -0
  142. package/dist/runtime/query/iteration/phases/compaction-model-routing.test.js.map +1 -0
  143. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  144. package/dist/runtime/query/iteration/phases/compaction.js +95 -5
  145. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  146. package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.d.ts +2 -0
  147. package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.d.ts.map +1 -0
  148. package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.js +180 -0
  149. package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.js.map +1 -0
  150. package/dist/runtime/query/iteration/phases/context.d.ts +9 -0
  151. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  152. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  153. package/dist/runtime/query/resume-run.d.ts +70 -0
  154. package/dist/runtime/query/resume-run.d.ts.map +1 -0
  155. package/dist/runtime/query/resume-run.js +46 -0
  156. package/dist/runtime/query/resume-run.js.map +1 -0
  157. package/dist/telemetry/__tests__/model-call-span.test.d.ts +2 -0
  158. package/dist/telemetry/__tests__/model-call-span.test.d.ts.map +1 -0
  159. package/dist/telemetry/__tests__/model-call-span.test.js +147 -0
  160. package/dist/telemetry/__tests__/model-call-span.test.js.map +1 -0
  161. package/dist/telemetry/__tests__/span-closure.test.d.ts +2 -0
  162. package/dist/telemetry/__tests__/span-closure.test.d.ts.map +1 -0
  163. package/dist/telemetry/__tests__/span-closure.test.js +124 -0
  164. package/dist/telemetry/__tests__/span-closure.test.js.map +1 -0
  165. package/dist/tools/advisory/index.js +1 -1
  166. package/dist/tools/advisory/index.js.map +1 -1
  167. package/dist/tools/coordinator/__tests__/plan-dependencies.test.d.ts +2 -0
  168. package/dist/tools/coordinator/__tests__/plan-dependencies.test.d.ts.map +1 -0
  169. package/dist/tools/coordinator/__tests__/plan-dependencies.test.js +126 -0
  170. package/dist/tools/coordinator/__tests__/plan-dependencies.test.js.map +1 -0
  171. package/dist/tools/coordinator/index.d.ts.map +1 -1
  172. package/dist/tools/coordinator/index.js +13 -1
  173. package/dist/tools/coordinator/index.js.map +1 -1
  174. package/dist/tools/coordinator/plan-dependencies.d.ts +43 -0
  175. package/dist/tools/coordinator/plan-dependencies.d.ts.map +1 -0
  176. package/dist/tools/coordinator/plan-dependencies.js +148 -0
  177. package/dist/tools/coordinator/plan-dependencies.js.map +1 -0
  178. package/dist/types/agent/supervisor.d.ts +15 -0
  179. package/dist/types/agent/supervisor.d.ts.map +1 -1
  180. package/dist/types/connector/core.d.ts +34 -0
  181. package/dist/types/connector/core.d.ts.map +1 -1
  182. package/dist/types/connector/definition.d.ts +10 -0
  183. package/dist/types/connector/definition.d.ts.map +1 -1
  184. package/dist/types/connector/mcp.d.ts +13 -0
  185. package/dist/types/connector/mcp.d.ts.map +1 -1
  186. package/dist/types/rag/retrieval.d.ts +16 -0
  187. package/dist/types/rag/retrieval.d.ts.map +1 -1
  188. package/dist/types/rag/storage.d.ts +9 -0
  189. package/dist/types/rag/storage.d.ts.map +1 -1
  190. package/dist/types/rag/vector.d.ts +11 -0
  191. package/dist/types/rag/vector.d.ts.map +1 -1
  192. package/dist/types/router/task-router.d.ts +19 -0
  193. package/dist/types/router/task-router.d.ts.map +1 -1
  194. package/dist/types/run/prepare-step.d.ts +56 -3
  195. package/dist/types/run/prepare-step.d.ts.map +1 -1
  196. package/dist/types/toolset/index.d.ts +22 -0
  197. package/dist/types/toolset/index.d.ts.map +1 -1
  198. package/package.json +1 -1
  199. package/src/advisory/__tests__/consultation-context.test.ts +191 -0
  200. package/src/advisory/context.ts +32 -0
  201. package/src/advisory/executor.ts +30 -3
  202. package/src/compaction/__tests__/context-reducer.test.ts +239 -0
  203. package/src/compaction/factory.ts +7 -0
  204. package/src/compaction/index.ts +8 -0
  205. package/src/compaction/interface.ts +13 -0
  206. package/src/compaction/managers/null.ts +3 -0
  207. package/src/compaction/managers/slidingWindow.ts +6 -0
  208. package/src/compaction/managers/structured.ts +10 -0
  209. package/src/compaction/reducer.ts +154 -0
  210. package/src/connector/builtins/__tests__/oauth2-auth.test.ts +73 -0
  211. package/src/connector/builtins/http.test.ts +28 -2
  212. package/src/connector/builtins/http.ts +26 -2
  213. package/src/connector/index.ts +6 -2
  214. package/src/connector/mcp/__tests__/prompts-and-lifecycle.test.ts +286 -0
  215. package/src/connector/mcp/client.ts +95 -0
  216. package/src/connector/mcp/discovery.ts +19 -4
  217. package/src/connector/mcp/index.ts +6 -2
  218. package/src/connector/mcp/server.ts +101 -3
  219. package/src/manager/agent/__tests__/depth-limit-authority.test.ts +74 -0
  220. package/src/plugin/__tests__/discovery-scopes.test.ts +133 -0
  221. package/src/plugin/__tests__/enable-contributions.test.ts +5 -1
  222. package/src/plugin/__tests__/mcp-admission.test.ts +242 -0
  223. package/src/plugin/lifecycle.ts +57 -1
  224. package/src/plugin/loader.ts +57 -3
  225. package/src/public-runtime.ts +8 -0
  226. package/src/public-types.ts +5 -0
  227. package/src/rag/__tests__/namespace-isolation.test.ts +109 -0
  228. package/src/rag/ingestion.ts +1 -0
  229. package/src/rag/retriever.ts +2 -0
  230. package/src/rag/vector-store.ts +5 -0
  231. package/src/registry/tool/execute.ts +123 -119
  232. package/src/runtime/query/__tests__/per-step-skills.test.ts +154 -0
  233. package/src/runtime/query/__tests__/per-step-tool-choice.test.ts +180 -0
  234. package/src/runtime/query/__tests__/resume-run.test.ts +262 -0
  235. package/src/runtime/query/index.ts +39 -0
  236. package/src/runtime/query/iteration/index.ts +103 -34
  237. package/src/runtime/query/iteration/phases/compaction-model-routing.test.ts +125 -0
  238. package/src/runtime/query/iteration/phases/compaction.ts +106 -5
  239. package/src/runtime/query/iteration/phases/context-reducer-dispatch.test.ts +238 -0
  240. package/src/runtime/query/iteration/phases/context.ts +11 -0
  241. package/src/runtime/query/resume-run.ts +93 -0
  242. package/src/telemetry/__tests__/model-call-span.test.ts +189 -0
  243. package/src/telemetry/__tests__/span-closure.test.ts +153 -0
  244. package/src/tools/advisory/index.ts +1 -1
  245. package/src/tools/coordinator/__tests__/plan-dependencies.test.ts +186 -0
  246. package/src/tools/coordinator/index.ts +14 -1
  247. package/src/tools/coordinator/plan-dependencies.ts +175 -0
  248. package/src/types/agent/supervisor.ts +15 -0
  249. package/src/types/connector/core.ts +34 -0
  250. package/src/types/connector/definition.ts +10 -0
  251. package/src/types/connector/mcp.ts +14 -0
  252. package/src/types/rag/retrieval.ts +16 -0
  253. package/src/types/rag/storage.ts +9 -0
  254. package/src/types/rag/vector.ts +11 -0
  255. package/src/types/router/task-router.ts +19 -0
  256. package/src/types/run/prepare-step.ts +58 -3
  257. package/src/types/toolset/index.ts +22 -0
@@ -375,151 +375,155 @@ Executable tool names, descriptions, and JSON input schemas are attached through
375
375
  : otelContext.active()
376
376
 
377
377
  return tracer.startActiveSpan(toolSpanName(toolName), {}, parentCtx, async (span) => {
378
- span.setAttributes({
379
- [GENAI.TOOL_NAME]: toolName,
380
- [GENAI.TOOL_TYPE]: 'function',
381
- })
382
-
383
- const tool = this.getOrThrow(toolName)
384
-
385
- const availability = this.getAvailability(toolName)
386
- if (availability !== 'active') {
387
- const msg = `Tool "${toolName}" is ${availability} and cannot be executed`
388
- this.log.warn(msg)
378
+ try {
389
379
  span.setAttributes({
390
- [NAMZU.TOOL_SUCCESS]: false,
391
- [NAMZU.TOOL_ERROR]: msg,
380
+ [GENAI.TOOL_NAME]: toolName,
381
+ [GENAI.TOOL_TYPE]: 'function',
392
382
  })
393
- span.setStatus({ code: SpanStatusCode.ERROR, message: msg })
394
- span.end()
395
- return {
396
- success: false,
397
- output: '',
398
- error: msg,
399
- }
400
- }
401
383
 
402
- const mode = context.permissionContext?.mode ?? 'auto'
403
- if (mode === 'plan') {
404
- const isReadOnly = tool.isReadOnly ? tool.isReadOnly(rawInput) : false
405
- if (!isReadOnly) {
406
- const msg = `plan mode: non-read-only tool "${toolName}" blocked`
384
+ const tool = this.getOrThrow(toolName)
385
+
386
+ const availability = this.getAvailability(toolName)
387
+ if (availability !== 'active') {
388
+ const msg = `Tool "${toolName}" is ${availability} and cannot be executed`
389
+ this.log.warn(msg)
407
390
  span.setAttributes({
408
391
  [NAMZU.TOOL_SUCCESS]: false,
409
392
  [NAMZU.TOOL_ERROR]: msg,
410
393
  })
411
394
  span.setStatus({ code: SpanStatusCode.ERROR, message: msg })
412
- span.end()
413
395
  return {
414
396
  success: false,
415
397
  output: '',
416
398
  error: msg,
417
- permissionDenied: true,
418
- permissionMessage: msg,
419
399
  }
420
400
  }
421
- }
422
401
 
423
- const parseResult = tool.inputSchema.safeParse(rawInput)
424
- if (!parseResult.success) {
425
- const errorMessage = parseResult.error.issues
426
- .map((i) => `${i.path.join('.')}: ${i.message}`)
427
- .join('; ')
428
-
429
- // Distinguish "model sent an empty/no-arg call" from
430
- // "model sent partial args" — the first is most often a
431
- // streaming hiccup or a definition-test ping (a provider
432
- // occasionally pings tool surfaces with `{}` while the
433
- // schema is still loading), the second is a genuine
434
- // programming mistake by the model. The model self-
435
- // corrects MUCH more reliably when the error tells it
436
- // (a) which fields are required, (b) their types, and
437
- // (c) a minimal example call. Without these hints the
438
- // downstream UI just shows a red "Failed" row and the
439
- // model rarely retries with the right args.
440
- const isEmptyInput =
441
- rawInput === null ||
442
- rawInput === undefined ||
443
- (typeof rawInput === 'object' &&
444
- !Array.isArray(rawInput) &&
445
- Object.keys(rawInput as Record<string, unknown>).length === 0)
446
-
447
- const requiredHint = describeRequiredInput(tool.inputSchema)
448
- // A conditional schema's required shape cannot be reconstructed
449
- // from JSON Schema's top-level `required`, so the author gets to
450
- // say what a valid retry looks like.
451
- const recoveryHint = tool.validationErrorHint?.trim()
452
- ? ` ${tool.validationErrorHint.trim()}`
453
- : ''
454
-
455
- const enrichedMessage = isEmptyInput
456
- ? `Tool "${toolName}" was called with no arguments. ${requiredHint}${recoveryHint} Retry the call with the required parameters populated.`
457
- : `Validation failed for "${toolName}": ${errorMessage}. ${requiredHint}${recoveryHint}`
458
-
459
- this.log.error(`Tool input validation failed: ${toolName}`, {
460
- errors: errorMessage,
461
- empty: isEmptyInput,
462
- })
402
+ const mode = context.permissionContext?.mode ?? 'auto'
403
+ if (mode === 'plan') {
404
+ const isReadOnly = tool.isReadOnly ? tool.isReadOnly(rawInput) : false
405
+ if (!isReadOnly) {
406
+ const msg = `plan mode: non-read-only tool "${toolName}" blocked`
407
+ span.setAttributes({
408
+ [NAMZU.TOOL_SUCCESS]: false,
409
+ [NAMZU.TOOL_ERROR]: msg,
410
+ })
411
+ span.setStatus({ code: SpanStatusCode.ERROR, message: msg })
412
+ return {
413
+ success: false,
414
+ output: '',
415
+ error: msg,
416
+ permissionDenied: true,
417
+ permissionMessage: msg,
418
+ }
419
+ }
420
+ }
463
421
 
464
- span.setAttributes({
465
- [NAMZU.TOOL_SUCCESS]: false,
466
- [NAMZU.TOOL_ERROR]: `Validation: ${errorMessage}`,
467
- })
468
- span.setStatus({ code: SpanStatusCode.ERROR, message: errorMessage })
469
- span.end()
422
+ const parseResult = tool.inputSchema.safeParse(rawInput)
423
+ if (!parseResult.success) {
424
+ const errorMessage = parseResult.error.issues
425
+ .map((i) => `${i.path.join('.')}: ${i.message}`)
426
+ .join('; ')
427
+
428
+ // Distinguish "model sent an empty/no-arg call" from
429
+ // "model sent partial args" — the first is most often a
430
+ // streaming hiccup or a definition-test ping (a provider
431
+ // occasionally pings tool surfaces with `{}` while the
432
+ // schema is still loading), the second is a genuine
433
+ // programming mistake by the model. The model self-
434
+ // corrects MUCH more reliably when the error tells it
435
+ // (a) which fields are required, (b) their types, and
436
+ // (c) a minimal example call. Without these hints the
437
+ // downstream UI just shows a red "Failed" row and the
438
+ // model rarely retries with the right args.
439
+ const isEmptyInput =
440
+ rawInput === null ||
441
+ rawInput === undefined ||
442
+ (typeof rawInput === 'object' &&
443
+ !Array.isArray(rawInput) &&
444
+ Object.keys(rawInput as Record<string, unknown>).length === 0)
445
+
446
+ const requiredHint = describeRequiredInput(tool.inputSchema)
447
+ // A conditional schema's required shape cannot be reconstructed
448
+ // from JSON Schema's top-level `required`, so the author gets to
449
+ // say what a valid retry looks like.
450
+ const recoveryHint = tool.validationErrorHint?.trim()
451
+ ? ` ${tool.validationErrorHint.trim()}`
452
+ : ''
453
+
454
+ const enrichedMessage = isEmptyInput
455
+ ? `Tool "${toolName}" was called with no arguments. ${requiredHint}${recoveryHint} Retry the call with the required parameters populated.`
456
+ : `Validation failed for "${toolName}": ${errorMessage}. ${requiredHint}${recoveryHint}`
457
+
458
+ this.log.error(`Tool input validation failed: ${toolName}`, {
459
+ errors: errorMessage,
460
+ empty: isEmptyInput,
461
+ })
470
462
 
471
- return {
472
- success: false,
473
- output: '',
474
- error: enrichedMessage,
463
+ span.setAttributes({
464
+ [NAMZU.TOOL_SUCCESS]: false,
465
+ [NAMZU.TOOL_ERROR]: `Validation: ${errorMessage}`,
466
+ })
467
+ span.setStatus({ code: SpanStatusCode.ERROR, message: errorMessage })
468
+
469
+ return {
470
+ success: false,
471
+ output: '',
472
+ error: enrichedMessage,
473
+ }
475
474
  }
476
- }
477
475
 
478
- const finalInput = parseResult.data
476
+ const finalInput = parseResult.data
479
477
 
480
- try {
481
- this.log.debug(`Executing tool: ${toolName}`)
482
- const startedAt = Date.now()
483
- const result = await tool.execute(finalInput, context)
484
- const durationMs = Date.now() - startedAt
485
- this.log.debug(`Tool completed: ${toolName}`, {
486
- success: result.success,
487
- })
478
+ try {
479
+ this.log.debug(`Executing tool: ${toolName}`)
480
+ const startedAt = Date.now()
481
+ const result = await tool.execute(finalInput, context)
482
+ const durationMs = Date.now() - startedAt
483
+ this.log.debug(`Tool completed: ${toolName}`, {
484
+ success: result.success,
485
+ })
488
486
 
489
- span.setAttribute(NAMZU.TOOL_SUCCESS, result.success)
490
- recordToolCall(
491
- toolName,
492
- result.success,
493
- result.success ? undefined : result.error,
494
- durationMs,
495
- )
496
- if (!result.success && result.error) {
497
- span.setAttribute(NAMZU.TOOL_ERROR, result.error)
498
- span.setStatus({ code: SpanStatusCode.ERROR, message: result.error })
499
- } else {
500
- span.setStatus({ code: SpanStatusCode.OK })
501
- }
487
+ span.setAttribute(NAMZU.TOOL_SUCCESS, result.success)
488
+ recordToolCall(
489
+ toolName,
490
+ result.success,
491
+ result.success ? undefined : result.error,
492
+ durationMs,
493
+ )
494
+ if (!result.success && result.error) {
495
+ span.setAttribute(NAMZU.TOOL_ERROR, result.error)
496
+ span.setStatus({ code: SpanStatusCode.ERROR, message: result.error })
497
+ } else {
498
+ span.setStatus({ code: SpanStatusCode.OK })
499
+ }
502
500
 
503
- return result
504
- } catch (err) {
505
- const errorMessage = toErrorMessage(err)
506
- this.log.error(`Tool execution error: ${toolName}`, {
507
- error: errorMessage,
508
- })
501
+ return result
502
+ } catch (err) {
503
+ const errorMessage = toErrorMessage(err)
504
+ this.log.error(`Tool execution error: ${toolName}`, {
505
+ error: errorMessage,
506
+ })
509
507
 
510
- span.setAttributes({
511
- [NAMZU.TOOL_SUCCESS]: false,
512
- [NAMZU.TOOL_ERROR]: errorMessage,
513
- })
514
- span.setStatus({ code: SpanStatusCode.ERROR, message: errorMessage })
515
- span.recordException(err instanceof Error ? err : new Error(errorMessage))
508
+ span.setAttributes({
509
+ [NAMZU.TOOL_SUCCESS]: false,
510
+ [NAMZU.TOOL_ERROR]: errorMessage,
511
+ })
512
+ span.setStatus({ code: SpanStatusCode.ERROR, message: errorMessage })
513
+ span.recordException(err instanceof Error ? err : new Error(errorMessage))
516
514
 
517
- return {
518
- success: false,
519
- output: '',
520
- error: `Tool "${toolName}" execution failed: ${errorMessage}`,
515
+ return {
516
+ success: false,
517
+ output: '',
518
+ error: `Tool "${toolName}" execution failed: ${errorMessage}`,
519
+ }
521
520
  }
522
521
  } finally {
522
+ // The only place this span ends. It used to be ended at three
523
+ // early returns and in a finally that opened below them, so
524
+ // anything throwing before that try — `getOrThrow` on a name the
525
+ // registry does not hold, for one — left the span open and the
526
+ // tool's trace unclosed.
523
527
  span.end()
524
528
  }
525
529
  })
@@ -0,0 +1,154 @@
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
+
6
+ import { MockLLMProvider } from '../../../provider/mock.js'
7
+ import { ToolRegistry } from '../../../registry/tool/execute.js'
8
+ import type { SessionId, TenantId } from '../../../types/ids/index.js'
9
+ import { createUserMessage } from '../../../types/message/index.js'
10
+ import type { ProjectId, ThreadId } from '../../../types/session/ids.js'
11
+ import type { Skill } from '../../../types/skills/index.js'
12
+ import { drainQuery } from '../index.js'
13
+
14
+ /**
15
+ * A run's skills are fixed at `query()` time and rendered into the cached
16
+ * system prefix, so every skill a run might ever need is paid for on every
17
+ * turn. A phased agent rarely needs them all at once.
18
+ *
19
+ * A peer runtime resolves instructions, model, tools, skills and subagents
20
+ * from context at run time. namzu had the first three; this is the fourth.
21
+ * The fifth is deliberately absent — see the note at the bottom.
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
+ function skill(name: string, body?: string): Skill {
32
+ return {
33
+ metadata: { name, description: `${name} description` },
34
+ dirPath: `/skills/${name}`,
35
+ ...(body !== undefined ? { body } : {}),
36
+ }
37
+ }
38
+
39
+ async function run(provider: MockLLMProvider, over: Record<string, unknown> = {}): Promise<void> {
40
+ const dir = await mkdtemp(join(tmpdir(), 'namzu-skills-'))
41
+ workdirs.push(dir)
42
+ await drainQuery({
43
+ provider,
44
+ tools: new ToolRegistry(),
45
+ runConfig: {
46
+ model: 'mock-model',
47
+ timeoutMs: 30_000,
48
+ tokenBudget: 100_000,
49
+ maxIterations: 4,
50
+ maxResponseTokens: 256,
51
+ },
52
+ agentId: 'agent_sk',
53
+ agentName: 'Skill Agent',
54
+ workingDirectory: dir,
55
+ sessionId: 'ses_sk' as SessionId,
56
+ threadId: 'thd_sk' as ThreadId,
57
+ projectId: 'prj_sk' as ProjectId,
58
+ tenantId: 'tnt_sk' as TenantId,
59
+ messages: [createUserMessage('go')],
60
+ ...over,
61
+ } as never)
62
+ }
63
+
64
+ const sent = (provider: MockLLMProvider, index = 0): string =>
65
+ JSON.stringify(provider.requests.at(index)?.messages ?? [])
66
+
67
+ describe('a step can put a skill in front of the model', () => {
68
+ it('renders the skill it named', async () => {
69
+ const provider = new MockLLMProvider({ turns: [{ text: 'done' }] })
70
+
71
+ await run(provider, { prepareStep: () => ({ skills: [skill('search-the-web')] }) })
72
+
73
+ expect(sent(provider)).toContain('search-the-web')
74
+ })
75
+
76
+ it('sends nothing extra when the step names none', async () => {
77
+ const provider = new MockLLMProvider({ turns: [{ text: 'done' }] })
78
+
79
+ await run(provider)
80
+
81
+ expect(sent(provider)).not.toContain('Available Skills')
82
+ })
83
+
84
+ it('treats an empty list as naming none', async () => {
85
+ const provider = new MockLLMProvider({ turns: [{ text: 'done' }] })
86
+
87
+ await run(provider, { prepareStep: () => ({ skills: [] }) })
88
+
89
+ // An empty list is a caller saying "no skills this step", not a
90
+ // request for an empty section header.
91
+ expect(sent(provider)).not.toContain('Available Skills')
92
+ })
93
+
94
+ it('carries the step guidance alongside it', async () => {
95
+ const provider = new MockLLMProvider({ turns: [{ text: 'done' }] })
96
+
97
+ await run(provider, {
98
+ prepareStep: () => ({ system: 'You are researching.', skills: [skill('search-the-web')] }),
99
+ })
100
+
101
+ // Both ride the same ephemeral trailing message; neither should
102
+ // displace the other.
103
+ const body = sent(provider)
104
+ expect(body).toContain('You are researching.')
105
+ expect(body).toContain('search-the-web')
106
+ })
107
+ })
108
+
109
+ describe('a step skill does not outlive its step', () => {
110
+ it('is absent from the next step that does not ask for it', async () => {
111
+ const provider = new MockLLMProvider({
112
+ turns: [{ toolCalls: [] as never, text: 'thinking' }, { text: 'done' }],
113
+ })
114
+
115
+ await run(provider, {
116
+ prepareStep: ({ stepNumber }: { stepNumber: number }) =>
117
+ stepNumber === 1 ? { skills: [skill('search-the-web')] } : {},
118
+ })
119
+
120
+ expect(sent(provider, 0)).toContain('search-the-web')
121
+ if (provider.requests.length > 1) {
122
+ expect(sent(provider, 1)).not.toContain('search-the-web')
123
+ }
124
+ })
125
+
126
+ it('does not accumulate across steps', async () => {
127
+ const provider = new MockLLMProvider({
128
+ turns: [{ text: 'still working' }, { text: 'done' }],
129
+ })
130
+
131
+ await run(provider, {
132
+ maxIterations: 2,
133
+ prepareStep: () => ({ skills: [skill('search-the-web')] }),
134
+ })
135
+
136
+ // Appended for the call, never retained. Were it written into the run's
137
+ // history the section would stack up turn after turn — the same
138
+ // message repeated, growing the prompt and invalidating the cached
139
+ // prefix every iteration.
140
+ const systemCounts = provider.requests.map(
141
+ (r) => (r.messages ?? []).filter((m) => m.role === 'system').length,
142
+ )
143
+ expect(new Set(systemCounts).size).toBe(1)
144
+ })
145
+ })
146
+
147
+ /**
148
+ * Sub-agents are deliberately NOT per-step, and this is the note rather than
149
+ * a test that would pretend otherwise. Which agents `create_task` can reach
150
+ * is baked into that tool's input schema, so varying it per step would
151
+ * rebuild the tool catalogue every turn — a worse prompt-cache trade than
152
+ * moving tools around, for a narrowing a step can already express by
153
+ * withholding `create_task` through `activeTools`.
154
+ */
@@ -0,0 +1,180 @@
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 { z } from 'zod'
6
+
7
+ import { MockLLMProvider } from '../../../provider/mock.js'
8
+ import { ToolRegistry } from '../../../registry/tool/execute.js'
9
+ import type { SessionId, TenantId } from '../../../types/ids/index.js'
10
+ import { createUserMessage } from '../../../types/message/index.js'
11
+ import type { ProjectId, ThreadId } from '../../../types/session/ids.js'
12
+ import { drainQuery } from '../index.js'
13
+
14
+ /**
15
+ * Forcing a tool is the one step-shaping knob that can hang an agent.
16
+ *
17
+ * A forced choice that PERSISTS makes the model call a tool, read the
18
+ * result, and be forced again — forever. The peer SDK that puts
19
+ * `tool_choice` on persistent model settings carries three moving parts to
20
+ * undo that: a tool-use tracker, an opt-out flag, and a reset applied at
21
+ * two separate call sites. Its flag defaults to on precisely because
22
+ * turning it off hangs the agent.
23
+ *
24
+ * Putting the knob on `prepareStep` removes the failure instead of
25
+ * managing it: the next step is prepared from scratch, so a force cannot
26
+ * outlive the step that asked for it. There is no flag to get wrong.
27
+ */
28
+
29
+ let workdirs: string[] = []
30
+
31
+ afterEach(async () => {
32
+ await Promise.all(workdirs.map((d) => rm(d, { recursive: true, force: true })))
33
+ workdirs = []
34
+ })
35
+
36
+ async function mkWorkdir(): Promise<string> {
37
+ const dir = await mkdtemp(join(tmpdir(), 'namzu-tool-choice-'))
38
+ workdirs.push(dir)
39
+ return dir
40
+ }
41
+
42
+ function registerEcho(tools: ToolRegistry): void {
43
+ tools.register({
44
+ name: 'echo',
45
+ description: 'Echo the text back.',
46
+ inputSchema: z.object({ text: z.string().optional() }),
47
+ execute: async () => ({ success: true, output: 'ok' }),
48
+ })
49
+ }
50
+
51
+ async function baseParams(provider: MockLLMProvider, tools: ToolRegistry) {
52
+ return {
53
+ provider,
54
+ tools,
55
+ runConfig: {
56
+ model: 'mock-model',
57
+ timeoutMs: 30_000,
58
+ tokenBudget: 100_000,
59
+ maxIterations: 4,
60
+ maxResponseTokens: 256,
61
+ },
62
+ agentId: 'agent_tc',
63
+ agentName: 'Tool Choice Agent',
64
+ workingDirectory: await mkWorkdir(),
65
+ sessionId: 'ses_tc' as SessionId,
66
+ threadId: 'thd_tc' as ThreadId,
67
+ projectId: 'prj_tc' as ProjectId,
68
+ tenantId: 'tnt_tc' as TenantId,
69
+ messages: [createUserMessage('go')],
70
+ }
71
+ }
72
+
73
+ describe('a step can force the model to call a tool', () => {
74
+ it('puts the caller choice on that step request', async () => {
75
+ const provider = new MockLLMProvider({ turns: [{ text: 'done' }] })
76
+ const tools = new ToolRegistry()
77
+ registerEcho(tools)
78
+
79
+ await drainQuery({
80
+ ...(await baseParams(provider, tools)),
81
+ prepareStep: () => ({ toolChoice: 'required' as const }),
82
+ })
83
+
84
+ expect(provider.requests.at(0)?.toolChoice).toBe('required')
85
+ })
86
+
87
+ it('carries a named function through unchanged', async () => {
88
+ const provider = new MockLLMProvider({ turns: [{ text: 'done' }] })
89
+ const tools = new ToolRegistry()
90
+ registerEcho(tools)
91
+
92
+ await drainQuery({
93
+ ...(await baseParams(provider, tools)),
94
+ prepareStep: () => ({
95
+ toolChoice: { type: 'function' as const, function: { name: 'echo' } },
96
+ }),
97
+ })
98
+
99
+ expect(provider.requests.at(0)?.toolChoice).toEqual({
100
+ type: 'function',
101
+ function: { name: 'echo' },
102
+ })
103
+ })
104
+
105
+ it('leaves the request alone when no step asks', async () => {
106
+ const provider = new MockLLMProvider({ turns: [{ text: 'done' }] })
107
+ const tools = new ToolRegistry()
108
+ registerEcho(tools)
109
+
110
+ await drainQuery(await baseParams(provider, tools))
111
+
112
+ expect(provider.requests.at(0)?.toolChoice).toBeUndefined()
113
+ })
114
+
115
+ it('says nothing when there are no tools to choose between', async () => {
116
+ const provider = new MockLLMProvider({ turns: [{ text: 'done' }] })
117
+
118
+ await drainQuery({
119
+ ...(await baseParams(provider, new ToolRegistry())),
120
+ prepareStep: () => ({ toolChoice: 'required' as const }),
121
+ })
122
+
123
+ // `tool_choice` alongside an absent tool list is rejected by the
124
+ // providers, so a forced choice with nothing registered has to drop.
125
+ expect(provider.requests.at(0)?.toolChoice).toBeUndefined()
126
+ })
127
+ })
128
+
129
+ /**
130
+ * NOT covered here, and deliberately not faked: the forced-final turn takes
131
+ * precedence over a step's choice (`forceFinalize` wins in
132
+ * `iteration/index.ts`), so a budget-exhausted run can still stop asking
133
+ * for tools and answer. Reaching that branch needs the guard to raise a
134
+ * budget warning, which needs real usage the mock provider does not report.
135
+ * The precedence is implemented and read in review; it is not pinned.
136
+ */
137
+ describe('a forced choice cannot outlive the step that asked for it', () => {
138
+ it('applies to the named step only, leaving later steps free', async () => {
139
+ const provider = new MockLLMProvider({
140
+ turns: [
141
+ { toolCalls: [{ id: 'c1', name: 'echo', rawArguments: '{}' }] },
142
+ { text: 'finished' },
143
+ ],
144
+ })
145
+ const tools = new ToolRegistry()
146
+ registerEcho(tools)
147
+
148
+ await drainQuery({
149
+ ...(await baseParams(provider, tools)),
150
+ // Only the first step is forced. Without per-step scoping this
151
+ // would keep forcing and the run would never reach a text answer.
152
+ prepareStep: ({ stepNumber }) =>
153
+ stepNumber === 1 ? { toolChoice: 'required' as const } : {},
154
+ })
155
+
156
+ expect(provider.requests.at(0)?.toolChoice).toBe('required')
157
+ expect(provider.requests.at(1)?.toolChoice).toBeUndefined()
158
+ })
159
+
160
+ it('a run whose every step forces a tool still ends, bounded by the loop', async () => {
161
+ const provider = new MockLLMProvider({
162
+ turns: [
163
+ { toolCalls: [{ id: 'c1', name: 'echo', rawArguments: '{}' }] },
164
+ { toolCalls: [{ id: 'c2', name: 'echo', rawArguments: '{}' }] },
165
+ { text: 'finished' },
166
+ ],
167
+ })
168
+ const tools = new ToolRegistry()
169
+ registerEcho(tools)
170
+
171
+ const run = await drainQuery({
172
+ ...(await baseParams(provider, tools)),
173
+ prepareStep: () => ({ toolChoice: 'required' as const }),
174
+ })
175
+
176
+ // The knob cannot hang a run on its own: the iteration cap is still
177
+ // the backstop, and it settles rather than spinning.
178
+ expect(['completed', 'failed']).toContain(run.status)
179
+ })
180
+ })