This file defines every agent in the agentic clustering pipeline, its role,
skills, communication contract with the orchestrator, and retry/fallback
behaviour. Each agent implementation lives in agents/<name>.py and uses
skills from skills/.
File: agents/orchestrator.py
Class: Orchestrator
Central coordinator. Owns the pipeline state, routes feedback between agents, maintains the orchestrator message log, and uses LLM reasoning to diagnose complex failures. Presents a human checkpoint when the pipeline converges or exhausts its retry budget.
config: dict(fromconfig.yaml)user_intent: UserIntentfeatures_path: str
dictwith keysstatus,personas,run_history,timing,llm_usage
- Receive
OrchestratorMessagefrom every agent viaorchestrator_bus - Log all messages to
pipeline_log(saved tooutputs/pipeline_log.json) - Use LLM to analyse failure reports and decide routing
- After each failed iteration, call
_ask_parameter_tuning()to let LLM propose new values forvif_threshold,k_range,algorithm,min_silhouette, andfeature_focus— these are passed directly to FeatureSelector and Clusterer - Enforce per-loop retry budgets (default
max_total_iterations=10) - At max iterations with no approved result, deliver a best-effort analysis by running PersonaNamer (force_proceed=True) and Classifier on the highest-silhouette clustering observed across all iterations
- Present human checkpoint with full pipeline log summary
| Parameter | Default | LLM tuning range |
|---|---|---|
vif_threshold |
10.0 | 5 – 25 |
k_range |
[3,4,5,6,7,8,10,12,15] |
any subset of k ∈ [2,20] |
algorithm |
null (auto) | "kmeans", "hierarchical", "dbscan", "gmm", "fuzzy_cmeans", null |
min_silhouette |
0.05 | 0.02 – 0.12 |
feature_focus |
"" |
free-text hint injected into FeatureSelector prompt |
| Agent reports | Orchestrator action |
|---|---|
FeatureSelector BLOCKED |
→ route to FeatureEngineer (more features needed) |
Clusterer WARNING (low silhouette) |
→ proceed with warning; tune params |
Clusterer BLOCKED (sil < min_silhouette) |
→ tune params → route to FeatureSelector |
PersonaNamer BLOCKED (Clarity Gate fail) |
→ tune params → route to Clusterer |
Classifier BLOCKED (F1 < 0.70) |
→ tune params → route to FeatureSelector or Clusterer |
Any recommendation=escalate |
→ trigger human checkpoint immediately |
| Max iterations reached, no approved result | → best-effort fallback (force_proceed=True) |
File: agents/user_input.py
Class: UserInputAgent
Collects and validates the user's clustering intent before any computation. This is the entry point of the pipeline.
- (none from
skills/— pure interactive I/O)
- None (interactive terminal)
UserIntentdataclass:target_entity: str— what is being clusteredbusiness_purpose: str— why we are clusteringdataset_path: str— path to raw data fileconstraints: str— optional free-text constraintsn_clusters_requested: int | None— if set, ClusteringAgent uses this exact k (skips silhouette optimisation)must_have_clusters: list[str]— cluster labels that MUST appear in the final personas (e.g.['traveller', 'VIP']); enforced by PersonaNamingAgent Clarity Gate
- "What entity are you clustering?" —
target_entity - "What is the business purpose?" —
business_purpose(follow-up if < 20 chars) - "Dataset path?" —
dataset_path - "Any constraints?" —
constraints - "How many clusters? (Enter = data-driven)" —
n_clusters_requested - "Must any specific types appear as clusters? (Enter = none)" —
must_have_clusters(comma-separated)
{
"agent": "UserInput",
"status": "success | blocked",
"what_was_done": "Collected target entity and business purpose from user",
"what_was_not_done": "",
"doubts": "Business purpose may still be too vague",
"issues": [],
"metrics": {
"n_clusters_requested": 5,
"must_have_clusters": ["traveller", "VIP"]
},
"recommendation": "proceed"
}- If user gives a vague business purpose (< 20 chars), agent asks one clarifying follow-up question before proceeding.
- If running non-interactively (EOFError), agent uses sensible defaults from
config.yamland reportsdoubts="running with defaults".
File: agents/dataset_examiner.py
Class: DatasetExaminerAgent
Profiles the raw dataset and identifies feature engineering opportunities aligned with the stated business purpose. Uses LLM with the schema and business purpose to get suggested feature groups.
orchestrator_bus.report()
user_intent: UserIntent- Raw DataFrame (loaded from
user_intent.dataset_path)
If the dataset folder contains a README.md (e.g. data/raw/air_quality/README.md),
the agent reads it automatically and includes its content in:
- The LLM prompt for feature group suggestions (so the LLM can use domain context from the data provider)
DatasetProfile.dataset_readme— passed downstream toFeatureEngineerAgentandFeatureSelectionAgent
The README text is capped at 3 000 chars to keep prompts manageable. If no README exists, the field is "".
DatasetProfiledataclass:n_rows: int,n_cols: intcolumn_types: dict[str, str]missing_rates: dict[str, float]distribution_summary: dict[str, dict](skewness, kurtosis, min/max/mean)suggested_feature_groups: list[str](from LLM)warnings: list[str]dataset_readme: str— full text ofREADME.mdfrom the dataset folder (empty string if absent)
{
"agent": "DatasetExaminer",
"status": "success | warning | blocked",
"what_was_done": "Profiled schema, analysed distributions, called LLM for feature group suggestions",
"what_was_not_done": "Did not load data subsets for validation",
"doubts": "A high-cardinality column may need grouping before feature engineering",
"issues": ["Column 'age' missing in 15% of rows"],
"metrics": { "n_rows": 10000, "n_cols": 25, "n_suggested_groups": 5, "has_readme": true },
"recommendation": "proceed"
}| Issue | Status | Recommendation |
|---|---|---|
| Dataset not found | blocked |
escalate |
| No numeric columns | blocked |
escalate |
| > 30% missing in key columns | warning |
proceed (with imputation note) |
| All columns constant | blocked |
escalate |
File: agents/feature_engineer.py
Class: FeatureEngineerAgent
Builds an entity-level feature matrix from raw event-level data. The LLM (via OrchestratorBus) reads the actual dataset schema and business purpose, then plans which of 8 generic statistical operations to apply to which columns. No domain vocabulary is hard-coded — the same agent handles transaction logs, product catalogs, patient visits, sensor readings, or any other tabular event data.
orchestrator_bus.ask(),orchestrator_bus.report()
- Raw DataFrame (event-level)
DatasetProfile— schema, suggested feature groups, algo hintUserIntent— target entity, business purpose- Orchestrator feedback (free-text, injected into LLM prompt on retry)
- Engineered feature DataFrame (entity-level, persisted to
data/processed/) FeatureEngineeringResult:n_entities: intn_features: intfeature_names: list[str]groups_built: list[str]output_path: strreasoning: str
Before building features, the agent auto-detects four structural columns from the schema using exact match first, then case-insensitive substring match:
| Role | What it identifies | Example column names detected |
|---|---|---|
| entity / ID | The column that identifies each entity being clustered (required) | id, user_id, customer_id, patient_id, device_id, sensor_id, uuid, … |
| timestamp / date | When each event occurred (optional) | timestamp, date, time, ts, created_at, event_time, visit_date, … |
| value / amount | The primary numeric measure per event (optional) | amount, value, price, cost, qty, score, duration, reading, … |
| category / kind | The column that groups events into types (optional) | category, type, kind, label, class, group, tag, genre, department, sector, channel, … |
If auto-detection fails for any column, the agent asks the user interactively — it prints all available column names and waits for input. Required columns (entity/ID) must be provided; optional columns can be skipped by pressing Enter.
| Builder | What it computes | Example column names |
|---|---|---|
group_aggregate |
count/sum/mean/std/max/freq/pct_count/pct_sum per group value × window | count_{col}_{val}_{w} |
group_trend |
change in count or sum between two windows | trend_count_{col}_{val} |
group_streak |
consecutive active periods per group value | streak_{col}_{val} |
overall_aggregate |
aggregate over all events (no grouping) | sum_{val_col}_{w} |
frequency_recency |
event frequency, active periods, recency, gap | event_count_{w}, days_since_last |
entity_diversity |
number of unique values per column × window | n_unique_{col}_{w} |
temporal_patterns |
morning/evening/weekend ratios, peak hour | pct_morning_{w}, pct_weekend_{w} |
static_attributes |
copy entity-level static columns as-is | original column name |
The LLM is shown the actual column names from the dataset schema and chooses which builders to apply to which columns. Feature column names embed the actual data column names, not domain abbreviations.
{
"agent": "FeatureEngineer",
"status": "success | warning | blocked",
"what_was_done": "Built 108 features across 6 behavioral groups from LLM plan",
"what_was_not_done": "Could not build temporal features (no timestamp column found)",
"doubts": "Frequency features may overlap with diversity features",
"issues": [],
"metrics": { "n_features": 108, "n_entities": 983, "n_groups": 6 },
"recommendation": "proceed"
}| Issue | Status | Recommendation |
|---|---|---|
| Entity/ID column not auto-detected | — | Agent asks user interactively (required) |
| Timestamp/value/category column not auto-detected | — | Agent asks user; pressing Enter skips that role |
| Fewer than 20 features built | blocked |
escalate |
| All features are binary/constant | blocked |
escalate |
File: agents/feature_selector.py
Class: FeatureSelectionAgent
Scores all engineered features using PCA importance and autoencoder reconstruction error, then applies VIF and correlation gates, and finally asks LLM to select the optimal subset for clustering.
skills.vif_checker.compute_vif()skills.vif_checker.remove_high_vif()skills.vif_checker.flag_high_correlation()orchestrator_bus.report()
- Engineered feature DataFrame
UserIntent- Orchestrator feedback (free-text)
FeatureSelectionResult:selected_features: list[str]n_features: intpca_scores: dict[str, float]ae_scores: dict[str, float]vif_table: dict[str, float]removed_by_vif: list[str]reasoning: str
- VIF < 10 for all features (iterative removal; threshold tuned dynamically by Orchestrator)
- |r| < 0.85 between any two features
- ≥ 10 features survive; ≤ 80 features retained
{
"agent": "FeatureSelector",
"status": "success | warning | blocked",
"what_was_done": "Scored 108 features, removed 22 via VIF gate, LLM selected 45",
"what_was_not_done": "Did not apply p-value gate (sufficient features after VIF)",
"doubts": "Several features have borderline VIF (~9.5)",
"issues": [],
"metrics": { "n_input": 108, "n_after_vif": 86, "n_selected": 45, "max_vif": 8.2 },
"recommendation": "proceed"
}| Issue | Status | Recommendation |
|---|---|---|
| < 10 features survive VIF gate | blocked |
retry (relax to VIF < 15) or escalate |
| LLM returns invalid JSON | warning |
retry |
File: agents/clusterer.py
Class: ClusteringAgent
Selects the clustering algorithm and number of clusters data-driven (via silhouette optimisation), fits the model, runs the deepening loop for oversized clusters, and builds cluster profiles.
skills.algo_recommender.recommend_algorithm()skills.silhouette_optimizer.optimize_k()orchestrator_bus.report()
- Engineered feature DataFrame
selected_features: list[str]UserIntentDatasetProfile(for algorithm recommendation)- Orchestrator feedback
ClusteringResult:action: str(proceed | reselect_features)cluster_labels: pd.Seriesprofiles: dictlineage: dictsilhouette: floatn_leaf: intk_scores: dict[int, float](silhouette for each k tried)algo_used: str
Each cluster entry in profiles contains:
{
"n_entities": 142,
"pct_total": 0.144,
"top_above_average": [["feature_name", 3.2], ...],
"top_below_average": [["feature_name", 0.4], ...],
"feature_means": {"feature_name": 0.72, ...},
"feature_relative": {"feature_name": 1.6, ...}
}Delegated to skills.algo_recommender. Scores all 5 supported algorithms
based on dataset characteristics; picks the highest-scoring one:
| Algorithm | Best for |
|---|---|
kmeans |
Large datasets (> 100k rows), fast, convex clusters |
hierarchical |
Nested sub-groups, non-convex shapes, dendrogram insight |
dbscan |
Arbitrary shapes, noise/outlier detection |
gmm |
Soft, probabilistic cluster memberships |
fuzzy_cmeans |
Overlapping clusters where entities belong to multiple groups |
Priority order:
user_intent.n_clusters_requested— if the user specified an exact count at Q5, use it directly (silhouette optimisation is skipped)config.n_clusters— if set inconfig.yaml, use it directly- Silhouette optimisation (default) — delegated to
skills.silhouette_optimizer:- Try k ∈
k_search_range(default: [3,4,5,6,7,8,10,12,15]) - Fit model for each k; compute silhouette
- Pick k with maximum silhouette; report curve to orchestrator
- Try k ∈
{
"agent": "Clusterer",
"status": "success | warning | blocked",
"what_was_done": "Tried k=3..15 via silhouette; selected k=7 (sil=0.38); ran deepening loop",
"what_was_not_done": "Deepening loop skipped (no oversized clusters)",
"doubts": "Silhouette improvement from k=6 to k=7 is marginal (0.01 diff)",
"issues": [],
"metrics": { "best_k": 7, "silhouette": 0.38, "n_leaf": 9, "algo": "hierarchical" },
"recommendation": "proceed"
}| Issue | Status | Recommendation |
|---|---|---|
| Silhouette < 0.20 for all k | warning |
retry with different algo |
| Silhouette < 0.15 after all algorithms tried | blocked |
reselect_features |
| Deepening loop can't resolve oversized cluster | warning |
reselect_features |
File: agents/persona_namer.py
Class: PersonaNamingAgent
Uses LLM to generate human-readable persona names, taglines, descriptions, and traits from cluster profiles. Applies the Clarity Gate to validate output quality before proceeding.
orchestrator_bus.report()
profiles: dict(from ClusteringAgent — usesn_entities,pct_total,top_above_average,top_below_average,feature_means)lineage: dicttone: strUserIntent- Orchestrator feedback
NamingResult:personas: dict(cid → name, tagline, description, traits, confidence)passed: boolavg_confidence: floatissues: list[str]
profiles: dict(from ClusteringAgent)lineage: dicttone: struser_intent: UserIntent— used to injectmust_have_clustersconstraint into the LLM prompt and Clarity Gate- Orchestrator feedback
- Avg confidence ≥ 6/10
- No duplicate persona names
- Must-have clusters covered — if
user_intent.must_have_clustersis non-empty, every required type must appear in at least one persona name or description. Failure triggersrecluster.
{
"agent": "PersonaNamer",
"status": "success | warning | blocked",
"what_was_done": "Named 9 clusters; avg confidence=7.2; Clarity Gate passed",
"what_was_not_done": "",
"doubts": "Cluster 4 and 7 are similar; confidence 6/10 for both",
"issues": [],
"metrics": { "n_clusters": 9, "avg_confidence": 7.2, "gate_passed": true },
"recommendation": "proceed"
}File: agents/classifier.py
Class: ClassifierAgent
Treats persona labels as pseudo ground truth, trains an LLM-selected classifier, evaluates cluster separability via stratified CV, and routes the pipeline back to feature selection or clustering if performance is poor.
orchestrator_bus.report()
- Feature DataFrame
cluster_labels: pd.Seriespersonas: dictUserIntent- History + feedback
ClassifierResult:action: str(proceed | reselect_features | recluster)cv_accuracy, cv_f1_macro, cv_f1_weighted: floatper_class_f1: dict[str, float]feature_importances: dict[str, float]reasoning: str
The LLM chooses the best model type based on dataset size, class balance, and interpretability requirements:
| Model | When preferred |
|---|---|
random_forest |
Medium datasets, need feature importances |
xgboost |
Large datasets, best accuracy |
gradient_boosting |
Balanced datasets, robust to outliers |
logistic_regression |
Small datasets, high interpretability |
- CV macro-F1 ≥ 0.70 → proceed
- Below threshold → LLM diagnoses and routes
{
"agent": "Classifier",
"status": "success | warning | blocked",
"what_was_done": "LLM selected random_forest; trained with 5-fold CV; computed feature importances",
"what_was_not_done": "Did not compute SHAP values (not in current skill set)",
"doubts": "Persona 'Moderate All-Rounder' is borderline (F1=0.65)",
"issues": [],
"metrics": { "cv_f1_macro": 0.82, "cv_accuracy": 0.85, "n_classes": 9 },
"recommendation": "proceed"
}UserInputAgent
│ UserIntent
▼
DatasetExaminerAgent
│ DatasetProfile
▼
FeatureEngineerAgent
│ engineered DataFrame
▼
FeatureSelectionAgent ◄───────────────────────────────────┐
│ selected_features │
▼ (reselect_features)
ClusteringAgent ◄────────────────────────────┐ │
│ cluster_labels, profiles │ │
▼ (recluster) │
PersonaNamingAgent ──(gate fail)──────────────┘ │
│ personas │
▼ │
ClassifierAgent ──(low F1)────────────────────────────────┘
│ ClassifierResult
▼
Orchestrator Human Checkpoint
│
└── approve → save outputs → DONE
- Create
agents/<name>.pywith a class inheriting no base class (duck-typed) - Implement
run(self, ...) -> <ResultDataclass> - Call
bus.report(OrchestratorMessage(...))at the end ofrun() - Add the result dataclass to
agents/state.py - Register the agent in this file (
agent.md) - Register any new skills in
skill.md - Wire the agent into
agents/orchestrator.py