diff --git a/packages/paperclip-runner/docs/runner-protocol-live-evals.md b/packages/paperclip-runner/docs/runner-protocol-live-evals.md index 6ed8f6f214..809ef86ebd 100644 --- a/packages/paperclip-runner/docs/runner-protocol-live-evals.md +++ b/packages/paperclip-runner/docs/runner-protocol-live-evals.md @@ -62,6 +62,43 @@ same `paperclip.native-execution-input.v3` contract as production, including the AgentCore HarnessSkill upload path, without borrowing any browser E2E setup. +The direct CLI resolves native Codex from the pinned Codex ACP dependency, as +the browser E2E launcher does; no globally installed `codex` is required. +Its isolated fixture sessions explicitly use unattended ACPX `approve-all` +permissions, matching the full-stack ACPX test profiles. OpenCode retains its +existing semantic-only permission boundary. +The seeded mock control plane still enforces tool grants, company boundaries, +and governance checks. These test-only choices do not change production +permission defaults and are not a qualification of interactive approval UI. + +AgentCore eval admission waits up to 125 seconds, capped by the configured +whole-turn timeout. Its network worker already allows 120 seconds for cold +invocation delivery; the generic 30-second facade wait must not cut that +delivery short during parallel fleet startup. Other providers retain their +existing admission timeout. + +The runtime instructions distinguish the fixture's `finish_task`/`block_task` +state changes from native `paperclip_finish`/`paperclip_block` run-result +reporting. This layer has no production server to apply a reported run result +to a task. Its completion cases still require the actual semantic operation +and durable mock-state change; reporting success alone does not pass them. +The current turn request defines the requested work. Shared seed descriptions, +old eval notes, or past accepted interactions are background state, not a +replacement objective or proof that a newly requested action is already done. +Finishing the provider turn does not authorize an unrequested mock task-state +change or completion comment. These harness instructions keep single-operation +cases bounded while leaving their operation and state-effect assertions intact. + +ACPX accounting uses the qualified server's billable token semantics: Claude +and Codex already include reasoning in output, and Codex has no cache-write +billing category. Other missing categories remain unknown. ACPX stores Codex's +terminal prompt-response usage without streaming it, so the sidecar forwards +the single newly persisted receipt bound to that exact turn before its terminal +event. It never substitutes a previous turn's receipt or context occupancy for +billable usage. These receipt corrections also apply to non-eval ACPX sessions. +The durable redactor preserves these known numeric token-counter fields; +string/object values under the same names remain redacted as sensitive data. + `all` means the maintained enabled campaign, not every matching file in the roster directory. A missing campaign file fails closed. Within an enabled lane, a missing remote profile or unavailable provider is retained as an diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs index c1e29bef7d..1a73082224 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs @@ -1135,6 +1135,12 @@ fn sensitive_key(key: &str, value: &Value) -> bool { | "outputtokens" | "cachereadtokens" | "cachewritetokens" + // The qualified ACPX sidecar uses these numeric billing aliases. + // Strings under the same names remain credentials, not counters. + | "cachedreadtokens" + | "cachedwritetokens" + | "thoughttokens" + | "totaltokens" | "pretokens" | "posttokens" ) { @@ -1825,6 +1831,24 @@ mod tests { sanitize_value(&json!({"inputTokens": "plain-provider-secret"})), json!({"inputTokens": "[REDACTED]"}) ); + for key in [ + "cachedReadTokens", + "cachedWriteTokens", + "thoughtTokens", + "totalTokens", + ] { + assert_eq!(sanitize_value(&json!({key: 12})), json!({key: 12})); + for value in [ + json!("plain-provider-secret"), + json!({"value":"secret"}), + json!(["secret"]), + ] { + assert_eq!( + sanitize_value(&json!({key: value})), + json!({key: "[REDACTED]"}) + ); + } + } } #[test] diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_events.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_events.rs index a51efa7136..2dc412039b 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_events.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_events.rs @@ -201,6 +201,49 @@ fn maps_bounded_plan_and_completion_state() { assert_eq!(events[0].priority, EventPriority::P1); } +#[test] +fn preserves_usage_counters_across_sidecar_payload_redaction() { + let mut scope = AcpxEventScope::new("run-1").unwrap(); + scope.bind_turn("turn-1").unwrap(); + let decoded = decode_acpx_event( + &scope, + &AcpxSidecarEvent { + sequence: 1, + event_type: GeneratedAcpxSidecarEventType::RuntimeEvent, + run_id: Some("run-1".to_owned()), + turn_id: Some("turn-1".to_owned()), + payload: json!({ + "type":"status", "tag":"usage_update", + "breakdown":{ + "inputTokens":12, "outputTokens":7, "thoughtTokens":0, + "cachedReadTokens":2, "cachedWriteTokens":0, "totalTokens":21 + }, + "accessToken":"provider-secret", + "cost":{"amount":0.25,"currency":"USD"} + }), + }, + ) + .unwrap(); + let AcpxEventPayload::Runtime { + kind, + tool_operation, + payload, + .. + } = decoded + else { + panic!("runtime usage must decode as a runtime payload"); + }; + assert_eq!(payload["accessToken"], "[REDACTED]"); + assert_eq!(payload["breakdown"]["totalTokens"], 21); + let events = + normalize_acpx_runtime_event(kind, &payload, tool_operation, "event-7", "turn-1", 3); + assert_eq!(events[0].payload["runDeltaAvailable"], true); + assert_eq!(events[0].payload["runDelta"]["inputTokens"], 12); + assert_eq!(events[0].payload["runDelta"]["outputTokens"], 7); + assert_eq!(events[0].payload["runDelta"]["cacheReadTokens"], 2); + assert_eq!(events[0].payload["runDelta"]["cacheWriteTokens"], 0); +} + #[test] fn maps_usage_and_review_status_but_ignores_inventory_updates() { let usage = normalize( diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index 2922d635a5..aa7440b32e 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -51,6 +51,10 @@ import { type AcpxSidecarResponse, } from "../drivers/acpx/sidecar-protocol.js"; import { safeAcpxLocations } from "./acpx-sidecar-locations.js"; +import { + persistedAcpxTurnUsage, + qualifiedAcpxUsageBreakdown, +} from "../drivers/acpx/usage-accounting.js"; import { validatePrpStructuredRunResult } from "../protocol/replay-contract.js"; import type { RunnerToolCall } from "../drivers/runner-tool-bridge.js"; import { @@ -314,7 +318,9 @@ async function dispatch( const currentTurnId = boundedIdentity(request.params.turnId, "turnId"); turnId = currentTurnId; let runtimeTurn: AcpxRuntimeTurn; + let usageBefore: unknown; try { + usageBefore = await readSidecarHostStatusWithin(activeHost); runtimeTurn = activeHost.startTurn({ requestId: `${runId}:${currentTurnId}`, text: boundedText(request.params.message, "message", 1024 * 1024), @@ -325,7 +331,7 @@ async function dispatch( turnId = null; throw error; } - void pumpTurn(currentTurnId, runtimeTurn); + void pumpTurn(currentTurnId, runtimeTurn, activeHost, usageBefore); return { turnId: currentTurnId }; } if (request.command === "turn.cancel") { @@ -479,6 +485,8 @@ async function dispatch( async function pumpTurn( currentTurnId: string, runtimeTurn: AcpxRuntimeTurn, + activeHost: AcpxRuntimeHost, + usageBefore: unknown, ): Promise { let terminal: Record; try { @@ -496,6 +504,24 @@ async function pumpTurn( ); } const result = await runtimeTurn.result; + try { + const usage = persistedAcpxTurnUsage( + usageBefore, + await readSidecarHostStatusWithin(activeHost), + runtimeTurn.requestId, + ); + if (usage) { + emit( + "runtime.event", + sanitizeRuntimeEvent(usage as unknown as AcpRuntimeEvent), + currentTurnId, + ); + } + } catch (error) { + // Missing usage must stay unknown, but an accounting read failure must + // not replace the provider's authoritative completed/cancelled result. + diagnostic("acpx_terminal_usage_unavailable", safeMessage(error)); + } terminal = boundedSidecarValue(result); } catch (error) { terminal = { @@ -868,7 +894,9 @@ function sanitizeRuntimeStatus(value: unknown): Record { function safeUsage(cost: unknown, breakdown: unknown): Record { const nativeCost = record(cost); - const nativeBreakdown = record(breakdown); + const nativeBreakdown = record( + qualifiedAcpxUsageBreakdown(initializedAgent, breakdown), + ); return { cost: cost === undefined || cost === null diff --git a/packages/paperclip-runner/src/cli/eval-provider-runtime.test.ts b/packages/paperclip-runner/src/cli/eval-provider-runtime.test.ts new file mode 100644 index 0000000000..5116264a92 --- /dev/null +++ b/packages/paperclip-runner/src/cli/eval-provider-runtime.test.ts @@ -0,0 +1,29 @@ +import { existsSync } from "node:fs"; +import { isAbsolute } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { evalProviderTransportOptions } from "./eval-provider-runtime.js"; + +describe("direct eval provider runtime", () => { + it("uses the installed pinned Codex dependency without requiring a global CLI", () => { + const options = evalProviderTransportOptions("codex"); + expect(isAbsolute(options.codexCommand!)).toBe(true); + expect(existsSync(options.codexCommand!)).toBe(true); + expect(options.codexCommand).toMatch(/@openai\/codex\/bin\/codex\.js$/); + expect(options.acpxPermissionMode).toBeUndefined(); + }); + + it("uses explicit unattended permissions only for isolated local eval providers", () => { + expect(evalProviderTransportOptions("acpx")).toEqual({ + acpxPermissionMode: "approve-all", + acpxPermissionModePinned: true, + }); + expect(evalProviderTransportOptions("opencode")).toEqual({}); + expect(evalProviderTransportOptions("claude_managed")).toEqual({}); + }); + + it("bounds AgentCore admission by both the turn budget and delivery timeout", () => { + expect(evalProviderTransportOptions("aws_agentcore", 180_000)).toEqual({ turnStartTimeoutMs: 125_000 }); + expect(evalProviderTransportOptions("aws_agentcore", 10_000)).toEqual({ turnStartTimeoutMs: 10_000 }); + }); +}); diff --git a/packages/paperclip-runner/src/cli/eval-provider-runtime.ts b/packages/paperclip-runner/src/cli/eval-provider-runtime.ts new file mode 100644 index 0000000000..ff2c2f96d1 --- /dev/null +++ b/packages/paperclip-runner/src/cli/eval-provider-runtime.ts @@ -0,0 +1,36 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, resolve } from "node:path"; + +import type { CapabilityRunnerdCodexTransportOptions } from "../live/runnerd-codex-transport.js"; + +/** The direct fixture harness uses the same unattended policy as Runner E2E. */ +export function evalProviderTransportOptions( + provider: "codex" | "opencode" | "acpx" | "claude_managed" | "aws_agentcore", + turnTimeoutMs = 120_000, +): Pick { + if (provider === "aws_agentcore") { + // AgentCore's worker permits 120 seconds for invocation delivery. Cold + // starts under fleet load must not hit the facade's shorter 30s default. + return { turnStartTimeoutMs: Math.min(turnTimeoutMs, 125_000) }; + } + if (provider === "acpx") { + // The operator requested this isolated mock-control-plane campaign. This + // is an eval policy, not a change to production permission defaults. + return { acpxPermissionMode: "approve-all", acpxPermissionModePinned: true }; + } + if (provider !== "codex") return {}; + const runnerRequire = createRequire(import.meta.url); + const codexRequire = createRequire( + runnerRequire.resolve("@agentclientprotocol/codex-acp/package.json"), + ); + const manifestPath = codexRequire.resolve("@openai/codex/package.json"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { + bin?: string | Record; + }; + const executable = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.codex; + if (!executable) throw new Error("Pinned Codex dependency does not expose its executable"); + return { codexCommand: resolve(dirname(manifestPath), executable) }; +} diff --git a/packages/paperclip-runner/src/cli/eval-session-contract.test.ts b/packages/paperclip-runner/src/cli/eval-session-contract.test.ts index 322a4fbf70..590d575e3e 100644 --- a/packages/paperclip-runner/src/cli/eval-session-contract.test.ts +++ b/packages/paperclip-runner/src/cli/eval-session-contract.test.ts @@ -78,6 +78,9 @@ describe("eval-session request contract", () => { )).toContain("Paperclip direct live evaluation"); const systemInstructions = evalRuntimeSystemInstructions(context); expect(systemInstructions).toContain("Paperclip direct live evaluation"); + expect(systemInstructions).toContain("Task-state changes in this mock control plane use finish_task and block_task"); + expect(systemInstructions).toContain("The current user request defines the work for this turn"); + expect(systemInstructions).toContain("Do not finish or block the mock task unless the current request asks for that state change"); expect(systemInstructions).toContain( `Read-only instruction sibling root: ${context.instructions.bundle.rootPath}`, ); diff --git a/packages/paperclip-runner/src/cli/eval-session.ts b/packages/paperclip-runner/src/cli/eval-session.ts index 220aee7adb..7aadfd3fd2 100644 --- a/packages/paperclip-runner/src/cli/eval-session.ts +++ b/packages/paperclip-runner/src/cli/eval-session.ts @@ -32,6 +32,7 @@ import { type EvalSessionRequest, type EvalSessionUsage, } from "./eval-session-contract.js"; +import { evalProviderTransportOptions } from "./eval-provider-runtime.js"; interface EvalSessionCliOptions { requestPath: string; @@ -68,6 +69,9 @@ const EVAL_RUNTIME_INSTRUCTIONS = [ "", "Use the provided Paperclip semantic tools to inspect and act on the assigned task.", "Treat the seeded control-plane state as authoritative and keep every action within the requested scope.", + "The current user request defines the work for this turn. Seeded task descriptions, notes, and past interaction results are background context; they do not supersede that request or establish that a newly requested action has already been performed.", + "Task-state changes in this mock control plane use finish_task and block_task. Native paperclip_finish and paperclip_block report the provider run result but do not update the mock task. When asked to finish or block the assigned task, use its task-state semantic operation before reporting the run result.", + "Do not finish or block the mock task unless the current request asks for that state change. Ending the provider turn after another requested action does not authorize additional task-state changes or completion comments.", "", ].join("\n"); @@ -293,6 +297,7 @@ export async function runEvalSessionCli( const service = options.serviceFactory?.(runnerdPath) ?? new CapabilityLiveSessionService({ transportOptions: { + ...evalProviderTransportOptions(requestedProvider, request.limits.turnTimeoutMs), runnerBinary: runnerdPath, runtimeContext, baseInstructions: evalRuntimeSystemInstructions(runtimeContext), diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts index 0be26c2410..633209f371 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts @@ -206,6 +206,9 @@ describe("Codex ACPX runtime adapter", () => { acpxRecordId: "record-1", acpSessionId: "backend-1", agentSessionId: "agent-1", + lastRequestId: "run:turn-1", + request_token_usage: { prompt: { input_tokens: 12, output_tokens: 30 } }, + cumulative_cost: { amount: 0.1, currency: "USD" }, acpx: { current_model_id: "gpt-5.6-sol", available_models: ["gpt-5.6-sol"], @@ -225,6 +228,9 @@ describe("Codex ACPX runtime adapter", () => { acpxRecordId: "record-1", backendSessionId: "backend-1", agentSessionId: "agent-1", + lastRequestId: "run:turn-1", + requestTokenUsage: { prompt: { input_tokens: 12, output_tokens: 30 } }, + usageCost: { amount: 0.1, currency: "USD" }, models: { currentModelId: "gpt-5.6-sol", availableModelIds: ["gpt-5.6-sol"], diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts index 13c122bcc2..ba78a6a746 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -1148,6 +1148,9 @@ async function persistedRuntimeStatus( acpxRecordId: record.acpxRecordId, backendSessionId: record.acpSessionId, agentSessionId: persistedAgentSessionId, + lastRequestId: record.lastRequestId, + requestTokenUsage: structuredClone(record.request_token_usage ?? {}), + usageCost: structuredClone(record.cumulative_cost), ...(currentModelId === undefined && !availableModelIds?.length ? {} : { diff --git a/packages/paperclip-runner/src/drivers/acpx/usage-accounting.test.ts b/packages/paperclip-runner/src/drivers/acpx/usage-accounting.test.ts new file mode 100644 index 0000000000..168e81cc8c --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/usage-accounting.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { + persistedAcpxTurnUsage, + qualifiedAcpxUsageBreakdown, +} from "./usage-accounting.js"; + +describe("qualified ACPX usage", () => { + it("accepts Claude's four-field aggregate without inventing extra reasoning", () => { + expect( + qualifiedAcpxUsageBreakdown("claude", { + inputTokens: 12, + outputTokens: 30, + cachedReadTokens: 40, + cachedWriteTokens: 50, + }), + ).toEqual({ + inputTokens: 12, + outputTokens: 30, + cachedReadTokens: 40, + cachedWriteTokens: 50, + thoughtTokens: 0, + }); + }); + + it("does not count Codex reasoning twice and knows cache writes are inapplicable", () => { + expect( + qualifiedAcpxUsageBreakdown("codex", { + inputTokens: 12, + outputTokens: 30, + cachedReadTokens: 40, + thoughtTokens: 20, + }), + ).toEqual({ + inputTokens: 12, + outputTokens: 30, + cachedReadTokens: 40, + cachedWriteTokens: 0, + thoughtTokens: 0, + }); + }); + + it("preserves unknown billable fields and explicit invalid values", () => { + expect(qualifiedAcpxUsageBreakdown("claude", { inputTokens: 12 })).toEqual({ + inputTokens: 12, + thoughtTokens: 0, + }); + expect( + qualifiedAcpxUsageBreakdown("codex", { + inputTokens: null, + cachedWriteTokens: null, + }), + ).toEqual({ inputTokens: null, cachedWriteTokens: null, thoughtTokens: 0 }); + expect(qualifiedAcpxUsageBreakdown("codex", undefined)).toBeUndefined(); + expect(qualifiedAcpxUsageBreakdown("claude", null)).toBeNull(); + }); + + it("does not infer accounting semantics for Pi or an uninitialized agent", () => { + const usage = { inputTokens: 12, outputTokens: 30, thoughtTokens: 20 }; + expect(qualifiedAcpxUsageBreakdown("pi", usage)).toEqual(usage); + expect(qualifiedAcpxUsageBreakdown(null, usage)).toEqual(usage); + }); +}); + +describe("persisted terminal ACPX usage", () => { + const before = { requestTokenUsage: { previous: { input_tokens: 999 } } }; + const receipt = { + input_tokens: 10, + output_tokens: 20, + cache_read_input_tokens: 30, + thought_tokens: 5, + total_tokens: 60, + }; + const after = { + lastRequestId: "run:turn-2", + usageCost: { amount: 0.5, currency: "USD" }, + requestTokenUsage: { ...before.requestTokenUsage, current: receipt }, + }; + + it("recovers only the newly persisted prompt receipt, including prompt-response-only usage", () => { + expect(persistedAcpxTurnUsage(before, after, "run:turn-2")).toEqual({ + type: "status", + tag: "usage_update", + text: "terminal prompt usage", + cost: { amount: 0.5, currency: "USD" }, + breakdown: { + inputTokens: 10, + outputTokens: 20, + cachedReadTokens: 30, + cachedWriteTokens: undefined, + thoughtTokens: 5, + totalTokens: 60, + }, + }); + }); + + it("rejects old, ambiguous, or differently bound receipts", () => { + expect(persistedAcpxTurnUsage(before, after, "run:other")).toBeNull(); + expect(persistedAcpxTurnUsage(after, after, "run:turn-2")).toBeNull(); + expect( + persistedAcpxTurnUsage( + before, + { + ...after, + requestTokenUsage: { current: receipt, extra: receipt }, + }, + "run:turn-2", + ), + ).toBeNull(); + expect( + persistedAcpxTurnUsage(undefined, undefined, "run:turn-2"), + ).toBeNull(); + }); + + it("works for the first turn without prior usage and retains missing fields", () => { + expect( + persistedAcpxTurnUsage( + {}, + { + lastRequestId: "first", + requestTokenUsage: { current: { input_tokens: 10 } }, + }, + "first", + ), + ).toMatchObject({ + breakdown: { + inputTokens: 10, + outputTokens: undefined, + cachedReadTokens: undefined, + }, + }); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts b/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts new file mode 100644 index 0000000000..f6c2ad50d6 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts @@ -0,0 +1,64 @@ +import type { QualifiedAcpxAgent } from "./qualified-profiles.js"; + +function record(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +/** Normalize only semantics established by the pinned, qualified ACP servers. */ +export function qualifiedAcpxUsageBreakdown( + agent: QualifiedAcpxAgent | null, + value: unknown, +): unknown { + if (value === null || value === undefined) return value; + const breakdown = record(value); + if (agent !== "claude" && agent !== "codex") return breakdown; + // Claude SDK aggregate output and Codex ACP toPromptUsage.outputTokens both + // INCLUDE reasoning. PRP folds thought into output, so its additive component + // is zero here, not the provider's diagnostic reasoning-token subset. + return { + ...breakdown, + thoughtTokens: 0, + // Codex ACP 1.6.2 has no cache-write billing category. Do not apply this + // provider-specific zero to Claude/Pi or to an explicitly invalid value. + ...(agent === "codex" && breakdown.cachedWriteTokens === undefined + ? { cachedWriteTokens: 0 } + : {}), + }; +} + +/** + * ACPX persists terminal prompt-response usage but does not stream it. Recover + * exactly the new prompt receipt belonging to this turn, never a prior receipt + * or the misleadingly named cumulative_token_usage (which is last-write-wins). + */ +export function persistedAcpxTurnUsage( + before: unknown, + after: unknown, + requestId: string, +): Record | null { + const current = record(after); + if (current.lastRequestId !== requestId) return null; + const previousReceipts = record(record(before).requestTokenUsage); + const receipts = record(current.requestTokenUsage); + const added = Object.keys(receipts).filter( + (key) => !Object.hasOwn(previousReceipts, key), + ); + if (added.length !== 1) return null; + const usage = record(receipts[added[0]!]); + return { + type: "status", + tag: "usage_update", + text: "terminal prompt usage", + cost: current.usageCost, + breakdown: { + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + cachedReadTokens: usage.cache_read_input_tokens, + cachedWriteTokens: usage.cache_creation_input_tokens, + thoughtTokens: usage.thought_tokens, + totalTokens: usage.total_tokens, + }, + }; +} diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index a28105aa37..420d304d00 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -940,6 +940,8 @@ export interface CapabilityRunnerdCodexTransportOptions { /** Provider system instructions supplied by a native execution caller. */ baseInstructions?: string; closeGraceMs?: number; + /** Bounded provider turn-admission wait; defaults to 30 seconds. */ + turnStartTimeoutMs?: number; onDiagnostic?: (message: string) => void; onEvidence?: (evidence: Readonly) => void; stateDirectory?: string; @@ -3737,6 +3739,11 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { async #startTurn( params: Record, ): Promise> { + const turnStartTimeoutMs = this.options.turnStartTimeoutMs ?? 30_000; + if (!Number.isSafeInteger(turnStartTimeoutMs) || turnStartTimeoutMs <= 0) { + throw new Error("turnStartTimeoutMs must be a positive safe integer"); + } + const commandDeadline = Date.now() + turnStartTimeoutMs; const input = Array.isArray(params.input) ? params.input.map(record) : []; const message = input .map((item) => (typeof item.text === "string" ? item.text : "")) @@ -3755,7 +3762,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { const startResult = await this.#commandResult("turn.start", { text: message, turnId: pendingTurnId, - }); + }, commandDeadline); const expectedProviderTurnId = typeof startResult.providerTurnId === "string" && startResult.providerTurnId.length > 0 @@ -3788,7 +3795,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { // command's durable result so a delayed prior-turn event cannot satisfy // the new response fence. ACPX echoes the requested identity, while // Codex and OpenCode return their provider-assigned identity. - const deadline = Date.now() + 30_000; + const deadline = this.options.turnStartTimeoutMs === undefined + ? Date.now() + 30_000 + : commandDeadline; const providerTurnStarted = () => turnStartResponseReady({ responseEpoch,