Skip to content

fix: route plan_route stops by resolved waypoints and add text-search fallback - #87

Merged
cablate merged 4 commits into
cablate:mainfrom
weikhang95:feat/route-waypoint-resolution
Sep 11, 2026
Merged

cablate merged 4 commits into
cablate:mainfrom
weikhang95:feat/route-waypoint-resolution

Conversation

@weikhang95

@weikhang95 weikhang95 commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Summary

PlacesSearcher.planRoute resolved every stop twice, with two different resolvers, then zipped the results by array index. geocoded[] came from the Geocoding API and supplied only the display labels; the raw strings went separately to computeRoutes, where toWaypoint turned any non-lat,lng string into { address: rawString } — a second, independent geocode. Google's Specify locations for a route says an address string "must first be geocoded by the Routes API" and that the result may differ from the Geocoding API.

So labels and distances could describe different places while the tool returned success: true. On main (v0.0.55):

plan_route(["Tokyo Tower", "SAWA Hotel"])
  → success: true, label: SAWA Hotel (488 Rue de Verdun, Douala, Cameroon), distance: 12158.8 km
distance_matrix(["35.6585805,139.7454329"] → ["4.0340411,9.6839268"])   # Tokyo Tower → that label
  → 20251.4 km

12158.8 ≠ 20251.4 — Routes had resolved SAWA Hotel to a third place, so the agent sees success and reports a route nobody measured.

plan_route and explore_area also threw on free text Geocoding cannot resolve, even when Text Search finds it:

plan_route(["河口湖駅", "精進湖 他根浜"])  →  Failed to geocode: 精進湖 他根浜
search_places("精進湖 他根浜")             →  Tatego-Hama Beach, ChIJ13ziPsznG2ARIqd4uJcjN0s

The class is narrow: text that is neither an address nor an indexed landmark. Tokyo Tower geocodes fine and explore_area("Tokyo Tower") works on main.

Fix

PlacesSearcher.resolveLocation(input) resolves a string once — lat,lng parsed and range-checked locally, otherwise Geocoding, then Places Text Search, throwing if neither resolves. It throws rather than returning { success: false } because planRoute.ts and exploreArea.ts JSON.stringify(result.data) without checking the flag.

The result reaches computeRoutes as a place ID waypoint, so label and route cannot disagree. Google recommends place IDs "instead of latitude/longitude coordinates or address strings", because a coordinate is "snapped to the road nearest to those coordinates".

Changes

File Change
src/services/RoutesService.ts Export COORDINATE_STRING_PATTERN; add RouteWaypoint / WaypointInput so origin / destination / intermediates accept structured waypoints; add describeWaypoint; optional originLabel / destinationLabel so no-route errors still name the caller's stop
src/services/PlacesSearcher.ts Add ResolvedLocation, toRouteWaypoint, resolveLocation; planRoute and exploreArea resolve each input once
tests/locationResolution.unit.test.ts New — 9 tests over resolution, fallback, coordinate handling and the label/route guard
tests/routesWaypoints.unit.test.ts New — 5 tests over waypoint conversion, the matrix path and error text

No tool schema, description or registration changed, so the 9-file tool checklist in CLAUDE.md does not apply. computeRouteMatrix is untouched and still takes strings.

Impact

  • Distances shift slightly for name-based stops — place IDs route to a place's own access points, not the nearest road. The three-stop Tokyo route went 130.3 → 128.5 km. Anyone snapshot-testing distances will see a diff.
  • Geocoding's answer now always wins. Label and route are consistent, but a name the Routes geocoder happened to resolve better can get worse. SAWA Hotel still lands in Cameroon — now consistently rather than divergently.
  • Coordinate stops keep the caller's exact point and no longer depend on Geocoding. Out-of-range coordinates fail locally in plan_route / explore_area; toWaypoint is unchanged, so get_directions and distance_matrix behave as today.
  • Error text: Failed to geocode: XFailed to resolve location: X (geocoding: …; places text search: …).
  • One extra API call, only when Geocoding returns nothing — and on any geocoding failure, not just zero results, since geocode flattens every error into { success: false } with no status code. Text Search is the pricier call ($32.00 vs $5.00 per 1000 at the first paid tier).
  • Not from this PR: 3 stops with the default optimize: true fails with Cannot read properties of undefined (reading 'originalName') on this branch and on main alike — the bug fix: require at least 2 intermediates for waypoint optimization #86 fixes, in the same index-zip code the divergence above came from.

Test plan

  • npm run build succeeds
  • npm run lint — 0 errors (175 pre-existing warnings, plus 22 no-explicit-any on the new tests' stubs)
  • npm test198 passed, 0 failed
  • npm run test:unit — 16 passed, 0 failed, with tests/exploreArea.unit.test.ts unedited
  • Reverting only src/ to main and keeping the new tests: 5 pass, 11 fail — the 11 are exactly the new assertions
  • Manual verification against the live API

Verification

before after
plan_route(["Tokyo Tower","SAWA Hotel"]) 12158.8 km, label ≠ route 20206.0 km, label and route share one place_id
plan_route(["河口湖駅","精進湖 他根浜"]) Failed to geocode success, 18.3 km
explore_area("精進湖 他根浜") Failed to geocode address success, 35.4906841 / 138.6046066
plan_route([2 coordinate strings]) 12.3 km 12.3 km, unchanged

Follow-ups (not in this PR)

  • Location biasing would fix the wrong-continent match itself, but it changes which place an input resolves to — your call as maintainer.
  • get_directions, search_along_route and distance_matrix still pass raw strings to Routes, with the same divergence exposure.
  • npm run test:unit is not in CI. I left ci.yml alone deliberately; say the word and I will send the one-line PR.

Changelog entry

- fix: route plan_route stops by resolved waypoints and add text-search fallback (#87)

… fallback

planRoute resolved every stop twice, with two different resolvers, and then
zipped the results by array index: the display labels came from the Geocoding
API, while the distances came from the Routes API independently re-resolving the
same raw strings. When the two disagreed, the tool returned success and its
legs described a different place than its own labels.

Resolve each stop once and pass the result to computeRoutes as a structured
place ID waypoint, falling back to coordinates when the resolved place has no
place ID. Routes then routes to the same place the label names, so the two can
no longer diverge. Google recommends place IDs over coordinates and address
strings for exactly this reason: they are unambiguous and let Routes use the
place's own access points, while coordinates are snapped to the nearest road.

Coordinate stops keep the caller's exact point and no longer depend on the
Geocoding API at all: the point needs no resolving, so geocoding is used only to
fetch a display address and its failure is not fatal. A coordinate-shaped string
outside valid latitude/longitude range now fails immediately, without an API
call, instead of being text-searched.

Also add a Places Text Search fallback for input Geocoding cannot resolve. The
failing class is non-address free text, such as "精進湖 他根浜", which
previously threw in both planRoute and exploreArea; landmark names geocode
normally and are unaffected.

computeRoutes takes optional originLabel/destinationLabel so its no-route and
no-transit errors keep naming the stop the caller asked for rather than the
resolved place ID.

No tool schema changed, so the tool checklist in CLAUDE.md does not apply.
@weikhang95
weikhang95 force-pushed the feat/route-waypoint-resolution branch from 6ea5090 to 1955128 Compare September 11, 2026 08:32
akazakou and others added 3 commits September 11, 2026 21:59
The Routes API rejects waypoint optimization requests with a single
intermediate stop, so only enable `optimizeWaypointOrder` when there are
more than 3 stops (2+ intermediates).

Also add unit tests for `planRoute` covering 2, 3 and more stops with
optimization enabled and disabled, using stubbed geocoding and Routes
API responses.
@cablate

cablate commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Thanks for the exceptionally detailed report and verification — especially the concrete label/route divergence example, the fallback cost note, and the follow-up scope.

I’ve updated this branch as maintainer to integrate #86 while preserving both contributors’ commits. The PR now also:

  • skips waypoint optimization for fewer than two intermediates;
  • includes the fix: require at least 2 intermediates for waypoint optimization #86 unit and live regression coverage;
  • tests the optimization threshold at the RoutesService boundary;
  • runs npm run test:unit in CI; and
  • skips the live place-photo smoke test when fork PRs cannot access GOOGLE_MAPS_API_KEY.

CI is now green. I intentionally left location biasing and the related raw-string paths in get_directions, search_along_route, and distance_matrix for focused follow-up work, since those change resolution semantics beyond this PR.

@cablate
cablate merged commit 400b447 into cablate:main Sep 11, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants