Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/reference/proxy-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ should select among several targets.

Credential-bearing model, image, video, and search requests do not automatically follow HTTP redirects, including same-origin redirects. Configure the final upstream API URL instead of a redirecting alias. A redirect does not cause the server to resend credentials or the request body to its destination. The response owner retains its existing error or relay behavior; native Responses and compact routes can return the original 3xx and `Location` to the client. Client redirect behavior is separate from this server transport policy.

## Empty search answers

After hosted search, a clean but empty forced-answer pass receives one additional answer
attempt with tools removed and existing results retained. This can incur another model
request. A second empty answer fails; malformed calls and provider refusal or truncation
outcomes are preserved without this retry.

## Endpoint overview

| Client surface | Endpoint | Successful non-stream result | Successful stream or socket result |
Expand Down
58 changes: 55 additions & 3 deletions src/web-search/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig, Ocx
import { namespacedToolName, toolChoiceToolPredicate } from "../types";
import { cloneProviderOpaqueToolCallMetadata } from "../responses/provider-opaque-metadata";
import type { AttemptRecoveryKind } from "../usage/log";
import { isTruncatedStopReason } from "../responses/truncated-stop-reason";
import { bridgeToResponsesSSE } from "../bridge";
import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor";
import { runAnthropicWebSearch } from "./anthropic-executor";
Expand Down Expand Up @@ -230,6 +231,24 @@ function forcedAnswerNudge(): OcxMessage {
};
}

/**
* Transient developer-role nudge for the ONE recovery pass after a forced answer came back empty.
* The recovery also removes every tool, so the model has nothing to call and can only return text;
* this turn says so explicitly rather than relying on the removal alone. Like {@link forcedAnswerNudge}
* it is iteration-local and never touches the persisted `messages`.
*/
function forcedAnswerRetryNudge(): OcxMessage {
return {
role: "developer",
content:
"Your previous response contained no usable answer. Web search has finished for this turn and " +
"no tools are available for this response. Answer the user's question now in assistant text, " +
"using the web search results already gathered above. If those results are insufficient, say " +
"what is missing instead of returning an empty response.",
timestamp: Date.now(),
};
}

function jsonError(status: number, message: string): Response {
return new Response(JSON.stringify({ error: { message, type: "upstream_error", code: null } }), {
status,
Expand Down Expand Up @@ -370,7 +389,9 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
const signal = internalAbort.signal;

// Hard iteration bound (termination safety net); forceAnswer normally ends the loop sooner.
const HARD_CAP = maxSearches + 2;
// One iteration beyond the forced answer is reserved for its empty-answer recovery below.
const HARD_CAP = maxSearches + 3;
let emptyAnswerRetries = 0;
const connectTimeoutMs = deps.connectTimeoutMs ?? 200_000;
const routedModelStallTimeoutMs = deps.routedModelStallTimeoutMs ?? 200_000;

Expand Down Expand Up @@ -407,12 +428,19 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
// ignores what the search found, which reads to the user as "the search did nothing". Nudge it
// (iteration-locally — never mutate the shared `messages`) to actually use the gathered results.
// Only when a REAL search ran (executedSearchCount, not empty-query/limit/repeat placeholders).
const iterMessages: OcxMessage[] = forceAnswer && executedSearchCount > 0
let iterMessages: OcxMessage[] = forceAnswer && executedSearchCount > 0
? [...messages, forcedAnswerNudge()]
: messages;
// #1001 follow-up: the recovery pass for an empty forced answer. Removing every tool leaves the
// model nothing to call, and the extra developer turn asks it for the text it just failed to
// produce. `toolChoice: "none"` is what drops those definitions in the adapter, so the retry
// cannot repeat the same empty or tool-shaped response.
const recoveringEmptyAnswer = forceAnswer && emptyAnswerRetries > 0;
if (recoveringEmptyAnswer) iterMessages = [...iterMessages, forcedAnswerRetryNudge()];
const iterParsed: OcxParsedRequest = {
...parsed, stream: true,
context: { ...parsed.context, messages: iterMessages, tools: forceAnswer ? toolsNoWebSearch : allTools },
...(recoveringEmptyAnswer ? { options: { ...parsed.options, toolChoice: "none" as const } } : {}),
context: { ...parsed.context, messages: iterMessages, tools: recoveringEmptyAnswer ? [] : forceAnswer ? toolsNoWebSearch : allTools },
};
// One cumulative header deadline spans every pool-key 429 rotation in this model iteration.
// clear() stops only its timer after final headers; the direct turn signal remains attached to
Expand Down Expand Up @@ -847,9 +875,33 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
// An unterminated call flushes AFTER the terminal event, so find
// the terminal rather than assuming it is last (#1001).
const terminalEvent = split.passthrough.find(event => event.type === "done");
if (terminalEvent?.type === "done" && isTruncatedStopReason(terminalEvent.stopReason)) {
Comment thread
lidge-jun marked this conversation as resolved.
Resolved
// A provider refusal or truncation is authoritative, even without text.
// Preserve it once; neither an empty-answer retry nor a generic 502 applies.
yield* replay(split.passthrough.slice(split.streamedPassthroughCount));
return;
}
if (terminalEvent?.type === "done"
&& (split.hasMalformedToolCall
|| (!split.hasRealToolCall && !hasVisibleAssistantText(split.passthrough)))) {
// #1001 fixed the silent success by failing here. A malformed call still fails: it
// reports a protocol problem, and replaying it would only re-ask an unwell upstream.
// Silence is different — it is recoverable, so retry exactly once with the results
// already gathered before failing the turn.
console.warn("[web-search-loop] unusable forced answer", JSON.stringify({
model: parsed.modelId,
recoveryAttempt: emptyAnswerRetries,
searchCalls: split.calls.length,
malformed: split.hasMalformedToolCall,
stopReason: terminalEvent.stopReason,
eventTypes: [...new Set(split.passthrough.map(event => event.type))],
}));
if (!split.hasMalformedToolCall && !split.hasRealToolCall && emptyAnswerRetries === 0) {
emptyAnswerRetries++;
console.warn("[web-search-loop] empty forced answer — retrying once without tools");
yield { type: "heartbeat" };
continue;
}
throw new LoopError(502, "forced-answer pass produced no usable assistant output");
}
}
Expand Down
4 changes: 4 additions & 0 deletions structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,7 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol
Chat helper admission in `src/server/responses/core.ts` follows the
[deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper
claims stored main, after terminal vision, routed vision and search exclusions.

### Empty forced search answers

`src/web-search/loop.ts` makes at most one extra answer attempt after a clean forced-answer terminal with no visible output or tool call. The recovery has no tools and reuses gathered search results. Malformed calls fail, and recognized refusal/truncation terminals pass through unchanged, including empty or partial answers. The extra generation may incur provider usage.
118 changes: 118 additions & 0 deletions tests/web-search/web-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,124 @@ describe("issue #1001 — forced-answer passes must produce usable output", () =
expect(frames.some(frame => frame.event === "response.completed")).toBe(true);
expect(frames.some(frame => frame.event === "response.failed")).toBe(false);
});

// #1001 chose to fail rather than complete silently, which turned silence into a dead turn:
// the user sees "stream disconnected before completion: forced-answer pass produced no usable
// assistant output". Silence is recoverable, so the pass is retried once with no tools before
// the same error is reported. Malformed calls still fail immediately.
describe("empty forced answer recovery", () => {
function sequenceAdapter(passes: AdapterEvent[][], seen: OcxParsedRequest[]): ProviderAdapter {
let pass = 0;
return {
name: "sequence",
buildRequest: (request) => {
seen.push(request);
return { url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" };
},
fetchResponse: async () => new Response("wire", { status: 200 }),
async *parseStream() {
for (const event of passes[Math.min(pass++, passes.length - 1)] ?? []) yield event;
},
async parseResponse() {
throw new Error("parseResponse must be unreachable");
},
};
}

async function drivePasses(passes: AdapterEvent[][], seen: OcxParsedRequest[] = [], ordinaryTool = false, liveOutput = false) {
const response = await runWithWebSearch({
parsed: parseRequest({ model: "routed/model", input: "hi", stream: true, tools: [{ type: "web_search" }, ...(ordinaryTool ? [{ type: "function", name: "fixture", parameters: { type: "object", properties: {} } }] : [])] }),
adapter: sequenceAdapter(passes, seen),
forwardProvider,
hostedTool: { type: "web_search" },
selectedForwardHeaders: new Headers({ authorization: "Bearer token" }),
settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 },
maxSearches: 1,
streamRoutedModelOutput: liveOutput,
});
return collectSse(response.body!);
}

test("an empty forced pass is retried once and completes", async () => {
const frames = await drivePasses([
webSearchFirstPass,
[{ type: "done" }],
[{ type: "text_delta", text: "recovered answer" }, { type: "done" }],
]);
expect(frames.some(frame => frame.event === "response.completed")).toBe(true);
expect(frames.some(frame => frame.event === "response.failed")).toBe(false);
});

test("the recovery pass asks for text with every tool removed", async () => {
const seen: OcxParsedRequest[] = [];
await drivePasses([
webSearchFirstPass,
[{ type: "done" }],
[{ type: "text_delta", text: "recovered answer" }, { type: "done" }],
], seen);
// The search pass plus the empty forced pass plus exactly one recovery — no extra upstream call.
expect(seen).toHaveLength(3);
const recovery = seen[2]!;
expect(recovery.options.toolChoice).toBe("none");
expect(recovery.context.tools).toEqual([]);
// The results gathered by the search reach the recovery turn as a tool result ...
expect(recovery.context.messages.filter(message => message.role === "toolResult")).toHaveLength(1);
// ... and the recovery turn carries the developer nudge that asks for the missing text.
expect(recovery.context.messages.some(message =>
message.role === "developer" && String(message.content).includes("no tools are available")))
.toBe(true);
});

test("recovery removes ordinary tools as well as web search", async () => {
const seen: OcxParsedRequest[] = [];
await drivePasses([webSearchFirstPass, [{ type: "done" }], [{ type: "text_delta", text: "answer" }, { type: "done" }]], seen, true);
expect(seen).toHaveLength(3);
expect(seen[1]!.context.tools.length).toBeGreaterThan(0);
expect(seen[2]!.context.tools).toEqual([]);
expect(seen[2]!.options.toolChoice).toBe("none");
});

for (const [stopReason, reason] of [["refusal", "content_filter"], ["content_filter", "content_filter"], ["max_tokens", "max_output_tokens"], ["length", "max_output_tokens"]]) {
for (const partial of [false, true]) {
test(`${stopReason} partial=${partial} stays authoritative without a retry`, async () => {
const seen: OcxParsedRequest[] = [];
const terminalPass: AdapterEvent[] = [
...(partial ? [{ type: "text_delta" as const, text: "partial answer" }] : []),
{ type: "done", stopReason },
];
const frames = await drivePasses([webSearchFirstPass, terminalPass, [{ type: "done" }]], seen, false, true);
expect(seen).toHaveLength(2);
expect(frames.filter(frame => ["response.incomplete", "response.completed", "response.failed"].includes(frame.event ?? "")).map(frame => frame.event)).toEqual(["response.incomplete"]);
const terminalResponse = frames.find(frame => frame.event === "response.incomplete")!.data.response as { incomplete_details: { reason: string } };
expect(terminalResponse.incomplete_details.reason).toBe(reason);
if (partial) expect(frames.filter(frame => frame.event === "response.output_text.delta").map(frame => frame.data.delta).join("")).toBe("partial answer");
});
}
}

test("a persistent empty forced pass still fails after the one recovery", async () => {
const seen: OcxParsedRequest[] = [];
const frames = await drivePasses([
webSearchFirstPass,
[{ type: "done" }],
[{ type: "done" }],
], seen);
expect(seen).toHaveLength(3);
expect(frames.some(frame => frame.event === "response.failed")).toBe(true);
expect(frames.some(frame => frame.event === "response.completed")).toBe(false);
});

test("a malformed forced call is not retried", async () => {
const seen: OcxParsedRequest[] = [];
const frames = await drivePasses([
webSearchFirstPass,
[{ type: "tool_call_start", id: "", name: "" }, { type: "tool_call_end" }, { type: "done" }],
[{ type: "text_delta", text: "recovered answer" }, { type: "done" }],
], seen);
expect(seen).toHaveLength(2);
expect(frames.some(frame => frame.event === "response.failed")).toBe(true);
});
});
});

const routedProvider: OcxProviderConfig = {
Expand Down
Loading