Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0533639
feat: introduce per-provider autonomy policy
May 23, 2026
f212aa1
Merge remote-tracking branch 'origin/develop' into feat/autonomy-per-…
May 23, 2026
3fb3d98
feat: route direct API autonomous gate through AutonomyPolicy
May 23, 2026
c6e4bc4
feat: route qa_reviewer and qa_fixer through the runtime layer
May 23, 2026
affe5b0
feat: split runtime capability from policy promotion
May 23, 2026
e099b7b
feat: move runtime artifacts to .auto-claude/runtime with legacy fall…
May 23, 2026
f1859b1
docs: ADR-006 propose user-facing autonomy levels
May 23, 2026
bbf4dba
feat: implement ADR-006 user-facing autonomy level
May 23, 2026
360eac1
feat: per-provider native tool capability for Ollama and ZhipuAI
May 23, 2026
c75d37b
feat: declared native tool capability for OpenAI/OpenRouter/LiteLLM/G…
May 23, 2026
1a2cc5c
feat: MCP execution capability grant + per-server execution smoke
May 24, 2026
9bcef83
feat: mutating subagents capability/policy wiring (Phase 1.2)
May 24, 2026
6d68f21
feat: cross-platform sandbox skeleton + capability/policy wiring (Pha…
May 24, 2026
1f4c689
Merge remote-tracking branch 'origin/develop' into feat/autonomy-per-…
May 24, 2026
a259b24
style: fix ruff findings on PR-touched files
May 24, 2026
e278d0d
style: apply ruff format on PR-touched files
May 24, 2026
af72c83
fix: address PR #267 review (CodeRabbit + CodeQL)
OBenner May 24, 2026
49de67d
fix: restore GoogleProvider class structure (_google_model_identifier…
OBenner May 24, 2026
0e534f8
fix: GoogleAgentSession.provider_supports_native_tools defaults to Tr…
OBenner May 24, 2026
3891406
style: ruff format for google.py (line wrap)
OBenner May 24, 2026
1740fca
feat: wire mcp_execution_smoke into provider e2e suite + sandbox refa…
OBenner May 25, 2026
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
8 changes: 8 additions & 0 deletions apps/backend/agents/runtime/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from .capabilities import (
RuntimeCapabilities,
RuntimeCapabilityError,
RuntimePolicy,
RuntimeRequirements,
)
from .cli_profiles import (
Expand Down Expand Up @@ -86,6 +87,10 @@
registered_external_mcp_servers,
resolve_runtime_mcp_support,
)
from .mcp_execution_smoke import (
McpExecutionSmokeResult,
mcp_execution_smoke,
)
from .modes import RuntimeMode, get_runtime_mode, normalize_runtime_mode
from .result import AgentRunResult
from .runner_router import (
Expand Down Expand Up @@ -113,6 +118,7 @@
"RuntimeCapabilities",
"RuntimeCapabilityError",
"RuntimeFallbackDecision",
"RuntimePolicy",
"RuntimeRequirements",
"RUNTIME_FALLBACK_ENV",
"RUNNER_ROUTER_ENV",
Expand Down Expand Up @@ -155,6 +161,8 @@
"MAX_SUBAGENT_ATTEMPTS",
"build_external_mcp_health_matrix",
"build_mcp_bridge_plan",
"McpExecutionSmokeResult",
"mcp_execution_smoke",
"check_external_mcp_contract",
"check_external_mcp_contracts",
"check_mcp_tool_permission",
Expand Down
49 changes: 46 additions & 3 deletions apps/backend/agents/runtime/adapters/direct_api_autonomous.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,54 @@
"""Direct API autonomous runtime adapter."""
"""Direct API autonomous runtime adapter.

from ..capabilities import RuntimeCapabilities
This adapter wraps the Generic Edit engine and advertises its honest
capability set (:meth:`RuntimeCapabilities.promoted_edit`) plus a
:class:`RuntimePolicy` that flips ``promoted_to_full_autonomous`` to
``True``. The factory selects this adapter only when the
:func:`resolve_direct_api_autonomous_gate` evidence gate passes for the
caller's provider, so the promotion is always backed by recorded e2e and
reliability evidence rather than by a hardcoded capability claim.

Phase 1.1: when ``AUTO_CODE_AUTONOMY=safe`` (or ``bold``), or when the
operator opts in via ``AUTO_CODE_EXTERNAL_MCP_CLIENT=true``, the runtime
also flips ``mcp_execution_enabled`` so direct providers honestly
declare an ``mcp`` capability. The flag is computed from
:func:`resolve_autonomy_settings` rather than hardcoded so explicit
low-level env overrides keep winning.
"""

from collections.abc import Mapping

from core.autonomy_level import resolve_autonomy_settings

from ..capabilities import RuntimeCapabilities, RuntimePolicy
from .generic_edit import GenericEditRuntimeSession


def _build_runtime_policy(
env: Mapping[str, str] | None = None,
) -> RuntimePolicy:
"""Build the runtime policy for a promoted direct-API session."""
settings = resolve_autonomy_settings(env=env)
return RuntimePolicy(
promoted_to_full_autonomous=True,
mcp_execution_enabled=settings.external_mcp_client_enabled,
mutating_subagents_enabled=settings.mutating_subagents_enabled,
sandbox_enabled=settings.sandbox_enabled,
)


class DirectApiAutonomousRuntimeSession(GenericEditRuntimeSession):
"""Promoted direct-provider runtime backed by the Generic Edit engine."""

name = "direct_api_autonomous"
capabilities = RuntimeCapabilities.direct_api_autonomous()
capabilities = RuntimeCapabilities.promoted_edit()

@property
def runtime_policy(self) -> RuntimePolicy: # type: ignore[override]
"""Resolve the runtime policy lazily so env changes take effect.

Built per-call rather than at class instantiation time so tests
and operators that flip ``AUTO_CODE_AUTONOMY`` between sessions
see the updated grant without needing to reinstantiate.
"""
return _build_runtime_policy()
24 changes: 21 additions & 3 deletions apps/backend/agents/runtime/adapters/generic_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -1624,9 +1624,27 @@ def _unresolved_partial_failure_finish_result(
)

def _supports_native_tool_calls(self) -> bool:
return callable(
getattr(self.agent_session, "complete_with_tool_calls", None)
) and callable(getattr(self.agent_session, "add_tool_result", None))
if not (
callable(getattr(self.agent_session, "complete_with_tool_calls", None))
and callable(getattr(self.agent_session, "add_tool_result", None))
):
return False
# Per-provider declared support: some providers know which of
# their models can actually drive a tool loop (Ollama, ZhipuAI).
# Skipping the native loop up-front avoids paying for an
# unsupported-tools error round trip.
provider_supports = getattr(
self.agent_session, "provider_supports_native_tools", None
)
if callable(provider_supports):
try:
model = getattr(self.agent_session, "model", None)
if not provider_supports(model):
return False
except Exception:
# If detection itself raises, fall back to runtime probing.
pass
return True

def _provider_tool_schemas(self) -> list[dict[str, Any]]:
schemas = local_action_tool_schemas()
Expand Down
166 changes: 147 additions & 19 deletions apps/backend/agents/runtime/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,17 @@

The provider layer answers "which model can we call?" The runtime layer answers
"which workspace actions can this session perform safely?"

``RuntimeCapabilities`` describes what the runtime physically supports.
Promotion of a runtime to satisfy a stricter requirement (for example
treating a Generic Edit session as ``full_autonomous`` after a provider
clears the AutonomyPolicy gate) is a separate decision expressed via
``RuntimePolicy``. Capabilities must never claim a flag that the runtime
cannot back; promotion lives in policy so operators can see the two
forces independently.
"""

import warnings
from dataclasses import dataclass


Expand Down Expand Up @@ -92,20 +101,37 @@ def generic_edit(cls) -> "RuntimeCapabilities":
apply_patch=True,
)

@classmethod
def promoted_edit(cls) -> "RuntimeCapabilities":
"""Capabilities for a Generic Edit session that may be policy-promoted.

Physically identical to :meth:`generic_edit`. Promotion to satisfy
``full_coder``/``planner`` requirements is represented separately
via :class:`RuntimePolicy` so the capability flags stay honest.
"""
return cls.generic_edit()

@classmethod
def direct_api_autonomous(cls) -> "RuntimeCapabilities":
"""Capabilities for promoted direct API providers using local tools."""
return cls(
text_completion=True,
streaming_text=True,
structured_output=True,
native_tool_loop=True,
function_tools=True,
filesystem_read=True,
filesystem_edit=True,
shell=True,
apply_patch=True,
"""Deprecated alias for :meth:`promoted_edit`.

The previous implementation set ``native_tool_loop=True`` to make
:class:`DirectApiAutonomousRuntimeSession` satisfy ``full_coder``
requirements through the capability check. That conflated
capability (what the runtime physically does) with policy (whether
the AutonomyPolicy gate promoted the runtime). Use
:meth:`promoted_edit` for the honest capability and combine it
with a :class:`RuntimePolicy` carrying
``promoted_to_full_autonomous=True`` for the promotion decision.
"""
warnings.warn(
"RuntimeCapabilities.direct_api_autonomous() is deprecated; "
"use RuntimeCapabilities.promoted_edit() combined with a "
"RuntimePolicy that carries promoted_to_full_autonomous=True.",
DeprecationWarning,
stacklevel=2,
)
return cls.promoted_edit()

def available(self) -> list[str]:
"""Return capability names set to true."""
Expand All @@ -115,17 +141,107 @@ def available(self) -> list[str]:
if isinstance(value, bool) and value
]

def missing(self, requirements: "RuntimeRequirements") -> list[str]:
"""Return required capabilities this runtime does not provide."""
def missing(
self,
requirements: "RuntimeRequirements",
*,
policy: "RuntimePolicy | None" = None,
) -> list[str]:
"""Return required capabilities this runtime does not provide.

``policy`` may grant the runtime additional satisfied capabilities
through evidence-based promotion (see :class:`RuntimePolicy`).
"""
granted = policy.granted_capabilities() if policy is not None else frozenset()
return [
capability
for capability in requirements.required
if not bool(getattr(self, capability, False))
if not bool(getattr(self, capability, False)) and capability not in granted
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def supports(self, requirements: "RuntimeRequirements") -> bool:
"""Return true when all required capabilities are available."""
return not self.missing(requirements)
def supports(
self,
requirements: "RuntimeRequirements",
*,
policy: "RuntimePolicy | None" = None,
) -> bool:
"""Return true when all required capabilities are available.

Pass ``policy`` to honor evidence-based promotion (for example a
Generic Edit runtime that the AutonomyPolicy gate promoted to
``full_autonomous`` for a specific provider).
"""
return not self.missing(requirements, policy=policy)


# Capabilities the promoted-edit policy grants the runtime when the
# AutonomyPolicy gate signs off. ``native_tool_loop`` is granted because
# the underlying Generic Edit engine attempts the provider's native tool
# API and falls back to its JSON action loop when the provider does not
# support tools; the gate evidence proves the provider/model combination
# can drive that loop end-to-end. Promotion alone does NOT grant
# ``subagents`` or ``sandbox``: those still require Phase 1.2 and Phase
# 1.3 capability work in docs/roadmap/non-claude-provider-autonomy.md.
_PROMOTED_FULL_AUTONOMOUS_GRANTS: frozenset[str] = frozenset({"native_tool_loop"})
# Additional capability the policy grants once the external MCP client
# bridge is enabled. Phase 1.1: direct API providers can reach Graphiti,
# Linear, Electron, Puppeteer, and custom MCP servers through the
# provider-neutral bridge so they match the Claude SDK MCP surface for
# tool discovery and invocation.
_MCP_EXECUTION_GRANTS: frozenset[str] = frozenset({"mcp"})
# Phase 1.2: mutating subagents. The orchestrator already produces
# isolated child contexts and per-child artifacts; transactional
# boundaries and conflict-aware merge are tracked separately in the
# runtime_subagent_mutation_policy matrix. This grant lets operators
# (via AutonomyLevel.BOLD or AUTO_CODE_MUTATING_SUBAGENTS=true) opt in
# once the merge protocol scaffolding lands.
_MUTATING_SUBAGENT_GRANTS: frozenset[str] = frozenset({"subagents"})
# Phase 1.3: sandbox. The cross-platform sandbox skeleton in
# ``core/sandbox.py`` detects whether Seatbelt (macOS), bubblewrap
# (Linux), or AppContainer (Windows) is available. The policy grant
# only fires when ``sandbox_enabled=True``, which the autonomy layer
# only sets when (a) the level requests sandboxing and (b) the host
# actually exposes a real backend; otherwise the runtime keeps
# ``sandbox`` missing so the capability error is honest.
_SANDBOX_GRANTS: frozenset[str] = frozenset({"sandbox"})


@dataclass(frozen=True)
class RuntimePolicy:
"""Policy-driven attributes applied on top of :class:`RuntimeCapabilities`.

Capability and policy are intentionally separate concerns: capability
describes what the runtime can physically do, policy describes what the
operator (or an evidence gate) has decided to allow on top of that.
"""

promoted_to_full_autonomous: bool = False
mcp_execution_enabled: bool = False
mutating_subagents_enabled: bool = False
sandbox_enabled: bool = False

def granted_capabilities(self) -> frozenset[str]:
"""Return capability names the policy treats as satisfied."""
granted: set[str] = set()
if self.promoted_to_full_autonomous:
granted |= _PROMOTED_FULL_AUTONOMOUS_GRANTS
if self.mcp_execution_enabled:
granted |= _MCP_EXECUTION_GRANTS
if self.mutating_subagents_enabled:
granted |= _MUTATING_SUBAGENT_GRANTS
if self.sandbox_enabled:
granted |= _SANDBOX_GRANTS
return frozenset(granted)

def to_dict(self) -> dict[str, object]:
"""Return a JSON-safe policy snapshot."""
return {
"promoted_to_full_autonomous": self.promoted_to_full_autonomous,
"mcp_execution_enabled": self.mcp_execution_enabled,
"mutating_subagents_enabled": self.mutating_subagents_enabled,
"sandbox_enabled": self.sandbox_enabled,
"granted_capabilities": sorted(self.granted_capabilities()),
}


@dataclass(frozen=True)
Expand Down Expand Up @@ -205,12 +321,14 @@ def __init__(
runtime_name: str,
requirements: RuntimeRequirements,
capabilities: RuntimeCapabilities,
policy: RuntimePolicy | None = None,
):
self.provider_name = provider_name
self.runtime_name = runtime_name
self.requirements = requirements
self.capabilities = capabilities
self.missing = capabilities.missing(requirements)
self.policy = policy
self.missing = capabilities.missing(requirements, policy=policy)
super().__init__(self._build_message())

def _build_message(self) -> str:
Expand All @@ -222,12 +340,22 @@ def _build_message(self) -> str:
else "- none"
)
missing = "\n".join(f"- {name}" for name in self.missing)
policy_block = ""
if self.policy is not None:
granted = sorted(self.policy.granted_capabilities())
granted_text = "\n".join(f"- {name}" for name in granted) or "- none"
policy_block = (
f"\nPolicy-granted capabilities:\n{granted_text}\n"
f"Promoted to full autonomous: "
f"{self.policy.promoted_to_full_autonomous}\n"
)
return (
f"Cannot run {self.requirements.mode} with provider={self.provider_name} "
f"runtime={self.runtime_name}.\n\n"
f"Missing capabilities:\n{missing}\n\n"
f"Required capabilities:\n{required}\n\n"
f"Available capabilities:\n{available}\n\n"
f"Available capabilities:\n{available}\n"
f"{policy_block}\n"
"Use Claude Agent SDK for full autonomous coding today, or run a "
"limited generic_edit, patch_proposal, or analysis_only phase with "
"a compatible runtime."
Expand Down
Loading
Loading