@cogitator-ai/self-modifying 19.2.1 → 19.3.1

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 (53) hide show
  1. package/README.md +23 -6
  2. package/dist/architecture-evolution/capability-analyzer.d.ts +3 -0
  3. package/dist/architecture-evolution/capability-analyzer.d.ts.map +1 -1
  4. package/dist/architecture-evolution/capability-analyzer.js +3 -1
  5. package/dist/architecture-evolution/capability-analyzer.js.map +1 -1
  6. package/dist/architecture-evolution/parameter-optimizer.d.ts +6 -0
  7. package/dist/architecture-evolution/parameter-optimizer.d.ts.map +1 -1
  8. package/dist/architecture-evolution/parameter-optimizer.js +5 -2
  9. package/dist/architecture-evolution/parameter-optimizer.js.map +1 -1
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/self-modifying-agent.d.ts +6 -0
  14. package/dist/self-modifying-agent.d.ts.map +1 -1
  15. package/dist/self-modifying-agent.js +12 -2
  16. package/dist/self-modifying-agent.js.map +1 -1
  17. package/dist/tool-generation/gap-analyzer.d.ts +13 -4
  18. package/dist/tool-generation/gap-analyzer.d.ts.map +1 -1
  19. package/dist/tool-generation/gap-analyzer.js +5 -2
  20. package/dist/tool-generation/gap-analyzer.js.map +1 -1
  21. package/dist/tool-generation/index.d.ts +2 -2
  22. package/dist/tool-generation/index.d.ts.map +1 -1
  23. package/dist/tool-generation/index.js.map +1 -1
  24. package/dist/tool-generation/prompts.d.ts +7 -2
  25. package/dist/tool-generation/prompts.d.ts.map +1 -1
  26. package/dist/tool-generation/prompts.js +71 -13
  27. package/dist/tool-generation/prompts.js.map +1 -1
  28. package/dist/tool-generation/tool-generator.d.ts +6 -0
  29. package/dist/tool-generation/tool-generator.d.ts.map +1 -1
  30. package/dist/tool-generation/tool-generator.js +26 -2
  31. package/dist/tool-generation/tool-generator.js.map +1 -1
  32. package/dist/tool-generation/tool-sandbox.d.ts.map +1 -1
  33. package/dist/tool-generation/tool-sandbox.js +14 -2
  34. package/dist/tool-generation/tool-sandbox.js.map +1 -1
  35. package/dist/tool-generation/tool-validator.d.ts +16 -0
  36. package/dist/tool-generation/tool-validator.d.ts.map +1 -1
  37. package/dist/tool-generation/tool-validator.js +110 -17
  38. package/dist/tool-generation/tool-validator.js.map +1 -1
  39. package/package.json +2 -2
  40. package/src/__tests__/sandbox-security.test.ts +12 -0
  41. package/src/__tests__/self-modifying-agent.test.ts +52 -0
  42. package/src/__tests__/tool-generation.test.ts +22 -0
  43. package/src/__tests__/tool-review-regressions.test.ts +473 -0
  44. package/src/architecture-evolution/capability-analyzer.ts +5 -1
  45. package/src/architecture-evolution/parameter-optimizer.ts +10 -2
  46. package/src/index.ts +2 -0
  47. package/src/self-modifying-agent.ts +18 -2
  48. package/src/tool-generation/gap-analyzer.ts +24 -11
  49. package/src/tool-generation/index.ts +2 -1
  50. package/src/tool-generation/prompts.ts +78 -14
  51. package/src/tool-generation/tool-generator.ts +33 -2
  52. package/src/tool-generation/tool-sandbox.ts +14 -2
  53. package/src/tool-generation/tool-validator.ts +121 -20
@@ -14,27 +14,38 @@ export interface GapAnalyzerOptions {
14
14
  config: ToolSelfGenerationConfig;
15
15
  /** Model the LLM calls use, without the provider prefix of the backend */
16
16
  model: string;
17
+ /** Upper bound on the output tokens of the analysis call. Unset leaves the backend default. */
18
+ maxTokens?: number;
19
+ }
20
+
21
+ export interface GapAnalysisContext {
22
+ failedAttempts?: string[];
23
+ previousGaps?: CapabilityGap[];
24
+ /**
25
+ * The agent's instructions. Shown to the analyzer, so a capability the instructions require
26
+ * a tool for counts as a gap even when the model could answer on its own.
27
+ */
28
+ instructions?: string;
17
29
  }
18
30
 
19
31
  export class GapAnalyzer {
20
32
  private readonly llm: LLMBackend;
21
33
  private readonly config: ToolSelfGenerationConfig;
22
34
  private readonly model: string;
35
+ private readonly maxTokens?: number;
23
36
  private readonly analysisCache = new Map<string, GapAnalysisResult>();
24
37
 
25
38
  constructor(options: GapAnalyzerOptions) {
26
39
  this.llm = options.llm;
27
40
  this.config = options.config;
28
41
  this.model = options.model;
42
+ this.maxTokens = options.maxTokens;
29
43
  }
30
44
 
31
45
  async analyze(
32
46
  userIntent: string,
33
47
  availableTools: Tool[],
34
- context?: {
35
- failedAttempts?: string[];
36
- previousGaps?: CapabilityGap[];
37
- }
48
+ context?: GapAnalysisContext
38
49
  ): Promise<GapAnalysisResult> {
39
50
  const cacheKey = this.buildCacheKey(userIntent, availableTools, context);
40
51
  const cached = this.analysisCache.get(cacheKey);
@@ -47,7 +58,12 @@ export class GapAnalyzer {
47
58
  description: t.description,
48
59
  }));
49
60
 
50
- const prompt = buildGapAnalysisPrompt(userIntent, toolSummaries, context?.failedAttempts);
61
+ const prompt = buildGapAnalysisPrompt(
62
+ userIntent,
63
+ toolSummaries,
64
+ context?.failedAttempts,
65
+ context?.instructions
66
+ );
51
67
 
52
68
  const content = await llmChat(
53
69
  this.llm,
@@ -61,7 +77,7 @@ Consider tool composition before suggesting new tools.`,
61
77
  },
62
78
  { role: 'user', content: prompt },
63
79
  ],
64
- { model: this.model, temperature: 0.3 }
80
+ { model: this.model, temperature: 0.3, maxTokens: this.maxTokens }
65
81
  );
66
82
 
67
83
  const parsed = parseGapAnalysisResponse(content);
@@ -220,11 +236,7 @@ Consider tool composition before suggesting new tools.`,
220
236
  return `Identified ${gaps.length} capability gap(s):\n${gapDescriptions}`;
221
237
  }
222
238
 
223
- private buildCacheKey(
224
- userIntent: string,
225
- tools: Tool[],
226
- context?: { failedAttempts?: string[]; previousGaps?: CapabilityGap[] }
227
- ): string {
239
+ private buildCacheKey(userIntent: string, tools: Tool[], context?: GapAnalysisContext): string {
228
240
  const toolSignature = tools
229
241
  .map((t) => `${t.name}:${t.description}`)
230
242
  .sort()
@@ -234,6 +246,7 @@ Consider tool composition before suggesting new tools.`,
234
246
  toolSignature,
235
247
  context?.failedAttempts ?? [],
236
248
  (context?.previousGaps ?? []).map((g) => g.id).sort(),
249
+ context?.instructions ?? '',
237
250
  ]);
238
251
  return createHash('sha256').update(raw).digest('hex');
239
252
  }
@@ -1,4 +1,4 @@
1
- export { GapAnalyzer, type GapAnalyzerOptions } from './gap-analyzer';
1
+ export { GapAnalyzer, type GapAnalyzerOptions, type GapAnalysisContext } from './gap-analyzer';
2
2
  export {
3
3
  ToolGenerator,
4
4
  type ToolGeneratorOptions,
@@ -26,4 +26,5 @@ export {
26
26
  parseToolGenerationResponse,
27
27
  parseValidationResponse,
28
28
  TOOL_GENERATION_SYSTEM_PROMPT,
29
+ type ToolReview,
29
30
  } from './prompts';
@@ -1,4 +1,5 @@
1
1
  import type { CapabilityGap, GeneratedTool, ToolValidationResult } from '@cogitator-ai/types';
2
+ import { Script } from 'node:vm';
2
3
  import { extractJson } from '../utils';
3
4
 
4
5
  export const TOOL_GENERATION_SYSTEM_PROMPT = `You generate JavaScript tools. Respond with ONLY a JSON object, no other text.
@@ -35,9 +36,16 @@ function sanitizeToolName(name: string): string {
35
36
  export function buildGapAnalysisPrompt(
36
37
  userIntent: string,
37
38
  availableTools: Array<{ name: string; description: string }>,
38
- failedAttempts?: string[]
39
+ failedAttempts?: string[],
40
+ agentInstructions?: string
39
41
  ): string {
40
- const toolList = availableTools.map((t) => `- ${t.name}: ${t.description}`).join('\n');
42
+ const toolList = availableTools.length
43
+ ? availableTools.map((t) => `- ${t.name}: ${t.description}`).join('\n')
44
+ : '(none)';
45
+
46
+ const instructionsSection = agentInstructions?.trim()
47
+ ? `\nAGENT INSTRUCTIONS (the agent must follow these, so a capability they require a tool for is a gap when no tool provides it, even if the model could answer on its own):\n${agentInstructions.trim()}\n`
48
+ : '';
41
49
 
42
50
  const failureContext = failedAttempts?.length
43
51
  ? `\n\nPrevious failed attempts:\n${failedAttempts.map((f) => `- ${f}`).join('\n')}`
@@ -47,8 +55,8 @@ export function buildGapAnalysisPrompt(
47
55
 
48
56
  USER INTENT:
49
57
  ${userIntent}
50
-
51
- AVAILABLE TOOLS:
58
+ ${instructionsSection}
59
+ AVAILABLE TOOLS (each one works as described, including tools generated earlier; a capability a listed tool already provides is not a gap, whatever the tool is called):
52
60
  ${toolList}
53
61
  ${failureContext}
54
62
 
@@ -82,7 +90,7 @@ export function buildToolGenerationPrompt(
82
90
  }
83
91
  ): string {
84
92
  const toolName = sanitizeToolName(gap.suggestedToolName);
85
- const description = sanitizeForPrompt(gap.description);
93
+ const task = sanitizeForPrompt(gap.description);
86
94
  const capability = sanitizeForPrompt(gap.requiredCapability);
87
95
 
88
96
  const existingToolsSection =
@@ -121,11 +129,14 @@ export function buildToolGenerationPrompt(
121
129
  : '';
122
130
 
123
131
  return `Create a tool named "${toolName}".
124
- Task: ${description}
132
+ Task: ${task}
125
133
  Formula/Logic: ${capability}
126
134
  ${existingToolsSection}${constraintsText}${parametersText}
135
+ "description" tells an agent what the tool does: one sentence naming its input and its output, not the task above.
136
+ "examples" lists 2 or 3 realistic valid inputs the tool must accept without throwing.
137
+
127
138
  Respond with ONLY this JSON (no other text):
128
- {"name":"${toolName}","description":"${description}","implementation":"async function execute(params) { /* YOUR CODE HERE — throw on invalid input */ }","parameters":${parametersJson},"reasoning":"explanation"}`;
139
+ {"name":"${toolName}","description":"WHAT THE TOOL DOES","implementation":"async function execute(params) { /* YOUR CODE HERE - throw on invalid input */ }","parameters":${parametersJson},"examples":[{"input":{/* VALID PARAMS */}}],"reasoning":"explanation"}`;
129
140
  }
130
141
 
131
142
  export function buildToolValidationPrompt(
@@ -199,9 +210,10 @@ IMPORTANT: Use plain JavaScript only (NO TypeScript, NO type annotations). The e
199
210
  Respond with the same JSON format as before:
200
211
  {
201
212
  "name": "${tool.name}",
202
- "description": "Updated description if needed",
213
+ "description": "What the tool does, its input and its output",
203
214
  "implementation": "async function execute(params) { ... }",
204
215
  "parameters": { ... },
216
+ "examples": [{ "input": { ... } }],
205
217
  "reasoning": "What was changed and why"
206
218
  }`;
207
219
  }
@@ -315,23 +327,72 @@ function normalizeParameters(value: unknown): Record<string, unknown> {
315
327
  return normalized;
316
328
  }
317
329
 
330
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
331
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
332
+ }
333
+
334
+ /** Example inputs from a generation response, as `{ input }` entries. Bare input objects are accepted. */
335
+ function parseExamples(value: unknown): Array<{ input: Record<string, unknown> }> {
336
+ if (!Array.isArray(value)) return [];
337
+ return value.flatMap((entry: unknown) => {
338
+ if (!isPlainObject(entry)) return [];
339
+ if ('input' in entry) return isPlainObject(entry.input) ? [{ input: entry.input }] : [];
340
+ return [{ input: entry }];
341
+ });
342
+ }
343
+
344
+ function compiles(code: string): boolean {
345
+ try {
346
+ new Script(`(async function () {\n${code}\n})`);
347
+ return true;
348
+ } catch {
349
+ return false;
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Some models escape the code twice, so it arrives on one line with literal `\n` and `\"`
355
+ * sequences that do not compile. Such code is unescaped once, and kept as is when that does not
356
+ * make it compile.
357
+ */
358
+ function repairEscapedImplementation(code: string): string {
359
+ if (code.includes('\n') || !/\\[nrt"]/.test(code) || compiles(code)) return code;
360
+ try {
361
+ const unescaped: unknown = JSON.parse(`"${code}"`);
362
+ return typeof unescaped === 'string' && compiles(unescaped) ? unescaped : code;
363
+ } catch {
364
+ return code;
365
+ }
366
+ }
367
+
318
368
  function buildToolFromParsed(parsed: Record<string, unknown>): GeneratedTool {
369
+ const examples = parseExamples(parsed.examples);
370
+ const metadata: Record<string, unknown> = {};
371
+ if (parsed.reasoning) metadata.reasoning = String(parsed.reasoning);
372
+ if (examples.length > 0) metadata.examples = examples;
373
+
319
374
  return {
320
375
  id: `gen_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
321
376
  name: sanitizeToolName(String(parsed.name)),
322
- description: String(parsed.description || ''),
323
- implementation: String(parsed.implementation),
377
+ description: typeof parsed.description === 'string' ? parsed.description.trim() : '',
378
+ implementation: repairEscapedImplementation(String(parsed.implementation)),
324
379
  parameters: normalizeParameters(parsed.parameters),
325
380
  createdAt: new Date(),
326
381
  version: 1,
327
382
  status: 'pending_validation',
328
- metadata: {
329
- reasoning: parsed.reasoning ? String(parsed.reasoning) : undefined,
330
- },
383
+ metadata,
331
384
  };
332
385
  }
333
386
 
334
- export function parseValidationResponse(response: string): ToolValidationResult | null {
387
+ /** A reviewer's verdict on a generated tool. */
388
+ export interface ToolReview extends ToolValidationResult {
389
+ /** What the reviewer wants done with the tool, when it said so */
390
+ recommendation?: 'approve' | 'revise' | 'reject';
391
+ }
392
+
393
+ const RECOMMENDATIONS = new Set(['approve', 'revise', 'reject']);
394
+
395
+ export function parseValidationResponse(response: string): ToolReview | null {
335
396
  const json = extractJson(response);
336
397
  if (!json) {
337
398
  return null;
@@ -355,6 +416,9 @@ export function parseValidationResponse(response: string): ToolValidationResult
355
416
  }))
356
417
  : [],
357
418
  overallScore: typeof parsed.overallScore === 'number' ? parsed.overallScore : 0,
419
+ ...(RECOMMENDATIONS.has(parsed.recommendation) && {
420
+ recommendation: parsed.recommendation as ToolReview['recommendation'],
421
+ }),
358
422
  };
359
423
  } catch {
360
424
  return null;
@@ -23,6 +23,11 @@ export interface ToolGeneratorOptions {
23
23
  config: ToolSelfGenerationConfig;
24
24
  /** Model the LLM calls use, without the provider prefix of the backend */
25
25
  model: string;
26
+ /**
27
+ * Upper bound on the output tokens of each generation, improvement and review call.
28
+ * Unset leaves the backend default.
29
+ */
30
+ maxTokens?: number;
26
31
  }
27
32
 
28
33
  export interface GenerateOptions {
@@ -41,6 +46,7 @@ export class ToolGenerator {
41
46
  private readonly llm: LLMBackend;
42
47
  private readonly config: ToolSelfGenerationConfig;
43
48
  private readonly model: string;
49
+ private readonly maxTokens?: number;
44
50
  private readonly validator: ToolValidator;
45
51
  private readonly sandbox: ToolSandbox;
46
52
 
@@ -48,10 +54,12 @@ export class ToolGenerator {
48
54
  this.llm = options.llm;
49
55
  this.config = options.config;
50
56
  this.model = options.model;
57
+ this.maxTokens = options.maxTokens;
51
58
  this.validator = new ToolValidator({
52
59
  llm: options.llm,
53
60
  config: options.config,
54
61
  model: this.model,
62
+ maxTokens: options.maxTokens,
55
63
  });
56
64
  this.sandbox = new ToolSandbox(options.config.sandboxConfig);
57
65
  }
@@ -86,6 +94,8 @@ export class ToolGenerator {
86
94
  continue;
87
95
  }
88
96
 
97
+ currentTool.description = describeTool(currentTool.description, gap);
98
+
89
99
  if (options.parameters) {
90
100
  currentTool.parameters = options.parameters;
91
101
  }
@@ -183,7 +193,7 @@ export class ToolGenerator {
183
193
  { role: 'system', content: TOOL_GENERATION_SYSTEM_PROMPT },
184
194
  { role: 'user', content: prompt },
185
195
  ],
186
- { model: this.model, temperature: 0.4 }
196
+ { model: this.model, temperature: 0.4, maxTokens: this.maxTokens }
187
197
  );
188
198
 
189
199
  const tool = parseToolGenerationResponse(content);
@@ -192,6 +202,7 @@ export class ToolGenerator {
192
202
  tool.metadata = {
193
203
  ...tool.metadata,
194
204
  gapId: gap.id,
205
+ capability: gap.requiredCapability,
195
206
  complexity: gap.complexity,
196
207
  };
197
208
  }
@@ -212,12 +223,13 @@ export class ToolGenerator {
212
223
  { role: 'system', content: TOOL_GENERATION_SYSTEM_PROMPT },
213
224
  { role: 'user', content: prompt },
214
225
  ],
215
- { model: this.model, temperature: 0.3 }
226
+ { model: this.model, temperature: 0.3, maxTokens: this.maxTokens }
216
227
  );
217
228
 
218
229
  const improved = parseToolGenerationResponse(content);
219
230
 
220
231
  if (improved) {
232
+ improved.description ||= tool.description;
221
233
  improved.id = tool.id;
222
234
  improved.name = tool.name;
223
235
  improved.createdAt = tool.createdAt;
@@ -268,6 +280,25 @@ export class ToolGenerator {
268
280
  }
269
281
  }
270
282
 
283
+ /**
284
+ * What an agent is told the tool does. The model writes it, but a copy of the gap text
285
+ * ("no tool can ...") or an empty one would make the next gap analysis think the
286
+ * capability is still missing, so those fall back to the capability the tool provides.
287
+ */
288
+ function describeTool(description: string, gap: CapabilityGap): string {
289
+ const written = description.trim();
290
+ const echoesGap = normalize(written) === normalize(gap.description);
291
+ if (written && !echoesGap) return written;
292
+ return gap.requiredCapability.trim() || gap.description.trim() || written;
293
+ }
294
+
295
+ function normalize(text: string): string {
296
+ return text
297
+ .toLowerCase()
298
+ .replace(/[^a-z0-9]+/g, ' ')
299
+ .trim();
300
+ }
301
+
271
302
  function toToolJsonSchema(parameters: Record<string, unknown>): ToolSchema['parameters'] {
272
303
  const properties =
273
304
  parameters.type === 'object' &&
@@ -85,10 +85,22 @@ const context = vm.createContext(Object.create(null), {
85
85
  codeGeneration: { strings: false, wasm: false },
86
86
  });
87
87
 
88
- let started = false;
88
+ let script;
89
89
  try {
90
+ script = new vm.Script(runner);
91
+ } catch (err) {
92
+ parentPort.postMessage({
93
+ success: false,
94
+ thrown: false,
95
+ error: 'Tool code does not compile: ' + (err && typeof err.message === 'string' ? err.message : String(err)),
96
+ logs: [],
97
+ });
98
+ }
99
+
100
+ let started = false;
101
+ if (script) try {
90
102
  vm.runInContext(BOOTSTRAP, context);
91
- vm.runInContext(runner, context, { timeout });
103
+ script.runInContext(context, { timeout });
92
104
  started = true;
93
105
  } catch (err) {
94
106
  const timedOut = err && err.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT';
@@ -5,17 +5,19 @@ import type {
5
5
  ToolSelfGenerationConfig,
6
6
  } from '@cogitator-ai/types';
7
7
  import { ToolSandbox, type SandboxTestCase } from './tool-sandbox';
8
- import { buildToolValidationPrompt, parseValidationResponse } from './prompts';
8
+ import { buildToolValidationPrompt, parseValidationResponse, type ToolReview } from './prompts';
9
9
  import { llmChat, requireModelForLLM } from '../utils/llm-helper';
10
10
 
11
11
  export interface ToolValidatorOptions {
12
12
  llm?: LLMBackend;
13
13
  config: ToolSelfGenerationConfig;
14
14
  model?: string;
15
+ /** Upper bound on the output tokens of the review call. Unset leaves the backend default. */
16
+ maxTokens?: number;
15
17
  }
16
18
 
17
19
  interface JsonPropertySchema {
18
- type?: string;
20
+ type?: string | string[];
19
21
  default?: unknown;
20
22
  enum?: unknown[];
21
23
  examples?: unknown[];
@@ -147,20 +149,89 @@ const STATIC_VALIDATION_RULES: ValidationRule[] = [
147
149
  },
148
150
  ];
149
151
 
152
+ /** Edge cases and approved review notes lower the score, but never below a passing grade on their own. */
153
+ const MAX_ADVISORY_PENALTY = 0.3;
154
+
155
+ function isRecord(value: unknown): value is Record<string, unknown> {
156
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
157
+ }
158
+
159
+ function primaryType(type: JsonPropertySchema['type']): string | undefined {
160
+ return Array.isArray(type) ? type.find((t) => t !== 'null') : type;
161
+ }
162
+
163
+ function matchesType(value: unknown, type: JsonPropertySchema['type']): boolean {
164
+ if (type === undefined) return true;
165
+ const types = Array.isArray(type) ? type : [type];
166
+ return types.some((t) => {
167
+ switch (t) {
168
+ case 'string':
169
+ return typeof value === 'string';
170
+ case 'number':
171
+ return typeof value === 'number' && Number.isFinite(value);
172
+ case 'integer':
173
+ return Number.isInteger(value);
174
+ case 'boolean':
175
+ return typeof value === 'boolean';
176
+ case 'array':
177
+ return Array.isArray(value);
178
+ case 'object':
179
+ return isRecord(value);
180
+ case 'null':
181
+ return value === null;
182
+ default:
183
+ return true;
184
+ }
185
+ });
186
+ }
187
+
188
+ /** Whether an input has every required parameter, no unknown one, and values of the declared types. */
189
+ function fitsSchema(input: Record<string, unknown>, parameters: Record<string, unknown>): boolean {
190
+ const properties = isRecord(parameters.properties)
191
+ ? (parameters.properties as Record<string, JsonPropertySchema>)
192
+ : {};
193
+ const required = Array.isArray(parameters.required) ? parameters.required : [];
194
+ if (required.some((key) => typeof key === 'string' && !(key in input))) return false;
195
+ if (Object.keys(properties).length === 0) return true;
196
+
197
+ return Object.entries(input).every(([key, value]) => {
198
+ const schema = properties[key];
199
+ return schema !== undefined && matchesType(value, schema.type);
200
+ });
201
+ }
202
+
203
+ /** A review approves when it calls the tool valid and does not ask for a revision or a rejection. */
204
+ function approves(review: ToolReview): boolean {
205
+ return review.isValid && (review.recommendation ?? 'approve') === 'approve';
206
+ }
207
+
150
208
  export class ToolValidator {
151
209
  private readonly llm?: LLMBackend;
152
210
  private readonly config: ToolSelfGenerationConfig;
153
211
  private readonly model: string;
212
+ private readonly maxTokens?: number;
154
213
  private readonly sandbox: ToolSandbox;
155
214
  private readonly customRules: ValidationRule[] = [];
156
215
 
157
216
  constructor(options: ToolValidatorOptions) {
158
217
  this.llm = options.llm;
218
+ this.maxTokens = options.maxTokens;
159
219
  this.config = options.config;
160
220
  this.model = requireModelForLLM(options.llm, options.model, 'ToolValidator');
161
221
  this.sandbox = new ToolSandbox(options.config.sandboxConfig);
162
222
  }
163
223
 
224
+ /**
225
+ * Grades a tool. Static security checks and sandbox test failures are authoritative: either
226
+ * makes the tool invalid whatever a review says. When `requireLLMValidation` is on, the
227
+ * reviewer's verdict decides the rest: an approving review keeps its security and logic
228
+ * notes as suggestions, while a review that recommends a revision or rejects the tool makes
229
+ * it invalid and its findings become the issues to fix.
230
+ *
231
+ * Without `testCases`, the tool runs on the example inputs it was generated with (they must
232
+ * succeed) and on inputs synthesized from its parameters schema (they only prove the tool
233
+ * runs: a descriptive error is accepted).
234
+ */
164
235
  async validate(
165
236
  tool: GeneratedTool,
166
237
  testCases?: SandboxTestCase[]
@@ -186,7 +257,10 @@ export class ToolValidator {
186
257
  };
187
258
  }
188
259
 
189
- const effectiveTestCases = testCases || this.generateBasicTestCases(tool);
260
+ const effectiveTestCases = testCases ?? [
261
+ ...this.exampleTestCases(tool),
262
+ ...this.generateBasicTestCases(tool),
263
+ ];
190
264
  const sandboxResult = await this.sandbox.testWithCases(tool, effectiveTestCases);
191
265
 
192
266
  const testResults = sandboxResult.results.map((r) => ({
@@ -206,26 +280,42 @@ export class ToolValidator {
206
280
  );
207
281
  }
208
282
 
283
+ let advisoryNotes = 0;
209
284
  if (this.llm && this.config.requireLLMValidation) {
210
- const llmResult = await this.runLLMValidation(tool, effectiveTestCases);
211
- if (llmResult) {
212
- securityIssues.push(...llmResult.securityIssues);
213
- logicIssues.push(...llmResult.logicIssues);
214
- edgeCases.push(...llmResult.edgeCases);
215
- suggestions.push(...llmResult.suggestions);
285
+ const review = await this.runLLMValidation(tool, effectiveTestCases);
286
+ if (review) {
287
+ edgeCases.push(...review.edgeCases);
288
+ suggestions.push(...review.suggestions);
289
+
290
+ if (approves(review)) {
291
+ const notes = [
292
+ ...review.securityIssues.map((issue) => `Review note (security): ${issue}`),
293
+ ...review.logicIssues.map((issue) => `Review note (logic): ${issue}`),
294
+ ];
295
+ advisoryNotes = notes.length;
296
+ suggestions.push(...notes);
297
+ } else {
298
+ securityIssues.push(...review.securityIssues);
299
+ logicIssues.push(...review.logicIssues);
300
+ if (review.securityIssues.length === 0 && review.logicIssues.length === 0) {
301
+ logicIssues.push(
302
+ `The review recommends to ${review.recommendation ?? 'reject'} the tool without naming an issue: check it against its description`
303
+ );
304
+ }
305
+ }
216
306
  }
217
307
  }
218
308
 
219
309
  const score = this.calculateScore(
220
310
  securityIssues.length,
221
311
  logicIssues.length,
222
- edgeCases.length,
312
+ edgeCases.length + advisoryNotes,
223
313
  sandboxResult.passed,
224
314
  sandboxResult.failed
225
315
  );
226
316
 
227
317
  return {
228
- isValid: securityIssues.length === 0 && logicIssues.length === 0 && score >= 0.7,
318
+ isValid: securityIssues.length === 0 && logicIssues.length === 0,
229
319
  securityIssues: [...new Set(securityIssues)],
230
320
  logicIssues: [...new Set(logicIssues)],
231
321
  edgeCases: [...new Set(edgeCases)],
@@ -264,7 +354,7 @@ export class ToolValidator {
264
354
  private async runLLMValidation(
265
355
  tool: GeneratedTool,
266
356
  testCases: SandboxTestCase[]
267
- ): Promise<ToolValidationResult | null> {
357
+ ): Promise<ToolReview | null> {
268
358
  if (!this.llm) return null;
269
359
 
270
360
  try {
@@ -293,7 +383,7 @@ Be thorough but practical - focus on real issues.`,
293
383
  },
294
384
  { role: 'user', content: prompt },
295
385
  ],
296
- { model: this.model, temperature: 0.2 }
386
+ { model: this.model, temperature: 0.2, maxTokens: this.maxTokens }
297
387
  );
298
388
 
299
389
  return parseValidationResponse(content);
@@ -302,6 +392,17 @@ Be thorough but practical - focus on real issues.`,
302
392
  }
303
393
  }
304
394
 
395
+ /** The example inputs the tool was generated with that fit its schema, as cases that must succeed. */
396
+ private exampleTestCases(tool: GeneratedTool): SandboxTestCase[] {
397
+ const examples = tool.metadata?.examples;
398
+ if (!Array.isArray(examples)) return [];
399
+
400
+ return examples.flatMap((example: unknown) => {
401
+ if (!isRecord(example) || !isRecord(example.input)) return [];
402
+ return fitsSchema(example.input, tool.parameters) ? [{ input: example.input }] : [];
403
+ });
404
+ }
405
+
305
406
  private generateBasicTestCases(tool: GeneratedTool): SandboxTestCase[] {
306
407
  const params = tool.parameters;
307
408
 
@@ -318,11 +419,11 @@ Be thorough but practical - focus on real issues.`,
318
419
  const entries = Object.entries(properties);
319
420
  const testCases: SandboxTestCase[] = [];
320
421
 
321
- const validInput: Record<string, unknown> = {};
422
+ const sampleInput: Record<string, unknown> = {};
322
423
  for (const [key, schema] of entries) {
323
- validInput[key] = this.generateSampleValue(schema);
424
+ sampleInput[key] = this.generateSampleValue(schema);
324
425
  }
325
- testCases.push({ input: validInput });
426
+ testCases.push({ input: sampleInput, allowThrow: true });
326
427
 
327
428
  const hasOptional = entries.some(([key]) => !required.has(key));
328
429
  if (required.size > 0 && hasOptional) {
@@ -332,12 +433,12 @@ Be thorough but practical - focus on real issues.`,
332
433
  requiredOnlyInput[key] = this.generateSampleValue(schema);
333
434
  }
334
435
  }
335
- testCases.push({ input: requiredOnlyInput });
436
+ testCases.push({ input: requiredOnlyInput, allowThrow: true });
336
437
  }
337
438
 
338
439
  const edgeInput: Record<string, unknown> = {};
339
440
  for (const [key, schema] of entries) {
340
- edgeInput[key] = this.generateEdgeValue(schema.type);
441
+ edgeInput[key] = this.generateEdgeValue(primaryType(schema.type));
341
442
  }
342
443
  testCases.push({ input: edgeInput, allowThrow: true });
343
444
 
@@ -359,7 +460,7 @@ Be thorough but practical - focus on real issues.`,
359
460
  if (Array.isArray(schema.examples) && schema.examples.length > 0) return schema.examples[0];
360
461
  if (Array.isArray(schema.enum) && schema.enum.length > 0) return schema.enum[0];
361
462
 
362
- switch (schema.type) {
463
+ switch (primaryType(schema.type)) {
363
464
  case 'string':
364
465
  return 'test';
365
466
  case 'number':
@@ -407,7 +508,7 @@ Be thorough but practical - focus on real issues.`,
407
508
  let score = 1.0;
408
509
 
409
510
  score -= logicCount * 0.2;
410
- score -= edgeCaseCount * 0.05;
511
+ score -= Math.min(MAX_ADVISORY_PENALTY, edgeCaseCount * 0.05);
411
512
 
412
513
  const totalTests = testsPassed + testsFailed;
413
514
  if (totalTests > 0) {