From b93f2676335396ac71114b8012fdad5764c614b5 Mon Sep 17 00:00:00 2001 From: cpb175 Date: Sun, 6 Sep 2026 20:42:24 +0100 Subject: [PATCH 1/5] Fix Stateful ReAct no-tool finalization --- apodex/task_runner.py | 11 +++++++++++ apodex/tests/test_features.py | 27 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/apodex/task_runner.py b/apodex/task_runner.py index 1ccb527..b440831 100644 --- a/apodex/task_runner.py +++ b/apodex/task_runner.py @@ -93,6 +93,17 @@ def _is_complete_run( # phase hit its soft deadline. Its explicit complete status is authoritative. if answer_status == "complete" and answer_source == "reporter_llm": return True + # Stateful ReAct normally finishes with a plain-text assistant turn, which + # the loop records as ``no_tool``. Once the workflow has explicitly marked + # that agent-produced answer complete, treat that terminal as successful. + # Keep this narrower than accepting workflow ``no_tool`` in general: an + # exhausted no-tool nudge budget can also use the same stop reason. + if ( + stopped_by == _NO_TOOL_STOP + and answer_status == "complete" + and answer_source == "agent" + ): + return True if no_tool_is_complete and stopped_by == _NO_TOOL_STOP: return True return stopped_by in _COMPLETE_TOP_LEVEL_STOPS diff --git a/apodex/tests/test_features.py b/apodex/tests/test_features.py index 6615908..364c1bd 100644 --- a/apodex/tests/test_features.py +++ b/apodex/tests/test_features.py @@ -942,6 +942,33 @@ def test_generic_loop_no_tool_stop_is_a_normal_finish(): assert _is_complete_run("max_turns", no_tool_is_complete=True) is False +def test_stateful_react_complete_agent_no_tool_is_a_normal_finish(): + """A workflow-certified agent answer may terminate via a tool-free turn.""" + from apodex.task_runner import _is_complete_run + + assert _is_complete_run( + "no_tool", + answer_status="complete", + answer_source="agent", + ) is True + + # Do not broaden workflow no_tool into an unconditional success signal. + assert _is_complete_run( + "no_tool", + answer_status="best_effort", + answer_source="agent", + ) is False + assert _is_complete_run( + "no_tool", + answer_source="agent", + ) is False + assert _is_complete_run( + "max_turns", + answer_status="complete", + answer_source="agent", + ) is False + + async def _drive_workflow(session, profile, follow_up): """Run one native workflow with ``run_task`` stubbed to record follow-ups.""" session.run_task = follow_up # type: ignore[method-assign] From 448bc06d8458753111147755ecaaddb4842c98fd Mon Sep 17 00:00:00 2001 From: cpb175 Date: Sun, 6 Sep 2026 20:57:54 +0100 Subject: [PATCH 2/5] Fix native workflow telemetry counters --- apodex/task_runner.py | 18 ++++++++++++++---- apodex/tests/test_features.py | 17 +++++++++++++++++ .../stateful_react_agent/nodes/main_agent.py | 4 ++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/apodex/task_runner.py b/apodex/task_runner.py index b440831..180f82f 100644 --- a/apodex/task_runner.py +++ b/apodex/task_runner.py @@ -576,18 +576,28 @@ async def _run_native_workflow(self, task: str, profile: Any) -> None: answer_status=str(state.get("answer_status") or ""), answer_source=str(state.get("final_answer_source") or ""), ) + turns_used = ( + int(state.get("turns_used") or 0) + if "turns_used" in state + else len(state.get("react_steps") or []) + ) + tool_calls_count = ( + int(state.get("tool_calls_count") or 0) + if "tool_calls_count" in state + else 0 + ) if complete: self.r.final( final, - turns=len(state.get("react_steps") or []), - tool_calls=0, + turns=turns_used, + tool_calls=tool_calls_count, stopped_by=stopped_by, ) else: self._show_incomplete_run( final, - turns=len(state.get("react_steps") or []), - tool_calls=0, + turns=turns_used, + tool_calls=tool_calls_count, stopped_by=stopped_by, ) await self._render_changed_files() diff --git a/apodex/tests/test_features.py b/apodex/tests/test_features.py index 364c1bd..641b0d4 100644 --- a/apodex/tests/test_features.py +++ b/apodex/tests/test_features.py @@ -1555,3 +1555,20 @@ def test_download_file_target_is_the_resolved_destination(monkeypatch, tmp_path) assert named.startswith(str(tmp_path / "downloads" / "p.pdf")) assert "/elsewhere/" not in named # the requested directory is ignored assert "renamed" in named # collisions rename it + + +def test_native_workflow_uses_authoritative_loop_telemetry( + tmp_path, monkeypatch, capsys, +): + _run_workflow_returning({ + "final_answer": "done", + "answer_status": "complete", + "final_answer_source": "agent", + "stopped_by": "no_tool", + "react_steps": [{}] * 7, + "turns_used": 8, + "tool_calls_count": 7, + }, tmp_path, monkeypatch) + + out = capsys.readouterr().out + assert "turns=8 · tools=7 · no_tool" in out diff --git a/workflows/stateful_react_agent/nodes/main_agent.py b/workflows/stateful_react_agent/nodes/main_agent.py index 142d5fd..8fcaa4b 100644 --- a/workflows/stateful_react_agent/nodes/main_agent.py +++ b/workflows/stateful_react_agent/nodes/main_agent.py @@ -1162,6 +1162,10 @@ async def react_agent_node(state: dict[str, Any], ctx: NodeContext) -> dict[str, steps=result.metadata.get("react_steps", []), ), "react_steps": result.metadata.get("react_steps", []), + # Authoritative loop telemetry. ``react_steps`` counts tool results, + # not LLM turns, so it cannot substitute for either counter. + "turns_used": result.turns_used, + "tool_calls_count": result.tool_calls_count, "language": answer_language, # Keep the user-facing answer non-empty while preserving the old # machine-readable infra/eval failure signal out of band. From 445a8fa1c88542b7c223d71876314f5fa463fdba Mon Sep 17 00:00:00 2001 From: cpb175 Date: Sun, 6 Sep 2026 21:06:53 +0100 Subject: [PATCH 3/5] Preserve project paths for native read_file --- apodex/agent_tools.py | 15 +++++++++++++++ apodex/tests/test_features.py | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/apodex/agent_tools.py b/apodex/agent_tools.py index a6687a5..3d21e12 100644 --- a/apodex/agent_tools.py +++ b/apodex/agent_tools.py @@ -284,6 +284,21 @@ def localize_path_args(name: str, args: dict, cwd: str) -> dict | None: except Exception: continue if not (rel == ".." or rel.startswith(".." + os.sep)): + # Native workflow mode deliberately separates the user's project + # (cwd) from its run-private execution workspace. The workflow + # read_file resolves relative paths in that private workspace, so + # converting a correct absolute project path to "README.md" would + # make it read the wrong filesystem location. + runtime_workspace = os.environ.get( + "FRONTIER_AGENT_WORKSPACE_DIR", "" + ).strip() + if name == "read_file" and runtime_workspace: + try: + workspace_real = os.path.realpath(runtime_workspace) + except Exception: + workspace_real = "" + if workspace_real and workspace_real != cwd_real: + return None new = dict(args) new[key] = rel or "." return new diff --git a/apodex/tests/test_features.py b/apodex/tests/test_features.py index 641b0d4..6178033 100644 --- a/apodex/tests/test_features.py +++ b/apodex/tests/test_features.py @@ -284,6 +284,27 @@ def test_localize_absolute_path_inside_cwd(tmp_path): assert out is not None and out["path"] == "sub/f.py" +def test_read_file_keeps_project_absolute_path_with_split_runtime_workspace( + tmp_path, monkeypatch, +): + project = tmp_path / "project" + runtime_workspace = tmp_path / "private-workspace" + project.mkdir() + runtime_workspace.mkdir() + target = project / "README.md" + target.write_text("# project\n") + + monkeypatch.setenv( + "FRONTIER_AGENT_WORKSPACE_DIR", str(runtime_workspace), + ) + + out = localize_path_args( + "read_file", {"path": str(target)}, str(project), + ) + + assert out is None + + def test_localize_preserves_absolute_task_input_path(tmp_path, monkeypatch): inputs = tmp_path / ".apodex" / "inputs" / "run" inputs.mkdir(parents=True) From c26d940adcceb1177905d2b00d033ee48a7cbb34 Mon Sep 17 00:00:00 2001 From: cpb175 Date: Sun, 6 Sep 2026 21:17:13 +0100 Subject: [PATCH 4/5] Silence expected native tool-user warning --- apodex/tests/test_native.py | 60 +++++++++++++++++++++++++++++++++++++ plugins/tools/_sandbox.py | 35 +++++++++++++++++----- 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/apodex/tests/test_native.py b/apodex/tests/test_native.py index 20c1a88..6b2e356 100644 --- a/apodex/tests/test_native.py +++ b/apodex/tests/test_native.py @@ -60,6 +60,66 @@ def test_native_strategy_is_explicitly_not_os_isolated() -> None: assert "not an OS sandbox" in strategy.describe() +def test_nonroot_native_current_sandbox_skips_tool_user_warning( + tmp_path, monkeypatch, caplog, +) -> None: + from plugins.tools import _sandbox as tool_sandbox + + monkeypatch.setenv("APODEX_IN_NATIVE", "1") + monkeypatch.delenv("FRONTIER_AGENT_REQUIRE_TOOL_USER", raising=False) + monkeypatch.setattr(tool_sandbox.os, "geteuid", lambda: 1000) + monkeypatch.setattr( + tool_sandbox, "container_uses_inner_bwrap", lambda: False, + ) + + def unexpected_identity_lookup(): + raise AssertionError( + "ordinary non-root native mode must not request a tool-user identity" + ) + + monkeypatch.setattr( + tool_sandbox, "tool_identity", unexpected_identity_lookup, + ) + + current = tool_sandbox.CurrentSandbox(tmp_path) + + assert current.commands._identity is None + assert "Tool-user isolation inactive" not in caplog.text + assert "own uid" not in caplog.text + + +def test_strict_nonroot_native_still_requires_tool_identity( + tmp_path, monkeypatch, +) -> None: + from plugins.tools import _sandbox as tool_sandbox + + monkeypatch.setenv("APODEX_IN_NATIVE", "1") + monkeypatch.setenv("FRONTIER_AGENT_REQUIRE_TOOL_USER", "1") + monkeypatch.setattr(tool_sandbox.os, "geteuid", lambda: 1000) + monkeypatch.setattr( + tool_sandbox, "container_uses_inner_bwrap", lambda: False, + ) + + calls = [] + + def required_identity(): + calls.append(True) + raise tool_sandbox.SandboxUnavailableError( + "strict tool-user requirement exercised" + ) + + monkeypatch.setattr(tool_sandbox, "tool_identity", required_identity) + + try: + tool_sandbox.CurrentSandbox(tmp_path) + except tool_sandbox.SandboxUnavailableError as exc: + assert "strict tool-user requirement exercised" in str(exc) + else: + raise AssertionError("strict native mode unexpectedly bypassed tool_identity") + + assert calls == [True] + + def test_native_runtime_resolves_canonical_mount_aliases( tmp_path, monkeypatch, ) -> None: diff --git a/plugins/tools/_sandbox.py b/plugins/tools/_sandbox.py index 4478e05..5e23b86 100644 --- a/plugins/tools/_sandbox.py +++ b/plugins/tools/_sandbox.py @@ -1428,6 +1428,23 @@ def _check_ulimit_below_cgroup(mem_mb: int) -> None: ) +def _native_same_uid_is_expected() -> bool: + """True when local native execution intentionally uses the harness uid. + + Native mode is explicitly not an OS sandbox: a normal non-root invocation + runs approved model commands with the current user's permissions. Do not + route that expected case through the container/service tool-user warning + path. Root-native and explicitly strict runs still attempt the dedicated + tool identity. + """ + return ( + os.environ.get("APODEX_IN_NATIVE", "").strip() == "1" + and os.name == "posix" + and os.geteuid() != 0 + and not _require_tool_user() + ) + + class _CurrentCommands: """Command executor for an existing checkout in the current process. @@ -1438,8 +1455,10 @@ class _CurrentCommands: def __init__(self, workdir: str, *, private_tmp: bool = False) -> None: self._workdir = workdir - self._identity = tool_identity() native = os.environ.get("APODEX_IN_NATIVE", "").strip() == "1" + self._identity = ( + None if _native_same_uid_is_expected() else tool_identity() + ) self._runtime_home = ( os.environ.get("HOME", "").strip() or workdir if native else workdir @@ -2023,13 +2042,15 @@ def __init__(self, workdir: str | Path, *, private_tmp: bool = False) -> None: self.commands = self._inner.commands self.files = self._inner.files else: - identity = tool_identity() + same_uid_expected = _native_same_uid_is_expected() + identity = None if same_uid_expected else tool_identity() if identity is None: - logger.warning( - "CurrentSandbox running model commands with the harness's " - "own uid: the child environment is scrubbed, but " - "/proc//environ remains readable" - ) + if not same_uid_expected: + logger.warning( + "CurrentSandbox running model commands with the harness's " + "own uid: the child environment is scrubbed, but " + "/proc//environ remains readable" + ) else: _, outputs_dir, inputs_dir = resolve_mount_dirs() _prepare_tool_writable(self._workdir, outputs_dir) From 30cfb1b6a35305dd003b813413d16955dc53c020 Mon Sep 17 00:00:00 2001 From: cpb175 Date: Sun, 6 Sep 2026 21:32:46 +0100 Subject: [PATCH 5/5] Polish native workflow compatibility tests --- apodex/task_runner.py | 2 +- apodex/tests/test_features.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apodex/task_runner.py b/apodex/task_runner.py index 180f82f..83e7929 100644 --- a/apodex/task_runner.py +++ b/apodex/task_runner.py @@ -93,7 +93,7 @@ def _is_complete_run( # phase hit its soft deadline. Its explicit complete status is authoritative. if answer_status == "complete" and answer_source == "reporter_llm": return True - # Stateful ReAct normally finishes with a plain-text assistant turn, which + # Main-agent workflows may finish with a plain-text assistant turn, which # the loop records as ``no_tool``. Once the workflow has explicitly marked # that agent-produced answer complete, treat that terminal as successful. # Keep this narrower than accepting workflow ``no_tool`` in general: an diff --git a/apodex/tests/test_features.py b/apodex/tests/test_features.py index 6178033..e5945c8 100644 --- a/apodex/tests/test_features.py +++ b/apodex/tests/test_features.py @@ -952,6 +952,8 @@ def test_native_workflow_no_tool_stop_is_not_reported_as_delivery( assert "no_tool" in out assert "partial output was not saved as a final report" in out assert "Final report" not in out + assert "turns=12" in out + assert "tools=0" in out def test_generic_loop_no_tool_stop_is_a_normal_finish(): @@ -963,7 +965,7 @@ def test_generic_loop_no_tool_stop_is_a_normal_finish(): assert _is_complete_run("max_turns", no_tool_is_complete=True) is False -def test_stateful_react_complete_agent_no_tool_is_a_normal_finish(): +def test_workflow_complete_agent_no_tool_is_a_normal_finish(): """A workflow-certified agent answer may terminate via a tool-free turn.""" from apodex.task_runner import _is_complete_run