mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17:54 +08:00
config: resolve thinking strength across agent, provider, and model layers
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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"]
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user