Skip to content

Commit efc9a95

Browse files
Ashvin Nagarajanclaude
authored andcommitted
fix(cache): emit tools before input in OpenAI Responses request body
`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]>
1 parent 96c0309 commit efc9a95

3 files changed

Lines changed: 67 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ Docs: https://docs.openclaw.ai
163163
- Codex app-server: mirror native Codex subagent spawn lifecycle events into Task Registry so app-server child agents appear in task/status surfaces without relying on transcript text. (#79512) Thanks @mbelinky.
164164
- Gateway: expose optional `isHeartbeat` metadata on agent event payloads so clients can distinguish scheduled heartbeat runs from ordinary chat runs. (#80610) Thanks @medns.
165165
- Agents: add `agents.defaults.runRetries` and `agents.list[].runRetries` config for embedded Pi runner retry loop limits. (#80661) Thanks @medns.
166+
- Agents/openai-responses: emit `tools` before `input` in the request body for `openai-responses`, `azure-openai-responses`, and `openai-codex-responses` calls so the (large, per-session-stable) tools schema lives inside the cacheable byte prefix instead of trailing the per-turn-mutating `input` array. On a real Codex Responses session this raised the byte-stable prefix between consecutive turns from 15% (~12k tokens) to 99% (~73k tokens) and dropped per-turn end-to-end latency by ~3.5s on 5/5 paired trials.
166167

167168
### Fixes
168169

src/agents/openai-transport-stream.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5294,4 +5294,51 @@ describe("openai transport stream", () => {
52945294
__testing.processOpenAICompletionsStream(mockStream(), output, model, stream),
52955295
).rejects.toThrow("Exceeded tool-call argument buffer limit");
52965296
});
5297+
5298+
it("emits `tools` before `input` so the tools schema lives in the cacheable prefix", () => {
5299+
// Object property insertion order determines JSON key order on the wire,
5300+
// which determines the byte prefix that the upstream prompt cache matches
5301+
// against. The tools block is large and stable across turns within a
5302+
// session; `input` mutates each turn. If `input` is enumerated before
5303+
// `tools`, the entire (stable) tools schema sits past the per-turn
5304+
// divergence point and is excluded from the cached prefix on every turn.
5305+
const params = buildOpenAIResponsesParams(
5306+
{
5307+
id: "codex-mini-latest",
5308+
name: "Codex Mini Latest",
5309+
api: "openai-codex-responses",
5310+
provider: "openai-codex",
5311+
baseUrl: "https://chatgpt.com/backend-api",
5312+
reasoning: true,
5313+
input: ["text"],
5314+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
5315+
contextWindow: 200000,
5316+
maxTokens: 8192,
5317+
} satisfies Model<"openai-codex-responses">,
5318+
{
5319+
systemPrompt: "system",
5320+
messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
5321+
tools: [
5322+
{
5323+
name: "demo",
5324+
description: "demo",
5325+
parameters: { type: "object", properties: {}, additionalProperties: false },
5326+
},
5327+
],
5328+
} as never,
5329+
undefined,
5330+
) as Record<string, unknown>;
5331+
5332+
const keys = Object.keys(params);
5333+
const toolsIndex = keys.indexOf("tools");
5334+
const inputIndex = keys.indexOf("input");
5335+
expect(toolsIndex).toBeGreaterThanOrEqual(0);
5336+
expect(inputIndex).toBeGreaterThanOrEqual(0);
5337+
expect(toolsIndex).toBeLessThan(inputIndex);
5338+
5339+
// Belt-and-suspenders: confirm JSON.stringify (which uses insertion order)
5340+
// also serializes `"tools"` before `"input"`.
5341+
const serialized = JSON.stringify(params);
5342+
expect(serialized.indexOf('"tools"')).toBeLessThan(serialized.indexOf('"input"'));
5343+
});
52975344
});

src/agents/openai-transport-stream.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1336,14 +1336,31 @@ export function buildOpenAIResponsesParams(
13361336
const payloadPolicy = resolveOpenAIResponsesPayloadPolicy(model, {
13371337
storeMode: "disable",
13381338
});
1339+
// Convert tools up-front so they can be inserted into the params literal
1340+
// before `input`. Object property insertion order determines JSON key order
1341+
// on the wire, which determines the byte prefix the upstream prompt cache
1342+
// matches against. The tools block is large (~tens of KB once converted)
1343+
// and stable across turns within a session, while `input` mutates each
1344+
// turn — keeping tools in front of input puts the tools schema inside the
1345+
// cacheable prefix instead of trailing per-turn-divergent input.
1346+
const tools = context.tools
1347+
? convertResponsesTools(context.tools, model as OpenAIModeModel, {
1348+
strict: resolveOpenAIStrictToolSetting(model as OpenAIModeModel, {
1349+
transport: "stream",
1350+
}),
1351+
})
1352+
: undefined;
13391353
const params: OpenAIResponsesRequestParams = {
13401354
model: model.id,
1341-
input: messages,
13421355
stream: true,
1356+
...(tools !== undefined ? { tools } : {}),
1357+
...(isCodexResponses ? { instructions: buildOpenAICodexResponsesInstructions(context) } : {}),
13431358
prompt_cache_key: cacheRetention === "none" ? undefined : options?.sessionId,
13441359
prompt_cache_retention: getPromptCacheRetention(model.baseUrl, cacheRetention),
1345-
...(isCodexResponses ? { instructions: buildOpenAICodexResponsesInstructions(context) } : {}),
13461360
...(metadata ? { metadata } : {}),
1361+
// `input` is placed last so per-turn mutations (the new user/assistant
1362+
// turn appended each call) sit downstream of the cache-stable prefix.
1363+
input: messages,
13471364
};
13481365
const effectiveMaxTokens = options?.maxTokens || model.maxTokens;
13491366
if (effectiveMaxTokens) {
@@ -1358,13 +1375,6 @@ export function buildOpenAIResponsesParams(
13581375
if (options?.serviceTier !== undefined && payloadPolicy.allowsServiceTier) {
13591376
params.service_tier = options.serviceTier;
13601377
}
1361-
if (context.tools) {
1362-
params.tools = convertResponsesTools(context.tools, model as OpenAIModeModel, {
1363-
strict: resolveOpenAIStrictToolSetting(model as OpenAIModeModel, {
1364-
transport: "stream",
1365-
}),
1366-
});
1367-
}
13681378
if (model.reasoning) {
13691379
if (options?.reasoningEffort || options?.reasoning || options?.reasoningSummary) {
13701380
const requestedReasoningEffort = resolveOpenAIReasoningEffort(options);

0 commit comments

Comments
 (0)