@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
|
@@ -7,30 +7,41 @@ import type { LifecycleEvent, McpServerConfig, PartInput, ToolPart } from "../..
|
|
|
7
7
|
* (the vocabularies were aligned for this: ToolKind, tool status, and
|
|
8
8
|
* permission shapes pass through unchanged).
|
|
9
9
|
*
|
|
10
|
-
* Deliberate v1 gaps: sub-agent parts (`
|
|
11
|
-
* and are dropped (the parent tool's result carries the outcome);
|
|
12
|
-
* `session.
|
|
13
|
-
* so gauges without a window size are skipped.
|
|
10
|
+
* Deliberate v1 gaps: sub-agent parts (`parentToolCallId`) have no ACP slot
|
|
11
|
+
* and are dropped (the parent tool's result carries the outcome); the
|
|
12
|
+
* `session.compaction` entity has no v1 equivalent; `usage_update` requires
|
|
13
|
+
* `size`, so gauges without a window size are skipped. The user echo is
|
|
14
|
+
* skipped too — the ACP client authored the prompt and re-chunking it back
|
|
15
|
+
* would duplicate it.
|
|
14
16
|
*/
|
|
15
17
|
export class AcpUpdateTranslator {
|
|
16
18
|
/** toolCallIds already announced via `tool_call`. */
|
|
17
19
|
private readonly openedTools = new Set<string>();
|
|
18
20
|
/** Text/reasoning characters already emitted per part (dedupe snapshots vs deltas). */
|
|
19
21
|
private readonly sentByPart = new Map<string, number>();
|
|
22
|
+
/** Message ids of the user echo (role "user") — skipped entirely. */
|
|
23
|
+
private readonly userMessageIds = new Set<string>();
|
|
24
|
+
/** Part ids that belong to a sub-agent (parented) — their deltas are skipped. */
|
|
25
|
+
private readonly parentedParts = new Set<string>();
|
|
20
26
|
|
|
21
27
|
translate(event: LifecycleEvent): SessionUpdate[] {
|
|
22
28
|
switch (event.type) {
|
|
29
|
+
case "message.started": {
|
|
30
|
+
if (event.role === "user") this.userMessageIds.add(event.messageId);
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
23
33
|
case "message.part.delta": {
|
|
24
|
-
if (event.
|
|
34
|
+
if (this.parentedParts.has(event.partId)) return [];
|
|
35
|
+
if (this.userMessageIds.has(event.messageId)) return [];
|
|
25
36
|
const { delta } = event;
|
|
26
|
-
if (delta.type === "
|
|
37
|
+
if (delta.type === "tool_input") return []; // covered by part snapshots
|
|
27
38
|
this.sentByPart.set(
|
|
28
39
|
event.partId,
|
|
29
40
|
(this.sentByPart.get(event.partId) ?? 0) + delta.text.length,
|
|
30
41
|
);
|
|
31
42
|
return [
|
|
32
43
|
chunk(
|
|
33
|
-
delta.type === "text
|
|
44
|
+
delta.type === "text" ? "agent_message_chunk" : "agent_thought_chunk",
|
|
34
45
|
delta.text,
|
|
35
46
|
event.messageId,
|
|
36
47
|
),
|
|
@@ -38,8 +49,14 @@ export class AcpUpdateTranslator {
|
|
|
38
49
|
}
|
|
39
50
|
case "message.part": {
|
|
40
51
|
const part = event.part;
|
|
41
|
-
if (part.
|
|
52
|
+
if (part.parentToolCallId) {
|
|
53
|
+
this.parentedParts.add(part.id);
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
if (this.userMessageIds.has(event.messageId)) return [];
|
|
42
57
|
if (part.type === "tool") return this.toolUpdate(part);
|
|
58
|
+
// Image/file parts have no v1 chunk slot (agent-side); dropped.
|
|
59
|
+
if (part.type !== "text" && part.type !== "reasoning") return [];
|
|
43
60
|
// While a part is still streaming, its snapshots mirror the deltas
|
|
44
61
|
// (adapters mutate in place, so an early snapshot can already carry
|
|
45
62
|
// delta text — emitting both doubles it). Only the final `done`
|
|
@@ -75,9 +92,29 @@ export class AcpUpdateTranslator {
|
|
|
75
92
|
}
|
|
76
93
|
|
|
77
94
|
private toolUpdate(part: ToolPart): SessionUpdate[] {
|
|
78
|
-
// Statuses and ToolKind are the SDK's own vocabulary (DESIGN D2)
|
|
79
|
-
//
|
|
80
|
-
|
|
95
|
+
// Statuses and ToolKind are the SDK's own vocabulary (DESIGN D2), with
|
|
96
|
+
// two edge maps: `cancelled` (ACP v2 / schema 1.6+) predates our pinned
|
|
97
|
+
// SDK's ToolCallStatus and maps to `failed`; our open kind narrows via a
|
|
98
|
+
// membership check — `task` and extensions fall back to `other`.
|
|
99
|
+
const status = (part.state.status === "cancelled" ? "failed" : part.state.status) as Exclude<
|
|
100
|
+
typeof part.state.status,
|
|
101
|
+
"cancelled"
|
|
102
|
+
>;
|
|
103
|
+
const acpKinds = new Set([
|
|
104
|
+
"read",
|
|
105
|
+
"edit",
|
|
106
|
+
"delete",
|
|
107
|
+
"move",
|
|
108
|
+
"search",
|
|
109
|
+
"execute",
|
|
110
|
+
"think",
|
|
111
|
+
"fetch",
|
|
112
|
+
"switch_mode",
|
|
113
|
+
"other",
|
|
114
|
+
]);
|
|
115
|
+
const kind = part.kind
|
|
116
|
+
? ((acpKinds.has(part.kind) ? part.kind : "other") as "other")
|
|
117
|
+
: undefined;
|
|
81
118
|
if (!this.openedTools.has(part.toolCallId)) {
|
|
82
119
|
this.openedTools.add(part.toolCallId);
|
|
83
120
|
return [
|
|
@@ -86,10 +123,10 @@ export class AcpUpdateTranslator {
|
|
|
86
123
|
toolCallId: part.toolCallId,
|
|
87
124
|
title: part.title ?? part.toolName,
|
|
88
125
|
name: part.toolName,
|
|
89
|
-
...(
|
|
126
|
+
...(kind && { kind }),
|
|
90
127
|
status,
|
|
91
128
|
...(part.locations?.length && { locations: part.locations }),
|
|
92
|
-
...(
|
|
129
|
+
...("input" in part.state && { rawInput: part.state.input }),
|
|
93
130
|
},
|
|
94
131
|
];
|
|
95
132
|
}
|
|
@@ -126,7 +163,7 @@ export function fromPromptBlocks(prompt: ContentBlock[]): string | PartInput[] {
|
|
|
126
163
|
for (const block of prompt) {
|
|
127
164
|
if (block.type === "text" && block.text) parts.push({ type: "text", text: block.text });
|
|
128
165
|
else if (block.type === "image" && block.data) {
|
|
129
|
-
parts.push({ type: "image", data: block.data,
|
|
166
|
+
parts.push({ type: "image", data: block.data, mimeType: block.mimeType });
|
|
130
167
|
}
|
|
131
168
|
}
|
|
132
169
|
if (parts.length === 1 && parts[0]?.type === "text") return parts[0].text;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AgentRuntime, callbackSink } from "../core/index.ts";
|
|
1
|
+
import { type AgentRuntime, TurnConflictError, callbackSink } from "../core/index.ts";
|
|
2
2
|
import {
|
|
3
3
|
type AgentCapabilities,
|
|
4
4
|
type AgentHarness,
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type RunRequest,
|
|
11
11
|
SessionCloseParamsSchema,
|
|
12
12
|
TurnCancelParamsSchema,
|
|
13
|
+
type TurnCancelResult,
|
|
13
14
|
TurnStartParamsSchema,
|
|
14
15
|
WIRE_ERROR_CODES,
|
|
15
16
|
WIRE_METHODS,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
type WireEventEnvelope,
|
|
18
19
|
type WireImplementationInfo,
|
|
19
20
|
type WireTransport,
|
|
21
|
+
classifyError,
|
|
20
22
|
decodeWireMessage,
|
|
21
23
|
encodeErrorResponse,
|
|
22
24
|
encodeNotification,
|
|
@@ -57,6 +59,15 @@ export class AgentServer {
|
|
|
57
59
|
* unbounded only across a server lifetime with unbounded distinct ids, the
|
|
58
60
|
* same order of growth already accepted for `sessions`. */
|
|
59
61
|
private readonly retiredSeqs = new Map<string, number>();
|
|
62
|
+
private readonly observers = new Set<(envelope: WireEventEnvelope) => void>();
|
|
63
|
+
/**
|
|
64
|
+
* One id per server PROCESS, handed out in `initialize`. Within a process,
|
|
65
|
+
* session logs never restart (`retiredSeqs` keeps seq monotonic across
|
|
66
|
+
* close/reopen of an id), so a client that sees this id change across a
|
|
67
|
+
* reconnect knows — as a stated fact, not an inference — that every log it
|
|
68
|
+
* tracked belongs to a dead process.
|
|
69
|
+
*/
|
|
70
|
+
private readonly instanceId = generateUUIDv7();
|
|
60
71
|
private shuttingDown = false;
|
|
61
72
|
|
|
62
73
|
constructor(
|
|
@@ -76,6 +87,25 @@ export class AgentServer {
|
|
|
76
87
|
return detach;
|
|
77
88
|
}
|
|
78
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Observe every broadcast envelope in-process; returns an unsubscribe.
|
|
92
|
+
*
|
|
93
|
+
* The same sequenced envelopes the transports receive, already appended to
|
|
94
|
+
* the session log — a host that embeds the server (to keep its own session
|
|
95
|
+
* bookkeeping, mirror events into a DB, trigger follow-up work) gets them
|
|
96
|
+
* as decoded objects instead of attaching a fake transport and parsing its
|
|
97
|
+
* own wire back out of NDJSON.
|
|
98
|
+
*
|
|
99
|
+
* Synchronous and isolated: an observer that throws is swallowed, exactly
|
|
100
|
+
* like a faulty transport or a failing sink — an event is a fact that
|
|
101
|
+
* already happened, and a consumer's bug must not unwind the broadcast for
|
|
102
|
+
* everyone else. Do the slow part yourself (queue it, don't block here).
|
|
103
|
+
*/
|
|
104
|
+
onEvent(observer: (envelope: WireEventEnvelope) => void): () => void {
|
|
105
|
+
this.observers.add(observer);
|
|
106
|
+
return () => this.observers.delete(observer);
|
|
107
|
+
}
|
|
108
|
+
|
|
79
109
|
/** Cancel all turns, terminate harnesses, and close every transport. */
|
|
80
110
|
async shutdown(): Promise<void> {
|
|
81
111
|
if (this.shuttingDown) return;
|
|
@@ -131,6 +161,7 @@ export class AgentServer {
|
|
|
131
161
|
const result: InitializeResult = {
|
|
132
162
|
protocolVersion: WIRE_PROTOCOL_VERSION,
|
|
133
163
|
server: this.options.info ?? { name: "agent-server" },
|
|
164
|
+
instanceId: this.instanceId,
|
|
134
165
|
harnesses,
|
|
135
166
|
};
|
|
136
167
|
return transport.send(encodeResponse(id, result));
|
|
@@ -176,6 +207,23 @@ export class AgentServer {
|
|
|
176
207
|
),
|
|
177
208
|
);
|
|
178
209
|
}
|
|
210
|
+
// Idempotent admission, delegated to the runtime: a retried start
|
|
211
|
+
// (same turnId, identical input) converges — ack again, never run
|
|
212
|
+
// twice; `events/replay` serves anything the retryer missed. The
|
|
213
|
+
// same turnId with different input is a caller bug → turnConflict.
|
|
214
|
+
const request = { sessionId, turnId, input, config };
|
|
215
|
+
try {
|
|
216
|
+
if (this.runtime.admission(request).status !== "new") {
|
|
217
|
+
return transport.send(encodeResponse(id, { sessionId, turnId, deduplicated: true }));
|
|
218
|
+
}
|
|
219
|
+
} catch (err) {
|
|
220
|
+
if (err instanceof TurnConflictError) {
|
|
221
|
+
return transport.send(
|
|
222
|
+
encodeErrorResponse(id, WIRE_ERROR_CODES.turnConflict, err.message, { turnId }),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
179
227
|
if (this.activeTurns.has(sessionId)) {
|
|
180
228
|
return transport.send(
|
|
181
229
|
encodeErrorResponse(
|
|
@@ -207,9 +255,37 @@ export class AgentServer {
|
|
|
207
255
|
const parsed = TurnCancelParamsSchema.safeParse(params);
|
|
208
256
|
if (!parsed.success) return this.invalidParams(transport, id, parsed.error);
|
|
209
257
|
const active = this.activeTurns.get(parsed.data.sessionId);
|
|
210
|
-
if (!active)
|
|
211
|
-
|
|
212
|
-
|
|
258
|
+
if (!active) {
|
|
259
|
+
return transport.send(
|
|
260
|
+
encodeResponse(id, { outcome: "no_active_turn" } satisfies TurnCancelResult),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
if (parsed.data.turnId && parsed.data.turnId !== active.turnId) {
|
|
264
|
+
// Turn-stamped cancel for a turn that is no longer the active one —
|
|
265
|
+
// refuse to kill its successor; report who IS active instead.
|
|
266
|
+
return transport.send(
|
|
267
|
+
encodeResponse(id, {
|
|
268
|
+
outcome: "no_active_turn",
|
|
269
|
+
activeTurnId: active.turnId,
|
|
270
|
+
} satisfies TurnCancelResult),
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
const { confirmed, hadTurn } = await this.runtime.cancel(
|
|
274
|
+
active.harness,
|
|
275
|
+
parsed.data.sessionId,
|
|
276
|
+
);
|
|
277
|
+
// An unacknowledged interrupt is NOT a cancel: the agent may still be
|
|
278
|
+
// running and `turn.ended` stays the source of truth. `hadTurn: false`
|
|
279
|
+
// while the server tracks an active turn is the pre-first-yield window
|
|
280
|
+
// (between the turn/start quick-ack and the harness registering its
|
|
281
|
+
// abortable turn) — the abort landed on nothing and the turn may run
|
|
282
|
+
// to completion, so it must report `unconfirmed`, never a clean cancel.
|
|
283
|
+
return transport.send(
|
|
284
|
+
encodeResponse(id, {
|
|
285
|
+
outcome: confirmed && hadTurn ? "cancelled" : "unconfirmed",
|
|
286
|
+
turnId: active.turnId,
|
|
287
|
+
} satisfies TurnCancelResult),
|
|
288
|
+
);
|
|
213
289
|
}
|
|
214
290
|
|
|
215
291
|
case WIRE_METHODS.sessionClose: {
|
|
@@ -241,6 +317,21 @@ export class AgentServer {
|
|
|
241
317
|
this.closingSessions.add(sessionId);
|
|
242
318
|
try {
|
|
243
319
|
await this.runtime.closeSession(log.harness, sessionId);
|
|
320
|
+
// The terminal fact for CONNECTED subscribers (spec §6.1): they
|
|
321
|
+
// learn the session is over from the stream, not from the RPC
|
|
322
|
+
// result (which only the closer sees). It is deliberately NOT
|
|
323
|
+
// replayable — the log is retired on the next line, so a
|
|
324
|
+
// subscriber that was detached gets `unknownSession` from
|
|
325
|
+
// `events/replay`, and that answer IS its terminal signal (the
|
|
326
|
+
// session is gone; nothing more will ever be appended).
|
|
327
|
+
this.broadcast(
|
|
328
|
+
log.append({
|
|
329
|
+
type: "session.ended",
|
|
330
|
+
sessionId,
|
|
331
|
+
reason: "released",
|
|
332
|
+
timestamp: Date.now(),
|
|
333
|
+
}),
|
|
334
|
+
);
|
|
244
335
|
if (log.latestSeq !== null) this.retiredSeqs.set(sessionId, log.latestSeq);
|
|
245
336
|
if (this.sessions.get(sessionId) === log) this.sessions.delete(sessionId);
|
|
246
337
|
return transport.send(encodeResponse(id, { closed: true }));
|
|
@@ -323,7 +414,7 @@ export class AgentServer {
|
|
|
323
414
|
sessionId: request.sessionId,
|
|
324
415
|
turnId: request.turnId,
|
|
325
416
|
stopReason: "error",
|
|
326
|
-
error: {
|
|
417
|
+
error: { category: classifyError(err), message },
|
|
327
418
|
timestamp: Date.now(),
|
|
328
419
|
}),
|
|
329
420
|
);
|
|
@@ -333,13 +424,25 @@ export class AgentServer {
|
|
|
333
424
|
}
|
|
334
425
|
|
|
335
426
|
private broadcast(envelope: WireEventEnvelope): void {
|
|
427
|
+
// In-process observers first: they see the same envelope, in the same
|
|
428
|
+
// order, whether or not any transport is attached.
|
|
429
|
+
for (const observer of [...this.observers]) {
|
|
430
|
+
try {
|
|
431
|
+
observer(envelope);
|
|
432
|
+
} catch {
|
|
433
|
+
// An observer's failure is its own; the event is already logged.
|
|
434
|
+
}
|
|
435
|
+
}
|
|
336
436
|
const line = encodeNotification(WIRE_METHODS.event, envelope);
|
|
337
437
|
for (const transport of this.transports) {
|
|
338
438
|
try {
|
|
339
439
|
transport.send(line);
|
|
340
440
|
} catch {
|
|
341
441
|
// One faulty transport must not starve the others; the event is
|
|
342
|
-
// already in the session log, so the client heals via replay.
|
|
442
|
+
// already in the session log, so the client heals via replay. The
|
|
443
|
+
// one exception is `session.ended` from `session/close`, whose log
|
|
444
|
+
// is retired in the same breath — a client that missed it learns the
|
|
445
|
+
// session is over from `unknownSession` on replay instead.
|
|
343
446
|
}
|
|
344
447
|
}
|
|
345
448
|
}
|