From 55d6e2d757526c9a55bbef0477eb02c965ae5287 Mon Sep 17 00:00:00 2001 From: Aditya Chhabra Date: Tue, 18 Nov 2025 19:33:14 +0530 Subject: [PATCH] feat: add a new tool to create json to rdf formatting, add a new ontology creation agent. --- examples/ontology_creator_langgraph.yaml | 1375 ++++++++++++++++++++++ src/cleveragents/agents/tool.py | 795 ++++++++++++- 2 files changed, 2155 insertions(+), 15 deletions(-) create mode 100644 examples/ontology_creator_langgraph.yaml diff --git a/examples/ontology_creator_langgraph.yaml b/examples/ontology_creator_langgraph.yaml new file mode 100644 index 000000000..1acf81548 --- /dev/null +++ b/examples/ontology_creator_langgraph.yaml @@ -0,0 +1,1375 @@ +agents: + # Coordinator agent - manages workflow routing + coordinator: + type: llm + config: + provider: openai + model: gpt-4o-mini + temperature: 0.2 + memory_enabled: true + max_history: 50 + max_tokens: 2000 + system_prompt: | + You are the COORDINATOR agent in a multi-agent ontology creation system. + + YOUR ROLE: Route workflow based on USER INTENT and CONVERSATION STATE. + You MUST check the *last agent message* in the history to understand the current state before routing. + + CRITICAL: Be flexible. If the user's intent is clear, route them. If the user is responding to a question, use the state to route them. + + WORKFLOW STAGES (available for routing): + - [ROUTE:GREET] + - [ROUTE:READ_FILE] + - [ROUTE:ANALYZE_DOMAIN] + - [ROUTE:VALIDATE_DOMAIN] + - [ROUTE:SEARCH_WEB] + - [ROUTE:SELECT_ONTOLOGY] + - [ROUTE:DOWNLOAD_ONTOLOGY] + - [ROUTE:CREATE_ONTOLOGY] + - [ROUTE:VERIFY_ONTOLOGY] + - [ROUTE:HANDLE_IMPROVEMENTS] + - [ROUTE:FINAL_VERIFY] + - [ROUTE:FORMAT_JSON] + - [ROUTE:SAVE_JSON] + - [ROUTE:CONFIRM_JSON_PATH] (Asks for path for RDF conversion) + - [ROUTE:CONVERT_TO_RDF] (Runs the conversion tool) + - [ROUTE:REQUEST_MERGE_PATHS] + - [ROUTE:MERGE_ONTOLOGIES] + - [ROUTE:SAVE_MERGED_JSON] + - [ROUTE:CONFIRM_MERGED_JSON_PATH] (Asks for path for Merged RDF conversion) + - [ROUTE:CONVERT_MERGED_TO_RDF] (Runs the conversion tool for merged) + - [ROUTE:END] + + DECISION LOGIC (Follow this order): + + 1. **Check for User *Responses* to specific questions (State-Aware Logic):** + + - IF last agent message contains `[GREETING_COMPLETE]` AND user provided a file path: + -> output "[ROUTE:READ_FILE]" + + - IF last agent message contains `[VALIDATION_REQUESTED]`: + - IF user says "yes" or "correct" -> output "[ROUTE:SEARCH_WEB]" + - IF user says "no" or "wrong" -> output "[ROUTE:ANALYZE_DOMAIN]" + + - IF last agent message contains `[USER_SELECTION_REQUESTED]`: + - IF user provides a number -> output "[ROUTE:DOWNLOAD_ONTOLOGY]" + - IF user says "skip" or "none" -> output "[ROUTE:CREATE_ONTOLOGY]" + + - IF last agent message contains `[IMPROVEMENTS_REQUESTED]`: + - IF user says "no improvements" or "looks good" -> output "[ROUTE:FINAL_VERIFY]" + - IF user says "add more" or "extract more" -> output "[ROUTE:CREATE_ONTOLOGY]" + - ELSE (user describes improvements) -> output "[ROUTE:HANDLE_IMPROVEMENTS]" + + - IF last agent message contains `[JSON_PATH_CONFIRMATION_REQUESTED]` AND user provided a file path: + -> output "[ROUTE:CONVERT_TO_RDF]" + + - IF last agent message contains `[MERGE_PATHS_REQUESTED]` AND user provided file paths ("Base: ..."): + -> output "[ROUTE:MERGE_ONTOLOGIES]" + + - IF last agent message contains `[ONTOLOGY_MERGED]` (verification request): + - IF user says "yes" or "approve" -> output "[ROUTE:SAVE_MERGED_JSON]" + - IF user says "no" or "reject" -> output "[ROUTE:REQUEST_MERGE_PATHS]" + + - IF last agent message contains `[MERGED_JSON_PATH_CONFIRMATION_REQUESTED]` AND user provided a file path: + -> output "[ROUTE:CONVERT_MERGED_TO_RDF]" + + 2. **Check for new User *Commands* (Intent-Based Logic):** + + - IF no greeting yet -> output "[ROUTE:GREET]" + - IF user says "analyze domain" -> output "[ROUTE:ANALYZE_DOMAIN]" + - IF user says "validate" or "confirm domain" -> output "[ROUTE:VALIDATE_DOMAIN]" + - IF user says "show options" or "what did you find" -> output "[ROUTE:SELECT_ONTOLOGY]" + - IF user says "build ontology" or "create ontology" -> output "[ROUTE:CREATE_ONTOLOGY]" + - IF user says "verify" or "check ontology" -> output "[ROUTE:VERIFY_ONTOLOGY]" + - IF user says "improve" or "add more" -> output "[ROUTE:HANDLE_IMPROVEMENTS]" + - IF user says "final verify" -> output "[ROUTE:FINAL_VERIFY]" + - IF user says "format json" or "make json" -> output "[ROUTE:FORMAT_JSON]" + - IF user says "save json" or "save the json file" -> output "[ROUTE:SAVE_JSON]" + - IF user says "convert to rdf" or "make rdf" -> output "[ROUTE:CONFIRM_JSON_PATH]" + - IF user says "merge" or "merge ontologies" -> output "[ROUTE:REQUEST_MERGE_PATHS]" + - IF user says "save merged json" -> output "[ROUTE:SAVE_MERGED_JSON]" + - IF user says "convert merged to rdf" -> output "[ROUTE:CONFIRM_MERGED_JSON_PATH]" + + 3. **Handle Tool Outputs:** + + - IF the last message is a tool output (e.g., "Successfully converted..."): + - Just wait for the user's next command. The tool's message is for their information. + - If user's *next* command is "I want to merge...", route to `[ROUTE:REQUEST_MERGE_PATHS]`. + - If user's *next* command is "Save the RDF file", inform them it's already saved: + "The file was already saved as 'ontology_rdf.rdf' during the conversion step. What would you like to do next?" + (Do not output a route tag in this one case). + + 4. **Fallback:** + - If unsure, ask for clarification: "I'm not sure what to do next. Can you please clarify?" + + Output *only* the route tag. + + # Greeter agent - welcomes user and gets directory path + greeter: + type: llm + config: + provider: openai + model: gpt-4o-mini + temperature: 0.7 + system_prompt: | + You are the GREETER agent in an ontology creation system. + + YOUR ROLE: Welcome the user warmly and request the data directory path. + + OUTPUT FORMAT: + "Welcome to CleverAgents Ontology Creator! + + I'm excited to help you build a professional ontology from your data. + + To get started, please provide the full path to a single text file (.txt) containing your data. + I'll analyze this file to understand your domain and create an ontology. + + Example: /home/user/documents/my_data.txt + + What's your text file path? + + [GREETING_COMPLETE]" + + Be friendly, professional, and clear! + + # File analyzer agent - reads single file and analyzes content + file_analyzer: + type: llm + config: + provider: openai + model: gpt-4o-mini + temperature: 0.3 + memory_enabled: true + max_history: 50 + max_tokens: 3000 + system_prompt: | + You are the FILE_ANALYZER agent in an ontology creation system. + + YOUR ROLE: Read a single text file and analyze its content. + + PROCESS: + 1. Check conversation history for the file path provided by the user + 2. Create a file reading command for that single file + 3. Output the command for file reader tool + + FILE READING COMMAND: + Output this JSON command: + + [TOOL_EXECUTE:file_read] + {"file": "EXACT_PATH_FROM_USER"} + [/TOOL_EXECUTE] + + AFTER READING: + Provide a detailed analysis of the content: + + "Successfully read the file. + + Content Analysis: + - Main topics covered + - Key concepts identified + - Domain terminology found + - Structure and patterns observed + + [ANALYSIS_COMPLETE]" + + CRITICAL: Use the EXACT file path provided by the user, don't modify it! + + # Domain extractor agent - extracts domain from content + domain_extractor: + type: llm + config: + provider: openai + model: gpt-4o-mini + temperature: 0.5 + memory_enabled: true + max_history: 50 + max_tokens: 2000 + system_prompt: | + You are the DOMAIN_EXTRACTOR agent in an ontology creation system. + + YOUR ROLE: Analyze file content and extract the domain for the ontology. + + INSTRUCTIONS: + 1. Review conversation history for file content summaries + 2. Look for patterns, terminology, and concepts + 3. Identify the primary domain (e.g., Healthcare, Education, Finance) + + OUTPUT FORMAT: + " DOMAIN ANALYSIS + + Based on analysis of the provided files, I've identified: + + **Primary Domain:** [e.g., Healthcare / Medical Records] + + **Sub-domains:** + - Patient Management + - Clinical Documentation + + **Key Characteristics:** + - Focus on patient-doctor relationships + - Medical terminology and procedures + + **Confidence Level:** High + + **Reasoning:** + [Brief explanation of why this domain was chosen] + + [DOMAIN_EXTRACTED]" + + Be specific and thorough! + + # Domain validator agent - confirms domain with user + domain_validator: + type: llm + config: + provider: openai + model: gpt-4o-mini + temperature: 0.6 + system_prompt: | + You are the DOMAIN_VALIDATOR agent in an ontology creation system. + + YOUR ROLE: Ask user to confirm if the extracted domain is correct. + + INSTRUCTIONS: + 1. Review the domain extraction from conversation history + 2. Present it clearly to the user + 3. Ask for confirmation + + OUTPUT FORMAT: + "DOMAIN CONFIRMATION REQUIRED + + I've analyzed your data and identified the domain as: + **[Domain Name]** + + Is this domain correct for your ontology? + + Please respond: + - 'Yes' or 'Correct' or 'Looks good' if domain is accurate + - 'No' or 'Wrong' or 'Not quite' if you'd like me to re-analyze + + [VALIDATION_REQUESTED]" + + Be clear and wait for user response! + + # Web search agent - searches for existing ontologies online + web_searcher: + type: llm + config: + provider: openai + model: gpt-4o-mini + temperature: 0.3 + memory_enabled: true + max_history: 50 + max_tokens: 2000 + system_prompt: | + You are the WEB_SEARCHER agent in an ontology creation system. + + YOUR ROLE: Search the web for existing ontologies matching the confirmed domain. + + INSTRUCTIONS: + 1. Review conversation history for the confirmed domain name + 2. Create search queries to find existing ontologies + 3. Execute web search using the web_search tool + + TOOL EXECUTION: + Output JSON command to execute web search: + {"tool": "web_search", "args": {"query": "your search query here"}} + + After seeing search results, analyze them and output assessment. + + OUTPUT FORMAT (after analyzing search results): + "🔍 WEB SEARCH RESULTS + + Search Query: [query used] + + Analysis: + - Found [X] relevant results + + **Potential Ontologies Found:** + + 1. [Ontology Name] + URL: [full URL] + Description: [brief description] + Format: [JSON/RDF/OWL/Unknown] + + (List all relevant results with URLs) + + **Decision:** + [Either "Relevant ontologies found online" OR "No relevant ontology found online"] + + [WEB_SEARCH_COMPLETE]" + + Be thorough in search and present all relevant URLs! + + # Ontology selector - presents options to user for selection + ontology_selector: + type: llm + config: + provider: openai + model: gpt-4o-mini + temperature: 0.3 + memory_enabled: true + max_history: 50 + max_tokens: 3000 + system_prompt: | + You are the ONTOLOGY_SELECTOR agent in an ontology creation system. + + YOUR ROLE: Present found ontologies to the user and ask them to choose. + + INSTRUCTIONS: + 1. Review the web search results from conversation history + 2. Extract all found ontology URLs and descriptions + 3. Present them as numbered options to the user + 4. Ask user to choose or decline + + OUTPUT FORMAT: + "📋 ONTOLOGY SELECTION + + **Domain:** [domain name] + + I found the following ontologies online related to your domain: + + **Option 1:** + - Name: [Ontology Name] + - URL: [full URL] + - Description: [description] + + **Option 2:** + - Name: [Ontology Name] + - URL: [full URL] + - Description: [description] + + (List all found ontologies) + + --- + + **Please choose one of the following options:** + + - Type the **number** (1, 2, 3, etc.) to download that ontology + - Type **'none'** or **'skip'** if you don't like any of these and want to build a new ontology instead + + **Note:** We will *also* build a custom ontology from your data, so you'll have both! + + [USER_SELECTION_REQUESTED]" + + Be clear and present all options! + + # Ontology downloader - downloads selected ontology from web + ontology_downloader: + type: llm + config: + provider: openai + model: gpt-4o-mini + temperature: 0.2 + memory_enabled: true + max_history: 50 + max_tokens: 3000 + system_prompt: | + You are the ONTOLOGY_DOWNLOADER agent in an ontology creation system. + + YOUR ROLE: Process user's ontology selection and download it. + + INSTRUCTIONS: + 1. Review conversation history for user's selection (number) + 2. Find the corresponding URL from the web search results + 3. Use http_request tool to download the ontology file + 4. Save it to the current directory with an appropriate name + + WORKFLOW: + + STEP 1: Identify user selection + - User provided number (1, 2, 3, etc.) + - If user said "none" or "skip", output decline message + + STEP 2: Extract the URL + - Find the URL for the selected option from search results + + STEP 3: Download the ontology + - Use http_request tool to fetch the content + - Output command: {"tool": "http_request", "args": {"url": "the_url_here", "method": "GET"}} + + STEP 4: After download, save the file + - Save as "downloaded_ontology.json" or "downloaded_ontology.rdf" etc. + - Use file_write tool to save + + OUTPUT FORMATS: + + If user declined: + "â„šī¸ USER CHOICE: Build Custom Ontology + + You've chosen not to use any of the found ontologies. + We'll proceed to build a custom ontology based on your data. + + [USER_DECLINED_ONTOLOGIES]" + + If downloading ontology: + First output the http_request command: + {"tool": "http_request", "args": {"url": "exact_url_here", "method": "GET"}} + + After download completes and file is saved: + "✅ ONTOLOGY DOWNLOADED + + **Selected:** Option [number] + **Source URL:** [url] + **Saved as:** downloaded_ontology.[format] + + The ontology has been successfully downloaded. + Now we'll also build a custom ontology based on your data! + + [ONTOLOGY_DOWNLOADED]" + + # Ontology builder agent - creates ontology components + ontology_builder: + type: llm + config: + provider: openai + model: gpt-4o-mini + temperature: 0.6 + memory_enabled: true + max_history: 50 + max_tokens: 4000 + system_prompt: | + You are the ONTOLOGY_BUILDER agent in an ontology creation system. + + YOUR ROLE: Create comprehensive ontology components based on a confirmed domain, structured to match DBpedia format. You must be exhaustive, inferring all possible classes and relationships, not just the most obvious ones. + + REVIEW CONVERSATION FOR: + - Confirmed domain name + - File content analysis + - Domain characteristics + - Existing ontology (if this is an expansion/improvement request) + + IF THIS IS AN EXPANSION REQUEST (user asked to add more entities and relations): + - First check if there are more entities that can be extracted from the domain that are not already in the existing extracted ontology. + - Review the original file content to identify missing entity types (especially abstract, process, or role-based types). + - Only extract/build if there are genuinely more entities available. + - If no more entities can be extracted, output: "No more entities can be extracted from this domain. The ontology is complete. [NO_MORE_ENTITIES]" + + CRITICAL WORKFLOW - FOLLOW THIS THREE-PHASE APPROACH: + + PHASE 0: DOMAIN CONCEPTUALIZATION & BRAINSTORMING (Internal Monologue) + - Before extracting, first, *think* about the domain. What *kinds* of things, people, processes, and concepts exist within it? + - Brainstorm categories of entities *beyond* standard NER (Person, Location, Organization). + - CRITICAL: You MUST consider these categories: + - **Abstract Concepts:** e.g., `Strategy`, `Policy`, `Diagnosis`, `Risk`, `Goal`, `Theory` + - **Processes/Workflows:** e.g., `ManufacturingProcess`, `AdmissionProcess`, `VerificationStep`, `Transaction` + - **Roles:** e.g., `Manager`, `Patient`, `Administrator`, `Customer`, `Participant` + - **Events:** e.g., `Meeting`, `Purchase`, `Conference`, `Accident`, `Publication` + - **Documents/Data:** e.g., `Report`, `Invoice`, `MedicalRecord`, `Dataset`, `Contract` + - **Physical/Digital Objects:** e.g., `Product`, `Equipment`, `Software`, `Vehicle`, `Building` + - **Collections/Groups:** e.g., `Team`, `Department`, `Project`, `Inventory` + - This brainstorming phase will inform your extraction in Phase 1. + + PHASE 1: COMPLETE ENTITY EXTRACTION + - Based on your Phase 0 brainstorming, extract ALL possible entity types (Classes) from the domain. + - Identify every class/concept mentioned *or implied* in the content. + - Include both general (parent) and specific (child) entity types. + - Ensure comprehensive coverage. Iterate and refine this list until no more concepts can be reasonably inferred from the domain. + - Create a complete list of ALL classes before moving to Phase 2. + + PHASE 2: COMPREHENSIVE RELATION EXTRACTION + - For EACH class, systematically identify ALL possible relationships with OTHER classes (including itself). + - Use this **SYSTEMATIC RELATION-FINDING METHOD:** + 1. **Iterate Per Class:** For *each* class in your final list (e.g., `ClassA`)... + 2. **Relational Matrix (Cross-Check):** ...systematically compare it against *EVERY OTHER* class (e.g., `ClassB`), including itself. + 3. **Ask Key Questions:** For each `[ClassA, ClassB]` pair, ask: + * `How does ClassA interact with ClassB?` (e.g., `manages`, `uses`, `affects`, `collaboratesWith`) + * `Is ClassA a part of ClassB?` (e.g., `partOf`, `memberOf`) + * `Does ClassA create/produce/own ClassB?` (e.g., `creates`, `produces`, `owns`, `authors`) + * `Is ClassA located in/near ClassB?` (e.g., `locatedIn`, `adjacentTo`, `contains`) + * `Does ClassA precede/follow ClassB (in time)?` (e.g., `precedes`, `follows`, `occursDuring`) + * `Does ClassA depend on/require ClassB?` (e.g., `dependsOn`, `requires`, `prerequisiteFor`) + * `Is ClassA a 'role' for ClassB?` (e.g., `hasRole`, `roleOf`) + 4. **Create Bidirectional Relations:** For every `relation(A, B)` you create (e.g., `partOf`), *immediately* create its logical inverse `inverseRelation(B, A)` (e.g., `hasPart`). (e.g., `worksFor`/`employs`, `manages`/`managedBy`, `contains`/`locatedIn`). + - CRITICAL REQUIREMENTS: + * Create relations for EVERY class - no class should be left without relations. + * One class MUST have multiple relations with multiple other classes. + * The total number of relations MUST be **significantly GREATER** (e.g., 1.5x - 2x) than the number of classes. + * Think of all semantic connections: spatial, temporal, social, functional, hierarchical, logical, etc. + + CREATE THESE COMPONENTS (matching DBpedia structure): + + 1. **CLASSES (Entities) - EXTRACT ALL** + 2. **CLASS DESCRIPTIONS - FOR ALL CLASSES** + 3. **RELATIONS (Properties) - COMPREHENSIVE COVERAGE REQUIRED** + - ONLY Object properties: relationships between classes (Class to Class). + - NO Data properties allowed + 4. **RELATION DESCRIPTIONS - FOR ALL RELATIONS** + 5. **CLASSES HIERARCHY - COMPLETE HIERARCHY** + + OUTPUT FORMAT: + "ONTOLOGY CONSTRUCTION + + ## DOMAIN + [Confirmed domain name as single string] + + ## CLASSES (Complete List - ALL Entities Extracted) + - ClassName1 + - ClassName2 + (List ALL classes - be exhaustive) + + ## CLASS DESCRIPTIONS + - **ClassName1** + Description: [Clear description of the class] + (Continue for ALL classes) + + ## RELATIONS (ONLY Object Properties - Class to Class) + (CRITICAL: Number of relations MUST significantly exceed number of classes) + - **relationName1** + Domain: ClassName1 + Range: ClassName2 + Description: [Description of the relationship] + (Continue for ALL relations) + + ## RELATION DESCRIPTIONS + - **relationName1**: [Description] + (Continue for ALL relations) + + ## CLASSES HIERARCHY + - **ParentClass1** + Subclasses: SubClass1, SubClass2 + (Continue for all parent classes that have subclasses...) + + ## VERIFICATION SUMMARY + Total Classes: [count] + Total Relations: [count] (must be >> classes) + + [ONTOLOGY_BUILT]" + + IMPORTANT: + - Use PascalCase for class names + - Use camelCase for relation names + - ONLY create Object Properties (Class to Class relationships) + - DO NOT create Data Properties (no string, integer, float, etc. as range) + - CRITICAL: Extract ALL entities first (Phase 1), then create relations for ALL classes (Phase 2) + - CRITICAL: Number of relations MUST be significantly greater than number of classes + - CRITICAL: Every class must have at least one relation + - Be comprehensive, exhaustive, and structured! + + # Ontology verifier agent - verifies ontology against domain + ontology_verifier: + type: llm + config: + provider: openai + model: gpt-4o-mini + temperature: 0.4 + memory_enabled: true + max_history: 50 + max_tokens: 2000 + system_prompt: | + You are the ONTOLOGY_VERIFIER agent in an ontology creation system. + + YOUR ROLE: Verify that the built ontology aligns with the confirmed domain. + + VERIFICATION CHECKLIST: + ✅ **Completeness** + ✅ **Correctness** + ✅ **Consistency** + ✅ **Domain Alignment** + + OUTPUT FORMAT: + "🔍 ONTOLOGY VERIFICATION REPORT + + ## Verification Results + + ✅ **Completeness:** PASS + ✅ **Correctness:** PASS + âš ī¸ **Issues Found:** [None / List issues] + + ## Recommendations + - [Suggestion 1] + + ## Overall Assessment + The ontology is [ready/needs minor adjustments]. + + [VERIFICATION_COMPLETE]" + + Be thorough and constructive! + + # Improvement handler agent - asks for and processes improvements + improvement_handler: + type: llm + config: + provider: openai + model: gpt-4o-mini + temperature: 0.5 + memory_enabled: true + max_history: 50 + max_tokens: 2000 + system_prompt: | + You are the IMPROVEMENT_HANDLER agent in an ontology creation system. + + YOUR ROLE: Ask user for improvements or process their improvement requests. + + TWO MODES: + + MODE 1 - ASKING FOR IMPROVEMENTS (if no user request yet): + "đŸŽ¯ ONTOLOGY REVIEW + + The ontology has been created and verified. + + Would you like to make any improvements or additions? + + You can: + - Add more entities/classes (by saying 'add more' or 'extract more') + - Describe specific changes + - Say 'No improvements' or 'Looks good' to proceed + + [IMPROVEMENTS_REQUESTED]" + + MODE 2 - APPLYING IMPROVEMENTS (if user provided improvement request): + + IF user asks to "add more", "extract more", "add more entities": + "Processing request to add more entities. This will route back to the Ontology Builder. + [IMPROVEMENTS_APPLIED]" + (The coordinator will see this and route to CREATE_ONTOLOGY) + + IF user provides specific improvement request: + "🔧 ONTOLOGY IMPROVEMENTS + + **User Request:** [Summarize what user asked for] + + **Changes Made:** + [Detail the changes made to the ontology structure] + + [IMPROVEMENTS_APPLIED]" + + Use MODE 1 if conversation shows verification just completed. + Use MODE 2 if user has provided specific improvement request. + + # Final verifier agent - final validation + final_verifier: + type: llm + config: + provider: openai + model: gpt-4o-mini + temperature: 0.3 + memory_enabled: true + max_history: 50 + max_tokens: 2000 + system_prompt: | + You are the FINAL_VERIFIER agent in an ontology creation system. + + YOUR ROLE: Perform final comprehensive validation before JSON formatting. + + FINAL CHECKS: + ✅ **Structure Validation** + ✅ **Semantic Validation** + ✅ **Completeness Check** + ✅ **JSON Readiness (DBpedia Format)** + + OUTPUT FORMAT: + "✅ FINAL VERIFICATION + + ## Validation Status: PASSED ✓ + + ### Structure: ✓ Valid + ### Semantics: ✓ Sound + ### Completeness: ✓ Complete + + ## Summary + The ontology is ready to be formatted as JSON. + + [FINAL_VERIFICATION_PASSED]" + + Be confident and conclusive! + + # JSON formatter agent - converts to JSON format + json_formatter: + type: llm + config: + provider: openai + model: gpt-4o-mini + temperature: 0.2 + memory_enabled: true + max_history: 50 + max_tokens: 4000 + system_prompt: | + You are the JSON_FORMATTER agent in an ontology creation system. + + YOUR ROLE: Convert the verified ontology to the EXACT DBpedia JSON format structure. + + INSTRUCTIONS: + 1. Review complete ontology from conversation history + 2. Generate valid JSON following the EXACT structure below + + EXACT JSON STRUCTURE (must match dbpedia-v4.json format EXACTLY): + + ```json + { + "Domain": "domain_name", + "Classes": [ + "ClassName1", + "ClassName2" + ], + "Classes_descriptions": { + "ClassName1": { + "Description": "Description of ClassName1" + } + }, + "Relations": { + "relationName1": [ + ["DomainClass1", "RangeClass1"] + ] + }, + "Relations_descriptions": { + "relationName1": { + "Description": "Description of relation1" + } + }, + "Classes_hierarchy": { + "ParentClass1": [ + "SubClass1" + ] + } + } + ``` + + NOTE: "Classes_hierarchy" must ALWAYS be included. + - If no hierarchies: use "Classes_hierarchy": {} (empty object) + - Never omit the "Classes_hierarchy" key. + + KEY REQUIREMENTS: + 1. **"Domain"**: Single string + 2. **"Classes"**: Flat array of ALL class name strings + 3. **"Classes_descriptions"**: Object, "ClassName": { "Description": "..." } + 4. **"Relations"**: Object, "relationName": [["DomainClass", "RangeClass"]] + - CRITICAL: Range must ALWAYS be a Class name (PascalCase), NEVER a data type. + - ONLY Object Properties - NO Data Properties. + 5. **"Relations_descriptions"**: Object, "relationName": { "Description": "..." } + 6. **"Classes_hierarchy"**: Object, "ParentClass": ["SubClass1"] + - MUST be present, even if empty {}. + + CRITICAL RULES: + - Use PascalCase for class names + - Use camelCase for relation names + - Relations: ONLY Object Properties (Class to Class) + - Ensure ALL 6 keys are present. + + OUTPUT FORMAT: + "📄 JSON FORMATTED ONTOLOGY + + ```json + {Complete JSON content here - must be valid JSON} + ``` + + **Statistics:** + - Domain: [domain name] + - Classes: [count] + - Relations: [count] + + [JSON_FORMATTING_COMPLETE]" + + # JSON path confirmer - asks user to confirm JSON file path before RDF conversion + json_path_confirmer: + type: llm + config: + provider: openai + model: gpt-4o-mini + temperature: 0.3 + system_prompt: | + You are the JSON_PATH_CONFIRMER agent. + + YOUR ROLE: Ask user to provide the JSON file path or URL to be converted to RDF. + + INSTRUCTIONS: + 1. Ask the user for the path to the JSON file they want to convert. + 2. Suggest the default 'ontology_output.json' if it was just saved. + + OUTPUT FORMAT: + "📄 JSON to RDF Conversion + + To convert the JSON ontology file to RDF format, I need the file path or URL. + + **Please enter the path or URL of the JSON file** + (e.g., 'ontology_output.json', '/home/user/my_ontology.json', or a URL) + + Enter the file path or URL now: + + [JSON_PATH_CONFIRMATION_REQUESTED]" + + Be clear and wait for user to provide the file path/URL! + + # JSON Saver agent - saves the formatted JSON + json_saver: + type: llm + config: + provider: openai + model: gpt-4o-mini + temperature: 0.1 + system_prompt: | + You are the JSON_SAVER agent. + + YOUR ROLE: Find the JSON content from the `json_formatter` in history and output a `file_write` tool call to save it as 'ontology_output.json'. + + INSTRUCTIONS: + 1. Look back in the conversation history for the `json_formatter`'s output. + 2. Find the message containing "JSON FORMATTED ONTOLOGY" and the ```json code block. + 3. Extract the ENTIRE, COMPLETE JSON content from inside that code block (from the first { to the last }). + 4. Output *only* the `file_write` tool command. + + CRITICAL: The JSON content must be properly escaped within the tool call's "content" field. + + EXAMPLE OUTPUT (This is your ONLY output): + {"tool": "file_write", "args": {"file": "ontology_output.json", "content": "{\n \"Domain\": \"...\",\n \"Classes\": [...],\n ... (THE ENTIRE ESCAPED JSON) ...\n}", "mode": "w"}} + + # RDF Converter agent - converts JSON to RDF + rdf_converter: + type: llm + config: + provider: openai + model: gpt-4o-mini + temperature: 0.1 + system_prompt: | + You are the RDF_CONVERTER agent. + + YOUR ROLE: The user just provided a JSON file path. Output a `json_to_rdf` tool call. + + INSTRUCTIONS: + 1. Look at the user's last message to find the file path they provided. + 2. Extract that EXACT file path. + 3. Output *only* the `json_to_rdf` tool command. + 4. The output file name should be 'ontology_rdf.rdf'. + + EXAMPLE OUTPUT (This is your ONLY output): + {"tool": "json_to_rdf", "args": {"input_file": "USER_PROVIDED_PATH_HERE", "output_file": "ontology_rdf.rdf", "format": "xml", "base_uri": "[http://example.org/ontology#](http://example.org/ontology#)"}} + + # Ontology merge path requester - asks user for base ontology and generated ontology paths + ontology_merge_path_requester: + type: llm + config: + provider: openai + model: gpt-4o-mini + temperature: 0.3 + system_prompt: | + You are the ONTOLOGY_MERGE_PATH_REQUESTER agent. + + YOUR ROLE: Ask user for the file paths needed for merging ontologies. + + INSTRUCTIONS: + 1. Ask user to provide two JSON file paths: + - Base ontology path (e.g., dbpedia-v4.json) + - Generated ontology path (e.g., ontology_output.json) + + OUTPUT FORMAT: + "🔄 Ontology Merging + + To merge your generated ontology with a base ontology, please provide the following **JSON file paths**: + + 1. **Base Ontology Path (JSON):** (The existing ontology file) + 2. **Generated Ontology Path (JSON):** (The new ontology file, e.g., ontology_output.json) + + Please provide both JSON file paths in this format: + Base: [base_ontology_json_path] + Generated: [generated_ontology_json_path] + + [MERGE_PATHS_REQUESTED]" + + Be clear and wait for user to provide both file paths! + + # Ontology merger agent - merges two ontologies without duplicates + ontology_merger: + type: llm + config: + provider: openai + model: gpt-4o-mini + temperature: 0.2 + memory_enabled: true + max_history: 50 + max_tokens: 4000 + system_prompt: | + You are the ONTOLOGY_MERGER agent. + + YOUR ROLE: Use the merge_ontologies tool to merge two ontology JSON files, OR convert a merged JSON to RDF. + + WORKFLOW: + + IF routed for MERGE: + STEP 1: Extract file paths from conversation (e.g., "Base: ... Generated: ..."). + STEP 2: Call merge_ontologies tool: + {"tool": "merge_ontologies", "args": {"base_file": "BASE_PATH_OR_URL", "generated_file": "GENERATED_PATH_OR_URL", "output_file": "merged_ontology.json"}} + STEP 3: After tool executes, ask for verification: + "🔄 ONTOLOGY MERGING COMPLETE + [Include the merging summary from tool output] + **Merged Ontology Saved:** merged_ontology.json + Please verify if the merged ontology is correct. + - 'Yes' or 'Approve' to save the merged JSON. + - 'No' or 'Reject' to re-enter the merge paths. + [ONTOLOGY_MERGED]" + + IF routed for CONVERT MERGED TO RDF: + STEP 1: Extract file path from user's last message. + STEP 2: Call json_to_rdf tool: + {"tool": "json_to_rdf", "args": {"input_file": "USER_PROVIDED_MERGED_PATH", "output_file": "merged_ontology.rdf", "format": "xml", "base_uri": "[http://example.org/ontology#](http://example.org/ontology#)"}} + + # Merged JSON path confirmer - asks for merged JSON file path for RDF conversion + merged_json_path_confirmer: + type: llm + config: + provider: openai + model: gpt-4o-mini + temperature: 0.3 + system_prompt: | + You are the MERGED_JSON_PATH_CONFIRMER agent. + + YOUR ROLE: Ask user for the merged JSON file path/URL for RDF conversion. + + OUTPUT FORMAT: + "📄 Merged JSON to RDF Conversion + + To convert the merged JSON ontology file to RDF, I need the file path. + + **Please enter the path or URL of the merged JSON file** + (Default is 'merged_ontology.json') + + Enter the file path or URL now (or press Enter to use default): + + [MERGED_JSON_PATH_CONFIRMATION_REQUESTED]" + + # Save merged JSON agent - saves merged JSON file + save_merged_json: + type: llm + config: + provider: openai + model: gpt-4o-mini + temperature: 0.1 + system_prompt: | + You are the SAVE_MERGED_JSON agent. + + YOUR ROLE: The file 'merged_ontology.json' was created by the merge tool. Save it. + + INSTRUCTIONS: + 1. Output a `file_read` tool call for 'merged_ontology.json'. + 2. After read, output a `file_write` tool call to save 'merged_ontology.json'. + + STEP 1 OUTPUT: + {"tool": "file_read", "args": {"file": "merged_ontology.json"}} + + STEP 2 OUTPUT (after file_read completes): + {"tool": "file_write", "args": {"file": "merged_ontology.json", "content": "THE_MERGED_JSON_CONTENT", "mode": "w"}} + + After saving, output the final message: + "✅ Merged JSON file saved successfully: merged_ontology.json + + [MERGED_JSON_SAVED]" + + # Tool agents for file operations + file_reader_tool: + type: tool + config: + tools: ["file_read"] + safe_mode: true + + file_writer_tool: + type: tool + config: + tools: ["file_write"] + safe_mode: false + + json_to_rdf_tool: + type: tool + config: + tools: ["json_to_rdf"] + safe_mode: false + + merge_ontologies_tool: + type: tool + config: + tools: ["merge_ontologies"] + safe_mode: false + + web_search_tool: + type: tool + config: + tools: ["web_search"] + safe_mode: true + timeout: 10 + + http_request_tool: + type: tool + config: + tools: ["http_request"] + safe_mode: true + timeout: 30 + +# Routes section - defines the workflow +routes: + # Main LangGraph workflow + ontology_workflow: + type: graph + entry_point: start + checkpointing: true + + nodes: + # Coordinator routes workflow + coordinate: + type: agent + agent: coordinator + + # Greet user + greet: + type: agent + agent: greeter + + # Analyze files + analyze_files: + type: agent + agent: file_analyzer + + # Extract domain + extract_domain: + type: agent + agent: domain_extractor + + # Validate domain + validate_domain: + type: agent + agent: domain_validator + + # Search web for existing ontologies + search_web: + type: agent + agent: web_searcher + + # Select ontology from options + select_ontology: + type: agent + agent: ontology_selector + + # Download selected ontology + download_ontology: + type: agent + agent: ontology_downloader + + # Execute web search tool + execute_web_search: + type: agent + agent: web_search_tool + + # Execute HTTP request tool + execute_http_request: + type: agent + agent: http_request_tool + + # Build ontology + build_ontology: + type: agent + agent: ontology_builder + + # Verify ontology + verify_ontology: + type: agent + agent: ontology_verifier + + # Handle improvements + handle_improvements: + type: agent + agent: improvement_handler + + # Final verification + final_verify: + type: agent + agent: final_verifier + + # Format as JSON + format_json: + type: agent + agent: json_formatter + + # Confirm JSON file path + confirm_json_path: + type: agent + agent: json_path_confirmer + + # Save JSON file + save_json: + type: agent + agent: json_saver + + # Convert JSON to RDF + convert_to_rdf: + type: agent + agent: rdf_converter + + # Request merge paths + request_merge_paths: + type: agent + agent: ontology_merge_path_requester + + # Merge ontologies + merge_ontologies: + type: agent + agent: ontology_merger + + # Confirm merged JSON path for RDF conversion + confirm_merged_json_path: + type: agent + agent: merged_json_path_confirmer + + # Save merged JSON + save_merged_json_node: + type: agent + agent: save_merged_json + + # Execute file read + read_file: + type: agent + agent: file_reader_tool + + # Execute file write + write_file: + type: agent + agent: file_writer_tool + + # Convert JSON to RDF tool + convert_to_rdf_tool: + type: agent + agent: json_to_rdf_tool + + # Merge ontologies tool + merge_ontologies_tool_node: + type: agent + agent: merge_ontologies_tool + + edges: + # Start with coordinator + - source: start + target: coordinate + + # Coordinator routes to all agents + - source: coordinate + target: greet + condition: + type: content_contains + text: "[ROUTE:GREET]" + - source: coordinate + target: analyze_files + condition: + type: content_contains + text: "[ROUTE:READ_FILE]" + - source: coordinate + target: extract_domain + condition: + type: content_contains + text: "[ROUTE:ANALYZE_DOMAIN]" + - source: coordinate + target: validate_domain + condition: + type: content_contains + text: "[ROUTE:VALIDATE_DOMAIN]" + - source: coordinate + target: search_web + condition: + type: content_contains + text: "[ROUTE:SEARCH_WEB]" + - source: coordinate + target: select_ontology + condition: + type: content_contains + text: "[ROUTE:SELECT_ONTOLOGY]" + - source: coordinate + target: download_ontology + condition: + type: content_contains + text: "[ROUTE:DOWNLOAD_ONTOLOGY]" + - source: coordinate + target: build_ontology + condition: + type: content_contains + text: "[ROUTE:CREATE_ONTOLOGY]" + - source: coordinate + target: verify_ontology + condition: + type: content_contains + text: "[ROUTE:VERIFY_ONTOLOGY]" + - source: coordinate + target: handle_improvements + condition: + type: content_contains + text: "[ROUTE:HANDLE_IMPROVEMENTS]" + - source: coordinate + target: final_verify + condition: + type: content_contains + text: "[ROUTE:FINAL_VERIFY]" + - source: coordinate + target: format_json + condition: + type: content_contains + text: "[ROUTE:FORMAT_JSON]" + - source: coordinate + target: save_json + condition: + type: content_contains + text: "[ROUTE:SAVE_JSON]" + - source: coordinate + target: confirm_json_path + condition: + type: content_contains + text: "[ROUTE:CONFIRM_JSON_PATH]" + - source: coordinate + target: convert_to_rdf + condition: + type: content_contains + text: "[ROUTE:CONVERT_TO_RDF]" + - source: coordinate + target: request_merge_paths + condition: + type: content_contains + text: "[ROUTE:REQUEST_MERGE_PATHS]" + - source: coordinate + target: merge_ontologies + condition: + type: content_contains + text: "[ROUTE:MERGE_ONTOLOGIES]" + - source: coordinate + target: save_merged_json_node + condition: + type: content_contains + text: "[ROUTE:SAVE_MERGED_JSON]" + - source: coordinate + target: confirm_merged_json_path + condition: + type: content_contains + text: "[ROUTE:CONFIRM_MERGED_JSON_PATH]" + - source: coordinate + target: merge_ontologies # Re-using merger agent for tool call + condition: + type: content_contains + text: "[ROUTE:CONVERT_MERGED_TO_RDF]" + - source: coordinate + target: end + condition: + type: content_contains + text: "[ROUTE:END]" + + # All agents return to coordinator + - source: greet + target: coordinate + - source: analyze_files + target: coordinate + - source: extract_domain + target: coordinate + - source: validate_domain + target: coordinate + - source: select_ontology + target: coordinate + - source: build_ontology + target: coordinate + - source: verify_ontology + target: coordinate + - source: handle_improvements + target: coordinate + - source: final_verify + target: coordinate + - source: format_json + target: coordinate + - source: confirm_json_path + target: coordinate + - source: request_merge_paths + target: coordinate + - source: confirm_merged_json_path + target: coordinate + + # Tool-running agent flows + + # Web Search + - source: search_web + target: execute_web_search + condition: + type: content_contains + text: '"tool": "web_search"' + - source: execute_web_search + target: coordinate + - source: search_web + target: coordinate # Fallback + + # Download + - source: download_ontology + target: execute_http_request + condition: + type: content_contains + text: '"tool": "http_request"' + - source: download_ontology + target: write_file + condition: + type: content_contains + text: '"tool": "file_write"' + - source: execute_http_request + target: download_ontology # Return to downloader to save + - source: download_ontology + target: coordinate # Fallback + + # Save JSON + - source: save_json + target: write_file + condition: + type: content_contains + text: '"tool": "file_write"' + - source: write_file # After any write + target: coordinate + + # Convert RDF + - source: convert_to_rdf + target: convert_to_rdf_tool + condition: + type: content_contains + text: '"tool": "json_to_rdf"' + - source: convert_to_rdf_tool + target: coordinate + + # Merge + - source: merge_ontologies + target: merge_ontologies_tool_node + condition: + type: content_contains + text: '"tool": "merge_ontologies"' + - source: merge_ontologies + target: convert_to_rdf_tool # For converting merged + condition: + type: content_contains + text: '"tool": "json_to_rdf"' + - source: merge_ontologies_tool_node + target: merge_ontologies # Return to merger to ask for verify + - source: merge_ontologies + target: coordinate # Fallback + + # Save Merged JSON + - source: save_merged_json_node + target: read_file + condition: + type: content_contains + text: '"tool": "file_read"' + - source: save_merged_json_node + target: write_file + condition: + type: content_contains + text: '"tool": "file_write"' + - source: read_file + target: save_merged_json_node + - source: save_merged_json_node + target: coordinate # Fallback + + # Stream executor for the graph + graph_executor: + type: stream + stream_type: cold + operators: + - type: graph_execute + params: + graph: ontology_workflow + publications: + - __output__ + +# Merge input to graph executor +merges: + - sources: [__input__] + target: graph_executor + +# Global context +context: + global: + app_name: "CleverAgents Ontology Creator" + version: "1.2" + unsafe: true + log_level: "INFO" \ No newline at end of file diff --git a/src/cleveragents/agents/tool.py b/src/cleveragents/agents/tool.py index ac56c6ef9..905ecbbdb 100644 --- a/src/cleveragents/agents/tool.py +++ b/src/cleveragents/agents/tool.py @@ -11,12 +11,34 @@ import logging import os import re import subprocess -from typing import Any +from pathlib import Path +from typing import Any, TYPE_CHECKING from typing import List from typing import Optional, Union, Literal +from urllib.parse import quote import aiohttp +if TYPE_CHECKING: + from rdflib import Graph, Namespace, URIRef, Literal as RDFLiteral, BNode + from rdflib.namespace import RDF, RDFS, XSD +else: + Graph = Any + Namespace = Any + URIRef = Any + RDFLiteral = Any + BNode = Any + RDF = Any + RDFS = Any + XSD = Any + +try: + from rdflib import Graph, Namespace, URIRef, Literal as RDFLiteral, BNode # noqa: F401 + from rdflib.namespace import RDF, RDFS, XSD # noqa: F401 + RDFLIB_AVAILABLE = True +except ImportError: + RDFLIB_AVAILABLE = False + from cleveragents.agents.base import Agent from cleveragents.core.exceptions import AgentCreationError from cleveragents.core.exceptions import ExecutionError @@ -62,6 +84,9 @@ class ToolAgent(Agent): "http_request": self._http_request_tool, "file_read": self._file_read_tool, "file_write": self._file_write_tool, + "json_to_rdf": self._json_to_rdf_tool, + "web_search": self._web_search_tool, + "merge_ontologies": self._merge_ontologies_tool, } # Validate tools @@ -97,15 +122,35 @@ class ToolAgent(Agent): message_stripped = message.strip() # If message looks like JSON (starts with { and ends with }), try to parse it + # Also try with all whitespace removed (in case of formatting issues) if message_stripped.startswith("{") and message_stripped.endswith("}"): try: parsed = json.loads(message_stripped) if isinstance(parsed, dict): return parsed # Not a dict, fall through to extraction logic - except json.JSONDecodeError: + except json.JSONDecodeError as e: + # Try with whitespace normalization + try: + normalized = " ".join(message_stripped.split()) + parsed = json.loads(normalized) + if isinstance(parsed, dict): + return parsed + except (json.JSONDecodeError, ValueError): + pass + # If it starts with { but parsing fails, maybe there's trailing content + # Try to find the last } and parse up to that point + if message_stripped.count('{') > 0: + last_brace = message_stripped.rfind('}') + if last_brace > 0: + try: + truncated = message_stripped[:last_brace + 1] + parsed = json.loads(truncated) + if isinstance(parsed, dict): + return parsed + except json.JSONDecodeError: + pass # Not valid complete JSON, fall through to extraction logic - pass # Try to extract JSON from markdown code blocks # Pattern: ```json ... ``` or ``` ... ``` @@ -119,16 +164,196 @@ class ToolAgent(Agent): except json.JSONDecodeError: pass # Fall through - # Try to find any JSON object in the message - json_pattern = r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}' - match = re.search(json_pattern, message, re.DOTALL) - if match: + # Try to find JSON by looking for the tool command pattern + # This is more reliable for tool commands + tool_pattern = r'\{\s*"tool"\s*:' + tool_match = re.search(tool_pattern, message) + if tool_match: + # Found potential tool command, try to extract complete JSON from this point + start_idx = tool_match.start() + # Use JSON decoder to find the end of the JSON object + decoder = json.JSONDecoder() try: - parsed = json.loads(match.group(0)) - if isinstance(parsed, dict): - return parsed - except json.JSONDecodeError: - pass # Already handled by fall-through + # Get the substring from the start of the JSON object + json_str = message[start_idx:] + # Skip any leading whitespace + idx = 0 + while idx < len(json_str) and json_str[idx].isspace(): + idx += 1 + # Make sure we start with a brace + if idx < len(json_str) and json_str[idx] == '{': + # Use raw_decode to parse the JSON object and find where it ends + # This handles escaped content properly + obj, end_idx = decoder.raw_decode(json_str, idx) + if isinstance(obj, dict) and "tool" in obj: + return obj + except json.JSONDecodeError as e: + # Log the error with more context + error_msg = str(e) + logger.debug("JSON decoder failed at tool pattern (pos %d): %s. Message snippet: %s", + start_idx, e, message[start_idx:start_idx+300]) + + # Check if it's an unterminated string error + if "Unterminated string" in error_msg or "Expecting" in error_msg: + # Try to fix common escaping issues with content field + try: + # Sometimes the content field has unescaped newlines or quotes, or is truncated + # Try to extract and fix the JSON structure + content_match = re.search(r'"content"\s*:\s*"', json_str[idx:]) + if content_match: + content_start_pos = idx + content_match.end() + + # For unterminated strings, the content is likely truncated + # Try multiple strategies to recover: + + # Strategy 1: Try to find ", "mode" pattern (content is complete but improperly escaped) + mode_pattern = r'",\s*"mode"\s*:\s*"[^"]*"\s*\}' + mode_match = re.search(mode_pattern, json_str[content_start_pos:]) + + if mode_match: + # Found mode field - content ends before it + content_end_pos = content_start_pos + mode_match.start() + raw_content = json_str[content_start_pos:content_end_pos] + + # Properly escape the content using json.dumps for correct escaping + try: + # Try to unescape and re-escape properly + # First, try to decode what we have + unescaped = raw_content.encode().decode('unicode_escape') + # Now properly escape it using json.dumps + properly_escaped = json.dumps(unescaped)[1:-1] # Remove outer quotes + except (UnicodeDecodeError, ValueError): + # If that fails, do manual escaping + properly_escaped = raw_content.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n').replace('\r', '\\r').replace('\t', '\\t') + + # Reconstruct JSON + json_prefix = json_str[idx:content_start_pos] + json_suffix = json_str[content_end_pos:] + fixed_json = json_prefix + properly_escaped + json_suffix + + try: + obj = json.loads(fixed_json) + if isinstance(obj, dict) and "tool" in obj: + logger.debug("Successfully parsed JSON after fixing content escaping") + return obj + except json.JSONDecodeError: + pass + + # Strategy 2: Content is truncated - try to find mode field and reconstruct + potential_end = json_str.find('"mode"', content_start_pos) + if potential_end > 0: + # Extract what we have of the content (might be truncated) + raw_content = json_str[content_start_pos:potential_end-3] # -3 for ", " + + # Try to find the original JSON in the full message + # Look for ```json code blocks that might contain the original + json_block_pattern = r'```json\s*(\{.*?\})\s*```' + json_block_match = re.search(json_block_pattern, message, re.DOTALL) + + if json_block_match: + # Found original JSON - use it properly escaped + original_json_str = json_block_match.group(1) + try: + # Validate it's proper JSON + json.loads(original_json_str) + # Properly escape it using json.dumps + properly_escaped = json.dumps(original_json_str)[1:-1] # Remove outer quotes + + # Reconstruct command + json_prefix = json_str[idx:content_start_pos] + mode_value_match = re.search(r'"mode"\s*:\s*"([^"]*)"', json_str[potential_end:]) + mode_value = mode_value_match.group(1) if mode_value_match else "w" + file_match = re.search(r'"file"\s*:\s*"([^"]*)"', json_str[idx:]) + file_value = file_match.group(1) if file_match else "ontology_json.json" + + # Reconstruct the complete command + fixed_json = f'{{"tool": "file_write", "args": {{"file": "{file_value}", "content": "{properly_escaped}", "mode": "{mode_value}"}}}}' + + try: + obj = json.loads(fixed_json) + if isinstance(obj, dict) and "tool" in obj: + logger.info("Successfully reconstructed JSON command from original JSON in message") + return obj + except json.JSONDecodeError: + pass + except json.JSONDecodeError: + pass + + # If no JSON block found, try manual escaping of what we have + escaped_content = raw_content.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n').replace('\r', '\\r').replace('\t', '\\t') + json_prefix = json_str[idx:content_start_pos] + mode_value_match = re.search(r'"mode"\s*:\s*"([^"]*)"', json_str[potential_end:]) + if mode_value_match: + mode_value = mode_value_match.group(1) + fixed_json = json_prefix + escaped_content + '", "mode": "' + mode_value + '"}' + try: + obj = json.loads(fixed_json) + if isinstance(obj, dict) and "tool" in obj: + logger.debug("Successfully parsed JSON after reconstructing with mode field") + return obj + except json.JSONDecodeError: + pass + except (json.JSONDecodeError, ValueError, AttributeError, IndexError) as fix_error: + logger.debug("Failed to fix JSON escaping: %s", fix_error) + pass + + # Try to find where the error occurred and maybe fix it + # Sometimes the issue is trailing content - try to find the last valid brace + try: + # Try to find the last closing brace before the error position + # This handles cases where there's extra text after the JSON + last_brace = json_str.rfind('}', 0, len(json_str)) + if last_brace > idx: + truncated = json_str[:last_brace + 1] + obj = json.loads(truncated[idx:]) + if isinstance(obj, dict) and "tool" in obj: + logger.debug("Successfully parsed JSON after truncating trailing content") + return obj + except (json.JSONDecodeError, ValueError): + pass + except (ValueError, IndexError) as e: + logger.debug("JSON decoder error at tool pattern: %s", e) + pass + + # Fallback: Try to find any JSON object by finding matching braces + # This handles nested objects and strings with escaped content + brace_count = 0 + start_idx = -1 + in_string = False + escape_next = False + + for i, char in enumerate(message): + if escape_next: + escape_next = False + continue + + if char == '\\' and in_string: + escape_next = True + continue + + if char == '"' and not escape_next: + in_string = not in_string + continue + + if not in_string: + if char == '{': + if start_idx == -1: + start_idx = i + brace_count += 1 + elif char == '}': + brace_count -= 1 + if brace_count == 0 and start_idx != -1: + # Found complete JSON object + json_str = message[start_idx:i + 1] + try: + parsed = json.loads(json_str) + if isinstance(parsed, dict): + return parsed + except json.JSONDecodeError: + pass # Try next occurrence + # Reset for next potential JSON object + start_idx = -1 + brace_count = 0 return None @@ -151,7 +376,11 @@ class ToolAgent(Agent): try: # Check if message looks like JSON but might be invalid message_stripped = message.strip() - looks_like_json = message_stripped.startswith("{") and message_stripped.endswith("}") + # Check if message contains JSON (starts with { after whitespace) + looks_like_json = ( + message_stripped.startswith("{") or + "{" in message_stripped[:10] # JSON starts within first 10 chars + ) # Try to extract JSON tool request from message tool_request = self._extract_json_from_message(message) @@ -164,11 +393,105 @@ class ToolAgent(Agent): # Execute the tool result = await self._execute_tool(tool_name, tool_args, context) return str(result) + else: + raise ExecutionError("JSON tool request missing 'tool' field") elif looks_like_json: - # Message looks like JSON but couldn't be parsed - report as invalid JSON - raise ExecutionError("Invalid JSON in tool request") + # Message looks like JSON but couldn't be parsed - try one more time with raw_decode + # This handles cases where there's trailing content after the JSON + try: + decoder = json.JSONDecoder() + # Find the first { + first_brace = message_stripped.find('{') + if first_brace >= 0: + obj, _ = decoder.raw_decode(message_stripped, first_brace) + if isinstance(obj, dict) and "tool" in obj: + # Success! Execute the tool + tool_name = obj.get("tool") + tool_args = obj.get("args", {}) + result = await self._execute_tool(tool_name, tool_args, context) + return str(result) + except (json.JSONDecodeError, ValueError, IndexError) as decode_err: + # Special handling for file_write with truncated content + error_str = str(decode_err) + if "file_write" in message_stripped and ("Unterminated string" in error_str or "Expecting" in error_str): + # Try to extract original JSON from message or conversation history + original_json = None + + # Strategy 1: Look for JSON in the current message + json_block_pattern = r'```json\s*(\{.*?\})\s*```' + json_block_match = re.search(json_block_pattern, message, re.DOTALL) + if json_block_match: + try: + candidate = json_block_match.group(1) + json.loads(candidate) # Validate + original_json = candidate + except json.JSONDecodeError: + pass + + # Strategy 2: Look in conversation history from context + if not original_json and context and "conversation_history" in context: + history = context["conversation_history"] + # Search backwards through history for json_formatter output + for msg in reversed(history): + content = msg.get("content", "") + if "```json" in content or "[JSON_FORMATTING_COMPLETE]" in content: + json_match = re.search(json_block_pattern, content, re.DOTALL) + if json_match: + try: + candidate = json_match.group(1) + json.loads(candidate) # Validate + original_json = candidate + logger.debug("Found original JSON in conversation history") + break + except json.JSONDecodeError: + pass + + # If we found original JSON, reconstruct the command + if original_json: + try: + # Extract file and mode from the broken command + file_match = re.search(r'"file"\s*:\s*"([^"]*)"', message_stripped) + mode_match = re.search(r'"mode"\s*:\s*"([^"]*)"', message_stripped) + file_value = file_match.group(1) if file_match else "ontology_json.json" + mode_value = mode_match.group(1) if mode_match else "w" + + # Properly escape the JSON content using json.dumps + properly_escaped = json.dumps(original_json)[1:-1] # Remove outer quotes + + # Reconstruct the command + reconstructed = f'{{"tool": "file_write", "args": {{"file": "{file_value}", "content": "{properly_escaped}", "mode": "{mode_value}"}}}}' + + try: + obj = json.loads(reconstructed) + if isinstance(obj, dict) and "tool" in obj: + logger.info("Successfully reconstructed file_write command from original JSON") + tool_name = obj.get("tool") + tool_args = obj.get("args", {}) + result = await self._execute_tool(tool_name, tool_args, context) + return str(result) + except json.JSONDecodeError as recon_err: + logger.debug("Failed to reconstruct command: %s", recon_err) + except (AttributeError, IndexError, ValueError) as extract_err: + logger.debug("Failed to extract file/mode from broken command: %s", extract_err) + + # Log the actual decoding error for debugging + msg_preview = message_stripped[:300] + ("..." if len(message_stripped) > 300 else "") + logger.error("Failed to parse JSON tool request. Decode error: %s. Message preview: %s", + decode_err, msg_preview) + raise ExecutionError( + f"Invalid JSON in tool request - could not parse JSON object. " + f"Error: {decode_err}. Message starts with: {message_stripped[:150]}" + ) from decode_err + # If we get here, raw_decode didn't work either + msg_preview = message_stripped[:300] + ("..." if len(message_stripped) > 300 else "") + logger.error("Failed to parse JSON tool request after all attempts. Message preview: %s", msg_preview) + raise ExecutionError( + f"Invalid JSON in tool request - could not parse JSON object. " + f"Message starts with: {message_stripped[:150]}" + ) # Fall back to simple format: "tool_name arg1 arg2" + # Only do this if message doesn't look like JSON parts = message_stripped.split() if not parts: raise ExecutionError("Empty tool request") @@ -578,6 +901,445 @@ class ToolAgent(Agent): logger.error("File write failed for %s: %s", filepath, e) raise ExecutionError(f"File write failed: {e}") from e + def _json_to_rdf_converter( + self, data: Any, base_uri: str = "http://example.org/", graph: Optional[Graph] = None + ) -> Graph: + """Convert JSON data to RDF graph.""" + if not RDFLIB_AVAILABLE: + raise ExecutionError("rdflib is not installed. Install it with: pip install rdflib") + + if graph is None: + graph = Graph() + + base_ns = Namespace(base_uri) + graph.bind("", base_ns) + graph.bind("rdf", RDF) + graph.bind("rdfs", RDFS) + graph.bind("xsd", XSD) + + def sanitize_key(key: str) -> str: + return quote(str(key).replace(" ", "_").replace("/", "_"), safe="") + + def convert_literal(value: Any) -> Union[RDFLiteral, URIRef]: + if isinstance(value, bool): + return RDFLiteral(value, datatype=XSD.boolean) + elif isinstance(value, int): + return RDFLiteral(value, datatype=XSD.integer) + elif isinstance(value, float): + return RDFLiteral(value, datatype=XSD.double) + elif isinstance(value, str): + if value.startswith("http://") or value.startswith("https://"): + return URIRef(value) + return RDFLiteral(value, datatype=XSD.string) + else: + return RDFLiteral(str(value), datatype=XSD.string) + + def process_value(parent_uri: str, key: str, value: Any) -> None: + parent_ref = URIRef(parent_uri) + safe_key = sanitize_key(key) + predicate = base_ns[safe_key] + + if isinstance(value, dict): + subject = base_ns[f"{parent_uri.split('#')[-1]}_{safe_key}"] + graph.add((parent_ref, predicate, subject)) + graph.add((subject, RDF.type, RDFS.Resource)) + if key: + graph.add((subject, RDFS.label, RDFLiteral(key, datatype=XSD.string))) + + for sub_key, sub_value in value.items(): + process_value(str(subject), sub_key, sub_value) + + elif isinstance(value, list): + list_node = BNode() + graph.add((parent_ref, predicate, list_node)) + graph.add((list_node, RDF.type, RDF.Seq)) + + for idx, item in enumerate(value): + seq_prop = URIRef(f"http://www.w3.org/1999/02/22-rdf-syntax-ns#_{idx + 1}") + if isinstance(item, dict): + item_uri = base_ns[f"{parent_uri.split('#')[-1]}_{safe_key}_{idx}"] + graph.add((list_node, seq_prop, item_uri)) + graph.add((item_uri, RDF.type, RDFS.Resource)) + for sub_key, sub_value in item.items(): + process_value(str(item_uri), sub_key, sub_value) + else: + graph.add((list_node, seq_prop, convert_literal(item))) + + else: + obj = convert_literal(value) + graph.add((parent_ref, predicate, obj)) + + root_uri = base_ns["root"] + + if isinstance(data, dict): + graph.add((root_uri, RDF.type, RDFS.Resource)) + for key, value in data.items(): + process_value(str(root_uri), key, value) + elif isinstance(data, list): + graph.add((root_uri, RDF.type, RDF.Seq)) + for idx, item in enumerate(data): + seq_prop = URIRef(f"http://www.w3.org/1999/02/22-rdf-syntax-ns#_{idx + 1}") + if isinstance(item, dict): + item_uri = base_ns[f"item_{idx}"] + graph.add((root_uri, seq_prop, item_uri)) + graph.add((item_uri, RDF.type, RDFS.Resource)) + for sub_key, sub_value in item.items(): + process_value(str(item_uri), sub_key, sub_value) + else: + graph.add((root_uri, seq_prop, convert_literal(item))) + else: + graph.add((root_uri, RDF.type, RDFS.Resource)) + graph.add((root_uri, base_ns["value"], convert_literal(data))) + + return graph + + async def _json_to_rdf_tool( + self, args: dict[str, Any], context: Optional[dict[str, Any]] + ) -> str: + """Convert JSON file to RDF format.""" + if not RDFLIB_AVAILABLE: + raise ExecutionError("rdflib is not installed. Install it with: pip install rdflib") + + input_file = args.get("input_file", args.get("file", "")) + output_file = args.get("output_file", args.get("output", "")) + format_type = args.get("format", "xml") + base_uri = args.get("base_uri", "http://example.org/") + + if not input_file: + raise ExecutionError("json_to_rdf tool requires input_file parameter") + + unsafe_mode = context and context.get("_unsafe_mode", False) + if not unsafe_mode: + raise ExecutionError("json_to_rdf tool requires unsafe mode") + + input_path = Path(input_file) + if not input_path.exists(): + raise ExecutionError(f"Input file '{input_file}' does not exist") + + if not input_path.is_file(): + raise ExecutionError(f"'{input_file}' is not a file") + + try: + with open(input_path, "r", encoding="utf-8") as f: + json_data = json.load(f) + except json.JSONDecodeError as e: + raise ExecutionError(f"Invalid JSON in '{input_file}': {e}") from e + except Exception as e: + raise ExecutionError(f"Error reading file '{input_file}': {e}") from e + + try: + graph = self._json_to_rdf_converter(json_data, base_uri=base_uri) + except Exception as e: + raise ExecutionError(f"Error converting JSON to RDF: {e}") from e + + if output_file: + output_path = Path(output_file) + else: + format_extensions = { + "turtle": ".ttl", + "xml": ".rdf", + "json-ld": ".jsonld", + "nt": ".nt", + "n3": ".n3", + } + ext = format_extensions.get(format_type, ".rdf") + output_path = input_path.with_suffix(ext) + + self._validate_file_path_safety(str(output_path), unsafe_mode) + + try: + output_format_map = { + "turtle": "turtle", + "xml": "xml", + "json-ld": "json-ld", + "nt": "nt", + "n3": "n3", + } + + graph.serialize(destination=str(output_path), format=output_format_map.get(format_type, "xml")) + return f"Successfully converted '{input_file}' to '{output_path}' ({format_type} format)" + except Exception as e: + raise ExecutionError(f"Error writing output file '{output_path}': {e}") from e + + async def _web_search_tool( + self, args: dict[str, Any], context: Optional[dict[str, Any]] + ) -> str: + """Real-time web search tool using Serper API.""" + query = args.get("query", "") + if "args" in args and args["args"]: + query = " ".join(str(arg) for arg in args["args"]) + + if not query: + raise ExecutionError("Web search tool requires a query") + + serper_api_key = os.environ.get("SERPER_API_KEY") + if not serper_api_key: + raise ExecutionError( + "SERPER_API_KEY environment variable not set. " + "Get your API key from https://serper.dev" + ) + + url = "https://google.serper.dev/search" + headers = { + "X-API-KEY": serper_api_key, + "Content-Type": "application/json" + } + payload = {"q": query} + + try: + async with aiohttp.ClientSession() as session: + async with session.post( + url, headers=headers, json=payload, timeout=self.timeout + ) as response: + if response.status != 200: + error_text = await response.text() + raise ExecutionError( + f"Serper API request failed with status {response.status}: {error_text}" + ) + + result = await response.json() + + formatted_results = [] + formatted_results.append(f"Search results for: {query}\n") + + if "answerBox" in result: + answer_box = result["answerBox"] + formatted_results.append("=== Quick Answer ===") + if "answer" in answer_box: + formatted_results.append(f"Answer: {answer_box['answer']}") + if "snippet" in answer_box: + formatted_results.append(f"Snippet: {answer_box['snippet']}") + formatted_results.append("") + + if "organic" in result: + formatted_results.append("=== Top Results ===") + for idx, item in enumerate(result["organic"][:5], 1): + title = item.get("title", "No title") + link = item.get("link", "") + snippet = item.get("snippet", "") + formatted_results.append(f"{idx}. {title}") + formatted_results.append(f" URL: {link}") + if snippet: + formatted_results.append(f" {snippet}") + formatted_results.append("") + + if "relatedSearches" in result: + related = result["relatedSearches"][:3] + if related: + formatted_results.append("=== Related Searches ===") + for item in related: + formatted_results.append(f"- {item.get('query', '')}") + + return "\n".join(formatted_results) + + except aiohttp.ClientError as e: + raise ExecutionError(f"Web search request failed: {e}") from e + except Exception as e: + raise ExecutionError(f"Web search failed: {e}") from e + + async def _merge_ontologies_tool( + self, args: dict[str, Any], context: Optional[dict[str, Any]] + ) -> str: + """Merge two ontology JSON files into a single merged ontology.""" + base_file = args.get("base_file", args.get("base", "")) + generated_file = args.get("generated_file", args.get("generated", "")) + output_file = args.get("output_file", args.get("output", "merged_ontology.json")) + + if not base_file: + raise ExecutionError("merge_ontologies tool requires base_file parameter") + if not generated_file: + raise ExecutionError("merge_ontologies tool requires generated_file parameter") + + unsafe_mode = context and context.get("_unsafe_mode", False) + if not unsafe_mode: + raise ExecutionError("merge_ontologies tool requires unsafe mode") + + # Helper function to read JSON from file or URL + async def read_json_file(file_path: str) -> dict[str, Any]: + """Read JSON from file path or URL.""" + file_path_str = str(file_path) + + # Check if it's a URL + if file_path_str.startswith(("http://", "https://")): + async with aiohttp.ClientSession() as session: + async with session.get(file_path_str, timeout=self.timeout) as response: + if response.status != 200: + raise ExecutionError(f"Failed to fetch URL '{file_path_str}': HTTP {response.status}") + return await response.json() + else: + # It's a file path + file_path_obj = Path(file_path_str) + if not file_path_obj.exists(): + raise ExecutionError(f"File '{file_path_str}' does not exist") + if not file_path_obj.is_file(): + raise ExecutionError(f"'{file_path_str}' is not a file") + + try: + with open(file_path_obj, "r", encoding="utf-8") as f: + return json.load(f) + except json.JSONDecodeError as e: + raise ExecutionError(f"Invalid JSON in '{file_path_str}': {e}") from e + except Exception as e: + raise ExecutionError(f"Error reading file '{file_path_str}': {e}") from e + + # Read both JSON files + try: + base_ontology = await read_json_file(base_file) + generated_ontology = await read_json_file(generated_file) + except Exception as e: + raise ExecutionError(f"Error reading ontology files: {e}") from e + + # Validate structure + required_keys = ["Domain", "Classes", "Classes_descriptions", "Relations", "Relations_descriptions", "Classes_hierarchy"] + for key in required_keys: + if key not in base_ontology: + logger.warning(f"Base ontology missing key '{key}', using empty default") + if key == "Classes": + base_ontology[key] = [] + elif key == "Domain": + base_ontology[key] = "" + else: + base_ontology[key] = {} + if key not in generated_ontology: + logger.warning(f"Generated ontology missing key '{key}', using empty default") + if key == "Classes": + generated_ontology[key] = [] + elif key == "Domain": + generated_ontology[key] = "" + else: + generated_ontology[key] = {} + + # Merge Classes array (combine, remove duplicates) + base_classes = base_ontology.get("Classes", []) + generated_classes = generated_ontology.get("Classes", []) + merged_classes = list(base_classes) # Start with base classes + for class_name in generated_classes: + if class_name not in merged_classes: # Case-sensitive duplicate check + merged_classes.append(class_name) + + # Merge Classes_descriptions (prefer generated for conflicts) + merged_descriptions = {} + # Add all base descriptions first + for class_name, desc_obj in base_ontology.get("Classes_descriptions", {}).items(): + merged_descriptions[class_name] = desc_obj + # Override with generated descriptions (prefer generated) + for class_name, desc_obj in generated_ontology.get("Classes_descriptions", {}).items(): + merged_descriptions[class_name] = desc_obj + + # Merge Relations (combine domain-range pairs, remove duplicate pairs) + merged_relations = {} + # Add all base relations first + for relation_name, domain_range_pairs in base_ontology.get("Relations", {}).items(): + if not isinstance(domain_range_pairs, list): + continue + merged_relations[relation_name] = [pair.copy() for pair in domain_range_pairs] + + # Merge generated relations + for relation_name, domain_range_pairs in generated_ontology.get("Relations", {}).items(): + if not isinstance(domain_range_pairs, list): + continue + if relation_name in merged_relations: + # Merge domain-range pairs, remove duplicates + existing_pairs = merged_relations[relation_name] + for pair in domain_range_pairs: + if not isinstance(pair, list) or len(pair) != 2: + continue + # Check if this pair already exists (exact match) + pair_tuple = tuple(pair) + if not any(tuple(existing_pair) == pair_tuple for existing_pair in existing_pairs): + existing_pairs.append(pair) + else: + # New relation, add it + merged_relations[relation_name] = [pair.copy() for pair in domain_range_pairs] + + # Merge Relations_descriptions (prefer generated for conflicts) + merged_relation_descriptions = {} + # Add all base relation descriptions first + for relation_name, desc_obj in base_ontology.get("Relations_descriptions", {}).items(): + merged_relation_descriptions[relation_name] = desc_obj + # Override with generated descriptions (prefer generated) + for relation_name, desc_obj in generated_ontology.get("Relations_descriptions", {}).items(): + merged_relation_descriptions[relation_name] = desc_obj + + # Merge Classes_hierarchy (combine subclasses arrays, remove duplicates) + merged_hierarchy = {} + # Add all base hierarchies first + for parent_class, subclasses in base_ontology.get("Classes_hierarchy", {}).items(): + if not isinstance(subclasses, list): + continue + merged_hierarchy[parent_class] = list(subclasses) + + # Merge generated hierarchies + for parent_class, subclasses in generated_ontology.get("Classes_hierarchy", {}).items(): + if not isinstance(subclasses, list): + continue + if parent_class in merged_hierarchy: + # Merge subclasses arrays, remove duplicates + existing_subclasses = merged_hierarchy[parent_class] + for subclass in subclasses: + if subclass not in existing_subclasses: + existing_subclasses.append(subclass) + else: + # New parent class, add it + merged_hierarchy[parent_class] = list(subclasses) + + # Merge Domain (prefer base, fallback to generated) + merged_domain = base_ontology.get("Domain", "") + if not merged_domain: + merged_domain = generated_ontology.get("Domain", "") + + # Create merged ontology + merged_ontology = { + "Domain": merged_domain, + "Classes": merged_classes, + "Classes_descriptions": merged_descriptions, + "Relations": merged_relations, + "Relations_descriptions": merged_relation_descriptions, + "Classes_hierarchy": merged_hierarchy, + } + + # Save to output file if provided + if output_file: + output_path = Path(output_file) + self._validate_file_path_safety(str(output_path), unsafe_mode) + + try: + with open(output_path, "w", encoding="utf-8") as f: + json.dump(merged_ontology, f, indent=4, ensure_ascii=False) + + # Calculate statistics + base_class_count = len(base_ontology.get("Classes", [])) + generated_class_count = len(generated_ontology.get("Classes", [])) + merged_class_count = len(merged_classes) + duplicates_removed = base_class_count + generated_class_count - merged_class_count + + base_relation_count = len(base_ontology.get("Relations", {})) + generated_relation_count = len(generated_ontology.get("Relations", {})) + merged_relation_count = len(merged_relations) + + base_hierarchy_count = len(base_ontology.get("Classes_hierarchy", {})) + generated_hierarchy_count = len(generated_ontology.get("Classes_hierarchy", {})) + merged_hierarchy_count = len(merged_hierarchy) + + return ( + f"Successfully merged ontologies and saved to '{output_file}'\n" + f"**Merging Summary:**\n" + f"- Base Classes: {base_class_count}\n" + f"- Generated Classes: {generated_class_count}\n" + f"- Merged Classes: {merged_class_count} (duplicates removed: {duplicates_removed})\n" + f"- Base Relations: {base_relation_count}\n" + f"- Generated Relations: {generated_relation_count}\n" + f"- Merged Relations: {merged_relation_count}\n" + f"- Base Hierarchies: {base_hierarchy_count}\n" + f"- Generated Hierarchies: {generated_hierarchy_count}\n" + f"- Merged Hierarchies: {merged_hierarchy_count}" + ) + except Exception as e: + raise ExecutionError(f"Error writing merged ontology to '{output_file}': {e}") from e + else: + # Return merged JSON as string + return json.dumps(merged_ontology, indent=4, ensure_ascii=False) + def get_capabilities(self) -> List[str]: """Get the capabilities of the tool agent.""" capabilities = ["tool-execution", "command-execution"] @@ -599,6 +1361,9 @@ class ToolAgent(Agent): if "math" in self.tools or "math" in dict_tool_names: capabilities.append("math-evaluation") + if "web_search" in self.tools or "web_search" in dict_tool_names: + capabilities.append("web-search") + return capabilities def get_metadata(self) -> dict[str, Any]: