├── __init__.py ├── models ├── __init__.py ├── tool_definitions.py └── data_types.py ├── tests ├── __init__.py ├── test_workflow_helpers.py ├── conftest.py └── workflowtests │ └── agent_goal_workflow_test.py ├── activities └── __init__.py ├── prompts └── __init__.py ├── workflows └── __init__.py ├── enterprise ├── .gitignore ├── Models │ ├── BookTrainRequest.cs │ ├── JourneyResponse.cs │ ├── SearchTrainsRequest.cs │ ├── BookTrainsResponse.cs │ └── Journey.cs ├── TrainSearchWorker.csproj ├── TemporalClientHelper.cs ├── Program.cs └── Activities │ └── TrainActivities.cs ├── assets ├── 0.2.0_changes.jpeg ├── agent-youtube-screenshot.jpeg └── temporal-agentic-ai-slides.pdf ├── docs ├── assets │ ├── interaction_loop.png │ ├── Architecture_elements.png │ ├── argument_gathering_cycle.png │ └── ai_agent_architecture_model.png ├── README.md ├── changelog.md ├── todo.md ├── architecture-decisions.md ├── architecture.md ├── contributing.md └── testing.md ├── frontend ├── postcss.config.js ├── vite.config.js ├── src │ ├── main.jsx │ ├── hooks │ │ └── useLocalChatHistory.js │ ├── components │ │ ├── NavBar.jsx │ │ ├── LoadingIndicator.jsx │ │ ├── LLMResponse.jsx │ │ ├── MessageBubble.jsx │ │ ├── ChatWindow.jsx │ │ └── ConfirmInline.jsx │ ├── index.css │ └── services │ │ └── api.js ├── tailwind.config.js ├── index.html ├── package.json └── .gitignore ├── tools ├── transfer_control.py ├── search_trains.py ├── change_goal.py ├── hr │ ├── book_pto.py │ ├── checkpaybankstatus.py │ ├── current_pto.py │ └── future_pto_calc.py ├── data │ ├── employee_pto_data.json │ ├── customer_account_data.json │ ├── customer_order_data.json │ └── dummy_tracking_data.json ├── ecommerce │ ├── get_order.py │ ├── list_orders.py │ └── track_package.py ├── guess_location.py ├── fin │ ├── check_account_valid.py │ ├── get_account_balances.py │ ├── submit_loan_application.py │ └── move_money.py ├── food │ ├── add_to_cart.py │ └── setup │ │ ├── archive_food_products.py │ │ ├── create_stripe_products.py │ │ └── stripe_pizza_products.json ├── list_agents.py ├── give_hint.py ├── find_events.py ├── create_invoice.py └── __init__.py ├── scripts ├── find_events_test.py ├── flight_api_test.py ├── create_invoice_test.py ├── send_confirm.py ├── end_chat.py ├── get_history.py ├── run_legacy_worker.py └── run_worker.py ├── .gitignore ├── docker-compose.override.yml ├── .devcontainer └── devcontainer.json ├── shared ├── mcp_config.py ├── config.py └── mcp_client_manager.py ├── .github └── workflows │ └── ci.yml ├── Dockerfile ├── LICENSE ├── temporal-ai-agent.sln ├── goals ├── __init__.py ├── stripe_mcp.py ├── hr.py ├── agent_selection.py ├── ecommerce.py └── finance.py ├── Makefile ├── pyproject.toml ├── .env.example ├── docker-compose.yml ├── README.md └── api └── main.py /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /activities/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /prompts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /workflows/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /enterprise/.gitignore: -------------------------------------------------------------------------------- 1 | obj 2 | bin 3 | -------------------------------------------------------------------------------- /assets/0.2.0_changes.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/temporal-ai-agent/main/assets/0.2.0_changes.jpeg -------------------------------------------------------------------------------- /docs/assets/interaction_loop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/temporal-ai-agent/main/docs/assets/interaction_loop.png -------------------------------------------------------------------------------- /assets/agent-youtube-screenshot.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/temporal-ai-agent/main/assets/agent-youtube-screenshot.jpeg -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /assets/temporal-agentic-ai-slides.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/temporal-ai-agent/main/assets/temporal-agentic-ai-slides.pdf -------------------------------------------------------------------------------- /docs/assets/Architecture_elements.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/temporal-ai-agent/main/docs/assets/Architecture_elements.png -------------------------------------------------------------------------------- /docs/assets/argument_gathering_cycle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/temporal-ai-agent/main/docs/assets/argument_gathering_cycle.png -------------------------------------------------------------------------------- /docs/assets/ai_agent_architecture_model.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/temporal-ai-agent/main/docs/assets/ai_agent_architecture_model.png -------------------------------------------------------------------------------- /tools/transfer_control.py: -------------------------------------------------------------------------------- 1 | import shared.config 2 | 3 | 4 | def transfer_control(args: dict) -> dict: 5 | return { 6 | "new_goal": shared.config.AGENT_GOAL, 7 | } 8 | -------------------------------------------------------------------------------- /tools/search_trains.py: -------------------------------------------------------------------------------- 1 | def search_trains(args: dict) -> dict: 2 | raise NotImplementedError("TODO implement :)") 3 | 4 | 5 | def book_trains(args: dict) -> dict: 6 | raise NotImplementedError("TODO implement :)") 7 | -------------------------------------------------------------------------------- /tools/change_goal.py: -------------------------------------------------------------------------------- 1 | def change_goal(args: dict) -> dict: 2 | new_goal = args.get("goalID") 3 | if new_goal is None: 4 | new_goal = "goal_choose_agent_type" 5 | 6 | return { 7 | "new_goal": new_goal, 8 | } 9 | -------------------------------------------------------------------------------- /enterprise/Models/BookTrainRequest.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace TrainSearchWorker.Models; 4 | 5 | public record BookTrainsRequest 6 | { 7 | [JsonPropertyName("train_ids")] 8 | public required string TrainIds { get; init; } 9 | } 10 | -------------------------------------------------------------------------------- /frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | server: { 7 | open: true, 8 | host: process.env.VITE_HOST ?? 'localhost', 9 | }, 10 | }); -------------------------------------------------------------------------------- /enterprise/Models/JourneyResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace TrainSearchWorker.Models; 5 | 6 | public record JourneyResponse 7 | { 8 | [JsonPropertyName("journeys")] 9 | public List? Journeys { get; init; } 10 | } 11 | -------------------------------------------------------------------------------- /scripts/find_events_test.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from tools.search_flights import search_flights 4 | 5 | # Example usage 6 | if __name__ == "__main__": 7 | search_args = {"city": "Sydney", "month": "July"} 8 | results = search_flights(search_args) 9 | print(json.dumps(results, indent=2)) 10 | -------------------------------------------------------------------------------- /frontend/src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import App from "./pages/App"; 4 | import "./index.css"; // Tailwind imports 5 | 6 | const container = document.getElementById("root"); 7 | const root = createRoot(container); 8 | 9 | root.render(); 10 | -------------------------------------------------------------------------------- /scripts/flight_api_test.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from tools.search_flights import search_flights 4 | 5 | if __name__ == "__main__": 6 | # Suppose user typed "new" for New York, "lon" for London 7 | flights = search_flights("London", "JFK", "2025-01-15", "2025-01-23") 8 | print(json.dumps(flights, indent=2)) 9 | -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./index.html", 5 | "./src/**/*.{js,jsx,ts,tsx}", 6 | ], 7 | darkMode: "class", // enable dark mode by toggling a .dark class 8 | theme: { 9 | extend: {}, 10 | }, 11 | plugins: [], 12 | }; -------------------------------------------------------------------------------- /tools/hr/book_pto.py: -------------------------------------------------------------------------------- 1 | def book_pto(args: dict) -> dict: 2 | email = args.get("email") 3 | start_date = args.get("start_date") 4 | end_date = args.get("end_date") 5 | 6 | print( 7 | f"[BookPTO] Totally would send an email confirmation of PTO from {start_date} to {end_date} to {email} here!" 8 | ) 9 | 10 | return {"status": "success"} 11 | -------------------------------------------------------------------------------- /scripts/create_invoice_test.py: -------------------------------------------------------------------------------- 1 | from tools.create_invoice import create_invoice 2 | 3 | if __name__ == "__main__": 4 | # Example usage: 5 | args_create = { 6 | "email": "jenny.rosen@example.com", 7 | "amount": 150.00, 8 | "description": "Flight to Seattle", 9 | "days_until_due": 7, 10 | } 11 | invoice_details = create_invoice(args_create) 12 | print(invoice_details) 13 | -------------------------------------------------------------------------------- /tools/hr/checkpaybankstatus.py: -------------------------------------------------------------------------------- 1 | def checkpaybankstatus(args: dict) -> dict: 2 | email = args.get("email") 3 | 4 | if email == "grinch@grinch.com": 5 | print("THE GRINCH IS FOUND!") 6 | return {"status": "no money for the grinch"} 7 | 8 | # could do logic here or look up data but for now everyone but the grinch is getting paid 9 | return_msg = "connected" 10 | return {"status": return_msg} 11 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Temporal AI Agent 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /scripts/send_confirm.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | 4 | from shared.config import get_temporal_client 5 | 6 | 7 | async def main(): 8 | # Connect to Temporal and signal the workflow 9 | client = await get_temporal_client() 10 | 11 | workflow_id = "agent-workflow" 12 | 13 | await client.get_workflow_handle(workflow_id).signal("confirm") 14 | 15 | 16 | if __name__ == "__main__": 17 | if len(sys.argv) != 1: 18 | print("Usage: python send_confirm.py'") 19 | else: 20 | asyncio.run(main()) 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS-specific files 2 | .DS_Store 3 | 4 | # Python cache & compiled files 5 | __pycache__/ 6 | *.py[cod] 7 | *.pyd 8 | *.pyo 9 | *.so 10 | *.egg-info/ 11 | *.egg 12 | 13 | # Virtual environment directory 14 | venv/ 15 | .venv/ 16 | 17 | # Build artifacts 18 | build/ 19 | dist/ 20 | 21 | # Test / coverage files 22 | .coverage 23 | .coverage.* 24 | *.cover 25 | coverage.xml 26 | .pytest_cache/ 27 | 28 | # VSCode settings 29 | .vscode/ 30 | 31 | # PyCharm / IntelliJ settings 32 | .idea/ 33 | 34 | .env 35 | .env* 36 | 37 | # Cursor 38 | .cursor -------------------------------------------------------------------------------- /enterprise/Models/SearchTrainsRequest.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace TrainSearchWorker.Models; 4 | 5 | public record SearchTrainsRequest 6 | { 7 | [JsonPropertyName("origin")] 8 | public required string From { get; init; } 9 | 10 | [JsonPropertyName("destination")] 11 | public required string To { get; init; } 12 | 13 | [JsonPropertyName("outbound_time")] 14 | public required string OutboundTime { get; init; } 15 | 16 | [JsonPropertyName("return_time")] 17 | public required string ReturnTime { get; init; } 18 | } 19 | -------------------------------------------------------------------------------- /enterprise/TrainSearchWorker.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Exe 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Documentation Index 2 | 3 | - **architecture.md** - Overview of system components and how they interact. 4 | - **architecture-decisions.md** - Rationale behind key design choices. 5 | - **changelog.md** - Project history and notable changes. 6 | - **contributing.md** - How to contribute and run tests. 7 | - **setup.md** - Installation and configuration instructions. 8 | - **testing.md** - Commands for running the test suite. 9 | - **adding-goals-and-tools.md** - Guide to extending the agent with new goals and tools. 10 | - **todo.md** - Planned enhancements and future work. 11 | -------------------------------------------------------------------------------- /docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | services: 2 | api: 3 | volumes: 4 | - ./:/app:cached 5 | command: uv run uvicorn api.main:app --host 0.0.0.0 --port 8000 --reload 6 | 7 | worker: 8 | volumes: 9 | - ./:/app:cached 10 | command: uv run scripts/run_worker.py 11 | 12 | train-api: 13 | volumes: 14 | - ./:/app:cached 15 | command: uv run thirdparty/train_api.py 16 | 17 | frontend: 18 | volumes: 19 | - ./frontend:/app:cached 20 | command: sh -c "apk update && apk add --no-cache xdg-utils && npm install && npx vite --host 0.0.0.0 --port 5173" 21 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "mcr.microsoft.com/devcontainers/base:ubuntu", 3 | "name": "Temporal AI Agentic Demo", 4 | "features": { 5 | "ghcr.io/devcontainers/features/node:1": {}, 6 | "ghcr.io/va-h/devcontainers-features/uv:1": {}, 7 | "ghcr.io/devcontainers/features/python:1": {}, 8 | "ghcr.io/devcontainers-extra/features/temporal-cli:1": {}, 9 | "ghcr.io/mrsimonemms/devcontainers/tcld:1": {} 10 | }, 11 | "forwardPorts": [ 12 | 5173, 13 | 7233, 14 | 8000, 15 | 8233 16 | ], 17 | "containerEnv": { 18 | "VITE_HOST": "0.0.0.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /enterprise/Models/BookTrainsResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace TrainSearchWorker.Models; 5 | 6 | public record BookTrainsResponse 7 | { 8 | [JsonPropertyName("booking_reference")] 9 | public required string BookingReference { get; init; } 10 | 11 | // If the API now returns train_ids as an array, use List 12 | [JsonPropertyName("train_ids")] 13 | public required List TrainIds { get; init; } 14 | 15 | [JsonPropertyName("status")] 16 | public required string Status { get; init; } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "temporal-ai-agent-frontend", 3 | "version": "1.0.0", 4 | "description": "React and Tailwind", 5 | "license": "ISC", 6 | "author": "", 7 | "type": "commonjs", 8 | "main": "index.js", 9 | "scripts": { 10 | "dev": "vite", 11 | "build": "vite build" 12 | }, 13 | "dependencies": { 14 | "@vitejs/plugin-react": "^4.3.4", 15 | "react": "^19.0.0", 16 | "react-dom": "^19.0.0", 17 | "vite": "^6.0.7" 18 | }, 19 | "devDependencies": { 20 | "autoprefixer": "^10.4.20", 21 | "postcss": "^8.4.49", 22 | "tailwindcss": "^3.4.17" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/hooks/useLocalChatHistory.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | export default function useLocalChatHistory(key, initialValue) { 4 | const [state, setState] = useState(() => { 5 | try { 6 | const stored = window.localStorage.getItem(key); 7 | return stored ? JSON.parse(stored) : initialValue; 8 | } catch (err) { 9 | console.error("Error parsing localStorage:", err); 10 | return initialValue; 11 | } 12 | }); 13 | 14 | useEffect(() => { 15 | window.localStorage.setItem(key, JSON.stringify(state)); 16 | }, [key, state]); 17 | 18 | return [state, setState]; 19 | } 20 | -------------------------------------------------------------------------------- /scripts/end_chat.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from shared.config import get_temporal_client 4 | from workflows.agent_goal_workflow import AgentGoalWorkflow 5 | 6 | 7 | async def main(): 8 | # Create client connected to server at the given address 9 | client = await get_temporal_client() 10 | 11 | workflow_id = "agent-workflow" 12 | 13 | handle = client.get_workflow_handle_for(AgentGoalWorkflow.run, workflow_id) 14 | 15 | # Sends a signal to the workflow 16 | await handle.signal(AgentGoalWorkflow.end_chat) 17 | 18 | 19 | if __name__ == "__main__": 20 | print("Sending signal to end chat.") 21 | asyncio.run(main()) 22 | -------------------------------------------------------------------------------- /frontend/src/components/NavBar.jsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from "react"; 2 | 3 | const NavBar = memo(({ title }) => { 4 | return ( 5 |
11 |

12 | {title} 13 |

14 |
15 | ); 16 | }); 17 | 18 | NavBar.displayName = 'NavBar'; 19 | 20 | export default NavBar; 21 | -------------------------------------------------------------------------------- /scripts/get_history.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from shared.config import get_temporal_client 4 | from workflows.agent_goal_workflow import AgentGoalWorkflow 5 | 6 | 7 | async def main(): 8 | # Create client connected to server at the given address 9 | client = await get_temporal_client() 10 | workflow_id = "agent-workflow" 11 | 12 | handle = client.get_workflow_handle(workflow_id) 13 | 14 | # Queries the workflow for the conversation history 15 | history = await handle.query(AgentGoalWorkflow.get_conversation_history) 16 | 17 | print("Conversation History") 18 | print(history) 19 | 20 | 21 | if __name__ == "__main__": 22 | asyncio.run(main()) 23 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Node.js 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | 7 | # Logs 8 | logs 9 | *.log 10 | logs/*.log 11 | 12 | # OS-specific files 13 | .DS_Store 14 | 15 | # Build output 16 | dist/ 17 | build/ 18 | 19 | # Dependency directories 20 | jspm_packages/ 21 | 22 | # Testing 23 | coverage/ 24 | 25 | # Next.js 26 | .next/ 27 | 28 | # Vite 29 | .vite/ 30 | 31 | # Parcel 32 | .cache/ 33 | 34 | # Environment files 35 | .env 36 | .env.*.local 37 | 38 | # Editor files 39 | .idea/ 40 | .vscode/ 41 | *.swp 42 | 43 | # Temporary files 44 | *.tmp 45 | *.temp 46 | *.bak 47 | *.orig 48 | 49 | # Lock files 50 | *.lock 51 | 52 | # Others 53 | public/**/*.cache 54 | -------------------------------------------------------------------------------- /tools/data/employee_pto_data.json: -------------------------------------------------------------------------------- 1 | { 2 | "theCompany": { 3 | "weLove": "theCompany", 4 | "employees": [ 5 | { 6 | "email": "josh.smith@temporal.io", 7 | "currentPTOHrs": 400, 8 | "hrsAddedPerMonth": 8 9 | }, 10 | { 11 | "email": "laine@awesome.com", 12 | "currentPTOHrs": 40, 13 | "hrsAddedPerMonth": 12 14 | }, 15 | { 16 | "email": "steve.this.is.for.you@gmail.com", 17 | "currentPTOHrs": 4000, 18 | "hrsAddedPerMonth": 20 19 | }, 20 | { 21 | "email": "your_email_here@yourcompany.com", 22 | "currentPTOHrs": 150, 23 | "hrsAddedPerMonth": 19 24 | } 25 | ] 26 | } 27 | } -------------------------------------------------------------------------------- /frontend/src/components/LoadingIndicator.jsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from "react"; 2 | 3 | const LoadingIndicator = memo(() => { 4 | return ( 5 |
10 | {[0, 1, 2].map((i) => ( 11 |
0 ? `delay-${i}00` : ''}`} 15 | /> 16 | ))} 17 | Loading... 18 |
19 | ); 20 | }); 21 | 22 | LoadingIndicator.displayName = 'LoadingIndicator'; 23 | 24 | export default LoadingIndicator; 25 | -------------------------------------------------------------------------------- /enterprise/Models/Journey.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace TrainSearchWorker.Models; 4 | 5 | public record Journey 6 | { 7 | [JsonPropertyName("id")] 8 | public required string Id { get; init; } 9 | 10 | [JsonPropertyName("type")] 11 | public required string Type { get; init; } 12 | 13 | [JsonPropertyName("departure")] 14 | public required string Departure { get; init; } 15 | 16 | [JsonPropertyName("arrival")] 17 | public required string Arrival { get; init; } 18 | 19 | [JsonPropertyName("departure_time")] 20 | public required string DepartureTime { get; init; } 21 | 22 | [JsonPropertyName("arrival_time")] 23 | public required string ArrivalTime { get; init; } 24 | 25 | [JsonPropertyName("price")] 26 | public required decimal Price { get; init; } 27 | } -------------------------------------------------------------------------------- /shared/mcp_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from models.tool_definitions import MCPServerDefinition 4 | 5 | 6 | def get_stripe_mcp_server_definition(included_tools: list[str]) -> MCPServerDefinition: 7 | """ 8 | Returns a Stripe MCP server definition with customizable included tools. 9 | 10 | Args: 11 | included_tools: List of tool names to include from the Stripe MCP server 12 | 13 | Returns: 14 | MCPServerDefinition configured for Stripe 15 | """ 16 | return MCPServerDefinition( 17 | name="stripe-mcp", 18 | command="npx", 19 | args=[ 20 | "-y", 21 | "@stripe/mcp", 22 | "--tools=all", 23 | f"--api-key={os.getenv('STRIPE_API_KEY')}", 24 | ], 25 | env=None, 26 | included_tools=included_tools, 27 | ) 28 | -------------------------------------------------------------------------------- /tools/ecommerce/get_order.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | 5 | # this is made to demonstrate functionality but it could just as durably be an API call 6 | # called as part of a temporal activity with automatic retries 7 | def get_order(args: dict) -> dict: 8 | order_id = args.get("order_id") 9 | 10 | file_path = ( 11 | Path(__file__).resolve().parent.parent / "data" / "customer_order_data.json" 12 | ) 13 | if not file_path.exists(): 14 | return {"error": "Data file not found."} 15 | 16 | with open(file_path, "r") as file: 17 | data = json.load(file) 18 | order_list = data["orders"] 19 | 20 | for order in order_list: 21 | if order["id"] == order_id: 22 | return order 23 | 24 | return_msg = "Order " + order_id + " not found." 25 | return {"error": return_msg} 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [ main ] 7 | 8 | jobs: 9 | lint-test: 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | matrix: 13 | python-version: ['3.13'] 14 | os: [ubuntu-latest] 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Install uv 21 | uses: astral-sh/setup-uv@v6 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | 25 | - name: Install dependencies 26 | run: | 27 | uv sync 28 | 29 | - name: Format, lint, type check 30 | run: | 31 | uv run poe format 32 | uv run poe lint 33 | uv run poe lint-types 34 | 35 | - name: Run tests 36 | run: | 37 | uv run pytest 38 | -------------------------------------------------------------------------------- /tools/hr/current_pto.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | 5 | def current_pto(args: dict) -> dict: 6 | email = args.get("email") 7 | 8 | file_path = ( 9 | Path(__file__).resolve().parent.parent / "data" / "employee_pto_data.json" 10 | ) 11 | if not file_path.exists(): 12 | return {"error": "Data file not found."} 13 | 14 | data = json.load(open(file_path)) 15 | employee_list = data["theCompany"]["employees"] 16 | 17 | for employee in employee_list: 18 | if employee["email"] == email: 19 | num_hours = int(employee["currentPTOHrs"]) 20 | num_days = float(num_hours / 8) 21 | return { 22 | "num_hours": num_hours, 23 | "num_days": num_days, 24 | } 25 | 26 | return_msg = "Employee not found with email address " + email 27 | return {"error": return_msg} 28 | -------------------------------------------------------------------------------- /tools/guess_location.py: -------------------------------------------------------------------------------- 1 | from .give_hint import TREASURE_LOCATION 2 | 3 | 4 | def guess_location(args: dict) -> dict: 5 | guess_address = args.get("address").lower() 6 | guess_city = args.get("city").lower() 7 | guess_state = args.get("state").lower() 8 | 9 | if len(guess_state) == 2: 10 | compare_state = TREASURE_LOCATION.get("state_abbrev").lower() 11 | else: 12 | compare_state = TREASURE_LOCATION.get("state_full").lower() 13 | 14 | # Check for the street address to be included in the guess to account for "st" vs "street" or leaving Street off entirely 15 | if ( 16 | TREASURE_LOCATION.get("address").lower() in guess_address 17 | and TREASURE_LOCATION.get("city").lower() == guess_city 18 | and compare_state == guess_state 19 | ): 20 | return {"treasure_found": "True"} 21 | else: 22 | return {"treasure_found": "False"} 23 | -------------------------------------------------------------------------------- /tools/ecommerce/list_orders.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | 5 | def sorting(e): 6 | return e["order_date"] 7 | 8 | 9 | def list_orders(args: dict) -> dict: 10 | email_address = args.get("email_address") 11 | 12 | file_path = ( 13 | Path(__file__).resolve().parent.parent / "data" / "customer_order_data.json" 14 | ) 15 | if not file_path.exists(): 16 | return {"error": "Data file not found."} 17 | 18 | with open(file_path, "r") as file: 19 | data = json.load(file) 20 | order_list = data["orders"] 21 | 22 | rtn_order_list = [] 23 | for order in order_list: 24 | if order["email"] == email_address: 25 | rtn_order_list.append(order) 26 | 27 | if len(rtn_order_list) > 0: 28 | rtn_order_list.sort(key=sorting) 29 | return {"orders": rtn_order_list} 30 | else: 31 | return_msg = "No orders for customer " + email_address + " found." 32 | return {"error": return_msg} 33 | -------------------------------------------------------------------------------- /scripts/run_legacy_worker.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import concurrent.futures 3 | 4 | from temporalio.worker import Worker 5 | 6 | from activities.tool_activities import dynamic_tool_activity 7 | from shared.config import TEMPORAL_LEGACY_TASK_QUEUE, get_temporal_client 8 | 9 | 10 | async def main(): 11 | # Create the client 12 | client = await get_temporal_client() 13 | 14 | # Run the worker 15 | with concurrent.futures.ThreadPoolExecutor(max_workers=100) as activity_executor: 16 | worker = Worker( 17 | client, 18 | task_queue=TEMPORAL_LEGACY_TASK_QUEUE, 19 | activities=[ 20 | dynamic_tool_activity, 21 | ], 22 | activity_executor=activity_executor, 23 | ) 24 | 25 | print( 26 | f"Starting legacy worker, connecting to task queue: {TEMPORAL_LEGACY_TASK_QUEUE}" 27 | ) 28 | await worker.run() 29 | 30 | 31 | if __name__ == "__main__": 32 | asyncio.run(main()) 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | 3 | WORKDIR /app 4 | 5 | # Install system dependencies 6 | RUN apt-get update && \ 7 | apt-get install -y --no-install-recommends gcc build-essential curl && \ 8 | apt-get clean && \ 9 | rm -rf /var/lib/apt/lists/* 10 | 11 | # Install uv 12 | RUN curl -LsSf https://astral.sh/uv/install.sh | sh 13 | ENV PATH="$PATH:/root/.local/bin" 14 | 15 | # Copy dependency files and README (needed for package build) 16 | COPY pyproject.toml uv.lock README.md ./ 17 | 18 | # Install dependencies and create virtual environment 19 | RUN uv sync --frozen 20 | 21 | # Copy application code 22 | COPY . . 23 | 24 | # Set Python to run in unbuffered mode (recommended for Docker) 25 | ENV PYTHONUNBUFFERED=1 26 | ENV PYTHONPATH=/app 27 | 28 | # Expose the port the app will run on 29 | EXPOSE 8000 30 | 31 | # Default to running only the API server; worker and train-api are separate Compose services 32 | CMD ["uv", "run", "uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"] 33 | -------------------------------------------------------------------------------- /tests/test_workflow_helpers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from models.tool_definitions import ( 4 | AgentGoal, 5 | MCPServerDefinition, 6 | ToolArgument, 7 | ToolDefinition, 8 | ) 9 | from workflows.workflow_helpers import is_mcp_tool 10 | 11 | 12 | def make_goal(with_mcp: bool) -> AgentGoal: 13 | tools = [ToolDefinition(name="AddToCart", description="", arguments=[])] 14 | mcp_def = None 15 | if with_mcp: 16 | mcp_def = MCPServerDefinition( 17 | name="stripe", command="python", args=["server.py"] 18 | ) 19 | return AgentGoal( 20 | id="g", 21 | category_tag="test", 22 | agent_name="Test", 23 | agent_friendly_description="", 24 | tools=tools, 25 | mcp_server_definition=mcp_def, 26 | ) 27 | 28 | 29 | def test_is_mcp_tool_recognizes_native(): 30 | goal = make_goal(True) 31 | assert not is_mcp_tool("AddToCart", goal) 32 | 33 | 34 | def test_is_mcp_tool_recognizes_mcp(): 35 | goal = make_goal(True) 36 | assert is_mcp_tool("list_products", goal) 37 | -------------------------------------------------------------------------------- /tools/fin/check_account_valid.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | 5 | # this is made to demonstrate functionality but it could just as durably be an API call 6 | # called as part of a temporal activity with automatic retries 7 | def check_account_valid(args: dict) -> dict: 8 | email = args.get("email") 9 | account_id = args.get("account_id") 10 | 11 | file_path = ( 12 | Path(__file__).resolve().parent.parent / "data" / "customer_account_data.json" 13 | ) 14 | if not file_path.exists(): 15 | return {"error": "Data file not found."} 16 | 17 | with open(file_path, "r") as file: 18 | data = json.load(file) 19 | account_list = data["accounts"] 20 | 21 | for account in account_list: 22 | if account["email"] == email or account["account_id"] == account_id: 23 | return {"status": "account valid"} 24 | 25 | return_msg = ( 26 | "Account not found with email address " 27 | + email 28 | + " or account ID: " 29 | + account_id 30 | ) 31 | return {"error": return_msg} 32 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@100;200;300;400;500;600;700;800;900&display=swap'); 2 | @import url('https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap'); 3 | 4 | @tailwind base; 5 | @tailwind components; 6 | @tailwind utilities; 7 | 8 | *, 9 | html, 10 | body { 11 | box-sizing: border-box; 12 | } 13 | 14 | html { 15 | height: 100vh; 16 | width: 100vw; 17 | } 18 | 19 | body { 20 | font-family: Inter, sans-serif; 21 | position: relative; 22 | height: 100%; 23 | width: 100%; 24 | } 25 | 26 | h1, 27 | h2, 28 | h3, 29 | h4, 30 | h5, 31 | h6, 32 | titles, 33 | labels { 34 | font-family: Poppins, sans-serif; 35 | } 36 | 37 | /* example if you want it in index.css or a separate .css */ 38 | .corner { 39 | width: 3em; 40 | height: 3em; 41 | } 42 | .corner a { 43 | display: flex; 44 | align-items: center; 45 | justify-content: center; 46 | width: 100%; 47 | height: 100%; 48 | } 49 | .corner img { 50 | width: 2em; 51 | height: 2em; 52 | object-fit: contain; 53 | } 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Steve Androulakis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /temporal-ai-agent.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | # Visual Studio Version 17 3 | VisualStudioVersion = 17.5.2.0 4 | MinimumVisualStudioVersion = 10.0.40219.1 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrainSearchWorker", "enterprise\TrainSearchWorker.csproj", "{E415E5FE-0362-B204-B4B1-A5E60F3A436D}" 6 | EndProject 7 | Global 8 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 9 | Debug|Any CPU = Debug|Any CPU 10 | Release|Any CPU = Release|Any CPU 11 | EndGlobalSection 12 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 13 | {E415E5FE-0362-B204-B4B1-A5E60F3A436D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 14 | {E415E5FE-0362-B204-B4B1-A5E60F3A436D}.Debug|Any CPU.Build.0 = Debug|Any CPU 15 | {E415E5FE-0362-B204-B4B1-A5E60F3A436D}.Release|Any CPU.ActiveCfg = Release|Any CPU 16 | {E415E5FE-0362-B204-B4B1-A5E60F3A436D}.Release|Any CPU.Build.0 = Release|Any CPU 17 | EndGlobalSection 18 | GlobalSection(SolutionProperties) = preSolution 19 | HideSolutionNode = FALSE 20 | EndGlobalSection 21 | GlobalSection(ExtensibilityGlobals) = postSolution 22 | SolutionGuid = {463FDBB3-0167-4747-8007-C25ADDC83630} 23 | EndGlobalSection 24 | EndGlobal 25 | -------------------------------------------------------------------------------- /models/tool_definitions.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Dict, List, Optional 3 | 4 | 5 | @dataclass 6 | class MCPServerDefinition: 7 | """Definition for an MCP (Model Context Protocol) server connection""" 8 | 9 | name: str 10 | command: str 11 | args: List[str] 12 | env: Optional[Dict[str, str]] = None 13 | connection_type: str = "stdio" 14 | included_tools: Optional[List[str]] = None 15 | 16 | 17 | @dataclass 18 | class ToolArgument: 19 | name: str 20 | type: str 21 | description: str 22 | 23 | 24 | @dataclass 25 | class ToolDefinition: 26 | name: str 27 | description: str 28 | arguments: List[ToolArgument] 29 | 30 | 31 | @dataclass 32 | class AgentGoal: 33 | id: str 34 | category_tag: str 35 | agent_name: str 36 | agent_friendly_description: str 37 | tools: List[ToolDefinition] 38 | description: str = "Description of the tools purpose and overall goal" 39 | starter_prompt: str = "Initial prompt to start the conversation" 40 | example_conversation_history: str = "Example conversation history to help the AI agent understand the context of the conversation" 41 | mcp_server_definition: Optional[MCPServerDefinition] = None 42 | -------------------------------------------------------------------------------- /tools/food/add_to_cart.py: -------------------------------------------------------------------------------- 1 | def add_to_cart(args: dict) -> dict: 2 | """ 3 | Simple stateless cart tool for demo purposes. 4 | In production, this would use proper session storage or database. 5 | """ 6 | customer_email = args.get("customer_email") 7 | item_name = args.get("item_name") 8 | item_price = float(args.get("item_price", 0)) 9 | quantity = int(args.get("quantity", 1)) 10 | stripe_product_id = args.get("stripe_product_id") 11 | 12 | # Basic validation 13 | if not customer_email: 14 | return {"error": "Customer email is required"} 15 | if not item_name: 16 | return {"error": "Item name is required"} 17 | if item_price <= 0: 18 | return {"error": "Item price must be greater than 0"} 19 | if quantity <= 0: 20 | return {"error": "Quantity must be greater than 0"} 21 | 22 | # For demo purposes, just acknowledge the addition 23 | # In a real system, this would store to session/database 24 | return { 25 | "status": "success", 26 | "message": f"Added {quantity} x {item_name} (${item_price}) to cart for {customer_email}", 27 | "item_added": { 28 | "name": item_name, 29 | "price": item_price, 30 | "quantity": quantity, 31 | "stripe_product_id": stripe_product_id, 32 | }, 33 | } 34 | -------------------------------------------------------------------------------- /tools/fin/get_account_balances.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | 5 | # this is made to demonstrate functionality but it could just as durably be an API call 6 | # this assumes it's a valid account - use check_account_valid() to verify that first 7 | def get_account_balance(args: dict) -> dict: 8 | account_key = args.get("email_address_or_account_ID") 9 | 10 | file_path = ( 11 | Path(__file__).resolve().parent.parent / "data" / "customer_account_data.json" 12 | ) 13 | if not file_path.exists(): 14 | return {"error": "Data file not found."} 15 | 16 | with open(file_path, "r") as file: 17 | data = json.load(file) 18 | account_list = data["accounts"] 19 | 20 | for account in account_list: 21 | if account["email"] == account_key or account["account_id"] == account_key: 22 | return { 23 | "name": account["name"], 24 | "email": account["email"], 25 | "account_id": account["account_id"], 26 | "checking_balance": account["checking_balance"], 27 | "savings_balance": account["savings_balance"], 28 | "bitcoin_balance": account["bitcoin_balance"], 29 | "account_creation_date": account["account_creation_date"], 30 | } 31 | 32 | return_msg = "Account not found with for " + account_key 33 | return {"error": return_msg} 34 | -------------------------------------------------------------------------------- /models/data_types.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any, Deque, Dict, List, Literal, Optional, Union 3 | 4 | from models.tool_definitions import AgentGoal 5 | 6 | 7 | @dataclass 8 | class AgentGoalWorkflowParams: 9 | conversation_summary: Optional[str] = None 10 | prompt_queue: Optional[Deque[str]] = None 11 | 12 | 13 | @dataclass 14 | class CombinedInput: 15 | tool_params: AgentGoalWorkflowParams 16 | agent_goal: AgentGoal 17 | 18 | 19 | Message = Dict[str, Union[str, Dict[str, Any]]] 20 | ConversationHistory = Dict[str, List[Message]] 21 | NextStep = Literal["confirm", "question", "pick-new-goal", "done"] 22 | 23 | 24 | @dataclass 25 | class ToolPromptInput: 26 | prompt: str 27 | context_instructions: str 28 | 29 | 30 | @dataclass 31 | class ValidationInput: 32 | prompt: str 33 | conversation_history: ConversationHistory 34 | agent_goal: AgentGoal 35 | 36 | 37 | @dataclass 38 | class ValidationResult: 39 | validationResult: bool 40 | validationFailedReason: dict = None 41 | 42 | def __post_init__(self): 43 | # Initialize empty dict if None 44 | if self.validationFailedReason is None: 45 | self.validationFailedReason = {} 46 | 47 | 48 | @dataclass 49 | class EnvLookupInput: 50 | show_confirm_env_var_name: str 51 | show_confirm_default: bool 52 | 53 | 54 | @dataclass 55 | class EnvLookupOutput: 56 | show_confirm: bool 57 | multi_goal_mode: bool 58 | -------------------------------------------------------------------------------- /goals/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import List 3 | 4 | import tools.tool_registry as tool_registry 5 | from goals.agent_selection import agent_selection_goals 6 | from goals.ecommerce import ecommerce_goals 7 | from goals.finance import finance_goals 8 | from goals.food import food_goals 9 | from goals.hr import hr_goals 10 | from goals.stripe_mcp import mcp_goals 11 | from goals.travel import travel_goals 12 | from models.tool_definitions import AgentGoal 13 | 14 | goal_list: List[AgentGoal] = [] 15 | goal_list.extend(agent_selection_goals) 16 | goal_list.extend(travel_goals) 17 | goal_list.extend(hr_goals) 18 | goal_list.extend(finance_goals) 19 | goal_list.extend(ecommerce_goals) 20 | goal_list.extend(mcp_goals) 21 | goal_list.extend(food_goals) 22 | 23 | # for multi-goal, just set list agents as the last tool 24 | first_goal_value = os.getenv("AGENT_GOAL") 25 | if first_goal_value is None: 26 | multi_goal_mode = False # default to single agent mode if unset 27 | elif ( 28 | first_goal_value is not None 29 | and first_goal_value.lower() == "goal_choose_agent_type" 30 | ): 31 | multi_goal_mode = True 32 | else: 33 | multi_goal_mode = False 34 | 35 | if multi_goal_mode: 36 | for goal in goal_list: 37 | list_agents_found: bool = False 38 | for tool in goal.tools: 39 | if tool.name == "ListAgents": 40 | list_agents_found = True 41 | continue 42 | if list_agents_found is False: 43 | goal.tools.append(tool_registry.list_agents_tool) 44 | continue 45 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: setup install run-worker run-api run-frontend run-train-api run-legacy-worker run-enterprise setup-venv check-python run-dev 2 | 3 | setup: 4 | uv sync 5 | cd frontend && npm install 6 | 7 | # Run commands 8 | run-worker: 9 | uv run scripts/run_worker.py 10 | 11 | run-api: 12 | uv run uvicorn api.main:app --reload 13 | 14 | run-frontend: 15 | cd frontend && npx vite 16 | 17 | run-train-api: 18 | uv run thirdparty/train_api.py 19 | 20 | run-legacy-worker: 21 | uv run scripts/run_legacy_worker.py 22 | 23 | run-enterprise: 24 | cd enterprise && dotnet build && dotnet run 25 | 26 | # Development environment setup 27 | setup-temporal-mac: 28 | brew install temporal 29 | temporal server start-dev 30 | 31 | # Run all development services 32 | run-dev: 33 | @echo "Starting all development services..." 34 | @make run-worker & \ 35 | make run-api & \ 36 | make run-frontend & \ 37 | wait 38 | 39 | # Help command 40 | help: 41 | @echo "Available commands:" 42 | @echo " make setup - Install all dependencies" 43 | @echo " make run-worker - Start the Temporal worker" 44 | @echo " make run-api - Start the API server" 45 | @echo " make run-frontend - Start the frontend development server" 46 | @echo " make run-train-api - Start the train API server" 47 | @echo " make run-legacy-worker - Start the legacy worker" 48 | @echo " make run-enterprise - Build and run the enterprise .NET worker" 49 | @echo " make setup-temporal-mac - Install and start Temporal server on Mac" 50 | @echo " make run-dev - Start all development services (worker, API, frontend) in parallel" -------------------------------------------------------------------------------- /tools/list_agents.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import goals 4 | 5 | 6 | def list_agents(args: dict) -> dict: 7 | goal_categories_start = os.getenv("GOAL_CATEGORIES") 8 | if goal_categories_start is None: 9 | goal_categories = ["all"] # default to 'all' categories 10 | else: 11 | goal_categories_start.strip().lower() # handle extra spaces or non-lowercase 12 | goal_categories = goal_categories_start.split(",") 13 | 14 | # if multi-goal-mode, add agent_selection as a goal (defaults to True) 15 | if "agent_selection" not in goal_categories: 16 | first_goal_value = os.getenv("AGENT_GOAL") 17 | if ( 18 | first_goal_value is None 19 | or first_goal_value.lower() == "goal_choose_agent_type" 20 | ): 21 | goal_categories.append("agent_selection") 22 | 23 | # always show goals labeled as "system," like the goal chooser 24 | if "system" not in goal_categories: 25 | goal_categories.append("system") 26 | 27 | agents = [] 28 | if goals.goal_list is not None: 29 | for goal in goals.goal_list: 30 | # add to list if either 31 | # - all 32 | # - current goal's tag is in goal_categories 33 | if "all" in goal_categories or goal.category_tag in goal_categories: 34 | agents.append( 35 | { 36 | "agent_name": goal.agent_name, 37 | "goal_id": goal.id, 38 | "agent_description": goal.agent_friendly_description, 39 | } 40 | ) 41 | return { 42 | "agents": agents, 43 | } 44 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [0.2.0] - 2025-04-24 6 | 7 | ![0.2.0 Changes Screenshot](./assets/0.2.0_changes.jpeg) 8 | 9 | ### Added 10 | - **Multi‑goal agent architecture** with dynamic goal switching (`goal_choose_agent_type`, `ListAgents`, `ChangeGoal`). 11 | - See [the architecture guide](./architecture.md) and [setup guide](./setup.md). 12 | - **New goal categories & agents**: HR PTO scheduling/checking, paycheck integration, Financial (balances, money movement, loan application), E‑commerce order tracking. 13 | - See [the guide for adding goals and tools](./adding-goals-and-tools.md). 14 | - **Force Confirmation**: `SHOW_CONFIRM` will show a confirmation box before allowing the agent to run a tool. 15 | - **Grok (`x.ai`) LLM provider** support via `GROK_API_KEY`. 16 | - Extensive **docs**: `setup.md`, `architecture.md`, `architecture-decisions.md`, `adding-goals-and-tools.md`, plus new diagrams & assets. 17 | 18 | ### Changed 19 | - **UI Confirmation Box** is less 'debug' looking and prettier. 20 | - Package renamed to **`temporal_AI_agent`** and version bumped to **0.2.0** in `pyproject.toml`. 21 | - Environment variables changed (see `.env_example`): (`RAPIDAPI_HOST_*`, `AGENT_GOAL` defaults, `GOAL_CATEGORIES`, `SHOW_CONFIRM`, `FIN_START_REAL_WORKFLOW`). 22 | 23 | ## [0.1.0] - 2025-01-04 24 | 25 | ### Added 26 | - **Initial release** of the Temporal AI Agent demo. 27 | - **Single goal agent** architecture with a single goal and agent type. 28 | - This is the agent demoed in the [YouTube video](https://www.youtube.com/watch?v=GEXllEH2XiQ). 29 | 30 | [0.2.0]: https://github.com/temporal-community/temporal-ai-agent/pull/29 -------------------------------------------------------------------------------- /tools/food/setup/archive_food_products.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from dotenv import load_dotenv 4 | 5 | 6 | def delete_food_ordering_products(): 7 | """Archive all Stripe products with metadata use_case = food_ordering_demo (since products with prices cannot be deleted).""" 8 | import stripe 9 | 10 | # Load environment variables and configure stripe 11 | load_dotenv(override=True) 12 | stripe.api_key = os.getenv("STRIPE_API_KEY") 13 | 14 | if not stripe.api_key: 15 | print("Error: STRIPE_API_KEY not found in environment variables") 16 | return 17 | 18 | try: 19 | # Search for products with food_ordering_demo use_case 20 | products = stripe.Product.search( 21 | query="metadata['use_case']:'food_ordering_demo'", limit=100 22 | ) 23 | 24 | if not products.data: 25 | print("No products found with use_case = food_ordering_demo") 26 | return 27 | 28 | archived_count = 0 29 | 30 | for product in products.data: 31 | try: 32 | # Archive the product (set active=False) 33 | stripe.Product.modify(product.id, active=False) 34 | print(f"Archived product: {product.name} (ID: {product.id})") 35 | archived_count += 1 36 | 37 | except Exception as e: 38 | print( 39 | f"Error archiving product {product.name} (ID: {product.id}): {str(e)}" 40 | ) 41 | 42 | print(f"\nSuccessfully archived {archived_count} products") 43 | 44 | except Exception as e: 45 | print(f"Error searching for products: {str(e)}") 46 | 47 | 48 | if __name__ == "__main__": 49 | delete_food_ordering_products() 50 | -------------------------------------------------------------------------------- /enterprise/TemporalClientHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Collections.Generic; 4 | using Temporalio.Client; 5 | 6 | public static class TemporalClientHelper 7 | { 8 | public static async Task CreateClientAsync() 9 | { 10 | var address = Environment.GetEnvironmentVariable("TEMPORAL_ADDRESS") ?? "localhost:7233"; 11 | var ns = Environment.GetEnvironmentVariable("TEMPORAL_NAMESPACE") ?? "default"; 12 | var clientCertPath = Environment.GetEnvironmentVariable("TEMPORAL_TLS_CERT"); 13 | var clientKeyPath = Environment.GetEnvironmentVariable("TEMPORAL_TLS_KEY"); 14 | var apiKey = Environment.GetEnvironmentVariable("TEMPORAL_API_KEY"); 15 | 16 | var options = new TemporalClientConnectOptions(address) 17 | { 18 | Namespace = ns 19 | }; 20 | 21 | if (!string.IsNullOrEmpty(clientCertPath) && !string.IsNullOrEmpty(clientKeyPath)) 22 | { 23 | // mTLS authentication 24 | options.Tls = new() 25 | { 26 | ClientCert = await File.ReadAllBytesAsync(clientCertPath), 27 | ClientPrivateKey = await File.ReadAllBytesAsync(clientKeyPath), 28 | }; 29 | } 30 | else if (!string.IsNullOrEmpty(apiKey)) 31 | { 32 | // API Key authentication 33 | // TODO test 34 | options.RpcMetadata = new Dictionary() 35 | { 36 | ["authorization"] = $"Bearer {apiKey}", 37 | ["temporal-namespace"] = ns 38 | }; 39 | options.RpcMetadata = new Dictionary() 40 | { 41 | ["temporal-namespace"] = ns 42 | }; 43 | options.Tls = new(); 44 | } 45 | 46 | return await TemporalClient.ConnectAsync(options); 47 | } 48 | } -------------------------------------------------------------------------------- /goals/stripe_mcp.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from models.tool_definitions import AgentGoal 4 | from shared.mcp_config import get_stripe_mcp_server_definition 5 | 6 | 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." 7 | 8 | goal_mcp_stripe = AgentGoal( 9 | id="goal_mcp_stripe", 10 | category_tag="mcp-integrations", 11 | agent_name="Stripe MCP Agent", 12 | agent_friendly_description="Manage Stripe operations via MCP", 13 | tools=[], # Will be populated dynamically 14 | mcp_server_definition=get_stripe_mcp_server_definition(included_tools=[]), 15 | description="Help manage Stripe operations for customer and product data by using the customers.read and products.read tools.", 16 | starter_prompt="Welcome! I can help you read Stripe customer and product information.", 17 | example_conversation_history="\n ".join( 18 | [ 19 | "agent: Welcome! I can help you read Stripe customer and product information. What would you like to do first?", 20 | "user: what customers are there?", 21 | "agent: I'll check for customers now.", 22 | "user_confirmed_tool_run: ", 23 | 'tool_result: { "customers": [{"id": "cus_abc", "name": "Customer A"}, {"id": "cus_xyz", "name": "Customer B"}] }', 24 | "agent: I found two customers: Customer A and Customer B. Can I help with anything else?", 25 | "user: what products exist?", 26 | "agent: Let me get the list of products for you.", 27 | "user_confirmed_tool_run: ", 28 | 'tool_result: { "products": [{"id": "prod_123", "name": "Gold Plan"}, {"id": "prod_456", "name": "Silver Plan"}] }', 29 | "agent: I found two products: Gold Plan and Silver Plan.", 30 | ] 31 | ), 32 | ) 33 | 34 | 35 | mcp_goals: List[AgentGoal] = [ 36 | goal_mcp_stripe, 37 | ] 38 | -------------------------------------------------------------------------------- /tools/give_hint.py: -------------------------------------------------------------------------------- 1 | TREASURE_LOCATION = { 2 | "address": "300 Lenora", 3 | "city": "Seattle", 4 | "state_full": "Washington", 5 | "state_abbrev": "WA", 6 | "zip": "98121", 7 | "country": "USA", 8 | } 9 | 10 | HINTS = [ 11 | "country of " + TREASURE_LOCATION["country"], 12 | "state of " + TREASURE_LOCATION["state_full"], 13 | "city of " + TREASURE_LOCATION["city"], 14 | "at a company HQ", 15 | "The company's tech traces its roots to a project called Cadence", # thanks, Grok 16 | "The company offers a tool that lets developers write code as if it's running forever, no matter what crashes", # thanks, Grok 17 | ] 18 | ''' Additional Grok provided hints about Temporal: 19 | "This company was founded by two engineers who previously worked on a system named after a South American river at Uber." 20 | "Their platform is all about orchestrating workflows that can survive failures—like a conductor keeping the music going." 21 | "They offer a tool that lets developers write code as if it’s running forever, no matter what crashes." 22 | "Their mission is tied to making distributed systems feel as simple as writing a single app." 23 | "They’ve got a knack for ‘durability’—both in their software and their growing reputation." 24 | "This outfit spun out of experiences at AWS and Uber, blending cloud and ride-sharing know-how." 25 | "Their open-source framework has a community that’s ticking along, fixing bugs and adding features daily." 26 | "They’re backed by big venture capital names like Sequoia, betting on their vision for reliable software." 27 | "The company’s name might remind you of a word for something fleeting, yet their tech is built to last."''' 28 | 29 | 30 | def give_hint(args: dict) -> dict: 31 | hint_total = args.get("hint_total") 32 | if hint_total is None: 33 | hint_total = 0 34 | 35 | index = hint_total % len(HINTS) 36 | hint_text = HINTS[index] 37 | 38 | hint_total = hint_total + 1 39 | return {"hint_number": hint_total, "hint": hint_text} 40 | -------------------------------------------------------------------------------- /tools/data/customer_account_data.json: -------------------------------------------------------------------------------- 1 | { 2 | "accounts": [ 3 | { 4 | "name": "Matt Murdock", 5 | "email": "matt.murdock@nelsonmurdock.com", 6 | "account_id": "11235", 7 | "checking_balance": "1275.4", 8 | "savings_balance": "2800.15", 9 | "bitcoin_balance": 0.1378, 10 | "account_creation_date": "2014-03-10" 11 | }, 12 | { 13 | "name": "Foggy Nelson", 14 | "email": "foggy.nelson@nelsonmurdock.com", 15 | "account_id": "112358", 16 | "checking_balance": "1523.66", 17 | "savings_balance": "4875.89", 18 | "bitcoin_balance": 0.0923, 19 | "account_creation_date": "2014-03-10" 20 | }, 21 | { 22 | "name": "Karen Page", 23 | "email": "karen.page@nelsonmurdock.com", 24 | "account_id": "112", 25 | "checking_balance": 645.25, 26 | "savings_balance": "830.5", 27 | "bitcoin_balance": 0.0456, 28 | "account_creation_date": "2015-01-15" 29 | }, 30 | { 31 | "name": "Wilson Fisk", 32 | "email": "wilson.fisk@fiskcorp.com", 33 | "account_id": "11", 34 | "checking_balance": 25000.0, 35 | "savings_balance": 150000.75, 36 | "bitcoin_balance": 5987.6721, 37 | "account_creation_date": "2013-09-20" 38 | }, 39 | { 40 | "name": "Frank Castle", 41 | "email": "frank.castle@vigilante.net", 42 | "account_id": "1", 43 | "checking_balance": 320.1, 44 | "savings_balance": 0.3, 45 | "bitcoin_balance": 15.2189, 46 | "account_creation_date": "2016-02-05" 47 | }, 48 | { 49 | "name": "Joshua Smith", 50 | "email": "joshmsmith@gmail.com", 51 | "account_id": "11235813", 52 | "checking_balance": 3021.9, 53 | "savings_balance": 500.5, 54 | "bitcoin_balance": 0.001, 55 | "account_creation_date": "2020-03-19" 56 | } 57 | ] 58 | } -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "temporal_AI_agent" 3 | version = "0.2.0" 4 | description = "Temporal AI Agent" 5 | authors = [ 6 | { name = "Steve Androulakis", email = "steve.androulakis@temporal.io" }, 7 | { name = "Laine Smith", email = "lainecaseysmith@gmail.com" }, 8 | { name = "Joshua Smith", email = "josh.smith@temporal.io" }, 9 | ] 10 | requires-python = ">=3.10,<4.0" 11 | readme = "README.md" 12 | license = "MIT" 13 | dependencies = [ 14 | "temporalio>=1.8.0,<2", 15 | "litellm>=1.70.0,<2", 16 | "pyyaml>=6.0.2,<7", 17 | "fastapi>=0.115.6,<0.116", 18 | "uvicorn>=0.34.0,<0.35", 19 | "python-dotenv>=1.0.1,<2", 20 | "requests>=2.32.3,<3", 21 | "pandas>=2.2.3,<3", 22 | "stripe>=11.4.1,<12", 23 | "gtfs-kit>=10.1.1,<11", 24 | "fastmcp>=2.7.0,<3", 25 | ] 26 | 27 | [project.urls] 28 | "Bug Tracker" = "https://github.com/temporal-community/temporal-ai-agent/issues" 29 | 30 | [dependency-groups] 31 | dev = [ 32 | "pytest>=8.2", 33 | "pytest-asyncio>=0.26.0,<0.27", 34 | "black~=23.7", 35 | "isort~=5.12", 36 | "mypy>=1.16.0,<2", 37 | "poethepoet>=0.37.0", 38 | ] 39 | 40 | [tool.poe.tasks] 41 | format = [{cmd = "black ."}, {cmd = "isort ."}] 42 | lint = [{cmd = "black --check ."}, {cmd = "isort --check-only ."}, {ref = "lint-types" }] 43 | lint-types = "mypy --check-untyped-defs --namespace-packages ." 44 | test = "pytest" 45 | 46 | [tool.hatch.metadata] 47 | allow-direct-references = true 48 | 49 | [tool.hatch.build] 50 | packages = ["activities", "api", "goals", "models", "prompts", "shared", "tools", "workflows"] 51 | 52 | [build-system] 53 | requires = ["hatchling"] 54 | build-backend = "hatchling.build" 55 | 56 | [tool.pytest.ini_options] 57 | asyncio_mode = "auto" 58 | log_cli = true 59 | log_cli_level = "INFO" 60 | log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)" 61 | asyncio_default_fixture_loop_scope = "function" 62 | norecursedirs = ["vibe"] 63 | 64 | [tool.mypy] 65 | python_version = "3.10" 66 | ignore_missing_imports = true 67 | check_untyped_defs = true 68 | namespace_packages = true 69 | explicit_package_bases = true 70 | ignore_errors = true 71 | 72 | [tool.isort] 73 | profile = "black" 74 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Example environment configuration 2 | 3 | ### LLM configuration 4 | LLM_MODEL=openai/gpt-4o 5 | LLM_KEY=sk-proj-... 6 | # LLM_MODEL=anthropic/claude-3-5-sonnet-20240620 7 | # LLM_KEY=${ANTHROPIC_API_KEY} 8 | # LLM_MODEL=gemini/gemini-2.5-flash-preview-04-17 9 | # LLM_KEY=${GOOGLE_API_KEY} 10 | 11 | ### Tool API keys 12 | # RAPIDAPI_KEY=9df2cb5... # Optional - if unset flight search generates realistic mock data 13 | # RAPIDAPI_HOST_FLIGHTS=sky-scrapper.p.rapidapi.com # For real travel flight information (optional) 14 | RAPIDAPI_HOST_PACKAGE=trackingpackage.p.rapidapi.com # For eCommerce order status package tracking tool 15 | FOOTBALL_DATA_API_KEY= 16 | # Leave blank to use the built-in mock fixtures generator 17 | 18 | STRIPE_API_KEY=sk_test_51J... 19 | # Optional for `goal_event_flight_invoice` – if unset a mock invoice is created. 20 | # Sign up for a free Stripe account and get a test key at https://dashboard.stripe.com/test/apikeys 21 | 22 | ### Temporal connection (optional) 23 | # Uncomment and update these values to connect to a non-default Temporal server 24 | # TEMPORAL_ADDRESS=namespace.acct.tmprl.cloud:7233 25 | # TEMPORAL_NAMESPACE=default 26 | # TEMPORAL_TASK_QUEUE=agent-task-queue 27 | # TEMPORAL_TLS_CERT='path/to/cert.pem' 28 | # TEMPORAL_TLS_KEY='path/to/key.pem' 29 | # TEMPORAL_API_KEY=abcdef1234567890 30 | 31 | ### Agent goal configuration 32 | # Set starting goal of agent - if unset default is goal_event_flight_invoice (single agent mode) 33 | #AGENT_GOAL=goal_choose_agent_type # for multi-goal mode (experimental) 34 | AGENT_GOAL=goal_event_flight_invoice 35 | #AGENT_GOAL=goal_match_train_invoice # for replay goal 36 | 37 | # Choose which goal categories are listed by the Agent Goal picker if enabled above 38 | # Options: system (always included), hr, travel-flights, travel-trains, fin, ecommerce, mcp-integrations, food, all 39 | GOAL_CATEGORIES=all 40 | #GOAL_CATEGORIES=travel-flights 41 | 42 | ### Other settings 43 | SHOW_CONFIRM=True 44 | 45 | # Money Scenarios: 46 | # Set if you want it to really start workflows - otherwise it'll fake it 47 | # if you want it to be real you'll need moneytransfer and early return workers running 48 | FIN_START_REAL_WORKFLOW=FALSE 49 | -------------------------------------------------------------------------------- /shared/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from dotenv import load_dotenv 4 | from temporalio.client import Client 5 | from temporalio.service import TLSConfig 6 | 7 | load_dotenv(override=True) 8 | 9 | # Temporal connection settings 10 | TEMPORAL_ADDRESS = os.getenv("TEMPORAL_ADDRESS", "localhost:7233") 11 | TEMPORAL_NAMESPACE = os.getenv("TEMPORAL_NAMESPACE", "default") 12 | TEMPORAL_TASK_QUEUE = os.getenv("TEMPORAL_TASK_QUEUE", "agent-task-queue") 13 | TEMPORAL_LEGACY_TASK_QUEUE = os.getenv( 14 | "TEMPORAL_LEGACY_TASK_QUEUE", "agent-task-queue-legacy" 15 | ) 16 | 17 | # Authentication settings 18 | TEMPORAL_TLS_CERT = os.getenv("TEMPORAL_TLS_CERT", "") 19 | TEMPORAL_TLS_KEY = os.getenv("TEMPORAL_TLS_KEY", "") 20 | TEMPORAL_API_KEY = os.getenv("TEMPORAL_API_KEY", "") 21 | 22 | 23 | async def get_temporal_client() -> Client: 24 | """ 25 | Creates a Temporal client based on environment configuration. 26 | Supports local server, mTLS, and API key authentication methods. 27 | """ 28 | # Default to no TLS for local development 29 | tls_config = False 30 | print(f"Address: {TEMPORAL_ADDRESS}, Namespace {TEMPORAL_NAMESPACE}") 31 | print("(If unset, then will try to connect to local server)") 32 | 33 | # Configure mTLS if certificate and key are provided 34 | if TEMPORAL_TLS_CERT and TEMPORAL_TLS_KEY: 35 | print(f"TLS cert: {TEMPORAL_TLS_CERT}") 36 | print(f"TLS key: {TEMPORAL_TLS_KEY}") 37 | with open(TEMPORAL_TLS_CERT, "rb") as f: 38 | client_cert = f.read() 39 | with open(TEMPORAL_TLS_KEY, "rb") as f: 40 | client_key = f.read() 41 | tls_config = TLSConfig( 42 | client_cert=client_cert, 43 | client_private_key=client_key, 44 | ) 45 | 46 | # Use API key authentication if provided 47 | if TEMPORAL_API_KEY: 48 | print(f"API key: {TEMPORAL_API_KEY}") 49 | return await Client.connect( 50 | TEMPORAL_ADDRESS, 51 | namespace=TEMPORAL_NAMESPACE, 52 | api_key=TEMPORAL_API_KEY, 53 | tls=True, # Always use TLS with API key 54 | ) 55 | 56 | # Use mTLS or local connection 57 | return await Client.connect( 58 | TEMPORAL_ADDRESS, 59 | namespace=TEMPORAL_NAMESPACE, 60 | tls=tls_config, 61 | ) 62 | -------------------------------------------------------------------------------- /frontend/src/components/LLMResponse.jsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useEffect } from "react"; 2 | import MessageBubble from "./MessageBubble"; 3 | import ConfirmInline from "./ConfirmInline"; 4 | 5 | const LLMResponse = memo(({ data, onConfirm, isLastMessage, onHeightChange }) => { 6 | const [isConfirmed, setIsConfirmed] = React.useState(false); 7 | const responseRef = React.useRef(null); 8 | 9 | // Notify parent of height changes when confirm UI appears/changes 10 | useEffect(() => { 11 | if (isLastMessage && responseRef.current && onHeightChange) { 12 | onHeightChange(); 13 | } 14 | }, [isLastMessage, isConfirmed, onHeightChange]); 15 | 16 | const handleConfirm = async () => { 17 | try { 18 | if (onConfirm) await onConfirm(); 19 | setIsConfirmed(true); 20 | } catch (error) { 21 | console.error('Error confirming action:', error); 22 | } 23 | }; 24 | 25 | const response = typeof data?.response === 'object' 26 | ? data.response.response 27 | : data?.response; 28 | 29 | const displayText = (response || '').trim(); 30 | const requiresConfirm = data.force_confirm && data.next === "confirm" && isLastMessage; 31 | const defaultText = requiresConfirm 32 | ? `Agent is ready to run "${data.tool}". Please confirm.` 33 | : ''; 34 | 35 | return ( 36 |
37 | 40 | {requiresConfirm && ( 41 | 46 | )} 47 | {!requiresConfirm && data.tool && data.next === "confirm" && ( 48 |
49 |
50 | Agent chose tool: {data.tool ?? "Unknown"} 51 |
52 |
53 | )} 54 |
55 | ); 56 | }); 57 | 58 | LLMResponse.displayName = 'LLMResponse'; 59 | 60 | export default LLMResponse; 61 | -------------------------------------------------------------------------------- /enterprise/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Temporalio.Client; 3 | using Temporalio.Worker; 4 | using TrainSearchWorker.Activities; 5 | using Microsoft.Extensions.Logging; 6 | using Microsoft.Extensions.Logging.Console; 7 | 8 | // Set up dependency injection 9 | var services = new ServiceCollection(); 10 | 11 | var loggerFactory = LoggerFactory.Create(builder => 12 | { 13 | builder 14 | .AddSimpleConsole(options => options.TimestampFormat = "[HH:mm:ss] ") 15 | .SetMinimumLevel(LogLevel.Information); 16 | }); 17 | 18 | // Add HTTP client 19 | services.AddHttpClient("TrainApi", client => 20 | { 21 | client.BaseAddress = new Uri("http://localhost:8080/"); 22 | client.DefaultRequestHeaders.Add("Accept", "application/json"); 23 | }); 24 | 25 | // Add activities 26 | services.AddScoped(); 27 | 28 | var serviceProvider = services.BuildServiceProvider(); 29 | 30 | // Create client using the helper, which supports Temporal Cloud if environment variables are set 31 | var client = await TemporalClientHelper.CreateClientAsync(); 32 | 33 | // Read connection details from environment or use defaults 34 | var address = Environment.GetEnvironmentVariable("TEMPORAL_ADDRESS") ?? "localhost:7233"; 35 | var ns = Environment.GetEnvironmentVariable("TEMPORAL_NAMESPACE") ?? "default"; 36 | 37 | // Log connection details 38 | Console.WriteLine("Starting worker..."); 39 | Console.WriteLine($"Connecting to Temporal at address: {address}"); 40 | Console.WriteLine($"Using namespace: {ns}"); 41 | 42 | // Create worker options 43 | var options = new TemporalWorkerOptions("agent-task-queue-legacy") 44 | { 45 | LoggerFactory = loggerFactory 46 | }; 47 | 48 | // Register activities 49 | var activities = serviceProvider.GetRequiredService(); 50 | options.AddActivity(activities.SearchTrains); 51 | options.AddActivity(activities.BookTrains); 52 | 53 | // Create and run worker 54 | var worker = new TemporalWorker(client, options); 55 | 56 | using var tokenSource = new CancellationTokenSource(); 57 | Console.CancelKeyPress += (_, eventArgs) => 58 | { 59 | eventArgs.Cancel = true; 60 | tokenSource.Cancel(); 61 | }; 62 | 63 | try 64 | { 65 | await worker.ExecuteAsync(tokenSource.Token); 66 | } 67 | catch (OperationCanceledException) 68 | { 69 | Console.WriteLine("Worker shutting down..."); 70 | } 71 | -------------------------------------------------------------------------------- /docs/todo.md: -------------------------------------------------------------------------------- 1 | # todo list 2 | 3 | ## General Agent Enhancements 4 | 5 | [ ] Google's A2A is emerging as the standard way to hand off agents to other agents. We should examine implementing this soon. 6 | 7 | [ ] Custom metrics/tracing is important for AI specific aspects such as number of LLM calls, number of bad LLM responses that require retrying, number of bad chat outcomes. We should add this. 8 | 9 | [ ] Evals are very important in agents. We want to be able to 'judge' the agent's performance both in dev and production (AIOps). This will help us improve our agent's performance over time in a targeted fashion. 10 | 11 | [ ] Dynamically switch LLMs on persistent failures:
12 | - detect failure in the activity using failurecount
13 | - activity switches to secondary LLM defined in .env 14 | - activity reports switch to workflow 15 | 16 | [ ] Collapse history/summarize chat after goal finished
17 | 18 | [ ] Write tests
19 | 20 | [ ] non-retry the api key error - "Invalid API Key provided: sk_test_**J..." and "AuthenticationError"
21 | 22 | [ ] add visual feedback when workflow starting
23 | 24 | [ ] enable user to list agents at any time - like end conversation - probably with a next step
25 | 26 | [ ] get this on the Model Context Protocol site's list of MCP clients https://modelcontextprotocol.io/clients 27 | 28 | ## Ideas for more goals and tools 29 | 30 | [ ] Add fintech goals
31 | - Fraud Detection and Prevention - The AI monitors transactions across accounts, flagging suspicious activities (e.g., unusual spending patterns or login attempts) and autonomously freezing accounts or notifying customers and compliance teams.
32 | - Personalized Financial Advice - An AI agent analyzes a customer’s financial data (e.g., income, spending habits, savings, investments) and provides tailored advice, such as budgeting tips, investment options, or debt repayment strategies.
33 | - Portfolio Management and Rebalancing - The AI monitors a customer’s investment portfolio, rebalancing it automatically based on market trends, risk tolerance, and financial goals (e.g., shifting assets between stocks, bonds, or crypto).
34 | 35 | [ ] new loan/fraud check/update with start
36 | [ ] financial advise - args being freeform customer input about their financial situation, goals 37 | [ ] tool is maybe a new tool asking the LLM to advise 38 | 39 | [ ] for demo simulate failure - add utilities/simulated failures from pipeline demo
40 | -------------------------------------------------------------------------------- /tools/find_events.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime 3 | from pathlib import Path 4 | 5 | 6 | def find_events(args: dict) -> dict: 7 | search_city = args.get("city", "").lower() 8 | search_month = args.get("month", "").capitalize() 9 | 10 | file_path = Path(__file__).resolve().parent / "data" / "find_events_data.json" 11 | if not file_path.exists(): 12 | return {"error": "Data file not found."} 13 | 14 | try: 15 | month_number = datetime.strptime(search_month, "%B").month 16 | except ValueError: 17 | return {"error": "Invalid month provided."} 18 | 19 | # Helper to wrap months into [1..12] 20 | def get_adjacent_months(m): 21 | prev_m = 12 if m == 1 else (m - 1) 22 | next_m = 1 if m == 12 else (m + 1) 23 | return [prev_m, m, next_m] 24 | 25 | valid_months = get_adjacent_months(month_number) 26 | 27 | matching_events = [] 28 | for city_name, events in json.load(open(file_path)).items(): 29 | if search_city and search_city not in city_name.lower(): 30 | continue 31 | 32 | for event in events: 33 | date_from = datetime.strptime(event["dateFrom"], "%Y-%m-%d") 34 | date_to = datetime.strptime(event["dateTo"], "%Y-%m-%d") 35 | 36 | # If the event's start or end month is in our valid months 37 | if date_from.month in valid_months or date_to.month in valid_months: 38 | # Add metadata explaining how it matches 39 | if date_from.month == month_number or date_to.month == month_number: 40 | month_context = "requested month" 41 | elif ( 42 | date_from.month == valid_months[0] 43 | or date_to.month == valid_months[0] 44 | ): 45 | month_context = "previous month" 46 | else: 47 | month_context = "next month" 48 | 49 | matching_events.append( 50 | { 51 | "city": city_name, 52 | "eventName": event["eventName"], 53 | "dateFrom": event["dateFrom"], 54 | "dateTo": event["dateTo"], 55 | "description": event["description"], 56 | "month": month_context, 57 | } 58 | ) 59 | 60 | # Add top-level metadata if you wish 61 | return { 62 | "note": f"Returning events from {search_month} plus one month either side (i.e., {', '.join(datetime(2025, m, 1).strftime('%B') for m in valid_months)}).", 63 | "events": matching_events, 64 | } 65 | -------------------------------------------------------------------------------- /enterprise/Activities/TrainActivities.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http.Json; 2 | using System.Text.Json; 3 | using Temporalio.Activities; 4 | using TrainSearchWorker.Models; 5 | using Microsoft.Extensions.Logging; 6 | 7 | namespace TrainSearchWorker.Activities; 8 | 9 | public class TrainActivities 10 | { 11 | private readonly HttpClient _client; 12 | private readonly JsonSerializerOptions _jsonOptions; 13 | 14 | public TrainActivities(IHttpClientFactory clientFactory) 15 | { 16 | _client = clientFactory.CreateClient("TrainApi"); 17 | _jsonOptions = new JsonSerializerOptions 18 | { 19 | PropertyNameCaseInsensitive = true 20 | }; 21 | } 22 | 23 | 24 | [Activity] 25 | public async Task SearchTrains(SearchTrainsRequest request) 26 | { 27 | ActivityExecutionContext.Current.Logger.LogInformation($"SearchTrains from {request.From} to {request.To}"); 28 | var response = await _client.GetAsync( 29 | $"api/search?from={Uri.EscapeDataString(request.From)}" + 30 | $"&to={Uri.EscapeDataString(request.To)}" + 31 | $"&outbound_time={Uri.EscapeDataString(request.OutboundTime)}" + 32 | $"&return_time={Uri.EscapeDataString(request.ReturnTime)}"); 33 | 34 | response.EnsureSuccessStatusCode(); 35 | 36 | // Deserialize into JourneyResponse rather than List 37 | var journeyResponse = await response.Content.ReadFromJsonAsync(_jsonOptions) 38 | ?? throw new InvalidOperationException("Received null response from API"); 39 | 40 | ActivityExecutionContext.Current.Logger.LogInformation("SearchTrains completed"); 41 | 42 | return journeyResponse; 43 | } 44 | 45 | [Activity] 46 | public async Task BookTrains(BookTrainsRequest request) 47 | { 48 | ActivityExecutionContext.Current.Logger.LogInformation($"Booking trains with IDs: {request.TrainIds}"); 49 | 50 | // Build the URL using the train IDs from the request 51 | var url = $"api/book/{Uri.EscapeDataString(request.TrainIds)}"; 52 | 53 | // POST with no JSON body, matching the Python version 54 | var response = await _client.PostAsync(url, null); 55 | response.EnsureSuccessStatusCode(); 56 | 57 | // Deserialize into a BookTrainsResponse (a single object) 58 | var bookingResponse = await response.Content.ReadFromJsonAsync(_jsonOptions) 59 | ?? throw new InvalidOperationException("Received null response from API"); 60 | 61 | ActivityExecutionContext.Current.Logger.LogInformation("BookTrains completed"); 62 | 63 | return bookingResponse; 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /tools/data/customer_order_data.json: -------------------------------------------------------------------------------- 1 | { 2 | "orders": [ 3 | { 4 | "id": "100", 5 | "summary": "Lawyer Books", 6 | "email": "matt.murdock@nelsonmurdock.com", 7 | "status": "cancelled", 8 | "order_date": "2025-03-30", 9 | "last_update": "2025-04-01" 10 | }, 11 | { 12 | "id": "101", 13 | "summary": "Bonking Sticks", 14 | "email": "matt.murdock@nelsonmurdock.com", 15 | "status": "paid", 16 | "order_date": "2025-04-01", 17 | "last_order_update": "2025-04-01" 18 | }, 19 | { 20 | "id": "102", 21 | "summary": "Red Sunglasses", 22 | "email": "matt.murdock@nelsonmurdock.com", 23 | "status": "shipped", 24 | "order_date": "2025-04-01", 25 | "last_order_update": "2025-04-01", 26 | "tracking_id": "UPS67890" 27 | }, 28 | { 29 | "id": "200", 30 | "summary": "Paper", 31 | "email": "foggy.nelson@nelsonmurdock.com", 32 | "status": "shipped", 33 | "order_date": "2025-04-03", 34 | "last_update": "2025-04-06", 35 | "tracking_id": "USPS12345" 36 | }, 37 | { 38 | "id": "300", 39 | "summary": "Chemistry Books", 40 | "email": "heisenberg@blue-meth.com", 41 | "status": "shipped", 42 | "order_date": "2025-03-30", 43 | "last_update": "2025-04-06", 44 | "tracking_id": "USPS12345" 45 | }, 46 | { 47 | "id": "301", 48 | "summary": "Book: Being a Cool Bro", 49 | "email": "heisenberg@blue-meth.com", 50 | "status": "cancelled", 51 | "order_date": "2025-04-01", 52 | "last_update": "2025-04-02" 53 | }, 54 | { 55 | "id": "302", 56 | "summary": "Black Hat", 57 | "email": "heisenberg@blue-meth.com", 58 | "status": "delivered", 59 | "order_date": "2025-04-01", 60 | "last_update": "2025-04-06", 61 | "tracking_id": "UPS67890" 62 | }, 63 | { 64 | "id": "400", 65 | "summary": "Giant Graphic Hoodie", 66 | "email": "jessenotpinkman@blue-meth.com", 67 | "status": "shipped", 68 | "order_date": "2025-04-03", 69 | "last_update": "2025-04-09", 70 | "tracking_id": "UPS67890" 71 | }, 72 | { 73 | "id": "401", 74 | "summary": "Giant Pants", 75 | "email": "jessenotpinkman@blue-meth.com", 76 | "status": "processing", 77 | "order_date": "2025-04-03", 78 | "last_update": "2025-04-09" 79 | } 80 | ] 81 | } -------------------------------------------------------------------------------- /tools/create_invoice.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from dotenv import load_dotenv 4 | 5 | 6 | def ensure_customer_exists( 7 | customer_id: str = None, email: str = "default@example.com" 8 | ) -> str: 9 | """Ensure a Stripe customer exists; create one if not.""" 10 | import stripe 11 | 12 | if customer_id: 13 | try: 14 | stripe.Customer.retrieve(customer_id) 15 | return customer_id 16 | except stripe.error.InvalidRequestError: 17 | # Customer ID is invalid or doesn't exist 18 | pass 19 | 20 | # Create a new customer if no valid customer_id 21 | customer = stripe.Customer.create(email=email) 22 | return customer.id 23 | 24 | 25 | def create_invoice(args: dict) -> dict: 26 | """Create and finalize a Stripe invoice.""" 27 | import stripe 28 | 29 | # Load environment variables and configure stripe 30 | load_dotenv(override=True) 31 | stripe.api_key = os.getenv("STRIPE_API_KEY") 32 | 33 | # If an API key exists in the env file, find or create customer 34 | if stripe.api_key is not None and stripe.api_key != "": 35 | customer_id = ensure_customer_exists( 36 | args.get("customer_id"), args.get("email", "default@example.com") 37 | ) 38 | 39 | # Get amount and convert to cents 40 | amount = args.get("amount", 200.00) # Default to $200.00 41 | try: 42 | amount_cents = int(float(amount) * 100) 43 | except (TypeError, ValueError): 44 | return {"error": "Invalid amount provided. Please confirm the amount."} 45 | 46 | # Create an invoice item 47 | stripe.InvoiceItem.create( 48 | customer=customer_id, 49 | amount=amount_cents, 50 | currency="usd", 51 | description=args.get("tripDetails", "Service Invoice"), 52 | ) 53 | 54 | # Create and finalize the invoice 55 | invoice = stripe.Invoice.create( 56 | customer=customer_id, 57 | collection_method="send_invoice", # Invoice is sent to the customer 58 | days_until_due=args.get("days_until_due", 7), # Default due date: 7 days 59 | pending_invoice_items_behavior="include", # No pending invoice items 60 | ) 61 | finalized_invoice = stripe.Invoice.finalize_invoice(invoice.id) 62 | 63 | return { 64 | "invoiceStatus": finalized_invoice.status, 65 | "invoiceURL": finalized_invoice.hosted_invoice_url, 66 | "reference": finalized_invoice.number, 67 | } 68 | # if no API key is in the env file, return dummy info 69 | else: 70 | print("[CreateInvoice] Creating invoice with:", args) 71 | return { 72 | "invoiceStatus": "generated", 73 | "invoiceURL": "https://pay.example.com/invoice/12345", 74 | "reference": "INV-12345", 75 | } 76 | -------------------------------------------------------------------------------- /tools/__init__.py: -------------------------------------------------------------------------------- 1 | from .change_goal import change_goal 2 | from .create_invoice import create_invoice 3 | from .ecommerce.get_order import get_order 4 | from .ecommerce.list_orders import list_orders 5 | from .ecommerce.track_package import track_package 6 | from .fin.check_account_valid import check_account_valid 7 | from .fin.get_account_balances import get_account_balance 8 | from .fin.move_money import move_money 9 | from .fin.submit_loan_application import submit_loan_application 10 | from .find_events import find_events 11 | from .food.add_to_cart import add_to_cart 12 | from .give_hint import give_hint 13 | from .guess_location import guess_location 14 | from .hr.book_pto import book_pto 15 | from .hr.checkpaybankstatus import checkpaybankstatus 16 | from .hr.current_pto import current_pto 17 | from .hr.future_pto_calc import future_pto_calc 18 | from .list_agents import list_agents 19 | from .search_fixtures import search_fixtures 20 | from .search_flights import search_flights 21 | from .search_trains import book_trains, search_trains 22 | from .transfer_control import transfer_control 23 | 24 | 25 | def get_handler(tool_name: str): 26 | if tool_name == "SearchFixtures": 27 | return search_fixtures 28 | if tool_name == "SearchFlights": 29 | return search_flights 30 | if tool_name == "SearchTrains": 31 | return search_trains 32 | if tool_name == "BookTrains": 33 | return book_trains 34 | if tool_name == "CreateInvoice": 35 | return create_invoice 36 | if tool_name == "FindEvents": 37 | return find_events 38 | if tool_name == "ListAgents": 39 | return list_agents 40 | if tool_name == "ChangeGoal": 41 | return change_goal 42 | if tool_name == "TransferControl": 43 | return transfer_control 44 | if tool_name == "CurrentPTO": 45 | return current_pto 46 | if tool_name == "BookPTO": 47 | return book_pto 48 | if tool_name == "FuturePTOCalc": 49 | return future_pto_calc 50 | if tool_name == "CheckPayBankStatus": 51 | return checkpaybankstatus 52 | if tool_name == "FinCheckAccountIsValid": 53 | return check_account_valid 54 | if tool_name == "FinCheckAccountBalance": 55 | return get_account_balance 56 | if tool_name == "FinMoveMoney": 57 | return move_money 58 | if tool_name == "FinCheckAccountSubmitLoanApproval": 59 | return submit_loan_application 60 | if tool_name == "GetOrder": 61 | return get_order 62 | if tool_name == "TrackPackage": 63 | return track_package 64 | if tool_name == "ListOrders": 65 | return list_orders 66 | if tool_name == "GiveHint": 67 | return give_hint 68 | if tool_name == "GuessLocation": 69 | return guess_location 70 | if tool_name == "AddToCart": 71 | return add_to_cart 72 | 73 | raise ValueError(f"Unknown tool: {tool_name}") 74 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | # Database service 3 | postgresql: 4 | image: postgres:14 5 | container_name: temporal-postgresql 6 | environment: 7 | POSTGRES_USER: temporal 8 | POSTGRES_PASSWORD: temporal 9 | POSTGRES_DB: temporal 10 | volumes: 11 | - postgresql:/var/lib/postgresql/data 12 | networks: 13 | - temporal-network 14 | 15 | # Temporal services 16 | temporal: 17 | image: temporalio/auto-setup:1.27.2 18 | container_name: temporal 19 | ports: 20 | - "7233:7233" 21 | environment: 22 | - DB=postgres12 23 | - DB_PORT=5432 24 | - POSTGRES_USER=temporal 25 | - POSTGRES_PWD=temporal 26 | - POSTGRES_SEEDS=postgresql 27 | depends_on: 28 | - postgresql 29 | networks: 30 | - temporal-network 31 | 32 | temporal-admin-tools: 33 | image: temporalio/admin-tools:1.27 34 | container_name: temporal-admin-tools 35 | depends_on: 36 | - temporal 37 | environment: 38 | - TEMPORAL_CLI_ADDRESS=temporal:7233 39 | networks: 40 | - temporal-network 41 | 42 | temporal-ui: 43 | image: temporalio/ui:2.37.2 44 | container_name: temporal-ui 45 | ports: 46 | - "8080:8080" 47 | environment: 48 | - TEMPORAL_ADDRESS=temporal:7233 49 | - TEMPORAL_CORS_ORIGINS=http://localhost:8080 50 | depends_on: 51 | - temporal 52 | networks: 53 | - temporal-network 54 | 55 | api: 56 | build: 57 | context: . 58 | dockerfile: Dockerfile 59 | container_name: temporal-ai-agent-api 60 | ports: 61 | - "8000:8000" 62 | depends_on: 63 | - temporal 64 | networks: 65 | - temporal-network 66 | env_file: 67 | - .env 68 | environment: 69 | - TEMPORAL_ADDRESS=temporal:7233 70 | 71 | worker: 72 | build: 73 | context: . 74 | dockerfile: Dockerfile 75 | container_name: temporal-ai-agent-worker 76 | depends_on: 77 | - temporal 78 | env_file: 79 | - .env 80 | environment: 81 | - TEMPORAL_ADDRESS=temporal:7233 82 | command: uv run scripts/run_worker.py 83 | networks: 84 | - temporal-network 85 | 86 | train-api: 87 | build: 88 | context: . 89 | dockerfile: Dockerfile 90 | container_name: temporal-ai-agent-train-api 91 | depends_on: 92 | - temporal 93 | env_file: 94 | - .env 95 | environment: 96 | - TEMPORAL_ADDRESS=temporal:7233 97 | command: uv run thirdparty/train_api.py 98 | networks: 99 | - temporal-network 100 | 101 | frontend: 102 | image: node:18-alpine 103 | container_name: temporal-ai-agent-frontend 104 | working_dir: /app 105 | volumes: 106 | - ./frontend:/app 107 | command: sh -c "apk update && apk add --no-cache xdg-utils && npm install && npx vite --host 0.0.0.0" 108 | ports: 109 | - "5173:5173" 110 | depends_on: 111 | - api 112 | networks: 113 | - temporal-network 114 | 115 | networks: 116 | temporal-network: 117 | driver: bridge 118 | 119 | volumes: 120 | postgresql: 121 | -------------------------------------------------------------------------------- /tools/hr/future_pto_calc.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import date, datetime 3 | from pathlib import Path 4 | 5 | import pandas 6 | from dateutil.relativedelta import relativedelta 7 | 8 | 9 | def future_pto_calc(args: dict) -> dict: 10 | file_path = ( 11 | Path(__file__).resolve().parent.parent / "data" / "employee_pto_data.json" 12 | ) 13 | if not file_path.exists(): 14 | return {"error": "Data file not found."} 15 | 16 | start_date = datetime.strptime(args.get("start_date"), "%Y-%m-%d").date() 17 | end_date = datetime.strptime(args.get("end_date"), "%Y-%m-%d").date() 18 | email = args.get("email") 19 | 20 | # Next, set up the ability to calculate how much PTO will be added to the user's total by the start of the PTO request 21 | today = date.today() 22 | 23 | if today > start_date: 24 | return_msg = ( 25 | "PTO start date " + args.get("start_date") + "cannot be in the past" 26 | ) 27 | return {"error": return_msg} 28 | 29 | if end_date < start_date: 30 | return_msg = ( 31 | "PTO end date " 32 | + args.get("end_date") 33 | + " must be after PTO start date " 34 | + args.get("start_date") 35 | ) 36 | return {"error": return_msg} 37 | 38 | # Get the number of business days, and then business hours (assume 8 hr biz day), included in the PTO request 39 | biz_days_of_request = len( 40 | pandas.bdate_range(start=start_date, end=end_date, inclusive="both") 41 | ) 42 | if biz_days_of_request == 0: 43 | return_msg = ( 44 | "There are no business days between " 45 | + args.get("start_date") 46 | + " and " 47 | + args.get("end_date") 48 | ) 49 | return {"error": return_msg} 50 | biz_hours_of_request = biz_days_of_request * 8 51 | 52 | # Assume PTO is added on the first of every month - month math compares rolling dates, so compare the PTO request with the first day of the current month. 53 | today_first_of_month = date(today.year, today.month, 1) 54 | time_difference = relativedelta(start_date, today_first_of_month) 55 | months_to_accrue = time_difference.years * 12 + time_difference.months 56 | 57 | data = json.load(open(file_path)) 58 | employee_list = data["theCompany"]["employees"] 59 | 60 | enough_pto = False 61 | 62 | for employee in employee_list: 63 | if employee["email"] == email: 64 | current_pto_hours = int(employee["currentPTOHrs"]) 65 | hrs_added_per_month = int(employee["hrsAddedPerMonth"]) 66 | pto_available_at_start = current_pto_hours + ( 67 | months_to_accrue * hrs_added_per_month 68 | ) 69 | pto_hrs_remaining_after = pto_available_at_start - biz_hours_of_request 70 | if pto_hrs_remaining_after >= 0: 71 | enough_pto = True 72 | return { 73 | "enough_pto": enough_pto, 74 | "pto_hrs_remaining_after": str(pto_hrs_remaining_after), 75 | } 76 | 77 | return_msg = "Employee not found with email address " + email 78 | return {"error": return_msg} 79 | -------------------------------------------------------------------------------- /frontend/src/components/MessageBubble.jsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from "react"; 2 | 3 | const MessageBubble = memo(({ message, fallback = "", isUser = false }) => { 4 | const displayText = message.response?.trim() ? message.response : fallback; 5 | 6 | if (displayText.startsWith("###")) { 7 | return null; 8 | } 9 | 10 | const renderTextWithLinks = (text) => { 11 | // First handle image markdown: ![alt text](url) 12 | const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; 13 | const urlRegex = /(https?:\/\/[^\s]+)/g; 14 | 15 | // Split by image markdown first 16 | const imageParts = text.split(imageRegex); 17 | 18 | return imageParts.map((part, index) => { 19 | // Every third element (starting from index 2) is an image URL 20 | if (index > 0 && (index - 2) % 3 === 0) { 21 | const altText = imageParts[index - 1]; 22 | const imageUrl = part; 23 | return ( 24 | {altText} 32 | ); 33 | } 34 | // Skip alt text parts (every second element after first) 35 | if (index > 0 && (index - 1) % 3 === 0) { 36 | return null; 37 | } 38 | 39 | // Handle regular text and links 40 | const linkParts = part.split(urlRegex); 41 | return linkParts.map((linkPart, linkIndex) => { 42 | if (urlRegex.test(linkPart)) { 43 | return ( 44 | 52 | {linkPart} 53 | 54 | ); 55 | } 56 | return linkPart; 57 | }); 58 | }).filter(Boolean); 59 | }; 60 | 61 | return ( 62 |
74 | {renderTextWithLinks(displayText)} 75 |
76 | ); 77 | }); 78 | 79 | MessageBubble.displayName = 'MessageBubble'; 80 | 81 | export default MessageBubble; 82 | -------------------------------------------------------------------------------- /tools/food/setup/create_stripe_products.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from dotenv import load_dotenv 5 | 6 | 7 | def create_stripe_products(): 8 | """Create Stripe products and prices from the stripe_pizza_products.json file.""" 9 | import stripe 10 | 11 | # Load environment variables and configure stripe 12 | load_dotenv(override=True) 13 | stripe.api_key = os.getenv("STRIPE_API_KEY") 14 | 15 | if not stripe.api_key: 16 | print("Error: STRIPE_API_KEY not found in environment variables") 17 | return 18 | 19 | # Load the products data 20 | current_dir = os.path.dirname(__file__) 21 | products_file = os.path.join(current_dir, "stripe_pizza_products.json") 22 | 23 | with open(products_file, "r") as f: 24 | products_data = json.load(f) 25 | 26 | # Filter for food ordering demo products only 27 | food_products = [ 28 | p 29 | for p in products_data 30 | if p.get("metadata", {}).get("use_case") == "food_ordering_demo" 31 | ] 32 | 33 | created_products = [] 34 | 35 | for product_data in food_products: 36 | try: 37 | # Create the product with relevant fields 38 | product = stripe.Product.create( 39 | name=product_data["name"], 40 | description=product_data.get("description"), 41 | images=product_data.get("images", []), 42 | metadata=product_data.get("metadata", {}), 43 | type=product_data.get("type", "service"), 44 | active=product_data.get("active", True), 45 | ) 46 | 47 | # Create price for the product if price_info exists 48 | price_info = product_data.get("price_info") 49 | if price_info: 50 | price_amount = price_info.get("amount") 51 | currency = price_info.get("currency", "usd") 52 | 53 | price = stripe.Price.create( 54 | currency=currency, unit_amount=price_amount, product=product.id 55 | ) 56 | 57 | # Set this price as the default price for the product 58 | stripe.Product.modify(product.id, default_price=price.id) 59 | 60 | print( 61 | f"Created product: {product.name} (ID: {product.id}) with default price ${price_amount/100:.2f}" 62 | ) 63 | 64 | created_products.append( 65 | { 66 | "name": product.name, 67 | "id": product.id, 68 | "price_id": price.id, 69 | "price_amount": price_amount, 70 | "original_id": product_data["id"], 71 | } 72 | ) 73 | else: 74 | print( 75 | f"Created product: {product.name} (ID: {product.id}) - No price defined" 76 | ) 77 | created_products.append( 78 | { 79 | "name": product.name, 80 | "id": product.id, 81 | "original_id": product_data["id"], 82 | } 83 | ) 84 | 85 | except Exception as e: 86 | print(f"Error creating product {product_data['name']}: {str(e)}") 87 | 88 | print(f"\nSuccessfully created {len(created_products)} products with prices") 89 | return created_products 90 | 91 | 92 | if __name__ == "__main__": 93 | create_stripe_products() 94 | -------------------------------------------------------------------------------- /docs/architecture-decisions.md: -------------------------------------------------------------------------------- 1 | # Architecture Decisions 2 | This documents some of the "why" behind the [architecture](./architecture.md). 3 | 4 | ## AI Models 5 | We wanted to have flexibility to use different models, because this space is changing rapidly and models get better regularly. 6 | Also, for you, we wanted to let you pick your model of choice. The system is designed to make changing models out simple. For how to do that, checkout the [setup guide](./setup.md). 7 | 8 | ## Temporal 9 | We asked one of the AI models used in this demo to answer this question (edited minorly): 10 | 11 | ### Reliability and State Management: 12 | Temporal ensures durability and fault tolerance, which are critical for agentic AI systems that involve long-running, complex workflows. For example, it preserves application state across failures, allowing AI agents to resume from where they left off without losing progress. Major AI companies use this for research experiments and agentic flows, where reliability is essential for continuous exploration. 13 | ### Handling Complex, Dynamic Workflows: 14 | Agentic AI often involves unpredictable, multi-step processes like web crawling or data searching. Temporal’s workflow orchestration simplifies managing these tasks by abstracting complexity, providing features like retries, timeouts, and signals/queries. Temporal makes observability and resuming failed complex experiments and deep searches simple. 15 | ### Scalability and Speed: 16 | Temporal enables rapid development and scaling, crucial for AI systems handling large-scale experiments or production workloads. AI model deployment and SRE teams use it to get code to production quickly with scale as a focus, while research teams can (and do!) run hundreds of experiments daily. Temporal customers report a significant reduction in development time (e.g., 20 weeks to 2 weeks for a feature). 17 | ### Observability and Debugging: 18 | Agentic AI systems need insight into where processes succeed or fail. Temporal provides end-to-end visibility and durable workflow history, which Temporal customers are using to track agentic flows and understand failure points. 19 | ### Simplified Error Handling: 20 | Temporal abstracts failure management (e.g., retries, rollbacks) so developers can focus on AI logic rather than "plumbing" code. This is vital for agentic AI, where external interactions (e.g., APIs, data sources) are prone to failure. 21 | ### Flexibility for Experimentation: 22 | For research-heavy agentic AI, Temporal supports dynamic, code-first workflows and easy integration of new signals/queries, aligning with researchers needs to iterate quickly on experimental paths. 23 | 24 | In essence, Temporal’s value lies in its ability to make agentic AI systems more reliable, scalable, and easier to develop by handling the underlying complexity of distributed workflows for both research and applied AI tasks. 25 | 26 | Temporal was built to solve the problems of distributed computing, including scalability, reliability, security, visibility, and complexity. Agentic AI systems are complex distributed systems, so Temporal should fit well. Scaling, security, and productionalization are major pain points in March 2025 for building agentic systems. 27 | 28 | In this system Temporal lets you: 29 | - Orchestrate interactions across distributed data stores and tools
30 | - Hold state, potentially over long periods of time
31 | - Ability to ‘self-heal’ and retry until the (probabilistic) LLM returns valid data
32 | - Support for human intervention such as approvals
33 | - Parallel processing for efficiency of data retrieval and tool use
-------------------------------------------------------------------------------- /scripts/run_worker.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import concurrent.futures 3 | import logging 4 | import os 5 | 6 | from dotenv import load_dotenv 7 | from temporalio.worker import Worker 8 | 9 | from activities.tool_activities import ( 10 | ToolActivities, 11 | dynamic_tool_activity, 12 | mcp_list_tools, 13 | ) 14 | from shared.config import TEMPORAL_TASK_QUEUE, get_temporal_client 15 | from shared.mcp_client_manager import MCPClientManager 16 | from workflows.agent_goal_workflow import AgentGoalWorkflow 17 | 18 | 19 | async def main(): 20 | # Load environment variables 21 | load_dotenv(override=True) 22 | 23 | # Print LLM configuration info 24 | llm_model = os.environ.get("LLM_MODEL", "openai/gpt-4") 25 | print(f"Worker will use LLM model: {llm_model}") 26 | 27 | # Create shared MCP client manager 28 | mcp_client_manager = MCPClientManager() 29 | 30 | # Create the client 31 | client = await get_temporal_client() 32 | 33 | # Initialize the activities class with injected manager 34 | activities = ToolActivities(mcp_client_manager) 35 | print(f"ToolActivities initialized with LLM model: {llm_model}") 36 | 37 | # If using Ollama, pre-load the model to avoid cold start latency 38 | if llm_model.startswith("ollama"): 39 | print("\n======== OLLAMA MODEL INITIALIZATION ========") 40 | print("Ollama models need to be loaded into memory on first use.") 41 | print("This may take 30+ seconds depending on your hardware and model size.") 42 | print("Please wait while the model is being loaded...") 43 | 44 | # This call will load the model and measure initialization time 45 | success = activities.warm_up_ollama() 46 | 47 | if success: 48 | print("===========================================================") 49 | print("✅ Ollama model successfully pre-loaded and ready for requests!") 50 | print("===========================================================\n") 51 | else: 52 | print("===========================================================") 53 | print("⚠️ Ollama model pre-loading failed. The worker will continue,") 54 | print("but the first actual request may experience a delay while") 55 | print("the model is loaded on-demand.") 56 | print("===========================================================\n") 57 | 58 | print("Worker ready to process tasks!") 59 | logging.basicConfig(level=logging.INFO) 60 | 61 | # Run the worker with proper cleanup 62 | try: 63 | with concurrent.futures.ThreadPoolExecutor( 64 | max_workers=100 65 | ) as activity_executor: 66 | worker = Worker( 67 | client, 68 | task_queue=TEMPORAL_TASK_QUEUE, 69 | workflows=[AgentGoalWorkflow], 70 | activities=[ 71 | activities.agent_validatePrompt, 72 | activities.agent_toolPlanner, 73 | activities.get_wf_env_vars, 74 | activities.mcp_tool_activity, 75 | dynamic_tool_activity, 76 | mcp_list_tools, 77 | ], 78 | activity_executor=activity_executor, 79 | ) 80 | 81 | print(f"Starting worker, connecting to task queue: {TEMPORAL_TASK_QUEUE}") 82 | await worker.run() 83 | finally: 84 | # Cleanup MCP connections when worker shuts down 85 | await mcp_client_manager.cleanup() 86 | 87 | 88 | if __name__ == "__main__": 89 | asyncio.run(main()) 90 | -------------------------------------------------------------------------------- /frontend/src/components/ChatWindow.jsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useCallback } from "react"; 2 | import LLMResponse from "./LLMResponse"; 3 | import MessageBubble from "./MessageBubble"; 4 | import LoadingIndicator from "./LoadingIndicator"; 5 | 6 | class ChatErrorBoundary extends React.Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { hasError: false }; 10 | } 11 | 12 | static getDerivedStateFromError(error) { 13 | return { hasError: true }; 14 | } 15 | 16 | componentDidCatch(error, errorInfo) { 17 | console.error("ChatWindow error:", error, errorInfo); 18 | } 19 | 20 | render() { 21 | if (this.state.hasError) { 22 | return ( 23 |
24 | Something went wrong. Please Terminate the workflow and try again. 25 |
26 | ); 27 | } 28 | return this.props.children; 29 | } 30 | } 31 | 32 | const safeParse = (str) => { 33 | try { 34 | return typeof str === 'string' ? JSON.parse(str) : str; 35 | } catch (err) { 36 | console.error("safeParse error:", err, "Original string:", str); 37 | return str; 38 | } 39 | }; 40 | 41 | const Message = memo(({ msg, idx, isLastMessage, onConfirm, onContentChange }) => { 42 | const { actor, response } = msg; 43 | 44 | if (actor === "user") { 45 | return ; 46 | } 47 | 48 | if (actor === "agent") { 49 | const data = safeParse(response); 50 | return ( 51 | 57 | ); 58 | } 59 | 60 | return null; 61 | }); 62 | 63 | Message.displayName = 'Message'; 64 | 65 | const ChatWindow = memo(({ conversation, loading, onConfirm, onContentChange }) => { 66 | const validateConversation = useCallback((conv) => { 67 | if (!Array.isArray(conv)) { 68 | console.error("ChatWindow expected conversation to be an array, got:", conv); 69 | return []; 70 | } 71 | return conv; 72 | }, []); 73 | 74 | const filtered = validateConversation(conversation).filter((msg) => { 75 | const { actor } = msg; 76 | return actor === "user" || actor === "agent"; 77 | }); 78 | 79 | return ( 80 | 81 |
82 |
83 | {filtered.map((msg, idx) => ( 84 | 92 | ))} 93 | {loading && ( 94 |
95 | 96 |
97 | )} 98 |
99 |
100 |
101 | ); 102 | }); 103 | 104 | ChatWindow.displayName = 'ChatWindow'; 105 | 106 | export default ChatWindow; 107 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import multiprocessing 3 | import sys 4 | from typing import AsyncGenerator 5 | 6 | import pytest 7 | import pytest_asyncio 8 | from temporalio.client import Client 9 | from temporalio.testing import WorkflowEnvironment 10 | 11 | # Due to https://github.com/python/cpython/issues/77906, multiprocessing on 12 | # macOS starting with Python 3.8 has changed from "fork" to "spawn". For 13 | # pre-3.8, we are changing it for them. 14 | if sys.version_info < (3, 8) and sys.platform.startswith("darwin"): 15 | multiprocessing.set_start_method("spawn", True) 16 | 17 | 18 | def pytest_addoption(parser): 19 | parser.addoption( 20 | "--workflow-environment", 21 | default="local", 22 | help="Which workflow environment to use ('local', 'time-skipping', or target to existing server)", 23 | ) 24 | 25 | 26 | @pytest.fixture(scope="session") 27 | def event_loop(): 28 | # See https://github.com/pytest-dev/pytest-asyncio/issues/68 29 | # See https://github.com/pytest-dev/pytest-asyncio/issues/257 30 | # Also need ProactorEventLoop on older versions of Python with Windows so 31 | # that asyncio subprocess works properly 32 | if sys.version_info < (3, 8) and sys.platform == "win32": 33 | loop = asyncio.ProactorEventLoop() 34 | else: 35 | loop = asyncio.get_event_loop_policy().new_event_loop() 36 | yield loop 37 | loop.close() 38 | 39 | 40 | @pytest_asyncio.fixture(scope="session") 41 | async def env(request) -> AsyncGenerator[WorkflowEnvironment, None]: 42 | env_type = request.config.getoption("--workflow-environment") 43 | if env_type == "local": 44 | env = await WorkflowEnvironment.start_local( 45 | dev_server_extra_args=[ 46 | "--dynamic-config-value", 47 | "frontend.enableExecuteMultiOperation=true", 48 | ] 49 | ) 50 | elif env_type == "time-skipping": 51 | env = await WorkflowEnvironment.start_time_skipping() 52 | else: 53 | env = WorkflowEnvironment.from_client(await Client.connect(env_type)) 54 | yield env 55 | await env.shutdown() 56 | 57 | 58 | @pytest_asyncio.fixture 59 | async def client(env: WorkflowEnvironment) -> Client: 60 | return env.client 61 | 62 | 63 | @pytest.fixture 64 | def sample_agent_goal(): 65 | """Sample agent goal for testing.""" 66 | from models.tool_definitions import AgentGoal, ToolArgument, ToolDefinition 67 | 68 | return AgentGoal( 69 | id="test_goal", 70 | category_tag="test", 71 | agent_name="TestAgent", 72 | agent_friendly_description="A test agent for testing purposes", 73 | description="Test goal for agent testing", 74 | tools=[ 75 | ToolDefinition( 76 | name="TestTool", 77 | description="A test tool for testing purposes", 78 | arguments=[ 79 | ToolArgument( 80 | name="test_arg", type="string", description="A test argument" 81 | ) 82 | ], 83 | ) 84 | ], 85 | ) 86 | 87 | 88 | @pytest.fixture 89 | def sample_conversation_history(): 90 | """Sample conversation history for testing.""" 91 | return { 92 | "messages": [ 93 | {"actor": "user", "response": "Hello, I need help with testing"}, 94 | {"actor": "agent", "response": "I can help you with that"}, 95 | ] 96 | } 97 | 98 | 99 | @pytest.fixture 100 | def sample_combined_input(sample_agent_goal): 101 | """Sample combined input for workflow testing.""" 102 | from collections import deque 103 | 104 | from models.data_types import AgentGoalWorkflowParams, CombinedInput 105 | 106 | tool_params = AgentGoalWorkflowParams( 107 | conversation_summary="Test conversation summary", 108 | prompt_queue=deque(), # Start with empty queue for most tests 109 | ) 110 | 111 | return CombinedInput(agent_goal=sample_agent_goal, tool_params=tool_params) 112 | -------------------------------------------------------------------------------- /docs/architecture.md: -------------------------------------------------------------------------------- 1 | # Elements 2 | These are the main elements of this system. See [architecture decisions](./architecture-decisions.md) for information beind these choices. 3 | In this document we will explain each element and their interactions, and then connect them all at the end. 4 | Architecture Elements 5 | 6 | ## Workflow 7 | This is a [Temporal Workflow](https://docs.temporal.io/workflows) - a durable straightforward description of the process to be executed. See [agent_goal_workflow.py](./workflows/agent_goal_workflow.py). 8 | Temporal is used to make the process scalable, durable, reliable, secure, and visible. 9 | 10 | ### Workflow Responsibilities: 11 | - Orchestrates interactive loops: 12 | - LLM Loop: Prompts LLM, durably executes LLM, stores responses 13 | - Interactive Loop: Elicits responses from input (in our case a human) and validates input responses 14 | - Tool Execution Loop: Durably executes Tools 15 | - Keeps record of all interactions ([Signals, Queries, Updates](https://docs.temporal.io/develop/python/message-passing)) 16 | - Handles failures gracefully 17 | - Input, LLM and Tool interaction history stored for debugging and analysis 18 | 19 | ## Activities 20 | These are [Temporal Activities](https://docs.temporal.io/activities). Defined as simple functions, they are auto-retried async/event driven behind the scenes. Activities durably execute Tools and the LLM. See [a sample activity](./activities/tool_activities.py). 21 | 22 | ## Tools 23 | Tools define the capabilities of the system. They are simple Python functions (could be in any language as Temporal supports multiple languages). 24 | They are executed by Temporal Activities. They are “just code” - can connect to any API or system. They also are where the deterministic business logic is: you can validate and retry actions using code you write. 25 | Failures are handled gracefully by Temporal. 26 | 27 | Activities + Tools turn the probabalistic input from the user and LLM into deterministic action. 28 | 29 | ## Prompts 30 | Prompts are where the instructions to the LLM are. Prompts are made up of initial instructions, goal instructions, and tool instructions. 31 | See [agent prompts](./prompts/agent_prompt_generators.py) and [goal & tool prompts](./tools/goal_registry.py). 32 | 33 | This is where you can add probabalistic business logic to 34 | - to control process flow 35 | - describe what to do 36 | - give examples of interactions 37 | - give instruction and validation for the LLM 38 | 39 | ## LLM 40 | Probabalistic execution: it will _probably_ do what you tell it to do. 41 | Turns the guidance from the prompts (see [agent prompts](./prompts/agent_prompt_generators.py) and [goal prompts](./tools/goal_registry.py)) into 42 | You have a choice of providers - see [setup](./setup.md). 43 | The LLM: 44 | - Drives toward the initial Goal and any subsequent Goals selected by user 45 | - Decides what to do based on input, such as: 46 | - Validates user input for Tools 47 | - Decides when to execute Tools 48 | - Decides on next step for Goal 49 | - Formats input and interprets output for Tools 50 | - is executed by Temporal Activities 51 | - API failures and logical failures are handled transparently 52 | 53 | ## Interaction 54 | Interaction is managed with Temporal Signals and Queries. These are durably stored in Workflow History. 55 | History can be used for analysis and debugging. It's all “just code” so it's easy to add new Signals and Queries. 56 | Input can be very dynamic, just needs to be serializable. 57 | 58 | The Workflow executes the Interaction Loop: gathering input, validating input, and providing a response: 59 | 60 | ![Interaction Loop](./assets/interaction_loop.png) 61 | 62 | Here's a more detailed example for gathering inputs for Tools: 63 | 64 | ![Tool Gathering](./assets/argument_gathering_cycle.png) 65 | 66 | # Architecture Model 67 | Now that we have the pieces and what they do, here is a more complete diagram of how the pieces work together: 68 | 69 | 70 | ![Architecture](./assets/ai_agent_architecture_model.png "Architecture Model") 71 | 72 | 73 | # Adding features 74 | Want to add more Goals and Tools? See [adding goals and tools](./adding-goals-and-tools.md). Have fun! -------------------------------------------------------------------------------- /frontend/src/services/api.js: -------------------------------------------------------------------------------- 1 | const API_BASE_URL = 'http://127.0.0.1:8000'; 2 | 3 | const resolveRequestTimeout = () => { 4 | const env = typeof import.meta !== 'undefined' ? import.meta.env : undefined; 5 | const configured = env?.VITE_API_TIMEOUT_MS; 6 | const parsed = Number.parseInt(configured, 10); 7 | if (Number.isFinite(parsed) && parsed > 0) { 8 | return parsed; 9 | } 10 | return 15000; 11 | }; 12 | 13 | const REQUEST_TIMEOUT_MS = resolveRequestTimeout(); // default to 15s, overridable via Vite env 14 | 15 | class ApiError extends Error { 16 | constructor(message, status) { 17 | super(message); 18 | this.status = status; 19 | this.name = 'ApiError'; 20 | } 21 | } 22 | 23 | async function handleResponse(response) { 24 | if (!response.ok) { 25 | const errorData = await response.json().catch(() => ({})); 26 | throw new ApiError( 27 | errorData.message || 'An error occurred', 28 | response.status 29 | ); 30 | } 31 | return response.json(); 32 | } 33 | 34 | async function fetchWithTimeout(url, options = {}, timeout = REQUEST_TIMEOUT_MS) { 35 | const controller = new AbortController(); 36 | const timeoutId = setTimeout(() => controller.abort(), timeout); 37 | 38 | try { 39 | return await fetch(url, { ...options, signal: controller.signal }); 40 | } catch (error) { 41 | if (error.name === 'AbortError') { 42 | throw new ApiError('Request timed out', 408); 43 | } 44 | throw error; 45 | } finally { 46 | clearTimeout(timeoutId); 47 | } 48 | } 49 | 50 | export const apiService = { 51 | async getConversationHistory() { 52 | try { 53 | const res = await fetchWithTimeout(`${API_BASE_URL}/get-conversation-history`); 54 | return handleResponse(res); 55 | } catch (error) { 56 | if (error instanceof ApiError) { 57 | throw error; 58 | } 59 | throw new ApiError( 60 | 'Failed to fetch conversation history', 61 | error.status || 500 62 | ); 63 | } 64 | }, 65 | 66 | async sendMessage(message) { 67 | if (!message?.trim()) { 68 | throw new ApiError('Message cannot be empty', 400); 69 | } 70 | 71 | try { 72 | const res = await fetchWithTimeout( 73 | `${API_BASE_URL}/send-prompt?prompt=${encodeURIComponent(message)}`, 74 | { 75 | method: 'POST', 76 | headers: { 77 | 'Content-Type': 'application/json' 78 | } 79 | } 80 | ); 81 | return handleResponse(res); 82 | } catch (error) { 83 | if (error instanceof ApiError) { 84 | throw error; 85 | } 86 | throw new ApiError( 87 | 'Failed to send message', 88 | error.status || 500 89 | ); 90 | } 91 | }, 92 | 93 | async startWorkflow() { 94 | try { 95 | const res = await fetchWithTimeout( 96 | `${API_BASE_URL}/start-workflow`, 97 | { 98 | method: 'POST', 99 | headers: { 100 | 'Content-Type': 'application/json' 101 | } 102 | } 103 | ); 104 | return handleResponse(res); 105 | } catch (error) { 106 | if (error instanceof ApiError) { 107 | throw error; 108 | } 109 | throw new ApiError( 110 | 'Failed to start workflow', 111 | error.status || 500 112 | ); 113 | } 114 | }, 115 | 116 | async confirm() { 117 | try { 118 | const res = await fetchWithTimeout(`${API_BASE_URL}/confirm`, { 119 | method: 'POST', 120 | headers: { 121 | 'Content-Type': 'application/json' 122 | } 123 | }); 124 | return handleResponse(res); 125 | } catch (error) { 126 | if (error instanceof ApiError) { 127 | throw error; 128 | } 129 | throw new ApiError( 130 | 'Failed to confirm action', 131 | error.status || 500 132 | ); 133 | } 134 | } 135 | }; 136 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to the Temporal AI Agent Project 2 | 3 | This document provides guidelines for contributing to `temporal-ai-agent`. All setup and installation instructions can be found in [setup.md](./setup.md). 4 | 5 | ## Getting Started 6 | 7 | ### Code Style & Formatting 8 | We use `black` for code formatting and `isort` for import sorting to maintain a consistent codebase. 9 | - **Format code:** 10 | ```bash 11 | uv run poe format 12 | ``` 13 | Or manually 14 | ``` 15 | uv run black . 16 | uv run isort . 17 | ``` 18 | Please format your code before committing. 19 | 20 | ### Linting & Type Checking 21 | We use `mypy` for static type checking and other linters configured via `poe`. 22 | - **Run linters and type checks:** 23 | ```bash 24 | uv run poe lint 25 | ``` 26 | Or manually for type checking: 27 | ```bash 28 | uv run mypy --check-untyped-defs --namespace-packages . 29 | ``` 30 | Ensure all linting and type checks pass before submitting a pull request. 31 | 32 | ## Testing 33 | Comprehensive testing is crucial for this project. We use `pytest` and Temporal's testing framework. 34 | - **Install test dependencies:** 35 | ```bash 36 | uv sync 37 | ``` 38 | - **Run all tests:** 39 | ```bash 40 | uv run pytest 41 | ``` 42 | - **Run tests with time-skipping (recommended for faster execution, especially in CI):** 43 | ```bash 44 | uv run pytest --workflow-environment=time-skipping 45 | ``` 46 | 47 | For detailed information on test categories, running specific tests, test environments, coverage, and troubleshooting, please refer to: 48 | - [testing.md](./testing.md) (Quick Start and overview) 49 | - [tests/README.md](../tests/README.md) (Comprehensive guide, patterns, and best practices) 50 | 51 | **Ensure all tests pass before submitting a pull request.** 52 | 53 | ## Making Changes 54 | 55 | ### Adding New Tools or Goals 56 | If you're looking to extend the agent's capabilities: 57 | 1. Create your tool implementation in the `tools/` directory. 58 | 2. Register your tool and associate it with relevant goals. 59 | For detailed instructions, please see: 60 | - [Agent Customization in AGENTS.md](../AGENTS.md#agent-customization) 61 | - [Adding Goals and Tools Guide](./adding-goals-and-tools.md) 62 | 63 | ### General Code Changes 64 | - Follow the existing code style and patterns. 65 | - Ensure any new code is well-documented with comments. 66 | - Write new tests for new functionality or bug fixes. 67 | - Update existing tests if necessary. 68 | 69 | ## Submitting Contributions 70 | 71 | ### Pull Requests 72 | When you're ready to submit your changes: 73 | 1. Push your branch to the remote repository. 74 | 2. Open a Pull Request (PR) against the `main` branch. 75 | 3. **Describe your changes:** Clearly explain what you changed and why. Reference any related issues. 76 | 4. **Ensure tests pass:** All CI checks, including tests and linters, must pass. The command `uv run pytest --workflow-environment=time-skipping` is a good one to run locally. 77 | 5. **Request review:** Request a review from one or more maintainers. 78 | 79 | ## Reporting Bugs 80 | If you encounter a bug, please: 81 | 1. **Search existing issues:** Check if the bug has already been reported. 82 | 2. **Open a new issue:** If not, create a new issue. 83 | - Provide a clear and descriptive title. 84 | - Include steps to reproduce the bug. 85 | - Describe the expected behavior and what actually happened. 86 | - Provide details about your environment (OS, Python version, Temporal server version, etc.). 87 | - Include any relevant logs or screenshots. 88 | 89 | ## Suggesting Enhancements 90 | We welcome suggestions for new features or improvements! 91 | 1. **Search existing issues/discussions:** See if your idea has already been discussed. 92 | 2. **Open a new issue:** 93 | - Use a clear and descriptive title. 94 | - Provide a detailed explanation of the enhancement and its benefits. 95 | - Explain the use case or problem it solves. 96 | - Include any potential implementation ideas if you have them. 97 | 98 | ## Key Resources 99 | - **Project Overview**: [README.md](../README.md) 100 | - **Detailed Contribution & Development Guide**: [AGENTS.md](../AGENTS.md) 101 | - **Setup Instructions**: [setup.md](./setup.md) 102 | - **Comprehensive Testing Guide**: [testing.md](./testing.md) and [tests/README.md](../tests/README.md) 103 | - **System Architecture**: [architecture.md](./architecture.md) 104 | - **Architecture Decisions**: [architecture-decisions.md](./architecture-decisions.md) 105 | - **Customizing Agent Tools and Goals**: [adding-goals-and-tools.md](./adding-goals-and-tools.md) 106 | - **To-Do List / Future Enhancements**: [todo.md](./todo.md) -------------------------------------------------------------------------------- /tools/fin/submit_loan_application.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dataclasses import dataclass 3 | from datetime import date 4 | 5 | from temporalio import common 6 | from temporalio.client import WithStartWorkflowOperation, WorkflowUpdateFailedError 7 | 8 | from shared.config import get_temporal_client 9 | 10 | 11 | # Define data structures to match the Java workflow's expected input/output 12 | # see https://github.com/temporal-sa/temporal-latency-optimization-scenarios for more details 13 | @dataclass 14 | class TransactionRequest: 15 | amount: float 16 | sourceAccount: str 17 | targetAccount: str 18 | 19 | 20 | @dataclass 21 | class TxResult: 22 | transactionId: str 23 | status: str 24 | 25 | 26 | # demonstrate starting a workflow and early return pattern while the workflow continues 27 | async def submit_loan_application(args: dict) -> dict: 28 | account_key = args.get("email_address_or_account_ID") 29 | amount = args.get("amount") 30 | 31 | loan_status: dict = await start_workflow(amount=amount, account_name=account_key) 32 | 33 | if loan_status.get("error") is None: 34 | return { 35 | "status": loan_status.get("loan_application_status"), 36 | "detailed_status": loan_status.get("application_details"), 37 | "next_step": loan_status.get("advisement"), 38 | "confirmation_id": loan_status.get("transaction_id"), 39 | } 40 | else: 41 | print(loan_status) 42 | return loan_status 43 | 44 | 45 | # Async function to start workflow 46 | async def start_workflow( 47 | amount: str, 48 | account_name: str, 49 | ) -> dict: 50 | start_real_workflow = os.getenv("FIN_START_REAL_WORKFLOW") 51 | if start_real_workflow is not None and start_real_workflow.lower() == "false": 52 | # START_REAL_WORKFLOW = False 53 | return { 54 | "loan_application_status": "applied", 55 | "application_details": "loan application is submitted and initial validation is complete", 56 | "transaction_id": "APPLICATION" + account_name, 57 | "advisement": "You'll receive a confirmation for final approval in three business days", 58 | } 59 | else: 60 | # START_REAL_WORKFLOW = True 61 | # Connect to Temporal 62 | client = await get_temporal_client() 63 | 64 | # Define the workflow ID and task queue 65 | workflow_id = ( 66 | "LOAN_APPLICATION-" + account_name + "-" + date.today().strftime("%Y-%m-%d") 67 | ) 68 | task_queue = "LatencyOptimizationTEST" 69 | 70 | # Create a TransactionRequest (matching the Java workflow's expected input) 71 | tx_request = TransactionRequest( 72 | amount=float(amount), 73 | targetAccount=account_name, 74 | sourceAccount=account_name, 75 | ) 76 | 77 | start_op = WithStartWorkflowOperation( 78 | "TransactionWorkflowLocalBeforeUpdate", 79 | tx_request, 80 | id=workflow_id, 81 | id_conflict_policy=common.WorkflowIDConflictPolicy.USE_EXISTING, 82 | task_queue=task_queue, 83 | ) 84 | 85 | try: 86 | print("trying update-with-start") 87 | tx_result = TxResult( 88 | await client.execute_update_with_start_workflow( 89 | "returnInitResult", 90 | start_workflow_operation=start_op, 91 | ) 92 | ) 93 | except WorkflowUpdateFailedError: 94 | print("aww man got exception WorkflowUpdateFailedError") 95 | tx_result = None 96 | return_msg = "Loan could not be processed for " + account_name 97 | return {"error": return_msg} 98 | 99 | workflow_handle = await start_op.workflow_handle() 100 | print(f"Workflow started with ID: {workflow_handle.id}") 101 | print(tx_result) 102 | 103 | print( 104 | f"Update result: Transaction ID = {tx_result.transactionId}, Message = {tx_result.status}" 105 | ) 106 | 107 | # Optionally, wait for the workflow to complete and get the final result 108 | # final_result = await handle.result() 109 | # print(f"Workflow completed with result: {final_result}") 110 | 111 | # return {'status': loan_status.get("loan_status"), 'detailed_status': loan_status.get("results"), 'next_step': loan_status.get("advisement"), 'confirmation_id': loan_status.get("workflowID")} 112 | return { 113 | "loan_application_status": "applied", 114 | "application_details": "loan application is submitted and initial validation is complete", 115 | "transaction_id": tx_result.transactionId, 116 | "advisement": "You'll receive a confirmation for final approval in three business days", 117 | } 118 | -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | # Testing the Temporal AI Agent 2 | 3 | This guide provides instructions for running the comprehensive test suite for the Temporal AI Agent project. 4 | 5 | ## Quick Start 6 | 7 | 1. **Install dependencies**: 8 | ```bash 9 | uv sync 10 | ``` 11 | 12 | 2. **Run all tests**: 13 | ```bash 14 | uv run pytest 15 | ``` 16 | 17 | 3. **Run with time-skipping for faster execution**: 18 | ```bash 19 | uv run pytest --workflow-environment=time-skipping 20 | ``` 21 | 22 | ## Test Categories 23 | 24 | ### Unit Tests 25 | - **Activity Tests**: `tests/test_tool_activities.py` 26 | - LLM integration (mocked) 27 | - Environment configuration 28 | - JSON processing 29 | - Dynamic tool execution 30 | 31 | ### Integration Tests 32 | - **Workflow Tests**: `tests/test_agent_goal_workflow.py` 33 | - Full workflow execution 34 | - Signal and query handling 35 | - State management 36 | - Error scenarios 37 | 38 | ## Running Specific Tests 39 | 40 | ```bash 41 | # Run only activity tests 42 | uv run pytest tests/test_tool_activities.py -v 43 | 44 | # Run only workflow tests 45 | uv run pytest tests/test_agent_goal_workflow.py -v 46 | 47 | # Run a specific test 48 | uv run pytest tests/test_tool_activities.py::TestToolActivities::test_sanitize_json_response -v 49 | 50 | # Run tests matching a pattern 51 | uv run pytest -k "validation" -v 52 | ``` 53 | 54 | ## Test Environment Options 55 | 56 | ### Local Environment (Default) 57 | ```bash 58 | uv run pytest --workflow-environment=local 59 | ``` 60 | 61 | ### Time-Skipping Environment (Recommended for CI) 62 | ```bash 63 | uv run pytest --workflow-environment=time-skipping 64 | ``` 65 | 66 | ### External Temporal Server 67 | ```bash 68 | uv run pytest --workflow-environment=localhost:7233 69 | ``` 70 | 71 | ## Environment Variables 72 | 73 | Tests can be configured with these environment variables: 74 | 75 | - `LLM_MODEL`: Model for LLM testing (default: "openai/gpt-4") 76 | - `LLM_KEY`: API key for LLM service (mocked in tests) 77 | - `LLM_BASE_URL`: Custom LLM endpoint (optional) 78 | 79 | ## Test Coverage 80 | 81 | The test suite covers: 82 | 83 | ✅ **Workflows** 84 | - AgentGoalWorkflow initialization and execution 85 | - Signal handling (user_prompt, confirm, end_chat) 86 | - Query methods (conversation history, agent goal, tool data) 87 | - State management and conversation flow 88 | - Validation and error handling 89 | 90 | ✅ **Activities** 91 | - ToolActivities class methods 92 | - LLM integration (mocked) 93 | - Environment variable handling 94 | - JSON response processing 95 | - Dynamic tool activity execution 96 | 97 | ✅ **Integration** 98 | - End-to-end workflow execution 99 | - Activity registration in workers 100 | - Temporal client interactions 101 | 102 | ## Test Output 103 | 104 | Successful test run example: 105 | ``` 106 | ============================== test session starts ============================== 107 | platform darwin -- Python 3.11.3, pytest-8.3.5, pluggy-1.5.0 108 | rootdir: /Users/steveandroulakis/Documents/Code/agentic/temporal-demo/temporal-ai-agent 109 | configfile: pyproject.toml 110 | plugins: anyio-4.5.2, asyncio-0.26.0 111 | collected 21 items 112 | 113 | tests/test_tool_activities.py::TestToolActivities::test_sanitize_json_response PASSED 114 | tests/test_tool_activities.py::TestToolActivities::test_parse_json_response_success PASSED 115 | tests/test_tool_activities.py::TestToolActivities::test_get_wf_env_vars_default_values PASSED 116 | ... 117 | 118 | ============================== 21 passed in 12.5s ============================== 119 | ``` 120 | 121 | ## Troubleshooting 122 | 123 | ### Common Issues 124 | 125 | 1. **Module not found errors**: Run `uv sync` 126 | 2. **Async warnings**: These are expected with pytest-asyncio and can be ignored 127 | 3. **Test timeouts**: Use `--workflow-environment=time-skipping` for faster execution 128 | 4. **Import errors**: Check that you're running tests from the project root directory 129 | 130 | ### Debugging Tests 131 | 132 | Enable verbose logging: 133 | ```bash 134 | uv run pytest --log-cli-level=DEBUG -s 135 | ``` 136 | 137 | Run with coverage: 138 | ```bash 139 | uv run pytest --cov=workflows --cov=activities 140 | ``` 141 | 142 | ## Continuous Integration 143 | 144 | For CI environments, use: 145 | ```bash 146 | uv run pytest --workflow-environment=time-skipping --tb=short 147 | ``` 148 | 149 | ## Additional Resources 150 | 151 | - See `tests/README.md` for detailed testing documentation 152 | - Review `tests/conftest.py` for available test fixtures 153 | - Check individual test files for specific test scenarios 154 | 155 | ## Test Architecture 156 | 157 | The tests use: 158 | - **Temporal Testing Framework**: For workflow and activity testing 159 | - **pytest-asyncio**: For async test support 160 | - **unittest.mock**: For mocking external dependencies 161 | - **Test Fixtures**: For consistent test data and setup 162 | 163 | All external dependencies (LLM calls, file I/O) are mocked to ensure fast, reliable tests. -------------------------------------------------------------------------------- /goals/hr.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import tools.tool_registry as tool_registry 4 | from models.tool_definitions import AgentGoal 5 | 6 | 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." 7 | 8 | goal_hr_schedule_pto = AgentGoal( 9 | id="goal_hr_schedule_pto", 10 | category_tag="hr", 11 | agent_name="Schedule PTO", 12 | agent_friendly_description="Schedule PTO based on your available PTO.", 13 | tools=[ 14 | tool_registry.current_pto_tool, 15 | tool_registry.future_pto_calc_tool, 16 | tool_registry.book_pto_tool, 17 | ], 18 | description="The user wants to schedule paid time off (PTO) after today's date. To assist with that goal, help the user gather args for these tools in order: " 19 | "1. CurrentPTO: Tell the user how much PTO they currently have " 20 | "2. FuturePTOCalc: Tell the user how much PTO they will have as of the prospective future date " 21 | "3. BookPTO: Book PTO after user types 'yes'", 22 | starter_prompt=starter_prompt_generic, 23 | example_conversation_history="\n ".join( 24 | [ 25 | "user: I'd like to schedule some time off", 26 | "agent: Sure! Let's start by determining how much PTO you currently have. May I have your email address?", 27 | "user: bob.johnson@emailzzz.com", 28 | "agent: Great! I can tell you how much PTO you currently have accrued.", 29 | "user_confirmed_tool_run: ", 30 | "tool_result: { 'num_hours': 400, 'num_days': 50 }", 31 | "agent: You have 400 hours, or 50 days, of PTO available. What dates would you like to take your time off? ", 32 | "user: Dec 1 through Dec 5", 33 | "agent: Let's check if you'll have enough PTO accrued by Dec 1 of this year to accomodate that.", 34 | "user_confirmed_tool_run: " 35 | 'tool_result: {"enough_pto": True, "pto_hrs_remaining_after": 410}', 36 | "agent: You do in fact have enough PTO to accommodate that, and will have 410 hours remaining after you come back. Do you want to book the PTO? ", 37 | "user: yes ", 38 | "user_confirmed_tool_run: ", 39 | 'tool_result: { "status": "success" }', 40 | "agent: PTO successfully booked! ", 41 | ] 42 | ), 43 | ) 44 | 45 | goal_hr_check_pto = AgentGoal( 46 | id="goal_hr_check_pto", 47 | category_tag="hr", 48 | agent_name="Check PTO Amount", 49 | agent_friendly_description="Check your available PTO.", 50 | tools=[ 51 | tool_registry.current_pto_tool, 52 | ], 53 | description="The user wants to check their paid time off (PTO) after today's date. To assist with that goal, help the user gather args for these tools in order: " 54 | "1. CurrentPTO: Tell the user how much PTO they currently have ", 55 | starter_prompt=starter_prompt_generic, 56 | example_conversation_history="\n ".join( 57 | [ 58 | "user: I'd like to check my time off amounts at the current time", 59 | "agent: Sure! I can help you out with that. May I have your email address?", 60 | "user: bob.johnson@emailzzz.com", 61 | "agent: Great! I can tell you how much PTO you currently have accrued.", 62 | "user_confirmed_tool_run: ", 63 | "tool_result: { 'num_hours': 400, 'num_days': 50 }", 64 | "agent: You have 400 hours, or 50 days, of PTO available.", 65 | ] 66 | ), 67 | ) 68 | 69 | goal_hr_check_paycheck_bank_integration_status = AgentGoal( 70 | id="goal_hr_check_paycheck_bank_integration_status", 71 | category_tag="hr", 72 | agent_name="Check paycheck deposit status", 73 | agent_friendly_description="Check your integration between your employer and your financial institution.", 74 | tools=[ 75 | tool_registry.paycheck_bank_integration_status_check, 76 | ], 77 | description="The user wants to check their bank integration used to deposit their paycheck. To assist with that goal, help the user gather args for these tools in order: " 78 | "1. CheckPayBankStatus: Tell the user the status of their paycheck bank integration ", 79 | starter_prompt=starter_prompt_generic, 80 | example_conversation_history="\n ".join( 81 | [ 82 | "user: I'd like to check paycheck bank integration", 83 | "agent: Sure! I can help you out with that. May I have your email address?", 84 | "user: bob.johnson@emailzzz.com", 85 | "agent: Great! I can tell you what the status is for your paycheck bank integration.", 86 | "user_confirmed_tool_run: ", 87 | "tool_result: { 'status': connected }", 88 | "agent: Your paycheck bank deposit integration is properly connected.", 89 | ] 90 | ), 91 | ) 92 | 93 | hr_goals: List[AgentGoal] = [ 94 | goal_hr_schedule_pto, 95 | goal_hr_check_pto, 96 | goal_hr_check_paycheck_bank_integration_status, 97 | ] 98 | -------------------------------------------------------------------------------- /tests/workflowtests/agent_goal_workflow_test.py: -------------------------------------------------------------------------------- 1 | import concurrent.futures 2 | import uuid 3 | from contextlib import contextmanager 4 | from typing import Any, Dict, List, Optional, Sequence 5 | 6 | from temporalio import activity 7 | from temporalio.client import Client, WorkflowExecutionStatus 8 | from temporalio.common import RawValue 9 | from temporalio.worker import Worker 10 | 11 | from api.main import get_initial_agent_goal 12 | from models.data_types import ( 13 | AgentGoalWorkflowParams, 14 | CombinedInput, 15 | EnvLookupInput, 16 | EnvLookupOutput, 17 | ToolPromptInput, 18 | ValidationInput, 19 | ValidationResult, 20 | ) 21 | from models.tool_definitions import MCPServerDefinition 22 | from workflows.agent_goal_workflow import AgentGoalWorkflow 23 | 24 | 25 | @contextmanager 26 | def my_context(): 27 | print("Setup") 28 | yield "some_value" # Value assigned to 'as' variable 29 | print("Cleanup") 30 | 31 | 32 | async def test_flight_booking(client: Client): 33 | # load_dotenv("test_flights_single.env") 34 | 35 | with my_context() as value: 36 | print(f"Working with {value}") 37 | 38 | # Create the test environment 39 | # env = await WorkflowEnvironment.start_local() 40 | # client = env.client 41 | task_queue_name = str(uuid.uuid4()) 42 | workflow_id = str(uuid.uuid4()) 43 | 44 | # Create mock activity functions with proper signatures 45 | @activity.defn(name="get_wf_env_vars") 46 | async def mock_get_wf_env_vars(input: EnvLookupInput) -> EnvLookupOutput: 47 | return EnvLookupOutput(show_confirm=True, multi_goal_mode=True) 48 | 49 | @activity.defn(name="agent_validatePrompt") 50 | async def mock_agent_validatePrompt( 51 | validation_input: ValidationInput, 52 | ) -> ValidationResult: 53 | return ValidationResult(validationResult=True, validationFailedReason={}) 54 | 55 | @activity.defn(name="agent_toolPlanner") 56 | async def mock_agent_toolPlanner(input: ToolPromptInput) -> dict: 57 | return {"next": "done", "response": "Test response from LLM"} 58 | 59 | @activity.defn(name="mcp_list_tools") 60 | async def mock_mcp_list_tools( 61 | server_definition: MCPServerDefinition, 62 | include_tools: Optional[List[str]] = None, 63 | ) -> Dict[str, Any]: 64 | return {"success": True, "tools": {}, "server_name": "test"} 65 | 66 | @activity.defn(name="mcp_tool_activity") 67 | async def mock_mcp_tool_activity( 68 | tool_name: str, tool_args: Dict[str, Any] 69 | ) -> Dict[str, Any]: 70 | return {"success": True, "result": "Mock MCP tool result"} 71 | 72 | @activity.defn(name="dynamic_tool_activity", dynamic=True) 73 | async def mock_dynamic_tool_activity(args: Sequence[RawValue]) -> dict: 74 | return {"success": True, "result": "Mock dynamic tool result"} 75 | 76 | with concurrent.futures.ThreadPoolExecutor( 77 | max_workers=100 78 | ) as activity_executor: 79 | worker = Worker( 80 | client, 81 | task_queue=task_queue_name, 82 | workflows=[AgentGoalWorkflow], 83 | activities=[ 84 | mock_get_wf_env_vars, 85 | mock_agent_validatePrompt, 86 | mock_agent_toolPlanner, 87 | mock_mcp_list_tools, 88 | mock_mcp_tool_activity, 89 | mock_dynamic_tool_activity, 90 | ], 91 | activity_executor=activity_executor, 92 | ) 93 | 94 | async with worker: 95 | initial_agent_goal = get_initial_agent_goal() 96 | # Create combined input 97 | combined_input = CombinedInput( 98 | tool_params=AgentGoalWorkflowParams(None, None), 99 | agent_goal=initial_agent_goal, 100 | ) 101 | 102 | prompt = "Hello!" 103 | 104 | # async with Worker(client, task_queue=task_queue_name, workflows=[AgentGoalWorkflow], activities=[ToolActivities.agent_validatePrompt, ToolActivities.agent_toolPlanner, dynamic_tool_activity]): 105 | 106 | # todo set goal categories for scenarios 107 | handle = await client.start_workflow( 108 | AgentGoalWorkflow.run, 109 | combined_input, 110 | id=workflow_id, 111 | task_queue=task_queue_name, 112 | start_signal="user_prompt", 113 | start_signal_args=[prompt], 114 | ) 115 | # todo send signals to simulate user input 116 | # await handle.signal(AgentGoalWorkflow.user_prompt, "book flights") # for multi-goal 117 | await handle.signal( 118 | AgentGoalWorkflow.user_prompt, "sydney in september" 119 | ) 120 | assert ( 121 | WorkflowExecutionStatus.RUNNING == (await handle.describe()).status 122 | ) 123 | 124 | # assert ["Hello, user1", "Hello, user2"] == await handle.result() 125 | await handle.signal( 126 | AgentGoalWorkflow.user_prompt, "I'm all set, end conversation" 127 | ) 128 | 129 | # assert WorkflowExecutionStatus.COMPLETED == (await handle.describe()).status 130 | 131 | result = await handle.result() 132 | print(f"Workflow result: {result}") 133 | # todo dump workflow history for analysis optional 134 | # todo assert result is good 135 | -------------------------------------------------------------------------------- /frontend/src/components/ConfirmInline.jsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useState } from "react"; 2 | 3 | /** Inline SVG icons so we don’t need an extra library */ 4 | const PlayIcon = ({ className }) => ( 5 | 14 | ); 15 | 16 | const SpinnerIcon = ({ className }) => ( 17 | 31 | ); 32 | 33 | /** 34 | * User‑friendly confirmation card that surfaces tool invocation details 35 | * without developer jargon. Tweaks include: 36 | * • Left green accent‑border + compact heading (visual hierarchy) 37 | * • Collapsible arg list & array support (argument‑list UX) 38 | * • Mobile‑first, pulsing confirm button (button affordance) 39 | */ 40 | const ConfirmInline = memo(({ data, confirmed, onConfirm }) => { 41 | const { args = {}, tool } = data || {}; 42 | 43 | // Collapsible argument list if we have more than 4 root keys 44 | const [showAll, setShowAll] = useState(false); 45 | const argEntries = Object.entries(args); 46 | const shouldCollapse = argEntries.length > 4 && !showAll; 47 | 48 | /** Recursively pretty‑print argument values (objects & arrays). */ 49 | const RenderValue = ({ value }) => { 50 | if (value === null || value === undefined) return ; 51 | 52 | if (Array.isArray(value)) { 53 | return ( 54 |
    55 | {value.map((v, i) => ( 56 |
  1. 57 | 58 |
  2. 59 | ))} 60 |
61 | ); 62 | } 63 | 64 | if (typeof value === "object") { 65 | return ( 66 |
    67 | {Object.entries(value).map(([k, v]) => ( 68 |
  • 69 | {k}:  70 | 71 |
  • 72 | ))} 73 |
74 | ); 75 | } 76 | 77 | return {String(value)}; 78 | }; 79 | 80 | const cardBase = 81 | "mt-2 p-3 rounded-lg border-l-4 border-green-500 bg-gray-100/60 dark:bg-gray-800/60 shadow-sm"; 82 | 83 | // ===== Running state ===== 84 | if (confirmed) { 85 | return ( 86 |
87 | 88 | 89 | Running {tool ?? "Unknown"} … 90 | 91 |
92 | ); 93 | } 94 | 95 | // ===== Confirmation state ===== 96 | return ( 97 |
98 | {/* Heading */} 99 |
100 | 101 |

102 | Ready to run {tool ?? "Unknown"} 103 |

104 |
105 | 106 | {/* Dynamic argument list */} 107 | {argEntries.length > 0 && ( 108 |
109 | {argEntries 110 | .slice(0, shouldCollapse ? 4 : argEntries.length) 111 | .map(([k, v]) => ( 112 |
113 | {k}:  114 | 115 |
116 | ))} 117 | {shouldCollapse && ( 118 | 124 | )} 125 | {showAll && argEntries.length > 4 && ( 126 | 132 | )} 133 |
134 | )} 135 | 136 | {/* Confirm button */} 137 |
138 | 146 |
147 |
148 | ); 149 | }); 150 | 151 | ConfirmInline.displayName = "ConfirmInline"; 152 | 153 | export default ConfirmInline; -------------------------------------------------------------------------------- /tools/fin/move_money.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from dataclasses import dataclass 4 | from pathlib import Path 5 | 6 | from temporalio.exceptions import WorkflowAlreadyStartedError 7 | 8 | from shared.config import get_temporal_client 9 | 10 | # enums for the java enum 11 | # class ExecutionScenarios(Enum): 12 | # HAPPY_PATH = 0 13 | # ADVANCED_VISIBILITY = auto() # 1 14 | # HUMAN_IN_LOOP = auto() # 2 15 | # API_DOWNTIME = auto() # 3 16 | # BUG_IN_WORKFLOW = auto() # 4 17 | # INVALID_ACCOUNT = auto() # 5 18 | 19 | 20 | # these dataclasses are for calling the Temporal Workflow 21 | # Python equivalent of the workflow we're calling's Java WorkflowParameterObj 22 | @dataclass 23 | class MoneyMovementWorkflowParameterObj: 24 | amount: int # Using snake_case as per Python conventions 25 | scenario: str 26 | 27 | 28 | # this is made to demonstrate functionality but it could just as durably be an API call 29 | # this assumes it's a valid account - use check_account_valid() to verify that first 30 | async def move_money(args: dict) -> dict: 31 | account_key = args.get("email_address_or_account_ID") 32 | account_type: str = args.get("accounttype") 33 | amount = args.get("amount") 34 | destinationaccount = args.get("destinationaccount") 35 | 36 | file_path = ( 37 | Path(__file__).resolve().parent.parent / "data" / "customer_account_data.json" 38 | ) 39 | if not file_path.exists(): 40 | return {"error": "Data file not found."} 41 | 42 | with open(file_path, "r") as file: 43 | data = json.load(file) 44 | account_list = data["accounts"] 45 | 46 | for account in account_list: 47 | if account["email"] == account_key or account["account_id"] == account_key: 48 | amount_str: str = str(amount) 49 | from_account_combo = account_key + account_type 50 | 51 | transfer_workflow_id = await start_workflow( 52 | amount_cents=str_dollars_to_cents(amount_str), 53 | from_account_name=from_account_combo, 54 | to_account_name=destinationaccount, 55 | ) 56 | 57 | if account_type.casefold() == "checking": 58 | from_key = "checking_balance" 59 | elif account_type.casefold() == "savings": 60 | from_key = "savings_balance" 61 | else: 62 | return_msg = "Money order for account types other than checking or savings is not implemented." 63 | return {"error": return_msg} 64 | 65 | to_key = ( 66 | "savings_balance" 67 | if destinationaccount.casefold() == "savings" 68 | else "checking_balance" 69 | ) 70 | 71 | # Update from-account balance 72 | from_balance = float(str_dollars_to_cents(str(account[from_key]))) 73 | from_balance -= float(str_dollars_to_cents(amount_str)) 74 | account[from_key] = str(from_balance / 100) 75 | 76 | # Update destination-account balance 77 | to_balance = float(str_dollars_to_cents(str(account[to_key]))) 78 | to_balance += float(str_dollars_to_cents(amount_str)) 79 | account[to_key] = str(to_balance / 100) 80 | 81 | with open(file_path, "w") as file: 82 | json.dump(data, file, indent=4) 83 | 84 | return { 85 | "status": "money movement complete", 86 | "confirmation id": transfer_workflow_id, 87 | "new_balance": account[from_key], 88 | "destination_balance": account[to_key], 89 | } 90 | 91 | return_msg = "Account not found with for " + account_key 92 | return {"error": return_msg} 93 | 94 | 95 | # Async function to start workflow 96 | async def start_workflow( 97 | amount_cents: int, from_account_name: str, to_account_name: str 98 | ) -> str: 99 | start_real_workflow = os.getenv("FIN_START_REAL_WORKFLOW") 100 | if start_real_workflow is not None and start_real_workflow.lower() == "false": 101 | START_REAL_WORKFLOW = False 102 | else: 103 | START_REAL_WORKFLOW = True 104 | 105 | if START_REAL_WORKFLOW: 106 | # Connect to Temporal 107 | client = await get_temporal_client() 108 | # Create the parameter object 109 | params = MoneyMovementWorkflowParameterObj( 110 | amount=amount_cents, scenario="HAPPY_PATH" 111 | ) 112 | 113 | workflow_id = ( 114 | "TRANSFER-ACCT-" + from_account_name + "-TO-" + to_account_name 115 | ) # business-relevant workflow ID 116 | 117 | try: 118 | handle = await client.start_workflow( 119 | "moneyTransferWorkflow", # Workflow name 120 | params, # Workflow parameters 121 | id=workflow_id, 122 | task_queue="MoneyTransferJava", # Task queue name 123 | ) 124 | return handle.id 125 | except WorkflowAlreadyStartedError: 126 | existing_handle = client.get_workflow_handle(workflow_id=workflow_id) 127 | return existing_handle.id 128 | else: 129 | return ( 130 | "TRANSFER-ACCT-" + from_account_name + "-TO-" + to_account_name + "not-real" 131 | ) 132 | 133 | 134 | # cleans a string dollar amount description to cents value 135 | def str_dollars_to_cents(dollar_str: str) -> int: 136 | try: 137 | # Remove '$' and any whitespace 138 | cleaned_str = dollar_str.replace("$", "").strip() 139 | 140 | # Handle empty string or invalid input 141 | if not cleaned_str: 142 | raise ValueError("Empty amount provided") 143 | 144 | # Convert to float and then to cents 145 | amount = float(cleaned_str) 146 | if amount < 0: 147 | raise ValueError("Negative amounts not allowed") 148 | 149 | return int(amount * 100) 150 | except ValueError as e: 151 | raise ValueError(f"Invalid dollar amount format: {dollar_str}") from e 152 | -------------------------------------------------------------------------------- /goals/agent_selection.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import tools.tool_registry as tool_registry 4 | from models.tool_definitions import AgentGoal 5 | 6 | # Turn on Silly Mode - this should be a description of the persona you'd like the bot to have and can be a single word or a phrase. 7 | # Example if you want the bot to be a specific person, like Mario or Christopher Walken, or to describe a specific tone: 8 | # SILLY_MODE="Christopher Walken" 9 | # SILLY_MODE="belligerent" 10 | # 11 | # Example if you want it to take on a persona (include 'a'): 12 | # SILLY_MODE="a pirate" 13 | # Note - this only works with certain LLMs. Grok for sure will stay in character, while OpenAI will not. 14 | SILLY_MODE = "off" 15 | if SILLY_MODE is not None and SILLY_MODE != "off": 16 | silly_prompt = "You are " + SILLY_MODE + ", stay in character at all times. " 17 | print("Silly mode is on: " + SILLY_MODE) 18 | else: 19 | silly_prompt = "" 20 | 21 | starter_prompt_generic = ( 22 | silly_prompt 23 | + "Welcome me, give me a description of what you can do, then ask me for the details you need to do your job." 24 | ) 25 | 26 | goal_choose_agent_type = AgentGoal( 27 | id="goal_choose_agent_type", 28 | category_tag="agent_selection", 29 | agent_name="Choose Agent", 30 | agent_friendly_description="Choose the type of agent to assist you today. You can always interrupt an existing agent to pick a new one.", 31 | tools=[ 32 | tool_registry.list_agents_tool, 33 | tool_registry.change_goal_tool, 34 | ], 35 | description="The user wants to choose which type of agent they will interact with. " 36 | "Help the user select an agent by gathering args for the Changegoal tool, in order: " 37 | "1. ListAgents: List agents available to interact with. Do not ask for user confirmation for this tool. " 38 | "2. ChangeGoal: Change goal of agent " 39 | "After these tools are complete, change your goal to the new goal as chosen by the user. ", 40 | starter_prompt=silly_prompt 41 | + "Welcome me, give me a description of what you can do, then ask me for the details you need to do your job. List all details of all agents as provided by the output of the first tool included in this goal. ", 42 | example_conversation_history="\n ".join( 43 | [ 44 | "agent: Here are the currently available agents.", 45 | "tool_result: { agents: 'agent_name': 'Event Flight Finder', 'goal_id': 'goal_event_flight_invoice', 'agent_description': 'Helps users find interesting events and arrange travel to them'," 46 | "'agent_name': 'Schedule PTO', 'goal_id': 'goal_hr_schedule_pto', 'agent_description': 'Schedule PTO based on your available PTO.' }", 47 | "agent: The available agents are: Event Flight Finder and Schedule PTO. \n Which agent would you like to work with? ", 48 | "user: I'd like to find an event and book flights using the Event Flight Finder", 49 | "user_confirmed_tool_run: ", 50 | "tool_result: { 'new_goal': 'goal_event_flight_invoice' }", 51 | ] 52 | ), 53 | ) 54 | 55 | # Easter egg - if silly mode = a pirate, include goal_pirate_treasure as a "system" goal so it always shows up. 56 | # Can also turn make this goal available by setting the GOAL_CATEGORIES in the env file to include 'pirate', but if SILLY_MODE 57 | # is not 'a pirate', the interaction as a whole will be less pirate-y. 58 | pirate_category_tag = "pirate" 59 | if SILLY_MODE == "a pirate": 60 | pirate_category_tag = "system" 61 | goal_pirate_treasure = AgentGoal( 62 | id="goal_pirate_treasure", 63 | category_tag=pirate_category_tag, 64 | agent_name="Arrr, Find Me Treasure!", 65 | agent_friendly_description="Sail the high seas and find me pirate treasure, ye land lubber!", 66 | tools=[ 67 | tool_registry.give_hint_tool, 68 | tool_registry.guess_location_tool, 69 | ], 70 | description="The user wants to find a pirate treasure. " 71 | "Help the user gather args for these tools, in a loop, until treasure_found is True or the user requests to be done: " 72 | "1. GiveHint: If the user wants a hint regarding the location of the treasure, give them a hint. If they do not want a hint, this tool is optional." 73 | "2. GuessLocation: The user guesses where the treasure is, by giving an address. ", 74 | starter_prompt=starter_prompt_generic, 75 | example_conversation_history="\n ".join( 76 | [ 77 | "user: I'd like to try to find the treasure", 78 | "agent: Sure! Do you want a hint?", 79 | "user: yes", 80 | "agent: Here is hint number 1!", 81 | "user_confirmed_tool_run: ", 82 | "tool_result: { 'hint_number': 1, 'hint': 'The treasure is in the state of Arizona.' }", 83 | "agent: The treasure is in the state of Arizona. Would you like to guess the address of the treasure? ", 84 | "user: Yes, address is 123 Main St Phoenix, AZ", 85 | "agent: Let's see if you found the treasure...", 86 | "user_confirmed_tool_run: " 87 | "tool_result: {'treasure_found':False}", 88 | "agent: Nope, that's not the right location! Do you want another hint?", 89 | "user: yes", 90 | "agent: Here is hint number 2.", 91 | "user_confirmed_tool_run: ", 92 | "tool_result: { 'hint_number': 2, 'hint': 'The treasure is in the city of Tucson, AZ.' }", 93 | "agent: The treasure is in the city of Tucson, AZ. Would you like to guess the address of the treasure? ", 94 | "user: Yes, address is 456 Main St Tucson, AZ", 95 | "agent: Let's see if you found the treasure...", 96 | "user_confirmed_tool_run: ", 97 | "tool_result: {'treasure_found':True}", 98 | "agent: Congratulations, Land Lubber, you've found the pirate treasure!", 99 | ] 100 | ), 101 | ) 102 | 103 | agent_selection_goals: List[AgentGoal] = [ 104 | goal_choose_agent_type, 105 | goal_pirate_treasure, 106 | ] 107 | -------------------------------------------------------------------------------- /tools/ecommerce/track_package.py: -------------------------------------------------------------------------------- 1 | import http 2 | import json 3 | import os 4 | from pathlib import Path 5 | 6 | 7 | # Send back dummy data in the correct format - to use the real API, 1) change this to be track_package_fake and 2) change the below track_package_real to be track_package 8 | def track_package(args: dict) -> dict: 9 | tracking_id = args.get("tracking_id") 10 | file_path = ( 11 | Path(__file__).resolve().parent.parent / "data" / "dummy_tracking_data.json" 12 | ) 13 | if not file_path.exists(): 14 | return {"error": "Data file not found."} 15 | 16 | with open(file_path, "r") as file: 17 | data = json.load(file) 18 | package_list = data["packages"] 19 | 20 | for package in package_list: 21 | if package["TrackingNumber"] == tracking_id: 22 | scheduled_delivery_date = package["ScheduledDeliveryDate"] 23 | carrier = package["Carrier"] 24 | status_summary = package["StatusSummary"] 25 | tracking_details = package.get("TrackingDetails", []) 26 | last_tracking_update = "" 27 | if ( 28 | tracking_details 29 | and tracking_details is not None 30 | and tracking_details[0] is not None 31 | ): 32 | last_tracking_update = tracking_details[0][ 33 | "EventDateTimeInDateTimeFormat" 34 | ] 35 | 36 | tracking_link = "" 37 | if carrier == "USPS": 38 | tracking_link = f"https://tools.usps.com/go/TrackConfirmAction?qtc_tLabels1={tracking_id}" 39 | elif carrier == "UPS": 40 | tracking_link = ( 41 | f"https://www.ups.com/track?track=yes&trackNums={tracking_id}" 42 | ) 43 | 44 | return { 45 | "scheduled_delivery_date": scheduled_delivery_date, 46 | "carrier": carrier, 47 | "status_summary": status_summary, 48 | "tracking_link": tracking_link, 49 | "last_tracking_update": last_tracking_update, 50 | } 51 | 52 | return_msg = "Package not found with tracking info " + tracking_id 53 | return {"error": return_msg} 54 | 55 | 56 | """Format of response: 57 | { 58 | "TrackingNumber": "", 59 | "Delivered": false, 60 | "Carrier": "USPS", 61 | "ServiceType": "USPS Ground Advantage", 62 | "PickupDate": "", 63 | "ScheduledDeliveryDate": "April 14, 2025", 64 | "ScheduledDeliveryDateInDateTimeFromat": "2025-04-14T00:00:00", 65 | "StatusCode": "In Transit from Origin Processing", 66 | "Status": "Departed Post Office", 67 | "StatusSummary": "Your item has left our acceptance facility and is in transit to a sorting facility on April 10, 2025 at 7:06 am in IRON RIDGE, WI 53035.", 68 | "Message": "", 69 | "DeliveredDateTime": "", 70 | "DeliveredDateTimeInDateTimeFormat": null, 71 | "SignatureName": "", 72 | "DestinationCity": "CITY", 73 | "DestinationState": "ST", 74 | "DestinationZip": "12345", 75 | "DestinationCountry": null, 76 | "EventDate": "2025-04-10T07:06:00", 77 | "TrackingDetails": [ 78 | { 79 | "EventDateTime": "April 10, 2025 7:06 am", 80 | "Event": "Departed Post Office", 81 | "EventAddress": "IRON RIDGE WI 53035", 82 | "State": "WI", 83 | "City": "IRON RIDGE", 84 | "Zip": "53035", 85 | "EventDateTimeInDateTimeFormat": "2025-04-10T07:06:00" 86 | }, 87 | { 88 | "EventDateTime": "April 9, 2025 11:29 am", 89 | "Event": "USPS picked up item", 90 | "EventAddress": "IRON RIDGE WI 53035", 91 | "State": "WI", 92 | "City": "IRON RIDGE", 93 | "Zip": "53035", 94 | "EventDateTimeInDateTimeFormat": "2025-04-09T11:29:00" 95 | }, 96 | { 97 | "EventDateTime": "April 7, 2025 6:29 am", 98 | "Event": "Shipping Label Created, USPS Awaiting Item", 99 | "EventAddress": "IRON RIDGE WI 53035", 100 | "State": "WI", 101 | "City": "IRON RIDGE", 102 | "Zip": "53035", 103 | "EventDateTimeInDateTimeFormat": "2025-04-07T06:29:00" 104 | } 105 | ] 106 | } 107 | """ 108 | 109 | 110 | def track_package_real(args: dict) -> dict: 111 | tracking_id = args.get("tracking_id") 112 | 113 | api_key = os.getenv("RAPIDAPI_KEY") 114 | api_host = os.getenv("RAPIDAPI_HOST_PACKAGE", "trackingpackage.p.rapidapi.com") 115 | 116 | conn = http.client.HTTPSConnection(api_host) 117 | headers = { 118 | "x-rapidapi-key": api_key, 119 | "x-rapidapi-host": api_host, 120 | "Authorization": "Basic Ym9sZGNoYXQ6TGZYfm0zY2d1QzkuKz9SLw==", 121 | } 122 | 123 | path = f"/TrackingPackage?trackingNumber={tracking_id}" 124 | 125 | conn.request("GET", path, headers=headers) 126 | res = conn.getresponse() 127 | data = res.read() 128 | data_decoded = data.decode("utf-8") 129 | conn.close() 130 | 131 | try: 132 | json_data = json.loads(data_decoded) 133 | except json.JSONDecodeError: 134 | return {"error": "Invalid JSON response"} 135 | 136 | scheduled_delivery_date = json_data["ScheduledDeliveryDate"] 137 | carrier = json_data["Carrier"] 138 | status_summary = json_data["StatusSummary"] 139 | tracking_details = json_data.get("TrackingDetails", []) 140 | last_tracking_update = "" 141 | if ( 142 | tracking_details 143 | and tracking_details is not None 144 | and tracking_details[0] is not None 145 | ): 146 | last_tracking_update = tracking_details[0]["EventDateTimeInDateTimeFormat"] 147 | tracking_link = "" 148 | if carrier == "USPS": 149 | tracking_link = ( 150 | f"https://tools.usps.com/go/TrackConfirmAction?qtc_tLabels1={tracking_id}" 151 | ) 152 | elif carrier == "UPS": 153 | tracking_link = f"https://www.ups.com/track?track=yes&trackNums={tracking_id}" 154 | 155 | return { 156 | "scheduled_delivery_date": scheduled_delivery_date, 157 | "carrier": carrier, 158 | "status_summary": status_summary, 159 | "tracking_link": tracking_link, 160 | "last_tracking_update": last_tracking_update, 161 | } 162 | -------------------------------------------------------------------------------- /goals/ecommerce.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import tools.tool_registry as tool_registry 4 | from models.tool_definitions import AgentGoal 5 | 6 | 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." 7 | 8 | goal_ecomm_order_status = AgentGoal( 9 | id="goal_ecomm_order_status", 10 | category_tag="ecommerce", 11 | agent_name="Check Order Status", 12 | agent_friendly_description="Check the status of your order.", 13 | tools=[ 14 | tool_registry.ecomm_get_order, 15 | tool_registry.ecomm_track_package, 16 | ], 17 | description="The user wants to learn the status of a specific order. If the status is 'shipped' or 'delivered', they might want to get the package tracking information. To assist with that goal, help the user gather args for these tools in order: " 18 | "1. GetOrder: get information about an order" 19 | "2. TrackPackage: provide tracking information for the package. This tool is optional and should only be offered if the status is 'shipped' OR 'delivered' - otherwise, skip this tool and do not mention it to the user.", 20 | starter_prompt=starter_prompt_generic, 21 | example_conversation_history="\n ".join( 22 | [ 23 | "user: I'd like to know the status of my order", 24 | "agent: Sure! I can help you out with that. May I have your order number?", 25 | "user: 102 ", 26 | "user_confirmed_tool_run: ", 27 | "tool_result: { 'id': '102', 'summary': 'Red Sunglasses', 'email': 'matt.murdock@nelsonmurdock.com', 'status': 'shipped', 'order_date': '2025-04-01', 'last_order_update': '2025-04-06', 'tracking_id': '039813852990618' }", 28 | "agent: Your order 'Red Sunglasses,' placed April 1, 2025, was shipped on April 6, 2025. Would you like to see the tracking inforation?", 29 | "user: Yes", 30 | "user_confirmed_tool_run: ", 31 | "tool_result: { 'scheduled_delivery_date': 'April 30, 2025', 'carrier': 'USPS', 'status_summary': 'Your item has left our acceptance facility and is in transit to a sorting facility on April 10, 2025 at 7:06 am in IRON RIDGE, WI 53035.', 'tracking_link': 'https://tools.usps.com/go/TrackConfirmAction?qtc_tLabels1=12345','last_tracking_update': '2025-03-22T16:14:48'}", 32 | "agent: Your package is scheduled to be delivered on April 30, 2025 via USPS. Here is the most recent status from them regarding your package, updated as of March 22: \\n" 33 | "Your item has left our acceptance facility and is in transit to a sorting facility on April 10, 2025 at 7:06 am in IRON RIDGE, WI 53035. \\n" 34 | "You can find the full tracking details here: tracking_link !", 35 | ] 36 | ), 37 | ) 38 | 39 | goal_ecomm_list_orders = AgentGoal( 40 | id="goal_ecomm_list_orders", 41 | category_tag="ecommerce", 42 | agent_name="List All Orders", 43 | agent_friendly_description="List all orders for a user.", 44 | tools=[ 45 | tool_registry.ecomm_list_orders, 46 | tool_registry.ecomm_get_order, 47 | tool_registry.ecomm_track_package, 48 | ], 49 | description="The user wants to see all of their orders. They may want more details about specific orders, and if the status of an order is 'shipped' or 'delivered', they might want to get the package tracking information. To assist with that goal, help the user gather args for this tool: " 50 | "1. ListOrders: list orders for a user" 51 | " and then offer the following tools, in a loop, until the user indicates they are done:" 52 | "2. GetOrder: get information about an order. This tool is optional." 53 | "3. TrackPackage: provide tracking information for the package. This tool is optional and should only be offered if the status is 'shipped' OR 'delivered' - otherwise, skip this tool and do not mention it to the user.", 54 | starter_prompt=starter_prompt_generic, 55 | example_conversation_history="\n ".join( 56 | [ 57 | "user: I'd like to see all of my orders.", 58 | "agent: Sure! I can help you out with that. May I have your email address?", 59 | "user: email is bob.johnson@emailzzz.com ", 60 | "user_confirmed_tool_run: ", 61 | "tool_result: a list of orders including [{'id': '102', 'summary': 'Red Sunglasses', 'email': 'matt.murdock@nelsonmurdock.com', 'status': 'shipped', 'order_date': '2025-04-01', 'last_order_update': '2025-04-06', 'tracking_id': '039813852990618' }, { 'id': '103', 'summary': 'Blue Sunglasses', 'email': 'matt.murdock@nelsonmurdock.com', 'status': 'paid', 'order_date': '2025-04-03', 'last_order_update': '2025-04-07' }]", 62 | "agent: Your orders are as follows: \\n", 63 | "1. Red Sunglasses, ordered 4/1/2025 \\n", 64 | "2. Blue Sunglasses, ordered 4/3/2025 \\n", 65 | "Would you like more information about any of your orders?" 66 | "user: Yes, the Red Sunglasses", 67 | "agent: Your order 'Red Sunglasses,' placed April 1, 2025, was shipped on April 6, 2025. Would you like to see the tracking inforation?", 68 | "user: Yes", 69 | "user_confirmed_tool_run: ", 70 | "tool_result: { 'scheduled_delivery_date': 'April 30, 2025', 'carrier': 'USPS', 'status_summary': 'Your item has left our acceptance facility and is in transit to a sorting facility on April 10, 2025 at 7:06 am in IRON RIDGE, WI 53035.', 'tracking_link': 'https://tools.usps.com/go/TrackConfirmAction?qtc_tLabels1=12345','last_tracking_update': '2025-03-22T16:14:48'}", 71 | "agent: Your package is scheduled to be delivered on April 30, 2025 via USPS. Here is the most recent status from them regarding your package \\n, updated as of March 22: \\n" 72 | "Your item has left our acceptance facility and is in transit to a sorting facility on April 10, 2025 at 7:06 am in IRON RIDGE, WI 53035. \\n" 73 | "You can find the full tracking details here: tracking_link ! \\n" 74 | "Would you like more information about any of your other orders?", 75 | "user: No" "agent: Thanks, and have a great day!", 76 | ] 77 | ), 78 | ) 79 | 80 | ecommerce_goals: List[AgentGoal] = [ 81 | goal_ecomm_order_status, 82 | goal_ecomm_list_orders, 83 | ] 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Temporal AI Agent 2 | 3 | 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. The agent supports both native tools and Model Context Protocol (MCP) tools, allowing it to interact with external services. 4 | 5 | The agent operates in single-agent mode by default, focusing on one specific goal. It also supports experimental multi-agent/multi-goal mode where users can choose between different agent types and switch between them during conversations. 6 | 7 | Goals are organized in the `/goals/` directory by category (finance, HR, travel, ecommerce, etc.) and can leverage both native and MCP tools. 8 | 9 | The AI will respond with clarifications and ask for any missing information to that goal. You can configure it to use any LLM supported by [LiteLLM](https://docs.litellm.ai/docs/providers), including: 10 | - OpenAI models (GPT-4, GPT-3.5) 11 | - Anthropic Claude models 12 | - Google Gemini models 13 | - Deepseek models 14 | - Ollama models (local) 15 | - And many more! 16 | 17 | It's really helpful to [watch the demo (5 minute YouTube video)](https://www.youtube.com/watch?v=GEXllEH2XiQ) to understand how interaction works. 18 | 19 | [![Watch the demo](./assets/agent-youtube-screenshot.jpeg)](https://www.youtube.com/watch?v=GEXllEH2XiQ) 20 | 21 | ### Multi-Agent Demo Video 22 | See multi-agent execution in action [here](https://www.youtube.com/watch?v=8Dc_0dC14yY). 23 | 24 | ## Why Temporal? 25 | There are a lot of AI and Agentic AI tools out there, and more on the way. But why Temporal? Temporal gives this system reliability, state management, a code-first approach that we really like, built-in observability and easy error handling. 26 | For more, check out [architecture-decisions](docs/architecture-decisions.md). 27 | 28 | ## What is "Agentic AI"? 29 | These are the key elements of an agentic framework: 30 | 1. Goals that a system can accomplish, made up of tools that can execute individual steps 31 | 2. Agent loops - executing an LLM, executing tools, and eliciting input from an external source such as a human: repeat until goal(s) are done 32 | 3. Support for tool calls that require input and approval 33 | 4. Use of an LLM to check human input for relevance before calling the 'real' LLM 34 | 5. Use of an LLM to summarize and compact the conversation history 35 | 6. Prompt construction made of system prompts, conversation history, and tool metadata - sent to the LLM to create user questions and confirmations 36 | 7. Ideally high durability (done in this system with Temporal Workflow and Activities) 37 | 38 | For a deeper dive into this, check out the [architecture guide](docs/architecture.md). 39 | 40 | ## 🔧 MCP Tool Calling Support 41 | 42 | This agent acts as an **MCP (Model Context Protocol) client**, enabling seamless integration with external services and tools. The system supports two types of tools: 43 | - **Native Tools**: Custom tools implemented directly in the codebase (in `/tools/`) 44 | - **MCP Tools**: External tools accessed via Model Context Protocol (MCP) servers like Stripe, databases, or APIs. Configuration is covered in [the Setup guide](docs/setup.md) 45 | - Set `AGENT_GOAL=goal_food_ordering` with `SHOW_CONFIRM=False` in `.env` for an example of a goal that calls MCP Tools (Stripe). 46 | 47 | ## Setup and Configuration 48 | See [the Setup guide](docs/setup.md) for detailed instructions. The basic configuration requires just two environment variables: 49 | ```bash 50 | LLM_MODEL=openai/gpt-4o # or any other model supported by LiteLLM 51 | LLM_KEY=your-api-key-here 52 | ``` 53 | 54 | ## Customizing Interaction & Tools 55 | See [the guide to adding goals and tools](docs/adding-goals-and-tools.md). 56 | 57 | The system supports MCP (Model Context Protocol) for easy integration with external services. MCP server configurations are managed in `shared/mcp_config.py`, and goals are organized by category in the `/goals/` directory. 58 | 59 | ## Architecture 60 | See [the architecture guide](docs/architecture.md). 61 | 62 | ## Testing 63 | 64 | The project includes comprehensive tests for workflows and activities using Temporal's testing framework: 65 | 66 | ```bash 67 | # Install dependencies including test dependencies 68 | uv sync 69 | 70 | # Run all tests 71 | uv run pytest 72 | 73 | # Run with time-skipping for faster execution 74 | uv run pytest --workflow-environment=time-skipping 75 | ``` 76 | 77 | **Test Coverage:** 78 | - ✅ **Workflow Tests**: AgentGoalWorkflow signals, queries, state management 79 | - ✅ **Activity Tests**: ToolActivities, LLM integration (mocked), environment configuration 80 | - ✅ **Integration Tests**: End-to-end workflow and activity execution 81 | 82 | - **Quick Start**: [testing.md](docs/testing.md) - Simple commands to run tests 83 | - **Comprehensive Guide**: [tests/README.md](tests/README.md) - Detailed testing documentation, patterns, and best practices 84 | 85 | ## Development 86 | 87 | To contribute to this project, see [contributing.md](docs/contributing.md). 88 | 89 | Start the Temporal Server and API server, see [setup](docs/setup.md) 90 | 91 | ## Productionalization & Adding Features 92 | - 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. 93 | - A single worker can easily support many agent workflows (chats) running at the same time. Currently the workflow ID is the same each time, so it will only run one agent at a time. To run multiple agents, you can use a different workflow ID each time (e.g. by using a UUID or timestamp). 94 | - Perhaps the UI should show when the LLM response is being retried (i.e. activity retry attempt because the LLM provided bad output) 95 | - The project now includes comprehensive tests for workflows and activities! [See testing guide](docs/testing.md). 96 | 97 | See [the todo](docs/todo.md) for more details on things we want to do (or that you could contribute!). 98 | 99 | See [the guide to adding goals and tools](docs/adding-goals-and-tools.md) for more ways you can add features. 100 | 101 | ## Enablement Guide (internal resource for Temporal employees) 102 | Check out the [slides](https://docs.google.com/presentation/d/1wUFY4v17vrtv8llreKEBDPLRtZte3FixxBUn0uWy5NU/edit#slide=id.g3333e5deaa9_0_0) here and the [enablement guide](https://docs.google.com/document/d/14E0cEOibUAgHPBqConbWXgPUBY0Oxrnt6_AImdiheW4/edit?tab=t.0#heading=h.ajnq2v3xqbu1). 103 | 104 | 105 | -------------------------------------------------------------------------------- /goals/finance.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import tools.tool_registry as tool_registry 4 | from models.tool_definitions import AgentGoal 5 | 6 | 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." 7 | 8 | goal_fin_check_account_balances = AgentGoal( 9 | id="goal_fin_check_account_balances", 10 | category_tag="fin", 11 | agent_name="Account Balances", 12 | agent_friendly_description="Check your account balances in Checking, Savings, etc.", 13 | tools=[ 14 | tool_registry.financial_check_account_is_valid, 15 | tool_registry.financial_get_account_balances, 16 | ], 17 | description="The user wants to check their account balances at the bank or financial institution. To assist with that goal, help the user gather args for these tools in order: " 18 | "1. FinCheckAccountIsValid: validate the user's account is valid" 19 | "2. FinCheckAccountBalance: Tell the user their account balance at the bank or financial institution", 20 | starter_prompt=starter_prompt_generic, 21 | example_conversation_history="\n ".join( 22 | [ 23 | "user: I'd like to check my account balances", 24 | "agent: Sure! I can help you out with that. May I have your email address and account number?", 25 | "user: email is bob.johnson@emailzzz.com ", 26 | "user_confirmed_tool_run: ", 27 | "tool_result: { 'status': account valid }", 28 | "agent: Great! I can tell you what the your account balances are.", 29 | "user_confirmed_tool_run: ", 30 | "tool_result: { 'name': Matt Murdock, 'email': matt.murdock@nelsonmurdock.com, 'account_id': 11235, 'checking_balance': 875.40, 'savings_balance': 3200.15, 'bitcoin_balance': 0.1378, 'account_creation_date': 2014-03-10 }", 31 | "agent: Your account balances are as follows: \\n " 32 | "Checking: $875.40. \\n " 33 | "Savings: $3200.15. \\n " 34 | "Bitcoin: 0.1378 \\n " 35 | "Thanks for being a customer since 2014!", 36 | ] 37 | ), 38 | ) 39 | 40 | goal_fin_move_money = AgentGoal( 41 | id="goal_fin_move_money", 42 | category_tag="fin", 43 | agent_name="Money Movement", 44 | agent_friendly_description="Initiate money movement.", 45 | tools=[ 46 | tool_registry.financial_check_account_is_valid, 47 | tool_registry.financial_get_account_balances, 48 | tool_registry.financial_move_money, 49 | ], 50 | description="The user wants to transfer money in their account at the bank or financial institution. To assist with that goal, help the user gather args for these tools in order: " 51 | "1. FinCheckAccountIsValid: validate the user's account is valid" 52 | "2. FinCheckAccountBalance: Tell the user their account balance at the bank or financial institution" 53 | "3. FinMoveMoney: Initiate money movement (transfer)", 54 | starter_prompt=starter_prompt_generic, 55 | example_conversation_history="\n ".join( 56 | [ 57 | "user: I'd like to transfer some money", 58 | "agent: Sure! I can help you out with that. May I have account number and email address?", 59 | "user: my account number is 11235 and my email address is matt.murdock@nelsonmurdock.com", 60 | "user_confirmed_tool_run: ", 61 | "tool_result: { 'status': account valid }", 62 | "agent: Great! Here are your account balances:", 63 | "user_confirmed_tool_run: ", 64 | "tool_result: { 'name': Matt Murdock, 'email': matt.murdock@nelsonmurdock.com, 'account_id': 11235, 'checking_balance': 875.40, 'savings_balance': 3200.15, 'bitcoin_balance': 0.1378, 'account_creation_date': 2014-03-10 }", 65 | "agent: Your account balances are as follows: \\n " 66 | "Checking: $875.40. \\n " 67 | "Savings: $3200.15. \\n " 68 | "Bitcoint: 0.1378 \\n " 69 | "agent: how much would you like to move, from which account type, and to which account number?", 70 | "user: I'd like to move $500 from savings to account number #56789", 71 | "user_confirmed_tool_run: ", 72 | "tool_result: { 'status': money movement complete, 'confirmation id': 333421, 'new_balance': $2700.15 }", 73 | "agent: Money movement completed! New account balance: $2700.15. Your confirmation id is 333421. ", 74 | ] 75 | ), 76 | ) 77 | 78 | goal_fin_loan_application = AgentGoal( 79 | id="goal_fin_loan_application", 80 | category_tag="fin", 81 | agent_name="Easy Loan", 82 | agent_friendly_description="Initiate a simple loan application.", 83 | tools=[ 84 | tool_registry.financial_check_account_is_valid, 85 | tool_registry.financial_submit_loan_approval, 86 | ], 87 | description="The user wants to apply for a loan at the financial institution. To assist with that goal, help the user gather args for these tools in order: " 88 | "1. FinCheckAccountIsValid: validate the user's account is valid" 89 | "2. FinCheckAccountSubmitLoanApproval: submit the loan for approval", 90 | starter_prompt=starter_prompt_generic, 91 | example_conversation_history="\n ".join( 92 | [ 93 | "user: I'd like to apply for a loan", 94 | "agent: Sure! I can help you out with that. May I have account number and email address to validate your account?", 95 | "user: account number is 11235813", 96 | "user_confirmed_tool_run: ", 97 | "tool_result: { 'status': account valid }", 98 | "agent: Great! We've validated your account. What will the loan amount be?", 99 | "user: I'd like a loan for $500", 100 | "user_confirmed_tool_run: ", 101 | "tool_result: { 'status': submitted, 'detailed_status': loan application is submitted and initial validation is complete, 'confirmation id': 333421, 'next_step': You'll receive a confirmation for final approval in three business days }", 102 | "agent: I have submitted your loan application process and the initial validation is successful. Your application ID is 333421. You'll receive a notification for final approval from us in three business days. ", 103 | ] 104 | ), 105 | ) 106 | 107 | finance_goals: List[AgentGoal] = [ 108 | goal_fin_check_account_balances, 109 | goal_fin_move_money, 110 | goal_fin_loan_application, 111 | ] 112 | -------------------------------------------------------------------------------- /shared/mcp_client_manager.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from contextlib import asynccontextmanager 3 | from typing import TYPE_CHECKING, Any, Dict, Tuple 4 | 5 | from temporalio import activity 6 | 7 | from models.tool_definitions import MCPServerDefinition 8 | 9 | # Import MCP client libraries 10 | if TYPE_CHECKING: 11 | from mcp import ClientSession, StdioServerParameters 12 | from mcp.client.stdio import stdio_client 13 | else: 14 | try: 15 | from mcp import ClientSession, StdioServerParameters 16 | from mcp.client.stdio import stdio_client 17 | except ImportError: 18 | # Fallback if MCP not installed 19 | ClientSession = None 20 | StdioServerParameters = None 21 | stdio_client = None 22 | 23 | 24 | class MCPClientManager: 25 | """Manages pooled MCP client connections for reuse across tool calls""" 26 | 27 | def __init__(self): 28 | self._clients: Dict[str, Any] = {} 29 | self._connections: Dict[str, Tuple[Any, Any]] = {} 30 | self._lock = asyncio.Lock() 31 | 32 | async def get_client( 33 | self, server_def: MCPServerDefinition | Dict[str, Any] | None 34 | ) -> Any: 35 | """Return existing client or create new one, keyed by server definition hash""" 36 | async with self._lock: 37 | key = self._get_server_key(server_def) 38 | if key not in self._clients: 39 | await self._create_client(server_def, key) 40 | activity.logger.info( 41 | f"Created new MCP client for {self._get_server_name(server_def)}" 42 | ) 43 | else: 44 | activity.logger.info( 45 | f"Reusing existing MCP client for {self._get_server_name(server_def)}" 46 | ) 47 | return self._clients[key] 48 | 49 | def _get_server_key( 50 | self, server_def: MCPServerDefinition | Dict[str, Any] | None 51 | ) -> str: 52 | """Generate unique key for server definition""" 53 | if server_def is None: 54 | return "default:python:server.py" 55 | 56 | # Handle both MCPServerDefinition objects and dicts (from Temporal serialization) 57 | if isinstance(server_def, dict): 58 | name = server_def.get("name", "default") 59 | command = server_def.get("command", "python") 60 | args = server_def.get("args", ["server.py"]) 61 | else: 62 | name = server_def.name 63 | command = server_def.command 64 | args = server_def.args 65 | 66 | return f"{name}:{command}:{':'.join(args)}" 67 | 68 | def _get_server_name( 69 | self, server_def: MCPServerDefinition | Dict[str, Any] | None 70 | ) -> str: 71 | """Get server name for logging""" 72 | if server_def is None: 73 | return "default" 74 | 75 | if isinstance(server_def, dict): 76 | return server_def.get("name", "default") 77 | else: 78 | return server_def.name 79 | 80 | def _build_connection( 81 | self, server_def: MCPServerDefinition | Dict[str, Any] | None 82 | ) -> Dict[str, Any]: 83 | """Build connection parameters from MCPServerDefinition or dict""" 84 | if server_def is None: 85 | # Default to stdio connection with the main server 86 | return { 87 | "type": "stdio", 88 | "command": "python", 89 | "args": ["server.py"], 90 | "env": {}, 91 | } 92 | 93 | # Handle both MCPServerDefinition objects and dicts (from Temporal serialization) 94 | if isinstance(server_def, dict): 95 | return { 96 | "type": server_def.get("connection_type", "stdio"), 97 | "command": server_def.get("command", "python"), 98 | "args": server_def.get("args", ["server.py"]), 99 | "env": server_def.get("env", {}) or {}, 100 | } 101 | 102 | return { 103 | "type": server_def.connection_type, 104 | "command": server_def.command, 105 | "args": server_def.args, 106 | "env": server_def.env or {}, 107 | } 108 | 109 | @asynccontextmanager 110 | async def _stdio_connection(self, command: str, args: list, env: dict): 111 | """Create stdio connection to MCP server""" 112 | if stdio_client is None: 113 | raise Exception("MCP client libraries not available") 114 | 115 | # Create server parameters 116 | server_params = StdioServerParameters(command=command, args=args, env=env) 117 | 118 | async with stdio_client(server_params) as (read, write): 119 | yield read, write 120 | 121 | async def _create_client( 122 | self, server_def: MCPServerDefinition | Dict[str, Any] | None, key: str 123 | ): 124 | """Create and store new client connection""" 125 | connection = self._build_connection(server_def) 126 | 127 | if connection["type"] == "stdio": 128 | # Create stdio connection 129 | connection_manager = self._stdio_connection( 130 | command=connection.get("command", "python"), 131 | args=connection.get("args", ["server.py"]), 132 | env=connection.get("env", {}), 133 | ) 134 | 135 | # Enter the connection context 136 | read, write = await connection_manager.__aenter__() 137 | 138 | # Create and initialize client session 139 | session = ClientSession(read, write) 140 | await session.initialize() 141 | 142 | # Store both the session and connection manager for cleanup 143 | self._clients[key] = session 144 | self._connections[key] = (connection_manager, read, write) 145 | else: 146 | raise Exception(f"Unsupported connection type: {connection['type']}") 147 | 148 | async def cleanup(self): 149 | """Close all connections gracefully""" 150 | async with self._lock: 151 | # Close all client sessions 152 | for session in self._clients.values(): 153 | try: 154 | await session.close() 155 | except Exception as e: 156 | activity.logger.warning(f"Error closing MCP session: {e}") 157 | 158 | # Exit all connection contexts 159 | for connection_manager, read, write in self._connections.values(): 160 | try: 161 | await connection_manager.__aexit__(None, None, None) 162 | except Exception as e: 163 | activity.logger.warning(f"Error closing MCP connection: {e}") 164 | 165 | self._clients.clear() 166 | self._connections.clear() 167 | activity.logger.info("All MCP connections closed") 168 | -------------------------------------------------------------------------------- /tools/food/setup/stripe_pizza_products.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "prod_SSWirxxS5A8gcT", 4 | "object": "product", 5 | "active": true, 6 | "attributes": [], 7 | "created": 1749360061, 8 | "default_price": "price_1RXbfGKVZbzw7QA57Mj1akGI", 9 | "description": "A large size bottle of cola.", 10 | "images": [ 11 | "https://files.stripe.com/links/MDB8YWNjdF8xTkJPTHVLVlpienc3UUE1fGZsX3Rlc3RfbDJxckJKMDRnT1dDc253OHlZNWNkZkY5006Xg07kHT" 12 | ], 13 | "livemode": false, 14 | "marketing_features": [], 15 | "metadata": { 16 | "use_case": "food_ordering_demo" 17 | }, 18 | "name": "Soda", 19 | "price_info": { 20 | "amount": 349, 21 | "currency": "usd" 22 | }, 23 | "package_dimensions": null, 24 | "shippable": null, 25 | "statement_descriptor": null, 26 | "tax_code": null, 27 | "type": "service", 28 | "unit_label": null, 29 | "updated": 1749360062, 30 | "url": null 31 | }, 32 | { 33 | "id": "prod_SSWhxv3tUy1YOG", 34 | "object": "product", 35 | "active": true, 36 | "attributes": [], 37 | "created": 1749359978, 38 | "default_price": "price_1RXbdvKVZbzw7QA5ARomQvaf", 39 | "description": "Our warm, crusty bread is generously spread with a savory garlic butter and toasted to golden perfection. It's the ideal aromatic and flavorful side to accompany your main course.", 40 | "images": [ 41 | "https://files.stripe.com/links/MDB8YWNjdF8xTkJPTHVLVlpienc3UUE1fGZsX3Rlc3RfWTdIZTBkUjNZNFQ1ZEhSVG9nRnduY1pS00XVgLRRZD" 42 | ], 43 | "livemode": false, 44 | "marketing_features": [], 45 | "metadata": { 46 | "use_case": "food_ordering_demo" 47 | }, 48 | "name": "Garlic Bread", 49 | "price_info": { 50 | "amount": 799, 51 | "currency": "usd" 52 | }, 53 | "package_dimensions": null, 54 | "shippable": null, 55 | "statement_descriptor": null, 56 | "tax_code": null, 57 | "type": "service", 58 | "unit_label": null, 59 | "updated": 1749360084, 60 | "url": null 61 | }, 62 | { 63 | "id": "prod_SSWgXa5bwUFCJs", 64 | "object": "product", 65 | "active": true, 66 | "attributes": [], 67 | "created": 1749359922, 68 | "default_price": "price_1RXbd0KVZbzw7QA5Nq36vdLW", 69 | "description": "A tribute to Italian simplicity, this pizza is topped with fresh mozzarella, a vibrant tomato sauce, and fragrant basil leaves. Each bite delivers a clean and authentic taste of Italy's most famous flavors.", 70 | "images": [ 71 | "https://files.stripe.com/links/MDB8YWNjdF8xTkJPTHVLVlpienc3UUE1fGZsX3Rlc3RfamdmTXBFbzY0TW9rS2N0c2g0Tml2SERL00Evl60Ttq" 72 | ], 73 | "livemode": false, 74 | "marketing_features": [], 75 | "metadata": { 76 | "use_case": "food_ordering_demo" 77 | }, 78 | "name": "Margherita Pizza", 79 | "price_info": { 80 | "amount": 1699, 81 | "currency": "usd" 82 | }, 83 | "package_dimensions": null, 84 | "shippable": null, 85 | "statement_descriptor": null, 86 | "tax_code": null, 87 | "type": "service", 88 | "unit_label": null, 89 | "updated": 1749359998, 90 | "url": null 91 | }, 92 | { 93 | "id": "prod_SSWf738UqIJzzi", 94 | "object": "product", 95 | "active": true, 96 | "attributes": [], 97 | "created": 1749359845, 98 | "default_price": "price_1RXbbmKVZbzw7QA53EkjV2nB", 99 | "description": "A timeless classic featuring a generous layer of savory pepperoni over rich tomato sauce and melted mozzarella cheese. It's the perfect choice for those who love a bold, meaty flavor on a perfectly baked crust.", 100 | "images": [ 101 | "https://files.stripe.com/links/MDB8YWNjdF8xTkJPTHVLVlpienc3UUE1fGZsX3Rlc3RfcGRHc0c4cEZYWmR2bm0zOHBOa0FWMk5t008QmCJoWr" 102 | ], 103 | "livemode": false, 104 | "marketing_features": [], 105 | "metadata": { 106 | "use_case": "food_ordering_demo" 107 | }, 108 | "name": "Pepperoni Pizza", 109 | "price_info": { 110 | "amount": 2299, 111 | "currency": "usd" 112 | }, 113 | "package_dimensions": null, 114 | "shippable": null, 115 | "statement_descriptor": null, 116 | "tax_code": null, 117 | "type": "service", 118 | "unit_label": null, 119 | "updated": 1749359846, 120 | "url": null 121 | }, 122 | { 123 | "id": "prod_SGMXBnatLlkJ4d", 124 | "object": "product", 125 | "active": true, 126 | "attributes": [], 127 | "created": 1746554502, 128 | "default_price": "price_1RLpoJKVZbzw7QA5ra76Fk6g", 129 | "description": null, 130 | "images": [], 131 | "livemode": false, 132 | "marketing_features": [], 133 | "metadata": {}, 134 | "name": "ACME Scooter Token", 135 | "package_dimensions": null, 136 | "shippable": null, 137 | "statement_descriptor": null, 138 | "tax_code": null, 139 | "type": "service", 140 | "unit_label": null, 141 | "updated": 1746554503, 142 | "url": null 143 | }, 144 | { 145 | "id": "prod_NxJPcqTWzXk45K", 146 | "object": "product", 147 | "active": true, 148 | "attributes": [], 149 | "created": 1684961969, 150 | "default_price": null, 151 | "description": "$12/Month subscription", 152 | "images": [], 153 | "livemode": false, 154 | "marketing_features": [], 155 | "metadata": {}, 156 | "name": "Starter Subscription", 157 | "package_dimensions": null, 158 | "shippable": null, 159 | "statement_descriptor": null, 160 | "tax_code": null, 161 | "type": "service", 162 | "unit_label": null, 163 | "updated": 1684961969, 164 | "url": null 165 | }, 166 | { 167 | "id": "prod_NxJ4KvyENd0uUu", 168 | "object": "product", 169 | "active": true, 170 | "attributes": [], 171 | "created": 1684960731, 172 | "default_price": null, 173 | "description": "Created with the Stripe CLI", 174 | "images": [], 175 | "livemode": false, 176 | "marketing_features": [], 177 | "metadata": {}, 178 | "name": "Temporal Money Transfer", 179 | "package_dimensions": null, 180 | "shippable": null, 181 | "statement_descriptor": null, 182 | "tax_code": null, 183 | "type": "service", 184 | "unit_label": null, 185 | "updated": 1684960731, 186 | "url": null 187 | } 188 | ] -------------------------------------------------------------------------------- /tools/data/dummy_tracking_data.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | { 4 | "TrackingNumber": "USPS12345", 5 | "Delivered": false, 6 | "Carrier": "USPS", 7 | "ServiceType": "USPS Ground Advantage", 8 | "PickupDate": "", 9 | "ScheduledDeliveryDate": "April 14, 2025", 10 | "ScheduledDeliveryDateInDateTimeFromat": "2025-04-14T00:00:00", 11 | "StatusCode": "In Transit from Origin Processing", 12 | "Status": "Departed Post Office", 13 | "StatusSummary": "Your item has left our acceptance facility and is in transit to a sorting facility on April 10, 2025 at 7:06 am in IRON RIDGE, WI 53035.", 14 | "Message": "", 15 | "DeliveredDateTime": "", 16 | "DeliveredDateTimeInDateTimeFormat": null, 17 | "SignatureName": "", 18 | "DestinationCity": "CITY", 19 | "DestinationState": "ST", 20 | "DestinationZip": "12345", 21 | "DestinationCountry": null, 22 | "EventDate": "2025-04-10T07:06:00", 23 | "TrackingDetails": [ 24 | { 25 | "EventDateTime": "April 10, 2025 7:06 am", 26 | "Event": "Departed Post Office", 27 | "EventAddress": "IRON RIDGE WI 53035", 28 | "State": "WI", 29 | "City": "IRON RIDGE", 30 | "Zip": "53035", 31 | "EventDateTimeInDateTimeFormat": "2025-04-10T07:06:00" 32 | }, 33 | { 34 | "EventDateTime": "April 9, 2025 11:29 am", 35 | "Event": "USPS picked up item", 36 | "EventAddress": "IRON RIDGE WI 53035", 37 | "State": "WI", 38 | "City": "IRON RIDGE", 39 | "Zip": "53035", 40 | "EventDateTimeInDateTimeFormat": "2025-04-09T11:29:00" 41 | }, 42 | { 43 | "EventDateTime": "April 7, 2025 6:29 am", 44 | "Event": "Shipping Label Created, USPS Awaiting Item", 45 | "EventAddress": "IRON RIDGE WI 53035", 46 | "State": "WI", 47 | "City": "IRON RIDGE", 48 | "Zip": "53035", 49 | "EventDateTimeInDateTimeFormat": "2025-04-07T06:29:00" 50 | } 51 | ] 52 | }, 53 | { 54 | "TrackingNumber": "UPS67890", 55 | "Delivered": true, 56 | "Carrier": "UPS", 57 | "ServiceType": "UPS Ground Saver®", 58 | "PickupDate": "", 59 | "ScheduledDeliveryDate": "", 60 | "ScheduledDeliveryDateInDateTimeFromat": null, 61 | "StatusCode": "D", 62 | "Status": "DELIVERED", 63 | "StatusSummary": "DELIVERED", 64 | "Message": "", 65 | "DeliveredDateTime": "20250415 154315", 66 | "DeliveredDateTimeInDateTimeFormat": "2025-04-15T15:43:15", 67 | "SignatureName": "", 68 | "DestinationCity": "CHICAGO", 69 | "DestinationState": "IL", 70 | "DestinationZip": "", 71 | "DestinationCountry": "US", 72 | "EventDate": "2025-04-15T15:43:15", 73 | "TrackingDetails": [ 74 | { 75 | "EventDateTime": "20250415 154315", 76 | "Event": "DELIVERED ", 77 | "EventAddress": "CHICAGO IL US", 78 | "State": "IL", 79 | "City": "CHICAGO", 80 | "Zip": null, 81 | "EventDateTimeInDateTimeFormat": "2025-04-15T15:43:15" 82 | }, 83 | { 84 | "EventDateTime": "20250415 090938", 85 | "Event": "Out For Delivery Today", 86 | "EventAddress": "Chicago IL US", 87 | "State": "IL", 88 | "City": "Chicago", 89 | "Zip": null, 90 | "EventDateTimeInDateTimeFormat": "2025-04-15T09:09:38" 91 | }, 92 | { 93 | "EventDateTime": "20250415 074141", 94 | "Event": "Loaded on Delivery Vehicle ", 95 | "EventAddress": "Chicago IL US", 96 | "State": "IL", 97 | "City": "Chicago", 98 | "Zip": null, 99 | "EventDateTimeInDateTimeFormat": "2025-04-15T07:41:41" 100 | }, 101 | { 102 | "EventDateTime": "20250415 032200", 103 | "Event": "Arrived at Facility", 104 | "EventAddress": "Chicago IL US", 105 | "State": "IL", 106 | "City": "Chicago", 107 | "Zip": null, 108 | "EventDateTimeInDateTimeFormat": "2025-04-15T03:22:00" 109 | }, 110 | { 111 | "EventDateTime": "20250414 223000", 112 | "Event": "Departed from Facility", 113 | "EventAddress": "Hodgkins IL US", 114 | "State": "IL", 115 | "City": "Hodgkins", 116 | "Zip": null, 117 | "EventDateTimeInDateTimeFormat": "2025-04-14T22:30:00" 118 | }, 119 | { 120 | "EventDateTime": "20250414 002700", 121 | "Event": "Arrived at Facility", 122 | "EventAddress": "Hodgkins IL US", 123 | "State": "IL", 124 | "City": "Hodgkins", 125 | "Zip": null, 126 | "EventDateTimeInDateTimeFormat": "2025-04-14T00:27:00" 127 | }, 128 | { 129 | "EventDateTime": "20250410 211700", 130 | "Event": "Departed from Facility", 131 | "EventAddress": "Las Vegas NV US", 132 | "State": "NV", 133 | "City": "Las Vegas", 134 | "Zip": null, 135 | "EventDateTimeInDateTimeFormat": "2025-04-10T21:17:00" 136 | }, 137 | { 138 | "EventDateTime": "20250410 132625", 139 | "Event": "Arrived at Facility", 140 | "EventAddress": "Las Vegas NV US", 141 | "State": "NV", 142 | "City": "Las Vegas", 143 | "Zip": null, 144 | "EventDateTimeInDateTimeFormat": "2025-04-10T13:26:25" 145 | }, 146 | { 147 | "EventDateTime": "20250409 100659", 148 | "Event": "Shipper created a label, UPS has not received the package yet. ", 149 | "EventAddress": " US", 150 | "State": null, 151 | "City": null, 152 | "Zip": null, 153 | "EventDateTimeInDateTimeFormat": "2025-04-09T10:06:59" 154 | } 155 | ] 156 | } 157 | ] 158 | } -------------------------------------------------------------------------------- /api/main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | from typing import Optional 4 | 5 | from dotenv import load_dotenv 6 | from fastapi import FastAPI, HTTPException 7 | from fastapi.middleware.cors import CORSMiddleware 8 | from temporalio.api.enums.v1 import WorkflowExecutionStatus 9 | from temporalio.client import Client 10 | from temporalio.exceptions import TemporalError 11 | 12 | from goals import goal_list 13 | from models.data_types import AgentGoalWorkflowParams, CombinedInput 14 | from shared.config import TEMPORAL_TASK_QUEUE, get_temporal_client 15 | from workflows.agent_goal_workflow import AgentGoalWorkflow 16 | 17 | app = FastAPI() 18 | temporal_client: Optional[Client] = None 19 | 20 | # Load environment variables 21 | load_dotenv() 22 | 23 | 24 | def get_initial_agent_goal(): 25 | """Get the agent goal from environment variables.""" 26 | env_goal = os.getenv( 27 | "AGENT_GOAL", "goal_event_flight_invoice" 28 | ) # if no goal is set in the env file, default to single agent mode 29 | for listed_goal in goal_list: 30 | if listed_goal.id == env_goal: 31 | return listed_goal 32 | 33 | 34 | @app.on_event("startup") 35 | async def startup_event(): 36 | global temporal_client 37 | temporal_client = await get_temporal_client() 38 | 39 | 40 | app.add_middleware( 41 | CORSMiddleware, 42 | allow_origins=["http://localhost:5173"], 43 | allow_credentials=True, 44 | allow_methods=["*"], 45 | allow_headers=["*"], 46 | ) 47 | 48 | 49 | @app.get("/") 50 | def root(): 51 | return {"message": "Temporal AI Agent!"} 52 | 53 | 54 | @app.get("/tool-data") 55 | async def get_tool_data(): 56 | """Calls the workflow's 'get_tool_data' query.""" 57 | try: 58 | # Get workflow handle 59 | handle = temporal_client.get_workflow_handle("agent-workflow") 60 | 61 | # Check if the workflow is completed 62 | workflow_status = await handle.describe() 63 | if workflow_status.status == 2: 64 | # Workflow is completed; return an empty response 65 | return {} 66 | 67 | # Query the workflow 68 | tool_data = await handle.query("get_latest_tool_data") 69 | return tool_data 70 | except TemporalError as e: 71 | # Workflow not found; return an empty response 72 | print(e) 73 | return {} 74 | 75 | 76 | @app.get("/get-conversation-history") 77 | async def get_conversation_history(): 78 | """Calls the workflow's 'get_conversation_history' query.""" 79 | try: 80 | handle = temporal_client.get_workflow_handle("agent-workflow") 81 | 82 | failed_states = [ 83 | WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_TERMINATED, 84 | WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_CANCELED, 85 | WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_FAILED, 86 | ] 87 | 88 | description = await handle.describe() 89 | if description.status in failed_states: 90 | print("Workflow is in a failed state. Returning empty history.") 91 | return [] 92 | 93 | # Set a timeout for the query 94 | try: 95 | conversation_history = await asyncio.wait_for( 96 | handle.query("get_conversation_history"), 97 | timeout=5, # Timeout after 5 seconds 98 | ) 99 | return conversation_history 100 | except asyncio.TimeoutError: 101 | raise HTTPException( 102 | status_code=404, 103 | detail="Temporal query timed out (worker may be unavailable).", 104 | ) 105 | 106 | except TemporalError as e: 107 | error_message = str(e) 108 | print(f"Temporal error: {error_message}") 109 | 110 | # If worker is down or no poller is available, return a 404 111 | if "no poller seen for task queue recently" in error_message: 112 | raise HTTPException( 113 | status_code=404, detail="Workflow worker unavailable or not found." 114 | ) 115 | 116 | if "workflow not found" in error_message: 117 | await start_workflow() 118 | return [] 119 | else: 120 | # For other Temporal errors, return a 500 121 | raise HTTPException( 122 | status_code=500, detail="Internal server error while querying workflow." 123 | ) 124 | 125 | 126 | @app.get("/agent-goal") 127 | async def get_agent_goal(): 128 | """Calls the workflow's 'get_agent_goal' query.""" 129 | try: 130 | # Get workflow handle 131 | handle = temporal_client.get_workflow_handle("agent-workflow") 132 | 133 | # Check if the workflow is completed 134 | workflow_status = await handle.describe() 135 | if workflow_status.status == 2: 136 | # Workflow is completed; return an empty response 137 | return {} 138 | 139 | # Query the workflow 140 | agent_goal = await handle.query("get_agent_goal") 141 | return agent_goal 142 | except TemporalError as e: 143 | # Workflow not found; return an empty response 144 | print(e) 145 | return {} 146 | 147 | 148 | @app.post("/send-prompt") 149 | async def send_prompt(prompt: str): 150 | # Create combined input with goal from environment 151 | combined_input = CombinedInput( 152 | tool_params=AgentGoalWorkflowParams(None, None), 153 | agent_goal=get_initial_agent_goal(), 154 | # change to get from workflow query 155 | ) 156 | 157 | workflow_id = "agent-workflow" 158 | 159 | # Start (or signal) the workflow 160 | await temporal_client.start_workflow( 161 | AgentGoalWorkflow.run, 162 | combined_input, 163 | id=workflow_id, 164 | task_queue=TEMPORAL_TASK_QUEUE, 165 | start_signal="user_prompt", 166 | start_signal_args=[prompt], 167 | ) 168 | 169 | return {"message": f"Prompt '{prompt}' sent to workflow {workflow_id}."} 170 | 171 | 172 | @app.post("/confirm") 173 | async def send_confirm(): 174 | """Sends a 'confirm' signal to the workflow.""" 175 | workflow_id = "agent-workflow" 176 | handle = temporal_client.get_workflow_handle(workflow_id) 177 | await handle.signal("confirm") 178 | return {"message": "Confirm signal sent."} 179 | 180 | 181 | @app.post("/end-chat") 182 | async def end_chat(): 183 | """Sends a 'end_chat' signal to the workflow.""" 184 | workflow_id = "agent-workflow" 185 | 186 | try: 187 | handle = temporal_client.get_workflow_handle(workflow_id) 188 | await handle.signal("end_chat") 189 | return {"message": "End chat signal sent."} 190 | except TemporalError as e: 191 | print(e) 192 | # Workflow not found; return an empty response 193 | return {} 194 | 195 | 196 | @app.post("/start-workflow") 197 | async def start_workflow(): 198 | initial_agent_goal = get_initial_agent_goal() 199 | 200 | # Create combined input 201 | combined_input = CombinedInput( 202 | tool_params=AgentGoalWorkflowParams(None, None), 203 | agent_goal=initial_agent_goal, 204 | ) 205 | 206 | workflow_id = "agent-workflow" 207 | 208 | # Start the workflow with the starter prompt from the goal 209 | await temporal_client.start_workflow( 210 | AgentGoalWorkflow.run, 211 | combined_input, 212 | id=workflow_id, 213 | task_queue=TEMPORAL_TASK_QUEUE, 214 | start_signal="user_prompt", 215 | start_signal_args=["### " + initial_agent_goal.starter_prompt], 216 | ) 217 | 218 | return { 219 | "message": f"Workflow started with goal's starter prompt: {initial_agent_goal.starter_prompt}." 220 | } 221 | --------------------------------------------------------------------------------