Rifqi Hafizuddin Claude Opus 4.8 commited on
Commit ·
be96601
1
Parent(s): 61c746f
[KM-567][AI] Planner agent: contracts, schemas, inputs, registry
Browse filesPhase 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 +8 -0
- src/agents/planner/contracts.py +104 -0
- src/agents/planner/errors.py +15 -0
- src/agents/planner/inputs.py +132 -0
- src/agents/planner/registry.py +159 -0
- src/agents/planner/schemas.py +54 -0
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)
|