44import contextvars
55import json
66import os
7+ import shutil
8+ import tempfile
79from pathlib import Path
810from typing import Any
911
1618active_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
2328def _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+
84146async 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