@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.
- package/CHANGELOG.md +317 -0
- package/README.md +20 -4
- package/docs/consuming.md +269 -0
- package/docs/deploy.md +80 -0
- package/docs/harnesses.md +64 -0
- package/docs/rfds/0001-deterministic-echo-ids.md +44 -0
- package/package.json +23 -3
- package/src/client/client.ts +143 -50
- package/src/core/agents/acp/acp-agent.ts +9 -0
- package/src/core/agents/acp/mappings.ts +3 -3
- package/src/core/agents/base.ts +9 -1
- package/src/core/agents/claude-code/adapter.ts +116 -26
- package/src/core/agents/claude-code/claude-agent.ts +31 -3
- package/src/core/agents/claude-code/generator-session.ts +16 -5
- package/src/core/agents/claude-code/options.ts +13 -3
- package/src/core/agents/claude-code/session-manager.ts +9 -4
- package/src/core/agents/codex-app-server/codex-app-server-agent.ts +17 -3
- package/src/core/agents/codex-sdk/codex-sdk-agent.ts +3 -3
- package/src/core/agents/types.ts +1 -1
- package/src/core/diagnostics.ts +59 -0
- package/src/core/index.ts +3 -1
- package/src/core/presets.ts +15 -2
- package/src/core/provision/pins.ts +5 -1
- package/src/core/proxy/anthropic-proxy.ts +43 -2
- package/src/core/proxy/api-key-store.ts +37 -5
- package/src/core/proxy/index.ts +8 -1
- package/src/core/runtime/agent-runtime.ts +66 -18
- package/src/core/runtime/event-processor.ts +51 -26
- package/src/protocol/config.ts +8 -6
- package/src/{core/agents/error-classifier.ts → protocol/errors.ts} +33 -7
- package/src/protocol/factories.ts +106 -10
- package/src/protocol/guards.ts +53 -0
- package/src/protocol/index.ts +10 -0
- package/src/protocol/lifecycle.ts +289 -112
- package/src/protocol/meta.ts +14 -0
- package/src/protocol/part-input.ts +56 -7
- package/src/protocol/parts.ts +125 -10
- package/src/protocol/reduce.ts +749 -0
- package/src/protocol/selectors.ts +162 -0
- package/src/protocol/seq-cursor.ts +87 -0
- package/src/protocol/stop-reasons.ts +45 -0
- package/src/protocol/time.ts +23 -0
- package/src/protocol/tokens.ts +23 -0
- package/src/protocol/tool-state.ts +85 -25
- package/src/protocol/verify.ts +440 -0
- package/src/protocol/vocabulary.ts +18 -0
- package/src/protocol/wire.ts +81 -13
- package/src/server/acp/binding.ts +23 -2
- package/src/server/acp/translate.ts +51 -14
- package/src/server/agent-server.ts +85 -6
- package/AGENTS.md +0 -21
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
// The package ships raw .ts — keep imports exactly-used, or every consumer
|
|
3
|
+
// compiling with noUnusedLocals inherits the noise as build errors.
|
|
4
|
+
import { ErrorCategorySchema, ErrorInfoSchema } from "./errors.ts";
|
|
5
|
+
import { isUnknownEvent } from "./guards.ts";
|
|
6
|
+
import { AgentHarnessSchema } from "./harness.ts";
|
|
7
|
+
import {
|
|
8
|
+
type CompactionStatus,
|
|
9
|
+
CompactionStatusSchema,
|
|
10
|
+
CompactionTriggerSchema,
|
|
11
|
+
type DecodedLifecycleEvent,
|
|
12
|
+
PermissionOptionSchema,
|
|
13
|
+
PermissionOutcomeSchema,
|
|
14
|
+
PermissionToolCallSchema,
|
|
15
|
+
SessionEndReasonSchema,
|
|
16
|
+
StopReasonSchema,
|
|
17
|
+
type UnknownEvent,
|
|
18
|
+
} from "./lifecycle.ts";
|
|
19
|
+
import { type Part, PartSchema, type UnknownPart } from "./parts.ts";
|
|
20
|
+
import { EpochMsSchema } from "./time.ts";
|
|
21
|
+
import { DEFAULT_TOKEN_USAGE, TokenUsageSchema, addTokenUsage } from "./tokens.ts";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The canonical LifecycleEvent → conversation-state reducer.
|
|
25
|
+
*
|
|
26
|
+
* Every consumer of the stream ends up writing this fold (a UI store, a
|
|
27
|
+
* persistence shim, a CLI renderer — deus, agnt, and this repo's own CLI each
|
|
28
|
+
* had one). This is the reference implementation of the stream's consumption
|
|
29
|
+
* rules, so products inherit them instead of re-deriving them:
|
|
30
|
+
*
|
|
31
|
+
* - `message.part` snapshots UPSERT by `part.id` — including a tool part
|
|
32
|
+
* completing after its message (or turn) ended: the event's `messageId`
|
|
33
|
+
* names the ORIGINAL message, and the upsert lands there.
|
|
34
|
+
* - deltas are additive streaming aids; part snapshots are authoritative and
|
|
35
|
+
* replace whatever the deltas built (field-ownership rule:
|
|
36
|
+
* `delta("A"), delta("B"), snapshot{text:"C"}` folds to `"C"`).
|
|
37
|
+
* - permission requests resolve exactly once (turn end / cancel resolves
|
|
38
|
+
* stragglers as cancelled) — `pendingPermissions()` is what a UI renders.
|
|
39
|
+
* - at `turn.ended{stopReason:"cancelled"}` every non-terminal tool part of
|
|
40
|
+
* that turn transitions to `status:"cancelled"` (cancellation closure).
|
|
41
|
+
* - `session.compaction` is an ID-addressed POSITIONAL entity: its first
|
|
42
|
+
* event anchors it in the `timeline`; later upserts advance it in place.
|
|
43
|
+
* - `turn.ended.tokens` is the turn's billing total (summed into `totals`);
|
|
44
|
+
* `session.usage` is the live context gauge — different numbers, both kept.
|
|
45
|
+
* - unknown event types are preserved in `unknownEvents` (Law 6), never
|
|
46
|
+
* dropped.
|
|
47
|
+
*
|
|
48
|
+
* Pure and copy-on-write: `reduceConversation` never mutates its input; every
|
|
49
|
+
* changed slice (timeline entry, parts array, turn) is a fresh object, so the
|
|
50
|
+
* state is directly usable in identity-diffing UIs (React et al).
|
|
51
|
+
*
|
|
52
|
+
* Feed each event exactly once, in `seq` order — the wire client already
|
|
53
|
+
* dedupes and heals gaps; embedded consumers get ordered events from the sink.
|
|
54
|
+
* (`createSeqCursor()` is that discipline on its own, for consumers folding
|
|
55
|
+
* envelopes they received some other way.)
|
|
56
|
+
*
|
|
57
|
+
* Sinks that WRITE rather than render use `reduceConversationWithChanges`: the
|
|
58
|
+
* same fold, plus the list of what it touched (see `ConversationChange`).
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
const UnknownPartSchema = z.looseObject({
|
|
62
|
+
type: z.string(),
|
|
63
|
+
id: z.string(),
|
|
64
|
+
sessionId: z.string(),
|
|
65
|
+
messageId: z.string(),
|
|
66
|
+
parentToolCallId: z.string().optional(),
|
|
67
|
+
raw: z.record(z.string(), z.unknown()),
|
|
68
|
+
}) as unknown as z.ZodType<UnknownPart>;
|
|
69
|
+
|
|
70
|
+
export const ConversationMessageSchema = z.object({
|
|
71
|
+
kind: z.literal("message"),
|
|
72
|
+
messageId: z.string(),
|
|
73
|
+
sessionId: z.string(),
|
|
74
|
+
turnId: z.string(),
|
|
75
|
+
outputIndex: z.number(),
|
|
76
|
+
role: z.enum(["user", "assistant"]),
|
|
77
|
+
/** Set when this is a sub-agent's output — nest it under that tool call. */
|
|
78
|
+
parentToolCallId: z.string().optional(),
|
|
79
|
+
model: z.string().optional(),
|
|
80
|
+
parts: z.array(z.union([PartSchema, UnknownPartSchema])),
|
|
81
|
+
startedAt: EpochMsSchema,
|
|
82
|
+
endedAt: EpochMsSchema.optional(),
|
|
83
|
+
});
|
|
84
|
+
export type ConversationMessage = z.infer<typeof ConversationMessageSchema>;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The folded compaction entity. Upserts MERGE (a later event that omits
|
|
88
|
+
* `summary`/`preTokens` keeps the ones already known) — deliberately unlike
|
|
89
|
+
* `message.part`, whose snapshots fully REPLACE. A compaction's fields arrive
|
|
90
|
+
* from different harness events at different times ("started, 150k in" then
|
|
91
|
+
* "done, here's the summary"), so replace-semantics would make every producer
|
|
92
|
+
* restate everything or lose it. The cost is stated plainly: a producer cannot
|
|
93
|
+
* clear a compaction field once set.
|
|
94
|
+
*/
|
|
95
|
+
export const ConversationCompactionSchema = z.object({
|
|
96
|
+
kind: z.literal("compaction"),
|
|
97
|
+
compactionId: z.string(),
|
|
98
|
+
turnId: z.string(),
|
|
99
|
+
status: CompactionStatusSchema,
|
|
100
|
+
trigger: CompactionTriggerSchema.optional(),
|
|
101
|
+
preTokens: z.number().optional(),
|
|
102
|
+
postTokens: z.number().optional(),
|
|
103
|
+
summary: z.string().optional(),
|
|
104
|
+
abstractsIds: z.array(z.string()).optional(),
|
|
105
|
+
timestamp: EpochMsSchema,
|
|
106
|
+
});
|
|
107
|
+
export type ConversationCompaction = z.infer<typeof ConversationCompactionSchema>;
|
|
108
|
+
|
|
109
|
+
/** The ordered render list: messages and compactions interleaved in stream order. */
|
|
110
|
+
export const TimelineEntrySchema = z.discriminatedUnion("kind", [
|
|
111
|
+
ConversationMessageSchema,
|
|
112
|
+
ConversationCompactionSchema,
|
|
113
|
+
]);
|
|
114
|
+
export type TimelineEntry = z.infer<typeof TimelineEntrySchema>;
|
|
115
|
+
|
|
116
|
+
export const ConversationTurnSchema = z.object({
|
|
117
|
+
turnId: z.string(),
|
|
118
|
+
/** Derived view state, not wire vocabulary. */
|
|
119
|
+
status: z.enum(["active", "ended"]),
|
|
120
|
+
stopReason: StopReasonSchema.optional(),
|
|
121
|
+
finishReason: z.string().optional(),
|
|
122
|
+
tokens: TokenUsageSchema.optional(),
|
|
123
|
+
cost: z.number().optional(),
|
|
124
|
+
/** Terminal error from `turn.ended`, when the turn failed. */
|
|
125
|
+
error: ErrorInfoSchema.optional(),
|
|
126
|
+
/** Non-terminal `error` events attributed to this turn. */
|
|
127
|
+
errors: z.array(
|
|
128
|
+
z.object({
|
|
129
|
+
category: ErrorCategorySchema,
|
|
130
|
+
message: z.string(),
|
|
131
|
+
recoverable: z.boolean(),
|
|
132
|
+
timestamp: EpochMsSchema,
|
|
133
|
+
}),
|
|
134
|
+
),
|
|
135
|
+
startedAt: EpochMsSchema,
|
|
136
|
+
endedAt: EpochMsSchema.optional(),
|
|
137
|
+
});
|
|
138
|
+
export type ConversationTurn = z.infer<typeof ConversationTurnSchema>;
|
|
139
|
+
|
|
140
|
+
export const ConversationPermissionSchema = z.object({
|
|
141
|
+
requestId: z.string(),
|
|
142
|
+
turnId: z.string(),
|
|
143
|
+
title: z.string(),
|
|
144
|
+
toolCall: PermissionToolCallSchema.optional(),
|
|
145
|
+
options: z.array(PermissionOptionSchema),
|
|
146
|
+
requestedAt: EpochMsSchema,
|
|
147
|
+
/** Absent while the request is pending. */
|
|
148
|
+
outcome: PermissionOutcomeSchema.optional(),
|
|
149
|
+
resolvedAt: EpochMsSchema.optional(),
|
|
150
|
+
});
|
|
151
|
+
export type ConversationPermission = z.infer<typeof ConversationPermissionSchema>;
|
|
152
|
+
|
|
153
|
+
const UnknownEventSchema = z.looseObject({
|
|
154
|
+
type: z.string(),
|
|
155
|
+
sessionId: z.string().optional(),
|
|
156
|
+
raw: z.record(z.string(), z.unknown()),
|
|
157
|
+
}) as unknown as z.ZodType<UnknownEvent>;
|
|
158
|
+
|
|
159
|
+
export const ConversationStateSchema = z.object({
|
|
160
|
+
sessionId: z.string().optional(),
|
|
161
|
+
/** Persist this and pass it back as `config.resumeSessionId` to resume. */
|
|
162
|
+
nativeSessionId: z.string().optional(),
|
|
163
|
+
harness: AgentHarnessSchema.optional(),
|
|
164
|
+
model: z.string().optional(),
|
|
165
|
+
/** From the last resume-requesting turn: false = fresh-session fallback. */
|
|
166
|
+
resumed: z.boolean().optional(),
|
|
167
|
+
timeline: z.array(TimelineEntrySchema),
|
|
168
|
+
turns: z.array(ConversationTurnSchema),
|
|
169
|
+
permissions: z.array(ConversationPermissionSchema),
|
|
170
|
+
/** Live context gauge (`session.usage`) — how full the window is NOW. */
|
|
171
|
+
usage: z
|
|
172
|
+
.object({
|
|
173
|
+
used: z.number(),
|
|
174
|
+
size: z.number().optional(),
|
|
175
|
+
cost: z.number().optional(),
|
|
176
|
+
updatedAt: EpochMsSchema,
|
|
177
|
+
})
|
|
178
|
+
.optional(),
|
|
179
|
+
/** Sum of every ended turn's billing tokens. */
|
|
180
|
+
totals: TokenUsageSchema,
|
|
181
|
+
totalCost: z.number().optional(),
|
|
182
|
+
/** `error` events that carried no turnId. */
|
|
183
|
+
errors: z.array(
|
|
184
|
+
z.object({
|
|
185
|
+
category: ErrorCategorySchema,
|
|
186
|
+
message: z.string(),
|
|
187
|
+
recoverable: z.boolean(),
|
|
188
|
+
timestamp: EpochMsSchema,
|
|
189
|
+
}),
|
|
190
|
+
),
|
|
191
|
+
/** The harness session is over (`session.ended`). */
|
|
192
|
+
ended: z.object({ reason: SessionEndReasonSchema, timestamp: EpochMsSchema }).optional(),
|
|
193
|
+
/** Unknown event types, preserved in arrival order (Law 6). */
|
|
194
|
+
unknownEvents: z.array(UnknownEventSchema),
|
|
195
|
+
/**
|
|
196
|
+
* Raw JSON accumulated from `tool_input` deltas per part id, for UIs that
|
|
197
|
+
* stream tool input as it is typed. Cleared when the part's snapshot
|
|
198
|
+
* arrives with the parsed input (the authoritative form).
|
|
199
|
+
*/
|
|
200
|
+
toolInputJson: z.record(z.string(), z.string()),
|
|
201
|
+
});
|
|
202
|
+
export type ConversationState = z.infer<typeof ConversationStateSchema>;
|
|
203
|
+
|
|
204
|
+
/** A fresh, empty conversation state. */
|
|
205
|
+
export function emptyConversation(): ConversationState {
|
|
206
|
+
return {
|
|
207
|
+
timeline: [],
|
|
208
|
+
turns: [],
|
|
209
|
+
permissions: [],
|
|
210
|
+
// The same zero as `addTokenUsage` produces, so "nothing yet" and "summed
|
|
211
|
+
// nothing" are the same shape — a UI never has to handle two empties.
|
|
212
|
+
totals: { ...DEFAULT_TOKEN_USAGE },
|
|
213
|
+
errors: [],
|
|
214
|
+
unknownEvents: [],
|
|
215
|
+
toolInputJson: {},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The permission requests still awaiting an answer — render these. */
|
|
220
|
+
export function pendingPermissions(state: ConversationState): ConversationPermission[] {
|
|
221
|
+
return state.permissions.filter((p) => p.outcome === undefined);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The message entries of the timeline, in order (convenience accessor). */
|
|
225
|
+
export function conversationMessages(state: ConversationState): ConversationMessage[] {
|
|
226
|
+
return state.timeline.filter((e): e is ConversationMessage => e.kind === "message");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* What one fold actually touched — the seam between "the stream says this" and
|
|
231
|
+
* "so write these rows / invalidate these queries".
|
|
232
|
+
*
|
|
233
|
+
* Emitted by `reduceConversationWithChanges`, which INSTRUMENTS the fold
|
|
234
|
+
* rather than diffing states: every mutation site reports itself, so a change
|
|
235
|
+
* list is exactly the set of writes the reducer performed, in the order it
|
|
236
|
+
* performed them. A no-op fold (a replayed duplicate) reports nothing.
|
|
237
|
+
*
|
|
238
|
+
* The addressing is the wire's: `messageId`/`partId`/`turnId`/`requestId` are
|
|
239
|
+
* the same ids the events carry, so a sink can upsert by them without holding
|
|
240
|
+
* a second index. `outputIndex`/`partIndex` are the event's positions when the
|
|
241
|
+
* change came from a `message.part`; for the parts the reducer closes itself
|
|
242
|
+
* (cancellation closure) they are the message's `outputIndex` and the part's
|
|
243
|
+
* position in that message — the same numbers for any stream that arrived in
|
|
244
|
+
* order.
|
|
245
|
+
*/
|
|
246
|
+
export const ConversationChangeSchema = z.discriminatedUnion("kind", [
|
|
247
|
+
/** A message was added to the timeline (started, or shelled in by a part). */
|
|
248
|
+
z.object({ kind: z.literal("message-upserted"), messageId: z.string(), turnId: z.string() }),
|
|
249
|
+
/** A part snapshot landed in its message (or a delta extended one in place). */
|
|
250
|
+
z.object({
|
|
251
|
+
kind: z.literal("part-upserted"),
|
|
252
|
+
messageId: z.string(),
|
|
253
|
+
partId: z.string(),
|
|
254
|
+
outputIndex: z.number(),
|
|
255
|
+
partIndex: z.number(),
|
|
256
|
+
}),
|
|
257
|
+
/** A message got its `endedAt` (once — a repeat is not a change). */
|
|
258
|
+
z.object({ kind: z.literal("message-ended"), messageId: z.string() }),
|
|
259
|
+
/** Streamed tool-input JSON was appended to `toolInputJson[partId]`. */
|
|
260
|
+
z.object({ kind: z.literal("delta-buffered"), partId: z.string() }),
|
|
261
|
+
/** A turn was opened, ended, or gained a non-terminal error. */
|
|
262
|
+
z.object({ kind: z.literal("turn-updated"), turnId: z.string() }),
|
|
263
|
+
z.object({ kind: z.literal("compaction-upserted"), compactionId: z.string() }),
|
|
264
|
+
/** A permission was requested, answered, or cancelled by its turn ending. */
|
|
265
|
+
z.object({ kind: z.literal("permission-updated"), requestId: z.string() }),
|
|
266
|
+
/** The context gauge (`session.usage`) or the billing totals moved. */
|
|
267
|
+
z.object({ kind: z.literal("usage-updated") }),
|
|
268
|
+
/**
|
|
269
|
+
* Session-scoped facts changed: the `session.created` identity fields, or
|
|
270
|
+
* an `error` event with no turn to attribute (it lands in `state.errors`).
|
|
271
|
+
*/
|
|
272
|
+
z.object({ kind: z.literal("session-meta-updated") }),
|
|
273
|
+
z.object({ kind: z.literal("session-ended") }),
|
|
274
|
+
z.object({ kind: z.literal("unknown-event-recorded") }),
|
|
275
|
+
]);
|
|
276
|
+
export type ConversationChange = z.infer<typeof ConversationChangeSchema>;
|
|
277
|
+
|
|
278
|
+
/** Where a mutation site reports itself. */
|
|
279
|
+
type ChangeRecorder = (change: ConversationChange) => void;
|
|
280
|
+
|
|
281
|
+
/** The recorder the public `reduceConversation` passes — zero behavior change. */
|
|
282
|
+
const IGNORE_CHANGES: ChangeRecorder = () => {};
|
|
283
|
+
|
|
284
|
+
/** Replace the message with id `messageId` via `update`; no-op when absent. */
|
|
285
|
+
function withMessage(
|
|
286
|
+
state: ConversationState,
|
|
287
|
+
messageId: string,
|
|
288
|
+
update: (message: ConversationMessage) => ConversationMessage,
|
|
289
|
+
): ConversationState {
|
|
290
|
+
// Search from the end: the message being updated is almost always recent.
|
|
291
|
+
for (let i = state.timeline.length - 1; i >= 0; i--) {
|
|
292
|
+
const entry = state.timeline[i] as TimelineEntry;
|
|
293
|
+
if (entry.kind !== "message" || entry.messageId !== messageId) continue;
|
|
294
|
+
const updated = update(entry);
|
|
295
|
+
// Identity short-circuit: an update that declined (a repeated
|
|
296
|
+
// message.ended, a delta with no open snapshot) is a true no-op —
|
|
297
|
+
// returning the SAME state object makes "no changes recorded" and
|
|
298
|
+
// "state unchanged" the same fact, which sinks and tests rely on.
|
|
299
|
+
if (updated === entry) return state;
|
|
300
|
+
const timeline = state.timeline.slice();
|
|
301
|
+
timeline[i] = updated;
|
|
302
|
+
return { ...state, timeline };
|
|
303
|
+
}
|
|
304
|
+
return state;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Append a fresh message unless its id is already known (replay-safe). */
|
|
308
|
+
function openMessage(
|
|
309
|
+
state: ConversationState,
|
|
310
|
+
init: {
|
|
311
|
+
messageId: string;
|
|
312
|
+
sessionId: string;
|
|
313
|
+
turnId: string;
|
|
314
|
+
outputIndex: number;
|
|
315
|
+
timestamp: number;
|
|
316
|
+
role: "user" | "assistant";
|
|
317
|
+
parentToolCallId?: string;
|
|
318
|
+
model?: string;
|
|
319
|
+
},
|
|
320
|
+
record: ChangeRecorder,
|
|
321
|
+
): ConversationState {
|
|
322
|
+
if (state.timeline.some((e) => e.kind === "message" && e.messageId === init.messageId)) {
|
|
323
|
+
return state;
|
|
324
|
+
}
|
|
325
|
+
record({ kind: "message-upserted", messageId: init.messageId, turnId: init.turnId });
|
|
326
|
+
const message: ConversationMessage = {
|
|
327
|
+
kind: "message",
|
|
328
|
+
messageId: init.messageId,
|
|
329
|
+
sessionId: init.sessionId,
|
|
330
|
+
turnId: init.turnId,
|
|
331
|
+
outputIndex: init.outputIndex,
|
|
332
|
+
role: init.role,
|
|
333
|
+
...(init.parentToolCallId !== undefined && { parentToolCallId: init.parentToolCallId }),
|
|
334
|
+
...(init.model !== undefined && { model: init.model }),
|
|
335
|
+
parts: [],
|
|
336
|
+
startedAt: init.timestamp,
|
|
337
|
+
};
|
|
338
|
+
return { ...state, timeline: [...state.timeline, message] };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Resolve an ended turn's still-pending permissions as cancelled. */
|
|
342
|
+
function cancelPendingForTurn(
|
|
343
|
+
permissions: ConversationPermission[],
|
|
344
|
+
turnId: string,
|
|
345
|
+
at: number,
|
|
346
|
+
record: ChangeRecorder,
|
|
347
|
+
): ConversationPermission[] {
|
|
348
|
+
if (!permissions.some((p) => p.turnId === turnId && p.outcome === undefined)) {
|
|
349
|
+
return permissions;
|
|
350
|
+
}
|
|
351
|
+
return permissions.map((p) => {
|
|
352
|
+
if (p.turnId !== turnId || p.outcome !== undefined) return p;
|
|
353
|
+
record({ kind: "permission-updated", requestId: p.requestId });
|
|
354
|
+
return { ...p, outcome: { outcome: "cancelled" as const }, resolvedAt: at };
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function upsertPart(
|
|
359
|
+
parts: Array<Part | UnknownPart>,
|
|
360
|
+
part: Part | UnknownPart,
|
|
361
|
+
): Array<Part | UnknownPart> {
|
|
362
|
+
const index = parts.findIndex((p) => p.id === part.id);
|
|
363
|
+
if (index === -1) return [...parts, part];
|
|
364
|
+
const next = parts.slice();
|
|
365
|
+
next[index] = part;
|
|
366
|
+
return next;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* `_meta` key holding the partial JSON a PENDING tool was still streaming when
|
|
371
|
+
* the turn was cancelled. The `cancelled` state carries a parsed `input`,
|
|
372
|
+
* which a pending call never had — without this the only record of what the
|
|
373
|
+
* call was about is dropped, and a cancel-heavy UI renders an empty tool card.
|
|
374
|
+
* Namespaced per Law 4: root names on canonical types stay reserved.
|
|
375
|
+
*/
|
|
376
|
+
export const PARTIAL_INPUT_META_KEY = "agent-server/partialInput";
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Cancellation closure: transition every non-terminal tool part of the turn's
|
|
380
|
+
* messages to `status:"cancelled"` — guarantees a consistent terminal state
|
|
381
|
+
* even when the harness died mid-tool and could emit nothing.
|
|
382
|
+
*/
|
|
383
|
+
function closeCancelledTools(
|
|
384
|
+
state: ConversationState,
|
|
385
|
+
turnId: string,
|
|
386
|
+
at: number,
|
|
387
|
+
record: ChangeRecorder,
|
|
388
|
+
): ConversationState {
|
|
389
|
+
let changed = false;
|
|
390
|
+
const timeline = state.timeline.map((entry) => {
|
|
391
|
+
if (entry.kind !== "message" || entry.turnId !== turnId) return entry;
|
|
392
|
+
let messageChanged = false;
|
|
393
|
+
const parts = entry.parts.map((part, partIndex): Part | UnknownPart => {
|
|
394
|
+
if ("raw" in part || part.type !== "tool") return part;
|
|
395
|
+
const open = part.state;
|
|
396
|
+
if (open.status !== "pending" && open.status !== "in_progress") return part;
|
|
397
|
+
messageChanged = true;
|
|
398
|
+
record({
|
|
399
|
+
kind: "part-upserted",
|
|
400
|
+
messageId: entry.messageId,
|
|
401
|
+
partId: part.id,
|
|
402
|
+
outputIndex: entry.outputIndex,
|
|
403
|
+
partIndex,
|
|
404
|
+
});
|
|
405
|
+
// The half-streamed input survives the cancel. Its source is the
|
|
406
|
+
// SNAPSHOT's partialInput when the adapter re-stated one, else the
|
|
407
|
+
// fold's own delta accumulator — shipped adapters stream tool input
|
|
408
|
+
// as deltas without re-snapshotting the pending tool, so without the
|
|
409
|
+
// fallback the preservation never fires on a real cancel.
|
|
410
|
+
const streamed =
|
|
411
|
+
open.status === "pending" ? open.partialInput || state.toolInputJson[part.id] : undefined;
|
|
412
|
+
const meta = streamed ? { ...open._meta, [PARTIAL_INPUT_META_KEY]: streamed } : open._meta;
|
|
413
|
+
return {
|
|
414
|
+
...part,
|
|
415
|
+
state: {
|
|
416
|
+
status: "cancelled" as const,
|
|
417
|
+
input: open.status === "in_progress" ? open.input : {},
|
|
418
|
+
// A pending tool never started, so the cancel stamps both ends.
|
|
419
|
+
time: { start: open.status === "in_progress" ? open.time.start : at, end: at },
|
|
420
|
+
...(meta !== undefined && { _meta: meta }),
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
});
|
|
424
|
+
if (!messageChanged) return entry;
|
|
425
|
+
changed = true;
|
|
426
|
+
return { ...entry, parts };
|
|
427
|
+
});
|
|
428
|
+
return changed ? { ...state, timeline } : state;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Fold one lifecycle event into the conversation. Pure; returns a new state. */
|
|
432
|
+
export function reduceConversation(
|
|
433
|
+
state: ConversationState,
|
|
434
|
+
event: DecodedLifecycleEvent | UnknownEvent,
|
|
435
|
+
): ConversationState {
|
|
436
|
+
return reduceWith(state, event, IGNORE_CHANGES);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The same fold, plus the list of what it touched — for sinks that must WRITE
|
|
441
|
+
* rather than re-render: a persistence layer deciding which rows to upsert, a
|
|
442
|
+
* cache deciding which queries to invalidate, a projection keeping a second
|
|
443
|
+
* store in step.
|
|
444
|
+
*
|
|
445
|
+
* The changes are recorded BY the fold, not diffed from its output: there is
|
|
446
|
+
* exactly one implementation of the consumption rules, and `reduceConversation`
|
|
447
|
+
* is this function with the recorder thrown away. That is what makes the two
|
|
448
|
+
* impossible to drift apart — a new mutation site either reports itself or is
|
|
449
|
+
* invisible to every sink at once, which the tests pin.
|
|
450
|
+
*
|
|
451
|
+
* Ordering within one event is causal: the message shell before the part that
|
|
452
|
+
* forced it, the turn before the permissions its end cancelled. SQL semantics
|
|
453
|
+
* (ON CONFLICT vs REPLACE, COALESCE guards) stay yours — they are schema
|
|
454
|
+
* knowledge, not fold knowledge.
|
|
455
|
+
*/
|
|
456
|
+
export function reduceConversationWithChanges(
|
|
457
|
+
state: ConversationState,
|
|
458
|
+
event: DecodedLifecycleEvent | UnknownEvent,
|
|
459
|
+
): { state: ConversationState; changes: ConversationChange[] } {
|
|
460
|
+
const changes: ConversationChange[] = [];
|
|
461
|
+
const next = reduceWith(state, event, (change) => changes.push(change));
|
|
462
|
+
return { state: next, changes };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function reduceWith(
|
|
466
|
+
state: ConversationState,
|
|
467
|
+
event: DecodedLifecycleEvent | UnknownEvent,
|
|
468
|
+
record: ChangeRecorder,
|
|
469
|
+
): ConversationState {
|
|
470
|
+
if (isUnknownEvent(event)) {
|
|
471
|
+
record({ kind: "unknown-event-recorded" });
|
|
472
|
+
return { ...state, unknownEvents: [...state.unknownEvents, event] };
|
|
473
|
+
}
|
|
474
|
+
switch (event.type) {
|
|
475
|
+
case "session.created":
|
|
476
|
+
record({ kind: "session-meta-updated" });
|
|
477
|
+
return {
|
|
478
|
+
...state,
|
|
479
|
+
sessionId: event.sessionId,
|
|
480
|
+
nativeSessionId: event.nativeSessionId,
|
|
481
|
+
harness: event.harness,
|
|
482
|
+
...(event.model !== undefined && { model: event.model }),
|
|
483
|
+
...(event.resumed !== undefined && { resumed: event.resumed }),
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
case "session.ended":
|
|
487
|
+
record({ kind: "session-ended" });
|
|
488
|
+
return { ...state, ended: { reason: event.reason, timestamp: event.timestamp } };
|
|
489
|
+
|
|
490
|
+
case "turn.started": {
|
|
491
|
+
const existing = state.turns.findIndex((t) => t.turnId === event.turnId);
|
|
492
|
+
if (existing !== -1) return state; // replay overlap — the turn is known
|
|
493
|
+
const turn: ConversationTurn = {
|
|
494
|
+
turnId: event.turnId,
|
|
495
|
+
status: "active",
|
|
496
|
+
errors: [],
|
|
497
|
+
startedAt: event.timestamp,
|
|
498
|
+
};
|
|
499
|
+
record({ kind: "turn-updated", turnId: event.turnId });
|
|
500
|
+
return { ...state, turns: [...state.turns, turn] };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
case "message.started":
|
|
504
|
+
return openMessage(state, { ...event, role: event.role }, record);
|
|
505
|
+
|
|
506
|
+
case "message.part": {
|
|
507
|
+
// Tolerance for the contract's stated exception: a part may arrive for
|
|
508
|
+
// a message whose start this consumer never saw — open a shell for it.
|
|
509
|
+
const ensured = openMessage(state, { ...event, role: "assistant" }, record);
|
|
510
|
+
let upserted = withMessage(ensured, event.messageId, (message) => ({
|
|
511
|
+
...message,
|
|
512
|
+
parts: upsertPart(message.parts, event.part),
|
|
513
|
+
}));
|
|
514
|
+
record({
|
|
515
|
+
kind: "part-upserted",
|
|
516
|
+
messageId: event.messageId,
|
|
517
|
+
partId: event.part.id,
|
|
518
|
+
outputIndex: event.outputIndex,
|
|
519
|
+
partIndex: event.partIndex,
|
|
520
|
+
});
|
|
521
|
+
// Redelivery convergence: a snapshot replayed AFTER its turn already
|
|
522
|
+
// ended cancelled would re-open the tool (the replayed turn.ended is a
|
|
523
|
+
// no-op, so the closure never re-runs) — re-apply the closure so a
|
|
524
|
+
// whole-turn redelivery folds to the same terminal state.
|
|
525
|
+
const owningTurn = upserted.turns.find((t) => t.turnId === event.turnId);
|
|
526
|
+
if (
|
|
527
|
+
owningTurn?.status === "ended" &&
|
|
528
|
+
!("raw" in event.part) &&
|
|
529
|
+
event.part.type === "tool" &&
|
|
530
|
+
(event.part.state.status === "pending" || event.part.state.status === "in_progress")
|
|
531
|
+
) {
|
|
532
|
+
upserted = closeCancelledTools(
|
|
533
|
+
upserted,
|
|
534
|
+
event.turnId,
|
|
535
|
+
owningTurn.endedAt ?? event.timestamp,
|
|
536
|
+
record,
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
// The snapshot's input is authoritative — drop the streamed JSON.
|
|
540
|
+
if (
|
|
541
|
+
!("raw" in event.part) &&
|
|
542
|
+
event.part.type === "tool" &&
|
|
543
|
+
upserted.toolInputJson[event.part.id] !== undefined
|
|
544
|
+
) {
|
|
545
|
+
const { [event.part.id]: _dropped, ...toolInputJson } = upserted.toolInputJson;
|
|
546
|
+
return { ...upserted, toolInputJson };
|
|
547
|
+
}
|
|
548
|
+
return upserted;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
case "message.part.delta": {
|
|
552
|
+
const { delta } = event;
|
|
553
|
+
if (delta.type === "tool_input") {
|
|
554
|
+
record({ kind: "delta-buffered", partId: event.partId });
|
|
555
|
+
return {
|
|
556
|
+
...state,
|
|
557
|
+
toolInputJson: {
|
|
558
|
+
...state.toolInputJson,
|
|
559
|
+
[event.partId]: (state.toolInputJson[event.partId] ?? "") + delta.input,
|
|
560
|
+
},
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
return withMessage(state, event.messageId, (message) => {
|
|
564
|
+
const index = message.parts.findIndex((p) => p.id === event.partId);
|
|
565
|
+
if (index === -1) return message; // no open snapshot yet — the next snapshot carries the text
|
|
566
|
+
const part = message.parts[index] as Part | UnknownPart;
|
|
567
|
+
if ("raw" in part) return message;
|
|
568
|
+
if (part.type !== "text" && part.type !== "reasoning") return message;
|
|
569
|
+
const parts = message.parts.slice();
|
|
570
|
+
parts[index] = { ...part, text: part.text + delta.text };
|
|
571
|
+
record({
|
|
572
|
+
kind: "part-upserted",
|
|
573
|
+
messageId: event.messageId,
|
|
574
|
+
partId: event.partId,
|
|
575
|
+
outputIndex: event.outputIndex,
|
|
576
|
+
partIndex: event.partIndex,
|
|
577
|
+
});
|
|
578
|
+
return { ...message, parts };
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
case "message.ended":
|
|
583
|
+
return withMessage(state, event.messageId, (message) => {
|
|
584
|
+
if (message.endedAt !== undefined) return message;
|
|
585
|
+
record({ kind: "message-ended", messageId: event.messageId });
|
|
586
|
+
return { ...message, endedAt: event.timestamp };
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
case "turn.ended": {
|
|
590
|
+
const index = state.turns.findIndex((t) => t.turnId === event.turnId);
|
|
591
|
+
const base: ConversationTurn = (state.turns[index] as ConversationTurn | undefined) ?? {
|
|
592
|
+
turnId: event.turnId,
|
|
593
|
+
status: "active",
|
|
594
|
+
errors: [],
|
|
595
|
+
startedAt: event.timestamp,
|
|
596
|
+
};
|
|
597
|
+
if (base.status === "ended") return state; // replay overlap
|
|
598
|
+
const turn: ConversationTurn = {
|
|
599
|
+
...base,
|
|
600
|
+
status: "ended",
|
|
601
|
+
stopReason: event.stopReason,
|
|
602
|
+
...(event.finishReason !== undefined && { finishReason: event.finishReason }),
|
|
603
|
+
...(event.tokens !== undefined && { tokens: event.tokens }),
|
|
604
|
+
...(event.cost !== undefined && { cost: event.cost }),
|
|
605
|
+
...(event.error !== undefined && { error: event.error }),
|
|
606
|
+
endedAt: event.timestamp,
|
|
607
|
+
};
|
|
608
|
+
const turns = index === -1 ? [...state.turns, turn] : state.turns.slice();
|
|
609
|
+
if (index !== -1) turns[index] = turn;
|
|
610
|
+
record({ kind: "turn-updated", turnId: event.turnId });
|
|
611
|
+
// Terminal-permission invariant: normally a no-op (the engine emits
|
|
612
|
+
// permission.resolved first) — load-bearing on anomalous/partial
|
|
613
|
+
// streams so no prompt of an ended turn dangles as pending.
|
|
614
|
+
const permissions = cancelPendingForTurn(
|
|
615
|
+
state.permissions,
|
|
616
|
+
event.turnId,
|
|
617
|
+
event.timestamp,
|
|
618
|
+
record,
|
|
619
|
+
);
|
|
620
|
+
// The turn's billing rolls into the session aggregate.
|
|
621
|
+
if (event.tokens !== undefined || event.cost !== undefined) {
|
|
622
|
+
record({ kind: "usage-updated" });
|
|
623
|
+
}
|
|
624
|
+
let next: ConversationState = {
|
|
625
|
+
...state,
|
|
626
|
+
turns,
|
|
627
|
+
permissions,
|
|
628
|
+
...(event.tokens !== undefined && { totals: addTokenUsage(state.totals, event.tokens) }),
|
|
629
|
+
...(event.cost !== undefined && { totalCost: (state.totalCost ?? 0) + event.cost }),
|
|
630
|
+
};
|
|
631
|
+
// A turn's end closes whatever it left in flight — for EVERY terminal,
|
|
632
|
+
// not just user cancels. The contract already says so (§7's
|
|
633
|
+
// late-parts-are-terminal-tools rule: nothing non-terminal may follow
|
|
634
|
+
// turn.ended), and an error/max_tokens end that leaves a tool
|
|
635
|
+
// `in_progress` otherwise renders as a spinner that never stops in
|
|
636
|
+
// every consumer. `cancelled` is the truthful closed status: the tool
|
|
637
|
+
// did not fail — the turn died before it settled.
|
|
638
|
+
next = closeCancelledTools(next, event.turnId, event.timestamp, record);
|
|
639
|
+
return next;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
case "session.usage":
|
|
643
|
+
// Sticky merge: harnesses report `size`/`cost` intermittently (Claude
|
|
644
|
+
// only on the final result) — a fresher `used` must not erase the last
|
|
645
|
+
// known window size or cumulative cost. Consumers inherit this instead
|
|
646
|
+
// of re-deriving it (deus carried a SQL COALESCE for exactly this).
|
|
647
|
+
record({ kind: "usage-updated" });
|
|
648
|
+
return {
|
|
649
|
+
...state,
|
|
650
|
+
usage: {
|
|
651
|
+
used: event.used,
|
|
652
|
+
...(event.size !== undefined
|
|
653
|
+
? { size: event.size }
|
|
654
|
+
: state.usage?.size !== undefined
|
|
655
|
+
? { size: state.usage.size }
|
|
656
|
+
: {}),
|
|
657
|
+
...(event.cost !== undefined
|
|
658
|
+
? { cost: event.cost }
|
|
659
|
+
: state.usage?.cost !== undefined
|
|
660
|
+
? { cost: state.usage.cost }
|
|
661
|
+
: {}),
|
|
662
|
+
updatedAt: event.timestamp,
|
|
663
|
+
},
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
case "session.compaction": {
|
|
667
|
+
const index = state.timeline.findIndex(
|
|
668
|
+
(e) => e.kind === "compaction" && e.compactionId === event.compactionId,
|
|
669
|
+
);
|
|
670
|
+
const entry: ConversationCompaction = {
|
|
671
|
+
kind: "compaction",
|
|
672
|
+
compactionId: event.compactionId,
|
|
673
|
+
turnId: event.turnId,
|
|
674
|
+
status: event.status as CompactionStatus,
|
|
675
|
+
...(event.trigger !== undefined && { trigger: event.trigger }),
|
|
676
|
+
...(event.preTokens !== undefined && { preTokens: event.preTokens }),
|
|
677
|
+
...(event.postTokens !== undefined && { postTokens: event.postTokens }),
|
|
678
|
+
...(event.summary !== undefined && { summary: event.summary }),
|
|
679
|
+
...(event.abstractsIds !== undefined && { abstractsIds: event.abstractsIds }),
|
|
680
|
+
timestamp: event.timestamp,
|
|
681
|
+
};
|
|
682
|
+
record({ kind: "compaction-upserted", compactionId: event.compactionId });
|
|
683
|
+
if (index === -1) {
|
|
684
|
+
// First appearance anchors the entity's position in the timeline.
|
|
685
|
+
return { ...state, timeline: [...state.timeline, entry] };
|
|
686
|
+
}
|
|
687
|
+
// Upsert in place — the entity never moves.
|
|
688
|
+
const existing = state.timeline[index] as ConversationCompaction;
|
|
689
|
+
const timeline = state.timeline.slice();
|
|
690
|
+
timeline[index] = {
|
|
691
|
+
...existing,
|
|
692
|
+
...entry,
|
|
693
|
+
// Preserve first-seen anchor semantics: keep the original timestamp.
|
|
694
|
+
timestamp: existing.timestamp,
|
|
695
|
+
};
|
|
696
|
+
return { ...state, timeline };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
case "permission.requested": {
|
|
700
|
+
if (state.permissions.some((p) => p.requestId === event.requestId)) return state;
|
|
701
|
+
const permission: ConversationPermission = {
|
|
702
|
+
requestId: event.requestId,
|
|
703
|
+
turnId: event.turnId,
|
|
704
|
+
title: event.title,
|
|
705
|
+
...(event.toolCall !== undefined && { toolCall: event.toolCall }),
|
|
706
|
+
options: event.options,
|
|
707
|
+
requestedAt: event.timestamp,
|
|
708
|
+
};
|
|
709
|
+
record({ kind: "permission-updated", requestId: event.requestId });
|
|
710
|
+
return { ...state, permissions: [...state.permissions, permission] };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
case "permission.resolved": {
|
|
714
|
+
const index = state.permissions.findIndex((p) => p.requestId === event.requestId);
|
|
715
|
+
if (index === -1) return state;
|
|
716
|
+
const existing = state.permissions[index] as ConversationPermission;
|
|
717
|
+
if (existing.outcome !== undefined) return state; // resolves exactly once
|
|
718
|
+
const permissions = state.permissions.slice();
|
|
719
|
+
permissions[index] = { ...existing, outcome: event.outcome, resolvedAt: event.timestamp };
|
|
720
|
+
record({ kind: "permission-updated", requestId: event.requestId });
|
|
721
|
+
return { ...state, permissions };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
case "error": {
|
|
725
|
+
const entry = {
|
|
726
|
+
category: event.category,
|
|
727
|
+
message: event.message,
|
|
728
|
+
recoverable: event.recoverable,
|
|
729
|
+
timestamp: event.timestamp,
|
|
730
|
+
};
|
|
731
|
+
if (event.turnId !== undefined) {
|
|
732
|
+
const index = state.turns.findIndex((t) => t.turnId === event.turnId);
|
|
733
|
+
if (index !== -1) {
|
|
734
|
+
const turn = state.turns[index] as ConversationTurn;
|
|
735
|
+
const turns = state.turns.slice();
|
|
736
|
+
turns[index] = { ...turn, errors: [...turn.errors, entry] };
|
|
737
|
+
record({ kind: "turn-updated", turnId: event.turnId });
|
|
738
|
+
return { ...state, turns };
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
// No turn to attribute it to — a session-scoped fact.
|
|
742
|
+
record({ kind: "session-meta-updated" });
|
|
743
|
+
return { ...state, errors: [...state.errors, entry] };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
case "raw":
|
|
747
|
+
return state;
|
|
748
|
+
}
|
|
749
|
+
}
|