Compare commits

...
1 Commits
Author SHA1 Message Date
Mervin Praison c86f660f66 Release v2.6.3 2025-12-28 07:31:42 +00:00
57 changed files with 3825 additions and 112 deletions

No files matched your search

+12 -8
View File
@@ -1994,19 +1994,23 @@ manager.delete_checkpoint("deploy-v1")
### Tools CLI:
```bash
# List all available tools
praisonai tools list
# Get info about a specific tool
praisonai tools info internet_search
# Search for tools
praisonai tools search "web"
# Show all commands
praisonai tools help
praisonai tools doctor
praisonai tools resolve shell_tool
praisonai tools discover
praisonai tools show-sources
praisonai tools show-sources --template ai-video-editor
```
| Command | Example | Docs |
|---------|---------|------|
| `tools list` | [example](examples/tools/) | [docs](https://docs.praison.ai/docs/cli/tools) |
| `tools resolve` | [example](examples/tools/example_tools_resolve.py) | [docs](https://docs.praison.ai/docs/cli/tools-resolve) |
| `tools discover` | [example](examples/tools/example_tools_discover.py) | [docs](https://docs.praison.ai/docs/cli/tools-discover) |
| `tools show-sources` | [example](examples/tools/example_tools_sources.py) | [docs](https://docs.praison.ai/docs/cli/tools-show-sources) |
### Handoff CLI:
```bash
# Enable agent-to-agent task delegation
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.6.2" \
"praisonai>=2.6.3" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.6.2" \
"praisonai>=2.6.3" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.6.2" \
"praisonai>=2.6.3" \
"praisonai[ui]" \
"praisonai[crewai]"
+53
View File
@@ -0,0 +1,53 @@
# Templates CLI
```bash
praisonai templates list
```
```bash
praisonai templates search video
```
```bash
praisonai templates info ai-video-editor
```
```bash
praisonai templates run ai-video-editor --input video.mp4 --output edited.mp4
```
```bash
praisonai templates run ai-video-editor --input video.mp4 --output edited.mp4 --offline
```
```bash
praisonai templates run ai-video-editor --input video.mp4 --output edited.mp4 --strict-tools
```
```bash
praisonai templates run ai-video-editor --input video.mp4 --tools ./my_tools.py
```
```bash
praisonai templates run ai-video-editor --input video.mp4 --tools-dir ./tools
```
```bash
praisonai templates run ai-video-editor --input video.mp4 --tools-source praisonai_tools.video
```
```bash
praisonai templates run ai-video-editor --input video.mp4 --no-template-tools-py
```
```bash
praisonai templates init my-project --template ai-video-editor
```
```bash
praisonai templates cache clear
```
```bash
praisonai templates cache list
```
+104
View File
@@ -0,0 +1,104 @@
# Templates Module
## Loading Templates
```python
from praisonai.templates.loader import TemplateLoader
loader = TemplateLoader()
# Load from local path
template = loader.load("./my-template")
# Load from URI
template = loader.load("ai-video-editor")
# Load offline
template = loader.load("ai-video-editor", offline=True)
# Check requirements
missing = loader.check_requirements(template)
```
## Template Discovery
```python
from praisonai.templates.discovery import TemplateDiscovery
discovery = TemplateDiscovery(
include_package=True,
include_defaults=True,
)
# Find template
result = discovery.find_template("ai-video-editor")
# List all templates
templates = discovery.list_templates()
```
## Template Config
```python
from praisonai.templates.loader import TemplateConfig
# Access template properties
template.name
template.description
template.version
template.author
template.requires
template.workflow_file
template.agents_file
template.path
```
## Tool Override Integration
```python
from praisonai.templates.tool_override import (
create_tool_registry_with_overrides,
resolve_tools,
)
# Build registry with template sources
tools_sources = template.requires.get("tools_sources", [])
registry = create_tool_registry_with_overrides(
tools_sources=tools_sources,
template_dir=str(template.path),
include_defaults=True,
)
# Resolve tool names
tools = resolve_tools(["shell_tool"], registry=registry)
```
## TEMPLATE.yaml Schema
```yaml
name: my-template
version: "1.0.0"
description: Template description
requires:
tools: [shell_tool]
packages: [praisonai-tools]
env: [OPENAI_API_KEY]
tools_sources:
- praisonai_tools.video
- ./local_tools.py
workflow: workflow.yaml
agents: agents.yaml
config:
input:
type: string
required: true
output:
type: string
defaults:
preset: podcast
```
+65
View File
@@ -0,0 +1,65 @@
# Tools CLI
```bash
praisonai tools list
```
```bash
praisonai tools info shell_tool
```
```bash
praisonai tools info internet_search
```
```bash
praisonai tools search "web"
```
```bash
praisonai tools search "file"
```
```bash
praisonai tools doctor
```
```bash
praisonai tools doctor --json
```
```bash
praisonai tools resolve shell_tool
```
```bash
praisonai tools resolve internet_search --template ai-video-editor
```
```bash
praisonai tools resolve my_tool --tools ./my_tools.py
```
```bash
praisonai tools resolve my_tool --tools-dir ./tools
```
```bash
praisonai tools discover
```
```bash
praisonai tools discover --include praisonai_tools.video
```
```bash
praisonai tools discover --entrypoints
```
```bash
praisonai tools show-sources
```
```bash
praisonai tools show-sources --template ai-video-editor
```
+90
View File
@@ -0,0 +1,90 @@
# Tools Module
## Tool Registry
```python
from praisonai.templates.tool_override import (
create_tool_registry_with_overrides,
resolve_tools,
)
# Create registry with defaults
registry = create_tool_registry_with_overrides(include_defaults=True)
# Create registry with overrides
registry = create_tool_registry_with_overrides(
override_files=["my_tools.py"],
override_dirs=["./tools"],
include_defaults=True,
)
# Create registry with tools_sources
registry = create_tool_registry_with_overrides(
tools_sources=["praisonai_tools.video"],
template_dir="./my-template",
include_defaults=True,
)
# Resolve tool names to callables
tools = resolve_tools(
["shell_tool", "internet_search"],
registry=registry,
)
```
## Tool Override Loader
```python
from praisonai.templates.tool_override import ToolOverrideLoader
loader = ToolOverrideLoader()
# Load from file
tools = loader.load_from_file("my_tools.py")
# Load from directory
tools = loader.load_from_directory("./tools")
# Load from module
tools = loader.load_from_module("praisonai_tools.video")
# Get default tool directories
dirs = loader.get_default_tool_dirs()
```
## Resolution Order
1. CLI `--tools` files (highest priority)
2. CLI `--tools-dir` directories
3. Template `tools_sources` (from TEMPLATE.yaml)
4. Template-local `tools.py`
5. Default dirs (`~/.praison/tools`, `~/.config/praison/tools`)
6. Package discovery (`praisonai_tools`)
7. Built-in tools (lowest priority)
## Creating Custom Tools
```python
from praisonai_tools import tool
@tool
def my_custom_tool(query: str) -> str:
"""My custom tool description.
Args:
query: The search query
"""
return f"Result for: {query}"
```
## Template tools_sources
```yaml
# TEMPLATE.yaml
requires:
tools: [shell_tool]
tools_sources:
- praisonai_tools.video
- ./local_tools.py
- ./tools_dir/
```
+25
View File
@@ -0,0 +1,25 @@
"""Example: Discover tools from installed packages."""
# Discover from praisonaiagents built-in tools
try:
from praisonaiagents.tools import TOOL_MAPPINGS
print("Built-in tools from praisonaiagents:")
for name in list(TOOL_MAPPINGS.keys())[:10]:
print(f" - {name}")
print(f" ... and {len(TOOL_MAPPINGS) - 10} more")
except ImportError:
print("praisonaiagents not installed")
# Discover from praisonai_tools
try:
import praisonai_tools
print("\nTools from praisonai_tools:")
# Check for video module
try:
from praisonai_tools import video
print(" - praisonai_tools.video (video editing)")
except ImportError:
pass
except ImportError:
print("praisonai_tools not installed")
+18
View File
@@ -0,0 +1,18 @@
"""Example: Resolve tool names to callable tools."""
from praisonai.templates.tool_override import (
create_tool_registry_with_overrides,
resolve_tools,
)
# Create registry with defaults
registry = create_tool_registry_with_overrides(include_defaults=True)
print(f"Registry contains {len(registry)} tools")
# Resolve tool names
tool_names = ["shell_tool", "internet_search"]
resolved = resolve_tools(tool_names, registry=registry)
print(f"\nResolved {len(resolved)} tools:")
for tool in resolved:
print(f" - {getattr(tool, '__name__', type(tool).__name__)}")
+14
View File
@@ -0,0 +1,14 @@
"""Example: Configure tool sources for templates."""
from praisonai.templates.tool_override import create_tool_registry_with_overrides
# Create registry with tools_sources
registry = create_tool_registry_with_overrides(
tools_sources=["praisonai_tools.video"],
include_defaults=True,
)
print(f"Registry with tools_sources: {len(registry)} tools")
# List some tools
for name in list(registry.keys())[:10]:
print(f" - {name}")
@@ -1745,7 +1745,11 @@ Your Goal: {self.goal}"""
# Special handling for MCP tools
# Check if tools is an MCP instance with the requested function name
from ..mcp.mcp import MCP
MCP = None
try:
from ..mcp.mcp import MCP
except ImportError:
pass # MCP not available
# Helper function to execute MCP tool
def _execute_mcp_tool(mcp_instance, func_name, args):
@@ -1780,7 +1784,7 @@ Your Goal: {self.goal}"""
return False, None
# Check if tools is a single MCP instance
if isinstance(self.tools, MCP):
if MCP is not None and isinstance(self.tools, MCP):
logging.debug(f"Looking for MCP tool {function_name}")
found, result = _execute_mcp_tool(self.tools, function_name, arguments)
if found:
@@ -1789,7 +1793,7 @@ Your Goal: {self.goal}"""
# Check if tools is a list that may contain MCP instances
if isinstance(self.tools, (list, tuple)):
for tool in self.tools:
if isinstance(tool, MCP):
if MCP is not None and isinstance(tool, MCP):
logging.debug(f"Looking for MCP tool {function_name} in MCP instance")
found, result = _execute_mcp_tool(tool, function_name, arguments)
if found:
@@ -2211,8 +2215,12 @@ Your Goal: {self.goal}"""
# Convert MCP tool objects to OpenAI format if needed
if tool_param is not None:
from ..mcp.mcp import MCP
if isinstance(tool_param, MCP) and hasattr(tool_param, 'to_openai_tool'):
MCP = None
try:
from ..mcp.mcp import MCP
except ImportError:
pass
if MCP is not None and isinstance(tool_param, MCP) and hasattr(tool_param, 'to_openai_tool'):
# Single MCP instance
logging.debug("Converting single MCP tool to OpenAI format")
openai_tool = tool_param.to_openai_tool()
@@ -2227,7 +2235,7 @@ Your Goal: {self.goal}"""
# List that may contain MCP instances - convert each MCP to OpenAI format
converted_tools = []
for t in tool_param:
if isinstance(t, MCP) and hasattr(t, 'to_openai_tool'):
if MCP is not None and isinstance(t, MCP) and hasattr(t, 'to_openai_tool'):
logging.debug("Converting MCP instance in list to OpenAI format")
openai_tools = t.to_openai_tool()
if isinstance(openai_tools, list):
@@ -3162,8 +3170,12 @@ Write the complete compiled report:"""
# Convert MCP tools if needed
if tool_param is not None:
from ..mcp.mcp import MCP
if isinstance(tool_param, MCP) and hasattr(tool_param, 'to_openai_tool'):
MCP = None
try:
from ..mcp.mcp import MCP
except ImportError:
pass
if MCP is not None and isinstance(tool_param, MCP) and hasattr(tool_param, 'to_openai_tool'):
openai_tool = tool_param.to_openai_tool()
if openai_tool:
if isinstance(openai_tool, list):
+219 -2
View File
@@ -104,6 +104,24 @@ class LLM:
OLLAMA_TOOL_USAGE_PROMPT = "Please analyze the request and use the available tools to help answer the question. Start by identifying what information you need."
OLLAMA_FINAL_ANSWER_PROMPT = "Based on the tool results above, please provide the final answer to the original question."
# Force tool usage prompt template (used when model ignores tools)
FORCE_TOOL_USAGE_PROMPT = """You MUST use one of the available tools to answer this question.
Do NOT provide a direct answer. Respond ONLY with a tool call.
Available tools: {tool_names}
Respond with a JSON object in this format:
{{"name": "tool_name", "arguments": {{"arg1": value1, "arg2": value2}}}}"""
# Tool call repair prompt template (used when tool call is malformed)
TOOL_CALL_REPAIR_PROMPT = """Your previous tool call was malformed: {error}
Please try again. Available tools:
{tool_schemas}
Respond with ONLY a valid JSON tool call in this format:
{{"name": "tool_name", "arguments": {{"arg1": value1, "arg2": value2}}}}"""
# Ollama iteration threshold for summary generation
OLLAMA_SUMMARY_ITERATION_THRESHOLD = 1
@@ -294,6 +312,16 @@ class LLM:
# Auto-detect XML tool format for known models, or allow manual override
self.xml_tool_format = extra_settings.get('xml_tool_format', 'auto')
# Tool calling reliability settings (for weak models like Ollama)
# These are auto-configured for Ollama providers if not explicitly set
self._max_tool_repairs_explicit = 'max_tool_repairs' in extra_settings
self._force_tool_usage_explicit = 'force_tool_usage' in extra_settings
self.max_tool_repairs = extra_settings.get('max_tool_repairs', 0) # Will be set to 2 for Ollama if not explicit
self.force_tool_usage = extra_settings.get('force_tool_usage', 'never') # Will be set to 'auto' for Ollama if not explicit
# Apply Ollama-specific defaults after model is set
self._apply_ollama_defaults()
# Token tracking
self.last_token_metrics: Optional[TokenMetrics] = None
self.session_token_metrics: Optional[TokenMetrics] = None
@@ -347,6 +375,16 @@ class LLM:
self._console = Console()
return self._console
def _apply_ollama_defaults(self):
"""Apply Ollama-specific defaults for tool calling reliability."""
if self._is_ollama_provider():
# Apply defaults only if not explicitly set by user
if not self._max_tool_repairs_explicit:
self.max_tool_repairs = 2
if not self._force_tool_usage_explicit:
self.force_tool_usage = 'auto'
logging.debug(f"[OLLAMA_RELIABILITY] Applied Ollama defaults: max_tool_repairs={self.max_tool_repairs}, force_tool_usage={self.force_tool_usage}")
def _is_ollama_provider(self) -> bool:
"""Detect if this is an Ollama provider regardless of naming convention"""
if not self.model:
@@ -562,14 +600,150 @@ class LLM:
def _format_ollama_tool_result_message(self, function_name: str, tool_result: Any) -> Dict[str, str]:
"""
Format tool result message for Ollama provider.
Simplified approach without hardcoded regex extraction.
Enhanced to instruct model to use the result for final answer.
"""
tool_result_str = str(tool_result)
return {
"role": "user",
"content": f"The {function_name} function returned: {tool_result_str}"
"content": f"""Tool execution complete.
Function: {function_name}
Result: {tool_result_str}
Now provide your final answer using this result."""
}
def _get_tool_names_for_prompt(self, formatted_tools: Optional[List]) -> str:
"""Extract tool names from formatted tools for prompts."""
if not formatted_tools:
return "None"
names = []
for tool in formatted_tools:
if isinstance(tool, dict) and 'function' in tool:
names.append(tool['function'].get('name', 'unknown'))
return ', '.join(names) if names else "None"
def _get_tool_schemas_for_prompt(self, formatted_tools: Optional[List]) -> str:
"""Generate compact tool schemas for repair prompts."""
if not formatted_tools:
return "None"
schemas = []
for tool in formatted_tools:
if isinstance(tool, dict) and 'function' in tool:
func = tool['function']
name = func.get('name', 'unknown')
params = func.get('parameters', {})
required = params.get('required', [])
props = params.get('properties', {})
param_strs = []
for pname, pinfo in props.items():
ptype = pinfo.get('type', 'any')
req = '*' if pname in required else ''
param_strs.append(f"{pname}{req}: {ptype}")
schemas.append(f"- {name}({', '.join(param_strs)})")
return '\n'.join(schemas) if schemas else "None"
def _should_force_tool_usage(self, response_text: str, tool_calls: Optional[List], formatted_tools: Optional[List], iteration_count: int) -> bool:
"""
Determine if we should force tool usage based on settings and context.
Returns True if:
- force_tool_usage == 'always' and tools exist and no tool_calls
- force_tool_usage == 'auto' and is Ollama and first iteration and tools exist and no tool_calls
"""
if not formatted_tools:
return False
if tool_calls:
return False
if self.force_tool_usage == 'never':
return False
elif self.force_tool_usage == 'always':
return True
elif self.force_tool_usage == 'auto':
# Auto mode: only for Ollama on first iteration when model ignores tools
return self._is_ollama_provider() and iteration_count == 0
return False
def _try_parse_tool_call_json(self, response_text: str, iteration_count: int) -> tuple:
"""
Try to parse tool call from JSON response text.
Returns:
tuple: (tool_calls list or None, parse_error string or None)
"""
if not response_text or not response_text.strip():
return None, None
try:
response_json = json.loads(response_text.strip())
if isinstance(response_json, dict) and "name" in response_json:
tool_calls = [{
"id": f"tool_{iteration_count}",
"type": "function",
"function": {
"name": response_json["name"],
"arguments": json.dumps(response_json.get("arguments", {}))
}
}]
return tool_calls, None
elif isinstance(response_json, list):
tool_calls = []
for idx, tool_json in enumerate(response_json):
if isinstance(tool_json, dict) and "name" in tool_json:
tool_calls.append({
"id": f"tool_{iteration_count}_{idx}",
"type": "function",
"function": {
"name": tool_json["name"],
"arguments": json.dumps(tool_json.get("arguments", {}))
}
})
return tool_calls if tool_calls else None, None
else:
return None, "Response is not a valid tool call format (missing 'name' field)"
except json.JSONDecodeError as e:
return None, f"JSON parse error: {str(e)}"
def _validate_tool_call(self, tool_call: Dict, formatted_tools: Optional[List]) -> Optional[str]:
"""
Validate a tool call against available tools.
Returns:
Error message string if invalid, None if valid
"""
if not formatted_tools:
return None
try:
func_info = tool_call.get('function', {})
tool_name = func_info.get('name', '')
arguments_str = func_info.get('arguments', '{}')
# Check if tool name exists
available_names = [t['function']['name'] for t in formatted_tools if isinstance(t, dict) and 'function' in t]
if tool_name not in available_names:
return f"Unknown tool '{tool_name}'. Available: {', '.join(available_names)}"
# Try to parse arguments
try:
arguments = json.loads(arguments_str) if isinstance(arguments_str, str) else arguments_str
except json.JSONDecodeError as e:
return f"Invalid arguments JSON: {str(e)}"
# Find the tool schema and validate required args
for tool in formatted_tools:
if isinstance(tool, dict) and tool.get('function', {}).get('name') == tool_name:
params = tool['function'].get('parameters', {})
required = params.get('required', [])
missing = [r for r in required if r not in arguments]
if missing:
return f"Missing required arguments for '{tool_name}': {', '.join(missing)}"
break
return None # Valid
except Exception as e:
return f"Validation error: {str(e)}"
def _process_stream_delta(self, delta, response_text: str, tool_calls: List[Dict], formatted_tools: Optional[List] = None) -> tuple:
"""
Process a streaming delta chunk to extract content and tool calls.
@@ -1637,6 +1811,18 @@ class LLM:
if tool_calls:
logging.debug(f"Parsed {len(tool_calls)} tool call(s) from XML format")
# Force tool usage logic: if model ignores tools but should use them
if self._should_force_tool_usage(response_text, tool_calls, formatted_tools, iteration_count):
tool_names = self._get_tool_names_for_prompt(formatted_tools)
force_prompt = self.FORCE_TOOL_USAGE_PROMPT.format(tool_names=tool_names)
logging.debug(f"[OLLAMA_RELIABILITY] Force tool usage triggered. Adding prompt for tools: {tool_names}")
messages.append({
"role": "user",
"content": force_prompt
})
iteration_count += 1
continue
# For Ollama, if response is empty but we have tools, prompt for tool usage
if self._is_ollama_provider() and (not response_text or response_text.strip() == "") and formatted_tools and iteration_count == 0:
messages.append({
@@ -1646,6 +1832,37 @@ class LLM:
iteration_count += 1
continue
# Tool call repair logic: validate and repair malformed tool calls
repair_attempt_count = getattr(self, '_current_repair_count', 0)
if tool_calls and self.max_tool_repairs > 0 and repair_attempt_count < self.max_tool_repairs:
# Validate each tool call
validation_errors = []
for tc in tool_calls:
error = self._validate_tool_call(tc, formatted_tools)
if error:
validation_errors.append(error)
if validation_errors:
# Tool call is invalid, attempt repair
error_msg = "; ".join(validation_errors)
tool_schemas = self._get_tool_schemas_for_prompt(formatted_tools)
repair_prompt = self.TOOL_CALL_REPAIR_PROMPT.format(
error=error_msg,
tool_schemas=tool_schemas
)
logging.debug(f"[OLLAMA_RELIABILITY] Tool call repair attempt {repair_attempt_count + 1}/{self.max_tool_repairs}: {error_msg}")
messages.append({
"role": "user",
"content": repair_prompt
})
self._current_repair_count = repair_attempt_count + 1
iteration_count += 1
tool_calls = None # Clear invalid tool calls
continue
# Reset repair count on successful tool call
self._current_repair_count = 0
# Handle tool calls - Sequential tool calling logic
if tool_calls and execute_tool_fn:
# Convert tool_calls to a serializable format for all providers
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "0.4.0"
version = "0.4.1"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
+1 -1
View File
@@ -2422,7 +2422,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.4.0"
version = "0.4.1"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -0,0 +1,52 @@
/**
* Evaluation Example - Testing agent accuracy and performance
*
* Run: npx ts-node examples/features/evaluation.ts
*/
import { Agent, accuracyEval, performanceEval } from '../../src';
async function main() {
// Create an agent to evaluate
const agent = new Agent({
instructions: "You are a math tutor. Answer math questions accurately.",
llm: "gpt-4o-mini",
verbose: false
});
console.log("=== Accuracy Evaluation ===");
// Test accuracy with expected outputs
const testCases = [
{ input: "What is 2 + 2?", expected: "4" },
{ input: "What is 10 * 5?", expected: "50" },
{ input: "What is 100 / 4?", expected: "25" }
];
for (const test of testCases) {
const response = await agent.chat(test.input);
const passed = response.includes(test.expected);
console.log(` ${passed ? '✅' : '❌'} "${test.input}" → Expected: ${test.expected}`);
}
console.log("\n=== Performance Evaluation ===");
// Measure response time
const iterations = 3;
const times: number[] = [];
for (let i = 0; i < iterations; i++) {
const start = Date.now();
await agent.chat("What is 5 + 5?");
const duration = Date.now() - start;
times.push(duration);
console.log(` Run ${i + 1}: ${duration}ms`);
}
const avgTime = times.reduce((a, b) => a + b, 0) / times.length;
console.log(`\n Average: ${avgTime.toFixed(0)}ms`);
console.log(` Min: ${Math.min(...times)}ms`);
console.log(` Max: ${Math.max(...times)}ms`);
}
main().catch(console.error);
@@ -0,0 +1,49 @@
/**
* Guardrails Example - Input/output validation for agents
*
* Run: npx ts-node examples/features/guardrails.ts
*/
import { Agent, LLMGuardrail } from '../../src';
async function main() {
// Create an LLM-based guardrail
const contentGuardrail = new LLMGuardrail({
name: "ContentSafety",
criteria: "Content must be professional, non-offensive, and factually accurate",
llm: "gpt-4o-mini",
threshold: 0.7,
verbose: true
});
// Create agent
const agent = new Agent({
instructions: "You are a helpful assistant that provides accurate information.",
verbose: true
});
// Example: Check content before responding
const userInput = "Tell me about artificial intelligence";
// Validate input using check()
const inputCheck = await contentGuardrail.check(userInput);
console.log("Input validation:", inputCheck);
if (inputCheck.status === 'passed') {
const response = await agent.chat(userInput);
// Validate output
const outputCheck = await contentGuardrail.check(response);
console.log("Output validation:", outputCheck);
if (outputCheck.status === 'passed') {
console.log("\n✅ Response passed guardrails");
} else {
console.log("\n❌ Response failed guardrails:", outputCheck.reasoning);
}
} else {
console.log("❌ Input failed guardrails:", inputCheck.reasoning);
}
}
main().catch(console.error);
@@ -0,0 +1,64 @@
/**
* Knowledge Base Example - RAG with agents
*
* Run: npx ts-node examples/features/knowledge.ts
*/
import { Agent, BaseKnowledgeBase, type Knowledge } from '../../src';
async function main() {
// Create knowledge base
const kb = new BaseKnowledgeBase();
// Add knowledge entries
kb.addKnowledge({
id: "overview",
type: "documentation",
content: `
PraisonAI is a TypeScript SDK for building AI agents.
It supports multiple LLM providers including OpenAI, Anthropic, and Google.
Key features include:
- Simple Agent API with instructions
- Multi-agent orchestration with Agents class
- Workflow-based execution
- Tool integration with auto-schema generation
- Session persistence with db() factory
`,
metadata: { source: "docs", topic: "overview" }
});
kb.addKnowledge({
id: "agent-usage",
type: "documentation",
content: `
The Agent class is the primary entry point.
Create an agent with: new Agent({ instructions: "..." })
Use agent.chat("message") to interact.
Tools can be plain functions passed in the tools array.
`,
metadata: { source: "docs", topic: "agent" }
});
// Search the knowledge base
console.log("=== Searching Knowledge Base ===");
const results = kb.searchKnowledge("agent");
console.log("Found", results.length, "relevant entries");
results.forEach((r: Knowledge, i: number) => {
console.log(`\n[${i + 1}] ID: ${r.id}`);
console.log(String(r.content).substring(0, 100) + "...");
});
// Create agent that uses knowledge
const knowledgeContext = results.map((r: Knowledge) => r.content).join('\n\n');
const agent = new Agent({
instructions: `You are a helpful assistant. Use the following knowledge to answer questions:
${knowledgeContext}`,
verbose: true
});
console.log("\n=== Agent Response ===");
await agent.chat("How do I create an agent in PraisonAI?");
}
main().catch(console.error);
@@ -0,0 +1,37 @@
/**
* Memory Example - Using memory for agent context
*
* Run: npx ts-node examples/features/memory.ts
*/
import { Agent, Memory } from '../../src';
async function main() {
// Create a memory instance
const memory = new Memory({
maxEntries: 100,
maxTokens: 50000
});
// Add some context to memory
await memory.add("User prefers concise answers", "system", { priority: "high" });
await memory.add("User is working on a TypeScript project", "system", { topic: "programming" });
// Search memory
const results = await memory.search("TypeScript");
console.log("Memory search results:", results);
// Get recent entries
const recent = memory.getRecent(5);
console.log("Recent entries:", recent);
// Create agent
const agent = new Agent({
instructions: "You are a helpful assistant. Use context from memory.",
verbose: true
});
await agent.chat("Help me with my project");
}
main().catch(console.error);
@@ -0,0 +1,31 @@
/**
* Observability Example - Tracing and monitoring agents
*
* Run: npx ts-node examples/features/observability.ts
*/
import { Agent, setObservabilityAdapter, ConsoleObservabilityAdapter } from '../../src';
async function main() {
// Set up console observability (logs all traces)
const adapter = new ConsoleObservabilityAdapter();
setObservabilityAdapter(adapter);
console.log("=== Observability Enabled ===");
console.log("All agent operations will be traced to console.\n");
// Create agent
const agent = new Agent({
instructions: "You are a helpful assistant.",
verbose: true
});
// Run some operations - they will be traced
console.log("=== Running Agent ===");
await agent.chat("Hello! Tell me a joke.");
console.log("\n=== Trace Complete ===");
console.log("Check console output above for trace information.");
}
main().catch(console.error);
@@ -0,0 +1,50 @@
/**
* Planning Example - Using planning mode for complex tasks
*
* Run: npx ts-node examples/features/planning.ts
*/
import { Agent, Plan, PlanStep, TodoList, TodoItem } from '../../src';
async function main() {
// Create a plan for a complex task
console.log("=== Creating a Plan ===");
const plan = new Plan({
name: "Build a Web App",
description: "Plan to build a simple web application"
});
// Add steps to the plan
plan.addStep(new PlanStep({ description: "Initialize project structure" }));
plan.addStep(new PlanStep({ description: "Build API endpoints" }));
plan.addStep(new PlanStep({ description: "Create UI components" }));
plan.addStep(new PlanStep({ description: "Write and run tests" }));
plan.addStep(new PlanStep({ description: "Deploy to production" }));
console.log("Plan created with", plan.steps.length, "steps");
// Create a todo list
console.log("\n=== Creating a Todo List ===");
const todos = new TodoList();
todos.add(new TodoItem({ content: "Research frameworks", priority: "high" }));
todos.add(new TodoItem({ content: "Design database schema", priority: "medium" }));
todos.add(new TodoItem({ content: "Write documentation", priority: "low" }));
console.log("Todo list has", todos.items.length, "items");
// Create agent with planning context
const agent = new Agent({
instructions: `You are a project planning assistant.
Current plan: ${plan.name}
Steps: ${plan.steps.map((s: PlanStep) => s.description).join(', ')}
Help the user execute this plan.`,
verbose: true
});
await agent.chat("What should I do first?");
}
main().catch(console.error);
@@ -0,0 +1,45 @@
/**
* LLM Providers Example - Using different LLM providers
*
* Run: npx ts-node examples/features/providers.ts
*/
import { Agent } from '../../src';
async function main() {
// OpenAI (default)
console.log("=== OpenAI Agent ===");
const openaiAgent = new Agent({
instructions: "You are a helpful assistant.",
llm: "gpt-4o-mini" // or just omit for default
});
await openaiAgent.chat("Hello from OpenAI!");
// Anthropic Claude
console.log("\n=== Anthropic Agent ===");
const anthropicAgent = new Agent({
instructions: "You are a helpful assistant.",
llm: "anthropic/claude-3-haiku-20240307"
});
// Uncomment if you have ANTHROPIC_API_KEY set
// await anthropicAgent.chat("Hello from Anthropic!");
// Google Gemini
console.log("\n=== Google Agent ===");
const googleAgent = new Agent({
instructions: "You are a helpful assistant.",
llm: "google/gemini-1.5-flash"
});
// Uncomment if you have GOOGLE_API_KEY set
// await googleAgent.chat("Hello from Google!");
// Model string formats
console.log("\n=== Model String Formats ===");
console.log("Supported formats:");
console.log(" - 'gpt-4o-mini' (defaults to OpenAI)");
console.log(" - 'openai/gpt-4o'");
console.log(" - 'anthropic/claude-3-sonnet-20240229'");
console.log(" - 'google/gemini-1.5-pro'");
}
main().catch(console.error);
@@ -0,0 +1,31 @@
/**
* Sessions Example - Conversation persistence with session IDs
*
* Run: npx ts-node examples/features/sessions.ts
*/
import { Agent, db } from '../../src';
async function main() {
// Create an agent with session persistence
const agent = new Agent({
instructions: "You are a helpful assistant that remembers our conversation.",
db: db("sqlite:./sessions.db"),
sessionId: "user-alice-123"
});
// First conversation
console.log("=== First Conversation ===");
await agent.chat("My name is Alice and I love programming.");
// Continue the conversation - agent remembers context
console.log("\n=== Continuing Conversation ===");
await agent.chat("What's my name and what do I love?");
// Get session info
console.log("\n=== Session Info ===");
console.log("Session ID:", agent.getSessionId());
console.log("Run ID:", agent.getRunId());
}
main().catch(console.error);
@@ -0,0 +1,51 @@
/**
* Skills Example - Using agent skills
*
* Run: npx ts-node examples/features/skills.ts
*/
import { Agent, SkillManager, type Skill } from '../../src';
async function main() {
// Create a skill manager
const skillManager = new SkillManager({
paths: ['./.praison/skills'],
includeProject: true,
includeUser: true
});
// Discover available skills
console.log("=== Discovering Skills ===");
const skills = await skillManager.discover();
if (skills.length === 0) {
console.log("No skills found. Create a skill in .praison/skills/");
console.log("\nExample SKILL.md:");
console.log(`---
name: code-review
description: Reviews code for best practices
---
# Code Review Skill
When reviewing code:
1. Check for security issues
2. Verify error handling
3. Suggest improvements
`);
} else {
skills.forEach((skill: Skill) => {
console.log(` - ${skill.metadata.name}: ${skill.metadata.description}`);
});
}
// Create agent (skills would be loaded automatically if configured)
const agent = new Agent({
instructions: "You are a helpful coding assistant.",
verbose: true
});
await agent.chat("Help me write clean code");
}
main().catch(console.error);
@@ -0,0 +1,35 @@
/**
* Multi-Agent Pattern - Orchestrate multiple agents
*
* Run: npx ts-node examples/patterns/multi-agent.ts
*/
import { Agent, Agents } from '../../src';
// Create specialized agents
const researcher = new Agent({
instructions: "You are a research specialist. Thoroughly research the given topic and provide detailed findings.",
name: "Researcher"
});
const writer = new Agent({
instructions: "You are a professional writer. Take the research provided and write a clear, engaging summary.",
name: "Writer"
});
// Orchestrate agents - they run sequentially, passing output to the next
const agents = new Agents([researcher, writer]);
// Alternative: use config object for more control
// const agents = new Agents({
// agents: [researcher, writer],
// process: 'sequential', // or 'parallel'
// verbose: true
// });
agents.start().then(results => {
console.log("\n=== Research Agent Output ===");
console.log(results[0]);
console.log("\n=== Writer Agent Output ===");
console.log(results[1]);
});
@@ -0,0 +1,21 @@
/**
* Streaming Pattern - Real-time response streaming
*
* Run: npx ts-node examples/patterns/streaming.ts
*/
import { Agent } from '../../src';
// Create an agent with streaming enabled
const agent = new Agent({
instructions: "You are a creative storyteller. Write engaging, detailed stories.",
name: "Storyteller",
stream: true, // Enable streaming (default is true)
verbose: true
});
// The response will stream to stdout as it's generated
console.log("=== Streaming Story ===\n");
agent.chat("Write a short story about a robot learning to paint.").then(() => {
console.log("\n\n=== Story Complete ===");
});
@@ -0,0 +1,41 @@
/**
* Workflow Pattern - Step-based pipeline with agent integration
*
* Run: npx ts-node examples/patterns/workflow.ts
*/
import { Agent, Workflow } from '../../src';
// Create agents for the workflow
const researcher = new Agent({
instructions: "You are a research specialist. Research the given topic thoroughly.",
name: "Researcher"
});
const analyzer = new Agent({
instructions: "You are a data analyst. Analyze the research and extract key insights.",
name: "Analyzer"
});
const writer = new Agent({
instructions: "You are a professional writer. Write a clear report based on the analysis.",
name: "Writer"
});
// Create a workflow with agent steps
const workflow = new Workflow("Research Pipeline")
.agent(researcher, "Research the topic")
.agent(analyzer, "Analyze the research findings")
.agent(writer, "Write a summary report");
// Run the workflow
workflow.run("Artificial Intelligence trends in 2025").then(result => {
console.log("\n=== Workflow Results ===");
console.log(`Steps completed: ${result.results.length}`);
console.log(`Final output:\n${result.output}`);
// Access individual step results
result.results.forEach((step, i) => {
console.log(`\nStep ${i + 1} (${step.stepName}): ${step.status} in ${step.duration}ms`);
});
});
@@ -0,0 +1,10 @@
/**
* Hello Agent - The simplest possible PraisonAI example (3 lines)
*
* Run: npx ts-node examples/quickstart/hello-agent.ts
*/
import { Agent } from '../../src';
const agent = new Agent({ instructions: "You are a helpful assistant" });
agent.chat("Hello! Tell me a fun fact about AI.").then(console.log);
@@ -0,0 +1,15 @@
/**
* Agent with Persistence - Auto-save messages to database (4 lines)
*
* Run: npx ts-node examples/quickstart/with-persistence.ts
*/
import { Agent, db } from '../../src';
const agent = new Agent({
instructions: "You are a helpful assistant with memory",
db: db("memory:"), // Use in-memory db for demo (use "sqlite:./data.db" for file)
sessionId: "my-session"
});
agent.chat("Remember that my favorite color is blue.").then(console.log);
@@ -0,0 +1,17 @@
/**
* Agent with Tools - Pass plain functions as tools (5 lines)
*
* Run: npx ts-node examples/quickstart/with-tools.ts
*/
import { Agent } from '../../src';
// Define a simple tool as a plain function
const getWeather = (city: string) => `Weather in ${city}: 22°C, Sunny`;
const agent = new Agent({
instructions: "You are a weather assistant. Use the getWeather tool to answer questions.",
tools: [getWeather]
});
agent.chat("What's the weather like in Paris?").then(console.log);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "praisonai",
"version": "1.2.4",
"version": "1.3.0",
"description": "PraisonAI TypeScript AI Agents Framework - Node.js, npm, and Javascript AI Agents Framework",
"main": "dist/index.js",
"types": "dist/index.d.ts",
+26 -7
View File
@@ -1,12 +1,31 @@
// Export implementation based on mode
/**
* Agent Module - Core agent classes for PraisonAI
*
* The primary exports are:
* - Agent: Single agent with instructions, tools, and optional persistence
* - Agents: Multi-agent orchestration (alias for PraisonAIAgents)
* - Workflow: Step-based workflow execution (from workflows module)
*/
// Core exports - the main API surface
export { Agent, PraisonAIAgents, Agents } from './simple';
export type { SimpleAgentConfig, PraisonAIAgentsConfig } from './simple';
// Task support (for advanced use cases)
export { Task } from './types';
export type { TaskConfig, AgentConfig as TaskAgentConfig } from './types';
// Legacy compatibility - setTaskMode is deprecated but kept for backward compat
let useTaskMode = false;
/**
* @deprecated Task mode is no longer needed. Use Agent with role/goal/backstory instead.
*/
export function setTaskMode(enabled: boolean) {
if (enabled) {
console.warn(
'setTaskMode() is deprecated. Use Agent({ role, goal, backstory }) instead of task mode.'
);
}
useTaskMode = enabled;
}
export { Agent, PraisonAIAgents, Task } from './proxy';
export type { ProxyAgentConfig } from './proxy';
export type { AgentConfig } from './types';
export type { TaskConfig } from './types';
export type { PraisonAIAgentsConfig, SimpleAgentConfig } from './simple';
+191 -4
View File
@@ -1,17 +1,84 @@
import { OpenAIService } from '../llm/openai';
import { Logger } from '../utils/logger';
import type { ChatCompletionTool } from 'openai/resources/chat/completions';
import type { DbAdapter, DbMessage, DbRun } from '../db/types';
import { randomUUID } from 'crypto';
/**
* Agent Configuration
*
* The Agent class is the primary entry point for PraisonAI.
* It supports both simple instruction-based agents and advanced configurations.
*
* @example Simple usage (3 lines)
* ```typescript
* import { Agent } from 'praisonai';
* const agent = new Agent({ instructions: "You are helpful" });
* await agent.chat("Hello!");
* ```
*
* @example With tools (5 lines)
* ```typescript
* const getWeather = (city: string) => `Weather in ${city}: 20°C`;
* const agent = new Agent({
* instructions: "You provide weather info",
* tools: [getWeather]
* });
* await agent.chat("Weather in Paris?");
* ```
*
* @example With persistence (4 lines)
* ```typescript
* import { Agent, db } from 'praisonai';
* const agent = new Agent({
* instructions: "You are helpful",
* db: db("sqlite:./data.db"),
* sessionId: "my-session"
* });
* await agent.chat("Hello!");
* ```
*/
export interface SimpleAgentConfig {
/** Agent instructions/system prompt (required) */
instructions: string;
/** Agent name (auto-generated if not provided) */
name?: string;
/** Enable verbose logging (default: true) */
verbose?: boolean;
/** Enable pretty output formatting */
pretty?: boolean;
/**
* LLM model to use. Accepts:
* - Model name: "gpt-4o-mini", "claude-3-sonnet"
* - Provider/model: "openai/gpt-4o", "anthropic/claude-3"
* Default: "gpt-4o-mini"
*/
llm?: string;
/** Enable markdown formatting in responses */
markdown?: boolean;
/** Enable streaming responses (default: true) */
stream?: boolean;
/**
* Tools available to the agent.
* Can be plain functions (auto-schema) or OpenAI tool definitions.
*/
tools?: any[] | Function[];
/** Map of tool function implementations */
toolFunctions?: Record<string, Function>;
/** Database adapter for persistence */
db?: DbAdapter;
/** Session ID for conversation persistence */
sessionId?: string;
/** Run ID for tracing (auto-generated if not provided) */
runId?: string;
// Advanced mode (role/goal/backstory) - for compatibility
/** Agent role (advanced mode) */
role?: string;
/** Agent goal (advanced mode) */
goal?: string;
/** Agent backstory (advanced mode) */
backstory?: string;
}
export class Agent {
@@ -25,16 +92,36 @@ export class Agent {
private llmService: OpenAIService;
private tools?: any[];
private toolFunctions: Record<string, Function> = {};
private dbAdapter?: DbAdapter;
private sessionId: string;
private runId: string;
private messages: Array<{ role: string; content: string | null; tool_calls?: any[]; tool_call_id?: string }> = [];
constructor(config: SimpleAgentConfig) {
this.instructions = config.instructions;
// Build instructions from either simple or advanced mode
if (config.instructions) {
this.instructions = config.instructions;
} else if (config.role || config.goal || config.backstory) {
// Advanced mode: construct instructions from role/goal/backstory
const parts: string[] = [];
if (config.role) parts.push(`You are a ${config.role}.`);
if (config.goal) parts.push(`Your goal is: ${config.goal}`);
if (config.backstory) parts.push(`Background: ${config.backstory}`);
this.instructions = parts.join('\n');
} else {
this.instructions = 'You are a helpful AI assistant.';
}
this.name = config.name || `Agent_${Math.random().toString(36).substr(2, 9)}`;
this.verbose = config.verbose ?? process.env.PRAISON_VERBOSE !== 'false';
this.pretty = config.pretty ?? process.env.PRAISON_PRETTY === 'true';
this.llm = config.llm || 'gpt-4o-mini';
this.llm = config.llm || process.env.OPENAI_MODEL_NAME || process.env.PRAISONAI_MODEL || 'gpt-4o-mini';
this.markdown = config.markdown ?? true;
this.stream = config.stream ?? true;
this.tools = config.tools;
this.dbAdapter = config.db;
this.sessionId = config.sessionId || randomUUID();
this.runId = config.runId || randomUUID();
this.llmService = new OpenAIService(this.llm);
// Configure logging
@@ -315,7 +402,19 @@ export class Agent {
}
async chat(prompt: string, previousResult?: string): Promise<string> {
return this.start(prompt, previousResult);
// Persist user message if db is configured
if (this.dbAdapter) {
await this.persistMessage('user', prompt);
}
const response = await this.start(prompt, previousResult);
// Persist assistant response if db is configured
if (this.dbAdapter) {
await this.persistMessage('assistant', response);
}
return response;
}
async execute(previousResult?: string): Promise<string> {
@@ -323,6 +422,41 @@ export class Agent {
return this.start(this.instructions, previousResult);
}
/**
* Persist a message to the database
*/
private async persistMessage(role: 'user' | 'assistant' | 'system' | 'tool', content: string): Promise<void> {
if (!this.dbAdapter) return;
try {
const message: DbMessage = {
id: randomUUID(),
sessionId: this.sessionId,
runId: this.runId,
role,
content,
createdAt: Date.now()
};
await this.dbAdapter.saveMessage(message);
} catch (error) {
await Logger.warn('Failed to persist message:', error);
}
}
/**
* Get the session ID for this agent
*/
getSessionId(): string {
return this.sessionId;
}
/**
* Get the run ID for this agent
*/
getRunId(): string {
return this.runId;
}
getResult(): string | null {
return null;
}
@@ -332,6 +466,9 @@ export class Agent {
}
}
/**
* Configuration for multi-agent orchestration
*/
export interface PraisonAIAgentsConfig {
agents: Agent[];
tasks?: string[];
@@ -340,6 +477,28 @@ export interface PraisonAIAgentsConfig {
process?: 'sequential' | 'parallel';
}
/**
* Multi-agent orchestration class
*
* @example Simple array syntax
* ```typescript
* import { Agent, Agents } from 'praisonai';
*
* const researcher = new Agent({ instructions: "Research the topic" });
* const writer = new Agent({ instructions: "Write based on research" });
*
* const agents = new Agents([researcher, writer]);
* await agents.start();
* ```
*
* @example Config object syntax
* ```typescript
* const agents = new Agents({
* agents: [researcher, writer],
* process: 'parallel'
* });
* ```
*/
export class PraisonAIAgents {
private agents: Agent[];
private tasks: string[];
@@ -347,7 +506,16 @@ export class PraisonAIAgents {
private pretty: boolean;
private process: 'sequential' | 'parallel';
constructor(config: PraisonAIAgentsConfig) {
/**
* Create a multi-agent orchestration
* @param configOrAgents - Either an array of agents or a config object
*/
constructor(configOrAgents: PraisonAIAgentsConfig | Agent[]) {
// Support array syntax: new Agents([a1, a2])
const config: PraisonAIAgentsConfig = Array.isArray(configOrAgents)
? { agents: configOrAgents }
: configOrAgents;
this.agents = config.agents;
this.verbose = config.verbose ?? process.env.PRAISON_VERBOSE !== 'false';
this.pretty = config.pretty ?? process.env.PRAISON_PRETTY === 'true';
@@ -425,3 +593,22 @@ export class PraisonAIAgents {
return this.start();
}
}
/**
* Agents - Alias for PraisonAIAgents
*
* This is the recommended class name for multi-agent orchestration.
* PraisonAIAgents is kept for backward compatibility.
*
* @example
* ```typescript
* import { Agent, Agents } from 'praisonai';
*
* const agents = new Agents([
* new Agent({ instructions: "Research the topic" }),
* new Agent({ instructions: "Write based on research" })
* ]);
* await agents.start();
* ```
*/
export const Agents = PraisonAIAgents;
+148
View File
@@ -0,0 +1,148 @@
/**
* Agent command - Run a single agent with instructions
*
* Usage:
* praisonai-ts agent chat "Hello, how are you?"
* praisonai-ts agent run --instructions "You are helpful" "Tell me a joke"
*/
import { Agent } from '../../agent';
import { resolveConfig } from '../config/resolve';
import { printSuccess, printError, outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES, normalizeError } from '../output/errors';
export interface AgentOptions {
instructions?: string;
model?: string;
stream?: boolean;
verbose?: boolean;
profile?: string;
config?: string;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
sessionId?: string;
}
/**
* Execute agent chat subcommand
*/
export async function executeChat(args: string[], options: AgentOptions): Promise<void> {
const prompt = args[0];
if (!prompt) {
if (options.json || options.output === 'json') {
printError(ERROR_CODES.MISSING_ARG, 'Please provide a prompt');
} else {
await pretty.error('Please provide a prompt');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const config = resolveConfig({
configPath: options.config,
profile: options.profile,
model: options.model,
verbose: options.verbose,
stream: options.stream
});
const startTime = Date.now();
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
try {
const agent = new Agent({
instructions: options.instructions || 'You are a helpful AI assistant.',
llm: config.model,
stream: config.stream && outputFormat !== 'json',
verbose: config.verbose,
sessionId: options.sessionId
});
const response = await agent.chat(prompt);
const duration = Date.now() - startTime;
if (outputFormat === 'json') {
outputJson(formatSuccess(
{
response,
agent: agent.name,
sessionId: agent.getSessionId()
},
{
duration_ms: duration,
model: config.model
}
));
} else if (outputFormat === 'text') {
console.log(response);
} else {
// Pretty output - response already printed if streaming
if (!config.stream) {
console.log(response);
}
console.log(); // Newline
await pretty.dim(`Completed in ${duration}ms`);
}
} catch (error) {
const cliError = normalizeError(error);
if (outputFormat === 'json') {
outputJson(formatError(cliError.code, cliError.message, cliError.details));
} else {
await pretty.error(cliError.message);
if (config.verbose && error instanceof Error && error.stack) {
await pretty.dim(error.stack);
}
}
process.exit(cliError.exitCode);
}
}
/**
* Execute agent run subcommand (same as chat but requires instructions)
*/
export async function executeRun(args: string[], options: AgentOptions): Promise<void> {
if (!options.instructions) {
if (options.json || options.output === 'json') {
printError(ERROR_CODES.MISSING_ARG, 'Please provide --instructions');
} else {
await pretty.error('Please provide --instructions');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
return executeChat(args, options);
}
/**
* Main execute function - routes to subcommands
*/
export async function execute(args: string[], options: AgentOptions): Promise<void> {
const subcommand = args[0];
const subArgs = args.slice(1);
switch (subcommand) {
case 'chat':
return executeChat(subArgs, options);
case 'run':
return executeRun(subArgs, options);
default:
// If no subcommand, treat the first arg as a prompt for chat
if (subcommand) {
return executeChat(args, options);
}
if (options.json || options.output === 'json') {
printError(ERROR_CODES.MISSING_ARG, 'Please provide a subcommand (chat, run) or a prompt');
} else {
await pretty.error('Usage: praisonai-ts agent [chat|run] <prompt>');
await pretty.info(' agent chat "Hello" - Chat with default agent');
await pretty.info(' agent run --instructions "..." "Hello" - Run with custom instructions');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
}
+167
View File
@@ -0,0 +1,167 @@
/**
* Agents command - Run multi-agent orchestration
*
* Usage:
* praisonai-ts agents run --agents "researcher,writer" "Research AI trends"
*/
import { Agent, Agents } from '../../agent';
import { resolveConfig } from '../config/resolve';
import { printSuccess, printError, outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES, normalizeError } from '../output/errors';
export interface AgentsOptions {
agents?: string; // Comma-separated agent instructions
process?: 'sequential' | 'parallel';
model?: string;
verbose?: boolean;
profile?: string;
config?: string;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
}
/**
* Parse agent definitions from comma-separated string
*/
function parseAgentDefinitions(agentsStr: string): string[] {
return agentsStr.split(',').map(s => s.trim()).filter(s => s.length > 0);
}
/**
* Execute agents run subcommand
*/
export async function executeRun(args: string[], options: AgentsOptions): Promise<void> {
const prompt = args[0];
if (!prompt) {
if (options.json || options.output === 'json') {
printError(ERROR_CODES.MISSING_ARG, 'Please provide a prompt/task');
} else {
await pretty.error('Please provide a prompt/task');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const config = resolveConfig({
configPath: options.config,
profile: options.profile,
model: options.model,
verbose: options.verbose
});
const startTime = Date.now();
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
try {
// Create agents from definitions or use defaults
let agentList: Agent[];
if (options.agents) {
const definitions = parseAgentDefinitions(options.agents);
agentList = definitions.map((instructions, i) =>
new Agent({
instructions,
name: `Agent_${i + 1}`,
llm: config.model,
verbose: config.verbose
})
);
} else {
// Default: create a researcher and writer
agentList = [
new Agent({
instructions: `Research the following topic thoroughly: ${prompt}`,
name: 'Researcher',
llm: config.model,
verbose: config.verbose
}),
new Agent({
instructions: 'Summarize and write a clear report based on the research provided.',
name: 'Writer',
llm: config.model,
verbose: config.verbose
})
];
}
if (outputFormat !== 'json') {
await pretty.heading(`Running ${agentList.length} agents (${options.process || 'sequential'})`);
}
const agents = new Agents({
agents: agentList,
process: options.process || 'sequential',
verbose: config.verbose
});
const results = await agents.start();
const duration = Date.now() - startTime;
if (outputFormat === 'json') {
outputJson(formatSuccess(
{
results,
agentCount: agentList.length,
process: options.process || 'sequential'
},
{
duration_ms: duration,
model: config.model
}
));
} else if (outputFormat === 'text') {
results.forEach((result, i) => {
console.log(`\n--- Agent ${i + 1} ---\n${result}`);
});
} else {
// Pretty output
console.log();
await pretty.success(`Completed ${agentList.length} agents in ${duration}ms`);
}
} catch (error) {
const cliError = normalizeError(error);
if (outputFormat === 'json') {
outputJson(formatError(cliError.code, cliError.message, cliError.details));
} else {
await pretty.error(cliError.message);
if (config.verbose && error instanceof Error && error.stack) {
await pretty.dim(error.stack);
}
}
process.exit(cliError.exitCode);
}
}
/**
* Main execute function - routes to subcommands
*/
export async function execute(args: string[], options: AgentsOptions): Promise<void> {
const subcommand = args[0];
const subArgs = args.slice(1);
switch (subcommand) {
case 'run':
return executeRun(subArgs, options);
default:
// If no subcommand, treat as run
if (subcommand) {
return executeRun(args, options);
}
if (options.json || options.output === 'json') {
printError(ERROR_CODES.MISSING_ARG, 'Please provide a task/prompt');
} else {
await pretty.error('Usage: praisonai-ts agents [run] <task>');
await pretty.info(' agents run "Research AI" - Run with default agents');
await pretty.info(' agents run --agents "..." "Research AI" - Run with custom agents');
await pretty.info(' agents run --process parallel "Research AI" - Run in parallel');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
}
+77 -19
View File
@@ -1,11 +1,61 @@
// Export all public modules
export * from './agent';
export * from './knowledge';
export * from './llm';
export * from './memory';
export * from './process';
/**
* PraisonAI TypeScript SDK
*
* The primary API surface consists of three core classes:
* - Agent: Single AI agent with instructions, tools, and optional persistence
* - Agents: Multi-agent orchestration (sequential or parallel)
* - Workflow: Step-based workflow execution
*
* @example Quickstart (3 lines)
* ```typescript
* import { Agent } from 'praisonai';
* const agent = new Agent({ instructions: "You are helpful" });
* await agent.chat("Hello!");
* ```
*
* @example With tools (5 lines)
* ```typescript
* const getWeather = (city: string) => `Weather in ${city}: 20°C`;
* const agent = new Agent({ instructions: "You provide weather", tools: [getWeather] });
* await agent.chat("Weather in Paris?");
* ```
*
* @example With persistence (4 lines)
* ```typescript
* import { Agent, db } from 'praisonai';
* const agent = new Agent({ instructions: "You are helpful", db: db("sqlite:./data.db") });
* await agent.chat("Hello!");
* ```
*
* @example Multi-agent (6 lines)
* ```typescript
* import { Agent, Agents } from 'praisonai';
* const researcher = new Agent({ instructions: "Research the topic" });
* const writer = new Agent({ instructions: "Write based on research" });
* const agents = new Agents([researcher, writer]);
* await agents.start();
* ```
*/
// Export tools (excluding conflicting types)
// ============================================================================
// CORE API - The main classes users should use
// ============================================================================
// Agent - Single agent with instructions, tools, and optional persistence
export { Agent, Agents, PraisonAIAgents } from './agent';
export type { SimpleAgentConfig, PraisonAIAgentsConfig } from './agent';
// Workflow - Step-based workflow execution
export { Workflow, parallel, route, loop, repeat } from './workflows';
export type { WorkflowStep, WorkflowContext, StepResult } from './workflows';
// Database factory - Python-like db() shortcut
export { db, createDbAdapter, getDefaultDbAdapter, setDefaultDbAdapter } from './db';
export type { DbAdapter, DbConfig, DbMessage, DbRun, DbTrace } from './db';
// ============================================================================
// TOOLS - Function tools and tool registry
// ============================================================================
export {
BaseTool, ToolResult, ToolValidationError, validateTool, createTool,
FunctionTool, tool, ToolRegistry, getRegistry, registerTool, getTool,
@@ -14,14 +64,25 @@ export {
export * from './tools/arxivTools';
export * from './tools/mcpSse';
// Export session management
// ============================================================================
// SESSION & MEMORY
// ============================================================================
export * from './session';
// Export database adapters
export * from './db';
// ============================================================================
// KNOWLEDGE & RAG
// ============================================================================
export * from './knowledge';
// Export workflows
export * from './workflows';
// ============================================================================
// LLM PROVIDERS
// ============================================================================
export * from './llm';
export * from './process';
// ============================================================================
// WORKFLOWS (additional exports)
// ============================================================================
// Export guardrails
export * from './guardrails';
@@ -35,8 +96,6 @@ export { RouterAgent, createRouter, routeConditions, type RouterConfig, type Rou
// Export context agent
export { ContextAgent, createContextAgent, type ContextAgentConfig, type ContextMessage } from './agent/context';
// Export knowledge base (RAG)
export { KnowledgeBase, createKnowledgeBase, type Document, type SearchResult, type EmbeddingProvider, type KnowledgeBaseConfig } from './knowledge/rag';
// Export evaluation framework
export { accuracyEval, performanceEval, reliabilityEval, EvalSuite, type EvalResult, type PerformanceResult, type AccuracyEvalConfig, type PerformanceEvalConfig, type ReliabilityEvalConfig } from './eval';
@@ -50,8 +109,9 @@ export { SkillManager, createSkillManager, parseSkillFile, type Skill, type Skil
// Export CLI
export { parseArgs, executeCommand, CLI_SPEC_VERSION } from './cli';
// Export Memory
export { Memory, createMemory, type MemoryEntry, type MemoryConfig, type SearchResult as MemorySearchResult } from './memory/memory';
// Export Memory (explicit for convenience)
export { Memory, createMemory } from './memory/memory';
export type { MemoryEntry, MemoryConfig } from './memory/memory';
// Export FileMemory
export { FileMemory, createFileMemory, type FileMemoryConfig, type FileMemoryEntry } from './memory/file-memory';
@@ -82,8 +142,6 @@ export { QueryRewriterAgent, createQueryRewriterAgent, type QueryRewriterConfig,
// Export PromptExpanderAgent
export { PromptExpanderAgent, createPromptExpanderAgent, type PromptExpanderConfig, type ExpandResult, type ExpandStrategy } from './agent/prompt-expander';
// Export Chunking
export { Chunking, createChunking, type ChunkingConfig, type Chunk, type ChunkStrategy } from './knowledge/chunking';
// Export LLMGuardrail
export { LLMGuardrail, createLLMGuardrail, type LLMGuardrailConfig, type LLMGuardrailResult } from './guardrails/llm-guardrail';
@@ -101,7 +159,7 @@ export { PubSub, EventEmitterPubSub, AgentEventBus, AgentEvents, createEventBus,
export { parseYAMLWorkflow, createWorkflowFromYAML, loadWorkflowFromFile, validateWorkflowDefinition, type YAMLWorkflowDefinition, type YAMLStepDefinition, type ParsedWorkflow } from './workflows/yaml-parser';
// Export SQLite Adapter
export { SQLiteAdapter, createSQLiteAdapter, type SQLiteConfig, type DbMessage, type DbRun, type DbTrace } from './db/sqlite';
export { SQLiteAdapter, createSQLiteAdapter, type SQLiteConfig } from './db/sqlite';
// Export Redis Adapter
export {
+34
View File
@@ -194,6 +194,40 @@ export class Workflow<TInput = any, TOutput = any> {
return this.addStep({ name, execute });
}
/**
* Add an agent step to the workflow
*
* @example
* ```typescript
* import { Agent, Workflow } from 'praisonai';
*
* const researcher = new Agent({ instructions: "Research the topic" });
* const writer = new Agent({ instructions: "Write based on research" });
*
* const workflow = new Workflow("Research Pipeline")
* .agent(researcher, "Research AI trends")
* .agent(writer, "Write article based on research");
*
* await workflow.run("AI in 2025");
* ```
*/
agent(agentInstance: { chat: (prompt: string) => Promise<string>; name?: string }, task?: string): this {
const agentName = (agentInstance as any).name || 'Agent';
const stepName = task ? `${agentName}: ${task.slice(0, 30)}...` : agentName;
return this.addStep({
name: stepName,
execute: async (input: any, context: WorkflowContext) => {
// Build prompt from task and previous step output
const prompt = task
? `${task}\n\nInput: ${typeof input === 'string' ? input : JSON.stringify(input)}`
: typeof input === 'string' ? input : JSON.stringify(input);
return agentInstance.chat(prompt);
}
});
}
/**
* Run the workflow
*/
@@ -0,0 +1,101 @@
/**
* Real API Smoke Test - Run outside Jest to avoid mocks
*
* Usage: npx ts-node tests/integration/real-api-smoke.ts
*/
import { Agent } from '../../src';
async function runSmokeTests() {
console.log('🔥 Running Real API Smoke Tests\n');
const OPENAI_KEY = process.env.OPENAI_API_KEY;
if (!OPENAI_KEY) {
console.log('⚠️ OPENAI_API_KEY not set - skipping real API tests');
process.exit(0);
}
console.log('✅ OPENAI_API_KEY detected (masked)');
let passed = 0;
let failed = 0;
// Test 1: Simple chat
console.log('\n📝 Test 1: Simple Agent.chat()');
try {
const agent = new Agent({
instructions: 'You are a helpful assistant. Be very brief.',
llm: 'gpt-4o-mini',
stream: false,
verbose: false
});
const response = await agent.chat('Say "hello" and nothing else.');
if (response && response.toLowerCase().includes('hello')) {
console.log(' ✅ PASSED - Response contains "hello"');
passed++;
} else {
console.log(` ❌ FAILED - Response: "${response}"`);
failed++;
}
} catch (error: any) {
console.log(` ❌ FAILED - Error: ${error.message}`);
failed++;
}
// Test 2: Tool calling
console.log('\n📝 Test 2: Agent with tool calling');
try {
const getWeather = (city: string) => `Weather in ${city}: 22°C, Sunny`;
const agent = new Agent({
instructions: 'You are a weather assistant. Use the getWeather tool to answer weather questions.',
llm: 'gpt-4o-mini',
stream: false,
verbose: false,
tools: [getWeather]
});
const response = await agent.chat('What is the weather in Paris?');
if (response && /paris|22|sunny|weather/i.test(response)) {
console.log(' ✅ PASSED - Response mentions weather data');
passed++;
} else {
console.log(` ❌ FAILED - Response: "${response}"`);
failed++;
}
} catch (error: any) {
console.log(` ❌ FAILED - Error: ${error.message}`);
failed++;
}
// Test 3: Session ID
console.log('\n📝 Test 3: Session ID generation');
try {
const agent1 = new Agent({ instructions: 'Test' });
const agent2 = new Agent({ instructions: 'Test' });
if (agent1.getSessionId() !== agent2.getSessionId()) {
console.log(' ✅ PASSED - Unique session IDs generated');
passed++;
} else {
console.log(' ❌ FAILED - Session IDs are not unique');
failed++;
}
} catch (error: any) {
console.log(` ❌ FAILED - Error: ${error.message}`);
failed++;
}
// Summary
console.log('\n' + '='.repeat(50));
console.log(`📊 Results: ${passed} passed, ${failed} failed`);
console.log('='.repeat(50));
process.exit(failed > 0 ? 1 : 0);
}
runSmokeTests().catch(console.error);
@@ -0,0 +1,82 @@
/**
* Smoke Tests - Real API key validation (gated)
*
* These tests only run when API keys are available in environment
* AND when not running in mock mode.
* They validate real provider integration without exposing secrets.
*
* To run with real API:
* OPENAI_API_KEY=sk-... npm test -- --testPathPattern=smoke --no-coverage
*/
import { Agent } from '../../src';
const OPENAI_KEY = process.env.OPENAI_API_KEY;
const IS_MOCK_MODE = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined;
const SKIP_REASON = IS_MOCK_MODE
? 'Skipping: Running in mock mode (use real-smoke.test.ts for real API tests)'
: 'Skipping: OPENAI_API_KEY not set';
const SHOULD_RUN_REAL_API = OPENAI_KEY && !IS_MOCK_MODE;
describe('Real Provider Smoke Tests', () => {
describe('Agent.chat with OpenAI', () => {
(SHOULD_RUN_REAL_API ? it : it.skip)('should complete a simple chat', async () => {
const agent = new Agent({
instructions: 'You are a helpful assistant. Be very brief.',
llm: 'gpt-4o-mini',
stream: false,
verbose: false
});
const response = await agent.chat('Say "hello" and nothing else.');
expect(response).toBeDefined();
expect(typeof response).toBe('string');
expect(response.length).toBeGreaterThan(0);
// Response should contain "hello" in some form
expect(response.toLowerCase()).toContain('hello');
}, 30000);
(SHOULD_RUN_REAL_API ? it : it.skip)('should handle tool calling', async () => {
const getWeather = (city: string) => `Weather in ${city}: 22°C, Sunny`;
const agent = new Agent({
instructions: 'You are a weather assistant. Use the getWeather tool to answer.',
llm: 'gpt-4o-mini',
stream: false,
verbose: false,
tools: [getWeather]
});
const response = await agent.chat('What is the weather in Paris?');
expect(response).toBeDefined();
expect(typeof response).toBe('string');
// Response should mention the weather data
expect(response.toLowerCase()).toMatch(/paris|22|sunny|weather/i);
}, 60000);
});
describe('Session persistence', () => {
it('should generate unique session IDs', () => {
const agent1 = new Agent({ instructions: 'Test' });
const agent2 = new Agent({ instructions: 'Test' });
expect(agent1.getSessionId()).not.toBe(agent2.getSessionId());
});
it('should use provided session ID', () => {
const agent = new Agent({
instructions: 'Test',
sessionId: 'test-session-123'
});
expect(agent.getSessionId()).toBe('test-session-123');
});
});
});
// Log skip reason if no API key
if (!OPENAI_KEY) {
console.log(`\n⚠️ ${SKIP_REASON}\n`);
}
@@ -0,0 +1,170 @@
/**
* Agent API Tests - Unified Agent, Agents, db(), tools
*/
import { Agent, Agents, PraisonAIAgents, db, Workflow } from '../../src';
describe('Agent API', () => {
describe('Agent constructor', () => {
it('should create agent with instructions', () => {
const agent = new Agent({ instructions: 'You are helpful' });
expect(agent).toBeDefined();
expect(agent.getInstructions()).toBe('You are helpful');
});
it('should auto-generate name if not provided', () => {
const agent = new Agent({ instructions: 'Test' });
expect(agent.name).toMatch(/^Agent_/);
});
it('should use provided name', () => {
const agent = new Agent({ instructions: 'Test', name: 'MyAgent' });
expect(agent.name).toBe('MyAgent');
});
it('should support role/goal/backstory mode', () => {
const agent = new Agent({
instructions: '',
role: 'researcher',
goal: 'find information',
backstory: 'expert in research'
});
const instructions = agent.getInstructions();
expect(instructions).toContain('researcher');
expect(instructions).toContain('find information');
expect(instructions).toContain('expert in research');
});
it('should have sessionId', () => {
const agent = new Agent({ instructions: 'Test' });
expect(agent.getSessionId()).toBeDefined();
expect(typeof agent.getSessionId()).toBe('string');
});
it('should use provided sessionId', () => {
const agent = new Agent({ instructions: 'Test', sessionId: 'my-session' });
expect(agent.getSessionId()).toBe('my-session');
});
it('should have runId', () => {
const agent = new Agent({ instructions: 'Test' });
expect(agent.getRunId()).toBeDefined();
expect(typeof agent.getRunId()).toBe('string');
});
it('should accept db adapter', () => {
const dbAdapter = db('memory:');
const agent = new Agent({
instructions: 'Test',
db: dbAdapter
});
expect(agent).toBeDefined();
});
});
describe('Agents alias', () => {
it('should export Agents as alias for PraisonAIAgents', () => {
expect(Agents).toBe(PraisonAIAgents);
});
it('should support array syntax', () => {
const a1 = new Agent({ instructions: 'Agent 1' });
const a2 = new Agent({ instructions: 'Agent 2' });
const agents = new Agents([a1, a2]);
expect(agents).toBeDefined();
});
it('should support config object syntax', () => {
const a1 = new Agent({ instructions: 'Agent 1' });
const a2 = new Agent({ instructions: 'Agent 2' });
const agents = new Agents({
agents: [a1, a2],
process: 'sequential'
});
expect(agents).toBeDefined();
});
});
});
describe('db() factory', () => {
it('should create memory adapter by default', () => {
const adapter = db();
expect(adapter).toBeDefined();
});
it('should parse memory: URL', () => {
const adapter = db('memory:');
expect(adapter).toBeDefined();
});
it('should parse :memory: URL', () => {
const adapter = db(':memory:');
expect(adapter).toBeDefined();
});
it('should accept config object', () => {
const adapter = db({ type: 'memory' });
expect(adapter).toBeDefined();
});
it('should throw on invalid URL', () => {
expect(() => db('invalid://url')).toThrow();
});
});
describe('Tool auto-schema', () => {
it('should accept plain functions as tools', () => {
const getWeather = (city: string) => `Weather in ${city}: 20°C`;
const agent = new Agent({
instructions: 'Weather assistant',
tools: [getWeather]
});
expect(agent).toBeDefined();
});
it('should accept multiple function tools', () => {
const getWeather = (city: string) => `Weather: 20°C`;
const getTime = () => new Date().toISOString();
const agent = new Agent({
instructions: 'Assistant',
tools: [getWeather, getTime]
});
expect(agent).toBeDefined();
});
});
describe('Workflow.agent()', () => {
it('should add agent step to workflow', () => {
const agent = new Agent({ instructions: 'Test' });
const workflow = new Workflow('Test')
.agent(agent, 'Do something');
expect(workflow.stepCount).toBe(1);
});
it('should chain multiple agent steps', () => {
const a1 = new Agent({ instructions: 'Agent 1' });
const a2 = new Agent({ instructions: 'Agent 2' });
const workflow = new Workflow('Test')
.agent(a1, 'Step 1')
.agent(a2, 'Step 2');
expect(workflow.stepCount).toBe(2);
});
it('should mix agent and function steps', () => {
const agent = new Agent({ instructions: 'Test' });
const workflow = new Workflow('Test')
.step('preprocess', async (input) => input.toUpperCase())
.agent(agent, 'Process')
.step('postprocess', async (input) => input.trim());
expect(workflow.stepCount).toBe(3);
});
});
+12 -8
View File
@@ -1994,19 +1994,23 @@ manager.delete_checkpoint("deploy-v1")
### Tools CLI:
```bash
# List all available tools
praisonai tools list
# Get info about a specific tool
praisonai tools info internet_search
# Search for tools
praisonai tools search "web"
# Show all commands
praisonai tools help
praisonai tools doctor
praisonai tools resolve shell_tool
praisonai tools discover
praisonai tools show-sources
praisonai tools show-sources --template ai-video-editor
```
| Command | Example | Docs |
|---------|---------|------|
| `tools list` | [example](examples/tools/) | [docs](https://docs.praison.ai/docs/cli/tools) |
| `tools resolve` | [example](examples/tools/example_tools_resolve.py) | [docs](https://docs.praison.ai/docs/cli/tools-resolve) |
| `tools discover` | [example](examples/tools/example_tools_discover.py) | [docs](https://docs.praison.ai/docs/cli/tools-discover) |
| `tools show-sources` | [example](examples/tools/example_tools_sources.py) | [docs](https://docs.praison.ai/docs/cli/tools-show-sources) |
### Handoff CLI:
```bash
# Enable agent-to-agent task delegation
Whitespace-only changes.
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.6.2.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.6.2.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.6.3.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.6.3.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
@@ -35,6 +35,7 @@ Features:
- thinking: Thinking budget management
- compaction: Context compaction settings
- output_style: Output style configuration
- ollama: Ollama provider and Weak-Model-Proof execution
"""
# Type hints available for IDE support
@@ -87,6 +88,8 @@ __all__ = [
'ThinkingHandler',
'CompactionHandler',
'OutputStyleHandler',
# Ollama + WMP
'OllamaHandler',
]
def __getattr__(name):
@@ -204,4 +207,7 @@ def __getattr__(name):
elif name == 'OutputStyleHandler':
from .output_style import OutputStyleHandler
return OutputStyleHandler
elif name == 'OllamaHandler':
from .ollama import OllamaHandler
return OllamaHandler
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,361 @@
"""
Ollama CLI Handler
CLI support for Ollama provider and Weak-Model-Proof execution.
"""
import logging
import os
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
class OllamaHandler:
"""
Handler for Ollama CLI commands.
Supports:
- --provider ollama
- --weak-model-proof / --wmp
- --ollama-host
- --ollama-model
"""
def __init__(
self,
model: str = "llama3.2:3b",
host: Optional[str] = None,
weak_model_proof: bool = True,
verbose: bool = False,
**kwargs
):
"""
Initialize Ollama handler.
Args:
model: Ollama model name
host: Ollama server host
weak_model_proof: Enable WMP
verbose: Verbose output
**kwargs: Additional options
"""
self.model = model
self.host = host or os.environ.get("OLLAMA_HOST", "http://localhost:11434")
self.weak_model_proof = weak_model_proof
self.verbose = verbose
self.extra_kwargs = kwargs
self._provider = None
self._wmp_hooks = None
@property
def provider(self):
"""Lazy-load Ollama provider."""
if self._provider is None:
try:
from praisonai.providers import OllamaProvider
self._provider = OllamaProvider(
model=self.model,
host=self.host,
weak_model_proof=self.weak_model_proof,
**self.extra_kwargs
)
except ImportError as e:
logger.error(f"Failed to import OllamaProvider: {e}")
raise
return self._provider
@property
def wmp_hooks(self):
"""Lazy-load WMP hooks."""
if self._wmp_hooks is None and self.weak_model_proof:
try:
from praisonai.wmp import WMPHooks, WeakModelProofConfig
# Auto-configure based on model
config = WeakModelProofConfig.for_ollama(
self._detect_model_size()
)
self._wmp_hooks = WMPHooks(
config=config,
llm_fn=self._llm_fn,
)
except ImportError as e:
logger.warning(f"WMP not available: {e}")
self._wmp_hooks = None
return self._wmp_hooks
def _detect_model_size(self) -> str:
"""Detect model size from name."""
model_lower = self.model.lower()
if any(x in model_lower for x in ["70b", "72b", "65b"]):
return "large"
elif any(x in model_lower for x in ["13b", "14b", "7b", "8b"]):
return "medium"
else:
return "small"
def _llm_fn(self, prompt: str) -> str:
"""LLM function for WMP."""
response = self.provider.chat(prompt)
return response.content
def chat(
self,
message: str,
system: Optional[str] = None,
stream: bool = False,
) -> str:
"""
Send a chat message.
Args:
message: User message
system: System prompt
stream: Enable streaming
Returns:
Response text
"""
if self.weak_model_proof and self.wmp_hooks:
# Use WMP execution
self.wmp_hooks.on_task_start(message)
messages = [{"role": "user", "content": message}]
if system:
messages.insert(0, {"role": "system", "content": system})
enhanced_messages = self.wmp_hooks.on_before_llm_call(messages)
# Call Ollama
response = self.provider.chat(
messages=enhanced_messages,
stream=stream,
)
if stream:
# Collect streamed response
full_response = ""
for chunk in response:
full_response += chunk
if self.verbose:
print(chunk, end="", flush=True)
if self.verbose:
print()
response_text = full_response
else:
response_text = response.content
# Post-process with WMP
response_text = self.wmp_hooks.on_after_llm_call(response_text)
return response_text
else:
# Direct Ollama call
response = self.provider.chat(
messages=message,
system=system,
stream=stream,
)
if stream:
full_response = ""
for chunk in response:
full_response += chunk
if self.verbose:
print(chunk, end="", flush=True)
if self.verbose:
print()
return full_response
else:
return response.content
def execute(
self,
task: str,
tools: Optional[List] = None,
) -> Dict[str, Any]:
"""
Execute a task with WMP.
Args:
task: Task description
tools: Available tools
Returns:
Execution result
"""
if self.weak_model_proof:
try:
from praisonai.wmp import WMPExecutor, WeakModelProofConfig
config = WeakModelProofConfig.for_ollama(
self._detect_model_size()
)
executor = WMPExecutor(config=config)
result = executor.execute(
task=task,
llm_fn=self._llm_fn,
tools=tools,
)
return result.to_dict()
except ImportError:
logger.warning("WMP not available, falling back to direct execution")
# Direct execution
response = self.chat(task)
return {
"success": True,
"result": response,
"wmp_enabled": False,
}
def list_models(self) -> List[Dict[str, Any]]:
"""List available Ollama models."""
return self.provider.list_models()
def pull_model(self, model: str) -> None:
"""Pull a model from Ollama registry."""
if self.verbose:
print(f"Pulling model: {model}")
for progress in self.provider.pull_model(model, stream=True):
if self.verbose and hasattr(progress, 'status'):
print(f" {progress.status}", end="\r")
if self.verbose:
print(f"\nModel {model} pulled successfully")
def is_available(self) -> bool:
"""Check if Ollama is available."""
return self.provider.is_available()
def handle_ollama_command(args, unknown_args: List[str] = None) -> int:
"""
Handle Ollama CLI commands.
Args:
args: Parsed arguments
unknown_args: Unknown arguments
Returns:
Exit code
"""
from rich.console import Console
console = Console()
# Get options from args
model = getattr(args, 'ollama_model', None) or getattr(args, 'model', 'llama3.2:3b')
host = getattr(args, 'ollama_host', None)
wmp = getattr(args, 'weak_model_proof', True)
verbose = getattr(args, 'verbose', False)
try:
handler = OllamaHandler(
model=model,
host=host,
weak_model_proof=wmp,
verbose=verbose,
)
# Check availability
if not handler.is_available():
console.print("[red]Error: Ollama server not available[/red]")
console.print(f"Make sure Ollama is running at {handler.host}")
return 1
# Get command/prompt
prompt = getattr(args, 'command', None) or getattr(args, 'direct_prompt', None)
if not prompt:
console.print("[yellow]No prompt provided[/yellow]")
return 1
# Execute
if wmp:
console.print(f"[dim]Using Ollama with Weak-Model-Proof ({model})[/dim]")
else:
console.print(f"[dim]Using Ollama ({model})[/dim]")
result = handler.execute(prompt)
if result.get("success"):
console.print(result.get("result", ""))
if verbose and result.get("wmp_enabled"):
console.print(f"\n[dim]Steps: {result.get('steps_executed', 0)}, "
f"Retries: {result.get('retries', 0)}[/dim]")
else:
console.print(f"[red]Error: {result.get('error', 'Unknown error')}[/red]")
return 1
return 0
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
if verbose:
import traceback
console.print(traceback.format_exc())
return 1
def add_ollama_arguments(parser) -> None:
"""
Add Ollama-related arguments to parser.
Args:
parser: ArgumentParser instance
"""
parser.add_argument(
"--provider",
type=str,
choices=["ollama", "litellm", "openai"],
help="LLM provider to use"
)
parser.add_argument(
"--ollama-model",
type=str,
default="llama3.2:3b",
help="Ollama model name (e.g., llama3.2:3b, mistral, qwen2.5:7b)"
)
parser.add_argument(
"--ollama-host",
type=str,
help="Ollama server host (default: http://localhost:11434)"
)
parser.add_argument(
"--weak-model-proof", "--wmp",
action="store_true",
default=None,
dest="weak_model_proof",
help="Enable Weak-Model-Proof execution (default: auto for Ollama)"
)
parser.add_argument(
"--no-wmp",
action="store_false",
dest="weak_model_proof",
help="Disable Weak-Model-Proof execution"
)
parser.add_argument(
"--wmp-strict",
action="store_true",
help="Use strict WMP mode (more retries, stricter validation)"
)
parser.add_argument(
"--wmp-fast",
action="store_true",
help="Use fast WMP mode (fewer retries, faster execution)"
)
parser.add_argument(
"--step-budget",
type=int,
default=10,
help="Maximum steps for WMP execution (default: 10)"
)
@@ -472,10 +472,12 @@ class TemplatesHandler:
template_args = args[1:]
offline = "--offline" in args
strict_tools = "--strict-tools" in args
no_template_tools_py = "--no-template-tools-py" in args
# Parse --tools override
# Parse --tools, --tools-dir, --tools-source overrides
tools_files = []
tools_dirs = []
tools_sources_override = []
i = 0
while i < len(args):
if args[i] == "--tools" and i + 1 < len(args):
@@ -484,6 +486,9 @@ class TemplatesHandler:
elif args[i] == "--tools-dir" and i + 1 < len(args):
tools_dirs.append(args[i + 1])
i += 2
elif args[i] == "--tools-source" and i + 1 < len(args):
tools_sources_override.append(args[i + 1])
i += 2
else:
i += 1
@@ -530,13 +535,33 @@ class TemplatesHandler:
# Load tool overrides if specified
tool_registry = None
from praisonai.templates.tool_override import create_tool_registry_with_overrides, resolve_tools
# Get template directory for local tools.py autoload (unless disabled)
template_dir = None
if not no_template_tools_py:
template_dir = str(template.path) if template.path else None
# Get tools_sources from template requires + CLI overrides
tools_sources = []
if template.requires and isinstance(template.requires, dict):
ts = template.requires.get("tools_sources", [])
if ts:
tools_sources.extend(ts)
# Add CLI --tools-source overrides
if tools_sources_override:
tools_sources.extend(tools_sources_override)
# Always build registry (includes defaults + template sources)
tool_registry = create_tool_registry_with_overrides(
override_files=tools_files if tools_files else None,
override_dirs=tools_dirs if tools_dirs else None,
include_defaults=True,
tools_sources=tools_sources if tools_sources else None,
template_dir=template_dir,
)
if tools_files or tools_dirs:
from praisonai.templates.tool_override import create_tool_registry_with_overrides
tool_registry = create_tool_registry_with_overrides(
override_files=tools_files,
override_dirs=tools_dirs,
include_defaults=True
)
print(f"✓ Loaded {len(tool_registry)} tools from overrides")
# Load and run workflow
@@ -560,12 +585,20 @@ class TemplatesHandler:
agent_map = {}
for agent_cfg in agents_config:
# Resolve tool names to callable tools
agent_tool_names = agent_cfg.get("tools", [])
resolved_agent_tools = resolve_tools(
agent_tool_names,
registry=tool_registry,
template_dir=template_dir
)
agent = Agent(
name=agent_cfg.get("name", "Agent"),
role=agent_cfg.get("role", ""),
goal=agent_cfg.get("goal", ""),
backstory=agent_cfg.get("backstory", ""),
tools=agent_cfg.get("tools", []),
tools=resolved_agent_tools,
llm=agent_cfg.get("llm"),
verbose=agent_cfg.get("verbose", True)
)
@@ -597,8 +630,21 @@ class TemplatesHandler:
)
praison_agents.start()
elif "steps" in workflow_config:
# Workflow format (steps)
# Workflow format (steps) - resolve tools in each step's agent
from praisonaiagents import Workflow
# Resolve tools for each step's agent if specified
steps = workflow_config.get("steps", [])
for step in steps:
if "agent" in step and isinstance(step["agent"], dict):
agent_cfg = step["agent"]
if "tools" in agent_cfg:
agent_cfg["tools"] = resolve_tools(
agent_cfg["tools"],
registry=tool_registry,
template_dir=template_dir
)
workflow = Workflow(**workflow_config)
workflow.run()
else:
+202 -1
View File
@@ -31,7 +31,7 @@ class ToolsHandler(CommandHandler):
return "tools"
def get_actions(self) -> List[str]:
return ["list", "info", "search", "doctor", "help"]
return ["list", "info", "search", "doctor", "resolve", "discover", "show-sources", "help"]
def get_help_text(self) -> str:
return """
@@ -41,6 +41,9 @@ Tools Commands:
praisonai tools search <query> - Search tools by name/description
praisonai tools doctor - Diagnose tool availability and dependencies
praisonai tools doctor --json - Output diagnosis as JSON
praisonai tools resolve <name> - Resolve a tool name to its source
praisonai tools discover - Discover tools from installed packages
praisonai tools show-sources - Show all tool sources for a template
Built-in tools include: internet_search, calculator, file operations, etc.
"""
@@ -243,6 +246,204 @@ Built-in tools include: internet_search, calculator, file operations, etc.
self.print_status(f"Error running diagnostics: {e}", "error")
return {}
def action_resolve(self, args: List[str], **kwargs) -> Dict[str, Any]:
"""
Resolve a tool name to its source.
Args:
args: [tool_name, --template <template>, --tools <file>, --tools-dir <dir>]
"""
if not args:
self.print_status("Usage: praisonai tools resolve <tool_name> [--template <template>]", "error")
return {}
tool_name = args[0]
template_name = None
tools_files = []
tools_dirs = []
# Parse flags
i = 1
while i < len(args):
if args[i] == "--template" and i + 1 < len(args):
template_name = args[i + 1]
i += 2
elif args[i] == "--tools" and i + 1 < len(args):
tools_files.append(args[i + 1])
i += 2
elif args[i] == "--tools-dir" and i + 1 < len(args):
tools_dirs.append(args[i + 1])
i += 2
else:
i += 1
try:
from praisonai.templates.tool_override import create_tool_registry_with_overrides, resolve_tools
registry = create_tool_registry_with_overrides(
override_files=tools_files if tools_files else None,
override_dirs=tools_dirs if tools_dirs else None,
include_defaults=True,
)
resolved = resolve_tools([tool_name], registry=registry)
if resolved:
tool = resolved[0]
info = {
"name": tool_name,
"resolved": True,
"type": type(tool).__name__,
"module": getattr(tool, "__module__", "unknown"),
}
self.print_status(f"\n✓ Tool '{tool_name}' resolved:", "success")
for k, v in info.items():
self.print_status(f" {k}: {v}", "info")
return info
else:
self.print_status(f"✗ Tool '{tool_name}' not found", "error")
return {"name": tool_name, "resolved": False}
except Exception as e:
self.print_status(f"Error resolving tool: {e}", "error")
return {}
def action_discover(self, args: List[str], **kwargs) -> Dict[str, Any]:
"""
Discover tools from installed packages.
Args:
args: [--include <package>, --entrypoints]
"""
include_packages = []
use_entrypoints = "--entrypoints" in args
i = 0
while i < len(args):
if args[i] == "--include" and i + 1 < len(args):
include_packages.append(args[i + 1])
i += 2
else:
i += 1
discovered = {}
# Try praisonai_tools package
try:
import praisonai_tools
pkg_tools = []
# Check for video module
try:
from praisonai_tools import video
pkg_tools.append("praisonai_tools.video")
except ImportError:
pass
# Check for tools module
try:
import praisonai_tools.tools as ext_tools
for name in dir(ext_tools):
if not name.startswith('_'):
obj = getattr(ext_tools, name, None)
if callable(obj):
pkg_tools.append(name)
except ImportError:
pass
if pkg_tools:
discovered["praisonai_tools"] = pkg_tools
except ImportError:
pass
# Try praisonaiagents built-in tools
try:
from praisonaiagents.tools import TOOL_MAPPINGS
discovered["praisonaiagents.tools"] = list(TOOL_MAPPINGS.keys())[:20] # Limit
except ImportError:
pass
# Additional packages from --include
for pkg in include_packages:
try:
import importlib
mod = importlib.import_module(pkg)
tools = [n for n in dir(mod) if not n.startswith('_') and callable(getattr(mod, n, None))]
if tools:
discovered[pkg] = tools[:20]
except ImportError:
self.print_status(f"Package '{pkg}' not found", "warning")
self.print_status("\n🔍 Discovered Tools:", "info")
for pkg, tools in discovered.items():
self.print_status(f"\n {pkg}:", "info")
for t in tools[:10]:
self.print_status(f" • {t}", "info")
if len(tools) > 10:
self.print_status(f" ... and {len(tools) - 10} more", "info")
return discovered
def action_show_sources(self, args: List[str], **kwargs) -> Dict[str, Any]:
"""
Show all tool sources for a template.
Args:
args: [--template <template>]
"""
template_name = None
i = 0
while i < len(args):
if args[i] == "--template" and i + 1 < len(args):
template_name = args[i + 1]
i += 2
else:
i += 1
sources = {
"built_in": "praisonaiagents.tools.TOOL_MAPPINGS",
"package_discovery": ["praisonai_tools"],
"default_dirs": [
"~/.praison/tools",
"~/.config/praison/tools",
],
}
if template_name:
try:
from praisonai.templates.loader import TemplateLoader
loader = TemplateLoader()
template = loader.load_template(template_name)
if template.requires and isinstance(template.requires, dict):
ts = template.requires.get("tools_sources", [])
if ts:
sources["template_tools_sources"] = ts
if template.path:
from pathlib import Path
tools_py = Path(template.path) / "tools.py"
if tools_py.exists():
sources["template_local_tools_py"] = str(tools_py)
except Exception as e:
self.print_status(f"Could not load template: {e}", "warning")
self.print_status("\n📦 Tool Sources:", "info")
for source_type, value in sources.items():
if isinstance(value, list):
self.print_status(f"\n {source_type}:", "info")
for v in value:
self.print_status(f" • {v}", "info")
else:
self.print_status(f"\n {source_type}: {value}", "info")
return sources
def execute(self, action: str, action_args: List[str], **kwargs) -> Any:
"""Execute tools command action."""
# Handle hyphenated action names
if action == "show-sources":
return self.action_show_sources(action_args, **kwargs)
return super().execute(action, action_args, **kwargs)
+68 -20
View File
@@ -312,7 +312,8 @@ class PraisonAI:
if combined_inputs:
combined_prompt = f"{args.command} {' '.join(combined_inputs)}"
result = self.handle_direct_prompt(combined_prompt)
print(result)
if result is not None:
print(result)
return result
else:
self.agent_file = args.command
@@ -339,7 +340,8 @@ class PraisonAI:
prompt_parts.append(file_input)
prompt = ' '.join(prompt_parts)
result = self.handle_direct_prompt(prompt)
print(result)
if result is not None:
print(result)
return result
else:
# Agent file was explicitly set, ignore direct prompt and use the file
@@ -355,7 +357,8 @@ class PraisonAI:
inputs.append(file_input)
combined_input = ' '.join(inputs)
result = self.handle_direct_prompt(combined_input)
print(result)
if result is not None:
print(result)
return result
# If no command or direct_prompt, preserve agent_file from constructor (don't overwrite)
@@ -678,7 +681,7 @@ class PraisonAI:
return default_args
# Define special commands
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools', 'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile', 'eval', 'agents', 'run', 'thinking', 'compaction', 'output', 'deploy']
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools', 'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile', 'eval', 'agents', 'run', 'thinking', 'compaction', 'output', 'deploy', 'templates']
parser = argparse.ArgumentParser(prog="praisonai", description="praisonAI command-line interface")
parser.add_argument("--framework", choices=["crewai", "autogen", "praisonai"], help="Specify the framework")
@@ -711,6 +714,7 @@ class PraisonAI:
parser.add_argument("--expand-prompt", action="store_true", help="Expand short prompt into detailed prompt (works with any command)")
parser.add_argument("--expand-tools", type=str, help="Tools for prompt expander (e.g., 'internet_search' or path to tools.py)")
parser.add_argument("--tools", "-t", type=str, help="Path to tools.py file for research agent")
parser.add_argument("--no-tools", action="store_true", help="Disable default built-in tools (for models that don't support tool calling)")
parser.add_argument("--save", "-s", action="store_true", help="Save research output to file (output/research/)")
parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose output for research")
parser.add_argument("--web-search", action="store_true", help="Enable native web search (OpenAI, Gemini, Anthropic, xAI, Perplexity)")
@@ -832,6 +836,16 @@ class PraisonAI:
parser.add_argument("--temperature", type=float, help="Override temperature for LLM calls")
parser.add_argument("--llm-provider", type=str, help="Override LLM provider (openai, anthropic, google, etc.)")
# Ollama Provider - native Ollama integration
parser.add_argument("--ollama-model", type=str, help="Ollama model name (e.g., llama3.2:3b, mistral, qwen2.5:7b)")
parser.add_argument("--ollama-host", type=str, help="Ollama server host (default: http://localhost:11434)")
# Tool calling reliability - for weak/local models like Ollama
parser.add_argument("--max-tool-repairs", type=int, default=None,
help="Max tool call repair attempts (default: 2 for Ollama, 0 for others)")
parser.add_argument("--force-tool-usage", type=str, choices=["auto", "always", "never"], default=None,
help="Force tool usage mode: auto (default for Ollama), always, never")
# If we're in a test environment, parse with empty args to avoid pytest interference
if in_test_env:
args, unknown_args = parser.parse_known_args([])
@@ -3320,21 +3334,32 @@ Provide ONLY the commit message, no explanations."""
else:
agent_config["verbose"] = False
# Load default tools (same as interactive mode)
default_tools = self._load_interactive_tools()
if default_tools:
agent_config["tools"] = default_tools
# Load default tools (same as interactive mode) unless --no-tools is set
if not getattr(self.args, 'no_tools', False):
default_tools = self._load_interactive_tools()
if default_tools:
agent_config["tools"] = default_tools
else:
agent_config["tools"] = [] # Explicitly set empty tools
# Add llm if specified
if hasattr(self, 'args') and self.args.llm:
# Check if max_tokens is specified - pass as dict config
# Build LLM config dict
llm_config = {"model": self.args.llm}
# Add max_tokens if specified
max_tokens = getattr(self.args, 'max_tokens', 16000)
if max_tokens:
# Pass llm as dict with model and max_tokens
agent_config["llm"] = {"model": self.args.llm, "max_tokens": max_tokens}
llm_config["max_tokens"] = max_tokens
logging.debug(f"Max tokens set to: {max_tokens}")
else:
agent_config["llm"] = self.args.llm
# Add tool reliability settings (for weak models like Ollama)
if getattr(self.args, 'max_tool_repairs', None) is not None:
llm_config["max_tool_repairs"] = self.args.max_tool_repairs
if getattr(self.args, 'force_tool_usage', None) is not None:
llm_config["force_tool_usage"] = self.args.force_tool_usage
agent_config["llm"] = llm_config
# Add feature flags if enabled
if hasattr(self, 'args'):
@@ -3487,8 +3512,7 @@ Provide ONLY the commit message, no explanations."""
from .features.external_agents import ExternalAgentsHandler
handler = ExternalAgentsHandler(verbose=getattr(self.args, 'verbose', False))
# Get workspace from current directory
import os
# Get workspace from current directory (os is imported at module level)
workspace = os.getcwd()
integration = handler.get_integration(external_agent_name, workspace=workspace)
@@ -3604,7 +3628,11 @@ Provide ONLY the commit message, no explanations."""
if not is_verbose:
result = self._run_with_status_display(agent, prompt)
else:
result = agent.start(prompt)
# Try start method first, fallback to chat
if hasattr(agent, 'start'):
result = agent.start(prompt)
else:
result = agent.chat(prompt)
# ===== POST-PROCESSING WITH NEW FEATURES =====
@@ -3752,11 +3780,24 @@ Now, {final_instruction.lower()}:"""
from rich.live import Live
from rich.text import Text
# Import callback registration from praisonaiagents
from praisonaiagents import register_display_callback, sync_display_callbacks
console = Console()
# Import callback registration from praisonaiagents
try:
from praisonaiagents import register_display_callback, sync_display_callbacks
except ImportError:
# Fallback if callbacks not available - just run agent directly
try:
result = agent.start(prompt)
except AttributeError:
# Try chat method if start not available
result = agent.chat(prompt)
if result:
console.print(result)
elif result == "" or result is None:
console.print("[dim]No response generated[/dim]")
return result
# Get tool names for display
tool_names = []
if hasattr(agent, 'tools') and agent.tools:
@@ -3936,7 +3977,14 @@ Now, {final_instruction.lower()}:"""
if status_info['tool_calls']:
console.print(f"[dim]Tools used: {', '.join(status_info['tool_calls'])}[/dim]")
return status_info['result']
# Get the result and print it directly here to ensure it's displayed
result = status_info['result']
if result:
console.print(result)
elif result == "" or result is None:
console.print("[dim]No response generated[/dim]")
return result
def _handle_serve_command(self, args, unknown_args):
"""
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==2.6.2 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==2.6.3 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -271,21 +271,28 @@ class ToolOverrideLoader:
def create_tool_registry_with_overrides(
override_files: Optional[List[str]] = None,
override_dirs: Optional[List[str]] = None,
include_defaults: bool = True
include_defaults: bool = True,
tools_sources: Optional[List[str]] = None,
template_dir: Optional[str] = None,
) -> Dict[str, Callable]:
"""
Create a tool registry with custom overrides.
Resolution order (highest priority first):
1. Override files (explicit)
2. Override directories
3. Default custom dirs (~/.praison/tools, etc.)
4. Built-in tools
1. Override files (explicit CLI --tools)
2. Override directories (explicit CLI --tools-dir)
3. Template tools_sources (from TEMPLATE.yaml)
4. Template-local tools.py
5. Default custom dirs (~/.praison/tools, etc.)
6. Package discovery (praisonai-tools if installed)
7. Built-in tools
Args:
override_files: Explicit tool files to load
override_dirs: Directories to scan for tools
include_defaults: Whether to include default tool directories
tools_sources: Template-declared tool sources (modules or paths)
template_dir: Template directory for local tools.py
Returns:
Dict mapping tool names to callable functions
@@ -293,14 +300,26 @@ def create_tool_registry_with_overrides(
registry = {}
loader = ToolOverrideLoader()
# Start with built-in tools (lowest priority)
# 7. Start with built-in tools (lowest priority)
try:
from praisonaiagents.tools import TOOL_MAPPINGS
registry.update(TOOL_MAPPINGS)
except ImportError:
pass
# Add default custom dirs
# 6. Package discovery - try praisonai-tools if installed
try:
import praisonai_tools.tools as external_tools
# Get all exported tools from praisonai_tools
for name in dir(external_tools):
if not name.startswith('_'):
obj = getattr(external_tools, name, None)
if callable(obj) or (hasattr(obj, 'run') and callable(getattr(obj, 'run', None))):
registry[name] = obj
except ImportError:
pass
# 5. Add default custom dirs
if include_defaults:
for dir_path in loader.get_default_tool_dirs():
if dir_path.exists():
@@ -310,7 +329,42 @@ def create_tool_registry_with_overrides(
except Exception:
pass
# Add override directories
# 4. Template-local tools.py
if template_dir:
tools_py = Path(template_dir) / "tools.py"
if tools_py.exists():
try:
tools = loader.load_from_file(str(tools_py))
registry.update(tools)
except Exception:
pass
# 3. Template tools_sources (from TEMPLATE.yaml)
if tools_sources:
for source in tools_sources:
try:
# Security: only allow local paths and python modules
if source.startswith(("http://", "https://", "ftp://")):
continue # Skip remote URLs
source_path = Path(source).expanduser()
if source_path.exists():
# It's a local path
if source_path.is_file() and source_path.suffix == ".py":
tools = loader.load_from_file(str(source_path))
registry.update(tools)
elif source_path.is_dir():
tools = loader.load_from_directory(str(source_path))
registry.update(tools)
else:
# Try as a Python module path
tools = loader.load_from_module(source)
registry.update(tools)
except Exception:
pass
# 2. Add override directories (CLI --tools-dir)
if override_dirs:
for dir_path in override_dirs:
try:
@@ -319,7 +373,7 @@ def create_tool_registry_with_overrides(
except Exception:
pass
# Add override files (highest priority)
# 1. Add override files (highest priority, CLI --tools)
if override_files:
for file_path in override_files:
try:
@@ -329,3 +383,106 @@ def create_tool_registry_with_overrides(
pass
return registry
def resolve_tools(
tool_names: List[Any],
registry: Optional[Dict[str, Callable]] = None,
template_dir: Optional[str] = None,
) -> List[Callable]:
"""
Resolve tool names to callable tools from registry.
Handles:
- String tool names (looked up in registry)
- Already-callable tools (passed through)
- Built-in tool names (shell_tool, file_tool, etc.)
Args:
tool_names: List of tool names (strings) or callables
registry: Tool registry to look up names in
template_dir: Optional template directory for local tools.py autoload
Returns:
List of resolved callable tools
"""
if not tool_names:
return []
resolved = []
# Build registry if not provided
if registry is None:
registry = create_tool_registry_with_overrides(include_defaults=True)
# Load template-local tools.py if exists
if template_dir:
loader = ToolOverrideLoader()
tools_py = Path(template_dir) / "tools.py"
if tools_py.exists():
try:
local_tools = loader.load_from_file(str(tools_py))
registry.update(local_tools)
except Exception:
pass
for tool in tool_names:
if callable(tool):
# Already a callable, use directly
resolved.append(tool)
elif isinstance(tool, str):
# Look up by name in registry
tool_name = tool.strip()
# Try exact match first
if tool_name in registry:
tool_obj = registry[tool_name]
# Handle lazy-loaded tools (tuples of module, class)
if isinstance(tool_obj, tuple):
try:
module_name, class_name = tool_obj
import importlib
module = importlib.import_module(module_name)
tool_class = getattr(module, class_name)
resolved.append(tool_class())
except Exception:
pass
elif callable(tool_obj):
resolved.append(tool_obj)
else:
# Try to instantiate if it's a class
try:
resolved.append(tool_obj())
except Exception:
resolved.append(tool_obj)
else:
# Try common variations
variations = [
tool_name,
tool_name.lower(),
tool_name.replace("-", "_"),
tool_name.replace("_", "-"),
f"{tool_name}_tool",
f"{tool_name}Tool",
]
found = False
for var in variations:
if var in registry:
tool_obj = registry[var]
if callable(tool_obj):
resolved.append(tool_obj)
found = True
break
if not found:
# Try to import from praisonaiagents.tools
try:
from praisonaiagents import tools as agent_tools
if hasattr(agent_tools, tool_name):
resolved.append(getattr(agent_tools, tool_name))
elif hasattr(agent_tools, f"{tool_name}_tool"):
resolved.append(getattr(agent_tools, f"{tool_name}_tool"))
except (ImportError, AttributeError):
pass
return resolved
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.6.2"
__version__ = "2.6.3"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=0.4.0",
"praisonaiagents>=0.4.1",
"python-dotenv>=0.19.0",
"instructor>=1.3.3",
"PyYAML>=6.0",
+3 -2
View File
@@ -24,6 +24,7 @@ import argparse
import subprocess
import shutil
from pathlib import Path
from typing import Optional
def get_project_root() -> Path:
@@ -36,7 +37,7 @@ def get_praisonai_dir() -> Path:
return get_project_root() / "src/praisonai"
def run(cmd: list[str], cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess:
def run(cmd: list[str], cwd: Optional[Path] = None, check: bool = True) -> subprocess.CompletedProcess:
"""Run a command and print it."""
print(f"$ {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
@@ -71,7 +72,7 @@ def update_file(filepath: Path, patterns: list[tuple[str, str]], root: Path) ->
return False
def bump_version(new_version: str, agents_version: str | None = None):
def bump_version(new_version: str, agents_version: Optional[str] = None):
"""Bump version in all required files."""
root = get_project_root()
praisonai_dir = get_praisonai_dir()
@@ -0,0 +1,230 @@
"""
Live integration tests for Ollama tool calling.
These tests require:
1. Ollama running locally (default: http://localhost:11434)
2. olmo-3 model pulled: ollama pull olmo-3
3. Environment variable: PRAISONAI_TEST_OLLAMA=1
Run with:
PRAISONAI_TEST_OLLAMA=1 LOGLEVEL=debug python -m pytest tests/integration/test_ollama_tool_calling_live.py -v
"""
import os
import pytest
import logging
# Skip all tests in this module if PRAISONAI_TEST_OLLAMA is not set
pytestmark = pytest.mark.skipif(
not os.getenv("PRAISONAI_TEST_OLLAMA"),
reason="Ollama live tests disabled. Set PRAISONAI_TEST_OLLAMA=1 to enable."
)
def calculator(a: int, b: int) -> int:
"""Add two integers together.
Args:
a: First number to add
b: Second number to add
Returns:
The sum of a and b
"""
return a + b
def check_ollama_available():
"""Check if Ollama is running and accessible."""
try:
import httpx
response = httpx.get("http://localhost:11434/api/tags", timeout=5.0)
return response.status_code == 200
except Exception:
return False
@pytest.fixture(scope="module")
def ollama_available():
"""Fixture to check Ollama availability."""
if not check_ollama_available():
pytest.skip("Ollama is not running at localhost:11434")
return True
class TestOllamaToolCallingLive:
"""Live tests for Ollama tool calling with olmo-3."""
def test_basic_tool_call(self, ollama_available):
"""Test basic tool calling with calculator."""
from praisonaiagents import Agent
agent = Agent(
name="Calculator Agent",
llm="ollama/olmo-3",
tools=[calculator],
verbose=True
)
result = agent.chat("Compute 17 + 25. You MUST use the calculator tool.")
# Verify result contains the correct answer
assert result is not None
assert "42" in str(result)
def test_forced_tool_usage(self, ollama_available):
"""Test that force_tool_usage=always works."""
from praisonaiagents import Agent
agent = Agent(
name="Calculator Agent",
llm="ollama/olmo-3",
tools=[calculator],
force_tool_usage="always",
verbose=True
)
result = agent.chat("What is 17 plus 25?")
# Should still get correct answer via tool
assert result is not None
assert "42" in str(result)
def test_tool_call_with_distraction(self, ollama_available):
"""Test tool calling when model might want to answer directly."""
from praisonaiagents import Agent
agent = Agent(
name="Calculator Agent",
llm="ollama/olmo-3",
tools=[calculator],
verbose=True
)
result = agent.chat(
"I know 17+25 is easy, but please use the calculator tool to compute it anyway."
)
assert result is not None
assert "42" in str(result)
def test_multi_step_arithmetic(self, ollama_available):
"""Test multiple tool calls for multi-step computation."""
from praisonaiagents import Agent
agent = Agent(
name="Calculator Agent",
llm="ollama/olmo-3",
tools=[calculator],
verbose=True
)
result = agent.chat(
"Compute (17+25) + (8+9). Use the calculator tool for each addition."
)
# Final answer should be 59 (42 + 17)
assert result is not None
# Check for either intermediate or final results
assert any(x in str(result) for x in ["42", "17", "59"])
def test_max_tool_repairs_setting(self, ollama_available):
"""Test that max_tool_repairs setting is respected."""
from praisonaiagents import Agent
agent = Agent(
name="Calculator Agent",
llm="ollama/olmo-3",
tools=[calculator],
max_tool_repairs=3,
verbose=True
)
result = agent.chat("Calculate 100 + 200 using the calculator tool.")
assert result is not None
assert "300" in str(result)
def test_no_tools_direct_answer(self, ollama_available):
"""Test that model answers directly when no tools provided."""
from praisonaiagents import Agent
agent = Agent(
name="Direct Agent",
llm="ollama/olmo-3",
tools=[], # No tools
verbose=True
)
result = agent.chat("What is 17 + 25?")
# Should get an answer (may or may not be correct without tools)
assert result is not None
class TestOllamaToolCallingDebugLogging:
"""Tests that verify debug logging works correctly."""
def test_debug_logging_enabled(self, ollama_available, caplog):
"""Test that debug logging captures tool call details."""
from praisonaiagents import Agent
# Enable debug logging
logging.getLogger().setLevel(logging.DEBUG)
with caplog.at_level(logging.DEBUG):
agent = Agent(
name="Calculator Agent",
llm="ollama/olmo-3",
tools=[calculator],
verbose=True
)
result = agent.chat("Compute 5 + 3 using the calculator tool.")
# Check that debug logs were captured
# Should see tool-related debug messages
# Note: exact messages depend on implementation
assert result is not None
# Verify some logging occurred (caplog.text contains all captured logs)
assert len(caplog.records) >= 0 # At minimum, logging infrastructure works
if __name__ == "__main__":
# Enable debug logging for manual runs
logging.basicConfig(level=logging.DEBUG)
print("=" * 60)
print("Ollama Tool Calling Live Tests")
print("=" * 60)
if not check_ollama_available():
print("ERROR: Ollama is not running at localhost:11434")
print("Start Ollama with: ollama serve")
print("Pull model with: ollama pull olmo-3")
exit(1)
print("Ollama is available. Running tests...")
print()
# Run a simple test
from praisonaiagents import Agent
agent = Agent(
name="Calculator Agent",
llm="ollama/olmo-3",
tools=[calculator],
verbose=True
)
print("Test 1: Basic tool call")
print("-" * 40)
result = agent.chat("Compute 17 + 25. You MUST use the calculator tool.")
print(f"Result: {result}")
print()
if "42" in str(result):
print("✅ Test PASSED: Got correct answer 42")
else:
print("❌ Test FAILED: Did not get expected answer 42")
+1
View File
@@ -0,0 +1 @@
# Unit tests for LLM module
@@ -0,0 +1,541 @@
"""
Unit tests for Ollama tool calling reliability features.
Tests cover:
1. Tool call parsing from JSON response
2. Force tool usage prompting
3. Tool call repair loop
4. max_tool_repairs configuration
5. force_tool_usage modes (auto/always/never)
6. Loop prevention
These tests mock the LLM class to avoid requiring litellm.
"""
import pytest
import os
import json
# Set dummy API key for tests
os.environ.setdefault('OPENAI_API_KEY', 'test-key-for-unit-tests')
class MockLLM:
"""Mock LLM class for testing tool reliability features without litellm."""
# Prompt templates (copied from actual LLM class)
FORCE_TOOL_USAGE_PROMPT = """You MUST use one of the available tools to answer this question.
Do NOT provide a direct answer. Respond ONLY with a tool call.
Available tools: {tool_names}
Respond with a JSON object in this format:
{{"name": "tool_name", "arguments": {{"arg1": value1, "arg2": value2}}}}"""
TOOL_CALL_REPAIR_PROMPT = """Your previous tool call was malformed: {error}
Please try again. Available tools:
{tool_schemas}
Respond with ONLY a valid JSON tool call in this format:
{{"name": "tool_name", "arguments": {{"arg1": value1, "arg2": value2}}}}"""
def __init__(self, model, max_tool_repairs=None, force_tool_usage=None, base_url=None, **kwargs):
self.model = model
self.base_url = base_url
# Track if values were explicitly set
self._max_tool_repairs_explicit = max_tool_repairs is not None
self._force_tool_usage_explicit = force_tool_usage is not None
# Set defaults
self.max_tool_repairs = max_tool_repairs if max_tool_repairs is not None else 0
self.force_tool_usage = force_tool_usage if force_tool_usage is not None else 'never'
# Apply Ollama defaults
self._apply_ollama_defaults()
def _is_ollama_provider(self) -> bool:
"""Detect if this is an Ollama provider."""
if not self.model:
return False
if self.model.startswith("ollama/"):
return True
if self.base_url and ("ollama" in self.base_url.lower() or ":11434" in self.base_url):
return True
return False
def _apply_ollama_defaults(self):
"""Apply Ollama-specific defaults for tool calling reliability."""
if self._is_ollama_provider():
if not self._max_tool_repairs_explicit:
self.max_tool_repairs = 2
if not self._force_tool_usage_explicit:
self.force_tool_usage = 'auto'
def _get_tool_names_for_prompt(self, formatted_tools):
"""Extract tool names from formatted tools for prompts."""
if not formatted_tools:
return "None"
names = []
for tool in formatted_tools:
if isinstance(tool, dict) and 'function' in tool:
names.append(tool['function'].get('name', 'unknown'))
return ', '.join(names) if names else "None"
def _get_tool_schemas_for_prompt(self, formatted_tools):
"""Generate compact tool schemas for repair prompts."""
if not formatted_tools:
return "None"
schemas = []
for tool in formatted_tools:
if isinstance(tool, dict) and 'function' in tool:
func = tool['function']
name = func.get('name', 'unknown')
params = func.get('parameters', {})
required = params.get('required', [])
props = params.get('properties', {})
param_strs = []
for pname, pinfo in props.items():
ptype = pinfo.get('type', 'any')
req = '*' if pname in required else ''
param_strs.append(f"{pname}{req}: {ptype}")
schemas.append(f"- {name}({', '.join(param_strs)})")
return '\n'.join(schemas) if schemas else "None"
def _should_force_tool_usage(self, response_text, tool_calls, formatted_tools, iteration_count):
"""Determine if we should force tool usage."""
if not formatted_tools:
return False
if tool_calls:
return False
if self.force_tool_usage == 'never':
return False
elif self.force_tool_usage == 'always':
return True
elif self.force_tool_usage == 'auto':
return self._is_ollama_provider() and iteration_count == 0
return False
def _try_parse_tool_call_json(self, response_text, iteration_count):
"""Try to parse tool call from JSON response text."""
if not response_text or not response_text.strip():
return None, None
try:
response_json = json.loads(response_text.strip())
if isinstance(response_json, dict) and "name" in response_json:
tool_calls = [{
"id": f"tool_{iteration_count}",
"type": "function",
"function": {
"name": response_json["name"],
"arguments": json.dumps(response_json.get("arguments", {}))
}
}]
return tool_calls, None
elif isinstance(response_json, list):
tool_calls = []
for idx, tool_json in enumerate(response_json):
if isinstance(tool_json, dict) and "name" in tool_json:
tool_calls.append({
"id": f"tool_{iteration_count}_{idx}",
"type": "function",
"function": {
"name": tool_json["name"],
"arguments": json.dumps(tool_json.get("arguments", {}))
}
})
return tool_calls if tool_calls else None, None
else:
return None, "Response is not a valid tool call format (missing 'name' field)"
except json.JSONDecodeError as e:
return None, f"JSON parse error: {str(e)}"
def _validate_tool_call(self, tool_call, formatted_tools):
"""Validate a tool call against available tools."""
if not formatted_tools:
return None
try:
func_info = tool_call.get('function', {})
tool_name = func_info.get('name', '')
arguments_str = func_info.get('arguments', '{}')
available_names = [t['function']['name'] for t in formatted_tools if isinstance(t, dict) and 'function' in t]
if tool_name not in available_names:
return f"Unknown tool '{tool_name}'. Available: {', '.join(available_names)}"
try:
arguments = json.loads(arguments_str) if isinstance(arguments_str, str) else arguments_str
except json.JSONDecodeError as e:
return f"Invalid arguments JSON: {str(e)}"
for tool in formatted_tools:
if isinstance(tool, dict) and tool.get('function', {}).get('name') == tool_name:
params = tool['function'].get('parameters', {})
required = params.get('required', [])
missing = [r for r in required if r not in arguments]
if missing:
return f"Missing required arguments for '{tool_name}': {', '.join(missing)}"
break
return None
except Exception as e:
return f"Validation error: {str(e)}"
def _format_ollama_tool_result_message(self, function_name, tool_result):
"""Format tool result message for Ollama provider."""
tool_result_str = str(tool_result)
return {
"role": "user",
"content": f"""Tool execution complete.
Function: {function_name}
Result: {tool_result_str}
Now provide your final answer using this result."""
}
class TestOllamaToolReliability:
"""Tests for Ollama tool calling reliability enhancements."""
@pytest.fixture
def mock_llm(self):
"""Create a mock LLM instance for testing."""
return MockLLM(model="ollama/olmo-3")
@pytest.fixture
def mock_llm_openai(self):
"""Create a mock LLM instance for OpenAI (non-Ollama)."""
return MockLLM(model="gpt-4o-mini")
@pytest.fixture
def sample_formatted_tools(self):
"""Sample formatted tools for testing."""
return [
{
"type": "function",
"function": {
"name": "calculator",
"description": "Add two integers",
"parameters": {
"type": "object",
"properties": {
"a": {"type": "integer"},
"b": {"type": "integer"}
},
"required": ["a", "b"]
}
}
}
]
# ===== Test Ollama Defaults =====
def test_ollama_defaults_applied(self, mock_llm):
"""Test that Ollama-specific defaults are applied."""
assert mock_llm.max_tool_repairs == 2
assert mock_llm.force_tool_usage == 'auto'
def test_non_ollama_defaults(self, mock_llm_openai):
"""Test that non-Ollama models get default values."""
assert mock_llm_openai.max_tool_repairs == 0
assert mock_llm_openai.force_tool_usage == 'never'
def test_explicit_override_respected(self):
"""Test that explicit user overrides are respected."""
llm = MockLLM(model="ollama/olmo-3", max_tool_repairs=5, force_tool_usage='always')
assert llm.max_tool_repairs == 5
assert llm.force_tool_usage == 'always'
# ===== Test Tool Call Parsing =====
def test_try_parse_tool_call_json_valid(self, mock_llm):
"""Test parsing valid tool call JSON."""
response_text = '{"name": "calculator", "arguments": {"a": 17, "b": 25}}'
tool_calls, error = mock_llm._try_parse_tool_call_json(response_text, 0)
assert tool_calls is not None
assert len(tool_calls) == 1
assert tool_calls[0]['function']['name'] == 'calculator'
assert error is None
def test_try_parse_tool_call_json_invalid(self, mock_llm):
"""Test parsing invalid JSON returns error."""
response_text = 'This is not JSON'
tool_calls, error = mock_llm._try_parse_tool_call_json(response_text, 0)
assert tool_calls is None
assert error is not None
assert "JSON parse error" in error
def test_try_parse_tool_call_json_empty(self, mock_llm):
"""Test parsing empty response."""
tool_calls, error = mock_llm._try_parse_tool_call_json("", 0)
assert tool_calls is None
assert error is None
def test_try_parse_tool_call_json_list(self, mock_llm):
"""Test parsing list of tool calls."""
response_text = '[{"name": "calculator", "arguments": {"a": 1, "b": 2}}, {"name": "calculator", "arguments": {"a": 3, "b": 4}}]'
tool_calls, error = mock_llm._try_parse_tool_call_json(response_text, 0)
assert tool_calls is not None
assert len(tool_calls) == 2
# ===== Test Tool Call Validation =====
def test_validate_tool_call_valid(self, mock_llm, sample_formatted_tools):
"""Test validation of valid tool call."""
tool_call = {
"id": "tool_0",
"type": "function",
"function": {
"name": "calculator",
"arguments": '{"a": 17, "b": 25}'
}
}
error = mock_llm._validate_tool_call(tool_call, sample_formatted_tools)
assert error is None
def test_validate_tool_call_unknown_tool(self, mock_llm, sample_formatted_tools):
"""Test validation fails for unknown tool."""
tool_call = {
"id": "tool_0",
"type": "function",
"function": {
"name": "unknown_tool",
"arguments": '{}'
}
}
error = mock_llm._validate_tool_call(tool_call, sample_formatted_tools)
assert error is not None
assert "Unknown tool" in error
def test_validate_tool_call_missing_required_args(self, mock_llm, sample_formatted_tools):
"""Test validation fails for missing required arguments."""
tool_call = {
"id": "tool_0",
"type": "function",
"function": {
"name": "calculator",
"arguments": '{"a": 17}' # Missing 'b'
}
}
error = mock_llm._validate_tool_call(tool_call, sample_formatted_tools)
assert error is not None
assert "Missing required arguments" in error
def test_validate_tool_call_invalid_json_args(self, mock_llm, sample_formatted_tools):
"""Test validation fails for invalid JSON arguments."""
tool_call = {
"id": "tool_0",
"type": "function",
"function": {
"name": "calculator",
"arguments": 'not valid json'
}
}
error = mock_llm._validate_tool_call(tool_call, sample_formatted_tools)
assert error is not None
assert "Invalid arguments JSON" in error
# ===== Test Force Tool Usage =====
def test_should_force_tool_usage_auto_ollama(self, mock_llm, sample_formatted_tools):
"""Test force tool usage in auto mode for Ollama."""
# First iteration, no tool calls, has tools
result = mock_llm._should_force_tool_usage(
response_text="I'll just answer directly",
tool_calls=None,
formatted_tools=sample_formatted_tools,
iteration_count=0
)
assert result is True
def test_should_force_tool_usage_auto_not_first_iteration(self, mock_llm, sample_formatted_tools):
"""Test force tool usage doesn't trigger after first iteration."""
result = mock_llm._should_force_tool_usage(
response_text="I'll just answer directly",
tool_calls=None,
formatted_tools=sample_formatted_tools,
iteration_count=1
)
assert result is False
def test_should_force_tool_usage_never(self, mock_llm, sample_formatted_tools):
"""Test force tool usage never mode."""
mock_llm.force_tool_usage = 'never'
result = mock_llm._should_force_tool_usage(
response_text="I'll just answer directly",
tool_calls=None,
formatted_tools=sample_formatted_tools,
iteration_count=0
)
assert result is False
def test_should_force_tool_usage_always(self, mock_llm, sample_formatted_tools):
"""Test force tool usage always mode."""
mock_llm.force_tool_usage = 'always'
result = mock_llm._should_force_tool_usage(
response_text="I'll just answer directly",
tool_calls=None,
formatted_tools=sample_formatted_tools,
iteration_count=5 # Even on later iterations
)
assert result is True
def test_should_force_tool_usage_with_existing_tool_calls(self, mock_llm, sample_formatted_tools):
"""Test force tool usage doesn't trigger when tool calls exist."""
mock_llm.force_tool_usage = 'always'
tool_calls = [{"id": "tool_0", "function": {"name": "calculator"}}]
result = mock_llm._should_force_tool_usage(
response_text="",
tool_calls=tool_calls,
formatted_tools=sample_formatted_tools,
iteration_count=0
)
assert result is False
def test_should_force_tool_usage_no_tools(self, mock_llm):
"""Test force tool usage doesn't trigger without tools."""
mock_llm.force_tool_usage = 'always'
result = mock_llm._should_force_tool_usage(
response_text="I'll just answer directly",
tool_calls=None,
formatted_tools=None,
iteration_count=0
)
assert result is False
# ===== Test Helper Methods =====
def test_get_tool_names_for_prompt(self, mock_llm, sample_formatted_tools):
"""Test extracting tool names for prompts."""
names = mock_llm._get_tool_names_for_prompt(sample_formatted_tools)
assert names == "calculator"
def test_get_tool_names_for_prompt_empty(self, mock_llm):
"""Test extracting tool names from empty list."""
names = mock_llm._get_tool_names_for_prompt(None)
assert names == "None"
def test_get_tool_schemas_for_prompt(self, mock_llm, sample_formatted_tools):
"""Test generating tool schemas for prompts."""
schemas = mock_llm._get_tool_schemas_for_prompt(sample_formatted_tools)
assert "calculator" in schemas
assert "a*" in schemas # Required arg marked with *
assert "b*" in schemas
def test_format_ollama_tool_result_message(self, mock_llm):
"""Test formatting tool result message for Ollama."""
result = mock_llm._format_ollama_tool_result_message("calculator", 42)
assert result["role"] == "user"
assert "calculator" in result["content"]
assert "42" in result["content"]
assert "final answer" in result["content"].lower()
# ===== Test Prompt Templates =====
def test_force_tool_usage_prompt_template(self, mock_llm):
"""Test force tool usage prompt template."""
prompt = mock_llm.FORCE_TOOL_USAGE_PROMPT.format(tool_names="calculator")
assert "MUST use" in prompt
assert "calculator" in prompt
assert "tool call" in prompt.lower()
def test_tool_call_repair_prompt_template(self, mock_llm):
"""Test tool call repair prompt template."""
prompt = mock_llm.TOOL_CALL_REPAIR_PROMPT.format(
error="Missing required argument 'b'",
tool_schemas="- calculator(a*: integer, b*: integer)"
)
assert "malformed" in prompt.lower()
assert "Missing required argument" in prompt
assert "calculator" in prompt
class TestOllamaToolReliabilityIntegration:
"""Integration-style tests for tool reliability using MockLLM."""
def test_force_tool_usage_flow(self):
"""Test the complete force tool usage flow with MockLLM."""
llm = MockLLM(model="ollama/olmo-3")
sample_tools = [{
"type": "function",
"function": {
"name": "calculator",
"parameters": {"type": "object", "properties": {"a": {"type": "integer"}}, "required": ["a"]}
}
}]
# Simulate: model returns text instead of tool call
response_text = "The answer is 42"
tool_calls = None
# Should trigger force tool usage on first iteration
should_force = llm._should_force_tool_usage(response_text, tool_calls, sample_tools, 0)
assert should_force is True
# Should NOT trigger on second iteration
should_force = llm._should_force_tool_usage(response_text, tool_calls, sample_tools, 1)
assert should_force is False
def test_repair_loop_flow(self):
"""Test the complete repair loop flow with MockLLM."""
llm = MockLLM(model="ollama/olmo-3")
sample_tools = [{
"type": "function",
"function": {
"name": "calculator",
"parameters": {"type": "object", "properties": {"a": {"type": "integer"}}, "required": ["a"]}
}
}]
# Simulate: malformed tool call (unknown tool)
bad_tool_call = {
"id": "tool_0",
"type": "function",
"function": {"name": "unknown_tool", "arguments": "{}"}
}
error = llm._validate_tool_call(bad_tool_call, sample_tools)
assert error is not None
assert "Unknown tool" in error
# Simulate: valid tool call after repair
good_tool_call = {
"id": "tool_1",
"type": "function",
"function": {"name": "calculator", "arguments": '{"a": 17}'}
}
error = llm._validate_tool_call(good_tool_call, sample_tools)
assert error is None
class TestOllamaProviderDetection:
"""Tests for Ollama provider detection."""
def test_detect_ollama_prefix(self):
"""Test detection via ollama/ prefix."""
llm = MockLLM(model="ollama/llama3.2")
assert llm._is_ollama_provider() is True
def test_detect_ollama_base_url(self):
"""Test detection via base_url containing ollama."""
llm = MockLLM(model="llama3.2", base_url="http://localhost:11434")
assert llm._is_ollama_provider() is True
def test_detect_non_ollama(self):
"""Test non-Ollama models are not detected as Ollama."""
llm = MockLLM(model="gpt-4o-mini")
assert llm._is_ollama_provider() is False
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+4 -4
View File
@@ -4117,7 +4117,7 @@ requires-dist = [
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.1.0" },
{ name = "praisonaiagents", specifier = ">=0.4.0" },
{ name = "praisonaiagents", specifier = ">=0.4.1" },
{ name = "pyautogen", marker = "extra == 'autogen'", specifier = "==0.2.29" },
{ name = "pydantic", marker = "extra == 'chat'", specifier = "<=2.10.1" },
{ name = "pydantic", marker = "extra == 'code'", specifier = "<=2.10.1" },
@@ -4159,7 +4159,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.4.0"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -4168,9 +4168,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/21/f3d8224b0dc6fe8fa0f9a2209f8def8941f18b43696befb41e1a5574b1a6/praisonaiagents-0.4.0.tar.gz", hash = "sha256:cd96b0711502cafc5c7d31cd667a0754cb37ce354cf3e819bca77491741e7f07", size = 763895, upload-time = "2025-12-26T19:30:31.887Z" }
sdist = { url = "https://files.pythonhosted.org/packages/de/e8/5cf1306785b5e47b278363f58dee68ea381d26acc87ac9fd86b9d7abab0a/praisonaiagents-0.4.1.tar.gz", hash = "sha256:61cf37b3392c8857855176cdae6a758a88b110d103c61e226a4da7ab2ad8d30c", size = 766683, upload-time = "2025-12-28T07:27:21.999Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/7e/53291318129fb5e61e4efc3af74183c9dc558c2b9b1477d3c68c879f08df/praisonaiagents-0.4.0-py3-none-any.whl", hash = "sha256:a08e7d350fd3e47e15eb3d919279c79af5867967834b84010489ec419b008729", size = 711196, upload-time = "2025-12-26T19:30:30.084Z" },
{ url = "https://files.pythonhosted.org/packages/0d/26/b1593bf8d360793cc7e82020f7bef080734290370ddbb54658c6d9a0d8e1/praisonaiagents-0.4.1-py3-none-any.whl", hash = "sha256:0cdb9c8514cf042340c093103d9051a4d25dfb6d8126d05757b552f21b8b7085", size = 713637, upload-time = "2025-12-28T07:27:19.944Z" },
]
[[package]]