Skip to content

Commit e423783

Browse files
fix(codex): harden native structured output handling
1 parent 8e79bc1 commit e423783

3 files changed

Lines changed: 220 additions & 27 deletions

File tree

swe_af/hitl/ask_user.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ def approval_webhook_url(app: Any) -> str | None:
7272
]
7373

7474

75+
class AskUserFormOption(BaseModel):
76+
"""One selectable option for select, radio, or checkbox_group fields."""
77+
78+
value: str = Field(description="Submitted value for this option.")
79+
label: str = Field(description="Human-readable label shown to the user.")
80+
81+
7582
class AskUserFormField(BaseModel):
7683
"""One field in a form the agent is constructing for the user."""
7784

@@ -103,7 +110,7 @@ class AskUserFormField(BaseModel):
103110
default=None,
104111
description="Pre-filled value if the user submits without changing it.",
105112
)
106-
options: list[dict[str, str]] | None = Field(
113+
options: list[AskUserFormOption] | None = Field(
107114
default=None,
108115
description=(
109116
"Required for 'select', 'radio', 'checkbox_group'. Each entry is "
@@ -217,6 +224,7 @@ def _field_to_form_builder_call(form: Any, field: AskUserFormField) -> None:
217224
common["default_value"] = field.default_value
218225

219226
ftype = field.type
227+
options = [option.model_dump() for option in field.options or []]
220228

221229
if ftype == "input":
222230
form.input(field.id, **common)
@@ -243,19 +251,19 @@ def _field_to_form_builder_call(form: Any, field: AskUserFormField) -> None:
243251
kwargs["step"] = field.step
244252
form.slider(field.id, **kwargs)
245253
elif ftype == "select":
246-
if not field.options:
254+
if not options:
247255
raise ValueError(f"select field '{field.id}' requires options")
248-
form.select(field.id, options=field.options, **common)
256+
form.select(field.id, options=options, **common)
249257
elif ftype == "radio":
250-
if not field.options:
258+
if not options:
251259
raise ValueError(f"radio field '{field.id}' requires options")
252-
form.radio_group(field.id, options=field.options, **common)
260+
form.radio_group(field.id, options=options, **common)
253261
elif ftype == "checkbox_group":
254-
if not field.options:
262+
if not options:
255263
raise ValueError(
256264
f"checkbox_group field '{field.id}' requires options"
257265
)
258-
form.checkbox_group(field.id, options=field.options, **common)
266+
form.checkbox_group(field.id, options=options, **common)
259267
elif ftype == "checkbox":
260268
common.pop("placeholder", None)
261269
form.checkbox(field.id, checkbox_label=field.label, **common)

swe_af/runtime/codex_harness_patch.py

Lines changed: 118 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import contextvars
55
import json
66
import os
7+
import shutil
8+
import tempfile
79
from pathlib import Path
810
from typing import Any
911

@@ -16,13 +18,20 @@
1618
active_provider: contextvars.ContextVar[str | None] = contextvars.ContextVar(
1719
"swe_af_codex_active_provider", default=None
1820
)
21+
active_output_paths: contextvars.ContextVar[dict[str, str] | None] = contextvars.ContextVar(
22+
"swe_af_codex_output_paths", default=None
23+
)
1924

2025
_ORIGINAL_BUILD_PROMPT_SUFFIX: Any = None
2126

2227

2328
def _codex_strict_json_schema(schema: dict[str, Any]) -> dict[str, Any]:
2429
if not isinstance(schema, dict):
2530
return schema
31+
if not schema:
32+
# Codex/OpenAI structured output rejects unconstrained `{}` schemas,
33+
# including Pydantic `Any` branches inside `anyOf`.
34+
return {"type": "string"}
2635
strict = dict(schema)
2736
schema_type = strict.get("type")
2837
if schema_type == "object":
@@ -39,6 +48,23 @@ def _codex_strict_json_schema(schema: dict[str, Any]) -> dict[str, Any]:
3948
strict["properties"] = cleaned
4049
strict["required"] = list(cleaned.keys())
4150
strict["additionalProperties"] = False
51+
else:
52+
additional = strict.get("additionalProperties")
53+
if additional is True:
54+
# Codex/OpenAI strict structured output does not accept
55+
# free-form maps. Keep the field object-shaped for Pydantic,
56+
# but require it to be empty.
57+
strict["properties"] = {}
58+
strict["required"] = []
59+
strict["additionalProperties"] = False
60+
elif isinstance(additional, dict):
61+
strict["properties"] = {}
62+
strict["required"] = []
63+
strict["additionalProperties"] = False
64+
else:
65+
strict["properties"] = {}
66+
strict["required"] = []
67+
strict["additionalProperties"] = False
4268
if schema_type == "array":
4369
items = strict.get("items")
4470
if isinstance(items, dict):
@@ -81,6 +107,42 @@ def _augment_codex_error_message(message: str, detail: str) -> str:
81107
return message
82108

83109

110+
def _codex_no_final_message_error(records: Any) -> tuple[str, bool]:
111+
if not isinstance(records, list):
112+
return ("Codex CLI completed without a final assistant message.", False)
113+
114+
for record in records:
115+
if not isinstance(record, dict):
116+
continue
117+
payload = record.get("payload")
118+
event = payload if isinstance(payload, dict) else record
119+
if event.get("type") != "token_count":
120+
continue
121+
rate_limits = event.get("rate_limits")
122+
if not isinstance(rate_limits, dict):
123+
continue
124+
credits = rate_limits.get("credits")
125+
if isinstance(credits, dict) and credits.get("has_credits") is False:
126+
limit_id = rate_limits.get("limit_id") or "unknown"
127+
balance = credits.get("balance")
128+
balance_note = f", balance={balance}" if balance is not None else ""
129+
return (
130+
"Codex CLI completed without a final assistant message because "
131+
f"Codex reported unavailable credits/rate-limit capacity "
132+
f"(limit_id={limit_id}{balance_note}).",
133+
True,
134+
)
135+
rate_limit_type = rate_limits.get("rate_limit_reached_type")
136+
if rate_limit_type:
137+
return (
138+
"Codex CLI completed without a final assistant message because "
139+
f"Codex reported a rate limit ({rate_limit_type}).",
140+
True,
141+
)
142+
143+
return ("Codex CLI completed without a final assistant message.", False)
144+
145+
84146
async def _run_codex_cli_with_stdin(
85147
cmd: list[str],
86148
prompt_for_codex: str,
@@ -110,6 +172,7 @@ def apply_codex_harness_patch() -> None:
110172
from agentfield.agent import Agent
111173
from agentfield.harness import _runner, _schema
112174
from agentfield.harness._cli import (
175+
apply_subprocess_env,
113176
estimate_cli_cost,
114177
extract_final_text,
115178
parse_jsonl,
@@ -136,8 +199,17 @@ def build_prompt_suffix_with_schema_file(schema: Any, cwd: str) -> str:
136199
_codex_strict_json_schema(_schema.schema_to_json_schema(schema)),
137200
indent=2,
138201
)
139-
_schema.write_schema_file(schema_json, cwd)
140-
schema_path = _schema.get_schema_path(cwd)
202+
output_dir = tempfile.mkdtemp(prefix=".agentfield-codex-", dir=cwd)
203+
schema_path = Path(output_dir) / "schema.json"
204+
output_path = Path(output_dir) / "output.json"
205+
schema_path.write_text(schema_json, encoding="utf-8")
206+
active_output_paths.set(
207+
{
208+
"schema": str(schema_path),
209+
"output": str(output_path),
210+
"dir": output_dir,
211+
}
212+
)
141213
return (
142214
"\n\n---\n"
143215
"CRITICAL CODEX STRUCTURED OUTPUT REQUIREMENTS:\n"
@@ -148,13 +220,15 @@ def build_prompt_suffix_with_schema_file(schema: Any, cwd: str) -> str:
148220
)
149221

150222
async def execute_with_native_structured_output(self: Any, prompt: str, options: dict[str, object]) -> Any:
151-
cwd = str(options.get("cwd")) if isinstance(options.get("cwd"), str) else None
223+
root = options.get("project_dir") or options.get("cwd")
224+
cwd = str(root) if isinstance(root, str) else None
152225
model = options.get("model")
153226
permission_mode = options.get("permission_mode")
154227
env_value = options.get("env")
155228
merged_env = {**os.environ}
156229
if isinstance(env_value, dict):
157230
merged_env.update({str(k): str(v) for k, v in env_value.items() if isinstance(k, str)})
231+
apply_subprocess_env(merged_env)
158232

159233
cmd = [self._bin, "exec", "--json", "--skip-git-repo-check"]
160234
if cwd:
@@ -170,20 +244,24 @@ async def execute_with_native_structured_output(self: Any, prompt: str, options:
170244
cmd.extend(["--sandbox", "workspace-write"])
171245

172246
prompt_for_codex = prompt
173-
if cwd:
247+
output_paths = active_output_paths.get()
248+
schema_path = output_paths.get("schema") if output_paths else None
249+
output_path = output_paths.get("output") if output_paths else None
250+
if not schema_path and cwd:
174251
schema_path = _schema.get_schema_path(cwd)
175252
output_path = _schema.get_output_path(cwd)
176-
if Path(schema_path).exists():
177-
cmd.extend(["--output-schema", schema_path])
178-
cmd.extend(["--output-last-message", output_path])
179-
prompt_for_codex += (
180-
"\n\n---\n"
181-
"CODEX STRUCTURED OUTPUT CONTRACT:\n"
182-
f"The Codex CLI will save your final response to: {output_path}\n"
183-
f"Your final response MUST be a single JSON object conforming to: {schema_path}\n"
184-
"Return the JSON object as your final answer. Do not write "
185-
"the output file yourself or make the output file the task."
186-
)
253+
254+
if schema_path and output_path and Path(schema_path).exists():
255+
cmd.extend(["--output-schema", schema_path])
256+
cmd.extend(["--output-last-message", output_path])
257+
prompt_for_codex += (
258+
"\n\n---\n"
259+
"CODEX STRUCTURED OUTPUT CONTRACT:\n"
260+
f"The Codex CLI will save your final response to: {output_path}\n"
261+
f"Your final response MUST be a single JSON object conforming to: {schema_path}\n"
262+
"Return the JSON object as your final answer. Do not write "
263+
"the output file yourself or make the output file the task."
264+
)
187265

188266
try:
189267
start = asyncio.get_running_loop().time()
@@ -233,8 +311,7 @@ async def execute_with_native_structured_output(self: Any, prompt: str, options:
233311
records = parse_jsonl(stdout or "")
234312
result_text = extract_final_text(records) or ""
235313

236-
if not result_text and cwd:
237-
output_path = _schema.get_output_path(cwd)
314+
if not result_text and output_path:
238315
output_file = Path(output_path)
239316
if output_file.exists():
240317
try:
@@ -245,10 +322,26 @@ async def execute_with_native_structured_output(self: Any, prompt: str, options:
245322
is_error = returncode != 0
246323
error_message = ""
247324
failure_type = FailureType.NONE
325+
if not result_text:
326+
error_message, is_api_error = _codex_no_final_message_error(records)
327+
is_error = True
328+
failure_type = FailureType.API_ERROR if is_api_error else FailureType.NO_OUTPUT
248329
if is_error:
249-
base_error = stderr_clean or "Codex CLI failed"
330+
stdout_error = ""
331+
if isinstance(records, list):
332+
for record in records:
333+
if isinstance(record, dict) and record.get("type") in {
334+
"error",
335+
"turn.failed",
336+
}:
337+
stdout_error = json.dumps(record, ensure_ascii=False)
338+
break
339+
base_error = "\n".join(
340+
part for part in (stderr_clean, stdout_error) if part
341+
) or error_message or "Codex CLI failed"
250342
error_message = _augment_codex_error_message(base_error, base_error)
251-
failure_type = FailureType.CRASH
343+
if returncode != 0:
344+
failure_type = FailureType.CRASH
252345

253346
return RawResult(
254347
result=result_text,
@@ -289,9 +382,15 @@ async def _harness_with_provider_context(
289382
) -> Any:
290383
provider_value = kwargs.get("provider")
291384
token = active_provider.set(str(provider_value) if provider_value else None)
385+
output_token = active_output_paths.set(None)
292386
try:
293387
return await _orig_agent_harness(self, prompt, *args, **kwargs)
294388
finally:
389+
output_paths = active_output_paths.get()
390+
tmp_dir = output_paths.get("dir") if output_paths else None
391+
if tmp_dir:
392+
shutil.rmtree(tmp_dir, ignore_errors=True)
393+
active_output_paths.reset(output_token)
295394
active_provider.reset(token)
296395

297396
_schema.build_prompt_suffix = build_prompt_suffix_dispatching

0 commit comments

Comments
 (0)