From 20debf9cc72416a5802cd6200e23b36bd730096b Mon Sep 17 00:00:00 2001 From: Ryker_Feng <90562015+18062706139fcz@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:44:55 +0800 Subject: [PATCH] feat(agents): per-agent model and generation settings (#4347) * feat(agents): per-agent model and generation settings Let each custom agent choose its own model and sampling settings (temperature, max_tokens) plus thinking / reasoning_effort defaults, so agents sharing a model profile are no longer stuck with one shared temperature and output length (#4336). AgentConfig gains optional model_settings / thinking_enabled / reasoning_effort (None = inherit). create_chat_model applies per-caller model_overrides on top of the profile before the thinking/Codex transforms; the lead agent resolves each knob with precedence request > agent config > profile/default. The /api/agents create/update routes persist the fields and reject an unknown model. The default lead agent path is unchanged (no agent config -> overrides None). The agent chat composer also stops force-overriding an agent's configured default model with models[0]. * fix(agents): tri-state thinking control and default-model capability gating The model-settings dialog seeded the thinking switch to false, so opening it to tweak temperature and saving silently disabled thinking (the runtime default is on) with no way back to inherit. It also hid the thinking / reasoning controls whenever the agent inherited the global default model, since `__default__` never resolved through `models.find`. Give thinking an explicit Inherit / On / Off tri-state so an untouched save is a no-op, and resolve `__default__` to the effective default (models[0]) for the capability check. Logic lives in the tested helpers module. --- backend/app/gateway/routers/agents.py | 116 +++++++- .../deerflow/agents/lead_agent/agent.py | 33 ++- .../harness/deerflow/config/agents_config.py | 76 ++++- .../harness/deerflow/models/factory.py | 16 +- .../tools/builtins/update_agent_tool.py | 24 +- backend/tests/test_agent_model_settings.py | 144 ++++++++++ .../test_agents_router_model_settings.py | 146 ++++++++++ .../tests/test_lead_agent_model_resolution.py | 122 +++++++- backend/tests/test_model_factory.py | 54 ++++ backend/tests/test_update_agent_tool.py | 28 ++ .../[agent_name]/chats/[thread_id]/page.tsx | 1 + .../workspace/agents/agent-card.tsx | 29 +- .../agents/agent-settings-dialog-helpers.ts | 106 +++++++ .../agents/agent-settings-dialog.tsx | 264 ++++++++++++++++++ .../src/components/workspace/input-box.tsx | 18 +- frontend/src/core/agents/types.ts | 16 ++ frontend/src/core/i18n/locales/en-US.ts | 19 ++ frontend/src/core/i18n/locales/types.ts | 17 ++ frontend/src/core/i18n/locales/zh-CN.ts | 18 ++ .../agent-settings-dialog-helpers.test.ts | 105 +++++++ frontend/tests/unit/core/agents/api.test.ts | 40 ++- 21 files changed, 1356 insertions(+), 36 deletions(-) create mode 100644 backend/tests/test_agent_model_settings.py create mode 100644 backend/tests/test_agents_router_model_settings.py create mode 100644 frontend/src/components/workspace/agents/agent-settings-dialog-helpers.ts create mode 100644 frontend/src/components/workspace/agents/agent-settings-dialog.tsx create mode 100644 frontend/tests/unit/components/workspace/agents/agent-settings-dialog-helpers.test.ts 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", + }); + }); +});