From b60c9d2f9599b3b8f3fa51e09a86569885ffd66a Mon Sep 17 00:00:00 2001 From: Steven Huang <134844631+remote-controlled-man@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:38:16 +0800 Subject: [PATCH] fix(sdk/python): skip schema output dir for schema-free harness runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HarnessRunner.run() allocated the per-run .agentfield-out-* directory before dispatching the provider even when schema is None. That directory only ever holds .agentfield_output.json (#684, #891), and every consumer of it is already inside an `if schema is not None` guard, so a text-only run paid a filesystem write it never read back. When the project root could not accept one — a read-only mount, an immutable CI workspace — a plain permission_mode="plan" call failed during setup, before the coding agent was invoked at all. Allocate the directory only for schema-bearing runs. Their per-run isolation and cleanup are unchanged, so concurrent runs sharing one cwd still cannot overwrite or delete each other's output. Refs #684, #891 --- sdk/python/agentfield/harness/_runner.py | 21 ++++++++------- sdk/python/tests/test_harness_runner.py | 33 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/sdk/python/agentfield/harness/_runner.py b/sdk/python/agentfield/harness/_runner.py index 5e4a8d918..f83ab1d8e 100644 --- a/sdk/python/agentfield/harness/_runner.py +++ b/sdk/python/agentfield/harness/_runner.py @@ -294,15 +294,18 @@ async def run( # project_dir, or cwd when project_dir is unset. Besides keeping the file # inside the agent root, this prevents concurrent runs sharing one cwd # from overwriting or deleting each other's fixed output filename. - resolved_project_dir = options.get("project_dir") - if isinstance(resolved_project_dir, str) and resolved_project_dir: - base_dir = resolved_project_dir - else: - base_dir = resolved_cwd - os.makedirs(base_dir, exist_ok=True) - temp_output_dir: Optional[str] = tempfile.mkdtemp( - prefix=".agentfield-out-", dir=base_dir - ) + # A schema-free run never writes or reads that file, so it must not need + # a writable root either: allocating one unconditionally aborted + # text-only permission_mode="plan" dispatches before the provider ran. + temp_output_dir: Optional[str] = None + if schema is not None: + resolved_project_dir = options.get("project_dir") + if isinstance(resolved_project_dir, str) and resolved_project_dir: + base_dir = resolved_project_dir + else: + base_dir = resolved_cwd + os.makedirs(base_dir, exist_ok=True) + temp_output_dir = tempfile.mkdtemp(prefix=".agentfield-out-", dir=base_dir) output_dir = temp_output_dir # schema_mode selects how the agent is asked to produce the output: diff --git a/sdk/python/tests/test_harness_runner.py b/sdk/python/tests/test_harness_runner.py index adce2b751..27b14e358 100644 --- a/sdk/python/tests/test_harness_runner.py +++ b/sdk/python/tests/test_harness_runner.py @@ -240,6 +240,39 @@ async def test_run_without_schema_returns_plain_harness_result(tmp_path): assert result.session_id == "sess-1" +@pytest.mark.asyncio +async def test_run_without_schema_does_not_require_writable_project_dir(tmp_path): + """A schema-free run must reach the provider without allocating artifacts. + + The per-run ``.agentfield-out-*`` directory only exists to hold the schema + output file (#684, #891). Allocating it unconditionally aborted a text-only + ``permission_mode="plan"`` run during setup, before the provider was ever + dispatched, whenever the project root could not be written to. + """ + blocker = tmp_path / "blocker.txt" + blocker.write_text("not a directory", encoding="utf-8") + # Cannot be created on any platform: its parent is a regular file. + unwritable_root = blocker / "project" + + provider = MockProvider([RawResult(result="plan text")]) + runner = HarnessRunner() + + with patch("agentfield.harness._runner.build_provider", return_value=provider): + result = await runner.run( + "hello", + provider="codex", + permission_mode="plan", + cwd=str(tmp_path), + project_dir=str(unwritable_root), + ) + + assert provider.call_count == 1, "schema-free run must dispatch the provider" + assert result.is_error is False + assert result.result == "plan text" + assert result.parsed is None + assert list(tmp_path.glob(".agentfield-out-*")) == [] + + @pytest.mark.asyncio async def test_run_with_schema_injects_prompt_suffix_and_parses_output(tmp_path): provider = FileWritingProvider(json.dumps({"name": "ok", "count": 1}))