From 48f180f52c9895014f38910eb8cb17916e239a40 Mon Sep 17 00:00:00 2001 From: Eloi Date: Thu, 2 Jul 2026 01:57:56 +0200 Subject: [PATCH 1/3] feat(api): first-class model/reasoning_effort fields + FinishReason vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Promote model and reasoning_effort to typed AgentTask fields instead of the stringly-typed metadata['model'] / metadata['reasoning_effort'] magic keys. Adapters prefer the fields and fall back to the metadata aliases, so existing callers keep working while the typed surface becomes the documented path. - Add a FinishReason(str, Enum) canonical vocabulary (done/failed/max_turns/ max_tokens/interrupted) that callers can match against instead of bare string literals. finish_reason stays typed str for forward-compat; because FinishReason is a str subclass, both == FinishReason.X and == 'x' hold. Refs REVIEW.md §5.3, §5.4. --- src/agent_runtime_kit/__init__.py | 2 ++ src/agent_runtime_kit/_types.py | 23 ++++++++++++++++- src/agent_runtime_kit/adapters/antigravity.py | 2 +- src/agent_runtime_kit/adapters/claude.py | 2 +- src/agent_runtime_kit/adapters/codex.py | 4 +-- tests/test_codex_adapter.py | 25 +++++++++++++++++++ tests/test_core.py | 11 ++++++++ 7 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/agent_runtime_kit/__init__.py b/src/agent_runtime_kit/__init__.py index 71fb1b4..fe1b848 100644 --- a/src/agent_runtime_kit/__init__.py +++ b/src/agent_runtime_kit/__init__.py @@ -17,6 +17,7 @@ AvailabilityReason, EventSink, FilesystemAccess, + FinishReason, McpServerConfig, PermissionMode, PermissionProfile, @@ -51,6 +52,7 @@ "EventSink", "FakeAgentRuntime", "FilesystemAccess", + "FinishReason", "McpServerConfig", "PermissionMode", "PermissionProfile", diff --git a/src/agent_runtime_kit/_types.py b/src/agent_runtime_kit/_types.py index 1da287f..529b646 100644 --- a/src/agent_runtime_kit/_types.py +++ b/src/agent_runtime_kit/_types.py @@ -65,6 +65,22 @@ class PermissionMode(str, Enum): PERMISSIVE = "permissive" +class FinishReason(str, Enum): + """Canonical ``AgentResult.finish_reason`` values across all runtimes. + + ``finish_reason`` is typed ``str`` for forward-compatibility, but the built-in + adapters only ever emit these values. Callers can compare against these members + (a ``str`` subclass, so ``result.finish_reason == FinishReason.FAILED`` and + ``== "failed"`` both hold) instead of matching bare string literals. + """ + + DONE = "done" + FAILED = "failed" + MAX_TURNS = "max_turns" + MAX_TOKENS = "max_tokens" + INTERRUPTED = "interrupted" + + class FilesystemAccess(str, Enum): """Filesystem mutation level requested by a task.""" @@ -231,6 +247,11 @@ class AgentTask: goal: str task_id: str = field(default_factory=lambda: f"task-{uuid4().hex}") system: str | None = None + # First-class model / reasoning-effort selection. Adapters prefer these over the + # legacy metadata["model"] / metadata["reasoning_effort"] aliases, which are kept + # working for back-compat. + model: str | None = None + reasoning_effort: str | None = None working_directory: Path | None = None mcp_servers: tuple[McpServerConfig, ...] = () permissions: PermissionProfile = field(default_factory=PermissionProfile) @@ -251,7 +272,7 @@ class AgentResult: """Typed result returned by all runtimes.""" output: str - finish_reason: str = "done" + finish_reason: str = FinishReason.DONE.value error: str | None = None parsed_output: Any | None = None usage: Usage = field(default_factory=Usage) diff --git a/src/agent_runtime_kit/adapters/antigravity.py b/src/agent_runtime_kit/adapters/antigravity.py index 842f8db..78ebaa1 100644 --- a/src/agent_runtime_kit/adapters/antigravity.py +++ b/src/agent_runtime_kit/adapters/antigravity.py @@ -583,7 +583,7 @@ def _auth_config(self) -> _AntigravityAuthConfig: return self._vertex_auth_config() def _model(self, task: AgentTask) -> str: - return metadata_str(task.metadata, "model") or self._default_model + return task.model or metadata_str(task.metadata, "model") or self._default_model def _runtime_dir(self, name: str) -> Path: base = self._data_dir or _default_data_dir() diff --git a/src/agent_runtime_kit/adapters/claude.py b/src/agent_runtime_kit/adapters/claude.py index 57e3955..03c4d81 100644 --- a/src/agent_runtime_kit/adapters/claude.py +++ b/src/agent_runtime_kit/adapters/claude.py @@ -375,7 +375,7 @@ def _build_options( return options_cls(**supported), dropped def _model(self, task: AgentTask) -> str: - return metadata_str(task.metadata, "model") or self._default_model + return task.model or metadata_str(task.metadata, "model") or self._default_model class _StreamState: diff --git a/src/agent_runtime_kit/adapters/codex.py b/src/agent_runtime_kit/adapters/codex.py index 26f1b15..fd18548 100644 --- a/src/agent_runtime_kit/adapters/codex.py +++ b/src/agent_runtime_kit/adapters/codex.py @@ -350,7 +350,7 @@ async def _invoke_codex( schema = output_schema_from(task.output_schema, task.metadata) if schema is not None: run_kwargs["output_schema"] = dict(schema) - effort = metadata_str(task.metadata, "reasoning_effort") + effort = task.reasoning_effort or metadata_str(task.metadata, "reasoning_effort") if effort: run_kwargs["effort"] = effort run_supported, run_dropped = filter_supported_kwargs( @@ -435,7 +435,7 @@ async def _start_or_resume_thread( return await codex.thread_start(**supported), dropped def _model(self, task: AgentTask) -> str: - return metadata_str(task.metadata, "model") or self._default_model + return task.model or metadata_str(task.metadata, "model") or self._default_model def _translate_run_result( diff --git a/tests/test_codex_adapter.py b/tests/test_codex_adapter.py index 3174bff..dea2242 100644 --- a/tests/test_codex_adapter.py +++ b/tests/test_codex_adapter.py @@ -474,6 +474,31 @@ async def test_codex_sandbox_mapping(filesystem: FilesystemAccess, expected: str assert FakeThread.last_run_kwargs["sandbox"] == expected +@pytest.mark.asyncio +async def test_codex_prefers_typed_model_and_effort_fields() -> None: + runtime = make_runtime() + + await runtime.run( + AgentTask(goal="x", model="gpt-5.5-codex", reasoning_effort="high"), + ) + + assert FakeThread.last_run_kwargs is not None + assert FakeThread.last_run_kwargs["model"] == "gpt-5.5-codex" + assert FakeThread.last_run_kwargs["effort"] == "high" + + +@pytest.mark.asyncio +async def test_codex_typed_model_field_overrides_metadata_alias() -> None: + runtime = make_runtime() + + await runtime.run( + AgentTask(goal="x", model="from-field", metadata={"model": "from-metadata"}), + ) + + assert FakeThread.last_run_kwargs is not None + assert FakeThread.last_run_kwargs["model"] == "from-field" + + @pytest.mark.asyncio async def test_codex_tolerates_config_option_drift() -> None: # A config class that no longer accepts 'config_overrides' must not crash the diff --git a/tests/test_core.py b/tests/test_core.py index 7885377..6c254c3 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -4,10 +4,12 @@ from agent_runtime_kit import ( AgentCapabilities, + AgentResult, AgentRuntime, AgentRuntimeKind, AgentTask, FakeAgentRuntime, + FinishReason, RuntimeNotRegisteredError, UnsupportedTaskInputError, create_default_registry, @@ -55,6 +57,15 @@ def test_registry_accepts_namespaced_third_party_kind() -> None: assert isinstance(runtime, FakeAgentRuntime) +def test_finish_reason_enum_compares_as_string() -> None: + result = AgentResult(output="x", finish_reason=FinishReason.FAILED) + + # FinishReason is a str subclass: enum-vs-enum and enum-vs-literal both hold. + assert result.finish_reason == FinishReason.FAILED + assert result.finish_reason == "failed" + assert AgentResult(output="x").finish_reason == FinishReason.DONE + + def test_coerce_returns_namespaced_string_and_rejects_blank() -> None: assert AgentRuntimeKind.coerce("claude-agent-sdk") is AgentRuntimeKind.CLAUDE_AGENT_SDK assert AgentRuntimeKind.coerce("x-myorg-agent") == "x-myorg-agent" From 46f6a1b4b73cae1358d5e54da3006658fc73e703 Mon Sep 17 00:00:00 2001 From: Eloi Date: Thu, 2 Jul 2026 11:46:34 +0200 Subject: [PATCH 2/3] fix(types): FinishReason renders as its value in f-strings on Python 3.11+ (str, Enum) format/str changed in 3.11: f"{FinishReason.FAILED}" produced "FinishReason.FAILED", leaking the enum name into event summaries for callers who pass members. Adopt the StrEnum __str__/__format__ assignments so rendering matches the wire value on every supported Python. --- src/agent_runtime_kit/_types.py | 8 ++++++++ tests/test_core.py | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/src/agent_runtime_kit/_types.py b/src/agent_runtime_kit/_types.py index 529b646..1320c07 100644 --- a/src/agent_runtime_kit/_types.py +++ b/src/agent_runtime_kit/_types.py @@ -74,6 +74,14 @@ class FinishReason(str, Enum): ``== "failed"`` both hold) instead of matching bare string literals. """ + # StrEnum semantics on every supported Python: without this, Python >= 3.11 + # renders f"{FinishReason.FAILED}" as "FinishReason.FAILED" instead of + # "failed", leaking the enum name into event summaries and logs. Same + # assignments CPython's own StrEnum uses; typeshed's str.__format__ + # self-type does not line up with Enum's, hence the ignore. + __str__ = str.__str__ + __format__ = str.__format__ # type: ignore[assignment] + DONE = "done" FAILED = "failed" MAX_TURNS = "max_turns" diff --git a/tests/test_core.py b/tests/test_core.py index 6c254c3..4e5215f 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -64,6 +64,11 @@ def test_finish_reason_enum_compares_as_string() -> None: assert result.finish_reason == FinishReason.FAILED assert result.finish_reason == "failed" assert AgentResult(output="x").finish_reason == FinishReason.DONE + # And it renders as its value everywhere, on 3.10 and 3.11+ alike — event + # summaries interpolate finish_reason and must never show "FinishReason.FAILED". + assert str(FinishReason.FAILED) == "failed" + assert f"{FinishReason.FAILED}" == "failed" + assert format(FinishReason.FAILED) == "failed" def test_coerce_returns_namespaced_string_and_rejects_blank() -> None: From 336bc6c1fc83773d9c3d6d3c88e8b0df3fb50b21 Mon Sep 17 00:00:00 2001 From: Eloi Date: Thu, 2 Jul 2026 17:00:18 +0200 Subject: [PATCH 3/3] fix(api): wire reasoning_effort end-to-end; keep new AgentTask fields keyword-only The first-class model/reasoning_effort fields shipped only half-wired: Codex consumed both, Claude and Antigravity consumed model only, so a requested reasoning_effort silently no-oped on two of three adapters. - Claude maps reasoning_effort -> effort= (claude-agent-sdk >= 0.2.87); on an older SDK the drop is recorded in dropped_options, not fatal, since effort is a quality preference rather than a security posture. - Antigravity raises UnsupportedTaskInputError: google-antigravity 0.1.x exposes no reasoning-effort control, and an explicit typed request must not silently no-op. The legacy metadata alias stays ignored as before for this adapter. - AgentTask.model / .reasoning_effort become kw_only so inserting them mid-dataclass does not shift the positional layout that predates them (the fourth positional argument is still working_directory). --- src/agent_runtime_kit/_types.py | 8 +++-- src/agent_runtime_kit/adapters/antigravity.py | 11 ++++++ src/agent_runtime_kit/adapters/claude.py | 6 ++++ tests/test_antigravity_adapter.py | 12 +++++++ tests/test_claude_adapter.py | 35 +++++++++++++++++++ tests/test_core.py | 15 ++++++++ 6 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/agent_runtime_kit/_types.py b/src/agent_runtime_kit/_types.py index 1320c07..a47e219 100644 --- a/src/agent_runtime_kit/_types.py +++ b/src/agent_runtime_kit/_types.py @@ -257,9 +257,11 @@ class AgentTask: system: str | None = None # First-class model / reasoning-effort selection. Adapters prefer these over the # legacy metadata["model"] / metadata["reasoning_effort"] aliases, which are kept - # working for back-compat. - model: str | None = None - reasoning_effort: str | None = None + # working for back-compat. kw_only so inserting them here does not shift the + # positional layout that predates them (goal, task_id, system, + # working_directory, ...). + model: str | None = field(default=None, kw_only=True) + reasoning_effort: str | None = field(default=None, kw_only=True) working_directory: Path | None = None mcp_servers: tuple[McpServerConfig, ...] = () permissions: PermissionProfile = field(default_factory=PermissionProfile) diff --git a/src/agent_runtime_kit/adapters/antigravity.py b/src/agent_runtime_kit/adapters/antigravity.py index 78ebaa1..acb705f 100644 --- a/src/agent_runtime_kit/adapters/antigravity.py +++ b/src/agent_runtime_kit/adapters/antigravity.py @@ -154,6 +154,17 @@ async def run(self, task: AgentTask) -> AgentResult: reject_unsupported_inputs( self.kind, task, budget=True, network=True, tool_filters=False ) + if task.reasoning_effort: + # LocalAgentConfig exposes no reasoning/thinking-effort control + # (google-antigravity 0.1.x), so the first-class field must not + # silently no-op. The legacy metadata alias stays ignored, as it + # always has been for this adapter. + raise UnsupportedTaskInputError( + self.kind, + "reasoning_effort", + "google-antigravity exposes no reasoning-effort control; " + "unset reasoning_effort for this runtime", + ) model = self._model(task) ensure_supported_model( kind=self.kind, diff --git a/src/agent_runtime_kit/adapters/claude.py b/src/agent_runtime_kit/adapters/claude.py index 03c4d81..3a5c716 100644 --- a/src/agent_runtime_kit/adapters/claude.py +++ b/src/agent_runtime_kit/adapters/claude.py @@ -346,6 +346,12 @@ def _build_options( kwargs["resume"] = task.session_id if task.budget_usd is not None: kwargs["max_budget_usd"] = task.budget_usd + effort = task.reasoning_effort or metadata_str(metadata, "reasoning_effort") + if effort: + # claude-agent-sdk >= 0.2.87 accepts effort=. On older SDKs the drop is + # recorded, not fatal: effort is a quality preference, unlike the + # security/spend kwargs enforced above. + kwargs["effort"] = effort output_schema = output_schema_from(task.output_schema, metadata) if output_schema is not None: kwargs["output_format"] = {"type": "json_schema", "schema": dict(output_schema)} diff --git a/tests/test_antigravity_adapter.py b/tests/test_antigravity_adapter.py index f9fb69b..478a9bd 100644 --- a/tests/test_antigravity_adapter.py +++ b/tests/test_antigravity_adapter.py @@ -1024,6 +1024,18 @@ async def test_antigravity_explicit_vertex_beats_ambient_api_key( assert config.kwargs["api_key"] is None +@pytest.mark.asyncio +async def test_antigravity_rejects_reasoning_effort(tmp_path: Path) -> None: + # No LocalAgentConfig field maps onto reasoning effort, so the first-class + # task field must raise instead of silently no-oping. + runtime = make_runtime(data_dir=tmp_path) + + with pytest.raises(UnsupportedTaskInputError) as exc_info: + await runtime.run(AgentTask(goal="task", reasoning_effort="high")) + + assert exc_info.value.field == "reasoning_effort" + + @pytest.mark.asyncio async def test_antigravity_rejects_allow_and_deny_list_together(tmp_path: Path) -> None: runtime = make_runtime(data_dir=tmp_path) diff --git a/tests/test_claude_adapter.py b/tests/test_claude_adapter.py index 1adeb05..0f0f2d6 100644 --- a/tests/test_claude_adapter.py +++ b/tests/test_claude_adapter.py @@ -33,6 +33,7 @@ class FakeClaudeOptions: mcp_servers: dict[str, Any] | None = None resume: str | None = None max_budget_usd: float | None = None + effort: str | None = None output_format: dict[str, Any] | None = None setting_sources: list[str] | None = None env: dict[str, str] | None = None @@ -471,6 +472,40 @@ async def test_claude_builds_full_request(tmp_path: Path) -> None: assert options.mcp_servers["fs"]["type"] == "stdio" +@pytest.mark.asyncio +async def test_claude_maps_reasoning_effort_to_effort() -> None: + runtime = ClaudeAgentRuntime( + query_func=make_query([assistant("ok"), result_message()]), + options_cls=FakeClaudeOptions, + ) + + await runtime.run(AgentTask(goal="x", reasoning_effort="xhigh")) + + assert RECORDED["options"].effort == "xhigh" + + +@pytest.mark.asyncio +async def test_claude_reasoning_effort_drop_is_recorded_not_fatal() -> None: + # effort is a quality preference: an SDK that predates effort= must record + # the drop, not fail the run (unlike permission/budget drift). + @dataclass + class NoEffortOptions: + model: str | None = None + permission_mode: str | None = None + allowed_tools: list[str] = field(default_factory=list) + disallowed_tools: list[str] = field(default_factory=list) + + runtime = ClaudeAgentRuntime( + query_func=make_query([assistant("ok"), result_message()]), + options_cls=NoEffortOptions, + ) + + result = await runtime.run(AgentTask(goal="x", reasoning_effort="high")) + + assert result.error is None + assert "effort" in result.metadata["dropped_options"] + + @pytest.mark.asyncio async def test_claude_resume_from_session_id() -> None: runtime = ClaudeAgentRuntime( diff --git a/tests/test_core.py b/tests/test_core.py index 4e5215f..a462daf 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -57,6 +57,21 @@ def test_registry_accepts_namespaced_third_party_kind() -> None: assert isinstance(runtime, FakeAgentRuntime) +def test_agent_task_model_fields_are_keyword_only() -> None: + from pathlib import Path + + # model/reasoning_effort were inserted mid-dataclass; kw_only keeps the + # positional layout that predates them, so the fourth positional argument is + # still working_directory (not model). + task = AgentTask("goal", "task-1", "system prompt", Path("/tmp")) + + assert task.system == "system prompt" + assert task.working_directory == Path("/tmp") + assert task.model is None + assert task.reasoning_effort is None + assert AgentTask("g", model="m-1", reasoning_effort="high").model == "m-1" + + def test_finish_reason_enum_compares_as_string() -> None: result = AgentResult(output="x", finish_reason=FinishReason.FAILED)