test_extension_components.py 16 KB

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