Rifqi Hafizuddin Claude Opus 4.8 commited on
Commit
be96601
·
1 Parent(s): 61c746f

[KM-567][AI] Planner agent: contracts, schemas, inputs, registry

Browse files

Phase 3 slow-path Planner foundation (AGENT_ARCHITECTURE_CONTEXT_new.md §7.3).

- contracts.py: STUB BusinessContext / ToolSpec / ToolRegistry / ToolOutput
(to reconcile with lead + tool team KM-608).
- schemas.py: TaskList / Task / ToolCall output contract. No replan schemas.
- inputs.py: CatalogSummary (condensed, PII sample_values nulled, from_catalog
builder) + Constraints.
- registry.py: STUB v1 P0 tool registry (query_structured, retrieve_documents,
list/describe_source, compute_median/stddev/percentile/mode, date_trunc).
- errors.py: PlannerError / PlannerValidationError.

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

src/agents/planner/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """Planner agent — slow-path CRISP-DM analysis planner.
2
+
3
+ Single LLM call: BusinessContext + CatalogSummary + ToolRegistry + question +
4
+ Constraints -> a validated, static `TaskList` (a DAG of fully-specified
5
+ tool-call chains). No replanning (INV-6); tool-agnostic (INV-7).
6
+
7
+ See AGENT_ARCHITECTURE_CONTEXT_new.md §7.3.
8
+ """
src/agents/planner/contracts.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """STUB contracts the planner consumes from other teams.
2
+
3
+ These models are LOCAL STUBS so the planner is buildable and testable now.
4
+ They must be reconciled with their owners before integration:
5
+
6
+ - `BusinessContext` (+ KeyTerm / DataTableNote / DataColumnNote)
7
+ Owner: the lead (interview / Business Understanding).
8
+ Shape mirrors AGENT_ARCHITECTURE_CONTEXT_new.md §7.1.
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 Any, Literal
27
+
28
+ from pydantic import BaseModel, Field
29
+
30
+ # --------------------------------------------------------------------------- #
31
+ # BusinessContext (lead's contract — §7.1)
32
+ # --------------------------------------------------------------------------- #
33
+
34
+
35
+ class KeyTerm(BaseModel):
36
+ term: str
37
+ meaning: str
38
+
39
+
40
+ class DataTableNote(BaseModel):
41
+ table_name: str
42
+ row_represents: str
43
+
44
+
45
+ class DataColumnNote(BaseModel):
46
+ column_name: str
47
+ meaning: str
48
+
49
+
50
+ class BusinessContext(BaseModel):
51
+ project_id: str
52
+ industry: str
53
+ completeness: Literal["partial", "complete"]
54
+ business_description: str
55
+ scale_and_scope: str
56
+ key_terms: list[KeyTerm] = Field(default_factory=list)
57
+ data_overview: list[DataTableNote] = Field(default_factory=list)
58
+ data_column_notes: list[DataColumnNote] = Field(default_factory=list)
59
+ whats_normal: str = ""
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
src/agents/planner/errors.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Typed errors for the planner agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class PlannerError(Exception):
7
+ """Base error for the planner agent."""
8
+
9
+
10
+ class PlannerValidationError(PlannerError):
11
+ """A TaskList failed one of the planner validator's checks.
12
+
13
+ The message is specific enough that the planner can be re-prompted with it
14
+ to self-correct (max 3 attempts, see service.py).
15
+ """
src/agents/planner/inputs.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Planner input models — CatalogSummary and Constraints.
2
+
3
+ `CatalogSummary` is a condensed, PII-safe view of the user's `Catalog`, built
4
+ for the planner prompt. It carries every table + column id/type/PII flag + row
5
+ counts + low-cardinality top_values, with `sample_values` nulled on PII columns
6
+ (INV: no PII sample values into the prompt, see doc §13). It also lists the
7
+ available unstructured sources so the planner can plan `retrieve_documents`.
8
+
9
+ The planner *validator* still checks inline `query_structured` IRs against the
10
+ full `Catalog` via the existing IRValidator — the summary is a prompt input, not
11
+ the validation source of truth.
12
+
13
+ See AGENT_ARCHITECTURE_CONTEXT_new.md §7.3.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from pydantic import BaseModel, Field
21
+
22
+ from ...catalog.models import Catalog, DataType
23
+
24
+
25
+ class ColumnSummary(BaseModel):
26
+ column_id: str
27
+ name: str
28
+ data_type: DataType
29
+ pii_flag: bool = False
30
+ sample_values: list[Any] | None = None # nulled when pii_flag is True
31
+ top_values: list[Any] | None = None
32
+
33
+
34
+ class TableSummary(BaseModel):
35
+ table_id: str
36
+ name: str
37
+ row_count: int | None = None
38
+ columns: list[ColumnSummary] = Field(default_factory=list)
39
+
40
+
41
+ class StructuredSourceSummary(BaseModel):
42
+ source_id: str
43
+ name: str
44
+ source_type: str # "schema" | "tabular"
45
+ tables: list[TableSummary] = Field(default_factory=list)
46
+
47
+
48
+ class UnstructuredSourceSummary(BaseModel):
49
+ source_id: str
50
+ name: str
51
+
52
+
53
+ class CatalogSummary(BaseModel):
54
+ structured_sources: list[StructuredSourceSummary] = Field(default_factory=list)
55
+ unstructured_sources: list[UnstructuredSourceSummary] = Field(default_factory=list)
56
+
57
+ @classmethod
58
+ def from_catalog(cls, catalog: Catalog) -> CatalogSummary:
59
+ structured: list[StructuredSourceSummary] = []
60
+ unstructured: list[UnstructuredSourceSummary] = []
61
+
62
+ for source in catalog.sources:
63
+ if source.source_type == "unstructured":
64
+ unstructured.append(
65
+ UnstructuredSourceSummary(source_id=source.source_id, name=source.name)
66
+ )
67
+ continue
68
+
69
+ tables = [
70
+ TableSummary(
71
+ table_id=table.table_id,
72
+ name=table.name,
73
+ row_count=table.row_count,
74
+ columns=[
75
+ ColumnSummary(
76
+ column_id=col.column_id,
77
+ name=col.name,
78
+ data_type=col.data_type,
79
+ pii_flag=col.pii_flag,
80
+ sample_values=None if col.pii_flag else col.sample_values,
81
+ top_values=col.stats.top_values if col.stats else None,
82
+ )
83
+ for col in table.columns
84
+ ],
85
+ )
86
+ for table in source.tables
87
+ ]
88
+ structured.append(
89
+ StructuredSourceSummary(
90
+ source_id=source.source_id,
91
+ name=source.name,
92
+ source_type=source.source_type,
93
+ tables=tables,
94
+ )
95
+ )
96
+
97
+ return cls(structured_sources=structured, unstructured_sources=unstructured)
98
+
99
+ def render(self) -> str:
100
+ """Render the summary as compact text for the planner prompt."""
101
+ if not self.structured_sources and not self.unstructured_sources:
102
+ return "(catalog is empty — the user has not registered any data yet)"
103
+
104
+ lines: list[str] = []
105
+ for source in self.structured_sources:
106
+ lines.append(f"Source: {source.name} ({source.source_type}) — id={source.source_id}")
107
+ for table in source.tables:
108
+ rc = f" ({table.row_count:,} rows)" if table.row_count is not None else ""
109
+ lines.append(f" Table: {table.name}{rc} — id={table.table_id}")
110
+ for col in table.columns:
111
+ samples = "PII (suppressed)" if col.pii_flag else (col.sample_values or [])
112
+ top = f", top={col.top_values}" if col.top_values else ""
113
+ lines.append(
114
+ f" - {col.name} [{col.data_type}]: "
115
+ f"samples={samples}{top} — id={col.column_id}"
116
+ )
117
+ lines.append("")
118
+
119
+ if self.unstructured_sources:
120
+ lines.append("Unstructured sources (for retrieve_documents):")
121
+ for src in self.unstructured_sources:
122
+ lines.append(f" - {src.name} — id={src.source_id}")
123
+
124
+ return "\n".join(lines).rstrip()
125
+
126
+
127
+ class Constraints(BaseModel):
128
+ max_tasks: int = 5
129
+ modeling_allowed: bool = False # no modeling tools in v1
130
+ token_budget: int | None = None
131
+ time_budget_seconds: int | None = None
132
+ row_budget: int = 10_000
src/agents/planner/registry.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """STUB v1 P0 tool registry.
2
+
3
+ This is the agent team's local stand-in for the tool team's inventory (KM-608)
4
+ so the planner is buildable and testable before the real tools land. The tools
5
+ here are *contracts only* — there is no implementation behind them; the planner
6
+ plans against the registry and never names a tool outside it (INV-7).
7
+
8
+ `input_schema` is a lightweight JSON-schema-ish dict consumed by the planner
9
+ validator (validator.py check #8): it carries `required` (list of arg names) and
10
+ `properties` (allowed arg names). Arg *values* may be "${t<id>}" placeholders the
11
+ TaskRunner resolves at execution time, so the validator checks arg *keys*, not
12
+ value types — except `query_structured.args["ir"]`, whose inline QueryIR is
13
+ validated against the catalog by the existing IRValidator.
14
+
15
+ When KM-608 ships, replace `default_registry()` with the real registry import.
16
+ See AGENT_ARCHITECTURE_CONTEXT_new.md §9.2 / §9.3.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from .contracts import ToolRegistry, ToolSpec
22
+
23
+ _P0_TOOLS: list[ToolSpec] = [
24
+ ToolSpec(
25
+ name="query_structured",
26
+ category="analytics.query",
27
+ input_schema={"required": ["ir"], "properties": {"ir": {"type": "object"}}},
28
+ output_kind="table",
29
+ description=(
30
+ "Run one validated, single-table query against a structured source (DB "
31
+ "schema or tabular file) and return rows. The `ir` argument is an inline "
32
+ "QueryIR (the JSON intent: source_id, table_id, select, filters, group_by, "
33
+ "order_by, limit) — never SQL. Use this for any selection, filtering, "
34
+ "grouping, or built-in aggregation (count/sum/avg/min/max/count_distinct). "
35
+ "Do NOT use it for medians/percentiles/modes/stddev (use the compute_* "
36
+ "tools on its output) and do NOT use it to read documents (use "
37
+ "retrieve_documents)."
38
+ ),
39
+ ),
40
+ ToolSpec(
41
+ name="retrieve_documents",
42
+ category="retrieval.documents",
43
+ input_schema={
44
+ "required": ["query"],
45
+ "properties": {
46
+ "query": {"type": "string"},
47
+ "source_id": {"type": "string"},
48
+ "top_k": {"type": "integer"},
49
+ },
50
+ },
51
+ output_kind="documents",
52
+ description=(
53
+ "Dense-retrieve the most relevant chunks from the user's unstructured "
54
+ "sources (PDF/DOCX/TXT) for a natural-language `query`. Use this to pull "
55
+ "qualitative context into an analysis. Optionally scope to one `source_id`. "
56
+ "Do NOT use it for numbers in tables — that is query_structured's job."
57
+ ),
58
+ ),
59
+ ToolSpec(
60
+ name="list_sources",
61
+ category="catalog.introspection",
62
+ input_schema={"required": [], "properties": {}},
63
+ output_kind="table",
64
+ description=(
65
+ "List the user's available data sources (id, name, type, table count). Use "
66
+ "early in data_understanding when the plan must discover what exists before "
67
+ "querying. Cheap. Do NOT use it to read column details (use describe_source)."
68
+ ),
69
+ ),
70
+ ToolSpec(
71
+ name="describe_source",
72
+ category="catalog.introspection",
73
+ input_schema={
74
+ "required": ["source_id"],
75
+ "properties": {"source_id": {"type": "string"}},
76
+ },
77
+ output_kind="table",
78
+ description=(
79
+ "Return the tables and columns (names, types, row counts) of one source by "
80
+ "`source_id`. Use in data_understanding to confirm the shape of a source "
81
+ "before querying it. Do NOT use it to fetch data rows (use query_structured)."
82
+ ),
83
+ ),
84
+ ToolSpec(
85
+ name="compute_median",
86
+ category="analytics.aggregation",
87
+ input_schema={"required": ["values"], "properties": {"values": {"type": "array"}}},
88
+ output_kind="scalar",
89
+ description=(
90
+ "Compute the median of a numeric series. `values` is typically a "
91
+ "'${t<id>}' placeholder referencing an upstream query_structured output "
92
+ "column. Use this because SQL/pandas median is not exposed via the IR. Do "
93
+ "NOT use it on categorical data (use compute_mode)."
94
+ ),
95
+ ),
96
+ ToolSpec(
97
+ name="compute_stddev",
98
+ category="analytics.aggregation",
99
+ input_schema={"required": ["values"], "properties": {"values": {"type": "array"}}},
100
+ output_kind="scalar",
101
+ description=(
102
+ "Compute the standard deviation of a numeric series (`values`, usually a "
103
+ "'${t<id>}' placeholder from an upstream query). Use to quantify spread or "
104
+ "to flag outliers. Do NOT use on non-numeric data."
105
+ ),
106
+ ),
107
+ ToolSpec(
108
+ name="compute_percentile",
109
+ category="analytics.aggregation",
110
+ input_schema={
111
+ "required": ["values", "percentile"],
112
+ "properties": {
113
+ "values": {"type": "array"},
114
+ "percentile": {"type": "number"},
115
+ },
116
+ },
117
+ output_kind="scalar",
118
+ description=(
119
+ "Compute a given `percentile` (0-100) of a numeric series `values` "
120
+ "(usually a '${t<id>}' placeholder). Use for p90/p95-style thresholds. Do "
121
+ "NOT use for the median alone (use compute_median)."
122
+ ),
123
+ ),
124
+ ToolSpec(
125
+ name="compute_mode",
126
+ category="analytics.aggregation",
127
+ input_schema={"required": ["values"], "properties": {"values": {"type": "array"}}},
128
+ output_kind="scalar",
129
+ description=(
130
+ "Compute the most frequent value(s) of a series `values` (usually a "
131
+ "'${t<id>}' placeholder). Works on categorical or numeric data. Use to find "
132
+ "the typical category. Do NOT use it for an average (use query_structured "
133
+ "avg)."
134
+ ),
135
+ ),
136
+ ToolSpec(
137
+ name="date_trunc",
138
+ category="analytics.timeseries",
139
+ input_schema={
140
+ "required": ["values", "granularity"],
141
+ "properties": {
142
+ "values": {"type": "array"},
143
+ "granularity": {"type": "string"},
144
+ },
145
+ },
146
+ output_kind="series",
147
+ description=(
148
+ "Truncate a datetime series `values` (usually a '${t<id>}' placeholder) to "
149
+ "a `granularity` ('day' | 'week' | 'month' | 'quarter' | 'year') so results "
150
+ "can be grouped into time buckets for trend analysis. Do NOT use it to "
151
+ "filter by date — put a date filter in the query_structured IR instead."
152
+ ),
153
+ ),
154
+ ]
155
+
156
+
157
+ def default_registry() -> ToolRegistry:
158
+ """The v1 P0 stub registry (a fresh instance per call)."""
159
+ return ToolRegistry(tools=list(_P0_TOOLS))
src/agents/planner/schemas.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Planner output schemas — the `TaskList` contract.
2
+
3
+ The planner emits exactly one `TaskList`: a DAG of typed tasks, each an ordered
4
+ chain of fully-specified tool calls. This is the static plan the TaskRunner
5
+ executes verbatim. There is no replanning (INV-6), so there are no
6
+ `ReplanRequest` / `ReplanResponse` schemas.
7
+
8
+ See AGENT_ARCHITECTURE_CONTEXT_new.md §7.3.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Literal
14
+
15
+ from pydantic import BaseModel, Field
16
+
17
+ CrispStage = Literal[
18
+ "data_understanding",
19
+ "data_preparation",
20
+ "modeling", # no tools in v1; the planner does not emit modeling tasks
21
+ "evaluation",
22
+ ]
23
+
24
+
25
+ class ToolCall(BaseModel):
26
+ """One call to a registry tool with concrete, fully-specified arguments.
27
+
28
+ `tool` must exist in the ToolRegistry. `args` is validated against the
29
+ tool's input_schema; it may contain "${t<id>}" placeholders that the
30
+ TaskRunner resolves from an upstream task's output at execution time.
31
+ """
32
+
33
+ tool: str
34
+ args: dict[str, Any] = Field(default_factory=dict)
35
+
36
+
37
+ class Task(BaseModel):
38
+ id: str # "t1", "t2", ...
39
+ stage: CrispStage
40
+ objective: str # plain-language intent for this step
41
+ tool_calls: list[ToolCall] = Field(..., min_length=1) # ordered chain
42
+ expected_output: str # named result this task produces
43
+ success_criteria: str # REPORTING signal, not a control trigger
44
+ depends_on: list[str] = Field(default_factory=list) # task ids
45
+ parallelizable_with: list[str] = Field(default_factory=list)
46
+ estimated_cost: Literal["low", "medium", "high"] = "low"
47
+
48
+
49
+ class TaskList(BaseModel):
50
+ plan_id: str
51
+ goal_restated: str
52
+ assumptions: list[str] = Field(default_factory=list)
53
+ open_questions: list[str] = Field(default_factory=list)
54
+ tasks: list[Task] = Field(default_factory=list)