feat: Implement Trillium notes executor for searching and creating notes via ETAPI
- Added `trillium.py` for searching and creating notes with Trillium's ETAPI. - Implemented `search_notes` and `create_note` functions with appropriate error handling and validation. feat: Add web search functionality using DuckDuckGo - Introduced `web_search.py` for performing web searches without API keys. - Implemented `search_web` function with result handling and validation. feat: Create provider-agnostic function caller for iterative tool calling - Developed `function_caller.py` to manage LLM interactions with tools. - Implemented iterative calling logic with error handling and tool execution. feat: Establish a tool registry for managing available tools - Created `registry.py` to define and manage tool availability and execution. - Integrated feature flags for enabling/disabling tools based on environment variables. feat: Implement event streaming for tool calling processes - Added `stream_events.py` to manage Server-Sent Events (SSE) for tool calling. - Enabled real-time updates during tool execution for enhanced user experience. test: Add tests for tool calling system components - Created `test_tools.py` to validate functionality of code execution, web search, and tool registry. - Implemented asynchronous tests to ensure proper execution and result handling. chore: Add Dockerfile for sandbox environment setup - Created `Dockerfile` to set up a Python environment with necessary dependencies for code execution. chore: Add debug regex script for testing XML parsing - Introduced `debug_regex.py` to validate regex patterns against XML tool calls. chore: Add HTML template for displaying thinking stream events - Created `test_thinking_stream.html` for visualizing tool calling events in a user-friendly format. test: Add tests for OllamaAdapter XML parsing - Developed `test_ollama_parser.py` to validate XML parsing with various test cases, including malformed XML.
This commit is contained in:
13
cortex/autonomy/tools/adapters/__init__.py
Normal file
13
cortex/autonomy/tools/adapters/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Provider adapters for tool calling."""
|
||||
|
||||
from .base import ToolAdapter
|
||||
from .openai_adapter import OpenAIAdapter
|
||||
from .ollama_adapter import OllamaAdapter
|
||||
from .llamacpp_adapter import LlamaCppAdapter
|
||||
|
||||
__all__ = [
|
||||
"ToolAdapter",
|
||||
"OpenAIAdapter",
|
||||
"OllamaAdapter",
|
||||
"LlamaCppAdapter",
|
||||
]
|
||||
79
cortex/autonomy/tools/adapters/base.py
Normal file
79
cortex/autonomy/tools/adapters/base.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Base adapter interface for provider-agnostic tool calling.
|
||||
|
||||
This module defines the abstract base class that all LLM provider adapters
|
||||
must implement to support tool calling in Lyra.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
class ToolAdapter(ABC):
|
||||
"""Base class for provider-specific tool adapters.
|
||||
|
||||
Each LLM provider (OpenAI, Ollama, llama.cpp, etc.) has its own
|
||||
way of handling tool calls. This adapter pattern allows Lyra to
|
||||
support tools across all providers with a unified interface.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def prepare_request(
|
||||
self,
|
||||
messages: List[Dict],
|
||||
tools: List[Dict],
|
||||
tool_choice: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Convert Lyra tool definitions to provider-specific format.
|
||||
|
||||
Args:
|
||||
messages: Conversation history in OpenAI format
|
||||
tools: List of Lyra tool definitions (provider-agnostic)
|
||||
tool_choice: Optional tool forcing ("auto", "required", "none")
|
||||
|
||||
Returns:
|
||||
dict: Provider-specific request payload ready to send to LLM
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def parse_response(self, response) -> Dict:
|
||||
"""Extract tool calls from provider response.
|
||||
|
||||
Args:
|
||||
response: Raw provider response (format varies by provider)
|
||||
|
||||
Returns:
|
||||
dict: Standardized response in Lyra format:
|
||||
{
|
||||
"content": str, # Assistant's text response
|
||||
"tool_calls": [ # List of tool calls or None
|
||||
{
|
||||
"id": str, # Unique call ID
|
||||
"name": str, # Tool name
|
||||
"arguments": dict # Tool arguments
|
||||
}
|
||||
] or None
|
||||
}
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def format_tool_result(
|
||||
self,
|
||||
tool_call_id: str,
|
||||
tool_name: str,
|
||||
result: Dict
|
||||
) -> Dict:
|
||||
"""Format tool execution result for next LLM call.
|
||||
|
||||
Args:
|
||||
tool_call_id: ID from the original tool call
|
||||
tool_name: Name of the executed tool
|
||||
result: Tool execution result dictionary
|
||||
|
||||
Returns:
|
||||
dict: Message object to append to conversation
|
||||
(format varies by provider)
|
||||
"""
|
||||
pass
|
||||
17
cortex/autonomy/tools/adapters/llamacpp_adapter.py
Normal file
17
cortex/autonomy/tools/adapters/llamacpp_adapter.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
llama.cpp adapter for tool calling.
|
||||
|
||||
Since llama.cpp has similar constraints to Ollama (no native function calling),
|
||||
this adapter reuses the XML-based approach from OllamaAdapter.
|
||||
"""
|
||||
|
||||
from .ollama_adapter import OllamaAdapter
|
||||
|
||||
|
||||
class LlamaCppAdapter(OllamaAdapter):
|
||||
"""llama.cpp adapter - uses same XML approach as Ollama.
|
||||
|
||||
llama.cpp doesn't have native function calling support, so we use
|
||||
the same XML-based prompt engineering approach as Ollama.
|
||||
"""
|
||||
pass
|
||||
191
cortex/autonomy/tools/adapters/ollama_adapter.py
Normal file
191
cortex/autonomy/tools/adapters/ollama_adapter.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
Ollama adapter for tool calling using XML-structured prompts.
|
||||
|
||||
Since Ollama doesn't have native function calling, this adapter uses
|
||||
XML-based prompts to instruct the model how to call tools.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
from .base import ToolAdapter
|
||||
|
||||
|
||||
class OllamaAdapter(ToolAdapter):
|
||||
"""Ollama adapter using XML-structured prompts for tool calling.
|
||||
|
||||
This adapter injects tool descriptions into the system prompt and
|
||||
teaches the model to respond with XML when it wants to use a tool.
|
||||
"""
|
||||
|
||||
SYSTEM_PROMPT = """You have access to the following tools:
|
||||
|
||||
{tool_descriptions}
|
||||
|
||||
To use a tool, respond with XML in this exact format:
|
||||
<tool_call>
|
||||
<name>tool_name</name>
|
||||
<arguments>
|
||||
<arg_name>value</arg_name>
|
||||
</arguments>
|
||||
<reason>why you're using this tool</reason>
|
||||
</tool_call>
|
||||
|
||||
You can call multiple tools by including multiple <tool_call> blocks.
|
||||
If you don't need to use any tools, respond normally without XML.
|
||||
After tools are executed, you'll receive results and can continue the conversation."""
|
||||
|
||||
async def prepare_request(
|
||||
self,
|
||||
messages: List[Dict],
|
||||
tools: List[Dict],
|
||||
tool_choice: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Inject tool descriptions into system prompt.
|
||||
|
||||
Args:
|
||||
messages: Conversation history
|
||||
tools: Lyra tool definitions
|
||||
tool_choice: Ignored for Ollama (no native support)
|
||||
|
||||
Returns:
|
||||
dict: Request payload with modified messages
|
||||
"""
|
||||
# Format tool descriptions
|
||||
tool_desc = "\n".join([
|
||||
f"- {t['name']}: {t['description']}\n Parameters: {self._format_parameters(t['parameters'], t.get('required', []))}"
|
||||
for t in tools
|
||||
])
|
||||
|
||||
system_msg = self.SYSTEM_PROMPT.format(tool_descriptions=tool_desc)
|
||||
|
||||
# Check if first message is already a system message
|
||||
modified_messages = messages.copy()
|
||||
if modified_messages and modified_messages[0].get("role") == "system":
|
||||
# Prepend tool instructions to existing system message
|
||||
modified_messages[0]["content"] = system_msg + "\n\n" + modified_messages[0]["content"]
|
||||
else:
|
||||
# Add new system message at the beginning
|
||||
modified_messages.insert(0, {"role": "system", "content": system_msg})
|
||||
|
||||
return {"messages": modified_messages}
|
||||
|
||||
def _format_parameters(self, parameters: Dict, required: List[str]) -> str:
|
||||
"""Format parameters for tool description.
|
||||
|
||||
Args:
|
||||
parameters: Parameter definitions
|
||||
required: List of required parameter names
|
||||
|
||||
Returns:
|
||||
str: Human-readable parameter description
|
||||
"""
|
||||
param_strs = []
|
||||
for name, spec in parameters.items():
|
||||
req_marker = "(required)" if name in required else "(optional)"
|
||||
param_strs.append(f"{name} {req_marker}: {spec.get('description', '')}")
|
||||
return ", ".join(param_strs)
|
||||
|
||||
async def parse_response(self, response) -> Dict:
|
||||
"""Extract tool calls from XML in response.
|
||||
|
||||
Args:
|
||||
response: String response from Ollama
|
||||
|
||||
Returns:
|
||||
dict: Standardized Lyra format with content and tool_calls
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Ollama returns a string
|
||||
if isinstance(response, dict):
|
||||
content = response.get("message", {}).get("content", "")
|
||||
else:
|
||||
content = str(response)
|
||||
|
||||
logger.info(f"🔍 OllamaAdapter.parse_response: content length={len(content)}, has <tool_call>={('<tool_call>' in content)}")
|
||||
logger.debug(f"🔍 Content preview: {content[:500]}")
|
||||
|
||||
# Parse XML tool calls
|
||||
tool_calls = []
|
||||
if "<tool_call>" in content:
|
||||
# Split content by <tool_call> to get each block
|
||||
blocks = content.split('<tool_call>')
|
||||
logger.info(f"🔍 Split into {len(blocks)} blocks")
|
||||
|
||||
# First block is content before any tool calls
|
||||
clean_parts = [blocks[0]]
|
||||
|
||||
for idx, block in enumerate(blocks[1:]): # Skip first block (pre-tool content)
|
||||
# Extract tool name
|
||||
name_match = re.search(r'<name>(.*?)</name>', block)
|
||||
if not name_match:
|
||||
logger.warning(f"Block {idx} has no <name> tag, skipping")
|
||||
continue
|
||||
|
||||
name = name_match.group(1).strip()
|
||||
arguments = {}
|
||||
|
||||
# Extract arguments
|
||||
args_match = re.search(r'<arguments>(.*?)</arguments>', block, re.DOTALL)
|
||||
if args_match:
|
||||
args_xml = args_match.group(1)
|
||||
# Parse <key>value</key> pairs
|
||||
arg_pairs = re.findall(r'<(\w+)>(.*?)</\1>', args_xml, re.DOTALL)
|
||||
arguments = {k: v.strip() for k, v in arg_pairs}
|
||||
|
||||
tool_calls.append({
|
||||
"id": f"call_{idx}",
|
||||
"name": name,
|
||||
"arguments": arguments
|
||||
})
|
||||
|
||||
# For clean content, find what comes AFTER the tool call block
|
||||
# Look for the last closing tag (</tool_call> or malformed </xxx>) and keep what's after
|
||||
# Split by any closing tag at the END of the tool block
|
||||
remaining = block
|
||||
# Remove everything up to and including a standalone closing tag
|
||||
# Pattern: find </something> that's not followed by more XML
|
||||
end_match = re.search(r'</[a-z_]+>\s*(.*)$', remaining, re.DOTALL)
|
||||
if end_match:
|
||||
after_content = end_match.group(1).strip()
|
||||
if after_content and not after_content.startswith('<'):
|
||||
# Only keep if it's actual text content, not more XML
|
||||
clean_parts.append(after_content)
|
||||
|
||||
clean_content = ''.join(clean_parts).strip()
|
||||
else:
|
||||
clean_content = content
|
||||
|
||||
return {
|
||||
"content": clean_content,
|
||||
"tool_calls": tool_calls if tool_calls else None
|
||||
}
|
||||
|
||||
def format_tool_result(
|
||||
self,
|
||||
tool_call_id: str,
|
||||
tool_name: str,
|
||||
result: Dict
|
||||
) -> Dict:
|
||||
"""Format tool result as XML for next prompt.
|
||||
|
||||
Args:
|
||||
tool_call_id: ID from the original tool call
|
||||
tool_name: Name of the executed tool
|
||||
result: Tool execution result
|
||||
|
||||
Returns:
|
||||
dict: Message in user role with XML-formatted result
|
||||
"""
|
||||
# Format result as XML
|
||||
result_xml = f"""<tool_result>
|
||||
<tool>{tool_name}</tool>
|
||||
<result>{json.dumps(result, ensure_ascii=False)}</result>
|
||||
</tool_result>"""
|
||||
|
||||
return {
|
||||
"role": "user",
|
||||
"content": result_xml
|
||||
}
|
||||
130
cortex/autonomy/tools/adapters/openai_adapter.py
Normal file
130
cortex/autonomy/tools/adapters/openai_adapter.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
OpenAI adapter for tool calling using native function calling API.
|
||||
|
||||
This adapter converts Lyra tool definitions to OpenAI's function calling
|
||||
format and parses OpenAI responses back to Lyra's standardized format.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, List, Optional
|
||||
from .base import ToolAdapter
|
||||
|
||||
|
||||
class OpenAIAdapter(ToolAdapter):
|
||||
"""OpenAI-specific adapter using native function calling.
|
||||
|
||||
OpenAI supports function calling natively through the 'tools' parameter
|
||||
in chat completions. This adapter leverages that capability.
|
||||
"""
|
||||
|
||||
async def prepare_request(
|
||||
self,
|
||||
messages: List[Dict],
|
||||
tools: List[Dict],
|
||||
tool_choice: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Convert Lyra tools to OpenAI function calling format.
|
||||
|
||||
Args:
|
||||
messages: Conversation history
|
||||
tools: Lyra tool definitions
|
||||
tool_choice: "auto", "required", "none", or None
|
||||
|
||||
Returns:
|
||||
dict: Request payload with OpenAI-formatted tools
|
||||
"""
|
||||
# Convert Lyra tools → OpenAI function calling format
|
||||
openai_tools = []
|
||||
for tool in tools:
|
||||
openai_tools.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool["name"],
|
||||
"description": tool["description"],
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": tool["parameters"],
|
||||
"required": tool.get("required", [])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
payload = {
|
||||
"messages": messages,
|
||||
"tools": openai_tools
|
||||
}
|
||||
|
||||
# Add tool_choice if specified
|
||||
if tool_choice:
|
||||
if tool_choice == "required":
|
||||
payload["tool_choice"] = "required"
|
||||
elif tool_choice == "none":
|
||||
payload["tool_choice"] = "none"
|
||||
else: # "auto" or default
|
||||
payload["tool_choice"] = "auto"
|
||||
|
||||
return payload
|
||||
|
||||
async def parse_response(self, response) -> Dict:
|
||||
"""Extract tool calls from OpenAI response.
|
||||
|
||||
Args:
|
||||
response: OpenAI ChatCompletion response object
|
||||
|
||||
Returns:
|
||||
dict: Standardized Lyra format with content and tool_calls
|
||||
"""
|
||||
message = response.choices[0].message
|
||||
content = message.content if message.content else ""
|
||||
tool_calls = []
|
||||
|
||||
# Check if response contains tool calls
|
||||
if hasattr(message, 'tool_calls') and message.tool_calls:
|
||||
for tc in message.tool_calls:
|
||||
try:
|
||||
# Parse arguments (may be JSON string)
|
||||
args = tc.function.arguments
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
|
||||
tool_calls.append({
|
||||
"id": tc.id,
|
||||
"name": tc.function.name,
|
||||
"arguments": args
|
||||
})
|
||||
except json.JSONDecodeError as e:
|
||||
# If arguments can't be parsed, include error
|
||||
tool_calls.append({
|
||||
"id": tc.id,
|
||||
"name": tc.function.name,
|
||||
"arguments": {},
|
||||
"error": f"Failed to parse arguments: {str(e)}"
|
||||
})
|
||||
|
||||
return {
|
||||
"content": content,
|
||||
"tool_calls": tool_calls if tool_calls else None
|
||||
}
|
||||
|
||||
def format_tool_result(
|
||||
self,
|
||||
tool_call_id: str,
|
||||
tool_name: str,
|
||||
result: Dict
|
||||
) -> Dict:
|
||||
"""Format tool result as OpenAI tool message.
|
||||
|
||||
Args:
|
||||
tool_call_id: ID from the original tool call
|
||||
tool_name: Name of the executed tool
|
||||
result: Tool execution result
|
||||
|
||||
Returns:
|
||||
dict: Message in OpenAI tool message format
|
||||
"""
|
||||
return {
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"name": tool_name,
|
||||
"content": json.dumps(result, ensure_ascii=False)
|
||||
}
|
||||
Reference in New Issue
Block a user