test_extension_components.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. """扩展服务生命周期、中间件排序与真实图装配。"""
  2. import asyncio
  3. from contextlib import asynccontextmanager
  4. from types import SimpleNamespace
  5. import pytest
  6. from langchain.agents.middleware.types import AgentMiddleware
  7. from langchain_core.messages import AIMessage
  8. import config
  9. from agents import agent_factory, agent_memory, agent_registry
  10. from db import db_conn, chat_store
  11. from extensions import ExtensionRegistry, get_service
  12. from extensions import registry as registry_module
  13. from extensions.services import ServiceContainer, service_view
  14. from runtime import environment
  15. from runtime.environment import RuntimeEnvironment
  16. from runtime.execution import execute
  17. from tools import registry as tools
  18. @asynccontextmanager
  19. async def resource(dependencies):
  20. yield object()
  21. def middleware(name):
  22. return lambda services: type(name, (AgentMiddleware,), {})()
  23. @pytest.fixture
  24. def isolated(monkeypatch, tmp_path):
  25. # 此文件验证通用扩展生命周期;临时根没有业务技能,独立关闭主通风机扩展。
  26. monkeypatch.setenv('MAIN_FAN_ENABLED', '0')
  27. schema = config.ROOT / 'db' / 'schema_sqlite.sql'
  28. adapter = db_conn._SqliteAdapter(str(tmp_path / 'business.sqlite'))
  29. monkeypatch.setattr(adapter, 'schema_file', lambda: schema)
  30. monkeypatch.setattr(db_conn, '_adapter', adapter)
  31. monkeypatch.setattr(db_conn, '_schema_ready', False)
  32. monkeypatch.setattr(config, 'ROOT', tmp_path)
  33. monkeypatch.setattr(agent_memory, '_saver', None)
  34. monkeypatch.setattr(agent_memory, '_kind', 'memory')
  35. yield
  36. db_conn.close()
  37. def use_catalog(monkeypatch, catalog):
  38. catalog.validate()
  39. monkeypatch.setattr(environment, 'load_extensions', lambda: catalog)
  40. monkeypatch.setattr(environment, 'approval_names', lambda: set())
  41. @pytest.mark.parametrize('kind', ['service', 'middleware', 'tool'])
  42. def test_missing_service_dependencies(kind):
  43. catalog = ExtensionRegistry()
  44. if kind == 'service':
  45. catalog.register_service('retriever', resource, requires=['missing'])
  46. elif kind == 'middleware':
  47. catalog.register_middleware('Recall', middleware('Recall'), requires=['missing'])
  48. else:
  49. def search(query: str) -> str:
  50. """检索。"""
  51. return query
  52. catalog.register_tool(search, requires=['missing'])
  53. with pytest.raises(ValueError, match='未注册'):
  54. catalog.validate()
  55. @pytest.mark.parametrize('kind', ['service', 'middleware'])
  56. def test_cycles_and_duplicate_names(kind):
  57. catalog = ExtensionRegistry()
  58. if kind == 'service':
  59. catalog.register_service('one', resource, requires=['two'])
  60. catalog.register_service('two', resource, requires=['one'])
  61. with pytest.raises(ValueError, match='冲突'):
  62. catalog.register_service('one', resource)
  63. else:
  64. catalog.register_middleware('One', middleware('One'), after=['Two'])
  65. catalog.register_middleware('Two', middleware('Two'), after=['One'])
  66. with pytest.raises(ValueError, match='冲突'):
  67. catalog.register_middleware('One', middleware('One'))
  68. with pytest.raises(ValueError, match='循环'):
  69. catalog.validate()
  70. def test_middleware_order_scope_and_independent_instances():
  71. catalog = ExtensionRegistry()
  72. catalog.register_agent(name='researcher', description='检索', prompt='检索', tools=[])
  73. catalog.register_middleware('Recall', middleware('Recall'), targets=['main', 'researcher'], before=['Budget'])
  74. catalog.register_middleware('Budget', middleware('Budget'), targets=['main'])
  75. catalog.register_middleware('ChildOnly', middleware('ChildOnly'), targets=['researcher'], after=['Recall'])
  76. catalog.validate()
  77. main = catalog.build_middleware('main')
  78. child = catalog.build_middleware('researcher')
  79. assert [m.name for m in main] == ['Recall', 'Budget']
  80. assert [m.name for m in child] == ['Recall', 'ChildOnly']
  81. assert main[0] is not child[0]
  82. assert main[0] is not catalog.build_middleware('main')[0]
  83. assert catalog.build_middleware('general-purpose') == []
  84. @pytest.mark.parametrize('kwargs,match', [
  85. ({'targets': ['missing']}, '未注册目标'),
  86. ({'after': ['missing']}, '未注册项'),
  87. ])
  88. def test_invalid_middleware_references(kwargs, match):
  89. catalog = ExtensionRegistry()
  90. catalog.register_middleware('Recall', middleware('Recall'), **kwargs)
  91. with pytest.raises(ValueError, match=match):
  92. catalog.validate()
  93. def test_reserved_names_and_actual_middleware_name():
  94. catalog = ExtensionRegistry()
  95. with pytest.raises(ValueError, match='冲突'):
  96. catalog.register_middleware('SummarizationMiddleware', middleware('SummarizationMiddleware'))
  97. catalog.register_middleware('Recall', middleware('WrongName'))
  98. with pytest.raises(ValueError, match='同名'):
  99. catalog.build_middleware('main')
  100. @pytest.mark.asyncio
  101. async def test_service_order_shared_lookup_and_reverse_cleanup(isolated, monkeypatch):
  102. calls = []
  103. catalog = ExtensionRegistry()
  104. @asynccontextmanager
  105. async def database(deps):
  106. assert not deps
  107. value = object()
  108. calls.append('open_db')
  109. try:
  110. yield value
  111. finally:
  112. calls.append('close_db')
  113. @asynccontextmanager
  114. async def search(deps):
  115. calls.append('open_search')
  116. assert set(deps) == {'db'}
  117. with pytest.raises(TypeError):
  118. deps['other'] = object()
  119. try:
  120. yield SimpleNamespace(database=deps['db'])
  121. finally:
  122. calls.append('close_search')
  123. catalog.register_service('search', search, requires=['db'])
  124. catalog.register_service('db', database)
  125. use_catalog(monkeypatch, catalog)
  126. old = None
  127. for _ in range(2):
  128. async with RuntimeEnvironment(prewarm=False, billing_retry=False):
  129. assert get_service('search').database is get_service('db')
  130. assert get_service('db') is not old
  131. old = get_service('db')
  132. with pytest.raises(RuntimeError, match='尚未启动'):
  133. get_service('db')
  134. assert calls == ['open_db', 'open_search', 'close_search', 'close_db'] * 2
  135. @pytest.mark.asyncio
  136. @pytest.mark.parametrize('fail_at', ['service', 'middleware'])
  137. async def test_startup_failure_releases_all_services(isolated, monkeypatch, fail_at):
  138. calls = []
  139. catalog = ExtensionRegistry()
  140. @asynccontextmanager
  141. async def db(deps):
  142. calls.append('open')
  143. try:
  144. yield object()
  145. finally:
  146. calls.append('close')
  147. @asynccontextmanager
  148. async def broken(deps):
  149. raise ValueError('unavailable')
  150. yield
  151. catalog.register_service('db', db)
  152. if fail_at == 'service':
  153. catalog.register_service('broken', broken, requires=['db'])
  154. else:
  155. catalog.register_middleware('Recall', lambda deps: object(), requires=['db'])
  156. use_catalog(monkeypatch, catalog)
  157. with pytest.raises(ValueError):
  158. async with RuntimeEnvironment(prewarm=False, billing_retry=False):
  159. pytest.fail('不可进入运行体')
  160. assert calls == ['open', 'close']
  161. assert not agent_memory.is_initialized()
  162. assert db_conn._adapter._conn is None
  163. with pytest.raises(RuntimeError, match='尚未启动'):
  164. get_service('db')
  165. use_catalog(monkeypatch, ExtensionRegistry())
  166. async with RuntimeEnvironment(prewarm=False, billing_retry=False):
  167. pass
  168. @pytest.mark.asyncio
  169. async def test_cancel_during_service_start_waits_and_closes(isolated, monkeypatch):
  170. calls = []
  171. started, finish = asyncio.Event(), asyncio.Event()
  172. catalog = ExtensionRegistry()
  173. @asynccontextmanager
  174. async def slow(deps):
  175. started.set()
  176. await finish.wait()
  177. calls.append('open')
  178. try:
  179. yield object()
  180. finally:
  181. calls.append('close')
  182. catalog.register_service('slow', slow)
  183. use_catalog(monkeypatch, catalog)
  184. async def run():
  185. async with RuntimeEnvironment(prewarm=False, billing_retry=False):
  186. pytest.fail('取消后不可进入运行体')
  187. task = asyncio.create_task(run())
  188. await started.wait()
  189. task.cancel()
  190. await asyncio.sleep(0)
  191. task.cancel()
  192. finish.set()
  193. with pytest.raises(asyncio.CancelledError):
  194. await task
  195. assert calls == ['open', 'close']
  196. with pytest.raises(RuntimeError):
  197. get_service('slow')
  198. def test_disabled_extension_skips_config_validation_and_setup(monkeypatch):
  199. def forbidden(*args):
  200. pytest.fail('停用模块不能执行校验或 setup')
  201. fake = SimpleNamespace(enabled=lambda: False, validate_config=forbidden, setup=forbidden)
  202. original = registry_module.importlib.import_module
  203. monkeypatch.setattr(registry_module, 'discover_modules', lambda: ['extensions.core.extension', 'optional'])
  204. monkeypatch.setattr(registry_module.importlib, 'import_module', lambda name: fake if name == 'optional' else original(name))
  205. tools.reset_for_tests()
  206. catalog = registry_module.load_extensions()
  207. assert catalog.disabled_modules == ['optional']
  208. assert catalog.modules == ['extensions.core.extension']
  209. def test_extension_config_failure_prevents_publication(monkeypatch):
  210. def invalid():
  211. raise ValueError('missing endpoint')
  212. fake = SimpleNamespace(enabled=lambda: True, validate_config=invalid,
  213. setup=lambda catalog: pytest.fail('配置错误后不能注册'))
  214. monkeypatch.setattr(registry_module, 'discover_modules', lambda: ['broken'])
  215. monkeypatch.setattr(registry_module.importlib, 'import_module', lambda name: fake)
  216. tools.reset_for_tests()
  217. with pytest.raises(ValueError, match='missing endpoint'):
  218. registry_module.load_extensions()
  219. assert registry_module._loaded is None
  220. assert not tools.get_tool_names()
  221. @pytest.mark.asyncio
  222. async def test_enabled_demo_runs_real_graph_without_network(isolated, monkeypatch):
  223. from test_runtime_regressions import ScriptedModel
  224. from extensions.context_demo.extension import lookup_demo_term
  225. from pydantic import Field
  226. class Model(ScriptedModel):
  227. seen: list[str] = Field(default_factory=list)
  228. def _stream(self, messages, *args, **kwargs):
  229. self.seen.append(str(messages[0].content))
  230. yield from super()._stream(messages, *args, **kwargs)
  231. monkeypatch.setenv('CONTEXT_DEMO_ENABLED', '1')
  232. tools.reset_for_tests()
  233. model = Model(responses=[
  234. AIMessage(content='', tool_calls=[{'id': 'term', 'name': 'lookup_demo_term', 'args': {'term': '风量'}}]),
  235. AIMessage(content='已查询术语表。'),
  236. ])
  237. monkeypatch.setattr('agents.model_factory.get_model', lambda: model)
  238. async with RuntimeEnvironment(prewarm=False, billing_retry=False):
  239. assert '空气体积' in lookup_demo_term('风量')
  240. sid = chat_store.create_session('demo-user')['id']
  241. result = await execute(user_name='demo-user', session_id=sid, message='什么是风量?')
  242. assert result.status == 'success'
  243. assert '已查询术语表' in result.output
  244. assert model.seen and all('演示术语表可查询:风速、风量' in text for text in model.seen)
  245. assert model.seen[0].count('演示术语表可查询') == 1
  246. assert model.seen[1].count('演示术语表可查询') == 1
  247. assert agent_registry.peek_agent() is None
  248. with pytest.raises(RuntimeError, match='尚未启动'):
  249. lookup_demo_term('风量')
  250. def test_agent_factory_attaches_middleware_only_to_requested_targets(monkeypatch):
  251. catalog = registry_module.load_extensions()
  252. # 使用独立目录副本,保留核心工具,避免修改共享扩展缓存。
  253. local = ExtensionRegistry()
  254. local.tools.update(catalog.tools)
  255. local.agents.update(catalog.agents)
  256. local.register_middleware('ChildOnly', middleware('ChildOnly'), targets=['demo-searcher'])
  257. local.register_middleware('MainOnly', middleware('MainOnly'))
  258. local.validate()
  259. monkeypatch.setattr(agent_factory, 'load_extensions', lambda: local)
  260. monkeypatch.setattr(agent_factory.model_factory, 'get_model', lambda: object())
  261. monkeypatch.setattr(agent_factory, '_build_backend', lambda: None)
  262. monkeypatch.setattr('deepagents.create_deep_agent', lambda **kwargs: kwargs)
  263. agent = agent_factory.build_agent()
  264. assert [m.name for m in agent['middleware']] == ['DynamicTimeMiddleware', 'MainOnly']
  265. assert [m.name for m in agent['subagents'][0]['middleware']] == ['ChildOnly']
  266. @pytest.mark.asyncio
  267. async def test_cleanup_failure_does_not_skip_other_services(isolated, monkeypatch):
  268. calls = []
  269. catalog = ExtensionRegistry()
  270. @asynccontextmanager
  271. async def db(deps):
  272. try:
  273. yield object()
  274. finally:
  275. calls.append('db_closed')
  276. @asynccontextmanager
  277. async def search(deps):
  278. try:
  279. yield object()
  280. finally:
  281. calls.append('search_closed')
  282. raise RuntimeError('close failed')
  283. catalog.register_service('db', db)
  284. catalog.register_service('search', search, requires=['db'])
  285. use_catalog(monkeypatch, catalog)
  286. with pytest.raises(RuntimeError, match='close failed'):
  287. async with RuntimeEnvironment(prewarm=False, billing_retry=False):
  288. pass
  289. assert calls == ['search_closed', 'db_closed']
  290. assert not agent_memory.is_initialized()
  291. assert db_conn._adapter._conn is None
  292. with pytest.raises(RuntimeError):
  293. get_service('db')
  294. @pytest.mark.asyncio
  295. async def test_middleware_executes_only_on_target_in_real_delegation(isolated, monkeypatch):
  296. from test_runtime_regressions import ScriptedModel
  297. seen = []
  298. def factory(name):
  299. class Middleware(AgentMiddleware):
  300. async def awrap_model_call(self, request, handler):
  301. seen.append(name + '_before')
  302. result = await handler(request)
  303. seen.append(name + '_after')
  304. return result
  305. Middleware.__name__ = name
  306. return lambda deps: Middleware()
  307. base = registry_module.load_extensions()
  308. local = ExtensionRegistry()
  309. local.tools.update(base.tools)
  310. local.agents.update(base.agents)
  311. local.register_middleware('MainOuter', factory('MainOuter'), before=['MainInner'])
  312. local.register_middleware('MainInner', factory('MainInner'))
  313. local.register_middleware('ChildOnly', factory('ChildOnly'), targets=['demo-searcher'])
  314. local.validate()
  315. monkeypatch.setattr(registry_module, '_loaded', local)
  316. model = ScriptedModel(responses=[
  317. AIMessage(content='', tool_calls=[{'id': 'delegate', 'name': 'task',
  318. 'args': {'subagent_type': 'demo-searcher', 'description': '查询术语'}}]),
  319. AIMessage(content='检索结论。'), AIMessage(content='汇总完成。'),
  320. ])
  321. monkeypatch.setattr('agents.model_factory.get_model', lambda: model)
  322. async with RuntimeEnvironment(prewarm=False, billing_retry=False):
  323. sid = chat_store.create_session('delegation')['id']
  324. result = await execute(user_name='delegation', session_id=sid, message='请检索并汇总')
  325. assert result.status == 'success', result.error
  326. assert result.output.endswith('汇总完成。')
  327. main_call = ['MainOuter_before', 'MainInner_before', 'MainInner_after', 'MainOuter_after']
  328. assert seen == main_call + ['ChildOnly_before', 'ChildOnly_after'] + main_call
  329. @pytest.mark.parametrize('hook', ['enabled', 'validate_config', 'setup'])
  330. def test_async_declaration_hooks_are_rejected(monkeypatch, hook):
  331. async def asynchronous(*args):
  332. return True
  333. fake = SimpleNamespace(enabled=lambda: True, validate_config=lambda: None, setup=lambda registry: None)
  334. setattr(fake, hook, asynchronous)
  335. monkeypatch.setattr(registry_module, 'discover_modules', lambda: ['async_extension'])
  336. monkeypatch.setattr(registry_module.importlib, 'import_module', lambda name: fake)
  337. tools.reset_for_tests()
  338. with pytest.raises(ValueError, match='同步'):
  339. registry_module.load_extensions()
  340. assert registry_module._loaded is None