Commit ·
0cb4e6d
1
Parent(s): db217d6
[KM-627] Canonical ToolSpec + ToolOutput
Browse filesMove ToolSpec/ToolRegistry/ToolOutput to src/tools/contracts.py as the single
source of truth (owned by the tool team, KM-465). planner/contracts.py now
re-exports them so the planner/slow_path importers are unchanged; shapes are
identical to the prior stubs (zero field change).
Records the data-flow decision: Pattern A (analyze_* take data="${t<id>}"
placeholder, not self-fetch by source_id) and analyze_comparison.output_kind =
"stats", aligning with the planner registry.
Verified: imports OK, ruff + mypy strict clean, 72 analytics tests green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- src/agents/planner/contracts.py +23 -62
- src/tools/contracts.py +70 -0
src/agents/planner/contracts.py
CHANGED
|
@@ -1,32 +1,35 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
- `ToolSpec` / `ToolRegistry`
|
| 11 |
-
Owner: the tool team (KM-608).
|
| 12 |
-
Shape mirrors §9.2. The concrete v1 P0 registry instance lives in
|
| 13 |
-
`registry.py`; this file only defines the contract types.
|
| 14 |
-
|
| 15 |
-
- `ToolOutput`
|
| 16 |
-
The tool -> agent output envelope (§8.1). Not produced by the planner
|
| 17 |
-
(tools return it at TaskRunner time); defined here so the planner layer
|
| 18 |
-
owns one definition of the contract it plans against.
|
| 19 |
-
|
| 20 |
-
When the real modules land, delete the corresponding stub here and import from
|
| 21 |
-
the shared location instead.
|
| 22 |
"""
|
| 23 |
|
| 24 |
from __future__ import annotations
|
| 25 |
|
| 26 |
-
from typing import
|
| 27 |
|
| 28 |
from pydantic import BaseModel, Field
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
# --------------------------------------------------------------------------- #
|
| 31 |
# BusinessContext (lead's contract — §7.1)
|
| 32 |
# --------------------------------------------------------------------------- #
|
|
@@ -60,45 +63,3 @@ class BusinessContext(BaseModel):
|
|
| 60 |
recent_events: str = ""
|
| 61 |
things_to_watch_for: str = ""
|
| 62 |
open_questions: list[str] = Field(default_factory=list)
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
# --------------------------------------------------------------------------- #
|
| 66 |
-
# Tool registry (tool team's contract — §9.2)
|
| 67 |
-
# --------------------------------------------------------------------------- #
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
class ToolSpec(BaseModel):
|
| 71 |
-
name: str
|
| 72 |
-
category: str # analytics.query | .aggregation | .timeseries | ...
|
| 73 |
-
input_schema: dict[str, Any] # validates ToolCall.args
|
| 74 |
-
output_kind: str # the ToolOutput.kind it returns
|
| 75 |
-
description: str # prompt-style: what it does, edge cases, what NOT to use it for
|
| 76 |
-
phase: Literal["P0", "P1", "P2"] = "P0"
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
class ToolRegistry(BaseModel):
|
| 80 |
-
tools: list[ToolSpec] = Field(default_factory=list)
|
| 81 |
-
|
| 82 |
-
def names(self) -> set[str]:
|
| 83 |
-
return {t.name for t in self.tools}
|
| 84 |
-
|
| 85 |
-
def get(self, name: str) -> ToolSpec | None:
|
| 86 |
-
for t in self.tools:
|
| 87 |
-
if t.name == name:
|
| 88 |
-
return t
|
| 89 |
-
return None
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
# --------------------------------------------------------------------------- #
|
| 93 |
-
# Tool output envelope (tool -> agent contract — §8.1)
|
| 94 |
-
# --------------------------------------------------------------------------- #
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
class ToolOutput(BaseModel):
|
| 98 |
-
tool: str
|
| 99 |
-
kind: Literal["scalar", "table", "stats", "series", "documents", "error"]
|
| 100 |
-
value: Any | None = None
|
| 101 |
-
columns: list[str] | None = None
|
| 102 |
-
rows: list[list[Any]] | None = None
|
| 103 |
-
meta: dict[str, Any] = Field(default_factory=dict)
|
| 104 |
-
error: str | None = None
|
|
|
|
| 1 |
+
"""Contracts the planner consumes from other teams.
|
| 2 |
|
| 3 |
+
`BusinessContext` (+ KeyTerm / DataTableNote / DataColumnNote) is still a LOCAL
|
| 4 |
+
STUB owned by the lead (interview / Business Understanding); shape mirrors
|
| 5 |
+
AGENT_ARCHITECTURE_CONTEXT_new.md §7.1 and must be reconciled before integration.
|
| 6 |
|
| 7 |
+
`ToolSpec` / `ToolRegistry` / `ToolOutput` are NO LONGER defined here — the tool
|
| 8 |
+
team owns them (KM-465). They now live in `src.tools.contracts` and are
|
| 9 |
+
re-exported below so existing importers (`registry.py`, the slow-path
|
| 10 |
+
`invoker.py` / `schemas.py`) keep working against one shared definition.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
+
from typing import Literal
|
| 16 |
|
| 17 |
from pydantic import BaseModel, Field
|
| 18 |
|
| 19 |
+
# Canonical tool contracts now owned by the tool team (KM-465). Re-exported here
|
| 20 |
+
# for backwards-compatible imports; the definitions live in src.tools.contracts.
|
| 21 |
+
from src.tools.contracts import ToolOutput, ToolRegistry, ToolSpec
|
| 22 |
+
|
| 23 |
+
__all__ = [
|
| 24 |
+
"KeyTerm",
|
| 25 |
+
"DataTableNote",
|
| 26 |
+
"DataColumnNote",
|
| 27 |
+
"BusinessContext",
|
| 28 |
+
"ToolSpec",
|
| 29 |
+
"ToolRegistry",
|
| 30 |
+
"ToolOutput",
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
# --------------------------------------------------------------------------- #
|
| 34 |
# BusinessContext (lead's contract — §7.1)
|
| 35 |
# --------------------------------------------------------------------------- #
|
|
|
|
| 63 |
recent_events: str = ""
|
| 64 |
things_to_watch_for: str = ""
|
| 65 |
open_questions: list[str] = Field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/tools/contracts.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Canonical tool contracts (KM-465 — owned by the tool team).
|
| 2 |
+
|
| 3 |
+
These are the single source of truth for the tool <-> agent interface:
|
| 4 |
+
|
| 5 |
+
- `ToolSpec` / `ToolRegistry` — the registry contract (§9.2). The concrete v1
|
| 6 |
+
analytics registry instance is built on top of these.
|
| 7 |
+
- `ToolOutput` — the tool -> agent output envelope (§8.1). Tools return this at
|
| 8 |
+
TaskRunner time; the agent layer plans and degrades against it.
|
| 9 |
+
|
| 10 |
+
Ownership note (2026-06-08): the tool team owns these definitions outright; the
|
| 11 |
+
agent team's earlier copies in `src/agents/planner/contracts.py` are now thin
|
| 12 |
+
re-exports of this module so there is exactly ONE definition across the codebase.
|
| 13 |
+
The shapes here are kept byte-for-byte identical to those original stubs so the
|
| 14 |
+
already-landed planner / TaskRunner / Assembler keep working unchanged.
|
| 15 |
+
|
| 16 |
+
Data-flow decision (KM-465, agreed with the agent team): the analytics tools use
|
| 17 |
+
**Pattern A** — `analyze_*` tools do NOT self-fetch by `source_id`; each takes a
|
| 18 |
+
`data` argument that is a `"${t<id>}"` placeholder resolved to a DataFrame at
|
| 19 |
+
execution time. `analyze_comparison.output_kind` is `"stats"` (a labelled-metric
|
| 20 |
+
dict), aligning with the planner registry.
|
| 21 |
+
|
| 22 |
+
See AGENT_ARCHITECTURE_CONTEXT_new.md §8.1 / §9.2.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
from typing import Any, Literal
|
| 28 |
+
|
| 29 |
+
from pydantic import BaseModel, Field
|
| 30 |
+
|
| 31 |
+
# --------------------------------------------------------------------------- #
|
| 32 |
+
# Tool registry (§9.2)
|
| 33 |
+
# --------------------------------------------------------------------------- #
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class ToolSpec(BaseModel):
|
| 37 |
+
name: str
|
| 38 |
+
category: str # analytics.query | .aggregation | .timeseries | ...
|
| 39 |
+
input_schema: dict[str, Any] # validates ToolCall.args
|
| 40 |
+
output_kind: str # the ToolOutput.kind it returns
|
| 41 |
+
description: str # prompt-style: what it does, edge cases, what NOT to use it for
|
| 42 |
+
phase: Literal["P0", "P1", "P2"] = "P0"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class ToolRegistry(BaseModel):
|
| 46 |
+
tools: list[ToolSpec] = Field(default_factory=list)
|
| 47 |
+
|
| 48 |
+
def names(self) -> set[str]:
|
| 49 |
+
return {t.name for t in self.tools}
|
| 50 |
+
|
| 51 |
+
def get(self, name: str) -> ToolSpec | None:
|
| 52 |
+
for t in self.tools:
|
| 53 |
+
if t.name == name:
|
| 54 |
+
return t
|
| 55 |
+
return None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# --------------------------------------------------------------------------- #
|
| 59 |
+
# Tool output envelope (§8.1)
|
| 60 |
+
# --------------------------------------------------------------------------- #
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class ToolOutput(BaseModel):
|
| 64 |
+
tool: str
|
| 65 |
+
kind: Literal["scalar", "table", "stats", "series", "documents", "error"]
|
| 66 |
+
value: Any | None = None
|
| 67 |
+
columns: list[str] | None = None
|
| 68 |
+
rows: list[list[Any]] | None = None
|
| 69 |
+
meta: dict[str, Any] = Field(default_factory=dict)
|
| 70 |
+
error: str | None = None
|