avoigt1121
Claude Opus 5.5
fix(drugs): one ChEMBL window for the mechanism lookups, retried round-robin
01b3ee5 Download src/tu_client.py from anne-voigt/pdac-knowledge-agent-dev: direct link, hf CLI and curl.
- Browser
- Download file 15.1 kB
-
https://huggingface.co/spaces/anne-voigt/pdac-knowledge-agent-dev/resolve/main/src/tu_client.py
- Command line
-
hf download hf://spaces/anne-voigt/pdac-knowledge-agent-dev/src/tu_client.py
-
curl -L -o tu_client.py https://huggingface.co/spaces/anne-voigt/pdac-knowledge-agent-dev/resolve/main/src/tu_client.py
15.1 kB
| """The governed ToolUniverse client β the ONLY module that imports ``tooluniverse``. | |
| ADR-0001 Β§7, enforced in code rather than by convention: | |
| - **Allow-list by construction.** ToolUniverse is built with ``keep_default_tools=False`` and | |
| ``load_workspace=False``, then ``load_tools(include_tools=<the endpoints' set>)``, and startup | |
| is REFUSED unless the loaded set equals that set exactly. With these arguments 1.5.6 loads the | |
| named tools and nothing else: no special tools, no tool finder, no agentic tools, no remote MCP | |
| servers (measured 2026-10-20, with outbound sockets blocked, so loading opens no connection). | |
| - **No workspace.** ``load_workspace=False`` is not optional. With a workspace directory present | |
| (``TOOLUNIVERSE_HOME``, or ``./.tooluniverse``), 1.5.6's constructor writes a default | |
| ``profile.yaml`` into it and applies it, which loads ALL 2,691 tools before ``load_tools`` runs; | |
| it also reads that workspace's ``.env`` and ``~/.tooluniverse/.env`` into the environment, which | |
| could bring a model key in after the check below. Found by this repo's own tests on 2026-10-20, | |
| when an earlier draft pointed ``TOOLUNIVERSE_HOME`` at an empty temp dir. | |
| - **No model provider keys.** ToolUniverse's agentic tools and tool finder can reach OpenAI, Azure | |
| OpenAI, Gemini, OpenRouter or a vLLM server when keys are set. None of those tools can load | |
| here, and startup still refuses if any key is present: the Space makes no model call | |
| (ADR-0001 Β§2). ``HF_TOKEN`` is allowed β the machine gate needs it β and in 1.5.6 only | |
| Hugging Face inference and embedding tools read it, none of which is on the allow-list. | |
| - **Exactly one cache.** ToolUniverse's own result cache is disabled through its environment | |
| switches and every call passes ``use_cache=False``; :mod:`src.cache` is the only cache, because | |
| it is the one that keeps ``retrieved_at`` honest. | |
| - **Pinned.** The installed version must equal ``config/tools_allowlist.yaml``'s | |
| ``tooluniverse_version``. A bump is its own lane (doctor, re-recorded fixtures, schema tests, | |
| security scan) and never changes an endpoint's name or payload shape. | |
| :class:`ToolClient` is what the endpoints call. It returns a :class:`SourceResult` or raises a | |
| :class:`SourceError` whose ``reason`` is one of the three payload source-error reasons, so a | |
| timeout, a limit or a drifted response is always structured, never a guess. | |
| """ | |
| from __future__ import annotations | |
| import ast | |
| import concurrent.futures | |
| import inspect | |
| import os | |
| import re | |
| import threading | |
| import time | |
| from collections.abc import Callable, Mapping | |
| from dataclasses import dataclass | |
| from typing import Any | |
| from src import config, schemas | |
| from src.cache import ResponseCache | |
| from src.ratelimit import RateLimiters | |
| # Every variable through which ToolUniverse 1.5.6 (or a library it can load) reaches a model. | |
| LLM_KEY_VARS = ( | |
| "OPENAI_API_KEY", | |
| "OPENAI_BASE_URL", | |
| "AZURE_OPENAI_API_KEY", | |
| "AZURE_OPENAI_ENDPOINT", | |
| "GEMINI_API_KEY", | |
| "GOOGLE_API_KEY", | |
| "OPENROUTER_API_KEY", | |
| "ANTHROPIC_API_KEY", | |
| "VLLM_SERVER_URL", | |
| ) | |
| # Tool types that must never load, whatever the allow-list says: LLM-backed agents, the tool | |
| # finders (they can call a model or download an embedding index), remote MCP auto-loaders and | |
| # web-search backends. A tool whose config names `required_api_keys` is key-gated and refused too. | |
| FORBIDDEN_TOOL_TYPES = frozenset( | |
| { | |
| "AgenticTool", | |
| "SmolAgentTool", | |
| "MCPAutoLoaderTool", | |
| "MCPClientTool", | |
| "ToolFinderEmbedding", | |
| "ToolFinderLLM", | |
| "ToolFinderKeyword", | |
| "ToolFinderJev", | |
| "WebSearchTool", | |
| "WebAPIDocumentationSearchTool", | |
| } | |
| ) | |
| # ToolUniverse's own switches: its result cache off. The workspace and profile variables are | |
| # REMOVED rather than set, because any workspace widens what loads (see the module docstring). | |
| _TU_ENV = { | |
| "TOOLUNIVERSE_CACHE_ENABLED": "false", | |
| "TOOLUNIVERSE_CACHE_PERSIST": "false", | |
| "TOOLUNIVERSE_QUIET": "1", | |
| } | |
| _TU_ENV_REMOVED = ("TOOLUNIVERSE_PROFILE", "TOOLUNIVERSE_HOME") | |
| _HOST = re.compile(r"https?://([A-Za-z0-9.-]+)") | |
| _build_lock = threading.Lock() | |
| class StartupRefused(RuntimeError): | |
| """The Space must not start: a key is set, the version drifted, or the tool set is wrong.""" | |
| class SourceError(Exception): | |
| """A structured failure from one source call (``reason`` β payload SOURCE_ERROR_REASONS).""" | |
| def __init__(self, source: str, reason: str, detail: str): | |
| super().__init__(f"{source}: {reason}: {detail}") | |
| self.source = source | |
| self.reason = reason | |
| self.detail = detail | |
| def as_payload(self) -> dict[str, str]: | |
| return {"source": self.source, "reason": self.reason, "detail": self.detail} | |
| class SourceResult: | |
| tool: str | |
| source: str | |
| args: dict[str, Any] | |
| data: Any | |
| retrieved_at: float # epoch seconds when the SOURCE answered | |
| cache: str # "hit" | "miss" | |
| tooluniverse_version: str | |
| def assert_no_llm_keys(environ: Mapping[str, str] | None = None) -> None: | |
| env = os.environ if environ is None else environ | |
| present = [var for var in LLM_KEY_VARS if (env.get(var) or "").strip()] | |
| if present: | |
| raise StartupRefused( | |
| f"Model-provider settings are present ({', '.join(present)}). This Space makes no " | |
| "model call and must run without them (ADR-0001 Β§2); remove them from the Space's " | |
| "secrets and variables." | |
| ) | |
| def configure_tooluniverse_env() -> None: | |
| """Set ToolUniverse's environment before an instance is built (idempotent).""" | |
| for var, value in _TU_ENV.items(): | |
| os.environ[var] = value | |
| for var in _TU_ENV_REMOVED: | |
| os.environ.pop(var, None) | |
| def installed_tooluniverse_version() -> str: | |
| import tooluniverse | |
| return str(getattr(tooluniverse, "__version__", "")) | |
| def build_tooluniverse(include: frozenset[str] | None = None): | |
| """A ToolUniverse holding exactly ``include`` (default: the endpoints' set), or refuse.""" | |
| include = config.endpoint_tool_names() if include is None else include | |
| if not include <= config.allowed_tool_names(): | |
| raise StartupRefused( | |
| f"not on the allow-list: {sorted(include - config.allowed_tool_names())}" | |
| ) | |
| assert_no_llm_keys() | |
| configure_tooluniverse_env() | |
| version = installed_tooluniverse_version() | |
| if version != config.pinned_tooluniverse_version(): | |
| raise StartupRefused( | |
| f"ToolUniverse {version!r} is installed; config pins " | |
| f"{config.pinned_tooluniverse_version()!r}. A version bump is its own reviewed lane." | |
| ) | |
| from tooluniverse import ToolUniverse | |
| tu = ToolUniverse(keep_default_tools=False, load_workspace=False) | |
| if tu.all_tool_dict: | |
| raise StartupRefused( | |
| f"ToolUniverse loaded {len(tu.all_tool_dict)} tools before the allow-list was applied" | |
| ) | |
| tu.load_tools(include_tools=sorted(include), quiet=True) | |
| assert_no_llm_keys() # again: nothing during construction may have brought one in | |
| loaded = frozenset(tu.all_tool_dict) | |
| if loaded != include: | |
| raise StartupRefused( | |
| f"loaded tool set differs from the allow-listed set: extra {sorted(loaded - include)}, " | |
| f"missing {sorted(include - loaded)}" | |
| ) | |
| unsafe = sorted( | |
| name | |
| for name, cfg in tu.all_tool_dict.items() | |
| if cfg.get("type") in FORBIDDEN_TOOL_TYPES or cfg.get("required_api_keys") | |
| ) | |
| if unsafe: | |
| raise StartupRefused(f"agentic, finder, remote-MCP, web-search or key-gated: {unsafe}") | |
| cache_manager = getattr(tu, "cache_manager", None) | |
| if cache_manager is not None and getattr(cache_manager, "enabled", False): | |
| raise StartupRefused("ToolUniverse's own result cache is enabled; it must be off") | |
| return tu | |
| def tool_hosts(tu, tool: str) -> set[str]: | |
| """Every host a loaded tool can reach: URLs in its config plus its class's code. | |
| Read offline from the installed package: the tool's config dict, the source of its class and | |
| ToolUniverse base classes, and any module-level URL constant those classes name (HGNC keeps | |
| its base URL in one). So the egress statement in config/sources.yaml can be checked without a | |
| network call (tests/test_sources_hosts.py). | |
| """ | |
| from tooluniverse.tool_registry import get_tool_class_lazy | |
| cfg = tu.all_tool_dict[tool] | |
| hosts = set(_HOST.findall(repr(cfg))) | |
| # The lazy lookup imports the class's module (offline); the plain registry is empty until a | |
| # tool instance has been created, which would make this check silently find nothing. | |
| cls = get_tool_class_lazy(cfg["type"]) | |
| if cls is None: | |
| raise StartupRefused(f"{tool}: tool type {cfg['type']!r} has no class to inspect") | |
| for klass in inspect.getmro(cls): | |
| if not klass.__module__.startswith("tooluniverse"): | |
| continue | |
| try: | |
| class_source = inspect.getsource(klass) | |
| module_source = inspect.getsource(inspect.getmodule(klass)) | |
| except (OSError, TypeError): | |
| continue | |
| hosts.update(_HOST.findall(class_source)) | |
| for name, value in _module_url_constants(module_source).items(): | |
| if re.search(rf"\b{re.escape(name)}\b", class_source): | |
| hosts.update(_HOST.findall(value)) | |
| return hosts | |
| def _module_url_constants(module_source: str) -> dict[str, str]: | |
| """Top-level ``NAME = "https://β¦"`` assignments in a module.""" | |
| found: dict[str, str] = {} | |
| for node in ast.parse(module_source).body: | |
| if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant): | |
| value = node.value.value | |
| if isinstance(value, str) and _HOST.search(value): | |
| for target in node.targets: | |
| if isinstance(target, ast.Name): | |
| found[target.id] = value | |
| return found | |
| def _unwrap(tool: str, source: str, raw: Any) -> Any: | |
| """ToolUniverse's envelope β the source's data, or a structured error.""" | |
| if raw is None: | |
| raise SourceError(source, "source_unavailable", f"{tool} returned nothing") | |
| if isinstance(raw, dict): | |
| status = raw.get("status") | |
| if status == "success" and "data" in raw: | |
| return raw["data"] | |
| if status == "error" or ("error" in raw and raw.get("error")): | |
| detail = str(raw.get("error") or raw.get("message") or "error")[:200] | |
| raise SourceError(source, "source_unavailable", f"{tool}: {detail}") | |
| if isinstance(raw, str) and raw.lower().startswith("error"): | |
| raise SourceError(source, "source_unavailable", f"{tool}: {raw[:200]}") | |
| return raw | |
| class ToolClient: | |
| """Cache β rate limit β call with a deadline β schema check β cache. | |
| ``runner(tool, args)`` performs the actual call. By default it is ToolUniverse's | |
| ``run_one_function`` on a lazily built, allow-listed instance; tests inject a fake runner | |
| with synthetic or recorded responses, so the suite never reaches the network. | |
| """ | |
| def __init__( | |
| self, | |
| *, | |
| runner: Callable[[str, dict[str, Any]], Any] | None = None, | |
| cache: ResponseCache | None = None, | |
| limiters: RateLimiters | None = None, | |
| clock: Callable[[], float] = time.time, | |
| tooluniverse_version: str | None = None, | |
| tooluniverse: Any = None, | |
| ): | |
| self._runner = runner | |
| # gradio_ui passes an instance built at import, so a Space that must not start (a model | |
| # key, a version drift, a widened tool set) fails at startup, not on its first call. | |
| self._tu = tooluniverse | |
| self.cache = cache if cache is not None else ResponseCache() | |
| self.limiters = limiters if limiters is not None else RateLimiters() | |
| self._clock = clock | |
| self._version = tooluniverse_version | |
| self._pool = concurrent.futures.ThreadPoolExecutor( | |
| max_workers=8, thread_name_prefix="tu-call" | |
| ) | |
| def tooluniverse_version(self) -> str: | |
| if self._version is None: | |
| self._version = installed_tooluniverse_version() | |
| return self._version | |
| def _default_runner(self, tool: str, args: dict[str, Any]) -> Any: | |
| with _build_lock: | |
| if self._tu is None: | |
| self._tu = build_tooluniverse() | |
| return self._tu.run_one_function({"name": tool, "arguments": args}, use_cache=False) | |
| def call( | |
| self, | |
| tool: str, | |
| args: dict[str, Any], | |
| *, | |
| max_wait: float = 10.0, | |
| deadline: float | None = None, | |
| ) -> SourceResult: | |
| """``deadline`` can shorten the source's ``timeout_seconds`` for this call, never extend it.""" | |
| if tool not in config.endpoint_tool_names(): | |
| # A programming error, not a user input: endpoints only ever name loaded tools. | |
| raise ValueError(f"{tool!r} is not an endpoint tool") | |
| source = config.source_of(tool) | |
| cfg = config.source(source) | |
| hit = self.cache.get(source, tool, args) | |
| if hit is not None: | |
| data, retrieved_at = hit | |
| return SourceResult( | |
| tool, source, dict(args), data, retrieved_at, "hit", self.tooluniverse_version | |
| ) | |
| if not self.limiters.acquire(source, max_wait): | |
| raise SourceError( | |
| source, "rate_limited", f"{cfg['name']} limit of {cfg['rate_per_second']}/s reached" | |
| ) | |
| timeout = cfg["timeout_seconds"] | |
| if deadline is not None: | |
| timeout = max(0.0, min(timeout, deadline)) | |
| runner = self._runner or self._default_runner | |
| future = self._pool.submit(runner, tool, dict(args)) | |
| # `wait`, not `result(timeout=β¦)`: since Python 3.11 concurrent.futures.TimeoutError IS the | |
| # builtin TimeoutError, so a source's own read timeout raised inside the call would be | |
| # indistinguishable from our deadline passing (caught by a test on 2026-10-20). | |
| done, _ = concurrent.futures.wait([future], timeout=timeout) | |
| if not done: | |
| future.cancel() | |
| raise SourceError( | |
| source, | |
| "source_unavailable", | |
| f"{cfg['name']} did not answer within {round(timeout, 1):g} s", | |
| ) | |
| try: | |
| raw = future.result() | |
| except SourceError: | |
| raise | |
| except Exception as e: # any failure inside a source call is structured, never raised | |
| raise SourceError(source, "source_unavailable", f"{tool}: {type(e).__name__}") from e | |
| retrieved_at = self._clock() | |
| data = _unwrap(tool, source, raw) | |
| try: | |
| schemas.validate(tool, data) | |
| except schemas.SchemaDrift as drift: | |
| raise SourceError(source, "upstream_schema_drift", drift.detail) from drift | |
| self.cache.put(source, tool, args, data, retrieved_at) | |
| return SourceResult( | |
| tool, source, dict(args), data, retrieved_at, "miss", self.tooluniverse_version | |
| ) | |