From 922056d20c66a9ff91b05a289516a38b1b4113c2 Mon Sep 17 00:00:00 2001 From: VibeSWE <338779468+VibeSWE@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:30:44 -0500 Subject: [PATCH] resolve_dataset: prefer exact-case selectedDataset match; fix not_found message Dataset names are case-sensitive, but selectedDataset/contextDataset took the first case-insensitive match, so with both `Logs` and `logs` visible a request for `logs` could resolve to `Logs`. Prefer an exact match, fall back to a case-insensitive match only when it is unique, and return `ambiguous` with the case variants as candidates otherwise. When every dataset is filtered out the status was `not_found` but the message claimed the resolution was ambiguous; report that no visible dataset matches instead. Co-Authored-By: Claude Opus 5.5 --- docs/tool-api.md | 16 ++++--- src/tool_executor.rs | 104 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 106 insertions(+), 14 deletions(-) diff --git a/docs/tool-api.md b/docs/tool-api.md index fc9cca74f..3f5db2993 100644 --- a/docs/tool-api.md +++ b/docs/tool-api.md @@ -151,12 +151,16 @@ datasets. `resolve_dataset` accepts an optional `telemetryType` (`logs`, `metrics` or `traces`) and `limit` (1–20, default 5). It resolves a `selectedDataset` or -`contextDataset` exactly, or resolves the only visible dataset. Otherwise it -ranks visible datasets by name match against `query`. It resolves only a -unique high-confidence name match. In every other case it returns -`status: "ambiguous"` with up to `limit` scored `candidates`, or `"not_found"`. -It never falls back to an arbitrary dataset. A selected dataset whose telemetry -type does not match `telemetryType` returns `not_found`. +`contextDataset` by exact, case-sensitive name. If there is no exact match, it +falls back to a case-insensitive match only when that match is unique; several +case-insensitive matches return `status: "ambiguous"` with those datasets as +`candidates`. Without a selection, it resolves the only visible dataset. +Otherwise it ranks visible datasets by name match against `query`. It resolves +only a unique high-confidence name match. In every other case it returns +`status: "ambiguous"` with up to `limit` scored `candidates`, or `"not_found"` +when no visible dataset matches the requested telemetry type. It never falls +back to an arbitrary dataset. A selected dataset whose telemetry type does not +match `telemetryType` returns `not_found`. `discover_datasets` counts events in the requested range for each visible dataset of the requested telemetry type that the caller can query. It returns diff --git a/src/tool_executor.rs b/src/tool_executor.rs index f2c477041..93e70941a 100644 --- a/src/tool_executor.rs +++ b/src/tool_executor.rs @@ -363,12 +363,43 @@ pub fn resolve_dataset_candidates(payload: Value, arguments: &Value) -> Value { .and_then(Value::as_str) .filter(|name| !name.is_empty()); if let Some(requested_dataset) = requested_dataset { - let selected = datasets.iter().find(|dataset| { - dataset - .get("name") - .and_then(Value::as_str) - .is_some_and(|name| name.eq_ignore_ascii_case(requested_dataset)) - }); + // Dataset names are case-sensitive: prefer an exact match and fall + // back to a case-insensitive match only when it is unique. + fn dataset_name(dataset: &Value) -> Option<&str> { + dataset.get("name").and_then(Value::as_str) + } + let exact = datasets + .iter() + .find(|dataset| dataset_name(dataset) == Some(requested_dataset)); + let case_insensitive = datasets + .iter() + .filter(|dataset| { + dataset_name(dataset) + .is_some_and(|name| name.eq_ignore_ascii_case(requested_dataset)) + }) + .collect::>(); + let selected = match (exact, case_insensitive.as_slice()) { + (Some(dataset), _) => Some(dataset), + (None, [dataset]) => Some(*dataset), + (None, []) => None, + (None, matches) => { + return json!({ + "status": "ambiguous", + "datasets": [], + "candidates": matches + .iter() + .map(|dataset| json!({ + "name": dataset_name(dataset), + "reasons": ["case-insensitive dataset name match"], + "dataset": dataset, + })) + .collect::>(), + "message": format!( + "Dataset `{requested_dataset}` matches several caller-visible datasets that differ only in case. Call resolve_dataset again with the exact dataset name." + ), + }); + } + }; return match selected { Some(dataset) if dataset_matches_telemetry_type(dataset, telemetry_type.as_deref()) => { json!({ @@ -488,9 +519,16 @@ pub fn resolve_dataset_candidates(payload: Value, arguments: &Value) -> Value { "candidates": candidates, "query": query, "message": if datasets.is_empty() { - "No caller-visible datasets were found." + "No caller-visible datasets were found.".to_owned() + } else if candidates.is_empty() { + match telemetry_type.as_deref() { + Some(telemetry_type) => format!( + "No caller-visible datasets match requested telemetry type `{telemetry_type}`." + ), + None => "No caller-visible datasets match the request.".to_owned(), + } } else { - "Dataset resolution is ambiguous. Inspect candidate schemas, then call resolve_dataset again with selectedDataset." + "Dataset resolution is ambiguous. Inspect candidate schemas, then call resolve_dataset again with selectedDataset.".to_owned() } }) } @@ -2010,6 +2048,56 @@ mod tests { assert_eq!(sole_mismatch["status"], "not_found"); } + #[actix_web::test] + async fn shared_dataset_resolution_prefers_exact_case_selection() { + let datasets = json!([{ "name": "Logs" }, { "name": "logs" }, { "name": "Traces" }]); + for (requested, expected) in [("logs", "logs"), ("Logs", "Logs")] { + let resolved = resolve_dataset_candidates( + datasets.clone(), + &json!({ "selectedDataset": requested }), + ); + assert_eq!(resolved["status"], "resolved"); + assert_eq!(resolved["selectedDatasets"], json!([expected])); + assert_eq!(resolved["datasets"][0]["name"], expected); + } + + let unique_fallback = + resolve_dataset_candidates(datasets.clone(), &json!({ "contextDataset": "traces" })); + assert_eq!(unique_fallback["status"], "resolved"); + assert_eq!(unique_fallback["selectedDatasets"], json!(["Traces"])); + + let ambiguous_case = + resolve_dataset_candidates(datasets, &json!({ "selectedDataset": "LOGS" })); + assert_eq!(ambiguous_case["status"], "ambiguous"); + assert_eq!(ambiguous_case["datasets"], json!([])); + let names = ambiguous_case["candidates"] + .as_array() + .unwrap() + .iter() + .map(|candidate| candidate["name"].clone()) + .collect::>(); + assert_eq!(names, vec![json!("Logs"), json!("logs")]); + } + + #[actix_web::test] + async fn shared_dataset_resolution_reports_no_matching_candidates() { + let none_match = resolve_dataset_candidates( + json!([ + { "name": "app-logs", "telemetryType": "logs" }, + { "name": "otel-metrics", "telemetryType": "metrics" } + ]), + &json!({ "query": "checkout", "telemetryType": "traces" }), + ); + assert_eq!(none_match["status"], "not_found"); + assert_eq!(none_match["candidates"], json!([])); + let message = none_match["message"].as_str().unwrap(); + assert!(!message.contains("ambiguous"), "{message}"); + assert_eq!( + message, + "No caller-visible datasets match requested telemetry type `traces`." + ); + } + #[actix_web::test] async fn shared_time_resolution_honors_timezone_and_reference() { let reference = DateTime::parse_from_rfc3339("2026-10-01T12:00:00Z")