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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jobs:
- run: npm run build
- run: npm run lint
- run: npm run format:check
- run: npm run test:unit

- name: Smoke Test
run: npm test
Expand Down
152 changes: 125 additions & 27 deletions src/services/PlacesSearcher.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { GoogleMapsTools } from "./toolclass.js";
import { NewPlacesService } from "./NewPlacesService.js";
import { RoutesService, parseDuration, formatDistance, formatDuration } from "./RoutesService.js";
import {
RoutesService,
parseDuration,
formatDistance,
formatDuration,
COORDINATE_STRING_PATTERN,
type RouteWaypoint,
} from "./RoutesService.js";

interface SearchResponse {
success: boolean;
Expand Down Expand Up @@ -100,6 +107,44 @@ interface ElevationResponse {
}>;
}

interface ResolvedLocation {
originalName: string;
address: string;
lat: number;
lng: number;
placeId: string;
}

/**
* Parse a "latitude,longitude" input, or null when it is not one or is out of range.
*/
function parseCoordinateInput(input: string): { lat: number; lng: number } | null {
const match = input.match(COORDINATE_STRING_PATTERN);
if (!match) {
return null;
}
const lat = parseFloat(match[1]);
const lng = parseFloat(match[2]);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
return null;
}
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
return null;
}
return { lat, lng };
}

/**
* Place ID over coordinates: Routes snaps coordinates to the nearest road, which
* may not be an entrance. Text Search can return a result without a place ID.
*/
function toRouteWaypoint(loc: ResolvedLocation): RouteWaypoint {
if (loc.placeId) {
return { placeId: loc.placeId };
}
return { latLng: { latitude: loc.lat, longitude: loc.lng } };
}

export class PlacesSearcher {
private mapsTools: GoogleMapsTools;
private newPlacesService: NewPlacesService;
Expand Down Expand Up @@ -449,17 +494,75 @@ export class PlacesSearcher {

// --------------- Composite Tools ---------------

/**
* Resolve a location string to one concrete place, exactly once: geocoding
* first, then Places Text Search for informal names it does not index.
*
* Throws rather than returning { success: false } — the plan_route and
* explore_area actions JSON.stringify(result.data) without checking the flag,
* so a failure object would surface as an empty success.
*/
private async resolveLocation(input: string): Promise<ResolvedLocation> {
if (COORDINATE_STRING_PATTERN.test(input)) {
const coordinates = parseCoordinateInput(input);
if (!coordinates) {
throw new Error(`Failed to resolve location: ${input} (not a valid "latitude,longitude" pair)`);
}
// Already exact, so geocoding only supplies a display address and its failure
// must not fail the stop. No place ID: the nearest addressable place would
// silently move the caller's point. geocode() over reverseGeocode() because it
// labels sparse areas better — "Canada" rather than a bare Plus Code.
const displayGeocode = await this.geocode(input);
return {
originalName: input,
address: displayGeocode.success && displayGeocode.data ? displayGeocode.data.formatted_address : input,
lat: coordinates.lat,
lng: coordinates.lng,
placeId: "",
};
}

const geo = await this.geocode(input);
if (geo.success && geo.data) {
return {
originalName: input,
address: geo.data.formatted_address,
lat: geo.data.location.lat,
lng: geo.data.location.lng,
placeId: geo.data.place_id,
};
}

// Any geocoding failure falls through, not just zero results: geocode() flattens
// every error into { success: false } with no status code. A systemic failure
// (bad key, exhausted quota) fails the text search too, and both errors are thrown.
const textSearch = await this.searchText({ query: input });
if (textSearch.success && textSearch.data && textSearch.data.length > 0) {
const topMatch = textSearch.data[0];
return {
originalName: input,
address: topMatch.address || topMatch.name,
lat: topMatch.location.lat,
lng: topMatch.location.lng,
placeId: topMatch.place_id || "",
};
}

throw new Error(
`Failed to resolve location: ${input} (geocoding: ${geo.error || "no result"}; places text search: ${textSearch.error || "no result"})`
);
}

async exploreArea(params: { location: string; types?: string[]; radius?: number; topN?: number }): Promise<any> {
// "tourist_attraction" is the Places API (New) type name; a bare
// "attraction" is rejected with INVALID_ARGUMENT: Unsupported types.
const types = params.types || ["restaurant", "cafe", "tourist_attraction"];
const radius = params.radius || 1000;
const topN = params.topN || 3;

// 1. Geocode
const geo = await this.geocode(params.location);
if (!geo.success || !geo.data) throw new Error(geo.error || "Geocode failed");
const { lat, lng } = geo.data.location;
// 1. Resolve location
const resolved = await this.resolveLocation(params.location);
const { lat, lng } = resolved;

// 2. Search each type
const categories: any[] = [];
Expand Down Expand Up @@ -493,7 +596,7 @@ export class PlacesSearcher {
return {
success: true,
data: {
location: { address: geo.data.formatted_address, lat, lng },
location: { address: resolved.address, lat, lng },
radius,
categories,
},
Expand All @@ -512,32 +615,27 @@ export class PlacesSearcher {
const stops = params.stops;
if (stops.length < 2) throw new Error("Need at least 2 stops");

// 1. Geocode all stops for display addresses
const geocoded: Array<{ originalName: string; address: string; lat: number; lng: number }> = [];
// 1. Resolve all stops for display addresses and waypoints
const resolvedStops: ResolvedLocation[] = [];
for (const stop of stops) {
const geo = await this.geocode(stop);
if (!geo.success || !geo.data) throw new Error(`Failed to geocode: ${stop}`);
geocoded.push({
originalName: stop,
address: geo.data.formatted_address,
lat: geo.data.location.lat,
lng: geo.data.location.lng,
});
resolvedStops.push(await this.resolveLocation(stop));
}

// 2. Single Routes API call handles optimization + all leg directions
const origin = stops[0];
const destination = stops[stops.length - 1];
const intermediates = stops.length > 2 ? stops.slice(1, -1) : undefined;
// Optimize if requested, > 2 stops, and not transit (transit doesn't support intermediates for optimization)
const shouldOptimize = params.optimize !== false && stops.length > 2 && mode !== "transit";
const origin = toRouteWaypoint(resolvedStops[0]);
const destination = toRouteWaypoint(resolvedStops[resolvedStops.length - 1]);
const intermediates = stops.length > 2 ? resolvedStops.slice(1, -1).map((s) => toRouteWaypoint(s)) : undefined;
// Optimize if requested, > 3 stops (at least 2 intermediates), and not transit (transit doesn't support intermediates for optimization)
const shouldOptimize = params.optimize !== false && stops.length > 3 && mode !== "transit";

const routeResult = await this.routesService.computeRoutes({
origin,
destination,
mode,
intermediates,
optimizeWaypointOrder: shouldOptimize,
originLabel: stops[0],
destinationLabel: stops[stops.length - 1],
...(params.departure_time ? { departureTime: new Date(params.departure_time) } : {}),
...(params.avoid_tolls !== undefined ? { avoidTolls: params.avoid_tolls } : {}),
...(params.avoid_highways !== undefined ? { avoidHighways: params.avoid_highways } : {}),
Expand All @@ -547,17 +645,17 @@ export class PlacesSearcher {
const routeLegs = route?.legs || [];

// 3. Determine ordered stops based on optimization result
let orderedStops: typeof geocoded;
let orderedStops: typeof resolvedStops;
if (shouldOptimize && routeResult.optimizedIntermediateWaypointIndex) {
const optimizedOrder = routeResult.optimizedIntermediateWaypointIndex;
const intermediateGeocoded = geocoded.slice(1, -1);
const intermediateResolved = resolvedStops.slice(1, -1);
orderedStops = [
geocoded[0],
...optimizedOrder.map((i: number) => intermediateGeocoded[i]),
geocoded[geocoded.length - 1],
resolvedStops[0],
...optimizedOrder.map((i: number) => intermediateResolved[i]),
resolvedStops[resolvedStops.length - 1],
];
} else {
orderedStops = geocoded;
orderedStops = resolvedStops;
}

// 4. Build legs from Routes API response
Expand Down
52 changes: 42 additions & 10 deletions src/services/RoutesService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,36 @@ export function formatDuration(seconds: number): string {
return `${mins} min${mins !== 1 ? "s" : ""}`;
}

export const COORDINATE_STRING_PATTERN = /^\s*(-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)\s*$/;

export type RouteWaypoint = { latLng: { latitude: number; longitude: number } } | { placeId: string };
export type WaypointInput = string | RouteWaypoint;

/**
* Format a waypoint for human-readable error messages.
*/
function describeWaypoint(waypoint: WaypointInput): string {
if (typeof waypoint === "string") {
return waypoint;
}
if ("placeId" in waypoint) {
return `placeId:${waypoint.placeId}`;
}
return `${waypoint.latLng.latitude},${waypoint.latLng.longitude}`;
}

/**
* Convert address/coordinates string to Routes API Waypoint.
* Convert address/coordinates string or structured waypoint to Routes API Waypoint.
*
* NOTE: must stay synchronous and take exactly one argument. computeRoutes below
* calls params.intermediates.map(toWaypoint), so a second parameter would silently
* bind the array index, and returning a promise would serialise as {}.
*/
function toWaypoint(location: string): any {
const coordMatch = location.match(/^\s*(-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)\s*$/);
function toWaypoint(location: WaypointInput): any {
if (typeof location !== "string") {
return "placeId" in location ? { placeId: location.placeId } : { location: { latLng: location.latLng } };
}
const coordMatch = location.match(COORDINATE_STRING_PATTERN);
if (coordMatch) {
return {
location: {
Expand Down Expand Up @@ -127,15 +152,18 @@ export class RoutesService {
* Returns response compatible with existing DirectionsResponse.data interface.
*/
async computeRoutes(params: {
origin: string;
destination: string;
origin: WaypointInput;
destination: WaypointInput;
mode?: string;
departureTime?: Date;
arrivalTime?: Date;
intermediates?: string[];
intermediates?: WaypointInput[];
optimizeWaypointOrder?: boolean;
avoidTolls?: boolean;
avoidHighways?: boolean;
/** What to call the endpoints in error messages. Defaults to the waypoints themselves. */
originLabel?: string;
destinationLabel?: string;
}): Promise<{
routes: any[];
summary: string;
Expand Down Expand Up @@ -176,11 +204,11 @@ export class RoutesService {
requestBody.intermediates = params.intermediates.map(toWaypoint);
}

// Waypoint optimization (not supported for TRANSIT)
// Waypoint optimization (not supported for TRANSIT, requires at least 2 intermediates)
if (
params.optimizeWaypointOrder &&
params.intermediates &&
params.intermediates.length > 0 &&
params.intermediates.length > 1 &&
travelMode !== "TRANSIT"
) {
requestBody.optimizeWaypointOrder = true;
Expand Down Expand Up @@ -208,12 +236,16 @@ export class RoutesService {
const mode = params.mode || "driving";
if (mode === "transit") {
throw new Error(
`No transit route found from "${params.origin}" to "${params.destination}". ` +
`No transit route found from "${params.originLabel ?? describeWaypoint(params.origin)}" ` +
`to "${params.destinationLabel ?? describeWaypoint(params.destination)}". ` +
`The Google Routes API does not support transit directions in some regions (notably Japan and India). ` +
`Try using mode "driving" or "walking" instead, or use a regional transit service for public transportation details.`
);
}
throw new Error(`No route found from "${params.origin}" to "${params.destination}" with mode: ${mode}`);
throw new Error(
`No route found from "${params.originLabel ?? describeWaypoint(params.origin)}" ` +
`to "${params.destinationLabel ?? describeWaypoint(params.destination)}" with mode: ${mode}`
);
}

const route = data.routes[0];
Expand Down
4 changes: 2 additions & 2 deletions src/tools/maps/planRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { getCurrentApiKey } from "../../utils/requestContext.js";

const NAME = "maps_plan_route";
const DESCRIPTION =
"Plan an optimized multi-stop route in one call — geocodes all stops, uses Routes API waypoint optimization (up to 25 intermediate stops) to find the most efficient visit order, and returns directions for each leg. Use when the user says 'visit these 5 places efficiently', 'plan a route through A, B, C', or needs a multi-stop itinerary. Replaces the manual chain of geocode → distance-matrix → directions. For multi-day trips: create one plan_route call per day with stops that follow a geographic arc (e.g. east→west) rather than mixing distant areas. After results, call static_map to visualize the route.";
"Plan an optimized multi-stop route in one call — geocodes all stops, uses Routes API waypoint optimization (2 to 25 intermediate stops) to find the most efficient visit order, and returns directions for each leg. Use when the user says 'visit these 5 places efficiently', 'plan a route through A, B, C', or needs a multi-stop itinerary. Replaces the manual chain of geocode → distance-matrix → directions. Waypoint optimization requires at least 4 stops (2 intermediates); with 2 or 3 stops the route is returned in the original order. For multi-day trips: create one plan_route call per day with stops that follow a geographic arc (e.g. east→west) rather than mixing distant areas. After results, call static_map to visualize the route.";

const SCHEMA = {
stops: z.array(z.string()).min(2).describe("List of addresses or landmarks to visit (minimum 2)"),
Expand All @@ -13,7 +13,7 @@ const SCHEMA = {
.boolean()
.optional()
.describe(
"Auto-optimize visit order via Routes API waypoint optimization (default: true). Set false to keep original order. Not available for transit mode."
"Auto-optimize visit order via Routes API waypoint optimization (default: true). Requires at least 4 stops (2 intermediates) — ignored for 2-3 stops. Set false to keep original order. Not available for transit mode."
),
departure_time: z
.string()
Expand Down
Loading
Loading