feat: add a new tool to create json to rdf formatting, add a new ontology creation agent.

This commit is contained in:
2025-11-18 19:33:14 +05:30
parent 6fff3968a6
commit 55d6e2d757
2 changed files with 2155 additions and 15 deletions
File diff suppressed because it is too large Load Diff
+780 -15
View File
@@ -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]: