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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user