Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/agent_runtime_kit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
AvailabilityReason,
EventSink,
FilesystemAccess,
FinishReason,
McpServerConfig,
PermissionMode,
PermissionProfile,
Expand Down Expand Up @@ -51,6 +52,7 @@
"EventSink",
"FakeAgentRuntime",
"FilesystemAccess",
"FinishReason",
"McpServerConfig",
"PermissionMode",
"PermissionProfile",
Expand Down
33 changes: 32 additions & 1 deletion src/agent_runtime_kit/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,30 @@ 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.
"""

# 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"
MAX_TOKENS = "max_tokens"
INTERRUPTED = "interrupted"


class FilesystemAccess(str, Enum):
"""Filesystem mutation level requested by a task."""

Expand Down Expand Up @@ -231,6 +255,13 @@ 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. 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)
Expand All @@ -251,7 +282,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)
Expand Down
13 changes: 12 additions & 1 deletion src/agent_runtime_kit/adapters/antigravity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -583,7 +594,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()
Expand Down
8 changes: 7 additions & 1 deletion src/agent_runtime_kit/adapters/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down Expand Up @@ -375,7 +381,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:
Expand Down
4 changes: 2 additions & 2 deletions src/agent_runtime_kit/adapters/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 12 additions & 0 deletions tests/test_antigravity_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions tests/test_claude_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
25 changes: 25 additions & 0 deletions tests/test_codex_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@

from agent_runtime_kit import (
AgentCapabilities,
AgentResult,
AgentRuntime,
AgentRuntimeKind,
AgentTask,
FakeAgentRuntime,
FinishReason,
RuntimeNotRegisteredError,
UnsupportedTaskInputError,
create_default_registry,
Expand Down Expand Up @@ -55,6 +57,35 @@ 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)

# 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
# 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:
assert AgentRuntimeKind.coerce("claude-agent-sdk") is AgentRuntimeKind.CLAUDE_AGENT_SDK
assert AgentRuntimeKind.coerce("x-myorg-agent") == "x-myorg-agent"
Expand Down
Loading