Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 122 additions & 2 deletions conductor-daemon/src/daemon/mcp_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
use super::mcp_types::{ToolCallResult, ToolDefinition, ToolRiskTier};
use conductor_core::config::{ActionConfig, Config, Trigger};
use conductor_core::device_intelligence::fingerprint::{
EventStats, suggest_binding as compute_suggestion,
DeviceCategory, EventStats, suggest_binding as compute_suggestion,
};
use dashmap::DashMap;
use serde_json::{Value, json};
Expand Down Expand Up @@ -688,7 +688,7 @@ pub fn get_tool_definitions() -> Vec<ToolDefinition> {
// Event fingerprinting (ADR-022 Phase 5D, #755)
ToolDefinition {
name: "conductor_suggest_binding".to_string(),
description: "Suggest a binding configuration for a port. Uses live event fingerprinting when events have been observed (method: event_fingerprint, confidence based on observed event volume), and falls back to port-name heuristics otherwise (method: port_name_heuristic, confidence capped at 0.5). Returns device category, confidence score, suggested alias, and reasoning.".to_string(),
description: "Suggest a binding configuration for a port. Uses live event fingerprinting when events have been observed (method: event_fingerprint), falls back to port-name heuristics (method: port_name_heuristic, confidence capped at 0.5). Returns primary suggestion (category, confidence, alias, protocol, reasoning) plus ranked alternatives array with lower-confidence fallback categories.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
Expand Down Expand Up @@ -795,6 +795,68 @@ impl McpToolExecutor {
Self {}
}

/// Build ranked alternative suggestions using core's suggest_binding (P18).
/// Returns up to 2 alternatives with lower confidence than the primary.
fn build_alternatives(
primary_category: &DeviceCategory,
primary_confidence: f64,
port_name: &str,
) -> Vec<serde_json::Value> {
// No alternatives when primary has no confidence (Unknown/no data)
if primary_confidence <= 0.0 {
return Vec::new();
}

// Minimal synthetic EventStats to trigger each category.
// Uses core's compute_suggestion to keep alias/protocol in sync.
let categories = [
(DeviceCategory::PadController, 36u8, 40u8, false, false),
(DeviceCategory::Keyboard, 21, 100, false, false),
(DeviceCategory::FaderController, 0, 1, true, false),
(DeviceCategory::EncoderController, 0, 0, true, true),
(DeviceCategory::GameController, 0, 0, false, false),
];
let mut alts = Vec::new();
let mut rank = 0usize;
for (cat, a, b, is_cc, is_encoder) in &categories {
if cat == primary_category {
continue;
}
if rank >= 2 {
break;
}
let mut stats = EventStats::new();
if *cat == DeviceCategory::GameController {
stats.record_gamepad();
stats.record_gamepad();
} else if *is_encoder {
// Encoder: high event density on few CCs (>10 hits, <=4 unique CCs)
for _ in 0..12 {
stats.record_cc(0);
}
} else if *is_cc {
stats.record_cc(*a);
stats.record_cc(*b);
} else {
stats.record_note(*a, 80);
stats.record_note(*b, 80);
}
let suggestion = compute_suggestion(&stats, port_name);
let cat_value = serde_json::to_value(cat)
.unwrap_or(serde_json::Value::String("Unknown".to_string()));
let decay = if rank == 0 { 0.3 } else { 0.15 };
let alt_conf = (primary_confidence * decay).min(0.2);
alts.push(json!({
"category": cat_value,
"suggested_alias": suggestion.suggested_alias,
"suggested_protocol": suggestion.suggested_protocol,
"confidence": alt_conf,
}));
rank += 1;
}
alts
}

/// Look up last event timestamp for a port from the fingerprint stats.
fn lookup_last_event_ms(
event_stats: Option<&DashMap<String, EventStats>>,
Expand Down Expand Up @@ -1689,6 +1751,11 @@ impl McpToolExecutor {
let suggestion = compute_suggestion(stats, port_name);
let category_value = serde_json::to_value(&suggestion.category)
.unwrap_or(serde_json::Value::String("Unknown".to_string()));
let alternatives = Self::build_alternatives(
&suggestion.category,
suggestion.confidence,
port_name,
);
Some(ToolCallResult::json(&json!({
"port_name": port_name,
"category": category_value,
Expand All @@ -1698,6 +1765,7 @@ impl McpToolExecutor {
"reasoning": suggestion.reasoning,
"method": "event_fingerprint",
"event_count": stats.note_count + stats.cc_count + stats.gamepad_count,
"alternatives": alternatives,
"note": "Classification based on observed event patterns"
})))
} else {
Expand Down Expand Up @@ -1795,6 +1863,9 @@ impl McpToolExecutor {
let category_value = serde_json::to_value(&suggestion.category)
.unwrap_or(serde_json::Value::String("Unknown".to_string()));

let alternatives =
Self::build_alternatives(&suggestion.category, heuristic_confidence, port_name);

ToolCallResult::json(&json!({
"port_name": port_name,
"category": category_value,
Expand All @@ -1803,6 +1874,7 @@ impl McpToolExecutor {
"suggested_protocol": suggestion.suggested_protocol,
"reasoning": suggestion.reasoning,
"method": "port_name_heuristic",
"alternatives": alternatives,
"note": "Based on port name heuristics. Confidence is capped at 0.5 without real event data."
}))
}
Expand Down Expand Up @@ -2199,6 +2271,23 @@ mod tests {
assert_eq!(parsed["category"], "PadController");
assert!(parsed["confidence"].as_f64().unwrap() <= 0.5);
assert_eq!(parsed["method"], "port_name_heuristic");
// P18: ranked alternatives
let alts = parsed["alternatives"]
.as_array()
.expect("should have alternatives");
assert!(alts.len() >= 1, "should have at least 1 alternative");
let mut prev_conf = parsed["confidence"].as_f64().unwrap();
for alt in alts {
assert!(alt["category"].is_string());
assert!(alt["suggested_alias"].is_string());
let alt_conf = alt["confidence"].as_f64().unwrap();
assert!(alt_conf > 0.0, "alternative confidence should be positive");
assert!(
alt_conf <= prev_conf,
"alternatives should have decreasing confidence"
);
prev_conf = alt_conf;
}
Comment on lines +2274 to +2290
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new alternatives assertions only cover cases where the primary confidence is > 0 (e.g., names that match the heuristic seeds). It would be good to add a regression test for an “unknown/insufficient data” port name (primary confidence 0.0) to ensure alternatives are either omitted or never exceed the primary confidence.

Copilot uses AI. Check for mistakes.
} else {
panic!("Expected text content");
}
Expand Down Expand Up @@ -2286,6 +2375,37 @@ mod tests {
}
}

#[tokio::test]
async fn test_suggest_binding_unknown_port_returns_empty_alternatives() {
let executor = McpToolExecutor::new();
// Unknown port name — primary confidence is 0, so alternatives should be empty
let result = executor
.execute(
"conductor_suggest_binding",
Some(json!({"port_name": "Totally Unknown Device XYZ"})),
None,
None,
None,
None,
)
.await;
assert!(result.is_error.is_none());
let content = &result.content[0];
if let ToolContent::Text { text } = content {
let parsed: serde_json::Value = serde_json::from_str(text).unwrap();
assert_eq!(parsed["category"], "Unknown");
let alts = parsed["alternatives"]
.as_array()
.expect("should have alternatives array");
assert!(
alts.is_empty(),
"Unknown port (0 confidence) should have no alternatives"
);
} else {
panic!("Expected text content");
}
}

#[tokio::test]
async fn test_suggest_binding_falls_back_with_empty_stats() {
let executor = McpToolExecutor::new();
Expand Down
67 changes: 67 additions & 0 deletions conductor-gui/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2248,6 +2248,32 @@ pub struct DiscoveredPort {
pub direction: String,
pub binding: Option<String>,
pub connected: bool,
/// Port metadata (always includes display_name; HID ports add manufacturer, vendor_id, product_id)
#[serde(default)]
pub metadata: std::collections::HashMap<String, String>,
Comment on lines +2251 to +2253
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description/test plan says DiscoveredPort serialization includes metadata when non-empty, but metadata currently lacks skip_serializing_if, so an empty map would still serialize as {}. If the intent is conditional inclusion, add #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] (or adjust the description if metadata is always expected to be present).

Copilot uses AI. Check for mistakes.
}

/// Extract metadata from a port name. Strips OS-appended instance suffixes
/// (" #2", " Port 3") while preserving model numbers (e.g., "nanoKONTROL2").
fn port_metadata(name: &str) -> std::collections::HashMap<String, String> {
let mut meta = std::collections::HashMap::new();
let base = if let Some((prefix, suffix)) = name.rsplit_once(" #") {
if suffix.chars().all(|c| c.is_ascii_digit()) {
prefix
} else {
name
}
} else if let Some((prefix, suffix)) = name.rsplit_once(" Port ") {
if suffix.chars().all(|c| c.is_ascii_digit()) {
prefix
} else {
name
}
} else {
name
};
meta.insert("display_name".to_string(), base.to_string());
meta
}

/// Get all discovered ports across protocols with binding status (ADR-022 Phase 2A)
Expand Down Expand Up @@ -2281,12 +2307,14 @@ pub fn get_discovered_ports() -> Result<Vec<DiscoveredPort>, String> {
})
.map(|d| d.alias.clone())
});
let metadata = port_metadata(&name);
ports.push(DiscoveredPort {
name,
protocol: "midi".to_string(),
direction: "receive".to_string(),
binding,
connected: true,
metadata,
});
}
}
Expand All @@ -2308,12 +2336,14 @@ pub fn get_discovered_ports() -> Result<Vec<DiscoveredPort>, String> {
})
.map(|d| d.alias.clone())
});
let metadata = port_metadata(&name);
ports.push(DiscoveredPort {
name,
protocol: "midi".to_string(),
direction: "send".to_string(),
binding,
connected: true,
metadata,
});
}
}
Expand Down Expand Up @@ -2348,12 +2378,19 @@ pub fn get_discovered_ports() -> Result<Vec<DiscoveredPort>, String> {
})
.map(|d| d.alias.clone())
});
let mut metadata = port_metadata(name);
if let Some(mfg) = device_info.manufacturer_string() {
metadata.insert("manufacturer".to_string(), mfg.to_string());
}
metadata.insert("vendor_id".to_string(), format!("0x{:04X}", vid));
metadata.insert("product_id".to_string(), format!("0x{:04X}", pid));
ports.push(DiscoveredPort {
name: name.to_string(),
protocol: "hid".to_string(),
direction: "receive".to_string(),
binding,
connected: true,
metadata,
});
}
}
Expand Down Expand Up @@ -2750,4 +2787,34 @@ mod tests {
.contains("must be within the midimon config directory")
);
}

#[test]
fn test_port_metadata_preserves_model_numbers() {
let meta = port_metadata("nanoKONTROL2");
assert_eq!(meta["display_name"], "nanoKONTROL2");
}

#[test]
fn test_port_metadata_strips_instance_suffix() {
let meta = port_metadata("Maschine Mikro MK3 #2");
assert_eq!(meta["display_name"], "Maschine Mikro MK3");
}

#[test]
fn test_port_metadata_strips_port_suffix() {
let meta = port_metadata("USB MIDI Port 3");
assert_eq!(meta["display_name"], "USB MIDI");
}

#[test]
fn test_port_metadata_no_suffix() {
let meta = port_metadata("Launchpad Mini MK3 MIDI");
assert_eq!(meta["display_name"], "Launchpad Mini MK3 MIDI");
}

#[test]
fn test_port_metadata_preserves_apc40() {
let meta = port_metadata("APC40");
assert_eq!(meta["display_name"], "APC40");
}
}
18 changes: 0 additions & 18 deletions conductor-gui/ui/src/lib/components/DeviceStatusPills.svelte

This file was deleted.

Loading
Loading