@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
@@ -5,7 +5,12 @@ import type {
5
5
  PermissionToolCall,
6
6
  } from "../../../protocol/index.ts";
7
7
  import { AsyncQueue, codexReasoningEffort } from "../../../protocol/index.ts";
8
- import type { AgentExecuteOptions, PermissionRequestHandler, RawAgentEvent } from "../base.ts";
8
+ import type {
9
+ AgentExecuteOptions,
10
+ CancelResult,
11
+ PermissionRequestHandler,
12
+ RawAgentEvent,
13
+ } from "../base.ts";
9
14
  import { BaseAgent } from "../base.ts";
10
15
  import { configFingerprint } from "../config-fingerprint.ts";
11
16
  import { SessionStore } from "../session-store.ts";
@@ -59,10 +64,10 @@ type UserInput = { type: "text"; text: string; text_elements: [] } | { type: "im
59
64
 
60
65
  function mapSandbox(mode: PermissionMode | undefined): string {
61
66
  switch (mode) {
62
- case "bypassPermissions":
67
+ case "bypass_permissions":
63
68
  return "danger-full-access";
64
- case "acceptEdits":
65
- case "dontAsk":
69
+ case "accept_edits":
70
+ case "dont_ask":
66
71
  // Never-ask posture, but inside the normal sandbox — danger-full is
67
72
  // reserved for the explicitly dangerous mode.
68
73
  return "workspace-write";
@@ -77,6 +82,14 @@ function toUserInput(input: AgentInput): UserInput[] {
77
82
  for (const part of input) {
78
83
  if (part.type === "text") out.push({ type: "text", text: part.text, text_elements: [] });
79
84
  else if (part.url) out.push({ type: "image", url: part.url });
85
+ else if (part.type === "image" && part.data) {
86
+ // Codex's user input carries images by URL only; a pasted image arrives
87
+ // as base64 `data` and used to be dropped SILENTLY while the negotiated
88
+ // capability said supported. A data URL delivers it on the same field —
89
+ // and a Codex build that rejects it fails the turn loudly instead of
90
+ // the prompt losing its attachment with no trace.
91
+ out.push({ type: "image", url: `data:${part.mimeType};base64,${part.data}` });
92
+ }
80
93
  }
81
94
  return out.length ? out : [{ type: "text", text: "", text_elements: [] }];
82
95
  }
@@ -255,8 +268,14 @@ export class CodexAppServerAgent extends BaseAgent {
255
268
  const handler = session.permissionHandler;
256
269
  if (!handler) return decline;
257
270
  const decision = await handler(approvalToolCall(method, params));
271
+ // Codex's approval reply is a bare verdict — no slot for
272
+ // `decision.updatedInput`, so an edited input cannot be honoured here
273
+ // (only claude-code's `canUseTool` can execute the edit). An edited
274
+ // approval therefore DECLINES: accepting would run input nobody
275
+ // approved.
258
276
  switch (decision.decision) {
259
277
  case "allow":
278
+ if (decision.updatedInput !== undefined) return decline;
260
279
  return v2 ? { decision: "accept" } : { decision: "approved" };
261
280
  case "cancel":
262
281
  return v2 ? { decision: "cancel" } : { decision: "abort" };
@@ -369,8 +388,8 @@ export class CodexAppServerAgent extends BaseAgent {
369
388
  }
370
389
  }
371
390
 
372
- override async cancel(sessionId: string): Promise<void> {
373
- await super.cancel(sessionId);
391
+ override async cancel(sessionId: string): Promise<CancelResult> {
392
+ return await super.cancel(sessionId);
374
393
  }
375
394
 
376
395
  override async release(sessionId: string): Promise<void> {
@@ -77,10 +77,10 @@ export function codexThreadCompatible(
77
77
 
78
78
  function mapSandbox(mode: PermissionMode | undefined): ThreadOptions["sandboxMode"] {
79
79
  switch (mode) {
80
- case "bypassPermissions":
80
+ case "bypass_permissions":
81
81
  return "danger-full-access";
82
- case "acceptEdits":
83
- case "dontAsk":
82
+ case "accept_edits":
83
+ case "dont_ask":
84
84
  // Never-ask posture, but inside the normal sandbox — danger-full is
85
85
  // reserved for the explicitly dangerous mode.
86
86
  return "workspace-write";
@@ -26,7 +26,7 @@ export type AdapterEvent =
26
26
  }
27
27
  /** Context-window gauge snapshot (→ `session.usage`). */
28
28
  | { kind: "usage"; used: number; size?: number; cost?: number }
29
- /** History-compaction boundary (→ `session.compacted`). */
29
+ /** History-compaction boundary (→ the `session.compaction` entity). */
30
30
  | { kind: "compacted"; trigger?: string; preTokens?: number; postTokens?: number };
31
31
 
32
32
  /** Terminal result of a turn, surfaced on `turn.ended`. */
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The engine's diagnostics port: operational signals the engine used to
3
+ * swallow (best-effort paths that must not fail a turn) surfaced to the host
4
+ * instead. Products log/metric these; the engine never does its own logging.
5
+ * Handler errors are always swallowed — a diagnostics sink must never be able
6
+ * to break a turn.
7
+ */
8
+
9
+ /**
10
+ * Known diagnostic kinds (open set — new kinds may appear in minor versions;
11
+ * the `(string & {})` arm keeps the union open without losing autocomplete):
12
+ * - `interruptTimeout` — a cancel's SDK interrupt round-trip timed out
13
+ * (the turn was reported `confirmed: false`; the agent may still run).
14
+ * - `resumeFallback` — a requested resume failed and the turn re-ran on a
15
+ * fresh session (also visible as `session.created.resumed: false`).
16
+ * - `sinkError` — an EventSink emit threw; the event was dropped for that
17
+ * sink and the turn continued.
18
+ * - `proxyUpstreamAuth` — the BYOK proxy's upstream rejected the real key
19
+ * (401/403): the stored key is invalid/expired, not the placeholder.
20
+ */
21
+ export type DiagnosticKind =
22
+ | "interruptTimeout"
23
+ | "resumeFallback"
24
+ | "sinkError"
25
+ | "proxyUpstreamAuth"
26
+ | (string & {});
27
+
28
+ export interface EngineDiagnostic {
29
+ type: DiagnosticKind;
30
+ sessionId?: string;
31
+ message: string;
32
+ detail?: unknown;
33
+ timestamp: number;
34
+ }
35
+
36
+ /**
37
+ * Declared `=> void` so any callback shape is assignable (TS's void-return
38
+ * exemption covers `() => diagnostics.push(d)` AND async handlers); a
39
+ * returned promise is still detected at runtime and its rejection swallowed.
40
+ */
41
+ export type DiagnosticHandler = (diagnostic: EngineDiagnostic) => void;
42
+
43
+ /** Invoke a handler without letting it break the calling path — sync throws
44
+ * AND async rejections are swallowed (the diagnostics sink owns its own
45
+ * reliability). Stamps the timestamp so call sites don't repeat it. */
46
+ export function emitDiagnostic(
47
+ handler: DiagnosticHandler | undefined,
48
+ diagnostic: Omit<EngineDiagnostic, "timestamp">,
49
+ ): void {
50
+ if (!handler) return;
51
+ try {
52
+ const result = handler({ ...diagnostic, timestamp: Date.now() }) as unknown;
53
+ if (result instanceof Promise) {
54
+ result.catch(() => {});
55
+ }
56
+ } catch {
57
+ // see above
58
+ }
59
+ }
package/src/core/index.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  // @zvada/agent-server/core — harness-agnostic agent execution engine.
2
2
 
3
3
  // Runtime
4
- export { AgentRuntime, type RunSummary } from "./runtime/agent-runtime.ts";
4
+ export { AgentRuntime, type RunSummary, type TurnAdmission } from "./runtime/agent-runtime.ts";
5
+ export { type DiagnosticHandler, type EngineDiagnostic, emitDiagnostic } from "./diagnostics.ts";
5
6
  export {
6
7
  type EventSink,
7
8
  callbackSink,
@@ -14,6 +15,7 @@ export { EventProcessor } from "./runtime/event-processor.ts";
14
15
  export {
15
16
  type Agent,
16
17
  type AgentExecuteOptions,
18
+ type CancelResult,
17
19
  type RawAgentEvent,
18
20
  BaseAgent,
19
21
  } from "./agents/base.ts";
@@ -24,7 +26,7 @@ export {
24
26
  classifyError,
25
27
  isRecoverable,
26
28
  isCancellation,
27
- } from "./agents/error-classifier.ts";
29
+ } from "../protocol/errors.ts";
28
30
 
29
31
  // Adapter contract
30
32
  export type {
@@ -57,6 +59,7 @@ export {
57
59
  export type { ClaudeSdkOptionOverrides, SdkMcpServers } from "./agents/claude-code/options.ts";
58
60
  export type {
59
61
  ClaudeHooksFactory,
62
+ ClaudeSessionEndReason,
60
63
  ClaudeSessionExtras,
61
64
  ClaudeToolPolicy,
62
65
  } from "./agents/claude-code/generator-session.ts";
@@ -108,6 +111,7 @@ export {
108
111
  CliNotFoundError,
109
112
  CliProvisionError,
110
113
  HarnessNotFoundError,
114
+ TurnConflictError,
111
115
  } from "./utils/errors.ts";
112
116
 
113
117
  // Re-export the wire contract for convenience.
@@ -8,6 +8,7 @@ import { CodexAppServerAgent } from "./agents/codex-app-server/codex-app-server-
8
8
  import { createCodexSdkTransformer } from "./agents/codex-sdk/adapter.ts";
9
9
  import { CodexSdkAgent } from "./agents/codex-sdk/codex-sdk-agent.ts";
10
10
  import { AgentRegistry } from "./agents/registry.ts";
11
+ import type { DiagnosticHandler } from "./diagnostics.ts";
11
12
  import { CliProvisioner, type ProvisionOptions } from "./provision/provisioner.ts";
12
13
  import { AgentRuntime } from "./runtime/agent-runtime.ts";
13
14
 
@@ -32,6 +33,14 @@ export interface CreateRegistryOptions {
32
33
  * `resolveCliPath` stays provisioner-wired here.
33
34
  */
34
35
  claudeCode?: Omit<ClaudeCodeAgentOptions, "resolveCliPath">;
36
+ /**
37
+ * Operational diagnostics port (see `EngineDiagnostic`): interrupt
38
+ * timeouts, resume fallbacks, sink failures — signals the engine must not
39
+ * fail a turn over, surfaced for the host to log/metric. Applied to the
40
+ * runtime and every harness that emits them; a harness-level
41
+ * `claudeCode.onDiagnostic` overrides for that harness.
42
+ */
43
+ onDiagnostic?: DiagnosticHandler;
35
44
  }
36
45
 
37
46
  /** Build a registry with the standard harnesses wired to their adapters. */
@@ -50,7 +59,11 @@ export function createAgentRegistry(opts: CreateRegistryOptions = {}): AgentRegi
50
59
  const registry = new AgentRegistry();
51
60
  if (enabled.has("claude-code")) {
52
61
  registry.register(
53
- new ClaudeCodeAgent({ ...opts.claudeCode, resolveCliPath: claudeCli }),
62
+ new ClaudeCodeAgent({
63
+ onDiagnostic: opts.onDiagnostic,
64
+ ...opts.claudeCode,
65
+ resolveCliPath: claudeCli,
66
+ }),
54
67
  createClaudeCodeTransformer,
55
68
  );
56
69
  }
@@ -74,5 +87,5 @@ export function createAgentRegistry(opts: CreateRegistryOptions = {}): AgentRegi
74
87
 
75
88
  /** Convenience: a ready-to-use runtime with the standard harnesses. */
76
89
  export function createAgentRuntime(opts: CreateRegistryOptions = {}): AgentRuntime {
77
- return new AgentRuntime(createAgentRegistry(opts));
90
+ return new AgentRuntime(createAgentRegistry(opts), { onDiagnostic: opts.onDiagnostic });
78
91
  }
@@ -27,7 +27,11 @@ export type CliTool = (typeof CLI_TOOLS)[number];
27
27
  * set `provision.pins.codex` to their SDK's vendored version.
28
28
  */
29
29
  export const DEFAULT_PINS: Record<CliTool, string> = {
30
- claude: "0.3.168",
30
+ // Held at 0.3.220: the paired CLI (2.1.220) honors `--thinking-display
31
+ // summarized`, so reasoning parts carry text. 2.1.233 ignores the flag and
32
+ // streams token-estimate placeholders instead — bump only after verifying
33
+ // thinking summaries still flow (see options.ts extraArgs).
34
+ claude: "0.3.220",
31
35
  // 0.146.1 re-verified 2026-08-06: generate-ts snapshot carries our full
32
36
  // surface unchanged (thread/turn/item methods, tokenUsage w/ window,
33
37
  // approvals) and a live turn ran clean incl. the gpt-5.6-sol default.
@@ -1,13 +1,24 @@
1
+ import { type DiagnosticHandler, emitDiagnostic } from "../diagnostics.ts";
1
2
  import { type ApiKeyStore, apiKeyStore } from "./api-key-store.ts";
2
3
 
3
4
  /** Placeholder key handed to the agent subprocess; swapped for the real one here. */
4
5
  export const PROXY_PLACEHOLDER_KEY = "sk-proxy-managed";
5
6
 
7
+ /** Minimal fetch shape (`globalThis.fetch` qualifies; injectable for tests/platforms). */
8
+ export type FetchLike = (
9
+ input: string | URL | Request,
10
+ init?: RequestInit & { duplex?: "half" },
11
+ ) => Promise<Response>;
12
+
6
13
  export interface AnthropicProxyOptions {
7
14
  /** Store to resolve real credentials from. Defaults to the shared `apiKeyStore`. */
8
15
  store?: ApiKeyStore;
9
16
  /** Path prefix the proxy is mounted under. Default `/proxy/anthropic`. */
10
17
  pathPrefix?: string;
18
+ /** Fetch implementation (test seam / platform override). Default `globalThis.fetch`. */
19
+ fetch?: FetchLike;
20
+ /** Notified on upstream 401/403 — the STORED key is bad, not the placeholder. */
21
+ onDiagnostic?: DiagnosticHandler;
11
22
  }
12
23
 
13
24
  /**
@@ -15,8 +26,15 @@ export interface AnthropicProxyOptions {
15
26
  * implementing the BYOK Anthropic proxy. Mount it in any server (Bun, Hono,
16
27
  * Workers, Node 18+). Requests come in at `{prefix}/{sessionId}/{...path}`; the
17
28
  * handler looks up the real key for `sessionId`, swaps the `x-api-key` header,
18
- * and forwards to the configured upstream — streaming the response through
19
- * unchanged (SSE included).
29
+ * and forwards to the configured upstream. Responses are NOT passed through
30
+ * verbatim — two host-runtime bugs require reshaping (both hit Claude Code as
31
+ * its API client, found in agnt's production sidecar):
32
+ * - SSE bodies are pumped through a `TransformStream`: Bun's HTTP server
33
+ * mishandles direct ReadableStream passthrough for SSE and the node
34
+ * client sees a premature "terminated".
35
+ * - Non-SSE responses drop `content-encoding`/`content-length`/
36
+ * `transfer-encoding`: fetch already decompressed the body, so the
37
+ * original headers make the client gunzip plain bytes (ZlibError).
20
38
  *
21
39
  * Point the agent at it with:
22
40
  * ANTHROPIC_BASE_URL=http://localhost:PORT/proxy/anthropic/<sessionId>
@@ -64,6 +82,61 @@ export function createAnthropicProxy(
64
82
  // Required when forwarding a streaming request body on fetch.
65
83
  if (request.body) init.duplex = "half";
66
84
 
67
- return fetch(`${entry.upstreamBaseUrl}${subPath}${url.search}`, init);
85
+ const doFetch: FetchLike = options.fetch ?? (globalThis.fetch as FetchLike);
86
+ let upstream: Response;
87
+ try {
88
+ upstream = await doFetch(`${entry.upstreamBaseUrl}${subPath}${url.search}`, init);
89
+ } catch (error) {
90
+ // An unreachable upstream must surface as a structured API error the
91
+ // agent can classify, not a host-framework 500 with an opaque body.
92
+ return new Response(
93
+ JSON.stringify({
94
+ type: "error",
95
+ error: {
96
+ type: "api_error",
97
+ message: error instanceof Error ? error.message : "proxy request failed",
98
+ },
99
+ }),
100
+ { status: 502, headers: { "content-type": "application/json" } },
101
+ );
102
+ }
103
+
104
+ if (upstream.status === 401 || upstream.status === 403) {
105
+ emitDiagnostic(options.onDiagnostic, {
106
+ type: "proxyUpstreamAuth",
107
+ sessionId,
108
+ message: `upstream rejected the stored key for session ${sessionId} (${upstream.status})`,
109
+ detail: { status: upstream.status },
110
+ });
111
+ }
112
+
113
+ const contentType = upstream.headers.get("content-type") ?? "";
114
+ // Media type only — parameters (charset) stripped, case-insensitive, so
115
+ // `text/event-streamish` or a parameter VALUE never selects SSE handling.
116
+ const mediaType = (contentType.split(";", 1)[0] ?? "").trim().toLowerCase();
117
+ if (mediaType === "text/event-stream" && upstream.body) {
118
+ // Pumped through a fresh stream — see the header comment for why raw
119
+ // passthrough breaks on Bun. pipeTo (not a manual read/write loop) so a
120
+ // downstream disconnect propagates cancellation to the upstream body
121
+ // and the socket is released instead of held open.
122
+ const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>();
123
+ void upstream.body.pipeTo(writable).catch(() => {
124
+ // downstream went away or upstream died — both ends settled by pipeTo
125
+ });
126
+ return new Response(readable, {
127
+ status: upstream.status,
128
+ headers: { "content-type": "text/event-stream", "cache-control": "no-cache" },
129
+ });
130
+ }
131
+
132
+ const responseHeaders = new Headers(upstream.headers);
133
+ responseHeaders.delete("content-encoding");
134
+ responseHeaders.delete("content-length");
135
+ responseHeaders.delete("transfer-encoding");
136
+ return new Response(upstream.body, {
137
+ status: upstream.status,
138
+ statusText: upstream.statusText,
139
+ headers: responseHeaders,
140
+ });
68
141
  };
69
142
  }