@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.
- package/CHANGELOG.md +41 -0
- package/docs/consuming.md +110 -14
- 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 +17 -8
- package/src/core/agents/claude-code/claude-agent.ts +71 -93
- package/src/core/agents/claude-code/generator-session.ts +65 -44
- package/src/core/agents/claude-code/session-manager.ts +20 -36
- 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 +8 -10
- 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
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, resolve } from "node:path";
|
|
1
4
|
import type {
|
|
2
5
|
AgentCapabilities,
|
|
3
6
|
AgentInput,
|
|
@@ -5,20 +8,28 @@ import type {
|
|
|
5
8
|
PermissionToolCall,
|
|
6
9
|
} from "../../../protocol/index.ts";
|
|
7
10
|
import { AsyncQueue, codexReasoningEffort } from "../../../protocol/index.ts";
|
|
11
|
+
import { SessionResumeError } from "../../utils/errors.ts";
|
|
8
12
|
import type {
|
|
9
13
|
AgentExecuteOptions,
|
|
10
14
|
CancelResult,
|
|
11
15
|
PermissionRequestHandler,
|
|
12
16
|
RawAgentEvent,
|
|
13
17
|
} from "../base.ts";
|
|
14
|
-
import { BaseAgent } from "../base.ts";
|
|
15
18
|
import { configFingerprint } from "../config-fingerprint.ts";
|
|
19
|
+
import { snapshotMcpServers } from "../mcp-config.ts";
|
|
20
|
+
import { SessionAgent } from "../session-agent.ts";
|
|
16
21
|
import { SessionStore } from "../session-store.ts";
|
|
17
22
|
import {
|
|
18
23
|
CodexAppServerClient,
|
|
19
24
|
type CodexAppServerClientOptions,
|
|
20
25
|
type CodexNotification,
|
|
21
26
|
} from "./client.ts";
|
|
27
|
+
import {
|
|
28
|
+
assertCodexMcpConfig,
|
|
29
|
+
assertCodexMcpReady,
|
|
30
|
+
codexMcpServers,
|
|
31
|
+
writeCodexMcpServers,
|
|
32
|
+
} from "./mcp.ts";
|
|
22
33
|
|
|
23
34
|
const CAPABILITIES: AgentCapabilities = {
|
|
24
35
|
multiTurn: true,
|
|
@@ -27,7 +38,7 @@ const CAPABILITIES: AgentCapabilities = {
|
|
|
27
38
|
modelSwitch: "in-session",
|
|
28
39
|
thinkingLevels: true,
|
|
29
40
|
images: true,
|
|
30
|
-
mcpServers:
|
|
41
|
+
mcpServers: true,
|
|
31
42
|
permissionRequests: true,
|
|
32
43
|
};
|
|
33
44
|
|
|
@@ -41,6 +52,8 @@ export interface AppServerSession {
|
|
|
41
52
|
envFingerprint: string;
|
|
42
53
|
/** Native model connections retain the identity used at their first handshake. */
|
|
43
54
|
authFingerprint?: string;
|
|
55
|
+
/** Last native config write; readiness is checked separately before every prompt. */
|
|
56
|
+
mcpFingerprint?: string;
|
|
44
57
|
/** Credentials and permission broker belong only to the current execute. */
|
|
45
58
|
turn?: AppServerTurn;
|
|
46
59
|
idleTimer?: ReturnType<typeof setTimeout>;
|
|
@@ -213,10 +226,13 @@ export function appServerNotificationEndsTurn(notification: CodexNotification):
|
|
|
213
226
|
* thread is created per logical session and reused for multi-turn; model/effort
|
|
214
227
|
* are passed per `turn/start`. Resume uses `thread/resume` with the native id.
|
|
215
228
|
*/
|
|
216
|
-
export class CodexAppServerAgent extends
|
|
229
|
+
export class CodexAppServerAgent extends SessionAgent {
|
|
217
230
|
readonly harness = "codex-app-server" as const;
|
|
218
231
|
readonly capabilities = CAPABILITIES;
|
|
219
232
|
private readonly sessions = new SessionStore<AppServerSession>();
|
|
233
|
+
private readonly invalidatedClients = new WeakSet<CodexAppServerClient>();
|
|
234
|
+
/** Home ownership outlives idle process eviction and failed preparation. */
|
|
235
|
+
private readonly mcpHomes = new Map<string, string>();
|
|
220
236
|
/** A cancel acknowledgement belongs to one invocation, even after it drains. */
|
|
221
237
|
private readonly turnInterruptState = new WeakMap<
|
|
222
238
|
AbortController,
|
|
@@ -230,6 +246,7 @@ export class CodexAppServerAgent extends BaseAgent {
|
|
|
230
246
|
private async resolveSession(
|
|
231
247
|
options: AgentExecuteOptions,
|
|
232
248
|
turn: AppServerTurn,
|
|
249
|
+
mcpServers: Record<string, unknown> | undefined,
|
|
233
250
|
): Promise<AppServerSession> {
|
|
234
251
|
const { signal } = turn;
|
|
235
252
|
signal.throwIfAborted();
|
|
@@ -240,9 +257,13 @@ export class CodexAppServerAgent extends BaseAgent {
|
|
|
240
257
|
// Codex caches an authenticated model WebSocket across turns. Login alone
|
|
241
258
|
// cannot switch its identity: restart on account/key changes and resume
|
|
242
259
|
// the same thread. Same-account token renewal can keep the process warm.
|
|
243
|
-
|
|
260
|
+
const resumeThreadId = options.resumeSessionId;
|
|
244
261
|
if (existing && !existing.client.closed) {
|
|
245
|
-
if (
|
|
262
|
+
if (
|
|
263
|
+
appServerSessionCompatible(existing, options) &&
|
|
264
|
+
existing.authFingerprint === identity &&
|
|
265
|
+
!this.invalidatedClients.has(existing.client)
|
|
266
|
+
) {
|
|
246
267
|
this.sessions.clearIdle(existing);
|
|
247
268
|
existing.turn = turn;
|
|
248
269
|
const onAbort = () => existing.client.close();
|
|
@@ -258,15 +279,7 @@ export class CodexAppServerAgent extends BaseAgent {
|
|
|
258
279
|
signal.removeEventListener("abort", onAbort);
|
|
259
280
|
}
|
|
260
281
|
}
|
|
261
|
-
|
|
262
|
-
// claude-code restart policy); an explicit resumeSessionId always wins.
|
|
263
|
-
if (
|
|
264
|
-
resumeThreadId === undefined &&
|
|
265
|
-
existing.cwd === options.cwd &&
|
|
266
|
-
existing.envFingerprint === configFingerprint(options.env)
|
|
267
|
-
) {
|
|
268
|
-
resumeThreadId = existing.threadId;
|
|
269
|
-
}
|
|
282
|
+
|
|
270
283
|
this.sessions.close(options.sessionId);
|
|
271
284
|
}
|
|
272
285
|
|
|
@@ -301,6 +314,8 @@ export class CodexAppServerAgent extends BaseAgent {
|
|
|
301
314
|
signal.throwIfAborted();
|
|
302
315
|
await client.initialize();
|
|
303
316
|
await this.authenticate(session);
|
|
317
|
+
// Clear stale per-turn credentials before thread startup can use them.
|
|
318
|
+
await this.configureMcp(session, mcpServers);
|
|
304
319
|
|
|
305
320
|
const startParams: Record<string, unknown> = {
|
|
306
321
|
cwd: options.cwd,
|
|
@@ -313,24 +328,28 @@ export class CodexAppServerAgent extends BaseAgent {
|
|
|
313
328
|
if (options.systemPromptAppend)
|
|
314
329
|
startParams.developerInstructions = options.systemPromptAppend;
|
|
315
330
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
331
|
+
const method = resumeThreadId ? "thread/resume" : "thread/start";
|
|
332
|
+
const result = (await client
|
|
333
|
+
.request(
|
|
334
|
+
method,
|
|
335
|
+
resumeThreadId ? { threadId: resumeThreadId, ...startParams } : startParams,
|
|
336
|
+
)
|
|
337
|
+
.catch((cause: unknown) => {
|
|
338
|
+
if (!resumeThreadId) throw cause;
|
|
339
|
+
throw new SessionResumeError(
|
|
340
|
+
resumeThreadId,
|
|
341
|
+
cause instanceof Error ? cause.message : String(cause),
|
|
342
|
+
{ cause },
|
|
343
|
+
);
|
|
344
|
+
})) as { thread?: { id?: string } };
|
|
345
|
+
const threadId = result?.thread?.id;
|
|
346
|
+
if (resumeThreadId && threadId !== resumeThreadId) {
|
|
347
|
+
throw new SessionResumeError(
|
|
348
|
+
resumeThreadId,
|
|
349
|
+
`Codex returned ${threadId ? `a different thread: ${threadId}` : "no thread id"}`,
|
|
350
|
+
);
|
|
333
351
|
}
|
|
352
|
+
if (!threadId) throw new Error("codex app-server thread/start returned no thread id");
|
|
334
353
|
signal.throwIfAborted();
|
|
335
354
|
session.threadId = threadId;
|
|
336
355
|
this.sessions.set(options.sessionId, session);
|
|
@@ -346,6 +365,45 @@ export class CodexAppServerAgent extends BaseAgent {
|
|
|
346
365
|
}
|
|
347
366
|
}
|
|
348
367
|
|
|
368
|
+
private managedMcpHome(options: AgentExecuteOptions, hasServers: boolean): boolean {
|
|
369
|
+
const home = options.env?.CODEX_HOME;
|
|
370
|
+
if (!home) {
|
|
371
|
+
if (hasServers)
|
|
372
|
+
throw new Error("Codex per-turn MCP requires a session-specific env.CODEX_HOME");
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
if (!isAbsolute(home)) throw new Error("Codex session CODEX_HOME must be an absolute path");
|
|
376
|
+
const shared = resolve(process.env.CODEX_HOME ?? `${homedir()}/.codex`);
|
|
377
|
+
if (resolve(home) === shared) {
|
|
378
|
+
throw new Error("Codex per-turn MCP cannot use the shared user CODEX_HOME");
|
|
379
|
+
}
|
|
380
|
+
const path = realpathSync(home);
|
|
381
|
+
if (existsSync(shared) && path === realpathSync(shared)) {
|
|
382
|
+
throw new Error("Codex per-turn MCP cannot use the shared user CODEX_HOME");
|
|
383
|
+
}
|
|
384
|
+
const owner = this.mcpHomes.get(path);
|
|
385
|
+
if (owner && owner !== options.sessionId) {
|
|
386
|
+
throw new Error("Codex CODEX_HOME is already owned by another session");
|
|
387
|
+
}
|
|
388
|
+
this.mcpHomes.set(path, options.sessionId);
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private async configureMcp(
|
|
393
|
+
session: AppServerSession,
|
|
394
|
+
servers: Record<string, unknown> | undefined,
|
|
395
|
+
): Promise<void> {
|
|
396
|
+
if (servers === undefined) return;
|
|
397
|
+
const fingerprint = configFingerprint(servers);
|
|
398
|
+
const changed = session.mcpFingerprint !== fingerprint;
|
|
399
|
+
// A rejected write or changed project layer invalidates the cached identity.
|
|
400
|
+
session.mcpFingerprint = undefined;
|
|
401
|
+
if (changed) await writeCodexMcpServers(session.client, servers);
|
|
402
|
+
await assertCodexMcpConfig(session.client, session.cwd, servers);
|
|
403
|
+
if (changed) await session.client.request("config/mcpServer/reload", undefined);
|
|
404
|
+
session.mcpFingerprint = fingerprint;
|
|
405
|
+
}
|
|
406
|
+
|
|
349
407
|
private async authenticate(session: AppServerSession): Promise<void> {
|
|
350
408
|
session.turn?.signal.throwIfAborted();
|
|
351
409
|
const auth = session.turn?.auth;
|
|
@@ -430,11 +488,11 @@ export class CodexAppServerAgent extends BaseAgent {
|
|
|
430
488
|
});
|
|
431
489
|
}
|
|
432
490
|
|
|
433
|
-
async *
|
|
491
|
+
protected override async *executeTurn(
|
|
434
492
|
input: AgentInput,
|
|
435
493
|
options: AgentExecuteOptions,
|
|
494
|
+
controller: AbortController,
|
|
436
495
|
): AsyncIterableIterator<RawAgentEvent> {
|
|
437
|
-
const controller = this.trackTurn(options.sessionId, options.signal);
|
|
438
496
|
const turn: AppServerTurn = {
|
|
439
497
|
permissionHandler: options.onPermissionRequest,
|
|
440
498
|
signal: controller.signal,
|
|
@@ -455,16 +513,36 @@ export class CodexAppServerAgent extends BaseAgent {
|
|
|
455
513
|
let turnEnded = false;
|
|
456
514
|
try {
|
|
457
515
|
controller.signal.throwIfAborted();
|
|
516
|
+
const requestedMcp = codexMcpServers(snapshotMcpServers(options.mcpServers));
|
|
517
|
+
const mcpServers = this.managedMcpHome(options, Object.keys(requestedMcp).length > 0)
|
|
518
|
+
? requestedMcp
|
|
519
|
+
: undefined;
|
|
458
520
|
if (this.agentOptions.resolveAuth) {
|
|
459
521
|
turn.auth = await this.agentOptions.resolveAuth(options.sessionId);
|
|
460
522
|
}
|
|
461
523
|
controller.signal.throwIfAborted();
|
|
462
|
-
session = await this.resolveSession(options, turn);
|
|
524
|
+
session = await this.resolveSession(options, turn, mcpServers);
|
|
463
525
|
options.onNativeSession?.(session.threadId, {
|
|
464
526
|
resumed: session.threadId === options.resumeSessionId,
|
|
465
527
|
});
|
|
466
528
|
const threadId = session.threadId;
|
|
467
529
|
const client = session.client;
|
|
530
|
+
const abortPreparation = () => client.close();
|
|
531
|
+
controller.signal.addEventListener("abort", abortPreparation, { once: true });
|
|
532
|
+
try {
|
|
533
|
+
controller.signal.throwIfAborted();
|
|
534
|
+
await this.configureMcp(session, mcpServers);
|
|
535
|
+
try {
|
|
536
|
+
await assertCodexMcpReady(client, threadId, Object.keys(mcpServers ?? {}));
|
|
537
|
+
} catch (error) {
|
|
538
|
+
// A later explicit turn must reload failed clients even if config is unchanged.
|
|
539
|
+
session.mcpFingerprint = undefined;
|
|
540
|
+
throw error;
|
|
541
|
+
}
|
|
542
|
+
controller.signal.throwIfAborted();
|
|
543
|
+
} finally {
|
|
544
|
+
controller.signal.removeEventListener("abort", abortPreparation);
|
|
545
|
+
}
|
|
468
546
|
|
|
469
547
|
let turnId: string | undefined;
|
|
470
548
|
const forceCancel = () => {
|
|
@@ -544,6 +622,10 @@ export class CodexAppServerAgent extends BaseAgent {
|
|
|
544
622
|
if (options.thinkingLevel) turnParams.effort = codexReasoningEffort(options.thinkingLevel);
|
|
545
623
|
|
|
546
624
|
if (controller.signal.aborted) throw new Error("turn aborted");
|
|
625
|
+
if (this.invalidatedClients.has(client)) {
|
|
626
|
+
client.close();
|
|
627
|
+
throw new Error("Codex process was invalidated before turn admission");
|
|
628
|
+
}
|
|
547
629
|
client.request("turn/start", turnParams).catch((err: unknown) => {
|
|
548
630
|
if (controller.signal.aborted) {
|
|
549
631
|
forceCancel();
|
|
@@ -563,7 +645,6 @@ export class CodexAppServerAgent extends BaseAgent {
|
|
|
563
645
|
clearStall();
|
|
564
646
|
if (cancelTimer) clearTimeout(cancelTimer);
|
|
565
647
|
for (const off of unsubscribe) off();
|
|
566
|
-
this.endTurn(options.sessionId, controller);
|
|
567
648
|
if (session?.turn === turn) {
|
|
568
649
|
session.turn = undefined;
|
|
569
650
|
if (!session.client.closed) {
|
|
@@ -593,13 +674,33 @@ export class CodexAppServerAgent extends BaseAgent {
|
|
|
593
674
|
return { confirmed, hadTurn: true };
|
|
594
675
|
}
|
|
595
676
|
|
|
677
|
+
/** Unchanged MCP config reloads retain dead remote connections after VM resume. */
|
|
678
|
+
invalidateMcpConnections(): void {
|
|
679
|
+
// Keep SessionAgent's conversation targets and runtime admission receipts.
|
|
680
|
+
// Transport recovery can signal this while a turn is running: let that
|
|
681
|
+
// turn finish, then use the existing strict thread/resume path.
|
|
682
|
+
const active = new Set(
|
|
683
|
+
[...this.sessions.values()]
|
|
684
|
+
.filter((session) => session.turn)
|
|
685
|
+
.map((session) => session.client),
|
|
686
|
+
);
|
|
687
|
+
for (const client of this.sessions.clients()) {
|
|
688
|
+
this.invalidatedClients.add(client);
|
|
689
|
+
if (!active.has(client)) client.close();
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
596
693
|
override async release(sessionId: string): Promise<void> {
|
|
597
694
|
await super.release(sessionId);
|
|
598
695
|
this.sessions.close(sessionId);
|
|
696
|
+
for (const [home, owner] of this.mcpHomes) {
|
|
697
|
+
if (owner === sessionId) this.mcpHomes.delete(home);
|
|
698
|
+
}
|
|
599
699
|
}
|
|
600
700
|
|
|
601
701
|
override async terminateAll(): Promise<void> {
|
|
602
702
|
await super.terminateAll();
|
|
603
703
|
this.sessions.closeAll();
|
|
704
|
+
this.mcpHomes.clear();
|
|
604
705
|
}
|
|
605
706
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { AsyncQueue, type McpServerConfig } from "../../../protocol/index.ts";
|
|
2
|
+
import { configFingerprint } from "../config-fingerprint.ts";
|
|
3
|
+
import { assertMcpConnected, mcpConnectionErrors } from "../mcp-config.ts";
|
|
4
|
+
import type { CodexAppServerClient } from "./client.ts";
|
|
5
|
+
|
|
6
|
+
/** Codex supports stdio and Streamable HTTP, not the legacy SSE transport. */
|
|
7
|
+
export function codexMcpServers(servers: Record<string, McpServerConfig>): Record<string, unknown> {
|
|
8
|
+
return Object.fromEntries(
|
|
9
|
+
Object.entries(servers).map(([name, server]) => {
|
|
10
|
+
if (name === "codex_apps") throw new Error("MCP server name codex_apps is reserved by Codex");
|
|
11
|
+
if (server.type === "sse") {
|
|
12
|
+
throw new Error(`Codex MCP server ${name}: legacy SSE is unsupported; use Streamable HTTP`);
|
|
13
|
+
}
|
|
14
|
+
return [
|
|
15
|
+
name,
|
|
16
|
+
server.type === "stdio"
|
|
17
|
+
? {
|
|
18
|
+
command: server.command,
|
|
19
|
+
...(server.args && { args: server.args }),
|
|
20
|
+
...(server.env && { env: server.env }),
|
|
21
|
+
}
|
|
22
|
+
: { url: server.url, ...(server.headers && { http_headers: server.headers }) },
|
|
23
|
+
];
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Only call against an explicitly isolated, engine-managed CODEX_HOME. */
|
|
29
|
+
export async function writeCodexMcpServers(
|
|
30
|
+
client: CodexAppServerClient,
|
|
31
|
+
servers: Record<string, unknown>,
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
const result = (await client.request("config/batchWrite", {
|
|
34
|
+
edits: [{ keyPath: "mcp_servers", value: servers, mergeStrategy: "replace" }],
|
|
35
|
+
// Check project-layer overrides before any connection can use credentials.
|
|
36
|
+
reloadUserConfig: false,
|
|
37
|
+
})) as { status?: string; overriddenMetadata?: unknown };
|
|
38
|
+
if (result.status !== "ok" || result.overriddenMetadata) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
"Codex MCP configuration was not applied; check higher-priority native settings",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizedConfig(value: unknown): unknown {
|
|
46
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
47
|
+
const config = { ...(value as Record<string, unknown>) };
|
|
48
|
+
// Defaults materialized by config/read; preserve every non-default field.
|
|
49
|
+
if (config.environment_id === "local") config.environment_id = undefined;
|
|
50
|
+
if (config.enabled === true) config.enabled = undefined;
|
|
51
|
+
if (config.tool_timeout_sec === null) config.tool_timeout_sec = undefined;
|
|
52
|
+
if (typeof config.command === "string") {
|
|
53
|
+
config.args ??= [];
|
|
54
|
+
config.env ??= {};
|
|
55
|
+
} else if (typeof config.url === "string") {
|
|
56
|
+
config.http_headers ??= {};
|
|
57
|
+
}
|
|
58
|
+
return config;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** User-layer write acknowledgements do not account for repository overrides. */
|
|
62
|
+
export async function assertCodexMcpConfig(
|
|
63
|
+
client: CodexAppServerClient,
|
|
64
|
+
cwd: string,
|
|
65
|
+
servers: Record<string, unknown>,
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
if (!Object.keys(servers).length) return;
|
|
68
|
+
const result = (await client.request("config/read", { cwd })) as {
|
|
69
|
+
config?: { mcp_servers?: Record<string, unknown> };
|
|
70
|
+
};
|
|
71
|
+
for (const [name, server] of Object.entries(servers)) {
|
|
72
|
+
if (
|
|
73
|
+
configFingerprint(normalizedConfig(result.config?.mcp_servers?.[name])) !==
|
|
74
|
+
configFingerprint(normalizedConfig(server))
|
|
75
|
+
) {
|
|
76
|
+
throw new Error(`Codex MCP server ${name} is overridden by native settings`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function assertCodexMcpReady(
|
|
82
|
+
client: CodexAppServerClient,
|
|
83
|
+
threadId: string,
|
|
84
|
+
required: string[],
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
if (!required.length) return;
|
|
87
|
+
// A status snapshot can arrive while startup is still in progress. Subscribe
|
|
88
|
+
// first so completion during the request cannot be lost. Native notifications
|
|
89
|
+
// trigger another authoritative read; no setup, model or tool is retried.
|
|
90
|
+
const updates = new AsyncQueue<void>();
|
|
91
|
+
const offStatus = client.onNotification(({ method, params }) => {
|
|
92
|
+
if (
|
|
93
|
+
method === "mcpServer/startupStatus/updated" &&
|
|
94
|
+
params.threadId === threadId &&
|
|
95
|
+
required.includes(String(params.name))
|
|
96
|
+
)
|
|
97
|
+
updates.push(undefined);
|
|
98
|
+
});
|
|
99
|
+
const offClose = client.onClose(() => updates.fail(new Error("Codex closed during MCP startup")));
|
|
100
|
+
const deadline = Date.now() + 30_000;
|
|
101
|
+
const timer = setTimeout(() => updates.fail(new Error("Codex MCP startup timed out")), 30_000);
|
|
102
|
+
try {
|
|
103
|
+
for (;;) {
|
|
104
|
+
const statuses: Array<{ name: string; status: string | null }> = [];
|
|
105
|
+
let cursor: string | null | undefined;
|
|
106
|
+
do {
|
|
107
|
+
const page = (await client.request(
|
|
108
|
+
"mcpServerStatus/list",
|
|
109
|
+
{ threadId, detail: "toolsAndAuthOnly", limit: 100, ...(cursor && { cursor }) },
|
|
110
|
+
Math.max(1, deadline - Date.now()),
|
|
111
|
+
)) as {
|
|
112
|
+
data?: Array<{ name: string; runtimeStatus: string | null }>;
|
|
113
|
+
nextCursor?: string | null;
|
|
114
|
+
};
|
|
115
|
+
if (!Array.isArray(page.data)) throw new Error("Codex returned no MCP readiness status");
|
|
116
|
+
statuses.push(
|
|
117
|
+
...page.data.map(({ name, runtimeStatus }) => ({ name, status: runtimeStatus })),
|
|
118
|
+
);
|
|
119
|
+
cursor = page.nextCursor;
|
|
120
|
+
} while (cursor);
|
|
121
|
+
const starting = required.filter(
|
|
122
|
+
(name) => statuses.find((entry) => entry.name === name)?.status === "starting",
|
|
123
|
+
);
|
|
124
|
+
assertMcpConnected(
|
|
125
|
+
mcpConnectionErrors(
|
|
126
|
+
required.filter((name) => !starting.includes(name)),
|
|
127
|
+
statuses,
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
if (!starting.length) return;
|
|
131
|
+
await updates.next();
|
|
132
|
+
}
|
|
133
|
+
} finally {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
offStatus();
|
|
136
|
+
offClose();
|
|
137
|
+
updates.end();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { Codex, Thread, ThreadEvent, ThreadOptions } from "@openai/codex-sdk";
|
|
2
2
|
import type { AgentCapabilities, AgentInput, PermissionMode } from "../../../protocol/index.ts";
|
|
3
|
-
import { codexReasoningEffort
|
|
3
|
+
import { codexReasoningEffort } from "../../../protocol/index.ts";
|
|
4
|
+
import { SessionResumeError } from "../../utils/errors.ts";
|
|
4
5
|
import type { AgentExecuteOptions, RawAgentEvent } from "../base.ts";
|
|
5
|
-
import { BaseAgent } from "../base.ts";
|
|
6
6
|
import { configFingerprint } from "../config-fingerprint.ts";
|
|
7
|
+
import { SessionAgent } from "../session-agent.ts";
|
|
7
8
|
|
|
8
9
|
const CAPABILITIES: AgentCapabilities = {
|
|
9
10
|
multiTurn: true,
|
|
@@ -132,7 +133,7 @@ export interface CodexSdkAgentOptions {
|
|
|
132
133
|
* `resumeThread(nativeId)`. Streams at item granularity — the adapter derives
|
|
133
134
|
* text deltas by diffing successive item snapshots.
|
|
134
135
|
*/
|
|
135
|
-
export class CodexSdkAgent extends
|
|
136
|
+
export class CodexSdkAgent extends SessionAgent {
|
|
136
137
|
readonly harness = "codex-sdk" as const;
|
|
137
138
|
readonly capabilities = CAPABILITIES;
|
|
138
139
|
private readonly threads = new Map<string, LiveThread>();
|
|
@@ -184,13 +185,7 @@ export class CodexSdkAgent extends BaseAgent {
|
|
|
184
185
|
const codex = await this.getCodex(options, codexPathOverride);
|
|
185
186
|
if (signal?.aborted) throw new Error("turn aborted");
|
|
186
187
|
const fingerprint = credentialsFingerprint(options);
|
|
187
|
-
|
|
188
|
-
// claude-code restart policy); an explicit resumeSessionId always wins.
|
|
189
|
-
const resumeThreadId =
|
|
190
|
-
options.resumeSessionId ??
|
|
191
|
-
(existing && existing.cwd === options.cwd && existing.credentialsFingerprint === fingerprint
|
|
192
|
-
? existing.thread.id
|
|
193
|
-
: undefined);
|
|
188
|
+
const resumeThreadId = options.resumeSessionId;
|
|
194
189
|
const thread = resumeThreadId
|
|
195
190
|
? codex.resumeThread(resumeThreadId, threadOptions(options))
|
|
196
191
|
: codex.startThread(threadOptions(options));
|
|
@@ -209,59 +204,38 @@ export class CodexSdkAgent extends BaseAgent {
|
|
|
209
204
|
return live;
|
|
210
205
|
}
|
|
211
206
|
|
|
212
|
-
async *
|
|
207
|
+
protected override async *executeTurn(
|
|
213
208
|
input: AgentInput,
|
|
214
209
|
options: AgentExecuteOptions,
|
|
210
|
+
controller: AbortController,
|
|
215
211
|
): AsyncIterableIterator<RawAgentEvent> {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
reported = true;
|
|
229
|
-
options.onNativeSession?.(id, { resumed: id === options.resumeSessionId });
|
|
230
|
-
}
|
|
231
|
-
};
|
|
232
|
-
try {
|
|
233
|
-
const { events } = await live.thread.runStreamed(toCodexInput(input), {
|
|
234
|
-
signal: controller.signal,
|
|
235
|
-
});
|
|
236
|
-
for await (const event of events as AsyncGenerator<ThreadEvent>) {
|
|
237
|
-
sawOutput = true;
|
|
238
|
-
reportSession(event.type === "thread.started" ? event.thread_id : live.thread.id);
|
|
239
|
-
yield event as RawAgentEvent;
|
|
240
|
-
}
|
|
241
|
-
reportSession(live.thread.id);
|
|
242
|
-
return;
|
|
243
|
-
} catch (error) {
|
|
244
|
-
// Only a proven missing rollout is safe to retry without its history.
|
|
245
|
-
// Temporary failures and cancellation must retain a valid native id.
|
|
246
|
-
const missingId =
|
|
247
|
-
error instanceof Error
|
|
248
|
-
? /no rollout found for thread id ([^\s]+)/.exec(error.message)?.[1]
|
|
249
|
-
: undefined;
|
|
250
|
-
if (
|
|
251
|
-
attempt !== 0 ||
|
|
252
|
-
!resumeThreadId ||
|
|
253
|
-
missingId !== resumeThreadId ||
|
|
254
|
-
sawOutput ||
|
|
255
|
-
controller.signal.aborted ||
|
|
256
|
-
isCancellation(error)
|
|
257
|
-
) {
|
|
258
|
-
throw error;
|
|
259
|
-
}
|
|
212
|
+
controller.signal.throwIfAborted();
|
|
213
|
+
const live = await this.resolveThread(options, controller.signal);
|
|
214
|
+
controller.signal.throwIfAborted();
|
|
215
|
+
let reported = false;
|
|
216
|
+
const { events } = await live.thread.runStreamed(toCodexInput(input), {
|
|
217
|
+
signal: controller.signal,
|
|
218
|
+
});
|
|
219
|
+
for await (const event of events as AsyncGenerator<ThreadEvent>) {
|
|
220
|
+
if (event.type === "thread.started") {
|
|
221
|
+
if (options.resumeSessionId && event.thread_id !== options.resumeSessionId) {
|
|
222
|
+
// The SDK mutates Thread.id before yielding this event. Discard
|
|
223
|
+
// that wrapper; the retained resume target remains unchanged.
|
|
260
224
|
if (this.threads.get(options.sessionId) === live) this.threads.delete(options.sessionId);
|
|
225
|
+
controller.abort();
|
|
226
|
+
throw new SessionResumeError(
|
|
227
|
+
options.resumeSessionId,
|
|
228
|
+
`Codex returned a different thread: ${event.thread_id}`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (!reported) {
|
|
232
|
+
reported = true;
|
|
233
|
+
options.onNativeSession?.(event.thread_id, {
|
|
234
|
+
resumed: Boolean(options.resumeSessionId),
|
|
235
|
+
});
|
|
261
236
|
}
|
|
262
237
|
}
|
|
263
|
-
|
|
264
|
-
this.endTurn(options.sessionId, controller);
|
|
238
|
+
yield event as RawAgentEvent;
|
|
265
239
|
}
|
|
266
240
|
}
|
|
267
241
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { McpServerConfig } from "../../protocol/index.ts";
|
|
2
|
+
|
|
3
|
+
/** A turn owns its wire configuration even when the caller reuses its objects. */
|
|
4
|
+
export function snapshotMcpServers(
|
|
5
|
+
servers: Record<string, McpServerConfig> | undefined,
|
|
6
|
+
): Record<string, McpServerConfig> {
|
|
7
|
+
return structuredClone(servers ?? {});
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function mcpConnectionErrors(
|
|
11
|
+
required: string[],
|
|
12
|
+
statuses: Array<{ name: string; status: string | null; error?: string }>,
|
|
13
|
+
): Record<string, string> {
|
|
14
|
+
return Object.fromEntries(
|
|
15
|
+
required.flatMap((name) => {
|
|
16
|
+
const status = statuses.find((entry) => entry.name === name);
|
|
17
|
+
return status?.status === "connected"
|
|
18
|
+
? []
|
|
19
|
+
: [[name, status?.error ?? `server is ${status?.status ?? "missing"}`]];
|
|
20
|
+
}),
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function assertMcpConnected(errors: Record<string, string>): void {
|
|
25
|
+
const failed = Object.entries(errors);
|
|
26
|
+
if (failed.length) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`mcp servers failed to connect: ${failed.map(([name, error]) => `${name} (${error})`).join(", ")}`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|