@zvada/agent-server 0.2.1 → 0.3.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/CHANGELOG.md +317 -0
- package/README.md +34 -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 +153 -49
- 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 +35 -5
- package/src/core/agents/claude-code/adapter.ts +116 -26
- package/src/core/agents/claude-code/claude-agent.ts +44 -7
- package/src/core/agents/claude-code/generator-session.ts +53 -14
- 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 +25 -6
- 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 +6 -2
- package/src/core/presets.ts +15 -2
- package/src/core/provision/pins.ts +5 -1
- package/src/core/proxy/anthropic-proxy.ts +76 -3
- package/src/core/runtime/agent-runtime.ts +215 -28
- package/src/core/runtime/event-processor.ts +51 -26
- package/src/core/utils/errors.ts +37 -3
- 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 +103 -7
- package/src/server/acp/binding.ts +23 -2
- package/src/server/acp/translate.ts +51 -14
- package/src/server/agent-server.ts +109 -6
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { isUnknownEvent } from "./guards.ts";
|
|
2
|
+
import {
|
|
3
|
+
type DecodedLifecycleEvent,
|
|
4
|
+
type UnknownEvent,
|
|
5
|
+
decodeLifecycleEvent,
|
|
6
|
+
} from "./lifecycle.ts";
|
|
7
|
+
import { conversationMessages, emptyConversation, reduceConversation } from "./reduce.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Machine-checkable stream-contract verifier (DeepSeek-harness style: replay
|
|
11
|
+
* the log, validate the projection, fail nonzero on disagreement).
|
|
12
|
+
*
|
|
13
|
+
* `verifyStreamContract` takes one session's ordered event stream and returns
|
|
14
|
+
* every violation of the PROTOCOL §7 ordering/delivery contract it can detect,
|
|
15
|
+
* plus a projection check: the reducer's final state must agree with the
|
|
16
|
+
* authoritative part snapshots (snapshots-beat-deltas made testable).
|
|
17
|
+
*
|
|
18
|
+
* Pure and consumer-grade: the CLI's `--verify` flag, the live tests, and any
|
|
19
|
+
* product pipeline (an agnt DO, deus's cli:backend) can all run it over a
|
|
20
|
+
* recorded stream.
|
|
21
|
+
*
|
|
22
|
+
* The ordering bookkeeping below (turns, open messages, part addresses,
|
|
23
|
+
* pending permissions) deliberately does NOT read `ConversationState`, even
|
|
24
|
+
* though the fold at the bottom computes lookalikes of most of it: a checker
|
|
25
|
+
* derived from the reducer can only ever confirm that the reducer agrees with
|
|
26
|
+
* itself. The duplication is the independence that makes `projection-agrees`
|
|
27
|
+
* and the re-delivery probe able to fail.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface ContractViolation {
|
|
31
|
+
/** Stable rule id, e.g. "echo-first" — grep-able and assertable in tests. */
|
|
32
|
+
rule: string;
|
|
33
|
+
/** Index into the supplied event array (-1 for stream-level violations). */
|
|
34
|
+
index: number;
|
|
35
|
+
message: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface VerifyOptions {
|
|
39
|
+
/**
|
|
40
|
+
* The engine always echoes the user message first (spec §7.2). Disable only
|
|
41
|
+
* for partial/legacy captures that begin mid-turn.
|
|
42
|
+
*/
|
|
43
|
+
expectEcho?: boolean;
|
|
44
|
+
/** Per-session seq values aligned with `events` (from wire envelopes). */
|
|
45
|
+
seqs?: number[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function verifyStreamContract(
|
|
49
|
+
events: Array<DecodedLifecycleEvent | UnknownEvent>,
|
|
50
|
+
options: VerifyOptions = {},
|
|
51
|
+
): ContractViolation[] {
|
|
52
|
+
const expectEcho = options.expectEcho ?? true;
|
|
53
|
+
const violations: ContractViolation[] = [];
|
|
54
|
+
const report = (rule: string, index: number, message: string) => {
|
|
55
|
+
violations.push({ rule, index, message });
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// -- validity: every event decodes ------------------------------------------
|
|
59
|
+
// Via the Law-6 decoder, not the closed union: an unknown event type (or an
|
|
60
|
+
// unknown part inside a known event) is forward-compat, not a violation —
|
|
61
|
+
// only a KNOWN type with a malformed body is.
|
|
62
|
+
events.forEach((event, i) => {
|
|
63
|
+
try {
|
|
64
|
+
decodeLifecycleEvent(event);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
report(
|
|
67
|
+
"schema-valid",
|
|
68
|
+
i,
|
|
69
|
+
`event ${i} (${String((event as { type?: unknown }).type)}) fails schema: ${err instanceof Error ? err.message : String(err)}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const ts = (event as { timestamp?: unknown }).timestamp;
|
|
73
|
+
if (typeof ts === "number" && (!Number.isSafeInteger(ts) || ts < 0 || Object.is(ts, -0))) {
|
|
74
|
+
report("timestamp-valid", i, `event ${i} timestamp ${ts} is not a non-negative safe integer`);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// -- seq monotonicity (when envelopes were supplied) ------------------------
|
|
79
|
+
if (options.seqs) {
|
|
80
|
+
for (let i = 1; i < options.seqs.length; i++) {
|
|
81
|
+
const prev = options.seqs[i - 1] as number;
|
|
82
|
+
const next = options.seqs[i] as number;
|
|
83
|
+
if (next <= prev) {
|
|
84
|
+
report("seq-monotonic", i, `seq ${next} at event ${i} does not increase past ${prev}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// -- turn / message / part bookkeeping --------------------------------------
|
|
90
|
+
interface TurnState {
|
|
91
|
+
startedIndex: number;
|
|
92
|
+
endedIndex?: number;
|
|
93
|
+
firstMessageChecked: boolean;
|
|
94
|
+
openMessages: Map<string, number>; // messageId -> started index
|
|
95
|
+
endedMessages: Set<string>;
|
|
96
|
+
lastOutputIndex: number;
|
|
97
|
+
}
|
|
98
|
+
const turns = new Map<string, TurnState>();
|
|
99
|
+
/** part.id -> its fixed wire address (message/indices are upsert-stable). */
|
|
100
|
+
const partAddress = new Map<string, { messageId: string; partIndex: number }>();
|
|
101
|
+
const messageRole = new Map<string, "user" | "assistant">();
|
|
102
|
+
const pendingPermissions = new Map<string, number>();
|
|
103
|
+
const resolvedPermissions = new Set<string>();
|
|
104
|
+
/**
|
|
105
|
+
* Scoped PER TURN, not per stream: the engine re-announces the harness
|
|
106
|
+
* session on every turn's first yield (`session.created` is where the native
|
|
107
|
+
* id — and `resumed` — becomes knowable), so a multi-turn session legally
|
|
108
|
+
* carries one per turn. Two within a single turn is the real violation.
|
|
109
|
+
*/
|
|
110
|
+
let sessionCreatedInTurn = 0;
|
|
111
|
+
|
|
112
|
+
events.forEach((event, i) => {
|
|
113
|
+
// Unknown types carry no contract obligations we can check — they are
|
|
114
|
+
// preserved, not interpreted.
|
|
115
|
+
if (isUnknownEvent(event)) return;
|
|
116
|
+
switch (event.type) {
|
|
117
|
+
case "session.created":
|
|
118
|
+
sessionCreatedInTurn++;
|
|
119
|
+
if (sessionCreatedInTurn > 1) {
|
|
120
|
+
report("session-created-once", i, "session.created emitted twice within one turn");
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
case "turn.started": {
|
|
124
|
+
sessionCreatedInTurn = 0;
|
|
125
|
+
if (turns.has(event.turnId)) {
|
|
126
|
+
report("turn-started-once", i, `turn.started repeated for ${event.turnId}`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
turns.set(event.turnId, {
|
|
130
|
+
startedIndex: i,
|
|
131
|
+
firstMessageChecked: false,
|
|
132
|
+
openMessages: new Map(),
|
|
133
|
+
endedMessages: new Set(),
|
|
134
|
+
lastOutputIndex: -1,
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
case "message.started": {
|
|
139
|
+
const turn = turns.get(event.turnId);
|
|
140
|
+
if (!turn) {
|
|
141
|
+
report("message-in-turn", i, `message.started for unknown turn ${event.turnId}`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (turn.endedIndex !== undefined) {
|
|
145
|
+
report(
|
|
146
|
+
"no-messages-after-turn-end",
|
|
147
|
+
i,
|
|
148
|
+
`message.started after turn.ended (${event.turnId})`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (!turn.firstMessageChecked) {
|
|
152
|
+
turn.firstMessageChecked = true;
|
|
153
|
+
if (expectEcho && (event.role !== "user" || event.outputIndex !== 0)) {
|
|
154
|
+
report(
|
|
155
|
+
"echo-first",
|
|
156
|
+
i,
|
|
157
|
+
`first message of turn ${event.turnId} is role=${event.role} outputIndex=${event.outputIndex}; expected the user echo at outputIndex 0`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (event.outputIndex <= turn.lastOutputIndex) {
|
|
162
|
+
report(
|
|
163
|
+
"output-index-increases",
|
|
164
|
+
i,
|
|
165
|
+
`outputIndex ${event.outputIndex} does not increase past ${turn.lastOutputIndex}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
turn.lastOutputIndex = Math.max(turn.lastOutputIndex, event.outputIndex);
|
|
169
|
+
turn.openMessages.set(event.messageId, i);
|
|
170
|
+
messageRole.set(event.messageId, event.role);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
case "message.part": {
|
|
174
|
+
const known = partAddress.get(event.part.id);
|
|
175
|
+
if (known) {
|
|
176
|
+
if (known.messageId !== event.messageId || known.partIndex !== event.partIndex) {
|
|
177
|
+
report(
|
|
178
|
+
"part-address-stable",
|
|
179
|
+
i,
|
|
180
|
+
`part ${event.part.id} moved from ${known.messageId}#${known.partIndex} to ${event.messageId}#${event.partIndex} — upsert addresses are fixed at first emission`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
partAddress.set(event.part.id, {
|
|
185
|
+
messageId: event.messageId,
|
|
186
|
+
partIndex: event.partIndex,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
// Two distinct failures, two distinct messages: an unaddressable part
|
|
190
|
+
// and a part that disagrees with its event are different bugs, and a
|
|
191
|
+
// shared message sends the reader hunting the wrong one.
|
|
192
|
+
if (event.part.id === "") {
|
|
193
|
+
report("part-self-describing", i, "part carries an empty id — nothing to upsert by");
|
|
194
|
+
} else if (event.part.messageId !== event.messageId) {
|
|
195
|
+
report(
|
|
196
|
+
"part-self-describing",
|
|
197
|
+
i,
|
|
198
|
+
`part ${event.part.id} carries messageId ${event.part.messageId} but the event says ${event.messageId}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
// An unknown part type is preserved, never interpreted: the rules
|
|
202
|
+
// below read a part's KNOWN shape, so they simply don't apply to it.
|
|
203
|
+
const part = event.part;
|
|
204
|
+
if ("raw" in part) return;
|
|
205
|
+
const turn = turns.get(event.turnId);
|
|
206
|
+
if (turn?.endedIndex !== undefined) {
|
|
207
|
+
const lateOk =
|
|
208
|
+
part.type === "tool" &&
|
|
209
|
+
(part.state.status === "completed" ||
|
|
210
|
+
part.state.status === "failed" ||
|
|
211
|
+
part.state.status === "cancelled");
|
|
212
|
+
if (!lateOk) {
|
|
213
|
+
report(
|
|
214
|
+
"late-parts-are-terminal-tools",
|
|
215
|
+
i,
|
|
216
|
+
`non-terminal ${part.type} snapshot after turn.ended`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (
|
|
221
|
+
expectEcho &&
|
|
222
|
+
messageRole.get(event.messageId) === "user" &&
|
|
223
|
+
(part.type === "text" || part.type === "reasoning") &&
|
|
224
|
+
part.state !== "done"
|
|
225
|
+
) {
|
|
226
|
+
report("echo-parts-done", i, "user echo part is not state done");
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
case "message.part.delta": {
|
|
231
|
+
if (!partAddress.has(event.partId)) {
|
|
232
|
+
report(
|
|
233
|
+
"delta-follows-snapshot",
|
|
234
|
+
i,
|
|
235
|
+
`delta for part ${event.partId} arrived before any message.part snapshot`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
case "message.ended": {
|
|
241
|
+
const turn = turns.get(event.turnId);
|
|
242
|
+
if (turn) {
|
|
243
|
+
if (!turn.openMessages.has(event.messageId) && !turn.endedMessages.has(event.messageId)) {
|
|
244
|
+
report("bracket-pairing", i, `message.ended for unknown message ${event.messageId}`);
|
|
245
|
+
}
|
|
246
|
+
turn.openMessages.delete(event.messageId);
|
|
247
|
+
turn.endedMessages.add(event.messageId);
|
|
248
|
+
}
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
case "turn.ended": {
|
|
252
|
+
const turn = turns.get(event.turnId);
|
|
253
|
+
if (!turn) {
|
|
254
|
+
report("turn-ended-pairs", i, `turn.ended for unknown turn ${event.turnId}`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (turn.endedIndex !== undefined) {
|
|
258
|
+
report("turn-ended-once", i, `turn.ended repeated for ${event.turnId}`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
turn.endedIndex = i;
|
|
262
|
+
if (turn.openMessages.size > 0) {
|
|
263
|
+
report(
|
|
264
|
+
"messages-closed-by-turn-end",
|
|
265
|
+
i,
|
|
266
|
+
`turn.ended with ${turn.openMessages.size} unclosed message(s)`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
if (event.stopReason === "error" && event.error === undefined) {
|
|
270
|
+
report("error-carries-info", i, "turn.ended stopReason=error without error info");
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
case "permission.requested":
|
|
275
|
+
pendingPermissions.set(event.requestId, i);
|
|
276
|
+
return;
|
|
277
|
+
case "permission.resolved":
|
|
278
|
+
if (resolvedPermissions.has(event.requestId)) {
|
|
279
|
+
report("permission-resolves-once", i, `permission ${event.requestId} resolved twice`);
|
|
280
|
+
}
|
|
281
|
+
resolvedPermissions.add(event.requestId);
|
|
282
|
+
pendingPermissions.delete(event.requestId);
|
|
283
|
+
return;
|
|
284
|
+
default:
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
for (const [turnId, turn] of turns) {
|
|
290
|
+
if (turn.endedIndex === undefined) {
|
|
291
|
+
report("turn-ended-pairs", turn.startedIndex, `turn ${turnId} never ended`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
for (const [requestId, index] of pendingPermissions) {
|
|
295
|
+
report("permission-resolves-once", index, `permission ${requestId} never resolved`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// -- projection agreement: reducer state ≡ authoritative snapshots ----------
|
|
299
|
+
// (DeepSeek's "projection and storage must not disagree", for our fold.)
|
|
300
|
+
/** part.id -> its last full snapshot and where in the stream it landed. */
|
|
301
|
+
const lastSnapshot = new Map<
|
|
302
|
+
string,
|
|
303
|
+
{ event: DecodedLifecycleEvent & { type: "message.part" }; index: number }
|
|
304
|
+
>();
|
|
305
|
+
/** part.id -> index of the last text/reasoning delta (tool_input never folds into text). */
|
|
306
|
+
const lastTextDelta = new Map<string, number>();
|
|
307
|
+
events.forEach((event, i) => {
|
|
308
|
+
if (isUnknownEvent(event)) return;
|
|
309
|
+
if (event.type === "message.part") lastSnapshot.set(event.part.id, { event, index: i });
|
|
310
|
+
else if (event.type === "message.part.delta" && event.delta.type !== "tool_input") {
|
|
311
|
+
lastTextDelta.set(event.partId, i);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
let state = emptyConversation();
|
|
315
|
+
try {
|
|
316
|
+
for (const event of events) state = reduceConversation(state, event);
|
|
317
|
+
} catch (err) {
|
|
318
|
+
report(
|
|
319
|
+
"reducer-total",
|
|
320
|
+
-1,
|
|
321
|
+
`reduceConversation threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
322
|
+
);
|
|
323
|
+
return violations;
|
|
324
|
+
}
|
|
325
|
+
// The fold closes in-flight tools on EVERY terminal turn.ended (not only
|
|
326
|
+
// cancels): an ended turn's dangling tool legitimately reduces to
|
|
327
|
+
// `cancelled` with no snapshot saying so.
|
|
328
|
+
const endedTurns = new Set(state.turns.filter((t) => t.status === "ended").map((t) => t.turnId));
|
|
329
|
+
for (const message of conversationMessages(state)) {
|
|
330
|
+
for (const part of message.parts) {
|
|
331
|
+
if ("raw" in part) continue;
|
|
332
|
+
const source = lastSnapshot.get(part.id);
|
|
333
|
+
const snapshot = source?.event.part;
|
|
334
|
+
if (!snapshot) {
|
|
335
|
+
report("projection-agrees", -1, `reduced part ${part.id} has no source snapshot`);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if ("raw" in snapshot) continue; // preserved, not interpreted (Law 6)
|
|
339
|
+
if (
|
|
340
|
+
(part.type === "text" || part.type === "reasoning") &&
|
|
341
|
+
(snapshot.type === "text" || snapshot.type === "reasoning")
|
|
342
|
+
) {
|
|
343
|
+
// Deltas that arrive AFTER the last snapshot legitimately extend it —
|
|
344
|
+
// that is the shipped fold ("appends later deltas onto the replacement
|
|
345
|
+
// snapshot"), and it is how EVERY cancelled turn ends: trailing deltas
|
|
346
|
+
// with no closing `content_block_stop` to snapshot them. Authority
|
|
347
|
+
// still bites: the snapshot must be a PREFIX of what the fold holds.
|
|
348
|
+
const extended = (lastTextDelta.get(part.id) ?? -1) > (source?.index ?? -1);
|
|
349
|
+
const agrees = extended ? part.text.startsWith(snapshot.text) : part.text === snapshot.text;
|
|
350
|
+
if (!agrees) {
|
|
351
|
+
report(
|
|
352
|
+
"projection-agrees",
|
|
353
|
+
-1,
|
|
354
|
+
extended
|
|
355
|
+
? `part ${part.id} reduced text (${part.text.length} chars) does not extend its last snapshot (${snapshot.text.length} chars) — later deltas may only append`
|
|
356
|
+
: `part ${part.id} reduced text (${part.text.length} chars) != last snapshot (${snapshot.text.length} chars) — snapshots must be authoritative`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (part.type === "tool" && snapshot.type === "tool") {
|
|
361
|
+
const closedByTurnEnd =
|
|
362
|
+
endedTurns.has(message.turnId) &&
|
|
363
|
+
part.state.status === "cancelled" &&
|
|
364
|
+
(snapshot.state.status === "pending" || snapshot.state.status === "in_progress");
|
|
365
|
+
if (part.state.status !== snapshot.state.status && !closedByTurnEnd) {
|
|
366
|
+
report(
|
|
367
|
+
"projection-agrees",
|
|
368
|
+
-1,
|
|
369
|
+
`tool ${part.id} reduced status ${part.state.status} != snapshot ${snapshot.state.status}`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// -- re-delivery idempotency ------------------------------------------------
|
|
377
|
+
// The wire redelivers: a replay overlap or a reconnect resync hands the same
|
|
378
|
+
// event to the fold twice. Folding a stream in which every event WITH
|
|
379
|
+
// history is duplicated must land on the same state as folding it once —
|
|
380
|
+
// otherwise a reconnect silently doubles a part, a token bill, or an answer.
|
|
381
|
+
// (Refolding the identical sequence, which this check used to do, cannot
|
|
382
|
+
// fail: the reducer is a pure function of its arguments.)
|
|
383
|
+
const withDuplicates: Array<DecodedLifecycleEvent | UnknownEvent> = [];
|
|
384
|
+
for (const event of events) {
|
|
385
|
+
withDuplicates.push(event);
|
|
386
|
+
if (
|
|
387
|
+
event.type === "message.part" ||
|
|
388
|
+
event.type === "turn.ended" ||
|
|
389
|
+
event.type === "permission.resolved"
|
|
390
|
+
) {
|
|
391
|
+
withDuplicates.push(event);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
let redelivered = emptyConversation();
|
|
395
|
+
try {
|
|
396
|
+
for (const event of withDuplicates) redelivered = reduceConversation(redelivered, event);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
report(
|
|
399
|
+
"reducer-total",
|
|
400
|
+
-1,
|
|
401
|
+
`reduceConversation threw on re-delivery: ${err instanceof Error ? err.message : String(err)}`,
|
|
402
|
+
);
|
|
403
|
+
return violations;
|
|
404
|
+
}
|
|
405
|
+
const partCounts = (s: typeof state) =>
|
|
406
|
+
new Map(conversationMessages(s).map((m) => [m.messageId, m.parts.length] as const));
|
|
407
|
+
const outcomes = (s: typeof state) =>
|
|
408
|
+
new Map(s.permissions.map((p) => [p.requestId, JSON.stringify(p.outcome)] as const));
|
|
409
|
+
const idempotent = (message: string) => report("reducer-idempotent", -1, message);
|
|
410
|
+
if (redelivered.timeline.length !== state.timeline.length) {
|
|
411
|
+
idempotent(
|
|
412
|
+
`re-delivering upserts changed the timeline (${state.timeline.length} -> ${redelivered.timeline.length} entries)`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
const once = partCounts(state);
|
|
416
|
+
for (const [messageId, count] of partCounts(redelivered)) {
|
|
417
|
+
if (once.get(messageId) !== count) {
|
|
418
|
+
idempotent(
|
|
419
|
+
`re-delivering upserts changed message ${messageId}'s part count (${once.get(messageId)} -> ${count})`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const resolvedOnce = outcomes(state);
|
|
424
|
+
for (const [requestId, outcome] of outcomes(redelivered)) {
|
|
425
|
+
if (resolvedOnce.get(requestId) !== outcome) {
|
|
426
|
+
idempotent(`re-delivering changed permission ${requestId}'s outcome`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (JSON.stringify(redelivered.totals) !== JSON.stringify(state.totals)) {
|
|
430
|
+
idempotent("re-delivering turn.ended double-counted the billing totals");
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return violations;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Render violations for terminal output; empty string when clean. */
|
|
437
|
+
export function formatViolations(violations: ContractViolation[]): string {
|
|
438
|
+
if (violations.length === 0) return "";
|
|
439
|
+
return violations.map((v) => ` [${v.rule}] #${v.index}: ${v.message}`).join("\n");
|
|
440
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Law 3 — an OPEN vocabulary. The schema accepts values this build does not
|
|
5
|
+
* know (a newer engine's `stopReason`, an adapter's `_`-prefixed extension),
|
|
6
|
+
* because rejecting them is exactly how a forward-compatible wire turns into a
|
|
7
|
+
* compatibility break — the failure ACP moved to v2 to escape. The cast keeps
|
|
8
|
+
* the TypeScript side as `"a" | "b" | (string & {})`, so the canonical members
|
|
9
|
+
* still autocomplete and still get checked by the casing test (Law 9), while
|
|
10
|
+
* the runtime lets an unknown value through to be preserved.
|
|
11
|
+
*
|
|
12
|
+
* Blank is the one thing every open vocabulary rejects: `""` and `" "` are
|
|
13
|
+
* not values a consumer can switch on, render, or persist — they are a
|
|
14
|
+
* producer bug, and accepting them silently is how it reaches a UI.
|
|
15
|
+
*/
|
|
16
|
+
export function openVocabulary<T extends string>(): z.ZodType<T> {
|
|
17
|
+
return z.string().min(1).regex(/\S/, "must not be blank") as unknown as z.ZodType<T>;
|
|
18
|
+
}
|
package/src/protocol/wire.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { RunConfigSchema } from "./config.ts";
|
|
3
3
|
import { AgentCapabilitiesSchema } from "./harness.ts";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
type DecodedLifecycleEvent,
|
|
6
|
+
LifecycleEventSchema,
|
|
7
|
+
PermissionOutcomeSchema,
|
|
8
|
+
type UnknownEvent,
|
|
9
|
+
decodeLifecycleEvent,
|
|
10
|
+
} from "./lifecycle.ts";
|
|
5
11
|
import { AgentInputSchema } from "./part-input.ts";
|
|
6
12
|
|
|
7
13
|
/**
|
|
@@ -12,7 +18,7 @@ import { AgentInputSchema } from "./part-input.ts";
|
|
|
12
18
|
* via the `turn.ended` lifecycle event, mirroring the engine's event-driven
|
|
13
19
|
* model (and ACP v2's prompt lifecycle).
|
|
14
20
|
*/
|
|
15
|
-
export const WIRE_PROTOCOL_VERSION =
|
|
21
|
+
export const WIRE_PROTOCOL_VERSION = 2;
|
|
16
22
|
|
|
17
23
|
// ---- JSON-RPC 2.0 envelopes --------------------------------------------------
|
|
18
24
|
|
|
@@ -45,6 +51,8 @@ export const WIRE_ERROR_CODES = {
|
|
|
45
51
|
protocolVersionMismatch: -32004,
|
|
46
52
|
/** A session/close for this session is still in flight; retry after it settles. */
|
|
47
53
|
sessionClosing: -32005,
|
|
54
|
+
/** A turn with this turnId already ran (or is running) with DIFFERENT input. */
|
|
55
|
+
turnConflict: -32006,
|
|
48
56
|
} as const;
|
|
49
57
|
|
|
50
58
|
// ---- methods -----------------------------------------------------------------
|
|
@@ -77,6 +85,14 @@ export type InitializeParams = z.infer<typeof InitializeParamsSchema>;
|
|
|
77
85
|
export const InitializeResultSchema = z.object({
|
|
78
86
|
protocolVersion: z.number().int().positive(),
|
|
79
87
|
server: WireImplementationInfoSchema,
|
|
88
|
+
/**
|
|
89
|
+
* Minted once per server PROCESS. A reconnecting client re-initializes and
|
|
90
|
+
* compares: a changed id means every session log it was tracking belongs to
|
|
91
|
+
* a dead process — adopt the fresh logs (reset cursors, resync) instead of
|
|
92
|
+
* inferring the restart from seq patterns. The handshake STATES the one
|
|
93
|
+
* fact four downstream heuristics used to guess.
|
|
94
|
+
*/
|
|
95
|
+
instanceId: z.string(),
|
|
80
96
|
/** Capabilities per registered harness — only available harnesses appear. */
|
|
81
97
|
harnesses: z.record(z.string(), AgentCapabilitiesSchema),
|
|
82
98
|
});
|
|
@@ -89,8 +105,8 @@ export type InitializeResult = z.infer<typeof InitializeResultSchema>;
|
|
|
89
105
|
* arrive before the ack's promise continuation runs.)
|
|
90
106
|
*/
|
|
91
107
|
export const TurnStartParamsSchema = z.object({
|
|
92
|
-
sessionId: z.string().optional(),
|
|
93
|
-
turnId: z.string().optional(),
|
|
108
|
+
sessionId: z.string().min(1).optional(),
|
|
109
|
+
turnId: z.string().min(1).optional(),
|
|
94
110
|
input: AgentInputSchema,
|
|
95
111
|
config: RunConfigSchema,
|
|
96
112
|
});
|
|
@@ -100,14 +116,42 @@ export type TurnStartParams = z.infer<typeof TurnStartParamsSchema>;
|
|
|
100
116
|
export const TurnStartResultSchema = z.object({
|
|
101
117
|
sessionId: z.string(),
|
|
102
118
|
turnId: z.string(),
|
|
119
|
+
/**
|
|
120
|
+
* The request converged onto an already-admitted turn with the same turnId
|
|
121
|
+
* and identical input (a retried `turn/start` after a lost ack). No second
|
|
122
|
+
* execution happened; catch up on its events via `events/replay`.
|
|
123
|
+
*/
|
|
124
|
+
deduplicated: z.boolean().optional(),
|
|
103
125
|
});
|
|
104
126
|
export type TurnStartResult = z.infer<typeof TurnStartResultSchema>;
|
|
105
127
|
|
|
106
|
-
export const TurnCancelParamsSchema = z.object({
|
|
128
|
+
export const TurnCancelParamsSchema = z.object({
|
|
129
|
+
sessionId: z.string().min(1),
|
|
130
|
+
/**
|
|
131
|
+
* Cancel only if THIS turn is the active one — a late cancel meant for a
|
|
132
|
+
* finished turn must not kill its successor. Omitted = session-scoped
|
|
133
|
+
* cancel of whatever is active (legacy behavior). Empty ids are rejected
|
|
134
|
+
* rather than silently treated as unstamped.
|
|
135
|
+
*/
|
|
136
|
+
turnId: z.string().min(1).optional(),
|
|
137
|
+
});
|
|
107
138
|
export type TurnCancelParams = z.infer<typeof TurnCancelParamsSchema>;
|
|
108
139
|
|
|
109
|
-
/**
|
|
110
|
-
|
|
140
|
+
/**
|
|
141
|
+
* Single-outcome cancel result (replaces the 0.2 two-boolean shape whose
|
|
142
|
+
* combinations included illegal states):
|
|
143
|
+
* - `cancelled` — the harness confirmed the interrupt.
|
|
144
|
+
* - `unconfirmed` — best-effort dispatched but unacknowledged; the agent
|
|
145
|
+
* process may still be running and the turn's `turn.ended` remains the
|
|
146
|
+
* source of truth.
|
|
147
|
+
* - `no_active_turn` — nothing to cancel: the session had no active turn, or
|
|
148
|
+
* `turnId` was given and a DIFFERENT turn is active (see `activeTurnId`).
|
|
149
|
+
*/
|
|
150
|
+
export const TurnCancelResultSchema = z.discriminatedUnion("outcome", [
|
|
151
|
+
z.object({ outcome: z.literal("cancelled"), turnId: z.string() }),
|
|
152
|
+
z.object({ outcome: z.literal("unconfirmed"), turnId: z.string() }),
|
|
153
|
+
z.object({ outcome: z.literal("no_active_turn"), activeTurnId: z.string().optional() }),
|
|
154
|
+
]);
|
|
111
155
|
export type TurnCancelResult = z.infer<typeof TurnCancelResultSchema>;
|
|
112
156
|
|
|
113
157
|
export const SessionCloseParamsSchema = z.object({ sessionId: z.string() });
|
|
@@ -141,6 +185,58 @@ export const WireEventEnvelopeSchema = z.object({
|
|
|
141
185
|
});
|
|
142
186
|
export type WireEventEnvelope = z.infer<typeof WireEventEnvelopeSchema>;
|
|
143
187
|
|
|
188
|
+
/** The routing fields of an envelope — strict, because the wire owns them. */
|
|
189
|
+
const WireEventEnvelopeHeaderSchema = z.object({
|
|
190
|
+
sessionId: z.string(),
|
|
191
|
+
seq: z.number().int().positive(),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
/** An envelope whose event was decoded the Law-6 way (unknowns preserved). */
|
|
195
|
+
export interface DecodedWireEventEnvelope {
|
|
196
|
+
sessionId: string;
|
|
197
|
+
seq: number;
|
|
198
|
+
event: DecodedLifecycleEvent | UnknownEvent;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Decode one `event` notification: envelope strict, event lenient. A consumer
|
|
203
|
+
* that instead drops an unknown event type does not degrade gracefully — it
|
|
204
|
+
* leaves a hole where a `seq` was, and every seq-tracking client then reads
|
|
205
|
+
* the next event as an unfillable gap.
|
|
206
|
+
*/
|
|
207
|
+
export function decodeWireEventEnvelope(value: unknown): DecodedWireEventEnvelope {
|
|
208
|
+
const header = WireEventEnvelopeHeaderSchema.parse(value);
|
|
209
|
+
return {
|
|
210
|
+
sessionId: header.sessionId,
|
|
211
|
+
seq: header.seq,
|
|
212
|
+
event: decodeLifecycleEvent((value as { event?: unknown }).event),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** `events/replay` with its envelopes decoded leniently. */
|
|
217
|
+
export interface DecodedEventsReplayResult {
|
|
218
|
+
events: DecodedWireEventEnvelope[];
|
|
219
|
+
firstAvailableSeq: number | null;
|
|
220
|
+
latestSeq: number | null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The replay result, decoded envelope-by-envelope. `EventsReplayResultSchema`
|
|
225
|
+
* embeds the CLOSED event union, so parsing a buffer with it makes one unknown
|
|
226
|
+
* event type poison the whole replay — the healing path for a gap must not be
|
|
227
|
+
* the thing that cannot survive an unknown.
|
|
228
|
+
*/
|
|
229
|
+
export function decodeEventsReplayResult(value: unknown): DecodedEventsReplayResult {
|
|
230
|
+
const parsed = z
|
|
231
|
+
.object({
|
|
232
|
+
events: z.array(z.unknown()),
|
|
233
|
+
firstAvailableSeq: z.number().int().positive().nullable(),
|
|
234
|
+
latestSeq: z.number().int().positive().nullable(),
|
|
235
|
+
})
|
|
236
|
+
.parse(value);
|
|
237
|
+
return { ...parsed, events: parsed.events.map((event) => decodeWireEventEnvelope(event)) };
|
|
238
|
+
}
|
|
239
|
+
|
|
144
240
|
export const EventsReplayParamsSchema = z.object({
|
|
145
241
|
sessionId: z.string(),
|
|
146
242
|
/** Replay buffered events with `seq >= fromSeq`. */
|
|
@@ -2,6 +2,7 @@ import { Readable, Writable } from "node:stream";
|
|
|
2
2
|
import type {
|
|
3
3
|
AgentApp,
|
|
4
4
|
AgentConnection,
|
|
5
|
+
PromptResponse,
|
|
5
6
|
RequestPermissionResponse,
|
|
6
7
|
} from "@agentclientprotocol/sdk";
|
|
7
8
|
import type { AgentRuntime, EventSink } from "../../core/index.ts";
|
|
@@ -143,10 +144,30 @@ export async function createAcpAgentApp(options: AcpBindingOptions): Promise<Age
|
|
|
143
144
|
},
|
|
144
145
|
sink,
|
|
145
146
|
);
|
|
147
|
+
// ACP v1's StopReason set is closed and has no `error` member. Map our
|
|
148
|
+
// open stopReason onto it: `error` reports as an ended turn with the
|
|
149
|
+
// failure in `_meta` (an ACP client must see a stop, not a JSON-RPC
|
|
150
|
+
// failure), and extension/unknown values fall back to `end_turn`.
|
|
151
|
+
const acpStopReasons = new Set([
|
|
152
|
+
"end_turn",
|
|
153
|
+
"max_tokens",
|
|
154
|
+
"max_turn_requests",
|
|
155
|
+
"refusal",
|
|
156
|
+
"cancelled",
|
|
157
|
+
]);
|
|
146
158
|
if (summary.stopReason === "error") {
|
|
147
|
-
|
|
159
|
+
return {
|
|
160
|
+
stopReason: "end_turn",
|
|
161
|
+
_meta: {
|
|
162
|
+
"zvada.dev/error": summary.error ?? { category: "internal", message: "turn failed" },
|
|
163
|
+
},
|
|
164
|
+
} as PromptResponse;
|
|
148
165
|
}
|
|
149
|
-
return {
|
|
166
|
+
return {
|
|
167
|
+
stopReason: (acpStopReasons.has(summary.stopReason)
|
|
168
|
+
? summary.stopReason
|
|
169
|
+
: "end_turn") as PromptResponse["stopReason"],
|
|
170
|
+
};
|
|
150
171
|
});
|
|
151
172
|
}
|
|
152
173
|
|