@pikku/inspector 0.12.56 → 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 (42) hide show
  1. package/CHANGELOG.md +130 -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 +1 -0
  9. package/dist/error-codes.js +2 -0
  10. package/dist/inspector.js +7 -3
  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 +13 -3
  14. package/dist/utils/post-process.js +33 -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/visit.js +2 -0
  21. package/package.json +3 -3
  22. package/src/add/add-ai-agent.ts +35 -0
  23. package/src/add/add-ai-scorer.test.ts +197 -0
  24. package/src/add/add-ai-scorer.ts +83 -0
  25. package/src/add/add-scenario-step.test.ts +19 -0
  26. package/src/add/add-wire-addon.test.ts +43 -0
  27. package/src/add/add-wire-addon.ts +14 -0
  28. package/src/add/scenario-instrumentation.ts +6 -0
  29. package/src/error-codes.ts +3 -0
  30. package/src/inspector.ts +7 -3
  31. package/src/types.ts +17 -0
  32. package/src/utils/filter-inspector-state.ts +8 -0
  33. package/src/utils/post-process.test.ts +71 -0
  34. package/src/utils/post-process.ts +41 -16
  35. package/src/utils/serialize-inspector-state.ts +14 -0
  36. package/src/utils/workflow/dsl/extract-approval-policy.test.ts +68 -0
  37. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +12 -4
  38. package/src/utils/workflow/dsl/patterns.ts +8 -5
  39. package/src/visit.ts +2 -0
  40. package/tsconfig.tsbuildinfo +1 -1
  41. package/src/utils/secret-brokers.test.ts +0 -53
  42. package/src/utils/secret-brokers.ts +0 -11
@@ -11,6 +11,7 @@ import {
11
11
  validateRemoteAddonDependencies,
12
12
  validateRemoteAddonAuth,
13
13
  validateAgentToolReferences,
14
+ validateAgentModels,
14
15
  } from './post-process.js'
15
16
  import { ErrorCode } from '../error-codes.js'
16
17
  import type { InspectorState, InspectorLogger } from '../types.js'
@@ -713,3 +714,73 @@ describe('validateAgentToolReferences (ref() resolved at build time)', () => {
713
714
  assert.deepEqual(criticals, [])
714
715
  })
715
716
  })
717
+
718
+ const makeModelState = (
719
+ models: Record<string, string>
720
+ ): Omit<InspectorState, 'typesLookup'> =>
721
+ ({
722
+ agents: {
723
+ agentsMeta: Object.fromEntries(
724
+ Object.entries(models).map(([name, model]) => [
725
+ name,
726
+ { name, model, sourceFile: `src/${name}.agent.ts` },
727
+ ])
728
+ ),
729
+ },
730
+ }) as unknown as Omit<InspectorState, 'typesLookup'>
731
+
732
+ describe('validateAgentModels (aliases resolve against pikku.config.json)', () => {
733
+ test('a provider-qualified model needs no alias', () => {
734
+ const { logger, criticals } = makeCriticalLogger()
735
+ validateAgentModels(logger, makeModelState({ triage: 'openai/gpt-5-mini' }))
736
+ assert.deepEqual(criticals, [])
737
+ })
738
+
739
+ test('a missing model is still reported', () => {
740
+ const { logger, criticals } = makeCriticalLogger()
741
+ validateAgentModels(logger, makeModelState({ triage: '' }))
742
+ assert.equal(criticals.length, 1)
743
+ assert.equal(criticals[0]!.code, ErrorCode.MISSING_MODEL)
744
+ })
745
+
746
+ test('a bare name that the models table defines is accepted', () => {
747
+ const { logger, criticals } = makeCriticalLogger()
748
+ validateAgentModels(logger, makeModelState({ triage: 'cheap' }), {
749
+ cheap: 'openai/gpt-5-mini',
750
+ })
751
+ assert.deepEqual(criticals, [])
752
+ })
753
+
754
+ test('a bare name with no alias is an error naming the ones that exist', () => {
755
+ const { logger, criticals } = makeCriticalLogger()
756
+ validateAgentModels(logger, makeModelState({ triage: 'exspensive' }), {
757
+ cheap: 'openai/gpt-5-mini',
758
+ expensive: 'openai/gpt-5',
759
+ })
760
+ assert.equal(criticals.length, 1)
761
+ assert.equal(criticals[0]!.code, ErrorCode.INVALID_MODEL)
762
+ assert.match(criticals[0]!.message, /cheap, expensive/)
763
+ })
764
+
765
+ test('with no models table a bare name still fails, as it always has', () => {
766
+ const { logger, criticals } = makeCriticalLogger()
767
+ validateAgentModels(logger, makeModelState({ triage: 'gpt-5-mini' }))
768
+ assert.equal(criticals.length, 1)
769
+ assert.equal(criticals[0]!.code, ErrorCode.INVALID_MODEL)
770
+ })
771
+
772
+ for (const inherited of ['toString', 'constructor', '__proto__']) {
773
+ test(`a bare '${inherited}' is not an alias just because Object has one`, () => {
774
+ const { logger, criticals } = makeCriticalLogger()
775
+ validateAgentModels(logger, makeModelState({ triage: inherited }), {
776
+ cheap: 'openai/gpt-5-mini',
777
+ })
778
+ assert.equal(
779
+ criticals.length,
780
+ 1,
781
+ 'an inherited property name must not read as a configured alias'
782
+ )
783
+ assert.equal(criticals[0]!.code, ErrorCode.INVALID_MODEL)
784
+ })
785
+ }
786
+ })
@@ -15,7 +15,6 @@ import { existsSync, readFileSync } from 'node:fs'
15
15
  import { join } from 'node:path'
16
16
  import { extractTypeKeys } from './type-utils.js'
17
17
  import { ErrorCode } from '../error-codes.js'
18
- import { isSecretBrokerFunction } from './secret-brokers.js'
19
18
  import { findSecretAliasServices } from './secret-alias-services.js'
20
19
  import { relative } from 'node:path'
21
20
  import { AUTH_HANDLER_FUNC_ID } from '../add/add-auth.js'
@@ -746,9 +745,14 @@ export function validateSchemaReferences(
746
745
  })
747
746
  }
748
747
 
748
+ /**
749
+ * A model is either concrete (`provider/model`) or an alias, which must appear
750
+ * in the `models` table from pikku.config.json.
751
+ */
749
752
  export function validateAgentModels(
750
753
  logger: InspectorLogger,
751
- state: InspectorState | Omit<InspectorState, 'typesLookup'>
754
+ state: InspectorState | Omit<InspectorState, 'typesLookup'>,
755
+ modelAliases: Record<string, string> = {}
752
756
  ): void {
753
757
  for (const [, meta] of Object.entries(state.agents.agentsMeta)) {
754
758
  const model = meta.model
@@ -759,10 +763,17 @@ export function validateAgentModels(
759
763
  )
760
764
  continue
761
765
  }
762
- if (!model.includes('/')) {
766
+ // `hasOwn`, not a truthy lookup: every object literal inherits `toString`,
767
+ // `constructor` and `__proto__`, so a bare model named after one of them
768
+ // would read as a configured alias and pass a check nothing else repeats.
769
+ if (!model.includes('/') && !Object.hasOwn(modelAliases, model)) {
770
+ const known = Object.keys(modelAliases).sort()
763
771
  logger.critical(
764
772
  ErrorCode.INVALID_MODEL,
765
- `AI agent '${meta.name}' uses model '${model}', which must be provider-qualified as '<provider>/<model>' (e.g. 'openai/gpt-4').`
773
+ `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. ` +
774
+ (known.length
775
+ ? `Known aliases: ${known.join(', ')}.`
776
+ : `No "models" aliases are configured.`)
766
777
  )
767
778
  }
768
779
  }
@@ -982,15 +993,6 @@ export function validateSchemaWiringSeparation(
982
993
  }
983
994
  }
984
995
 
985
- /** Marks the functions that keep the full `SecretService`. */
986
- export function computeSecretBrokers(state: InspectorState): void {
987
- for (const [id, meta] of Object.entries(state.functions.meta)) {
988
- if (isSecretBrokerFunction(meta.pikkuFuncId ?? id)) {
989
- meta.secretBroker = true
990
- }
991
- }
992
- }
993
-
994
996
  /**
995
997
  * Refuses a singleton service that is a `SecretService` under another name.
996
998
  *
@@ -1029,9 +1031,6 @@ export function validateNoSecretAliasServices(
1029
1031
  }
1030
1032
 
1031
1033
  for (const [id, meta] of Object.entries(state.functions.meta)) {
1032
- if (meta.secretBroker) {
1033
- continue
1034
- }
1035
1034
  report('Function', id, meta.services?.services ?? [])
1036
1035
  }
1037
1036
  for (const [id, def] of Object.entries(state.permissions.definitions)) {
@@ -1284,6 +1283,32 @@ export function validateAgentToolReferences(
1284
1283
  }
1285
1284
  }
1286
1285
 
1286
+ /**
1287
+ * Validates that every scorer an agent asks to be graded by exists.
1288
+ *
1289
+ * The runtime warns and skips an unresolvable name rather than failing a run
1290
+ * that has already answered the user — which means a typo would otherwise cost
1291
+ * nothing visible and simply grade nothing, forever.
1292
+ */
1293
+ export function validateAgentScorerReferences(
1294
+ logger: InspectorLogger,
1295
+ state: InspectorState | Omit<InspectorState, 'typesLookup'>
1296
+ ): void {
1297
+ const known = Object.keys(state.scorers.scorersMeta)
1298
+
1299
+ for (const [agentKey, agent] of Object.entries(state.agents.agentsMeta)) {
1300
+ const where = agent.sourceFile ? ` (${agent.sourceFile})` : ''
1301
+ for (const scorer of agent.scorers ?? []) {
1302
+ if (known.includes(scorer)) continue
1303
+ logger.critical(
1304
+ ErrorCode.AGENT_SCORER_NOT_FOUND,
1305
+ `AI agent '${agentKey}'${where} asks to be graded by scorer '${scorer}', which is not declared in this project. ` +
1306
+ `Declared scorers: ${known.join(', ') || 'none'}.`
1307
+ )
1308
+ }
1309
+ }
1310
+ }
1311
+
1287
1312
  /**
1288
1313
  * Validates that every scope granted by a system role is declared via
1289
1314
  * `defineScope`. Runs after all visitors, so declaration order does not matter.
@@ -170,6 +170,8 @@ export interface SerializableInspectorState {
170
170
  secretOverrides?: Record<string, string>
171
171
  variableOverrides?: Record<string, string>
172
172
  credentialOverrides?: Record<string, string>
173
+ globalSecrets?: string
174
+ globalCredentials?: string
173
175
  },
174
176
  ]
175
177
  >
@@ -185,6 +187,10 @@ export interface SerializableInspectorState {
185
187
  agentsMeta: InspectorState['agents']['agentsMeta']
186
188
  files: [string, { path: string; exportedName: string }][]
187
189
  }
190
+ scorers: {
191
+ scorersMeta: InspectorState['scorers']['scorersMeta']
192
+ files: [string, { path: string; exportedName: string }][]
193
+ }
188
194
  cli: {
189
195
  meta: InspectorState['cli']['meta']
190
196
  files: string[]
@@ -409,6 +415,10 @@ export function serializeInspectorState(
409
415
  agentsMeta: state.agents?.agentsMeta ?? {},
410
416
  files: Array.from(state.agents?.files?.entries() ?? []),
411
417
  },
418
+ scorers: {
419
+ scorersMeta: state.scorers?.scorersMeta ?? {},
420
+ files: Array.from(state.scorers?.files?.entries() ?? []),
421
+ },
412
422
  cli: {
413
423
  meta: state.cli.meta,
414
424
  files: Array.from(state.cli.files),
@@ -613,6 +623,10 @@ export function deserializeInspectorState(
613
623
  agentsMeta: data.agents?.agentsMeta || {},
614
624
  files: new Map(data.agents?.files || []),
615
625
  },
626
+ scorers: {
627
+ scorersMeta: data.scorers?.scorersMeta || {},
628
+ files: new Map(data.scorers?.files || []),
629
+ },
616
630
  cli: {
617
631
  meta: data.cli.meta,
618
632
  files: new Set(data.cli.files),
@@ -0,0 +1,68 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import * as ts from 'typescript'
4
+ import { extractDSLWorkflow } from './extract-dsl-workflow.js'
5
+ import type { ApprovalStepMeta } from '@pikku/core/workflow'
6
+
7
+ const checker = {
8
+ getSymbolAtLocation: () => undefined,
9
+ getAliasedSymbol: () => undefined,
10
+ getShorthandAssignmentValueSymbol: () => undefined,
11
+ getTypeAtLocation: () => undefined,
12
+ } as unknown as ts.TypeChecker
13
+
14
+ /** Extracts the single approval step from a workflow body. */
15
+ const approvalStepFor = (options: string): ApprovalStepMeta => {
16
+ const file = ts.createSourceFile(
17
+ 'wf.ts',
18
+ `const wf = pikkuWorkflowFunc({
19
+ func: async (services, data, { workflow }) => {
20
+ await workflow.approval('Release funds', ${options})
21
+ },
22
+ })`,
23
+ ts.ScriptTarget.Latest,
24
+ true
25
+ )
26
+
27
+ let node: ts.Node | undefined
28
+ const visit = (n: ts.Node) => {
29
+ if (ts.isCallExpression(n) && n.expression.getText() === 'pikkuWorkflowFunc') {
30
+ node = n
31
+ }
32
+ ts.forEachChild(n, visit)
33
+ }
34
+ visit(file)
35
+ assert.ok(node, 'failed to find the workflow definition')
36
+
37
+ const result = extractDSLWorkflow(node, checker)
38
+ assert.equal(result.status, 'ok', JSON.stringify(result))
39
+ const step = result.steps?.find(
40
+ (s): s is ApprovalStepMeta => s.type === 'approval'
41
+ )
42
+ assert.ok(step, 'no approval step was extracted')
43
+ return step
44
+ }
45
+
46
+ describe('an approval gate carries its policy into the graph', () => {
47
+ test('approvers and approverScope survive extraction', () => {
48
+ const step = approvalStepFor(
49
+ `{ schema: DecisionSchema, approvers: 'not-initiator', approverScope: 'payments:approve' }`
50
+ )
51
+ assert.equal(step.approvers, 'not-initiator')
52
+ assert.equal(step.approverScope, 'payments:approve')
53
+ })
54
+
55
+ test('expiry still survives alongside them', () => {
56
+ const step = approvalStepFor(
57
+ `{ schema: DecisionSchema, expiry: '3d', approvers: 'owner' }`
58
+ )
59
+ assert.equal(step.expiry, '3d')
60
+ assert.equal(step.approvers, 'owner')
61
+ })
62
+
63
+ test('a gate declaring no policy records none', () => {
64
+ const step = approvalStepFor(`{ schema: DecisionSchema }`)
65
+ assert.equal(step.approvers, undefined)
66
+ assert.equal(step.approverScope, undefined)
67
+ })
68
+ })
@@ -1051,14 +1051,15 @@ function extractApprovalStep(
1051
1051
  step.outputVar = outputVar
1052
1052
  }
1053
1053
  // The `schema` option is a runtime value validated inside the workflow body,
1054
- // so it is deliberately not serialized here — only `expiry`, which the graph
1055
- // and planned-step ladder need in order to describe the gate.
1054
+ // so it is deliberately not serialized here — only the literal options the
1055
+ // graph and planned-step ladder need in order to describe the gate.
1056
1056
  const options = args[1]
1057
1057
  if (options && ts.isObjectLiteralExpression(options)) {
1058
1058
  for (const prop of options.properties) {
1059
+ if (!ts.isPropertyAssignment(prop)) continue
1060
+ const name = prop.name.getText()
1059
1061
  if (
1060
- ts.isPropertyAssignment(prop) &&
1061
- prop.name.getText() === 'expiry' &&
1062
+ name === 'expiry' &&
1062
1063
  (ts.isStringLiteral(prop.initializer) ||
1063
1064
  ts.isNumericLiteral(prop.initializer))
1064
1065
  ) {
@@ -1066,6 +1067,13 @@ function extractApprovalStep(
1066
1067
  ? Number(prop.initializer.text)
1067
1068
  : prop.initializer.text
1068
1069
  }
1070
+ if (name === 'approvers' && ts.isStringLiteral(prop.initializer)) {
1071
+ step.approvers = prop.initializer
1072
+ .text as ApprovalStepMeta['approvers']
1073
+ }
1074
+ if (name === 'approverScope' && ts.isStringLiteral(prop.initializer)) {
1075
+ step.approverScope = prop.initializer.text
1076
+ }
1069
1077
  }
1070
1078
  }
1071
1079
  return step
@@ -77,20 +77,23 @@ export function getScenarioStepPhase(
77
77
  /**
78
78
  * The expectation helpers that assert without being a `then`.
79
79
  *
80
- * `expectService` and `expectError` are inline steps rather than scenario
81
- * steps, so they carry no phase and never reach the step meta — but each one is
82
- * a witness: a recorded service call, or a refusal. PKU680 has to see them, or
83
- * a scenario whose only assertion is one of these reads as asserting nothing.
80
+ * `expectService`, `expectError` and `expectScore` are inline steps rather than
81
+ * scenario steps, so they carry no phase and never reach the step meta — but
82
+ * each one is a witness: a recorded service call, a refusal, or a graded run.
83
+ * PKU680 has to see them, or a scenario whose only assertion is one of these
84
+ * reads as asserting nothing.
84
85
  */
85
86
  const SCENARIO_EXPECTATION_CALLS = [
86
87
  'expectService',
87
88
  'expectError',
88
89
  'expectEventually',
90
+ 'expectScore',
89
91
  ] as const
90
92
 
91
93
  /**
92
94
  * Check if a call expression is one of the scenario expectation helpers —
93
- * `scenario.expectService()`, `.expectError()` or `.expectEventually()`.
95
+ * `scenario.expectService()`, `.expectError()`, `.expectEventually()` or
96
+ * `.expectScore()`.
94
97
  */
95
98
  export function isScenarioExpectationCall(node: ts.CallExpression): boolean {
96
99
  return (
package/src/visit.ts CHANGED
@@ -34,6 +34,7 @@ import { addVariable } from './add/add-variable.js'
34
34
  import { addWorkflowGraph } from './add/add-workflow-graph.js'
35
35
  import { addFeature } from './add/add-feature.js'
36
36
  import { addAIAgent } from './add/add-ai-agent.js'
37
+ import { addAIScorer } from './add/add-ai-scorer.js'
37
38
  import { addApprovalDescription } from './add/add-approval-description.js'
38
39
 
39
40
  export const visitSetup = (
@@ -172,6 +173,7 @@ export const visitRoutes = (
172
173
  addWorkflowGraph(logger, node, checker, state, nextOptions)
173
174
  addFeature(logger, node, checker, state, nextOptions)
174
175
  addAIAgent(logger, node, checker, state, nextOptions)
176
+ addAIScorer(logger, node, checker, state, nextOptions)
175
177
 
176
178
  ts.forEachChild(node, (child) =>
177
179
  visitRoutes(logger, checker, child, state, nextOptions)