@kindgi/api 0.1.2 → 0.1.4-rc.0

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 (250) hide show
  1. package/dist/agent-binding.d.ts +18 -4
  2. package/dist/agent-binding.d.ts.map +1 -1
  3. package/dist/agent-pins.d.ts +48 -0
  4. package/dist/agent-pins.d.ts.map +1 -0
  5. package/dist/agent-pins.js +102 -0
  6. package/dist/agent-pins.js.map +1 -0
  7. package/dist/app.d.ts +22 -0
  8. package/dist/app.d.ts.map +1 -1
  9. package/dist/app.js +22 -3
  10. package/dist/app.js.map +1 -1
  11. package/dist/block-binding.d.ts +132 -0
  12. package/dist/block-binding.d.ts.map +1 -0
  13. package/dist/block-binding.js +4 -0
  14. package/dist/block-binding.js.map +1 -0
  15. package/dist/block-pins.d.ts +22 -0
  16. package/dist/block-pins.d.ts.map +1 -0
  17. package/dist/block-pins.js +112 -0
  18. package/dist/block-pins.js.map +1 -0
  19. package/dist/cost-binding.d.ts +100 -5
  20. package/dist/cost-binding.d.ts.map +1 -1
  21. package/dist/cost-binding.js +10 -0
  22. package/dist/cost-binding.js.map +1 -1
  23. package/dist/deploy-versions.d.ts +58 -0
  24. package/dist/deploy-versions.d.ts.map +1 -0
  25. package/dist/deploy-versions.js +91 -0
  26. package/dist/deploy-versions.js.map +1 -0
  27. package/dist/deployment-binding.d.ts +24 -3
  28. package/dist/deployment-binding.d.ts.map +1 -1
  29. package/dist/derive-agent-version.d.ts +69 -0
  30. package/dist/derive-agent-version.d.ts.map +1 -0
  31. package/dist/derive-agent-version.js +139 -0
  32. package/dist/derive-agent-version.js.map +1 -0
  33. package/dist/errors.d.ts.map +1 -1
  34. package/dist/errors.js +17 -0
  35. package/dist/errors.js.map +1 -1
  36. package/dist/eval-case-binding.d.ts +65 -0
  37. package/dist/eval-case-binding.d.ts.map +1 -0
  38. package/dist/eval-case-binding.js +4 -0
  39. package/dist/eval-case-binding.js.map +1 -0
  40. package/dist/eval-run-binding.d.ts +30 -0
  41. package/dist/eval-run-binding.d.ts.map +1 -1
  42. package/dist/eval-run-dispatcher.d.ts +38 -3
  43. package/dist/eval-run-dispatcher.d.ts.map +1 -1
  44. package/dist/eval-run-dispatcher.js +21 -15
  45. package/dist/eval-run-dispatcher.js.map +1 -1
  46. package/dist/eval-suite-binding.d.ts +1 -1
  47. package/dist/eval-suite-binding.d.ts.map +1 -1
  48. package/dist/eval-suite-binding.js +2 -0
  49. package/dist/eval-suite-binding.js.map +1 -1
  50. package/dist/flow-binding.d.ts +10 -4
  51. package/dist/flow-binding.d.ts.map +1 -1
  52. package/dist/flow-pins.d.ts +36 -0
  53. package/dist/flow-pins.d.ts.map +1 -0
  54. package/dist/flow-pins.js +81 -0
  55. package/dist/flow-pins.js.map +1 -0
  56. package/dist/hitl-binding.d.ts +20 -5
  57. package/dist/hitl-binding.d.ts.map +1 -1
  58. package/dist/index.d.ts +19 -8
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +8 -2
  61. package/dist/index.js.map +1 -1
  62. package/dist/judged-dispatcher.d.ts +134 -0
  63. package/dist/judged-dispatcher.d.ts.map +1 -0
  64. package/dist/judged-dispatcher.js +297 -0
  65. package/dist/judged-dispatcher.js.map +1 -0
  66. package/dist/judged-items.d.ts +86 -0
  67. package/dist/judged-items.d.ts.map +1 -0
  68. package/dist/judged-items.js +184 -0
  69. package/dist/judged-items.js.map +1 -0
  70. package/dist/judgment-binding.d.ts +316 -0
  71. package/dist/judgment-binding.d.ts.map +1 -0
  72. package/dist/judgment-binding.js +19 -0
  73. package/dist/judgment-binding.js.map +1 -0
  74. package/dist/middleware/auth.d.ts +3 -2
  75. package/dist/middleware/auth.d.ts.map +1 -1
  76. package/dist/middleware/auth.js.map +1 -1
  77. package/dist/middleware/idempotency.d.ts +5 -1
  78. package/dist/middleware/idempotency.d.ts.map +1 -1
  79. package/dist/middleware/idempotency.js +8 -1
  80. package/dist/middleware/idempotency.js.map +1 -1
  81. package/dist/openapi/generate.d.ts.map +1 -1
  82. package/dist/openapi/generate.js +4 -1
  83. package/dist/openapi/generate.js.map +1 -1
  84. package/dist/openapi/operations.d.ts.map +1 -1
  85. package/dist/openapi/operations.js +543 -24
  86. package/dist/openapi/operations.js.map +1 -1
  87. package/dist/openapi/schemas.d.ts +58 -0
  88. package/dist/openapi/schemas.d.ts.map +1 -1
  89. package/dist/openapi/schemas.js +1058 -27
  90. package/dist/openapi/schemas.js.map +1 -1
  91. package/dist/provenance-binding.d.ts +27 -1
  92. package/dist/provenance-binding.d.ts.map +1 -1
  93. package/dist/provenance-binding.js.map +1 -1
  94. package/dist/provider-binding.d.ts +12 -7
  95. package/dist/provider-binding.d.ts.map +1 -1
  96. package/dist/reviewer-binding.d.ts +10 -2
  97. package/dist/reviewer-binding.d.ts.map +1 -1
  98. package/dist/reviewer-role.d.ts +13 -0
  99. package/dist/reviewer-role.d.ts.map +1 -0
  100. package/dist/reviewer-role.js +27 -0
  101. package/dist/reviewer-role.js.map +1 -0
  102. package/dist/routes/agents.d.ts +9 -1
  103. package/dist/routes/agents.d.ts.map +1 -1
  104. package/dist/routes/agents.js +175 -11
  105. package/dist/routes/agents.js.map +1 -1
  106. package/dist/routes/approvals.d.ts +5 -4
  107. package/dist/routes/approvals.d.ts.map +1 -1
  108. package/dist/routes/approvals.js +52 -16
  109. package/dist/routes/approvals.js.map +1 -1
  110. package/dist/routes/blocks.d.ts +19 -0
  111. package/dist/routes/blocks.d.ts.map +1 -0
  112. package/dist/routes/blocks.js +281 -0
  113. package/dist/routes/blocks.js.map +1 -0
  114. package/dist/routes/conversations.d.ts +7 -1
  115. package/dist/routes/conversations.d.ts.map +1 -1
  116. package/dist/routes/conversations.js +41 -1
  117. package/dist/routes/conversations.js.map +1 -1
  118. package/dist/routes/cost.d.ts.map +1 -1
  119. package/dist/routes/cost.js +142 -11
  120. package/dist/routes/cost.js.map +1 -1
  121. package/dist/routes/deployments.d.ts +3 -0
  122. package/dist/routes/deployments.d.ts.map +1 -1
  123. package/dist/routes/deployments.js +208 -55
  124. package/dist/routes/deployments.js.map +1 -1
  125. package/dist/routes/eval-comparison.d.ts +14 -0
  126. package/dist/routes/eval-comparison.d.ts.map +1 -0
  127. package/dist/routes/eval-comparison.js +87 -0
  128. package/dist/routes/eval-comparison.js.map +1 -0
  129. package/dist/routes/eval-runs.d.ts.map +1 -1
  130. package/dist/routes/eval-runs.js +8 -0
  131. package/dist/routes/eval-runs.js.map +1 -1
  132. package/dist/routes/flows.d.ts +13 -1
  133. package/dist/routes/flows.d.ts.map +1 -1
  134. package/dist/routes/flows.js +44 -3
  135. package/dist/routes/flows.js.map +1 -1
  136. package/dist/routes/hierarchy-errors.d.ts +35 -0
  137. package/dist/routes/hierarchy-errors.d.ts.map +1 -0
  138. package/dist/routes/hierarchy-errors.js +39 -0
  139. package/dist/routes/hierarchy-errors.js.map +1 -0
  140. package/dist/routes/identity.d.ts +7 -0
  141. package/dist/routes/identity.d.ts.map +1 -1
  142. package/dist/routes/identity.js +3 -2
  143. package/dist/routes/identity.js.map +1 -1
  144. package/dist/routes/judged-suites.d.ts +20 -0
  145. package/dist/routes/judged-suites.d.ts.map +1 -0
  146. package/dist/routes/judged-suites.js +272 -0
  147. package/dist/routes/judged-suites.js.map +1 -0
  148. package/dist/routes/judgment-context.d.ts +22 -0
  149. package/dist/routes/judgment-context.d.ts.map +1 -0
  150. package/dist/routes/judgment-context.js +88 -0
  151. package/dist/routes/judgment-context.js.map +1 -0
  152. package/dist/routes/judgment-flow-context.d.ts +32 -0
  153. package/dist/routes/judgment-flow-context.d.ts.map +1 -0
  154. package/dist/routes/judgment-flow-context.js +195 -0
  155. package/dist/routes/judgment-flow-context.js.map +1 -0
  156. package/dist/routes/judgments.d.ts +41 -0
  157. package/dist/routes/judgments.d.ts.map +1 -0
  158. package/dist/routes/judgments.js +566 -0
  159. package/dist/routes/judgments.js.map +1 -0
  160. package/dist/routes/orgs.d.ts +5 -2
  161. package/dist/routes/orgs.d.ts.map +1 -1
  162. package/dist/routes/orgs.js +38 -22
  163. package/dist/routes/orgs.js.map +1 -1
  164. package/dist/routes/policies.d.ts.map +1 -1
  165. package/dist/routes/policies.js +12 -1
  166. package/dist/routes/policies.js.map +1 -1
  167. package/dist/routes/projects.d.ts +10 -2
  168. package/dist/routes/projects.d.ts.map +1 -1
  169. package/dist/routes/projects.js +87 -79
  170. package/dist/routes/projects.js.map +1 -1
  171. package/dist/routes/provenance.d.ts.map +1 -1
  172. package/dist/routes/provenance.js +32 -1
  173. package/dist/routes/provenance.js.map +1 -1
  174. package/dist/routes/providers.d.ts.map +1 -1
  175. package/dist/routes/providers.js +6 -1
  176. package/dist/routes/providers.js.map +1 -1
  177. package/dist/routes/runs.d.ts +0 -7
  178. package/dist/routes/runs.d.ts.map +1 -1
  179. package/dist/routes/runs.js +65 -20
  180. package/dist/routes/runs.js.map +1 -1
  181. package/dist/routes/scope-params.d.ts +16 -1
  182. package/dist/routes/scope-params.d.ts.map +1 -1
  183. package/dist/routes/scope-params.js +28 -0
  184. package/dist/routes/scope-params.js.map +1 -1
  185. package/dist/routes/teams.d.ts +6 -2
  186. package/dist/routes/teams.d.ts.map +1 -1
  187. package/dist/routes/teams.js +77 -73
  188. package/dist/routes/teams.js.map +1 -1
  189. package/dist/types.d.ts +4 -3
  190. package/dist/types.d.ts.map +1 -1
  191. package/dist/webhook-endpoint-binding.d.ts +11 -0
  192. package/dist/webhook-endpoint-binding.d.ts.map +1 -1
  193. package/dist/webhook-endpoint-binding.js.map +1 -1
  194. package/openapi.json +13316 -9299
  195. package/package.json +21 -21
  196. package/src/agent-binding.ts +19 -4
  197. package/src/agent-pins.ts +147 -0
  198. package/src/app.ts +76 -3
  199. package/src/block-binding.ts +137 -0
  200. package/src/block-pins.ts +148 -0
  201. package/src/cost-binding.ts +116 -5
  202. package/src/deploy-versions.ts +157 -0
  203. package/src/deployment-binding.ts +27 -3
  204. package/src/derive-agent-version.ts +206 -0
  205. package/src/errors.ts +17 -0
  206. package/src/eval-case-binding.ts +71 -0
  207. package/src/eval-run-binding.ts +33 -0
  208. package/src/eval-run-dispatcher.ts +57 -16
  209. package/src/eval-suite-binding.ts +2 -0
  210. package/src/flow-binding.ts +11 -4
  211. package/src/flow-pins.ts +113 -0
  212. package/src/hitl-binding.ts +20 -4
  213. package/src/index.ts +88 -2
  214. package/src/judged-dispatcher.ts +507 -0
  215. package/src/judged-items.ts +263 -0
  216. package/src/judgment-binding.ts +349 -0
  217. package/src/middleware/auth.ts +3 -2
  218. package/src/middleware/idempotency.ts +7 -1
  219. package/src/openapi/generate.ts +7 -1
  220. package/src/openapi/operations.ts +615 -24
  221. package/src/openapi/schemas.ts +1157 -22
  222. package/src/provenance-binding.ts +42 -1
  223. package/src/provider-binding.ts +12 -7
  224. package/src/reviewer-binding.ts +10 -2
  225. package/src/reviewer-role.ts +35 -0
  226. package/src/routes/agents.ts +243 -19
  227. package/src/routes/approvals.ts +70 -19
  228. package/src/routes/blocks.ts +362 -0
  229. package/src/routes/conversations.ts +57 -1
  230. package/src/routes/cost.ts +159 -21
  231. package/src/routes/deployments.ts +266 -56
  232. package/src/routes/eval-comparison.ts +101 -0
  233. package/src/routes/eval-runs.ts +11 -0
  234. package/src/routes/flows.ts +63 -5
  235. package/src/routes/hierarchy-errors.ts +51 -0
  236. package/src/routes/identity.ts +10 -2
  237. package/src/routes/judged-suites.ts +363 -0
  238. package/src/routes/judgment-context.ts +128 -0
  239. package/src/routes/judgment-flow-context.ts +245 -0
  240. package/src/routes/judgments.ts +743 -0
  241. package/src/routes/orgs.ts +44 -27
  242. package/src/routes/policies.ts +19 -0
  243. package/src/routes/projects.ts +106 -95
  244. package/src/routes/provenance.ts +48 -1
  245. package/src/routes/providers.ts +5 -0
  246. package/src/routes/runs.ts +79 -22
  247. package/src/routes/scope-params.ts +35 -1
  248. package/src/routes/teams.ts +96 -90
  249. package/src/types.ts +4 -3
  250. package/src/webhook-endpoint-binding.ts +11 -0
@@ -0,0 +1,363 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import { type Context, Hono } from 'hono';
5
+
6
+ import { type Principal, ref, tuplesForCreate } from '@kindgi/authz';
7
+ import type { Cursor, ProjectId, TenantId, UserId } from '@kindgi/types';
8
+
9
+ import { statusFor, toWireError } from '../errors.js';
10
+ import type {
11
+ EvalCaseStoreBinding,
12
+ JudgedEvalCase,
13
+ JudgedItemSummary,
14
+ } from '../eval-case-binding.js';
15
+ import type { EvalSuiteRegistryBinding } from '../eval-suite-binding.js';
16
+ import type {
17
+ JudgedRunListInput,
18
+ JudgedRunWithJudgments,
19
+ Judgment,
20
+ JudgmentRegistryBinding,
21
+ } from '../judgment-binding.js';
22
+ import type { Authorizer } from '../middleware/authorize.js';
23
+ import type { AppEnv } from '../types.js';
24
+ import { clampLimit } from './pagination.js';
25
+
26
+ /** The most cases a test set built from judgments holds. */
27
+ export const MAX_JUDGED_CASES = 1000;
28
+ const PAGE = 100;
29
+ const SEMVER_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
30
+
31
+ /**
32
+ * Test sets built from judgments: `judged` eval suites.
33
+ *
34
+ * - `POST /v1/eval-suites/:suiteId/versions/from-judgments` builds and
35
+ * publishes a version whose cases are copies of judged runs (input,
36
+ * what the turn read, the judged output) with each item's judgments
37
+ * summed up, weighted by judge class (an unclassified judgment counts
38
+ * 1). Needs `admin` on the project, as publishing a suite does.
39
+ * - `GET /v1/eval-suites/:suiteId/versions/:version/cases` lists them.
40
+ */
41
+ export function judgedSuitesRouter(
42
+ suites: EvalSuiteRegistryBinding,
43
+ judgments: JudgmentRegistryBinding,
44
+ cases: EvalCaseStoreBinding,
45
+ authorizer?: Authorizer,
46
+ ): Hono<AppEnv> {
47
+ const r = new Hono<AppEnv>();
48
+
49
+ r.post('/:suiteId/versions/from-judgments', async (c) => {
50
+ const tenantId = c.get('tenantId') as TenantId;
51
+ const suiteId = c.req.param('suiteId');
52
+ const parsed = parseBuildBody(await c.req.json().catch(() => null));
53
+ if (parsed.kind === 'err') return fail(c, 'bad-input', parsed.message);
54
+ const body = parsed.body;
55
+ if (
56
+ authorizer !== undefined &&
57
+ !(await authorizer.can(c, 'admin', ref('project', body.projectId)))
58
+ ) {
59
+ return fail(c, 'permission-denied', 'Building a test set needs admin on the project.');
60
+ }
61
+ if (judgments.listJudgedRuns === undefined) {
62
+ return fail(
63
+ c,
64
+ 'test-sets-not-supported',
65
+ 'This deployment cannot build test sets from judgments.',
66
+ );
67
+ }
68
+
69
+ const built = await buildCases(judgments, tenantId, body);
70
+ const principal = c.get('principal') as Principal | undefined;
71
+ const creatorUserId =
72
+ principal?.actor.kind === 'user' ? (principal.actor.id as UserId) : undefined;
73
+ const outcome = await suites.publish({
74
+ tenantId,
75
+ projectId: body.projectId,
76
+ suite: {
77
+ id: suiteId,
78
+ tenantId,
79
+ version: body.version,
80
+ kind: 'judged',
81
+ ...(body.description !== undefined && { description: body.description }),
82
+ spec: {
83
+ source: 'judgments',
84
+ // Where the judgments came from: a comparison's summary names it.
85
+ projectId: body.projectId,
86
+ query: body.query,
87
+ caseCount: built.cases.length,
88
+ truncated: built.truncated,
89
+ builtAt: new Date().toISOString(),
90
+ },
91
+ },
92
+ enqueueTuples: (id) =>
93
+ tuplesForCreate(
94
+ { kind: 'eval_suite', id, tenantId, projectId: body.projectId },
95
+ creatorUserId,
96
+ ),
97
+ });
98
+ if (outcome.kind === 'already-registered') {
99
+ return fail(
100
+ c,
101
+ 'eval-suite-already-registered',
102
+ `Eval suite "${suiteId}" version "${body.version}" is already registered.`,
103
+ );
104
+ }
105
+ if (outcome.kind === 'project-not-found') {
106
+ return fail(c, 'bad-input', `\`projectId\` "${body.projectId}" is not a project here.`);
107
+ }
108
+ await cases.putCases({ tenantId, suiteId, version: body.version, cases: built.cases });
109
+ c.status(201);
110
+ return c.json({
111
+ suiteId,
112
+ version: body.version,
113
+ kind: 'judged',
114
+ caseCount: built.cases.length,
115
+ truncated: built.truncated,
116
+ });
117
+ });
118
+
119
+ r.get('/:suiteId/versions/:version/cases', async (c) => {
120
+ const tenantId = c.get('tenantId') as TenantId;
121
+ const suiteId = c.req.param('suiteId');
122
+ const version = c.req.param('version');
123
+ if (
124
+ authorizer !== undefined &&
125
+ !(await authorizer.can(c, 'read', ref('eval_suite', suiteId)))
126
+ ) {
127
+ return fail(c, 'eval-suite-not-found', `No eval suite "${suiteId}".`);
128
+ }
129
+ const cursor = c.req.query('cursor');
130
+ const page = await cases.listCases({
131
+ tenantId,
132
+ suiteId,
133
+ version,
134
+ limit: clampLimit(c.req.query('limit'), 25, 100),
135
+ ...(cursor !== undefined && cursor.length > 0 && { cursor: cursor as Cursor }),
136
+ });
137
+ return c.json({
138
+ data: page.data,
139
+ hasMore: page.hasMore,
140
+ ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor as unknown as string }),
141
+ });
142
+ });
143
+
144
+ return r;
145
+ }
146
+
147
+ function fail(c: Context<AppEnv>, code: string, message: string): Response {
148
+ c.status(statusFor(code) as never);
149
+ return c.json(toWireError({ code, message }, c.get('requestId')));
150
+ }
151
+
152
+ // ---------- building ----------
153
+
154
+ interface BuildBody {
155
+ readonly version: string;
156
+ readonly projectId: ProjectId;
157
+ readonly description?: string;
158
+ /** The judged-run filter, as recorded in the suite's spec. */
159
+ readonly query: {
160
+ readonly agentId?: string;
161
+ readonly agentVersion?: string;
162
+ readonly flowId?: string;
163
+ readonly since?: string;
164
+ readonly until?: string;
165
+ readonly judgeClassIds?: readonly string[];
166
+ readonly minJudgments?: number;
167
+ };
168
+ }
169
+
170
+ /** Each class's weight, read once (an unclassified judgment counts 1; a missing class too). */
171
+ function classWeights(
172
+ judgments: JudgmentRegistryBinding,
173
+ tenantId: TenantId,
174
+ ): (judgeClassId: string | undefined) => Promise<number> {
175
+ const weights = new Map<string, number>();
176
+ return async (judgeClassId) => {
177
+ if (judgeClassId === undefined) return 1;
178
+ const known = weights.get(judgeClassId);
179
+ if (known !== undefined) return known;
180
+ const k = await judgments.getClass({ tenantId, judgeClassId, includeUnregistered: true });
181
+ const w = k?.weight ?? 1;
182
+ weights.set(judgeClassId, w);
183
+ return w;
184
+ };
185
+ }
186
+
187
+ function judgedRunFilter(
188
+ tenantId: TenantId,
189
+ body: BuildBody,
190
+ ): Omit<JudgedRunListInput, 'limit' | 'cursor'> {
191
+ const { judgeClassIds: _ids, minJudgments: _min, ...runFilter } = body.query;
192
+ return { tenantId, projectId: body.projectId, ...runFilter };
193
+ }
194
+
195
+ /** Pages judged runs (newest first, up to MAX_JUDGED_CASES) and turns each into a case. */
196
+ async function buildCases(
197
+ judgments: JudgmentRegistryBinding,
198
+ tenantId: TenantId,
199
+ body: BuildBody,
200
+ ): Promise<{ readonly cases: JudgedEvalCase[]; readonly truncated: boolean }> {
201
+ const list = judgments.listJudgedRuns as NonNullable<JudgmentRegistryBinding['listJudgedRuns']>;
202
+ const weightOf = classWeights(judgments, tenantId);
203
+ const filter = judgedRunFilter(tenantId, body);
204
+ const cases: JudgedEvalCase[] = [];
205
+ let cursor: Cursor | undefined;
206
+ for (;;) {
207
+ const page = await list({ ...filter, limit: PAGE, ...(cursor !== undefined && { cursor }) });
208
+ for (const judged of page.data) {
209
+ const built = await toCase(judged, body.query, weightOf);
210
+ if (built !== undefined) cases.push(built);
211
+ if (cases.length >= MAX_JUDGED_CASES) return { cases, truncated: true };
212
+ }
213
+ if (!page.hasMore || page.nextCursor === undefined) return { cases, truncated: false };
214
+ cursor = page.nextCursor;
215
+ }
216
+ }
217
+
218
+ async function toCase(
219
+ judged: JudgedRunWithJudgments,
220
+ q: BuildBody['query'],
221
+ weightOf: (judgeClassId: string | undefined) => Promise<number>,
222
+ ): Promise<JudgedEvalCase | undefined> {
223
+ const kept =
224
+ q.judgeClassIds === undefined
225
+ ? judged.judgments
226
+ : judged.judgments.filter(
227
+ (j) => j.judgeClassId !== undefined && q.judgeClassIds?.includes(j.judgeClassId),
228
+ );
229
+ if (kept.length === 0 || kept.length < (q.minJudgments ?? 1)) return undefined;
230
+ const byKey = new Map<string, Judgment[]>();
231
+ for (const j of kept) byKey.set(j.item.key, [...(byKey.get(j.item.key) ?? []), j]);
232
+ const items: JudgedItemSummary[] = [];
233
+ for (const [key, list] of byKey) items.push(await summarize(key, list, weightOf));
234
+ items.sort((a, b) => (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER));
235
+ const run = judged.run;
236
+ return {
237
+ caseId: run.runId,
238
+ subject: run.subject,
239
+ input: run.input,
240
+ ...(run.context !== undefined && { context: run.context }),
241
+ output: run.output,
242
+ items,
243
+ };
244
+ }
245
+
246
+ async function summarize(
247
+ key: string,
248
+ judgments: readonly Judgment[],
249
+ weightOf: (judgeClassId: string | undefined) => Promise<number>,
250
+ ): Promise<JudgedItemSummary> {
251
+ let yes = 0;
252
+ let no = 0;
253
+ let yesWeight = 0;
254
+ let totalWeight = 0;
255
+ for (const j of judgments) {
256
+ const w = await weightOf(j.judgeClassId);
257
+ totalWeight += w;
258
+ if (j.verdict === 'yes') {
259
+ yes += 1;
260
+ yesWeight += w;
261
+ } else {
262
+ no += 1;
263
+ }
264
+ }
265
+ const first = judgments[0];
266
+ return {
267
+ key,
268
+ ...(first?.item.pointer !== undefined && { pointer: first.item.pointer }),
269
+ ...(first?.item.rank !== undefined && { rank: first.item.rank }),
270
+ yes,
271
+ no,
272
+ yesWeight,
273
+ totalWeight,
274
+ reasons: judgments.flatMap((j) =>
275
+ j.reason !== undefined ? [{ verdict: j.verdict, reason: j.reason }] : [],
276
+ ),
277
+ };
278
+ }
279
+
280
+ // ---------- parsing ----------
281
+
282
+ type Parsed<T> =
283
+ | { readonly kind: 'ok'; readonly body: T }
284
+ | { readonly kind: 'err'; readonly message: string };
285
+
286
+ const STRING_FILTERS = ['agentId', 'agentVersion', 'flowId', 'since', 'until'] as const;
287
+
288
+ /** The string filters (agent, version, flow, time window); a message when one is malformed. */
289
+ function parseStringFilters(b: Record<string, unknown>): Record<string, string> | string {
290
+ const query: Record<string, string> = {};
291
+ for (const field of STRING_FILTERS) {
292
+ const v = b[field];
293
+ if (v === undefined) continue;
294
+ if (typeof v !== 'string' || v.length === 0) return `${field} must be a non-empty string.`;
295
+ if ((field === 'since' || field === 'until') && Number.isNaN(Date.parse(v))) {
296
+ return `${field} must be an ISO 8601 time.`;
297
+ }
298
+ query[field] = v;
299
+ }
300
+ if (query.agentVersion !== undefined && query.agentId === undefined) {
301
+ return 'agentVersion needs agentId.';
302
+ }
303
+ return query;
304
+ }
305
+
306
+ /** Which judgments count: by class, and how many a run needs. */
307
+ function parseJudgmentFilters(
308
+ b: Record<string, unknown>,
309
+ ): Pick<BuildBody['query'], 'judgeClassIds' | 'minJudgments'> | string {
310
+ const ids = b.judgeClassIds;
311
+ if (
312
+ ids !== undefined &&
313
+ (!Array.isArray(ids) || ids.some((i) => typeof i !== 'string' || i.length === 0))
314
+ ) {
315
+ return 'judgeClassIds must be a list of judge class ids.';
316
+ }
317
+ const min = b.minJudgments;
318
+ if (min !== undefined && !(Number.isInteger(min) && (min as number) >= 1)) {
319
+ return 'minJudgments must be a whole number ≥ 1.';
320
+ }
321
+ return {
322
+ ...(ids !== undefined && { judgeClassIds: ids as string[] }),
323
+ ...(min !== undefined && { minJudgments: min as number }),
324
+ };
325
+ }
326
+
327
+ /** The optional judged-run filters; a message when one is malformed. */
328
+ function parseQuery(b: Record<string, unknown>): BuildBody['query'] | string {
329
+ const strings = parseStringFilters(b);
330
+ if (typeof strings === 'string') return strings;
331
+ const counted = parseJudgmentFilters(b);
332
+ if (typeof counted === 'string') return counted;
333
+ return { ...strings, ...counted };
334
+ }
335
+
336
+ function parseBuildBody(raw: unknown): Parsed<BuildBody> {
337
+ const err = (message: string): Parsed<BuildBody> => ({ kind: 'err', message });
338
+ if (raw === null || typeof raw !== 'object') return err('Send a JSON object.');
339
+ const b = raw as Record<string, unknown>;
340
+ if (typeof b.version !== 'string' || !SEMVER_RE.test(b.version)) {
341
+ return err('version is required: a semver such as "1.0.0".');
342
+ }
343
+ if (typeof b.projectId !== 'string' || b.projectId.length === 0) {
344
+ return err('projectId is required.');
345
+ }
346
+ if (b.agentId === undefined && b.flowId === undefined) {
347
+ return err('Name the agent (agentId) or the flow (flowId) whose judged runs to use.');
348
+ }
349
+ if (b.description !== undefined && typeof b.description !== 'string') {
350
+ return err('description must be a string.');
351
+ }
352
+ const query = parseQuery(b);
353
+ if (typeof query === 'string') return err(query);
354
+ return {
355
+ kind: 'ok',
356
+ body: {
357
+ version: b.version,
358
+ projectId: b.projectId as ProjectId,
359
+ ...(typeof b.description === 'string' && { description: b.description }),
360
+ query,
361
+ },
362
+ };
363
+ }
@@ -0,0 +1,128 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import {
5
+ type ConversationBinding,
6
+ RUN_RETRIEVALS_NODE,
7
+ SESSION_GATE_RECORD,
8
+ readGateDecision,
9
+ } from '@kindgi/agents';
10
+ import type { JournalEntry, RunBinding } from '@kindgi/runtime';
11
+ import type { ConversationId, RunId, TenantId } from '@kindgi/types';
12
+
13
+ import type { JudgedRunContext } from '../judgment-binding.js';
14
+
15
+ /** The most messages of conversation history a judged turn keeps. */
16
+ export const MAX_JUDGED_HISTORY = 200;
17
+ /** How many messages are read to find a turn's history. */
18
+ const HISTORY_READ_LIMIT = 1000;
19
+
20
+ function obj(value: unknown): Record<string, unknown> | undefined {
21
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
22
+ ? (value as Record<string, unknown>)
23
+ : undefined;
24
+ }
25
+
26
+ /** The sequence of the first message the turn appended (its user message). */
27
+ function firstAppendedSequence(output: unknown): number | undefined {
28
+ const appended = obj(output)?.appended;
29
+ if (!Array.isArray(appended)) return undefined;
30
+ const sequences = appended
31
+ .map((m) => obj(m)?.sequence)
32
+ .filter((s): s is number => typeof s === 'number');
33
+ return sequences.length > 0 ? Math.min(...sequences) : undefined;
34
+ }
35
+
36
+ async function readHistory(
37
+ conversations: ConversationBinding,
38
+ tenantId: TenantId,
39
+ conversationId: string,
40
+ before: number,
41
+ ): Promise<Pick<JudgedRunContext, 'history' | 'historyTruncated'>> {
42
+ const read = await conversations.readMessages({
43
+ tenantId,
44
+ conversationId: conversationId as ConversationId,
45
+ limit: HISTORY_READ_LIMIT,
46
+ });
47
+ if (read.kind === 'err') return {};
48
+ const earlier = read.value.filter((m) => m.sequence < before);
49
+ const kept = earlier.slice(-MAX_JUDGED_HISTORY);
50
+ return {
51
+ history: kept,
52
+ ...(kept.length < earlier.length && { historyTruncated: true }),
53
+ };
54
+ }
55
+
56
+ type JournalParts = Pick<JudgedRunContext, 'retrieved' | 'sessionApproval'>;
57
+
58
+ /** What the turn's journal says it retrieved, and how its session approval was decided. */
59
+ async function readJournalParts(
60
+ runBinding: RunBinding,
61
+ tenantId: TenantId,
62
+ runId: string,
63
+ ): Promise<JournalParts> {
64
+ const journal = await runBinding.readJournal(tenantId, runId as RunId);
65
+ if (journal.kind === 'err') return {};
66
+ const entries = journal.value;
67
+ const step = entries.find(
68
+ (e) =>
69
+ e.kind === 'step.completed' &&
70
+ typeof e.nodeId === 'string' &&
71
+ (e.nodeId === RUN_RETRIEVALS_NODE || e.nodeId.endsWith(`/${RUN_RETRIEVALS_NODE}`)),
72
+ );
73
+ const retrieved = obj(obj(step?.payload)?.output)?.retrieved;
74
+ const sessionApproval = sessionApprovalOf(entries);
75
+ return {
76
+ ...(retrieved !== undefined && { retrieved }),
77
+ ...(sessionApproval !== undefined && { sessionApproval }),
78
+ };
79
+ }
80
+
81
+ /** The decision that resolved the turn's session approval gate, if it waited on one. */
82
+ function sessionApprovalOf(
83
+ entries: readonly JournalEntry[],
84
+ ): JudgedRunContext['sessionApproval'] | undefined {
85
+ const gate = entries.find(
86
+ (e) => e.kind === 'value.recorded' && obj(e.payload)?.key === SESSION_GATE_RECORD,
87
+ );
88
+ const tokenId = obj(obj(gate?.payload)?.value)?.waitTokenId;
89
+ if (typeof tokenId !== 'string') return undefined;
90
+ const resumed = entries.find(
91
+ (e) => e.kind === 'wait.resumed' && obj(e.payload)?.tokenId === tokenId,
92
+ );
93
+ if (resumed === undefined) return undefined;
94
+ const decision = readGateDecision(obj(resumed.payload)?.value);
95
+ return {
96
+ approved: decision.approved,
97
+ ...(!decision.approved &&
98
+ decision.rationale !== undefined && { rationale: decision.rationale }),
99
+ };
100
+ }
101
+
102
+ /**
103
+ * What a judged agent turn read besides its input, so it can be replayed
104
+ * faithfully later: the conversation before the turn, what its
105
+ * retrievals returned, and the decision at its session approval gate. Best effort and read-only: a part that can't be
106
+ * read is left out (the judgment never fails over it). `undefined` for
107
+ * a flow run, or when nothing could be read.
108
+ */
109
+ export async function captureTurnContext(input: {
110
+ readonly tenantId: TenantId;
111
+ readonly runId: string;
112
+ readonly output: unknown;
113
+ readonly conversationId: string | undefined;
114
+ readonly runBinding: RunBinding;
115
+ readonly conversations: ConversationBinding | undefined;
116
+ }): Promise<JudgedRunContext | undefined> {
117
+ const before = firstAppendedSequence(input.output);
118
+ const [history, journal] = await Promise.all([
119
+ input.conversations !== undefined && input.conversationId !== undefined && before !== undefined
120
+ ? readHistory(input.conversations, input.tenantId, input.conversationId, before).catch(
121
+ () => ({}),
122
+ )
123
+ : Promise.resolve({}),
124
+ readJournalParts(input.runBinding, input.tenantId, input.runId).catch(() => ({})),
125
+ ]);
126
+ const context: JudgedRunContext = { ...history, ...journal };
127
+ return Object.keys(context).length > 0 ? context : undefined;
128
+ }