ag-ui-validate 0.1.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.
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/dist/catalog-BglXBNbL.js +472 -0
- package/dist/catalog-BglXBNbL.js.map +1 -0
- package/dist/catalog-Ci9dqc1a.cjs +495 -0
- package/dist/catalog-Ci9dqc1a.cjs.map +1 -0
- package/dist/cli.js +2783 -0
- package/dist/cli.js.map +1 -0
- package/dist/index-Hmqj3r_r.d.cts +52 -0
- package/dist/index-oNG1kOp9.d.ts +52 -0
- package/dist/index.cjs +14 -0
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/report.cjs +139 -0
- package/dist/report.cjs.map +1 -0
- package/dist/report.d.cts +85 -0
- package/dist/report.d.ts +85 -0
- package/dist/report.js +134 -0
- package/dist/report.js.map +1 -0
- package/dist/src-HmI-kxef.cjs +1596 -0
- package/dist/src-HmI-kxef.cjs.map +1 -0
- package/dist/src-rGZ2G4qA.js +1555 -0
- package/dist/src-rGZ2G4qA.js.map +1 -0
- package/dist/transport.cjs +329 -0
- package/dist/transport.cjs.map +1 -0
- package/dist/transport.d.cts +89 -0
- package/dist/transport.d.ts +89 -0
- package/dist/transport.js +323 -0
- package/dist/transport.js.map +1 -0
- package/dist/types-oH_QTnn2.d.cts +148 -0
- package/dist/types-oH_QTnn2.d.ts +148 -0
- package/dist/vitest.d.ts +28 -0
- package/dist/vitest.js +2089 -0
- package/dist/vitest.js.map +1 -0
- package/package.json +127 -0
- package/src/cli-args.ts +202 -0
- package/src/cli.ts +147 -0
- package/src/index.ts +465 -0
- package/src/protocol/event-table.ts +316 -0
- package/src/protocol/jsonpatch.ts +220 -0
- package/src/report/index.ts +10 -0
- package/src/report/json.ts +20 -0
- package/src/report/junit.ts +56 -0
- package/src/report/pretty.ts +59 -0
- package/src/report/sarif.ts +109 -0
- package/src/rules/catalog.json +431 -0
- package/src/rules/catalog.ts +84 -0
- package/src/rules/checks/context.ts +117 -0
- package/src/rules/checks/lifecycle.ts +59 -0
- package/src/rules/checks/reasoning.ts +97 -0
- package/src/rules/checks/state.ts +72 -0
- package/src/rules/checks/text.ts +109 -0
- package/src/rules/checks/toolcalls.ts +167 -0
- package/src/rules/checks/transport.ts +17 -0
- package/src/transport/index.ts +331 -0
- package/src/transport/ndjson.ts +25 -0
- package/src/transport/sse.ts +126 -0
- package/src/types.ts +136 -0
- package/src/vitest/index.ts +19 -0
- package/src/vitest/matcher.ts +77 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Internal validator state and the API each check module sees.
|
|
2
|
+
// Everything here is engine-internal; the public surface lives in src/types.ts.
|
|
3
|
+
|
|
4
|
+
import type { CanonicalFeature } from "../../types.js"
|
|
5
|
+
|
|
6
|
+
/** Emit a diagnostic for rule `id`; params fill the catalog messageTemplate. */
|
|
7
|
+
export type EmitFn = (
|
|
8
|
+
ruleId: string,
|
|
9
|
+
params: Record<string, unknown>,
|
|
10
|
+
extra?: {
|
|
11
|
+
eventIndex?: number
|
|
12
|
+
pointer?: string
|
|
13
|
+
relatedEventIndex?: number
|
|
14
|
+
/** Instance-level severity floor (e.g. draft META downgrades AGUI503). */
|
|
15
|
+
severity?: "error" | "warning" | "info"
|
|
16
|
+
/** Instance-level spec link override (e.g. draft docs page). */
|
|
17
|
+
specUrl?: string
|
|
18
|
+
/** Appended to the formatted message (e.g. casing hints). */
|
|
19
|
+
messageSuffix?: string
|
|
20
|
+
},
|
|
21
|
+
) => void
|
|
22
|
+
|
|
23
|
+
export interface OpenToolCall {
|
|
24
|
+
startIndex: number
|
|
25
|
+
args: string
|
|
26
|
+
sawArgs: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RunState {
|
|
30
|
+
/** null while the run is implicit (stream opened without RUN_STARTED). */
|
|
31
|
+
runId: string | null
|
|
32
|
+
threadId: string | null
|
|
33
|
+
startIndex: number
|
|
34
|
+
implicit: boolean
|
|
35
|
+
terminal: { type: string; index: number } | null
|
|
36
|
+
|
|
37
|
+
openMessages: Map<string, { startIndex: number }>
|
|
38
|
+
/** messageId -> index of the event that closed it. */
|
|
39
|
+
closedMessages: Map<string, number>
|
|
40
|
+
/** Every message id observed (starts, chunks, snapshots, tool results). */
|
|
41
|
+
knownMessageIds: Set<string>
|
|
42
|
+
|
|
43
|
+
openToolCalls: Map<string, OpenToolCall>
|
|
44
|
+
closedToolCalls: Map<string, number>
|
|
45
|
+
/** Ids also known from MESSAGES_SNAPSHOT history. */
|
|
46
|
+
knownToolCallIds: Set<string>
|
|
47
|
+
|
|
48
|
+
/** stepName -> re-entrant open count (SQ-10) and first-open index. */
|
|
49
|
+
openSteps: Map<string, { count: number; firstIndex: number }>
|
|
50
|
+
|
|
51
|
+
openReasoningBlocks: Map<string, number>
|
|
52
|
+
openReasoningMessages: Map<string, number>
|
|
53
|
+
|
|
54
|
+
state: {
|
|
55
|
+
/** True once a STATE_SNAPSHOT established an observable base (SQ-1). */
|
|
56
|
+
known: boolean
|
|
57
|
+
value: unknown
|
|
58
|
+
deltasSinceSnapshot: number
|
|
59
|
+
snapshotSeen: boolean
|
|
60
|
+
agui301Fired: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Implicit streams opened by *_CHUNK events; closed by any other event. */
|
|
64
|
+
textChunk: { messageId: string; startIndex: number } | null
|
|
65
|
+
toolChunk: { toolCallId: string; startIndex: number; args: string; sawArgs: boolean } | null
|
|
66
|
+
reasoningChunk: { messageId: string; startIndex: number } | null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function newRunState(init: {
|
|
70
|
+
runId: string | null
|
|
71
|
+
threadId: string | null
|
|
72
|
+
startIndex: number
|
|
73
|
+
implicit: boolean
|
|
74
|
+
}): RunState {
|
|
75
|
+
return {
|
|
76
|
+
...init,
|
|
77
|
+
terminal: null,
|
|
78
|
+
openMessages: new Map(),
|
|
79
|
+
closedMessages: new Map(),
|
|
80
|
+
knownMessageIds: new Set(),
|
|
81
|
+
openToolCalls: new Map(),
|
|
82
|
+
closedToolCalls: new Map(),
|
|
83
|
+
knownToolCallIds: new Set(),
|
|
84
|
+
openSteps: new Map(),
|
|
85
|
+
openReasoningBlocks: new Map(),
|
|
86
|
+
openReasoningMessages: new Map(),
|
|
87
|
+
state: { known: false, value: undefined, deltasSinceSnapshot: 0, snapshotSeen: false, agui301Fired: false },
|
|
88
|
+
textChunk: null,
|
|
89
|
+
toolChunk: null,
|
|
90
|
+
reasoningChunk: null,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface StreamState {
|
|
95
|
+
eventCount: number
|
|
96
|
+
sawTimestamp: boolean
|
|
97
|
+
anySnapshot: boolean
|
|
98
|
+
agui001Fired: boolean
|
|
99
|
+
features: Set<CanonicalFeature>
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** What a per-event check handler receives. */
|
|
103
|
+
export interface CheckApi {
|
|
104
|
+
index: number
|
|
105
|
+
type: string
|
|
106
|
+
event: Record<string, unknown>
|
|
107
|
+
run: RunState
|
|
108
|
+
stream: StreamState
|
|
109
|
+
emit: EmitFn
|
|
110
|
+
feature: (f: CanonicalFeature) => void
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Reads a field only if it is a string (schema problems already reported). */
|
|
114
|
+
export function str(event: Record<string, unknown>, field: string): string | undefined {
|
|
115
|
+
const v = event[field]
|
|
116
|
+
return typeof v === "string" ? v : undefined
|
|
117
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Lifecycle rules: AGUI001–AGUI008. Run open/terminate logic lives in the
|
|
2
|
+
// engine (src/index.ts) because it owns run-scope resets; this module holds
|
|
3
|
+
// the step pairing and the RUN_FINISHED id-stability check.
|
|
4
|
+
|
|
5
|
+
import type { CheckApi, RunState, EmitFn } from "./context.js"
|
|
6
|
+
import { str } from "./context.js"
|
|
7
|
+
|
|
8
|
+
export function handleStepEvent(api: CheckApi): void {
|
|
9
|
+
const { type, event, run, emit, index } = api
|
|
10
|
+
const stepName = str(event, "stepName")
|
|
11
|
+
if (stepName === undefined) return
|
|
12
|
+
|
|
13
|
+
if (type === "STEP_STARTED") {
|
|
14
|
+
const open = run.openSteps.get(stepName)
|
|
15
|
+
if (open !== undefined) {
|
|
16
|
+
open.count += 1 // re-entrant same-name steps are tolerated (SQ-10)
|
|
17
|
+
} else {
|
|
18
|
+
run.openSteps.set(stepName, { count: 1, firstIndex: index })
|
|
19
|
+
}
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (type === "STEP_FINISHED") {
|
|
24
|
+
const open = run.openSteps.get(stepName)
|
|
25
|
+
if (open === undefined) {
|
|
26
|
+
emit("AGUI006", { stepName }, { pointer: "/stepName" })
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
open.count -= 1
|
|
30
|
+
if (open.count === 0) run.openSteps.delete(stepName)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** AGUI008 — RUN_FINISHED must carry the ids RUN_STARTED established. */
|
|
35
|
+
export function checkRunIdStability(api: CheckApi): void {
|
|
36
|
+
const { event, run, emit } = api
|
|
37
|
+
if (run.implicit) return // no RUN_STARTED to compare against
|
|
38
|
+
for (const field of ["threadId", "runId"] as const) {
|
|
39
|
+
const actual = str(api.event, field)
|
|
40
|
+
const expected = field === "threadId" ? run.threadId : run.runId
|
|
41
|
+
if (actual !== undefined && expected !== null && actual !== expected) {
|
|
42
|
+
emit("AGUI008", { field, actual, expected }, {
|
|
43
|
+
pointer: `/${field}`,
|
|
44
|
+
relatedEventIndex: run.startIndex,
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
void event
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** AGUI007 — open steps when the run reaches a clean end. */
|
|
52
|
+
export function endOfRunSteps(run: RunState, emit: EmitFn, atIndex: number): void {
|
|
53
|
+
for (const [stepName, open] of run.openSteps) {
|
|
54
|
+
emit("AGUI007", { stepName }, {
|
|
55
|
+
eventIndex: atIndex,
|
|
56
|
+
relatedEventIndex: open.firstIndex,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Reasoning rules: AGUI401–AGUI402, plus REASONING_MESSAGE_CHUNK handling.
|
|
2
|
+
//
|
|
3
|
+
// Per SQ-4: block-level nesting (REASONING_MESSAGE_* inside
|
|
4
|
+
// REASONING_START/END) is only described as "a typical flow" by the docs, so
|
|
5
|
+
// it is NOT enforced. AGUI401 enforces the message-level pairing, mirroring
|
|
6
|
+
// the text message rules; AGUI402 reports unterminated starts at warning.
|
|
7
|
+
|
|
8
|
+
import type { CheckApi, RunState, EmitFn } from "./context.js"
|
|
9
|
+
import { str } from "./context.js"
|
|
10
|
+
|
|
11
|
+
export function handleReasoningEvent(api: CheckApi): void {
|
|
12
|
+
const { type, event, run, emit, index } = api
|
|
13
|
+
|
|
14
|
+
switch (type) {
|
|
15
|
+
case "REASONING_START": {
|
|
16
|
+
const id = str(event, "messageId")
|
|
17
|
+
if (id !== undefined) run.openReasoningBlocks.set(id, index)
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
case "REASONING_END": {
|
|
22
|
+
const id = str(event, "messageId")
|
|
23
|
+
if (id !== undefined) run.openReasoningBlocks.delete(id)
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
case "REASONING_MESSAGE_START": {
|
|
28
|
+
const id = str(event, "messageId")
|
|
29
|
+
if (id !== undefined) run.openReasoningMessages.set(id, index)
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
case "REASONING_MESSAGE_CONTENT": {
|
|
34
|
+
const id = str(event, "messageId")
|
|
35
|
+
if (id === undefined) return
|
|
36
|
+
const openChunk = run.reasoningChunk !== null && run.reasoningChunk.messageId === id
|
|
37
|
+
if (!run.openReasoningMessages.has(id) && !openChunk) {
|
|
38
|
+
emit("AGUI401", { messageId: id }, { pointer: "/messageId" })
|
|
39
|
+
}
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
case "REASONING_MESSAGE_END": {
|
|
44
|
+
const id = str(event, "messageId")
|
|
45
|
+
if (id !== undefined) run.openReasoningMessages.delete(id)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
case "REASONING_MESSAGE_CHUNK": {
|
|
50
|
+
const id = str(event, "messageId")
|
|
51
|
+
if (id === undefined) {
|
|
52
|
+
if (run.reasoningChunk === null) {
|
|
53
|
+
emit("AGUI504", { type, detail: "first REASONING_MESSAGE_CHUNK must include messageId" }, {
|
|
54
|
+
pointer: "/messageId",
|
|
55
|
+
})
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
if (event.delta === "") run.reasoningChunk = null // documented implicit close
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
if (run.reasoningChunk !== null && run.reasoningChunk.messageId !== id) {
|
|
62
|
+
run.reasoningChunk = null
|
|
63
|
+
}
|
|
64
|
+
if (event.delta === "") {
|
|
65
|
+
run.reasoningChunk = null // empty delta implicitly closes the message
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
if (run.reasoningChunk === null) {
|
|
69
|
+
run.reasoningChunk = { messageId: id, startIndex: index }
|
|
70
|
+
}
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// REASONING_ENCRYPTED_VALUE: schema-checked only; no ordering rules cited.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Chunked reasoning also closes on any non-reasoning event (documented). */
|
|
79
|
+
export function closeReasoningChunk(run: RunState): void {
|
|
80
|
+
run.reasoningChunk = null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** AGUI402 — unterminated reasoning at a clean run end. */
|
|
84
|
+
export function endOfRunReasoning(run: RunState, emit: EmitFn, atIndex: number): void {
|
|
85
|
+
for (const [id, startIndex] of run.openReasoningBlocks) {
|
|
86
|
+
emit("AGUI402", { startType: "REASONING_START", messageId: id }, {
|
|
87
|
+
eventIndex: atIndex,
|
|
88
|
+
relatedEventIndex: startIndex,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
for (const [id, startIndex] of run.openReasoningMessages) {
|
|
92
|
+
emit("AGUI402", { startType: "REASONING_MESSAGE_START", messageId: id }, {
|
|
93
|
+
eventIndex: atIndex,
|
|
94
|
+
relatedEventIndex: startIndex,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// State rules: AGUI301–AGUI305, plus MESSAGES_SNAPSHOT history harvesting.
|
|
2
|
+
|
|
3
|
+
import { applyPatch, validatePatchShape } from "../../protocol/jsonpatch.js"
|
|
4
|
+
import type { CheckApi } from "./context.js"
|
|
5
|
+
|
|
6
|
+
export function handleStateEvent(api: CheckApi): void {
|
|
7
|
+
const { type, event, run, stream, emit } = api
|
|
8
|
+
|
|
9
|
+
switch (type) {
|
|
10
|
+
case "STATE_SNAPSHOT": {
|
|
11
|
+
api.feature("shared-state")
|
|
12
|
+
if (run.state.deltasSinceSnapshot > 0) {
|
|
13
|
+
emit("AGUI304", { deltaCount: run.state.deltasSinceSnapshot }, {})
|
|
14
|
+
}
|
|
15
|
+
run.state.known = true
|
|
16
|
+
run.state.value = event.snapshot
|
|
17
|
+
run.state.snapshotSeen = true
|
|
18
|
+
run.state.deltasSinceSnapshot = 0
|
|
19
|
+
stream.anySnapshot = true
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
case "STATE_DELTA": {
|
|
24
|
+
api.feature("shared-state")
|
|
25
|
+
const delta = event.delta
|
|
26
|
+
if (!Array.isArray(delta)) return // AGUI504 already reported the kind mismatch
|
|
27
|
+
const shape = validatePatchShape(delta)
|
|
28
|
+
if (shape !== null) {
|
|
29
|
+
emit("AGUI303", { error: shape.error }, { pointer: `/delta${shape.pointer}` })
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
if (!run.state.snapshotSeen && !run.state.agui301Fired) {
|
|
33
|
+
// The base may be seeded out-of-band via RunAgentInput.state (SQ-1),
|
|
34
|
+
// so this is informational, and patch application is not judged until
|
|
35
|
+
// a snapshot establishes an observable base.
|
|
36
|
+
emit("AGUI301", {}, {})
|
|
37
|
+
run.state.agui301Fired = true
|
|
38
|
+
}
|
|
39
|
+
if (run.state.known) {
|
|
40
|
+
const applied = applyPatch(run.state.value, delta)
|
|
41
|
+
if (applied.ok) {
|
|
42
|
+
run.state.value = applied.result
|
|
43
|
+
} else {
|
|
44
|
+
emit("AGUI302", { error: applied.error }, { pointer: `/delta/${applied.opIndex}` })
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
run.state.deltasSinceSnapshot += 1
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
case "MESSAGES_SNAPSHOT": {
|
|
52
|
+
// Snapshots can carry history from before this capture: harvest ids so
|
|
53
|
+
// reference checks (AGUI207/AGUI208) don't false-positive on them.
|
|
54
|
+
const messages = event.messages
|
|
55
|
+
if (!Array.isArray(messages)) return
|
|
56
|
+
for (const message of messages) {
|
|
57
|
+
if (typeof message !== "object" || message === null) continue
|
|
58
|
+
const m = message as Record<string, unknown>
|
|
59
|
+
if (typeof m.id === "string") run.knownMessageIds.add(m.id)
|
|
60
|
+
if (Array.isArray(m.toolCalls)) {
|
|
61
|
+
for (const call of m.toolCalls) {
|
|
62
|
+
if (typeof call === "object" && call !== null) {
|
|
63
|
+
const id = (call as Record<string, unknown>).id
|
|
64
|
+
if (typeof id === "string") run.knownToolCallIds.add(id)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Text message rules: AGUI101–AGUI106, plus TEXT_MESSAGE_CHUNK stream handling.
|
|
2
|
+
|
|
3
|
+
import type { CheckApi, RunState, EmitFn } from "./context.js"
|
|
4
|
+
import { str } from "./context.js"
|
|
5
|
+
|
|
6
|
+
export function handleTextEvent(api: CheckApi): void {
|
|
7
|
+
const { type, event, run, emit, index } = api
|
|
8
|
+
api.feature("agentic-chat")
|
|
9
|
+
|
|
10
|
+
switch (type) {
|
|
11
|
+
case "TEXT_MESSAGE_START": {
|
|
12
|
+
const id = str(event, "messageId")
|
|
13
|
+
if (id === undefined) return
|
|
14
|
+
if (run.openMessages.has(id)) {
|
|
15
|
+
emit("AGUI106", { messageId: id }, {
|
|
16
|
+
pointer: "/messageId",
|
|
17
|
+
relatedEventIndex: run.openMessages.get(id)!.startIndex,
|
|
18
|
+
})
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
if (run.closedMessages.has(id)) {
|
|
22
|
+
emit("AGUI104", { messageId: id }, {
|
|
23
|
+
pointer: "/messageId",
|
|
24
|
+
relatedEventIndex: run.closedMessages.get(id)!,
|
|
25
|
+
})
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
run.openMessages.set(id, { startIndex: index })
|
|
29
|
+
run.knownMessageIds.add(id)
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
case "TEXT_MESSAGE_CONTENT": {
|
|
34
|
+
const id = str(event, "messageId")
|
|
35
|
+
if (id === undefined) return
|
|
36
|
+
const open = run.openMessages.get(id)
|
|
37
|
+
if (open === undefined) {
|
|
38
|
+
const closedAt = run.closedMessages.get(id)
|
|
39
|
+
emit("AGUI101", { messageId: id }, {
|
|
40
|
+
pointer: "/messageId",
|
|
41
|
+
...(closedAt !== undefined ? { relatedEventIndex: closedAt } : {}),
|
|
42
|
+
})
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
if (event.delta === "") {
|
|
46
|
+
emit("AGUI105", { messageId: id }, { pointer: "/delta", relatedEventIndex: open.startIndex })
|
|
47
|
+
}
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
case "TEXT_MESSAGE_END": {
|
|
52
|
+
const id = str(event, "messageId")
|
|
53
|
+
if (id === undefined) return
|
|
54
|
+
if (!run.openMessages.has(id)) {
|
|
55
|
+
emit("AGUI102", { messageId: id }, { pointer: "/messageId" })
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
run.openMessages.delete(id)
|
|
59
|
+
run.closedMessages.set(id, index)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
case "TEXT_MESSAGE_CHUNK": {
|
|
64
|
+
const id = str(event, "messageId")
|
|
65
|
+
if (id === undefined) {
|
|
66
|
+
// Continuation chunks may omit messageId; the first chunk must not
|
|
67
|
+
// (docs: "First chunk for a message must include messageId").
|
|
68
|
+
if (run.textChunk === null) {
|
|
69
|
+
emit("AGUI504", { type, detail: "first TEXT_MESSAGE_CHUNK for a message must include messageId" }, {
|
|
70
|
+
pointer: "/messageId",
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
// Mixing chunk and explicit forms on one id is undefined behaviour
|
|
76
|
+
// (SQ-9): tolerated, treated as content for the open message.
|
|
77
|
+
if (run.openMessages.has(id)) return
|
|
78
|
+
if (run.textChunk !== null && run.textChunk.messageId === id) return
|
|
79
|
+
closeTextChunk(run, index)
|
|
80
|
+
if (run.closedMessages.has(id)) {
|
|
81
|
+
emit("AGUI104", { messageId: id }, {
|
|
82
|
+
pointer: "/messageId",
|
|
83
|
+
relatedEventIndex: run.closedMessages.get(id)!,
|
|
84
|
+
})
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
run.textChunk = { messageId: id, startIndex: index }
|
|
88
|
+
run.knownMessageIds.add(id)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Chunk streams close implicitly on the next non-chunk event. */
|
|
95
|
+
export function closeTextChunk(run: RunState, atIndex: number): void {
|
|
96
|
+
if (run.textChunk === null) return
|
|
97
|
+
run.closedMessages.set(run.textChunk.messageId, atIndex)
|
|
98
|
+
run.textChunk = null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** AGUI103 — open messages when the run reaches a clean end. */
|
|
102
|
+
export function endOfRunText(run: RunState, emit: EmitFn, atIndex: number): void {
|
|
103
|
+
for (const [id, open] of run.openMessages) {
|
|
104
|
+
emit("AGUI103", { messageId: id }, {
|
|
105
|
+
eventIndex: atIndex,
|
|
106
|
+
relatedEventIndex: open.startIndex,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Tool call rules: AGUI201–AGUI208, plus TOOL_CALL_CHUNK stream handling.
|
|
2
|
+
|
|
3
|
+
import type { CheckApi, RunState, EmitFn } from "./context.js"
|
|
4
|
+
import { str } from "./context.js"
|
|
5
|
+
|
|
6
|
+
export function handleToolCallEvent(api: CheckApi): void {
|
|
7
|
+
const { type, event, run, emit, index } = api
|
|
8
|
+
api.feature("backend-tool-rendering")
|
|
9
|
+
|
|
10
|
+
switch (type) {
|
|
11
|
+
case "TOOL_CALL_START": {
|
|
12
|
+
const id = str(event, "toolCallId")
|
|
13
|
+
if (id === undefined) return
|
|
14
|
+
if (run.openToolCalls.has(id) || run.closedToolCalls.has(id)) {
|
|
15
|
+
const related = run.openToolCalls.get(id)?.startIndex ?? run.closedToolCalls.get(id)!
|
|
16
|
+
emit("AGUI205", { toolCallId: id }, { pointer: "/toolCallId", relatedEventIndex: related })
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
run.openToolCalls.set(id, { startIndex: index, args: "", sawArgs: false })
|
|
20
|
+
const parent = str(event, "parentMessageId")
|
|
21
|
+
if (parent !== undefined && !run.knownMessageIds.has(parent)) {
|
|
22
|
+
emit("AGUI208", { parentMessageId: parent }, { pointer: "/parentMessageId" })
|
|
23
|
+
}
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
case "TOOL_CALL_ARGS": {
|
|
28
|
+
const id = str(event, "toolCallId")
|
|
29
|
+
if (id === undefined) return
|
|
30
|
+
const open = run.openToolCalls.get(id)
|
|
31
|
+
if (open === undefined) {
|
|
32
|
+
emit("AGUI201", { toolCallId: id }, { pointer: "/toolCallId" })
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
const delta = str(event, "delta")
|
|
36
|
+
if (delta !== undefined) {
|
|
37
|
+
open.args += delta
|
|
38
|
+
open.sawArgs = true
|
|
39
|
+
}
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
case "TOOL_CALL_END": {
|
|
44
|
+
const id = str(event, "toolCallId")
|
|
45
|
+
if (id === undefined) return
|
|
46
|
+
const open = run.openToolCalls.get(id)
|
|
47
|
+
if (open === undefined) {
|
|
48
|
+
emit("AGUI202", { toolCallId: id }, { pointer: "/toolCallId" })
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
run.openToolCalls.delete(id)
|
|
52
|
+
run.closedToolCalls.set(id, index)
|
|
53
|
+
checkArgsJson(id, open, emit, index)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
case "TOOL_CALL_RESULT": {
|
|
58
|
+
const id = str(event, "toolCallId")
|
|
59
|
+
if (id !== undefined) {
|
|
60
|
+
const open = run.openToolCalls.get(id)
|
|
61
|
+
if (open !== undefined) {
|
|
62
|
+
emit("AGUI206", { toolCallId: id }, {
|
|
63
|
+
pointer: "/toolCallId",
|
|
64
|
+
relatedEventIndex: open.startIndex,
|
|
65
|
+
})
|
|
66
|
+
} else if (!run.closedToolCalls.has(id) && !run.knownToolCallIds.has(id)) {
|
|
67
|
+
emit("AGUI207", { toolCallId: id }, { pointer: "/toolCallId" })
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// The result is itself a tool message in the conversation.
|
|
71
|
+
const messageId = str(event, "messageId")
|
|
72
|
+
if (messageId !== undefined) run.knownMessageIds.add(messageId)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
case "TOOL_CALL_CHUNK": {
|
|
77
|
+
const id = str(event, "toolCallId")
|
|
78
|
+
const delta = str(event, "delta")
|
|
79
|
+
if (id === undefined) {
|
|
80
|
+
if (run.toolChunk === null) {
|
|
81
|
+
emit("AGUI504", { type, detail: "first TOOL_CALL_CHUNK for a tool call must include toolCallId and toolCallName" }, {
|
|
82
|
+
pointer: "/toolCallId",
|
|
83
|
+
})
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
if (delta !== undefined) {
|
|
87
|
+
run.toolChunk.args += delta
|
|
88
|
+
run.toolChunk.sawArgs = true
|
|
89
|
+
}
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
// SQ-9: chunk continuing an explicitly-opened call — treat as args.
|
|
93
|
+
const openExplicit = run.openToolCalls.get(id)
|
|
94
|
+
if (openExplicit !== undefined) {
|
|
95
|
+
if (delta !== undefined) {
|
|
96
|
+
openExplicit.args += delta
|
|
97
|
+
openExplicit.sawArgs = true
|
|
98
|
+
}
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
if (run.toolChunk !== null && run.toolChunk.toolCallId === id) {
|
|
102
|
+
if (delta !== undefined) {
|
|
103
|
+
run.toolChunk.args += delta
|
|
104
|
+
run.toolChunk.sawArgs = true
|
|
105
|
+
}
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
closeToolChunk(run, emit, index)
|
|
109
|
+
if (run.closedToolCalls.has(id)) {
|
|
110
|
+
emit("AGUI205", { toolCallId: id }, {
|
|
111
|
+
pointer: "/toolCallId",
|
|
112
|
+
relatedEventIndex: run.closedToolCalls.get(id)!,
|
|
113
|
+
})
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
if (str(event, "toolCallName") === undefined) {
|
|
117
|
+
emit("AGUI504", { type, detail: "first TOOL_CALL_CHUNK for a tool call must include toolCallName" }, {
|
|
118
|
+
pointer: "/toolCallName",
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
run.toolChunk = {
|
|
122
|
+
toolCallId: id,
|
|
123
|
+
startIndex: index,
|
|
124
|
+
args: delta ?? "",
|
|
125
|
+
sawArgs: delta !== undefined && delta.length > 0,
|
|
126
|
+
}
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function checkArgsJson(
|
|
133
|
+
id: string,
|
|
134
|
+
call: { startIndex: number; args: string; sawArgs: boolean },
|
|
135
|
+
emit: EmitFn,
|
|
136
|
+
atIndex: number,
|
|
137
|
+
): void {
|
|
138
|
+
// A call that streamed no args at all is fine (a no-argument tool).
|
|
139
|
+
if (!call.sawArgs || call.args.length === 0) return
|
|
140
|
+
try {
|
|
141
|
+
JSON.parse(call.args)
|
|
142
|
+
} catch (e) {
|
|
143
|
+
emit("AGUI204", { toolCallId: id, error: e instanceof Error ? e.message : String(e) }, {
|
|
144
|
+
eventIndex: atIndex,
|
|
145
|
+
relatedEventIndex: call.startIndex,
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Chunk streams close implicitly on the next non-chunk event. */
|
|
151
|
+
export function closeToolChunk(run: RunState, emit: EmitFn, atIndex: number): void {
|
|
152
|
+
if (run.toolChunk === null) return
|
|
153
|
+
const { toolCallId, startIndex, args, sawArgs } = run.toolChunk
|
|
154
|
+
run.toolChunk = null
|
|
155
|
+
run.closedToolCalls.set(toolCallId, atIndex)
|
|
156
|
+
checkArgsJson(toolCallId, { startIndex, args, sawArgs }, emit, atIndex)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** AGUI203 — open tool calls when the run reaches a clean end. */
|
|
160
|
+
export function endOfRunToolCalls(run: RunState, emit: EmitFn, atIndex: number): void {
|
|
161
|
+
for (const [id, open] of run.openToolCalls) {
|
|
162
|
+
emit("AGUI203", { toolCallId: id }, {
|
|
163
|
+
eventIndex: atIndex,
|
|
164
|
+
relatedEventIndex: open.startIndex,
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Transport rules (AGUI501, AGUI505–AGUI508) are only checkable against a
|
|
2
|
+
// live connection: SSE framing, Content-Type, keepalive timing, flush
|
|
3
|
+
// behaviour, abnormal EOF. The core cannot observe any of that from a parsed
|
|
4
|
+
// event sequence, so it reports these rules as skipped — never silently —
|
|
5
|
+
// and the transport layer (ag-ui-validate/transport, M5) evaluates them.
|
|
6
|
+
//
|
|
7
|
+
// AGUI502/503/504 are transport-adjacent but *are* checkable here (the core
|
|
8
|
+
// accepts raw JSON strings), so they live in the engine, not this list.
|
|
9
|
+
|
|
10
|
+
import { CATALOG } from "../catalog.js"
|
|
11
|
+
|
|
12
|
+
export const TRANSPORT_SKIP_REASON =
|
|
13
|
+
"transport-layer rule; only checkable against a live connection (validated by ag-ui-validate/transport)"
|
|
14
|
+
|
|
15
|
+
export const TRANSPORT_RULE_IDS: readonly string[] = CATALOG.rules
|
|
16
|
+
.filter((r) => r.checkedIn === "transport")
|
|
17
|
+
.map((r) => r.id)
|