@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.
- package/CHANGELOG.md +106 -0
- package/dist/compaction/__tests__/verifier-empty-reply.test.d.ts +12 -0
- package/dist/compaction/__tests__/verifier-empty-reply.test.d.ts.map +1 -0
- package/dist/compaction/__tests__/verifier-empty-reply.test.js +73 -0
- package/dist/compaction/__tests__/verifier-empty-reply.test.js.map +1 -0
- package/dist/compaction/verifier.d.ts.map +1 -1
- package/dist/compaction/verifier.js +10 -1
- package/dist/compaction/verifier.js.map +1 -1
- package/dist/config/__tests__/compaction-budget-schema.test.d.ts +18 -0
- package/dist/config/__tests__/compaction-budget-schema.test.d.ts.map +1 -0
- package/dist/config/__tests__/compaction-budget-schema.test.js +96 -0
- package/dist/config/__tests__/compaction-budget-schema.test.js.map +1 -0
- package/dist/config/runtime.d.ts.map +1 -1
- package/dist/config/runtime.js +20 -11
- package/dist/config/runtime.js.map +1 -1
- package/dist/manager/run/persistence.d.ts +2 -1
- package/dist/manager/run/persistence.d.ts.map +1 -1
- package/dist/manager/run/persistence.js +3 -1
- package/dist/manager/run/persistence.js.map +1 -1
- package/dist/provider/__tests__/errors.test.d.ts +2 -0
- package/dist/provider/__tests__/errors.test.d.ts.map +1 -0
- package/dist/provider/__tests__/errors.test.js +61 -0
- package/dist/provider/__tests__/errors.test.js.map +1 -0
- package/dist/provider/errors.d.ts +108 -0
- package/dist/provider/errors.d.ts.map +1 -0
- package/dist/provider/errors.js +311 -0
- package/dist/provider/errors.js.map +1 -0
- package/dist/provider/index.d.ts +2 -0
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/provider/index.js +1 -0
- package/dist/provider/index.js.map +1 -1
- package/dist/public-runtime.d.ts +11 -11
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +11 -11
- package/dist/public-runtime.js.map +1 -1
- package/dist/registry/tool/execute.d.ts.map +1 -1
- package/dist/registry/tool/execute.js +45 -12
- package/dist/registry/tool/execute.js.map +1 -1
- package/dist/registry/tool/execute.test.js +104 -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 +55 -2
- 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/runtime/query/iteration/phases/compaction-safe-cut.test.d.ts +32 -0
- package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.d.ts.map +1 -0
- package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.js +152 -0
- package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.js.map +1 -0
- package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.js +52 -12
- package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
- package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
- package/dist/runtime/query/iteration/stream-turn.js +11 -5
- package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
- package/dist/runtime/query/result.d.ts.map +1 -1
- package/dist/runtime/query/result.js +11 -1
- package/dist/runtime/query/result.js.map +1 -1
- package/dist/tools/builtins/__tests__/edit.test.js +216 -10
- 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 +105 -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/coordinator/__tests__/task-list.test.js +14 -0
- package/dist/tools/coordinator/__tests__/task-list.test.js.map +1 -1
- package/dist/tools/coordinator/agent.d.ts +6 -8
- package/dist/tools/coordinator/agent.d.ts.map +1 -1
- package/dist/tools/coordinator/agent.js.map +1 -1
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +11 -32
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/tools/defineTool.d.ts +3 -0
- package/dist/tools/defineTool.d.ts.map +1 -1
- package/dist/tools/defineTool.js +3 -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/provider/error.d.ts +22 -0
- package/dist/types/provider/error.d.ts.map +1 -0
- package/dist/types/provider/error.js +2 -0
- package/dist/types/provider/error.js.map +1 -0
- package/dist/types/provider/index.d.ts +1 -0
- package/dist/types/provider/index.d.ts.map +1 -1
- package/dist/types/run/entity.d.ts +2 -0
- package/dist/types/run/entity.d.ts.map +1 -1
- package/dist/types/run/events.d.ts +2 -0
- package/dist/types/run/events.d.ts.map +1 -1
- package/dist/types/run/events.js.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 +20 -0
- package/dist/types/tool/index.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/compaction/__tests__/verifier-empty-reply.test.ts +96 -0
- package/src/compaction/verifier.ts +10 -1
- package/src/config/__tests__/compaction-budget-schema.test.ts +114 -0
- package/src/config/runtime.ts +20 -11
- package/src/manager/run/persistence.ts +3 -1
- package/src/provider/__tests__/errors.test.ts +85 -0
- package/src/provider/errors.ts +344 -0
- package/src/provider/index.ts +15 -0
- package/src/public-runtime.ts +53 -10
- package/src/registry/tool/execute.test.ts +121 -7
- package/src/registry/tool/execute.ts +50 -12
- 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 +65 -2
- package/src/runtime/query/executor.ts +2 -2
- package/src/runtime/query/iteration/index.ts +15 -0
- package/src/runtime/query/iteration/phases/compaction-safe-cut.test.ts +186 -0
- package/src/runtime/query/iteration/phases/compaction.ts +54 -12
- package/src/runtime/query/iteration/stream-turn.ts +11 -6
- package/src/runtime/query/result.ts +11 -1
- package/src/tools/builtins/__tests__/edit.test.ts +270 -10
- 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 +116 -162
- 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/coordinator/__tests__/task-list.test.ts +16 -0
- package/src/tools/coordinator/agent.ts +6 -8
- package/src/tools/coordinator/index.ts +11 -34
- package/src/tools/defineTool.ts +6 -0
- package/src/types/provider/chat.ts +14 -1
- package/src/types/provider/error.ts +29 -0
- package/src/types/provider/index.ts +5 -0
- package/src/types/run/entity.ts +2 -0
- package/src/types/run/events.ts +7 -1
- package/src/types/sandbox/index.ts +4 -0
- 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({
|
|
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
|
})
|
|
@@ -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 =
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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:
|
|
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')
|
|
@@ -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 () => ({
|
|
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
|
-
'
|
|
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/
|
|
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
|
}
|