@pikku/core 0.12.80 → 0.12.83

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 (237) hide show
  1. package/CHANGELOG.md +345 -0
  2. package/dist/errors/index.d.ts +1 -1
  3. package/dist/errors/index.js +1 -1
  4. package/dist/function/function-runner.js +2 -5
  5. package/dist/function/index.d.ts +1 -1
  6. package/dist/index.d.ts +11 -11
  7. package/dist/index.js +3 -3
  8. package/dist/pikku-state.js +4 -0
  9. package/dist/services/ai-agent-runner-service.d.ts +7 -0
  10. package/dist/services/ai-run-state-service.d.ts +10 -0
  11. package/dist/services/in-memory-ai-run-state-service.d.ts +5 -1
  12. package/dist/services/in-memory-ai-run-state-service.js +9 -0
  13. package/dist/services/index.d.ts +15 -16
  14. package/dist/services/index.js +5 -5
  15. package/dist/services/meta-service.d.ts +2 -1
  16. package/dist/services/scoped-credential-service.d.ts +21 -0
  17. package/dist/services/scoped-credential-service.js +53 -0
  18. package/dist/testing/service-tests/ai-storage-service-tests.js +76 -0
  19. package/dist/types/core.types.d.ts +2 -3
  20. package/dist/types/state.types.d.ts +19 -1
  21. package/dist/wirings/actor-flow/index.d.ts +1 -1
  22. package/dist/wirings/ai-agent/ai-agent-finalize.d.ts +58 -0
  23. package/dist/wirings/ai-agent/ai-agent-finalize.js +138 -0
  24. package/dist/wirings/ai-agent/ai-agent-interrupt.js +1 -0
  25. package/dist/wirings/ai-agent/ai-agent-memory.d.ts +2 -8
  26. package/dist/wirings/ai-agent/ai-agent-memory.js +34 -17
  27. package/dist/wirings/ai-agent/ai-agent-model-config.d.ts +7 -0
  28. package/dist/wirings/ai-agent/ai-agent-model-config.js +44 -1
  29. package/dist/wirings/ai-agent/ai-agent-prepare.js +4 -0
  30. package/dist/wirings/ai-agent/ai-agent-runner.js +61 -40
  31. package/dist/wirings/ai-agent/ai-agent-stream.js +89 -36
  32. package/dist/wirings/ai-agent/ai-agent-turn.d.ts +1 -0
  33. package/dist/wirings/ai-agent/ai-agent-turn.js +1 -0
  34. package/dist/wirings/ai-agent/ai-agent.types.d.ts +46 -1
  35. package/dist/wirings/ai-agent/index.d.ts +8 -7
  36. package/dist/wirings/ai-agent/index.js +5 -4
  37. package/dist/wirings/ai-scorer/ai-scorer-grade.d.ts +26 -0
  38. package/dist/wirings/ai-scorer/ai-scorer-grade.js +33 -0
  39. package/dist/wirings/ai-scorer/ai-scorer-judge.d.ts +17 -0
  40. package/dist/wirings/ai-scorer/ai-scorer-judge.js +92 -0
  41. package/dist/wirings/ai-scorer/ai-scorer-live.d.ts +15 -0
  42. package/dist/wirings/ai-scorer/ai-scorer-live.js +38 -0
  43. package/dist/wirings/ai-scorer/ai-scorer-registry.d.ts +18 -0
  44. package/dist/wirings/ai-scorer/ai-scorer-registry.js +46 -0
  45. package/dist/wirings/ai-scorer/ai-scorer-sampling.d.ts +8 -0
  46. package/dist/wirings/ai-scorer/ai-scorer-sampling.js +31 -0
  47. package/dist/wirings/ai-scorer/ai-scorer-snapshots.d.ts +10 -0
  48. package/dist/wirings/ai-scorer/ai-scorer-snapshots.js +40 -0
  49. package/dist/wirings/ai-scorer/ai-scorer-worker.d.ts +15 -0
  50. package/dist/wirings/ai-scorer/ai-scorer-worker.js +58 -0
  51. package/dist/wirings/ai-scorer/ai-scorer.d.ts +39 -0
  52. package/dist/wirings/ai-scorer/ai-scorer.js +40 -0
  53. package/dist/wirings/ai-scorer/ai-scorer.types.d.ts +90 -0
  54. package/dist/wirings/ai-scorer/ai-scorer.types.js +4 -0
  55. package/dist/wirings/ai-scorer/index.d.ts +6 -0
  56. package/dist/wirings/ai-scorer/index.js +5 -0
  57. package/dist/wirings/channel/index.d.ts +5 -6
  58. package/dist/wirings/channel/index.js +3 -4
  59. package/dist/wirings/channel/local/local-channel-runner.js +8 -1
  60. package/dist/wirings/cli/channel/cli-raw-channel-runner.js +9 -1
  61. package/dist/wirings/cli/channel/index.d.ts +1 -2
  62. package/dist/wirings/cli/channel/index.js +0 -1
  63. package/dist/wirings/cli/cli-runner.js +13 -1
  64. package/dist/wirings/credential/index.d.ts +1 -1
  65. package/dist/wirings/gateway/index.d.ts +1 -1
  66. package/dist/wirings/http/http-runner.js +8 -2
  67. package/dist/wirings/http/index.d.ts +1 -2
  68. package/dist/wirings/mcp/index.d.ts +1 -1
  69. package/dist/wirings/mcp/mcp-runner.d.ts +15 -0
  70. package/dist/wirings/mcp/mcp-runner.js +18 -5
  71. package/dist/wirings/persona/index.d.ts +3 -4
  72. package/dist/wirings/persona/index.js +2 -3
  73. package/dist/wirings/queue/index.d.ts +1 -3
  74. package/dist/wirings/queue/index.js +1 -3
  75. package/dist/wirings/rpc/addon-runner.d.ts +8 -0
  76. package/dist/wirings/rpc/addon-runner.js +31 -3
  77. package/dist/wirings/rpc/rpc-runner.js +4 -0
  78. package/dist/wirings/rpc/rpc-types.d.ts +8 -0
  79. package/dist/wirings/rpc/wire-addon.d.ts +25 -0
  80. package/dist/wirings/rpc/wire-addon.js +8 -0
  81. package/dist/wirings/scheduler/index.d.ts +1 -1
  82. package/dist/wirings/trigger/index.d.ts +1 -1
  83. package/dist/wirings/virtual-user/index.d.ts +5 -6
  84. package/dist/wirings/virtual-user/index.js +2 -4
  85. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +85 -15
  86. package/dist/wirings/workflow/feature.d.ts +2 -1
  87. package/dist/wirings/workflow/index.d.ts +5 -16
  88. package/dist/wirings/workflow/index.js +1 -9
  89. package/dist/wirings/workflow/pikku-scenario-service.d.ts +17 -7
  90. package/dist/wirings/workflow/pikku-scenario-service.js +48 -13
  91. package/dist/wirings/workflow/pikku-workflow-service.js +17 -3
  92. package/dist/wirings/workflow/scenario-step.types.d.ts +8 -0
  93. package/dist/wirings/workflow/scenario.types.d.ts +37 -0
  94. package/dist/wirings/workflow/workflow-approval-audit.d.ts +16 -0
  95. package/dist/wirings/workflow/workflow-approval-audit.js +40 -0
  96. package/dist/wirings/workflow/workflow-approval-policy.d.ts +20 -0
  97. package/dist/wirings/workflow/workflow-approval-policy.js +48 -0
  98. package/dist/wirings/workflow/workflow-approval.d.ts +29 -1
  99. package/dist/wirings/workflow/workflow-approval.js +65 -2
  100. package/dist/wirings/workflow/workflow-run-ownership.d.ts +2 -1
  101. package/dist/wirings/workflow/workflow-run-ownership.js +2 -1
  102. package/dist/wirings/workflow/workflow.types.d.ts +2 -37
  103. package/knowledge/decisions/internals/addon-pikku-meta-ships-at-the-package-root-or-under-dist.md +32 -0
  104. package/knowledge/decisions/internals/an-addon-scope-root-loses-to-a-root-the-host-already-declares.md +39 -0
  105. package/knowledge/decisions/internals/index.md +30 -3
  106. package/knowledge/decisions/internals/validate-runs-checks-by-precondition.md +115 -0
  107. package/knowledge/decisions/security/a-function-never-receives-the-secret-service.md +37 -0
  108. package/knowledge/decisions/security/a-workflow-run-is-read-and-approved-by-its-owner.md +30 -14
  109. package/knowledge/decisions/security/an-approval-answer-outlives-the-run-it-answered.md +59 -0
  110. package/knowledge/decisions/security/index.md +3 -1
  111. package/knowledge/questions/index.md +1 -1
  112. package/package.json +3 -2
  113. package/scripts/generate-api-report.mts +143 -18
  114. package/src/api-report.test.ts +2 -2
  115. package/src/errors/index.ts +1 -1
  116. package/src/function/function-runner.test.ts +52 -0
  117. package/src/function/function-runner.ts +5 -9
  118. package/src/function/index.ts +0 -2
  119. package/src/index.ts +0 -35
  120. package/src/pikku-state.ts +5 -0
  121. package/src/public-surface.json +81 -118
  122. package/src/services/ai-agent-runner-service.ts +12 -1
  123. package/src/services/ai-run-state-service.ts +11 -0
  124. package/src/services/in-memory-ai-run-state-service.ts +13 -0
  125. package/src/services/index.ts +7 -58
  126. package/src/services/meta-service.ts +2 -4
  127. package/src/services/scoped-credential-service.test.ts +86 -0
  128. package/src/services/scoped-credential-service.ts +63 -0
  129. package/src/testing/service-tests/ai-storage-service-tests.ts +93 -0
  130. package/src/types/core.types.ts +4 -7
  131. package/src/types/state.types.ts +21 -1
  132. package/src/wirings/actor-flow/index.ts +0 -3
  133. package/src/wirings/ai-agent/ai-agent-finalize.test.ts +186 -0
  134. package/src/wirings/ai-agent/ai-agent-finalize.ts +197 -0
  135. package/src/wirings/ai-agent/ai-agent-interrupt.ts +1 -0
  136. package/src/wirings/ai-agent/ai-agent-memory.ts +54 -38
  137. package/src/wirings/ai-agent/ai-agent-model-config.test.ts +72 -3
  138. package/src/wirings/ai-agent/ai-agent-model-config.ts +49 -1
  139. package/src/wirings/ai-agent/ai-agent-prepare.ts +4 -0
  140. package/src/wirings/ai-agent/ai-agent-runner.ts +71 -40
  141. package/src/wirings/ai-agent/ai-agent-stream-output-hooks.test.ts +353 -0
  142. package/src/wirings/ai-agent/ai-agent-stream.ts +116 -54
  143. package/src/wirings/ai-agent/ai-agent-turn.test.ts +67 -0
  144. package/src/wirings/ai-agent/ai-agent-turn.ts +1 -0
  145. package/src/wirings/ai-agent/ai-agent.types.ts +64 -4
  146. package/src/wirings/ai-agent/index.ts +2 -16
  147. package/src/wirings/ai-scorer/ai-scorer-grade.test.ts +106 -0
  148. package/src/wirings/ai-scorer/ai-scorer-grade.ts +55 -0
  149. package/src/wirings/ai-scorer/ai-scorer-judge.test.ts +143 -0
  150. package/src/wirings/ai-scorer/ai-scorer-judge.ts +120 -0
  151. package/src/wirings/ai-scorer/ai-scorer-live.test.ts +174 -0
  152. package/src/wirings/ai-scorer/ai-scorer-live.ts +56 -0
  153. package/src/wirings/ai-scorer/ai-scorer-registry.ts +63 -0
  154. package/src/wirings/ai-scorer/ai-scorer-sampling.test.ts +34 -0
  155. package/src/wirings/ai-scorer/ai-scorer-sampling.ts +36 -0
  156. package/src/wirings/ai-scorer/ai-scorer-snapshots.test.ts +49 -0
  157. package/src/wirings/ai-scorer/ai-scorer-snapshots.ts +46 -0
  158. package/src/wirings/ai-scorer/ai-scorer-worker.test.ts +122 -0
  159. package/src/wirings/ai-scorer/ai-scorer-worker.ts +69 -0
  160. package/src/wirings/ai-scorer/ai-scorer.ts +76 -0
  161. package/src/wirings/ai-scorer/ai-scorer.types.ts +107 -0
  162. package/src/wirings/ai-scorer/index.ts +24 -0
  163. package/src/wirings/channel/index.ts +1 -20
  164. package/src/wirings/channel/local/local-channel-runner.test.ts +68 -0
  165. package/src/wirings/channel/local/local-channel-runner.ts +8 -1
  166. package/src/wirings/cli/channel/cli-raw-channel-runner.test.ts +23 -0
  167. package/src/wirings/cli/channel/cli-raw-channel-runner.ts +12 -1
  168. package/src/wirings/cli/channel/index.ts +0 -7
  169. package/src/wirings/cli/cli-runner.test.ts +68 -0
  170. package/src/wirings/cli/cli-runner.ts +18 -1
  171. package/src/wirings/credential/index.ts +0 -1
  172. package/src/wirings/gateway/index.ts +0 -3
  173. package/src/wirings/http/http-runner.test.ts +66 -0
  174. package/src/wirings/http/http-runner.ts +10 -2
  175. package/src/wirings/http/index.ts +1 -1
  176. package/src/wirings/mcp/index.ts +0 -1
  177. package/src/wirings/mcp/mcp-runner.test.ts +181 -0
  178. package/src/wirings/mcp/mcp-runner.ts +35 -5
  179. package/src/wirings/persona/index.ts +0 -8
  180. package/src/wirings/queue/index.ts +0 -14
  181. package/src/wirings/rpc/addon-runner.ts +62 -3
  182. package/src/wirings/rpc/addon-secrets.test.ts +391 -0
  183. package/src/wirings/rpc/rpc-runner.test.ts +2 -0
  184. package/src/wirings/rpc/rpc-runner.ts +4 -0
  185. package/src/wirings/rpc/rpc-types.ts +8 -0
  186. package/src/wirings/rpc/wire-addon.ts +33 -0
  187. package/src/wirings/scheduler/index.ts +0 -1
  188. package/src/wirings/trigger/index.ts +0 -1
  189. package/src/wirings/virtual-user/index.ts +0 -16
  190. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +96 -16
  191. package/src/wirings/workflow/feature.ts +2 -5
  192. package/src/wirings/workflow/graph/graph-runner.test.ts +72 -0
  193. package/src/wirings/workflow/index.ts +2 -68
  194. package/src/wirings/workflow/pikku-scenario-service.ts +81 -16
  195. package/src/wirings/workflow/pikku-workflow-service.test.ts +13 -12
  196. package/src/wirings/workflow/pikku-workflow-service.ts +28 -4
  197. package/src/wirings/workflow/scenario-expectations.test.ts +75 -0
  198. package/src/wirings/workflow/scenario-hooks.test.ts +3 -2
  199. package/src/wirings/workflow/scenario-step.types.ts +8 -0
  200. package/src/wirings/workflow/scenario.types.ts +63 -0
  201. package/src/wirings/workflow/workflow-approval-audit.ts +47 -0
  202. package/src/wirings/workflow/workflow-approval-policy.test.ts +524 -0
  203. package/src/wirings/workflow/workflow-approval-policy.ts +68 -0
  204. package/src/wirings/workflow/workflow-approval.ts +113 -9
  205. package/src/wirings/workflow/workflow-run-authority.test.ts +12 -15
  206. package/src/wirings/workflow/workflow-run-ownership.ts +2 -1
  207. package/src/wirings/workflow/workflow.types.ts +1 -63
  208. package/src/wirings-stay-decoupled.test.ts +6 -2
  209. package/tsconfig.tsbuildinfo +1 -1
  210. package/dist/internal.d.ts +0 -3
  211. package/dist/internal.js +0 -2
  212. package/dist/middleware/timeout.d.ts +0 -9
  213. package/dist/middleware/timeout.js +0 -15
  214. package/dist/pikku-response.d.ts +0 -6
  215. package/dist/pikku-response.js +0 -6
  216. package/dist/services/gopass-secrets.d.ts +0 -15
  217. package/dist/services/gopass-secrets.js +0 -76
  218. package/dist/services/http-scenario-actors.d.ts +0 -75
  219. package/dist/services/http-scenario-actors.js +0 -195
  220. package/dist/services/http-user-flow-actors.d.ts +0 -67
  221. package/dist/services/http-user-flow-actors.js +0 -193
  222. package/dist/services/scenario-actors-service.d.ts +0 -127
  223. package/dist/services/scenario-actors-service.js +0 -40
  224. package/dist/services/user-flow-actors-service.d.ts +0 -39
  225. package/dist/wirings/credential/wire-credential.d.ts +0 -48
  226. package/dist/wirings/credential/wire-credential.js +0 -47
  227. package/dist/wirings/oauth2/oauth2-client.d.ts +0 -47
  228. package/dist/wirings/oauth2/oauth2-client.js +0 -263
  229. package/dist/wirings/oauth2/oauth2-routes.d.ts +0 -35
  230. package/dist/wirings/oauth2/oauth2-routes.js +0 -146
  231. package/dist/wirings/scope/wire-scope.d.ts +0 -33
  232. package/dist/wirings/scope/wire-scope.js +0 -32
  233. package/dist/wirings/workflow/dsl/index.d.ts +0 -5
  234. package/dist/wirings/workflow/dsl/index.js +0 -4
  235. package/dist/wirings/workflow/graph/index.d.ts +0 -5
  236. package/dist/wirings/workflow/graph/index.js +0 -4
  237. /package/dist/{services/user-flow-actors-service.js → wirings/workflow/scenario.types.js} +0 -0
@@ -0,0 +1,143 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { buildJudgePrompt, runJudge } from './ai-scorer-judge.js'
4
+ import { pikkuAIJudge } from './ai-scorer.js'
5
+ import type { ScorerInput } from './ai-scorer.types.js'
6
+ import type { AIAgentRunnerService } from '../../services/ai-agent-runner-service.js'
7
+
8
+ const input = (overrides: Partial<ScorerInput> = {}): ScorerInput => ({
9
+ runId: 'run-1',
10
+ agentName: 'assistant',
11
+ input: 'what is the capital of France?',
12
+ output: 'Paris',
13
+ toolCalls: [],
14
+ usage: { inputTokens: 10, outputTokens: 5 },
15
+ ...overrides,
16
+ })
17
+
18
+ const runner = (
19
+ object: unknown,
20
+ seen?: { params: any }
21
+ ): AIAgentRunnerService =>
22
+ ({
23
+ run: async (params: any) => {
24
+ if (seen) seen.params = params
25
+ return { object, usage: { inputTokens: 100, outputTokens: 20 } }
26
+ },
27
+ }) as unknown as AIAgentRunnerService
28
+
29
+ describe('buildJudgePrompt', () => {
30
+ test('shows the answer key to a reference-based judge', () => {
31
+ const scorer = pikkuAIJudge({
32
+ name: 'correctness',
33
+ description: 'Is the answer right',
34
+ model: 'claude-opus-5',
35
+ goal: 'Grade correctness.',
36
+ requiresReference: true,
37
+ })
38
+
39
+ const prompt = buildJudgePrompt(
40
+ scorer.judge!,
41
+ input({ reference: 'Paris' })
42
+ )
43
+
44
+ assert.match(prompt, /Reference answer:\nParis/)
45
+ })
46
+
47
+ test('withholds a reference section when there is no answer key', () => {
48
+ const scorer = pikkuAIJudge({
49
+ name: 'helpfulness',
50
+ description: 'Is the answer useful',
51
+ model: 'claude-opus-5',
52
+ goal: 'Grade helpfulness.',
53
+ })
54
+
55
+ const prompt = buildJudgePrompt(scorer.judge!, input())
56
+
57
+ assert.doesNotMatch(prompt, /Reference answer/)
58
+ assert.match(prompt, /Grade helpfulness\./)
59
+ })
60
+
61
+ test('a scorer that supplies its own prompt replaces the framing entirely', () => {
62
+ const scorer = pikkuAIJudge({
63
+ name: 'custom',
64
+ description: 'Custom framing',
65
+ model: 'claude-opus-5',
66
+ goal: 'ignored',
67
+ prompt: (run) => `Only this: ${run.output}`,
68
+ })
69
+
70
+ assert.equal(buildJudgePrompt(scorer.judge!, input()), 'Only this: Paris')
71
+ })
72
+ })
73
+
74
+ describe('runJudge', () => {
75
+ const helpfulness = pikkuAIJudge({
76
+ name: 'helpfulness',
77
+ description: 'Is the answer useful',
78
+ model: 'claude-opus-5',
79
+ goal: 'Grade helpfulness.',
80
+ })
81
+
82
+ test('records the score, the reason and what the judgement cost', async () => {
83
+ const result = await runJudge(
84
+ helpfulness,
85
+ input(),
86
+ runner({ score: 0.8, reason: 'Direct and correct.' })
87
+ )
88
+
89
+ assert.equal(result.score, 0.8)
90
+ assert.equal(result.reason, 'Direct and correct.')
91
+ assert.deepEqual(result.metadata, {
92
+ judgeModel: 'claude-opus-5',
93
+ judgeTokens: 120,
94
+ })
95
+ })
96
+
97
+ test('clamps an out-of-range score rather than losing the judgement', async () => {
98
+ const high = await runJudge(helpfulness, input(), runner({ score: 1.4 }))
99
+ const low = await runJudge(helpfulness, input(), runner({ score: -2 }))
100
+
101
+ assert.equal(high.score, 1)
102
+ assert.equal(low.score, 0)
103
+ })
104
+
105
+ test('rejects a non-numeric score, which is not a judgement at all', async () => {
106
+ await assert.rejects(
107
+ () => runJudge(helpfulness, input(), runner({ score: 'great' })),
108
+ /non-numeric score/
109
+ )
110
+ })
111
+
112
+ test('gives the judge no tools, so its structured output is actually honoured', async () => {
113
+ const seen = { params: undefined as any }
114
+ await runJudge(helpfulness, input(), runner({ score: 1 }, seen))
115
+
116
+ assert.deepEqual(seen.params.tools, [])
117
+ assert.equal(seen.params.toolChoice, 'none')
118
+ assert.equal(seen.params.maxSteps, 1)
119
+ assert.equal(seen.params.model, 'claude-opus-5')
120
+ })
121
+
122
+ test('names the missing provider rather than failing as an undefined call', async () => {
123
+ await assert.rejects(
124
+ () => runJudge(helpfulness, input(), undefined),
125
+ /needs an AI provider/
126
+ )
127
+ })
128
+
129
+ test('refuses to grade a reference-based judge with no answer key', async () => {
130
+ const correctness = pikkuAIJudge({
131
+ name: 'correctness',
132
+ description: 'Is the answer right',
133
+ model: 'claude-opus-5',
134
+ goal: 'Grade correctness.',
135
+ requiresReference: true,
136
+ })
137
+
138
+ await assert.rejects(
139
+ () => runJudge(correctness, input(), runner({ score: 1 })),
140
+ /grades against a reference answer/
141
+ )
142
+ })
143
+ })
@@ -0,0 +1,120 @@
1
+ import type { AIAgentRunnerService } from '../../services/ai-agent-runner-service.js'
2
+ import type { AIMessage } from '../ai-agent/ai-agent.types.js'
3
+ import { randomUUID } from '../ai-agent/ai-agent-utils.js'
4
+ import type {
5
+ PikkuAIScorer,
6
+ ScorerInput,
7
+ ScorerJudgeConfig,
8
+ ScorerOutput,
9
+ } from './ai-scorer.types.js'
10
+
11
+ const JUDGE_OUTPUT_SCHEMA = {
12
+ type: 'object',
13
+ properties: {
14
+ score: {
15
+ type: 'number',
16
+ minimum: 0,
17
+ maximum: 1,
18
+ description: 'How well the answer meets the rubric. 1 is best.',
19
+ },
20
+ reason: {
21
+ type: 'string',
22
+ description: 'One or two sentences explaining the score.',
23
+ },
24
+ },
25
+ required: ['score', 'reason'],
26
+ additionalProperties: false,
27
+ }
28
+
29
+ /**
30
+ * The message a judge grades when its scorer supplies no `prompt`.
31
+ *
32
+ * A reference-based judge is shown the answer key; a reference-free one is not,
33
+ * and grades the answer on its own merits.
34
+ */
35
+ export const buildJudgePrompt = (
36
+ judge: ScorerJudgeConfig,
37
+ input: ScorerInput
38
+ ): string => {
39
+ if (judge.prompt) return judge.prompt(input)
40
+
41
+ const sections = [`User asked:\n${input.input}`]
42
+ if (input.reference !== undefined) {
43
+ sections.push(`Reference answer:\n${input.reference}`)
44
+ }
45
+ sections.push(`Assistant answered:\n${input.output}`)
46
+ sections.push(judge.goal)
47
+ sections.push('Respond with a score 0..1 and a brief reason.')
48
+ return sections.join('\n\n')
49
+ }
50
+
51
+ /**
52
+ * Clamp rather than reject: a model that answers 1.2 has still made a legible
53
+ * judgement, and failing the job would lose it. A non-number has not.
54
+ */
55
+ const normalizeScore = (scorerName: string, value: unknown): number => {
56
+ if (typeof value !== 'number' || Number.isNaN(value)) {
57
+ throw new Error(
58
+ `Judge '${scorerName}' returned a non-numeric score: ${JSON.stringify(value)}`
59
+ )
60
+ }
61
+ return Math.min(1, Math.max(0, value))
62
+ }
63
+
64
+ /**
65
+ * Run an LLM judge and force the `{ score, reason }` contract, so a scorer
66
+ * author writes a rubric rather than a parser.
67
+ *
68
+ * A judge has no tools, which is the one case where an output schema is
69
+ * reliably honoured.
70
+ */
71
+ export const runJudge = async (
72
+ scorer: PikkuAIScorer<any>,
73
+ input: ScorerInput,
74
+ aiAgentRunner: AIAgentRunnerService | undefined
75
+ ): Promise<ScorerOutput> => {
76
+ const judge = scorer.judge
77
+ if (!judge) {
78
+ throw new Error(`Scorer '${scorer.name}' is not a judge`)
79
+ }
80
+ if (!aiAgentRunner) {
81
+ throw new Error(
82
+ `Judge '${scorer.name}' needs an AI provider, but no aiAgentRunner is registered in this process. ` +
83
+ `A worker deployed apart from the API has to register one to run the slow scoring lane.`
84
+ )
85
+ }
86
+ if (scorer.requiresReference && input.reference === undefined) {
87
+ throw new Error(
88
+ `Judge '${scorer.name}' grades against a reference answer, but none was supplied`
89
+ )
90
+ }
91
+
92
+ const messages: AIMessage[] = [
93
+ {
94
+ id: randomUUID(),
95
+ role: 'user',
96
+ content: buildJudgePrompt(judge, input),
97
+ createdAt: new Date(),
98
+ },
99
+ ]
100
+
101
+ const result = await aiAgentRunner.run({
102
+ model: judge.model,
103
+ instructions: judge.goal,
104
+ messages,
105
+ tools: [],
106
+ maxSteps: 1,
107
+ toolChoice: 'none',
108
+ outputSchema: JUDGE_OUTPUT_SCHEMA,
109
+ })
110
+
111
+ const object = (result.object ?? {}) as { score?: unknown; reason?: unknown }
112
+ return {
113
+ score: normalizeScore(scorer.name, object.score),
114
+ ...(typeof object.reason === 'string' ? { reason: object.reason } : {}),
115
+ metadata: {
116
+ judgeModel: judge.model,
117
+ judgeTokens: result.usage.inputTokens + result.usage.outputTokens,
118
+ },
119
+ }
120
+ }
@@ -0,0 +1,174 @@
1
+ import { beforeEach, describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { pikkuState, resetPikkuState } from '../../pikku-state.js'
4
+ import { scoreFinishedRun } from './ai-scorer-live.js'
5
+ import { pikkuAIJudge, pikkuAIScorer } from './ai-scorer.js'
6
+ import type { PikkuAIScorer, ScorerInput } from './ai-scorer.types.js'
7
+
8
+ const register = (agentName: string, scorers: PikkuAIScorer[]) => {
9
+ pikkuState(null, 'agent', 'agents').set(agentName, {
10
+ name: agentName,
11
+ scorers: scorers.map((scorer) => scorer.name),
12
+ } as never)
13
+ for (const scorer of scorers) {
14
+ pikkuState(null, 'agent', 'scorers').set(scorer.name, scorer)
15
+ }
16
+ }
17
+
18
+ const run = (overrides: Partial<ScorerInput> = {}): ScorerInput => ({
19
+ runId: 'run-1',
20
+ agentName: 'assistant',
21
+ threadId: 'thread-1',
22
+ input: 'what is the capital of France?',
23
+ output: 'Paris',
24
+ toolCalls: [],
25
+ usage: { inputTokens: 10, outputTokens: 5 },
26
+ ...overrides,
27
+ })
28
+
29
+ const collectingServices = () => {
30
+ const added: { queueName: string; data: any }[] = []
31
+ const warnings: string[] = []
32
+ const errors: string[] = []
33
+ return {
34
+ added,
35
+ warnings,
36
+ errors,
37
+ services: {
38
+ logger: {
39
+ warn: (message: string) => warnings.push(message),
40
+ error: (message: string) => errors.push(message),
41
+ },
42
+ queueService: {
43
+ add: async (queueName: string, data: unknown) => {
44
+ added.push({ queueName, data })
45
+ return undefined
46
+ },
47
+ },
48
+ } as any,
49
+ }
50
+ }
51
+
52
+ const alwaysOne = pikkuAIScorer({
53
+ name: 'brevity',
54
+ description: 'Shorter is better',
55
+ score: () => ({ score: 1 }),
56
+ })
57
+
58
+ describe('scoreFinishedRun', () => {
59
+ beforeEach(() => {
60
+ resetPikkuState()
61
+ })
62
+
63
+ test('sends one message per scorer, each to its own lane', async () => {
64
+ const judge = pikkuAIJudge({
65
+ name: 'helpfulness',
66
+ description: 'Is the answer useful',
67
+ model: 'claude-opus-5',
68
+ goal: 'Grade helpfulness.',
69
+ })
70
+ register('assistant', [alwaysOne, judge])
71
+ const { added, services } = collectingServices()
72
+
73
+ await scoreFinishedRun(run(), services)
74
+
75
+ assert.deepEqual(
76
+ added.map((entry) => [entry.queueName, entry.data.scorerName]),
77
+ [
78
+ ['ai-score-fast', 'brevity'],
79
+ ['ai-score-slow', 'helpfulness'],
80
+ ]
81
+ )
82
+ })
83
+
84
+ test('never grades a reference-based judge, because live traffic has no answer key', async () => {
85
+ const correctness = pikkuAIJudge({
86
+ name: 'correctness',
87
+ description: 'Is the answer right',
88
+ model: 'claude-opus-5',
89
+ goal: 'Grade correctness.',
90
+ requiresReference: true,
91
+ })
92
+ register('assistant', [correctness])
93
+ const { added, services } = collectingServices()
94
+
95
+ await scoreFinishedRun(run(), services)
96
+
97
+ assert.deepEqual(added, [])
98
+ })
99
+
100
+ test('honours the sample rate rather than grading every run', async () => {
101
+ const sampled = pikkuAIScorer({
102
+ name: 'brevity',
103
+ description: 'Shorter is better',
104
+ sampleRate: 0,
105
+ score: () => ({ score: 1 }),
106
+ })
107
+ register('assistant', [sampled])
108
+ const { added, services } = collectingServices()
109
+
110
+ await scoreFinishedRun(run(), services)
111
+
112
+ assert.deepEqual(added, [])
113
+ })
114
+
115
+ test('warns about a scorer the agent named but nothing registered, rather than failing the run', async () => {
116
+ pikkuState(null, 'agent', 'agents').set('assistant', {
117
+ name: 'assistant',
118
+ scorers: ['nonexistent'],
119
+ } as never)
120
+ const { added, warnings, services } = collectingServices()
121
+
122
+ await scoreFinishedRun(run(), services)
123
+
124
+ assert.deepEqual(added, [])
125
+ assert.equal(warnings.length, 1)
126
+ assert.match(warnings[0]!, /nonexistent/)
127
+ })
128
+
129
+ test('says why nothing was graded when there is no queue to grade on', async () => {
130
+ register('assistant', [alwaysOne])
131
+ const { warnings, services } = collectingServices()
132
+ delete services.queueService
133
+
134
+ await scoreFinishedRun(run(), services)
135
+
136
+ assert.equal(warnings.length, 1)
137
+ assert.match(warnings[0]!, /no queue service is registered/)
138
+ })
139
+
140
+ test('one scorer that cannot be enqueued does not stop the others', async () => {
141
+ const judge = pikkuAIJudge({
142
+ name: 'helpfulness',
143
+ description: 'Is the answer useful',
144
+ model: 'claude-opus-5',
145
+ goal: 'Grade helpfulness.',
146
+ })
147
+ register('assistant', [alwaysOne, judge])
148
+ const { added, errors, services } = collectingServices()
149
+ const add = services.queueService.add
150
+ services.queueService.add = async (queueName: string, data: any) => {
151
+ if (data.scorerName === 'brevity') throw new Error('queue is down')
152
+ return add(queueName, data)
153
+ }
154
+
155
+ await scoreFinishedRun(run(), services)
156
+
157
+ assert.deepEqual(
158
+ added.map((entry) => entry.data.scorerName),
159
+ ['helpfulness']
160
+ )
161
+ assert.equal(errors.length, 1)
162
+ })
163
+
164
+ test('an agent that names no scorers touches the queue at all', async () => {
165
+ pikkuState(null, 'agent', 'agents').set('assistant', {
166
+ name: 'assistant',
167
+ } as never)
168
+ const { added, services } = collectingServices()
169
+
170
+ await scoreFinishedRun(run(), services)
171
+
172
+ assert.deepEqual(added, [])
173
+ })
174
+ })
@@ -0,0 +1,56 @@
1
+ import type { CoreSingletonServices } from '../../types/core.types.js'
2
+ import { scorersForAgent } from './ai-scorer-registry.js'
3
+ import { isSampled } from './ai-scorer-sampling.js'
4
+ import { SCORER_LANE_QUEUES, type ScorerInput } from './ai-scorer.types.js'
5
+
6
+ /**
7
+ * Grade one finished run on live traffic.
8
+ *
9
+ * Called as the terminal step of `finalizeAgentRun`, so it is structurally last
10
+ * — a developer cannot register anything after it, and cannot reorder it. It is
11
+ * also strictly best-effort: the client already has its answer, so nothing here
12
+ * may fail the run.
13
+ */
14
+ export const scoreFinishedRun = async (
15
+ run: ScorerInput,
16
+ services: Pick<CoreSingletonServices, 'logger'> & {
17
+ queueService?: { add: (queueName: string, data: unknown) => Promise<any> }
18
+ }
19
+ ): Promise<void> => {
20
+ const scorers = scorersForAgent(run.agentName, services.logger).filter(
21
+ // A reference-based judge grades against an answer key, and live traffic
22
+ // has none.
23
+ (scorer) => !scorer.requiresReference
24
+ )
25
+ if (scorers.length === 0) return
26
+
27
+ if (!services.queueService) {
28
+ services.logger?.warn(
29
+ `[pikku] Agent '${run.agentName}' declares scorers but no queue service is registered — skipping live scoring`
30
+ )
31
+ return
32
+ }
33
+
34
+ const sampled = scorers.filter((scorer) =>
35
+ isSampled(run.runId, scorer.name, scorer.sampleRate)
36
+ )
37
+
38
+ // One message per scorer, so each gets its own retry, isolation and lane.
39
+ await Promise.all(
40
+ sampled.map(async (scorer) => {
41
+ try {
42
+ await services.queueService!.add(SCORER_LANE_QUEUES[scorer.lane], {
43
+ ...run,
44
+ scorerName: scorer.name,
45
+ // No reference: a live run has no answer key.
46
+ reference: undefined,
47
+ })
48
+ } catch (error) {
49
+ services.logger?.error(
50
+ `[pikku] Failed to enqueue the '${scorer.name}' grade of run ${run.runId}`,
51
+ { error }
52
+ )
53
+ }
54
+ })
55
+ )
56
+ }
@@ -0,0 +1,63 @@
1
+ import { pikkuState } from '../../pikku-state.js'
2
+ import type { PikkuAIScorer } from './ai-scorer.types.js'
3
+
4
+ export const addAIScorer = (
5
+ scorerName: string,
6
+ scorer: PikkuAIScorer<any>,
7
+ packageName: string | null = null
8
+ ) => {
9
+ const scorersMeta = pikkuState(packageName, 'agent', 'scorersMeta')
10
+ if (!scorersMeta[scorerName]) {
11
+ console.warn(
12
+ `[pikku] Skipping AI scorer '${scorerName}' — metadata not found. Scorers must be declared in a *.scorer.ts file.`
13
+ )
14
+ return
15
+ }
16
+ const scorers = pikkuState(packageName, 'agent', 'scorers')
17
+ if (scorers.has(scorerName)) {
18
+ throw new Error(`AI scorer already exists: ${scorerName}`)
19
+ }
20
+ scorers.set(scorerName, scorer)
21
+ }
22
+
23
+ export const getAIScorers = () => pikkuState(null, 'agent', 'scorers')
24
+
25
+ export const getAIScorersMeta = () => pikkuState(null, 'agent', 'scorersMeta')
26
+
27
+ /**
28
+ * Resolve a scorer by name across every registered package, the way an agent's
29
+ * tools are resolved: a scorer declared in an addon is nameable by an app agent.
30
+ */
31
+ export const resolveAIScorer = (scorerName: string): PikkuAIScorer<any> => {
32
+ const scorer = pikkuState(null, 'agent', 'scorers').get(scorerName)
33
+ if (!scorer) {
34
+ throw new Error(`AI scorer not found: ${scorerName}`)
35
+ }
36
+ return scorer
37
+ }
38
+
39
+ /**
40
+ * The scorers an agent asked to be graded by.
41
+ *
42
+ * A name that resolves to nothing is warned about rather than thrown: a missing
43
+ * scorer must not take down a run that has already answered the user.
44
+ */
45
+ export const scorersForAgent = (
46
+ agentName: string,
47
+ logger?: { warn: (message: string) => void }
48
+ ): PikkuAIScorer<any>[] => {
49
+ const agent = pikkuState(null, 'agent', 'agents').get(agentName)
50
+ const names = agent?.scorers ?? []
51
+ const scorers: PikkuAIScorer<any>[] = []
52
+ for (const name of names) {
53
+ const scorer = pikkuState(null, 'agent', 'scorers').get(name)
54
+ if (!scorer) {
55
+ logger?.warn(
56
+ `[pikku] Agent '${agentName}' names scorer '${name}', which is not registered — skipping it`
57
+ )
58
+ continue
59
+ }
60
+ scorers.push(scorer)
61
+ }
62
+ return scorers
63
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { isSampled } from './ai-scorer-sampling.js'
4
+
5
+ describe('isSampled', () => {
6
+ test('grades every run at a rate of 1 and none at 0', () => {
7
+ assert.equal(isSampled('run-1', 'relevance', 1), true)
8
+ assert.equal(isSampled('run-1', 'relevance', 0), false)
9
+ })
10
+
11
+ test('lands the same way every time it is asked, so a retried job is not regraded by chance', () => {
12
+ const first = isSampled('run-42', 'relevance', 0.5)
13
+ for (let i = 0; i < 20; i++) {
14
+ assert.equal(isSampled('run-42', 'relevance', 0.5), first)
15
+ }
16
+ })
17
+
18
+ test('two scorers at the same rate do not sample the same set of runs', () => {
19
+ const runs = Array.from({ length: 200 }, (_, i) => `run-${i}`)
20
+ const a = runs.filter((run) => isSampled(run, 'relevance', 0.5))
21
+ const b = runs.filter((run) => isSampled(run, 'toxicity', 0.5))
22
+ assert.notDeepEqual(a, b)
23
+ })
24
+
25
+ test('samples roughly the requested fraction over many runs', () => {
26
+ const runs = Array.from({ length: 2000 }, (_, i) => `run-${i}`)
27
+ const sampled = runs.filter((run) => isSampled(run, 'relevance', 0.25))
28
+ const rate = sampled.length / runs.length
29
+ assert.ok(
30
+ rate > 0.2 && rate < 0.3,
31
+ `expected roughly 0.25 of runs to be sampled, got ${rate}`
32
+ )
33
+ })
34
+ })
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Decide whether one run is in a scorer's sample.
3
+ *
4
+ * Deterministic on `(runId, scorerName)` rather than random, so re-running the
5
+ * decision — a retried job, a replayed run, a test — always lands the same way,
6
+ * and so two scorers at the same rate do not sample the same runs.
7
+ */
8
+ export const isSampled = (
9
+ runId: string,
10
+ scorerName: string,
11
+ sampleRate: number
12
+ ): boolean => {
13
+ if (sampleRate >= 1) return true
14
+ if (sampleRate <= 0) return false
15
+
16
+ const key = `${scorerName}:${runId}`
17
+ // FNV-1a: no crypto dependency, and stable across processes and platforms,
18
+ // which a language-level string hash is not guaranteed to be.
19
+ let hash = 0x811c9dc5
20
+ for (let i = 0; i < key.length; i++) {
21
+ hash ^= key.charCodeAt(i)
22
+ hash = Math.imul(hash, 0x01000193) >>> 0
23
+ }
24
+
25
+ // FNV-1a alone leaves its high bits barely moved between keys that differ
26
+ // only in their last characters — sequential run ids being exactly that —
27
+ // which biases the fraction actually sampled well away from the rate asked
28
+ // for. MurmurHash3's finalizer spreads the low bits back over the whole word.
29
+ hash ^= hash >>> 16
30
+ hash = Math.imul(hash, 0x85ebca6b) >>> 0
31
+ hash ^= hash >>> 13
32
+ hash = Math.imul(hash, 0xc2b2ae35) >>> 0
33
+ hash ^= hash >>> 16
34
+
35
+ return (hash >>> 0) / 0x100000000 < sampleRate
36
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, test, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import {
5
+ enableScoreSnapshots,
6
+ getScoreSnapshot,
7
+ recordScoreSnapshot,
8
+ resetScoreSnapshots,
9
+ } from './ai-scorer-snapshots.js'
10
+ import type { ScorerInput } from './ai-scorer.types.js'
11
+
12
+ const run = (runId: string): ScorerInput => ({
13
+ runId,
14
+ agentName: 'assistant',
15
+ input: 'Where is Paris?',
16
+ output: 'France.',
17
+ toolCalls: [],
18
+ usage: { inputTokens: 5, outputTokens: 2 },
19
+ })
20
+
21
+ describe('score snapshots', () => {
22
+ beforeEach(() => resetScoreSnapshots())
23
+
24
+ test('a process that was never asked to retain runs holds none', () => {
25
+ recordScoreSnapshot(run('run-1'))
26
+
27
+ assert.equal(getScoreSnapshot('run-1'), undefined)
28
+ })
29
+
30
+ test('a retained run is handed back whole, so a grade sees what live scoring saw', () => {
31
+ enableScoreSnapshots()
32
+
33
+ recordScoreSnapshot(run('run-1'))
34
+
35
+ assert.deepEqual(getScoreSnapshot('run-1'), run('run-1'))
36
+ })
37
+
38
+ test('the buffer is bounded, so a long-lived dev server cannot accumulate run content', () => {
39
+ enableScoreSnapshots(2)
40
+
41
+ recordScoreSnapshot(run('run-1'))
42
+ recordScoreSnapshot(run('run-2'))
43
+ recordScoreSnapshot(run('run-3'))
44
+
45
+ assert.equal(getScoreSnapshot('run-1'), undefined)
46
+ assert.equal(getScoreSnapshot('run-2')?.runId, 'run-2')
47
+ assert.equal(getScoreSnapshot('run-3')?.runId, 'run-3')
48
+ })
49
+ })