cognitive-modules-cli 2.2.0 → 2.2.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.
- package/dist/cli.js +65 -12
- package/dist/commands/compose.d.ts +31 -0
- package/dist/commands/compose.js +148 -0
- package/dist/commands/index.d.ts +1 -0
- package/dist/commands/index.js +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -1
- package/dist/modules/composition.d.ts +251 -0
- package/dist/modules/composition.js +1265 -0
- package/dist/modules/composition.test.d.ts +11 -0
- package/dist/modules/composition.test.js +450 -0
- package/dist/modules/index.d.ts +2 -0
- package/dist/modules/index.js +2 -0
- package/dist/modules/loader.d.ts +22 -2
- package/dist/modules/loader.js +167 -4
- package/dist/modules/policy.test.d.ts +10 -0
- package/dist/modules/policy.test.js +369 -0
- package/dist/modules/runner.d.ts +357 -1
- package/dist/modules/runner.js +1221 -64
- package/dist/modules/subagent.js +2 -0
- package/dist/modules/validator.d.ts +28 -0
- package/dist/modules/validator.js +629 -0
- package/dist/types.d.ts +92 -8
- package/package.json +2 -1
- package/src/cli.ts +73 -12
- package/src/commands/compose.ts +185 -0
- package/src/commands/index.ts +1 -0
- package/src/index.ts +35 -0
- package/src/modules/composition.test.ts +558 -0
- package/src/modules/composition.ts +1674 -0
- package/src/modules/index.ts +2 -0
- package/src/modules/loader.ts +196 -6
- package/src/modules/policy.test.ts +455 -0
- package/src/modules/runner.ts +1562 -74
- package/src/modules/subagent.ts +2 -0
- package/src/modules/validator.ts +700 -0
- package/src/types.ts +112 -8
- package/tsconfig.json +1 -1
package/src/modules/runner.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Module Runner - Execute Cognitive Modules
|
|
3
3
|
* v2.2: Envelope format with meta/data separation, risk_rule, repair pass
|
|
4
|
+
* v2.2.1: Version field, enhanced error taxonomy, observability hooks, streaming
|
|
4
5
|
*/
|
|
5
6
|
|
|
7
|
+
import _Ajv from 'ajv';
|
|
8
|
+
const Ajv = _Ajv.default || _Ajv;
|
|
6
9
|
import type {
|
|
7
10
|
Provider,
|
|
8
11
|
CognitiveModule,
|
|
@@ -20,6 +23,899 @@ import type {
|
|
|
20
23
|
} from '../types.js';
|
|
21
24
|
import { aggregateRisk, isV22Envelope } from '../types.js';
|
|
22
25
|
|
|
26
|
+
// =============================================================================
|
|
27
|
+
// Schema Validation
|
|
28
|
+
// =============================================================================
|
|
29
|
+
|
|
30
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate data against JSON schema. Returns list of errors.
|
|
34
|
+
*/
|
|
35
|
+
export function validateData(data: unknown, schema: object, label: string = 'Data'): string[] {
|
|
36
|
+
const errors: string[] = [];
|
|
37
|
+
if (!schema || Object.keys(schema).length === 0) {
|
|
38
|
+
return errors;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const validate = ajv.compile(schema);
|
|
43
|
+
const valid = validate(data);
|
|
44
|
+
|
|
45
|
+
if (!valid && validate.errors) {
|
|
46
|
+
for (const err of validate.errors) {
|
|
47
|
+
const path = err.instancePath || '/';
|
|
48
|
+
errors.push(`${label} validation error: ${err.message} at ${path}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch (e) {
|
|
52
|
+
errors.push(`Schema error: ${(e as Error).message}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return errors;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// =============================================================================
|
|
59
|
+
// v2.2 Policy Enforcement
|
|
60
|
+
// =============================================================================
|
|
61
|
+
|
|
62
|
+
/** Action types that can be checked against policies */
|
|
63
|
+
export type PolicyAction = 'network' | 'filesystem_write' | 'side_effects' | 'code_execution';
|
|
64
|
+
|
|
65
|
+
/** Tool categories for automatic policy mapping */
|
|
66
|
+
const TOOL_POLICY_MAPPING: Record<string, PolicyAction[]> = {
|
|
67
|
+
// Network tools
|
|
68
|
+
'fetch': ['network'],
|
|
69
|
+
'http': ['network'],
|
|
70
|
+
'request': ['network'],
|
|
71
|
+
'curl': ['network'],
|
|
72
|
+
'wget': ['network'],
|
|
73
|
+
'api_call': ['network'],
|
|
74
|
+
|
|
75
|
+
// Filesystem tools
|
|
76
|
+
'write_file': ['filesystem_write', 'side_effects'],
|
|
77
|
+
'create_file': ['filesystem_write', 'side_effects'],
|
|
78
|
+
'delete_file': ['filesystem_write', 'side_effects'],
|
|
79
|
+
'rename_file': ['filesystem_write', 'side_effects'],
|
|
80
|
+
'mkdir': ['filesystem_write', 'side_effects'],
|
|
81
|
+
'rmdir': ['filesystem_write', 'side_effects'],
|
|
82
|
+
|
|
83
|
+
// Code execution tools
|
|
84
|
+
'shell': ['code_execution', 'side_effects'],
|
|
85
|
+
'exec': ['code_execution', 'side_effects'],
|
|
86
|
+
'run_code': ['code_execution', 'side_effects'],
|
|
87
|
+
'code_interpreter': ['code_execution', 'side_effects'],
|
|
88
|
+
'eval': ['code_execution', 'side_effects'],
|
|
89
|
+
|
|
90
|
+
// Database tools
|
|
91
|
+
'sql_query': ['side_effects'],
|
|
92
|
+
'db_write': ['side_effects'],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Result of a policy check */
|
|
96
|
+
export interface PolicyCheckResult {
|
|
97
|
+
allowed: boolean;
|
|
98
|
+
reason?: string;
|
|
99
|
+
policy?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Check if a tool is allowed by the module's tools policy.
|
|
104
|
+
*
|
|
105
|
+
* @param toolName The name of the tool to check
|
|
106
|
+
* @param module The cognitive module config
|
|
107
|
+
* @returns PolicyCheckResult indicating if the tool is allowed
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* const result = checkToolPolicy('write_file', module);
|
|
111
|
+
* if (!result.allowed) {
|
|
112
|
+
* throw new Error(result.reason);
|
|
113
|
+
* }
|
|
114
|
+
*/
|
|
115
|
+
export function checkToolPolicy(
|
|
116
|
+
toolName: string,
|
|
117
|
+
module: CognitiveModule
|
|
118
|
+
): PolicyCheckResult {
|
|
119
|
+
const toolsPolicy = module.tools;
|
|
120
|
+
|
|
121
|
+
// No policy = allow all
|
|
122
|
+
if (!toolsPolicy) {
|
|
123
|
+
return { allowed: true };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const normalizedName = toolName.toLowerCase().replace(/[-\s]/g, '_');
|
|
127
|
+
|
|
128
|
+
// Check explicit denied list first
|
|
129
|
+
if (toolsPolicy.denied?.some(d => d.toLowerCase().replace(/[-\s]/g, '_') === normalizedName)) {
|
|
130
|
+
return {
|
|
131
|
+
allowed: false,
|
|
132
|
+
reason: `Tool '${toolName}' is explicitly denied by module tools policy`,
|
|
133
|
+
policy: 'tools.denied'
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check policy mode
|
|
138
|
+
if (toolsPolicy.policy === 'deny_by_default') {
|
|
139
|
+
// In deny_by_default mode, tool must be in allowed list
|
|
140
|
+
const isAllowed = toolsPolicy.allowed?.some(
|
|
141
|
+
a => a.toLowerCase().replace(/[-\s]/g, '_') === normalizedName
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
if (!isAllowed) {
|
|
145
|
+
return {
|
|
146
|
+
allowed: false,
|
|
147
|
+
reason: `Tool '${toolName}' not in allowed list (policy: deny_by_default)`,
|
|
148
|
+
policy: 'tools.policy'
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { allowed: true };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Check if an action is allowed by the module's policies.
|
|
158
|
+
*
|
|
159
|
+
* @param action The action to check (network, filesystem_write, etc.)
|
|
160
|
+
* @param module The cognitive module config
|
|
161
|
+
* @returns PolicyCheckResult indicating if the action is allowed
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* const result = checkPolicy('network', module);
|
|
165
|
+
* if (!result.allowed) {
|
|
166
|
+
* throw new Error(result.reason);
|
|
167
|
+
* }
|
|
168
|
+
*/
|
|
169
|
+
export function checkPolicy(
|
|
170
|
+
action: PolicyAction,
|
|
171
|
+
module: CognitiveModule
|
|
172
|
+
): PolicyCheckResult {
|
|
173
|
+
const policies = module.policies;
|
|
174
|
+
|
|
175
|
+
// No policies = allow all
|
|
176
|
+
if (!policies) {
|
|
177
|
+
return { allowed: true };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Check the specific policy
|
|
181
|
+
if (policies[action] === 'deny') {
|
|
182
|
+
return {
|
|
183
|
+
allowed: false,
|
|
184
|
+
reason: `Action '${action}' is denied by module policy`,
|
|
185
|
+
policy: `policies.${action}`
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { allowed: true };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Check if a tool is allowed considering both tools policy and general policies.
|
|
194
|
+
* This performs a comprehensive check that:
|
|
195
|
+
* 1. Checks the tools policy (allowed/denied lists)
|
|
196
|
+
* 2. Maps the tool to policy actions and checks those
|
|
197
|
+
*
|
|
198
|
+
* @param toolName The name of the tool to check
|
|
199
|
+
* @param module The cognitive module config
|
|
200
|
+
* @returns PolicyCheckResult with detailed information
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* const result = checkToolAllowed('write_file', module);
|
|
204
|
+
* if (!result.allowed) {
|
|
205
|
+
* return makeErrorResponse({
|
|
206
|
+
* code: 'POLICY_VIOLATION',
|
|
207
|
+
* message: result.reason,
|
|
208
|
+
* });
|
|
209
|
+
* }
|
|
210
|
+
*/
|
|
211
|
+
export function checkToolAllowed(
|
|
212
|
+
toolName: string,
|
|
213
|
+
module: CognitiveModule
|
|
214
|
+
): PolicyCheckResult {
|
|
215
|
+
// First check explicit tools policy
|
|
216
|
+
const toolCheck = checkToolPolicy(toolName, module);
|
|
217
|
+
if (!toolCheck.allowed) {
|
|
218
|
+
return toolCheck;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Then check mapped policies
|
|
222
|
+
const normalizedName = toolName.toLowerCase().replace(/[-\s]/g, '_');
|
|
223
|
+
const mappedActions = TOOL_POLICY_MAPPING[normalizedName] || [];
|
|
224
|
+
|
|
225
|
+
for (const action of mappedActions) {
|
|
226
|
+
const policyCheck = checkPolicy(action, module);
|
|
227
|
+
if (!policyCheck.allowed) {
|
|
228
|
+
return {
|
|
229
|
+
allowed: false,
|
|
230
|
+
reason: `Tool '${toolName}' requires '${action}' which is denied by policy`,
|
|
231
|
+
policy: policyCheck.policy
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { allowed: true };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Validate that a list of tools are all allowed by the module's policies.
|
|
241
|
+
* Returns all violations found.
|
|
242
|
+
*
|
|
243
|
+
* @param toolNames List of tool names to check
|
|
244
|
+
* @param module The cognitive module config
|
|
245
|
+
* @returns Array of PolicyCheckResult for denied tools
|
|
246
|
+
*/
|
|
247
|
+
export function validateToolsAllowed(
|
|
248
|
+
toolNames: string[],
|
|
249
|
+
module: CognitiveModule
|
|
250
|
+
): PolicyCheckResult[] {
|
|
251
|
+
const violations: PolicyCheckResult[] = [];
|
|
252
|
+
|
|
253
|
+
for (const toolName of toolNames) {
|
|
254
|
+
const result = checkToolAllowed(toolName, module);
|
|
255
|
+
if (!result.allowed) {
|
|
256
|
+
violations.push(result);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return violations;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Get all denied actions for a module based on its policies.
|
|
265
|
+
* Useful for informing LLM about restrictions.
|
|
266
|
+
*/
|
|
267
|
+
export function getDeniedActions(module: CognitiveModule): PolicyAction[] {
|
|
268
|
+
const denied: PolicyAction[] = [];
|
|
269
|
+
const policies = module.policies;
|
|
270
|
+
|
|
271
|
+
if (!policies) return denied;
|
|
272
|
+
|
|
273
|
+
const actions: PolicyAction[] = ['network', 'filesystem_write', 'side_effects', 'code_execution'];
|
|
274
|
+
for (const action of actions) {
|
|
275
|
+
if (policies[action] === 'deny') {
|
|
276
|
+
denied.push(action);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return denied;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Get all denied tools for a module based on its tools policy.
|
|
285
|
+
*/
|
|
286
|
+
export function getDeniedTools(module: CognitiveModule): string[] {
|
|
287
|
+
return module.tools?.denied || [];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Get all allowed tools for a module (only meaningful in deny_by_default mode).
|
|
292
|
+
*/
|
|
293
|
+
export function getAllowedTools(module: CognitiveModule): string[] | null {
|
|
294
|
+
if (module.tools?.policy === 'deny_by_default') {
|
|
295
|
+
return module.tools.allowed || [];
|
|
296
|
+
}
|
|
297
|
+
return null; // null means "all allowed except denied list"
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// =============================================================================
|
|
301
|
+
// Tool Call Interceptor
|
|
302
|
+
// =============================================================================
|
|
303
|
+
|
|
304
|
+
/** Tool call request from LLM */
|
|
305
|
+
export interface ToolCallRequest {
|
|
306
|
+
name: string;
|
|
307
|
+
arguments: Record<string, unknown>;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Tool call result */
|
|
311
|
+
export interface ToolCallResult {
|
|
312
|
+
success: boolean;
|
|
313
|
+
result?: unknown;
|
|
314
|
+
error?: {
|
|
315
|
+
code: string;
|
|
316
|
+
message: string;
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Tool executor function type */
|
|
321
|
+
export type ToolExecutor = (args: Record<string, unknown>) => Promise<unknown>;
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* ToolCallInterceptor - Intercepts and validates tool calls against module policies.
|
|
325
|
+
*
|
|
326
|
+
* Use this class to wrap tool execution with policy enforcement:
|
|
327
|
+
*
|
|
328
|
+
* @example
|
|
329
|
+
* const interceptor = new ToolCallInterceptor(module);
|
|
330
|
+
*
|
|
331
|
+
* // Register tool executors
|
|
332
|
+
* interceptor.registerTool('read_file', async (args) => {
|
|
333
|
+
* return fs.readFile(args.path as string, 'utf-8');
|
|
334
|
+
* });
|
|
335
|
+
*
|
|
336
|
+
* // Execute tool with policy check
|
|
337
|
+
* const result = await interceptor.execute({
|
|
338
|
+
* name: 'write_file',
|
|
339
|
+
* arguments: { path: '/tmp/test.txt', content: 'hello' }
|
|
340
|
+
* });
|
|
341
|
+
*
|
|
342
|
+
* if (!result.success) {
|
|
343
|
+
* console.error('Tool blocked:', result.error);
|
|
344
|
+
* }
|
|
345
|
+
*/
|
|
346
|
+
export class ToolCallInterceptor {
|
|
347
|
+
private module: CognitiveModule;
|
|
348
|
+
private tools: Map<string, ToolExecutor> = new Map();
|
|
349
|
+
private callLog: Array<{ tool: string; allowed: boolean; timestamp: number }> = [];
|
|
350
|
+
|
|
351
|
+
constructor(module: CognitiveModule) {
|
|
352
|
+
this.module = module;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Register a tool executor.
|
|
357
|
+
*/
|
|
358
|
+
registerTool(name: string, executor: ToolExecutor): void {
|
|
359
|
+
this.tools.set(name.toLowerCase(), executor);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Register multiple tools at once.
|
|
364
|
+
*/
|
|
365
|
+
registerTools(tools: Record<string, ToolExecutor>): void {
|
|
366
|
+
for (const [name, executor] of Object.entries(tools)) {
|
|
367
|
+
this.registerTool(name, executor);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Check if a tool call is allowed without executing it.
|
|
373
|
+
*/
|
|
374
|
+
checkAllowed(toolName: string): PolicyCheckResult {
|
|
375
|
+
return checkToolAllowed(toolName, this.module);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Execute a tool call with policy enforcement.
|
|
380
|
+
*
|
|
381
|
+
* @param request The tool call request
|
|
382
|
+
* @returns ToolCallResult with success/error
|
|
383
|
+
*/
|
|
384
|
+
async execute(request: ToolCallRequest): Promise<ToolCallResult> {
|
|
385
|
+
const { name, arguments: args } = request;
|
|
386
|
+
const timestamp = Date.now();
|
|
387
|
+
|
|
388
|
+
// Check policy
|
|
389
|
+
const policyResult = checkToolAllowed(name, this.module);
|
|
390
|
+
|
|
391
|
+
if (!policyResult.allowed) {
|
|
392
|
+
this.callLog.push({ tool: name, allowed: false, timestamp });
|
|
393
|
+
return {
|
|
394
|
+
success: false,
|
|
395
|
+
error: {
|
|
396
|
+
code: 'TOOL_NOT_ALLOWED',
|
|
397
|
+
message: policyResult.reason || `Tool '${name}' is not allowed`,
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Find executor
|
|
403
|
+
const executor = this.tools.get(name.toLowerCase());
|
|
404
|
+
if (!executor) {
|
|
405
|
+
return {
|
|
406
|
+
success: false,
|
|
407
|
+
error: {
|
|
408
|
+
code: 'TOOL_NOT_FOUND',
|
|
409
|
+
message: `Tool '${name}' is not registered`,
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Execute
|
|
415
|
+
try {
|
|
416
|
+
this.callLog.push({ tool: name, allowed: true, timestamp });
|
|
417
|
+
const result = await executor(args);
|
|
418
|
+
return { success: true, result };
|
|
419
|
+
} catch (e) {
|
|
420
|
+
return {
|
|
421
|
+
success: false,
|
|
422
|
+
error: {
|
|
423
|
+
code: 'TOOL_EXECUTION_ERROR',
|
|
424
|
+
message: (e as Error).message,
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Execute multiple tool calls in sequence.
|
|
432
|
+
* Stops on first policy violation.
|
|
433
|
+
*/
|
|
434
|
+
async executeMany(requests: ToolCallRequest[]): Promise<ToolCallResult[]> {
|
|
435
|
+
const results: ToolCallResult[] = [];
|
|
436
|
+
|
|
437
|
+
for (const request of requests) {
|
|
438
|
+
const result = await this.execute(request);
|
|
439
|
+
results.push(result);
|
|
440
|
+
|
|
441
|
+
// Stop on policy violation (not execution error)
|
|
442
|
+
if (!result.success && result.error?.code === 'TOOL_NOT_ALLOWED') {
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return results;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Get the call log for auditing.
|
|
452
|
+
*/
|
|
453
|
+
getCallLog(): Array<{ tool: string; allowed: boolean; timestamp: number }> {
|
|
454
|
+
return [...this.callLog];
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Get summary of denied calls.
|
|
459
|
+
*/
|
|
460
|
+
getDeniedCalls(): Array<{ tool: string; timestamp: number }> {
|
|
461
|
+
return this.callLog
|
|
462
|
+
.filter(c => !c.allowed)
|
|
463
|
+
.map(({ tool, timestamp }) => ({ tool, timestamp }));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Clear the call log.
|
|
468
|
+
*/
|
|
469
|
+
clearLog(): void {
|
|
470
|
+
this.callLog = [];
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Get policy summary for this module.
|
|
475
|
+
*/
|
|
476
|
+
getPolicySummary(): {
|
|
477
|
+
deniedActions: PolicyAction[];
|
|
478
|
+
deniedTools: string[];
|
|
479
|
+
allowedTools: string[] | null;
|
|
480
|
+
toolsPolicy: 'allow_by_default' | 'deny_by_default' | undefined;
|
|
481
|
+
} {
|
|
482
|
+
return {
|
|
483
|
+
deniedActions: getDeniedActions(this.module),
|
|
484
|
+
deniedTools: getDeniedTools(this.module),
|
|
485
|
+
allowedTools: getAllowedTools(this.module),
|
|
486
|
+
toolsPolicy: this.module.tools?.policy,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Create a policy-aware tool executor wrapper.
|
|
493
|
+
*
|
|
494
|
+
* @example
|
|
495
|
+
* const safeExecutor = createPolicyAwareExecutor(module, 'write_file', async (args) => {
|
|
496
|
+
* return fs.writeFile(args.path, args.content);
|
|
497
|
+
* });
|
|
498
|
+
*
|
|
499
|
+
* // This will throw if write_file is denied
|
|
500
|
+
* await safeExecutor({ path: '/tmp/test.txt', content: 'hello' });
|
|
501
|
+
*/
|
|
502
|
+
export function createPolicyAwareExecutor(
|
|
503
|
+
module: CognitiveModule,
|
|
504
|
+
toolName: string,
|
|
505
|
+
executor: ToolExecutor
|
|
506
|
+
): ToolExecutor {
|
|
507
|
+
return async (args: Record<string, unknown>) => {
|
|
508
|
+
const policyResult = checkToolAllowed(toolName, module);
|
|
509
|
+
|
|
510
|
+
if (!policyResult.allowed) {
|
|
511
|
+
throw new Error(`Policy violation: ${policyResult.reason}`);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return executor(args);
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// =============================================================================
|
|
519
|
+
// v2.2 Runtime Enforcement - Overflow & Enum
|
|
520
|
+
// =============================================================================
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Validate overflow.insights against module's max_items config.
|
|
524
|
+
*
|
|
525
|
+
* @param data The response data object
|
|
526
|
+
* @param module The cognitive module config
|
|
527
|
+
* @returns Array of errors if insights exceed limit
|
|
528
|
+
*/
|
|
529
|
+
export function validateOverflowLimits(
|
|
530
|
+
data: Record<string, unknown>,
|
|
531
|
+
module: CognitiveModule
|
|
532
|
+
): string[] {
|
|
533
|
+
const errors: string[] = [];
|
|
534
|
+
|
|
535
|
+
const overflowConfig = module.overflow;
|
|
536
|
+
if (!overflowConfig?.enabled) {
|
|
537
|
+
// If overflow disabled, insights should not exist
|
|
538
|
+
const extensions = data.extensions as Record<string, unknown> | undefined;
|
|
539
|
+
if (extensions?.insights && Array.isArray(extensions.insights) && extensions.insights.length > 0) {
|
|
540
|
+
errors.push('Overflow is disabled but extensions.insights contains data');
|
|
541
|
+
}
|
|
542
|
+
return errors;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const maxItems = overflowConfig.max_items ?? 5;
|
|
546
|
+
const extensions = data.extensions as Record<string, unknown> | undefined;
|
|
547
|
+
|
|
548
|
+
if (extensions?.insights && Array.isArray(extensions.insights)) {
|
|
549
|
+
const insights = extensions.insights as unknown[];
|
|
550
|
+
|
|
551
|
+
if (insights.length > maxItems) {
|
|
552
|
+
errors.push(`overflow.max_items exceeded: ${insights.length} > ${maxItems}`);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Check require_suggested_mapping
|
|
556
|
+
if (overflowConfig.require_suggested_mapping) {
|
|
557
|
+
for (let i = 0; i < insights.length; i++) {
|
|
558
|
+
const insight = insights[i] as Record<string, unknown>;
|
|
559
|
+
if (!insight.suggested_mapping) {
|
|
560
|
+
errors.push(`insight[${i}] missing required suggested_mapping`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return errors;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Validate enum values against module's enum strategy.
|
|
571
|
+
* For strict mode, custom enum objects are not allowed.
|
|
572
|
+
*
|
|
573
|
+
* @param data The response data object
|
|
574
|
+
* @param module The cognitive module config
|
|
575
|
+
* @returns Array of errors if enum violations found
|
|
576
|
+
*/
|
|
577
|
+
export function validateEnumStrategy(
|
|
578
|
+
data: Record<string, unknown>,
|
|
579
|
+
module: CognitiveModule
|
|
580
|
+
): string[] {
|
|
581
|
+
const errors: string[] = [];
|
|
582
|
+
|
|
583
|
+
const enumStrategy = module.enums?.strategy ?? 'strict';
|
|
584
|
+
|
|
585
|
+
if (enumStrategy === 'strict') {
|
|
586
|
+
// In strict mode, custom enum objects (with 'custom' key) are not allowed
|
|
587
|
+
const checkForCustomEnums = (obj: unknown, path: string): void => {
|
|
588
|
+
if (obj === null || obj === undefined) return;
|
|
589
|
+
|
|
590
|
+
if (Array.isArray(obj)) {
|
|
591
|
+
obj.forEach((item, i) => checkForCustomEnums(item, `${path}[${i}]`));
|
|
592
|
+
} else if (typeof obj === 'object') {
|
|
593
|
+
const record = obj as Record<string, unknown>;
|
|
594
|
+
|
|
595
|
+
// Check if this is a custom enum object
|
|
596
|
+
if ('custom' in record && 'reason' in record && Object.keys(record).length === 2) {
|
|
597
|
+
errors.push(`Custom enum not allowed in strict mode at ${path}: { custom: "${record.custom}" }`);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Recurse into nested objects
|
|
602
|
+
for (const [key, value] of Object.entries(record)) {
|
|
603
|
+
checkForCustomEnums(value, `${path}.${key}`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
checkForCustomEnums(data, 'data');
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return errors;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// =============================================================================
|
|
615
|
+
// Constants
|
|
616
|
+
// =============================================================================
|
|
617
|
+
|
|
618
|
+
const ENVELOPE_VERSION = '2.2';
|
|
619
|
+
|
|
620
|
+
// =============================================================================
|
|
621
|
+
// Utility Functions
|
|
622
|
+
// =============================================================================
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Deep clone an object to avoid mutation issues.
|
|
626
|
+
* Handles nested objects, arrays, and primitive values.
|
|
627
|
+
*/
|
|
628
|
+
function deepClone<T>(obj: T): T {
|
|
629
|
+
if (obj === null || typeof obj !== 'object') {
|
|
630
|
+
return obj;
|
|
631
|
+
}
|
|
632
|
+
if (Array.isArray(obj)) {
|
|
633
|
+
return obj.map(item => deepClone(item)) as T;
|
|
634
|
+
}
|
|
635
|
+
const cloned = {} as T;
|
|
636
|
+
for (const key in obj) {
|
|
637
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
638
|
+
cloned[key] = deepClone(obj[key]);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return cloned;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// =============================================================================
|
|
645
|
+
// Observability Hooks
|
|
646
|
+
// =============================================================================
|
|
647
|
+
|
|
648
|
+
/** Hook called before module execution */
|
|
649
|
+
export type BeforeCallHook = (moduleName: string, inputData: ModuleInput, moduleConfig: CognitiveModule) => void;
|
|
650
|
+
|
|
651
|
+
/** Hook called after successful module execution */
|
|
652
|
+
export type AfterCallHook = (moduleName: string, result: EnvelopeResponseV22<unknown>, latencyMs: number) => void;
|
|
653
|
+
|
|
654
|
+
/** Hook called when an error occurs */
|
|
655
|
+
export type ErrorHook = (moduleName: string, error: Error, partialResult: unknown | null) => void;
|
|
656
|
+
|
|
657
|
+
// Global hook registries
|
|
658
|
+
const _beforeCallHooks: BeforeCallHook[] = [];
|
|
659
|
+
const _afterCallHooks: AfterCallHook[] = [];
|
|
660
|
+
const _errorHooks: ErrorHook[] = [];
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Decorator to register a before-call hook.
|
|
664
|
+
*
|
|
665
|
+
* @example
|
|
666
|
+
* onBeforeCall((moduleName, inputData, config) => {
|
|
667
|
+
* console.log(`Calling ${moduleName} with`, inputData);
|
|
668
|
+
* });
|
|
669
|
+
*/
|
|
670
|
+
export function onBeforeCall(hook: BeforeCallHook): BeforeCallHook {
|
|
671
|
+
_beforeCallHooks.push(hook);
|
|
672
|
+
return hook;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Decorator to register an after-call hook.
|
|
677
|
+
*
|
|
678
|
+
* @example
|
|
679
|
+
* onAfterCall((moduleName, result, latencyMs) => {
|
|
680
|
+
* console.log(`${moduleName} completed in ${latencyMs}ms`);
|
|
681
|
+
* });
|
|
682
|
+
*/
|
|
683
|
+
export function onAfterCall(hook: AfterCallHook): AfterCallHook {
|
|
684
|
+
_afterCallHooks.push(hook);
|
|
685
|
+
return hook;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Decorator to register an error hook.
|
|
690
|
+
*
|
|
691
|
+
* @example
|
|
692
|
+
* onError((moduleName, error, partialResult) => {
|
|
693
|
+
* console.error(`Error in ${moduleName}:`, error);
|
|
694
|
+
* });
|
|
695
|
+
*/
|
|
696
|
+
export function onError(hook: ErrorHook): ErrorHook {
|
|
697
|
+
_errorHooks.push(hook);
|
|
698
|
+
return hook;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Register a hook programmatically.
|
|
703
|
+
*/
|
|
704
|
+
export function registerHook(
|
|
705
|
+
hookType: 'before_call' | 'after_call' | 'error',
|
|
706
|
+
hook: BeforeCallHook | AfterCallHook | ErrorHook
|
|
707
|
+
): void {
|
|
708
|
+
if (hookType === 'before_call') {
|
|
709
|
+
_beforeCallHooks.push(hook as BeforeCallHook);
|
|
710
|
+
} else if (hookType === 'after_call') {
|
|
711
|
+
_afterCallHooks.push(hook as AfterCallHook);
|
|
712
|
+
} else if (hookType === 'error') {
|
|
713
|
+
_errorHooks.push(hook as ErrorHook);
|
|
714
|
+
} else {
|
|
715
|
+
throw new Error(`Unknown hook type: ${hookType}`);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Unregister a hook. Returns true if found and removed.
|
|
721
|
+
*/
|
|
722
|
+
export function unregisterHook(
|
|
723
|
+
hookType: 'before_call' | 'after_call' | 'error',
|
|
724
|
+
hook: BeforeCallHook | AfterCallHook | ErrorHook
|
|
725
|
+
): boolean {
|
|
726
|
+
let hooks: unknown[];
|
|
727
|
+
if (hookType === 'before_call') {
|
|
728
|
+
hooks = _beforeCallHooks;
|
|
729
|
+
} else if (hookType === 'after_call') {
|
|
730
|
+
hooks = _afterCallHooks;
|
|
731
|
+
} else if (hookType === 'error') {
|
|
732
|
+
hooks = _errorHooks;
|
|
733
|
+
} else {
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const index = hooks.indexOf(hook);
|
|
738
|
+
if (index !== -1) {
|
|
739
|
+
hooks.splice(index, 1);
|
|
740
|
+
return true;
|
|
741
|
+
}
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Clear all registered hooks.
|
|
747
|
+
*/
|
|
748
|
+
export function clearHooks(): void {
|
|
749
|
+
_beforeCallHooks.length = 0;
|
|
750
|
+
_afterCallHooks.length = 0;
|
|
751
|
+
_errorHooks.length = 0;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function _invokeBeforeHooks(moduleName: string, inputData: ModuleInput, moduleConfig: CognitiveModule): void {
|
|
755
|
+
for (const hook of _beforeCallHooks) {
|
|
756
|
+
try {
|
|
757
|
+
hook(moduleName, inputData, moduleConfig);
|
|
758
|
+
} catch {
|
|
759
|
+
// Hooks should not break the main flow
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function _invokeAfterHooks(moduleName: string, result: EnvelopeResponseV22<unknown>, latencyMs: number): void {
|
|
765
|
+
for (const hook of _afterCallHooks) {
|
|
766
|
+
try {
|
|
767
|
+
hook(moduleName, result, latencyMs);
|
|
768
|
+
} catch {
|
|
769
|
+
// Hooks should not break the main flow
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function _invokeErrorHooks(moduleName: string, error: Error, partialResult: unknown | null): void {
|
|
775
|
+
for (const hook of _errorHooks) {
|
|
776
|
+
try {
|
|
777
|
+
hook(moduleName, error, partialResult);
|
|
778
|
+
} catch {
|
|
779
|
+
// Hooks should not break the main flow
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// =============================================================================
|
|
785
|
+
// Error Response Builder
|
|
786
|
+
// =============================================================================
|
|
787
|
+
|
|
788
|
+
/** Error codes and their default properties */
|
|
789
|
+
export const ERROR_PROPERTIES: Record<string, { recoverable: boolean; retry_after_ms: number | null }> = {
|
|
790
|
+
MODULE_NOT_FOUND: { recoverable: false, retry_after_ms: null },
|
|
791
|
+
INVALID_INPUT: { recoverable: false, retry_after_ms: null },
|
|
792
|
+
PARSE_ERROR: { recoverable: true, retry_after_ms: 1000 },
|
|
793
|
+
SCHEMA_VALIDATION_FAILED: { recoverable: true, retry_after_ms: 1000 },
|
|
794
|
+
META_VALIDATION_FAILED: { recoverable: true, retry_after_ms: 1000 },
|
|
795
|
+
POLICY_VIOLATION: { recoverable: false, retry_after_ms: null },
|
|
796
|
+
TOOL_NOT_ALLOWED: { recoverable: false, retry_after_ms: null },
|
|
797
|
+
LLM_ERROR: { recoverable: true, retry_after_ms: 5000 },
|
|
798
|
+
RATE_LIMITED: { recoverable: true, retry_after_ms: 10000 },
|
|
799
|
+
TIMEOUT: { recoverable: true, retry_after_ms: 5000 },
|
|
800
|
+
UNKNOWN: { recoverable: false, retry_after_ms: null },
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
export interface MakeErrorResponseOptions {
|
|
804
|
+
code: string;
|
|
805
|
+
message: string;
|
|
806
|
+
explain?: string;
|
|
807
|
+
partialData?: unknown;
|
|
808
|
+
details?: Record<string, unknown>;
|
|
809
|
+
recoverable?: boolean;
|
|
810
|
+
retryAfterMs?: number;
|
|
811
|
+
confidence?: number;
|
|
812
|
+
risk?: RiskLevel;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Build a standardized error response with enhanced taxonomy.
|
|
817
|
+
*/
|
|
818
|
+
export function makeErrorResponse(options: MakeErrorResponseOptions): EnvelopeResponseV22<unknown> {
|
|
819
|
+
const {
|
|
820
|
+
code,
|
|
821
|
+
message,
|
|
822
|
+
explain,
|
|
823
|
+
partialData,
|
|
824
|
+
details,
|
|
825
|
+
recoverable,
|
|
826
|
+
retryAfterMs,
|
|
827
|
+
confidence = 0.0,
|
|
828
|
+
risk = 'high',
|
|
829
|
+
} = options;
|
|
830
|
+
|
|
831
|
+
// Get default properties from error code
|
|
832
|
+
const defaults = ERROR_PROPERTIES[code] || ERROR_PROPERTIES.UNKNOWN;
|
|
833
|
+
|
|
834
|
+
const errorObj: {
|
|
835
|
+
code: string;
|
|
836
|
+
message: string;
|
|
837
|
+
recoverable?: boolean;
|
|
838
|
+
retry_after_ms?: number;
|
|
839
|
+
details?: Record<string, unknown>;
|
|
840
|
+
} = {
|
|
841
|
+
code,
|
|
842
|
+
message,
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
// Add recoverable flag
|
|
846
|
+
const isRecoverable = recoverable ?? defaults.recoverable;
|
|
847
|
+
if (isRecoverable !== undefined) {
|
|
848
|
+
errorObj.recoverable = isRecoverable;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Add retry suggestion
|
|
852
|
+
const retryMs = retryAfterMs ?? defaults.retry_after_ms;
|
|
853
|
+
if (retryMs !== null) {
|
|
854
|
+
errorObj.retry_after_ms = retryMs;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Add details if provided
|
|
858
|
+
if (details) {
|
|
859
|
+
errorObj.details = details;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
return {
|
|
863
|
+
ok: false,
|
|
864
|
+
version: ENVELOPE_VERSION,
|
|
865
|
+
meta: {
|
|
866
|
+
confidence,
|
|
867
|
+
risk,
|
|
868
|
+
explain: (explain || message).slice(0, 280),
|
|
869
|
+
},
|
|
870
|
+
error: errorObj,
|
|
871
|
+
partial_data: partialData,
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
export interface MakeSuccessResponseOptions {
|
|
876
|
+
data: unknown;
|
|
877
|
+
confidence: number;
|
|
878
|
+
risk: RiskLevel;
|
|
879
|
+
explain: string;
|
|
880
|
+
latencyMs?: number;
|
|
881
|
+
model?: string;
|
|
882
|
+
traceId?: string;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Build a standardized success response.
|
|
887
|
+
*/
|
|
888
|
+
export function makeSuccessResponse(options: MakeSuccessResponseOptions): EnvelopeResponseV22<unknown> {
|
|
889
|
+
const { data, confidence, risk, explain, latencyMs, model, traceId } = options;
|
|
890
|
+
|
|
891
|
+
const meta: EnvelopeMeta = {
|
|
892
|
+
confidence: Math.max(0.0, Math.min(1.0, confidence)),
|
|
893
|
+
risk,
|
|
894
|
+
explain: explain ? explain.slice(0, 280) : 'No explanation provided',
|
|
895
|
+
};
|
|
896
|
+
|
|
897
|
+
if (latencyMs !== undefined) {
|
|
898
|
+
meta.latency_ms = latencyMs;
|
|
899
|
+
}
|
|
900
|
+
if (model) {
|
|
901
|
+
meta.model = model;
|
|
902
|
+
}
|
|
903
|
+
if (traceId) {
|
|
904
|
+
meta.trace_id = traceId;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
return {
|
|
908
|
+
ok: true,
|
|
909
|
+
version: ENVELOPE_VERSION,
|
|
910
|
+
meta,
|
|
911
|
+
data,
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// =============================================================================
|
|
916
|
+
// Run Options
|
|
917
|
+
// =============================================================================
|
|
918
|
+
|
|
23
919
|
export interface RunOptions {
|
|
24
920
|
// Clean input (v2 style)
|
|
25
921
|
input?: ModuleInput;
|
|
@@ -30,6 +926,12 @@ export interface RunOptions {
|
|
|
30
926
|
// Runtime options
|
|
31
927
|
verbose?: boolean;
|
|
32
928
|
|
|
929
|
+
// Whether to validate input against schema (default: true)
|
|
930
|
+
validateInput?: boolean;
|
|
931
|
+
|
|
932
|
+
// Whether to validate output against schema (default: true)
|
|
933
|
+
validateOutput?: boolean;
|
|
934
|
+
|
|
33
935
|
// Force envelope format (default: auto-detect from module.output.envelope)
|
|
34
936
|
useEnvelope?: boolean;
|
|
35
937
|
|
|
@@ -38,6 +940,12 @@ export interface RunOptions {
|
|
|
38
940
|
|
|
39
941
|
// Enable repair pass for validation failures (default: true)
|
|
40
942
|
enableRepair?: boolean;
|
|
943
|
+
|
|
944
|
+
// Trace ID for distributed tracing
|
|
945
|
+
traceId?: string;
|
|
946
|
+
|
|
947
|
+
// Model identifier (for meta.model tracking)
|
|
948
|
+
model?: string;
|
|
41
949
|
}
|
|
42
950
|
|
|
43
951
|
// =============================================================================
|
|
@@ -47,20 +955,24 @@ export interface RunOptions {
|
|
|
47
955
|
/**
|
|
48
956
|
* Attempt to repair envelope format issues without changing semantics.
|
|
49
957
|
*
|
|
50
|
-
* Repairs (lossless
|
|
958
|
+
* Repairs (mostly lossless, except explain truncation):
|
|
51
959
|
* - Missing meta fields (fill with conservative defaults)
|
|
52
960
|
* - Truncate explain if too long
|
|
53
961
|
* - Trim whitespace from string fields
|
|
962
|
+
* - Clamp confidence to [0, 1] range
|
|
54
963
|
*
|
|
55
964
|
* Does NOT repair:
|
|
56
965
|
* - Invalid enum values (treated as validation failure)
|
|
966
|
+
*
|
|
967
|
+
* Note: Returns a deep copy to avoid modifying the original data.
|
|
57
968
|
*/
|
|
58
969
|
function repairEnvelope(
|
|
59
970
|
response: Record<string, unknown>,
|
|
60
971
|
riskRule: RiskRule = 'max_changes_risk',
|
|
61
972
|
maxExplainLength: number = 280
|
|
62
973
|
): EnvelopeResponseV22<unknown> {
|
|
63
|
-
|
|
974
|
+
// Deep clone to avoid mutation
|
|
975
|
+
const repaired = deepClone(response);
|
|
64
976
|
|
|
65
977
|
// Ensure meta exists
|
|
66
978
|
if (!repaired.meta || typeof repaired.meta !== 'object') {
|
|
@@ -101,7 +1013,7 @@ function repairEnvelope(
|
|
|
101
1013
|
meta.explain = (meta.explain as string).slice(0, maxExplainLength - 3) + '...';
|
|
102
1014
|
}
|
|
103
1015
|
|
|
104
|
-
// Build proper v2.2 response
|
|
1016
|
+
// Build proper v2.2 response with version
|
|
105
1017
|
const builtMeta: EnvelopeMeta = {
|
|
106
1018
|
confidence: meta.confidence as number,
|
|
107
1019
|
risk: meta.risk as RiskLevel,
|
|
@@ -110,11 +1022,13 @@ function repairEnvelope(
|
|
|
110
1022
|
|
|
111
1023
|
const result: EnvelopeResponseV22<unknown> = repaired.ok === false ? {
|
|
112
1024
|
ok: false,
|
|
1025
|
+
version: ENVELOPE_VERSION,
|
|
113
1026
|
meta: builtMeta,
|
|
114
1027
|
error: (repaired.error as { code: string; message: string }) ?? { code: 'UNKNOWN', message: 'Unknown error' },
|
|
115
1028
|
partial_data: repaired.partial_data
|
|
116
1029
|
} : {
|
|
117
1030
|
ok: true,
|
|
1031
|
+
version: ENVELOPE_VERSION,
|
|
118
1032
|
meta: builtMeta,
|
|
119
1033
|
data: repaired.data
|
|
120
1034
|
};
|
|
@@ -122,6 +1036,50 @@ function repairEnvelope(
|
|
|
122
1036
|
return result;
|
|
123
1037
|
}
|
|
124
1038
|
|
|
1039
|
+
/**
|
|
1040
|
+
* Repair error envelope format.
|
|
1041
|
+
*
|
|
1042
|
+
* Note: Returns a deep copy to avoid modifying the original data.
|
|
1043
|
+
*/
|
|
1044
|
+
function repairErrorEnvelope(
|
|
1045
|
+
data: Record<string, unknown>,
|
|
1046
|
+
maxExplainLength: number = 280
|
|
1047
|
+
): EnvelopeResponseV22<unknown> {
|
|
1048
|
+
// Deep clone to avoid mutation
|
|
1049
|
+
const repaired = deepClone(data);
|
|
1050
|
+
|
|
1051
|
+
// Ensure meta exists for errors
|
|
1052
|
+
if (!repaired.meta || typeof repaired.meta !== 'object') {
|
|
1053
|
+
repaired.meta = {};
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
const meta = repaired.meta as Record<string, unknown>;
|
|
1057
|
+
|
|
1058
|
+
// Set default meta for errors
|
|
1059
|
+
if (typeof meta.confidence !== 'number') {
|
|
1060
|
+
meta.confidence = 0.0;
|
|
1061
|
+
}
|
|
1062
|
+
if (!meta.risk) {
|
|
1063
|
+
meta.risk = 'high';
|
|
1064
|
+
}
|
|
1065
|
+
if (typeof meta.explain !== 'string') {
|
|
1066
|
+
const error = (repaired.error ?? {}) as Record<string, unknown>;
|
|
1067
|
+
meta.explain = ((error.message as string) ?? 'An error occurred').slice(0, maxExplainLength);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
return {
|
|
1071
|
+
ok: false,
|
|
1072
|
+
version: ENVELOPE_VERSION,
|
|
1073
|
+
meta: {
|
|
1074
|
+
confidence: meta.confidence as number,
|
|
1075
|
+
risk: meta.risk as RiskLevel,
|
|
1076
|
+
explain: meta.explain as string,
|
|
1077
|
+
},
|
|
1078
|
+
error: (repaired.error as { code: string; message: string }) ?? { code: 'UNKNOWN', message: 'Unknown error' },
|
|
1079
|
+
partial_data: repaired.partial_data,
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
|
|
125
1083
|
/**
|
|
126
1084
|
* Wrap v2.1 response to v2.2 format
|
|
127
1085
|
*/
|
|
@@ -130,6 +1088,10 @@ function wrapV21ToV22(
|
|
|
130
1088
|
riskRule: RiskRule = 'max_changes_risk'
|
|
131
1089
|
): EnvelopeResponseV22<unknown> {
|
|
132
1090
|
if (isV22Envelope(response)) {
|
|
1091
|
+
// Already v2.2, but ensure version field exists
|
|
1092
|
+
if (!('version' in response) || !response.version) {
|
|
1093
|
+
return { ...deepClone(response), version: ENVELOPE_VERSION };
|
|
1094
|
+
}
|
|
133
1095
|
return response;
|
|
134
1096
|
}
|
|
135
1097
|
|
|
@@ -140,6 +1102,7 @@ function wrapV21ToV22(
|
|
|
140
1102
|
|
|
141
1103
|
return {
|
|
142
1104
|
ok: true,
|
|
1105
|
+
version: ENVELOPE_VERSION,
|
|
143
1106
|
meta: {
|
|
144
1107
|
confidence,
|
|
145
1108
|
risk: aggregateRisk(data, riskRule),
|
|
@@ -151,6 +1114,7 @@ function wrapV21ToV22(
|
|
|
151
1114
|
const errorMsg = response.error?.message ?? 'Unknown error';
|
|
152
1115
|
return {
|
|
153
1116
|
ok: false,
|
|
1117
|
+
version: ENVELOPE_VERSION,
|
|
154
1118
|
meta: {
|
|
155
1119
|
confidence: 0,
|
|
156
1120
|
risk: 'high',
|
|
@@ -162,12 +1126,61 @@ function wrapV21ToV22(
|
|
|
162
1126
|
}
|
|
163
1127
|
}
|
|
164
1128
|
|
|
1129
|
+
/**
|
|
1130
|
+
* Convert legacy format (no envelope) to v2.2 envelope.
|
|
1131
|
+
*/
|
|
1132
|
+
function convertLegacyToEnvelope(
|
|
1133
|
+
data: Record<string, unknown>,
|
|
1134
|
+
isError: boolean = false
|
|
1135
|
+
): EnvelopeResponseV22<unknown> {
|
|
1136
|
+
if (isError || 'error' in data) {
|
|
1137
|
+
const error = (data.error ?? {}) as Record<string, unknown>;
|
|
1138
|
+
const errorMsg = typeof error === 'object'
|
|
1139
|
+
? ((error.message as string) ?? String(error))
|
|
1140
|
+
: String(error);
|
|
1141
|
+
|
|
1142
|
+
return {
|
|
1143
|
+
ok: false,
|
|
1144
|
+
version: ENVELOPE_VERSION,
|
|
1145
|
+
meta: {
|
|
1146
|
+
confidence: 0.0,
|
|
1147
|
+
risk: 'high',
|
|
1148
|
+
explain: errorMsg.slice(0, 280),
|
|
1149
|
+
},
|
|
1150
|
+
error: {
|
|
1151
|
+
code: (typeof error === 'object' ? (error.code as string) : undefined) ?? 'UNKNOWN',
|
|
1152
|
+
message: errorMsg,
|
|
1153
|
+
},
|
|
1154
|
+
partial_data: undefined,
|
|
1155
|
+
};
|
|
1156
|
+
} else {
|
|
1157
|
+
const confidence = (data.confidence as number) ?? 0.5;
|
|
1158
|
+
const rationale = (data.rationale as string) ?? '';
|
|
1159
|
+
|
|
1160
|
+
return {
|
|
1161
|
+
ok: true,
|
|
1162
|
+
version: ENVELOPE_VERSION,
|
|
1163
|
+
meta: {
|
|
1164
|
+
confidence,
|
|
1165
|
+
risk: aggregateRisk(data),
|
|
1166
|
+
explain: rationale.slice(0, 280) || 'No explanation provided',
|
|
1167
|
+
},
|
|
1168
|
+
data,
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// =============================================================================
|
|
1174
|
+
// Main Runner
|
|
1175
|
+
// =============================================================================
|
|
1176
|
+
|
|
165
1177
|
export async function runModule(
|
|
166
1178
|
module: CognitiveModule,
|
|
167
1179
|
provider: Provider,
|
|
168
1180
|
options: RunOptions = {}
|
|
169
1181
|
): Promise<ModuleResult> {
|
|
170
|
-
const { args, input, verbose = false, useEnvelope, useV22, enableRepair = true } = options;
|
|
1182
|
+
const { args, input, verbose = false, validateInput = true, validateOutput = true, useEnvelope, useV22, enableRepair = true, traceId, model: modelOverride } = options;
|
|
1183
|
+
const startTime = Date.now();
|
|
171
1184
|
|
|
172
1185
|
// Determine if we should use envelope format
|
|
173
1186
|
const shouldUseEnvelope = useEnvelope ?? (module.output?.envelope === true || module.format === 'v2');
|
|
@@ -192,6 +1205,26 @@ export async function runModule(
|
|
|
192
1205
|
}
|
|
193
1206
|
}
|
|
194
1207
|
|
|
1208
|
+
// Invoke before hooks
|
|
1209
|
+
_invokeBeforeHooks(module.name, inputData, module);
|
|
1210
|
+
|
|
1211
|
+
// Validate input against schema
|
|
1212
|
+
if (validateInput && module.inputSchema && Object.keys(module.inputSchema).length > 0) {
|
|
1213
|
+
const inputErrors = validateData(inputData, module.inputSchema, 'Input');
|
|
1214
|
+
if (inputErrors.length > 0) {
|
|
1215
|
+
const errorResult = makeErrorResponse({
|
|
1216
|
+
code: 'INVALID_INPUT',
|
|
1217
|
+
message: inputErrors.join('; '),
|
|
1218
|
+
explain: 'Input validation failed.',
|
|
1219
|
+
confidence: 1.0,
|
|
1220
|
+
risk: 'none',
|
|
1221
|
+
details: { validation_errors: inputErrors },
|
|
1222
|
+
});
|
|
1223
|
+
_invokeErrorHooks(module.name, new Error(inputErrors.join('; ')), null);
|
|
1224
|
+
return errorResult as ModuleResult;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
195
1228
|
// Build prompt with clean substitution
|
|
196
1229
|
const prompt = buildPrompt(module, inputData);
|
|
197
1230
|
|
|
@@ -269,82 +1302,437 @@ export async function runModule(
|
|
|
269
1302
|
{ role: 'user', content: prompt },
|
|
270
1303
|
];
|
|
271
1304
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
1305
|
+
try {
|
|
1306
|
+
// Invoke provider
|
|
1307
|
+
const result = await provider.invoke({
|
|
1308
|
+
messages,
|
|
1309
|
+
jsonSchema: module.outputSchema,
|
|
1310
|
+
temperature: 0.3,
|
|
1311
|
+
});
|
|
278
1312
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
1313
|
+
if (verbose) {
|
|
1314
|
+
console.error('--- Response ---');
|
|
1315
|
+
console.error(result.content);
|
|
1316
|
+
console.error('--- End Response ---');
|
|
1317
|
+
}
|
|
284
1318
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
try {
|
|
288
|
-
const jsonMatch = result.content.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
|
289
|
-
const jsonStr = jsonMatch ? jsonMatch[1] : result.content;
|
|
290
|
-
parsed = JSON.parse(jsonStr.trim());
|
|
291
|
-
} catch {
|
|
292
|
-
throw new Error(`Failed to parse JSON response: ${result.content.substring(0, 500)}`);
|
|
293
|
-
}
|
|
1319
|
+
// Calculate latency
|
|
1320
|
+
const latencyMs = Date.now() - startTime;
|
|
294
1321
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
1322
|
+
// Parse response
|
|
1323
|
+
let parsed: unknown;
|
|
1324
|
+
try {
|
|
1325
|
+
const jsonMatch = result.content.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
|
1326
|
+
const jsonStr = jsonMatch ? jsonMatch[1] : result.content;
|
|
1327
|
+
parsed = JSON.parse(jsonStr.trim());
|
|
1328
|
+
} catch (e) {
|
|
1329
|
+
const errorResult = makeErrorResponse({
|
|
1330
|
+
code: 'PARSE_ERROR',
|
|
1331
|
+
message: `Failed to parse JSON response: ${(e as Error).message}`,
|
|
1332
|
+
explain: 'Failed to parse LLM response as JSON.',
|
|
1333
|
+
details: { raw_response: result.content.substring(0, 500) },
|
|
1334
|
+
});
|
|
1335
|
+
_invokeErrorHooks(module.name, e as Error, null);
|
|
1336
|
+
return errorResult as ModuleResult;
|
|
308
1337
|
}
|
|
309
|
-
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
);
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
meta: repaired.meta as EnvelopeMeta,
|
|
319
|
-
data: (repaired as { data?: ModuleResultData }).data,
|
|
320
|
-
raw: result.content
|
|
321
|
-
} as ModuleResultV22;
|
|
1338
|
+
|
|
1339
|
+
// Convert to v2.2 envelope
|
|
1340
|
+
let response: EnvelopeResponseV22<unknown>;
|
|
1341
|
+
if (isV22Envelope(parsed as EnvelopeResponse<unknown>)) {
|
|
1342
|
+
response = parsed as EnvelopeResponseV22<unknown>;
|
|
1343
|
+
} else if (isEnvelopeResponse(parsed)) {
|
|
1344
|
+
response = wrapV21ToV22(parsed as EnvelopeResponse<unknown>, riskRule);
|
|
1345
|
+
} else {
|
|
1346
|
+
response = convertLegacyToEnvelope(parsed as Record<string, unknown>);
|
|
322
1347
|
}
|
|
323
|
-
|
|
324
|
-
|
|
1348
|
+
|
|
1349
|
+
// Add version and meta fields
|
|
1350
|
+
response.version = ENVELOPE_VERSION;
|
|
1351
|
+
if (response.meta) {
|
|
1352
|
+
response.meta.latency_ms = latencyMs;
|
|
1353
|
+
if (traceId) {
|
|
1354
|
+
response.meta.trace_id = traceId;
|
|
1355
|
+
}
|
|
1356
|
+
if (modelOverride) {
|
|
1357
|
+
response.meta.model = modelOverride;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// Validate and potentially repair output
|
|
1362
|
+
if (response.ok && validateOutput) {
|
|
1363
|
+
// Get data schema (support both "data" and "output" aliases)
|
|
1364
|
+
const dataSchema = module.dataSchema || module.outputSchema;
|
|
1365
|
+
const metaSchema = module.metaSchema;
|
|
1366
|
+
const dataToValidate = (response as EnvelopeResponseV22<unknown> & { data?: unknown }).data ?? {};
|
|
1367
|
+
|
|
1368
|
+
if (dataSchema && Object.keys(dataSchema).length > 0) {
|
|
1369
|
+
let dataErrors = validateData(dataToValidate, dataSchema, 'Data');
|
|
1370
|
+
|
|
1371
|
+
if (dataErrors.length > 0 && enableRepair) {
|
|
1372
|
+
// Attempt repair pass
|
|
1373
|
+
response = repairEnvelope(
|
|
1374
|
+
response as unknown as Record<string, unknown>,
|
|
1375
|
+
riskRule
|
|
1376
|
+
);
|
|
1377
|
+
response.version = ENVELOPE_VERSION;
|
|
1378
|
+
|
|
1379
|
+
// Re-validate after repair
|
|
1380
|
+
const repairedData = (response as EnvelopeResponseV22<unknown> & { data?: unknown }).data ?? {};
|
|
1381
|
+
dataErrors = validateData(repairedData, dataSchema, 'Data');
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
if (dataErrors.length > 0) {
|
|
1385
|
+
const errorResult = makeErrorResponse({
|
|
1386
|
+
code: 'SCHEMA_VALIDATION_FAILED',
|
|
1387
|
+
message: dataErrors.join('; '),
|
|
1388
|
+
explain: 'Schema validation failed after repair attempt.',
|
|
1389
|
+
partialData: (response as EnvelopeResponseV22<unknown> & { data?: unknown }).data,
|
|
1390
|
+
details: { validation_errors: dataErrors },
|
|
1391
|
+
});
|
|
1392
|
+
_invokeErrorHooks(module.name, new Error(dataErrors.join('; ')), (response as EnvelopeResponseV22<unknown> & { data?: unknown }).data);
|
|
1393
|
+
return errorResult as ModuleResult;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
// v2.2: Validate overflow limits
|
|
1398
|
+
const overflowErrors = validateOverflowLimits(dataToValidate as Record<string, unknown>, module);
|
|
1399
|
+
if (overflowErrors.length > 0) {
|
|
1400
|
+
const errorResult = makeErrorResponse({
|
|
1401
|
+
code: 'SCHEMA_VALIDATION_FAILED',
|
|
1402
|
+
message: overflowErrors.join('; '),
|
|
1403
|
+
explain: 'Overflow validation failed.',
|
|
1404
|
+
partialData: dataToValidate,
|
|
1405
|
+
details: { overflow_errors: overflowErrors },
|
|
1406
|
+
});
|
|
1407
|
+
_invokeErrorHooks(module.name, new Error(overflowErrors.join('; ')), dataToValidate);
|
|
1408
|
+
return errorResult as ModuleResult;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// v2.2: Validate enum strategy
|
|
1412
|
+
const enumErrors = validateEnumStrategy(dataToValidate as Record<string, unknown>, module);
|
|
1413
|
+
if (enumErrors.length > 0) {
|
|
1414
|
+
const errorResult = makeErrorResponse({
|
|
1415
|
+
code: 'SCHEMA_VALIDATION_FAILED',
|
|
1416
|
+
message: enumErrors.join('; '),
|
|
1417
|
+
explain: 'Enum strategy validation failed.',
|
|
1418
|
+
partialData: dataToValidate,
|
|
1419
|
+
details: { enum_errors: enumErrors },
|
|
1420
|
+
});
|
|
1421
|
+
_invokeErrorHooks(module.name, new Error(enumErrors.join('; ')), dataToValidate);
|
|
1422
|
+
return errorResult as ModuleResult;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// Validate meta if schema exists
|
|
1426
|
+
if (metaSchema && Object.keys(metaSchema).length > 0) {
|
|
1427
|
+
let metaErrors = validateData(response.meta ?? {}, metaSchema, 'Meta');
|
|
1428
|
+
|
|
1429
|
+
if (metaErrors.length > 0 && enableRepair) {
|
|
1430
|
+
response = repairEnvelope(
|
|
1431
|
+
response as unknown as Record<string, unknown>,
|
|
1432
|
+
riskRule
|
|
1433
|
+
);
|
|
1434
|
+
response.version = ENVELOPE_VERSION;
|
|
1435
|
+
|
|
1436
|
+
// Re-validate meta after repair
|
|
1437
|
+
metaErrors = validateData(response.meta ?? {}, metaSchema, 'Meta');
|
|
1438
|
+
|
|
1439
|
+
if (metaErrors.length > 0) {
|
|
1440
|
+
const errorResult = makeErrorResponse({
|
|
1441
|
+
code: 'META_VALIDATION_FAILED',
|
|
1442
|
+
message: metaErrors.join('; '),
|
|
1443
|
+
explain: 'Meta schema validation failed after repair attempt.',
|
|
1444
|
+
partialData: (response as EnvelopeResponseV22<unknown> & { data?: unknown }).data,
|
|
1445
|
+
details: { validation_errors: metaErrors },
|
|
1446
|
+
});
|
|
1447
|
+
_invokeErrorHooks(module.name, new Error(metaErrors.join('; ')), (response as EnvelopeResponseV22<unknown> & { data?: unknown }).data);
|
|
1448
|
+
return errorResult as ModuleResult;
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
} else if (enableRepair) {
|
|
1453
|
+
// Repair error envelopes to ensure they have proper meta fields
|
|
1454
|
+
response = repairErrorEnvelope(response as unknown as Record<string, unknown>);
|
|
1455
|
+
response.version = ENVELOPE_VERSION;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// Invoke after hooks
|
|
1459
|
+
const finalLatencyMs = Date.now() - startTime;
|
|
1460
|
+
_invokeAfterHooks(module.name, response, finalLatencyMs);
|
|
1461
|
+
|
|
1462
|
+
return response as ModuleResult;
|
|
1463
|
+
|
|
1464
|
+
} catch (e) {
|
|
1465
|
+
const latencyMs = Date.now() - startTime;
|
|
1466
|
+
const errorResult = makeErrorResponse({
|
|
1467
|
+
code: 'UNKNOWN',
|
|
1468
|
+
message: (e as Error).message,
|
|
1469
|
+
explain: `Unexpected error: ${(e as Error).name}`,
|
|
1470
|
+
details: { exception_type: (e as Error).name },
|
|
1471
|
+
});
|
|
1472
|
+
if (errorResult.meta) {
|
|
1473
|
+
errorResult.meta.latency_ms = latencyMs;
|
|
1474
|
+
}
|
|
1475
|
+
_invokeErrorHooks(module.name, e as Error, null);
|
|
1476
|
+
return errorResult as ModuleResult;
|
|
325
1477
|
}
|
|
1478
|
+
}
|
|
326
1479
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
1480
|
+
// =============================================================================
|
|
1481
|
+
// Streaming Support
|
|
1482
|
+
// =============================================================================
|
|
1483
|
+
|
|
1484
|
+
/** Event types emitted during streaming execution */
|
|
1485
|
+
export type StreamEventType = 'start' | 'chunk' | 'meta' | 'complete' | 'error';
|
|
1486
|
+
|
|
1487
|
+
/** Event emitted during streaming execution */
|
|
1488
|
+
export interface StreamEvent {
|
|
1489
|
+
type: StreamEventType;
|
|
1490
|
+
timestamp_ms: number;
|
|
1491
|
+
module_name: string;
|
|
1492
|
+
chunk?: string;
|
|
1493
|
+
meta?: EnvelopeMeta;
|
|
1494
|
+
result?: EnvelopeResponseV22<unknown>;
|
|
1495
|
+
error?: { code: string; message: string };
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
export interface StreamOptions {
|
|
1499
|
+
input?: ModuleInput;
|
|
1500
|
+
args?: string;
|
|
1501
|
+
validateInput?: boolean;
|
|
1502
|
+
validateOutput?: boolean;
|
|
1503
|
+
useV22?: boolean;
|
|
1504
|
+
enableRepair?: boolean;
|
|
1505
|
+
traceId?: string;
|
|
1506
|
+
model?: string; // Model identifier for meta.model
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/**
|
|
1510
|
+
* Run a cognitive module with streaming output.
|
|
1511
|
+
*
|
|
1512
|
+
* Yields StreamEvent objects as the module executes:
|
|
1513
|
+
* - type="start": Module execution started
|
|
1514
|
+
* - type="chunk": Incremental data chunk (if LLM supports streaming)
|
|
1515
|
+
* - type="meta": Meta information available early
|
|
1516
|
+
* - type="complete": Final complete result
|
|
1517
|
+
* - type="error": Error occurred
|
|
1518
|
+
*
|
|
1519
|
+
* @example
|
|
1520
|
+
* for await (const event of runModuleStream(module, provider, options)) {
|
|
1521
|
+
* if (event.type === 'chunk') {
|
|
1522
|
+
* process.stdout.write(event.chunk);
|
|
1523
|
+
* } else if (event.type === 'complete') {
|
|
1524
|
+
* console.log('Result:', event.result);
|
|
1525
|
+
* }
|
|
1526
|
+
* }
|
|
1527
|
+
*/
|
|
1528
|
+
export async function* runModuleStream(
|
|
1529
|
+
module: CognitiveModule,
|
|
1530
|
+
provider: Provider,
|
|
1531
|
+
options: StreamOptions = {}
|
|
1532
|
+
): AsyncGenerator<StreamEvent> {
|
|
1533
|
+
const { input, args, validateInput = true, validateOutput = true, useV22 = true, enableRepair = true, traceId, model } = options;
|
|
1534
|
+
const startTime = Date.now();
|
|
1535
|
+
const moduleName = module.name;
|
|
1536
|
+
|
|
1537
|
+
function makeEvent(type: StreamEventType, extra: Partial<StreamEvent> = {}): StreamEvent {
|
|
333
1538
|
return {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
1539
|
+
type,
|
|
1540
|
+
timestamp_ms: Date.now() - startTime,
|
|
1541
|
+
module_name: moduleName,
|
|
1542
|
+
...extra,
|
|
1543
|
+
};
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
try {
|
|
1547
|
+
// Emit start event
|
|
1548
|
+
yield makeEvent('start');
|
|
1549
|
+
|
|
1550
|
+
// Build input data
|
|
1551
|
+
const inputData: ModuleInput = input || {};
|
|
1552
|
+
if (args && !inputData.code && !inputData.query) {
|
|
1553
|
+
if (looksLikeCode(args)) {
|
|
1554
|
+
inputData.code = args;
|
|
1555
|
+
} else {
|
|
1556
|
+
inputData.query = args;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// Validate input if enabled
|
|
1561
|
+
if (validateInput && module.inputSchema && Object.keys(module.inputSchema).length > 0) {
|
|
1562
|
+
const inputErrors = validateData(inputData, module.inputSchema, 'Input');
|
|
1563
|
+
if (inputErrors.length > 0) {
|
|
1564
|
+
const errorResult = makeErrorResponse({
|
|
1565
|
+
code: 'INVALID_INPUT',
|
|
1566
|
+
message: inputErrors.join('; '),
|
|
1567
|
+
confidence: 1.0,
|
|
1568
|
+
risk: 'none',
|
|
1569
|
+
});
|
|
1570
|
+
const errorObj = (errorResult as { error: { code: string; message: string } }).error;
|
|
1571
|
+
yield makeEvent('error', { error: errorObj });
|
|
1572
|
+
yield makeEvent('complete', { result: errorResult });
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// Get risk_rule from module config
|
|
1578
|
+
const riskRule: RiskRule = module.metaConfig?.risk_rule ?? 'max_changes_risk';
|
|
1579
|
+
|
|
1580
|
+
// Build prompt
|
|
1581
|
+
const prompt = buildPrompt(module, inputData);
|
|
1582
|
+
|
|
1583
|
+
// Build messages
|
|
1584
|
+
const systemParts: string[] = [
|
|
1585
|
+
`You are executing the "${module.name}" Cognitive Module.`,
|
|
1586
|
+
'',
|
|
1587
|
+
`RESPONSIBILITY: ${module.responsibility}`,
|
|
1588
|
+
];
|
|
1589
|
+
|
|
1590
|
+
if (useV22) {
|
|
1591
|
+
systemParts.push('', 'RESPONSE FORMAT (Envelope v2.2):');
|
|
1592
|
+
systemParts.push('- Wrap your response in the v2.2 envelope format');
|
|
1593
|
+
systemParts.push('- Success: { "ok": true, "meta": { "confidence": 0.9, "risk": "low", "explain": "short summary" }, "data": { ...payload... } }');
|
|
1594
|
+
systemParts.push('- Return ONLY valid JSON.');
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
const messages: Message[] = [
|
|
1598
|
+
{ role: 'system', content: systemParts.join('\n') },
|
|
1599
|
+
{ role: 'user', content: prompt },
|
|
1600
|
+
];
|
|
1601
|
+
|
|
1602
|
+
// Invoke provider (streaming not yet supported in provider interface, so we fallback)
|
|
1603
|
+
const result = await provider.invoke({
|
|
1604
|
+
messages,
|
|
1605
|
+
jsonSchema: module.outputSchema,
|
|
1606
|
+
temperature: 0.3,
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
// Emit chunk event with full response
|
|
1610
|
+
yield makeEvent('chunk', { chunk: result.content });
|
|
1611
|
+
|
|
1612
|
+
// Parse response
|
|
1613
|
+
let parsed: unknown;
|
|
1614
|
+
try {
|
|
1615
|
+
const jsonMatch = result.content.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
|
1616
|
+
const jsonStr = jsonMatch ? jsonMatch[1] : result.content;
|
|
1617
|
+
parsed = JSON.parse(jsonStr.trim());
|
|
1618
|
+
} catch (e) {
|
|
1619
|
+
const errorResult = makeErrorResponse({
|
|
1620
|
+
code: 'PARSE_ERROR',
|
|
1621
|
+
message: `Failed to parse JSON: ${(e as Error).message}`,
|
|
1622
|
+
});
|
|
1623
|
+
// errorResult is always an error response from makeErrorResponse
|
|
1624
|
+
const errorObj = (errorResult as { error: { code: string; message: string } }).error;
|
|
1625
|
+
yield makeEvent('error', { error: errorObj });
|
|
1626
|
+
yield makeEvent('complete', { result: errorResult });
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// Convert to v2.2 envelope
|
|
1631
|
+
let response: EnvelopeResponseV22<unknown>;
|
|
1632
|
+
if (isV22Envelope(parsed as EnvelopeResponse<unknown>)) {
|
|
1633
|
+
response = parsed as EnvelopeResponseV22<unknown>;
|
|
1634
|
+
} else if (isEnvelopeResponse(parsed)) {
|
|
1635
|
+
response = wrapV21ToV22(parsed as EnvelopeResponse<unknown>, riskRule);
|
|
1636
|
+
} else {
|
|
1637
|
+
response = convertLegacyToEnvelope(parsed as Record<string, unknown>);
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// Add version and meta
|
|
1641
|
+
response.version = ENVELOPE_VERSION;
|
|
1642
|
+
const latencyMs = Date.now() - startTime;
|
|
1643
|
+
if (response.meta) {
|
|
1644
|
+
response.meta.latency_ms = latencyMs;
|
|
1645
|
+
if (traceId) {
|
|
1646
|
+
response.meta.trace_id = traceId;
|
|
1647
|
+
}
|
|
1648
|
+
if (model) {
|
|
1649
|
+
response.meta.model = model;
|
|
1650
|
+
}
|
|
1651
|
+
// Emit meta event early
|
|
1652
|
+
yield makeEvent('meta', { meta: response.meta });
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// Validate and repair output
|
|
1656
|
+
if (response.ok && validateOutput) {
|
|
1657
|
+
const dataSchema = module.dataSchema || module.outputSchema;
|
|
1658
|
+
const metaSchema = module.metaSchema;
|
|
1659
|
+
|
|
1660
|
+
if (dataSchema && Object.keys(dataSchema).length > 0) {
|
|
1661
|
+
const dataToValidate = (response as EnvelopeResponseV22<unknown> & { data?: unknown }).data ?? {};
|
|
1662
|
+
let dataErrors = validateData(dataToValidate, dataSchema, 'Data');
|
|
1663
|
+
|
|
1664
|
+
if (dataErrors.length > 0 && enableRepair) {
|
|
1665
|
+
response = repairEnvelope(response as unknown as Record<string, unknown>, riskRule);
|
|
1666
|
+
response.version = ENVELOPE_VERSION;
|
|
1667
|
+
// Re-validate after repair
|
|
1668
|
+
const repairedData = (response as EnvelopeResponseV22<unknown> & { data?: unknown }).data ?? {};
|
|
1669
|
+
dataErrors = validateData(repairedData, dataSchema, 'Data');
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
if (dataErrors.length > 0) {
|
|
1673
|
+
const errorResult = makeErrorResponse({
|
|
1674
|
+
code: 'SCHEMA_VALIDATION_FAILED',
|
|
1675
|
+
message: dataErrors.join('; '),
|
|
1676
|
+
explain: 'Schema validation failed after repair attempt.',
|
|
1677
|
+
partialData: (response as EnvelopeResponseV22<unknown> & { data?: unknown }).data,
|
|
1678
|
+
details: { validation_errors: dataErrors },
|
|
1679
|
+
});
|
|
1680
|
+
const errorObj = (errorResult as { error: { code: string; message: string } }).error;
|
|
1681
|
+
yield makeEvent('error', { error: errorObj });
|
|
1682
|
+
yield makeEvent('complete', { result: errorResult });
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// Validate meta if schema exists
|
|
1688
|
+
if (metaSchema && Object.keys(metaSchema).length > 0) {
|
|
1689
|
+
let metaErrors = validateData(response.meta ?? {}, metaSchema, 'Meta');
|
|
1690
|
+
|
|
1691
|
+
if (metaErrors.length > 0 && enableRepair) {
|
|
1692
|
+
response = repairEnvelope(response as unknown as Record<string, unknown>, riskRule);
|
|
1693
|
+
response.version = ENVELOPE_VERSION;
|
|
1694
|
+
metaErrors = validateData(response.meta ?? {}, metaSchema, 'Meta');
|
|
1695
|
+
|
|
1696
|
+
if (metaErrors.length > 0) {
|
|
1697
|
+
const errorResult = makeErrorResponse({
|
|
1698
|
+
code: 'META_VALIDATION_FAILED',
|
|
1699
|
+
message: metaErrors.join('; '),
|
|
1700
|
+
explain: 'Meta validation failed after repair attempt.',
|
|
1701
|
+
partialData: (response as EnvelopeResponseV22<unknown> & { data?: unknown }).data,
|
|
1702
|
+
details: { validation_errors: metaErrors },
|
|
1703
|
+
});
|
|
1704
|
+
const errorObj = (errorResult as { error: { code: string; message: string } }).error;
|
|
1705
|
+
yield makeEvent('error', { error: errorObj });
|
|
1706
|
+
yield makeEvent('complete', { result: errorResult });
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
} else if (!response.ok && enableRepair) {
|
|
1712
|
+
response = repairErrorEnvelope(response as unknown as Record<string, unknown>);
|
|
1713
|
+
response.version = ENVELOPE_VERSION;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// Emit complete event
|
|
1717
|
+
yield makeEvent('complete', { result: response });
|
|
1718
|
+
|
|
1719
|
+
} catch (e) {
|
|
1720
|
+
const errorResult = makeErrorResponse({
|
|
1721
|
+
code: 'UNKNOWN',
|
|
1722
|
+
message: (e as Error).message,
|
|
1723
|
+
explain: `Unexpected error: ${(e as Error).name}`,
|
|
1724
|
+
});
|
|
1725
|
+
// errorResult is always an error response from makeErrorResponse
|
|
1726
|
+
const errorObj = (errorResult as { error: { code: string; message: string } }).error;
|
|
1727
|
+
yield makeEvent('error', { error: errorObj });
|
|
1728
|
+
yield makeEvent('complete', { result: errorResult });
|
|
343
1729
|
}
|
|
344
|
-
|
|
345
|
-
return legacyResult;
|
|
346
1730
|
}
|
|
347
1731
|
|
|
1732
|
+
// =============================================================================
|
|
1733
|
+
// Helper Functions
|
|
1734
|
+
// =============================================================================
|
|
1735
|
+
|
|
348
1736
|
/**
|
|
349
1737
|
* Check if response is in envelope format
|
|
350
1738
|
*/
|
|
@@ -442,6 +1830,12 @@ function parseLegacyResponse(output: unknown, raw: string): ModuleResult {
|
|
|
442
1830
|
|
|
443
1831
|
/**
|
|
444
1832
|
* Build prompt with clean variable substitution
|
|
1833
|
+
*
|
|
1834
|
+
* Substitution order (important to avoid partial replacements):
|
|
1835
|
+
* 1. ${variable} - v2 style placeholders
|
|
1836
|
+
* 2. $ARGUMENTS[N] - indexed access (descending order to avoid $1 matching $10)
|
|
1837
|
+
* 3. $N - shorthand indexed access (descending order)
|
|
1838
|
+
* 4. $ARGUMENTS - full argument string (LAST to avoid partial matches)
|
|
445
1839
|
*/
|
|
446
1840
|
function buildPrompt(module: CognitiveModule, input: ModuleInput): string {
|
|
447
1841
|
let prompt = module.prompt;
|
|
@@ -452,18 +1846,25 @@ function buildPrompt(module: CognitiveModule, input: ModuleInput): string {
|
|
|
452
1846
|
prompt = prompt.replace(new RegExp(`\\$\\{${key}\\}`, 'g'), strValue);
|
|
453
1847
|
}
|
|
454
1848
|
|
|
455
|
-
// v1 compatibility:
|
|
1849
|
+
// v1 compatibility: get args value
|
|
456
1850
|
const argsValue = input.code || input.query || '';
|
|
457
|
-
prompt = prompt.replace(/\$ARGUMENTS/g, argsValue);
|
|
458
1851
|
|
|
459
|
-
// Substitute $N placeholders (v1 compatibility)
|
|
1852
|
+
// Substitute $ARGUMENTS[N] and $N placeholders FIRST (v1 compatibility)
|
|
1853
|
+
// Process in descending order to avoid $1 replacing part of $10
|
|
460
1854
|
if (typeof argsValue === 'string') {
|
|
461
1855
|
const argsList = argsValue.split(/\s+/);
|
|
462
|
-
argsList.
|
|
1856
|
+
for (let i = argsList.length - 1; i >= 0; i--) {
|
|
1857
|
+
const arg = argsList[i];
|
|
1858
|
+
// Replace $ARGUMENTS[N] first
|
|
1859
|
+
prompt = prompt.replace(new RegExp(`\\$ARGUMENTS\\[${i}\\]`, 'g'), arg);
|
|
1860
|
+
// Replace $N shorthand
|
|
463
1861
|
prompt = prompt.replace(new RegExp(`\\$${i}\\b`, 'g'), arg);
|
|
464
|
-
}
|
|
1862
|
+
}
|
|
465
1863
|
}
|
|
466
1864
|
|
|
1865
|
+
// Replace $ARGUMENTS LAST (after indexed forms to avoid partial matches)
|
|
1866
|
+
prompt = prompt.replace(/\$ARGUMENTS/g, argsValue);
|
|
1867
|
+
|
|
467
1868
|
// Append input summary if not already in prompt
|
|
468
1869
|
if (!prompt.includes(argsValue) && argsValue) {
|
|
469
1870
|
prompt += '\n\n## Input\n\n';
|
|
@@ -493,3 +1894,90 @@ function looksLikeCode(str: string): boolean {
|
|
|
493
1894
|
];
|
|
494
1895
|
return codeIndicators.some(re => re.test(str));
|
|
495
1896
|
}
|
|
1897
|
+
|
|
1898
|
+
// =============================================================================
|
|
1899
|
+
// Legacy API (for backward compatibility)
|
|
1900
|
+
// =============================================================================
|
|
1901
|
+
|
|
1902
|
+
export interface RunModuleLegacyOptions {
|
|
1903
|
+
validateInput?: boolean;
|
|
1904
|
+
validateOutput?: boolean;
|
|
1905
|
+
model?: string;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
/**
|
|
1909
|
+
* Run a cognitive module (legacy API, returns raw output).
|
|
1910
|
+
* For backward compatibility. Throws on error instead of returning error envelope.
|
|
1911
|
+
*/
|
|
1912
|
+
export async function runModuleLegacy(
|
|
1913
|
+
module: CognitiveModule,
|
|
1914
|
+
provider: Provider,
|
|
1915
|
+
input: ModuleInput,
|
|
1916
|
+
options: RunModuleLegacyOptions = {}
|
|
1917
|
+
): Promise<unknown> {
|
|
1918
|
+
const { validateInput = true, validateOutput = true, model } = options;
|
|
1919
|
+
|
|
1920
|
+
const result = await runModule(module, provider, {
|
|
1921
|
+
input,
|
|
1922
|
+
validateInput,
|
|
1923
|
+
validateOutput,
|
|
1924
|
+
useEnvelope: false,
|
|
1925
|
+
useV22: false,
|
|
1926
|
+
model,
|
|
1927
|
+
});
|
|
1928
|
+
|
|
1929
|
+
if (result.ok && 'data' in result) {
|
|
1930
|
+
return result.data;
|
|
1931
|
+
} else {
|
|
1932
|
+
const error = 'error' in result ? result.error : { code: 'UNKNOWN', message: 'Unknown error' };
|
|
1933
|
+
throw new Error(`${error?.code ?? 'UNKNOWN'}: ${error?.message ?? 'Unknown error'}`);
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// =============================================================================
|
|
1938
|
+
// Convenience Functions
|
|
1939
|
+
// =============================================================================
|
|
1940
|
+
|
|
1941
|
+
/**
|
|
1942
|
+
* Extract meta from v2.2 envelope for routing/logging.
|
|
1943
|
+
*/
|
|
1944
|
+
export function extractMeta(result: EnvelopeResponseV22<unknown>): EnvelopeMeta {
|
|
1945
|
+
return result.meta ?? {
|
|
1946
|
+
confidence: 0.5,
|
|
1947
|
+
risk: 'medium',
|
|
1948
|
+
explain: 'No meta available',
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
// Alias for backward compatibility
|
|
1953
|
+
export const extractMetaV22 = extractMeta;
|
|
1954
|
+
|
|
1955
|
+
/**
|
|
1956
|
+
* Determine if result should be escalated to human review based on meta.
|
|
1957
|
+
*/
|
|
1958
|
+
export function shouldEscalate(
|
|
1959
|
+
result: EnvelopeResponseV22<unknown>,
|
|
1960
|
+
confidenceThreshold: number = 0.7
|
|
1961
|
+
): boolean {
|
|
1962
|
+
const meta = extractMeta(result);
|
|
1963
|
+
|
|
1964
|
+
// Escalate if low confidence
|
|
1965
|
+
if (meta.confidence < confidenceThreshold) {
|
|
1966
|
+
return true;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
// Escalate if high risk
|
|
1970
|
+
if (meta.risk === 'high') {
|
|
1971
|
+
return true;
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
// Escalate if error
|
|
1975
|
+
if (!result.ok) {
|
|
1976
|
+
return true;
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
return false;
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
// Alias for backward compatibility
|
|
1983
|
+
export const shouldEscalateV22 = shouldEscalate;
|