merged old agent goal in with keynote goal

This commit is contained in:
Steve Androulakis
2025-02-20 15:30:54 -08:00
parent ed069d9521
commit 08672d79e3
10 changed files with 500 additions and 103 deletions

View File

@@ -31,3 +31,6 @@ OPENAI_API_KEY=sk-proj-...
# Uncomment if using API key (not needed for local dev server) # Uncomment if using API key (not needed for local dev server)
# TEMPORAL_API_KEY=abcdef1234567890 # TEMPORAL_API_KEY=abcdef1234567890
# Agent Goal Configuration
# AGENT_GOAL=goal_event_flight_invoice # (default) or goal_match_train_invoice

111
README.md
View File

@@ -1,6 +1,8 @@
# Temporal AI Agent # 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) [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 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 ### 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: 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`. 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. 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: 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. 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 ## 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. 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. 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 ### React UI
Start the frontend: Start the frontend:
```bash ```bash
@@ -142,29 +156,36 @@ npx vite
``` ```
Access the UI at `http://localhost:5173` 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 ## Customizing the Agent
- `tool_registry.py` contains the mapping of tool names to tool definitions (so the AI understands how to use them) - `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 - `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` - The tools themselves are defined in their own files in `/tools`
- Note the mapping in `tools/__init__.py` to each tool - 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 ## 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. - 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. - 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) - 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! - 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

View File

@@ -3,16 +3,30 @@ from typing import Optional
from temporalio.client import Client from temporalio.client import Client
from temporalio.exceptions import TemporalError from temporalio.exceptions import TemporalError
from temporalio.api.enums.v1 import WorkflowExecutionStatus from temporalio.api.enums.v1 import WorkflowExecutionStatus
from dotenv import load_dotenv
import os
from workflows.agent_goal_workflow import AgentGoalWorkflow from workflows.agent_goal_workflow import AgentGoalWorkflow
from models.data_types import CombinedInput, AgentGoalWorkflowParams 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 fastapi.middleware.cors import CORSMiddleware
from shared.config import get_temporal_client, TEMPORAL_TASK_QUEUE from shared.config import get_temporal_client, TEMPORAL_TASK_QUEUE
app = FastAPI() app = FastAPI()
temporal_client: Optional[Client] = None 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") @app.on_event("startup")
async def startup_event(): async def startup_event():
@@ -92,10 +106,10 @@ async def get_conversation_history():
@app.post("/send-prompt") @app.post("/send-prompt")
async def send_prompt(prompt: str): async def send_prompt(prompt: str):
# Create combined input # Create combined input with goal from environment
combined_input = CombinedInput( combined_input = CombinedInput(
tool_params=AgentGoalWorkflowParams(None, None), tool_params=AgentGoalWorkflowParams(None, None),
agent_goal=goal_match_train_invoice, agent_goal=get_agent_goal(),
) )
workflow_id = "agent-workflow" workflow_id = "agent-workflow"
@@ -139,10 +153,13 @@ async def end_chat():
@app.post("/start-workflow") @app.post("/start-workflow")
async def start_workflow(): async def start_workflow():
# Get the configured goal
agent_goal = get_agent_goal()
# Create combined input # Create combined input
combined_input = CombinedInput( combined_input = CombinedInput(
tool_params=AgentGoalWorkflowParams(None, None), tool_params=AgentGoalWorkflowParams(None, None),
agent_goal=goal_match_train_invoice, agent_goal=agent_goal,
) )
workflow_id = "agent-workflow" workflow_id = "agent-workflow"
@@ -154,9 +171,9 @@ async def start_workflow():
id=workflow_id, id=workflow_id,
task_queue=TEMPORAL_TASK_QUEUE, task_queue=TEMPORAL_TASK_QUEUE,
start_signal="user_prompt", start_signal="user_prompt",
start_signal_args=["### " + goal_match_train_invoice.starter_prompt], start_signal_args=["### " + agent_goal.starter_prompt],
) )
return { 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}."
} }

View File

@@ -3,6 +3,7 @@ from .search_flights import search_flights
from .search_trains import search_trains from .search_trains import search_trains
from .search_trains import book_trains from .search_trains import book_trains
from .create_invoice import create_invoice from .create_invoice import create_invoice
from .find_events import find_events
def get_handler(tool_name: str): def get_handler(tool_name: str):
@@ -16,5 +17,7 @@ def get_handler(tool_name: str):
return book_trains return book_trains
if tool_name == "CreateInvoice": if tool_name == "CreateInvoice":
return create_invoice return create_invoice
if tool_name == "FindEvents":
return find_events
raise ValueError(f"Unknown tool: {tool_name}") raise ValueError(f"Unknown tool: {tool_name}")

View File

@@ -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."
}
]
}

64
tools/find_events.py Normal file
View File

@@ -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,
}

View File

@@ -5,6 +5,7 @@ from tools.tool_registry import (
search_trains_tool, search_trains_tool,
book_trains_tool, book_trains_tool,
create_invoice_tool, create_invoice_tool,
find_events_tool,
) )
goal_match_train_invoice = AgentGoal( goal_match_train_invoice = AgentGoal(
@@ -53,46 +54,31 @@ goal_match_train_invoice = AgentGoal(
# unused # unused
goal_event_flight_invoice = AgentGoal( goal_event_flight_invoice = AgentGoal(
tools=[ tools=[
search_fixtures_tool, find_events_tool,
search_flights_tool, search_flights_tool,
search_trains_tool,
create_invoice_tool, create_invoice_tool,
], ],
description="Help the user gather args for these tools in order: " description="Help the user gather args for these tools in order: "
"1. SearchFixtures: Search for fixtures for a team in a given month" "1. FindEvents: Find an event to travel to "
"2. SearchFlights: Search for a flight around the match dates" "2. SearchFlights: search for a flight around the event dates "
"3. SearchTrains: Search for trains to visit somewhere before or after the match" "3. CreateInvoice: Create a simple invoice for the cost of that flight ",
"4. BookTrain: Book the train tickets"
"5. CreateInvoice: Create a simple invoice for the cost of the flights and train tickets",
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", 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( example_conversation_history="\n ".join(
[ [
"user: I'd like to travel to a football match", "user: I'd like to travel to an event",
"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?", "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: Wolves in May please", "user: sydney in may please",
"agent: Great! Let's find a match for Wolverhampton Wanderers FC in May.", "agent: Great! Let's find an events in Sydney in May.",
"user_confirmed_tool_run: <user clicks confirm on SearchFixtures tool, passing the full team name as an input>", "user_confirmed_tool_run: <user clicks confirm on FindEvents tool>",
'tool_result: results including {"homeTeam": "Wolverhampton Wanderers FC", "awayTeam": "Manchester United", "date": "2025-05-04"}', "tool_result: { 'event_name': 'Vivid Sydney', 'event_date': '2023-05-01' }",
"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?", "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", "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", "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: <user clicks confirm on SearchFlights tool>" "user_confirmed_tool_run: <user clicks confirm on SearchFlights tool>"
'tool_result: results including {"flight_number": "CX101", "return_flight_number": "CX102", "price": 850.0}', '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?", "agent: Found some flights! The cheapest is CX101 for $850. Would you like to generate an invoice for this flight?",
"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: <user clicks confirm on SearchTrains tool>",
'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: <user clicks confirm on BookTrain tool>",
'tool_result: results including {"status": "success"}',
"agent: Train tickets booked! Would you like me to create an invoice for the flights and train tickets?",
"user_confirmed_tool_run: <user clicks confirm on CreateInvoice tool>", "user_confirmed_tool_run: <user clicks confirm on CreateInvoice tool>",
'tool_result: { "status": "success", "invoice": { "flight_number": "CX101", "amount": 850.0 }, invoiceURL: "https://example.com/invoice" }', '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", "agent: Invoice generated! Here's the link: https://example.com/invoice",

View File

@@ -37,10 +37,14 @@ def search_airport(query: str) -> list:
try: try:
return json.loads(data).get("data", []) return json.loads(data).get("data", [])
except json.JSONDecodeError: except json.JSONDecodeError:
print("Error: Failed to decode JSON response")
print(f"Response: {data.decode('utf-8')}")
return [] 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. 1) Looks up airport/city codes via search_airport.
2) Finds the first matching skyId/entityId for both origin & destination. 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. 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_flight_code": "NZ527",
"return_operating_carrier": "Air New Zealand", "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",
},
], ],
} }

View File

@@ -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)",
),
],
)

View File

@@ -5,7 +5,10 @@ from temporalio.exceptions import ActivityError
from temporalio.common import RetryPolicy from temporalio.common import RetryPolicy
from models.data_types import ConversationHistory, ToolPromptInput 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 from shared.config import TEMPORAL_LEGACY_TASK_QUEUE
# Constants from original file # 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_START_TO_CLOSE_TIMEOUT = timedelta(seconds=10)
LLM_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT = timedelta(minutes=30) LLM_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT = timedelta(minutes=30)
async def handle_tool_execution( async def handle_tool_execution(
current_tool: str, current_tool: str,
tool_data: Dict[str, Any], tool_data: Dict[str, Any],
tool_results: list, tool_results: list,
add_message_callback: callable, add_message_callback: callable,
prompt_queue: Deque[str] prompt_queue: Deque[str],
) -> None: ) -> None:
"""Execute a tool after confirmation and handle its result.""" """Execute a tool after confirmation and handle its result."""
workflow.logger.info(f"Confirmed. Proceeding with tool: {current_tool}") 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) add_message_callback("tool_result", dynamic_result)
prompt_queue.append(generate_tool_completion_prompt(current_tool, dynamic_result)) prompt_queue.append(generate_tool_completion_prompt(current_tool, dynamic_result))
async def handle_missing_args( async def handle_missing_args(
current_tool: str, current_tool: str,
args: Dict[str, Any], args: Dict[str, Any],
tool_data: Dict[str, Any], tool_data: Dict[str, Any],
prompt_queue: Deque[str] prompt_queue: Deque[str],
) -> bool: ) -> bool:
"""Check for missing arguments and handle them if found.""" """Check for missing arguments and handle them if found."""
missing_args = [key for key, value in args.items() if value is None] 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 True
return False return False
def format_history(conversation_history: ConversationHistory) -> str: def format_history(conversation_history: ConversationHistory) -> str:
"""Format the conversation history into a single string.""" """Format the conversation history into a single string."""
return " ".join( return " ".join(str(msg["response"]) for msg in conversation_history["messages"])
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.""" """Generate a context-aware prompt with conversation history."""
history_string = format_history(conversation_history) history_string = format_history(conversation_history)
context_instructions = ( context_instructions = (
@@ -86,16 +93,19 @@ def prompt_with_history(conversation_history: ConversationHistory, prompt: str)
) )
return (context_instructions, prompt) return (context_instructions, prompt)
async def continue_as_new_if_needed( async def continue_as_new_if_needed(
conversation_history: ConversationHistory, conversation_history: ConversationHistory,
prompt_queue: Deque[str], prompt_queue: Deque[str],
agent_goal: Any, agent_goal: Any,
max_turns: int, max_turns: int,
add_message_callback: callable add_message_callback: callable,
) -> None: ) -> None:
"""Handle workflow continuation if message limit is reached.""" """Handle workflow continuation if message limit is reached."""
if len(conversation_history["messages"]) >= max_turns: 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( summary_input = ToolPromptInput(
prompt=summary_prompt, context_instructions=summary_context prompt=summary_prompt, context_instructions=summary_context
) )
@@ -104,21 +114,24 @@ async def continue_as_new_if_needed(
summary_input, summary_input,
schedule_to_close_timeout=LLM_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT, schedule_to_close_timeout=LLM_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT,
) )
workflow.logger.info( workflow.logger.info(f"Continuing as new after {max_turns} turns.")
f"Continuing as new after {max_turns} turns."
)
add_message_callback("conversation_summary", conversation_summary) add_message_callback("conversation_summary", conversation_summary)
workflow.continue_as_new( workflow.continue_as_new(
args=[{ args=[
"tool_params": { {
"conversation_summary": conversation_summary, "tool_params": {
"prompt_queue": prompt_queue, "conversation_summary": conversation_summary,
}, "prompt_queue": prompt_queue,
"agent_goal": agent_goal, },
}] "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. """Generate a prompt for summarizing the conversation.
Used only for continue as new of the workflow.""" Used only for continue as new of the workflow."""
history_string = format_history(conversation_history) 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. " "Please produce a two sentence summary of this conversation. "
'Put the summary in the format { "summary": "<plain text>" }' 'Put the summary in the format { "summary": "<plain text>" }'
) )
return (context_instructions, actual_prompt) return (context_instructions, actual_prompt)