mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53:14 +00:00
feat: Configure subagent model defaults (#1342)
This commit is contained in:
parent
0d4d1c5a3b
commit
aeaa92eb8a
13 changed files with 561 additions and 13 deletions
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
159
tests/test_agent_subagent_models.py
Normal file
159
tests/test_agent_subagent_models.py
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue