feat: Configure subagent model defaults (#1342)

This commit is contained in:
Johannes du Plessis 2026-05-27 12:34:52 -07:00 • committed by GitHub
parent 0d4d1c5a3b
commit aeaa92eb8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 561 additions and 13 deletions

View file

@ -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",
)

View file

@ -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,

View file

@ -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()

View file

@ -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(),

View file

@ -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(),

View file

@ -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"

View file

@ -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

View file

@ -36,6 +36,17 @@ export function ProfileForm({ models, repos, initial, onSubmit, saving, error }:
const [effort, setEffort] = useState<string>(
initial.reasoning_effort ?? currentModel?.default_effort ?? "",
);
const [subagentModelId, setSubagentModelId] = useState<string>(
initial.default_subagent_model ?? initial.default_model ?? first?.id ?? "",
);
const currentSubagentModel: ModelOption | undefined =
models.find((m) => m.id === subagentModelId) ?? first;
const [subagentEffort, setSubagentEffort] = useState<string>(
initial.subagent_reasoning_effort ??
initial.reasoning_effort ??
currentSubagentModel?.default_effort ??
"",
);
const [defaultRepo, setDefaultRepo] = useState<string>(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 }:
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="subagent-model">Default subagent model</Label>
<Select
value={subagentModelId}
onValueChange={(v) => v && setSubagentModelId(v)}
>
<SelectTrigger id="subagent-model">
<SelectValue placeholder="Pick a model" />
</SelectTrigger>
<SelectContent>
{models.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="subagent-effort">Subagent reasoning effort</Label>
<Select
value={subagentEffort}
onValueChange={(v) => v && setSubagentEffort(v)}
>
<SelectTrigger id="subagent-effort">
<SelectValue placeholder="Pick an effort level" />
</SelectTrigger>
<SelectContent>
{currentSubagentModel?.efforts.map((e) => (
<SelectItem key={e} value={e}>
{e}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="repo">Default repo</Label>
{repos.length > 0 ? (
@ -123,7 +183,12 @@ export function ProfileForm({ models, repos, initial, onSubmit, saving, error }:
{error && <p className="text-destructive text-sm">{error}</p>}
<div className="flex justify-end">
<Button type="submit" disabled={saving || !modelId || !effort}>
<Button
type="submit"
disabled={
saving || !modelId || !effort || !subagentModelId || !subagentEffort
}
>
{saving ? "Saving…" : "Save"}
</Button>
</div>

View file

@ -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;
}

View file

@ -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,

View file

@ -132,7 +132,7 @@ function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
return (
<SettingsSection
title="Global defaults"
description="Workspace-wide model defaults. Per-user Cloud Agent selections override these for the agent."
description="Workspace-wide model defaults. Per-user Cloud Agent selections override the agent defaults."
>
<div className="divide-y divide-border">
<RolePicker
@ -151,6 +151,22 @@ function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
}
disabled={!settings.data || save.isPending}
/>
<RolePicker
label="Open SWE Agent subagents"
description="Model used by delegated main-agent tasks."
models={models}
model={settings.data?.default_agent_subagent_model ?? null}
effort={settings.data?.default_agent_subagent_reasoning_effort ?? null}
onChange={(model, effort) =>
settings.data &&
save.mutate({
...settings.data,
default_agent_subagent_model: model,
default_agent_subagent_reasoning_effort: effort,
})
}
disabled={!settings.data || save.isPending}
/>
<RolePicker
label="Open SWE Reviewer"
description="Model used for PR review runs."
@ -167,6 +183,22 @@ function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
}
disabled={!settings.data || save.isPending}
/>
<RolePicker
label="Open SWE Reviewer subagents"
description="Model used by delegated reviewer tasks."
models={models}
model={settings.data?.default_reviewer_subagent_model ?? null}
effort={settings.data?.default_reviewer_subagent_reasoning_effort ?? null}
onChange={(model, effort) =>
settings.data &&
save.mutate({
...settings.data,
default_reviewer_subagent_model: model,
default_reviewer_subagent_reasoning_effort: effort,
})
}
disabled={!settings.data || save.isPending}
/>
</div>
{error && <p className="px-4 pb-3 text-xs text-destructive">{error}</p>}
</SettingsSection>

View file

@ -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 (
<main className="p-6">
@ -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() {
</Select>
}
/>
<SettingsRow
label="Default Subagent Model"
description="Used for delegated tasks launched by your agent"
control={
<Select
value={subagentModelId}
onValueChange={(v) => v && setSubagentModelId(v)}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Pick a model" />
</SelectTrigger>
<SelectContent>
{options.data?.models.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
}
/>
<SettingsRow
label="Subagent Reasoning Effort"
description="How hard delegated subagents think before answering"
control={
<Select
value={subagentEffort}
onValueChange={(v) => v && setSubagentEffort(v)}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{currentSubagentModel?.efforts.map((e) => (
<SelectItem key={e} value={e}>
{e}
</SelectItem>
))}
</SelectContent>
</Select>
}
/>
<SettingsRow
label="Default Repository"
description="Used when no repository is specified"

View file

@ -52,8 +52,12 @@ const DEFAULT_SETTINGS: TeamSettings = {
autofix_severity_threshold: "medium",
default_agent_model: null,
default_agent_reasoning_effort: null,
default_agent_subagent_model: null,
default_agent_subagent_reasoning_effort: null,
default_reviewer_model: null,
default_reviewer_reasoning_effort: null,
default_reviewer_subagent_model: null,
default_reviewer_subagent_reasoning_effort: null,
};
function ReviewPage() {