From aeaa92eb8a517092626080b8575271783dadf3d6 Mon Sep 17 00:00:00 2001 From: Johannes du Plessis Date: Wed, 27 May 2026 12:34:52 -0700 Subject: [PATCH] feat: Configure subagent model defaults (#1342) --- agent/dashboard/agent_overrides.py | 32 +++++- agent/dashboard/profiles.py | 18 ++++ agent/dashboard/team_settings.py | 43 ++++++++ agent/reviewer.py | 29 ++++- agent/server.py | 46 +++++++- tests/test_agent_subagent_models.py | 159 ++++++++++++++++++++++++++++ tests/test_reviewer.py | 61 ++++++++++- ui/src/components/ProfileForm.tsx | 67 +++++++++++- ui/src/lib/api.ts | 8 ++ ui/src/lib/profile.ts | 4 + ui/src/routes/admin.tsx | 34 +++++- ui/src/routes/cloud-agents.tsx | 69 ++++++++++++ ui/src/routes/review.tsx | 4 + 13 files changed, 561 insertions(+), 13 deletions(-) create mode 100644 tests/test_agent_subagent_models.py diff --git a/agent/dashboard/agent_overrides.py b/agent/dashboard/agent_overrides.py index a6deeaa8..6d572a5e 100644 --- a/agent/dashboard/agent_overrides.py +++ b/agent/dashboard/agent_overrides.py @@ -82,10 +82,14 @@ def profile_create_prs(profile: dict[str, Any] | None) -> bool: return False -def normalize_profile_overrides(profile: dict[str, Any]) -> tuple[str | None, str | None]: - """Return ``(model_id, reasoning_effort)`` if both are valid, else ``(None, None)``.""" - model_id = profile.get("default_model") - effort = profile.get("reasoning_effort") +def _normalize_profile_model_pair( + profile: dict[str, Any], + *, + model_key: str, + effort_key: str, +) -> tuple[str | None, str | None]: + model_id = profile.get(model_key) + effort = profile.get(effort_key) if ( isinstance(model_id, str) and model_id in SUPPORTED_MODEL_IDS @@ -94,3 +98,23 @@ def normalize_profile_overrides(profile: dict[str, Any]) -> tuple[str | None, st ): return model_id, effort return None, None + + +def normalize_profile_overrides(profile: dict[str, Any]) -> tuple[str | None, str | None]: + """Return ``(model_id, reasoning_effort)`` if both are valid, else ``(None, None)``.""" + return _normalize_profile_model_pair( + profile, + model_key="default_model", + effort_key="reasoning_effort", + ) + + +def normalize_profile_subagent_overrides( + profile: dict[str, Any], +) -> tuple[str | None, str | None]: + """Return the profile's subagent model pair if valid, else ``(None, None)``.""" + return _normalize_profile_model_pair( + profile, + model_key="default_subagent_model", + effort_key="subagent_reasoning_effort", + ) diff --git a/agent/dashboard/profiles.py b/agent/dashboard/profiles.py index b87ff3a2..59309d2f 100644 --- a/agent/dashboard/profiles.py +++ b/agent/dashboard/profiles.py @@ -35,6 +35,8 @@ OAUTH_TOKENS_NAMESPACE: list[str] = ["oauth_tokens"] class ProfileUpdate(BaseModel): default_model: str reasoning_effort: str + default_subagent_model: str | None = None + subagent_reasoning_effort: str | None = None default_repo: str | None = None base_branch: str | None = None branch_prefix: str | None = None @@ -54,6 +56,20 @@ class ProfileUpdate(BaseModel): raise ValueError( f"effort {self.reasoning_effort!r} not supported by {self.default_model!r}" ) + if self.default_subagent_model is None and self.subagent_reasoning_effort is None: + return + if self.default_subagent_model is None: + raise ValueError("subagent reasoning effort set without a model") + if self.default_subagent_model not in SUPPORTED_MODEL_IDS: + raise ValueError(f"unsupported subagent model: {self.default_subagent_model}") + if self.subagent_reasoning_effort is None or not model_supports_effort( + self.default_subagent_model, + self.subagent_reasoning_effort, + ): + raise ValueError( + f"effort {self.subagent_reasoning_effort!r} not supported by " + f"{self.default_subagent_model!r}" + ) def _client(): @@ -91,6 +107,8 @@ async def upsert_profile(login: str, email: str, update: ProfileUpdate) -> dict[ "email": email or existing.get("email", ""), "default_model": update.default_model, "reasoning_effort": update.reasoning_effort, + "default_subagent_model": update.default_subagent_model, + "subagent_reasoning_effort": update.subagent_reasoning_effort, "default_repo": update.default_repo, "base_branch": update.base_branch, "branch_prefix": update.branch_prefix, diff --git a/agent/dashboard/team_settings.py b/agent/dashboard/team_settings.py index e4b0570e..313ce64e 100644 --- a/agent/dashboard/team_settings.py +++ b/agent/dashboard/team_settings.py @@ -33,17 +33,31 @@ class TeamSettingsUpdate(BaseModel): autofix_severity_threshold: AutofixMode = "medium" default_agent_model: str | None = None default_agent_reasoning_effort: str | None = None + default_agent_subagent_model: str | None = None + default_agent_subagent_reasoning_effort: str | None = None default_reviewer_model: str | None = None default_reviewer_reasoning_effort: str | None = None + default_reviewer_subagent_model: str | None = None + default_reviewer_subagent_reasoning_effort: str | None = None @model_validator(mode="after") def _validate_model_pairs(self) -> TeamSettingsUpdate: _validate_model_effort_pair( self.default_agent_model, self.default_agent_reasoning_effort, "agent" ) + _validate_model_effort_pair( + self.default_agent_subagent_model, + self.default_agent_subagent_reasoning_effort, + "agent subagent", + ) _validate_model_effort_pair( self.default_reviewer_model, self.default_reviewer_reasoning_effort, "reviewer" ) + _validate_model_effort_pair( + self.default_reviewer_subagent_model, + self.default_reviewer_subagent_reasoning_effort, + "reviewer subagent", + ) return self @@ -72,8 +86,12 @@ def _default_settings() -> dict[str, Any]: "autofix_severity_threshold": "medium", "default_agent_model": fallback_model, "default_agent_reasoning_effort": fallback_effort, + "default_agent_subagent_model": fallback_model, + "default_agent_subagent_reasoning_effort": fallback_effort, "default_reviewer_model": fallback_model, "default_reviewer_reasoning_effort": fallback_effort, + "default_reviewer_subagent_model": fallback_model, + "default_reviewer_subagent_reasoning_effort": fallback_effort, "updated_at": None, } @@ -110,8 +128,12 @@ async def upsert_team_settings(update: TeamSettingsUpdate) -> dict[str, Any]: "autofix_severity_threshold": update.autofix_severity_threshold, "default_agent_model": update.default_agent_model, "default_agent_reasoning_effort": update.default_agent_reasoning_effort, + "default_agent_subagent_model": update.default_agent_subagent_model, + "default_agent_subagent_reasoning_effort": update.default_agent_subagent_reasoning_effort, "default_reviewer_model": update.default_reviewer_model, "default_reviewer_reasoning_effort": update.default_reviewer_reasoning_effort, + "default_reviewer_subagent_model": update.default_reviewer_subagent_model, + "default_reviewer_subagent_reasoning_effort": update.default_reviewer_subagent_reasoning_effort, "updated_at": datetime.now(UTC).isoformat(), } await _client().store.put_item(TEAM_SETTINGS_NAMESPACE, TEAM_SETTINGS_KEY, value) @@ -143,3 +165,24 @@ async def get_team_default_model( ): return model, effort return default_model_pair() + + +async def get_team_default_subagent_model( + role: Literal["agent", "reviewer"], +) -> tuple[str, str]: + """Return the team-wide default subagent ``(model_id, reasoning_effort)`` for ``role``.""" + settings = await get_team_settings() + if role == "agent": + model = settings.get("default_agent_subagent_model") + effort = settings.get("default_agent_subagent_reasoning_effort") + else: + model = settings.get("default_reviewer_subagent_model") + effort = settings.get("default_reviewer_subagent_reasoning_effort") + if ( + isinstance(model, str) + and model in SUPPORTED_MODEL_IDS + and isinstance(effort, str) + and model_supports_effort(model, effort) + ): + return model, effort + return default_model_pair() diff --git a/agent/reviewer.py b/agent/reviewer.py index 944c8056..e8d72898 100644 --- a/agent/reviewer.py +++ b/agent/reviewer.py @@ -49,6 +49,7 @@ from .server import ( DEFAULT_LLM_MAX_TOKENS, DEFAULT_RECURSION_LIMIT, MODEL_CALL_RECURSION_LIMIT, + _general_purpose_subagent, ensure_sandbox_for_thread, graph_loaded_for_execution, ) @@ -622,13 +623,15 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel: existing_threads_block=existing_threads_block, ) - from .dashboard.team_settings import get_team_default_model + from .dashboard.team_settings import get_team_default_model, get_team_default_subagent_model configured_model_id = config["configurable"].get("reviewer_model_id") configured_effort = config["configurable"].get("reviewer_reasoning_effort") if isinstance(configured_model_id, str) and configured_model_id: model_id = configured_model_id reasoning_effort = configured_effort if isinstance(configured_effort, str) else None + subagent_model_id = model_id + subagent_effort = reasoning_effort else: model_id, reasoning_effort = await get_team_default_model("reviewer") logger.info( @@ -636,12 +639,31 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel: model_id, reasoning_effort, ) + subagent_model_id, subagent_effort = await get_team_default_subagent_model("reviewer") + logger.info( + "Using team default reviewer subagent model: model=%s effort=%s", + subagent_model_id, + subagent_effort, + ) + configured_subagent_model_id = config["configurable"].get("reviewer_subagent_model_id") + configured_subagent_effort = config["configurable"].get("reviewer_subagent_reasoning_effort") + if isinstance(configured_subagent_model_id, str) and configured_subagent_model_id: + subagent_model_id = configured_subagent_model_id + subagent_effort = ( + configured_subagent_effort if isinstance(configured_subagent_effort, str) else None + ) model_kwargs = provider_model_kwargs( model_id, reasoning_effort, max_tokens=DEFAULT_LLM_MAX_TOKENS, openai_reasoning_default=DEFAULT_LLM_REASONING, ) + subagent_model_kwargs = provider_model_kwargs( + subagent_model_id, + subagent_effort, + max_tokens=DEFAULT_LLM_MAX_TOKENS, + openai_reasoning_default=DEFAULT_LLM_REASONING, + ) reviewer_eval = ( config["configurable"].get("reviewer_eval") is True @@ -688,8 +710,10 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel: if review_context: system_prompt = f"{system_prompt}\n\n{review_context}" + reviewer_model = make_model(model_id, **model_kwargs) + reviewer_subagent_model = make_model(subagent_model_id, **subagent_model_kwargs) return create_deep_agent( - model=make_model(model_id, **model_kwargs), + model=reviewer_model, system_prompt=system_prompt, tools=[ add_finding, @@ -702,6 +726,7 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel: fetch_url, http_request, ], + subagents=[_general_purpose_subagent(reviewer_subagent_model)], backend=sandbox_backend, middleware=[ SanitizeToolInputsMiddleware(), diff --git a/agent/server.py b/agent/server.py index d28bf09e..7dc5d0b5 100644 --- a/agent/server.py +++ b/agent/server.py @@ -29,17 +29,20 @@ _apply_messages_reducer_patch() from deepagents import create_deep_agent from deepagents.backends import LangSmithSandbox from deepagents.backends.protocol import SandboxBackendProtocol +from deepagents.middleware.subagents import GENERAL_PURPOSE_SUBAGENT, SubAgent from langchain.agents.middleware import ModelCallLimitMiddleware +from langchain_core.language_models import BaseChatModel from langsmith.sandbox import SandboxClientError from .dashboard.agent_overrides import ( load_profile, normalize_profile_overrides, + normalize_profile_subagent_overrides, profile_create_prs, resolve_github_login, ) from .dashboard.options import DEFAULT_MODEL_ID, SUPPORTED_MODEL_IDS, model_supports_effort -from .dashboard.team_settings import get_team_default_model +from .dashboard.team_settings import get_team_default_model, get_team_default_subagent_model from .integrations.langsmith import _configure_github_proxy from .middleware import ( ModelFallbackMiddleware, @@ -344,6 +347,15 @@ DEFAULT_RECURSION_LIMIT = 9_999 MODEL_CALL_RECURSION_LIMIT = 5_000 # ~half the recursion limit to account for tool calls +def _general_purpose_subagent(model: BaseChatModel) -> SubAgent: + return { + "name": GENERAL_PURPOSE_SUBAGENT["name"], + "description": GENERAL_PURPOSE_SUBAGENT["description"], + "system_prompt": GENERAL_PURPOSE_SUBAGENT["system_prompt"], + "model": model, + } + + def _get_cached_sandbox_backend(thread_id: str) -> SandboxBackendProtocol: sandbox_backend = SANDBOX_BACKENDS.get(thread_id) if sandbox_backend is None: @@ -385,6 +397,12 @@ async def get_agent(config: RunnableConfig) -> Pregel: model_id, profile_effort = await get_team_default_model("agent") logger.info("Using team default agent model: model=%s effort=%s", model_id, profile_effort) + subagent_model_id, subagent_effort = await get_team_default_subagent_model("agent") + logger.info( + "Using team default agent subagent model: model=%s effort=%s", + subagent_model_id, + subagent_effort, + ) profile: dict[str, Any] | None = None profile_login = resolve_github_login(config) @@ -401,6 +419,20 @@ async def get_agent(config: RunnableConfig) -> Pregel: ) model_id = overridden_model profile_effort = overridden_effort + subagent_model_id = overridden_model + subagent_effort = overridden_effort + overridden_subagent_model, overridden_subagent_effort = ( + normalize_profile_subagent_overrides(profile) + ) + if overridden_subagent_model: + logger.info( + "Applying dashboard profile subagent override for %s: model=%s effort=%s", + profile_login, + overridden_subagent_model, + overridden_subagent_effort, + ) + subagent_model_id = overridden_subagent_model + subagent_effort = overridden_subagent_effort configurable = (config or {}).get("configurable") or {} per_thread_model = configurable.get("agent_model_id") @@ -418,6 +450,8 @@ async def get_agent(config: RunnableConfig) -> Pregel: ) model_id = per_thread_model profile_effort = per_thread_effort + subagent_model_id = per_thread_model + subagent_effort = per_thread_effort always_create_prs = profile_create_prs(profile) if always_create_prs: @@ -428,6 +462,11 @@ async def get_agent(config: RunnableConfig) -> Pregel: profile_effort, max_tokens=DEFAULT_LLM_MAX_TOKENS, ) + subagent_model_kwargs = provider_model_kwargs( + subagent_model_id, + subagent_effort, + max_tokens=DEFAULT_LLM_MAX_TOKENS, + ) fallback_model_id = os.environ.get("LLM_FALLBACK_MODEL_ID") or fallback_model_id_for(model_id) fallback_middleware: list[Any] = [] @@ -441,8 +480,10 @@ async def get_agent(config: RunnableConfig) -> Pregel: logger.info("Configured model fallback %s -> %s", model_id, fallback_model_id) logger.info("Returning agent with sandbox for thread %s", thread_id) + main_model = make_model(model_id, **model_kwargs) + subagent_model = make_model(subagent_model_id, **subagent_model_kwargs) return create_deep_agent( - model=make_model(model_id, **model_kwargs), + model=main_model, system_prompt=construct_system_prompt( working_dir=work_dir, linear_project_id=linear_project_id, @@ -465,6 +506,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: slack_read_thread_messages, slack_thread_reply, ], + subagents=[_general_purpose_subagent(subagent_model)], backend=backend_factory, middleware=[ SanitizeToolInputsMiddleware(), diff --git a/tests/test_agent_subagent_models.py b/tests/test_agent_subagent_models.py new file mode 100644 index 00000000..f53033f8 --- /dev/null +++ b/tests/test_agent_subagent_models.py @@ -0,0 +1,159 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from langgraph.graph.state import RunnableConfig + +from agent.server import get_agent + + +class _DummyAgent: + def with_config(self, config: RunnableConfig) -> "_DummyAgent": + self.config = config + return self + + +@pytest.mark.asyncio +async def test_agent_uses_profile_subagent_model_override() -> None: + config: RunnableConfig = { + "configurable": { + "__is_for_execution__": True, + "thread_id": "thread-123", + "github_login": "octocat", + }, + "metadata": {}, + } + main_model = MagicMock(name="main_model") + subagent_model = MagicMock(name="subagent_model") + captured: dict[str, object] = {} + + def fake_create_deep_agent(**kwargs: object) -> _DummyAgent: + captured.update(kwargs) + return _DummyAgent() + + with ( + patch( + "agent.server.resolve_github_token", + new_callable=AsyncMock, + return_value=("ghp", "enc", None), + ), + patch("agent.server.resolve_triggering_user_identity", return_value=None), + patch( + "agent.server.ensure_sandbox_for_thread", + new_callable=AsyncMock, + return_value=MagicMock(), + ), + patch( + "agent.server.aresolve_sandbox_work_dir", + new_callable=AsyncMock, + return_value="/workspace", + ), + patch( + "agent.server.get_team_default_model", + new_callable=AsyncMock, + return_value=("openai:gpt-5.5", "medium"), + ), + patch( + "agent.server.get_team_default_subagent_model", + new_callable=AsyncMock, + return_value=("openai:gpt-5.5", "low"), + ), + patch( + "agent.server.load_profile", + new_callable=AsyncMock, + return_value={ + "default_model": "anthropic:claude-opus-4-7", + "reasoning_effort": "high", + "default_subagent_model": "openai:gpt-5.5", + "subagent_reasoning_effort": "xhigh", + }, + ), + patch("agent.server.fallback_model_id_for", return_value=None), + patch("agent.server.make_model", side_effect=[main_model, subagent_model]) as make_model, + patch("agent.server.construct_system_prompt", return_value="prompt"), + patch("agent.server.create_deep_agent", side_effect=fake_create_deep_agent), + ): + await get_agent(config) + + assert captured["model"] is main_model + subagents = captured["subagents"] + assert isinstance(subagents, list) + assert subagents[0]["name"] == "general-purpose" + assert subagents[0]["model"] is subagent_model + + main_call = make_model.call_args_list[0] + assert main_call.args == ("anthropic:claude-opus-4-7",) + assert main_call.kwargs["thinking"] == {"type": "adaptive"} + assert main_call.kwargs["effort"] == "high" + + subagent_call = make_model.call_args_list[1] + assert subagent_call.args == ("openai:gpt-5.5",) + assert subagent_call.kwargs["reasoning"] == {"effort": "xhigh"} + + +@pytest.mark.asyncio +async def test_agent_subagent_inherits_profile_model_override_without_explicit_pair() -> None: + config: RunnableConfig = { + "configurable": { + "__is_for_execution__": True, + "thread_id": "thread-123", + "github_login": "octocat", + }, + "metadata": {}, + } + main_model = MagicMock(name="main_model") + subagent_model = MagicMock(name="subagent_model") + captured: dict[str, object] = {} + + def fake_create_deep_agent(**kwargs: object) -> _DummyAgent: + captured.update(kwargs) + return _DummyAgent() + + with ( + patch( + "agent.server.resolve_github_token", + new_callable=AsyncMock, + return_value=("ghp", "enc", None), + ), + patch("agent.server.resolve_triggering_user_identity", return_value=None), + patch( + "agent.server.ensure_sandbox_for_thread", + new_callable=AsyncMock, + return_value=MagicMock(), + ), + patch( + "agent.server.aresolve_sandbox_work_dir", + new_callable=AsyncMock, + return_value="/workspace", + ), + patch( + "agent.server.get_team_default_model", + new_callable=AsyncMock, + return_value=("openai:gpt-5.5", "medium"), + ), + patch( + "agent.server.get_team_default_subagent_model", + new_callable=AsyncMock, + return_value=("openai:gpt-5.5", "low"), + ), + patch( + "agent.server.load_profile", + new_callable=AsyncMock, + return_value={ + "default_model": "anthropic:claude-opus-4-7", + "reasoning_effort": "high", + }, + ), + patch("agent.server.fallback_model_id_for", return_value=None), + patch("agent.server.make_model", side_effect=[main_model, subagent_model]) as make_model, + patch("agent.server.construct_system_prompt", return_value="prompt"), + patch("agent.server.create_deep_agent", side_effect=fake_create_deep_agent), + ): + await get_agent(config) + + subagents = captured["subagents"] + assert isinstance(subagents, list) + assert subagents[0]["model"] is subagent_model + assert make_model.call_args_list[0].args == ("anthropic:claude-opus-4-7",) + assert make_model.call_args_list[1].args == ("anthropic:claude-opus-4-7",) + assert make_model.call_args_list[1].kwargs["thinking"] == {"type": "adaptive"} + assert make_model.call_args_list[1].kwargs["effort"] == "high" diff --git a/tests/test_reviewer.py b/tests/test_reviewer.py index e3ca2661..d92dab7e 100644 --- a/tests/test_reviewer.py +++ b/tests/test_reviewer.py @@ -95,6 +95,8 @@ async def test_reviewer_applies_eval_model_and_effort_overrides() -> None: "head_sha": "head", "reviewer_model_id": "anthropic:claude-opus-4-7", "reviewer_reasoning_effort": "high", + "reviewer_subagent_model_id": "openai:gpt-5.5", + "reviewer_subagent_reasoning_effort": "low", }, "metadata": {}, } @@ -121,9 +123,62 @@ async def test_reviewer_applies_eval_model_and_effort_overrides() -> None: ): await reviewer.get_reviewer_agent(config) - assert make_model.call_args.args == ("anthropic:claude-opus-4-7",) - assert make_model.call_args.kwargs["thinking"] == {"type": "adaptive"} - assert make_model.call_args.kwargs["effort"] == "high" + main_model_call = make_model.call_args_list[0] + assert main_model_call.args == ("anthropic:claude-opus-4-7",) + assert main_model_call.kwargs["thinking"] == {"type": "adaptive"} + assert main_model_call.kwargs["effort"] == "high" + subagent_model_call = make_model.call_args_list[1] + assert subagent_model_call.args == ("openai:gpt-5.5",) + assert subagent_model_call.kwargs["reasoning"] == {"effort": "low"} + + +@pytest.mark.asyncio +async def test_reviewer_subagent_inherits_eval_model_without_explicit_override() -> None: + config: RunnableConfig = { + "configurable": { + "__is_for_execution__": True, + "thread_id": "reviewer-thread-id", + "repo": {"owner": "acme", "name": "repo"}, + "pr_number": 1, + "pr_url": "https://github.com/acme/repo/pull/1", + "base_sha": "base", + "head_sha": "head", + "reviewer_model_id": "anthropic:claude-opus-4-7", + "reviewer_reasoning_effort": "high", + }, + "metadata": {}, + } + dummy_agent = _DummyAgent() + + with ( + patch( + "agent.reviewer.ensure_sandbox_for_thread", + new_callable=AsyncMock, + return_value=MagicMock(), + ), + patch( + "agent.reviewer.aresolve_sandbox_work_dir", + new_callable=AsyncMock, + return_value="/workspace", + ), + patch("agent.reviewer.make_model", return_value=MagicMock()) as make_model, + patch("agent.reviewer.create_deep_agent", return_value=dummy_agent), + patch( + "agent.reviewer.fetch_agents_md", + new_callable=AsyncMock, + return_value=None, + ), + ): + await reviewer.get_reviewer_agent(config) + + main_model_call = make_model.call_args_list[0] + assert main_model_call.args == ("anthropic:claude-opus-4-7",) + assert main_model_call.kwargs["thinking"] == {"type": "adaptive"} + assert main_model_call.kwargs["effort"] == "high" + subagent_model_call = make_model.call_args_list[1] + assert subagent_model_call.args == ("anthropic:claude-opus-4-7",) + assert subagent_model_call.kwargs["thinking"] == {"type": "adaptive"} + assert subagent_model_call.kwargs["effort"] == "high" @pytest.mark.asyncio diff --git a/ui/src/components/ProfileForm.tsx b/ui/src/components/ProfileForm.tsx index b6638b49..50320778 100644 --- a/ui/src/components/ProfileForm.tsx +++ b/ui/src/components/ProfileForm.tsx @@ -36,6 +36,17 @@ export function ProfileForm({ models, repos, initial, onSubmit, saving, error }: const [effort, setEffort] = useState( initial.reasoning_effort ?? currentModel?.default_effort ?? "", ); + const [subagentModelId, setSubagentModelId] = useState( + initial.default_subagent_model ?? initial.default_model ?? first?.id ?? "", + ); + const currentSubagentModel: ModelOption | undefined = + models.find((m) => m.id === subagentModelId) ?? first; + const [subagentEffort, setSubagentEffort] = useState( + initial.subagent_reasoning_effort ?? + initial.reasoning_effort ?? + currentSubagentModel?.default_effort ?? + "", + ); const [defaultRepo, setDefaultRepo] = useState(initial.default_repo ?? ""); useEffect(() => { @@ -44,11 +55,22 @@ export function ProfileForm({ models, repos, initial, onSubmit, saving, error }: } }, [modelId, currentModel, effort]); + useEffect(() => { + if ( + currentSubagentModel !== undefined && + !currentSubagentModel.efforts.includes(subagentEffort) + ) { + setSubagentEffort(currentSubagentModel.default_effort); + } + }, [subagentModelId, currentSubagentModel, subagentEffort]); + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); void onSubmit({ default_model: modelId, reasoning_effort: effort, + default_subagent_model: subagentModelId, + subagent_reasoning_effort: subagentEffort, default_repo: defaultRepo || null, }); }; @@ -87,6 +109,44 @@ export function ProfileForm({ models, repos, initial, onSubmit, saving, error }: +
+ + +
+ +
+ + +
+
{repos.length > 0 ? ( @@ -123,7 +183,12 @@ export function ProfileForm({ models, repos, initial, onSubmit, saving, error }: {error &&

{error}

}
-
diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 743c000b..f0ab913d 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -66,6 +66,8 @@ export interface Profile { email?: string; default_model?: string; reasoning_effort?: string; + default_subagent_model?: string | null; + subagent_reasoning_effort?: string | null; default_repo?: string | null; base_branch?: string | null; branch_prefix?: string | null; @@ -78,6 +80,8 @@ export interface Profile { export interface ProfileUpdate { default_model: string; reasoning_effort: string; + default_subagent_model?: string | null; + subagent_reasoning_effort?: string | null; default_repo?: string | null; base_branch?: string | null; branch_prefix?: string | null; @@ -97,8 +101,12 @@ export interface TeamSettings { autofix_severity_threshold: AutofixMode; default_agent_model?: string | null; default_agent_reasoning_effort?: string | null; + default_agent_subagent_model?: string | null; + default_agent_subagent_reasoning_effort?: string | null; default_reviewer_model?: string | null; default_reviewer_reasoning_effort?: string | null; + default_reviewer_subagent_model?: string | null; + default_reviewer_subagent_reasoning_effort?: string | null; updated_at?: string | null; } diff --git a/ui/src/lib/profile.ts b/ui/src/lib/profile.ts index 5123a523..1ec8ea30 100644 --- a/ui/src/lib/profile.ts +++ b/ui/src/lib/profile.ts @@ -63,6 +63,10 @@ export function buildProfileUpdate( return { default_model: current?.default_model ?? fallbackModel, reasoning_effort: current?.reasoning_effort ?? fallbackEffort, + default_subagent_model: + current?.default_subagent_model ?? current?.default_model ?? fallbackModel, + subagent_reasoning_effort: + current?.subagent_reasoning_effort ?? current?.reasoning_effort ?? fallbackEffort, default_repo: current?.default_repo ?? null, base_branch: current?.base_branch ?? null, branch_prefix: current?.branch_prefix ?? null, diff --git a/ui/src/routes/admin.tsx b/ui/src/routes/admin.tsx index 94a0f7c3..2e58d571 100644 --- a/ui/src/routes/admin.tsx +++ b/ui/src/routes/admin.tsx @@ -132,7 +132,7 @@ function GlobalDefaultsSection({ models }: { models: Array }) { return (
}) { } disabled={!settings.data || save.isPending} /> + + settings.data && + save.mutate({ + ...settings.data, + default_agent_subagent_model: model, + default_agent_subagent_reasoning_effort: effort, + }) + } + disabled={!settings.data || save.isPending} + /> }) { } disabled={!settings.data || save.isPending} /> + + settings.data && + save.mutate({ + ...settings.data, + default_reviewer_subagent_model: model, + default_reviewer_subagent_reasoning_effort: effort, + }) + } + disabled={!settings.data || save.isPending} + />
{error &&

{error}

}
diff --git a/ui/src/routes/cloud-agents.tsx b/ui/src/routes/cloud-agents.tsx index 8fce3241..fbe634ed 100644 --- a/ui/src/routes/cloud-agents.tsx +++ b/ui/src/routes/cloud-agents.tsx @@ -44,6 +44,8 @@ function CloudAgentsPage() { const [modelId, setModelId] = useState("") const [effort, setEffort] = useState("") + const [subagentModelId, setSubagentModelId] = useState("") + const [subagentEffort, setSubagentEffort] = useState("") const [defaultRepo, setDefaultRepo] = useState("") const [baseBranch, setBaseBranch] = useState("") const [branchPrefix, setBranchPrefix] = useState("") @@ -53,6 +55,8 @@ function CloudAgentsPage() { const firstModel: ModelOption | undefined = options.data?.models[0] const currentModel: ModelOption | undefined = options.data?.models.find((m) => m.id === modelId) ?? firstModel + const currentSubagentModel: ModelOption | undefined = + options.data?.models.find((m) => m.id === subagentModelId) ?? firstModel useEffect(() => { if (!profile.data || initialized.current) return @@ -63,6 +67,18 @@ function CloudAgentsPage() { initialized.current = true setModelId(profile.data.default_model ?? firstModel?.id ?? "") setEffort(profile.data.reasoning_effort ?? firstModel?.default_effort ?? "") + setSubagentModelId( + profile.data.default_subagent_model ?? + profile.data.default_model ?? + firstModel?.id ?? + "" + ) + setSubagentEffort( + profile.data.subagent_reasoning_effort ?? + profile.data.reasoning_effort ?? + firstModel?.default_effort ?? + "" + ) setDefaultRepo(profile.data.default_repo ?? "") setBaseBranch(profile.data.base_branch ?? "") setBranchPrefix(profile.data.branch_prefix ?? "") @@ -74,6 +90,15 @@ function CloudAgentsPage() { } }, [currentModel, effort]) + useEffect(() => { + if ( + currentSubagentModel && + !currentSubagentModel.efforts.includes(subagentEffort) + ) { + setSubagentEffort(currentSubagentModel.default_effort) + } + }, [currentSubagentModel, subagentEffort]) + if (session.isLoading) { return (
@@ -99,6 +124,8 @@ function CloudAgentsPage() { persist({ default_model: modelId, reasoning_effort: effort, + default_subagent_model: subagentModelId, + subagent_reasoning_effort: subagentEffort, default_repo: defaultRepo || null, base_branch: baseBranch || null, branch_prefix: branchPrefix || null, @@ -149,6 +176,48 @@ function CloudAgentsPage() { } /> + v && setSubagentModelId(v)} + > + + + + + {options.data?.models.map((m) => ( + + {m.label} + + ))} + + + } + /> + v && setSubagentEffort(v)} + > + + + + + {currentSubagentModel?.efforts.map((e) => ( + + {e} + + ))} + + + } + />