sofhiaazzhr Claude Opus 4.7 commited on
Commit
421cf11
·
1 Parent(s): 4abb099

[KM-629] AnalyticsToolInvoker (wrapper layer)

Browse files

Implement the ToolInvoker Protocol (src/agents/slow_path/invoker.py) for the
analyze_* family: invoke(tool_name, args) -> ToolOutput.

- Dispatch tool_name -> (compute fn, output_kind); unknown name -> error envelope.
- Materialize the Pattern A `data` arg (already resolved by the TaskRunner to the
upstream ToolOutput kind="table") into a DataFrame; also accepts a raw
DataFrame or {columns, rows} dict.
- Pass remaining args as kwargs (names match compute signatures 1:1); wrap the
result in ToolOutput with the tool's declared kind.
- Never-throw: unknown tool, bad/missing data, or a compute exception all return
ToolOutput(kind="error"), honoring the §8.4 seam guarantee.

Verified locally (tests are gitignored): 10 invoker tests + full tools suite
82 passed; ruff + mypy strict clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Files changed (1) hide show
  1. src/tools/invoker.py +105 -0
src/tools/invoker.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AnalyticsToolInvoker — the runtime seam implementation (KM-465).
2
+
3
+ Implements the `ToolInvoker` Protocol the slow-path TaskRunner calls
4
+ (src/agents/slow_path/invoker.py). One method, `invoke(tool_name, args)`, does the
5
+ whole job for the `analyze_*` family:
6
+
7
+ 1. Look the tool up in a name -> (compute fn, output_kind) dispatch map; an unknown
8
+ name returns an error envelope (never an exception).
9
+ 2. Materialize the Pattern A `data` argument — which the TaskRunner has already
10
+ resolved to the upstream task's `ToolOutput` (kind="table") — into a DataFrame.
11
+ 3. Call the pure compute function with the remaining args as keyword arguments
12
+ (their names match the compute signatures one-to-one).
13
+ 4. Wrap the result in a `ToolOutput` with the tool's declared `kind`.
14
+
15
+ Frozen guarantee (§8.4): **never throws.** Any failure — unknown tool, bad data,
16
+ or an exception from compute (e.g. GroupNotFoundError) — comes back as
17
+ `ToolOutput(kind="error", error=...)`, so the TaskRunner's degrade-and-continue
18
+ keeps working.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Callable
24
+ from typing import Any
25
+
26
+ import pandas as pd
27
+
28
+ from src.tools.analytics import (
29
+ aggregation,
30
+ comparison,
31
+ decomposition,
32
+ descriptive,
33
+ quality,
34
+ relationship,
35
+ segmentation,
36
+ temporal,
37
+ )
38
+ from src.tools.contracts import ToolOutput
39
+
40
+ # tool name -> (compute callable, ToolOutput.kind it produces). Kept in lockstep
41
+ # with src/tools/registry.py output_kind values.
42
+ _DISPATCH: dict[str, tuple[Callable[..., Any], str]] = {
43
+ "analyze_descriptive": (descriptive.analyze_descriptive, "stats"),
44
+ "analyze_aggregate": (aggregation.analyze_aggregate, "table"),
45
+ "analyze_comparison": (comparison.analyze_comparison, "stats"),
46
+ "analyze_contribution": (decomposition.analyze_contribution, "table"),
47
+ "analyze_profile": (quality.analyze_profile, "stats"),
48
+ "analyze_correlation": (relationship.analyze_correlation, "stats"),
49
+ "analyze_segment": (segmentation.analyze_segment, "table"),
50
+ "analyze_trend": (temporal.analyze_trend, "series"),
51
+ }
52
+
53
+
54
+ class AnalyticsToolInvoker:
55
+ """Never-throwing invoker for the `analyze_*` tools (implements ToolInvoker)."""
56
+
57
+ async def invoke(self, tool_name: str, args: dict[str, Any]) -> ToolOutput:
58
+ entry = _DISPATCH.get(tool_name)
59
+ if entry is None:
60
+ return ToolOutput(
61
+ tool=tool_name, kind="error", error=f"unknown tool {tool_name!r}"
62
+ )
63
+ fn, kind = entry
64
+
65
+ df, err = _materialize(args.get("data"))
66
+ if err is not None:
67
+ return ToolOutput(tool=tool_name, kind="error", error=err)
68
+
69
+ kwargs = {k: v for k, v in args.items() if k != "data"}
70
+ try:
71
+ result = fn(df, **kwargs)
72
+ except Exception as exc: # noqa: BLE001 — never-throw seam (§8.4)
73
+ return ToolOutput(
74
+ tool=tool_name,
75
+ kind="error",
76
+ error=f"{type(exc).__name__}: {exc}",
77
+ )
78
+
79
+ return ToolOutput(tool=tool_name, kind=kind, value=result)
80
+
81
+
82
+ def _materialize(data: Any) -> tuple[pd.DataFrame, None] | tuple[None, str]:
83
+ """Turn the resolved `data` argument into a DataFrame.
84
+
85
+ Accepts the upstream `ToolOutput` (kind="table"), a raw DataFrame, or a
86
+ {"columns", "rows"} dict (a serialized table). Returns (df, None) on success
87
+ or (None, error_message) on failure — the caller wraps the message.
88
+ """
89
+ if data is None:
90
+ return None, "missing 'data' argument (no upstream table to analyze)"
91
+
92
+ if isinstance(data, pd.DataFrame):
93
+ return data, None
94
+
95
+ if isinstance(data, ToolOutput):
96
+ if data.kind == "error":
97
+ return None, f"upstream data unavailable: {data.error}"
98
+ if data.kind != "table" or data.columns is None:
99
+ return None, f"cannot materialize 'data' of kind {data.kind!r}"
100
+ return pd.DataFrame(data.rows or [], columns=data.columns), None
101
+
102
+ if isinstance(data, dict) and "columns" in data:
103
+ return pd.DataFrame(data.get("rows") or [], columns=data["columns"]), None
104
+
105
+ return None, f"unsupported 'data' type: {type(data).__name__}"