diff --git a/.env.example b/.env.example index fe015a8..9bf05b9 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,6 @@ OPENAI_API_KEY=sk-proj-... # Uncomment if using API key (not needed for local dev server) # TEMPORAL_API_KEY=abcdef1234567890 + +# Agent Goal Configuration +# AGENT_GOAL=goal_event_flight_invoice # (default) or goal_match_train_invoice diff --git a/README.md b/README.md index 02d1880..17bd8ea 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Temporal AI Agent -This demo shows a multi-turn conversation with an AI agent running inside a Temporal workflow. The purpose of the agent is to collect information towards a goal, running tools along the way. There's a simple DSL input for collecting information (currently set up to use mock functions to search for public events, search for flights around those events, then create a test Stripe invoice for the trip). The AI will respond with clarifications and ask for any missing information to that goal. You can configure it to use [ChatGPT 4o](https://openai.com/index/hello-gpt-4o/), [Anthropic Claude](https://www.anthropic.com/claude), [Google Gemini](https://gemini.google.com), [Deepseek-V3](https://www.deepseek.com/) or a local LLM of your choice using [Ollama](https://ollama.com). +This demo shows a multi-turn conversation with an AI agent running inside a Temporal workflow. The purpose of the agent is to collect information towards a goal, running tools along the way. There's a simple DSL input for collecting information (currently set up to use mock functions to search for public events, search for flights around those events, then create a test Stripe invoice for the trip). + +The AI will respond with clarifications and ask for any missing information to that goal. You can configure it to use [ChatGPT 4o](https://openai.com/index/hello-gpt-4o/), [Anthropic Claude](https://www.anthropic.com/claude), [Google Gemini](https://gemini.google.com), [Deepseek-V3](https://www.deepseek.com/) or a local LLM of your choice using [Ollama](https://ollama.com). [Watch the demo (5 minute YouTube video)](https://www.youtube.com/watch?v=GEXllEH2XiQ) @@ -14,6 +16,43 @@ This application uses `.env` files for configuration. Copy the [.env.example](.e cp .env.example .env ``` +### Agent Goal Configuration + +The agent can be configured to pursue different goals using the `AGENT_GOAL` environment variable in your `.env` file. + +#### Goal: Find an event in APAC, book flights to it and invoice the user for the cost +- `AGENT_GOAL=goal_event_flight_invoice` (default) - Helps users find events, book flights, and arrange train travel with invoice generation + - This is the scenario in the video above + +#### Goal: Find a Premier League match, book train tickets to it and invoice the user for the cost +- `AGENT_GOAL=goal_match_train_invoice` - Focuses on Premier League match attendance with train booking and invoice generation + - This is a new goal that is part of an upcoming conference talk + +If not specified, the agent defaults to `goal_event_flight_invoice`. Each goal comes with its own set of tools and conversation flows designed for specific use cases. You can examine `tools/goal_registry.py` to see the detailed configuration of each goal. + +See the next section for tool configuration for each goal. + +### Tool Configuration + +#### Agent Goal: goal_event_flight_invoice (default) +* The agent uses a mock function to search for events. This has zero configuration. +* By default the agent uses a mock function to search for flights. + * If you want to use the real flights API, go to `tools/search_flights.py` and replace the `search_flights` function with `search_flights_real_api` that exists in the same file. + * It's free to sign up at [RapidAPI](https://rapidapi.com/apiheya/api/sky-scrapper) + * This api might be slow to respond, so you may want to increase the start to close timeout, `TOOL_ACTIVITY_START_TO_CLOSE_TIMEOUT` in `workflows/workflow_helpers.py` +* Requires a Stripe key for the `create_invoice` tool. Set this in the `STRIPE_API_KEY` environment variable in .env + * It's free to sign up and get a key at [Stripe](https://stripe.com/) + * If you're lazy go to `tools/create_invoice.py` and replace the `create_invoice` function with the mock `create_invoice_example` that exists in the same file. + +#### Agent Goal: goal_match_train_invoice + +* Finding a match requires a key from [Football Data](https://www.football-data.org). Sign up for a free account, then see the 'My Account' page to get your API token. Set `FOOTBALL_DATA_API_KEY` to this value. +* We use a mock function to search for trains. Start the train API server to use the real API: `python thirdparty/train_api.py` +* * The train activity is 'enterprise' so it's written in C# and requires a .NET runtime. See the [.NET backend](#net-(enterprise)-backend) section for details on running it. +* Requires a Stripe key for the `create_invoice` tool. Set this in the `STRIPE_API_KEY` environment variable in .env + * It's free to sign up and get a key at [Stripe](https://stripe.com/) + * If you're lazy go to `tools/create_invoice.py` and replace the `create_invoice` function with the mock `create_invoice_example` that exists in the same file. + ### LLM Provider Configuration The agent can use OpenAI's GPT-4o, Google Gemini, Anthropic Claude, or a local LLM via Ollama. Set the `LLM_PROVIDER` environment variable in your `.env` file to choose the desired provider: @@ -35,7 +74,9 @@ To use Google Gemini: 1. Obtain a Google API key and set it in the `GOOGLE_API_KEY` environment variable in `.env`. 2. Set `LLM_PROVIDER=google` in your `.env` file. -### Option 3: Anthropic Claude +### Option 3: Anthropic Claude (recommended) + +I find that Claude Sonnet 3.5 performs better than the other hosted LLMs for this use case. To use Anthropic: @@ -61,15 +102,6 @@ To use a local LLM with Ollama: Note: I found the other (hosted) LLMs to be MUCH more reliable for this use case. However, you can switch to Ollama if desired, and choose a suitably large model if your computer has the resources. -## Agent Tools -* Requires a Rapidapi key for sky-scrapper (how we find flights). Set this in the `RAPIDAPI_KEY` environment variable in .env - * It's free to sign up and get a key at [RapidAPI](https://rapidapi.com/apiheya/api/sky-scrapper) - * If you're lazy go to `tools/search_flights.py` and replace the `get_flights` function with the mock `search_flights_example` that exists in the same file. -* Requires a Stripe key for the `create_invoice` tool. Set this in the `STRIPE_API_KEY` environment variable in .env - * It's free to sign up and get a key at [Stripe](https://stripe.com/) - * If you're lazy go to `tools/create_invoice.py` and replace the `create_invoice` function with the mock `create_invoice_example` that exists in the same file. -* Requires a key from [Football Data](https://www.football-data.org). Sign up for a free account, then see the 'My Account' page to get your API token. Set `FOOTBALL_DATA_API_KEY` to this value. - ## Configuring Temporal Connection By default, this application will connect to a local Temporal server (`localhost:7233`) in the default namespace, using the `agent-task-queue` task queue. You can override these settings in your `.env` file. @@ -115,24 +147,6 @@ poetry run uvicorn api.main:app --reload ``` Access the API at `/docs` to see the available endpoints. -### Python Search Trains API -Required to search and book trains! -```bash -poetry run python thirdparty/train_api.py - -# example url -# http://localhost:8080/api/search?from=london&to=liverpool&outbound_time=2025-04-18T09:00:00&inbound_time=2025-04-20T09:00:00 -``` - -### .NET (enterprise) Backend ;) -We have activities written in C# to call the train APIs. -```bash -cd enterprise -dotnet build # ensure you brew install dotnet@8 first! -dotnet run -``` -If you're running your train API above on a different host/port then change the API URL in `Program.cs`. - ### React UI Start the frontend: ```bash @@ -142,29 +156,36 @@ npx vite ``` Access the UI at `http://localhost:5173` +### Python Search Trains API +> Agent Goal: goal_match_train_invoice only + +Required to search and book trains! +```bash +poetry run python thirdparty/train_api.py + +# example url +# http://localhost:8080/api/search?from=london&to=liverpool&outbound_time=2025-04-18T09:00:00&inbound_time=2025-04-20T09:00:00 +``` + +### .NET (enterprise) Backend ;) +> Agent Goal: goal_match_train_invoice only + +We have activities written in C# to call the train APIs. +```bash +cd enterprise +dotnet build # ensure you brew install dotnet@8 first! +dotnet run +``` +If you're running your train API above on a different host/port then change the API URL in `Program.cs`. Otherwise, be sure to run it using `python thirdparty/train_api.py`. + ## Customizing the Agent - `tool_registry.py` contains the mapping of tool names to tool definitions (so the AI understands how to use them) - `goal_registry.py` contains descriptions of goals and the tools used to achieve them - The tools themselves are defined in their own files in `/tools` - Note the mapping in `tools/__init__.py` to each tool -- See main.py where some tool-specific logic is defined (todo, move this to the tool definition) ## TODO -- I should prove this out with other tool definitions outside of the event/flight search case (take advantage of my nice DSL). -- Currently hardcoded to the Temporal dev server at localhost:7233. Need to support options incl Temporal Cloud. - In a prod setting, I would need to ensure that payload data is stored separately (e.g. in S3 or a noSQL db - the claim-check pattern), or otherwise 'garbage collected'. Without these techniques, long conversations will fill up the workflow's conversation history, and start to breach Temporal event history payload limits. - Continue-as-new shouldn't be a big consideration for this use case (as it would take many conversational turns to trigger). Regardless, I should ensure that it's able to carry the agent state over to the new workflow execution. - Perhaps the UI should show when the LLM response is being retried (i.e. activity retry attempt because the LLM provided bad output) -- Tests would be nice! - -# TODO for this branch -## Agent -- We'll have to figure out which matches are where. No use going to Manchester for a match that isn't there. -- The use of `###` in prompts I want excluded from the conversation history is a bit of a hack. - -## UI -- Possibly need a 'worker down' type of message? I think I already have one when queries fail - -## Validator function -- Probably keep data types, but move the activity and workflow code for the demo -- Probably don't need the validator function if its the result from a tool call or confirmation step \ No newline at end of file +- Tests would be nice! \ No newline at end of file diff --git a/api/main.py b/api/main.py index 128c1f7..81ce54d 100644 --- a/api/main.py +++ b/api/main.py @@ -3,16 +3,30 @@ from typing import Optional from temporalio.client import Client from temporalio.exceptions import TemporalError from temporalio.api.enums.v1 import WorkflowExecutionStatus +from dotenv import load_dotenv +import os from workflows.agent_goal_workflow import AgentGoalWorkflow from models.data_types import CombinedInput, AgentGoalWorkflowParams -from tools.goal_registry import goal_match_train_invoice +from tools.goal_registry import goal_match_train_invoice, goal_event_flight_invoice from fastapi.middleware.cors import CORSMiddleware from shared.config import get_temporal_client, TEMPORAL_TASK_QUEUE app = FastAPI() temporal_client: Optional[Client] = None +# Load environment variables +load_dotenv() + +def get_agent_goal(): + """Get the agent goal from environment variables.""" + goal_name = os.getenv("AGENT_GOAL", "goal_match_train_invoice") + goals = { + "goal_match_train_invoice": goal_match_train_invoice, + "goal_event_flight_invoice": goal_event_flight_invoice + } + return goals.get(goal_name, goal_event_flight_invoice) + @app.on_event("startup") async def startup_event(): @@ -92,10 +106,10 @@ async def get_conversation_history(): @app.post("/send-prompt") async def send_prompt(prompt: str): - # Create combined input + # Create combined input with goal from environment combined_input = CombinedInput( tool_params=AgentGoalWorkflowParams(None, None), - agent_goal=goal_match_train_invoice, + agent_goal=get_agent_goal(), ) workflow_id = "agent-workflow" @@ -139,10 +153,13 @@ async def end_chat(): @app.post("/start-workflow") async def start_workflow(): + # Get the configured goal + agent_goal = get_agent_goal() + # Create combined input combined_input = CombinedInput( tool_params=AgentGoalWorkflowParams(None, None), - agent_goal=goal_match_train_invoice, + agent_goal=agent_goal, ) workflow_id = "agent-workflow" @@ -154,9 +171,9 @@ async def start_workflow(): id=workflow_id, task_queue=TEMPORAL_TASK_QUEUE, start_signal="user_prompt", - start_signal_args=["### " + goal_match_train_invoice.starter_prompt], + start_signal_args=["### " + agent_goal.starter_prompt], ) return { - "message": f"Workflow started with goal's starter prompt: {goal_match_train_invoice.starter_prompt}." + "message": f"Workflow started with goal's starter prompt: {agent_goal.starter_prompt}." } diff --git a/tools/__init__.py b/tools/__init__.py index 93aa8ef..37672c7 100644 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -3,6 +3,7 @@ from .search_flights import search_flights from .search_trains import search_trains from .search_trains import book_trains from .create_invoice import create_invoice +from .find_events import find_events def get_handler(tool_name: str): @@ -16,5 +17,7 @@ def get_handler(tool_name: str): return book_trains if tool_name == "CreateInvoice": return create_invoice + if tool_name == "FindEvents": + return find_events raise ValueError(f"Unknown tool: {tool_name}") diff --git a/tools/data/find_events_data.json b/tools/data/find_events_data.json new file mode 100644 index 0000000..21de846 --- /dev/null +++ b/tools/data/find_events_data.json @@ -0,0 +1,252 @@ +{ + "Melbourne": [ + { + "eventName": "Australian Open", + "dateFrom": "2025-01-13", + "dateTo": "2025-01-26", + "description": "A two-week Grand Slam tennis tournament featuring the world's top players, accompanied by various entertainment options including live music and family-friendly activities." + }, + { + "eventName": "Melbourne International Comedy Festival", + "dateFrom": "2025-03-26", + "dateTo": "2025-04-20", + "description": "One of the world's largest comedy festivals, showcasing stand-up, cabaret, theatre, and street performances across numerous city venues." + }, + { + "eventName": "Melbourne International Film Festival (MIFF)", + "dateFrom": "2025-08-07", + "dateTo": "2025-08-23", + "description": "Established in 1952, MIFF presents a diverse selection of Australian and international films, including features, documentaries, and shorts." + }, + { + "eventName": "Melbourne Fringe Festival", + "dateFrom": "2025-09-17", + "dateTo": "2025-10-04", + "description": "An open-access arts festival featuring a wide array of art forms such as theatre, comedy, music, and digital art across various venues." + }, + { + "eventName": "Moomba Festival", + "dateFrom": "2025-03-07", + "dateTo": "2025-03-10", + "description": "Australia's largest free community festival, celebrated over four days during the Labour Day long weekend, including a parade, live music, fireworks, and the famous Birdman Rally along the Yarra River." + }, + { + "eventName": "White Night Melbourne", + "dateFrom": "2025-08-22", + "dateTo": "2025-08-24", + "description": "A dusk-to-dawn arts and cultural festival transforming the city with light installations, projections, music, and performances." + }, + { + "eventName": "Melbourne Food and Wine Festival", + "dateFrom": "2025-03-19", + "dateTo": "2025-03-29", + "description": "A celebration of Victoria's culinary scene, featuring food and wine events, masterclasses, and dining experiences." + } + ], + "Sydney": [ + { + "eventName": "Sydney Gay and Lesbian Mardi Gras", + "dateFrom": "2025-02-14", + "dateTo": "2025-03-01", + "description": "One of the largest LGBTQ+ festivals globally, featuring a vibrant parade, parties, and cultural events celebrating diversity and inclusion." + }, + { + "eventName": "Vivid Sydney", + "dateFrom": "2025-05-22", + "dateTo": "2025-06-13", + "description": "An annual festival of light, music, and ideas, transforming the city with mesmerizing light installations and projections." + }, + { + "eventName": "Sydney Festival", + "dateFrom": "2025-01-08", + "dateTo": "2025-01-26", + "description": "A major arts festival presenting a diverse program of theatre, dance, music, and visual arts across the city." + }, + { + "eventName": "Sculpture by the Sea, Bondi", + "dateFrom": "2025-10-23", + "dateTo": "2025-11-09", + "description": "An outdoor sculpture exhibition along the Bondi to Tamarama coastal walk, showcasing works by Australian and international artists." + }, + { + "eventName": "Sydney Writers' Festival", + "dateFrom": "2025-04-27", + "dateTo": "2025-05-03", + "description": "An annual literary festival featuring talks, panel discussions, and workshops with acclaimed authors and thinkers." + }, + { + "eventName": "Sydney Film Festival", + "dateFrom": "2025-06-04", + "dateTo": "2025-06-15", + "description": "One of the longest-running film festivals in the world, showcasing a diverse selection of local and international films." + } + ], + "Auckland": [ + { + "eventName": "Pasifika Festival", + "dateFrom": "2025-03-08", + "dateTo": "2025-03-09", + "description": "The largest Pacific Islands-themed festival globally, celebrating the diverse cultures of the Pacific with traditional cuisine, performances, and arts." + }, + { + "eventName": "Auckland Arts Festival", + "dateFrom": "2025-03-11", + "dateTo": "2025-03-29", + "description": "A biennial multi-arts festival showcasing local and international artists in theatre, dance, music, and visual arts." + }, + { + "eventName": "Auckland Writers Festival", + "dateFrom": "2025-05-13", + "dateTo": "2025-05-18", + "description": "An annual event bringing together international and local writers for discussions, readings, and workshops." + }, + { + "eventName": "Auckland Diwali Festival", + "dateFrom": "2025-10-26", + "dateTo": "2025-10-27", + "description": "A vibrant celebration of Indian culture and the Hindu festival of Diwali, featuring performances, food stalls, and traditional activities." + } + ], + "Brisbane": [ + { + "eventName": "Brisbane Festival", + "dateFrom": "2025-09-05", + "dateTo": "2025-09-26", + "description": "A major international arts festival featuring theatre, music, dance, and visual arts, culminating in the Riverfire fireworks display." + }, + { + "eventName": "NRL Magic Round", + "dateFrom": "2025-05-02", + "dateTo": "2025-05-04", + "description": "A rugby league extravaganza where all NRL matches for the round are played at Suncorp Stadium, attracting fans nationwide." + }, + { + "eventName": "Brisbane International Film Festival", + "dateFrom": "2025-10-01", + "dateTo": "2025-10-11", + "description": "Showcasing a curated selection of films from around the world, including premieres and special events." + }, + { + "eventName": "Brisbane Comedy Festival", + "dateFrom": "2025-02-22", + "dateTo": "2025-03-24", + "description": "A month-long comedy festival featuring local and international comedians in stand-up, sketch, and improv performances." + }, + { + "eventName": "Brisbane Writers Festival", + "dateFrom": "2025-09-05", + "dateTo": "2025-09-08", + "description": "An annual literary festival celebrating books, writing, and ideas with author talks, panel discussions, and workshops." + }, + { + "eventName": "Brisbane Asia Pacific Film Festival", + "dateFrom": "2025-11-29", + "dateTo": "2025-12-08", + "description": "Showcasing the best cinema from the Asia Pacific region, including features, documentaries, and short films." + } + ], + "Perth": [ + { + "eventName": "Perth Festival", + "dateFrom": "2025-02-07", + "dateTo": "2025-03-01", + "description": "Australia's longest-running cultural festival, offering a diverse program of music, theatre, dance, literature, and visual arts." + }, + { + "eventName": "Fringe World Festival", + "dateFrom": "2025-01-16", + "dateTo": "2025-02-15", + "description": "One of the largest fringe festivals globally, featuring a vast array of performances including comedy, cabaret, theatre, and street arts." + }, + { + "eventName": "Sculpture by the Sea", + "dateFrom": "2025-03-06", + "dateTo": "2025-03-23", + "description": "An annual outdoor sculpture exhibition along Cottesloe Beach, showcasing works from Australian and international artists." + }, + { + "eventName": "Revelation Perth International Film Festival", + "dateFrom": "2025-07-03", + "dateTo": "2025-07-13", + "description": "A showcase of independent cinema, featuring a diverse selection of films, documentaries, and short films." + }, + { + "eventName": "Perth Comedy Festival", + "dateFrom": "2025-04-22", + "dateTo": "2025-05-19", + "description": "A month-long comedy festival featuring local and international comedians in stand-up, sketch, and improv performances." + } + ], + "Adelaide": [ + { + "eventName": "Adelaide Festival", + "dateFrom": "2025-02-28", + "dateTo": "2025-03-15", + "description": "A premier arts festival offering a rich program of theatre, music, dance, and visual arts from renowned international and local artists." + }, + { + "eventName": "Adelaide Fringe", + "dateFrom": "2025-02-14", + "dateTo": "2025-03-15", + "description": "The largest open-access arts festival in the Southern Hemisphere, featuring thousands of performances across various genres and venues." + }, + { + "eventName": "SALA Festival", + "dateFrom": "2025-08-01", + "dateTo": "2025-08-31", + "description": "South Australia's largest visual arts festival, showcasing the work of local artists in exhibitions, workshops, and events." + }, + { + "eventName": "OzAsia Festival", + "dateFrom": "2025-09-25", + "dateTo": "2025-10-11", + "description": "A celebration of Asian arts and culture, featuring performances, exhibitions, and events from across the region." + }, + { + "eventName": "Adelaide Film Festival", + "dateFrom": "2025-10-16", + "dateTo": "2025-10-26", + "description": "Showcasing a diverse selection of Australian and international films, including features, documentaries, and shorts." + }, + { + "eventName": "Adelaide Writers' Week", + "dateFrom": "2025-03-01", + "dateTo": "2025-03-06", + "description": "An annual literary festival featuring talks, panel discussions, and readings by acclaimed authors and thinkers." + } + ], + "Wellington": [ + { + "eventName": "New Zealand Festival of the Arts", + "dateFrom": "2025-02-21", + "dateTo": "2025-03-15", + "description": "The nation's largest celebration of contemporary arts and culture, featuring a diverse range of performances and exhibitions across various venues in Wellington.", + "url": "https://www.festival.nz/" + }, + { + "eventName": "Wellington Jazz Festival", + "dateFrom": "2025-06-05", + "dateTo": "2025-06-09", + "description": "A five-day festival showcasing local and international jazz musicians in concerts, workshops, and community events.", + "url": "https://www.jazzfestival.co.nz/" + }, + { + "eventName": "Wellington on a Plate", + "dateFrom": "2025-08-01", + "dateTo": "2025-08-16", + "description": "A culinary festival celebrating the city's food and beverage industry with special menus, events, and culinary experiences." + }, + { + "eventName": "CubaDupa", + "dateFrom": "2025-03-28", + "dateTo": "2025-03-29", + "description": "A vibrant street festival in Wellington's Cuba Street, featuring music, dance, street performers, and food stalls." + }, + { + "eventName": "Wellington Pasifika Festival", + "dateFrom": "2025-01-18", + "dateTo": "2025-01-19", + "description": "A celebration of Pacific Island culture with traditional performances, food stalls, and arts and crafts." + } + ] + } \ No newline at end of file diff --git a/tools/find_events.py b/tools/find_events.py new file mode 100644 index 0000000..51f3d42 --- /dev/null +++ b/tools/find_events.py @@ -0,0 +1,64 @@ +from datetime import datetime +from pathlib import Path +import json + + +def find_events(args: dict) -> dict: + search_city = args.get("city", "").lower() + search_month = args.get("month", "").capitalize() + + file_path = Path(__file__).resolve().parent / "data" / "find_events_data.json" + if not file_path.exists(): + return {"error": "Data file not found."} + + try: + month_number = datetime.strptime(search_month, "%B").month + except ValueError: + return {"error": "Invalid month provided."} + + # Helper to wrap months into [1..12] + def get_adjacent_months(m): + prev_m = 12 if m == 1 else (m - 1) + next_m = 1 if m == 12 else (m + 1) + return [prev_m, m, next_m] + + valid_months = get_adjacent_months(month_number) + + matching_events = [] + for city_name, events in json.load(open(file_path)).items(): + if search_city and search_city not in city_name.lower(): + continue + + for event in events: + date_from = datetime.strptime(event["dateFrom"], "%Y-%m-%d") + date_to = datetime.strptime(event["dateTo"], "%Y-%m-%d") + + # If the event's start or end month is in our valid months + if date_from.month in valid_months or date_to.month in valid_months: + # Add metadata explaining how it matches + if date_from.month == month_number or date_to.month == month_number: + month_context = "requested month" + elif ( + date_from.month == valid_months[0] + or date_to.month == valid_months[0] + ): + month_context = "previous month" + else: + month_context = "next month" + + matching_events.append( + { + "city": city_name, + "eventName": event["eventName"], + "dateFrom": event["dateFrom"], + "dateTo": event["dateTo"], + "description": event["description"], + "month": month_context, + } + ) + + # Add top-level metadata if you wish + return { + "note": f"Returning events from {search_month} plus one month either side (i.e., {', '.join(datetime(2025, m, 1).strftime('%B') for m in valid_months)}).", + "events": matching_events, + } diff --git a/tools/goal_registry.py b/tools/goal_registry.py index 5cf62c3..a7067f0 100644 --- a/tools/goal_registry.py +++ b/tools/goal_registry.py @@ -5,6 +5,7 @@ from tools.tool_registry import ( search_trains_tool, book_trains_tool, create_invoice_tool, + find_events_tool, ) goal_match_train_invoice = AgentGoal( @@ -53,46 +54,31 @@ goal_match_train_invoice = AgentGoal( # unused goal_event_flight_invoice = AgentGoal( tools=[ - search_fixtures_tool, + find_events_tool, search_flights_tool, - search_trains_tool, create_invoice_tool, ], description="Help the user gather args for these tools in order: " - "1. SearchFixtures: Search for fixtures for a team in a given month" - "2. SearchFlights: Search for a flight around the match dates" - "3. SearchTrains: Search for trains to visit somewhere before or after the match" - "4. BookTrain: Book the train tickets" - "5. CreateInvoice: Create a simple invoice for the cost of the flights and train tickets", + "1. FindEvents: Find an event to travel to " + "2. SearchFlights: search for a flight around the event dates " + "3. CreateInvoice: Create a simple invoice for the cost of that flight ", starter_prompt="Welcome me, give me a description of what you can do, then ask me for the details you need to do your job", example_conversation_history="\n ".join( [ - "user: I'd like to travel to a football match", - "agent: Sure! Let's start by finding an match you'd like to attend. I know about Premier League fixtures in the UK. Could you tell me which team and month you're interested in?", - "user: Wolves in May please", - "agent: Great! Let's find a match for Wolverhampton Wanderers FC in May.", - "user_confirmed_tool_run: ", - 'tool_result: results including {"homeTeam": "Wolverhampton Wanderers FC", "awayTeam": "Manchester United", "date": "2025-05-04"}', - "agent: Found a match! There's an away game against Manchester United on May 4 2025. Would you like to search for flights around this date?", + "user: I'd like to travel to an event", + "agent: Sure! Let's start by finding an event you'd like to attend. I know about events in Australia and New Zealand cities. Could you tell me which city and month you're interested in?", + "user: sydney in may please", + "agent: Great! Let's find an events in Sydney in May.", + "user_confirmed_tool_run: ", + "tool_result: { 'event_name': 'Vivid Sydney', 'event_date': '2023-05-01' }", + "agent: Found an event! There's Vivid Sydney on May 1 2025, ending on May 14 2025. Would you like to search for flights around these dates?", "user: Yes, please", - "agent: Let's search for flights around this date. Could you provide your departure city?", + "agent: Let's search for flights around these dates. Could you provide your departure city?", "user: San Francisco", - "agent: Thanks, searching for flights from San Francisco to the Manchester around 2025-05-03 to 2025-05-05.", + "agent: Thanks, searching for flights from San Francisco to Sydney around 2023-02-25 to 2023-02-28.", "user_confirmed_tool_run: " 'tool_result: results including {"flight_number": "CX101", "return_flight_number": "CX102", "price": 850.0}', - "agent: Found some flights! The cheapest is CX101 for $850. Would you like to go for this one?", - "user: Yes, please", - "agent: Great! So you're flying to the city the match is in, anywhere else you'd like to visit while you're there?", - "user: I'd like to visit my friend in Edinburgh after the match", - "agent: Got it! Let's search for trains from Manchester to Edinburgh and back.", - "user_confirmed_tool_run: ", - 'tool_result: results including {"train_number": "T1234", "price": 30.0}', - "agent: Found these train options! The cheapest is T1234 for $30. Would you like to go for this one?", - "user: Yes, please", - "agent: Great, lets book the train tickets", - "user_confirmed_tool_run: ", - 'tool_result: results including {"status": "success"}', - "agent: Train tickets booked! Would you like me to create an invoice for the flights and train tickets?", + "agent: Found some flights! The cheapest is CX101 for $850. Would you like to generate an invoice for this flight?", "user_confirmed_tool_run: ", 'tool_result: { "status": "success", "invoice": { "flight_number": "CX101", "amount": 850.0 }, invoiceURL: "https://example.com/invoice" }', "agent: Invoice generated! Here's the link: https://example.com/invoice", diff --git a/tools/search_flights.py b/tools/search_flights.py index f5cac85..a2335f0 100644 --- a/tools/search_flights.py +++ b/tools/search_flights.py @@ -37,10 +37,14 @@ def search_airport(query: str) -> list: try: return json.loads(data).get("data", []) except json.JSONDecodeError: + print("Error: Failed to decode JSON response") + print(f"Response: {data.decode('utf-8')}") return [] -def search_flights(args: dict) -> dict: # _realapi +def search_flights_real_api( + args: dict, +) -> dict: # rename to search_flights to use the real API """ 1) Looks up airport/city codes via search_airport. 2) Finds the first matching skyId/entityId for both origin & destination. @@ -169,7 +173,7 @@ def search_flights(args: dict) -> dict: # _realapi } -def search_flights_example(args: dict) -> dict: +def search_flights(args: dict) -> dict: """ Returns example flight search results in the requested JSON format. """ @@ -195,5 +199,19 @@ def search_flights_example(args: dict) -> dict: "return_flight_code": "NZ527", "return_operating_carrier": "Air New Zealand", }, + { + "operating_carrier": "United Airlines", + "outbound_flight_code": "UA100", + "price": 1500.00, + "return_flight_code": "UA101", + "return_operating_carrier": "United Airlines", + }, + { + "operating_carrier": "Delta Airlines", + "outbound_flight_code": "DL200", + "price": 1600.00, + "return_flight_code": "DL201", + "return_operating_carrier": "Delta Airlines", + }, ], } diff --git a/tools/tool_registry.py b/tools/tool_registry.py index 31db141..545a4a1 100644 --- a/tools/tool_registry.py +++ b/tools/tool_registry.py @@ -104,3 +104,23 @@ search_fixtures_tool = ToolDefinition( ), ], ) + +find_events_tool = ToolDefinition( + name="FindEvents", + description="Find upcoming events to travel to a given city (e.g., 'Melbourne') and a date or month. " + "It knows about events in Oceania only (e.g. major Australian and New Zealand cities). " + "It will search 1 month either side of the month provided. " + "Returns a list of events. ", + arguments=[ + ToolArgument( + name="city", + type="string", + description="Which city to search for events", + ), + ToolArgument( + name="month", + type="string", + description="The month to search for events (will search 1 month either side of the month provided)", + ), + ], +) diff --git a/workflows/workflow_helpers.py b/workflows/workflow_helpers.py index 5546a7f..35a0aa1 100644 --- a/workflows/workflow_helpers.py +++ b/workflows/workflow_helpers.py @@ -5,7 +5,10 @@ from temporalio.exceptions import ActivityError from temporalio.common import RetryPolicy from models.data_types import ConversationHistory, ToolPromptInput -from prompts.agent_prompt_generators import generate_missing_args_prompt, generate_tool_completion_prompt +from prompts.agent_prompt_generators import ( + generate_missing_args_prompt, + generate_tool_completion_prompt, +) from shared.config import TEMPORAL_LEGACY_TASK_QUEUE # Constants from original file @@ -14,12 +17,13 @@ TOOL_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT = timedelta(minutes=30) LLM_ACTIVITY_START_TO_CLOSE_TIMEOUT = timedelta(seconds=10) LLM_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT = timedelta(minutes=30) + async def handle_tool_execution( current_tool: str, tool_data: Dict[str, Any], tool_results: list, add_message_callback: callable, - prompt_queue: Deque[str] + prompt_queue: Deque[str], ) -> None: """Execute a tool after confirmation and handle its result.""" workflow.logger.info(f"Confirmed. Proceeding with tool: {current_tool}") @@ -50,11 +54,12 @@ async def handle_tool_execution( add_message_callback("tool_result", dynamic_result) prompt_queue.append(generate_tool_completion_prompt(current_tool, dynamic_result)) + async def handle_missing_args( current_tool: str, args: Dict[str, Any], tool_data: Dict[str, Any], - prompt_queue: Deque[str] + prompt_queue: Deque[str], ) -> bool: """Check for missing arguments and handle them if found.""" missing_args = [key for key, value in args.items() if value is None] @@ -69,13 +74,15 @@ async def handle_missing_args( return True return False + def format_history(conversation_history: ConversationHistory) -> str: """Format the conversation history into a single string.""" - return " ".join( - str(msg["response"]) for msg in conversation_history["messages"] - ) + return " ".join(str(msg["response"]) for msg in conversation_history["messages"]) -def prompt_with_history(conversation_history: ConversationHistory, prompt: str) -> tuple[str, str]: + +def prompt_with_history( + conversation_history: ConversationHistory, prompt: str +) -> tuple[str, str]: """Generate a context-aware prompt with conversation history.""" history_string = format_history(conversation_history) context_instructions = ( @@ -86,16 +93,19 @@ def prompt_with_history(conversation_history: ConversationHistory, prompt: str) ) return (context_instructions, prompt) + async def continue_as_new_if_needed( conversation_history: ConversationHistory, prompt_queue: Deque[str], agent_goal: Any, max_turns: int, - add_message_callback: callable + add_message_callback: callable, ) -> None: """Handle workflow continuation if message limit is reached.""" if len(conversation_history["messages"]) >= max_turns: - summary_context, summary_prompt = prompt_summary_with_history(conversation_history) + summary_context, summary_prompt = prompt_summary_with_history( + conversation_history + ) summary_input = ToolPromptInput( prompt=summary_prompt, context_instructions=summary_context ) @@ -104,21 +114,24 @@ async def continue_as_new_if_needed( summary_input, schedule_to_close_timeout=LLM_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT, ) - workflow.logger.info( - f"Continuing as new after {max_turns} turns." - ) + workflow.logger.info(f"Continuing as new after {max_turns} turns.") add_message_callback("conversation_summary", conversation_summary) workflow.continue_as_new( - args=[{ - "tool_params": { - "conversation_summary": conversation_summary, - "prompt_queue": prompt_queue, - }, - "agent_goal": agent_goal, - }] + args=[ + { + "tool_params": { + "conversation_summary": conversation_summary, + "prompt_queue": prompt_queue, + }, + "agent_goal": agent_goal, + } + ] ) -def prompt_summary_with_history(conversation_history: ConversationHistory) -> tuple[str, str]: + +def prompt_summary_with_history( + conversation_history: ConversationHistory, +) -> tuple[str, str]: """Generate a prompt for summarizing the conversation. Used only for continue as new of the workflow.""" history_string = format_history(conversation_history) @@ -127,4 +140,4 @@ def prompt_summary_with_history(conversation_history: ConversationHistory) -> tu "Please produce a two sentence summary of this conversation. " 'Put the summary in the format { "summary": "" }' ) - return (context_instructions, actual_prompt) \ No newline at end of file + return (context_instructions, actual_prompt)