diff --git a/backend/app/gateway/routers/agents.py b/backend/app/gateway/routers/agents.py index 98a932665..4c87d51be 100644 --- a/backend/app/gateway/routers/agents.py +++ b/backend/app/gateway/routers/agents.py @@ -4,13 +4,22 @@ import asyncio import logging import re import shutil +from typing import Literal import yaml from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from deerflow.config.agents_api_config import get_agents_api_config -from deerflow.config.agents_config import AgentConfig, list_custom_agents, load_agent_config, load_agent_soul, preserve_non_managed_fields +from deerflow.config.agents_config import ( + AgentConfig, + AgentModelSettings, + list_custom_agents, + load_agent_config, + load_agent_soul, + preserve_non_managed_fields, +) +from deerflow.config.app_config import get_app_config from deerflow.config.paths import get_paths from deerflow.runtime.user_context import get_effective_user_id @@ -19,6 +28,13 @@ router = APIRouter(prefix="/api", tags=["agents"]) AGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$") +ReasoningEffort = Literal["low", "medium", "high"] + +# Fields carrying a custom agent's per-agent model behavior (issue #4336), +# shared by the create/update request bodies and the response so the three +# stay in lockstep. ``model`` picks the profile; the rest layer on top of it. +_MODEL_BEHAVIOR_FIELDS = ("model", "model_settings", "thinking_enabled", "reasoning_effort") + class AgentResponse(BaseModel): """Response model for a custom agent.""" @@ -28,6 +44,9 @@ class AgentResponse(BaseModel): model: str | None = Field(default=None, description="Optional model override") tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist") skills: list[str] | None = Field(default=None, description="Optional skill whitelist (None=all, []=none)") + model_settings: AgentModelSettings | None = Field(default=None, description="Per-agent sampling overrides (temperature / max_tokens)") + thinking_enabled: bool | None = Field(default=None, description="Per-agent thinking-mode default (None = runtime default)") + reasoning_effort: ReasoningEffort | None = Field(default=None, description="Per-agent reasoning-effort default (None = runtime default)") soul: str | None = Field(default=None, description="SOUL.md content") @@ -45,6 +64,9 @@ class AgentCreateRequest(BaseModel): model: str | None = Field(default=None, description="Optional model override") tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist") skills: list[str] | None = Field(default=None, description="Optional skill whitelist (None=all enabled, []=none)") + model_settings: AgentModelSettings | None = Field(default=None, description="Per-agent sampling overrides (temperature / max_tokens)") + thinking_enabled: bool | None = Field(default=None, description="Per-agent thinking-mode default (None = runtime default)") + reasoning_effort: ReasoningEffort | None = Field(default=None, description="Per-agent reasoning-effort default (None = runtime default)") soul: str = Field(default="", description="SOUL.md content — agent personality and behavioral guardrails") @@ -55,6 +77,9 @@ class AgentUpdateRequest(BaseModel): model: str | None = Field(default=None, description="Updated model override") tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist") skills: list[str] | None = Field(default=None, description="Updated skill whitelist (None=all, []=none)") + model_settings: AgentModelSettings | None = Field(default=None, description="Updated per-agent sampling overrides") + thinking_enabled: bool | None = Field(default=None, description="Updated per-agent thinking-mode default") + reasoning_effort: ReasoningEffort | None = Field(default=None, description="Updated per-agent reasoning-effort default") soul: str | None = Field(default=None, description="Updated SOUL.md content") @@ -88,6 +113,71 @@ def _require_agents_api_enabled() -> None: ) +def _validate_model_exists(model: str | None) -> None: + """Reject an agent ``model`` that is not a configured profile. + + Mirrors the ``update_agent`` harness tool: without this, an unknown model + silently falls back to the default at runtime and the user sees confusing + repeated warnings on every later turn instead of an actionable error here. + ``None``/empty means "use the global default" and is always allowed. + + Best-effort: if the app config cannot be loaded (e.g. no ``config.yaml`` on + disk in a bare/test deployment), skip the check rather than failing the + write — the runtime still falls back to the default for an unknown model. + """ + if not model: + return + try: + app_config = get_app_config() + except Exception: + logger.warning("Could not load app config to validate agent model %r; skipping model existence check.", model) + return + if app_config.get_model_config(model) is None: + raise HTTPException(status_code=422, detail=f"Unknown model '{model}'. Use a model name defined under `models:` in config.yaml.") + + +def _merge_model_settings_update(value: AgentModelSettings, existing: AgentModelSettings | None) -> dict: + """Merge an explicit ``model_settings`` update with existing sub-fields. + + The top-level ``model_settings`` key is optional in update requests: + omitted means "preserve the current block", while explicit ``null`` means + "clear the block". Inside the block, omitted sub-fields should behave the + same way. This lets API callers update only ``temperature`` without + accidentally clearing an existing ``max_tokens``. + """ + merged = existing.model_dump(exclude_none=True) if existing is not None else {} + for field in value.model_fields_set: + field_value = getattr(value, field) + if field_value is None: + merged.pop(field, None) + else: + merged[field] = field_value + return merged + + +def _apply_model_behavior(config_data: dict, source: BaseModel, existing: AgentConfig | None = None) -> None: + """Write the model-behavior fields (issue #4336) onto ``config_data``. + + Only fields explicitly set on ``source`` (``model_fields_set``) are taken + from it; the rest fall back to ``existing`` (on update) so an omitted field + is preserved rather than cleared. A resulting ``None`` is dropped so the + persisted YAML stays minimal and "unset" round-trips cleanly. + """ + for field in _MODEL_BEHAVIOR_FIELDS: + if field in source.model_fields_set: + value = getattr(source, field) + else: + value = getattr(existing, field, None) if existing is not None else None + if value is None: + continue + if field == "model_settings" and isinstance(value, AgentModelSettings): + dumped_settings = _merge_model_settings_update(value, existing.model_settings if existing is not None else None) + if dumped_settings: + config_data[field] = dumped_settings + continue + config_data[field] = value.model_dump(exclude_none=True) if isinstance(value, BaseModel) else value + + def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False, *, user_id: str | None = None) -> AgentResponse: """Convert AgentConfig to AgentResponse.""" soul: str | None = None @@ -100,6 +190,9 @@ def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False model=agent_cfg.model, tool_groups=agent_cfg.tool_groups, skills=agent_cfg.skills, + model_settings=agent_cfg.model_settings, + thinking_enabled=agent_cfg.thinking_enabled, + reasoning_effort=agent_cfg.reasoning_effort, soul=soul, ) @@ -210,6 +303,7 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse: """ _require_agents_api_enabled() _validate_agent_name(request.name) + _validate_model_exists(request.model) normalized_name = _normalize_agent_name(request.name) user_id = get_effective_user_id() paths = get_paths() @@ -233,16 +327,15 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse: config_data: dict = {"name": normalized_name} if request.description: config_data["description"] = request.description - if request.model is not None: - config_data["model"] = request.model if request.tool_groups is not None: config_data["tool_groups"] = request.tool_groups if request.skills is not None: config_data["skills"] = request.skills + _apply_model_behavior(config_data, request) config_file = agent_dir / "config.yaml" with open(config_file, "w", encoding="utf-8") as f: - yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True) + yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True, sort_keys=False) # Write SOUL.md soul_file = agent_dir / "SOUL.md" @@ -315,21 +408,21 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse: detail=(f"Agent '{name}' only exists in the legacy shared layout and is not scoped to a user. Run scripts/migrate_user_isolation.py to move legacy agents into the per-user layout before updating."), ) + if "model" in request.model_fields_set: + _validate_model_exists(request.model) + try: # Update config if any config fields changed # Use model_fields_set to distinguish "field omitted" from "explicitly set to null". # This is critical for skills where None means "inherit all" (not "don't change"). fields_set = request.model_fields_set - config_changed = bool(fields_set & {"description", "model", "tool_groups", "skills"}) + config_changed = bool(fields_set & ({"description", "tool_groups", "skills"} | set(_MODEL_BEHAVIOR_FIELDS))) if config_changed: updated: dict = { "name": agent_cfg.name, "description": request.description if "description" in fields_set else agent_cfg.description, } - new_model = request.model if "model" in fields_set else agent_cfg.model - if new_model is not None: - updated["model"] = new_model new_tool_groups = request.tool_groups if "tool_groups" in fields_set else agent_cfg.tool_groups if new_tool_groups is not None: @@ -343,6 +436,11 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse: if new_skills is not None: updated["skills"] = new_skills + # model / model_settings / thinking_enabled / reasoning_effort: + # take explicitly-set request fields, else preserve the existing + # value (issue #4336). + _apply_model_behavior(updated, request, existing=agent_cfg) + # Carry forward every top-level AgentConfig field this route does # not manage (currently ``github:``, plus any future field added # to :class:`AgentConfig`). The harness ``update_agent`` tool uses @@ -356,7 +454,7 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse: config_file = agent_dir / "config.yaml" with open(config_file, "w", encoding="utf-8") as f: - yaml.dump(updated, f, default_flow_style=False, allow_unicode=True) + yaml.dump(updated, f, default_flow_style=False, allow_unicode=True, sort_keys=False) # Update SOUL.md if provided if request.soul is not None: diff --git a/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/backend/packages/harness/deerflow/agents/lead_agent/agent.py index 0ec8b4389..3275ff6dd 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -79,6 +79,22 @@ def _default_max_total_subagents(app_config: object) -> int: return getattr(subagents_config, "max_total_per_run", DEFAULT_MAX_TOTAL_SUBAGENTS_PER_RUN) +def _resolve_runtime_option(cfg: dict, key: str, agent_value, default): + """Resolve a runtime option with ``request > agent config > default`` precedence. + + ``key in cfg`` (not ``cfg.get(key)``) distinguishes "request omitted the + field" from "request set it to a falsy value", so a request-supplied + ``thinking_enabled: false`` is honored instead of falling through to the + agent default. ``agent_value`` is used only when it is not ``None`` (a + custom agent's unset field means "do not override" — issue #4336). + """ + if key in cfg: + return cfg[key] + if agent_value is not None: + return agent_value + return default + + def _append_memory_tools_without_name_conflicts(tools: list) -> None: """Append memory tools without dropping unrelated duplicate-named tools.""" from deerflow.agents.memory.tools import get_memory_tools @@ -503,8 +519,6 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): runtime_user_id = cfg.get("user_id") resolved_user_id = str(runtime_user_id) if runtime_user_id else get_effective_user_id() - thinking_enabled = cfg.get("thinking_enabled", True) - reasoning_effort = cfg.get("reasoning_effort", None) requested_model_name: str | None = cfg.get("model_name") or cfg.get("model") is_plan_mode = cfg.get("is_plan_mode", False) subagent_enabled = cfg.get("subagent_enabled", False) @@ -519,6 +533,19 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): # Custom agent model from agent config (if any), or None to let _resolve_model_name pick the default agent_model_name = agent_config.model if agent_config and agent_config.model else None + # thinking / reasoning precedence: request > custom agent default > runtime + # default (issue #4336). See ``_resolve_runtime_option`` for the falsy-vs-unset + # handling. + agent_thinking = getattr(agent_config, "thinking_enabled", None) if agent_config else None + agent_reasoning = getattr(agent_config, "reasoning_effort", None) if agent_config else None + thinking_enabled = bool(_resolve_runtime_option(cfg, "thinking_enabled", agent_thinking, True)) + reasoning_effort = _resolve_runtime_option(cfg, "reasoning_effort", agent_reasoning, None) + + # Per-agent sampling overrides (temperature / max_tokens) layered on top of + # the resolved model profile (issue #4336). None when the agent set none. + agent_model_settings = getattr(agent_config, "model_settings", None) if agent_config else None + agent_model_overrides = agent_model_settings.model_dump(exclude_none=True) if agent_model_settings else None + # Final model name resolution: request → agent config → global default, with fallback for unknown names model_name = _resolve_model_name(requested_model_name or agent_model_name, app_config=resolved_app_config) @@ -676,7 +703,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): if should_use_memory_tools(resolved_app_config.memory): _append_memory_tools_without_name_conflicts(final_tools) return create_agent( - model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config, attach_tracing=False), + model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config, attach_tracing=False, model_overrides=agent_model_overrides), tools=final_tools, middleware=normalize_middleware_state_schemas( build_middlewares( diff --git a/backend/packages/harness/deerflow/config/agents_config.py b/backend/packages/harness/deerflow/config/agents_config.py index 95647a725..25663c03d 100644 --- a/backend/packages/harness/deerflow/config/agents_config.py +++ b/backend/packages/harness/deerflow/config/agents_config.py @@ -10,10 +10,10 @@ per-user layout. import logging import re from pathlib import Path -from typing import Any +from typing import Any, Literal import yaml -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from deerflow.config.paths import get_paths from deerflow.runtime.user_context import get_effective_user_id @@ -22,6 +22,7 @@ logger = logging.getLogger(__name__) SOUL_FILENAME = "SOUL.md" AGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$") +MAX_AGENT_OUTPUT_TOKENS = 200_000 def _blank_to_none(value: str | None) -> str | None: @@ -156,6 +157,38 @@ def validate_agent_name(name: str | None) -> str | None: return name +class AgentModelSettings(BaseModel): + """Per-agent LLM sampling overrides layered on top of the model profile. + + These are provider sampling knobs (not DeerFlow runtime switches like + ``thinking_enabled``). They let two agents that reference the *same* + ``models:`` profile still run with different temperature / output length — + the core ask of issue #4336, where "different agents have different + capabilities, so a shared temperature is a poor fit". + + ``extra="forbid"``: the sampling surface is an explicit allowlist so a + stray key never reaches the provider request body and fails at request + time with an opaque error. Widen it by adding a declared field (e.g. + ``top_p``) rather than relaxing the model config. Every field is optional; + ``None`` means "do not override the profile value". + """ + + model_config = ConfigDict(extra="forbid") + + temperature: float | None = Field( + default=None, + ge=0.0, + le=2.0, + description="Sampling temperature override (0.0-2.0). None = inherit the model profile's value.", + ) + max_tokens: int | None = Field( + default=None, + ge=1, + le=MAX_AGENT_OUTPUT_TOKENS, + description=f"Max output tokens override (1-{MAX_AGENT_OUTPUT_TOKENS}). None = inherit the model profile's value.", + ) + + class AgentConfig(BaseModel): """Configuration for a custom agent.""" @@ -169,21 +202,42 @@ class AgentConfig(BaseModel): # - [] (explicit empty list): disable all skills # - ["skill1", "skill2"]: load only the specified skills skills: list[str] | None = None + # Per-agent LLM sampling overrides (temperature / max_tokens) layered on top + # of the referenced model profile. None = no overrides (issue #4336). + model_settings: AgentModelSettings | None = None + # Per-agent thinking-mode default. None = do not override the runtime + # default (a request-supplied thinking flag still wins over this). + thinking_enabled: bool | None = None + # Per-agent reasoning-effort default for models that support it. None = do + # not override (a request-supplied reasoning_effort still wins over this). + reasoning_effort: Literal["low", "medium", "high"] | None = None # Optional binding to GitHub repositories so this agent can respond to # webhook events from the gateway dispatcher. None means "no GitHub # integration", which is the case for every existing agent. github: GitHubAgentConfig | None = None -# Fields explicitly managed by the agent-update surfaces (the -# ``update_agent`` harness tool and the HTTP ``PATCH /api/agents/{name}`` -# route). Anything else declared on :class:`AgentConfig` — currently -# ``github``, and any future field — is preserved verbatim by -# :func:`preserve_non_managed_fields` so neither surface can silently -# drop hand-authored configuration. ``name`` is included because the -# updaters always re-emit it from the directory name (it must never come -# from the request body). -MANAGED_AGENT_CONFIG_FIELDS: frozenset[str] = frozenset({"name", "description", "model", "tool_groups", "skills"}) +# Fields explicitly managed by agent-update surfaces. Anything else declared +# on :class:`AgentConfig` — currently ``github``, and any future field — is +# preserved verbatim by :func:`preserve_non_managed_fields` so update surfaces +# do not silently drop hand-authored configuration. Some surfaces expose only a +# subset of these managed fields (for example, the harness ``update_agent`` +# tool does not accept model-behavior arguments), so they must carry their +# unsupported managed fields forward explicitly when rewriting config.yaml. +# ``name`` is included because updaters always re-emit it from the directory +# name (it must never come from the request body). +MANAGED_AGENT_CONFIG_FIELDS: frozenset[str] = frozenset( + { + "name", + "description", + "model", + "tool_groups", + "skills", + "model_settings", + "thinking_enabled", + "reasoning_effort", + } +) def preserve_non_managed_fields(existing_cfg: AgentConfig) -> dict[str, object]: diff --git a/backend/packages/harness/deerflow/models/factory.py b/backend/packages/harness/deerflow/models/factory.py index d06eed687..c6cd3bc3f 100644 --- a/backend/packages/harness/deerflow/models/factory.py +++ b/backend/packages/harness/deerflow/models/factory.py @@ -171,13 +171,19 @@ def _apply_stream_chunk_timeout_default(model_class: type, model_settings_from_c model_settings_from_config["stream_chunk_timeout"] = _DEFAULT_STREAM_CHUNK_TIMEOUT_SECONDS -def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *, app_config: AppConfig | None = None, attach_tracing: bool = True, **kwargs) -> BaseChatModel: +def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *, app_config: AppConfig | None = None, attach_tracing: bool = True, model_overrides: dict | None = None, **kwargs) -> BaseChatModel: """Create a chat model instance from the config. Args: name: The name of the model to create. If None, the first model in the config will be used. thinking_enabled: Enable the model's extended-thinking mode when supported. app_config: Explicit application config; falls back to the cached global if omitted. + model_overrides: Optional per-caller sampling overrides (e.g. a custom + agent's ``temperature`` / ``max_tokens``) layered on top of the + model profile. ``None`` values are ignored so an unset override + never clobbers a profile value. Applied before the thinking / Codex + transforms so provider-specific normalization (e.g. Codex dropping + ``max_tokens``) still governs an overridden value. attach_tracing: When True (default), attach tracing callbacks (Langfuse, LangSmith) directly to the model instance. Standalone callers — anything that invokes the model outside a LangGraph run that already wires tracing @@ -219,6 +225,14 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, * "pricing", }, ) + # Layer per-caller sampling overrides (e.g. a custom agent's temperature / + # max_tokens) on top of the profile. Ignore None so an unset override never + # clobbers a configured profile value. Applied here — before the thinking + # and Codex transforms below — so provider-specific normalization (Codex + # dropping max_tokens, thinking disable-paths) still governs the merged + # value exactly as it would a profile-native one. + if model_overrides: + model_settings_from_config.update({key: value for key, value in model_overrides.items() if value is not None}) # Compute effective when_thinking_enabled by merging in the `thinking` shortcut field. # The `thinking` shortcut is equivalent to setting when_thinking_enabled["thinking"]. has_thinking_settings = (model_config.when_thinking_enabled is not None) or (model_config.thinking is not None) diff --git a/backend/packages/harness/deerflow/tools/builtins/update_agent_tool.py b/backend/packages/harness/deerflow/tools/builtins/update_agent_tool.py index 1dbc74317..7e4910f70 100644 --- a/backend/packages/harness/deerflow/tools/builtins/update_agent_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/update_agent_tool.py @@ -23,7 +23,7 @@ import yaml from langchain_core.messages import ToolMessage from langchain_core.tools import tool from langgraph.types import Command -from pydantic import BeforeValidator +from pydantic import BaseModel, BeforeValidator from deerflow.config.agents_config import load_agent_config, preserve_non_managed_fields, validate_agent_name from deerflow.config.app_config import get_app_config @@ -43,6 +43,12 @@ _NULLISH_STRINGS = frozenset({"null", "none", "undefined"}) # expose self-mutation over a webhook. _UNTRUSTED_CHANNELS: frozenset[str] = frozenset({"github"}) +_MODEL_BEHAVIOR_FIELDS: tuple[str, ...] = ( + "model_settings", + "thinking_enabled", + "reasoning_effort", +) + def _stage_temp(path: Path, text: str) -> Path: """Write ``text`` into a sibling temp file and return its path. @@ -227,6 +233,22 @@ def update_agent( if skills is not None and skills != existing_cfg.skills: updated_fields.append("skills") + # This tool intentionally does not expose the #4336 model-behavior fields + # as LLM-callable arguments yet, but it still rewrites config.yaml when any + # of its supported fields changes. Carry those values forward explicitly so + # an agent refining its description/model/skills cannot erase UI/API-owned + # defaults such as temperature or reasoning effort. + for key in _MODEL_BEHAVIOR_FIELDS: + value = getattr(existing_cfg, key, None) + if value is None: + continue + if isinstance(value, BaseModel): + dumped = value.model_dump(exclude_none=True) + if dumped: + config_data[key] = dumped + else: + config_data[key] = value + # Preserve every top-level AgentConfig field that this tool does not # expose as an argument (currently ``github:``, plus any future field # added to :class:`AgentConfig`). The same helper is used by the HTTP diff --git a/backend/tests/test_agent_model_settings.py b/backend/tests/test_agent_model_settings.py new file mode 100644 index 000000000..e468527c9 --- /dev/null +++ b/backend/tests/test_agent_model_settings.py @@ -0,0 +1,144 @@ +"""Tests for the per-agent model-settings block on :class:`AgentConfig` (#4336). + +Covers the new ``model_settings`` / ``thinking_enabled`` / ``reasoning_effort`` +fields: validation bounds, YAML round-trip via ``load_agent_config``, backward +compatibility (absent = None), and that they are treated as managed fields by +``preserve_non_managed_fields``. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml +from pydantic import ValidationError + +from deerflow.config.agents_config import ( + MANAGED_AGENT_CONFIG_FIELDS, + MAX_AGENT_OUTPUT_TOKENS, + AgentConfig, + AgentModelSettings, + load_agent_config, + preserve_non_managed_fields, +) + + +def test_model_settings_default_to_none() -> None: + cfg = AgentConfig(name="solo") + assert cfg.model_settings is None + assert cfg.thinking_enabled is None + assert cfg.reasoning_effort is None + + +def test_model_settings_parse_full_shape() -> None: + cfg = AgentConfig( + name="researcher", + model_settings={"temperature": 0.2, "max_tokens": 12000}, + thinking_enabled=True, + reasoning_effort="high", + ) + assert isinstance(cfg.model_settings, AgentModelSettings) + assert cfg.model_settings.temperature == 0.2 + assert cfg.model_settings.max_tokens == 12000 + assert cfg.thinking_enabled is True + assert cfg.reasoning_effort == "high" + + +@pytest.mark.parametrize("temperature", [-0.1, 2.1]) +def test_temperature_out_of_range_rejected(temperature: float) -> None: + with pytest.raises(ValidationError): + AgentModelSettings(temperature=temperature) + + +def test_max_tokens_must_be_positive() -> None: + with pytest.raises(ValidationError): + AgentModelSettings(max_tokens=0) + + +def test_max_tokens_has_sane_upper_bound() -> None: + with pytest.raises(ValidationError): + AgentModelSettings(max_tokens=MAX_AGENT_OUTPUT_TOKENS + 1) + + +def test_model_settings_forbids_unknown_keys() -> None: + with pytest.raises(ValidationError): + AgentModelSettings(top_p=0.9) # type: ignore[call-arg] + + +def test_reasoning_effort_rejects_unknown_value() -> None: + with pytest.raises(ValidationError): + AgentConfig(name="x", reasoning_effort="turbo") # type: ignore[arg-type] + + +def test_reasoning_effort_rejects_codex_unsupported_minimal() -> None: + with pytest.raises(ValidationError): + AgentConfig(name="x", reasoning_effort="minimal") # type: ignore[arg-type] + + +def test_model_settings_are_managed_fields() -> None: + # These are managed by the HTTP agent settings API, so the generic + # preserve_non_managed_fields helper must not also carry them. Surfaces that + # do not expose them directly, such as the harness update_agent tool, need a + # dedicated carry-forward path instead. + for field in ("model_settings", "thinking_enabled", "reasoning_effort"): + assert field in MANAGED_AGENT_CONFIG_FIELDS + + +def test_preserve_non_managed_excludes_model_settings() -> None: + cfg = AgentConfig( + name="a", + model_settings={"temperature": 0.5}, + thinking_enabled=True, + github={"installation_id": 7}, + ) + preserved = preserve_non_managed_fields(cfg) + assert "model_settings" not in preserved + assert "thinking_enabled" not in preserved + # github stays preserved (hand-authored, not managed by the update surfaces). + assert "github" in preserved + + +def _write_agent(base: Path, user_id: str, name: str, body: dict) -> None: + agent_dir = base / "users" / user_id / "agents" / name + agent_dir.mkdir(parents=True, exist_ok=True) + (agent_dir / "config.yaml").write_text(yaml.safe_dump(body), encoding="utf-8") + + +def test_load_agent_config_round_trips_model_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path)) + from deerflow.config import paths as paths_module + + monkeypatch.setattr(paths_module, "_paths", None) + + body = { + "name": "researcher", + "model": "claude-sonnet-4-6", + "model_settings": {"temperature": 0.2, "max_tokens": 12000}, + "thinking_enabled": True, + "reasoning_effort": "high", + } + _write_agent(tmp_path, "default", "researcher", body) + + cfg = load_agent_config("researcher", user_id="default") + assert cfg is not None + assert cfg.model_settings is not None + assert cfg.model_settings.temperature == 0.2 + assert cfg.model_settings.max_tokens == 12000 + assert cfg.thinking_enabled is True + assert cfg.reasoning_effort == "high" + + +def test_load_agent_config_without_model_settings_is_none(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path)) + from deerflow.config import paths as paths_module + + monkeypatch.setattr(paths_module, "_paths", None) + + _write_agent(tmp_path, "default", "plain", {"name": "plain", "model": "gpt-4o"}) + + cfg = load_agent_config("plain", user_id="default") + assert cfg is not None + assert cfg.model_settings is None + assert cfg.thinking_enabled is None + assert cfg.reasoning_effort is None diff --git a/backend/tests/test_agents_router_model_settings.py b/backend/tests/test_agents_router_model_settings.py new file mode 100644 index 000000000..908224bb5 --- /dev/null +++ b/backend/tests/test_agents_router_model_settings.py @@ -0,0 +1,146 @@ +"""API tests for per-agent model settings (issue #4336). + +Exercises the ``/api/agents`` create/update handlers directly: the new +``model_settings`` / ``thinking_enabled`` / ``reasoning_effort`` fields +persist and round-trip, an omitted field is preserved on update, and an +unknown ``model`` is rejected before it reaches the runtime. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi import HTTPException + +from app.gateway.routers.agents import ( + AgentCreateRequest, + AgentUpdateRequest, + create_agent_endpoint, + get_agent, + update_agent, +) +from deerflow.config.agents_api_config import load_agents_api_config_from_dict +from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config +from deerflow.config.model_config import ModelConfig +from deerflow.config.sandbox_config import SandboxConfig + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture +def _agent_env(tmp_path: Path, monkeypatch): + monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path)) + monkeypatch.setattr("deerflow.config.paths._paths", None) + load_agents_api_config_from_dict({"enabled": True}) + set_app_config( + AppConfig( + models=[ModelConfig(name="agent-model", display_name="Agent Model", description=None, use="langchain_openai:ChatOpenAI", model="agent-model")], + sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"), + ) + ) + try: + yield + finally: + load_agents_api_config_from_dict({}) + reset_app_config() + + +async def test_create_persists_model_settings(_agent_env) -> None: + resp = await create_agent_endpoint( + AgentCreateRequest( + name="researcher", + model="agent-model", + model_settings={"temperature": 0.2, "max_tokens": 12000}, + thinking_enabled=True, + reasoning_effort="high", + soul="You are a researcher.", + ) + ) + assert resp.model == "agent-model" + assert resp.model_settings is not None + assert resp.model_settings.temperature == 0.2 + assert resp.model_settings.max_tokens == 12000 + assert resp.thinking_enabled is True + assert resp.reasoning_effort == "high" + + # Reload through the read path to confirm it round-tripped to disk. + fetched = await get_agent("researcher") + assert fetched.model_settings is not None + assert fetched.model_settings.temperature == 0.2 + assert fetched.reasoning_effort == "high" + + +async def test_create_rejects_unknown_model(_agent_env) -> None: + with pytest.raises(HTTPException) as excinfo: + await create_agent_endpoint(AgentCreateRequest(name="bad", model="ghost-model")) + assert excinfo.value.status_code == 422 + + +async def test_update_preserves_unset_model_settings(_agent_env) -> None: + await create_agent_endpoint( + AgentCreateRequest( + name="researcher", + model="agent-model", + model_settings={"temperature": 0.2, "max_tokens": 12000}, + thinking_enabled=True, + ) + ) + + # Update only the description — model_settings / thinking must be preserved. + resp = await update_agent("researcher", AgentUpdateRequest(description="updated")) + assert resp.description == "updated" + assert resp.model_settings is not None + assert resp.model_settings.temperature == 0.2 + assert resp.thinking_enabled is True + + +async def test_update_changes_model_settings(_agent_env) -> None: + await create_agent_endpoint(AgentCreateRequest(name="researcher", model="agent-model", model_settings={"temperature": 0.2})) + + resp = await update_agent( + "researcher", + AgentUpdateRequest(model_settings={"temperature": 0.9, "max_tokens": 2048}), + ) + assert resp.model_settings is not None + assert resp.model_settings.temperature == 0.9 + assert resp.model_settings.max_tokens == 2048 + + +async def test_update_model_settings_merges_omitted_subfields(_agent_env) -> None: + await create_agent_endpoint( + AgentCreateRequest( + name="researcher", + model="agent-model", + model_settings={"temperature": 0.2, "max_tokens": 12000}, + ) + ) + + resp = await update_agent("researcher", AgentUpdateRequest(model_settings={"temperature": 0.9})) + + assert resp.model_settings is not None + assert resp.model_settings.temperature == 0.9 + assert resp.model_settings.max_tokens == 12000 + + +async def test_update_model_settings_null_subfield_clears_only_that_subfield(_agent_env) -> None: + await create_agent_endpoint( + AgentCreateRequest( + name="researcher", + model="agent-model", + model_settings={"temperature": 0.2, "max_tokens": 12000}, + ) + ) + + resp = await update_agent("researcher", AgentUpdateRequest(model_settings={"max_tokens": None})) + + assert resp.model_settings is not None + assert resp.model_settings.temperature == 0.2 + assert resp.model_settings.max_tokens is None + + +async def test_update_rejects_unknown_model(_agent_env) -> None: + await create_agent_endpoint(AgentCreateRequest(name="researcher", model="agent-model")) + with pytest.raises(HTTPException) as excinfo: + await update_agent("researcher", AgentUpdateRequest(model="ghost-model")) + assert excinfo.value.status_code == 422 diff --git a/backend/tests/test_lead_agent_model_resolution.py b/backend/tests/test_lead_agent_model_resolution.py index 14c092e14..80d44207e 100644 --- a/backend/tests/test_lead_agent_model_resolution.py +++ b/backend/tests/test_lead_agent_model_resolution.py @@ -149,7 +149,7 @@ def test_make_lead_agent_attaches_tracing_callbacks_at_graph_root(monkeypatch): seen_attach_tracing: list[bool] = [] - def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True): + def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True, model_overrides=None): seen_attach_tracing.append(attach_tracing) return object() @@ -184,7 +184,7 @@ def test_internal_make_lead_agent_uses_explicit_app_config(monkeypatch): captured: dict[str, object] = {} - def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True): + def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True, model_overrides=None): captured["name"] = name captured["app_config"] = app_config return object() @@ -305,7 +305,7 @@ def test_make_lead_agent_uses_runtime_app_config_from_context_without_global_rea captured: dict[str, object] = {} - def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True): + def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True, model_overrides=None): captured["name"] = name captured["app_config"] = app_config return object() @@ -384,7 +384,7 @@ def test_make_lead_agent_disables_thinking_when_model_does_not_support_it(monkey captured: dict[str, object] = {} - def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True): + def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True, model_overrides=None): captured["name"] = name captured["thinking_enabled"] = thinking_enabled captured["reasoning_effort"] = reasoning_effort @@ -428,7 +428,7 @@ def test_make_lead_agent_reads_runtime_options_from_context(monkeypatch): captured: dict[str, object] = {} - def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True): + def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True, model_overrides=None): captured["name"] = name captured["thinking_enabled"] = thinking_enabled captured["reasoning_effort"] = reasoning_effort @@ -958,7 +958,7 @@ def test_create_summarization_middleware_uses_configured_model_alias(monkeypatch fake_model = MagicMock() fake_model.with_config.return_value = fake_model - def _fake_create_chat_model(*, name=None, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True): + def _fake_create_chat_model(*, name=None, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True, model_overrides=None): captured["name"] = name captured["thinking_enabled"] = thinking_enabled captured["reasoning_effort"] = reasoning_effort @@ -1034,7 +1034,7 @@ def test_create_summarization_middleware_threads_resolved_app_config_to_model(mo fake_model = MagicMock() fake_model.with_config.return_value = fake_model - def _fake_create_chat_model(*, name=None, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True): + def _fake_create_chat_model(*, name=None, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True, model_overrides=None): captured["app_config"] = app_config return fake_model @@ -1059,3 +1059,111 @@ def test_memory_middleware_uses_explicit_memory_config_without_global_read(monke middleware = MemoryMiddleware(memory_config=MemoryConfig(enabled=False)) assert middleware.after_agent({"messages": []}, runtime=MagicMock(context={"thread_id": "thread-1"})) is None + + +# --------------------------------------------------------------------------- +# Per-agent model settings (issue #4336) +# --------------------------------------------------------------------------- + + +def test_resolve_runtime_option_precedence(): + # request value wins, even when falsy + assert lead_agent_module._resolve_runtime_option({"thinking_enabled": False}, "thinking_enabled", True, True) is False + # agent value used when request omits the key + assert lead_agent_module._resolve_runtime_option({}, "thinking_enabled", True, False) is True + # default when neither request nor agent set it + assert lead_agent_module._resolve_runtime_option({}, "thinking_enabled", None, False) is False + + +def _make_agent_config(**kwargs): + from deerflow.config.agents_config import AgentConfig + + return AgentConfig(name="researcher", **kwargs) + + +def test_make_lead_agent_applies_agent_model_settings(monkeypatch): + """A custom agent's model_settings flow into create_chat_model as + model_overrides, and its thinking/reasoning defaults apply when the request + omits them (issue #4336).""" + app_config = _make_app_config([_make_model("agent-model", supports_thinking=True)]) + agent_config = _make_agent_config( + model="agent-model", + model_settings={"temperature": 0.2, "max_tokens": 12000}, + thinking_enabled=False, + reasoning_effort="high", + ) + + import deerflow.tools as tools_module + + monkeypatch.setattr(lead_agent_module, "load_agent_config", lambda name: agent_config) + monkeypatch.setattr(tools_module, "get_available_tools", lambda **kwargs: []) + monkeypatch.setattr(lead_agent_module, "build_middlewares", lambda config, model_name, agent_name=None, **kwargs: []) + + captured: dict[str, object] = {} + + def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True, model_overrides=None): + captured["thinking_enabled"] = thinking_enabled + captured["reasoning_effort"] = reasoning_effort + captured["model_overrides"] = model_overrides + return object() + + monkeypatch.setattr(lead_agent_module, "create_chat_model", _fake_create_chat_model) + monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs) + + lead_agent_module._make_lead_agent({"context": {"agent_name": "researcher"}}, app_config=app_config) + + assert captured["model_overrides"] == {"temperature": 0.2, "max_tokens": 12000} + assert captured["thinking_enabled"] is False # from agent config + assert captured["reasoning_effort"] == "high" # from agent config + + +def test_request_thinking_overrides_agent_default(monkeypatch): + """An explicit request thinking_enabled wins over the agent's default.""" + app_config = _make_app_config([_make_model("agent-model", supports_thinking=True)]) + agent_config = _make_agent_config(model="agent-model", thinking_enabled=False) + + import deerflow.tools as tools_module + + monkeypatch.setattr(lead_agent_module, "load_agent_config", lambda name: agent_config) + monkeypatch.setattr(tools_module, "get_available_tools", lambda **kwargs: []) + monkeypatch.setattr(lead_agent_module, "build_middlewares", lambda config, model_name, agent_name=None, **kwargs: []) + + captured: dict[str, object] = {} + + def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True, model_overrides=None): + captured["thinking_enabled"] = thinking_enabled + return object() + + monkeypatch.setattr(lead_agent_module, "create_chat_model", _fake_create_chat_model) + monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs) + + lead_agent_module._make_lead_agent( + {"context": {"agent_name": "researcher", "thinking_enabled": True}}, + app_config=app_config, + ) + + assert captured["thinking_enabled"] is True # request wins over agent's False + + +def test_make_lead_agent_no_agent_settings_passes_none_overrides(monkeypatch): + """Without a custom agent, model_overrides is None (no behavior change).""" + app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)]) + + import deerflow.tools as tools_module + + monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config) + monkeypatch.setattr(tools_module, "get_available_tools", lambda **kwargs: []) + monkeypatch.setattr(lead_agent_module, "build_middlewares", lambda config, model_name, agent_name=None, **kwargs: []) + + captured: dict[str, object] = {} + + def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True, model_overrides=None): + captured["model_overrides"] = model_overrides + return object() + + monkeypatch.setattr(lead_agent_module, "create_chat_model", _fake_create_chat_model) + monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs) + + lead_agent_module._make_lead_agent({"context": {"model_name": "safe-model"}}, app_config=app_config) + + assert captured["model_overrides"] is None diff --git a/backend/tests/test_model_factory.py b/backend/tests/test_model_factory.py index ca2adc738..fb849bd07 100644 --- a/backend/tests/test_model_factory.py +++ b/backend/tests/test_model_factory.py @@ -1536,3 +1536,57 @@ def test_api_base_reaches_real_minimax_constructor_as_base_url(monkeypatch): assert instance.openai_api_base == "https://api.minimax.io/v1" assert "api_base" not in (instance.model_kwargs or {}) + + +# --------------------------------------------------------------------------- +# Per-agent model_overrides (issue #4336) +# --------------------------------------------------------------------------- + + +def test_model_overrides_layer_on_top_of_profile(monkeypatch): + """A caller's model_overrides win over the profile's same-named values.""" + cfg = _make_app_config([_make_model("base", max_tokens=4096)]) + _patch_factory(monkeypatch, cfg) + + FakeChatModel.captured_kwargs = {} + factory_module.create_chat_model(name="base", model_overrides={"temperature": 0.2, "max_tokens": 12000}) + + assert FakeChatModel.captured_kwargs.get("temperature") == 0.2 + assert FakeChatModel.captured_kwargs.get("max_tokens") == 12000 + + +def test_model_overrides_ignore_none_values(monkeypatch): + """A None override never clobbers a configured profile value.""" + cfg = _make_app_config([_make_model("base", max_tokens=4096)]) + _patch_factory(monkeypatch, cfg) + + FakeChatModel.captured_kwargs = {} + factory_module.create_chat_model(name="base", model_overrides={"temperature": None, "max_tokens": None}) + + assert "temperature" not in FakeChatModel.captured_kwargs + assert FakeChatModel.captured_kwargs.get("max_tokens") == 4096 + + +def test_model_overrides_none_is_a_noop(monkeypatch): + """Passing model_overrides=None leaves the profile untouched.""" + cfg = _make_app_config([_make_model("base", max_tokens=4096)]) + _patch_factory(monkeypatch, cfg) + + FakeChatModel.captured_kwargs = {} + factory_module.create_chat_model(name="base", model_overrides=None) + + assert FakeChatModel.captured_kwargs.get("max_tokens") == 4096 + + +def test_codex_still_strips_overridden_max_tokens(monkeypatch): + """Codex drops max_tokens even when it arrived via an override, so the + provider-specific normalization still governs the merged value.""" + cfg = _make_app_config([_make_model("codex", use="deerflow.models.openai_codex_provider:CodexChatModel")]) + captured: dict = {} + monkeypatch.setattr(factory_module, "get_app_config", lambda: cfg) + monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: _capturing_class(codex_provider_module.CodexChatModel, captured)) + monkeypatch.setattr(factory_module, "build_tracing_callbacks", lambda: []) + + factory_module.create_chat_model(name="codex", model_overrides={"max_tokens": 9999}) + + assert "max_tokens" not in captured diff --git a/backend/tests/test_update_agent_tool.py b/backend/tests/test_update_agent_tool.py index 593eb068c..2e2e14e0f 100644 --- a/backend/tests/test_update_agent_tool.py +++ b/backend/tests/test_update_agent_tool.py @@ -342,6 +342,34 @@ def test_update_agent_preserves_github_block_on_description_change(tmp_path, pat assert cfg["github"] == github_block +def test_update_agent_preserves_model_behavior_on_description_change(tmp_path, patched_paths): + """UI/API-owned model behavior must survive agent self-edits. + + ``update_agent`` does not expose temperature / max_tokens / thinking / + reasoning arguments to the LLM, but it still rewrites config.yaml for + ordinary self-edits. Those fields therefore need an explicit carry-forward + path or a description tweak would silently reset the agent's model defaults. + """ + agent_dir = _seed_agent(tmp_path, description="old desc") + cfg = yaml.safe_load((agent_dir / "config.yaml").read_text()) + cfg.update( + { + "model_settings": {"temperature": 0.2, "max_tokens": 12000}, + "thinking_enabled": True, + "reasoning_effort": "high", + } + ) + (agent_dir / "config.yaml").write_text(yaml.safe_dump(cfg, sort_keys=False), encoding="utf-8") + + update_agent.func(runtime=_runtime(), description="refined desc") + + out = yaml.safe_load((agent_dir / "config.yaml").read_text()) + assert out["description"] == "refined desc" + assert out["model_settings"] == {"temperature": 0.2, "max_tokens": 12000} + assert out["thinking_enabled"] is True + assert out["reasoning_effort"] == "high" + + def test_update_agent_skills_empty_list_disables_all(tmp_path, patched_paths): agent_dir = _seed_agent(tmp_path, skills=["a", "b"]) diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index c2d59ea13..a92d07ef4 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -365,6 +365,7 @@ export default function AgentChatPage() { threadId={threadId} draftThreadId={isNewThread ? "new" : threadId} draftAgentName={agent_name} + defaultModelName={agent?.model} autoFocus={isWelcomeMode} status={ thread.error diff --git a/frontend/src/components/workspace/agents/agent-card.tsx b/frontend/src/components/workspace/agents/agent-card.tsx index 8cb7f6b7a..a38848f16 100644 --- a/frontend/src/components/workspace/agents/agent-card.tsx +++ b/frontend/src/components/workspace/agents/agent-card.tsx @@ -1,6 +1,11 @@ "use client"; -import { BotIcon, MessageSquareIcon, Trash2Icon } from "lucide-react"; +import { + BotIcon, + MessageSquareIcon, + Settings2Icon, + Trash2Icon, +} from "lucide-react"; import { useRouter } from "next/navigation"; import { type ComponentProps, type ReactElement, useState } from "react"; import { toast } from "sonner"; @@ -33,6 +38,8 @@ import type { Agent } from "@/core/agents"; import { useI18n } from "@/core/i18n/hooks"; import { cn } from "@/lib/utils"; +import { AgentSettingsDialog } from "./agent-settings-dialog"; + interface AgentCardProps { agent: Agent; } @@ -105,6 +112,7 @@ export function AgentCard({ agent }: AgentCardProps) { const router = useRouter(); const deleteAgent = useDeleteAgent(); const [deleteOpen, setDeleteOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); function handleChat() { router.push(`/workspace/agents/${agent.name}/chats/new`); @@ -183,6 +191,15 @@ export function AgentCard({ agent }: AgentCardProps) { {t.agents.chat}
+ + + + + + ); +} diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 097137211..e44801edd 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -292,6 +292,7 @@ export function InputBox({ threadId, draftThreadId = threadId, draftAgentName, + defaultModelName, initialValue, onContextChange, onFollowupsVisibilityChange, @@ -320,6 +321,13 @@ export function InputBox({ threadId: string; draftThreadId?: string; draftAgentName?: string | null; + /** + * The active custom agent's configured default model, if any. Used as the + * auto-selection fallback so an agent chat honors the agent's own default + * model instead of silently snapping to the first configured model + * (issue #4336). ``null`` / undefined = no agent default → use models[0]. + */ + defaultModelName?: string | null; initialValue?: string; onContextChange?: ( context: Omit< @@ -546,7 +554,13 @@ export function InputBox({ return; } const currentModel = models.find((m) => m.name === context.model_name); - const fallbackModel = currentModel ?? models[0]!; + // Prefer the active agent's configured default model over models[0] as the + // auto-selection fallback, so an agent chat respects the agent's own + // default instead of snapping to the first model (issue #4336). + const agentDefaultModel = defaultModelName + ? models.find((m) => m.name === defaultModelName) + : undefined; + const fallbackModel = currentModel ?? agentDefaultModel ?? models[0]!; const supportsThinking = fallbackModel.supports_thinking ?? false; const nextModelName = fallbackModel.name; const nextMode = getResolvedMode(context.mode, supportsThinking); @@ -560,7 +574,7 @@ export function InputBox({ model_name: nextModelName, mode: nextMode, }); - }, [context, models, onContextChange]); + }, [context, models, defaultModelName, onContextChange]); const selectedModel = useMemo(() => { if (models.length === 0) { diff --git a/frontend/src/core/agents/types.ts b/frontend/src/core/agents/types.ts index 53e09ba66..7fe75d464 100644 --- a/frontend/src/core/agents/types.ts +++ b/frontend/src/core/agents/types.ts @@ -1,9 +1,19 @@ +export interface AgentModelSettings { + temperature?: number | null; + max_tokens?: number | null; +} + +export type ReasoningEffort = "low" | "medium" | "high"; + export interface Agent { name: string; description: string; model: string | null; tool_groups: string[] | null; skills: string[] | null; + model_settings?: AgentModelSettings | null; + thinking_enabled?: boolean | null; + reasoning_effort?: ReasoningEffort | null; soul?: string | null; } @@ -13,6 +23,9 @@ export interface CreateAgentRequest { model?: string | null; tool_groups?: string[] | null; skills?: string[] | null; + model_settings?: AgentModelSettings | null; + thinking_enabled?: boolean | null; + reasoning_effort?: ReasoningEffort | null; soul?: string; } @@ -21,5 +34,8 @@ export interface UpdateAgentRequest { model?: string | null; tool_groups?: string[] | null; skills?: string[] | null; + model_settings?: AgentModelSettings | null; + thinking_enabled?: boolean | null; + reasoning_effort?: ReasoningEffort | null; soul?: string | null; } diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 5ba0e7cee..b18c5ac5f 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -444,6 +444,25 @@ export const enUS: Translations = { agentCreated: "Agent created!", startChatting: "Start chatting", backToGallery: "Back to Gallery", + settings: "Model settings", + settingsTitle: "Model settings", + settingsDescription: + "Choose the default model and generation parameters for this agent. Changes take effect on the next message.", + settingsModel: "Default model", + settingsModelDefault: "Use global default", + settingsTemperature: "Temperature", + settingsTemperatureHint: "0 = deterministic, higher = more creative (0–2).", + settingsMaxTokens: "Max output tokens", + settingsMaxTokensPlaceholder: "Inherit from model", + settingsThinking: "Thinking mode", + settingsThinkingOn: "On", + settingsThinkingOff: "Off", + settingsReasoningEffort: "Reasoning effort", + settingsInherit: "Inherit", + settingsSaved: "Model settings saved", + settingsInvalidTemperature: "Temperature must be between 0 and 2", + settingsInvalidMaxTokens: + "Max output tokens must be a positive integer up to 200,000", }, // Breadcrumb diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 2ac4b413b..8e0248a65 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -346,6 +346,23 @@ export interface Translations { agentCreated: string; startChatting: string; backToGallery: string; + settings: string; + settingsTitle: string; + settingsDescription: string; + settingsModel: string; + settingsModelDefault: string; + settingsTemperature: string; + settingsTemperatureHint: string; + settingsMaxTokens: string; + settingsMaxTokensPlaceholder: string; + settingsThinking: string; + settingsThinkingOn: string; + settingsThinkingOff: string; + settingsReasoningEffort: string; + settingsInherit: string; + settingsSaved: string; + settingsInvalidTemperature: string; + settingsInvalidMaxTokens: string; }; // Breadcrumb diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 8909dc7b8..3f20c1062 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -423,6 +423,24 @@ export const zhCN: Translations = { agentCreated: "智能体已创建!", startChatting: "开始对话", backToGallery: "返回 Gallery", + settings: "模型设置", + settingsTitle: "模型设置", + settingsDescription: + "为该智能体选择默认模型和生成参数,修改在下一条消息生效。", + settingsModel: "默认模型", + settingsModelDefault: "使用全局默认", + settingsTemperature: "温度", + settingsTemperatureHint: "0 = 确定性输出,越高越发散(0–2)。", + settingsMaxTokens: "最大输出 token", + settingsMaxTokensPlaceholder: "继承模型配置", + settingsThinking: "思考模式", + settingsThinkingOn: "开启", + settingsThinkingOff: "关闭", + settingsReasoningEffort: "推理强度", + settingsInherit: "继承", + settingsSaved: "模型设置已保存", + settingsInvalidTemperature: "温度必须在 0 到 2 之间", + settingsInvalidMaxTokens: "最大输出 token 必须为不超过 200,000 的正整数", }, // Breadcrumb diff --git a/frontend/tests/unit/components/workspace/agents/agent-settings-dialog-helpers.test.ts b/frontend/tests/unit/components/workspace/agents/agent-settings-dialog-helpers.test.ts new file mode 100644 index 000000000..4a50d2887 --- /dev/null +++ b/frontend/tests/unit/components/workspace/agents/agent-settings-dialog-helpers.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "@rstest/core"; + +import { + DEFAULT_MODEL_VALUE, + INHERIT_VALUE, + MAX_AGENT_OUTPUT_TOKENS, + parseAgentModelSettingsDraft, + resolveEffectiveModel, + selectionToThinkingEnabled, + thinkingEnabledToSelection, +} from "@/components/workspace/agents/agent-settings-dialog-helpers"; +import type { Model } from "@/core/models/types"; + +describe("parseAgentModelSettingsDraft", () => { + it("rejects invalid temperature values before save", () => { + expect( + parseAgentModelSettingsDraft({ temperature: "-0.1", maxTokens: "" }), + ).toEqual({ ok: false, error: "temperature" }); + expect( + parseAgentModelSettingsDraft({ temperature: "2.1", maxTokens: "" }), + ).toEqual({ ok: false, error: "temperature" }); + expect( + parseAgentModelSettingsDraft({ temperature: "warm", maxTokens: "" }), + ).toEqual({ ok: false, error: "temperature" }); + }); + + it("rejects invalid max token values before save", () => { + expect( + parseAgentModelSettingsDraft({ temperature: "", maxTokens: "0" }), + ).toEqual({ ok: false, error: "max_tokens" }); + expect( + parseAgentModelSettingsDraft({ temperature: "", maxTokens: "1.5" }), + ).toEqual({ ok: false, error: "max_tokens" }); + expect( + parseAgentModelSettingsDraft({ + temperature: "", + maxTokens: String(MAX_AGENT_OUTPUT_TOKENS + 1), + }), + ).toEqual({ ok: false, error: "max_tokens" }); + }); + + it("returns null settings when both fields inherit", () => { + expect( + parseAgentModelSettingsDraft({ temperature: " ", maxTokens: "" }), + ).toEqual({ ok: true, modelSettings: null }); + }); + + it("keeps explicit nulls for cleared sub-fields when another setting remains", () => { + expect( + parseAgentModelSettingsDraft({ temperature: "0.2", maxTokens: "" }), + ).toEqual({ + ok: true, + modelSettings: { temperature: 0.2, max_tokens: null }, + }); + }); +}); + +describe("thinkingEnabledToSelection", () => { + it("maps null/undefined to inherit so an untouched save stays a no-op", () => { + // Runtime default is thinking-on; a plain on/off switch seeded to false + // would silently disable it. Inherit keeps the persisted null intact. + expect(thinkingEnabledToSelection(null)).toBe(INHERIT_VALUE); + expect(thinkingEnabledToSelection(undefined)).toBe(INHERIT_VALUE); + }); + + it("maps explicit booleans to on/off", () => { + expect(thinkingEnabledToSelection(true)).toBe("on"); + expect(thinkingEnabledToSelection(false)).toBe("off"); + }); +}); + +describe("selectionToThinkingEnabled", () => { + it("round-trips the tri-state back to the persisted value", () => { + expect(selectionToThinkingEnabled(INHERIT_VALUE)).toBeNull(); + expect(selectionToThinkingEnabled("on")).toBe(true); + expect(selectionToThinkingEnabled("off")).toBe(false); + }); +}); + +describe("resolveEffectiveModel", () => { + const models: Model[] = [ + { + id: "a", + name: "a", + model: "a", + display_name: "A", + supports_thinking: true, + }, + { id: "b", name: "b", model: "b", display_name: "B" }, + ]; + + it("resolves the default sentinel to the effective default (models[0])", () => { + // An agent inheriting the global default must still surface the default + // model's capabilities instead of hiding thinking/reasoning controls. + expect(resolveEffectiveModel(models, DEFAULT_MODEL_VALUE)).toBe(models[0]); + }); + + it("resolves an explicit model by name", () => { + expect(resolveEffectiveModel(models, "b")).toBe(models[1]); + }); + + it("returns undefined for an unknown model", () => { + expect(resolveEffectiveModel(models, "missing")).toBeUndefined(); + }); +}); diff --git a/frontend/tests/unit/core/agents/api.test.ts b/frontend/tests/unit/core/agents/api.test.ts index 297f8ae59..0395a82ce 100644 --- a/frontend/tests/unit/core/agents/api.test.ts +++ b/frontend/tests/unit/core/agents/api.test.ts @@ -26,7 +26,11 @@ rs.mock("@/core/config", () => ({ getBackendBaseURL: () => "", })); -import { AgentsApiDisabledError, checkAgentName } from "@/core/agents/api"; +import { + AgentsApiDisabledError, + checkAgentName, + updateAgent, +} from "@/core/agents/api"; import { fetch as fetcher } from "@/core/api/fetcher"; const mockedFetch = rs.mocked(fetcher); @@ -147,3 +151,37 @@ describe("checkAgentName", () => { ); }); }); + +describe("updateAgent", () => { + test("serializes per-agent model settings into the request body (issue #4336)", async () => { + mockedFetch.mockResolvedValueOnce( + jsonResponse(200, { + name: "researcher", + description: "", + model: "agent-model", + tool_groups: null, + skills: null, + model_settings: { temperature: 0.2, max_tokens: 12000 }, + thinking_enabled: true, + reasoning_effort: "high", + }), + ); + + await updateAgent("researcher", { + model: "agent-model", + model_settings: { temperature: 0.2, max_tokens: 12000 }, + thinking_enabled: true, + reasoning_effort: "high", + }); + + const [, init] = mockedFetch.mock.calls[0]!; + expect(init?.method).toBe("PUT"); + const body = JSON.parse(init?.body as string); + expect(body).toMatchObject({ + model: "agent-model", + model_settings: { temperature: 0.2, max_tokens: 12000 }, + thinking_enabled: true, + reasoning_effort: "high", + }); + }); +});