navi-go

3. Agent Design

NaviGo implements a specialized multi-agent pattern where each agent owns a single planning domain. Agents communicate through a shared, strongly-typed state rather than direct message passing. This design decouples agent implementations, enables independent testing, and ensures that the supervisor router can reason about planning progress by inspecting state completeness.

3.1 Agent Taxonomy

Agent File Responsibility LLM Required?
Requirement Parser src/agents/requirement-parser.agent.ts Extract structured trip fields from natural language Yes
Form Completer src/agents/form-completer.agent.ts Validate completeness; assemble UserRequest or ask clarifying questions Yes
Risk Guard src/agents/risk-guard.agent.ts Scan inputs/outputs for prompt injection and unsafe content via rules + LLM Yes
Preference src/agents/preference.agent.ts Extract structured preferences from free-text request Yes
Destination src/agents/destination.agent.ts Suggest destination candidates with rationale Yes
Itinerary src/agents/itinerary.agent.ts Build day-by-day itinerary via LLM, fetch flights and weather Yes
Budget src/agents/budget.agent.ts Estimate total cost and flag budget issues via LLM Yes
Packing src/agents/packing.agent.ts Generate weather/activity-aware packing list via LLM Yes
Plan Synthesizer src/agents/plan-synthesizer.agent.ts Assemble final plan artifact and apply output guardrails via LLM Yes

3.2 Agent Contract

Every agent conforms to the same function signature:

async (state: PlannerState, deps?: AgentDependencies): Promise<Partial<PlannerState>>

3.3 Requirement Parser Agent

Purpose: Convert a raw natural-language request into partially structured trip fields.

Implementation (src/agents/requirement-parser.agent.ts):

Input:  state.naturalLanguage
Output: parsedRequest, decisionLog

Decision Log Evidence:

3.4 Form Completer Agent

Purpose: Validate whether extracted fields are sufficient to assemble a complete UserRequest, or generate clarifying questions.

Implementation (src/agents/form-completer.agent.ts):

Input:  state.parsedRequest, state.naturalLanguage
Output: userRequest (if complete), pendingQuestions (if incomplete), decisionLog

Decision Log Evidence:

3.5 Risk Guard Agent

Purpose: First and last line of defense against adversarial inputs and unsafe outputs.

Implementation (src/agents/risk-guard.agent.ts):

Input:  state.userRequest.requestText, state.finalPlan
Output: safetyFlags, decisionLog

Patterns Detected (rules):

Unsafe Output Patterns:

3.6 Preference Agent

Purpose: Convert unstructured user intent into a structured preference profile.

Implementation (src/agents/preference.agent.ts):

Uses model.withStructuredOutput(PreferencesSchema) with a prompt template:

PreferencesSchema = z.object({
  travelStyle: z.enum(["relaxed", "balanced", "packed"]),
  prioritizedInterests: z.array(z.string().min(1)),
  preferredPace: z.enum(["slow", "normal", "fast"]),
  accommodationPreference: z.enum(["budget", "midrange", "premium"]),
});

If the user explicitly provided interests in the request, those override the model-extracted interests. This ensures user intent is not silently overwritten.

Decision Log Evidence:

3.7 Destination Agent

Purpose: Generate ranked destination candidates with supporting rationale.

Implementation (src/agents/destination.agent.ts):

Uses model.withStructuredOutput(DestinationSuggestionsSchema) where candidates include:

DestinationCandidateSchema = z.object({
  name: z.string(),
  country: z.string(),
  iataCode: z.string().regex(/^[A-Z]{3}$/).nullable(),
  cityCode: z.string().regex(/^[A-Z]{3}$/).nullable(),
  rationale: z.string(),
});

Fallback Logic: If the user provided a destinationHint, destinationCityCode, and destinationIata, the agent prepends this as an explicit fallback candidate when it is not already present in the generated list. The final candidate list is capped to 3 entries.

Prompt Context:

3.8 Itinerary Agent

Purpose: Construct a concrete day-by-day itinerary grounded in real flights and weather data.

Implementation (src/agents/itinerary.agent.ts):

If originIata and destination IATA are available, calls searchFlightOffers() (Duffel integration) twice:

Flight options are ranked by pickRecommendedFlightOption() (src/agents/flight-option-selection.ts) using an O(n) min-find reduce, preferring flights arriving on or before the travel start date, then by earlier arrival, lower price, and earlier departure.

Weather Risk Assessment

Calls fetchWeatherRiskSummary() (Open-Meteo integration) to get daily forecasts with risk levels:

Activity Generation

Uses model.withStructuredOutput(ItineraryDraftSchema) to generate the itinerary. The prompt includes:

Instructions to the LLM:

Decision Log Evidence:

3.9 Budget Agent

Purpose: Estimate total trip cost and determine feasibility against the user budget.

Implementation (src/agents/budget.agent.ts):

Uses model.withStructuredOutput(BudgetAssessmentSchema) with a prompt that includes:

The LLM returns:

BudgetAssessmentSchema = z.object({
  estimatedTotal: z.number().nonnegative(),
  budgetLimit: z.number().positive(),
  withinBudget: z.boolean(),
  optimizationTips: z.array(z.string()),
});

Risk Flag: BUDGET_EXCEEDED when estimatedTotal > budgetLimit.

Decision Log Evidence:

3.10 Packing Agent

Purpose: Generate a contextual packing list based on weather forecasts and planned activities.

Implementation (src/agents/packing.agent.ts):

Uses model.withStructuredOutput(PackingListSchema) with a prompt that includes:

The LLM returns a concise, deduplicated packing list of essential items.

Decision Log Evidence:

3.11 Plan Synthesizer Agent

Purpose: Assemble all agent outputs into a final, validated plan artifact.

Implementation (src/agents/plan-synthesizer.agent.ts):

Safe Refusal Path

If BLOCKED_PROMPT_INJECTION is present in safetyFlags, the synthesizer produces a refusal summary instead of a travel plan.

Normal Path

Uses model.withStructuredOutput(PlanSynthesisSchema) to generate:

Then assembles the final artifact via buildFinalPlan() which accepts individual state fields (itineraryDraft, budgetAssessment, packingList, existingSafetyFlags) instead of the full state object, avoiding non-null assertions:

Output Guardrails

Before returning, the synthesizer runs detectUnsafeOutput() on the generated summary. Any unsafe patterns are added to safetyFlags.

The final plan is validated against FinalPlanSchema using Zod before being written to state.

3.12 Agent Dependency Injection

Agents accept dependencies to support testing with fakes and stubs:

Agent Dependencies
requirement_parser { model: ChatOpenAI }
form_completer { model: ChatOpenAI }
risk_guard { model: ChatOpenAI }
preference_agent { model: ChatOpenAI }
destination_agent { model: ChatOpenAI }
itinerary_agent { model: ChatOpenAI, searchFlights, fetchWeather }
budget_agent { model: ChatOpenAI }
packing_agent { model: ChatOpenAI }
plan_synthesizer { model: ChatOpenAI }

Default dependencies use production implementations (real OpenAI model, real Duffel/Open-Meteo APIs), while tests inject FakeStructuredChatModel and stubbed tool functions.

3.13 Decision Log

Every agent appends a DecisionLogEntry to state.decisionLog:

{
  agent: string,
  inputSummary: string,
  keyEvidence: string[],
  outputSummary: string,
  riskFlags: string[],
  timestamp: string, // ISO 8601
}

This creates an immutable, append-only audit trail of every planning decision, evidence considered, and risk flagged. The log is returned in API responses and printed in CLI output.