Scope strict response schema to Codex

Keep generic Responses tool schemas provider-neutral and apply the strict text-only response contract at the Codex OAuth request boundary. Preserve tool-policy filtering by tightening only an already-advertised response tool, with focused regression coverage for both generic and Codex paths.

Alessandro committed Aug 19, 2026 at 04:04 UTC fdf64a7f29f8cef55ddc589346268297ba36f699
6 files changed +73 -31
helpers/responses_tools.py
+11 -22
@@ -39,31 +39,20 @@ def build_responses_function_tools(agent: Any) -> tuple[list[dict[str, Any]], di
39 continue
40 native_name = _native_tool_name(tool_name)
41 name_map[native_name] = tool_name
42 - parameters = (
42 + tools.append(
43 {
44 - "type": "object",
45 - "properties": {"text": {"type": "string"}},
46 - "required": ["text"],
47 - "additionalProperties": False,
44 + "type": "function",
45 + "name": native_name,
46 + "description": _truncate(
47 + tool_policy.tool_prompt_description(
48 + prompt,
49 + tool_name,
50 + fallback=tool_name,
51 + )
52 + ),
53 + "parameters": _schema_from_prompt(prompt),
54 }
49 - if tool_name == "response"
50 - else _schema_from_prompt(prompt)
55 )
52 - tool = {
53 - "type": "function",
54 - "name": native_name,
55 - "description": _truncate(
56 - tool_policy.tool_prompt_description(
57 - prompt,
58 - tool_name,
59 - fallback=tool_name,
60 - )
61 - ),
62 - "parameters": parameters,
63 - }
64 - if tool_name == "response":
65 - tool["strict"] = True
66 - tools.append(tool)
56
57 for tool_name, tool in _mcp_tools(agent):
58 if not tool_policy.resolve_tool(
helpers/responses_tools.py.dox.md
+1 -1
@@ -17,7 +17,7 @@
17 owns the Responses-specific prompt-name compatibility rules.
18 - Local prompt-derived function names use existing bullet declarations that pair a backticked name with `arg` or `args` for multi-tool prompt files, otherwise prefer explicit `"tool_name"` examples, then the first prompt heading, and finally the prompt filename.
19 - Apply registered tool-prompt render kwargs before deriving native metadata so descriptions never expose unresolved prompt templates.
20 -- Always expose the native `response` tool as strict with one required `text` string, independent of profile prompt wording.
20 +- Keep emitted schemas provider-neutral; provider-specific strictness belongs at the provider request boundary.
21 - Use an explicitly embedded JSON input schema when present. Infer only an unambiguous single backticked argument on an otherwise empty `args:` line; all other local tools receive an honest permissive object schema instead of prose-guessed types.
22 - Native local-tool descriptions reuse the tool catalog's compact prompt
23 description; Responses retains native-name mapping, schema derivation, and
plugins/_oauth/AGENTS.md
+1
@@ -46,6 +46,7 @@
46 - Codex Responses proxy requests must include Codex client metadata and compatibility headers such as `client_metadata`, `x-codex-installation-id`, `originator`, `session-id`, and `thread-id`, and must forward `input` as a list for upstream Codex compatibility.
47 - Codex Responses proxy requests must translate the legacy top-level `reasoning_effort` field to `reasoning.effort`; an explicit native `reasoning` field takes precedence.
48 - Codex Responses proxy defaults for reasoning effort, reasoning summary, and text verbosity come from the `codex` plugin config; explicit native request values take precedence.
49 +- Codex request shaping tightens an already-advertised native `response` tool to a strict required `text` schema; it must not add tools omitted by the framework tool policy.
50 - OAuth providers without upstream Responses support must set `a0_api_mode: chat`; native Responses providers rely on the default, since a local proxy route alone does not prove upstream support.
51
52 ## Work Guidance
plugins/_oauth/helpers/codex.py
+19
@@ -670,6 +670,25 @@ def fetch_models() -> list[str]:
670 def prepare_responses_body(body: dict[str, Any], *, force_stream: bool) -> dict[str, Any]:
671 normalized = dict(body)
672 settings = codex_config()
673 + tools = normalized.get("tools")
674 + if isinstance(tools, list):
675 + normalized["tools"] = [
676 + {
677 + **tool,
678 + "strict": True,
679 + "parameters": {
680 + "type": "object",
681 + "properties": {"text": {"type": "string"}},
682 + "required": ["text"],
683 + "additionalProperties": False,
684 + },
685 + }
686 + if isinstance(tool, dict)
687 + and tool.get("type") == "function"
688 + and tool.get("name") == "response"
689 + else tool
690 + for tool in tools
691 + ]
692 reasoning_effort = normalized.pop("reasoning_effort", None)
693 reasoning = normalized.get("reasoning")
694 if isinstance(reasoning, dict):
tests/test_oauth_codex.py
+38
@@ -208,6 +208,44 @@ def test_prepare_responses_body_adds_codex_client_metadata(monkeypatch):
208 assert body["include"] == ["output_text", "reasoning.encrypted_content"]
209
210
211 +def test_prepare_responses_body_tightens_existing_response_tool_only(monkeypatch):
212 + monkeypatch.setattr(codex, "build_client_metadata", lambda: {})
213 + response_tool = {
214 + "type": "function",
215 + "name": "response",
216 + "description": "final answer",
217 + "parameters": {"type": "object", "additionalProperties": True},
218 + }
219 + other_tool = {
220 + "type": "function",
221 + "name": "search",
222 + "parameters": {"type": "object", "additionalProperties": True},
223 + }
224 +
225 + body = codex.prepare_responses_body(
226 + {"input": [], "tools": [response_tool, other_tool]},
227 + force_stream=True,
228 + )
229 +
230 + assert body["tools"] == [
231 + {
232 + **response_tool,
233 + "strict": True,
234 + "parameters": {
235 + "type": "object",
236 + "properties": {"text": {"type": "string"}},
237 + "required": ["text"],
238 + "additionalProperties": False,
239 + },
240 + },
241 + other_tool,
242 + ]
243 + assert codex.prepare_responses_body(
244 + {"input": [], "tools": [other_tool]},
245 + force_stream=True,
246 + )["tools"] == [other_tool]
247 +
248 +
249 @pytest.mark.parametrize(
250 ("request_reasoning", "expected"),
251 [
tests/test_responses_tools.py
+3 -8
@@ -168,7 +168,7 @@ def test_responses_function_tools_add_empty_properties_to_mcp_schemas(
168 ]
169
170
171 -def test_response_tool_native_contract_is_strict_and_requires_text(monkeypatch):
171 +def test_response_tool_native_contract_stays_provider_neutral(monkeypatch):
172 prompt_root = PROJECT_ROOT / "agents" / "agent0" / "prompts"
173 prompt = (prompt_root / "agent.system.tool.response.md").read_text(encoding="utf-8")
174
@@ -195,13 +195,8 @@ def test_response_tool_native_contract_is_strict_and_requires_text(monkeypatch):
195 response_tool = next(tool for tool in tools if tool["name"] == "response")
196
197 assert description == "final answer to user"
198 - assert response_tool["parameters"] == {
199 - "type": "object",
200 - "properties": {"text": {"type": "string"}},
201 - "required": ["text"],
202 - "additionalProperties": False,
203 - }
204 - assert response_tool["strict"] is True
198 + assert response_tool["parameters"] == responses_tools._schema_from_prompt(prompt)
199 + assert "strict" not in response_tool
200
201
202 def test_complex_prompt_args_are_not_guessed_as_string_schemas():