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
3 changes: 3 additions & 0 deletions coding-agent/src/coding_agent/plugins/approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "",
Expand Down
59 changes: 51 additions & 8 deletions coding-agent/src/coding_agent/server/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
)
Expand All @@ -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.
Expand All @@ -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:
Expand All @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion coding-agent/src/coding_agent/server/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 10 additions & 1 deletion coding-agent/tests/coding_agent/plugins/test_approval.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import pytest
from coding_agent.plugins.approval import ApprovalPlugin, ApprovalPolicy
from agentkit.directive.types import Approve, Reject, AskUser

Expand All @@ -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(
Expand Down
12 changes: 10 additions & 2 deletions coding-agent/tests/coding_agent/test_runtime_config_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand All @@ -30,11 +30,19 @@ 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"
assert request.provider == "anthropic"
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",
}
68 changes: 68 additions & 0 deletions coding-agent/tests/ui/test_http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
Expand Down Expand Up @@ -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."""

Expand Down
16 changes: 15 additions & 1 deletion coding-agent/webui/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export default function App() {
[config.baseUrl, config.apiKey],
);

const patchConfig = (p: Partial<Config>) =>
const patchConfig = useCallback((p: Partial<Config>) => {
setConfig((c) => {
const next = { ...c, ...p };
try {
Expand All @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions coding-agent/webui/app/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,21 @@ export class AgentClient {
return (await r.json()).session_id as string;
}

async updateRuntimeConfig(
sessionId: string,
opts: { approval?: ApprovalPolicy },
): Promise<void> {
const body: Record<string, unknown> = {};
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<SessionSummary[]> {
const data = await (
await this.check(await fetch(this.url("/sessions"), { headers: this.headers() }))
Expand Down
Loading