test_extension_components.py 16 KB

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