@nifrajs/agent-protocol 4.0.0 → 5.0.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/README.md +47 -0
- package/dist/conformance.d.ts +41 -0
- package/dist/conformance.d.ts.map +1 -0
- package/dist/conformance.js +393 -0
- package/dist/conformance.js.map +1 -0
- package/dist/index.d.ts +97 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +348 -92
- package/dist/index.js.map +1 -1
- package/package.json +6 -1
- package/src/conformance.ts +490 -0
- package/src/index.ts +467 -94
package/src/index.ts
CHANGED
|
@@ -16,6 +16,9 @@ export type AgentEventType =
|
|
|
16
16
|
| "turn.started"
|
|
17
17
|
| "assistant.delta"
|
|
18
18
|
| "assistant.message"
|
|
19
|
+
| "reasoning.delta"
|
|
20
|
+
| "plan.updated"
|
|
21
|
+
| "context.usage"
|
|
19
22
|
| "tool.started"
|
|
20
23
|
| "tool.delta"
|
|
21
24
|
| "tool.completed"
|
|
@@ -28,6 +31,13 @@ export type AgentEventType =
|
|
|
28
31
|
| "session.completed"
|
|
29
32
|
| "session.failed"
|
|
30
33
|
| "session.stopped"
|
|
34
|
+
| AgentExtensionEventType
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The type of an event the protocol does not define: `x-<vendor>.<name>`, lowercase, at most 64
|
|
38
|
+
* characters, such as `x-codex.plan` or `x-acme.context.usage`. See {@link isAgentExtensionEventType}.
|
|
39
|
+
*/
|
|
40
|
+
export type AgentExtensionEventType = `x-${string}`
|
|
31
41
|
|
|
32
42
|
export interface AgentSessionSnapshot {
|
|
33
43
|
readonly version: typeof AGENT_PROTOCOL_VERSION
|
|
@@ -85,6 +95,48 @@ export interface AgentAssistantMessageEvent extends AgentSessionEventBase {
|
|
|
85
95
|
readonly text: string
|
|
86
96
|
}
|
|
87
97
|
|
|
98
|
+
/** A piece of the model's reasoning. `itemId` groups the pieces of one reasoning block. */
|
|
99
|
+
export interface AgentReasoningDeltaEvent extends AgentSessionEventBase {
|
|
100
|
+
readonly type: "reasoning.delta"
|
|
101
|
+
readonly turnId: string
|
|
102
|
+
readonly itemId?: string
|
|
103
|
+
readonly text: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type AgentPlanEntryStatus = "pending" | "in_progress" | "completed"
|
|
107
|
+
|
|
108
|
+
export interface AgentPlanEntry {
|
|
109
|
+
readonly content: string
|
|
110
|
+
readonly status: AgentPlanEntryStatus
|
|
111
|
+
readonly priority?: "high" | "medium" | "low"
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The agent's whole current plan: each event replaces the one before. */
|
|
115
|
+
export interface AgentPlanUpdatedEvent extends AgentSessionEventBase {
|
|
116
|
+
readonly type: "plan.updated"
|
|
117
|
+
readonly turnId: string
|
|
118
|
+
/** The tool call that carried the plan, when a tool did. */
|
|
119
|
+
readonly callId?: string
|
|
120
|
+
readonly explanation?: string
|
|
121
|
+
readonly entries: readonly AgentPlanEntry[]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** How full the model's context is after its latest request. Token counts only. */
|
|
125
|
+
export interface AgentContextUsageEvent extends AgentSessionEventBase {
|
|
126
|
+
readonly type: "context.usage"
|
|
127
|
+
readonly turnId?: string
|
|
128
|
+
/** Tokens the latest request used: its prompt plus its answer. */
|
|
129
|
+
readonly usedTokens: number
|
|
130
|
+
/** The model's context window, when known. */
|
|
131
|
+
readonly windowTokens?: number
|
|
132
|
+
readonly inputTokens?: number
|
|
133
|
+
readonly outputTokens?: number
|
|
134
|
+
readonly cachedInputTokens?: number
|
|
135
|
+
readonly reasoningTokens?: number
|
|
136
|
+
/** True when the provider reported no usage and the counts are an estimate. */
|
|
137
|
+
readonly estimated?: boolean
|
|
138
|
+
}
|
|
139
|
+
|
|
88
140
|
export interface AgentToolStartedEvent extends AgentSessionEventBase {
|
|
89
141
|
readonly type: "tool.started"
|
|
90
142
|
readonly turnId: string
|
|
@@ -181,12 +233,25 @@ export interface AgentSessionStoppedEvent extends AgentSessionEventBase {
|
|
|
181
233
|
readonly reason?: string
|
|
182
234
|
}
|
|
183
235
|
|
|
236
|
+
/**
|
|
237
|
+
* A backend's own event, such as a vendor's plan or reasoning frames. Hosts that do not know the
|
|
238
|
+
* type pass it through; `data` is a display copy inside the payload bounds, like a tool's output.
|
|
239
|
+
*/
|
|
240
|
+
export interface AgentExtensionEvent extends AgentSessionEventBase {
|
|
241
|
+
readonly type: AgentExtensionEventType
|
|
242
|
+
readonly turnId?: string
|
|
243
|
+
readonly data?: unknown
|
|
244
|
+
}
|
|
245
|
+
|
|
184
246
|
export type AgentEvent =
|
|
185
247
|
| AgentSessionStartedEvent
|
|
186
248
|
| AgentSessionUpdatedEvent
|
|
187
249
|
| AgentTurnStartedEvent
|
|
188
250
|
| AgentAssistantDeltaEvent
|
|
189
251
|
| AgentAssistantMessageEvent
|
|
252
|
+
| AgentReasoningDeltaEvent
|
|
253
|
+
| AgentPlanUpdatedEvent
|
|
254
|
+
| AgentContextUsageEvent
|
|
190
255
|
| AgentToolStartedEvent
|
|
191
256
|
| AgentToolDeltaEvent
|
|
192
257
|
| AgentToolCompletedEvent
|
|
@@ -199,6 +264,7 @@ export type AgentEvent =
|
|
|
199
264
|
| AgentSessionCompletedEvent
|
|
200
265
|
| AgentSessionFailedEvent
|
|
201
266
|
| AgentSessionStoppedEvent
|
|
267
|
+
| AgentExtensionEvent
|
|
202
268
|
|
|
203
269
|
/** Event payload before the transport adds protocol version, session id, sequence, and timestamp. */
|
|
204
270
|
export type AgentEventPayload =
|
|
@@ -207,6 +273,9 @@ export type AgentEventPayload =
|
|
|
207
273
|
| Omit<AgentTurnStartedEvent, "version" | "sessionId" | "seq" | "at">
|
|
208
274
|
| Omit<AgentAssistantDeltaEvent, "version" | "sessionId" | "seq" | "at">
|
|
209
275
|
| Omit<AgentAssistantMessageEvent, "version" | "sessionId" | "seq" | "at">
|
|
276
|
+
| Omit<AgentReasoningDeltaEvent, "version" | "sessionId" | "seq" | "at">
|
|
277
|
+
| Omit<AgentPlanUpdatedEvent, "version" | "sessionId" | "seq" | "at">
|
|
278
|
+
| Omit<AgentContextUsageEvent, "version" | "sessionId" | "seq" | "at">
|
|
210
279
|
| Omit<AgentToolStartedEvent, "version" | "sessionId" | "seq" | "at">
|
|
211
280
|
| Omit<AgentToolDeltaEvent, "version" | "sessionId" | "seq" | "at">
|
|
212
281
|
| Omit<AgentToolCompletedEvent, "version" | "sessionId" | "seq" | "at">
|
|
@@ -219,6 +288,7 @@ export type AgentEventPayload =
|
|
|
219
288
|
| Omit<AgentSessionCompletedEvent, "version" | "sessionId" | "seq" | "at">
|
|
220
289
|
| Omit<AgentSessionFailedEvent, "version" | "sessionId" | "seq" | "at">
|
|
221
290
|
| Omit<AgentSessionStoppedEvent, "version" | "sessionId" | "seq" | "at">
|
|
291
|
+
| Omit<AgentExtensionEvent, "version" | "sessionId" | "seq" | "at">
|
|
222
292
|
|
|
223
293
|
export interface AgentError {
|
|
224
294
|
readonly code: string
|
|
@@ -274,7 +344,11 @@ export interface AgentBackend {
|
|
|
274
344
|
fork?(input: ForkSessionInput): Promise<ForkSessionResult>
|
|
275
345
|
checkpoint?(sessionId: string, payload?: unknown): Promise<AgentSessionCheckpoint>
|
|
276
346
|
reload(sessionId: string): Promise<ReloadResult>
|
|
277
|
-
/**
|
|
347
|
+
/**
|
|
348
|
+
* Resolve an approval surfaced by a backend UI/tool, when that backend supports interactive
|
|
349
|
+
* approvals. Returns a boolean once the backend has taken the answer, or `undefined` when it does not
|
|
350
|
+
* know the approval (never asked, already settled, or its turn ended).
|
|
351
|
+
*/
|
|
278
352
|
resolveApproval?(
|
|
279
353
|
sessionId: string,
|
|
280
354
|
approvalId: string,
|
|
@@ -296,9 +370,13 @@ export interface AgentEventStream extends AgentEventSink, AsyncIterableIterator<
|
|
|
296
370
|
|
|
297
371
|
const MAX_AGENT_EVENT_QUEUE_SIZE = 65_536
|
|
298
372
|
|
|
373
|
+
// The final `assistant.message` / `tool.completed` carries what the deltas streamed.
|
|
374
|
+
const isTransientEvent = (event: AgentEvent): boolean =>
|
|
375
|
+
event.type === "assistant.delta" || event.type === "tool.delta"
|
|
376
|
+
|
|
299
377
|
/**
|
|
300
|
-
* Small bounded event stream for RPC clients and UIs.
|
|
301
|
-
*
|
|
378
|
+
* Small bounded event stream for RPC clients and UIs. When a consumer falls behind, the oldest
|
|
379
|
+
* delta goes first; a lifecycle event is dropped only once no delta is left to drop.
|
|
302
380
|
*/
|
|
303
381
|
export function createAgentEventStream(maxQueueSize = 256): AgentEventStream {
|
|
304
382
|
if (
|
|
@@ -341,8 +419,11 @@ export function createAgentEventStream(maxQueueSize = 256): AgentEventStream {
|
|
|
341
419
|
return
|
|
342
420
|
}
|
|
343
421
|
if (queue.length >= maxQueueSize) {
|
|
344
|
-
queue.shift()
|
|
345
422
|
dropped += 1
|
|
423
|
+
const at = queue.findIndex(isTransientEvent)
|
|
424
|
+
if (at >= 0) queue.splice(at, 1)
|
|
425
|
+
else if (isTransientEvent(event)) return
|
|
426
|
+
else queue.shift()
|
|
346
427
|
}
|
|
347
428
|
queue.push(event)
|
|
348
429
|
},
|
|
@@ -401,6 +482,8 @@ export * from "./run-lifecycle.ts"
|
|
|
401
482
|
export * from "./transport-policy.ts"
|
|
402
483
|
|
|
403
484
|
const AGENT_TOKEN_RE = /^[A-Za-z0-9._:/-]{1,128}$/
|
|
485
|
+
const AGENT_EXTENSION_TYPE_RE = /^x-[a-z0-9]+(?:-[a-z0-9]+)*\.[a-z0-9]+(?:[._-][a-z0-9]+)*$/
|
|
486
|
+
const AGENT_MAX_EXTENSION_TYPE = 64
|
|
404
487
|
const AGENT_MAX_TEXT = 256 * 1024
|
|
405
488
|
const AGENT_MAX_PAYLOAD_TEXT = 64 * 1024
|
|
406
489
|
const AGENT_MAX_ARRAY = 256
|
|
@@ -414,6 +497,9 @@ const AGENT_STATUSES: ReadonlySet<string> = new Set([
|
|
|
414
497
|
"failed",
|
|
415
498
|
"stopped",
|
|
416
499
|
])
|
|
500
|
+
const AGENT_PLAN_STATUSES: ReadonlySet<string> = new Set(["pending", "in_progress", "completed"])
|
|
501
|
+
const AGENT_PLAN_PRIORITIES: ReadonlySet<string> = new Set(["high", "medium", "low"])
|
|
502
|
+
const AGENT_MAX_PLAN_ENTRY = 4_096
|
|
417
503
|
const AGENT_COMPACTION_REASONS: ReadonlySet<string> = new Set([
|
|
418
504
|
"manual",
|
|
419
505
|
"threshold",
|
|
@@ -421,6 +507,164 @@ const AGENT_COMPACTION_REASONS: ReadonlySet<string> = new Set([
|
|
|
421
507
|
"workflow",
|
|
422
508
|
])
|
|
423
509
|
|
|
510
|
+
/** Longest `text`, `prompt`, or error message an event may carry, in UTF-16 code units. */
|
|
511
|
+
export const AGENT_MAX_EVENT_TEXT = AGENT_MAX_TEXT
|
|
512
|
+
/** Longest string an event's `input`, `output`, or `details` value may carry. */
|
|
513
|
+
export const AGENT_MAX_PAYLOAD_STRING = AGENT_MAX_PAYLOAD_TEXT
|
|
514
|
+
|
|
515
|
+
const AGENT_TRUNCATED = "…[truncated]"
|
|
516
|
+
const AGENT_REPLACEMENT = String.fromCodePoint(0xfffd)
|
|
517
|
+
// Soft budget for the strings of one bounded value, so a display copy stays near one text field.
|
|
518
|
+
const AGENT_MAX_VALUE_CHARS = AGENT_MAX_TEXT
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Split text into pieces that each fit an event's text field, so a long reply travels as
|
|
522
|
+
* consecutive events instead of failing validation. Joined, the pieces give back the text with
|
|
523
|
+
* each NUL replaced by U+FFFD. A piece ends after a line break when one falls in its last
|
|
524
|
+
* quarter, and never between the halves of a surrogate pair. Empty text gives one empty piece.
|
|
525
|
+
*/
|
|
526
|
+
export function splitAgentText(text: string, maxChars: number = AGENT_MAX_TEXT): string[] {
|
|
527
|
+
if (!Number.isSafeInteger(maxChars) || maxChars < 2 || maxChars > AGENT_MAX_TEXT)
|
|
528
|
+
throw new RangeError(`splitAgentText: maxChars must be between 2 and ${AGENT_MAX_TEXT}`)
|
|
529
|
+
const clean = text.replaceAll("\0", AGENT_REPLACEMENT)
|
|
530
|
+
if (clean.length <= maxChars) return [clean]
|
|
531
|
+
const pieces: string[] = []
|
|
532
|
+
let start = 0
|
|
533
|
+
while (clean.length - start > maxChars) {
|
|
534
|
+
let end = start + maxChars
|
|
535
|
+
const searchFrom = start + Math.floor((maxChars * 3) / 4)
|
|
536
|
+
const lineEnd = clean.slice(searchFrom, end).lastIndexOf("\n")
|
|
537
|
+
if (lineEnd !== -1) end = searchFrom + lineEnd + 1
|
|
538
|
+
else if (isHighSurrogate(clean.charCodeAt(end - 1)) && isLowSurrogate(clean.charCodeAt(end)))
|
|
539
|
+
end -= 1
|
|
540
|
+
pieces.push(clean.slice(start, end))
|
|
541
|
+
start = end
|
|
542
|
+
}
|
|
543
|
+
pieces.push(clean.slice(start))
|
|
544
|
+
return pieces
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Bound text for one event field: NUL becomes U+FFFD and text past `maxChars` is cut, ending
|
|
549
|
+
* in a truncation marker, never between the halves of a surrogate pair.
|
|
550
|
+
*/
|
|
551
|
+
export function boundAgentText(text: string, maxChars: number = AGENT_MAX_TEXT): string {
|
|
552
|
+
if (
|
|
553
|
+
!Number.isSafeInteger(maxChars) ||
|
|
554
|
+
maxChars < AGENT_TRUNCATED.length ||
|
|
555
|
+
maxChars > AGENT_MAX_TEXT
|
|
556
|
+
)
|
|
557
|
+
throw new RangeError(
|
|
558
|
+
`boundAgentText: maxChars must be between ${AGENT_TRUNCATED.length} and ${AGENT_MAX_TEXT}`,
|
|
559
|
+
)
|
|
560
|
+
const clean = text.replaceAll("\0", AGENT_REPLACEMENT)
|
|
561
|
+
return clean.length <= maxChars ? clean : truncateAgentText(clean, maxChars)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* A copy of a JSON-like value that fits an event's `input`, `output`, or `details`, for display
|
|
566
|
+
* only: the tool, model, or store keeps the full value. Strings are cut with a truncation marker
|
|
567
|
+
* (and their NULs replaced); arrays keep their first items and end with a marker string; objects
|
|
568
|
+
* keep their first keys and drop entries whose key the protocol refuses; a container too deep
|
|
569
|
+
* becomes a marker string; numbers past the safe-integer range become strings, non-finite ones
|
|
570
|
+
* null; functions, symbols, bigints, and `undefined` are dropped (null inside an array). Returns
|
|
571
|
+
* `undefined` when the value itself is one of those.
|
|
572
|
+
*/
|
|
573
|
+
export function boundAgentValue(value: unknown): unknown {
|
|
574
|
+
const copy = copyBoundedAgentValue(value, 0, { nodes: 2_048, chars: AGENT_MAX_VALUE_CHARS })
|
|
575
|
+
return copy === AGENT_OMIT ? undefined : copy
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
interface AgentValueBudget {
|
|
579
|
+
nodes: number
|
|
580
|
+
chars: number
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const AGENT_OMIT: unique symbol = Symbol("omit")
|
|
584
|
+
|
|
585
|
+
function copyBoundedAgentValue(
|
|
586
|
+
value: unknown,
|
|
587
|
+
depth: number,
|
|
588
|
+
budget: AgentValueBudget,
|
|
589
|
+
): unknown | typeof AGENT_OMIT {
|
|
590
|
+
if (
|
|
591
|
+
value === undefined ||
|
|
592
|
+
typeof value === "function" ||
|
|
593
|
+
typeof value === "symbol" ||
|
|
594
|
+
typeof value === "bigint"
|
|
595
|
+
)
|
|
596
|
+
return AGENT_OMIT
|
|
597
|
+
if (budget.nodes <= 0) return AGENT_OMIT
|
|
598
|
+
budget.nodes -= 1
|
|
599
|
+
if (value === null || typeof value === "boolean") return value
|
|
600
|
+
if (typeof value === "number") {
|
|
601
|
+
if (!Number.isFinite(value)) return null
|
|
602
|
+
return Math.abs(value) <= Number.MAX_SAFE_INTEGER ? value : String(value)
|
|
603
|
+
}
|
|
604
|
+
if (typeof value === "string") {
|
|
605
|
+
const clean = value.replaceAll("\0", AGENT_REPLACEMENT)
|
|
606
|
+
const limit = Math.max(AGENT_TRUNCATED.length, Math.min(AGENT_MAX_PAYLOAD_TEXT, budget.chars))
|
|
607
|
+
const bounded = clean.length <= limit ? clean : truncateAgentText(clean, limit)
|
|
608
|
+
budget.chars = Math.max(0, budget.chars - bounded.length)
|
|
609
|
+
return bounded
|
|
610
|
+
}
|
|
611
|
+
if (typeof value !== "object") return AGENT_OMIT
|
|
612
|
+
if (Array.isArray(value)) {
|
|
613
|
+
if (depth >= AGENT_MAX_PAYLOAD_DEPTH) return value.length === 0 ? [] : AGENT_TRUNCATED
|
|
614
|
+
const copy: unknown[] = []
|
|
615
|
+
for (const [index, item] of value.entries()) {
|
|
616
|
+
if (copy.length === AGENT_MAX_ARRAY - 1 && value.length > AGENT_MAX_ARRAY) {
|
|
617
|
+
const marker = copyBoundedAgentValue(
|
|
618
|
+
`…[${value.length - index} more items]`,
|
|
619
|
+
depth + 1,
|
|
620
|
+
budget,
|
|
621
|
+
)
|
|
622
|
+
if (marker !== AGENT_OMIT) copy.push(marker)
|
|
623
|
+
break
|
|
624
|
+
}
|
|
625
|
+
// An item JSON has no form for stands as null, which costs a node like any other.
|
|
626
|
+
const child = copyBoundedAgentValue(item, depth + 1, budget)
|
|
627
|
+
const kept = child === AGENT_OMIT ? copyBoundedAgentValue(null, depth + 1, budget) : child
|
|
628
|
+
if (kept === AGENT_OMIT) break
|
|
629
|
+
copy.push(kept)
|
|
630
|
+
}
|
|
631
|
+
return copy
|
|
632
|
+
}
|
|
633
|
+
const entries = Object.entries(value)
|
|
634
|
+
if (depth >= AGENT_MAX_PAYLOAD_DEPTH) return entries.length === 0 ? {} : AGENT_TRUNCATED
|
|
635
|
+
const copy: Record<string, unknown> = {}
|
|
636
|
+
let keys = 0
|
|
637
|
+
for (const [key, item] of entries) {
|
|
638
|
+
if (keys === AGENT_MAX_OBJECT_KEYS || budget.nodes <= 0) break
|
|
639
|
+
if (!agentText(key, 128, false)) continue
|
|
640
|
+
const child = copyBoundedAgentValue(item, depth + 1, budget)
|
|
641
|
+
if (child === AGENT_OMIT) continue
|
|
642
|
+
// A "__proto__" key from parsed JSON must stay an own entry, not replace the prototype.
|
|
643
|
+
Object.defineProperty(copy, key, {
|
|
644
|
+
value: child,
|
|
645
|
+
enumerable: true,
|
|
646
|
+
writable: true,
|
|
647
|
+
configurable: true,
|
|
648
|
+
})
|
|
649
|
+
keys += 1
|
|
650
|
+
}
|
|
651
|
+
return copy
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function truncateAgentText(text: string, maxChars: number): string {
|
|
655
|
+
let end = Math.max(0, maxChars - AGENT_TRUNCATED.length)
|
|
656
|
+
if (end > 0 && isHighSurrogate(text.charCodeAt(end - 1))) end -= 1
|
|
657
|
+
return `${text.slice(0, end)}${AGENT_TRUNCATED}`
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function isHighSurrogate(code: number): boolean {
|
|
661
|
+
return code >= 0xd800 && code <= 0xdbff
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function isLowSurrogate(code: number): boolean {
|
|
665
|
+
return code >= 0xdc00 && code <= 0xdfff
|
|
666
|
+
}
|
|
667
|
+
|
|
424
668
|
function agentRecord(value: unknown): value is Record<string, unknown> {
|
|
425
669
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
426
670
|
}
|
|
@@ -463,144 +707,273 @@ function boundedAgentValue(
|
|
|
463
707
|
)
|
|
464
708
|
}
|
|
465
709
|
|
|
466
|
-
|
|
710
|
+
type AgentProblem = string | undefined
|
|
711
|
+
|
|
712
|
+
function tokenProblem(value: unknown, field: string): AgentProblem {
|
|
713
|
+
return agentToken(value) ? undefined : `${field} must be 1-128 characters of [A-Za-z0-9._:/-]`
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function optionalTokenProblem(value: unknown, field: string): AgentProblem {
|
|
717
|
+
return value === undefined ? undefined : tokenProblem(value, field)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function textProblem(
|
|
721
|
+
value: unknown,
|
|
722
|
+
field: string,
|
|
723
|
+
max = AGENT_MAX_TEXT,
|
|
724
|
+
allowEmpty = true,
|
|
725
|
+
): AgentProblem {
|
|
726
|
+
if (typeof value !== "string") return `${field} must be a string`
|
|
727
|
+
if (value.length > max) return `${field} is longer than ${max} characters`
|
|
728
|
+
if (!allowEmpty && value.length === 0) return `${field} must not be empty`
|
|
729
|
+
return value.includes("\0") ? `${field} contains NUL` : undefined
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function optionalTextProblem(value: unknown, field: string): AgentProblem {
|
|
733
|
+
return value === undefined ? undefined : textProblem(value, field)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function countProblem(value: unknown, field: string): AgentProblem {
|
|
737
|
+
return agentCount(value) ? undefined : `${field} must be a non-negative safe integer`
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function booleanProblem(value: unknown, field: string): AgentProblem {
|
|
741
|
+
return typeof value === "boolean" ? undefined : `${field} must be a boolean`
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function tokenListProblem(value: unknown, field: string): AgentProblem {
|
|
745
|
+
if (!Array.isArray(value) || value.length > AGENT_MAX_ARRAY)
|
|
746
|
+
return `${field} must be an array of at most ${AGENT_MAX_ARRAY} tokens`
|
|
747
|
+
return value.every(agentToken) ? undefined : `${field} must hold only tokens`
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function valueProblem(record: Record<string, unknown>, key: string, field: string): AgentProblem {
|
|
467
751
|
return !Object.hasOwn(record, key) || boundedAgentValue(record[key])
|
|
752
|
+
? undefined
|
|
753
|
+
: `${field} is outside the payload bounds (strings ${AGENT_MAX_PAYLOAD_TEXT} characters, ${AGENT_MAX_ARRAY} items or keys, depth ${AGENT_MAX_PAYLOAD_DEPTH})`
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function optionalCountProblem(value: unknown, field: string): AgentProblem {
|
|
757
|
+
return value === undefined ? undefined : countProblem(value, field)
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function planEntriesProblem(value: unknown, field: string): AgentProblem {
|
|
761
|
+
if (!Array.isArray(value) || value.length > AGENT_MAX_ARRAY)
|
|
762
|
+
return `${field} must be an array of at most ${AGENT_MAX_ARRAY} entries`
|
|
763
|
+
for (const [index, entry] of value.entries()) {
|
|
764
|
+
const at = `${field}[${index}]`
|
|
765
|
+
if (!agentRecord(entry)) return `${at} must be an object`
|
|
766
|
+
const problem =
|
|
767
|
+
textProblem(entry.content, `${at}.content`, AGENT_MAX_PLAN_ENTRY) ??
|
|
768
|
+
(typeof entry.status === "string" && AGENT_PLAN_STATUSES.has(entry.status)
|
|
769
|
+
? undefined
|
|
770
|
+
: `${at}.status must be one of ${[...AGENT_PLAN_STATUSES].join(", ")}`) ??
|
|
771
|
+
(entry.priority === undefined ||
|
|
772
|
+
(typeof entry.priority === "string" && AGENT_PLAN_PRIORITIES.has(entry.priority))
|
|
773
|
+
? undefined
|
|
774
|
+
: `${at}.priority must be one of ${[...AGENT_PLAN_PRIORITIES].join(", ")}`)
|
|
775
|
+
if (problem !== undefined) return problem
|
|
776
|
+
}
|
|
777
|
+
return undefined
|
|
468
778
|
}
|
|
469
779
|
|
|
470
|
-
function
|
|
471
|
-
if (!agentRecord(value)) return
|
|
780
|
+
function errorProblem(value: unknown, field: string): AgentProblem {
|
|
781
|
+
if (!agentRecord(value)) return `${field} must be an object`
|
|
472
782
|
return (
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
783
|
+
tokenProblem(value.code, `${field}.code`) ??
|
|
784
|
+
textProblem(value.message, `${field}.message`, AGENT_MAX_TEXT, false) ??
|
|
785
|
+
valueProblem(value, "details", `${field}.details`)
|
|
476
786
|
)
|
|
477
787
|
}
|
|
478
788
|
|
|
479
|
-
function
|
|
480
|
-
if (!agentRecord(value)) return
|
|
481
|
-
if (
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
typeof value.status
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
value.capabilities.
|
|
494
|
-
|
|
789
|
+
function snapshotProblem(value: unknown, sessionId: unknown, field: string): AgentProblem {
|
|
790
|
+
if (!agentRecord(value)) return `${field} must be an object`
|
|
791
|
+
if (value.version !== AGENT_PROTOCOL_VERSION)
|
|
792
|
+
return `${field}.version must be ${AGENT_PROTOCOL_VERSION}`
|
|
793
|
+
if (value.id !== sessionId) return `${field}.id must be the event's sessionId`
|
|
794
|
+
return (
|
|
795
|
+
tokenProblem(value.backend, `${field}.backend`) ??
|
|
796
|
+
textProblem(value.cwd, `${field}.cwd`, 4_096, false) ??
|
|
797
|
+
(typeof value.status === "string" && AGENT_STATUSES.has(value.status)
|
|
798
|
+
? undefined
|
|
799
|
+
: `${field}.status must be one of ${[...AGENT_STATUSES].join(", ")}`) ??
|
|
800
|
+
countProblem(value.createdAt, `${field}.createdAt`) ??
|
|
801
|
+
countProblem(value.updatedAt, `${field}.updatedAt`) ??
|
|
802
|
+
countProblem(value.lastSeq, `${field}.lastSeq`) ??
|
|
803
|
+
tokenListProblem(value.capabilities, `${field}.capabilities`) ??
|
|
804
|
+
optionalTokenProblem(value.activeTurnId, `${field}.activeTurnId`) ??
|
|
805
|
+
optionalTokenProblem(value.extensionRevision, `${field}.extensionRevision`)
|
|
495
806
|
)
|
|
496
|
-
return false
|
|
497
|
-
if (value.activeTurnId !== undefined && !agentToken(value.activeTurnId)) return false
|
|
498
|
-
if (value.extensionRevision !== undefined && !agentToken(value.extensionRevision)) return false
|
|
499
|
-
return true
|
|
500
807
|
}
|
|
501
808
|
|
|
502
|
-
function
|
|
503
|
-
if (!agentRecord(value)) return
|
|
809
|
+
function repairTaskProblem(value: unknown, field: string): AgentProblem {
|
|
810
|
+
if (!agentRecord(value)) return `${field} must be an object`
|
|
504
811
|
return (
|
|
505
|
-
|
|
812
|
+
tokenProblem(value.id, `${field}.id`) ??
|
|
506
813
|
(value.verification === "check" ||
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
value.
|
|
513
|
-
value.capabilities.
|
|
514
|
-
(value.output
|
|
515
|
-
|
|
814
|
+
value.verification === "assure" ||
|
|
815
|
+
value.verification === "test"
|
|
816
|
+
? undefined
|
|
817
|
+
: `${field}.verification must be check, assure, or test`) ??
|
|
818
|
+
textProblem(value.cwd, `${field}.cwd`, 4_096, false) ??
|
|
819
|
+
textProblem(value.reason, `${field}.reason`, AGENT_MAX_TEXT, false) ??
|
|
820
|
+
tokenListProblem(value.capabilities, `${field}.capabilities`) ??
|
|
821
|
+
optionalTextProblem(value.output, `${field}.output`) ??
|
|
822
|
+
valueProblem(value, "report", `${field}.report`)
|
|
823
|
+
)
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Whether `type` names an extension event: `x-<vendor>.<name>` in lowercase letters, digits, and
|
|
828
|
+
* single `-`, `.`, or `_` separators, at most 64 characters.
|
|
829
|
+
*/
|
|
830
|
+
export function isAgentExtensionEventType(type: unknown): type is AgentExtensionEventType {
|
|
831
|
+
return (
|
|
832
|
+
typeof type === "string" &&
|
|
833
|
+
type.length <= AGENT_MAX_EXTENSION_TYPE &&
|
|
834
|
+
AGENT_EXTENSION_TYPE_RE.test(type)
|
|
516
835
|
)
|
|
517
836
|
}
|
|
518
837
|
|
|
519
838
|
/** Validate the complete discriminated event union before a projection or UI consumer sees it. */
|
|
520
839
|
export function isAgentEvent(value: unknown): value is AgentEvent {
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
840
|
+
return agentEventProblem(value) === undefined
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Why `value` is not a valid agent event, naming the field and the limit it broke but never its
|
|
845
|
+
* content, so the text is safe to log or show; `undefined` when it is valid.
|
|
846
|
+
*/
|
|
847
|
+
export function agentEventProblem(value: unknown): string | undefined {
|
|
848
|
+
if (!agentRecord(value)) return "event must be an object"
|
|
849
|
+
if (value.version !== AGENT_PROTOCOL_VERSION) return `version must be ${AGENT_PROTOCOL_VERSION}`
|
|
850
|
+
const base =
|
|
851
|
+
tokenProblem(value.sessionId, "sessionId") ??
|
|
852
|
+
countProblem(value.seq, "seq") ??
|
|
853
|
+
countProblem(value.at, "at")
|
|
854
|
+
if (base !== undefined) return base
|
|
855
|
+
if (typeof value.type !== "string") return "type must be a string"
|
|
856
|
+
const type = value.type
|
|
857
|
+
switch (type) {
|
|
531
858
|
case "session.started":
|
|
532
859
|
case "session.updated":
|
|
533
860
|
case "session.completed":
|
|
534
|
-
return
|
|
861
|
+
return snapshotProblem(value.snapshot, value.sessionId, `${type}.snapshot`)
|
|
535
862
|
case "turn.started":
|
|
536
|
-
return
|
|
863
|
+
return (
|
|
864
|
+
tokenProblem(value.turnId, `${type}.turnId`) ?? textProblem(value.prompt, `${type}.prompt`)
|
|
865
|
+
)
|
|
537
866
|
case "assistant.delta":
|
|
538
867
|
case "assistant.message":
|
|
539
|
-
return
|
|
868
|
+
return tokenProblem(value.turnId, `${type}.turnId`) ?? textProblem(value.text, `${type}.text`)
|
|
869
|
+
case "reasoning.delta":
|
|
870
|
+
return (
|
|
871
|
+
tokenProblem(value.turnId, `${type}.turnId`) ??
|
|
872
|
+
optionalTokenProblem(value.itemId, `${type}.itemId`) ??
|
|
873
|
+
textProblem(value.text, `${type}.text`)
|
|
874
|
+
)
|
|
875
|
+
case "plan.updated":
|
|
876
|
+
return (
|
|
877
|
+
tokenProblem(value.turnId, `${type}.turnId`) ??
|
|
878
|
+
optionalTokenProblem(value.callId, `${type}.callId`) ??
|
|
879
|
+
(value.explanation === undefined
|
|
880
|
+
? undefined
|
|
881
|
+
: textProblem(value.explanation, `${type}.explanation`, AGENT_MAX_PAYLOAD_TEXT)) ??
|
|
882
|
+
planEntriesProblem(value.entries, `${type}.entries`)
|
|
883
|
+
)
|
|
884
|
+
case "context.usage":
|
|
885
|
+
return (
|
|
886
|
+
optionalTokenProblem(value.turnId, `${type}.turnId`) ??
|
|
887
|
+
countProblem(value.usedTokens, `${type}.usedTokens`) ??
|
|
888
|
+
optionalCountProblem(value.windowTokens, `${type}.windowTokens`) ??
|
|
889
|
+
optionalCountProblem(value.inputTokens, `${type}.inputTokens`) ??
|
|
890
|
+
optionalCountProblem(value.outputTokens, `${type}.outputTokens`) ??
|
|
891
|
+
optionalCountProblem(value.cachedInputTokens, `${type}.cachedInputTokens`) ??
|
|
892
|
+
optionalCountProblem(value.reasoningTokens, `${type}.reasoningTokens`) ??
|
|
893
|
+
(value.estimated === undefined
|
|
894
|
+
? undefined
|
|
895
|
+
: booleanProblem(value.estimated, `${type}.estimated`))
|
|
896
|
+
)
|
|
540
897
|
case "tool.started":
|
|
541
898
|
return (
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
899
|
+
tokenProblem(value.turnId, `${type}.turnId`) ??
|
|
900
|
+
tokenProblem(value.callId, `${type}.callId`) ??
|
|
901
|
+
tokenProblem(value.name, `${type}.name`) ??
|
|
902
|
+
valueProblem(value, "input", `${type}.input`)
|
|
546
903
|
)
|
|
547
904
|
case "tool.delta":
|
|
548
|
-
return
|
|
905
|
+
return (
|
|
906
|
+
tokenProblem(value.turnId, `${type}.turnId`) ??
|
|
907
|
+
tokenProblem(value.callId, `${type}.callId`) ??
|
|
908
|
+
textProblem(value.text, `${type}.text`)
|
|
909
|
+
)
|
|
549
910
|
case "tool.completed":
|
|
550
911
|
return (
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
(value.error === undefined
|
|
912
|
+
tokenProblem(value.turnId, `${type}.turnId`) ??
|
|
913
|
+
tokenProblem(value.callId, `${type}.callId`) ??
|
|
914
|
+
tokenProblem(value.name, `${type}.name`) ??
|
|
915
|
+
booleanProblem(value.ok, `${type}.ok`) ??
|
|
916
|
+
valueProblem(value, "output", `${type}.output`) ??
|
|
917
|
+
(value.error === undefined ? undefined : errorProblem(value.error, `${type}.error`))
|
|
557
918
|
)
|
|
558
919
|
case "approval.required":
|
|
559
920
|
return (
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
(value.reason
|
|
921
|
+
tokenProblem(value.turnId, `${type}.turnId`) ??
|
|
922
|
+
tokenProblem(value.approvalId, `${type}.approvalId`) ??
|
|
923
|
+
textProblem(value.action, `${type}.action`, AGENT_MAX_TEXT, false) ??
|
|
924
|
+
tokenProblem(value.capability, `${type}.capability`) ??
|
|
925
|
+
optionalTextProblem(value.reason, `${type}.reason`)
|
|
565
926
|
)
|
|
566
927
|
case "approval.resolved":
|
|
567
928
|
return (
|
|
568
|
-
(value.turnId
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
(value.reason
|
|
929
|
+
optionalTokenProblem(value.turnId, `${type}.turnId`) ??
|
|
930
|
+
tokenProblem(value.approvalId, `${type}.approvalId`) ??
|
|
931
|
+
booleanProblem(value.approved, `${type}.approved`) ??
|
|
932
|
+
optionalTextProblem(value.reason, `${type}.reason`)
|
|
572
933
|
)
|
|
573
934
|
case "repair.required":
|
|
574
|
-
return (
|
|
935
|
+
return (
|
|
936
|
+
optionalTokenProblem(value.turnId, `${type}.turnId`) ??
|
|
937
|
+
repairTaskProblem(value.task, `${type}.task`)
|
|
938
|
+
)
|
|
575
939
|
case "verification.completed":
|
|
576
940
|
return (
|
|
577
|
-
(value.name === "check" || value.name === "assure" || value.name === "test"
|
|
578
|
-
|
|
579
|
-
|
|
941
|
+
(value.name === "check" || value.name === "assure" || value.name === "test"
|
|
942
|
+
? undefined
|
|
943
|
+
: `${type}.name must be check, assure, or test`) ??
|
|
944
|
+
booleanProblem(value.ok, `${type}.ok`) ??
|
|
945
|
+
valueProblem(value, "report", `${type}.report`)
|
|
580
946
|
)
|
|
581
947
|
case "memory.compacted":
|
|
582
948
|
return (
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
typeof value.reason === "string" &&
|
|
586
|
-
|
|
949
|
+
countProblem(value.before, `${type}.before`) ??
|
|
950
|
+
countProblem(value.after, `${type}.after`) ??
|
|
951
|
+
(typeof value.reason === "string" && AGENT_COMPACTION_REASONS.has(value.reason)
|
|
952
|
+
? undefined
|
|
953
|
+
: `${type}.reason must be one of ${[...AGENT_COMPACTION_REASONS].join(", ")}`)
|
|
587
954
|
)
|
|
588
955
|
case "extension.reloaded":
|
|
589
956
|
return (
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
value.
|
|
593
|
-
value.
|
|
594
|
-
Array.isArray(value.disabled) &&
|
|
595
|
-
value.disabled.length <= AGENT_MAX_ARRAY &&
|
|
596
|
-
value.disabled.every(agentToken) &&
|
|
597
|
-
typeof value.rolledBack === "boolean"
|
|
957
|
+
tokenProblem(value.revision, `${type}.revision`) ??
|
|
958
|
+
tokenListProblem(value.loaded, `${type}.loaded`) ??
|
|
959
|
+
tokenListProblem(value.disabled, `${type}.disabled`) ??
|
|
960
|
+
booleanProblem(value.rolledBack, `${type}.rolledBack`)
|
|
598
961
|
)
|
|
599
962
|
case "session.failed":
|
|
600
|
-
return
|
|
963
|
+
return (
|
|
964
|
+
errorProblem(value.error, `${type}.error`) ??
|
|
965
|
+
booleanProblem(value.recoverable, `${type}.recoverable`)
|
|
966
|
+
)
|
|
601
967
|
case "session.stopped":
|
|
602
|
-
return value.reason
|
|
968
|
+
return optionalTextProblem(value.reason, `${type}.reason`)
|
|
603
969
|
default:
|
|
604
|
-
|
|
970
|
+
if (isAgentExtensionEventType(type))
|
|
971
|
+
return (
|
|
972
|
+
optionalTokenProblem(value.turnId, `${type}.turnId`) ??
|
|
973
|
+
valueProblem(value, "data", `${type}.data`)
|
|
974
|
+
)
|
|
975
|
+
if (type.startsWith("x-"))
|
|
976
|
+
return "extension event type must be x-<vendor>.<name>, lowercase, at most 64 characters"
|
|
977
|
+
return agentToken(type) ? `unknown event type ${type}` : "unknown event type"
|
|
605
978
|
}
|
|
606
979
|
}
|