diff --git a/coding-agent/src/coding_agent/plugins/approval.py b/coding-agent/src/coding_agent/plugins/approval.py index af33dd043..203a55703 100644 --- a/coding-agent/src/coding_agent/plugins/approval.py +++ b/coding-agent/src/coding_agent/plugins/approval.py @@ -28,6 +28,9 @@ def __init__( def hooks(self) -> dict[str, Callable[..., Any]]: return {"approve_tool_call": self.approve_tool_call} + def set_policy(self, policy: ApprovalPolicy) -> None: + self._policy = policy + def approve_tool_call( self, tool_name: str = "", diff --git a/coding-agent/src/coding_agent/server/http_server.py b/coding-agent/src/coding_agent/server/http_server.py index 77e3f20c2..158ded609 100644 --- a/coding-agent/src/coding_agent/server/http_server.py +++ b/coding-agent/src/coding_agent/server/http_server.py @@ -30,12 +30,13 @@ from sse_starlette.sse import EventSourceResponse from agentkit.config.loader import load_config as load_agent_toml -from agentkit.errors import ConfigError +from agentkit.errors import ConfigError, PluginError from agentkit.result.models import ArtifactRef, TurnResult from agentkit.result.reducers import result_from_turn_trace from agentkit.tape.extract import ToolCallRecord, TurnTrace, extract_turns from agentkit.tape.tape import Tape from coding_agent.approval import ApprovalPolicy +from coding_agent.plugins.approval import ApprovalPlugin from coding_agent.bee.launch import BeeLaunchRecord, PGBeeLaunchStore from coding_agent.bee.template_pack import ( BeePackRegistry, @@ -2524,6 +2525,40 @@ async def create_session( return SessionResponse(session_id=session_id) +def _live_approval_plugin(session: Any) -> ApprovalPlugin | None: + pipeline = getattr(session, "runtime_pipeline", None) + if pipeline is None: + return None + + registry = getattr(pipeline, "_registry", None) + if registry is None: + raise RuntimeError("session runtime pipeline has no plugin registry") + + try: + plugin = registry.get("approval") + except PluginError: + plugin = None + + if isinstance(plugin, ApprovalPlugin): + return plugin + + plugins = getattr(registry, "_plugins", None) + if isinstance(plugins, Mapping): + for candidate in plugins.values(): + if isinstance(candidate, ApprovalPlugin): + return candidate + for candidate in plugins.values(): + if getattr(candidate, "state_key", None) == "approval": + if isinstance(candidate, ApprovalPlugin): + return candidate + raise RuntimeError("approval plugin is not an ApprovalPlugin") + + raise RuntimeError("session runtime pipeline is missing approval plugin") + + +@app.post( + "/sessions/{session_id}/runtime-config", response_model=RuntimeConfigUpdateResponse +) @app.patch( "/sessions/{session_id}/runtime-config", response_model=RuntimeConfigUpdateResponse ) @@ -2534,10 +2569,10 @@ async def update_runtime_config( body: RuntimeConfigUpdateRequest, api_key: str | None = Depends(verify_api_key), ) -> RuntimeConfigUpdateResponse: - """Update the session runtime provider/model/base_url/thinking config in-place. + """Update the session runtime provider/model/base_url/thinking/approval config. Field semantics are three-state: omitted = leave unchanged, explicit - null = reset to default (base_url only; null model/provider/thinking + null = reset to default (base_url only; null model/provider/thinking/approval are rejected with 422), value = set. Applies changes next turn. The session's tape and history are preserved. @@ -2548,7 +2583,7 @@ async def update_runtime_config( if not provided: raise HTTPException( status_code=400, - detail="At least one of model, provider, base_url, or thinking must be provided", + detail="At least one of model, provider, base_url, thinking, or approval must be provided", ) try: @@ -2559,15 +2594,23 @@ async def update_runtime_config( if getattr(session, "turn_in_progress", False): raise HTTPException(status_code=409, detail="Turn already in progress") - # Apply thinking config directly on session's mutable dict reference. - # No pipeline rebuild needed — provider reads it per-turn from ctx.config. - thinking_only = not (provided & {"model", "provider", "base_url"}) + # Apply thinking and approval directly on live mutable session/runtime state. + # No pipeline rebuild needed for these: provider reads thinking per-turn from + # ctx.config, and approval is evaluated by the existing live plugin. + no_rebuild_update = not (provided & {"model", "provider", "base_url"}) if body.thinking is not None: session.thinking_config["enabled"] = body.thinking.enabled session.thinking_config["effort"] = body.thinking.effort + if body.approval is not None: + approval_policy = ApprovalPolicy(body.approval) + plugin = _live_approval_plugin(session) + if plugin is not None: + plugin.set_policy(approval_policy) + session.approval_policy = approval_policy + await session_manager._persist_session_async(session) try: - if thinking_only: + if no_rebuild_update: updated_session = session else: updated_session = await session_manager.replace_session_runtime_config( diff --git a/coding-agent/src/coding_agent/server/schemas.py b/coding-agent/src/coding_agent/server/schemas.py index 660ecddaf..67f143b94 100644 --- a/coding-agent/src/coding_agent/server/schemas.py +++ b/coding-agent/src/coding_agent/server/schemas.py @@ -89,8 +89,9 @@ class RuntimeConfigUpdateRequest(BaseModel): provider: ProviderName | None = None base_url: str | None = Field(None, min_length=1, max_length=500) thinking: ThinkingConfigSchema | None = None + approval: str | None = Field(None, pattern="^(yolo|interactive|auto)$") - @field_validator("model", "provider", "thinking") + @field_validator("model", "provider", "thinking", "approval") @classmethod def _reject_explicit_null(cls, value: Any, info: ValidationInfo) -> Any: if value is None: diff --git a/coding-agent/tests/coding_agent/plugins/test_approval.py b/coding-agent/tests/coding_agent/plugins/test_approval.py index 4e77c1220..d834e279b 100644 --- a/coding-agent/tests/coding_agent/plugins/test_approval.py +++ b/coding-agent/tests/coding_agent/plugins/test_approval.py @@ -1,4 +1,3 @@ -import pytest from coding_agent.plugins.approval import ApprovalPlugin, ApprovalPolicy from agentkit.directive.types import Approve, Reject, AskUser @@ -18,6 +17,16 @@ def test_yolo_policy_approves_all(self): result = plugin.approve_tool_call(tool_name="bash_run", arguments={"cmd": "ls"}) assert isinstance(result, Approve) + def test_set_policy_updates_approval_behavior(self): + plugin = ApprovalPlugin(policy=ApprovalPolicy.INTERACTIVE) + before = plugin.approve_tool_call(tool_name="bash_run", arguments={}) + assert isinstance(before, AskUser) + + plugin.set_policy(ApprovalPolicy.YOLO) + + after = plugin.approve_tool_call(tool_name="bash_run", arguments={}) + assert isinstance(after, Approve) + def test_interactive_policy_asks_user(self): plugin = ApprovalPlugin(policy=ApprovalPolicy.INTERACTIVE) result = plugin.approve_tool_call( diff --git a/coding-agent/tests/coding_agent/test_runtime_config_schema.py b/coding-agent/tests/coding_agent/test_runtime_config_schema.py index 86c5a3acf..11406ce28 100644 --- a/coding-agent/tests/coding_agent/test_runtime_config_schema.py +++ b/coding-agent/tests/coding_agent/test_runtime_config_schema.py @@ -17,7 +17,7 @@ def test_explicit_null_base_url_means_reset() -> None: assert request.model_fields_set == {"base_url"} -@pytest.mark.parametrize("field", ["model", "provider", "thinking"]) +@pytest.mark.parametrize("field", ["model", "provider", "thinking", "approval"]) def test_explicit_null_rejected_for_non_resettable_fields(field: str) -> None: with pytest.raises(ValidationError, match=f"{field} may not be null"): RuntimeConfigUpdateRequest.model_validate({field: None}) @@ -30,6 +30,7 @@ def test_value_fields_still_accepted() -> None: "provider": "anthropic", "base_url": "https://api.example.com", "thinking": {"enabled": True, "effort": "high"}, + "approval": "interactive", } ) assert request.model == "claude-sonnet-4-6" @@ -37,4 +38,11 @@ def test_value_fields_still_accepted() -> None: assert request.base_url == "https://api.example.com" assert request.thinking is not None assert request.thinking.effort == "high" - assert request.model_fields_set == {"model", "provider", "base_url", "thinking"} + assert request.approval == "interactive" + assert request.model_fields_set == { + "model", + "provider", + "base_url", + "thinking", + "approval", + } diff --git a/coding-agent/tests/ui/test_http_server.py b/coding-agent/tests/ui/test_http_server.py index 420a150da..79c29b1e9 100644 --- a/coding-agent/tests/ui/test_http_server.py +++ b/coding-agent/tests/ui/test_http_server.py @@ -24,6 +24,7 @@ from httpx import AsyncClient, ASGITransport from httpx_sse import aconnect_sse from starlette.requests import Request +from agentkit.directive.types import Approve, AskUser from agentkit.errors import ConfigError from agentkit.checkpoint.models import CheckpointMeta from agentkit.providers.models import DoneEvent, TextEvent, ToolCallEvent @@ -36,6 +37,7 @@ from coding_agent.adapter.types import StopReason, TurnOutcome from coding_agent.approval import ApprovalPolicy from coding_agent.approval.store import ApprovalStore +from coding_agent.plugins.approval import ApprovalPlugin from coding_agent.core.config import settings from coding_agent.environment import CloudCommandResult, CloudEnvironment from coding_agent.stores.runtime_store import ( @@ -2403,6 +2405,72 @@ async def fake_sleep(delay: float) -> None: assert events == ["renew", "sleep"] +class TestRuntimeConfigUpdate: + """Tests for runtime config update endpoint.""" + + async def test_approval_only_updates_live_plugin_without_runtime_replacement( + self, client, monkeypatch + ): + response = await client.post("/sessions", json={"approval_policy": "auto"}) + session_id = response.json()["session_id"] + await session_manager.ensure_session_runtime(session_id) + session = session_manager.get_session(session_id) + plugin = session.runtime_pipeline._registry.get("approval") + assert isinstance(plugin, ApprovalPlugin) + assert isinstance( + plugin.approve_tool_call(tool_name="bash_run", arguments={}), + AskUser, + ) + + async def fail_replace_session_runtime_config(*args, **kwargs): + del args, kwargs + raise AssertionError("approval update should not replace runtime") + + monkeypatch.setattr( + session_manager, + "replace_session_runtime_config", + fail_replace_session_runtime_config, + ) + + update = await client.post( + f"/sessions/{session_id}/runtime-config", + json={"approval": "yolo"}, + ) + + assert update.status_code == 200 + assert session.approval_policy == ApprovalPolicy.YOLO + assert isinstance( + plugin.approve_tool_call(tool_name="bash_run", arguments={}), + Approve, + ) + + async def test_approval_null_is_rejected(self, client): + response = await client.post("/sessions", json={}) + session_id = response.json()["session_id"] + + update = await client.post( + f"/sessions/{session_id}/runtime-config", + json={"approval": None}, + ) + + assert update.status_code == 422 + assert "approval may not be null" in update.text + + async def test_approval_update_rejects_turn_in_progress(self, client): + response = await client.post("/sessions", json={}) + session_id = response.json()["session_id"] + session = session_manager.get_session(session_id) + session.turn_in_progress = True + + update = await client.post( + f"/sessions/{session_id}/runtime-config", + json={"approval": "interactive"}, + ) + + assert update.status_code == 409 + assert update.json()["detail"] == "Turn already in progress" + + class TestPromptStreaming: """Tests for prompt streaming endpoint.""" diff --git a/coding-agent/webui/app/src/App.tsx b/coding-agent/webui/app/src/App.tsx index ba68bf64c..68218debb 100644 --- a/coding-agent/webui/app/src/App.tsx +++ b/coding-agent/webui/app/src/App.tsx @@ -82,7 +82,7 @@ export default function App() { [config.baseUrl, config.apiKey], ); - const patchConfig = (p: Partial) => + const patchConfig = useCallback((p: Partial) => { setConfig((c) => { const next = { ...c, ...p }; try { @@ -92,6 +92,20 @@ export default function App() { } return next; }); + const activeSessionId = activeSessionRef.current; + if (p.approval !== undefined && activeSessionId) { + void client.updateRuntimeConfig(activeSessionId, { approval: p.approval }).catch((e) => { + setItems((prev) => [ + ...prev, + { + id: `e${Date.now()}`, + kind: "error", + text: `approval policy update failed: ${msg(e)}`, + }, + ]); + }); + } + }, [client]); const setSessionLoadingState = useCallback((value: boolean) => { sessionLoadingRef.current = value; diff --git a/coding-agent/webui/app/src/lib/api.ts b/coding-agent/webui/app/src/lib/api.ts index 729fc188e..8aef09a51 100644 --- a/coding-agent/webui/app/src/lib/api.ts +++ b/coding-agent/webui/app/src/lib/api.ts @@ -56,6 +56,21 @@ export class AgentClient { return (await r.json()).session_id as string; } + async updateRuntimeConfig( + sessionId: string, + opts: { approval?: ApprovalPolicy }, + ): Promise { + const body: Record = {}; + if (opts.approval !== undefined) body.approval = opts.approval; + await this.check( + await fetch(this.url(`/sessions/${sessionId}/runtime-config`), { + method: "POST", + headers: this.headers(true), + body: JSON.stringify(body), + }), + ); + } + async listSessions(): Promise { const data = await ( await this.check(await fetch(this.url("/sessions"), { headers: this.headers() }))