@pikku/inspector 0.12.56 → 0.12.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +185 -0
- package/dist/add/add-ai-agent.js +25 -0
- package/dist/add/add-ai-scorer.d.ts +2 -0
- package/dist/add/add-ai-scorer.js +61 -0
- package/dist/add/add-wire-addon.js +26 -0
- package/dist/add/scenario-instrumentation.d.ts +6 -1
- package/dist/add/scenario-instrumentation.js +6 -0
- package/dist/error-codes.d.ts +2 -0
- package/dist/error-codes.js +3 -0
- package/dist/inspector.js +7 -3
- package/dist/types.d.ts +24 -0
- package/dist/utils/filter-inspector-state.js +8 -0
- package/dist/utils/post-process.d.ts +13 -3
- package/dist/utils/post-process.js +51 -21
- package/dist/utils/serialize-inspector-state.d.ts +11 -0
- package/dist/utils/serialize-inspector-state.js +8 -0
- package/dist/utils/workflow/dsl/extract-dsl-workflow.js +13 -4
- package/dist/utils/workflow/dsl/patterns.d.ts +2 -1
- package/dist/utils/workflow/dsl/patterns.js +8 -5
- package/dist/utils/workflow/graph/workflow-graph.types.d.ts +2 -1
- package/dist/visit.js +2 -0
- package/package.json +3 -3
- package/src/add/add-ai-agent.ts +35 -0
- package/src/add/add-ai-scorer.test.ts +197 -0
- package/src/add/add-ai-scorer.ts +83 -0
- package/src/add/add-functions.ts +1 -1
- package/src/add/add-scenario-step.test.ts +21 -1
- package/src/add/add-wire-addon.test.ts +74 -0
- package/src/add/add-wire-addon.ts +22 -0
- package/src/add/scenario-instrumentation.ts +6 -0
- package/src/error-codes.ts +4 -0
- package/src/inspector.ts +7 -3
- package/src/types.ts +21 -0
- package/src/utils/filter-inspector-state.ts +8 -0
- package/src/utils/post-process.test.ts +138 -0
- package/src/utils/post-process.ts +69 -22
- package/src/utils/serialize-inspector-state.ts +16 -0
- package/src/utils/workflow/dsl/extract-approval-policy.test.ts +68 -0
- package/src/utils/workflow/dsl/extract-dsl-workflow.ts +13 -5
- package/src/utils/workflow/dsl/patterns.ts +8 -5
- package/src/utils/workflow/graph/workflow-graph.types.ts +1 -1
- package/src/visit.ts +2 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/src/utils/secret-brokers.test.ts +0 -53
- package/src/utils/secret-brokers.ts +0 -11
|
@@ -2,7 +2,6 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { extractTypeKeys } from './type-utils.js';
|
|
4
4
|
import { ErrorCode } from '../error-codes.js';
|
|
5
|
-
import { isSecretBrokerFunction } from './secret-brokers.js';
|
|
6
5
|
import { findSecretAliasServices } from './secret-alias-services.js';
|
|
7
6
|
import { relative } from 'node:path';
|
|
8
7
|
import { AUTH_HANDLER_FUNC_ID } from '../add/add-auth.js';
|
|
@@ -318,14 +317,21 @@ export function validateSecretOverrides(logger, state) {
|
|
|
318
317
|
// older meta without a secretId field.
|
|
319
318
|
const secretIds = new Set(state.secrets.definitions.map((d) => d.secretId ?? d.name));
|
|
320
319
|
for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
|
|
321
|
-
|
|
322
|
-
continue;
|
|
323
|
-
for (const [logicalName, resolvedName] of Object.entries(addonDecl.secretOverrides)) {
|
|
320
|
+
for (const [logicalName, resolvedName] of Object.entries(addonDecl.secretOverrides ?? {})) {
|
|
324
321
|
if (!secretIds.has(resolvedName)) {
|
|
325
322
|
const availableSecrets = Array.from(secretIds);
|
|
326
323
|
logger.critical(ErrorCode.INVALID_VALUE, `Secret override '${logicalName}' -> '${resolvedName}' in addon '${namespace}' (${addonDecl.package}) targets a secret that does not exist. Available secrets: ${availableSecrets.join(', ') || 'none'}`);
|
|
327
324
|
}
|
|
328
325
|
}
|
|
326
|
+
// A grant names the secret as the addon reads it, so it resolves through
|
|
327
|
+
// the override map before it can be looked up in the project.
|
|
328
|
+
for (const logicalName of addonDecl.secretGrants ?? []) {
|
|
329
|
+
const resolvedName = addonDecl.secretOverrides?.[logicalName] ?? logicalName;
|
|
330
|
+
if (!secretIds.has(resolvedName)) {
|
|
331
|
+
const availableSecrets = Array.from(secretIds);
|
|
332
|
+
logger.critical(ErrorCode.INVALID_VALUE, `Secret grant '${logicalName}' in addon '${namespace}' (${addonDecl.package}) targets a secret that does not exist. Available secrets: ${availableSecrets.join(', ') || 'none'}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
329
335
|
}
|
|
330
336
|
}
|
|
331
337
|
export function validateCredentialOverrides(logger, state) {
|
|
@@ -334,14 +340,19 @@ export function validateCredentialOverrides(logger, state) {
|
|
|
334
340
|
return;
|
|
335
341
|
const credentialNames = new Set(state.credentials?.definitions.map((d) => d.name) ?? []);
|
|
336
342
|
for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
|
|
337
|
-
|
|
338
|
-
continue;
|
|
339
|
-
for (const [logicalName, resolvedName] of Object.entries(addonDecl.credentialOverrides)) {
|
|
343
|
+
for (const [logicalName, resolvedName] of Object.entries(addonDecl.credentialOverrides ?? {})) {
|
|
340
344
|
if (!credentialNames.has(resolvedName)) {
|
|
341
345
|
const availableCredentials = Array.from(credentialNames);
|
|
342
346
|
logger.critical(ErrorCode.INVALID_VALUE, `Credential override '${logicalName}' -> '${resolvedName}' in addon '${namespace}' (${addonDecl.package}) targets a credential that does not exist. Available credentials: ${availableCredentials.join(', ') || 'none'}`);
|
|
343
347
|
}
|
|
344
348
|
}
|
|
349
|
+
for (const logicalName of addonDecl.credentialGrants ?? []) {
|
|
350
|
+
const resolvedName = addonDecl.credentialOverrides?.[logicalName] ?? logicalName;
|
|
351
|
+
if (!credentialNames.has(resolvedName)) {
|
|
352
|
+
const availableCredentials = Array.from(credentialNames);
|
|
353
|
+
logger.critical(ErrorCode.INVALID_VALUE, `Credential grant '${logicalName}' in addon '${namespace}' (${addonDecl.package}) targets a credential that does not exist. Available credentials: ${availableCredentials.join(', ') || 'none'}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
345
356
|
}
|
|
346
357
|
}
|
|
347
358
|
export function validateVariableOverrides(logger, state) {
|
|
@@ -606,15 +617,26 @@ export function validateSchemaReferences(logger, state) {
|
|
|
606
617
|
`is declared but not exported — export it, or inline the type at the call site.`,
|
|
607
618
|
});
|
|
608
619
|
}
|
|
609
|
-
|
|
620
|
+
/**
|
|
621
|
+
* A model is either concrete (`provider/model`) or an alias, which must appear
|
|
622
|
+
* in the `models` table from pikku.config.json.
|
|
623
|
+
*/
|
|
624
|
+
export function validateAgentModels(logger, state, modelAliases = {}) {
|
|
610
625
|
for (const [, meta] of Object.entries(state.agents.agentsMeta)) {
|
|
611
626
|
const model = meta.model;
|
|
612
627
|
if (!model) {
|
|
613
628
|
logger.critical(ErrorCode.MISSING_MODEL, `AI agent '${meta.name}' is missing the 'model' property.`);
|
|
614
629
|
continue;
|
|
615
630
|
}
|
|
616
|
-
|
|
617
|
-
|
|
631
|
+
// `hasOwn`, not a truthy lookup: every object literal inherits `toString`,
|
|
632
|
+
// `constructor` and `__proto__`, so a bare model named after one of them
|
|
633
|
+
// would read as a configured alias and pass a check nothing else repeats.
|
|
634
|
+
if (!model.includes('/') && !Object.hasOwn(modelAliases, model)) {
|
|
635
|
+
const known = Object.keys(modelAliases).sort();
|
|
636
|
+
logger.critical(ErrorCode.INVALID_MODEL, `AI agent '${meta.name}' uses model '${model}', which is neither provider-qualified as '<provider>/<model>' (e.g. 'openai/gpt-4') nor an alias in the "models" table of pikku.config.json. ` +
|
|
637
|
+
(known.length
|
|
638
|
+
? `Known aliases: ${known.join(', ')}.`
|
|
639
|
+
: `No "models" aliases are configured.`));
|
|
618
640
|
}
|
|
619
641
|
}
|
|
620
642
|
}
|
|
@@ -793,14 +815,6 @@ export function validateSchemaWiringSeparation(logger, state) {
|
|
|
793
815
|
}
|
|
794
816
|
}
|
|
795
817
|
}
|
|
796
|
-
/** Marks the functions that keep the full `SecretService`. */
|
|
797
|
-
export function computeSecretBrokers(state) {
|
|
798
|
-
for (const [id, meta] of Object.entries(state.functions.meta)) {
|
|
799
|
-
if (isSecretBrokerFunction(meta.pikkuFuncId ?? id)) {
|
|
800
|
-
meta.secretBroker = true;
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
818
|
/**
|
|
805
819
|
* Refuses a singleton service that is a `SecretService` under another name.
|
|
806
820
|
*
|
|
@@ -830,9 +844,6 @@ export function validateNoSecretAliasServices(logger, checker, state) {
|
|
|
830
844
|
`SecretService is confined to pikkuServices, pikkuWireServices, addon service factories and middleware — give a service the secret value when you construct it and expose only what the function needs.`);
|
|
831
845
|
};
|
|
832
846
|
for (const [id, meta] of Object.entries(state.functions.meta)) {
|
|
833
|
-
if (meta.secretBroker) {
|
|
834
|
-
continue;
|
|
835
|
-
}
|
|
836
847
|
report('Function', id, meta.services?.services ?? []);
|
|
837
848
|
}
|
|
838
849
|
for (const [id, def] of Object.entries(state.permissions.definitions)) {
|
|
@@ -1038,6 +1049,25 @@ export function validateAgentToolReferences(logger, state, options = {}) {
|
|
|
1038
1049
|
}
|
|
1039
1050
|
}
|
|
1040
1051
|
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Validates that every scorer an agent asks to be graded by exists.
|
|
1054
|
+
*
|
|
1055
|
+
* The runtime warns and skips an unresolvable name rather than failing a run
|
|
1056
|
+
* that has already answered the user — which means a typo would otherwise cost
|
|
1057
|
+
* nothing visible and simply grade nothing, forever.
|
|
1058
|
+
*/
|
|
1059
|
+
export function validateAgentScorerReferences(logger, state) {
|
|
1060
|
+
const known = Object.keys(state.scorers.scorersMeta);
|
|
1061
|
+
for (const [agentKey, agent] of Object.entries(state.agents.agentsMeta)) {
|
|
1062
|
+
const where = agent.sourceFile ? ` (${agent.sourceFile})` : '';
|
|
1063
|
+
for (const scorer of agent.scorers ?? []) {
|
|
1064
|
+
if (known.includes(scorer))
|
|
1065
|
+
continue;
|
|
1066
|
+
logger.critical(ErrorCode.AGENT_SCORER_NOT_FOUND, `AI agent '${agentKey}'${where} asks to be graded by scorer '${scorer}', which is not declared in this project. ` +
|
|
1067
|
+
`Declared scorers: ${known.join(', ') || 'none'}.`);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1041
1071
|
/**
|
|
1042
1072
|
* Validates that every scope granted by a system role is declared via
|
|
1043
1073
|
* `defineScope`. Runs after all visitors, so declaration order does not matter.
|
|
@@ -195,6 +195,10 @@ export interface SerializableInspectorState {
|
|
|
195
195
|
secretOverrides?: Record<string, string>;
|
|
196
196
|
variableOverrides?: Record<string, string>;
|
|
197
197
|
credentialOverrides?: Record<string, string>;
|
|
198
|
+
secretGrants?: string[];
|
|
199
|
+
credentialGrants?: string[];
|
|
200
|
+
globalSecrets?: string;
|
|
201
|
+
globalCredentials?: string;
|
|
198
202
|
}
|
|
199
203
|
]>;
|
|
200
204
|
wireAddonFiles: string[];
|
|
@@ -212,6 +216,13 @@ export interface SerializableInspectorState {
|
|
|
212
216
|
exportedName: string;
|
|
213
217
|
}][];
|
|
214
218
|
};
|
|
219
|
+
scorers: {
|
|
220
|
+
scorersMeta: InspectorState['scorers']['scorersMeta'];
|
|
221
|
+
files: [string, {
|
|
222
|
+
path: string;
|
|
223
|
+
exportedName: string;
|
|
224
|
+
}][];
|
|
225
|
+
};
|
|
215
226
|
cli: {
|
|
216
227
|
meta: InspectorState['cli']['meta'];
|
|
217
228
|
files: string[];
|
|
@@ -95,6 +95,10 @@ export function serializeInspectorState(state) {
|
|
|
95
95
|
agentsMeta: state.agents?.agentsMeta ?? {},
|
|
96
96
|
files: Array.from(state.agents?.files?.entries() ?? []),
|
|
97
97
|
},
|
|
98
|
+
scorers: {
|
|
99
|
+
scorersMeta: state.scorers?.scorersMeta ?? {},
|
|
100
|
+
files: Array.from(state.scorers?.files?.entries() ?? []),
|
|
101
|
+
},
|
|
98
102
|
cli: {
|
|
99
103
|
meta: state.cli.meta,
|
|
100
104
|
files: Array.from(state.cli.files),
|
|
@@ -268,6 +272,10 @@ export function deserializeInspectorState(data) {
|
|
|
268
272
|
agentsMeta: data.agents?.agentsMeta || {},
|
|
269
273
|
files: new Map(data.agents?.files || []),
|
|
270
274
|
},
|
|
275
|
+
scorers: {
|
|
276
|
+
scorersMeta: data.scorers?.scorersMeta || {},
|
|
277
|
+
files: new Map(data.scorers?.files || []),
|
|
278
|
+
},
|
|
271
279
|
cli: {
|
|
272
280
|
meta: data.cli.meta,
|
|
273
281
|
files: new Set(data.cli.files),
|
|
@@ -803,19 +803,28 @@ function extractApprovalStep(call, context, outputVar) {
|
|
|
803
803
|
step.outputVar = outputVar;
|
|
804
804
|
}
|
|
805
805
|
// The `schema` option is a runtime value validated inside the workflow body,
|
|
806
|
-
// so it is deliberately not serialized here — only
|
|
807
|
-
// and planned-step ladder need in order to describe the gate.
|
|
806
|
+
// so it is deliberately not serialized here — only the literal options the
|
|
807
|
+
// graph and planned-step ladder need in order to describe the gate.
|
|
808
808
|
const options = args[1];
|
|
809
809
|
if (options && ts.isObjectLiteralExpression(options)) {
|
|
810
810
|
for (const prop of options.properties) {
|
|
811
|
-
if (ts.isPropertyAssignment(prop)
|
|
812
|
-
|
|
811
|
+
if (!ts.isPropertyAssignment(prop))
|
|
812
|
+
continue;
|
|
813
|
+
const name = prop.name.getText();
|
|
814
|
+
if (name === 'expiry' &&
|
|
813
815
|
(ts.isStringLiteral(prop.initializer) ||
|
|
814
816
|
ts.isNumericLiteral(prop.initializer))) {
|
|
815
817
|
step.expiry = ts.isNumericLiteral(prop.initializer)
|
|
816
818
|
? Number(prop.initializer.text)
|
|
817
819
|
: prop.initializer.text;
|
|
818
820
|
}
|
|
821
|
+
if (name === 'approvers' && ts.isStringLiteral(prop.initializer)) {
|
|
822
|
+
step.approvers = prop.initializer
|
|
823
|
+
.text;
|
|
824
|
+
}
|
|
825
|
+
if (name === 'approverScope' && ts.isStringLiteral(prop.initializer)) {
|
|
826
|
+
step.approverScope = prop.initializer.text;
|
|
827
|
+
}
|
|
819
828
|
}
|
|
820
829
|
}
|
|
821
830
|
return step;
|
|
@@ -35,7 +35,8 @@ export declare function isScenarioStepCall(node: ts.CallExpression): boolean;
|
|
|
35
35
|
export declare function getScenarioStepPhase(node: ts.CallExpression): ScenarioStepPhaseName | undefined;
|
|
36
36
|
/**
|
|
37
37
|
* Check if a call expression is one of the scenario expectation helpers —
|
|
38
|
-
* `scenario.expectService()`, `.expectError()
|
|
38
|
+
* `scenario.expectService()`, `.expectError()`, `.expectEventually()` or
|
|
39
|
+
* `.expectScore()`.
|
|
39
40
|
*/
|
|
40
41
|
export declare function isScenarioExpectationCall(node: ts.CallExpression): boolean;
|
|
41
42
|
/**
|
|
@@ -57,19 +57,22 @@ export function getScenarioStepPhase(node) {
|
|
|
57
57
|
/**
|
|
58
58
|
* The expectation helpers that assert without being a `then`.
|
|
59
59
|
*
|
|
60
|
-
* `expectService` and `
|
|
61
|
-
* steps, so they carry no phase and never reach the step meta — but
|
|
62
|
-
* a witness: a recorded service call,
|
|
63
|
-
* a scenario whose only assertion is one of these
|
|
60
|
+
* `expectService`, `expectError` and `expectScore` are inline steps rather than
|
|
61
|
+
* scenario steps, so they carry no phase and never reach the step meta — but
|
|
62
|
+
* each one is a witness: a recorded service call, a refusal, or a graded run.
|
|
63
|
+
* PKU680 has to see them, or a scenario whose only assertion is one of these
|
|
64
|
+
* reads as asserting nothing.
|
|
64
65
|
*/
|
|
65
66
|
const SCENARIO_EXPECTATION_CALLS = [
|
|
66
67
|
'expectService',
|
|
67
68
|
'expectError',
|
|
68
69
|
'expectEventually',
|
|
70
|
+
'expectScore',
|
|
69
71
|
];
|
|
70
72
|
/**
|
|
71
73
|
* Check if a call expression is one of the scenario expectation helpers —
|
|
72
|
-
* `scenario.expectService()`, `.expectError()
|
|
74
|
+
* `scenario.expectService()`, `.expectError()`, `.expectEventually()` or
|
|
75
|
+
* `.expectScore()`.
|
|
73
76
|
*/
|
|
74
77
|
export function isScenarioExpectationCall(node) {
|
|
75
78
|
return (ts.isPropertyAccessExpression(node.expression) &&
|
|
@@ -67,7 +67,8 @@ export interface NodeOptions {
|
|
|
67
67
|
* Flow node types for control flow (no RPC call)
|
|
68
68
|
*/
|
|
69
69
|
export type FlowType = 'sleep' | 'suspend' | 'approval' | 'branch' | 'parallel' | 'fanout' | 'inline' | 'switch' | 'filter' | 'arrayPredicate' | 'return' | 'cancel' | 'set';
|
|
70
|
-
import type { ContextVariable,
|
|
70
|
+
import type { ContextVariable, WorkflowContext, WorkflowPlannedStep } from '@pikku/core/workflow';
|
|
71
|
+
import type { ScenarioStepPhase } from '@pikku/core/scenario';
|
|
71
72
|
export type { ContextVariable, WorkflowContext };
|
|
72
73
|
/**
|
|
73
74
|
* Base node properties shared by all node types
|
package/dist/visit.js
CHANGED
|
@@ -29,6 +29,7 @@ import { addVariable } from './add/add-variable.js';
|
|
|
29
29
|
import { addWorkflowGraph } from './add/add-workflow-graph.js';
|
|
30
30
|
import { addFeature } from './add/add-feature.js';
|
|
31
31
|
import { addAIAgent } from './add/add-ai-agent.js';
|
|
32
|
+
import { addAIScorer } from './add/add-ai-scorer.js';
|
|
32
33
|
import { addApprovalDescription } from './add/add-approval-description.js';
|
|
33
34
|
export const visitSetup = (logger, checker, node, state, options) => {
|
|
34
35
|
addFileExtendsCoreType(node, checker, state.singletonServicesTypeImportMap, 'CoreSingletonServices', state);
|
|
@@ -88,5 +89,6 @@ export const visitRoutes = (logger, checker, node, state, options) => {
|
|
|
88
89
|
addWorkflowGraph(logger, node, checker, state, nextOptions);
|
|
89
90
|
addFeature(logger, node, checker, state, nextOptions);
|
|
90
91
|
addAIAgent(logger, node, checker, state, nextOptions);
|
|
92
|
+
addAIScorer(logger, node, checker, state, nextOptions);
|
|
91
93
|
ts.forEachChild(node, (child) => visitRoutes(logger, checker, child, state, nextOptions));
|
|
92
94
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/inspector",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.59",
|
|
4
4
|
"description": "TypeScript inspector for Pikku — discovers functions and wirings to drive code generation",
|
|
5
5
|
"author": "yasser.fadl@gmail.com",
|
|
6
6
|
"license": "BUSL-1.1",
|
|
@@ -45,10 +45,10 @@
|
|
|
45
45
|
"zod-to-ts": "^2.1.0"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@pikku/core": "^0.12.
|
|
48
|
+
"@pikku/core": "^0.12.83"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@pikku/core": "^0.12.
|
|
51
|
+
"@pikku/core": "^0.12.83",
|
|
52
52
|
"@types/node": "^24.11.0"
|
|
53
53
|
},
|
|
54
54
|
"engines": {
|
package/src/add/add-ai-agent.ts
CHANGED
|
@@ -330,6 +330,39 @@ function resolveIdentifierToAgentName(
|
|
|
330
330
|
return null
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
+
/**
|
|
334
|
+
* The scorers an agent asks to be graded by, which are plain names rather than
|
|
335
|
+
* references: a scorer is resolved out of the registry at grading time, not
|
|
336
|
+
* imported by the agent, so nothing here has a declaration to chase.
|
|
337
|
+
*/
|
|
338
|
+
function resolveScorerNames(
|
|
339
|
+
obj: ts.ObjectLiteralExpression,
|
|
340
|
+
agentName: string,
|
|
341
|
+
logger: InspectorLogger
|
|
342
|
+
): string[] | null {
|
|
343
|
+
const property = obj.properties.find(
|
|
344
|
+
(p) =>
|
|
345
|
+
ts.isPropertyAssignment(p) &&
|
|
346
|
+
ts.isIdentifier(p.name) &&
|
|
347
|
+
p.name.text === 'scorers'
|
|
348
|
+
)
|
|
349
|
+
if (!property || !ts.isPropertyAssignment(property)) return null
|
|
350
|
+
if (!ts.isArrayLiteralExpression(property.initializer)) return null
|
|
351
|
+
|
|
352
|
+
const names: string[] = []
|
|
353
|
+
for (const element of property.initializer.elements) {
|
|
354
|
+
if (!ts.isStringLiteral(element)) {
|
|
355
|
+
logger.critical(
|
|
356
|
+
ErrorCode.INVALID_VALUE,
|
|
357
|
+
`AI agent '${agentName}' lists a scorer that is not an inline string: \`${element.getText()}\`. A scorer is named, not imported, so the name has to be readable from source.`
|
|
358
|
+
)
|
|
359
|
+
continue
|
|
360
|
+
}
|
|
361
|
+
names.push(element.text)
|
|
362
|
+
}
|
|
363
|
+
return names.length > 0 ? names : null
|
|
364
|
+
}
|
|
365
|
+
|
|
333
366
|
export const addAIAgent: AddWiring = (
|
|
334
367
|
logger,
|
|
335
368
|
node,
|
|
@@ -377,6 +410,7 @@ export const addAIAgent: AddWiring = (
|
|
|
377
410
|
| number
|
|
378
411
|
| null
|
|
379
412
|
const toolChoiceValue = getPropertyValue(obj, 'toolChoice') as string | null
|
|
413
|
+
const scorersValue = resolveScorerNames(obj, nameValue || '', logger)
|
|
380
414
|
const toolsValue = resolveToolReferences(
|
|
381
415
|
obj,
|
|
382
416
|
checker,
|
|
@@ -582,6 +616,7 @@ export const addAIAgent: AddWiring = (
|
|
|
582
616
|
...(toolsValue !== null && { tools: toolsValue }),
|
|
583
617
|
...(agentsValue !== null && { agents: agentsValue }),
|
|
584
618
|
...(workflowsValue !== null && { workflows: workflowsValue }),
|
|
619
|
+
...(scorersValue !== null && { scorers: scorersValue }),
|
|
585
620
|
tags,
|
|
586
621
|
inputSchema,
|
|
587
622
|
outputSchema,
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { strict as assert } from 'assert'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { inspect } from '../inspector.js'
|
|
7
|
+
import { ErrorCode } from '../error-codes.js'
|
|
8
|
+
import type { InspectorLogger } from '../types.js'
|
|
9
|
+
|
|
10
|
+
const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
|
|
11
|
+
({
|
|
12
|
+
debug: () => {},
|
|
13
|
+
info: () => {},
|
|
14
|
+
warn: () => {},
|
|
15
|
+
error: () => {},
|
|
16
|
+
diagnostic: ({ code, message }) => {
|
|
17
|
+
criticals.push({ code, message })
|
|
18
|
+
},
|
|
19
|
+
critical: (code: ErrorCode, message: string) => {
|
|
20
|
+
criticals.push({ code, message })
|
|
21
|
+
},
|
|
22
|
+
hasCriticalErrors: () => criticals.length > 0,
|
|
23
|
+
}) satisfies InspectorLogger
|
|
24
|
+
|
|
25
|
+
const inspectSources = async (sources: Record<string, string>) => {
|
|
26
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-ai-scorer-'))
|
|
27
|
+
const files: Record<string, string> = {}
|
|
28
|
+
for (const [name, source] of Object.entries(sources)) {
|
|
29
|
+
files[name] = join(rootDir, name)
|
|
30
|
+
await writeFile(files[name]!, source)
|
|
31
|
+
}
|
|
32
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
33
|
+
try {
|
|
34
|
+
const state = await inspect(makeLogger(criticals), Object.values(files), {
|
|
35
|
+
rootDir,
|
|
36
|
+
})
|
|
37
|
+
return { state, criticals }
|
|
38
|
+
} finally {
|
|
39
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const inspectSource = (source: string) =>
|
|
44
|
+
inspectSources({ 'grading.scorer.ts': source })
|
|
45
|
+
|
|
46
|
+
describe('addAIScorer', () => {
|
|
47
|
+
test('reads the lane off which constructor was called, not off a field', async () => {
|
|
48
|
+
const { state, criticals } = await inspectSource(
|
|
49
|
+
[
|
|
50
|
+
`export const brevity = pikkuAIScorer({`,
|
|
51
|
+
` name: 'brevity',`,
|
|
52
|
+
` description: 'Shorter answers score higher',`,
|
|
53
|
+
` score: () => ({ score: 1 }),`,
|
|
54
|
+
`})`,
|
|
55
|
+
`export const helpfulness = pikkuAIJudge({`,
|
|
56
|
+
` name: 'helpfulness',`,
|
|
57
|
+
` description: 'Does the answer help',`,
|
|
58
|
+
` model: 'claude-opus-5',`,
|
|
59
|
+
` goal: 'Grade helpfulness.',`,
|
|
60
|
+
`})`,
|
|
61
|
+
].join('\n')
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
assert.deepEqual(criticals, [])
|
|
65
|
+
assert.equal(state.scorers.scorersMeta['brevity']?.lane, 'fast')
|
|
66
|
+
assert.equal(state.scorers.scorersMeta['helpfulness']?.lane, 'slow')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('defaults an unstated sample rate to grading every run', async () => {
|
|
70
|
+
const { state } = await inspectSource(
|
|
71
|
+
[
|
|
72
|
+
`export const brevity = pikkuAIScorer({`,
|
|
73
|
+
` name: 'brevity',`,
|
|
74
|
+
` description: 'Shorter answers score higher',`,
|
|
75
|
+
` score: () => ({ score: 1 }),`,
|
|
76
|
+
`})`,
|
|
77
|
+
].join('\n')
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
assert.equal(state.scorers.scorersMeta['brevity']?.sampleRate, 1)
|
|
81
|
+
assert.equal(state.scorers.scorersMeta['brevity']?.requiresReference, false)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('carries the sample rate and the reference requirement into the meta', async () => {
|
|
85
|
+
const { state } = await inspectSource(
|
|
86
|
+
[
|
|
87
|
+
`export const correctness = pikkuAIJudge({`,
|
|
88
|
+
` name: 'correctness',`,
|
|
89
|
+
` description: 'Is the answer right',`,
|
|
90
|
+
` model: 'claude-opus-5',`,
|
|
91
|
+
` goal: 'Grade correctness.',`,
|
|
92
|
+
` sampleRate: 0.1,`,
|
|
93
|
+
` requiresReference: true,`,
|
|
94
|
+
`})`,
|
|
95
|
+
].join('\n')
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
assert.equal(state.scorers.scorersMeta['correctness']?.sampleRate, 0.1)
|
|
99
|
+
assert.equal(
|
|
100
|
+
state.scorers.scorersMeta['correctness']?.requiresReference,
|
|
101
|
+
true
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('rejects a sample rate that is not a fraction', async () => {
|
|
106
|
+
const { state, criticals } = await inspectSource(
|
|
107
|
+
[
|
|
108
|
+
`export const brevity = pikkuAIScorer({`,
|
|
109
|
+
` name: 'brevity',`,
|
|
110
|
+
` description: 'Shorter answers score higher',`,
|
|
111
|
+
` sampleRate: 50,`,
|
|
112
|
+
` score: () => ({ score: 1 }),`,
|
|
113
|
+
`})`,
|
|
114
|
+
].join('\n')
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
assert.equal(criticals[0]?.code, ErrorCode.INVALID_VALUE)
|
|
118
|
+
assert.match(criticals[0]!.message, /between 0 and 1/)
|
|
119
|
+
assert.deepEqual(state.scorers.scorersMeta, {})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('a scorer with no description is refused, since the meta is what names it', async () => {
|
|
123
|
+
const { criticals } = await inspectSource(
|
|
124
|
+
[
|
|
125
|
+
`export const brevity = pikkuAIScorer({`,
|
|
126
|
+
` name: 'brevity',`,
|
|
127
|
+
` score: () => ({ score: 1 }),`,
|
|
128
|
+
`})`,
|
|
129
|
+
].join('\n')
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
assert.equal(criticals[0]?.code, ErrorCode.MISSING_DESCRIPTION)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('records where a scorer was declared, so codegen can import it back', async () => {
|
|
136
|
+
const { state } = await inspectSource(
|
|
137
|
+
[
|
|
138
|
+
`export const brevity = pikkuAIScorer({`,
|
|
139
|
+
` name: 'brevity',`,
|
|
140
|
+
` description: 'Shorter answers score higher',`,
|
|
141
|
+
` score: () => ({ score: 1 }),`,
|
|
142
|
+
`})`,
|
|
143
|
+
].join('\n')
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
assert.equal(state.scorers.files.get('brevity')?.exportedName, 'brevity')
|
|
147
|
+
assert.match(
|
|
148
|
+
state.scorers.files.get('brevity')?.path ?? '',
|
|
149
|
+
/grading\.scorer\.ts$/
|
|
150
|
+
)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('an agent asking to be graded', () => {
|
|
155
|
+
const scorerFile = [
|
|
156
|
+
`export const brevity = pikkuAIScorer({`,
|
|
157
|
+
` name: 'brevity',`,
|
|
158
|
+
` description: 'Shorter answers score higher',`,
|
|
159
|
+
` score: () => ({ score: 1 }),`,
|
|
160
|
+
`})`,
|
|
161
|
+
].join('\n')
|
|
162
|
+
|
|
163
|
+
const agent = (scorers: string) =>
|
|
164
|
+
[
|
|
165
|
+
`export const assistant = pikkuAIAgent({`,
|
|
166
|
+
` name: 'assistant',`,
|
|
167
|
+
` description: 'Answers questions',`,
|
|
168
|
+
` goal: 'Answer the question.',`,
|
|
169
|
+
` model: 'anthropic/claude-opus-5',`,
|
|
170
|
+
` scorers: [${scorers}],`,
|
|
171
|
+
`})`,
|
|
172
|
+
].join('\n')
|
|
173
|
+
|
|
174
|
+
test('carries the named scorers onto the agent meta', async () => {
|
|
175
|
+
const { state, criticals } = await inspectSources({
|
|
176
|
+
'grading.scorer.ts': scorerFile,
|
|
177
|
+
'assistant.agent.ts': agent(`'brevity'`),
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
assert.deepEqual(criticals, [])
|
|
181
|
+
assert.deepEqual(state.agents.agentsMeta['assistant']?.scorers, ['brevity'])
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('naming a scorer that does not exist fails the build rather than grading nothing', async () => {
|
|
185
|
+
const { criticals } = await inspectSources({
|
|
186
|
+
'grading.scorer.ts': scorerFile,
|
|
187
|
+
'assistant.agent.ts': agent(`'brevty'`),
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const finding = criticals.find(
|
|
191
|
+
(critical) => critical.code === ErrorCode.AGENT_SCORER_NOT_FOUND
|
|
192
|
+
)
|
|
193
|
+
assert.ok(finding, `expected a scorer-not-found critical`)
|
|
194
|
+
assert.match(finding!.message, /brevty/)
|
|
195
|
+
assert.match(finding!.message, /Declared scorers: brevity/)
|
|
196
|
+
})
|
|
197
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import * as ts from 'typescript'
|
|
2
|
+
import { getPropertyValue } from '../utils/get-property-value.js'
|
|
3
|
+
import type { AddWiring } from '../types.js'
|
|
4
|
+
import { extractFunctionName } from '../utils/extract-function-name.js'
|
|
5
|
+
import { ErrorCode } from '../error-codes.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `pikkuAIScorer` grades in code and `pikkuAIJudge` grades with a model, which
|
|
9
|
+
* is the whole difference between the two lanes — so the lane is read off which
|
|
10
|
+
* constructor was called rather than from a field an author could contradict.
|
|
11
|
+
*/
|
|
12
|
+
const LANES: Record<string, 'fast' | 'slow'> = {
|
|
13
|
+
pikkuAIScorer: 'fast',
|
|
14
|
+
pikkuAIJudge: 'slow',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const addAIScorer: AddWiring = (logger, node, checker, state) => {
|
|
18
|
+
if (!ts.isCallExpression(node)) return
|
|
19
|
+
|
|
20
|
+
const expression = node.expression
|
|
21
|
+
if (!ts.isIdentifier(expression)) return
|
|
22
|
+
|
|
23
|
+
const lane = LANES[expression.text]
|
|
24
|
+
if (!lane) return
|
|
25
|
+
|
|
26
|
+
const firstArg = node.arguments[0]
|
|
27
|
+
if (!firstArg || !ts.isObjectLiteralExpression(firstArg)) return
|
|
28
|
+
|
|
29
|
+
const obj = firstArg
|
|
30
|
+
const { exportedName } = extractFunctionName(node, checker, state.rootDir)
|
|
31
|
+
|
|
32
|
+
const nameValue = getPropertyValue(obj, 'name') as string | null
|
|
33
|
+
if (!nameValue) {
|
|
34
|
+
logger.critical(
|
|
35
|
+
ErrorCode.MISSING_NAME,
|
|
36
|
+
"AI scorer is missing the required 'name' property."
|
|
37
|
+
)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const description = getPropertyValue(obj, 'description') as string | null
|
|
42
|
+
if (!description) {
|
|
43
|
+
logger.critical(
|
|
44
|
+
ErrorCode.MISSING_DESCRIPTION,
|
|
45
|
+
`AI scorer '${nameValue}' is missing the required 'description' property.`
|
|
46
|
+
)
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const sampleRateValue = getPropertyValue(obj, 'sampleRate') as number | null
|
|
51
|
+
if (
|
|
52
|
+
sampleRateValue !== null &&
|
|
53
|
+
(sampleRateValue < 0 || sampleRateValue > 1)
|
|
54
|
+
) {
|
|
55
|
+
logger.critical(
|
|
56
|
+
ErrorCode.INVALID_VALUE,
|
|
57
|
+
`AI scorer '${nameValue}' has a sampleRate of ${sampleRateValue} — it is the fraction of live runs to grade, so it must be between 0 and 1.`
|
|
58
|
+
)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const requiresReference =
|
|
63
|
+
(getPropertyValue(obj, 'requiresReference') as boolean | null) ?? false
|
|
64
|
+
|
|
65
|
+
const scorerKey = exportedName || nameValue
|
|
66
|
+
|
|
67
|
+
if (exportedName) {
|
|
68
|
+
state.scorers.files.set(scorerKey, {
|
|
69
|
+
path: node.getSourceFile().fileName,
|
|
70
|
+
exportedName,
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
state.scorers.scorersMeta[scorerKey] = {
|
|
75
|
+
name: nameValue,
|
|
76
|
+
description,
|
|
77
|
+
lane,
|
|
78
|
+
sampleRate: sampleRateValue ?? 1,
|
|
79
|
+
requiresReference,
|
|
80
|
+
sourceFile: node.getSourceFile().fileName,
|
|
81
|
+
exportedName: exportedName || undefined,
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/add/add-functions.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
import { extractFunctionNode } from '../utils/extract-function-node.js'
|
|
10
10
|
import { extractUsedWires } from '../utils/extract-services.js'
|
|
11
11
|
import type { AuditDurability, FunctionServicesMeta } from '@pikku/core'
|
|
12
|
-
import type { ScenarioStepKind, ScenarioSurface } from '@pikku/core/
|
|
12
|
+
import type { ScenarioStepKind, ScenarioSurface } from '@pikku/core/scenario'
|
|
13
13
|
import { formatVersionedId, parseVersionedId } from '@pikku/core'
|
|
14
14
|
|
|
15
15
|
/** Binding keys a `pikkuScenarioStep` may declare, in report order. */
|