@zvada/agent-server 0.3.1 → 0.3.3
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/AGENTS.md +34 -0
- package/CHANGELOG.md +11 -0
- package/README.md +14 -3
- package/docs/consuming.md +13 -0
- package/docs/deploy.md +21 -2
- package/docs/harnesses.md +43 -0
- package/package.json +2 -2
- package/src/client/transports.ts +6 -1
- package/src/core/agents/acp/acp-agent.ts +57 -24
- package/src/core/agents/acp/adapter.ts +4 -0
- package/src/core/agents/claude-code/claude-agent.ts +24 -4
- package/src/core/agents/claude-code/generator-session.ts +1 -1
- package/src/core/agents/claude-code/session-manager.ts +15 -4
- package/src/core/agents/codex-app-server/adapter.ts +8 -5
- package/src/core/agents/codex-app-server/client.ts +33 -5
- package/src/core/agents/codex-app-server/codex-app-server-agent.ts +256 -74
- package/src/core/agents/codex-sdk/adapter.ts +8 -8
- package/src/core/agents/codex-sdk/codex-sdk-agent.ts +71 -29
- package/src/core/agents/session-store.ts +35 -8
- package/src/core/index.ts +3 -0
- package/src/core/presets.ts +7 -2
- package/src/core/provision/provisioner.ts +140 -55
- package/src/core/runtime/event-processor.ts +6 -1
- package/src/protocol/reduce.ts +5 -1
- package/src/protocol/verify.ts +36 -0
- package/src/protocol/wire.ts +11 -5
- package/src/server/acp/translate.ts +32 -22
- package/src/server/agent-server.ts +23 -5
- package/src/server/bin.ts +40 -5
- package/src/server/feedback.ts +76 -0
|
@@ -39,11 +39,19 @@ export interface AppServerSession {
|
|
|
39
39
|
sandbox: string;
|
|
40
40
|
systemPromptAppend?: string;
|
|
41
41
|
envFingerprint: string;
|
|
42
|
-
/**
|
|
43
|
-
|
|
42
|
+
/** Native CLI auth and externally managed tokens cannot share a warm process. */
|
|
43
|
+
authMode?: CodexAppServerAuth["type"];
|
|
44
|
+
/** Credentials and permission broker belong only to the current execute. */
|
|
45
|
+
turn?: AppServerTurn;
|
|
44
46
|
idleTimer?: ReturnType<typeof setTimeout>;
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
interface AppServerTurn {
|
|
50
|
+
auth?: CodexAppServerAuth;
|
|
51
|
+
permissionHandler?: PermissionRequestHandler;
|
|
52
|
+
signal: AbortSignal;
|
|
53
|
+
}
|
|
54
|
+
|
|
47
55
|
export function appServerSessionCompatible(
|
|
48
56
|
existing: AppServerSession | undefined,
|
|
49
57
|
options: AgentExecuteOptions,
|
|
@@ -145,17 +153,42 @@ const IDLE_TIMEOUT_MS = 5 * 60_000;
|
|
|
145
153
|
/** How long a retryable error may go unanswered before the turn is failed. */
|
|
146
154
|
const RETRY_STALL_TIMEOUT_MS = 120_000;
|
|
147
155
|
|
|
156
|
+
/** Access credentials for Codex's experimental, externally managed ChatGPT auth mode. */
|
|
157
|
+
export interface CodexChatGptAuthTokens {
|
|
158
|
+
accessToken: string;
|
|
159
|
+
chatgptAccountId: string;
|
|
160
|
+
chatgptPlanType?: string | null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The host owns login, durable token storage, refresh serialization, and revocation. */
|
|
164
|
+
export interface CodexChatGptAuth {
|
|
165
|
+
type: "chatgptAuthTokens";
|
|
166
|
+
tokens: CodexChatGptAuthTokens;
|
|
167
|
+
refresh(previousAccountId: string): Promise<CodexChatGptAuthTokens>;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export type CodexAppServerAuth = { type: "apiKey"; apiKey: string } | CodexChatGptAuth;
|
|
171
|
+
|
|
148
172
|
export interface CodexAppServerAgentOptions {
|
|
149
173
|
createClient?: (options: CodexAppServerClientOptions) => CodexAppServerClient;
|
|
150
174
|
idleTimeoutMs?: number;
|
|
151
175
|
/** Fail a turn if the app-server goes silent this long after a retryable error. */
|
|
152
176
|
retryStallTimeoutMs?: number;
|
|
177
|
+
/** Deadline for interrupt acknowledgement and the cancelled turn to drain. */
|
|
178
|
+
cancelGraceMs?: number;
|
|
153
179
|
/**
|
|
154
180
|
* Resolve the `codex` binary to spawn (typically the provisioner's,
|
|
155
181
|
* memoized). Falls back to `$CODEX_CLI_PATH`, then PATH. Operator-level
|
|
156
182
|
* only — never sourced from the wire.
|
|
157
183
|
*/
|
|
158
184
|
resolveCliPath?: () => Promise<string | undefined>;
|
|
185
|
+
/**
|
|
186
|
+
* Resolve the selected API key or ChatGPT account before EVERY turn, including warm
|
|
187
|
+
* sessions. Operator-only: neither callbacks nor tokens belong in RunConfig.
|
|
188
|
+
* Return undefined to select native CLI auth; throw when the selected external
|
|
189
|
+
* account is unavailable. Switching back to native auth recreates the process.
|
|
190
|
+
*/
|
|
191
|
+
resolveAuth?: (sessionId: string) => Promise<CodexAppServerAuth | undefined>;
|
|
159
192
|
}
|
|
160
193
|
|
|
161
194
|
/** Retryable v2 `error` notifications do not end the turn; Codex keeps going. */
|
|
@@ -176,12 +209,22 @@ export class CodexAppServerAgent extends BaseAgent {
|
|
|
176
209
|
readonly harness = "codex-app-server" as const;
|
|
177
210
|
readonly capabilities = CAPABILITIES;
|
|
178
211
|
private readonly sessions = new SessionStore<AppServerSession>();
|
|
212
|
+
/** A cancel acknowledgement belongs to one invocation, even after it drains. */
|
|
213
|
+
private readonly turnInterruptState = new WeakMap<
|
|
214
|
+
AbortController,
|
|
215
|
+
{ acknowledgement?: Promise<boolean> }
|
|
216
|
+
>();
|
|
179
217
|
|
|
180
218
|
constructor(private readonly agentOptions: CodexAppServerAgentOptions = {}) {
|
|
181
219
|
super();
|
|
182
220
|
}
|
|
183
221
|
|
|
184
|
-
private async resolveSession(
|
|
222
|
+
private async resolveSession(
|
|
223
|
+
options: AgentExecuteOptions,
|
|
224
|
+
turn: AppServerTurn,
|
|
225
|
+
): Promise<AppServerSession> {
|
|
226
|
+
const { signal } = turn;
|
|
227
|
+
signal.throwIfAborted();
|
|
185
228
|
const approvalPolicy = approvalPolicyFor(options);
|
|
186
229
|
const sandbox = mapSandbox(options.permissionMode);
|
|
187
230
|
const existing = this.sessions.get(options.sessionId);
|
|
@@ -190,9 +233,21 @@ export class CodexAppServerAgent extends BaseAgent {
|
|
|
190
233
|
// survives. `resumeThreadId` carries that over to the spawn below.
|
|
191
234
|
let resumeThreadId = options.resumeSessionId;
|
|
192
235
|
if (existing && !existing.client.closed) {
|
|
193
|
-
if (appServerSessionCompatible(existing, options)) {
|
|
236
|
+
if (appServerSessionCompatible(existing, options) && existing.authMode === turn.auth?.type) {
|
|
194
237
|
this.sessions.clearIdle(existing);
|
|
195
|
-
|
|
238
|
+
existing.turn = turn;
|
|
239
|
+
const onAbort = () => existing.client.close();
|
|
240
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
241
|
+
try {
|
|
242
|
+
await this.authenticate(existing);
|
|
243
|
+
return existing;
|
|
244
|
+
} catch (error) {
|
|
245
|
+
existing.turn = undefined;
|
|
246
|
+
existing.client.close();
|
|
247
|
+
throw error;
|
|
248
|
+
} finally {
|
|
249
|
+
signal.removeEventListener("abort", onAbort);
|
|
250
|
+
}
|
|
196
251
|
}
|
|
197
252
|
// Preserve context within the same cwd/env boundary (mirrors the
|
|
198
253
|
// claude-code restart policy); an explicit resumeSessionId always wins.
|
|
@@ -211,63 +266,134 @@ export class CodexAppServerAgent extends BaseAgent {
|
|
|
211
266
|
(await this.agentOptions.resolveCliPath?.()) ?? process.env.CODEX_CLI_PATH ?? "codex",
|
|
212
267
|
cwd: options.cwd,
|
|
213
268
|
env: options.env ? ({ ...process.env, ...options.env } as NodeJS.ProcessEnv) : process.env,
|
|
269
|
+
...(turn.auth?.type === "chatgptAuthTokens" && { experimentalApi: true }),
|
|
214
270
|
};
|
|
271
|
+
signal.throwIfAborted();
|
|
215
272
|
const client =
|
|
216
273
|
this.agentOptions.createClient?.(clientOptions) ?? new CodexAppServerClient(clientOptions);
|
|
217
274
|
// Self-heal: if the subprocess ever exits, drop it so the next turn respawns.
|
|
218
275
|
client.onClose(() => this.sessions.dropIfCurrent(options.sessionId, client));
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
cwd: options.cwd,
|
|
223
|
-
sandbox,
|
|
224
|
-
approvalPolicy,
|
|
225
|
-
experimentalRawEvents: false,
|
|
226
|
-
persistExtendedHistory: false,
|
|
227
|
-
};
|
|
228
|
-
if (options.model) startParams.model = options.model;
|
|
229
|
-
if (options.systemPromptAppend) startParams.developerInstructions = options.systemPromptAppend;
|
|
230
|
-
|
|
231
|
-
// A dead/unknown resume target ("no rollout found ...") must not kill the
|
|
232
|
-
// turn: ANY resume failure falls back to a fresh thread, and the fresh id
|
|
233
|
-
// ≠ requested id makes execute() report resumed:false — the structured
|
|
234
|
-
// fallback signal (claude/acp parity). Systemic failures (auth, dead
|
|
235
|
-
// subprocess) fail thread/start identically, so nothing is masked.
|
|
236
|
-
const startThread = async (): Promise<string> => {
|
|
237
|
-
const r = (await client.request("thread/start", startParams)) as {
|
|
238
|
-
thread?: { id?: string };
|
|
239
|
-
};
|
|
240
|
-
if (!r?.thread?.id) throw new Error("codex app-server thread/start returned no thread id");
|
|
241
|
-
return r.thread.id;
|
|
242
|
-
};
|
|
243
|
-
let threadId: string | undefined;
|
|
244
|
-
if (resumeThreadId) {
|
|
245
|
-
threadId = await client
|
|
246
|
-
.request("thread/resume", { threadId: resumeThreadId, ...startParams })
|
|
247
|
-
.then((r) => (r as { thread?: { id?: string } })?.thread?.id ?? resumeThreadId)
|
|
248
|
-
.catch(() => undefined);
|
|
249
|
-
}
|
|
250
|
-
threadId ??= await startThread();
|
|
251
|
-
|
|
276
|
+
const untrack = this.sessions.trackPending(options.sessionId, client);
|
|
277
|
+
const onAbort = () => client.close();
|
|
278
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
252
279
|
const session: AppServerSession = {
|
|
253
280
|
client,
|
|
254
|
-
threadId,
|
|
281
|
+
threadId: "",
|
|
255
282
|
cwd: options.cwd,
|
|
256
283
|
approvalPolicy,
|
|
257
284
|
sandbox,
|
|
258
285
|
systemPromptAppend: options.systemPromptAppend,
|
|
259
286
|
envFingerprint: configFingerprint(options.env),
|
|
287
|
+
authMode: turn.auth?.type,
|
|
288
|
+
turn,
|
|
260
289
|
};
|
|
290
|
+
this.bindRequests(session);
|
|
291
|
+
try {
|
|
292
|
+
signal.throwIfAborted();
|
|
293
|
+
await client.initialize();
|
|
294
|
+
await this.authenticate(session);
|
|
295
|
+
|
|
296
|
+
const startParams: Record<string, unknown> = {
|
|
297
|
+
cwd: options.cwd,
|
|
298
|
+
sandbox,
|
|
299
|
+
approvalPolicy,
|
|
300
|
+
experimentalRawEvents: false,
|
|
301
|
+
persistExtendedHistory: false,
|
|
302
|
+
};
|
|
303
|
+
if (options.model) startParams.model = options.model;
|
|
304
|
+
if (options.systemPromptAppend)
|
|
305
|
+
startParams.developerInstructions = options.systemPromptAppend;
|
|
306
|
+
|
|
307
|
+
// A dead/unknown resume target falls back to a fresh thread. Auth was
|
|
308
|
+
// established above, outside this fallback, so a login failure cannot
|
|
309
|
+
// silently start a different conversation or use ambient credentials.
|
|
310
|
+
let threadId: string | undefined;
|
|
311
|
+
if (resumeThreadId) {
|
|
312
|
+
threadId = await client
|
|
313
|
+
.request("thread/resume", { threadId: resumeThreadId, ...startParams })
|
|
314
|
+
.then((r) => (r as { thread?: { id?: string } })?.thread?.id ?? resumeThreadId)
|
|
315
|
+
.catch(() => undefined);
|
|
316
|
+
}
|
|
317
|
+
signal.throwIfAborted();
|
|
318
|
+
if (!threadId) {
|
|
319
|
+
const result = (await client.request("thread/start", startParams)) as {
|
|
320
|
+
thread?: { id?: string };
|
|
321
|
+
};
|
|
322
|
+
threadId = result?.thread?.id;
|
|
323
|
+
if (!threadId) throw new Error("codex app-server thread/start returned no thread id");
|
|
324
|
+
}
|
|
325
|
+
signal.throwIfAborted();
|
|
326
|
+
session.threadId = threadId;
|
|
327
|
+
this.sessions.set(options.sessionId, session);
|
|
328
|
+
return session;
|
|
329
|
+
} catch (error) {
|
|
330
|
+
session.turn = undefined;
|
|
331
|
+
client.setRequestHandler(undefined);
|
|
332
|
+
client.close();
|
|
333
|
+
throw error;
|
|
334
|
+
} finally {
|
|
335
|
+
signal.removeEventListener("abort", onAbort);
|
|
336
|
+
untrack();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private async authenticate(session: AppServerSession): Promise<void> {
|
|
341
|
+
session.turn?.signal.throwIfAborted();
|
|
342
|
+
const auth = session.turn?.auth;
|
|
343
|
+
if (auth) {
|
|
344
|
+
await session.client.request(
|
|
345
|
+
"account/login/start",
|
|
346
|
+
auth.type === "apiKey"
|
|
347
|
+
? { type: "apiKey", apiKey: auth.apiKey }
|
|
348
|
+
: {
|
|
349
|
+
type: "chatgptAuthTokens",
|
|
350
|
+
accessToken: auth.tokens.accessToken,
|
|
351
|
+
chatgptAccountId: auth.tokens.chatgptAccountId,
|
|
352
|
+
chatgptPlanType: auth.tokens.chatgptPlanType ?? null,
|
|
353
|
+
},
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
session.turn?.signal.throwIfAborted();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private bindRequests(session: AppServerSession): void {
|
|
261
360
|
// Approval requests (server→client) resolve against the engine broker of
|
|
262
361
|
// whichever turn is current; thread mismatches and broker-less turns decline.
|
|
263
|
-
client.setRequestHandler(async (method, params) => {
|
|
362
|
+
session.client.setRequestHandler(async (method, params) => {
|
|
363
|
+
const turn = session.turn;
|
|
364
|
+
if (method === "account/chatgptAuthTokens/refresh") {
|
|
365
|
+
if (
|
|
366
|
+
turn?.auth?.type !== "chatgptAuthTokens" ||
|
|
367
|
+
turn.signal.aborted ||
|
|
368
|
+
session.client.closed
|
|
369
|
+
) {
|
|
370
|
+
throw new Error("Codex ChatGPT refresh has no active authenticated turn");
|
|
371
|
+
}
|
|
372
|
+
const accountId = turn.auth.tokens.chatgptAccountId;
|
|
373
|
+
if (params.previousAccountId != null && params.previousAccountId !== accountId) {
|
|
374
|
+
throw new Error("Codex ChatGPT refresh account does not match the selected account");
|
|
375
|
+
}
|
|
376
|
+
const tokens = await turn.auth.refresh(accountId);
|
|
377
|
+
if (session.turn !== turn || turn.signal.aborted || session.client.closed) {
|
|
378
|
+
throw new Error("Codex ChatGPT refresh outlived its turn");
|
|
379
|
+
}
|
|
380
|
+
if (tokens.chatgptAccountId !== accountId) {
|
|
381
|
+
throw new Error("Codex ChatGPT refresh returned a different account");
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
accessToken: tokens.accessToken,
|
|
385
|
+
chatgptAccountId: tokens.chatgptAccountId,
|
|
386
|
+
chatgptPlanType: tokens.chatgptPlanType ?? null,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
264
389
|
const requestThread = params.threadId ?? params.conversationId;
|
|
265
390
|
const v2 = method.startsWith("item/");
|
|
266
391
|
const decline = v2 ? { decision: "decline" } : { decision: "denied" };
|
|
267
392
|
if (requestThread && requestThread !== session.threadId) return decline;
|
|
268
|
-
const handler =
|
|
269
|
-
if (!handler) return decline;
|
|
393
|
+
const handler = turn?.permissionHandler;
|
|
394
|
+
if (!handler || turn?.signal.aborted) return decline;
|
|
270
395
|
const decision = await handler(approvalToolCall(method, params));
|
|
396
|
+
if (session.turn !== turn || turn?.signal.aborted) return decline;
|
|
271
397
|
// Codex's approval reply is a bare verdict — no slot for
|
|
272
398
|
// `decision.updatedInput`, so an edited input cannot be honoured here
|
|
273
399
|
// (only claude-code's `canUseTool` can execute the edit). An edited
|
|
@@ -283,8 +409,6 @@ export class CodexAppServerAgent extends BaseAgent {
|
|
|
283
409
|
return decline;
|
|
284
410
|
}
|
|
285
411
|
});
|
|
286
|
-
this.sessions.set(options.sessionId, session);
|
|
287
|
-
return session;
|
|
288
412
|
}
|
|
289
413
|
|
|
290
414
|
async *execute(
|
|
@@ -292,19 +416,31 @@ export class CodexAppServerAgent extends BaseAgent {
|
|
|
292
416
|
options: AgentExecuteOptions,
|
|
293
417
|
): AsyncIterableIterator<RawAgentEvent> {
|
|
294
418
|
const controller = this.trackTurn(options.sessionId, options.signal);
|
|
419
|
+
const turn: AppServerTurn = {
|
|
420
|
+
permissionHandler: options.onPermissionRequest,
|
|
421
|
+
signal: controller.signal,
|
|
422
|
+
};
|
|
423
|
+
const interruptState: { acknowledgement?: Promise<boolean> } = {};
|
|
424
|
+
this.turnInterruptState.set(controller, interruptState);
|
|
295
425
|
const queue = new AsyncQueue<CodexNotification>();
|
|
296
426
|
// A retryable error promises more notifications. If none arrive, the
|
|
297
427
|
// subprocess is wedged-but-alive and the turn would hang forever — bound it.
|
|
298
428
|
let stallTimer: ReturnType<typeof setTimeout> | undefined;
|
|
429
|
+
let cancelTimer: ReturnType<typeof setTimeout> | undefined;
|
|
299
430
|
const clearStall = () => {
|
|
300
431
|
if (stallTimer) clearTimeout(stallTimer);
|
|
301
432
|
stallTimer = undefined;
|
|
302
433
|
};
|
|
303
434
|
const unsubscribe: Array<() => void> = [];
|
|
304
435
|
let session: AppServerSession | undefined;
|
|
436
|
+
let turnEnded = false;
|
|
305
437
|
try {
|
|
306
|
-
|
|
307
|
-
|
|
438
|
+
controller.signal.throwIfAborted();
|
|
439
|
+
if (this.agentOptions.resolveAuth) {
|
|
440
|
+
turn.auth = await this.agentOptions.resolveAuth(options.sessionId);
|
|
441
|
+
}
|
|
442
|
+
controller.signal.throwIfAborted();
|
|
443
|
+
session = await this.resolveSession(options, turn);
|
|
308
444
|
options.onNativeSession?.(session.threadId, {
|
|
309
445
|
resumed: session.threadId === options.resumeSessionId,
|
|
310
446
|
});
|
|
@@ -312,11 +448,31 @@ export class CodexAppServerAgent extends BaseAgent {
|
|
|
312
448
|
const client = session.client;
|
|
313
449
|
|
|
314
450
|
let turnId: string | undefined;
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
451
|
+
const forceCancel = () => {
|
|
452
|
+
if (turnEnded) return;
|
|
453
|
+
queue.fail(new Error("turn aborted"));
|
|
454
|
+
client.close();
|
|
455
|
+
};
|
|
456
|
+
const onAbort = () => {
|
|
457
|
+
clearStall();
|
|
458
|
+
if (interruptState.acknowledgement) return;
|
|
459
|
+
if (!turnId) {
|
|
460
|
+
interruptState.acknowledgement = Promise.resolve(false);
|
|
461
|
+
forceCancel();
|
|
462
|
+
return;
|
|
319
463
|
}
|
|
464
|
+
const graceMs = this.agentOptions.cancelGraceMs ?? 2_000;
|
|
465
|
+
cancelTimer = setTimeout(forceCancel, graceMs);
|
|
466
|
+
cancelTimer.unref?.();
|
|
467
|
+
interruptState.acknowledgement = client
|
|
468
|
+
.request("turn/interrupt", { threadId, turnId }, graceMs)
|
|
469
|
+
.then(
|
|
470
|
+
() => true,
|
|
471
|
+
() => {
|
|
472
|
+
forceCancel();
|
|
473
|
+
return false;
|
|
474
|
+
},
|
|
475
|
+
);
|
|
320
476
|
};
|
|
321
477
|
|
|
322
478
|
unsubscribe.push(
|
|
@@ -326,17 +482,21 @@ export class CodexAppServerAgent extends BaseAgent {
|
|
|
326
482
|
const turn = n.params.turn as { id?: string } | undefined;
|
|
327
483
|
if (turn?.id) {
|
|
328
484
|
turnId = turn.id;
|
|
329
|
-
if (aborted) interrupt(); // abort arrived before the turn id was known
|
|
330
485
|
}
|
|
331
486
|
}
|
|
487
|
+
if (appServerNotificationEndsTurn(n)) turnEnded = true;
|
|
488
|
+
if (turnEnded && controller.signal.aborted) {
|
|
489
|
+
queue.fail(new Error("turn aborted"));
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
332
492
|
queue.push(n);
|
|
333
493
|
if (appServerNotificationEndsTurn(n)) queue.end();
|
|
334
|
-
if (n.method === "error" && n.params.willRetry === true) {
|
|
494
|
+
if (!controller.signal.aborted && n.method === "error" && n.params.willRetry === true) {
|
|
335
495
|
clearStall();
|
|
336
|
-
stallTimer = setTimeout(
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
);
|
|
496
|
+
stallTimer = setTimeout(() => {
|
|
497
|
+
queue.fail(new Error("codex app-server went silent after a retryable error"));
|
|
498
|
+
client.close();
|
|
499
|
+
}, this.agentOptions.retryStallTimeoutMs ?? RETRY_STALL_TIMEOUT_MS);
|
|
340
500
|
stallTimer.unref?.();
|
|
341
501
|
} else {
|
|
342
502
|
clearStall();
|
|
@@ -345,19 +505,17 @@ export class CodexAppServerAgent extends BaseAgent {
|
|
|
345
505
|
);
|
|
346
506
|
// If the subprocess dies mid-turn, fail the turn instead of hanging.
|
|
347
507
|
unsubscribe.push(
|
|
348
|
-
client.onClose((err) =>
|
|
508
|
+
client.onClose((err) =>
|
|
509
|
+
queue.fail(
|
|
510
|
+
controller.signal.aborted
|
|
511
|
+
? new Error("turn aborted")
|
|
512
|
+
: (err ?? new Error("codex app-server exited mid-turn")),
|
|
513
|
+
),
|
|
514
|
+
),
|
|
349
515
|
);
|
|
350
516
|
|
|
351
|
-
controller.signal.addEventListener(
|
|
352
|
-
|
|
353
|
-
() => {
|
|
354
|
-
aborted = true;
|
|
355
|
-
interrupt();
|
|
356
|
-
// Surface as a cancellation: the runtime classifies this as aborted.
|
|
357
|
-
queue.fail(new Error("turn aborted"));
|
|
358
|
-
},
|
|
359
|
-
{ once: true },
|
|
360
|
-
);
|
|
517
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
518
|
+
unsubscribe.push(() => controller.signal.removeEventListener("abort", onAbort));
|
|
361
519
|
|
|
362
520
|
const turnParams: Record<string, unknown> = {
|
|
363
521
|
threadId,
|
|
@@ -366,7 +524,12 @@ export class CodexAppServerAgent extends BaseAgent {
|
|
|
366
524
|
if (options.model) turnParams.model = options.model;
|
|
367
525
|
if (options.thinkingLevel) turnParams.effort = codexReasoningEffort(options.thinkingLevel);
|
|
368
526
|
|
|
527
|
+
if (controller.signal.aborted) throw new Error("turn aborted");
|
|
369
528
|
client.request("turn/start", turnParams).catch((err: unknown) => {
|
|
529
|
+
if (controller.signal.aborted) {
|
|
530
|
+
forceCancel();
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
370
533
|
queue.push({ method: "error", params: { message: String(err) } });
|
|
371
534
|
queue.end();
|
|
372
535
|
});
|
|
@@ -374,22 +537,41 @@ export class CodexAppServerAgent extends BaseAgent {
|
|
|
374
537
|
for await (const notification of queue) {
|
|
375
538
|
yield notification as RawAgentEvent;
|
|
376
539
|
}
|
|
540
|
+
} catch (error) {
|
|
541
|
+
throw controller.signal.aborted ? new Error("turn aborted") : error;
|
|
377
542
|
} finally {
|
|
543
|
+
turnEnded = true;
|
|
378
544
|
clearStall();
|
|
545
|
+
if (cancelTimer) clearTimeout(cancelTimer);
|
|
379
546
|
for (const off of unsubscribe) off();
|
|
380
547
|
this.endTurn(options.sessionId, controller);
|
|
381
|
-
if (session
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
548
|
+
if (session?.turn === turn) {
|
|
549
|
+
session.turn = undefined;
|
|
550
|
+
if (!session.client.closed) {
|
|
551
|
+
this.sessions.armIdle(
|
|
552
|
+
options.sessionId,
|
|
553
|
+
session,
|
|
554
|
+
this.agentOptions.idleTimeoutMs ?? IDLE_TIMEOUT_MS,
|
|
555
|
+
);
|
|
556
|
+
}
|
|
387
557
|
}
|
|
388
558
|
}
|
|
389
559
|
}
|
|
390
560
|
|
|
391
561
|
override async cancel(sessionId: string): Promise<CancelResult> {
|
|
392
|
-
|
|
562
|
+
// Snapshot the in-flight controllers before super.cancel() aborts them:
|
|
563
|
+
// the abort unblocks execute()'s queue and finally's endTurn drains the
|
|
564
|
+
// set on a later microtask, so reading afterwards would race that drain.
|
|
565
|
+
const controllers = [...(this.inflight.get(sessionId) ?? [])];
|
|
566
|
+
const base = await super.cancel(sessionId);
|
|
567
|
+
if (!base.hadTurn) return base;
|
|
568
|
+
const acknowledgements = await Promise.all(
|
|
569
|
+
controllers.map(
|
|
570
|
+
(controller) => this.turnInterruptState.get(controller)?.acknowledgement ?? false,
|
|
571
|
+
),
|
|
572
|
+
);
|
|
573
|
+
const confirmed = acknowledgements.every(Boolean);
|
|
574
|
+
return { confirmed, hadTurn: true };
|
|
393
575
|
}
|
|
394
576
|
|
|
395
577
|
override async release(sessionId: string): Promise<void> {
|
|
@@ -44,6 +44,7 @@ interface CodexEvent {
|
|
|
44
44
|
usage?: {
|
|
45
45
|
input_tokens?: number;
|
|
46
46
|
cached_input_tokens?: number;
|
|
47
|
+
cache_write_input_tokens?: number;
|
|
47
48
|
output_tokens?: number;
|
|
48
49
|
reasoning_output_tokens?: number;
|
|
49
50
|
};
|
|
@@ -83,18 +84,17 @@ export class CodexSdkTransformer implements EventTransformer<unknown> {
|
|
|
83
84
|
this.finishReason = "completed";
|
|
84
85
|
this.stopReason = "end_turn";
|
|
85
86
|
if (!ev.usage) return [];
|
|
87
|
+
const totalInput = ev.usage.input_tokens ?? 0;
|
|
88
|
+
const cached = ev.usage.cached_input_tokens ?? 0;
|
|
89
|
+
const written = ev.usage.cache_write_input_tokens ?? 0;
|
|
86
90
|
this.usage = {
|
|
87
|
-
|
|
91
|
+
// Input and cache buckets are disjoint in the normalized protocol.
|
|
92
|
+
input: Math.max(totalInput - cached - written, 0),
|
|
88
93
|
output: ev.usage.output_tokens ?? 0,
|
|
89
94
|
reasoning: ev.usage.reasoning_output_tokens ?? 0,
|
|
90
|
-
cache: { read:
|
|
95
|
+
cache: { read: cached, write: written },
|
|
91
96
|
};
|
|
92
|
-
|
|
93
|
-
// context, so it doubles as the context gauge.
|
|
94
|
-
const used =
|
|
95
|
-
(ev.usage.input_tokens ?? 0) +
|
|
96
|
-
(ev.usage.cached_input_tokens ?? 0) +
|
|
97
|
-
(ev.usage.output_tokens ?? 0);
|
|
97
|
+
const used = totalInput + (ev.usage.output_tokens ?? 0);
|
|
98
98
|
return [{ kind: "usage", used }];
|
|
99
99
|
}
|
|
100
100
|
case "turn.failed":
|
|
@@ -1,6 +1,6 @@
|
|
|
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 } from "../../../protocol/index.ts";
|
|
3
|
+
import { codexReasoningEffort, isCancellation } from "../../../protocol/index.ts";
|
|
4
4
|
import type { AgentExecuteOptions, RawAgentEvent } from "../base.ts";
|
|
5
5
|
import { BaseAgent } from "../base.ts";
|
|
6
6
|
import { configFingerprint } from "../config-fingerprint.ts";
|
|
@@ -19,6 +19,7 @@ const CAPABILITIES: AgentCapabilities = {
|
|
|
19
19
|
|
|
20
20
|
export interface LiveThread {
|
|
21
21
|
thread: Thread;
|
|
22
|
+
codexPathOverride?: string;
|
|
22
23
|
model?: string;
|
|
23
24
|
thinkingLevel?: string;
|
|
24
25
|
cwd: string;
|
|
@@ -49,8 +50,10 @@ function directoriesFingerprint(
|
|
|
49
50
|
*/
|
|
50
51
|
function instanceFingerprint(
|
|
51
52
|
options: Pick<AgentExecuteOptions, "apiKey" | "env" | "systemPromptAppend">,
|
|
53
|
+
codexPathOverride: string | undefined,
|
|
52
54
|
): string {
|
|
53
55
|
return configFingerprint({
|
|
56
|
+
codexPathOverride,
|
|
54
57
|
apiKey: options.apiKey,
|
|
55
58
|
env: options.env,
|
|
56
59
|
// One equality convention with codexThreadCompatible: absent == empty.
|
|
@@ -115,9 +118,9 @@ function threadOptions(options: AgentExecuteOptions): ThreadOptions {
|
|
|
115
118
|
|
|
116
119
|
export interface CodexSdkAgentOptions {
|
|
117
120
|
/**
|
|
118
|
-
* Resolve the `codex` binary
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
+
* Resolve the `codex` binary before each execution, including warm turns.
|
|
122
|
+
* `undefined` defers to the SDK's own lookup. Operator-level only — never
|
|
123
|
+
* sourced from the wire.
|
|
121
124
|
*/
|
|
122
125
|
resolveCliPath?: () => Promise<string | undefined>;
|
|
123
126
|
}
|
|
@@ -134,7 +137,7 @@ export class CodexSdkAgent extends BaseAgent {
|
|
|
134
137
|
readonly capabilities = CAPABILITIES;
|
|
135
138
|
private readonly threads = new Map<string, LiveThread>();
|
|
136
139
|
// Instance-level config (credentials AND developer_instructions) is fixed
|
|
137
|
-
// at construction, so one instance per distinct (apiKey, env, append) —
|
|
140
|
+
// at construction, so one instance per distinct (path, apiKey, env, append) —
|
|
138
141
|
// keying by a fingerprint prevents one session's key or instructions from
|
|
139
142
|
// bleeding into another's.
|
|
140
143
|
private readonly codexByCreds = new Map<string, Codex>();
|
|
@@ -143,11 +146,13 @@ export class CodexSdkAgent extends BaseAgent {
|
|
|
143
146
|
super();
|
|
144
147
|
}
|
|
145
148
|
|
|
146
|
-
private async getCodex(
|
|
147
|
-
|
|
149
|
+
private async getCodex(
|
|
150
|
+
options: AgentExecuteOptions,
|
|
151
|
+
codexPathOverride: string | undefined,
|
|
152
|
+
): Promise<Codex> {
|
|
153
|
+
const fingerprint = instanceFingerprint(options, codexPathOverride);
|
|
148
154
|
const cached = this.codexByCreds.get(fingerprint);
|
|
149
155
|
if (cached) return cached;
|
|
150
|
-
const codexPathOverride = await this.agentOptions.resolveCliPath?.();
|
|
151
156
|
const { Codex } = await import("@openai/codex-sdk");
|
|
152
157
|
const codex = new Codex({
|
|
153
158
|
...(codexPathOverride ? { codexPathOverride } : {}),
|
|
@@ -161,12 +166,23 @@ export class CodexSdkAgent extends BaseAgent {
|
|
|
161
166
|
return codex;
|
|
162
167
|
}
|
|
163
168
|
|
|
164
|
-
private async resolveThread(
|
|
169
|
+
private async resolveThread(
|
|
170
|
+
options: AgentExecuteOptions,
|
|
171
|
+
signal?: AbortSignal,
|
|
172
|
+
): Promise<LiveThread> {
|
|
173
|
+
// The SDK spawns again on every run, even when the Thread is reused.
|
|
174
|
+
const codexPathOverride = await this.agentOptions.resolveCliPath?.();
|
|
165
175
|
const existing = this.threads.get(options.sessionId);
|
|
166
176
|
const effort = options.thinkingLevel;
|
|
167
|
-
if (
|
|
177
|
+
if (
|
|
178
|
+
existing &&
|
|
179
|
+
existing.codexPathOverride === codexPathOverride &&
|
|
180
|
+
codexThreadCompatible(existing, options)
|
|
181
|
+
)
|
|
182
|
+
return existing;
|
|
168
183
|
|
|
169
|
-
const codex = await this.getCodex(options);
|
|
184
|
+
const codex = await this.getCodex(options, codexPathOverride);
|
|
185
|
+
if (signal?.aborted) throw new Error("turn aborted");
|
|
170
186
|
const fingerprint = credentialsFingerprint(options);
|
|
171
187
|
// Preserve context within the same cwd/credential boundary (mirrors the
|
|
172
188
|
// claude-code restart policy); an explicit resumeSessionId always wins.
|
|
@@ -180,6 +196,7 @@ export class CodexSdkAgent extends BaseAgent {
|
|
|
180
196
|
: codex.startThread(threadOptions(options));
|
|
181
197
|
const live: LiveThread = {
|
|
182
198
|
thread,
|
|
199
|
+
codexPathOverride,
|
|
183
200
|
model: options.model,
|
|
184
201
|
thinkingLevel: effort,
|
|
185
202
|
cwd: options.cwd,
|
|
@@ -198,26 +215,51 @@ export class CodexSdkAgent extends BaseAgent {
|
|
|
198
215
|
): AsyncIterableIterator<RawAgentEvent> {
|
|
199
216
|
const controller = this.trackTurn(options.sessionId, options.signal);
|
|
200
217
|
try {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
218
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
219
|
+
if (controller.signal.aborted) throw new Error("turn aborted");
|
|
220
|
+
const attemptOptions = attempt === 0 ? options : { ...options, resumeSessionId: undefined };
|
|
221
|
+
const live = await this.resolveThread(attemptOptions, controller.signal);
|
|
222
|
+
if (controller.signal.aborted) throw new Error("turn aborted");
|
|
223
|
+
const resumeThreadId = live.thread.id;
|
|
224
|
+
let sawOutput = false;
|
|
225
|
+
let reported = false;
|
|
226
|
+
const reportSession = (id: string | null) => {
|
|
227
|
+
if (!reported && id) {
|
|
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
|
+
}
|
|
260
|
+
if (this.threads.get(options.sessionId) === live) this.threads.delete(options.sessionId);
|
|
215
261
|
}
|
|
216
|
-
yield event as RawAgentEvent;
|
|
217
262
|
}
|
|
218
|
-
// thread.id is populated once the turn has started.
|
|
219
|
-
if (!reported && live.thread.id)
|
|
220
|
-
options.onNativeSession?.(live.thread.id, resumeInfo(live.thread.id));
|
|
221
263
|
} finally {
|
|
222
264
|
this.endTurn(options.sessionId, controller);
|
|
223
265
|
}
|