@zvada/agent-server 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +317 -0
  2. package/README.md +20 -4
  3. package/docs/consuming.md +269 -0
  4. package/docs/deploy.md +80 -0
  5. package/docs/harnesses.md +64 -0
  6. package/docs/rfds/0001-deterministic-echo-ids.md +44 -0
  7. package/package.json +23 -3
  8. package/src/client/client.ts +143 -50
  9. package/src/core/agents/acp/acp-agent.ts +9 -0
  10. package/src/core/agents/acp/mappings.ts +3 -3
  11. package/src/core/agents/base.ts +9 -1
  12. package/src/core/agents/claude-code/adapter.ts +116 -26
  13. package/src/core/agents/claude-code/claude-agent.ts +31 -3
  14. package/src/core/agents/claude-code/generator-session.ts +16 -5
  15. package/src/core/agents/claude-code/options.ts +13 -3
  16. package/src/core/agents/claude-code/session-manager.ts +9 -4
  17. package/src/core/agents/codex-app-server/codex-app-server-agent.ts +17 -3
  18. package/src/core/agents/codex-sdk/codex-sdk-agent.ts +3 -3
  19. package/src/core/agents/types.ts +1 -1
  20. package/src/core/diagnostics.ts +59 -0
  21. package/src/core/index.ts +3 -1
  22. package/src/core/presets.ts +15 -2
  23. package/src/core/provision/pins.ts +5 -1
  24. package/src/core/proxy/anthropic-proxy.ts +43 -2
  25. package/src/core/proxy/api-key-store.ts +37 -5
  26. package/src/core/proxy/index.ts +8 -1
  27. package/src/core/runtime/agent-runtime.ts +66 -18
  28. package/src/core/runtime/event-processor.ts +51 -26
  29. package/src/protocol/config.ts +8 -6
  30. package/src/{core/agents/error-classifier.ts → protocol/errors.ts} +33 -7
  31. package/src/protocol/factories.ts +106 -10
  32. package/src/protocol/guards.ts +53 -0
  33. package/src/protocol/index.ts +10 -0
  34. package/src/protocol/lifecycle.ts +289 -112
  35. package/src/protocol/meta.ts +14 -0
  36. package/src/protocol/part-input.ts +56 -7
  37. package/src/protocol/parts.ts +125 -10
  38. package/src/protocol/reduce.ts +749 -0
  39. package/src/protocol/selectors.ts +162 -0
  40. package/src/protocol/seq-cursor.ts +87 -0
  41. package/src/protocol/stop-reasons.ts +45 -0
  42. package/src/protocol/time.ts +23 -0
  43. package/src/protocol/tokens.ts +23 -0
  44. package/src/protocol/tool-state.ts +85 -25
  45. package/src/protocol/verify.ts +440 -0
  46. package/src/protocol/vocabulary.ts +18 -0
  47. package/src/protocol/wire.ts +81 -13
  48. package/src/server/acp/binding.ts +23 -2
  49. package/src/server/acp/translate.ts +51 -14
  50. package/src/server/agent-server.ts +85 -6
  51. package/AGENTS.md +0 -21
@@ -1,28 +1,56 @@
1
1
  import { z } from "zod";
2
+ import { ErrorCategorySchema, ErrorInfoSchema } from "./errors.ts";
3
+ import { isKnownLifecycleEventType } from "./guards.ts";
2
4
  import { AgentHarnessSchema } from "./harness.ts";
3
- import { PartSchema } from "./parts.ts";
5
+ import { MetaSchema } from "./meta.ts";
6
+ import { type Part, PartSchema, type UnknownPart, decodePart } from "./parts.ts";
7
+ import { EpochMsSchema } from "./time.ts";
4
8
  import { TokenUsageSchema } from "./tokens.ts";
5
9
  import { ToolKindSchema, ToolLocationSchema } from "./tool-state.ts";
10
+ import { openVocabulary } from "./vocabulary.ts";
6
11
 
7
12
  /**
8
13
  * The normalized event stream every harness is funneled into. Consumers (a WS
9
14
  * bridge, an SSE endpoint, a CLI, a test sink) subscribe to these and never see
10
15
  * raw SDK payloads. All keys are camelCase; serialize at your transport edge.
16
+ * Every event carries `sessionId`, `timestamp` (epoch ms), and `_meta`.
11
17
  *
12
18
  * Ordering within a turn:
13
- * session.created? -> turn.started -> (message.started -> message.part*
14
- * / message.part.delta* -> message.ended)* -> turn.ended
19
+ * turn.started
20
+ * -> [ USER ECHO: message.started{role:"user", outputIndex:0}
21
+ * -> message.part* -> message.ended ]
22
+ * -> ( message.started{role:"assistant"} -> message.part*
23
+ * / message.part.delta* -> message.ended )*
24
+ * -> turn.ended
25
+ * session.created lands WHEN THE HARNESS FIRST REPORTS its native session id
26
+ * — its first yield — so arriving MID-TURN, after the echo and before the
27
+ * first assistant message, is the normal case rather than an anomaly. It
28
+ * cannot be hoisted: the id does not exist until the harness produces it,
29
+ * and holding the echo back for it would delay the transcript of every turn
30
+ * on a subprocess spawn. Persist it whenever it arrives; never treat it as a
31
+ * turn preamble.
32
+ * session.compaction may appear anywhere within a turn (position = first
33
+ * appearance). permission.* and raw interleave anywhere within a turn.
34
+ * session.ended may follow at any later point.
15
35
  *
16
- * Two exceptions to strict message bracketing:
17
- * - a `message.part` may arrive after its message ended (a tool completing
18
- * after the model message that issued it) — upsert by `part.id`.
19
- * - `permission.requested`/`permission.resolved` and `raw` events interleave
20
- * anywhere between turn.started and turn.ended.
36
+ * Exceptions to strict message bracketing:
37
+ * - a `message.part` may arrive after its message (or turn) ended — a tool
38
+ * completing late. The event's `messageId` names the ORIGINAL message and
39
+ * the upsert lands there.
40
+ * - snapshots are authoritative over deltas: a content-bearing `message.part`
41
+ * wipes the delta accumulator for that part; later deltas append to the
42
+ * replacement. A non-terminal snapshot must not carry partially-accumulated
43
+ * streamed content (field-ownership rule).
21
44
  */
22
45
 
23
46
  /**
24
- * Why a turn stopped (ACP-aligned, plus `error`). This is the normalized
47
+ * Why a turn stopped (ACP StopReason verbatim, plus `error`). Normalized
25
48
  * terminal status; the raw provider string travels in `finishReason`.
49
+ *
50
+ * OPEN (Law 3): `_`-prefixed values are adapter extensions (e.g. Claude's
51
+ * error_max_budget_usd -> `_max_budget`); unknown non-`_` values are reserved
52
+ * for this protocol and MUST be preserved. `refusal` covers provider
53
+ * content-filter/safety stops.
26
54
  */
27
55
  export const STOP_REASONS = [
28
56
  "end_turn",
@@ -32,8 +60,25 @@ export const STOP_REASONS = [
32
60
  "cancelled",
33
61
  "error",
34
62
  ] as const;
35
- export type StopReason = (typeof STOP_REASONS)[number];
36
- export const StopReasonSchema = z.enum(STOP_REASONS);
63
+ export type StopReason = (typeof STOP_REASONS)[number] | (string & {});
64
+ export const StopReasonSchema = openVocabulary<StopReason>();
65
+
66
+ /** Why a harness session ended. OPEN. */
67
+ export const SESSION_END_REASONS = ["idle", "replaced", "released", "shutdown"] as const;
68
+ export type SessionEndReason = (typeof SESSION_END_REASONS)[number] | (string & {});
69
+ export const SessionEndReasonSchema = openVocabulary<SessionEndReason>();
70
+
71
+ /** Compaction entity status. OPEN. */
72
+ export const COMPACTION_STATUSES = ["in_progress", "completed", "failed", "cancelled"] as const;
73
+ export type CompactionStatus = (typeof COMPACTION_STATUSES)[number] | (string & {});
74
+ export const CompactionStatusSchema = openVocabulary<CompactionStatus>();
75
+
76
+ /** What caused a compaction (Claude: `auto` | `manual`). OPEN. */
77
+ export const COMPACTION_TRIGGERS = ["auto", "manual"] as const;
78
+ export type CompactionTrigger = (typeof COMPACTION_TRIGGERS)[number] | (string & {});
79
+ export const CompactionTriggerSchema = openVocabulary<CompactionTrigger>();
80
+
81
+ // ---- session events ---------------------------------------------------------
37
82
 
38
83
  /** Emitted once the harness-native session id is known — persist it to resume later. */
39
84
  export const SessionCreatedEventSchema = z.object({
@@ -51,22 +96,110 @@ export const SessionCreatedEventSchema = z.object({
51
96
  * successful resume (Claude).
52
97
  */
53
98
  resumed: z.boolean().optional(),
54
- timestamp: z.number(),
99
+ timestamp: EpochMsSchema,
100
+ _meta: MetaSchema,
55
101
  });
56
102
  export type SessionCreatedEvent = z.infer<typeof SessionCreatedEventSchema>;
57
103
 
104
+ /** The harness session is over (idle timeout, replacement, release, shutdown). */
105
+ export const SessionEndedEventSchema = z.object({
106
+ type: z.literal("session.ended"),
107
+ sessionId: z.string(),
108
+ reason: SessionEndReasonSchema,
109
+ timestamp: EpochMsSchema,
110
+ _meta: MetaSchema,
111
+ });
112
+ export type SessionEndedEvent = z.infer<typeof SessionEndedEventSchema>;
113
+
114
+ /**
115
+ * Context-window gauge, emitted whenever the harness reports fresh numbers
116
+ * (per model message on Claude, per token-count update on Codex, per ACP
117
+ * `usage_update`). Vocabulary tracks ACP's stable `usage_update` shape:
118
+ * `used`/`size`/`cost`. Distinct from `turn.ended.tokens`, which is the
119
+ * turn's billing total — this is "how full is the session right now".
120
+ */
121
+ export const SessionUsageEventSchema = z.object({
122
+ type: z.literal("session.usage"),
123
+ sessionId: z.string(),
124
+ turnId: z.string(),
125
+ /** Tokens currently occupying the context window (prompt + output). */
126
+ used: z.number(),
127
+ /** The model's context-window size, when the harness reports it. */
128
+ size: z.number().optional(),
129
+ /** Cumulative session cost in USD, when the harness reports it. */
130
+ cost: z.number().optional(),
131
+ timestamp: EpochMsSchema,
132
+ _meta: MetaSchema,
133
+ });
134
+ export type SessionUsageEvent = z.infer<typeof SessionUsageEventSchema>;
135
+
136
+ /**
137
+ * The compaction entity: an ID-addressed, POSITIONAL timeline entity — UPSERT
138
+ * by `compactionId`. The first event for an id anchors the entity's position
139
+ * in the ordered stream (the reducer places it there in the timeline); later
140
+ * upserts advance `status` and stream `summary` without moving it. Neither a
141
+ * part nor a bare notification: a replayed transcript needs the divider IN the
142
+ * order (Codex deprecated their session-level notification for exactly this;
143
+ * matches ACP's session-compaction draft so a future binding is a passthrough).
144
+ */
145
+ export const SessionCompactionEventSchema = z.object({
146
+ type: z.literal("session.compaction"),
147
+ sessionId: z.string(),
148
+ turnId: z.string(),
149
+ compactionId: z.string(),
150
+ status: CompactionStatusSchema,
151
+ /** What triggered it, when known (Claude: `manual` | `auto`). */
152
+ trigger: CompactionTriggerSchema.optional(),
153
+ /** Context tokens before/after the compaction, when reported. */
154
+ preTokens: z.number().optional(),
155
+ postTokens: z.number().optional(),
156
+ /** User-displayable summary; may stream via successive upserts. */
157
+ summary: z.string().optional(),
158
+ /** Part ids the summary subsumed — auditable compaction lineage. */
159
+ abstractsIds: z.array(z.string()).optional(),
160
+ timestamp: EpochMsSchema,
161
+ _meta: MetaSchema,
162
+ });
163
+ export type SessionCompactionEvent = z.infer<typeof SessionCompactionEventSchema>;
164
+
165
+ // ---- turn events ------------------------------------------------------------
166
+
58
167
  export const TurnStartedEventSchema = z.object({
59
168
  type: z.literal("turn.started"),
60
- turnId: z.string(),
61
169
  sessionId: z.string(),
62
- timestamp: z.number(),
170
+ turnId: z.string(),
171
+ timestamp: EpochMsSchema,
172
+ _meta: MetaSchema,
63
173
  });
64
174
  export type TurnStartedEvent = z.infer<typeof TurnStartedEventSchema>;
65
175
 
176
+ export const TurnEndedEventSchema = z.object({
177
+ type: z.literal("turn.ended"),
178
+ sessionId: z.string(),
179
+ turnId: z.string(),
180
+ /** Normalized terminal status — `cancelled` and `error` are first-class. */
181
+ stopReason: StopReasonSchema,
182
+ /** Raw provider finish string (`end_turn`, `completed`, …), always preserved when reported. */
183
+ finishReason: z.string().optional(),
184
+ /** The turn's BILLING total (never the context gauge — that is session.usage). */
185
+ tokens: TokenUsageSchema.optional(),
186
+ /** USD for this turn, when reported. */
187
+ cost: z.number().optional(),
188
+ /** Present when stopReason === "error". */
189
+ error: ErrorInfoSchema.optional(),
190
+ timestamp: EpochMsSchema,
191
+ _meta: MetaSchema,
192
+ });
193
+ export type TurnEndedEvent = z.infer<typeof TurnEndedEventSchema>;
194
+
195
+ // ---- message + part events --------------------------------------------------
196
+
66
197
  export const MessageStartedEventSchema = z.object({
67
198
  type: z.literal("message.started"),
199
+ sessionId: z.string(),
68
200
  turnId: z.string(),
69
201
  messageId: z.string(),
202
+ /** Position of this message within the turn. The user echo is 0; assistant messages are 1..n. */
70
203
  outputIndex: z.number(),
71
204
  role: z.enum(["user", "assistant"]),
72
205
  /**
@@ -75,36 +208,37 @@ export const MessageStartedEventSchema = z.object({
75
208
  * message — consumers nest it under that tool call. Absent for top-level
76
209
  * messages.
77
210
  */
78
- parentToolUseId: z.string().optional(),
79
- timestamp: z.number(),
80
- metadata: z
81
- .object({
82
- sessionId: z.string().optional(),
83
- harness: z.string().optional(),
84
- model: z.string().optional(),
85
- })
86
- .optional(),
211
+ parentToolCallId: z.string().optional(),
212
+ model: z.string().optional(),
213
+ timestamp: EpochMsSchema,
214
+ _meta: MetaSchema,
87
215
  });
88
216
  export type MessageStartedEvent = z.infer<typeof MessageStartedEventSchema>;
89
217
 
90
- /** A fully-formed (or newly-finalized) part snapshot. Upsert by `part.id`. */
218
+ /**
219
+ * A full part snapshot. UPSERT by `part.id` — authoritative, idempotent,
220
+ * re-delivery-safe. Full-snapshot semantics, never field patches: "clear a
221
+ * field" = omit it from the next snapshot.
222
+ */
91
223
  export const MessagePartEventSchema = z.object({
92
224
  type: z.literal("message.part"),
225
+ sessionId: z.string(),
93
226
  turnId: z.string(),
94
227
  messageId: z.string(),
95
228
  outputIndex: z.number(),
229
+ /** Position of this part within its message — persist as the ordering key. */
96
230
  partIndex: z.number(),
97
231
  part: PartSchema,
98
- parentToolUseId: z.string().optional(),
99
- timestamp: z.number(),
232
+ timestamp: EpochMsSchema,
233
+ _meta: MetaSchema,
100
234
  });
101
235
  export type MessagePartEvent = z.infer<typeof MessagePartEventSchema>;
102
236
 
103
237
  export const DeltaSchema = z.discriminatedUnion("type", [
104
- z.object({ type: z.literal("text-delta"), text: z.string() }),
105
- z.object({ type: z.literal("reasoning-delta"), text: z.string() }),
238
+ z.object({ type: z.literal("text"), text: z.string() }),
239
+ z.object({ type: z.literal("reasoning"), text: z.string() }),
106
240
  z.object({
107
- type: z.literal("tool-input-delta"),
241
+ type: z.literal("tool_input"),
108
242
  toolCallId: z.string(),
109
243
  toolName: z.string(),
110
244
  input: z.string(),
@@ -112,103 +246,47 @@ export const DeltaSchema = z.discriminatedUnion("type", [
112
246
  ]);
113
247
  export type Delta = z.infer<typeof DeltaSchema>;
114
248
 
115
- /** An incremental update to a streaming part (text/reasoning/tool-input). */
249
+ /**
250
+ * An additive streaming aid for a streaming part field. Advisory: snapshots
251
+ * are authoritative and wipe accumulated deltas. Deltas are NON-DURABLE —
252
+ * `events/replay` may omit them; snapshots fully reconstruct state.
253
+ */
116
254
  export const MessagePartDeltaEventSchema = z.object({
117
255
  type: z.literal("message.part.delta"),
256
+ sessionId: z.string(),
118
257
  turnId: z.string(),
119
258
  messageId: z.string(),
120
259
  outputIndex: z.number(),
121
260
  partIndex: z.number(),
122
261
  partId: z.string(),
123
262
  delta: DeltaSchema,
124
- parentToolUseId: z.string().optional(),
125
- timestamp: z.number(),
263
+ timestamp: EpochMsSchema,
264
+ _meta: MetaSchema,
126
265
  });
127
266
  export type MessagePartDeltaEvent = z.infer<typeof MessagePartDeltaEventSchema>;
128
267
 
268
+ /** Bracket marker ONLY — billing lives on turn.ended, never here. */
129
269
  export const MessageEndedEventSchema = z.object({
130
270
  type: z.literal("message.ended"),
271
+ sessionId: z.string(),
131
272
  turnId: z.string(),
132
273
  messageId: z.string(),
133
- timestamp: z.number(),
274
+ timestamp: EpochMsSchema,
275
+ _meta: MetaSchema,
134
276
  });
135
277
  export type MessageEndedEvent = z.infer<typeof MessageEndedEventSchema>;
136
278
 
137
- export const TurnEndedEventSchema = z.object({
138
- type: z.literal("turn.ended"),
139
- turnId: z.string(),
140
- sessionId: z.string(),
141
- /** Normalized terminal status — `cancelled` and `error` are first-class. */
142
- stopReason: StopReasonSchema,
143
- /** Raw provider finish string (`end_turn`, `completed`, …), when reported. */
144
- finishReason: z.string().optional(),
145
- tokens: TokenUsageSchema.optional(),
146
- cost: z.number().optional(),
147
- error: z.object({ name: z.string(), message: z.string() }).optional(),
148
- timestamp: z.number(),
149
- });
150
- export type TurnEndedEvent = z.infer<typeof TurnEndedEventSchema>;
151
-
152
- /**
153
- * Context-window gauge, emitted whenever the harness reports fresh numbers
154
- * (per model message on Claude, per token-count update on Codex, per ACP
155
- * `usage_update`). Vocabulary tracks ACP's stable `usage_update` shape:
156
- * `used`/`size`/`cost`. Distinct from `turn.ended.tokens`, which is the
157
- * turn's billing total — this is "how full is the session right now".
158
- */
159
- export const SessionUsageEventSchema = z.object({
160
- type: z.literal("session.usage"),
161
- sessionId: z.string(),
162
- turnId: z.string(),
163
- /** Tokens currently occupying the context window (prompt + output). */
164
- used: z.number(),
165
- /** The model's context-window size, when the harness reports it. */
166
- size: z.number().optional(),
167
- /** Cumulative session cost in USD, when the harness reports it. */
168
- cost: z.number().optional(),
169
- timestamp: z.number(),
170
- });
171
- export type SessionUsageEvent = z.infer<typeof SessionUsageEventSchema>;
172
-
173
- /**
174
- * The harness compacted the session's history (context summarization). A
175
- * boundary marker, not a rewrite: consumers that render history decide what
176
- * to do at the boundary; the engine stays append-only.
177
- */
178
- export const SessionCompactedEventSchema = z.object({
179
- type: z.literal("session.compacted"),
180
- sessionId: z.string(),
181
- turnId: z.string(),
182
- /** What triggered it, when known (Claude: `manual` | `auto`). */
183
- trigger: z.string().optional(),
184
- /** Context tokens before/after the compaction, when reported. */
185
- preTokens: z.number().optional(),
186
- postTokens: z.number().optional(),
187
- timestamp: z.number(),
188
- });
189
- export type SessionCompactedEvent = z.infer<typeof SessionCompactedEventSchema>;
190
-
191
- export const ErrorEventSchema = z.object({
192
- type: z.literal("error"),
193
- turnId: z.string().optional(),
194
- sessionId: z.string().optional(),
195
- error: z.string(),
196
- recoverable: z.boolean(),
197
- code: z.string().optional(),
198
- stack: z.string().optional(),
199
- timestamp: z.number(),
200
- });
201
- export type ErrorEvent = z.infer<typeof ErrorEventSchema>;
202
-
203
279
  // ---- permissions (ACP request_permission-shaped) ----------------------------
204
280
 
205
- export const PermissionOptionKindSchema = z.enum([
281
+ /** OPEN (Law 3) — but unknown outcomes/kinds MUST be handled conservatively: never treated as approval. */
282
+ export const PERMISSION_OPTION_KINDS = [
206
283
  "allow_once",
207
284
  "allow_always",
208
285
  "reject_once",
209
286
  "reject_always",
210
- ]);
211
- export type PermissionOptionKind = z.infer<typeof PermissionOptionKindSchema>;
287
+ ] as const;
288
+ export type PermissionOptionKind = (typeof PERMISSION_OPTION_KINDS)[number] | (string & {});
289
+ export const PermissionOptionKindSchema = openVocabulary<PermissionOptionKind>();
212
290
 
213
291
  export const PermissionOptionSchema = z.object({
214
292
  optionId: z.string(),
@@ -234,7 +312,16 @@ export const PermissionToolCallSchema = z.object({
234
312
  export type PermissionToolCall = z.infer<typeof PermissionToolCallSchema>;
235
313
 
236
314
  export const PermissionOutcomeSchema = z.discriminatedUnion("outcome", [
237
- z.object({ outcome: z.literal("selected"), optionId: z.string() }),
315
+ z.object({
316
+ outcome: z.literal("selected"),
317
+ optionId: z.string(),
318
+ /**
319
+ * Allow-with-modified-input. Without this, products bypass the broker
320
+ * entirely (Claude's canUseTool supports it; ACP v1's outcome could not
321
+ * carry it).
322
+ */
323
+ updatedInput: z.record(z.string(), z.unknown()).optional(),
324
+ }),
238
325
  z.object({ outcome: z.literal("cancelled") }),
239
326
  ]);
240
327
  export type PermissionOutcome = z.infer<typeof PermissionOutcomeSchema>;
@@ -243,6 +330,8 @@ export type PermissionOutcome = z.infer<typeof PermissionOutcomeSchema>;
243
330
  * The harness is blocked waiting for a decision. Answer via
244
331
  * `AgentRuntime.respondPermission(sessionId, requestId, outcome)`. Pending
245
332
  * requests resolve as `cancelled` when the turn is cancelled or ends.
333
+ * `options` is THE source of buttons — clients render the server's list,
334
+ * never derive affordances from local policy.
246
335
  */
247
336
  export const PermissionRequestedEventSchema = z.object({
248
337
  type: z.literal("permission.requested"),
@@ -258,28 +347,48 @@ export const PermissionRequestedEventSchema = z.object({
258
347
  */
259
348
  toolCall: PermissionToolCallSchema.optional(),
260
349
  options: z.array(PermissionOptionSchema),
261
- timestamp: z.number(),
350
+ timestamp: EpochMsSchema,
351
+ _meta: MetaSchema,
262
352
  });
263
353
  export type PermissionRequestedEvent = z.infer<typeof PermissionRequestedEventSchema>;
264
354
 
265
- /** A pending permission request was answered (or cancelled). */
355
+ /** A pending permission request was answered (or cancelled). Resolves exactly once. */
266
356
  export const PermissionResolvedEventSchema = z.object({
267
357
  type: z.literal("permission.resolved"),
268
358
  sessionId: z.string(),
269
359
  turnId: z.string(),
270
360
  requestId: z.string(),
271
361
  outcome: PermissionOutcomeSchema,
272
- timestamp: z.number(),
362
+ timestamp: EpochMsSchema,
363
+ _meta: MetaSchema,
273
364
  });
274
365
  export type PermissionResolvedEvent = z.infer<typeof PermissionResolvedEventSchema>;
275
366
 
276
- // ---- raw passthrough ---------------------------------------------------------
367
+ // ---- error + raw ------------------------------------------------------------
368
+
369
+ export const ErrorEventSchema = z.object({
370
+ type: z.literal("error"),
371
+ /** Absent only for pre-session failures. */
372
+ sessionId: z.string().optional(),
373
+ turnId: z.string().optional(),
374
+ category: ErrorCategorySchema,
375
+ message: z.string(),
376
+ /**
377
+ * true = the turn continues (retry/backoff in flight). Consumers MUST NOT
378
+ * flip UI to an error state on recoverable errors — surface as diagnostics.
379
+ */
380
+ recoverable: z.boolean(),
381
+ stack: z.string().optional(),
382
+ timestamp: EpochMsSchema,
383
+ _meta: MetaSchema,
384
+ });
385
+ export type ErrorEvent = z.infer<typeof ErrorEventSchema>;
277
386
 
278
387
  /**
279
388
  * Opt-in (`RunConfig.includeRaw`) passthrough of the harness's raw event,
280
- * interleaved with the normalized stream. Escape hatch for migration,
281
- * debugging, and fixture recording — `data` carries NO stability guarantees
282
- * and is not part of the contract.
389
+ * interleaved with the normalized stream. Escape hatch for debugging and
390
+ * fixture recording — `data` carries NO stability guarantees and is not part
391
+ * of the contract. Building a product pipeline on `raw` is a bug.
283
392
  */
284
393
  export const RawEventSchema = z.object({
285
394
  type: z.literal("raw"),
@@ -287,12 +396,16 @@ export const RawEventSchema = z.object({
287
396
  turnId: z.string(),
288
397
  harness: AgentHarnessSchema,
289
398
  data: z.unknown(),
290
- timestamp: z.number(),
399
+ timestamp: EpochMsSchema,
400
+ _meta: MetaSchema,
291
401
  });
292
402
  export type RawEvent = z.infer<typeof RawEventSchema>;
293
403
 
404
+ // ---- the union --------------------------------------------------------------
405
+
294
406
  export const LifecycleEventSchema = z.discriminatedUnion("type", [
295
407
  SessionCreatedEventSchema,
408
+ SessionEndedEventSchema,
296
409
  TurnStartedEventSchema,
297
410
  MessageStartedEventSchema,
298
411
  MessagePartEventSchema,
@@ -300,10 +413,74 @@ export const LifecycleEventSchema = z.discriminatedUnion("type", [
300
413
  MessageEndedEventSchema,
301
414
  TurnEndedEventSchema,
302
415
  SessionUsageEventSchema,
303
- SessionCompactedEventSchema,
416
+ SessionCompactionEventSchema,
304
417
  ErrorEventSchema,
305
418
  PermissionRequestedEventSchema,
306
419
  PermissionResolvedEventSchema,
307
420
  RawEventSchema,
308
421
  ]);
309
422
  export type LifecycleEvent = z.infer<typeof LifecycleEventSchema>;
423
+
424
+ /**
425
+ * Law 6 — tolerant of the unknown, strict about the known: an event with an
426
+ * unknown `type` is preserved (in order, never dropped) as an UnknownEvent;
427
+ * a KNOWN type with a malformed body is a hard error.
428
+ */
429
+ export interface UnknownEvent {
430
+ type: string & {};
431
+ sessionId?: string;
432
+ raw: Record<string, unknown>;
433
+ }
434
+
435
+ /**
436
+ * A `message.part` whose part may be an `UnknownPart`: the event envelope is
437
+ * protocol-owned (strict), the part inside it is not (Law 6).
438
+ */
439
+ export interface DecodedMessagePartEvent extends Omit<MessagePartEvent, "part"> {
440
+ part: Part | UnknownPart;
441
+ }
442
+
443
+ /**
444
+ * What a Law-6 decoder yields for a KNOWN event type. Identical to
445
+ * `LifecycleEvent` except that `message.part` may carry an unknown part — so
446
+ * every `LifecycleEvent` is a `DecodedLifecycleEvent`, and consumers that
447
+ * accept this type accept both.
448
+ */
449
+ export type DecodedLifecycleEvent =
450
+ | Exclude<LifecycleEvent, MessagePartEvent>
451
+ | DecodedMessagePartEvent;
452
+
453
+ /** The `message.part` envelope without its part — decoded separately, leniently. */
454
+ const MessagePartEnvelopeSchema = MessagePartEventSchema.omit({ part: true });
455
+
456
+ /**
457
+ * Decode a wire value into a LifecycleEvent, preserving unknown event types
458
+ * (and unknown PART types inside `message.part`) instead of dropping them.
459
+ * Throws on a known type with a malformed body.
460
+ *
461
+ * This is the decoder every wire consumer should use — dropping an event a
462
+ * newer server sent is not a graceful degradation, it is a hole in the
463
+ * transcript and, for a seq-tracking client, a fabricated gap.
464
+ */
465
+ export function decodeLifecycleEvent(value: unknown): DecodedLifecycleEvent | UnknownEvent {
466
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
467
+ throw new Error("lifecycle event must be an object");
468
+ }
469
+ const record = value as Record<string, unknown>;
470
+ const type = record.type;
471
+ if (typeof type !== "string" || type.length === 0) {
472
+ throw new Error("lifecycle event is missing its type");
473
+ }
474
+ if (!isKnownLifecycleEventType(type)) {
475
+ const sessionId = typeof record.sessionId === "string" ? record.sessionId : undefined;
476
+ return { type, ...(sessionId !== undefined ? { sessionId } : {}), raw: record };
477
+ }
478
+ if (type === "message.part") {
479
+ return { ...MessagePartEnvelopeSchema.parse(record), part: decodePart(record.part) };
480
+ }
481
+ return LifecycleEventSchema.parse(value);
482
+ }
483
+
484
+ // `LIFECYCLE_EVENT_TYPES`, `isKnownLifecycleEventType` and `isUnknownEvent`
485
+ // live in `./guards.ts` — same exports from the barrel, but reachable without
486
+ // loading zod.
@@ -0,0 +1,14 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * The extension slot (Law 4 — ACP pattern): present on every event, part,
5
+ * tool state, and result-content item. Adapters and products MUST NOT add
6
+ * root-level fields to canonical types — all root names are reserved for
7
+ * future protocol versions; extensions live here under namespaced keys
8
+ * (`deus/...`, `agnt/...`).
9
+ *
10
+ * Reserved keys (W3C trace context, MCP-compatible): `traceparent`,
11
+ * `tracestate`, `baggage`.
12
+ */
13
+ export const MetaSchema = z.record(z.string(), z.unknown()).optional();
14
+ export type Meta = Record<string, unknown>;
@@ -1,10 +1,31 @@
1
1
  import { z } from "zod";
2
2
 
3
+ /**
4
+ * A UI-authored span over the text (mentions, pills, inline references) that
5
+ * survives the round trip through the model, the user echo, and persistence.
6
+ * The model sees plain text; `byteRange` is [start, end) over the UTF-8 bytes
7
+ * of the parent `text`.
8
+ */
9
+ export const TextElementSchema = z.object({
10
+ byteRange: z
11
+ .tuple([z.number().int().nonnegative(), z.number().int().nonnegative()])
12
+ .refine(([start, end]) => start <= end, {
13
+ // A reversed range would survive the echo and persistence, and every
14
+ // consumer slicing with it would render an empty or wrong span with
15
+ // the producer bug invisible — reject it at the boundary.
16
+ message: "byteRange must be ordered: start <= end",
17
+ }),
18
+ /** Human-readable placeholder for the element, displayed in the UI. */
19
+ placeholder: z.string().optional(),
20
+ });
21
+ export type TextElement = z.infer<typeof TextElementSchema>;
22
+
3
23
  /** Inbound user content for a turn: plain text, or multimodal blocks. */
4
24
  export const TextPartInputSchema = z.object({
5
25
  type: z.literal("text"),
6
26
  id: z.string().optional(),
7
27
  text: z.string().min(1),
28
+ elements: z.array(TextElementSchema).optional(),
8
29
  });
9
30
  export type TextPartInput = z.infer<typeof TextPartInputSchema>;
10
31
 
@@ -15,11 +36,15 @@ export const ImagePartInputSchema = z
15
36
  /** Either a URL or base64 `data` (one is required). */
16
37
  url: z.string().min(1).optional(),
17
38
  data: z.string().min(1).optional(),
18
- mediaType: z.string(),
19
- filename: z.string().optional(),
39
+ mimeType: z.string(),
40
+ // No `filename` here: ImagePartSchema has no such field, so accepting it
41
+ // would validate a value the echo then silently drops (Law 7).
20
42
  })
21
- .refine((part) => part.url !== undefined || part.data !== undefined, {
22
- message: "image input requires a non-empty url or data payload",
43
+ .refine((part) => (part.data !== undefined) !== (part.url !== undefined), {
44
+ // Exactly one, matching the OUTPUT part schemas' `exactlyOneSource`: an
45
+ // input carrying both would echo as a part every consumer's decode
46
+ // rejects — the ambiguity dies at the boundary, not on the stream.
47
+ message: "image input requires exactly one of data | url",
23
48
  });
24
49
  export type ImagePartInput = z.infer<typeof ImagePartInputSchema>;
25
50
 
@@ -29,11 +54,11 @@ export const FilePartInputSchema = z
29
54
  id: z.string().optional(),
30
55
  url: z.string().min(1).optional(),
31
56
  data: z.string().min(1).optional(),
32
- mediaType: z.string(),
57
+ mimeType: z.string(),
33
58
  filename: z.string().optional(),
34
59
  })
35
- .refine((part) => part.url !== undefined || part.data !== undefined, {
36
- message: "file input requires a non-empty url or data payload",
60
+ .refine((part) => (part.data !== undefined) !== (part.url !== undefined), {
61
+ message: "file input requires exactly one of data | url",
37
62
  });
38
63
  export type FilePartInput = z.infer<typeof FilePartInputSchema>;
39
64
 
@@ -48,6 +73,30 @@ export type PartInput = z.infer<typeof PartInputSchema>;
48
73
  export const AgentInputSchema = z.union([z.string(), z.array(PartInputSchema)]);
49
74
  export type AgentInput = z.infer<typeof AgentInputSchema>;
50
75
 
76
+ /**
77
+ * Decode an untrusted value (an HTTP body, a queue message, a DB column) into
78
+ * an `AgentInput`, or throw with a message that names the offending path.
79
+ *
80
+ * The schema is the contract; a hand-rolled decoder in front of it is where
81
+ * tolerance for shapes the engine never accepted creeps in (`content` arrays,
82
+ * bare `{text}` objects) and where the error a product shows its user
83
+ * degrades to "invalid input". (An empty STRING is valid input — deliberately:
84
+ * "continue" semantics belong to the product, not the schema.)
85
+ */
86
+ export function parseAgentInput(value: unknown): AgentInput {
87
+ const parsed = AgentInputSchema.safeParse(value);
88
+ if (parsed.success) return parsed.data;
89
+ // A union failure's own message is just "Invalid input" — the useful paths
90
+ // and messages live one level down, in the per-branch issue lists.
91
+ const flattened = parsed.error.issues.flatMap((issue) =>
92
+ issue.code === "invalid_union" ? issue.errors.flat() : [issue],
93
+ );
94
+ const detail = flattened
95
+ .map((issue) => `${issue.path.length ? `${issue.path.join(".")}: ` : ""}${issue.message}`)
96
+ .join("; ");
97
+ throw new Error(`invalid agent input: ${detail}`, { cause: parsed.error });
98
+ }
99
+
51
100
  /** Collapse an AgentInput down to the text the model sees first. */
52
101
  export function inputToText(input: AgentInput): string {
53
102
  if (typeof input === "string") return input;