| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380 |
- """扩展服务生命周期、中间件排序与真实图装配。"""
- import asyncio
- from contextlib import asynccontextmanager
- from types import SimpleNamespace
- import pytest
- from langchain.agents.middleware.types import AgentMiddleware
- from langchain_core.messages import AIMessage
- import config
- from agents import agent_factory, agent_memory, agent_registry
- from db import db_conn, chat_store
- from extensions import ExtensionRegistry, get_service
- from extensions import registry as registry_module
- from extensions.services import ServiceContainer, service_view
- from runtime import environment
- from runtime.environment import RuntimeEnvironment
- from runtime.execution import execute
- from tools import registry as tools
- @asynccontextmanager
- async def resource(dependencies):
- yield object()
- def middleware(name):
- return lambda services: type(name, (AgentMiddleware,), {})()
- @pytest.fixture
- def isolated(monkeypatch, tmp_path):
- # 此文件验证通用扩展生命周期;临时根没有业务技能,独立关闭主通风机扩展。
- monkeypatch.setenv('MAIN_FAN_ENABLED', '0')
- schema = config.ROOT / 'db' / 'schema_sqlite.sql'
- adapter = db_conn._SqliteAdapter(str(tmp_path / 'business.sqlite'))
- monkeypatch.setattr(adapter, 'schema_file', lambda: schema)
- monkeypatch.setattr(db_conn, '_adapter', adapter)
- monkeypatch.setattr(db_conn, '_schema_ready', False)
- monkeypatch.setattr(config, 'ROOT', tmp_path)
- monkeypatch.setattr(agent_memory, '_saver', None)
- monkeypatch.setattr(agent_memory, '_kind', 'memory')
- yield
- db_conn.close()
- def use_catalog(monkeypatch, catalog):
- catalog.validate()
- monkeypatch.setattr(environment, 'load_extensions', lambda: catalog)
- monkeypatch.setattr(environment, 'approval_names', lambda: set())
- @pytest.mark.parametrize('kind', ['service', 'middleware', 'tool'])
- def test_missing_service_dependencies(kind):
- catalog = ExtensionRegistry()
- if kind == 'service':
- catalog.register_service('retriever', resource, requires=['missing'])
- elif kind == 'middleware':
- catalog.register_middleware('Recall', middleware('Recall'), requires=['missing'])
- else:
- def search(query: str) -> str:
- """检索。"""
- return query
- catalog.register_tool(search, requires=['missing'])
- with pytest.raises(ValueError, match='未注册'):
- catalog.validate()
- @pytest.mark.parametrize('kind', ['service', 'middleware'])
- def test_cycles_and_duplicate_names(kind):
- catalog = ExtensionRegistry()
- if kind == 'service':
- catalog.register_service('one', resource, requires=['two'])
- catalog.register_service('two', resource, requires=['one'])
- with pytest.raises(ValueError, match='冲突'):
- catalog.register_service('one', resource)
- else:
- catalog.register_middleware('One', middleware('One'), after=['Two'])
- catalog.register_middleware('Two', middleware('Two'), after=['One'])
- with pytest.raises(ValueError, match='冲突'):
- catalog.register_middleware('One', middleware('One'))
- with pytest.raises(ValueError, match='循环'):
- catalog.validate()
- def test_middleware_order_scope_and_independent_instances():
- catalog = ExtensionRegistry()
- catalog.register_agent(name='researcher', description='检索', prompt='检索', tools=[])
- catalog.register_middleware('Recall', middleware('Recall'), targets=['main', 'researcher'], before=['Budget'])
- catalog.register_middleware('Budget', middleware('Budget'), targets=['main'])
- catalog.register_middleware('ChildOnly', middleware('ChildOnly'), targets=['researcher'], after=['Recall'])
- catalog.validate()
- main = catalog.build_middleware('main')
- child = catalog.build_middleware('researcher')
- assert [m.name for m in main] == ['Recall', 'Budget']
- assert [m.name for m in child] == ['Recall', 'ChildOnly']
- assert main[0] is not child[0]
- assert main[0] is not catalog.build_middleware('main')[0]
- assert catalog.build_middleware('general-purpose') == []
- @pytest.mark.parametrize('kwargs,match', [
- ({'targets': ['missing']}, '未注册目标'),
- ({'after': ['missing']}, '未注册项'),
- ])
- def test_invalid_middleware_references(kwargs, match):
- catalog = ExtensionRegistry()
- catalog.register_middleware('Recall', middleware('Recall'), **kwargs)
- with pytest.raises(ValueError, match=match):
- catalog.validate()
- def test_reserved_names_and_actual_middleware_name():
- catalog = ExtensionRegistry()
- with pytest.raises(ValueError, match='冲突'):
- catalog.register_middleware('SummarizationMiddleware', middleware('SummarizationMiddleware'))
- catalog.register_middleware('Recall', middleware('WrongName'))
- with pytest.raises(ValueError, match='同名'):
- catalog.build_middleware('main')
- @pytest.mark.asyncio
- async def test_service_order_shared_lookup_and_reverse_cleanup(isolated, monkeypatch):
- calls = []
- catalog = ExtensionRegistry()
- @asynccontextmanager
- async def database(deps):
- assert not deps
- value = object()
- calls.append('open_db')
- try:
- yield value
- finally:
- calls.append('close_db')
- @asynccontextmanager
- async def search(deps):
- calls.append('open_search')
- assert set(deps) == {'db'}
- with pytest.raises(TypeError):
- deps['other'] = object()
- try:
- yield SimpleNamespace(database=deps['db'])
- finally:
- calls.append('close_search')
- catalog.register_service('search', search, requires=['db'])
- catalog.register_service('db', database)
- use_catalog(monkeypatch, catalog)
- old = None
- for _ in range(2):
- async with RuntimeEnvironment(prewarm=False, billing_retry=False):
- assert get_service('search').database is get_service('db')
- assert get_service('db') is not old
- old = get_service('db')
- with pytest.raises(RuntimeError, match='尚未启动'):
- get_service('db')
- assert calls == ['open_db', 'open_search', 'close_search', 'close_db'] * 2
- @pytest.mark.asyncio
- @pytest.mark.parametrize('fail_at', ['service', 'middleware'])
- async def test_startup_failure_releases_all_services(isolated, monkeypatch, fail_at):
- calls = []
- catalog = ExtensionRegistry()
- @asynccontextmanager
- async def db(deps):
- calls.append('open')
- try:
- yield object()
- finally:
- calls.append('close')
- @asynccontextmanager
- async def broken(deps):
- raise ValueError('unavailable')
- yield
- catalog.register_service('db', db)
- if fail_at == 'service':
- catalog.register_service('broken', broken, requires=['db'])
- else:
- catalog.register_middleware('Recall', lambda deps: object(), requires=['db'])
- use_catalog(monkeypatch, catalog)
- with pytest.raises(ValueError):
- async with RuntimeEnvironment(prewarm=False, billing_retry=False):
- pytest.fail('不可进入运行体')
- assert calls == ['open', 'close']
- assert not agent_memory.is_initialized()
- assert db_conn._adapter._conn is None
- with pytest.raises(RuntimeError, match='尚未启动'):
- get_service('db')
- use_catalog(monkeypatch, ExtensionRegistry())
- async with RuntimeEnvironment(prewarm=False, billing_retry=False):
- pass
- @pytest.mark.asyncio
- async def test_cancel_during_service_start_waits_and_closes(isolated, monkeypatch):
- calls = []
- started, finish = asyncio.Event(), asyncio.Event()
- catalog = ExtensionRegistry()
- @asynccontextmanager
- async def slow(deps):
- started.set()
- await finish.wait()
- calls.append('open')
- try:
- yield object()
- finally:
- calls.append('close')
- catalog.register_service('slow', slow)
- use_catalog(monkeypatch, catalog)
- async def run():
- async with RuntimeEnvironment(prewarm=False, billing_retry=False):
- pytest.fail('取消后不可进入运行体')
- task = asyncio.create_task(run())
- await started.wait()
- task.cancel()
- await asyncio.sleep(0)
- task.cancel()
- finish.set()
- with pytest.raises(asyncio.CancelledError):
- await task
- assert calls == ['open', 'close']
- with pytest.raises(RuntimeError):
- get_service('slow')
- def test_disabled_extension_skips_config_validation_and_setup(monkeypatch):
- def forbidden(*args):
- pytest.fail('停用模块不能执行校验或 setup')
- fake = SimpleNamespace(enabled=lambda: False, validate_config=forbidden, setup=forbidden)
- original = registry_module.importlib.import_module
- monkeypatch.setattr(registry_module, 'discover_modules', lambda: ['extensions.core.extension', 'optional'])
- monkeypatch.setattr(registry_module.importlib, 'import_module', lambda name: fake if name == 'optional' else original(name))
- tools.reset_for_tests()
- catalog = registry_module.load_extensions()
- assert catalog.disabled_modules == ['optional']
- assert catalog.modules == ['extensions.core.extension']
- def test_extension_config_failure_prevents_publication(monkeypatch):
- def invalid():
- raise ValueError('missing endpoint')
- fake = SimpleNamespace(enabled=lambda: True, validate_config=invalid,
- setup=lambda catalog: pytest.fail('配置错误后不能注册'))
- monkeypatch.setattr(registry_module, 'discover_modules', lambda: ['broken'])
- monkeypatch.setattr(registry_module.importlib, 'import_module', lambda name: fake)
- tools.reset_for_tests()
- with pytest.raises(ValueError, match='missing endpoint'):
- registry_module.load_extensions()
- assert registry_module._loaded is None
- assert not tools.get_tool_names()
- @pytest.mark.asyncio
- async def test_enabled_demo_runs_real_graph_without_network(isolated, monkeypatch):
- from test_runtime_regressions import ScriptedModel
- from extensions.context_demo.extension import lookup_demo_term
- from pydantic import Field
- class Model(ScriptedModel):
- seen: list[str] = Field(default_factory=list)
- def _stream(self, messages, *args, **kwargs):
- self.seen.append(str(messages[0].content))
- yield from super()._stream(messages, *args, **kwargs)
- monkeypatch.setenv('CONTEXT_DEMO_ENABLED', '1')
- tools.reset_for_tests()
- model = Model(responses=[
- AIMessage(content='', tool_calls=[{'id': 'term', 'name': 'lookup_demo_term', 'args': {'term': '风量'}}]),
- AIMessage(content='已查询术语表。'),
- ])
- monkeypatch.setattr('agents.model_factory.get_model', lambda: model)
- async with RuntimeEnvironment(prewarm=False, billing_retry=False):
- assert '空气体积' in lookup_demo_term('风量')
- sid = chat_store.create_session('demo-user')['id']
- result = await execute(user_name='demo-user', session_id=sid, message='什么是风量?')
- assert result.status == 'success'
- assert '已查询术语表' in result.output
- assert model.seen and all('演示术语表可查询:风速、风量' in text for text in model.seen)
- assert model.seen[0].count('演示术语表可查询') == 1
- assert model.seen[1].count('演示术语表可查询') == 1
- assert agent_registry.peek_agent() is None
- with pytest.raises(RuntimeError, match='尚未启动'):
- lookup_demo_term('风量')
- def test_agent_factory_attaches_middleware_only_to_requested_targets(monkeypatch):
- catalog = registry_module.load_extensions()
- # 使用独立目录副本,保留核心工具,避免修改共享扩展缓存。
- local = ExtensionRegistry()
- local.tools.update(catalog.tools)
- local.agents.update(catalog.agents)
- local.register_middleware('ChildOnly', middleware('ChildOnly'), targets=['demo-searcher'])
- local.register_middleware('MainOnly', middleware('MainOnly'))
- local.validate()
- monkeypatch.setattr(agent_factory, 'load_extensions', lambda: local)
- monkeypatch.setattr(agent_factory.model_factory, 'get_model', lambda: object())
- monkeypatch.setattr(agent_factory, '_build_backend', lambda: None)
- monkeypatch.setattr('deepagents.create_deep_agent', lambda **kwargs: kwargs)
- agent = agent_factory.build_agent()
- assert [m.name for m in agent['middleware']] == ['DynamicTimeMiddleware', 'MainOnly']
- assert [m.name for m in agent['subagents'][0]['middleware']] == ['ChildOnly']
- @pytest.mark.asyncio
- async def test_cleanup_failure_does_not_skip_other_services(isolated, monkeypatch):
- calls = []
- catalog = ExtensionRegistry()
- @asynccontextmanager
- async def db(deps):
- try:
- yield object()
- finally:
- calls.append('db_closed')
- @asynccontextmanager
- async def search(deps):
- try:
- yield object()
- finally:
- calls.append('search_closed')
- raise RuntimeError('close failed')
- catalog.register_service('db', db)
- catalog.register_service('search', search, requires=['db'])
- use_catalog(monkeypatch, catalog)
- with pytest.raises(RuntimeError, match='close failed'):
- async with RuntimeEnvironment(prewarm=False, billing_retry=False):
- pass
- assert calls == ['search_closed', 'db_closed']
- assert not agent_memory.is_initialized()
- assert db_conn._adapter._conn is None
- with pytest.raises(RuntimeError):
- get_service('db')
- @pytest.mark.asyncio
- async def test_middleware_executes_only_on_target_in_real_delegation(isolated, monkeypatch):
- from test_runtime_regressions import ScriptedModel
- seen = []
- def factory(name):
- class Middleware(AgentMiddleware):
- async def awrap_model_call(self, request, handler):
- seen.append(name + '_before')
- result = await handler(request)
- seen.append(name + '_after')
- return result
- Middleware.__name__ = name
- return lambda deps: Middleware()
- base = registry_module.load_extensions()
- local = ExtensionRegistry()
- local.tools.update(base.tools)
- local.agents.update(base.agents)
- local.register_middleware('MainOuter', factory('MainOuter'), before=['MainInner'])
- local.register_middleware('MainInner', factory('MainInner'))
- local.register_middleware('ChildOnly', factory('ChildOnly'), targets=['demo-searcher'])
- local.validate()
- monkeypatch.setattr(registry_module, '_loaded', local)
- model = ScriptedModel(responses=[
- AIMessage(content='', tool_calls=[{'id': 'delegate', 'name': 'task',
- 'args': {'subagent_type': 'demo-searcher', 'description': '查询术语'}}]),
- AIMessage(content='检索结论。'), AIMessage(content='汇总完成。'),
- ])
- monkeypatch.setattr('agents.model_factory.get_model', lambda: model)
- async with RuntimeEnvironment(prewarm=False, billing_retry=False):
- sid = chat_store.create_session('delegation')['id']
- result = await execute(user_name='delegation', session_id=sid, message='请检索并汇总')
- assert result.status == 'success', result.error
- assert result.output.endswith('汇总完成。')
- main_call = ['MainOuter_before', 'MainInner_before', 'MainInner_after', 'MainOuter_after']
- assert seen == main_call + ['ChildOnly_before', 'ChildOnly_after'] + main_call
- @pytest.mark.parametrize('hook', ['enabled', 'validate_config', 'setup'])
- def test_async_declaration_hooks_are_rejected(monkeypatch, hook):
- async def asynchronous(*args):
- return True
- fake = SimpleNamespace(enabled=lambda: True, validate_config=lambda: None, setup=lambda registry: None)
- setattr(fake, hook, asynchronous)
- monkeypatch.setattr(registry_module, 'discover_modules', lambda: ['async_extension'])
- monkeypatch.setattr(registry_module.importlib, 'import_module', lambda name: fake)
- tools.reset_for_tests()
- with pytest.raises(ValueError, match='同步'):
- registry_module.load_extensions()
- assert registry_module._loaded is None
|