feat: add a new tool to create json to rdf formatting, add a new ontology creation agent.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+780
-15
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user