makepad/apps/route/src/ai.rs
Admin 190062e4a1 route: the maps app exposes its tool table to the desktop assistant
The same tools route's own assistant has — plan, add and remove stops,
status, along-the-way search, map fly-to and trip framing, layers,
theme, markers, geocoding, weather, trip history, navigation — go on
the AI bus from one table (the definitions local_agent already maps),
dispatched through the one execute_tool; reads are Read, anything that
moves the map or changes the trip is Act. The context line carries the
map view and the trip; the desktop pane opening closes route's own
assistant panel. Written by the delegate from the sheets shape; route
25 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 18:29:43 +02:00

160 lines
5.1 KiB
Rust

//! Route's tools on the desktop assistant bus.
use crate::{broker, App};
use makepad_ai_services::wire::{Risk, ServiceCall, ServiceManifest, ToolDef, ToolResult};
use makepad_widgets::Cx;
/// The bus already supplies the `route.` namespace, so only route's own
/// prefix is removed. All descriptions and schemas still come from the one
/// registry used by the in-app assistant.
fn bus_name(internal: &str) -> &str {
match internal {
"route_plan" => "plan",
"route_add_stop" => "add_stop",
"route_remove_stop" => "remove_stop",
"route_status" => "status",
"route_along" => "along",
_ => internal,
}
}
fn risk(internal: &str) -> Risk {
match internal {
"geo_search" | "route_status" | "weather_now" | "trip_history"
| "images_search" | "cloud_ask" => Risk::Read,
"map_fly_to" | "map_show_trip" | "map_set_layer" | "map_set_theme"
| "map_set_markers" | "route_plan" | "route_add_stop"
| "route_remove_stop" | "route_along" | "nav_start" | "nav_stop" => Risk::Act,
name => panic!("route tool `{name}` has no AI-bus risk class"),
}
}
pub fn manifest() -> ServiceManifest {
let mut manifest = ServiceManifest::new(
"route",
"Route",
"The map on screen, including its centre, zoom, layers, markers, weather and current trip. Route-planning tools change the trip drawn on screen.",
);
for definition in broker::tool_definitions() {
let tool_name = bus_name(&definition.name);
manifest = manifest.with_tool(ToolDef::new(
tool_name,
definition.description,
definition.parameters,
risk(&definition.name),
));
}
manifest
}
fn internal_name(short_name: &str) -> Option<String> {
broker::tool_definitions()
.into_iter()
.find(|definition| bus_name(&definition.name) == short_name)
.map(|definition| definition.name)
}
fn result(call: &ServiceCall, outcome: Result<String, String>) -> ToolResult {
match outcome {
Ok(text) => ToolResult::ok(
&call.call_id,
text,
format!("route.{} completed", call.tool),
),
Err(error) if error.starts_with("unknown tool:") => {
let names = manifest()
.tools
.into_iter()
.map(|tool| tool.name)
.collect::<Vec<_>>()
.join(", ");
ToolResult::refused(
&call.call_id,
format!("unknown route tool `{}`; available tools: {names}", call.tool),
)
}
Err(error) => ToolResult::failed(&call.call_id, error),
}
}
/// Execute one desktop-bus call through the exact dispatcher used by route's
/// own assistant.
fn answer_with(
call: &ServiceCall,
execute: impl FnOnce(&str, &str) -> Result<String, String>,
) -> ToolResult {
let Some(name) = internal_name(&call.tool) else {
return result(call, Err(format!("unknown tool: {}", call.tool)));
};
result(call, execute(&name, &call.args))
}
pub fn answer(cx: &mut Cx, app: &mut App, call: &ServiceCall) -> ToolResult {
answer_with(call, |name, args| app.execute_tool(cx, name, args))
}
#[cfg(test)]
mod tests {
use super::*;
use makepad_ai_services::wire::ToolOutcome;
#[test]
fn manifest_is_valid_and_matches_the_local_tool_registry() {
let manifest = manifest();
manifest.validate().unwrap();
let local_names = broker::tool_definitions()
.iter()
.map(|definition| bus_name(&definition.name).to_string())
.collect::<Vec<_>>();
let bus_names = manifest
.tools
.iter()
.map(|tool| tool.name.clone())
.collect::<Vec<_>>();
assert_eq!(bus_names, local_names);
}
#[test]
fn unknown_calls_are_refused_with_the_real_names() {
let call = ServiceCall {
call_id: "call-unknown".into(),
tool: "not_a_route_tool".into(),
args: "{}".into(),
};
let answer = answer_with(&call, |_, _| panic!("unknown tools must not execute"));
assert_eq!(answer.outcome, ToolOutcome::Refused);
for tool in manifest().tools {
assert!(answer.text.contains(&tool.name), "missing {} in {}", tool.name, answer.text);
}
}
#[test]
fn risk_classes_match_route_state_effects() {
let manifest = manifest();
for name in [
"geo_search",
"status",
"weather_now",
"trip_history",
"images_search",
"cloud_ask",
] {
assert_eq!(manifest.tool(name).unwrap().risk, Risk::Read, "{name}");
}
for name in [
"map_fly_to",
"map_show_trip",
"map_set_layer",
"map_set_theme",
"map_set_markers",
"plan",
"add_stop",
"remove_stop",
"along",
"nav_start",
"nav_stop",
] {
assert_eq!(manifest.tool(name).unwrap().risk, Risk::Act, "{name}");
}
}
}