diff --git a/coding-agent/src/coding_agent/acp/__init__.py b/coding-agent/src/coding_agent/acp/__init__.py index 99e09214a..d226f6d23 100644 --- a/coding-agent/src/coding_agent/acp/__init__.py +++ b/coding-agent/src/coding_agent/acp/__init__.py @@ -1,5 +1,5 @@ """Agent Client Protocol stdio adapter for Coding Agent.""" -from coding_agent.acp.server import AcpServer, run_stdio +from coding_agent.acp.server import AcpMode, AcpServer, run_stdio -__all__ = ["AcpServer", "run_stdio"] +__all__ = ["AcpMode", "AcpServer", "run_stdio"] diff --git a/coding-agent/src/coding_agent/acp/server.py b/coding-agent/src/coding_agent/acp/server.py index 530387c05..ae479fbf2 100644 --- a/coding-agent/src/coding_agent/acp/server.py +++ b/coding-agent/src/coding_agent/acp/server.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import AsyncIterator, Awaitable, Callable, Iterable +from dataclasses import dataclass import json from pathlib import Path import sys @@ -26,6 +27,32 @@ ACP_DEFAULT_MODE_ID = "default" +@dataclass(frozen=True) +class AcpMode: + """A named provider/model preset exposed as an ACP session mode.""" + + id: str + name: str + description: str = "" + provider: str | None = None + model: str | None = None + base_url: str | None = None + + +def default_acp_modes() -> list[AcpMode]: + """Return the default single-mode configuration. + + Override by passing custom modes to AcpServer or via ACP_TOML config. + """ + return [ + AcpMode( + id=ACP_DEFAULT_MODE_ID, + name="Default", + description="Standard Coding Agent behavior.", + ), + ] + + class AcpSessionManager(Protocol): async def create_session(self, **kwargs: Any) -> str: ... @@ -72,6 +99,15 @@ async def update_session_additional_directories( additional_directories: list[str], ) -> None: ... + async def replace_session_runtime_config( + self, + session_id: str, + *, + model_name: str | None = None, + provider_name: str | None = None, + base_url: str | None = None, + ) -> Any: ... + class JsonRpcError(Exception): def __init__(self, code: int, message: str) -> None: @@ -92,6 +128,7 @@ def __init__( model_name: str | None = None, base_url: str | None = None, max_steps: int = 30, + modes: list[AcpMode] | None = None, ) -> None: self._session_manager = session_manager self._emit = emit @@ -101,6 +138,9 @@ def __init__( self._model_name = model_name self._base_url = base_url self._max_steps = max_steps + self._modes = ( + modes if modes is not None else default_acp_modes() + ) self._active_prompt_sessions: set[str] = set() self._cancelled_prompt_sessions: set[str] = set() @@ -173,10 +213,10 @@ async def _dispatch(self, method: str, params: JSONObject) -> object: return await self._session_new(params) if method == "session/load": await self._session_load(params) - return {"modes": _session_mode_state()} + return {"modes": _session_mode_state(self._modes)} if method == "session/resume": await self._session_resume(params) - return {"modes": _session_mode_state()} + return {"modes": _session_mode_state(self._modes)} if method == "session/list": return await self._session_list(params) if method == "session/close": @@ -213,6 +253,7 @@ def _initialize(self, params: JSONObject) -> JSONObject: "additionalDirectories": {}, }, }, + "modes": _session_mode_state(self._modes), "agentInfo": { "name": "coding-agent", "title": "Coding Agent", @@ -260,7 +301,7 @@ async def _session_new(self, params: JSONObject) -> JSONObject: mcp_servers=mcp_servers, additional_directories=additional_directories, ) - return {"sessionId": session_id, "modes": _session_mode_state()} + return {"sessionId": session_id, "modes": _session_mode_state(self._modes)} async def _session_list(self, params: JSONObject) -> JSONObject: cursor = params.get("cursor") @@ -399,12 +440,32 @@ async def _session_set_mode(self, params: JSONObject) -> None: -32602, "session/set_mode params.sessionId must be a string" ) mode_id = params.get("modeId") - if mode_id != ACP_DEFAULT_MODE_ID: + if not isinstance(mode_id, str) or not mode_id: + raise JsonRpcError( + -32602, "session/set_mode params.modeId must be a string" + ) + + mode = next((m for m in self._modes if m.id == mode_id), None) + if mode is None: raise JsonRpcError( -32602, - f"session/set_mode params.modeId must be {ACP_DEFAULT_MODE_ID}", + f"session/set_mode unknown modeId: {mode_id}", ) + await self._session_manager.get_session_async(session_id) + try: + await self._session_manager.replace_session_runtime_config( + session_id, + model_name=mode.model, + provider_name=mode.provider, + base_url=mode.base_url, + ) + except RuntimeError as exc: + if str(exc) == "turn already in progress": + raise JsonRpcError(-32000, "Turn already in progress") from exc + raise JsonRpcError(-32603, str(exc)) from exc + except ValueError as exc: + raise JsonRpcError(-32603, str(exc)) from exc async def _session_set_config_option(self, params: JSONObject) -> JSONObject: session_id = params.get("sessionId") @@ -770,16 +831,19 @@ def _session_additional_directories(session: object) -> list[str]: return list(value) -def _session_mode_state() -> JSONObject: +def _session_mode_state(modes: Iterable[AcpMode] | None = None) -> JSONObject: + available = [ + { + "id": m.id, + "name": m.name, + "description": m.description or f"Provider: {m.provider or 'any'}, Model: {m.model or 'any'}", + } + for m in (modes or default_acp_modes()) + ] + default_id = available[0]["id"] if available else ACP_DEFAULT_MODE_ID return { - "currentModeId": ACP_DEFAULT_MODE_ID, - "availableModes": [ - { - "id": ACP_DEFAULT_MODE_ID, - "name": "Default", - "description": "Standard Coding Agent behavior.", - } - ], + "currentModeId": default_id, + "availableModes": available, } diff --git a/coding-agent/src/coding_agent/cli/acp_command.py b/coding-agent/src/coding_agent/cli/acp_command.py index 1e8e9bdd3..0e2d36229 100644 --- a/coding-agent/src/coding_agent/cli/acp_command.py +++ b/coding-agent/src/coding_agent/cli/acp_command.py @@ -1,13 +1,14 @@ from __future__ import annotations import asyncio +import json import time import uuid import click from agentkit.tracing import configure_tracing -from coding_agent.acp import AcpServer, run_stdio +from coding_agent.acp import AcpServer, AcpMode, run_stdio from coding_agent.approval import ApprovalPolicy from coding_agent.cli.local_runtime import create_local_cli_session_manager from coding_agent.local_storage import ( @@ -29,6 +30,30 @@ def _shared_cli_arg(ctx: click.Context, name: str) -> str | None: return value if isinstance(value, str) and value else None +def _parse_acp_mode(mode_str: str) -> AcpMode: + """Parse a mode string in format 'id:name[:provider[:model[:base_url]]]'.""" + if not mode_str: + raise click.BadParameter("--acp-mode value must not be empty") + parts = mode_str.split(":", 4) + mode_id = parts[0] + if not mode_id: + raise click.BadParameter( + f"--acp-mode '{mode_str}' has empty mode id" + ) + name = parts[1] if len(parts) > 1 else mode_id + provider = parts[2] if len(parts) > 2 else None + model = parts[3] if len(parts) > 3 else None + base_url = parts[4] if len(parts) > 4 else None + return AcpMode( + id=mode_id, + name=name, + description=f"Provider: {provider or 'any'}, Model: {model or 'any'}", + provider=provider, + model=model, + base_url=base_url, + ) + + @click.command("acp") @click.option( "--approval", @@ -38,16 +63,46 @@ def _shared_cli_arg(ctx: click.Context, name: str) -> str | None: help="Tool approval policy for ACP sessions.", ) @click.option("--max-steps", default=30, type=int, show_default=True) -def acp(approval_policy: str, max_steps: int) -> None: +@click.option( + "--acp-mode", + "acp_modes", + multiple=True, + default=None, + help=( + "Define an ACP mode preset (repeatable). Format: " + "id:name[:provider[:model[:base_url]]]. " + "When set, these replace the single Default mode." + ), +) +@click.option( + "--acp-modes-json", + default=None, + help="JSON string defining ACP modes array (alternative to --acp-mode).", +) +def acp( + approval_policy: str, + max_steps: int, + acp_modes: tuple[str, ...], + acp_modes_json: str | None, +) -> None: """Run Coding Agent as an ACP stdio agent.""" - - asyncio.run(_run_acp_stdio(click.get_current_context(), approval_policy, max_steps)) + asyncio.run( + _run_acp_stdio( + click.get_current_context(), + approval_policy, + max_steps, + acp_modes, + acp_modes_json, + ) + ) async def _run_acp_stdio( ctx: click.Context, approval_policy: str, max_steps: int, + acp_modes: tuple[str, ...], + acp_modes_json: str | None, ) -> None: configure_tracing(enabled=False) data_dir = local_data_dir() @@ -57,6 +112,19 @@ async def _run_acp_stdio( owner_id=f"acp-stdio:{uuid.uuid4().hex}", fencing_token=time.time_ns(), ) + + modes: list[AcpMode] | None = None + if acp_modes: + modes = [_parse_acp_mode(m) for m in acp_modes] + elif acp_modes_json: + try: + raw = json.loads(acp_modes_json) + modes = [AcpMode(**m) for m in raw] + except (json.JSONDecodeError, TypeError, KeyError) as exc: + raise click.BadParameter( + f"Invalid --acp-modes-json: {exc}" + ) from exc + try: await manager.start_owner_lease_renewal() server = AcpServer( @@ -66,6 +134,7 @@ async def _run_acp_stdio( model_name=_shared_cli_arg(ctx, "model"), base_url=_shared_cli_arg(ctx, "base_url"), max_steps=max_steps, + modes=modes, ) await run_stdio(server) finally: diff --git a/coding-agent/src/coding_agent/cli/commands.py b/coding-agent/src/coding_agent/cli/commands.py index e2af1ace8..b277cbde8 100644 --- a/coding-agent/src/coding_agent/cli/commands.py +++ b/coding-agent/src/coding_agent/cli/commands.py @@ -107,27 +107,40 @@ async def cmd_plan(args: list[str], context: dict[str, Any]) -> None: print_pt("No active plan. Use todo_write to create one.", output=_out()) -@command("model", "Show or change model") +@command("model", "Show or change model. Syntax: [provider:]model") async def cmd_model(args: list[str], context: dict[str, Any]) -> None: if args: - new_model = args[0] - if len(new_model) < 2 or len(new_model) > 100: + raw = args[0] + if len(raw) < 2 or len(raw) > 100: print_html( - f"Invalid model name: {_h(new_model)}", + f"Invalid model name: {_h(raw)}", output=_out(), ) return + # Parse optional "provider:model" syntax + provider_override: str | None = None + model_name = raw + if ":" in raw: + provider_override, _, model_name = raw.partition(":") + if not provider_override or not model_name: + print_html( + f"Invalid syntax. Use [provider:]model. Got: {_h(raw)}", + output=_out(), + ) + return + context["provider_override"] = provider_override + + context["model"] = model_name on_model_change = cast( - Callable[[str], Coroutine[Any, Any, None]] | None, + Callable[[str, str | None], Coroutine[Any, Any, None]] | None, context.get("on_model_change"), ) if callable(on_model_change): - await on_model_change(new_model) - else: - context["model"] = new_model + await on_model_change(model_name, provider_override) output = _out() + label = f"{provider_override}:{model_name}" if provider_override else model_name print_html( - f"Model changed to: {_h(new_model)}", + f"Model changed to: {_h(label)}", output=output, ) print_html( @@ -136,8 +149,10 @@ async def cmd_model(args: list[str], context: dict[str, Any]) -> None: ) else: current = context.get("model", "unknown") + provider_current = context.get("provider", None) + label = f"{provider_current}:{current}" if provider_current else current print_html( - f"Current model: {_h(current)}", + f"Current model: {_h(label)}", output=_out(), ) diff --git a/coding-agent/src/coding_agent/cli/local_runtime.py b/coding-agent/src/coding_agent/cli/local_runtime.py index 31ccc20bd..69e558822 100644 --- a/coding-agent/src/coding_agent/cli/local_runtime.py +++ b/coding-agent/src/coding_agent/cli/local_runtime.py @@ -98,6 +98,8 @@ async def replace_session_runtime_config( session_id: str, *, model_name: str, + provider_name: str | None = None, + base_url: str | None = None, ) -> Any: ... async def restore_checkpoint(self, session_id: str, checkpoint_id: str) -> None: ... @@ -252,10 +254,14 @@ async def replace_session_runtime_config( session_id: str, *, model_name: str, + provider_name: str | None = None, + base_url: str | None = None, ) -> Any: return await self._delegate.replace_session_runtime_config( session_id, model_name=model_name, + provider_name=provider_name, + base_url=base_url, ) async def restore_checkpoint(self, session_id: str, checkpoint_id: str) -> None: diff --git a/coding-agent/src/coding_agent/cli/repl.py b/coding-agent/src/coding_agent/cli/repl.py index ed3621da8..162229ba4 100644 --- a/coding-agent/src/coding_agent/cli/repl.py +++ b/coding-agent/src/coding_agent/cli/repl.py @@ -210,6 +210,8 @@ def _sync_config_from_managed_session(self, managed_session: Any) -> None: "kimi-code", "kimi-code-anthropic", "deepseek", + "stepfun", + "codex", } if provider_name not in allowed_providers: raise RuntimeError( @@ -253,6 +255,7 @@ async def _switch_session(self, session_id: str) -> None: self._sync_config_from_managed_session(managed_session) self.context["session_id"] = managed_session.id self.context["model"] = self.config.model + self.context["provider"] = self.config.provider self._pipeline = managed_session.runtime_pipeline self._pipeline_ctx = managed_session.runtime_ctx self._pipeline_adapter = managed_session.runtime_adapter @@ -262,19 +265,31 @@ async def _switch_session(self, session_id: str) -> None: self._pipeline_ctx.config["wire_consumer"] = self._consumer self._refresh_command_context_from_pipeline_ctx(managed_session.runtime_ctx) - async def _change_model_for_next_turn(self, model_name: str) -> None: - if self.config.model == model_name: + async def _change_model_for_next_turn( + self, + model_name: str, + provider_name: str | None = None, + ) -> None: + if self.config.model == model_name and ( + provider_name is None or self.config.provider == provider_name + ): return old_model_name = self.config.model + old_provider = self.config.provider session_id = self.context.get("session_id") if not isinstance(session_id, str): self.config.model = model_name self.context["model"] = model_name + if provider_name is not None: + self.config.provider = provider_name + self.context["provider"] = provider_name try: self._setup_agent() except Exception: self.config.model = old_model_name + self.config.provider = old_provider self.context["model"] = old_model_name + self.context["provider"] = old_provider raise return @@ -283,16 +298,20 @@ async def _change_model_for_next_turn(self, model_name: str) -> None: await self._session_manager.replace_session_runtime_config( session_id, model_name=model_name, + provider_name=provider_name, ) ) except Exception: self.config.model = old_model_name + self.config.provider = old_provider self.context["model"] = old_model_name + self.context["provider"] = old_provider raise self._sync_config_from_managed_session(managed_session) self.context["session_id"] = managed_session.id self.context["model"] = self.config.model + self.context["provider"] = self.config.provider self._pipeline = managed_session.runtime_pipeline self._pipeline_ctx = managed_session.runtime_ctx self._pipeline_adapter = managed_session.runtime_adapter diff --git a/coding-agent/src/coding_agent/runs/replacement.py b/coding-agent/src/coding_agent/runs/replacement.py index 3e047ebb0..2716e4878 100644 --- a/coding-agent/src/coding_agent/runs/replacement.py +++ b/coding-agent/src/coding_agent/runs/replacement.py @@ -14,7 +14,9 @@ class RuntimeReplacementSession(Protocol): id: str provider: object | None + provider_name: str | None model_name: str | None + base_url: str | None tape_id: str | None def runtime_binding_snapshot(self) -> RuntimeBindingSnapshot: ... @@ -43,6 +45,8 @@ def __call__( session: RuntimeReplacementSession, *, model_name: str, + provider_name: str | None = None, + base_url: str | None = None, ) -> Awaitable[tuple[object, object, object]]: ... @@ -55,18 +59,31 @@ async def replace_runtime_config( session: RuntimeReplacementSession, *, model_name: str, + provider_name: str | None = None, + base_url: str | None = None, build_runtime: RuntimeReplacementBuilder, persist_session: RuntimeReplacementPersister, ) -> RuntimeReplacementSession: old_provider = session.provider + old_provider_name = session.provider_name old_model_name = session.model_name + old_base_url = session.base_url old_tape_id = session.tape_id old_runtime_binding = session.runtime_binding_snapshot() - pipeline, ctx, adapter = await build_runtime(session, model_name=model_name) + pipeline, ctx, adapter = await build_runtime( + session, + model_name=model_name, + provider_name=provider_name, + base_url=base_url, + ) session.provider = None session.model_name = model_name + if provider_name is not None: + session.provider_name = provider_name + if base_url is not None: + session.base_url = base_url session.attach_runtime_binding( pipeline=pipeline, ctx=ctx, @@ -77,7 +94,9 @@ async def replace_runtime_config( await persist_session(session) except Exception: session.provider = old_provider + session.provider_name = old_provider_name session.model_name = old_model_name + session.base_url = old_base_url session.restore_runtime_binding(old_runtime_binding) session.tape_id = old_tape_id await self.close_runtime_adapter(adapter) diff --git a/coding-agent/src/coding_agent/server/http_server.py b/coding-agent/src/coding_agent/server/http_server.py index c385547ed..065e59e5d 100644 --- a/coding-agent/src/coding_agent/server/http_server.py +++ b/coding-agent/src/coding_agent/server/http_server.py @@ -206,6 +206,8 @@ ReadinessResponse, ResolveInteractionRequest, ResumeSessionRequest, + RuntimeConfigUpdateRequest, + RuntimeConfigUpdateResponse, RuntimeEventResponse, RuntimeEventsResponse, RuntimeInteractionListResponse, @@ -2474,6 +2476,64 @@ async def create_session( return SessionResponse(session_id=session_id) +@app.patch("/sessions/{session_id}/runtime-config", response_model=RuntimeConfigUpdateResponse) +@limiter.limit(RateLimits.CREATE_SESSION) +async def update_runtime_config( + request: Request, + session_id: str, + body: RuntimeConfigUpdateRequest, + api_key: str | None = Depends(verify_api_key), +) -> RuntimeConfigUpdateResponse: + """Update the session runtime provider/model/base_url config in-place. + + Applies changes next turn. The session's tape and history are preserved. + Returns 409 if a turn is currently in progress. + Returns 400 if no config fields are provided. + """ + if body.model is None and body.provider is None and body.base_url is None: + raise HTTPException( + status_code=400, + detail="At least one of model, provider, or base_url must be provided", + ) + + try: + session = await session_manager.get_session_async(session_id) + except KeyError: + raise HTTPException(status_code=404, detail="Session not found") + + if getattr(session, "turn_in_progress", False): + raise HTTPException(status_code=409, detail="Turn already in progress") + + try: + updated_session = await session_manager.replace_session_runtime_config( + session_id, + model_name=body.model, + provider_name=body.provider, + base_url=body.base_url, + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail="Session not found") from exc + except RuntimeError as exc: + if str(exc) == "turn already in progress": + raise HTTPException( + status_code=409, detail="Turn already in progress" + ) from exc + raise HTTPException( + status_code=500, detail=_http_exception_detail(exc) + ) from exc + except Exception as exc: + raise HTTPException( + status_code=500, detail=_http_exception_detail(exc) + ) from exc + + return RuntimeConfigUpdateResponse( + session_id=session_id, + provider_name=getattr(updated_session, "provider_name", None), + model_name=getattr(updated_session, "model_name", None), + base_url=getattr(updated_session, "base_url", None), + ) + + @app.post("/sessions/{session_id}/prompt") @limiter.limit(RateLimits.SEND_PROMPT) async def send_prompt( diff --git a/coding-agent/src/coding_agent/server/schemas.py b/coding-agent/src/coding_agent/server/schemas.py index 67fa09279..4b8dc616a 100644 --- a/coding-agent/src/coding_agent/server/schemas.py +++ b/coding-agent/src/coding_agent/server/schemas.py @@ -59,6 +59,25 @@ class CreateSessionRequest(BaseModel): max_steps: int | None = Field(None, ge=0) +class RuntimeConfigUpdateRequest(BaseModel): + """Request schema for updating session runtime config.""" + + model_config = ConfigDict(extra="forbid") + + model: str | None = Field(None, min_length=1, max_length=200) + provider: ProviderName | None = None + base_url: str | None = Field(None, min_length=1, max_length=500) + + +class RuntimeConfigUpdateResponse(BaseModel): + """Response schema for runtime config update.""" + + session_id: str + provider_name: str | None + model_name: str | None + base_url: str | None + + class ApproveRequest(BaseModel): """Request schema for approval response.""" diff --git a/coding-agent/src/coding_agent/server/session_manager.py b/coding-agent/src/coding_agent/server/session_manager.py index 36c0842e2..07848df7d 100644 --- a/coding-agent/src/coding_agent/server/session_manager.py +++ b/coding-agent/src/coding_agent/server/session_manager.py @@ -3243,13 +3243,22 @@ async def replace_session_runtime_config( self, session_id: str, *, - model_name: str, + model_name: str | None = None, + provider_name: str | None = None, + base_url: str | None = None, ) -> Session: async def replace_admitted_runtime(session: object) -> Session: admitted_session = cast(Session, session) + resolved_model = ( + model_name if model_name is not None else admitted_session.model_name + ) + if not resolved_model: + raise RuntimeError("session is missing model_name, cannot replace runtime") return await self._runtime_replacement_service.replace_runtime_config( admitted_session, - model_name=model_name, + model_name=resolved_model, + provider_name=provider_name, + base_url=base_url, build_runtime=self._build_session_runtime, persist_session=self._persist_session_async, ) diff --git a/coding-agent/tests/coding_agent/test_runtime_replacement_service.py b/coding-agent/tests/coding_agent/test_runtime_replacement_service.py index 0ca334c1d..217397c44 100644 --- a/coding-agent/tests/coding_agent/test_runtime_replacement_service.py +++ b/coding-agent/tests/coding_agent/test_runtime_replacement_service.py @@ -21,7 +21,9 @@ class FakeSession: def __init__(self) -> None: self.id = "session-1" self.provider = object() + self.provider_name = "old-provider" self.model_name = "old-model" + self.base_url = "https://old.example.com" self.tape_id = "old-tape" self.pipeline = "old-pipeline" self.ctx = FakeContext(FakeTape("old-tape")) @@ -61,9 +63,13 @@ async def build_runtime( runtime_session: FakeSession, *, model_name: str, + provider_name: str | None = None, + base_url: str | None = None, ) -> tuple[object, FakeContext, object]: assert runtime_session is session assert model_name == "new-model" + assert provider_name is None + assert base_url is None return "new-pipeline", FakeContext(FakeTape("new-tape")), "new-adapter" async def persist_session(runtime_session: FakeSession) -> None: @@ -107,8 +113,10 @@ async def build_runtime( runtime_session: FakeSession, *, model_name: str, + provider_name: str | None = None, + base_url: str | None = None, ) -> tuple[object, FakeContext, object]: - del runtime_session, model_name + del runtime_session, model_name, provider_name, base_url return "new-pipeline", FakeContext(FakeTape("new-tape")), "new-adapter" async def persist_session(runtime_session: FakeSession) -> None: @@ -129,7 +137,9 @@ async def close_adapter(adapter: object | None) -> None: ) assert session.provider is old_provider + assert session.provider_name == "old-provider" assert session.model_name == "old-model" + assert session.base_url == "https://old.example.com" assert session.tape_id == "old-tape" assert session.pipeline == "old-pipeline" assert session.adapter == "old-adapter" @@ -144,8 +154,10 @@ async def build_runtime( runtime_session: FakeSession, *, model_name: str, + provider_name: str | None = None, + base_url: str | None = None, ) -> tuple[object, FakeContext, object]: - del runtime_session, model_name + del runtime_session, model_name, provider_name, base_url return "new-pipeline", FakeContext(FakeTape("new-tape")), "new-adapter" async def persist_session(runtime_session: FakeSession) -> None: