Merge pull request #12 from steveandroulakis/keynote-main

Merged keynote-main branch in for dual agent functionality
This commit is contained in:
Steve Androulakis
2025-02-20 15:34:19 -08:00
committed by GitHub
42 changed files with 2422 additions and 617 deletions

View File

@@ -1,5 +1,6 @@
RAPIDAPI_KEY=9df2cb5... RAPIDAPI_KEY=9df2cb5...
RAPIDAPI_HOST=sky-scrapper.p.rapidapi.com RAPIDAPI_HOST=sky-scrapper.p.rapidapi.com
FOOTBALL_DATA_API_KEY=....
STRIPE_API_KEY=sk_test_51J... STRIPE_API_KEY=sk_test_51J...
@@ -30,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

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,14 +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.
## 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.
@@ -123,16 +156,35 @@ 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)

View File

@@ -1,4 +1,3 @@
from dataclasses import dataclass
from temporalio import activity from temporalio import activity
from ollama import chat, ChatResponse from ollama import chat, ChatResponse
from openai import OpenAI from openai import OpenAI
@@ -11,6 +10,7 @@ import google.generativeai as genai
import anthropic import anthropic
import deepseek import deepseek
from dotenv import load_dotenv from dotenv import load_dotenv
from models.data_types import ValidationInput, ValidationResult, ToolPromptInput
load_dotenv(override=True) load_dotenv(override=True)
print( print(
@@ -23,15 +23,70 @@ if os.environ.get("LLM_PROVIDER") == "ollama":
print("Using Ollama (local) model: " + os.environ.get("OLLAMA_MODEL_NAME")) print("Using Ollama (local) model: " + os.environ.get("OLLAMA_MODEL_NAME"))
@dataclass
class ToolPromptInput:
prompt: str
context_instructions: str
class ToolActivities: class ToolActivities:
@activity.defn @activity.defn
def prompt_llm(self, input: ToolPromptInput) -> dict: async def agent_validatePrompt(
self, validation_input: ValidationInput
) -> ValidationResult:
"""
Validates the prompt in the context of the conversation history and agent goal.
Returns a ValidationResult indicating if the prompt makes sense given the context.
"""
# Create simple context string describing tools and goals
tools_description = []
for tool in validation_input.agent_goal.tools:
tool_str = f"Tool: {tool.name}\n"
tool_str += f"Description: {tool.description}\n"
tool_str += "Arguments: " + ", ".join(
[f"{arg.name} ({arg.type})" for arg in tool.arguments]
)
tools_description.append(tool_str)
tools_str = "\n".join(tools_description)
# Convert conversation history to string
history_str = json.dumps(validation_input.conversation_history, indent=2)
# Create context instructions
context_instructions = f"""The agent goal and tools are as follows:
Description: {validation_input.agent_goal.description}
Available Tools:
{tools_str}
The conversation history to date is:
{history_str}"""
# Create validation prompt
validation_prompt = f"""The user's prompt is: "{validation_input.prompt}"
Please validate if this prompt makes sense given the agent goal and conversation history.
If the prompt makes sense toward the goal then validationResult should be true.
If the prompt is wildly nonsensical or makes no sense toward the goal and current conversation history then validationResult should be false.
If the response is low content such as "yes" or "that's right" then the user is probably responding to a previous prompt.
Therefore examine it in the context of the conversation history to determine if it makes sense and return true if it makes sense.
Return ONLY a JSON object with the following structure:
"validationResult": true/false,
"validationFailedReason": "If validationResult is false, provide a clear explanation to the user in the response field
about why their request doesn't make sense in the context and what information they should provide instead.
validationFailedReason should contain JSON in the format
{{
"next": "question",
"response": "[your reason here and a response to get the user back on track with the agent goal]"
}}
If validationResult is true (the prompt makes sense), return an empty dict as its value {{}}"
"""
# Call the LLM with the validation prompt
prompt_input = ToolPromptInput(
prompt=validation_prompt, context_instructions=context_instructions
)
result = self.agent_toolPlanner(prompt_input)
return ValidationResult(
validationResult=result.get("validationResult", False),
validationFailedReason=result.get("validationFailedReason", {}),
)
@activity.defn
def agent_toolPlanner(self, input: ToolPromptInput) -> dict:
llm_provider = os.environ.get("LLM_PROVIDER", "openai").lower() llm_provider = os.environ.get("LLM_PROVIDER", "openai").lower()
print(f"LLM provider: {llm_provider}") print(f"LLM provider: {llm_provider}")
@@ -119,8 +174,10 @@ class ToolActivities:
genai.configure(api_key=api_key) genai.configure(api_key=api_key)
model = genai.GenerativeModel( model = genai.GenerativeModel(
"models/gemini-2.0-flash-exp", "models/gemini-1.5-flash",
system_instruction=input.context_instructions, system_instruction=input.context_instructions
+ ". The current date is "
+ datetime.now().strftime("%B %d, %Y"),
) )
response = model.generate_content(input.prompt) response = model.generate_content(input.prompt)
response_content = response.text response_content = response.text
@@ -143,7 +200,9 @@ class ToolActivities:
response = client.messages.create( response = client.messages.create(
model="claude-3-5-sonnet-20241022", model="claude-3-5-sonnet-20241022",
max_tokens=1024, max_tokens=1024,
system=input.context_instructions, system=input.context_instructions
+ ". The current date is "
+ get_current_date_human_readable(),
messages=[ messages=[
{ {
"role": "user", "role": "user",

View File

@@ -3,20 +3,37 @@ 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.tool_workflow import ToolWorkflow from workflows.agent_goal_workflow import AgentGoalWorkflow
from models.data_types import CombinedInput, ToolWorkflowParams from models.data_types import CombinedInput, AgentGoalWorkflowParams
from tools.goal_registry import goal_event_flight_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():
global temporal_client global temporal_client
temporal_client = await get_temporal_client() temporal_client = await get_temporal_client()
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:5173"], allow_origins=["http://localhost:5173"],
@@ -62,13 +79,13 @@ async def get_conversation_history():
status_names = { status_names = {
WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_TERMINATED: "WORKFLOW_EXECUTION_STATUS_TERMINATED", WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_TERMINATED: "WORKFLOW_EXECUTION_STATUS_TERMINATED",
WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_CANCELED: "WORKFLOW_EXECUTION_STATUS_CANCELED", WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_CANCELED: "WORKFLOW_EXECUTION_STATUS_CANCELED",
WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_FAILED: "WORKFLOW_EXECUTION_STATUS_FAILED" WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_FAILED: "WORKFLOW_EXECUTION_STATUS_FAILED",
} }
failed_states = [ failed_states = [
WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_TERMINATED, WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_TERMINATED,
WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_CANCELED, WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_CANCELED,
WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_FAILED WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_FAILED,
] ]
# Check workflow status first # Check workflow status first
@@ -89,17 +106,17 @@ 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=ToolWorkflowParams(None, None), tool_params=AgentGoalWorkflowParams(None, None),
agent_goal=goal_event_flight_invoice, agent_goal=get_agent_goal(),
) )
workflow_id = "agent-workflow" workflow_id = "agent-workflow"
# Start (or signal) the workflow # Start (or signal) the workflow
await temporal_client.start_workflow( await temporal_client.start_workflow(
ToolWorkflow.run, AgentGoalWorkflow.run,
combined_input, combined_input,
id=workflow_id, id=workflow_id,
task_queue=TEMPORAL_TASK_QUEUE, task_queue=TEMPORAL_TASK_QUEUE,
@@ -132,3 +149,31 @@ async def end_chat():
print(e) print(e)
# Workflow not found; return an empty response # Workflow not found; return an empty response
return {} return {}
@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=agent_goal,
)
workflow_id = "agent-workflow"
# Start the workflow with the starter prompt from the goal
await temporal_client.start_workflow(
AgentGoalWorkflow.run,
combined_input,
id=workflow_id,
task_queue=TEMPORAL_TASK_QUEUE,
start_signal="user_prompt",
start_signal_args=["### " + agent_goal.starter_prompt],
)
return {
"message": f"Workflow started with goal's starter prompt: {agent_goal.starter_prompt}."
}

50
backup_sessions.json Normal file
View File

@@ -0,0 +1,50 @@
{
"$schema": "https://cdn.statically.io/gh/nguyenngoclongdev/cdn/main/schema/v10/terminal-keeper.json",
"theme": "tribe",
"active": "default",
"activateOnStartup": false,
"keepExistingTerminals": false,
"sessions": {
"default": [
[
{
"name": "frontend",
"autoExecuteCommands": true,
"commands": [
"cd frontend && npx vite"
]
},
{
"name": "uvicorn",
"autoExecuteCommands": true,
"commands": [
"poetry run uvicorn api.main:app --reload"
]
}
],
[
{
"name": "agent worker",
"autoExecuteCommands": true,
"commands": [
"poetry run python scripts/run_worker.py"
]
},
{
"name": "trains worker",
"autoExecuteCommands": true,
"commands": [
"poetry run python scripts/run_legacy_worker.py"
]
}
],
{
"name": "trains_api",
"autoExecuteCommands": true,
"commands": [
"poetry run python thirdparty/train_api.py"
]
}
]
}
}

2
enterprise/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
obj
bin

View File

@@ -0,0 +1,58 @@
using System.Net.Http.Json;
using System.Text.Json;
using Temporalio.Activities;
using TrainSearchWorker.Models;
namespace TrainSearchWorker.Activities;
public class TrainActivities
{
private readonly HttpClient _client;
private readonly JsonSerializerOptions _jsonOptions;
public TrainActivities(IHttpClientFactory clientFactory)
{
_client = clientFactory.CreateClient("TrainApi");
_jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
}
[Activity]
public async Task<JourneyResponse> SearchTrains(SearchTrainsRequest request)
{
var response = await _client.GetAsync(
$"api/search?from={Uri.EscapeDataString(request.From)}" +
$"&to={Uri.EscapeDataString(request.To)}" +
$"&outbound_time={Uri.EscapeDataString(request.OutboundTime)}" +
$"&return_time={Uri.EscapeDataString(request.ReturnTime)}");
response.EnsureSuccessStatusCode();
// Deserialize into JourneyResponse rather than List<Journey>
var journeyResponse = await response.Content.ReadFromJsonAsync<JourneyResponse>(_jsonOptions)
?? throw new InvalidOperationException("Received null response from API");
return journeyResponse;
}
[Activity]
public async Task<BookTrainsResponse> BookTrains(BookTrainsRequest request)
{
// Build the URL using the train IDs from the request
var url = $"api/book/{Uri.EscapeDataString(request.TrainIds)}";
// POST with no JSON body, matching the Python version
var response = await _client.PostAsync(url, null);
response.EnsureSuccessStatusCode();
// Deserialize into a BookTrainsResponse (a single object)
var bookingResponse = await response.Content.ReadFromJsonAsync<BookTrainsResponse>(_jsonOptions)
?? throw new InvalidOperationException("Received null response from API");
return bookingResponse;
}
}

View File

@@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace TrainSearchWorker.Models;
public record BookTrainsRequest
{
[JsonPropertyName("train_ids")]
public required string TrainIds { get; init; }
}

View File

@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace TrainSearchWorker.Models;
public record BookTrainsResponse
{
[JsonPropertyName("booking_reference")]
public required string BookingReference { get; init; }
// If the API now returns train_ids as an array, use List<string>
[JsonPropertyName("train_ids")]
public required List<string> TrainIds { get; init; }
[JsonPropertyName("status")]
public required string Status { get; init; }
}

View File

@@ -0,0 +1,27 @@
using System.Text.Json.Serialization;
namespace TrainSearchWorker.Models;
public record Journey
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("type")]
public required string Type { get; init; }
[JsonPropertyName("departure")]
public required string Departure { get; init; }
[JsonPropertyName("arrival")]
public required string Arrival { get; init; }
[JsonPropertyName("departure_time")]
public required string DepartureTime { get; init; }
[JsonPropertyName("arrival_time")]
public required string ArrivalTime { get; init; }
[JsonPropertyName("price")]
public required decimal Price { get; init; }
}

View File

@@ -0,0 +1,10 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace TrainSearchWorker.Models;
public record JourneyResponse
{
[JsonPropertyName("journeys")]
public List<Journey>? Journeys { get; init; }
}

View File

@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace TrainSearchWorker.Models;
public record SearchTrainsRequest
{
[JsonPropertyName("origin")]
public required string From { get; init; }
[JsonPropertyName("destination")]
public required string To { get; init; }
[JsonPropertyName("outbound_time")]
public required string OutboundTime { get; init; }
[JsonPropertyName("return_time")]
public required string ReturnTime { get; init; }
}

58
enterprise/Program.cs Normal file
View File

@@ -0,0 +1,58 @@
using Microsoft.Extensions.DependencyInjection;
using Temporalio.Client;
using Temporalio.Worker;
using TrainSearchWorker.Activities;
// Set up dependency injection
var services = new ServiceCollection();
// Add HTTP client
services.AddHttpClient("TrainApi", client =>
{
client.BaseAddress = new Uri("http://localhost:8080/");
client.DefaultRequestHeaders.Add("Accept", "application/json");
});
// Add activities
services.AddScoped<TrainActivities>();
var serviceProvider = services.BuildServiceProvider();
// Create client using the helper, which supports Temporal Cloud if environment variables are set
var client = await TemporalClientHelper.CreateClientAsync();
// Read connection details from environment or use defaults
var address = Environment.GetEnvironmentVariable("TEMPORAL_ADDRESS") ?? "localhost:7233";
var ns = Environment.GetEnvironmentVariable("TEMPORAL_NAMESPACE") ?? "default";
// Log connection details
Console.WriteLine("Starting worker...");
Console.WriteLine($"Connecting to Temporal at address: {address}");
Console.WriteLine($"Using namespace: {ns}");
// Create worker options
var options = new TemporalWorkerOptions("agent-task-queue-legacy");
// Register activities
var activities = serviceProvider.GetRequiredService<TrainActivities>();
options.AddActivity(activities.SearchTrains);
options.AddActivity(activities.BookTrains);
// Create and run worker
var worker = new TemporalWorker(client, options);
using var tokenSource = new CancellationTokenSource();
Console.CancelKeyPress += (_, eventArgs) =>
{
eventArgs.Cancel = true;
tokenSource.Cancel();
};
try
{
await worker.ExecuteAsync(tokenSource.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Worker shutting down...");
}

View File

@@ -0,0 +1,48 @@
using System;
using System.IO;
using System.Collections.Generic;
using Temporalio.Client;
public static class TemporalClientHelper
{
public static async Task<ITemporalClient> CreateClientAsync()
{
var address = Environment.GetEnvironmentVariable("TEMPORAL_ADDRESS") ?? "localhost:7233";
var ns = Environment.GetEnvironmentVariable("TEMPORAL_NAMESPACE") ?? "default";
var clientCertPath = Environment.GetEnvironmentVariable("TEMPORAL_TLS_CERT");
var clientKeyPath = Environment.GetEnvironmentVariable("TEMPORAL_TLS_KEY");
var apiKey = Environment.GetEnvironmentVariable("TEMPORAL_API_KEY");
var options = new TemporalClientConnectOptions(address)
{
Namespace = ns
};
if (!string.IsNullOrEmpty(clientCertPath) && !string.IsNullOrEmpty(clientKeyPath))
{
// mTLS authentication
options.Tls = new()
{
ClientCert = await File.ReadAllBytesAsync(clientCertPath),
ClientPrivateKey = await File.ReadAllBytesAsync(clientKeyPath),
};
}
else if (!string.IsNullOrEmpty(apiKey))
{
// API Key authentication
// TODO test
options.RpcMetadata = new Dictionary<string, string>()
{
["authorization"] = $"Bearer {apiKey}",
["temporal-namespace"] = ns
};
options.RpcMetadata = new Dictionary<string, string>()
{
["temporal-namespace"] = ns
};
options.Tls = new();
}
return await TemporalClient.ConnectAsync(options);
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Temporalio" Version="1.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
</ItemGroup>
</Project>

View File

@@ -33,7 +33,7 @@ const LLMResponse = memo(({ data, onConfirm, isLastMessage, onHeightChange }) =>
: ''; : '';
return ( return (
<div ref={responseRef} className="space-y-2"> <div ref={responseRef} className="space-y-2" style={{ whiteSpace: 'pre-line' }}>
<MessageBubble <MessageBubble
message={{ response: displayText || defaultText }} message={{ response: displayText || defaultText }}
/> />

View File

@@ -167,7 +167,7 @@ export default function App() {
try { try {
setError(INITIAL_ERROR_STATE); setError(INITIAL_ERROR_STATE);
setLoading(true); setLoading(true);
await apiService.sendMessage("I'd like to travel for an event."); await apiService.startWorkflow();
setConversation([]); setConversation([]);
setLastMessage(null); setLastMessage(null);
} catch (err) { } catch (err) {

View File

@@ -56,6 +56,26 @@ export const apiService = {
} }
}, },
async startWorkflow() {
try {
const res = await fetch(
`${API_BASE_URL}/start-workflow`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
}
);
return handleResponse(res);
} catch (error) {
throw new ApiError(
'Failed to start workflow',
error.status || 500
);
}
},
async confirm() { async confirm() {
try { try {
const res = await fetch(`${API_BASE_URL}/confirm`, { const res = await fetch(`${API_BASE_URL}/confirm`, {

View File

@@ -1,15 +1,44 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional, Deque from typing import Optional, Deque, Dict, Any, List, Union, Literal
from models.tool_definitions import AgentGoal from models.tool_definitions import AgentGoal
@dataclass @dataclass
class ToolWorkflowParams: class AgentGoalWorkflowParams:
conversation_summary: Optional[str] = None conversation_summary: Optional[str] = None
prompt_queue: Optional[Deque[str]] = None prompt_queue: Optional[Deque[str]] = None
@dataclass @dataclass
class CombinedInput: class CombinedInput:
tool_params: ToolWorkflowParams tool_params: AgentGoalWorkflowParams
agent_goal: AgentGoal agent_goal: AgentGoal
Message = Dict[str, Union[str, Dict[str, Any]]]
ConversationHistory = Dict[str, List[Message]]
NextStep = Literal["confirm", "question", "done"]
@dataclass
class ToolPromptInput:
prompt: str
context_instructions: str
@dataclass
class ValidationInput:
prompt: str
conversation_history: ConversationHistory
agent_goal: AgentGoal
@dataclass
class ValidationResult:
validationResult: bool
validationFailedReason: dict = None
def __post_init__(self):
# Initialize empty dict if None
if self.validationFailedReason is None:
self.validationFailedReason = {}

View File

@@ -20,6 +20,7 @@ class ToolDefinition:
class AgentGoal: class AgentGoal:
tools: List[ToolDefinition] tools: List[ToolDefinition]
description: str = "Description of the tools purpose and overall goal" description: str = "Description of the tools purpose and overall goal"
starter_prompt: str = "Initial prompt to start the conversation"
example_conversation_history: str = ( example_conversation_history: str = (
"Example conversation history to help the AI agent understand the context of the conversation" "Example conversation history to help the AI agent understand the context of the conversation"
) )

692
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -108,3 +108,43 @@ def generate_genai_prompt(
) )
return "\n".join(prompt_lines) return "\n".join(prompt_lines)
def generate_tool_completion_prompt(current_tool: str, dynamic_result: dict) -> str:
"""
Generates a prompt for handling tool completion and determining next steps.
Args:
current_tool: The name of the tool that just completed
dynamic_result: The result data from the tool execution
Returns:
str: A formatted prompt string for the agent to process the tool completion
"""
return (
f"### The '{current_tool}' tool completed successfully with {dynamic_result}. "
"INSTRUCTIONS: Parse this tool result as plain text, and use the system prompt containing the list of tools in sequence and the conversation history (and previous tool_results) to figure out next steps, if any. "
"You will need to use the tool_results to auto-fill arguments for subsequent tools and also to figure out if all tools have been run."
'{"next": "<question|confirm|done>", "tool": "<tool_name or null>", "args": {"<arg1>": "<value1 or null>", "<arg2>": "<value2 or null>}, "response": "<plain text (can include \\n line breaks)>"}'
"ONLY return those json keys (next, tool, args, response), nothing else."
'Next should only be "done" if all tools have been run (use the system prompt to figure that out).'
'Next should be "question" if the tool is not the last one in the sequence.'
'Next should NOT be "confirm" at this point.'
)
def generate_missing_args_prompt(current_tool: str, tool_data: dict, missing_args: list[str]) -> str:
"""
Generates a prompt for handling missing arguments for a tool.
Args:
current_tool: The name of the tool that needs arguments
tool_data: The current tool data containing the response
missing_args: List of argument names that are missing
Returns:
str: A formatted prompt string for requesting missing arguments
"""
return (
f"### INSTRUCTIONS set next='question', combine this response response='{tool_data.get('response')}' "
f"and following missing arguments for tool {current_tool}: {missing_args}. "
"Only provide a valid JSON response without any comments or metadata."
)

View File

@@ -16,7 +16,7 @@ packages = [
"Bug Tracker" = "https://github.com/temporalio/samples-python/issues" "Bug Tracker" = "https://github.com/temporalio/samples-python/issues"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = ">=3.9,<4.0" python = ">=3.10,<4.0"
temporalio = "^1.8.0" temporalio = "^1.8.0"
# Standard library modules (e.g. asyncio, collections) don't need to be added # Standard library modules (e.g. asyncio, collections) don't need to be added
@@ -31,6 +31,9 @@ stripe = "^11.4.1"
google-generativeai = "^0.8.4" google-generativeai = "^0.8.4"
anthropic = "^0.45.0" anthropic = "^0.45.0"
deepseek = "^1.0.0" deepseek = "^1.0.0"
requests = "^2.32.3"
pandas = "^2.2.3"
gtfs-kit = "^10.1.1"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pytest = "^7.3" pytest = "^7.3"

View File

@@ -1,7 +1,7 @@
import asyncio import asyncio
from workflows.tool_workflow import ToolWorkflow from workflows.agent_goal_workflow import AgentGoalWorkflow
async def main(): async def main():
@@ -10,10 +10,10 @@ async def main():
workflow_id = "agent-workflow" workflow_id = "agent-workflow"
handle = client.get_workflow_handle_for(ToolWorkflow.run, workflow_id) handle = client.get_workflow_handle_for(AgentGoalWorkflow.run, workflow_id)
# Sends a signal to the workflow # Sends a signal to the workflow
await handle.signal(ToolWorkflow.end_chat) await handle.signal(AgentGoalWorkflow.end_chat)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -1,4 +1,4 @@
from tools.find_events import find_events from tools.search_events import find_events
import json import json
# Example usage # Example usage

View File

@@ -1,7 +1,7 @@
import asyncio import asyncio
from shared.config import get_temporal_client from shared.config import get_temporal_client
from workflows.tool_workflow import ToolWorkflow from workflows.agent_goal_workflow import AgentGoalWorkflow
async def main(): async def main():
@@ -12,7 +12,7 @@ async def main():
handle = client.get_workflow_handle(workflow_id) handle = client.get_workflow_handle(workflow_id)
# Queries the workflow for the conversation history # Queries the workflow for the conversation history
history = await handle.query(ToolWorkflow.get_conversation_history) history = await handle.query(AgentGoalWorkflow.get_conversation_history)
print("Conversation History") print("Conversation History")
print(history) print(history)

View File

@@ -0,0 +1,32 @@
import asyncio
import concurrent.futures
from temporalio.worker import Worker
from activities.tool_activities import dynamic_tool_activity
from shared.config import get_temporal_client, TEMPORAL_LEGACY_TASK_QUEUE
async def main():
# Create the client
client = await get_temporal_client()
# Run the worker
with concurrent.futures.ThreadPoolExecutor(max_workers=100) as activity_executor:
worker = Worker(
client,
task_queue=TEMPORAL_LEGACY_TASK_QUEUE,
activities=[
dynamic_tool_activity,
],
activity_executor=activity_executor,
)
print(f"Starting legacy worker, connecting to task queue: {TEMPORAL_LEGACY_TASK_QUEUE}")
await worker.run()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -5,7 +5,7 @@ import concurrent.futures
from temporalio.worker import Worker from temporalio.worker import Worker
from activities.tool_activities import ToolActivities, dynamic_tool_activity from activities.tool_activities import ToolActivities, dynamic_tool_activity
from workflows.tool_workflow import ToolWorkflow from workflows.agent_goal_workflow import AgentGoalWorkflow
from shared.config import get_temporal_client, TEMPORAL_TASK_QUEUE from shared.config import get_temporal_client, TEMPORAL_TASK_QUEUE
@@ -21,9 +21,10 @@ async def main():
worker = Worker( worker = Worker(
client, client,
task_queue=TEMPORAL_TASK_QUEUE, task_queue=TEMPORAL_TASK_QUEUE,
workflows=[ToolWorkflow], workflows=[AgentGoalWorkflow],
activities=[ activities=[
activities.prompt_llm, activities.agent_validatePrompt,
activities.agent_toolPlanner,
dynamic_tool_activity, dynamic_tool_activity,
], ],
activity_executor=activity_executor, activity_executor=activity_executor,

View File

@@ -9,6 +9,7 @@ load_dotenv(override=True)
TEMPORAL_ADDRESS = os.getenv("TEMPORAL_ADDRESS", "localhost:7233") TEMPORAL_ADDRESS = os.getenv("TEMPORAL_ADDRESS", "localhost:7233")
TEMPORAL_NAMESPACE = os.getenv("TEMPORAL_NAMESPACE", "default") TEMPORAL_NAMESPACE = os.getenv("TEMPORAL_NAMESPACE", "default")
TEMPORAL_TASK_QUEUE = os.getenv("TEMPORAL_TASK_QUEUE", "agent-task-queue") TEMPORAL_TASK_QUEUE = os.getenv("TEMPORAL_TASK_QUEUE", "agent-task-queue")
TEMPORAL_LEGACY_TASK_QUEUE = os.getenv("TEMPORAL_LEGACY_TASK_QUEUE", "agent-task-queue-legacy")
# Authentication settings # Authentication settings
TEMPORAL_TLS_CERT = os.getenv("TEMPORAL_TLS_CERT", "") TEMPORAL_TLS_CERT = os.getenv("TEMPORAL_TLS_CERT", "")

24
temporal-ai-agent.sln Normal file
View File

@@ -0,0 +1,24 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrainSearchWorker", "enterprise\TrainSearchWorker.csproj", "{E415E5FE-0362-B204-B4B1-A5E60F3A436D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{E415E5FE-0362-B204-B4B1-A5E60F3A436D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E415E5FE-0362-B204-B4B1-A5E60F3A436D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E415E5FE-0362-B204-B4B1-A5E60F3A436D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E415E5FE-0362-B204-B4B1-A5E60F3A436D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {463FDBB3-0167-4747-8007-C25ADDC83630}
EndGlobalSection
EndGlobal

213
thirdparty/train_api.py vendored Normal file
View File

@@ -0,0 +1,213 @@
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import parse_qs, urlparse
import json
import time
import random
import string
def parse_datetime(datetime_str):
# Remove trailing 'Z' if present
if datetime_str.endswith("Z"):
datetime_str = datetime_str[:-1]
formats = [
"%Y-%m-%dT%H:%M", # e.g. "2025-04-18T09:00"
"%Y-%m-%dT%H:%M:%S", # e.g. "2025-04-18T09:00:00"
"%Y-%m-%d %H:%M:%S", # e.g. "2025-04-18 09:00:00"
"%Y-%m-%d", # e.g. "2025-04-11"
]
for fmt in formats:
try:
parsed = time.strptime(datetime_str, fmt)
if fmt == "%Y-%m-%d":
# Default to 9am if no time provided
hour, minute = 9, 0
else:
hour, minute = parsed.tm_hour, parsed.tm_min
return (
parsed.tm_year,
parsed.tm_mon,
parsed.tm_mday,
hour,
minute,
)
except ValueError:
continue
return None, None, None, None, None
class TrainServer(BaseHTTPRequestHandler):
def generate_journeys(self, origin, destination, out_datetime, ret_datetime):
journeys = []
# Helper to format datetime
def format_datetime(year, month, day, hour, minute):
return f"{year:04d}-{month:02d}-{day:02d}T{hour:02d}:{minute:02d}"
# Generate outbound journeys
year, month, day, hour, minute = out_datetime
for offset in [-30, 0, 30]:
# Calculate journey times
adj_minutes = minute + offset
adj_hour = hour + (adj_minutes // 60)
adj_minute = adj_minutes % 60
# Simple handling of day rollover
adj_day = day + (adj_hour // 24)
adj_hour = adj_hour % 24
# Journey takes 1-2 hours
duration = 60 + random.randint(0, 60)
arr_hour = adj_hour + (duration // 60)
arr_minute = (adj_minute + (duration % 60)) % 60
arr_day = adj_day + (arr_hour // 24)
arr_hour = arr_hour % 24
journey = {
"id": f"T{random.randint(1000, 9999)}",
"type": "outbound",
"departure": origin,
"arrival": destination,
"departure_time": format_datetime(
year, month, adj_day, adj_hour, adj_minute
),
"arrival_time": format_datetime(
year, month, arr_day, arr_hour, arr_minute
),
"price": round(30 + random.random() * 50, 2),
}
journeys.append(journey)
# Generate return journeys if return datetime provided
if ret_datetime[0] is not None:
year, month, day, hour, minute = ret_datetime
for offset in [-30, 0, 30]:
adj_minutes = minute + offset
adj_hour = hour + (adj_minutes // 60)
adj_minute = adj_minutes % 60
adj_day = day + (adj_hour // 24)
adj_hour = adj_hour % 24
duration = 60 + random.randint(0, 60)
arr_hour = adj_hour + (duration // 60)
arr_minute = (adj_minute + (duration % 60)) % 60
arr_day = adj_day + (arr_hour // 24)
arr_hour = arr_hour % 24
journey = {
"id": f"T{random.randint(1000, 9999)}",
"type": "return",
"departure": destination,
"arrival": origin,
"departure_time": format_datetime(
year, month, adj_day, adj_hour, adj_minute
),
"arrival_time": format_datetime(
year, month, arr_day, arr_hour, arr_minute
),
"price": round(30 + random.random() * 50, 2),
}
journeys.append(journey)
return journeys
def do_GET(self):
parsed_url = urlparse(self.path)
if parsed_url.path == "/api/search":
try:
params = parse_qs(parsed_url.query)
origin = params.get("from", [""])[0]
destination = params.get("to", [""])[0]
outbound_datetime = params.get("outbound_time", [""])[0]
return_datetime = params.get("return_time", [""])[0]
if not origin or not destination or not outbound_datetime:
self.send_response(400)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(
json.dumps(
{
"error": "Required parameters: 'from', 'to', and 'outbound_time'"
}
).encode("utf-8")
)
return
# Parse datetimes
out_dt = parse_datetime(outbound_datetime)
ret_dt = (
parse_datetime(return_datetime)
if return_datetime
else (None, None, None, None, None)
)
if out_dt[0] is None:
self.send_response(400)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(
json.dumps(
{"error": "Invalid datetime format. Use YYYY-MM-DDTHH:MM"}
).encode("utf-8")
)
return
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
journeys = self.generate_journeys(origin, destination, out_dt, ret_dt)
response = json.dumps({"journeys": journeys})
self.wfile.write(response.encode("utf-8"))
except Exception as e:
self.send_response(500)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"error": str(e)}).encode("utf-8"))
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
parsed_url = urlparse(self.path)
if parsed_url.path.startswith("/api/book/"):
train_ids = parsed_url.path.split("/")[-1].split(",")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
booking_ref = "BR" + "".join(
[random.choice(string.digits) for _ in range(5)]
)
response = json.dumps(
{
"booking_reference": booking_ref,
"train_ids": train_ids,
"status": "confirmed",
}
)
self.wfile.write(response.encode("utf-8"))
else:
self.send_response(404)
self.end_headers()
def run_server():
server = HTTPServer(("", 8080), TrainServer)
print("Train booking server starting on port 8080...")
server.serve_forever()
if __name__ == "__main__":
run_server()

View File

@@ -1,14 +1,23 @@
from .find_events import find_events from .search_fixtures import search_fixtures
from .search_flights import search_flights 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 .create_invoice import create_invoice
from .find_events import find_events
def get_handler(tool_name: str): def get_handler(tool_name: str):
if tool_name == "FindEvents": if tool_name == "SearchFixtures":
return find_events return search_fixtures
if tool_name == "SearchFlights": if tool_name == "SearchFlights":
return search_flights return search_flights
if tool_name == "SearchTrains":
return search_trains
if tool_name == "BookTrains":
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

@@ -42,8 +42,8 @@ def create_invoice(args: dict) -> dict:
stripe.InvoiceItem.create( stripe.InvoiceItem.create(
customer=customer_id, customer=customer_id,
amount=amount_cents, amount=amount_cents,
currency="usd", currency="gbp",
description=args.get("flightDetails", "Service Invoice"), description=args.get("tripDetails", "Service Invoice"),
) )
# Create and finalize the invoice # Create and finalize the invoice

View File

@@ -1,252 +1,252 @@
{ {
"Melbourne": [ "Melbourne": [
{ {
"eventName": "Australian Open", "eventName": "Australian Open",
"dateFrom": "2025-01-13", "dateFrom": "2025-01-13",
"dateTo": "2025-01-26", "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." "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", "eventName": "Melbourne International Comedy Festival",
"dateFrom": "2025-03-26", "dateFrom": "2025-03-26",
"dateTo": "2025-04-20", "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." "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)", "eventName": "Melbourne International Film Festival (MIFF)",
"dateFrom": "2025-08-07", "dateFrom": "2025-08-07",
"dateTo": "2025-08-23", "dateTo": "2025-08-23",
"description": "Established in 1952, MIFF presents a diverse selection of Australian and international films, including features, documentaries, and shorts." "description": "Established in 1952, MIFF presents a diverse selection of Australian and international films, including features, documentaries, and shorts."
}, },
{ {
"eventName": "Melbourne Fringe Festival", "eventName": "Melbourne Fringe Festival",
"dateFrom": "2025-09-17", "dateFrom": "2025-09-17",
"dateTo": "2025-10-04", "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." "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", "eventName": "Moomba Festival",
"dateFrom": "2025-03-07", "dateFrom": "2025-03-07",
"dateTo": "2025-03-10", "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." "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", "eventName": "White Night Melbourne",
"dateFrom": "2025-08-22", "dateFrom": "2025-08-22",
"dateTo": "2025-08-24", "dateTo": "2025-08-24",
"description": "A dusk-to-dawn arts and cultural festival transforming the city with light installations, projections, music, and performances." "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", "eventName": "Melbourne Food and Wine Festival",
"dateFrom": "2025-03-19", "dateFrom": "2025-03-19",
"dateTo": "2025-03-29", "dateTo": "2025-03-29",
"description": "A celebration of Victoria's culinary scene, featuring food and wine events, masterclasses, and dining experiences." "description": "A celebration of Victoria's culinary scene, featuring food and wine events, masterclasses, and dining experiences."
} }
], ],
"Sydney": [ "Sydney": [
{ {
"eventName": "Sydney Gay and Lesbian Mardi Gras", "eventName": "Sydney Gay and Lesbian Mardi Gras",
"dateFrom": "2025-02-14", "dateFrom": "2025-02-14",
"dateTo": "2025-03-01", "dateTo": "2025-03-01",
"description": "One of the largest LGBTQ+ festivals globally, featuring a vibrant parade, parties, and cultural events celebrating diversity and inclusion." "description": "One of the largest LGBTQ+ festivals globally, featuring a vibrant parade, parties, and cultural events celebrating diversity and inclusion."
}, },
{ {
"eventName": "Vivid Sydney", "eventName": "Vivid Sydney",
"dateFrom": "2025-05-22", "dateFrom": "2025-05-22",
"dateTo": "2025-06-13", "dateTo": "2025-06-13",
"description": "An annual festival of light, music, and ideas, transforming the city with mesmerizing light installations and projections." "description": "An annual festival of light, music, and ideas, transforming the city with mesmerizing light installations and projections."
}, },
{ {
"eventName": "Sydney Festival", "eventName": "Sydney Festival",
"dateFrom": "2025-01-08", "dateFrom": "2025-01-08",
"dateTo": "2025-01-26", "dateTo": "2025-01-26",
"description": "A major arts festival presenting a diverse program of theatre, dance, music, and visual arts across the city." "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", "eventName": "Sculpture by the Sea, Bondi",
"dateFrom": "2025-10-23", "dateFrom": "2025-10-23",
"dateTo": "2025-11-09", "dateTo": "2025-11-09",
"description": "An outdoor sculpture exhibition along the Bondi to Tamarama coastal walk, showcasing works by Australian and international artists." "description": "An outdoor sculpture exhibition along the Bondi to Tamarama coastal walk, showcasing works by Australian and international artists."
}, },
{ {
"eventName": "Sydney Writers' Festival", "eventName": "Sydney Writers' Festival",
"dateFrom": "2025-04-27", "dateFrom": "2025-04-27",
"dateTo": "2025-05-03", "dateTo": "2025-05-03",
"description": "An annual literary festival featuring talks, panel discussions, and workshops with acclaimed authors and thinkers." "description": "An annual literary festival featuring talks, panel discussions, and workshops with acclaimed authors and thinkers."
}, },
{ {
"eventName": "Sydney Film Festival", "eventName": "Sydney Film Festival",
"dateFrom": "2025-06-04", "dateFrom": "2025-06-04",
"dateTo": "2025-06-15", "dateTo": "2025-06-15",
"description": "One of the longest-running film festivals in the world, showcasing a diverse selection of local and international films." "description": "One of the longest-running film festivals in the world, showcasing a diverse selection of local and international films."
} }
], ],
"Auckland": [ "Auckland": [
{ {
"eventName": "Pasifika Festival", "eventName": "Pasifika Festival",
"dateFrom": "2025-03-08", "dateFrom": "2025-03-08",
"dateTo": "2025-03-09", "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." "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", "eventName": "Auckland Arts Festival",
"dateFrom": "2025-03-11", "dateFrom": "2025-03-11",
"dateTo": "2025-03-29", "dateTo": "2025-03-29",
"description": "A biennial multi-arts festival showcasing local and international artists in theatre, dance, music, and visual arts." "description": "A biennial multi-arts festival showcasing local and international artists in theatre, dance, music, and visual arts."
}, },
{ {
"eventName": "Auckland Writers Festival", "eventName": "Auckland Writers Festival",
"dateFrom": "2025-05-13", "dateFrom": "2025-05-13",
"dateTo": "2025-05-18", "dateTo": "2025-05-18",
"description": "An annual event bringing together international and local writers for discussions, readings, and workshops." "description": "An annual event bringing together international and local writers for discussions, readings, and workshops."
}, },
{ {
"eventName": "Auckland Diwali Festival", "eventName": "Auckland Diwali Festival",
"dateFrom": "2025-10-26", "dateFrom": "2025-10-26",
"dateTo": "2025-10-27", "dateTo": "2025-10-27",
"description": "A vibrant celebration of Indian culture and the Hindu festival of Diwali, featuring performances, food stalls, and traditional activities." "description": "A vibrant celebration of Indian culture and the Hindu festival of Diwali, featuring performances, food stalls, and traditional activities."
} }
], ],
"Brisbane": [ "Brisbane": [
{ {
"eventName": "Brisbane Festival", "eventName": "Brisbane Festival",
"dateFrom": "2025-09-05", "dateFrom": "2025-09-05",
"dateTo": "2025-09-26", "dateTo": "2025-09-26",
"description": "A major international arts festival featuring theatre, music, dance, and visual arts, culminating in the Riverfire fireworks display." "description": "A major international arts festival featuring theatre, music, dance, and visual arts, culminating in the Riverfire fireworks display."
}, },
{ {
"eventName": "NRL Magic Round", "eventName": "NRL Magic Round",
"dateFrom": "2025-05-02", "dateFrom": "2025-05-02",
"dateTo": "2025-05-04", "dateTo": "2025-05-04",
"description": "A rugby league extravaganza where all NRL matches for the round are played at Suncorp Stadium, attracting fans nationwide." "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", "eventName": "Brisbane International Film Festival",
"dateFrom": "2025-10-01", "dateFrom": "2025-10-01",
"dateTo": "2025-10-11", "dateTo": "2025-10-11",
"description": "Showcasing a curated selection of films from around the world, including premieres and special events." "description": "Showcasing a curated selection of films from around the world, including premieres and special events."
}, },
{ {
"eventName": "Brisbane Comedy Festival", "eventName": "Brisbane Comedy Festival",
"dateFrom": "2025-02-22", "dateFrom": "2025-02-22",
"dateTo": "2025-03-24", "dateTo": "2025-03-24",
"description": "A month-long comedy festival featuring local and international comedians in stand-up, sketch, and improv performances." "description": "A month-long comedy festival featuring local and international comedians in stand-up, sketch, and improv performances."
}, },
{ {
"eventName": "Brisbane Writers Festival", "eventName": "Brisbane Writers Festival",
"dateFrom": "2025-09-05", "dateFrom": "2025-09-05",
"dateTo": "2025-09-08", "dateTo": "2025-09-08",
"description": "An annual literary festival celebrating books, writing, and ideas with author talks, panel discussions, and workshops." "description": "An annual literary festival celebrating books, writing, and ideas with author talks, panel discussions, and workshops."
}, },
{ {
"eventName": "Brisbane Asia Pacific Film Festival", "eventName": "Brisbane Asia Pacific Film Festival",
"dateFrom": "2025-11-29", "dateFrom": "2025-11-29",
"dateTo": "2025-12-08", "dateTo": "2025-12-08",
"description": "Showcasing the best cinema from the Asia Pacific region, including features, documentaries, and short films." "description": "Showcasing the best cinema from the Asia Pacific region, including features, documentaries, and short films."
} }
], ],
"Perth": [ "Perth": [
{ {
"eventName": "Perth Festival", "eventName": "Perth Festival",
"dateFrom": "2025-02-07", "dateFrom": "2025-02-07",
"dateTo": "2025-03-01", "dateTo": "2025-03-01",
"description": "Australia's longest-running cultural festival, offering a diverse program of music, theatre, dance, literature, and visual arts." "description": "Australia's longest-running cultural festival, offering a diverse program of music, theatre, dance, literature, and visual arts."
}, },
{ {
"eventName": "Fringe World Festival", "eventName": "Fringe World Festival",
"dateFrom": "2025-01-16", "dateFrom": "2025-01-16",
"dateTo": "2025-02-15", "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." "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", "eventName": "Sculpture by the Sea",
"dateFrom": "2025-03-06", "dateFrom": "2025-03-06",
"dateTo": "2025-03-23", "dateTo": "2025-03-23",
"description": "An annual outdoor sculpture exhibition along Cottesloe Beach, showcasing works from Australian and international artists." "description": "An annual outdoor sculpture exhibition along Cottesloe Beach, showcasing works from Australian and international artists."
}, },
{ {
"eventName": "Revelation Perth International Film Festival", "eventName": "Revelation Perth International Film Festival",
"dateFrom": "2025-07-03", "dateFrom": "2025-07-03",
"dateTo": "2025-07-13", "dateTo": "2025-07-13",
"description": "A showcase of independent cinema, featuring a diverse selection of films, documentaries, and short films." "description": "A showcase of independent cinema, featuring a diverse selection of films, documentaries, and short films."
}, },
{ {
"eventName": "Perth Comedy Festival", "eventName": "Perth Comedy Festival",
"dateFrom": "2025-04-22", "dateFrom": "2025-04-22",
"dateTo": "2025-05-19", "dateTo": "2025-05-19",
"description": "A month-long comedy festival featuring local and international comedians in stand-up, sketch, and improv performances." "description": "A month-long comedy festival featuring local and international comedians in stand-up, sketch, and improv performances."
} }
], ],
"Adelaide": [ "Adelaide": [
{ {
"eventName": "Adelaide Festival", "eventName": "Adelaide Festival",
"dateFrom": "2025-02-28", "dateFrom": "2025-02-28",
"dateTo": "2025-03-15", "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." "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", "eventName": "Adelaide Fringe",
"dateFrom": "2025-02-14", "dateFrom": "2025-02-14",
"dateTo": "2025-03-15", "dateTo": "2025-03-15",
"description": "The largest open-access arts festival in the Southern Hemisphere, featuring thousands of performances across various genres and venues." "description": "The largest open-access arts festival in the Southern Hemisphere, featuring thousands of performances across various genres and venues."
}, },
{ {
"eventName": "SALA Festival", "eventName": "SALA Festival",
"dateFrom": "2025-08-01", "dateFrom": "2025-08-01",
"dateTo": "2025-08-31", "dateTo": "2025-08-31",
"description": "South Australia's largest visual arts festival, showcasing the work of local artists in exhibitions, workshops, and events." "description": "South Australia's largest visual arts festival, showcasing the work of local artists in exhibitions, workshops, and events."
}, },
{ {
"eventName": "OzAsia Festival", "eventName": "OzAsia Festival",
"dateFrom": "2025-09-25", "dateFrom": "2025-09-25",
"dateTo": "2025-10-11", "dateTo": "2025-10-11",
"description": "A celebration of Asian arts and culture, featuring performances, exhibitions, and events from across the region." "description": "A celebration of Asian arts and culture, featuring performances, exhibitions, and events from across the region."
}, },
{ {
"eventName": "Adelaide Film Festival", "eventName": "Adelaide Film Festival",
"dateFrom": "2025-10-16", "dateFrom": "2025-10-16",
"dateTo": "2025-10-26", "dateTo": "2025-10-26",
"description": "Showcasing a diverse selection of Australian and international films, including features, documentaries, and shorts." "description": "Showcasing a diverse selection of Australian and international films, including features, documentaries, and shorts."
}, },
{ {
"eventName": "Adelaide Writers' Week", "eventName": "Adelaide Writers' Week",
"dateFrom": "2025-03-01", "dateFrom": "2025-03-01",
"dateTo": "2025-03-06", "dateTo": "2025-03-06",
"description": "An annual literary festival featuring talks, panel discussions, and readings by acclaimed authors and thinkers." "description": "An annual literary festival featuring talks, panel discussions, and readings by acclaimed authors and thinkers."
} }
], ],
"Wellington": [ "Wellington": [
{ {
"eventName": "New Zealand Festival of the Arts", "eventName": "New Zealand Festival of the Arts",
"dateFrom": "2025-02-21", "dateFrom": "2025-02-21",
"dateTo": "2025-03-15", "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.", "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/" "url": "https://www.festival.nz/"
}, },
{ {
"eventName": "Wellington Jazz Festival", "eventName": "Wellington Jazz Festival",
"dateFrom": "2025-06-05", "dateFrom": "2025-06-05",
"dateTo": "2025-06-09", "dateTo": "2025-06-09",
"description": "A five-day festival showcasing local and international jazz musicians in concerts, workshops, and community events.", "description": "A five-day festival showcasing local and international jazz musicians in concerts, workshops, and community events.",
"url": "https://www.jazzfestival.co.nz/" "url": "https://www.jazzfestival.co.nz/"
}, },
{ {
"eventName": "Wellington on a Plate", "eventName": "Wellington on a Plate",
"dateFrom": "2025-08-01", "dateFrom": "2025-08-01",
"dateTo": "2025-08-16", "dateTo": "2025-08-16",
"description": "A culinary festival celebrating the city's food and beverage industry with special menus, events, and culinary experiences." "description": "A culinary festival celebrating the city's food and beverage industry with special menus, events, and culinary experiences."
}, },
{ {
"eventName": "CubaDupa", "eventName": "CubaDupa",
"dateFrom": "2025-03-28", "dateFrom": "2025-03-28",
"dateTo": "2025-03-29", "dateTo": "2025-03-29",
"description": "A vibrant street festival in Wellington's Cuba Street, featuring music, dance, street performers, and food stalls." "description": "A vibrant street festival in Wellington's Cuba Street, featuring music, dance, street performers, and food stalls."
}, },
{ {
"eventName": "Wellington Pasifika Festival", "eventName": "Wellington Pasifika Festival",
"dateFrom": "2025-01-18", "dateFrom": "2025-01-18",
"dateTo": "2025-01-19", "dateTo": "2025-01-19",
"description": "A celebration of Pacific Island culture with traditional performances, food stalls, and arts and crafts." "description": "A celebration of Pacific Island culture with traditional performances, food stalls, and arts and crafts."
} }
] ]
} }

View File

@@ -1,16 +1,68 @@
from models.tool_definitions import AgentGoal from models.tool_definitions import AgentGoal
from tools.tool_registry import ( from tools.tool_registry import (
find_events_tool, search_fixtures_tool,
search_flights_tool, search_flights_tool,
search_trains_tool,
book_trains_tool,
create_invoice_tool, create_invoice_tool,
find_events_tool,
) )
goal_match_train_invoice = AgentGoal(
tools=[
search_fixtures_tool,
search_trains_tool,
book_trains_tool,
create_invoice_tool,
],
description="The user wants to book a trip to a city in the UK around the dates of a premier league match. "
"Help the user find a premier league match to attend, search and book trains for that match and offers to invoice them for the cost of train tickets. "
"The user lives in London. "
"Gather args for these tools in order, ensuring you move the user from one tool to the next: "
"1. SearchFixtures: Search for fixtures for a team within a specified date range. The user might ask questions about the matches dates and locations to decide on where to go. "
"2. SearchTrains: Search for trains to the city of the match and list them for the customer to choose from "
"3. BookTrains: Book the train tickets, used to invoice the user for the cost of the train tickets "
"4. CreateInvoice: Invoices the user for the cost of train tickets, with total and details inferred from the conversation history ",
starter_prompt="Welcome me, give me a description of what you can do, then ask me for the details you need to begin your job as an agent ",
example_conversation_history="\n ".join(
[
"user: I'd like to travel to a premier league match",
"agent: Sure! Let's start by finding a match you'd like to attend. I know about Premier League fixtures in the UK. Could you tell me which team and what date range you're interested in?"
"user: Wolves between mid-April and the first week of May, please."
"agent: Great! Let's find fixtures for Wolverhampton Wanderers FC between 2025-04-15 and 2025-05-05."
"user_confirmed_tool_run: <user clicks confirm on SearchFixtures tool, passing the full team name as an input>",
'tool_result: SearchFixtures responds in this JSON format and the results are parsed by the agent below {"fixtures": [{"awayTeam": "West Ham United FC","date": "2025-04-01","homeTeam": "Wolverhampton Wanderers FC"},...,'
"agent: I found 4 Wolves matches in May 2025: 1. Man City vs Wolves (Away) - May 3 2. Wolves vs Brighton (Home) - May 10 3. Crystal Palace vs Wolves (Away) - May 18 4. Wolves vs Brentford (Home) - May 25 Which match would you like to attend? I'll help you find trains from London.",
"user: How about the Man City match",
"agent: Great! Let's find trains to Manchester and back for the match.",
"user_confirmed_tool_run: <user clicks confirm on SearchTrains tool>",
'tool_result: SearchTrains responds in this JSON format and the results are parsed by the agent below: {"journeys": [{"arrival": "Manchester","arrival_time": "2025-04-18T12:11","departure": "London","departure_time": "2025-04-18T11:00","id": "T8049","platform": "1","price": 73.5,"type": "outbound"},',
"agent: I found several train options for your journey to Manchester including outbound options on May 9 at 11am, 3:30pm and 4:30pm and return options on May 11 at 9am, 11am and 1pm. Which trains would you like to book?",
"user: I'd like the 11am outbound and 3:30pm return",
"agent: Sounds good, let's book train tickets for the 11am outbound and 3:30pm return trains on May 9 and May 11, one day before and after the match.",
"user_confirmed_tool_run: <user clicks confirm on BookTrains tool>",
'tool_result: BookTrains responds in this JSON format and the results are parsed by the agent belowg [{"booking_reference": "BR10879","journey_id": "T9532,T1027","status": "confirmed"}]',
"agent: Your train tickets have been booked with the reference ABCD1234. Are you ready to be invoiced for the total cost of the train tickets?",
"user: Yes, pelase invoice me.",
"user_confirmed_tool_run: <user clicks confirm on CreateInvoice tool which includes details of the train journey, the match, and the total cost>",
'tool_result: CreateInvoice responds in this JSON format and the results are parsed by the agent below (including a link to the invoice): {"invoiceStatus": "open","invoiceURL": "https://invoice.stripe.com/i/acct_1NBOLuKVZbzw7QA5/test_YWNjdF8xTkJPTHVLVlpienc3UUE1LF9SbGJlZ2xLMjhlc3lxNzFkVVBXbkswOUlFa2Rna1RxLDEzMDAwMTY4Nw0200RLeJRMlw?s=ap","reference": "F9B01CD7-0001"}',
"agent: Great! I've generated your invoice for your trains to the Manchester City match on the 10th of May. You can view and pay your invoice at this link: https://invoice.stripe.com/i/acct_1NBOLuKVZbzw7QA5/test_YWNjdF8xTkJPTHVLVlpienc3UUE1LF9SbGJlZ2xLMjhlc3lxNzFkVVBXbkswOUlFa2Rna1RxLDEzMDAwMTY4Nw0200RLeJRMlw?s=ap",
]
),
)
# unused
goal_event_flight_invoice = AgentGoal( goal_event_flight_invoice = AgentGoal(
tools=[find_events_tool, search_flights_tool, create_invoice_tool], tools=[
find_events_tool,
search_flights_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. FindEvents: Find an event to travel to " "1. FindEvents: Find an event to travel to "
"2. SearchFlights: search for a flight around the event dates " "2. SearchFlights: search for a flight around the event dates "
"3. CreateInvoice: Create a simple invoice for the cost of that flight ", "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( example_conversation_history="\n ".join(
[ [
"user: I'd like to travel to an event", "user: I'd like to travel to an event",

65
tools/search_fixtures.py Normal file
View File

@@ -0,0 +1,65 @@
import os
import requests
from datetime import datetime
from dotenv import load_dotenv
BASE_URL = "https://api.football-data.org/v4"
def search_fixtures(args: dict) -> dict:
load_dotenv(override=True)
api_key = os.getenv("FOOTBALL_DATA_API_KEY", "YOUR_DEFAULT_KEY")
team_name = args.get("team")
start_date_str = args.get("start_date")
end_date_str = args.get("end_date")
headers = {"X-Auth-Token": api_key}
team_name = team_name.lower()
try:
start_date = datetime.strptime(start_date_str, "%Y-%m-%d")
end_date = datetime.strptime(end_date_str, "%Y-%m-%d")
except ValueError:
return {
"error": "Invalid date provided. Expected format YYYY-MM-DD for both start_date and end_date."
}
# Fetch team ID
teams_response = requests.get(f"{BASE_URL}/competitions/PL/teams", headers=headers)
if teams_response.status_code != 200:
return {"error": "Failed to fetch teams data."}
teams_data = teams_response.json()
team_id = None
for team in teams_data["teams"]:
if team_name in team["name"].lower():
team_id = team["id"]
break
if not team_id:
return {"error": "Team not found."}
start_date_formatted = start_date.strftime("%Y-%m-%d")
end_date_formatted = end_date.strftime("%Y-%m-%d")
fixtures_url = f"{BASE_URL}/teams/{team_id}/matches?dateFrom={start_date_formatted}&dateTo={end_date_formatted}"
print(fixtures_url)
fixtures_response = requests.get(fixtures_url, headers=headers)
if fixtures_response.status_code != 200:
return {"error": "Failed to fetch fixtures data."}
fixtures_data = fixtures_response.json()
matching_fixtures = []
for match in fixtures_data.get("matches", []):
match_datetime = datetime.strptime(match["utcDate"], "%Y-%m-%dT%H:%M:%SZ")
if match["competition"]["code"] == "PL":
matching_fixtures.append(
{
"date": match_datetime.strftime("%Y-%m-%d"),
"homeTeam": match["homeTeam"]["name"],
"awayTeam": match["awayTeam"]["name"],
}
)
return {"fixtures": matching_fixtures}

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",
},
], ],
} }

6
tools/search_trains.py Normal file
View File

@@ -0,0 +1,6 @@
def search_trains(args: dict) -> dict:
raise NotImplementedError("TODO implement :)")
def book_trains(args: dict) -> dict:
raise NotImplementedError("TODO implement :)")

View File

@@ -1,26 +1,5 @@
from models.tool_definitions import ToolDefinition, ToolArgument from models.tool_definitions import ToolDefinition, ToolArgument
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)",
),
],
)
# 2) Define the SearchFlights tool
search_flights_tool = ToolDefinition( search_flights_tool = ToolDefinition(
name="SearchFlights", name="SearchFlights",
description="Search for return flights from an origin to a destination within a date range (dateDepart, dateReturn).", description="Search for return flights from an origin to a destination within a date range (dateDepart, dateReturn).",
@@ -48,20 +27,100 @@ search_flights_tool = ToolDefinition(
], ],
) )
# 3) Define the CreateInvoice tool search_trains_tool = ToolDefinition(
name="SearchTrains",
description="Search for trains between two English cities. Returns a list of train information for the user to choose from.",
arguments=[
ToolArgument(
name="origin",
type="string",
description="The city or place to depart from",
),
ToolArgument(
name="destination",
type="string",
description="The city or place to arrive at",
),
ToolArgument(
name="outbound_time",
type="ISO8601",
description="The date and time to search for outbound trains. If time of day isn't asked for, assume a decent time of day/evening for the outbound journey",
),
ToolArgument(
name="return_time",
type="ISO8601",
description="The date and time to search for return trains. If time of day isn't asked for, assume a decent time of day/evening for the inbound journey",
),
],
)
book_trains_tool = ToolDefinition(
name="BookTrains",
description="Books train tickets. Returns a booking reference.",
arguments=[
ToolArgument(
name="train_ids",
type="string",
description="The IDs of the trains to book, comma separated",
),
],
)
create_invoice_tool = ToolDefinition( create_invoice_tool = ToolDefinition(
name="CreateInvoice", name="CreateInvoice",
description="Generate an invoice for the items described for the amount provided", description="Generate an invoice for the items described for the total inferred by the conversation history so far. Returns URL to invoice.",
arguments=[ arguments=[
ToolArgument( ToolArgument(
name="amount", name="amount",
type="float", type="float",
description="The total cost to be invoiced", description="The total cost to be invoiced. Infer this from the conversation history.",
), ),
ToolArgument( ToolArgument(
name="flightDetails", name="tripDetails",
type="string", type="string",
description="A description of the item details to be invoiced", description="A description of the item details to be invoiced, inferred from the conversation history.",
),
],
)
search_fixtures_tool = ToolDefinition(
name="SearchFixtures",
description="Search for upcoming fixtures for a given team within a date range inferred from the user's description. Valid teams this 24/25 season are Arsenal FC, Aston Villa FC, AFC Bournemouth, Brentford FC, Brighton & Hove Albion FC, Chelsea FC, Crystal Palace FC, Everton FC, Fulham FC, Ipswich Town FC, Leicester City FC, Liverpool FC, Manchester City FC, Manchester United FC, Newcastle United FC, Nottingham Forest FC, Southampton FC, Tottenham Hotspur FC, West Ham United FC, Wolverhampton Wanderers FC",
arguments=[
ToolArgument(
name="team",
type="string",
description="The full name of the team to search for.",
),
ToolArgument(
name="start_date",
type="string",
description="The start date in format (YYYY-MM-DD) for the fixture search inferred from the user's request (e.g. mid-March).",
),
ToolArgument(
name="end_date",
type="string",
description="The end date in format (YYYY-MM-DD) for the fixture search (e.g. 'the last week of May').",
),
],
)
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

@@ -0,0 +1,214 @@
from collections import deque
from datetime import timedelta
from typing import Dict, Any, Union, List, Optional, Deque, TypedDict
from temporalio.common import RetryPolicy
from temporalio import workflow
from models.data_types import ConversationHistory, NextStep, ValidationInput
from workflows.workflow_helpers import LLM_ACTIVITY_START_TO_CLOSE_TIMEOUT, \
LLM_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT
from workflows import workflow_helpers as helpers
with workflow.unsafe.imports_passed_through():
from activities.tool_activities import ToolActivities
from prompts.agent_prompt_generators import (
generate_genai_prompt
)
from models.data_types import (
CombinedInput,
ToolPromptInput,
)
# Constants
MAX_TURNS_BEFORE_CONTINUE = 250
class ToolData(TypedDict, total=False):
next: NextStep
tool: str
args: Dict[str, Any]
response: str
@workflow.defn
class AgentGoalWorkflow:
"""Workflow that manages tool execution with user confirmation and conversation history."""
def __init__(self) -> None:
self.conversation_history: ConversationHistory = {"messages": []}
self.prompt_queue: Deque[str] = deque()
self.conversation_summary: Optional[str] = None
self.chat_ended: bool = False
self.tool_data: Optional[ToolData] = None
self.confirm: bool = False
self.tool_results: List[Dict[str, Any]] = []
@workflow.run
async def run(self, combined_input: CombinedInput) -> str:
"""Main workflow execution method."""
params = combined_input.tool_params
agent_goal = combined_input.agent_goal
if params and params.conversation_summary:
self.add_message("conversation_summary", params.conversation_summary)
self.conversation_summary = params.conversation_summary
if params and params.prompt_queue:
self.prompt_queue.extend(params.prompt_queue)
waiting_for_confirm = False
current_tool = None
while True:
await workflow.wait_condition(
lambda: bool(self.prompt_queue) or self.chat_ended or self.confirm
)
if self.chat_ended:
workflow.logger.info("Chat ended.")
return f"{self.conversation_history}"
if self.confirm and waiting_for_confirm and current_tool and self.tool_data:
self.confirm = False
waiting_for_confirm = False
confirmed_tool_data = self.tool_data.copy()
confirmed_tool_data["next"] = "user_confirmed_tool_run"
self.add_message("user_confirmed_tool_run", confirmed_tool_data)
await helpers.handle_tool_execution(
current_tool,
self.tool_data,
self.tool_results,
self.add_message,
self.prompt_queue
)
continue
if self.prompt_queue:
prompt = self.prompt_queue.popleft()
if not prompt.startswith("###"):
self.add_message("user", prompt)
# Validate the prompt before proceeding
validation_input = ValidationInput(
prompt=prompt,
conversation_history=self.conversation_history,
agent_goal=agent_goal,
)
validation_result = await workflow.execute_activity(
ToolActivities.agent_validatePrompt,
args=[validation_input],
schedule_to_close_timeout=LLM_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT,
start_to_close_timeout=LLM_ACTIVITY_START_TO_CLOSE_TIMEOUT,
retry_policy=RetryPolicy(
initial_interval=timedelta(seconds=5), backoff_coefficient=1
),
)
if not validation_result.validationResult:
workflow.logger.warning(
f"Prompt validation failed: {validation_result.validationFailedReason}"
)
self.add_message(
"agent", validation_result.validationFailedReason
)
continue
# Proceed with generating the context and prompt
context_instructions = generate_genai_prompt(
agent_goal, self.conversation_history, self.tool_data
)
prompt_input = ToolPromptInput(
prompt=prompt,
context_instructions=context_instructions,
)
tool_data = await workflow.execute_activity(
ToolActivities.agent_toolPlanner,
prompt_input,
schedule_to_close_timeout=LLM_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT,
start_to_close_timeout=LLM_ACTIVITY_START_TO_CLOSE_TIMEOUT,
retry_policy=RetryPolicy(
initial_interval=timedelta(seconds=5), backoff_coefficient=1
),
)
self.tool_data = tool_data
next_step = tool_data.get("next")
current_tool = tool_data.get("tool")
if next_step == "confirm" and current_tool:
args = tool_data.get("args", {})
if await helpers.handle_missing_args(current_tool, args, tool_data, self.prompt_queue):
continue
waiting_for_confirm = True
self.confirm = False
workflow.logger.info("Waiting for user confirm signal...")
elif next_step == "done":
workflow.logger.info("All steps completed. Exiting workflow.")
self.add_message("agent", tool_data)
return str(self.conversation_history)
self.add_message("agent", tool_data)
await helpers.continue_as_new_if_needed(
self.conversation_history,
self.prompt_queue,
agent_goal,
MAX_TURNS_BEFORE_CONTINUE,
self.add_message
)
@workflow.signal
async def user_prompt(self, prompt: str) -> None:
"""Signal handler for receiving user prompts."""
if self.chat_ended:
workflow.logger.warn(f"Message dropped due to chat closed: {prompt}")
return
self.prompt_queue.append(prompt)
@workflow.signal
async def confirm(self) -> None:
"""Signal handler for user confirmation of tool execution."""
workflow.logger.info("Received user confirmation")
self.confirm = True
@workflow.signal
async def end_chat(self) -> None:
"""Signal handler for ending the chat session."""
self.chat_ended = True
@workflow.query
def get_conversation_history(self) -> ConversationHistory:
"""Query handler to retrieve the full conversation history."""
return self.conversation_history
@workflow.query
def get_summary_from_history(self) -> Optional[str]:
"""Query handler to retrieve the conversation summary if available.
Used only for continue as new of the workflow."""
return self.conversation_summary
@workflow.query
def get_latest_tool_data(self) -> Optional[ToolData]:
"""Query handler to retrieve the latest tool data response if available."""
return self.tool_data
def add_message(self, actor: str, response: Union[str, Dict[str, Any]]) -> None:
"""Add a message to the conversation history.
Args:
actor: The entity that generated the message (e.g., "user", "agent")
response: The message content, either as a string or structured data
"""
if isinstance(response, dict):
response_str = str(response)
workflow.logger.debug(f"Adding {actor} message: {response_str[:100]}...")
else:
workflow.logger.debug(f"Adding {actor} message: {response[:100]}...")
self.conversation_history["messages"].append(
{"actor": actor, "response": response}
)

View File

@@ -1,273 +0,0 @@
from collections import deque
from datetime import timedelta
from typing import Dict, Any, Union, List, Optional, Deque, TypedDict, Literal
from temporalio.common import RetryPolicy
from temporalio import workflow
with workflow.unsafe.imports_passed_through():
from activities.tool_activities import ToolActivities, ToolPromptInput
from prompts.agent_prompt_generators import generate_genai_prompt
from models.data_types import CombinedInput, ToolWorkflowParams
# Constants
MAX_TURNS_BEFORE_CONTINUE = 250
TOOL_ACTIVITY_TIMEOUT = timedelta(seconds=20)
LLM_ACTIVITY_TIMEOUT = timedelta(seconds=60)
# Type definitions
Message = Dict[str, Union[str, Dict[str, Any]]]
ConversationHistory = Dict[str, List[Message]]
NextStep = Literal["confirm", "question", "done"]
class ToolData(TypedDict, total=False):
next: NextStep
tool: str
args: Dict[str, Any]
response: str
@workflow.defn
class ToolWorkflow:
"""Workflow that manages tool execution with user confirmation and conversation history."""
def __init__(self) -> None:
self.conversation_history: ConversationHistory = {"messages": []}
self.prompt_queue: Deque[str] = deque()
self.conversation_summary: Optional[str] = None
self.chat_ended: bool = False
self.tool_data: Optional[ToolData] = None
self.confirm: bool = False
self.tool_results: List[Dict[str, Any]] = []
async def _handle_tool_execution(
self, current_tool: str, tool_data: ToolData
) -> None:
"""Execute a tool after confirmation and handle its result."""
workflow.logger.info(f"Confirmed. Proceeding with tool: {current_tool}")
dynamic_result = await workflow.execute_activity(
current_tool,
tool_data["args"],
schedule_to_close_timeout=TOOL_ACTIVITY_TIMEOUT,
)
dynamic_result["tool"] = current_tool
self.add_message(
"tool_result", {"tool": current_tool, "result": dynamic_result}
)
self.prompt_queue.append(
f"### The '{current_tool}' tool completed successfully with {dynamic_result}. "
"INSTRUCTIONS: Parse this tool result as plain text, and use the system prompt containing the list of tools in sequence and the conversation history (and previous tool_results) to figure out next steps, if any. "
"You will need to use the tool_results to auto-fill arguments for subsequent tools and also to figure out if all tools have been run."
'{"next": "<question|confirm|done>", "tool": "<tool_name or null>", "args": {"<arg1>": "<value1 or null>", "<arg2>": "<value2 or null>}, "response": "<plain text>"}'
"ONLY return those json keys (next, tool, args, response), nothing else."
'Next should only be "done" if all tools have been run (use the system prompt to figure that out).'
'Next should be "question" if the tool is not the last one in the sequence.'
'Next should NOT be "confirm" at this point.'
)
async def _handle_missing_args(
self, current_tool: str, args: Dict[str, Any], tool_data: ToolData
) -> bool:
"""Check for missing arguments and handle them if found."""
missing_args = [key for key, value in args.items() if value is None]
if missing_args:
self.prompt_queue.append(
f"### INSTRUCTIONS set next='question', combine this response response='{tool_data.get('response')}' "
f"and following missing arguments for tool {current_tool}: {missing_args}. "
"Only provide a valid JSON response without any comments or metadata."
)
workflow.logger.info(
f"Missing arguments for tool: {current_tool}: {' '.join(missing_args)}"
)
return True
return False
async def _continue_as_new_if_needed(self, agent_goal: Any) -> None:
"""Handle workflow continuation if message limit is reached."""
if len(self.conversation_history["messages"]) >= MAX_TURNS_BEFORE_CONTINUE:
summary_context, summary_prompt = self.prompt_summary_with_history()
summary_input = ToolPromptInput(
prompt=summary_prompt, context_instructions=summary_context
)
self.conversation_summary = await workflow.start_activity_method(
ToolActivities.prompt_llm,
summary_input,
schedule_to_close_timeout=TOOL_ACTIVITY_TIMEOUT,
)
workflow.logger.info(
f"Continuing as new after {MAX_TURNS_BEFORE_CONTINUE} turns."
)
workflow.continue_as_new(
args=[
CombinedInput(
tool_params=ToolWorkflowParams(
conversation_summary=self.conversation_summary,
prompt_queue=self.prompt_queue,
),
agent_goal=agent_goal,
)
]
)
@workflow.run
async def run(self, combined_input: CombinedInput) -> str:
"""Main workflow execution method."""
params = combined_input.tool_params
agent_goal = combined_input.agent_goal
if params and params.conversation_summary:
self.add_message("conversation_summary", params.conversation_summary)
self.conversation_summary = params.conversation_summary
if params and params.prompt_queue:
self.prompt_queue.extend(params.prompt_queue)
waiting_for_confirm = False
current_tool = None
while True:
await workflow.wait_condition(
lambda: bool(self.prompt_queue) or self.chat_ended or self.confirm
)
if self.chat_ended:
workflow.logger.info("Chat ended.")
return f"{self.conversation_history}"
if self.confirm and waiting_for_confirm and current_tool and self.tool_data:
self.confirm = False
waiting_for_confirm = False
confirmed_tool_data = self.tool_data.copy()
confirmed_tool_data["next"] = "user_confirmed_tool_run"
self.add_message("user_confirmed_tool_run", confirmed_tool_data)
await self._handle_tool_execution(current_tool, self.tool_data)
continue
if self.prompt_queue:
prompt = self.prompt_queue.popleft()
if not prompt.startswith("###"):
self.add_message("user", prompt)
context_instructions = generate_genai_prompt(
agent_goal, self.conversation_history, self.tool_data
)
prompt_input = ToolPromptInput(
prompt=prompt,
context_instructions=context_instructions,
)
tool_data = await workflow.execute_activity(
ToolActivities.prompt_llm,
prompt_input,
schedule_to_close_timeout=LLM_ACTIVITY_TIMEOUT,
retry_policy=RetryPolicy(
maximum_attempts=5, initial_interval=timedelta(seconds=15)
),
)
self.tool_data = tool_data
next_step = tool_data.get("next")
current_tool = tool_data.get("tool")
if next_step == "confirm" and current_tool:
args = tool_data.get("args", {})
if await self._handle_missing_args(current_tool, args, tool_data):
continue
waiting_for_confirm = True
self.confirm = False
workflow.logger.info("Waiting for user confirm signal...")
elif next_step == "done":
workflow.logger.info("All steps completed. Exiting workflow.")
self.add_message("agent", tool_data)
return str(self.conversation_history)
self.add_message("agent", tool_data)
await self._continue_as_new_if_needed(agent_goal)
@workflow.signal
async def user_prompt(self, prompt: str) -> None:
"""Signal handler for receiving user prompts."""
if self.chat_ended:
workflow.logger.warn(f"Message dropped due to chat closed: {prompt}")
return
self.prompt_queue.append(prompt)
@workflow.signal
async def end_chat(self) -> None:
"""Signal handler for ending the chat session."""
self.chat_ended = True
@workflow.signal
async def confirm(self) -> None:
"""Signal handler for user confirmation of tool execution."""
self.confirm = True
@workflow.query
def get_conversation_history(self) -> ConversationHistory:
"""Query handler to retrieve the full conversation history."""
return self.conversation_history
@workflow.query
def get_summary_from_history(self) -> Optional[str]:
"""Query handler to retrieve the conversation summary if available."""
return self.conversation_summary
@workflow.query
def get_tool_data(self) -> Optional[ToolData]:
"""Query handler to retrieve the current tool data if available."""
return self.tool_data
def format_history(self) -> str:
"""Format the conversation history into a single string."""
return " ".join(
str(msg["response"]) for msg in self.conversation_history["messages"]
)
def prompt_with_history(self, prompt: str) -> tuple[str, str]:
"""Generate a context-aware prompt with conversation history.
Returns:
tuple[str, str]: A tuple of (context_instructions, prompt)
"""
history_string = self.format_history()
context_instructions = (
f"Here is the conversation history: {history_string} "
"Please add a few sentence response in plain text sentences. "
"Don't editorialize or add metadata. "
"Keep the text a plain explanation based on the history."
)
return (context_instructions, prompt)
def prompt_summary_with_history(self) -> tuple[str, str]:
"""Generate a prompt for summarizing the conversation.
Returns:
tuple[str, str]: A tuple of (context_instructions, prompt)
"""
history_string = self.format_history()
context_instructions = f"Here is the conversation history between a user and a chatbot: {history_string}"
actual_prompt = (
"Please produce a two sentence summary of this conversation. "
'Put the summary in the format { "summary": "<plain text>" }'
)
return (context_instructions, actual_prompt)
def add_message(self, actor: str, response: Union[str, Dict[str, Any]]) -> None:
"""Add a message to the conversation history.
Args:
actor: The entity that generated the message (e.g., "user", "agent")
response: The message content, either as a string or structured data
"""
self.conversation_history["messages"].append(
{"actor": actor, "response": response}
)

View File

@@ -0,0 +1,143 @@
from datetime import timedelta
from typing import Dict, Any, Deque
from temporalio import workflow
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 shared.config import TEMPORAL_LEGACY_TASK_QUEUE
# Constants from original file
TOOL_ACTIVITY_START_TO_CLOSE_TIMEOUT = timedelta(seconds=10)
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],
) -> None:
"""Execute a tool after confirmation and handle its result."""
workflow.logger.info(f"Confirmed. Proceeding with tool: {current_tool}")
task_queue = (
TEMPORAL_LEGACY_TASK_QUEUE
if current_tool in ["SearchTrains", "BookTrains"]
else None
)
try:
dynamic_result = await workflow.execute_activity(
current_tool,
tool_data["args"],
task_queue=task_queue,
schedule_to_close_timeout=TOOL_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT,
start_to_close_timeout=TOOL_ACTIVITY_START_TO_CLOSE_TIMEOUT,
retry_policy=RetryPolicy(
initial_interval=timedelta(seconds=5), backoff_coefficient=1
),
)
dynamic_result["tool"] = current_tool
tool_results.append(dynamic_result)
except ActivityError as e:
workflow.logger.error(f"Tool execution failed: {str(e)}")
dynamic_result = {"error": str(e), "tool": current_tool}
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],
) -> bool:
"""Check for missing arguments and handle them if found."""
missing_args = [key for key, value in args.items() if value is None]
if missing_args:
prompt_queue.append(
generate_missing_args_prompt(current_tool, tool_data, missing_args)
)
workflow.logger.info(
f"Missing arguments for tool: {current_tool}: {' '.join(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"])
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 = (
f"Here is the conversation history: {history_string} "
"Please add a few sentence response in plain text sentences. "
"Don't editorialize or add metadata. "
"Keep the text a plain explanation based on the history."
)
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,
) -> 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_input = ToolPromptInput(
prompt=summary_prompt, context_instructions=summary_context
)
conversation_summary = await workflow.start_activity_method(
"ToolActivities.agent_toolPlanner",
summary_input,
schedule_to_close_timeout=LLM_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT,
)
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,
}
]
)
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)
context_instructions = f"Here is the conversation history between a user and a chatbot: {history_string}"
actual_prompt = (
"Please produce a two sentence summary of this conversation. "
'Put the summary in the format { "summary": "<plain text>" }'
)
return (context_instructions, actual_prompt)