@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/dist/add/add-ai-agent.js +25 -0
  3. package/dist/add/add-ai-scorer.d.ts +2 -0
  4. package/dist/add/add-ai-scorer.js +61 -0
  5. package/dist/add/add-wire-addon.js +16 -0
  6. package/dist/add/scenario-instrumentation.d.ts +6 -1
  7. package/dist/add/scenario-instrumentation.js +6 -0
  8. package/dist/error-codes.d.ts +2 -0
  9. package/dist/error-codes.js +3 -0
  10. package/dist/inspector.js +13 -4
  11. package/dist/types.d.ts +20 -0
  12. package/dist/utils/filter-inspector-state.js +8 -0
  13. package/dist/utils/post-process.d.ts +28 -3
  14. package/dist/utils/post-process.js +85 -15
  15. package/dist/utils/serialize-inspector-state.d.ts +9 -0
  16. package/dist/utils/serialize-inspector-state.js +8 -0
  17. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +13 -4
  18. package/dist/utils/workflow/dsl/patterns.d.ts +2 -1
  19. package/dist/utils/workflow/dsl/patterns.js +8 -5
  20. package/dist/utils/workflow/graph/serialize-workflow-graph.js +2 -10
  21. package/dist/visit.js +2 -0
  22. package/package.json +3 -3
  23. package/src/add/add-ai-agent.ts +35 -0
  24. package/src/add/add-ai-scorer.test.ts +197 -0
  25. package/src/add/add-ai-scorer.ts +83 -0
  26. package/src/add/add-scenario-step.test.ts +19 -0
  27. package/src/add/add-wire-addon.test.ts +43 -0
  28. package/src/add/add-wire-addon.ts +14 -0
  29. package/src/add/scenario-instrumentation.ts +6 -0
  30. package/src/error-codes.ts +4 -0
  31. package/src/inspector.ts +15 -4
  32. package/src/types.ts +17 -0
  33. package/src/utils/filter-inspector-state.ts +8 -0
  34. package/src/utils/post-process.test.ts +71 -0
  35. package/src/utils/post-process.ts +98 -16
  36. package/src/utils/serialize-inspector-state.ts +14 -0
  37. package/src/utils/workflow/dsl/extract-approval-policy.test.ts +68 -0
  38. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +12 -4
  39. package/src/utils/workflow/dsl/patterns.ts +8 -5
  40. package/src/utils/workflow/graph/serialize-workflow-graph.ts +2 -15
  41. package/src/visit.ts +2 -0
  42. package/tsconfig.tsbuildinfo +1 -1
  43. package/src/utils/secret-brokers.test.ts +0 -53
  44. 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
- export function validateAgentModels(logger, state) {
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
- if (!model.includes('/')) {
565
- logger.critical(ErrorCode.INVALID_MODEL, `AI agent '${meta.name}' uses model '${model}', which must be provider-qualified as '<provider>/<model>' (e.g. 'openai/gpt-4').`);
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 `expiry`, which the graph
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
- prop.name.getText() === 'expiry' &&
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()` or `.expectEventually()`.
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 `expectError` are inline steps rather than scenario
61
- * steps, so they carry no phase and never reach the step meta — but each one is
62
- * a witness: a recorded service call, or a refusal. PKU680 has to see them, or
63
- * a scenario whose only assertion is one of these reads as asserting nothing.
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()` or `.expectEventually()`.
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 (isRefValue(value)) {
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.55",
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.79"
48
+ "@pikku/core": "^0.12.82"
49
49
  },
50
50
  "devDependencies": {
51
- "@pikku/core": "^0.12.79",
51
+ "@pikku/core": "^0.12.82",
52
52
  "@types/node": "^24.11.0"
53
53
  },
54
54
  "engines": {
@@ -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
+ }