diff --git a/.env.example b/.env.example index 9bf05b9..4701495 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,9 @@ STRIPE_API_KEY=sk_test_51J... LLM_PROVIDER=openai # default OPENAI_API_KEY=sk-proj-... # or +#LLM_PROVIDER=grok +#GROK_API_KEY=xai-your-grok-api-key +# or # LLM_PROVIDER=ollama # OLLAMA_MODEL_NAME=qwen2.5:14b # or @@ -34,3 +37,6 @@ OPENAI_API_KEY=sk-proj-... # Agent Goal Configuration # AGENT_GOAL=goal_event_flight_invoice # (default) or goal_match_train_invoice + +# Set if the UI should force a user confirmation step or not +SHOW_CONFIRM=True \ No newline at end of file diff --git a/README.md b/README.md index fd070f2..236f468 100644 --- a/README.md +++ b/README.md @@ -2,191 +2,29 @@ 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). +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/), [Grok](https://docs.x.ai/docs/overview) 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) +It's really helpful to [watch the demo (5 minute YouTube video)](https://www.youtube.com/watch?v=GEXllEH2XiQ) to understand how interaction works. -[![Watch the demo](./agent-youtube-screenshot.jpeg)](https://www.youtube.com/watch?v=GEXllEH2XiQ) +[![Watch the demo](./assets/agent-youtube-screenshot.jpeg)](https://www.youtube.com/watch?v=GEXllEH2XiQ) -## Configuration +## Setup and Configuration +See [the Setup guide](./setup.md). -This application uses `.env` files for configuration. Copy the [.env.example](.env.example) file to `.env` and update the values: +## Interaction +TODO -```bash -cp .env.example .env -``` +## Architecture +See [the architecture guide](./architecture.md). -### 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 Australia / New Zealand, 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. - * If you're lazy go to `tools/search_fixtures.py` and replace the `search_fixtures` function with the mock `search_fixtures_example` that exists in the same file. -* We use a mock function to search for trains. Start the train API server to use the real API: `python thirdparty/train_api.py` -* * The train activity is 'enterprise' so it's written in C# and requires a .NET runtime. See the [.NET backend](#net-(enterprise)-backend) section for details on running it. -* Requires a Stripe key for the `create_invoice` tool. Set this in the `STRIPE_API_KEY` environment variable in .env - * It's free to sign up and get a key at [Stripe](https://stripe.com/) - * If you're lazy go to `tools/create_invoice.py` and replace the `create_invoice` function with the mock `create_invoice_example` that exists in the same file. - -### LLM Provider Configuration - -The agent can use OpenAI's GPT-4o, Google Gemini, Anthropic Claude, or a local LLM via Ollama. Set the `LLM_PROVIDER` environment variable in your `.env` file to choose the desired provider: - -- `LLM_PROVIDER=openai` for OpenAI's GPT-4o -- `LLM_PROVIDER=google` for Google Gemini -- `LLM_PROVIDER=anthropic` for Anthropic Claude -- `LLM_PROVIDER=deepseek` for DeepSeek-V3 -- `LLM_PROVIDER=ollama` for running LLMs via [Ollama](https://ollama.ai) (not recommended for this use case) - -### Option 1: OpenAI - -If using OpenAI, ensure you have an OpenAI key for the GPT-4o model. Set this in the `OPENAI_API_KEY` environment variable in `.env`. - -### Option 2: Google Gemini - -To use Google Gemini: - -1. Obtain a Google API key and set it in the `GOOGLE_API_KEY` environment variable in `.env`. -2. Set `LLM_PROVIDER=google` in your `.env` file. - -### Option 3: Anthropic Claude (recommended) - -I find that Claude Sonnet 3.5 performs better than the other hosted LLMs for this use case. - -To use Anthropic: - -1. Obtain an Anthropic API key and set it in the `ANTHROPIC_API_KEY` environment variable in `.env`. -2. Set `LLM_PROVIDER=anthropic` in your `.env` file. - -### Option 4: Deepseek-V3 - -To use Deepseek-V3: - -1. Obtain a Deepseek API key and set it in the `DEEPSEEK_API_KEY` environment variable in `.env`. -2. Set `LLM_PROVIDER=deepseek` in your `.env` file. - -### Option 5: Local LLM via Ollama (not recommended) - -To use a local LLM with Ollama: - -1. Install [Ollama](https://ollama.com) and the [Qwen2.5 14B](https://ollama.com/library/qwen2.5) model. - - Run `ollama run ` to start the model. Note that this model is about 9GB to download. - - Example: `ollama run qwen2.5:14b` - -2. Set `LLM_PROVIDER=ollama` in your `.env` file and `OLLAMA_MODEL_NAME` to the name of the model you installed. - -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. - -## 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. - -### Use Temporal Cloud - -See [.env.example](.env.example) for details on connecting to Temporal Cloud using mTLS or API key authentication. - -[Sign up for Temporal Cloud](https://temporal.io/get-cloud) - -### Use a local Temporal Dev Server - -On a Mac -```bash -brew install temporal -temporal server start-dev -``` -See the [Temporal documentation](https://learn.temporal.io/getting_started/python/dev_environment/) for other platforms. - - -## Running the Application - -### Python Backend - -Requires [Poetry](https://python-poetry.org/) to manage dependencies. - -1. `python -m venv venv` - -2. `source venv/bin/activate` - -3. `poetry install` - -Run the following commands in separate terminal windows: - -1. Start the Temporal worker: -```bash -poetry run python scripts/run_worker.py -``` - -2. Start the API server: -```bash -poetry run uvicorn api.main:app --reload -``` -Access the API at `/docs` to see the available endpoints. - -### React UI -Start the frontend: -```bash -cd frontend -npm install -npx vite -``` -Access the UI at `http://localhost:5173` - -### Python Search Trains API -> Agent Goal: goal_match_train_invoice only - -Required to search and book trains! -```bash -poetry run python thirdparty/train_api.py - -# example url -# http://localhost:8080/api/search?from=london&to=liverpool&outbound_time=2025-04-18T09:00:00&inbound_time=2025-04-20T09:00:00 -``` - -### .NET (enterprise) Backend ;) -> Agent Goal: goal_match_train_invoice only - -We have activities written in C# to call the train APIs. -```bash -cd enterprise -dotnet build # ensure you brew install dotnet@8 first! -dotnet run -``` -If you're running your train API above on a different host/port then change the API URL in `Program.cs`. Otherwise, be sure to run it using `python thirdparty/train_api.py`. - -## Customizing the Agent -- `tool_registry.py` contains the mapping of tool names to tool definitions (so the AI understands how to use them) -- `goal_registry.py` contains descriptions of goals and the tools used to achieve them -- The tools themselves are defined in their own files in `/tools` -- Note the mapping in `tools/__init__.py` to each tool - -## TODO +## Productionalization & Adding Features - In a prod setting, I would need to ensure that payload data is stored separately (e.g. in S3 or a noSQL db - the claim-check pattern), or otherwise 'garbage collected'. Without these techniques, long conversations will fill up the workflow's conversation history, and start to breach Temporal event history payload limits. - Continue-as-new shouldn't be a big consideration for this use case (as it would take many conversational turns to trigger). Regardless, I should ensure that it's able to carry the agent state over to the new workflow execution. - Perhaps the UI should show when the LLM response is being retried (i.e. activity retry attempt because the LLM provided bad output) -- Tests would be nice! \ No newline at end of file +- Tests would be nice! +See [the todo](./todo.md) for more details. + +See Customization for more details. <-- TODO + +## For Temporal SAs +Check out the [slides](https://docs.google.com/presentation/d/1wUFY4v17vrtv8llreKEBDPLRtZte3FixxBUn0uWy5NU/edit#slide=id.g3333e5deaa9_0_0) here and the enablement guide here (TODO). diff --git a/activities/tool_activities.py b/activities/tool_activities.py index e833b44..5d5ffc3 100644 --- a/activities/tool_activities.py +++ b/activities/tool_activities.py @@ -34,6 +34,7 @@ class ToolActivities: # Initialize client variables (all set to None initially) self.openai_client: Optional[OpenAI] = None + self.grok_client: Optional[OpenAI] = None self.anthropic_client: Optional[anthropic.Anthropic] = None self.genai_configured: bool = False self.deepseek_client: Optional[deepseek.DeepSeekAPI] = None @@ -47,6 +48,13 @@ class ToolActivities: print("Initialized OpenAI client") else: print("Warning: OPENAI_API_KEY not set but LLM_PROVIDER is 'openai'") + + if self.llm_provider == "grok": + if os.environ.get("GROK_API_KEY"): + self.grok_client = OpenAI(api_key=os.environ.get("GROK_API_KEY"), base_url="https://api.x.ai/v1") + print("Initialized grok client") + else: + print("Warning: GROK_API_KEY not set but LLM_PROVIDER is 'grok'") elif self.llm_provider == "anthropic": if os.environ.get("ANTHROPIC_API_KEY"): @@ -195,6 +203,8 @@ class ToolActivities: return self.prompt_llm_anthropic(input) elif self.llm_provider == "deepseek": return self.prompt_llm_deepseek(input) + elif self.llm_provider == "grok": + return self.prompt_llm_grok(input) else: return self.prompt_llm_openai(input) @@ -237,13 +247,47 @@ class ToolActivities: ) response_content = chat_completion.choices[0].message.content - print(f"ChatGPT response: {response_content}") + activity.logger.info(f"ChatGPT response: {response_content}") # Use the new sanitize function response_content = self.sanitize_json_response(response_content) return self.parse_json_response(response_content) + def prompt_llm_grok(self, input: ToolPromptInput) -> dict: + if not self.grok_client: + api_key = os.environ.get("GROK_API_KEY") + if not api_key: + raise ValueError( + "GROK_API_KEY is not set in the environment variables but LLM_PROVIDER is 'grok'" + ) + self.grok_client = OpenAI(api_key=api_key, base_url="https://api.x.ai/v1") + print("Initialized grok client on demand") + + messages = [ + { + "role": "system", + "content": input.context_instructions + + ". The current date is " + + datetime.now().strftime("%B %d, %Y"), + }, + { + "role": "user", + "content": input.prompt, + }, + ] + + chat_completion = self.grok_client.chat.completions.create( + model="grok-2-1212", messages=messages + ) + + response_content = chat_completion.choices[0].message.content + activity.logger.info(f"Grok response: {response_content}") + + # Use the new sanitize function + response_content = self.sanitize_json_response(response_content) + + return self.parse_json_response(response_content) def prompt_llm_ollama(self, input: ToolPromptInput) -> dict: # If not yet initialized, try to do so now (this is a backup if warm_up_ollama wasn't called or failed) if not self.ollama_initialized: @@ -449,6 +493,7 @@ def dynamic_tool_activity(args: Sequence[RawValue]) -> dict: # Delegate to the relevant function handler = get_handler(tool_name) result = handler(tool_args) + print(f"in dynamic tool activity, result: {result}") # Optionally log or augment the result activity.logger.info(f"Tool '{tool_name}' result: {result}") diff --git a/api/main.py b/api/main.py index b381bb6..7a05ba9 100644 --- a/api/main.py +++ b/api/main.py @@ -1,3 +1,4 @@ +import os from fastapi import FastAPI from typing import Optional from temporalio.client import Client @@ -6,11 +7,10 @@ from temporalio.api.enums.v1 import WorkflowExecutionStatus from fastapi import HTTPException from dotenv import load_dotenv import asyncio -import os from workflows.agent_goal_workflow import AgentGoalWorkflow from models.data_types import CombinedInput, AgentGoalWorkflowParams -from tools.goal_registry import goal_match_train_invoice, goal_event_flight_invoice +from tools.goal_registry import goal_list from fastapi.middleware.cors import CORSMiddleware from shared.config import get_temporal_client, TEMPORAL_TASK_QUEUE @@ -21,14 +21,12 @@ temporal_client: Optional[Client] = None load_dotenv() -def get_agent_goal(): +def get_initial_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) + env_goal = os.getenv("AGENT_GOAL", "goal_choose_agent_type") #if no goal is set in the env file, default to choosing an agent + for listed_goal in goal_list: + if listed_goal.id == env_goal: + return listed_goal @app.on_event("startup") @@ -113,10 +111,35 @@ async def get_conversation_history(): status_code=404, detail="Workflow worker unavailable or not found." ) - # For other Temporal errors, return a 500 - raise HTTPException( - status_code=500, detail="Internal server error while querying workflow." - ) + if "workflow not found" in error_message: + await start_workflow() + return [] + else: + # For other Temporal errors, return a 500 + raise HTTPException( + status_code=500, detail="Internal server error while querying workflow." + ) + +@app.get("/agent-goal") +async def get_agent_goal(): + """Calls the workflow's 'get_agent_goal' query.""" + try: + # Get workflow handle + handle = temporal_client.get_workflow_handle("agent-workflow") + + # Check if the workflow is completed + workflow_status = await handle.describe() + if workflow_status.status == 2: + # Workflow is completed; return an empty response + return {} + + # Query the workflow + agent_goal = await handle.query("get_agent_goal") + return agent_goal + except TemporalError as e: + # Workflow not found; return an empty response + print(e) + return {} @app.post("/send-prompt") @@ -124,7 +147,8 @@ async def send_prompt(prompt: str): # Create combined input with goal from environment combined_input = CombinedInput( tool_params=AgentGoalWorkflowParams(None, None), - agent_goal=get_agent_goal(), + agent_goal=get_initial_agent_goal(), + #change to get from workflow query ) workflow_id = "agent-workflow" @@ -168,13 +192,12 @@ async def end_chat(): @app.post("/start-workflow") async def start_workflow(): - # Get the configured goal - agent_goal = get_agent_goal() + initial_agent_goal = get_initial_agent_goal() # Create combined input combined_input = CombinedInput( tool_params=AgentGoalWorkflowParams(None, None), - agent_goal=agent_goal, + agent_goal=initial_agent_goal, ) workflow_id = "agent-workflow" @@ -186,9 +209,9 @@ async def start_workflow(): id=workflow_id, task_queue=TEMPORAL_TASK_QUEUE, start_signal="user_prompt", - start_signal_args=["### " + agent_goal.starter_prompt], + start_signal_args=["### " + initial_agent_goal.starter_prompt], ) return { - "message": f"Workflow started with goal's starter prompt: {agent_goal.starter_prompt}." + "message": f"Workflow started with goal's starter prompt: {initial_agent_goal.starter_prompt}." } diff --git a/architecture.md b/architecture.md new file mode 100644 index 0000000..f1a5d7b --- /dev/null +++ b/architecture.md @@ -0,0 +1,12 @@ +# Elements +![Architecture Elements](./assets/Architecture_elements.png "Architecture Elements") + +talk through the pieces + +# Architecture Model +![Architecture](./assets/ai_agent_architecture_model.png "Architecture Model") + +explain elements + +# Adding features +link to how to LLM interactions/how to change \ No newline at end of file diff --git a/assets/Architecture_elements.png b/assets/Architecture_elements.png new file mode 100644 index 0000000..a1f7b61 Binary files /dev/null and b/assets/Architecture_elements.png differ diff --git a/agent-youtube-screenshot.jpeg b/assets/agent-youtube-screenshot.jpeg similarity index 100% rename from agent-youtube-screenshot.jpeg rename to assets/agent-youtube-screenshot.jpeg diff --git a/assets/ai_agent_architecture_model.png b/assets/ai_agent_architecture_model.png new file mode 100644 index 0000000..e38f19b Binary files /dev/null and b/assets/ai_agent_architecture_model.png differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 56a695a..5bc88a3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -825,247 +825,228 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.29.1.tgz", - "integrity": "sha512-ssKhA8RNltTZLpG6/QNkCSge+7mBQGUqJRisZ2MDQcEGaK93QESEgWK2iOpIDZ7k9zPVkG5AS3ksvD5ZWxmItw==", + "version": "4.34.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.7.tgz", + "integrity": "sha512-l6CtzHYo8D2TQ3J7qJNpp3Q1Iye56ssIAtqbM2H8axxCEEwvN7o8Ze9PuIapbxFL3OHrJU2JBX6FIIVnP/rYyw==", "cpu": [ "arm" ], - "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.29.1.tgz", - "integrity": "sha512-CaRfrV0cd+NIIcVVN/jx+hVLN+VRqnuzLRmfmlzpOzB87ajixsN/+9L5xNmkaUUvEbI5BmIKS+XTwXsHEb65Ew==", + "version": "4.34.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.7.tgz", + "integrity": "sha512-KvyJpFUueUnSp53zhAa293QBYqwm94TgYTIfXyOTtidhm5V0LbLCJQRGkQClYiX3FXDQGSvPxOTD/6rPStMMDg==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.29.1.tgz", - "integrity": "sha512-2ORr7T31Y0Mnk6qNuwtyNmy14MunTAMx06VAPI6/Ju52W10zk1i7i5U3vlDRWjhOI5quBcrvhkCHyF76bI7kEw==", + "version": "4.34.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.7.tgz", + "integrity": "sha512-jq87CjmgL9YIKvs8ybtIC98s/M3HdbqXhllcy9EdLV0yMg1DpxES2gr65nNy7ObNo/vZ/MrOTxt0bE5LinL6mA==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.29.1.tgz", - "integrity": "sha512-j/Ej1oanzPjmN0tirRd5K2/nncAhS9W6ICzgxV+9Y5ZsP0hiGhHJXZ2JQ53iSSjj8m6cRY6oB1GMzNn2EUt6Ng==", + "version": "4.34.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.7.tgz", + "integrity": "sha512-rSI/m8OxBjsdnMMg0WEetu/w+LhLAcCDEiL66lmMX4R3oaml3eXz3Dxfvrxs1FbzPbJMaItQiksyMfv1hoIxnA==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.29.1.tgz", - "integrity": "sha512-91C//G6Dm/cv724tpt7nTyP+JdN12iqeXGFM1SqnljCmi5yTXriH7B1r8AD9dAZByHpKAumqP1Qy2vVNIdLZqw==", + "version": "4.34.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.7.tgz", + "integrity": "sha512-oIoJRy3ZrdsXpFuWDtzsOOa/E/RbRWXVokpVrNnkS7npz8GEG++E1gYbzhYxhxHbO2om1T26BZjVmdIoyN2WtA==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "freebsd" ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.29.1.tgz", - "integrity": "sha512-hEioiEQ9Dec2nIRoeHUP6hr1PSkXzQaCUyqBDQ9I9ik4gCXQZjJMIVzoNLBRGet+hIUb3CISMh9KXuCcWVW/8w==", + "version": "4.34.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.7.tgz", + "integrity": "sha512-X++QSLm4NZfZ3VXGVwyHdRf58IBbCu9ammgJxuWZYLX0du6kZvdNqPwrjvDfwmi6wFdvfZ/s6K7ia0E5kI7m8Q==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "freebsd" ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.29.1.tgz", - "integrity": "sha512-Py5vFd5HWYN9zxBv3WMrLAXY3yYJ6Q/aVERoeUFwiDGiMOWsMs7FokXihSOaT/PMWUty/Pj60XDQndK3eAfE6A==", + "version": "4.34.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.7.tgz", + "integrity": "sha512-Z0TzhrsNqukTz3ISzrvyshQpFnFRfLunYiXxlCRvcrb3nvC5rVKI+ZXPFG/Aa4jhQa1gHgH3A0exHaRRN4VmdQ==", "cpu": [ "arm" ], - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.29.1.tgz", - "integrity": "sha512-RiWpGgbayf7LUcuSNIbahr0ys2YnEERD4gYdISA06wa0i8RALrnzflh9Wxii7zQJEB2/Eh74dX4y/sHKLWp5uQ==", + "version": "4.34.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.7.tgz", + "integrity": "sha512-nkznpyXekFAbvFBKBy4nNppSgneB1wwG1yx/hujN3wRnhnkrYVugMTCBXED4+Ni6thoWfQuHNYbFjgGH0MBXtw==", "cpu": [ "arm" ], - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.29.1.tgz", - "integrity": "sha512-Z80O+taYxTQITWMjm/YqNoe9d10OX6kDh8X5/rFCMuPqsKsSyDilvfg+vd3iXIqtfmp+cnfL1UrYirkaF8SBZA==", + "version": "4.34.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.7.tgz", + "integrity": "sha512-KCjlUkcKs6PjOcxolqrXglBDcfCuUCTVlX5BgzgoJHw+1rWH1MCkETLkLe5iLLS9dP5gKC7mp3y6x8c1oGBUtA==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.29.1.tgz", - "integrity": "sha512-fOHRtF9gahwJk3QVp01a/GqS4hBEZCV1oKglVVq13kcK3NeVlS4BwIFzOHDbmKzt3i0OuHG4zfRP0YoG5OF/rA==", + "version": "4.34.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.7.tgz", + "integrity": "sha512-uFLJFz6+utmpbR313TTx+NpPuAXbPz4BhTQzgaP0tozlLnGnQ6rCo6tLwaSa6b7l6gRErjLicXQ1iPiXzYotjw==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.29.1.tgz", - "integrity": "sha512-5a7q3tnlbcg0OodyxcAdrrCxFi0DgXJSoOuidFUzHZ2GixZXQs6Tc3CHmlvqKAmOs5eRde+JJxeIf9DonkmYkw==", + "version": "4.34.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.7.tgz", + "integrity": "sha512-ws8pc68UcJJqCpneDFepnwlsMUFoWvPbWXT/XUrJ7rWUL9vLoIN3GAasgG+nCvq8xrE3pIrd+qLX/jotcLy0Qw==", "cpu": [ "loong64" ], - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.29.1.tgz", - "integrity": "sha512-9b4Mg5Yfz6mRnlSPIdROcfw1BU22FQxmfjlp/CShWwO3LilKQuMISMTtAu/bxmmrE6A902W2cZJuzx8+gJ8e9w==", + "version": "4.34.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.7.tgz", + "integrity": "sha512-vrDk9JDa/BFkxcS2PbWpr0C/LiiSLxFbNOBgfbW6P8TBe9PPHx9Wqbvx2xgNi1TOAyQHQJ7RZFqBiEohm79r0w==", "cpu": [ "ppc64" ], - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.29.1.tgz", - "integrity": "sha512-G5pn0NChlbRM8OJWpJFMX4/i8OEU538uiSv0P6roZcbpe/WfhEO+AT8SHVKfp8qhDQzaz7Q+1/ixMy7hBRidnQ==", + "version": "4.34.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.7.tgz", + "integrity": "sha512-rB+ejFyjtmSo+g/a4eovDD1lHWHVqizN8P0Hm0RElkINpS0XOdpaXloqM4FBkF9ZWEzg6bezymbpLmeMldfLTw==", "cpu": [ "riscv64" ], - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.29.1.tgz", - "integrity": "sha512-WM9lIkNdkhVwiArmLxFXpWndFGuOka4oJOZh8EP3Vb8q5lzdSCBuhjavJsw68Q9AKDGeOOIHYzYm4ZFvmWez5g==", + "version": "4.34.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.7.tgz", + "integrity": "sha512-nNXNjo4As6dNqRn7OrsnHzwTgtypfRA3u3AKr0B3sOOo+HkedIbn8ZtFnB+4XyKJojIfqDKmbIzO1QydQ8c+Pw==", "cpu": [ "s390x" ], - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.29.1.tgz", - "integrity": "sha512-87xYCwb0cPGZFoGiErT1eDcssByaLX4fc0z2nRM6eMtV9njAfEE6OW3UniAoDhX4Iq5xQVpE6qO9aJbCFumKYQ==", + "version": "4.34.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.7.tgz", + "integrity": "sha512-9kPVf9ahnpOMSGlCxXGv980wXD0zRR3wyk8+33/MXQIpQEOpaNe7dEHm5LMfyRZRNt9lMEQuH0jUKj15MkM7QA==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.29.1.tgz", - "integrity": "sha512-xufkSNppNOdVRCEC4WKvlR1FBDyqCSCpQeMMgv9ZyXqqtKBfkw1yfGMTUTs9Qsl6WQbJnsGboWCp7pJGkeMhKA==", + "version": "4.34.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.7.tgz", + "integrity": "sha512-7wJPXRWTTPtTFDFezA8sle/1sdgxDjuMoRXEKtx97ViRxGGkVQYovem+Q8Pr/2HxiHp74SSRG+o6R0Yq0shPwQ==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.29.1.tgz", - "integrity": "sha512-F2OiJ42m77lSkizZQLuC+jiZ2cgueWQL5YC9tjo3AgaEw+KJmVxHGSyQfDUoYR9cci0lAywv2Clmckzulcq6ig==", + "version": "4.34.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.7.tgz", + "integrity": "sha512-MN7aaBC7mAjsiMEZcsJvwNsQVNZShgES/9SzWp1HC9Yjqb5OpexYnRjF7RmE4itbeesHMYYQiAtUAQaSKs2Rfw==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.29.1.tgz", - "integrity": "sha512-rYRe5S0FcjlOBZQHgbTKNrqxCBUmgDJem/VQTCcTnA2KCabYSWQDrytOzX7avb79cAAweNmMUb/Zw18RNd4mng==", + "version": "4.34.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.7.tgz", + "integrity": "sha512-aeawEKYswsFu1LhDM9RIgToobquzdtSc4jSVqHV8uApz4FVvhFl/mKh92wc8WpFc6aYCothV/03UjY6y7yLgbg==", "cpu": [ "ia32" ], - "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.29.1.tgz", - "integrity": "sha512-+10CMg9vt1MoHj6x1pxyjPSMjHTIlqs8/tBztXvPAx24SKs9jwVnKqHJumlH/IzhaPUaj3T6T6wfZr8okdXaIg==", + "version": "4.34.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.7.tgz", + "integrity": "sha512-4ZedScpxxIrVO7otcZ8kCX1mZArtH2Wfj3uFCxRJ9NO80gg1XV0U/b2f/MKaGwj2X3QopHfoWiDQ917FRpwY3w==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -1115,8 +1096,7 @@ "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "license": "MIT" + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==" }, "node_modules/@vitejs/plugin-react": { "version": "4.3.4", @@ -2084,9 +2064,9 @@ } }, "node_modules/postcss": { - "version": "8.4.49", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", - "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.2.tgz", + "integrity": "sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA==", "funding": [ { "type": "opencollective", @@ -2101,9 +2081,8 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", + "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2339,10 +2318,9 @@ } }, "node_modules/rollup": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.29.1.tgz", - "integrity": "sha512-RaJ45M/kmJUzSWDs1Nnd5DdV4eerC98idtUOVr6FfKcgxqvjwHmxc5upLF9qZU9EpsVzzhleFahrT3shLuJzIw==", - "license": "MIT", + "version": "4.34.7", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.7.tgz", + "integrity": "sha512-8qhyN0oZ4x0H6wmBgfKxJtxM7qS98YJ0k0kNh5ECVtuchIJ7z9IVVvzpmtQyT10PXKMtBxYr1wQ5Apg8RS8kXQ==", "dependencies": { "@types/estree": "1.0.6" }, @@ -2354,25 +2332,25 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.29.1", - "@rollup/rollup-android-arm64": "4.29.1", - "@rollup/rollup-darwin-arm64": "4.29.1", - "@rollup/rollup-darwin-x64": "4.29.1", - "@rollup/rollup-freebsd-arm64": "4.29.1", - "@rollup/rollup-freebsd-x64": "4.29.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.29.1", - "@rollup/rollup-linux-arm-musleabihf": "4.29.1", - "@rollup/rollup-linux-arm64-gnu": "4.29.1", - "@rollup/rollup-linux-arm64-musl": "4.29.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.29.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.29.1", - "@rollup/rollup-linux-riscv64-gnu": "4.29.1", - "@rollup/rollup-linux-s390x-gnu": "4.29.1", - "@rollup/rollup-linux-x64-gnu": "4.29.1", - "@rollup/rollup-linux-x64-musl": "4.29.1", - "@rollup/rollup-win32-arm64-msvc": "4.29.1", - "@rollup/rollup-win32-ia32-msvc": "4.29.1", - "@rollup/rollup-win32-x64-msvc": "4.29.1", + "@rollup/rollup-android-arm-eabi": "4.34.7", + "@rollup/rollup-android-arm64": "4.34.7", + "@rollup/rollup-darwin-arm64": "4.34.7", + "@rollup/rollup-darwin-x64": "4.34.7", + "@rollup/rollup-freebsd-arm64": "4.34.7", + "@rollup/rollup-freebsd-x64": "4.34.7", + "@rollup/rollup-linux-arm-gnueabihf": "4.34.7", + "@rollup/rollup-linux-arm-musleabihf": "4.34.7", + "@rollup/rollup-linux-arm64-gnu": "4.34.7", + "@rollup/rollup-linux-arm64-musl": "4.34.7", + "@rollup/rollup-linux-loongarch64-gnu": "4.34.7", + "@rollup/rollup-linux-powerpc64le-gnu": "4.34.7", + "@rollup/rollup-linux-riscv64-gnu": "4.34.7", + "@rollup/rollup-linux-s390x-gnu": "4.34.7", + "@rollup/rollup-linux-x64-gnu": "4.34.7", + "@rollup/rollup-linux-x64-musl": "4.34.7", + "@rollup/rollup-win32-arm64-msvc": "4.34.7", + "@rollup/rollup-win32-ia32-msvc": "4.34.7", + "@rollup/rollup-win32-x64-msvc": "4.34.7", "fsevents": "~2.3.2" } }, @@ -2719,14 +2697,13 @@ "license": "MIT" }, "node_modules/vite": { - "version": "6.0.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.7.tgz", - "integrity": "sha512-RDt8r/7qx9940f8FcOIAH9PTViRrghKaK2K1jY3RaAURrEUbm9Du1mJ72G+jlhtG3WwodnfzY8ORQZbBavZEAQ==", - "license": "MIT", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.0.tgz", + "integrity": "sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ==", "dependencies": { "esbuild": "^0.24.2", - "postcss": "^8.4.49", - "rollup": "^4.23.0" + "postcss": "^8.5.1", + "rollup": "^4.30.1" }, "bin": { "vite": "bin/vite.js" diff --git a/frontend/src/components/LLMResponse.jsx b/frontend/src/components/LLMResponse.jsx index feedbf7..33de334 100644 --- a/frontend/src/components/LLMResponse.jsx +++ b/frontend/src/components/LLMResponse.jsx @@ -27,7 +27,7 @@ const LLMResponse = memo(({ data, onConfirm, isLastMessage, onHeightChange }) => : data?.response; const displayText = (response || '').trim(); - const requiresConfirm = data.next === "confirm" && isLastMessage; + const requiresConfirm = data.force_confirm && data.next === "confirm" && isLastMessage; const defaultText = requiresConfirm ? `Agent is ready to run "${data.tool}". Please confirm.` : ''; diff --git a/models/data_types.py b/models/data_types.py index 6f331a1..39b9357 100644 --- a/models/data_types.py +++ b/models/data_types.py @@ -17,7 +17,7 @@ class CombinedInput: Message = Dict[str, Union[str, Dict[str, Any]]] ConversationHistory = Dict[str, List[Message]] -NextStep = Literal["confirm", "question", "done"] +NextStep = Literal["confirm", "question", "pick-new-goal", "done"] @dataclass diff --git a/models/tool_definitions.py b/models/tool_definitions.py index d4b085f..76d9ab3 100644 --- a/models/tool_definitions.py +++ b/models/tool_definitions.py @@ -15,9 +15,11 @@ class ToolDefinition: description: str arguments: List[ToolArgument] - @dataclass class AgentGoal: + id: str + agent_name: str + agent_friendly_description: str tools: List[ToolDefinition] description: str = "Description of the tools purpose and overall goal" starter_prompt: str = "Initial prompt to start the conversation" diff --git a/prompts/agent_prompt_generators.py b/prompts/agent_prompt_generators.py index 205205a..4ce9194 100644 --- a/prompts/agent_prompt_generators.py +++ b/prompts/agent_prompt_generators.py @@ -68,7 +68,7 @@ def generate_genai_prompt( "Your JSON format must be:\n" "{\n" ' "response": "",\n' - ' "next": "",\n' + ' "next": "",\n' ' "tool": "",\n' ' "args": {\n' ' "": "",\n' @@ -81,9 +81,8 @@ def generate_genai_prompt( "1) If any required argument is missing, set next='question' and ask the user.\n" "2) If all required arguments are known, set next='confirm' and specify the tool.\n" " The user will confirm before the tool is run.\n" - "3) If no more tools are needed (user_confirmed_tool_run has been run for all), set next='done' and tool=null.\n" + "3) If no more tools are needed (user_confirmed_tool_run has been run for all), set next='confirm' and tool='ListAgents'.\n" "4) response should be short and user-friendly.\n" - "5) Don't set next='done' until the final tool has returned user_confirmed_tool_run.\n" ) # Validation Task (If raw_json is provided) @@ -123,12 +122,11 @@ def generate_tool_completion_prompt(current_tool: str, dynamic_result: dict) -> 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": "", "tool": "", "args": {"": "", "": "}, "response": ""}' - "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.' + "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": "", "tool": "", "args": {"": "", "": "}, "response": ""}' + "ONLY return those json keys (next, tool, args, response), nothing else. " + 'Next should be "question" if the tool is not the last one in the sequence. ' + 'Next should only be "pick-new-goal" if all tools have been run (use the system prompt to figure that out).' ) def generate_missing_args_prompt(current_tool: str, tool_data: dict, missing_args: list[str]) -> str: diff --git a/setup.md b/setup.md new file mode 100644 index 0000000..5f20618 --- /dev/null +++ b/setup.md @@ -0,0 +1,176 @@ +## Configuration + +This application uses `.env` files for configuration. Copy the [.env.example](.env.example) file to `.env` and update the values: + +```bash +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 Australia / New Zealand, 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. + * If you're lazy go to `tools/search_fixtures.py` and replace the `search_fixtures` function with the mock `search_fixtures_example` that exists in the same file. +* We use a mock function to search for trains. Start the train API server to use the real API: `python thirdparty/train_api.py` +* * The train activity is 'enterprise' so it's written in C# and requires a .NET runtime. See the [.NET backend](#net-(enterprise)-backend) section for details on running it. +* Requires a Stripe key for the `create_invoice` tool. Set this in the `STRIPE_API_KEY` environment variable in .env + * It's free to sign up and get a key at [Stripe](https://stripe.com/) + * If you're lazy go to `tools/create_invoice.py` and replace the `create_invoice` function with the mock `create_invoice_example` that exists in the same file. + +### LLM Provider Configuration + +The agent can use OpenAI's GPT-4o, Google Gemini, Anthropic Claude, or a local LLM via Ollama. Set the `LLM_PROVIDER` environment variable in your `.env` file to choose the desired provider: + +- `LLM_PROVIDER=openai` for OpenAI's GPT-4o +- `LLM_PROVIDER=google` for Google Gemini +- `LLM_PROVIDER=anthropic` for Anthropic Claude +- `LLM_PROVIDER=deepseek` for DeepSeek-V3 +- `LLM_PROVIDER=ollama` for running LLMs via [Ollama](https://ollama.ai) (not recommended for this use case) + +### Option 1: OpenAI + +If using OpenAI, ensure you have an OpenAI key for the GPT-4o model. Set this in the `OPENAI_API_KEY` environment variable in `.env`. + +### Option 2: Google Gemini + +To use Google Gemini: + +1. Obtain a Google API key and set it in the `GOOGLE_API_KEY` environment variable in `.env`. +2. Set `LLM_PROVIDER=google` in your `.env` file. + +### Option 3: Anthropic Claude (recommended) + +I find that Claude Sonnet 3.5 performs better than the other hosted LLMs for this use case. + +To use Anthropic: + +1. Obtain an Anthropic API key and set it in the `ANTHROPIC_API_KEY` environment variable in `.env`. +2. Set `LLM_PROVIDER=anthropic` in your `.env` file. + +### Option 4: Deepseek-V3 + +To use Deepseek-V3: + +1. Obtain a Deepseek API key and set it in the `DEEPSEEK_API_KEY` environment variable in `.env`. +2. Set `LLM_PROVIDER=deepseek` in your `.env` file. + +### Option 5: Local LLM via Ollama (not recommended) + +To use a local LLM with Ollama: + +1. Install [Ollama](https://ollama.com) and the [Qwen2.5 14B](https://ollama.com/library/qwen2.5) model. + - Run `ollama run ` to start the model. Note that this model is about 9GB to download. + - Example: `ollama run qwen2.5:14b` + +2. Set `LLM_PROVIDER=ollama` in your `.env` file and `OLLAMA_MODEL_NAME` to the name of the model you installed. + +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. + +## 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. + +### Use Temporal Cloud + +See [.env.example](.env.example) for details on connecting to Temporal Cloud using mTLS or API key authentication. + +[Sign up for Temporal Cloud](https://temporal.io/get-cloud) + +### Use a local Temporal Dev Server + +On a Mac +```bash +brew install temporal +temporal server start-dev +``` +See the [Temporal documentation](https://learn.temporal.io/getting_started/python/dev_environment/) for other platforms. + + +## Running the Application + +### Python Backend + +Requires [Poetry](https://python-poetry.org/) to manage dependencies. + +1. `python -m venv venv` + +2. `source venv/bin/activate` + +3. `poetry install` + +Run the following commands in separate terminal windows: + +1. Start the Temporal worker: +```bash +poetry run python scripts/run_worker.py +``` + +2. Start the API server: +```bash +poetry run uvicorn api.main:app --reload +``` +Access the API at `/docs` to see the available endpoints. + +### React UI +Start the frontend: +```bash +cd frontend +npm install +npx vite +``` +Access the UI at `http://localhost:5173` + +### Python Search Trains API +> Agent Goal: goal_match_train_invoice only + +Required to search and book trains! +```bash +poetry run python thirdparty/train_api.py + +# example url +# http://localhost:8080/api/search?from=london&to=liverpool&outbound_time=2025-04-18T09:00:00&inbound_time=2025-04-20T09:00:00 +``` + +### .NET (enterprise) Backend ;) +> Agent Goal: goal_match_train_invoice only + +We have activities written in C# to call the train APIs. +```bash +cd enterprise +dotnet build # ensure you brew install dotnet@8 first! +dotnet run +``` +If you're running your train API above on a different host/port then change the API URL in `Program.cs`. Otherwise, be sure to run it using `python thirdparty/train_api.py`. + +## Customizing the Agent +- `tool_registry.py` contains the mapping of tool names to tool definitions (so the AI understands how to use them) +- `goal_registry.py` contains descriptions of goals and the tools used to achieve them +- The tools themselves are defined in their own files in `/tools` +- Note the mapping in `tools/__init__.py` to each tool \ No newline at end of file diff --git a/shared/config.py b/shared/config.py index 282e6d2..9590634 100644 --- a/shared/config.py +++ b/shared/config.py @@ -16,7 +16,6 @@ TEMPORAL_TLS_CERT = os.getenv("TEMPORAL_TLS_CERT", "") TEMPORAL_TLS_KEY = os.getenv("TEMPORAL_TLS_KEY", "") TEMPORAL_API_KEY = os.getenv("TEMPORAL_API_KEY", "") - async def get_temporal_client() -> Client: """ Creates a Temporal client based on environment configuration. diff --git a/tests/agent_goal_workflow_test.py b/tests/agent_goal_workflow_test.py new file mode 100644 index 0000000..b280def --- /dev/null +++ b/tests/agent_goal_workflow_test.py @@ -0,0 +1,55 @@ +import asyncio + +from temporalio.client import Client, WorkflowExecutionStatus +from temporalio.worker import Worker +from temporalio.testing import TestWorkflowEnvironment +from api.main import get_initial_agent_goal +from models.data_types import AgentGoalWorkflowParams, CombinedInput +from workflows import AgentGoalWorkflow +from activities.tool_activities import ToolActivities, dynamic_tool_activity + + +async def asyncSetUp(self): + # Set up the test environment + self.env = await TestWorkflowEnvironment.create_local() + +async def asyncTearDown(self): + # Clean up after tests + await self.env.shutdown() + +async def test_workflow_success(client: Client): + # Register the workflow and activity + # self.env.register_workflow(AgentGoalWorkflow) + # self.env.register_activity(ToolActivities.agent_validatePrompt) + # self.env.register_activity(ToolActivities.agent_toolPlanner) + # self.env.register_activity(dynamic_tool_activity) + + task_queue_name = "agent-ai-workflow" + workflow_id = "agent-workflow" + + initial_agent_goal = get_initial_agent_goal() + + # Create combined input + combined_input = CombinedInput( + tool_params=AgentGoalWorkflowParams(None, None), + agent_goal=initial_agent_goal, + ) + + workflow_id = "agent-workflow" + async with Worker(client, task_queue=task_queue_name, workflows=[AgentGoalWorkflow], activities=[ToolActivities.agent_validatePrompt, ToolActivities.agent_toolPlanner, dynamic_tool_activity]): + handle = await client.start_workflow( + AgentGoalWorkflow.run, id=workflow_id, task_queue=task_queue_name + ) + # todo fix signals + await handle.signal(AgentGoalWorkflow.submit_greeting, "user1") + await handle.signal(AgentGoalWorkflow.submit_greeting, "user2") + assert WorkflowExecutionStatus.RUNNING == (await handle.describe()).status + + await handle.signal(AgentGoalWorkflow.exit) + assert ["Hello, user1", "Hello, user2"] == await handle.result() + assert WorkflowExecutionStatus.COMPLETED == (await handle.describe()).status + + + + + \ No newline at end of file diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..d477f4c --- /dev/null +++ b/todo.md @@ -0,0 +1,45 @@ +# todo list +[ ] clean up workflow/make functions + +[ ] make the debugging confirms optional
+
+[ ] document *why* temporal for ai agents - scalability, durability, visibility in the readme
+[ ] fix readme: move setup to its own page, demo to its own page, add the why /|\ section
+[ ] add architecture to readme
+- elements of app
+- dive into llm interaction
+- workflow breakdown - interactive loop
+- why temporal
+ +[ ] setup readme, why readme, architecture readme, what this is in main readme with temporal value props and pictures
+[ ] how to add more scenarios, tools
+
+
+[ ] create tests
+ +[ ] create people management scenario
+- check pay status
+- book work travel
+- check PTO levels
+- check insurance coverages
+- book PTO around a date (https://developers.google.com/calendar/api/guides/overview)?
+- scenario should use multiple tools
+- expense management
+- check in on the health of the team
+ +[ ] demo the reasons why:
+- Orchestrate interactions across distributed data stores and tools
+- Hold state, potentially over long periods of time
+- Ability to ‘self-heal’ and retry until the (probabilistic) LLM returns valid data
+- Support for human intervention such as approvals
+- Parallel processing for efficiency of data retrieval and tool use
+- Insight into the agent’s performance
+ - ask the ai agent how it did at the end of the conversation, was it efficient? successful? insert a search attribute to document that before return + +[ ] customize prompts in [workflow to manage scenario](./workflows/tool_workflow.py)
+[ ] add in new tools?
+ +[ ] non-retry the api key error - "Invalid API Key provided: sk_test_**J..." and "AuthenticationError"
+[ ] make it so you can yeet yourself out of a goal and pick a new one
+ +[ ] add visual feedback when workflow starting \ No newline at end of file diff --git a/tools/__init__.py b/tools/__init__.py index 37672c7..1ca7da2 100644 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -4,6 +4,9 @@ from .search_trains import search_trains from .search_trains import book_trains from .create_invoice import create_invoice from .find_events import find_events +from .list_agents import list_agents +from .change_goal import change_goal +from .transfer_control import transfer_control def get_handler(tool_name: str): @@ -19,5 +22,11 @@ def get_handler(tool_name: str): return create_invoice if tool_name == "FindEvents": return find_events + if tool_name == "ListAgents": + return list_agents + if tool_name == "ChangeGoal": + return change_goal + if tool_name == "TransferControl": + return transfer_control raise ValueError(f"Unknown tool: {tool_name}") diff --git a/tools/change_goal.py b/tools/change_goal.py new file mode 100644 index 0000000..df897ef --- /dev/null +++ b/tools/change_goal.py @@ -0,0 +1,9 @@ +def change_goal(args: dict) -> dict: + + new_goal = args.get("goalID") + if new_goal is None: + new_goal = "goal_choose_agent_type" + + return { + "new_goal": new_goal, + } \ No newline at end of file diff --git a/tools/create_invoice.py b/tools/create_invoice.py index 4771a53..9fcd1aa 100644 --- a/tools/create_invoice.py +++ b/tools/create_invoice.py @@ -4,7 +4,7 @@ from dotenv import load_dotenv load_dotenv(override=True) # Load environment variables from a .env file -stripe.api_key = os.getenv("STRIPE_API_KEY", "YOUR_DEFAULT_KEY") +stripe.api_key = os.getenv("STRIPE_API_KEY") def ensure_customer_exists( @@ -26,41 +26,49 @@ def ensure_customer_exists( def create_invoice(args: dict) -> dict: """Create and finalize a Stripe invoice.""" - # Find or create customer - customer_id = ensure_customer_exists( - args.get("customer_id"), args.get("email", "default@example.com") - ) + # If an API key exists in the env file, find or create customer + if stripe.api_key is not None: + customer_id = ensure_customer_exists( + args.get("customer_id"), args.get("email", "default@example.com") + ) - # Get amount and convert to cents - amount = args.get("amount", 200.00) # Default to $200.00 - try: - amount_cents = int(float(amount) * 100) - except (TypeError, ValueError): - return {"error": "Invalid amount provided. Please confirm the amount."} + # Get amount and convert to cents + amount = args.get("amount", 200.00) # Default to $200.00 + try: + amount_cents = int(float(amount) * 100) + except (TypeError, ValueError): + return {"error": "Invalid amount provided. Please confirm the amount."} - # Create an invoice item - stripe.InvoiceItem.create( - customer=customer_id, - amount=amount_cents, - currency="gbp", - description=args.get("tripDetails", "Service Invoice"), - ) + # Create an invoice item + stripe.InvoiceItem.create( + customer=customer_id, + amount=amount_cents, + currency="gbp", + description=args.get("tripDetails", "Service Invoice"), + ) - # Create and finalize the invoice - invoice = stripe.Invoice.create( - customer=customer_id, - collection_method="send_invoice", # Invoice is sent to the customer - days_until_due=args.get("days_until_due", 7), # Default due date: 7 days - pending_invoice_items_behavior="include", # No pending invoice items - ) - finalized_invoice = stripe.Invoice.finalize_invoice(invoice.id) - - return { - "invoiceStatus": finalized_invoice.status, - "invoiceURL": finalized_invoice.hosted_invoice_url, - "reference": finalized_invoice.number, - } + # Create and finalize the invoice + invoice = stripe.Invoice.create( + customer=customer_id, + collection_method="send_invoice", # Invoice is sent to the customer + days_until_due=args.get("days_until_due", 7), # Default due date: 7 days + pending_invoice_items_behavior="include", # No pending invoice items + ) + finalized_invoice = stripe.Invoice.finalize_invoice(invoice.id) + return { + "invoiceStatus": finalized_invoice.status, + "invoiceURL": finalized_invoice.hosted_invoice_url, + "reference": finalized_invoice.number, + } + # if no API key is in the env file, return dummy info + else: + print("[CreateInvoice] Creating invoice with:", args) + return { + "invoiceStatus": "generated", + "invoiceURL": "https://pay.example.com/invoice/12345", + "reference": "INV-12345", + } def create_invoice_example(args: dict) -> dict: """ diff --git a/tools/goal_registry.py b/tools/goal_registry.py index a903d68..39e2187 100644 --- a/tools/goal_registry.py +++ b/tools/goal_registry.py @@ -1,3 +1,4 @@ +from typing import List from models.tool_definitions import AgentGoal from tools.tool_registry import ( search_fixtures_tool, @@ -6,14 +7,50 @@ from tools.tool_registry import ( book_trains_tool, create_invoice_tool, find_events_tool, + change_goal_tool, + list_agents_tool +) + +starter_prompt_generic = "Welcome me, give me a description of what you can do, then ask me for the details you need to do your job" + +goal_choose_agent_type = AgentGoal( + id = "goal_choose_agent_type", + agent_name="Choose Agent", + agent_friendly_description="Choose the type of agent to assist you today.", + tools=[ + list_agents_tool, + change_goal_tool, + ], + description="The user wants to choose which type of agent they will interact with. " + "Help the user gather args for these tools, in order: " + "1. ListAgents: List agents available to interact with " + "2. ChangeGoal: Change goal of agent " + "After these tools are complete, change your goal to the new goal as chosen by the user. ", + starter_prompt=starter_prompt_generic, + example_conversation_history="\n ".join( + [ + "user: I'd like to choose an agent", + "agent: Sure! Would you like me to list the available agents?", + "user_confirmed_tool_run: ", + "tool_result: { 'agent_name': 'Event Flight Finder', 'goal_id': 'goal_event_flight_invoice', 'agent_description': 'Helps users find interesting events and arrange travel to them' }", + "agent: The available agents are: 1. Event Flight Finder. Which agent would you like to speak to?", + "user: 1", + "user_confirmed_tool_run: ", + "tool_result: { 'new_goal': 'goal_event_flight_invoice' }", + ] + ), ) goal_match_train_invoice = AgentGoal( + id = "goal_match_train_invoice", + agent_name="UK Premier League Match Trip Booking", + agent_friendly_description="Book a trip to a city in the UK around the dates of a premier league match.", tools=[ search_fixtures_tool, search_trains_tool, book_trains_tool, create_invoice_tool, + list_agents_tool, #last tool must be list_agents to fasciliate changing back to picking an agent again at the end ], 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. " @@ -23,7 +60,7 @@ goal_match_train_invoice = AgentGoal( "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 ", + starter_prompt=starter_prompt_generic, example_conversation_history="\n ".join( [ "user: I'd like to travel to a premier league match", @@ -51,18 +88,21 @@ goal_match_train_invoice = AgentGoal( ), ) -# unused goal_event_flight_invoice = AgentGoal( + id = "goal_event_flight_invoice", + agent_name="Australia and New Zealand Event Flight Booking", + agent_friendly_description="Book a trip to a city in Australia or New Zealand around the dates of events in that city.", tools=[ find_events_tool, search_flights_tool, create_invoice_tool, + list_agents_tool, #last tool must be list_agents to fasciliate changing back to picking an agent again at the end ], description="Help the user gather args for these tools in order: " "1. FindEvents: Find an event to travel to " "2. SearchFlights: search for a flight around the event dates " "3. CreateInvoice: Create a simple invoice for the cost of that flight ", - starter_prompt="Welcome me, give me a description of what you can do, then ask me for the details you need to do your job", + starter_prompt=starter_prompt_generic, example_conversation_history="\n ".join( [ "user: I'd like to travel to an event", @@ -85,3 +125,9 @@ goal_event_flight_invoice = AgentGoal( ] ), ) + +#Add the goals to a list for more generic processing, like listing available agents +goal_list: List[AgentGoal] = [] +goal_list.append(goal_choose_agent_type) +goal_list.append(goal_event_flight_invoice) +goal_list.append(goal_match_train_invoice) diff --git a/tools/list_agents.py b/tools/list_agents.py new file mode 100644 index 0000000..1fc56f3 --- /dev/null +++ b/tools/list_agents.py @@ -0,0 +1,17 @@ +import tools.goal_registry as goals + +def list_agents(args: dict) -> dict: + + agents = [] + if goals.goal_list is not None: + for goal in goals.goal_list: + agents.append( + { + "agent_name": goal.agent_name, + "goal_id": goal.id, + "agent_description": goal.agent_friendly_description, + } + ) + return { + "agents": agents, + } diff --git a/tools/tool_registry.py b/tools/tool_registry.py index 7fdc243..f80aa8b 100644 --- a/tools/tool_registry.py +++ b/tools/tool_registry.py @@ -1,5 +1,23 @@ from models.tool_definitions import ToolDefinition, ToolArgument +list_agents_tool = ToolDefinition( + name="ListAgents", + description="List available agents to interact with, pulled from goal_registry. ", + arguments=[], +) + +change_goal_tool = ToolDefinition( + name="ChangeGoal", + description="Change the goal of the active agent. ", + arguments=[ + ToolArgument( + name="goalID", + type="string", + description="Which goal to change to", + ), + ], +) + search_flights_tool = ToolDefinition( name="SearchFlights", description="Search for return flights from an origin to a destination within a date range (dateDepart, dateReturn).", diff --git a/tools/transfer_control.py b/tools/transfer_control.py new file mode 100644 index 0000000..127cc38 --- /dev/null +++ b/tools/transfer_control.py @@ -0,0 +1,7 @@ +import shared.config + +def transfer_control(args: dict) -> dict: + + return { + "new_goal": shared.config.AGENT_GOAL, + } \ No newline at end of file diff --git a/workflows/agent_goal_workflow.py b/workflows/agent_goal_workflow.py index fdc8fc5..3f73866 100644 --- a/workflows/agent_goal_workflow.py +++ b/workflows/agent_goal_workflow.py @@ -1,11 +1,13 @@ from collections import deque from datetime import timedelta +import os 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 models.tool_definitions import AgentGoal from workflows.workflow_helpers import LLM_ACTIVITY_START_TO_CLOSE_TIMEOUT, \ LLM_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT from workflows import workflow_helpers as helpers @@ -19,15 +21,24 @@ with workflow.unsafe.imports_passed_through(): CombinedInput, ToolPromptInput, ) + from tools.goal_registry import goal_list # Constants MAX_TURNS_BEFORE_CONTINUE = 250 +SHOW_CONFIRM = True +show_confirm_env = os.getenv("SHOW_CONFIRM") +if show_confirm_env is not None: + if show_confirm_env == "False": + SHOW_CONFIRM = False + +#ToolData as part of the workflow is what's accessible to the UI - see LLMResponse.jsx for example class ToolData(TypedDict, total=False): next: NextStep tool: str args: Dict[str, Any] response: str + force_confirm: bool = True @workflow.defn class AgentGoalWorkflow: @@ -41,13 +52,18 @@ class AgentGoalWorkflow: self.tool_data: Optional[ToolData] = None self.confirm: bool = False self.tool_results: List[Dict[str, Any]] = [] + self.goal: AgentGoal = {"tools": []} + # see ../api/main.py#temporal_client.start_workflow() for how the input parameters are set @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 + """Main workflow execution method.""" + # setup phase, starts with blank tool_params and agent_goal prompt as defined in tools/goal_registry.py + params = combined_input.tool_params + self.goal = combined_input.agent_goal + + # add message from sample conversation provided in tools/goal_registry.py, if it exists if params and params.conversation_summary: self.add_message("conversation_summary", params.conversation_summary) self.conversation_summary = params.conversation_summary @@ -55,19 +71,29 @@ class AgentGoalWorkflow: if params and params.prompt_queue: self.prompt_queue.extend(params.prompt_queue) - waiting_for_confirm = False + waiting_for_confirm = False current_tool = None + # This is the main interactive loop. Main responsibilities: + # - Selecting and changing goals as directed by the user + # - reacting to user input (from signals) + # - calling activities to determine next steps and prompts + # - executing the selected tools while True: + # wait indefinitely for input from signals - user_prompt, end_chat, or confirm as defined below await workflow.wait_condition( lambda: bool(self.prompt_queue) or self.chat_ended or self.confirm ) + # handle chat-end signal if self.chat_ended: + workflow.logger.warning(f"workflow step: chat-end signal received, ending") workflow.logger.info("Chat ended.") return f"{self.conversation_history}" + # Execute the tool if self.confirm and waiting_for_confirm and current_tool and self.tool_data: + workflow.logger.warning(f"workflow step: user has confirmed, executing the tool {current_tool}") self.confirm = False waiting_for_confirm = False @@ -75,6 +101,7 @@ class AgentGoalWorkflow: confirmed_tool_data["next"] = "user_confirmed_tool_run" self.add_message("user_confirmed_tool_run", confirmed_tool_data) + # execute the tool by key as defined in tools/__init__.py await helpers.handle_tool_execution( current_tool, self.tool_data, @@ -82,18 +109,30 @@ class AgentGoalWorkflow: self.add_message, self.prompt_queue ) + + #set new goal if we should + if len(self.tool_results) > 0: + if "ChangeGoal" in self.tool_results[-1].values() and "new_goal" in self.tool_results[-1].keys(): + new_goal = self.tool_results[-1].get("new_goal") + workflow.logger.warning(f"Booya new goal!: {new_goal}") + self.change_goal(new_goal) + elif "ListAgents" in self.tool_results[-1].values() and self.goal.id != "goal_choose_agent_type": + workflow.logger.warning("setting goal to goal_choose_agent_type") + self.change_goal("goal_choose_agent_type") continue + # if we've received messages to be processed on the prompt queue... if self.prompt_queue: prompt = self.prompt_queue.popleft() - if not prompt.startswith("###"): + workflow.logger.warning(f"workflow step: processing message on the prompt queue, message is {prompt}") + if not prompt.startswith("###"): #if the message isn't from the LLM but is instead from the user self.add_message("user", prompt) # Validate the prompt before proceeding validation_input = ValidationInput( prompt=prompt, conversation_history=self.conversation_history, - agent_goal=agent_goal, + agent_goal=self.goal, ) validation_result = await workflow.execute_activity( ToolActivities.agent_validatePrompt, @@ -105,25 +144,17 @@ class AgentGoalWorkflow: ), ) + #If validation fails, provide that feedback to the user - i.e., "your words make no sense, puny human" end this iteration of processing if not validation_result.validationResult: - workflow.logger.warning( - f"Prompt validation failed: {validation_result.validationFailedReason}" - ) - self.add_message( - "agent", validation_result.validationFailedReason - ) + 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, - ) + # If valid, proceed with generating the context and prompt + context_instructions = generate_genai_prompt(self.goal, self.conversation_history, self.tool_data) + prompt_input = ToolPromptInput(prompt=prompt, context_instructions=context_instructions) + # connect to LLM and execute to get next steps tool_data = await workflow.execute_activity( ToolActivities.agent_toolPlanner, prompt_input, @@ -133,57 +164,84 @@ class AgentGoalWorkflow: initial_interval=timedelta(seconds=5), backoff_coefficient=1 ), ) + tool_data["force_confirm"] = SHOW_CONFIRM self.tool_data = tool_data + # process the tool as dictated by the prompt response - what to do next, and with which tool next_step = tool_data.get("next") current_tool = tool_data.get("tool") + workflow.logger.warning(f"next_step: {next_step}, current tool is {current_tool}") + #if the next step is to confirm... if next_step == "confirm" and current_tool: args = tool_data.get("args", {}) + #if we're missing arguments, go back to the top of the loop if await helpers.handle_missing_args(current_tool, args, tool_data, self.prompt_queue): continue + #...otherwise, if we want to force the user to confirm, set that up waiting_for_confirm = True - self.confirm = False - workflow.logger.info("Waiting for user confirm signal...") + if SHOW_CONFIRM: + self.confirm = False + workflow.logger.info("Waiting for user confirm signal...") + else: + #theory - set self.confirm to true bc that's the signal, so we can get around the signal?? + self.confirm = True + # else if the next step is to pick a new goal... + elif next_step == "pick-new-goal": + workflow.logger.info("All steps completed. Resetting goal.") + self.change_goal("goal_choose_agent_type") + + # else if the next step is to be done - this should only happen if the user requests it via "end conversation" elif next_step == "done": - workflow.logger.info("All steps completed. Exiting workflow.") self.add_message("agent", tool_data) + # end the workflow 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, + self.goal, MAX_TURNS_BEFORE_CONTINUE, self.add_message ) + #Signal that comes from api/main.py via a post to /send-prompt @workflow.signal async def user_prompt(self, prompt: str) -> None: """Signal handler for receiving user prompts.""" + workflow.logger.warning(f"signal received: user_prompt, prompt is {prompt}") if self.chat_ended: - workflow.logger.warn(f"Message dropped due to chat closed: {prompt}") + workflow.logger.warning(f"Message dropped due to chat closed: {prompt}") return self.prompt_queue.append(prompt) + #Signal that comes from api/main.py via a post to /confirm @workflow.signal async def confirm(self) -> None: """Signal handler for user confirmation of tool execution.""" workflow.logger.info("Received user confirmation") + workflow.logger.warning(f"signal recieved: confirm") self.confirm = True + #Signal that comes from api/main.py via a post to /end-chat @workflow.signal async def end_chat(self) -> None: """Signal handler for ending the chat session.""" + workflow.logger.warning("signal received: end_chat") 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_agent_goal(self) -> AgentGoal: + """Query handler to retrieve the current goal of the agent.""" + return self.goal @workflow.query def get_summary_from_history(self) -> Optional[str]: @@ -212,3 +270,18 @@ class AgentGoalWorkflow: self.conversation_history["messages"].append( {"actor": actor, "response": response} ) + + def change_goal(self, goal: str) -> None: + '''goalsLocal = { + "goal_match_train_invoice": goal_match_train_invoice, + "goal_event_flight_invoice": goal_event_flight_invoice, + "goal_choose_agent_type": goal_choose_agent_type, + }''' + + if goal is not None: + for listed_goal in goal_list: + if listed_goal.id == goal: + self.goal = listed_goal + # self.goal = goals.get(goal) + workflow.logger.warning("Changed goal to " + goal) + #todo reset goal or tools if this doesn't work or whatever