@zvada/agent-server 0.3.5 → 0.3.7-attribution.1
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 +22 -0
- package/docs/consuming.md +13 -1
- package/package.json +1 -1
- package/src/client/client.ts +111 -24
- package/src/client/index.ts +1 -0
- package/src/core/agents/claude-code/adapter.ts +4 -1
- package/src/core/agents/codex-app-server/adapter.ts +6 -0
- package/src/core/agents/codex-app-server/codex-app-server-agent.ts +10 -0
- package/src/core/agents/types.ts +2 -0
- package/src/core/runtime/agent-runtime.ts +13 -2
- package/src/core/runtime/event-processor.ts +16 -3
- package/src/protocol/execution.ts +16 -0
- package/src/protocol/index.ts +1 -0
- package/src/protocol/lifecycle.ts +3 -0
- package/src/protocol/reduce.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.7-attribution.1
|
|
4
|
+
|
|
5
|
+
- Preserve per-turn harness, configured model and thinking level in lifecycle events, run summaries and the canonical conversation fold.
|
|
6
|
+
- Keep primary model reports separate from requested aliases; collect Claude message models and Codex routing targets without attributing subagent models to the main run.
|
|
7
|
+
- Execution metadata is an explicit allowlist and contains no credentials, environment variables or working-directory paths.
|
|
8
|
+
|
|
9
|
+
## 0.3.7-replay.1
|
|
10
|
+
|
|
11
|
+
- Add `AgentServerClient.onEventGap` for consumers using `startTurn` and `onEvent`.
|
|
12
|
+
Missing replay data and server restarts are reported before surviving events.
|
|
13
|
+
A gap describes incomplete delivery; native execution may still be running.
|
|
14
|
+
- Track turns that disconnect before their first event and keep replay attempts
|
|
15
|
+
bound to the transport that started them. Preserve frames received during a
|
|
16
|
+
reconnect handshake even when they have already left the server's replay window.
|
|
17
|
+
|
|
18
|
+
## 0.3.6
|
|
19
|
+
|
|
20
|
+
- Unsupported Codex app-server requests now receive an explicit protocol error.
|
|
21
|
+
Previously, requests such as MCP elicitations were treated as file-edit
|
|
22
|
+
approvals and could leave a turn waiting in the permission broker. Command
|
|
23
|
+
and file approvals and host-managed credential refresh keep their existing behavior.
|
|
24
|
+
|
|
3
25
|
## 0.3.5
|
|
4
26
|
|
|
5
27
|
- Switching Codex ChatGPT accounts or API keys now recreates the native process
|
package/docs/consuming.md
CHANGED
|
@@ -264,7 +264,19 @@ product that doesn't surface them debugs blind.
|
|
|
264
264
|
and replays by default), `attach` (an accepted socket, e.g. the server dialed
|
|
265
265
|
OUT to you via `--dial`). The client dedupes by per-session `seq` and heals
|
|
266
266
|
gaps via `events/replay`; an unfillable gap raises `EventGapError` instead of
|
|
267
|
-
silently losing events.
|
|
267
|
+
silently losing events. When using `startTurn` with `onEvent`, subscribe to
|
|
268
|
+
`onEventGap` before starting work. It runs synchronously before any surviving
|
|
269
|
+
suffix and returns an unsubscribe function. `runTurn().result` continues to
|
|
270
|
+
reject on these same gaps.
|
|
271
|
+
|
|
272
|
+
A gap describes incomplete delivery, not a successful stop. Keep the affected
|
|
273
|
+
turn's output marked incomplete even if its native terminal says `end_turn`.
|
|
274
|
+
`server_restarted` and `session_missing` mean the old terminal cannot arrive;
|
|
275
|
+
`evicted`, `replay_failed`, and `log_reset` can require a turn-stamped
|
|
276
|
+
`cancelTurn(sessionId, turnId)` to reconcile execution. Never cancel a successor
|
|
277
|
+
by reading its ID after an asynchronous recovery operation.
|
|
278
|
+
|
|
279
|
+
The WebSocket wire itself carries no auth — keep it
|
|
268
280
|
on a trusted channel (localhost, sandbox-internal, or behind your own
|
|
269
281
|
authenticated boundary).
|
|
270
282
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zvada/agent-server",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7-attribution.1",
|
|
4
4
|
"description": "Harness-agnostic agent execution engine: run Claude Code, Codex (SDK/CLI + app-server), and any ACP agent behind one interface with a normalized event stream, multi-turn sessions, and resume. Root export is the wire contract; /core, /server, /client are the seats.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/client/client.ts
CHANGED
|
@@ -62,6 +62,13 @@ export class EventGapError extends Error {
|
|
|
62
62
|
override readonly name = "EventGapError";
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** A delivery failure, reported before surviving events. Execution may continue. */
|
|
66
|
+
export interface SessionEventGap {
|
|
67
|
+
sessionId: string;
|
|
68
|
+
reason: "evicted" | "replay_failed" | "session_missing" | "server_restarted" | "log_reset";
|
|
69
|
+
error: EventGapError;
|
|
70
|
+
}
|
|
71
|
+
|
|
65
72
|
/**
|
|
66
73
|
* A running turn: consume `events` (ends after `turn.ended`) or await `result`.
|
|
67
74
|
*
|
|
@@ -145,11 +152,16 @@ export class AgentServerClient {
|
|
|
145
152
|
private readonly pending = new Map<JsonRpcId, PendingRequest>();
|
|
146
153
|
/** Per-session seq discipline — the shared `/protocol` implementation. */
|
|
147
154
|
private readonly cursor = createSeqCursor();
|
|
148
|
-
private readonly heldBack = new Map<
|
|
155
|
+
private readonly heldBack = new Map<
|
|
156
|
+
string,
|
|
157
|
+
Array<{ envelope: DecodedWireEventEnvelope; transport: WireTransport | null }>
|
|
158
|
+
>();
|
|
149
159
|
/** The server process behind the transport, learned at `initialize`. */
|
|
150
160
|
private serverInstanceId: string | undefined;
|
|
151
|
-
private readonly syncing = new
|
|
161
|
+
private readonly syncing = new Map<string, WireTransport>();
|
|
152
162
|
private readonly eventHandlers = new Set<(envelope: DecodedWireEventEnvelope) => void>();
|
|
163
|
+
private readonly gapHandlers = new Set<(gap: SessionEventGap) => void>();
|
|
164
|
+
private awaitingHandshake = false;
|
|
153
165
|
private readonly turnBySession = new Map<string, InternalTurn>();
|
|
154
166
|
private initializePromise: Promise<InitializeResult> | null = null;
|
|
155
167
|
private closed = false;
|
|
@@ -266,16 +278,26 @@ export class AgentServerClient {
|
|
|
266
278
|
// log will ever emit THEIR turn.ended — an un-failed handle hangs forever
|
|
267
279
|
// (a session id the fresh process happens to reuse would even feed it
|
|
268
280
|
// someone else's events).
|
|
269
|
-
|
|
270
|
-
|
|
281
|
+
const sessionIds = new Set([...this.cursor.sessions(), ...this.turnBySession.keys()]);
|
|
282
|
+
for (const sessionId of sessionIds) {
|
|
283
|
+
this.reportEventGap(
|
|
271
284
|
sessionId,
|
|
285
|
+
"server_restarted",
|
|
272
286
|
new EventGapError(
|
|
273
287
|
`server instance changed; the turn's session log died with the old process`,
|
|
274
288
|
),
|
|
275
289
|
);
|
|
276
290
|
}
|
|
277
291
|
for (const sessionId of this.cursor.sessions()) this.cursor.reset(sessionId);
|
|
278
|
-
|
|
292
|
+
// Keep frames actually received from the fresh process: they may already
|
|
293
|
+
// have left its bounded replay window. Only old transport entries die.
|
|
294
|
+
for (const [sessionId, held] of this.heldBack) {
|
|
295
|
+
const fresh = held.filter((entry) => entry.transport === this.transport);
|
|
296
|
+
if (fresh.length) {
|
|
297
|
+
this.heldBack.set(sessionId, fresh);
|
|
298
|
+
this.cursor.seek(sessionId, 0);
|
|
299
|
+
} else this.heldBack.delete(sessionId);
|
|
300
|
+
}
|
|
279
301
|
}
|
|
280
302
|
|
|
281
303
|
/**
|
|
@@ -317,7 +339,14 @@ export class AgentServerClient {
|
|
|
317
339
|
/** Quick-ack only — observe the turn via `onEvent` / `replay`. */
|
|
318
340
|
async startTurn(params: TurnStartParams): Promise<{ sessionId: string; turnId: string }> {
|
|
319
341
|
await this.initialize();
|
|
320
|
-
|
|
342
|
+
const sessionId = params.sessionId ?? generateUUIDv7();
|
|
343
|
+
const turnId = params.turnId ?? generateUUIDv7();
|
|
344
|
+
this.cursor.seek(sessionId, this.cursor.last(sessionId));
|
|
345
|
+
return this.request(
|
|
346
|
+
WIRE_METHODS.turnStart,
|
|
347
|
+
{ ...params, sessionId, turnId },
|
|
348
|
+
TurnStartResultSchema,
|
|
349
|
+
);
|
|
321
350
|
}
|
|
322
351
|
|
|
323
352
|
/** Answer a `permission.requested` event. */
|
|
@@ -380,6 +409,12 @@ export class AgentServerClient {
|
|
|
380
409
|
return () => this.eventHandlers.delete(handler);
|
|
381
410
|
}
|
|
382
411
|
|
|
412
|
+
/** Observe unrecoverable delivery gaps even when using startTurn without a handle. */
|
|
413
|
+
onEventGap(handler: (gap: SessionEventGap) => void): () => void {
|
|
414
|
+
this.gapHandlers.add(handler);
|
|
415
|
+
return () => this.gapHandlers.delete(handler);
|
|
416
|
+
}
|
|
417
|
+
|
|
383
418
|
/** Ask the server to shut down, then close this client. */
|
|
384
419
|
async shutdownServer(): Promise<void> {
|
|
385
420
|
await this.request(WIRE_METHODS.shutdown, {}, ShutdownResultSchema).catch(() => undefined);
|
|
@@ -406,7 +441,10 @@ export class AgentServerClient {
|
|
|
406
441
|
}
|
|
407
442
|
if (transport.closed) throw new ConnectionClosedError("transport opened already closed");
|
|
408
443
|
this.transport = transport;
|
|
409
|
-
|
|
444
|
+
this.awaitingHandshake = this.reconnecting;
|
|
445
|
+
transport.onLine((line) => {
|
|
446
|
+
if (this.transport === transport) this.handleLine(line);
|
|
447
|
+
});
|
|
410
448
|
transport.onClose((reason) => this.handleTransportClose(transport, reason));
|
|
411
449
|
}
|
|
412
450
|
|
|
@@ -443,10 +481,15 @@ export class AgentServerClient {
|
|
|
443
481
|
this.initializePromise = null;
|
|
444
482
|
await this.initialize();
|
|
445
483
|
if (this.closed || !this.transport) continue;
|
|
484
|
+
this.awaitingHandshake = false;
|
|
446
485
|
// Heal every tracked session — including turns that saw no event
|
|
447
486
|
// before the drop (or none after it): without a resync they would
|
|
448
487
|
// never receive their turn.ended and would hang forever.
|
|
449
|
-
const sessionIds = new Set([
|
|
488
|
+
const sessionIds = new Set([
|
|
489
|
+
...this.cursor.sessions(),
|
|
490
|
+
...this.turnBySession.keys(),
|
|
491
|
+
...this.heldBack.keys(),
|
|
492
|
+
]);
|
|
450
493
|
for (const sessionId of sessionIds) void this.syncSession(sessionId);
|
|
451
494
|
if (!this.transport) continue;
|
|
452
495
|
return;
|
|
@@ -500,7 +543,7 @@ export class AgentServerClient {
|
|
|
500
543
|
const sessionId = envelope.sessionId;
|
|
501
544
|
// While a replay is in flight, every envelope queues: the drain re-runs
|
|
502
545
|
// the cursor over the merged (replayed + live) backlog in seq order.
|
|
503
|
-
if (this.syncing.
|
|
546
|
+
if (this.awaitingHandshake || this.syncing.get(sessionId) === this.transport) {
|
|
504
547
|
this.holdBack(sessionId, envelope);
|
|
505
548
|
return;
|
|
506
549
|
}
|
|
@@ -513,6 +556,11 @@ export class AgentServerClient {
|
|
|
513
556
|
// arm fires only if a fresh log appears with NO transport drop,
|
|
514
557
|
// which no current topology produces. If it does: the queue is
|
|
515
558
|
// dead-log state, the fresh frame is authoritative.
|
|
559
|
+
this.reportEventGap(
|
|
560
|
+
sessionId,
|
|
561
|
+
"log_reset",
|
|
562
|
+
new EventGapError(`session ${sessionId} log reset`),
|
|
563
|
+
);
|
|
516
564
|
this.heldBack.delete(sessionId);
|
|
517
565
|
this.deliver(envelope);
|
|
518
566
|
return;
|
|
@@ -529,16 +577,17 @@ export class AgentServerClient {
|
|
|
529
577
|
|
|
530
578
|
private holdBack(sessionId: string, envelope: DecodedWireEventEnvelope): void {
|
|
531
579
|
const held = this.heldBack.get(sessionId) ?? [];
|
|
532
|
-
held.push(envelope);
|
|
580
|
+
held.push({ envelope, transport: this.transport });
|
|
533
581
|
this.heldBack.set(sessionId, held);
|
|
534
582
|
}
|
|
535
583
|
|
|
536
584
|
private drainHeld(sessionId: string): void {
|
|
585
|
+
if (this.awaitingHandshake) return;
|
|
537
586
|
const held = this.heldBack.get(sessionId);
|
|
538
587
|
if (!held?.length) return;
|
|
539
|
-
held.sort((a, b) => a.seq - b.seq);
|
|
588
|
+
held.sort((a, b) => a.envelope.seq - b.envelope.seq);
|
|
540
589
|
while (held.length) {
|
|
541
|
-
const next = held[0]
|
|
590
|
+
const next = held[0]!.envelope;
|
|
542
591
|
// `gap` leaves the cursor untouched, so breaking here is safe: the
|
|
543
592
|
// envelope stays queued for the replay that fills the hole.
|
|
544
593
|
const verdict = this.cursor.advance(sessionId, next.seq);
|
|
@@ -547,9 +596,15 @@ export class AgentServerClient {
|
|
|
547
596
|
if (verdict === "reset") {
|
|
548
597
|
// Defense-in-depth only (see the reset arm in handleEnvelope):
|
|
549
598
|
// drop the queue, deliver the fresh frame, resync for its tail.
|
|
599
|
+
this.reportEventGap(
|
|
600
|
+
sessionId,
|
|
601
|
+
"log_reset",
|
|
602
|
+
new EventGapError(`session ${sessionId} log reset`),
|
|
603
|
+
);
|
|
550
604
|
held.length = 0;
|
|
551
605
|
this.deliver(next);
|
|
552
|
-
|
|
606
|
+
// Let any replay owning this drain release its transport first.
|
|
607
|
+
queueMicrotask(() => void this.syncSession(sessionId));
|
|
553
608
|
break;
|
|
554
609
|
}
|
|
555
610
|
if (verdict !== "duplicate") this.deliver(next);
|
|
@@ -558,32 +613,48 @@ export class AgentServerClient {
|
|
|
558
613
|
}
|
|
559
614
|
|
|
560
615
|
private async syncSession(sessionId: string): Promise<void> {
|
|
561
|
-
|
|
562
|
-
|
|
616
|
+
const transport = this.transport;
|
|
617
|
+
if (
|
|
618
|
+
!transport ||
|
|
619
|
+
this.awaitingHandshake ||
|
|
620
|
+
this.closed ||
|
|
621
|
+
this.syncing.get(sessionId) === transport
|
|
622
|
+
)
|
|
623
|
+
return;
|
|
624
|
+
this.syncing.set(sessionId, transport);
|
|
625
|
+
const isCurrent = () => !this.closed && !this.awaitingHandshake && this.transport === transport;
|
|
563
626
|
try {
|
|
564
627
|
const maxAttempts = Math.max(1, this.options.maxReplayAttempts ?? 3);
|
|
565
628
|
const retryDelay = this.options.replayRetryDelayMs ?? this.options.reconnectDelayMs ?? 250;
|
|
566
|
-
for (let attempt = 1; attempt <= maxAttempts &&
|
|
629
|
+
for (let attempt = 1; attempt <= maxAttempts && isCurrent(); attempt++) {
|
|
630
|
+
this.drainHeld(sessionId);
|
|
631
|
+
if (!isCurrent()) return;
|
|
567
632
|
const fromSeq = this.cursor.last(sessionId) + 1;
|
|
568
633
|
try {
|
|
569
634
|
const replayed = await this.replay(sessionId, fromSeq);
|
|
570
|
-
if (
|
|
635
|
+
if (!isCurrent()) return;
|
|
636
|
+
for (const envelope of replayed.events) this.holdBack(sessionId, envelope);
|
|
637
|
+
this.drainHeld(sessionId);
|
|
638
|
+
const missingFrom = this.cursor.last(sessionId) + 1;
|
|
639
|
+
if (replayed.firstAvailableSeq !== null && replayed.firstAvailableSeq > missingFrom) {
|
|
571
640
|
// The prefix was evicted — the gap is unfillable. Fail the turn
|
|
572
641
|
// handle (its part reconstruction is broken) and jump forward.
|
|
573
|
-
this.
|
|
642
|
+
this.reportEventGap(
|
|
574
643
|
sessionId,
|
|
644
|
+
"evicted",
|
|
575
645
|
new EventGapError(
|
|
576
|
-
`events ${
|
|
646
|
+
`events ${missingFrom}..${replayed.firstAvailableSeq - 1} for session ${sessionId} were evicted`,
|
|
577
647
|
),
|
|
578
648
|
);
|
|
579
649
|
this.cursor.seek(sessionId, replayed.firstAvailableSeq - 1);
|
|
580
650
|
}
|
|
581
|
-
for (const envelope of replayed.events) this.holdBack(sessionId, envelope);
|
|
582
651
|
break;
|
|
583
652
|
} catch (err) {
|
|
653
|
+
if (!isCurrent()) return;
|
|
584
654
|
if (err instanceof WireRequestError && err.code === WIRE_ERROR_CODES.unknownSession) {
|
|
585
|
-
this.
|
|
655
|
+
this.reportEventGap(
|
|
586
656
|
sessionId,
|
|
657
|
+
"session_missing",
|
|
587
658
|
new EventGapError(`session ${sessionId} is unknown to the server (restarted?)`),
|
|
588
659
|
);
|
|
589
660
|
this.cursor.reset(sessionId);
|
|
@@ -592,8 +663,9 @@ export class AgentServerClient {
|
|
|
592
663
|
}
|
|
593
664
|
if (!this.transport || this.closed) break; // reconnect performs a fresh resync
|
|
594
665
|
if (attempt === maxAttempts) {
|
|
595
|
-
this.
|
|
666
|
+
this.reportEventGap(
|
|
596
667
|
sessionId,
|
|
668
|
+
"replay_failed",
|
|
597
669
|
new EventGapError(
|
|
598
670
|
`failed to replay events from ${fromSeq} for session ${sessionId} after ${maxAttempts} attempts`,
|
|
599
671
|
),
|
|
@@ -605,9 +677,9 @@ export class AgentServerClient {
|
|
|
605
677
|
}
|
|
606
678
|
}
|
|
607
679
|
} finally {
|
|
608
|
-
this.syncing.delete(sessionId);
|
|
680
|
+
if (this.syncing.get(sessionId) === transport) this.syncing.delete(sessionId);
|
|
609
681
|
}
|
|
610
|
-
this.drainHeld(sessionId);
|
|
682
|
+
if (isCurrent()) this.drainHeld(sessionId);
|
|
611
683
|
}
|
|
612
684
|
|
|
613
685
|
/** Dispatch an envelope the cursor already accepted (it owns the watermark). */
|
|
@@ -630,6 +702,21 @@ export class AgentServerClient {
|
|
|
630
702
|
}
|
|
631
703
|
}
|
|
632
704
|
|
|
705
|
+
private reportEventGap(
|
|
706
|
+
sessionId: string,
|
|
707
|
+
reason: SessionEventGap["reason"],
|
|
708
|
+
error: EventGapError,
|
|
709
|
+
): void {
|
|
710
|
+
this.failTurn(sessionId, error);
|
|
711
|
+
for (const handler of [...this.gapHandlers]) {
|
|
712
|
+
try {
|
|
713
|
+
handler({ sessionId, reason, error });
|
|
714
|
+
} catch {
|
|
715
|
+
// A consumer callback cannot interrupt replay or other subscribers.
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
633
720
|
private failTurn(sessionId: string, error: Error): void {
|
|
634
721
|
const turn = this.turnBySession.get(sessionId);
|
|
635
722
|
if (!turn) return;
|
package/src/client/index.ts
CHANGED
|
@@ -79,7 +79,7 @@ type ClaudeMessage =
|
|
|
79
79
|
| { type: "turn_interrupted" }
|
|
80
80
|
| {
|
|
81
81
|
type: "assistant";
|
|
82
|
-
message?: { content?: ContentBlock[]; usage?: RawUsage };
|
|
82
|
+
message?: { content?: ContentBlock[]; usage?: RawUsage; model?: string };
|
|
83
83
|
parent_tool_use_id?: string | null;
|
|
84
84
|
}
|
|
85
85
|
| { type: "user"; message?: { content?: ContentBlock[] | string } }
|
|
@@ -227,6 +227,7 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
|
|
|
227
227
|
private hasStreamed = false;
|
|
228
228
|
|
|
229
229
|
private usage: TokenUsage = { ...DEFAULT_TOKEN_USAGE };
|
|
230
|
+
private readonly reportedModels = new Set<string>();
|
|
230
231
|
private stopReason?: StopReason;
|
|
231
232
|
private finishReason?: string;
|
|
232
233
|
private cost?: number;
|
|
@@ -287,6 +288,7 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
|
|
|
287
288
|
: undefined);
|
|
288
289
|
return {
|
|
289
290
|
usage: this.usage,
|
|
291
|
+
...(this.reportedModels.size && { reportedModels: [...this.reportedModels] }),
|
|
290
292
|
stopReason: this.stopReason,
|
|
291
293
|
finishReason: this.finishReason,
|
|
292
294
|
cost: this.cost,
|
|
@@ -308,6 +310,7 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
|
|
|
308
310
|
* skip the duplicate. Otherwise emit its content blocks as finalized parts.
|
|
309
311
|
*/
|
|
310
312
|
private handleAssistant(msg: Extract<ClaudeMessage, { type: "assistant" }>): AdapterEvent[] {
|
|
313
|
+
if (!msg.parent_tool_use_id && msg.message?.model) this.reportedModels.add(msg.message.model);
|
|
311
314
|
// Context gauge: every top-level model message carries fresh usage (the
|
|
312
315
|
// sub-agent's context is its own and would misreport the session's).
|
|
313
316
|
const gauge: AdapterEvent[] =
|
|
@@ -47,6 +47,7 @@ interface Notification {
|
|
|
47
47
|
error?: { message?: string; additionalDetails?: string | null };
|
|
48
48
|
willRetry?: boolean;
|
|
49
49
|
message?: string;
|
|
50
|
+
toModel?: string;
|
|
50
51
|
};
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -55,6 +56,7 @@ export class CodexAppServerTransformer implements EventTransformer<unknown> {
|
|
|
55
56
|
private readonly partByItem = new Map<string, Part>();
|
|
56
57
|
|
|
57
58
|
private usage: TokenUsage = { ...DEFAULT_TOKEN_USAGE };
|
|
59
|
+
private readonly reportedModels = new Set<string>();
|
|
58
60
|
private stopReason?: StopReason;
|
|
59
61
|
private finishReason?: string;
|
|
60
62
|
private error?: string;
|
|
@@ -66,6 +68,9 @@ export class CodexAppServerTransformer implements EventTransformer<unknown> {
|
|
|
66
68
|
process(raw: unknown): AdapterEvent[] {
|
|
67
69
|
const { method, params } = raw as Notification;
|
|
68
70
|
switch (method) {
|
|
71
|
+
case "model/rerouted":
|
|
72
|
+
if (params.toModel) this.reportedModels.add(params.toModel);
|
|
73
|
+
return [];
|
|
69
74
|
case "item/started":
|
|
70
75
|
return params.item ? this.openItem(params.item) : [];
|
|
71
76
|
case "item/completed":
|
|
@@ -105,6 +110,7 @@ export class CodexAppServerTransformer implements EventTransformer<unknown> {
|
|
|
105
110
|
finish(): TransformResult {
|
|
106
111
|
return {
|
|
107
112
|
usage: this.usage,
|
|
113
|
+
...(this.reportedModels.size && { reportedModels: [...this.reportedModels] }),
|
|
108
114
|
stopReason: this.stopReason,
|
|
109
115
|
finishReason: this.finishReason,
|
|
110
116
|
error: this.error,
|
|
@@ -395,6 +395,16 @@ export class CodexAppServerAgent extends BaseAgent {
|
|
|
395
395
|
chatgptPlanType: tokens.chatgptPlanType ?? null,
|
|
396
396
|
};
|
|
397
397
|
}
|
|
398
|
+
// Elicitations and other server requests have their own response shapes.
|
|
399
|
+
// Sending them to the approval broker can leave a turn waiting forever.
|
|
400
|
+
if (
|
|
401
|
+
method !== "item/commandExecution/requestApproval" &&
|
|
402
|
+
method !== "item/fileChange/requestApproval" &&
|
|
403
|
+
method !== "execCommandApproval" &&
|
|
404
|
+
method !== "applyPatchApproval"
|
|
405
|
+
) {
|
|
406
|
+
throw new Error(`Unsupported Codex server request: ${method}`);
|
|
407
|
+
}
|
|
398
408
|
const requestThread = params.threadId ?? params.conversationId;
|
|
399
409
|
const v2 = method.startsWith("item/");
|
|
400
410
|
const decline = v2 ? { decision: "decline" } : { decision: "denied" };
|
package/src/core/agents/types.ts
CHANGED
|
@@ -32,6 +32,8 @@ export type AdapterEvent =
|
|
|
32
32
|
/** Terminal result of a turn, surfaced on `turn.ended`. */
|
|
33
33
|
export interface TransformResult {
|
|
34
34
|
usage: TokenUsage;
|
|
35
|
+
/** Models observed on the primary run, excluding subagents and configured defaults. */
|
|
36
|
+
reportedModels?: string[];
|
|
35
37
|
/** Normalized terminal status, when the harness reported one. The engine
|
|
36
38
|
* overrides with `cancelled`/`error` based on what it observed. */
|
|
37
39
|
stopReason?: StopReason;
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
RunRequest,
|
|
15
15
|
StopReason,
|
|
16
16
|
TokenUsage,
|
|
17
|
+
TurnExecution,
|
|
17
18
|
} from "../../protocol/index.ts";
|
|
18
19
|
import { DEFAULT_TOKEN_USAGE, generateUUIDv7 } from "../../protocol/index.ts";
|
|
19
20
|
import type { AgentExecuteOptions, CancelResult, PermissionDecision } from "../agents/base.ts";
|
|
@@ -28,6 +29,7 @@ export interface RunSummary {
|
|
|
28
29
|
sessionId: string;
|
|
29
30
|
turnId: string;
|
|
30
31
|
harness: AgentHarness;
|
|
32
|
+
execution: TurnExecution;
|
|
31
33
|
/** Native session/thread id to persist for a future resume. */
|
|
32
34
|
nativeSessionId?: string;
|
|
33
35
|
/** When the turn requested a resume: whether the harness honored it. */
|
|
@@ -239,7 +241,12 @@ export class AgentRuntime {
|
|
|
239
241
|
const { sessionId, turnId, input, config } = request;
|
|
240
242
|
const agent = this.registry.getAgent(config.harness);
|
|
241
243
|
const transformer = this.registry.getAdapter(config.harness)({ sessionId });
|
|
242
|
-
const
|
|
244
|
+
const execution: TurnExecution = {
|
|
245
|
+
harness: config.harness,
|
|
246
|
+
...(config.model !== undefined && { model: config.model }),
|
|
247
|
+
...(config.thinkingLevel !== undefined && { thinkingLevel: config.thinkingLevel }),
|
|
248
|
+
};
|
|
249
|
+
const processor = new EventProcessor(sessionId, turnId, execution);
|
|
243
250
|
|
|
244
251
|
// Isolate sink failures: a misbehaving transport must never break the
|
|
245
252
|
// turn's lifecycle bracketing (turn.started ... turn.ended).
|
|
@@ -374,7 +381,7 @@ export class AgentRuntime {
|
|
|
374
381
|
await Promise.all([...settles, ...settleEmits]);
|
|
375
382
|
};
|
|
376
383
|
|
|
377
|
-
await emit({ type: "turn.started", turnId, sessionId, timestamp: Date.now() });
|
|
384
|
+
await emit({ type: "turn.started", turnId, sessionId, execution, timestamp: Date.now() });
|
|
378
385
|
|
|
379
386
|
// The user echo (spec §7.2): the submitted input goes back onto the stream
|
|
380
387
|
// as a complete user message (outputIndex 0) before any harness output, so
|
|
@@ -474,6 +481,10 @@ export class AgentRuntime {
|
|
|
474
481
|
nativeSessionId,
|
|
475
482
|
...(resumed !== undefined && { resumed }),
|
|
476
483
|
usage: result.usage ?? DEFAULT_TOKEN_USAGE,
|
|
484
|
+
execution: {
|
|
485
|
+
...execution,
|
|
486
|
+
...(result.reportedModels && { reportedModels: result.reportedModels }),
|
|
487
|
+
},
|
|
477
488
|
stopReason,
|
|
478
489
|
finishReason: result.finishReason,
|
|
479
490
|
cost: result.cost,
|
|
@@ -4,7 +4,14 @@ import {
|
|
|
4
4
|
echoMessageId,
|
|
5
5
|
generateUUIDv7,
|
|
6
6
|
} from "../../protocol/index.ts";
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
AgentInput,
|
|
9
|
+
Delta,
|
|
10
|
+
LifecycleEvent,
|
|
11
|
+
Part,
|
|
12
|
+
StopReason,
|
|
13
|
+
TurnExecution,
|
|
14
|
+
} from "../../protocol/index.ts";
|
|
8
15
|
import type { AdapterEvent, TransformResult } from "../agents/types.ts";
|
|
9
16
|
|
|
10
17
|
/** Where a part lives on the wire — fixed at first emission for the whole turn. */
|
|
@@ -36,7 +43,7 @@ export class EventProcessor {
|
|
|
36
43
|
constructor(
|
|
37
44
|
private readonly sessionId: string,
|
|
38
45
|
private readonly turnId: string,
|
|
39
|
-
private readonly
|
|
46
|
+
private readonly execution?: TurnExecution,
|
|
40
47
|
) {}
|
|
41
48
|
|
|
42
49
|
/**
|
|
@@ -125,6 +132,12 @@ export class EventProcessor {
|
|
|
125
132
|
finishReason: result.finishReason,
|
|
126
133
|
tokens: result.usage,
|
|
127
134
|
cost: result.cost,
|
|
135
|
+
...(this.execution && {
|
|
136
|
+
execution: {
|
|
137
|
+
...this.execution,
|
|
138
|
+
...(result.reportedModels && { reportedModels: result.reportedModels }),
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
128
141
|
error:
|
|
129
142
|
result.error && !result.cancelled
|
|
130
143
|
? { category: classifyError(result.error), message: result.error }
|
|
@@ -160,7 +173,7 @@ export class EventProcessor {
|
|
|
160
173
|
// user-authored. Gating here keeps it off every user message; every
|
|
161
174
|
// assistant message (top-level AND parented sub-agent output) keeps it,
|
|
162
175
|
// since `config.model` is the only per-turn model signal available.
|
|
163
|
-
...(role === "assistant" && this.
|
|
176
|
+
...(role === "assistant" && this.execution?.model && { model: this.execution?.model }),
|
|
164
177
|
timestamp: Date.now(),
|
|
165
178
|
};
|
|
166
179
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { AgentHarnessSchema } from "./harness.ts";
|
|
3
|
+
import { ThinkingLevelSchema } from "./thinking.ts";
|
|
4
|
+
|
|
5
|
+
/** Safe, per-turn execution metadata. Never serialize the full RunConfig here. */
|
|
6
|
+
export const TurnExecutionSchema = z.object({
|
|
7
|
+
harness: AgentHarnessSchema,
|
|
8
|
+
/** Model id/alias passed to the harness. Absent means the harness chose its default. */
|
|
9
|
+
model: z.string().optional(),
|
|
10
|
+
/** Requested setting, not a measurement of the model's internal reasoning. */
|
|
11
|
+
thinkingLevel: ThinkingLevelSchema.optional(),
|
|
12
|
+
/** Distinct models reported by the primary harness during this turn, in observation order.
|
|
13
|
+
* Excludes subagents. Absence means unreported, never a fallback to the requested alias. */
|
|
14
|
+
reportedModels: z.array(z.string()).optional(),
|
|
15
|
+
});
|
|
16
|
+
export type TurnExecution = z.infer<typeof TurnExecutionSchema>;
|
package/src/protocol/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ export * from "./harness.ts";
|
|
|
17
17
|
export * from "./thinking.ts";
|
|
18
18
|
export * from "./models.ts";
|
|
19
19
|
export * from "./config.ts";
|
|
20
|
+
export * from "./execution.ts";
|
|
20
21
|
export * from "./lifecycle.ts";
|
|
21
22
|
export * from "./stop-reasons.ts";
|
|
22
23
|
export * from "./seq-cursor.ts";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { ErrorCategorySchema, ErrorInfoSchema } from "./errors.ts";
|
|
3
|
+
import { TurnExecutionSchema } from "./execution.ts";
|
|
3
4
|
import { isKnownLifecycleEventType } from "./guards.ts";
|
|
4
5
|
import { AgentHarnessSchema } from "./harness.ts";
|
|
5
6
|
import { MetaSchema } from "./meta.ts";
|
|
@@ -168,6 +169,7 @@ export const TurnStartedEventSchema = z.object({
|
|
|
168
169
|
type: z.literal("turn.started"),
|
|
169
170
|
sessionId: z.string(),
|
|
170
171
|
turnId: z.string(),
|
|
172
|
+
execution: TurnExecutionSchema.optional(),
|
|
171
173
|
timestamp: EpochMsSchema,
|
|
172
174
|
_meta: MetaSchema,
|
|
173
175
|
});
|
|
@@ -177,6 +179,7 @@ export const TurnEndedEventSchema = z.object({
|
|
|
177
179
|
type: z.literal("turn.ended"),
|
|
178
180
|
sessionId: z.string(),
|
|
179
181
|
turnId: z.string(),
|
|
182
|
+
execution: TurnExecutionSchema.optional(),
|
|
180
183
|
/** Normalized terminal status — `cancelled` and `error` are first-class. */
|
|
181
184
|
stopReason: StopReasonSchema,
|
|
182
185
|
/** Raw provider finish string (`end_turn`, `completed`, …), always preserved when reported. */
|
package/src/protocol/reduce.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
// The package ships raw .ts — keep imports exactly-used, or every consumer
|
|
3
3
|
// compiling with noUnusedLocals inherits the noise as build errors.
|
|
4
4
|
import { ErrorCategorySchema, ErrorInfoSchema } from "./errors.ts";
|
|
5
|
+
import { TurnExecutionSchema } from "./execution.ts";
|
|
5
6
|
import { isUnknownEvent } from "./guards.ts";
|
|
6
7
|
import { AgentHarnessSchema } from "./harness.ts";
|
|
7
8
|
import {
|
|
@@ -115,6 +116,7 @@ export type TimelineEntry = z.infer<typeof TimelineEntrySchema>;
|
|
|
115
116
|
|
|
116
117
|
export const ConversationTurnSchema = z.object({
|
|
117
118
|
turnId: z.string(),
|
|
119
|
+
execution: TurnExecutionSchema.optional(),
|
|
118
120
|
/** Derived view state, not wire vocabulary. */
|
|
119
121
|
status: z.enum(["active", "ended"]),
|
|
120
122
|
stopReason: StopReasonSchema.optional(),
|
|
@@ -496,6 +498,7 @@ function reduceWith(
|
|
|
496
498
|
if (existing !== -1) return state; // replay overlap — the turn is known
|
|
497
499
|
const turn: ConversationTurn = {
|
|
498
500
|
turnId: event.turnId,
|
|
501
|
+
...(event.execution !== undefined && { execution: event.execution }),
|
|
499
502
|
status: "active",
|
|
500
503
|
errors: [],
|
|
501
504
|
startedAt: event.timestamp,
|
|
@@ -603,6 +606,7 @@ function reduceWith(
|
|
|
603
606
|
...base,
|
|
604
607
|
status: "ended",
|
|
605
608
|
stopReason: event.stopReason,
|
|
609
|
+
...(event.execution !== undefined && { execution: event.execution }),
|
|
606
610
|
...(event.finishReason !== undefined && { finishReason: event.finishReason }),
|
|
607
611
|
...(event.tokens !== undefined && { tokens: event.tokens }),
|
|
608
612
|
...(event.cost !== undefined && { cost: event.cost }),
|