Skip to content

fix(cache): emit tools before input in OpenAI Responses request body for prefix-cache stability#78747

Open
ashvinnagarajan wants to merge 1 commit into
openclaw:mainfrom
ashvinnagarajan:fix/codex-responses-tools-before-input
Open

fix(cache): emit tools before input in OpenAI Responses request body for prefix-cache stability#78747
ashvinnagarajan wants to merge 1 commit into
openclaw:mainfrom
ashvinnagarajan:fix/codex-responses-tools-before-input

Conversation

@ashvinnagarajan

@ashvinnagarajan ashvinnagarajan commented May 7, 2026

Copy link
Copy Markdown

Summary

  • Problem: buildOpenAIResponsesParams (src/agents/openai-transport-stream.ts:979) inserts the literal with input: messages first, then assigns params.tools = convertResponsesTools(...) later via a conditional block. JS property insertion order determines JSON key order, which determines the byte prefix the upstream prompt cache matches. With input enumerated first, the (large, per-session-stable) tools schema lands past the per-turn divergence point and is excluded from the cached prefix every turn.
  • Why it matters: On a real Codex Responses session the byte-stable prefix between consecutive turns was only 15% (~12k tokens) of the request; the other ~85% (~67k tokens) was the tools schema being re-prefilled by the upstream every turn. End-to-end per-turn latency carried the cost.
  • What changed: Hoist convertResponsesTools above the params literal so the converted tools can be inserted in the literal between stream and instructions. Move input to the end of the literal so per-turn mutations sit downstream of the cache-stable prefix. Drop the now-redundant if (context.tools) params.tools = ... block. Same fields, same values; only on-the-wire byte order changes.
  • What did NOT change (scope boundary): No semantic change to any field. tools is still produced by the same convertResponsesTools call with the same strict flag; input, metadata, prompt_cache_key, prompt_cache_retention, and the conditional reasoning/include/service_tier/temperature/max_output_tokens code paths are unchanged. No transport, auth, or sanitize-policy changes. Same applies to the Azure and Codex variants since they all flow through buildOpenAIResponsesParams.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration

Linked Issue/PR

Real behavior proof

  • Behavior or issue addressed: the tools schema, ~270KB of converted function definitions, was being re-prefilled by the upstream model every turn instead of being included in the prompt cache prefix. Surfaced while debugging SMS-bound agent latency.
  • Real environment tested: OpenClaw v2026.4.15 gateway running on Ubuntu 24.04 (EC2 t3.large), agent model openai-codex/gpt-5.4-mini, real customer session with the standard SOUL/USER/TOOLS/IDENTITY/HEARTBEAT system prompt and 6 plugins loaded (acpx, browser, device-pair, phone-control, sendblue, talk-voice).
  • Exact steps or command run after this patch: to verify the fix on a real install before opening this PR, I monkey-patched globalThis.fetch via a NODE_OPTIONS=--require=... preload that does the equivalent JSON-key reorder on the wire. Restarted the gateway, then sent the same 5 SMS in the same order on each side of a paired trial (REORDER OFF baseline → REORDER ON), measuring per-turn endToEndMs from the existing sendblue plugin timing instrumentation. Phase 2 ran after phase 1, so the session already had 5 extra turns of conversation history baked in (~70KB extra input per phase-2 request) — that biased phase 2 slower by ~1-3s/turn, which makes the result a conservative measurement.
  • Evidence after fix:

Byte-prefix stability (single biggest signal):

Before reorder:
  common prefix between consecutive turns: 50,351 bytes (15%)
  divergent middle: ~ε bytes (the new turn appended)
  common suffix:    269,693 bytes (84%)   ← tools schema, sent byte-identical, NOT cached
  total:            320,063 bytes  (~80K tokens)

After reorder:
  common prefix between consecutive turns: 335,698 bytes (99%)
  divergent tail:    ~ε bytes (the new turn appended)
  total:            335,720 bytes  (~84K tokens)

Paired trial — same 5 SMS in the same order, real production agent:

Turn reply len endToEndMs (OFF) endToEndMs (ON) Δ ms Δ %
1 ~70 chars 25,411 17,157 -8,254 -32%
2 ~15 chars 21,326 17,213 -4,113 -19%
3 ~140 chars 15,240 11,401 -3,839 -25%
4 ~16 chars 5,807 5,050 -757 -13%
5 ~14 chars 8,770 8,313 -457 -5%
mean 15,311 11,827 -3,484 -23%

5/5 wins. Mean per-turn latency dropped 3.5s.

Status code check: all 8 reordered codex calls captured during phase 2 returned status: 200. The Codex Responses backend accepts the reordered body shape without complaint, confirming JSON semantic equivalence holds — no other plumbing reorder is needed.

Captured directly from the wire (request body head, post-reorder):

{"model":"gpt-5.4-mini","stream":true,"tools":[{"type":"function","name":"read",...

vs pre-reorder:

{"model":"gpt-5.4-mini","store":false,"stream":true,"instructions":"You are a personal assistant...

(tools now sits in the byte-stable prefix; per-turn input content is at the end.)

  • Observed result after fix: 99% byte-stable prefix between consecutive turns vs 15% before. Per-turn end-to-end latency reduced by 3.5s on average (5/5 trials in the same direction). All requests accepted by the upstream with status 200.
  • What was not tested: TTFT (time-to-first-streamed-token) directly — measurement attempt via a TransformStream-wrapped Response interfered with how the OpenAI SDK consumes the response stream and was rolled back. endToEndMs from the sendblue plugin's existing instrumentation (request received → outbound reply sent) was used as the proxy. I have not tested the Azure or non-Codex (openai-responses against api.openai.com) variants since this PR uses openai-codex-responses against chatgpt.com — but buildOpenAIResponsesParams is the shared builder for all three, so the same key ordering applies.

Root Cause

  • Root cause: the params literal at src/agents/openai-transport-stream.ts:1008 placed input early; the conditional if (context.tools) params.tools = ... block at line 1027 reassigned a property that was not in the literal, so V8 inserted tools at the end of the property order. JSON.stringify (used by the OpenAI SDK serialization path) emits keys in insertion order, putting the (cache-stable) tools schema downstream of the (per-turn-mutating) input array on the wire. Upstream prefix cache matches byte-by-byte from offset 0, so caching stops at the first byte of input and never sees tools.
  • Missing detection / guardrail: there was no test asserting key order on the params object. Existing buildOpenAIResponsesParams tests assert values via toMatchObject / toEqual, both of which are order-insensitive.
  • Contributing context: the existing prompt-cache infrastructure in this repo is Anthropic-shaped (explicit cache_control markers, OPENCLAW_CACHE_BOUNDARY, deterministic tool sort in fix(cache): sort MCP tools deterministically to stabilize prompt cache #58037). Codex Responses goes through a different cache mechanism — byte-prefix matching with no explicit markers — and the symmetric ordering fix wasn't there yet.

Regression Test Plan

  • Coverage level that should have caught this:
    • Unit test
  • Target test or file: src/agents/openai-transport-stream.test.ts, new it("emits \tools` before `input` so the tools schema lives in the cacheable prefix", ...)`
  • Scenario the test should lock in: for any buildOpenAIResponsesParams call that produces both tools and input, Object.keys(params) must enumerate tools before input. Belt-and-suspenders: JSON.stringify(params) must serialize "tools" before "input".
  • Why this is the smallest reliable guardrail: the failure mode is purely about JSON key insertion order. Asserting Object.keys(params) directly catches any future refactor that re-introduces a late params.tools = ... assignment without needing to mock the SDK or upstream cache.

User-visible / Behavior Changes

None. Same fields, same values. Only the on-the-wire JSON key order changes — semantically equivalent to the upstream API. The user-visible effect is faster turns once the upstream cache warms up.

Diagram

Before (cacheable prefix = 15%):
  [ small cacheable prefix  | input (per-turn) | tools schema (~270KB, identical-but-uncached) ]

After (cacheable prefix = 99%):
  [ tools schema (~270KB, cached) | other stable fields | input (per-turn) ]

Security Impact

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? No
  • New/changed network calls? No (same endpoint, same method, same headers, same fields, same values; only JSON key order changes)
  • Command/tool execution surface changed? No
  • Data access scope changed? No

Repro + Verification

Environment

  • OS: Ubuntu 24.04
  • Runtime/container: Node 22, OpenClaw gateway v2026.4.15 systemd user service
  • Model/provider: openai-codex/gpt-5.4-mini via chatgpt.com/backend-api/codex/responses
  • Integration/channel: SendBlue (iMessage); not relevant to this codepath

Steps

  1. On a real OpenClaw box, install a NODE_OPTIONS=--require=/path/to/fetch-tap.cjs preload that captures the request body for any call to chatgpt.com/backend-api/codex/responses.
  2. Restart the gateway. Send 5 SMS to the agent (any varied content, e.g. hi, what's the weather, tell me a joke, what time is it, thanks). Capture each turn's request body.
  3. Diff captured bodies between any two consecutive turns. Compute longest common prefix.
  4. Apply this PR (or the equivalent on-wire reorder). Restart the gateway. Send the same 5 SMS in the same order.
  5. Diff again. Compute longest common prefix. Compare per-turn endToEndMs from the existing sendblue timing log.

Expected

  • Common prefix between consecutive request bodies grows from ~15% to ~99% of total body length.
  • Per-turn endToEndMs drops by several seconds on warm-cache turns.
  • Upstream returns status: 200 for the reordered body.

Actual

Matches expected. See "Real behavior proof" above for the measured numbers.

Evidence

  • Failing test/log before + passing after — wire capture before/after, paired trial table above
  • Trace/log snippets — request body head before/after included
  • Screenshot/recording
  • Perf numbers — 5/5 wins, mean -3.5s/turn, byte-stable prefix 15% → 99%

Human Verification

  • Verified scenarios: real production OpenClaw gateway, real Codex Responses backend, real session with full system prompt + 6 plugins, paired-trial comparison of 5 turns each side.
  • Edge cases checked: request bodies with conversation history that includes function-call/function-call-output records (a calendar tool call was triggered during one of the trial sessions) — reorder still produces a stable prefix and 200 response.
  • What I did NOT verify: Azure (azure-openai-responses) and direct OpenAI public-API (openai-responses against api.openai.com) paths. Both flow through the same buildOpenAIResponsesParams, so the source change applies, but I did not exercise them on real traffic.

Compatibility / Migration

  • Backward compatible? Yes — same JSON, just different key order. JSON parsers are order-insensitive; no upstream consumer of this body can depend on order.
  • Config/env changes? No
  • Migration needed? No

Risks and Mitigations

  • Risk: an undocumented upstream behavior somewhere expects a specific JSON key order. Mitigation: verified on real Codex Responses traffic that the reordered body returns 200 for 8/8 captured calls. JSON ordering is not part of the documented OpenAI/Codex contract.
  • Risk: the existing prompt cache (cached against the old key order with the same prompt_cache_key) will be a one-turn miss for every active session immediately after this lands. Mitigation: the cache rebuild happens during the very first turn after deploy and benefits every subsequent turn within the session; observed in the paired trial as turn-1-post-reorder being slightly slower than turn-2-post-reorder.

Notes

  • AI-assisted PR. Built with Claude Code, mechanical change, no novel logic.
  • Pre-existing pnpm tsgo:core errors in unrelated files (src/media/**, src/plugins/**, src/secrets/**, etc.) are visible on main; tsgo:core reports zero errors in the touched files (src/agents/openai-transport-stream.ts, src/agents/openai-transport-stream.test.ts).
  • Could not run the targeted vitest run locally because node_modules/@openclaw/fs-safe/dist/ is missing after pnpm install (the package's exports map points at ./dist/... but the dist artifacts aren't shipped). That's not introduced by this PR. Real-environment proof above stands in for the unit test until CI runs the suite.

🤖 Generated with Claude Code

@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: S triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup. labels May 7, 2026
@clawsweeper

clawsweeper Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs changes before merge. Reviewed July 3, 2026, 5:06 PM ET / 21:06 UTC.

Summary
The PR reorders OpenAI Responses request construction so tools are emitted before input, adds a cache-stability regression test, and adds a changelog entry.

PR surface: Source +10, Tests +47, Docs +1. Total +58 across 3 files.

Reproducibility: yes. source-level reproduction is high-confidence: current main constructs the Responses payload with input before assigning tools, and the OpenAI SDK serializes that object with JSON.stringify.

Review metrics: 2 noteworthy metrics.

  • Shared Responses adapters: 3 adapters affected. The transport path feeds public OpenAI Responses, Azure OpenAI Responses, and ChatGPT/Codex Responses request shaping, so stale tool handling has provider-wide blast radius.
  • Release-owned changelog entries: 1 added. The repository reserves CHANGELOG.md for release generation, so this entry must be removed before merge.

Merge readiness
Overall: 🦐 gold shrimp
Proof: 🦞 diamond lobster
Patch quality: 🦐 gold shrimp
Result: needs maintainer review before merge.

Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch.

Rank-up moves:

  • Rebase and preserve the current OpenAI tool projection/quarantine path while moving projected tools before input.
  • Remove the CHANGELOG.md edit.
  • Run git diff --check and node scripts/run-vitest.mjs src/agents/openai-transport-stream.test.ts --runInBand.

Risk before merge

  • [P1] Merging the stale branch as-is could drop current-main OpenAI tool projection/quarantine and tool_choice reconciliation, regressing unreadable-schema and forced-tool handling for existing Responses users.
  • [P1] The request-order fix intentionally changes serialized request bytes, so maintainers should accept the expected one-time cache-prefix churn in exchange for stable subsequent turns.

Maintainer options:

  1. Repair on current main (recommended)
    Replay the field-order fix on the current projected-tool path, keep reconcileOpenAIResponsesToolChoice, remove the changelog edit, and rerun the focused OpenAI transport proof.
  2. Keep waiting for contributor refresh
    Leave the PR open for the author to rebase and adapt the patch to fix(openai): quarantine unreadable tool schemas #92921 before another maintainer review.
  3. Close only if a replacement lands
    If maintainers land an equivalent current-main fix elsewhere, close this PR as superseded with a pointer to the merged replacement.
Copy recommended automerge instruction
@clawsweeper automerge

Special instructions:
Move current main's projected OpenAI Responses `converted.tools` assignment into the initial params object before `input` while preserving unreadable-schema quarantine, empty-tool behavior, and `reconcileOpenAIResponsesToolChoice`; remove the `CHANGELOG.md` edit; update or keep the key-order regression test; validate with `git diff --check` and `node scripts/run-vitest.mjs src/agents/openai-transport-stream.test.ts --runInBand`.

Next step before merge

  • [P2] A narrow automated repair can preserve the contributor's intended request-order fix while adapting it to current-main projection behavior and removing release-owned changelog churn.

Security
Cleared: The diff changes OpenAI request object ordering, a focused test, and a changelog entry without adding dependencies, scripts, permissions, secret handling, or code-execution surfaces.

Review findings

  • [P1] Preserve projected tool handling when moving tools — src/agents/openai-transport-stream.ts:1356-1357
  • [P3] Drop the release-owned changelog entry — CHANGELOG.md:166
Review details

Best possible solution:

Refresh the PR on current main by moving the surviving projected tools field before input, preserving quarantine and tool_choice reconciliation, and removing the changelog edit.

Do we have a high-confidence way to reproduce the issue?

Yes, source-level reproduction is high-confidence: current main constructs the Responses payload with input before assigning tools, and the OpenAI SDK serializes that object with JSON.stringify.

Is this the best way to solve the issue?

No as submitted: the fix belongs in this transport path, but the branch must be rebased onto the current projected-tool implementation instead of replacing it.

Full review comments:

  • [P1] Preserve projected tool handling when moving tools — src/agents/openai-transport-stream.ts:1356-1357
    This hoist predates the current projection path: current main snapshots/quarantines unreadable tools and reconciles tool_choice against surviving function tools before assigning params.tools. Replacing that with a direct early convertResponsesTools result would reintroduce invalid payloads or stale forced-tool choices for existing Responses users; move the current projected converted.tools field before input instead of dropping the projection/reconciliation path.
    Confidence: 0.9
  • [P3] Drop the release-owned changelog entry — CHANGELOG.md:166
    Root policy reserves CHANGELOG.md for release generation and says normal PRs should not edit it. Keep this release-note context in the PR body or commit metadata and remove the changelog hunk before merge.
    Confidence: 0.86

Overall correctness: patch is incorrect
Overall confidence: 0.9

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against 010b61746379.

Label changes

Label justifications:

  • P1: The PR targets a real prompt-cache regression in agent/provider requests, but its stale implementation can break current Responses tool handling if merged unchanged.
  • merge-risk: 🚨 compatibility: The stale diff can remove current-main compatibility behavior for unreadable OpenAI tool schemas and reconciled forced tool choices.
  • rating: 🦐 gold shrimp: Overall readiness is 🦐 gold shrimp; proof is 🦞 diamond lobster and patch quality is 🦐 gold shrimp.
  • status: ⏳ waiting on author: ClawSweeper has contributor-facing work open and is waiting for author action. Sufficient (logs): The PR body supplies after-fix production gateway evidence showing reordered OpenAI Responses calls, status-200 results, and a large cache-prefix improvement on repeated real turns.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body supplies after-fix production gateway evidence showing reordered OpenAI Responses calls, status-200 results, and a large cache-prefix improvement on repeated real turns.
Evidence reviewed

PR surface:

Source +10, Tests +47, Docs +1. Total +58 across 3 files.

View PR surface stats
Area Files Added Removed Net
Source 1 19 9 +10
Tests 1 47 0 +47
Docs 1 1 0 +1
Config 0 0 0 0
Generated 0 0 0 0
Other 0 0 0 0
Total 3 67 9 +58

Acceptance criteria:

  • [P1] git diff --check.
  • [P1] node scripts/run-vitest.mjs src/agents/openai-transport-stream.test.ts --runInBand.

What I checked:

  • Current main still emits input before tools: params is created with input before tools; tools are assigned later from converted Responses tools, so JSON serialization keeps input ahead of tools on the current OpenAI Responses transport path. (src/agents/openai-transport-stream.ts:2353, 010b61746379)
  • Current main has projected-tool quarantine and reconciliation that the PR must preserve: The current transport converts tools through a projection result and reconciles tool_choice against the surviving Responses tools before assigning payload fields. (src/agents/openai-transport-stream.ts:2383, 010b61746379)
  • Projection helper preserves healthy tools and quarantines unreadable descriptors: projectOpenAITools snapshots readable tool descriptors and records unreadable function tools instead of letting bad schemas poison the whole OpenAI payload. (src/agents/openai-tool-projection.ts:54, 010b61746379)
  • PR branch predates projected-tool handling: The proposed hoist converts context.tools directly and places tools before input, but it does not include the current projection/quarantine result or the current reconcileOpenAIResponsesToolChoice path. (src/agents/openai-transport-stream.ts:1346, efc9a95b2b76)
  • Changelog policy applies: Root AGENTS.md says CHANGELOG.md is release-only and should not be edited for normal PRs. (AGENTS.md:304, 010b61746379)
  • OpenAI SDK preserves object insertion order on the wire: openai-node v6.39.1 posts body through its request builder and the JSON fallback encoder uses JSON.stringify(body), so JavaScript field insertion order determines request bytes.

Likely related people:

What the crustacean ranks mean
  • 🦀 challenger crab: rare, exceptional readiness with strong proof, clean implementation, and convincing validation.
  • 🦞 diamond lobster: very strong readiness with only minor maintainer review expected.
  • 🐚 platinum hermit: good normal PR, likely mergeable with ordinary maintainer review.
  • 🦐 gold shrimp: useful signal, but proof or patch confidence is still limited.
  • 🦪 silver shellfish: thin signal; proof, validation, or implementation needs work.
  • 🧂 unranked krab: not merge-ready because proof is missing/unusable or there are serious correctness or safety concerns.
  • 🌊 off-meta tidepool: rating does not apply to this item.

Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

How this review workflow works
  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

@georgel

georgel commented May 7, 2026

Copy link
Copy Markdown

+1 from independent production traffic — same symptom, different model.

Running an agent (openclaw v2026.5.4) on openai-codex/gpt-5.5 via the ChatGPT subscription (Codex backend, api=openai-codex-responses, streamStrategy=session-custom). Persistent prompt-cache drops every turn, with the cache-observability tracker consistently logging "no tracked cache input change" — exactly what your diagnosis predicts (the break is in JSON property order, not in any dimension the tracker hashes: model / system-prompt-digest / tool-names-digest are all stable).

Sample of [prompt-cache] cache read dropped lines from one day of real iMessage traffic (single session, ~92K-token context, no model/tool/system-prompt changes between turns):

370688 → 27136
654848 → 51712
914944 → 88576 ← 826K-token cache loss in one turn
1094656 → 496640
619520 → 448512
675328 → 195584
198656 → 136704
206336 → 115200

Twelve such drops in a 24h window, all on the same session-custom / openai-codex-responses path. Aggregate observed cache reads were a small fraction of what should have been stable across consecutive turns.

End-to-end latency on this agent: median 21s, p90 78s. Tool-heavy turns make 9 sequential LLM round-trips; if your paired-trial result (−3.5 s/turn) holds for gpt-5.5, that compounds to ~30 s off the long tail of replies. Happy to capture before/after numbers once this lands if useful.

@ashvinnagarajan

ashvinnagarajan commented May 7, 2026

Copy link
Copy Markdown
Author

@georgel — thanks for posting that. The shape of your [prompt-cache] cache read dropped numbers (large stable-context turns dropping to a small fraction of expected reads, no model/tool/system-prompt-digest changes) is exactly what byte-order divergence past input looks like to the upstream cache: the in-process tracker hashes by logical dimensions that don't capture wire-byte position, so the cache miss is invisible to it. The 826K-token loss in particular is consistent with the entire tools block landing past the per-turn divergence point.

If it's useful, here's the exact capture script we used to produce the 15% → 99% byte-stable-prefix numbers in the PR body. It's a Node preload (NODE_OPTIONS=--require=...) that taps globalThis.fetch, optionally reorders the codex/responses body keys before sending, and writes one JSON record per call to /tmp/fetch-tap.log. Toggle reorder via env to A/B.

/tmp/fetch-tap.cjs:

// fetch tap: log codex/responses calls + (toggleable) reorder body keys so
// `tools` sits before `input`. JSON semantics unchanged; on-the-wire bytes
// move so the upstream prompt cache can match the long stable prefix.
//
// Wire it in via: NODE_OPTIONS=--require=/tmp/fetch-tap.cjs in your gateway
// systemd env, then `systemctl --user restart openclaw-gateway`.
// Toggle reorder via FETCH_TAP_REORDER=1 in the same env.
const fs = require('fs');
const LOG = '/tmp/fetch-tap.log';
const REORDER = process.env.FETCH_TAP_REORDER === '1';
const orig = global.fetch;
if (orig && !orig.__tapped) {
  global.fetch = async function tapped(input, init) {
    const url = typeof input === 'string' ? input : input?.url || '';
    const isCodex = /chatgpt\.com.*backend-api\/codex\/responses/.test(url);
    const t0 = Date.now();
    let bodyStr = null, reordered = false;
    if (isCodex && init && init.body) {
      try {
        bodyStr = typeof init.body === 'string'
          ? init.body
          : Buffer.from(init.body).toString('utf8');
      } catch (_) {}
    }
    if (REORDER && isCodex && bodyStr) {
      try {
        const parsed = JSON.parse(bodyStr);
        if (parsed && typeof parsed === 'object' && 'tools' in parsed && 'input' in parsed) {
          const order = [
            'model','store','stream','include','instructions','tools',
            'tool_choice','parallel_tool_calls','prompt_cache_key',
            'prompt_cache_retention','text','reasoning','metadata',
            'previous_response_id','context_management','input',
          ];
          const out = {};
          for (const k of order) if (k in parsed) out[k] = parsed[k];
          for (const k of Object.keys(parsed)) if (!(k in out)) out[k] = parsed[k];
          init = { ...init, body: JSON.stringify(out) };
          bodyStr = init.body;
          reordered = true;
        }
      } catch (_) {}
    }
    const resp = await orig.call(this, input, init);
    if (isCodex) {
      try {
        fs.appendFileSync(LOG, JSON.stringify({
          t: t0, url, status: resp.status,
          dur_ms: Date.now() - t0,
          body_len: bodyStr ? bodyStr.length : 0,
          reordered,
          // Comment in if you also want the body for prefix-stability
          // analysis (warning: this writes customer prompts to disk):
          // body: bodyStr ? bodyStr.slice(0, 1000000) : null,
        }) + '\n');
      } catch (_) {}
    }
    return resp;
  };
  global.fetch.__tapped = true;
}

Workflow we used:

  1. Drop the file at /tmp/fetch-tap.cjs (or any path your gateway can read).
  2. Append NODE_OPTIONS=--require=/tmp/fetch-tap.cjs and FETCH_TAP_REORDER=0 to ~/.openclaw/gateway.systemd.env (or wherever your unit reads env from), then systemctl --user restart openclaw-gateway.
  3. Send N test inbounds (we used 5). Each codex call appends one line to /tmp/fetch-tap.log.
  4. Flip FETCH_TAP_REORDER=1, restart the gateway, send the same N inbounds in the same order. (Same content controls for reply length, the dominant noise source. The session-history-grew-by-N-turns bias goes against the reorder side, so any win is conservative.)
  5. Compare. If you uncomment the body: field, this short Python computes the stable-prefix percentage between consecutive captures:
import json
rs = [json.loads(l) for l in open('/tmp/fetch-tap.log') if 'codex/responses' in l]
b0, b1 = rs[-2]['body'], rs[-1]['body']
n = min(len(b0), len(b1))
i = 0
while i < n and b0[i] == b1[i]: i += 1
j = 0
while j < n and b0[len(b0)-1-j] == b1[len(b1)-1-j]: j += 1
print(f'common prefix: {i*100//n}%  common suffix: {j*100//n}%  body: {len(b0)}b')

Heads-up: with body: enabled the log captures customer prompts in plaintext — fine for short experiments on a controlled box, not something you want on every customer's instance long-term.

Your tool-heavy 9-round-trip turns are exactly the case where this compounds; 5/5 paired wins on our (single-LLM-roundtrip) SMS turns at -3.5s mean — if it scales linearly with round-trips, you're looking at ~30s off the long tail like you said. Would love to see your numbers if you do capture them.

Re-review progress:

@openclaw-barnacle openclaw-barnacle Bot added proof: supplied External PR includes structured after-fix real behavior proof. and removed triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup. labels May 7, 2026
@clawsweeper clawsweeper Bot added the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 7, 2026
@ashvinnagarajan
ashvinnagarajan force-pushed the fix/codex-responses-tools-before-input branch from 4a1fd5e to 864dc61 Compare May 10, 2026 14:30
@openclaw-barnacle openclaw-barnacle Bot removed the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 10, 2026
@clawsweeper clawsweeper Bot added the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 10, 2026
@ashvinnagarajan

ashvinnagarajan commented May 10, 2026

Copy link
Copy Markdown
Author

Rebased and resolved conflicts. cc @steipete @vincentkoc — would appreciate a review when you have a moment.

`buildOpenAIResponsesParams` was inserting `params.tools = ...` AFTER
the literal that already contained `input: messages`. JS object
property insertion order determines JSON key order, which determines
the byte stream the upstream prompt cache matches against. With
`input` enumerated first, the (large, per-session-stable) tools
schema landed past the per-turn divergence point and was excluded
from the cached prefix every turn.

Hoist tool conversion above the literal and place `tools` between
`stream` and `instructions`; move `input` to the end of the literal
so per-turn mutations sit downstream of the cache-stable prefix.
Semantics unchanged: the same fields with the same values, only
their on-the-wire byte order changes.

Adds a regression test that asserts `Object.keys(params)` enumerates
`tools` before `input` (and double-checks via `JSON.stringify`).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@ashvinnagarajan
ashvinnagarajan force-pushed the fix/codex-responses-tools-before-input branch from 864dc61 to efc9a95 Compare May 13, 2026 13:45
@openclaw-barnacle openclaw-barnacle Bot removed the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 13, 2026
@clawsweeper clawsweeper Bot added the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 14, 2026
@barnacle-openclaw

Copy link
Copy Markdown

This pull request has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.

@barnacle-openclaw barnacle-openclaw Bot added the stale Marked as stale due to inactivity label May 31, 2026
@clawsweeper clawsweeper Bot added rating: 🌊 off-meta tidepool PR readiness rating does not apply to this item. and removed proof: sufficient ClawSweeper judged the real behavior proof convincing. labels May 31, 2026
@openclaw-barnacle openclaw-barnacle Bot removed the stale Marked as stale due to inactivity label Jun 1, 2026
@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. P1 High-priority user-facing bug, regression, or broken workflow. merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. and removed rating: 🌊 off-meta tidepool PR readiness rating does not apply to this item. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. labels Jun 14, 2026
@clawsweeper clawsweeper Bot added status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. and removed status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. labels Jun 20, 2026
@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. labels Jun 30, 2026
@clawsweeper clawsweeper Bot added rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. and removed rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. labels Jul 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. P1 High-priority user-facing bug, regression, or broken workflow. proof: sufficient ClawSweeper judged the real behavior proof convincing. proof: supplied External PR includes structured after-fix real behavior proof. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. size: S status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants