46 KiB
title, type, permalink
| title | type | permalink |
|---|---|---|
| travel-agent-context-and-models | note | voyage/plans/travel-agent-context-and-models |
Plan: Travel Agent Context + Models Follow-up
Scope
Address three follow-up issues in collection-level AI Travel Assistant:
- Provider model dropdown only shows one option.
- Chat context appears location-centric instead of full-trip/collection-centric.
- Suggested prompts still assume a single location instead of itinerary-wide planning.
Tasks
-
F1 — Expand model options for OpenCode Zen provider
- Acceptance criteria:
- Model dropdown offers multiple valid options for
opencode_zen(not just one hardcoded value). - Options are sourced in a maintainable way (backend-side).
- Selecting an option is sent through existing
modeloverride path.
- Model dropdown offers multiple valid options for
- Agent: explorer → coder → reviewer → tester
- Dependencies: discovery of current
/api/chat/providers/{id}/models/behavior. - Workstream:
main(follow-up bugfix set) - Implementation note (2026-03-09): Updated
ChatProviderCatalogViewSet.models()inbackend/server/chat/views/__init__.pyto return a curated multi-model list foropencode_zen(OpenAI + Anthropic options), excludingopenai/o1-previewandopenai/o1-miniper critic guardrail.
- Acceptance criteria:
-
F2 — Correct chat context to reflect full trip/collection
- Acceptance criteria:
- Assistant guidance/prompt context emphasizes full collection itinerary and date window.
- Tool calls for planning are grounded in trip-level context (not only one location label).
- No regression in existing collection-context fields.
- Agent: explorer → coder → reviewer → tester
- Dependencies: discovery of system prompt + tool context assembly.
- Workstream:
main - Implementation note (2026-03-09): Updated frontend
deriveCollectionDestination()to summarize unique itinerary stops (city/country-first with fallback names, compact cap), enriched backendsend_message()trip context with collection-derived multi-stop itinerary data fromcollection.locations, and added explicit system prompt guidance to treat collection chats as trip-level and callget_trip_detailsbefore location search when additional context is needed.
- Acceptance criteria:
-
F3 — Make suggested prompts itinerary-centric
- Acceptance criteria:
- Quick-action prompts no longer require/assume a single destination.
- Prompts read naturally for multi-city/multi-country collections.
- Agent: explorer → coder → reviewer → tester
- Dependencies: discovery of prompt rendering logic in
AITravelChat.svelte. - Workstream:
main - Implementation note (2026-03-09): Updated
AITravelChat.sveltequick-action guard to usecollectionName || destinationcontext and itinerary-focused wording for Restaurants/Activities prompts; fixedsearch_placestool result parsing by changing.placesreads to backend-aligned.resultsin bothhasPlaceResults()andgetPlaceResults(), restoring place-card rendering and Add-to-Itinerary actions.
- Acceptance criteria:
Notes
- User-provided trace in
agent-interaction.txtindicates location-heavy responses and a{"error":"location is required"}tool failure during itinerary add flow.
Discovery Findings
F1 — Model dropdown shows only one option
Root cause: backend/server/chat/views/__init__.py lines 417–418, ChatProviderCatalogViewSet.models():
if provider in ["opencode_zen"]:
return Response({"models": ["openai/gpt-5-nano"]})
The opencode_zen branch returns a single-element list. All other non-matched providers fall to return Response({"models": []}) (line 420).
Frontend loading path (AITravelChat.svelte lines 115–142, loadModelsForProvider()):
GET /api/chat/providers/{provider}/models/→ setsavailableModels = data.models.- When the list has exactly one item, the dropdown shows only that item (correct DaisyUI
<select>, lines 599–613). availableModels.length === 0→ shows a single "Default" option (line 607), so both the zero-model and one-model paths surface as a one-option dropdown.
Also: The models endpoint (line 339–426) requires an API key and returns HTTP 403 if absent; the frontend silently sets availableModels = [] on any non-OK response (line 136–138) — so users without a key see "Default" only, regardless of provider.
Edit point:
backend/server/chat/views/__init__.pylines 417–418: expandopencode_zenmodel list to include Zen-compatible models (e.g.,openai/gpt-5-nano,openai/gpt-4o-mini,openai/gpt-4o,anthropic/claude-3-5-haiku-20241022).- Optionally:
AITravelChat.svelteloadModelsForProvider()— handle non-OK response more gracefully (log distinct error instead of silent fallback to empty).
F2 — Context appears location-centric, not trip-centric
Root cause — destination prop is a single derived location string:
frontend/src/routes/collections/[id]/+page.svelte lines 259–278, deriveCollectionDestination():
const firstLocation = current.locations.find(...)
return `${cityName}, ${countryName}` // first location only
Only the first location in collection.locations is used. Multi-city trips surface a single city/country string.
How it propagates (+page.svelte lines 1287–1294):
<AITravelChat
destination={collectionDestination} // ← single-location string
...
/>
Backend trip context (backend/server/chat/views/__init__.py lines 144–168, send_message):
context_parts = []
if collection_name: context_parts.append(f"Trip: {collection_name}")
if destination: context_parts.append(f"Destination: {destination}") # ← single string
if start_date and end_date: context_parts.append(f"Dates: ...")
system_prompt += "\n\n## Trip Context\n" + "\n".join(context_parts)
The Destination: line is a single string from the frontend — no multi-stop awareness. The collection object IS fetched from DB (lines 152–164) and passed to get_system_prompt(user, collection), but get_system_prompt (llm_client.py lines 310–358) only uses collection to decide single-user vs. party preferences — it never reads collection locations, itinerary, or dates from the collection model itself.
Edit points:
frontend/src/routes/collections/[id]/+page.sveltederiveCollectionDestination()(lines 259–278): Change to derive a multi-location string (e.g., comma-joined list of unique city/country pairs, capped at 4–5) rather than first-only. Or rename to make clear it's itinerary-wide and returnundefinedwhen collection has many diverse destinations.backend/server/chat/views/__init__.pysend_message()(lines 144–168): Sincecollectionis already fetched, enrichcontext_partsdirectly fromcollection.locations(unique cities/countries) rather than relying solely on the single-stringdestinationparam.- Optionally,
backend/server/chat/llm_client.pyget_system_prompt()(lines 310–358): Whencollectionis not None, add a collection-derived section to the base prompt listing all itinerary destinations and dates from the collection object.
F3 — Quick-action prompts assume a single destination
Root cause — all destination-dependent prompts are gated on destination prop (AITravelChat.svelte lines 766–804):
{#if destination}
<button>🍽️ Restaurants in {destination}</button>
<button>🎯 Activities in {destination}</button>
{/if}
{#if startDate && endDate}
<button>🎒 Packing tips for {startDate} to {endDate}</button>
{/if}
<button>📅 Itinerary help</button> ← always shown, generic
The "Restaurants" and "Activities" buttons are hidden when no destination is derived (multi-city trip with no single dominant location), and their prompt strings hard-code ${destination} — a single-city reference. They also don't reference the collection name or multi-stop nature.
Edit points (AITravelChat.svelte lines 766–804):
- Replace
{#if destination}guard for restaurant/activity buttons with a{#if collectionName || destination}guard. - Change prompt strings to use
collectionNameas primary context, falling back todestination:What are the best restaurants for my trip to ${collectionName || destination}?What activities are there across my ${collectionName} itinerary?
- Add a "Budget" or "Transport" quick action that references the collection dates + itinerary scope (doesn't need
destination). - The "📅 Itinerary help" button (line 797–804) sends
'Can you help me plan a day-by-day itinerary for this trip?'— already collection-neutral; no change needed. - Packing tip prompt (lines 788–795) already uses
startDate/endDatewithoutdestination— this one is already correct.
Cross-cutting risk: destination prop semantics are overloaded
The destination prop in AITravelChat.svelte is used for:
- Header subtitle display (line 582: removed in current code — subtitle block gone)
- Quick-action prompt strings (lines 771, 779)
send_messagepayload (line 268:destination)
Changing deriveCollectionDestination() to return a multi-location string affects all three uses. The header display is currently suppressed (no {destination} in the HTML header block after WS4-F4 changes), so that's safe. The send_message backend receives it as the Destination: context line, which is acceptable for a multi-city string.
No regression surface from loadModelsForProvider reactive trigger
The $: if (selectedProvider) { void loadModelsForProvider(); } reactive statement (line 190–192) fires whenever selectedProvider changes. Expanding the opencode_zen model list won't affect other providers. The loadModelPref/saveModelPref localStorage path is independent of model list size.
add_to_itinerary tool location required error (from Notes)
search_places tool (agent_tools.py) requires a location string param. When the LLM calls it with no location (because context only mentions a trip name, not a geocodable string), the tool returns {"error": "location is required"}. This is downstream of F2 — fixing the context so the LLM receives actual geocodable location strings will reduce these errors, but the tool itself should also be documented as requiring a geocodable string.
Deep-Dive Findings (explorer pass 2 — 2026-03-09)
F1: Exact line for single-model fix
backend/server/chat/views/__init__.py lines 417–418:
if provider in ["opencode_zen"]:
return Response({"models": ["openai/gpt-5-nano"]})
Single-entry hard-coded list. No Zen API call is made. Expand to all Zen-compatible models.
Recommended minimal list (OpenAI-compatible pass-through documented for Zen):
return Response({"models": [
"openai/gpt-5-nano",
"openai/gpt-4o-mini",
"openai/gpt-4o",
"openai/o1-preview",
"openai/o1-mini",
"anthropic/claude-sonnet-4-20250514",
"anthropic/claude-3-5-haiku-20241022",
]})
F2: System prompt never injects collection locations into context
backend/server/chat/views/__init__.py lines 144–168 (send_message): collection is fetched from DB but only passed to get_system_prompt() for preference aggregation — its .locations queryset is never read to enrich context.
backend/server/chat/llm_client.py lines 310–358 (get_system_prompt): collection param only used for shared_with preference branch. Zero use of collection.locations, .start_date, .end_date, or .itinerary_items.
Minimal fix — inject into context_parts in send_message:
After line 164 (collection = requested_collection), add:
if collection:
loc_names = list(collection.locations.values_list("name", flat=True)[:8])
if loc_names:
context_parts.append(f"Locations in this trip: {', '.join(loc_names)}")
Also strengthen the base system prompt in llm_client.py to instruct the model to call get_trip_details when operating in collection context before calling search_places.
F3a: Frontend hasPlaceResults / getPlaceResults use wrong key .places — cards never render
Critical bug — AITravelChat.svelte:
- Line 377: checks
(result.result as { places?: unknown[] }).places— should beresults - Line 386: returns
(result.result as { places: any[] }).places— should beresults
Backend search_places (agent_tools.py line 188–192) returns:
return {"location": location_name, "category": category, "results": results}
The key is results, not places. Because hasPlaceResults always returns false, the "Add to Itinerary" button on place cards is never rendered for any real tool output. The <pre> JSON fallback block shows instead.
Minimal fix: change both .places references → .results in AITravelChat.svelte lines 377 and 386.
F3b: {"error": "location is required"} origin
backend/server/chat/agent_tools.py line 128:
if not location_name:
return {"error": "location is required"}
Triggered when LLM calls search_places({}) with no location argument — which happens when the system prompt only contains a non-geocodable trip name (e.g., Destination: Rome Trip 2025) without actual city/place strings.
This error surfaces in the SSE stream → rendered as a tool result card with {"error": "..."} text.
Fix: Resolved by F2 (richer context); also improve guard message to be user-safe: "Please provide a location or city name to search near.".
Summary of edit points
| Issue | File | Lines | Change |
|---|---|---|---|
| F1: expand opencode_zen models | backend/server/chat/views/__init__.py |
417–418 | Replace 1-item list with 7-item list |
| F2: inject collection locations | backend/server/chat/views/__init__.py |
144–168 | Add loc_names context_parts after line 164 |
| F2: reinforce system prompt | backend/server/chat/llm_client.py |
314–332 | Add guidance to use get_trip_details in collection context |
F3a: fix .places → .results |
frontend/src/lib/components/AITravelChat.svelte |
377, 386 | Two-char key rename |
| F3b: improve error guard | backend/server/chat/agent_tools.py |
128 | Better user-safe message (optional) |
Critic Gate
- Verdict: APPROVED
- Date: 2026-03-09
- Reviewer: critic agent
Assumption Challenges
- F2
values_list("name")may not produce geocodable strings —Location.namecan be opaque (e.g., "Eiffel Tower"). Mitigated: plan already proposes system prompt guidance to callget_trip_detailsfirst. Enhancement: usecity__name/country__namein addition tonamefor the injected context. - F3a
.placesvs.resultskey mismatch — confirmed real bug.agent_tools.pyreturnsresultskey; frontend checksplaces. Place cards never render. Two-char fix validated.
Execution Guardrails
- Sequencing: F1 (independent) → F2 (context enrichment) → F3 (prompts +
.placesfix). F3 depends on F2'sderiveCollectionDestinationchanges. - F1 model list: Exclude
openai/o1-previewandopenai/o1-mini— reasoning models may not support tool-use in streaming chat. Verify compatibility before including. - F2 context injection: Use
select_related('city', 'country')orvalues_list('name', 'city__name', 'country__name')— barenamealone is insufficient for geocoding context. - F3a is atomic: The
.places→.resultsfix is a standalone bug, separate from prompt wording changes. Can bundle in F3's review cycle. - Quality pipeline: Each fix gets reviewer + tester pass. No batch validation.
- Functional verification required: (a) model dropdown shows multiple options, (b) chat context includes multi-city info, (c) quick-action prompts render for multi-location collections, (d) search result place cards actually render (F3a).
- Decomposition: Single workstream appropriate — tightly coupled bugfixes in same component/view pair, not independent services.
F1 Review
- Verdict: APPROVED (score 0)
- Lens: Correctness
- Date: 2026-03-09
- Reviewer: reviewer agent
Scope: backend/server/chat/views/__init__.py lines 417–428 — opencode_zen model list expanded from 1 to 5 entries.
Findings: No CRITICAL or WARNING issues. Change is minimal and correctly scoped.
Verified:
- Critic guardrail followed:
o1-previewando1-miniexcluded (reasoning models, no streaming tool-use). - All 5 model IDs use valid LiteLLM
provider/modelformat;anthropic/*IDs match exact entries in Anthropic branch. _is_model_override_compatible()bypasses prefix check forapi_basegateways — all IDs pass validation.- No regression in other provider branches (openai, anthropic, gemini, groq, ollama) — all untouched.
- Frontend
loadModelsForProvider()handles multi-item arrays correctly; dropdown will show all 5 options. - localStorage model persistence unaffected by list size change.
Suggestion: Add inline comment on why o1-preview/o1-mini are excluded to prevent future re-addition.
Reference: See Critic Gate, decisions.md
F1 Test
- Verdict: PASS (Standard + Adversarial)
- Date: 2026-03-09
- Tester: tester agent
Commands run
| # | Command | Exit code | Output |
|---|---|---|---|
| 1 | docker compose exec server python3 -m py_compile /code/chat/views/__init__.py |
0 | (no output — syntax OK) |
| 2 | Inline python3 -c assertion of opencode_zen branch |
0 | count: 5, all 5 model IDs confirmed present, PASS |
| 3 | Adversarial: branch isolation for 8 non-opencode_zen providers |
0 | All return [], ADVERSARIAL PASS |
| 4 | Adversarial: critic guardrail + LiteLLM format check | 0 | o1-preview / o1-mini absent; all IDs in provider/model format, PASS |
| 5 | docker compose exec server python3 -c "import chat.views; ..." |
0 | Module import OK, ChatProviderCatalogViewSet.models action present |
| 6 | docker compose exec server python3 manage.py test --verbosity=1 --keepdb |
1 (pre-existing) | 30 tests: 24 pass, 1 fail, 5 errors — identical to known baseline (2 user email key + 4 geocoding mock). Zero new failures. |
Key findings
opencode_zenbranch now returns exactly 5 models:openai/gpt-5-nano,openai/gpt-4o-mini,openai/gpt-4o,anthropic/claude-sonnet-4-20250514,anthropic/claude-3-5-haiku-20241022.- Critic guardrail respected:
openai/o1-previewandopenai/o1-miniabsent from list. - All model IDs use valid
provider/modelformat compatible with LiteLLM routing. - No other provider branches affected.
- No regression in full Django test suite beyond pre-existing baseline.
Adversarial attempts
- Case insensitive match (
OPENCODE_ZEN): does not match branch → returns[](correct; exact case match required). - Partial match (
opencode_zen_extra): does not match → returns[](correct; no prefix leakage). - Empty string provider
"": returns[](correct). openai/o1-previewinclusion check: absent from list (critic guardrail upheld).openai/o1-miniinclusion check: absent from list (critic guardrail upheld).
MUTATION_ESCAPES: 0/4
All critical branch mutations checked: wrong provider name, case variation, extra-suffix variation, empty string — all correctly return []. The 5-model list is hard-coded so count drift would be immediately caught by assertion.
LESSON_CHECKS
- Pre-existing test failures (2 user + 4 geocoding) — confirmed, baseline unchanged.
F2 Review
- Verdict: APPROVED (score 0)
- Lens: Correctness
- Date: 2026-03-09
- Reviewer: reviewer agent
Scope: F2 — Correct chat context to reflect full trip/collection. Three files changed:
frontend/src/routes/collections/[id]/+page.svelte(lines 259–300):deriveCollectionDestination()rewritten from first-location-only to multi-stop itinerary summary.backend/server/chat/views/__init__.py(lines 166–199):send_message()enriched with collection-derivedItinerary stops:context fromcollection.locations.backend/server/chat/llm_client.py(lines 333–336): System prompt updated with trip-level reasoning guidance andget_trip_details-first instruction.
Acceptance criteria verified:
- ✅ Frontend derives multi-stop destination string (unique city/country pairs, capped at 4, semicolon-joined,
+N moreoverflow). - ✅ Backend enriches system prompt with
Itinerary stops:from collection locations (up to 8,select_related('city', 'country')for efficiency). - ✅ System prompt instructs trip-level reasoning and
get_trip_details-first behavior (tool confirmed to exist inagent_tools.py). - ✅ No regression: non-collection chats, single-location collections, and empty-location collections all handled correctly via guard conditions.
Findings: No CRITICAL or WARNING issues. Two minor suggestions (dead guard on line 274 of +page.svelte; undocumented cap constant in views/__init__.py line 195).
Prior guidance: Critic gate recommendation to use select_related('city', 'country') and city/country names — confirmed followed.
Reference: See Critic Gate, F1 Review
F2 Test
- Verdict: PASS (Standard + Adversarial)
- Date: 2026-03-09
- Tester: tester agent
Commands run
| # | Command | Exit code | Output summary |
|---|---|---|---|
| 1 | bun run check (frontend) |
0 | 0 errors, 6 warnings — all 6 are pre-existing in CollectionRecommendationView.svelte + RegionCard.svelte; no new issues from F2 changes |
| 2 | docker compose exec server python3 -m py_compile /code/chat/views/__init__.py |
0 | Syntax OK |
| 3 | docker compose exec server python3 -m py_compile /code/chat/llm_client.py |
0 | Syntax OK |
| 4 | Backend functional enrichment test (mock collection, 6 inputs → 5 unique stops) | 0 | Itinerary stops: Rome, Italy; Florence, Italy; Venice, Italy; Switzerland; Eiffel Tower — multi-stop line confirmed |
| 5 | Adversarial backend: 7 cases (cap-8, empty, all-blank, whitespace, unicode, dedup-12, None city) | 0 | All 7 PASS |
| 6 | Frontend JS adversarial: 7 cases (multi-stop, single, null, empty, overflow +N, fallback, all-blank) | 0 | All 7 PASS |
| 7 | System prompt phrase check | 0 | itinerary-wide + get_trip_details + Treat context as itinerary-wide all confirmed present |
| 8 | docker compose exec server python3 manage.py test --verbosity=1 --keepdb |
1 (pre-existing) | 30 tests: 24 pass, 1 fail, 5 errors — identical to known baseline; zero new failures |
Acceptance criteria verdict
| Criterion | Result | Evidence |
|---|---|---|
| Multi-stop destination string derived in frontend | ✅ PASS | JS test: 3-city collection → Rome, Italy; Florence, Italy; Venice, Italy; 6-city → A, X; B, X; C, X; D, X; +2 more |
Backend injects Itinerary stops: from collection.locations |
✅ PASS | Python test: 6 inputs → 5 unique stops joined with ; , correctly prefixed Itinerary stops: |
System prompt has trip-level + get_trip_details-first guidance |
✅ PASS | get_system_prompt() output contains itinerary-wide, get_trip_details first, Treat context as itinerary-wide |
| No regression in existing fields | ✅ PASS | Django test suite unchanged at baseline (24 pass, 6 pre-existing fail/error) |
Adversarial attempts
| Hypothesis | Test | Expected failure signal | Observed |
|---|---|---|---|
| 12-city collection exceeds cap | Supply 12 unique cities | >8 stops returned | Capped at exactly 8 ✅ |
Empty locations list |
Pass locations=[] |
Crash or non-empty result | Returns undefined/[] cleanly ✅ |
| All-blank location entries | All city/country/name empty or whitespace | Non-empty or crash | All skipped, returns undefined/[] ✅ |
| Whitespace-only city/country | city.name=' ' with valid fallback |
Whitespace treated as valid | Strip applied, fallback used ✅ |
| Unicode city names | 東京, Zürich, São Paulo |
Encoding corruption or skip | All 3 preserved correctly ✅ |
| 12 duplicate identical entries | Same city×12 | Multiple copies in output | Deduped to exactly 1 ✅ |
city.name = None (DB null) |
None city name, valid country |
AttributeError or crash |
Handled via or '' guard, country used ✅ |
null collection passed to frontend func |
deriveCollectionDestination(null) |
Crash | Returns undefined cleanly ✅ |
| Overflow suffix formatting | 6 unique stops, maxStops=4 | Wrong suffix or missing | +2 more suffix correct ✅ |
| Fallback name path | No city/country, location='Eiffel Tower' |
Missing or wrong label | Eiffel Tower used ✅ |
MUTATION_ESCAPES: 0/6
Mutation checks applied:
>= 8cap mutated to> 8→ A1 test (12-city produces 8, not 9) would catch.seen_stopsdedup check mutated to always-false → A6 test (12-dupes) would catch.or ''null-guard oncity.nameremoved → A7 test would catchAttributeError.if not fallback_name: continueremoved → A3 test (all-blank) would catch spurious entries.stops.slice(0, maxStops).join('; ')separator mutated to', '→ Multi-stop tests check for'; 'as separator.return undefinedon empty guard mutated toreturn ''→ A4 empty-locations test checks=== undefined.
All 6 mutations would be caught by existing test cases.
LESSON_CHECKS
- Pre-existing test failures (2 user email key + 4 geocoding mock) — confirmed, baseline unchanged.
- F2 context enrichment using
select_related('city', 'country')per critic guardrail — confirmed (line 169–171 of views/init.py). - Fallback to
location/namefields when geo data absent — confirmed working via A4/A5 tests.
Reference: See F2 Review, Critic Gate
F3 Review
- Verdict: APPROVED (score 0)
- Lens: Correctness
- Date: 2026-03-09
- Reviewer: reviewer agent
Scope: Targeted re-review of two F3 findings in frontend/src/lib/components/AITravelChat.svelte:
.places→.resultskey mismatch inhasPlaceResults()/getPlaceResults()- Quick-action prompt guard and wording — location-centric → itinerary-centric
Finding 1 — .places → .results (RESOLVED):
hasPlaceResults()(line 378): checks(result.result as { results?: unknown[] }).results✅getPlaceResults()(line 387): returns(result.result as { results: any[] }).results✅- Cross-verified against backend
agent_tools.py:188-191:return {"location": ..., "category": ..., "results": results}— keys match.
Finding 2 — Itinerary-centric prompts (RESOLVED):
- New reactive
promptTripContext(line 72):collectionName || destination || ''— prefers collection name over single destination. - Guard changed from
{#if destination}→{#if promptTripContext}(line 768) — buttons now visible for named collections even without a single derived destination. - Prompt strings use
across my ${promptTripContext} itinerary?wording (lines 773, 783) — no longer implies single location. - No impact on packing tips (still
startDate && endDategated) or itinerary help (always shown).
No introduced issues: promptTripContext always resolves to string; template interpolation safe; existing tool result rendering and sendMessage() logic unchanged beyond the key rename.
SUGGESTIONS: Minor indentation inconsistency between {#if promptTripContext} block (lines 768-789) and adjacent {#if startDate} block (lines 790-801) — cosmetic, bun run format should normalize.
Reference: See Critic Gate, F2 Review, decisions.md
F3 Test
- Verdict: PASS (Standard + Adversarial)
- Date: 2026-03-09
- Tester: tester agent
Commands run
| # | Command | Exit code | Output summary |
|---|---|---|---|
| 1 | bun run check (frontend) |
0 | 0 errors, 6 warnings — all 6 pre-existing in CollectionRecommendationView.svelte + RegionCard.svelte; zero new issues from F3 changes |
| 2 | bun run f3_test.mjs (functional simulation) |
0 | 20 assertions: S1–S6 standard + A1–A6 adversarial + PTC1–PTC4 promptTripContext + prompt wording — ALL PASSED |
Acceptance criteria verdict
| Criterion | Result | Evidence |
|---|---|---|
.places → .results key fix in hasPlaceResults() |
✅ PASS | S1: {results:[...]} → true; S2: {places:[...]} → false (old key correctly rejected) |
.places → .results key fix in getPlaceResults() |
✅ PASS | S1: returns 2-item array from .results; S2: returns [] on .places key |
Old .places key no longer triggers card rendering |
✅ PASS | S2 regression guard: hasPlaceResults({places:[...]}) → false |
promptTripContext = collectionName || destination || '' |
✅ PASS | PTC1–PTC4: collectionName wins; falls back to destination; empty string when both absent |
Quick-action guard is {#if promptTripContext} |
✅ PASS | Source inspection confirmed line 768 uses promptTripContext |
| Prompt wording is itinerary-centric | ✅ PASS | Both prompts contain itinerary; neither uses single-location "in X" wording |
Adversarial attempts
| Hypothesis | Test design | Expected failure signal | Observed |
|---|---|---|---|
results is a string, not array |
result: { results: 'not-array' } |
Array.isArray fails → false |
false ✅ |
results is null |
result: { results: null } |
Array.isArray(null) false |
false ✅ |
result.result is a number |
result: 42 |
typeof guard rejects | false ✅ |
result.result is a string |
result: 'str' |
typeof guard rejects | false ✅ |
Both .places and .results present |
both keys in result | Must use .results |
getPlaceResults returns .results item ✅ |
results is an object {foo:'bar'} |
not an array | Array.isArray false |
false ✅ |
promptTripContext with empty collectionName string |
'' || 'London' || '' |
Should fall through to destination | 'London' ✅ |
MUTATION_ESCAPES: 0/5
Mutation checks applied:
result.result !== nullguard removed → S5 (null result) would crashArray.isArray(null.results)and be caught.Array.isArray(...)replaced with truthy check → A1 (string results) test would catch.result.name === 'search_places'removed → S4 (wrong tool name) would catch..resultskey swapped back to.places→ S1 (standard payload) would return empty array, caught.collectionName || destinationorder swapped → PTC1 test would return wrong value, caught.
All 5 mutations would be caught by existing assertions.
LESSON_CHECKS
.placesvs.resultskey mismatch (F3a critical bug from discovery) — confirmed fixed: S1 passes with.results; S2 regression guard confirms.placesno longer triggers card rendering.- Pre-existing 6 svelte-check warnings — confirmed, no new warnings introduced.
Completion Summary
- Status: ALL COMPLETE (F1 + F2 + F3)
- Date: 2026-03-09
- All tasks: Implemented, reviewed (APPROVED score 0), and tested (PASS standard + adversarial)
- Zero regressions: Frontend 0 errors / 6 pre-existing warnings; backend 24/30 pass (6 pre-existing failures)
- Files changed:
backend/server/chat/views/__init__.py— F1 (model list expansion) + F2 (itinerary stops context injection)backend/server/chat/llm_client.py— F2 (system prompt trip-level guidance)frontend/src/routes/collections/[id]/+page.svelte— F2 (multi-stopderiveCollectionDestination)frontend/src/lib/components/AITravelChat.svelte— F3 (itinerary-centric prompts +.resultskey fix)
- Knowledge recorded: knowledge.md (multi-stop context, quick prompts, search_places key convention, opencode_zen model list)
- Decisions recorded: decisions.md (critic gate)
- AGENTS.md updated: Chat model override pattern (dropdown) + chat context pattern added
Discovery: runtime failures (2026-03-09)
Explorer investigation of three user-trace errors against the complete scoped file set.
Error 1 — "The model provider rate limit was reached"
Exact origin: backend/server/chat/llm_client.py lines 128–132 (_safe_error_payload):
if isinstance(exc, rate_limit_cls):
return {
"error": "The model provider rate limit was reached. Please wait and try again.",
"error_category": "rate_limited",
}
The user-trace text "model provider rate limit was reached" is a substring of this exact message. This is not a bug — it is the intended sanitized error surface for litellm.exceptions.RateLimitError. The error is raised by LiteLLM when the upstream provider (OpenAI, Anthropic, etc.) returns HTTP 429, and _safe_error_payload() converts it to this user-safe string. The SSE error payload is then propagated through stream_chat_completion (line 457) → event_stream() in send_message (line 256: if data.get("error"): encountered_error = True; break) → yielded to frontend → frontend SSE loop sets assistantMsg.content = parsed.error (line 307 of AITravelChat.svelte).
Root cause of rate limiting itself: Most likely openai/gpt-5-nano as the opencode_zen default model, or the user's provider hitting quota. No code fix required — this is provider-side throttling surfaced correctly. However, if the opencode_zen provider is being mistakenly routed to OpenAI's public endpoint instead of https://opencode.ai/zen/v1, it would exhaust a real OpenAI key rather than Zen. See Risk 1 below.
No auth/session issue involved — the error path reaches LiteLLM, meaning auth already succeeded up to the LLM call.
Error 2 — {"error":"location is required"}
Exact origin: backend/server/chat/agent_tools.py line 128:
if not location_name:
return {"error": "location is required"}
Triggered when LLM calls search_places({}) or search_places({"category": "food"}) with no location argument. This happens when the system prompt's trip context does not give the model a geocodable string — the model knows a "trip name" but not a city/country, so it calls search_places without a location.
Current state (post-F2): The F2 fix injects "Itinerary stops: Rome, Italy; ..." into the system prompt from collection.locations only when collection_id is supplied and resolves to an authorized collection. If collection_id is missing from the frontend payload OR if the collection has locations with no city/country FK and no location/name fallback, the context_parts will still have only the destination string.
Residual trigger path (still reachable after F2):
collection_idnot sent insend_messagepayload → collection never fetched →context_partshas onlyDestination: <multi-stop string>→ LLM picks a trip-name string like "Italy 2025" as its location arg →search_places(location="Italy 2025")succeeds (geocoding finds "Italy") OR model sendssearch_places({})→ error returned.- OR:
collection_idIS sent, all locations have nocity/countryANDlocationfield is blank ANDnameis not geocodable (e.g.,"Hotel California") →itinerary_stopslist is empty → noItinerary stops:line injected.
Second remaining trigger: get_trip_details fails (Collection.DoesNotExist or exception) → returns {"error": "An unexpected error occurred while fetching trip details"} → model falls back to calling search_places without a location derived from context.
Error 3 — {"error":"An unexpected error occurred while fetching trip details"}
Exact origin: backend/server/chat/agent_tools.py lines 394–396 (get_trip_details):
except Exception:
logger.exception("get_trip_details failed")
return {"error": "An unexpected error occurred while fetching trip details"}
Root cause — get_trip_details uses owner-only filter: agent_tools.py line 317:
collection = (
Collection.objects.filter(user=user)
...
.get(id=collection_id)
)
This uses filter(user=user) — shared collections are excluded. If the logged-in user is a shared member (not the owner) of the collection, Collection.DoesNotExist is raised, falls to the outer except Exception, and returns the generic error. However, Collection.DoesNotExist is caught specifically on line 392 and returns {"error": "Trip not found"}, not the generic message. So the generic error can only come from a genuine Python exception inside the try block — most likely:
item.itemAttributeError —CollectionItineraryItemuses aGenericForeignKey; if the referenced object has been deleted,item.itemreturnsNoneandgetattr(None, "name", "")would return""(safe, not an error) — so this is not the cause.collection.itinerary_itemsreverse relation — if therelated_name="itinerary_items"is not defined onCollectionItineraryItem.collectionFK, the queryset call raisesAttributeError. Checkingadventures/models.pyline 716:related_name="itinerary_items"is present — so this is not the cause.collection.transportation_set/collection.lodging_set— ifTransportationorLodgingdoesn't haverelated_namedefaulting totransportation_set/lodging_set, these would fail. This is the most likely cause — Django only auto-creates_setaccessors with the model name in lowercase;transportation_setrequires that the FKrelated_nameis either set or left as defaulttransportation_set. Need to verify model definition.collection.start_date.isoformat()on None — guarded byif collection.start_date(line 347) — safe.
Verified: Transportation.collection (models.py:332) and Lodging.collection (models.py:570) are both ForeignKeys with no related_name, so Django auto-assigns transportation_set and lodging_set — the accessors used in get_trip_details lines 375/382 are correct. These do NOT cause the error.
Actual culprit: The except Exception at line 394 catches everything. Any unhandled exception inside the try block (e.g., a prefetch_related("itinerary_items__content_type") failure if a content_type row is missing, or a date field deserialization error on a malformed DB record) results in the generic error. Most commonly, the issue is the shared-user access gap: Collection.objects.filter(user=user).get(id=...) raises Collection.DoesNotExist for shared users, but that is caught by the specific handler at line 392 as {"error": "Trip not found"}, NOT the generic message. The generic message therefore indicates a true runtime Python exception somewhere inside the try body.
Additionally: the shared-collection access gap means get_trip_details returns {"error": "Trip not found"} (not the generic error) for shared users — this is a separate functional bug where shared users cannot use the AI tool on their shared trips.
Authentication / CSRF in Chat Calls
Verdict: Auth is working correctly for the SSE path. No auth failure in the reported errors.
Evidence:
- Proxy path (
frontend/src/routes/api/[...path]/+server.ts):POSTtosend_messagegoes throughhandleRequest()(line 16) withrequreTrailingSlash=true.- On every proxied request: proxy deletes old
csrftokencookie, callsfetchCSRFToken()to get a fresh token fromGET /csrf/, then setsX-CSRFTokenheader and reconstructs theCookieheader withcsrftoken=<new>; sessionid=<from-browser>(lines 57–75). - SSE streaming:
content-type: text/event-streamis detected (line 94) and the response body is streamed directly without buffering.
- Session:
sessionidcookie is extracted from browser cookies (line 66) and forwarded.SESSION_COOKIE_SAMESITE=Laxallows this. - Rate-limit error is downstream of auth — LiteLLM only fires if the Django view already authenticated the user and reached
stream_chat_completion. A CSRF or session failure would return HTTP 403/401 before the SSE stream starts, and the frontend would hit theif (!res.ok)branch (line 273), not the SSE error path.
One auth-adjacent gap: loadConversations() (line 196) and createConversation() (line 203) do NOT include credentials: 'include' — but these go through the SvelteKit proxy which handles session injection server-side, so this is not a real failure point. The send_message fetch (line 258) also lacks explicit credentials, but again routes through the proxy.
Potential auth issue — missing trailing slash for models endpoint:
loadModelsForProvider() fetches /api/chat/providers/${selectedProvider}/models/ (line 124) — this ends with / which is correct for the proxy's requreTrailingSlash logic. However, the proxy only adds a trailing slash for non-GET requests (it's applied to POST/PATCH/PUT/DELETE but not GET). Since models/ is already in the URL, this is fine.
Ranked Fixes by Impact
| Rank | Error | File | Line(s) | Fix |
|---|---|---|---|---|
| 1 (HIGH) | get_trip_details generic error |
backend/server/chat/agent_tools.py |
316–325 | Add | Q(shared_with=user) to collection filter so shared users can call the tool; also add specific catches for known exception types before the bare except Exception |
| 2 (HIGH) | {"error":"location is required"} residual |
backend/server/chat/views/__init__.py |
152–164 | Ensure collection_id auth check also grants access for shared users (currently shared_with.filter(id=request.user.id).exists() IS present — ✅ already correct); verify collection_id is actually being sent from frontend on every sendMessage call |
| 2b (MEDIUM) | search_places called without location |
backend/server/chat/agent_tools.py |
127–128 | Improve error message to be user-instructional: "Please provide a city or location name to search near." — already noted in prior plan; also add location as a required field in the JSON schema so LLM is more likely to provide it |
| 3 (MEDIUM) | transportation_set/lodging_set crash |
backend/server/chat/agent_tools.py |
370–387 | Verify FK related_name values on Transportation/Lodging models; if wrong, correct the accessor names in get_trip_details |
| 4 (LOW) | Rate limiting | Provider config | N/A | No code fix — operational issue. Document that opencode_zen uses https://opencode.ai/zen/v1 as api_base (already set in CHAT_PROVIDER_CONFIG) — ensure users aren't accidentally using a real OpenAI key with opencode_zen provider |
Risks
-
get_trip_detailsshared-user gap: Shared users get{"error": "Trip not found"}— the LLM may then callsearch_placeswithout the location context thatget_trip_detailswould have provided, cascading into Error 2. Fix: add| Q(shared_with=user)to the collection filter atagent_tools.py:317. -
transportation_set/lodging_setreverse accessor names confirmed safe: Django auto-generatestransportation_setandlodging_setfor the FKs (norelated_nameonTransportation.collectionatmodels.py:332orLodging.collectionatmodels.py:570). These accessors work correctly. The generic error inget_trip_detailsmust be from another exception path (e.g., malformed DB records, missing ContentType rows for deleted itinerary items, or theprefetch_relatedinteraction on orphaned GFK references). -
collection_idnot forwarded on all sends: IfAITravelChat.svelteis embedded withoutcollectionIdprop (e.g., standalone chat page),collection_idisundefinedin the payload, the backend never fetches the collection, and noItinerary stops:context is injected. The LLM then has no geocodable location data → callssearch_placeswithoutlocation. -
search_placesJSON schema markslocationas required butexecute_toolusesfiltered_kwargs: The tool schema (agent_tools.py:103) sets"required": Trueonlocation. However,execute_tool(line 619) passes onlyfiltered_kwargsfrom the JSON-parsedargumentsdict. If LLM sends{}(empty),location=Noneis the function default, not a schema-enforcement error. There is no server-side validation of required tool arguments — the required flag is only advisory to the LLM.
See decisions.md for critic gate context.
Research: Provider Strategy (2026-03-09)
Full findings: research/provider-strategy.md
Verdict: Keep LiteLLM, Harden It
Replacing LiteLLM is not warranted. Every Voyage issue is in the integration layer (no retries, no capability checks, hardcoded models), not in LiteLLM itself. OpenCode's Python-equivalent IS LiteLLM — OpenCode uses Vercel AI SDK with ~20 bundled @ai-sdk/* provider packages, which is the TypeScript analogue.
Architecture Options
| Option | Effort | Risk | Recommended? |
|---|---|---|---|
| A. Keep LiteLLM, harden (retry, tool-guard, metadata) | Low (1-2 sessions) | Low | ✅ YES |
| B. Hybrid: direct SDK for some providers | High (1-2 weeks) | High | No |
| C. Replace LiteLLM entirely | Very High (3-4 weeks) | Very High | No |
| D. LiteLLM Proxy sidecar | Medium (2-3 days) | Medium | Not yet — future multi-user |
Immediate Code Fixes (4 items)
| # | Fix | File | Line(s) | Impact |
|---|---|---|---|---|
| 1 | Add num_retries=2, request_timeout=60 to litellm.acompletion() |
llm_client.py |
418 | Retry on rate-limit/timeout — biggest gap |
| 2 | Add litellm.supports_function_calling(model=) guard before passing tools |
llm_client.py |
~397 | Prevents tool-call errors on incapable models |
| 3 | Return model objects with supports_tools metadata instead of bare strings |
views/__init__.py |
models() action |
Frontend can warn/adapt per model capability |
| 4 | Replace hardcoded model="gpt-4o-mini" with provider config default |
day_suggestions.py |
194 | Respects user's configured provider |
Long-Term Recommendations
- Curated model registry (YAML/JSON file like OpenCode's
models.dev) with capabilities, costs, context limits — loaded at startup - LiteLLM Proxy sidecar — only if/when Voyage gains multi-user production deployment
- WSGI→ASGI migration — long-term fix for event loop fragility (out of scope)
Key Patterns Observed in Other Projects
- No production project does universal runtime model discovery — all use curated/admin-managed lists
- Every production LiteLLM user has retry logic — Voyage is the outlier with zero retries
- Tool-call capability guards are standard (
litellm.supports_function_calling()used by PraisonAI, open-interpreter, mem0, ragbits, dspy) - Rate-limit resilience ranges from simple
num_retriesto fulllitellm.RouterwithRetryPolicyand cross-model fallbacks