From da789550a85417943557fb9d54aba1d98bffe9bf Mon Sep 17 00:00:00 2001 From: ArkForge Date: Sat, 11 Apr 2026 08:00:44 +0000 Subject: [PATCH 1/2] feat: add OpenAI Agents SDK cookbook example (#92) Add cookbook example showing how to use Moss semantic search with the OpenAI Agents SDK. Includes function tools for search (with optional metadata filters), document management, and index listing. Demo: multi-agent travel planner where specialist agents search separate Moss indexes and a planner orchestrates via agent.as_tool(). Co-Authored-By: Claude Opus 4.6 --- examples/cookbook/openai-agents/.env.example | 3 + examples/cookbook/openai-agents/README.md | 178 ++++++++++++++ .../openai-agents/data/activities_moss.json | 194 +++++++++++++++ .../openai-agents/data/destinations_moss.json | 82 +++++++ .../openai-agents/data/stays_moss.json | 90 +++++++ .../cookbook/openai-agents/example_usage.py | 130 ++++++++++ .../openai-agents/moss_openai_agents.py | 222 ++++++++++++++++++ .../cookbook/openai-agents/pyproject.toml | 19 ++ examples/cookbook/openai-agents/test_live.py | 136 +++++++++++ 9 files changed, 1054 insertions(+) create mode 100644 examples/cookbook/openai-agents/.env.example create mode 100644 examples/cookbook/openai-agents/README.md create mode 100644 examples/cookbook/openai-agents/data/activities_moss.json create mode 100644 examples/cookbook/openai-agents/data/destinations_moss.json create mode 100644 examples/cookbook/openai-agents/data/stays_moss.json create mode 100644 examples/cookbook/openai-agents/example_usage.py create mode 100644 examples/cookbook/openai-agents/moss_openai_agents.py create mode 100644 examples/cookbook/openai-agents/pyproject.toml create mode 100644 examples/cookbook/openai-agents/test_live.py diff --git a/examples/cookbook/openai-agents/.env.example b/examples/cookbook/openai-agents/.env.example new file mode 100644 index 00000000..56695b22 --- /dev/null +++ b/examples/cookbook/openai-agents/.env.example @@ -0,0 +1,3 @@ +MOSS_PROJECT_ID=your-project-id +MOSS_PROJECT_KEY=your-project-key +OPENAI_API_KEY=your-openai-key diff --git a/examples/cookbook/openai-agents/README.md b/examples/cookbook/openai-agents/README.md new file mode 100644 index 00000000..e40c3d31 --- /dev/null +++ b/examples/cookbook/openai-agents/README.md @@ -0,0 +1,178 @@ +# OpenAI Agents SDK + Moss Cookbook Example + +Use [Moss](https://moss.dev) semantic search as a retrieval tool for [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) agents. Agents get sub-10ms search over your knowledge base. + +## Installation + +```bash +pip install openai-agents moss python-dotenv +``` + +## Setup + +Set your credentials as environment variables or in a `.env` file (see `.env.example`): + +```bash +MOSS_PROJECT_ID=your-project-id +MOSS_PROJECT_KEY=your-project-key +OPENAI_API_KEY=your-openai-key +``` + +## Quick Start + +```python +import asyncio +from agents import Agent, Runner +from moss import MossClient +from moss_openai_agents import moss_search_tool + +client = MossClient("your-project-id", "your-project-key") +search = moss_search_tool(client=client, index_name="knowledge-base", top_k=5) + +agent = Agent( + name="Research Assistant", + instructions="Find accurate answers using the knowledge base.", + tools=[search], +) + +async def main(): + result = await Runner.run(agent, input="What are the best budget hotels in Tokyo?") + print(result.final_output) + +asyncio.run(main()) +``` + +## Demo: Multi-Agent Travel Planner + +The included `example_usage.py` runs an interactive CLI travel planner with **4 agents**: + +``` +User Question (e.g. "Plan a 2-day trip to Tokyo on a budget") + | + v ++-------------------+ +------------------+ +-------------------+ +| Destinations | | Hotels & Stays | | Activities & | +| Specialist | | Specialist | | Tours Specialist | ++--------+----------+ +--------+---------+ +---------+---------+ + \ | / + +---------------------+---------------------+ + | + v + +------------------+ + | Travel Planner | + | (orchestrator) | + +------------------+ + | + v + Final Answer +``` + +### How it works + +1. **Setup**: Travel data is indexed into 3 Moss indexes: + - `travel-destinations` (10 docs) — city guides, budget tips, transport, best times to visit + - `travel-stays` (11 docs) — hotels, hostels, prices, amenities + - `travel-activities` (24 docs) — tours, sightseeing, dining, experiences with costs + +2. **3 Specialist Agents** each search their Moss index via `moss_search` function tool + +3. **Travel Planner Agent** uses `agent.as_tool()` to delegate to specialists, then synthesizes findings + +### Run the demo + +```bash +cd examples/cookbook/openai-agents +pip install -e . +python example_usage.py +``` + +``` +=== Moss + OpenAI Agents SDK Travel Planner === +Plan your next trip! Type 'quit' to exit. + +You: Plan a 2-day trip to Tokyo on a budget +Agent: ... +``` + +## Available Tools + +All tools are defined in `moss_openai_agents.py`. Import the factory functions and pass the returned tools to your agent. + +### Search + +| Factory Function | Agent Input | Description | +|-----------------|------------|-------------| +| `moss_search_tool(client, index_name)` | `query: str` | Semantic search with ranked results and relevance scores | +| `moss_search_with_filter_tool(client, index_name)` | `query: str`, `filter_field?: str`, `filter_value?: str` | Search with optional metadata filtering | + +**Configuration** (set at construction, not controlled by the agent): + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `client` | required | Shared MossClient instance | +| `index_name` | required | Index to search | +| `top_k` | 5 | Number of results | +| `alpha` | 0.8 | Hybrid search balance (0=keyword, 1=semantic) | + +### Document Management + +| Factory Function | Agent Input | Description | +|-----------------|------------|-------------| +| `moss_add_docs_tool(client, index_name)` | `texts: list[str]`, `ids?: list[str]`, `upsert?: bool` | Add documents to an index | +| `moss_delete_docs_tool(client, index_name)` | `doc_ids: list[str]` | Delete specific documents by their IDs | +| `moss_get_docs_tool(client, index_name)` | `doc_ids?: list[str]` | Retrieve documents (all if no IDs) | + +### Index Management + +| Factory Function | Agent Input | Description | +|-----------------|------------|-------------| +| `moss_list_indexes_tool(client)` | *(none)* | List all indexes with doc counts and status | + +### moss_tools() Factory + +Create all 6 tools with shared configuration: + +```python +from moss import MossClient +from moss_openai_agents import moss_tools + +client = MossClient("your-project-id", "your-project-key") +tools = moss_tools(client=client, index_name="knowledge-base") + +agent = Agent(name="Assistant", tools=tools) +``` + +## Metadata Filters + +Use `moss_search_with_filter_tool` when your documents have metadata fields you want to filter on: + +```python +from moss_openai_agents import moss_search_with_filter_tool + +search = moss_search_with_filter_tool(client=client, index_name="travel-destinations") + +agent = Agent( + name="Japan Specialist", + instructions="Search for travel info. Use filter_field='country' and filter_value='Japan' to narrow results.", + tools=[search], +) +``` + +## Local vs Cloud Speed + +Moss supports loading indexes locally for sub-10ms retrieval: + +- **Cloud query** (default): ~50-200ms — data stays on Moss servers +- **Local query** (after `load_index()`): ~1-10ms — index loaded into memory + +All tools in this cookbook call `load_index()` automatically before the first search. For latency-sensitive applications, ensure `load_index()` completes before serving requests. + +## Files + +| File | Description | +|------|-------------| +| `moss_openai_agents.py` | 6 tool factory functions + `moss_tools()` helper | +| `example_usage.py` | Multi-agent travel planner CLI demo | +| `data/` | Travel data: `destinations_moss.json`, `stays_moss.json`, `activities_moss.json` | +| `test_live.py` | Live platform tests against real Moss API | +| `.env.example` | Template for required environment variables | diff --git a/examples/cookbook/openai-agents/data/activities_moss.json b/examples/cookbook/openai-agents/data/activities_moss.json new file mode 100644 index 00000000..6543fdfd --- /dev/null +++ b/examples/cookbook/openai-agents/data/activities_moss.json @@ -0,0 +1,194 @@ +[ + { + "id": "act-tok-201", + "text": "Ueno Park Museum Hopping - Ueno Park is free to enter. Includes many shrines and the lotus-filled Shinobazu Pond. Cost: Free. Type: Outdoor/Culture.", + "metadata": { + "cost": "Free", + "type": "Outdoor/Culture" + } + }, + { + "id": "act-tok-202", + "text": "Yoyogi Park & Meiji Jingu - Largest green space in Tokyo. Meiji Shrine is free. Great for people-watching and forest walks. Cost: Free. Type: Outdoor.", + "metadata": { + "cost": "Free", + "type": "Outdoor" + } + }, + { + "id": "act-tok-203", + "text": "Convenience Store Gourmet - Experience 'Konbini' dining. High-quality meals (Onigiri, Bento) for under 600 yen at 7-Eleven or Lawson. Cost: 500 - 1,000 yen. Type: Dining.", + "metadata": { + "cost": "500 - 1,000 yen", + "type": "Dining" + } + }, + { + "id": "act-tok-204", + "text": "Shibuya Crossing & Hachiko - The world's busiest intersection. Free to experience. Best viewed from the 2nd floor of the Magnet building. Cost: Free. Type: Sightseeing.", + "metadata": { + "cost": "Free", + "type": "Sightseeing" + } + }, + { + "id": "act-tok-205", + "text": "Tsukiji Outer Market Food Tour - Walk through the famous outer market sampling fresh sushi, tamagoyaki, and street food. Self-guided is free, guided tours available. Cost: 2,000 - 5,000 yen. Type: Dining/Tour.", + "metadata": { + "cost": "2,000 - 5,000 yen", + "type": "Dining/Tour" + } + }, + { + "id": "act-tok-206", + "text": "Akihabara Electric Town - Browse anime shops, retro game arcades, and maid cafes in Tokyo's geek culture capital. Window shopping is free. Cost: Free - 3,000 yen. Type: Culture/Shopping.", + "metadata": { + "cost": "Free - 3,000 yen", + "type": "Culture/Shopping" + } + }, + { + "id": "act-vn-207", + "text": "Hanoi Old Quarter Walking Tour - Explore the 36 streets of Hanoi's Old Quarter on foot. Sample egg coffee and bun cha at street stalls. Cost: Free (self-guided). Type: Walking Tour.", + "metadata": { + "cost": "Free (self-guided)", + "type": "Walking Tour" + } + }, + { + "id": "act-vn-208", + "text": "Ha Long Bay Day Cruise - Cruise through limestone karsts and floating villages. Budget day trips available from Hanoi with lunch included. Cost: 30 - 50 USD. Type: Tour/Nature.", + "metadata": { + "cost": "30 - 50 USD", + "type": "Tour/Nature" + } + }, + { + "id": "act-vn-209", + "text": "Da Nang Beach & Marble Mountains - Relax on My Khe Beach (free) then explore the Marble Mountains caves and pagodas. Elevator or stairs to the top. Cost: 40,000 VND entry. Type: Beach/Nature.", + "metadata": { + "cost": "40,000 VND entry", + "type": "Beach/Nature" + } + }, + { + "id": "act-al-210", + "text": "Ksamil Beach Hopping - Visit three stunning white-sand beaches on the Albanian Riviera. Crystal-clear water, no entry fee. Bring your own food to save money. Cost: Free. Type: Beach.", + "metadata": { + "cost": "Free", + "type": "Beach" + } + }, + { + "id": "act-al-211", + "text": "Bunk'Art Museum Tirana - Explore a massive Cold War bunker turned art museum. Fascinating look at Albania's communist past through contemporary art installations. Cost: 500 ALL (about 5 USD). Type: Museum/History.", + "metadata": { + "cost": "500 ALL (about 5 USD)", + "type": "Museum/History" + } + }, + { + "id": "act-tr-212", + "text": "Istanbul Bosphorus Ferry - Take the public ferry across the Bosphorus for a fraction of the cost of tourist boats. Best views of the city skyline and mosques. Cost: 7 TL (about 0.25 USD). Type: Sightseeing.", + "metadata": { + "cost": "7 TL (about 0.25 USD)", + "type": "Sightseeing" + } + }, + { + "id": "act-tr-213", + "text": "Grand Bazaar & Spice Market - Wander through one of the world's oldest covered markets. Over 4,000 shops selling spices, lamps, carpets, and Turkish delight. Cost: Free to browse. Type: Shopping/Culture.", + "metadata": { + "cost": "Free to browse", + "type": "Shopping/Culture" + } + }, + { + "id": "act-tr-214", + "text": "Cappadocia Hot Air Balloon - Sunrise balloon ride over fairy chimneys and cave dwellings. Book in advance for best prices. One of the top experiences in the world. Cost: 150 - 250 USD. Type: Adventure.", + "metadata": { + "cost": "150 - 250 USD", + "type": "Adventure" + } + }, + { + "id": "act-ro-215", + "text": "Brasov Old Town & Black Church - Walk through the medieval old town and visit the Gothic Black Church, the largest in southeastern Europe. Council Square is free to explore. Cost: Free (church entry 15 RON). Type: History/Walking.", + "metadata": { + "cost": "Free (church entry 15 RON)", + "type": "History/Walking" + } + }, + { + "id": "act-ro-216", + "text": "Bran Castle (Dracula's Castle) - Visit the castle associated with the Dracula legend. Surrounded by scenic Transylvanian mountains. Budget tip: take a bus from Brasov. Cost: 50 RON (about 10 USD). Type: History/Sightseeing.", + "metadata": { + "cost": "50 RON (about 10 USD)", + "type": "History/Sightseeing" + } + }, + { + "id": "act-th-217", + "text": "Bangkok Temple Trail - Visit Wat Pho (Reclining Buddha), Wat Arun, and the Grand Palace. Wat Pho includes a free Thai massage school demo. Cost: 200 - 500 THB per temple. Type: Culture/History.", + "metadata": { + "cost": "200 - 500 THB per temple", + "type": "Culture/History" + } + }, + { + "id": "act-th-218", + "text": "Chiang Mai Night Bazaar - Massive night market with street food, handicrafts, and live music. Best pad thai and mango sticky rice in the north. Cost: Free to browse. Type: Shopping/Dining.", + "metadata": { + "cost": "Free to browse", + "type": "Shopping/Dining" + } + }, + { + "id": "act-th-219", + "text": "Phi Phi Islands Day Trip - Speedboat tour to Maya Bay and surrounding islands. Snorkeling, swimming, and beach time included. Book from Phuket or Krabi. Cost: 1,500 - 2,500 THB. Type: Beach/Tour.", + "metadata": { + "cost": "1,500 - 2,500 THB", + "type": "Beach/Tour" + } + }, + { + "id": "act-ge-220", + "text": "Tbilisi Sulfur Baths - Relax in natural hot sulfur baths in the Abanotubani district. Private rooms available. A Tbilisi tradition for centuries. Cost: 600 - 3,000 GEL. Type: Wellness.", + "metadata": { + "cost": "600 - 3,000 GEL", + "type": "Wellness" + } + }, + { + "id": "act-ge-221", + "text": "Mtskheta Day Trip - Visit Georgia's ancient capital, a UNESCO World Heritage site. See Jvari Monastery and Svetitskhoveli Cathedral. 30 mins from Tbilisi by marshrutka. Cost: 1 GEL (marshrutka fare). Type: History/Culture.", + "metadata": { + "cost": "1 GEL (marshrutka fare)", + "type": "History/Culture" + } + }, + { + "id": "act-pt-222", + "text": "Lisbon Tram 28 Ride - Ride the iconic yellow tram through Alfama's narrow streets. Cheaper than a tour and covers all major viewpoints. Use a Viva Viagem card. Cost: 3 EUR with card. Type: Sightseeing.", + "metadata": { + "cost": "3 EUR with card", + "type": "Sightseeing" + } + }, + { + "id": "act-pt-223", + "text": "Porto Wine Tasting in Vila Nova de Gaia - Cross the Dom Luis I bridge and visit port wine cellars. Many offer free or cheap tastings. Best views of Porto from the riverside. Cost: 5 - 15 EUR per tasting. Type: Dining/Culture.", + "metadata": { + "cost": "5 - 15 EUR per tasting", + "type": "Dining/Culture" + } + }, + { + "id": "act-pt-224", + "text": "Sintra Palace Day Trip - Fairytale palaces and gardens 40 mins from Lisbon by train. Visit Pena Palace and the Moorish Castle. Go early to avoid crowds. Cost: 14 EUR palace entry. Type: History/Sightseeing.", + "metadata": { + "cost": "14 EUR palace entry", + "type": "History/Sightseeing" + } + } +] \ No newline at end of file diff --git a/examples/cookbook/openai-agents/data/destinations_moss.json b/examples/cookbook/openai-agents/data/destinations_moss.json new file mode 100644 index 00000000..f1b8dfad --- /dev/null +++ b/examples/cookbook/openai-agents/data/destinations_moss.json @@ -0,0 +1,82 @@ +[ + { + "id": "dest-tok-001", + "text": "Tokyo Budget Overview: Tokyo can be affordable. Use a Pasmo or Suica card for subways. Average daily budget for low-cost travelers is \u00c2\u00a55,000\u00e2\u20ac\u201c\u00c2\u00a58,000. Best free view: Tokyo Metropolitan Government Building in Shinjuku.", + "metadata": { + "country": "Japan", + "topic": "Tokyo Budget Overview" + } + }, + { + "id": "dest-tok-002", + "text": "Neighborhood Guide: Asakusa: Asakusa retains a 'Low City' vibe. Home to Senso-ji Temple. It is the most budget-friendly area for traditional sightseeing and street food like Taiyaki.", + "metadata": { + "country": "Japan", + "topic": "Neighborhood Guide: Asakusa" + } + }, + { + "id": "dest-tok-003", + "text": "Best Time to Visit: Mid-week visits (Tues-Thurs) see lower crowds at free attractions. Late March for Blossoms is expensive; January and June offer the lowest regional travel costs.", + "metadata": { + "country": "Japan", + "topic": "Best Time to Visit" + } + }, + { + "id": "dest-vn-004", + "text": "All-Rounder Budget Travel: Vietnam offers incredible value. Key hubs: Hanoi (culture/food) and Da Nang (beaches). Use the 'Grab' app for cheap transport. Street food like Banh Mi or Pho costs \u00c2\u00a5200-\u00c2\u00a5400. Best time: Feb\u00e2\u20ac\u201cApril for dry weather.", + "metadata": { + "country": "Vietnam", + "topic": "All-Rounder Budget Travel" + } + }, + { + "id": "dest-al-005", + "text": "The Mediterranean Alternative: The 'Maldives of Europe' at half the price. Focus on the Albanian Riviera (Ksamil/Sarande). Local tip: Use 'furgons' (minibuses) for intercity travel. Cash is king here; few places outside Tirana take cards.", + "metadata": { + "country": "Albania", + "topic": "The Mediterranean Alternative" + } + }, + { + "id": "dest-tr-006", + "text": "Crossroads of Culture: Ideal for history buffs. Istanbul's public ferries are the cheapest scenic 'tours.' Explore the Fatih district for authentic, low-cost Lokantas (tradesmen restaurants). Currency fluctuations often favor travelers.", + "metadata": { + "country": "Turkey", + "topic": "Crossroads of Culture" + } + }, + { + "id": "dest-ro-007", + "text": "Medieval Charm: Focus on Transylvania (Brasov and Sibiu). Medieval architecture and hiking are the main draws. Trains are affordable and reliable. Local tip: Visit 'Autoservire' restaurants for cheap, home-cooked Romanian meals.", + "metadata": { + "country": "Romania", + "topic": "Medieval Charm" + } + }, + { + "id": "dest-th-008", + "text": "The Budget Standard: Bangkok and Chiang Mai are the best for low-cost living. Use the BTS/MRT in Bangkok to avoid traffic. Local tip: Shop at night markets (like Jodd Fairs) for high-quality, low-cost clothing and souvenirs.", + "metadata": { + "country": "Thailand", + "topic": "The Budget Standard" + } + }, + { + "id": "dest-ge-009", + "text": "Wine and Mountains: Tbilisi is a design-forward city that remains very affordable. Famous for sulfur baths (approx. \u00c2\u00a5600 entry) and world-class wine. Tip: Order 'Khachapuri' (cheese bread) for a filling, budget-friendly meal.", + "metadata": { + "country": "Georgia", + "topic": "Wine and Mountains" + } + }, + { + "id": "dest-pt-010", + "text": "Affordable Western Europe: Lisbon and Porto are more affordable than neighboring Spain or France. Walkable cities with free viewpoints (Miradouros). Tip: Buy a 'Viva Viagem' card for discounted metro and tram 28 rides.", + "metadata": { + "country": "Portugal", + "topic": "Affordable Western Europe" + } + } +] \ No newline at end of file diff --git a/examples/cookbook/openai-agents/data/stays_moss.json b/examples/cookbook/openai-agents/data/stays_moss.json new file mode 100644 index 00000000..f7cec719 --- /dev/null +++ b/examples/cookbook/openai-agents/data/stays_moss.json @@ -0,0 +1,90 @@ +[ + { + "id": "stay-tok-101", + "text": "Nine Hours Capsule Hotel - Minimalist capsule stay in Shinjuku. High-speed Wi-Fi, lockers, and showers included. Price: \u00c2\u00a53,500 per night. Location: Shinjuku/Kanda. Amenities: Wi-Fi, Locker, Shower.", + "metadata": { + "location": "Shinjuku/Kanda", + "price": "\u00c2\u00a53,500" + } + }, + { + "id": "stay-tok-102", + "text": "Khaosan Tokyo Origami - Highly rated hostel in Asakusa. Offers dormitory beds and a communal kitchen to save on meal costs. Price: \u00c2\u00a52,800 per night. Location: Asakusa. Amenities: Kitchen, Laundry, Lounge.", + "metadata": { + "location": "Asakusa", + "price": "\u00c2\u00a52,800" + } + }, + { + "id": "stay-tok-103", + "text": "Sakura Hotel Ikebukuro - Budget hotel with private rooms and 24-hour cafe. Good for groups of two on a budget. Price: \u00c2\u00a57,000 per night. Location: Ikebukuro. Amenities: Private Room, Cafe, Multilingual Staff.", + "metadata": { + "location": "Ikebukuro", + "price": "\u00c2\u00a57,000" + } + }, + { + "id": "stay-vn-104", + "text": "Little Hanoi Hostel - Authentic hostel in the heart of the Old Quarter. Offers free breakfast and walking tours. Price: \u00c2\u00a51,200 per night. Location: Hanoi, Vietnam. Amenities: Free Breakfast, Walking Tours, Bicycle Rental.", + "metadata": { + "location": "Hanoi, Vietnam", + "price": "\u00c2\u00a51,200" + } + }, + { + "id": "stay-al-105", + "text": "Santi Quanta Hostel - Boutique hostel near the Skanderbeg Square. Known for a social atmosphere and clean facilities. Price: \u00c2\u00a52,000 per night. Location: Tirana, Albania. Amenities: Common Room, City Maps, Air Conditioning.", + "metadata": { + "location": "Tirana, Albania", + "price": "\u00c2\u00a52,000" + } + }, + { + "id": "stay-tr-106", + "text": "Cheers Lighthouse - Budget-friendly guesthouse in Sultanahmet with views of the Marmara Sea. Very close to the Blue Mosque. Price: \u00c2\u00a54,500 per night. Location: Istanbul, Turkey. Amenities: Ocean View, Restaurant, Airport Shuttle.", + "metadata": { + "location": "Istanbul, Turkey", + "price": "\u00c2\u00a54,500" + } + }, + { + "id": "stay-ro-107", + "text": "Kismet Dao Hostel - Legendary hostel in Brasov. Famous for its backyard BBQ and proximity to the Black Church. Price: \u00c2\u00a52,500 per night. Location: Brasov, Romania. Amenities: Garden BBQ, Lockers, Kitchen Access.", + "metadata": { + "location": "Brasov, Romania", + "price": "\u00c2\u00a52,500" + } + }, + { + "id": "stay-th-108", + "text": "Bed Station Hostel Khaosan - Modern, industrial-style hostel. Features a pool and bar area, perfect for solo travelers. Price: \u00c2\u00a51,800 per night. Location: Bangkok, Thailand. Amenities: Swimming Pool, Bar, Coworking Space.", + "metadata": { + "location": "Bangkok, Thailand", + "price": "\u00c2\u00a51,800" + } + }, + { + "id": "stay-ge-109", + "text": "Fabrika Hostel & Suites - A converted Soviet sewing factory turned into a multi-functional urban space and hostel. Price: \u00c2\u00a53,200 per night. Location: Tbilisi, Georgia. Amenities: Art Studios, Courtyard Bars, Private Suites.", + "metadata": { + "location": "Tbilisi, Georgia", + "price": "\u00c2\u00a53,200" + } + }, + { + "id": "stay-pt-110", + "text": "Yes! Lisbon Hostel - Award-winning hostel known for its group dinners and central location near Pra\u00c3\u00a7a do Com\u00c3\u00a9rcio. Price: \u00c2\u00a54,800 per night. Location: Lisbon, Portugal. Amenities: Group Dinners, Bar, Elevator.", + "metadata": { + "location": "Lisbon, Portugal", + "price": "\u00c2\u00a54,800" + } + }, + { + "id": "stay-vn-111", + "text": "Seashore Hotel & Apartment - Affordable beachfront stay in Da Nang. Ideal for travelers wanting hotel comfort at hostel prices. Price: \u00c2\u00a53,900 per night. Location: Da Nang, Vietnam. Amenities: Beach Access, Rooftop Pool, Gym.", + "metadata": { + "location": "Da Nang, Vietnam", + "price": "\u00c2\u00a53,900" + } + } +] \ No newline at end of file diff --git a/examples/cookbook/openai-agents/example_usage.py b/examples/cookbook/openai-agents/example_usage.py new file mode 100644 index 00000000..4768083a --- /dev/null +++ b/examples/cookbook/openai-agents/example_usage.py @@ -0,0 +1,130 @@ +"""Multi-agent travel planner using OpenAI Agents SDK + Moss semantic search. + +Three specialist agents each search their own Moss index, then a planner +agent synthesizes findings into an actionable travel plan. +""" + +import asyncio +import json +import os + +from agents import Agent, Runner +from dotenv import load_dotenv +from moss import DocumentInfo, MossClient + +from moss_openai_agents import moss_search_tool + +load_dotenv() + +client = MossClient(os.getenv("MOSS_PROJECT_ID"), os.getenv("MOSS_PROJECT_KEY")) + +DATA_DIR = os.path.join(os.path.dirname(__file__), "data") + +INDEXES = { + "travel-destinations": { + "file": "destinations_moss.json", + "name": "Destinations Specialist", + "instructions": ( + "You are a travel destination expert. " + "Always use the moss_search tool to find information. " + "Return all relevant results from the knowledge base." + ), + }, + "travel-stays": { + "file": "stays_moss.json", + "name": "Hotels & Stays Specialist", + "instructions": ( + "You are an accommodation expert. " + "Always use the moss_search tool to find hotels and stays. " + "Return all relevant results from the knowledge base." + ), + }, + "travel-activities": { + "file": "activities_moss.json", + "name": "Activities & Tours Specialist", + "instructions": ( + "You are an activities and tours expert. " + "Always use the moss_search tool to find experiences. " + "Return all relevant results from the knowledge base." + ), + }, +} + + +async def setup_indexes(): + """Create travel indexes from Moss-formatted JSON data.""" + for index_name, config in INDEXES.items(): + path = os.path.join(DATA_DIR, config["file"]) + with open(path) as f: + raw = json.load(f) + + docs = [ + DocumentInfo(id=item["id"], text=item["text"], metadata=item.get("metadata", {})) + for item in raw + ] + + print(f"Setting up '{index_name}' ({len(docs)} docs)...") + try: + await client.create_index(index_name, docs) + except RuntimeError as e: + if "already exists" not in str(e): + raise + print(" Already exists, skipping.") + await client.load_index(index_name) + print(" Loaded.") + print() + + +def build_agents() -> tuple[list[Agent], Agent]: + """Create 3 specialist agents and a travel planner.""" + specialists = [] + for index_name, config in INDEXES.items(): + search = moss_search_tool(client=client, index_name=index_name, top_k=5) + specialists.append( + Agent( + name=config["name"], + instructions=config["instructions"], + tools=[search], + ) + ) + + planner = Agent( + name="Travel Planner", + instructions=( + "You are an experienced travel planner. " + "Use the specialist agents to research destinations, stays, and activities, " + "then create a clear, actionable travel plan. " + "Never make up information — only use what the specialists return." + ), + tools=[ + agent.as_tool( + tool_name=agent.name.lower().replace(" ", "_").replace("&", "and"), + tool_description=f"Ask the {agent.name} to search for relevant information.", + ) + for agent in specialists + ], + ) + return specialists, planner + + +async def chat(): + """Interactive travel planner chat.""" + _, planner = build_agents() + + print("=== Moss + OpenAI Agents SDK Travel Planner ===") + print("Plan your next trip! Type 'quit' to exit.\n") + + while True: + question = input("You: ").strip() + if not question or question.lower() in ("quit", "exit", "q"): + if question: + print("Goodbye!") + break + + result = await Runner.run(planner, input=question) + print(f"\nAgent: {result.final_output}\n") + + +if __name__ == "__main__": + asyncio.run(setup_indexes()) + asyncio.run(chat()) diff --git a/examples/cookbook/openai-agents/moss_openai_agents.py b/examples/cookbook/openai-agents/moss_openai_agents.py new file mode 100644 index 00000000..2e842643 --- /dev/null +++ b/examples/cookbook/openai-agents/moss_openai_agents.py @@ -0,0 +1,222 @@ +"""Moss retrieval tools for the OpenAI Agents SDK. + +Wraps MossClient.query() as @function_tool callables so any OpenAI Agents SDK +agent can perform semantic search, manage documents, and inspect indexes. +""" + +import asyncio +import uuid +from typing import Any + +from agents import RunContextWrapper, function_tool +from moss import DocumentInfo, GetDocumentsOptions, MossClient, MutationOptions, QueryOptions + + +def moss_search_tool(client: MossClient, index_name: str, top_k: int = 5, alpha: float = 0.8): + """Create a Moss semantic search tool bound to a specific index. + + Args: + client: Shared MossClient instance. + index_name: Index to search. + top_k: Number of results to return. + alpha: Hybrid search balance (0=keyword, 1=semantic). + """ + _index_loaded = False + + @function_tool(name_override="moss_search") + async def moss_search(query: str) -> str: + """Search a knowledge base using Moss semantic search. Returns the most relevant documents for a given query. + + Args: + query: The search query text. + """ + nonlocal _index_loaded + if not _index_loaded: + await client.load_index(index_name) + _index_loaded = True + + results = await client.query( + index_name, + query, + QueryOptions(top_k=top_k, alpha=alpha), + ) + if not results.docs: + return "No relevant information found." + return "\n\n".join( + f"Result {i + 1} (score: {doc.score:.2f}):\n{doc.text}" + for i, doc in enumerate(results.docs) + ) + + return moss_search + + +def moss_search_with_filter_tool( + client: MossClient, index_name: str, top_k: int = 5, alpha: float = 0.8 +): + """Create a Moss search tool that supports optional metadata filters. + + Args: + client: Shared MossClient instance. + index_name: Index to search. + top_k: Number of results to return. + alpha: Hybrid search balance (0=keyword, 1=semantic). + """ + _index_loaded = False + + @function_tool(name_override="moss_search_with_filter") + async def moss_search_with_filter( + query: str, + filter_field: str | None = None, + filter_value: str | None = None, + ) -> str: + """Search a knowledge base with optional metadata filtering. + + Args: + query: The search query text. + filter_field: Optional metadata field name to filter on (e.g. 'country'). + filter_value: Value the filter_field must match (e.g. 'Japan'). + """ + nonlocal _index_loaded + if not _index_loaded: + await client.load_index(index_name) + _index_loaded = True + + opts = QueryOptions(top_k=top_k, alpha=alpha) + if filter_field and filter_value: + opts = QueryOptions( + top_k=top_k, + alpha=alpha, + filter={filter_field: filter_value}, + ) + + results = await client.query(index_name, query, opts) + if not results.docs: + return "No relevant information found." + return "\n\n".join( + f"Result {i + 1} (score: {doc.score:.2f}):\n{doc.text}" + for i, doc in enumerate(results.docs) + ) + + return moss_search_with_filter + + +def moss_add_docs_tool(client: MossClient, index_name: str): + """Create a tool to add documents to a Moss index. + + Args: + client: Shared MossClient instance. + index_name: Index to add documents to. + """ + + @function_tool(name_override="moss_add_docs") + async def moss_add_docs(texts: list[str], ids: list[str] | None = None, upsert: bool = False) -> str: + """Add text documents to a Moss semantic search index. + + Args: + texts: List of text documents to add. + ids: Optional document IDs (auto-generated if omitted). + upsert: If True, update existing documents with the same ID. + """ + doc_ids = ids or [str(uuid.uuid4()) for _ in texts] + docs = [DocumentInfo(id=did, text=text) for did, text in zip(doc_ids, texts)] + options = MutationOptions(upsert=upsert) + await client.add_docs(index_name, docs, options) + return f"Added {len(docs)} document(s) to '{index_name}'." + + return moss_add_docs + + +def moss_get_docs_tool(client: MossClient, index_name: str): + """Create a tool to retrieve documents from a Moss index. + + Args: + client: Shared MossClient instance. + index_name: Index to retrieve from. + """ + + @function_tool(name_override="moss_get_docs") + async def moss_get_docs(doc_ids: list[str] | None = None) -> str: + """Retrieve documents from a Moss index. Fetches all if no IDs provided. + + Args: + doc_ids: Optional list of document IDs to retrieve. + """ + options = GetDocumentsOptions(ids=doc_ids) if doc_ids else GetDocumentsOptions() + result = await client.get_docs(index_name, options) + if not result.docs: + return "No documents found." + return "\n\n".join( + f"[{doc.id}]: {doc.text[:200]}{'...' if len(doc.text) > 200 else ''}" + for doc in result.docs + ) + + return moss_get_docs + + +def moss_delete_docs_tool(client: MossClient, index_name: str): + """Create a tool to delete documents from a Moss index. + + Args: + client: Shared MossClient instance. + index_name: Index to delete from. + """ + + @function_tool(name_override="moss_delete_docs") + async def moss_delete_docs(doc_ids: list[str]) -> str: + """Delete specific documents from a Moss index by their IDs. + + Args: + doc_ids: List of document IDs to delete. + """ + await client.delete_docs(index_name, doc_ids) + return f"Deleted {len(doc_ids)} document(s) from '{index_name}'." + + return moss_delete_docs + + +def moss_list_indexes_tool(client: MossClient): + """Create a tool to list all Moss indexes. + + Args: + client: Shared MossClient instance. + """ + + @function_tool(name_override="moss_list_indexes") + async def moss_list_indexes() -> str: + """List all indexes with document counts and status.""" + indexes = await client.list_indexes() + if not indexes: + return "No indexes found." + return "\n".join( + f"- {idx.name}: {idx.doc_count} docs ({idx.status})" + for idx in indexes + ) + + return moss_list_indexes + + +def moss_tools( + client: MossClient, + index_name: str, + top_k: int = 5, + alpha: float = 0.8, +) -> list: + """Create all Moss tools with shared configuration. + + Args: + client: Shared MossClient instance. + index_name: Default index for tools that operate on a single index. + top_k: Number of search results. + alpha: Hybrid search balance. + + Returns: + List of function tools ready to pass to an Agent. + """ + return [ + moss_search_tool(client, index_name, top_k, alpha), + moss_search_with_filter_tool(client, index_name, top_k, alpha), + moss_add_docs_tool(client, index_name), + moss_get_docs_tool(client, index_name), + moss_delete_docs_tool(client, index_name), + moss_list_indexes_tool(client), + ] diff --git a/examples/cookbook/openai-agents/pyproject.toml b/examples/cookbook/openai-agents/pyproject.toml new file mode 100644 index 00000000..6c5417d8 --- /dev/null +++ b/examples/cookbook/openai-agents/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "openai-agents-moss" +version = "0.1.0" +description = "OpenAI Agents SDK cookbook example for Moss semantic search" +readme = "README.md" +requires-python = ">=3.11,<3.14" +license = { text = "BSD-2-Clause" } +authors = [ + { name = "InferEdge Inc.", email = "contact@moss.dev" } +] +dependencies = [ + "openai-agents>=0.0.7", + "moss>=1.0.0", + "python-dotenv", +] diff --git a/examples/cookbook/openai-agents/test_live.py b/examples/cookbook/openai-agents/test_live.py new file mode 100644 index 00000000..7c466e63 --- /dev/null +++ b/examples/cookbook/openai-agents/test_live.py @@ -0,0 +1,136 @@ +"""Live platform tests for Moss + OpenAI Agents SDK tools. + +Run: python test_live.py + +Requires MOSS_PROJECT_ID and MOSS_PROJECT_KEY in environment or .env file. +""" + +import asyncio +import os +import sys + +from dotenv import load_dotenv +from moss import MossClient + +from moss_openai_agents import ( + moss_add_docs_tool, + moss_delete_docs_tool, + moss_get_docs_tool, + moss_list_indexes_tool, + moss_search_tool, + moss_search_with_filter_tool, +) + +load_dotenv() + +PROJECT_ID = os.getenv("MOSS_PROJECT_ID") +PROJECT_KEY = os.getenv("MOSS_PROJECT_KEY") +TEST_INDEX = "openai-agents-live-test" + +passed = 0 +failed = 0 + + +def report(name, success, detail=""): + global passed, failed + if success: + passed += 1 + else: + failed += 1 + status = "PASS" if success else "FAIL" + msg = f" [{status}] {name}" + if detail: + msg += f" -- {detail}" + print(msg) + + +async def run_tests(): + if not PROJECT_ID or not PROJECT_KEY: + print("ERROR: MOSS_PROJECT_ID and MOSS_PROJECT_KEY must be set.") + sys.exit(1) + + print("Running live tests against Moss platform...\n") + client = MossClient(PROJECT_ID, PROJECT_KEY) + + # --- 1. Add documents --- + print("1. moss_add_docs") + add_tool = moss_add_docs_tool(client, TEST_INDEX) + try: + # Create index first + from moss import DocumentInfo + await client.create_index( + TEST_INDEX, + [DocumentInfo(id="seed", text="Seed document for index creation.")], + ) + except RuntimeError as e: + if "already exists" not in str(e): + raise + + try: + result = await add_tool.on_invoke_tool( + None, + '{"texts": ["Moss delivers sub-10ms semantic search.", "OpenAI Agents SDK orchestrates tool-using agents.", "Python is popular for AI development."], "ids": ["doc-1", "doc-2", "doc-3"]}', + ) + report("add 3 docs", "Added 3" in result, result) + except Exception as e: + report("add 3 docs", False, str(e)) + + # --- 2. Search --- + print("2. moss_search") + search_tool = moss_search_tool(client, TEST_INDEX, top_k=3) + try: + result = await search_tool.on_invoke_tool(None, '{"query": "semantic search"}') + report("search", "Result" in result, result[:80]) + except Exception as e: + report("search", False, str(e)) + + # --- 3. Search with filter --- + print("3. moss_search_with_filter") + filter_tool = moss_search_with_filter_tool(client, TEST_INDEX, top_k=3) + try: + result = await filter_tool.on_invoke_tool(None, '{"query": "search"}') + report("search (no filter)", "Result" in result or "No relevant" in result, result[:80]) + except Exception as e: + report("search (no filter)", False, str(e)) + + # --- 4. Get documents --- + print("4. moss_get_docs") + get_tool = moss_get_docs_tool(client, TEST_INDEX) + try: + result = await get_tool.on_invoke_tool(None, '{"doc_ids": ["doc-1"]}') + report("get by ID", "doc-1" in result, result[:80]) + except Exception as e: + report("get by ID", False, str(e)) + + # --- 5. List indexes --- + print("5. moss_list_indexes") + list_tool = moss_list_indexes_tool(client) + try: + result = await list_tool.on_invoke_tool(None, '{}') + report("list indexes", TEST_INDEX in result or "docs" in result, result[:80]) + except Exception as e: + report("list indexes", False, str(e)) + + # --- 6. Delete documents --- + print("6. moss_delete_docs") + delete_tool = moss_delete_docs_tool(client, TEST_INDEX) + try: + result = await delete_tool.on_invoke_tool(None, '{"doc_ids": ["doc-1", "doc-2", "doc-3"]}') + report("delete docs", "Deleted 3" in result, result) + except Exception as e: + report("delete docs", False, str(e)) + + # --- Cleanup --- + print("\nCleanup...") + try: + await client.delete_index(TEST_INDEX) + print(f" Deleted test index '{TEST_INDEX}'.") + except Exception: + print(f" Could not delete '{TEST_INDEX}' (may not exist).") + + print(f"\nResults: {passed} passed, {failed} failed out of {passed + failed}") + sys.exit(1 if failed else 0) + + +if __name__ == "__main__": + asyncio.run(run_tests()) From 44c23cfaed1696fb1fc3d33a21b89bf886da6e55 Mon Sep 17 00:00:00 2001 From: ArkForge Date: Sat, 11 Apr 2026 08:07:44 +0000 Subject: [PATCH 2/2] fix: use ToolContext instead of None in test_live.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit on_invoke_tool requires a ToolContext instance — passing None causes silent errors in openai-agents >= 0.13 where ctx.tool_name is accessed. Co-Authored-By: Claude Opus 4.6 --- examples/cookbook/openai-agents/test_live.py | 34 ++++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/examples/cookbook/openai-agents/test_live.py b/examples/cookbook/openai-agents/test_live.py index 7c466e63..f67b7ad7 100644 --- a/examples/cookbook/openai-agents/test_live.py +++ b/examples/cookbook/openai-agents/test_live.py @@ -9,6 +9,7 @@ import os import sys +from agents.tool_context import ToolContext from dotenv import load_dotenv from moss import MossClient @@ -29,6 +30,19 @@ passed = 0 failed = 0 +_call_counter = 0 + + +def _make_ctx(tool_name: str, args: str) -> ToolContext: + """Build a minimal ToolContext for direct tool invocation in tests.""" + global _call_counter + _call_counter += 1 + return ToolContext( + context=None, + tool_name=tool_name, + tool_call_id=f"test_call_{_call_counter}", + tool_arguments=args, + ) def report(name, success, detail=""): @@ -67,9 +81,10 @@ async def run_tests(): raise try: + add_args = '{"texts": ["Moss delivers sub-10ms semantic search.", "OpenAI Agents SDK orchestrates tool-using agents.", "Python is popular for AI development."], "ids": ["doc-1", "doc-2", "doc-3"]}' result = await add_tool.on_invoke_tool( - None, - '{"texts": ["Moss delivers sub-10ms semantic search.", "OpenAI Agents SDK orchestrates tool-using agents.", "Python is popular for AI development."], "ids": ["doc-1", "doc-2", "doc-3"]}', + _make_ctx("moss_add_docs", add_args), + add_args, ) report("add 3 docs", "Added 3" in result, result) except Exception as e: @@ -79,7 +94,8 @@ async def run_tests(): print("2. moss_search") search_tool = moss_search_tool(client, TEST_INDEX, top_k=3) try: - result = await search_tool.on_invoke_tool(None, '{"query": "semantic search"}') + search_args = '{"query": "semantic search"}' + result = await search_tool.on_invoke_tool(_make_ctx("moss_search", search_args), search_args) report("search", "Result" in result, result[:80]) except Exception as e: report("search", False, str(e)) @@ -88,7 +104,8 @@ async def run_tests(): print("3. moss_search_with_filter") filter_tool = moss_search_with_filter_tool(client, TEST_INDEX, top_k=3) try: - result = await filter_tool.on_invoke_tool(None, '{"query": "search"}') + filter_args = '{"query": "search"}' + result = await filter_tool.on_invoke_tool(_make_ctx("moss_search_with_filter", filter_args), filter_args) report("search (no filter)", "Result" in result or "No relevant" in result, result[:80]) except Exception as e: report("search (no filter)", False, str(e)) @@ -97,7 +114,8 @@ async def run_tests(): print("4. moss_get_docs") get_tool = moss_get_docs_tool(client, TEST_INDEX) try: - result = await get_tool.on_invoke_tool(None, '{"doc_ids": ["doc-1"]}') + get_args = '{"doc_ids": ["doc-1"]}' + result = await get_tool.on_invoke_tool(_make_ctx("moss_get_docs", get_args), get_args) report("get by ID", "doc-1" in result, result[:80]) except Exception as e: report("get by ID", False, str(e)) @@ -106,7 +124,8 @@ async def run_tests(): print("5. moss_list_indexes") list_tool = moss_list_indexes_tool(client) try: - result = await list_tool.on_invoke_tool(None, '{}') + list_args = '{}' + result = await list_tool.on_invoke_tool(_make_ctx("moss_list_indexes", list_args), list_args) report("list indexes", TEST_INDEX in result or "docs" in result, result[:80]) except Exception as e: report("list indexes", False, str(e)) @@ -115,7 +134,8 @@ async def run_tests(): print("6. moss_delete_docs") delete_tool = moss_delete_docs_tool(client, TEST_INDEX) try: - result = await delete_tool.on_invoke_tool(None, '{"doc_ids": ["doc-1", "doc-2", "doc-3"]}') + delete_args = '{"doc_ids": ["doc-1", "doc-2", "doc-3"]}' + result = await delete_tool.on_invoke_tool(_make_ctx("moss_delete_docs", delete_args), delete_args) report("delete docs", "Deleted 3" in result, result) except Exception as e: report("delete docs", False, str(e))