test_tool_registry_contract.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. from __future__ import annotations
  2. import json
  3. import pytest
  4. from agent.tools.models import ToolCapability
  5. from agent.tools.registry import ToolRegistry, get_tool_registry, tool
  6. def _schema(name: str) -> dict:
  7. return {
  8. "type": "function",
  9. "function": {
  10. "name": name,
  11. "description": name,
  12. "parameters": {"type": "object", "properties": {}},
  13. },
  14. }
  15. @pytest.mark.asyncio
  16. async def test_registering_the_same_tool_twice_is_idempotent() -> None:
  17. registry = ToolRegistry()
  18. async def sample() -> str:
  19. return "ok"
  20. registry.register(
  21. sample,
  22. schema=_schema("sample"),
  23. groups=["test"],
  24. capabilities=[ToolCapability.READ],
  25. )
  26. assert await registry.execute("sample", {}) == "ok"
  27. registry.register(
  28. sample,
  29. schema=_schema("sample"),
  30. groups=["test"],
  31. capabilities=[ToolCapability.READ],
  32. )
  33. assert registry.get_tool_names() == ["sample"]
  34. assert registry.get_stats("sample")["sample"]["call_count"] == 1
  35. @pytest.mark.asyncio
  36. async def test_same_factory_definition_rebinds_a_fresh_host_adapter() -> None:
  37. registry = ToolRegistry()
  38. class Gateway:
  39. def __init__(self, value: str) -> None:
  40. self.value = value
  41. def make_tool(gateway: Gateway):
  42. async def host_tool() -> str:
  43. return gateway.value
  44. return host_tool
  45. registry.register(
  46. make_tool(Gateway("first")),
  47. schema=_schema("host_tool"),
  48. capabilities=[ToolCapability.READ],
  49. )
  50. assert await registry.execute("host_tool", {}) == "first"
  51. registry.register(
  52. make_tool(Gateway("second")),
  53. schema=_schema("host_tool"),
  54. capabilities=[ToolCapability.READ],
  55. )
  56. assert await registry.execute("host_tool", {}) == "second"
  57. assert registry.get_stats("host_tool")["host_tool"]["call_count"] == 2
  58. def test_same_name_from_a_different_callable_is_rejected() -> None:
  59. registry = ToolRegistry()
  60. async def original() -> str:
  61. return "original"
  62. async def replacement() -> str:
  63. return "replacement"
  64. replacement.__name__ = original.__name__
  65. registry.register(original, schema=_schema("original"))
  66. with pytest.raises(
  67. ValueError,
  68. match=r"Duplicate tool registration for 'original'.*different callable",
  69. ) as exc_info:
  70. registry.register(replacement, schema=_schema("original"))
  71. message = str(exc_info.value)
  72. assert "original" in message
  73. assert "replacement" in message
  74. assert registry._tools["original"]["func"] is original
  75. def test_reregistering_a_tool_with_different_metadata_is_rejected() -> None:
  76. registry = ToolRegistry()
  77. async def sample() -> str:
  78. return "ok"
  79. registry.register(sample, schema=_schema("sample"), groups=["read"])
  80. with pytest.raises(
  81. ValueError,
  82. match=r"Duplicate tool registration for 'sample'.*metadata differs: groups",
  83. ):
  84. registry.register(sample, schema=_schema("sample"), groups=["write"])
  85. @pytest.mark.asyncio
  86. async def test_legacy_execute_inject_values_are_defaults_not_overrides() -> None:
  87. registry = ToolRegistry()
  88. async def sample(value: str) -> str:
  89. return value
  90. registry.register(sample, schema=_schema("sample"))
  91. assert await registry.execute(
  92. "sample",
  93. {},
  94. inject_values={"value": "injected", "unknown": "ignored"},
  95. ) == "injected"
  96. assert await registry.execute(
  97. "sample",
  98. {"value": "explicit"},
  99. inject_values={"value": "injected"},
  100. ) == "explicit"
  101. @pytest.mark.asyncio
  102. async def test_host_can_replace_a_capability_matched_submission_protocol() -> None:
  103. registry = ToolRegistry()
  104. async def submit_attempt() -> str:
  105. return "framework"
  106. submit_attempt.__module__ = "agent.tools.builtin.example"
  107. registry.register(
  108. submit_attempt,
  109. schema=_schema("submit_attempt"),
  110. groups=["orchestration_worker"],
  111. capabilities=[ToolCapability.ATTEMPT_SUBMIT],
  112. )
  113. assert await registry.execute("submit_attempt", {}) == "framework"
  114. async def submit_attempt() -> str: # noqa: F811
  115. return "host"
  116. submit_attempt.__module__ = "host.tools"
  117. registry.register(
  118. submit_attempt,
  119. schema={
  120. **_schema("submit_attempt"),
  121. "x-host-contract": True,
  122. },
  123. groups=["host"],
  124. capabilities=[ToolCapability.ATTEMPT_SUBMIT],
  125. )
  126. assert await registry.execute("submit_attempt", {}) == "host"
  127. assert registry.get_stats("submit_attempt")["submit_attempt"]["call_count"] == 2
  128. async def competing_host_tool() -> str:
  129. return "competing host"
  130. competing_host_tool.__name__ = "submit_attempt"
  131. competing_host_tool.__module__ = "another_host.tools"
  132. with pytest.raises(ValueError, match="different callable"):
  133. registry.register(
  134. competing_host_tool,
  135. schema=_schema("submit_attempt"),
  136. groups=["another_host"],
  137. capabilities=[ToolCapability.ATTEMPT_SUBMIT],
  138. )
  139. def test_host_cannot_replace_a_builtin_with_different_capabilities() -> None:
  140. registry = ToolRegistry()
  141. async def read_contract() -> str:
  142. return "framework"
  143. read_contract.__module__ = "agent.tools.builtin.example"
  144. registry.register(
  145. read_contract,
  146. schema=_schema("read_contract"),
  147. capabilities=[ToolCapability.READ],
  148. )
  149. async def read_contract() -> str: # noqa: F811
  150. return "host"
  151. read_contract.__module__ = "host.tools"
  152. with pytest.raises(ValueError, match="different callable"):
  153. registry.register(
  154. read_contract,
  155. schema=_schema("read_contract"),
  156. capabilities=[ToolCapability.WRITE],
  157. )
  158. def test_builtin_tool_registration_contract() -> None:
  159. """Guard the security-relevant metadata without snapshotting optional tools."""
  160. registry = get_tool_registry()
  161. schemas = registry.get_schemas()
  162. schema_names = [schema["function"]["name"] for schema in schemas]
  163. assert len(schema_names) == len(set(schema_names))
  164. assert set(schema_names) == set(registry.get_tool_names())
  165. expected = {
  166. "read_file": (
  167. "agent.tools.builtin.file.read",
  168. {"core"},
  169. {ToolCapability.READ},
  170. ),
  171. "write_file": (
  172. "agent.tools.builtin.file.write",
  173. {"core"},
  174. {ToolCapability.WRITE},
  175. ),
  176. "glob_files": (
  177. "agent.tools.builtin.file.glob",
  178. {"core"},
  179. {ToolCapability.READ},
  180. ),
  181. "bash_command": (
  182. "agent.tools.builtin.bash",
  183. {"system"},
  184. {ToolCapability.WRITE},
  185. ),
  186. "agent": (
  187. "agent.tools.builtin.subagent",
  188. {"core"},
  189. {ToolCapability.AGENT_SPAWN},
  190. ),
  191. "task_plan": (
  192. "agent.tools.builtin.orchestration",
  193. {"orchestration_planner"},
  194. {ToolCapability.TASK_CONTROL},
  195. ),
  196. "submit_attempt": (
  197. "agent.tools.builtin.orchestration",
  198. {"orchestration_worker"},
  199. {ToolCapability.ATTEMPT_SUBMIT},
  200. ),
  201. "submit_validation": (
  202. "agent.tools.builtin.orchestration",
  203. {"orchestration_validator"},
  204. {ToolCapability.VALIDATION_SUBMIT},
  205. ),
  206. }
  207. actual = {
  208. name: (
  209. registry._tools[name]["func"].__module__,
  210. set(registry._tools[name]["groups"]),
  211. set(registry.get_capabilities(name)),
  212. )
  213. for name in expected
  214. }
  215. assert actual == expected, json.dumps(
  216. {
  217. name: {
  218. "module": module,
  219. "groups": sorted(groups),
  220. "capabilities": sorted(map(str, capabilities)),
  221. }
  222. for name, (module, groups, capabilities) in actual.items()
  223. },
  224. ensure_ascii=False,
  225. indent=2,
  226. )
  227. def test_tool_decorator_applies_explicit_schema_descriptions() -> None:
  228. registry = get_tool_registry()
  229. try:
  230. @tool(
  231. description="Explicit tool description",
  232. param_descriptions={"value": "Explicit value description"},
  233. )
  234. async def governance_description_probe(value: str) -> str:
  235. """Docstring description.
  236. Args:
  237. value: Docstring value description.
  238. """
  239. return value
  240. schema = registry.get_schemas(["governance_description_probe"])[0]["function"]
  241. assert schema["description"] == "Explicit tool description"
  242. assert (
  243. schema["parameters"]["properties"]["value"]["description"]
  244. == "Explicit value description"
  245. )
  246. finally:
  247. registry._tools.pop("governance_description_probe", None)
  248. registry._stats.pop("governance_description_probe", None)