@lota-sdk/core 0.1.9 → 0.1.11

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 (34) hide show
  1. package/infrastructure/schema/00_workstream.surql +1 -0
  2. package/infrastructure/schema/02_execution_plan.surql +202 -52
  3. package/package.json +4 -2
  4. package/src/bifrost/bifrost.ts +94 -25
  5. package/src/config/model-constants.ts +8 -6
  6. package/src/db/memory-store.ts +3 -71
  7. package/src/db/service.ts +42 -2
  8. package/src/db/tables.ts +9 -2
  9. package/src/embeddings/provider.ts +92 -21
  10. package/src/index.ts +6 -0
  11. package/src/redis/stream-context.ts +44 -0
  12. package/src/runtime/approval-continuation.ts +59 -0
  13. package/src/runtime/chat-request-routing.ts +5 -1
  14. package/src/runtime/execution-plan.ts +21 -14
  15. package/src/runtime/turn-lifecycle.ts +12 -4
  16. package/src/services/document-chunk.service.ts +2 -2
  17. package/src/services/execution-plan.service.ts +579 -786
  18. package/src/services/learned-skill.service.ts +2 -2
  19. package/src/services/plan-approval.service.ts +83 -0
  20. package/src/services/plan-artifact.service.ts +45 -0
  21. package/src/services/plan-builder.service.ts +61 -0
  22. package/src/services/plan-checkpoint.service.ts +53 -0
  23. package/src/services/plan-compiler.service.ts +81 -0
  24. package/src/services/plan-executor.service.ts +1623 -0
  25. package/src/services/plan-run.service.ts +422 -0
  26. package/src/services/plan-validator.service.ts +760 -0
  27. package/src/services/workstream-turn-preparation.ts +57 -15
  28. package/src/services/workstream-turn.ts +12 -0
  29. package/src/services/workstream.service.ts +26 -0
  30. package/src/services/workstream.types.ts +1 -0
  31. package/src/system-agents/title-generator.agent.ts +2 -2
  32. package/src/tools/execution-plan.tool.ts +20 -46
  33. package/src/tools/log-hello-world.tool.ts +17 -0
  34. package/src/workers/skill-extraction.runner.ts +2 -2
@@ -0,0 +1,760 @@
1
+ import type {
2
+ PlanCompletionCheck,
3
+ PlanDataSchema,
4
+ PlanDraft,
5
+ PlanFailureClass,
6
+ PlanNodeSpec,
7
+ PlanValidationIssueSeverity,
8
+ } from '@lota-sdk/shared/schemas/execution-plan'
9
+ import type { PlanNodeResultSubmission } from '@lota-sdk/shared/schemas/tools'
10
+
11
+ const STRUCTURAL_NODE_TYPES = new Set(['switch', 'join'])
12
+ const HUMAN_NODE_TYPES = new Set(['human-input', 'human-approval', 'human-review-edit', 'human-decision'])
13
+
14
+ export interface PlanValidationIssueInput {
15
+ severity: PlanValidationIssueSeverity
16
+ code: string
17
+ message: string
18
+ detail?: Record<string, unknown>
19
+ nodeId?: string
20
+ }
21
+
22
+ export interface DraftValidationResult {
23
+ blocking: PlanValidationIssueInput[]
24
+ warnings: PlanValidationIssueInput[]
25
+ }
26
+
27
+ export interface NodeResultValidationResult {
28
+ blocking: PlanValidationIssueInput[]
29
+ warnings: PlanValidationIssueInput[]
30
+ failureClass: PlanFailureClass | null
31
+ }
32
+
33
+ function createIssue(params: {
34
+ code: string
35
+ message: string
36
+ severity?: PlanValidationIssueSeverity
37
+ detail?: Record<string, unknown>
38
+ nodeId?: string
39
+ }): PlanValidationIssueInput {
40
+ return {
41
+ severity: params.severity ?? 'blocking',
42
+ code: params.code,
43
+ message: params.message,
44
+ ...(params.detail ? { detail: params.detail } : {}),
45
+ ...(params.nodeId ? { nodeId: params.nodeId } : {}),
46
+ }
47
+ }
48
+
49
+ function isRecord(value: unknown): value is Record<string, unknown> {
50
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
51
+ }
52
+
53
+ function readPathValue(source: unknown, path: string): unknown {
54
+ if (!path.trim()) return source
55
+
56
+ let current: unknown = source
57
+ for (const segment of path
58
+ .split('.')
59
+ .map((part) => part.trim())
60
+ .filter(Boolean)) {
61
+ if (!isRecord(current)) return undefined
62
+ current = current[segment]
63
+ }
64
+ return current
65
+ }
66
+
67
+ function hasAllFields(value: unknown, fields: string[]): boolean {
68
+ if (!isRecord(value)) return false
69
+ return fields.every((field) => readPathValue(value, field) !== undefined)
70
+ }
71
+
72
+ function validateSchemaValue(params: { schema: PlanDataSchema; value: unknown; path: string }): string[] {
73
+ const issues: string[] = []
74
+ const { schema, value, path } = params
75
+
76
+ if (value === null || value === undefined) {
77
+ if (schema.required || (!schema.nullable && value === null)) {
78
+ issues.push(`${path} is required`)
79
+ }
80
+ return issues
81
+ }
82
+
83
+ if (schema.enum && !schema.enum.some((candidate) => Object.is(candidate, value))) {
84
+ issues.push(`${path} must be one of the allowed enum values`)
85
+ return issues
86
+ }
87
+
88
+ if (schema.type === 'string') {
89
+ if (typeof value !== 'string') issues.push(`${path} must be a string`)
90
+ return issues
91
+ }
92
+
93
+ if (schema.type === 'number') {
94
+ if (typeof value !== 'number' || Number.isNaN(value)) issues.push(`${path} must be a number`)
95
+ return issues
96
+ }
97
+
98
+ if (schema.type === 'boolean') {
99
+ if (typeof value !== 'boolean') issues.push(`${path} must be a boolean`)
100
+ return issues
101
+ }
102
+
103
+ if (schema.type === 'array') {
104
+ if (!Array.isArray(value)) {
105
+ issues.push(`${path} must be an array`)
106
+ return issues
107
+ }
108
+ if (schema.minItems !== undefined && value.length < schema.minItems) {
109
+ issues.push(`${path} must contain at least ${schema.minItems} item(s)`)
110
+ }
111
+ if (schema.maxItems !== undefined && value.length > schema.maxItems) {
112
+ issues.push(`${path} must contain at most ${schema.maxItems} item(s)`)
113
+ }
114
+ const itemSchema = schema.items
115
+ if (itemSchema) {
116
+ value.forEach((entry, index) => {
117
+ issues.push(...validateSchemaValue({ schema: itemSchema, value: entry, path: `${path}[${index}]` }))
118
+ })
119
+ }
120
+ return issues
121
+ }
122
+
123
+ if (!isRecord(value)) {
124
+ issues.push(`${path} must be an object`)
125
+ return issues
126
+ }
127
+
128
+ for (const [key, childSchema] of Object.entries(schema.properties ?? {})) {
129
+ issues.push(...validateSchemaValue({ schema: childSchema, value: value[key], path: `${path}.${key}` }))
130
+ }
131
+
132
+ return issues
133
+ }
134
+
135
+ function buildReachableNodeIds(entryNodeIds: string[], adjacency: Map<string, string[]>): Set<string> {
136
+ const visited = new Set<string>()
137
+ const queue = [...entryNodeIds]
138
+
139
+ while (queue.length > 0) {
140
+ const nodeId = queue.shift()
141
+ if (!nodeId || visited.has(nodeId)) continue
142
+ visited.add(nodeId)
143
+ for (const next of adjacency.get(nodeId) ?? []) {
144
+ if (!visited.has(next)) {
145
+ queue.push(next)
146
+ }
147
+ }
148
+ }
149
+
150
+ return visited
151
+ }
152
+
153
+ function graphHasCycle(nodeIds: string[], adjacency: Map<string, string[]>): boolean {
154
+ const visiting = new Set<string>()
155
+ const visited = new Set<string>()
156
+
157
+ const visit = (nodeId: string): boolean => {
158
+ if (visited.has(nodeId)) return false
159
+ if (visiting.has(nodeId)) return true
160
+
161
+ visiting.add(nodeId)
162
+ for (const next of adjacency.get(nodeId) ?? []) {
163
+ if (visit(next)) return true
164
+ }
165
+ visiting.delete(nodeId)
166
+ visited.add(nodeId)
167
+ return false
168
+ }
169
+
170
+ return nodeIds.some((nodeId) => visit(nodeId))
171
+ }
172
+
173
+ function resolveSchemaRef(
174
+ draft: Pick<PlanDraft, 'schemas'>,
175
+ schemaRef: string | null | undefined,
176
+ ): PlanDataSchema | undefined {
177
+ if (!schemaRef) return undefined
178
+ return draft.schemas[schemaRef]
179
+ }
180
+
181
+ class PlanValidatorService {
182
+ validateDraft(draft: PlanDraft): DraftValidationResult {
183
+ const blocking: PlanValidationIssueInput[] = []
184
+ const warnings: PlanValidationIssueInput[] = []
185
+ const nodeIds = new Set<string>()
186
+ const edgeIds = new Set<string>()
187
+ const nodesById = new Map(draft.nodes.map((node) => [node.id, node]))
188
+ const adjacency = new Map<string, string[]>(draft.nodes.map((node) => [node.id, []]))
189
+ const inbound = new Map<string, string[]>(draft.nodes.map((node) => [node.id, []]))
190
+
191
+ for (const node of draft.nodes) {
192
+ if (nodeIds.has(node.id)) {
193
+ blocking.push(
194
+ createIssue({
195
+ code: 'duplicate_node_id',
196
+ message: `Plan contains duplicate node id "${node.id}".`,
197
+ nodeId: node.id,
198
+ }),
199
+ )
200
+ }
201
+ nodeIds.add(node.id)
202
+
203
+ const isStructuralNode = STRUCTURAL_NODE_TYPES.has(node.type)
204
+ if (!isStructuralNode && node.deliverables.length === 0) {
205
+ blocking.push(
206
+ createIssue({
207
+ code: 'node_missing_deliverables',
208
+ message: `Node "${node.label}" must define at least one deliverable.`,
209
+ nodeId: node.id,
210
+ }),
211
+ )
212
+ }
213
+ if (node.successCriteria.length === 0) {
214
+ blocking.push(
215
+ createIssue({
216
+ code: 'node_missing_success_criteria',
217
+ message: `Node "${node.label}" must define at least one success criterion.`,
218
+ nodeId: node.id,
219
+ }),
220
+ )
221
+ }
222
+ if (!isStructuralNode && node.completionChecks.length === 0) {
223
+ blocking.push(
224
+ createIssue({
225
+ code: 'node_missing_completion_checks',
226
+ message: `Node "${node.label}" must define at least one completion check.`,
227
+ nodeId: node.id,
228
+ }),
229
+ )
230
+ }
231
+ if (HUMAN_NODE_TYPES.has(node.type) && node.owner.executorType !== 'user') {
232
+ blocking.push(
233
+ createIssue({
234
+ code: 'human_node_owner_mismatch',
235
+ message: `Human node "${node.label}" must be owned by a user executor.`,
236
+ nodeId: node.id,
237
+ }),
238
+ )
239
+ }
240
+ if (
241
+ !HUMAN_NODE_TYPES.has(node.type) &&
242
+ node.type !== 'join' &&
243
+ node.type !== 'switch' &&
244
+ node.owner.ref.trim().length === 0
245
+ ) {
246
+ blocking.push(
247
+ createIssue({
248
+ code: 'node_owner_missing',
249
+ message: `Node "${node.label}" must declare an executor reference.`,
250
+ nodeId: node.id,
251
+ }),
252
+ )
253
+ }
254
+ if (node.inputSchemaRef && !resolveSchemaRef(draft, node.inputSchemaRef)) {
255
+ blocking.push(
256
+ createIssue({
257
+ code: 'missing_input_schema_ref',
258
+ message: `Node "${node.label}" references unknown input schema "${node.inputSchemaRef}".`,
259
+ nodeId: node.id,
260
+ }),
261
+ )
262
+ }
263
+ if (node.outputSchemaRef && !resolveSchemaRef(draft, node.outputSchemaRef)) {
264
+ blocking.push(
265
+ createIssue({
266
+ code: 'missing_output_schema_ref',
267
+ message: `Node "${node.label}" references unknown output schema "${node.outputSchemaRef}".`,
268
+ nodeId: node.id,
269
+ }),
270
+ )
271
+ }
272
+ for (const deliverable of node.deliverables) {
273
+ if (deliverable.schemaRef && !resolveSchemaRef(draft, deliverable.schemaRef)) {
274
+ blocking.push(
275
+ createIssue({
276
+ code: 'missing_artifact_schema_ref',
277
+ message: `Node "${node.label}" references unknown artifact schema "${deliverable.schemaRef}".`,
278
+ nodeId: node.id,
279
+ detail: { artifactName: deliverable.name },
280
+ }),
281
+ )
282
+ }
283
+ }
284
+ for (const check of node.completionChecks) {
285
+ const checkSchemaRef = typeof check.config.schemaRef === 'string' ? check.config.schemaRef : undefined
286
+ if (checkSchemaRef && !resolveSchemaRef(draft, checkSchemaRef)) {
287
+ blocking.push(
288
+ createIssue({
289
+ code: 'missing_completion_check_schema_ref',
290
+ message: `Node "${node.label}" references unknown completion-check schema "${checkSchemaRef}".`,
291
+ nodeId: node.id,
292
+ detail: { checkDescription: check.description },
293
+ }),
294
+ )
295
+ }
296
+ }
297
+ if (node.toolPolicy.allow.some((toolName) => node.toolPolicy.deny.includes(toolName))) {
298
+ warnings.push(
299
+ createIssue({
300
+ code: 'tool_policy_overlap',
301
+ severity: 'warning',
302
+ message: `Node "${node.label}" lists the same tool in allow and deny policy.`,
303
+ nodeId: node.id,
304
+ }),
305
+ )
306
+ }
307
+ }
308
+
309
+ const entryNodeIds = draft.entryNodeIds ?? []
310
+ for (const entryNodeId of entryNodeIds) {
311
+ if (!nodesById.has(entryNodeId)) {
312
+ blocking.push(
313
+ createIssue({
314
+ code: 'missing_entry_node',
315
+ message: `Entry node "${entryNodeId}" does not exist in the plan.`,
316
+ detail: { entryNodeId },
317
+ }),
318
+ )
319
+ }
320
+ }
321
+
322
+ for (const edge of draft.edges) {
323
+ if (edgeIds.has(edge.id)) {
324
+ blocking.push(
325
+ createIssue({
326
+ code: 'duplicate_edge_id',
327
+ message: `Plan contains duplicate edge id "${edge.id}".`,
328
+ detail: { edgeId: edge.id },
329
+ }),
330
+ )
331
+ }
332
+ edgeIds.add(edge.id)
333
+
334
+ if (!nodesById.has(edge.source)) {
335
+ blocking.push(
336
+ createIssue({
337
+ code: 'missing_edge_source',
338
+ message: `Edge "${edge.id}" references missing source node "${edge.source}".`,
339
+ detail: { edgeId: edge.id, source: edge.source },
340
+ }),
341
+ )
342
+ continue
343
+ }
344
+ if (!nodesById.has(edge.target)) {
345
+ blocking.push(
346
+ createIssue({
347
+ code: 'missing_edge_target',
348
+ message: `Edge "${edge.id}" references missing target node "${edge.target}".`,
349
+ detail: { edgeId: edge.id, target: edge.target },
350
+ }),
351
+ )
352
+ continue
353
+ }
354
+
355
+ adjacency.get(edge.source)?.push(edge.target)
356
+ inbound.get(edge.target)?.push(edge.source)
357
+ }
358
+
359
+ for (const node of draft.nodes) {
360
+ const inboundEdges = inbound.get(node.id) ?? []
361
+ const outboundEdges = adjacency.get(node.id) ?? []
362
+
363
+ if (node.type === 'join' && inboundEdges.length < 2) {
364
+ blocking.push(
365
+ createIssue({
366
+ code: 'join_requires_multiple_inbound_edges',
367
+ message: `Join node "${node.label}" must have at least two inbound edges.`,
368
+ nodeId: node.id,
369
+ }),
370
+ )
371
+ }
372
+
373
+ if (node.type === 'switch') {
374
+ const conditionalEdges = draft.edges.filter((edge) => edge.source === node.id && edge.when)
375
+ if (conditionalEdges.length === 0) {
376
+ blocking.push(
377
+ createIssue({
378
+ code: 'switch_requires_conditional_edges',
379
+ message: `Switch node "${node.label}" must define conditional outbound edges.`,
380
+ nodeId: node.id,
381
+ }),
382
+ )
383
+ }
384
+ if (outboundEdges.length < 2) {
385
+ warnings.push(
386
+ createIssue({
387
+ code: 'switch_has_single_path',
388
+ severity: 'warning',
389
+ message: `Switch node "${node.label}" only routes to one target.`,
390
+ nodeId: node.id,
391
+ }),
392
+ )
393
+ }
394
+ }
395
+
396
+ if (!entryNodeIds.includes(node.id) && inboundEdges.length === 0) {
397
+ blocking.push(
398
+ createIssue({
399
+ code: 'orphan_node',
400
+ message: `Node "${node.label}" is not connected to the graph.`,
401
+ nodeId: node.id,
402
+ }),
403
+ )
404
+ }
405
+
406
+ if (node.inputSchemaRef && inboundEdges.length > 0) {
407
+ const hasMappedInput = draft.edges
408
+ .filter((edge) => edge.target === node.id)
409
+ .some((edge) => Object.keys(edge.map).length > 0)
410
+ if (!hasMappedInput) {
411
+ blocking.push(
412
+ createIssue({
413
+ code: 'unresolved_node_input',
414
+ message: `Node "${node.label}" declares inputSchemaRef but no inbound edge maps data into it.`,
415
+ nodeId: node.id,
416
+ }),
417
+ )
418
+ }
419
+ }
420
+ }
421
+
422
+ const reachableNodeIds = buildReachableNodeIds(entryNodeIds, adjacency)
423
+ for (const node of draft.nodes) {
424
+ if (!reachableNodeIds.has(node.id)) {
425
+ blocking.push(
426
+ createIssue({
427
+ code: 'unreachable_node',
428
+ message: `Node "${node.label}" is unreachable from the configured entry nodes.`,
429
+ nodeId: node.id,
430
+ }),
431
+ )
432
+ }
433
+ }
434
+
435
+ if (
436
+ graphHasCycle(
437
+ draft.nodes.map((node) => node.id),
438
+ adjacency,
439
+ )
440
+ ) {
441
+ blocking.push(
442
+ createIssue({
443
+ code: 'cycle_detected',
444
+ message: 'Plan graph contains a cycle. Loop semantics are not supported in this cutover.',
445
+ }),
446
+ )
447
+ }
448
+
449
+ return { blocking, warnings }
450
+ }
451
+
452
+ validateNodeResult(params: {
453
+ draft: Pick<PlanDraft, 'schemas'>
454
+ node: PlanNodeSpec
455
+ result: PlanNodeResultSubmission
456
+ }): NodeResultValidationResult {
457
+ const blocking: PlanValidationIssueInput[] = []
458
+ const warnings: PlanValidationIssueInput[] = []
459
+ const artifactsByName = new Map(params.result.artifacts.map((artifact) => [artifact.name, artifact]))
460
+
461
+ if (params.node.outputSchemaRef) {
462
+ const outputSchema = resolveSchemaRef(params.draft, params.node.outputSchemaRef)
463
+ if (!params.result.structuredOutput) {
464
+ blocking.push(
465
+ createIssue({
466
+ code: 'structured_output_missing',
467
+ message: `Node "${params.node.label}" requires structured output.`,
468
+ nodeId: params.node.id,
469
+ }),
470
+ )
471
+ } else if (outputSchema) {
472
+ const schemaIssues = validateSchemaValue({
473
+ schema: outputSchema,
474
+ value: params.result.structuredOutput,
475
+ path: 'structuredOutput',
476
+ })
477
+ if (schemaIssues.length > 0) {
478
+ blocking.push(
479
+ createIssue({
480
+ code: 'schema_validation_failed',
481
+ message: `Structured output for node "${params.node.label}" failed schema validation.`,
482
+ nodeId: params.node.id,
483
+ detail: { issues: schemaIssues },
484
+ }),
485
+ )
486
+ }
487
+ }
488
+ }
489
+
490
+ for (const deliverable of params.node.deliverables) {
491
+ const artifact = artifactsByName.get(deliverable.name)
492
+ if (!artifact) {
493
+ if (deliverable.required) {
494
+ blocking.push(
495
+ createIssue({
496
+ code: 'required_artifact_missing',
497
+ message: `Node "${params.node.label}" is missing required artifact "${deliverable.name}".`,
498
+ nodeId: params.node.id,
499
+ }),
500
+ )
501
+ }
502
+ continue
503
+ }
504
+
505
+ if (artifact.kind !== deliverable.kind) {
506
+ blocking.push(
507
+ createIssue({
508
+ code: 'artifact_kind_mismatch',
509
+ message: `Artifact "${deliverable.name}" must be of kind "${deliverable.kind}".`,
510
+ nodeId: params.node.id,
511
+ }),
512
+ )
513
+ }
514
+
515
+ if (deliverable.schemaRef) {
516
+ if (artifact.schemaRef !== deliverable.schemaRef) {
517
+ blocking.push(
518
+ createIssue({
519
+ code: 'artifact_schema_mismatch',
520
+ message: `Artifact "${deliverable.name}" must declare schemaRef "${deliverable.schemaRef}".`,
521
+ nodeId: params.node.id,
522
+ }),
523
+ )
524
+ }
525
+
526
+ const artifactSchema = resolveSchemaRef(params.draft, deliverable.schemaRef)
527
+ if (!artifact.payload) {
528
+ blocking.push(
529
+ createIssue({
530
+ code: 'artifact_payload_missing',
531
+ message: `Artifact "${deliverable.name}" must include payload data for schema validation.`,
532
+ nodeId: params.node.id,
533
+ }),
534
+ )
535
+ } else if (artifactSchema) {
536
+ const schemaIssues = validateSchemaValue({
537
+ schema: artifactSchema,
538
+ value: artifact.payload,
539
+ path: `artifact:${deliverable.name}`,
540
+ })
541
+ if (schemaIssues.length > 0) {
542
+ blocking.push(
543
+ createIssue({
544
+ code: 'schema_validation_failed',
545
+ message: `Artifact "${deliverable.name}" failed schema validation.`,
546
+ nodeId: params.node.id,
547
+ detail: { issues: schemaIssues, artifact: deliverable.name },
548
+ }),
549
+ )
550
+ }
551
+ }
552
+ }
553
+ }
554
+
555
+ for (const check of params.node.completionChecks) {
556
+ const issue = this.evaluateCompletionCheck({
557
+ draft: params.draft,
558
+ node: params.node,
559
+ result: params.result,
560
+ check,
561
+ })
562
+ if (!issue) continue
563
+ if (issue.severity === 'warning') {
564
+ warnings.push(issue)
565
+ } else {
566
+ blocking.push(issue)
567
+ }
568
+ }
569
+
570
+ return { blocking, warnings, failureClass: this.resolveFailureClass(blocking) }
571
+ }
572
+
573
+ private evaluateCompletionCheck(params: {
574
+ draft: Pick<PlanDraft, 'schemas'>
575
+ node: PlanNodeSpec
576
+ result: PlanNodeResultSubmission
577
+ check: PlanCompletionCheck
578
+ }): PlanValidationIssueInput | null {
579
+ const { check, node, result, draft } = params
580
+ const artifactName = typeof check.config.artifact === 'string' ? check.config.artifact : undefined
581
+ const artifact = artifactName ? result.artifacts.find((candidate) => candidate.name === artifactName) : undefined
582
+ const target =
583
+ artifactName && artifact
584
+ ? artifact.payload
585
+ : typeof check.config.target === 'string'
586
+ ? readPathValue(result.structuredOutput, check.config.target)
587
+ : result.structuredOutput
588
+
589
+ if (check.type === 'schema') {
590
+ const schemaRef =
591
+ (typeof check.config.schemaRef === 'string' ? check.config.schemaRef : undefined) ??
592
+ artifact?.schemaRef ??
593
+ (artifactName ? node.deliverables.find((candidate) => candidate.name === artifactName)?.schemaRef : undefined)
594
+ if (!schemaRef) {
595
+ return createIssue({
596
+ code: 'schema_check_missing_schema_ref',
597
+ message: `Completion check "${check.description}" does not resolve a schema reference.`,
598
+ nodeId: node.id,
599
+ severity: check.blocking ? 'blocking' : 'warning',
600
+ })
601
+ }
602
+
603
+ if (artifactName && !artifact) {
604
+ return createIssue({
605
+ code: 'schema_check_artifact_missing',
606
+ message: `Completion check "${check.description}" requires artifact "${artifactName}".`,
607
+ nodeId: node.id,
608
+ severity: check.blocking ? 'blocking' : 'warning',
609
+ })
610
+ }
611
+
612
+ const schema = resolveSchemaRef(draft, schemaRef)
613
+ if (!schema) {
614
+ return createIssue({
615
+ code: 'schema_check_schema_missing',
616
+ message: `Completion check "${check.description}" references unknown schema "${schemaRef}".`,
617
+ nodeId: node.id,
618
+ severity: check.blocking ? 'blocking' : 'warning',
619
+ })
620
+ }
621
+
622
+ const schemaIssues = validateSchemaValue({
623
+ schema,
624
+ value: target,
625
+ path: artifactName ? `artifact:${artifactName}` : 'structuredOutput',
626
+ })
627
+ if (schemaIssues.length > 0) {
628
+ return createIssue({
629
+ code: 'schema_validation_failed',
630
+ message: `Completion check "${check.description}" failed schema validation.`,
631
+ nodeId: node.id,
632
+ severity: check.blocking ? 'blocking' : 'warning',
633
+ detail: { issues: schemaIssues, schemaRef },
634
+ })
635
+ }
636
+
637
+ return null
638
+ }
639
+
640
+ if (check.type === 'assertion') {
641
+ const requiredFields = Array.isArray(check.config.mustContainFields)
642
+ ? check.config.mustContainFields.filter((field): field is string => typeof field === 'string')
643
+ : []
644
+ if (requiredFields.length > 0 && !hasAllFields(target, requiredFields)) {
645
+ return createIssue({
646
+ code: 'assertion_failed',
647
+ message: `Assertion "${check.description}" failed because required fields are missing.`,
648
+ nodeId: node.id,
649
+ severity: check.blocking ? 'blocking' : 'warning',
650
+ detail: { mustContainFields: requiredFields },
651
+ })
652
+ }
653
+
654
+ const equalityPath = typeof check.config.path === 'string' ? check.config.path : undefined
655
+ if (equalityPath && 'equals' in check.config) {
656
+ const actual = readPathValue(target, equalityPath)
657
+ if (!Object.is(actual, check.config.equals)) {
658
+ return createIssue({
659
+ code: 'assertion_failed',
660
+ message: `Assertion "${check.description}" failed at path "${equalityPath}".`,
661
+ nodeId: node.id,
662
+ severity: check.blocking ? 'blocking' : 'warning',
663
+ detail: { path: equalityPath, expected: check.config.equals, actual },
664
+ })
665
+ }
666
+ }
667
+
668
+ const truthyPaths = Array.isArray(check.config.truthyPaths)
669
+ ? check.config.truthyPaths.filter((entry): entry is string => typeof entry === 'string')
670
+ : []
671
+ if (truthyPaths.some((path) => !readPathValue(target, path))) {
672
+ return createIssue({
673
+ code: 'assertion_failed',
674
+ message: `Assertion "${check.description}" expected truthy values that were not present.`,
675
+ nodeId: node.id,
676
+ severity: check.blocking ? 'blocking' : 'warning',
677
+ detail: { truthyPaths },
678
+ })
679
+ }
680
+
681
+ return null
682
+ }
683
+
684
+ if (check.type === 'tool-check') {
685
+ const mode = typeof check.config.mode === 'string' ? check.config.mode : 'artifact-present'
686
+ if (mode === 'artifact-present' && artifactName && !artifact) {
687
+ return createIssue({
688
+ code: 'required_artifact_missing',
689
+ message: `Tool check "${check.description}" requires artifact "${artifactName}".`,
690
+ nodeId: node.id,
691
+ severity: check.blocking ? 'blocking' : 'warning',
692
+ })
693
+ }
694
+ if (
695
+ mode === 'artifact-kind' &&
696
+ artifactName &&
697
+ artifact &&
698
+ typeof check.config.kind === 'string' &&
699
+ artifact.kind !== check.config.kind
700
+ ) {
701
+ return createIssue({
702
+ code: 'artifact_kind_mismatch',
703
+ message: `Tool check "${check.description}" expected artifact "${artifactName}" to be kind "${check.config.kind}".`,
704
+ nodeId: node.id,
705
+ severity: check.blocking ? 'blocking' : 'warning',
706
+ })
707
+ }
708
+ if (
709
+ mode === 'min-artifact-count' &&
710
+ typeof check.config.count === 'number' &&
711
+ result.artifacts.length < check.config.count
712
+ ) {
713
+ return createIssue({
714
+ code: 'required_artifact_missing',
715
+ message: `Tool check "${check.description}" expected at least ${check.config.count} artifact(s).`,
716
+ nodeId: node.id,
717
+ severity: check.blocking ? 'blocking' : 'warning',
718
+ })
719
+ }
720
+ return null
721
+ }
722
+
723
+ if (check.type === 'human-approval') {
724
+ const approvedField = typeof check.config.approvedField === 'string' ? check.config.approvedField : 'approved'
725
+ if (readPathValue(result.structuredOutput, approvedField) !== true) {
726
+ return createIssue({
727
+ code: 'human_rejected',
728
+ message: `Human approval check "${check.description}" did not pass.`,
729
+ nodeId: node.id,
730
+ severity: check.blocking ? 'blocking' : 'warning',
731
+ detail: { approvedField },
732
+ })
733
+ }
734
+ return null
735
+ }
736
+
737
+ const resultField = typeof check.config.resultField === 'string' ? check.config.resultField : 'passed'
738
+ if (readPathValue(result.structuredOutput, resultField) !== true) {
739
+ return createIssue({
740
+ code: 'schema_validation_failed',
741
+ message: `LLM judge check "${check.description}" did not report success.`,
742
+ nodeId: node.id,
743
+ severity: check.blocking ? 'blocking' : 'warning',
744
+ detail: { resultField },
745
+ })
746
+ }
747
+ return null
748
+ }
749
+
750
+ private resolveFailureClass(blocking: PlanValidationIssueInput[]): PlanFailureClass | null {
751
+ for (const issue of blocking) {
752
+ if (issue.code === 'required_artifact_missing') return 'required_artifact_missing'
753
+ if (issue.code === 'human_rejected') return 'human_rejected'
754
+ if (issue.code === 'schema_validation_failed') return 'schema_validation_failed'
755
+ }
756
+ return blocking.length > 0 ? 'non_recoverable_logic_error' : null
757
+ }
758
+ }
759
+
760
+ export const planValidatorService = new PlanValidatorService()