跳到正文
DEV Community· vexaloom-labs·· 3 小時前AI 評分55

智能體找到往返票價,卻未能確定回程航班

Your Agent Found a Round-Trip Fare but No Flight Home

AI 導讀

這篇教學示範如何以 Google Flights 搜尋結果建立航班建議,並分清去程選項所附的行程總價、回程選項及尚未選定的回程航班。適配器固定 returnOptionsTop 為 3、returnOptionsPerOutbound 為 1,並保留去程與回程的配對關係。文中以合成資料檢查沒有回程選項和有候選回程兩種狀態,並指出報價不等於保證的結帳總額。

正文

Your agent quotes a round-trip fare. Then the user asks, “When do we land back home?” Suddenly, that tidy flight record looks less reassuring.

Google Flights lists round trips as outbound choices priced for the whole trip. A round-trip price does not establish which return flight the traveler will take. The catch is easy to miss: returnOptionsTop defaults to 0, so actual return options require an explicit request.

Lab note 007: can we expose that distinction to an agent before it invents an arrival home? We'll use synthetic fixtures to make the evidence boundary visible.

What can the agent actually promise?

The agent's job is to find a priced outbound choice, inspect its attached returns, and explain what remains undecided.

Those are different recommendation states. A requested return date tells us what the user searched for. It doesn't supply an arrival time, an overnight connection, or a selected flight.

We'd make the execution policy concrete before writing the agent prompt. This adapter accepts traveler choices and adds application-controlled settings:

EXPOSED_FIELDS = {
    "originAirport", "destinationAirport", "outboundDate", "returnDate",
    "adults", "children", "infants", "infantsInSeat",
    "cabinClass", "maxStops", "maxPrice", "maxDurationMinutes", "sortBy",
}

def make_input(arguments):
    unexpected = set(arguments) - EXPOSED_FIELDS
    if unexpected:
        raise ValueError(f"Unsupported agent arguments: {sorted(unexpected)}")

    required = {
        "originAirport", "destinationAirport", "outboundDate", "returnDate",
    }
    if any(not arguments.get(key) for key in required):
        raise ValueError("This tool requires an explicit round-trip route and dates")

    run_input = {
        **arguments,
        "mode": "search",
        "currency": "USD",
        "country": "US",
        "language": "en",
        "maxResults": 3,
        "returnOptionsTop": 3,
        "returnOptionsPerOutbound": 1,
        "includePriceInsights": False,
    }
    return run_input

This is deliberately a round-trip tool. Requiring explicit dates also keeps a vague request from quietly becoming a search on a date the traveler never chose.

The Actor still validates its input constraints. The adapter handles a different responsibility: preventing the model from changing the application's evidence policy.

Register the tool, then enforce the policy

The MCP registration exposes the Actor:

{
  "mcpServers": {
    "flight-search": {
      "command": "npx",
      "args": [
        "-y",
        "@apify/actors-mcp-server",
        "--actors",
        "vexaloom-labs/google-flights-fare-and-itinerary-filter-scraper"
      ]
    }
  }
}

Launch the client with APIFY_TOKEN available to the server process through its environment.

That registration exposes the Actor's input surface. It doesn't enforce our narrower contract.

Your client's tool executor or gateway must accept the exposed arguments, apply make_input, execute the request, and replace the raw result with the envelope below. The Python execution cell later demonstrates that adapter path directly.

If the agent can also call the unrestricted tool, the pinned settings are merely a suggestion wearing a configuration file.

Pin the evidence budget

The useful boundary is between traveler intent and retrieval policy.

Agent controls Application pins Reason
Route and explicit dates mode This tool needs flight choices, not calendar fares.
Passenger counts and cabin currency, country, language Keep quotes tied to the application's selected market and presentation.
Stops, price ceiling, duration ceiling maxResults Bound the candidate set sent toward the prompt.
Requested ordering returnOptionsTop, returnOptionsPerOutbound Fetch return evidence consistently across the retained outbound choices.
includePriceInsights Keep this task focused on flight selection.

For this adapter, the outbound limit and return-enrichment limit match. That avoids deliberately presenting retained outbound candidates whose returns were never requested.

The return limit remains a trade-off. A small attached set can support a concrete option, but it can't support “there are no other suitable flights home.”

Pinning currency also makes a price ceiling interpretable. If the traveler requests a different currency or market, change the application policy explicitly before searching.

Keep the return attached to its outbound

The dangerous compression happens when a pipeline flattens everything into “flights.”

Keep the relationship visible:

outbound choice
├── trip-total quote
├── outbound flight details
├── return options
│   └── return flight + its price + exact-itinerary link
└── selected return: unresolved

An available return is still a candidate. Fetching it doesn't mean the traveler selected it.

This projection preserves that distinction while dropping unrelated fields:

COMPONENT_FIELDS = (
    "departureAirport", "arrivalAirport",
    "departureDateTime", "arrivalDateTime",
    "durationMinutes", "airline", "flightNumber", "stops",
)

def flight_component(record):
    return {
        key: record[key]
        for key in COMPONENT_FIELDS
        if key in record
    }

def shape_choice(record, currency):
    returns = record.get("returnOptions", [])
    if not isinstance(returns, list) or any(
        not isinstance(option, dict) for option in returns
    ):
        raise ValueError("Unexpected return-option structure")

    return_options = [
        {
            "return_flight": flight_component(option),
            "trip_total": {
                "amount": option.get("price"),
                "currency": currency,
            },
            "booking_url": option.get("bookingUrl"),
        }
        for option in returns
    ]

    return {
        "state": (
            "return_options_available"
            if return_options else "priced_outbound_choice"
        ),
        "outbound": flight_component(record),
        "quoted_trip_total": {
            "amount": record.get("price"),
            "currency": currency,
        },
        "return_options": return_options,
        "selected_return": None,
        "outbound_choices_url": record.get("googleFlightsUrl"),
    }

Names such as selected_return and quoted_trip_total belong to our application envelope. They make the scope explicit for the model.

The root durationMinutes stays inside outbound. A return's duration stays inside that return. Adding them together would still miss any time spent at the destination, so “total trip duration” is an especially unhelpful label here.

We also retain each return option's own price. If that price is absent, the application should leave the paired quote unresolved rather than borrow the outbound choice's quote.

The links carry different promises too. googleFlightsUrl opens the selected outbound and its return choices. A return option's bookingUrl opens the exact itinerary. Neither link means a purchase happened.

Check the claim with synthetic fixtures

The useful regression check is whether adding return evidence changes the permitted answer without silently selecting anything.

Example fixtures below use placeholder prices and links; they're synthetic records, not collected fares.

outbound_fixture = {
    "price": "Example trip-total fare",
    "departureAirport": "JFK",
    "arrivalAirport": "LHR",
    "googleFlightsUrl": "Example outbound-choices link",
}

paired_fixture = {
    **outbound_fixture,
    "returnOptions": [
        {
            "recordType": "returnOption",
            "price": "Example paired trip-total fare",
            "departureAirport": "LHR",
            "arrivalAirport": "JFK",
            "arrivalDateTime": "2026-12-10T18:00:00",
            "bookingUrl": "Example exact-itinerary link",
        }
    ],
}

outbound_evidence = shape_choice(outbound_fixture, "USD")
paired_evidence = shape_choice(paired_fixture, "USD")

assert outbound_evidence["state"] == "priced_outbound_choice"
assert outbound_evidence["return_options"] == []
assert paired_evidence["state"] == "return_options_available"
assert paired_evidence["selected_return"] is None
assert "arrivalDateTime" in (
    paired_evidence["return_options"][0]["return_flight"]
)

For the outbound fixture, the permitted answer is: “There's a trip-total quote attached to this outbound choice; the return flight isn't specified.”

For the paired fixture: “An attached return option has an arrival time; choosing that option would establish which arrival we're discussing.”

That conditional wording matters. “You land at…” turns an available option into a decision the user never made.

Spend characters on complete choices

Context trimming should preserve the pairing and the uncertainty state together.

Cutting serialized JSON at an arbitrary boundary can remove the return evidence while leaving an attractive price near the front. The model gets the sales pitch and loses the qualification. Excellent compression, unfortunate product.

This execution path packs whole application objects under a character budget. It also marks omitted choices explicitly:

import json
import os

from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])
prepared_input = make_input({
    "originAirport": "JFK",
    "destinationAirport": "LHR",
    "outboundDate": "2026-12-01",
    "returnDate": "2026-12-10",
    "adults": 1,
    "cabinClass": "economy",
    "maxStops": "nonstop",
    "sortBy": "cheapest",
})

run = client.actor(
    "vexaloom-labs/google-flights-fare-and-itinerary-filter-scraper"
).call(run_input=prepared_input)

if run["status"] != "SUCCEEDED":
    raise RuntimeError(f"Flight search ended with {run['status']}")

items = client.dataset(run["defaultDatasetId"]).list_items().items

character_budget = 100000
envelope = {"choices": [], "context_omitted_choices": False}

for item in items:
    choice = shape_choice(item, prepared_input["currency"])
    candidate = {
        "choices": [*envelope["choices"], choice],
        "context_omitted_choices": False,
    }
    serialized = json.dumps(candidate, ensure_ascii=False)
    if len(serialized) > character_budget:
        envelope["context_omitted_choices"] = True
        break
    envelope = candidate

agent_context = json.dumps(envelope, ensure_ascii=False)

This is a character guard, not a token estimate. Set it conservatively for your model and reserve room for the conversation, instructions, and answer.

If the envelope can't hold a choice, return that limitation as a tool-level outcome. An empty packed list must not become “no flights available.”

Likewise, a failed call should remain a retrieval failure. Turning it into an empty result gives the agent a clean-looking falsehood.

Where the confident answer breaks

The worked example is our Google Flights Fare & Itinerary Filter Scraper. The reusable lesson is to preserve what each field actually establishes.

Several tempting shortcuts fail:

  • Requesting more root rows: maxResults caps results; it doesn't request return enrichment.
  • Reading returnDate as an arrival: it's a search input, not evidence of when the traveler reaches home.
  • Flattening return options: a return detached from its parent loses the pairing that makes the recommendation meaningful.
  • Calling a deep link booking confirmation: the booking-options panel, including sellers, fare brands, and bag fees, is outside the returned data.
  • Calling the cheapest retained option the cheapest available: the public page provides a subset of the full board.

Prices are snapshots and can change before purchase. Even a selected pair therefore supports an itinerary recommendation with a quoted fare, not a guaranteed checkout total.

We'd ship the search tool with recommendation state enforced by the application. An unresolved return should survive every transformation into the prompt.

Reproduce the boundary

  • Configure the MCP server and provide its token through the environment.
  • Route agent requests through the adapter, then return the shaped envelope.
  • Use the synthetic fixtures to check the missing-return and available-return states.
  • For a live search, submit this exact Actor input. Then ask the agent when the traveler lands home and inspect which return evidence it cites.
{
  "originAirport": "JFK",
  "destinationAirport": "LHR",
  "outboundDate": "2026-12-01",
  "returnDate": "2026-12-10",
  "adults": 1,
  "cabinClass": "economy",
  "maxStops": "nonstop",
  "sortBy": "cheapest",
  "mode": "search",
  "currency": "USD",
  "country": "US",
  "language": "en",
  "maxResults": 3,
  "returnOptionsTop": 3,
  "returnOptionsPerOutbound": 1,
  "includePriceInsights": false
}

來源:DEV Community · dev.to