fix(reasoning): apply explicit per-call levels without runner config caching

This commit is contained in:
RockChinQ
2026-09-19 00:05:10 +08:00
parent da4b0d6ca2
commit 79cac0c6a3
24 changed files with 262 additions and 427 deletions
@@ -1,129 +1,26 @@
"""Host-only, descriptor-driven Runner reasoning policy and durable snapshot tests."""
"""Explicit model call options preserve legacy defaults and shared state."""
from __future__ import annotations
import importlib
from types import SimpleNamespace
import pytest
from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry
from langbot.pkg.provider.modelmgr import errors, reasoning
from tests.unit_tests.provider.test_reasoning_control import _requester, _runtime_model
PRIMARY = '00000000-0000-4000-8000-000000000011'
FALLBACK = '00000000-0000-4000-8000-000000000012'
OTHER = '00000000-0000-4000-8000-000000000013'
def policy():
return importlib.import_module('langbot.pkg.agent.runner.model_reasoning')
def descriptor(*names):
return SimpleNamespace(config_schema=[{'name': name, 'type': 'model-fallback-selector'} for name in names])
def resources(*ids):
return {'models': [{'model_id': model_id} for model_id in ids]}
def selection(level='high'):
return {'primary': PRIMARY, 'fallbacks': [FALLBACK], 'reasoning': {PRIMARY: level, FALLBACK: 'low'}}
def test_generic_descriptor_extracts_only_selected_authorized_models():
value = selection()
value['reasoning'][OTHER] = 'max'
result = policy().extract_model_reasoning_overrides(
descriptor('arbitrary'), {'arbitrary': value}, resources(PRIMARY, FALLBACK, OTHER)
)
assert result == {PRIMARY: {'level': 'high'}, FALLBACK: {'level': 'low'}}
assert policy().extract_model_reasoning_overrides(
descriptor('arbitrary'), {'arbitrary': value}, resources(FALLBACK)
) == {FALLBACK: {'level': 'low'}}
value['reasoning'][PRIMARY] = 'disabled'
assert result[PRIMARY] == {'level': 'high'}
@pytest.mark.parametrize('level', reasoning.REASONING_LEVELS)
def test_all_canonical_levels_use_core_normalization(level):
result = policy().extract_model_reasoning_overrides(
descriptor('models'), {'models': selection(level)}, resources(PRIMARY)
)
assert result == {PRIMARY: reasoning.normalize_reasoning_config({'level': level})}
@pytest.mark.parametrize('value', ['plain-model', {}, {'primary': PRIMARY}, {'primary': PRIMARY, 'reasoning': {}}])
def test_absent_map_does_not_create_default_override(value):
assert policy().extract_model_reasoning_overrides(descriptor('models'), {'models': value}, resources(PRIMARY)) == {}
def test_undeclared_fields_and_descriptor_defaults_do_not_supply_overrides():
desc = descriptor('declared')
desc.config_schema[0]['default'] = selection()
assert policy().extract_model_reasoning_overrides(desc, {'model': selection()}, resources(PRIMARY)) == {}
@pytest.mark.parametrize(
'value', [None, [], 'high', {PRIMARY: None}, {PRIMARY: {}}, {PRIMARY: 'secret-invalid-level'}, {PRIMARY: ['high']}]
)
def test_invalid_explicit_maps_fail_with_safe_error(value):
with pytest.raises(ValueError, match='Invalid runner model reasoning configuration') as exc:
policy().extract_model_reasoning_overrides(
descriptor('models'), {'models': {'primary': PRIMARY, 'reasoning': value}}, resources(PRIMARY)
)
assert 'secret-invalid-level' not in str(exc.value)
assert PRIMARY not in str(exc.value)
@pytest.mark.parametrize('reverse', [False, True])
def test_multiple_selectors_reject_conflicting_overrides_deterministically(reverse):
names = ['one', 'two']
if reverse:
names.reverse()
config = {'one': selection('high'), 'two': selection('provider_default')}
with pytest.raises(ValueError, match='Conflicting runner model reasoning overrides'):
policy().extract_model_reasoning_overrides(descriptor(*names), config, resources(PRIMARY))
config['two'] = selection('high')
assert policy().extract_model_reasoning_overrides(descriptor(*names), config, resources(PRIMARY)) == {
PRIMARY: {'level': 'high'}
}
@pytest.mark.asyncio
async def test_session_deepcopies_reasoning_without_granting_models():
registry = AgentRunSessionRegistry()
overrides = {PRIMARY: {'level': 'high'}, OTHER: {'level': 'max'}}
await registry.register(
run_id='frozen',
runner_id='plugin:test/runner/main',
query_id=None,
plugin_identity='test/runner',
resources=resources(PRIMARY),
model_reasoning_overrides=overrides,
)
overrides[PRIMARY]['level'] = 'low'
session = await registry.get('frozen')
assert session['authorization']['model_reasoning_overrides'][PRIMARY] == {'level': 'high'}
assert not registry.is_resource_allowed(session, 'model', OTHER, 'invoke')
def test_request_local_clone_preserves_shared_model_and_absent_default():
model = _runtime_model(_requester('openai'), 'high', name='gpt-5')
assert policy().model_with_reasoning_override(model, PRIMARY, None) is model
assert policy().model_with_reasoning_override(model, PRIMARY, {'authorization': {}}) is model
overrides = {PRIMARY: {'level': 'provider_default'}}
clone = policy().model_with_reasoning_override(
model, PRIMARY, {'authorization': {'model_reasoning_overrides': overrides}}
)
assert reasoning.model_with_reasoning_level(model, None) is model
assert reasoning.model_with_reasoning_level(model, None) is model
clone = reasoning.model_with_reasoning_level(model, 'provider_default')
assert clone is not model
assert clone.provider is model.provider
assert clone.model_entity is model.model_entity
assert clone.reasoning_config_override == {'level': 'provider_default'}
clone.reasoning_config_override['level'] = 'disabled'
assert overrides[PRIMARY]['level'] == 'provider_default'
assert model.reasoning_config_override is None
assert model.model_entity.reasoning_config == {'level': 'high'}
@@ -141,9 +38,7 @@ def test_real_requester_reasoning_boundary(provider, name, expected, monkeypatch
monkeypatch.setattr(request, '_supports_reasoning', lambda _: True)
monkeypatch.setattr(request, '_safe_model_info', lambda _: {})
model = _runtime_model(request, 'low', name=name)
clone = policy().model_with_reasoning_override(
model, PRIMARY, {'authorization': {'model_reasoning_overrides': {PRIMARY: {'level': 'high'}}}}
)
clone = reasoning.model_with_reasoning_level(model, 'high')
assert request._build_reasoning_args(clone) == expected
clone.reasoning_config_override = {'level': 'provider_default'}
assert request._build_reasoning_args(clone) == {}
@@ -158,58 +53,15 @@ def test_real_requester_still_rejects_ability_and_capability_mismatches(name, ab
monkeypatch.setattr(request, '_supports_reasoning', lambda _: True)
monkeypatch.setattr(request, '_safe_model_info', lambda _: {})
model = _runtime_model(request, name=name, abilities=abilities)
session = {'authorization': {'model_reasoning_overrides': {PRIMARY: {'level': level}}}}
if not abilities:
with pytest.raises(ValueError, match='reasoning ability'):
policy().model_with_reasoning_override(model, PRIMARY, session)
reasoning.model_with_reasoning_level(model, level)
else:
clone = policy().model_with_reasoning_override(model, PRIMARY, session)
clone = reasoning.model_with_reasoning_level(model, level)
with pytest.raises(errors.RequesterError):
request._build_reasoning_args(clone)
@pytest.mark.asyncio
async def test_orchestrator_freezes_host_policy_and_persistent_reload(tmp_path):
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.agent.runner.orchestrator import AgentRunOrchestrator
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.plugin.agent_run_support import _load_persistent_agent_run_session
from tests.unit_tests.agent.test_orchestrator_integration import (
FakeApplication,
FakePluginConnector,
FakeRegistry,
make_descriptor,
make_query,
)
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "reasoning.db"}')
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
connector = FakePluginConnector(results=[{'type': 'run.completed', 'data': {}}])
ap = FakeApplication(connector, engine)
desc = make_descriptor()
query = make_query()
query.pipeline_config['ai']['runner_config'][desc.id]['model']['reasoning'] = {
'model_primary': 'high',
'model_fallback': 'low',
}
expected = {'model_primary': {'level': 'high'}, 'model_fallback': {'level': 'low'}}
try:
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(desc))
_ = [value async for value in orchestrator.run_from_query(query)]
session = connector.sessions_during_run[0]
assert session['authorization']['model_reasoning_overrides'] == expected
wire = connector.contexts[0]
assert 'model_reasoning_overrides' not in wire
assert 'model_reasoning_overrides' not in wire['resources']
query.pipeline_config['ai']['runner_config'][desc.id]['model']['reasoning']['model_primary'] = 'disabled'
assert session['authorization']['model_reasoning_overrides'] == expected
restored = await _load_persistent_agent_run_session(wire['run_id'], ap, 'test')
assert restored['authorization']['model_reasoning_overrides'] == expected
finally:
await engine.dispose()
@pytest.mark.asyncio
@pytest.mark.parametrize(
('provider', 'name', 'level', 'expected'),
@@ -229,9 +81,7 @@ async def test_real_completion_and_count_tokens_build_boundary(provider, name, l
monkeypatch.setattr(request, '_safe_model_info', lambda _: {})
model = _runtime_model(request, name=name)
model.provider.token_mgr.get_token = lambda: 'test-only-not-a-secret'
clone = policy().model_with_reasoning_override(
model, PRIMARY, {'authorization': {'model_reasoning_overrides': {PRIMARY: {'level': level}}}}
)
clone = reasoning.model_with_reasoning_level(model, level)
messages = [provider_message.Message(role='user', content='hello')]
for stream in (False, True):
built = await request._build_completion_args(clone, messages, extra_args={'temperature': 0.7}, stream=stream)
@@ -259,10 +109,46 @@ async def test_real_requester_rejects_caller_reasoning_conflicts(monkeypatch):
monkeypatch.setattr(request, '_safe_model_info', lambda _: {})
model = _runtime_model(request, name='gpt-5')
model.provider.token_mgr.get_token = lambda: 'test-only-not-a-secret'
clone = policy().model_with_reasoning_override(
model, PRIMARY, {'authorization': {'model_reasoning_overrides': {PRIMARY: {'level': 'high'}}}}
)
clone = reasoning.model_with_reasoning_level(model, 'high')
with pytest.raises(errors.RequesterError, match='conflicts with advanced parameters'):
await request._build_completion_args(
clone, [provider_message.Message(role='user', content='hello')], extra_args={'reasoning_effort': 'low'}
)
@pytest.mark.asyncio
@pytest.mark.parametrize('stream', [False, True])
async def test_space_detected_reasoning_without_catalog_flag_reaches_completion(stream, monkeypatch):
from langbot_plugin.api.entities.builtin.provider import message as provider_message
request = _requester('openai', 'space-chat-completions')
monkeypatch.setattr(request, '_supports_reasoning', lambda _: True)
monkeypatch.setattr(request, '_safe_model_info', lambda _: {})
model = _runtime_model(request, name='gpt-5.6-sol', abilities=['vision', 'func_call'])
model.provider.token_mgr.get_token = lambda: 'test-only-not-a-secret'
scoped = reasoning.model_with_reasoning_level(model, 'medium')
built = await request._build_completion_args(
scoped, [provider_message.Message(role='user', content='hello')], stream=stream
)
assert built['reasoning_effort'] == 'medium'
assert built.get('stream', False) is stream
assert model.model_entity.abilities == ['vision', 'func_call']
assert model.reasoning_config_override is None
def test_space_unknown_model_does_not_gain_reasoning(monkeypatch):
request = _requester('openai', 'space-chat-completions')
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
monkeypatch.setattr(request, '_safe_model_info', lambda _: {})
model = _runtime_model(request, name='unknown-model', abilities=[])
with pytest.raises(ValueError, match='reasoning ability'):
reasoning.model_with_reasoning_level(model, 'medium')
def test_space_detected_reasoning_still_validates_conflicting_parameters(monkeypatch):
request = _requester('openai', 'space-chat-completions')
monkeypatch.setattr(request, '_supports_reasoning', lambda _: True)
model = _runtime_model(request, name='gpt-5.6-sol', abilities=[])
model.model_entity.extra_args = {'reasoning_effort': 'low'}
with pytest.raises(ValueError, match='conflicts with advanced parameters'):
reasoning.model_with_reasoning_level(model, 'medium')
@@ -1,4 +1,4 @@
"""Actual secured Host actions consume frozen policy, not plugin payload hints."""
"""Secured Host actions accept explicit per-call options after authorization."""
from __future__ import annotations
@@ -29,6 +29,7 @@ class RecordingProvider:
def __init__(self, request):
self.requester = request
self.provider_entity = SimpleNamespace(requester='openai')
self.calls = []
async def record(self, kwargs):
@@ -74,9 +75,7 @@ async def host(monkeypatch):
)
runtime = make_handler(ap)
async def register(
run_id='run', overrides=None, workspace='workspace-a', plugin='test-author/test-plugin', operations=None
):
async def register(run_id='run', workspace='workspace-a', plugin='test-author/test-plugin', operations=None):
await registry.register(
run_id=run_id,
runner_id='plugin:test-author/test-plugin/arbitrary',
@@ -89,7 +88,6 @@ async def host(monkeypatch):
for model_id in (PRIMARY, FALLBACK)
]
},
model_reasoning_overrides=overrides,
)
return await registry.get(run_id)
@@ -114,10 +112,10 @@ async def call(host, action, model_id=PRIMARY, run_id='run', **extra):
@pytest.mark.asyncio
@pytest.mark.parametrize('action', ACTIONS)
async def test_primary_fallback_and_repeated_tool_round_use_frozen_per_model_policy(host, action):
await host.register(overrides={PRIMARY: {'level': 'high'}, FALLBACK: {'level': 'low'}})
async def test_primary_fallback_and_repeated_tool_round_use_explicit_options(host, action):
await host.register()
for model_id, level in [(PRIMARY, 'high'), (FALLBACK, 'low'), (FALLBACK, 'low')]:
responses = await call(host, action, model_id)
responses = await call(host, action, model_id, reasoning_level=level)
assert all(response.code == 0 for response in responses)
kwargs, built = host.provider.calls[-1]
assert built == {'reasoning_effort': level}
@@ -132,8 +130,8 @@ async def test_primary_fallback_and_repeated_tool_round_use_frozen_per_model_pol
@pytest.mark.parametrize('action', ACTIONS)
@pytest.mark.parametrize('level', [None, 'provider_default'])
async def test_absent_and_explicit_provider_default_are_distinct(host, action, level):
await host.register(overrides={PRIMARY: {'level': level}} if level else None)
assert all(response.code == 0 for response in await call(host, action))
await host.register()
assert all(response.code == 0 for response in await call(host, action, reasoning_level=level))
kwargs, built = host.provider.calls[-1]
assert built == ({} if level else {'reasoning_effort': 'medium'})
assert (kwargs['model'] is host.models[PRIMARY]) is (level is None)
@@ -158,8 +156,8 @@ async def test_regular_plugin_without_run_keeps_model_defaults_and_ignores_forge
@pytest.mark.asyncio
@pytest.mark.parametrize('action', ACTIONS)
async def test_plugin_cannot_replace_host_map(host, action):
await host.register(overrides={PRIMARY: {'level': 'high'}})
async def test_obsolete_hidden_policy_fields_do_not_override_explicit_api(host, action):
await host.register()
responses = await call(
host,
action,
@@ -167,7 +165,7 @@ async def test_plugin_cannot_replace_host_map(host, action):
reasoning_config_override={'level': 'disabled'},
)
assert all(response.code == 0 for response in responses)
assert host.provider.calls[-1][1] == {'reasoning_effort': 'high'}
assert host.provider.calls[-1][1] == {'reasoning_effort': 'medium'}
@pytest.mark.asyncio
@@ -175,7 +173,6 @@ async def test_plugin_cannot_replace_host_map(host, action):
@pytest.mark.parametrize('denial', ['workspace', 'plugin', 'unselected', 'operation', 'expired'])
async def test_authorization_denial_happens_before_model_access(host, action, denial):
await host.register(
overrides={PRIMARY: {'level': 'high'}, OTHER: {'level': 'max'}},
workspace='workspace-b' if denial == 'workspace' else 'workspace-a',
plugin='other/plugin' if denial == 'plugin' else 'test-author/test-plugin',
operations=['rerank'] if denial == 'operation' else None,
@@ -191,9 +188,14 @@ async def test_authorization_denial_happens_before_model_access(host, action, de
@pytest.mark.asyncio
@pytest.mark.parametrize('action', ACTIONS)
async def test_concurrent_runs_share_model_without_cross_run_or_round_leakage(host, action):
await host.register('high-run', {PRIMARY: {'level': 'high'}})
await host.register('low-run', {PRIMARY: {'level': 'low'}})
results = await asyncio.gather(*(call(host, action, run_id=run_id) for run_id in ['high-run', 'low-run'] * 3))
await host.register('high-run')
await host.register('low-run')
results = await asyncio.gather(
*(
call(host, action, run_id=run_id, reasoning_level=run_id.split('-')[0])
for run_id in ['high-run', 'low-run'] * 3
)
)
assert all(response.code == 0 for result in results for response in result)
assert sorted(built['reasoning_effort'] for _, built in host.provider.calls) == ['high'] * 3 + ['low'] * 3
assert len({id(kwargs['model']) for kwargs, _ in host.provider.calls}) == 6
@@ -203,7 +205,7 @@ async def test_concurrent_runs_share_model_without_cross_run_or_round_leakage(ho
@pytest.mark.asyncio
@pytest.mark.parametrize('action', ACTIONS)
async def test_model_runtime_workspace_mismatch_denies(host, action):
await host.register(overrides={PRIMARY: {'level': 'high'}})
await host.register()
host.models[PRIMARY].model_entity.workspace_uuid = 'workspace-b'
responses = await call(host, action)
assert all(response.code != 0 for response in responses)
@@ -213,33 +215,16 @@ async def test_model_runtime_workspace_mismatch_denies(host, action):
@pytest.mark.asyncio
@pytest.mark.parametrize('action', ACTIONS)
async def test_host_reuses_core_ability_validation_before_provider_call(host, action):
await host.register(overrides={PRIMARY: {'level': 'high'}})
await host.register()
host.models[PRIMARY].model_entity.abilities = []
with pytest.raises(ValueError, match='reasoning ability'):
await call(host, action)
await call(host, action, reasoning_level='high')
assert not host.provider.calls
@pytest.mark.asyncio
@pytest.mark.parametrize('action', ACTIONS)
async def test_snapshot_survives_configuration_edits_and_tool_followup(host, action):
config = {PRIMARY: {'level': 'high'}, FALLBACK: {'level': 'low'}}
await host.register(overrides=config)
config[PRIMARY]['level'] = 'disabled'
config[FALLBACK]['level'] = 'max'
responses = await call(
host,
action,
FALLBACK,
messages=[
{'role': 'user', 'content': 'search'},
{
'role': 'assistant',
'content': '',
'tool_calls': [{'id': 'call-1', 'type': 'function', 'function': {'name': 'search', 'arguments': '{}'}}],
},
{'role': 'tool', 'content': 'search result', 'tool_call_id': 'call-1'},
],
)
async def test_regular_plugins_can_explicitly_set_level_without_runner_session(host, action):
responses = await call(host, action, run_id=None, reasoning_level='low')
assert all(response.code == 0 for response in responses)
assert host.provider.calls[-1][1] == {'reasoning_effort': 'low'}