@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.
- package/CHANGELOG.md +19 -0
- package/dist/registry/tool/execute.d.ts.map +1 -1
- package/dist/registry/tool/execute.js +40 -10
- package/dist/registry/tool/execute.js.map +1 -1
- package/dist/registry/tool/execute.test.js +93 -7
- package/dist/registry/tool/execute.test.js.map +1 -1
- package/dist/registry/toolset/catalog.d.ts.map +1 -1
- package/dist/registry/toolset/catalog.js +17 -4
- package/dist/registry/toolset/catalog.js.map +1 -1
- package/dist/registry/toolset/catalog.test.js +101 -0
- package/dist/registry/toolset/catalog.test.js.map +1 -1
- package/dist/runtime/query/__tests__/capability-negotiation.test.js +79 -0
- package/dist/runtime/query/__tests__/capability-negotiation.test.js.map +1 -1
- package/dist/runtime/query/__tests__/deferred-tools.test.js +100 -0
- package/dist/runtime/query/__tests__/deferred-tools.test.js.map +1 -1
- package/dist/runtime/query/__tests__/long-document-flow.test.js +12 -12
- package/dist/runtime/query/__tests__/long-document-flow.test.js.map +1 -1
- package/dist/runtime/query/__tests__/stream-recovery.test.js +1 -1
- package/dist/runtime/query/__tests__/stream-recovery.test.js.map +1 -1
- package/dist/runtime/query/executor.js +2 -2
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +10 -0
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/tools/builtins/__tests__/edit.test.js +206 -54
- package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
- package/dist/tools/builtins/__tests__/payload-budget.test.js +30 -16
- package/dist/tools/builtins/__tests__/payload-budget.test.js.map +1 -1
- package/dist/tools/builtins/__tests__/write-file.test.js +37 -13
- package/dist/tools/builtins/__tests__/write-file.test.js.map +1 -1
- package/dist/tools/builtins/atomic-write-file.d.ts +13 -0
- package/dist/tools/builtins/atomic-write-file.d.ts.map +1 -0
- package/dist/tools/builtins/atomic-write-file.js +46 -0
- package/dist/tools/builtins/atomic-write-file.js.map +1 -0
- package/dist/tools/builtins/bash.js +2 -2
- package/dist/tools/builtins/bash.js.map +1 -1
- package/dist/tools/builtins/edit.d.ts +2 -5
- package/dist/tools/builtins/edit.d.ts.map +1 -1
- package/dist/tools/builtins/edit.js +107 -125
- package/dist/tools/builtins/edit.js.map +1 -1
- package/dist/tools/builtins/file-mutation-lock.d.ts +7 -0
- package/dist/tools/builtins/file-mutation-lock.d.ts.map +1 -0
- package/dist/tools/builtins/file-mutation-lock.js +25 -0
- package/dist/tools/builtins/file-mutation-lock.js.map +1 -0
- package/dist/tools/builtins/index.js +2 -2
- package/dist/tools/builtins/index.js.map +1 -1
- package/dist/tools/builtins/write-file.d.ts +1 -2
- package/dist/tools/builtins/write-file.d.ts.map +1 -1
- package/dist/tools/builtins/write-file.js +66 -40
- package/dist/tools/builtins/write-file.js.map +1 -1
- package/dist/tools/defineTool.d.ts +2 -0
- package/dist/tools/defineTool.d.ts.map +1 -1
- package/dist/tools/defineTool.js +2 -0
- package/dist/tools/defineTool.js.map +1 -1
- package/dist/types/provider/chat.d.ts +9 -0
- package/dist/types/provider/chat.d.ts.map +1 -1
- package/dist/types/sandbox/index.d.ts +4 -0
- package/dist/types/sandbox/index.d.ts.map +1 -1
- package/dist/types/sandbox/index.js.map +1 -1
- package/dist/types/tool/index.d.ts +14 -0
- package/dist/types/tool/index.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/registry/tool/execute.test.ts +107 -7
- package/src/registry/tool/execute.ts +45 -10
- package/src/registry/toolset/catalog.test.ts +118 -0
- package/src/registry/toolset/catalog.ts +26 -4
- package/src/runtime/query/__tests__/capability-negotiation.test.ts +88 -0
- package/src/runtime/query/__tests__/deferred-tools.test.ts +111 -0
- package/src/runtime/query/__tests__/long-document-flow.test.ts +12 -12
- package/src/runtime/query/__tests__/stream-recovery.test.ts +1 -1
- package/src/runtime/query/executor.ts +2 -2
- package/src/runtime/query/iteration/index.ts +15 -0
- package/src/tools/builtins/__tests__/edit.test.ts +248 -64
- package/src/tools/builtins/__tests__/payload-budget.test.ts +40 -20
- package/src/tools/builtins/__tests__/write-file.test.ts +52 -13
- package/src/tools/builtins/atomic-write-file.ts +58 -0
- package/src/tools/builtins/bash.ts +2 -2
- package/src/tools/builtins/edit.ts +117 -163
- package/src/tools/builtins/file-mutation-lock.ts +27 -0
- package/src/tools/builtins/index.ts +2 -2
- package/src/tools/builtins/write-file.ts +68 -44
- package/src/tools/defineTool.ts +4 -0
- package/src/types/provider/chat.ts +14 -1
- package/src/types/sandbox/index.ts +4 -0
- 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({
|
|
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:
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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 =
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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:
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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{{
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
121
|
+
})),
|
|
122
|
+
{
|
|
123
123
|
name: 'edit',
|
|
124
124
|
input: {
|
|
125
125
|
path: 'outputs/long-document-flow.md',
|
|
126
|
-
|
|
127
|
-
|
|
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(
|
|
173
|
-
expect(executingTools).toEqual(['write', 'edit', 'edit', 'edit', 'edit'])
|
|
174
|
-
expect(final).not.toContain('{{
|
|
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
|
-
'
|
|
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/
|
|
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
|
|
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
|
+
}
|