@pikku/inspector 0.12.55 → 0.12.58
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 +168 -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 +16 -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 +13 -4
- package/dist/types.d.ts +20 -0
- package/dist/utils/filter-inspector-state.js +8 -0
- package/dist/utils/post-process.d.ts +28 -3
- package/dist/utils/post-process.js +85 -15
- package/dist/utils/serialize-inspector-state.d.ts +9 -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/serialize-workflow-graph.js +2 -10
- 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-scenario-step.test.ts +19 -0
- package/src/add/add-wire-addon.test.ts +43 -0
- package/src/add/add-wire-addon.ts +14 -0
- package/src/add/scenario-instrumentation.ts +6 -0
- package/src/error-codes.ts +4 -0
- package/src/inspector.ts +15 -4
- package/src/types.ts +17 -0
- package/src/utils/filter-inspector-state.ts +8 -0
- package/src/utils/post-process.test.ts +71 -0
- package/src/utils/post-process.ts +98 -16
- package/src/utils/serialize-inspector-state.ts +14 -0
- package/src/utils/workflow/dsl/extract-approval-policy.test.ts +68 -0
- package/src/utils/workflow/dsl/extract-dsl-workflow.ts +12 -4
- package/src/utils/workflow/dsl/patterns.ts +8 -5
- package/src/utils/workflow/graph/serialize-workflow-graph.ts +2 -15
- 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';
|
|
@@ -554,15 +553,78 @@ export function computeRequiredSchemas(state, options) {
|
|
|
554
553
|
...Array.from(schemaLookup.keys()),
|
|
555
554
|
]);
|
|
556
555
|
}
|
|
557
|
-
|
|
556
|
+
/**
|
|
557
|
+
* Every schema a function's contract names has to exist once codegen is done.
|
|
558
|
+
*
|
|
559
|
+
* The way it stops existing is quiet. `generateCustomTypes` hands the schema
|
|
560
|
+
* generator a virtual source file that *imports* each named contract type, so a
|
|
561
|
+
* type that is declared but never exported resolves to nothing and yields no
|
|
562
|
+
* schema — while the function's meta still carries the name. Nothing downstream
|
|
563
|
+
* reads that name until the first call, which fails with a runtime
|
|
564
|
+
* `MissingSchemaError` in a deployment rather than in the build that caused it.
|
|
565
|
+
*
|
|
566
|
+
* Reported rather than critical: the reference is only *guaranteed* to break
|
|
567
|
+
* the functions that are actually invoked, so this blocks CI through
|
|
568
|
+
* `--fail-on-error` without failing a dev server over a function nobody calls.
|
|
569
|
+
*/
|
|
570
|
+
export function validateSchemaReferences(logger, state) {
|
|
571
|
+
const { functions, schemas } = state;
|
|
572
|
+
const available = new Set(Object.keys(schemas ?? {}));
|
|
573
|
+
const unresolved = [];
|
|
574
|
+
const resolve = (typeName) => {
|
|
575
|
+
try {
|
|
576
|
+
return functions.typesMap.getUniqueName(typeName);
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
return typeName;
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
for (const [funcName, meta] of Object.entries(functions.meta)) {
|
|
583
|
+
for (const [role, declared] of [
|
|
584
|
+
['input', meta.inputs?.[0]],
|
|
585
|
+
['output', meta.outputs?.[0]],
|
|
586
|
+
]) {
|
|
587
|
+
if (!declared || PRIMITIVE_TYPES.has(declared))
|
|
588
|
+
continue;
|
|
589
|
+
const resolved = resolve(declared);
|
|
590
|
+
if (PRIMITIVE_TYPES.has(resolved) || available.has(resolved))
|
|
591
|
+
continue;
|
|
592
|
+
unresolved.push(`${funcName}.${role} → '${resolved}'`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (unresolved.length === 0)
|
|
596
|
+
return;
|
|
597
|
+
logger.diagnostic({
|
|
598
|
+
severity: 'error',
|
|
599
|
+
code: ErrorCode.SCHEMA_REFERENCE_UNRESOLVED,
|
|
600
|
+
message: `${unresolved.length} function contract${unresolved.length === 1 ? '' : 's'} ` +
|
|
601
|
+
`name${unresolved.length === 1 ? 's' : ''} a schema that was never generated, so ` +
|
|
602
|
+
`calling ${unresolved.length === 1 ? 'it' : 'them'} fails at runtime with ` +
|
|
603
|
+
`MissingSchemaError: ${unresolved.join(', ')}. ` +
|
|
604
|
+
`The usual cause is a named type used as the function's input/output that ` +
|
|
605
|
+
`is declared but not exported — export it, or inline the type at the call site.`,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* A model is either concrete (`provider/model`) or an alias, which must appear
|
|
610
|
+
* in the `models` table from pikku.config.json.
|
|
611
|
+
*/
|
|
612
|
+
export function validateAgentModels(logger, state, modelAliases = {}) {
|
|
558
613
|
for (const [, meta] of Object.entries(state.agents.agentsMeta)) {
|
|
559
614
|
const model = meta.model;
|
|
560
615
|
if (!model) {
|
|
561
616
|
logger.critical(ErrorCode.MISSING_MODEL, `AI agent '${meta.name}' is missing the 'model' property.`);
|
|
562
617
|
continue;
|
|
563
618
|
}
|
|
564
|
-
|
|
565
|
-
|
|
619
|
+
// `hasOwn`, not a truthy lookup: every object literal inherits `toString`,
|
|
620
|
+
// `constructor` and `__proto__`, so a bare model named after one of them
|
|
621
|
+
// would read as a configured alias and pass a check nothing else repeats.
|
|
622
|
+
if (!model.includes('/') && !Object.hasOwn(modelAliases, model)) {
|
|
623
|
+
const known = Object.keys(modelAliases).sort();
|
|
624
|
+
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. ` +
|
|
625
|
+
(known.length
|
|
626
|
+
? `Known aliases: ${known.join(', ')}.`
|
|
627
|
+
: `No "models" aliases are configured.`));
|
|
566
628
|
}
|
|
567
629
|
}
|
|
568
630
|
}
|
|
@@ -741,14 +803,6 @@ export function validateSchemaWiringSeparation(logger, state) {
|
|
|
741
803
|
}
|
|
742
804
|
}
|
|
743
805
|
}
|
|
744
|
-
/** Marks the functions that keep the full `SecretService`. */
|
|
745
|
-
export function computeSecretBrokers(state) {
|
|
746
|
-
for (const [id, meta] of Object.entries(state.functions.meta)) {
|
|
747
|
-
if (isSecretBrokerFunction(meta.pikkuFuncId ?? id)) {
|
|
748
|
-
meta.secretBroker = true;
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
806
|
/**
|
|
753
807
|
* Refuses a singleton service that is a `SecretService` under another name.
|
|
754
808
|
*
|
|
@@ -778,9 +832,6 @@ export function validateNoSecretAliasServices(logger, checker, state) {
|
|
|
778
832
|
`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.`);
|
|
779
833
|
};
|
|
780
834
|
for (const [id, meta] of Object.entries(state.functions.meta)) {
|
|
781
|
-
if (meta.secretBroker) {
|
|
782
|
-
continue;
|
|
783
|
-
}
|
|
784
835
|
report('Function', id, meta.services?.services ?? []);
|
|
785
836
|
}
|
|
786
837
|
for (const [id, def] of Object.entries(state.permissions.definitions)) {
|
|
@@ -986,6 +1037,25 @@ export function validateAgentToolReferences(logger, state, options = {}) {
|
|
|
986
1037
|
}
|
|
987
1038
|
}
|
|
988
1039
|
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Validates that every scorer an agent asks to be graded by exists.
|
|
1042
|
+
*
|
|
1043
|
+
* The runtime warns and skips an unresolvable name rather than failing a run
|
|
1044
|
+
* that has already answered the user — which means a typo would otherwise cost
|
|
1045
|
+
* nothing visible and simply grade nothing, forever.
|
|
1046
|
+
*/
|
|
1047
|
+
export function validateAgentScorerReferences(logger, state) {
|
|
1048
|
+
const known = Object.keys(state.scorers.scorersMeta);
|
|
1049
|
+
for (const [agentKey, agent] of Object.entries(state.agents.agentsMeta)) {
|
|
1050
|
+
const where = agent.sourceFile ? ` (${agent.sourceFile})` : '';
|
|
1051
|
+
for (const scorer of agent.scorers ?? []) {
|
|
1052
|
+
if (known.includes(scorer))
|
|
1053
|
+
continue;
|
|
1054
|
+
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. ` +
|
|
1055
|
+
`Declared scorers: ${known.join(', ') || 'none'}.`);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
989
1059
|
/**
|
|
990
1060
|
* Validates that every scope granted by a system role is declared via
|
|
991
1061
|
* `defineScope`. Runs after all visitors, so declaration order does not matter.
|
|
@@ -195,6 +195,8 @@ export interface SerializableInspectorState {
|
|
|
195
195
|
secretOverrides?: Record<string, string>;
|
|
196
196
|
variableOverrides?: Record<string, string>;
|
|
197
197
|
credentialOverrides?: Record<string, string>;
|
|
198
|
+
globalSecrets?: string;
|
|
199
|
+
globalCredentials?: string;
|
|
198
200
|
}
|
|
199
201
|
]>;
|
|
200
202
|
wireAddonFiles: string[];
|
|
@@ -212,6 +214,13 @@ export interface SerializableInspectorState {
|
|
|
212
214
|
exportedName: string;
|
|
213
215
|
}][];
|
|
214
216
|
};
|
|
217
|
+
scorers: {
|
|
218
|
+
scorersMeta: InspectorState['scorers']['scorersMeta'];
|
|
219
|
+
files: [string, {
|
|
220
|
+
path: string;
|
|
221
|
+
exportedName: string;
|
|
222
|
+
}][];
|
|
223
|
+
};
|
|
215
224
|
cli: {
|
|
216
225
|
meta: InspectorState['cli']['meta'];
|
|
217
226
|
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) &&
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRef } from '@pikku/core/workflow';
|
|
1
2
|
/**
|
|
2
3
|
* Convert a RefValue (from runtime) to DataRef (serialized)
|
|
3
4
|
*/
|
|
@@ -7,22 +8,13 @@ function convertRef(ref) {
|
|
|
7
8
|
path: ref.path,
|
|
8
9
|
};
|
|
9
10
|
}
|
|
10
|
-
/**
|
|
11
|
-
* Check if a value is a runtime RefValue
|
|
12
|
-
*/
|
|
13
|
-
function isRefValue(value) {
|
|
14
|
-
return (typeof value === 'object' &&
|
|
15
|
-
value !== null &&
|
|
16
|
-
'__isRef' in value &&
|
|
17
|
-
value.__isRef === true);
|
|
18
|
-
}
|
|
19
11
|
/**
|
|
20
12
|
* Convert input mapping from runtime format to serialized format
|
|
21
13
|
*/
|
|
22
14
|
function serializeInputMapping(input) {
|
|
23
15
|
const result = {};
|
|
24
16
|
for (const [key, value] of Object.entries(input)) {
|
|
25
|
-
if (
|
|
17
|
+
if (isRef(value)) {
|
|
26
18
|
result[key] = convertRef(value);
|
|
27
19
|
}
|
|
28
20
|
else {
|
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.58",
|
|
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.82"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@pikku/core": "^0.12.
|
|
51
|
+
"@pikku/core": "^0.12.82",
|
|
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
|
+
}
|