@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +185 -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 +26 -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 +7 -3
  11. package/dist/types.d.ts +24 -0
  12. package/dist/utils/filter-inspector-state.js +8 -0
  13. package/dist/utils/post-process.d.ts +13 -3
  14. package/dist/utils/post-process.js +51 -21
  15. package/dist/utils/serialize-inspector-state.d.ts +11 -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/workflow-graph.types.d.ts +2 -1
  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-functions.ts +1 -1
  27. package/src/add/add-scenario-step.test.ts +21 -1
  28. package/src/add/add-wire-addon.test.ts +74 -0
  29. package/src/add/add-wire-addon.ts +22 -0
  30. package/src/add/scenario-instrumentation.ts +6 -0
  31. package/src/error-codes.ts +4 -0
  32. package/src/inspector.ts +7 -3
  33. package/src/types.ts +21 -0
  34. package/src/utils/filter-inspector-state.ts +8 -0
  35. package/src/utils/post-process.test.ts +138 -0
  36. package/src/utils/post-process.ts +69 -22
  37. package/src/utils/serialize-inspector-state.ts +16 -0
  38. package/src/utils/workflow/dsl/extract-approval-policy.test.ts +68 -0
  39. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +13 -5
  40. package/src/utils/workflow/dsl/patterns.ts +8 -5
  41. package/src/utils/workflow/graph/workflow-graph.types.ts +1 -1
  42. package/src/visit.ts +2 -0
  43. package/tsconfig.tsbuildinfo +1 -1
  44. package/src/utils/secret-brokers.test.ts +0 -53
  45. 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
- if (!addonDecl.secretOverrides)
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
- if (!addonDecl.credentialOverrides)
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
- export function validateAgentModels(logger, state) {
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
- if (!model.includes('/')) {
617
- 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').`);
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 `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) &&
@@ -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, ScenarioStepPhase, WorkflowContext, WorkflowPlannedStep } from '@pikku/core/workflow';
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.56",
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.80"
48
+ "@pikku/core": "^0.12.83"
49
49
  },
50
50
  "devDependencies": {
51
- "@pikku/core": "^0.12.80",
51
+ "@pikku/core": "^0.12.83",
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
+ }
@@ -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/workflow'
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. */