truegrain.tools

Tool definitions for agent frameworks.

Most frameworks, whatever else they disagree about, accept a list of function schemas in the shape OpenAI popularised, and call back with a name and a dictionary of arguments. This module produces that list and dispatches those calls, which is enough to wire the engine into LangChain, CrewAI, the OpenAI Agents SDK, Pydantic AI or something hand-rolled, without this library growing an adapter per framework. Adapters age badly; a schema does not.

If your client speaks the Model Context Protocol, use the engine's MCP server directly instead. It is the same four tools with the same guarantees.

Example:
>>> from truegrain import Client, tools
>>> client = Client.from_env()
>>> specs = tools.tool_specs()                     # hand to the framework
>>> tools.dispatch(client, "query", {"metrics": ["sales.order_revenue"]})
  1"""Tool definitions for agent frameworks.
  2
  3Most frameworks, whatever else they disagree about, accept a list of function
  4schemas in the shape OpenAI popularised, and call back with a name and a
  5dictionary of arguments. This module produces that list and dispatches those
  6calls, which is enough to wire the engine into LangChain, CrewAI, the OpenAI
  7Agents SDK, Pydantic AI or something hand-rolled, without this library growing
  8an adapter per framework. Adapters age badly; a schema does not.
  9
 10If your client speaks the Model Context Protocol, use the engine's MCP server
 11directly instead. It is the same four tools with the same guarantees.
 12
 13Example:
 14    >>> from truegrain import Client, tools
 15    >>> client = Client.from_env()
 16    >>> specs = tools.tool_specs()                     # hand to the framework
 17    >>> tools.dispatch(client, "query", {"metrics": ["sales.order_revenue"]})
 18"""
 19
 20from __future__ import annotations
 21
 22from typing import Any, Mapping
 23
 24from .client import Client
 25from .errors import Refused
 26
 27__all__ = ["tool_specs", "dispatch", "TOOL_NAMES"]
 28
 29TOOL_NAMES = ("list_metrics", "describe_metric", "list_dimensions", "query")
 30
 31_FILTER_SCHEMA: dict[str, Any] = {
 32    "type": "object",
 33    "description": (
 34        "A structured predicate. This is not SQL and no SQL fragment is accepted."
 35    ),
 36    "required": ["dimension", "op"],
 37    "properties": {
 38        "dimension": {"type": "string", "description": "dataset.field or namespace.dataset.field"},
 39        "op": {
 40            "type": "string",
 41            "enum": ["eq", "ne", "in", "not_in", "gt", "gte", "lt", "lte",
 42                     "between", "is_null", "is_not_null"],
 43        },
 44        "values": {
 45            "type": "array",
 46            "description": (
 47                "One value for the comparisons, two for between, one or more for "
 48                "in and not_in, none for the null checks."
 49            ),
 50            "items": {},
 51        },
 52    },
 53}
 54
 55
 56def tool_specs() -> list[dict[str, Any]]:
 57    """Return the engine's tools as OpenAI-style function schemas.
 58
 59    The descriptions are written for a model rather than a human: they say when
 60    to use a tool and when not to, because that is what decides whether an agent
 61    picks the right metric or invents one.
 62    """
 63    return [
 64        {
 65            "type": "function",
 66            "function": {
 67                "name": "list_metrics",
 68                "description": (
 69                    "List the metrics this semantic layer can answer, with a "
 70                    "plain-language description of each. Call this first when you "
 71                    "do not already know the exact metric name. Do not guess a "
 72                    "metric name: a name not in this list does not exist and the "
 73                    "query will be refused."
 74                ),
 75                "parameters": {
 76                    "type": "object",
 77                    "properties": {
 78                        "search": {
 79                            "type": "string",
 80                            "description": "Narrow by name, description or synonym.",
 81                        }
 82                    },
 83                },
 84            },
 85        },
 86        {
 87            "type": "function",
 88            "function": {
 89                "name": "describe_metric",
 90                "description": (
 91                    "Return one metric's full definition and exactly which "
 92                    "dimensions it can be grouped by. Call this before query "
 93                    "whenever you are unsure a metric answers the question asked, "
 94                    "or which dimensions are legal for it."
 95                ),
 96                "parameters": {
 97                    "type": "object",
 98                    "required": ["name"],
 99                    "properties": {
100                        "name": {
101                            "type": "string",
102                            "description": "Qualified as namespace.metric, or bare when unambiguous.",
103                        }
104                    },
105                },
106            },
107        },
108        {
109            "type": "function",
110            "function": {
111                "name": "list_dimensions",
112                "description": (
113                    "List the dimensions available for grouping and filtering. "
114                    "Pass a metric to get only the dimensions valid for it, which "
115                    "is almost always what you want."
116                ),
117                "parameters": {
118                    "type": "object",
119                    "properties": {
120                        "metric": {"type": "string", "description": "Restrict to this metric."}
121                    },
122                },
123            },
124        },
125        {
126            "type": "function",
127            "function": {
128                "name": "query",
129                "description": (
130                    "Answer a question by naming metrics, dimensions and filters. "
131                    "This is the only way to get numbers, and it is not a SQL "
132                    "interface. The response carries the rows, the SQL that was "
133                    "compiled and the model version, so any number can be traced "
134                    "back. If a request is refused, read the refusal: it says "
135                    "whether to change the request, wait, or stop."
136                ),
137                "parameters": {
138                    "type": "object",
139                    "required": ["metrics"],
140                    "properties": {
141                        "metrics": {
142                            "type": "array",
143                            "items": {"type": "string"},
144                            "description": "Metric names from list_metrics.",
145                        },
146                        "dimensions": {
147                            "type": "array",
148                            "items": {"type": "string"},
149                            "description": "Dimension names from list_dimensions.",
150                        },
151                        "filters": {"type": "array", "items": _FILTER_SCHEMA},
152                        "grain": {
153                            "type": "string",
154                            "enum": ["second", "minute", "hour", "day", "week",
155                                     "month", "quarter", "year"],
156                            "description": "Time bucket for the selected time dimension.",
157                        },
158                        "limit": {"type": "integer", "description": "Maximum rows."},
159                        "order_by": {
160                            "type": "array",
161                            "items": {
162                                "type": "object",
163                                "required": ["field"],
164                                "properties": {
165                                    "field": {"type": "string"},
166                                    "desc": {"type": "boolean"},
167                                },
168                            },
169                        },
170                    },
171                },
172            },
173        },
174    ]
175
176
177def dispatch(client: Client, name: str, arguments: Mapping[str, Any]) -> dict[str, Any]:
178    """Run one tool call and return a JSON-serializable result.
179
180    A refusal is returned rather than raised, because a framework feeding this
181    back to a model wants the text, not a traceback. The shape carries the
182    ``retry`` class so the model learns whether to change the request, wait, or
183    stop, instead of looping on a denial.
184    """
185    args = dict(arguments)
186    try:
187        if name == "list_metrics":
188            metrics = client.metrics(search=args.get("search"))
189            return {"metrics": [m.raw for m in metrics]}
190        if name == "describe_metric":
191            if "name" not in args:
192                raise ValueError("describe_metric needs a `name`")
193            return client.metric(args["name"]).raw
194        if name == "list_dimensions":
195            dims = client.dimensions(metric=args.get("metric"))
196            return {"dimensions": [d.raw for d in dims]}
197        if name == "query":
198            result = client.query(
199                metrics=args.get("metrics", []),
200                dimensions=args.get("dimensions"),
201                filters=args.get("filters"),
202                grain=args.get("grain"),
203                limit=args.get("limit"),
204                order_by=args.get("order_by"),
205            )
206            return result.raw
207    except Refused as refusal:
208        return {
209            "refused": True,
210            "code": refusal.code,
211            "reason": refusal.reason,
212            "hint": refusal.hint,
213            "retry": refusal.retry,
214            "note": (
215                "This is a refusal, not an empty result. Do not report a number "
216                "for this question."
217            ),
218        }
219    except ValueError as bad_argument:
220        return {"refused": True, "code": "invalid_arguments",
221                "reason": str(bad_argument), "retry": "modify"}
222
223    raise ValueError(f"unknown tool {name!r}; expected one of {', '.join(TOOL_NAMES)}")
def tool_specs() -> list[dict[str, typing.Any]]:
 57def tool_specs() -> list[dict[str, Any]]:
 58    """Return the engine's tools as OpenAI-style function schemas.
 59
 60    The descriptions are written for a model rather than a human: they say when
 61    to use a tool and when not to, because that is what decides whether an agent
 62    picks the right metric or invents one.
 63    """
 64    return [
 65        {
 66            "type": "function",
 67            "function": {
 68                "name": "list_metrics",
 69                "description": (
 70                    "List the metrics this semantic layer can answer, with a "
 71                    "plain-language description of each. Call this first when you "
 72                    "do not already know the exact metric name. Do not guess a "
 73                    "metric name: a name not in this list does not exist and the "
 74                    "query will be refused."
 75                ),
 76                "parameters": {
 77                    "type": "object",
 78                    "properties": {
 79                        "search": {
 80                            "type": "string",
 81                            "description": "Narrow by name, description or synonym.",
 82                        }
 83                    },
 84                },
 85            },
 86        },
 87        {
 88            "type": "function",
 89            "function": {
 90                "name": "describe_metric",
 91                "description": (
 92                    "Return one metric's full definition and exactly which "
 93                    "dimensions it can be grouped by. Call this before query "
 94                    "whenever you are unsure a metric answers the question asked, "
 95                    "or which dimensions are legal for it."
 96                ),
 97                "parameters": {
 98                    "type": "object",
 99                    "required": ["name"],
100                    "properties": {
101                        "name": {
102                            "type": "string",
103                            "description": "Qualified as namespace.metric, or bare when unambiguous.",
104                        }
105                    },
106                },
107            },
108        },
109        {
110            "type": "function",
111            "function": {
112                "name": "list_dimensions",
113                "description": (
114                    "List the dimensions available for grouping and filtering. "
115                    "Pass a metric to get only the dimensions valid for it, which "
116                    "is almost always what you want."
117                ),
118                "parameters": {
119                    "type": "object",
120                    "properties": {
121                        "metric": {"type": "string", "description": "Restrict to this metric."}
122                    },
123                },
124            },
125        },
126        {
127            "type": "function",
128            "function": {
129                "name": "query",
130                "description": (
131                    "Answer a question by naming metrics, dimensions and filters. "
132                    "This is the only way to get numbers, and it is not a SQL "
133                    "interface. The response carries the rows, the SQL that was "
134                    "compiled and the model version, so any number can be traced "
135                    "back. If a request is refused, read the refusal: it says "
136                    "whether to change the request, wait, or stop."
137                ),
138                "parameters": {
139                    "type": "object",
140                    "required": ["metrics"],
141                    "properties": {
142                        "metrics": {
143                            "type": "array",
144                            "items": {"type": "string"},
145                            "description": "Metric names from list_metrics.",
146                        },
147                        "dimensions": {
148                            "type": "array",
149                            "items": {"type": "string"},
150                            "description": "Dimension names from list_dimensions.",
151                        },
152                        "filters": {"type": "array", "items": _FILTER_SCHEMA},
153                        "grain": {
154                            "type": "string",
155                            "enum": ["second", "minute", "hour", "day", "week",
156                                     "month", "quarter", "year"],
157                            "description": "Time bucket for the selected time dimension.",
158                        },
159                        "limit": {"type": "integer", "description": "Maximum rows."},
160                        "order_by": {
161                            "type": "array",
162                            "items": {
163                                "type": "object",
164                                "required": ["field"],
165                                "properties": {
166                                    "field": {"type": "string"},
167                                    "desc": {"type": "boolean"},
168                                },
169                            },
170                        },
171                    },
172                },
173            },
174        },
175    ]

Return the engine's tools as OpenAI-style function schemas.

The descriptions are written for a model rather than a human: they say when to use a tool and when not to, because that is what decides whether an agent picks the right metric or invents one.

def dispatch( client: truegrain.Client, name: str, arguments: Mapping[str, Any]) -> dict[str, typing.Any]:
178def dispatch(client: Client, name: str, arguments: Mapping[str, Any]) -> dict[str, Any]:
179    """Run one tool call and return a JSON-serializable result.
180
181    A refusal is returned rather than raised, because a framework feeding this
182    back to a model wants the text, not a traceback. The shape carries the
183    ``retry`` class so the model learns whether to change the request, wait, or
184    stop, instead of looping on a denial.
185    """
186    args = dict(arguments)
187    try:
188        if name == "list_metrics":
189            metrics = client.metrics(search=args.get("search"))
190            return {"metrics": [m.raw for m in metrics]}
191        if name == "describe_metric":
192            if "name" not in args:
193                raise ValueError("describe_metric needs a `name`")
194            return client.metric(args["name"]).raw
195        if name == "list_dimensions":
196            dims = client.dimensions(metric=args.get("metric"))
197            return {"dimensions": [d.raw for d in dims]}
198        if name == "query":
199            result = client.query(
200                metrics=args.get("metrics", []),
201                dimensions=args.get("dimensions"),
202                filters=args.get("filters"),
203                grain=args.get("grain"),
204                limit=args.get("limit"),
205                order_by=args.get("order_by"),
206            )
207            return result.raw
208    except Refused as refusal:
209        return {
210            "refused": True,
211            "code": refusal.code,
212            "reason": refusal.reason,
213            "hint": refusal.hint,
214            "retry": refusal.retry,
215            "note": (
216                "This is a refusal, not an empty result. Do not report a number "
217                "for this question."
218            ),
219        }
220    except ValueError as bad_argument:
221        return {"refused": True, "code": "invalid_arguments",
222                "reason": str(bad_argument), "retry": "modify"}
223
224    raise ValueError(f"unknown tool {name!r}; expected one of {', '.join(TOOL_NAMES)}")

Run one tool call and return a JSON-serializable result.

A refusal is returned rather than raised, because a framework feeding this back to a model wants the text, not a traceback. The shape carries the retry class so the model learns whether to change the request, wait, or stop, instead of looping on a denial.

TOOL_NAMES = ('list_metrics', 'describe_metric', 'list_dimensions', 'query')