diff --git a/docs/tool-api.md b/docs/tool-api.md index 74d415fdd..b8b64cddf 100644 --- a/docs/tool-api.md +++ b/docs/tool-api.md @@ -160,20 +160,22 @@ datasets. `resolve_dataset` accepts an optional `telemetryType` (`logs`, `metrics` or `traces`) and `limit` (1–20, default 5). Its `status` is `resolved`, -`ambiguous`, `not_found` or `error`. Dataset names are matched -case-insensitively. +`ambiguous`, `not_found` or `error`. - A `selectedDataset` or `contextDataset` resolves to the visible dataset with - that name. If no visible dataset has that name, the result has - `status: "error"`. If its telemetry type does not match `telemetryType`, the - result is `not_found`. + exactly that (case-sensitive) name. If none has that exact name, it resolves + to the one visible dataset whose name matches case-insensitively; if several + do, the result is `ambiguous` with those datasets as `candidates`. If no + visible dataset matches, the result has `status: "error"`. If the selected + dataset's telemetry type does not match `telemetryType`, the result is + `not_found`. - Otherwise, if exactly one dataset is visible, it resolves to that dataset, or returns `not_found` if its telemetry type does not match. - Otherwise it scores the visible datasets that match `telemetryType` by name - against `query`: 1000 if the name equals the query, 750 if the query contains - the name, or 500 if the name contains the query, plus 25 for each query term - longer than two characters that the name contains, plus 15 when - `telemetryType` is given. It resolves only a unique top score of at least + against `query`, ignoring case: 1000 if the name equals the query, 750 if the + query contains the name, or 500 if the name contains the query, plus 25 for + each query term longer than two characters that the name contains, plus 15 + when `telemetryType` is given. It resolves only a unique top score of at least 500. In every other case it returns `ambiguous` with up to `limit` scored `candidates`, or `not_found` if no visible dataset matches `telemetryType`. diff --git a/src/tool_executor.rs b/src/tool_executor.rs index 360632335..fe82f380f 100644 --- a/src/tool_executor.rs +++ b/src/tool_executor.rs @@ -366,12 +366,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!({ @@ -491,9 +522,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() } }) } @@ -2119,6 +2157,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")