diff --git a/src/plyngent/config/__init__.py b/src/plyngent/config/__init__.py index 9df1dbd..526e40d 100644 --- a/src/plyngent/config/__init__.py +++ b/src/plyngent/config/__init__.py @@ -21,10 +21,13 @@ from .models import OpenAICompatibleProvider as OpenAICompatibleProvider from .models import OpenAIProvider as OpenAIProvider from .models import PluginsConfig as PluginsConfig from .models import ProviderConfig as ProviderConfig +from .models import ReasoningEffortConfig as ReasoningEffortConfig from .models import SkillDiscoverySource as SkillDiscoverySource from .models import SkillsConfig as SkillsConfig from .models import compose_agent_system_content as compose_agent_system_content from .path import get_default_path as _get_default_path +from .reasoning import ReasoningConfig as ReasoningConfig +from .reasoning import resolve_reasoning as resolve_reasoning from .routing import EffectiveProvider as EffectiveProvider from .routing import ProviderPreset as ProviderPreset from .routing import default_url_for_preset as default_url_for_preset diff --git a/src/plyngent/config/models.py b/src/plyngent/config/models.py index bb599a9..a93d887 100644 --- a/src/plyngent/config/models.py +++ b/src/plyngent/config/models.py @@ -13,6 +13,14 @@ type DeepSeekConvention = Literal["", "openai", "anthropic", "responses"] # Static directory pre-allow mode for ``[agent].allow_paths`` (path → mode). type AllowPathMode = Literal["read", "write", "exec"] +# Thinking strength, normalized across API surfaces (``reasoning_effort`` on +# ``[agent]``, a provider, or a single model; the most specific one wins). +# "" = send nothing and let the provider pick its own default. +# Mapped per surface by ``config.reasoning``: chat completions / Responses send +# ``reasoning_effort`` / ``reasoning.effort`` (``max`` is DeepSeek-only and +# clamps to ``xhigh`` elsewhere), Anthropic maps it onto ``thinking``. +type ReasoningEffortConfig = Literal["", "none", "minimal", "low", "medium", "high", "xhigh", "max"] + # Built-in skill discovery sources (``[skills].discover``): our own user config # directory, and the skill directories other harnesses install into. type SkillDiscoverySource = Literal["plyngent", "claude"] @@ -150,6 +158,14 @@ class AgentConfig(Struct, omit_defaults=True): compact_user_prefix: str = "" compact_seed_text: str = "" + # Thinking strength for every request (a provider or single model may + # override either value; the most specific one wins). + # reasoning_effort: "" | none | minimal | low | medium | high | xhigh | max. + # thinking_budget_tokens: explicit Anthropic ``thinking`` budget (0 = derive + # it from ``reasoning_effort``). See config/reasoning.py. + reasoning_effort: ReasoningEffortConfig = "" + thinking_budget_tokens: int = 0 + class PluginsConfig(Struct, omit_defaults=True): """Third-party plugins (not tool-specific config). @@ -268,6 +284,9 @@ class ModelConfig(Struct, omit_defaults=True): url: str = "" # DeepSeek API surface override ("" = inherit provider convention). convention: DeepSeekConvention = "" + # Thinking strength for this model only ("" / 0 = inherit the provider). + reasoning_effort: ReasoningEffortConfig = "" + thinking_budget_tokens: int = 0 class HttpTimeoutConfig(Struct, omit_defaults=True): @@ -299,6 +318,9 @@ class ProviderConfig(Struct, tag_field="preset", omit_defaults=True): models: dict[str, ModelConfig] = field(default_factory=dict) # float = single timeout for the session; table = connect/read split; omit = defaults. timeout: float | HttpTimeoutConfig | None = None + # Thinking strength for every model of this provider ("" / 0 = inherit [agent]). + reasoning_effort: ReasoningEffortConfig = "" + thinking_budget_tokens: int = 0 def _default_openai_models() -> dict[str, ModelConfig]: diff --git a/src/plyngent/config/reasoning.py b/src/plyngent/config/reasoning.py new file mode 100644 index 0000000..c9df677 --- /dev/null +++ b/src/plyngent/config/reasoning.py @@ -0,0 +1,80 @@ +"""Thinking-strength resolution across config layers and API surfaces. + +``reasoning_effort`` (and the optional Anthropic ``thinking_budget_tokens``) may +be set on ``[agent]``, on a provider, or on a single model. This module merges +the layers — the most specific one wins, per field — and normalizes the result +for the API surface the provider actually talks to, so the agent loop and the +provider bridges receive one value with a single meaning: + +* ``""`` — send nothing; the provider applies its own default. +* chat completions / Responses — ``reasoning_effort`` / ``reasoning.effort``. +* Anthropic — ``thinking`` (an effort level has no wire form there). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from .routing import model_config_for, resolve_effective_provider + +if TYPE_CHECKING: + from .models import AgentConfig, Provider, ReasoningEffortConfig + +# DeepSeek's own ``reasoning_effort`` accepts "max"; OpenAI and Anthropic do not, +# so anywhere else it clamps down to the highest level they do understand. +_DEEPSEEK_CHAT_CONVENTIONS = frozenset({"", "openai"}) + + +@dataclass(frozen=True, slots=True) +class ReasoningConfig: + """Merged thinking settings for one provider + model.""" + + effort: ReasoningEffortConfig = "" + # Explicit Anthropic ``thinking`` budget; 0 = derive it from ``effort``. + thinking_budget_tokens: int = 0 + + +def _clamp_effort(effort: ReasoningEffortConfig, *, preset: str, convention: str) -> ReasoningEffortConfig: + """Keep ``max`` only on the DeepSeek chat surface that defines it.""" + if effort != "max": + return effort + if preset == "deepseek" and convention in _DEEPSEEK_CHAT_CONVENTIONS: + return effort + return "xhigh" + + +def resolve_reasoning( + agent: AgentConfig | None, + provider: Provider, + model: str | None, +) -> ReasoningConfig: + """Merge ``[agent]`` < provider < model thinking settings for *model*. + + *agent* is ``None`` for callers that only know the provider (config tools, + tests); the model layer is the provider's entry for *model*, if any. Each + field falls back on its own, so a model may inherit only the effort (or only + the budget) from above. + """ + effective = resolve_effective_provider(provider, model=model) + model_config = model_config_for(provider, model) + + effort: ReasoningEffortConfig = "" + for layer in (model_config, provider, agent): + if layer is not None and layer.reasoning_effort: + effort = layer.reasoning_effort + break + + budget = 0 + for layer in (model_config, provider, agent): + if layer is not None and layer.thinking_budget_tokens > 0: + budget = layer.thinking_budget_tokens + break + + return ReasoningConfig( + effort=_clamp_effort(effort, preset=effective.preset, convention=effective.convention), + thinking_budget_tokens=budget, + ) + + +__all__ = ["ReasoningConfig", "resolve_reasoning"] diff --git a/tests/test_config/test_agent_section.py b/tests/test_config/test_agent_section.py index 1237f58..05ba9ba 100644 --- a/tests/test_config/test_agent_section.py +++ b/tests/test_config/test_agent_section.py @@ -29,6 +29,8 @@ def test_agent_section_defaults(tmp_path: Path) -> None: assert store.agent_config.allow_paths == {} assert store.agent_config.auto_continue_limits is False assert store.agent_config.max_context_tokens == 200_000 + assert store.agent_config.reasoning_effort == "" + assert store.agent_config.thinking_budget_tokens == 0 assert store.plugins_config.enable == [] assert store.plugins_config.disable == [] assert store.networking_config.ssrf_assume_public_cidrs == [] diff --git a/tests/test_config/test_config.py b/tests/test_config/test_config.py index 942be5c..97c886b 100644 --- a/tests/test_config/test_config.py +++ b/tests/test_config/test_config.py @@ -299,6 +299,70 @@ def test_read_bad_config() -> None: assert isinstance(config.bad_providers, Mapping) +def test_reasoning_effort_from_toml(tmp_path: Path) -> None: + path = tmp_path / "reasoning.toml" + _ = path.write_text( + """ +[agent] +reasoning_effort = "low" +thinking_budget_tokens = 4000 + +[providers.ds] +preset = "deepseek" +access_key_or_token = "sk-test" +reasoning_effort = "high" + +[providers.ds.models] +"deepseek-v4-pro" = { text = true, reasoning_effort = "max", thinking_budget_tokens = 32000 } +"deepseek-v4-flash" = { text = true } +""", + encoding="utf-8", + ) + config = plyngent.config.load(path) + agent_cfg = config.agent_config + assert agent_cfg.reasoning_effort == "low" + assert agent_cfg.thinking_budget_tokens == 4000 + provider = config.providers["ds"] + assert provider.reasoning_effort == "high" + assert provider.thinking_budget_tokens == 0 + assert provider.models["deepseek-v4-pro"].reasoning_effort == "max" + assert provider.models["deepseek-v4-pro"].thinking_budget_tokens == 32000 + assert provider.models["deepseek-v4-flash"].reasoning_effort == "" + assert provider.models["deepseek-v4-flash"].thinking_budget_tokens == 0 + + +def test_reasoning_effort_defaults_are_unset(tmp_path: Path) -> None: + path = tmp_path / "reasoning-default.toml" + _ = path.write_text( + """ +[providers.ds] +preset = "deepseek" +access_key_or_token = "sk-test" +""", + encoding="utf-8", + ) + config = plyngent.config.load(path) + assert config.agent_config.reasoning_effort == "" + assert config.agent_config.thinking_budget_tokens == 0 + assert config.providers["ds"].reasoning_effort == "" + + +def test_reasoning_effort_invalid_is_bad_provider(tmp_path: Path) -> None: + path = tmp_path / "reasoning-bad.toml" + _ = path.write_text( + """ +[providers.ds] +preset = "deepseek" +access_key_or_token = "sk-test" +reasoning_effort = "highest" +""", + encoding="utf-8", + ) + config = plyngent.config.load(path) + assert "ds" not in config.providers + assert "ds" in config.bad_providers + + def test_provider_with_empty_models_is_recoverable(tmp_path: Path) -> None: path = tmp_path / "empty-models.toml" _ = path.write_text( diff --git a/tests/test_config/test_reasoning.py b/tests/test_config/test_reasoning.py new file mode 100644 index 0000000..eaa8baf --- /dev/null +++ b/tests/test_config/test_reasoning.py @@ -0,0 +1,100 @@ +"""Tests for thinking-strength resolution across config layers and surfaces.""" + +from __future__ import annotations + +from plyngent.config.models import ( + AgentConfig, + AnthropicProvider, + DeepseekProvider, + ModelConfig, + OpenAICompatibleProvider, + OpenAIProvider, +) +from plyngent.config.reasoning import resolve_reasoning + + +def _compat() -> OpenAICompatibleProvider: + return OpenAICompatibleProvider(access_key_or_token="sk-test", url="https://example.com/v1") + + +def test_agent_layer_only() -> None: + reasoning = resolve_reasoning( + AgentConfig(reasoning_effort="high", thinking_budget_tokens=9000), + _compat(), + "gpt-4o-mini", + ) + assert reasoning.effort == "high" + assert reasoning.thinking_budget_tokens == 9000 + + +def test_provider_overrides_agent_and_model_overrides_provider() -> None: + provider = OpenAICompatibleProvider( + access_key_or_token="sk-test", + url="https://example.com/v1", + reasoning_effort="low", + models={"gpt-4o-mini": ModelConfig(reasoning_effort="xhigh")}, + ) + agent = AgentConfig(reasoning_effort="minimal") + assert resolve_reasoning(agent, provider, "gpt-4o-mini").effort == "xhigh" + assert resolve_reasoning(agent, provider, "other-model").effort == "low" + assert resolve_reasoning(agent, provider, None).effort == "low" + + +def test_fields_fall_back_independently() -> None: + provider = OpenAICompatibleProvider( + access_key_or_token="sk-test", + url="https://example.com/v1", + reasoning_effort="high", + thinking_budget_tokens=20000, + models={"gpt-4o-mini": ModelConfig(thinking_budget_tokens=4000)}, + ) + reasoning = resolve_reasoning(AgentConfig(reasoning_effort="low"), provider, "gpt-4o-mini") + # Model sets the budget only → the effort still comes from the provider. + assert reasoning.effort == "high" + assert reasoning.thinking_budget_tokens == 4000 + + +def test_agent_layer_optional() -> None: + provider = OpenAICompatibleProvider( + access_key_or_token="sk-test", + url="https://example.com/v1", + reasoning_effort="medium", + ) + assert resolve_reasoning(None, provider, "gpt-4o-mini").effort == "medium" + + +def test_unset_layers_stay_unspecified() -> None: + reasoning = resolve_reasoning(None, _compat(), "gpt-4o-mini") + assert reasoning.effort == "" + assert reasoning.thinking_budget_tokens == 0 + + +def test_non_positive_budget_is_ignored() -> None: + provider = OpenAICompatibleProvider( + access_key_or_token="sk-test", + url="https://example.com/v1", + thinking_budget_tokens=0, + ) + resolved = resolve_reasoning(AgentConfig(thinking_budget_tokens=-5), provider, "gpt-4o-mini") + assert resolved.thinking_budget_tokens == 0 + + +def test_max_survives_only_on_deepseek_chat() -> None: + chat = DeepseekProvider(access_key_or_token="sk-test", reasoning_effort="max") + assert resolve_reasoning(None, chat, "deepseek-v4-pro").effort == "max" + + responses = DeepseekProvider(access_key_or_token="sk-test", convention="responses", reasoning_effort="max") + assert resolve_reasoning(None, responses, "deepseek-v4-pro").effort == "xhigh" + + anthropic = AnthropicProvider(access_key_or_token="sk-test", reasoning_effort="max") + assert resolve_reasoning(None, anthropic, "claude-test").effort == "xhigh" + + openai = OpenAIProvider(access_key_or_token="sk-test", reasoning_effort="max") + assert resolve_reasoning(None, openai, "gpt-5.4").effort == "xhigh" + + +def test_effort_levels_pass_through_unchanged() -> None: + provider = _compat() + for effort in ("none", "minimal", "low", "medium", "high", "xhigh"): + resolved = resolve_reasoning(AgentConfig(reasoning_effort=effort), provider, "gpt-4o-mini") + assert resolved.effort == effort