@nifrajs/agent-protocol 4.0.2 → 5.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/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
- /** Resolve an approval surfaced by a backend UI/tool, when that backend supports interactive approvals. */
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. The authoritative event history belongs to
301
- * the backend/session store; this live view may drop old transient events if a consumer falls behind.
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
- function optionalBoundedAgentValue(record: Record<string, unknown>, key: string): boolean {
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 validAgentError(value: unknown): boolean {
471
- if (!agentRecord(value)) return false
780
+ function errorProblem(value: unknown, field: string): AgentProblem {
781
+ if (!agentRecord(value)) return `${field} must be an object`
472
782
  return (
473
- agentToken(value.code) &&
474
- agentText(value.message, AGENT_MAX_TEXT, false) &&
475
- optionalBoundedAgentValue(value, "details")
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 validAgentSnapshot(value: unknown, sessionId: string): value is AgentSessionSnapshot {
480
- if (!agentRecord(value)) return false
481
- if (
482
- value.version !== AGENT_PROTOCOL_VERSION ||
483
- !agentToken(value.id) ||
484
- value.id !== sessionId ||
485
- !agentToken(value.backend) ||
486
- !agentText(value.cwd, 4_096, false) ||
487
- typeof value.status !== "string" ||
488
- !AGENT_STATUSES.has(value.status) ||
489
- !agentCount(value.createdAt) ||
490
- !agentCount(value.updatedAt) ||
491
- !agentCount(value.lastSeq) ||
492
- !Array.isArray(value.capabilities) ||
493
- value.capabilities.length > AGENT_MAX_ARRAY ||
494
- !value.capabilities.every(agentToken)
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 validRepairTask(value: unknown): boolean {
503
- if (!agentRecord(value)) return false
809
+ function repairTaskProblem(value: unknown, field: string): AgentProblem {
810
+ if (!agentRecord(value)) return `${field} must be an object`
504
811
  return (
505
- agentToken(value.id) &&
812
+ tokenProblem(value.id, `${field}.id`) ??
506
813
  (value.verification === "check" ||
507
- value.verification === "assure" ||
508
- value.verification === "test") &&
509
- agentText(value.cwd, 4_096, false) &&
510
- agentText(value.reason, AGENT_MAX_TEXT, false) &&
511
- Array.isArray(value.capabilities) &&
512
- value.capabilities.length <= AGENT_MAX_ARRAY &&
513
- value.capabilities.every(agentToken) &&
514
- (value.output === undefined || agentText(value.output)) &&
515
- optionalBoundedAgentValue(value, "report")
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
- if (!agentRecord(value)) return false
522
- if (
523
- value.version !== AGENT_PROTOCOL_VERSION ||
524
- !agentToken(value.sessionId) ||
525
- !agentCount(value.seq) ||
526
- !agentCount(value.at) ||
527
- typeof value.type !== "string"
528
- )
529
- return false
530
- switch (value.type) {
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 validAgentSnapshot(value.snapshot, value.sessionId)
861
+ return snapshotProblem(value.snapshot, value.sessionId, `${type}.snapshot`)
535
862
  case "turn.started":
536
- return agentToken(value.turnId) && agentText(value.prompt)
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 agentToken(value.turnId) && agentText(value.text)
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
- agentToken(value.turnId) &&
543
- agentToken(value.callId) &&
544
- agentToken(value.name) &&
545
- optionalBoundedAgentValue(value, "input")
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 agentToken(value.turnId) && agentToken(value.callId) && agentText(value.text)
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
- agentToken(value.turnId) &&
552
- agentToken(value.callId) &&
553
- agentToken(value.name) &&
554
- typeof value.ok === "boolean" &&
555
- optionalBoundedAgentValue(value, "output") &&
556
- (value.error === undefined || validAgentError(value.error))
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
- agentToken(value.turnId) &&
561
- agentToken(value.approvalId) &&
562
- agentText(value.action, AGENT_MAX_TEXT, false) &&
563
- agentToken(value.capability) &&
564
- (value.reason === undefined || agentText(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 === undefined || agentToken(value.turnId)) &&
569
- agentToken(value.approvalId) &&
570
- typeof value.approved === "boolean" &&
571
- (value.reason === undefined || agentText(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 (value.turnId === undefined || agentToken(value.turnId)) && validRepairTask(value.task)
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
- typeof value.ok === "boolean" &&
579
- optionalBoundedAgentValue(value, "report")
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
- agentCount(value.before) &&
584
- agentCount(value.after) &&
585
- typeof value.reason === "string" &&
586
- AGENT_COMPACTION_REASONS.has(value.reason)
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
- agentToken(value.revision) &&
591
- Array.isArray(value.loaded) &&
592
- value.loaded.length <= AGENT_MAX_ARRAY &&
593
- value.loaded.every(agentToken) &&
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 validAgentError(value.error) && typeof value.recoverable === "boolean"
963
+ return (
964
+ errorProblem(value.error, `${type}.error`) ??
965
+ booleanProblem(value.recoverable, `${type}.recoverable`)
966
+ )
601
967
  case "session.stopped":
602
- return value.reason === undefined || agentText(value.reason)
968
+ return optionalTextProblem(value.reason, `${type}.reason`)
603
969
  default:
604
- return false
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
  }