From f2d3812a9bf81a6793f21cfe2efdea1d81f55372 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:59:45 +0000 Subject: [PATCH 01/34] fix: preserve cache-write usage in native history --- .changeset/preserve-history-cache-writes.md | 5 + packages/agents/src/runtime/history.ts | 46 +++++- .../history-cache-writes-native.test.ts | 78 +++++++++++ .../native/history-counters-native.test.ts | 5 +- .../test/native/history-counters.test.ts | 131 ++++++++++++++++++ .../agents/test/native/parity-data.test.ts | 2 +- 6 files changed, 258 insertions(+), 9 deletions(-) create mode 100644 .changeset/preserve-history-cache-writes.md create mode 100644 packages/agents/test/native/history-cache-writes-native.test.ts diff --git a/.changeset/preserve-history-cache-writes.md b/.changeset/preserve-history-cache-writes.md new file mode 100644 index 000000000..9dcba2c66 --- /dev/null +++ b/.changeset/preserve-history-cache-writes.md @@ -0,0 +1,5 @@ +--- +'@openai/agents': patch +--- + +fix: Preserve reported cache-write tokens in native turn history and JSON-safe repair observations. diff --git a/packages/agents/src/runtime/history.ts b/packages/agents/src/runtime/history.ts index db48c2322..5b12c0620 100644 --- a/packages/agents/src/runtime/history.ts +++ b/packages/agents/src/runtime/history.ts @@ -27,7 +27,11 @@ export type ThreadHistory = z.infer; /** Safe counters remain numbers; larger exact counts are bigint. */ export interface RecordedTokenUsage { inputTokens: TokenCount; - inputTokensDetails: { cachedTokens: TokenCount }; + inputTokensDetails: { + cachedTokens: TokenCount; + /** Omitted when the cache-write count is unavailable or incomplete. */ + cacheWriteTokens?: TokenCount; + }; outputTokens: TokenCount; outputTokensDetails: { reasoningTokens: TokenCount }; totalTokens: TokenCount; @@ -35,7 +39,10 @@ export interface RecordedTokenUsage { export interface RecordedTokenUsageWire { inputTokens: TokenCountWire; - inputTokensDetails: { cachedTokens: TokenCountWire }; + inputTokensDetails: { + cachedTokens: TokenCountWire; + cacheWriteTokens?: TokenCountWire; + }; outputTokens: TokenCountWire; outputTokensDetails: { reasoningTokens: TokenCountWire }; totalTokens: TokenCountWire; @@ -96,7 +103,10 @@ export const repairTurnWireSchema = z .object({ inputTokens: nonnegativeTokenCountWireSchema, inputTokensDetails: z - .object({ cachedTokens: nonnegativeTokenCountWireSchema }) + .object({ + cachedTokens: nonnegativeTokenCountWireSchema, + cacheWriteTokens: nonnegativeTokenCountWireSchema.optional(), + }) .strict(), outputTokens: nonnegativeTokenCountWireSchema, outputTokensDetails: z @@ -134,6 +144,13 @@ function mapRecordedUsage( inputTokens: convert(usage.inputTokens), inputTokensDetails: { cachedTokens: convert(usage.inputTokensDetails.cachedTokens), + ...(usage.inputTokensDetails.cacheWriteTokens === undefined + ? {} + : { + cacheWriteTokens: convert( + usage.inputTokensDetails.cacheWriteTokens, + ), + }), }, outputTokens: convert(usage.outputTokens), outputTokensDetails: { @@ -423,6 +440,9 @@ function recordedUsage(raw: JsonObject): RecordedTokenUsage { inputTokens: usageCount(raw, 'input_tokens'), inputTokensDetails: { cachedTokens: usageCount(raw, 'cached_input_tokens'), + ...(raw.cache_write_input_tokens === undefined + ? {} + : { cacheWriteTokens: usageCount(raw, 'cache_write_input_tokens') }), }, outputTokens: usageCount(raw, 'output_tokens'), outputTokensDetails: { @@ -833,6 +853,7 @@ export function projectHistory( const turns = new Map(); const usage = new Map(); const responseUsage = new Map(); + const incompleteCacheWriteUsage = new Set(); const failures = new Map(); let items: HistoryOccurrence[] = []; const identities = new Map(); @@ -946,6 +967,7 @@ export function projectHistory( turns.delete(id); usage.delete(id); responseUsage.delete(id); + incompleteCacheWriteUsage.delete(id); failures.delete(id); } items = items.filter(({ item }) => !removed.has(item.turnId)); @@ -968,13 +990,22 @@ export function projectHistory( failures.set(id, failure(payload)); if (payload.type === 'raw_response_completed') { const report = object(payload.token_usage); + if (report?.cache_write_input_tokens === undefined) + incompleteCacheWriteUsage.add(id); + if (incompleteCacheWriteUsage.has(id)) { + const total = responseUsage.get(id); + if (total) delete total.cache_write_input_tokens; + } if (report) { const total = responseUsage.get(id) ?? {}; for (const key of usageFields) { - const sum = addTokenCounts( - usageCount(total, key), - usageCount(report, key), - ); + const count = usageCount(report, key); + if ( + key === 'cache_write_input_tokens' && + incompleteCacheWriteUsage.has(id) + ) + continue; + const sum = addTokenCounts(usageCount(total, key), count); total[key] = tokenCountToWire(sum); } responseUsage.set(id, total); @@ -1017,6 +1048,7 @@ export function projectHistory( if (started) { failures.delete(id); responseUsage.delete(id); + incompleteCacheWriteUsage.delete(id); } const error = failures.get(id) ?? diff --git a/packages/agents/test/native/history-cache-writes-native.test.ts b/packages/agents/test/native/history-cache-writes-native.test.ts new file mode 100644 index 000000000..98b3e3346 --- /dev/null +++ b/packages/agents/test/native/history-cache-writes-native.test.ts @@ -0,0 +1,78 @@ +import { afterEach, expect, it } from 'vitest'; +import { z } from 'zod'; +import { Agent, NativeProvider, tool, type RecordedTurn } from '../../src'; +import { ModelServer, call, message } from './fixtures/model-server'; + +afterEach(() => ModelServer.closeAll()); + +it('retains per-turn cache writes through public history and restore', async () => { + const server = new ModelServer( + [ + [call('probe', {}, { callId: 'probe' })], + [message('first done')], + [message('second done')], + ], + { + input_tokens: 10, + input_tokens_details: { cached_tokens: 0, cache_write_tokens: 4 }, + output_tokens: 2, + total_tokens: 12, + }, + ); + const config = await server.config(); + await using provider = new NativeProvider({ + baseUrl: (config['model_providers.fixture'] as { base_url: string }) + .base_url, + apiKey: 'synthetic-history-fixture', + supportsWebsockets: false, + }); + const agent = new Agent({ + instructions: 'Use probe then answer.', + model: 'gpt-5.5', + provider, + tools: [ + tool({ name: 'probe', parameters: z.object({}), execute: () => 'ok' }), + ], + }); + const session = await agent.session(); + let state; + let firstId: string; + let secondId: string; + try { + const first = await session.submit('first'); + firstId = first.turnId; + expect( + (await first.wait()).usage?.totalTokenUsage.cacheWriteInputTokens, + ).toBe(8); + const second = await session.submit('second'); + secondId = second.turnId; + expect( + (await second.wait()).usage?.totalTokenUsage.cacheWriteInputTokens, + ).toBe(12); + const turns: RecordedTurn[] = []; + for await (const turn of session.turns()) turns.push(turn); + expect( + turns.find((turn) => turn.id === firstId)?.usage?.inputTokensDetails + .cacheWriteTokens, + ).toBe(8); + expect( + turns.find((turn) => turn.id === secondId)?.usage?.inputTokensDetails + .cacheWriteTokens, + ).toBe(4); + state = await session.saveState(); + } finally { + await session.close(); + } + await using restored = await agent.session({ state }); + const restoredTurns: RecordedTurn[] = []; + for await (const turn of restored.turns()) restoredTurns.push(turn); + expect( + restoredTurns.find((turn) => turn.id === firstId)?.usage?.inputTokensDetails + .cacheWriteTokens, + ).toBe(8); + expect( + restoredTurns.find((turn) => turn.id === secondId)?.usage + ?.inputTokensDetails.cacheWriteTokens, + ).toBe(4); + server.assertExhausted(); +}, 30_000); diff --git a/packages/agents/test/native/history-counters-native.test.ts b/packages/agents/test/native/history-counters-native.test.ts index 39d3de41c..5483a95a9 100644 --- a/packages/agents/test/native/history-counters-native.test.ts +++ b/packages/agents/test/native/history-counters-native.test.ts @@ -656,7 +656,10 @@ function assertTurnUsage( expect(turn, `missing actual recorded turn ${turnId}`).toBeDefined(); expect(turn?.usage).toEqual({ inputTokens: expected.inputTokens, - inputTokensDetails: { cachedTokens: expected.cachedInputTokens }, + inputTokensDetails: { + cachedTokens: expected.cachedInputTokens, + cacheWriteTokens: expected.cacheWriteInputTokens, + }, outputTokens: expected.outputTokens, outputTokensDetails: { reasoningTokens: expected.reasoningOutputTokens }, totalTokens: expected.totalTokens, diff --git a/packages/agents/test/native/history-counters.test.ts b/packages/agents/test/native/history-counters.test.ts index d074b14b2..0ef49ffcc 100644 --- a/packages/agents/test/native/history-counters.test.ts +++ b/packages/agents/test/native/history-counters.test.ts @@ -3,6 +3,7 @@ import { projectHistory, recordedTurnFromWire, recordedTurnToWire, + repairTurnWireSchema, type ThreadHistory, } from '../../src/runtime/history'; import { @@ -91,6 +92,136 @@ describe('exact observational counters', () => { ).toBeNull(); }); + it('preserves reported cache writes, including zero and exact large counts', () => { + for (const [raw, expected] of [ + [0, 0], + [7, 7], + [large, 9007199254740993n], + ] as const) { + expect( + project(record({ cache_write_input_tokens: raw })).usage + ?.inputTokensDetails, + ).toEqual({ cachedTokens: 0, cacheWriteTokens: expected }); + } + expect(project(record({})).usage?.inputTokensDetails).not.toHaveProperty( + 'cacheWriteTokens', + ); + }); + + it('only totals cache writes when every response reports the counter', () => { + expect( + project([ + ...response({ cache_write_input_tokens: large }), + ...response({ cache_write_input_tokens: 2 }), + ]).usage?.inputTokensDetails, + ).toEqual({ cachedTokens: 0, cacheWriteTokens: 9007199254740995n }); + expect( + project([ + ...response({ cache_write_input_tokens: 0 }), + ...response({ cache_write_input_tokens: 0 }), + ]).usage?.inputTokensDetails, + ).toEqual({ cachedTokens: 0, cacheWriteTokens: 0 }); + const unavailable: ThreadHistory[] = [ + response({}), + [ + { + type: 'event_msg', + payload: { type: 'raw_response_completed', turn_id: 't' }, + }, + ], + ]; + for (const missing of unavailable) { + for (const records of [ + [...missing, ...response({ cache_write_input_tokens: 7 })], + [...response({ cache_write_input_tokens: 7 }), ...missing], + ]) { + expect(project(records).usage?.inputTokensDetails).not.toHaveProperty( + 'cacheWriteTokens', + ); + } + } + expect(project(response({})).usage?.inputTokensDetails).not.toHaveProperty( + 'cacheWriteTokens', + ); + expect(project(unavailable[1]).usage).toBeNull(); + expect( + project([...unavailable[1], ...record({ cache_write_input_tokens: 5 })]) + .usage?.inputTokensDetails, + ).toEqual({ cachedTokens: 0, cacheWriteTokens: 5 }); + }); + + it('still rejects malformed cache writes after an incomplete response', () => { + expect(() => + project([ + ...response({}), + ...response({ cache_write_input_tokens: null }), + ]), + ).toThrow(); + }); + + it('clears incomplete response cache accounting when a turn restarts', () => { + expect( + project([ + { + type: 'event_msg', + payload: { type: 'raw_response_completed', turn_id: 't' }, + }, + ...start, + ...response({ cache_write_input_tokens: 3 }), + ]).usage?.inputTokensDetails, + ).toEqual({ cachedTokens: 0, cacheWriteTokens: 3 }); + }); + + it('discards incomplete response accounting when history rolls back', () => { + expect( + project([ + { + type: 'event_msg', + payload: { type: 'raw_response_completed', turn_id: 't' }, + }, + { + type: 'event_msg', + payload: { type: 'thread_rolled_back', num_turns: 1 }, + }, + ...response({ cache_write_input_tokens: 4 }), + ]).usage?.inputTokensDetails, + ).toEqual({ cachedTokens: 0, cacheWriteTokens: 4 }); + }); + + it('roundtrips optional cache writes through JSON and strict repair turns', () => { + for (const raw of [undefined, 0, large]) { + const turn = { + ...project( + record(raw === undefined ? {} : { cache_write_input_tokens: raw }), + ), + subagentId: null, + status: 'completed' as const, + parentSessionId: 'parent', + parentTurnId: 'parent-turn', + }; + const wire = repairTurnWireSchema.parse( + JSON.parse(JSON.stringify(recordedTurnToWire(turn))), + ); + expect(wire.usage?.inputTokensDetails).toEqual( + raw === undefined + ? { cachedTokens: 0 } + : { cachedTokens: 0, cacheWriteTokens: raw }, + ); + expect(recordedTurnFromWire(wire)).toEqual(turn); + if (wire.usage) { + expect( + repairTurnWireSchema.safeParse({ + ...wire, + usage: { + ...wire.usage, + inputTokensDetails: { cachedTokens: 0, cacheWriteTokens: -1 }, + }, + }).success, + ).toBe(false); + } + } + }); + it('preserves safe counts and every large recorded counter exactly', () => { expect(parseTokenCount(Number.MAX_SAFE_INTEGER)).toBe( Number.MAX_SAFE_INTEGER, diff --git a/packages/agents/test/native/parity-data.test.ts b/packages/agents/test/native/parity-data.test.ts index d5f04cc20..88792e19f 100644 --- a/packages/agents/test/native/parity-data.test.ts +++ b/packages/agents/test/native/parity-data.test.ts @@ -249,7 +249,7 @@ describe('Python data API parity', () => { }, usage: { inputTokens: 17, - inputTokensDetails: { cachedTokens: 3 }, + inputTokensDetails: { cachedTokens: 3, cacheWriteTokens: 4 }, outputTokens: 7, outputTokensDetails: { reasoningTokens: 2 }, totalTokens: 24, From 66c0cffd2efb63e7a99273d8456c0334cbf90e1d Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:22:52 +0000 Subject: [PATCH 02/34] test: stabilize filesystem ownership fixtures --- .changeset/preserve-history-cache-writes.md | 1 + .../test/sandboxes/docker.materialization.fileIO.test.ts | 5 +++-- packages/agents-core/test/sandboxes/unixLocal.fileIO.test.ts | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.changeset/preserve-history-cache-writes.md b/.changeset/preserve-history-cache-writes.md index 9dcba2c66..de4004968 100644 --- a/.changeset/preserve-history-cache-writes.md +++ b/.changeset/preserve-history-cache-writes.md @@ -1,5 +1,6 @@ --- '@openai/agents': patch +'@openai/agents-core': patch --- fix: Preserve reported cache-write tokens in native turn history and JSON-safe repair observations. diff --git a/packages/agents-core/test/sandboxes/docker.materialization.fileIO.test.ts b/packages/agents-core/test/sandboxes/docker.materialization.fileIO.test.ts index 5be501199..1d6ec6a27 100644 --- a/packages/agents-core/test/sandboxes/docker.materialization.fileIO.test.ts +++ b/packages/agents-core/test/sandboxes/docker.materialization.fileIO.test.ts @@ -55,7 +55,8 @@ describe.skipIf(process.platform === 'win32')( it('stops a large directory before probing members beyond the limit', async () => { const source = join(root, 'many-files'); await mkdir(source); - for (let index = 0; index < 20; index++) { + // The directory is one member, so four files exceed the limit below. + for (let index = 0; index < 4; index++) { await writeFile(join(source, `file-${index}`), 'content'); } session.state.manifest.extraPathGrants.push({ @@ -73,7 +74,7 @@ describe.skipIf(process.platform === 'win32')( // A failed copy closes its reader and leaves the session usable. session.setArchiveLimits(null); await session.materializeEntry({ path: 'trusted', entry }); - expect(await readFile(join(workspace, 'trusted/file-19'), 'utf8')).toBe( + expect(await readFile(join(workspace, 'trusted/file-3'), 'utf8')).toBe( 'content', ); }); diff --git a/packages/agents-core/test/sandboxes/unixLocal.fileIO.test.ts b/packages/agents-core/test/sandboxes/unixLocal.fileIO.test.ts index eb2f861d1..9c09d7c3b 100644 --- a/packages/agents-core/test/sandboxes/unixLocal.fileIO.test.ts +++ b/packages/agents-core/test/sandboxes/unixLocal.fileIO.test.ts @@ -245,7 +245,8 @@ describe.skipIf(process.platform === 'win32')( ); expect(await readFile(leaf, 'utf8')).toBe('deep\n'); }, - 15_000, + // Allow the full-depth ownership walk to run with the parallel suite. + 30_000, ); it.each(['materializeEntry', 'applyManifest'] as const)( From 3698ec75744b287551b00d041c1590bd6ab48421 Mon Sep 17 00:00:00 2001 From: Mark Stuart Date: Wed, 4 Nov 2026 20:09:58 -0800 Subject: [PATCH 03/34] fix: retain response history with valid received counters (#2151) Co-authored-by: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> --- .changeset/quiet-responses-retained.md | 5 + .../0024-native-response-observations.patch | 1715 +++++++++++++++++ packages/agents/native/patches/series.json | 6 +- .../agents/native/rust/response_integrity.rs | 13 + packages/agents/native/rust/usage.rs | 34 + packages/agents/src/index.ts | 2 + packages/agents/src/runtime/history.ts | 251 ++- .../test/native/fixtures/model-server.ts | 6 +- .../native/history-counters-native.test.ts | 21 + .../test/native/history-counters.test.ts | 210 ++ .../agents/test/native/parity-data.test.ts | 24 +- .../native/response-history-native.test.ts | 285 +++ 12 files changed, 2565 insertions(+), 7 deletions(-) create mode 100644 .changeset/quiet-responses-retained.md create mode 100644 packages/agents/native/patches/0024-native-response-observations.patch create mode 100644 packages/agents/test/native/response-history-native.test.ts diff --git a/.changeset/quiet-responses-retained.md b/.changeset/quiet-responses-retained.md new file mode 100644 index 000000000..97f3ba82a --- /dev/null +++ b/.changeset/quiet-responses-retained.md @@ -0,0 +1,5 @@ +--- +'@openai/agents': patch +--- + +feat: expose retained native response observations in recorded turn history. Preserve received terminal status, stable origin identities, and optional exact usage counters independently of turn totals. Mark legacy accounting counters as native normalized, and retain response details through saved state and repair receipts. This preview surface requires a reader that supports the new history records. diff --git a/packages/agents/native/patches/0024-native-response-observations.patch b/packages/agents/native/patches/0024-native-response-observations.patch new file mode 100644 index 000000000..c2b7c8d77 --- /dev/null +++ b/packages/agents/native/patches/0024-native-response-observations.patch @@ -0,0 +1,1715 @@ +diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs +index e7128a59cc3cc7f859d41a1326e0b6f9516c307b..b32ef6d20c8d0c9fa26cd121e630641fa871aa24 100644 +--- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs ++++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs +@@ -429,6 +429,7 @@ impl ThreadHistoryBuilder { + RolloutItem::InterAgentCommunication(_) + | RolloutItem::InterAgentCommunicationMetadata { .. } + | RolloutItem::TurnContext(_) ++ | RolloutItem::ResponseObservationRecord(_) + | RolloutItem::TokenUsageRecord(_) + | RolloutItem::WorldState(_) + | RolloutItem::RealtimeItem(_) +diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs b/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs +index b6ad558bb5169cf941cf0d1b23918e35b8de94f9..c3171c9a862ec9593605e6d6e2e56dee9c10aa2f 100644 +--- a/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs ++++ b/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs +@@ -90,6 +90,7 @@ pub fn project_rollout_line(line: &RolloutLine) -> ThreadHistoryChangeSet { + | RolloutItem::InterAgentCommunicationMetadata { .. } + | RolloutItem::Compacted(_) + | RolloutItem::TurnContext(_) ++ | RolloutItem::ResponseObservationRecord(_) + | RolloutItem::TokenUsageRecord(_) + | RolloutItem::WorldState(_) + | RolloutItem::RealtimeItem(_) +diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs +index 42c9fecd1a3065cf1729ce7b53f017fe1dbac53c..f594f15a31fbc208327d7b94d12687b0f6ddfe87 100644 +--- a/codex-rs/codex-api/src/common.rs ++++ b/codex-rs/codex-api/src/common.rs +@@ -79,6 +79,7 @@ pub struct ResponseId(pub String); + + #[derive(Debug)] + pub enum ResponseEvent { ++ ResponseObserved(codex_protocol::protocol::ResponseObservation), + Created { + /// Existing server response ID, when supplied by the stream. + response_id: Option, +diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs +index 9e79edda926c5c5a8d233d56162705451931d0c4..94c881cd0a8cf3b2cdf07d9175d4166b0e828c6e 100644 +--- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs ++++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs +@@ -816,6 +816,16 @@ async fn run_websocket_response_stream( + "response event consumer dropped".to_string(), + )); + } ++ if let Some(observation) = event.response_observation() ++ && tx_event ++ .send(Ok(ResponseEvent::ResponseObserved(observation))) ++ .await ++ .is_err() ++ { ++ return Err(ApiError::Stream( ++ "response event consumer dropped".to_string(), ++ )); ++ } + match process_responses_event(event) { + Ok(Some(event)) => { + if let ResponseEvent::Created { response_id: id } = &event { +@@ -941,6 +951,75 @@ mod tests { + use std::collections::HashMap; + use std::sync::Arc; + ++ #[tokio::test] ++ async fn websocket_observes_incomplete_before_returning_existing_error() { ++ let (tx_command, mut rx_command) = mpsc::channel(1); ++ let (tx_message, rx_message) = mpsc::unbounded_channel(); ++ let pump_task = ++ tokio::spawn(async move { ++ let Some(WsCommand::Send { message, tx_result }) = rx_command.recv().await else { ++ panic!("missing response.create"); ++ }; ++ assert!(matches!(message, Message::Text(_))); ++ tx_result.send(Ok(())).unwrap(); ++ tx_message.send(Ok(Message::Text(json!({ ++ "type": "response.incomplete", "response": { ++ "id": "ws-response", "incomplete_details": {"reason": "content_filter"}, ++ "usage": {"input_tokens": 0} ++ } ++ }).to_string().into()))).unwrap(); ++ }); ++ let mut stream = WsStream { ++ tx_command, ++ rx_message, ++ pump_task, ++ }; ++ let (tx_event, mut rx_event) = mpsc::channel(8); ++ let (_tx_interrupt, rx_interrupt) = oneshot::channel(); ++ let context = ResponsesWebsocketTimingLogContext { ++ model: "test-model".to_string(), ++ session_id: None, ++ thread_id: None, ++ turn_id: None, ++ traceparent: None, ++ previous_response_id: None, ++ request_start_ms: None, ++ warmup: false, ++ connection_reused: false, ++ }; ++ let error = run_websocket_response_stream( ++ &mut stream, ++ tx_event, ++ "{}".to_string(), ++ Duration::from_secs(1), ++ None, ++ None, ++ &context, ++ rx_interrupt, ++ ) ++ .await ++ .unwrap_err(); ++ assert!(matches!(error, ApiError::ContentFilter)); ++ let ResponseEvent::ResponseObserved(observation) = rx_event.recv().await.unwrap().unwrap() ++ else { ++ panic!("missing websocket terminal observation"); ++ }; ++ assert_eq!(observation.response_id.as_deref(), Some("ws-response")); ++ assert_eq!( ++ observation.status, ++ codex_protocol::protocol::ResponseObservationStatus::Incomplete ++ ); ++ assert_eq!( ++ observation.incomplete_reason, ++ Some(codex_protocol::protocol::ResponseIncompleteReason::ContentFilter) ++ ); ++ let usage = observation.usage.unwrap(); ++ assert_eq!(usage.input_tokens, Some(0)); ++ assert_eq!(usage.cached_input_tokens, None); ++ assert_eq!(usage.total_tokens, None); ++ assert!(rx_event.recv().await.is_none()); ++ } ++ + #[test] + fn direct_serialization_preserves_websocket_request_payload() { + let api_request = ResponsesApiRequest { +diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs +index 0b14cdb03906e2d203c3f77097a4021212516d9e..dae0519fe514ad2ea384ef3bf7a674c088746d56 100644 +--- a/codex-rs/codex-api/src/sse/responses.rs ++++ b/codex-rs/codex-api/src/sse/responses.rs +@@ -182,6 +182,61 @@ where + } + + impl ResponsesStreamEvent { ++ /// Read only safe typed facts; malformed or missing counters stay unknown. ++ pub(crate) fn response_observation( ++ &self, ++ ) -> Option { ++ use codex_protocol::protocol::{ ++ ReceivedResponseUsage, ResponseIncompleteReason, ResponseObservation, ++ ResponseObservationStatus, ++ }; ++ let status = match self.kind() { ++ "response.completed" => ResponseObservationStatus::Completed, ++ "response.incomplete" => ResponseObservationStatus::Incomplete, ++ "response.failed" => ResponseObservationStatus::Failed, ++ _ => return None, ++ }; ++ let response = self.response.as_ref(); ++ let count = |value: &Value| value.as_i64().filter(|count| *count >= 0); ++ let usage = response ++ .and_then(|r| r.get("usage")) ++ .filter(|u| u.is_object()) ++ .map(|u| ReceivedResponseUsage { ++ input_tokens: u.get("input_tokens").and_then(count), ++ cached_input_tokens: u ++ .pointer("/input_tokens_details/cached_tokens") ++ .and_then(count), ++ cache_write_input_tokens: u ++ .pointer("/input_tokens_details/cache_write_tokens") ++ .and_then(count), ++ output_tokens: u.get("output_tokens").and_then(count), ++ reasoning_output_tokens: u ++ .pointer("/output_tokens_details/reasoning_tokens") ++ .and_then(count), ++ total_tokens: u.get("total_tokens").and_then(count), ++ }); ++ let incomplete_reason = (status == ResponseObservationStatus::Incomplete).then(|| { ++ match response ++ .and_then(|r| r.pointer("/incomplete_details/reason")) ++ .and_then(Value::as_str) ++ { ++ Some("content_filter") => ResponseIncompleteReason::ContentFilter, ++ Some("max_output_tokens") => ResponseIncompleteReason::MaxOutputTokens, ++ Some("interrupted") => ResponseIncompleteReason::Interrupted, ++ _ => ResponseIncompleteReason::Unknown, ++ } ++ }); ++ Some(ResponseObservation { ++ response_id: response ++ .and_then(|r| r.get("id")) ++ .and_then(Value::as_str) ++ .map(str::to_owned), ++ status, ++ incomplete_reason, ++ usage, ++ }) ++ } ++ + pub fn kind(&self) -> &str { + &self.kind + } +@@ -626,6 +681,14 @@ async fn process_sse_with_treatment( + return; + } + ++ if let Some(observation) = event.response_observation() ++ && tx_event ++ .send(Ok(ResponseEvent::ResponseObserved(observation))) ++ .await ++ .is_err() ++ { ++ return; ++ } + match process_responses_event(event) { + Ok(Some(event)) => { + let is_completed = matches!(event, ResponseEvent::Completed { .. }); +@@ -678,6 +741,164 @@ mod tests { + use tokio_test::io::Builder as IoBuilder; + use tokio_util::io::ReaderStream; + ++ #[tokio::test] ++ async fn received_terminal_facts_precede_existing_settlement() { ++ use codex_protocol::protocol::{ResponseIncompleteReason, ResponseObservationStatus}; ++ for (kind, reason, status) in [ ++ ( ++ "response.completed", ++ None, ++ ResponseObservationStatus::Completed, ++ ), ++ ( ++ "response.incomplete", ++ Some("max_output_tokens"), ++ ResponseObservationStatus::Incomplete, ++ ), ++ ( ++ "response.incomplete", ++ Some("content_filter"), ++ ResponseObservationStatus::Incomplete, ++ ), ++ ( ++ "response.incomplete", ++ Some("interrupted"), ++ ResponseObservationStatus::Incomplete, ++ ), ++ ("response.failed", None, ResponseObservationStatus::Failed), ++ ] { ++ let terminal = json!({"type": kind, "response": { ++ "id": "reused-response", "incomplete_details": {"reason": reason}, ++ "usage": {"input_tokens": 9007199254740993_i64, "output_tokens": 0, "total_tokens": 9007199254740993_i64, ++ "input_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 0}} ++ }}); ++ let body = format!("data: {terminal}\n\n"); ++ let events = collect_events(&[body.as_bytes()]).await; ++ assert_eq!(events.len(), 2); ++ let Ok(ResponseEvent::ResponseObserved(observation)) = &events[0] else { ++ panic!("missing observation: {events:?}") ++ }; ++ assert_eq!(observation.response_id.as_deref(), Some("reused-response")); ++ assert_eq!(observation.status, status); ++ let usage = observation.usage.as_ref().unwrap(); ++ assert_eq!(usage.input_tokens, Some(9007199254740993)); ++ assert_eq!(usage.cache_write_input_tokens, Some(0)); ++ assert_eq!(usage.cached_input_tokens, Some(0)); ++ assert_eq!(usage.reasoning_output_tokens, None); ++ if reason == Some("content_filter") { ++ assert_eq!( ++ observation.incomplete_reason, ++ Some(ResponseIncompleteReason::ContentFilter) ++ ); ++ assert_matches!(&events[1], Err(ApiError::ContentFilter)); ++ } else if kind == "response.completed" || reason == Some("interrupted") { ++ assert_matches!(&events[1], Ok(ResponseEvent::Completed { .. })); ++ } else { ++ assert!(events[1].is_err()); ++ } ++ } ++ } ++ ++ #[test] ++ fn negative_received_counters_are_unknown_and_valid_peers_survive() { ++ let raw = json!({ ++ "input_tokens": 9007199254740993_i64, ++ "input_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 2}, ++ "output_tokens": 3, ++ "output_tokens_details": {"reasoning_tokens": 0}, ++ "total_tokens": 9007199254740996_i64, ++ }); ++ let expected = json!({ ++ "input_tokens": 9007199254740993_i64, ++ "cached_input_tokens": 0, ++ "cache_write_input_tokens": 2, ++ "output_tokens": 3, ++ "reasoning_output_tokens": 0, ++ "total_tokens": 9007199254740996_i64, ++ }); ++ for (pointer, field) in [ ++ ("/input_tokens", "input_tokens"), ++ ("/input_tokens_details/cached_tokens", "cached_input_tokens"), ++ ( ++ "/input_tokens_details/cache_write_tokens", ++ "cache_write_input_tokens", ++ ), ++ ("/output_tokens", "output_tokens"), ++ ( ++ "/output_tokens_details/reasoning_tokens", ++ "reasoning_output_tokens", ++ ), ++ ("/total_tokens", "total_tokens"), ++ ] { ++ for negative in [-1, i64::MIN] { ++ let mut raw = raw.clone(); ++ *raw.pointer_mut(pointer).unwrap() = json!(negative); ++ let event: ResponsesStreamEvent = serde_json::from_value(json!({ ++ "type": "response.incomplete", "response": {"usage": raw} ++ })) ++ .unwrap(); ++ let received = event.response_observation().unwrap().usage.unwrap(); ++ let mut expected = expected.clone(); ++ expected.as_object_mut().unwrap().remove(field); ++ assert_eq!( ++ serde_json::to_value(received).unwrap(), ++ expected, ++ "{field}: {negative}" ++ ); ++ } ++ } ++ } ++ ++ #[test] ++ fn missing_received_values_are_not_normalized_or_derived() { ++ for usage in [ ++ json!(null), ++ json!({}), ++ json!({"output_tokens": 0, "input_tokens_details": null}), ++ ] { ++ let event: ResponsesStreamEvent = serde_json::from_value(json!({ ++ "type": "response.incomplete", "response": {"usage": usage} ++ })) ++ .unwrap(); ++ let observation = event.response_observation().unwrap(); ++ assert_eq!(observation.response_id, None); ++ assert_eq!( ++ observation.incomplete_reason, ++ Some(codex_protocol::protocol::ResponseIncompleteReason::Unknown) ++ ); ++ assert_eq!(observation.usage.is_some(), usage.is_object()); ++ if let Some(received) = observation.usage { ++ assert_eq!(received.input_tokens, None); ++ assert_eq!(received.total_tokens, None); ++ assert_eq!( ++ received.output_tokens, ++ usage.get("output_tokens").and_then(Value::as_i64) ++ ); ++ } ++ } ++ let event: ResponsesStreamEvent = ++ serde_json::from_value(json!({"type": "response.created"})).unwrap(); ++ assert!(event.response_observation().is_none()); ++ let failed: ResponsesStreamEvent = ++ serde_json::from_value(json!({"type": "response.failed"})).unwrap(); ++ let observation = failed.response_observation().unwrap(); ++ assert_eq!( ++ observation.status, ++ codex_protocol::protocol::ResponseObservationStatus::Failed ++ ); ++ assert_eq!(observation.response_id, None); ++ assert_eq!(observation.usage, None); ++ assert_eq!(observation.incomplete_reason, None); ++ } ++ ++ async fn collect_legacy_events(chunks: &[&[u8]]) -> Vec> { ++ collect_events(chunks) ++ .await ++ .into_iter() ++ .filter(|event| !matches!(event, Ok(ResponseEvent::ResponseObserved(_)))) ++ .collect() ++ } ++ + async fn collect_events(chunks: &[&[u8]]) -> Vec> { + let mut builder = IoBuilder::new(); + for chunk in chunks { +@@ -798,7 +1019,7 @@ mod tests { + .iter() + .map(|event| format!("data: {event}\n\n")) + .collect::(); +- let results = collect_events(&[body.as_bytes()]).await; ++ let results = collect_legacy_events(&[body.as_bytes()]).await; + assert_eq!(results.len(), 2); + assert_matches!(&results[0], Ok(ResponseEvent::OutputTextDelta(text)) if text == "earlier commentary"); + assert_matches!(&results[1], Err(ApiError::InvalidRequest { message }) +@@ -834,7 +1055,10 @@ mod tests { + + let mut out = Vec::new(); + while let Some(ev) = rx.recv().await { +- out.push(ev.expect("channel closed")); ++ let event = ev.expect("channel closed"); ++ if !matches!(event, ResponseEvent::ResponseObserved(_)) { ++ out.push(event); ++ } + } + out + } +@@ -876,7 +1100,8 @@ mod tests { + let sse2 = format!("event: response.output_item.done\ndata: {item2}\n\n"); + let sse3 = format!("event: response.completed\ndata: {completed}\n\n"); + +- let events = collect_events(&[sse1.as_bytes(), sse2.as_bytes(), sse3.as_bytes()]).await; ++ let events = ++ collect_legacy_events(&[sse1.as_bytes(), sse2.as_bytes(), sse3.as_bytes()]).await; + + assert_eq!(events.len(), 3); + +@@ -980,7 +1205,7 @@ mod tests { + + let sse1 = format!("event: response.output_item.done\ndata: {item1}\n\n"); + +- let events = collect_events(&[sse1.as_bytes()]).await; ++ let events = collect_legacy_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 2); + +@@ -1085,7 +1310,9 @@ mod tests { + let events = tokio::time::timeout(Duration::from_millis(1000), async { + let mut events = Vec::new(); + while let Some(ev) = rx.recv().await { +- events.push(ev); ++ if !matches!(ev, Ok(ResponseEvent::ResponseObserved(_))) { ++ events.push(ev); ++ } + } + events + }) +@@ -1115,7 +1342,7 @@ mod tests { + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + +- let events = collect_events(&[sse1.as_bytes()]).await; ++ let events = collect_legacy_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 1); + +@@ -1152,7 +1379,7 @@ mod tests { + "response": { "error": { "code": code, "message": message } }, + }); + let sse = format!("event: response.failed\ndata: {event}\n\n"); +- let events = collect_events(&[sse.as_bytes()]).await; ++ let events = collect_legacy_events(&[sse.as_bytes()]).await; + match (code, events.as_slice()) { + ( + "rate_limit_exceeded" | "slow_down", +@@ -1191,7 +1418,7 @@ mod tests { + json!({"type": "response.failed", "response": {"error": {"code": "server_is_overloaded", "plan_type": 42}}}), + ] { + let sse = format!("event: response.failed\ndata: {event}\n\n"); +- let events = collect_events(&[sse.as_bytes()]).await; ++ let events = collect_legacy_events(&[sse.as_bytes()]).await; + match events.as_slice() { + [Err(ApiError::Stream(message))] => { + assert_eq!(message, "response.failed event received"); +@@ -1207,7 +1434,7 @@ mod tests { + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + +- let events = collect_events(&[sse1.as_bytes()]).await; ++ let events = collect_legacy_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 1); + +@@ -1220,7 +1447,7 @@ mod tests { + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + +- let events = collect_events(&[sse1.as_bytes()]).await; ++ let events = collect_legacy_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 1); + +@@ -1233,7 +1460,7 @@ mod tests { + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + +- let events = collect_events(&[sse1.as_bytes()]).await; ++ let events = collect_legacy_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 1); + +@@ -1246,7 +1473,7 @@ mod tests { + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + +- let events = collect_events(&[sse1.as_bytes()]).await; ++ let events = collect_legacy_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 1); + +@@ -1264,7 +1491,7 @@ mod tests { + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + +- let events = collect_events(&[sse1.as_bytes()]).await; ++ let events = collect_legacy_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 1); + +@@ -1284,7 +1511,7 @@ mod tests { + let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_fatal_misalignment","object":"response","status":"failed","error":{"type":"invalid_request_error","code":"misalignment_policy_violation","message":"This request violated the misalignment policy."}}}"#; + + let sse = format!("event: response.failed\ndata: {raw_error}\n\n"); +- let events = collect_events(&[sse.as_bytes()]).await; ++ let events = collect_legacy_events(&[sse.as_bytes()]).await; + + assert_eq!(events.len(), 1); + match &events[0] { +@@ -1315,7 +1542,7 @@ mod tests { + }, + }); + let sse = format!("event: response.failed\ndata: {raw_error}\n\n"); +- let events = collect_events(&[sse.as_bytes()]).await; ++ let events = collect_legacy_events(&[sse.as_bytes()]).await; + + assert_eq!(events.len(), 1); + match &events[0] { +@@ -1347,7 +1574,7 @@ mod tests { + } + }); + let sse = format!("event: response.failed\ndata: {raw_error}\n\n"); +- let events = collect_events(&[sse.as_bytes()]).await; ++ let events = collect_legacy_events(&[sse.as_bytes()]).await; + + assert_eq!(events.len(), 1); + match &events[0] { +@@ -1388,7 +1615,7 @@ mod tests { + } + }); + let sse = format!("event: response.failed\ndata: {raw_error}\n\n"); +- let events = collect_events(&[sse.as_bytes()]).await; ++ let events = collect_legacy_events(&[sse.as_bytes()]).await; + + assert_eq!(events.len(), 1); + assert_matches!( +@@ -1428,7 +1655,7 @@ mod tests { + .to_string(); + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + +- let events = collect_events(&[sse1.as_bytes()]).await; ++ let events = collect_legacy_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 1); + match (code, &events[0]) { +@@ -1463,7 +1690,7 @@ mod tests { + _ => fallback, + }; + let sse = format!("event: response.failed\ndata: {event}\n\n"); +- let events = collect_events(&[sse.as_bytes()]).await; ++ let events = collect_legacy_events(&[sse.as_bytes()]).await; + match (code, events.as_slice()) { + ("bio_policy", [Err(ApiError::BioPolicy { message })]) + | ("invalid_prompt", [Err(ApiError::InvalidPrompt { message })]) => { +diff --git a/codex-rs/codex-api/tests/sse_end_to_end.rs b/codex-rs/codex-api/tests/sse_end_to_end.rs +index 5c1dcb8ee5ba5657e93b5adc1f25a58435b61e1b..aba43ce9f19428b4970aaa65b5d0ed99621047c3 100644 +--- a/codex-rs/codex-api/tests/sse_end_to_end.rs ++++ b/codex-rs/codex-api/tests/sse_end_to_end.rs +@@ -148,7 +148,7 @@ async fn responses_stream_parses_items_and_completed_end_to_end() -> Result<()> + .filter(|ev| !matches!(ev, ResponseEvent::RateLimits(_))) + .collect(); + +- assert_eq!(events.len(), 3); ++ assert_eq!(events.len(), 4); + + match &events[0] { + ResponseEvent::OutputItemDone(ResponseItem::Message { role, .. }) => { +@@ -165,6 +165,18 @@ async fn responses_stream_parses_items_and_completed_end_to_end() -> Result<()> + } + + match &events[2] { ++ ResponseEvent::ResponseObserved(observation) => { ++ assert_eq!(observation.response_id.as_deref(), Some("resp1")); ++ assert_eq!( ++ observation.status, ++ codex_protocol::protocol::ResponseObservationStatus::Completed ++ ); ++ assert_eq!(observation.usage.as_ref().unwrap().total_tokens, Some(15)); ++ } ++ other => panic!("unexpected terminal observation: {other:?}"), ++ } ++ ++ match &events[3] { + ResponseEvent::Completed { + response_id, + token_usage, +diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs +index 6957710644bec5e4d4935fca68dd4530273fffe8..da478e35f30a24fbe52140d8f0ac70adac8fa1bd 100644 +--- a/codex-rs/core/src/agent/control/spawn.rs ++++ b/codex-rs/core/src/agent/control/spawn.rs +@@ -123,6 +123,8 @@ fn keep_forked_rollout_item(item: &RolloutItem, preserve_context_baselines: bool + // from the parent's durable baseline. Truncated forks drop part of that prompt, + // so they must rebuild context on their first child turn. + RolloutItem::TurnContext(_) | RolloutItem::WorldState(_) => preserve_context_baselines, ++ // Observations preserve their original identity without restoring aggregate usage. ++ RolloutItem::ResponseObservationRecord(_) => true, + // Child threads inherit model context, not the parent's cumulative usage state. + RolloutItem::TokenUsageRecord(_) => false, + RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => true, +@@ -1242,7 +1244,8 @@ impl LocalAgentControl { + true + } + RolloutItem::RealtimeItem(_) => false, +- RolloutItem::EventMsg(_) ++ RolloutItem::ResponseObservationRecord(_) ++ | RolloutItem::EventMsg(_) + | RolloutItem::SessionMeta(_) + | RolloutItem::TurnContext(_) + | RolloutItem::InterAgentCommunication(_) +diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs +index 1acd116f119642a1556a78d84cc70c2a3f857c4c..9c70ba9f1e57bb5371cddb966157bb8483e5dc36 100644 +--- a/codex-rs/core/src/agent/control_tests.rs ++++ b/codex-rs/core/src/agent/control_tests.rs +@@ -2138,6 +2138,7 @@ async fn spawn_agent_fork_sanitizes_inherited_compaction_metadata() { + ..TokenUsage::default() + }; + let parent_record = TokenUsageRecord { ++ response_observation_id: None, + thread_id: parent_thread_id, + turn_id: "parent-turn".to_string(), + session_id: parent_thread.session.session_id(), +@@ -2205,6 +2206,7 @@ async fn spawn_agent_fork_sanitizes_inherited_compaction_metadata() { + .record_observed_response_completed( + turn_context.as_ref(), + "child-response", ++ None, + Some(&child_usage), + /*usage_metadata*/ None, + ) +@@ -2261,6 +2263,7 @@ async fn spawn_agent_fork_sanitizes_inherited_compaction_metadata() { + assert_eq!( + child_record, + Some(&TokenUsageRecord { ++ response_observation_id: None, + thread_id: child_thread_id, + turn_id: turn_context.sub_id.clone(), + session_id: child_thread.session.session_id(), +diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs +index 83f9774074a1adfcaf1a7ab8db4f751e7cfe61cc..c01b0cab585dc3565f9ccd125b4e3ccaf4bd1379 100644 +--- a/codex-rs/core/src/compact.rs ++++ b/codex-rs/core/src/compact.rs +@@ -219,6 +219,7 @@ async fn run_compact_task_inner( + ) + .await; + if let Err(err) = &result ++ && !matches!(err.details(), CodexErrorDetails::Fatal(_)) + && !matches!(phase, CompactionPhase::PostTurn) + && !matches!( + err.details(), +@@ -300,6 +301,8 @@ async fn run_compact_task_inner_impl( + { + return Err(err); + } ++ // Fatal history-retention failures must not repeat the model request. ++ Err(e) if matches!(e.details(), CodexErrorDetails::Fatal(_)) => return Err(e), + Err(e) if matches!(e.details(), CodexErrorDetails::SessionBudgetExceeded) => { + return Err(e); + } +@@ -761,6 +764,7 @@ async fn drain_to_completed( + ) + .await?; + let mut output = Vec::new(); ++ let mut response_observation_id = None; + loop { + let maybe_event = stream.next().await; + let Some(event) = maybe_event else { +@@ -795,6 +799,11 @@ async fn drain_to_completed( + Ok(ResponseEvent::RateLimits(snapshot)) => { + sess.update_rate_limits(turn_context, snapshot).await; + } ++ Ok(ResponseEvent::ResponseObserved(observation)) => { ++ response_observation_id = sess ++ .record_response_observation(turn_context, observation) ++ .await?; ++ } + Ok(ResponseEvent::Completed { + response_id, + token_usage, +@@ -804,6 +813,7 @@ async fn drain_to_completed( + sess.record_observed_response_completed( + turn_context, + &response_id, ++ response_observation_id.take(), + token_usage.as_ref(), + usage_metadata.as_ref(), + ) +diff --git a/codex-rs/core/src/compact_model_fallback.rs b/codex-rs/core/src/compact_model_fallback.rs +index 9e9a6213d54f6bfb3231e4728d85b910f78d95b1..d7b97d3c48e7a3c5def5e6cf41400cdbb53a821b 100644 +--- a/codex-rs/core/src/compact_model_fallback.rs ++++ b/codex-rs/core/src/compact_model_fallback.rs +@@ -15,6 +15,7 @@ pub(crate) fn should_retry_with_current_model( + if matches!( + error.details(), + CodexErrorDetails::TurnAborted ++ | CodexErrorDetails::Fatal(_) + | CodexErrorDetails::Interrupted + | CodexErrorDetails::SessionBudgetExceeded + ) || (previous.model_info().slug == current.model_info().slug +diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs +index e20b0a7f5cf5f6d4c5f8bc701facfb785aa900c0..f1af831d1467f1a550c47031e765f9938f522ac1 100644 +--- a/codex-rs/core/src/compact_remote_v2.rs ++++ b/codex-rs/core/src/compact_remote_v2.rs +@@ -194,7 +194,8 @@ async fn run_remote_compact_task_inner( + match result { + Ok(()) => Ok(()), + Err(err) +- if matches!(err.details(), CodexErrorDetails::TurnAborted) ++ if matches!(err.details(), CodexErrorDetails::Fatal(_)) ++ || matches!(err.details(), CodexErrorDetails::TurnAborted) + || matches!(phase, CompactionPhase::PostTurn) => + { + Err(err) +@@ -279,6 +280,11 @@ async fn run_remote_compact_task_inner_impl( + ); + match fallback_result { + Ok(attempt) => (attempt, fallback_turn_context), ++ Err(fallback_error) ++ if matches!(fallback_error.details(), CodexErrorDetails::Fatal(_)) => ++ { ++ return Err(fallback_error); ++ } + Err(_) => return Err(error), + } + } +@@ -436,6 +442,7 @@ async fn collect_compaction_output( + let mut compaction_output = None; + let mut completed_response_id = None; + let mut completed_token_usage = None; ++ let mut response_observation_id = None; + while let Some(event) = stream.next().await { + match event? { + ResponseEvent::OutputItemDone(item) => { +@@ -447,6 +454,11 @@ async fn collect_compaction_output( + } + } + } ++ ResponseEvent::ResponseObserved(observation) => { ++ response_observation_id = sess ++ .record_response_observation(turn_context, observation) ++ .await?; ++ } + ResponseEvent::Completed { + response_id, + token_usage, +@@ -456,6 +468,7 @@ async fn collect_compaction_output( + sess.record_observed_response_completed( + turn_context, + &response_id, ++ response_observation_id.take(), + token_usage.as_ref(), + usage_metadata.as_ref(), + ) +diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs +index d8ffb4ef7cd8f299009d3ff445f34c162cf41536..fe07a27188c883874f55b88c6a7d27c2cd00f894 100644 +--- a/codex-rs/core/src/session/mod.rs ++++ b/codex-rs/core/src/session/mod.rs +@@ -4737,10 +4737,38 @@ impl Session { + result + } + ++ pub(crate) async fn record_response_observation( ++ &self, ++ turn_context: &TurnContext, ++ observation: codex_protocol::protocol::ResponseObservation, ++ ) -> CodexResult> { ++ // Ephemeral sessions deliberately have no retained history sink. ++ if self.live_thread().is_none() { ++ return Ok(None); ++ } ++ let id = Uuid::now_v7().to_string(); ++ let record = codex_protocol::protocol::ResponseObservationRecord { ++ id: id.clone(), ++ thread_id: self.thread_id, ++ turn_id: turn_context.sub_id.clone(), ++ observation, ++ }; ++ if !self ++ .persist_rollout_items(&[RolloutItem::ResponseObservationRecord(record)]) ++ .await ++ { ++ return Err(CodexErr::Fatal( ++ "Could not retain response observation".to_string(), ++ )); ++ } ++ Ok(Some(id)) ++ } ++ + pub(crate) async fn record_observed_response_completed( + &self, + turn_context: &TurnContext, + response_id: &str, ++ response_observation_id: Option, + usage: Option<&TokenUsage>, + usage_metadata: Option<&ResponseUsageMetadata>, + ) { +@@ -4765,6 +4793,7 @@ impl Session { + .root_turn_id() + .unwrap_or_else(|| turn_context.sub_id.clone()), + response_id.to_string(), ++ response_observation_id, + usage, + ); + self.persist_rollout_items(&[RolloutItem::TokenUsageRecord(record)]) +diff --git a/codex-rs/core/src/session/rollout_reconstruction.rs b/codex-rs/core/src/session/rollout_reconstruction.rs +index ab20e5b30ef4a363d12d12ee9d5c1375b985a825..9671b1fe89796cfd6443d769135ee13a4b24c505 100644 +--- a/codex-rs/core/src/session/rollout_reconstruction.rs ++++ b/codex-rs/core/src/session/rollout_reconstruction.rs +@@ -210,6 +210,7 @@ impl Session { + | RolloutItem::WorldState(_) + | RolloutItem::RetainedContext(_) + | RolloutItem::SecurityRiskScore(_) ++ | RolloutItem::ResponseObservationRecord(_) + | RolloutItem::TokenUsageRecord(_) + | RolloutItem::RealtimeItem(_) + | RolloutItem::Compacted(_) +@@ -357,6 +358,7 @@ impl Session { + | RolloutItem::RealtimeItem(_) + | RolloutItem::RetainedContext(_) + | RolloutItem::SecurityRiskScore(_) ++ | RolloutItem::ResponseObservationRecord(_) + | RolloutItem::TokenUsageRecord(_) + | RolloutItem::InterAgentCommunicationMetadata { .. } => {} + } +@@ -465,6 +467,7 @@ impl Session { + | RolloutItem::RealtimeItem(_) + | RolloutItem::WorldState(_) + | RolloutItem::SecurityRiskScore(_) ++ | RolloutItem::ResponseObservationRecord(_) + | RolloutItem::TokenUsageRecord(_) + | RolloutItem::SessionMeta(_) => {} + } +@@ -505,6 +508,7 @@ impl Session { + | RolloutItem::InterAgentCommunicationMetadata { .. } + | RolloutItem::TurnContext(_) + | RolloutItem::RealtimeItem(_) ++ | RolloutItem::ResponseObservationRecord(_) + | RolloutItem::TokenUsageRecord(_) + | RolloutItem::RetainedContext(_) + | RolloutItem::SecurityRiskScore(_) +diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs +index f4663fbfadf7f31e8c2de7407d5e3e640eea70e1..6d8a9a49837f2054019683123a1199504ae16204 100644 +--- a/codex-rs/core/src/session/session.rs ++++ b/codex-rs/core/src/session/session.rs +@@ -1671,7 +1671,7 @@ impl Session { + | RolloutItem::TurnContext(_) + | RolloutItem::WorldState(_) + | RolloutItem::RealtimeItem(_) +- | RolloutItem::TokenUsageRecord(_) ++ | RolloutItem::ResponseObservationRecord(_) | RolloutItem::TokenUsageRecord(_) + | RolloutItem::RetainedContext(_) + | RolloutItem::SecurityRiskScore(_) => {} + } +diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs +index 8e13dbf102466ef98ce6e04bcd450f1760860c16..ff08f5bb6b6c2febf2545ef456f81fabe7584e8c 100644 +--- a/codex-rs/core/src/session/tests.rs ++++ b/codex-rs/core/src/session/tests.rs +@@ -3379,6 +3379,7 @@ async fn record_initial_history_seeds_token_info_from_rollout() { + fn latest_token_usage_record_stops_at_compaction_checkpoint() { + let thread_id = ThreadId::new(); + let checkpoint_record = TokenUsageRecord { ++ response_observation_id: None, + thread_id, + turn_id: "turn-1".to_string(), + session_id: SessionId::from(thread_id), +@@ -3998,6 +3999,7 @@ async fn start_new_context_window_persists_checkpoint_state() { + attach_thread_persistence(Arc::get_mut(&mut session).expect("unique session")).await; + let thread_id = ThreadId::new(); + let token_usage_record = TokenUsageRecord { ++ response_observation_id: None, + thread_id, + turn_id: "turn-1".to_string(), + session_id: SessionId::from(thread_id), +@@ -4059,6 +4061,7 @@ async fn start_new_context_window_persists_checkpoint_state() { + | RolloutItem::WorldState(_) + | RolloutItem::RetainedContext(_) + | RolloutItem::SecurityRiskScore(_) ++ | RolloutItem::ResponseObservationRecord(_) + | RolloutItem::TokenUsageRecord(_) + | RolloutItem::RealtimeItem(_) + | RolloutItem::EventMsg(_) => None, +@@ -4154,6 +4157,7 @@ async fn record_initial_history_assigns_and_persists_id_for_forked_response_item + | RolloutItem::WorldState(_) + | RolloutItem::RetainedContext(_) + | RolloutItem::SecurityRiskScore(_) ++ | RolloutItem::ResponseObservationRecord(_) + | RolloutItem::TokenUsageRecord(_) + | RolloutItem::RealtimeItem(_) + | RolloutItem::EventMsg(_) => None, +@@ -11829,6 +11833,9 @@ async fn finished_turn_retains_last_known_step_context(terminal: TerminalEventKi + enum FirstAttempt { + Succeeds, + Retries, ++ Fatal, ++ FallbackFatal, ++ FallbackFails, + } + + async fn make_remote_compaction_session( +@@ -11856,10 +11863,27 @@ async fn make_remote_compaction_session( + + #[test_case(FirstAttempt::Succeeds; "primary succeeds")] + #[test_case(FirstAttempt::Retries; "fallback executes")] ++#[test_case(FirstAttempt::Fatal; "fatal primary does not fallback")] ++#[test_case(FirstAttempt::FallbackFatal; "fallback fatal replaces original error")] ++#[test_case(FirstAttempt::FallbackFails; "ordinary fallback error preserves original")] + #[tokio::test] + async fn remote_compaction_v2_retains_only_the_selected_step(first_attempt: FirstAttempt) { + let server = responses::start_mock_server().await; +- let (session, turn, events) = make_remote_compaction_session(&server.uri()).await; ++ let (mut session, turn, events) = make_remote_compaction_session(&server.uri()).await; ++ if matches!( ++ first_attempt, ++ FirstAttempt::Fatal | FirstAttempt::FallbackFatal ++ ) { ++ // Closing the real local recorder leaves an active Session with a sink ++ // whose observation append must fail, including on a replacement model. ++ open_thread_persistence(Arc::get_mut(&mut session).expect("unshared session")).await; ++ session ++ .live_thread() ++ .expect("local persistence") ++ .shutdown() ++ .await ++ .expect("close recorder"); ++ } + session + .record_conversation_items( + &turn, +@@ -11903,6 +11927,33 @@ async fn remote_compaction_v2_retains_only_the_selected_step(first_attempt: Firs + Some(Arc::as_ptr(&primary)), + ); + ++ assert!(fallback.turn.provider.info().is_openai()); ++ assert_ne!( ++ primary.turn.model_info().slug, ++ fallback.turn.model_info().slug ++ ); ++ assert!( ++ crate::compact_model_fallback::should_retry_with_current_model( ++ &CodexErr::Stream("ordinary stream failure".to_string()), ++ &primary.turn, ++ &fallback.turn, ++ ) ++ ); ++ assert!( ++ !crate::compact_model_fallback::should_retry_with_current_model( ++ &CodexErr::Fatal("Could not retain response observation".to_string()), ++ &primary.turn, ++ &fallback.turn, ++ ) ++ ); ++ ++ // The closed recorder fails observation retention before terminal acceptance. ++ // Exercise the real authenticated, different-model fallback owner. ++ let fatal = ResponseTemplate::new(/*status*/ 200) ++ .insert_header("content-type", "text/event-stream") ++ .set_body_string(responses::sse(vec![responses::ev_completed( ++ "fatal-compact-response", ++ )])); + let success = ResponseTemplate::new(/*status*/ 200) + .insert_header("content-type", "text/event-stream") + .set_body_string(responses::sse(vec![ +@@ -11919,10 +11970,22 @@ async fn remote_compaction_v2_retains_only_the_selected_step(first_attempt: Firs + .set_body_json(json!({ "detail": "previous model unavailable" })), + success, + ], ++ FirstAttempt::Fatal => vec![fatal], ++ FirstAttempt::FallbackFatal => vec![ ++ ResponseTemplate::new(/*status*/ 400) ++ .set_body_json(json!({ "detail": "previous model unavailable" })), ++ fatal, ++ ], ++ FirstAttempt::FallbackFails => vec![ ++ ResponseTemplate::new(/*status*/ 400) ++ .set_body_json(json!({ "detail": "previous model unavailable" })), ++ ResponseTemplate::new(/*status*/ 400) ++ .set_body_json(json!({ "detail": "replacement model unavailable" })), ++ ], + }; + let requests = responses::mount_response_sequence(&server, replies).await; + let mut client_session = session.services.model_client.new_session(); +- crate::compact_remote_v2::run_inline_remote_auto_compact_task( ++ let result = crate::compact_remote_v2::run_inline_remote_auto_compact_task( + Arc::clone(&session), + Arc::clone(&primary), + Arc::clone(&fallback), +@@ -11931,12 +11994,29 @@ async fn remote_compaction_v2_retains_only_the_selected_step(first_attempt: Firs + CompactionReason::ModelDownshift, + CompactionPhase::PreTurn, + ) +- .await +- .expect("compaction succeeds"); ++ .await; ++ match first_attempt { ++ FirstAttempt::Succeeds | FirstAttempt::Retries => result.expect("compaction succeeds"), ++ FirstAttempt::Fatal | FirstAttempt::FallbackFatal => { ++ let error = result.expect_err("Fatal compaction must fail"); ++ assert!(matches!(error.details(), CodexErrorDetails::Fatal(message) ++ if message == "Could not retain response observation")); ++ } ++ FirstAttempt::FallbackFails => { ++ let error = result.expect_err("both model attempts fail"); ++ assert!( ++ matches!(error.details(), CodexErrorDetails::InvalidRequest(message) ++ if message.contains("previous model unavailable") ++ && !message.contains("replacement model unavailable")) ++ ); ++ } ++ } + + let (expected, models) = match first_attempt { +- FirstAttempt::Succeeds => (&primary, vec![json!("gpt-5.4")]), +- FirstAttempt::Retries => (&fallback, vec![json!("gpt-5.4"), json!("gpt-5.2")]), ++ FirstAttempt::Succeeds | FirstAttempt::Fatal => (&primary, vec![json!("gpt-5.4")]), ++ FirstAttempt::Retries | FirstAttempt::FallbackFatal | FirstAttempt::FallbackFails => { ++ (&fallback, vec![json!("gpt-5.4"), json!("gpt-5.2")]) ++ } + }; + assert_eq!( + state +@@ -11965,6 +12045,115 @@ async fn remote_compaction_v2_retains_only_the_selected_step(first_attempt: Firs + recv_terminal_event(&events, TerminalEventKind::TurnAborted).await; + } + ++#[tokio::test] ++async fn mid_turn_compaction_retention_failure_after_tool_reaches_task_error_owner() { ++ let (release_compaction, compaction_gate) = tokio::sync::oneshot::channel(); ++ let (server, _) = start_streaming_sse_server(vec![ ++ vec![StreamingSseChunk { ++ gate: None, ++ body: responses::sse(vec![ ++ responses::ev_function_call( ++ "plan-before-compaction", ++ "update_plan", ++ r#"{"plan":[{"step":"Record one tool effect","status":"completed"}]}"#, ++ ), ++ responses::ev_completed_with_tokens("sampling-response", 500), ++ ]), ++ }], ++ vec![StreamingSseChunk { ++ gate: Some(compaction_gate), ++ body: responses::sse_completed("compaction-response"), ++ }], ++ ]) ++ .await; ++ let mut provider = built_in_model_providers(/*openai_base_url*/ None)["openai"].clone(); ++ provider.base_url = Some(format!("{}/v1", server.uri())); ++ provider.supports_websockets = false; ++ let server_uri = server.uri().to_string(); ++ let (mut session, turn, events) = make_session_and_context_with_auth_and_config_and_rx( ++ CodexAuth::create_dummy_chatgpt_auth_for_testing(), ++ Vec::new(), ++ move |config| { ++ config.model = Some("gpt-5.2".to_string()); ++ config.model_provider = provider; ++ config.chatgpt_base_url = server_uri; ++ config.update_plan_enabled = true; ++ config.model_auto_compact_token_limit = Some(200); ++ let _ = config.features.disable(Feature::TokenBudget); ++ let _ = config.features.disable(Feature::EnableRequestCompression); ++ }, ++ ) ++ .await; ++ open_thread_persistence(Arc::get_mut(&mut session).expect("unshared session")).await; ++ session ++ .record_conversation_items(&turn, turn.model_info(), &[user_message("Update the plan")]) ++ .await; ++ session ++ .spawn_task( ++ Arc::clone(&turn), ++ Vec::new(), ++ crate::tasks::RegularTask::new(), ++ ) ++ .await; ++ timeout(Duration::from_secs(10), server.wait_for_request_count(2)) ++ .await ++ .expect("tool result must reach the mid-turn compaction request"); ++ session ++ .live_thread() ++ .expect("local persistence") ++ .shutdown() ++ .await ++ .expect("close recorder before compaction terminal"); ++ release_compaction ++ .send(()) ++ .expect("release compaction terminal"); ++ ++ let mut errors = 0; ++ let mut terminals = 0; ++ let mut plan_updates = 0; ++ timeout(Duration::from_secs(10), async { ++ loop { ++ let event = events.recv().await.expect("owning task event"); ++ match event.msg { ++ EventMsg::PlanUpdate(_) => plan_updates += 1, ++ EventMsg::Error(error) => { ++ errors += 1; ++ assert!( ++ error ++ .message ++ .contains("Could not retain response observation") ++ ); ++ } ++ EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_) => { ++ terminals += 1; ++ break; ++ } ++ _ => {} ++ } ++ } ++ }) ++ .await ++ .expect("retention failure must settle its owning task"); ++ while let Ok(event) = events.try_recv() { ++ match event.msg { ++ EventMsg::PlanUpdate(_) => plan_updates += 1, ++ EventMsg::Error(_) => errors += 1, ++ EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_) => terminals += 1, ++ _ => {} ++ } ++ } ++ assert_eq!((errors, terminals, plan_updates), (1, 1, 1)); ++ let requests = server.requests().await; ++ assert_eq!( ++ requests.len(), ++ 2, ++ "retention failure must not repeat inference" ++ ); ++ let compaction_request = String::from_utf8_lossy(&requests[1]); ++ assert!(compaction_request.contains("compaction_trigger")); ++ assert!(compaction_request.contains("function_call_output")); ++} ++ + #[tokio::test] + async fn interrupting_compaction_fallback_retains_last_known_step_context() { + let (release_primary, primary_gate) = tokio::sync::oneshot::channel(); +diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs +index ef861ed4de8cdd71d2506e049dc35b38cc4012ee..8abea93a01b6dcb646cc7e6c446975882abd536c 100644 +--- a/codex-rs/core/src/session/turn.rs ++++ b/codex-rs/core/src/session/turn.rs +@@ -633,7 +633,10 @@ pub(crate) async fn run_turn( + ) + .await + { +- if matches!(err.details(), CodexErrorDetails::TurnAborted) { ++ if matches!( ++ err.details(), ++ CodexErrorDetails::TurnAborted | CodexErrorDetails::Fatal(_) ++ ) { + return Err(err); + } + let error = err.to_codex_protocol_error(); +@@ -742,10 +745,12 @@ pub(crate) async fn run_turn( + ) + .await + { +- if matches!( +- err.details(), +- CodexErrorDetails::Interrupted | CodexErrorDetails::TurnAborted +- ) { ++ if matches!(err.details(), CodexErrorDetails::Fatal(_)) ++ || matches!( ++ err.details(), ++ CodexErrorDetails::Interrupted | CodexErrorDetails::TurnAborted ++ ) ++ { + return Err(err); + } + let error = err.to_codex_protocol_error(); +@@ -2600,6 +2605,7 @@ async fn try_run_sampling_request( + )> = None; + let mut should_emit_turn_diff = false; + let mut should_emit_token_count = false; ++ let mut response_observation_id = None; + const MAX_ANALYTICS_TOOL_CALL_IDS_PER_RESPONSE: usize = 256; + let mut analytics_tool_call_ids = Vec::new(); + let reasoning_effort = step_context +@@ -2953,6 +2959,18 @@ async fn try_run_sampling_request( + .refresh_if_new_etag(etag, turn_context.config.http_client_factory()) + .await; + } ++ ResponseEvent::ResponseObserved(observation) => { ++ let result = sess ++ .record_response_observation(&turn_context, observation) ++ .await; ++ if cancellation_token.is_cancelled() { ++ break Err(CodexErr::TurnAborted); ++ } ++ match result { ++ Ok(id) => response_observation_id = id, ++ Err(error) => break Err(error), ++ } ++ } + ResponseEvent::Completed { + response_id, + token_usage, +@@ -2979,6 +2997,7 @@ async fn try_run_sampling_request( + sess.record_observed_response_completed( + &turn_context, + &response_id, ++ response_observation_id.take(), + token_usage.as_ref(), + usage_metadata.as_ref(), + ) +diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs +index 6a93e160a4773ee6368cce41b63a418151b7adab..b10c99bfd9334b5aded11e38f02186febc6b8b3a 100644 +--- a/codex-rs/core/src/state/session.rs ++++ b/codex-rs/core/src/state/session.rs +@@ -214,6 +214,7 @@ impl SessionState { + session_id: SessionId, + root_turn_id: String, + response_id: String, ++ response_observation_id: Option, + usage: &TokenUsage, + ) -> TokenUsageRecord { + let mut turn_token_usage = self +@@ -232,6 +233,7 @@ impl SessionState { + }); + thread_token_usage.add_assign(usage); + let record = TokenUsageRecord { ++ response_observation_id, + thread_id, + turn_id: turn_id.to_string(), + session_id, +diff --git a/codex-rs/core/src/state/session_tests.rs b/codex-rs/core/src/state/session_tests.rs +index 5d3defeefee1a2dcfcc1add69f3e5434342531df..acf97898015ecf59f697855fa71d39b15491883a 100644 +--- a/codex-rs/core/src/state/session_tests.rs ++++ b/codex-rs/core/src/state/session_tests.rs +@@ -20,6 +20,7 @@ async fn record_token_usage_continues_restored_totals() { + }; + let mut restored = SessionState::new(make_session_configuration_for_tests().await); + restored.latest_token_usage_record = Some(TokenUsageRecord { ++ response_observation_id: None, + thread_id, + turn_id: "turn-b".to_string(), + session_id, +@@ -35,11 +36,13 @@ async fn record_token_usage_continues_restored_totals() { + session_id, + "root-turn".to_string(), + "response-d".to_string(), ++ Some("observation-d".to_string()), + &usage(20), + ); + assert_eq!( + after_resume, + TokenUsageRecord { ++ response_observation_id: Some("observation-d".to_string()), + thread_id, + turn_id: "turn-b".to_string(), + session_id, +diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs +index 62808c9e604c9c996675dad983df4d757bc45af7..5c5b3e2c4bf8ef0141e2fad382abdc5c218d9e21 100644 +--- a/codex-rs/core/src/tasks/compact.rs ++++ b/codex-rs/core/src/tasks/compact.rs +@@ -91,7 +91,9 @@ impl SessionTask for CompactTask { + } + }; + if let Err(err) = result { +- if matches!(err.details(), CodexErrorDetails::TurnAborted) { ++ if matches!(err.details(), CodexErrorDetails::Fatal(_)) ++ || matches!(err.details(), CodexErrorDetails::TurnAborted) ++ { + return Err(err); + } + let error = err.to_codex_protocol_error(); +diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs +index 75a2bf9bf04b17bbf84b4e12b08f7608b9f85b2d..d5328b3ec668e5a9e12464ef521495b8a77e71fa 100644 +--- a/codex-rs/core/src/turn_timing.rs ++++ b/codex-rs/core/src/turn_timing.rs +@@ -394,7 +394,8 @@ fn response_event_records_turn_ttft(event: &ResponseEvent) -> bool { + | ResponseEvent::ReasoningSummaryDelta { .. } + | ResponseEvent::ReasoningSummaryDone { .. } + | ResponseEvent::ReasoningContentDelta { .. } => true, +- ResponseEvent::Created { .. } ++ ResponseEvent::ResponseObserved(_) ++ | ResponseEvent::Created { .. } + | ResponseEvent::ServerModel(_) + | ResponseEvent::ModelVerifications(_) + | ResponseEvent::TurnModerationMetadata(_) +diff --git a/codex-rs/external-agent-migration/src/sessions/append.rs b/codex-rs/external-agent-migration/src/sessions/append.rs +index b77481afd3b2dc8a036da1fb721e6fae01aba258..35a29eccb3e50e735e9ba495490fb6b718649894 100644 +--- a/codex-rs/external-agent-migration/src/sessions/append.rs ++++ b/codex-rs/external-agent-migration/src/sessions/append.rs +@@ -220,6 +220,7 @@ fn source_model_items(items: &[RolloutItem]) -> Option>> + match item { + RolloutItem::SessionMeta(_) + | RolloutItem::InterAgentCommunicationMetadata { .. } ++ | RolloutItem::ResponseObservationRecord(_) + | RolloutItem::TokenUsageRecord(_) + | RolloutItem::RealtimeItem(_) => {} + RolloutItem::ResponseItem(response_item) => { +@@ -260,6 +261,7 @@ fn history_model_items(items: &[RolloutItem]) -> Option> { + match item { + RolloutItem::SessionMeta(_) + | RolloutItem::InterAgentCommunicationMetadata { .. } ++ | RolloutItem::ResponseObservationRecord(_) + | RolloutItem::TokenUsageRecord(_) + | RolloutItem::RealtimeItem(_) + | RolloutItem::RetainedContext(_) +diff --git a/codex-rs/history/src/compaction_resume_metadata.rs b/codex-rs/history/src/compaction_resume_metadata.rs +index 4886fcd52b8a63fe978b3a0304bac0b4dfcdf75f..effd83cf895edf9d0f535c957f2b18e542e1adfb 100644 +--- a/codex-rs/history/src/compaction_resume_metadata.rs ++++ b/codex-rs/history/src/compaction_resume_metadata.rs +@@ -43,6 +43,7 @@ pub fn resume_multi_agent_version(item: &RolloutItem) -> Option { + TurnContext { + payload: Cow<'a, TurnContextItem>, + }, ++ ResponseObservationRecord { ++ payload: Cow<'a, ResponseObservationRecord>, ++ }, + TokenUsageRecord { + payload: Cow<'a, TokenUsageRecord>, + }, +@@ -97,6 +101,9 @@ impl<'a> From<&'a RolloutItem> for RolloutItemWire<'a> { + RolloutItem::TurnContext(payload) => Self::TurnContext { + payload: Cow::Borrowed(payload), + }, ++ RolloutItem::ResponseObservationRecord(payload) => Self::ResponseObservationRecord { ++ payload: Cow::Borrowed(payload), ++ }, + RolloutItem::TokenUsageRecord(payload) => Self::TokenUsageRecord { + payload: Cow::Borrowed(payload), + }, +@@ -214,6 +221,9 @@ impl From> for RolloutItem { + } + RolloutItemWire::Compacted { payload } => Self::Compacted(payload.into_owned()), + RolloutItemWire::TurnContext { payload } => Self::TurnContext(payload.into_owned()), ++ RolloutItemWire::ResponseObservationRecord { payload } => { ++ Self::ResponseObservationRecord(payload.into_owned()) ++ } + RolloutItemWire::TokenUsageRecord { payload } => { + Self::TokenUsageRecord(payload.into_owned()) + } +diff --git a/codex-rs/memories/write/src/phase1.rs b/codex-rs/memories/write/src/phase1.rs +index 1fa128c2f5d2eb96730553092992949079c2e725..ae28974ef1dab7509160c1d355d66feb0b14d96a 100644 +--- a/codex-rs/memories/write/src/phase1.rs ++++ b/codex-rs/memories/write/src/phase1.rs +@@ -408,6 +408,7 @@ mod job { + | RolloutItem::InterAgentCommunicationMetadata { .. } + | RolloutItem::Compacted(_) + | RolloutItem::TurnContext(_) ++ | RolloutItem::ResponseObservationRecord(_) + | RolloutItem::TokenUsageRecord(_) + | RolloutItem::RealtimeItem(_) + | RolloutItem::WorldState(_) +diff --git a/codex-rs/memories/write/src/rollout_input.rs b/codex-rs/memories/write/src/rollout_input.rs +index 7a1d1d94c3e0c7efde98993dd128722dde2bf627..67c94762958d64f0216642fb4f0a142fd40cce54 100644 +--- a/codex-rs/memories/write/src/rollout_input.rs ++++ b/codex-rs/memories/write/src/rollout_input.rs +@@ -52,6 +52,7 @@ pub(crate) fn serialize_tiered_input( + | RolloutItem::RealtimeItem(_) + | RolloutItem::WorldState(_) + | RolloutItem::SecurityRiskScore(_) ++ | RolloutItem::ResponseObservationRecord(_) + | RolloutItem::TokenUsageRecord(_) + | RolloutItem::RetainedContext(_) + | RolloutItem::EventMsg(_) => None, +diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs +index 352265d9ea21f7e0812708888c1cedb90f30687c..6b25af0ae8eff9c092938a7bf4c01d5846b9d857 100644 +--- a/codex-rs/otel/src/events/session_telemetry.rs ++++ b/codex-rs/otel/src/events/session_telemetry.rs +@@ -1366,6 +1366,7 @@ impl SessionTelemetry { + + fn responses_type(event: &ResponseEvent) -> String { + match event { ++ ResponseEvent::ResponseObserved(_) => "response_observed".into(), + ResponseEvent::Created { .. } => "created".into(), + ResponseEvent::OutputItemDone(item) | ResponseEvent::OutputItemAdded(item) => { + SessionTelemetry::responses_item_type(item) +diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs +index d79d21f78249f186d55f2f958d2ef440829f30e4..b7103bf1dab55ea1231a5578902a2c55fb1c9c5f 100644 +--- a/codex-rs/protocol/src/protocol.rs ++++ b/codex-rs/protocol/src/protocol.rs +@@ -2266,9 +2266,65 @@ pub struct TokenUsage { + pub codex_rollout_budget_units: Option, + } + ++/// Counters received on one terminal response. Missing counters remain unknown. ++#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] ++pub struct ReceivedResponseUsage { ++ #[serde(default, skip_serializing_if = "Option::is_none")] ++ pub input_tokens: Option, ++ #[serde(default, skip_serializing_if = "Option::is_none")] ++ pub cached_input_tokens: Option, ++ #[serde(default, skip_serializing_if = "Option::is_none")] ++ pub cache_write_input_tokens: Option, ++ #[serde(default, skip_serializing_if = "Option::is_none")] ++ pub output_tokens: Option, ++ #[serde(default, skip_serializing_if = "Option::is_none")] ++ pub reasoning_output_tokens: Option, ++ #[serde(default, skip_serializing_if = "Option::is_none")] ++ pub total_tokens: Option, ++} ++ ++/// Provider-reported terminal kind, independent of SDK acceptance or turn success. ++#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] ++#[serde(rename_all = "snake_case")] ++pub enum ResponseObservationStatus { ++ Completed, ++ Incomplete, ++ Failed, ++} ++ ++#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] ++#[serde(rename_all = "snake_case")] ++pub enum ResponseIncompleteReason { ++ ContentFilter, ++ MaxOutputTokens, ++ Interrupted, ++ Unknown, ++} ++ ++/// Safe received terminal facts before accounting normalization. ++#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] ++pub struct ResponseObservation { ++ pub response_id: Option, ++ pub status: ResponseObservationStatus, ++ pub incomplete_reason: Option, ++ pub usage: Option, ++} ++ ++/// A retained terminal occurrence with its original native identities. ++#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] ++pub struct ResponseObservationRecord { ++ pub id: String, ++ pub thread_id: ThreadId, ++ pub turn_id: String, ++ #[serde(flatten)] ++ pub observation: ResponseObservation, ++} ++ + /// Best-effort Responses API usage observed for one completed response. + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] + pub struct TokenUsageRecord { ++ #[serde(default, skip_serializing_if = "Option::is_none")] ++ pub response_observation_id: Option, + pub thread_id: ThreadId, + pub turn_id: String, + pub session_id: SessionId, +@@ -6442,3 +6498,54 @@ mod tests { + assert_eq!(info.model_context_window, Some(258_400)); + } + } ++ ++#[cfg(test)] ++mod response_observation_tests { ++ use super::*; ++ ++ #[test] ++ fn received_observation_roundtrip_preserves_origin_and_exact_optional_counters() { ++ let record = ResponseObservationRecord { ++ id: "occurrence".to_string(), ++ thread_id: ThreadId::new(), ++ turn_id: "origin-turn".to_string(), ++ observation: ResponseObservation { ++ response_id: None, ++ status: ResponseObservationStatus::Incomplete, ++ incomplete_reason: Some(ResponseIncompleteReason::Unknown), ++ usage: Some(ReceivedResponseUsage { ++ input_tokens: Some(9007199254740993), ++ cache_write_input_tokens: Some(0), ++ ..ReceivedResponseUsage::default() ++ }), ++ }, ++ }; ++ let value = serde_json::to_value(&record).unwrap(); ++ assert_eq!(value["id"], "occurrence"); ++ assert_eq!(value["turn_id"], "origin-turn"); ++ assert_eq!(value["usage"]["input_tokens"], 9007199254740993_i64); ++ assert_eq!(value["usage"]["cache_write_input_tokens"], 0); ++ assert!(value["usage"].get("cached_input_tokens").is_none()); ++ assert_eq!( ++ serde_json::from_value::(value).unwrap(), ++ record ++ ); ++ } ++ ++ #[test] ++ fn old_accounting_record_has_no_invented_occurrence_link() { ++ let value = serde_json::json!({ ++ "thread_id": ThreadId::new(), "turn_id": "turn", "session_id": SessionId::from(ThreadId::new()), ++ "root_turn_id": "root", "response_id": "response", "usage": TokenUsage::default(), ++ "turn_token_usage": TokenUsage::default(), "thread_token_usage": TokenUsage::default() ++ }); ++ let record: TokenUsageRecord = serde_json::from_value(value).unwrap(); ++ assert!(record.response_observation_id.is_none()); ++ assert!( ++ serde_json::to_value(record) ++ .unwrap() ++ .get("response_observation_id") ++ .is_none() ++ ); ++ } ++} +diff --git a/codex-rs/rollout/src/list.rs b/codex-rs/rollout/src/list.rs +index bfed9a7097b9c44d9863ffd5584dc656165341d2..ca661ec1bc3ae685569bf08a74f11f5dff57639e 100644 +--- a/codex-rs/rollout/src/list.rs ++++ b/codex-rs/rollout/src/list.rs +@@ -1042,7 +1042,7 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result { + // Not included in `head`; skip. + } +- RolloutItem::TokenUsageRecord(_) => { ++ RolloutItem::ResponseObservationRecord(_) | RolloutItem::TokenUsageRecord(_) => { + // Not included in `head`; skip. + } + RolloutItem::RetainedContext(_) +@@ -1122,6 +1122,7 @@ pub async fn read_head_for_summary(path: &Path) -> io::Result io::Result + RolloutItem::InterAgentCommunicationMetadata { .. } + | RolloutItem::Compacted(_) + | RolloutItem::TurnContext(_) ++ | RolloutItem::ResponseObservationRecord(_) + | RolloutItem::TokenUsageRecord(_) + | RolloutItem::WorldState(_) + | RolloutItem::RealtimeItem(_) +diff --git a/codex-rs/rollout/src/metadata.rs b/codex-rs/rollout/src/metadata.rs +index c2469d0d708efd3284e3086fe13aa38cf2f6e17d..7c64b4a77f1472654c8db8825562158e4a6283f8 100644 +--- a/codex-rs/rollout/src/metadata.rs ++++ b/codex-rs/rollout/src/metadata.rs +@@ -81,6 +81,7 @@ pub fn builder_from_items( + | RolloutItem::TurnContext(_) + | RolloutItem::WorldState(_) + | RolloutItem::RealtimeItem(_) ++ | RolloutItem::ResponseObservationRecord(_) + | RolloutItem::TokenUsageRecord(_) + | RolloutItem::RetainedContext(_) + | RolloutItem::SecurityRiskScore(_) +@@ -173,6 +174,7 @@ pub async fn extract_metadata_from_rollout( + | RolloutItem::TurnContext(_) + | RolloutItem::WorldState(_) + | RolloutItem::RealtimeItem(_) ++ | RolloutItem::ResponseObservationRecord(_) + | RolloutItem::TokenUsageRecord(_) + | RolloutItem::RetainedContext(_) + | RolloutItem::SecurityRiskScore(_) +diff --git a/codex-rs/rollout/src/persistence_metrics.rs b/codex-rs/rollout/src/persistence_metrics.rs +index bea161d9df049fb31b9d81b55be549933b64a9db..9c71472add7192d533bb1d0144c360e415b505e7 100644 +--- a/codex-rs/rollout/src/persistence_metrics.rs ++++ b/codex-rs/rollout/src/persistence_metrics.rs +@@ -261,6 +261,7 @@ fn rollout_item_type(item: &RolloutItem) -> String { + } + RolloutItem::Compacted(_) => "compacted".to_string(), + RolloutItem::TurnContext(_) => "turn_context".to_string(), ++ RolloutItem::ResponseObservationRecord(_) => "response_observation_record".to_string(), + RolloutItem::TokenUsageRecord(_) => "token_usage_record".to_string(), + RolloutItem::WorldState(_) => "world_state".to_string(), + RolloutItem::RetainedContext(_) => "retained_context".to_string(), +diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs +index e2f5e2952305e5cdb2573d6e77e7a2ce562aab52..ea8a8838e9f1e6e444edd1e339a24c9f485015b3 100644 +--- a/codex-rs/rollout/src/policy.rs ++++ b/codex-rs/rollout/src/policy.rs +@@ -41,6 +41,7 @@ pub fn persisted_rollout_item( + // Persist Codex executive markers so we can analyze flows (e.g., compaction, API turns). + RolloutItem::Compacted(_) + | RolloutItem::TurnContext(_) ++ | RolloutItem::ResponseObservationRecord(_) + | RolloutItem::TokenUsageRecord(_) + | RolloutItem::WorldState(_) + | RolloutItem::RetainedContext(_) +diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs +index 7422c7f4e761c72a2c547d6a55e49a58502a939d..ea61f5a709082b1b6ff542cce005435c8813d475 100644 +--- a/codex-rs/rollout/src/recorder.rs ++++ b/codex-rs/rollout/src/recorder.rs +@@ -2202,6 +2202,7 @@ async fn resume_candidate_matches_cwd( + | RolloutItem::Compacted(_) + | RolloutItem::WorldState(_) + | RolloutItem::RealtimeItem(_) ++ | RolloutItem::ResponseObservationRecord(_) + | RolloutItem::TokenUsageRecord(_) + | RolloutItem::RetainedContext(_) + | RolloutItem::SecurityRiskScore(_) +diff --git a/codex-rs/rollout/src/search.rs b/codex-rs/rollout/src/search.rs +index b0f1a68f87aa999d60d56d43fd84d11a32ad7ae6..4cba77315313625377b2c4dc4fb581efb09133b7 100644 +--- a/codex-rs/rollout/src/search.rs ++++ b/codex-rs/rollout/src/search.rs +@@ -313,6 +313,7 @@ fn conversation_text_from_item(item: &RolloutItem) -> Option { + | RolloutItem::RealtimeItem(_) + | RolloutItem::RetainedContext(_) + | RolloutItem::SecurityRiskScore(_) ++ | RolloutItem::ResponseObservationRecord(_) + | RolloutItem::TokenUsageRecord(_) + | RolloutItem::WorldState(_) => None, + } +diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs +index 8adeb295bcc22fa0067994c3590bf17bc025d03f..d27ee4fbaa5ae0cb9f06f91b1981ead0605eb0e7 100644 +--- a/codex-rs/state/src/extract.rs ++++ b/codex-rs/state/src/extract.rs +@@ -41,7 +41,7 @@ pub fn apply_rollout_item( + RolloutItem::WorldState(_) => {} + RolloutItem::RetainedContext(_) | RolloutItem::SecurityRiskScore(_) => {} + RolloutItem::RealtimeItem(_) => {} +- RolloutItem::TokenUsageRecord(_) => {} ++ RolloutItem::ResponseObservationRecord(_) | RolloutItem::TokenUsageRecord(_) => {} + } + if metadata.model_provider.is_empty() { + metadata.model_provider = default_provider.to_string(); +@@ -74,6 +74,7 @@ pub fn rollout_item_affects_thread_metadata(item: &RolloutItem) -> bool { + | RolloutItem::RealtimeItem(_) + | RolloutItem::RetainedContext(_) + | RolloutItem::SecurityRiskScore(_) ++ | RolloutItem::ResponseObservationRecord(_) + | RolloutItem::TokenUsageRecord(_) + | RolloutItem::WorldState(_) => false, + } +diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs +index fe64fb6050eaa8d819a2767070fc519ce29e990b..bef927ddf6dad812a93dff007be8e5208ae1e9db 100644 +--- a/codex-rs/state/src/runtime/threads.rs ++++ b/codex-rs/state/src/runtime/threads.rs +@@ -1371,6 +1371,7 @@ pub(super) fn extract_memory_mode(items: &[RolloutItem]) -> Option { + | RolloutItem::RealtimeItem(_) + | RolloutItem::RetainedContext(_) + | RolloutItem::SecurityRiskScore(_) ++ | RolloutItem::ResponseObservationRecord(_) + | RolloutItem::TokenUsageRecord(_) + | RolloutItem::EventMsg(_) => None, + }) +diff --git a/codex-rs/thread-store/src/local/rollout_migration/canonicalizer.rs b/codex-rs/thread-store/src/local/rollout_migration/canonicalizer.rs +index 1575bcf090af04381d9cebd8f825bfd3d0004986..7877b40ed8b81487f9883bfe07e95ba08f957902 100644 +--- a/codex-rs/thread-store/src/local/rollout_migration/canonicalizer.rs ++++ b/codex-rs/thread-store/src/local/rollout_migration/canonicalizer.rs +@@ -290,6 +290,7 @@ impl LegacyRolloutCanonicalizer { + } + item @ (RolloutItem::InterAgentCommunicationMetadata { .. } + | RolloutItem::TurnContext(_) ++ | RolloutItem::ResponseObservationRecord(_) + | RolloutItem::TokenUsageRecord(_) + | RolloutItem::RealtimeItem(_) + | RolloutItem::RetainedContext(_) +diff --git a/codex-rs/thread-store/src/local/rollout_migration/rollback_plan.rs b/codex-rs/thread-store/src/local/rollout_migration/rollback_plan.rs +index 45081b468934963403340a415d869a7abac10cd0..c20a0080fa153f1cc2b80ee1b81fa18032361b65 100644 +--- a/codex-rs/thread-store/src/local/rollout_migration/rollback_plan.rs ++++ b/codex-rs/thread-store/src/local/rollout_migration/rollback_plan.rs +@@ -275,6 +275,9 @@ impl RollbackPlanner { + self.pending_turn_records.push(index); + } + } ++ RolloutItem::ResponseObservationRecord(record) => { ++ self.assign_targeted_record(index, Some(record.turn_id.as_str())); ++ } + RolloutItem::TokenUsageRecord(record) => { + self.assign_targeted_record(index, Some(record.turn_id.as_str())); + } +diff --git a/codex-rs/thread-store/src/local/rollout_migration/rollback_replay.rs b/codex-rs/thread-store/src/local/rollout_migration/rollback_replay.rs +index ac23e9c5772b99ab6fa812518891ea92e21e66de..30357e035a9e7a0c7b276cad1b391246d079e8cc 100644 +--- a/codex-rs/thread-store/src/local/rollout_migration/rollback_replay.rs ++++ b/codex-rs/thread-store/src/local/rollout_migration/rollback_replay.rs +@@ -76,6 +76,7 @@ impl ModelReplayPlanner { + | RolloutItem::EventMsg(_) + | RolloutItem::InterAgentCommunicationMetadata { .. } + | RolloutItem::RealtimeItem(_) ++ | RolloutItem::ResponseObservationRecord(_) + | RolloutItem::TokenUsageRecord(_) + | RolloutItem::RetainedContext(_) + | RolloutItem::SecurityRiskScore(_) +diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs +index cb3559c1373c58fb59e5e95a1bb61aff84af1ce6..3812cec25037aa08f2ddaf8f52bf5122f43955c3 100644 +--- a/codex-rs/thread-store/src/thread_metadata_sync.rs ++++ b/codex-rs/thread-store/src/thread_metadata_sync.rs +@@ -345,6 +345,7 @@ impl ThreadMetadataSync { + | RolloutItem::InterAgentCommunicationMetadata { .. } + | RolloutItem::Compacted(_) + | RolloutItem::RealtimeItem(_) ++ | RolloutItem::ResponseObservationRecord(_) + | RolloutItem::TokenUsageRecord(_) + | RolloutItem::RetainedContext(_) + | RolloutItem::SecurityRiskScore(_) diff --git a/packages/agents/native/patches/series.json b/packages/agents/native/patches/series.json index c2aefc85e..1320c0efd 100644 --- a/packages/agents/native/patches/series.json +++ b/packages/agents/native/patches/series.json @@ -95,7 +95,11 @@ { "path": "0024-api-key-rate-limits.patch", "sha256": "7214955c12f507f0534a7813205eeba656265aa6d919aa3704ec5a47f3ea7d64" + }, + { + "path": "0024-native-response-observations.patch", + "sha256": "506fd6c938ae58e7f44a2ddcef2f2794ba33069c5f0cc3c098090fcf2c4e4c26" } ], - "result_tree": "fa74ed80025deb5a9da3fbb5fcc3da9fbb6be38d" + "result_tree": "587d7b2bbcdd944dbbb4c20f40f138cdf267d2ca" } diff --git a/packages/agents/native/rust/response_integrity.rs b/packages/agents/native/rust/response_integrity.rs index 0de853daa..790ea1873 100644 --- a/packages/agents/native/rust/response_integrity.rs +++ b/packages/agents/native/rust/response_integrity.rs @@ -165,6 +165,19 @@ mod tests { })) } + #[test] + fn terminal_observation_does_not_grant_supported_output() { + let mut output = SupportedOutput::default(); + let observation = codex_protocol::protocol::ResponseObservation { + response_id: Some("provider-completed".into()), + status: codex_protocol::protocol::ResponseObservationStatus::Completed, + incomplete_reason: None, + usage: None, + }; + assert!(!output.rejects(&ResponseEvent::ResponseObserved(observation))); + assert!(output.rejects(&completed(None))); + } + fn completed(end_turn: Option) -> ResponseEvent { ResponseEvent::Completed { response_id: "response".into(), diff --git a/packages/agents/native/rust/usage.rs b/packages/agents/native/rust/usage.rs index eb6cd71cd..ef1b6f2fe 100644 --- a/packages/agents/native/rust/usage.rs +++ b/packages/agents/native/rust/usage.rs @@ -143,6 +143,14 @@ fn visit_history(history: &mut Value, counter: CounterVisitor) -> anyhow::Result .ok_or_else(|| anyhow::anyhow!("History must be an array"))?; for item in items { match item.get("type").and_then(Value::as_str) { + Some("response_observation_record") => { + if let Some(usage) = item + .get_mut("payload") + .and_then(|payload| payload.get_mut("usage")) + { + visit_usage(usage, counter)?; + } + } Some("token_usage_record") => { if let Some(record) = item.get_mut("payload") { visit_record(record, counter)?; @@ -214,6 +222,32 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn response_observation_usage_roundtrips_exact_optional_counters() -> anyhow::Result<()> { + let mut history = json!([ + {"type":"response_observation_record","payload":{"id":"o","usage":{"input_tokens":i64::MAX,"cached_input_tokens":0}}}, + {"type":"response_observation_record","payload":{"id":"missing","usage":null}}, + {"type":"response_item","payload":{"arguments":{"type":"response_observation_record","usage":{"input_tokens":i64::MAX}}}} + ]); + let original = history.clone(); + history_usage_to_wire(&mut history)?; + assert_eq!( + history[0]["payload"]["usage"]["input_tokens"], + i64::MAX.to_string() + ); + assert_eq!(history[0]["payload"]["usage"]["cached_input_tokens"], 0); + assert!( + history[0]["payload"]["usage"] + .get("output_tokens") + .is_none() + ); + assert!(history[1]["payload"]["usage"].is_null()); + assert_eq!(history[2], original[2]); + history_from_wire(&mut history)?; + assert_eq!(history, original); + Ok(()) + } + #[test] fn typed_child_usage_preserves_large_counts() { let mut usage = TokenUsage::default(); diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts index d8126e894..146c60023 100644 --- a/packages/agents/src/index.ts +++ b/packages/agents/src/index.ts @@ -123,6 +123,8 @@ export type { RecordedTurn, Failure, RecordedTokenUsage, + ReceivedTokenUsage, + RecordedResponse, } from './runtime/history'; export { OutputValidationError } from './runtime/output'; export type { OutputSpec } from './runtime/output'; diff --git a/packages/agents/src/runtime/history.ts b/packages/agents/src/runtime/history.ts index 5b12c0620..69890fbc4 100644 --- a/packages/agents/src/runtime/history.ts +++ b/packages/agents/src/runtime/history.ts @@ -48,6 +48,47 @@ export interface RecordedTokenUsageWire { totalTokens: TokenCountWire; } +/** Counters received from the provider; absent fields remain unknown. */ +export interface ReceivedTokenUsage { + inputTokens?: TokenCount; + inputTokensDetails?: { + cachedTokens?: TokenCount; + cacheWriteTokens?: TokenCount; + }; + outputTokens?: TokenCount; + outputTokensDetails?: { reasoningTokens?: TokenCount }; + totalTokens?: TokenCount; +} + +export interface ReceivedTokenUsageWire { + inputTokens?: TokenCountWire; + inputTokensDetails?: { + cachedTokens?: TokenCountWire; + cacheWriteTokens?: TokenCountWire; + }; + outputTokens?: TokenCountWire; + outputTokensDetails?: { reasoningTokens?: TokenCountWire }; + totalTokens?: TokenCountWire; +} + +/** Retained provider facts, independent of SDK acceptance and turn success. */ +export interface RecordedResponse { + /** Native observation identity; unavailable for unlinked legacy records. */ + id: string | null; + threadId: string; + turnId: string; + responseId: string | null; + status?: 'completed' | 'incomplete' | 'failed'; + incompleteReason?: + 'content_filter' | 'max_output_tokens' | 'interrupted' | 'unknown'; + /** Legacy normalized zeros do not prove the provider supplied those fields. */ + usageSource: 'received' | 'native_normalized'; + usage: ReceivedTokenUsage | null; +} +export type RecordedResponseWire = Omit & { + usage: ReceivedTokenUsageWire | null; +}; + export interface Failure { code: string; message: string | null; @@ -64,14 +105,17 @@ export type HistoryTurn = { completedAt: number | null; error: Failure | null; usage: RecordedTokenUsage | null; + /** Retained observations, not complete provider-attempt or charge coverage. */ + responses?: RecordedResponse[]; }; export type RecordedTurn = HistoryTurn & { parentSessionId?: string; parentTurnId?: string; }; /** JSON-safe observational turn data for history/receipt owners, not Core checkpoints. */ -export type RecordedTurnWire = Omit & { +export type RecordedTurnWire = Omit & { usage: RecordedTokenUsageWire | null; + responses?: RecordedResponseWire[]; }; /** @internal Passive terminal observations; never Core history or grants. */ @@ -81,6 +125,40 @@ export type RepairTurnWire = RecordedTurnWire & { parentSessionId: string; parentTurnId: string; }; +const receivedUsageWireSchema = z + .object({ + inputTokens: nonnegativeTokenCountWireSchema.optional(), + inputTokensDetails: z + .object({ + cachedTokens: nonnegativeTokenCountWireSchema.optional(), + cacheWriteTokens: nonnegativeTokenCountWireSchema.optional(), + }) + .strict() + .optional(), + outputTokens: nonnegativeTokenCountWireSchema.optional(), + outputTokensDetails: z + .object({ + reasoningTokens: nonnegativeTokenCountWireSchema.optional(), + }) + .strict() + .optional(), + totalTokens: nonnegativeTokenCountWireSchema.optional(), + }) + .strict(); +const recordedResponseWireSchema = z + .object({ + id: z.string().min(1).nullable(), + threadId: z.string().min(1), + turnId: z.string().min(1), + responseId: z.string().nullable(), + status: z.enum(['completed', 'incomplete', 'failed']).optional(), + incompleteReason: z + .enum(['content_filter', 'max_output_tokens', 'interrupted', 'unknown']) + .optional(), + usageSource: z.enum(['received', 'native_normalized']), + usage: receivedUsageWireSchema.nullable(), + }) + .strict(); export const repairTurnWireSchema = z .object({ id: z.string().min(1), @@ -116,6 +194,7 @@ export const repairTurnWireSchema = z }) .strict() .nullable(), + responses: z.array(recordedResponseWireSchema).optional(), parentSessionId: z.string().min(1), parentTurnId: z.string().min(1), }) @@ -160,9 +239,71 @@ function mapRecordedUsage( }; } +function mapReceivedUsage( + usage: ReceivedTokenUsage | ReceivedTokenUsageWire, + convert: (value: number | bigint | string) => T, +) { + return { + ...(usage.inputTokens === undefined + ? {} + : { inputTokens: convert(usage.inputTokens) }), + ...(usage.inputTokensDetails === undefined + ? {} + : { + inputTokensDetails: { + ...(usage.inputTokensDetails.cachedTokens === undefined + ? {} + : { + cachedTokens: convert(usage.inputTokensDetails.cachedTokens), + }), + ...(usage.inputTokensDetails.cacheWriteTokens === undefined + ? {} + : { + cacheWriteTokens: convert( + usage.inputTokensDetails.cacheWriteTokens, + ), + }), + }, + }), + ...(usage.outputTokens === undefined + ? {} + : { outputTokens: convert(usage.outputTokens) }), + ...(usage.outputTokensDetails === undefined + ? {} + : { + outputTokensDetails: { + ...(usage.outputTokensDetails.reasoningTokens === undefined + ? {} + : { + reasoningTokens: convert( + usage.outputTokensDetails.reasoningTokens, + ), + }), + }, + }), + ...(usage.totalTokens === undefined + ? {} + : { totalTokens: convert(usage.totalTokens) }), + }; +} + export function recordedTurnToWire(turn: RecordedTurn): RecordedTurnWire { + const { responses, ...rest } = turn; return { - ...turn, + ...rest, + ...(responses === undefined + ? {} + : { + responses: responses.map((response) => ({ + ...response, + usage: + response.usage === null + ? null + : mapReceivedUsage(response.usage, (value) => + tokenCountToWire(parseTokenCount(value)), + ), + })), + }), usage: turn.usage === null ? null @@ -173,8 +314,20 @@ export function recordedTurnToWire(turn: RecordedTurn): RecordedTurnWire { } export function recordedTurnFromWire(turn: RecordedTurnWire): RecordedTurn { + const { responses, ...rest } = turn; return { - ...turn, + ...rest, + ...(responses === undefined + ? {} + : { + responses: responses.map((response) => ({ + ...response, + usage: + response.usage === null + ? null + : mapReceivedUsage(response.usage, parseTokenCount), + })), + }), usage: turn.usage === null ? null @@ -451,6 +604,33 @@ function recordedUsage(raw: JsonObject): RecordedTokenUsage { totalTokens: usageCount(raw, 'total_tokens'), }; } +function receivedUsage(raw: JsonObject): ReceivedTokenUsage { + const count = (key: string) => + raw[key] == null ? undefined : parseTokenCount(raw[key]); + const inputTokens = count('input_tokens'); + const cachedTokens = count('cached_input_tokens'); + const cacheWriteTokens = count('cache_write_input_tokens'); + const outputTokens = count('output_tokens'); + const reasoningTokens = count('reasoning_output_tokens'); + const totalTokens = count('total_tokens'); + return { + ...(inputTokens === undefined ? {} : { inputTokens }), + ...(cachedTokens === undefined && cacheWriteTokens === undefined + ? {} + : { + inputTokensDetails: { + ...(cachedTokens === undefined ? {} : { cachedTokens }), + ...(cacheWriteTokens === undefined ? {} : { cacheWriteTokens }), + }, + }), + ...(outputTokens === undefined ? {} : { outputTokens }), + ...(reasoningTokens === undefined + ? {} + : { outputTokensDetails: { reasoningTokens } }), + ...(totalTokens === undefined ? {} : { totalTokens }), + }; +} + function failure(raw: JsonObject): Failure { const info = object(raw.codex_error_info); const variant = @@ -852,6 +1032,17 @@ export function projectHistory( ) { const turns = new Map(); const usage = new Map(); + const responses = new Map(); + const observations = new Map>(); + function appendResponse( + target: Map, + id: string, + response: RecordedResponse, + ) { + const entries = target.get(id) ?? []; + entries.push(response); + target.set(id, entries); + } const responseUsage = new Map(); const incompleteCacheWriteUsage = new Set(); const failures = new Map(); @@ -903,12 +1094,50 @@ export function projectHistory( current = payload.turn_id; aborted = null; if (!order.includes(current)) order.push(current); + } else if ( + type === 'response_observation_record' && + typeof payload.turn_id === 'string' && + typeof payload.id === 'string' + ) { + const ids = observations.get(payload.turn_id) ?? new Set(); + if (!ids.has(payload.id)) { + ids.add(payload.id); + observations.set(payload.turn_id, ids); + const reason = text(payload.incomplete_reason); + appendResponse(responses, payload.turn_id, { + id: payload.id, + threadId: String(payload.thread_id), + turnId: payload.turn_id, + responseId: text(payload.response_id), + status: payload.status as RecordedResponse['status'], + ...(reason === null + ? {} + : { + incompleteReason: + reason as RecordedResponse['incompleteReason'], + }), + usageSource: 'received', + usage: object(payload.usage) + ? receivedUsage(object(payload.usage)!) + : null, + }); + } } else if ( type === 'token_usage_record' && typeof payload.turn_id === 'string' ) { const value = object(payload.turn_token_usage); if (value) usage.set(payload.turn_id, recordedUsage(value)); + appendResponse(responses, payload.turn_id, { + id: text(payload.response_observation_id), + threadId: text(payload.thread_id) ?? sessionId, + turnId: payload.turn_id, + responseId: text(payload.response_id), + usageSource: 'native_normalized', + usage: object(payload.usage) + ? recordedUsage(object(payload.usage)!) + : null, + }); } else if (type === 'response_item') { let turn = current; const metadata = object( @@ -966,6 +1195,8 @@ export function projectHistory( for (const id of removed) { turns.delete(id); usage.delete(id); + responses.delete(id); + observations.delete(id); responseUsage.delete(id); incompleteCacheWriteUsage.delete(id); failures.delete(id); @@ -1091,6 +1322,20 @@ export function projectHistory( for (const [id, turn] of turns) { const total = responseUsage.get(id); turn.usage = usage.get(id) ?? (total ? recordedUsage(total) : null); + const nativeIds = observations.get(id); + // Only an exact native link identifies the accounting copy of an observation. + const projectedIds = new Set(); + const retained = (responses.get(id) ?? []).filter((response) => { + if (response.id === null) return true; + if ( + (response.usageSource !== 'received' && nativeIds?.has(response.id)) || + projectedIds.has(response.id) + ) + return false; + projectedIds.add(response.id); + return true; + }); + if (retained.length) turn.responses = retained; } const cursorIds = new Set( items.filter(({ recordedId }) => recordedId).map(({ item }) => item.id), diff --git a/packages/agents/test/native/fixtures/model-server.ts b/packages/agents/test/native/fixtures/model-server.ts index 4ac6288b2..058cd5343 100644 --- a/packages/agents/test/native/fixtures/model-server.ts +++ b/packages/agents/test/native/fixtures/model-server.ts @@ -77,9 +77,10 @@ export class ModelServer { // Check syntax only; emit the original text so large JSON integers stay exact. if (usage !== undefined) JSON.parse(usage); const completed = - usage === undefined + this.terminalFrames?.[requestIndex] ?? + (usage === undefined ? JSON.stringify({ type: 'response.completed', response: { id } }) - : `{"type":"response.completed","response":{"id":${JSON.stringify(id)},"usage":${usage}}}`; + : `{"type":"response.completed","response":{"id":${JSON.stringify(id)},"usage":${usage}}}`); response.writeHead(200, { 'content-type': 'text/event-stream' }); const events = [ { type: 'response.created', response: { id } }, @@ -121,6 +122,7 @@ export class ModelServer { index: number, request: JsonObject, ) => void | Promise, + private readonly terminalFrames?: readonly string[], ) { this.rawUsage = Array.isArray(usage) ? usage diff --git a/packages/agents/test/native/history-counters-native.test.ts b/packages/agents/test/native/history-counters-native.test.ts index 5483a95a9..bce4194ed 100644 --- a/packages/agents/test/native/history-counters-native.test.ts +++ b/packages/agents/test/native/history-counters-native.test.ts @@ -878,6 +878,24 @@ describe( const result = await unwrap(context.track(turn.wait())); expect(result.finalOutput).toBe('original done'); assertUsageInfo(result.usage, usageOf(scenario, true), true); + const responseObservations = (await collect(session.turns()))[0] + .responses; + expect(responseObservations).toHaveLength(2); + expect(responseObservations?.[0]).toMatchObject({ + responseId: 'response-1', + status: 'completed', + usageSource: 'received', + usage: { + inputTokens: 9007199254740993n, + totalTokens: 9007199254741000n, + }, + }); + expect(responseObservations?.[1]).toMatchObject({ + responseId: 'response-2', + status: 'completed', + usageSource: 'received', + usage: { inputTokens: 7, totalTokens: 9 }, + }); const before = await session.saveState(); assertCheckpointUsageGroups( before, @@ -963,6 +981,9 @@ describe( ); await restored.compact({ summary }); expect(server.requests).toHaveLength(2); + expect((await collect(restored.turns()))[0].responses).toEqual( + responseObservations, + ); const second = await restored.saveState(); expect(second.history.slice(0, stored.history.length)).toEqual( stored.history, diff --git a/packages/agents/test/native/history-counters.test.ts b/packages/agents/test/native/history-counters.test.ts index 0ef49ffcc..a74586564 100644 --- a/packages/agents/test/native/history-counters.test.ts +++ b/packages/agents/test/native/history-counters.test.ts @@ -437,3 +437,213 @@ describe('existing format-3 manual usage wire', () => { expect(sessionStateSchema.safeParse(invalid).success).toBe(false); }); }); + +describe('recorded response observations', () => { + const observed = ( + id: string, + usage: Record | null, + extra: Record = {}, + ): ThreadHistory[number] => ({ + type: 'response_observation_record', + payload: { + id, + thread_id: 'origin-thread', + turn_id: 't', + response_id: 'reused-provider-id', + status: 'completed', + incomplete_reason: null, + usage, + ...extra, + } as ThreadHistory[number]['payload'], + }); + const accounting = (link?: string): ThreadHistory[number] => ({ + type: 'token_usage_record', + payload: { + thread_id: 'origin-thread', + turn_id: 't', + response_id: 'reused-provider-id', + usage: { input_tokens: 7, total_tokens: 9 }, + turn_token_usage: { input_tokens: 14, total_tokens: 18 }, + ...(link ? { response_observation_id: link } : {}), + }, + }); + + it('preserves optional received counters, exact counts and absent whole usage', () => { + const turn = project([ + observed('o1', { input_tokens: large, cached_input_tokens: 0 }), + accounting('o1'), + observed('o2', null, { response_id: null }), + observed('o3', {}), + ]); + expect(turn.responses).toEqual([ + { + id: 'o1', + threadId: 'origin-thread', + turnId: 't', + responseId: 'reused-provider-id', + status: 'completed', + usageSource: 'received', + usage: { + inputTokens: 9007199254740993n, + inputTokensDetails: { cachedTokens: 0 }, + }, + }, + { + id: 'o2', + threadId: 'origin-thread', + turnId: 't', + responseId: null, + status: 'completed', + usageSource: 'received', + usage: null, + }, + { + id: 'o3', + threadId: 'origin-thread', + turnId: 't', + responseId: 'reused-provider-id', + status: 'completed', + usageSource: 'received', + usage: {}, + }, + ]); + expect(turn.usage?.totalTokens).toBe(18); + const wire = recordedTurnToWire(turn); + expect(wire.responses?.[0].usage?.inputTokens).toBe(large); + expect(JSON.parse(JSON.stringify(wire))).toEqual(wire); + expect(recordedTurnFromWire(wire)).toEqual(turn); + expect(project([])).not.toHaveProperty('responses'); + }); + + it('matches accounting by exact observation link and retains occurrence order', () => { + const old = accounting(); + const fresh = observed('new', { total_tokens: 9 }); + const missing = accounting('missing-observation'); + const records = [old, fresh, accounting('new'), old, missing]; + const responses = project(records).responses!; + expect(responses.map((row) => [row.id, row.usageSource])).toEqual([ + [null, 'native_normalized'], + ['new', 'received'], + [null, 'native_normalized'], + ['missing-observation', 'native_normalized'], + ]); + expect(responses[0]).not.toHaveProperty('status'); + expect(responses[0]).not.toHaveProperty('incompleteReason'); + expect(responses[0].usage?.inputTokens).toBe(7); + expect( + project([ + ...records, + fresh, + { + type: 'compacted', + payload: { latest_token_usage_record: accounting('new').payload }, + }, + ]).responses, + ).toEqual(responses); + // Projecting inherited history must not replace its native origin with the SDK session ID. + expect( + projectHistory([...start, ...records, ...finish], 'fork').turns[0] + .responses, + ).toEqual(responses); + expect(project(records).responses).toEqual(responses); + }); + + it('deduplicates linked accounting fallbacks and preserves unlinked occurrences', () => { + const records = [ + accounting('missing-observation'), + accounting(), + accounting('missing-observation'), + accounting('received-observation'), + accounting(), + observed('received-observation', { total_tokens: 9 }), + ]; + const turn = project(records); + expect( + turn.responses?.map(({ id, usageSource }) => [id, usageSource]), + ).toEqual([ + ['missing-observation', 'native_normalized'], + [null, 'native_normalized'], + [null, 'native_normalized'], + ['received-observation', 'received'], + ]); + expect(turn.usage).toEqual(project([accounting()]).usage); + }); + + it('retains provider terminal facts independently of SDK failure', () => { + for (const status of ['completed', 'incomplete', 'failed']) { + const turn = project([ + observed( + 'o', + { total_tokens: 0 }, + { + status, + incomplete_reason: + status === 'incomplete' ? 'max_output_tokens' : null, + }, + ), + { + type: 'event_msg', + payload: { + type: 'error', + turn_id: 't', + message: 'fixture rejection', + }, + }, + ]); + expect(turn.status).toBe('failed'); + expect(turn.responses?.[0].status).toBe(status); + expect(turn.responses?.[0].usage?.totalTokens).toBe(0); + } + expect( + project([ + { type: 'event_msg', payload: { type: 'error', turn_id: 't' } }, + ]), + ).not.toHaveProperty('responses'); + }); + + it('removes response observations and link indexes with rolled-back turns', () => { + const history: ThreadHistory = [ + ...start, + observed('same', { total_tokens: 1 }), + accounting('same'), + ...finish, + { + type: 'event_msg', + payload: { type: 'thread_rolled_back', num_turns: 1 }, + }, + ...start, + observed('same', { total_tokens: 2 }), + accounting('same'), + ...finish, + ]; + expect(projectHistory(history, 'session').turns[0].responses).toHaveLength( + 1, + ); + expect( + projectHistory(history, 'session').turns[0].responses?.[0].usage + ?.totalTokens, + ).toBe(2); + }); + + it('strictly validates repair response wire counters while accepting old receipts', () => { + const turn = { + ...recordedTurnToWire(project([observed('o', { total_tokens: large })])), + parentSessionId: 'parent', + parentTurnId: 'parent-turn', + }; + expect(repairTurnWireSchema.parse(turn)).toEqual(turn); + const { responses: _responses, ...legacy } = turn; + expect(repairTurnWireSchema.parse(legacy)).toEqual(legacy); + for (const counter of ['01', '-1', 1.5, Number.MAX_SAFE_INTEGER + 1]) { + const invalid = structuredClone(turn); + invalid.responses![0].usage!.totalTokens = counter; + expect(repairTurnWireSchema.safeParse(invalid).success).toBe(false); + } + expect( + repairTurnWireSchema.safeParse({ + ...turn, + responses: [{ ...turn.responses![0], rawProviderError: 'private' }], + }).success, + ).toBe(false); + }); +}); diff --git a/packages/agents/test/native/parity-data.test.ts b/packages/agents/test/native/parity-data.test.ts index 88792e19f..970305bc9 100644 --- a/packages/agents/test/native/parity-data.test.ts +++ b/packages/agents/test/native/parity-data.test.ts @@ -207,7 +207,13 @@ describe('Python data API parity', () => { [ { type: 'token_usage_record', - payload: { turn_id: 'turn', turn_token_usage: counts }, + payload: { + thread_id: 'native-thread', + turn_id: 'turn', + response_id: 'response', + usage: counts, + turn_token_usage: counts, + }, }, { type: 'event_msg', @@ -247,6 +253,22 @@ describe('Python data API parity', () => { message: 'busy', httpStatusCode: 503, }, + responses: [ + { + id: null, + threadId: 'native-thread', + turnId: 'turn', + responseId: 'response', + usageSource: 'native_normalized', + usage: { + inputTokens: 17, + inputTokensDetails: { cachedTokens: 3, cacheWriteTokens: 4 }, + outputTokens: 7, + outputTokensDetails: { reasoningTokens: 2 }, + totalTokens: 24, + }, + }, + ], usage: { inputTokens: 17, inputTokensDetails: { cachedTokens: 3, cacheWriteTokens: 4 }, diff --git a/packages/agents/test/native/response-history-native.test.ts b/packages/agents/test/native/response-history-native.test.ts new file mode 100644 index 000000000..a99d8f57e --- /dev/null +++ b/packages/agents/test/native/response-history-native.test.ts @@ -0,0 +1,285 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { Agent, NativeProvider, tool, type RecordedTurn } from '../../src'; +import { ModelServer, call, message } from './fixtures/model-server'; + +afterEach(() => ModelServer.closeAll()); + +async function providerFor(server: ModelServer, noRetries = false) { + const config = await server.config(); + const provider = new NativeProvider({ + baseUrl: (config['model_providers.fixture'] as { base_url: string }) + .base_url, + apiKey: 'synthetic-response-history', + supportsWebsockets: false, + }); + if (noRetries) { + // Select Core's existing test configuration; preserve production retry policy. + const acquire = provider._acquire.bind(provider); + vi.spyOn(provider, '_acquire').mockImplementationOnce((captured) => { + const lease = acquire(captured); + lease.config['model_providers.native_agent.request_max_retries'] = 0; + lease.config['model_providers.native_agent.stream_max_retries'] = 0; + return lease; + }); + } + return provider; +} +async function turnsOf(session: { turns(): AsyncIterable }) { + const turns: RecordedTurn[] = []; + for await (const turn of session.turns()) turns.push(turn); + return turns; +} + +it('retains two response occurrences with repeated provider IDs through save, restore and fork', async () => { + const terminal = + '{"type":"response.completed","response":{"id":"same","usage":{"input_tokens":11,"input_tokens_details":{"cached_tokens":0},"output_tokens":2,"total_tokens":13}}}'; + const server = new ModelServer( + [[call('probe', {}, { callId: 'probe' })], [message('done')]], + [], + undefined, + [terminal, terminal], + ); + await using provider = await providerFor(server); + const agent = new Agent({ + instructions: 'Use probe then answer.', + model: 'gpt-5.5', + provider, + tools: [ + tool({ name: 'probe', parameters: z.object({}), execute: () => 'ok' }), + ], + }); + await using session = await agent.session(); + const turn = await session.submit('go'); + await turn.wait(); + const recorded = (await turnsOf(session))[0]; + expect(recorded.usage?.totalTokens).toBe(26); + expect(recorded.responses).toHaveLength(2); + expect(new Set(recorded.responses!.map((row) => row.id)).size).toBe(2); + expect( + recorded.responses!.every( + (row) => + row.id !== null && + row.responseId === 'same' && + row.status === 'completed' && + row.usageSource === 'received', + ), + ).toBe(true); + expect(recorded.responses![0].usage).toEqual({ + inputTokens: 11, + inputTokensDetails: { cachedTokens: 0 }, + outputTokens: 2, + totalTokens: 13, + }); + const state = await session.saveState(); + await using restored = await agent.session({ state }); + expect((await turnsOf(restored))[0].responses).toEqual(recorded.responses); + const fork = await session.fork(); + await using inherited = await agent.session({ state: fork }); + expect((await turnsOf(inherited))[0].responses).toEqual(recorded.responses); + expect((await turnsOf(session))[0].responses).toEqual(recorded.responses); + server.assertExhausted(); +}, 30_000); + +for (const reason of ['max_output_tokens', 'content_filter']) { + it(`retains ${reason} incomplete facts without retrying inference`, async () => { + const terminal = JSON.stringify({ + type: 'response.incomplete', + response: { + id: 'partial', + incomplete_details: { reason }, + usage: { + input_tokens: 7, + output_tokens: 0, + total_tokens: 7, + }, + }, + }); + const server = new ModelServer([[]], [], undefined, [terminal]); + await using provider = await providerFor(server, true); + const agent = new Agent({ + instructions: 'Answer.', + model: 'gpt-5.5', + provider, + }); + await using session = await agent.session(); + const submission = await session.submit('go'); + await expect(submission.wait()).rejects.toThrow(); + const recorded = (await turnsOf(session))[0]; + expect(recorded.status).toBe('failed'); + expect(recorded.responses).toEqual([ + expect.objectContaining({ + responseId: 'partial', + status: 'incomplete', + incompleteReason: reason, + usageSource: 'received', + usage: { inputTokens: 7, outputTokens: 0, totalTokens: 7 }, + }), + ]); + expect(server.requests).toHaveLength(1); + server.assertExhausted(); + }, 30_000); +} + +it('retains provider completed facts when SDK integrity rejects output', async () => { + const server = new ModelServer([[]]); + await using provider = await providerFor(server); + const agent = new Agent({ + instructions: 'Answer.', + model: 'gpt-5.5', + provider, + }); + await using session = await agent.session(); + const submission = await session.submit('go'); + await expect(submission.wait()).rejects.toThrow(); + const recorded = (await turnsOf(session))[0]; + expect(recorded.status).toBe('failed'); + expect(recorded.responses).toEqual([ + expect.objectContaining({ status: 'completed', usage: null }), + ]); + expect(server.requests).toHaveLength(1); + server.assertExhausted(); +}, 30_000); + +it('preserves default retry behavior and keeps failed received usage out of turn totals', async () => { + const incomplete = JSON.stringify({ + type: 'response.incomplete', + response: { + id: 'partial', + incomplete_details: { reason: 'max_output_tokens' }, + usage: { input_tokens: 7, output_tokens: 0, total_tokens: 7 }, + }, + }); + const completed = JSON.stringify({ + type: 'response.completed', + response: { + id: 'finished', + usage: { input_tokens: 11, output_tokens: 3, total_tokens: 14 }, + }, + }); + const server = new ModelServer([[], [message('done')]], [], undefined, [ + incomplete, + completed, + ]); + await using provider = await providerFor(server); + const agent = new Agent({ + instructions: 'Answer.', + model: 'gpt-5.5', + provider, + }); + await using session = await agent.session(); + const submission = await session.submit('go'); + expect((await submission.wait()).finalOutput).toBe('done'); + const recorded = (await turnsOf(session))[0]; + expect(recorded.status).toBe('completed'); + expect( + recorded.responses?.map((row) => [ + row.status, + row.responseId, + row.usage?.totalTokens, + ]), + ).toEqual([ + ['incomplete', 'partial', 7], + ['completed', 'finished', 14], + ]); + expect(new Set(recorded.responses?.map((row) => row.id)).size).toBe(2); + expect(recorded.usage?.totalTokens).toBe(14); + expect(server.requests).toHaveLength(2); + server.assertExhausted(); +}, 30_000); + +it('keeps negative counters unknown in retained prompted-repair history across save/restore', async () => { + const original = JSON.stringify({ + type: 'response.completed', + response: { + id: 'original', + usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 }, + }, + }); + const incomplete = JSON.stringify({ + type: 'response.incomplete', + response: { + id: 'malformed-correction', + incomplete_details: { reason: 'max_output_tokens' }, + usage: { + input_tokens: -7, + input_tokens_details: { cached_tokens: 0, cache_write_tokens: -1 }, + output_tokens: 0, + output_tokens_details: { reasoning_tokens: -3 }, + total_tokens: 7, + }, + }, + }); + const completed = JSON.stringify({ + type: 'response.completed', + response: { + id: 'finished-correction', + usage: { input_tokens: 11, output_tokens: 3, total_tokens: 14 }, + }, + }); + const server = new ModelServer( + [[message('{"label":"x"}')], [], [message('{"label":"corrected"}')]], + [], + undefined, + [original, incomplete, completed], + ); + await using provider = await providerFor(server); + const agent = new Agent({ + instructions: 'Answer.', + model: 'gpt-5.5', + provider, + outputType: { + mode: 'prompted', + schema: z.object({ label: z.string().min(2) }), + maxRetries: 1, + }, + }); + await using session = await agent.session(); + const turn = await session.submit('go'); + const result = await turn.wait(); + // Validate retained correction observations before inspecting ordinary history. + const state = await session.saveState(); + expect(result.finalOutput).toEqual({ label: 'corrected' }); + expect(result.repairAttempts).toHaveLength(1); + const attempt = result.repairAttempts[0]; + expect(attempt).toMatchObject({ + parentSessionId: session.id, + parentTurnId: turn.turnId, + status: 'completed', + usage: { inputTokens: 11, outputTokens: 3, totalTokens: 14 }, + }); + expect(attempt.sessionId).not.toBe(session.id); + expect(attempt.responses).toEqual([ + expect.objectContaining({ + responseId: 'malformed-correction', + status: 'incomplete', + usageSource: 'received', + usage: { + inputTokensDetails: { cachedTokens: 0 }, + outputTokens: 0, + totalTokens: 7, + }, + }), + expect.objectContaining({ + responseId: 'finished-correction', + status: 'completed', + usageSource: 'received', + usage: { inputTokens: 11, outputTokens: 3, totalTokens: 14 }, + }), + ]); + expect(new Set(attempt.responses?.map((row) => row.id)).size).toBe(2); + const recorded = await turnsOf(session); + expect(recorded).toHaveLength(2); + expect(recorded[0].usage?.totalTokens).toBe(7); + expect(recorded[1]).toEqual(attempt); + if (state.format_version !== 4) + throw new Error('Expected output-aware idle state'); + expect(state.repair_turns).toHaveLength(1); + expect(state.repair_turns[0].responses).toHaveLength(2); + await using restored = await agent.session({ state }); + expect(await turnsOf(restored)).toEqual(recorded); + expect((await restored.saveState()).format_version).toBe(4); + expect(await turnsOf(session)).toEqual(recorded); + expect(server.requests).toHaveLength(3); + server.assertExhausted(); +}, 30_000); From 16265ea7daeafda692c55f62d99303ff05e9c51d Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:23:47 +0000 Subject: [PATCH 04/34] Allow bounded editing of native request presentation --- .changeset/native-prepared-request.md | 5 + packages/agents/examples/prepared-request.ts | 109 ++ packages/agents/native/Cargo.lock | 3 +- packages/agents/native/index.d.ts | 1 + .../0054-native-prepared-request.patch | 602 ++++++++++ packages/agents/native/patches/series.json | 6 +- packages/agents/native/rust/checkpoint.rs | 3 + packages/agents/native/rust/extensions.rs | 3 + packages/agents/native/rust/lib.rs | 23 + .../agents/native/rust/prepared_requests.rs | 366 ++++++ packages/agents/native/rust/python_tools.rs | 29 +- .../agents/native/rust/schema_fidelity.rs | 287 ++++- packages/agents/src/index.ts | 10 +- .../agents/src/runtime/prepared-request.ts | 91 ++ packages/agents/src/runtime/public-agent.ts | 37 + packages/agents/src/runtime/session.ts | 168 ++- .../native/prepared-request-native.test.ts | 1010 +++++++++++++++++ packages/agents/test/prepared-request.test.ts | 199 ++++ 18 files changed, 2937 insertions(+), 15 deletions(-) create mode 100644 .changeset/native-prepared-request.md create mode 100644 packages/agents/examples/prepared-request.ts create mode 100644 packages/agents/native/patches/0054-native-prepared-request.patch create mode 100644 packages/agents/native/rust/prepared_requests.rs create mode 100644 packages/agents/src/runtime/prepared-request.ts create mode 100644 packages/agents/test/native/prepared-request-native.test.ts create mode 100644 packages/agents/test/prepared-request.test.ts diff --git a/.changeset/native-prepared-request.md b/.changeset/native-prepared-request.md new file mode 100644 index 000000000..f28ff92c4 --- /dev/null +++ b/.changeset/native-prepared-request.md @@ -0,0 +1,5 @@ +--- +'@openai/agents': minor +--- + +feat: Add per-operation prepared-request editing for native HTTP root conversations while preserving original tool and history authority. diff --git a/packages/agents/examples/prepared-request.ts b/packages/agents/examples/prepared-request.ts new file mode 100644 index 000000000..13f3ef4e4 --- /dev/null +++ b/packages/agents/examples/prepared-request.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import { z } from 'zod'; +import { Agent, Runner, ToolOutput, tool } from '@openai/agents'; +import { text, toolCall, withScriptedResponses } from '@openai/agents/testing'; + +/** + * Exercise the public request, argument and result controls without live credentials. + * The scripted provider uses explicit HTTP and a standard Responses (non-Lite) model. + * Keep eager ordinary tools and a direct root; + * Environment, code mode, children and automatic compaction are outside this profile. + * Sampling edits do not change canonical history, executable schemas or policies. + * For structured output, use native mode or prompted mode with maxRetries: 0. + */ +export async function runPreparedRequestExample(): Promise { + const effects: string[] = []; + const lookup = tool({ + name: 'lookup', + description: 'Look up a synthetic record.', + parameters: z.object({ key: z.enum(['allowed']), label: z.string() }), + before: ({ callId, originalArguments }) => { + assert.equal(callId, 'lookup'); + assert.deepEqual(originalArguments, { + key: 'allowed', + label: 'original', + }); + return { + action: 'replaceArguments', + arguments: { key: 'allowed' as const, label: 'corrected' }, + }; + }, + execute: ({ key, label }) => { + effects.push(label); + return `Raw record ${key}`; + }, + after: (_output, { callId, effectiveArguments }) => { + assert.equal(callId, 'lookup'); + assert.equal(effectiveArguments.label, 'corrected'); + return new ToolOutput([{ type: 'inputText', text: 'SHAPED_RESULT' }]); + }, + }); + return withScriptedResponses( + [ + [ + toolCall( + 'lookup', + { key: 'allowed', label: 'original' }, + { callId: 'lookup' }, + ), + ], + [text('The record is allowed.')], + ], + async (peer) => { + const result = await Runner.run( + new Agent({ + model: 'gpt-5.5', + instructions: 'Call lookup, then explain its result.', + provider: peer.provider, + tools: [lookup], + }), + 'Look up the record.', + { + prepareRequest({ request, signal }) { + signal.throwIfAborted(); + // Recovery can advance the index without a result. Inspect native input. + if ( + !request.input.some( + (item) => + item !== null && + typeof item === 'object' && + !Array.isArray(item) && + item.type === 'function_call_output' && + item.call_id === 'lookup', + ) + ) + return; + request.instructions = 'Explain the lookup result in one sentence.'; + request.input.push({ + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'SAMPLING_ONLY_NOTE' }], + }); + request.tools[0].description = 'Sampled record description.'; + request.tools[0].parameters.properties = { + key: { type: 'string', enum: ['allowed', 'sampled-only'] }, + label: { type: 'string' }, + }; + return request; + }, + }, + ); + assert.deepEqual(effects, ['corrected']); + assert.equal(peer.toolOutput('lookup'), 'SHAPED_RESULT'); + assert.equal(peer.requests.length, 2); + assert.match( + JSON.stringify(peer.requests[1].input), + /SAMPLING_ONLY_NOTE/, + ); + assert.match( + JSON.stringify(peer.requests[1].tools), + /Sampled record description/, + ); + assert.match( + JSON.stringify(peer.requests[1].input), + /Explain the lookup result in one sentence/, + ); + return result.finalOutput; + }, + ); +} diff --git a/packages/agents/native/Cargo.lock b/packages/agents/native/Cargo.lock index 1ed46c8de..2fa95c134 100644 --- a/packages/agents/native/Cargo.lock +++ b/packages/agents/native/Cargo.lock @@ -1697,8 +1697,8 @@ dependencies = [ "serde", "serde_ignored", "serde_json", - "serde_yaml", "serde_path_to_error", + "serde_yaml", "sha2 0.10.9", "strum", "strum_macros 0.28.0", @@ -2411,6 +2411,7 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-path-uri", "futures", + "serde", "serde_json", ] diff --git a/packages/agents/native/index.d.ts b/packages/agents/native/index.d.ts index 49cc83461..c14ccb61b 100644 --- a/packages/agents/native/index.d.ts +++ b/packages/agents/native/index.d.ts @@ -157,6 +157,7 @@ export class NativeThread { _update_model_settings(settings_json: string): Promise; _compact(): Promise; _execute_command(name: string, arguments_: string): Promise; + _reply_prepared_request(responseJson: string): void; _reply_step(response_json: string): void; _check_step(response_json: string): boolean; _reply_child_step(response_json: string): boolean; diff --git a/packages/agents/native/patches/0054-native-prepared-request.patch b/packages/agents/native/patches/0054-native-prepared-request.patch new file mode 100644 index 000000000..cde50c700 --- /dev/null +++ b/packages/agents/native/patches/0054-native-prepared-request.patch @@ -0,0 +1,602 @@ +diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock +index 295d1f826b19efa6e781761ca0c3d8bba6dd0d8a..61d3e12d4c59edaed9778a9bcc2cc4c65bf7e4e2 100644 +--- a/codex-rs/Cargo.lock ++++ b/codex-rs/Cargo.lock +@@ -3360,6 +3360,7 @@ dependencies = [ + "codex-utils-path-uri", + "futures", + "pretty_assertions", ++ "serde", + "serde_json", + "tokio", + ] +diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs +index 55f091582aa2ebfe4bee24de2f65285c3bc9f625..9ff682dfffe02ab464d03a64fa3349f8afd0f9e8 100644 +--- a/codex-rs/core/src/client.rs ++++ b/codex-rs/core/src/client.rs +@@ -1781,6 +1781,9 @@ impl ModelClientSession { + ) + .with_telemetry(Some(request_telemetry), Some(sse_telemetry)); + crate::model_request::wait_if_sampling_stopped(prompt.sampling_stop.as_deref()).await; ++ if let Some(admission) = &prompt.prepared_request_admission { ++ admission.admit_request().map_err(CodexErr::Fatal)?; ++ } + let stream_result = client.stream_request(request, options).await; + + match stream_result { +diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs +index 6188306e2ae26a96b758f2359697dc4ad4654b36..764b7b82562a831a482ce8c76187d4e1bc46e288 100644 +--- a/codex-rs/core/src/client_common.rs ++++ b/codex-rs/core/src/client_common.rs +@@ -34,6 +34,10 @@ pub struct Prompt { + /// Stops new provider requests for this turn, including prompts without tools. + pub(crate) sampling_stop: Option>, + ++ /// Request-scoped host ownership, checked independently of response policies. ++ pub(crate) prepared_request_admission: ++ Option>, ++ + /// Whether parallel tool calls are permitted for this prompt. + pub(crate) parallel_tool_calls: bool, + +@@ -55,6 +59,7 @@ impl Default for Prompt { + tools: Arc::default(), + tool_admission: None, + sampling_stop: None, ++ prepared_request_admission: None, + parallel_tool_calls: false, + base_instructions: BaseInstructions::default(), + output_schema: None, +diff --git a/codex-rs/core/src/compact_remote_v2_attempt.rs b/codex-rs/core/src/compact_remote_v2_attempt.rs +index c302aac22a6b055739501c55033aec814eb7a248..6843656823fe6a779c6d12ad191728510d1e1e98 100644 +--- a/codex-rs/core/src/compact_remote_v2_attempt.rs ++++ b/codex-rs/core/src/compact_remote_v2_attempt.rs +@@ -98,6 +98,7 @@ pub(super) async fn run_remote_compact_v2_attempt( + Arc::clone(tool_router), + )), + sampling_stop: crate::model_request::turn_sampling_stop(&turn_context.extension_data), ++ prepared_request_admission: None, + parallel_tool_calls: true, + base_instructions, + output_schema: None, +diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs +index ba7896a2533635a63324a2f58dd4724d9dcf125a..0ff43989e5386f050cf35e5db69925d0c8568fc8 100644 +--- a/codex-rs/core/src/lib.rs ++++ b/codex-rs/core/src/lib.rs +@@ -11,6 +11,7 @@ mod apps; + mod client; + mod client_common; + mod model_request; ++mod prepared_request; + mod realtime_context; + mod realtime_conversation; + mod realtime_history; +diff --git a/codex-rs/core/src/prepared_request.rs b/codex-rs/core/src/prepared_request.rs +new file mode 100644 +index 0000000000000000000000000000000000000000..a70e5faf6d3b77f8fa6d90f9c92276aed1cb8ec0 +--- /dev/null ++++ b/codex-rs/core/src/prepared_request.rs +@@ -0,0 +1,289 @@ ++//! Sampling-copy editing, separate from the selected execution plan and canonical history. ++use crate::client_common::Prompt; ++use codex_extension_api::{PreparedRequestData, PreparedToolPresentation}; ++use codex_protocol::models::ResponseItem; ++use codex_tools::{JsonSchema, ResponsesApiNamespaceTool, ResponsesApiTool, ToolSpec}; ++use serde::Serialize; ++use serde::de::DeserializeOwned; ++use serde_json::Value; ++ ++const PROFILE: &str = "prepare_request requires a standard Responses (non-Lite) model, Responses HTTP (supports_websockets=false), and direct-root eager function/MCP tools"; ++ ++/// Preserve submitted keys, including null values, while allowing native defaults. ++fn preserves(submitted: &Value, native: &Value) -> bool { ++ match (submitted, native) { ++ (Value::Object(a), Value::Object(b)) => a ++ .iter() ++ .all(|(key, value)| b.get(key).is_some_and(|actual| preserves(value, actual))), ++ (Value::Array(a), Value::Array(b)) => { ++ a.len() == b.len() && a.iter().zip(b).all(|(a, b)| preserves(a, b)) ++ } ++ _ => submitted == native, ++ } ++} ++ ++fn parse_preserving(value: Value) -> Result { ++ let parsed: T = serde_json::from_value(value.clone()) ++ .map_err(|_| "invalid native prepared-request value".to_string())?; ++ let canonical = serde_json::to_value(&parsed) ++ .map_err(|_| "cannot serialize prepared-request value".to_string())?; ++ if !preserves(&value, &canonical) { ++ return Err("prepared-request value loses fields in native serialization; omit skipped optional fields instead of explicit null".into()); ++ } ++ Ok(parsed) ++} ++ ++fn presentation( ++ namespace: Option<&str>, ++ tool: &ResponsesApiTool, ++) -> Result { ++ if tool.defer_loading == Some(true) { ++ return Err(PROFILE.into()); ++ } ++ Ok(PreparedToolPresentation { ++ namespace: namespace.map(str::to_owned), ++ name: tool.name.clone(), ++ kind: "function".into(), ++ strict: tool.strict, ++ defer_loading: tool.defer_loading, ++ description: tool.description.clone(), ++ parameters: serde_json::to_value(&tool.parameters) ++ .map_err(|_| "cannot serialize native schema".to_string())?, ++ }) ++} ++ ++pub(crate) fn snapshot(prompt: &Prompt) -> Result { ++ let mut tools = Vec::new(); ++ for spec in prompt.tools.iter() { ++ match spec { ++ ToolSpec::Function(tool) => tools.push(presentation(None, tool)?), ++ ToolSpec::Namespace(namespace) => { ++ for leaf in &namespace.tools { ++ match leaf { ++ ResponsesApiNamespaceTool::Function(tool) => { ++ tools.push(presentation(Some(&namespace.name), tool)?) ++ } ++ _ => return Err(PROFILE.into()), ++ } ++ } ++ } ++ _ => return Err(PROFILE.into()), ++ } ++ } ++ Ok(PreparedRequestData { ++ instructions: prompt.base_instructions.text.clone(), ++ input: prompt ++ .input ++ .iter() ++ .map(serde_json::to_value) ++ .collect::>() ++ .map_err(|_| "cannot serialize native input".to_string())?, ++ tools, ++ }) ++} ++ ++fn edit_tool( ++ namespace: Option<&str>, ++ tool: &mut ResponsesApiTool, ++ edit: &PreparedToolPresentation, ++) -> Result<(), String> { ++ let original = presentation(namespace, tool)?; ++ if edit.namespace != original.namespace ++ || edit.name != original.name ++ || edit.kind != original.kind ++ || edit.strict != original.strict ++ || edit.defer_loading != original.defer_loading ++ { ++ return Err( ++ "prepared-request tool identity, order, kind, strict and defer_loading are immutable" ++ .into(), ++ ); ++ } ++ if !edit.parameters.is_object() { ++ return Err("prepared-request parameters must be a supported schema object".into()); ++ } ++ if edit.parameters != original.parameters { ++ let mut schema: JsonSchema = parse_preserving(edit.parameters.clone())?; ++ // Retain native-only MCP bookkeeping; the executable owner remains untouched. ++ schema.mcp_input_schema_max_bytes = tool.parameters.mcp_input_schema_max_bytes; ++ tool.parameters = schema; ++ } ++ tool.description = edit.description.clone(); ++ Ok(()) ++} ++ ++pub(crate) fn apply(original: &Prompt, data: PreparedRequestData) -> Result { ++ let before = snapshot(original)?; ++ if before.tools.len() != data.tools.len() { ++ return Err("prepared-request tool count is immutable".into()); ++ } ++ let input: Vec = data ++ .input ++ .into_iter() ++ .enumerate() ++ .map(|(index, value)| { ++ parse_preserving(value) ++ .map_err(|category| format!("prepared-request input[{index}]: {category}")) ++ }) ++ .collect::>()?; ++ let mut tools = original.tools.to_vec(); ++ let mut edits = data.tools.iter(); ++ for spec in &mut tools { ++ match spec { ++ ToolSpec::Function(tool) => { ++ edit_tool(None, tool, edits.next().ok_or("missing tool presentation")?)? ++ } ++ ToolSpec::Namespace(namespace) => { ++ for leaf in &mut namespace.tools { ++ match leaf { ++ ResponsesApiNamespaceTool::Function(tool) => edit_tool( ++ Some(&namespace.name), ++ tool, ++ edits.next().ok_or("missing tool presentation")?, ++ )?, ++ _ => return Err(PROFILE.into()), ++ } ++ } ++ } ++ _ => return Err(PROFILE.into()), ++ } ++ } ++ let mut result = original.clone(); ++ result.input = input; ++ result.base_instructions.text = data.instructions; ++ result.tools = tools.into(); ++ Ok(result) ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ use serde_json::json; ++ ++ #[test] ++ fn native_text_function_and_mcp_items_preserve_submitted_fields() { ++ for item in [ ++ json!({"type":"message","role":"user","content":[{"type":"input_text","text":"note"}]}), ++ json!({"type":"function_call","name":"reserve","arguments":"{\"seats\":2}","call_id":"call-1"}), ++ json!({"type":"function_call_output","call_id":"call-1","output":"SHAPED_RESULT"}), ++ json!({"type":"function_call","namespace":"mcp__local","name":"reserve","arguments":"{}","call_id":"mcp-1"}), ++ json!({"type":"function_call_output","call_id":"mcp-1","output":"ok"}), ++ ] { ++ assert!(parse_preserving::(item).is_ok()); ++ } ++ } ++ ++ #[test] ++ fn unknown_nested_null_and_skipped_optional_null_are_rejected() { ++ for item in [ ++ json!({"type":"message","role":"user","content":[{"type":"input_text","text":"note","unknown":null}]}), ++ json!({"type":"message","role":"user","id":null,"content":[]}), ++ ] { ++ assert!(parse_preserving::(item).is_err()); ++ } ++ } ++ ++ #[test] ++ fn native_schema_rejects_discarded_keywords_without_sanitizing() { ++ assert!(parse_preserving::(json!({"type":"integer","maximum":99})).is_err()); ++ assert!( ++ parse_preserving::(json!({"type":"string","enum":["one","two"]})).is_ok() ++ ); ++ } ++ ++ fn function(name: &str) -> ResponsesApiTool { ++ ResponsesApiTool { ++ name: name.into(), ++ description: "original".into(), ++ strict: false, ++ defer_loading: None, ++ parameters: serde_json::from_value(json!({"type":"string","enum":["one"]})).unwrap(), ++ output_schema: None, ++ } ++ } ++ ++ #[test] ++ fn edit_changes_only_detached_sampling_presentation_and_input() { ++ let original = Prompt { ++ tools: vec![ ++ ToolSpec::Function(function("local")), ++ ToolSpec::Namespace(codex_tools::ResponsesApiNamespace { ++ name: "mcp__local".into(), ++ description: "container".into(), ++ tools: vec![ResponsesApiNamespaceTool::Function(function("remote"))], ++ }), ++ ] ++ .into(), ++ ..Default::default() ++ }; ++ let original_tools = original.tools.clone(); ++ let mut data = snapshot(&original).unwrap(); ++ data.instructions = "edited".into(); ++ data.input.push(json!({"type":"message","role":"user","content":[{"type":"input_text","text":"sampling only"}]})); ++ for tool in &mut data.tools { ++ tool.description = "edited description".into(); ++ tool.parameters = json!({"type":"string","enum":["one","two"]}); ++ } ++ let edited = apply(&original, data).unwrap(); ++ assert_eq!( ++ snapshot(&edited).unwrap().tools[1].namespace.as_deref(), ++ Some("mcp__local") ++ ); ++ assert_eq!(edited.base_instructions.text, "edited"); ++ assert_eq!(edited.input.len(), 1); ++ assert!(original.input.is_empty()); ++ assert_eq!(original.tools, original_tools); ++ assert_eq!( ++ snapshot(&original).unwrap().tools[0].description, ++ "original" ++ ); ++ match &edited.tools[1] { ++ ToolSpec::Namespace(namespace) => assert_eq!(namespace.description, "container"), ++ _ => panic!("namespace was lost"), ++ } ++ } ++ ++ #[test] ++ fn tool_mutations_and_invalid_schemas_apply_nothing() { ++ let original = Prompt { ++ tools: vec![ToolSpec::Function(function("local"))].into(), ++ ..Default::default() ++ }; ++ let before = snapshot(&original).unwrap(); ++ for mutation in 0..6 { ++ let mut data = before.clone(); ++ match mutation { ++ 0 => data.tools[0].name = "invented".into(), ++ 1 => data.tools[0].strict = true, ++ 2 => data.tools[0].defer_loading = Some(true), ++ 3 => data.tools.clear(), ++ 4 => data.tools[0].parameters = json!({"maximum":99}), ++ _ => data.tools[0].namespace = Some("other".into()), ++ } ++ assert!(apply(&original, data).is_err()); ++ assert_eq!(snapshot(&original).unwrap(), before); ++ } ++ } ++ ++ #[test] ++ fn unsupported_selected_presentations_fail_before_callback_entry() { ++ let mut tool = function("deferred"); ++ tool.defer_loading = Some(true); ++ let original = Prompt { ++ tools: vec![ToolSpec::Function(tool)].into(), ++ ..Default::default() ++ }; ++ assert!(snapshot(&original).is_err()); ++ } ++ ++ #[test] ++ fn defaults_may_be_added_but_array_shape_and_scalar_values_must_stay() { ++ assert!(preserves( ++ &json!({"a":[1]}), ++ &json!({"a":[1],"default":null}) ++ )); ++ assert!(!preserves(&json!({"a":null}), &json!({}))); ++ assert!(!preserves(&json!([1]), &json!([1, 2]))); ++ assert!(!preserves(&json!(1), &json!(2))); ++ } ++} +diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs +index 6995888c4fff7fd6fd135db322e85db302c09462..e3ef2d324922a0ffbaed57218fd3484774208917 100644 +--- a/codex-rs/core/src/session/turn.rs ++++ b/codex-rs/core/src/session/turn.rs +@@ -1459,6 +1459,16 @@ async fn run_auto_compact( + phase: CompactionPhase, + ) -> CodexResult<()> { + let turn_context = &step_context.turn; ++ if sess ++ .services ++ .extensions ++ .model_request_contributors() ++ .iter() ++ .any(|contributor| contributor.prepares_request(&turn_context.extension_data)) ++ { ++ return Err(CodexErr::Fatal("automatic compaction is unsupported for an operation with prepare_request; shorten the input or start a new operation".into())); ++ } ++ + if matches!(phase, CompactionPhase::PreTurn) + && crate::guardian::is_basic_session_source(&turn_context.session_source) + && !crate::guardian::should_compact_guardian_input(sess)? +@@ -1602,6 +1612,7 @@ pub(crate) fn build_prompt( + parallel_tool_calls: true, + tool_admission: None, + sampling_stop: None, ++ prepared_request_admission: None, + base_instructions, + output_schema: turn_context.final_output_json_schema.clone(), + output_schema_strict: !crate::guardian::is_basic_session_source( +@@ -1653,6 +1664,7 @@ async fn run_sampling_request( + ); + let max_retries = turn_context.provider.info().stream_max_retries(); + let mut retry_state = ResponsesStreamRetryState::default(); ++ let mut recovery_attempt = 0; + let mut initial_input = Some(input); + let mut original_input = None; + let mut executed_tool_calls_by_output = HashMap::new(); +@@ -1685,6 +1697,27 @@ async fn run_sampling_request( + let responses_metadata = sess + .responses_metadata(step_context.as_ref(), CodexResponsesRequestKind::Turn) + .await; ++ let editors: Vec<_> = sess ++ .services ++ .extensions ++ .model_request_contributors() ++ .iter() ++ .filter(|contributor| contributor.prepares_request(&turn_context.extension_data)) ++ .collect(); ++ if !editors.is_empty() { ++ if editors.len() != 1 ++ || turn_context.provider.info().supports_websockets ++ || step_context.settings.model_info.use_responses_lite ++ || step_context.realtime.active ++ || turn_context.config.features.enabled(Feature::CodeMode) ++ || matches!( ++ turn_context.session_source, ++ SessionSource::SubAgent(_) | SessionSource::Internal(_) ++ ) ++ { ++ return Err(CodexErr::Fatal("prepare_request requires a standard Responses (non-Lite) model, Responses HTTP (supports_websockets=false), and direct-root eager function/MCP tools".into())); ++ } ++ } + if crate::guardian::is_basic_session_source(&turn_context.session_source) { + crate::guardian::prepare_guardian_prompt( + &sess, +@@ -1694,6 +1727,33 @@ async fn run_sampling_request( + ) + .await?; + } ++ // The original prompt remains authoritative for history and retry bookkeeping. ++ let mut sampling_prompt = prompt.clone(); ++ if !editors.is_empty() { ++ let data = crate::prepared_request::snapshot(&prompt).map_err(CodexErr::Fatal)?; ++ let admission = prompt.tool_admission.clone().ok_or_else(|| { ++ CodexErr::Fatal("prepared request has no selected tool admission".into()) ++ })?; ++ let output = editors[0] ++ .prepare_request(codex_extension_api::PreparedRequestInput { ++ admission, ++ recovery_attempt, ++ data, ++ }) ++ .or_cancel(&preempt) ++ .or_cancel(&cancellation_token) ++ .await?? ++ .map_err(CodexErr::Fatal)?; ++ if cancellation_token.is_cancelled() || preempt.is_cancelled() { ++ return Err(CodexErr::TurnAborted); ++ } ++ output.admission.admit_request().map_err(CodexErr::Fatal)?; ++ if let Some(data) = output.data { ++ sampling_prompt = ++ crate::prepared_request::apply(&prompt, data).map_err(CodexErr::Fatal)?; ++ } ++ sampling_prompt.prepared_request_admission = Some(output.admission); ++ } + let err = match try_run_sampling_request( + tool_runtime.clone(), + Arc::clone(&sess), +@@ -1702,7 +1762,7 @@ async fn run_sampling_request( + client_session, + &responses_metadata, + Arc::clone(&turn_diff_tracker), +- &prompt, ++ &sampling_prompt, + cancellation_token.child_token(), + ) + .await +@@ -1758,6 +1818,7 @@ async fn run_sampling_request( + )); + } + retry??; ++ recovery_attempt += 1; + turn_context.turn_timing_state.record_sampling_retry(); + } + } +diff --git a/codex-rs/ext/extension-api/Cargo.toml b/codex-rs/ext/extension-api/Cargo.toml +index 4b487226eb7deb0a44ad5d9ae16e53f90fd7ec8e..3e19d0358c23532df40aca4dff37125b201c938c 100644 +--- a/codex-rs/ext/extension-api/Cargo.toml ++++ b/codex-rs/ext/extension-api/Cargo.toml +@@ -27,6 +27,7 @@ codex-protocol = { workspace = true } + codex-tools = { workspace = true } + codex-utils-absolute-path = { workspace = true } + codex-utils-path-uri = { workspace = true } ++serde = { workspace = true } + serde_json = { workspace = true } + + [dev-dependencies] +diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs +index f2dbed8d89823999a82e89e43576ecf7efab7a5f..54d70d95f627509f85c177d38173c8568ebd5666 100644 +--- a/codex-rs/ext/extension-api/src/lib.rs ++++ b/codex-rs/ext/extension-api/src/lib.rs +@@ -126,6 +126,12 @@ pub use model_request::ModelToolAdmission; + pub use model_request::ResponseEvent; + pub use model_request::TurnSamplingStop; + ++pub use model_request::PreparedRequestAdmission; ++pub use model_request::PreparedRequestData; ++pub use model_request::PreparedRequestInput; ++pub use model_request::PreparedRequestOutput; ++pub use model_request::PreparedToolPresentation; ++ + /// SDK host context for recording the settings used to prepare a sampling request. + pub struct SdkTurnReceiptSeed { + pub codex_revision: &'static str, +diff --git a/codex-rs/ext/extension-api/src/model_request.rs b/codex-rs/ext/extension-api/src/model_request.rs +index 5263f85cd4517aa8b72186b02ac91d9c08d0b2e2..34d02776f0119a72955a827c3a5cf48c3bfda7e5 100644 +--- a/codex-rs/ext/extension-api/src/model_request.rs ++++ b/codex-rs/ext/extension-api/src/model_request.rs +@@ -3,7 +3,10 @@ use crate::ExtensionData; + pub use codex_api::ApiError as ModelResponseError; + pub use codex_api::ResponseEvent; + use codex_protocol::models::ResponseItem; ++use futures::future::BoxFuture; + use futures::stream::BoxStream; ++use serde::{Deserialize, Serialize}; ++use serde_json::Value; + use std::collections::HashMap; + use std::sync::Arc; + use std::sync::atomic::AtomicBool; +@@ -78,9 +81,25 @@ pub struct ModelRequestInput<'a> { + pub model: &'a str, + } + +-/// Creates request-scoped interceptors without delaying inference. ++/// Contributes request-scoped preparation and response interception. + /// Return None for requests this host does not manage. + pub trait ModelRequestContributor: Send + Sync + std::fmt::Debug { ++ /// Whether this exact operation opted into prepared-request editing. ++ /// Keep this captured marker true through retirement; it is not an owner-liveness check. ++ /// Hosts must reject unsupported operation settings before starting work. ++ fn prepares_request(&self, _turn_store: &ExtensionData) -> bool { ++ false ++ } ++ ++ /// Prepare an owned sampling copy. Dropping the future revokes its pending reply; ++ /// the host still owns cancellation and joining of application callbacks. ++ fn prepare_request( ++ &self, ++ _input: PreparedRequestInput, ++ ) -> BoxFuture<'_, Result> { ++ Box::pin(async { Err("prepared-request contributor is not implemented".into()) }) ++ } ++ + fn request(&self, input: ModelRequestInput<'_>) -> Option>; + } + +@@ -114,6 +133,46 @@ pub trait ModelResponseInterceptor: Send + Sync { + fn intercept(self: Box, stream: ModelResponseStream) -> ModelResponseStream; + } + ++/// Detached native sampling data. Unknown fields are rejected at the host reply boundary. ++#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] ++#[serde(deny_unknown_fields)] ++pub struct PreparedRequestData { ++ pub instructions: String, ++ pub input: Vec, ++ pub tools: Vec, ++} ++ ++/// Ordered function leaf. Only description and parameters may change. ++#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] ++#[serde(deny_unknown_fields)] ++pub struct PreparedToolPresentation { ++ pub namespace: Option, ++ pub name: String, ++ pub kind: String, ++ pub strict: bool, ++ pub defer_loading: Option, ++ pub description: String, ++ pub parameters: Value, ++} ++ ++/// Native ownership evidence; hosts add their generation and one-shot nonce. ++pub struct PreparedRequestInput { ++ pub admission: Arc, ++ /// Outer sampling recovery count; lower-level transport retries reuse this preparation. ++ pub recovery_attempt: usize, ++ pub data: PreparedRequestData, ++} ++ ++/// A settled reply retains an owner check even when it makes no edit. ++pub struct PreparedRequestOutput { ++ pub data: Option, ++ pub admission: Arc, ++} ++ ++pub trait PreparedRequestAdmission: Send + Sync + std::fmt::Debug { ++ fn admit_request(&self) -> Result<(), String>; ++} ++ + #[cfg(test)] + mod sampling_stop_tests { + use super::TurnSamplingStop; diff --git a/packages/agents/native/patches/series.json b/packages/agents/native/patches/series.json index 439ef35b8..96ddff1cb 100644 --- a/packages/agents/native/patches/series.json +++ b/packages/agents/native/patches/series.json @@ -211,7 +211,11 @@ { "path": "0053-serialize-token-budget-context-commit.patch", "sha256": "a2481ece54b2d1289b698035e6697e4e68fa6391d68bc2db47f0af8475daefd4" + }, + { + "path": "0054-native-prepared-request.patch", + "sha256": "8a744feaead0a45f54be30df5d4be237b1aca29995d96eeb410c11b7f13cfd53" } ], - "result_tree": "049bf9bbdd75072a30a7739245d79bd05391eda8" + "result_tree": "5296ecb1f56ece03e66636e1ccda16fc723fa956" } diff --git a/packages/agents/native/rust/checkpoint.rs b/packages/agents/native/rust/checkpoint.rs index 0825c8936..a10ac2697 100644 --- a/packages/agents/native/rust/checkpoint.rs +++ b/packages/agents/native/rust/checkpoint.rs @@ -441,6 +441,9 @@ async fn export_inner( .thread .thread_extension_data() .get::(); + if let Some(tools) = &tools { + tools.check_prepared_checkpoint()?; + } let managed_tool_specs = if let Some(tools) = &tools { if let Some(key) = pending { check_capture(state, key)?; diff --git a/packages/agents/native/rust/extensions.rs b/packages/agents/native/rust/extensions.rs index b291ae7db..0658036dd 100644 --- a/packages/agents/native/rust/extensions.rs +++ b/packages/agents/native/rust/extensions.rs @@ -334,6 +334,9 @@ pub fn build( skills_registry.build(), crate::native_interception::DirectOutput::Json, ); + builder.model_request_contributor(Arc::new( + crate::python_tools::prepared_requests::Contributor, + )); builder.model_request_contributor(recovery); builder.model_request_contributor(Arc::new(crate::response_integrity::Contributor( interception.clone(), diff --git a/packages/agents/native/rust/lib.rs b/packages/agents/native/rust/lib.rs index c62526572..f38bd32c2 100644 --- a/packages/agents/native/rust/lib.rs +++ b/packages/agents/native/rust/lib.rs @@ -2064,6 +2064,21 @@ impl NativeThread { .map_err(input_error) } + #[napi(js_name = "_reply_prepared_request")] + pub fn _reply_prepared_request(&self, response_json: String) -> napi::Result<()> { + let inner = self.current()?; + let response: python_tools::prepared_requests::Reply = serde_json::from_str(&response_json) + .map_err(|_| input_error("Invalid prepared request reply"))?; + let tools = inner + .thread + .thread_extension_data() + .get::() + .ok_or_else(|| input_error("Prepared request requires managed Session"))?; + tools + .reply_prepared(&inner.id.to_string(), response) + .map_err(input_error) + } + #[napi(js_name = "_reply_step")] pub fn _reply_step(&self, response_json: String) -> napi::Result<()> { let inner = self.current()?; @@ -2831,6 +2846,14 @@ impl NativeThread { .transpose()?; // A restored root can use step preparation to enforce its remaining // SDK budget. The SDK never serializes an application callback. + if selection + .as_ref() + .is_some_and(|selection| selection.prepare_request) + { + return Err(native_error( + "manual continuation recovery cannot restore request preparation", + )); + } let inner = self.current()?; let _operation = inner.engine.operation(false).map_err(native_error)?; inner diff --git a/packages/agents/native/rust/prepared_requests.rs b/packages/agents/native/rust/prepared_requests.rs new file mode 100644 index 000000000..dc418913b --- /dev/null +++ b/packages/agents/native/rust/prepared_requests.rs @@ -0,0 +1,366 @@ +//! One issuing turn owns each prepared sampling reply and dispatch capability. +use super::*; +use codex_extension_api::{ + ModelRequestContributor, ModelRequestInput, ModelResponseInterceptor, ModelToolAdmission, + PreparedRequestAdmission, PreparedRequestData, PreparedRequestInput, PreparedRequestOutput, +}; +use futures_util::future::BoxFuture; +use std::sync::atomic::AtomicBool; + +pub(super) struct Owner { + tools: Weak, + generation: Arc, + turn: String, + thread: String, + store: usize, + live: AtomicBool, + replies: Mutex>>>, +} +impl Owner { + fn check(&self) -> std::result::Result, String> { + let tools = self + .tools + .upgrade() + .ok_or("Prepared request owner closed")?; + if !self.live.load(Ordering::Acquire) + || !Arc::ptr_eq(&tools.snapshot(), &self.generation) + || !tools + .prepared + .lock() + .expect("prepared owner poisoned") + .as_ref() + .is_some_and(|current| std::ptr::eq(current.as_ref(), self)) + { + return Err("Prepared request owner retired".into()); + } + Ok(tools) + } + pub(super) fn queued(&self, id: u64) -> bool { + self.check().is_ok() + && self + .replies + .lock() + .expect("prepared replies poisoned") + .get(&id) + .is_some_and(|reply| !reply.is_closed()) + } + pub(super) fn retire(&self) { + self.live.store(false, Ordering::Release); + self.replies + .lock() + .expect("prepared replies poisoned") + .clear(); + } +} +#[derive(Default, Debug)] +pub(crate) struct Contributor; +impl ModelRequestContributor for Contributor { + fn request(&self, _: ModelRequestInput<'_>) -> Option> { + None + } + fn prepares_request(&self, turn: &ExtensionData) -> bool { + turn.get::().is_some() + } + fn prepare_request( + &self, + input: PreparedRequestInput, + ) -> BoxFuture<'_, std::result::Result> { + Box::pin(async move { + let owner = input + .admission + .turn_store() + .get::() + .ok_or("Missing prepared request owner")?; + let tools = owner.check()?; + if owner.store != input.admission.turn_store() as *const ExtensionData as usize + || owner.turn != input.admission.turn_store().level_id() + || owner.thread != input.admission.thread_id() + || tools.instance.is_some() + { + return Err("Expired prepared request store".into()); + } + let presentation = tools + .schema_receipts + .prepared(input.data) + .map_err(|_| "Invalid prepared schema owner")?; + let id = tools.family.next_step.fetch_add(1, Ordering::Relaxed); + let (send, receive) = oneshot::channel(); + { + let mut pending = owner.replies.lock().expect("prepared replies poisoned"); + owner.check()?; + pending.insert(id, send); + } + let wait = Wait { + owner: owner.clone(), + id, + }; + tools.family.requests.send(FamilyRequest::Prepared(Arc::downgrade(&owner), id, json!({ + "id": owner.turn, "msg": {"type":"_sdk_prepare_request", "turn_id":owner.turn, + "thread":input.admission.thread_id(), "generation":owner.generation.id.to_string(), + "request_id":id.to_string(), "recovery_attempt":input.recovery_attempt,"data":presentation.data} + }))).map_err(|_| "Prepared request reader closed")?; + let mut data = receive + .await + .map_err(|_| "Prepared request callback failed or cancelled")?; + owner.check()?; + let sampling_schemas = data + .as_mut() + .map(|data| { + tools + .schema_receipts + .reconcile_prepared(&presentation, data) + }) + .transpose() + .map_err(|_| "Invalid prepared schema presentation")? + .flatten(); + drop(wait); + Ok(PreparedRequestOutput { + data, + admission: Arc::new(Admission { + owner, + original: input.admission, + _sampling_schemas: sampling_schemas, + }), + }) + }) + } +} +struct Wait { + owner: Arc, + id: u64, +} +impl Drop for Wait { + fn drop(&mut self) { + self.owner + .replies + .lock() + .expect("prepared replies poisoned") + .remove(&self.id); + } +} +struct Admission { + owner: Arc, + original: Arc, + // A sampling copy keeps only its own immutable presentation receipts alive. + _sampling_schemas: Option>, +} +impl std::fmt::Debug for Admission { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("PreparedRequestAdmission") + } +} +impl PreparedRequestAdmission for Admission { + fn admit_request(&self) -> std::result::Result<(), String> { + self.owner.check()?; + if self.owner.store != self.original.turn_store() as *const ExtensionData as usize + || !self + .original + .turn_store() + .get::() + .is_some_and(|current| Arc::ptr_eq(¤t, &self.owner)) + { + return Err("Expired prepared request admission".into()); + } + Ok(()) + } +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Reply { + pub(crate) thread: String, + turn_id: String, + generation: String, + request_id: String, + // A failed callback closes the native wait without treating failure as no edit. + failed: bool, + data: Option, +} +impl PythonTools { + pub(super) fn begin_preparation(self: &Arc, input: &TurnStartInput<'_>) { + let enabled = input + .turn_store + .get::>() + .is_some_and(|operation| { + operation + .as_ref() + .as_ref() + .is_some_and(|operation| operation.prepare_request) + }); + if !enabled { + self.retire_preparation(None); + return; + } + let owner = input.turn_store.get_or_init(|| Owner { + tools: Arc::downgrade(self), + generation: self.snapshot(), + turn: input.turn_id.to_owned(), + thread: input.thread_store.level_id().to_owned(), + store: input.turn_store as *const ExtensionData as usize, + live: AtomicBool::new(true), + replies: Mutex::new(HashMap::new()), + }); + let previous = self + .prepared + .lock() + .expect("prepared owner poisoned") + .replace(owner.clone()); + if let Some(previous) = previous.filter(|previous| !Arc::ptr_eq(previous, &owner)) { + previous.retire(); + } + } + pub(crate) fn reply_prepared(&self, thread: &str, response: Reply) -> Result<()> { + let owner = self + .prepared + .lock() + .expect("prepared owner poisoned") + .clone() + .context("Expired prepared request")?; + owner.check().map_err(anyhow::Error::msg)?; + ensure!( + response.thread == thread + && response.turn_id == owner.turn + && response.generation == owner.generation.id.to_string(), + "Expired prepared request owner" + ); + let sender = owner + .replies + .lock() + .expect("prepared replies poisoned") + .remove(&response.request_id.parse()?) + .context("Expired prepared request reply")?; + if response.failed { + drop(sender); + return Ok(()); + } + sender + .send(response.data) + .map_err(|_| anyhow::anyhow!("Expired prepared request reply")) + } + pub(crate) fn check_prepared_checkpoint(&self) -> Result<()> { + ensure!( + self.prepared + .lock() + .expect("prepared owner poisoned") + .is_none(), + "Cannot capture a prepared-request operation" + ); + Ok(()) + } + pub(super) fn retire_preparation(&self, turn: Option<&str>) { + let retired = { + let mut owner = self.prepared.lock().expect("prepared owner poisoned"); + if owner + .as_ref() + .is_some_and(|owner| turn.is_none_or(|turn| turn == owner.turn)) + { + owner.take() + } else { + None + } + }; + if let Some(owner) = retired { + owner.retire(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::models::ResponseItem; + + #[derive(Debug)] + struct Original { + turn: Arc, + } + impl ModelToolAdmission for Original { + fn thread_id(&self) -> &str { + "root" + } + fn turn_store(&self) -> &ExtensionData { + &self.turn + } + fn admits(&self, _: &ResponseItem) -> bool { + true + } + } + fn fixture() -> (Arc, Arc, Arc) { + let tools = super::super::pending_tests::tools(); + let turn = Arc::new(ExtensionData::new("turn")); + let owner = turn.get_or_init(|| Owner { + tools: Arc::downgrade(&tools), + generation: tools.snapshot(), + turn: "turn".into(), + thread: "root".into(), + store: turn.as_ref() as *const ExtensionData as usize, + live: AtomicBool::new(true), + replies: Mutex::new(HashMap::new()), + }); + *tools.prepared.lock().unwrap() = Some(owner.clone()); + (tools, turn, owner) + } + fn input(turn: Arc) -> PreparedRequestInput { + PreparedRequestInput { + admission: Arc::new(Original { turn }), + recovery_attempt: 0, + data: PreparedRequestData { + instructions: "original".into(), + input: Vec::new(), + tools: Vec::new(), + }, + } + } + fn reply(event: &Value) -> Reply { + serde_json::from_value(json!({ "thread":"root", "turn_id":"turn", "generation":"0", "request_id":event["msg"]["request_id"], "failed":false,"data":null })).unwrap() + } + #[tokio::test] + async fn no_edit_retains_dispatch_admission_and_reply_is_one_shot() { + let (tools, turn, owner) = fixture(); + let contributor = Contributor; + let mut prepare = contributor.prepare_request(input(turn)); + assert!(futures_util::poll!(&mut prepare).is_pending()); + let event = tools.next_request().await; + tools.reply_prepared("root", reply(&event)).unwrap(); + assert!(tools.reply_prepared("root", reply(&event)).is_err()); + let output = prepare.await.unwrap(); + assert!(output.data.is_none()); + assert!(output.admission.admit_request().is_ok()); + tools.retire_preparation(Some("turn")); + assert!(!owner.live.load(Ordering::Acquire)); + assert!(output.admission.admit_request().is_err()); + } + #[tokio::test] + async fn dropped_wait_revokes_receipt_and_active_capture_is_refused() { + let (tools, turn, _) = fixture(); + assert!(tools.check_prepared_checkpoint().is_err()); + let contributor = Contributor; + let mut prepare = contributor.prepare_request(input(turn)); + assert!(futures_util::poll!(&mut prepare).is_pending()); + let event = tools.next_request().await; + drop(prepare); + assert!(tools.reply_prepared("root", reply(&event)).is_err()); + tools.retire_preparation(None); + assert!(tools.check_prepared_checkpoint().is_ok()); + } + #[tokio::test] + async fn superseded_owner_and_exact_store_mismatch_cannot_dispatch() { + let (tools, turn, owner) = fixture(); + let contributor = Contributor; + let mut prepare = contributor.prepare_request(input(turn.clone())); + assert!(futures_util::poll!(&mut prepare).is_pending()); + let event = tools.next_request().await; + tools.reply_prepared("root", reply(&event)).unwrap(); + let output = prepare.await.unwrap(); + let impostor = Arc::new(ExtensionData::new("turn")); + // Even the same marker and textual ID cannot change the original store identity. + let wrong = Admission { + owner: owner.clone(), + original: Arc::new(Original { turn: impostor }), + _sampling_schemas: None, + }; + assert!(wrong.admit_request().is_err()); + *tools.prepared.lock().unwrap() = None; + assert!(output.admission.admit_request().is_err()); + assert!(contributor.prepares_request(&turn)); + } +} diff --git a/packages/agents/native/rust/python_tools.rs b/packages/agents/native/rust/python_tools.rs index 224aab27a..febb21206 100644 --- a/packages/agents/native/rust/python_tools.rs +++ b/packages/agents/native/rust/python_tools.rs @@ -38,6 +38,9 @@ pub(crate) use manual::{Continuation, ManualLedger, ManualSlot}; #[path = "schema_fidelity.rs"] mod schema_fidelity; +#[path = "prepared_requests.rs"] +pub(crate) mod prepared_requests; + struct Entry { name: ToolName, spec: ToolSpec, @@ -113,11 +116,13 @@ pub struct PythonTools { manual: Mutex, selection: Mutex>, step_replies: Mutex>, + prepared: Mutex>>, } // The family inbox transports requests; reply authority stays with each issuer. enum FamilyRequest { Step(Weak, u64, Value), + Prepared(Weak, u64, Value), Call(Weak, PendingKey, Value), Retired { thread: String, instance: String }, } @@ -440,6 +445,7 @@ impl PythonTools { manual: Mutex::new(ManualLedger::default()), selection: Mutex::new(None), step_replies: Mutex::new(HashMap::new()), + prepared: Mutex::new(None), }; let seed = Arc::new(tools.prepare(0, specs)?); tools.schema_receipts.register(&seed); @@ -491,6 +497,7 @@ impl PythonTools { manual: Mutex::new(parent.child_manual_ledger()), selection: Mutex::new(None), step_replies: Mutex::new(HashMap::new()), + prepared: Mutex::new(None), }; // Validate the inherited catalog against this child's current ceiling. let parent_generation = parent.snapshot(); @@ -692,6 +699,11 @@ impl PythonTools { let mut receiver = self.family.receiver.lock().await; loop { match receiver.recv().await { + Some(FamilyRequest::Prepared(owner, id, request)) => { + if owner.upgrade().is_some_and(|owner| owner.queued(id)) { + return request; + } + } Some(FamilyRequest::Step(owner, id, request)) => { if owner.upgrade().is_some_and(|tools| { if tools.instance.is_some() { @@ -934,6 +946,7 @@ impl ThreadLifecycleContributor for PythonContributor { progress.0.send_replace(()); } if let Some(tools) = input.thread_store.get::() { + tools.retire_preparation(None); tools.retire_manual_wait(); tools .step_replies @@ -980,6 +993,8 @@ pub(crate) struct OperationSelection { // Names select across all native families; slots remain the legacy JS ceiling. pub names: Option>, pub before_step: bool, + #[serde(default)] + pub prepare_request: bool, } #[derive(Clone)] struct ActiveSelection { @@ -988,6 +1003,7 @@ struct ActiveSelection { slots: Option>, names: Option>, before_step: bool, + prepare_request: bool, } struct PendingStep { turn: String, @@ -1087,6 +1103,7 @@ impl Drop for StepWait { impl PythonTools { fn retire_selection(&self, turn: &str) { + self.retire_preparation(Some(turn)); let mut active = self .selection .lock() @@ -1114,6 +1131,7 @@ impl PythonTools { slots: selection.slots.clone(), names: selection.names.clone(), before_step: selection.before_step, + prepare_request: selection.prepare_request, }) } @@ -1208,6 +1226,7 @@ impl PythonTools { slots: None, names: None, before_step: true, + prepare_request: false, }); } operation.map(|selection| ActiveSelection { @@ -1216,6 +1235,7 @@ impl PythonTools { slots: selection.slots.clone(), names: selection.names.clone(), before_step: selection.before_step, + prepare_request: selection.prepare_request, }) } @@ -1627,6 +1647,7 @@ impl TurnLifecycleContributor for PythonContributor { .selection .lock() .expect("JavaScript selection poisoned") = selection; + tools.begin_preparation(&input); tools.begin_native_manual_turn(input); } }) @@ -1646,6 +1667,7 @@ impl TurnLifecycleContributor for PythonContributor { .lock() .expect("JavaScript step replies poisoned") .retain(|_, pending| pending.turn != input.turn_store.level_id()); + tools.retire_preparation(Some(input.turn_store.level_id())); tools.stop_native_manual_turn(input); } }) @@ -1726,7 +1748,7 @@ mod pending_tests { // Exercise the actual registry and its lifetime owners without starting Core, // a runtime, a network service, or a native thread. - fn tools() -> Arc { + pub(super) fn tools() -> Arc { let tools = Arc::new(PythonTools { local_store: None, preserve_schemas: false, @@ -1749,6 +1771,7 @@ mod pending_tests { manual: Mutex::default(), selection: Mutex::new(None), step_replies: Mutex::new(HashMap::new()), + prepared: Mutex::new(None), }); let specs = vec![DynamicToolSpec::Function(DynamicToolFunctionSpec { name: "decision".into(), @@ -2019,6 +2042,7 @@ mod pending_tests { slots: None, names, before_step: true, + prepare_request: false, }); (thread, tools) } @@ -2040,6 +2064,7 @@ mod pending_tests { slots: Some(Vec::new()), names: None, before_step: false, + prepare_request: false, }; *child.selection.lock().unwrap() = child.turn_selection("child-turn", Some(&root_operation)); @@ -2335,6 +2360,7 @@ mod pending_tests { slots: Some(Vec::new()), names: None, before_step: false, + prepare_request: false, }); let successor = ExtensionData::new("successor-step"); assert!( @@ -2463,6 +2489,7 @@ mod pending_tests { slots: operation.slots, names: operation.names, before_step: operation.before_step, + prepare_request: operation.prepare_request, }); let continued = tools.continuation_selection("turn").unwrap(); assert_eq!(continued.slots, expected_slots); diff --git a/packages/agents/native/rust/schema_fidelity.rs b/packages/agents/native/rust/schema_fidelity.rs index 2b5f8a95f..87b537226 100644 --- a/packages/agents/native/rust/schema_fidelity.rs +++ b/packages/agents/native/rust/schema_fidelity.rs @@ -7,11 +7,13 @@ use std::collections::HashMap; use std::sync::{Arc, LazyLock, Mutex, Weak}; use anyhow::{Result, ensure}; +use codex_extension_api::{PreparedRequestData, PreparedToolPresentation}; use codex_protocol::ThreadId; use codex_tools::{ResponsesApiNamespaceTool, ToolSpec}; use serde_json::Value; -use super::{Generation, PythonTools, functions}; +use super::{Entry, Generation, PythonTools, functions}; +use codex_protocol::dynamic_tools::{DynamicToolNamespaceTool, DynamicToolSpec}; static MARKER: LazyLock = LazyLock::new(|| format!("\n[agents-schema:{}/", ThreadId::new())); @@ -21,6 +23,19 @@ struct Receipt { historical: Option>, } +struct ManagedSchema { + generation: Arc, + slot: usize, +} + +/// One clean callback view plus its private original presentation owners. +/// This metadata never supplies executors or changes the live tool generation. +pub(super) struct PreparedSchemas { + pub(super) data: PreparedRequestData, + original: Vec, + managed: Vec>, +} + #[derive(Default)] pub(super) struct Receipts(Mutex>); @@ -162,12 +177,120 @@ impl Receipts { Ok(()) } - fn function(&self, tool: &mut Value, namespace: &str) -> Result<()> { + pub(super) fn prepared(&self, mut data: PreparedRequestData) -> Result { + let original = data.tools.clone(); + let mut managed = Vec::with_capacity(data.tools.len()); + for tool in &mut data.tools { + let owner = self.resolve( + &serde_json::json!({ + "name":tool.name, "description":tool.description, "parameters":tool.parameters, + }), + tool.namespace.as_deref().unwrap_or("functions"), + )?; + if let Some(owner) = &owner { + let originals = functions(&owner.generation.specs); + tool.description = originals[owner.slot].1.description.clone(); + } + managed.push(owner); + } + Ok(PreparedSchemas { + data, + original, + managed, + }) + } + + pub(super) fn reconcile_prepared( + &self, + captured: &PreparedSchemas, + data: &mut PreparedRequestData, + ) -> Result>> { + ensure!( + data.tools.len() == captured.data.tools.len(), + "Prepared tool count changed" + ); + let token = ThreadId::new().to_string(); + let mut entries = Vec::new(); + let mut specs = Vec::new(); + for (index, tool) in data.tools.iter_mut().enumerate() { + // Only this runtime's actual private syntax is reserved. Ordinary + // documentation that mentions agents-schema is still plain text. + ensure!( + !tool.description.contains(MARKER.as_str()), + "Prepared description contains a reserved schema reference" + ); + let Some(owner) = &captured.managed[index] else { + continue; + }; + let public = &captured.data.tools[index]; + let description_changed = tool.description != public.description; + let parameters_changed = tool.parameters != public.parameters; + if !description_changed && !parameters_changed { + tool.description = captured.original[index].description.clone(); + continue; + } + let original_entry = &owner.generation.entries[owner.slot]; + let originals = functions(&owner.generation.specs); + let (namespace, original) = originals[owner.slot]; + let mut raw = original.clone(); + raw.description = tool.description.clone(); + if parameters_changed { + raw.input_schema = tool.parameters.clone(); + } + let mut projected = original_entry.spec.clone(); + let function = match &mut projected { + ToolSpec::Function(function) => function, + ToolSpec::Namespace(namespace) => match namespace.tools.first_mut() { + Some(ResponsesApiNamespaceTool::Function(function)) => function, + _ => anyhow::bail!("Invalid managed prepared namespace"), + }, + _ => anyhow::bail!("Invalid managed prepared function"), + }; + // This is only the receipt's expected native wire projection. + // Keep tool.parameters byte-for-value intact: Core still owns its + // lossless validation, including rejection of unknown nested keys. + if parameters_changed { + function.parameters = serde_json::from_value(tool.parameters.clone()) + .map_err(|_| anyhow::anyhow!("Invalid prepared schema projection"))?; + } + function.description = Self::description(&tool.description, &token, entries.len()); + tool.description = function.description.clone(); + entries.push(Entry { + name: original_entry.name.clone(), + spec: projected, + exposure: original_entry.exposure, + }); + specs.push(match namespace { + None => DynamicToolSpec::Function(raw), + Some(namespace) => { + let mut namespace = namespace.clone(); + namespace.tools = vec![DynamicToolNamespaceTool::Function(raw)]; + DynamicToolSpec::Namespace(namespace) + } + }); + } + if entries.is_empty() { + return Ok(None); + } + let generation = Arc::new(Generation { + id: 0, + schema_token: Some(token), + entries, + specs, + defaults: Vec::new(), + }); + // Register only presentation metadata. No executable plan, current + // generation, search-history retention, or application policy changes. + self.register(&generation); + Ok(Some(generation)) + } + + fn resolve(&self, tool: &Value, namespace: &str) -> Result> { let Some(description) = tool.get("description").and_then(Value::as_str) else { - return Ok(()); + return Ok(None); }; let Some((_, suffix)) = description.rsplit_once(MARKER.as_str()) else { - return Ok(()); + return Ok(None); }; let (token, slot) = suffix .split_once(']') @@ -213,6 +336,25 @@ impl Receipts { && tool.get("parameters") == Some(&serde_json::to_value(&projected.parameters)?), "Managed tool schema declaration does not match its native owner" ); + Ok(Some(ManagedSchema { generation, slot })) + } + + fn function(&self, tool: &mut Value, namespace: &str) -> Result<()> { + let Some(ManagedSchema { generation, slot }) = self.resolve(tool, namespace)? else { + return Ok(()); + }; + let description = tool + .get("description") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("Invalid managed tool description"))?; + let projected = match &generation.entries[slot].spec { + ToolSpec::Function(function) => function, + ToolSpec::Namespace(namespace) => match namespace.tools.first() { + Some(ResponsesApiNamespaceTool::Function(function)) => function, + _ => unreachable!("resolved namespace function"), + }, + _ => unreachable!("resolved function"), + }; let originals = functions(&generation.specs); let (_, original) = originals .get(slot) @@ -419,3 +561,140 @@ mod tests { } } } + +#[cfg(test)] +mod prepared_tests { + use super::*; + use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; + use codex_tools::{ToolExposure, ToolName, dynamic_tool_to_responses_api_tool}; + use serde_json::json; + + fn fixture(receipts: &Receipts) -> (Arc, PreparedRequestData) { + let raw = DynamicToolFunctionSpec { + name: "lookup".into(), + description: "Original description.".into(), + input_schema: json!({"type":"object","properties":{"key":{"type":"string","minLength":3}},"required":["key"],"additionalProperties":false}), + defer_loading: false, + }; + let token = ThreadId::new().to_string(); + let mut projected = dynamic_tool_to_responses_api_tool(&raw).unwrap(); + projected.description = Receipts::description(&raw.description, &token, 0); + let data = PreparedRequestData { + instructions: "original".into(), + input: Vec::new(), + tools: vec![PreparedToolPresentation { + namespace: None, + name: projected.name.clone(), + kind: "function".into(), + strict: projected.strict, + defer_loading: projected.defer_loading, + description: projected.description.clone(), + parameters: serde_json::to_value(&projected.parameters).unwrap(), + }], + }; + let generation = Arc::new(Generation { + id: 7, + schema_token: Some(token), + entries: vec![Entry { + name: ToolName::plain("lookup"), + spec: ToolSpec::Function(projected), + exposure: ToolExposure::Direct, + }], + specs: vec![DynamicToolSpec::Function(raw)], + defaults: vec![0], + }); + receipts.register(&generation); + (generation, data) + } + fn wire(data: &PreparedRequestData) -> Value { + json!({"tools":data.tools.iter().map(|tool|json!({"type":"function","name":tool.name,"description":tool.description,"parameters":serde_json::to_value(serde_json::from_value::(tool.parameters.clone()).unwrap()).unwrap()})).collect::>()}) + } + #[test] + fn field_intent_retains_raw_schema_until_parameters_actually_change() { + for (description_changed, parameters_changed) in + [(false, false), (true, false), (false, true), (true, true)] + { + let receipts = Receipts::default(); + let (original, data) = fixture(&receipts); + let view = receipts.prepared(data).unwrap(); + assert_eq!(view.data.tools[0].description, "Original description."); + assert!(!view.data.tools[0].description.contains(MARKER.as_str())); + let mut edited = view.data.clone(); + if description_changed { + edited.tools[0].description = "Edited description.".into(); + } + if parameters_changed { + edited.tools[0].parameters = json!({"type":"object","properties":{"key":{"type":"string","enum":["allowed","wider"]}},"required":["key"],"additionalProperties":false}); + } + let returned_parameters = edited.tools[0].parameters.clone(); + let metadata = receipts.reconcile_prepared(&view, &mut edited).unwrap(); + assert_eq!(edited.tools[0].parameters, returned_parameters); + assert_eq!( + metadata.is_some(), + description_changed || parameters_changed + ); + let mut request = wire(&edited); + receipts.restore(&mut request).unwrap(); + assert_eq!( + request["tools"][0]["description"], + if description_changed { + "Edited description." + } else { + "Original description." + } + ); + if parameters_changed { + assert_eq!(request["tools"][0]["parameters"], returned_parameters); + } else { + assert_eq!( + request["tools"][0]["parameters"], + functions(&original.specs)[0].1.input_schema + ); + } + if let Some(metadata) = metadata { + let mut stale = wire(&edited); + drop(metadata); + assert!(receipts.restore(&mut stale).is_err()); + } + } + } + #[test] + fn returned_lossy_data_and_identity_are_never_normalized_for_core() { + let receipts = Receipts::default(); + let (_original, data) = fixture(&receipts); + let view = receipts.prepared(data).unwrap(); + let mut edited = view.data.clone(); + edited.tools[0].parameters["properties"]["key"]["unknown_null_key"] = Value::Null; + edited.tools[0].name = "renamed".into(); + edited.tools[0].namespace = Some("changed_namespace".into()); + edited.tools[0].kind = "changed_kind".into(); + edited.tools[0].strict = !edited.tools[0].strict; + edited.tools[0].defer_loading = Some(true); + let submitted = edited.tools[0].clone(); + let _metadata = receipts.reconcile_prepared(&view, &mut edited).unwrap(); + assert_eq!(edited.tools[0].parameters, submitted.parameters); + assert_eq!(edited.tools[0].name, submitted.name); + assert_eq!(edited.tools[0].namespace, submitted.namespace); + assert_eq!(edited.tools[0].kind, submitted.kind); + assert_eq!(edited.tools[0].strict, submitted.strict); + assert_eq!(edited.tools[0].defer_loading, submitted.defer_loading); + } + #[test] + fn prepared_receipts_remain_strict_and_only_actual_private_syntax_is_reserved() { + let receipts = Receipts::default(); + let (_original, data) = fixture(&receipts); + let private = data.tools[0].description.clone(); + let view = receipts.prepared(data).unwrap(); + let mut forged = view.data.clone(); + forged.tools[0].description = private; + assert!(receipts.reconcile_prepared(&view, &mut forged).is_err()); + let mut edited = view.data.clone(); + edited.tools[0].description = "Docs mention [agents-schema:example] syntax.".into(); + let _metadata = receipts.reconcile_prepared(&view, &mut edited).unwrap(); + let mut request = wire(&edited); + request["tools"][0]["parameters"]["properties"]["key"]["type"] = json!("number"); + let before = request.clone(); + assert!(receipts.restore(&mut request).is_err()); + assert_eq!(request, before); + } +} diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts index a549b21aa..49534f624 100644 --- a/packages/agents/src/index.ts +++ b/packages/agents/src/index.ts @@ -133,7 +133,15 @@ export { OutputValidationError } from './runtime/output'; export type { OutputSpec } from './runtime/output'; export type { ToolRef, ToolSelector } from './runtime/tools'; -export type { OperationOptions, BeforeStepContext } from './runtime/session'; +export type { + PreparedRequestData, + PreparedToolPresentation, +} from './runtime/prepared-request'; +export type { + OperationOptions, + BeforeStepContext, + PrepareRequestContext, +} from './runtime/session'; export * as telemetry from './runtime/telemetry'; export type { diff --git a/packages/agents/src/runtime/prepared-request.ts b/packages/agents/src/runtime/prepared-request.ts new file mode 100644 index 000000000..952cf7635 --- /dev/null +++ b/packages/agents/src/runtime/prepared-request.ts @@ -0,0 +1,91 @@ +import { z } from 'zod'; +import { losslessJsonValueSchema, type JsonValue } from './binding'; + +/** Sampling presentation only; executable declarations keep their original authority. */ +export interface PreparedToolPresentation { + readonly namespace: string | null; + readonly name: string; + readonly kind: 'function'; + readonly strict: boolean; + readonly deferLoading: boolean | null; + description: string; + parameters: { [key: string]: JsonValue }; +} +/** Native Responses input items; optional values use native omission, not null. */ +export interface PreparedRequestData { + instructions: string; + input: JsonValue[]; + tools: PreparedToolPresentation[]; +} +const toolSchema = z + .object({ + namespace: z.string().nullable(), + name: z.string(), + kind: z.literal('function'), + strict: z.boolean(), + deferLoading: z.boolean().nullable(), + description: z.string(), + parameters: z.record(z.string(), z.unknown()), + }) + .strict(); +const requestSchema = z + .object({ + instructions: z.string(), + input: z.array(z.unknown()), + tools: z.array(toolSchema), + }) + .strict(); + +/** Validate own keys before mapping so undefined-valued unknown fields cannot vanish. */ +function ownKeys(value: unknown, keys: readonly string[]): void { + if (value === null || typeof value !== 'object' || Array.isArray(value)) + throw new TypeError('Expected a prepared request object'); + if ( + Reflect.ownKeys(value).some( + (key) => typeof key !== 'string' || !keys.includes(key), + ) + ) + throw new TypeError('Unknown prepared request field'); +} +export function capturePreparedRequest(value: unknown): string { + ownKeys(value, ['instructions', 'input', 'tools']); + const tools = (value as PreparedRequestData).tools; + if (!Array.isArray(tools)) throw new TypeError('Expected prepared tools'); + for (const tool of tools) + ownKeys(tool, [ + 'namespace', + 'name', + 'kind', + 'strict', + 'deferLoading', + 'description', + 'parameters', + ]); + // Copy first: schema parsing alone can drop undefined/non-JSON nested values. + const captured = requestSchema.parse( + losslessJsonValueSchema.parse(value), + ) as PreparedRequestData; + return JSON.stringify({ + ...captured, + tools: captured.tools.map(({ deferLoading, ...tool }) => ({ + ...tool, + defer_loading: deferLoading, + })), + }); +} +export function preparedRequestFromNative(value: unknown): PreparedRequestData { + const captured = losslessJsonValueSchema.parse(value) as unknown as { + instructions: string; + input: JsonValue[]; + tools: (Omit & { + defer_loading: boolean | null; + })[]; + }; + return { + ...captured, + tools: captured.tools.map(({ defer_loading, ...tool }) => ({ + ...tool, + deferLoading: defer_loading, + })), + }; +} diff --git a/packages/agents/src/runtime/public-agent.ts b/packages/agents/src/runtime/public-agent.ts index 876ba5884..c652617d6 100644 --- a/packages/agents/src/runtime/public-agent.ts +++ b/packages/agents/src/runtime/public-agent.ts @@ -94,6 +94,7 @@ export interface RunOptions extends Omit< > { /** Root model preparation limit for a new run. Resumed runs inherit their saved budget. */ maxModelSteps?: OperationOptions['maxModelSteps']; + prepareRequest?: import('./session').OperationOptions['prepareRequest']; returnState?: boolean; onNativeQuestion?: ( request: NativeQuestion, @@ -361,6 +362,7 @@ export class Agent { ], [ownedRun]: { input: preparedInput, + prepareRequest: options.prepareRequest, onNativeQuestion: options.onNativeQuestion, onRequiredAction: options.onInteraction ? (action, signal) => @@ -502,6 +504,41 @@ export class Agent { ? Object.freeze({ ...declaredOutput }) : declaredOutput; const output = opening ? opening.output : prepareOutput(outputType); + if ( + (options as OwnedSessionOptions)[ownedRun]?.prepareRequest !== + undefined + ) { + const prepareRequest = (options as OwnedSessionOptions)[ + ownedRun + ]!.prepareRequest; + if (typeof prepareRequest !== 'function') + throw new TypeError('prepareRequest must be callable'); + if (output.repair) + throw new TypeError( + 'prepareRequest does not support output correction attempts; use native output or prompted maxRetries: 0', + ); + if ( + (provider.supportsWebsockets ?? provider.baseUrl === undefined) !== + false + ) + throw new TypeError( + 'prepareRequest requires NativeProvider({ supportsWebsockets: false })', + ); + if ( + programmaticToolCalling || + environmentInput || + declarations.nativeAgents || + declarations.nativeGoals || + declarations.webSearch || + declarations.tools.some((tool) => tool.deferLoading) || + declarations.mcp.some( + (tool) => tool.deferLoading || isHostedRegistration(tool), + ) + ) + throw new TypeError( + 'prepareRequest supports only direct root eager function and ordinary MCP tools', + ); + } const { instructions, files } = !opening && options.instructionFiles !== undefined ? await loadInstructionFiles( diff --git a/packages/agents/src/runtime/session.ts b/packages/agents/src/runtime/session.ts index 1feca3e91..f4abcfe8d 100644 --- a/packages/agents/src/runtime/session.ts +++ b/packages/agents/src/runtime/session.ts @@ -1,3 +1,8 @@ +import { + capturePreparedRequest, + preparedRequestFromNative, + type PreparedRequestData, +} from './prepared-request'; import { networkPolicyDigest } from './network-policy'; import { apiRateLimits, type ApiRateLimits } from './api-rate-limits'; import { @@ -253,6 +258,7 @@ export interface ManualCheckpointEntry { /** Options used by an owned Agent.run Session to drive manual replies. */ export type OneShotOptions = { input: string | JsonValue[] | null; + prepareRequest?: OperationOptions['prepareRequest']; onNativeQuestion?: ( request: NativeQuestion, signal: AbortSignal, @@ -278,6 +284,16 @@ export interface OperationOptions { writableRoots?: readonly string[]; /** Omission uses Session defaults; an empty list selects no tools. */ tools?: readonly ToolSelector[]; + /** + * Edit detached native sampling data; original tools and history retain authority. + * Requires a standard Responses (non-Lite) model, explicit HTTP, and eager root function/MCP tools. + * Undefined keeps the original. + * Recovery may prepare again; avoid business effects in this callback. Active checkpoints refuse it. + */ + prepareRequest?: ( + context: PrepareRequestContext, + ) => + PreparedRequestData | undefined | Promise; /** * Select a subset of the operation ceiling before each model preparation. * Returning undefined uses that ceiling. Indices start at zero. @@ -289,6 +305,14 @@ export interface OperationOptions { | undefined | Promise[] | undefined>; } +export interface PrepareRequestContext extends Omit< + BeforeStepContext, + 'stepIndex' +> { + readonly request: PreparedRequestData; + readonly preparationIndex: number; + readonly recoveryAttempt: number; +} export interface BeforeStepContext { readonly context: Context; readonly submission: Turn; @@ -314,6 +338,8 @@ interface OperationSelection { readonly names: readonly ReturnType[] | undefined; readonly maxModelSteps?: number; readonly beforeStep?: OperationOptions['beforeStep']; + readonly prepareRequest?: OperationOptions['prepareRequest']; + preparationIndex: number; generation?: bigint; stepIndex: number; } @@ -2365,7 +2391,10 @@ export class Session { false, undefined, false, - this.captureSelection({ maxModelSteps }), + this.captureSelection({ + maxModelSteps, + prepareRequest: this.oneShot?.prepareRequest, + }), callerSignal, ); @@ -2383,7 +2412,7 @@ export class Session { throw new TypeError('submitWithOutput requires an output contract'); const parts = nativeInput(input); const output = prepareOutput(outputType); - const selection = this.captureSelection(options); + const selection = this.captureSelection(options, output); const run = this.startTurn( parts, true, @@ -2434,7 +2463,7 @@ export class Session { const parts = nativeInput(input); const output = outputType === undefined ? this.defaultOutput : prepareOutput(outputType); - const selection = this.captureSelection(options); + const selection = this.captureSelection(options, output); const writableRoots = options?.writableRoots === undefined ? undefined @@ -2511,7 +2540,8 @@ export class Session { if ( options?.tools !== undefined || options?.beforeStep !== undefined || - options?.maxModelSteps !== undefined + options?.maxModelSteps !== undefined || + options?.prepareRequest !== undefined ) throw new TypeError('Operation selection applies only to new roots'); const run = this.rootRun(this.lifecycle.run); @@ -2592,20 +2622,51 @@ export class Session { private captureSelection( options?: OperationOptions, + output = this.defaultOutput, ): OperationSelection | undefined { if ( options?.tools === undefined && options?.beforeStep === undefined && - options?.maxModelSteps === undefined + options?.maxModelSteps === undefined && + options?.prepareRequest === undefined ) return; + if (options.prepareRequest !== undefined) { + if (typeof options.prepareRequest !== 'function') + throw new TypeError('prepareRequest must be callable'); + if (output.repair) + throw new TypeError( + 'prepareRequest does not support output correction attempts; use native output or prompted maxRetries: 0', + ); + if (this.options.websocketsAllowed !== false) + throw new TypeError( + 'prepareRequest requires NativeProvider({ supportsWebsockets: false })', + ); + if ( + this.options.nativeAgents || + this.codeModeEnabled || + this.environment || + this.options.webSearch || + this.options.nativeGoals || + this.options.checklist || + this.catalog.tools.some((tool) => tool.deferLoading) || + this.openingMcpTools.some( + (tool) => tool.deferLoading || isHostedRegistration(tool), + ) + ) + throw new TypeError( + 'prepareRequest supports only direct root eager function and ordinary MCP tools', + ); + } return { + prepareRequest: options.prepareRequest, names: options.tools === undefined ? undefined : this.selectionNames([...options.tools]), maxModelSteps: validateMaxModelSteps(options.maxModelSteps), beforeStep: options.beforeStep, + preparationIndex: 0, stepIndex: 0, }; } @@ -3119,6 +3180,10 @@ export class Session { private dispatchStart(run: NativeTurn, parts: JsonValue[]): void { const { output } = submissions.get(run)!; const selection = run.selection; + if (selection?.prepareRequest && output.repair) + throw new TypeError( + 'prepareRequest does not support output correction attempts; use native output or prompted maxRetries: 0', + ); if (selection) selection.generation = this.catalog.generation; const wire = encode({ ...(selection @@ -3129,6 +3194,7 @@ export class Session { before_step: selection.beforeStep !== undefined || selection.maxModelSteps !== undefined, + prepare_request: selection.prepareRequest !== undefined, }, } : {}), @@ -4248,6 +4314,11 @@ export class Session { ); return; } + if (kind === '_sdk_prepare_request') { + if (msg.turn_id !== run.id) return; + this.track(run, this.prepareRequest(run, event)); + return; + } if (kind === '_sdk_select_step') { if (msg.turn_id !== run.id) return; this.track(run, this.selectStep(run, event)); @@ -4481,6 +4552,81 @@ export class Session { ); } } + private prepareRequest(run: NativeTurn, event: NativeEvent): Promise { + const msg = event.msg; + const selection = run.selection; + const preparation = new AbortController(); + const signal = AbortSignal.any([run.controller.signal, preparation.signal]); + run.preparation = preparation; + return this.invokeCallback( + run, + async () => { + let replied = false; + const reply = (data: string | undefined, failed: boolean) => { + if (replied) return; + replied = true; + // The replacement was captured before this boundary; never stringify an application object here. + const receipt = `{"thread":${JSON.stringify(this.id)},"turn_id":${JSON.stringify(msg.turn_id)},"generation":${JSON.stringify(msg.generation)},"request_id":${JSON.stringify(msg.request_id)},"failed":${failed},"data":${data ?? 'null'}}`; + if (failed) { + try { + this.#native._reply_prepared_request(receipt); + } catch { + /* Already revoked. */ + } + } else this.#native._reply_prepared_request(receipt); + }; + const abort = () => reply(undefined, true); + signal.addEventListener('abort', abort, { once: true }); + try { + if ( + !selection?.prepareRequest || + !this.current(run) || + msg.turn_id !== run.id || + msg.generation !== this.catalog.generation.toString() || + selection.generation !== this.catalog.generation + ) + throw new NativeError('Expired prepared request owner'); + signal.throwIfAborted(); + if (this.graphStops.size) + throw new TurnAborted('Request preparation stopped'); + const returned = selection.prepareRequest({ + context: this.options.context as Context, + submission: this.#turnFor(this.rootRun(run)), + preparationIndex: selection.preparationIndex++, + recoveryAttempt: msg.recovery_attempt as number, + signal, + request: preparedRequestFromNative(msg.data), + }); + const replacement = + returned && + typeof (returned as Promise).then === + 'function' + ? await returned + : returned; + // Capture immediately after settlement, before cancellation/owner checks or another await. + const captured = + replacement === undefined + ? undefined + : capturePreparedRequest(replacement); + signal.throwIfAborted(); + if ( + !this.current(run) || + selection.generation !== this.catalog.generation + ) + throw new NativeError('Prepared request is no longer active'); + reply(captured, false); + } catch (error) { + reply(undefined, true); + throw error; + } finally { + signal.removeEventListener('abort', abort); + if (run.preparation === preparation) run.preparation = undefined; + } + }, + signal, + ); + } + private selectStep(run: NativeTurn, event: NativeEvent): Promise { const msg = event.msg; const selection = run.selection; @@ -4576,6 +4722,7 @@ export class Session { selection = { names: undefined, generation: this.catalog.generation, + preparationIndex: 0, stepIndex: 0, }; this.childSelections.set(owner, selection); @@ -5194,7 +5341,10 @@ export class Session { beforeSeal?: (entries: readonly ManualCheckpointEntry[]) => void, signal?: AbortSignal, ): Promise { - if (run.selection?.beforeStep !== undefined) + if ( + run.selection?.beforeStep !== undefined || + run.selection?.prepareRequest !== undefined + ) return Promise.reject( new NativeError('Cannot capture a callback-selected operation'), ); @@ -5503,6 +5653,7 @@ export class Session { ), maxModelSteps: remaining, generation: this.catalog.generation, + preparationIndex: 0, stepIndex: 0, }; submissions.set(run, { @@ -6850,7 +7001,10 @@ export class Session { if (this.lifecycle.kind !== 'turn') return checkpointStatus('notReady', 'No settled pending manual boundary'); const run = this.lifecycle.run; - if (run.selection?.beforeStep !== undefined) + if ( + run.selection?.beforeStep !== undefined || + run.selection?.prepareRequest !== undefined + ) return checkpointStatus( 'notReady', 'Cannot capture a callback-selected operation', diff --git a/packages/agents/test/native/prepared-request-native.test.ts b/packages/agents/test/native/prepared-request-native.test.ts new file mode 100644 index 000000000..975849d66 --- /dev/null +++ b/packages/agents/test/native/prepared-request-native.test.ts @@ -0,0 +1,1010 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { createServer } from 'node:http'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { jsonObject, type JsonObject } from '../../src/runtime/binding'; +import { + Agent, + NativeProvider, + Runner, + ToolOutput, + tool, + manualTool, + HttpMCP, + localStorage, +} from '@openai/agents'; +import type { PreparedRequestData } from '@openai/agents'; +import { ModelServer, call, message } from './fixtures/model-server'; + +// This fixture observes actual HTTP bytes and native tool execution, not a mock model loop. +const closePeers: (() => Promise)[] = []; +afterEach(async () => { + await Promise.all(closePeers.splice(0).map((close) => close())); + await ModelServer.closeAll(); +}); +async function peer() { + const calls: JsonObject[] = []; + const effects: unknown[] = []; + const server = createServer(async (request, response) => { + if (request.method !== 'POST') { + response.writeHead(405).end(); + return; + } + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const rpc = jsonObject.parse(JSON.parse(Buffer.concat(chunks).toString())); + if (rpc.id === undefined) { + response.writeHead(202).end(); + return; + } + let result: JsonObject = {}; + if (rpc.method === 'initialize') + result = { + protocolVersion: jsonObject.parse(rpc.params).protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: 'editor-peer', version: '1' }, + }; + else if (rpc.method === 'tools/list') + result = { + tools: [ + { + name: 'lookup', + description: 'Original remote description.', + inputSchema: { + type: 'object', + properties: { value: { type: 'string', enum: ['allowed'] } }, + required: ['value'], + additionalProperties: false, + }, + }, + ], + }; + else if (rpc.method === 'tools/call') { + calls.push(jsonObject.parse(rpc.params)); + effects.push(calls.at(-1)!.arguments); + result = { content: [{ type: 'text', text: 'MCP_RESULT' }] }; + } + response + .writeHead(200, { 'content-type': 'application/json' }) + .end(JSON.stringify({ jsonrpc: '2.0', id: rpc.id, result })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + closePeers.push(async () => { + server.closeAllConnections(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + }); + const address = server.address(); + if (!address || typeof address === 'string') + throw new Error('Missing peer address'); + return { url: `http://127.0.0.1:${address.port}/mcp`, calls, effects }; +} +async function providerFor(server: ModelServer) { + const config = await server.config(); + return new NativeProvider({ + baseUrl: (config['model_providers.fixture'] as { base_url: string }) + .base_url, + apiKey: 'synthetic-editor', + supportsWebsockets: false, + }); +} + +it('combines prepared edits, argument correction and result shaping through public Runner.run', async () => { + const remote = await peer(); + const effects: string[] = []; + const stages: string[] = []; + const reserve = tool({ + name: 'reserve', + description: 'Original declaration.', + parameters: z.object({ value: z.enum(['allowed', 'corrected']) }), + before: ({ callId, originalArguments }) => { + expect(callId).toBe('first'); + expect(originalArguments).toEqual({ value: 'allowed' }); + stages.push('before'); + return { + action: 'replaceArguments', + arguments: { value: 'corrected' as const }, + }; + }, + execute: ({ value }) => { + stages.push('execute'); + effects.push(value); + return 'RAW_FUNCTION_RESULT'; + }, + after: (output, { callId, effectiveArguments }) => { + expect(callId).toBe('first'); + expect(effectiveArguments).toEqual({ value: 'corrected' }); + expect(output.contentItems).toEqual([ + { type: 'inputText', text: 'RAW_FUNCTION_RESULT' }, + ]); + stages.push('after'); + return new ToolOutput([ + { type: 'inputText', text: 'SHAPED_FUNCTION_RESULT' }, + ]); + }, + }); + const mcp = new HttpMCP({ + name: 'records', + url: remote.url, + tools: ['lookup'], + approvalMode: 'approve', + approval: ({ call, effectiveArguments }) => { + expect(call.callId).toBe('remote'); + expect(effectiveArguments).toEqual({ value: 'allowed' }); + stages.push('mcp-approval'); + return effectiveArguments.value === 'allowed'; + }, + }); + const server = new ModelServer([ + [call('reserve', { value: 'allowed' }, { callId: 'first' })], + [ + call( + 'lookup', + { value: 'allowed' }, + { callId: 'remote', namespace: 'mcp__records' }, + ), + ], + [message('done')], + ]); + await using provider = await providerFor(server); + const snapshots: PreparedRequestData[] = []; + const context = { callback: () => 'resource' }; + const result = await Runner.run( + new Agent({ + model: 'gpt-5.5', + instructions: 'Original instructions.', + provider, + tools: [reserve, mcp], + }), + 'go', + { + context, + returnState: true, + prepareRequest({ + request, + preparationIndex, + recoveryAttempt, + context: supplied, + }) { + expect(supplied).toBe(context); + expect(recoveryAttempt).toBe(0); + expect(preparationIndex).toBe(snapshots.length); + snapshots.push(structuredClone(request)); + const input = z.array(jsonObject).parse(request.input); + if ( + !input.some( + (item) => + item.type === 'function_call_output' && item.call_id === 'first', + ) + ) + return; + request.instructions = 'Edited after the tool.'; + request.input.push({ + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'sampling-only-note' }], + }); + for (const presentation of request.tools) { + presentation.description = 'Sampled widened presentation.'; + presentation.parameters = { + type: 'object', + properties: { + value: { + type: 'string', + enum: ['allowed', 'corrected', 'forbidden'], + }, + }, + required: ['value'], + additionalProperties: false, + }; + } + return request; + }, + }, + ); + expect(result.finalOutput).toBe('done'); + expect(effects).toEqual(['corrected']); + expect(stages).toEqual(['before', 'execute', 'after', 'mcp-approval']); + expect(remote.calls).toHaveLength(1); + expect(remote.calls[0].name).toBe('lookup'); + expect(remote.calls[0].arguments).toEqual({ value: 'allowed' }); + expect(remote.effects).toEqual([{ value: 'allowed' }]); + expect(snapshots).toHaveLength(3); + const functionOutput = z + .array(jsonObject) + .parse(server.requests[1].input) + .filter( + (item) => + item.type === 'function_call_output' && item.call_id === 'first', + ); + expect(functionOutput).toHaveLength(1); + expect(JSON.stringify(functionOutput[0].output)).toContain( + 'SHAPED_FUNCTION_RESULT', + ); + expect(JSON.stringify(functionOutput[0].output)).not.toContain( + 'RAW_FUNCTION_RESULT', + ); + const mcpOutput = z + .array(jsonObject) + .parse(server.requests[2].input) + .filter( + (item) => + item.type === 'function_call_output' && item.call_id === 'remote', + ); + expect(mcpOutput).toHaveLength(1); + expect(JSON.stringify(mcpOutput[0].output)).toContain('MCP_RESULT'); + for (const request of server.requests.slice(1)) { + expect(JSON.stringify(request)).toContain('sampling-only-note'); + expect(JSON.stringify(request.input)).toContain('Edited after the tool.'); + const selected = JSON.stringify(request.tools); + expect(selected).toContain('Sampled widened presentation.'); + expect(selected).toContain('reserve'); + expect(selected).toContain('lookup'); + expect(selected).toContain('forbidden'); + expect(request).not.toHaveProperty('previous_response_id'); + } + expect(JSON.stringify(result.state)).not.toContain('sampling-only-note'); + expect(reserve.description).toBe('Original declaration.'); +}); + +it('keeps the original function validator when a sampled enum is widened', async () => { + const execute = vi.fn(() => 'effect'); + const reserve = tool({ + name: 'reserve', + parameters: z.object({ value: z.enum(['allowed']) }), + execute, + }); + const server = new ModelServer([ + [call('reserve', { value: 'forbidden' }, { callId: 'widened' })], + ]); + await using provider = await providerFor(server); + const agent = new Agent({ + model: 'gpt-5.5', + instructions: 'Use reserve.', + provider, + tools: [reserve], + }); + await expect( + agent.run('go', { + prepareRequest({ request }) { + request.tools[0].parameters = { + type: 'object', + properties: { + value: { type: 'string', enum: ['allowed', 'forbidden'] }, + }, + required: ['value'], + additionalProperties: false, + }; + return request; + }, + }), + ).rejects.toThrow(); + expect(execute).not.toHaveBeenCalled(); + expect(server.requests).toHaveLength(1); + expect(JSON.stringify(server.requests[0])).toContain('forbidden'); +}, 10_000); + +it('fails callback errors before dispatch, then permits a fresh operation', async () => { + const server = new ModelServer([[message('fresh')]]); + await using provider = await providerFor(server); + await using session = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + }).session(); + const failed = await session.submit('first', { + prepareRequest() { + throw new Error('synthetic callback failure'); + }, + }); + await expect(failed.wait()).rejects.toThrow('synthetic callback failure'); + expect(server.requests).toHaveLength(0); + const fresh = await session.submit('next', { + prepareRequest: () => undefined, + }); + expect((await fresh.wait()).finalOutput).toBe('fresh'); +}); + +it('joins a cancelled uncooperative callback before admitting the next root', async () => { + const server = new ModelServer([[message('fresh')]]); + await using provider = await providerFor(server); + await using session = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + }).session(); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + let signal!: AbortSignal; + const first = await session.submit('old', { + async prepareRequest(context) { + signal = context.signal; + entered.resolve(); + await release.promise; + return context.request; + }, + }); + let cancellation: Promise | undefined; + try { + await entered.promise; + let joined = false; + cancellation = first.cancel().then(() => { + joined = true; + }); + await vi.waitFor(() => expect(signal.aborted).toBe(true)); + expect(joined).toBe(false); + expect(server.requests).toHaveLength(0); + } finally { + release.resolve(); + await cancellation; + await first.cancel(); + } + await expect(first.wait()).rejects.toThrow(); + expect(server.requests).toHaveLength(0); + const next = await session.submit('fresh', { + prepareRequest: () => undefined, + }); + expect((await next.wait()).finalOutput).toBe('fresh'); +}); + +it('refuses active manual checkpoint readiness and export after preparation settles', async () => { + const approval = manualTool({ + name: 'approval', + description: 'Wait.', + parameters: z.object({}), + result: z.string(), + }); + const server = new ModelServer([ + [call('approval', {}, { callId: 'manual' })], + [message('continued')], + ]); + await using provider = await providerFor(server); + await using session = await new Agent({ + model: 'gpt-5.5', + instructions: 'Ask.', + provider, + tools: [approval], + }).session(); + const indices: number[] = []; + const turn = await session.submit('go', { + prepareRequest: ({ preparationIndex }) => { + indices.push(preparationIndex); + return undefined; + }, + }); + const pending = await turn.wait({ until: 'request' }); + if (!('requests' in pending)) throw new Error('Expected manual request'); + expect((await session.checkpointStatus({ kind: 'manualWait' })).status).toBe( + 'notReady', + ); + await expect(session.suspend()).rejects.toThrow( + 'callback-selected operation', + ); + await session.respond(approval.match(pending.requests[0])!.reply('approved')); + expect((await turn.wait()).finalOutput).toBe('continued'); + expect(indices).toEqual([0, 1]); +}); + +it('edits the combined function/MCP conversation after both results while original MCP policy blocks widened arguments before RPC', async () => { + const remote = await peer(); + const approvals: unknown[] = []; + const mcp = new HttpMCP({ + name: 'records', + url: remote.url, + tools: ['lookup'], + approvalMode: 'approve', + approval: ({ effectiveArguments }) => { + approvals.push(effectiveArguments.value); + return effectiveArguments.value === 'allowed' + ? true + : { action: 'stop' as const }; + }, + }); + let effects = 0; + const local = tool({ + name: 'reserve', + parameters: z.object({ value: z.enum(['allowed']) }), + execute: () => { + effects++; + return 'FUNCTION_RESULT'; + }, + }); + const server = new ModelServer([ + [call('reserve', { value: 'allowed' }, { callId: 'local' })], + [ + call( + 'lookup', + { value: 'allowed' }, + { namespace: 'mcp__records', callId: 'remote' }, + ), + ], + [ + call( + 'lookup', + { value: 'forbidden' }, + { namespace: 'mcp__records', callId: 'denied' }, + ), + ], + [message('done')], + ]); + await using provider = await providerFor(server); + await using session = await new Agent({ + model: 'gpt-5.5', + instructions: 'Use the selected tools.', + provider, + tools: [local, mcp], + }).session(); + const turn = await session.submit('go', { + prepareRequest({ request, preparationIndex }) { + if (preparationIndex < 2) return; + const history = JSON.stringify(request.input); + expect(history).toContain('FUNCTION_RESULT'); + expect(history).toContain('MCP_RESULT'); + request.instructions = 'Edited combined request.'; + request.input.push({ + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'sampling-only-combined-note' }], + }); + for (const presentation of request.tools.filter((tool) => + ['reserve', 'lookup'].includes(tool.name), + )) { + presentation.description = 'Edited selected description.'; + presentation.parameters = { + type: 'object', + properties: { + value: { type: 'string', enum: ['allowed', 'forbidden'] }, + }, + required: ['value'], + additionalProperties: false, + }; + } + return request; + }, + }); + await expect(turn.wait()).rejects.toThrow(); + expect(effects).toBe(1); + expect(approvals).toEqual(['allowed', 'forbidden']); + expect(remote.calls).toHaveLength(1); + expect(server.requests).toHaveLength(3); + const edited = JSON.stringify(server.requests[2]); + expect(edited).toContain('sampling-only-combined-note'); + expect(edited).toContain('Edited selected description.'); + expect(edited).toContain('forbidden'); + expect(server.requests[2]).not.toHaveProperty('previous_response_id'); +}); + +it('uses one captured edit for an HTTP retry and detaches retained callback objects', async () => { + const server = new ModelServer([ + { status: 500, body: 'synthetic retry' }, + [message('done')], + ]); + await using provider = await providerFor(server); + const lookup = tool({ + name: 'lookup', + description: 'Original lookup.', + parameters: z.object({ value: z.string().min(3) }), + execute: () => 'unused', + }); + let preparations = 0; + let retained!: PreparedRequestData; + const result = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + tools: [lookup], + }).run('go', { + prepareRequest({ request }) { + preparations++; + request.instructions = 'Captured edit.'; + request.tools[0].description = 'Captured description.'; + retained = request; + setTimeout(() => { + retained.instructions = 'Late retained mutation.'; + retained.tools[0].description = 'Late description mutation.'; + }, 0); + return request; + }, + }); + expect(result.finalOutput).toBe('done'); + expect(preparations).toBe(1); + expect(server.requests).toHaveLength(2); + for (const request of server.requests) { + expect(JSON.stringify(request)).toContain('Captured edit.'); + expect(JSON.stringify(request)).not.toContain('Late retained mutation.'); + expect(JSON.stringify(request.tools)).toContain('Captured description.'); + expect(JSON.stringify(request.tools)).toContain('minLength'); + expect(JSON.stringify(request.tools)).not.toContain( + 'Late description mutation.', + ); + } +}); + +it('exports idle canonical history and requires a fresh editor after restore', async () => { + const server = new ModelServer([[message('first')], [message('second')]]); + await using provider = await providerFor(server); + const agent = new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + }); + const first = await agent.run('go', { + returnState: true, + prepareRequest({ request }) { + request.input.push({ + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'ephemeral-note' }], + }); + return request; + }, + }); + expect(JSON.stringify(first.state)).not.toContain('ephemeral-note'); + const editor = vi.fn(() => undefined); + const second = await agent.run('next', { + state: first.state, + prepareRequest: editor, + }); + expect(second.finalOutput).toBe('second'); + expect(editor).toHaveBeenCalledTimes(1); + expect(JSON.stringify(server.requests[1])).not.toContain('ephemeral-note'); +}); + +it.each([true, false])( + 'refuses hidden model-change compaction only with editor=%s', + async (edited) => { + const server = new ModelServer([ + [message('original')], + [message('summary')], + [message('changed')], + ]); + await using provider = await providerFor(server); + await using session = await new Agent({ + instructions: 'Answer.', + provider, + }).session(); + await (await session.submit('establish history')).wait(); + await session.update({ model: 'gpt-5.5' }); + const editor = vi.fn(() => undefined); + const turn = await session.submit( + 'change model', + edited ? { prepareRequest: editor } : undefined, + ); + if (edited) { + await expect(turn.wait()).rejects.toThrow(/compaction/i); + const terminals = []; + for await (const event of turn.events()) + if (event.type === 'turn_finished') terminals.push(event); + expect(terminals).toHaveLength(1); + expect(terminals[0].status).toBe('failed'); + expect(server.requests).toHaveLength(1); + expect(editor).not.toHaveBeenCalled(); + } else { + expect((await turn.wait()).finalOutput).toBe('changed'); + expect(server.requests).toHaveLength(3); + } + }, +); + +it.each([true, false])( + 'refuses post-tool rollover before hidden dispatch only with editor=%s', + async (edited) => { + // The native factory is the existing low-level capacity seam. The public operation and all HTTP/tool work remain real. + const { binding } = await import('../../src/runtime/binding'); + const native = await binding(); + const create = native.create_engine; + const spy = vi + .spyOn(native, 'create_engine') + .mockImplementation((config, ...args) => { + const parsed = JSON.parse(config); + return create( + JSON.stringify({ + ...parsed, + model_context_window: 200000, + model_auto_compact_token_limit: 10000, + }), + ...args, + ); + }); + let effects = 0; + const reserve = tool({ + name: 'reserve', + parameters: z.object({}), + execute: () => { + effects++; + return 'completed tool effect'; + }, + }); + const server = new ModelServer( + [ + [call('reserve', {}, { callId: 'effect' })], + [message('summary')], + [message('done')], + ], + ['{"input_tokens":12000,"output_tokens":1,"total_tokens":12001}'], + ); + try { + await using provider = await providerFor(server); + await using session = await new Agent({ + model: 'gpt-5.5', + instructions: 'Use reserve once.', + provider, + tools: [reserve], + }).session(); + const editor = vi.fn(() => undefined); + const turn = await session.submit( + 'go', + edited ? { prepareRequest: editor } : undefined, + ); + if (edited) { + await expect(turn.wait()).rejects.toThrow(/compaction/i); + const terminals = []; + for await (const event of turn.events()) + if (event.type === 'turn_finished') terminals.push(event); + expect(terminals).toHaveLength(1); + expect(terminals[0].status).toBe('failed'); + expect(server.requests).toHaveLength(1); + expect(editor).toHaveBeenCalledTimes(1); + } else { + expect((await turn.wait()).finalOutput).toBe('done'); + expect(server.requests).toHaveLength(3); + } + expect(effects).toBe(1); + } finally { + spy.mockRestore(); + } + }, +); + +it('rejects a retired native reply capability while a later root awaits its own callback', async () => { + const { binding } = await import('../../src/runtime/binding'); + const native = await binding(); + const original = native.NativeThread.prototype._reply_prepared_request; + let retained: + | { owner: typeof native.NativeThread.prototype; receipt: string } + | undefined; + const spy = vi + .spyOn(native.NativeThread.prototype, '_reply_prepared_request') + .mockImplementation(function ( + this: typeof native.NativeThread.prototype, + receipt, + ) { + retained ??= { owner: this, receipt }; + return original.call(this, receipt); + }); + const release = Promise.withResolvers(); + try { + const server = new ModelServer([[message('old')], [message('new')]]); + await using provider = await providerFor(server); + await using session = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + }).session(); + await ( + await session.submit('old', { prepareRequest: () => undefined }) + ).wait(); + const entered = Promise.withResolvers(); + const next = await session.submit('new', { + async prepareRequest() { + entered.resolve(); + await release.promise; + return undefined; + }, + }); + try { + await entered.promise; + expect(retained).toBeDefined(); + expect(() => original.call(retained!.owner, retained!.receipt)).toThrow( + /Expired prepared request/, + ); + expect(server.requests).toHaveLength(1); + } finally { + release.resolve(); + } + expect((await next.wait()).finalOutput).toBe('new'); + expect(server.requests).toHaveLength(2); + } finally { + release.resolve(); + spy.mockRestore(); + } +}); + +it('reopens completed idle LocalStorage history with only a freshly supplied editor', async () => { + const home = await mkdtemp(join(tmpdir(), 'native-editor-storage-')); + try { + const storage = localStorage(join(home, 'profile')); + const server = new ModelServer([ + [message('persisted answer')], + [message('fresh answer')], + ]); + await using provider = await providerFor(server); + const agent = new Agent({ + model: 'gpt-5.5', + instructions: 'Canonical instructions.', + provider, + }); + const oldEditor = vi.fn(({ request }: { request: PreparedRequestData }) => { + request.instructions = 'Old sampling instructions.'; + request.input.push({ + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'sampling-only-local-note' }], + }); + return request; + }); + let id!: string; + let canonical!: unknown[]; + { + await using first = await agent.session({ storage }); + id = first.id; + expect( + ( + await ( + await first.submit('original user input', { + prepareRequest: oldEditor, + }) + ).wait() + ).finalOutput, + ).toBe('persisted answer'); + canonical = []; + for await (const item of first.items()) canonical.push(item); + expect(JSON.stringify(canonical)).not.toContain( + 'sampling-only-local-note', + ); + expect(JSON.stringify(canonical)).not.toContain( + 'Old sampling instructions.', + ); + } + await using reopened = await agent.session({ storage, resume: id }); + expect(reopened.id).toBe(id); + const restored = []; + for await (const item of reopened.items()) restored.push(item); + expect(restored).toEqual(canonical); + const freshEditor = vi.fn( + ({ + request, + preparationIndex, + recoveryAttempt, + }: { + request: PreparedRequestData; + preparationIndex: number; + recoveryAttempt: number; + }) => { + expect(preparationIndex).toBe(0); + expect(recoveryAttempt).toBe(0); + expect(JSON.stringify(request)).not.toContain( + 'sampling-only-local-note', + ); + expect(request.instructions).not.toContain( + 'Old sampling instructions.', + ); + request.instructions = 'Fresh sampling instructions.'; + return request; + }, + ); + const next = await reopened.submit('next user input', { + prepareRequest: freshEditor, + }); + expect((await next.wait()).finalOutput).toBe('fresh answer'); + expect(oldEditor).toHaveBeenCalledTimes(1); + expect(freshEditor).toHaveBeenCalledTimes(1); + expect(JSON.stringify(server.requests[1])).toContain('original user input'); + expect(JSON.stringify(server.requests[1])).toContain('persisted answer'); + expect(JSON.stringify(server.requests[1])).toContain( + 'Fresh sampling instructions.', + ); + expect(JSON.stringify(server.requests[1])).not.toContain( + 'sampling-only-local-note', + ); + server.assertExhausted(); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + +it('prepares again when outer stream recovery rebuilds the prompt', async () => { + // Match Python's authored recovery oracle: this is an SSE response.failed, not an HTTP status retry. + const failure = JSON.stringify({ + type: 'response.failed', + response: { + error: { + type: 'server_error', + code: 'server_error', + message: 'retry synthetic stream', + }, + }, + }); + const server = new ModelServer([[], [message('recovered')]], [], undefined, [ + failure, + ]); + await using provider = await providerFor(server); + const indices: [number, number][] = []; + const result = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + }).run('Try', { + prepareRequest({ request, preparationIndex, recoveryAttempt }) { + indices.push([preparationIndex, recoveryAttempt]); + return request; + }, + }); + expect(result.finalOutput).toBe('recovered'); + expect(indices).toEqual([ + [0, 0], + [1, 1], + ]); + expect(server.requests).toHaveLength(2); + expect( + server.requests.every((request) => !('previous_response_id' in request)), + ).toBe(true); + server.assertExhausted(); +}, 30_000); + +it('rejects correction-enabled Session defaults and per-operation overrides before native submission', async () => { + const server = new ModelServer([]); + await using provider = await providerFor(server); + const outputType = { + mode: 'prompted' as const, + schema: z.object({ answer: z.string() }), + maxRetries: 1, + }; + const editor = vi.fn(() => undefined); + await using repairing = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + outputType, + }).session(); + await expect( + repairing.submit('default', { prepareRequest: editor }), + ).rejects.toThrow('output correction attempts'); + await using plain = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + }).session(); + await expect( + plain.submitWithOutput('override', outputType, { prepareRequest: editor }), + ).rejects.toThrow('output correction attempts'); + await expect( + plain.enqueue('queued override', outputType, { prepareRequest: editor }), + ).rejects.toThrow('output correction attempts'); + expect(server.requests).toHaveLength(0); + expect(editor).not.toHaveBeenCalled(); +}); + +it.each(['native', 'prompted-zero'] as const)( + 'supports edited %s output without correction requests', + async (profile) => { + const server = new ModelServer([[message('{"answer":"valid"}')]]); + await using provider = await providerFor(server); + const schema = z.object({ answer: z.string() }); + const outputType = + profile === 'native' + ? { mode: 'native' as const, schema } + : { mode: 'prompted' as const, schema, maxRetries: 0 }; + const editor = vi.fn(() => undefined); + const result = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + outputType, + }).run('go', { prepareRequest: editor }); + expect(result.finalOutput).toEqual({ answer: 'valid' }); + expect(editor).toHaveBeenCalledTimes(1); + expect(server.requests).toHaveLength(1); + }, +); + +it('preserves ordinary output correction when prepareRequest is omitted', async () => { + const server = new ModelServer([ + [message('invalid output')], + [message('{"answer":"corrected"}')], + ]); + await using provider = await providerFor(server); + const outputType = { + mode: 'prompted' as const, + schema: z.object({ answer: z.string() }), + maxRetries: 1, + }; + const result = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + outputType, + }).run('go'); + expect(result.finalOutput).toEqual({ answer: 'corrected' }); + expect(server.requests).toHaveLength(2); +}); + +it.each(['no-edit', 'unchanged', 'description', 'parameters', 'both'] as const)( + 'keeps clean presentation and field-specific schema fidelity for %s', + async (edit) => { + const selected = tool({ + name: 'lookup', + description: 'Original raw-schema description.', + parameters: z.object({ value: z.string().min(3) }), + execute: () => 'unused', + }); + const server = new ModelServer([[message('done')]]); + await using provider = await providerFor(server); + const replacement = { + type: 'object', + properties: { value: { type: 'string', enum: ['allowed', 'wider'] } }, + required: ['value'], + additionalProperties: false, + }; + const result = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + tools: [selected], + }).run('go', { + prepareRequest({ request }) { + expect(request.tools[0].description).toBe( + 'Original raw-schema description.', + ); + expect(request.tools[0].description).not.toContain('[agents-schema:'); + if (edit === 'no-edit') return; + if (edit === 'description' || edit === 'both') + request.tools[0].description = 'Edited clean description.'; + if (edit === 'parameters' || edit === 'both') + request.tools[0].parameters = replacement; + return request; + }, + }); + expect(result.finalOutput).toBe('done'); + const declaration = z + .array(jsonObject) + .parse(server.requests[0].tools) + .find((tool) => tool.name === 'lookup'); + expect(declaration).toBeDefined(); + expect(declaration!.description).toBe( + edit === 'description' || edit === 'both' + ? 'Edited clean description.' + : 'Original raw-schema description.', + ); + expect(JSON.stringify(declaration)).not.toContain('[agents-schema:'); + expect(declaration!.parameters).toEqual( + edit === 'parameters' || edit === 'both' + ? replacement + : selected.parameters, + ); + expect(server.requests).toHaveLength(1); + }, +); + +it('rejects a lossy nested schema key before HTTP and before tool effects', async () => { + const execute = vi.fn(() => 'effect'); + const selected = tool({ + name: 'lookup', + parameters: z.object({ value: z.string() }), + execute, + }); + const server = new ModelServer([]); + await using provider = await providerFor(server); + await expect( + new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + tools: [selected], + }).run('go', { + prepareRequest({ request }) { + request.tools[0].parameters = { + type: 'object', + properties: { value: { type: 'string', unknown_null_key: null } }, + required: ['value'], + }; + return request; + }, + }), + ).rejects.toThrow(/loses fields|prepared.request/i); + expect(execute).not.toHaveBeenCalled(); + expect(server.requests).toHaveLength(0); +}); diff --git a/packages/agents/test/prepared-request.test.ts b/packages/agents/test/prepared-request.test.ts new file mode 100644 index 000000000..fded6643e --- /dev/null +++ b/packages/agents/test/prepared-request.test.ts @@ -0,0 +1,199 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as nativeBinding from '../src/runtime/binding'; +import * as turnReceipt from '../src/runtime/turn-receipt'; +import { Agent, NativeProvider, tool, HttpMCP } from '../src'; +import { z } from 'zod'; +import { + capturePreparedRequest, + preparedRequestFromNative, + type PreparedRequestData, +} from '../src/runtime/prepared-request'; + +afterEach(() => vi.restoreAllMocks()); + +const native = () => ({ + instructions: 'original', + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello' }], + }, + ], + tools: [ + { + namespace: null, + name: 'reserve', + kind: 'function', + strict: true, + defer_loading: null, + description: 'reserve allowed', + parameters: { + type: 'object', + properties: { value: { type: 'string', enum: ['allowed'] } }, + }, + }, + ], +}); + +describe('prepared request capture boundary', () => { + it('detaches native input and preserves public camelCase evidence through strict capture', () => { + const source = native(); + const request = preparedRequestFromNative(source); + request.instructions = 'sampling only'; + (request.input[0] as { role: string }).role = 'system'; + request.tools[0].description = 'changed'; + expect(source).toEqual(native()); + const captured = capturePreparedRequest(request); + request.instructions = 'retained mutation'; + expect(JSON.parse(captured)).toMatchObject({ + instructions: 'sampling only', + tools: [{ defer_loading: null, description: 'changed' }], + }); + expect(captured).not.toContain('deferLoading'); + }); + it.each(['extra', 'defer_loading'])( + 'rejects unknown public fields even when undefined: %s', + (key) => { + const request = preparedRequestFromNative(native()); + Object.assign(request, { [key]: undefined }); + expect(() => capturePreparedRequest(request)).toThrow( + 'Unknown prepared request field', + ); + const tool = preparedRequestFromNative(native()); + Object.assign(tool.tools[0], { [key]: undefined }); + expect(() => capturePreparedRequest(tool)).toThrow( + 'Unknown prepared request field', + ); + }, + ); + it.each([undefined, NaN, Infinity, new Date(), { toJSON: () => ({}) }])( + 'rejects lossy nested JSON: %s', + (invalid) => { + const request = preparedRequestFromNative(native()); + request.input.push({ unknown: invalid } as never); + expect(() => capturePreparedRequest(request)).toThrow(); + }, + ); + it('rejects omitted fields, cycles, sparse arrays, and non-object schemas', () => { + const request = preparedRequestFromNative(native()); + delete (request as Partial).instructions; + expect(() => capturePreparedRequest(request)).toThrow(); + const cyclic = preparedRequestFromNative(native()); + cyclic.input.push(cyclic as never); + expect(() => capturePreparedRequest(cyclic)).toThrow(); + const sparse = preparedRequestFromNative(native()); + sparse.input = new Array(2); + expect(() => capturePreparedRequest(sparse)).toThrow(); + const schema = preparedRequestFromNative(native()); + schema.tools[0].parameters = [] as never; + expect(() => capturePreparedRequest(schema)).toThrow(); + }); +}); + +it.each(['websocket', 'deferred-function', 'deferred-mcp'] as const)( + 'rejects unsupported %s before provider acquisition or editor invocation', + async (profile) => { + const provider = new NativeProvider( + profile === 'websocket' ? {} : { supportsWebsockets: false }, + ); + const acquire = vi.spyOn(provider, '_acquire'); + const editor = vi.fn(() => undefined); + const tools = + profile === 'deferred-function' + ? [ + tool({ + name: 'lookup', + deferLoading: true, + parameters: z.object({}), + execute: () => 'unreachable', + }), + ] + : profile === 'deferred-mcp' + ? [ + new HttpMCP({ + name: 'remote', + tools: ['lookup'], + deferLoading: true, + url: 'http://127.0.0.1:1/mcp', + }), + ] + : []; + await expect( + new Agent({ instructions: 'Answer.', provider, tools }).run('go', { + prepareRequest: editor, + }), + ).rejects.toThrow( + profile === 'websocket' ? 'supportsWebsockets: false' : 'eager function', + ); + expect(acquire).not.toHaveBeenCalled(); + expect(editor).not.toHaveBeenCalled(); + await provider.close(); + }, +); + +it.each(['default', 'override'] as const)( + 'rejects resolved %s output correction before provider acquisition', + async (source) => { + const provider = new NativeProvider({ supportsWebsockets: false }); + const acquire = vi.spyOn(provider, '_acquire'); + const loadFiles = vi + .spyOn(turnReceipt, 'loadInstructionFiles') + .mockRejectedValue( + new Error('instruction loading must follow editor preflight'), + ); + const prepareRequest = vi.fn(() => undefined); + const outputType = { + mode: 'prompted' as const, + schema: z.object({ answer: z.string() }), + maxRetries: 1, + }; + const agent = new Agent({ + instructions: 'Answer.', + provider, + ...(source === 'default' ? { outputType } : {}), + }); + const view = + source === 'default' ? agent : agent.withOutputType(outputType); + await expect( + view.run('go', { + prepareRequest, + instructionFiles: ['unreachable-instructions.md'], + }), + ).rejects.toThrow('output correction attempts'); + expect(loadFiles).not.toHaveBeenCalled(); + expect(acquire).not.toHaveBeenCalled(); + expect(prepareRequest).not.toHaveBeenCalled(); + await provider.close(); + }, +); + +it.each(['native', 'prompted-zero', 'omitted-editor'] as const)( + 'retains public admission for %s output', + async (profile) => { + const provider = new NativeProvider({ supportsWebsockets: false }); + const boundary = new Error('native loader boundary'); + const loader = vi + .spyOn(nativeBinding, 'binding') + .mockRejectedValueOnce(boundary); + const acquire = vi.spyOn(provider, '_acquire'); + const outputType = + profile === 'native' + ? { mode: 'native' as const, schema: z.object({ answer: z.string() }) } + : { + mode: 'prompted' as const, + schema: z.object({ answer: z.string() }), + maxRetries: profile === 'prompted-zero' ? 0 : 1, + }; + const agent = new Agent({ instructions: 'Answer.', provider, outputType }); + await expect( + agent.run( + 'go', + profile === 'omitted-editor' ? {} : { prepareRequest: () => undefined }, + ), + ).rejects.toBe(boundary); + expect(loader).toHaveBeenCalledTimes(1); + expect(acquire).not.toHaveBeenCalled(); + await provider.close(); + }, +); From 1a2b6cc9d8da983879ec6c6dbc3cbd2f9d3eb44e Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:34:44 +0000 Subject: [PATCH 05/34] Send a real failed SSE response in the request recovery test --- packages/agents/test/native/fixtures/model-server.ts | 6 ++++-- .../test/native/prepared-request-native.test.ts | 11 ++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/agents/test/native/fixtures/model-server.ts b/packages/agents/test/native/fixtures/model-server.ts index 3103a3e07..2daddfb38 100644 --- a/packages/agents/test/native/fixtures/model-server.ts +++ b/packages/agents/test/native/fixtures/model-server.ts @@ -4,7 +4,9 @@ import { createServer, type IncomingHttpHeaders } from 'node:http'; import type { JsonObject, JsonValue } from '../../../src/runtime/binding'; import type { NativeConfig } from '../../../src/runtime/config'; -type ResponseValue = JsonObject[] | { status: number; body: string }; +type ResponseValue = + | JsonObject[] + | { status: number; body: string; headers?: Record }; type Response = | ResponseValue | ((request: JsonObject) => ResponseValue | Promise); @@ -65,7 +67,7 @@ export class ModelServer { const output = typeof selected === 'function' ? await selected(requestBody) : selected; if (!Array.isArray(output)) { - response.writeHead(output.status).end(output.body); + response.writeHead(output.status, output.headers).end(output.body); return; } const id = `response-${requestIndex + 1}`; diff --git a/packages/agents/test/native/prepared-request-native.test.ts b/packages/agents/test/native/prepared-request-native.test.ts index 975849d66..95a30277a 100644 --- a/packages/agents/test/native/prepared-request-native.test.ts +++ b/packages/agents/test/native/prepared-request-native.test.ts @@ -807,7 +807,7 @@ it('reopens completed idle LocalStorage history with only a freshly supplied edi }); it('prepares again when outer stream recovery rebuilds the prompt', async () => { - // Match Python's authored recovery oracle: this is an SSE response.failed, not an HTTP status retry. + // A failed event on a successful HTTP response exercises outer stream recovery. const failure = JSON.stringify({ type: 'response.failed', response: { @@ -818,8 +818,13 @@ it('prepares again when outer stream recovery rebuilds the prompt', async () => }, }, }); - const server = new ModelServer([[], [message('recovered')]], [], undefined, [ - failure, + const server = new ModelServer([ + { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + body: `data: ${failure}\n\n`, + }, + [message('recovered')], ]); await using provider = await providerFor(server); const indices: [number, number][] = []; From 3b6e67f37e45e43e0df6ec12a97ae7ff091667f6 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:49:35 +0000 Subject: [PATCH 06/34] fix: bind response completeness and repair history identities --- packages/agents/src/runtime/history.ts | 17 +++++ .../native/history-counters-native.test.ts | 76 +++++++++++++++++++ .../test/native/history-counters.test.ts | 21 ++++- .../native/response-history-native.test.ts | 14 ++++ 4 files changed, 127 insertions(+), 1 deletion(-) diff --git a/packages/agents/src/runtime/history.ts b/packages/agents/src/runtime/history.ts index 074dc5a41..f2cc90aa9 100644 --- a/packages/agents/src/runtime/history.ts +++ b/packages/agents/src/runtime/history.ts @@ -216,6 +216,12 @@ export const repairTurnWireSchema = z (turn.receipt.threadId === turn.sessionId && turn.receipt.turnId === turn.id), 'Repair receipt identity must match its recorded turn', + ) + .refine( + (turn) => + turn.responses === undefined || + turn.responses.every((response) => response.turnId === turn.id), + 'Repair response identity must match its recorded turn', ) satisfies z.ZodType; export const repairItemWireSchema = z .object({ @@ -1501,6 +1507,15 @@ export function projectHistory( if (receipt) turn.receipt = receipt; const total = responseUsage.get(id); turn.usage = usage.get(id) ?? (total ? recordedUsage(total) : null); + const accountedIds = new Set( + (responses.get(id) ?? []) + .filter( + (response) => + response.usageSource === 'native_normalized' && + response.id !== null, + ) + .map((response) => response.id), + ); const nativeIds = observations.get(id); // Only an exact native link identifies the accounting copy of an observation. const projectedIds = new Set(); @@ -1521,6 +1536,8 @@ export function projectHistory( retained.some( (response) => response.usageSource === 'received' && + response.id !== null && + accountedIds.has(response.id) && response.usage?.inputTokensDetails?.cacheWriteTokens === undefined, ) ) diff --git a/packages/agents/test/native/history-counters-native.test.ts b/packages/agents/test/native/history-counters-native.test.ts index 0edfd790f..892c84bcc 100644 --- a/packages/agents/test/native/history-counters-native.test.ts +++ b/packages/agents/test/native/history-counters-native.test.ts @@ -1846,3 +1846,79 @@ it('preserves cache-write availability through native totals, receipts and resto await ModelServer.closeAll(); } }, 60_000); + +it('keeps a successful accounted cache-write count after an unlinked failed attempt', async () => { + const failed = JSON.stringify({ + type: 'response.failed', + response: { + id: 'failed-unlinked', + error: { code: 'unknown_error', message: 'Temporary fixture failure.' }, + usage: { input_tokens: 1, output_tokens: 0, total_tokens: 1 }, + }, + }); + const completed = JSON.stringify({ + type: 'response.completed', + response: { + id: 'successful-linked', + usage: { + input_tokens: 10, + input_tokens_details: { cached_tokens: 2, cache_write_tokens: 4 }, + output_tokens: 3, + total_tokens: 13, + }, + }, + }); + const server = new ModelServer([[], [message('done')]], [], undefined, [ + failed, + completed, + ]); + try { + const config = await server.config(); + await using provider = new NativeProvider({ + baseUrl: (config['model_providers.fixture'] as { base_url: string }) + .base_url, + apiKey: 'synthetic-retry-availability', + supportsWebsockets: false, + }); + const agent = new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + }); + await using session = await agent.session(); + expect( + (await (await session.submit('retry once')).wait()).finalOutput, + ).toBe('done'); + const turns: RecordedTurn[] = []; + for await (const row of session.turns()) turns.push(row); + const recorded = turns[0]; + expect(recorded.usage?.totalTokens).toBe(13); + expect(recorded.usage?.inputTokensDetails.cacheWriteTokens).toBe(4); + const rejected = recorded.responses?.find( + (row) => row.responseId === 'failed-unlinked', + ); + const accepted = recorded.responses?.find( + (row) => row.responseId === 'successful-linked', + ); + expect(rejected?.status).toBe('failed'); + expect( + rejected?.usage?.inputTokensDetails?.cacheWriteTokens, + ).toBeUndefined(); + expect(accepted?.status).toBe('completed'); + const state = await session.saveState(); + const accountingLinks = state.history + .filter((row) => row.type === 'token_usage_record') + .map((row) => (row.payload as JsonObject).response_observation_id); + expect(accountingLinks).not.toContain(rejected?.id); + expect(accountingLinks).toContain(accepted?.id); + await using restored = await agent.session({ state }); + const restoredTurns: RecordedTurn[] = []; + for await (const row of restored.turns()) restoredTurns.push(row); + expect(restoredTurns[0].usage?.inputTokensDetails.cacheWriteTokens).toBe(4); + expect(restoredTurns[0].responses).toEqual(recorded.responses); + expect(server.requests).toHaveLength(2); + server.assertExhausted(); + } finally { + await ModelServer.closeAll(); + } +}, 30_000); diff --git a/packages/agents/test/native/history-counters.test.ts b/packages/agents/test/native/history-counters.test.ts index 2ed56631a..907e1c256 100644 --- a/packages/agents/test/native/history-counters.test.ts +++ b/packages/agents/test/native/history-counters.test.ts @@ -515,7 +515,25 @@ describe('recorded response observations', () => { JSON.parse(JSON.stringify(recordedTurnToWire(turn))), ); expect(recordedTurnFromWire(wire)).toEqual(turn); + const wrongTurn = { + ...wire, + responses: wire.responses?.map((response) => ({ + ...response, + turnId: 'contradictory-turn', + })), + }; + expect(repairTurnWireSchema.safeParse(wrongTurn).success).toBe(false); } + const failed = observed('unlinked-failure', {}, { status: 'failed' }); + const success = observed('linked-success', { cache_write_input_tokens: 4 }); + const successfulAccounting = accounting('linked-success'); + successfulAccounting.payload.turn_token_usage = { + cache_write_input_tokens: 4, + }; + expect( + project([failed, success, successfulAccounting]).usage?.inputTokensDetails + .cacheWriteTokens, + ).toBe(4); const partial = accounting('known'); partial.payload.turn_token_usage = { cache_write_input_tokens: 4 }; for (const observations of [ @@ -529,7 +547,8 @@ describe('recorded response observations', () => { ], ]) { expect( - project([...observations, partial]).usage?.inputTokensDetails, + project([...observations, accounting('unknown'), partial]).usage + ?.inputTokensDetails, ).not.toHaveProperty('cacheWriteTokens'); } }); diff --git a/packages/agents/test/native/response-history-native.test.ts b/packages/agents/test/native/response-history-native.test.ts index 1b1c7d992..803a6eae1 100644 --- a/packages/agents/test/native/response-history-native.test.ts +++ b/packages/agents/test/native/response-history-native.test.ts @@ -279,6 +279,20 @@ it('keeps negative counters unknown in retained prompted-repair history across s await using restored = await agent.session({ state }); expect(await turnsOf(restored)).toEqual(recorded); expect((await restored.saveState()).format_version).toBe(4); + const contradictory = JSON.parse( + JSON.stringify({ + ...state, + repair_turns: state.repair_turns.map((row) => ({ + ...row, + responses: row.responses?.map((response) => ({ + ...response, + turnId: 'contradictory-turn', + })), + })), + }), + ); + await expect(agent.session({ state: contradictory })).rejects.toThrow(); + expect(await turnsOf(session)).toEqual(recorded); expect(server.requests).toHaveLength(3); server.assertExhausted(); From 23304ddecdbc6062c0d7b423a6085066159dc2c6 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:51:55 +0000 Subject: [PATCH 07/34] Report rejected request-editor compaction after a tool call --- .../0054-native-prepared-request.patch | 28 +++++++++++++------ packages/agents/native/patches/series.json | 4 +-- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/agents/native/patches/0054-native-prepared-request.patch b/packages/agents/native/patches/0054-native-prepared-request.patch index cde50c700..d25752934 100644 --- a/packages/agents/native/patches/0054-native-prepared-request.patch +++ b/packages/agents/native/patches/0054-native-prepared-request.patch @@ -367,10 +367,10 @@ index 0000000000000000000000000000000000000000..a70e5faf6d3b77f8fa6d90f9c92276ae + } +} diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs -index 6995888c4fff7fd6fd135db322e85db302c09462..e3ef2d324922a0ffbaed57218fd3484774208917 100644 +index 6995888c4fff7fd6fd135db322e85db302c09462..ae5e9360041d44bb86015c1d7320eda62b164a7c 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs -@@ -1459,6 +1459,16 @@ async fn run_auto_compact( +@@ -1459,6 +1459,26 @@ async fn run_auto_compact( phase: CompactionPhase, ) -> CodexResult<()> { let turn_context = &step_context.turn; @@ -381,13 +381,23 @@ index 6995888c4fff7fd6fd135db322e85db302c09462..e3ef2d324922a0ffbaed57218fd34847 + .iter() + .any(|contributor| contributor.prepares_request(&turn_context.extension_data)) + { -+ return Err(CodexErr::Fatal("automatic compaction is unsupported for an operation with prepare_request; shorten the input or start a new operation".into())); ++ let err = CodexErr::Fatal("automatic compaction is unsupported for an operation with prepare_request; shorten the input or start a new operation".into()); ++ // The guard bypasses the compact task that normally reports mid-turn failures. ++ if matches!(phase, CompactionPhase::MidTurn) { ++ sess.track_turn_codex_error(turn_context.as_ref(), &err); ++ sess.send_event( ++ turn_context.as_ref(), ++ EventMsg::Error(err.to_error_event(None)), ++ ) ++ .await; ++ } ++ return Err(err); + } + if matches!(phase, CompactionPhase::PreTurn) && crate::guardian::is_basic_session_source(&turn_context.session_source) && !crate::guardian::should_compact_guardian_input(sess)? -@@ -1602,6 +1612,7 @@ pub(crate) fn build_prompt( +@@ -1602,6 +1622,7 @@ pub(crate) fn build_prompt( parallel_tool_calls: true, tool_admission: None, sampling_stop: None, @@ -395,7 +405,7 @@ index 6995888c4fff7fd6fd135db322e85db302c09462..e3ef2d324922a0ffbaed57218fd34847 base_instructions, output_schema: turn_context.final_output_json_schema.clone(), output_schema_strict: !crate::guardian::is_basic_session_source( -@@ -1653,6 +1664,7 @@ async fn run_sampling_request( +@@ -1653,6 +1674,7 @@ async fn run_sampling_request( ); let max_retries = turn_context.provider.info().stream_max_retries(); let mut retry_state = ResponsesStreamRetryState::default(); @@ -403,7 +413,7 @@ index 6995888c4fff7fd6fd135db322e85db302c09462..e3ef2d324922a0ffbaed57218fd34847 let mut initial_input = Some(input); let mut original_input = None; let mut executed_tool_calls_by_output = HashMap::new(); -@@ -1685,6 +1697,27 @@ async fn run_sampling_request( +@@ -1685,6 +1707,27 @@ async fn run_sampling_request( let responses_metadata = sess .responses_metadata(step_context.as_ref(), CodexResponsesRequestKind::Turn) .await; @@ -431,7 +441,7 @@ index 6995888c4fff7fd6fd135db322e85db302c09462..e3ef2d324922a0ffbaed57218fd34847 if crate::guardian::is_basic_session_source(&turn_context.session_source) { crate::guardian::prepare_guardian_prompt( &sess, -@@ -1694,6 +1727,33 @@ async fn run_sampling_request( +@@ -1694,6 +1737,33 @@ async fn run_sampling_request( ) .await?; } @@ -465,7 +475,7 @@ index 6995888c4fff7fd6fd135db322e85db302c09462..e3ef2d324922a0ffbaed57218fd34847 let err = match try_run_sampling_request( tool_runtime.clone(), Arc::clone(&sess), -@@ -1702,7 +1762,7 @@ async fn run_sampling_request( +@@ -1702,7 +1772,7 @@ async fn run_sampling_request( client_session, &responses_metadata, Arc::clone(&turn_diff_tracker), @@ -474,7 +484,7 @@ index 6995888c4fff7fd6fd135db322e85db302c09462..e3ef2d324922a0ffbaed57218fd34847 cancellation_token.child_token(), ) .await -@@ -1758,6 +1818,7 @@ async fn run_sampling_request( +@@ -1758,6 +1828,7 @@ async fn run_sampling_request( )); } retry??; diff --git a/packages/agents/native/patches/series.json b/packages/agents/native/patches/series.json index 96ddff1cb..0a04fd06f 100644 --- a/packages/agents/native/patches/series.json +++ b/packages/agents/native/patches/series.json @@ -214,8 +214,8 @@ }, { "path": "0054-native-prepared-request.patch", - "sha256": "8a744feaead0a45f54be30df5d4be237b1aca29995d96eeb410c11b7f13cfd53" + "sha256": "88d33dfacf650cf4e3f20983bbfca0083e5cab245efda3fbe778e0e084113e1f" } ], - "result_tree": "5296ecb1f56ece03e66636e1ccda16fc723fa956" + "result_tree": "cdfb5e9dc38a0a9af42a8abdadbfce261131ccb8" } From 990aa5a9c60d226d72e568c86563442f4ed35cb5 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:28:24 +0000 Subject: [PATCH 08/34] feat: observe passive native request metadata --- .changeset/passive-request-metadata.md | 5 + helpers/vitest/nativeAgentTestProfile.ts | 1 + packages/agents/native/index.d.ts | 1 + .../0054-native-request-metadata.patch | 702 ++++++++++++++++++ packages/agents/native/patches/series.json | 6 +- packages/agents/native/rust/engine.rs | 6 + packages/agents/native/rust/lib.rs | 6 +- .../agents/native/rust/native_interception.rs | 2 + packages/agents/native/rust/ops.rs | 25 +- .../agents/native/rust/request_metadata.rs | 174 +++++ packages/agents/src/index.ts | 2 + packages/agents/src/runtime/observation.ts | 34 + .../agents/src/runtime/request-metadata.ts | 101 +++ packages/agents/src/runtime/session.ts | 79 +- .../native/request-metadata-native.test.ts | 569 ++++++++++++++ .../test/native/request-metadata.test.ts | 99 +++ .../native/worker-receipts-native.test.ts | 22 + 17 files changed, 1816 insertions(+), 18 deletions(-) create mode 100644 .changeset/passive-request-metadata.md create mode 100644 packages/agents/native/patches/0054-native-request-metadata.patch create mode 100644 packages/agents/native/rust/request_metadata.rs create mode 100644 packages/agents/src/runtime/request-metadata.ts create mode 100644 packages/agents/test/native/request-metadata-native.test.ts create mode 100644 packages/agents/test/native/request-metadata.test.ts diff --git a/.changeset/passive-request-metadata.md b/.changeset/passive-request-metadata.md new file mode 100644 index 000000000..baa14397c --- /dev/null +++ b/.changeset/passive-request-metadata.md @@ -0,0 +1,5 @@ +--- +'@openai/agents': minor +--- + +Expose passive root request metadata and optional links to retained response occurrences through existing observation streams. Report resolved serialized settings and the selected client invocation transport without retaining request bodies or changing inference behavior. diff --git a/helpers/vitest/nativeAgentTestProfile.ts b/helpers/vitest/nativeAgentTestProfile.ts index a09cad1f4..4cd4e5680 100644 --- a/helpers/vitest/nativeAgentTestProfile.ts +++ b/helpers/vitest/nativeAgentTestProfile.ts @@ -17,4 +17,5 @@ export const nativeAgentTestIncludes = 'test/classic/**/*.test.ts', 'test/native/output-state.test.ts', 'test/native/repair-observation.test.ts', + 'test/native/request-metadata.test.ts', ]; diff --git a/packages/agents/native/index.d.ts b/packages/agents/native/index.d.ts index 49cc83461..be693da56 100644 --- a/packages/agents/native/index.d.ts +++ b/packages/agents/native/index.d.ts @@ -187,6 +187,7 @@ export class NativeThread { _complete_manual_continuation( outputSchemaJson?: string, selectionJson?: string, + observationSubmissionId?: string, ): Promise; read_thread_history(subagent_id: string | null): Promise; read_checklist(subagent_id?: string | null): Promise; diff --git a/packages/agents/native/patches/0054-native-request-metadata.patch b/packages/agents/native/patches/0054-native-request-metadata.patch new file mode 100644 index 000000000..377be029a --- /dev/null +++ b/packages/agents/native/patches/0054-native-request-metadata.patch @@ -0,0 +1,702 @@ +diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs +index 867efce..2b70746 100644 +--- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs ++++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs +@@ -968,6 +968,65 @@ mod tests { + use std::collections::HashMap; + use std::sync::Arc; + ++ #[tokio::test] ++ #[expect( ++ clippy::await_holding_invalid_type, ++ reason = "the test must hold the socket lock across invocation to prevent a network write" ++ )] ++ async fn dropping_invocation_receiver_while_connection_is_locked_sends_no_frame() { ++ let (tx_command, mut rx_command) = mpsc::channel(1); ++ let (_tx_message, rx_message) = mpsc::unbounded_channel(); ++ let stream = WsStream { ++ tx_command, ++ rx_message, ++ pump_task: tokio::spawn(std::future::pending::<()>()), ++ upgrade_rate_limits: None, ++ }; ++ let connection = ++ ResponsesWebsocketConnection::new(stream, Duration::from_secs(1), false, None, None); ++ // stream_request returns its receiver before its worker acquires this ++ // lock. The caller may already have observed client invocation. ++ let held = connection.stream.lock().await; ++ let request = ResponsesApiRequest { ++ model: "test-model".into(), ++ input: Vec::new(), ++ tools: None, ++ tool_choice: "auto".into(), ++ parallel_tool_calls: false, ++ reasoning: None, ++ store: false, ++ stream: true, ++ stream_options: None, ++ include: Vec::new(), ++ service_tier: None, ++ prompt_cache_key: None, ++ text: None, ++ client_metadata: None, ++ access_programs: None, ++ }; ++ let response = connection ++ .stream_request( ++ ResponsesWsRequest::ResponseCreate(ResponseCreateWsRequest::from(&request)), ++ false, ++ None, ++ ) ++ .await ++ .expect("invocation returns before the socket lock is acquired"); ++ drop(response); ++ drop(held); ++ tokio::time::timeout(Duration::from_secs(1), async { ++ while Arc::strong_count(&connection.stream) > 1 { ++ tokio::task::yield_now().await; ++ } ++ }) ++ .await ++ .expect("the dropped receiver retires the invocation worker"); ++ assert!( ++ rx_command.try_recv().is_err(), ++ "no response.create reaches the socket pump" ++ ); ++ } ++ + #[tokio::test] + async fn websocket_observes_incomplete_before_returning_existing_error() { + let (tx_command, mut rx_command) = mpsc::channel(1); +diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs +index 55f0915..f2d1c89 100644 +--- a/codex-rs/core/src/client.rs ++++ b/codex-rs/core/src/client.rs +@@ -1756,6 +1756,7 @@ impl ModelClientSession { + } + let request_session_telemetry = + session_telemetry_for_request(session_telemetry, &request); ++ let observation_is_reviewer = is_guardian_reviewer(&responses_headers); + options.extra_headers.extend(responses_headers); + let interceptors = crate::model_request::prepare( + &self.client.request_contributors, +@@ -1781,17 +1782,34 @@ impl ModelClientSession { + ) + .with_telemetry(Some(request_telemetry), Some(sse_telemetry)); + crate::model_request::wait_if_sampling_stopped(prompt.sampling_stop.as_deref()).await; ++ let request_observation = if observation_is_reviewer { ++ None ++ } else { ++ crate::request_observation::dispatching( ++ prompt.tool_admission.as_ref(), ++ &request.model, ++ request.reasoning.as_ref(), ++ request.service_tier.as_deref(), ++ if model_info.use_responses_lite { ++ codex_extension_api::ModelRequestWire::ResponsesLite ++ } else { ++ codex_extension_api::ModelRequestWire::Responses ++ }, ++ codex_extension_api::ModelRequestTransport::HttpSse, ++ ) ++ }; + let stream_result = client.stream_request(request, options).await; + + match stream_result { + Ok(stream) => { +- let (stream, _) = map_response_stream( ++ let (mut stream, _) = map_response_stream( + stream, + request_session_telemetry, + inference_trace_attempt, + Arc::clone(&self.client.state.provider), + interceptors, + ); ++ stream.request_observation = request_observation; + return Ok(stream); + } + Err(ApiError::Transport(unauthorized_transport)) +@@ -2093,6 +2111,23 @@ impl ModelClientSession { + ], + ); + crate::model_request::wait_if_sampling_stopped(prompt.sampling_stop.as_deref()).await; ++ let request_observation = if warmup || is_guardian_reviewer(&responses_headers) { ++ None ++ } else { ++ let ResponsesWsRequest::ResponseCreate(payload) = &ws_request; ++ crate::request_observation::dispatching( ++ prompt.tool_admission.as_ref(), ++ payload.model, ++ payload.reasoning, ++ payload.service_tier, ++ if model_info.use_responses_lite { ++ codex_extension_api::ModelRequestWire::ResponsesLite ++ } else { ++ codex_extension_api::ModelRequestWire::Responses ++ }, ++ codex_extension_api::ModelRequestTransport::Websocket, ++ ) ++ }; + let stream_result = websocket_connection + .stream_request( + ws_request, +@@ -2117,13 +2152,14 @@ impl ModelClientSession { + ); + err + })?; +- let (stream, last_request_rx) = map_response_stream( ++ let (mut stream, last_request_rx) = map_response_stream( + stream_result, + request_session_telemetry, + inference_trace_attempt, + Arc::clone(&self.client.state.provider), + interceptors, + ); ++ stream.request_observation = request_observation; + self.websocket_session.last_response_rx = Some(last_request_rx); + return Ok(WebsocketStreamOutcome::Stream(stream)); + } +@@ -2532,6 +2568,7 @@ where + + ( + ResponseStream { ++ request_observation: None, + rx_event, + interrupt: None, + consumer_dropped: consumer_dropped_for_stream, +diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs +index 6188306..594edaa 100644 +--- a/codex-rs/core/src/client_common.rs ++++ b/codex-rs/core/src/client_common.rs +@@ -125,6 +125,8 @@ fn normalize_image_detail(detail: &mut Option, model_info: &ModelIn + } + + pub struct ResponseStream { ++ pub(crate) request_observation: ++ Option>, + pub(crate) rx_event: mpsc::Receiver>, + pub(crate) interrupt: Option>, + /// Signals the mapper task that the consumer stopped polling before the +diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs +index f1af831..2e88f11 100644 +--- a/codex-rs/core/src/compact_remote_v2.rs ++++ b/codex-rs/core/src/compact_remote_v2.rs +@@ -846,6 +846,7 @@ mod tests { + } + drop(tx_event); + ResponseStream { ++ request_observation: None, + rx_event, + interrupt: None, + consumer_dropped: CancellationToken::new(), +diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs +index ba7896a..7e3d2ef 100644 +--- a/codex-rs/core/src/lib.rs ++++ b/codex-rs/core/src/lib.rs +@@ -15,6 +15,7 @@ mod realtime_context; + mod realtime_conversation; + mod realtime_history; + mod realtime_prompt; ++mod request_observation; + mod responses_headers; + pub use responses_headers::CodexResponsesHeaders; + mod responses_metadata; +diff --git a/codex-rs/core/src/model_request_tests.rs b/codex-rs/core/src/model_request_tests.rs +index cf943d3..5b33e6d 100644 +--- a/codex-rs/core/src/model_request_tests.rs ++++ b/codex-rs/core/src/model_request_tests.rs +@@ -30,6 +30,7 @@ async fn interceptors_forward_before_completion_in_order_and_cancel_upstream() { + let mut stream = intercept_stream( + Box::pin( + ResponseStream { ++ request_observation: None, + rx_event, + interrupt: None, + consumer_dropped, +diff --git a/codex-rs/core/src/request_observation.rs b/codex-rs/core/src/request_observation.rs +new file mode 100644 +index 0000000..bf67bf3 +--- /dev/null ++++ b/codex-rs/core/src/request_observation.rs +@@ -0,0 +1,103 @@ ++//! Final scalar projection at selected client invocation; delivery is best effort. ++use codex_api::Reasoning; ++use codex_extension_api::ModelRequestDispatching; ++use codex_extension_api::ModelRequestEffort; ++use codex_extension_api::ModelRequestObservation; ++use codex_extension_api::ModelRequestObservationRoute; ++use codex_extension_api::ModelRequestTransport; ++use codex_extension_api::ModelRequestWire; ++use codex_extension_api::ModelResponseLinked; ++use codex_extension_api::ModelToolAdmission; ++use std::sync::Arc; ++ ++/// Transient request identity and the original immutable SDK submission route. ++#[derive(Debug)] ++pub(crate) struct RequestObservationContext { ++ // Retain original store object identity; never re-query a current turn. ++ _admission: Arc, ++ request_id: String, ++ thread_id: String, ++ turn_id: String, ++ route: Arc, ++} ++impl RequestObservationContext { ++ pub(crate) fn response_linked(&self, response_observation_id: &str) { ++ self.route ++ .0 ++ .observe(ModelRequestObservation::ResponseLinked( ++ ModelResponseLinked { ++ request_id: self.request_id.clone(), ++ thread_id: self.thread_id.clone(), ++ turn_id: self.turn_id.clone(), ++ response_observation_id: response_observation_id.to_owned(), ++ }, ++ )); ++ } ++} ++ ++/// Serialize only Reasoning so effort follows the native wire serializer exactly. ++/// A projection failure drops metadata; it must never reject an inference request. ++fn resolved_reasoning( ++ reasoning: Option<&Reasoning>, ++) -> Option<(Option, Option)> { ++ let Some(reasoning) = reasoning else { ++ return Some((None, None)); ++ }; ++ let value = serde_json::to_value(reasoning).ok()?; ++ let effort = match value.get("effort") { ++ None | Some(serde_json::Value::Null) => None, ++ Some(serde_json::Value::String(value)) => Some(ModelRequestEffort::Text(value.clone())), ++ Some(value) => Some(ModelRequestEffort::Integer(value.as_u64()?)), ++ }; ++ let summary = match value.get("summary") { ++ None | Some(serde_json::Value::Null) => None, ++ Some(value) => Some(value.as_str()?.to_owned()), ++ }; ++ Some((effort, summary)) ++} ++ ++/// Invoke synchronously after applicable existing checks, before entering the API. ++/// The transport may still fail encoding or send zero bytes. Its internal retries ++/// share this invocation; later client invocations call this function afresh. ++pub(crate) fn dispatching( ++ admission: Option<&Arc>, ++ model: &str, ++ reasoning: Option<&Reasoning>, ++ service_tier: Option<&str>, ++ wire: ModelRequestWire, ++ transport: ModelRequestTransport, ++) -> Option> { ++ let admission = admission?; ++ let route = admission ++ .turn_store() ++ .get::()?; ++ let (reasoning_effort, reasoning_summary) = resolved_reasoning(reasoning)?; ++ let context = Arc::new(RequestObservationContext { ++ _admission: Arc::clone(admission), ++ request_id: uuid::Uuid::now_v7().to_string(), ++ thread_id: admission.thread_id().to_owned(), ++ turn_id: admission.turn_store().level_id().to_owned(), ++ route, ++ }); ++ context ++ .route ++ .0 ++ .observe(ModelRequestObservation::Dispatching( ++ ModelRequestDispatching { ++ request_id: context.request_id.clone(), ++ thread_id: context.thread_id.clone(), ++ turn_id: context.turn_id.clone(), ++ model: model.to_owned(), ++ reasoning_effort, ++ reasoning_summary, ++ service_tier: service_tier.map(str::to_owned), ++ wire, ++ transport, ++ }, ++ )); ++ Some(context) ++} ++ ++#[cfg(test)] ++#[path = "request_observation_tests.rs"] ++mod tests; +diff --git a/codex-rs/core/src/request_observation_tests.rs b/codex-rs/core/src/request_observation_tests.rs +new file mode 100644 +index 0000000..9665e64 +--- /dev/null ++++ b/codex-rs/core/src/request_observation_tests.rs +@@ -0,0 +1,205 @@ ++use super::*; ++use codex_extension_api::ExtensionData; ++use codex_extension_api::ModelRequestObserver; ++use codex_protocol::config_types::ReasoningSummary; ++use codex_protocol::models::ResponseItem; ++use codex_protocol::openai_models::ReasoningEffort; ++use serde_json::json; ++use std::sync::atomic::{AtomicBool, Ordering}; ++use tokio::sync::mpsc; ++ ++#[derive(Debug)] ++struct BoundedRoute { ++ sender: mpsc::Sender, ++ retired: AtomicBool, ++} ++impl ModelRequestObserver for BoundedRoute { ++ fn observe(&self, observation: ModelRequestObservation) { ++ if !self.retired.load(Ordering::Acquire) { ++ // Full and closed queues intentionally lose observations. ++ let _ = self.sender.try_send(observation); ++ } ++ } ++} ++#[derive(Debug)] ++struct Admission { ++ thread: String, ++ store: ExtensionData, ++} ++impl ModelToolAdmission for Admission { ++ fn thread_id(&self) -> &str { ++ &self.thread ++ } ++ fn turn_store(&self) -> &ExtensionData { ++ &self.store ++ } ++ fn admits(&self, _: &ResponseItem) -> bool { ++ false ++ } ++} ++ ++fn fixture( ++ capacity: usize, ++) -> ( ++ Arc, ++ Arc, ++ mpsc::Receiver, ++) { ++ let (sender, receiver) = mpsc::channel(capacity); ++ let route = Arc::new(BoundedRoute { ++ sender, ++ retired: AtomicBool::new(false), ++ }); ++ let admission = Arc::new(Admission { ++ thread: "original-thread".into(), ++ store: ExtensionData::new("original-turn"), ++ }); ++ admission ++ .store ++ .get_or_init(|| ModelRequestObservationRoute(route.clone())); ++ (admission, route, receiver) ++} ++fn notify( ++ admission: Option<&Arc>, ++) -> Option> { ++ dispatching( ++ admission, ++ "resolved-model", ++ None, ++ None, ++ ModelRequestWire::Responses, ++ ModelRequestTransport::HttpSse, ++ ) ++} ++ ++#[test] ++fn effort_bridge_preserves_wire_scalar_kind_and_exact_integer() { ++ for (input, expected) in [ ++ ( ++ "18446744073709551615", ++ json!({"kind":"integer", "decimal":"18446744073709551615"}), ++ ), ++ ( ++ "9007199254740993", ++ json!({"kind":"integer", "decimal":"9007199254740993"}), ++ ), ++ ("00042", json!({"kind":"integer", "decimal":"42"})), ++ ( ++ "18446744073709551616", ++ json!({"kind":"text", "value":"18446744073709551616"}), ++ ), ++ ("1e3", json!({"kind":"text", "value":"1e3"})), ++ ("high", json!({"kind":"text", "value":"high"})), ++ ] { ++ let reasoning = Reasoning { ++ effort: Some(ReasoningEffort::Custom(input.into())), ++ summary: None, ++ context: None, ++ }; ++ let (effort, summary) = ++ resolved_reasoning(Some(&reasoning)).expect("known scalar reasoning"); ++ assert_eq!(effort.expect("present effort").to_bridge_value(), expected); ++ assert_eq!(summary, None); ++ } ++ assert_eq!(resolved_reasoning(None), Some((None, None))); ++ let reasoning = Reasoning { ++ effort: None, ++ summary: Some(ReasoningSummary::Detailed), ++ context: None, ++ }; ++ assert_eq!( ++ resolved_reasoning(Some(&reasoning)), ++ Some((None, Some("detailed".into()))) ++ ); ++} ++ ++#[test] ++fn no_captured_route_produces_no_observation_or_rejection() { ++ assert!(notify(None).is_none()); ++ let admission: Arc = Arc::new(Admission { ++ thread: "thread".into(), ++ store: ExtensionData::new("turn"), ++ }); ++ assert!(notify(Some(&admission)).is_none()); ++} ++ ++#[test] ++fn invocation_and_terminal_link_share_exact_original_identity() { ++ let (admission, _, mut receiver) = fixture(8); ++ let first = notify(Some(&admission)).expect("captured root route"); ++ let second = dispatching( ++ Some(&admission), ++ "resolved-model-2", ++ None, ++ Some("flex"), ++ ModelRequestWire::ResponsesLite, ++ ModelRequestTransport::Websocket, ++ ) ++ .expect("captured root route"); ++ assert!(std::ptr::eq( ++ first._admission.turn_store(), ++ admission.turn_store() ++ )); ++ let first_event = receiver ++ .try_recv() ++ .expect("first invocation") ++ .to_bridge_value(); ++ let second_event = receiver ++ .try_recv() ++ .expect("second invocation") ++ .to_bridge_value(); ++ assert_ne!(first_event["request_id"], second_event["request_id"]); ++ assert_eq!(first_event["thread_id"], "original-thread"); ++ assert_eq!(first_event["turn_id"], "original-turn"); ++ assert_eq!(first_event["model"], "resolved-model"); ++ assert_eq!(first_event["service_tier"], serde_json::Value::Null); ++ assert_eq!(first_event["reasoning_effort"], serde_json::Value::Null); ++ assert_eq!(first_event["transport"], "http_sse"); ++ assert_eq!(second_event["wire"], "responses_lite"); ++ assert_eq!(second_event["transport"], "websocket"); ++ assert_eq!(second_event["service_tier"], "flex"); ++ first.response_linked("occurrence-A"); ++ second.response_linked("occurrence-B"); ++ let first_link = receiver ++ .try_recv() ++ .expect("first retained occurrence") ++ .to_bridge_value(); ++ let second_link = receiver ++ .try_recv() ++ .expect("second retained occurrence") ++ .to_bridge_value(); ++ assert_eq!(first_link["request_id"], first_event["request_id"]); ++ assert_eq!(first_link["response_observation_id"], "occurrence-A"); ++ assert_eq!(second_link["request_id"], second_event["request_id"]); ++ assert_eq!(second_link["response_observation_id"], "occurrence-B"); ++ assert_eq!(first_link["thread_id"], "original-thread"); ++ assert_eq!(first_link["turn_id"], "original-turn"); ++ assert_eq!(first_event.as_object().expect("object").len(), 10); ++ assert_eq!(first_link.as_object().expect("object").len(), 5); ++} ++ ++#[test] ++fn native_full_closed_and_retired_routes_are_nonblocking_and_do_not_redirect() { ++ let (admission, route, mut receiver) = fixture(1); ++ let first = notify(Some(&admission)).expect("first context"); ++ // The queue is already full. A second invocation still receives its context. ++ assert!(notify(Some(&admission)).is_some()); ++ receiver.try_recv().expect("only retained notification"); ++ assert!(receiver.try_recv().is_err()); ++ route.retired.store(true, Ordering::Release); ++ first.response_linked("retained-before-retirement"); ++ assert!(receiver.try_recv().is_err()); ++ let (later_admission, _, mut later_receiver) = fixture(1); ++ let later = notify(Some(&later_admission)).expect("later owner"); ++ assert_ne!(first.request_id, later.request_id); ++ first.response_linked("late-old-occurrence"); ++ let later_event = later_receiver ++ .try_recv() ++ .expect("later owner event") ++ .to_bridge_value(); ++ assert_eq!(later_event["request_id"], later.request_id); ++ assert!(later_receiver.try_recv().is_err()); ++ drop(receiver); ++ route.retired.store(false, Ordering::Release); ++ assert!(notify(Some(&admission)).is_some()); ++} +diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs +index d156b94..f6fd9b9 100644 +--- a/codex-rs/core/src/session/turn.rs ++++ b/codex-rs/core/src/session/turn.rs +@@ -2636,6 +2636,7 @@ async fn try_run_sampling_request( + if cancellation_token.is_cancelled() { + return Err(CodexErr::TurnAborted); + } ++ let request_observation = stream.request_observation.clone(); + let mut in_flight: FuturesOrdered> = FuturesOrdered::new(); + let mut needs_follow_up = false; + let mut last_agent_message: Option = None; +@@ -3011,7 +3012,12 @@ async fn try_run_sampling_request( + break Err(CodexErr::TurnAborted); + } + match result { +- Ok(id) => response_observation_id = id, ++ Ok(id) => { ++ if let (Some(context), Some(id)) = (&request_observation, id.as_deref()) { ++ context.response_linked(id); ++ } ++ response_observation_id = id; ++ } + Err(error) => break Err(error), + } + } +diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs +index f2dbed8..ae9c47d 100644 +--- a/codex-rs/ext/extension-api/src/lib.rs ++++ b/codex-rs/ext/extension-api/src/lib.rs +@@ -116,6 +116,13 @@ pub use user_instructions::UserInstructionsProvider; + pub use contributors::ToolDispatchInput; + + mod model_request; ++mod model_request_observation; ++pub use model_request_observation::{ ++ ModelRequestDispatching, ModelRequestEffort, ModelRequestObservation, ++ ModelRequestObservationRoute, ModelRequestObserver, ModelRequestTransport, ModelRequestWire, ++ ModelResponseLinked, ++}; ++ + pub use model_request::ModelRequestContributor; + pub use model_request::ModelRequestInput; + pub use model_request::ModelRequestKind; +diff --git a/codex-rs/ext/extension-api/src/model_request_observation.rs b/codex-rs/ext/extension-api/src/model_request_observation.rs +new file mode 100644 +index 0000000..fe2ebeb +--- /dev/null ++++ b/codex-rs/ext/extension-api/src/model_request_observation.rs +@@ -0,0 +1,117 @@ ++//! Passive, metadata-only observations for an exact SDK-owned root sampling turn. ++use std::sync::Arc; ++ ++/// Effort's serialized scalar kind. Bindings must preserve it before JSON parsing. ++#[derive(Clone, Debug, PartialEq, Eq)] ++pub enum ModelRequestEffort { ++ Text(String), ++ Integer(u64), ++} ++impl ModelRequestEffort { ++ /// Private binding encoding; decimal text prevents host JSON-number rounding. ++ pub fn to_bridge_value(&self) -> serde_json::Value { ++ match self { ++ Self::Text(value) => serde_json::json!({"kind": "text", "value": value}), ++ Self::Integer(value) => { ++ serde_json::json!({"kind": "integer", "decimal": value.to_string()}) ++ } ++ } ++ } ++} ++ ++#[derive(Clone, Copy, Debug, PartialEq, Eq)] ++pub enum ModelRequestWire { ++ Responses, ++ ResponsesLite, ++} ++impl ModelRequestWire { ++ fn as_str(self) -> &'static str { ++ match self { ++ Self::Responses => "responses", ++ Self::ResponsesLite => "responses_lite", ++ } ++ } ++} ++ ++#[derive(Clone, Copy, Debug, PartialEq, Eq)] ++pub enum ModelRequestTransport { ++ HttpSse, ++ Websocket, ++} ++impl ModelRequestTransport { ++ fn as_str(self) -> &'static str { ++ match self { ++ Self::HttpSse => "http_sse", ++ Self::Websocket => "websocket", ++ } ++ } ++} ++ ++/// Reaching this notification does not establish serialization or a network send. ++#[derive(Clone, Debug, PartialEq, Eq)] ++pub struct ModelRequestDispatching { ++ pub request_id: String, ++ pub thread_id: String, ++ pub turn_id: String, ++ pub model: String, ++ pub reasoning_effort: Option, ++ pub reasoning_summary: Option, ++ pub service_tier: Option, ++ pub wire: ModelRequestWire, ++ pub transport: ModelRequestTransport, ++} ++ ++#[derive(Clone, Debug, PartialEq, Eq)] ++pub struct ModelResponseLinked { ++ pub request_id: String, ++ pub thread_id: String, ++ pub turn_id: String, ++ pub response_observation_id: String, ++} ++ ++#[derive(Clone, Debug, PartialEq, Eq)] ++pub enum ModelRequestObservation { ++ Dispatching(ModelRequestDispatching), ++ ResponseLinked(ModelResponseLinked), ++} ++impl ModelRequestObservation { ++ /// Safe private native-to-host bridge. Contains no body, endpoint or headers. ++ pub fn to_bridge_value(&self) -> serde_json::Value { ++ match self { ++ Self::Dispatching(event) => serde_json::json!({ ++ "type": "model_request_dispatching", ++ "request_id": event.request_id, ++ "thread_id": event.thread_id, ++ "turn_id": event.turn_id, ++ "model": event.model, ++ "reasoning_effort": event.reasoning_effort.as_ref().map(ModelRequestEffort::to_bridge_value), ++ "reasoning_summary": event.reasoning_summary, ++ "service_tier": event.service_tier, ++ "wire": event.wire.as_str(), ++ "transport": event.transport.as_str(), ++ }), ++ Self::ResponseLinked(event) => serde_json::json!({ ++ "type": "model_response_linked", ++ "request_id": event.request_id, ++ "thread_id": event.thread_id, ++ "turn_id": event.turn_id, ++ "response_observation_id": event.response_observation_id, ++ }), ++ } ++ } ++} ++ ++/// Internal SDK route, captured for one immutable public submission/handle. ++/// ++/// Implementations must use bounded, nonblocking delivery and return immediately. ++/// Full/closed/retired routes drop the record. Never invoke application callbacks, ++/// look up a current turn, await capacity, or propagate delivery failure to inference. ++/// The captured owner must fence retired submissions without redirecting records. ++pub trait ModelRequestObserver: Send + Sync + std::fmt::Debug { ++ fn observe(&self, observation: ModelRequestObservation); ++} ++ ++/// Install only in a managed direct-root turn's exact ExtensionData store. ++/// Missing routes leave execution unchanged. Children must not inherit this value. ++#[derive(Clone, Debug)] ++pub struct ModelRequestObservationRoute(pub Arc); diff --git a/packages/agents/native/patches/series.json b/packages/agents/native/patches/series.json index 213e9a3b4..997739c93 100644 --- a/packages/agents/native/patches/series.json +++ b/packages/agents/native/patches/series.json @@ -215,7 +215,11 @@ { "path": "0024-native-response-observations.patch", "sha256": "506fd6c938ae58e7f44a2ddcef2f2794ba33069c5f0cc3c098090fcf2c4e4c26" + }, + { + "path": "0054-native-request-metadata.patch", + "sha256": "5b3b9a410f2f284b3b54eaa5763ac3c8d25f2031f22dc6fc7468389eacd07e4c" } ], - "result_tree": "ea9493714212f7d98100810870de3f64033801fd" + "result_tree": "ecbfecf7f0b90b834effc7b38dd74f61bc5fe7db" } diff --git a/packages/agents/native/rust/engine.rs b/packages/agents/native/rust/engine.rs index ee106132c..bf4c55dbf 100644 --- a/packages/agents/native/rust/engine.rs +++ b/packages/agents/native/rust/engine.rs @@ -768,6 +768,7 @@ impl EngineState { let (publication_boundaries, boundary_requests) = mpsc::channel(1); let native_dequeued = Arc::new(AtomicBool::new(false)); let state = Arc::new(ThreadState { + request_metadata: Arc::new(crate::request_metadata::Queue::default()), id: new.thread_id, thread: new.thread.clone(), engine: self.clone(), @@ -1543,6 +1544,7 @@ impl ModelsManager for ConfiguredModels { } pub struct ThreadState { + pub(crate) request_metadata: Arc, pub id: ThreadId, pub thread: Arc, pub engine: Arc, @@ -2560,6 +2562,9 @@ impl ThreadState { return self.root_event(event, None); } loop { + if let Some(metadata) = self.request_metadata.try_next() { + return Ok(metadata); + } if let Some((event, turn)) = &events.session_terminal { tokio::select! { _ = closing.changed() => anyhow::bail!("Codex thread is closed"), @@ -2661,6 +2666,7 @@ impl ThreadState { .is_none_or(|prefix| prefix.extensions > 0); tokio::select! { _ = closing.changed() => anyhow::bail!("Codex thread is closed"), + metadata = self.request_metadata.next() => return Ok(metadata), request = async { python_tools.as_ref().expect("guarded JavaScript channel").next_request().await }, if python_tools.is_some() && !bounded => { diff --git a/packages/agents/native/rust/lib.rs b/packages/agents/native/rust/lib.rs index c62526572..b1fb6a4bf 100644 --- a/packages/agents/native/rust/lib.rs +++ b/packages/agents/native/rust/lib.rs @@ -2819,6 +2819,7 @@ impl NativeThread { &self, output_schema_json: Option, selection_json: Option, + observation_submission_id: Option, ) -> napi::Result { let output_schema = output_schema_json .map(|schema| serde_json::from_str::(&schema).map_err(native_error)) @@ -2863,12 +2864,13 @@ impl NativeThread { .map_err(native_error)?; } let result = ops::recover_prepared( - &inner.thread, + &inner, frame.turn_id, frame.reply_order, settings, output_schema, selection, + observation_submission_id, ) .await .map_err(native_error)?; @@ -3512,3 +3514,5 @@ pub async fn configure_telemetry(config_json: String) -> napi::Result<()> { pub async fn shutdown_telemetry() -> napi::Result<()> { telemetry::shutdown().await.map_err(telemetry_error) } + +mod request_metadata; diff --git a/packages/agents/native/rust/native_interception.rs b/packages/agents/native/rust/native_interception.rs index 5f9bfc745..54d0a837f 100644 --- a/packages/agents/native/rust/native_interception.rs +++ b/packages/agents/native/rust/native_interception.rs @@ -2470,6 +2470,7 @@ impl TurnLifecycleContributor for Boundary { } fn on_turn_stop<'a>(&'a self, input: TurnStopInput<'a>) -> ExtensionFuture<'a, ()> { Box::pin(async move { + crate::request_metadata::retire(input.turn_store); if let Some(control) = input.thread_store.get::() { control.end_direct_turn(input.turn_store); if let Some(owner) = input.turn_store.get::() { @@ -2481,6 +2482,7 @@ impl TurnLifecycleContributor for Boundary { } fn on_turn_abort<'a>(&'a self, input: TurnAbortInput<'a>) -> ExtensionFuture<'a, ()> { Box::pin(async move { + crate::request_metadata::retire(input.turn_store); if let Some(control) = input.thread_store.get::() { control.end_direct_turn(input.turn_store); if let Some(owner) = input.turn_store.get::() { diff --git a/packages/agents/native/rust/ops.rs b/packages/agents/native/rust/ops.rs index f32c621b6..d48e8b02c 100644 --- a/packages/agents/native/rust/ops.rs +++ b/packages/agents/native/rust/ops.rs @@ -278,6 +278,7 @@ impl Default for Mode { params!(TurnParams { input: Value, + observation_submission_id: Option, client_id: Option, #[serde(default, alias = "settings", with = "ThreadSettings")] thread_settings: ThreadSettingsOverrides, @@ -559,14 +560,21 @@ async fn continue_managed<'a>( } pub async fn recover_prepared( - thread: &CodexThread, + state: &crate::engine::ThreadState, turn_id: String, original_call_ids: Vec, thread_settings: ThreadSettingsOverrides, final_output_json_schema: Option, selection: Option, + observation_submission_id: Option, ) -> Result { + let thread = &state.thread; let mut init = thread.config_snapshot().await.turn_extension_init; + if let Some(submission) = observation_submission_id { + state + .request_metadata + .seed(&mut init, submission, state.id.to_string()); + } init.insert(selection); crate::recovery_guard::Seed::attach(&mut init, &turn_id, original_call_ids); let value = idle_submission( @@ -603,6 +611,21 @@ async fn start_with_params<'a>( "exact_steering requires steer mode" ))); } + + let turn_init = if let (Some((state, _)), Some(submission)) = + (managed.as_ref(), p.observation_submission_id) + { + let mut init = match turn_init { + Some(init) => init, + None => thread.config_snapshot().await.turn_extension_init, + }; + state + .request_metadata + .seed(&mut init, submission, state.id.to_string()); + Some(init) + } else { + turn_init + }; let input = if p.input.is_array() { TurnInput::UserInput { content: decode::>(p.input).map_err(pre_admission)?, diff --git a/packages/agents/native/rust/request_metadata.rs b/packages/agents/native/rust/request_metadata.rs new file mode 100644 index 000000000..2cbb8d380 --- /dev/null +++ b/packages/agents/native/rust/request_metadata.rs @@ -0,0 +1,174 @@ +//! Bounded passive metadata delivery, separate from authority-bearing requests. +use codex_extension_api::{ + ExtensionData, ExtensionDataInit, ModelRequestObservation, ModelRequestObservationRoute, + ModelRequestObserver, +}; +use serde_json::{Value, json}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Weak}; +use tokio::sync::{Mutex, mpsc}; + +const CAPACITY: usize = 256; + +#[derive(Debug)] +pub(crate) struct Queue { + sender: mpsc::Sender, + receiver: Mutex>, +} +impl Default for Queue { + fn default() -> Self { + let (sender, receiver) = mpsc::channel(CAPACITY); + Self { + sender, + receiver: Mutex::new(receiver), + } + } +} +impl Queue { + /// Seed only managed root submissions; tokens never enter provider metadata. + pub(crate) fn seed( + self: &Arc, + init: &mut ExtensionDataInit, + submission: String, + thread_id: String, + ) { + if submission.is_empty() + || submission.len() > 64 + || !submission + .bytes() + .all(|c| c.is_ascii_hexdigit() || c == b'-') + { + return; + } + let route = Arc::new(Route { + queue: Arc::downgrade(self), + submission, + thread_id, + live: AtomicBool::new(true), + }); + init.insert(ModelRequestObservationRoute(route.clone())); + init.insert(route); + } + pub(crate) fn try_next(&self) -> Option { + self.receiver.try_lock().ok()?.try_recv().ok() + } + pub(crate) async fn next(&self) -> Value { + self.receiver + .lock() + .await + .recv() + .await + .unwrap_or(Value::Null) + } +} + +#[derive(Debug)] +struct Route { + queue: Weak, + submission: String, + thread_id: String, + live: AtomicBool, +} +impl ModelRequestObserver for Route { + fn observe(&self, observation: ModelRequestObservation) { + if !self.live.load(Ordering::Acquire) { + return; + } + let Some(queue) = self.queue.upgrade() else { + return; + }; + let value = observation.to_bridge_value(); + if value["thread_id"].as_str() != Some(self.thread_id.as_str()) { + return; + } + let notice = json!({ + "id": value["turn_id"], + "msg": { "type": "_sdk_request_metadata", "submission_id": self.submission, "observation": value }, + }); + // Full/closed delivery is harmless. No callback, await, current turn or + // provider-visible metadata participates in this internal route. + let _ = queue.sender.try_send(notice); + } +} + +pub(crate) fn retire(store: &ExtensionData) { + if let Some(route) = store.get::>() { + route.live.store(false, Ordering::Release); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_extension_api::{ModelRequestDispatching, ModelRequestTransport, ModelRequestWire}; + fn event() -> ModelRequestObservation { + ModelRequestObservation::Dispatching(ModelRequestDispatching { + request_id: "request".into(), + thread_id: "thread".into(), + turn_id: "same-turn".into(), + model: "model".into(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + wire: ModelRequestWire::Responses, + transport: ModelRequestTransport::HttpSse, + }) + } + #[tokio::test] + async fn exact_submission_full_closed_and_retired_delivery_is_passive() { + let queue = Arc::new(Queue::default()); + let mut init = ExtensionDataInit::new(); + queue.seed( + &mut init, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), + "thread".into(), + ); + let inherited = ExtensionData::new_with_init("child-turn", init.clone()); + let store = ExtensionData::new_with_init("same-turn", init); + let route = store.get::().unwrap(); + let mut child = event(); + if let ModelRequestObservation::Dispatching(event) = &mut child { + event.thread_id = "child-thread".into(); + } + inherited + .get::() + .unwrap() + .0 + .observe(child); + assert!(queue.try_next().is_none()); + for _ in 0..CAPACITY + 1 { + route.0.observe(event()); + } + let mut receiver = queue.receiver.lock().await; + for _ in 0..CAPACITY { + let notice = receiver.try_recv().unwrap(); + assert_eq!( + notice["msg"]["submission_id"], + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + } + assert!(receiver.try_recv().is_err()); + retire(&store); + route.0.observe(event()); + assert!(receiver.try_recv().is_err()); + let mut later = ExtensionDataInit::new(); + queue.seed( + &mut later, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(), + "thread".into(), + ); + let later_route = later.get::().unwrap(); + later_route.0.observe(event()); + route.0.observe(event()); + assert_eq!( + receiver.try_recv().unwrap()["msg"]["submission_id"], + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ); + assert!(receiver.try_recv().is_err()); + receiver.close(); + drop(receiver); + later_route.0.observe(event()); + drop(queue); + route.0.observe(event()); + } +} diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts index c689901b2..7d69f170e 100644 --- a/packages/agents/src/index.ts +++ b/packages/agents/src/index.ts @@ -110,6 +110,8 @@ export type { ChecklistState, SessionState } from './runtime/state'; export { parseSessionState, readSessionInstructions } from './runtime/state'; export type { TokenUsage, TokenUsageInfo } from './runtime/run-events'; export type { + ModelRequestDispatching, + ModelResponseLinked, Observation, ObservationOptions, ExecutionItemSummary, diff --git a/packages/agents/src/runtime/observation.ts b/packages/agents/src/runtime/observation.ts index 3af2477a4..77e6a12a0 100644 --- a/packages/agents/src/runtime/observation.ts +++ b/packages/agents/src/runtime/observation.ts @@ -36,7 +36,41 @@ type ExecutionObservation = SubmissionEvent & export type ApiRateLimitsUpdated = SubmissionEvent & Readonly<{ type: 'api_rate_limits_updated'; snapshot: ApiRateLimits }>; +/** SDK-resolved root sampling fields immediately before client invocation. + * Encoding and socket writes may not occur. Null means omitted, not a server default. + * Internal transport retries share requestId; later client invocations get new IDs. + * Separate correction roots retain their own thread/submission IDs and existing + * parentSubmissionId. Native children, warmup, compaction and background/reviewer + * requests are outside coverage. Restored manual roots emit fresh events for + * resumed work; previously observed request/link events are not replayed. + */ +export type ModelRequestDispatching = SubmissionEvent & + Readonly<{ + type: 'model_request_dispatching'; + requestId: string; + threadId: string; + model: string; + reasoningEffort: string | number | bigint | null; + reasoningSummary: string | null; + serviceTier: string | null; + wire: 'responses' | 'responses_lite'; + transport: 'http_sse' | 'websocket'; + }>; + +/** Best-effort link to a retained terminal occurrence. Retirement can drop a + * link after history retention; absence does not establish absence of a terminal. + */ +export type ModelResponseLinked = SubmissionEvent & + Readonly<{ + type: 'model_response_linked'; + requestId: string; + threadId: string; + responseObservationId: string; + }>; + export type Observation = + | ModelRequestDispatching + | ModelResponseLinked | (Event & Readonly<{ type: 'warning'; message: string }>) | (SubmissionEvent & Readonly<{ type: 'turn_started' }>) | (SubmissionEvent & diff --git a/packages/agents/src/runtime/request-metadata.ts b/packages/agents/src/runtime/request-metadata.ts new file mode 100644 index 000000000..b132fdc02 --- /dev/null +++ b/packages/agents/src/runtime/request-metadata.ts @@ -0,0 +1,101 @@ +import type { ObservationInput } from './observation'; +import { parseTokenCount } from './run-events'; + +type ObjectValue = Record; +function object(value: unknown): value is ObjectValue { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} +function exact(value: ObjectValue, keys: string[]): boolean { + return ( + Object.keys(value).length === keys.length && + keys.every((key) => Object.hasOwn(value, key)) + ); +} + +/** Strict private bridge decoding; malformed passive metadata never fails work. */ +export function decodeRequestMetadata( + value: unknown, + submissionId: string, +): ObservationInput | null { + if (!object(value)) return null; + const common = ['type', 'request_id', 'thread_id', 'turn_id']; + if ( + !common.every((key) => typeof value[key] === 'string' && value[key] !== '') + ) + return null; + const identity = { + submissionId, + requestId: value.request_id as string, + threadId: value.thread_id as string, + turnId: value.turn_id as string, + }; + if (value.type === 'model_response_linked') { + if ( + !exact(value, [...common, 'response_observation_id']) || + typeof value.response_observation_id !== 'string' || + !value.response_observation_id + ) + return null; + return { + type: 'model_response_linked', + ...identity, + responseObservationId: value.response_observation_id, + }; + } + if ( + value.type !== 'model_request_dispatching' || + !exact(value, [ + ...common, + 'model', + 'reasoning_effort', + 'reasoning_summary', + 'service_tier', + 'wire', + 'transport', + ]) + ) + return null; + if ( + typeof value.model !== 'string' || + (value.wire !== 'responses' && value.wire !== 'responses_lite') || + (value.transport !== 'http_sse' && value.transport !== 'websocket') + ) + return null; + if ( + (value.reasoning_summary !== null && + typeof value.reasoning_summary !== 'string') || + (value.service_tier !== null && typeof value.service_tier !== 'string') + ) + return null; + const raw = value.reasoning_effort; + let effort: string | number | bigint | null = null; + if (raw !== null) { + if (!object(raw)) return null; + if ( + raw.kind === 'text' && + exact(raw, ['kind', 'value']) && + typeof raw.value === 'string' + ) + effort = raw.value; + else if ( + raw.kind === 'integer' && + exact(raw, ['kind', 'decimal']) && + typeof raw.decimal === 'string' && + /^(0|[1-9][0-9]{0,19})$/.test(raw.decimal) + ) { + const integer = BigInt(raw.decimal); + if (integer > 18446744073709551615n) return null; + effort = parseTokenCount(integer); + } else return null; + } + return { + type: 'model_request_dispatching', + ...identity, + model: value.model, + reasoningEffort: effort, + reasoningSummary: value.reasoning_summary, + serviceTier: value.service_tier, + wire: value.wire, + transport: value.transport, + }; +} diff --git a/packages/agents/src/runtime/session.ts b/packages/agents/src/runtime/session.ts index 1feca3e91..b70b0d4e8 100644 --- a/packages/agents/src/runtime/session.ts +++ b/packages/agents/src/runtime/session.ts @@ -1,3 +1,4 @@ +import { decodeRequestMetadata } from './request-metadata'; import { networkPolicyDigest } from './network-policy'; import { apiRateLimits, type ApiRateLimits } from './api-rate-limits'; import { @@ -835,6 +836,7 @@ export class Session { >(); private readonly issuedRuns = new Map(); private readonly submissionIds = new WeakMap(); + private readonly requestObservationRoutes = new Map(); private readonly allowedTools: Set; private defaultTools: readonly SessionTool[]; private readonly admittedDeclarations = new Map>(); @@ -3136,6 +3138,7 @@ export class Session { ...(run.writableRoots === undefined ? {} : { writable_roots: run.writableRoots }), + observation_submission_id: this.submissionIds.get(run)!, input: output.prompt ? [...parts, { type: 'text', text: output.prompt }] : parts, @@ -3161,18 +3164,28 @@ export class Session { const run = new NativeTurn(() => this.assertCanWait(run)); submissions.set(run, { output, outputOverride, observation: 'open' }); this.submissionIds.set(run, randomUUID()); + this.registerRequestObservationRoute(run); return run; } + private registerRequestObservationRoute(run: NativeTurn): void { + const routeId = this.submissionIds.get(run)!; + this.requestObservationRoutes.set(routeId, run); + const retire = () => { + this.requestObservationRoutes.delete(routeId); + }; + run.completion.promise.then(retire, retire); + } + private begin( - submit: () => Promise, + submit: (run: NativeTurn) => Promise, output = this.defaultOutput, outputOverride = false, selection?: OperationSelection, ): NativeTurn { const run = this.allocateRun(output, outputOverride); run.selection = selection; - this.dispatchRun(run, submit); + this.dispatchRun(run, () => submit(run)); return run; } @@ -3581,11 +3594,12 @@ export class Session { // Native still decides admission from the exact managed Turn, never from // this event hint. Input is already in history and is not resubmitted. const next = this.begin( - () => + (nextRun) => this.#native._submit( encode({ input: [], mode: 'continue_if_idle', + observation_submission_id: this.submissionIds.get(nextRun)!, expected_previous_turn_id: run.id, ...(submission.output.schema === undefined ? {} @@ -3660,12 +3674,23 @@ export class Session { throw error; } if (!this.isOpen || this.#native !== native) return; - const run = this.lifecycle.kind === 'turn' ? this.lifecycle.run : null; const msg = event.msg; if (msg.type === 'token_count') { const snapshot = apiRateLimits(msg.api_rate_limits); if (snapshot) this.apiRateLimitState.snapshot = snapshot; } + if (msg.type === '_sdk_request_metadata') { + const route = + typeof msg.submission_id === 'string' + ? this.requestObservationRoutes.get(msg.submission_id) + : undefined; + if (route) { + if (route.id === null) route.pending.push(event); + else this.observeRequestMetadata(route, event); + } + continue; + } + const run = this.lifecycle.kind === 'turn' ? this.lifecycle.run : null; if (msg.type === 'elicitation_status') { await this.observeNativeUrlStatus(native, event); continue; @@ -4229,7 +4254,33 @@ export class Session { } else this.observations.gap(); } + private observeRequestMetadata(run: NativeTurn, event: NativeEvent): void { + const msg = event.msg; + if ( + run.controller.signal.aborted || + typeof msg.submission_id !== 'string' || + this.requestObservationRoutes.get(msg.submission_id) !== run + ) + return; + const observation = decodeRequestMetadata( + msg.observation, + msg.submission_id, + ); + if ( + observation && + 'threadId' in observation && + observation.threadId === this.id && + observation.turnId === event.id && + run.id === observation.turnId + ) + this.observations.append(observation); + } + private process(run: NativeTurn, event: NativeEvent): void { + if (event.msg.type === '_sdk_request_metadata') { + this.observeRequestMetadata(run, event); + return; + } if (this.lifecycle.kind !== 'turn' || this.lifecycle.run !== run) return; const msg = event.msg; const kind = msg.type; @@ -5114,20 +5165,17 @@ export class Session { this.manualRestoring = false; try { const schema = submissions.get(run)!.output.schema; - const wire = run.selection - ? await this.#native._complete_manual_continuation( - schema == null ? undefined : encodeReceipt(schema), - encode({ + const wire = await this.#native._complete_manual_continuation( + schema == null ? undefined : encodeReceipt(schema), + run.selection + ? encode({ generation: this.catalog.generation.toString(), names: run.selection.names ?? null, before_step: run.selection.maxModelSteps !== undefined, - }), - ) - : schema == null - ? await this.#native._complete_manual_continuation() - : await this.#native._complete_manual_continuation( - encodeReceipt(schema), - ); + }) + : undefined, + this.submissionIds.get(run)!, + ); const submission = decode(submissionSchema, wire); if (submission.status !== 'started' || submission.turn_id !== run.id) throw new NativeError( @@ -5513,6 +5561,7 @@ export class Session { observation: 'open', }); this.submissionIds.set(run, randomUUID()); + this.registerRequestObservationRoute(run); this.#turnFor(run); run.accepted.resolve({ status: 'started', turn_id: run.id }); const usage = state.manual_context.usage; diff --git a/packages/agents/test/native/request-metadata-native.test.ts b/packages/agents/test/native/request-metadata-native.test.ts new file mode 100644 index 000000000..8d870d7fa --- /dev/null +++ b/packages/agents/test/native/request-metadata-native.test.ts @@ -0,0 +1,569 @@ +import { once } from 'node:events'; +import { createServer } from 'node:http'; +import { WebSocketServer } from 'ws'; +import { afterEach, expect, it } from 'vitest'; +import { z } from 'zod'; +import { + Agent, + manualTool, + type ActionRequested, + NativeProvider, + tool, + type ModelRequestDispatching, + type ModelResponseLinked, +} from '../../src'; +import type { JsonObject } from '../../src/runtime/binding'; +import { ModelServer, call, message } from './fixtures/model-server'; + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + await ModelServer.closeAll(); + for (const close of cleanups.splice(0)) await close(); +}); + +it('matches final HTTP scalar fields and links repeated provider IDs by occurrence', async () => { + const terminal = JSON.stringify({ + type: 'response.completed', + response: { + id: 'same', + headers: { + 'x-ratelimit-remaining-requests': '7', + authorization: 'PRIVATE_HEADER_MARKER', + }, + usage: { input_tokens: 11, output_tokens: 2, total_tokens: 13 }, + }, + }); + const server = new ModelServer( + [[call('probe', {}, { callId: 'probe' })], [message('done')]], + [], + undefined, + [terminal, terminal], + ); + const config = await server.config(); + await using provider = new NativeProvider({ + baseUrl: (config['model_providers.fixture'] as { base_url: string }) + .base_url, + apiKey: 'synthetic-metadata-key', + supportsWebsockets: false, + }); + const agent = new Agent({ + instructions: 'Use probe then answer.', + model: 'gpt-5.5', + provider, + modelSettings: { + reasoningEffort: 'low', + reasoningSummary: 'concise', + serviceTier: 'flex', + }, + tools: [ + tool({ name: 'probe', parameters: z.object({}), execute: () => 'ok' }), + ], + }); + await using session = await agent.session(); + const turn = await session.submit('PRIVATE_INPUT_MARKER'); + const result = await turn.wait(); + const events = []; + for await (const event of turn.events()) events.push(event); + const dispatches = events.filter( + (event): event is ModelRequestDispatching => + event.type === 'model_request_dispatching', + ); + const links = events.filter( + (event): event is ModelResponseLinked => + event.type === 'model_response_linked', + ); + const rateUpdates = events.filter( + (event) => event.type === 'api_rate_limits_updated', + ); + expect(rateUpdates).toHaveLength(2); + for (const event of rateUpdates) { + expect(event.turnId).toBe(turn.turnId); + expect(event.submissionId).toBe(turn.submissionId); + expect(event.snapshot).toEqual({ requests: { remaining: 7 } }); + } + expect(session.rateLimits()).toEqual({ requests: { remaining: 7 } }); + expect(result.usage?.totalTokenUsage.totalTokens).toBe(26); + expect( + events.filter( + (event) => event.type === 'usage_updated' && event.usage === null, + ), + ).toEqual([]); + expect(JSON.stringify(events)).not.toContain('PRIVATE_HEADER_MARKER'); + expect(dispatches).toHaveLength(2); + expect(new Set(dispatches.map((event) => event.requestId)).size).toBe(2); + for (const [index, event] of dispatches.entries()) { + const request = server.requests[index]; + expect(event.threadId).toBe(session.id); + expect(event.turnId).toBe(turn.turnId); + expect(event.submissionId).toBe(turn.submissionId); + expect(event.model).toBe(request.model); + expect(event.reasoningEffort).toBe( + (request.reasoning as JsonObject | undefined)?.effort ?? null, + ); + expect(event.reasoningSummary).toBe( + (request.reasoning as JsonObject | undefined)?.summary ?? null, + ); + expect(event.serviceTier).toBe(request.service_tier ?? null); + expect(event.transport).toBe('http_sse'); + expect(JSON.stringify(event)).not.toContain('PRIVATE_INPUT_MARKER'); + expect(Object.isFrozen(event)).toBe(true); + } + const recorded = []; + for await (const row of session.turns()) recorded.push(row); + expect(recorded[0].responses).toHaveLength(2); + expect(new Set(recorded[0].responses!.map((row) => row.responseId))).toEqual( + new Set(['same']), + ); + expect(new Set(links.map((event) => event.responseObservationId))).toEqual( + new Set(recorded[0].responses!.map((row) => row.id)), + ); + expect(new Set(links.map((event) => event.requestId))).toEqual( + new Set(dispatches.map((event) => event.requestId)), + ); + server.assertExhausted(); +}, 30_000); + +for (const mode of ['websocket', 'fallback', 'failed-websocket'] as const) { + it(`reports the selected invocation transport over ${mode}`, async () => { + const requests: JsonObject[] = []; + let upgrades = 0; + const failures: unknown[] = []; + function events(request: JsonObject): JsonObject[] { + if (request.generate === false) + return [ + { type: 'response.created', response: { id: 'warmup' } }, + { type: 'response.completed', response: { id: 'warmup' } }, + ]; + requests.push(request); + if (mode === 'failed-websocket') + return [ + { + type: 'response.output_item.done', + item: { + type: 'message', + role: 'assistant', + content: [{ type: 'refusal', refusal: 'PRIVATE_REFUSAL' }], + }, + }, + ]; + return [ + { type: 'response.created', response: { id: 'response' } }, + { + type: 'response.output_item.done', + output_index: 0, + item: message('done'), + }, + { + type: 'response.completed', + response: { + id: 'response', + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ]; + } + const server = createServer(async (request, response) => { + try { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const output = events(JSON.parse(Buffer.concat(chunks).toString())); + response.writeHead(200, { 'content-type': 'text/event-stream' }); + for (const event of output) + response.write(`data: ${JSON.stringify(event)}\n\n`); + response.end(); + } catch (error) { + failures.push(error); + response.writeHead(500).end(); + } + }); + const ws = new WebSocketServer({ noServer: true }); + server.on('upgrade', (request, socket, head) => { + upgrades++; + if (mode === 'fallback') { + socket.end( + 'HTTP/1.1 426 Upgrade Required\r\nContent-Length: 0\r\nConnection: close\r\n\r\n', + ); + return; + } + ws.handleUpgrade(request, socket, head, (connection) => + connection.on('message', (data) => { + try { + for (const event of events(JSON.parse(data.toString()))) + connection.send(JSON.stringify(event)); + } catch (error) { + failures.push(error); + connection.close(); + } + }), + ); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') + throw new Error('No test peer address'); + cleanups.push(async () => { + for (const connection of ws.clients) connection.terminate(); + await new Promise((resolve) => ws.close(() => resolve())); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + expect(failures).toEqual([]); + }); + await using provider = new NativeProvider({ + baseUrl: `http://127.0.0.1:${address.port}/v1`, + apiKey: 'synthetic-metadata-key', + supportsWebsockets: true, + }); + await using session = await new Agent({ + instructions: 'Answer.', + provider, + }).session(); + const turn = await session.submit('go'); + if (mode === 'failed-websocket') + await expect(turn.wait()).rejects.toThrow(); + else await turn.wait(); + const observed = []; + for await (const event of turn.events()) observed.push(event); + const dispatches = observed.filter( + (event): event is ModelRequestDispatching => + event.type === 'model_request_dispatching', + ); + expect(dispatches).toHaveLength(1); + expect(requests).toHaveLength(1); + expect(dispatches[0].transport).toBe( + mode === 'fallback' ? 'http_sse' : 'websocket', + ); + expect(dispatches[0].model).toBe(requests[0].model); + expect(upgrades).toBeGreaterThan(0); + if (mode === 'failed-websocket') { + expect( + observed.some((event) => event.type === 'model_response_linked'), + ).toBe(false); + expect(JSON.stringify(observed)).not.toContain('PRIVATE_REFUSAL'); + } + }, 30_000); +} + +for (const cancel of [false, true]) { + it(`keeps a closed observation reader passive while the root ${cancel ? 'cancels' : 'completes'}`, async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const server = new ModelServer([ + async () => { + entered.resolve(); + await release.promise; + return [message('done')]; + }, + ]); + const config = await server.config(); + await using provider = new NativeProvider({ + baseUrl: (config['model_providers.fixture'] as { base_url: string }) + .base_url, + apiKey: 'synthetic-metadata-key', + supportsWebsockets: false, + }); + await using session = await new Agent({ + instructions: 'Answer.', + provider, + }).session(); + const turn = await session.submit('go'); + const reader = turn.events(); + let dispatch: ModelRequestDispatching | undefined; + for await (const event of reader) { + if (event.type === 'model_request_dispatching') { + dispatch = event; + break; + } + } + expect(dispatch).toBeDefined(); + await reader.return?.(); + await entered.promise; + if (cancel) { + await turn.cancel(); + await expect(turn.wait()).rejects.toThrow(); + } + release.resolve(); + if (!cancel) expect((await turn.wait()).finalOutput).toBe('done'); + const events = []; + for await (const event of turn.events()) events.push(event); + expect(events).toContainEqual(dispatch); + expect(server.requests).toHaveLength(1); + if (cancel) + expect( + events.some((event) => event.type === 'model_response_linked'), + ).toBe(false); + }, 30_000); +} + +it('keeps prompted correction request ownership and the existing parent submission link', async () => { + const server = new ModelServer([ + [message('not valid JSON')], + [message('{"answer":"done"}')], + ]); + const config = await server.config(); + await using provider = new NativeProvider({ + baseUrl: (config['model_providers.fixture'] as { base_url: string }) + .base_url, + apiKey: 'synthetic-metadata-key', + supportsWebsockets: false, + }); + await using session = await new Agent({ + instructions: 'Answer.', + provider, + outputType: { + mode: 'prompted', + schema: z.object({ answer: z.literal('done') }), + maxRetries: 1, + }, + }).session(); + const turn = await session.submit('go'); + expect((await turn.wait()).finalOutput).toEqual({ answer: 'done' }); + const events = []; + for await (const event of turn.events()) events.push(event); + const dispatches = events.filter( + (event): event is ModelRequestDispatching => + event.type === 'model_request_dispatching', + ); + const links = events.filter( + (event): event is ModelResponseLinked => + event.type === 'model_response_linked', + ); + expect(dispatches).toHaveLength(2); + expect(server.requests).toHaveLength(2); + const [original, correction] = dispatches; + expect(original.threadId).toBe(session.id); + expect(original.sessionId).toBe(session.id); + expect(original.submissionId).toBe(turn.submissionId); + expect(correction.threadId).toBe(correction.sessionId); + expect(correction.threadId).not.toBe(session.id); + expect(correction.submissionId).not.toBe(turn.submissionId); + expect(correction.parentSubmissionId).toBe(turn.submissionId); + expect(correction.requestId).not.toBe(original.requestId); + expect(links).toHaveLength(2); + for (const event of links) { + const owner = dispatches.find((row) => row.requestId === event.requestId)!; + expect([ + event.threadId, + event.submissionId, + event.parentSubmissionId, + ]).toEqual([owner.threadId, owner.submissionId, owner.parentSubmissionId]); + } + server.assertExhausted(); +}, 30_000); + +for (const restore of [false, true]) { + it(`binds a fresh ephemeral route for ${restore ? 'restored' : 'in-memory'} manual follow-up`, async () => { + const server = new ModelServer([ + [call('lookup', {}, { callId: 'manual-A' })], + [message('continued')], + ]); + const config = await server.config(); + await using provider = new NativeProvider({ + baseUrl: (config['model_providers.fixture'] as { base_url: string }) + .base_url, + apiKey: 'synthetic-metadata-key', + supportsWebsockets: false, + }); + const agent = new Agent({ + instructions: 'Use lookup.', + provider, + tools: [ + manualTool({ + name: 'lookup', + description: 'Read a record.', + parameters: z.object({}), + result: z.string(), + }), + ], + }); + const source = await agent.session(); + let owner = source; + try { + const original = await source.submit('read A'); + let action: ActionRequested | undefined; + for await (const event of source.events()) { + if (event.type === 'action_requested') { + action = event; + break; + } + } + if (!action) throw new Error('No manual request'); + const priorIds = new Set(); + for await (const row of source.turns()) + for (const response of row.responses ?? []) priorIds.add(response.id); + expect(priorIds.size).toBeGreaterThan(0); + if (restore) { + const saved = await source.suspend(); + const encoded = JSON.stringify(saved); + expect(encoded).not.toContain('observation_submission_id'); + expect(encoded).not.toContain(original.submissionId); + await source.close(); + owner = await agent.session({ state: saved }); + action = undefined; + for await (const event of owner.events()) { + if (event.type === 'action_requested') { + action = event; + break; + } + } + if (!action) throw new Error('No restored manual request'); + } + expect(server.requests).toHaveLength(1); + const resumed = await owner.submit(action.reply('record A')); + expect((await resumed.wait()).finalOutput).toBe('continued'); + const events = []; + for await (const event of resumed.events()) events.push(event); + const dispatches = events.filter( + (event): event is ModelRequestDispatching => + event.type === 'model_request_dispatching', + ); + const links = events.filter( + (event): event is ModelResponseLinked => + event.type === 'model_response_linked', + ); + expect(dispatches).toHaveLength(restore ? 1 : 2); + expect(links).toHaveLength(restore ? 1 : 2); + expect( + dispatches.every( + (event) => + event.submissionId === resumed.submissionId && + event.threadId === owner.id, + ), + ).toBe(true); + expect(dispatches.at(-1)?.turnId).toBe(original.turnId); + if (restore) { + expect(resumed.submissionId).not.toBe(original.submissionId); + expect( + links.every((event) => !priorIds.has(event.responseObservationId)), + ).toBe(true); + } + const retainedIds = new Set(); + for await (const row of owner.turns()) + for (const response of row.responses ?? []) + retainedIds.add(response.id); + expect([...priorIds].every((id) => retainedIds.has(id))).toBe(true); + server.assertExhausted(); + } finally { + if (owner !== source) await owner.close(); + await source.close(); + } + }, 30_000); +} + +it('does not report a client invocation after a held sampling step is closed', async () => { + // The blocked request must never consume this response. + const server = new ModelServer([[message('must not send')]]); + const config = await server.config(); + await using provider = new NativeProvider({ + baseUrl: (config['model_providers.fixture'] as { base_url: string }) + .base_url, + apiKey: 'synthetic-stop-metadata', + supportsWebsockets: false, + }); + const agent = new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + }); + const session = await agent.session(); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + try { + const turn = await session.submit('close at sampling admission', { + async beforeStep() { + entered.resolve(); + await release.promise; + return undefined; + }, + }); + const waiting = turn.wait().then( + () => ({ completed: true }), + () => ({ completed: false }), + ); + await entered.promise; + const closing = session.close(); + release.resolve(); + await closing; + expect((await waiting).completed).toBe(false); + const events = []; + for await (const event of turn.events()) events.push(event); + expect( + events.filter((event) => event.type === 'model_request_dispatching'), + ).toEqual([]); + expect( + events.filter((event) => event.type === 'model_response_linked'), + ).toEqual([]); + expect(server.requests).toHaveLength(0); + } finally { + release.resolve(); + await session.close(); + } +}, 30_000); + +it('routes queued work to its issued handle and drops a canceled queued root', async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + let holdFirst = true; + const server = new ModelServer( + [[message('first')], [message('second')]], + [], + async () => { + if (!holdFirst) return; + holdFirst = false; + entered.resolve(); + await release.promise; + }, + ); + const config = await server.config(); + await using provider = new NativeProvider({ + baseUrl: (config['model_providers.fixture'] as { base_url: string }) + .base_url, + apiKey: 'synthetic-queued-metadata', + supportsWebsockets: false, + }); + const agent = new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + }); + const session = await agent.session(); + try { + const first = await session.submit('first'); + await entered.promise; + const second = await session.enqueue('second'); + const canceled = await session.enqueue('must not dispatch'); + expect(second.turnId).toBeNull(); + const canceledWait = canceled.wait().then( + () => false, + () => true, + ); + await canceled.cancel(); + expect(await canceledWait).toBe(true); + release.resolve(); + expect((await first.wait()).finalOutput).toBe('first'); + expect((await second.wait()).finalOutput).toBe('second'); + const requests: ModelRequestDispatching[] = []; + for (const turn of [first, second, canceled]) { + const events = []; + for await (const event of turn.events()) events.push(event); + const dispatches = events.filter( + (event): event is ModelRequestDispatching => + event.type === 'model_request_dispatching', + ); + expect(dispatches).toHaveLength(turn === canceled ? 0 : 1); + for (const event of dispatches) { + expect(event.submissionId).toBe(turn.submissionId); + expect(event.turnId).toBe(turn.turnId); + expect(event.threadId).toBe(session.id); + requests.push(event); + } + } + expect(new Set(requests.map((event) => event.requestId)).size).toBe(2); + expect(first.submissionId).not.toBe(second.submissionId); + expect(server.requests).toHaveLength(2); + server.assertExhausted(); + } finally { + release.resolve(); + await session.close(); + } +}, 30_000); diff --git a/packages/agents/test/native/request-metadata.test.ts b/packages/agents/test/native/request-metadata.test.ts new file mode 100644 index 000000000..04c70c4c5 --- /dev/null +++ b/packages/agents/test/native/request-metadata.test.ts @@ -0,0 +1,99 @@ +import { expect, it } from 'vitest'; +import { decodeRequestMetadata } from '../../src/runtime/request-metadata'; +import { ObservationBuffer } from '../../src/runtime/observation'; + +function payload(reasoning_effort: unknown = null) { + return { + type: 'model_request_dispatching', + request_id: 'request', + thread_id: 'thread', + turn_id: 'turn', + model: 'resolved-model', + reasoning_effort, + reasoning_summary: null, + service_tier: null, + wire: 'responses', + transport: 'http_sse', + }; +} +it.each([ + [null, null], + [{ kind: 'text', value: 'future-effort' }, 'future-effort'], + [{ kind: 'text', value: '18446744073709551616' }, '18446744073709551616'], + [{ kind: 'integer', decimal: '9007199254740991' }, 9007199254740991], + [{ kind: 'integer', decimal: '9007199254740992' }, 9007199254740992n], + [{ kind: 'integer', decimal: '9007199254740993' }, 9007199254740993n], + [{ kind: 'integer', decimal: '18446744073709551615' }, 18446744073709551615n], + [{ kind: 'integer', decimal: '0' }, 0], +])('preserves exact bridge scalar kind %#', (raw, expected) => { + const event = decodeRequestMetadata(payload(raw), 'submission'); + expect(event?.type).toBe('model_request_dispatching'); + if (event?.type !== 'model_request_dispatching') + throw new Error('missing metadata'); + expect(event.reasoningEffort).toBe(expected); + const buffer = new ObservationBuffer('observer'); + expect(Object.isFrozen(buffer.append(event))).toBe(true); +}); +it.each([ + 9007199254740993n, + '9007199254740993', + true, + { kind: 'integer', decimal: '01' }, + { kind: 'integer', decimal: '-1' }, + { kind: 'integer', decimal: '18446744073709551616' }, + { kind: 'integer', decimal: '١' }, + { kind: 'integer', decimal: '1', body: 'private' }, + { kind: 'text', value: 1 }, + { kind: 'unknown', value: 'x' }, +])('drops malformed passive scalar without failure %#', (raw) => { + expect(decodeRequestMetadata(payload(raw), 'submission')).toBeNull(); +}); +it('drops extra or malformed fields and links only the exact retained occurrence key', () => { + for (const value of [ + { ...payload(), body: 'private' }, + { ...payload(), transport: 'unknown' }, + { ...payload(), service_tier: {} }, + { ...payload(), turn_id: null }, + ]) + expect(decodeRequestMetadata(value, 'submission')).toBeNull(); + expect( + decodeRequestMetadata( + { + type: 'model_response_linked', + request_id: 'request', + thread_id: 'thread', + turn_id: 'turn', + response_observation_id: 'occurrence', + }, + 'original-submission', + ), + ).toEqual({ + type: 'model_response_linked', + requestId: 'request', + threadId: 'thread', + turnId: 'turn', + responseObservationId: 'occurrence', + submissionId: 'original-submission', + }); +}); +it('keeps existing reader closure and bounded gap behavior with bigint effort', async () => { + const buffer = new ObservationBuffer('observer'); + const event = decodeRequestMetadata( + payload({ kind: 'integer', decimal: '9007199254740993' }), + 'submission', + )!; + const reader = buffer.events({}); + await reader.return?.(); + for (let i = 0; i < 300; i++) buffer.append(event); + buffer.finish('closed'); + const events = []; + for await (const value of buffer.events({})) events.push(value); + expect(events[0].type).toBe('gap'); + expect( + events.some( + (value) => + value.type === 'model_request_dispatching' && + value.reasoningEffort === 9007199254740993n, + ), + ).toBe(true); +}); diff --git a/packages/agents/test/native/worker-receipts-native.test.ts b/packages/agents/test/native/worker-receipts-native.test.ts index aa4cafae6..d7aac256f 100644 --- a/packages/agents/test/native/worker-receipts-native.test.ts +++ b/packages/agents/test/native/worker-receipts-native.test.ts @@ -8,6 +8,8 @@ import { Environment, NativeProvider, type ExecutionItem, + type ModelRequestDispatching, + type ModelResponseLinked, type SessionState, } from '@openai/agents'; import { binding, type NativeThread } from '../../src/runtime/binding'; @@ -146,11 +148,16 @@ it('records sampled instructions, build identity and matching live and retained projectGuidance: true, }); const turn = await session.submit('Run the fixture command.'); + const dispatches: ModelRequestDispatching[] = []; + const links: ModelResponseLinked[] = []; const completions = (async () => { const items: ExecutionItem[] = []; for await (const event of turn.events({ includeExecutionDetails: true, })) { + if (event.type === 'model_request_dispatching') + dispatches.push(event); + if (event.type === 'model_response_linked') links.push(event); if (event.type === 'execution_item_completed') { if (!('command' in event.item)) throw new Error('Expected command details'); @@ -199,6 +206,21 @@ it('records sampled instructions, build identity and matching live and retained 'permission_profile', ); const live = await completions; + expect(dispatches).toHaveLength(2); + expect(links).toHaveLength(2); + expect(new Set(dispatches.map((event) => event.requestId)).size).toBe(2); + for (const event of [...dispatches, ...links]) { + expect(event.threadId).toBe(session.id); + expect(event.turnId).toBe(turnId); + expect(event.submissionId).toBe(turn.submissionId); + } + expect(new Set(links.map((event) => event.requestId))).toEqual( + new Set(dispatches.map((event) => event.requestId)), + ); + expect( + new Set(links.map((event) => event.responseObservationId)), + ).toEqual(new Set(recorded?.responses?.map((response) => response.id))); + const retained = (await collect(session.items())).filter( (item) => item.type === 'command_execution', ); From f2e3810f3628ef8e494d1216eab17be57905e331 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:31:52 +0000 Subject: [PATCH 09/34] fix(agents): enforce prepared-request operation boundaries --- packages/agents/examples/prepared-request.ts | 4 ++ .../0054-native-prepared-request.patch | 30 +++++++------ packages/agents/native/patches/series.json | 4 +- packages/agents/src/runtime/public-agent.ts | 3 ++ packages/agents/src/runtime/session.ts | 3 ++ .../native/prepared-request-native.test.ts | 43 +++++++++++++++++++ 6 files changed, 73 insertions(+), 14 deletions(-) diff --git a/packages/agents/examples/prepared-request.ts b/packages/agents/examples/prepared-request.ts index 13f3ef4e4..b943375bf 100644 --- a/packages/agents/examples/prepared-request.ts +++ b/packages/agents/examples/prepared-request.ts @@ -8,6 +8,10 @@ import { text, toolCall, withScriptedResponses } from '@openai/agents/testing'; * The scripted provider uses explicit HTTP and a standard Responses (non-Lite) model. * Keep eager ordinary tools and a direct root; * Environment, code mode, children and automatic compaction are outside this profile. + * Pre-turn or mid-turn automatic compaction fails before any hidden model request. + * Shorten the input or start a new operation without the callback. Earlier tool effects remain. + * Post-turn compaction is refused while preserving the completed answer. + * Supply new input to Agent.run when using prepareRequest. A resumed turn cannot add an editor. * Sampling edits do not change canonical history, executable schemas or policies. * For structured output, use native mode or prompted mode with maxRetries: 0. */ diff --git a/packages/agents/native/patches/0054-native-prepared-request.patch b/packages/agents/native/patches/0054-native-prepared-request.patch index d25752934..a23e22d3d 100644 --- a/packages/agents/native/patches/0054-native-prepared-request.patch +++ b/packages/agents/native/patches/0054-native-prepared-request.patch @@ -367,10 +367,15 @@ index 0000000000000000000000000000000000000000..a70e5faf6d3b77f8fa6d90f9c92276ae + } +} diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs -index 6995888c4fff7fd6fd135db322e85db302c09462..ae5e9360041d44bb86015c1d7320eda62b164a7c 100644 +index 6995888c4fff7fd6fd135db322e85db302c09462..498b82ed76e161a4212ba0baab834bc147fede4c 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs -@@ -1459,6 +1459,26 @@ async fn run_auto_compact( +@@ -1,3 +1,4 @@ ++use std::borrow::Cow; + use std::collections::HashMap; + use std::collections::HashSet; + use std::sync::Arc; +@@ -1459,6 +1460,26 @@ async fn run_auto_compact( phase: CompactionPhase, ) -> CodexResult<()> { let turn_context = &step_context.turn; @@ -397,7 +402,7 @@ index 6995888c4fff7fd6fd135db322e85db302c09462..ae5e9360041d44bb86015c1d7320eda6 if matches!(phase, CompactionPhase::PreTurn) && crate::guardian::is_basic_session_source(&turn_context.session_source) && !crate::guardian::should_compact_guardian_input(sess)? -@@ -1602,6 +1622,7 @@ pub(crate) fn build_prompt( +@@ -1602,6 +1623,7 @@ pub(crate) fn build_prompt( parallel_tool_calls: true, tool_admission: None, sampling_stop: None, @@ -405,7 +410,7 @@ index 6995888c4fff7fd6fd135db322e85db302c09462..ae5e9360041d44bb86015c1d7320eda6 base_instructions, output_schema: turn_context.final_output_json_schema.clone(), output_schema_strict: !crate::guardian::is_basic_session_source( -@@ -1653,6 +1674,7 @@ async fn run_sampling_request( +@@ -1653,6 +1675,7 @@ async fn run_sampling_request( ); let max_retries = turn_context.provider.info().stream_max_retries(); let mut retry_state = ResponsesStreamRetryState::default(); @@ -413,7 +418,7 @@ index 6995888c4fff7fd6fd135db322e85db302c09462..ae5e9360041d44bb86015c1d7320eda6 let mut initial_input = Some(input); let mut original_input = None; let mut executed_tool_calls_by_output = HashMap::new(); -@@ -1685,6 +1707,27 @@ async fn run_sampling_request( +@@ -1685,6 +1708,27 @@ async fn run_sampling_request( let responses_metadata = sess .responses_metadata(step_context.as_ref(), CodexResponsesRequestKind::Turn) .await; @@ -441,12 +446,12 @@ index 6995888c4fff7fd6fd135db322e85db302c09462..ae5e9360041d44bb86015c1d7320eda6 if crate::guardian::is_basic_session_source(&turn_context.session_source) { crate::guardian::prepare_guardian_prompt( &sess, -@@ -1694,6 +1737,33 @@ async fn run_sampling_request( +@@ -1694,6 +1738,34 @@ async fn run_sampling_request( ) .await?; } + // The original prompt remains authoritative for history and retry bookkeeping. -+ let mut sampling_prompt = prompt.clone(); ++ let mut sampling_prompt = Cow::Borrowed(&prompt); + if !editors.is_empty() { + let data = crate::prepared_request::snapshot(&prompt).map_err(CodexErr::Fatal)?; + let admission = prompt.tool_admission.clone().ok_or_else(|| { @@ -467,15 +472,16 @@ index 6995888c4fff7fd6fd135db322e85db302c09462..ae5e9360041d44bb86015c1d7320eda6 + } + output.admission.admit_request().map_err(CodexErr::Fatal)?; + if let Some(data) = output.data { -+ sampling_prompt = -+ crate::prepared_request::apply(&prompt, data).map_err(CodexErr::Fatal)?; ++ sampling_prompt = Cow::Owned( ++ crate::prepared_request::apply(&prompt, data).map_err(CodexErr::Fatal)?, ++ ); + } -+ sampling_prompt.prepared_request_admission = Some(output.admission); ++ sampling_prompt.to_mut().prepared_request_admission = Some(output.admission); + } let err = match try_run_sampling_request( tool_runtime.clone(), Arc::clone(&sess), -@@ -1702,7 +1772,7 @@ async fn run_sampling_request( +@@ -1702,7 +1774,7 @@ async fn run_sampling_request( client_session, &responses_metadata, Arc::clone(&turn_diff_tracker), @@ -484,7 +490,7 @@ index 6995888c4fff7fd6fd135db322e85db302c09462..ae5e9360041d44bb86015c1d7320eda6 cancellation_token.child_token(), ) .await -@@ -1758,6 +1828,7 @@ async fn run_sampling_request( +@@ -1758,6 +1830,7 @@ async fn run_sampling_request( )); } retry??; diff --git a/packages/agents/native/patches/series.json b/packages/agents/native/patches/series.json index 0a04fd06f..cb0bfe079 100644 --- a/packages/agents/native/patches/series.json +++ b/packages/agents/native/patches/series.json @@ -214,8 +214,8 @@ }, { "path": "0054-native-prepared-request.patch", - "sha256": "88d33dfacf650cf4e3f20983bbfca0083e5cab245efda3fbe778e0e084113e1f" + "sha256": "d1b3f896d04a45c7d23e4efd649ad8e172e6cd838cd4d89e77848326ccd5722b" } ], - "result_tree": "cdfb5e9dc38a0a9af42a8abdadbfce261131ccb8" + "result_tree": "112ac800d02e7afe1f398f923cdc1cf7218230c0" } diff --git a/packages/agents/src/runtime/public-agent.ts b/packages/agents/src/runtime/public-agent.ts index c652617d6..da5f13422 100644 --- a/packages/agents/src/runtime/public-agent.ts +++ b/packages/agents/src/runtime/public-agent.ts @@ -94,6 +94,7 @@ export interface RunOptions extends Omit< > { /** Root model preparation limit for a new run. Resumed runs inherit their saved budget. */ maxModelSteps?: OperationOptions['maxModelSteps']; + /** Edit requests for a new run with input. A resumed turn cannot add an editor. */ prepareRequest?: import('./session').OperationOptions['prepareRequest']; returnState?: boolean; onNativeQuestion?: ( @@ -345,6 +346,8 @@ export class Agent { throw new TypeError( 'Cannot replace the model step budget when resuming a run', ); + if (input === null && options.prepareRequest !== undefined) + throw new TypeError('Cannot add prepareRequest when resuming a run'); const preparedInput = input === null ? null : nativeInput(input); const factory = this.session; const sessionOptions: OwnedSessionOptions = { diff --git a/packages/agents/src/runtime/session.ts b/packages/agents/src/runtime/session.ts index f4abcfe8d..5a65b7833 100644 --- a/packages/agents/src/runtime/session.ts +++ b/packages/agents/src/runtime/session.ts @@ -289,6 +289,9 @@ export interface OperationOptions { * Requires a standard Responses (non-Lite) model, explicit HTTP, and eager root function/MCP tools. * Undefined keeps the original. * Recovery may prepare again; avoid business effects in this callback. Active checkpoints refuse it. + * Pre-turn or mid-turn automatic compaction fails the operation before any hidden request. + * Shorten the input or start a new operation without this callback. Earlier tool effects remain. + * Post-turn compaction is also refused, but the operation keeps its completed answer. */ prepareRequest?: ( context: PrepareRequestContext, diff --git a/packages/agents/test/native/prepared-request-native.test.ts b/packages/agents/test/native/prepared-request-native.test.ts index 95a30277a..5d92488ea 100644 --- a/packages/agents/test/native/prepared-request-native.test.ts +++ b/packages/agents/test/native/prepared-request-native.test.ts @@ -350,6 +350,49 @@ it('joins a cancelled uncooperative callback before admitting the next root', as expect((await next.wait()).finalOutput).toBe('fresh'); }); +it('rejects a new request editor before restoring an admitted manual turn', async () => { + const approval = manualTool({ + name: 'approval', + description: 'Wait.', + parameters: z.object({}), + result: z.string(), + }); + const server = new ModelServer([ + [call('approval', {}, { callId: 'manual' })], + [message('continued')], + ]); + await using provider = await providerFor(server); + const agent = new Agent({ + model: 'gpt-5.5', + instructions: 'Ask.', + provider, + tools: [approval], + }); + await using session = await agent.session(); + const turn = await session.submit('go'); + const pending = await turn.wait({ until: 'request' }); + expect(pending).toHaveProperty('requests'); + const state = await session.suspend(); + await session.close(); + const editor = vi.fn(() => undefined); + const onInteraction = vi.fn((action: Parameters[0]) => + approval.match(action)!.reply('approved'), + ); + await expect( + agent.run(null, { state, prepareRequest: editor, onInteraction }), + ).rejects.toThrow('Cannot add prepareRequest when resuming a run'); + expect(editor).not.toHaveBeenCalled(); + expect(onInteraction).not.toHaveBeenCalled(); + expect(server.requests).toHaveLength(1); + await expect( + agent.run(null, { state, onInteraction }), + ).resolves.toMatchObject({ + finalOutput: 'continued', + }); + expect(server.requests).toHaveLength(2); + expect(JSON.stringify(server.requests[1]!.input)).toContain('approved'); +}); + it('refuses active manual checkpoint readiness and export after preparation settles', async () => { const approval = manualTool({ name: 'approval', From 1831525a69120e009479763300f3a2d010970a51 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:43:35 +0000 Subject: [PATCH 10/34] fix(agents): validate the sampled request-editor tool roster --- packages/agents/examples/prepared-request.ts | 3 +- packages/agents/src/runtime/session.ts | 6 +- .../native/prepared-request-native.test.ts | 166 ++++++++++++++++++ 3 files changed, 170 insertions(+), 5 deletions(-) diff --git a/packages/agents/examples/prepared-request.ts b/packages/agents/examples/prepared-request.ts index b943375bf..4c11c557b 100644 --- a/packages/agents/examples/prepared-request.ts +++ b/packages/agents/examples/prepared-request.ts @@ -6,7 +6,8 @@ import { text, toolCall, withScriptedResponses } from '@openai/agents/testing'; /** * Exercise the public request, argument and result controls without live credentials. * The scripted provider uses explicit HTTP and a standard Responses (non-Lite) model. - * Keep eager ordinary tools and a direct root; + * Select eager ordinary tools for each sampling step of a direct root. + * beforeStep may narrow a mixed opening catalog. Deferred sampled tools fail before the editor. * Environment, code mode, children and automatic compaction are outside this profile. * Pre-turn or mid-turn automatic compaction fails before any hidden model request. * Shorten the input or start a new operation without the callback. Earlier tool effects remain. diff --git a/packages/agents/src/runtime/session.ts b/packages/agents/src/runtime/session.ts index e62dfdc0e..ec1835bfc 100644 --- a/packages/agents/src/runtime/session.ts +++ b/packages/agents/src/runtime/session.ts @@ -298,6 +298,7 @@ export interface OperationOptions { /** * Edit detached native sampling data; original tools and history retain authority. * Requires a standard Responses (non-Lite) model, explicit HTTP, and eager root function/MCP tools. + * Unselected deferred tools may remain in the opening catalog. beforeStep may narrow a mixed ceiling. * Undefined keeps the original. * Recovery may prepare again; avoid business effects in this callback. Active checkpoints refuse it. * Pre-turn or mid-turn automatic compaction fails the operation before any hidden request. @@ -2676,10 +2677,7 @@ export class Session { this.options.webSearch || this.options.nativeGoals || this.options.checklist || - this.catalog.tools.some((tool) => tool.deferLoading) || - this.openingMcpTools.some( - (tool) => tool.deferLoading || isHostedRegistration(tool), - ) + this.openingMcpTools.some(isHostedRegistration) ) throw new TypeError( 'prepareRequest supports only direct root eager function and ordinary MCP tools', diff --git a/packages/agents/test/native/prepared-request-native.test.ts b/packages/agents/test/native/prepared-request-native.test.ts index 5d92488ea..71d131a4b 100644 --- a/packages/agents/test/native/prepared-request-native.test.ts +++ b/packages/agents/test/native/prepared-request-native.test.ts @@ -12,6 +12,8 @@ import { ToolOutput, tool, manualTool, + toolRef, + HostedVerificationMCP, HttpMCP, localStorage, } from '@openai/agents'; @@ -350,6 +352,170 @@ it('joins a cancelled uncooperative callback before admitting the next root', as expect((await next.wait()).finalOutput).toBe('fresh'); }); +it.each(['empty', 'eager-reference'] as const)( + 'prepares the selected %s roster when the opening catalog has a deferred tool', + async (selection) => { + const eager = tool({ + name: 'eager', + parameters: z.object({}), + execute: () => 'unused', + }); + const deferred = tool({ + name: 'deferred', + parameters: z.object({}), + deferLoading: true, + execute: () => 'unused', + }); + const server = new ModelServer([[message('done')]]); + await using provider = await providerFor(server); + await using session = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + tools: [eager, deferred], + }).session(); + const editor = vi.fn(({ request }: { request: PreparedRequestData }) => { + expect(request.tools.map((tool) => tool.name)).toEqual( + selection === 'empty' ? [] : ['eager'], + ); + return request; + }); + const turn = await session.submit('go', { + tools: selection === 'empty' ? [] : [toolRef('eager')], + prepareRequest: editor, + }); + expect((await turn.wait()).finalOutput).toBe('done'); + expect(editor).toHaveBeenCalledTimes(1); + expect(server.requests[0].tools).toEqual( + selection === 'empty' + ? [] + : [expect.objectContaining({ type: 'function', name: 'eager' })], + ); + }, +); + +it('refuses a selected deferred tool before the editor, HTTP and tool effects', async () => { + const execute = vi.fn(() => 'unused'); + const deferred = tool({ + name: 'deferred', + parameters: z.object({}), + deferLoading: true, + execute, + }); + const server = new ModelServer([]); + await using provider = await providerFor(server); + await using session = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + tools: [deferred], + }).session(); + const editor = vi.fn(() => undefined); + const turn = await session.submit('go', { + tools: [toolRef('deferred')], + prepareRequest: editor, + }); + await expect(turn.wait()).rejects.toThrow(/prepare_request.*eager/i); + expect(editor).not.toHaveBeenCalled(); + expect(execute).not.toHaveBeenCalled(); + expect(server.requests).toHaveLength(0); +}); + +it('checks the roster after beforeStep and preserves effects before a later deferred selection', async () => { + const execute = vi.fn(() => 'original effect'); + const eager = tool({ name: 'eager', parameters: z.object({}), execute }); + const deferredExecute = vi.fn(() => 'unused'); + const deferred = tool({ + name: 'deferred', + parameters: z.object({}), + deferLoading: true, + execute: deferredExecute, + }); + const server = new ModelServer([[call('eager', {}, { callId: 'effect' })]]); + await using provider = await providerFor(server); + await using session = await new Agent({ + model: 'gpt-5.5', + instructions: 'Call eager.', + provider, + tools: [eager, deferred], + }).session(); + const editor = vi.fn(({ request }: { request: PreparedRequestData }) => { + expect(request.tools.map((tool) => tool.name)).toEqual(['eager']); + return request; + }); + const beforeStep = vi.fn(({ stepIndex }: { stepIndex: number }) => [ + toolRef(stepIndex === 0 ? 'eager' : 'deferred'), + ]); + const turn = await session.submit('go', { + beforeStep, + prepareRequest: editor, + }); + await expect(turn.wait()).rejects.toThrow(/prepare_request.*eager/i); + expect(beforeStep).toHaveBeenCalledTimes(2); + expect(editor).toHaveBeenCalledTimes(1); + expect(execute).toHaveBeenCalledTimes(1); + expect(deferredExecute).not.toHaveBeenCalled(); + expect(server.requests).toHaveLength(1); + expect(server.requests[0].tools).toEqual([ + expect.objectContaining({ type: 'function', name: 'eager' }), + ]); +}); + +it('keeps the operation ceiling when beforeStep tries to select an excluded deferred tool', async () => { + const eager = tool({ + name: 'eager', + parameters: z.object({}), + execute: () => 'unused', + }); + const deferred = tool({ + name: 'deferred', + parameters: z.object({}), + deferLoading: true, + execute: () => 'unused', + }); + const server = new ModelServer([]); + await using provider = await providerFor(server); + await using session = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + tools: [eager, deferred], + }).session(); + const editor = vi.fn(() => undefined); + const turn = await session.submit('go', { + tools: [toolRef('eager')], + beforeStep: () => [toolRef('deferred')], + prepareRequest: editor, + }); + await expect(turn.wait()).rejects.toThrow(); + expect(editor).not.toHaveBeenCalled(); + expect(server.requests).toHaveLength(0); +}); + +it('keeps fixed hosted MCP registration outside the editor profile even with no selected tools', async () => { + const remote = await peer(); + const hosted = new HostedVerificationMCP({ + url: remote.url, + tools: ['lookup'], + deferLoading: false, + }); + const server = new ModelServer([]); + await using provider = await providerFor(server); + await using session = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + tools: [hosted], + }).session(); + const editor = vi.fn(() => undefined); + await expect( + session.submit('go', { tools: [], prepareRequest: editor }), + ).rejects.toThrow(/eager function/i); + expect(editor).not.toHaveBeenCalled(); + expect(remote.effects).toHaveLength(0); + expect(server.requests).toHaveLength(0); +}); + it('rejects a new request editor before restoring an admitted manual turn', async () => { const approval = manualTool({ name: 'approval', From 1afbb7e6f1c07a942ff41d5da30ff3aa7266c69e Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:21:04 +0000 Subject: [PATCH 11/34] fix: retain metadata routing through managed continuation --- packages/agents/native/rust/ops.rs | 5 ++ .../native/rust/turn_settlement_tests.rs | 39 +++++++- .../native/request-metadata-native.test.ts | 88 +++++++++++++++++++ 3 files changed, 129 insertions(+), 3 deletions(-) diff --git a/packages/agents/native/rust/ops.rs b/packages/agents/native/rust/ops.rs index 8489f51cb..bc64b630f 100644 --- a/packages/agents/native/rust/ops.rs +++ b/packages/agents/native/rust/ops.rs @@ -552,6 +552,11 @@ async fn continue_managed<'a>( } } init.insert(selection); + if let Some(submission) = p.observation_submission_id { + state + .request_metadata + .seed(&mut init, submission, state.id.to_string()); + } let value = submission( crate::turn_settlement::continue_retained( state, diff --git a/packages/agents/native/rust/turn_settlement_tests.rs b/packages/agents/native/rust/turn_settlement_tests.rs index 90bc6b1ff..092579871 100644 --- a/packages/agents/native/rust/turn_settlement_tests.rs +++ b/packages/agents/native/rust/turn_settlement_tests.rs @@ -192,7 +192,7 @@ async fn managed_root_continues_only_its_own_successfully_settled_tail() { json!({}) }; let params = json!({"mode":"start_if_idle", "input":[{"type":"text","text":"begin"}], - "start":start}); + "start":start, "observation_submission_id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"}); let started: Value = serde_json::from_str(&native._submit(params.to_string()).await.unwrap()).unwrap(); assert_eq!(started["status"], "started"); let first = started["turn_id"].as_str().unwrap(); @@ -220,8 +220,12 @@ async fn managed_root_continues_only_its_own_successfully_settled_tail() { } else { hold.release.add_permits(1); } + let mut metadata = Vec::new(); let original = loop { let event = state.next_session_event().await.unwrap(); + if event["msg"]["type"] == "_sdk_request_metadata" { + metadata.push(event.clone()); + } if matches!( event["msg"]["type"].as_str(), Some("task_complete" | "turn_complete" | "turn_aborted") @@ -259,7 +263,7 @@ async fn managed_root_continues_only_its_own_successfully_settled_tail() { } let submit = json!({"mode":"continue_if_idle", "expected_previous_turn_id":first, "input":[], - "start":start}); + "start":start, "observation_submission_id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"}); let continued: Value = serde_json::from_str(&native._submit(submit.to_string()).await.unwrap()).unwrap(); if route == "replaceable" { @@ -269,7 +273,36 @@ async fn managed_root_continues_only_its_own_successfully_settled_tail() { assert_eq!(continued["status"], "started"); let next_id = continued["turn_id"].as_str().unwrap(); assert_ne!(next_id, first); - let next = session_terminal(&state, next_id).await.unwrap(); + let next = loop { + let event = state.next_session_event().await.unwrap(); + if event["msg"]["type"] == "_sdk_request_metadata" { + metadata.push(event.clone()); + } + if matches!(event["msg"]["type"].as_str(), Some("task_complete" | "turn_complete")) { + assert_eq!(event["id"], next_id); + break event; + } + }; + while let Some(notice) = state.request_metadata.try_next() { + metadata.push(notice); + } + for (turn_id, submission_id) in [ + (first, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + (next_id, "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"), + ] { + let owned: Vec<_> = metadata.iter().filter(|notice| notice["msg"]["submission_id"] == submission_id).collect(); + assert_eq!(owned.len(), 2, "missing dispatch/link metadata for {submission_id}"); + let dispatch = &owned[0]["msg"]["observation"]; + let linked = &owned[1]["msg"]["observation"]; + assert_eq!(dispatch["type"], "model_request_dispatching"); + assert_eq!(linked["type"], "model_response_linked"); + assert_eq!(dispatch["turn_id"], turn_id); + assert_eq!(linked["turn_id"], turn_id); + assert_eq!(dispatch["thread_id"], state.id.to_string()); + assert_eq!(linked["request_id"], dispatch["request_id"]); + assert!(linked["response_observation_id"].as_str().is_some_and(|id| !id.is_empty())); + } + assert_ne!(metadata[0]["msg"]["observation"]["request_id"], metadata[2]["msg"]["observation"]["request_id"]); assert_eq!(next["sdk_unsampled_steering"], false); assert_eq!(next["msg"]["last_agent_message"], output); let requests = provider.requests.lock().unwrap(); diff --git a/packages/agents/test/native/request-metadata-native.test.ts b/packages/agents/test/native/request-metadata-native.test.ts index 76cb0d641..4ce56cbec 100644 --- a/packages/agents/test/native/request-metadata-native.test.ts +++ b/packages/agents/test/native/request-metadata-native.test.ts @@ -16,6 +16,94 @@ import { import type { JsonObject } from '../../src/runtime/binding'; import { ModelServer, call, message } from './fixtures/model-server'; +it('preserves metadata through live steering of the same native turn', async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const marker = 'retained-metadata-steering'; + const server = new ModelServer([ + async () => { + entered.resolve(); + await release.promise; + return [message('initial answer')]; + }, + (request) => { + expect(JSON.stringify(request.input)).toContain(marker); + return [message('steered answer')]; + }, + ]); + const config = await server.config(); + await using provider = new NativeProvider({ + baseUrl: (config['model_providers.fixture'] as { base_url: string }) + .base_url, + apiKey: 'synthetic-retained-metadata', + supportsWebsockets: false, + }); + const agent = new Agent({ + instructions: 'Answer, then handle steering.', + provider, + }); + await using session = await agent.session(); + try { + const turn = await session.submit('first'); + const completed = turn.wait(); + void completed.catch(() => undefined); + await entered.promise; + await turn.steer(marker); + release.resolve(); + expect((await completed).finalOutput).toBe('steered answer'); + const events = []; + for await (const event of turn.events()) events.push(event); + const dispatches = events.filter( + (event): event is ModelRequestDispatching => + event.type === 'model_request_dispatching', + ); + const links = events.filter( + (event): event is ModelResponseLinked => + event.type === 'model_response_linked', + ); + expect(server.requests).toHaveLength(2); + expect(dispatches).toHaveLength(2); + expect(links).toHaveLength(2); + expect(dispatches[0].submissionId).toBe(turn.submissionId); + expect(dispatches[1].submissionId).toBe(dispatches[0].submissionId); + expect(dispatches[1].requestId).not.toBe(dispatches[0].requestId); + expect(dispatches[1].turnId).toBe(dispatches[0].turnId); + for (const dispatch of dispatches) + expect(dispatch.threadId).toBe(session.id); + const records = []; + for await (const record of session.turns()) records.push(record); + const occurrences = records.flatMap((record) => record.responses ?? []); + expect(occurrences).toHaveLength(2); + for (const link of links) { + const dispatch = dispatches.find( + (event) => event.requestId === link.requestId, + )!; + expect([link.threadId, link.turnId, link.submissionId]).toEqual([ + dispatch.threadId, + dispatch.turnId, + dispatch.submissionId, + ]); + expect( + occurrences.some( + (response) => response.id === link.responseObservationId, + ), + ).toBe(true); + } + const state = await session.saveState(); + await session.close(); + await using restored = await agent.session({ state }); + const restoredRecords = []; + for await (const record of restored.turns()) restoredRecords.push(record); + expect(restoredRecords.map((record) => record.responses)).toEqual( + records.map((record) => record.responses), + ); + expect(server.requests).toHaveLength(2); + server.assertExhausted(); + } finally { + release.resolve(); + } +}, 30_000); + const cleanups: Array<() => Promise> = []; afterEach(async () => { await ModelServer.closeAll(); From 34eec9d81d64ab40428c297b406b4bb2432f5e12 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:29:38 +0000 Subject: [PATCH 12/34] fix: clear prior operation selection before metadata setup --- packages/agents/native/rust/ops.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/agents/native/rust/ops.rs b/packages/agents/native/rust/ops.rs index bc64b630f..16f1e2d16 100644 --- a/packages/agents/native/rust/ops.rs +++ b/packages/agents/native/rust/ops.rs @@ -630,7 +630,12 @@ async fn start_with_params<'a>( { let mut init = match turn_init { Some(init) => init, - None => thread.config_snapshot().await.turn_extension_init, + None => { + // Making metadata explicit must preserve the ordinary-root reset. + let mut init = thread.config_snapshot().await.turn_extension_init; + init.insert(None::); + init + } }; state .request_metadata From 5f8999f3fb812a9ade654160d84d186687df8979 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:43:01 +0000 Subject: [PATCH 13/34] Preserve defined W3C flags through native trace headers --- .changeset/defined-native-trace-flags.md | 5 + .../0055-w3c-defined-trace-flags.patch | 120 ++++++++++++++++++ packages/agents/native/patches/series.json | 6 +- packages/agents/src/runtime/trace-context.ts | 5 +- .../native/fixtures/trace-context-child.ts | 36 +++++- .../test/native/trace-context-native.test.ts | 9 ++ packages/agents/test/trace-context.test.ts | 12 +- 7 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 .changeset/defined-native-trace-flags.md create mode 100644 packages/agents/native/patches/0055-w3c-defined-trace-flags.patch diff --git a/.changeset/defined-native-trace-flags.md b/.changeset/defined-native-trace-flags.md new file mode 100644 index 000000000..4ce843f6e --- /dev/null +++ b/.changeset/defined-native-trace-flags.md @@ -0,0 +1,5 @@ +--- +'@openai/agents': patch +--- + +fix: accept defined sampled and random-trace-ID flag combinations and preserve them through native parent and callback header propagation. diff --git a/packages/agents/native/patches/0055-w3c-defined-trace-flags.patch b/packages/agents/native/patches/0055-w3c-defined-trace-flags.patch new file mode 100644 index 000000000..1c40d0b5a --- /dev/null +++ b/packages/agents/native/patches/0055-w3c-defined-trace-flags.patch @@ -0,0 +1,120 @@ +diff --git a/codex-rs/otel/src/trace_context.rs b/codex-rs/otel/src/trace_context.rs +index 8ce0f99a4bb0c4145be276aea94f1a0fa5626e35..584f547c9be88d64a2dd4924ad86097209b1ec0d 100644 +--- a/codex-rs/otel/src/trace_context.rs ++++ b/codex-rs/otel/src/trace_context.rs +@@ -9,7 +9,9 @@ use std::sync::RwLock; + use codex_protocol::protocol::W3cTraceContext; + use opentelemetry::Context; + use opentelemetry::propagation::TextMapPropagator; ++use opentelemetry::trace::SpanContext; + use opentelemetry::trace::TraceContextExt; ++use opentelemetry::trace::TraceFlags; + use opentelemetry::trace::TraceState; + use opentelemetry_sdk::propagation::TraceContextPropagator; + use tracing::Span; +@@ -38,6 +40,16 @@ pub fn span_w3c_trace_context(span: &Span) -> Option { + + let mut headers = HashMap::new(); + TraceContextPropagator::new().inject_context(&context, &mut headers); ++ // The pinned propagator predates the W3C random-trace-id flag. Child ++ // spans retain it, so preserve it when writing their canonical header. ++ let flags = context.span().span_context().trace_flags().to_u8() & 0x03; ++ if flags & 0x02 != 0 { ++ if let Some(parent) = headers.get_mut("traceparent") { ++ if let Some((prefix, _)) = parent.rsplit_once('-') { ++ *parent = format!("{prefix}-{flags:02x}"); ++ } ++ } ++ } + let tracestate = headers.remove("tracestate"); + let configured_tracestate_guard = tracestate_entries() + .read() +@@ -131,8 +143,25 @@ pub(crate) fn context_from_trace_headers( + tracestate: Option<&str>, + ) -> Option { + let traceparent = traceparent?; ++ // W3C Level 2 defines bit 0 as sampled and bit 1 as random trace ID. ++ // Let the pinned propagator validate all other header syntax using its ++ // supported sampling bit, then restore the complete defined flags. ++ let flags = match traceparent.split('-').nth(3) { ++ Some("02") => Some(0x02), ++ Some("03") => Some(0x03), ++ _ => None, ++ }; ++ let normalized = flags.and_then(|flags| { ++ traceparent.strip_prefix("00-")?; ++ traceparent ++ .strip_suffix(if flags == 2 { "02" } else { "03" }) ++ .map(|prefix| format!("{prefix}{:02x}", flags & 1)) ++ }); + let mut headers = HashMap::new(); +- headers.insert("traceparent".to_string(), traceparent.to_string()); ++ headers.insert( ++ "traceparent".to_string(), ++ normalized.unwrap_or_else(|| traceparent.to_string()), ++ ); + if let Some(tracestate) = tracestate { + headers.insert("tracestate".to_string(), tracestate.to_string()); + } +@@ -142,6 +171,17 @@ pub(crate) fn context_from_trace_headers( + if !context.span().span_context().is_valid() { + return None; + } ++ if let Some(flags) = flags { ++ let span = context.span(); ++ let parent = span.span_context(); ++ return Some(context.with_remote_span_context(SpanContext::new( ++ parent.trace_id(), ++ parent.span_id(), ++ TraceFlags::new(flags), ++ true, ++ parent.trace_state().clone(), ++ ))); ++ } + Some(context) + } + +@@ -368,6 +408,43 @@ mod tests { + assert!(span_context.is_remote()); + } + ++ #[test] ++ fn defined_trace_flags_survive_child_header_propagation() { ++ use super::span_w3c_trace_context; ++ use tracing_opentelemetry::OpenTelemetrySpanExt; ++ ++ for flags in ["00", "01", "02", "03"] { ++ let provider = SdkTracerProvider::builder().build(); ++ let subscriber = tracing_subscriber::registry() ++ .with(tracing_opentelemetry::layer().with_tracer(provider.tracer("flags"))); ++ tracing::subscriber::with_default(subscriber, || { ++ let parent = ++ format!("00-11111111111111111111111111111111-2222222222222222-{flags}"); ++ let context = ++ context_from_trace_headers(Some(&parent), Some("vendor=value")).unwrap(); ++ assert_eq!( ++ context.span().span_context().trace_flags().to_u8(), ++ u8::from_str_radix(flags, 16).unwrap() ++ ); ++ let span = tracing::info_span!("child"); ++ span.set_parent(context).unwrap(); ++ let headers = span_w3c_trace_context(&span).unwrap(); ++ let header = headers.traceparent.unwrap(); ++ assert_eq!(&header[3..35], &parent[3..35]); ++ assert_eq!(&header[53..], flags); ++ assert_eq!(headers.tracestate.as_deref(), Some("vendor=value")); ++ }); ++ } ++ assert!( ++ context_from_trace_headers( ++ Some("00-00000000000000000000000000000000-2222222222222222-03"), ++ None ++ ) ++ .is_none() ++ ); ++ assert!(context_from_trace_headers(Some("00-bad-2222222222222222-03"), None).is_none()); ++ } ++ + #[test] + fn invalid_traceparent_returns_none() { + let parent = context_from_trace_headers( diff --git a/packages/agents/native/patches/series.json b/packages/agents/native/patches/series.json index 213e9a3b4..9d047ed5d 100644 --- a/packages/agents/native/patches/series.json +++ b/packages/agents/native/patches/series.json @@ -215,7 +215,11 @@ { "path": "0024-native-response-observations.patch", "sha256": "506fd6c938ae58e7f44a2ddcef2f2794ba33069c5f0cc3c098090fcf2c4e4c26" + }, + { + "path": "0055-w3c-defined-trace-flags.patch", + "sha256": "e18ef85b7d975a646b65e9ea43473af9dc046bd8d650117fcaee3fbf52d6ee33" } ], - "result_tree": "ea9493714212f7d98100810870de3f64033801fd" + "result_tree": "8c2b54f417db0c2bbfc2be8e4891dcd0d538ea38" } diff --git a/packages/agents/src/runtime/trace-context.ts b/packages/agents/src/runtime/trace-context.ts index 8dad4eb1d..216e208d9 100644 --- a/packages/agents/src/runtime/trace-context.ts +++ b/packages/agents/src/runtime/trace-context.ts @@ -9,7 +9,7 @@ export interface TraceContextHeaders { /** * Immutable explicit native trace parent, independent of any tracing provider. * Traceparent accepts at most 512 ASCII characters, nonzero lowercase IDs, and - * version 00 flags 00 or 01. Future versions retain their opaque suffix. + * version 00 flags 00 through 03 (sampled and random trace ID). Future versions retain their opaque suffix. * Tracestate accepts 32 entries with keys and values up to 256 characters; * tenant system IDs accept at most 13 characters to match native OpenTelemetry. * This native profile does not accept every standards-valid W3C carrier. @@ -44,7 +44,8 @@ export class TraceContext { match[2] === '0'.repeat(32) || match[3] === '0'.repeat(16) || (match[1] === '00' && - (parent.length !== 55 || !['00', '01'].includes(match[4]!))) || + (parent.length !== 55 || + !['00', '01', '02', '03'].includes(match[4]!))) || (parent.length > 55 && parent[55] !== '-') || /[^\x21-\x7e]/.test(parent) ) diff --git a/packages/agents/test/native/fixtures/trace-context-child.ts b/packages/agents/test/native/fixtures/trace-context-child.ts index 2cb2cc24f..2f9aace34 100644 --- a/packages/agents/test/native/fixtures/trace-context-child.ts +++ b/packages/agents/test/native/fixtures/trace-context-child.ts @@ -114,6 +114,37 @@ async function review() { } } +async function definedFlags(flags: string) { + let callback: ReturnType | undefined; + const server = new ModelServer([ + [call('observe', {}, { callId: 'flags-call' })], + [final('done')], + ]); + const agent = new Agent({ + instructions: 'Call observe.', + model: 'gpt-5', + provider: await providerFor(server), + tools: [ + tool({ + name: 'observe', + parameters: z.object({}), + execute: (_args, context) => { + assert(context.traceContext); + callback = context.traceContext.toHeaders(); + return 'accepted'; + }, + }), + ], + }); + const result = await agent.run('start', { + parentTrace: parent(PARENT.slice(0, -2) + flags), + }); + assert.equal(result.finalOutput, 'done'); + assert(callback); + server.assertExhausted(); + return { callback, turn: result.turn.turnId }; +} + async function graph() { const remoteServer = new ModelServer([[final('remote')]]); const remote = new Agent({ @@ -293,8 +324,9 @@ async function main() { traces: { kind: 'otlp-http', endpoint: collector!, protocol: 'json' }, }); try { - const result = - mode === 'graph' + const result = mode?.startsWith('flags-') + ? await definedFlags(mode.slice(6)) + : mode === 'graph' ? await graph() : mode === 'compact' ? await compact() diff --git a/packages/agents/test/native/trace-context-native.test.ts b/packages/agents/test/native/trace-context-native.test.ts index e4b8220c3..2a44907fd 100644 --- a/packages/agents/test/native/trace-context-native.test.ts +++ b/packages/agents/test/native/trace-context-native.test.ts @@ -92,6 +92,15 @@ it('exports isolated root and compaction parents, queued snapshots, exact callba return false; }; try { + for (const flags of ['02', '03']) { + const receipt = await run('flags-' + flags); + expect(receipt.callback.traceparent.slice(3, 35)).toBe( + PARENT.slice(3, 35), + ); + expect(receipt.callback.traceparent.endsWith('-' + flags)).toBe(true); + if (flags === '03') + expectParent(dispatch(receipt.turn), PARENT.slice(0, -2) + flags); + } const compact = await run('compact'); expectParent(dispatch(compact.first), OVERRIDE); expectParent(dispatch(compact.compact), PARENT); diff --git a/packages/agents/test/trace-context.test.ts b/packages/agents/test/trace-context.test.ts index 56375783f..259d002de 100644 --- a/packages/agents/test/trace-context.test.ts +++ b/packages/agents/test/trace-context.test.ts @@ -69,7 +69,17 @@ describe('explicit W3C trace carriers', () => { expect(new Headers(headers).get('tracestate')).toBeNull(); }); - it.each(['02', '09'])( + it.each(['00', '01', '02', '03'])( + 'round-trips defined trace flags %s', + (flags) => { + const value = parent.slice(0, -2) + flags; + expect(new TraceContext({ traceparent: value }).toHeaders()).toEqual({ + traceparent: value, + }); + }, + ); + + it.each(['04', '09'])( 'rejects trace flags %s outside the documented native profile', (flags) => { expect( From b8b0da5551d8775baba1550f8060b5449719ae36 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:06:47 +0000 Subject: [PATCH 14/34] fix(a2a): reject encoded responses before body reads --- .changeset/limit-a2a-response-encoding.md | 5 + packages/agents/A2A.md | 2 +- packages/agents/src/a2a/client.ts | 6 + .../agents/test/a2a-response-encoding.test.ts | 153 ++++++++++++++++++ 4 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 .changeset/limit-a2a-response-encoding.md create mode 100644 packages/agents/test/a2a-response-encoding.test.ts diff --git a/.changeset/limit-a2a-response-encoding.md b/.changeset/limit-a2a-response-encoding.md new file mode 100644 index 000000000..894ff0644 --- /dev/null +++ b/.changeset/limit-a2a-response-encoding.md @@ -0,0 +1,5 @@ +--- +'@openai/agents': patch +--- + +Reject compressed A2A discovery and RPC responses before reading their bodies, and request identity encoding so the adapter's response-byte bound does not depend on automatic decompression. diff --git a/packages/agents/A2A.md b/packages/agents/A2A.md index df91fb17d..51cd64eba 100644 --- a/packages/agents/A2A.md +++ b/packages/agents/A2A.md @@ -27,7 +27,7 @@ Discovery sends no credentials. After the card passes admission, the adapter res The returned client is the official A2A `Client`. Direct callers must treat an uncertain mutation acknowledgment as an unknown outcome; message IDs do not make creation idempotent. This profile supports `sendMessage`, `getTask`, and `cancelTask`. It uses polling rather than streaming, push notifications, extended cards, or remote callback execution. Native input supports text and inline PNG, JPEG, GIF, and WebP images. File URLs, arbitrary URIs, and other file types fail before native admission. Result validation remains the application's responsibility. -`A2ALimits` sets `bodyBytes`, `inputBytes`, `checkpointBytes`, and `timeoutMs`. Defaults are 2 MiB, 1 MiB, 8 MiB, and 30 seconds. The HTTP reader stops before buffering more than `bodyBytes`. These bounds are adapter defaults, not service quotas. The host should also bound incoming HTTP bodies before protocol parsing. +`A2ALimits` sets `bodyBytes`, `inputBytes`, `checkpointBytes`, and `timeoutMs`. Defaults are 2 MiB, 1 MiB, 8 MiB, and 30 seconds. The adapter requests identity HTTP encoding and cancels responses that declare another encoding before reading their bodies. `bodyBytes` limits the chunks the adapter retains. A custom `fetch` must preserve response encoding headers and body cancellation. The transport controls its own internal buffers. These bounds are adapter defaults, not service quotas. The host should also bound incoming HTTP bodies before protocol parsing. ## Deferred remote work diff --git a/packages/agents/src/a2a/client.ts b/packages/agents/src/a2a/client.ts index 81b30b9b1..4c56d00f9 100644 --- a/packages/agents/src/a2a/client.ts +++ b/packages/agents/src/a2a/client.ts @@ -91,6 +91,11 @@ async function boundedResponse( await response.body?.cancel().catch(() => {}); throw new A2AError('unavailable'); } + const encoding = response.headers.get('Content-Encoding'); + if (encoding !== null && encoding.toLowerCase() !== 'identity') { + await response.body.cancel().catch(() => {}); + throw new A2AError('unsupported'); + } const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let size = 0; @@ -166,6 +171,7 @@ export async function connectA2A( ...(init.signal ? [init.signal] : []), ]); const headers = new Headers(init.headers); + headers.set('Accept-Encoding', 'identity'); if (credential !== undefined && parent) for (const [key, value] of Object.entries(parent.toHeaders())) headers.set(key, value); diff --git a/packages/agents/test/a2a-response-encoding.test.ts b/packages/agents/test/a2a-response-encoding.test.ts new file mode 100644 index 000000000..7fdeef029 --- /dev/null +++ b/packages/agents/test/a2a-response-encoding.test.ts @@ -0,0 +1,153 @@ +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { gzipSync } from 'node:zlib'; +import { expect, it } from 'vitest'; +import { AgentCard, Task } from '@a2a-js/sdk'; +import { connectA2A } from '../src/a2a/client'; + +it.each( + ['discovery', 'RPC'].flatMap((phase) => + ['gzip', 'identity', 'absent', 'empty'].map((coding) => ({ + phase, + coding, + })), + ), +)( + 'checks $coding coding before consuming the held $phase response', + async ({ phase, coding }) => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const closed = Promise.withResolvers(); + const limit = 1024; + let released = false; + let credentials = 0; + const requestedEncodings: (string | undefined)[] = []; + const server = createServer(async (request, response) => { + const discovery = request.url === '/card'; + requestedEncodings.push(request.headers['accept-encoding']); + let id: unknown; + if (!discovery) { + let body = ''; + for await (const chunk of request) body += chunk; + id = JSON.parse(body).id; + } + const payload = Buffer.from( + JSON.stringify( + discovery + ? AgentCard.toJSON(card) + : { + jsonrpc: '2.0', + id, + result: Task.toJSON( + Task.fromJSON({ + id: 'task', + contextId: 'context', + status: { state: 'TASK_STATE_COMPLETED' }, + }), + ), + }, + ), + ); + const selected = discovery === (phase === 'discovery'); + expect(payload.byteLength).toBeLessThan(limit); + response.setHeader('Content-Type', 'application/json'); + if (selected && coding === 'gzip') { + const gzip = gzipSync(payload); + const header = Buffer.from(gzip.subarray(0, 10)); + header[3] |= 8; + // A legal gzip filename exceeds the cap without producing decoded bytes. + const prefix = Buffer.concat([header, Buffer.alloc(limit * 8, 97)]); + expect(prefix.byteLength).toBeGreaterThan(limit); + response.setHeader('Content-Encoding', 'gzip'); + response.on('close', () => closed.resolve(!released)); + response.flushHeaders(); + response.write(prefix); + entered.resolve(); + await release.promise; + if (!response.destroyed) + response.end(Buffer.concat([Buffer.from([0]), gzip.subarray(10)])); + } else { + if (selected && coding === 'empty') + response.setHeader('Content-Encoding', ''); + else if (coding !== 'absent') + response.setHeader('Content-Encoding', 'identity'); + response.end(payload); + if (selected) entered.resolve(); + } + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') + throw new Error('Missing server address'); + const base = `http://127.0.0.1:${address.port}`; + const card = AgentCard.fromJSON({ + name: 'bounded', + version: '1', + supportedInterfaces: [ + { + url: `${base}/rpc`, + protocolVersion: '1.0', + protocolBinding: 'JSONRPC', + }, + ], + }); + const open = () => + connectA2A( + { name: 'bounded', cardUrl: `${base}/card`, rpcUrl: `${base}/rpc` }, + { + authorization: async () => { + credentials++; + return 'synthetic'; + }, + limits: { bodyBytes: limit }, + }, + ); + try { + const operation = + phase === 'discovery' + ? open() + : (await open()).getTask({ tenant: '', id: 'task' }); + const result = operation.then( + (value) => ({ value }), + (error: unknown) => ({ error }), + ); + await entered.promise; + if (coding === 'gzip') { + const early = await Promise.race([ + result.then(() => true), + new Promise((resolve) => + setTimeout(() => resolve(false), 500), + ), + ]); + if (!early) { + released = true; + release.resolve(); + } + const outcome = await result; + expect(early).toBe(true); + expect('error' in outcome && outcome.error).toMatchObject({ + reason: 'unsupported', + }); + expect(await closed.promise).toBe(true); + expect(credentials).toBe(phase === 'discovery' ? 0 : 1); + } else if (coding === 'empty') { + const outcome = await result; + expect('error' in outcome && outcome.error).toMatchObject({ + reason: 'unsupported', + }); + } else { + expect('value' in (await result)).toBe(true); + } + expect(requestedEncodings).toEqual( + Array(phase === 'discovery' ? 1 : 2).fill('identity'), + ); + } finally { + released = true; + release.resolve(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + }, + 10_000, +); From 02aba47b96a0e4c86ac4bae51cfb1c93e5a9fc9d Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:38:22 +0000 Subject: [PATCH 15/34] fix(a2a): preserve serialized structured results --- .changeset/a2a-serialized-results.md | 6 ++ packages/agents-codex/A2A.md | 2 +- packages/agents-codex/src/a2a/client.ts | 3 +- packages/agents-codex/src/a2a/executor.ts | 6 +- packages/agents-codex/test/a2a-native.test.ts | 82 ++++++++++++------- 5 files changed, 66 insertions(+), 33 deletions(-) create mode 100644 .changeset/a2a-serialized-results.md diff --git a/.changeset/a2a-serialized-results.md b/.changeset/a2a-serialized-results.md new file mode 100644 index 000000000..d45e7864e --- /dev/null +++ b/.changeset/a2a-serialized-results.md @@ -0,0 +1,6 @@ +--- +'@openai/agents': patch +'@openai/agents-codex': patch +--- + +fix: preserve the JSON representation of structured A2A results when saving and publishing a task. diff --git a/packages/agents-codex/A2A.md b/packages/agents-codex/A2A.md index a5edc1c98..c142d99f1 100644 --- a/packages/agents-codex/A2A.md +++ b/packages/agents-codex/A2A.md @@ -35,7 +35,7 @@ The returned client is the official A2A `Client`. Direct callers must treat an u The provider requires separate callbacks for current authorization, credential resolution, durable mutation reservation, input encoding, and result validation. Authorization checks the current principal, immutable native invocation binding, and claim generation. After discovery and local validation, the provider resolves credentials, checks authorization again, spends one mutation reservation, and sends once. A timeout or lost create, input-reply, or cancel acknowledgment returns `unknown`. It never resends that mutation. A known receipt binds the endpoint and original native invocation. A fresh worker observes that same remote task with `getTask`; it does not create another task. -`inputRequired` exposes a remote challenge. `A2AInputReply` supplies its task ID, context ID, challenge, generation, and JSON `result`. The provider reads the current remote task before reserving a reply. Python and TypeScript share one wire profile: the status message contains one data part `{challenge, generation, tool, arguments}`; a reply contains exactly `{challenge, generation, result}`. Task and context IDs belong in the A2A message envelope. Structured completion artifacts contain `{result: output}`; string results remain text parts. `authRequired` remains a host credential flow and never becomes a generic approval answer. A host must not accept untrusted remote approval text as authority to perform local actions. +`inputRequired` exposes a remote challenge. `A2AInputReply` supplies its task ID, context ID, challenge, generation, and JSON `result`. The provider reads the current remote task before reserving a reply. Python and TypeScript share one wire profile: the status message contains one data part `{challenge, generation, tool, arguments}`; a reply contains exactly `{challenge, generation, result}`. Task and context IDs belong in the A2A message envelope. The executor publishes string results as text parts. For structured results, it saves and sends `{result: output}` using the JSON representation that passed the byte limit. `authRequired` remains a host credential flow and never becomes a generic approval answer. A host must not accept untrusted remote approval text as authority to perform local actions. ## Serve a native agent diff --git a/packages/agents-codex/src/a2a/client.ts b/packages/agents-codex/src/a2a/client.ts index 81b30b9b1..cc23ca2e6 100644 --- a/packages/agents-codex/src/a2a/client.ts +++ b/packages/agents-codex/src/a2a/client.ts @@ -72,7 +72,7 @@ export function bound(value: number | undefined, fallback: number): number { return result; } -export function checkBytes(value: unknown, limit: number): void { +export function checkBytes(value: unknown, limit: number): string { let encoded: string | undefined; try { encoded = JSON.stringify(value); @@ -81,6 +81,7 @@ export function checkBytes(value: unknown, limit: number): void { } if (encoded === undefined || Buffer.byteLength(encoded) > limit) throw new A2AError('limit'); + return encoded; } async function boundedResponse( diff --git a/packages/agents-codex/src/a2a/executor.ts b/packages/agents-codex/src/a2a/executor.ts index b152175bd..13b9a0b8b 100644 --- a/packages/agents-codex/src/a2a/executor.ts +++ b/packages/agents-codex/src/a2a/executor.ts @@ -397,7 +397,7 @@ export class NativeA2AExecutor< await session.close(); closed = true; await claim.current(); - checkBytes( + const encodedOutput = checkBytes( outcome.finalOutput, bound(this.#options.limits?.bodyBytes, 2 * 1024 * 1024), ); @@ -417,7 +417,9 @@ export class NativeA2AExecutor< ? { $case: 'text', value: outcome.finalOutput } : { $case: 'data', - value: { result: outcome.finalOutput }, + value: { + result: JSON.parse(encodedOutput) as unknown, + }, }, mediaType: typeof outcome.finalOutput === 'string' diff --git a/packages/agents-codex/test/a2a-native.test.ts b/packages/agents-codex/test/a2a-native.test.ts index 07a9cc879..5969dbf13 100644 --- a/packages/agents-codex/test/a2a-native.test.ts +++ b/packages/agents-codex/test/a2a-native.test.ts @@ -367,37 +367,61 @@ describe.skipIf(process.env.OPENAI_AGENTS_NATIVE_TESTS !== '1')( 30000, ); - it('publishes validated structured output with the shared result wrapper', async () => { - await withScriptedResponses([[text('{"value":7}')]], async (model) => { - const store = new FileA2AStore(); - const agent = new Agent({ - instructions: 'Return a value.', - model: 'gpt-5.5', - provider: model.provider, - outputType: z.strictObject({ value: z.number().int() }), - }); - const remote = await host( - store, - new NativeA2AExecutor({ + class FormattedValue { + constructor(readonly localValue: number) {} + + toJSON() { + return { value: this.localValue }; + } + } + + const valueSchema = z.strictObject({ value: z.number().int() }); + it.each([ + { name: 'native object', outputType: valueSchema }, + { + name: 'prompted object with a JSON serializer', + outputType: { + mode: 'prompted' as const, + schema: valueSchema.transform( + ({ value }) => new FormattedValue(value), + ), + }, + }, + ])( + 'publishes the JSON result of a $name over HTTP', + async ({ outputType }) => { + await withScriptedResponses([[text('{"value":7}')]], async (model) => { + const store = new FileA2AStore(); + const agent = new Agent({ + instructions: 'Return a value.', + model: 'gpt-5.5', + provider: model.provider, + outputType, + }); + const remote = await host( store, - agentFactory: async () => ({ agent, context: undefined }), - }), - ); - try { - const created = await remote.client.sendMessage( - input([Part.fromJSON({ text: 'begin' })]), + new NativeA2AExecutor({ + store, + agentFactory: async () => ({ agent, context: undefined }), + }), ); - if (!('id' in created)) throw new Error(); - const completed = await settled(remote.client, created); - expect(completed.artifacts[0].parts[0].content).toEqual({ - $case: 'data', - value: { result: { value: 7 } }, - }); - } finally { - await remote.close(); - } - }); - }, 30_000); + try { + const created = await remote.client.sendMessage( + input([Part.fromJSON({ text: 'begin' })]), + ); + if (!('id' in created)) throw new Error(); + const completed = await settled(remote.client, created); + expect(completed.artifacts[0].parts[0].content).toEqual({ + $case: 'data', + value: { result: { value: 7 } }, + }); + } finally { + await remote.close(); + } + }); + }, + 30_000, + ); it('exports each remote native dispatch beneath the original coordinator tool span across HTTP', async () => { type Span = { From 291d975c23afbbe27c4a26fedc18d0174f9a4ca9 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:42:05 +0000 Subject: [PATCH 16/34] Bound authentication recovery across transport fallback --- .changeset/quiet-terminal-authentication.md | 6 + .../0058-terminal-authentication-retry.patch | 774 ++++++++++++++++++ .../agents-codex/native/patches/series.json | 6 +- .../test/native/parity-error-metadata.test.ts | 293 ++++++- 4 files changed, 1043 insertions(+), 36 deletions(-) create mode 100644 .changeset/quiet-terminal-authentication.md create mode 100644 packages/agents-codex/native/patches/0058-terminal-authentication-retry.patch diff --git a/.changeset/quiet-terminal-authentication.md b/.changeset/quiet-terminal-authentication.md new file mode 100644 index 000000000..e920b2f1b --- /dev/null +++ b/.changeset/quiet-terminal-authentication.md @@ -0,0 +1,6 @@ +--- +'@openai/agents': patch +'@openai/agents-codex': patch +--- + +fix: stop sampling after final HTTP 401 or 403 responses and share bounded credential recovery across WebSocket-to-HTTP fallback while preserving final provider metadata. diff --git a/packages/agents-codex/native/patches/0058-terminal-authentication-retry.patch b/packages/agents-codex/native/patches/0058-terminal-authentication-retry.patch new file mode 100644 index 000000000..c6cb1a497 --- /dev/null +++ b/packages/agents-codex/native/patches/0058-terminal-authentication-retry.patch @@ -0,0 +1,774 @@ +diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs +index 55f091582aa2ebfe4bee24de2f65285c3bc9f625..3d02d17fa16a24eee88d36407542b3ae97ab4f84 100644 +--- a/codex-rs/core/src/client.rs ++++ b/codex-rs/core/src/client.rs +@@ -151,6 +151,7 @@ use codex_model_provider_info::DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS; + use codex_model_provider_info::ModelProviderInfo; + use codex_model_provider_info::WireApi; + use codex_protocol::error::CodexErr; ++use codex_protocol::error::CodexErrorDetails; + use codex_protocol::error::Result; + use codex_response_debug_context::extract_response_debug_context; + use codex_response_debug_context::extract_response_debug_context_from_api_error; +@@ -1529,18 +1530,28 @@ impl ModelClientSession { + Err(ApiError::Transport(unauthorized_transport)) + if provider.is_recoverable_auth_error(&unauthorized_transport) => + { +- pending_retry = PendingUnauthorizedRetry::from_recovery( +- handle_unauthorized( +- unauthorized_transport, +- &mut auth_recovery, +- &mut provider_auth_recovery_attempted, +- session_telemetry, +- &provider, +- self.client.event_sender.as_ref(), +- responses_metadata.turn_id.as_deref(), +- ) +- .await?, +- ); ++ match handle_unauthorized( ++ unauthorized_transport, ++ &mut auth_recovery, ++ &mut provider_auth_recovery_attempted, ++ session_telemetry, ++ &provider, ++ self.client.event_sender.as_ref(), ++ responses_metadata.turn_id.as_deref(), ++ ) ++ .await ++ { ++ Ok(recovery) => { ++ pending_retry = PendingUnauthorizedRetry::from_recovery(recovery); ++ } ++ // No prompt was sent: negotiate HTTP after auth recovery is exhausted. ++ Err(error) if matches!(error.details(), CodexErrorDetails::UnexpectedStatus(response) if response.status == StatusCode::UNAUTHORIZED) => ++ { ++ self.try_switch_fallback_transport(session_telemetry, model_info); ++ return Ok(()); ++ } ++ Err(error) => return Err(error), ++ } + } + Err(err) => return Err(provider.map_api_error(err)), + } +@@ -1669,13 +1680,8 @@ impl ModelClientSession { + service_tier: Option, + responses_metadata: &CodexResponsesMetadata, + inference_trace: &InferenceTraceContext, ++ auth_recovery: &mut RequestAuthRecovery, + ) -> Result { +- let auth_manager = self.client.state.provider.auth_manager(); +- let mut auth_recovery = auth_manager +- .as_ref() +- .map(AuthManager::unauthorized_recovery); +- let mut provider_auth_recovery_attempted = false; +- let mut pending_retry = PendingUnauthorizedRetry::default(); + loop { + let client_setup = self + .client +@@ -1699,7 +1705,7 @@ impl ModelClientSession { + client_setup.auth.as_ref().map(CodexAuth::auth_mode), + client_setup.api_auth.as_ref(), + client_setup.agent_identity_telemetry.clone(), +- pending_retry, ++ auth_recovery.pending_retry, + ); + let (request_telemetry, sse_telemetry) = Self::build_streaming_telemetry( + session_telemetry, +@@ -1808,11 +1814,11 @@ impl ModelClientSession { + response_debug_context.request_id.as_deref(), + /*output_items*/ &[], + ); +- pending_retry = PendingUnauthorizedRetry::from_recovery( ++ auth_recovery.pending_retry = PendingUnauthorizedRetry::from_recovery( + handle_unauthorized( + unauthorized_transport, +- &mut auth_recovery, +- &mut provider_auth_recovery_attempted, ++ &mut auth_recovery.manager, ++ &mut auth_recovery.provider_attempted, + session_telemetry, + &self.client.state.provider, + self.client.event_sender.as_ref(), +@@ -1864,16 +1870,10 @@ impl ModelClientSession { + warmup: bool, + request_trace: Option, + inference_trace: &InferenceTraceContext, ++ auth_recovery: &mut RequestAuthRecovery, + ) -> Result { + let after_prewarm = !warmup && self.websocket_session.last_response_from_untraced_warmup; + let provider = Arc::clone(&self.client.state.provider); +- let auth_manager = provider.auth_manager(); +- +- let mut auth_recovery = auth_manager +- .as_ref() +- .map(AuthManager::unauthorized_recovery); +- let mut provider_auth_recovery_attempted = false; +- let mut pending_retry = PendingUnauthorizedRetry::default(); + loop { + let client_setup = self + .client +@@ -1892,7 +1892,7 @@ impl ModelClientSession { + client_setup.auth.as_ref().map(CodexAuth::auth_mode), + client_setup.api_auth.as_ref(), + client_setup.agent_identity_telemetry.clone(), +- pending_retry, ++ auth_recovery.pending_retry, + ); + let mut request = self.client.build_responses_request( + prompt, +@@ -1947,18 +1947,28 @@ impl ModelClientSession { + Err(ApiError::Transport(unauthorized_transport)) + if provider.is_recoverable_auth_error(&unauthorized_transport) => + { +- pending_retry = PendingUnauthorizedRetry::from_recovery( +- handle_unauthorized( +- unauthorized_transport, +- &mut auth_recovery, +- &mut provider_auth_recovery_attempted, +- session_telemetry, +- &provider, +- self.client.event_sender.as_ref(), +- responses_metadata.turn_id.as_deref(), +- ) +- .await?, +- ); ++ match handle_unauthorized( ++ unauthorized_transport, ++ &mut auth_recovery.manager, ++ &mut auth_recovery.provider_attempted, ++ session_telemetry, ++ &provider, ++ self.client.event_sender.as_ref(), ++ responses_metadata.turn_id.as_deref(), ++ ) ++ .await ++ { ++ Ok(recovery) => { ++ auth_recovery.pending_retry = ++ PendingUnauthorizedRetry::from_recovery(recovery); ++ } ++ // No prompt was sent: negotiate HTTP after auth recovery is exhausted. ++ Err(error) if matches!(error.details(), CodexErrorDetails::UnexpectedStatus(response) if response.status == StatusCode::UNAUTHORIZED) => ++ { ++ return Ok(WebsocketStreamOutcome::FallbackToHttp); ++ } ++ Err(error) => return Err(error), ++ } + continue; + } + Err(err) => return Err(provider.map_api_error(err)), +@@ -2195,6 +2205,7 @@ impl ModelClientSession { + } + + let disabled_trace = InferenceTraceContext::disabled(); ++ let mut auth_recovery = RequestAuthRecovery::new(&self.client.state.provider); + match self + .stream_responses_websocket( + prompt, +@@ -2207,6 +2218,7 @@ impl ModelClientSession { + /*warmup*/ true, + current_span_w3c_trace_context(), + &disabled_trace, ++ &mut auth_recovery, + ) + .await + { +@@ -2252,6 +2264,7 @@ impl ModelClientSession { + let wire_api = self.client.state.provider.info().wire_api; + match wire_api { + WireApi::Responses => { ++ let mut auth_recovery = RequestAuthRecovery::new(&self.client.state.provider); + if self.client.responses_websocket_enabled() { + let request_trace = current_span_w3c_trace_context(); + match self +@@ -2266,6 +2279,7 @@ impl ModelClientSession { + /*warmup*/ false, + request_trace, + inference_trace, ++ &mut auth_recovery, + ) + .await? + { +@@ -2285,6 +2299,7 @@ impl ModelClientSession { + service_tier, + responses_metadata, + inference_trace, ++ &mut auth_recovery, + ) + .await + } +@@ -2567,6 +2582,27 @@ impl PendingUnauthorizedRetry { + } + } + ++/// Recovery belongs to one model request, including its transport negotiation. ++/// Preconnect, warmup and later requests create independent recovery budgets. ++struct RequestAuthRecovery { ++ manager: Option, ++ provider_attempted: bool, ++ pending_retry: PendingUnauthorizedRetry, ++} ++ ++impl RequestAuthRecovery { ++ fn new(provider: &SharedModelProvider) -> Self { ++ Self { ++ manager: provider ++ .auth_manager() ++ .as_ref() ++ .map(AuthManager::unauthorized_recovery), ++ provider_attempted: false, ++ pending_retry: PendingUnauthorizedRetry::default(), ++ } ++ } ++} ++ + #[derive(Clone, Debug, Default)] + struct AuthRequestTelemetryContext { + auth_mode: Option<&'static str>, +diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs +index 779a40ca2be8f8687ca067f6e922d53be852c51c..e1587ed3fa414f9b58f275a8778ad264e4034dcc 100644 +--- a/codex-rs/core/src/client_tests.rs ++++ b/codex-rs/core/src/client_tests.rs +@@ -1674,6 +1674,8 @@ struct TestRecoveryProvider { + inner: SharedModelProvider, + should_fail: bool, + attempts: Arc, ++ recovery_gate: Option<(Arc, Arc)>, ++ rotate_keys: bool, + } + + impl ModelProvider for TestRecoveryProvider { +@@ -1686,6 +1688,13 @@ impl ModelProvider for TestRecoveryProvider { + } + + fn auth(&self) -> ModelProviderFuture<'_, Option> { ++ if self.rotate_keys { ++ let key = format!( ++ "synthetic-rotation-{}", ++ self.attempts.load(Ordering::Relaxed) ++ ); ++ return Box::pin(async move { Some(CodexAuth::from_api_key(&key)) }); ++ } + self.inner.auth() + } + +@@ -1705,6 +1714,10 @@ impl ModelProvider for TestRecoveryProvider { + ) -> ModelProviderFuture<'_, codex_protocol::error::Result> { + self.attempts.fetch_add(1, Ordering::Relaxed); + Box::pin(async move { ++ if let Some((started, release)) = &self.recovery_gate { ++ started.notify_one(); ++ release.notified().await; ++ } + if self.should_fail { + Err(CodexErr::Io(std::io::Error::other( + "provider recovery failed", +@@ -1732,6 +1745,8 @@ async fn provider_owned_auth_recovery_is_bounded_and_preserves_unauthorized_fail + inner: test_model_provider(), + should_fail, + attempts: Arc::clone(&attempts), ++ recovery_gate: None, ++ rotate_keys: false, + }); + assert!(provider.auth_manager().is_none()); + +@@ -2208,3 +2223,374 @@ async fn inference_tools_changes_follow_full_specs_across_turns() { + ); + } + } ++ ++/// Rejected handshakes negotiate HTTP without spending the sampling retry budget. ++/// Exercise both startup preconnect and ordinary generation connection owners. ++#[tokio::test] ++async fn rejected_websocket_auth_falls_back_and_preserves_final_http_failure() -> anyhow::Result<()> ++{ ++ for preconnect in [false, true] { ++ for final_status in [401, 403] { ++ let server = MockServer::start().await; ++ Mock::given(method("GET")) ++ .and(path("/v1/responses")) ++ .respond_with(ResponseTemplate::new(401)) ++ .expect(1) ++ .mount(&server) ++ .await; ++ let final_body = r#"{"error":{"message":"final HTTP rejection","code":"final_http_rejection","type":"invalid_request_error"}}"#; ++ Mock::given(method("POST")) ++ .and(path("/v1/responses")) ++ .respond_with( ++ ResponseTemplate::new(final_status) ++ .insert_header("content-type", "application/json") ++ .insert_header("x-request-id", "final-http-request") ++ .set_body_string(final_body), ++ ) ++ .expect(1) ++ .mount(&server) ++ .await; ++ let mut provider = ++ ModelProviderInfo::create_openai_provider(Some(format!("{}/v1", server.uri()))); ++ provider.requires_openai_auth = false; ++ provider.supports_websockets = true; ++ provider.request_max_retries = Some(0); ++ let mut client = test_model_client(SessionSource::Cli); ++ Arc::get_mut(&mut client.state) ++ .expect("test client should own unique state") ++ .provider = create_model_provider(provider, /*auth_manager*/ None); ++ let metadata = test_responses_metadata_for_client( ++ &client, ++ None, ++ format!("{}:0", client.state.thread_id), ++ None, ++ TestCodexResponsesRequestKind::Turn, ++ ); ++ let mut session = client.new_session(); ++ if preconnect { ++ session ++ .preconnect_websocket( ++ &test_model_info(), ++ None, ++ &test_session_telemetry(), ++ &metadata, ++ ) ++ .await?; ++ assert!(!client.responses_websocket_enabled()); ++ } ++ let error = session ++ .stream( ++ &Prompt::default(), ++ &test_model_info(), ++ &test_session_telemetry(), ++ None, ++ codex_protocol::config_types::ReasoningSummary::None, ++ None, ++ &metadata, ++ &InferenceTraceContext::disabled(), ++ ) ++ .await ++ .err() ++ .expect("the final HTTP request must fail"); ++ let codex_protocol::error::CodexErrorDetails::UnexpectedStatus(details) = ++ error.details() ++ else { ++ panic!("final HTTP failure must remain structured"); ++ }; ++ assert_eq!(details.status.as_u16(), final_status); ++ assert_eq!(details.body, final_body); ++ assert_eq!(details.request_id.as_deref(), Some("final-http-request")); ++ assert!(!client.responses_websocket_enabled()); ++ assert_eq!(server.received_requests().await.unwrap().len(), 2); ++ } ++ } ++ Ok(()) ++} ++ ++/// Cancellation drops the actual provider-recovery future before it can dispatch ++/// another authenticated request or switch a rejected WebSocket handshake to HTTP. ++#[tokio::test] ++async fn cancellation_during_auth_recovery_stops_provider_dispatch() -> anyhow::Result<()> { ++ use codex_async_utils::OrCancelExt; ++ use tokio_util::sync::CancellationToken; ++ ++ for websockets in [false, true] { ++ let server = MockServer::start().await; ++ Mock::given(method(if websockets { "GET" } else { "POST" })) ++ .and(path("/v1/responses")) ++ .respond_with(ResponseTemplate::new(401)) ++ .expect(1) ++ .mount(&server) ++ .await; ++ let started = Arc::new(Notify::new()); ++ let release = Arc::new(Notify::new()); ++ let attempts = Arc::new(AtomicUsize::new(0)); ++ let mut info = ++ ModelProviderInfo::create_openai_provider(Some(format!("{}/v1", server.uri()))); ++ info.requires_openai_auth = false; ++ info.supports_websockets = websockets; ++ info.request_max_retries = Some(0); ++ let provider: SharedModelProvider = Arc::new(TestRecoveryProvider { ++ inner: create_model_provider(info, /*auth_manager*/ None), ++ should_fail: false, ++ attempts: Arc::clone(&attempts), ++ recovery_gate: Some((Arc::clone(&started), Arc::clone(&release))), ++ rotate_keys: false, ++ }); ++ let mut client = test_model_client(SessionSource::Cli); ++ Arc::get_mut(&mut client.state) ++ .expect("unique test client state") ++ .provider = provider; ++ let metadata = test_responses_metadata_for_client( ++ &client, ++ None, ++ format!("{}:0", client.state.thread_id), ++ None, ++ TestCodexResponsesRequestKind::Turn, ++ ); ++ let mut session = client.new_session(); ++ let prompt = Prompt::default(); ++ let model_info = test_model_info(); ++ let telemetry = test_session_telemetry(); ++ let trace = InferenceTraceContext::disabled(); ++ let cancellation = CancellationToken::new(); ++ let outcome = { ++ let pending = session ++ .stream( ++ &prompt, ++ &model_info, ++ &telemetry, ++ None, ++ codex_protocol::config_types::ReasoningSummary::None, ++ None, ++ &metadata, ++ &trace, ++ ) ++ .or_cancel(&cancellation); ++ tokio::pin!(pending); ++ tokio::select! { ++ _ = started.notified() => {} ++ _ = &mut pending => panic!("recovery must remain pending at its gate"), ++ } ++ assert_eq!(attempts.load(Ordering::Relaxed), 1); ++ cancellation.cancel(); ++ pending.await ++ }; ++ assert!(outcome.is_err(), "sampling cancellation must drop recovery"); ++ release.notify_one(); ++ tokio::task::yield_now().await; ++ assert_eq!(attempts.load(Ordering::Relaxed), 1); ++ assert_eq!(server.received_requests().await.unwrap().len(), 1); ++ assert_eq!(client.responses_websocket_enabled(), websockets); ++ } ++ Ok(()) ++} ++ ++fn auth_recovery_model_client( ++ base_url: String, ++ websockets: bool, ++ rotate_keys: bool, ++) -> (ModelClient, Arc) { ++ let mut info = ModelProviderInfo::create_openai_provider(Some(base_url)); ++ info.requires_openai_auth = rotate_keys; ++ info.supports_websockets = websockets; ++ info.request_max_retries = Some(0); ++ let attempts = Arc::new(AtomicUsize::new(0)); ++ let provider: SharedModelProvider = Arc::new(TestRecoveryProvider { ++ inner: create_model_provider(info, /*auth_manager*/ None), ++ should_fail: false, ++ attempts: Arc::clone(&attempts), ++ recovery_gate: None, ++ rotate_keys, ++ }); ++ let mut client = test_model_client(SessionSource::Cli); ++ Arc::get_mut(&mut client.state).unwrap().provider = provider; ++ (client, attempts) ++} ++ ++async fn auth_recovery_stream( ++ session: &mut super::ModelClientSession, ++) -> codex_protocol::error::Result { ++ let metadata = test_responses_metadata_for_client( ++ &session.client, ++ None, ++ format!("{}:0", session.client.state.thread_id), ++ None, ++ TestCodexResponsesRequestKind::Turn, ++ ); ++ session ++ .stream( ++ &Prompt::default(), ++ &test_model_info(), ++ &test_session_telemetry(), ++ None, ++ codex_protocol::config_types::ReasoningSummary::None, ++ None, ++ &metadata, ++ &InferenceTraceContext::disabled(), ++ ) ++ .await ++} ++ ++/// Only the request that attempted the rejected upgrade owns its spent recovery. ++#[tokio::test] ++async fn authentication_recovery_spans_fallback_but_not_preconnect_or_later_requests() ++-> anyhow::Result<()> { ++ for preconnect in [false, true] { ++ for status in [401, 403] { ++ let server = MockServer::start().await; ++ Mock::given(method("GET")) ++ .respond_with(ResponseTemplate::new(401)) ++ .expect(2) ++ .mount(&server) ++ .await; ++ let http_attempts = if preconnect && status == 401 { 2 } else { 1 }; ++ Mock::given(method("POST")) ++ .respond_with( ++ ResponseTemplate::new(status) ++ .insert_header("x-request-id", "final-http-request") ++ .set_body_string("final HTTP rejection"), ++ ) ++ .expect(http_attempts as u64) ++ .mount(&server) ++ .await; ++ let (client, attempts) = ++ auth_recovery_model_client(format!("{}/v1", server.uri()), true, true); ++ let metadata = test_responses_metadata_for_client( ++ &client, ++ None, ++ format!("{}:0", client.state.thread_id), ++ None, ++ TestCodexResponsesRequestKind::Turn, ++ ); ++ let mut session = client.new_session(); ++ if preconnect { ++ session ++ .preconnect_websocket( ++ &test_model_info(), ++ None, ++ &test_session_telemetry(), ++ &metadata, ++ ) ++ .await?; ++ } ++ let error = auth_recovery_stream(&mut session).await.err().unwrap(); ++ let CodexErrorDetails::UnexpectedStatus(details) = error.details() else { ++ panic!("the final HTTP failure must remain structured"); ++ }; ++ assert_eq!(details.status.as_u16(), status); ++ assert_eq!(details.request_id.as_deref(), Some("final-http-request")); ++ assert!(!error.is_retryable()); ++ assert_eq!( ++ attempts.load(Ordering::Relaxed), ++ if preconnect && status == 401 { 2 } else { 1 } ++ ); ++ assert_eq!( ++ server.received_requests().await.unwrap().len(), ++ 2 + http_attempts ++ ); ++ } ++ } ++ ++ let server = MockServer::start().await; ++ Mock::given(method("GET")) ++ .respond_with(ResponseTemplate::new(401)) ++ .expect(2) ++ .mount(&server) ++ .await; ++ Mock::given(method("POST")) ++ .respond_with(ResponseTemplate::new(401)) ++ .expect(3) ++ .mount(&server) ++ .await; ++ let (client, attempts) = auth_recovery_model_client(format!("{}/v1", server.uri()), true, true); ++ let mut session = client.new_session(); ++ assert!(auth_recovery_stream(&mut session).await.is_err()); ++ assert_eq!(attempts.load(Ordering::Relaxed), 1); ++ assert_eq!(server.received_requests().await.unwrap().len(), 3); ++ assert!(auth_recovery_stream(&mut session).await.is_err()); ++ assert_eq!(attempts.load(Ordering::Relaxed), 2); ++ assert_eq!(server.received_requests().await.unwrap().len(), 5); ++ Ok(()) ++} ++ ++/// A fresh bearer can succeed after ordinary HTTP recovery. ++#[tokio::test] ++async fn authentication_recovery_rotates_http_credentials() -> anyhow::Result<()> { ++ let server = MockServer::start().await; ++ Mock::given(method("POST")) ++ .and(wiremock::matchers::header( ++ "authorization", ++ "Bearer synthetic-rotation-0", ++ )) ++ .respond_with(ResponseTemplate::new(401)) ++ .expect(1) ++ .mount(&server) ++ .await; ++ Mock::given(method("POST")) ++ .and(wiremock::matchers::header("authorization", "Bearer synthetic-rotation-1")) ++ .respond_with(ResponseTemplate::new(200) ++ .insert_header("content-type", "text/event-stream") ++ .set_body_string(concat!( ++ "data: {\"type\":\"response.created\",\"response\":{\"id\":\"recovered\"}}\n\n", ++ "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"recovered\"}}\n\n", ++ ))) ++ .expect(1).mount(&server).await; ++ let (client, attempts) = ++ auth_recovery_model_client(format!("{}/v1", server.uri()), false, true); ++ let mut session = client.new_session(); ++ let mut stream = auth_recovery_stream(&mut session).await?; ++ let mut completed = false; ++ while let Some(event) = stream.next().await { ++ completed |= matches!(event?, ResponseEvent::Completed { .. }); ++ } ++ assert!(completed); ++ assert_eq!(attempts.load(Ordering::Relaxed), 1); ++ assert_eq!(server.received_requests().await.unwrap().len(), 2); ++ Ok(()) ++} ++ ++/// Rotated WebSocket credentials remain usable for one HTTP fallback prompt. ++#[tokio::test] ++async fn authentication_recovery_rotates_websocket_credentials() -> anyhow::Result<()> { ++ let server = MockServer::start().await; ++ Mock::given(method("GET")) ++ .and(wiremock::matchers::header( ++ "authorization", ++ "Bearer synthetic-rotation-0", ++ )) ++ .respond_with(ResponseTemplate::new(401)) ++ .expect(1) ++ .mount(&server) ++ .await; ++ Mock::given(method("GET")) ++ .and(wiremock::matchers::header( ++ "authorization", ++ "Bearer synthetic-rotation-1", ++ )) ++ .respond_with(ResponseTemplate::new(426)) ++ .expect(1) ++ .mount(&server) ++ .await; ++ Mock::given(method("POST")) ++ .and(wiremock::matchers::header("authorization", "Bearer synthetic-rotation-1")) ++ .respond_with(ResponseTemplate::new(200) ++ .insert_header("content-type", "text/event-stream") ++ .set_body_string(concat!( ++ "data: {\"type\":\"response.created\",\"response\":{\"id\":\"recovered\"}}\n\n", ++ "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"recovered\"}}\n\n", ++ ))) ++ .expect(1).mount(&server).await; ++ let (client, attempts) = auth_recovery_model_client(format!("{}/v1", server.uri()), true, true); ++ let mut session = client.new_session(); ++ let mut stream = auth_recovery_stream(&mut session).await?; ++ let mut completed = false; ++ while let Some(event) = stream.next().await { ++ completed |= matches!(event?, ResponseEvent::Completed { .. }); ++ } ++ assert!(completed); ++ assert_eq!(attempts.load(Ordering::Relaxed), 1); ++ assert!(!client.responses_websocket_enabled()); ++ assert_eq!(server.received_requests().await.unwrap().len(), 3); ++ Ok(()) ++} +diff --git a/codex-rs/core/src/responses_retry_tests.rs b/codex-rs/core/src/responses_retry_tests.rs +index 65aa29397c5f5524116b0b9f932ab3719e1ed488..7046d163858432e7e7baa4b69da4edb72676b383 100644 +--- a/codex-rs/core/src/responses_retry_tests.rs ++++ b/codex-rs/core/src/responses_retry_tests.rs +@@ -98,3 +98,46 @@ async fn stream_retry_preserves_deadline_across_delayed_notification() { + "retry resumed at {resumed_at:?}, expected {advice:?}" + ); + } ++ ++/// Once client authentication recovery has failed, the sampling owner returns ++/// the structured failure immediately instead of beginning another request attempt. ++#[tokio::test] ++async fn terminal_unauthorized_does_not_restart_sampling_or_discard_metadata() { ++ let (session, turn_context) = make_session_and_context().await; ++ let step_context = StepContext::for_test(Arc::new(turn_context)); ++ let mut client_session = session.services.model_client.new_session(); ++ let mut retry_state = ResponsesStreamRetryState::default(); ++ let original = CodexErr::UnexpectedStatus(codex_protocol::error::UnexpectedResponseError { ++ status: http::StatusCode::UNAUTHORIZED, ++ body: r#"{"error":{"code":"invalid_api_key","message":"synthetic rejection"}}"#.to_owned(), ++ user_message: Some("synthetic rejection".to_owned()), ++ url: None, ++ cf_ray: None, ++ request_id: Some("synthetic-auth-request".to_owned()), ++ identity_authorization_error: None, ++ identity_error_code: None, ++ }) ++ .with_retry_after(RetryAfter::from_delay(Duration::ZERO).expect("retry deadline")); ++ let returned = handle_response_stream_error( ++ &mut retry_state, ++ /*max_retries*/ 5, ++ original, ++ &mut client_session, ++ &session, ++ &step_context, ++ ResponsesStreamRequest::Sampling, ++ ) ++ .await ++ .expect_err("terminal authentication must not retry sampling"); ++ assert_eq!(retry_state.retries, 0); ++ let codex_protocol::error::CodexErrorDetails::UnexpectedStatus(details) = returned.details() ++ else { ++ panic!("original authentication details must remain available"); ++ }; ++ assert_eq!(details.status, http::StatusCode::UNAUTHORIZED); ++ assert_eq!( ++ details.request_id.as_deref(), ++ Some("synthetic-auth-request") ++ ); ++ assert!(details.body.contains("invalid_api_key")); ++} +diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs +index d9dbb4373d26de9ecb7d63cc926ffd00a9b8647e..5853e08a6054e00543350695a12b32ee8964f5b2 100644 +--- a/codex-rs/protocol/src/error.rs ++++ b/codex-rs/protocol/src/error.rs +@@ -442,6 +442,16 @@ impl CodexErr { + CodexErrorDetails::ServerOverloaded | CodexErrorDetails::RetryLimit(_) => { + self.retry_after.is_some() + } ++ // The client owns its single authentication-recovery opportunity. ++ // A terminal rejection must not restart sampling with the same key. ++ CodexErrorDetails::UnexpectedStatus(error) ++ if matches!( ++ error.status, ++ http::StatusCode::UNAUTHORIZED | http::StatusCode::FORBIDDEN ++ ) => ++ { ++ false ++ } + CodexErrorDetails::Stream(..) + | CodexErrorDetails::ContentFilter + | CodexErrorDetails::RateLimitExceeded(_) +diff --git a/codex-rs/protocol/src/error_tests.rs b/codex-rs/protocol/src/error_tests.rs +index d5386aec6bbdac835e8cafda0a65fed789ae4e70..fbde183b251e435071d54c5c5ffe3f19da817e02 100644 +--- a/codex-rs/protocol/src/error_tests.rs ++++ b/codex-rs/protocol/src/error_tests.rs +@@ -890,3 +890,47 @@ fn response_integrity_reason_has_only_the_four_safe_wire_values() { + .is_err() + ); + } ++ ++/// Terminal authentication keeps its details and cannot gain eligibility from advice. ++#[test] ++fn terminal_unauthorized_preserves_details_without_retry() { ++ for (status, expected_retry) in [ ++ (StatusCode::UNAUTHORIZED, false), ++ (StatusCode::FORBIDDEN, false), ++ (StatusCode::TOO_MANY_REQUESTS, true), ++ (StatusCode::INTERNAL_SERVER_ERROR, true), ++ (StatusCode::SERVICE_UNAVAILABLE, true), ++ ] { ++ for advised in [false, true] { ++ let mut error = CodexErr::UnexpectedStatus(UnexpectedResponseError { ++ status, ++ body: r#"{"error":{"code":"invalid_api_key","message":"synthetic rejection"}}"# ++ .to_owned(), ++ user_message: Some("synthetic rejection".to_owned()), ++ url: None, ++ cf_ray: None, ++ request_id: Some("synthetic-auth-request".to_owned()), ++ identity_authorization_error: None, ++ identity_error_code: None, ++ }); ++ if advised { ++ error = error.with_retry_after( ++ RetryAfter::from_delay(Duration::ZERO).expect("retry deadline"), ++ ); ++ } ++ for attempt in [1, 5] { ++ assert_eq!(error.retry_delay(attempt).is_some(), expected_retry); ++ } ++ assert_eq!(error.is_retryable(), expected_retry); ++ let CodexErrorDetails::UnexpectedStatus(details) = error.details() else { ++ panic!("authentication error details must remain structured"); ++ }; ++ assert_eq!(details.status, status); ++ assert_eq!( ++ details.request_id.as_deref(), ++ Some("synthetic-auth-request") ++ ); ++ assert!(details.body.contains("invalid_api_key")); ++ } ++ } ++} diff --git a/packages/agents-codex/native/patches/series.json b/packages/agents-codex/native/patches/series.json index fa451435b..3e849b72a 100644 --- a/packages/agents-codex/native/patches/series.json +++ b/packages/agents-codex/native/patches/series.json @@ -235,7 +235,11 @@ { "path": "0054-native-response-integrity-reasons.patch", "sha256": "006be27601ea216c750b5df1c0f9b0f9f4259b9206e1f33869400cf4ff61ea7a" + }, + { + "path": "0058-terminal-authentication-retry.patch", + "sha256": "2064b337e86352296df7ac5bde428a11ad145ca568478f977dcfb2ef3a87cff8" } ], - "result_tree": "1a6546142e4a9a1af351c0a4b987288110b0c7c1" + "result_tree": "b15cd10e00fef8e10856dbe0aad012607a60ac9a" } diff --git a/packages/agents-codex/test/native/parity-error-metadata.test.ts b/packages/agents-codex/test/native/parity-error-metadata.test.ts index fe56b1a82..5a48b4274 100644 --- a/packages/agents-codex/test/native/parity-error-metadata.test.ts +++ b/packages/agents-codex/test/native/parity-error-metadata.test.ts @@ -39,21 +39,25 @@ async function providerPeer( failureFor: (request: JsonObject) => Failure | null | Promise, rejectWebsocketUpgrade = false, retries?: { request: number; stream: number }, + websocketStatus: 401 | 426 = 401, ) { const config = await model.config(); const upstream = (config['model_providers.fixture'] as { base_url: string }) .base_url; const failures: unknown[] = []; let rejectedRequests = 0; + const rejectedBodies: JsonObject[] = []; let rejectedUpgrades = 0; const server = createServer(async (request, response) => { try { const chunks: Buffer[] = []; for await (const chunk of request) chunks.push(Buffer.from(chunk)); const body = Buffer.concat(chunks); - const failure = await failureFor(JSON.parse(body.toString())); + const parsedBody = JSON.parse(body.toString()); + const failure = await failureFor(parsedBody); if (failure) { rejectedRequests++; + rejectedBodies.push(parsedBody); response.writeHead(failure.status, { 'content-type': 'application/json', ...(failure.retryAfter === null @@ -92,7 +96,7 @@ async function providerPeer( server.on('upgrade', (_request, socket) => { rejectedUpgrades++; socket.end( - 'HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n', + `HTTP/1.1 ${websocketStatus} Rejected\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`, ); }); } @@ -124,6 +128,9 @@ async function providerPeer( get rejectedRequests() { return rejectedRequests; }, + get rejectedBodies() { + return rejectedBodies; + }, get rejectedUpgrades() { return rejectedUpgrades; }, @@ -370,6 +377,202 @@ describe('native retry guidance', () => { }); describe('Python-aligned native provider error metadata', () => { + it.each([401, 403])( + 'cancels after a tool effect without a late authentication retry (%s)', + async (status) => { + const started = Promise.withResolvers(); + const release = Promise.withResolvers(); + let effects = 0; + let rejectAfterEffect = true; + const model = new ModelServer([ + [call('write_once', {}, { callId: 'write-once-call' })], + [message('fresh turn')], + ]); + const peer = await providerPeer(model, async () => { + if (!effects || !rejectAfterEffect) return null; + started.resolve(); + await release.promise; + return { ...authentication, status }; + }); + try { + await using session = await new Agent({ + instructions: 'Use write_once then answer.', + model: 'gpt-5', + provider: peer.provider, + tools: [ + tool({ + name: 'write_once', + parameters: z.object({}), + execute: () => { + effects++; + return 'receipt-1'; + }, + }), + ], + }).session(); + const turn = await session.submit( + 'Record one effect then hold the auth rejection.', + ); + const outcome = turn.wait().catch((error: unknown) => error); + try { + await started.promise; + expect(effects).toBe(1); + await turn.cancel(); + expect(await outcome).toBeInstanceOf(NativeError); + } finally { + release.resolve(); + } + rejectAfterEffect = false; + expect( + (await (await session.submit('Answer after cancellation.')).wait()) + .finalOutput, + ).toBe('fresh turn'); + expect(effects).toBe(1); + expect(peer.rejectedRequests).toBe(1); + model.assertExhausted(); + } finally { + release.resolve(); + await peer.close(); + } + }, + 30_000, + ); + + it.each([ + { status: 401, attempts: 2 }, + { status: 403, attempts: 1 }, + ])( + 'ends terminal HTTP authentication after normal recovery ($status)', + async ({ status, attempts }) => { + const model = new ModelServer([]); + const peer = await providerPeer(model, () => ({ + ...authentication, + status, + })); + try { + await using session = await new Agent({ + instructions: 'Answer.', + model: 'gpt-5', + provider: peer.provider, + }).session(); + const turn = await session.submit('Reject the captured key.'); + const error: unknown = await turn + .wait() + .catch((error: unknown) => error); + expectMetadata(error, { + statusCode: status, + code: 'invalid_api_key', + requestId: authentication.requestId, + category: 'authentication_error', + }); + expect(error).toMatchObject({ retryable: false, retryAfterMs: 0 }); + expect(peer.rejectedRequests).toBe(attempts); + await expect(turn.wait()).rejects.toBe(error); + await expect(turn.waitForAttention()).rejects.toBe(error); + model.assertExhausted(); + } finally { + await peer.close(); + } + }, + ); + + it.each([ + { status: 401, attempts: 2 }, + { status: 403, attempts: 1 }, + ])( + 'retains one tool effect when later HTTP authentication fails ($status)', + async ({ status, attempts }) => { + const model = new ModelServer([ + [call('write_once', {}, { callId: 'write-once-call' })], + ]); + let effects = 0; + const peer = await providerPeer(model, () => + effects ? { ...authentication, status } : null, + ); + try { + await using session = await new Agent({ + instructions: 'Use write_once then answer.', + model: 'gpt-5', + provider: peer.provider, + tools: [ + tool({ + name: 'write_once', + parameters: z.object({}), + execute: () => { + effects++; + return 'receipt-1'; + }, + }), + ], + }).session(); + const error: unknown = await ( + await session.submit('Record one effect.') + ) + .wait() + .catch((error: unknown) => error); + expectMetadata(error, { + statusCode: status, + code: 'invalid_api_key', + requestId: authentication.requestId, + category: 'authentication_error', + }); + expect(error).toMatchObject({ retryable: false, retryAfterMs: 0 }); + expect(effects).toBe(1); + expect(peer.rejectedRequests).toBe(attempts); + expect(model.requests).toHaveLength(1); + for (const body of peer.rejectedBodies) { + const input = body.input as { + type?: string; + call_id?: string; + output?: unknown; + }[]; + const original = input.find((item) => item.type === 'function_call'); + const outputs = input.filter( + (item) => item.type === 'function_call_output', + ); + expect(outputs).toHaveLength(1); + expect(outputs[0]?.call_id).toBe(original?.call_id); + expect(original?.call_id).toBeTruthy(); + expect(outputs[0]?.output).toBe('receipt-1'); + } + model.assertExhausted(); + } finally { + await peer.close(); + } + }, + ); + + it('keeps authentication recovery available to a later model request', async () => { + const model = new ModelServer([]); + const peer = await providerPeer(model, () => authentication); + try { + await using session = await new Agent({ + instructions: 'Answer.', + model: 'gpt-5', + provider: peer.provider, + }).session(); + for (const attempt of [1, 2]) { + const turn = await session.submit( + 'Reject this request once recovery is spent.', + ); + const error: unknown = await turn + .wait() + .catch((error: unknown) => error); + expectMetadata(error, { + statusCode: 401, + code: authentication.code, + requestId: authentication.requestId, + category: 'authentication_error', + }); + expect(error).toMatchObject({ retryable: false }); + expect(peer.rejectedRequests).toBe(attempt * 2); + } + model.assertExhausted(); + } finally { + await peer.close(); + } + }); + it.each([ { name: 'authentication', @@ -432,44 +635,64 @@ describe('Python-aligned native provider error metadata', () => { 30_000, ); - it('uses final HTTP failure metadata after rejected WebSocket upgrades', async () => { - const model = new ModelServer([]); - const finalHttpFailure: Failure = { - ...authentication, - status: 403, - code: 'final_http_rejection', - requestId: 'final-http-request', - }; - const peer = await providerPeer(model, () => finalHttpFailure, true); - let session: Session | undefined; - try { - session = await new Agent({ - instructions: 'Reply briefly.', - model: 'gpt-5', - provider: peer.provider, - }).session(); - const turn = await session.submit('Reject WebSocket and HTTP requests.'); - const error: unknown = await turn.wait().catch((error: unknown) => error); - expectMetadata(error, { - statusCode: 403, + it.each([ + { status: 401, upgrade: 401 as const, upgrades: 2, httpAttempts: 2 }, + { status: 403, upgrade: 401 as const, upgrades: 2, httpAttempts: 1 }, + { status: 401, upgrade: 426 as const, upgrades: 1, httpAttempts: 2 }, + { status: 403, upgrade: 426 as const, upgrades: 1, httpAttempts: 1 }, + ])( + 'keeps startup-preconnect recovery independent after WebSocket $upgrade ($status)', + async ({ status, upgrade, upgrades, httpAttempts }) => { + const model = new ModelServer([]); + const finalHttpFailure: Failure = { + ...authentication, + status, code: 'final_http_rejection', requestId: 'final-http-request', - category: 'authentication_error', - }); - // Core retries upgrade failures and then switches to HTTP. The terminal - // metadata must describe that last failure, rather than an earlier 401. - expect(peer.rejectedUpgrades).toBeGreaterThan(0); - expect(peer.rejectedRequests).toBeGreaterThan(0); - await expect(turn.wait()).rejects.toBe(error); - model.assertExhausted(); - } finally { + }; + const peer = await providerPeer( + model, + () => finalHttpFailure, + true, + undefined, + upgrade, + ); + let session: Session | undefined; try { - await session?.close(); + session = await new Agent({ + instructions: 'Reply briefly.', + model: 'gpt-5', + provider: peer.provider, + }).session(); + const turn = await session.submit( + 'Reject WebSocket and HTTP requests.', + ); + const error: unknown = await turn + .wait() + .catch((error: unknown) => error); + expectMetadata(error, { + statusCode: status, + code: 'final_http_rejection', + requestId: 'final-http-request', + category: 'authentication_error', + }); + // Startup preconnect owns a separate recovery budget. This first turn + // keeps normal HTTP recovery and owns the final failure metadata. + expect(error).toMatchObject({ retryable: false }); + expect(peer.rejectedUpgrades).toBe(upgrades); + expect(peer.rejectedRequests).toBe(httpAttempts); + await expect(turn.wait()).rejects.toBe(error); + model.assertExhausted(); } finally { - await peer.close(); + try { + await session?.close(); + } finally { + await peer.close(); + } } - } - }, 30_000); + }, + 30_000, + ); it('recovers and preserves a later callback NativeError without borrowed metadata', async () => { let failure: Failure | null = authentication; From ce6124ef95792f730fbe76c104bb0b4537acf94d Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:57:17 +0000 Subject: [PATCH 17/34] Read the realtime pairing test request count from the token server --- .../test/token-server.test.ts | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/examples/realtime-react-native/test/token-server.test.ts b/examples/realtime-react-native/test/token-server.test.ts index f22209616..6113a1ec7 100644 --- a/examples/realtime-react-native/test/token-server.test.ts +++ b/examples/realtime-react-native/test/token-server.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; +import { once } from 'node:events'; import { request as httpRequest } from 'node:http'; import { createServer } from 'node:net'; import type { AddressInfo } from 'node:net'; @@ -28,8 +29,10 @@ async function startServer(host: string, configuredPort?: number) { return listen.call(this, ${port}, ...args); };` } + let upstreamRequests = 0; + process.on('message', () => process.send(upstreamRequests)); globalThis.fetch = async () => { - console.log('UPSTREAM_REQUEST'); + upstreamRequests++; return new Response(JSON.stringify({ value: 'test-client-secret', expires_at: 123 }), { status: 200 }); }; await import(${JSON.stringify(serverUrl)}); @@ -41,7 +44,7 @@ async function startServer(host: string, configuredPort?: number) { PORT: String(configuredPort ?? port), OPENAI_API_KEY: 'test-api-key', }, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], }); let output = ''; await new Promise((resolve, reject) => { @@ -63,8 +66,11 @@ async function startServer(host: string, configuredPort?: number) { }); return { port, - get output() { - return output; + async upstreamRequests() { + const reply = once(child, 'message'); + child.send('upstreamRequests'); + const [count] = await reply; + return count as number; }, pairingToken: output.match(/Pairing token: ([A-Za-z0-9_-]+)/)?.[1], request: (headers?: Record) => @@ -95,7 +101,7 @@ test('LAN token issuance requires pairing and limits each client', async () => { (await server.request({ Authorization: 'Bearer wrong' })).status, 401, ); - assert.equal(server.output.includes('UPSTREAM_REQUEST'), false); + assert.equal(await server.upstreamRequests(), 0); assert.ok(server.pairingToken); const headers = { Authorization: `Bearer ${server.pairingToken}` }; @@ -105,7 +111,7 @@ test('LAN token issuance requires pairing and limits each client', async () => { assert.equal((await response.json()).value, 'test-client-secret'); } assert.equal((await server.request(headers)).status, 429); - assert.equal(server.output.match(/UPSTREAM_REQUEST/g)?.length, 6); + assert.equal(await server.upstreamRequests(), 6); } finally { server.stop(); } @@ -137,7 +143,7 @@ test('loopback token issuance requires pairing and an expected origin', async () try { assert.ok(server.pairingToken); assert.equal((await server.request()).status, 401); - assert.equal(server.output.includes('UPSTREAM_REQUEST'), false); + assert.equal(await server.upstreamRequests(), 0); const headers = { Authorization: `Bearer ${server.pairingToken}` }; assert.equal( @@ -162,7 +168,7 @@ test('loopback token issuance requires pairing and an expected origin', async () ).status, 403, ); - assert.equal(server.output.includes('UPSTREAM_REQUEST'), false); + assert.equal(await server.upstreamRequests(), 0); assert.equal((await server.request(headers)).status, 200); assert.equal( @@ -170,7 +176,7 @@ test('loopback token issuance requires pairing and an expected origin', async () .status, 200, ); - assert.equal(server.output.match(/UPSTREAM_REQUEST/g)?.length, 2); + assert.equal(await server.upstreamRequests(), 2); } finally { server.stop(); } @@ -204,7 +210,7 @@ test('HTTP port 80 accepts exact portless aliases and serialized origins while r ).status, 403, ); - assert.equal(server.output.includes('UPSTREAM_REQUEST'), false); + assert.equal(await server.upstreamRequests(), 0); for (const alias of ['127.0.0.1', 'localhost', '10.0.2.2']) { for (const Host of [alias, `${alias}:80`]) { @@ -221,7 +227,7 @@ test('HTTP port 80 accepts exact portless aliases and serialized origins while r (await server.request({ ...headers, Host: 'localhost' })).status, 429, ); - assert.equal(server.output.match(/UPSTREAM_REQUEST/g)?.length, 6); + assert.equal(await server.upstreamRequests(), 6); } finally { server.stop(); } From d3a9cc4d2665f911e19d6afd8e32d1fa598840b4 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:52:15 +0000 Subject: [PATCH 18/34] Await the Core carrier close handshake before sampling reconnect --- .../agents-codex/test/native/parity-error-metadata.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/agents-codex/test/native/parity-error-metadata.test.ts b/packages/agents-codex/test/native/parity-error-metadata.test.ts index 7c92b880d..5b6fb3faf 100644 --- a/packages/agents-codex/test/native/parity-error-metadata.test.ts +++ b/packages/agents-codex/test/native/parity-error-metadata.test.ts @@ -213,7 +213,8 @@ async function providerPeer( [...coreCarrierSockets].map(async (socket) => { if (socket.readyState === 3) return; const closed = once(socket, 'close'); - socket.terminate(); + // Await the Core pump's close handshake before the next model send. + socket.close(); await closed; }), ); From b5c2b46a0fd011ab6706d2870e29e20033e78c9e Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:24:11 +0000 Subject: [PATCH 19/34] Retain the native workflow policy through queued child submissions --- .changeset/owned-child-tracing-context.md | 6 + .../0059-sdk-queued-trace-policy.patch | 254 ++++++++++++++++++ .../agents-codex/native/patches/series.json | 6 +- 3 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 .changeset/owned-child-tracing-context.md create mode 100644 packages/agents-codex/native/patches/0059-sdk-queued-trace-policy.patch diff --git a/.changeset/owned-child-tracing-context.md b/.changeset/owned-child-tracing-context.md new file mode 100644 index 000000000..c76483f68 --- /dev/null +++ b/.changeset/owned-child-tracing-context.md @@ -0,0 +1,6 @@ +--- +'@openai/agents': patch +'@openai/agents-codex': patch +--- + +fix: preserve the originating workflow's trace context and privacy settings in native child callbacks diff --git a/packages/agents-codex/native/patches/0059-sdk-queued-trace-policy.patch b/packages/agents-codex/native/patches/0059-sdk-queued-trace-policy.patch new file mode 100644 index 000000000..2433bfb74 --- /dev/null +++ b/packages/agents-codex/native/patches/0059-sdk-queued-trace-policy.patch @@ -0,0 +1,254 @@ +diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs +index 6ac6135..140dd3d 100644 +--- a/codex-rs/core/src/agent/control_tests.rs ++++ b/codex-rs/core/src/agent/control_tests.rs +@@ -5835,6 +5835,7 @@ async fn tree_shutdown_preserves_sibling_completion_activity_with_full_parent_qu + op: Op::ReloadUserConfig, + trace: None, + turn_extension_init: None, ++ trace_policy: None, + parent_turn_id: None, + root_turn_id: None, + residency_guard: None, +diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs +index 6caab5e..28b5147 100644 +--- a/codex-rs/core/src/codex_delegate.rs ++++ b/codex-rs/core/src/codex_delegate.rs +@@ -287,6 +287,7 @@ pub(crate) async fn run_codex_thread_one_shot( + op: Op::Shutdown {}, + turn_extension_init: None, + trace: None, ++ trace_policy: None, + parent_turn_id: None, + root_turn_id: None, + residency_guard: None, +diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs +index 0ca83da..78fb323 100644 +--- a/codex-rs/core/src/codex_delegate_tests.rs ++++ b/codex-rs/core/src/codex_delegate_tests.rs +@@ -155,6 +155,7 @@ async fn forward_ops_preserves_submission_trace_context() { + ), + tracestate: Some("vendor=state".to_string()), + }), ++ trace_policy: None, + parent_turn_id: Some("parent-turn".to_string()), + root_turn_id: Some("root-turn".to_string()), + residency_guard: None, +diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs +index f9c5f8e..d1c66d4 100644 +--- a/codex-rs/core/src/codex_thread.rs ++++ b/codex-rs/core/src/codex_thread.rs +@@ -576,6 +576,7 @@ impl CodexThread { + op: Op::SuspendTurnAndShutdown { reply }, + turn_extension_init: None, + trace: current_span_w3c_trace_context(), ++ trace_policy: None, + parent_turn_id: None, + root_turn_id: None, + residency_guard: None, +diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs +index f6fcf14..e190c5f 100644 +--- a/codex-rs/core/src/session/handlers.rs ++++ b/codex-rs/core/src/session/handlers.rs +@@ -6,7 +6,8 @@ use crate::realtime_conversation::handle_speech as handle_realtime_conversation_ + use crate::realtime_conversation::handle_start as handle_realtime_conversation_start; + use crate::realtime_conversation::handle_text as handle_realtime_conversation_text; + use async_channel::Receiver; +-use codex_otel::set_parent_from_w3c_trace_context; ++use codex_otel::context_from_w3c_trace_context; ++use codex_otel::set_parent_from_context; + use tracing::Instrument; + use tracing::debug_span; + use tracing::info_span; +@@ -773,13 +774,18 @@ pub(super) fn submission_dispatch_span(sub: &Submission) -> tracing::Span { + codex.op = op_name + ), + }; +- if let Some(trace) = sub.trace.as_ref() +- && !set_parent_from_w3c_trace_context(&dispatch_span, trace) +- { +- warn!( +- submission.id = sub.id.as_str(), +- "ignoring invalid submission trace carrier" +- ); ++ if let Some(trace) = sub.trace.as_ref() { ++ if let Some(mut parent) = context_from_w3c_trace_context(trace) { ++ if let Some(policy) = &sub.trace_policy { ++ parent = parent.with_value(policy.clone()); ++ } ++ set_parent_from_context(&dispatch_span, parent); ++ } else { ++ warn!( ++ submission.id = sub.id.as_str(), ++ "ignoring invalid submission trace carrier" ++ ); ++ } + } + dispatch_span + } +diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs +index 0187a60..413a2e6 100644 +--- a/codex-rs/core/src/session/mod.rs ++++ b/codex-rs/core/src/session/mod.rs +@@ -97,7 +97,6 @@ use codex_network_proxy::NetworkProxyAuditMetadata; + use codex_network_proxy::normalize_host; + use codex_otel::auth_storage::AuthStorageOriginator; + use codex_otel::current_span_trace_id; +-use codex_otel::current_span_w3c_trace_context; + use codex_otel::set_parent_from_w3c_trace_context; + use codex_prompts::render_model_instructions; + use codex_protocol::ResponseUsageMetadata; +@@ -1018,6 +1017,7 @@ impl SessionIo { + op, + turn_extension_init, + trace, ++ trace_policy: None, + parent_turn_id, + root_turn_id, + residency_guard, +@@ -1029,7 +1029,7 @@ impl SessionIo { + /// Use sparingly: prefer `submit()` so submission IDs are generated consistently. + pub(crate) async fn submit_with_id(&self, mut sub: Submission) -> CodexResult<()> { + if sub.trace.is_none() { +- sub.trace = current_span_w3c_trace_context(); ++ (sub.trace, sub.trace_policy) = codex_otel::sdk_trace::submission_trace(); + } + self.tx_sub + .send(sub) +@@ -1063,6 +1063,7 @@ impl SessionIo { + }, + trace, + turn_extension_init, ++ trace_policy: None, + parent_turn_id: None, + root_turn_id: None, + residency_guard: None, +@@ -1089,6 +1090,7 @@ impl SessionIo { + }, + trace, + turn_extension_init, ++ trace_policy: None, + parent_turn_id: None, + root_turn_id: None, + residency_guard: None, +diff --git a/codex-rs/core/src/session/step_activation_tests.rs b/codex-rs/core/src/session/step_activation_tests.rs +index 68e2cf7..5ad38f4 100644 +--- a/codex-rs/core/src/session/step_activation_tests.rs ++++ b/codex-rs/core/src/session/step_activation_tests.rs +@@ -497,6 +497,7 @@ fn settings_submission( + reply, + }, + trace: None, ++ trace_policy: None, + parent_turn_id: None, + root_turn_id: None, + residency_guard: None, +diff --git a/codex-rs/core/src/session/submission.rs b/codex-rs/core/src/session/submission.rs +index 413e187..a4a34bd 100644 +--- a/codex-rs/core/src/session/submission.rs ++++ b/codex-rs/core/src/session/submission.rs +@@ -14,6 +14,8 @@ pub(crate) struct Submission { + pub turn_extension_init: Option, + /// Optional W3C trace carrier propagated across async submission handoffs. + pub trace: Option, ++ /// Local workflow policy owned by this queued submission until dispatch. ++ pub trace_policy: Option>, + pub parent_turn_id: Option, + pub root_turn_id: Option, + /// Keeps a V2 recipient resident until this submission is handled or dropped. +diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs +index d5ee490..3744bc3 100644 +--- a/codex-rs/core/src/session/tests.rs ++++ b/codex-rs/core/src/session/tests.rs +@@ -6285,6 +6285,7 @@ async fn standalone_settings_invalidate_continuation_before_delivering_acceptanc + reply: Some(reply), + }, + trace: None, ++ trace_policy: None, + parent_turn_id: None, + root_turn_id: None, + residency_guard: None, +@@ -8366,6 +8367,7 @@ fn submission_dispatch_span_prefers_submission_trace_context() { + turn_extension_init: None, + id: "sub-1".into(), + op: Op::Interrupt, ++ trace_policy: None, + parent_turn_id: None, + root_turn_id: None, + residency_guard: None, +@@ -8396,6 +8398,7 @@ fn submission_dispatch_span_uses_debug_for_realtime_audio() { + item_id: None, + }, + }), ++ trace_policy: None, + parent_turn_id: None, + root_turn_id: None, + residency_guard: None, +@@ -8755,6 +8758,7 @@ async fn spawn_task_turn_span_inherits_dispatch_trace_context() { + turn_extension_init: None, + id: "sub-1".into(), + op: Op::Interrupt, ++ trace_policy: None, + parent_turn_id: None, + root_turn_id: None, + residency_guard: None, +diff --git a/codex-rs/otel/src/sdk_trace.rs b/codex-rs/otel/src/sdk_trace.rs +index 6d56fba..a655dde 100644 +--- a/codex-rs/otel/src/sdk_trace.rs ++++ b/codex-rs/otel/src/sdk_trace.rs +@@ -542,13 +542,26 @@ pub fn end_trace_span(key: &str, encoded: &str) -> Result<(), String> { + pub fn context(key: &str) -> Result { + Ok(resolve(key)?.context.clone()) + } ++pub fn submission_trace() -> ( ++ Option, ++ Option>, ++) { ++ let active = crate::trace_context::current_span_context(); ++ ( ++ crate::trace_context::context_w3c_trace_context(&active), ++ policy_for_context(&active), ++ ) ++} ++ + pub fn current_context_token() -> Option { +- // Native lifecycle hooks may run in an instrumented tracing span without +- // an attached OpenTelemetry Context. Prefer that exact span's carrier. +- let context = crate::trace_context::current_span_w3c_trace_context() +- .and_then(|trace| crate::trace_context::context_from_w3c_trace_context(&trace)) +- .filter(|context| policy_for_context(context).is_some()) +- .unwrap_or_else(|| restore_policy(Context::current())); ++ // Native lifecycle hooks can inherit private values on the instrumented ++ // span. A W3C round trip would discard those values before policy lookup. ++ let active = crate::trace_context::current_span_context(); ++ let context = if policy_for_context(&active).is_some() { ++ active ++ } else { ++ restore_policy(Context::current()) ++ }; + let policy = policy_for_context(&context)?; + let registration = retain_policy(context.span().span_context(), policy); + let handle = Arc::new(Handle { +diff --git a/codex-rs/otel/src/trace_context.rs b/codex-rs/otel/src/trace_context.rs +index e1c6277..4d9cf8c 100644 +--- a/codex-rs/otel/src/trace_context.rs ++++ b/codex-rs/otel/src/trace_context.rs +@@ -27,8 +27,16 @@ static TRACESTATE_ENTRIES: OnceLock Option { +- span_w3c_trace_context(&Span::current()) +- .or_else(|| context_w3c_trace_context(&Context::current())) ++ context_w3c_trace_context(¤t_span_context()) ++} ++ ++pub(crate) fn current_span_context() -> Context { ++ let active = Span::current().context(); ++ if active.span().span_context().is_valid() { ++ active ++ } else { ++ Context::current() ++ } + } + + pub fn span_w3c_trace_context(span: &Span) -> Option { diff --git a/packages/agents-codex/native/patches/series.json b/packages/agents-codex/native/patches/series.json index ff25b4ba8..dbc57e8ff 100644 --- a/packages/agents-codex/native/patches/series.json +++ b/packages/agents-codex/native/patches/series.json @@ -255,7 +255,11 @@ { "path": "0058-native-metadata-durability.patch", "sha256": "10136a8852249737dbf7974c579cb84faf0d36fc495fcbaeeafe66a3dfa7e3d6" + }, + { + "path": "0059-sdk-queued-trace-policy.patch", + "sha256": "deeb02a044f92cad6026f9e54dcd1c78f8d6b878581d99cdde77a3f7d8eb29fc" } ], - "result_tree": "7f082be8686f215dff9ee18b226ba21281a561e4" + "result_tree": "72f3f37c5fe9388f9e170b0cf85e80fe018a7370" } From b18278378ad8e92dffc4a0a4816168902096b99e Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:34:10 +0000 Subject: [PATCH 20/34] Use the owning turn for executor approval tracing --- .../0059-sdk-queued-trace-policy.patch | 111 ++++++++++++++++-- .../agents-codex/native/patches/series.json | 4 +- .../native/rust/native_interception.rs | 7 +- 3 files changed, 111 insertions(+), 11 deletions(-) diff --git a/packages/agents-codex/native/patches/0059-sdk-queued-trace-policy.patch b/packages/agents-codex/native/patches/0059-sdk-queued-trace-policy.patch index 2433bfb74..9008e8e53 100644 --- a/packages/agents-codex/native/patches/0059-sdk-queued-trace-policy.patch +++ b/packages/agents-codex/native/patches/0059-sdk-queued-trace-policy.patch @@ -193,10 +193,10 @@ index d5ee490..3744bc3 100644 root_turn_id: None, residency_guard: None, diff --git a/codex-rs/otel/src/sdk_trace.rs b/codex-rs/otel/src/sdk_trace.rs -index 6d56fba..a655dde 100644 +index 6d56fba..57dd15e 100644 --- a/codex-rs/otel/src/sdk_trace.rs +++ b/codex-rs/otel/src/sdk_trace.rs -@@ -542,13 +542,26 @@ pub fn end_trace_span(key: &str, encoded: &str) -> Result<(), String> { +@@ -542,13 +542,21 @@ pub fn end_trace_span(key: &str, encoded: &str) -> Result<(), String> { pub fn context(key: &str) -> Result { Ok(resolve(key)?.context.clone()) } @@ -220,15 +220,110 @@ index 6d56fba..a655dde 100644 - .unwrap_or_else(|| restore_policy(Context::current())); + // Native lifecycle hooks can inherit private values on the instrumented + // span. A W3C round trip would discard those values before policy lookup. -+ let active = crate::trace_context::current_span_context(); -+ let context = if policy_for_context(&active).is_some() { -+ active -+ } else { -+ restore_policy(Context::current()) -+ }; ++ let context = crate::trace_context::current_span_context(); let policy = policy_for_context(&context)?; let registration = retain_policy(context.span().span_context(), policy); let handle = Arc::new(Handle { +@@ -979,6 +987,99 @@ mod context_lease_tests { + use super::*; + use opentelemetry_sdk::trace::{InMemorySpanExporter, Sampler, SdkTracerProvider}; + ++ #[test] ++ fn unowned_instrumented_span_never_borrows_ambient_workflow_policy() { ++ use opentelemetry::trace::TracerProvider as _; ++ use tracing_opentelemetry::OpenTelemetrySpanExt as _; ++ use tracing_subscriber::prelude::*; ++ ++ const CHILD: &str = "CODEX_SDK_UNOWNED_INSTRUMENTED_CONTEXT_CHILD"; ++ if std::env::var_os(CHILD).is_none() { ++ let status = std::process::Command::new(std::env::current_exe().unwrap()) ++ .args([ ++ "--exact", ++ "sdk_trace::context_lease_tests::unowned_instrumented_span_never_borrows_ambient_workflow_policy", ++ "--nocapture", ++ ]) ++ .env(CHILD, "1") ++ .status() ++ .unwrap(); ++ assert!(status.success()); ++ return; ++ } ++ ++ let provider = SdkTracerProvider::builder() ++ .with_sampler(Sampler::AlwaysOn) ++ .build(); ++ global::set_tracer_provider(provider.clone()); ++ let subscriber = tracing_subscriber::registry().with( ++ tracing_opentelemetry::layer().with_tracer(provider.tracer("unowned-context-fixture")), ++ ); ++ let _subscriber = tracing::subscriber::set_default(subscriber); ++ configure_pipeline(true); ++ ++ let workflow: Value = serde_json::from_str( ++ &start_trace_span( ++ r#"{"name":"unrelated-B","kind":"workflow","trace_id":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","disabled":false,"include_sensitive_data":true}"#, ++ ) ++ .unwrap(), ++ ) ++ .unwrap(); ++ let workflow_key = workflow["handle"].as_str().unwrap(); ++ let unrelated = context(workflow_key).unwrap(); ++ let unrelated_policy = policy_for_context(&unrelated).unwrap(); ++ assert!(unrelated_policy.include_sensitive_data); ++ assert!(!unrelated_policy.disabled); ++ let attached = unrelated.attach(); ++ ++ // A is a valid instrumented native span with an explicit, unowned parent. ++ // B stays attached on this thread, but must not supply A's workflow policy. ++ let carrier = codex_protocol::protocol::W3cTraceContext { ++ traceparent: Some("00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1111111111111111-01".to_owned()), ++ tracestate: None, ++ }; ++ let explicit = crate::context_from_w3c_trace_context(&carrier).unwrap(); ++ assert!(policy_for_context(&explicit).is_none()); ++ let native = tracing::info_span!(parent: None, "unowned-native-A"); ++ let _ = native.set_parent(explicit); ++ let captured = { ++ let _entered = native.enter(); ++ let active = crate::trace_context::current_span_context(); ++ assert!(active.span().span_context().is_valid()); ++ assert_eq!( ++ active.span().span_context().trace_id().to_string(), ++ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ++ ); ++ assert!(policy_for_context(&active).is_none()); ++ assert_eq!( ++ Context::current() ++ .span() ++ .span_context() ++ .trace_id() ++ .to_string(), ++ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" ++ ); ++ ContextLease::capture() ++ }; ++ ++ // Save only public tracing flags for the failure message, then release any ++ // wrongly captured lease before asserting. No credential or payload is used. ++ let borrowed = captured.as_ref().map(|lease| { ++ serde_json::from_str::(&trace_span_context(lease.token()).unwrap()).unwrap() ++ }); ++ drop(captured); ++ drop(native); ++ drop(attached); ++ end_trace_span(workflow_key, r#"{"status":"ok"}"#).unwrap(); ++ release_trace_span(workflow_key); ++ provider.shutdown().unwrap(); ++ ++ assert!( ++ borrowed.is_none(), ++ "unowned instrumented A borrowed unrelated B's SDK context: {borrowed:?}" ++ ); ++ } ++ + #[test] + fn captured_leases_retain_intermediate_identity_until_last_owner_releases() { + const CHILD: &str = "CODEX_SDK_CONTEXT_LEASE_IDENTITY_CHILD"; diff --git a/codex-rs/otel/src/trace_context.rs b/codex-rs/otel/src/trace_context.rs index e1c6277..4d9cf8c 100644 --- a/codex-rs/otel/src/trace_context.rs diff --git a/packages/agents-codex/native/patches/series.json b/packages/agents-codex/native/patches/series.json index dbc57e8ff..636c2f97b 100644 --- a/packages/agents-codex/native/patches/series.json +++ b/packages/agents-codex/native/patches/series.json @@ -258,8 +258,8 @@ }, { "path": "0059-sdk-queued-trace-policy.patch", - "sha256": "deeb02a044f92cad6026f9e54dcd1c78f8d6b878581d99cdde77a3f7d8eb29fc" + "sha256": "844fe703a60734bb92146558d23534357047ff15ccdc743790e4a32a9fb6837b" } ], - "result_tree": "72f3f37c5fe9388f9e170b0cf85e80fe018a7370" + "result_tree": "2c27f1adff2d62e35c5964c5b73e509ba70cf672" } diff --git a/packages/agents-codex/native/rust/native_interception.rs b/packages/agents-codex/native/rust/native_interception.rs index a727f4380..a91e53336 100644 --- a/packages/agents-codex/native/rust/native_interception.rs +++ b/packages/agents-codex/native/rust/native_interception.rs @@ -3340,7 +3340,12 @@ impl DirectRequest { // web/image, missing ids and malformed search are not client calls. _ => return Ok((item, false)), }; - let tracing = codex_otel::sdk_trace::ContextLease::capture(); + // The model stream can deliver this item on a different task. Its + // executor approval belongs to the turn retained by this request. + let tracing = match &self.owner.settlement { + Some(receipt) => receipt.upgrade().and_then(|turn| turn.tracing.clone()), + None => codex_otel::sdk_trace::ContextLease::capture().map(Arc::new), + }; let request = Request { tracing_context: tracing.as_ref().map(|context| context.token()), phase: "before", From f80b0d02c08606e89b8cb61dba7100a1afd57aa8 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:36:15 +0000 Subject: [PATCH 21/34] test: hold retained continuation after the recorded model response --- .../native/rust/turn_settlement_tests.rs | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/agents-codex/native/rust/turn_settlement_tests.rs b/packages/agents-codex/native/rust/turn_settlement_tests.rs index 81ac81f6c..98558d078 100644 --- a/packages/agents-codex/native/rust/turn_settlement_tests.rs +++ b/packages/agents-codex/native/rust/turn_settlement_tests.rs @@ -198,13 +198,23 @@ async fn managed_root_continues_only_its_own_successfully_settled_tail() { let first = started["turn_id"].as_str().unwrap(); loop { hold.entered.acquire().await.unwrap().forget(); - let requests = provider.requests.lock().unwrap().len(); - if requests != 0 { - assert_eq!(requests, 1); + let post_response = engine + .load_checkpoint_history(state.id) + .await + .unwrap() + .iter() + .any(|item| matches!(item, + codex_history::RolloutItem::EventMsg(EventMsg::TokenCount(event)) + if event.info.as_ref().is_some_and(|info| + info.last_token_usage.input_tokens == 1 + && info.last_token_usage.output_tokens == 1 + && info.last_token_usage.total_tokens == 2))); + if post_response { + assert_eq!(provider.requests.lock().unwrap().len(), 1); break; } - // Initial durability barriers precede the model request. Keep - // the same hold armed until sampling has actually started. + // Skip the initial and post-tool durability barriers. The + // response's actual usage precedes the finalization flush. *store.next_flush_hold.lock().unwrap() = Some(hold.clone()); hold.release.add_permits(1); } From f367048ca98db9605cf2201e36685c601bc1dbd3 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:38:25 +0000 Subject: [PATCH 22/34] Assert the denied child command stops before executor dispatch --- .../native/child-turn-events-native.test.ts | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/agents-codex/test/native/child-turn-events-native.test.ts b/packages/agents-codex/test/native/child-turn-events-native.test.ts index c43c1f048..8ce7e35ef 100644 --- a/packages/agents-codex/test/native/child-turn-events-native.test.ts +++ b/packages/agents-codex/test/native/child-turn-events-native.test.ts @@ -949,17 +949,15 @@ it('keeps child tracing policy for explicit starts and active steering', async ( childModels++; return childModels === 1 ? [message('initial child done')] - : childModels === 2 - ? [ - call( - 'exec_command', - { cmd: 'printf denied' }, - { callId: 'denied-command' }, - ), - ] - : [message('explicit child done')]; + : [ + call( + 'exec_command', + { cmd: 'printf denied' }, + { callId: 'denied-command' }, + ), + ]; }; - const model = new ModelServer(Array.from({ length: 5 }, () => respond)); + const model = new ModelServer(Array.from({ length: 4 }, () => respond)); const executor = await startPublicExecutor(); try { await using provider = await providerFor(model); @@ -1029,11 +1027,14 @@ it('keeps child tracing policy for explicit starts and active steering', async ( ]); expect(await child.submit('steer active child')).toBe(second); release.resolve(); - expect((await finished).finalOutput).toBe('explicit child done'); + await expect(finished).rejects.toBeInstanceOf(NativeError); }, ); expect(approvals).toBe(1); - expect(childModels).toBe(3); + expect(childModels).toBe(2); + expect( + executor.requests.some((rpc) => rpc.method === 'process/start'), + ).toBe(false); expect(steps).toBeGreaterThanOrEqual(3); model.assertExhausted(); } finally { From 8206687060b59f7e70f5f68bc55b1a476a0cf4a4 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:44:23 +0000 Subject: [PATCH 23/34] Exercise independent ambient and instrumented trace contexts --- .../native/patches/0059-sdk-queued-trace-policy.patch | 7 +++---- packages/agents-codex/native/patches/series.json | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/agents-codex/native/patches/0059-sdk-queued-trace-policy.patch b/packages/agents-codex/native/patches/0059-sdk-queued-trace-policy.patch index 9008e8e53..92d5f1bc4 100644 --- a/packages/agents-codex/native/patches/0059-sdk-queued-trace-policy.patch +++ b/packages/agents-codex/native/patches/0059-sdk-queued-trace-policy.patch @@ -193,7 +193,7 @@ index d5ee490..3744bc3 100644 root_turn_id: None, residency_guard: None, diff --git a/codex-rs/otel/src/sdk_trace.rs b/codex-rs/otel/src/sdk_trace.rs -index 6d56fba..57dd15e 100644 +index 6d56fba..9127d03 100644 --- a/codex-rs/otel/src/sdk_trace.rs +++ b/codex-rs/otel/src/sdk_trace.rs @@ -542,13 +542,21 @@ pub fn end_trace_span(key: &str, encoded: &str) -> Result<(), String> { @@ -224,7 +224,7 @@ index 6d56fba..57dd15e 100644 let policy = policy_for_context(&context)?; let registration = retain_policy(context.span().span_context(), policy); let handle = Arc::new(Handle { -@@ -979,6 +987,99 @@ mod context_lease_tests { +@@ -979,6 +987,98 @@ mod context_lease_tests { use super::*; use opentelemetry_sdk::trace::{InMemorySpanExporter, Sampler, SdkTracerProvider}; @@ -271,7 +271,6 @@ index 6d56fba..57dd15e 100644 + let unrelated_policy = policy_for_context(&unrelated).unwrap(); + assert!(unrelated_policy.include_sensitive_data); + assert!(!unrelated_policy.disabled); -+ let attached = unrelated.attach(); + + // A is a valid instrumented native span with an explicit, unowned parent. + // B stays attached on this thread, but must not supply A's workflow policy. @@ -285,6 +284,7 @@ index 6d56fba..57dd15e 100644 + let _ = native.set_parent(explicit); + let captured = { + let _entered = native.enter(); ++ let _attached_unrelated = unrelated.attach(); + let active = crate::trace_context::current_span_context(); + assert!(active.span().span_context().is_valid()); + assert_eq!( @@ -310,7 +310,6 @@ index 6d56fba..57dd15e 100644 + }); + drop(captured); + drop(native); -+ drop(attached); + end_trace_span(workflow_key, r#"{"status":"ok"}"#).unwrap(); + release_trace_span(workflow_key); + provider.shutdown().unwrap(); diff --git a/packages/agents-codex/native/patches/series.json b/packages/agents-codex/native/patches/series.json index 636c2f97b..2b7d2455d 100644 --- a/packages/agents-codex/native/patches/series.json +++ b/packages/agents-codex/native/patches/series.json @@ -258,8 +258,8 @@ }, { "path": "0059-sdk-queued-trace-policy.patch", - "sha256": "844fe703a60734bb92146558d23534357047ff15ccdc743790e4a32a9fb6837b" + "sha256": "1ec311d0ba35b0d41dba0ee319b85729039a55800f7477f4c5cb5e457462ffc5" } ], - "result_tree": "2c27f1adff2d62e35c5964c5b73e509ba70cf672" + "result_tree": "61a4406bf7acc950e4914f68a759059293a90457" } From b723a8221394d54ae3afb1a176d7a6a4b8996f47 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:36:59 +0000 Subject: [PATCH 24/34] Keep W3C trace ancestry separate from workflow export settings --- .../0059-sdk-queued-trace-policy.patch | 233 +++++++++++++++++- .../agents-codex/native/patches/series.json | 4 +- packages/agents-codex/native/rust/lib.rs | 15 +- 3 files changed, 236 insertions(+), 16 deletions(-) diff --git a/packages/agents-codex/native/patches/0059-sdk-queued-trace-policy.patch b/packages/agents-codex/native/patches/0059-sdk-queued-trace-policy.patch index 92d5f1bc4..41914016f 100644 --- a/packages/agents-codex/native/patches/0059-sdk-queued-trace-policy.patch +++ b/packages/agents-codex/native/patches/0059-sdk-queued-trace-policy.patch @@ -87,18 +87,54 @@ index f6fcf14..e190c5f 100644 dispatch_span } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs -index 0187a60..413a2e6 100644 +index 0187a60..7ecd486 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs -@@ -97,7 +97,6 @@ use codex_network_proxy::NetworkProxyAuditMetadata; +@@ -97,7 +97,7 @@ use codex_network_proxy::NetworkProxyAuditMetadata; use codex_network_proxy::normalize_host; use codex_otel::auth_storage::AuthStorageOriginator; use codex_otel::current_span_trace_id; -use codex_otel::current_span_w3c_trace_context; ++#[cfg(test)] use codex_otel::set_parent_from_w3c_trace_context; use codex_prompts::render_model_instructions; use codex_protocol::ResponseUsageMetadata; -@@ -1018,6 +1017,7 @@ impl SessionIo { +@@ -521,22 +521,20 @@ impl Session { + args: SessionSpawnArgs, + ) -> BoxFuture<'static, CodexResult<(Arc, SessionIo)>> { + let tree_shutdown = args.agent_control.runtime().shutdown.clone(); ++ let caller_policy = codex_otel::sdk_trace::current_scope_policy(); + Box::pin(async move { +- let parent_trace = match args.parent_trace { +- Some(trace) => { +- if codex_otel::context_from_w3c_trace_context(&trace).is_some() { +- Some(trace) +- } else { +- warn!("ignoring invalid thread spawn trace carrier"); +- None +- } +- } +- None => None, +- }; + let thread_spawn_span = info_span!("thread_spawn", otel.name = "thread_spawn"); +- if let Some(trace) = parent_trace.as_ref() { +- let _ = set_parent_from_w3c_trace_context(&thread_spawn_span, trace); +- } ++ let parent_trace = args.parent_trace.filter(|trace| { ++ let Some(mut parent) = codex_otel::context_from_w3c_trace_context(trace) else { ++ warn!("ignoring invalid thread spawn trace carrier"); ++ return false; ++ }; ++ if let Some(policy) = &caller_policy { ++ parent = parent.with_value(policy.clone()); ++ } ++ codex_otel::set_parent_from_context(&thread_spawn_span, parent); ++ true ++ }); + let spawn = Self::spawn_internal(SessionSpawnArgs { + parent_trace, + ..args +@@ -1018,6 +1016,7 @@ impl SessionIo { op, turn_extension_init, trace, @@ -106,16 +142,18 @@ index 0187a60..413a2e6 100644 parent_turn_id, root_turn_id, residency_guard, -@@ -1029,7 +1029,7 @@ impl SessionIo { +@@ -1029,7 +1028,9 @@ impl SessionIo { /// Use sparingly: prefer `submit()` so submission IDs are generated consistently. pub(crate) async fn submit_with_id(&self, mut sub: Submission) -> CodexResult<()> { if sub.trace.is_none() { - sub.trace = current_span_w3c_trace_context(); + (sub.trace, sub.trace_policy) = codex_otel::sdk_trace::submission_trace(); ++ } else if sub.trace_policy.is_none() { ++ sub.trace_policy = codex_otel::sdk_trace::current_scope_policy(); } self.tx_sub .send(sub) -@@ -1063,6 +1063,7 @@ impl SessionIo { +@@ -1063,6 +1064,7 @@ impl SessionIo { }, trace, turn_extension_init, @@ -123,7 +161,7 @@ index 0187a60..413a2e6 100644 parent_turn_id: None, root_turn_id: None, residency_guard: None, -@@ -1089,6 +1090,7 @@ impl SessionIo { +@@ -1089,6 +1091,7 @@ impl SessionIo { }, trace, turn_extension_init, @@ -193,13 +231,41 @@ index d5ee490..3744bc3 100644 root_turn_id: None, residency_guard: None, diff --git a/codex-rs/otel/src/sdk_trace.rs b/codex-rs/otel/src/sdk_trace.rs -index 6d56fba..9127d03 100644 +index 6d56fba..b9ca36b 100644 --- a/codex-rs/otel/src/sdk_trace.rs +++ b/codex-rs/otel/src/sdk_trace.rs -@@ -542,13 +542,21 @@ pub fn end_trace_span(key: &str, encoded: &str) -> Result<(), String> { +@@ -81,6 +81,10 @@ fn policies() -> &'static Mutex>> { + POLICIES.get_or_init(Default::default) + } + fn policy_registration(span: &SpanContext) -> Option> { ++ // Wire IDs identify ancestry. Only a locally created span can own a registry entry. ++ if span.is_remote() { ++ return None; ++ } + // Release the registry lock before upgrading: dropping the final upgraded + // owner removes its entry and must never run while this lock is held. + let registration = policies() +@@ -109,12 +113,6 @@ pub fn policy_for_context(context: &Context) -> Option> { + .cloned() + .or_else(|| policy_for_span(context.span().span_context())) + } +-pub fn restore_policy(context: Context) -> Context { +- match policy_for_context(&context) { +- Some(policy) => context.with_value(policy), +- None => context, +- } +-} + pub fn scoped_capture_allowed() -> bool { + policy_for_context(&Context::current()) + .is_none_or(|policy| !policy.disabled && policy.include_sensitive_data) +@@ -542,18 +540,36 @@ pub fn end_trace_span(key: &str, encoded: &str) -> Result<(), String> { pub fn context(key: &str) -> Result { Ok(resolve(key)?.context.clone()) } ++pub fn current_scope_policy() -> Option> { ++ policy_for_context(&crate::trace_context::current_span_context()) ++} ++ +pub fn submission_trace() -> ( + Option, + Option>, @@ -222,12 +288,136 @@ index 6d56fba..9127d03 100644 + // span. A W3C round trip would discard those values before policy lookup. + let context = crate::trace_context::current_span_context(); let policy = policy_for_context(&context)?; - let registration = retain_policy(context.span().span_context(), policy); +- let registration = retain_policy(context.span().span_context(), policy); ++ let registration = { ++ let span = context.span(); ++ let identity = span.span_context(); ++ // A remote parent may carry an independently captured caller policy. ++ // Keep that value on the handle without claiming the parent's IDs. ++ (!identity.is_remote()).then(|| retain_policy(identity, policy)) ++ }; let handle = Arc::new(Handle { -@@ -979,6 +987,98 @@ mod context_lease_tests { + context, +- _registration: Some(registration), ++ _registration: registration, + parent_span_id: None, + ended: Mutex::new(true), + }); +@@ -627,7 +643,9 @@ mod tests { + release_trace_span(key); + } + let carrier = trace_carrier(&lease).unwrap(); +- let parent = crate::context_from_w3c_trace_context(&carrier).unwrap(); ++ let remote = crate::context_from_w3c_trace_context(&carrier).unwrap(); ++ assert!(policy_for_context(&remote).is_none()); ++ let parent = context(&lease).unwrap(); + assert_eq!( + policy_for_context(&parent) + .unwrap() +@@ -750,7 +768,9 @@ mod lifecycle_tests { + end_trace_span(root_key, r#"{"status":"error"}"#).unwrap(); + release_trace_span(root_key); + let carrier = trace_carrier(&lease).unwrap(); +- let parent = crate::context_from_w3c_trace_context(&carrier).unwrap(); ++ let remote = crate::context_from_w3c_trace_context(&carrier).unwrap(); ++ assert!(policy_for_context(&remote).is_none()); ++ let parent = context(&lease).unwrap(); + assert!(!policy_for_context(&parent).unwrap().include_sensitive_data); + let child = global::tracer("native-fixture").start_with_context("native-child", &parent); + let mut child = wrap_span(child, &parent); +@@ -979,6 +999,187 @@ mod context_lease_tests { use super::*; use opentelemetry_sdk::trace::{InMemorySpanExporter, Sampler, SdkTracerProvider}; ++ #[test] ++ fn remote_trace_identity_cannot_authorize_an_sdk_export_route() { ++ const CHILD: &str = "CODEX_SDK_REMOTE_POLICY_ROUTE_TEST"; ++ if std::env::var_os(CHILD).is_none() { ++ let status = std::process::Command::new(std::env::current_exe().unwrap()) ++ .args(["--exact", "sdk_trace::context_lease_tests::remote_trace_identity_cannot_authorize_an_sdk_export_route"]) ++ .env(CHILD, "1") ++ .status() ++ .unwrap(); ++ assert!(status.success()); ++ return; ++ } ++ ++ let provider = SdkTracerProvider::builder() ++ .with_sampler(Sampler::AlwaysOn) ++ .build(); ++ global::set_tracer_provider(provider.clone()); ++ configure_pipeline(true); ++ crate::sdk_trace_routes::configure_default(true); ++ let start = |name: &str| { ++ let reference = crate::sdk_trace_routes::register_trace_credentials( ++ &json!({"x-test-route": name}).to_string(), ++ ) ++ .unwrap(); ++ let value: Value = serde_json::from_str( ++ &start_trace_span( ++ &json!({"name":name,"kind":"workflow","credential_ref":reference}).to_string(), ++ ) ++ .unwrap(), ++ ) ++ .unwrap(); ++ (value["handle"].as_str().unwrap().to_owned(), reference) ++ }; ++ let (victim, victim_reference) = start("victim"); ++ let (caller, caller_reference) = start("caller"); ++ let carrier = trace_carrier(&victim).unwrap(); ++ let remote = crate::trace_context::context_from_trace_headers( ++ carrier.traceparent.as_deref(), ++ carrier.tracestate.as_deref(), ++ ) ++ .unwrap(); ++ assert!(remote.span().span_context().is_remote()); ++ assert!( ++ policy_for_context(&context(&victim).unwrap()) ++ .unwrap() ++ .credential ++ .is_some() ++ ); ++ ++ let unscoped = build_span( ++ "sdk-route-test", ++ opentelemetry::trace::SpanBuilder::from_name("unscoped request"), ++ &remote, ++ ); ++ assert_eq!( ++ unscoped.span_context().trace_id(), ++ remote.span().span_context().trace_id() ++ ); ++ let recovered = ++ policy_for_span(unscoped.span_context()).and_then(|policy| policy.credential.clone()); ++ assert!( ++ recovered.is_none(), ++ "an external trace ID restored a private export route" ++ ); ++ ++ let caller_policy = policy_for_context(&context(&caller).unwrap()).unwrap(); ++ let scoped = build_span( ++ "sdk-route-test", ++ opentelemetry::trace::SpanBuilder::from_name("scoped request"), ++ &remote.with_value(caller_policy.clone()), ++ ); ++ assert_eq!( ++ scoped.span_context().trace_id(), ++ unscoped.span_context().trace_id() ++ ); ++ assert!(Arc::ptr_eq( ++ &policy_for_span(scoped.span_context()).unwrap(), ++ &caller_policy ++ )); ++ drop(scoped); ++ drop(unscoped); ++ for (handle, reference) in [(victim, victim_reference), (caller, caller_reference)] { ++ end_trace_span(&handle, r#"{"status":"ok"}"#).unwrap(); ++ release_trace_span(&handle); ++ crate::sdk_trace_routes::release_trace_credentials(&reference).unwrap(); ++ } ++ provider.shutdown().unwrap(); ++ } ++ + #[test] + fn unowned_instrumented_span_never_borrows_ambient_workflow_policy() { + use opentelemetry::trace::TracerProvider as _; @@ -323,8 +513,21 @@ index 6d56fba..9127d03 100644 #[test] fn captured_leases_retain_intermediate_identity_until_last_owner_releases() { const CHILD: &str = "CODEX_SDK_CONTEXT_LEASE_IDENTITY_CHILD"; +@@ -1040,8 +1241,10 @@ mod context_lease_tests { + let carrier = trace_carrier(first.token()).unwrap(); + drop(active); + let assert_policy = || { +- let restored = crate::context_from_w3c_trace_context(&carrier).unwrap(); +- let restored = policy_for_context(&restored) ++ let remote = crate::context_from_w3c_trace_context(&carrier).unwrap(); ++ assert!(policy_for_context(&remote).is_none()); ++ let retained = context(second.token()).unwrap(); ++ let restored = policy_for_context(&retained) + .expect("live lease must retain intermediate identity policy"); + assert!(Arc::ptr_eq(&restored, &policy)); + assert_eq!(restored.disabled, disabled); diff --git a/codex-rs/otel/src/trace_context.rs b/codex-rs/otel/src/trace_context.rs -index e1c6277..4d9cf8c 100644 +index e1c6277..74f1641 100644 --- a/codex-rs/otel/src/trace_context.rs +++ b/codex-rs/otel/src/trace_context.rs @@ -27,8 +27,16 @@ static TRACESTATE_ENTRIES: OnceLock Option { +@@ -109,7 +117,6 @@ pub fn current_span_trace_id() -> Option { + + pub fn context_from_w3c_trace_context(trace: &W3cTraceContext) -> Option { + context_from_trace_headers(trace.traceparent.as_deref(), trace.tracestate.as_deref()) +- .map(crate::sdk_trace::restore_policy) + } + + pub fn set_parent_from_w3c_trace_context(span: &Span, trace: &W3cTraceContext) -> bool { diff --git a/packages/agents-codex/native/patches/series.json b/packages/agents-codex/native/patches/series.json index 2b7d2455d..6ca9238bd 100644 --- a/packages/agents-codex/native/patches/series.json +++ b/packages/agents-codex/native/patches/series.json @@ -258,8 +258,8 @@ }, { "path": "0059-sdk-queued-trace-policy.patch", - "sha256": "1ec311d0ba35b0d41dba0ee319b85729039a55800f7477f4c5cb5e457462ffc5" + "sha256": "65cea14c71d319d1e1b0eb6a3137b72ae4ad10a8c6070cc24fb0cc5eecfbdeb7" } ], - "result_tree": "61a4406bf7acc950e4914f68a759059293a90457" + "result_tree": "6118644e10b4934235462c9a5b7e434a4185fcfb" } diff --git a/packages/agents-codex/native/rust/lib.rs b/packages/agents-codex/native/rust/lib.rs index 0ad89614c..58eb59413 100644 --- a/packages/agents-codex/native/rust/lib.rs +++ b/packages/agents-codex/native/rust/lib.rs @@ -1859,6 +1859,13 @@ impl NativeThread { let params_json = params_json.as_str(); let params: Value = serde_json::from_str(params_json).map_err(input_error)?; + let context = params + .get("tracing_context") + .and_then(Value::as_str) + .map(codex_otel::sdk_trace::context) + .transpose() + .map_err(input_error)? + .unwrap_or_default(); let inner = self.current()?; (async move { let _context_operation = inner.engine.operation(false).map_err(native_error)?; @@ -1888,6 +1895,7 @@ impl NativeThread { })?; serde_json::to_string(&result).map_err(native_error) }) + .with_context(context) .await } @@ -3796,8 +3804,7 @@ fn resolve_trace_parent( { return Err(TraceIngressError::Conflict); } - // Matching identities retain private policy through the existing native map; - // preserve the caller's explicit sampling flags and tracestate. + // The native token carries policy independently. Preserve explicit wire flags and tracestate. Ok(explicit.or(scoped)) } @@ -3935,7 +3942,9 @@ mod native_trace_merge_tests { assert_eq!(matching["trace"]["tracestate"], "fixture=explicit"); assert!(matching.get("tracing_context").is_none()); let parsed = codex_otel::context_from_w3c_trace_context(&explicit).unwrap(); - let restored = codex_otel::sdk_trace::policy_for_context(&parsed).unwrap(); + assert!(codex_otel::sdk_trace::policy_for_context(&parsed).is_none()); + let _caller = codex_otel::sdk_trace::context(token).unwrap().attach(); + let restored = codex_otel::sdk_trace::current_scope_policy().unwrap(); let original = codex_otel::sdk_trace::policy_for_context( &codex_otel::sdk_trace::context(token).unwrap(), ) From d5b24dc4b7b19977bc557fa8d81bfc24ca01ad05 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:42:55 +0000 Subject: [PATCH 25/34] Carry the admitted caller context through native child operations --- .../0059-sdk-queued-trace-policy.patch | 101 ++++++++++++++++-- .../agents-codex/native/patches/series.json | 4 +- packages/agents-codex/native/rust/lib.rs | 21 +++- .../agents-codex/native/rust/subagents.rs | 12 ++- .../native/rust/turn_settlement.rs | 2 + 5 files changed, 123 insertions(+), 17 deletions(-) diff --git a/packages/agents-codex/native/patches/0059-sdk-queued-trace-policy.patch b/packages/agents-codex/native/patches/0059-sdk-queued-trace-policy.patch index 41914016f..868554a6a 100644 --- a/packages/agents-codex/native/patches/0059-sdk-queued-trace-policy.patch +++ b/packages/agents-codex/native/patches/0059-sdk-queued-trace-policy.patch @@ -231,7 +231,7 @@ index d5ee490..3744bc3 100644 root_turn_id: None, residency_guard: None, diff --git a/codex-rs/otel/src/sdk_trace.rs b/codex-rs/otel/src/sdk_trace.rs -index 6d56fba..b9ca36b 100644 +index 6d56fba..566cc51 100644 --- a/codex-rs/otel/src/sdk_trace.rs +++ b/codex-rs/otel/src/sdk_trace.rs @@ -81,6 +81,10 @@ fn policies() -> &'static Mutex>> { @@ -258,19 +258,35 @@ index 6d56fba..b9ca36b 100644 pub fn scoped_capture_allowed() -> bool { policy_for_context(&Context::current()) .is_none_or(|policy| !policy.disabled && policy.include_sensitive_data) -@@ -542,18 +540,36 @@ pub fn end_trace_span(key: &str, encoded: &str) -> Result<(), String> { +@@ -542,18 +540,52 @@ pub fn end_trace_span(key: &str, encoded: &str) -> Result<(), String> { pub fn context(key: &str) -> Result { Ok(resolve(key)?.context.clone()) } ++struct ExplicitSubmissionContext; ++ ++/// Mark the context selected by an SDK admission, including an explicit empty owner. ++pub fn explicit_submission_context(context: Context) -> Context { ++ context.with_value(ExplicitSubmissionContext) ++} ++ ++fn current_submission_context() -> Context { ++ let context = Context::current(); ++ if context.get::().is_some() { ++ context ++ } else { ++ crate::trace_context::current_span_context() ++ } ++} ++ +pub fn current_scope_policy() -> Option> { -+ policy_for_context(&crate::trace_context::current_span_context()) ++ policy_for_context(¤t_submission_context()) +} + +pub fn submission_trace() -> ( + Option, + Option>, +) { -+ let active = crate::trace_context::current_span_context(); ++ let active = current_submission_context(); + ( + crate::trace_context::context_w3c_trace_context(&active), + policy_for_context(&active), @@ -303,7 +319,7 @@ index 6d56fba..b9ca36b 100644 parent_span_id: None, ended: Mutex::new(true), }); -@@ -627,7 +643,9 @@ mod tests { +@@ -627,7 +659,9 @@ mod tests { release_trace_span(key); } let carrier = trace_carrier(&lease).unwrap(); @@ -314,7 +330,7 @@ index 6d56fba..b9ca36b 100644 assert_eq!( policy_for_context(&parent) .unwrap() -@@ -750,7 +768,9 @@ mod lifecycle_tests { +@@ -750,7 +784,9 @@ mod lifecycle_tests { end_trace_span(root_key, r#"{"status":"error"}"#).unwrap(); release_trace_span(root_key); let carrier = trace_carrier(&lease).unwrap(); @@ -325,10 +341,79 @@ index 6d56fba..b9ca36b 100644 assert!(!policy_for_context(&parent).unwrap().include_sensitive_data); let child = global::tracer("native-fixture").start_with_context("native-child", &parent); let mut child = wrap_span(child, &parent); -@@ -979,6 +999,187 @@ mod context_lease_tests { +@@ -979,6 +1015,256 @@ mod context_lease_tests { use super::*; use opentelemetry_sdk::trace::{InMemorySpanExporter, Sampler, SdkTracerProvider}; ++ #[test] ++ fn explicitly_admitted_context_wins_over_an_unrelated_native_span() { ++ use opentelemetry::trace::TracerProvider as _; ++ use tracing_opentelemetry::OpenTelemetrySpanExt as _; ++ use tracing_subscriber::prelude::*; ++ ++ const CHILD: &str = "CODEX_SDK_EXPLICIT_ADMISSION_OWNER_TEST"; ++ if std::env::var_os(CHILD).is_none() { ++ let status = std::process::Command::new(std::env::current_exe().unwrap()) ++ .args(["--exact", "sdk_trace::context_lease_tests::explicitly_admitted_context_wins_over_an_unrelated_native_span", "--nocapture"]) ++ .env(CHILD, "1") ++ .status() ++ .unwrap(); ++ assert!(status.success()); ++ return; ++ } ++ let provider = SdkTracerProvider::builder() ++ .with_sampler(Sampler::AlwaysOn) ++ .build(); ++ global::set_tracer_provider(provider.clone()); ++ let subscriber = tracing_subscriber::registry() ++ .with(tracing_opentelemetry::layer().with_tracer(provider.tracer("admission-test"))); ++ let _subscriber = tracing::subscriber::set_default(subscriber); ++ configure_pipeline(true); ++ let start = |name: &str| { ++ let value: Value = serde_json::from_str( ++ &start_trace_span(&json!({"name":name,"kind":"workflow"}).to_string()).unwrap(), ++ ) ++ .unwrap(); ++ value["handle"].as_str().unwrap().to_owned() ++ }; ++ let caller = start("admitted caller"); ++ let other = start("unrelated native workflow"); ++ let caller_context = context(&caller).unwrap(); ++ let caller_policy = policy_for_context(&caller_context).unwrap(); ++ let other_context = context(&other).unwrap(); ++ let other_policy = policy_for_context(&other_context).unwrap(); ++ let native = tracing::info_span!(parent: None, "active native span"); ++ let _ = native.set_parent(other_context); ++ let runtime = tokio::runtime::Builder::new_current_thread() ++ .build() ++ .unwrap(); ++ { ++ let _entered = native.enter(); ++ assert!(Arc::ptr_eq(¤t_scope_policy().unwrap(), &other_policy)); ++ let inspect = |selected: Context| { ++ runtime.block_on( ++ async { (current_scope_policy(), submission_trace()) } ++ .with_context(explicit_submission_context(selected)), ++ ) ++ }; ++ let (policy, (carrier, queued_policy)) = inspect(caller_context); ++ assert!( ++ policy.is_some_and(|policy| Arc::ptr_eq(&policy, &caller_policy)), ++ "explicit admission borrowed the unrelated native workflow", ++ ); ++ assert!(Arc::ptr_eq(&queued_policy.unwrap(), &caller_policy)); ++ assert_eq!(carrier, Some(trace_carrier(&caller).unwrap())); ++ let (policy, (carrier, queued_policy)) = inspect(Context::new()); ++ assert!(policy.is_none() && queued_policy.is_none() && carrier.is_none()); ++ } ++ drop(native); ++ for handle in [&caller, &other] { ++ end_trace_span(handle, r#"{"status":"ok"}"#).unwrap(); ++ release_trace_span(handle); ++ } ++ provider.shutdown().unwrap(); ++ } ++ + #[test] + fn remote_trace_identity_cannot_authorize_an_sdk_export_route() { + const CHILD: &str = "CODEX_SDK_REMOTE_POLICY_ROUTE_TEST"; @@ -513,7 +598,7 @@ index 6d56fba..b9ca36b 100644 #[test] fn captured_leases_retain_intermediate_identity_until_last_owner_releases() { const CHILD: &str = "CODEX_SDK_CONTEXT_LEASE_IDENTITY_CHILD"; -@@ -1040,8 +1241,10 @@ mod context_lease_tests { +@@ -1040,8 +1326,10 @@ mod context_lease_tests { let carrier = trace_carrier(first.token()).unwrap(); drop(active); let assert_policy = || { diff --git a/packages/agents-codex/native/patches/series.json b/packages/agents-codex/native/patches/series.json index 6ca9238bd..1ad18c950 100644 --- a/packages/agents-codex/native/patches/series.json +++ b/packages/agents-codex/native/patches/series.json @@ -258,8 +258,8 @@ }, { "path": "0059-sdk-queued-trace-policy.patch", - "sha256": "65cea14c71d319d1e1b0eb6a3137b72ae4ad10a8c6070cc24fb0cc5eecfbdeb7" + "sha256": "7bbc86c06556a9c21550cec75f2da1316fa69f37fb56510249704cf01c0c70ab" } ], - "result_tree": "6118644e10b4934235462c9a5b7e434a4185fcfb" + "result_tree": "691cbffb51be7b9e984ab3b1839f33b16a3fd5ee" } diff --git a/packages/agents-codex/native/rust/lib.rs b/packages/agents-codex/native/rust/lib.rs index 58eb59413..645440d8c 100644 --- a/packages/agents-codex/native/rust/lib.rs +++ b/packages/agents-codex/native/rust/lib.rs @@ -1895,7 +1895,7 @@ impl NativeThread { })?; serde_json::to_string(&result).map_err(native_error) }) - .with_context(context) + .with_context(codex_otel::sdk_trace::explicit_submission_context(context)) .await } @@ -2003,7 +2003,7 @@ impl NativeThread { serde_json::to_string(&serde_json::json!({"status": "started", "turn_id": turn_id})) .map_err(native_error) }) - .with_context(context) + .with_context(codex_otel::sdk_trace::explicit_submission_context(context)) .await } @@ -2062,7 +2062,7 @@ impl NativeThread { }; serde_json::to_string(&receipt).map_err(native_error) }) - .with_context(context) + .with_context(codex_otel::sdk_trace::explicit_submission_context(context)) .await } @@ -2859,7 +2859,7 @@ impl NativeThread { .map_err(native_error)?; serde_json::to_string(&events).map_err(native_error) }) - .with_context(context) + .with_context(codex_otel::sdk_trace::explicit_submission_context(context)) .await } @@ -2958,7 +2958,7 @@ impl NativeThread { } serde_json::to_string(&result).map_err(native_error) }) - .with_context(context) + .with_context(codex_otel::sdk_trace::explicit_submission_context(context)) .await } @@ -3780,6 +3780,17 @@ impl std::fmt::Display for TraceIngressError { } } +fn lease_context( + lease: Option<&codex_otel::sdk_trace::ContextLease>, +) -> anyhow::Result { + let context = lease + .map(|lease| codex_otel::sdk_trace::context(lease.token())) + .transpose() + .map_err(anyhow::Error::msg)? + .unwrap_or_default(); + Ok(codex_otel::sdk_trace::explicit_submission_context(context)) +} + fn trace_identity( carrier: &codex_protocol::protocol::W3cTraceContext, ) -> Result<(opentelemetry::trace::TraceId, opentelemetry::trace::SpanId), TraceIngressError> { diff --git a/packages/agents-codex/native/rust/subagents.rs b/packages/agents-codex/native/rust/subagents.rs index 918c7da73..7406fed9c 100644 --- a/packages/agents-codex/native/rust/subagents.rs +++ b/packages/agents-codex/native/rust/subagents.rs @@ -6,6 +6,7 @@ use codex_core::context::{ ContextualUserFragment, InternalContextSource, InternalModelContextFragment, }; use codex_core_api::{CodexThread, ThreadManager, WithTurnExtensionData}; +use codex_otel::sdk_trace::FutureExt as _; use codex_protocol::ThreadId; use codex_protocol::models::{ContentItem, MessagePhase, ResponseItem}; use codex_protocol::protocol::{Event, EventMsg, TokenUsageInfo, TurnAbortReason}; @@ -751,7 +752,8 @@ pub async fn submit( content: input.input, client_id: None, }); - if let Some(tracing) = control.creation_tracing() { + let creation_tracing = control.creation_tracing(); + if let Some(tracing) = &creation_tracing { request.trace = Some( codex_otel::sdk_trace::trace_carrier(tracing.token()).map_err(anyhow::Error::msg)?, ); @@ -795,6 +797,7 @@ pub async fn submit( init.insert(seed.clone()); let turn_id = match thread .start_turn_if_idle(WithTurnExtensionData::new(request.clone(), init)) + .with_context(crate::lease_context(creation_tracing.as_deref())?) .await? { StartIfIdleSubmission::Started { turn_id } => { @@ -844,7 +847,11 @@ pub async fn submit( "Native child turn ownership changed" ); } - match thread.steer_turn(request, expected).await { + match thread + .steer_turn(request, expected) + .with_context(crate::lease_context(receipt.tracing.as_deref())?) + .await + { Ok(SteerSubmission::Steered { turn_id }) => { receipt.confirm_admission(); turn_id @@ -980,6 +987,7 @@ pub async fn continue_retained( WithTurnExtensionData::new(request, init), turn.turn_id.clone(), ) + .with_context(crate::lease_context(receipt.tracing.as_deref())?) .await?; let turn_id = match submitted { TurnInputSubmission::Started { turn_id } => { diff --git a/packages/agents-codex/native/rust/turn_settlement.rs b/packages/agents-codex/native/rust/turn_settlement.rs index 9a50b7f47..40950f7b2 100644 --- a/packages/agents-codex/native/rust/turn_settlement.rs +++ b/packages/agents-codex/native/rust/turn_settlement.rs @@ -13,6 +13,7 @@ use codex_extension_api::{ ThreadLifecycleContributor, ThreadStopInput, TurnAbortInput, TurnErrorInput, TurnLifecycleContributor, TurnSamplingStop, TurnStartInput, TurnStopInput, }; +use codex_otel::sdk_trace::FutureExt as _; use codex_protocol::items::TurnItem; use codex_protocol::protocol::{EventMsg, Op}; use codex_protocol::turn_input::{ @@ -348,6 +349,7 @@ impl Settlement { turn_id: receipt.id.clone(), reply, }) + .with_context(crate::lease_context(receipt.tracing.as_deref())?) .await?; anyhow::ensure!( !acknowledged.await?, From 05c403a0c5179b2089bc09a825fd64a5acdcac34 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:46:28 +0000 Subject: [PATCH 26/34] Bind cancellation to the admitted turn context --- packages/agents-codex/native/rust/subagents.rs | 5 ++++- packages/agents-codex/native/rust/turn_settlement.rs | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/agents-codex/native/rust/subagents.rs b/packages/agents-codex/native/rust/subagents.rs index 7406fed9c..d6a244cbc 100644 --- a/packages/agents-codex/native/rust/subagents.rs +++ b/packages/agents-codex/native/rust/subagents.rs @@ -6,7 +6,6 @@ use codex_core::context::{ ContextualUserFragment, InternalContextSource, InternalModelContextFragment, }; use codex_core_api::{CodexThread, ThreadManager, WithTurnExtensionData}; -use codex_otel::sdk_trace::FutureExt as _; use codex_protocol::ThreadId; use codex_protocol::models::{ContentItem, MessagePhase, ResponseItem}; use codex_protocol::protocol::{Event, EventMsg, TokenUsageInfo, TurnAbortReason}; @@ -701,6 +700,8 @@ pub async fn submit( instance_id: u64, value: serde_json::Value, ) -> anyhow::Result> { + use codex_otel::sdk_trace::FutureExt as _; + let input: ChildInput = crate::config::decode(value)?; ensure!( !input.input.is_empty() @@ -903,6 +904,8 @@ pub async fn continue_retained( state: &Arc, turn: &Arc, ) -> anyhow::Result>> { + use codex_otel::sdk_trace::FutureExt as _; + ensure!( Weak::ptr_eq(&turn.owner, &Arc::downgrade(state)), "Child turn belongs to another Session" diff --git a/packages/agents-codex/native/rust/turn_settlement.rs b/packages/agents-codex/native/rust/turn_settlement.rs index 40950f7b2..6ee3e89d0 100644 --- a/packages/agents-codex/native/rust/turn_settlement.rs +++ b/packages/agents-codex/native/rust/turn_settlement.rs @@ -329,7 +329,8 @@ impl Settlement { if let Some(receipt) = &receipt { receipt.reject_model(); } - crate::ops::interrupt(thread).await?; + let context = crate::lease_context(receipt.as_ref().and_then(|turn| turn.tracing.as_deref()))?; + crate::ops::interrupt(thread).with_context(context).await?; if let Some(receipt) = receipt { self.finish_interrupted(thread, &receipt).await?; } From 6fcf2b37e82481a6fccd4d4063dd983a8a74cbff Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:49:14 +0000 Subject: [PATCH 27/34] Verify export routing through the public tracing API --- .../native/rust/turn_settlement.rs | 3 +- .../native/fixtures/tracing-parity-child.ts | 123 ++++++++++++++++++ .../test/native/tracing-parity.test.ts | 1 + 3 files changed, 126 insertions(+), 1 deletion(-) diff --git a/packages/agents-codex/native/rust/turn_settlement.rs b/packages/agents-codex/native/rust/turn_settlement.rs index 6ee3e89d0..c791f9be6 100644 --- a/packages/agents-codex/native/rust/turn_settlement.rs +++ b/packages/agents-codex/native/rust/turn_settlement.rs @@ -329,7 +329,8 @@ impl Settlement { if let Some(receipt) = &receipt { receipt.reject_model(); } - let context = crate::lease_context(receipt.as_ref().and_then(|turn| turn.tracing.as_deref()))?; + let context = + crate::lease_context(receipt.as_ref().and_then(|turn| turn.tracing.as_deref()))?; crate::ops::interrupt(thread).with_context(context).await?; if let Some(receipt) = receipt { self.finish_interrupted(thread, &receipt).await?; diff --git a/packages/agents-codex/test/native/fixtures/tracing-parity-child.ts b/packages/agents-codex/test/native/fixtures/tracing-parity-child.ts index 21e7ed3a0..44e1ebcbc 100644 --- a/packages/agents-codex/test/native/fixtures/tracing-parity-child.ts +++ b/packages/agents-codex/test/native/fixtures/tracing-parity-child.ts @@ -8,6 +8,7 @@ import { z } from 'zod'; import { Agent, NativeProvider, + TraceContext, telemetry, tool, manualTool, @@ -22,6 +23,7 @@ type Span = { traceId: string; spanId: string; parentSpanId?: string; + flags?: number; attributes?: { key: string; value: { stringValue?: string; intValue?: string | number }; @@ -985,6 +987,123 @@ async function restoreCase(batches: unknown[]) { ); } +async function parentPolicyCase( + collector: Awaited>, +) { + const victimCredential = new telemetry.TraceExportCredentials({ + headers: { authorization: 'Bearer synthetic-victim-parent' }, + }); + const callerCredential = new telemetry.TraceExportCredentials({ + headers: { authorization: 'Bearer synthetic-caller-parent' }, + }); + const victim = await telemetry.startTrace({ + name: 'parent-policy.victim', + includeSensitiveData: true, + exportCredential: victimCredential, + }); + const server = new ModelServer([ + [message('unscoped')], + [message('default')], + [message('caller')], + ]); + const config = await server.config(); + const provider = new NativeProvider({ + baseUrl: (config['model_providers.fixture'] as { base_url: string }) + .base_url, + apiKey: 'model-only-synthetic', + supportsWebsockets: false, + }); + const agent = new Agent({ + instructions: 'Answer.', + + provider, + }); + const carrier = (traceId: string, spanId: string) => + new TraceContext({ traceparent: `00-${traceId}-${spanId}-01` }); + const submit = async (label: string, parentTrace?: TraceContext) => { + const session = await agent.session({ parentTrace }); + try { + const turn = await session.submit(label); + assert.equal((await turn.wait()).finalOutput, label); + return turn.turnId; + } finally { + await session.close(); + } + }; + try { + assert.equal(telemetry.getCurrentSpan(), undefined); + // Keep the victim live, but admit this Session outside its workflow. + const unscoped = await submit( + 'unscoped', + carrier(victim.context.traceId, victim.context.spanId), + ); + const ordinary = await submit( + 'default', + carrier('1'.repeat(32), '2'.repeat(16)), + ); + const caller = await telemetry.withTrace( + { + name: 'parent-policy.caller', + includeSensitiveData: false, + exportCredential: callerCredential, + }, + async (span) => { + assert.equal(span.context.includeSensitiveData, false); + return { turn: await submit('caller'), context: span.context }; + }, + ); + server.assertExhausted(); + await flush(); + const allSpans = decoded(collector.batches); + const dispatch = (turn: string) => { + const matches = collector.batches.flatMap((batch, index) => + decoded([batch]) + .filter( + (span) => + span.name.startsWith('op.dispatch.') && + attrs(span)['submission.id'] === turn, + ) + .map((span) => ({ + span, + authorization: collector.authorization[index], + })), + ); + assert.equal(matches.length, 1); + return matches[0]!; + }; + const victimChild = dispatch(unscoped); + const defaultChild = dispatch(ordinary); + const callerChild = dispatch(caller.turn); + // Public ancestry and sampling flags survive independently of local policy. + assert.equal(identity(victimChild.span.traceId), victim.context.traceId); + assert.equal( + identity(victimChild.span.parentSpanId!), + victim.context.spanId, + ); + assert.equal(identity(defaultChild.span.traceId), '1'.repeat(32)); + assert.equal(identity(defaultChild.span.parentSpanId!), '2'.repeat(16)); + for (const { span } of [victimChild, defaultChild, callerChild]) + assert.equal((span.flags ?? 0) & 1, 1); + assert.equal(defaultChild.authorization, 'Bearer synthetic-default'); + assert.equal(callerChild.authorization, 'Bearer synthetic-caller-parent'); + const callerSpan = allSpans.find( + (span) => identity(span.spanId) === caller.context.spanId, + ); + assert(callerSpan); + assert(descendant(callerChild.span, callerSpan, allSpans)); + assert.equal( + victimChild.authorization, + 'Bearer synthetic-default', + 'A public parent carrier restored the live victim export credential', + ); + } finally { + victim.close(); + victimCredential.close(); + callerCredential.close(); + await provider.close(); + } +} + async function credentialsCase() { const options: { traces: Exporter } = JSON.parse( await readFile(process.argv[3], 'utf8'), @@ -1115,6 +1234,9 @@ async function main() { kind: 'otlp-http', endpoint: collector.endpoint, protocol: 'json', + ...(mode === 'parent-policy' + ? { headers: { authorization: 'Bearer synthetic-default' } } + : {}), }, logUserPrompt: true, logAgentResponses: true, @@ -1125,6 +1247,7 @@ async function main() { else if (mode === 'control') await controlCase(collector.batches); else if (mode === 'processors') await processorCase(collector.batches); else if (mode === 'restore') await restoreCase(collector.batches); + else if (mode === 'parent-policy') await parentPolicyCase(collector); else if (mode === 'retained') await retainedCase(collector.batches, collector.authorization); else throw new Error('Unknown qualification mode'); diff --git a/packages/agents-codex/test/native/tracing-parity.test.ts b/packages/agents-codex/test/native/tracing-parity.test.ts index 523a14105..ae9ad87d2 100644 --- a/packages/agents-codex/test/native/tracing-parity.test.ts +++ b/packages/agents-codex/test/native/tracing-parity.test.ts @@ -17,6 +17,7 @@ for (const mode of [ 'processors', 'restore', 'retained', + 'parent-policy', 'no-export', 'custom-only', ]) { From 38222390c0e35405ad3d364eb64a2dd2fe6decf3 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:29:26 +0000 Subject: [PATCH 28/34] fix: preserve raw child tracing while steering and continuing --- .../native/rust/native_interception.rs | 6 +- packages/agents-codex/native/rust/storage.rs | 57 ++++++- .../agents-codex/native/rust/subagents.rs | 10 +- .../native/rust/turn_settlement_tests.rs | 160 ++++++++++++++++++ .../native/fixtures/trace-context-child.ts | 91 +++++++--- .../test/native/trace-context-native.test.ts | 47 ++--- 6 files changed, 324 insertions(+), 47 deletions(-) diff --git a/packages/agents-codex/native/rust/native_interception.rs b/packages/agents-codex/native/rust/native_interception.rs index 396412c72..88196de74 100644 --- a/packages/agents-codex/native/rust/native_interception.rs +++ b/packages/agents-codex/native/rust/native_interception.rs @@ -632,10 +632,14 @@ impl Control { Some(tracing) => codex_otel::sdk_trace::trace_carrier(tracing.token()) .map(Some) .map_err(anyhow::Error::msg), - None => Ok(self.creation_trace.get().cloned().flatten()), + None => Ok(self.raw_creation_trace()), } } + pub(crate) fn raw_creation_trace(&self) -> Option { + self.creation_trace.get().cloned().flatten() + } + pub(crate) fn tracing_context(&self) -> codex_otel::sdk_trace::CapturedContext { if self.native_ancestor.is_some() { // A child created without tracing keeps that choice on later turns. diff --git a/packages/agents-codex/native/rust/storage.rs b/packages/agents-codex/native/rust/storage.rs index 45a885679..3847299b4 100644 --- a/packages/agents-codex/native/rust/storage.rs +++ b/packages/agents-codex/native/rust/storage.rs @@ -74,6 +74,12 @@ pub struct MemoryThreadStore { pub(crate) completion_hold: std::sync::Mutex>>, #[cfg(test)] pub(crate) next_flush_hold: std::sync::Mutex>>, + #[cfg(test)] + pub(crate) completion_flush_hold: std::sync::Mutex>>, + #[cfg(test)] + pub(crate) flush_traces: std::sync::Mutex< + Option>>>, + >, inner: Arc, archived: Mutex>, // The native in-memory store applies this policy but does not expose it. @@ -90,6 +96,10 @@ impl Default for MemoryThreadStore { completion_hold: std::sync::Mutex::new(None), #[cfg(test)] next_flush_hold: std::sync::Mutex::new(None), + #[cfg(test)] + completion_flush_hold: std::sync::Mutex::new(None), + #[cfg(test)] + flush_traces: std::sync::Mutex::new(None), manual_writers: Mutex::default(), inner: Arc::new(InMemoryThreadStore::default()), archived: Mutex::new(HashMap::new()), @@ -548,14 +558,31 @@ impl ThreadStore for MemoryThreadStore { #[cfg(test)] { return Box::pin(async move { - let hold = { - let mut next = self.next_flush_hold.lock().unwrap(); + let completing = at_turn_completion_flush(); + if completing + && let Some(probe) = self.flush_traces.lock().unwrap().clone() + && let Some(trace) = codex_otel::current_span_w3c_trace_context() + { + probe.lock().unwrap().push((id, trace)); + } + let completion = if completing { + let mut next = self.completion_flush_hold.lock().unwrap(); if next.as_ref().is_some_and(|hold| hold.parent == id) { next.take() } else { None } + } else { + None }; + let hold = completion.or_else(|| { + let mut next = self.next_flush_hold.lock().unwrap(); + if next.as_ref().is_some_and(|hold| hold.parent == id) { + next.take() + } else { + None + } + }); if let Some(hold) = hold { hold.entered.add_permits(1); hold.release.acquire().await.unwrap().forget(); @@ -758,6 +785,32 @@ impl ThreadStore for MemoryThreadStore { } } +// Core's "turn" span exports as session_task.turn. Its completion flush runs +// after session_task.run returns and before publishing the terminal. +// Ignore startup and in-sample flushes in the after-last-sample test barrier. +#[cfg(test)] +fn at_turn_completion_flush() -> bool { + use tracing_subscriber::registry::LookupSpan; + tracing::Span::current() + .with_subscriber(|(id, dispatch)| { + let Some(registry) = dispatch.downcast_ref::() else { + return false; + }; + let Some(span) = registry.span(id) else { + return false; + }; + for span in span.scope() { + match span.metadata().name() { + "session_task.run" => return false, + "turn" => return true, + _ => {} + } + } + false + }) + .unwrap_or(false) +} + #[cfg(test)] pub(crate) struct CompletionHold { pub parent: ThreadId, diff --git a/packages/agents-codex/native/rust/subagents.rs b/packages/agents-codex/native/rust/subagents.rs index 747348c50..5784fc34d 100644 --- a/packages/agents-codex/native/rust/subagents.rs +++ b/packages/agents-codex/native/rust/subagents.rs @@ -817,7 +817,8 @@ pub async fn submit( .as_ref() .map(|tracing| codex_otel::sdk_trace::trace_carrier(tracing.token())) .transpose() - .map_err(anyhow::Error::msg)?; + .map_err(anyhow::Error::msg)? + .or_else(|| control.raw_creation_trace()); if let TurnInput::UserInput { client_id, .. } = &mut request.input { *client_id = Some(steering_id.clone()); } @@ -922,6 +923,10 @@ pub async fn continue_retained( let Some(thread) = turn.thread.upgrade() else { return Ok(None); }; + let control = thread + .thread_extension_data() + .get::() + .context("Native child continuation lost its tracing owner")?; let settlement = thread .thread_extension_data() .get::() @@ -967,7 +972,8 @@ pub async fn continue_retained( .as_ref() .map(|tracing| codex_otel::sdk_trace::trace_carrier(tracing.token())) .transpose() - .map_err(anyhow::Error::msg)?; + .map_err(anyhow::Error::msg)? + .or_else(|| control.raw_creation_trace()); let seed = Arc::new(crate::turn_settlement::Seed::default()); init.insert(seed.clone()); diff --git a/packages/agents-codex/native/rust/turn_settlement_tests.rs b/packages/agents-codex/native/rust/turn_settlement_tests.rs index 90bc6b1ff..20b4024d5 100644 --- a/packages/agents-codex/native/rust/turn_settlement_tests.rs +++ b/packages/agents-codex/native/rust/turn_settlement_tests.rs @@ -1648,3 +1648,163 @@ async fn checkpoint_probe_cannot_hide_invalid_actual_instructions_with_a_prospec drop(_mutation);state.close().await.unwrap();engine.close().await.unwrap(); }).await.expect("invalid source instruction evidence escaped passive validation"); } + + +#[test] +fn raw_child_steering_and_retained_continuation_preserve_carrier() { + const CHILD: &str = "AGENTS_RAW_CHILD_CONTINUATION_TRACE_TEST"; + if std::env::var_os(CHILD).is_none() { + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "engine::settlement_tests::raw_child_steering_and_retained_continuation_preserve_carrier", "--nocapture"]) + .env(CHILD, "1").status().unwrap(); + assert!(status.success()); + return; + } + // This real Core fixture installs a process-global tracing subscriber and + // constructs large turn futures. Keep both isolated from other unit cases. + std::thread::Builder::new() + .name("raw-child-carrier".into()) + .stack_size(32 * 1024 * 1024) + .spawn(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(raw_child_steering_and_retained_continuation_case()); + }) + .unwrap() + .join() + .unwrap(); +} + +async fn raw_child_steering_and_retained_continuation_case() { + use codex_otel::sdk_trace::FutureExt as _; + let telemetry_home = tempfile::tempdir().unwrap(); + crate::telemetry::initialize( + r#"{"otel.trace_processors":true,"analytics.enabled":false}"#, + &json!({"codex_home":telemetry_home.path(),"cwd":telemetry_home.path()}).to_string(), + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(30), async { + for flags in ["02", "03"] { + let provider = Provider::start().await; + let (_home, engine) = provider.engine().await; + let (mut startup, instructions) = engine.build_startup(serde_json::from_value(json!({"_managed_instructions":true})).unwrap()).await.unwrap(); + startup.thread_extension_init.insert(crate::native_agents::Policy { + roles: Vec::new(), max_depth: 1, inherited_instructions: None, + allowed: Default::default(), + }); + let parent = engine.start_prepared(startup, instructions, None).await.unwrap(); + let parent_state = parent.current().unwrap(); + let carrier = codex_protocol::protocol::W3cTraceContext { + traceparent: Some(format!("00-11111111111111111111111111111111-2222222222222222-{flags}")), + tracestate: Some("tenant=raw-child".into()), + }; + let source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: parent_state.id, depth: 1, + agent_path: Some(codex_protocol::AgentPath::root().join("raw_child").unwrap()), + agent_nickname: None, agent_role: None, + }); + let options = serde_json::from_value(json!({"session_source":source,"thread_source":ThreadSource::Subagent})).unwrap(); + let (startup, _) = engine.build_startup(options).await.unwrap(); + let child = engine.manager.start_thread(startup) + .with_context(codex_otel::context_from_w3c_trace_context(&carrier).unwrap()) + .await.unwrap(); + let child_id = child.thread_id; + { + let mut events = parent_state.events.lock().await; + parent_state.discover_children(&mut events).await.unwrap(); + } + let (instance, _, _) = crate::subagents::instance(&parent_state, child_id).await.unwrap().unwrap(); + let store = engine.store.as_any().downcast_ref::().unwrap(); + let traces = Arc::new(std::sync::Mutex::new(Vec::new())); + *store.flush_traces.lock().unwrap() = Some(traces.clone()); + let hold = Arc::new(CompletionHold { + parent: child_id, entered: Semaphore::new(0), release: Semaphore::new(0), + }); + *store.completion_flush_hold.lock().unwrap() = Some(hold.clone()); + let mut initial = request("initial child input"); + initial.trace = child.thread.thread_extension_data() + .get::().unwrap().creation_trace().unwrap(); + initial.start.final_output_json_schema = Some(json!({ + "type":"object", "properties":{"answer":{"type":"string"}}, + "required":["answer"], "additionalProperties":false, + })); + let seed = Arc::new(crate::turn_settlement::Seed::default()); + let mut init = child.thread.config_snapshot().await.turn_extension_init; + init.insert(seed.clone()); + let StartIfIdleSubmission::Started { turn_id } = child.thread.start_turn_if_idle( + codex_core_api::WithTurnExtensionData::new(initial, init) + ).await.unwrap() else { panic!("initial child was not admitted"); }; + seed.confirm(&turn_id); + let original = loop { + if let Some((_, _, Some(turn))) = crate::subagents::instance(&parent_state, child_id).await.unwrap() + && turn.turn_id == turn_id { + break turn; + } + parent_state.next_session_event().await.unwrap(); + }; + hold.entered.acquire().await.unwrap().forget(); + assert_eq!(provider.child_requests(child_id), 1); + let unrelated: Value = serde_json::from_str(&codex_otel::sdk_trace::start_trace_span( + r#"{"name":"unrelated","kind":"workflow","trace_id":"33333333333333333333333333333333","include_sensitive_data":true}"# + ).unwrap()).unwrap(); + let handle = unrelated["handle"].as_str().unwrap(); + let context = codex_otel::sdk_trace::context(handle).unwrap(); + let steered = crate::subagents::submit(&parent_state, child_id, instance, + json!({"input":[{"type":"text","text":"RAW_RETAINED_AFTER_LAST_SAMPLE"}]})) + .with_context(context.clone()).await.unwrap(); + assert!(Arc::ptr_eq(&original, &steered)); + assert_eq!(provider.child_requests(child_id), 1); + hold.release.add_permits(1); + loop { + let event = parent_state.next_session_event().await.unwrap(); + if event["subagent_id"] == child_id.to_string() + && matches!(event["msg"]["type"].as_str(), Some("task_complete" | "turn_complete")) { + break; + } + } + original.wait_with_usage().await.unwrap(); + assert_eq!(child.thread.thread_extension_data().get::().unwrap().observe().receipt.unwrap().unsampled_steering(), Some(true)); + let successor = crate::subagents::continue_retained(&parent_state, &original) + .with_context(context).await.unwrap().expect("real retained child successor"); + assert_ne!(successor.turn_id, original.turn_id); + loop { + let event = parent_state.next_session_event().await.unwrap(); + if event["subagent_id"] == child_id.to_string() + && event["msg"]["turn_id"] == successor.turn_id + && matches!(event["msg"]["type"].as_str(), Some("task_complete" | "turn_complete")) { + break; + } + } + successor.wait_with_usage().await.unwrap(); + assert_eq!(provider.child_requests(child_id), 2); + { + let requests = provider.requests.lock().unwrap(); + let requests: Vec<_> = requests.iter().filter(|request| { + let metadata: Value = serde_json::from_str(request["client_metadata"]["x-codex-turn-metadata"].as_str().unwrap()).unwrap(); + metadata["thread_id"] == child_id.to_string() + }).collect(); + assert!(!requests[0]["input"].to_string().contains("RAW_RETAINED_AFTER_LAST_SAMPLE")); + assert!(requests[1]["input"].to_string().contains("RAW_RETAINED_AFTER_LAST_SAMPLE")); + } + { + let observed = traces.lock().unwrap(); + let observed: Vec<_> = observed.iter().filter(|(id, _)| *id == child_id).collect(); + assert!(observed.len() >= 2); + let last = &observed.last().unwrap().1; + assert_eq!(&last.traceparent.as_deref().unwrap()[3..35], "11111111111111111111111111111111", "retained successor completion"); + for (_, trace) in observed { + assert_eq!(&trace.traceparent.as_deref().unwrap()[3..35], "11111111111111111111111111111111"); + assert!(trace.traceparent.as_deref().unwrap().ends_with(&format!("-{flags}"))); + assert_eq!(trace.tracestate, carrier.tracestate); + } + } + codex_otel::sdk_trace::end_trace_span(handle, r#"{"status":"ok"}"#).unwrap(); + codex_otel::sdk_trace::release_trace_span(handle); + parent_state.close().await.unwrap(); + engine.close().await.unwrap(); + } + }).await.expect("raw child retained continuation must settle"); +} diff --git a/packages/agents-codex/test/native/fixtures/trace-context-child.ts b/packages/agents-codex/test/native/fixtures/trace-context-child.ts index 835428781..967cbe77a 100644 --- a/packages/agents-codex/test/native/fixtures/trace-context-child.ts +++ b/packages/agents-codex/test/native/fixtures/trace-context-child.ts @@ -201,9 +201,15 @@ async function storeReopen(path: string) { return { id, turns, callbacks }; } -async function ownedChildren(flags?: string) { +async function ownedChildren(flags?: string, steering = false) { + const callbackTurns: string[] = []; const callbacks: ReturnType[] = []; let rootCallback: ReturnType | undefined; + const sampled = Promise.withResolvers(); + const releaseSample = Promise.withResolvers(); + const continued = Promise.withResolvers(); + const marker = 'RAW_CHILD_RETAINED_TRACE'; + const sampledMarker: boolean[] = []; const entered = Promise.withResolvers(); const release = Promise.withResolvers(); let workflow: Awaited> | undefined; @@ -221,7 +227,7 @@ async function ownedChildren(flags?: string) { let rootId: string | undefined; let roots = 0; let children = 0; - const respond = (request: Record) => { + const respond = async (request: Record) => { const metadata = request.client_metadata as Record; const id = String(JSON.parse(metadata['x-codex-turn-metadata']).thread_id); rootId ??= id; @@ -237,7 +243,13 @@ async function ownedChildren(flags?: string) { ), ] : [final('root done')]; - return ++children % 2 === 1 + children++; + sampledMarker.push(JSON.stringify(request.input).includes(marker)); + if (steering && children === 2) { + sampled.resolve(); + await releaseSample.promise; + } + return children % 2 === 1 ? [call('observe', {}, { callId: `child-observe-${children}` })] : [final('child done')]; }; @@ -270,6 +282,8 @@ async function ownedChildren(flags?: string) { checkPolicy(); assert(context.traceContext); callbacks.push(context.traceContext.toHeaders()); + callbackTurns.push(context.turnId); + if (callbacks.length === 2) continued.resolve(); if (callbacks.length === 1) { entered.resolve(); await release.promise; @@ -328,29 +342,58 @@ async function ownedChildren(flags?: string) { }), ]); release.resolve(); - assert.equal((await child.turn.wait()).finalOutput, 'child done'); - const later = await telemetry.withTrace( - { name: 'unrelated.caller', includeSensitiveData: true }, - () => child.submit('call observe again'), - ); - assert.equal((await later.wait()).finalOutput, 'child done'); + let later; + if (steering) { + await sampled.promise; + const steered = await telemetry.withTrace( + { name: 'unrelated.steering', includeSensitiveData: true }, + () => child.submit(marker), + ); + assert.equal(steered, child.turn); + releaseSample.resolve(); + assert.equal((await child.turn.wait()).finalOutput, 'child done'); + await continued.promise; + for await (const event of session.events()) { + if ( + event.type === 'turn_finished' && + event.turnId === callbackTurns[1] + ) { + assert.equal(event.status, 'completed'); + break; + } + } + assert.deepEqual(sampledMarker.slice(0, 3), [false, false, true]); + } else { + assert.equal((await child.turn.wait()).finalOutput, 'child done'); + later = await telemetry.withTrace( + { name: 'unrelated.caller', includeSensitiveData: true }, + () => child.submit('call observe again'), + ); + } + if (later) assert.equal((await later.wait()).finalOutput, 'child done'); + if (steering) assert.deepEqual(sampledMarker, [false, false, true, true]); assert.equal(callbacks.length, 2); server.assertExhausted(); return { flags, + steering, + sampledMarker, traceId, rootCallback, callbacks, + callbackTurns, rootTurn: root.turnId, - childTurns: [child.turn.turnId, later.turnId], + childTurns: callbackTurns, policyChecks, }; } finally { release.resolve(); + releaseSample.resolve(); await session.close(); } } finally { release.resolve(); + releaseSample.resolve(); await executor.close(); } } @@ -538,19 +581,21 @@ async function main() { ? await definedFlags(mode.slice(6)) : mode === 'store-reopen' ? await storeReopen(path!) - : mode?.startsWith('owned-children-') - ? await ownedChildren( - mode === 'owned-children-scoped' - ? undefined - : mode.slice('owned-children-'.length), - ) - : mode === 'graph' - ? await graph() - : mode === 'compact' - ? await compact() - : mode === 'review' - ? await review() - : await manual(mode!, path!); + : mode?.startsWith('owned-steering-') + ? await ownedChildren(mode.slice('owned-steering-'.length), true) + : mode?.startsWith('owned-children-') + ? await ownedChildren( + mode === 'owned-children-scoped' + ? undefined + : mode.slice('owned-children-'.length), + ) + : mode === 'graph' + ? await graph() + : mode === 'compact' + ? await compact() + : mode === 'review' + ? await review() + : await manual(mode!, path!); console.log(JSON.stringify(result)); } finally { await telemetry.shutdown(); diff --git a/packages/agents-codex/test/native/trace-context-native.test.ts b/packages/agents-codex/test/native/trace-context-native.test.ts index 9be8dec41..764a0f8e7 100644 --- a/packages/agents-codex/test/native/trace-context-native.test.ts +++ b/packages/agents-codex/test/native/trace-context-native.test.ts @@ -104,25 +104,34 @@ it('exports isolated root and compaction parents, queued snapshots, exact callba expectParent(dispatch(receipt.turn), PARENT.slice(0, -2) + flags); } for (const flags of ['02', '03']) { - const owned = await run('owned-children-' + flags); - expect(owned.rootCallback.traceparent.slice(3, 35)).toBe( - PARENT.slice(3, 35), - ); - expect(owned.rootCallback.traceparent.endsWith('-' + flags)).toBe(true); - expect(owned.rootCallback.tracestate).toBe(STATE); - expect(owned.callbacks).toHaveLength(2); - for (const callback of owned.callbacks) { - expect(callback.traceparent.slice(3, 35)).toBe(PARENT.slice(3, 35)); - expect(callback.traceparent.endsWith('-' + flags)).toBe(true); - expect(callback.tracestate).toBe(STATE); - } - if (flags === '03') { - const root = dispatch(owned.rootTurn); - expectParent(root, PARENT); - for (const turn of owned.childTurns) { - const child = dispatch(turn); - expect(child.traceId).toBe(root.traceId); - expect(descendsFrom(child, root)).toBe(true); + for (const kind of ['owned-children-', 'owned-steering-']) { + const firstSpan = spans.length; + const owned = await run(kind + flags); + expect(owned.rootCallback.traceparent.slice(3, 35)).toBe( + PARENT.slice(3, 35), + ); + expect(owned.rootCallback.traceparent.endsWith('-' + flags)).toBe(true); + expect(owned.rootCallback.tracestate).toBe(STATE); + expect(owned.callbacks).toHaveLength(2); + for (const callback of owned.callbacks) { + expect(callback.traceparent.slice(3, 35)).toBe(PARENT.slice(3, 35)); + expect(callback.traceparent.endsWith('-' + flags)).toBe(true); + expect(callback.tracestate).toBe(STATE); + } + for (const span of spans + .slice(firstSpan) + .filter((span) => span.name.startsWith('op.dispatch.turn_input'))) + expect(span.traceId).toBe(PARENT.slice(3, 35)); + if (kind === 'owned-steering-') + expect(owned.sampledMarker).toEqual([false, false, true, true]); + if (flags === '03') { + const root = dispatch(owned.rootTurn); + expectParent(root, PARENT); + for (const turn of owned.childTurns) { + const child = dispatch(turn); + expect(child.traceId).toBe(root.traceId); + expect(descendsFrom(child, root)).toBe(true); + } } } } From 6d01bd011318c9984f50a584744049a473a43abc Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:46:25 +0000 Subject: [PATCH 29/34] fix: wait for settled native child admission --- .../agents-codex/native/rust/subagents.rs | 27 ++++-- .../native/rust/turn_settlement.rs | 61 +++++++++++++ .../native/rust/turn_settlement_tests.rs | 86 +++++++++++++++++++ 3 files changed, 165 insertions(+), 9 deletions(-) diff --git a/packages/agents-codex/native/rust/subagents.rs b/packages/agents-codex/native/rust/subagents.rs index d6a244cbc..4424b4e14 100644 --- a/packages/agents-codex/native/rust/subagents.rs +++ b/packages/agents-codex/native/rust/subagents.rs @@ -793,21 +793,30 @@ pub async fn submit( ); } crate::graph_stop::check_after_wait(state, &serde_json::json!({}))?; - let mut init = thread.config_snapshot().await.turn_extension_init; - let seed = Arc::new(crate::turn_settlement::Seed::default()); - init.insert(seed.clone()); - let turn_id = match thread - .start_turn_if_idle(WithTurnExtensionData::new(request.clone(), init)) + let init = thread.config_snapshot().await.turn_extension_init; + let turn_id = match settlement + .start_child(state, &thread, request.clone(), init, || { + let instances = state + .child_instances + .read() + .map_err(|_| anyhow::anyhow!("Native child state unavailable"))?; + ensure!( + instances.get(&child_id).is_some_and(|current| { + current.id == instance_id + && Weak::ptr_eq(¤t.thread, &Arc::downgrade(&thread)) + }), + "Native child changed while waiting for admission" + ); + Ok(()) + }) .with_context(crate::lease_context(creation_tracing.as_deref())?) .await? { - StartIfIdleSubmission::Started { turn_id } => { - seed.confirm(&turn_id); - turn_id - } + StartIfIdleSubmission::Started { turn_id } => turn_id, StartIfIdleSubmission::NotSubmitted { reason: NotSubmittedReason::NotIdle, } => { + crate::graph_stop::check_after_wait(state, &serde_json::json!({}))?; let expected = control .active_turn_id() .context("Native child no longer has an active turn to steer")?; diff --git a/packages/agents-codex/native/rust/turn_settlement.rs b/packages/agents-codex/native/rust/turn_settlement.rs index c791f9be6..f608ef282 100644 --- a/packages/agents-codex/native/rust/turn_settlement.rs +++ b/packages/agents-codex/native/rust/turn_settlement.rs @@ -444,6 +444,67 @@ impl Settlement { .cloned() } + /// Child submissions hold the parent's mutation lock throughout this wait. + /// Native completion and its idle receipt do not need that SDK lock. + pub(crate) async fn start_child( + &self, + state: &crate::engine::ThreadState, + thread: &CodexThread, + request: TurnInputRequest, + mut init: ExtensionDataInit, + revalidate: impl Fn() -> anyhow::Result<()>, + ) -> anyhow::Result { + let mut changed = self.changed.subscribe(); + let expected = self.completed_child(); + let seed = Arc::new(Seed::default()); + init.insert(seed.clone()); + let deadline = Instant::now() + SETTLEMENT_GRACE; + loop { + let result = thread + .start_turn_if_idle(WithTurnExtensionData::new(request.clone(), init.clone())) + .await?; + if let StartIfIdleSubmission::Started { turn_id } = &result { + seed.confirm(turn_id); + } + if result + != (StartIfIdleSubmission::NotSubmitted { + reason: NotSubmittedReason::NotIdle, + }) + { + return Ok(result); + } + let Some(expected) = &expected else { + return Ok(result); + }; + if !self.is_current(expected) { + return Ok(result); + } + #[cfg(test)] + self.waiting.add_permits(1); + if !matches!(timeout_at(deadline, changed.changed()).await, Ok(Ok(()))) { + return Ok(result); + } + if Instant::now() >= deadline + || !self.is_current(expected) + || !crate::graph_stop::continuation_allowed(state) + { + return Ok(result); + } + revalidate()?; + } + } + + pub(crate) fn completed_child(&self) -> Option> { + let current = self.current.lock().expect("turn settlement poisoned"); + current + .turn + .as_ref() + .filter(|turn| { + !current.retired && turn.terminal() && turn.delivered.load(Ordering::Acquire) + }) + .cloned() + } + pub(crate) fn is_current(&self, expected: &Arc) -> bool { let current = self.current.lock().expect("turn settlement poisoned"); !current.retired diff --git a/packages/agents-codex/native/rust/turn_settlement_tests.rs b/packages/agents-codex/native/rust/turn_settlement_tests.rs index 90bc6b1ff..565c7363c 100644 --- a/packages/agents-codex/native/rust/turn_settlement_tests.rs +++ b/packages/agents-codex/native/rust/turn_settlement_tests.rs @@ -627,6 +627,92 @@ impl Fixture { } } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn child_submit_waits_for_its_passive_native_turn_to_release_the_slot() { + tokio::time::timeout(Duration::from_secs(20), async { + let provider = Provider::start().await; + let (_home, engine) = provider.engine().await; + let session = engine.start_with_tools(Default::default(), Vec::new()).await.unwrap(); + let parent = session.current().unwrap(); + let source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: parent.id, + depth: 1, + agent_path: Some(codex_protocol::AgentPath::root().join("settlement").unwrap()), + agent_nickname: None, + agent_role: None, + }); + let options = serde_json::from_value(json!({ + "session_source":source, "thread_source":ThreadSource::Subagent, + })).unwrap(); + let child = { + let _lifecycle = engine.lifecycle.lock().await; + let (mut startup, _) = engine.build_startup(options).await.unwrap(); + startup.thread_extension_init.insert(crate::native_agents::Policy { + roles: Vec::new(), max_depth: 1, inherited_instructions: None, + allowed: Default::default(), + }); + // The parent remains the only SDK event owner for this native child. + engine.manager.start_thread(startup).await.unwrap() + }; + let hold = Arc::new(CompletionHold { + parent: parent.id, entered: Semaphore::new(0), release: Semaphore::new(0), + }); + let store = engine.store.as_any().downcast_ref::().unwrap(); + *store.completion_hold.lock().unwrap() = Some(hold.clone()); + let StartIfIdleSubmission::Started { turn_id: previous } = child.thread + .start_turn_if_idle(request("first input")).await.unwrap() else { + panic!("passive child did not start"); + }; + hold.entered.acquire().await.unwrap().forget(); + let settlement = child.thread.thread_extension_data().get::().unwrap(); + loop { + let event = child.thread.next_event().await.unwrap(); + if matches!(&event.msg, EventMsg::TurnComplete(done) if done.turn_id == previous) { + settlement.delivered(&event.msg); + break; + } + } + { + let mut events = parent.events.lock().await; + parent.discover_children(&mut events).await.unwrap(); + } + let (instance, _, _) = crate::subagents::instance(&parent, child.thread_id) + .await.unwrap().expect("passive child must be an owned descendant"); + let control = child.thread.thread_extension_data() + .get::().unwrap(); + assert!(control.active_turn_id().is_none()); + assert!(settlement.completed_child().is_some()); + let id = child.thread_id; + let owner = parent.clone(); + let mut submit = tokio::spawn(async move { + Box::pin(crate::subagents::submit(&owner, id, instance, json!({ + "input":[{"type":"text", "text":"second input", "text_elements":[]}] + }))).await + }); + tokio::select! { + permit = settlement.waiting.acquire() => permit.unwrap().forget(), + result = &mut submit => { + panic!("child submission returned before its native slot became idle: {}", result.is_ok()); + } + } + assert!(parent.mutations.try_lock().is_err(), "child admission must retain its serialization"); + assert_eq!(provider.child_requests(id), 1); + *store.completion_hold.lock().unwrap() = None; + hold.release.add_permits(1); + let turn = submit.await.unwrap().unwrap(); + assert_ne!(turn.turn_id, previous); + loop { + let event = child.thread.next_event().await.unwrap(); + if matches!(&event.msg, EventMsg::TurnComplete(done) if done.turn_id == turn.turn_id) { + break; + } + } + assert_eq!(provider.child_requests(id), 2); + parent.close().await.unwrap(); + engine.close().await.unwrap(); + }).await.expect("passive child admission did not settle"); +} + #[tokio::test] async fn passive_manual_observation_serializes_with_context_change() { let fixture = Fixture::held_before_terminal().await; From 505ba7085b0d20e42cb65ff9b02c7efd9f8a3a5e Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:14:19 +0000 Subject: [PATCH 30/34] Integrate request preparation with current auth candidate --- .changeset/native-prepared-request.md | 5 + .../agents-codex/examples/prepared-request.ts | 118 ++ packages/agents-codex/native/Cargo.lock | 3 +- packages/agents-codex/native/index.d.ts | 1 + .../0054-native-prepared-request.patch | 609 ++++++++ .../agents-codex/native/patches/series.json | 6 +- .../agents-codex/native/rust/checkpoint.rs | 3 + .../agents-codex/native/rust/extensions.rs | 3 + packages/agents-codex/native/rust/lib.rs | 23 + .../native/rust/prepared_requests.rs | 366 +++++ .../agents-codex/native/rust/python_tools.rs | 29 +- .../native/rust/schema_fidelity.rs | 294 +++- packages/agents-codex/src/index.ts | 10 +- .../src/runtime/prepared-request.ts | 94 ++ .../agents-codex/src/runtime/public-agent.ts | 44 + packages/agents-codex/src/runtime/session.ts | 175 ++- .../test/native/fixtures/model-server.ts | 6 +- .../native/fixtures/tracing-parity-child.ts | 36 + .../native/prepared-request-native.test.ts | 1347 +++++++++++++++++ .../test/native/tracing-parity.test.ts | 1 + .../test/prepared-request.test.ts | 206 +++ 21 files changed, 3361 insertions(+), 18 deletions(-) create mode 100644 .changeset/native-prepared-request.md create mode 100644 packages/agents-codex/examples/prepared-request.ts create mode 100644 packages/agents-codex/native/patches/0054-native-prepared-request.patch create mode 100644 packages/agents-codex/native/rust/prepared_requests.rs create mode 100644 packages/agents-codex/src/runtime/prepared-request.ts create mode 100644 packages/agents-codex/test/native/prepared-request-native.test.ts create mode 100644 packages/agents-codex/test/prepared-request.test.ts diff --git a/.changeset/native-prepared-request.md b/.changeset/native-prepared-request.md new file mode 100644 index 000000000..619930096 --- /dev/null +++ b/.changeset/native-prepared-request.md @@ -0,0 +1,5 @@ +--- +'@openai/agents-codex': minor +--- + +feat: Add per-operation prepared-request editing for native HTTP root conversations while preserving original tool and history authority. diff --git a/packages/agents-codex/examples/prepared-request.ts b/packages/agents-codex/examples/prepared-request.ts new file mode 100644 index 000000000..a22aac109 --- /dev/null +++ b/packages/agents-codex/examples/prepared-request.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import { z } from 'zod'; +import { Agent, Runner, ToolOutput, tool } from '@openai/agents-codex'; +import { + text, + toolCall, + withScriptedResponses, +} from '@openai/agents-codex/testing'; + +/** + * Exercise the public request, argument and result controls without live credentials. + * The scripted provider uses explicit HTTP and a standard Responses (non-Lite) model. + * Select eager ordinary tools for each sampling step of a direct root. + * beforeStep may narrow a mixed opening catalog. Deferred sampled tools fail before the editor. + * Environment, code mode, children and automatic compaction are outside this profile. + * Pre-turn or mid-turn automatic compaction fails before any hidden model request. + * Shorten the input or start a new operation without the callback. Earlier tool effects remain. + * Post-turn compaction is refused while preserving the completed answer. + * Supply new input to Agent.run when using prepareRequest. A resumed turn cannot add an editor. + * Sampling edits do not change canonical history, executable schemas or policies. + * For structured output, use native mode or prompted mode with maxRetries: 0. + */ +export async function runPreparedRequestExample(): Promise { + const effects: string[] = []; + const lookup = tool({ + name: 'lookup', + description: 'Look up a synthetic record.', + parameters: z.object({ key: z.enum(['allowed']), label: z.string() }), + before: ({ callId, originalArguments }) => { + assert.equal(callId, 'lookup'); + assert.deepEqual(originalArguments, { + key: 'allowed', + label: 'original', + }); + return { + action: 'replaceArguments', + arguments: { key: 'allowed' as const, label: 'corrected' }, + }; + }, + execute: ({ key, label }) => { + effects.push(label); + return `Raw record ${key}`; + }, + after: (_output, { callId, effectiveArguments }) => { + assert.equal(callId, 'lookup'); + assert.equal(effectiveArguments.label, 'corrected'); + return new ToolOutput([{ type: 'inputText', text: 'SHAPED_RESULT' }]); + }, + }); + return withScriptedResponses( + [ + [ + toolCall( + 'lookup', + { key: 'allowed', label: 'original' }, + { callId: 'lookup' }, + ), + ], + [text('The record is allowed.')], + ], + async (peer) => { + const result = await Runner.run( + new Agent({ + model: 'gpt-5.5', + instructions: 'Call lookup, then explain its result.', + provider: peer.provider, + tools: [lookup], + }), + 'Look up the record.', + { + prepareRequest({ request, signal }) { + signal.throwIfAborted(); + // Recovery can advance the index without a result. Inspect native input. + if ( + !request.input.some( + (item) => + item !== null && + typeof item === 'object' && + !Array.isArray(item) && + item.type === 'function_call_output' && + item.call_id === 'lookup', + ) + ) + return; + request.instructions = 'Explain the lookup result in one sentence.'; + request.input.push({ + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'SAMPLING_ONLY_NOTE' }], + }); + request.tools[0].description = 'Sampled record description.'; + request.tools[0].parameters.properties = { + key: { type: 'string', enum: ['allowed', 'sampled-only'] }, + label: { type: 'string' }, + }; + return request; + }, + }, + ); + assert.deepEqual(effects, ['corrected']); + assert.equal(peer.toolOutput('lookup'), 'SHAPED_RESULT'); + assert.equal(peer.requests.length, 2); + assert.match( + JSON.stringify(peer.requests[1].input), + /SAMPLING_ONLY_NOTE/, + ); + assert.match( + JSON.stringify(peer.requests[1].tools), + /Sampled record description/, + ); + assert.match( + JSON.stringify(peer.requests[1].input), + /Explain the lookup result in one sentence/, + ); + return result.finalOutput; + }, + ); +} diff --git a/packages/agents-codex/native/Cargo.lock b/packages/agents-codex/native/Cargo.lock index 8259d48e1..c976b31fc 100644 --- a/packages/agents-codex/native/Cargo.lock +++ b/packages/agents-codex/native/Cargo.lock @@ -1698,8 +1698,8 @@ dependencies = [ "serde", "serde_ignored", "serde_json", - "serde_yaml", "serde_path_to_error", + "serde_yaml", "sha2 0.10.9", "strum", "strum_macros 0.28.0", @@ -2412,6 +2412,7 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-path-uri", "futures", + "serde", "serde_json", ] diff --git a/packages/agents-codex/native/index.d.ts b/packages/agents-codex/native/index.d.ts index 358ecb696..267969402 100644 --- a/packages/agents-codex/native/index.d.ts +++ b/packages/agents-codex/native/index.d.ts @@ -165,6 +165,7 @@ export class NativeThread { traceJson?: string | null, tracing_context?: string, ): Promise; + _reply_prepared_request(responseJson: string): void; _reply_step(response_json: string): void; _check_step(response_json: string): boolean; _reply_child_step(response_json: string): boolean; diff --git a/packages/agents-codex/native/patches/0054-native-prepared-request.patch b/packages/agents-codex/native/patches/0054-native-prepared-request.patch new file mode 100644 index 000000000..f8f99f008 --- /dev/null +++ b/packages/agents-codex/native/patches/0054-native-prepared-request.patch @@ -0,0 +1,609 @@ +diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock +index 295d1f826b19efa6e781761ca0c3d8bba6dd0d8a..61d3e12d4c59edaed9778a9bcc2cc4c65bf7e4e2 100644 +--- a/codex-rs/Cargo.lock ++++ b/codex-rs/Cargo.lock +@@ -3360,6 +3360,7 @@ dependencies = [ + "codex-utils-path-uri", + "futures", + "pretty_assertions", ++ "serde", + "serde_json", + "tokio", + ] +diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs +index 55f091582aa2ebfe4bee24de2f65285c3bc9f625..9ff682dfffe02ab464d03a64fa3349f8afd0f9e8 100644 +--- a/codex-rs/core/src/client.rs ++++ b/codex-rs/core/src/client.rs +@@ -1781,6 +1781,9 @@ impl ModelClientSession { + ) + .with_telemetry(Some(request_telemetry), Some(sse_telemetry)); + crate::model_request::wait_if_sampling_stopped(prompt.sampling_stop.as_deref()).await; ++ if let Some(admission) = &prompt.prepared_request_admission { ++ admission.admit_request().map_err(CodexErr::Fatal)?; ++ } + let stream_result = client.stream_request(request, options).await; + + match stream_result { +diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs +index 6188306e2ae26a96b758f2359697dc4ad4654b36..764b7b82562a831a482ce8c76187d4e1bc46e288 100644 +--- a/codex-rs/core/src/client_common.rs ++++ b/codex-rs/core/src/client_common.rs +@@ -34,6 +34,10 @@ pub struct Prompt { + /// Stops new provider requests for this turn, including prompts without tools. + pub(crate) sampling_stop: Option>, + ++ /// Request-scoped host ownership, checked independently of response policies. ++ pub(crate) prepared_request_admission: ++ Option>, ++ + /// Whether parallel tool calls are permitted for this prompt. + pub(crate) parallel_tool_calls: bool, + +@@ -55,6 +59,7 @@ impl Default for Prompt { + tools: Arc::default(), + tool_admission: None, + sampling_stop: None, ++ prepared_request_admission: None, + parallel_tool_calls: false, + base_instructions: BaseInstructions::default(), + output_schema: None, +diff --git a/codex-rs/core/src/compact_remote_v2_attempt.rs b/codex-rs/core/src/compact_remote_v2_attempt.rs +index c302aac22a6b055739501c55033aec814eb7a248..6843656823fe6a779c6d12ad191728510d1e1e98 100644 +--- a/codex-rs/core/src/compact_remote_v2_attempt.rs ++++ b/codex-rs/core/src/compact_remote_v2_attempt.rs +@@ -98,6 +98,7 @@ pub(super) async fn run_remote_compact_v2_attempt( + Arc::clone(tool_router), + )), + sampling_stop: crate::model_request::turn_sampling_stop(&turn_context.extension_data), ++ prepared_request_admission: None, + parallel_tool_calls: true, + base_instructions, + output_schema: None, +diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs +index 258b670961950f71e3a8dc29edb8a961158bdb6d..2f8c5526d90c21d44f61df78069a532209005ac6 100644 +--- a/codex-rs/core/src/lib.rs ++++ b/codex-rs/core/src/lib.rs +@@ -11,6 +11,7 @@ mod apps; + mod client; + mod client_common; + mod model_request; ++mod prepared_request; + mod realtime_context; + mod realtime_conversation; + mod realtime_history; +diff --git a/codex-rs/core/src/prepared_request.rs b/codex-rs/core/src/prepared_request.rs +new file mode 100644 +index 0000000000000000000000000000000000000000..a70e5faf6d3b77f8fa6d90f9c92276aed1cb8ec0 +--- /dev/null ++++ b/codex-rs/core/src/prepared_request.rs +@@ -0,0 +1,289 @@ ++//! Sampling-copy editing, separate from the selected execution plan and canonical history. ++use crate::client_common::Prompt; ++use codex_extension_api::{PreparedRequestData, PreparedToolPresentation}; ++use codex_protocol::models::ResponseItem; ++use codex_tools::{JsonSchema, ResponsesApiNamespaceTool, ResponsesApiTool, ToolSpec}; ++use serde::Serialize; ++use serde::de::DeserializeOwned; ++use serde_json::Value; ++ ++const PROFILE: &str = "prepare_request requires a standard Responses (non-Lite) model, Responses HTTP (supports_websockets=false), and direct-root eager function/MCP tools"; ++ ++/// Preserve submitted keys, including null values, while allowing native defaults. ++fn preserves(submitted: &Value, native: &Value) -> bool { ++ match (submitted, native) { ++ (Value::Object(a), Value::Object(b)) => a ++ .iter() ++ .all(|(key, value)| b.get(key).is_some_and(|actual| preserves(value, actual))), ++ (Value::Array(a), Value::Array(b)) => { ++ a.len() == b.len() && a.iter().zip(b).all(|(a, b)| preserves(a, b)) ++ } ++ _ => submitted == native, ++ } ++} ++ ++fn parse_preserving(value: Value) -> Result { ++ let parsed: T = serde_json::from_value(value.clone()) ++ .map_err(|_| "invalid native prepared-request value".to_string())?; ++ let canonical = serde_json::to_value(&parsed) ++ .map_err(|_| "cannot serialize prepared-request value".to_string())?; ++ if !preserves(&value, &canonical) { ++ return Err("prepared-request value loses fields in native serialization; omit skipped optional fields instead of explicit null".into()); ++ } ++ Ok(parsed) ++} ++ ++fn presentation( ++ namespace: Option<&str>, ++ tool: &ResponsesApiTool, ++) -> Result { ++ if tool.defer_loading == Some(true) { ++ return Err(PROFILE.into()); ++ } ++ Ok(PreparedToolPresentation { ++ namespace: namespace.map(str::to_owned), ++ name: tool.name.clone(), ++ kind: "function".into(), ++ strict: tool.strict, ++ defer_loading: tool.defer_loading, ++ description: tool.description.clone(), ++ parameters: serde_json::to_value(&tool.parameters) ++ .map_err(|_| "cannot serialize native schema".to_string())?, ++ }) ++} ++ ++pub(crate) fn snapshot(prompt: &Prompt) -> Result { ++ let mut tools = Vec::new(); ++ for spec in prompt.tools.iter() { ++ match spec { ++ ToolSpec::Function(tool) => tools.push(presentation(None, tool)?), ++ ToolSpec::Namespace(namespace) => { ++ for leaf in &namespace.tools { ++ match leaf { ++ ResponsesApiNamespaceTool::Function(tool) => { ++ tools.push(presentation(Some(&namespace.name), tool)?) ++ } ++ _ => return Err(PROFILE.into()), ++ } ++ } ++ } ++ _ => return Err(PROFILE.into()), ++ } ++ } ++ Ok(PreparedRequestData { ++ instructions: prompt.base_instructions.text.clone(), ++ input: prompt ++ .input ++ .iter() ++ .map(serde_json::to_value) ++ .collect::>() ++ .map_err(|_| "cannot serialize native input".to_string())?, ++ tools, ++ }) ++} ++ ++fn edit_tool( ++ namespace: Option<&str>, ++ tool: &mut ResponsesApiTool, ++ edit: &PreparedToolPresentation, ++) -> Result<(), String> { ++ let original = presentation(namespace, tool)?; ++ if edit.namespace != original.namespace ++ || edit.name != original.name ++ || edit.kind != original.kind ++ || edit.strict != original.strict ++ || edit.defer_loading != original.defer_loading ++ { ++ return Err( ++ "prepared-request tool identity, order, kind, strict and defer_loading are immutable" ++ .into(), ++ ); ++ } ++ if !edit.parameters.is_object() { ++ return Err("prepared-request parameters must be a supported schema object".into()); ++ } ++ if edit.parameters != original.parameters { ++ let mut schema: JsonSchema = parse_preserving(edit.parameters.clone())?; ++ // Retain native-only MCP bookkeeping; the executable owner remains untouched. ++ schema.mcp_input_schema_max_bytes = tool.parameters.mcp_input_schema_max_bytes; ++ tool.parameters = schema; ++ } ++ tool.description = edit.description.clone(); ++ Ok(()) ++} ++ ++pub(crate) fn apply(original: &Prompt, data: PreparedRequestData) -> Result { ++ let before = snapshot(original)?; ++ if before.tools.len() != data.tools.len() { ++ return Err("prepared-request tool count is immutable".into()); ++ } ++ let input: Vec = data ++ .input ++ .into_iter() ++ .enumerate() ++ .map(|(index, value)| { ++ parse_preserving(value) ++ .map_err(|category| format!("prepared-request input[{index}]: {category}")) ++ }) ++ .collect::>()?; ++ let mut tools = original.tools.to_vec(); ++ let mut edits = data.tools.iter(); ++ for spec in &mut tools { ++ match spec { ++ ToolSpec::Function(tool) => { ++ edit_tool(None, tool, edits.next().ok_or("missing tool presentation")?)? ++ } ++ ToolSpec::Namespace(namespace) => { ++ for leaf in &mut namespace.tools { ++ match leaf { ++ ResponsesApiNamespaceTool::Function(tool) => edit_tool( ++ Some(&namespace.name), ++ tool, ++ edits.next().ok_or("missing tool presentation")?, ++ )?, ++ _ => return Err(PROFILE.into()), ++ } ++ } ++ } ++ _ => return Err(PROFILE.into()), ++ } ++ } ++ let mut result = original.clone(); ++ result.input = input; ++ result.base_instructions.text = data.instructions; ++ result.tools = tools.into(); ++ Ok(result) ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ use serde_json::json; ++ ++ #[test] ++ fn native_text_function_and_mcp_items_preserve_submitted_fields() { ++ for item in [ ++ json!({"type":"message","role":"user","content":[{"type":"input_text","text":"note"}]}), ++ json!({"type":"function_call","name":"reserve","arguments":"{\"seats\":2}","call_id":"call-1"}), ++ json!({"type":"function_call_output","call_id":"call-1","output":"SHAPED_RESULT"}), ++ json!({"type":"function_call","namespace":"mcp__local","name":"reserve","arguments":"{}","call_id":"mcp-1"}), ++ json!({"type":"function_call_output","call_id":"mcp-1","output":"ok"}), ++ ] { ++ assert!(parse_preserving::(item).is_ok()); ++ } ++ } ++ ++ #[test] ++ fn unknown_nested_null_and_skipped_optional_null_are_rejected() { ++ for item in [ ++ json!({"type":"message","role":"user","content":[{"type":"input_text","text":"note","unknown":null}]}), ++ json!({"type":"message","role":"user","id":null,"content":[]}), ++ ] { ++ assert!(parse_preserving::(item).is_err()); ++ } ++ } ++ ++ #[test] ++ fn native_schema_rejects_discarded_keywords_without_sanitizing() { ++ assert!(parse_preserving::(json!({"type":"integer","maximum":99})).is_err()); ++ assert!( ++ parse_preserving::(json!({"type":"string","enum":["one","two"]})).is_ok() ++ ); ++ } ++ ++ fn function(name: &str) -> ResponsesApiTool { ++ ResponsesApiTool { ++ name: name.into(), ++ description: "original".into(), ++ strict: false, ++ defer_loading: None, ++ parameters: serde_json::from_value(json!({"type":"string","enum":["one"]})).unwrap(), ++ output_schema: None, ++ } ++ } ++ ++ #[test] ++ fn edit_changes_only_detached_sampling_presentation_and_input() { ++ let original = Prompt { ++ tools: vec![ ++ ToolSpec::Function(function("local")), ++ ToolSpec::Namespace(codex_tools::ResponsesApiNamespace { ++ name: "mcp__local".into(), ++ description: "container".into(), ++ tools: vec![ResponsesApiNamespaceTool::Function(function("remote"))], ++ }), ++ ] ++ .into(), ++ ..Default::default() ++ }; ++ let original_tools = original.tools.clone(); ++ let mut data = snapshot(&original).unwrap(); ++ data.instructions = "edited".into(); ++ data.input.push(json!({"type":"message","role":"user","content":[{"type":"input_text","text":"sampling only"}]})); ++ for tool in &mut data.tools { ++ tool.description = "edited description".into(); ++ tool.parameters = json!({"type":"string","enum":["one","two"]}); ++ } ++ let edited = apply(&original, data).unwrap(); ++ assert_eq!( ++ snapshot(&edited).unwrap().tools[1].namespace.as_deref(), ++ Some("mcp__local") ++ ); ++ assert_eq!(edited.base_instructions.text, "edited"); ++ assert_eq!(edited.input.len(), 1); ++ assert!(original.input.is_empty()); ++ assert_eq!(original.tools, original_tools); ++ assert_eq!( ++ snapshot(&original).unwrap().tools[0].description, ++ "original" ++ ); ++ match &edited.tools[1] { ++ ToolSpec::Namespace(namespace) => assert_eq!(namespace.description, "container"), ++ _ => panic!("namespace was lost"), ++ } ++ } ++ ++ #[test] ++ fn tool_mutations_and_invalid_schemas_apply_nothing() { ++ let original = Prompt { ++ tools: vec![ToolSpec::Function(function("local"))].into(), ++ ..Default::default() ++ }; ++ let before = snapshot(&original).unwrap(); ++ for mutation in 0..6 { ++ let mut data = before.clone(); ++ match mutation { ++ 0 => data.tools[0].name = "invented".into(), ++ 1 => data.tools[0].strict = true, ++ 2 => data.tools[0].defer_loading = Some(true), ++ 3 => data.tools.clear(), ++ 4 => data.tools[0].parameters = json!({"maximum":99}), ++ _ => data.tools[0].namespace = Some("other".into()), ++ } ++ assert!(apply(&original, data).is_err()); ++ assert_eq!(snapshot(&original).unwrap(), before); ++ } ++ } ++ ++ #[test] ++ fn unsupported_selected_presentations_fail_before_callback_entry() { ++ let mut tool = function("deferred"); ++ tool.defer_loading = Some(true); ++ let original = Prompt { ++ tools: vec![ToolSpec::Function(tool)].into(), ++ ..Default::default() ++ }; ++ assert!(snapshot(&original).is_err()); ++ } ++ ++ #[test] ++ fn defaults_may_be_added_but_array_shape_and_scalar_values_must_stay() { ++ assert!(preserves( ++ &json!({"a":[1]}), ++ &json!({"a":[1],"default":null}) ++ )); ++ assert!(!preserves(&json!({"a":null}), &json!({}))); ++ assert!(!preserves(&json!([1]), &json!([1, 2]))); ++ assert!(!preserves(&json!(1), &json!(2))); ++ } ++} +diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs +index 030a65b21d390af8a7f90bdd674039d09f370316..0375cd4c8c0a0cd4d835bf006ca5bfe548a41720 100644 +--- a/codex-rs/core/src/session/turn.rs ++++ b/codex-rs/core/src/session/turn.rs +@@ -1,3 +1,4 @@ ++use std::borrow::Cow; + use std::collections::HashMap; + use std::collections::HashSet; + use std::sync::Arc; +@@ -1465,6 +1466,17 @@ async fn run_auto_compact( + phase: CompactionPhase, + ) -> CodexResult<()> { + let turn_context = &step_context.turn; ++ if sess ++ .services ++ .extensions ++ .model_request_contributors() ++ .iter() ++ .any(|contributor| contributor.prepares_request(&turn_context.extension_data)) ++ { ++ let err = CodexErr::Fatal("automatic compaction is unsupported for an operation with prepare_request; shorten the input or start a new operation".into()); ++ return Err(err); ++ } ++ + if matches!(phase, CompactionPhase::PreTurn) + && crate::guardian::is_basic_session_source(&turn_context.session_source) + && !crate::guardian::should_compact_guardian_input(sess)? +@@ -1608,6 +1620,7 @@ pub(crate) fn build_prompt( + parallel_tool_calls: true, + tool_admission: None, + sampling_stop: None, ++ prepared_request_admission: None, + base_instructions, + output_schema: turn_context.final_output_json_schema.clone(), + output_schema_strict: !crate::guardian::is_basic_session_source( +@@ -1659,6 +1672,7 @@ async fn run_sampling_request( + ); + let max_retries = turn_context.provider.info().stream_max_retries(); + let mut retry_state = ResponsesStreamRetryState::default(); ++ let mut recovery_attempt = 0; + let mut initial_input = Some(input); + let mut original_input = None; + let mut executed_tool_calls_by_output = HashMap::new(); +@@ -1691,6 +1705,27 @@ async fn run_sampling_request( + let responses_metadata = sess + .responses_metadata(step_context.as_ref(), CodexResponsesRequestKind::Turn) + .await; ++ let editors: Vec<_> = sess ++ .services ++ .extensions ++ .model_request_contributors() ++ .iter() ++ .filter(|contributor| contributor.prepares_request(&turn_context.extension_data)) ++ .collect(); ++ if !editors.is_empty() { ++ if editors.len() != 1 ++ || turn_context.provider.info().supports_websockets ++ || step_context.settings.model_info.use_responses_lite ++ || step_context.realtime.active ++ || turn_context.config.features.enabled(Feature::CodeMode) ++ || matches!( ++ turn_context.session_source, ++ SessionSource::SubAgent(_) | SessionSource::Internal(_) ++ ) ++ { ++ return Err(CodexErr::Fatal("prepare_request requires a standard Responses (non-Lite) model, Responses HTTP (supports_websockets=false), and direct-root eager function/MCP tools".into())); ++ } ++ } + if crate::guardian::is_basic_session_source(&turn_context.session_source) { + crate::guardian::prepare_guardian_prompt( + &sess, +@@ -1700,6 +1735,34 @@ async fn run_sampling_request( + ) + .await?; + } ++ // The original prompt remains authoritative for history and retry bookkeeping. ++ let mut sampling_prompt = Cow::Borrowed(&prompt); ++ if !editors.is_empty() { ++ let data = crate::prepared_request::snapshot(&prompt).map_err(CodexErr::Fatal)?; ++ let admission = prompt.tool_admission.clone().ok_or_else(|| { ++ CodexErr::Fatal("prepared request has no selected tool admission".into()) ++ })?; ++ let output = editors[0] ++ .prepare_request(codex_extension_api::PreparedRequestInput { ++ admission, ++ recovery_attempt, ++ data, ++ }) ++ .or_cancel(&preempt) ++ .or_cancel(&cancellation_token) ++ .await?? ++ .map_err(CodexErr::Fatal)?; ++ if cancellation_token.is_cancelled() || preempt.is_cancelled() { ++ return Err(CodexErr::TurnAborted); ++ } ++ output.admission.admit_request().map_err(CodexErr::Fatal)?; ++ if let Some(data) = output.data { ++ sampling_prompt = Cow::Owned( ++ crate::prepared_request::apply(&prompt, data).map_err(CodexErr::Fatal)?, ++ ); ++ } ++ sampling_prompt.to_mut().prepared_request_admission = Some(output.admission); ++ } + let err = match try_run_sampling_request( + tool_runtime.clone(), + Arc::clone(&sess), +@@ -1708,7 +1771,7 @@ async fn run_sampling_request( + client_session, + &responses_metadata, + Arc::clone(&turn_diff_tracker), +- &prompt, ++ &sampling_prompt, + cancellation_token.child_token(), + ) + .await +@@ -1765,6 +1828,7 @@ async fn run_sampling_request( + )); + } + retry??; ++ recovery_attempt += 1; + turn_context.turn_timing_state.record_sampling_retry(); + } + } +diff --git a/codex-rs/ext/extension-api/Cargo.toml b/codex-rs/ext/extension-api/Cargo.toml +index 4b487226eb7deb0a44ad5d9ae16e53f90fd7ec8e..3e19d0358c23532df40aca4dff37125b201c938c 100644 +--- a/codex-rs/ext/extension-api/Cargo.toml ++++ b/codex-rs/ext/extension-api/Cargo.toml +@@ -27,6 +27,7 @@ codex-protocol = { workspace = true } + codex-tools = { workspace = true } + codex-utils-absolute-path = { workspace = true } + codex-utils-path-uri = { workspace = true } ++serde = { workspace = true } + serde_json = { workspace = true } + + [dev-dependencies] +diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs +index f2dbed8d89823999a82e89e43576ecf7efab7a5f..54d70d95f627509f85c177d38173c8568ebd5666 100644 +--- a/codex-rs/ext/extension-api/src/lib.rs ++++ b/codex-rs/ext/extension-api/src/lib.rs +@@ -126,6 +126,12 @@ pub use model_request::ModelToolAdmission; + pub use model_request::ResponseEvent; + pub use model_request::TurnSamplingStop; + ++pub use model_request::PreparedRequestAdmission; ++pub use model_request::PreparedRequestData; ++pub use model_request::PreparedRequestInput; ++pub use model_request::PreparedRequestOutput; ++pub use model_request::PreparedToolPresentation; ++ + /// SDK host context for recording the settings used to prepare a sampling request. + pub struct SdkTurnReceiptSeed { + pub codex_revision: &'static str, +diff --git a/codex-rs/ext/extension-api/src/model_request.rs b/codex-rs/ext/extension-api/src/model_request.rs +index 5263f85cd4517aa8b72186b02ac91d9c08d0b2e2..34d02776f0119a72955a827c3a5cf48c3bfda7e5 100644 +--- a/codex-rs/ext/extension-api/src/model_request.rs ++++ b/codex-rs/ext/extension-api/src/model_request.rs +@@ -3,7 +3,10 @@ use crate::ExtensionData; + pub use codex_api::ApiError as ModelResponseError; + pub use codex_api::ResponseEvent; + use codex_protocol::models::ResponseItem; ++use futures::future::BoxFuture; + use futures::stream::BoxStream; ++use serde::{Deserialize, Serialize}; ++use serde_json::Value; + use std::collections::HashMap; + use std::sync::Arc; + use std::sync::atomic::AtomicBool; +@@ -78,9 +81,25 @@ pub struct ModelRequestInput<'a> { + pub model: &'a str, + } + +-/// Creates request-scoped interceptors without delaying inference. ++/// Contributes request-scoped preparation and response interception. + /// Return None for requests this host does not manage. + pub trait ModelRequestContributor: Send + Sync + std::fmt::Debug { ++ /// Whether this exact operation opted into prepared-request editing. ++ /// Keep this captured marker true through retirement; it is not an owner-liveness check. ++ /// Hosts must reject unsupported operation settings before starting work. ++ fn prepares_request(&self, _turn_store: &ExtensionData) -> bool { ++ false ++ } ++ ++ /// Prepare an owned sampling copy. Dropping the future revokes its pending reply; ++ /// the host still owns cancellation and joining of application callbacks. ++ fn prepare_request( ++ &self, ++ _input: PreparedRequestInput, ++ ) -> BoxFuture<'_, Result> { ++ Box::pin(async { Err("prepared-request contributor is not implemented".into()) }) ++ } ++ + fn request(&self, input: ModelRequestInput<'_>) -> Option>; + } + +@@ -114,6 +133,46 @@ pub trait ModelResponseInterceptor: Send + Sync { + fn intercept(self: Box, stream: ModelResponseStream) -> ModelResponseStream; + } + ++/// Detached native sampling data. Unknown fields are rejected at the host reply boundary. ++#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] ++#[serde(deny_unknown_fields)] ++pub struct PreparedRequestData { ++ pub instructions: String, ++ pub input: Vec, ++ pub tools: Vec, ++} ++ ++/// Ordered function leaf. Only description and parameters may change. ++#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] ++#[serde(deny_unknown_fields)] ++pub struct PreparedToolPresentation { ++ pub namespace: Option, ++ pub name: String, ++ pub kind: String, ++ pub strict: bool, ++ pub defer_loading: Option, ++ pub description: String, ++ pub parameters: Value, ++} ++ ++/// Native ownership evidence; hosts add their generation and one-shot nonce. ++pub struct PreparedRequestInput { ++ pub admission: Arc, ++ /// Outer sampling recovery count; lower-level transport retries reuse this preparation. ++ pub recovery_attempt: usize, ++ pub data: PreparedRequestData, ++} ++ ++/// A settled reply retains an owner check even when it makes no edit. ++pub struct PreparedRequestOutput { ++ pub data: Option, ++ pub admission: Arc, ++} ++ ++pub trait PreparedRequestAdmission: Send + Sync + std::fmt::Debug { ++ fn admit_request(&self) -> Result<(), String>; ++} ++ + #[cfg(test)] + mod sampling_stop_tests { + use super::TurnSamplingStop; diff --git a/packages/agents-codex/native/patches/series.json b/packages/agents-codex/native/patches/series.json index 3f62a4307..1fabe30a7 100644 --- a/packages/agents-codex/native/patches/series.json +++ b/packages/agents-codex/native/patches/series.json @@ -263,7 +263,11 @@ { "path": "0059-terminal-authentication-retry.patch", "sha256": "2064b337e86352296df7ac5bde428a11ad145ca568478f977dcfb2ef3a87cff8" + }, + { + "path": "0054-native-prepared-request.patch", + "sha256": "2b56a9551dcdd687e5aef834109d9ff5b4ab3914fde2eae81f4b42c54caaa21d" } ], - "result_tree": "77c98d47f44a9f13525a2c7622e7682e3a607d64" + "result_tree": "17ce59431c36f0d69e0cbac6d48bcfde37bdf830" } diff --git a/packages/agents-codex/native/rust/checkpoint.rs b/packages/agents-codex/native/rust/checkpoint.rs index 92ff538e9..4e24cc834 100644 --- a/packages/agents-codex/native/rust/checkpoint.rs +++ b/packages/agents-codex/native/rust/checkpoint.rs @@ -501,6 +501,9 @@ async fn export_inner( .thread .thread_extension_data() .get::(); + if let Some(tools) = &tools { + tools.check_prepared_checkpoint()?; + } let managed_tool_specs = if let Some(tools) = &tools { if let Some(key) = pending { check_capture(state, key)?; diff --git a/packages/agents-codex/native/rust/extensions.rs b/packages/agents-codex/native/rust/extensions.rs index b291ae7db..0658036dd 100644 --- a/packages/agents-codex/native/rust/extensions.rs +++ b/packages/agents-codex/native/rust/extensions.rs @@ -334,6 +334,9 @@ pub fn build( skills_registry.build(), crate::native_interception::DirectOutput::Json, ); + builder.model_request_contributor(Arc::new( + crate::python_tools::prepared_requests::Contributor, + )); builder.model_request_contributor(recovery); builder.model_request_contributor(Arc::new(crate::response_integrity::Contributor( interception.clone(), diff --git a/packages/agents-codex/native/rust/lib.rs b/packages/agents-codex/native/rust/lib.rs index 645440d8c..39f33a56b 100644 --- a/packages/agents-codex/native/rust/lib.rs +++ b/packages/agents-codex/native/rust/lib.rs @@ -2111,6 +2111,21 @@ impl NativeThread { .map_err(input_error) } + #[napi(js_name = "_reply_prepared_request")] + pub fn _reply_prepared_request(&self, response_json: String) -> napi::Result<()> { + let inner = self.current()?; + let response: python_tools::prepared_requests::Reply = serde_json::from_str(&response_json) + .map_err(|_| input_error("Invalid prepared request reply"))?; + let tools = inner + .thread + .thread_extension_data() + .get::() + .ok_or_else(|| input_error("Prepared request requires managed Session"))?; + tools + .reply_prepared(&inner.id.to_string(), response) + .map_err(input_error) + } + #[napi(js_name = "_reply_step")] pub fn _reply_step(&self, response_json: String) -> napi::Result<()> { let inner = self.current()?; @@ -2911,6 +2926,14 @@ impl NativeThread { .transpose()?; // A restored root can use step preparation to enforce its remaining // SDK budget. The SDK never serializes an application callback. + if selection + .as_ref() + .is_some_and(|selection| selection.prepare_request) + { + return Err(native_error( + "manual continuation recovery cannot restore request preparation", + )); + } let inner = self.current()?; let _operation = inner.engine.operation(false).map_err(native_error)?; inner diff --git a/packages/agents-codex/native/rust/prepared_requests.rs b/packages/agents-codex/native/rust/prepared_requests.rs new file mode 100644 index 000000000..dc418913b --- /dev/null +++ b/packages/agents-codex/native/rust/prepared_requests.rs @@ -0,0 +1,366 @@ +//! One issuing turn owns each prepared sampling reply and dispatch capability. +use super::*; +use codex_extension_api::{ + ModelRequestContributor, ModelRequestInput, ModelResponseInterceptor, ModelToolAdmission, + PreparedRequestAdmission, PreparedRequestData, PreparedRequestInput, PreparedRequestOutput, +}; +use futures_util::future::BoxFuture; +use std::sync::atomic::AtomicBool; + +pub(super) struct Owner { + tools: Weak, + generation: Arc, + turn: String, + thread: String, + store: usize, + live: AtomicBool, + replies: Mutex>>>, +} +impl Owner { + fn check(&self) -> std::result::Result, String> { + let tools = self + .tools + .upgrade() + .ok_or("Prepared request owner closed")?; + if !self.live.load(Ordering::Acquire) + || !Arc::ptr_eq(&tools.snapshot(), &self.generation) + || !tools + .prepared + .lock() + .expect("prepared owner poisoned") + .as_ref() + .is_some_and(|current| std::ptr::eq(current.as_ref(), self)) + { + return Err("Prepared request owner retired".into()); + } + Ok(tools) + } + pub(super) fn queued(&self, id: u64) -> bool { + self.check().is_ok() + && self + .replies + .lock() + .expect("prepared replies poisoned") + .get(&id) + .is_some_and(|reply| !reply.is_closed()) + } + pub(super) fn retire(&self) { + self.live.store(false, Ordering::Release); + self.replies + .lock() + .expect("prepared replies poisoned") + .clear(); + } +} +#[derive(Default, Debug)] +pub(crate) struct Contributor; +impl ModelRequestContributor for Contributor { + fn request(&self, _: ModelRequestInput<'_>) -> Option> { + None + } + fn prepares_request(&self, turn: &ExtensionData) -> bool { + turn.get::().is_some() + } + fn prepare_request( + &self, + input: PreparedRequestInput, + ) -> BoxFuture<'_, std::result::Result> { + Box::pin(async move { + let owner = input + .admission + .turn_store() + .get::() + .ok_or("Missing prepared request owner")?; + let tools = owner.check()?; + if owner.store != input.admission.turn_store() as *const ExtensionData as usize + || owner.turn != input.admission.turn_store().level_id() + || owner.thread != input.admission.thread_id() + || tools.instance.is_some() + { + return Err("Expired prepared request store".into()); + } + let presentation = tools + .schema_receipts + .prepared(input.data) + .map_err(|_| "Invalid prepared schema owner")?; + let id = tools.family.next_step.fetch_add(1, Ordering::Relaxed); + let (send, receive) = oneshot::channel(); + { + let mut pending = owner.replies.lock().expect("prepared replies poisoned"); + owner.check()?; + pending.insert(id, send); + } + let wait = Wait { + owner: owner.clone(), + id, + }; + tools.family.requests.send(FamilyRequest::Prepared(Arc::downgrade(&owner), id, json!({ + "id": owner.turn, "msg": {"type":"_sdk_prepare_request", "turn_id":owner.turn, + "thread":input.admission.thread_id(), "generation":owner.generation.id.to_string(), + "request_id":id.to_string(), "recovery_attempt":input.recovery_attempt,"data":presentation.data} + }))).map_err(|_| "Prepared request reader closed")?; + let mut data = receive + .await + .map_err(|_| "Prepared request callback failed or cancelled")?; + owner.check()?; + let sampling_schemas = data + .as_mut() + .map(|data| { + tools + .schema_receipts + .reconcile_prepared(&presentation, data) + }) + .transpose() + .map_err(|_| "Invalid prepared schema presentation")? + .flatten(); + drop(wait); + Ok(PreparedRequestOutput { + data, + admission: Arc::new(Admission { + owner, + original: input.admission, + _sampling_schemas: sampling_schemas, + }), + }) + }) + } +} +struct Wait { + owner: Arc, + id: u64, +} +impl Drop for Wait { + fn drop(&mut self) { + self.owner + .replies + .lock() + .expect("prepared replies poisoned") + .remove(&self.id); + } +} +struct Admission { + owner: Arc, + original: Arc, + // A sampling copy keeps only its own immutable presentation receipts alive. + _sampling_schemas: Option>, +} +impl std::fmt::Debug for Admission { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("PreparedRequestAdmission") + } +} +impl PreparedRequestAdmission for Admission { + fn admit_request(&self) -> std::result::Result<(), String> { + self.owner.check()?; + if self.owner.store != self.original.turn_store() as *const ExtensionData as usize + || !self + .original + .turn_store() + .get::() + .is_some_and(|current| Arc::ptr_eq(¤t, &self.owner)) + { + return Err("Expired prepared request admission".into()); + } + Ok(()) + } +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Reply { + pub(crate) thread: String, + turn_id: String, + generation: String, + request_id: String, + // A failed callback closes the native wait without treating failure as no edit. + failed: bool, + data: Option, +} +impl PythonTools { + pub(super) fn begin_preparation(self: &Arc, input: &TurnStartInput<'_>) { + let enabled = input + .turn_store + .get::>() + .is_some_and(|operation| { + operation + .as_ref() + .as_ref() + .is_some_and(|operation| operation.prepare_request) + }); + if !enabled { + self.retire_preparation(None); + return; + } + let owner = input.turn_store.get_or_init(|| Owner { + tools: Arc::downgrade(self), + generation: self.snapshot(), + turn: input.turn_id.to_owned(), + thread: input.thread_store.level_id().to_owned(), + store: input.turn_store as *const ExtensionData as usize, + live: AtomicBool::new(true), + replies: Mutex::new(HashMap::new()), + }); + let previous = self + .prepared + .lock() + .expect("prepared owner poisoned") + .replace(owner.clone()); + if let Some(previous) = previous.filter(|previous| !Arc::ptr_eq(previous, &owner)) { + previous.retire(); + } + } + pub(crate) fn reply_prepared(&self, thread: &str, response: Reply) -> Result<()> { + let owner = self + .prepared + .lock() + .expect("prepared owner poisoned") + .clone() + .context("Expired prepared request")?; + owner.check().map_err(anyhow::Error::msg)?; + ensure!( + response.thread == thread + && response.turn_id == owner.turn + && response.generation == owner.generation.id.to_string(), + "Expired prepared request owner" + ); + let sender = owner + .replies + .lock() + .expect("prepared replies poisoned") + .remove(&response.request_id.parse()?) + .context("Expired prepared request reply")?; + if response.failed { + drop(sender); + return Ok(()); + } + sender + .send(response.data) + .map_err(|_| anyhow::anyhow!("Expired prepared request reply")) + } + pub(crate) fn check_prepared_checkpoint(&self) -> Result<()> { + ensure!( + self.prepared + .lock() + .expect("prepared owner poisoned") + .is_none(), + "Cannot capture a prepared-request operation" + ); + Ok(()) + } + pub(super) fn retire_preparation(&self, turn: Option<&str>) { + let retired = { + let mut owner = self.prepared.lock().expect("prepared owner poisoned"); + if owner + .as_ref() + .is_some_and(|owner| turn.is_none_or(|turn| turn == owner.turn)) + { + owner.take() + } else { + None + } + }; + if let Some(owner) = retired { + owner.retire(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::models::ResponseItem; + + #[derive(Debug)] + struct Original { + turn: Arc, + } + impl ModelToolAdmission for Original { + fn thread_id(&self) -> &str { + "root" + } + fn turn_store(&self) -> &ExtensionData { + &self.turn + } + fn admits(&self, _: &ResponseItem) -> bool { + true + } + } + fn fixture() -> (Arc, Arc, Arc) { + let tools = super::super::pending_tests::tools(); + let turn = Arc::new(ExtensionData::new("turn")); + let owner = turn.get_or_init(|| Owner { + tools: Arc::downgrade(&tools), + generation: tools.snapshot(), + turn: "turn".into(), + thread: "root".into(), + store: turn.as_ref() as *const ExtensionData as usize, + live: AtomicBool::new(true), + replies: Mutex::new(HashMap::new()), + }); + *tools.prepared.lock().unwrap() = Some(owner.clone()); + (tools, turn, owner) + } + fn input(turn: Arc) -> PreparedRequestInput { + PreparedRequestInput { + admission: Arc::new(Original { turn }), + recovery_attempt: 0, + data: PreparedRequestData { + instructions: "original".into(), + input: Vec::new(), + tools: Vec::new(), + }, + } + } + fn reply(event: &Value) -> Reply { + serde_json::from_value(json!({ "thread":"root", "turn_id":"turn", "generation":"0", "request_id":event["msg"]["request_id"], "failed":false,"data":null })).unwrap() + } + #[tokio::test] + async fn no_edit_retains_dispatch_admission_and_reply_is_one_shot() { + let (tools, turn, owner) = fixture(); + let contributor = Contributor; + let mut prepare = contributor.prepare_request(input(turn)); + assert!(futures_util::poll!(&mut prepare).is_pending()); + let event = tools.next_request().await; + tools.reply_prepared("root", reply(&event)).unwrap(); + assert!(tools.reply_prepared("root", reply(&event)).is_err()); + let output = prepare.await.unwrap(); + assert!(output.data.is_none()); + assert!(output.admission.admit_request().is_ok()); + tools.retire_preparation(Some("turn")); + assert!(!owner.live.load(Ordering::Acquire)); + assert!(output.admission.admit_request().is_err()); + } + #[tokio::test] + async fn dropped_wait_revokes_receipt_and_active_capture_is_refused() { + let (tools, turn, _) = fixture(); + assert!(tools.check_prepared_checkpoint().is_err()); + let contributor = Contributor; + let mut prepare = contributor.prepare_request(input(turn)); + assert!(futures_util::poll!(&mut prepare).is_pending()); + let event = tools.next_request().await; + drop(prepare); + assert!(tools.reply_prepared("root", reply(&event)).is_err()); + tools.retire_preparation(None); + assert!(tools.check_prepared_checkpoint().is_ok()); + } + #[tokio::test] + async fn superseded_owner_and_exact_store_mismatch_cannot_dispatch() { + let (tools, turn, owner) = fixture(); + let contributor = Contributor; + let mut prepare = contributor.prepare_request(input(turn.clone())); + assert!(futures_util::poll!(&mut prepare).is_pending()); + let event = tools.next_request().await; + tools.reply_prepared("root", reply(&event)).unwrap(); + let output = prepare.await.unwrap(); + let impostor = Arc::new(ExtensionData::new("turn")); + // Even the same marker and textual ID cannot change the original store identity. + let wrong = Admission { + owner: owner.clone(), + original: Arc::new(Original { turn: impostor }), + _sampling_schemas: None, + }; + assert!(wrong.admit_request().is_err()); + *tools.prepared.lock().unwrap() = None; + assert!(output.admission.admit_request().is_err()); + assert!(contributor.prepares_request(&turn)); + } +} diff --git a/packages/agents-codex/native/rust/python_tools.rs b/packages/agents-codex/native/rust/python_tools.rs index b403afaeb..2f25e39b6 100644 --- a/packages/agents-codex/native/rust/python_tools.rs +++ b/packages/agents-codex/native/rust/python_tools.rs @@ -38,6 +38,9 @@ pub(crate) use manual::{Continuation, ManualLedger, ManualSlot}; #[path = "schema_fidelity.rs"] mod schema_fidelity; +#[path = "prepared_requests.rs"] +pub(crate) mod prepared_requests; + struct Entry { name: ToolName, spec: ToolSpec, @@ -115,11 +118,13 @@ pub struct PythonTools { manual: Mutex, selection: Mutex>, step_replies: Mutex>, + prepared: Mutex>>, } // The family inbox transports requests; reply authority stays with each issuer. enum FamilyRequest { Step(Weak, u64, Value), + Prepared(Weak, u64, Value), Call(Weak, PendingKey, Value), Retired { thread: String, instance: String }, } @@ -451,6 +456,7 @@ impl PythonTools { manual: Mutex::new(ManualLedger::default()), selection: Mutex::new(None), step_replies: Mutex::new(HashMap::new()), + prepared: Mutex::new(None), }; let seed = Arc::new(tools.prepare(0, specs)?); tools.schema_receipts.register(&seed); @@ -503,6 +509,7 @@ impl PythonTools { manual: Mutex::new(parent.child_manual_ledger()), selection: Mutex::new(None), step_replies: Mutex::new(HashMap::new()), + prepared: Mutex::new(None), }; // Validate the inherited catalog against this child's current ceiling. let parent_generation = parent.snapshot(); @@ -704,6 +711,11 @@ impl PythonTools { let mut receiver = self.family.receiver.lock().await; loop { match receiver.recv().await { + Some(FamilyRequest::Prepared(owner, id, request)) => { + if owner.upgrade().is_some_and(|owner| owner.queued(id)) { + return request; + } + } Some(FamilyRequest::Step(owner, id, request)) => { if owner.upgrade().is_some_and(|tools| { if tools.instance.is_some() { @@ -952,6 +964,7 @@ impl ThreadLifecycleContributor for PythonContributor { progress.0.send_replace(()); } if let Some(tools) = input.thread_store.get::() { + tools.retire_preparation(None); tools.retire_manual_wait(); tools .step_replies @@ -998,6 +1011,8 @@ pub(crate) struct OperationSelection { // Names select across all native families; slots remain the legacy JS ceiling. pub names: Option>, pub before_step: bool, + #[serde(default)] + pub prepare_request: bool, } #[derive(Clone)] struct ActiveSelection { @@ -1006,6 +1021,7 @@ struct ActiveSelection { slots: Option>, names: Option>, before_step: bool, + prepare_request: bool, } struct PendingStep { turn: String, @@ -1105,6 +1121,7 @@ impl Drop for StepWait { impl PythonTools { fn retire_selection(&self, turn: &str) { + self.retire_preparation(Some(turn)); let mut active = self .selection .lock() @@ -1132,6 +1149,7 @@ impl PythonTools { slots: selection.slots.clone(), names: selection.names.clone(), before_step: selection.before_step, + prepare_request: selection.prepare_request, }) } @@ -1226,6 +1244,7 @@ impl PythonTools { slots: None, names: None, before_step: true, + prepare_request: false, }); } operation.map(|selection| ActiveSelection { @@ -1234,6 +1253,7 @@ impl PythonTools { slots: selection.slots.clone(), names: selection.names.clone(), before_step: selection.before_step, + prepare_request: selection.prepare_request, }) } @@ -1662,6 +1682,7 @@ impl TurnLifecycleContributor for PythonContributor { .selection .lock() .expect("JavaScript selection poisoned") = selection; + tools.begin_preparation(&input); tools.begin_native_manual_turn(input); } }) @@ -1681,6 +1702,7 @@ impl TurnLifecycleContributor for PythonContributor { .lock() .expect("JavaScript step replies poisoned") .retain(|_, pending| pending.turn != input.turn_store.level_id()); + tools.retire_preparation(Some(input.turn_store.level_id())); tools.stop_native_manual_turn(input); } }) @@ -1761,7 +1783,7 @@ mod pending_tests { // Exercise the actual registry and its lifetime owners without starting Core, // a runtime, a network service, or a native thread. - fn tools() -> Arc { + pub(super) fn tools() -> Arc { let tools = Arc::new(PythonTools { local_store: None, custom_store: None, @@ -1785,6 +1807,7 @@ mod pending_tests { manual: Mutex::default(), selection: Mutex::new(None), step_replies: Mutex::new(HashMap::new()), + prepared: Mutex::new(None), }); let specs = vec![DynamicToolSpec::Function(DynamicToolFunctionSpec { name: "decision".into(), @@ -2055,6 +2078,7 @@ mod pending_tests { slots: None, names, before_step: true, + prepare_request: false, }); (thread, tools) } @@ -2076,6 +2100,7 @@ mod pending_tests { slots: Some(Vec::new()), names: None, before_step: false, + prepare_request: false, }; *child.selection.lock().unwrap() = child.turn_selection("child-turn", Some(&root_operation)); @@ -2371,6 +2396,7 @@ mod pending_tests { slots: Some(Vec::new()), names: None, before_step: false, + prepare_request: false, }); let successor = ExtensionData::new("successor-step"); assert!( @@ -2499,6 +2525,7 @@ mod pending_tests { slots: operation.slots, names: operation.names, before_step: operation.before_step, + prepare_request: operation.prepare_request, }); let continued = tools.continuation_selection("turn").unwrap(); assert_eq!(continued.slots, expected_slots); diff --git a/packages/agents-codex/native/rust/schema_fidelity.rs b/packages/agents-codex/native/rust/schema_fidelity.rs index 2b5f8a95f..213929010 100644 --- a/packages/agents-codex/native/rust/schema_fidelity.rs +++ b/packages/agents-codex/native/rust/schema_fidelity.rs @@ -7,11 +7,13 @@ use std::collections::HashMap; use std::sync::{Arc, LazyLock, Mutex, Weak}; use anyhow::{Result, ensure}; +use codex_extension_api::{PreparedRequestData, PreparedToolPresentation}; use codex_protocol::ThreadId; use codex_tools::{ResponsesApiNamespaceTool, ToolSpec}; use serde_json::Value; -use super::{Generation, PythonTools, functions}; +use super::{Entry, Generation, PythonTools, functions}; +use codex_protocol::dynamic_tools::{DynamicToolNamespaceTool, DynamicToolSpec}; static MARKER: LazyLock = LazyLock::new(|| format!("\n[agents-schema:{}/", ThreadId::new())); @@ -21,6 +23,19 @@ struct Receipt { historical: Option>, } +struct ManagedSchema { + generation: Arc, + slot: usize, +} + +/// One clean callback view plus its private original presentation owners. +/// This metadata never supplies executors or changes the live tool generation. +pub(super) struct PreparedSchemas { + pub(super) data: PreparedRequestData, + original: Vec, + managed: Vec>, +} + #[derive(Default)] pub(super) struct Receipts(Mutex>); @@ -162,12 +177,122 @@ impl Receipts { Ok(()) } - fn function(&self, tool: &mut Value, namespace: &str) -> Result<()> { + pub(super) fn prepared(&self, mut data: PreparedRequestData) -> Result { + let original = data.tools.clone(); + let mut managed = Vec::with_capacity(data.tools.len()); + for tool in &mut data.tools { + let owner = self.resolve( + &serde_json::json!({ + "name":tool.name, "description":tool.description, "parameters":tool.parameters, + }), + tool.namespace.as_deref().unwrap_or("functions"), + )?; + if let Some(owner) = &owner { + let originals = functions(&owner.generation.specs); + tool.description = originals[owner.slot].1.description.clone(); + } + managed.push(owner); + } + Ok(PreparedSchemas { + data, + original, + managed, + }) + } + + pub(super) fn reconcile_prepared( + &self, + captured: &PreparedSchemas, + data: &mut PreparedRequestData, + ) -> Result>> { + ensure!( + data.tools.len() == captured.data.tools.len(), + "Prepared tool count changed" + ); + let token = ThreadId::new().to_string(); + let mut entries = Vec::new(); + let mut specs = Vec::new(); + for (index, tool) in data.tools.iter_mut().enumerate() { + // Only this runtime's actual private syntax is reserved. Ordinary + // documentation that mentions agents-schema is still plain text. + ensure!( + !tool.description.contains(MARKER.as_str()), + "Prepared description contains a reserved schema reference" + ); + let Some(owner) = &captured.managed[index] else { + continue; + }; + let public = &captured.data.tools[index]; + let description_changed = tool.description != public.description; + let parameters_changed = tool.parameters != public.parameters; + if !description_changed && !parameters_changed { + tool.description = captured.original[index].description.clone(); + continue; + } + let original_entry = &owner.generation.entries[owner.slot]; + let originals = functions(&owner.generation.specs); + let (namespace, original) = originals[owner.slot]; + let mut raw = original.clone(); + raw.description = tool.description.clone(); + if parameters_changed { + raw.input_schema = tool.parameters.clone(); + } + let mut projected = original_entry.spec.clone(); + let function = match &mut projected { + ToolSpec::Function(function) => function, + ToolSpec::Namespace(namespace) => match namespace.tools.first_mut() { + Some(ResponsesApiNamespaceTool::Function(function)) => function, + _ => anyhow::bail!("Invalid managed prepared namespace"), + }, + _ => anyhow::bail!("Invalid managed prepared function"), + }; + // Core validates the native projection. This captured sampling + // receipt restores the exact raw schema only after tool identity + // and projected declaration checks succeed at serialization. + if parameters_changed { + function.parameters = serde_json::from_value(tool.parameters.clone()) + .map_err(|_| anyhow::anyhow!("Invalid prepared schema projection"))?; + tool.parameters = serde_json::to_value(&function.parameters) + .map_err(|_| anyhow::anyhow!("Invalid prepared schema projection"))?; + } + function.description = Self::description(&tool.description, &token, entries.len()); + tool.description = function.description.clone(); + entries.push(Entry { + name: original_entry.name.clone(), + spec: projected, + exposure: original_entry.exposure, + }); + specs.push(match namespace { + None => DynamicToolSpec::Function(raw), + Some(namespace) => { + let mut namespace = namespace.clone(); + namespace.tools = vec![DynamicToolNamespaceTool::Function(raw)]; + DynamicToolSpec::Namespace(namespace) + } + }); + } + if entries.is_empty() { + return Ok(None); + } + let generation = Arc::new(Generation { + id: 0, + schema_token: Some(token), + entries, + specs, + defaults: Vec::new(), + }); + // Register only presentation metadata. No executable plan, current + // generation, search-history retention, or application policy changes. + self.register(&generation); + Ok(Some(generation)) + } + + fn resolve(&self, tool: &Value, namespace: &str) -> Result> { let Some(description) = tool.get("description").and_then(Value::as_str) else { - return Ok(()); + return Ok(None); }; let Some((_, suffix)) = description.rsplit_once(MARKER.as_str()) else { - return Ok(()); + return Ok(None); }; let (token, slot) = suffix .split_once(']') @@ -213,6 +338,25 @@ impl Receipts { && tool.get("parameters") == Some(&serde_json::to_value(&projected.parameters)?), "Managed tool schema declaration does not match its native owner" ); + Ok(Some(ManagedSchema { generation, slot })) + } + + fn function(&self, tool: &mut Value, namespace: &str) -> Result<()> { + let Some(ManagedSchema { generation, slot }) = self.resolve(tool, namespace)? else { + return Ok(()); + }; + let description = tool + .get("description") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("Invalid managed tool description"))?; + let projected = match &generation.entries[slot].spec { + ToolSpec::Function(function) => function, + ToolSpec::Namespace(namespace) => match namespace.tools.first() { + Some(ResponsesApiNamespaceTool::Function(function)) => function, + _ => unreachable!("resolved namespace function"), + }, + _ => unreachable!("resolved function"), + }; let originals = functions(&generation.specs); let (_, original) = originals .get(slot) @@ -419,3 +563,145 @@ mod tests { } } } + +#[cfg(test)] +mod prepared_tests { + use super::*; + use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; + use codex_tools::{ToolExposure, ToolName, dynamic_tool_to_responses_api_tool}; + use serde_json::json; + + fn fixture(receipts: &Receipts) -> (Arc, PreparedRequestData) { + let raw = DynamicToolFunctionSpec { + name: "lookup".into(), + description: "Original description.".into(), + input_schema: json!({"type":"object","properties":{"key":{"type":"string","minLength":3}},"required":["key"],"additionalProperties":false}), + defer_loading: false, + }; + let token = ThreadId::new().to_string(); + let mut projected = dynamic_tool_to_responses_api_tool(&raw).unwrap(); + projected.description = Receipts::description(&raw.description, &token, 0); + let data = PreparedRequestData { + instructions: "original".into(), + input: Vec::new(), + tools: vec![PreparedToolPresentation { + namespace: None, + name: projected.name.clone(), + kind: "function".into(), + strict: projected.strict, + defer_loading: projected.defer_loading, + description: projected.description.clone(), + parameters: serde_json::to_value(&projected.parameters).unwrap(), + }], + }; + let generation = Arc::new(Generation { + id: 7, + schema_token: Some(token), + entries: vec![Entry { + name: ToolName::plain("lookup"), + spec: ToolSpec::Function(projected), + exposure: ToolExposure::Direct, + }], + specs: vec![DynamicToolSpec::Function(raw)], + defaults: vec![0], + }); + receipts.register(&generation); + (generation, data) + } + fn wire(data: &PreparedRequestData) -> Value { + json!({"tools":data.tools.iter().map(|tool|json!({"type":"function","name":tool.name,"description":tool.description,"parameters":serde_json::to_value(serde_json::from_value::(tool.parameters.clone()).unwrap()).unwrap()})).collect::>()}) + } + #[test] + fn field_intent_retains_raw_schema_until_parameters_actually_change() { + for (description_changed, parameters_changed) in + [(false, false), (true, false), (false, true), (true, true)] + { + let receipts = Receipts::default(); + let (original, data) = fixture(&receipts); + let view = receipts.prepared(data).unwrap(); + assert_eq!(view.data.tools[0].description, "Original description."); + assert!(!view.data.tools[0].description.contains(MARKER.as_str())); + let mut edited = view.data.clone(); + if description_changed { + edited.tools[0].description = "Edited description.".into(); + } + if parameters_changed { + edited.tools[0].parameters = json!({"type":"object","properties":{"key":{"type":"string","enum":["allowed","wider"]}},"required":["key"],"additionalProperties":false}); + } + let returned_parameters = edited.tools[0].parameters.clone(); + let metadata = receipts.reconcile_prepared(&view, &mut edited).unwrap(); + assert_eq!(edited.tools[0].parameters, returned_parameters); + assert_eq!( + metadata.is_some(), + description_changed || parameters_changed + ); + let mut request = wire(&edited); + receipts.restore(&mut request).unwrap(); + assert_eq!( + request["tools"][0]["description"], + if description_changed { + "Edited description." + } else { + "Original description." + } + ); + if parameters_changed { + assert_eq!(request["tools"][0]["parameters"], returned_parameters); + } else { + assert_eq!( + request["tools"][0]["parameters"], + functions(&original.specs)[0].1.input_schema + ); + } + if let Some(metadata) = metadata { + let mut stale = wire(&edited); + drop(metadata); + assert!(receipts.restore(&mut stale).is_err()); + } + } + } + #[test] + fn managed_schema_projection_keeps_immutable_identity_for_core() { + let receipts = Receipts::default(); + let (_original, data) = fixture(&receipts); + let view = receipts.prepared(data).unwrap(); + let mut edited = view.data.clone(); + edited.tools[0].parameters["properties"]["key"]["unknown_null_key"] = Value::Null; + edited.tools[0].name = "renamed".into(); + edited.tools[0].namespace = Some("changed_namespace".into()); + edited.tools[0].kind = "changed_kind".into(); + edited.tools[0].strict = !edited.tools[0].strict; + edited.tools[0].defer_loading = Some(true); + let submitted = edited.tools[0].clone(); + let _metadata = receipts.reconcile_prepared(&view, &mut edited).unwrap(); + assert_ne!(edited.tools[0].parameters, submitted.parameters); + assert!( + edited.tools[0].parameters["properties"]["key"] + .get("unknown_null_key") + .is_none() + ); + assert_eq!(edited.tools[0].name, submitted.name); + assert_eq!(edited.tools[0].namespace, submitted.namespace); + assert_eq!(edited.tools[0].kind, submitted.kind); + assert_eq!(edited.tools[0].strict, submitted.strict); + assert_eq!(edited.tools[0].defer_loading, submitted.defer_loading); + } + #[test] + fn prepared_receipts_remain_strict_and_only_actual_private_syntax_is_reserved() { + let receipts = Receipts::default(); + let (_original, data) = fixture(&receipts); + let private = data.tools[0].description.clone(); + let view = receipts.prepared(data).unwrap(); + let mut forged = view.data.clone(); + forged.tools[0].description = private; + assert!(receipts.reconcile_prepared(&view, &mut forged).is_err()); + let mut edited = view.data.clone(); + edited.tools[0].description = "Docs mention [agents-schema:example] syntax.".into(); + let _metadata = receipts.reconcile_prepared(&view, &mut edited).unwrap(); + let mut request = wire(&edited); + request["tools"][0]["parameters"]["properties"]["key"]["type"] = json!("number"); + let before = request.clone(); + assert!(receipts.restore(&mut request).is_err()); + assert_eq!(request, before); + } +} diff --git a/packages/agents-codex/src/index.ts b/packages/agents-codex/src/index.ts index f1c12d483..2376f6cee 100644 --- a/packages/agents-codex/src/index.ts +++ b/packages/agents-codex/src/index.ts @@ -140,7 +140,15 @@ export { OutputValidationError } from './runtime/output'; export type { OutputSpec } from './runtime/output'; export type { ToolRef, ToolSelector } from './runtime/tools'; -export type { OperationOptions, BeforeStepContext } from './runtime/session'; +export type { + PreparedRequestData, + PreparedToolPresentation, +} from './runtime/prepared-request'; +export type { + OperationOptions, + BeforeStepContext, + PrepareRequestContext, +} from './runtime/session'; export * as telemetry from './runtime/telemetry'; export type { diff --git a/packages/agents-codex/src/runtime/prepared-request.ts b/packages/agents-codex/src/runtime/prepared-request.ts new file mode 100644 index 000000000..b922f3ff6 --- /dev/null +++ b/packages/agents-codex/src/runtime/prepared-request.ts @@ -0,0 +1,94 @@ +import { z } from 'zod'; +import { losslessJsonValueSchema, type JsonValue } from './binding'; + +/** Sampling presentation only; executable declarations keep their original authority. */ +export interface PreparedToolPresentation { + readonly namespace: string | null; + readonly name: string; + readonly kind: 'function'; + readonly strict: boolean; + readonly deferLoading: boolean | null; + description: string; + parameters: { [key: string]: JsonValue }; +} +/** Native Responses input items use native omission for optional values. + * Managed function parameter edits retain raw schema fields through their captured + * sampling receipt. Other tools require a lossless native schema round trip. + */ +export interface PreparedRequestData { + instructions: string; + input: JsonValue[]; + tools: PreparedToolPresentation[]; +} +const toolSchema = z + .object({ + namespace: z.string().nullable(), + name: z.string(), + kind: z.literal('function'), + strict: z.boolean(), + deferLoading: z.boolean().nullable(), + description: z.string(), + parameters: z.record(z.string(), z.unknown()), + }) + .strict(); +const requestSchema = z + .object({ + instructions: z.string(), + input: z.array(z.unknown()), + tools: z.array(toolSchema), + }) + .strict(); + +/** Validate own keys before mapping so undefined-valued unknown fields cannot vanish. */ +function ownKeys(value: unknown, keys: readonly string[]): void { + if (value === null || typeof value !== 'object' || Array.isArray(value)) + throw new TypeError('Expected a prepared request object'); + if ( + Reflect.ownKeys(value).some( + (key) => typeof key !== 'string' || !keys.includes(key), + ) + ) + throw new TypeError('Unknown prepared request field'); +} +export function capturePreparedRequest(value: unknown): string { + ownKeys(value, ['instructions', 'input', 'tools']); + const tools = (value as PreparedRequestData).tools; + if (!Array.isArray(tools)) throw new TypeError('Expected prepared tools'); + for (const tool of tools) + ownKeys(tool, [ + 'namespace', + 'name', + 'kind', + 'strict', + 'deferLoading', + 'description', + 'parameters', + ]); + // Copy first: schema parsing alone can drop undefined/non-JSON nested values. + const captured = requestSchema.parse( + losslessJsonValueSchema.parse(value), + ) as PreparedRequestData; + return JSON.stringify({ + ...captured, + tools: captured.tools.map(({ deferLoading, ...tool }) => ({ + ...tool, + defer_loading: deferLoading, + })), + }); +} +export function preparedRequestFromNative(value: unknown): PreparedRequestData { + const captured = losslessJsonValueSchema.parse(value) as unknown as { + instructions: string; + input: JsonValue[]; + tools: (Omit & { + defer_loading: boolean | null; + })[]; + }; + return { + ...captured, + tools: captured.tools.map(({ defer_loading, ...tool }) => ({ + ...tool, + deferLoading: defer_loading, + })), + }; +} diff --git a/packages/agents-codex/src/runtime/public-agent.ts b/packages/agents-codex/src/runtime/public-agent.ts index bceffaa99..26a9cb6a0 100644 --- a/packages/agents-codex/src/runtime/public-agent.ts +++ b/packages/agents-codex/src/runtime/public-agent.ts @@ -109,6 +109,11 @@ export interface RunOptions extends Omit< > { /** Root model preparation limit for a new run. Resumed runs inherit their saved budget. */ maxModelSteps?: OperationOptions['maxModelSteps']; + /** + * Edit requests for a new run with input. A resumed turn cannot add an editor. + * Manual tools require onInteraction because an edited run cannot save an active checkpoint. + */ + prepareRequest?: import('./session').OperationOptions['prepareRequest']; returnState?: boolean; onNativeQuestion?: ( request: NativeQuestion, @@ -378,6 +383,8 @@ export class Agent { throw new TypeError( 'Cannot replace the model step budget when resuming a run', ); + if (input === null && options.prepareRequest !== undefined) + throw new TypeError('Cannot add prepareRequest when resuming a run'); const preparedInput = input === null ? null : nativeInput(input); const factory = this.session; const sessionOptions: OwnedSessionOptions = { @@ -397,6 +404,7 @@ export class Agent { ], [ownedRun]: { input: preparedInput, + prepareRequest: options.prepareRequest, onNativeQuestion: options.onNativeQuestion, onRequiredAction: options.onInteraction ? (action, signal) => @@ -559,6 +567,42 @@ export class Agent { ? Object.freeze({ ...declaredOutput }) : declaredOutput; const output = opening ? opening.output : prepareOutput(outputType); + if ( + (options as OwnedSessionOptions)[ownedRun]?.prepareRequest !== + undefined + ) { + const prepareRequest = (options as OwnedSessionOptions)[ + ownedRun + ]!.prepareRequest; + if (typeof prepareRequest !== 'function') + throw new TypeError('prepareRequest must be callable'); + if (output.repair) + throw new TypeError( + 'prepareRequest does not support output correction attempts; use native output or prompted maxRetries: 0', + ); + if ( + (provider.supportsWebsockets ?? provider.baseUrl === undefined) !== + false + ) + throw new TypeError( + 'prepareRequest requires NativeProvider({ supportsWebsockets: false })', + ); + if ( + programmaticToolCalling || + environmentInput || + declarations.nativeAgents || + declarations.nativeGoals || + declarations.checklist || + declarations.webSearch || + declarations.tools.some((tool) => tool.deferLoading) || + declarations.mcp.some( + (tool) => tool.deferLoading || isHostedRegistration(tool), + ) + ) + throw new TypeError( + 'prepareRequest supports only direct root eager function and ordinary MCP tools', + ); + } const { instructions, files } = !opening && options.instructionFiles !== undefined ? await loadInstructionFiles( diff --git a/packages/agents-codex/src/runtime/session.ts b/packages/agents-codex/src/runtime/session.ts index 5e2dc91ba..980f1b97c 100644 --- a/packages/agents-codex/src/runtime/session.ts +++ b/packages/agents-codex/src/runtime/session.ts @@ -1,3 +1,8 @@ +import { + capturePreparedRequest, + preparedRequestFromNative, + type PreparedRequestData, +} from './prepared-request'; import { captureTracing, getCurrentSpan, @@ -274,6 +279,7 @@ export interface ManualCheckpointEntry { /** Options used by an owned Agent.run Session to drive manual replies. */ export type OneShotOptions = { input: string | JsonValue[] | null; + prepareRequest?: OperationOptions['prepareRequest']; onNativeQuestion?: ( request: NativeQuestion, signal: AbortSignal, @@ -306,6 +312,20 @@ export interface OperationOptions { writableRoots?: readonly string[]; /** Omission uses Session defaults; an empty list selects no tools. */ tools?: readonly ToolSelector[]; + /** + * Edit detached native sampling data; original tools and history retain authority. + * Requires a standard Responses (non-Lite) model, explicit HTTP, and eager root function/MCP tools. + * Unselected deferred tools may remain in the opening catalog. beforeStep may narrow a mixed ceiling. + * Undefined keeps the original. + * Recovery may prepare again; avoid business effects in this callback. Active checkpoints refuse it. + * Pre-turn or mid-turn automatic compaction fails the operation before any hidden request. + * Shorten the input or start a new operation without this callback. Earlier tool effects remain. + * Post-turn compaction is also refused, but the operation keeps its completed answer. + */ + prepareRequest?: ( + context: PrepareRequestContext, + ) => + PreparedRequestData | undefined | Promise; /** * Select a subset of the operation ceiling before each model preparation. * Returning undefined uses that ceiling. Indices start at zero. @@ -317,6 +337,14 @@ export interface OperationOptions { | undefined | Promise[] | undefined>; } +export interface PrepareRequestContext extends Omit< + BeforeStepContext, + 'stepIndex' +> { + readonly request: PreparedRequestData; + readonly preparationIndex: number; + readonly recoveryAttempt: number; +} export interface BeforeStepContext { readonly context: Context; readonly submission: Turn; @@ -342,6 +370,8 @@ interface OperationSelection { readonly names: readonly ReturnType[] | undefined; readonly maxModelSteps?: number; readonly beforeStep?: OperationOptions['beforeStep']; + readonly prepareRequest?: OperationOptions['prepareRequest']; + preparationIndex: number; generation?: bigint; stepIndex: number; } @@ -2486,7 +2516,10 @@ export class Session { false, undefined, false, - this.captureSelection({ maxModelSteps }), + this.captureSelection({ + maxModelSteps, + prepareRequest: this.oneShot?.prepareRequest, + }), callerSignal, ); @@ -2504,7 +2537,7 @@ export class Session { throw new TypeError('submitWithOutput requires an output contract'); const parts = nativeInput(input); const output = prepareOutput(outputType); - const selection = this.captureSelection(options); + const selection = this.captureSelection(options, output); const parentTrace = captureTraceContext(options?.parentTrace) ?? this.options.parentTrace; const run = this.startTurn( @@ -2559,7 +2592,7 @@ export class Session { const parts = nativeInput(input); const output = outputType === undefined ? this.defaultOutput : prepareOutput(outputType); - const selection = this.captureSelection(options); + const selection = this.captureSelection(options, output); const parentTrace = captureTraceContext(options?.parentTrace) ?? this.options.parentTrace; const writableRoots = @@ -2646,6 +2679,7 @@ export class Session { options?.tools !== undefined || options?.beforeStep !== undefined || options?.maxModelSteps !== undefined || + options?.prepareRequest !== undefined || options?.tracing !== undefined ) throw new TypeError('Operation selection applies only to new roots'); @@ -2729,20 +2763,48 @@ export class Session { private captureSelection( options?: OperationOptions, + output = this.defaultOutput, ): OperationSelection | undefined { if ( options?.tools === undefined && options?.beforeStep === undefined && - options?.maxModelSteps === undefined + options?.maxModelSteps === undefined && + options?.prepareRequest === undefined ) return; + if (options.prepareRequest !== undefined) { + if (typeof options.prepareRequest !== 'function') + throw new TypeError('prepareRequest must be callable'); + if (output.repair) + throw new TypeError( + 'prepareRequest does not support output correction attempts; use native output or prompted maxRetries: 0', + ); + if (this.options.websocketsAllowed !== false) + throw new TypeError( + 'prepareRequest requires NativeProvider({ supportsWebsockets: false })', + ); + if ( + this.options.nativeAgents || + this.codeModeEnabled || + this.environment || + this.options.webSearch || + this.options.nativeGoals || + this.options.checklist || + this.openingMcpTools.some(isHostedRegistration) + ) + throw new TypeError( + 'prepareRequest supports only direct root eager function and ordinary MCP tools', + ); + } return { + prepareRequest: options.prepareRequest, names: options.tools === undefined ? undefined : this.selectionNames([...options.tools]), maxModelSteps: validateMaxModelSteps(options.maxModelSteps), beforeStep: options.beforeStep, + preparationIndex: 0, stepIndex: 0, }; } @@ -3239,7 +3301,7 @@ export class Session { !interactive && this.defaultTools.some((tool) => tool.kind === 'manual') && !this.oneShot?.onRequiredAction && - !this.canCheckpointManual(output) + !this.canCheckpointManual(output, selection) ) throw new InteractionRequired(); const roots = @@ -3276,6 +3338,10 @@ export class Session { private dispatchStart(run: NativeTurn, parts: JsonValue[]): void { const { output } = submissions.get(run)!; const selection = run.selection; + if (selection?.prepareRequest && output.repair) + throw new TypeError( + 'prepareRequest does not support output correction attempts; use native output or prompted maxRetries: 0', + ); if (selection) selection.generation = this.catalog.generation; const wire = encode({ ...(selection @@ -3286,6 +3352,7 @@ export class Session { before_step: selection.beforeStep !== undefined || selection.maxModelSteps !== undefined, + prepare_request: selection.prepareRequest !== undefined, }, } : {}), @@ -4492,6 +4559,11 @@ export class Session { void this.trackNativeQuestion(run, event).catch(() => {}); return; } + if (kind === '_sdk_prepare_request') { + if (msg.turn_id !== run.id) return; + this.track(run, this.prepareRequest(run, event)); + return; + } if (kind === '_sdk_select_step') { if (msg.turn_id !== run.id) return; this.track(run, this.selectStep(run, event)); @@ -4725,6 +4797,81 @@ export class Session { ); } } + private prepareRequest(run: NativeTurn, event: NativeEvent): Promise { + const msg = event.msg; + const selection = run.selection; + const preparation = new AbortController(); + const signal = AbortSignal.any([run.controller.signal, preparation.signal]); + run.preparation = preparation; + return this.invokeCallback( + run, + async () => { + let replied = false; + const reply = (data: string | undefined, failed: boolean) => { + if (replied) return; + replied = true; + // The replacement was captured before this boundary; never stringify an application object here. + const receipt = `{"thread":${JSON.stringify(this.id)},"turn_id":${JSON.stringify(msg.turn_id)},"generation":${JSON.stringify(msg.generation)},"request_id":${JSON.stringify(msg.request_id)},"failed":${failed},"data":${data ?? 'null'}}`; + if (failed) { + try { + this.#native._reply_prepared_request(receipt); + } catch { + /* Already revoked. */ + } + } else this.#native._reply_prepared_request(receipt); + }; + const abort = () => reply(undefined, true); + signal.addEventListener('abort', abort, { once: true }); + try { + if ( + !selection?.prepareRequest || + !this.current(run) || + msg.turn_id !== run.id || + msg.generation !== this.catalog.generation.toString() || + selection.generation !== this.catalog.generation + ) + throw new NativeError('Expired prepared request owner'); + signal.throwIfAborted(); + if (this.graphStops.size) + throw new TurnAborted('Request preparation stopped'); + const returned = selection.prepareRequest({ + context: this.options.context as Context, + submission: this.#turnFor(this.rootRun(run)), + preparationIndex: selection.preparationIndex++, + recoveryAttempt: msg.recovery_attempt as number, + signal, + request: preparedRequestFromNative(msg.data), + }); + const replacement = + returned && + typeof (returned as Promise).then === + 'function' + ? await returned + : returned; + // Capture immediately after settlement, before cancellation/owner checks or another await. + const captured = + replacement === undefined + ? undefined + : capturePreparedRequest(replacement); + signal.throwIfAborted(); + if ( + !this.current(run) || + selection.generation !== this.catalog.generation + ) + throw new NativeError('Prepared request is no longer active'); + reply(captured, false); + } catch (error) { + reply(undefined, true); + throw error; + } finally { + signal.removeEventListener('abort', abort); + if (run.preparation === preparation) run.preparation = undefined; + } + }, + signal, + ); + } + private selectStep(run: NativeTurn, event: NativeEvent): Promise { const msg = event.msg; const selection = run.selection; @@ -4821,6 +4968,7 @@ export class Session { selection = { names: undefined, generation: this.catalog.generation, + preparationIndex: 0, stepIndex: 0, }; this.childSelections.set(owner, selection); @@ -5486,8 +5634,12 @@ export class Session { .catch((error) => this.readerFailed(error)); } - private canCheckpointManual(output = this.defaultOutput): boolean { + private canCheckpointManual( + output = this.defaultOutput, + selection?: OperationSelection, + ): boolean { return ( + selection?.prepareRequest === undefined && !output.prompt && this.nativeManualCheckpoint && supportsManualCheckpoint(this.threadOptions, !!this.environment) @@ -5499,7 +5651,10 @@ export class Session { beforeSeal?: (entries: readonly ManualCheckpointEntry[]) => void, signal?: AbortSignal, ): Promise { - if (run.selection?.beforeStep !== undefined) + if ( + run.selection?.beforeStep !== undefined || + run.selection?.prepareRequest !== undefined + ) return Promise.reject( new NativeError('Cannot capture a callback-selected operation'), ); @@ -5826,6 +5981,7 @@ export class Session { ), maxModelSteps: remaining, generation: this.catalog.generation, + preparationIndex: 0, stepIndex: 0, }; this.#turnFor(run); @@ -7222,7 +7378,10 @@ export class Session { if (this.lifecycle.kind !== 'turn') return checkpointStatus('notReady', 'No settled pending manual boundary'); const run = this.lifecycle.run; - if (run.selection?.beforeStep !== undefined) + if ( + run.selection?.beforeStep !== undefined || + run.selection?.prepareRequest !== undefined + ) return checkpointStatus( 'notReady', 'Cannot capture a callback-selected operation', diff --git a/packages/agents-codex/test/native/fixtures/model-server.ts b/packages/agents-codex/test/native/fixtures/model-server.ts index ac965fe11..e6ad2884c 100644 --- a/packages/agents-codex/test/native/fixtures/model-server.ts +++ b/packages/agents-codex/test/native/fixtures/model-server.ts @@ -4,7 +4,9 @@ import { createServer, type IncomingHttpHeaders } from 'node:http'; import type { JsonObject, JsonValue } from '../../../src/runtime/binding'; import type { NativeConfig } from '../../../src/runtime/config'; -type ResponseValue = JsonObject[] | { status: number; body: string }; +type ResponseValue = + | JsonObject[] + | { status: number; body: string; headers?: Record }; type Response = | ResponseValue | ((request: JsonObject) => ResponseValue | Promise); @@ -65,7 +67,7 @@ export class ModelServer { const output = typeof selected === 'function' ? await selected(requestBody) : selected; if (!Array.isArray(output)) { - response.writeHead(output.status).end(output.body); + response.writeHead(output.status, output.headers).end(output.body); return; } const id = `response-${requestIndex + 1}`; diff --git a/packages/agents-codex/test/native/fixtures/tracing-parity-child.ts b/packages/agents-codex/test/native/fixtures/tracing-parity-child.ts index 44e1ebcbc..e2e243a0e 100644 --- a/packages/agents-codex/test/native/fixtures/tracing-parity-child.ts +++ b/packages/agents-codex/test/native/fixtures/tracing-parity-child.ts @@ -1159,6 +1159,41 @@ async function credentialsCase() { await telemetry.shutdown(); console.log(JSON.stringify({ traceIds })); } +async function preparedRequestCase() { + const server = new ModelServer([[message('traced answer')]]); + const config = await server.config(); + await using provider = new NativeProvider({ + baseUrl: (config['model_providers.fixture'] as { base_url: string }) + .base_url, + apiKey: 'synthetic-editor-trace', + supportsWebsockets: false, + }); + const agent = new Agent({ + instructions: 'Answer.', + model: 'gpt-5.5', + provider, + }); + let callbacks = 0; + await telemetry.withTrace( + { name: 'prepared-request-workflow' }, + async (workflow) => { + const result = await agent.run('answer', { + prepareRequest() { + callbacks++; + assert.equal(telemetry.getCurrentTrace(), workflow); + const callback = telemetry.getCurrentSpan(); + assert.equal(callback?.context.traceId, workflow.context.traceId); + assert.notEqual(callback?.context.spanId, workflow.context.spanId); + return undefined; + }, + }); + assert.equal(result.finalOutput, 'traced answer'); + }, + ); + assert.equal(callbacks, 1); + assert.equal(server.requests.length, 1); +} + async function main() { const mode = process.argv[2]; if (mode === 'custom-only') { @@ -1247,6 +1282,7 @@ async function main() { else if (mode === 'control') await controlCase(collector.batches); else if (mode === 'processors') await processorCase(collector.batches); else if (mode === 'restore') await restoreCase(collector.batches); + else if (mode === 'model-preparation') await preparedRequestCase(); else if (mode === 'parent-policy') await parentPolicyCase(collector); else if (mode === 'retained') await retainedCase(collector.batches, collector.authorization); diff --git a/packages/agents-codex/test/native/prepared-request-native.test.ts b/packages/agents-codex/test/native/prepared-request-native.test.ts new file mode 100644 index 000000000..d6a7d0aa9 --- /dev/null +++ b/packages/agents-codex/test/native/prepared-request-native.test.ts @@ -0,0 +1,1347 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { createServer } from 'node:http'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + jsonObject, + type JsonObject, + type NativeThread, +} from '../../src/runtime/binding'; +import { + Agent, + NativeProvider, + Runner, + ToolOutput, + tool, + manualTool, + toolRef, + HostedVerificationMCP, + InteractionRequired, + HttpMCP, + localStorage, +} from '@openai/agents-codex'; +import type { PreparedRequestData } from '@openai/agents-codex'; +import { ModelServer, call, message } from './fixtures/model-server'; + +// This fixture observes actual HTTP bytes and native tool execution, not a mock model loop. +const closePeers: (() => Promise)[] = []; +afterEach(async () => { + await Promise.all(closePeers.splice(0).map((close) => close())); + await ModelServer.closeAll(); +}); +async function peer() { + const calls: JsonObject[] = []; + const effects: unknown[] = []; + const server = createServer(async (request, response) => { + if (request.method !== 'POST') { + response.writeHead(405).end(); + return; + } + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const rpc = jsonObject.parse(JSON.parse(Buffer.concat(chunks).toString())); + if (rpc.id === undefined) { + response.writeHead(202).end(); + return; + } + let result: JsonObject = {}; + if (rpc.method === 'initialize') + result = { + protocolVersion: jsonObject.parse(rpc.params).protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: 'editor-peer', version: '1' }, + }; + else if (rpc.method === 'tools/list') + result = { + tools: [ + { + name: 'lookup', + description: 'Original remote description.', + inputSchema: { + type: 'object', + properties: { value: { type: 'string', enum: ['allowed'] } }, + required: ['value'], + additionalProperties: false, + }, + }, + ], + }; + else if (rpc.method === 'tools/call') { + calls.push(jsonObject.parse(rpc.params)); + effects.push(calls.at(-1)!.arguments); + result = { content: [{ type: 'text', text: 'MCP_RESULT' }] }; + } + response + .writeHead(200, { 'content-type': 'application/json' }) + .end(JSON.stringify({ jsonrpc: '2.0', id: rpc.id, result })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + closePeers.push(async () => { + server.closeAllConnections(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + }); + const address = server.address(); + if (!address || typeof address === 'string') + throw new Error('Missing peer address'); + return { url: `http://127.0.0.1:${address.port}/mcp`, calls, effects }; +} +async function providerFor(server: ModelServer) { + const config = await server.config(); + return new NativeProvider({ + baseUrl: (config['model_providers.fixture'] as { base_url: string }) + .base_url, + apiKey: 'synthetic-editor', + supportsWebsockets: false, + }); +} + +it('combines prepared edits, argument correction and result shaping through public Runner.run', async () => { + const remote = await peer(); + const effects: string[] = []; + const stages: string[] = []; + const reserve = tool({ + name: 'reserve', + description: 'Original declaration.', + parameters: z.object({ value: z.enum(['allowed', 'corrected']) }), + before: ({ callId, originalArguments }) => { + expect(callId).toBe('first'); + expect(originalArguments).toEqual({ value: 'allowed' }); + stages.push('before'); + return { + action: 'replaceArguments', + arguments: { value: 'corrected' as const }, + }; + }, + execute: ({ value }) => { + stages.push('execute'); + effects.push(value); + return 'RAW_FUNCTION_RESULT'; + }, + after: (output, { callId, effectiveArguments }) => { + expect(callId).toBe('first'); + expect(effectiveArguments).toEqual({ value: 'corrected' }); + expect(output.contentItems).toEqual([ + { type: 'inputText', text: 'RAW_FUNCTION_RESULT' }, + ]); + stages.push('after'); + return new ToolOutput([ + { type: 'inputText', text: 'SHAPED_FUNCTION_RESULT' }, + ]); + }, + }); + const mcp = new HttpMCP({ + name: 'records', + url: remote.url, + tools: ['lookup'], + approvalMode: 'approve', + approval: ({ call, effectiveArguments }) => { + expect(call.callId).toBe('remote'); + expect(effectiveArguments).toEqual({ value: 'allowed' }); + stages.push('mcp-approval'); + return effectiveArguments.value === 'allowed'; + }, + }); + const server = new ModelServer([ + [call('reserve', { value: 'allowed' }, { callId: 'first' })], + [ + call( + 'lookup', + { value: 'allowed' }, + { callId: 'remote', namespace: 'mcp__records' }, + ), + ], + [message('done')], + ]); + await using provider = await providerFor(server); + const snapshots: PreparedRequestData[] = []; + const context = { callback: () => 'resource' }; + const result = await Runner.run( + new Agent({ + model: 'gpt-5.5', + instructions: 'Original instructions.', + provider, + tools: [reserve, mcp], + }), + 'go', + { + context, + returnState: true, + prepareRequest({ + request, + preparationIndex, + recoveryAttempt, + context: supplied, + }) { + expect(supplied).toBe(context); + expect(recoveryAttempt).toBe(0); + expect(preparationIndex).toBe(snapshots.length); + snapshots.push(structuredClone(request)); + const input = z.array(jsonObject).parse(request.input); + if ( + !input.some( + (item) => + item.type === 'function_call_output' && item.call_id === 'first', + ) + ) + return; + request.instructions = 'Edited after the tool.'; + request.input.push({ + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'sampling-only-note' }], + }); + for (const presentation of request.tools) { + presentation.description = 'Sampled widened presentation.'; + presentation.parameters = { + type: 'object', + properties: { + value: { + type: 'string', + enum: ['allowed', 'corrected', 'forbidden'], + }, + }, + required: ['value'], + additionalProperties: false, + }; + } + return request; + }, + }, + ); + expect(result.finalOutput).toBe('done'); + expect(effects).toEqual(['corrected']); + expect(stages).toEqual(['before', 'execute', 'after', 'mcp-approval']); + expect(remote.calls).toHaveLength(1); + expect(remote.calls[0].name).toBe('lookup'); + expect(remote.calls[0].arguments).toEqual({ value: 'allowed' }); + expect(remote.effects).toEqual([{ value: 'allowed' }]); + expect(snapshots).toHaveLength(3); + const functionOutput = z + .array(jsonObject) + .parse(server.requests[1].input) + .filter( + (item) => + item.type === 'function_call_output' && item.call_id === 'first', + ); + expect(functionOutput).toHaveLength(1); + expect(JSON.stringify(functionOutput[0].output)).toContain( + 'SHAPED_FUNCTION_RESULT', + ); + expect(JSON.stringify(functionOutput[0].output)).not.toContain( + 'RAW_FUNCTION_RESULT', + ); + const mcpOutput = z + .array(jsonObject) + .parse(server.requests[2].input) + .filter( + (item) => + item.type === 'function_call_output' && item.call_id === 'remote', + ); + expect(mcpOutput).toHaveLength(1); + expect(JSON.stringify(mcpOutput[0].output)).toContain('MCP_RESULT'); + for (const request of server.requests.slice(1)) { + expect(JSON.stringify(request)).toContain('sampling-only-note'); + expect(JSON.stringify(request.input)).toContain('Edited after the tool.'); + const selected = JSON.stringify(request.tools); + expect(selected).toContain('Sampled widened presentation.'); + expect(selected).toContain('reserve'); + expect(selected).toContain('lookup'); + expect(selected).toContain('forbidden'); + expect(request).not.toHaveProperty('previous_response_id'); + } + expect(JSON.stringify(result.state)).not.toContain('sampling-only-note'); + expect(reserve.description).toBe('Original declaration.'); +}); + +it('keeps the original function validator when a sampled enum is widened', async () => { + const execute = vi.fn(() => 'effect'); + const reserve = tool({ + name: 'reserve', + parameters: z.object({ value: z.enum(['allowed']) }), + execute, + }); + const server = new ModelServer([ + [call('reserve', { value: 'forbidden' }, { callId: 'widened' })], + ]); + await using provider = await providerFor(server); + const agent = new Agent({ + model: 'gpt-5.5', + instructions: 'Use reserve.', + provider, + tools: [reserve], + }); + await expect( + agent.run('go', { + prepareRequest({ request }) { + request.tools[0].parameters = { + type: 'object', + properties: { + value: { type: 'string', enum: ['allowed', 'forbidden'] }, + }, + required: ['value'], + additionalProperties: false, + }; + return request; + }, + }), + ).rejects.toThrow(); + expect(execute).not.toHaveBeenCalled(); + expect(server.requests).toHaveLength(1); + expect(JSON.stringify(server.requests[0])).toContain('forbidden'); +}, 10_000); + +it('fails callback errors before dispatch, then permits a fresh operation', async () => { + const server = new ModelServer([[message('fresh')]]); + await using provider = await providerFor(server); + await using session = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + }).session(); + const failed = await session.submit('first', { + prepareRequest() { + throw new Error('synthetic callback failure'); + }, + }); + await expect(failed.wait()).rejects.toThrow('synthetic callback failure'); + expect(server.requests).toHaveLength(0); + const fresh = await session.submit('next', { + prepareRequest: () => undefined, + }); + expect((await fresh.wait()).finalOutput).toBe('fresh'); +}); + +it('joins a cancelled uncooperative callback before admitting the next root', async () => { + const server = new ModelServer([[message('fresh')]]); + await using provider = await providerFor(server); + await using session = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + }).session(); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + let signal!: AbortSignal; + const first = await session.submit('old', { + async prepareRequest(context) { + signal = context.signal; + entered.resolve(); + await release.promise; + return context.request; + }, + }); + let cancellation: Promise | undefined; + try { + await entered.promise; + let joined = false; + cancellation = first.cancel().then(() => { + joined = true; + }); + await vi.waitFor(() => expect(signal.aborted).toBe(true)); + expect(joined).toBe(false); + expect(server.requests).toHaveLength(0); + } finally { + release.resolve(); + await cancellation; + await first.cancel(); + } + await expect(first.wait()).rejects.toThrow(); + expect(server.requests).toHaveLength(0); + const next = await session.submit('fresh', { + prepareRequest: () => undefined, + }); + expect((await next.wait()).finalOutput).toBe('fresh'); +}); + +it.each(['empty', 'eager-reference'] as const)( + 'prepares the selected %s roster when the opening catalog has a deferred tool', + async (selection) => { + const eager = tool({ + name: 'eager', + parameters: z.object({}), + execute: () => 'unused', + }); + const deferred = tool({ + name: 'deferred', + parameters: z.object({}), + deferLoading: true, + execute: () => 'unused', + }); + const server = new ModelServer([[message('done')]]); + await using provider = await providerFor(server); + await using session = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + tools: [eager, deferred], + }).session(); + const editor = vi.fn(({ request }: { request: PreparedRequestData }) => { + expect(request.tools.map((tool) => tool.name)).toEqual( + selection === 'empty' ? [] : ['eager'], + ); + return request; + }); + const turn = await session.submit('go', { + tools: selection === 'empty' ? [] : [toolRef('eager')], + prepareRequest: editor, + }); + expect((await turn.wait()).finalOutput).toBe('done'); + expect(editor).toHaveBeenCalledTimes(1); + expect(server.requests[0].tools).toEqual( + selection === 'empty' + ? [] + : [expect.objectContaining({ type: 'function', name: 'eager' })], + ); + }, +); + +it('refuses a selected deferred tool before the editor, HTTP and tool effects', async () => { + const execute = vi.fn(() => 'unused'); + const deferred = tool({ + name: 'deferred', + parameters: z.object({}), + deferLoading: true, + execute, + }); + const server = new ModelServer([]); + await using provider = await providerFor(server); + await using session = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + tools: [deferred], + }).session(); + const editor = vi.fn(() => undefined); + const turn = await session.submit('go', { + tools: [toolRef('deferred')], + prepareRequest: editor, + }); + await expect(turn.wait()).rejects.toThrow(/prepare_request.*eager/i); + expect(editor).not.toHaveBeenCalled(); + expect(execute).not.toHaveBeenCalled(); + expect(server.requests).toHaveLength(0); +}); + +it('checks the roster after beforeStep and preserves effects before a later deferred selection', async () => { + const execute = vi.fn(() => 'original effect'); + const eager = tool({ name: 'eager', parameters: z.object({}), execute }); + const deferredExecute = vi.fn(() => 'unused'); + const deferred = tool({ + name: 'deferred', + parameters: z.object({}), + deferLoading: true, + execute: deferredExecute, + }); + const server = new ModelServer([[call('eager', {}, { callId: 'effect' })]]); + await using provider = await providerFor(server); + await using session = await new Agent({ + model: 'gpt-5.5', + instructions: 'Call eager.', + provider, + tools: [eager, deferred], + }).session(); + const editor = vi.fn(({ request }: { request: PreparedRequestData }) => { + expect(request.tools.map((tool) => tool.name)).toEqual(['eager']); + return request; + }); + const beforeStep = vi.fn(({ stepIndex }: { stepIndex: number }) => [ + toolRef(stepIndex === 0 ? 'eager' : 'deferred'), + ]); + const turn = await session.submit('go', { + beforeStep, + prepareRequest: editor, + }); + await expect(turn.wait()).rejects.toThrow(/prepare_request.*eager/i); + expect(beforeStep).toHaveBeenCalledTimes(2); + expect(editor).toHaveBeenCalledTimes(1); + expect(execute).toHaveBeenCalledTimes(1); + expect(deferredExecute).not.toHaveBeenCalled(); + expect(server.requests).toHaveLength(1); + expect(server.requests[0].tools).toEqual([ + expect.objectContaining({ type: 'function', name: 'eager' }), + ]); +}); + +it('keeps the operation ceiling when beforeStep tries to select an excluded deferred tool', async () => { + const eager = tool({ + name: 'eager', + parameters: z.object({}), + execute: () => 'unused', + }); + const deferred = tool({ + name: 'deferred', + parameters: z.object({}), + deferLoading: true, + execute: () => 'unused', + }); + const server = new ModelServer([]); + await using provider = await providerFor(server); + await using session = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + tools: [eager, deferred], + }).session(); + const editor = vi.fn(() => undefined); + const turn = await session.submit('go', { + tools: [toolRef('eager')], + beforeStep: () => [toolRef('deferred')], + prepareRequest: editor, + }); + await expect(turn.wait()).rejects.toThrow(); + expect(editor).not.toHaveBeenCalled(); + expect(server.requests).toHaveLength(0); +}); + +it('keeps fixed hosted MCP registration outside the editor profile even with no selected tools', async () => { + const remote = await peer(); + const hosted = new HostedVerificationMCP({ + url: remote.url, + tools: ['lookup'], + deferLoading: false, + }); + const server = new ModelServer([]); + await using provider = await providerFor(server); + await using session = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + tools: [hosted], + }).session(); + const editor = vi.fn(() => undefined); + await expect( + session.submit('go', { tools: [], prepareRequest: editor }), + ).rejects.toThrow(/eager function/i); + expect(editor).not.toHaveBeenCalled(); + expect(remote.effects).toHaveLength(0); + expect(server.requests).toHaveLength(0); +}); + +it.each([false, true])( + 'requires a one-shot interaction handler with an editor and manual tools (handler=%s)', + async (handled) => { + const approval = manualTool({ + name: 'approval', + description: 'Wait.', + parameters: z.object({}), + result: z.string(), + }); + const server = new ModelServer([ + [call('approval', {}, { callId: 'manual' })], + [message('continued')], + ]); + await using provider = await providerFor(server); + const editor = vi.fn(() => undefined); + const result = new Agent({ + model: 'gpt-5.5', + instructions: 'Ask.', + provider, + tools: [approval], + }).run('go', { + prepareRequest: editor, + ...(handled + ? { + onInteraction: (action: Parameters[0]) => + approval.match(action)!.reply('approved'), + } + : {}), + }); + if (handled) { + await expect(result).resolves.toMatchObject({ finalOutput: 'continued' }); + expect(editor).toHaveBeenCalledTimes(2); + expect(server.requests).toHaveLength(2); + } else { + await expect(result).rejects.toBeInstanceOf(InteractionRequired); + expect(editor).not.toHaveBeenCalled(); + expect(server.requests).toHaveLength(0); + } + }, +); + +it('rejects a new request editor before restoring an admitted manual turn', async () => { + const approval = manualTool({ + name: 'approval', + description: 'Wait.', + parameters: z.object({}), + result: z.string(), + }); + const server = new ModelServer([ + [call('approval', {}, { callId: 'manual' })], + [message('continued')], + ]); + await using provider = await providerFor(server); + const agent = new Agent({ + model: 'gpt-5.5', + instructions: 'Ask.', + provider, + tools: [approval], + }); + await using session = await agent.session(); + const turn = await session.submit('go'); + const pending = await turn.wait({ until: 'request' }); + expect(pending).toHaveProperty('requests'); + const state = await session.suspend(); + await session.close(); + const editor = vi.fn(() => undefined); + const onInteraction = vi.fn((action: Parameters[0]) => + approval.match(action)!.reply('approved'), + ); + await expect( + agent.run(null, { state, prepareRequest: editor, onInteraction }), + ).rejects.toThrow('Cannot add prepareRequest when resuming a run'); + expect(editor).not.toHaveBeenCalled(); + expect(onInteraction).not.toHaveBeenCalled(); + expect(server.requests).toHaveLength(1); + await expect( + agent.run(null, { state, onInteraction }), + ).resolves.toMatchObject({ + finalOutput: 'continued', + }); + expect(server.requests).toHaveLength(2); + expect(JSON.stringify(server.requests[1]!.input)).toContain('approved'); +}); + +it('refuses active manual checkpoint readiness and export after preparation settles', async () => { + const approval = manualTool({ + name: 'approval', + description: 'Wait.', + parameters: z.object({}), + result: z.string(), + }); + const server = new ModelServer([ + [call('approval', {}, { callId: 'manual' })], + [message('continued')], + ]); + await using provider = await providerFor(server); + await using session = await new Agent({ + model: 'gpt-5.5', + instructions: 'Ask.', + provider, + tools: [approval], + }).session(); + const indices: number[] = []; + const turn = await session.submit('go', { + prepareRequest: ({ preparationIndex }) => { + indices.push(preparationIndex); + return undefined; + }, + }); + const pending = await turn.wait({ until: 'request' }); + if (!('requests' in pending)) throw new Error('Expected manual request'); + expect((await session.checkpointStatus({ kind: 'manualWait' })).status).toBe( + 'notReady', + ); + await expect(session.suspend()).rejects.toThrow( + 'callback-selected operation', + ); + await session.respond(approval.match(pending.requests[0])!.reply('approved')); + expect((await turn.wait()).finalOutput).toBe('continued'); + expect(indices).toEqual([0, 1]); +}); + +it('edits the combined function/MCP conversation after both results while original MCP policy blocks widened arguments before RPC', async () => { + const remote = await peer(); + const approvals: unknown[] = []; + const mcp = new HttpMCP({ + name: 'records', + url: remote.url, + tools: ['lookup'], + approvalMode: 'approve', + approval: ({ effectiveArguments }) => { + approvals.push(effectiveArguments.value); + return effectiveArguments.value === 'allowed' + ? true + : { action: 'stop' as const }; + }, + }); + let effects = 0; + const local = tool({ + name: 'reserve', + parameters: z.object({ value: z.enum(['allowed']) }), + execute: () => { + effects++; + return 'FUNCTION_RESULT'; + }, + }); + const server = new ModelServer([ + [call('reserve', { value: 'allowed' }, { callId: 'local' })], + [ + call( + 'lookup', + { value: 'allowed' }, + { namespace: 'mcp__records', callId: 'remote' }, + ), + ], + [ + call( + 'lookup', + { value: 'forbidden' }, + { namespace: 'mcp__records', callId: 'denied' }, + ), + ], + [message('done')], + ]); + await using provider = await providerFor(server); + await using session = await new Agent({ + model: 'gpt-5.5', + instructions: 'Use the selected tools.', + provider, + tools: [local, mcp], + }).session(); + const turn = await session.submit('go', { + prepareRequest({ request, preparationIndex }) { + if (preparationIndex < 2) return; + const history = JSON.stringify(request.input); + expect(history).toContain('FUNCTION_RESULT'); + expect(history).toContain('MCP_RESULT'); + request.instructions = 'Edited combined request.'; + request.input.push({ + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'sampling-only-combined-note' }], + }); + for (const presentation of request.tools.filter((tool) => + ['reserve', 'lookup'].includes(tool.name), + )) { + presentation.description = 'Edited selected description.'; + presentation.parameters = { + type: 'object', + properties: { + value: { type: 'string', enum: ['allowed', 'forbidden'] }, + }, + required: ['value'], + additionalProperties: false, + }; + } + return request; + }, + }); + await expect(turn.wait()).rejects.toThrow(); + expect(effects).toBe(1); + expect(approvals).toEqual(['allowed', 'forbidden']); + expect(remote.calls).toHaveLength(1); + expect(server.requests).toHaveLength(3); + const edited = JSON.stringify(server.requests[2]); + expect(edited).toContain('sampling-only-combined-note'); + expect(edited).toContain('Edited selected description.'); + expect(edited).toContain('forbidden'); + expect(server.requests[2]).not.toHaveProperty('previous_response_id'); +}); + +it('uses one captured edit for an HTTP retry and detaches retained callback objects', async () => { + const server = new ModelServer([ + { status: 500, body: 'synthetic retry' }, + [message('done')], + ]); + await using provider = await providerFor(server); + const lookup = tool({ + name: 'lookup', + description: 'Original lookup.', + parameters: z.object({ value: z.string().min(3) }), + execute: () => 'unused', + }); + let preparations = 0; + let retained!: PreparedRequestData; + const result = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + tools: [lookup], + }).run('go', { + prepareRequest({ request }) { + preparations++; + request.instructions = 'Captured edit.'; + request.tools[0].description = 'Captured description.'; + retained = request; + setTimeout(() => { + retained.instructions = 'Late retained mutation.'; + retained.tools[0].description = 'Late description mutation.'; + }, 0); + return request; + }, + }); + expect(result.finalOutput).toBe('done'); + expect(preparations).toBe(1); + expect(server.requests).toHaveLength(2); + for (const request of server.requests) { + expect(JSON.stringify(request)).toContain('Captured edit.'); + expect(JSON.stringify(request)).not.toContain('Late retained mutation.'); + expect(JSON.stringify(request.tools)).toContain('Captured description.'); + expect(JSON.stringify(request.tools)).toContain('minLength'); + expect(JSON.stringify(request.tools)).not.toContain( + 'Late description mutation.', + ); + } +}); + +it('exports idle canonical history and requires a fresh editor after restore', async () => { + const server = new ModelServer([[message('first')], [message('second')]]); + await using provider = await providerFor(server); + const agent = new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + }); + const first = await agent.run('go', { + returnState: true, + prepareRequest({ request }) { + request.input.push({ + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'ephemeral-note' }], + }); + return request; + }, + }); + expect(JSON.stringify(first.state)).not.toContain('ephemeral-note'); + const editor = vi.fn(() => undefined); + const second = await agent.run('next', { + state: first.state, + prepareRequest: editor, + }); + expect(second.finalOutput).toBe('second'); + expect(editor).toHaveBeenCalledTimes(1); + expect(JSON.stringify(server.requests[1])).not.toContain('ephemeral-note'); +}); + +it.each([true, false])( + 'refuses hidden model-change compaction only with editor=%s', + async (edited) => { + const server = new ModelServer([ + [message('original')], + [message('summary')], + [message('changed')], + ]); + await using provider = await providerFor(server); + await using session = await new Agent({ + instructions: 'Answer.', + provider, + }).session(); + await (await session.submit('establish history')).wait(); + await session.update({ model: 'gpt-5.5' }); + const editor = vi.fn(() => undefined); + const turn = await session.submit( + 'change model', + edited ? { prepareRequest: editor } : undefined, + ); + if (edited) { + await expect(turn.wait()).rejects.toThrow(/compaction/i); + const terminals = []; + for await (const event of turn.events()) + if (event.type === 'turn_finished') terminals.push(event); + expect(terminals).toHaveLength(1); + expect(terminals[0].status).toBe('failed'); + expect(server.requests).toHaveLength(1); + expect(editor).not.toHaveBeenCalled(); + } else { + expect((await turn.wait()).finalOutput).toBe('changed'); + expect(server.requests).toHaveLength(3); + } + }, +); + +it.each([true, false])( + 'refuses post-tool rollover before hidden dispatch only with editor=%s', + async (edited) => { + // The native factory is the existing low-level capacity seam. The public operation and all HTTP/tool work remain real. + const { binding } = await import('../../src/runtime/binding'); + const native = await binding(); + const nativeErrors: unknown[] = []; + const nextEvent = native.NativeThread.prototype._next_session_event; + const observer = vi + .spyOn(native.NativeThread.prototype, '_next_session_event') + .mockImplementation(async function (this: NativeThread) { + const raw = await nextEvent.call(this); + const event = JSON.parse(raw); + if ( + event.msg.type === 'error' && + String(event.msg.message).includes('automatic compaction') + ) + nativeErrors.push(event.msg); + return raw; + }); + const create = native.create_engine; + const spy = vi + .spyOn(native, 'create_engine') + .mockImplementation((config, ...args) => { + const parsed = JSON.parse(config); + return create( + JSON.stringify({ + ...parsed, + model_context_window: 200000, + model_auto_compact_token_limit: 10000, + }), + ...args, + ); + }); + let effects = 0; + const reserve = tool({ + name: 'reserve', + parameters: z.object({}), + execute: () => { + effects++; + return 'completed tool effect'; + }, + }); + const server = new ModelServer( + [ + [call('reserve', {}, { callId: 'effect' })], + [message('summary')], + [message('done')], + ], + ['{"input_tokens":12000,"output_tokens":1,"total_tokens":12001}'], + ); + try { + await using provider = await providerFor(server); + await using session = await new Agent({ + model: 'gpt-5.5', + instructions: 'Use reserve once.', + provider, + tools: [reserve], + }).session(); + const editor = vi.fn(() => undefined); + const turn = await session.submit( + 'go', + edited ? { prepareRequest: editor } : undefined, + ); + if (edited) { + await expect(turn.wait()).rejects.toThrow(/compaction/i); + const terminals = []; + for await (const event of turn.events()) + if (event.type === 'turn_finished') terminals.push(event); + expect(terminals).toHaveLength(1); + expect(terminals[0].status).toBe('failed'); + expect(nativeErrors).toHaveLength(1); + expect(server.requests).toHaveLength(1); + expect(editor).toHaveBeenCalledTimes(1); + } else { + expect((await turn.wait()).finalOutput).toBe('done'); + expect(nativeErrors).toHaveLength(0); + expect(server.requests).toHaveLength(3); + } + expect(effects).toBe(1); + } finally { + observer.mockRestore(); + spy.mockRestore(); + } + }, +); + +it('rejects a retired native reply capability while a later root awaits its own callback', async () => { + const { binding } = await import('../../src/runtime/binding'); + const native = await binding(); + const original = native.NativeThread.prototype._reply_prepared_request; + let retained: + | { owner: typeof native.NativeThread.prototype; receipt: string } + | undefined; + const spy = vi + .spyOn(native.NativeThread.prototype, '_reply_prepared_request') + .mockImplementation(function ( + this: typeof native.NativeThread.prototype, + receipt, + ) { + retained ??= { owner: this, receipt }; + return original.call(this, receipt); + }); + const release = Promise.withResolvers(); + try { + const server = new ModelServer([[message('old')], [message('new')]]); + await using provider = await providerFor(server); + await using session = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + }).session(); + await ( + await session.submit('old', { prepareRequest: () => undefined }) + ).wait(); + const entered = Promise.withResolvers(); + const next = await session.submit('new', { + async prepareRequest() { + entered.resolve(); + await release.promise; + return undefined; + }, + }); + try { + await entered.promise; + expect(retained).toBeDefined(); + expect(() => original.call(retained!.owner, retained!.receipt)).toThrow( + /Expired prepared request/, + ); + expect(server.requests).toHaveLength(1); + } finally { + release.resolve(); + } + expect((await next.wait()).finalOutput).toBe('new'); + expect(server.requests).toHaveLength(2); + } finally { + release.resolve(); + spy.mockRestore(); + } +}); + +it('reopens completed idle LocalStorage history with only a freshly supplied editor', async () => { + const home = await mkdtemp(join(tmpdir(), 'native-editor-storage-')); + try { + const storage = localStorage(join(home, 'profile')); + const server = new ModelServer([ + [message('persisted answer')], + [message('fresh answer')], + ]); + await using provider = await providerFor(server); + const agent = new Agent({ + model: 'gpt-5.5', + instructions: 'Canonical instructions.', + provider, + }); + const oldEditor = vi.fn(({ request }: { request: PreparedRequestData }) => { + request.instructions = 'Old sampling instructions.'; + request.input.push({ + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'sampling-only-local-note' }], + }); + return request; + }); + let id!: string; + let canonical!: unknown[]; + { + await using first = await agent.session({ storage }); + id = first.id; + expect( + ( + await ( + await first.submit('original user input', { + prepareRequest: oldEditor, + }) + ).wait() + ).finalOutput, + ).toBe('persisted answer'); + canonical = []; + for await (const item of first.items()) canonical.push(item); + expect(JSON.stringify(canonical)).not.toContain( + 'sampling-only-local-note', + ); + expect(JSON.stringify(canonical)).not.toContain( + 'Old sampling instructions.', + ); + } + await using reopened = await agent.session({ storage, resume: id }); + expect(reopened.id).toBe(id); + const restored = []; + for await (const item of reopened.items()) restored.push(item); + expect(restored).toEqual(canonical); + const freshEditor = vi.fn( + ({ + request, + preparationIndex, + recoveryAttempt, + }: { + request: PreparedRequestData; + preparationIndex: number; + recoveryAttempt: number; + }) => { + expect(preparationIndex).toBe(0); + expect(recoveryAttempt).toBe(0); + expect(JSON.stringify(request)).not.toContain( + 'sampling-only-local-note', + ); + expect(request.instructions).not.toContain( + 'Old sampling instructions.', + ); + request.instructions = 'Fresh sampling instructions.'; + return request; + }, + ); + const next = await reopened.submit('next user input', { + prepareRequest: freshEditor, + }); + expect((await next.wait()).finalOutput).toBe('fresh answer'); + expect(oldEditor).toHaveBeenCalledTimes(1); + expect(freshEditor).toHaveBeenCalledTimes(1); + expect(JSON.stringify(server.requests[1])).toContain('original user input'); + expect(JSON.stringify(server.requests[1])).toContain('persisted answer'); + expect(JSON.stringify(server.requests[1])).toContain( + 'Fresh sampling instructions.', + ); + expect(JSON.stringify(server.requests[1])).not.toContain( + 'sampling-only-local-note', + ); + server.assertExhausted(); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + +it('prepares again when outer stream recovery rebuilds the prompt', async () => { + // A failed event on a successful HTTP response exercises outer stream recovery. + const failure = JSON.stringify({ + type: 'response.failed', + response: { + error: { + type: 'server_error', + code: 'server_error', + message: 'retry synthetic stream', + }, + }, + }); + const server = new ModelServer([ + { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + body: `data: ${failure}\n\n`, + }, + [message('recovered')], + ]); + await using provider = await providerFor(server); + const indices: [number, number][] = []; + const result = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + }).run('Try', { + prepareRequest({ request, preparationIndex, recoveryAttempt }) { + indices.push([preparationIndex, recoveryAttempt]); + return request; + }, + }); + expect(result.finalOutput).toBe('recovered'); + expect(indices).toEqual([ + [0, 0], + [1, 1], + ]); + expect(server.requests).toHaveLength(2); + expect( + server.requests.every((request) => !('previous_response_id' in request)), + ).toBe(true); + server.assertExhausted(); +}, 30_000); + +it('rejects correction-enabled Session defaults and per-operation overrides before native submission', async () => { + const server = new ModelServer([]); + await using provider = await providerFor(server); + const outputType = { + mode: 'prompted' as const, + schema: z.object({ answer: z.string() }), + maxRetries: 1, + }; + const editor = vi.fn(() => undefined); + await using repairing = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + outputType, + }).session(); + await expect( + repairing.submit('default', { prepareRequest: editor }), + ).rejects.toThrow('output correction attempts'); + await using plain = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + }).session(); + await expect( + plain.submitWithOutput('override', outputType, { prepareRequest: editor }), + ).rejects.toThrow('output correction attempts'); + await expect( + plain.enqueue('queued override', outputType, { prepareRequest: editor }), + ).rejects.toThrow('output correction attempts'); + expect(server.requests).toHaveLength(0); + expect(editor).not.toHaveBeenCalled(); +}); + +it.each(['native', 'prompted-zero'] as const)( + 'supports edited %s output without correction requests', + async (profile) => { + const server = new ModelServer([[message('{"answer":"valid"}')]]); + await using provider = await providerFor(server); + const schema = z.object({ answer: z.string() }); + const outputType = + profile === 'native' + ? { mode: 'native' as const, schema } + : { mode: 'prompted' as const, schema, maxRetries: 0 }; + const editor = vi.fn(() => undefined); + const result = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + outputType, + }).run('go', { prepareRequest: editor }); + expect(result.finalOutput).toEqual({ answer: 'valid' }); + expect(editor).toHaveBeenCalledTimes(1); + expect(server.requests).toHaveLength(1); + }, +); + +it('preserves ordinary output correction when prepareRequest is omitted', async () => { + const server = new ModelServer([ + [message('invalid output')], + [message('{"answer":"corrected"}')], + ]); + await using provider = await providerFor(server); + const outputType = { + mode: 'prompted' as const, + schema: z.object({ answer: z.string() }), + maxRetries: 1, + }; + const result = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + outputType, + }).run('go'); + expect(result.finalOutput).toEqual({ answer: 'corrected' }); + expect(server.requests).toHaveLength(2); +}); + +it.each(['no-edit', 'unchanged', 'description', 'parameters', 'both'] as const)( + 'keeps clean presentation and field-specific schema fidelity for %s', + async (edit) => { + const selected = tool({ + name: 'lookup', + description: 'Original raw-schema description.', + parameters: z.object({ value: z.string().min(3) }), + execute: () => 'unused', + }); + const server = new ModelServer([[message('done')]]); + await using provider = await providerFor(server); + const replacement = { + type: 'object', + properties: { value: { type: 'string', enum: ['allowed', 'wider'] } }, + required: ['value'], + additionalProperties: false, + }; + const result = await new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + tools: [selected], + }).run('go', { + prepareRequest({ request }) { + expect(request.tools[0].description).toBe( + 'Original raw-schema description.', + ); + expect(request.tools[0].description).not.toContain('[agents-schema:'); + if (edit === 'no-edit') return; + if (edit === 'description' || edit === 'both') + request.tools[0].description = 'Edited clean description.'; + if (edit === 'parameters' || edit === 'both') + request.tools[0].parameters = replacement; + return request; + }, + }); + expect(result.finalOutput).toBe('done'); + const declaration = z + .array(jsonObject) + .parse(server.requests[0].tools) + .find((tool) => tool.name === 'lookup'); + expect(declaration).toBeDefined(); + expect(declaration!.description).toBe( + edit === 'description' || edit === 'both' + ? 'Edited clean description.' + : 'Original raw-schema description.', + ); + expect(JSON.stringify(declaration)).not.toContain('[agents-schema:'); + expect(declaration!.parameters).toEqual( + edit === 'parameters' || edit === 'both' + ? replacement + : selected.parameters, + ); + expect(server.requests).toHaveLength(1); + }, +); + +it('preserves raw edited bounds and null schema keys while original execution validation remains authoritative', async () => { + const effects: number[] = []; + const selected = tool({ + name: 'lookup', + parameters: z.object({ value: z.number().max(10) }), + execute: ({ value }) => { + effects.push(value); + return 'effect'; + }, + }); + const replacement = { + type: 'object', + properties: { + value: { type: 'number', maximum: 20, unknown_null_key: null }, + }, + required: ['value'], + additionalProperties: false, + }; + const server = new ModelServer([ + [call('lookup', { value: 7 }, { callId: 'allowed' })], + [call('lookup', { value: 15 }, { callId: 'above-original-maximum' })], + ]); + await using provider = await providerFor(server); + const result = new Agent({ + model: 'gpt-5.5', + instructions: 'Call lookup then answer.', + provider, + tools: [selected], + }).run('go', { + prepareRequest({ request }) { + request.tools[0].parameters = structuredClone(replacement); + return request; + }, + }); + await expect(result).rejects.toMatchObject({ + issues: [ + expect.objectContaining({ + code: 'too_big', + maximum: 10, + path: ['value'], + }), + ], + }); + expect(effects).toEqual([7]); + expect(server.requests).toHaveLength(2); + for (const request of server.requests) { + expect(request.tools).toEqual([ + expect.objectContaining({ + name: 'lookup', + type: 'function', + parameters: replacement, + }), + ]); + expect(JSON.stringify(request.tools)).not.toContain('[agents-schema:'); + } +}); + +it('rejects a lossy ordinary MCP schema edit without a managed raw receipt before HTTP or effects', async () => { + const remote = await peer(); + const selected = new HttpMCP({ + name: 'records', + url: remote.url, + tools: ['lookup'], + deferLoading: false, + }); + const server = new ModelServer([]); + await using provider = await providerFor(server); + await expect( + new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + tools: [selected], + }).run('go', { + prepareRequest({ request }) { + request.tools[0].parameters = { + type: 'object', + properties: { + value: { type: 'number', maximum: 20, unknown_null_key: null }, + }, + required: ['value'], + }; + return request; + }, + }), + ).rejects.toThrow(/loses fields|prepared.request/i); + expect(remote.effects).toHaveLength(0); + expect(server.requests).toHaveLength(0); +}); diff --git a/packages/agents-codex/test/native/tracing-parity.test.ts b/packages/agents-codex/test/native/tracing-parity.test.ts index ae9ad87d2..598c6876e 100644 --- a/packages/agents-codex/test/native/tracing-parity.test.ts +++ b/packages/agents-codex/test/native/tracing-parity.test.ts @@ -17,6 +17,7 @@ for (const mode of [ 'processors', 'restore', 'retained', + 'model-preparation', 'parent-policy', 'no-export', 'custom-only', diff --git a/packages/agents-codex/test/prepared-request.test.ts b/packages/agents-codex/test/prepared-request.test.ts new file mode 100644 index 000000000..f42e93698 --- /dev/null +++ b/packages/agents-codex/test/prepared-request.test.ts @@ -0,0 +1,206 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as nativeBinding from '../src/runtime/binding'; +import * as turnReceipt from '../src/runtime/turn-receipt'; +import { Agent, NativeProvider, tool, HttpMCP, checklist } from '../src'; +import { z } from 'zod'; +import { + capturePreparedRequest, + preparedRequestFromNative, + type PreparedRequestData, +} from '../src/runtime/prepared-request'; + +afterEach(() => vi.restoreAllMocks()); + +const native = () => ({ + instructions: 'original', + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello' }], + }, + ], + tools: [ + { + namespace: null, + name: 'reserve', + kind: 'function', + strict: true, + defer_loading: null, + description: 'reserve allowed', + parameters: { + type: 'object', + properties: { value: { type: 'string', enum: ['allowed'] } }, + }, + }, + ], +}); + +describe('prepared request capture boundary', () => { + it('detaches native input and preserves public camelCase evidence through strict capture', () => { + const source = native(); + const request = preparedRequestFromNative(source); + request.instructions = 'sampling only'; + (request.input[0] as { role: string }).role = 'system'; + request.tools[0].description = 'changed'; + expect(source).toEqual(native()); + const captured = capturePreparedRequest(request); + request.instructions = 'retained mutation'; + expect(JSON.parse(captured)).toMatchObject({ + instructions: 'sampling only', + tools: [{ defer_loading: null, description: 'changed' }], + }); + expect(captured).not.toContain('deferLoading'); + }); + it.each(['extra', 'defer_loading'])( + 'rejects unknown public fields even when undefined: %s', + (key) => { + const request = preparedRequestFromNative(native()); + Object.assign(request, { [key]: undefined }); + expect(() => capturePreparedRequest(request)).toThrow( + 'Unknown prepared request field', + ); + const tool = preparedRequestFromNative(native()); + Object.assign(tool.tools[0], { [key]: undefined }); + expect(() => capturePreparedRequest(tool)).toThrow( + 'Unknown prepared request field', + ); + }, + ); + it.each([undefined, NaN, Infinity, new Date(), { toJSON: () => ({}) }])( + 'rejects lossy nested JSON: %s', + (invalid) => { + const request = preparedRequestFromNative(native()); + request.input.push({ unknown: invalid } as never); + expect(() => capturePreparedRequest(request)).toThrow(); + }, + ); + it('rejects omitted fields, cycles, sparse arrays, and non-object schemas', () => { + const request = preparedRequestFromNative(native()); + delete (request as Partial).instructions; + expect(() => capturePreparedRequest(request)).toThrow(); + const cyclic = preparedRequestFromNative(native()); + cyclic.input.push(cyclic as never); + expect(() => capturePreparedRequest(cyclic)).toThrow(); + const sparse = preparedRequestFromNative(native()); + sparse.input = new Array(2); + expect(() => capturePreparedRequest(sparse)).toThrow(); + const schema = preparedRequestFromNative(native()); + schema.tools[0].parameters = [] as never; + expect(() => capturePreparedRequest(schema)).toThrow(); + }); +}); + +it.each([ + 'websocket', + 'deferred-function', + 'deferred-mcp', + 'checklist', +] as const)( + 'rejects unsupported %s before provider acquisition or editor invocation', + async (profile) => { + const provider = new NativeProvider( + profile === 'websocket' ? {} : { supportsWebsockets: false }, + ); + const acquire = vi.spyOn(provider, '_acquire'); + const editor = vi.fn(() => undefined); + const tools = + profile === 'deferred-function' + ? [ + tool({ + name: 'lookup', + deferLoading: true, + parameters: z.object({}), + execute: () => 'unreachable', + }), + ] + : profile === 'deferred-mcp' + ? [ + new HttpMCP({ + name: 'remote', + tools: ['lookup'], + deferLoading: true, + url: 'http://127.0.0.1:1/mcp', + }), + ] + : profile === 'checklist' + ? [checklist()] + : []; + await expect( + new Agent({ instructions: 'Answer.', provider, tools }).run('go', { + prepareRequest: editor, + }), + ).rejects.toThrow( + profile === 'websocket' ? 'supportsWebsockets: false' : 'eager function', + ); + expect(acquire).not.toHaveBeenCalled(); + expect(editor).not.toHaveBeenCalled(); + await provider.close(); + }, +); + +it.each(['default', 'override'] as const)( + 'rejects resolved %s output correction before provider acquisition', + async (source) => { + const provider = new NativeProvider({ supportsWebsockets: false }); + const acquire = vi.spyOn(provider, '_acquire'); + const loadFiles = vi + .spyOn(turnReceipt, 'loadInstructionFiles') + .mockRejectedValue( + new Error('instruction loading must follow editor preflight'), + ); + const prepareRequest = vi.fn(() => undefined); + const outputType = { + mode: 'prompted' as const, + schema: z.object({ answer: z.string() }), + maxRetries: 1, + }; + const agent = new Agent({ + instructions: 'Answer.', + provider, + ...(source === 'default' ? { outputType } : {}), + }); + const view = + source === 'default' ? agent : agent.withOutputType(outputType); + await expect( + view.run('go', { + prepareRequest, + instructionFiles: ['unreachable-instructions.md'], + }), + ).rejects.toThrow('output correction attempts'); + expect(loadFiles).not.toHaveBeenCalled(); + expect(acquire).not.toHaveBeenCalled(); + expect(prepareRequest).not.toHaveBeenCalled(); + await provider.close(); + }, +); + +it.each(['native', 'prompted-zero', 'omitted-editor'] as const)( + 'retains public admission for %s output', + async (profile) => { + const provider = new NativeProvider({ supportsWebsockets: false }); + const boundary = new Error('native loader boundary'); + const loader = vi + .spyOn(nativeBinding, 'binding') + .mockRejectedValueOnce(boundary); + const acquire = vi.spyOn(provider, '_acquire'); + const outputType = + profile === 'native' + ? { mode: 'native' as const, schema: z.object({ answer: z.string() }) } + : { + mode: 'prompted' as const, + schema: z.object({ answer: z.string() }), + maxRetries: profile === 'prompted-zero' ? 0 : 1, + }; + const agent = new Agent({ instructions: 'Answer.', provider, outputType }); + await expect( + agent.run( + 'go', + profile === 'omitted-editor' ? {} : { prepareRequest: () => undefined }, + ), + ).rejects.toBe(boundary); + expect(loader).toHaveBeenCalledTimes(1); + expect(acquire).not.toHaveBeenCalled(); + await provider.close(); + }, +); From ea8a96efad573b9e30d6d43090ad6b08be8a7e5a Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:40:33 +0000 Subject: [PATCH 31/34] Preserve raw managed schemas in prepared requests --- .../native/rust/schema_fidelity.rs | 42 ++++++++++++++- .../native/prepared-request-native.test.ts | 51 +++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/packages/agents-codex/native/rust/schema_fidelity.rs b/packages/agents-codex/native/rust/schema_fidelity.rs index 213929010..fe013bd49 100644 --- a/packages/agents-codex/native/rust/schema_fidelity.rs +++ b/packages/agents-codex/native/rust/schema_fidelity.rs @@ -190,6 +190,8 @@ impl Receipts { if let Some(owner) = &owner { let originals = functions(&owner.generation.specs); tool.description = originals[owner.slot].1.description.clone(); + // Core's projection omits raw JSON Schema keywords. + tool.parameters = originals[owner.slot].1.input_schema.clone(); } managed.push(owner); } @@ -225,6 +227,10 @@ impl Receipts { let public = &captured.data.tools[index]; let description_changed = tool.description != public.description; let parameters_changed = tool.parameters != public.parameters; + if !parameters_changed { + // Return the unchanged declaration in Core's original form. + tool.parameters = captured.original[index].parameters.clone(); + } if !description_changed && !parameters_changed { tool.description = captured.original[index].description.clone(); continue; @@ -621,6 +627,10 @@ mod prepared_tests { let view = receipts.prepared(data).unwrap(); assert_eq!(view.data.tools[0].description, "Original description."); assert!(!view.data.tools[0].description.contains(MARKER.as_str())); + assert_eq!( + view.data.tools[0].parameters, + functions(&original.specs)[0].1.input_schema + ); let mut edited = view.data.clone(); if description_changed { edited.tools[0].description = "Edited description.".into(); @@ -630,7 +640,16 @@ mod prepared_tests { } let returned_parameters = edited.tools[0].parameters.clone(); let metadata = receipts.reconcile_prepared(&view, &mut edited).unwrap(); - assert_eq!(edited.tools[0].parameters, returned_parameters); + if parameters_changed { + let projected_parameters: codex_tools::JsonSchema = + serde_json::from_value(returned_parameters.clone()).unwrap(); + assert_eq!( + edited.tools[0].parameters, + serde_json::to_value(projected_parameters).unwrap() + ); + } else { + assert_eq!(edited.tools[0].parameters, view.original[0].parameters); + } assert_eq!( metadata.is_some(), description_changed || parameters_changed @@ -661,6 +680,27 @@ mod prepared_tests { } } #[test] + fn partial_parameter_edits_keep_original_raw_keywords() { + let receipts = Receipts::default(); + let (original, data) = fixture(&receipts); + let view = receipts.prepared(data).unwrap(); + assert_eq!( + view.data.tools[0].parameters["properties"]["key"]["minLength"], + json!(3) + ); + let mut edited = view.data.clone(); + edited.tools[0].parameters["properties"]["key"]["maxLength"] = json!(9); + let expected = edited.tools[0].parameters.clone(); + let _metadata = receipts.reconcile_prepared(&view, &mut edited).unwrap(); + let mut request = wire(&edited); + receipts.restore(&mut request).unwrap(); + assert_eq!(request["tools"][0]["parameters"], expected); + assert_eq!( + functions(&original.specs)[0].1.input_schema["properties"]["key"]["maxLength"], + Value::Null + ); + } + #[test] fn managed_schema_projection_keeps_immutable_identity_for_core() { let receipts = Receipts::default(); let (_original, data) = fixture(&receipts); diff --git a/packages/agents-codex/test/native/prepared-request-native.test.ts b/packages/agents-codex/test/native/prepared-request-native.test.ts index d6a7d0aa9..c24a351a3 100644 --- a/packages/agents-codex/test/native/prepared-request-native.test.ts +++ b/packages/agents-codex/test/native/prepared-request-native.test.ts @@ -257,6 +257,57 @@ it('combines prepared edits, argument correction and result shaping through publ expect(reserve.description).toBe('Original declaration.'); }); +it.each(['none', 'description', 'partial'] as const)( + 'preserves raw managed schemas through %s preparation', + async (edit) => { + const execute = vi.fn(() => 'unused'); + const lookup = tool({ + name: 'lookup', + description: 'Original lookup.', + parameters: z.object({ value: z.string().min(3) }), + execute, + }); + const server = new ModelServer([[message('done')]]); + await using provider = await providerFor(server); + const agent = new Agent({ + model: 'fixture-standard', + instructions: 'Answer.', + provider, + tools: [lookup], + }); + const result = await agent.run('go', { + prepareRequest({ request }) { + expect(request.tools[0].parameters).toMatchObject({ + properties: { value: { minLength: 3 } }, + }); + if (edit === 'description') + request.tools[0].description = 'Edited lookup.'; + if (edit === 'partial') { + const properties = jsonObject.parse( + request.tools[0].parameters.properties, + ); + request.tools[0].parameters = { + ...request.tools[0].parameters, + properties: { + ...properties, + value: { ...jsonObject.parse(properties.value), maxLength: 9 }, + }, + }; + } + return request; + }, + }); + expect(result.finalOutput).toBe('done'); + expect(server.requests).toHaveLength(1); + const declarations = JSON.stringify(server.requests[0].tools); + expect(declarations).toContain('"minLength":3'); + if (edit === 'partial') expect(declarations).toContain('"maxLength":9'); + if (edit === 'description') + expect(declarations).toContain('Edited lookup.'); + expect(execute).not.toHaveBeenCalled(); + }, +); + it('keeps the original function validator when a sampled enum is widened', async () => { const execute = vi.fn(() => 'effect'); const reserve = tool({ From 8edad04dd316a9bf3deb48a2fda7e24b48258ce9 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:17:05 +0000 Subject: [PATCH 32/34] Preserve completed answers when skipping prepared post-turn compaction --- .../0054-native-prepared-request.patch | 39 +++++++++--------- .../agents-codex/native/patches/series.json | 4 +- .../native/prepared-request-native.test.ts | 40 +++++++++++++++++++ 3 files changed, 63 insertions(+), 20 deletions(-) diff --git a/packages/agents-codex/native/patches/0054-native-prepared-request.patch b/packages/agents-codex/native/patches/0054-native-prepared-request.patch index f8f99f008..91700b6b4 100644 --- a/packages/agents-codex/native/patches/0054-native-prepared-request.patch +++ b/packages/agents-codex/native/patches/0054-native-prepared-request.patch @@ -11,10 +11,10 @@ index 295d1f826b19efa6e781761ca0c3d8bba6dd0d8a..61d3e12d4c59edaed9778a9bcc2cc4c6 "tokio", ] diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs -index 55f091582aa2ebfe4bee24de2f65285c3bc9f625..9ff682dfffe02ab464d03a64fa3349f8afd0f9e8 100644 +index 3d02d17fa16a24eee88d36407542b3ae97ab4f84..b5b634ea44ac24009e6f29a665bd606fc1bb6bb6 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs -@@ -1781,6 +1781,9 @@ impl ModelClientSession { +@@ -1787,6 +1787,9 @@ impl ModelClientSession { ) .with_telemetry(Some(request_telemetry), Some(sse_telemetry)); crate::model_request::wait_if_sampling_stopped(prompt.sampling_stop.as_deref()).await; @@ -22,7 +22,7 @@ index 55f091582aa2ebfe4bee24de2f65285c3bc9f625..9ff682dfffe02ab464d03a64fa3349f8 + admission.admit_request().map_err(CodexErr::Fatal)?; + } let stream_result = client.stream_request(request, options).await; - + match stream_result { diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 6188306e2ae26a96b758f2359697dc4ad4654b36..764b7b82562a831a482ce8c76187d4e1bc46e288 100644 @@ -31,14 +31,14 @@ index 6188306e2ae26a96b758f2359697dc4ad4654b36..764b7b82562a831a482ce8c76187d4e1 @@ -34,6 +34,10 @@ pub struct Prompt { /// Stops new provider requests for this turn, including prompts without tools. pub(crate) sampling_stop: Option>, - + + /// Request-scoped host ownership, checked independently of response policies. + pub(crate) prepared_request_admission: + Option>, + /// Whether parallel tool calls are permitted for this prompt. pub(crate) parallel_tool_calls: bool, - + @@ -55,6 +59,7 @@ impl Default for Prompt { tools: Arc::default(), tool_admission: None, @@ -367,7 +367,7 @@ index 0000000000000000000000000000000000000000..a70e5faf6d3b77f8fa6d90f9c92276ae + } +} diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs -index 030a65b21d390af8a7f90bdd674039d09f370316..0375cd4c8c0a0cd4d835bf006ca5bfe548a41720 100644 +index 030a65b21d390af8a7f90bdd674039d09f370316..9c43cb1685b0f4ea8a6a300282a8d54f7a2aafd5 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1,3 +1,4 @@ @@ -375,7 +375,7 @@ index 030a65b21d390af8a7f90bdd674039d09f370316..0375cd4c8c0a0cd4d835bf006ca5bfe5 use std::collections::HashMap; use std::collections::HashSet; use std::sync::Arc; -@@ -1465,6 +1466,17 @@ async fn run_auto_compact( +@@ -1465,6 +1466,20 @@ async fn run_auto_compact( phase: CompactionPhase, ) -> CodexResult<()> { let turn_context = &step_context.turn; @@ -386,6 +386,9 @@ index 030a65b21d390af8a7f90bdd674039d09f370316..0375cd4c8c0a0cd4d835bf006ca5bfe5 + .iter() + .any(|contributor| contributor.prepares_request(&turn_context.extension_data)) + { ++ if matches!(phase, CompactionPhase::PostTurn) { ++ return Ok(()); ++ } + let err = CodexErr::Fatal("automatic compaction is unsupported for an operation with prepare_request; shorten the input or start a new operation".into()); + return Err(err); + } @@ -393,7 +396,7 @@ index 030a65b21d390af8a7f90bdd674039d09f370316..0375cd4c8c0a0cd4d835bf006ca5bfe5 if matches!(phase, CompactionPhase::PreTurn) && crate::guardian::is_basic_session_source(&turn_context.session_source) && !crate::guardian::should_compact_guardian_input(sess)? -@@ -1608,6 +1620,7 @@ pub(crate) fn build_prompt( +@@ -1608,6 +1623,7 @@ pub(crate) fn build_prompt( parallel_tool_calls: true, tool_admission: None, sampling_stop: None, @@ -401,7 +404,7 @@ index 030a65b21d390af8a7f90bdd674039d09f370316..0375cd4c8c0a0cd4d835bf006ca5bfe5 base_instructions, output_schema: turn_context.final_output_json_schema.clone(), output_schema_strict: !crate::guardian::is_basic_session_source( -@@ -1659,6 +1672,7 @@ async fn run_sampling_request( +@@ -1659,6 +1675,7 @@ async fn run_sampling_request( ); let max_retries = turn_context.provider.info().stream_max_retries(); let mut retry_state = ResponsesStreamRetryState::default(); @@ -409,7 +412,7 @@ index 030a65b21d390af8a7f90bdd674039d09f370316..0375cd4c8c0a0cd4d835bf006ca5bfe5 let mut initial_input = Some(input); let mut original_input = None; let mut executed_tool_calls_by_output = HashMap::new(); -@@ -1691,6 +1705,27 @@ async fn run_sampling_request( +@@ -1691,6 +1708,27 @@ async fn run_sampling_request( let responses_metadata = sess .responses_metadata(step_context.as_ref(), CodexResponsesRequestKind::Turn) .await; @@ -437,7 +440,7 @@ index 030a65b21d390af8a7f90bdd674039d09f370316..0375cd4c8c0a0cd4d835bf006ca5bfe5 if crate::guardian::is_basic_session_source(&turn_context.session_source) { crate::guardian::prepare_guardian_prompt( &sess, -@@ -1700,6 +1735,34 @@ async fn run_sampling_request( +@@ -1700,6 +1738,34 @@ async fn run_sampling_request( ) .await?; } @@ -472,7 +475,7 @@ index 030a65b21d390af8a7f90bdd674039d09f370316..0375cd4c8c0a0cd4d835bf006ca5bfe5 let err = match try_run_sampling_request( tool_runtime.clone(), Arc::clone(&sess), -@@ -1708,7 +1771,7 @@ async fn run_sampling_request( +@@ -1708,7 +1774,7 @@ async fn run_sampling_request( client_session, &responses_metadata, Arc::clone(&turn_diff_tracker), @@ -481,7 +484,7 @@ index 030a65b21d390af8a7f90bdd674039d09f370316..0375cd4c8c0a0cd4d835bf006ca5bfe5 cancellation_token.child_token(), ) .await -@@ -1765,6 +1828,7 @@ async fn run_sampling_request( +@@ -1765,6 +1831,7 @@ async fn run_sampling_request( )); } retry??; @@ -499,7 +502,7 @@ index 4b487226eb7deb0a44ad5d9ae16e53f90fd7ec8e..3e19d0358c23532df40aca4dff37125b codex-utils-path-uri = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } - + [dev-dependencies] diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index f2dbed8d89823999a82e89e43576ecf7efab7a5f..54d70d95f627509f85c177d38173c8568ebd5666 100644 @@ -508,7 +511,7 @@ index f2dbed8d89823999a82e89e43576ecf7efab7a5f..54d70d95f627509f85c177d38173c856 @@ -126,6 +126,12 @@ pub use model_request::ModelToolAdmission; pub use model_request::ResponseEvent; pub use model_request::TurnSamplingStop; - + +pub use model_request::PreparedRequestAdmission; +pub use model_request::PreparedRequestData; +pub use model_request::PreparedRequestInput; @@ -536,7 +539,7 @@ index 5263f85cd4517aa8b72186b02ac91d9c08d0b2e2..34d02776f0119a72955a827c3a5cf48c @@ -78,9 +81,25 @@ pub struct ModelRequestInput<'a> { pub model: &'a str, } - + -/// Creates request-scoped interceptors without delaying inference. +/// Contributes request-scoped preparation and response interception. /// Return None for requests this host does not manage. @@ -559,11 +562,11 @@ index 5263f85cd4517aa8b72186b02ac91d9c08d0b2e2..34d02776f0119a72955a827c3a5cf48c + fn request(&self, input: ModelRequestInput<'_>) -> Option>; } - + @@ -114,6 +133,46 @@ pub trait ModelResponseInterceptor: Send + Sync { fn intercept(self: Box, stream: ModelResponseStream) -> ModelResponseStream; } - + +/// Detached native sampling data. Unknown fields are rejected at the host reply boundary. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] diff --git a/packages/agents-codex/native/patches/series.json b/packages/agents-codex/native/patches/series.json index 1fabe30a7..a4cb02204 100644 --- a/packages/agents-codex/native/patches/series.json +++ b/packages/agents-codex/native/patches/series.json @@ -266,8 +266,8 @@ }, { "path": "0054-native-prepared-request.patch", - "sha256": "2b56a9551dcdd687e5aef834109d9ff5b4ab3914fde2eae81f4b42c54caaa21d" + "sha256": "616c979bd3accbfdfa978713e2a82f0049645cd3ae9c72ffacff1abcd2c517c2" } ], - "result_tree": "17ce59431c36f0d69e0cbac6d48bcfde37bdf830" + "result_tree": "1c65094c37a0a4099f035256a8bd0cebb66d6bab" } diff --git a/packages/agents-codex/test/native/prepared-request-native.test.ts b/packages/agents-codex/test/native/prepared-request-native.test.ts index c24a351a3..fd3fb0fc6 100644 --- a/packages/agents-codex/test/native/prepared-request-native.test.ts +++ b/packages/agents-codex/test/native/prepared-request-native.test.ts @@ -982,6 +982,46 @@ it.each([true, false])( }, ); +it.each([true, false])( + 'preserves the completed answer at the post-turn threshold with editor=%s', + async (edited) => { + const { binding } = await import('../../src/runtime/binding'); + const native = await binding(); + const create = native.create_engine; + const spy = vi + .spyOn(native, 'create_engine') + .mockImplementation((config, ...args) => + create( + JSON.stringify({ + ...JSON.parse(config), + model_context_window: 200000, + model_auto_compact_token_limit: 10000, + model_post_turn_compact_threshold_percent: 1, + }), + ...args, + ), + ); + const server = new ModelServer( + [[message('completed answer')], [message('summary')]], + ['{"input_tokens":12000,"output_tokens":1,"total_tokens":12001}'], + ); + try { + await using provider = await providerFor(server); + const editor = vi.fn(() => undefined); + const result = await new Agent({ + model: 'fixture-standard', + instructions: 'Answer.', + provider, + }).run('go', edited ? { prepareRequest: editor } : undefined); + expect(result.finalOutput).toBe('completed answer'); + expect(server.requests).toHaveLength(edited ? 1 : 2); + expect(editor).toHaveBeenCalledTimes(edited ? 1 : 0); + } finally { + spy.mockRestore(); + } + }, +); + it('rejects a retired native reply capability while a later root awaits its own callback', async () => { const { binding } = await import('../../src/runtime/binding'); const native = await binding(); From ec7cbc5d310719d9ee1ed4b1cce9745c7a3de830 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:24:25 +0000 Subject: [PATCH 33/34] fix: compose metadata with post-turn preservation --- .changeset/passive-request-metadata.md | 6 + helpers/vitest/nativeAgentTestProfile.ts | 1 + packages/agents-codex/native/index.d.ts | 1 + .../0059-native-request-metadata.patch | 705 ++++++++++++++++++ .../agents-codex/native/patches/series.json | 6 +- packages/agents-codex/native/rust/engine.rs | 6 + packages/agents-codex/native/rust/lib.rs | 6 +- .../native/rust/native_interception.rs | 2 + packages/agents-codex/native/rust/ops.rs | 35 +- .../native/rust/request_metadata.rs | 174 +++++ .../native/rust/turn_settlement_tests.rs | 65 +- packages/agents-codex/src/index.ts | 2 + .../agents-codex/src/runtime/observation.ts | 34 + .../src/runtime/request-metadata.ts | 101 +++ packages/agents-codex/src/runtime/session.ts | 66 +- .../native/request-metadata-native.test.ts | 661 ++++++++++++++++ .../test/native/request-metadata.test.ts | 99 +++ .../native/worker-receipts-native.test.ts | 22 + 18 files changed, 1976 insertions(+), 16 deletions(-) create mode 100644 .changeset/passive-request-metadata.md create mode 100644 packages/agents-codex/native/patches/0059-native-request-metadata.patch create mode 100644 packages/agents-codex/native/rust/request_metadata.rs create mode 100644 packages/agents-codex/src/runtime/request-metadata.ts create mode 100644 packages/agents-codex/test/native/request-metadata-native.test.ts create mode 100644 packages/agents-codex/test/native/request-metadata.test.ts diff --git a/.changeset/passive-request-metadata.md b/.changeset/passive-request-metadata.md new file mode 100644 index 000000000..7e41df7d6 --- /dev/null +++ b/.changeset/passive-request-metadata.md @@ -0,0 +1,6 @@ +--- +'@openai/agents': minor +'@openai/agents-codex': minor +--- + +Expose passive root request metadata and optional links to retained response occurrences through existing observation streams. Report resolved serialized settings and the selected client invocation transport without retaining request bodies or changing inference behavior. diff --git a/helpers/vitest/nativeAgentTestProfile.ts b/helpers/vitest/nativeAgentTestProfile.ts index a09cad1f4..4cd4e5680 100644 --- a/helpers/vitest/nativeAgentTestProfile.ts +++ b/helpers/vitest/nativeAgentTestProfile.ts @@ -17,4 +17,5 @@ export const nativeAgentTestIncludes = 'test/classic/**/*.test.ts', 'test/native/output-state.test.ts', 'test/native/repair-observation.test.ts', + 'test/native/request-metadata.test.ts', ]; diff --git a/packages/agents-codex/native/index.d.ts b/packages/agents-codex/native/index.d.ts index 267969402..30e2c8417 100644 --- a/packages/agents-codex/native/index.d.ts +++ b/packages/agents-codex/native/index.d.ts @@ -201,6 +201,7 @@ export class NativeThread { selectionJson?: string, traceJson?: string, tracing_context?: string, + observationSubmissionId?: string, ): Promise; read_thread_history(subagent_id: string | null): Promise; read_checklist(subagent_id?: string | null): Promise; diff --git a/packages/agents-codex/native/patches/0059-native-request-metadata.patch b/packages/agents-codex/native/patches/0059-native-request-metadata.patch new file mode 100644 index 000000000..32de8b451 --- /dev/null +++ b/packages/agents-codex/native/patches/0059-native-request-metadata.patch @@ -0,0 +1,705 @@ +diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs +index 867efce..2b70746 100644 +--- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs ++++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs +@@ -968,6 +968,65 @@ mod tests { + use std::collections::HashMap; + use std::sync::Arc; + ++ #[tokio::test] ++ #[expect( ++ clippy::await_holding_invalid_type, ++ reason = "the test must hold the socket lock across invocation to prevent a network write" ++ )] ++ async fn dropping_invocation_receiver_while_connection_is_locked_sends_no_frame() { ++ let (tx_command, mut rx_command) = mpsc::channel(1); ++ let (_tx_message, rx_message) = mpsc::unbounded_channel(); ++ let stream = WsStream { ++ tx_command, ++ rx_message, ++ pump_task: tokio::spawn(std::future::pending::<()>()), ++ upgrade_rate_limits: None, ++ }; ++ let connection = ++ ResponsesWebsocketConnection::new(stream, Duration::from_secs(1), false, None, None); ++ // stream_request returns its receiver before its worker acquires this ++ // lock. The caller may already have observed client invocation. ++ let held = connection.stream.lock().await; ++ let request = ResponsesApiRequest { ++ model: "test-model".into(), ++ input: Vec::new(), ++ tools: None, ++ tool_choice: "auto".into(), ++ parallel_tool_calls: false, ++ reasoning: None, ++ store: false, ++ stream: true, ++ stream_options: None, ++ include: Vec::new(), ++ service_tier: None, ++ prompt_cache_key: None, ++ text: None, ++ client_metadata: None, ++ access_programs: None, ++ }; ++ let response = connection ++ .stream_request( ++ ResponsesWsRequest::ResponseCreate(ResponseCreateWsRequest::from(&request)), ++ false, ++ None, ++ ) ++ .await ++ .expect("invocation returns before the socket lock is acquired"); ++ drop(response); ++ drop(held); ++ tokio::time::timeout(Duration::from_secs(1), async { ++ while Arc::strong_count(&connection.stream) > 1 { ++ tokio::task::yield_now().await; ++ } ++ }) ++ .await ++ .expect("the dropped receiver retires the invocation worker"); ++ assert!( ++ rx_command.try_recv().is_err(), ++ "no response.create reaches the socket pump" ++ ); ++ } ++ + #[tokio::test] + async fn websocket_observes_incomplete_before_returning_existing_error() { + let (tx_command, mut rx_command) = mpsc::channel(1); +diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs +index 55f0915..f2d1c89 100644 +--- a/codex-rs/core/src/client.rs ++++ b/codex-rs/core/src/client.rs +@@ -1756,6 +1756,7 @@ impl ModelClientSession { + } + let request_session_telemetry = + session_telemetry_for_request(session_telemetry, &request); ++ let observation_is_reviewer = is_guardian_reviewer(&responses_headers); + options.extra_headers.extend(responses_headers); + let interceptors = crate::model_request::prepare( + &self.client.request_contributors, +@@ -1781,20 +1782,37 @@ impl ModelClientSession { + ) + .with_telemetry(Some(request_telemetry), Some(sse_telemetry)); + crate::model_request::wait_if_sampling_stopped(prompt.sampling_stop.as_deref()).await; + if let Some(admission) = &prompt.prepared_request_admission { + admission.admit_request().map_err(CodexErr::Fatal)?; + } ++ let request_observation = if observation_is_reviewer { ++ None ++ } else { ++ crate::request_observation::dispatching( ++ prompt.tool_admission.as_ref(), ++ &request.model, ++ request.reasoning.as_ref(), ++ request.service_tier.as_deref(), ++ if model_info.use_responses_lite { ++ codex_extension_api::ModelRequestWire::ResponsesLite ++ } else { ++ codex_extension_api::ModelRequestWire::Responses ++ }, ++ codex_extension_api::ModelRequestTransport::HttpSse, ++ ) ++ }; + let stream_result = client.stream_request(request, options).await; + + match stream_result { + Ok(stream) => { +- let (stream, _) = map_response_stream( ++ let (mut stream, _) = map_response_stream( + stream, + request_session_telemetry, + inference_trace_attempt, + Arc::clone(&self.client.state.provider), + interceptors, + ); ++ stream.request_observation = request_observation; + return Ok(stream); + } + Err(ApiError::Transport(unauthorized_transport)) +@@ -2093,6 +2111,23 @@ impl ModelClientSession { + ], + ); + crate::model_request::wait_if_sampling_stopped(prompt.sampling_stop.as_deref()).await; ++ let request_observation = if warmup || is_guardian_reviewer(&responses_headers) { ++ None ++ } else { ++ let ResponsesWsRequest::ResponseCreate(payload) = &ws_request; ++ crate::request_observation::dispatching( ++ prompt.tool_admission.as_ref(), ++ payload.model, ++ payload.reasoning, ++ payload.service_tier, ++ if model_info.use_responses_lite { ++ codex_extension_api::ModelRequestWire::ResponsesLite ++ } else { ++ codex_extension_api::ModelRequestWire::Responses ++ }, ++ codex_extension_api::ModelRequestTransport::Websocket, ++ ) ++ }; + let stream_result = websocket_connection + .stream_request( + ws_request, +@@ -2117,13 +2152,14 @@ impl ModelClientSession { + ); + err + })?; +- let (stream, last_request_rx) = map_response_stream( ++ let (mut stream, last_request_rx) = map_response_stream( + stream_result, + request_session_telemetry, + inference_trace_attempt, + Arc::clone(&self.client.state.provider), + interceptors, + ); ++ stream.request_observation = request_observation; + self.websocket_session.last_response_rx = Some(last_request_rx); + return Ok(WebsocketStreamOutcome::Stream(stream)); + } +@@ -2532,6 +2568,7 @@ where + + ( + ResponseStream { ++ request_observation: None, + rx_event, + interrupt: None, + consumer_dropped: consumer_dropped_for_stream, +diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs +index 6188306..594edaa 100644 +--- a/codex-rs/core/src/client_common.rs ++++ b/codex-rs/core/src/client_common.rs +@@ -125,6 +125,8 @@ fn normalize_image_detail(detail: &mut Option, model_info: &ModelIn + } + + pub struct ResponseStream { ++ pub(crate) request_observation: ++ Option>, + pub(crate) rx_event: mpsc::Receiver>, + pub(crate) interrupt: Option>, + /// Signals the mapper task that the consumer stopped polling before the +diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs +index f1af831..2e88f11 100644 +--- a/codex-rs/core/src/compact_remote_v2.rs ++++ b/codex-rs/core/src/compact_remote_v2.rs +@@ -846,6 +846,7 @@ mod tests { + } + drop(tx_event); + ResponseStream { ++ request_observation: None, + rx_event, + interrupt: None, + consumer_dropped: CancellationToken::new(), +diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs +index ba7896a..7e3d2ef 100644 +--- a/codex-rs/core/src/lib.rs ++++ b/codex-rs/core/src/lib.rs +@@ -15,6 +15,7 @@ mod realtime_context; + mod realtime_conversation; + mod realtime_history; + mod realtime_prompt; ++mod request_observation; + mod responses_headers; + pub use responses_headers::CodexResponsesHeaders; + mod responses_metadata; +diff --git a/codex-rs/core/src/model_request_tests.rs b/codex-rs/core/src/model_request_tests.rs +index cf943d3..5b33e6d 100644 +--- a/codex-rs/core/src/model_request_tests.rs ++++ b/codex-rs/core/src/model_request_tests.rs +@@ -30,6 +30,7 @@ async fn interceptors_forward_before_completion_in_order_and_cancel_upstream() { + let mut stream = intercept_stream( + Box::pin( + ResponseStream { ++ request_observation: None, + rx_event, + interrupt: None, + consumer_dropped, +diff --git a/codex-rs/core/src/request_observation.rs b/codex-rs/core/src/request_observation.rs +new file mode 100644 +index 0000000..bf67bf3 +--- /dev/null ++++ b/codex-rs/core/src/request_observation.rs +@@ -0,0 +1,103 @@ ++//! Final scalar projection at selected client invocation; delivery is best effort. ++use codex_api::Reasoning; ++use codex_extension_api::ModelRequestDispatching; ++use codex_extension_api::ModelRequestEffort; ++use codex_extension_api::ModelRequestObservation; ++use codex_extension_api::ModelRequestObservationRoute; ++use codex_extension_api::ModelRequestTransport; ++use codex_extension_api::ModelRequestWire; ++use codex_extension_api::ModelResponseLinked; ++use codex_extension_api::ModelToolAdmission; ++use std::sync::Arc; ++ ++/// Transient request identity and the original immutable SDK submission route. ++#[derive(Debug)] ++pub(crate) struct RequestObservationContext { ++ // Retain original store object identity; never re-query a current turn. ++ _admission: Arc, ++ request_id: String, ++ thread_id: String, ++ turn_id: String, ++ route: Arc, ++} ++impl RequestObservationContext { ++ pub(crate) fn response_linked(&self, response_observation_id: &str) { ++ self.route ++ .0 ++ .observe(ModelRequestObservation::ResponseLinked( ++ ModelResponseLinked { ++ request_id: self.request_id.clone(), ++ thread_id: self.thread_id.clone(), ++ turn_id: self.turn_id.clone(), ++ response_observation_id: response_observation_id.to_owned(), ++ }, ++ )); ++ } ++} ++ ++/// Serialize only Reasoning so effort follows the native wire serializer exactly. ++/// A projection failure drops metadata; it must never reject an inference request. ++fn resolved_reasoning( ++ reasoning: Option<&Reasoning>, ++) -> Option<(Option, Option)> { ++ let Some(reasoning) = reasoning else { ++ return Some((None, None)); ++ }; ++ let value = serde_json::to_value(reasoning).ok()?; ++ let effort = match value.get("effort") { ++ None | Some(serde_json::Value::Null) => None, ++ Some(serde_json::Value::String(value)) => Some(ModelRequestEffort::Text(value.clone())), ++ Some(value) => Some(ModelRequestEffort::Integer(value.as_u64()?)), ++ }; ++ let summary = match value.get("summary") { ++ None | Some(serde_json::Value::Null) => None, ++ Some(value) => Some(value.as_str()?.to_owned()), ++ }; ++ Some((effort, summary)) ++} ++ ++/// Invoke synchronously after applicable existing checks, before entering the API. ++/// The transport may still fail encoding or send zero bytes. Its internal retries ++/// share this invocation; later client invocations call this function afresh. ++pub(crate) fn dispatching( ++ admission: Option<&Arc>, ++ model: &str, ++ reasoning: Option<&Reasoning>, ++ service_tier: Option<&str>, ++ wire: ModelRequestWire, ++ transport: ModelRequestTransport, ++) -> Option> { ++ let admission = admission?; ++ let route = admission ++ .turn_store() ++ .get::()?; ++ let (reasoning_effort, reasoning_summary) = resolved_reasoning(reasoning)?; ++ let context = Arc::new(RequestObservationContext { ++ _admission: Arc::clone(admission), ++ request_id: uuid::Uuid::now_v7().to_string(), ++ thread_id: admission.thread_id().to_owned(), ++ turn_id: admission.turn_store().level_id().to_owned(), ++ route, ++ }); ++ context ++ .route ++ .0 ++ .observe(ModelRequestObservation::Dispatching( ++ ModelRequestDispatching { ++ request_id: context.request_id.clone(), ++ thread_id: context.thread_id.clone(), ++ turn_id: context.turn_id.clone(), ++ model: model.to_owned(), ++ reasoning_effort, ++ reasoning_summary, ++ service_tier: service_tier.map(str::to_owned), ++ wire, ++ transport, ++ }, ++ )); ++ Some(context) ++} ++ ++#[cfg(test)] ++#[path = "request_observation_tests.rs"] ++mod tests; +diff --git a/codex-rs/core/src/request_observation_tests.rs b/codex-rs/core/src/request_observation_tests.rs +new file mode 100644 +index 0000000..9665e64 +--- /dev/null ++++ b/codex-rs/core/src/request_observation_tests.rs +@@ -0,0 +1,205 @@ ++use super::*; ++use codex_extension_api::ExtensionData; ++use codex_extension_api::ModelRequestObserver; ++use codex_protocol::config_types::ReasoningSummary; ++use codex_protocol::models::ResponseItem; ++use codex_protocol::openai_models::ReasoningEffort; ++use serde_json::json; ++use std::sync::atomic::{AtomicBool, Ordering}; ++use tokio::sync::mpsc; ++ ++#[derive(Debug)] ++struct BoundedRoute { ++ sender: mpsc::Sender, ++ retired: AtomicBool, ++} ++impl ModelRequestObserver for BoundedRoute { ++ fn observe(&self, observation: ModelRequestObservation) { ++ if !self.retired.load(Ordering::Acquire) { ++ // Full and closed queues intentionally lose observations. ++ let _ = self.sender.try_send(observation); ++ } ++ } ++} ++#[derive(Debug)] ++struct Admission { ++ thread: String, ++ store: ExtensionData, ++} ++impl ModelToolAdmission for Admission { ++ fn thread_id(&self) -> &str { ++ &self.thread ++ } ++ fn turn_store(&self) -> &ExtensionData { ++ &self.store ++ } ++ fn admits(&self, _: &ResponseItem) -> bool { ++ false ++ } ++} ++ ++fn fixture( ++ capacity: usize, ++) -> ( ++ Arc, ++ Arc, ++ mpsc::Receiver, ++) { ++ let (sender, receiver) = mpsc::channel(capacity); ++ let route = Arc::new(BoundedRoute { ++ sender, ++ retired: AtomicBool::new(false), ++ }); ++ let admission = Arc::new(Admission { ++ thread: "original-thread".into(), ++ store: ExtensionData::new("original-turn"), ++ }); ++ admission ++ .store ++ .get_or_init(|| ModelRequestObservationRoute(route.clone())); ++ (admission, route, receiver) ++} ++fn notify( ++ admission: Option<&Arc>, ++) -> Option> { ++ dispatching( ++ admission, ++ "resolved-model", ++ None, ++ None, ++ ModelRequestWire::Responses, ++ ModelRequestTransport::HttpSse, ++ ) ++} ++ ++#[test] ++fn effort_bridge_preserves_wire_scalar_kind_and_exact_integer() { ++ for (input, expected) in [ ++ ( ++ "18446744073709551615", ++ json!({"kind":"integer", "decimal":"18446744073709551615"}), ++ ), ++ ( ++ "9007199254740993", ++ json!({"kind":"integer", "decimal":"9007199254740993"}), ++ ), ++ ("00042", json!({"kind":"integer", "decimal":"42"})), ++ ( ++ "18446744073709551616", ++ json!({"kind":"text", "value":"18446744073709551616"}), ++ ), ++ ("1e3", json!({"kind":"text", "value":"1e3"})), ++ ("high", json!({"kind":"text", "value":"high"})), ++ ] { ++ let reasoning = Reasoning { ++ effort: Some(ReasoningEffort::Custom(input.into())), ++ summary: None, ++ context: None, ++ }; ++ let (effort, summary) = ++ resolved_reasoning(Some(&reasoning)).expect("known scalar reasoning"); ++ assert_eq!(effort.expect("present effort").to_bridge_value(), expected); ++ assert_eq!(summary, None); ++ } ++ assert_eq!(resolved_reasoning(None), Some((None, None))); ++ let reasoning = Reasoning { ++ effort: None, ++ summary: Some(ReasoningSummary::Detailed), ++ context: None, ++ }; ++ assert_eq!( ++ resolved_reasoning(Some(&reasoning)), ++ Some((None, Some("detailed".into()))) ++ ); ++} ++ ++#[test] ++fn no_captured_route_produces_no_observation_or_rejection() { ++ assert!(notify(None).is_none()); ++ let admission: Arc = Arc::new(Admission { ++ thread: "thread".into(), ++ store: ExtensionData::new("turn"), ++ }); ++ assert!(notify(Some(&admission)).is_none()); ++} ++ ++#[test] ++fn invocation_and_terminal_link_share_exact_original_identity() { ++ let (admission, _, mut receiver) = fixture(8); ++ let first = notify(Some(&admission)).expect("captured root route"); ++ let second = dispatching( ++ Some(&admission), ++ "resolved-model-2", ++ None, ++ Some("flex"), ++ ModelRequestWire::ResponsesLite, ++ ModelRequestTransport::Websocket, ++ ) ++ .expect("captured root route"); ++ assert!(std::ptr::eq( ++ first._admission.turn_store(), ++ admission.turn_store() ++ )); ++ let first_event = receiver ++ .try_recv() ++ .expect("first invocation") ++ .to_bridge_value(); ++ let second_event = receiver ++ .try_recv() ++ .expect("second invocation") ++ .to_bridge_value(); ++ assert_ne!(first_event["request_id"], second_event["request_id"]); ++ assert_eq!(first_event["thread_id"], "original-thread"); ++ assert_eq!(first_event["turn_id"], "original-turn"); ++ assert_eq!(first_event["model"], "resolved-model"); ++ assert_eq!(first_event["service_tier"], serde_json::Value::Null); ++ assert_eq!(first_event["reasoning_effort"], serde_json::Value::Null); ++ assert_eq!(first_event["transport"], "http_sse"); ++ assert_eq!(second_event["wire"], "responses_lite"); ++ assert_eq!(second_event["transport"], "websocket"); ++ assert_eq!(second_event["service_tier"], "flex"); ++ first.response_linked("occurrence-A"); ++ second.response_linked("occurrence-B"); ++ let first_link = receiver ++ .try_recv() ++ .expect("first retained occurrence") ++ .to_bridge_value(); ++ let second_link = receiver ++ .try_recv() ++ .expect("second retained occurrence") ++ .to_bridge_value(); ++ assert_eq!(first_link["request_id"], first_event["request_id"]); ++ assert_eq!(first_link["response_observation_id"], "occurrence-A"); ++ assert_eq!(second_link["request_id"], second_event["request_id"]); ++ assert_eq!(second_link["response_observation_id"], "occurrence-B"); ++ assert_eq!(first_link["thread_id"], "original-thread"); ++ assert_eq!(first_link["turn_id"], "original-turn"); ++ assert_eq!(first_event.as_object().expect("object").len(), 10); ++ assert_eq!(first_link.as_object().expect("object").len(), 5); ++} ++ ++#[test] ++fn native_full_closed_and_retired_routes_are_nonblocking_and_do_not_redirect() { ++ let (admission, route, mut receiver) = fixture(1); ++ let first = notify(Some(&admission)).expect("first context"); ++ // The queue is already full. A second invocation still receives its context. ++ assert!(notify(Some(&admission)).is_some()); ++ receiver.try_recv().expect("only retained notification"); ++ assert!(receiver.try_recv().is_err()); ++ route.retired.store(true, Ordering::Release); ++ first.response_linked("retained-before-retirement"); ++ assert!(receiver.try_recv().is_err()); ++ let (later_admission, _, mut later_receiver) = fixture(1); ++ let later = notify(Some(&later_admission)).expect("later owner"); ++ assert_ne!(first.request_id, later.request_id); ++ first.response_linked("late-old-occurrence"); ++ let later_event = later_receiver ++ .try_recv() ++ .expect("later owner event") ++ .to_bridge_value(); ++ assert_eq!(later_event["request_id"], later.request_id); ++ assert!(later_receiver.try_recv().is_err()); ++ drop(receiver); ++ route.retired.store(false, Ordering::Release); ++ assert!(notify(Some(&admission)).is_some()); ++} +diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs +index d156b94..f6fd9b9 100644 +--- a/codex-rs/core/src/session/turn.rs ++++ b/codex-rs/core/src/session/turn.rs +@@ -2636,6 +2636,7 @@ async fn try_run_sampling_request( + if cancellation_token.is_cancelled() { + return Err(CodexErr::TurnAborted); + } ++ let request_observation = stream.request_observation.clone(); + let mut in_flight: FuturesOrdered> = FuturesOrdered::new(); + let mut needs_follow_up = false; + let mut last_agent_message: Option = None; +@@ -3011,7 +3012,12 @@ async fn try_run_sampling_request( + break Err(CodexErr::TurnAborted); + } + match result { +- Ok(id) => response_observation_id = id, ++ Ok(id) => { ++ if let (Some(context), Some(id)) = (&request_observation, id.as_deref()) { ++ context.response_linked(id); ++ } ++ response_observation_id = id; ++ } + Err(error) => break Err(error), + } + } +diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs +index f2dbed8..ae9c47d 100644 +--- a/codex-rs/ext/extension-api/src/lib.rs ++++ b/codex-rs/ext/extension-api/src/lib.rs +@@ -116,6 +116,13 @@ pub use user_instructions::UserInstructionsProvider; + pub use contributors::ToolDispatchInput; + + mod model_request; ++mod model_request_observation; ++pub use model_request_observation::{ ++ ModelRequestDispatching, ModelRequestEffort, ModelRequestObservation, ++ ModelRequestObservationRoute, ModelRequestObserver, ModelRequestTransport, ModelRequestWire, ++ ModelResponseLinked, ++}; ++ + pub use model_request::ModelRequestContributor; + pub use model_request::ModelRequestInput; + pub use model_request::ModelRequestKind; +diff --git a/codex-rs/ext/extension-api/src/model_request_observation.rs b/codex-rs/ext/extension-api/src/model_request_observation.rs +new file mode 100644 +index 0000000..fe2ebeb +--- /dev/null ++++ b/codex-rs/ext/extension-api/src/model_request_observation.rs +@@ -0,0 +1,117 @@ ++//! Passive, metadata-only observations for an exact SDK-owned root sampling turn. ++use std::sync::Arc; ++ ++/// Effort's serialized scalar kind. Bindings must preserve it before JSON parsing. ++#[derive(Clone, Debug, PartialEq, Eq)] ++pub enum ModelRequestEffort { ++ Text(String), ++ Integer(u64), ++} ++impl ModelRequestEffort { ++ /// Private binding encoding; decimal text prevents host JSON-number rounding. ++ pub fn to_bridge_value(&self) -> serde_json::Value { ++ match self { ++ Self::Text(value) => serde_json::json!({"kind": "text", "value": value}), ++ Self::Integer(value) => { ++ serde_json::json!({"kind": "integer", "decimal": value.to_string()}) ++ } ++ } ++ } ++} ++ ++#[derive(Clone, Copy, Debug, PartialEq, Eq)] ++pub enum ModelRequestWire { ++ Responses, ++ ResponsesLite, ++} ++impl ModelRequestWire { ++ fn as_str(self) -> &'static str { ++ match self { ++ Self::Responses => "responses", ++ Self::ResponsesLite => "responses_lite", ++ } ++ } ++} ++ ++#[derive(Clone, Copy, Debug, PartialEq, Eq)] ++pub enum ModelRequestTransport { ++ HttpSse, ++ Websocket, ++} ++impl ModelRequestTransport { ++ fn as_str(self) -> &'static str { ++ match self { ++ Self::HttpSse => "http_sse", ++ Self::Websocket => "websocket", ++ } ++ } ++} ++ ++/// Reaching this notification does not establish serialization or a network send. ++#[derive(Clone, Debug, PartialEq, Eq)] ++pub struct ModelRequestDispatching { ++ pub request_id: String, ++ pub thread_id: String, ++ pub turn_id: String, ++ pub model: String, ++ pub reasoning_effort: Option, ++ pub reasoning_summary: Option, ++ pub service_tier: Option, ++ pub wire: ModelRequestWire, ++ pub transport: ModelRequestTransport, ++} ++ ++#[derive(Clone, Debug, PartialEq, Eq)] ++pub struct ModelResponseLinked { ++ pub request_id: String, ++ pub thread_id: String, ++ pub turn_id: String, ++ pub response_observation_id: String, ++} ++ ++#[derive(Clone, Debug, PartialEq, Eq)] ++pub enum ModelRequestObservation { ++ Dispatching(ModelRequestDispatching), ++ ResponseLinked(ModelResponseLinked), ++} ++impl ModelRequestObservation { ++ /// Safe private native-to-host bridge. Contains no body, endpoint or headers. ++ pub fn to_bridge_value(&self) -> serde_json::Value { ++ match self { ++ Self::Dispatching(event) => serde_json::json!({ ++ "type": "model_request_dispatching", ++ "request_id": event.request_id, ++ "thread_id": event.thread_id, ++ "turn_id": event.turn_id, ++ "model": event.model, ++ "reasoning_effort": event.reasoning_effort.as_ref().map(ModelRequestEffort::to_bridge_value), ++ "reasoning_summary": event.reasoning_summary, ++ "service_tier": event.service_tier, ++ "wire": event.wire.as_str(), ++ "transport": event.transport.as_str(), ++ }), ++ Self::ResponseLinked(event) => serde_json::json!({ ++ "type": "model_response_linked", ++ "request_id": event.request_id, ++ "thread_id": event.thread_id, ++ "turn_id": event.turn_id, ++ "response_observation_id": event.response_observation_id, ++ }), ++ } ++ } ++} ++ ++/// Internal SDK route, captured for one immutable public submission/handle. ++/// ++/// Implementations must use bounded, nonblocking delivery and return immediately. ++/// Full/closed/retired routes drop the record. Never invoke application callbacks, ++/// look up a current turn, await capacity, or propagate delivery failure to inference. ++/// The captured owner must fence retired submissions without redirecting records. ++pub trait ModelRequestObserver: Send + Sync + std::fmt::Debug { ++ fn observe(&self, observation: ModelRequestObservation); ++} ++ ++/// Install only in a managed direct-root turn's exact ExtensionData store. ++/// Missing routes leave execution unchanged. Children must not inherit this value. ++#[derive(Clone, Debug)] ++pub struct ModelRequestObservationRoute(pub Arc); diff --git a/packages/agents-codex/native/patches/series.json b/packages/agents-codex/native/patches/series.json index a4cb02204..8beb7cfb6 100644 --- a/packages/agents-codex/native/patches/series.json +++ b/packages/agents-codex/native/patches/series.json @@ -267,7 +267,11 @@ { "path": "0054-native-prepared-request.patch", "sha256": "616c979bd3accbfdfa978713e2a82f0049645cd3ae9c72ffacff1abcd2c517c2" + }, + { + "path": "0059-native-request-metadata.patch", + "sha256": "00fb1ca9eff806e34ebd56521d4367c4905e6be285b773a2c32cc8ec617f17ae" } ], - "result_tree": "1c65094c37a0a4099f035256a8bd0cebb66d6bab" + "result_tree": "6e3ca2e842c1caf783abd91fb2aed74233b8366d" } diff --git a/packages/agents-codex/native/rust/engine.rs b/packages/agents-codex/native/rust/engine.rs index 2cbfac7ab..9846d99eb 100644 --- a/packages/agents-codex/native/rust/engine.rs +++ b/packages/agents-codex/native/rust/engine.rs @@ -863,6 +863,7 @@ impl EngineState { let (publication_boundaries, boundary_requests) = mpsc::channel(1); let native_dequeued = Arc::new(AtomicBool::new(false)); let state = Arc::new(ThreadState { + request_metadata: Arc::new(crate::request_metadata::Queue::default()), id: new.thread_id, thread: new.thread.clone(), engine: self.clone(), @@ -1713,6 +1714,7 @@ impl ModelsManager for ConfiguredModels { } pub struct ThreadState { + pub(crate) request_metadata: Arc, pub id: ThreadId, pub thread: Arc, pub engine: Arc, @@ -2748,6 +2750,9 @@ impl ThreadState { return self.root_event(event, None); } loop { + if let Some(metadata) = self.request_metadata.try_next() { + return Ok(metadata); + } if let Some((event, turn)) = &events.session_terminal { tokio::select! { _ = closing.changed() => anyhow::bail!("Codex thread is closed"), @@ -2865,6 +2870,7 @@ impl ThreadState { .is_none_or(|prefix| prefix.extensions > 0); tokio::select! { _ = closing.changed() => anyhow::bail!("Codex thread is closed"), + metadata = self.request_metadata.next() => return Ok(metadata), request = async { python_tools.as_ref().expect("guarded JavaScript channel").next_request().await }, if python_tools.is_some() && !bounded => { diff --git a/packages/agents-codex/native/rust/lib.rs b/packages/agents-codex/native/rust/lib.rs index 39f33a56b..22df3fe10 100644 --- a/packages/agents-codex/native/rust/lib.rs +++ b/packages/agents-codex/native/rust/lib.rs @@ -2905,6 +2905,7 @@ impl NativeThread { selection_json: Option, trace_json: Option, tracing_context: Option, + observation_submission_id: Option, ) -> napi::Result { let trace = resolve_trace_json(trace_json.as_deref(), tracing_context.as_deref()) .map_err(input_error)?; @@ -2966,13 +2967,14 @@ impl NativeThread { .map_err(native_error)?; } let result = ops::recover_prepared( - &inner.thread, + &inner, frame.turn_id, frame.reply_order, settings, output_schema, selection, trace, + observation_submission_id, ) .await .map_err(native_error)?; @@ -4003,3 +4005,5 @@ mod native_trace_merge_tests { codex_otel::sdk_trace::release_trace_span(token); } } + +mod request_metadata; diff --git a/packages/agents-codex/native/rust/native_interception.rs b/packages/agents-codex/native/rust/native_interception.rs index 70e5b2b80..846e5f84e 100644 --- a/packages/agents-codex/native/rust/native_interception.rs +++ b/packages/agents-codex/native/rust/native_interception.rs @@ -2494,6 +2494,7 @@ impl TurnLifecycleContributor for Boundary { } fn on_turn_stop<'a>(&'a self, input: TurnStopInput<'a>) -> ExtensionFuture<'a, ()> { Box::pin(async move { + crate::request_metadata::retire(input.turn_store); if let Some(control) = input.thread_store.get::() { control.end_direct_turn(input.turn_store); if let Some(owner) = input.turn_store.get::() { @@ -2505,6 +2506,7 @@ impl TurnLifecycleContributor for Boundary { } fn on_turn_abort<'a>(&'a self, input: TurnAbortInput<'a>) -> ExtensionFuture<'a, ()> { Box::pin(async move { + crate::request_metadata::retire(input.turn_store); if let Some(control) = input.thread_store.get::() { control.end_direct_turn(input.turn_store); if let Some(owner) = input.turn_store.get::() { diff --git a/packages/agents-codex/native/rust/ops.rs b/packages/agents-codex/native/rust/ops.rs index 188fab738..b1a6791e7 100644 --- a/packages/agents-codex/native/rust/ops.rs +++ b/packages/agents-codex/native/rust/ops.rs @@ -278,6 +278,7 @@ impl Default for Mode { params!(TurnParams { input: Value, + observation_submission_id: Option, client_id: Option, #[serde(default, alias = "settings", with = "ThreadSettings")] thread_settings: ThreadSettingsOverrides, @@ -571,6 +572,11 @@ async fn continue_managed<'a>( } } init.insert(selection); + if let Some(submission) = p.observation_submission_id { + state + .request_metadata + .seed(&mut init, submission, state.id.to_string()); + } let value = submission( crate::turn_settlement::continue_retained( state, @@ -586,15 +592,22 @@ async fn continue_managed<'a>( } pub async fn recover_prepared( - thread: &CodexThread, + state: &crate::engine::ThreadState, turn_id: String, original_call_ids: Vec, thread_settings: ThreadSettingsOverrides, final_output_json_schema: Option, selection: Option, trace: Option, + observation_submission_id: Option, ) -> Result { + let thread = &state.thread; let mut init = thread.config_snapshot().await.turn_extension_init; + if let Some(submission) = observation_submission_id { + state + .request_metadata + .seed(&mut init, submission, state.id.to_string()); + } init.insert(selection); crate::recovery_guard::Seed::attach(&mut init, &turn_id, original_call_ids); let value = idle_submission( @@ -631,6 +644,26 @@ async fn start_with_params<'a>( "exact_steering requires steer mode" ))); } + + let turn_init = if let (Some((state, _)), Some(submission)) = + (managed.as_ref(), p.observation_submission_id) + { + let mut init = match turn_init { + Some(init) => init, + None => { + // Making metadata explicit must preserve the ordinary-root reset. + let mut init = thread.config_snapshot().await.turn_extension_init; + init.insert(None::); + init + } + }; + state + .request_metadata + .seed(&mut init, submission, state.id.to_string()); + Some(init) + } else { + turn_init + }; let input = if p.input.is_array() { TurnInput::UserInput { content: decode::>(p.input).map_err(pre_admission)?, diff --git a/packages/agents-codex/native/rust/request_metadata.rs b/packages/agents-codex/native/rust/request_metadata.rs new file mode 100644 index 000000000..2cbb8d380 --- /dev/null +++ b/packages/agents-codex/native/rust/request_metadata.rs @@ -0,0 +1,174 @@ +//! Bounded passive metadata delivery, separate from authority-bearing requests. +use codex_extension_api::{ + ExtensionData, ExtensionDataInit, ModelRequestObservation, ModelRequestObservationRoute, + ModelRequestObserver, +}; +use serde_json::{Value, json}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Weak}; +use tokio::sync::{Mutex, mpsc}; + +const CAPACITY: usize = 256; + +#[derive(Debug)] +pub(crate) struct Queue { + sender: mpsc::Sender, + receiver: Mutex>, +} +impl Default for Queue { + fn default() -> Self { + let (sender, receiver) = mpsc::channel(CAPACITY); + Self { + sender, + receiver: Mutex::new(receiver), + } + } +} +impl Queue { + /// Seed only managed root submissions; tokens never enter provider metadata. + pub(crate) fn seed( + self: &Arc, + init: &mut ExtensionDataInit, + submission: String, + thread_id: String, + ) { + if submission.is_empty() + || submission.len() > 64 + || !submission + .bytes() + .all(|c| c.is_ascii_hexdigit() || c == b'-') + { + return; + } + let route = Arc::new(Route { + queue: Arc::downgrade(self), + submission, + thread_id, + live: AtomicBool::new(true), + }); + init.insert(ModelRequestObservationRoute(route.clone())); + init.insert(route); + } + pub(crate) fn try_next(&self) -> Option { + self.receiver.try_lock().ok()?.try_recv().ok() + } + pub(crate) async fn next(&self) -> Value { + self.receiver + .lock() + .await + .recv() + .await + .unwrap_or(Value::Null) + } +} + +#[derive(Debug)] +struct Route { + queue: Weak, + submission: String, + thread_id: String, + live: AtomicBool, +} +impl ModelRequestObserver for Route { + fn observe(&self, observation: ModelRequestObservation) { + if !self.live.load(Ordering::Acquire) { + return; + } + let Some(queue) = self.queue.upgrade() else { + return; + }; + let value = observation.to_bridge_value(); + if value["thread_id"].as_str() != Some(self.thread_id.as_str()) { + return; + } + let notice = json!({ + "id": value["turn_id"], + "msg": { "type": "_sdk_request_metadata", "submission_id": self.submission, "observation": value }, + }); + // Full/closed delivery is harmless. No callback, await, current turn or + // provider-visible metadata participates in this internal route. + let _ = queue.sender.try_send(notice); + } +} + +pub(crate) fn retire(store: &ExtensionData) { + if let Some(route) = store.get::>() { + route.live.store(false, Ordering::Release); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_extension_api::{ModelRequestDispatching, ModelRequestTransport, ModelRequestWire}; + fn event() -> ModelRequestObservation { + ModelRequestObservation::Dispatching(ModelRequestDispatching { + request_id: "request".into(), + thread_id: "thread".into(), + turn_id: "same-turn".into(), + model: "model".into(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + wire: ModelRequestWire::Responses, + transport: ModelRequestTransport::HttpSse, + }) + } + #[tokio::test] + async fn exact_submission_full_closed_and_retired_delivery_is_passive() { + let queue = Arc::new(Queue::default()); + let mut init = ExtensionDataInit::new(); + queue.seed( + &mut init, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), + "thread".into(), + ); + let inherited = ExtensionData::new_with_init("child-turn", init.clone()); + let store = ExtensionData::new_with_init("same-turn", init); + let route = store.get::().unwrap(); + let mut child = event(); + if let ModelRequestObservation::Dispatching(event) = &mut child { + event.thread_id = "child-thread".into(); + } + inherited + .get::() + .unwrap() + .0 + .observe(child); + assert!(queue.try_next().is_none()); + for _ in 0..CAPACITY + 1 { + route.0.observe(event()); + } + let mut receiver = queue.receiver.lock().await; + for _ in 0..CAPACITY { + let notice = receiver.try_recv().unwrap(); + assert_eq!( + notice["msg"]["submission_id"], + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + } + assert!(receiver.try_recv().is_err()); + retire(&store); + route.0.observe(event()); + assert!(receiver.try_recv().is_err()); + let mut later = ExtensionDataInit::new(); + queue.seed( + &mut later, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(), + "thread".into(), + ); + let later_route = later.get::().unwrap(); + later_route.0.observe(event()); + route.0.observe(event()); + assert_eq!( + receiver.try_recv().unwrap()["msg"]["submission_id"], + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ); + assert!(receiver.try_recv().is_err()); + receiver.close(); + drop(receiver); + later_route.0.observe(event()); + drop(queue); + route.0.observe(event()); + } +} diff --git a/packages/agents-codex/native/rust/turn_settlement_tests.rs b/packages/agents-codex/native/rust/turn_settlement_tests.rs index 565c7363c..2626bab49 100644 --- a/packages/agents-codex/native/rust/turn_settlement_tests.rs +++ b/packages/agents-codex/native/rust/turn_settlement_tests.rs @@ -149,7 +149,7 @@ impl Drop for Provider { } // The hold is inside the existing SDK test ThreadStore, after the actual -// provider response. No synthetic event can credit this native receipt. +// provider request. No synthetic event can credit this native receipt. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn managed_root_continues_only_its_own_successfully_settled_tail() { tokio::time::timeout(Duration::from_secs(60), async { @@ -192,12 +192,32 @@ async fn managed_root_continues_only_its_own_successfully_settled_tail() { json!({}) }; let params = json!({"mode":"start_if_idle", "input":[{"type":"text","text":"begin"}], - "start":start}); + "start":start, "observation_submission_id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"}); let started: Value = serde_json::from_str(&native._submit(params.to_string()).await.unwrap()).unwrap(); assert_eq!(started["status"], "started"); let first = started["turn_id"].as_str().unwrap(); - hold.entered.acquire().await.unwrap().forget(); - assert_eq!(provider.requests.lock().unwrap().len(), 1); + loop { + hold.entered.acquire().await.unwrap().forget(); + let post_response = engine + .load_checkpoint_history(state.id) + .await + .unwrap() + .iter() + .any(|item| matches!(item, + codex_history::RolloutItem::EventMsg(EventMsg::TokenCount(event)) + if event.info.as_ref().is_some_and(|info| + info.last_token_usage.input_tokens == 1 + && info.last_token_usage.output_tokens == 1 + && info.last_token_usage.total_tokens == 2))); + if post_response { + assert_eq!(provider.requests.lock().unwrap().len(), 1); + break; + } + // Skip the initial and post-tool durability barriers. The + // response's actual usage precedes the finalization flush. + *store.next_flush_hold.lock().unwrap() = Some(hold.clone()); + hold.release.add_permits(1); + } for text in ["retained-first-unique", "retained-second-unique"] { assert_eq!( serde_json::from_str::(&native._submit( @@ -220,8 +240,12 @@ async fn managed_root_continues_only_its_own_successfully_settled_tail() { } else { hold.release.add_permits(1); } + let mut metadata = Vec::new(); let original = loop { let event = state.next_session_event().await.unwrap(); + if event["msg"]["type"] == "_sdk_request_metadata" { + metadata.push(event.clone()); + } if matches!( event["msg"]["type"].as_str(), Some("task_complete" | "turn_complete" | "turn_aborted") @@ -259,7 +283,7 @@ async fn managed_root_continues_only_its_own_successfully_settled_tail() { } let submit = json!({"mode":"continue_if_idle", "expected_previous_turn_id":first, "input":[], - "start":start}); + "start":start, "observation_submission_id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"}); let continued: Value = serde_json::from_str(&native._submit(submit.to_string()).await.unwrap()).unwrap(); if route == "replaceable" { @@ -269,7 +293,36 @@ async fn managed_root_continues_only_its_own_successfully_settled_tail() { assert_eq!(continued["status"], "started"); let next_id = continued["turn_id"].as_str().unwrap(); assert_ne!(next_id, first); - let next = session_terminal(&state, next_id).await.unwrap(); + let next = loop { + let event = state.next_session_event().await.unwrap(); + if event["msg"]["type"] == "_sdk_request_metadata" { + metadata.push(event.clone()); + } + if matches!(event["msg"]["type"].as_str(), Some("task_complete" | "turn_complete")) { + assert_eq!(event["id"], next_id); + break event; + } + }; + while let Some(notice) = state.request_metadata.try_next() { + metadata.push(notice); + } + for (turn_id, submission_id) in [ + (first, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + (next_id, "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"), + ] { + let owned: Vec<_> = metadata.iter().filter(|notice| notice["msg"]["submission_id"] == submission_id).collect(); + assert_eq!(owned.len(), 2, "missing dispatch/link metadata for {submission_id}"); + let dispatch = &owned[0]["msg"]["observation"]; + let linked = &owned[1]["msg"]["observation"]; + assert_eq!(dispatch["type"], "model_request_dispatching"); + assert_eq!(linked["type"], "model_response_linked"); + assert_eq!(dispatch["turn_id"], turn_id); + assert_eq!(linked["turn_id"], turn_id); + assert_eq!(dispatch["thread_id"], state.id.to_string()); + assert_eq!(linked["request_id"], dispatch["request_id"]); + assert!(linked["response_observation_id"].as_str().is_some_and(|id| !id.is_empty())); + } + assert_ne!(metadata[0]["msg"]["observation"]["request_id"], metadata[2]["msg"]["observation"]["request_id"]); assert_eq!(next["sdk_unsampled_steering"], false); assert_eq!(next["msg"]["last_agent_message"], output); let requests = provider.requests.lock().unwrap(); diff --git a/packages/agents-codex/src/index.ts b/packages/agents-codex/src/index.ts index 2376f6cee..415e9a9fa 100644 --- a/packages/agents-codex/src/index.ts +++ b/packages/agents-codex/src/index.ts @@ -119,6 +119,8 @@ export type { ObservationOptions, ExecutionItemSummary, ObservedEvent, + ModelRequestDispatching, + ModelResponseLinked, ApiRateLimitsUpdated, } from './runtime/observation'; export type { diff --git a/packages/agents-codex/src/runtime/observation.ts b/packages/agents-codex/src/runtime/observation.ts index 3af2477a4..77e6a12a0 100644 --- a/packages/agents-codex/src/runtime/observation.ts +++ b/packages/agents-codex/src/runtime/observation.ts @@ -36,7 +36,41 @@ type ExecutionObservation = SubmissionEvent & export type ApiRateLimitsUpdated = SubmissionEvent & Readonly<{ type: 'api_rate_limits_updated'; snapshot: ApiRateLimits }>; +/** SDK-resolved root sampling fields immediately before client invocation. + * Encoding and socket writes may not occur. Null means omitted, not a server default. + * Internal transport retries share requestId; later client invocations get new IDs. + * Separate correction roots retain their own thread/submission IDs and existing + * parentSubmissionId. Native children, warmup, compaction and background/reviewer + * requests are outside coverage. Restored manual roots emit fresh events for + * resumed work; previously observed request/link events are not replayed. + */ +export type ModelRequestDispatching = SubmissionEvent & + Readonly<{ + type: 'model_request_dispatching'; + requestId: string; + threadId: string; + model: string; + reasoningEffort: string | number | bigint | null; + reasoningSummary: string | null; + serviceTier: string | null; + wire: 'responses' | 'responses_lite'; + transport: 'http_sse' | 'websocket'; + }>; + +/** Best-effort link to a retained terminal occurrence. Retirement can drop a + * link after history retention; absence does not establish absence of a terminal. + */ +export type ModelResponseLinked = SubmissionEvent & + Readonly<{ + type: 'model_response_linked'; + requestId: string; + threadId: string; + responseObservationId: string; + }>; + export type Observation = + | ModelRequestDispatching + | ModelResponseLinked | (Event & Readonly<{ type: 'warning'; message: string }>) | (SubmissionEvent & Readonly<{ type: 'turn_started' }>) | (SubmissionEvent & diff --git a/packages/agents-codex/src/runtime/request-metadata.ts b/packages/agents-codex/src/runtime/request-metadata.ts new file mode 100644 index 000000000..b132fdc02 --- /dev/null +++ b/packages/agents-codex/src/runtime/request-metadata.ts @@ -0,0 +1,101 @@ +import type { ObservationInput } from './observation'; +import { parseTokenCount } from './run-events'; + +type ObjectValue = Record; +function object(value: unknown): value is ObjectValue { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} +function exact(value: ObjectValue, keys: string[]): boolean { + return ( + Object.keys(value).length === keys.length && + keys.every((key) => Object.hasOwn(value, key)) + ); +} + +/** Strict private bridge decoding; malformed passive metadata never fails work. */ +export function decodeRequestMetadata( + value: unknown, + submissionId: string, +): ObservationInput | null { + if (!object(value)) return null; + const common = ['type', 'request_id', 'thread_id', 'turn_id']; + if ( + !common.every((key) => typeof value[key] === 'string' && value[key] !== '') + ) + return null; + const identity = { + submissionId, + requestId: value.request_id as string, + threadId: value.thread_id as string, + turnId: value.turn_id as string, + }; + if (value.type === 'model_response_linked') { + if ( + !exact(value, [...common, 'response_observation_id']) || + typeof value.response_observation_id !== 'string' || + !value.response_observation_id + ) + return null; + return { + type: 'model_response_linked', + ...identity, + responseObservationId: value.response_observation_id, + }; + } + if ( + value.type !== 'model_request_dispatching' || + !exact(value, [ + ...common, + 'model', + 'reasoning_effort', + 'reasoning_summary', + 'service_tier', + 'wire', + 'transport', + ]) + ) + return null; + if ( + typeof value.model !== 'string' || + (value.wire !== 'responses' && value.wire !== 'responses_lite') || + (value.transport !== 'http_sse' && value.transport !== 'websocket') + ) + return null; + if ( + (value.reasoning_summary !== null && + typeof value.reasoning_summary !== 'string') || + (value.service_tier !== null && typeof value.service_tier !== 'string') + ) + return null; + const raw = value.reasoning_effort; + let effort: string | number | bigint | null = null; + if (raw !== null) { + if (!object(raw)) return null; + if ( + raw.kind === 'text' && + exact(raw, ['kind', 'value']) && + typeof raw.value === 'string' + ) + effort = raw.value; + else if ( + raw.kind === 'integer' && + exact(raw, ['kind', 'decimal']) && + typeof raw.decimal === 'string' && + /^(0|[1-9][0-9]{0,19})$/.test(raw.decimal) + ) { + const integer = BigInt(raw.decimal); + if (integer > 18446744073709551615n) return null; + effort = parseTokenCount(integer); + } else return null; + } + return { + type: 'model_request_dispatching', + ...identity, + model: value.model, + reasoningEffort: effort, + reasoningSummary: value.reasoning_summary, + serviceTier: value.service_tier, + wire: value.wire, + transport: value.transport, + }; +} diff --git a/packages/agents-codex/src/runtime/session.ts b/packages/agents-codex/src/runtime/session.ts index 980f1b97c..476e14b5b 100644 --- a/packages/agents-codex/src/runtime/session.ts +++ b/packages/agents-codex/src/runtime/session.ts @@ -18,6 +18,7 @@ import { type TraceContext, type TraceContextHeaders, } from './trace-context'; +import { decodeRequestMetadata } from './request-metadata'; import { networkPolicyDigest } from './network-policy'; import { apiRateLimits, type ApiRateLimits } from './api-rate-limits'; import { @@ -904,6 +905,7 @@ export class Session { >(); private readonly issuedRuns = new Map(); private readonly submissionIds = new WeakMap(); + private readonly requestObservationRoutes = new Map(); private readonly allowedTools: Set; private defaultTools: readonly SessionTool[]; private readonly admittedDeclarations = new Map>(); @@ -3361,6 +3363,7 @@ export class Session { ...(run.writableRoots === undefined ? {} : { writable_roots: run.writableRoots }), + observation_submission_id: this.submissionIds.get(run)!, input: output.prompt ? [...parts, { type: 'text', text: output.prompt }] : parts, @@ -3410,11 +3413,21 @@ export class Session { ), ); this.submissionIds.set(run, randomUUID()); + this.registerRequestObservationRoute(run); return run; } + private registerRequestObservationRoute(run: NativeTurn): void { + const routeId = this.submissionIds.get(run)!; + this.requestObservationRoutes.set(routeId, run); + const retire = () => { + this.requestObservationRoutes.delete(routeId); + }; + run.completion.promise.then(retire, retire); + } + private begin( - submit: (tracingContext?: string) => Promise, + submit: (run: NativeTurn, tracingContext?: string) => Promise, output = this.defaultOutput, outputOverride = false, selection?: OperationSelection, @@ -3429,13 +3442,13 @@ export class Session { : this.captureRootTracing(tracing); if (continuedFrom) continuedFrom.tracing = undefined; this.dispatchRun(run, () => { - if (!run.tracing) return submit(); + if (!run.tracing) return submit(run); return this.tracingContext(run).then((context) => { if (!this.owns(run) || !this.isOpen || run.controller.signal.aborted) throw new TurnAborted( 'Tracing admission stopped before native submission', ); - return submit(context); + return submit(run, context); }); }); return run; @@ -3861,7 +3874,7 @@ export class Session { // Native still decides admission from the exact managed Turn, never from // this event hint. Input is already in history and is not resubmitted. const next = this.begin( - (tracingContext) => + (nextRun, tracingContext) => this.#native._submit( encode({ ...(tracingContext ? { tracing_context: tracingContext } : {}), @@ -3870,6 +3883,7 @@ export class Session { ...(run.parentTrace ? { trace: run.parentTrace.toHeaders() } : {}), + observation_submission_id: this.submissionIds.get(nextRun)!, expected_previous_turn_id: run.id, ...(submission.output.schema === undefined ? {} @@ -3947,12 +3961,23 @@ export class Session { throw error; } if (!this.isOpen || this.#native !== native) return; - const run = this.lifecycle.kind === 'turn' ? this.lifecycle.run : null; const msg = event.msg; if (msg.type === 'token_count') { const snapshot = apiRateLimits(msg.api_rate_limits); if (snapshot) this.apiRateLimitState.snapshot = snapshot; } + if (msg.type === '_sdk_request_metadata') { + const route = + typeof msg.submission_id === 'string' + ? this.requestObservationRoutes.get(msg.submission_id) + : undefined; + if (route) { + if (route.id === null) route.pending.push(event); + else this.observeRequestMetadata(route, event); + } + continue; + } + const run = this.lifecycle.kind === 'turn' ? this.lifecycle.run : null; if (msg.type === 'elicitation_status') { await this.observeNativeUrlStatus(native, event); continue; @@ -4544,7 +4569,33 @@ export class Session { } else this.observations.gap(); } + private observeRequestMetadata(run: NativeTurn, event: NativeEvent): void { + const msg = event.msg; + if ( + run.controller.signal.aborted || + typeof msg.submission_id !== 'string' || + this.requestObservationRoutes.get(msg.submission_id) !== run + ) + return; + const observation = decodeRequestMetadata( + msg.observation, + msg.submission_id, + ); + if ( + observation && + 'threadId' in observation && + observation.threadId === this.id && + observation.turnId === event.id && + run.id === observation.turnId + ) + this.observations.append(observation); + } + private process(run: NativeTurn, event: NativeEvent): void { + if (event.msg.type === '_sdk_request_metadata') { + this.observeRequestMetadata(run, event); + return; + } if (this.lifecycle.kind !== 'turn' || this.lifecycle.run !== run) return; const msg = event.msg; const kind = msg.type; @@ -5580,6 +5631,7 @@ export class Session { ? encode(this.options.parentTrace.toHeaders()) : undefined, tracingContext, + this.submissionIds.get(run)!, ); const submission = decode(submissionSchema, wire); if (submission.status !== 'started' || submission.turn_id !== run.id) @@ -7165,7 +7217,7 @@ export class Session { const previous = this.lastRun; const parentTrace = this.options.parentTrace; const run = this.begin( - (context) => + (_run, context) => this.#native._compact( parentTrace ? encode(parentTrace.toHeaders()) : null, context, @@ -7207,7 +7259,7 @@ export class Session { ); const parentTrace = this.options.parentTrace; const run = this.begin( - (context) => + (_run, context) => native._execute_command( 'review', command.arguments, diff --git a/packages/agents-codex/test/native/request-metadata-native.test.ts b/packages/agents-codex/test/native/request-metadata-native.test.ts new file mode 100644 index 000000000..4ce56cbec --- /dev/null +++ b/packages/agents-codex/test/native/request-metadata-native.test.ts @@ -0,0 +1,661 @@ +import { once } from 'node:events'; +import { createServer } from 'node:http'; +import { WebSocketServer } from 'ws'; +import { afterEach, expect, it } from 'vitest'; +import { z } from 'zod'; +import { + Agent, + manualTool, + type ActionRequested, + NativeProvider, + TraceContext, + tool, + type ModelRequestDispatching, + type ModelResponseLinked, +} from '../../src'; +import type { JsonObject } from '../../src/runtime/binding'; +import { ModelServer, call, message } from './fixtures/model-server'; + +it('preserves metadata through live steering of the same native turn', async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const marker = 'retained-metadata-steering'; + const server = new ModelServer([ + async () => { + entered.resolve(); + await release.promise; + return [message('initial answer')]; + }, + (request) => { + expect(JSON.stringify(request.input)).toContain(marker); + return [message('steered answer')]; + }, + ]); + const config = await server.config(); + await using provider = new NativeProvider({ + baseUrl: (config['model_providers.fixture'] as { base_url: string }) + .base_url, + apiKey: 'synthetic-retained-metadata', + supportsWebsockets: false, + }); + const agent = new Agent({ + instructions: 'Answer, then handle steering.', + provider, + }); + await using session = await agent.session(); + try { + const turn = await session.submit('first'); + const completed = turn.wait(); + void completed.catch(() => undefined); + await entered.promise; + await turn.steer(marker); + release.resolve(); + expect((await completed).finalOutput).toBe('steered answer'); + const events = []; + for await (const event of turn.events()) events.push(event); + const dispatches = events.filter( + (event): event is ModelRequestDispatching => + event.type === 'model_request_dispatching', + ); + const links = events.filter( + (event): event is ModelResponseLinked => + event.type === 'model_response_linked', + ); + expect(server.requests).toHaveLength(2); + expect(dispatches).toHaveLength(2); + expect(links).toHaveLength(2); + expect(dispatches[0].submissionId).toBe(turn.submissionId); + expect(dispatches[1].submissionId).toBe(dispatches[0].submissionId); + expect(dispatches[1].requestId).not.toBe(dispatches[0].requestId); + expect(dispatches[1].turnId).toBe(dispatches[0].turnId); + for (const dispatch of dispatches) + expect(dispatch.threadId).toBe(session.id); + const records = []; + for await (const record of session.turns()) records.push(record); + const occurrences = records.flatMap((record) => record.responses ?? []); + expect(occurrences).toHaveLength(2); + for (const link of links) { + const dispatch = dispatches.find( + (event) => event.requestId === link.requestId, + )!; + expect([link.threadId, link.turnId, link.submissionId]).toEqual([ + dispatch.threadId, + dispatch.turnId, + dispatch.submissionId, + ]); + expect( + occurrences.some( + (response) => response.id === link.responseObservationId, + ), + ).toBe(true); + } + const state = await session.saveState(); + await session.close(); + await using restored = await agent.session({ state }); + const restoredRecords = []; + for await (const record of restored.turns()) restoredRecords.push(record); + expect(restoredRecords.map((record) => record.responses)).toEqual( + records.map((record) => record.responses), + ); + expect(server.requests).toHaveLength(2); + server.assertExhausted(); + } finally { + release.resolve(); + } +}, 30_000); + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + await ModelServer.closeAll(); + for (const close of cleanups.splice(0)) await close(); +}); + +it('matches final HTTP scalar fields and links repeated provider IDs by occurrence', async () => { + const terminal = JSON.stringify({ + type: 'response.completed', + response: { + id: 'same', + headers: { + 'x-ratelimit-remaining-requests': '7', + authorization: 'PRIVATE_HEADER_MARKER', + }, + usage: { input_tokens: 11, output_tokens: 2, total_tokens: 13 }, + }, + }); + const server = new ModelServer( + [[call('probe', {}, { callId: 'probe' })], [message('done')]], + [], + undefined, + [terminal, terminal], + ); + const config = await server.config(); + await using provider = new NativeProvider({ + baseUrl: (config['model_providers.fixture'] as { base_url: string }) + .base_url, + apiKey: 'synthetic-metadata-key', + supportsWebsockets: false, + }); + const agent = new Agent({ + instructions: 'Use probe then answer.', + model: 'gpt-5.5', + provider, + modelSettings: { + reasoningEffort: 'low', + reasoningSummary: 'concise', + serviceTier: 'flex', + }, + tools: [ + tool({ name: 'probe', parameters: z.object({}), execute: () => 'ok' }), + ], + }); + await using session = await agent.session(); + const turn = await session.submit('PRIVATE_INPUT_MARKER'); + const result = await turn.wait(); + const events = []; + for await (const event of turn.events()) events.push(event); + const dispatches = events.filter( + (event): event is ModelRequestDispatching => + event.type === 'model_request_dispatching', + ); + const links = events.filter( + (event): event is ModelResponseLinked => + event.type === 'model_response_linked', + ); + const rateUpdates = events.filter( + (event) => event.type === 'api_rate_limits_updated', + ); + expect(rateUpdates).toHaveLength(2); + for (const event of rateUpdates) { + expect(event.turnId).toBe(turn.turnId); + expect(event.submissionId).toBe(turn.submissionId); + expect(event.snapshot).toEqual({ requests: { remaining: 7 } }); + } + expect(session.rateLimits()).toEqual({ requests: { remaining: 7 } }); + expect(result.usage?.totalTokenUsage.totalTokens).toBe(26); + expect( + events.filter( + (event) => event.type === 'usage_updated' && event.usage === null, + ), + ).toEqual([]); + expect(JSON.stringify(events)).not.toContain('PRIVATE_HEADER_MARKER'); + expect(dispatches).toHaveLength(2); + expect(new Set(dispatches.map((event) => event.requestId)).size).toBe(2); + for (const [index, event] of dispatches.entries()) { + const request = server.requests[index]; + expect(event.threadId).toBe(session.id); + expect(event.turnId).toBe(turn.turnId); + expect(event.submissionId).toBe(turn.submissionId); + expect(event.model).toBe(request.model); + expect(event.reasoningEffort).toBe( + (request.reasoning as JsonObject | undefined)?.effort ?? null, + ); + expect(event.reasoningSummary).toBe( + (request.reasoning as JsonObject | undefined)?.summary ?? null, + ); + expect(event.serviceTier).toBe(request.service_tier ?? null); + expect(event.transport).toBe('http_sse'); + expect(JSON.stringify(event)).not.toContain('PRIVATE_INPUT_MARKER'); + expect(Object.isFrozen(event)).toBe(true); + } + const recorded = []; + for await (const row of session.turns()) recorded.push(row); + expect(recorded[0].responses).toHaveLength(2); + expect(new Set(recorded[0].responses!.map((row) => row.responseId))).toEqual( + new Set(['same']), + ); + expect(new Set(links.map((event) => event.responseObservationId))).toEqual( + new Set(recorded[0].responses!.map((row) => row.id)), + ); + expect(new Set(links.map((event) => event.requestId))).toEqual( + new Set(dispatches.map((event) => event.requestId)), + ); + server.assertExhausted(); +}, 30_000); + +for (const mode of ['websocket', 'fallback', 'failed-websocket'] as const) { + it(`reports the selected invocation transport over ${mode}`, async () => { + const requests: JsonObject[] = []; + let upgrades = 0; + const failures: unknown[] = []; + function events(request: JsonObject): JsonObject[] { + if (request.generate === false) + return [ + { type: 'response.created', response: { id: 'warmup' } }, + { type: 'response.completed', response: { id: 'warmup' } }, + ]; + requests.push(request); + if (mode === 'failed-websocket') + return [ + { + type: 'response.output_item.done', + item: { + type: 'message', + role: 'assistant', + content: [{ type: 'refusal', refusal: 'PRIVATE_REFUSAL' }], + }, + }, + ]; + return [ + { type: 'response.created', response: { id: 'response' } }, + { + type: 'response.output_item.done', + output_index: 0, + item: message('done'), + }, + { + type: 'response.completed', + response: { + id: 'response', + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ]; + } + const server = createServer(async (request, response) => { + try { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const output = events(JSON.parse(Buffer.concat(chunks).toString())); + response.writeHead(200, { 'content-type': 'text/event-stream' }); + for (const event of output) + response.write(`data: ${JSON.stringify(event)}\n\n`); + response.end(); + } catch (error) { + failures.push(error); + response.writeHead(500).end(); + } + }); + const ws = new WebSocketServer({ noServer: true }); + server.on('upgrade', (request, socket, head) => { + upgrades++; + if (mode === 'fallback') { + socket.end( + 'HTTP/1.1 426 Upgrade Required\r\nContent-Length: 0\r\nConnection: close\r\n\r\n', + ); + return; + } + ws.handleUpgrade(request, socket, head, (connection) => + connection.on('message', (data) => { + try { + for (const event of events(JSON.parse(data.toString()))) + connection.send(JSON.stringify(event)); + } catch (error) { + failures.push(error); + connection.close(); + } + }), + ); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') + throw new Error('No test peer address'); + cleanups.push(async () => { + for (const connection of ws.clients) connection.terminate(); + await new Promise((resolve) => ws.close(() => resolve())); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + expect(failures).toEqual([]); + }); + await using provider = new NativeProvider({ + baseUrl: `http://127.0.0.1:${address.port}/v1`, + apiKey: 'synthetic-metadata-key', + supportsWebsockets: true, + }); + await using session = await new Agent({ + instructions: 'Answer.', + provider, + }).session(); + const turn = await session.submit('go'); + if (mode === 'failed-websocket') + await expect(turn.wait()).rejects.toThrow(); + else await turn.wait(); + const observed = []; + for await (const event of turn.events()) observed.push(event); + const dispatches = observed.filter( + (event): event is ModelRequestDispatching => + event.type === 'model_request_dispatching', + ); + expect(dispatches).toHaveLength(1); + expect(requests).toHaveLength(1); + expect(dispatches[0].transport).toBe( + mode === 'fallback' ? 'http_sse' : 'websocket', + ); + expect(dispatches[0].model).toBe(requests[0].model); + expect(upgrades).toBeGreaterThan(0); + if (mode === 'failed-websocket') { + expect( + observed.some((event) => event.type === 'model_response_linked'), + ).toBe(false); + expect(JSON.stringify(observed)).not.toContain('PRIVATE_REFUSAL'); + } + }, 30_000); +} + +for (const cancel of [false, true]) { + it(`keeps a closed observation reader passive while the root ${cancel ? 'cancels' : 'completes'}`, async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const server = new ModelServer([ + async () => { + entered.resolve(); + await release.promise; + return [message('done')]; + }, + ]); + const config = await server.config(); + await using provider = new NativeProvider({ + baseUrl: (config['model_providers.fixture'] as { base_url: string }) + .base_url, + apiKey: 'synthetic-metadata-key', + supportsWebsockets: false, + }); + await using session = await new Agent({ + instructions: 'Answer.', + provider, + }).session(); + const turn = await session.submit('go'); + const reader = turn.events(); + let dispatch: ModelRequestDispatching | undefined; + for await (const event of reader) { + if (event.type === 'model_request_dispatching') { + dispatch = event; + break; + } + } + expect(dispatch).toBeDefined(); + await reader.return?.(); + await entered.promise; + if (cancel) { + await turn.cancel(); + await expect(turn.wait()).rejects.toThrow(); + } + release.resolve(); + if (!cancel) expect((await turn.wait()).finalOutput).toBe('done'); + const events = []; + for await (const event of turn.events()) events.push(event); + expect(events).toContainEqual(dispatch); + expect(server.requests).toHaveLength(1); + if (cancel) + expect( + events.some((event) => event.type === 'model_response_linked'), + ).toBe(false); + }, 30_000); +} + +it('keeps prompted correction request ownership and the existing parent submission link', async () => { + const server = new ModelServer([ + [message('not valid JSON')], + [message('{"answer":"done"}')], + ]); + const config = await server.config(); + await using provider = new NativeProvider({ + baseUrl: (config['model_providers.fixture'] as { base_url: string }) + .base_url, + apiKey: 'synthetic-metadata-key', + supportsWebsockets: false, + }); + await using session = await new Agent({ + instructions: 'Answer.', + provider, + outputType: { + mode: 'prompted', + schema: z.object({ answer: z.literal('done') }), + maxRetries: 1, + }, + }).session(); + const turn = await session.submit('go'); + expect((await turn.wait()).finalOutput).toEqual({ answer: 'done' }); + const events = []; + for await (const event of turn.events()) events.push(event); + const dispatches = events.filter( + (event): event is ModelRequestDispatching => + event.type === 'model_request_dispatching', + ); + const links = events.filter( + (event): event is ModelResponseLinked => + event.type === 'model_response_linked', + ); + expect(dispatches).toHaveLength(2); + expect(server.requests).toHaveLength(2); + const [original, correction] = dispatches; + expect(original.threadId).toBe(session.id); + expect(original.sessionId).toBe(session.id); + expect(original.submissionId).toBe(turn.submissionId); + expect(correction.threadId).toBe(correction.sessionId); + expect(correction.threadId).not.toBe(session.id); + expect(correction.submissionId).not.toBe(turn.submissionId); + expect(correction.parentSubmissionId).toBe(turn.submissionId); + expect(correction.requestId).not.toBe(original.requestId); + expect(links).toHaveLength(2); + for (const event of links) { + const owner = dispatches.find((row) => row.requestId === event.requestId)!; + expect([ + event.threadId, + event.submissionId, + event.parentSubmissionId, + ]).toEqual([owner.threadId, owner.submissionId, owner.parentSubmissionId]); + } + server.assertExhausted(); +}, 30_000); + +for (const restore of [false, true]) { + it(`binds a fresh ephemeral route for ${restore ? 'restored' : 'in-memory'} manual follow-up`, async () => { + const server = new ModelServer([ + [call('lookup', {}, { callId: 'manual-A' })], + [message('continued')], + ]); + const config = await server.config(); + await using provider = new NativeProvider({ + baseUrl: (config['model_providers.fixture'] as { base_url: string }) + .base_url, + apiKey: 'synthetic-metadata-key', + supportsWebsockets: false, + }); + const agent = new Agent({ + instructions: 'Use lookup.', + provider, + tools: [ + manualTool({ + name: 'lookup', + description: 'Read a record.', + parameters: z.object({}), + result: z.string(), + }), + ], + }); + const parentTrace = new TraceContext({ + traceparent: '00-11111111111111111111111111111111-2222222222222222-01', + }); + const source = await agent.session({ parentTrace }); + let owner = source; + try { + const original = await source.submit('read A'); + let action: ActionRequested | undefined; + for await (const event of source.events()) { + if (event.type === 'action_requested') { + action = event; + break; + } + } + if (!action) throw new Error('No manual request'); + const priorIds = new Set(); + for await (const row of source.turns()) + for (const response of row.responses ?? []) priorIds.add(response.id); + expect(priorIds.size).toBeGreaterThan(0); + if (restore) { + const saved = await source.suspend(); + const encoded = JSON.stringify(saved); + expect(encoded).not.toContain('observation_submission_id'); + expect(encoded).not.toContain(original.submissionId); + await source.close(); + owner = await agent.session({ state: saved, parentTrace }); + action = undefined; + for await (const event of owner.events()) { + if (event.type === 'action_requested') { + action = event; + break; + } + } + if (!action) throw new Error('No restored manual request'); + } + expect(server.requests).toHaveLength(1); + const resumed = await owner.submit(action.reply('record A')); + expect((await resumed.wait()).finalOutput).toBe('continued'); + const events = []; + for await (const event of resumed.events()) events.push(event); + const dispatches = events.filter( + (event): event is ModelRequestDispatching => + event.type === 'model_request_dispatching', + ); + const links = events.filter( + (event): event is ModelResponseLinked => + event.type === 'model_response_linked', + ); + expect(dispatches).toHaveLength(restore ? 1 : 2); + expect(links).toHaveLength(restore ? 1 : 2); + expect( + dispatches.every( + (event) => + event.submissionId === resumed.submissionId && + event.threadId === owner.id, + ), + ).toBe(true); + expect(dispatches.at(-1)?.turnId).toBe(original.turnId); + if (restore) { + expect(resumed.submissionId).not.toBe(original.submissionId); + expect( + links.every((event) => !priorIds.has(event.responseObservationId)), + ).toBe(true); + } + const retainedIds = new Set(); + for await (const row of owner.turns()) + for (const response of row.responses ?? []) + retainedIds.add(response.id); + expect([...priorIds].every((id) => retainedIds.has(id))).toBe(true); + server.assertExhausted(); + } finally { + if (owner !== source) await owner.close(); + await source.close(); + } + }, 30_000); +} + +it('does not report a client invocation after a held sampling step is closed', async () => { + // The blocked request must never consume this response. + const server = new ModelServer([[message('must not send')]]); + const config = await server.config(); + await using provider = new NativeProvider({ + baseUrl: (config['model_providers.fixture'] as { base_url: string }) + .base_url, + apiKey: 'synthetic-stop-metadata', + supportsWebsockets: false, + }); + const agent = new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + }); + const session = await agent.session(); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + try { + const turn = await session.submit('close at sampling admission', { + async beforeStep() { + entered.resolve(); + await release.promise; + return undefined; + }, + }); + const waiting = turn.wait().then( + () => ({ completed: true }), + () => ({ completed: false }), + ); + await entered.promise; + const closing = session.close(); + release.resolve(); + await closing; + expect((await waiting).completed).toBe(false); + const events = []; + for await (const event of turn.events()) events.push(event); + expect( + events.filter((event) => event.type === 'model_request_dispatching'), + ).toEqual([]); + expect( + events.filter((event) => event.type === 'model_response_linked'), + ).toEqual([]); + expect(server.requests).toHaveLength(0); + } finally { + release.resolve(); + await session.close(); + } +}, 30_000); + +it('routes queued work to its issued handle and drops a canceled queued root', async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + let holdFirst = true; + const server = new ModelServer( + [[message('first')], [message('second')]], + [], + async () => { + if (!holdFirst) return; + holdFirst = false; + entered.resolve(); + await release.promise; + }, + ); + const config = await server.config(); + await using provider = new NativeProvider({ + baseUrl: (config['model_providers.fixture'] as { base_url: string }) + .base_url, + apiKey: 'synthetic-queued-metadata', + supportsWebsockets: false, + }); + const agent = new Agent({ + model: 'gpt-5.5', + instructions: 'Answer.', + provider, + }); + const session = await agent.session(); + try { + const first = await session.submit('first'); + await entered.promise; + const second = await session.enqueue('second'); + const canceled = await session.enqueue('must not dispatch'); + expect(second.turnId).toBeNull(); + const canceledWait = canceled.wait().then( + () => false, + () => true, + ); + await canceled.cancel(); + expect(await canceledWait).toBe(true); + release.resolve(); + expect((await first.wait()).finalOutput).toBe('first'); + expect((await second.wait()).finalOutput).toBe('second'); + const requests: ModelRequestDispatching[] = []; + for (const turn of [first, second, canceled]) { + const events = []; + for await (const event of turn.events()) events.push(event); + const dispatches = events.filter( + (event): event is ModelRequestDispatching => + event.type === 'model_request_dispatching', + ); + expect(dispatches).toHaveLength(turn === canceled ? 0 : 1); + for (const event of dispatches) { + expect(event.submissionId).toBe(turn.submissionId); + expect(event.turnId).toBe(turn.turnId); + expect(event.threadId).toBe(session.id); + requests.push(event); + } + } + expect(new Set(requests.map((event) => event.requestId)).size).toBe(2); + expect(first.submissionId).not.toBe(second.submissionId); + expect(server.requests).toHaveLength(2); + server.assertExhausted(); + } finally { + release.resolve(); + await session.close(); + } +}, 30_000); diff --git a/packages/agents-codex/test/native/request-metadata.test.ts b/packages/agents-codex/test/native/request-metadata.test.ts new file mode 100644 index 000000000..04c70c4c5 --- /dev/null +++ b/packages/agents-codex/test/native/request-metadata.test.ts @@ -0,0 +1,99 @@ +import { expect, it } from 'vitest'; +import { decodeRequestMetadata } from '../../src/runtime/request-metadata'; +import { ObservationBuffer } from '../../src/runtime/observation'; + +function payload(reasoning_effort: unknown = null) { + return { + type: 'model_request_dispatching', + request_id: 'request', + thread_id: 'thread', + turn_id: 'turn', + model: 'resolved-model', + reasoning_effort, + reasoning_summary: null, + service_tier: null, + wire: 'responses', + transport: 'http_sse', + }; +} +it.each([ + [null, null], + [{ kind: 'text', value: 'future-effort' }, 'future-effort'], + [{ kind: 'text', value: '18446744073709551616' }, '18446744073709551616'], + [{ kind: 'integer', decimal: '9007199254740991' }, 9007199254740991], + [{ kind: 'integer', decimal: '9007199254740992' }, 9007199254740992n], + [{ kind: 'integer', decimal: '9007199254740993' }, 9007199254740993n], + [{ kind: 'integer', decimal: '18446744073709551615' }, 18446744073709551615n], + [{ kind: 'integer', decimal: '0' }, 0], +])('preserves exact bridge scalar kind %#', (raw, expected) => { + const event = decodeRequestMetadata(payload(raw), 'submission'); + expect(event?.type).toBe('model_request_dispatching'); + if (event?.type !== 'model_request_dispatching') + throw new Error('missing metadata'); + expect(event.reasoningEffort).toBe(expected); + const buffer = new ObservationBuffer('observer'); + expect(Object.isFrozen(buffer.append(event))).toBe(true); +}); +it.each([ + 9007199254740993n, + '9007199254740993', + true, + { kind: 'integer', decimal: '01' }, + { kind: 'integer', decimal: '-1' }, + { kind: 'integer', decimal: '18446744073709551616' }, + { kind: 'integer', decimal: '١' }, + { kind: 'integer', decimal: '1', body: 'private' }, + { kind: 'text', value: 1 }, + { kind: 'unknown', value: 'x' }, +])('drops malformed passive scalar without failure %#', (raw) => { + expect(decodeRequestMetadata(payload(raw), 'submission')).toBeNull(); +}); +it('drops extra or malformed fields and links only the exact retained occurrence key', () => { + for (const value of [ + { ...payload(), body: 'private' }, + { ...payload(), transport: 'unknown' }, + { ...payload(), service_tier: {} }, + { ...payload(), turn_id: null }, + ]) + expect(decodeRequestMetadata(value, 'submission')).toBeNull(); + expect( + decodeRequestMetadata( + { + type: 'model_response_linked', + request_id: 'request', + thread_id: 'thread', + turn_id: 'turn', + response_observation_id: 'occurrence', + }, + 'original-submission', + ), + ).toEqual({ + type: 'model_response_linked', + requestId: 'request', + threadId: 'thread', + turnId: 'turn', + responseObservationId: 'occurrence', + submissionId: 'original-submission', + }); +}); +it('keeps existing reader closure and bounded gap behavior with bigint effort', async () => { + const buffer = new ObservationBuffer('observer'); + const event = decodeRequestMetadata( + payload({ kind: 'integer', decimal: '9007199254740993' }), + 'submission', + )!; + const reader = buffer.events({}); + await reader.return?.(); + for (let i = 0; i < 300; i++) buffer.append(event); + buffer.finish('closed'); + const events = []; + for await (const value of buffer.events({})) events.push(value); + expect(events[0].type).toBe('gap'); + expect( + events.some( + (value) => + value.type === 'model_request_dispatching' && + value.reasoningEffort === 9007199254740993n, + ), + ).toBe(true); +}); diff --git a/packages/agents-codex/test/native/worker-receipts-native.test.ts b/packages/agents-codex/test/native/worker-receipts-native.test.ts index aa4cafae6..d7aac256f 100644 --- a/packages/agents-codex/test/native/worker-receipts-native.test.ts +++ b/packages/agents-codex/test/native/worker-receipts-native.test.ts @@ -8,6 +8,8 @@ import { Environment, NativeProvider, type ExecutionItem, + type ModelRequestDispatching, + type ModelResponseLinked, type SessionState, } from '@openai/agents'; import { binding, type NativeThread } from '../../src/runtime/binding'; @@ -146,11 +148,16 @@ it('records sampled instructions, build identity and matching live and retained projectGuidance: true, }); const turn = await session.submit('Run the fixture command.'); + const dispatches: ModelRequestDispatching[] = []; + const links: ModelResponseLinked[] = []; const completions = (async () => { const items: ExecutionItem[] = []; for await (const event of turn.events({ includeExecutionDetails: true, })) { + if (event.type === 'model_request_dispatching') + dispatches.push(event); + if (event.type === 'model_response_linked') links.push(event); if (event.type === 'execution_item_completed') { if (!('command' in event.item)) throw new Error('Expected command details'); @@ -199,6 +206,21 @@ it('records sampled instructions, build identity and matching live and retained 'permission_profile', ); const live = await completions; + expect(dispatches).toHaveLength(2); + expect(links).toHaveLength(2); + expect(new Set(dispatches.map((event) => event.requestId)).size).toBe(2); + for (const event of [...dispatches, ...links]) { + expect(event.threadId).toBe(session.id); + expect(event.turnId).toBe(turnId); + expect(event.submissionId).toBe(turn.submissionId); + } + expect(new Set(links.map((event) => event.requestId))).toEqual( + new Set(dispatches.map((event) => event.requestId)), + ); + expect( + new Set(links.map((event) => event.responseObservationId)), + ).toEqual(new Set(recorded?.responses?.map((response) => response.id))); + const retained = (await collect(session.items())).filter( (item) => item.type === 'command_execution', ); From 8f3f2163c21f5820159ba6c9e3d58162df498a49 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:24:27 +0000 Subject: [PATCH 34/34] fix: compose trace carriers with post-turn preservation --- .changeset/defined-native-trace-flags.md | 6 + .../0058-w3c-defined-trace-flags.patch | 294 +++++++++++++++++ .../agents-codex/native/patches/series.json | 6 +- packages/agents-codex/native/rust/lib.rs | 76 ++--- .../native/rust/native_interception.rs | 77 +++++ packages/agents-codex/native/rust/storage.rs | 57 +++- .../agents-codex/native/rust/subagents.rs | 16 +- .../native/rust/turn_settlement_tests.rs | 160 +++++++++ .../agents-codex/src/runtime/trace-context.ts | 5 +- .../native/fixtures/trace-context-child.ts | 311 +++++++++++++++++- .../test/native/trace-context-native.test.ts | 63 ++++ .../agents-codex/test/trace-context.test.ts | 12 +- 12 files changed, 1025 insertions(+), 58 deletions(-) create mode 100644 .changeset/defined-native-trace-flags.md create mode 100644 packages/agents-codex/native/patches/0058-w3c-defined-trace-flags.patch diff --git a/.changeset/defined-native-trace-flags.md b/.changeset/defined-native-trace-flags.md new file mode 100644 index 000000000..14a297d5e --- /dev/null +++ b/.changeset/defined-native-trace-flags.md @@ -0,0 +1,6 @@ +--- +'@openai/agents': patch +'@openai/agents-codex': patch +--- + +fix: accept defined sampled and random-trace-ID flag combinations and preserve them through native parent and callback header propagation, including later owned child turns. diff --git a/packages/agents-codex/native/patches/0058-w3c-defined-trace-flags.patch b/packages/agents-codex/native/patches/0058-w3c-defined-trace-flags.patch new file mode 100644 index 000000000..9768d35c0 --- /dev/null +++ b/packages/agents-codex/native/patches/0058-w3c-defined-trace-flags.patch @@ -0,0 +1,294 @@ +diff --git a/codex-rs/otel/src/trace_context.rs b/codex-rs/otel/src/trace_context.rs +index e1c62777f1c1bd33eaf6898a57b9157de0085e79..bc5c1f256f8e5419c64508b25383fb2300e6a2ae 100644 +--- a/codex-rs/otel/src/trace_context.rs ++++ b/codex-rs/otel/src/trace_context.rs +@@ -9,7 +9,10 @@ use std::sync::RwLock; + use codex_protocol::protocol::W3cTraceContext; + use opentelemetry::Context; + use opentelemetry::propagation::TextMapPropagator; ++use opentelemetry::trace::SpanContext; + use opentelemetry::trace::TraceContextExt; ++use opentelemetry::trace::TraceFlags; ++use opentelemetry::trace::TraceId; + use opentelemetry::trace::TraceState; + use opentelemetry_sdk::propagation::TraceContextPropagator; + use tracing::Span; +@@ -17,6 +20,13 @@ use tracing::debug; + use tracing::warn; + use tracing_opentelemetry::OpenTelemetrySpanExt; + ++// The pinned SDK clears flags on dropped spans. Bind the inherited random bit ++// to its trace identity so it cannot leak when a Context adopts another trace. ++struct RandomTraceId { ++ trace_id: TraceId, ++ random: bool, ++} ++ + const TRACEPARENT_ENV_VAR: &str = "TRACEPARENT"; + const TRACESTATE_ENV_VAR: &str = "TRACESTATE"; + static TRACEPARENT_CONTEXT: OnceLock> = OnceLock::new(); +@@ -42,6 +52,22 @@ pub(crate) fn context_w3c_trace_context(context: &Context) -> Option() ++ .filter(|random| random.trace_id == context.span().span_context().trace_id()) ++ { ++ flags = (flags & !0x02) | if random.random { 0x02 } else { 0 }; ++ } ++ if flags & 0x02 != 0 { ++ if let Some(parent) = headers.get_mut("traceparent") { ++ if let Some((prefix, _)) = parent.rsplit_once('-') { ++ *parent = format!("{prefix}-{flags:02x}"); ++ } ++ } ++ } + let tracestate = headers.remove("tracestate"); + let configured_tracestate_guard = tracestate_entries() + .read() +@@ -122,7 +148,23 @@ pub fn set_parent_from_w3c_trace_context(span: &Span, trace: &W3cTraceContext) - + } + + pub fn set_parent_from_context(span: &Span, context: Context) { +- let _ = span.set_parent(context); ++ let _ = span.set_parent(with_inherited_random_flag(context)); ++} ++ ++fn with_inherited_random_flag(context: Context) -> Context { ++ let parent = context.span().span_context().clone(); ++ let context = ++ if parent.is_valid() && (parent.is_remote() || parent.trace_flags().to_u8() & 0x02 != 0) { ++ // Remote adoption is authoritative, including an explicitly unset bit. ++ // Local dropped children retain the inherited value in their Context. ++ context.with_value(RandomTraceId { ++ trace_id: parent.trace_id(), ++ random: parent.trace_flags().to_u8() & 0x02 != 0, ++ }) ++ } else { ++ context ++ }; ++ context + } + + pub fn traceparent_context_from_env() -> Option { +@@ -135,9 +177,36 @@ pub(crate) fn context_from_trace_headers( + traceparent: Option<&str>, + tracestate: Option<&str>, + ) -> Option { +- let traceparent = traceparent?; ++ let traceparent = traceparent?.trim(); ++ // The pinned propagator accepts short hexadecimal IDs. Require the W3C ++ // field widths, then delegate all remaining validation to the propagator. ++ let mut fields = traceparent.split('-'); ++ let version = fields.next()?; ++ let trace_id = fields.next()?; ++ let parent_id = fields.next()?; ++ let raw_flags = fields.next()?; ++ if version.len() != 2 || trace_id.len() != 32 || parent_id.len() != 16 || raw_flags.len() != 2 { ++ return None; ++ } ++ // W3C Level 2 defines bit 0 as sampled and bit 1 as random trace ID. ++ // Let the pinned propagator validate all other header syntax using its ++ // supported sampling bit, then restore the complete defined flags. ++ let flags = match raw_flags { ++ "02" => Some(0x02), ++ "03" => Some(0x03), ++ _ => None, ++ }; ++ let normalized = flags.and_then(|flags| { ++ traceparent.strip_prefix("00-")?; ++ traceparent ++ .strip_suffix(if flags == 2 { "02" } else { "03" }) ++ .map(|prefix| format!("{prefix}{:02x}", flags & 1)) ++ }); + let mut headers = HashMap::new(); +- headers.insert("traceparent".to_string(), traceparent.to_string()); ++ headers.insert( ++ "traceparent".to_string(), ++ normalized.unwrap_or_else(|| traceparent.to_string()), ++ ); + if let Some(tracestate) = tracestate { + headers.insert("tracestate".to_string(), tracestate.to_string()); + } +@@ -147,7 +216,20 @@ pub(crate) fn context_from_trace_headers( + if !context.span().span_context().is_valid() { + return None; + } +- Some(context) ++ if let Some(flags) = flags { ++ let span = context.span(); ++ let parent = span.span_context(); ++ return Some(with_inherited_random_flag( ++ context.with_remote_span_context(SpanContext::new( ++ parent.trace_id(), ++ parent.span_id(), ++ TraceFlags::new(flags), ++ true, ++ parent.trace_state().clone(), ++ )), ++ )); ++ } ++ Some(with_inherited_random_flag(context)) + } + + fn load_traceparent_context() -> Option { +@@ -373,6 +455,155 @@ mod tests { + assert!(span_context.is_remote()); + } + ++ #[test] ++ fn defined_trace_flags_survive_child_header_propagation() { ++ use super::span_w3c_trace_context; ++ use tracing_opentelemetry::OpenTelemetrySpanExt; ++ ++ for flags in ["00", "01", "02", "03"] { ++ let provider = SdkTracerProvider::builder().build(); ++ let subscriber = tracing_subscriber::registry() ++ .with(tracing_opentelemetry::layer().with_tracer(provider.tracer("flags"))); ++ tracing::subscriber::with_default(subscriber, || { ++ let parent = ++ format!("00-11111111111111111111111111111111-2222222222222222-{flags}"); ++ let context = ++ context_from_trace_headers(Some(&parent), Some("vendor=value")).unwrap(); ++ assert_eq!( ++ context.span().span_context().trace_flags().to_u8(), ++ u8::from_str_radix(flags, 16).unwrap() ++ ); ++ let span = tracing::info_span!("child"); ++ super::set_parent_from_context(&span, context); ++ let headers = span_w3c_trace_context(&span).unwrap(); ++ let header = headers.traceparent.unwrap(); ++ assert_eq!(&header[3..35], &parent[3..35]); ++ assert_eq!(&header[53..], flags); ++ assert_ne!(&header[36..52], &parent[36..52]); ++ let grandchild = tracing::info_span!("grandchild"); ++ super::set_parent_from_context(&grandchild, span.context()); ++ let grandchild_header = span_w3c_trace_context(&grandchild) ++ .unwrap() ++ .traceparent ++ .unwrap(); ++ assert_eq!(&grandchild_header[3..35], &parent[3..35]); ++ assert_eq!(&grandchild_header[53..], flags); ++ assert_ne!(&grandchild_header[36..52], &header[36..52]); ++ assert_eq!(headers.tracestate.as_deref(), Some("vendor=value")); ++ }); ++ } ++ assert!( ++ context_from_trace_headers( ++ Some("00-00000000000000000000000000000000-2222222222222222-03"), ++ None ++ ) ++ .is_none() ++ ); ++ assert!(context_from_trace_headers(Some("00-bad-2222222222222222-03"), None).is_none()); ++ } ++ ++ #[test] ++ fn inherited_random_flag_preserves_local_sampling_decisions() { ++ for (sampler, incoming, outgoing) in [ ++ (opentelemetry_sdk::trace::Sampler::AlwaysOff, "03", "02"), ++ (opentelemetry_sdk::trace::Sampler::AlwaysOn, "02", "03"), ++ ] { ++ let provider = SdkTracerProvider::builder().with_sampler(sampler).build(); ++ let subscriber = tracing_subscriber::registry() ++ .with(tracing_opentelemetry::layer().with_tracer(provider.tracer("sampling"))); ++ tracing::subscriber::with_default(subscriber, || { ++ let parent = ++ format!("00-11111111111111111111111111111111-2222222222222222-{incoming}"); ++ let context = context_from_trace_headers(Some(&parent), None).unwrap(); ++ let span = tracing::info_span!("sampling"); ++ super::set_parent_from_context(&span, context); ++ let header = super::span_w3c_trace_context(&span) ++ .unwrap() ++ .traceparent ++ .unwrap(); ++ assert_eq!(&header[53..], outgoing); ++ assert_eq!(&header[3..35], &parent[3..35]); ++ assert_ne!(&header[36..52], &parent[36..52]); ++ }); ++ } ++ } ++ ++ #[test] ++ fn inherited_random_flag_does_not_cross_trace_ids_or_fresh_roots() { ++ use tracing_opentelemetry::OpenTelemetrySpanExt; ++ let provider = SdkTracerProvider::builder().build(); ++ let subscriber = tracing_subscriber::registry() ++ .with(tracing_opentelemetry::layer().with_tracer(provider.tracer("identity"))); ++ tracing::subscriber::with_default(subscriber, || { ++ let parent = "00-11111111111111111111111111111111-2222222222222222-02"; ++ let span = tracing::info_span!("original"); ++ super::set_parent_from_context( ++ &span, ++ context_from_trace_headers(Some(parent), None).unwrap(), ++ ); ++ for (trace_id, flags) in [ ++ ("11111111111111111111111111111111", 0), ++ ("11111111111111111111111111111111", 1), ++ ("33333333333333333333333333333333", 1), ++ ] { ++ let replacement = span.context().with_remote_span_context( ++ opentelemetry::trace::SpanContext::new( ++ TraceId::from_hex(trace_id).unwrap(), ++ SpanId::from_hex("4444444444444444").unwrap(), ++ opentelemetry::trace::TraceFlags::new(flags), ++ true, ++ Default::default(), ++ ), ++ ); ++ let other = tracing::info_span!("other"); ++ super::set_parent_from_context(&other, replacement); ++ let header = super::span_w3c_trace_context(&other) ++ .unwrap() ++ .traceparent ++ .unwrap(); ++ assert_eq!(&header[3..35], trace_id); ++ assert_eq!(&header[53..], format!("{flags:02x}")); ++ assert_ne!(&header[36..52], "4444444444444444"); ++ } ++ let invalid = tracing::info_span!("invalid"); ++ super::set_parent_from_context( ++ &invalid, ++ span.context() ++ .with_remote_span_context(opentelemetry::trace::SpanContext::empty_context()), ++ ); ++ assert!(super::span_w3c_trace_context(&invalid).is_none()); ++ let fresh = tracing::info_span!("fresh"); ++ super::set_parent_from_context(&fresh, opentelemetry::Context::new()); ++ let header = super::span_w3c_trace_context(&fresh) ++ .unwrap() ++ .traceparent ++ .unwrap(); ++ assert_ne!(&header[3..35], &parent[3..35]); ++ assert_eq!(&header[53..], "01"); ++ }); ++ } ++ ++ #[test] ++ fn direct_native_context_keeps_unsampled_random_trace_flag() { ++ use opentelemetry::trace::Tracer as _; ++ let provider = SdkTracerProvider::builder().build(); ++ let tracer = provider.tracer("direct"); ++ let parent = context_from_trace_headers( ++ Some("00-11111111111111111111111111111111-2222222222222222-02"), ++ None, ++ ) ++ .unwrap(); ++ let child = tracer.start_with_context("direct-child", &parent); ++ let context = parent.with_span(child); ++ let header = super::context_w3c_trace_context(&context) ++ .unwrap() ++ .traceparent ++ .unwrap(); ++ assert_eq!(&header[3..35], "11111111111111111111111111111111"); ++ assert_ne!(&header[36..52], "2222222222222222"); ++ assert_eq!(&header[53..], "02"); ++ } ++ + #[test] + fn invalid_traceparent_returns_none() { + let parent = context_from_trace_headers( diff --git a/packages/agents-codex/native/patches/series.json b/packages/agents-codex/native/patches/series.json index 8beb7cfb6..8532f81fa 100644 --- a/packages/agents-codex/native/patches/series.json +++ b/packages/agents-codex/native/patches/series.json @@ -271,7 +271,11 @@ { "path": "0059-native-request-metadata.patch", "sha256": "00fb1ca9eff806e34ebd56521d4367c4905e6be285b773a2c32cc8ec617f17ae" + }, + { + "path": "0058-w3c-defined-trace-flags.patch", + "sha256": "1c689d4839940e41ceb5f47c3c65d02a51608a02c63230ed435997a1c751b6ed" } ], - "result_tree": "6e3ca2e842c1caf783abd91fb2aed74233b8366d" + "result_tree": "a58aca9912ec58771e46f1fd577395486798cfbf" } diff --git a/packages/agents-codex/native/rust/lib.rs b/packages/agents-codex/native/rust/lib.rs index 22df3fe10..5136452d1 100644 --- a/packages/agents-codex/native/rust/lib.rs +++ b/packages/agents-codex/native/rust/lib.rs @@ -3950,44 +3950,46 @@ mod native_trace_merge_tests { ) .unwrap(); let token = readback["handle"].as_str().unwrap(); - let explicit = W3cTraceContext { - traceparent: Some(format!( - "00-{}-{}-01", - readback["trace_id"].as_str().unwrap(), - readback["span_id"].as_str().unwrap() - )), - tracestate: Some("fixture=explicit".into()), - }; - let mut mismatch = json!({"input":[],"trace":carrier("12345678901234ab","01","fixture=other"),"tracing_context":token}); - let receipt = ops::prepare_submission_trace(&mut mismatch) - .unwrap() - .unwrap(); - assert_eq!(receipt["status"], "not_submitted"); - assert_eq!(receipt["reason"]["type"], "trace_context_conflict"); - assert!(codex_otel::sdk_trace::trace_span_context(token).is_ok()); - let mut matching = json!({"input":[],"trace":explicit,"tracing_context":token}); - assert!( - ops::prepare_submission_trace(&mut matching) + for flags in ["02", "03"] { + let explicit = W3cTraceContext { + traceparent: Some(format!( + "00-{}-{}-{flags}", + readback["trace_id"].as_str().unwrap(), + readback["span_id"].as_str().unwrap() + )), + tracestate: Some("fixture=explicit".into()), + }; + let mut mismatch = json!({"input":[],"trace":carrier("12345678901234ab","01","fixture=other"),"tracing_context":token}); + let receipt = ops::prepare_submission_trace(&mut mismatch) .unwrap() - .is_none() - ); - assert_eq!( - matching["trace"]["traceparent"], - explicit.traceparent.as_deref().unwrap() - ); - assert_eq!(matching["trace"]["tracestate"], "fixture=explicit"); - assert!(matching.get("tracing_context").is_none()); - let parsed = codex_otel::context_from_w3c_trace_context(&explicit).unwrap(); - assert!(codex_otel::sdk_trace::policy_for_context(&parsed).is_none()); - let _caller = codex_otel::sdk_trace::context(token).unwrap().attach(); - let restored = codex_otel::sdk_trace::current_scope_policy().unwrap(); - let original = codex_otel::sdk_trace::policy_for_context( - &codex_otel::sdk_trace::context(token).unwrap(), - ) - .unwrap(); - assert!(std::sync::Arc::ptr_eq(&restored, &original)); - assert!(restored.disabled); - assert!(!restored.include_sensitive_data); + .unwrap(); + assert_eq!(receipt["status"], "not_submitted"); + assert_eq!(receipt["reason"]["type"], "trace_context_conflict"); + assert!(codex_otel::sdk_trace::trace_span_context(token).is_ok()); + let mut matching = json!({"input":[],"trace":explicit,"tracing_context":token}); + assert!( + ops::prepare_submission_trace(&mut matching) + .unwrap() + .is_none() + ); + assert_eq!( + matching["trace"]["traceparent"], + explicit.traceparent.as_deref().unwrap() + ); + assert_eq!(matching["trace"]["tracestate"], "fixture=explicit"); + assert!(matching.get("tracing_context").is_none()); + let parsed = codex_otel::context_from_w3c_trace_context(&explicit).unwrap(); + assert!(codex_otel::sdk_trace::policy_for_context(&parsed).is_none()); + let _caller = codex_otel::sdk_trace::context(token).unwrap().attach(); + let restored = codex_otel::sdk_trace::current_scope_policy().unwrap(); + let original = codex_otel::sdk_trace::policy_for_context( + &codex_otel::sdk_trace::context(token).unwrap(), + ) + .unwrap(); + assert!(std::sync::Arc::ptr_eq(&restored, &original)); + assert!(restored.disabled); + assert!(!restored.include_sensitive_data); + } let mut scoped_only = json!({"input":[],"tracing_context":token}); assert!( ops::prepare_submission_trace(&mut scoped_only) diff --git a/packages/agents-codex/native/rust/native_interception.rs b/packages/agents-codex/native/rust/native_interception.rs index 846e5f84e..7a04d4e65 100644 --- a/packages/agents-codex/native/rust/native_interception.rs +++ b/packages/agents-codex/native/rust/native_interception.rs @@ -577,6 +577,7 @@ pub struct Control { native_ancestor: Option>, stopped: CancellationToken, creation_tracing: OnceLock>>, + creation_trace: OnceLock>, direct_turn: Mutex>>, code_mode_sessions: Mutex>>, pending_code_import: Mutex>>, @@ -624,6 +625,21 @@ impl Control { self.creation_tracing.get().cloned().flatten() } + pub(crate) fn creation_trace( + &self, + ) -> Result> { + match self.creation_tracing() { + Some(tracing) => codex_otel::sdk_trace::trace_carrier(tracing.token()) + .map(Some) + .map_err(anyhow::Error::msg), + None => Ok(self.raw_creation_trace()), + } + } + + pub(crate) fn raw_creation_trace(&self) -> Option { + self.creation_trace.get().cloned().flatten() + } + pub(crate) fn tracing_context(&self) -> codex_otel::sdk_trace::CapturedContext { if self.native_ancestor.is_some() { // A child created without tracing keeps that choice on later turns. @@ -725,6 +741,7 @@ impl Control { native_ancestor: None, stopped: CancellationToken::new(), creation_tracing: OnceLock::new(), + creation_trace: OnceLock::new(), direct_turn: Mutex::new(None), code_mode_sessions: Mutex::default(), pending_code_import: Mutex::default(), @@ -768,6 +785,7 @@ impl Control { native_ancestor: Some(Arc::downgrade(parent)), stopped: CancellationToken::new(), creation_tracing: OnceLock::new(), + creation_trace: OnceLock::new(), direct_turn: Mutex::new(None), code_mode_sessions: Mutex::default(), pending_code_import: Mutex::default(), @@ -2327,6 +2345,11 @@ impl ThreadLifecycleContributor for Boundary { ) -> ExtensionFuture<'a, ()> { Box::pin(async move { let creation_tracing = codex_otel::sdk_trace::CapturedContext::capture().context; + let creation_trace = if creation_tracing.is_none() { + codex_otel::current_span_w3c_trace_context() + } else { + None + }; let root = !matches!( input.session_source, SessionSource::Internal(_) | SessionSource::SubAgent(_) @@ -2359,6 +2382,7 @@ impl ThreadLifecycleContributor for Boundary { } }); let _ = control.creation_tracing.set(creation_tracing); + let _ = control.creation_trace.set(creation_trace); if strict_executor_writes { let _ = control.strict_executor_writes.set(()); } @@ -8221,6 +8245,43 @@ mod mcp_policy_tests { drop(output); drop(tx); } + #[test] + fn child_creation_trace_retains_raw_parent_without_sdk_policy() { + for flags in ["02", "03"] { + let original = codex_protocol::protocol::W3cTraceContext { + traceparent: Some(format!( + "00-11111111111111111111111111111111-2222222222222222-{flags}" + )), + tracestate: Some("tenant=original".into()), + }; + let attached = codex_otel::context_from_w3c_trace_context(&original) + .unwrap() + .attach(); + let control = Control::new("original-child".into(), false); + let _ = control + .creation_trace + .set(codex_otel::current_span_w3c_trace_context()); + assert!(control.creation_tracing().is_none()); + drop(attached); + let unrelated = codex_protocol::protocol::W3cTraceContext { + traceparent: Some("00-33333333333333333333333333333333-4444444444444444-01".into()), + tracestate: Some("tenant=unrelated".into()), + }; + let _attached = codex_otel::context_from_w3c_trace_context(&unrelated) + .unwrap() + .attach(); + let retained = control.creation_trace().unwrap().unwrap(); + assert_eq!(retained.traceparent, original.traceparent); + assert_eq!(retained.tracestate, original.tracestate); + assert!( + Control::new("another-child".into(), false) + .creation_trace() + .unwrap() + .is_none() + ); + } + } + #[tokio::test] async fn child_receipt_retains_original_tracing_after_origin_scope_ends() { const CHILD: &str = "AGENTS_CHILD_RECEIPT_TRACE_TEST"; @@ -8243,6 +8304,12 @@ mod mcp_policy_tests { let _ = control .creation_tracing .set(codex_otel::sdk_trace::CapturedContext::capture().context); + let _ = control + .creation_trace + .set(Some(codex_protocol::protocol::W3cTraceContext { + traceparent: Some("00-cccccccccccccccccccccccccccccccc-dddddddddddddddd-03".into()), + tracestate: None, + })); let mode = serde_json::from_value(serde_json::json!({"mode":"default", "settings":{"model":"test","reasoning_effort":null,"developer_instructions":null}})).unwrap(); crate::turn_settlement::Contributor .on_turn_start(TurnStartInput { @@ -8267,6 +8334,16 @@ mod mcp_policy_tests { let _unrelated = codex_otel::sdk_trace::context(unrelated_handle) .unwrap() .attach(); + assert!( + control + .creation_trace() + .unwrap() + .unwrap() + .traceparent + .as_deref() + .unwrap() + .starts_with("00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-") + ); for owner in [creation, tracing] { let snapshot: serde_json::Value = serde_json::from_str( &codex_otel::sdk_trace::trace_span_context(owner.token()).unwrap(), diff --git a/packages/agents-codex/native/rust/storage.rs b/packages/agents-codex/native/rust/storage.rs index 45a885679..3847299b4 100644 --- a/packages/agents-codex/native/rust/storage.rs +++ b/packages/agents-codex/native/rust/storage.rs @@ -74,6 +74,12 @@ pub struct MemoryThreadStore { pub(crate) completion_hold: std::sync::Mutex>>, #[cfg(test)] pub(crate) next_flush_hold: std::sync::Mutex>>, + #[cfg(test)] + pub(crate) completion_flush_hold: std::sync::Mutex>>, + #[cfg(test)] + pub(crate) flush_traces: std::sync::Mutex< + Option>>>, + >, inner: Arc, archived: Mutex>, // The native in-memory store applies this policy but does not expose it. @@ -90,6 +96,10 @@ impl Default for MemoryThreadStore { completion_hold: std::sync::Mutex::new(None), #[cfg(test)] next_flush_hold: std::sync::Mutex::new(None), + #[cfg(test)] + completion_flush_hold: std::sync::Mutex::new(None), + #[cfg(test)] + flush_traces: std::sync::Mutex::new(None), manual_writers: Mutex::default(), inner: Arc::new(InMemoryThreadStore::default()), archived: Mutex::new(HashMap::new()), @@ -548,14 +558,31 @@ impl ThreadStore for MemoryThreadStore { #[cfg(test)] { return Box::pin(async move { - let hold = { - let mut next = self.next_flush_hold.lock().unwrap(); + let completing = at_turn_completion_flush(); + if completing + && let Some(probe) = self.flush_traces.lock().unwrap().clone() + && let Some(trace) = codex_otel::current_span_w3c_trace_context() + { + probe.lock().unwrap().push((id, trace)); + } + let completion = if completing { + let mut next = self.completion_flush_hold.lock().unwrap(); if next.as_ref().is_some_and(|hold| hold.parent == id) { next.take() } else { None } + } else { + None }; + let hold = completion.or_else(|| { + let mut next = self.next_flush_hold.lock().unwrap(); + if next.as_ref().is_some_and(|hold| hold.parent == id) { + next.take() + } else { + None + } + }); if let Some(hold) = hold { hold.entered.add_permits(1); hold.release.acquire().await.unwrap().forget(); @@ -758,6 +785,32 @@ impl ThreadStore for MemoryThreadStore { } } +// Core's "turn" span exports as session_task.turn. Its completion flush runs +// after session_task.run returns and before publishing the terminal. +// Ignore startup and in-sample flushes in the after-last-sample test barrier. +#[cfg(test)] +fn at_turn_completion_flush() -> bool { + use tracing_subscriber::registry::LookupSpan; + tracing::Span::current() + .with_subscriber(|(id, dispatch)| { + let Some(registry) = dispatch.downcast_ref::() else { + return false; + }; + let Some(span) = registry.span(id) else { + return false; + }; + for span in span.scope() { + match span.metadata().name() { + "session_task.run" => return false, + "turn" => return true, + _ => {} + } + } + false + }) + .unwrap_or(false) +} + #[cfg(test)] pub(crate) struct CompletionHold { pub parent: ThreadId, diff --git a/packages/agents-codex/native/rust/subagents.rs b/packages/agents-codex/native/rust/subagents.rs index 4424b4e14..d0a91b86c 100644 --- a/packages/agents-codex/native/rust/subagents.rs +++ b/packages/agents-codex/native/rust/subagents.rs @@ -754,11 +754,7 @@ pub async fn submit( client_id: None, }); let creation_tracing = control.creation_tracing(); - if let Some(tracing) = &creation_tracing { - request.trace = Some( - codex_otel::sdk_trace::trace_carrier(tracing.token()).map_err(anyhow::Error::msg)?, - ); - } + request.trace = control.creation_trace()?; let settlement = thread .thread_extension_data() .get::() @@ -834,7 +830,8 @@ pub async fn submit( .as_ref() .map(|tracing| codex_otel::sdk_trace::trace_carrier(tracing.token())) .transpose() - .map_err(anyhow::Error::msg)?; + .map_err(anyhow::Error::msg)? + .or_else(|| control.raw_creation_trace()); if let TurnInput::UserInput { client_id, .. } = &mut request.input { *client_id = Some(steering_id.clone()); } @@ -945,6 +942,10 @@ pub async fn continue_retained( let Some(thread) = turn.thread.upgrade() else { return Ok(None); }; + let control = thread + .thread_extension_data() + .get::() + .context("Native child continuation lost its tracing owner")?; let settlement = thread .thread_extension_data() .get::() @@ -990,7 +991,8 @@ pub async fn continue_retained( .as_ref() .map(|tracing| codex_otel::sdk_trace::trace_carrier(tracing.token())) .transpose() - .map_err(anyhow::Error::msg)?; + .map_err(anyhow::Error::msg)? + .or_else(|| control.raw_creation_trace()); let seed = Arc::new(crate::turn_settlement::Seed::default()); init.insert(seed.clone()); diff --git a/packages/agents-codex/native/rust/turn_settlement_tests.rs b/packages/agents-codex/native/rust/turn_settlement_tests.rs index 2626bab49..8ea31dd57 100644 --- a/packages/agents-codex/native/rust/turn_settlement_tests.rs +++ b/packages/agents-codex/native/rust/turn_settlement_tests.rs @@ -1787,3 +1787,163 @@ async fn checkpoint_probe_cannot_hide_invalid_actual_instructions_with_a_prospec drop(_mutation);state.close().await.unwrap();engine.close().await.unwrap(); }).await.expect("invalid source instruction evidence escaped passive validation"); } + + +#[test] +fn raw_child_steering_and_retained_continuation_preserve_carrier() { + const CHILD: &str = "AGENTS_RAW_CHILD_CONTINUATION_TRACE_TEST"; + if std::env::var_os(CHILD).is_none() { + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "engine::settlement_tests::raw_child_steering_and_retained_continuation_preserve_carrier", "--nocapture"]) + .env(CHILD, "1").status().unwrap(); + assert!(status.success()); + return; + } + // This real Core fixture installs a process-global tracing subscriber and + // constructs large turn futures. Keep both isolated from other unit cases. + std::thread::Builder::new() + .name("raw-child-carrier".into()) + .stack_size(32 * 1024 * 1024) + .spawn(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(raw_child_steering_and_retained_continuation_case()); + }) + .unwrap() + .join() + .unwrap(); +} + +async fn raw_child_steering_and_retained_continuation_case() { + use codex_otel::sdk_trace::FutureExt as _; + let telemetry_home = tempfile::tempdir().unwrap(); + crate::telemetry::initialize( + r#"{"otel.trace_processors":true,"analytics.enabled":false}"#, + &json!({"codex_home":telemetry_home.path(),"cwd":telemetry_home.path()}).to_string(), + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(30), async { + for flags in ["02", "03"] { + let provider = Provider::start().await; + let (_home, engine) = provider.engine().await; + let (mut startup, instructions) = engine.build_startup(serde_json::from_value(json!({"_managed_instructions":true})).unwrap()).await.unwrap(); + startup.thread_extension_init.insert(crate::native_agents::Policy { + roles: Vec::new(), max_depth: 1, inherited_instructions: None, + allowed: Default::default(), + }); + let parent = engine.start_prepared(startup, instructions, None).await.unwrap(); + let parent_state = parent.current().unwrap(); + let carrier = codex_protocol::protocol::W3cTraceContext { + traceparent: Some(format!("00-11111111111111111111111111111111-2222222222222222-{flags}")), + tracestate: Some("tenant=raw-child".into()), + }; + let source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: parent_state.id, depth: 1, + agent_path: Some(codex_protocol::AgentPath::root().join("raw_child").unwrap()), + agent_nickname: None, agent_role: None, + }); + let options = serde_json::from_value(json!({"session_source":source,"thread_source":ThreadSource::Subagent})).unwrap(); + let (startup, _) = engine.build_startup(options).await.unwrap(); + let child = engine.manager.start_thread(startup) + .with_context(codex_otel::context_from_w3c_trace_context(&carrier).unwrap()) + .await.unwrap(); + let child_id = child.thread_id; + { + let mut events = parent_state.events.lock().await; + parent_state.discover_children(&mut events).await.unwrap(); + } + let (instance, _, _) = crate::subagents::instance(&parent_state, child_id).await.unwrap().unwrap(); + let store = engine.store.as_any().downcast_ref::().unwrap(); + let traces = Arc::new(std::sync::Mutex::new(Vec::new())); + *store.flush_traces.lock().unwrap() = Some(traces.clone()); + let hold = Arc::new(CompletionHold { + parent: child_id, entered: Semaphore::new(0), release: Semaphore::new(0), + }); + *store.completion_flush_hold.lock().unwrap() = Some(hold.clone()); + let mut initial = request("initial child input"); + initial.trace = child.thread.thread_extension_data() + .get::().unwrap().creation_trace().unwrap(); + initial.start.final_output_json_schema = Some(json!({ + "type":"object", "properties":{"answer":{"type":"string"}}, + "required":["answer"], "additionalProperties":false, + })); + let seed = Arc::new(crate::turn_settlement::Seed::default()); + let mut init = child.thread.config_snapshot().await.turn_extension_init; + init.insert(seed.clone()); + let StartIfIdleSubmission::Started { turn_id } = child.thread.start_turn_if_idle( + codex_core_api::WithTurnExtensionData::new(initial, init) + ).await.unwrap() else { panic!("initial child was not admitted"); }; + seed.confirm(&turn_id); + let original = loop { + if let Some((_, _, Some(turn))) = crate::subagents::instance(&parent_state, child_id).await.unwrap() + && turn.turn_id == turn_id { + break turn; + } + parent_state.next_session_event().await.unwrap(); + }; + hold.entered.acquire().await.unwrap().forget(); + assert_eq!(provider.child_requests(child_id), 1); + let unrelated: Value = serde_json::from_str(&codex_otel::sdk_trace::start_trace_span( + r#"{"name":"unrelated","kind":"workflow","trace_id":"33333333333333333333333333333333","include_sensitive_data":true}"# + ).unwrap()).unwrap(); + let handle = unrelated["handle"].as_str().unwrap(); + let context = codex_otel::sdk_trace::context(handle).unwrap(); + let steered = crate::subagents::submit(&parent_state, child_id, instance, + json!({"input":[{"type":"text","text":"RAW_RETAINED_AFTER_LAST_SAMPLE"}]})) + .with_context(context.clone()).await.unwrap(); + assert!(Arc::ptr_eq(&original, &steered)); + assert_eq!(provider.child_requests(child_id), 1); + hold.release.add_permits(1); + loop { + let event = parent_state.next_session_event().await.unwrap(); + if event["subagent_id"] == child_id.to_string() + && matches!(event["msg"]["type"].as_str(), Some("task_complete" | "turn_complete")) { + break; + } + } + original.wait_with_usage().await.unwrap(); + assert_eq!(child.thread.thread_extension_data().get::().unwrap().observe().receipt.unwrap().unsampled_steering(), Some(true)); + let successor = crate::subagents::continue_retained(&parent_state, &original) + .with_context(context).await.unwrap().expect("real retained child successor"); + assert_ne!(successor.turn_id, original.turn_id); + loop { + let event = parent_state.next_session_event().await.unwrap(); + if event["subagent_id"] == child_id.to_string() + && event["msg"]["turn_id"] == successor.turn_id + && matches!(event["msg"]["type"].as_str(), Some("task_complete" | "turn_complete")) { + break; + } + } + successor.wait_with_usage().await.unwrap(); + assert_eq!(provider.child_requests(child_id), 2); + { + let requests = provider.requests.lock().unwrap(); + let requests: Vec<_> = requests.iter().filter(|request| { + let metadata: Value = serde_json::from_str(request["client_metadata"]["x-codex-turn-metadata"].as_str().unwrap()).unwrap(); + metadata["thread_id"] == child_id.to_string() + }).collect(); + assert!(!requests[0]["input"].to_string().contains("RAW_RETAINED_AFTER_LAST_SAMPLE")); + assert!(requests[1]["input"].to_string().contains("RAW_RETAINED_AFTER_LAST_SAMPLE")); + } + { + let observed = traces.lock().unwrap(); + let observed: Vec<_> = observed.iter().filter(|(id, _)| *id == child_id).collect(); + assert!(observed.len() >= 2); + let last = &observed.last().unwrap().1; + assert_eq!(&last.traceparent.as_deref().unwrap()[3..35], "11111111111111111111111111111111", "retained successor completion"); + for (_, trace) in observed { + assert_eq!(&trace.traceparent.as_deref().unwrap()[3..35], "11111111111111111111111111111111"); + assert!(trace.traceparent.as_deref().unwrap().ends_with(&format!("-{flags}"))); + assert_eq!(trace.tracestate, carrier.tracestate); + } + } + codex_otel::sdk_trace::end_trace_span(handle, r#"{"status":"ok"}"#).unwrap(); + codex_otel::sdk_trace::release_trace_span(handle); + parent_state.close().await.unwrap(); + engine.close().await.unwrap(); + } + }).await.expect("raw child retained continuation must settle"); +} diff --git a/packages/agents-codex/src/runtime/trace-context.ts b/packages/agents-codex/src/runtime/trace-context.ts index 8dad4eb1d..216e208d9 100644 --- a/packages/agents-codex/src/runtime/trace-context.ts +++ b/packages/agents-codex/src/runtime/trace-context.ts @@ -9,7 +9,7 @@ export interface TraceContextHeaders { /** * Immutable explicit native trace parent, independent of any tracing provider. * Traceparent accepts at most 512 ASCII characters, nonzero lowercase IDs, and - * version 00 flags 00 or 01. Future versions retain their opaque suffix. + * version 00 flags 00 through 03 (sampled and random trace ID). Future versions retain their opaque suffix. * Tracestate accepts 32 entries with keys and values up to 256 characters; * tenant system IDs accept at most 13 characters to match native OpenTelemetry. * This native profile does not accept every standards-valid W3C carrier. @@ -44,7 +44,8 @@ export class TraceContext { match[2] === '0'.repeat(32) || match[3] === '0'.repeat(16) || (match[1] === '00' && - (parent.length !== 55 || !['00', '01'].includes(match[4]!))) || + (parent.length !== 55 || + !['00', '01', '02', '03'].includes(match[4]!))) || (parent.length > 55 && parent[55] !== '-') || /[^\x21-\x7e]/.test(parent) ) diff --git a/packages/agents-codex/test/native/fixtures/trace-context-child.ts b/packages/agents-codex/test/native/fixtures/trace-context-child.ts index 2cb2cc24f..967cbe77a 100644 --- a/packages/agents-codex/test/native/fixtures/trace-context-child.ts +++ b/packages/agents-codex/test/native/fixtures/trace-context-child.ts @@ -4,6 +4,9 @@ import { z } from 'zod'; import { Agent, NativeProvider, + NativeAgents, + Environment, + fileSessionStore, TraceContext, telemetry, tool, @@ -11,6 +14,7 @@ import { type SessionState, } from '../../../src'; import { ModelServer, call, message } from './model-server'; +import { startPublicExecutor } from './public-executor'; const PARENT = '00-11111111111111111111111111111111-2222222222222222-01'; const OVERRIDE = '00-33333333333333333333333333333333-4444444444444444-01'; @@ -114,6 +118,286 @@ async function review() { } } +async function definedFlags(flags: string) { + let callback: ReturnType | undefined; + const server = new ModelServer([ + [call('observe', {}, { callId: 'flags-call' })], + [final('done')], + ]); + const agent = new Agent({ + instructions: 'Call observe.', + model: 'gpt-5', + provider: await providerFor(server), + tools: [ + tool({ + name: 'observe', + parameters: z.object({}), + execute: (_args, context) => { + assert(context.traceContext); + callback = context.traceContext.toHeaders(); + return 'accepted'; + }, + }), + ], + }); + const result = await agent.run('start', { + parentTrace: parent(PARENT.slice(0, -2) + flags), + }); + assert.equal(result.finalOutput, 'done'); + assert(callback); + server.assertExhausted(); + return { callback, turn: result.turn.turnId }; +} + +async function storeReopen(path: string) { + const callbacks: ReturnType[] = []; + const server = new ModelServer( + Array.from({ length: 3 }, (_, index) => [ + [call('observe', {}, { callId: `store-${index}` })], + [final('stored')], + ]).flat(), + ); + const agent = new Agent({ + instructions: 'Call observe.', + model: 'gpt-5', + provider: await providerFor(server), + tools: [ + tool({ + name: 'observe', + parameters: z.object({}), + execute: (_args, context) => { + assert(context.traceContext); + callbacks.push(context.traceContext.toHeaders()); + return 'accepted'; + }, + }), + ], + }); + const storage = fileSessionStore(path + '.store'); + let id: string | undefined; + const turns: string[] = []; + for (const carrier of [ + PARENT.slice(0, -2) + '03', + WORKER.slice(0, -2) + '03', + undefined, + ]) { + const session = await agent.session({ + storage, + resume: id, + parentTrace: carrier ? parent(carrier) : undefined, + }); + try { + id ??= session.id; + assert.equal(session.id, id); + const turn = await session.submit('observe after opening'); + assert.equal((await turn.wait()).finalOutput, 'stored'); + turns.push(turn.turnId!); + } finally { + await session.close(); + } + } + assert.equal(callbacks.length, 3); + server.assertExhausted(); + return { id, turns, callbacks }; +} + +async function ownedChildren(flags?: string, steering = false) { + const callbackTurns: string[] = []; + const callbacks: ReturnType[] = []; + let rootCallback: ReturnType | undefined; + const sampled = Promise.withResolvers(); + const releaseSample = Promise.withResolvers(); + const continued = Promise.withResolvers(); + const marker = 'RAW_CHILD_RETAINED_TRACE'; + const sampledMarker: boolean[] = []; + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + let workflow: Awaited> | undefined; + let traceId = flags ? PARENT.slice(3, 35) : undefined; + let policyChecks = 0; + const checkPolicy = () => { + if (flags) return; + const span = telemetry.getCurrentSpan(); + assert(span); + assert.equal(telemetry.getCurrentTrace(), workflow); + assert.equal(span.context.disabled, false); + assert.equal(span.context.includeSensitiveData, false); + policyChecks++; + }; + let rootId: string | undefined; + let roots = 0; + let children = 0; + const respond = async (request: Record) => { + const metadata = request.client_metadata as Record; + const id = String(JSON.parse(metadata['x-codex-turn-metadata']).thread_id); + rootId ??= id; + if (id === rootId) + return ++roots === 1 + ? [call('root_observe', {}, { callId: 'root-observe' })] + : roots === 2 + ? [ + call( + 'spawn_agent', + { task_name: 'trace_child', message: 'Call observe.' }, + { namespace: 'collaboration', callId: 'spawn-trace-child' }, + ), + ] + : [final('root done')]; + children++; + sampledMarker.push(JSON.stringify(request.input).includes(marker)); + if (steering && children === 2) { + sampled.resolve(); + await releaseSample.promise; + } + return children % 2 === 1 + ? [call('observe', {}, { callId: `child-observe-${children}` })] + : [final('child done')]; + }; + const server = new ModelServer(Array.from({ length: 7 }, () => respond)); + const agent = new Agent({ + instructions: 'Delegate.', + model: 'gpt-5.5', + provider: await providerFor(server), + tools: [ + new NativeAgents({ + beforeStep: () => { + checkPolicy(); + return undefined; + }, + }), + tool({ + name: 'root_observe', + parameters: z.object({}), + execute: (_args, context) => { + checkPolicy(); + assert(context.traceContext); + rootCallback = context.traceContext.toHeaders(); + return 'root observed'; + }, + }), + tool({ + name: 'observe', + parameters: z.object({}), + execute: async (_args, context) => { + checkPolicy(); + assert(context.traceContext); + callbacks.push(context.traceContext.toHeaders()); + callbackTurns.push(context.turnId); + if (callbacks.length === 2) continued.resolve(); + if (callbacks.length === 1) { + entered.resolve(); + await release.promise; + } + await Promise.resolve(); + checkPolicy(); + return 'accepted'; + }, + }), + ], + }); + const executor = await startPublicExecutor(); + try { + const open = async () => { + const session = await agent.session({ + ...(flags + ? { parentTrace: parent(PARENT.slice(0, -2) + flags) } + : { tracing: { includeSensitiveData: false } }), + environment: new Environment({ + url: executor.url, + cwd: executor.cwd, + headers: executor.headers, + tools: ['exec_command', 'write_stdin', 'apply_patch'], + approval: () => false, + }), + }); + try { + const root = await session.submit('delegate'); + assert.equal((await root.wait()).finalOutput, 'root done'); + return { session, root }; + } catch (error) { + release.resolve(); + await session.close(); + throw error; + } + }; + const { session, root } = flags + ? await open() + : await telemetry.withTrace( + { name: 'carrier.owner', includeSensitiveData: false }, + async (original) => { + workflow = original; + traceId = original.context.traceId; + return open(); + }, + ); + try { + const owned = await Array.fromAsync(session.subagents()); + assert.equal(owned.length, 1); + const child = owned[0]!; + assert(child.turn); + await Promise.race([ + entered.promise, + child.turn.wait().then(() => { + throw new Error('Child finished without entering observe'); + }), + ]); + release.resolve(); + let later; + if (steering) { + await sampled.promise; + const steered = await telemetry.withTrace( + { name: 'unrelated.steering', includeSensitiveData: true }, + () => child.submit(marker), + ); + assert.equal(steered, child.turn); + releaseSample.resolve(); + assert.equal((await child.turn.wait()).finalOutput, 'child done'); + await continued.promise; + for await (const event of session.events()) { + if ( + event.type === 'turn_finished' && + event.turnId === callbackTurns[1] + ) { + assert.equal(event.status, 'completed'); + break; + } + } + assert.deepEqual(sampledMarker.slice(0, 3), [false, false, true]); + } else { + assert.equal((await child.turn.wait()).finalOutput, 'child done'); + later = await telemetry.withTrace( + { name: 'unrelated.caller', includeSensitiveData: true }, + () => child.submit('call observe again'), + ); + } + if (later) assert.equal((await later.wait()).finalOutput, 'child done'); + if (steering) assert.deepEqual(sampledMarker, [false, false, true, true]); + assert.equal(callbacks.length, 2); + server.assertExhausted(); + return { + flags, + steering, + sampledMarker, + traceId, + rootCallback, + callbacks, + callbackTurns, + rootTurn: root.turnId, + childTurns: callbackTurns, + policyChecks, + }; + } finally { + release.resolve(); + releaseSample.resolve(); + await session.close(); + } + } finally { + release.resolve(); + releaseSample.resolve(); + await executor.close(); + } +} + async function graph() { const remoteServer = new ModelServer([[final('remote')]]); const remote = new Agent({ @@ -293,14 +577,25 @@ async function main() { traces: { kind: 'otlp-http', endpoint: collector!, protocol: 'json' }, }); try { - const result = - mode === 'graph' - ? await graph() - : mode === 'compact' - ? await compact() - : mode === 'review' - ? await review() - : await manual(mode!, path!); + const result = mode?.startsWith('flags-') + ? await definedFlags(mode.slice(6)) + : mode === 'store-reopen' + ? await storeReopen(path!) + : mode?.startsWith('owned-steering-') + ? await ownedChildren(mode.slice('owned-steering-'.length), true) + : mode?.startsWith('owned-children-') + ? await ownedChildren( + mode === 'owned-children-scoped' + ? undefined + : mode.slice('owned-children-'.length), + ) + : mode === 'graph' + ? await graph() + : mode === 'compact' + ? await compact() + : mode === 'review' + ? await review() + : await manual(mode!, path!); console.log(JSON.stringify(result)); } finally { await telemetry.shutdown(); diff --git a/packages/agents-codex/test/native/trace-context-native.test.ts b/packages/agents-codex/test/native/trace-context-native.test.ts index e4b8220c3..764a0f8e7 100644 --- a/packages/agents-codex/test/native/trace-context-native.test.ts +++ b/packages/agents-codex/test/native/trace-context-native.test.ts @@ -11,6 +11,8 @@ const execute = promisify(execFile); const PARENT = '00-11111111111111111111111111111111-2222222222222222-01'; const OVERRIDE = '00-33333333333333333333333333333333-4444444444444444-01'; const WORKER = '00-55555555555555555555555555555555-6666666666666666-01'; +const STATE = + 'tenant@abcdefghijklm=' + 'x'.repeat(256) + ',other=' + 'y'.repeat(256); type Span = { name: string; traceId: string; @@ -92,6 +94,67 @@ it('exports isolated root and compaction parents, queued snapshots, exact callba return false; }; try { + for (const flags of ['02', '03']) { + const receipt = await run('flags-' + flags); + expect(receipt.callback.traceparent.slice(3, 35)).toBe( + PARENT.slice(3, 35), + ); + expect(receipt.callback.traceparent.endsWith('-' + flags)).toBe(true); + if (flags === '03') + expectParent(dispatch(receipt.turn), PARENT.slice(0, -2) + flags); + } + for (const flags of ['02', '03']) { + for (const kind of ['owned-children-', 'owned-steering-']) { + const firstSpan = spans.length; + const owned = await run(kind + flags); + expect(owned.rootCallback.traceparent.slice(3, 35)).toBe( + PARENT.slice(3, 35), + ); + expect(owned.rootCallback.traceparent.endsWith('-' + flags)).toBe(true); + expect(owned.rootCallback.tracestate).toBe(STATE); + expect(owned.callbacks).toHaveLength(2); + for (const callback of owned.callbacks) { + expect(callback.traceparent.slice(3, 35)).toBe(PARENT.slice(3, 35)); + expect(callback.traceparent.endsWith('-' + flags)).toBe(true); + expect(callback.tracestate).toBe(STATE); + } + for (const span of spans + .slice(firstSpan) + .filter((span) => span.name.startsWith('op.dispatch.turn_input'))) + expect(span.traceId).toBe(PARENT.slice(3, 35)); + if (kind === 'owned-steering-') + expect(owned.sampledMarker).toEqual([false, false, true, true]); + if (flags === '03') { + const root = dispatch(owned.rootTurn); + expectParent(root, PARENT); + for (const turn of owned.childTurns) { + const child = dispatch(turn); + expect(child.traceId).toBe(root.traceId); + expect(descendsFrom(child, root)).toBe(true); + } + } + } + } + const scoped = await run('owned-children-scoped'); + expect(scoped.rootCallback.traceparent.slice(3, 35)).toBe(scoped.traceId); + expect(scoped.callbacks).toHaveLength(2); + expect(scoped.policyChecks).toBeGreaterThanOrEqual(4); + for (const callback of scoped.callbacks) + expect(callback.traceparent.slice(3, 35)).toBe(scoped.traceId); + const reopened = await run('store-reopen'); + expectParent(dispatch(reopened.turns[0]), PARENT); + expectParent(dispatch(reopened.turns[1]), WORKER); + for (const [index, expected] of [PARENT, WORKER].entries()) { + expect(reopened.callbacks[index].traceparent.slice(3, 35)).toBe( + expected.slice(3, 35), + ); + expect(reopened.callbacks[index].traceparent.endsWith('-03')).toBe(true); + } + const fresh = reopened.callbacks[2].traceparent; + expect([PARENT.slice(3, 35), WORKER.slice(3, 35)]).not.toContain( + fresh.slice(3, 35), + ); + expect(Number.parseInt(fresh.slice(-2), 16) & 2).toBe(0); const compact = await run('compact'); expectParent(dispatch(compact.first), OVERRIDE); expectParent(dispatch(compact.compact), PARENT); diff --git a/packages/agents-codex/test/trace-context.test.ts b/packages/agents-codex/test/trace-context.test.ts index 56375783f..259d002de 100644 --- a/packages/agents-codex/test/trace-context.test.ts +++ b/packages/agents-codex/test/trace-context.test.ts @@ -69,7 +69,17 @@ describe('explicit W3C trace carriers', () => { expect(new Headers(headers).get('tracestate')).toBeNull(); }); - it.each(['02', '09'])( + it.each(['00', '01', '02', '03'])( + 'round-trips defined trace flags %s', + (flags) => { + const value = parent.slice(0, -2) + flags; + expect(new TraceContext({ traceparent: value }).toHeaders()).toEqual({ + traceparent: value, + }); + }, + ); + + it.each(['04', '09'])( 'rejects trace flags %s outside the documented native profile', (flags) => { expect(