@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.
- package/README.md +23 -6
- package/dist/architecture-evolution/capability-analyzer.d.ts +3 -0
- package/dist/architecture-evolution/capability-analyzer.d.ts.map +1 -1
- package/dist/architecture-evolution/capability-analyzer.js +3 -1
- package/dist/architecture-evolution/capability-analyzer.js.map +1 -1
- package/dist/architecture-evolution/parameter-optimizer.d.ts +6 -0
- package/dist/architecture-evolution/parameter-optimizer.d.ts.map +1 -1
- package/dist/architecture-evolution/parameter-optimizer.js +5 -2
- package/dist/architecture-evolution/parameter-optimizer.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/self-modifying-agent.d.ts +6 -0
- package/dist/self-modifying-agent.d.ts.map +1 -1
- package/dist/self-modifying-agent.js +12 -2
- package/dist/self-modifying-agent.js.map +1 -1
- package/dist/tool-generation/gap-analyzer.d.ts +13 -4
- package/dist/tool-generation/gap-analyzer.d.ts.map +1 -1
- package/dist/tool-generation/gap-analyzer.js +5 -2
- package/dist/tool-generation/gap-analyzer.js.map +1 -1
- package/dist/tool-generation/index.d.ts +2 -2
- package/dist/tool-generation/index.d.ts.map +1 -1
- package/dist/tool-generation/index.js.map +1 -1
- package/dist/tool-generation/prompts.d.ts +7 -2
- package/dist/tool-generation/prompts.d.ts.map +1 -1
- package/dist/tool-generation/prompts.js +44 -12
- package/dist/tool-generation/prompts.js.map +1 -1
- package/dist/tool-generation/tool-generator.d.ts +6 -0
- package/dist/tool-generation/tool-generator.d.ts.map +1 -1
- package/dist/tool-generation/tool-generator.js +26 -2
- package/dist/tool-generation/tool-generator.js.map +1 -1
- package/dist/tool-generation/tool-sandbox.d.ts.map +1 -1
- package/dist/tool-generation/tool-sandbox.js +21 -5
- package/dist/tool-generation/tool-sandbox.js.map +1 -1
- package/dist/tool-generation/tool-validator.d.ts +16 -0
- package/dist/tool-generation/tool-validator.d.ts.map +1 -1
- package/dist/tool-generation/tool-validator.js +110 -17
- package/dist/tool-generation/tool-validator.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/self-modifying-agent.test.ts +52 -0
- package/src/__tests__/tool-review-regressions.test.ts +473 -0
- package/src/architecture-evolution/capability-analyzer.ts +5 -1
- package/src/architecture-evolution/parameter-optimizer.ts +10 -2
- package/src/index.ts +2 -0
- package/src/self-modifying-agent.ts +18 -2
- package/src/tool-generation/gap-analyzer.ts +24 -11
- package/src/tool-generation/index.ts +2 -1
- package/src/tool-generation/prompts.ts +52 -13
- package/src/tool-generation/tool-generator.ts +33 -2
- package/src/tool-generation/tool-sandbox.ts +26 -5
- 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(
|
|
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.
|
|
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
|
|
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: ${
|
|
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":"
|
|
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": "
|
|
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:
|
|
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
|
-
|
|
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:
|
|
91
|
-
error:
|
|
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
|
-
|
|
333
|
-
settle(
|
|
334
|
-
|
|
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
|
|
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
|
|
211
|
-
if (
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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<
|
|
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
|
|
422
|
+
const sampleInput: Record<string, unknown> = {};
|
|
322
423
|
for (const [key, schema] of entries) {
|
|
323
|
-
|
|
424
|
+
sampleInput[key] = this.generateSampleValue(schema);
|
|
324
425
|
}
|
|
325
|
-
testCases.push({ input:
|
|
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) {
|