fix(gateway): /v1/responses tool schema should use flat Responses API format (#57166)

* gateway: fix /v1/responses tool schema to use flat Responses API format

* gateway: fix remaining stale wrapped-format tools in parity tests

* gateway: propagate strict flag through extractClientTools normalization

* fix(gateway): cover responses tool boundary

* Delete docs/internal/vincentkoc/2026-03-30-pr-57166-responses-tool-schema-followup.md

---------

Co-authored-by: Michel Belleau <mbelleau@Michels-MacBook-Pro.local>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Michel Belleau
2026-03-29 20:36:54 -04:00
committed by GitHub
parent 3034adfdb3
commit 26f34be20c
6 changed files with 66 additions and 39 deletions

View File

@@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai
- Agents/context pruning: count supplementary-plane CJK characters with the shared code-point-aware estimator so context pruning stops underestimating Japanese and Chinese text that uses Extension B ideographs. (#39985) Thanks @Edward-Qiang-2024.
- Slack/status reactions: add a reaction lifecycle for queued, thinking, tool, done, and error phases in Slack monitors, with safer cleanup so queued ack reactions stay correct across silent runs, pre-reply failures, and delayed transitions. (#56430) Thanks @hsiaoa.
- macOS/local gateway: stop OpenClaw.app from killing healthy local gateway listeners after startup by recognizing the current `openclaw-gateway` process title and using the current `openclaw gateway` launch shape.
- Gateway/OpenAI compatibility: accept flat Responses API function tool definitions on `/v1/responses` and preserve `strict` when normalizing hosted tools into the embedded runner, so spec-compliant clients like Codex no longer fail validation or silently lose strict tool enforcement. Thanks @malaiwah and @vincentkoc.
- Memory/QMD: resolve slugified `memory_search` file hints back to the indexed filesystem path before returning search hits, so `memory_get` works again for mixed-case and spaced paths. (#50313) Thanks @erra9x.
- OpenAI/Codex fast mode: map `/fast` to priority processing on native OpenAI and Codex Responses endpoints instead of rewriting reasoning settings, and document the exact endpoint and override behavior.
- Memory/QMD: weight CJK-heavy text correctly when estimating chunk sizes, preserve surrogate-pair characters during fine splits, and keep long Latin lines on the old chunk boundaries so memory indexing produces better-sized chunks for CJK notes. (#40271) Thanks @AaronLuo00.

View File

@@ -17,6 +17,8 @@ export type ClientToolDefinition = {
name: string;
description?: string;
parameters?: Record<string, unknown>;
/** Strict argument enforcement (Responses API). Propagated from the request. */
strict?: boolean;
};
};

View File

@@ -148,18 +148,18 @@ export type ItemParam = z.infer<typeof ItemParamSchema>;
// Tool Definitions
// ─────────────────────────────────────────────────────────────────────────────
// Responses API tool definition uses a flat format (not the Chat Completions
// wrapped-function format). Fields are at the top level alongside `type`.
export const FunctionToolDefinitionSchema = z
.object({
type: z.literal("function"),
function: z.object({
name: z.string().min(1, "Tool name cannot be empty"),
description: z.string().optional(),
parameters: z.record(z.string(), z.unknown()).optional(),
}),
name: z.string().min(1, "Tool name cannot be empty"),
description: z.string().optional(),
parameters: z.record(z.string(), z.unknown()).optional(),
strict: z.boolean().optional(),
})
.strict();
// OpenResponses tool definitions match internal ToolDefinition structure
export const ToolDefinitionSchema = FunctionToolDefinitionSchema;
export type ToolDefinition = z.infer<typeof ToolDefinitionSchema>;

View File

@@ -114,7 +114,8 @@ async function ensureResponseConsumed(res: Response) {
const WEATHER_TOOL = [
{
type: "function",
function: { name: "get_weather", description: "Get weather" },
name: "get_weather",
description: "Get weather",
},
] as const;
@@ -511,11 +512,14 @@ describe("OpenResponses HTTP API (e2e)", () => {
tools: [
{
type: "function",
function: { name: "get_weather", description: "Get weather" },
name: "get_weather",
description: "Get weather",
},
{
type: "function",
function: { name: "get_time", description: "Get time" },
name: "get_time",
description: "Get time",
strict: true,
},
],
tool_choice: { type: "function", function: { name: "get_time" } },
@@ -523,10 +527,16 @@ describe("OpenResponses HTTP API (e2e)", () => {
expect(resToolChoice.status).toBe(200);
const optsToolChoice = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0];
const clientTools =
(optsToolChoice as { clientTools?: Array<{ function?: { name?: string } }> } | undefined)
?.clientTools ?? [];
(
optsToolChoice as
| {
clientTools?: Array<{ function?: { name?: string; strict?: boolean } }>;
}
| undefined
)?.clientTools ?? [];
expect(clientTools).toHaveLength(1);
expect(clientTools[0]?.function?.name).toBe("get_time");
expect(clientTools[0]?.function?.strict).toBe(true);
await ensureResponseConsumed(resToolChoice);
const resUnknownTool = await postResponses(port, {

View File

@@ -255,7 +255,16 @@ function resolveResponsesLimits(
}
function extractClientTools(body: CreateResponseBody): ClientToolDefinition[] {
return (body.tools ?? []) as ClientToolDefinition[];
// Normalize from Responses API flat format to the internal wrapped format.
return (body.tools ?? []).map((tool) => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.parameters,
strict: tool.strict,
},
}));
}
function applyToolChoice(params: {

View File

@@ -110,19 +110,17 @@ describe("OpenResponses Feature Parity", () => {
expect(result.success).toBe(true);
});
it("should validate tool definition", async () => {
it("should validate tool definition in flat Responses API format", async () => {
const validTool = {
type: "function" as const,
function: {
name: "get_weather",
description: "Get the current weather",
parameters: {
type: "object",
properties: {
location: { type: "string" },
},
required: ["location"],
name: "get_weather",
description: "Get the current weather",
parameters: {
type: "object",
properties: {
location: { type: "string" },
},
required: ["location"],
},
};
@@ -130,13 +128,24 @@ describe("OpenResponses Feature Parity", () => {
expect(result.success).toBe(true);
});
it("should reject wrapped Chat Completions format (function: {...} wrapper)", async () => {
const wrappedTool = {
type: "function" as const,
function: {
name: "get_weather",
description: "Get the current weather",
},
};
const result = ToolDefinitionSchema.safeParse(wrappedTool);
expect(result.success).toBe(false);
});
it("should reject tool definition without name", async () => {
const invalidTool = {
type: "function" as const,
function: {
name: "", // Empty name
description: "Get the current weather",
},
name: "", // Empty name
description: "Get the current weather",
};
const result = ToolDefinitionSchema.safeParse(invalidTool);
@@ -186,16 +195,14 @@ describe("OpenResponses Feature Parity", () => {
tools: [
{
type: "function" as const,
function: {
name: "get_weather",
description: "Get weather for a location",
parameters: {
type: "object",
properties: {
location: { type: "string" },
},
required: ["location"],
name: "get_weather",
description: "Get weather for a location",
parameters: {
type: "object",
properties: {
location: { type: "string" },
},
required: ["location"],
},
},
],
@@ -234,10 +241,8 @@ describe("OpenResponses Feature Parity", () => {
tools: [
{
type: "function" as const,
function: {
name: "get_weather",
description: "Get weather for a location",
},
name: "get_weather",
description: "Get weather for a location",
},
],
};