Files
zeekay b97cdc1087 the product tools have a base class again, and a registry that finds them
Eleven products ship a `max_tools.py`, and every one of them was dead: they
subclass `MaxTool`, which was defined in the upstream `ee/` tree this
deployment does not carry. Nothing imported them, so nothing said so.

`MaxTool` is written natively here. It is a plain class rather than a LangChain
`BaseTool` because the assistant's loop speaks the OpenAI dialect directly, so
what a tool owes it is a JSON schema and a callable; the graph scaffolding
`BaseTool` exists to serve has nothing standing on it. Its surface is what the
tool files actually use and nothing more: `_team`, `_user`, `context`,
`_config`, `name`/`description`/`args_schema`/`context_prompt_template`, one of
`_run_impl` / `_arun_impl`, and the access and dangerous-operation hooks.

Eight products are ported. Three — replay, revenue_analytics and web_analytics
— also want `TaxonomyAgentToolkit`/`TaxonomyAgentNode`/`base_final_answer`, a
graph the upstream is itself migrating off, and are left alone. The registry
names them, so porting one is a visible change rather than a silent one.

Tenancy is structural, not documentary. `team` is a required keyword argument
from the authenticated request, arguments are validated through `args_schema`
before a tool sees them, and the registry refuses to expose any tool whose
schema declares a team, project or organization. No tool needed that fix today;
the guard is there so none can acquire it later.

Two things were broken beyond the missing base class, both from the debrand:
cdp imported `compile_iql`, which nothing defines — the function is
`compile_script` — and its test still spoke of `<iql_code>` where the tool
reads `<script_code>`. user_interviews read `settings.OPENAI_BASE_URL`, which
is not a setting here, and called an API the gateway does not serve; it now
reaches a model the one way this product does.
2026-08-04 12:33:29 -07:00

108 lines
4.2 KiB
Python

"""Which product tools exist, and how to run one.
Every `products/<product>/backend/max_tools.py` is imported once and searched for
`MaxTool` subclasses. There is no decorator and no list to keep in step: a tool
is registered by being a subclass declared in one of those modules, which is
already the only thing its author writes.
TENANCY. A tool is refused if its arguments declare a team, project or
organization. `team` reaches a tool as a keyword argument from the authenticated
request and never as something the model says, so a schema that asks for one is
a bug in that tool, not a route the registry will expose.
"""
from __future__ import annotations
import re
import pkgutil
import importlib
from functools import cache
import structlog
from products.insights_ai.backend.max_tool import MaxTool, MaxToolError
logger = structlog.get_logger(__name__)
# Tools whose graph scaffolding has not been ported. They are expected to fail to
# import, so their absence is noted rather than warned about.
UNPORTED = frozenset({"replay", "revenue_analytics", "web_analytics"})
# An argument by any of these names would be the model choosing the tenant.
_TENANCY = re.compile(r"team|project|organization|(^|_)org($|_)|tenant", re.I)
def _tenancy_fields(tool: type[MaxTool]) -> list[str]:
return [field for field in tool.args_schema.model_fields if _TENANCY.search(field)]
def _modules():
"""Import every product's `max_tools`, yielding the ones that load."""
import products
for module in pkgutil.iter_modules(products.__path__):
name = f"products.{module.name}.backend.max_tools"
try:
yield importlib.import_module(name)
except ModuleNotFoundError as error:
# The product has no tools, or it has some and one of its own imports
# is missing — worth telling apart, so only the first is silent.
if getattr(error, "name", "") != name:
logger.warning("max_tools.import_failed", module=name, error=str(error))
except Exception as error:
if module.name in UNPORTED:
logger.info("max_tools.unported", module=name)
else:
logger.warning("max_tools.import_failed", module=name, error=str(error))
def tools_in(module) -> dict[str, type[MaxTool]]:
"""The tools one module declares, minus any that ask the model for a tenant."""
found: dict[str, type[MaxTool]] = {}
for value in vars(module).values():
# Declared here, rather than imported into here, so a tool is counted
# once no matter how many modules mention it.
if not isinstance(value, type) or not issubclass(value, MaxTool) or value is MaxTool:
continue
if value.__module__ != module.__name__:
continue
leaked = _tenancy_fields(value)
if leaked:
logger.error("max_tools.tenancy_in_schema", tool=value.name, fields=leaked)
continue
found[value.name] = value
return found
@cache
def tools() -> dict[str, type[MaxTool]]:
"""Every registered tool, by the name the model calls it by."""
found: dict[str, type[MaxTool]] = {}
for module in _modules():
for name, tool in tools_in(module).items():
if name in found:
logger.error("max_tools.duplicate_name", tool=name, module=module.__name__)
continue
found[name] = tool
return found
def definitions() -> list[dict]:
"""Every tool as an OpenAI-dialect `tools=` array."""
return [tool.definition() for tool in tools().values()]
def run(name: str, arguments: dict, *, team, user=None, context=None, config=None, approved: bool = False):
"""Run one tool for `team`, returning its `(content, artifact)`.
Raises `MaxToolError` for a call the model can be told about — an unknown
tool, arguments that do not fit the schema, access it does not have, an
unapproved dangerous operation. Anything else is the tool failing, and is
left to the caller to log.
"""
tool = tools().get(name)
if tool is None:
raise MaxToolError(f"There is no tool called {name!r}.")
return tool(team=team, user=user, context=context, config=config).run(arguments, approved=approved)