@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +317 -0
  2. package/README.md +34 -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 +153 -49
  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 +35 -5
  12. package/src/core/agents/claude-code/adapter.ts +116 -26
  13. package/src/core/agents/claude-code/claude-agent.ts +44 -7
  14. package/src/core/agents/claude-code/generator-session.ts +53 -14
  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 +25 -6
  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 +6 -2
  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 +76 -3
  25. package/src/core/runtime/agent-runtime.ts +215 -28
  26. package/src/core/runtime/event-processor.ts +51 -26
  27. package/src/core/utils/errors.ts +37 -3
  28. package/src/protocol/config.ts +8 -6
  29. package/src/{core/agents/error-classifier.ts → protocol/errors.ts} +33 -7
  30. package/src/protocol/factories.ts +106 -10
  31. package/src/protocol/guards.ts +53 -0
  32. package/src/protocol/index.ts +10 -0
  33. package/src/protocol/lifecycle.ts +289 -112
  34. package/src/protocol/meta.ts +14 -0
  35. package/src/protocol/part-input.ts +56 -7
  36. package/src/protocol/parts.ts +125 -10
  37. package/src/protocol/reduce.ts +749 -0
  38. package/src/protocol/selectors.ts +162 -0
  39. package/src/protocol/seq-cursor.ts +87 -0
  40. package/src/protocol/stop-reasons.ts +45 -0
  41. package/src/protocol/time.ts +23 -0
  42. package/src/protocol/tokens.ts +23 -0
  43. package/src/protocol/tool-state.ts +85 -25
  44. package/src/protocol/verify.ts +440 -0
  45. package/src/protocol/vocabulary.ts +18 -0
  46. package/src/protocol/wire.ts +103 -7
  47. package/src/server/acp/binding.ts +23 -2
  48. package/src/server/acp/translate.ts +51 -14
  49. 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 (`parentToolUseId`) have no ACP slot
11
- * and are dropped (the parent tool's result carries the outcome);
12
- * `session.compacted` has no v1 equivalent; `usage_update` requires `size`,
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.parentToolUseId) return [];
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 === "tool-input-delta") return []; // covered by part snapshots
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-delta" ? "agent_message_chunk" : "agent_thought_chunk",
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.parentToolUseId) return [];
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) — no
79
- // re-narrowing, no casts.
80
- const status = part.state.status;
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
- ...(part.kind && { kind: part.kind }),
126
+ ...(kind && { kind }),
90
127
  status,
91
128
  ...(part.locations?.length && { locations: part.locations }),
92
- ...(status !== "pending" && { rawInput: part.state.input }),
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, mediaType: block.mimeType });
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) return transport.send(encodeResponse(id, { cancelled: false }));
211
- await this.runtime.cancel(active.harness, parsed.data.sessionId);
212
- return transport.send(encodeResponse(id, { cancelled: true }));
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: { name: err instanceof Error ? err.name : "Error", message },
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
  }