@namzu/sdk 1.4.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 (85) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/registry/tool/execute.d.ts.map +1 -1
  3. package/dist/registry/tool/execute.js +40 -10
  4. package/dist/registry/tool/execute.js.map +1 -1
  5. package/dist/registry/tool/execute.test.js +93 -7
  6. package/dist/registry/tool/execute.test.js.map +1 -1
  7. package/dist/registry/toolset/catalog.d.ts.map +1 -1
  8. package/dist/registry/toolset/catalog.js +17 -4
  9. package/dist/registry/toolset/catalog.js.map +1 -1
  10. package/dist/registry/toolset/catalog.test.js +101 -0
  11. package/dist/registry/toolset/catalog.test.js.map +1 -1
  12. package/dist/runtime/query/__tests__/capability-negotiation.test.js +79 -0
  13. package/dist/runtime/query/__tests__/capability-negotiation.test.js.map +1 -1
  14. package/dist/runtime/query/__tests__/deferred-tools.test.js +100 -0
  15. package/dist/runtime/query/__tests__/deferred-tools.test.js.map +1 -1
  16. package/dist/runtime/query/__tests__/long-document-flow.test.js +12 -12
  17. package/dist/runtime/query/__tests__/long-document-flow.test.js.map +1 -1
  18. package/dist/runtime/query/__tests__/stream-recovery.test.js +1 -1
  19. package/dist/runtime/query/__tests__/stream-recovery.test.js.map +1 -1
  20. package/dist/runtime/query/executor.js +2 -2
  21. package/dist/runtime/query/executor.js.map +1 -1
  22. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  23. package/dist/runtime/query/iteration/index.js +10 -0
  24. package/dist/runtime/query/iteration/index.js.map +1 -1
  25. package/dist/tools/builtins/__tests__/edit.test.js +206 -54
  26. package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
  27. package/dist/tools/builtins/__tests__/payload-budget.test.js +30 -16
  28. package/dist/tools/builtins/__tests__/payload-budget.test.js.map +1 -1
  29. package/dist/tools/builtins/__tests__/write-file.test.js +37 -13
  30. package/dist/tools/builtins/__tests__/write-file.test.js.map +1 -1
  31. package/dist/tools/builtins/atomic-write-file.d.ts +13 -0
  32. package/dist/tools/builtins/atomic-write-file.d.ts.map +1 -0
  33. package/dist/tools/builtins/atomic-write-file.js +46 -0
  34. package/dist/tools/builtins/atomic-write-file.js.map +1 -0
  35. package/dist/tools/builtins/bash.js +2 -2
  36. package/dist/tools/builtins/bash.js.map +1 -1
  37. package/dist/tools/builtins/edit.d.ts +2 -5
  38. package/dist/tools/builtins/edit.d.ts.map +1 -1
  39. package/dist/tools/builtins/edit.js +107 -125
  40. package/dist/tools/builtins/edit.js.map +1 -1
  41. package/dist/tools/builtins/file-mutation-lock.d.ts +7 -0
  42. package/dist/tools/builtins/file-mutation-lock.d.ts.map +1 -0
  43. package/dist/tools/builtins/file-mutation-lock.js +25 -0
  44. package/dist/tools/builtins/file-mutation-lock.js.map +1 -0
  45. package/dist/tools/builtins/index.js +2 -2
  46. package/dist/tools/builtins/index.js.map +1 -1
  47. package/dist/tools/builtins/write-file.d.ts +1 -2
  48. package/dist/tools/builtins/write-file.d.ts.map +1 -1
  49. package/dist/tools/builtins/write-file.js +66 -40
  50. package/dist/tools/builtins/write-file.js.map +1 -1
  51. package/dist/tools/defineTool.d.ts +2 -0
  52. package/dist/tools/defineTool.d.ts.map +1 -1
  53. package/dist/tools/defineTool.js +2 -0
  54. package/dist/tools/defineTool.js.map +1 -1
  55. package/dist/types/provider/chat.d.ts +9 -0
  56. package/dist/types/provider/chat.d.ts.map +1 -1
  57. package/dist/types/sandbox/index.d.ts +4 -0
  58. package/dist/types/sandbox/index.d.ts.map +1 -1
  59. package/dist/types/sandbox/index.js.map +1 -1
  60. package/dist/types/tool/index.d.ts +14 -0
  61. package/dist/types/tool/index.d.ts.map +1 -1
  62. package/package.json +2 -1
  63. package/src/registry/tool/execute.test.ts +107 -7
  64. package/src/registry/tool/execute.ts +45 -10
  65. package/src/registry/toolset/catalog.test.ts +118 -0
  66. package/src/registry/toolset/catalog.ts +26 -4
  67. package/src/runtime/query/__tests__/capability-negotiation.test.ts +88 -0
  68. package/src/runtime/query/__tests__/deferred-tools.test.ts +111 -0
  69. package/src/runtime/query/__tests__/long-document-flow.test.ts +12 -12
  70. package/src/runtime/query/__tests__/stream-recovery.test.ts +1 -1
  71. package/src/runtime/query/executor.ts +2 -2
  72. package/src/runtime/query/iteration/index.ts +15 -0
  73. package/src/tools/builtins/__tests__/edit.test.ts +248 -64
  74. package/src/tools/builtins/__tests__/payload-budget.test.ts +40 -20
  75. package/src/tools/builtins/__tests__/write-file.test.ts +52 -13
  76. package/src/tools/builtins/atomic-write-file.ts +58 -0
  77. package/src/tools/builtins/bash.ts +2 -2
  78. package/src/tools/builtins/edit.ts +117 -163
  79. package/src/tools/builtins/file-mutation-lock.ts +27 -0
  80. package/src/tools/builtins/index.ts +2 -2
  81. package/src/tools/builtins/write-file.ts +68 -44
  82. package/src/tools/defineTool.ts +4 -0
  83. package/src/types/provider/chat.ts +14 -1
  84. package/src/types/sandbox/index.ts +4 -0
  85. package/src/types/tool/index.ts +14 -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
  })
@@ -468,16 +479,40 @@ export function toolDiscoveryHint(description: string, maxLength = 100): string
468
479
  */
469
480
  function listArgumentNames(tool: ToolDefinition): string[] {
470
481
  try {
471
- const json = zodToJsonSchema(tool.inputSchema, {
472
- target: 'jsonSchema7',
473
- $refStrategy: 'none',
474
- }) as { properties?: Record<string, unknown> }
475
- 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())
476
489
  } catch {
477
490
  return []
478
491
  }
479
492
  }
480
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
+
481
516
  /**
482
517
  * Build a one-sentence "Required: <field>: <type>, <field>: <type>"
483
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')
@@ -170,7 +170,7 @@ describe('query stream recovery', () => {
170
170
  'call was cut off',
171
171
  )
172
172
  expect(completedTool?.type === 'tool_completed' ? completedTool.result : '').toContain(
173
- 'extend it with edit using insertLine',
173
+ 'advance that marker with bounded exact edit calls',
174
174
  )
175
175
  })
176
176
 
@@ -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
  }
@@ -6,6 +6,7 @@ import { GENAI, NAMZU, agentIterationSpanName } from '../../../telemetry/attribu
6
6
  import { getTracer } from '../../../telemetry/runtime-accessors.js'
7
7
  import { createAssistantMessage, createUserMessage } from '../../../types/message/index.js'
8
8
  import type { RunEvent, StopReason } from '../../../types/run/index.js'
9
+ import type { LLMToolSchema, ToolRegistryContract } from '../../../types/tool/index.js'
9
10
  import { toErrorMessage } from '../../../utils/error.js'
10
11
  import { generateMessageId } from '../../../utils/id.js'
11
12
  import { applyLifecycleHookResults } from '../plugin-hooks.js'
@@ -118,6 +119,7 @@ export class IterationOrchestrator {
118
119
  // risks a 400 because the history still carries
119
120
  // tool_use/tool_result blocks.
120
121
  const openAITools = this.ctx.tools.toLLMTools(this.ctx.allowedTools)
122
+ const enforceToolInputSchema = enforcedModelInputToolNames(this.ctx.tools, openAITools)
121
123
 
122
124
  const messages = forceFinalize
123
125
  ? [
@@ -152,6 +154,7 @@ export class IterationOrchestrator {
152
154
  model,
153
155
  messages,
154
156
  tools: openAITools.length > 0 ? openAITools : undefined,
157
+ enforceToolInputSchema,
155
158
  toolChoice: forceFinalize && openAITools.length > 0 ? 'none' : undefined,
156
159
  temperature: runConfig.temperature,
157
160
  maxTokens: runConfig.maxResponseTokens,
@@ -428,11 +431,13 @@ export class IterationOrchestrator {
428
431
  // tools param identical to prior iterations (cache prefix intact,
429
432
  // no 400 on tool blocks in history) and forbid use via tool_choice.
430
433
  const finalTools = this.ctx.tools.toLLMTools(this.ctx.allowedTools)
434
+ const enforceToolInputSchema = enforcedModelInputToolNames(this.ctx.tools, finalTools)
431
435
  const response = await collect(
432
436
  this.ctx.provider.chatStream({
433
437
  model,
434
438
  messages: finalMessages,
435
439
  tools: finalTools.length > 0 ? finalTools : undefined,
440
+ enforceToolInputSchema,
436
441
  toolChoice: finalTools.length > 0 ? 'none' : undefined,
437
442
  temperature: this.ctx.runConfig.temperature,
438
443
  maxTokens: this.ctx.runConfig.maxResponseTokens,
@@ -471,3 +476,13 @@ export class IterationOrchestrator {
471
476
  }
472
477
  }
473
478
  }
479
+
480
+ function enforcedModelInputToolNames(
481
+ registry: ToolRegistryContract,
482
+ tools: readonly LLMToolSchema[],
483
+ ): readonly string[] | undefined {
484
+ const names = tools
485
+ .map((tool) => tool.function.name)
486
+ .filter((name) => registry.get(name)?.enforceModelInput === true)
487
+ return names.length > 0 ? names : undefined
488
+ }