@namzu/sdk 1.3.0 → 2.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 (171) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/dist/compaction/__tests__/verifier-empty-reply.test.d.ts +12 -0
  3. package/dist/compaction/__tests__/verifier-empty-reply.test.d.ts.map +1 -0
  4. package/dist/compaction/__tests__/verifier-empty-reply.test.js +73 -0
  5. package/dist/compaction/__tests__/verifier-empty-reply.test.js.map +1 -0
  6. package/dist/compaction/verifier.d.ts.map +1 -1
  7. package/dist/compaction/verifier.js +10 -1
  8. package/dist/compaction/verifier.js.map +1 -1
  9. package/dist/config/__tests__/compaction-budget-schema.test.d.ts +18 -0
  10. package/dist/config/__tests__/compaction-budget-schema.test.d.ts.map +1 -0
  11. package/dist/config/__tests__/compaction-budget-schema.test.js +96 -0
  12. package/dist/config/__tests__/compaction-budget-schema.test.js.map +1 -0
  13. package/dist/config/runtime.d.ts.map +1 -1
  14. package/dist/config/runtime.js +20 -11
  15. package/dist/config/runtime.js.map +1 -1
  16. package/dist/manager/run/persistence.d.ts +2 -1
  17. package/dist/manager/run/persistence.d.ts.map +1 -1
  18. package/dist/manager/run/persistence.js +3 -1
  19. package/dist/manager/run/persistence.js.map +1 -1
  20. package/dist/provider/__tests__/errors.test.d.ts +2 -0
  21. package/dist/provider/__tests__/errors.test.d.ts.map +1 -0
  22. package/dist/provider/__tests__/errors.test.js +61 -0
  23. package/dist/provider/__tests__/errors.test.js.map +1 -0
  24. package/dist/provider/errors.d.ts +108 -0
  25. package/dist/provider/errors.d.ts.map +1 -0
  26. package/dist/provider/errors.js +311 -0
  27. package/dist/provider/errors.js.map +1 -0
  28. package/dist/provider/index.d.ts +2 -0
  29. package/dist/provider/index.d.ts.map +1 -1
  30. package/dist/provider/index.js +1 -0
  31. package/dist/provider/index.js.map +1 -1
  32. package/dist/public-runtime.d.ts +11 -11
  33. package/dist/public-runtime.d.ts.map +1 -1
  34. package/dist/public-runtime.js +11 -11
  35. package/dist/public-runtime.js.map +1 -1
  36. package/dist/registry/tool/execute.d.ts.map +1 -1
  37. package/dist/registry/tool/execute.js +45 -12
  38. package/dist/registry/tool/execute.js.map +1 -1
  39. package/dist/registry/tool/execute.test.js +104 -7
  40. package/dist/registry/tool/execute.test.js.map +1 -1
  41. package/dist/registry/toolset/catalog.d.ts.map +1 -1
  42. package/dist/registry/toolset/catalog.js +17 -4
  43. package/dist/registry/toolset/catalog.js.map +1 -1
  44. package/dist/registry/toolset/catalog.test.js +101 -0
  45. package/dist/registry/toolset/catalog.test.js.map +1 -1
  46. package/dist/runtime/query/__tests__/capability-negotiation.test.js +79 -0
  47. package/dist/runtime/query/__tests__/capability-negotiation.test.js.map +1 -1
  48. package/dist/runtime/query/__tests__/deferred-tools.test.js +100 -0
  49. package/dist/runtime/query/__tests__/deferred-tools.test.js.map +1 -1
  50. package/dist/runtime/query/__tests__/long-document-flow.test.js +12 -12
  51. package/dist/runtime/query/__tests__/long-document-flow.test.js.map +1 -1
  52. package/dist/runtime/query/__tests__/stream-recovery.test.js +55 -2
  53. package/dist/runtime/query/__tests__/stream-recovery.test.js.map +1 -1
  54. package/dist/runtime/query/executor.js +2 -2
  55. package/dist/runtime/query/executor.js.map +1 -1
  56. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  57. package/dist/runtime/query/iteration/index.js +10 -0
  58. package/dist/runtime/query/iteration/index.js.map +1 -1
  59. package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.d.ts +32 -0
  60. package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.d.ts.map +1 -0
  61. package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.js +152 -0
  62. package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.js.map +1 -0
  63. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  64. package/dist/runtime/query/iteration/phases/compaction.js +52 -12
  65. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  66. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
  67. package/dist/runtime/query/iteration/stream-turn.js +11 -5
  68. package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
  69. package/dist/runtime/query/result.d.ts.map +1 -1
  70. package/dist/runtime/query/result.js +11 -1
  71. package/dist/runtime/query/result.js.map +1 -1
  72. package/dist/tools/builtins/__tests__/edit.test.js +216 -10
  73. package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
  74. package/dist/tools/builtins/__tests__/payload-budget.test.js +30 -16
  75. package/dist/tools/builtins/__tests__/payload-budget.test.js.map +1 -1
  76. package/dist/tools/builtins/__tests__/write-file.test.js +37 -13
  77. package/dist/tools/builtins/__tests__/write-file.test.js.map +1 -1
  78. package/dist/tools/builtins/atomic-write-file.d.ts +13 -0
  79. package/dist/tools/builtins/atomic-write-file.d.ts.map +1 -0
  80. package/dist/tools/builtins/atomic-write-file.js +46 -0
  81. package/dist/tools/builtins/atomic-write-file.js.map +1 -0
  82. package/dist/tools/builtins/bash.js +2 -2
  83. package/dist/tools/builtins/bash.js.map +1 -1
  84. package/dist/tools/builtins/edit.d.ts +2 -5
  85. package/dist/tools/builtins/edit.d.ts.map +1 -1
  86. package/dist/tools/builtins/edit.js +105 -125
  87. package/dist/tools/builtins/edit.js.map +1 -1
  88. package/dist/tools/builtins/file-mutation-lock.d.ts +7 -0
  89. package/dist/tools/builtins/file-mutation-lock.d.ts.map +1 -0
  90. package/dist/tools/builtins/file-mutation-lock.js +25 -0
  91. package/dist/tools/builtins/file-mutation-lock.js.map +1 -0
  92. package/dist/tools/builtins/index.js +2 -2
  93. package/dist/tools/builtins/index.js.map +1 -1
  94. package/dist/tools/builtins/write-file.d.ts +1 -2
  95. package/dist/tools/builtins/write-file.d.ts.map +1 -1
  96. package/dist/tools/builtins/write-file.js +66 -40
  97. package/dist/tools/builtins/write-file.js.map +1 -1
  98. package/dist/tools/coordinator/__tests__/task-list.test.js +14 -0
  99. package/dist/tools/coordinator/__tests__/task-list.test.js.map +1 -1
  100. package/dist/tools/coordinator/agent.d.ts +6 -8
  101. package/dist/tools/coordinator/agent.d.ts.map +1 -1
  102. package/dist/tools/coordinator/agent.js.map +1 -1
  103. package/dist/tools/coordinator/index.d.ts.map +1 -1
  104. package/dist/tools/coordinator/index.js +11 -32
  105. package/dist/tools/coordinator/index.js.map +1 -1
  106. package/dist/tools/defineTool.d.ts +3 -0
  107. package/dist/tools/defineTool.d.ts.map +1 -1
  108. package/dist/tools/defineTool.js +3 -0
  109. package/dist/tools/defineTool.js.map +1 -1
  110. package/dist/types/provider/chat.d.ts +9 -0
  111. package/dist/types/provider/chat.d.ts.map +1 -1
  112. package/dist/types/provider/error.d.ts +22 -0
  113. package/dist/types/provider/error.d.ts.map +1 -0
  114. package/dist/types/provider/error.js +2 -0
  115. package/dist/types/provider/error.js.map +1 -0
  116. package/dist/types/provider/index.d.ts +1 -0
  117. package/dist/types/provider/index.d.ts.map +1 -1
  118. package/dist/types/run/entity.d.ts +2 -0
  119. package/dist/types/run/entity.d.ts.map +1 -1
  120. package/dist/types/run/events.d.ts +2 -0
  121. package/dist/types/run/events.d.ts.map +1 -1
  122. package/dist/types/run/events.js.map +1 -1
  123. package/dist/types/sandbox/index.d.ts +4 -0
  124. package/dist/types/sandbox/index.d.ts.map +1 -1
  125. package/dist/types/sandbox/index.js.map +1 -1
  126. package/dist/types/tool/index.d.ts +20 -0
  127. package/dist/types/tool/index.d.ts.map +1 -1
  128. package/package.json +2 -1
  129. package/src/compaction/__tests__/verifier-empty-reply.test.ts +96 -0
  130. package/src/compaction/verifier.ts +10 -1
  131. package/src/config/__tests__/compaction-budget-schema.test.ts +114 -0
  132. package/src/config/runtime.ts +20 -11
  133. package/src/manager/run/persistence.ts +3 -1
  134. package/src/provider/__tests__/errors.test.ts +85 -0
  135. package/src/provider/errors.ts +344 -0
  136. package/src/provider/index.ts +15 -0
  137. package/src/public-runtime.ts +53 -10
  138. package/src/registry/tool/execute.test.ts +121 -7
  139. package/src/registry/tool/execute.ts +50 -12
  140. package/src/registry/toolset/catalog.test.ts +118 -0
  141. package/src/registry/toolset/catalog.ts +26 -4
  142. package/src/runtime/query/__tests__/capability-negotiation.test.ts +88 -0
  143. package/src/runtime/query/__tests__/deferred-tools.test.ts +111 -0
  144. package/src/runtime/query/__tests__/long-document-flow.test.ts +12 -12
  145. package/src/runtime/query/__tests__/stream-recovery.test.ts +65 -2
  146. package/src/runtime/query/executor.ts +2 -2
  147. package/src/runtime/query/iteration/index.ts +15 -0
  148. package/src/runtime/query/iteration/phases/compaction-safe-cut.test.ts +186 -0
  149. package/src/runtime/query/iteration/phases/compaction.ts +54 -12
  150. package/src/runtime/query/iteration/stream-turn.ts +11 -6
  151. package/src/runtime/query/result.ts +11 -1
  152. package/src/tools/builtins/__tests__/edit.test.ts +270 -10
  153. package/src/tools/builtins/__tests__/payload-budget.test.ts +40 -20
  154. package/src/tools/builtins/__tests__/write-file.test.ts +52 -13
  155. package/src/tools/builtins/atomic-write-file.ts +58 -0
  156. package/src/tools/builtins/bash.ts +2 -2
  157. package/src/tools/builtins/edit.ts +116 -162
  158. package/src/tools/builtins/file-mutation-lock.ts +27 -0
  159. package/src/tools/builtins/index.ts +2 -2
  160. package/src/tools/builtins/write-file.ts +68 -44
  161. package/src/tools/coordinator/__tests__/task-list.test.ts +16 -0
  162. package/src/tools/coordinator/agent.ts +6 -8
  163. package/src/tools/coordinator/index.ts +11 -34
  164. package/src/tools/defineTool.ts +6 -0
  165. package/src/types/provider/chat.ts +14 -1
  166. package/src/types/provider/error.ts +29 -0
  167. package/src/types/provider/index.ts +5 -0
  168. package/src/types/run/entity.ts +2 -0
  169. package/src/types/run/events.ts +7 -1
  170. package/src/types/sandbox/index.ts +4 -0
  171. package/src/types/tool/index.ts +20 -0
@@ -54,7 +54,11 @@ export class ToolRegistry extends ManagedRegistry<ToolDefinition> {
54
54
  private tierConfig?: ToolTierConfig
55
55
 
56
56
  constructor(config?: ToolRegistryConfig) {
57
- super({ componentName: 'ToolRegistry', idField: 'name', logger: config?.logger })
57
+ super({
58
+ componentName: 'ToolRegistry',
59
+ idField: 'name',
60
+ logger: config?.logger,
61
+ })
58
62
  this.tierConfig = config?.tierConfig
59
63
  }
60
64
 
@@ -88,6 +92,11 @@ export class ToolRegistry extends ManagedRegistry<ToolDefinition> {
88
92
  }
89
93
 
90
94
  private registerOne(id: string, tool: ToolDefinition, state: ToolAvailability): void {
95
+ if (tool.enforceModelInput && !tool.modelInputSchema) {
96
+ throw new Error(
97
+ `Tool "${id}" enables enforceModelInput but does not define modelInputSchema. Constrained input generation requires an explicit provider-safe model schema.`,
98
+ )
99
+ }
91
100
  if (tool.tier && this.tierConfig) {
92
101
  const validIds = this.tierConfig.tiers.map((t) => t.id)
93
102
  if (!validIds.includes(tool.tier)) {
@@ -279,10 +288,12 @@ Executable tool names, descriptions, and JSON input schemas are attached through
279
288
  function: {
280
289
  name: tool.name,
281
290
  description,
282
- parameters: zodToJsonSchema(tool.inputSchema, {
283
- target: 'jsonSchema7',
284
- $refStrategy: 'none',
285
- }) as Record<string, unknown>,
291
+ parameters:
292
+ (tool.modelInputSchema ? structuredClone(tool.modelInputSchema) : undefined) ??
293
+ (zodToJsonSchema(tool.inputSchema, {
294
+ target: 'jsonSchema7',
295
+ $refStrategy: 'none',
296
+ }) as Record<string, unknown>),
286
297
  },
287
298
  }
288
299
  })
@@ -370,10 +381,13 @@ Executable tool names, descriptions, and JSON input schemas are attached through
370
381
  Object.keys(rawInput as Record<string, unknown>).length === 0)
371
382
 
372
383
  const requiredHint = describeRequiredInput(tool.inputSchema)
384
+ const recoveryHint = tool.validationErrorHint?.trim()
385
+ ? ` ${tool.validationErrorHint.trim()}`
386
+ : ''
373
387
 
374
388
  const enrichedMessage = isEmptyInput
375
- ? `Tool "${toolName}" was called with no arguments. ${requiredHint} Retry the call with the required parameters populated.`
376
- : `Validation failed for "${toolName}": ${errorMessage}. ${requiredHint}`
389
+ ? `Tool "${toolName}" was called with no arguments. ${requiredHint}${recoveryHint} Retry the call with the required parameters populated.`
390
+ : `Validation failed for "${toolName}": ${errorMessage}. ${requiredHint}${recoveryHint}`
377
391
 
378
392
  this.log.error(`Tool input validation failed: ${toolName}`, {
379
393
  errors: errorMessage,
@@ -465,16 +479,40 @@ export function toolDiscoveryHint(description: string, maxLength = 100): string
465
479
  */
466
480
  function listArgumentNames(tool: ToolDefinition): string[] {
467
481
  try {
468
- const json = zodToJsonSchema(tool.inputSchema, {
469
- target: 'jsonSchema7',
470
- $refStrategy: 'none',
471
- }) as { properties?: Record<string, unknown> }
472
- return Object.keys(json.properties ?? {}).map((key) => key.toLowerCase())
482
+ const json =
483
+ tool.modelInputSchema ??
484
+ (zodToJsonSchema(tool.inputSchema, {
485
+ target: 'jsonSchema7',
486
+ $refStrategy: 'none',
487
+ }) as Record<string, unknown>)
488
+ return [...collectSchemaPropertyNames(json)].map((key) => key.toLowerCase())
473
489
  } catch {
474
490
  return []
475
491
  }
476
492
  }
477
493
 
494
+ function collectSchemaPropertyNames(
495
+ schema: unknown,
496
+ names: Set<string> = new Set(),
497
+ seen: Set<object> = new Set(),
498
+ ): Set<string> {
499
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return names
500
+ if (seen.has(schema)) return names
501
+ seen.add(schema)
502
+
503
+ const record = schema as Record<string, unknown>
504
+ const properties = record.properties
505
+ if (properties && typeof properties === 'object' && !Array.isArray(properties)) {
506
+ for (const name of Object.keys(properties)) names.add(name)
507
+ }
508
+ for (const keyword of ['anyOf', 'oneOf', 'allOf'] as const) {
509
+ const branches = record[keyword]
510
+ if (!Array.isArray(branches)) continue
511
+ for (const branch of branches) collectSchemaPropertyNames(branch, names, seen)
512
+ }
513
+ return names
514
+ }
515
+
478
516
  /**
479
517
  * Build a one-sentence "Required: <field>: <type>, <field>: <type>"
480
518
  * hint from a Zod schema, used to enrich tool-input validation
@@ -16,6 +16,18 @@ function makeTool(name: string, description = `${name} tool`): ToolDefinition {
16
16
  }
17
17
  }
18
18
 
19
+ function makeCatalog(): ToolCatalog {
20
+ const catalog = new ToolCatalog()
21
+ catalog.registerSource({ id: 'host', kind: 'host_tool', name: 'Host' })
22
+ catalog.registerToolset({
23
+ id: 'tools',
24
+ sourceId: 'host',
25
+ name: 'Tools',
26
+ defaultPolicy: { enabled: true, loading: 'eager' },
27
+ })
28
+ return catalog
29
+ }
30
+
19
31
  describe('ToolCatalog', () => {
20
32
  it('keeps sources, toolsets, and tools as separate records', () => {
21
33
  const catalog = new ToolCatalog()
@@ -162,4 +174,110 @@ describe('ToolCatalog', () => {
162
174
  expect(catalog.getTool('write_file')?.policy.loading).toBe('suspended')
163
175
  expect(catalog.toLLMTools({ loading: ['suspended'] })).toEqual([])
164
176
  })
177
+
178
+ it('rejects catalog, definition, and LLM schema name mismatches', () => {
179
+ const catalog = makeCatalog()
180
+ expect(() =>
181
+ catalog.registerTool({
182
+ name: 'edit',
183
+ description: 'Edit a file',
184
+ sourceId: 'host',
185
+ toolsetId: 'tools',
186
+ policy: { enabled: true, loading: 'eager' },
187
+ definition: makeTool('write'),
188
+ }),
189
+ ).toThrow(/definition name mismatch/)
190
+
191
+ expect(() =>
192
+ catalog.registerTool({
193
+ name: 'edit',
194
+ description: 'Edit a file',
195
+ sourceId: 'host',
196
+ toolsetId: 'tools',
197
+ policy: { enabled: true, loading: 'eager' },
198
+ llmSchema: {
199
+ type: 'function',
200
+ function: {
201
+ name: 'write',
202
+ description: 'Write',
203
+ parameters: { type: 'object' },
204
+ },
205
+ },
206
+ }),
207
+ ).toThrow(/LLM schema name mismatch/)
208
+ })
209
+
210
+ it('rejects an explicit LLM schema that could replace an enforced definition schema', () => {
211
+ const catalog = makeCatalog()
212
+ const definition: ToolDefinition = {
213
+ ...makeTool('edit'),
214
+ modelInputSchema: {
215
+ type: 'object',
216
+ properties: { path: { type: 'string' } },
217
+ required: ['path'],
218
+ additionalProperties: false,
219
+ },
220
+ enforceModelInput: true,
221
+ }
222
+
223
+ expect(() =>
224
+ catalog.registerTool({
225
+ name: 'edit',
226
+ description: 'Edit a file',
227
+ sourceId: 'host',
228
+ toolsetId: 'tools',
229
+ policy: { enabled: true, loading: 'eager' },
230
+ definition,
231
+ llmSchema: {
232
+ type: 'function',
233
+ function: {
234
+ name: 'edit',
235
+ description: 'Unsafe override',
236
+ parameters: { type: 'object' },
237
+ },
238
+ },
239
+ }),
240
+ ).toThrow(/cannot combine an explicit llmSchema with enforceModelInput/)
241
+ })
242
+
243
+ it('rejects an enforced catalog definition without an explicit model schema', () => {
244
+ const catalog = makeCatalog()
245
+ expect(() =>
246
+ catalog.registerTool({
247
+ name: 'edit',
248
+ description: 'Edit a file',
249
+ sourceId: 'host',
250
+ toolsetId: 'tools',
251
+ policy: { enabled: true, loading: 'eager' },
252
+ definition: {
253
+ ...makeTool('edit'),
254
+ enforceModelInput: true,
255
+ },
256
+ }),
257
+ ).toThrow(/enforceModelInput.*modelInputSchema/)
258
+ })
259
+
260
+ it('uses a definition model schema when no explicit catalog schema overrides it', () => {
261
+ const registry = new ToolRegistry()
262
+ const modelInputSchema = {
263
+ type: 'object',
264
+ properties: { new_string: { type: 'string' } },
265
+ required: ['new_string'],
266
+ additionalProperties: false,
267
+ }
268
+ registry.register({
269
+ ...makeTool('edit'),
270
+ modelInputSchema,
271
+ })
272
+
273
+ const catalog = createToolCatalogFromRegistry(registry)
274
+ const firstSchema = catalog.toLLMTools()[0]?.function.parameters
275
+ expect(firstSchema).toEqual(modelInputSchema)
276
+ expect(firstSchema).not.toBe(modelInputSchema)
277
+ ;(firstSchema?.properties as Record<string, unknown>).newStr = { type: 'string' }
278
+
279
+ const nextSchema = catalog.toLLMTools()[0]?.function.parameters
280
+ expect(nextSchema).toEqual(modelInputSchema)
281
+ expect(JSON.stringify(nextSchema)).not.toContain('newStr')
282
+ })
165
283
  })
@@ -64,6 +64,26 @@ export class ToolCatalog {
64
64
  if (!this.toolsets.has(tool.toolsetId)) {
65
65
  throw new Error(`Tool "${tool.name}" references unknown toolset "${tool.toolsetId}"`)
66
66
  }
67
+ if (tool.definition && tool.definition.name !== tool.name) {
68
+ throw new Error(
69
+ `Tool "${tool.name}" definition name mismatch: received "${tool.definition.name}"`,
70
+ )
71
+ }
72
+ if (tool.llmSchema && tool.llmSchema.function.name !== tool.name) {
73
+ throw new Error(
74
+ `Tool "${tool.name}" LLM schema name mismatch: received "${tool.llmSchema.function.name}"`,
75
+ )
76
+ }
77
+ if (tool.definition?.enforceModelInput && !tool.definition.modelInputSchema) {
78
+ throw new Error(
79
+ `Tool "${tool.name}" enables enforceModelInput but does not define modelInputSchema.`,
80
+ )
81
+ }
82
+ if (tool.definition?.enforceModelInput && tool.llmSchema) {
83
+ throw new Error(
84
+ `Tool "${tool.name}" cannot combine an explicit llmSchema with enforceModelInput. Put the provider-safe schema on definition.modelInputSchema so enforcement and schema precedence stay paired.`,
85
+ )
86
+ }
67
87
  this.tools.set(tool.name, tool)
68
88
  }
69
89
 
@@ -226,10 +246,12 @@ function toolDefinitionToLLMTool(definition: ToolDefinition | undefined): LLMToo
226
246
  function: {
227
247
  name: definition.name,
228
248
  description: definition.description,
229
- parameters: zodToJsonSchema(definition.inputSchema, {
230
- target: 'jsonSchema7',
231
- $refStrategy: 'none',
232
- }) as Record<string, unknown>,
249
+ parameters:
250
+ (definition.modelInputSchema ? structuredClone(definition.modelInputSchema) : undefined) ??
251
+ (zodToJsonSchema(definition.inputSchema, {
252
+ target: 'jsonSchema7',
253
+ $refStrategy: 'none',
254
+ }) as Record<string, unknown>),
233
255
  },
234
256
  }
235
257
  }
@@ -46,6 +46,32 @@ class CapturingProvider implements LLMProvider {
46
46
  }
47
47
  }
48
48
 
49
+ class ClosingSummaryProvider implements LLMProvider {
50
+ readonly id = 'closing-summary'
51
+ readonly name = 'Closing Summary Provider'
52
+ readonly calls: ChatCompletionParams[] = []
53
+
54
+ async *chatStream(params: ChatCompletionParams): AsyncIterable<StreamChunk> {
55
+ this.calls.push(params)
56
+ if (this.calls.length === 1) {
57
+ yield {
58
+ id: 'msg_empty',
59
+ delta: {},
60
+ finishReason: 'stop',
61
+ usage: ZERO_USAGE,
62
+ }
63
+ return
64
+ }
65
+ yield { id: 'msg_summary', delta: { content: 'closing summary' } }
66
+ yield {
67
+ id: 'msg_summary',
68
+ delta: {},
69
+ finishReason: 'stop',
70
+ usage: ZERO_USAGE,
71
+ }
72
+ }
73
+ }
74
+
49
75
  const NO_TOOLS_CAPABILITIES: ProviderCapabilities = {
50
76
  supportsTools: false,
51
77
  supportsStreaming: true,
@@ -69,6 +95,25 @@ function registerEchoTool(tools: ToolRegistry): void {
69
95
  })
70
96
  }
71
97
 
98
+ function registerEnforcedTool(tools: ToolRegistry, name: string): void {
99
+ tools.register({
100
+ name,
101
+ description: `${name} tool`,
102
+ inputSchema: z.object({
103
+ new_string: z.string().optional(),
104
+ newStr: z.string().optional(),
105
+ }),
106
+ modelInputSchema: {
107
+ type: 'object',
108
+ properties: { new_string: { type: 'string' } },
109
+ required: ['new_string'],
110
+ additionalProperties: false,
111
+ },
112
+ enforceModelInput: true,
113
+ execute: async () => ({ success: true, output: 'ok' }),
114
+ })
115
+ }
116
+
72
117
  function baseParams(provider: LLMProvider, tools: ToolRegistry, workingDirectory: string) {
73
118
  return {
74
119
  provider,
@@ -176,6 +221,49 @@ describe('query() capability negotiation', () => {
176
221
  expect(provider.lastParams?.tools?.map((t) => t.function.name)).toContain('echo')
177
222
  })
178
223
 
224
+ it('propagates enforcement names from the exact allowed and cache-stable tool schemas', async () => {
225
+ const provider = new CapturingProvider()
226
+ const tools = new ToolRegistry()
227
+ registerEnforcedTool(tools, 'edit')
228
+ registerEnforcedTool(tools, 'cached_edit')
229
+ registerEnforcedTool(tools, 'excluded_edit')
230
+ tools.suspendAll()
231
+ tools.activate(['edit'])
232
+
233
+ const run = await drainQuery({
234
+ ...baseParams(provider, tools, await mkWorkdir()),
235
+ allowedTools: ['edit', 'cached_edit'],
236
+ messages: [createUserMessage('hello')],
237
+ })
238
+
239
+ expect(run.status).toBe('completed')
240
+ expect(provider.lastParams?.tools?.map((tool) => tool.function.name)).toEqual([
241
+ 'edit',
242
+ 'cached_edit',
243
+ ])
244
+ expect(provider.lastParams?.enforceToolInputSchema).toEqual(['edit', 'cached_edit'])
245
+ expect(JSON.stringify(provider.lastParams?.tools)).not.toContain('newStr')
246
+ })
247
+
248
+ it('keeps the same enforcement hint on the closing-summary provider call', async () => {
249
+ const provider = new ClosingSummaryProvider()
250
+ const tools = new ToolRegistry()
251
+ registerEnforcedTool(tools, 'edit')
252
+
253
+ const run = await drainQuery({
254
+ ...baseParams(provider, tools, await mkWorkdir()),
255
+ messages: [createUserMessage('hello')],
256
+ })
257
+
258
+ expect(run.status).toBe('completed')
259
+ expect(provider.calls).toHaveLength(2)
260
+ for (const call of provider.calls) {
261
+ expect(call.tools?.map((tool) => tool.function.name)).toEqual(['edit'])
262
+ expect(call.enforceToolInputSchema).toEqual(['edit'])
263
+ }
264
+ expect(provider.calls[1]?.toolChoice).toBe('none')
265
+ })
266
+
179
267
  it('emits a vision capability_warning when attachments hit a no-vision provider', async () => {
180
268
  const provider = new CapturingProvider(NO_VISION_CAPABILITIES)
181
269
  const events: RunEvent[] = []
@@ -44,6 +44,58 @@ class CapturingProvider implements LLMProvider {
44
44
  }
45
45
  }
46
46
 
47
+ class DeferredActivationProvider implements LLMProvider {
48
+ readonly id = 'deferred-activation'
49
+ readonly name = 'Deferred Activation Provider'
50
+ readonly calls: ChatCompletionParams[] = []
51
+
52
+ async *chatStream(params: ChatCompletionParams): AsyncIterable<StreamChunk> {
53
+ this.calls.push(params)
54
+ if (this.calls.length === 1) {
55
+ yield {
56
+ id: 'msg_search',
57
+ delta: {
58
+ toolCalls: [
59
+ {
60
+ index: 0,
61
+ id: 'toolu_search',
62
+ type: 'function',
63
+ function: { name: SearchToolsTool.name },
64
+ },
65
+ ],
66
+ },
67
+ }
68
+ yield {
69
+ id: 'msg_search',
70
+ delta: {
71
+ toolCalls: [
72
+ {
73
+ index: 0,
74
+ id: 'toolu_search',
75
+ function: { arguments: '{"query":"canonical_key"}' },
76
+ },
77
+ ],
78
+ },
79
+ }
80
+ yield {
81
+ id: 'msg_search',
82
+ delta: {},
83
+ finishReason: 'tool_calls',
84
+ usage: ZERO_USAGE,
85
+ }
86
+ return
87
+ }
88
+
89
+ yield { id: 'msg_done', delta: { content: 'done' } }
90
+ yield {
91
+ id: 'msg_done',
92
+ delta: {},
93
+ finishReason: 'stop',
94
+ usage: ZERO_USAGE,
95
+ }
96
+ }
97
+ }
98
+
47
99
  function registerDeferredDocumentTool(tools: ToolRegistry, name = 'generate_document'): void {
48
100
  tools.register(
49
101
  {
@@ -156,6 +208,65 @@ describe('query deferred tool discovery', () => {
156
208
  expect(systemPrompt).toContain('- generate_document')
157
209
  })
158
210
 
211
+ it('adds enforcement only after a deferred model-schema tool is activated', async () => {
212
+ const provider = new DeferredActivationProvider()
213
+ const tools = new ToolRegistry()
214
+ tools.register(
215
+ {
216
+ name: 'deferred_edit',
217
+ description: 'Mutate a document using the provided content.',
218
+ inputSchema: z.object({ legacyKey: z.string() }),
219
+ modelInputSchema: {
220
+ type: 'object',
221
+ properties: { canonical_key: { type: 'string' } },
222
+ required: ['canonical_key'],
223
+ additionalProperties: false,
224
+ },
225
+ enforceModelInput: true,
226
+ execute: async () => ({ success: true, output: 'edited' }),
227
+ },
228
+ 'deferred',
229
+ )
230
+
231
+ const workingDirectory = await mkdtemp(join(tmpdir(), 'namzu-deferred-enforcement-'))
232
+ workdirs.push(workingDirectory)
233
+ const run = await drainQuery({
234
+ provider,
235
+ tools,
236
+ allowedTools: ['deferred_edit'],
237
+ runConfig: {
238
+ model: 'mock-model',
239
+ timeoutMs: 5_000,
240
+ tokenBudget: 100_000,
241
+ maxIterations: 3,
242
+ maxResponseTokens: 256,
243
+ },
244
+ agentId: 'agent_test',
245
+ agentName: 'Test Agent',
246
+ messages: [createUserMessage('find and load the right tool')],
247
+ workingDirectory,
248
+ sessionId: 'ses_deferred_enforcement' as SessionId,
249
+ threadId: 'thd_deferred_enforcement' as ThreadId,
250
+ projectId: 'prj_deferred_enforcement' as ProjectId,
251
+ tenantId: 'tnt_deferred_enforcement' as TenantId,
252
+ })
253
+
254
+ expect(run.status).toBe('completed')
255
+ expect(provider.calls).toHaveLength(2)
256
+ expect(provider.calls[0]?.tools?.map((tool) => tool.function.name)).toEqual([
257
+ SearchToolsTool.name,
258
+ ])
259
+ expect(provider.calls[0]?.enforceToolInputSchema).toBeUndefined()
260
+ expect(provider.calls[1]?.tools?.map((tool) => tool.function.name)).toEqual([
261
+ 'deferred_edit',
262
+ SearchToolsTool.name,
263
+ ])
264
+ expect(provider.calls[1]?.enforceToolInputSchema).toEqual(['deferred_edit'])
265
+ expect(provider.calls[1]?.tools?.[0]?.function.parameters).toEqual(
266
+ tools.get('deferred_edit')?.modelInputSchema,
267
+ )
268
+ })
269
+
159
270
  it('does not let search_tools reveal or activate deferred tools outside allowedTools', async () => {
160
271
  const tools = new ToolRegistry()
161
272
  registerDeferredDocumentTool(tools)
@@ -107,27 +107,27 @@ describe('query long-document tool flow', () => {
107
107
  name: 'write',
108
108
  input: {
109
109
  path: 'outputs/long-document-flow.md',
110
- content: '# Long document flow\n\n{{BODY}}\n',
110
+ content: '# Long document flow\n\n{{CHUNK_001}}\n',
111
111
  },
112
112
  },
113
- {
113
+ ...chunks.map((chunk, index) => ({
114
114
  name: 'edit',
115
115
  input: {
116
116
  path: 'outputs/long-document-flow.md',
117
- oldStr: '{{BODY}}',
118
- newStr: chunks[0],
117
+ old_string: `{{CHUNK_${String(index + 1).padStart(3, '0')}}}`,
118
+ new_string: `${chunk}\n{{CHUNK_${String(index + 2).padStart(3, '0')}}}`,
119
119
  replace_all: false,
120
120
  },
121
- },
122
- ...chunks.slice(1).map((chunk) => ({
121
+ })),
122
+ {
123
123
  name: 'edit',
124
124
  input: {
125
125
  path: 'outputs/long-document-flow.md',
126
- insertLine: 'end',
127
- newStr: chunk,
126
+ old_string: `{{CHUNK_${String(chunks.length + 1).padStart(3, '0')}}}`,
127
+ new_string: '',
128
128
  replace_all: false,
129
129
  },
130
- })),
130
+ },
131
131
  ])
132
132
  const tools = new ToolRegistry()
133
133
  tools.register(WriteFileTool)
@@ -169,9 +169,9 @@ describe('query long-document tool flow', () => {
169
169
 
170
170
  expect(run.status).toBe('completed')
171
171
  expect(run.result).toBe('Long document created and verified.')
172
- expect(provider.calls).toBe(6)
173
- expect(executingTools).toEqual(['write', 'edit', 'edit', 'edit', 'edit'])
174
- expect(final).not.toContain('{{BODY}}')
172
+ expect(provider.calls).toBe(7)
173
+ expect(executingTools).toEqual(['write', 'edit', 'edit', 'edit', 'edit', 'edit'])
174
+ expect(final).not.toContain('{{CHUNK_')
175
175
  expect(final.split('\n').length).toBeGreaterThan(160)
176
176
  expect(final).toContain('## Section 1')
177
177
  expect(final).toContain('## Section 4')
@@ -4,6 +4,7 @@ import { join } from 'node:path'
4
4
  import { afterEach, describe, expect, it, vi } from 'vitest'
5
5
  import { z } from 'zod'
6
6
 
7
+ import { ProviderRequestError } from '../../../provider/errors.js'
7
8
  import { ToolRegistry } from '../../../registry/tool/execute.js'
8
9
  import type { SessionId, TenantId } from '../../../types/ids/index.js'
9
10
  import { createUserMessage } from '../../../types/message/index.js'
@@ -72,6 +73,22 @@ class IdleDuringToolInputProvider implements LLMProvider {
72
73
  }
73
74
  }
74
75
 
76
+ class ClassifiedFailureProvider implements LLMProvider {
77
+ readonly id = 'classified-failure'
78
+ readonly name = 'Classified Failure Provider'
79
+
80
+ async *chatStream(): AsyncIterable<StreamChunk> {
81
+ yield await Promise.reject(
82
+ new ProviderRequestError({
83
+ kind: 'throttle',
84
+ providerId: 'classified-failure',
85
+ status: 429,
86
+ retryAfterMs: 2000,
87
+ }),
88
+ )
89
+ }
90
+ }
91
+
75
92
  describe('query stream recovery', () => {
76
93
  let workdirs: string[] = []
77
94
 
@@ -82,7 +99,10 @@ describe('query stream recovery', () => {
82
99
 
83
100
  it('turns an idle stream with partial tool JSON into retryable tool feedback', async () => {
84
101
  const provider = new IdleDuringToolInputProvider()
85
- const actualWrite = vi.fn(async () => ({ success: true, output: 'should not run' }))
102
+ const actualWrite = vi.fn(async () => ({
103
+ success: true,
104
+ output: 'should not run',
105
+ }))
86
106
  const tools = new ToolRegistry()
87
107
  tools.register({
88
108
  name: 'write_file',
@@ -150,7 +170,50 @@ describe('query stream recovery', () => {
150
170
  'call was cut off',
151
171
  )
152
172
  expect(completedTool?.type === 'tool_completed' ? completedTool.result : '').toContain(
153
- 'extend it with edit using insertLine',
173
+ 'advance that marker with bounded exact edit calls',
154
174
  )
155
175
  })
176
+
177
+ it('preserves classified provider metadata through the primary run boundary', async () => {
178
+ const workingDirectory = await mkdtemp(join(tmpdir(), 'namzu-provider-error-'))
179
+ workdirs.push(workingDirectory)
180
+ const events: RunEvent[] = []
181
+
182
+ const run = await drainQuery(
183
+ {
184
+ provider: new ClassifiedFailureProvider(),
185
+ tools: new ToolRegistry(),
186
+ runConfig: {
187
+ model: 'mock-model',
188
+ timeoutMs: 5_000,
189
+ tokenBudget: 100_000,
190
+ maxIterations: 1,
191
+ maxResponseTokens: 256,
192
+ },
193
+ agentId: 'agent_test',
194
+ agentName: 'Test Agent',
195
+ messages: [createUserMessage('fail with classified metadata')],
196
+ workingDirectory,
197
+ sessionId: 'ses_provider_error' as SessionId,
198
+ threadId: 'thd_provider_error' as ThreadId,
199
+ projectId: 'prj_provider_error' as ProjectId,
200
+ tenantId: 'tnt_provider_error' as TenantId,
201
+ },
202
+ (event) => {
203
+ events.push(event)
204
+ },
205
+ )
206
+
207
+ expect(run.status).toBe('failed')
208
+ expect(run.lastProviderError).toEqual({
209
+ kind: 'throttle',
210
+ providerId: 'classified-failure',
211
+ status: 429,
212
+ retryAfterMs: 2000,
213
+ })
214
+ expect(events.find((event) => event.type === 'run_failed')).toMatchObject({
215
+ type: 'run_failed',
216
+ providerError: run.lastProviderError,
217
+ })
218
+ })
156
219
  })
@@ -103,7 +103,7 @@ export class ToolExecutor {
103
103
 
104
104
  // Respect each tool's `concurrencySafe` flag. Read-only tools
105
105
  // (ls/grep/glob/…) run in parallel; tools that mutate shared state
106
- // (edit/write/append/bash — `concurrencySafe: false`) are serialized in
106
+ // (edit/write/bash — `concurrencySafe: false`) are serialized in
107
107
  // a single chain, so e.g. several `edit` calls to the SAME file in one
108
108
  // turn apply one-after-another instead of racing read→modify→write
109
109
  // (which let the last writer clobber the rest). Results are written by
@@ -497,5 +497,5 @@ function formatFailedToolOutput(output: string | undefined, error: string | unde
497
497
  }
498
498
 
499
499
  function truncatedToolInputMessage(toolName: string): string {
500
- return `Error: Tool "${toolName}" call was cut off while the model was streaming JSON arguments. The tool was NOT executed. Retry with a much shorter input. Self-budget any content/newStr payload under 12000 characters before calling file tools. For long files, create a short opening with write, then extend it with edit using insertLine: "end" in bounded section chunks; for delegated work, pass a shared workspace filename/reference instead of embedding the content in the tool call.`
500
+ return `Error: Tool "${toolName}" call was cut off while the model was streaming JSON arguments. The tool was NOT executed. Retry with a much shorter input. Self-budget content/new_string under 12000 characters before calling file tools. For long files, create a short opening with write and a deterministic marker, then advance that marker with bounded exact edit calls; for delegated work, pass a shared workspace filename/reference instead of embedding the content in the tool call.`
501
501
  }