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():