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
20 changes: 11 additions & 9 deletions docs/tool-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
104 changes: 96 additions & 8 deletions src/tool_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>();
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::<Vec<_>>(),
"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!({
Expand Down Expand Up @@ -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()
}
})
}
Expand Down Expand Up @@ -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::<Vec<_>>();
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")
Expand Down
Loading