@cogitator-ai/self-modifying 19.2.0 → 19.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) 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 +44 -12
  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 +21 -5
  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__/self-modifying-agent.test.ts +52 -0
  41. package/src/__tests__/tool-review-regressions.test.ts +473 -0
  42. package/src/architecture-evolution/capability-analyzer.ts +5 -1
  43. package/src/architecture-evolution/parameter-optimizer.ts +10 -2
  44. package/src/index.ts +2 -0
  45. package/src/self-modifying-agent.ts +18 -2
  46. package/src/tool-generation/gap-analyzer.ts +24 -11
  47. package/src/tool-generation/index.ts +2 -1
  48. package/src/tool-generation/prompts.ts +52 -13
  49. package/src/tool-generation/tool-generator.ts +33 -2
  50. package/src/tool-generation/tool-sandbox.ts +26 -5
  51. 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';
@@ -35,9 +35,16 @@ function sanitizeToolName(name: string): string {
35
35
  export function buildGapAnalysisPrompt(
36
36
  userIntent: string,
37
37
  availableTools: Array<{ name: string; description: string }>,
38
- failedAttempts?: string[]
38
+ failedAttempts?: string[],
39
+ agentInstructions?: string
39
40
  ): string {
40
- const toolList = availableTools.map((t) => `- ${t.name}: ${t.description}`).join('\n');
41
+ const toolList = availableTools.length
42
+ ? availableTools.map((t) => `- ${t.name}: ${t.description}`).join('\n')
43
+ : '(none)';
44
+
45
+ const instructionsSection = agentInstructions?.trim()
46
+ ? `\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`
47
+ : '';
41
48
 
42
49
  const failureContext = failedAttempts?.length
43
50
  ? `\n\nPrevious failed attempts:\n${failedAttempts.map((f) => `- ${f}`).join('\n')}`
@@ -47,8 +54,8 @@ export function buildGapAnalysisPrompt(
47
54
 
48
55
  USER INTENT:
49
56
  ${userIntent}
50
-
51
- AVAILABLE TOOLS:
57
+ ${instructionsSection}
58
+ 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
59
  ${toolList}
53
60
  ${failureContext}
54
61
 
@@ -82,7 +89,7 @@ export function buildToolGenerationPrompt(
82
89
  }
83
90
  ): string {
84
91
  const toolName = sanitizeToolName(gap.suggestedToolName);
85
- const description = sanitizeForPrompt(gap.description);
92
+ const task = sanitizeForPrompt(gap.description);
86
93
  const capability = sanitizeForPrompt(gap.requiredCapability);
87
94
 
88
95
  const existingToolsSection =
@@ -121,11 +128,14 @@ export function buildToolGenerationPrompt(
121
128
  : '';
122
129
 
123
130
  return `Create a tool named "${toolName}".
124
- Task: ${description}
131
+ Task: ${task}
125
132
  Formula/Logic: ${capability}
126
133
  ${existingToolsSection}${constraintsText}${parametersText}
134
+ "description" tells an agent what the tool does: one sentence naming its input and its output, not the task above.
135
+ "examples" lists 2 or 3 realistic valid inputs the tool must accept without throwing.
136
+
127
137
  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"}`;
138
+ {"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
139
  }
130
140
 
131
141
  export function buildToolValidationPrompt(
@@ -199,9 +209,10 @@ IMPORTANT: Use plain JavaScript only (NO TypeScript, NO type annotations). The e
199
209
  Respond with the same JSON format as before:
200
210
  {
201
211
  "name": "${tool.name}",
202
- "description": "Updated description if needed",
212
+ "description": "What the tool does, its input and its output",
203
213
  "implementation": "async function execute(params) { ... }",
204
214
  "parameters": { ... },
215
+ "examples": [{ "input": { ... } }],
205
216
  "reasoning": "What was changed and why"
206
217
  }`;
207
218
  }
@@ -315,23 +326,48 @@ function normalizeParameters(value: unknown): Record<string, unknown> {
315
326
  return normalized;
316
327
  }
317
328
 
329
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
330
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
331
+ }
332
+
333
+ /** Example inputs from a generation response, as `{ input }` entries. Bare input objects are accepted. */
334
+ function parseExamples(value: unknown): Array<{ input: Record<string, unknown> }> {
335
+ if (!Array.isArray(value)) return [];
336
+ return value.flatMap((entry: unknown) => {
337
+ if (!isPlainObject(entry)) return [];
338
+ if ('input' in entry) return isPlainObject(entry.input) ? [{ input: entry.input }] : [];
339
+ return [{ input: entry }];
340
+ });
341
+ }
342
+
318
343
  function buildToolFromParsed(parsed: Record<string, unknown>): GeneratedTool {
344
+ const examples = parseExamples(parsed.examples);
345
+ const metadata: Record<string, unknown> = {};
346
+ if (parsed.reasoning) metadata.reasoning = String(parsed.reasoning);
347
+ if (examples.length > 0) metadata.examples = examples;
348
+
319
349
  return {
320
350
  id: `gen_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
321
351
  name: sanitizeToolName(String(parsed.name)),
322
- description: String(parsed.description || ''),
352
+ description: typeof parsed.description === 'string' ? parsed.description.trim() : '',
323
353
  implementation: String(parsed.implementation),
324
354
  parameters: normalizeParameters(parsed.parameters),
325
355
  createdAt: new Date(),
326
356
  version: 1,
327
357
  status: 'pending_validation',
328
- metadata: {
329
- reasoning: parsed.reasoning ? String(parsed.reasoning) : undefined,
330
- },
358
+ metadata,
331
359
  };
332
360
  }
333
361
 
334
- export function parseValidationResponse(response: string): ToolValidationResult | null {
362
+ /** A reviewer's verdict on a generated tool. */
363
+ export interface ToolReview extends ToolValidationResult {
364
+ /** What the reviewer wants done with the tool, when it said so */
365
+ recommendation?: 'approve' | 'revise' | 'reject';
366
+ }
367
+
368
+ const RECOMMENDATIONS = new Set(['approve', 'revise', 'reject']);
369
+
370
+ export function parseValidationResponse(response: string): ToolReview | null {
335
371
  const json = extractJson(response);
336
372
  if (!json) {
337
373
  return null;
@@ -355,6 +391,9 @@ export function parseValidationResponse(response: string): ToolValidationResult
355
391
  }))
356
392
  : [],
357
393
  overallScore: typeof parsed.overallScore === 'number' ? parsed.overallScore : 0,
394
+ ...(RECOMMENDATIONS.has(parsed.recommendation) && {
395
+ recommendation: parsed.recommendation as ToolReview['recommendation'],
396
+ }),
358
397
  };
359
398
  } catch {
360
399
  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' &&
@@ -9,6 +9,12 @@ export const DEFAULT_SANDBOX_CONFIG: ToolSandboxConfig = {
9
9
  isolationLevel: 'strict',
10
10
  };
11
11
 
12
+ /**
13
+ * How long a sandbox worker may take to boot. `maxExecutionTime` starts counting only once the
14
+ * worker is online, so a slow or loaded machine does not eat into the tool's own time budget.
15
+ */
16
+ const WORKER_STARTUP_TIMEOUT_MS = 10_000;
17
+
12
18
  const WORKER_SOURCE = String.raw`
13
19
  const { parentPort, workerData } = require('node:worker_threads');
14
20
  const vm = require('node:vm');
@@ -85,10 +91,13 @@ try {
85
91
  vm.runInContext(runner, context, { timeout });
86
92
  started = true;
87
93
  } catch (err) {
94
+ const timedOut = err && err.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT';
88
95
  parentPort.postMessage({
89
96
  success: false,
90
- thrown: true,
91
- error: err && typeof err.message === 'string' ? err.message : String(err),
97
+ thrown: !timedOut,
98
+ error: timedOut
99
+ ? 'Execution timeout: exceeded ' + timeout + 'ms'
100
+ : err && typeof err.message === 'string' ? err.message : String(err),
92
101
  logs: readLogs(context),
93
102
  });
94
103
  }
@@ -329,9 +338,11 @@ export class ToolSandbox {
329
338
  resolve({ result, thrownByTool });
330
339
  };
331
340
 
332
- const timer = setTimeout(() => {
333
- settle(failure(`Execution timeout: exceeded ${this.config.maxExecutionTime}ms`, startTime));
334
- }, this.config.maxExecutionTime);
341
+ let timer = setTimeout(() => {
342
+ settle(
343
+ failure(`Sandbox worker did not start within ${WORKER_STARTUP_TIMEOUT_MS}ms`, startTime)
344
+ );
345
+ }, WORKER_STARTUP_TIMEOUT_MS);
335
346
 
336
347
  const maxMemoryMb = Math.max(4, Math.ceil(this.config.maxMemory / (1024 * 1024)));
337
348
 
@@ -355,6 +366,16 @@ export class ToolSandbox {
355
366
  return;
356
367
  }
357
368
 
369
+ worker.on('online', () => {
370
+ if (settled) return;
371
+ clearTimeout(timer);
372
+ timer = setTimeout(() => {
373
+ settle(
374
+ failure(`Execution timeout: exceeded ${this.config.maxExecutionTime}ms`, startTime)
375
+ );
376
+ }, this.config.maxExecutionTime);
377
+ });
378
+
358
379
  worker.on('message', (msg: WorkerMessage) => {
359
380
  const logs = msg.logs ?? [];
360
381
  if (msg.success) {
@@ -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) {