@zvada/agent-server 0.3.9 → 0.3.10

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.
@@ -0,0 +1,91 @@
1
+ import type { AgentInput } from "../../protocol/index.ts";
2
+ import { SessionResumeError, TurnActiveError } from "../utils/errors.ts";
3
+ import { type AgentExecuteOptions, BaseAgent, type RawAgentEvent } from "./base.ts";
4
+ import { configFingerprint } from "./config-fingerprint.ts";
5
+
6
+ /**
7
+ * Shared execution lifecycle for built-in harnesses. One turn owns a logical
8
+ * session from preparation through native cleanup. BaseAgent remains available
9
+ * to custom harnesses with their own execution/concurrency contract.
10
+ */
11
+ export abstract class SessionAgent extends BaseAgent {
12
+ private terminated = false;
13
+ /** Cancel/release can return before execution drains; only finally relinquishes ownership. */
14
+ private readonly activeSessions = new Set<string>();
15
+ /** Conversation identity outlives subprocess eviction and failed startup. */
16
+ private readonly conversations = new Map<string, { id: string | undefined; boundary: string }>();
17
+
18
+ override async *execute(
19
+ input: AgentInput,
20
+ options: AgentExecuteOptions,
21
+ ): AsyncIterableIterator<RawAgentEvent> {
22
+ const { sessionId } = options;
23
+ if (this.terminated) throw new Error("Agent has been terminated");
24
+ if (this.activeSessions.has(sessionId)) throw new TurnActiveError(sessionId);
25
+ this.activeSessions.add(sessionId);
26
+ const controller = this.trackTurn(sessionId, options.signal);
27
+ try {
28
+ // The adapter still produces its native cancellation result for an
29
+ // already-aborted turn. It must not select a conversation or do setup.
30
+ const prepared = controller.signal.aborted ? options : this.prepareSessionOptions(options);
31
+ yield* this.executeTurn(input, prepared, controller);
32
+ } finally {
33
+ this.endTurn(sessionId, controller);
34
+ this.activeSessions.delete(sessionId);
35
+ }
36
+ }
37
+
38
+ /** Native protocol execution; options are prepared and the session is exclusively owned. */
39
+ protected abstract executeTurn(
40
+ input: AgentInput,
41
+ options: AgentExecuteOptions,
42
+ controller: AbortController,
43
+ ): AsyncIterableIterator<RawAgentEvent>;
44
+
45
+ private prepareSessionOptions(options: AgentExecuteOptions): AgentExecuteOptions {
46
+ if (options.resumeSessionId === "") {
47
+ throw new Error("Invalid request: resumeSessionId must not be empty");
48
+ }
49
+ const boundary = configFingerprint({
50
+ cwd: options.cwd,
51
+ env: options.env,
52
+ apiKey: options.apiKey,
53
+ });
54
+ const previous = this.conversations.get(options.sessionId);
55
+ if (!options.resumeSessionId && previous?.id && previous.boundary !== boundary) {
56
+ throw new SessionResumeError(
57
+ previous.id,
58
+ "working directory or credentials changed; explicitly provide resumeSessionId to continue, or use a new sessionId for a new conversation",
59
+ );
60
+ }
61
+ const resumeSessionId = options.resumeSessionId ?? previous?.id;
62
+ // Remember intent before setup: a failed retry must keep targeting the same history.
63
+ const selection = { id: resumeSessionId, boundary };
64
+ this.conversations.set(options.sessionId, selection);
65
+ return {
66
+ ...options,
67
+ resumeSessionId,
68
+ onNativeSession: (id, info) => {
69
+ if (this.conversations.get(options.sessionId) !== selection) {
70
+ throw new SessionResumeError(
71
+ resumeSessionId ?? id,
72
+ "session was released while this turn was preparing",
73
+ );
74
+ }
75
+ selection.id = id;
76
+ options.onNativeSession?.(id, info);
77
+ },
78
+ };
79
+ }
80
+
81
+ override async release(sessionId: string): Promise<void> {
82
+ this.conversations.delete(sessionId);
83
+ await super.release(sessionId);
84
+ }
85
+
86
+ override async terminateAll(): Promise<void> {
87
+ this.terminated = true;
88
+ this.conversations.clear();
89
+ await super.terminateAll();
90
+ }
91
+ }
@@ -51,6 +51,12 @@ export class SessionStore<S extends StoredSession> {
51
51
  return this.sessions.values();
52
52
  }
53
53
 
54
+ /** Native clients include both retained sessions and unfinished initialization. */
55
+ *clients(): IterableIterator<S["client"]> {
56
+ for (const session of this.sessions.values()) yield session.client;
57
+ for (const clients of this.pending.values()) yield* clients;
58
+ }
59
+
54
60
  /** Self-heal: drop the mapping when this exact client closed underneath us. */
55
61
  dropIfCurrent(sessionId: string, client: S["client"]): void {
56
62
  const current = this.sessions.get(sessionId);
@@ -11,19 +11,12 @@
11
11
  * the `(string & {})` arm keeps the union open without losing autocomplete):
12
12
  * - `interruptTimeout` — a cancel's SDK interrupt round-trip timed out
13
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
14
  * - `sinkError` — an EventSink emit threw; the event was dropped for that
17
15
  * sink and the turn continued.
18
16
  * - `proxyUpstreamAuth` — the BYOK proxy's upstream rejected the real key
19
17
  * (401/403): the stored key is invalid/expired, not the placeholder.
20
18
  */
21
- export type DiagnosticKind =
22
- | "interruptTimeout"
23
- | "resumeFallback"
24
- | "sinkError"
25
- | "proxyUpstreamAuth"
26
- | (string & {});
19
+ export type DiagnosticKind = "interruptTimeout" | "sinkError" | "proxyUpstreamAuth" | (string & {});
27
20
 
28
21
  export interface EngineDiagnostic {
29
22
  type: DiagnosticKind;
package/src/core/index.ts CHANGED
@@ -111,10 +111,12 @@ export {
111
111
  export {
112
112
  AgentServerError,
113
113
  AgentExecutionError,
114
+ SessionResumeError,
114
115
  CliNotFoundError,
115
116
  CliProvisionError,
116
117
  HarnessNotFoundError,
117
118
  TurnConflictError,
119
+ TurnActiveError,
118
120
  } from "./utils/errors.ts";
119
121
 
120
122
  // Re-export the wire contract for convenience.
@@ -40,7 +40,7 @@ export interface CreateRegistryOptions {
40
40
  codexAppServer?: Omit<CodexAppServerAgentOptions, "resolveCliPath">;
41
41
  /**
42
42
  * Operational diagnostics port (see `EngineDiagnostic`): interrupt
43
- * timeouts, resume fallbacks, sink failures — signals the engine must not
43
+ * timeouts and sink failures — signals the engine must not
44
44
  * fail a turn over, surfaced for the host to log/metric. Applied to the
45
45
  * runtime and every harness that emits them; a harness-level
46
46
  * `claudeCode.onDiagnostic` overrides for that harness.
@@ -1,5 +1,5 @@
1
1
  import {
2
- type ErrorCategory,
2
+ type ErrorInfo,
3
3
  classifyError,
4
4
  isCancellation,
5
5
  isRecoverable,
@@ -39,7 +39,7 @@ export interface RunSummary {
39
39
  stopReason: StopReason;
40
40
  finishReason?: string;
41
41
  cost?: number;
42
- error?: { category: ErrorCategory; message: string };
42
+ error?: ErrorInfo;
43
43
  cancelled: boolean;
44
44
  }
45
45
 
@@ -406,8 +406,7 @@ export class AgentRuntime {
406
406
  disableTools: config.disableTools,
407
407
  signal: opts.signal,
408
408
  onNativeSession: (id, info) => {
409
- // Agents report exactly once per turn (the claude fallback defers its
410
- // report until the surviving session is known); guard against a
409
+ // Agents report once the native conversation is confirmed; guard against a
411
410
  // misbehaving harness re-reporting after emission. Agents always pass
412
411
  // their honest `resumed` judgment — the runtime surfaces it only when
413
412
  // this turn actually REQUESTED a resume.
@@ -419,7 +418,7 @@ export class AgentRuntime {
419
418
  };
420
419
 
421
420
  let cancelled = false;
422
- let errored: { category: ErrorCategory; message: string } | undefined;
421
+ let errored: ErrorInfo | undefined;
423
422
 
424
423
  try {
425
424
  for await (const raw of agent.execute(input, options)) {
@@ -470,9 +469,7 @@ export class AgentRuntime {
470
469
  : errored
471
470
  ? "error"
472
471
  : (result.stopReason ?? "end_turn");
473
- const terminalResult =
474
- errored && !result.error ? { ...result, error: errored.message } : result;
475
- for (const le of processor.finish(terminalResult, stopReason)) await emit(le);
472
+ for (const le of processor.finish(result, stopReason, errored)) await emit(le);
476
473
 
477
474
  return {
478
475
  sessionId,
@@ -510,8 +507,9 @@ export class AgentRuntime {
510
507
 
511
508
  /**
512
509
  * Call after the host resumes from suspension, before admitting new turns.
513
- * Retains conversations and admission receipts; supported harnesses repair
514
- * remote MCP connections lazily with the next turn's current credentials.
510
+ * Retains conversations and admission receipts. Claude and Codex app-server
511
+ * replace retained subprocesses before the next prompt and strictly resume
512
+ * saved history. Their MCP reset/reload controls can retain dead connections.
515
513
  */
516
514
  invalidateMcpConnections(): void {
517
515
  for (const harness of this.registry.list()) {
@@ -7,6 +7,7 @@ import {
7
7
  import type {
8
8
  AgentInput,
9
9
  Delta,
10
+ ErrorInfo,
10
11
  LifecycleEvent,
11
12
  Part,
12
13
  StopReason,
@@ -122,7 +123,14 @@ export class EventProcessor {
122
123
  }
123
124
  }
124
125
 
125
- *finish(result: TransformResult, stopReason: StopReason): Generator<LifecycleEvent> {
126
+ /** Preserve the runtime's classified error; standalone callers can use the adapter text. */
127
+ *finish(
128
+ result: TransformResult,
129
+ stopReason: StopReason,
130
+ error: ErrorInfo | undefined = result.error && !result.cancelled && stopReason !== "cancelled"
131
+ ? { category: classifyError(result.error), message: result.error }
132
+ : undefined,
133
+ ): Generator<LifecycleEvent> {
126
134
  yield* this.closeMessage();
127
135
  yield {
128
136
  type: "turn.ended",
@@ -138,10 +146,7 @@ export class EventProcessor {
138
146
  ...(result.reportedModels && { reportedModels: result.reportedModels }),
139
147
  },
140
148
  }),
141
- error:
142
- result.error && !result.cancelled
143
- ? { category: classifyError(result.error), message: result.error }
144
- : undefined,
149
+ error,
145
150
  timestamp: Date.now(),
146
151
  };
147
152
  }
@@ -47,6 +47,14 @@ export class TurnConflictError extends AgentServerError {
47
47
  }
48
48
  }
49
49
 
50
+ /** A built-in harness already has a turn preparing, running, or draining on this session. */
51
+ export class TurnActiveError extends AgentServerError {
52
+ constructor(readonly sessionId: string) {
53
+ super(`Session ${sessionId} already has an active turn; wait for it to end`, "TURN_ACTIVE");
54
+ this.name = "TurnActiveError";
55
+ }
56
+ }
57
+
50
58
  export class AgentExecutionError extends AgentServerError {
51
59
  constructor(message: string, options?: { cause?: unknown }) {
52
60
  super(message, "AGENT_EXECUTION_ERROR");
@@ -55,6 +63,19 @@ export class AgentExecutionError extends AgentServerError {
55
63
  }
56
64
  }
57
65
 
66
+ /** The requested conversation could not be loaded. Never replay its prompt on a fresh one. */
67
+ export class SessionResumeError extends AgentServerError {
68
+ constructor(
69
+ readonly nativeSessionId: string,
70
+ reason: string,
71
+ options?: { cause?: unknown },
72
+ ) {
73
+ super(`Cannot resume conversation ${nativeSessionId}: ${reason}`, "SESSION_RESUME_FAILED");
74
+ this.name = "SessionResumeError";
75
+ if (options?.cause !== undefined) this.cause = options.cause;
76
+ }
77
+ }
78
+
58
79
  export class CliNotFoundError extends AgentServerError {
59
80
  constructor(name: string, hint?: string) {
60
81
  super(
@@ -62,7 +62,7 @@ export const RunConfigSchema = z.object({
62
62
  * Native session/thread id to resume (from a prior `session.created`). When
63
63
  * set, the harness continues that conversation instead of starting fresh.
64
64
  */
65
- resumeSessionId: z.string().optional(),
65
+ resumeSessionId: z.string().min(1).optional(),
66
66
  /**
67
67
  * Resume from a specific historical message within the resumed session
68
68
  * (branch/retry flows). Claude-only today; other harnesses ignore it.
@@ -20,6 +20,7 @@ export const ERROR_CATEGORIES = [
20
20
  "abort",
21
21
  "invalid_request",
22
22
  "process_exit",
23
+ "resume_failed",
23
24
  "internal",
24
25
  ] as const;
25
26
  export type ErrorCategory = (typeof ERROR_CATEGORIES)[number] | (string & {});
@@ -38,6 +39,7 @@ export type ErrorInfo = z.infer<typeof ErrorInfoSchema>;
38
39
  * classifier; lives in /protocol because categories are vocabulary.
39
40
  */
40
41
  const RULES: ReadonlyArray<[ErrorCategory, readonly string[]]> = [
42
+ ["resume_failed", ["no conversation found with session id", "no rollout found for thread id"]],
41
43
  ["abort", ["aborted", "aborterror", "cancelled", "canceled", "interrupted", "sigint"]],
42
44
  [
43
45
  "auth",
@@ -76,6 +78,15 @@ const RULES: ReadonlyArray<[ErrorCategory, readonly string[]]> = [
76
78
  ];
77
79
 
78
80
  export function classifyError(error: unknown): ErrorCategory {
81
+ if (error instanceof Error && "code" in error && error.code === "TURN_ACTIVE")
82
+ return "invalid_request";
83
+ if (error instanceof Error && "code" in error && error.code === "SESSION_RESUME_FAILED") {
84
+ if (error.cause !== undefined) {
85
+ const category = classifyError(error.cause);
86
+ if (category !== "internal") return category;
87
+ }
88
+ return "resume_failed";
89
+ }
79
90
  const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
80
91
  for (const [category, needles] of RULES) {
81
92
  if (needles.some((n) => message.includes(n))) return category;
@@ -94,7 +94,8 @@ export const SessionCreatedEventSchema = z.object({
94
94
  * actually continued that conversation. `false` = it fell back to a fresh
95
95
  * session (context lost) — surface this, never swallow it. Compare with
96
96
  * this flag, not ids: some harnesses mint a NEW nativeSessionId on a
97
- * successful resume (Claude).
97
+ * successful resume (Claude). Built-in harnesses fail instead of starting
98
+ * fresh; `false` remains readable for older engines and custom harnesses.
98
99
  */
99
100
  resumed: z.boolean().optional(),
100
101
  timestamp: EpochMsSchema,
@@ -48,6 +48,16 @@ export async function createAcpAgentApp(options: AcpBindingOptions): Promise<Age
48
48
  const acp = await import("@agentclientprotocol/sdk");
49
49
  const { runtime, harness } = options;
50
50
  const sessions = new Map<string, BoundSession>();
51
+ const checkMcpSupport = (servers: readonly unknown[] | undefined): void => {
52
+ if (harness === "codex-app-server" && servers?.length) {
53
+ // ACP supplies MCP entries, but has no session-specific CODEX_HOME
54
+ // field. Never write those credentials into the operator's user home.
55
+ throw acp.RequestError.invalidParams(
56
+ undefined,
57
+ "Codex MCP is not supported by this ACP binding; use the engine or JSON-RPC API with a session-specific env.CODEX_HOME",
58
+ );
59
+ }
60
+ };
51
61
 
52
62
  const requireSession = (sessionId: string): BoundSession => {
53
63
  const session = sessions.get(sessionId);
@@ -67,6 +77,7 @@ export async function createAcpAgentApp(options: AcpBindingOptions): Promise<Age
67
77
  authMethods: [],
68
78
  }))
69
79
  .onRequest("session/new", (ctx) => {
80
+ checkMcpSupport(ctx.params.mcpServers);
70
81
  const sessionId = generateUUIDv7();
71
82
  sessions.set(sessionId, {
72
83
  cwd: ctx.params.cwd,
@@ -76,6 +87,7 @@ export async function createAcpAgentApp(options: AcpBindingOptions): Promise<Age
76
87
  })
77
88
  .onRequest("session/resume", (ctx) => {
78
89
  const session = requireSession(ctx.params.sessionId);
90
+ checkMcpSupport(ctx.params.mcpServers);
79
91
  session.cwd = ctx.params.cwd;
80
92
  if (ctx.params.mcpServers) session.mcpServers = fromAcpMcpServers(ctx.params.mcpServers);
81
93
  return {};