@zvada/agent-server 0.3.8 → 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.
- package/CHANGELOG.md +51 -0
- package/docs/consuming.md +122 -9
- package/docs/harnesses.md +8 -3
- package/package.json +1 -1
- package/src/core/agents/acp/acp-agent.ts +29 -29
- package/src/core/agents/base.ts +19 -8
- package/src/core/agents/claude-code/claude-agent.ts +78 -90
- package/src/core/agents/claude-code/generator-session.ts +160 -12
- package/src/core/agents/claude-code/session-manager.ts +27 -37
- package/src/core/agents/codex-app-server/codex-app-server-agent.ts +136 -35
- package/src/core/agents/codex-app-server/mcp.ts +139 -0
- package/src/core/agents/codex-sdk/codex-sdk-agent.ts +31 -57
- package/src/core/agents/mcp-config.ts +31 -0
- package/src/core/agents/session-agent.ts +91 -0
- package/src/core/agents/session-store.ts +6 -0
- package/src/core/diagnostics.ts +1 -8
- package/src/core/index.ts +2 -0
- package/src/core/presets.ts +1 -1
- package/src/core/runtime/agent-runtime.ts +17 -8
- package/src/core/runtime/event-processor.ts +10 -5
- package/src/core/utils/errors.ts +21 -0
- package/src/protocol/config.ts +1 -1
- package/src/protocol/errors.ts +11 -0
- package/src/protocol/lifecycle.ts +2 -1
- package/src/server/acp/binding.ts +12 -0
|
@@ -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);
|
package/src/core/diagnostics.ts
CHANGED
|
@@ -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.
|
package/src/core/presets.ts
CHANGED
|
@@ -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
|
|
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
|
|
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?:
|
|
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
|
|
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:
|
|
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
|
|
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,
|
|
@@ -508,6 +505,18 @@ export class AgentRuntime {
|
|
|
508
505
|
return await this.registry.getAgent(harness).cancel(sessionId);
|
|
509
506
|
}
|
|
510
507
|
|
|
508
|
+
/**
|
|
509
|
+
* Call after the host resumes from suspension, before admitting new turns.
|
|
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.
|
|
513
|
+
*/
|
|
514
|
+
invalidateMcpConnections(): void {
|
|
515
|
+
for (const harness of this.registry.list()) {
|
|
516
|
+
this.registry.getAgent(harness).invalidateMcpConnections?.();
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
511
520
|
/** Dispose one idle logical session and its harness-native resources. */
|
|
512
521
|
async closeSession(harness: AgentHarness, sessionId: string): Promise<void> {
|
|
513
522
|
this.completedTurns.delete(sessionId);
|
|
@@ -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
|
-
|
|
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
|
}
|
package/src/core/utils/errors.ts
CHANGED
|
@@ -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(
|
package/src/protocol/config.ts
CHANGED
|
@@ -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.
|
package/src/protocol/errors.ts
CHANGED
|
@@ -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 {};
|