Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 3 additions & 1 deletion scripts/test-layout/layout-responses.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@
"passthrough-media-type.test.ts": "responses",
"passthrough-override.test.ts": "responses",
"passthrough-headers.test.ts": "responses",
"passthrough-abort.test.ts": "responses"
"passthrough-abort.test.ts": "responses",
"current-turn-tool-catalog-direct.test.ts": "responses",
"current-turn-tool-catalog-namespace.test.ts": "responses"
}
28 changes: 25 additions & 3 deletions src/lib/empty-tool-catalog.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,41 @@
import { normalizeDeclaredToolName, type AdapterEvent } from "../types";
import { namespacedToolName, normalizeDeclaredToolName, type AdapterEvent } from "../types";
import { UNDECLARED_TOOL_CALL_ERROR_CODE } from "./errors";
import { releaseTranslatedEvent, type TranslatorBudget } from "./translator-budget";
import { releaseCompactionCiphertextLease } from "../responses/compaction";

/** Snapshot of current-turn declarations when an explicit empty catalog activates enforcement. */
/** Current-turn names for normalization, with wire-specific membership enforcement. */
export interface ClientToolCatalogRestriction {
declaredToolNames: ReadonlySet<string>;
bareCustomToolNames: ReadonlySet<string>;
canonicalToolNames?: ReadonlySet<string>;
enforceDeclaredToolNames?: boolean;
}

/** Current declarations may narrow the selection-filtered bridge names, never widen them. */
export function currentTurnToolBridgeMaps<T extends Pick<ClientToolCatalogRestriction, "declaredToolNames" | "bareCustomToolNames"> & {
toolNsMap: ReadonlyMap<string, { namespace: string; name: string }>;
}>(
maps: T,
catalog: ClientToolCatalogRestriction | undefined,
): T {
if (!catalog) return maps;
const currentIdentity = (name: string): boolean => {
const mapped = maps.toolNsMap.get(name);
// An ambiguous historical bare alias may lack a mapping. Keep only current identities.
return catalog.canonicalToolNames?.has(mapped ? namespacedToolName(mapped.namespace, mapped.name) : name) ?? true;
};
return {
...maps,
declaredToolNames: new Set([...catalog.declaredToolNames].filter(name => maps.declaredToolNames.has(name) && currentIdentity(name))),
bareCustomToolNames: new Set([...catalog.bareCustomToolNames].filter(name => maps.bareCustomToolNames.has(name) && currentIdentity(name))),
};
}

export function emptyToolCatalogRefusal(
event: AdapterEvent,
catalog: ClientToolCatalogRestriction | undefined,
): Extract<AdapterEvent, { type: "error" }> | undefined {
if (!catalog) return;
if (!catalog || catalog.enforceDeclaredToolNames === false) return;
if (event.type === "tool_call_start") {
const name = normalizeDeclaredToolName(event.name, catalog.declaredToolNames, undefined, catalog.bareCustomToolNames);
if (catalog.declaredToolNames.has(name)) return;
Expand Down
11 changes: 7 additions & 4 deletions src/server/responses-undeclared-tool-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ function addWireToolName(
tool: unknown,
namespace?: string,
ambiguousDottedAliases?: ReadonlySet<string>,
canonicalOnly = false,
): void {
if (!isPlainObject(tool)) return;
const name = wireToolInnerName(tool);
Expand All @@ -103,6 +104,7 @@ function addWireToolName(
return;
}
names.add(namespacedToolName(namespace, name));
if (canonicalOnly) return;
// Some routed providers echo the flattened wire name with a dot (`ns.name`, observed with
// muse-spark via opencode-go) instead of `ns__name`. It is the same tool identity, so register
// the dotted spelling too, mirroring `toolChoiceAliases` (#3402) -- but only while that
Expand Down Expand Up @@ -150,16 +152,17 @@ function addWireToolSpecs(
names: Set<string>,
specs: unknown,
ambiguousDottedAliases?: ReadonlySet<string>,
canonicalOnly = false,
): void {
if (!Array.isArray(specs)) return;
for (const spec of specs) {
if (!isPlainObject(spec)) continue;
if (spec.type === "namespace" && Array.isArray(spec.tools)) {
const namespace = typeof spec.name === "string" ? spec.name : undefined;
for (const inner of spec.tools) addWireToolName(names, inner, namespace, ambiguousDottedAliases);
for (const inner of spec.tools) addWireToolName(names, inner, namespace, ambiguousDottedAliases, canonicalOnly);
continue;
}
addWireToolName(names, spec, undefined, ambiguousDottedAliases);
addWireToolName(names, spec, undefined, ambiguousDottedAliases, canonicalOnly);
}
}

Expand All @@ -171,7 +174,7 @@ function addWireToolSpecs(
* inside `input`, routed custom-tool rewrites) that the internal list flattens or renames, and
* only the wire names can be compared against what the provider echoes back.
*/
export function collectDeclaredWireToolNames(body: unknown): Set<string> {
export function collectDeclaredWireToolNames(body: unknown, options?: { canonicalOnly?: boolean }): Set<string> {
const names = new Set<string>();
if (!isPlainObject(body)) return names;
const specGroups: unknown[] = [body.tools];
Expand All @@ -184,7 +187,7 @@ export function collectDeclaredWireToolNames(body: unknown): Set<string> {
}
}
const ambiguousDottedAliases = collectAmbiguousDottedAliases(specGroups);
for (const specs of specGroups) addWireToolSpecs(names, specs, ambiguousDottedAliases);
for (const specs of specGroups) addWireToolSpecs(names, specs, ambiguousDottedAliases, options?.canonicalOnly);
return names;
}

Expand Down
5 changes: 2 additions & 3 deletions src/server/responses/adapter-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { readDisplaySafeErrorText, normalizeUpstreamErrorText } from "./core-err
import { sanitizeNonReplayableUpstreamError } from "./non-replayable-error";
import { isNonReplayableResponse } from "../../lib/upstream-retry";
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
import { guardEmptyToolCatalogEvents, guardEmptyToolCatalogStream } from "../../lib/empty-tool-catalog";
import { currentTurnToolBridgeMaps, guardEmptyToolCatalogEvents, guardEmptyToolCatalogStream } from "../../lib/empty-tool-catalog";
import { guardCompactionSummary } from "../../responses/compaction-summary";

/** One responsibility of the Responses request pipeline; state owners are explicit. */
Expand Down Expand Up @@ -123,8 +123,7 @@ export async function deliverAdapterResponse(

const clientToolCatalog = requestState.clientToolCatalogRestriction && route.provider.authMode !== "forward"
? requestState.clientToolCatalogRestriction : undefined;
const deliveryToolBridgeMaps = clientToolCatalog
? { ...toolBridgeMaps, ...clientToolCatalog } : toolBridgeMaps;
const deliveryToolBridgeMaps = currentTurnToolBridgeMaps(toolBridgeMaps, clientToolCatalog);


if (parsed.stream) {
Expand Down
23 changes: 13 additions & 10 deletions src/server/responses/request-prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,17 +482,20 @@ export async function prepareResponsesRequest(
if (options.directRequest) parsed = options.directRequest.decode(parsed);
parsed._nativeReasoningRetention = resolveNativeReasoningRetention(config);
// Capture before route rewrites or hosted sidecars can add tools; replayed catalogs are history.
// The direct settings body omits tools, including current legacy Chat functions. Recover
// their wire names only when this restriction needs a non-empty decoded catalog.
const catalogBody = options.directRequest && options.clientDeclaredEmptyToolCatalog === true && parsed.context.tools?.length
? options.directRequest.bridgeConversation() : parsed._rawBody;
// Direct decoders already own current function names (including legacy Chat functions).
// Project only those names; bridging the conversation would retain its reasoning twice.
const catalogBody = options.directRequest && parsed.context.tools?.length
? { tools: parsed.context.tools.map(tool => ({ type: "function", name: tool.name })) } : parsed._rawBody;
const currentCatalog = currentTurnWireToolCatalogBody(catalogBody, parsed._replayPrefixLen);
if (options.clientDeclaredEmptyToolCatalog === true || hasExplicitEmptyToolCatalog(currentCatalog)) {
clientToolCatalogRestriction = {
declaredToolNames: collectDeclaredWireToolNames(currentCatalog),
bareCustomToolNames: collectDeclaredBareCustomWireToolNames(currentCatalog),
};
}
clientToolCatalogRestriction = {
declaredToolNames: collectDeclaredWireToolNames(currentCatalog),
bareCustomToolNames: collectDeclaredBareCustomWireToolNames(currentCatalog),
canonicalToolNames: collectDeclaredWireToolNames(currentCatalog, { canonicalOnly: true }),
// Ordinary Responses validation stays with its delivery owner; replay cannot add authority.
enforceDeclaredToolNames: (inboundWire === "responses" && (parsed._replayPrefixLen ?? 0) > 0)
|| options.clientDeclaredEmptyToolCatalog === true
|| hasExplicitEmptyToolCatalog(currentCatalog),
};
if (options.inboundWire === "anthropic") {
parsed._nativeReasoningReplay = options.nativeReasoningReplay;
parsed._nativeReasoningMint = options.nativeReasoningMint;
Expand Down
5 changes: 2 additions & 3 deletions src/server/responses/run-turn-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ import { rememberResponseState } from "../../responses/state";
import { trackStreamLifetime } from "../lifecycle";
import { awaitThoughtSignatureDurability } from "../../responses/thought-signature-replay";
import { undeclaredToolCallMessage } from "../responses-undeclared-tool-guard";
import { emptyToolCatalogRefusal, guardEmptyToolCatalogEvents, guardEmptyToolCatalogStream } from "../../lib/empty-tool-catalog";
import { currentTurnToolBridgeMaps, emptyToolCatalogRefusal, guardEmptyToolCatalogEvents, guardEmptyToolCatalogStream } from "../../lib/empty-tool-catalog";
// LOCAL PATCH (runturn-websearch)
import { planWebSearch } from "../../web-search";
import { runTurnWebSearchInitialParsed, runTurnWebSearchLoop } from "../../web-search/run-turn-loop";
Expand Down Expand Up @@ -755,8 +755,7 @@ export async function executeResponsesRunTurn(

const clientToolCatalog = requestState.clientToolCatalogRestriction && route.provider.authMode !== "forward"
? requestState.clientToolCatalogRestriction : undefined;
const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, bareCustomToolNames, toolSearchToolNames } = clientToolCatalog
? { ...toolBridgeMaps, ...clientToolCatalog } : toolBridgeMaps;
const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, bareCustomToolNames, toolSearchToolNames } = currentTurnToolBridgeMaps(toolBridgeMaps, clientToolCatalog);
const enforceDeclaredToolNames = inboundWire !== "chat" && inboundWire !== "anthropic";
const classifyUndeclaredFirstTool = (
event: AdapterEvent,
Expand Down
16 changes: 16 additions & 0 deletions structure/transports/response-turn-membership.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,22 @@ so clearing a caller-owned Buffer does not mutate the signing key. No key is gen
The absent/malformed-key default changes no input. The pass uses bounded linear
indexing with at most 65,536 input items and retains existing item references.

Independently of structural normalization, request preparation snapshots current-turn
tool names for output normalization. Responses enforces membership in that catalog;
Chat and Messages enforce it when the caller explicitly empties a catalog, while
omitted and partial catalogs retain deferred delivery. Declarations prepended while
expanding `previous_response_id` remain history and cannot grant current-turn membership.
Delivery intersects current names with the existing selection-filtered bridge names, so
`tool_choice` cannot be widened by the snapshot. Ordinary Responses validation retains its
delivery owner and diagnostics; the additional guard enforces explicit-empty catalogs and
Responses replay boundaries. Initial hosted-sidecar deferred delivery retains its existing contract.
`tests/responses/explicit-empty-tool-catalog-history.test.ts` covers these continuation boundaries
and normalization across adapter/run-turn JSON/SSE. The real direct Messages planner and delivery
share one signed-history translation budget in `tests/responses/current-turn-tool-catalog-direct.test.ts`.
Namespace restoration must also name a current canonical identity; an ambiguous historical bare
alias is refused while the current canonical spelling stays callable. The two identity directions
are covered by `tests/responses/current-turn-tool-catalog-namespace.test.ts` on both delivery lanes.

`tests/responses/turn-membership.test.ts` covers token and scope controls;
`tests/responses/turn-membership-ingress.test.ts` proves actual ingress-to-supplier
request equality against the buffered reference and refusal of keyless ownership.
Expand Down
6 changes: 3 additions & 3 deletions structure/transports/responses-wire-shapes.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,9 +316,9 @@ input deltas are tracked separately from client-executed `tool_use` blocks.

Ingress `clientDeclaredEmptyToolCatalog` (`src/server/chat-completions.ts`, `src/server/claude-messages.ts`)
survives omission during translation. Preparation snapshots declarations after direct decoding, before hosted-tool rewrites.
Direct Chat with `tools: []` and legacy functions reads their names from the bridge projection;
empty and omitted catalogs need no extra projection. Explicit `tools: []` or empty `additional_tools`
activates the restriction; replayed declarations neither activate it nor grant names. Delivery
Direct decoders expose current function names, including legacy Chat functions, without translating
the conversation again or duplicating retained reasoning-budget charges. Explicit `tools: []` or empty
`additional_tools` activates the restriction; replayed declarations neither activate it nor grant names. Delivery
normalization uses those names; direct custom `exec` helpers use the bridge compiler. Refusals keep `undeclared_tool_call` in buffered envelopes and endpoint
projections; Messages sees `api_error`, never `overloaded_error`. Caller-forward routes retain their
exemption; `default.` normalization remains separate.
Expand Down
2 changes: 2 additions & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@
"messages-native-empty-inline.test.ts": "claude-integration",
"explicit-empty-tool-catalog.test.ts": "responses",
"explicit-empty-tool-catalog-history.test.ts": "responses",
"current-turn-tool-catalog-direct.test.ts": "responses",
"current-turn-tool-catalog-namespace.test.ts": "responses",
"gentle-shell-path.test.ts": "clients", "gentle-shell-client.test.ts": "clients", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config",
"codex-history-pending-wal.test.ts": "codex-integration",
"compaction-history-framing.test.ts": "responses",
Expand Down
Loading
Loading