mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 17:31:00 -10:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a855c4700 | ||
|
|
d51073710a | ||
|
|
6b4b59fbe7 | ||
|
|
709a038785 | ||
|
|
64f7fdeb84 | ||
|
|
f834d42448 | ||
|
|
705fae31f8 | ||
|
|
b4270173d4 | ||
|
|
6256744348 | ||
|
|
15d597da37 | ||
|
|
04d5e07e1b | ||
|
|
754386022c | ||
|
|
1e27a73633 | ||
|
|
2adfe7e832 | ||
|
|
1ad58ca029 | ||
|
|
2f9677abb2 | ||
|
|
9991e00f52 | ||
|
|
86aeaa4d89 | ||
|
|
a66db1836a | ||
|
|
e90d922318 | ||
|
|
43b9552f9b | ||
|
|
d5c77c244b | ||
|
|
bcb6957dac | ||
|
|
fe78664261 | ||
|
|
d9a7c1a01e | ||
|
|
6a122c3671 | ||
|
|
1f1cfa974b | ||
|
|
34d60ae8fa | ||
|
|
095653d78a | ||
|
|
7f6a483e62 | ||
|
|
c6883def69 | ||
|
|
d3c4a2afad | ||
|
|
034138ebc9 | ||
|
|
70b2df135b | ||
|
|
3532600887 | ||
|
|
13ba1ed954 | ||
|
|
4a1fe173e8 | ||
|
|
147bcce448 | ||
|
|
b5e8052809 | ||
|
|
8a93bc5096 | ||
|
|
f65b83614c | ||
|
|
25000d9b26 |
No files matched your search
@@ -200,6 +200,7 @@ jobs:
|
||||
allowed_bots: 'praisonai-triage-agent[bot]'
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ steps.app-token.outputs.token }}
|
||||
model: opus
|
||||
trigger_phrase: "@claude"
|
||||
label_trigger: "claude"
|
||||
|
||||
|
||||
@@ -60,16 +60,16 @@ jobs:
|
||||
- name: Run PraisonAI integration tests
|
||||
run: |
|
||||
cd PraisonAI/src/praisonai
|
||||
python -m pytest tests/integration/test_aiui_* -v --timeout=300
|
||||
python -m pytest tests/integration/test_aiui_* -v
|
||||
|
||||
- name: Run PraisonAIUI integration tests
|
||||
run: |
|
||||
cd PraisonAIUI
|
||||
if [ -f tests/integration/test_agentic_roundtrip.py ]; then
|
||||
python -m pytest tests/integration/test_agentic_roundtrip.py -v --timeout=300
|
||||
python -m pytest tests/integration/test_agentic_roundtrip.py -v
|
||||
fi
|
||||
if [ -f tests/test_feature_sdk_backends.py ]; then
|
||||
python -m pytest tests/test_feature_sdk_backends.py -v --timeout=300
|
||||
python -m pytest tests/test_feature_sdk_backends.py -v
|
||||
fi
|
||||
|
||||
- name: Test Pattern C CLI
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
run: |
|
||||
cd PraisonAI/src/praisonai
|
||||
if [ -n "$OPENAI_API_KEY" ]; then
|
||||
python -m pytest tests/integration/test_aiui_host_agentic.py -v --timeout=600
|
||||
python -m pytest tests/integration/test_aiui_host_agentic.py -v
|
||||
else
|
||||
echo "OPENAI_API_KEY not available, skipping agentic tests"
|
||||
fi
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Sync Claude Secrets to PraisonAIUI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Sync secrets to PraisonAIUI
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.SECRETS_ADMIN_PAT }}
|
||||
TARGET: MervinPraison/PraisonAIUI
|
||||
CLAUDE_APP_ID: ${{ secrets.CLAUDE_APP_ID }}
|
||||
CLAUDE_APP_PRIVATE_KEY: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
GH_TOKEN_VALUE: ${{ secrets.GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "SECRETS_ADMIN_PAT is not configured in this repository"
|
||||
exit 1
|
||||
fi
|
||||
echo -n "$CLAUDE_APP_ID" | gh secret set CLAUDE_APP_ID --repo "$TARGET"
|
||||
echo -n "$CLAUDE_APP_PRIVATE_KEY" | gh secret set CLAUDE_APP_PRIVATE_KEY --repo "$TARGET"
|
||||
echo -n "$CLAUDE_CODE_OAUTH_TOKEN" | gh secret set CLAUDE_CODE_OAUTH_TOKEN --repo "$TARGET"
|
||||
echo -n "$GH_TOKEN_VALUE" | gh secret set GH_TOKEN --repo "$TARGET"
|
||||
echo "Synced CLAUDE_APP_ID, CLAUDE_APP_PRIVATE_KEY, CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN → $TARGET"
|
||||
@@ -0,0 +1,10 @@
|
||||
# Agent Instructions
|
||||
|
||||
You are working on the PraisonAI project.
|
||||
|
||||
## Project Guidelines
|
||||
- Follow the existing code style and conventions
|
||||
- Be concise and helpful in responses
|
||||
- Test implementation thoroughly
|
||||
- Ensure backward compatibility with existing APIs
|
||||
- Follow protocol-driven design: core protocols in praisonaiagents/, heavy implementations in praisonai/
|
||||
@@ -770,11 +770,11 @@ agent.start('Write a movie script about a robot in Mars');
|
||||
|
||||
## ⚡ Performance
|
||||
|
||||
PraisonAI is built for speed, with agent instantiation in under 4μs. This reduces overhead, improves responsiveness, and helps multi-agent systems scale efficiently in real-world production workloads.
|
||||
PraisonAI is built for speed, with agent instantiation in around 14μs. This reduces overhead, improves responsiveness, and helps multi-agent systems scale efficiently in real-world production workloads.
|
||||
|
||||
| Performance Metric | PraisonAI |
|
||||
|--------------------|-----------|
|
||||
| Avg Instantiation Time | **3.77 μs** |
|
||||
| Avg Instantiation Time | **14 μs** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ Methods:
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">handoff_to</a>(target_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None, config: Optional['HandoffConfig'] = None) -> 'HandoffResult'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">handoff_to_async</a>(target_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None, config: Optional['HandoffConfig'] = None) -> 'HandoffResult'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">is_closed</a>() -> bool</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_instance</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_instance</a>(value)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_model</a>() -> Optional[str]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">output_style</a>() -> Optional[str]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">output_style</a>(value: Optional[str]) -> None</code>
|
||||
@@ -240,6 +242,7 @@ from praisonaiagents import Memory, MemoryBackend, MemoryConfig
|
||||
|
||||
Methods:
|
||||
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">build_cache_optimized_context</a>(task_descr: str, user_id: Optional[str] = None, additional: str = '', max_items: int = 3, include_cache_boundary: bool = True, include_in_output: Optional[bool] = None) -> Dict[str, str]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">build_context_for_task</a>(task_descr: str, user_id: Optional[str] = None, additional: str = '', max_items: int = 3, include_in_output: Optional[bool] = None) -> str</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">calculate_quality_metrics</a>(output: str, expected_output: str, llm: Optional[str] = None, custom_prompt: Optional[str] = None) -> Dict[str, float]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">close_connections</a>()</code>
|
||||
@@ -698,7 +701,7 @@ Methods:
|
||||
|
||||
Types:
|
||||
```python
|
||||
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, aembedding, aembeddings, embedding, embeddings, get_dimensions
|
||||
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, __version__, aembedding, aembeddings, embedding, embeddings, get_dimensions
|
||||
```
|
||||
|
||||
Methods:
|
||||
@@ -914,6 +917,9 @@ Methods:
|
||||
* <code title="cli">praisonai gateway install <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway logs <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway mint-link <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway pause <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway reconnect <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway resume <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway send <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway start <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway status <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
@@ -1302,7 +1308,7 @@ export { // Python parity additions
|
||||
export { // Subagent Tool (agent-as-tool pattern)
|
||||
SubagentTool, BaseTool, FunctionTool, ToolRegistry, ToolResult, ToolValidationError, createDelegator, createSubagentTool, createSubagentTools, createTool, getRegistry, getTool, registerTool, tool, validateTool } from "./tools";
|
||||
export { airweaveSearch, bedrockBrowserClick, bedrockBrowserFill, bedrockBrowserNavigate, bedrockCodeInterpreter, codeExecution, codeMode, createCustomTool, exaSearch, firecrawlCrawl, firecrawlScrape, parallelSearch, perplexitySearch, registerCustomTool, registerLocalTool, registerNpmTool, superagentGuard, superagentRedact, superagentVerify, tavilyCrawl, tavilyExtract, tavilySearch, valyuBioSearch, valyuCompanyResearch, valyuEconomicsSearch, valyuFinanceSearch, valyuPaperSearch, valyuPatentSearch, valyuSecSearch, valyuWebSearch } from "./tools/builtins";
|
||||
export { BudgetExceededError, MissingDependencyError, MissingEnvVarError, ToolsRegistry, composeMiddleware, createLoggingMiddleware, createRateLimitMiddleware, createRedactionMiddleware, createRetryMiddleware, createTimeoutMiddleware, createToolsRegistry, createTracingMiddleware, createValidationMiddleware, getRegistry, getTool, getToolsRegistry, get_registry, get_tool, registerTool, register_tool, resetToolsRegistry, validateTool, validate_tool } from "./tools/registry";
|
||||
export { BudgetExceededError, MissingDependencyError, MissingEnvVarError, ToolsRegistry, composeMiddleware, createLoggingMiddleware, createRateLimitMiddleware, createRedactionMiddleware, createRetryMiddleware, createTimeoutMiddleware, createToolsRegistry, createTracingMiddleware, createValidationMiddleware, getToolsRegistry, get_registry, get_tool, register_tool, resetToolsRegistry, validate_tool } from "./tools/registry";
|
||||
export type { InstallHints, PraisonTool, RedactionHooks, RegisteredTool, ToolCapabilities, ToolExecutionContext, ToolExecutionResult, ToolFactory, ToolHooks, ToolInstallStatus, ToolLimits, ToolLogger, ToolMetadata, ToolMiddleware, ToolParameterProperty, ToolParameterSchema } from "./tools/registry";
|
||||
export { registerBuiltinTools, tools } from "./tools/tools";
|
||||
export { // Classes
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.55" \
|
||||
"praisonai>=4.6.62" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.55" \
|
||||
"praisonai>=4.6.62" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.55" \
|
||||
"praisonai>=4.6.62" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ workflow = AgentFlow(
|
||||
),
|
||||
],
|
||||
# Workflow-level consolidated params
|
||||
output=WorkflowOutputConfig(output="verbose", stream=True),
|
||||
output=WorkflowOutputConfig(verbose=True, stream=True),
|
||||
planning=WorkflowPlanningConfig(enabled=True, reasoning=True),
|
||||
memory=WorkflowMemoryConfig(backend="file"),
|
||||
# Agent-like params
|
||||
|
||||
@@ -5,12 +5,12 @@ Demonstrates web search with consolidated params.
|
||||
Presets: duckduckgo, tavily, google, bing, serper, search_only, fetch_only
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents import Agent, WebConfig
|
||||
|
||||
# Basic: Enable web search with preset
|
||||
# Basic: Enable web search with preset (search only — fetch needs Claude)
|
||||
agent = Agent(
|
||||
instructions="You are a research assistant with web access.",
|
||||
web="duckduckgo", # Presets: duckduckgo, tavily, google, bing, serper
|
||||
web=WebConfig(search=True, fetch=False, search_provider="duckduckgo"),
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -6,10 +6,18 @@ It includes self-reflection for improving code quality and multiple specialized
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, Task, AgentTeam
|
||||
from praisonaiagents.tools import CodeInterpreter
|
||||
|
||||
# Initialize code execution tool
|
||||
code_interpreter = CodeInterpreter()
|
||||
def run_python_code(code: str) -> str:
|
||||
"""Execute Python code safely and return stdout or errors."""
|
||||
import io
|
||||
import contextlib
|
||||
try:
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
exec(code, {"__builtins__": __builtins__})
|
||||
return buf.getvalue() or "Executed successfully (no output)"
|
||||
except Exception as e:
|
||||
return f"Execution error: {e}"
|
||||
|
||||
# Custom code analysis tool
|
||||
def analyze_code_complexity(code: str) -> str:
|
||||
@@ -32,9 +40,7 @@ code_writer = Agent(
|
||||
goal="Write clean, efficient, and well-documented Python code",
|
||||
backstory="You are a senior Python developer with 10+ years of experience in writing production-quality code.",
|
||||
instructions="Write Python code that follows PEP 8 standards, includes proper error handling, and has clear documentation.",
|
||||
reflection=True,
|
||||
|
||||
|
||||
output="silent",
|
||||
)
|
||||
|
||||
code_reviewer = Agent(
|
||||
@@ -52,7 +58,7 @@ code_executor = Agent(
|
||||
goal="Safely execute and test Python code",
|
||||
backstory="You are responsible for running code and reporting results or errors.",
|
||||
instructions="Execute the provided code safely and report the output or any errors encountered.",
|
||||
tools=[code_interpreter]
|
||||
tools=[run_python_code]
|
||||
)
|
||||
|
||||
# Example tasks
|
||||
@@ -87,7 +93,7 @@ def demonstrate_code_agent():
|
||||
workflow = AgentTeam(
|
||||
agents=[code_writer, code_reviewer, code_executor],
|
||||
tasks=[write_task, review_task, execute_task],
|
||||
process="sequential", output="verbose"
|
||||
process="sequential", output="silent"
|
||||
)
|
||||
|
||||
# Run the workflow
|
||||
@@ -106,10 +112,8 @@ general_code_agent = Agent(
|
||||
3. Explain code concepts
|
||||
4. Refactor code for better performance
|
||||
5. Add tests and documentation""",
|
||||
tools=[code_interpreter, analyze_code_complexity],
|
||||
reflection=True,
|
||||
|
||||
|
||||
tools=[run_python_code, analyze_code_complexity],
|
||||
output="silent",
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -41,21 +41,25 @@ def calculate_statistics(numbers: list) -> str:
|
||||
"""Calculate basic statistics for a list of numbers."""
|
||||
if not numbers:
|
||||
return "No numbers provided"
|
||||
|
||||
n = len(numbers)
|
||||
mean = sum(numbers) / n
|
||||
sorted_nums = sorted(numbers)
|
||||
try:
|
||||
nums = [float(x) for x in numbers]
|
||||
except (TypeError, ValueError):
|
||||
return "Error: all values must be numbers"
|
||||
|
||||
n = len(nums)
|
||||
mean = sum(nums) / n
|
||||
sorted_nums = sorted(nums)
|
||||
median = sorted_nums[n//2] if n % 2 else (sorted_nums[n//2-1] + sorted_nums[n//2]) / 2
|
||||
variance = sum((x - mean)**2 for x in numbers) / n
|
||||
variance = sum((x - mean)**2 for x in nums) / n
|
||||
std_dev = math.sqrt(variance)
|
||||
|
||||
|
||||
return f"""Statistics:
|
||||
- Count: {n}
|
||||
- Mean: {mean:.4f}
|
||||
- Median: {median:.4f}
|
||||
- Standard Deviation: {std_dev:.4f}
|
||||
- Min: {min(numbers)}
|
||||
- Max: {max(numbers)}"""
|
||||
- Min: {min(nums)}
|
||||
- Max: {max(nums)}"""
|
||||
|
||||
# Create specialized math agents
|
||||
calculation_agent = Agent(
|
||||
@@ -66,8 +70,7 @@ calculation_agent = Agent(
|
||||
instructions="Perform the requested calculations step by step, showing your work clearly.",
|
||||
tools=[basic_calculator, solve_quadratic, calculate_statistics],
|
||||
reflection=True,
|
||||
|
||||
|
||||
output="silent",
|
||||
)
|
||||
|
||||
problem_solver_agent = Agent(
|
||||
@@ -82,8 +85,7 @@ problem_solver_agent = Agent(
|
||||
4. Show each step of the solution
|
||||
5. Verify the answer makes sense""",
|
||||
reflection=True,
|
||||
|
||||
|
||||
output="silent",
|
||||
)
|
||||
|
||||
math_tutor_agent = Agent(
|
||||
@@ -91,7 +93,8 @@ math_tutor_agent = Agent(
|
||||
role="Mathematics tutor and explainer",
|
||||
goal="Explain mathematical concepts clearly and help users understand math",
|
||||
backstory="You are a patient math tutor who can explain concepts at any level from elementary to advanced.",
|
||||
instructions="Explain mathematical concepts using simple language, provide examples, and check for understanding."
|
||||
instructions="Explain mathematical concepts using simple language, provide examples, and check for understanding.",
|
||||
output="silent",
|
||||
)
|
||||
|
||||
# Example workflow for solving complex problems
|
||||
@@ -126,7 +129,8 @@ def solve_math_problem_workflow():
|
||||
workflow = AgentTeam(
|
||||
agents=[problem_solver_agent, calculation_agent, math_tutor_agent],
|
||||
tasks=[understand_task, solve_task, verify_task],
|
||||
process="sequential", output="verbose"
|
||||
process="sequential",
|
||||
output="silent",
|
||||
)
|
||||
|
||||
return workflow.start()
|
||||
@@ -148,8 +152,7 @@ math_assistant = Agent(
|
||||
Always show your work step by step.""",
|
||||
tools=[basic_calculator, solve_quadratic, calculate_statistics],
|
||||
reflection=True,
|
||||
|
||||
|
||||
output="silent",
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Example: Bot In-Flight Run Control
|
||||
|
||||
Demonstrates how to use SessionRunControl to provide better UX for bots
|
||||
during long-running agent operations. Shows busy feedback, pending message
|
||||
handling, and /stop command support.
|
||||
|
||||
This example shows the "before" and "after" behavior described in issue #1914.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
# Mock agent for demonstration
|
||||
class MockAgent:
|
||||
"""Simple mock agent that simulates long-running tasks."""
|
||||
|
||||
def __init__(self, name="assistant"):
|
||||
self.name = name
|
||||
self.chat_history = []
|
||||
self._interrupt_controller = None
|
||||
|
||||
def chat(self, prompt: str) -> str:
|
||||
"""Simulate a long-running chat response."""
|
||||
print(f"[Agent] Starting to process: '{prompt[:50]}...'")
|
||||
|
||||
# Simulate long processing with interrupt checking
|
||||
for i in range(30): # 3 second task (30 x 0.1s)
|
||||
time.sleep(0.1)
|
||||
|
||||
# Check for interruption
|
||||
if (self._interrupt_controller and
|
||||
hasattr(self._interrupt_controller, 'is_set') and
|
||||
self._interrupt_controller.is_set()):
|
||||
reason = getattr(self._interrupt_controller, 'reason', 'unknown')
|
||||
print(f"[Agent] ⚠️ Interrupted: {reason}")
|
||||
raise InterruptedError(f"Task cancelled: {reason}")
|
||||
|
||||
response = f"Completed analysis of: {prompt}"
|
||||
print(f"[Agent] ✅ Finished: {response}")
|
||||
return response
|
||||
|
||||
|
||||
async def demo_without_run_control():
|
||||
"""Demonstrate the old behavior (silent blocking)."""
|
||||
print("\n" + "="*60)
|
||||
print("🔴 BEFORE: Without Run Control (Silent Blocking)")
|
||||
print("="*60)
|
||||
|
||||
from praisonai.bots._session import BotSessionManager
|
||||
|
||||
# Standard session manager (no run control)
|
||||
session_mgr = BotSessionManager()
|
||||
agent = MockAgent("research-agent")
|
||||
user_id = "user123"
|
||||
|
||||
print("\n1. User sends long task:")
|
||||
print(" User: 'research quantum computing trends'")
|
||||
|
||||
# Start first task (this will run for 3 seconds)
|
||||
task1 = asyncio.create_task(
|
||||
session_mgr.chat(agent, user_id, "research quantum computing trends")
|
||||
)
|
||||
|
||||
# Simulate user sending follow-up messages during execution
|
||||
await asyncio.sleep(0.5)
|
||||
print("\n2. User sends follow-up (this will block silently):")
|
||||
print(" User: 'actually focus on business impact'")
|
||||
|
||||
task2 = asyncio.create_task(
|
||||
session_mgr.chat(agent, user_id, "actually focus on business impact")
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
print("\n3. User tries to send /stop (this will also block):")
|
||||
print(" User: '/stop'")
|
||||
|
||||
task3 = asyncio.create_task(
|
||||
session_mgr.chat(agent, user_id, "/stop")
|
||||
)
|
||||
|
||||
print("\n⏳ Waiting for all messages to complete...")
|
||||
results = await asyncio.gather(task1, task2, task3)
|
||||
|
||||
print("\n📊 Results (all processed sequentially with no feedback):")
|
||||
for i, result in enumerate(results, 1):
|
||||
print(f" {i}. {result}")
|
||||
|
||||
print("\n❌ Problems with this approach:")
|
||||
print(" - No immediate feedback on follow-up messages")
|
||||
print(" - No way to cancel long-running tasks")
|
||||
print(" - User doesn't know if bot is working or broken")
|
||||
|
||||
|
||||
async def demo_with_run_control():
|
||||
"""Demonstrate the new behavior with run control."""
|
||||
print("\n" + "="*60)
|
||||
print("🟢 AFTER: With Run Control (Better UX)")
|
||||
print("="*60)
|
||||
|
||||
from praisonai.bots._run_control import SessionRunControl
|
||||
from praisonai.bots._session import BotSessionManager
|
||||
from praisonai.bots._commands import handle_stop_command
|
||||
|
||||
# Session manager with run control
|
||||
run_control = SessionRunControl(
|
||||
busy_mode="queue",
|
||||
busy_ack_template="⏳ {action} — will process after current task finishes"
|
||||
)
|
||||
session_mgr = BotSessionManager(run_control=run_control)
|
||||
agent = MockAgent("research-agent")
|
||||
user_id = "user123"
|
||||
|
||||
print("\n1. User sends long task:")
|
||||
print(" User: 'research quantum computing trends'")
|
||||
|
||||
# Start first task
|
||||
task1 = asyncio.create_task(
|
||||
session_mgr.chat_with_run_control(agent, user_id, "research quantum computing trends")
|
||||
)
|
||||
|
||||
# Immediate follow-up messages get acknowledgments
|
||||
await asyncio.sleep(0.5)
|
||||
print("\n2. User sends follow-up (gets immediate feedback):")
|
||||
print(" User: 'actually focus on business impact'")
|
||||
|
||||
result2 = await session_mgr.chat_with_run_control(
|
||||
agent, user_id, "actually focus on business impact"
|
||||
)
|
||||
print(f" Bot: {result2['response']}")
|
||||
print(f" Metadata: {result2['metadata']}")
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
print("\n3. User decides to stop current task:")
|
||||
print(" User: '/stop'")
|
||||
|
||||
stop_response = await handle_stop_command(user_id, run_control)
|
||||
print(f" Bot: {stop_response}")
|
||||
|
||||
# Wait for first task to complete (it should be cancelled)
|
||||
try:
|
||||
result1 = await task1
|
||||
print(f"\n📊 Task 1 result: {result1['response']}")
|
||||
print(f" Metadata: {result1['metadata']}")
|
||||
except asyncio.CancelledError:
|
||||
print("\n📊 Task 1 was cancelled as expected")
|
||||
|
||||
print("\n4. User sends fresh request after stopping:")
|
||||
print(" User: 'what's the weather like?'")
|
||||
|
||||
result4 = await session_mgr.chat_with_run_control(
|
||||
agent, user_id, "what's the weather like?"
|
||||
)
|
||||
print(f" Bot: {result4['response']}")
|
||||
|
||||
print("\n✅ Benefits of this approach:")
|
||||
print(" - Immediate feedback on all messages")
|
||||
print(" - Can cancel long-running tasks with /stop")
|
||||
print(" - Pending messages are queued and processed in order")
|
||||
print(" - User always knows what's happening")
|
||||
|
||||
|
||||
async def demo_interrupt_mode():
|
||||
"""Demonstrate interrupt mode."""
|
||||
print("\n" + "="*60)
|
||||
print("⚡ BONUS: Interrupt Mode (Cancel and Restart)")
|
||||
print("="*60)
|
||||
|
||||
from praisonai.bots._run_control import SessionRunControl
|
||||
from praisonai.bots._session import BotSessionManager
|
||||
|
||||
# Use interrupt mode instead of queue mode
|
||||
run_control = SessionRunControl(busy_mode="interrupt")
|
||||
session_mgr = BotSessionManager(run_control=run_control)
|
||||
agent = MockAgent("research-agent")
|
||||
user_id = "user123"
|
||||
|
||||
print("\n1. User starts long task:")
|
||||
print(" User: 'research renewable energy for 30 pages'")
|
||||
|
||||
task1 = asyncio.create_task(
|
||||
session_mgr.chat_with_run_control(agent, user_id, "research renewable energy for 30 pages")
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
print("\n2. User changes mind (interrupts and restarts):")
|
||||
print(" User: 'actually just summarize solar panel efficiency'")
|
||||
|
||||
result2 = await session_mgr.chat_with_run_control(
|
||||
agent, user_id, "actually just summarize solar panel efficiency"
|
||||
)
|
||||
print(f" Bot: {result2['response']}")
|
||||
|
||||
# Wait for original task (should be interrupted)
|
||||
try:
|
||||
result1 = await task1
|
||||
print(f"\n📊 Original task result: {result1['response']}")
|
||||
if result1['metadata'].get('interrupted'):
|
||||
print(" ✅ Original task was properly interrupted")
|
||||
except:
|
||||
print("\n📊 Original task was cancelled")
|
||||
|
||||
print("\n✅ Interrupt mode is great for:")
|
||||
print(" - When users frequently change direction")
|
||||
print(" - Interactive sessions where latest intent matters most")
|
||||
print(" - Real-time collaboration scenarios")
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run all demonstrations."""
|
||||
print("🚀 Bot In-Flight Run Control Demo")
|
||||
print("Solving the silent lock problem described in issue #1914")
|
||||
|
||||
# Show the problem
|
||||
await demo_without_run_control()
|
||||
|
||||
# Show the solution
|
||||
await demo_with_run_control()
|
||||
|
||||
# Show advanced feature
|
||||
await demo_interrupt_mode()
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("🎯 Summary")
|
||||
print("="*60)
|
||||
print("SessionRunControl solves the silent blocking problem by providing:")
|
||||
print("1. Immediate feedback for mid-run messages")
|
||||
print("2. /stop command support via InterruptController")
|
||||
print("3. Configurable policies (queue/interrupt/steer)")
|
||||
print("4. Run generation tracking to prevent race conditions")
|
||||
print("5. Pending message merging and ordering")
|
||||
print("\nThis makes bot interactions feel responsive and predictable!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -6,7 +6,6 @@ to different next steps based on output content.
|
||||
"""
|
||||
|
||||
from praisonaiagents import AgentFlow, Task
|
||||
from praisonaiagents import AgentFlowManager
|
||||
|
||||
# Create a workflow with branching
|
||||
workflow = AgentFlow(
|
||||
@@ -36,16 +35,9 @@ workflow = AgentFlow(
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Create manager and register workflow
|
||||
manager = WorkflowManager()
|
||||
manager.workflows["Decision Workflow"] = workflow
|
||||
|
||||
# Execute - will branch to success_handler since 42 is positive
|
||||
result = manager.execute(
|
||||
"Decision Workflow",
|
||||
default_llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
result = workflow.run("", llm="gpt-4o-mini", verbose=True)
|
||||
print("Workflow completed!")
|
||||
for step_result in result["results"]:
|
||||
print(f" {step_result['step']}: {step_result['status']}")
|
||||
for step in result.get("steps", []):
|
||||
name = step.get("step", step.get("name", "unknown"))
|
||||
status = step.get("status", "completed")
|
||||
print(f" {name}: {status}")
|
||||
@@ -6,9 +6,8 @@ functions that return StepResult with stop_workflow=True.
|
||||
"""
|
||||
|
||||
from praisonaiagents import (
|
||||
Workflow, Task, WorkflowContext, StepResult
|
||||
AgentFlow, Task, WorkflowContext, StepResult
|
||||
)
|
||||
from praisonaiagents import AgentFlowManager
|
||||
|
||||
# Custom validator that can stop the workflow
|
||||
def validate_data(context: WorkflowContext) -> StepResult:
|
||||
@@ -50,20 +49,13 @@ workflow = AgentFlow(
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
manager = WorkflowManager()
|
||||
manager.workflows["Data Processing"] = workflow
|
||||
|
||||
# Test with invalid data (will stop early)
|
||||
print("=== Testing with invalid data ===")
|
||||
result = manager.execute("Data Processing", default_llm="gpt-4o-mini")
|
||||
|
||||
for step_result in result["results"]:
|
||||
print(f" {step_result['step']}: {step_result['output']}")
|
||||
|
||||
# Test with valid data (will complete)
|
||||
result = workflow.run("", llm="gpt-4o-mini", verbose=True)
|
||||
for step in result.get("steps", []):
|
||||
print(f" {step.get('step', 'step')}: {step.get('output', step.get('status'))}")
|
||||
|
||||
print("\n=== Testing with valid data ===")
|
||||
workflow.variables["data"] = {"value": 42}
|
||||
result = manager.execute("Data Processing", default_llm="gpt-4o-mini")
|
||||
|
||||
for step_result in result["results"]:
|
||||
print(f" {step_result['step']}: {step_result['status']}")
|
||||
result = workflow.run("", llm="gpt-4o-mini", verbose=True)
|
||||
for step in result.get("steps", []):
|
||||
print(f" {step.get('step', 'step')}: {step.get('status', 'completed')}")
|
||||
@@ -6,7 +6,6 @@ Each item is processed individually with the loop variable.
|
||||
"""
|
||||
|
||||
from praisonaiagents import AgentFlow, Task
|
||||
from praisonaiagents import AgentFlowManager
|
||||
|
||||
# Create a workflow with loop
|
||||
workflow = AgentFlow(
|
||||
@@ -30,18 +29,12 @@ workflow = AgentFlow(
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Create manager and register workflow
|
||||
manager = WorkflowManager()
|
||||
manager.workflows["Process Items"] = workflow
|
||||
|
||||
# Execute - will process each fruit in the list
|
||||
result = manager.execute(
|
||||
"Process Items",
|
||||
default_llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
result = workflow.run("", llm="gpt-4o-mini", verbose=True)
|
||||
print("Workflow completed!")
|
||||
for step_result in result["results"]:
|
||||
print(f" {step_result['step']}: {step_result['status']}")
|
||||
if isinstance(step_result.get("output"), list):
|
||||
print(f" Loop processed {len(step_result['output'])} items")
|
||||
for step in result.get("steps", []):
|
||||
name = step.get("step", step.get("name", "unknown"))
|
||||
status = step.get("status", "completed")
|
||||
print(f" {name}: {status}")
|
||||
out = step.get("output")
|
||||
if isinstance(out, list):
|
||||
print(f" Loop processed {len(out)} items")
|
||||
@@ -53,7 +53,7 @@ workflow = AgentFlow(
|
||||
# Step 4: Function - formats output
|
||||
format_output
|
||||
],
|
||||
default_llm="gpt-4o-mini" # Used for action-based steps
|
||||
llm="gpt-4o-mini" # Used for action-based steps
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -5,7 +5,7 @@ Demonstrates running multiple agents concurrently and
|
||||
combining their results with an aggregator agent.
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, Workflow
|
||||
from praisonaiagents import Agent, AgentFlow
|
||||
from praisonaiagents.workflows import parallel
|
||||
|
||||
# Create parallel research agents
|
||||
|
||||
@@ -5,7 +5,7 @@ Demonstrates decision-based routing where an agent classifier
|
||||
routes to specialized handler agents based on the request type.
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, Workflow
|
||||
from praisonaiagents import Agent, AgentFlow
|
||||
from praisonaiagents.workflows import route
|
||||
|
||||
# Create classifier agent
|
||||
|
||||
@@ -1,35 +1,70 @@
|
||||
# PraisonAI Agents - Benchmark Results
|
||||
|
||||
**Generated:** 2025-12-18 14:40:22
|
||||
**Iterations:** 100
|
||||
**Test:** Agent instantiation (without tools)
|
||||
**Generated:** 2026-06-17
|
||||
**Methodology:** 10 warmup runs + 1000 measured iterations (`performance_benchmark.py` config)
|
||||
**Config:** `Agent(name=..., instructions=..., model="gpt-4o-mini", tools=[get_weather], output="silent")`
|
||||
|
||||
## Results
|
||||
## Agent Instantiation Time
|
||||
|
||||
| Framework | Avg Time (μs) | Relative |
|
||||
|-----------|---------------|----------|
|
||||
| **PraisonAI** | **3.77** | **1.00x (fastest)** |
|
||||
| OpenAI Agents SDK | 5.26 | 1.39x |
|
||||
| Agno | 5.64 | 1.49x |
|
||||
| PraisonAI (LiteLLM) | 7.56 | 2.00x |
|
||||
| PydanticAI | 226.94 | 60.16x |
|
||||
| LangGraph | 4,558.71 | 1,209x |
|
||||
| CrewAI | 15,607.92 | 4,138x |
|
||||
| Framework | Avg Time (μs) | Min (μs) | Median (μs) | Relative |
|
||||
|-----------|---------------|----------|-------------|----------|
|
||||
| OpenAI Agents SDK | 5.05 | — | — | 1.00x (fastest) |
|
||||
| Agno | 5.27 | — | — | 1.04x |
|
||||
| **PraisonAI** | **13.62** | **12.58** | **12.96** | **2.69x** |
|
||||
| PraisonAI (LiteLLM) | 14.49 | 13.37 | 13.71 | 2.86x |
|
||||
|
||||
### PraisonAI detail (1000 iter)
|
||||
|
||||
| Variant | Avg (μs) | Min (μs) | Median (μs) | Std (μs) |
|
||||
|---------|----------|----------|-------------|----------|
|
||||
| With tools (`gpt-4o-mini`) | 13.62 | 12.58 | 12.96 | 2.73 |
|
||||
| With tools (`openai/gpt-4o-mini`) | 14.49 | 13.37 | 13.71 | 4.14 |
|
||||
| Minimal, no tools | 13.40 | — | — | — |
|
||||
|
||||
## Historical baseline
|
||||
|
||||
| Source | Config | Avg (μs) | Notes |
|
||||
|--------|--------|----------|-------|
|
||||
| `performance_benchmark.py` (Dec 2025) | tools + 1000 iter | **3.20** | Pre-init-bloat baseline |
|
||||
| `simple_benchmark.py` (Dec 2025) | no tools, 100 iter | **3.77** | Saved in prior `BENCHMARK_RESULTS.md` |
|
||||
| Current (Jun 2026) | tools + 1000 iter | **13.62** | After lazy-init optimisations (was ~31 μs pre-fix) |
|
||||
|
||||
## Package Versions
|
||||
|
||||
| Package | Version |
|
||||
|---------|--------|
|
||||
| PraisonAI | 0.1.5 |
|
||||
| Agno | 2.3.14 |
|
||||
| PydanticAI | 1.35.0 |
|
||||
| OpenAI Agents SDK | 0.6.3 |
|
||||
| LangGraph | 1.0.5 |
|
||||
| CrewAI | 1.6.1 |
|
||||
|---------|---------|
|
||||
| PraisonAI | 1.6.60 |
|
||||
| Agno | 2.4.2 |
|
||||
| OpenAI Agents SDK | 0.7.0 |
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
PraisonAI-only (fast; avoids CrewAI/LangGraph import hang):
|
||||
|
||||
```bash
|
||||
cd praisonai-agents
|
||||
python benchmarks/simple_benchmark.py
|
||||
PYTHONWARNINGS='ignore::DeprecationWarning' python -c "
|
||||
import time, statistics
|
||||
from typing import Literal
|
||||
from praisonaiagents import Agent
|
||||
|
||||
def get_weather(city: Literal['nyc', 'sf']):
|
||||
return 'cloudy' if city == 'nyc' else 'sunny'
|
||||
|
||||
def bench(fn, n=1000, w=10):
|
||||
for _ in range(w): fn()
|
||||
times = []
|
||||
for _ in range(n):
|
||||
s = time.perf_counter(); fn(); times.append(time.perf_counter()-s)
|
||||
print(f'avg={statistics.mean(times)*1e6:.2f} μs')
|
||||
|
||||
bench(lambda: Agent(name='Test', instructions='Hi', model='gpt-4o-mini', tools=[get_weather], output='silent'))
|
||||
"
|
||||
```
|
||||
|
||||
Full multi-framework benchmark:
|
||||
|
||||
```bash
|
||||
cd praisonai-agents
|
||||
PYTHONWARNINGS='ignore::DeprecationWarning' python benchmarks/performance_benchmark.py --save
|
||||
```
|
||||
@@ -0,0 +1,62 @@
|
||||
# Agent Instantiation — Root Cause Analysis
|
||||
|
||||
**Date:** 2026-06-17
|
||||
**Version:** praisonaiagents 1.6.58 (dev)
|
||||
**Methodology:** 10 warmup + 1000 measured iterations, `time.perf_counter()`
|
||||
|
||||
## Summary
|
||||
|
||||
Instantiation regressed from **~3.2 μs** (Dec 2025) to **~31 μs** (~10×). After fixes, steady-state is **~16 μs** (~2× historical). PraisonAI is competitive with Agno (~5 μs) on warmed benchmarks using `model=`.
|
||||
|
||||
## Before vs After Fixes
|
||||
|
||||
| Config | Historical | Pre-fix | Post-fix |
|
||||
|--------|------------|---------|----------|
|
||||
| Minimal (`name`, `output="silent"`) | 3.20 μs | 31.36 μs | **16.35 μs** |
|
||||
| `model="gpt-4o-mini"` | 3.77 μs | 32.19 μs | **16.86 μs** |
|
||||
| `llm="gpt-4o-mini"` (deprecated) | 3.77 μs | 46.48 μs | **~18 μs** (warn once) |
|
||||
| LiteLLM `openai/gpt-4o-mini` cold | 7.31 μs | **3.7 s** | **53.63 μs** |
|
||||
| LiteLLM warmed instantiate | — | 60 μs | **19.83 μs** |
|
||||
| Import `Agent` | — | 20.8 ms | **29.6 ms** |
|
||||
|
||||
## Root Causes (validated)
|
||||
|
||||
| ID | Cause | Status | Fix applied |
|
||||
|----|-------|--------|-------------|
|
||||
| H1 | Config loader cold start (+9 ms first init) | CONFIRMED | Fast path when no config defaults (`_defaults_has_any_values`) |
|
||||
| H2 | LoopGuard on every init (~2 μs) | CONFIRMED | Lazy `_ensure_loop_guard()` on first tool call |
|
||||
| H3 | Approval presets | REJECTED | No change needed |
|
||||
| H4 | `llm=` deprecation every init (+16 μs) | CONFIRMED | Warn once per process in `warn_deprecated_param` |
|
||||
| H5 | Init bloat / config resolution (~25 μs) | CONFIRMED | Partially addressed via loader fast path |
|
||||
| H6 | LiteLLM eager `LLM()` import (+3.7 s) | CONFIRMED | Deferred `_llm_init_params` + lazy `llm_instance` property |
|
||||
| H7 | `ExecutionConfig` deprecation every init | CONFIRMED | Fixed stack walk; internal SDK path cached |
|
||||
|
||||
## cProfile — Post-fix (500 warmed `model=` inits)
|
||||
|
||||
| Function | Time (ms) | Per call |
|
||||
|----------|-----------|----------|
|
||||
| `Agent.__init__` | 8 | ~16 μs |
|
||||
| `apply_config_defaults` | 1 | ~2 μs |
|
||||
| `ExecutionConfig.__post_init__` | 1 | ~2 μs (first call only walks stack) |
|
||||
| `_warnings.warn` | **0** | removed from hot path |
|
||||
|
||||
## Fixes implemented
|
||||
|
||||
1. **`praisonaiagents/config/feature_configs.py`** — Fix `ExecutionConfig.__post_init__` deprecation guard; cache internal SDK detection.
|
||||
2. **`praisonaiagents/utils/deprecation.py`** — Warn once per process for deprecated params.
|
||||
3. **`praisonaiagents/config/loader.py`** — Skip `apply_config_defaults` lookups when config has no defaults.
|
||||
4. **`praisonaiagents/agent/agent.py`** — Lazy LoopGuard, lazy LLM instance, deferred LiteLLM params.
|
||||
5. **`benchmarks/simple_benchmark.py`** — Use `model=`, add 10-iteration warmup.
|
||||
6. **`benchmarks/performance_benchmark.py`** — Use `model=` instead of deprecated `llm=`.
|
||||
|
||||
## Remaining gap (~16 μs vs ~3 μs)
|
||||
|
||||
The residual ~13 μs is primarily **`Agent.__init__` size** (~1200 lines of consolidated config resolution added since v0.1.5). Further optimisation requires a dedicated slim fast-path for `Agent(name=..., output="silent")` without full config object churn.
|
||||
|
||||
## How to reproduce
|
||||
|
||||
```bash
|
||||
cd praisonai-agents
|
||||
PYTHONWARNINGS='ignore::DeprecationWarning' python benchmarks/simple_benchmark.py
|
||||
PYTHONWARNINGS='ignore::DeprecationWarning' python benchmarks/performance_benchmark.py
|
||||
```
|
||||
@@ -120,8 +120,8 @@ def run_benchmark(iterations: int = 100) -> List[ParamResult]:
|
||||
('backstory', 'I am an AI assistant.', 'backstory="..."'),
|
||||
|
||||
# LLM configuration
|
||||
('llm', 'gpt-4o-mini', 'llm="gpt-4o-mini"'),
|
||||
('llm', 'openai/gpt-4o-mini', 'llm="openai/gpt-4o-mini" (LiteLLM)'),
|
||||
('model', 'gpt-4o-mini', 'model="gpt-4o-mini"'),
|
||||
('model', 'openai/gpt-4o-mini', 'model="openai/gpt-4o-mini" (LiteLLM)'),
|
||||
|
||||
# Tools
|
||||
('tools', [sample_tool], 'tools=[sample_tool]'),
|
||||
@@ -298,7 +298,7 @@ def save_results(results: List[ParamResult], filename: str = 'PARAM_IMPACT_RESUL
|
||||
f.write('### Best Practices\n\n')
|
||||
f.write('1. Use `output="silent"` for production workloads\n')
|
||||
f.write('2. Only enable `context=True` when needed for long conversations\n')
|
||||
f.write('3. Use `llm="gpt-4o-mini"` (OpenAI SDK) for fastest instantiation\n')
|
||||
f.write('3. Use `model="gpt-4o-mini"` for fastest instantiation\n')
|
||||
f.write('4. Avoid `memory=True` unless persistent memory is required\n')
|
||||
|
||||
f.write('\n## How to Reproduce\n\n')
|
||||
|
||||
@@ -185,7 +185,7 @@ def instantiate_praisonai_agent():
|
||||
return PraisonAgent(
|
||||
name="Test Agent",
|
||||
instructions="Be concise, reply with one sentence.",
|
||||
llm="gpt-4o-mini",
|
||||
model="gpt-4o-mini",
|
||||
tools=tools,
|
||||
output="silent"
|
||||
)
|
||||
@@ -196,7 +196,7 @@ def instantiate_praisonai_litellm_agent():
|
||||
return PraisonAgent(
|
||||
name="Test Agent",
|
||||
instructions="Be concise, reply with one sentence.",
|
||||
llm="openai/gpt-4o-mini",
|
||||
model="openai/gpt-4o-mini",
|
||||
tools=tools,
|
||||
output="silent"
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import argparse
|
||||
|
||||
|
||||
ITERATIONS = 100
|
||||
WARMUP = 10
|
||||
|
||||
|
||||
def sample_tool(city: Literal['nyc', 'sf']):
|
||||
@@ -23,8 +24,10 @@ def sample_tool(city: Literal['nyc', 'sf']):
|
||||
return 'sunny'
|
||||
|
||||
|
||||
def measure_instantiation(create_fn, iterations=ITERATIONS):
|
||||
def measure_instantiation(create_fn, iterations=ITERATIONS, warmup=WARMUP):
|
||||
"""Measure average instantiation time in microseconds."""
|
||||
for _ in range(warmup):
|
||||
create_fn()
|
||||
times = []
|
||||
for _ in range(iterations):
|
||||
start = time.perf_counter()
|
||||
@@ -48,11 +51,11 @@ def run_benchmark():
|
||||
from praisonaiagents import Agent as PraisonAgent
|
||||
|
||||
results['PraisonAI'] = measure_instantiation(
|
||||
lambda: PraisonAgent(name='Test', llm='gpt-4o-mini', output="silent")
|
||||
lambda: PraisonAgent(name='Test', model='gpt-4o-mini', output="silent")
|
||||
)
|
||||
|
||||
results['PraisonAI (LiteLLM)'] = measure_instantiation(
|
||||
lambda: PraisonAgent(name='Test', llm='openai/gpt-4o-mini', output="silent")
|
||||
lambda: PraisonAgent(name='Test', model='openai/gpt-4o-mini', output="silent")
|
||||
)
|
||||
|
||||
# Other frameworks for comparison
|
||||
|
||||
@@ -114,6 +114,9 @@ def _get_lazy_cache():
|
||||
# ============================================================================
|
||||
|
||||
_LAZY_IMPORTS = {
|
||||
# Version information (lazy loaded to avoid file I/O on import)
|
||||
'__version__': ('praisonaiagents._version', '__version__'),
|
||||
|
||||
# Tools (moved from eager imports for lazy loading)
|
||||
'Tools': ('praisonaiagents.tools.tools', 'Tools'),
|
||||
'BaseTool': ('praisonaiagents.tools.base', 'BaseTool'),
|
||||
@@ -775,6 +778,9 @@ def warmup(include_litellm: bool = False, include_openai: bool = True) -> dict:
|
||||
# ============================================================================
|
||||
|
||||
__all__ = [
|
||||
# Version information
|
||||
'__version__',
|
||||
|
||||
# Core classes - the essentials
|
||||
'Agent',
|
||||
'AgentTeam', # Primary class for multi-agent coordination (v1.0+)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Version utilities for praisonaiagents package.
|
||||
|
||||
This module provides version information by reading from pyproject.toml
|
||||
to avoid duplication and ensure the single source of truth.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
"""
|
||||
Get the version string from pyproject.toml.
|
||||
|
||||
Returns:
|
||||
str: Version string (e.g., "1.6.52")
|
||||
|
||||
Raises:
|
||||
RuntimeError: If version cannot be found or parsed
|
||||
"""
|
||||
try:
|
||||
# Get the package root (praisonaiagents directory)
|
||||
package_root = Path(__file__).parent
|
||||
# Go up to src/praisonai-agents directory
|
||||
pyproject_path = package_root.parent / "pyproject.toml"
|
||||
|
||||
if not pyproject_path.exists():
|
||||
raise RuntimeError(f"pyproject.toml not found at {pyproject_path}")
|
||||
|
||||
content = pyproject_path.read_text()
|
||||
|
||||
# Look for version = "X.Y.Z" pattern
|
||||
match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
|
||||
if not match:
|
||||
raise RuntimeError("Version not found in pyproject.toml")
|
||||
|
||||
return match.group(1)
|
||||
|
||||
except Exception as e:
|
||||
# Fallback to "unknown" to avoid breaking imports
|
||||
import warnings
|
||||
warnings.warn(
|
||||
f"Failed to read version from pyproject.toml: {e}. Using 'unknown'.",
|
||||
RuntimeWarning
|
||||
)
|
||||
return "unknown"
|
||||
|
||||
|
||||
# Cache the version to avoid reading the file multiple times
|
||||
__version__ = get_version()
|
||||
@@ -313,6 +313,38 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
cls._default_model_checked = True
|
||||
return cls._default_model
|
||||
|
||||
def _ensure_llm_instance(self):
|
||||
"""Lazy-create LLM instance from deferred init params (avoids import at Agent())."""
|
||||
if self._llm_instance is not None:
|
||||
return self._llm_instance
|
||||
if self._llm_init_params:
|
||||
try:
|
||||
from ..llm.llm import LLM
|
||||
self._llm_instance = LLM(**self._llm_init_params)
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"LLM features requested but dependencies not installed. "
|
||||
"Please install with: pip install \"praisonaiagents[llm]\""
|
||||
) from e
|
||||
return self._llm_instance
|
||||
|
||||
@property
|
||||
def llm_instance(self):
|
||||
return self._ensure_llm_instance()
|
||||
|
||||
@llm_instance.setter
|
||||
def llm_instance(self, value):
|
||||
self._llm_instance = value
|
||||
if value is not None:
|
||||
self._llm_init_params = None
|
||||
|
||||
def _ensure_loop_guard(self):
|
||||
"""Lazy-create loop guard on first tool execution."""
|
||||
if self._loop_guard is None:
|
||||
from ..escalation.loop_guard import LoopGuard, LoopGuardConfig
|
||||
self._loop_guard = LoopGuard(LoopGuardConfig(enabled=True))
|
||||
return self._loop_guard
|
||||
|
||||
@classmethod
|
||||
def _configure_logging(cls):
|
||||
"""Configure logging settings once for all agent instances."""
|
||||
@@ -718,16 +750,16 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
# Precedence: Explicit params > Config file > Built-in defaults
|
||||
# Only applies when param is None (not explicitly set)
|
||||
# ============================================================
|
||||
from ..config.loader import apply_config_defaults, get_default
|
||||
from ..config.loader import apply_config_defaults, get_default, _defaults_has_any_values
|
||||
|
||||
# Apply config defaults for LLM if not explicitly set
|
||||
if llm is None and model is None:
|
||||
if llm is None and model is None and _defaults_has_any_values():
|
||||
config_model = get_default("model")
|
||||
if config_model:
|
||||
llm = config_model
|
||||
|
||||
# Apply config defaults for base_url if not explicitly set
|
||||
if base_url is None:
|
||||
if base_url is None and _defaults_has_any_values():
|
||||
config_base_url = get_default("base_url")
|
||||
if config_base_url:
|
||||
base_url = config_base_url
|
||||
@@ -1478,10 +1510,8 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
# Initialize autonomy features (agent-centric escalation/doom-loop)
|
||||
self._init_autonomy(autonomy, verification_hooks=verification_hooks)
|
||||
|
||||
# Initialize loop guard for all execution modes (not just autonomous)
|
||||
# Provides idempotency-aware guardrails for tool execution loops
|
||||
from ..escalation.loop_guard import LoopGuard, LoopGuardConfig
|
||||
self._loop_guard = LoopGuard(LoopGuardConfig(enabled=True))
|
||||
# Initialize loop guard lazily on first tool execution (zero init overhead)
|
||||
self._loop_guard = None
|
||||
|
||||
# If instructions are provided, use them to set role, goal, and backstory
|
||||
if instructions:
|
||||
@@ -1527,6 +1557,8 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
self.interrupt_controller = interrupt_controller
|
||||
# Check for model name in environment variable if not provided
|
||||
self._using_custom_llm = False
|
||||
self._llm_instance = None
|
||||
self._llm_init_params = None
|
||||
# Flag to track if final result has been displayed to prevent duplicates
|
||||
self._final_display_shown = False
|
||||
|
||||
@@ -1565,99 +1597,67 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
|
||||
# If base_url is provided, always create a custom LLM instance
|
||||
# If base_url is provided, defer custom LLM instance creation
|
||||
if base_url:
|
||||
try:
|
||||
from ..llm.llm import LLM
|
||||
# Handle different llm parameter types with base_url
|
||||
if isinstance(llm, dict):
|
||||
# Merge base_url and api_key into the dict
|
||||
llm_config = llm.copy()
|
||||
llm_config['base_url'] = base_url
|
||||
if api_key:
|
||||
llm_config['api_key'] = api_key
|
||||
if auth:
|
||||
llm_config['auth'] = auth
|
||||
llm_config['metrics'] = metrics
|
||||
llm_config['max_iter'] = max_iter
|
||||
self.llm_instance = LLM(**llm_config)
|
||||
self.llm = llm.get('model', Agent._get_default_model())
|
||||
else:
|
||||
# Create LLM with model string and base_url (cached for performance)
|
||||
model_name = llm or Agent._get_default_model()
|
||||
self.llm_instance = LLM(
|
||||
model=model_name,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
auth=auth,
|
||||
metrics=metrics,
|
||||
max_iter=max_iter,
|
||||
web_search=web_search,
|
||||
web_fetch=web_fetch,
|
||||
prompt_caching=prompt_caching,
|
||||
claude_memory=claude_memory
|
||||
)
|
||||
self.llm = model_name
|
||||
self._using_custom_llm = True
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"LLM features requested but dependencies not installed. "
|
||||
"Please install with: pip install \"praisonaiagents[llm]\""
|
||||
) from e
|
||||
if isinstance(llm, dict):
|
||||
llm_config = llm.copy()
|
||||
llm_config['base_url'] = base_url
|
||||
if api_key:
|
||||
llm_config['api_key'] = api_key
|
||||
if auth:
|
||||
llm_config['auth'] = auth
|
||||
llm_config['metrics'] = metrics
|
||||
llm_config['max_iter'] = max_iter
|
||||
self._llm_init_params = llm_config
|
||||
self.llm = llm.get('model', Agent._get_default_model())
|
||||
else:
|
||||
model_name = llm or Agent._get_default_model()
|
||||
self._llm_init_params = {
|
||||
'model': model_name,
|
||||
'base_url': base_url,
|
||||
'api_key': api_key,
|
||||
'auth': auth,
|
||||
'metrics': metrics,
|
||||
'max_iter': max_iter,
|
||||
'web_search': web_search,
|
||||
'web_fetch': web_fetch,
|
||||
'prompt_caching': prompt_caching,
|
||||
'claude_memory': claude_memory,
|
||||
}
|
||||
self.llm = model_name
|
||||
self._using_custom_llm = True
|
||||
# If the user passes a dictionary (for advanced configuration)
|
||||
elif isinstance(llm, dict) and "model" in llm:
|
||||
try:
|
||||
from ..llm.llm import LLM
|
||||
# Add api_key if provided and not in dict
|
||||
if api_key and 'api_key' not in llm:
|
||||
llm = llm.copy()
|
||||
llm['api_key'] = api_key
|
||||
# Add auth if provided and not in dict
|
||||
if auth and 'auth' not in llm:
|
||||
llm = llm.copy()
|
||||
llm['auth'] = auth
|
||||
# Add metrics parameter
|
||||
llm = llm.copy()
|
||||
llm['metrics'] = metrics
|
||||
llm['max_iter'] = max_iter
|
||||
self.llm_instance = LLM(**llm) # Pass all dict items as kwargs
|
||||
self._using_custom_llm = True
|
||||
self.llm = llm.get('model', Agent._get_default_model())
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"LLM features requested but dependencies not installed. "
|
||||
"Please install with: pip install \"praisonaiagents[llm]\""
|
||||
) from e
|
||||
# If the user passes a string with a slash (provider/model)
|
||||
llm_config = llm.copy()
|
||||
if api_key and 'api_key' not in llm_config:
|
||||
llm_config['api_key'] = api_key
|
||||
if auth and 'auth' not in llm_config:
|
||||
llm_config['auth'] = auth
|
||||
llm_config['metrics'] = metrics
|
||||
llm_config['max_iter'] = max_iter
|
||||
self._llm_init_params = llm_config
|
||||
self._using_custom_llm = True
|
||||
self.llm = llm_config.get('model', Agent._get_default_model())
|
||||
# If the user passes a string with a slash (provider/model) — defer LiteLLM import
|
||||
elif isinstance(llm, str) and "/" in llm:
|
||||
try:
|
||||
from ..llm.llm import LLM
|
||||
# Pass the entire string so LiteLLM can parse provider/model
|
||||
llm_params = {'model': llm}
|
||||
if api_key:
|
||||
llm_params['api_key'] = api_key
|
||||
if auth:
|
||||
llm_params['auth'] = auth
|
||||
llm_params['metrics'] = metrics
|
||||
llm_params['web_search'] = web_search
|
||||
llm_params['web_fetch'] = web_fetch
|
||||
llm_params['prompt_caching'] = prompt_caching
|
||||
llm_params['claude_memory'] = claude_memory
|
||||
llm_params['max_iter'] = max_iter
|
||||
self.llm_instance = LLM(**llm_params)
|
||||
self._using_custom_llm = True
|
||||
self.llm = llm
|
||||
|
||||
# Ensure tools are properly accessible when using custom LLM
|
||||
if tools:
|
||||
logging.debug(f"Tools passed to Agent with custom LLM: {tools}")
|
||||
# Store the tools for later use
|
||||
self.tools = tools
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"LLM features requested but dependencies not installed. "
|
||||
"Please install with: pip install \"praisonaiagents[llm]\""
|
||||
) from e
|
||||
llm_params = {'model': llm}
|
||||
if api_key:
|
||||
llm_params['api_key'] = api_key
|
||||
if auth:
|
||||
llm_params['auth'] = auth
|
||||
llm_params['metrics'] = metrics
|
||||
llm_params['web_search'] = web_search
|
||||
llm_params['web_fetch'] = web_fetch
|
||||
llm_params['prompt_caching'] = prompt_caching
|
||||
llm_params['claude_memory'] = claude_memory
|
||||
llm_params['max_iter'] = max_iter
|
||||
self._llm_init_params = llm_params
|
||||
self._using_custom_llm = True
|
||||
self.llm = llm
|
||||
|
||||
if tools:
|
||||
logging.debug(f"Tools passed to Agent with custom LLM: {tools}")
|
||||
self.tools = tools
|
||||
# Otherwise, fall back to OpenAI environment/name (cached for performance)
|
||||
else:
|
||||
self.llm = llm or Agent._get_default_model()
|
||||
|
||||
@@ -87,12 +87,41 @@ Your Goal: {self.goal}"""
|
||||
|
||||
# Add memory context if memory is enabled
|
||||
if self._memory_instance:
|
||||
memory_context = self.get_memory_context()
|
||||
if memory_context:
|
||||
system_prompt += f"\n\n## Memory (Information you remember about the user)\n{memory_context}"
|
||||
# Display memory info to user if verbose
|
||||
if self.verbose:
|
||||
self._display_memory_info()
|
||||
# Use cache-optimized context if model supports prompt caching and cache boundary is requested
|
||||
use_cache_boundaries = hasattr(self, '_model_supports_prompt_caching') and self._model_supports_prompt_caching()
|
||||
if use_cache_boundaries and hasattr(self._memory_instance, 'build_cache_optimized_context'):
|
||||
try:
|
||||
# Get the goal/task description for context building
|
||||
task_desc = getattr(self, 'goal', '') or 'general assistance'
|
||||
cache_result = self._memory_instance.build_cache_optimized_context(
|
||||
task_descr=task_desc,
|
||||
include_cache_boundary=True
|
||||
)
|
||||
memory_context = cache_result.get('stable_prefix', '')
|
||||
cache_boundary = cache_result.get('cache_boundary', '')
|
||||
if memory_context:
|
||||
system_prompt += f"\n\n## Memory (Information you remember about the user)\n{memory_context}"
|
||||
if cache_boundary:
|
||||
system_prompt += cache_boundary
|
||||
# Display memory info to user if verbose
|
||||
if self.verbose:
|
||||
self._display_memory_info()
|
||||
except (AttributeError, KeyError, TypeError) as e:
|
||||
# Fall back to standard memory context if cache optimization fails
|
||||
memory_context = self.get_memory_context()
|
||||
if memory_context:
|
||||
system_prompt += f"\n\n## Memory (Information you remember about the user)\n{memory_context}"
|
||||
# Display memory info to user if verbose
|
||||
if self.verbose:
|
||||
self._display_memory_info()
|
||||
else:
|
||||
# Standard memory context without cache boundaries
|
||||
memory_context = self.get_memory_context()
|
||||
if memory_context:
|
||||
system_prompt += f"\n\n## Memory (Information you remember about the user)\n{memory_context}"
|
||||
# Display memory info to user if verbose
|
||||
if self.verbose:
|
||||
self._display_memory_info()
|
||||
|
||||
# Add learn context if learn is enabled (auto-inject when memory="learn")
|
||||
learn_context = self.get_learn_context()
|
||||
@@ -452,6 +481,17 @@ Your Goal: {self.goal}"""
|
||||
else:
|
||||
cleaned_tools.append(tool)
|
||||
|
||||
# Sort tools deterministically for cache stability
|
||||
def sort_formatted_tools(tools_list):
|
||||
"""Sort already-formatted tool schemas by function name."""
|
||||
def sort_key(tool):
|
||||
if isinstance(tool, dict) and tool.get('type') == 'function':
|
||||
return str(tool.get('function', {}).get('name') or '')
|
||||
return ''
|
||||
return sorted(tools_list, key=sort_key)
|
||||
|
||||
cleaned_tools = sort_formatted_tools(cleaned_tools)
|
||||
|
||||
# Cache the formatted tools with LRU eviction, including tool search metadata
|
||||
self._cache_put(
|
||||
self._formatted_tools_cache,
|
||||
|
||||
@@ -231,9 +231,10 @@ class ToolExecutionMixin:
|
||||
arguments.update(res.output.modified_data)
|
||||
|
||||
# Loop guard check - prevent tool execution loops with graduated response
|
||||
if hasattr(self, '_loop_guard') and self._loop_guard:
|
||||
if hasattr(self, '_ensure_loop_guard'):
|
||||
loop_guard = self._ensure_loop_guard()
|
||||
from ..escalation.loop_guard import GuardAction
|
||||
decision = self._loop_guard.check(function_name, arguments, is_pre_execution=True)
|
||||
decision = loop_guard.check(function_name, arguments, is_pre_execution=True)
|
||||
|
||||
if decision.action == GuardAction.WARN:
|
||||
# Inject warning into tool result so LLM sees guidance
|
||||
@@ -394,11 +395,12 @@ class ToolExecutionMixin:
|
||||
self._doom_loop_tracker.mark_progress(f"tool:{function_name}")
|
||||
|
||||
# Record tool execution in loop guard
|
||||
if hasattr(self, '_loop_guard') and self._loop_guard:
|
||||
if hasattr(self, '_ensure_loop_guard'):
|
||||
loop_guard = self._ensure_loop_guard()
|
||||
is_success = result is not None and not (isinstance(result, dict) and result.get('error'))
|
||||
self._loop_guard.record(function_name, arguments, is_success)
|
||||
loop_guard.record(function_name, arguments, is_success)
|
||||
# Handle warning injection for WARN decisions
|
||||
decision = self._loop_guard.check(function_name, arguments, is_pre_execution=False)
|
||||
decision = loop_guard.check(function_name, arguments, is_pre_execution=False)
|
||||
if decision.action.value == "warn":
|
||||
if isinstance(result, str):
|
||||
result = f"{result}\n\n[loop-guard] {decision.message}"
|
||||
|
||||
@@ -78,8 +78,8 @@ class UnifiedExecutionMixin:
|
||||
_trace_emitter.agent_start(self.name, {"role": self.role, "goal": self.goal})
|
||||
|
||||
# Reset loop guard for new chat turn (always-on protection)
|
||||
if hasattr(self, '_loop_guard') and self._loop_guard:
|
||||
self._loop_guard.reset_turn()
|
||||
if hasattr(self, '_ensure_loop_guard'):
|
||||
self._ensure_loop_guard().reset_turn()
|
||||
|
||||
try:
|
||||
# CLI Backend routing - delegate entire turn if configured
|
||||
|
||||
@@ -40,6 +40,21 @@ class TaskStatus(Enum):
|
||||
# Set up logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _launch_auth_token() -> Optional[str]:
|
||||
return os.environ.get("PRAISONAI_LAUNCH_AUTH_TOKEN")
|
||||
|
||||
|
||||
def _authorise_launch_request(request) -> bool:
|
||||
token = _launch_auth_token()
|
||||
if not token:
|
||||
return True
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer ") and auth[7:] == token:
|
||||
return True
|
||||
return request.headers.get("X-Auth-Token") == token
|
||||
|
||||
|
||||
# Agent server registry for thread-safe server management
|
||||
|
||||
|
||||
@@ -245,7 +260,20 @@ Expected Output: {task.expected_output}."""
|
||||
# Add memory context if available
|
||||
if task.memory:
|
||||
try:
|
||||
memory_context = task.memory.build_context_for_task(task.description)
|
||||
# Use cache-optimized context if available for better prompt caching
|
||||
if hasattr(task.memory, 'build_cache_optimized_context'):
|
||||
try:
|
||||
cache_result = task.memory.build_cache_optimized_context(
|
||||
task_descr=task.description,
|
||||
include_cache_boundary=False # Don't include boundary for agent task context
|
||||
)
|
||||
memory_context = cache_result.get('stable_prefix', '')
|
||||
except Exception as e:
|
||||
# Fall back to standard context building
|
||||
logger.debug(f"Cache-optimized context failed for task '{task.description or task.id}', falling back to standard: {e}")
|
||||
memory_context = task.memory.build_context_for_task(task.description)
|
||||
else:
|
||||
memory_context = task.memory.build_context_for_task(task.description)
|
||||
if memory_context:
|
||||
# Log detailed memory context for debugging
|
||||
logger.debug(f"Memory context for task '{task.description}': {memory_context}")
|
||||
@@ -652,7 +680,7 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
_stream = _output_config.stream
|
||||
else:
|
||||
_verbose = 0
|
||||
_stream = True
|
||||
_stream = False
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Resolve EXECUTION param using canonical resolver
|
||||
@@ -1993,6 +2021,8 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
# Define the endpoint handler
|
||||
@app.post(path)
|
||||
async def handle_query(request: Request, query_data: Optional[AgentQuery] = None):
|
||||
if not _authorise_launch_request(request):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
# Handle both direct JSON with query field and form data
|
||||
if query_data is None:
|
||||
try:
|
||||
@@ -2071,7 +2101,9 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
|
||||
# Add GET endpoint to list available agents
|
||||
@app.get(f"{path}/list")
|
||||
async def list_agents():
|
||||
async def list_agents(request: Request):
|
||||
if not _authorise_launch_request(request):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
return {
|
||||
"agents": [
|
||||
{"name": agent.display_name, "id": agent.display_name.lower().replace(' ', '_')}
|
||||
@@ -2086,6 +2118,8 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
# Create a closure to capture the agent instance
|
||||
def create_agent_handler(agent):
|
||||
async def handle_single_agent(request: Request):
|
||||
if not _authorise_launch_request(request):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
try:
|
||||
request_data = await request.json()
|
||||
query = request_data.get("query", "")
|
||||
|
||||
@@ -53,3 +53,4 @@ class AgentAppConfig:
|
||||
workers: int = 1
|
||||
timeout: int = 60
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
api_key: Optional[str] = None
|
||||
@@ -194,9 +194,13 @@ class ApprovalRegistry:
|
||||
|
||||
def is_yaml_approved(self, tool_name: str) -> bool:
|
||||
try:
|
||||
return tool_name in self._yaml_approved_tools.get()
|
||||
if tool_name not in self._yaml_approved_tools.get():
|
||||
return False
|
||||
except LookupError:
|
||||
return False
|
||||
if self.get_risk_level(tool_name) == "critical":
|
||||
return False
|
||||
return True
|
||||
|
||||
# ── Env-var check ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -91,6 +91,16 @@ class BotConfig:
|
||||
# When set, stale sessions older than this are auto-reaped.
|
||||
session_ttl: int = 0
|
||||
|
||||
# In-flight run control settings for mid-run message handling
|
||||
# busy_mode: Policy for handling messages during active runs
|
||||
# - "queue": Queue message for next turn (default)
|
||||
# - "interrupt": Cancel current run and start new one
|
||||
# - "steer": Inject message into current run via steering
|
||||
busy_mode: str = "queue"
|
||||
|
||||
# Template for busy acknowledgment messages (use {action} placeholder)
|
||||
busy_ack: str = "⏳ {action} — will be considered next"
|
||||
|
||||
# Workspace settings for file operation containment and security
|
||||
workspace_dir: Optional[str] = None # default: ~/.praisonai/workspaces/<scope>/<session_key>
|
||||
workspace_access: str = "rw" # "rw" (read-write) | "ro" (read-only) | "none" (copy-on-write sandbox)
|
||||
@@ -136,6 +146,8 @@ class BotConfig:
|
||||
"ack_emoji": self.ack_emoji,
|
||||
"done_emoji": self.done_emoji,
|
||||
"session_ttl": self.session_ttl,
|
||||
"busy_mode": self.busy_mode,
|
||||
"busy_ack": self.busy_ack,
|
||||
"workspace_dir": self.workspace_dir,
|
||||
"workspace_access": self.workspace_access,
|
||||
"workspace_scope": self.workspace_scope,
|
||||
|
||||
@@ -33,6 +33,10 @@ __all__ = [
|
||||
"CompactionStrategy",
|
||||
# Results
|
||||
"CompactionResult",
|
||||
# Protocols
|
||||
"ToolResultPrunerProtocol",
|
||||
"MessageFormatterProtocol",
|
||||
"SummaryBuilderProtocol",
|
||||
]
|
||||
|
||||
|
||||
@@ -54,4 +58,16 @@ def __getattr__(name: str):
|
||||
from .result import CompactionResult
|
||||
return CompactionResult
|
||||
|
||||
if name == "ToolResultPrunerProtocol":
|
||||
from .protocols import ToolResultPrunerProtocol
|
||||
return ToolResultPrunerProtocol
|
||||
|
||||
if name == "MessageFormatterProtocol":
|
||||
from .protocols import MessageFormatterProtocol
|
||||
return MessageFormatterProtocol
|
||||
|
||||
if name == "SummaryBuilderProtocol":
|
||||
from .protocols import SummaryBuilderProtocol
|
||||
return SummaryBuilderProtocol
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -5,12 +5,13 @@ Manages context window by compacting messages when needed.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional, Callable, Awaitable
|
||||
from typing import List, Dict, Any, Optional, Callable, Awaitable, Tuple
|
||||
import asyncio
|
||||
|
||||
from .config import CompactionConfig, COMPACTION_PREFIX, SUMMARY_TEMPLATE
|
||||
from .strategy import CompactionStrategy
|
||||
from .result import CompactionResult
|
||||
from .protocols import ToolResultPrunerProtocol, MessageFormatterProtocol, SummaryBuilderProtocol
|
||||
|
||||
|
||||
class ContextCompactor:
|
||||
@@ -34,7 +35,10 @@ class ContextCompactor:
|
||||
preserve_system: bool = True,
|
||||
preserve_recent: int = 5,
|
||||
config: Optional[CompactionConfig] = None,
|
||||
llm_summarize_fn: Optional[Callable[[str], Awaitable[str]]] = None
|
||||
llm_summarize_fn: Optional[Callable[[str], Awaitable[str]]] = None,
|
||||
tool_pruner: Optional[ToolResultPrunerProtocol] = None,
|
||||
message_formatter: Optional[MessageFormatterProtocol] = None,
|
||||
summary_builder: Optional[SummaryBuilderProtocol] = None
|
||||
):
|
||||
"""
|
||||
Initialize the compactor.
|
||||
@@ -45,30 +49,43 @@ class ContextCompactor:
|
||||
strategy: Compaction strategy to use
|
||||
preserve_system: Keep system messages
|
||||
preserve_recent: Number of recent messages to preserve
|
||||
<<<<<<< HEAD
|
||||
config: Optional CompactionConfig to override defaults
|
||||
config: Optional CompactionConfig for advanced settings
|
||||
llm_summarize_fn: Async function to call LLM for summarization
|
||||
tool_pruner: Protocol implementation for tool result pruning
|
||||
message_formatter: Protocol implementation for message formatting
|
||||
summary_builder: Protocol implementation for summary building
|
||||
"""
|
||||
# Initialize config first
|
||||
self.config = config or CompactionConfig()
|
||||
# Use provided config or create default
|
||||
self.config = config or CompactionConfig(
|
||||
max_tokens=max_tokens,
|
||||
target_tokens=target_tokens or int(max_tokens * 0.75),
|
||||
preserve_system=preserve_system,
|
||||
preserve_recent=preserve_recent
|
||||
)
|
||||
|
||||
# Use config values if config provided, otherwise use constructor args
|
||||
if config is not None:
|
||||
self.max_tokens = config.max_tokens
|
||||
self.target_tokens = config.target_tokens
|
||||
self.preserve_system = config.preserve_system
|
||||
self.preserve_recent = config.preserve_recent
|
||||
else:
|
||||
self.max_tokens = max_tokens
|
||||
self.target_tokens = target_tokens or int(max_tokens * 0.75)
|
||||
self.preserve_system = preserve_system
|
||||
self.preserve_recent = preserve_recent
|
||||
|
||||
# Set instance attributes from config to ensure consistency
|
||||
self.max_tokens = self.config.max_tokens
|
||||
self.target_tokens = self.config.target_tokens
|
||||
self.strategy = strategy
|
||||
self.preserve_system = self.config.preserve_system
|
||||
self.preserve_recent = self.config.preserve_recent
|
||||
self.llm_summarize_fn = llm_summarize_fn
|
||||
|
||||
# Track previous summaries for iterative update
|
||||
# Protocol implementations (defaults to None - no heavy implementations in core)
|
||||
self.tool_pruner = tool_pruner
|
||||
self.message_formatter = message_formatter
|
||||
self.summary_builder = summary_builder
|
||||
|
||||
# Anti-thrashing state tracking
|
||||
self._last_savings_pct: float = 100.0 # Start high to allow first compaction
|
||||
self._low_savings_streak: int = 0
|
||||
|
||||
# Iterative summary state
|
||||
self._previous_summary: Optional[str] = None
|
||||
self._previous_summary_global_idx: int = 0
|
||||
|
||||
# Reset state tracking for each compact() call
|
||||
self._used_previous_summary: bool = False
|
||||
|
||||
def estimate_tokens(self, text: str) -> int:
|
||||
"""Estimate token count for text."""
|
||||
@@ -96,12 +113,26 @@ class ContextCompactor:
|
||||
return sum(self.count_message_tokens(m) for m in messages)
|
||||
|
||||
def needs_compaction(self, messages: List[Dict[str, Any]]) -> bool:
|
||||
"""Check if messages need compaction."""
|
||||
return self.count_total_tokens(messages) > self.max_tokens
|
||||
"""
|
||||
Check if messages need compaction with anti-thrashing protection.
|
||||
|
||||
Returns False if:
|
||||
- Token count is below threshold
|
||||
- We've had too many consecutive low-savings attempts
|
||||
"""
|
||||
if self.count_total_tokens(messages) <= self.max_tokens:
|
||||
return False
|
||||
|
||||
# Anti-thrashing: skip if we've had too many low-savings attempts
|
||||
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def compact(
|
||||
self,
|
||||
messages: List[Dict[str, Any]]
|
||||
messages: List[Dict[str, Any]],
|
||||
focus_topic: str = ""
|
||||
) -> tuple[List[Dict[str, Any]], CompactionResult]:
|
||||
"""
|
||||
Compact messages to fit within token limit (synchronous version).
|
||||
@@ -111,31 +142,49 @@ class ContextCompactor:
|
||||
|
||||
Args:
|
||||
messages: List of messages to compact
|
||||
focus_topic: Optional topic to focus on during summarization
|
||||
|
||||
Returns:
|
||||
Tuple of (compacted messages, result)
|
||||
"""
|
||||
# Reset state for this compaction call
|
||||
self._used_previous_summary = False
|
||||
|
||||
original_tokens = self.count_total_tokens(messages)
|
||||
|
||||
if original_tokens <= self.max_tokens:
|
||||
return messages, CompactionResult(
|
||||
result = CompactionResult(
|
||||
original_tokens=original_tokens,
|
||||
compacted_tokens=original_tokens,
|
||||
messages_removed=0,
|
||||
messages_kept=len(messages),
|
||||
strategy_used=self.strategy
|
||||
)
|
||||
result.calculate_savings_pct()
|
||||
return messages, result
|
||||
|
||||
# Apply tool result deduplication pre-pass if enabled
|
||||
processed_messages = messages
|
||||
tool_results_pruned = 0
|
||||
if self.config.tool_prune_before_summarise and self.tool_pruner:
|
||||
processed_messages, tool_results_pruned = self.tool_pruner.prune(
|
||||
messages,
|
||||
self.config.max_tool_result_size
|
||||
)
|
||||
|
||||
# Skip early exit - proceed with full compaction strategy
|
||||
# Anti-thrashing check will be applied after strategy runs
|
||||
|
||||
if self.strategy == CompactionStrategy.TRUNCATE:
|
||||
compacted = self._truncate(messages)
|
||||
compacted = self._truncate(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SLIDING:
|
||||
compacted = self._sliding_window(messages)
|
||||
compacted = self._sliding_window(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SUMMARIZE:
|
||||
compacted = self._summarize(messages)
|
||||
compacted = self._summarize(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SMART:
|
||||
compacted = self._smart_compact(messages)
|
||||
compacted = self._smart_compact(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.PRUNE:
|
||||
compacted = self._prune(messages)
|
||||
compacted = self._prune(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.LLM_SUMMARIZE:
|
||||
if self.llm_summarize_fn:
|
||||
# For sync calls with LLM function, we need to run async
|
||||
@@ -144,17 +193,17 @@ class ContextCompactor:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# If in async context, fallback to naive summarization
|
||||
compacted = self._summarize(messages)
|
||||
compacted = self._summarize(processed_messages)
|
||||
except RuntimeError:
|
||||
# No running loop, safe to create one
|
||||
compacted = asyncio.run(self._llm_summarize_async(messages))
|
||||
compacted = asyncio.run(self._llm_summarize_async(processed_messages, focus_topic))
|
||||
except Exception:
|
||||
# Fallback to naive summarization if async fails
|
||||
compacted = self._summarize(messages)
|
||||
compacted = self._summarize(processed_messages)
|
||||
else:
|
||||
compacted = self._llm_summarize(messages)
|
||||
compacted = self._llm_summarize(processed_messages, focus_topic)
|
||||
else:
|
||||
compacted = self._truncate(messages)
|
||||
compacted = self._truncate(processed_messages)
|
||||
|
||||
compacted_tokens = self.count_total_tokens(compacted)
|
||||
|
||||
@@ -163,49 +212,75 @@ class ContextCompactor:
|
||||
compacted_tokens=compacted_tokens,
|
||||
messages_removed=len(messages) - len(compacted),
|
||||
messages_kept=len(compacted),
|
||||
strategy_used=self.strategy
|
||||
strategy_used=self.strategy,
|
||||
tool_results_pruned=tool_results_pruned,
|
||||
previous_summary_reused=getattr(self, '_used_previous_summary', False)
|
||||
)
|
||||
result.calculate_savings_pct()
|
||||
|
||||
# Update anti-thrashing tracking based on actual results
|
||||
self._last_savings_pct = result.savings_pct
|
||||
if result.savings_pct >= self.config.min_savings_pct:
|
||||
self._low_savings_streak = 0
|
||||
else:
|
||||
self._low_savings_streak += 1
|
||||
|
||||
return compacted, result
|
||||
|
||||
async def compact_async(
|
||||
self,
|
||||
messages: List[Dict[str, Any]]
|
||||
messages: List[Dict[str, Any]],
|
||||
focus_topic: str = ""
|
||||
) -> tuple[List[Dict[str, Any]], CompactionResult]:
|
||||
"""
|
||||
Compact messages to fit within token limit (asynchronous version).
|
||||
|
||||
Args:
|
||||
messages: List of messages to compact
|
||||
focus_topic: Optional topic to focus on during summarization
|
||||
|
||||
Returns:
|
||||
Tuple of (compacted messages, result)
|
||||
"""
|
||||
# Reset state for this compaction call
|
||||
self._used_previous_summary = False
|
||||
|
||||
original_tokens = self.count_total_tokens(messages)
|
||||
|
||||
if original_tokens <= self.max_tokens:
|
||||
return messages, CompactionResult(
|
||||
result = CompactionResult(
|
||||
original_tokens=original_tokens,
|
||||
compacted_tokens=original_tokens,
|
||||
messages_removed=0,
|
||||
messages_kept=len(messages),
|
||||
strategy_used=self.strategy
|
||||
)
|
||||
result.calculate_savings_pct()
|
||||
return messages, result
|
||||
|
||||
# Apply tool result deduplication pre-pass if enabled
|
||||
processed_messages = messages
|
||||
tool_results_pruned = 0
|
||||
if self.config.tool_prune_before_summarise and self.tool_pruner:
|
||||
processed_messages, tool_results_pruned = self.tool_pruner.prune(
|
||||
messages,
|
||||
self.config.max_tool_result_size
|
||||
)
|
||||
|
||||
if self.strategy == CompactionStrategy.TRUNCATE:
|
||||
compacted = self._truncate(messages)
|
||||
compacted = self._truncate(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SLIDING:
|
||||
compacted = self._sliding_window(messages)
|
||||
compacted = self._sliding_window(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SUMMARIZE:
|
||||
compacted = self._summarize(messages)
|
||||
compacted = self._summarize(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SMART:
|
||||
compacted = self._smart_compact(messages)
|
||||
compacted = self._smart_compact(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.PRUNE:
|
||||
compacted = self._prune(messages)
|
||||
compacted = self._prune(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.LLM_SUMMARIZE:
|
||||
compacted = await self._llm_summarize_async(messages)
|
||||
compacted = await self._llm_summarize_async(processed_messages, focus_topic)
|
||||
else:
|
||||
compacted = self._truncate(messages)
|
||||
compacted = self._truncate(processed_messages)
|
||||
|
||||
compacted_tokens = self.count_total_tokens(compacted)
|
||||
|
||||
@@ -214,11 +289,35 @@ class ContextCompactor:
|
||||
compacted_tokens=compacted_tokens,
|
||||
messages_removed=len(messages) - len(compacted),
|
||||
messages_kept=len(compacted),
|
||||
strategy_used=self.strategy
|
||||
strategy_used=self.strategy,
|
||||
tool_results_pruned=tool_results_pruned,
|
||||
previous_summary_reused=getattr(self, '_used_previous_summary', False)
|
||||
)
|
||||
result.calculate_savings_pct()
|
||||
|
||||
# Update anti-thrashing tracking based on actual results
|
||||
self._last_savings_pct = result.savings_pct
|
||||
if result.savings_pct >= self.config.min_savings_pct:
|
||||
self._low_savings_streak = 0
|
||||
else:
|
||||
self._low_savings_streak += 1
|
||||
|
||||
return compacted, result
|
||||
|
||||
def _prune_tool_results(self, messages: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], int]:
|
||||
"""
|
||||
Delegate tool result pruning to injected protocol implementation.
|
||||
|
||||
If no tool pruner is provided, returns messages unchanged.
|
||||
This maintains backward compatibility while enforcing protocol-driven design.
|
||||
|
||||
Returns:
|
||||
Tuple of (processed messages, number of tool results pruned)
|
||||
"""
|
||||
if self.tool_pruner:
|
||||
return self.tool_pruner.prune(messages, self.config.max_tool_result_size)
|
||||
return messages, 0
|
||||
|
||||
def _truncate(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Truncate oldest messages."""
|
||||
result = []
|
||||
@@ -360,72 +459,19 @@ class ContextCompactor:
|
||||
result.extend(recent)
|
||||
return result
|
||||
|
||||
def _llm_summarize(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def _llm_summarize(self, messages: List[Dict[str, Any]], focus_topic: str = "") -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Use LLM to summarize older messages with anti-injection framing.
|
||||
Use LLM to summarize older messages with iterative support.
|
||||
|
||||
Note: This implementation uses structured templates and anti-injection prefixes
|
||||
to prevent the model from treating summarized content as active instructions.
|
||||
"""
|
||||
result = []
|
||||
Supports iterative summarization - if we have a previous summary,
|
||||
we only summarize the new messages since that summary.
|
||||
Also includes anti-injection framing when configured.
|
||||
|
||||
# Keep system messages (excluding previous compacted summaries)
|
||||
system_msgs = [
|
||||
m for m in messages
|
||||
if m.get("role") == "system" and not m.get("_compacted")
|
||||
]
|
||||
other_msgs = [m for m in messages if m.get("role") != "system"]
|
||||
|
||||
result.extend(system_msgs)
|
||||
|
||||
# Keep recent messages
|
||||
recent = other_msgs[-self.preserve_recent:]
|
||||
older = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
|
||||
|
||||
if older:
|
||||
if self.config.structured_template:
|
||||
structured = self._build_structured_summary(older)
|
||||
else:
|
||||
# Fallback to simple summary
|
||||
summary_parts = []
|
||||
for msg in older:
|
||||
role = msg.get("role", "unknown")
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str) and content:
|
||||
summary_parts.append(f"[{role}]: {content[:150]}...")
|
||||
structured = "\n".join(summary_parts[:10])
|
||||
|
||||
if structured:
|
||||
# Apply anti-injection prefix
|
||||
prefixed = f"{self.config.compaction_prefix}\n\n{structured}"
|
||||
|
||||
# Handle iterative update if enabled
|
||||
if self.config.iterative_update and self._previous_summary:
|
||||
structured = self._merge_summaries(self._previous_summary, structured)
|
||||
prefixed = f"{self.config.compaction_prefix}\n\n{structured}"
|
||||
|
||||
# Store for next iteration
|
||||
if self.config.iterative_update:
|
||||
self._previous_summary = structured
|
||||
|
||||
result.append({
|
||||
"role": "system",
|
||||
"content": prefixed,
|
||||
"_compacted": True,
|
||||
"_original_count": len(older),
|
||||
"_anti_injection": True,
|
||||
})
|
||||
|
||||
result.extend(recent)
|
||||
return result
|
||||
|
||||
async def _llm_summarize_async(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Use LLM to intelligently summarize older messages.
|
||||
|
||||
This method invokes the agent's LLM to create a meaningful summary
|
||||
that preserves key facts, identifiers, and the user's intent.
|
||||
Args:
|
||||
messages: List of messages to compact
|
||||
focus_topic: Optional topic to focus on during summarization
|
||||
"""
|
||||
self._used_previous_summary = False
|
||||
result = []
|
||||
|
||||
# Keep system messages
|
||||
@@ -436,33 +482,169 @@ class ContextCompactor:
|
||||
|
||||
# Keep recent messages
|
||||
recent = other_msgs[-self.preserve_recent:]
|
||||
older = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
|
||||
|
||||
# Determine what to summarize
|
||||
total_original_messages = len(messages)
|
||||
if (self.config.enable_iterative_summary and
|
||||
self._previous_summary and
|
||||
total_original_messages > self._previous_summary_global_idx):
|
||||
# Iterative: summarize only new messages since previous summary
|
||||
# Calculate how many old messages to summarize based on global position
|
||||
messages_since_summary = total_original_messages - self._previous_summary_global_idx
|
||||
new_older_messages = max(0, messages_since_summary - self.preserve_recent)
|
||||
if new_older_messages > 0:
|
||||
to_summarize = other_msgs[-messages_since_summary:-self.preserve_recent]
|
||||
else:
|
||||
to_summarize = []
|
||||
self._used_previous_summary = True
|
||||
else:
|
||||
# Fresh summary: summarize all older messages
|
||||
to_summarize = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
|
||||
self._used_previous_summary = False
|
||||
|
||||
if to_summarize or self._previous_summary:
|
||||
# Build summary content
|
||||
summary_parts = []
|
||||
|
||||
# Include previous summary if doing iterative summarization
|
||||
if self._used_previous_summary and self._previous_summary:
|
||||
summary_parts.append(f"[Previous Summary]: {self._previous_summary}")
|
||||
summary_parts.append("[New Activity]:")
|
||||
|
||||
# Add new messages to summarize
|
||||
for msg in to_summarize:
|
||||
role = msg.get("role", "unknown")
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str) and content:
|
||||
# Extract key information with focus consideration
|
||||
if focus_topic and focus_topic.lower() in content.lower():
|
||||
# Prioritize content related to focus topic
|
||||
summary_parts.append(f"[{role}] *FOCUS*: {content[:200]}...")
|
||||
else:
|
||||
summary_parts.append(f"[{role}]: {content[:150]}...")
|
||||
|
||||
if summary_parts:
|
||||
focus_hint = f"\nFocus on: {focus_topic}" if focus_topic else ""
|
||||
|
||||
if self._used_previous_summary:
|
||||
summary = (
|
||||
f"[Iterative conversation summary - update with new activity{focus_hint}]\n"
|
||||
+ "\n".join(summary_parts[:15])
|
||||
)
|
||||
else:
|
||||
summary = (
|
||||
f"[Compacted conversation history - summarize key points{focus_hint}]\n"
|
||||
+ "\n".join(summary_parts[:10])
|
||||
)
|
||||
|
||||
# Apply anti-injection prefix if configured
|
||||
if hasattr(self.config, 'compaction_prefix') and self.config.compaction_prefix:
|
||||
summary = f"{self.config.compaction_prefix}\n\n{summary}"
|
||||
|
||||
summary_msg = {
|
||||
"role": "system",
|
||||
"content": summary,
|
||||
"_compacted": True,
|
||||
"_original_count": len(to_summarize),
|
||||
"_iterative": self._used_previous_summary,
|
||||
"_focus_topic": focus_topic
|
||||
}
|
||||
result.append(summary_msg)
|
||||
|
||||
# Update tracking for future iterative summarization
|
||||
self._previous_summary = summary
|
||||
self._previous_summary_global_idx = len(messages)
|
||||
|
||||
result.extend(recent)
|
||||
return result
|
||||
|
||||
async def _llm_summarize_async(self, messages: List[Dict[str, Any]], focus_topic: str = "") -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Use LLM to intelligently summarize older messages.
|
||||
|
||||
This method invokes the agent's LLM to create a meaningful summary
|
||||
that preserves key facts, identifiers, and the user's intent.
|
||||
|
||||
Args:
|
||||
messages: List of messages to compact
|
||||
focus_topic: Optional topic to focus on during summarization
|
||||
"""
|
||||
self._used_previous_summary = False
|
||||
result = []
|
||||
|
||||
# Keep system messages
|
||||
system_msgs = [m for m in messages if m.get("role") == "system"]
|
||||
other_msgs = [m for m in messages if m.get("role") != "system"]
|
||||
|
||||
result.extend(system_msgs)
|
||||
|
||||
# Keep recent messages
|
||||
recent = other_msgs[-self.preserve_recent:]
|
||||
|
||||
# Determine what to summarize based on iterative settings
|
||||
total_original_messages = len(messages)
|
||||
if (self.config.enable_iterative_summary and
|
||||
self._previous_summary and
|
||||
total_original_messages > self._previous_summary_global_idx):
|
||||
# Iterative: summarize only new messages since previous summary
|
||||
messages_since_summary = total_original_messages - self._previous_summary_global_idx
|
||||
new_older_messages = max(0, messages_since_summary - self.preserve_recent)
|
||||
if new_older_messages > 0:
|
||||
older = other_msgs[-messages_since_summary:-self.preserve_recent]
|
||||
else:
|
||||
older = []
|
||||
self._used_previous_summary = True
|
||||
else:
|
||||
# Fresh summary: summarize all older messages
|
||||
older = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
|
||||
self._used_previous_summary = False
|
||||
|
||||
if older and self.llm_summarize_fn:
|
||||
try:
|
||||
# Format messages for summarization
|
||||
history_text = self._format_messages_for_summary(older)
|
||||
# Format messages for summarization using protocol
|
||||
if self.message_formatter:
|
||||
history_text = self.message_formatter.format_for_summary(older)
|
||||
else:
|
||||
# Fallback to basic formatting if no protocol implementation
|
||||
history_text = str(older)
|
||||
|
||||
# Create summarization prompt that preserves important information
|
||||
focus_hint = f" Focus especially on: {focus_topic}." if focus_topic else ""
|
||||
iterative_hint = ""
|
||||
if self._used_previous_summary and self._previous_summary:
|
||||
iterative_hint = f"\n\n[Previous Summary]: {self._previous_summary}\n[New Activity to Add]:"
|
||||
|
||||
prompt = (
|
||||
"Summarise the following conversation history. Preserve verbatim: "
|
||||
"all file paths, IDs, hashes, URLs, task references, error messages, "
|
||||
"tool outputs, and the user's requests. Be concise but complete. "
|
||||
"Focus on facts and actions taken, not general conversation.\n\n"
|
||||
f"{history_text}"
|
||||
f"Focus on facts and actions taken, not general conversation.{focus_hint}"
|
||||
f"{iterative_hint}\n\n{history_text}"
|
||||
)
|
||||
|
||||
# Call the LLM for summarization
|
||||
summary = await self.llm_summarize_fn(prompt)
|
||||
|
||||
# Add the LLM-generated summary as a system message
|
||||
summary_content = summary
|
||||
if self._used_previous_summary:
|
||||
summary_content = f"[Iterative conversation summary]\n{summary}"
|
||||
else:
|
||||
summary_content = f"[Previous conversation summary]\n{summary}"
|
||||
|
||||
result.append({
|
||||
"role": "system",
|
||||
"content": f"[Previous conversation summary]\n{summary}",
|
||||
"content": summary_content,
|
||||
"_compacted": True,
|
||||
"_original_count": len(older),
|
||||
"_llm_generated": True,
|
||||
"_iterative": self._used_previous_summary,
|
||||
"_focus_topic": focus_topic
|
||||
})
|
||||
|
||||
# Update tracking for future iterative summarization
|
||||
self._previous_summary = summary
|
||||
self._previous_summary_global_idx = len(messages)
|
||||
except Exception as e:
|
||||
# Fallback to naive summarization if LLM call fails
|
||||
import logging
|
||||
@@ -506,133 +688,41 @@ class ContextCompactor:
|
||||
"content": summary,
|
||||
"_compacted": True,
|
||||
"_original_count": len(older),
|
||||
"_fallback": True,
|
||||
})
|
||||
|
||||
result.extend(recent)
|
||||
return result
|
||||
|
||||
def _format_messages_for_summary(self, messages: List[Dict[str, Any]]) -> str:
|
||||
"""Format messages for LLM summarization."""
|
||||
formatted = []
|
||||
for i, msg in enumerate(messages):
|
||||
role = msg.get("role", "unknown")
|
||||
content = msg.get("content", "")
|
||||
|
||||
# Handle tool calls
|
||||
if msg.get("tool_calls"):
|
||||
tool_calls = msg.get("tool_calls", [])
|
||||
tool_names = [tc.get("function", {}).get("name", "unknown") for tc in tool_calls]
|
||||
formatted.append(f"{i+1}. {role}: Called tools: {', '.join(tool_names)}")
|
||||
|
||||
# Handle content
|
||||
if isinstance(content, str) and content.strip():
|
||||
# Truncate very long content but preserve structure
|
||||
if len(content) > 1000:
|
||||
content = content[:800] + "...[truncated]..." + content[-200:]
|
||||
formatted.append(f"{i+1}. {role}: {content}")
|
||||
elif isinstance(content, list):
|
||||
# Handle multi-part content
|
||||
parts = []
|
||||
for part in content[:3]: # Limit to first 3 parts
|
||||
if isinstance(part, dict):
|
||||
text = part.get("text", "")
|
||||
if text:
|
||||
parts.append(text[:200] + "..." if len(text) > 200 else text)
|
||||
if parts:
|
||||
formatted.append(f"{i+1}. {role}: {' | '.join(parts)}")
|
||||
|
||||
# Handle tool results with IDs
|
||||
if msg.get("tool_call_id"):
|
||||
tool_id = msg.get("tool_call_id", "")
|
||||
formatted.append(f"{i+1}. {role} (tool {tool_id}): {str(content)[:500]}...")
|
||||
"""
|
||||
Delegate message formatting to protocol implementation.
|
||||
|
||||
return "\n".join(formatted)
|
||||
If no formatter is provided, returns basic string representation.
|
||||
"""
|
||||
if self.message_formatter:
|
||||
return self.message_formatter.format_for_summary(messages)
|
||||
return str(messages)
|
||||
|
||||
def _build_structured_summary(self, messages: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
Build a structured summary using the configured template.
|
||||
Delegate structured summary building to protocol implementation.
|
||||
|
||||
Args:
|
||||
messages: Messages to summarize
|
||||
|
||||
Returns:
|
||||
Structured summary string
|
||||
If no summary builder is provided, returns basic summary.
|
||||
"""
|
||||
# Extract information from messages
|
||||
active_task = "No specific task identified"
|
||||
completed = []
|
||||
in_progress = []
|
||||
pending = []
|
||||
files = set()
|
||||
remaining = []
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
content = msg.get("content", "")
|
||||
|
||||
if not isinstance(content, str):
|
||||
continue
|
||||
|
||||
content_lower = content.lower()
|
||||
|
||||
# Extract file paths
|
||||
file_matches = re.findall(r'[\w/\.\-]+\.[a-zA-Z]{1,4}', content)
|
||||
files.update(file_matches[:5]) # Limit to 5 files
|
||||
|
||||
# Categorize content based on keywords and role
|
||||
content_snippet = content[:200] + ("..." if len(content) > 200 else "")
|
||||
|
||||
if role == "user":
|
||||
if any(word in content_lower for word in ["please", "can you", "help"]) or re.search(r'\bdo\b', content_lower):
|
||||
active_task = content_snippet
|
||||
elif "?" in content:
|
||||
pending.append(content_snippet)
|
||||
|
||||
elif role == "assistant":
|
||||
if any(word in content_lower for word in ["completed", "done", "finished"]):
|
||||
completed.append(content_snippet)
|
||||
elif any(word in content_lower for word in ["working", "processing", "analyzing"]):
|
||||
in_progress.append(content_snippet)
|
||||
elif any(word in content_lower for word in ["will", "plan to", "next"]):
|
||||
remaining.append(content_snippet)
|
||||
|
||||
elif role == "tool":
|
||||
# Tool results are generally completed actions
|
||||
tool_name = msg.get("name", "unknown")
|
||||
completed.append(f"Tool {tool_name}: {content_snippet}")
|
||||
|
||||
# Format using template
|
||||
return SUMMARY_TEMPLATE.format(
|
||||
active_task=active_task,
|
||||
completed="\n".join(f"- {item}" for item in completed[:3]) or "None identified",
|
||||
in_progress="\n".join(f"- {item}" for item in in_progress[:3]) or "None identified",
|
||||
pending="\n".join(f"- {item}" for item in pending[:3]) or "None identified",
|
||||
files=", ".join(list(files)[:5]) or "None mentioned",
|
||||
remaining="\n".join(f"- {item}" for item in remaining[:3]) or "None identified"
|
||||
)
|
||||
if self.summary_builder:
|
||||
return self.summary_builder.build_structured_summary(messages)
|
||||
return f"Summary of {len(messages)} messages"
|
||||
|
||||
def _merge_summaries(self, previous: str, current: str) -> str:
|
||||
"""
|
||||
Merge previous summary with current one for iterative updates.
|
||||
Delegate summary merging to protocol implementation.
|
||||
|
||||
Args:
|
||||
previous: Previous summary content
|
||||
current: Current summary content
|
||||
|
||||
Returns:
|
||||
Merged summary
|
||||
If no summary builder is provided, returns current summary only.
|
||||
"""
|
||||
# Simple merge strategy: prioritize current but preserve unique info from previous
|
||||
if not previous:
|
||||
return current
|
||||
|
||||
# Preserve context but avoid excessive growth
|
||||
MAX_PREVIOUS_LENGTH = 500
|
||||
if len(previous) > MAX_PREVIOUS_LENGTH:
|
||||
truncated_previous = previous[:MAX_PREVIOUS_LENGTH]
|
||||
return f"{current}\n\n[Previous context]: {truncated_previous}..."
|
||||
else:
|
||||
return f"{current}\n\n[Previous context]: {previous}"
|
||||
if self.summary_builder:
|
||||
return self.summary_builder.merge_summaries(previous, current)
|
||||
return current
|
||||
|
||||
def get_stats(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Get statistics about messages."""
|
||||
|
||||
@@ -50,3 +50,28 @@ class CompactionConfig:
|
||||
compaction_prefix: str = COMPACTION_PREFIX # Override for custom framing
|
||||
structured_template: bool = True # Emit Active Task / Remaining Work sections
|
||||
iterative_update: bool = True # Merge previous summary on re-compaction
|
||||
|
||||
# Anti-thrashing protection
|
||||
min_savings_pct: float = 10.0 # Skip if projected saving < 10% (0-100 scale)
|
||||
max_consecutive_low_savings: int = 2 # Abort after N low-savings attempts
|
||||
|
||||
# Tool result optimization
|
||||
tool_prune_before_summarise: bool = True # Deduplicate tool results before summarization
|
||||
max_tool_result_size: int = 500 # Maximum size for tool results before pruning
|
||||
|
||||
# Iterative summarization
|
||||
enable_iterative_summary: bool = True # Build on previous summaries instead of starting from scratch
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate and normalize configuration values."""
|
||||
# Normalize min_savings_pct to 0-100 scale if provided as ratio
|
||||
if self.min_savings_pct < 1.0:
|
||||
self.min_savings_pct *= 100.0
|
||||
|
||||
# Validate ranges
|
||||
if not 0.0 <= self.min_savings_pct <= 100.0:
|
||||
raise ValueError("min_savings_pct must be between 0 and 100")
|
||||
if self.max_consecutive_low_savings < 0:
|
||||
raise ValueError("max_consecutive_low_savings must be >= 0")
|
||||
if self.max_tool_result_size <= 0:
|
||||
raise ValueError("max_tool_result_size must be > 0")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Compaction Protocols for PraisonAI Agents.
|
||||
|
||||
Protocol-driven interfaces for context compaction functionality.
|
||||
"""
|
||||
|
||||
from typing import Protocol, List, Dict, Any, Tuple, Optional
|
||||
|
||||
|
||||
class ToolResultPrunerProtocol(Protocol):
|
||||
"""Protocol for pruning and deduplicating tool results."""
|
||||
|
||||
def prune(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
max_tool_result_size: int = 500
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
"""
|
||||
Prune and deduplicate tool results to reduce token usage.
|
||||
|
||||
Args:
|
||||
messages: List of messages to process
|
||||
max_tool_result_size: Maximum size for tool results before truncation
|
||||
|
||||
Returns:
|
||||
Tuple of (processed messages, number of tool results pruned)
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class MessageFormatterProtocol(Protocol):
|
||||
"""Protocol for formatting messages for summarization."""
|
||||
|
||||
def format_for_summary(self, messages: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
Format messages for LLM summarization.
|
||||
|
||||
Args:
|
||||
messages: Messages to format
|
||||
|
||||
Returns:
|
||||
Formatted string suitable for summarization
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class SummaryBuilderProtocol(Protocol):
|
||||
"""Protocol for building structured summaries."""
|
||||
|
||||
def build_structured_summary(self, messages: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
Build a structured summary from messages.
|
||||
|
||||
Args:
|
||||
messages: Messages to summarize
|
||||
|
||||
Returns:
|
||||
Structured summary string
|
||||
"""
|
||||
...
|
||||
|
||||
def merge_summaries(self, previous: str, current: str) -> str:
|
||||
"""
|
||||
Merge previous summary with current one for iterative updates.
|
||||
|
||||
Args:
|
||||
previous: Previous summary content
|
||||
current: Current summary content
|
||||
|
||||
Returns:
|
||||
Merged summary
|
||||
"""
|
||||
...
|
||||
@@ -18,6 +18,16 @@ class CompactionResult:
|
||||
strategy_used: CompactionStrategy
|
||||
summary: str = ""
|
||||
|
||||
# Anti-thrashing and optimization info
|
||||
savings_pct: float = field(default=0.0, init=False) # Percentage of tokens saved (computed)
|
||||
tool_results_pruned: int = 0 # Number of tool results deduplicated/pruned
|
||||
previous_summary_reused: bool = False # Whether iterative summary was used
|
||||
was_skipped_due_to_low_savings: bool = False # Whether compaction was skipped due to anti-thrashing
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize computed fields after construction."""
|
||||
self.calculate_savings_pct()
|
||||
|
||||
@property
|
||||
def tokens_saved(self) -> int:
|
||||
"""Get number of tokens saved."""
|
||||
@@ -30,6 +40,13 @@ class CompactionResult:
|
||||
return 0.0
|
||||
return self.compacted_tokens / self.original_tokens
|
||||
|
||||
def calculate_savings_pct(self) -> None:
|
||||
"""Calculate and set the savings percentage."""
|
||||
if self.original_tokens > 0:
|
||||
self.savings_pct = (self.tokens_saved / self.original_tokens) * 100.0
|
||||
else:
|
||||
self.savings_pct = 0.0
|
||||
|
||||
@property
|
||||
def was_compacted(self) -> bool:
|
||||
"""Check if compaction occurred."""
|
||||
@@ -45,5 +62,9 @@ class CompactionResult:
|
||||
"messages_kept": self.messages_kept,
|
||||
"strategy_used": self.strategy_used.value,
|
||||
"compression_ratio": self.compression_ratio,
|
||||
"savings_pct": self.savings_pct,
|
||||
"tool_results_pruned": self.tool_results_pruned,
|
||||
"previous_summary_reused": self.previous_summary_reused,
|
||||
"was_skipped_due_to_low_savings": self.was_skipped_due_to_low_savings,
|
||||
"summary": self.summary
|
||||
}
|
||||
@@ -774,29 +774,39 @@ class ExecutionConfig:
|
||||
self.context_compaction
|
||||
)
|
||||
|
||||
# Emit deprecation warning for default behavior change
|
||||
# Only warn if context_compaction was not explicitly set (i.e., using the default False)
|
||||
import warnings
|
||||
import inspect
|
||||
frame = inspect.currentframe()
|
||||
try:
|
||||
# Check if this is being called from user code (not internal)
|
||||
caller_frame = frame.f_back
|
||||
if (caller_frame and
|
||||
not any(path_part in caller_frame.f_code.co_filename
|
||||
for path_part in ['praisonaiagents', 'test_', '__pycache__'])):
|
||||
# This is being called from user code
|
||||
if self.context_compaction is False:
|
||||
warnings.warn(
|
||||
"ExecutionConfig.context_compaction will default to True in the next "
|
||||
"release for proactive context overflow protection. To disable, explicitly "
|
||||
"set context_compaction=False. To use the new default early, set "
|
||||
"context_compaction=True.",
|
||||
DeprecationWarning,
|
||||
stacklevel=3
|
||||
)
|
||||
finally:
|
||||
del frame
|
||||
# Emit deprecation warning once per process for default behavior change.
|
||||
# Walk the stack once — dataclass __init__ uses caller filename "<string>".
|
||||
if self.context_compaction is False:
|
||||
if getattr(ExecutionConfig, '_context_compaction_internal', False):
|
||||
return
|
||||
if getattr(ExecutionConfig, '_context_compaction_warned', False):
|
||||
return
|
||||
import warnings
|
||||
import inspect
|
||||
_INTERNAL = ('praisonaiagents', 'test_', '__pycache__')
|
||||
frame = inspect.currentframe()
|
||||
try:
|
||||
caller = frame.f_back if frame else None
|
||||
is_internal = False
|
||||
while caller is not None:
|
||||
if any(p in caller.f_code.co_filename for p in _INTERNAL):
|
||||
is_internal = True
|
||||
break
|
||||
caller = caller.f_back
|
||||
if is_internal:
|
||||
ExecutionConfig._context_compaction_internal = True
|
||||
return
|
||||
warnings.warn(
|
||||
"ExecutionConfig.context_compaction will default to True in the next "
|
||||
"release for proactive context overflow protection. To disable, explicitly "
|
||||
"set context_compaction=False. To use the new default early, set "
|
||||
"context_compaction=True.",
|
||||
DeprecationWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
ExecutionConfig._context_compaction_warned = True
|
||||
finally:
|
||||
del frame
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
@@ -1089,8 +1099,8 @@ class MultiAgentOutputConfig:
|
||||
# Verbosity level (0=silent, 1=minimal, 2+=verbose)
|
||||
verbose: int = 0
|
||||
|
||||
# Streaming output
|
||||
stream: bool = True
|
||||
# Streaming output - False by default for sync multi-agent compatibility
|
||||
stream: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
|
||||
@@ -83,6 +83,7 @@ class ConfigValidationError(Exception):
|
||||
# Global config cache (lazy loaded)
|
||||
_config_cache: Optional[Dict[str, Any]] = None
|
||||
_config_loaded: bool = False
|
||||
_defaults_has_values: Optional[bool] = None
|
||||
|
||||
|
||||
def _default_project_plugins_dir():
|
||||
@@ -418,11 +419,27 @@ def get_enabled_plugins() -> Optional[List[str]]:
|
||||
return None # All plugins enabled
|
||||
|
||||
|
||||
def _defaults_has_any_values() -> bool:
|
||||
"""Return True if config file defines any defaults (cached after first check)."""
|
||||
global _defaults_has_values
|
||||
if _defaults_has_values is None:
|
||||
defaults = get_defaults_config()
|
||||
_defaults_has_values = any(
|
||||
getattr(defaults, key, None) is not None
|
||||
for key in (
|
||||
"model", "base_url", "memory", "knowledge", "planning",
|
||||
"reflection", "web", "output", "execution", "caching", "autonomy",
|
||||
)
|
||||
)
|
||||
return _defaults_has_values
|
||||
|
||||
|
||||
def clear_config_cache() -> None:
|
||||
"""Clear the config cache (for testing)."""
|
||||
global _config_cache, _config_loaded
|
||||
global _config_cache, _config_loaded, _defaults_has_values
|
||||
_config_cache = None
|
||||
_config_loaded = False
|
||||
_defaults_has_values = None
|
||||
|
||||
|
||||
def validate_config(config: Dict[str, Any], raise_on_error: bool = False) -> List[str]:
|
||||
@@ -603,6 +620,8 @@ def apply_config_defaults(
|
||||
if explicit_value is not None:
|
||||
# If True and we have config defaults, merge them
|
||||
if explicit_value is True:
|
||||
if not _defaults_has_any_values():
|
||||
return explicit_value
|
||||
config_defaults = get_default(param_name)
|
||||
if config_defaults and isinstance(config_defaults, dict):
|
||||
if config_class:
|
||||
@@ -612,6 +631,10 @@ def apply_config_defaults(
|
||||
return config_defaults
|
||||
return explicit_value
|
||||
|
||||
# Fast path: no config defaults file values — skip lookups
|
||||
if not _defaults_has_any_values():
|
||||
return None
|
||||
|
||||
# Check if config has defaults for this param
|
||||
config_defaults = get_default(param_name)
|
||||
if config_defaults is None:
|
||||
|
||||
@@ -322,8 +322,8 @@ CACHING_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
# =============================================================================
|
||||
|
||||
MULTI_AGENT_OUTPUT_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
"verbose": {"verbose": 2, "stream": True},
|
||||
"minimal": {"verbose": 1, "stream": True},
|
||||
"verbose": {"verbose": 2, "stream": False},
|
||||
"minimal": {"verbose": 1, "stream": False},
|
||||
"silent": {"verbose": 0, "stream": False},
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import xml.etree.ElementTree as ET
|
||||
from ..errors import AgentErrorKind, FailoverDecision, IdleTimeoutBreaker
|
||||
# Gap 2: Tool call execution imports
|
||||
from ..tools.call_executor import ToolCall, create_tool_call_executor
|
||||
from ..tools.schema import annotation_to_json_schema, get_parameter_requirements
|
||||
# Display functions - lazy loaded to avoid importing rich at startup
|
||||
# These are only needed when output=verbose
|
||||
_display_module = None
|
||||
@@ -5886,34 +5887,74 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
function_name = function_or_name
|
||||
logging.debug(f"Attempting to generate tool definition for: {function_name}")
|
||||
|
||||
# First try to get the tool definition if it exists
|
||||
tool_def_name = f"{function_name}_definition"
|
||||
tool_def = globals().get(tool_def_name)
|
||||
logging.debug(f"Looking for {tool_def_name} in globals: {tool_def is not None}")
|
||||
|
||||
if not tool_def:
|
||||
import __main__
|
||||
tool_def = getattr(__main__, tool_def_name, None)
|
||||
logging.debug(f"Looking for {tool_def_name} in __main__: {tool_def is not None}")
|
||||
|
||||
if tool_def:
|
||||
logging.debug(f"Found tool definition: {tool_def}")
|
||||
return tool_def
|
||||
|
||||
# Try to find the function
|
||||
func = globals().get(function_name)
|
||||
logging.debug(f"Looking for {function_name} in globals: {func is not None}")
|
||||
|
||||
if not func:
|
||||
import __main__
|
||||
func = getattr(__main__, function_name, None)
|
||||
logging.debug(f"Looking for {function_name} in __main__: {func is not None}")
|
||||
|
||||
if not func or not callable(func):
|
||||
logging.debug(f"Function {function_name} not found or not callable")
|
||||
return None
|
||||
# First try to get from the tool registry (preferred method)
|
||||
try:
|
||||
from ..tools.registry import get_registry
|
||||
registry = get_registry()
|
||||
tool = registry.get(function_name)
|
||||
if tool:
|
||||
logging.debug(f"Found tool in registry: {function_name}")
|
||||
# If it's a BaseTool, get its schema
|
||||
if hasattr(tool, 'get_schema'):
|
||||
tool_def = tool.get_schema()
|
||||
# Apply same normalization as the callable path
|
||||
if (
|
||||
isinstance(tool_def, dict)
|
||||
and isinstance(tool_def.get("function"), dict)
|
||||
and isinstance(tool_def["function"].get("parameters"), dict)
|
||||
):
|
||||
tool_def = tool_def.copy()
|
||||
tool_def["function"] = tool_def["function"].copy()
|
||||
tool_def["function"]["parameters"] = self._fix_array_schemas(
|
||||
tool_def["function"]["parameters"]
|
||||
)
|
||||
return tool_def
|
||||
# If it's a callable, use it as func
|
||||
if callable(tool):
|
||||
func = tool
|
||||
else:
|
||||
logging.debug(f"Tool {function_name} in registry is not callable")
|
||||
return None
|
||||
else:
|
||||
logging.debug(f"Tool {function_name} not found in registry, falling back to globals/__main__")
|
||||
# Fall back to globals and __main__ for backward compatibility
|
||||
tool_def_name = f"{function_name}_definition"
|
||||
tool_def = globals().get(tool_def_name)
|
||||
if not tool_def:
|
||||
import __main__
|
||||
tool_def = getattr(__main__, tool_def_name, None)
|
||||
if tool_def:
|
||||
return tool_def
|
||||
|
||||
func = globals().get(function_name)
|
||||
if not func:
|
||||
import __main__
|
||||
func = getattr(__main__, function_name, None)
|
||||
if not func or not callable(func):
|
||||
logging.debug(f"Function {function_name} not found or not callable")
|
||||
return None
|
||||
except ImportError:
|
||||
logging.debug("Tool registry not available, falling back to globals/__main__")
|
||||
# Fall back to globals and __main__ when registry unavailable
|
||||
tool_def_name = f"{function_name}_definition"
|
||||
tool_def = globals().get(tool_def_name)
|
||||
if not tool_def:
|
||||
import __main__
|
||||
tool_def = getattr(__main__, tool_def_name, None)
|
||||
if tool_def:
|
||||
return tool_def
|
||||
|
||||
func = globals().get(function_name)
|
||||
if not func:
|
||||
import __main__
|
||||
func = getattr(__main__, function_name, None)
|
||||
if not func or not callable(func):
|
||||
logging.debug(f"Function {function_name} not found or not callable")
|
||||
return None
|
||||
|
||||
import inspect
|
||||
from typing import get_type_hints
|
||||
|
||||
# Handle Langchain and CrewAI tools
|
||||
if inspect.isclass(func) and hasattr(func, 'run') and not hasattr(func, '_run'):
|
||||
original_func = func
|
||||
@@ -5961,26 +6002,26 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
logging.debug(f"Parameter descriptions: {param_descriptions}")
|
||||
|
||||
# Get type hints for proper schema generation
|
||||
try:
|
||||
hints = get_type_hints(func) if getattr(func, "__annotations__", None) else {}
|
||||
except (NameError, TypeError, AttributeError):
|
||||
hints = getattr(func, "__annotations__", {}) or {}
|
||||
|
||||
for name, param in parameters_list:
|
||||
param_type = "string" # Default type
|
||||
if param.annotation != inspect.Parameter.empty:
|
||||
if param.annotation == int:
|
||||
param_type = "integer"
|
||||
elif param.annotation == float:
|
||||
param_type = "number"
|
||||
elif param.annotation == bool:
|
||||
param_type = "boolean"
|
||||
elif param.annotation == list:
|
||||
param_type = "array"
|
||||
elif param.annotation == dict:
|
||||
param_type = "object"
|
||||
# Get type annotation
|
||||
param_type = hints.get(name, param.annotation if param.annotation != inspect.Parameter.empty else str)
|
||||
|
||||
parameters["properties"][name] = {
|
||||
"type": param_type,
|
||||
"description": param_descriptions.get(name, "Parameter description not available")
|
||||
}
|
||||
# Use new schema utility for proper type handling
|
||||
prop_schema = annotation_to_json_schema(param_type)
|
||||
|
||||
if param.default == inspect.Parameter.empty:
|
||||
# Add description from docstring
|
||||
prop_schema["description"] = param_descriptions.get(name, "Parameter description not available")
|
||||
|
||||
parameters["properties"][name] = prop_schema
|
||||
|
||||
# Check if required using improved logic
|
||||
if get_parameter_requirements(sig, name):
|
||||
parameters["required"].append(name)
|
||||
|
||||
logging.debug(f"Generated parameters: {parameters}")
|
||||
|
||||
@@ -246,3 +246,74 @@ class SecurityConfig:
|
||||
def get_bind_address(self) -> str:
|
||||
"""Get recommended bind address based on config."""
|
||||
return "127.0.0.1" if self.bind_localhost_only else "0.0.0.0"
|
||||
|
||||
|
||||
def load_sse_security_from_env():
|
||||
"""Load optional SSE security settings from environment."""
|
||||
import os
|
||||
|
||||
token = os.environ.get("MCP_SSE_AUTH_TOKEN") or os.environ.get("MCP_AUTH_TOKEN")
|
||||
origins_raw = os.environ.get("MCP_SSE_ALLOWED_ORIGINS")
|
||||
if not token and not origins_raw:
|
||||
return None, None
|
||||
|
||||
origins = (
|
||||
[origin.strip() for origin in origins_raw.split(",") if origin.strip()]
|
||||
if origins_raw
|
||||
else None
|
||||
)
|
||||
config = SecurityConfig(
|
||||
require_auth=bool(token),
|
||||
validate_origin=origins is not None,
|
||||
allowed_origins=origins or SecurityConfig().allowed_origins,
|
||||
)
|
||||
return config, token
|
||||
|
||||
|
||||
def build_sse_security_app(app, security: Optional["SecurityConfig"] = None):
|
||||
"""Wrap a Starlette app with SSE security middleware when configured."""
|
||||
config = security
|
||||
token = None
|
||||
if config is None:
|
||||
config, token = load_sse_security_from_env()
|
||||
elif config.require_auth:
|
||||
import os
|
||||
|
||||
token = os.environ.get("MCP_SSE_AUTH_TOKEN") or os.environ.get("MCP_AUTH_TOKEN")
|
||||
if config is None or (not config.require_auth and not config.validate_origin):
|
||||
return app
|
||||
|
||||
class _SSESecurityMiddleware:
|
||||
def __init__(self, inner, sec: SecurityConfig, expected_token: Optional[str]):
|
||||
self._inner = inner
|
||||
self._sec = sec
|
||||
self._token = expected_token
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope.get("type") != "http":
|
||||
await self._inner(scope, receive, send)
|
||||
return
|
||||
|
||||
headers = {k.decode().lower(): v.decode() for k, v in scope.get("headers", [])}
|
||||
|
||||
if self._sec.require_auth and self._token:
|
||||
auth = headers.get("authorization", "")
|
||||
if not auth.startswith("Bearer ") or auth[7:] != self._token:
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
response = JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
|
||||
if self._sec.validate_origin:
|
||||
origin = headers.get("origin") or None
|
||||
if not self._sec.is_origin_allowed(origin):
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
response = JSONResponse({"error": "Origin not allowed"}, status_code=403)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
|
||||
await self._inner(scope, receive, send)
|
||||
|
||||
return _SSESecurityMiddleware(app, config, token)
|
||||
@@ -242,12 +242,18 @@ class ToolsMCPServer:
|
||||
|
||||
mcp.run()
|
||||
|
||||
def run_sse(self, host: str = "0.0.0.0", port: int = 8080) -> None:
|
||||
def run_sse(
|
||||
self,
|
||||
host: str = "0.0.0.0",
|
||||
port: int = 8080,
|
||||
security: "Optional[SecurityConfig]" = None,
|
||||
) -> None:
|
||||
"""Run the MCP server using SSE transport.
|
||||
|
||||
Args:
|
||||
host: Host to bind to
|
||||
port: Port to listen on
|
||||
security: Optional security config; when unset, reads MCP_SSE_* env vars
|
||||
"""
|
||||
try:
|
||||
import uvicorn
|
||||
@@ -260,6 +266,8 @@ class ToolsMCPServer:
|
||||
f"SSE dependencies not available: {e}. "
|
||||
"Install with: pip install uvicorn starlette"
|
||||
)
|
||||
|
||||
from .mcp_security import SecurityConfig, build_sse_security_app
|
||||
|
||||
mcp = self.get_fastmcp()
|
||||
|
||||
@@ -286,7 +294,8 @@ class ToolsMCPServer:
|
||||
Mount(messages_path, app=sse_transport.handle_post_message),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
app = build_sse_security_app(app, security)
|
||||
print(f"🚀 Starting MCP server '{self.name}' with SSE transport")
|
||||
print(f"📡 SSE endpoint: http://{host}:{port}{sse_path}")
|
||||
print(f"🛠️ Available tools: {', '.join(self.get_tool_names())}")
|
||||
|
||||
@@ -115,6 +115,16 @@ class FileMemory:
|
||||
"importance_threshold": 0.7, # Min importance for long-term
|
||||
"auto_promote": True, # Auto-promote important short-term to long-term
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _sanitise_user_id(user_id: str) -> str:
|
||||
"""Reject path traversal in user_id before using it as a directory name."""
|
||||
if not user_id or not isinstance(user_id, str):
|
||||
return "default"
|
||||
if ".." in user_id or "/" in user_id or "\\" in user_id:
|
||||
raise ValueError("user_id must not contain path separators or parent references")
|
||||
safe = user_id.strip()
|
||||
return safe or "default"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -132,7 +142,7 @@ class FileMemory:
|
||||
config: Configuration overrides
|
||||
verbose: Verbosity level (0=quiet, 1=info, 2+=debug)
|
||||
"""
|
||||
self.user_id = user_id
|
||||
self.user_id = self._sanitise_user_id(user_id)
|
||||
self.verbose = verbose
|
||||
|
||||
# Set up paths
|
||||
|
||||
@@ -29,6 +29,11 @@ logger = get_logger(__name__, extra_data={"subsystem": "memory"})
|
||||
TRACE_LEVEL = 5
|
||||
logging.addLevelName(TRACE_LEVEL, 'TRACE')
|
||||
|
||||
# Cache boundary constants for prompt prefix caching optimization
|
||||
# NOTE: This is a placeholder - current implementation does not provide
|
||||
# functional prompt caching as it requires structured API metadata
|
||||
CACHE_BOUNDARY = ""
|
||||
|
||||
|
||||
|
||||
class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
@@ -1704,6 +1709,41 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
entities = self.search_entity(q, limit=max_items)
|
||||
user_mem = self.search_user_memory(user_id, q, limit=max_items) if user_id else []
|
||||
|
||||
# Apply stable sorting to ensure deterministic order for prompt caching
|
||||
# Sort by timestamp descending, then by content hash for stable ordering
|
||||
def _sort_memory_results(results: List[Any]) -> List[Any]:
|
||||
"""Stable sort so identical query sets produce identical context strings."""
|
||||
import hashlib
|
||||
if not results:
|
||||
return results
|
||||
|
||||
def sort_key(r):
|
||||
if not isinstance(r, dict):
|
||||
return (0.0, hashlib.sha256(str(r).encode()).hexdigest())
|
||||
|
||||
# Prefer created_at over timestamp for better determinism
|
||||
timestamp = r.get("created_at") or r.get("timestamp") or 0
|
||||
if isinstance(timestamp, str):
|
||||
try:
|
||||
from datetime import datetime
|
||||
timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00")).timestamp()
|
||||
except ValueError:
|
||||
timestamp = 0.0
|
||||
timestamp = -(float(timestamp)) # Negative for descending order
|
||||
|
||||
# Use full content hash for true deterministic ordering
|
||||
content = r.get("text", "") if isinstance(r, dict) else str(r)
|
||||
content_hash = hashlib.sha256(content.encode()).hexdigest()
|
||||
return (timestamp, content_hash)
|
||||
|
||||
return sorted(results, key=sort_key)
|
||||
|
||||
# Apply stable sorting to all memory results
|
||||
short_term = _sort_memory_results(short_term)
|
||||
long_term = _sort_memory_results(long_term)
|
||||
entities = _sort_memory_results(entities)
|
||||
user_mem = _sort_memory_results(user_mem)
|
||||
|
||||
# Add sections in order of priority
|
||||
add_section("Short-term Memory Context", short_term)
|
||||
add_section("Long-term Memory Context", long_term)
|
||||
@@ -1713,6 +1753,51 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
|
||||
return "\n".join(lines) if lines else ""
|
||||
|
||||
def build_cache_optimized_context(
|
||||
self,
|
||||
task_descr: str,
|
||||
user_id: Optional[str] = None,
|
||||
additional: str = "",
|
||||
max_items: int = 3,
|
||||
include_cache_boundary: bool = True,
|
||||
include_in_output: Optional[bool] = None
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Build context with cache boundary markers for prompt prefix caching optimization.
|
||||
|
||||
Returns a dictionary with 'stable_prefix' and 'cache_boundary' keys.
|
||||
The stable prefix contains deterministically ordered content that should
|
||||
remain constant between turns for effective prompt caching.
|
||||
|
||||
Args:
|
||||
task_descr: Task description for memory search
|
||||
user_id: Optional user ID for personalized memory
|
||||
additional: Additional context to include in search
|
||||
max_items: Maximum items per memory category
|
||||
include_cache_boundary: Whether to include cache boundary marker
|
||||
include_in_output: Whether to include memory content in output
|
||||
|
||||
Returns:
|
||||
Dict with 'stable_prefix' and 'cache_boundary' keys
|
||||
"""
|
||||
# Preserve original behavior: include_in_output=None means debug-level-only
|
||||
# Don't change the default behavior for backward compatibility
|
||||
|
||||
# Build the stable context using existing method
|
||||
stable_context = self.build_context_for_task(
|
||||
task_descr=task_descr,
|
||||
user_id=user_id,
|
||||
additional=additional,
|
||||
max_items=max_items,
|
||||
include_in_output=include_in_output
|
||||
)
|
||||
|
||||
result = {"stable_prefix": stable_context}
|
||||
|
||||
result["cache_boundary"] = CACHE_BOUNDARY if include_cache_boundary else ""
|
||||
|
||||
return result
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Master Reset (Everything)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@@ -117,6 +117,16 @@ class AgentServer:
|
||||
self._server_thread: Optional[threading.Thread] = None
|
||||
self._app = None
|
||||
self._server = None
|
||||
|
||||
def _authorise_request(self, request) -> bool:
|
||||
"""Verify bearer token when auth_token is configured."""
|
||||
token = self.config.auth_token
|
||||
if not token:
|
||||
return True
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer ") and auth[7:] == token:
|
||||
return True
|
||||
return request.headers.get("X-Auth-Token") == token
|
||||
|
||||
def _create_app(self):
|
||||
"""Create the ASGI application."""
|
||||
@@ -137,6 +147,8 @@ class AgentServer:
|
||||
})
|
||||
|
||||
async def events(request):
|
||||
if not self._authorise_request(request):
|
||||
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
client_id = str(uuid.uuid4())
|
||||
client = SSEClient(client_id)
|
||||
self._clients[client_id] = client
|
||||
@@ -162,6 +174,8 @@ class AgentServer:
|
||||
)
|
||||
|
||||
async def publish(request):
|
||||
if not self._authorise_request(request):
|
||||
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
try:
|
||||
data = await request.json()
|
||||
event_type = data.get("type", "message")
|
||||
|
||||
@@ -14,6 +14,10 @@ import time
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
from .utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .memory.memory import Memory
|
||||
from .knowledge.knowledge import Knowledge
|
||||
@@ -504,6 +508,20 @@ class Session:
|
||||
Returns:
|
||||
Formatted context string
|
||||
"""
|
||||
# Use cache-optimized context if available for better prompt caching
|
||||
if hasattr(self.memory, 'build_cache_optimized_context'):
|
||||
try:
|
||||
cache_result = self.memory.build_cache_optimized_context(
|
||||
task_descr=query,
|
||||
user_id=self.user_id,
|
||||
max_items=max_items,
|
||||
include_cache_boundary=False # Don't include boundary for session context
|
||||
)
|
||||
return cache_result.get('stable_prefix', '')
|
||||
except Exception as e:
|
||||
# Fall back to standard context building
|
||||
logger.debug(f"Cache-optimized context failed for session '{self.id or self.user_id}', falling back to standard: {e}")
|
||||
|
||||
return self.memory.build_context_for_task(
|
||||
task_descr=query,
|
||||
user_id=self.user_id,
|
||||
|
||||
@@ -14,6 +14,8 @@ import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..memory.memory import Memory
|
||||
from ..knowledge.knowledge import Knowledge
|
||||
@@ -477,6 +479,20 @@ class Session:
|
||||
Returns:
|
||||
Formatted context string
|
||||
"""
|
||||
# Use cache-optimized context if available for better prompt caching
|
||||
if hasattr(self.memory, 'build_cache_optimized_context'):
|
||||
try:
|
||||
cache_result = self.memory.build_cache_optimized_context(
|
||||
task_descr=query,
|
||||
user_id=self.user_id,
|
||||
max_items=max_items,
|
||||
include_cache_boundary=False # Don't include boundary for session context
|
||||
)
|
||||
return cache_result.get('stable_prefix', '')
|
||||
except Exception as e:
|
||||
# Fall back to standard context building
|
||||
logger.debug(f"Cache-optimized context failed for session '{self.session_id}', falling back to standard: {e}")
|
||||
|
||||
return self.memory.build_context_for_task(
|
||||
task_descr=query,
|
||||
user_id=self.user_id,
|
||||
|
||||
@@ -740,10 +740,27 @@ class Task:
|
||||
# Build context for next tasks
|
||||
if self.next_tasks:
|
||||
logger.info(f"Task {self.id}: Building context for next tasks...")
|
||||
context = self.memory.build_context_for_task(
|
||||
task_descr=task_output.raw,
|
||||
max_items=5
|
||||
)
|
||||
# Use cache-optimized context if available for better prompt caching
|
||||
if hasattr(self.memory, 'build_cache_optimized_context'):
|
||||
try:
|
||||
cache_result = self.memory.build_cache_optimized_context(
|
||||
task_descr=task_output.raw,
|
||||
max_items=5,
|
||||
include_cache_boundary=False # Don't include boundary for task context
|
||||
)
|
||||
context = cache_result.get('stable_prefix', '')
|
||||
except Exception as e:
|
||||
# Fall back to standard context building
|
||||
logger.debug(f"Cache-optimized context failed for task '{self.id}', falling back to standard: {e}")
|
||||
context = self.memory.build_context_for_task(
|
||||
task_descr=task_output.raw,
|
||||
max_items=5
|
||||
)
|
||||
else:
|
||||
context = self.memory.build_context_for_task(
|
||||
task_descr=task_output.raw,
|
||||
max_items=5
|
||||
)
|
||||
logger.info(f"Task {self.id}: Built context for next tasks: {len(context)} items")
|
||||
|
||||
logger.info(f"Task {self.id}: Memory operations complete")
|
||||
|
||||
@@ -31,6 +31,8 @@ import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Optional, List
|
||||
|
||||
from ..approval import require_approval
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Availability cache for performance (checked once per process)
|
||||
@@ -146,6 +148,7 @@ def ast_grep_search(
|
||||
logger.exception("Unexpected error in ast_grep_search")
|
||||
return f"Error: {e}"
|
||||
|
||||
@require_approval(risk_level="high")
|
||||
def ast_grep_rewrite(
|
||||
pattern: str,
|
||||
replacement: str,
|
||||
|
||||
@@ -17,9 +17,12 @@ Usage:
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Callable, Dict, List, Optional, Type, get_type_hints
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import copy
|
||||
|
||||
from .schema import annotation_to_json_schema, get_parameter_requirements
|
||||
|
||||
|
||||
class ToolValidationError(Exception):
|
||||
"""Raised when a tool fails validation."""
|
||||
@@ -129,18 +132,17 @@ class BaseTool(ABC):
|
||||
|
||||
# Get type hint
|
||||
param_type = hints.get(param_name, Any)
|
||||
json_type = self._python_type_to_json(param_type)
|
||||
|
||||
# Build property schema
|
||||
prop_schema = {"type": json_type}
|
||||
# Use new schema utility for proper type handling
|
||||
prop_schema = annotation_to_json_schema(param_type)
|
||||
|
||||
# Add description from docstring if available
|
||||
# (Could parse docstring for param descriptions)
|
||||
|
||||
schema["properties"][param_name] = prop_schema
|
||||
|
||||
# Check if required (no default value)
|
||||
if param.default is inspect.Parameter.empty:
|
||||
# Check if required using improved logic
|
||||
if get_parameter_requirements(sig, param_name):
|
||||
schema["required"].append(param_name)
|
||||
except Exception as e:
|
||||
logging.debug(f"Could not generate schema for {self.name}: {e}")
|
||||
@@ -149,28 +151,15 @@ class BaseTool(ABC):
|
||||
|
||||
@staticmethod
|
||||
def _python_type_to_json(python_type: Type) -> str:
|
||||
"""Convert Python type to JSON Schema type."""
|
||||
type_map = {
|
||||
str: "string",
|
||||
int: "integer",
|
||||
float: "number",
|
||||
bool: "boolean",
|
||||
list: "array",
|
||||
dict: "object",
|
||||
type(None): "null"
|
||||
}
|
||||
"""Convert Python type to JSON Schema type.
|
||||
|
||||
# Handle Optional, Union, etc.
|
||||
origin = getattr(python_type, '__origin__', None)
|
||||
if origin is not None:
|
||||
# For List[X], return "array"
|
||||
if origin is list:
|
||||
return "array"
|
||||
# For Dict[X, Y], return "object"
|
||||
if origin is dict:
|
||||
return "object"
|
||||
|
||||
return type_map.get(python_type, "string")
|
||||
DEPRECATED: Use annotation_to_json_schema() from schema.py instead.
|
||||
This method is kept for backward compatibility but will be removed.
|
||||
"""
|
||||
# Legacy fallback - delegate to new schema utility and extract type
|
||||
from .schema import annotation_to_json_schema
|
||||
schema = annotation_to_json_schema(python_type)
|
||||
return schema.get("type", "string")
|
||||
|
||||
@abstractmethod
|
||||
def run(self, **kwargs) -> Any:
|
||||
@@ -442,5 +431,49 @@ def validate_tool_schema_consistency(tools: List[Any]) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def get_sorted_tool_schemas(tools: List[Any]) -> List[Dict[str, Any]]:
|
||||
"""Get tool schemas sorted by function name for deterministic ordering.
|
||||
|
||||
This ensures tool schemas are always ordered consistently for prompt caching optimization.
|
||||
|
||||
Args:
|
||||
tools: List of tool objects (BaseTool instances, callables, etc.)
|
||||
|
||||
Returns:
|
||||
List of tool schemas sorted alphabetically by function name
|
||||
|
||||
Raises:
|
||||
ToolValidationError: If validation fails
|
||||
"""
|
||||
if not tools:
|
||||
return []
|
||||
|
||||
# First validate all tools (reuse existing validation logic)
|
||||
validate_tool_schema_consistency(tools)
|
||||
|
||||
from .decorator import get_tool_schema
|
||||
schemas = []
|
||||
|
||||
for tool in tools:
|
||||
# Get schema from different tool types
|
||||
if isinstance(tool, BaseTool):
|
||||
schema = tool.get_schema()
|
||||
elif hasattr(tool, 'get_schema') and callable(getattr(tool, 'get_schema')):
|
||||
schema = tool.get_schema()
|
||||
elif callable(tool):
|
||||
schema = get_tool_schema(tool)
|
||||
else:
|
||||
continue # Skip invalid tools (validation already happened)
|
||||
|
||||
if schema:
|
||||
schemas.append(schema)
|
||||
|
||||
# Sort schemas by function name for deterministic ordering
|
||||
def sort_key(schema):
|
||||
return schema.get("function", {}).get("name", "")
|
||||
|
||||
return sorted(schemas, key=sort_key)
|
||||
|
||||
|
||||
# For backward compatibility - tools can also just be functions
|
||||
# The @tool decorator (in decorator.py) wraps functions into BaseTool instances
|
||||
@@ -31,6 +31,7 @@ import copy
|
||||
from typing import Any, Callable, Dict, Optional, Union, get_type_hints
|
||||
|
||||
from .base import BaseTool
|
||||
from .schema import annotation_to_json_schema, get_parameter_requirements
|
||||
|
||||
# Lazy load injected module functions to reduce import time
|
||||
_injected_module = None
|
||||
@@ -132,12 +133,13 @@ class FunctionTool(BaseTool):
|
||||
if is_injected_type(param_type):
|
||||
continue
|
||||
|
||||
json_type = BaseTool._python_type_to_json(param_type)
|
||||
# Use new schema utility for proper type handling
|
||||
prop_schema = annotation_to_json_schema(param_type)
|
||||
|
||||
schema["properties"][param_name] = {"type": json_type}
|
||||
schema["properties"][param_name] = prop_schema
|
||||
|
||||
# Check if required (no default value)
|
||||
if param.default is inspect.Parameter.empty:
|
||||
# Check if required using improved logic
|
||||
if get_parameter_requirements(sig, param_name):
|
||||
schema["required"].append(param_name)
|
||||
except Exception as e:
|
||||
logging.debug(f"Could not generate schema for {func.__name__}: {e}")
|
||||
@@ -342,11 +344,14 @@ def _schema_from_function(func: Callable) -> Dict[str, Any]:
|
||||
continue
|
||||
|
||||
param_type = hints.get(param_name, Any)
|
||||
json_type = BaseTool._python_type_to_json(param_type)
|
||||
|
||||
properties[param_name] = {"type": json_type}
|
||||
# Use new schema utility for proper type handling
|
||||
prop_schema = annotation_to_json_schema(param_type)
|
||||
|
||||
if param.default is inspect.Parameter.empty:
|
||||
properties[param_name] = prop_schema
|
||||
|
||||
# Check if required using improved logic
|
||||
if get_parameter_requirements(sig, param_name):
|
||||
required.append(param_name)
|
||||
except Exception as e:
|
||||
logging.debug(f"Could not generate schema for {name}: {e}")
|
||||
|
||||
@@ -532,6 +532,14 @@ def _smtp_reply_email(message_id: str, body: str) -> str:
|
||||
logger.error(f"Failed to reply to {message_id}: {e}")
|
||||
return f"Failed to reply: {e}"
|
||||
|
||||
def _imap_sanitize(value: str) -> str:
|
||||
"""Strip characters that break IMAP quoted-string search criteria."""
|
||||
cleaned = value.replace('"', "").replace("\\", "")
|
||||
if any(c in cleaned for c in "\r\n\x00"):
|
||||
raise ValueError("Invalid IMAP search value")
|
||||
return cleaned
|
||||
|
||||
|
||||
def _smtp_search_emails(
|
||||
query: Optional[str] = None,
|
||||
from_addr: Optional[str] = None,
|
||||
@@ -544,11 +552,11 @@ def _smtp_search_emails(
|
||||
# Build IMAP search criteria
|
||||
criteria = []
|
||||
if from_addr:
|
||||
criteria.append(f'FROM "{from_addr}"')
|
||||
criteria.append(f'FROM "{_imap_sanitize(from_addr)}"')
|
||||
if subject:
|
||||
criteria.append(f'SUBJECT "{subject}"')
|
||||
criteria.append(f'SUBJECT "{_imap_sanitize(subject)}"')
|
||||
if query:
|
||||
criteria.append(f'TEXT "{query}"')
|
||||
criteria.append(f'TEXT "{_imap_sanitize(query)}"')
|
||||
if not criteria:
|
||||
criteria.append("ALL")
|
||||
search_str = " ".join(criteria)
|
||||
|
||||
@@ -165,12 +165,17 @@ class MentionsParser:
|
||||
def _process_file_mention(self, file_path: str) -> Optional[str]:
|
||||
"""Process @file:path mention."""
|
||||
try:
|
||||
# Resolve path relative to workspace
|
||||
full_path = self.workspace_path / file_path
|
||||
if not full_path.exists():
|
||||
# Try as absolute path
|
||||
full_path = Path(file_path)
|
||||
|
||||
if ".." in file_path:
|
||||
self._log(f"Rejected path traversal in mention: {file_path}", logging.WARNING)
|
||||
return f"# File: {file_path}\n[Path not allowed]"
|
||||
|
||||
# Resolve only within workspace (no arbitrary absolute paths)
|
||||
full_path = (self.workspace_path / file_path).resolve()
|
||||
workspace_root = self.workspace_path.resolve()
|
||||
if not str(full_path).startswith(str(workspace_root) + os.sep) and full_path != workspace_root:
|
||||
self._log(f"File outside workspace: {file_path}", logging.WARNING)
|
||||
return f"# File: {file_path}\n[Path outside workspace]"
|
||||
|
||||
if not full_path.exists():
|
||||
self._log(f"File not found: {file_path}", logging.WARNING)
|
||||
return f"# File: {file_path}\n[File not found]"
|
||||
|
||||
@@ -57,6 +57,7 @@ _SANDBOX_BLOCKED_CALLS = frozenset({
|
||||
'exec', 'eval', 'compile', '__import__',
|
||||
'open', 'input', 'breakpoint',
|
||||
'setattr', 'delattr', 'dir', 'vars',
|
||||
'format', 'format_map',
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""JSON Schema generation utilities for tool parameters.
|
||||
|
||||
This module provides a shared utility for converting Python type annotations
|
||||
to JSON Schema format. It handles complex types like Optional, Union, Literal,
|
||||
and Enum that were previously collapsed to simple "string" types.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.tools.schema import annotation_to_json_schema
|
||||
|
||||
schema = annotation_to_json_schema(Optional[int])
|
||||
# {"anyOf": [{"type": "integer"}, {"type": "null"}]}
|
||||
|
||||
schema = annotation_to_json_schema(Literal["fast", "deep"])
|
||||
# {"type": "string", "enum": ["fast", "deep"]}
|
||||
"""
|
||||
|
||||
import enum
|
||||
import inspect
|
||||
from typing import Any, Union, Literal, get_origin, get_args
|
||||
|
||||
|
||||
def annotation_to_json_schema(annotation: Any) -> dict:
|
||||
"""Convert Python type annotation to JSON Schema format.
|
||||
|
||||
Handles:
|
||||
- Basic types (str, int, float, bool)
|
||||
- Optional[T] -> {"anyOf": [T schema, {"type": "null"}]}
|
||||
- Union[T1, T2] -> {"anyOf": [T1 schema, T2 schema]}
|
||||
- Literal["a", "b"] -> {"type": "string", "enum": ["a", "b"]}
|
||||
- Enum subclasses -> {"type": "string", "enum": [e.value for e in MyEnum]}
|
||||
- List[T] -> {"type": "array", "items": T schema}
|
||||
- Dict[K, V] -> {"type": "object", "additionalProperties": V schema}
|
||||
|
||||
Args:
|
||||
annotation: Python type annotation to convert
|
||||
|
||||
Returns:
|
||||
dict: JSON Schema representation
|
||||
"""
|
||||
# Handle None type
|
||||
if annotation is type(None):
|
||||
return {"type": "null"}
|
||||
|
||||
# Get typing origin and args
|
||||
origin = get_origin(annotation)
|
||||
args = get_args(annotation)
|
||||
|
||||
# Handle Union types (including Optional[T] which is Union[T, None])
|
||||
if origin is Union:
|
||||
schemas = [annotation_to_json_schema(arg) for arg in args]
|
||||
|
||||
# Check if this is Optional[T] (Union[T, None])
|
||||
null_schemas = [s for s in schemas if s == {"type": "null"}]
|
||||
non_null_schemas = [s for s in schemas if s != {"type": "null"}]
|
||||
|
||||
if null_schemas and len(non_null_schemas) == 1:
|
||||
# This is Optional[T] - simplified format
|
||||
return {"anyOf": [non_null_schemas[0], {"type": "null"}]}
|
||||
else:
|
||||
# General Union type
|
||||
return {"anyOf": schemas}
|
||||
|
||||
# Handle Literal types
|
||||
if origin is Literal or (hasattr(annotation, '__origin__') and
|
||||
str(annotation.__origin__).endswith('Literal')):
|
||||
# Infer type from literal values if all are the same type
|
||||
values = list(args)
|
||||
if values:
|
||||
types_seen = set(type(v) for v in values)
|
||||
if len(types_seen) == 1:
|
||||
single_type = next(iter(types_seen))
|
||||
type_map = {str: "string", int: "integer", float: "number", bool: "boolean"}
|
||||
if single_type in type_map:
|
||||
return {"type": type_map[single_type], "enum": values}
|
||||
# Mixed types or non-primitive - use enum without type constraint
|
||||
return {"enum": values}
|
||||
|
||||
# Handle Enum subclasses
|
||||
if isinstance(annotation, type) and issubclass(annotation, enum.Enum):
|
||||
# Infer type from enum values if all are the same type
|
||||
values = [e.value for e in annotation]
|
||||
if values:
|
||||
types_seen = set(type(v) for v in values)
|
||||
if len(types_seen) == 1:
|
||||
single_type = next(iter(types_seen))
|
||||
type_map = {str: "string", int: "integer", float: "number", bool: "boolean"}
|
||||
if single_type in type_map:
|
||||
return {"type": type_map[single_type], "enum": values}
|
||||
# Mixed types or non-primitive - use enum without type constraint
|
||||
return {"enum": values}
|
||||
|
||||
# Handle List types
|
||||
if origin is list:
|
||||
if args:
|
||||
items_schema = annotation_to_json_schema(args[0])
|
||||
return {"type": "array", "items": items_schema}
|
||||
else:
|
||||
return {"type": "array"}
|
||||
|
||||
# Handle Dict types
|
||||
if origin is dict:
|
||||
if len(args) >= 2:
|
||||
value_schema = annotation_to_json_schema(args[1])
|
||||
return {"type": "object", "additionalProperties": value_schema}
|
||||
else:
|
||||
return {"type": "object"}
|
||||
|
||||
# Handle basic types
|
||||
type_map = {
|
||||
str: "string",
|
||||
int: "integer",
|
||||
float: "number",
|
||||
bool: "boolean",
|
||||
list: "array",
|
||||
dict: "object",
|
||||
type(None): "null"
|
||||
}
|
||||
|
||||
return {"type": type_map.get(annotation, "string")}
|
||||
|
||||
|
||||
def get_parameter_requirements(sig: inspect.Signature, param_name: str) -> bool:
|
||||
"""Determine if a parameter is required based on its signature.
|
||||
|
||||
A parameter is required if it has no default value, unless it's Optional.
|
||||
|
||||
Args:
|
||||
sig: Function signature
|
||||
param_name: Parameter name to check
|
||||
|
||||
Returns:
|
||||
bool: True if parameter is required
|
||||
"""
|
||||
param = sig.parameters.get(param_name)
|
||||
if not param:
|
||||
return False
|
||||
|
||||
# Parameter with default is not required
|
||||
if param.default is not inspect.Parameter.empty:
|
||||
return False
|
||||
|
||||
# Check if the type annotation indicates optional
|
||||
if param.annotation is not inspect.Parameter.empty:
|
||||
origin = get_origin(param.annotation)
|
||||
if origin is Union:
|
||||
args = get_args(param.annotation)
|
||||
# Optional[T] is Union[T, None]
|
||||
if type(None) in args:
|
||||
return False
|
||||
|
||||
# No default and not optional = required
|
||||
return True
|
||||
@@ -37,9 +37,14 @@ def searxng_search(
|
||||
|
||||
try:
|
||||
import requests
|
||||
from praisonaiagents.tools.url_safety import validate_searxng_url
|
||||
|
||||
# Default URL for local SearxNG instance
|
||||
url = searxng_url or "http://localhost:32768/search"
|
||||
if validate_searxng_url(url) is None:
|
||||
error_msg = "SearxNG URL blocked by SSRF policy"
|
||||
logging.error(error_msg)
|
||||
return [{"error": error_msg}]
|
||||
|
||||
params = {
|
||||
'q': query,
|
||||
|
||||
@@ -58,9 +58,11 @@ def _host_is_blocked(hostname: str) -> bool:
|
||||
pass
|
||||
|
||||
try:
|
||||
return _ip_blocked(ipaddress.ip_address(socket.inet_aton(host)))
|
||||
except OSError:
|
||||
pass
|
||||
for info in socket.getaddrinfo(host, None):
|
||||
if _ip_blocked(ipaddress.ip_address(info[4][0])):
|
||||
return True
|
||||
except socket.gaierror:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -169,12 +171,31 @@ class SpiderTools:
|
||||
return {"error": error_msg}
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# Make request
|
||||
response = session.get(
|
||||
url,
|
||||
timeout=timeout,
|
||||
verify=verify_ssl
|
||||
)
|
||||
# Make request with manual redirect validation (SSRF-safe)
|
||||
current_url = url
|
||||
redirect_statuses = {301, 302, 303, 307, 308}
|
||||
max_redirects = 5
|
||||
response = None
|
||||
for _ in range(max_redirects + 1):
|
||||
if not self._validate_url(current_url):
|
||||
return {"error": f"Invalid or blocked redirect target: {current_url}"}
|
||||
response = session.get(
|
||||
current_url,
|
||||
timeout=timeout,
|
||||
verify=verify_ssl,
|
||||
allow_redirects=False,
|
||||
)
|
||||
if response.status_code not in redirect_statuses:
|
||||
break
|
||||
location = response.headers.get("Location")
|
||||
if not location:
|
||||
break
|
||||
current_url = urljoin(current_url, location)
|
||||
else:
|
||||
return {"error": "Too many redirects"}
|
||||
|
||||
if response is None:
|
||||
return {"error": "Request failed"}
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse HTML
|
||||
@@ -186,7 +207,7 @@ class SpiderTools:
|
||||
|
||||
# Initialize result
|
||||
result = {
|
||||
'url': url,
|
||||
'url': current_url,
|
||||
'status_code': response.status_code,
|
||||
'encoding': response.encoding,
|
||||
'headers': dict(response.headers),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Shared URL safety checks for tool HTTP requests (SSRF mitigation)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import socket
|
||||
import urllib.parse
|
||||
from typing import Optional, Set
|
||||
|
||||
|
||||
def _allowlist_hosts() -> Set[str]:
|
||||
raw = os.environ.get("SEARXNG_URL_ALLOWLIST", "")
|
||||
hosts = {h.strip().lower() for h in raw.split(",") if h.strip()}
|
||||
hosts.update({"localhost", "127.0.0.1", "::1"})
|
||||
return hosts
|
||||
|
||||
|
||||
def is_safe_http_url(url: str, *, allow_local: Optional[bool] = None) -> bool:
|
||||
"""Return True when *url* is safe for server-side HTTP requests."""
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return False
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
return False
|
||||
if hostname.lower() in _allowlist_hosts():
|
||||
return True
|
||||
if allow_local is None:
|
||||
allow_local = os.environ.get("ALLOW_LOCAL_CRAWL") == "true"
|
||||
if allow_local:
|
||||
return True
|
||||
for info in socket.getaddrinfo(hostname, None):
|
||||
ip = ipaddress.ip_address(info[4][0])
|
||||
if (
|
||||
ip.is_loopback
|
||||
or ip.is_private
|
||||
or ip.is_link_local
|
||||
or ip.is_multicast
|
||||
or ip.is_unspecified
|
||||
):
|
||||
return False
|
||||
return True
|
||||
except (socket.gaierror, ValueError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def validate_searxng_url(url: str) -> Optional[str]:
|
||||
"""Return normalised URL or None if blocked."""
|
||||
if not url:
|
||||
return None
|
||||
if not is_safe_http_url(url):
|
||||
return None
|
||||
return url
|
||||
@@ -130,17 +130,64 @@ def _crawl_with_crawl4ai(urls: List[str]) -> List[Dict[str, Any]]:
|
||||
except RuntimeError:
|
||||
return asyncio.run(_crawl_async())
|
||||
|
||||
def _is_safe_crawl_url(url: str) -> bool:
|
||||
"""Return True when a URL passes SSRF checks for crawling."""
|
||||
import urllib.parse
|
||||
import socket
|
||||
import ipaddress
|
||||
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return False
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
return False
|
||||
if os.environ.get("ALLOW_LOCAL_CRAWL") == "true":
|
||||
return True
|
||||
for info in socket.getaddrinfo(hostname, None):
|
||||
ip = ipaddress.ip_address(info[4][0])
|
||||
if (
|
||||
ip.is_loopback
|
||||
or ip.is_private
|
||||
or ip.is_link_local
|
||||
or ip.is_multicast
|
||||
or ip.is_unspecified
|
||||
):
|
||||
return False
|
||||
return True
|
||||
except (socket.gaierror, ValueError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def _crawl_with_httpx(urls: List[str]) -> List[Dict[str, Any]]:
|
||||
"""Crawl URLs using basic HTTP fetch with httpx or urllib."""
|
||||
import urllib.parse
|
||||
results = []
|
||||
|
||||
redirect_statuses = {301, 302, 303, 307, 308}
|
||||
max_redirects = 5
|
||||
|
||||
for url in urls:
|
||||
try:
|
||||
# Try httpx first
|
||||
try:
|
||||
import httpx
|
||||
with httpx.Client(follow_redirects=True, timeout=30.0) as client:
|
||||
response = client.get(url)
|
||||
current = url
|
||||
response = None
|
||||
with httpx.Client(follow_redirects=False, timeout=30.0) as client:
|
||||
for _ in range(max_redirects + 1):
|
||||
if not _is_safe_crawl_url(current):
|
||||
raise ValueError("Redirect target failed SSRF validation")
|
||||
response = client.get(current)
|
||||
if response.status_code not in redirect_statuses:
|
||||
break
|
||||
location = response.headers.get("Location")
|
||||
if not location:
|
||||
break
|
||||
current = urllib.parse.urljoin(current, location)
|
||||
else:
|
||||
raise ValueError("Too many redirects")
|
||||
if response is not None:
|
||||
response.raise_for_status()
|
||||
content = response.text
|
||||
except ImportError:
|
||||
@@ -210,37 +257,12 @@ def web_crawl(
|
||||
raw_url_list = [urls] if single_url else urls
|
||||
|
||||
# Validate URLs to prevent SSRF and Local File Read
|
||||
import urllib.parse
|
||||
import socket
|
||||
import ipaddress
|
||||
|
||||
url_list = []
|
||||
for u in raw_url_list:
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(u)
|
||||
if parsed.scheme not in ('http', 'https'):
|
||||
logger.warning(f"Rejected non-http/https URL: {u}")
|
||||
continue
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
continue
|
||||
|
||||
# Allow opting out of SSRF protection for advanced use cases
|
||||
if os.environ.get("ALLOW_LOCAL_CRAWL") != "true":
|
||||
try:
|
||||
ip_str = socket.gethostbyname(hostname)
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
if ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_multicast or ip.is_unspecified:
|
||||
logger.warning(f"Rejected SSRF or private IP attempt: {u}")
|
||||
continue
|
||||
except socket.gaierror:
|
||||
logger.warning(f"Could not resolve hostname for: {u}")
|
||||
continue
|
||||
|
||||
if _is_safe_crawl_url(u):
|
||||
url_list.append(u)
|
||||
except Exception as e:
|
||||
logger.warning(f"URL validation failed for {u}: {e}")
|
||||
continue
|
||||
else:
|
||||
logger.warning(f"Rejected unsafe crawl URL: {u}")
|
||||
|
||||
if not url_list:
|
||||
return {"error": "No valid or safe URLs provided. Local and non-http(s) URLs are blocked for security."}
|
||||
|
||||
@@ -235,8 +235,11 @@ def _search_duckduckgo(query: str, max_results: int = 5) -> List[Dict[str, Any]]
|
||||
def _search_searxng(query: str, max_results: int = 5, searxng_url: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Search using SearxNG."""
|
||||
import requests
|
||||
from praisonaiagents.tools.url_safety import validate_searxng_url
|
||||
|
||||
url = searxng_url or os.environ.get("SEARXNG_URL", "http://localhost:32768/search")
|
||||
if validate_searxng_url(url) is None:
|
||||
raise ValueError("SearxNG URL blocked by SSRF policy")
|
||||
|
||||
params = {
|
||||
'q': query,
|
||||
|
||||
@@ -12,6 +12,9 @@ import inspect
|
||||
|
||||
__all__ = ['deprecated', 'DeprecationConfig', 'check_expired_deprecations']
|
||||
|
||||
# Warn once per process for param deprecations (avoids hot-path overhead in Agent.__init__)
|
||||
_warned_params: set[str] = set()
|
||||
|
||||
class DeprecationConfig:
|
||||
"""Configuration for deprecation behavior and version management."""
|
||||
|
||||
@@ -182,6 +185,10 @@ def warn_deprecated_param(
|
||||
|
||||
message = "".join(msg_parts)
|
||||
|
||||
warn_key = f"{param_name}:{since}"
|
||||
if warn_key in _warned_params:
|
||||
return
|
||||
_warned_params.add(warn_key)
|
||||
warnings.warn(message, category=DeprecationWarning, stacklevel=stacklevel)
|
||||
|
||||
|
||||
|
||||
@@ -1020,10 +1020,11 @@ class AgentFlow:
|
||||
# Update workflow status
|
||||
self.status = "running"
|
||||
|
||||
# Set YAML-approved tools in approval context (for auto-approval of dangerous tools)
|
||||
# Set YAML-approved tools only when caller allows dangerous tools
|
||||
_approval_token = None
|
||||
approve_tools = getattr(self, 'approve_tools', [])
|
||||
if approve_tools:
|
||||
allow_dangerous = getattr(self, 'allow_dangerous_tools', False)
|
||||
if approve_tools and allow_dangerous:
|
||||
from ..approval import set_yaml_approved_tools
|
||||
_approval_token = set_yaml_approved_tools(approve_tools)
|
||||
|
||||
@@ -2952,22 +2953,36 @@ CONCISE SUMMARY:"""
|
||||
"variables": all_variables
|
||||
}
|
||||
|
||||
# Load tools from the recipe's tools.py if present
|
||||
# Load tools from the recipe's tools.py if present (opt-in only)
|
||||
tool_registry = {}
|
||||
tools_py = recipe_path / "tools.py"
|
||||
if tools_py.exists():
|
||||
try:
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("recipe_tools", tools_py)
|
||||
recipe_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(recipe_module)
|
||||
|
||||
# Extract callable tools
|
||||
for name in dir(recipe_module):
|
||||
if not name.startswith("_"):
|
||||
obj = getattr(recipe_module, name)
|
||||
if callable(obj):
|
||||
tool_registry[name] = obj
|
||||
import os
|
||||
_local_ok = os.environ.get("PRAISONAI_ALLOW_LOCAL_TOOLS", "").strip().lower() in (
|
||||
"1", "true", "yes", "on"
|
||||
)
|
||||
_template_ok = os.environ.get("PRAISONAI_ALLOW_TEMPLATE_TOOLS", "").strip().lower() in (
|
||||
"1", "true", "yes", "on"
|
||||
)
|
||||
if _local_ok or _template_ok:
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("recipe_tools", tools_py)
|
||||
recipe_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(recipe_module)
|
||||
else:
|
||||
logger.warning(
|
||||
"Skipping included recipe tools.py: set PRAISONAI_ALLOW_LOCAL_TOOLS "
|
||||
"or PRAISONAI_ALLOW_TEMPLATE_TOOLS to enable."
|
||||
)
|
||||
recipe_module = None
|
||||
if recipe_module is not None:
|
||||
# Extract callable tools
|
||||
for name in dir(recipe_module):
|
||||
if not name.startswith("_"):
|
||||
obj = getattr(recipe_module, name)
|
||||
if callable(obj):
|
||||
tool_registry[name] = obj
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load tools from {tools_py}: {e}")
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.55"
|
||||
version = "1.6.62"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -5,8 +5,7 @@ testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
addopts = -v --tb=short --timeout=60
|
||||
timeout_method = thread
|
||||
addopts = -v --tb=short
|
||||
filterwarnings =
|
||||
ignore::DeprecationWarning
|
||||
ignore::PendingDeprecationWarning
|
||||
|
||||
@@ -18,6 +18,7 @@ class TestExecutionConfigMaxIter:
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.test_llm_config = {
|
||||
"model": "gpt-4o-mini",
|
||||
"api_base": "http://test",
|
||||
"api_key": "test-key",
|
||||
"provider": "custom"
|
||||
@@ -33,21 +34,13 @@ class TestExecutionConfigMaxIter:
|
||||
config = ExecutionConfig(max_iter=15)
|
||||
assert config.max_iter == 15
|
||||
|
||||
@patch('praisonaiagents.llm.llm.LLM._chat_completion')
|
||||
def test_agent_with_execution_config_max_iter(self, mock_chat):
|
||||
def test_agent_with_execution_config_max_iter(self):
|
||||
"""Test that Agent passes ExecutionConfig.max_iter to LLM."""
|
||||
# Mock the LLM response to avoid actual API calls
|
||||
mock_response = Mock()
|
||||
mock_response.choices = [Mock()]
|
||||
mock_response.choices[0].message.content = "Hello, I'm a test response!"
|
||||
mock_response.choices[0].message.tool_calls = None
|
||||
mock_chat.return_value = mock_response
|
||||
|
||||
# Create agent with custom max_iter
|
||||
execution_config = ExecutionConfig(max_iter=15)
|
||||
|
||||
with patch('praisonaiagents.agent.agent.LLM') as MockLLM:
|
||||
with patch('praisonaiagents.llm.llm.LLM') as MockLLM:
|
||||
mock_llm_instance = Mock()
|
||||
mock_llm_instance.get_response.return_value = "Hello, I'm a test response!"
|
||||
MockLLM.return_value = mock_llm_instance
|
||||
|
||||
agent = Agent(
|
||||
@@ -57,6 +50,9 @@ class TestExecutionConfigMaxIter:
|
||||
execution=execution_config
|
||||
)
|
||||
|
||||
# LLM is deferred until first access
|
||||
_ = agent.llm_instance
|
||||
|
||||
# Verify LLM was instantiated with max_iter from ExecutionConfig
|
||||
MockLLM.assert_called()
|
||||
call_args = MockLLM.call_args
|
||||
@@ -72,7 +68,7 @@ class TestExecutionConfigMaxIter:
|
||||
def test_llm_constructor_accepts_max_iter(self):
|
||||
"""Test that LLM constructor accepts max_iter parameter."""
|
||||
llm = LLM(
|
||||
api_base="http://test",
|
||||
model="gpt-4o-mini",
|
||||
api_key="test-key",
|
||||
max_iter=25
|
||||
)
|
||||
@@ -82,7 +78,7 @@ class TestExecutionConfigMaxIter:
|
||||
def test_llm_constructor_default_max_iter(self):
|
||||
"""Test LLM constructor default max_iter value."""
|
||||
llm = LLM(
|
||||
api_base="http://test",
|
||||
model="gpt-4o-mini",
|
||||
api_key="test-key"
|
||||
)
|
||||
|
||||
@@ -90,7 +86,7 @@ class TestExecutionConfigMaxIter:
|
||||
assert hasattr(llm, 'max_iter')
|
||||
assert llm.max_iter == 20 # Default aligned with ExecutionConfig
|
||||
|
||||
@patch('praisonaiagents.agent.agent.LLM')
|
||||
@patch('praisonaiagents.llm.llm.LLM')
|
||||
def test_agent_without_execution_config_uses_llm_default(self, MockLLM):
|
||||
"""Test that Agent without ExecutionConfig doesn't override LLM max_iter."""
|
||||
mock_llm_instance = Mock()
|
||||
@@ -102,6 +98,8 @@ class TestExecutionConfigMaxIter:
|
||||
llm=self.test_llm_config
|
||||
)
|
||||
|
||||
_ = agent.llm_instance
|
||||
|
||||
# Verify LLM was called without max_iter override
|
||||
MockLLM.assert_called()
|
||||
call_args = MockLLM.call_args
|
||||
@@ -121,7 +119,7 @@ class TestExecutionConfigMaxIter:
|
||||
# Skip if presets not implemented yet
|
||||
pytest.skip("ExecutionConfig presets not implemented yet")
|
||||
|
||||
@patch('praisonaiagents.agent.agent.LLM')
|
||||
@patch('praisonaiagents.llm.llm.LLM')
|
||||
def test_different_llm_init_paths_respect_max_iter(self, MockLLM):
|
||||
"""Test that all LLM initialization paths in Agent respect max_iter."""
|
||||
mock_llm_instance = Mock()
|
||||
@@ -129,15 +127,15 @@ class TestExecutionConfigMaxIter:
|
||||
execution_config = ExecutionConfig(max_iter=30)
|
||||
|
||||
test_cases = [
|
||||
# Dict config
|
||||
{"provider": "openai", "model": "gpt-4o", "api_key": "test"},
|
||||
# String config
|
||||
"gpt-4o",
|
||||
# With base_url
|
||||
{"base_url": "http://test", "model": "test-model", "api_key": "test"}
|
||||
# Dict config — deferred LLM
|
||||
({"provider": "openai", "model": "gpt-4o", "api_key": "test"}, True),
|
||||
# Plain model string — OpenAI path, no LLM() at init
|
||||
("gpt-4o", False),
|
||||
# Dict with base_url — deferred LLM
|
||||
({"base_url": "http://test", "model": "test-model", "api_key": "test"}, True),
|
||||
]
|
||||
|
||||
for llm_config in test_cases:
|
||||
for llm_config, expects_custom_llm in test_cases:
|
||||
MockLLM.reset_mock()
|
||||
|
||||
agent = Agent(
|
||||
@@ -147,11 +145,17 @@ class TestExecutionConfigMaxIter:
|
||||
execution=execution_config
|
||||
)
|
||||
|
||||
# Verify LLM was called with max_iter
|
||||
MockLLM.assert_called()
|
||||
call_args = MockLLM.call_args
|
||||
assert 'max_iter' in call_args.kwargs
|
||||
assert call_args.kwargs['max_iter'] == 30
|
||||
if expects_custom_llm:
|
||||
assert agent._llm_init_params is not None
|
||||
assert agent._llm_init_params.get('max_iter') == 30
|
||||
_ = agent.llm_instance
|
||||
MockLLM.assert_called()
|
||||
call_args = MockLLM.call_args
|
||||
assert call_args.kwargs.get('max_iter') == 30
|
||||
else:
|
||||
assert agent._llm_init_params is None
|
||||
assert not agent._using_custom_llm
|
||||
assert agent.max_iter == 30
|
||||
|
||||
def test_max_iter_bounds_validation(self):
|
||||
"""Test validation of max_iter bounds."""
|
||||
@@ -184,7 +188,7 @@ class TestExecutionConfigMaxIter:
|
||||
autonomy_config = AutonomyConfig()
|
||||
|
||||
# This should not conflict
|
||||
with patch('praisonaiagents.agent.agent.LLM') as MockLLM:
|
||||
with patch('praisonaiagents.llm.llm.LLM') as MockLLM:
|
||||
mock_llm_instance = Mock()
|
||||
MockLLM.return_value = mock_llm_instance
|
||||
|
||||
@@ -196,6 +200,8 @@ class TestExecutionConfigMaxIter:
|
||||
autonomy=autonomy_config
|
||||
)
|
||||
|
||||
_ = agent.llm_instance
|
||||
|
||||
# Verify both configs can coexist and max_iter is propagated
|
||||
MockLLM.assert_called()
|
||||
call_args = MockLLM.call_args
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Test script to reproduce and verify fix for multi-agent streaming issue #1882
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
# Set minimal OpenAI API key for testing (should fail gracefully)
|
||||
os.environ.setdefault("OPENAI_API_KEY", "sk-test-key-for-reproduction")
|
||||
|
||||
|
||||
def test_config_defaults():
|
||||
"""Test that the config default is now False"""
|
||||
from praisonaiagents.config.feature_configs import MultiAgentOutputConfig
|
||||
|
||||
config = MultiAgentOutputConfig()
|
||||
assert config.stream is False
|
||||
|
||||
|
||||
@pytest.mark.live
|
||||
def test_single_agent():
|
||||
"""Test single agent (should work with fallback)"""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(instructions="Reply with exactly the requested text")
|
||||
result = agent.start("Reply with exactly: OK")
|
||||
assert result is not None
|
||||
|
||||
|
||||
@pytest.mark.live
|
||||
def test_multi_agent():
|
||||
"""Test multi-agent (should work after fix)"""
|
||||
from praisonaiagents import Agent, Agents
|
||||
|
||||
research_agent = Agent(instructions="Research about AI")
|
||||
summarise_agent = Agent(instructions="Summarise research agent's findings")
|
||||
agents = Agents(agents=[research_agent, summarise_agent])
|
||||
|
||||
try:
|
||||
result = agents.start("What is Python?")
|
||||
assert result is not None
|
||||
except Exception as exc:
|
||||
if "Streaming is not supported in sync OpenAIAdapter" in str(exc):
|
||||
pytest.fail("Streaming error still exists — fix did not work")
|
||||
raise
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to validate cache optimization integration.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the source path to Python path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src', 'praisonai-agents'))
|
||||
|
||||
def test_imports():
|
||||
"""Test that all imports work correctly."""
|
||||
try:
|
||||
from praisonaiagents.tools.base import get_sorted_tool_schemas
|
||||
from praisonaiagents.memory.memory import Memory, CACHE_BOUNDARY
|
||||
print("✓ Imports successful")
|
||||
return True
|
||||
except ImportError as e:
|
||||
print(f"✗ Import failed: {e}")
|
||||
return False
|
||||
|
||||
def test_tool_sorting():
|
||||
"""Test tool schema sorting functionality (internal sorting)."""
|
||||
try:
|
||||
# Test the sorting function directly
|
||||
def sort_formatted_tools(tools_list):
|
||||
"""Sort already-formatted tool schemas by function name."""
|
||||
def sort_key(tool):
|
||||
if isinstance(tool, dict) and tool.get('type') == 'function':
|
||||
return str(tool.get('function', {}).get('name') or '')
|
||||
return ''
|
||||
return sorted(tools_list, key=sort_key)
|
||||
|
||||
# Test with formatted tool schemas
|
||||
test_tools = [
|
||||
{"type": "function", "function": {"name": "zebra_tool", "description": "Z tool"}},
|
||||
{"type": "function", "function": {"name": "alpha_tool", "description": "A tool"}},
|
||||
{"type": "function", "function": {"name": "beta_tool", "description": "B tool"}},
|
||||
]
|
||||
|
||||
sorted_tools = sort_formatted_tools(test_tools)
|
||||
tool_names = [tool["function"]["name"] for tool in sorted_tools]
|
||||
|
||||
expected_order = ["alpha_tool", "beta_tool", "zebra_tool"]
|
||||
if tool_names == expected_order:
|
||||
print("✓ Tool sorting works correctly")
|
||||
return True
|
||||
else:
|
||||
print(f"✗ Tool sorting failed. Got: {tool_names}, Expected: {expected_order}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"✗ Tool sorting test failed: {e}")
|
||||
return False
|
||||
|
||||
def test_cache_optimized_context():
|
||||
"""Test cache-optimized context building API structure."""
|
||||
try:
|
||||
from praisonaiagents.memory.memory import Memory
|
||||
|
||||
# Create a basic memory instance for API testing
|
||||
config = {}
|
||||
memory = Memory(config)
|
||||
|
||||
# Mock the underlying methods to avoid database dependencies in unit test
|
||||
memory.build_context_for_task = lambda task_descr, **kwargs: "test context"
|
||||
|
||||
# Test cache-optimized context building
|
||||
result = memory.build_cache_optimized_context(
|
||||
task_descr="test task",
|
||||
include_cache_boundary=True
|
||||
)
|
||||
|
||||
if isinstance(result, dict) and 'stable_prefix' in result and 'cache_boundary' in result:
|
||||
# Verify the cache boundary is now empty (our fix)
|
||||
if result['cache_boundary'] == "":
|
||||
print("✓ Cache-optimized context building works (cache boundary properly empty)")
|
||||
return True
|
||||
else:
|
||||
print(f"✗ Cache boundary should be empty but got: '{result['cache_boundary']}'")
|
||||
return False
|
||||
else:
|
||||
print(f"✗ Cache-optimized context building failed. Got: {result}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"✗ Cache-optimized context test failed: {e}")
|
||||
return False
|
||||
|
||||
def test_agent_integration():
|
||||
"""Test agent integration with cache optimization."""
|
||||
try:
|
||||
from praisonaiagents.agent.chat_mixin import ChatMixin
|
||||
|
||||
# Mock the necessary methods for testing
|
||||
class TestAgent(ChatMixin):
|
||||
def __init__(self):
|
||||
self.tools = []
|
||||
self._formatted_tools_cache = {}
|
||||
self._system_prompt_cache = {}
|
||||
self._memory_instance = None
|
||||
self.use_system_prompt = True
|
||||
self.role = "test"
|
||||
self.goal = "test"
|
||||
self.backstory = "test"
|
||||
self._rules_manager_initialized = False
|
||||
self._rules_manager = None
|
||||
self._skills = None
|
||||
self._skills_dirs = None
|
||||
self.verbose = False
|
||||
|
||||
def _cache_get(self, cache, key):
|
||||
return cache.get(key)
|
||||
|
||||
def _cache_put(self, cache, key, value):
|
||||
cache[key] = value
|
||||
|
||||
def _get_tools_cache_key(self, tools):
|
||||
return str(tools)
|
||||
|
||||
def get_memory_context(self):
|
||||
return ""
|
||||
|
||||
def get_learn_context(self):
|
||||
return ""
|
||||
|
||||
def get_skills_prompt(self):
|
||||
return ""
|
||||
|
||||
agent = TestAgent()
|
||||
|
||||
# Test tool formatting with sorting - pass already formatted tools
|
||||
test_tools = [
|
||||
{"type": "function", "function": {"name": "zebra_tool", "description": "Z tool"}},
|
||||
{"type": "function", "function": {"name": "alpha_tool", "description": "A tool"}},
|
||||
]
|
||||
|
||||
formatted_tools = agent._format_tools_for_completion(test_tools)
|
||||
if len(formatted_tools) == 2:
|
||||
tool_names = [tool["function"]["name"] for tool in formatted_tools]
|
||||
if tool_names == ["alpha_tool", "zebra_tool"]:
|
||||
print("✓ Agent tool integration works correctly")
|
||||
return True
|
||||
else:
|
||||
print(f"✗ Agent tools not sorted correctly. Got: {tool_names}")
|
||||
return False
|
||||
else:
|
||||
print(f"✗ Agent tool formatting failed. Got {len(formatted_tools)} tools")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Agent integration test failed: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Run all tests."""
|
||||
print("Testing cache optimization integration...")
|
||||
print()
|
||||
|
||||
tests = [
|
||||
test_imports,
|
||||
test_tool_sorting,
|
||||
test_cache_optimized_context,
|
||||
test_agent_integration
|
||||
]
|
||||
|
||||
results = []
|
||||
for test in tests:
|
||||
results.append(test())
|
||||
|
||||
print()
|
||||
passed = sum(results)
|
||||
total = len(results)
|
||||
|
||||
if passed == total:
|
||||
print(f"🎉 All {total} tests passed!")
|
||||
return 0
|
||||
else:
|
||||
print(f"💥 {total - passed} out of {total} tests failed!")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for context assembly cache optimization (Issue #1808).
|
||||
|
||||
This test verifies that:
|
||||
1. Memory search results are sorted deterministically
|
||||
2. Tool schemas are sorted by function name
|
||||
3. Cache boundary markers are properly included
|
||||
4. Multiple runs produce identical context strings for caching
|
||||
"""
|
||||
|
||||
def test_memory_stable_sorting():
|
||||
"""Test that memory context is assembled deterministically."""
|
||||
print("Testing memory stable sorting...")
|
||||
|
||||
from praisonaiagents.memory.memory import Memory
|
||||
|
||||
# Create test data that simulates memory search results
|
||||
test_results = [
|
||||
{"text": "First memory", "timestamp": 1000, "id": "1"},
|
||||
{"text": "Second memory", "timestamp": 2000, "id": "2"},
|
||||
{"text": "Third memory", "timestamp": 1500, "id": "3"}
|
||||
]
|
||||
|
||||
# Create a memory instance and stub the search methods to return controlled data
|
||||
memory = Memory(config={"provider": "none"})
|
||||
|
||||
# Mock the search methods to return our test data
|
||||
original_search_short = memory.search_short_term
|
||||
original_search_long = memory.search_long_term
|
||||
original_search_entity = memory.search_entity
|
||||
original_search_user = memory.search_user_memory
|
||||
|
||||
try:
|
||||
memory.search_short_term = lambda q, limit=3: test_results.copy()
|
||||
memory.search_long_term = lambda q, limit=3: []
|
||||
memory.search_entity = lambda q, limit=3: []
|
||||
memory.search_user_memory = lambda user_id, q, limit=3: []
|
||||
|
||||
# Test multiple runs produce identical context strings
|
||||
context1 = memory.build_context_for_task("test task", include_in_output=True)
|
||||
context2 = memory.build_context_for_task("test task", include_in_output=True)
|
||||
|
||||
assert context1 == context2, "Memory context should be deterministic"
|
||||
|
||||
# Verify the order is correct in the actual context (newest first)
|
||||
lines = context1.split('\n')
|
||||
second_idx = next((i for i, line in enumerate(lines) if "Second memory" in line), -1)
|
||||
third_idx = next((i for i, line in enumerate(lines) if "Third memory" in line), -1)
|
||||
first_idx = next((i for i, line in enumerate(lines) if "First memory" in line), -1)
|
||||
|
||||
assert second_idx < third_idx < first_idx, "Memory should be ordered newest first"
|
||||
print("✓ Memory context is deterministic and correctly ordered")
|
||||
|
||||
finally:
|
||||
# Restore original methods
|
||||
memory.search_short_term = original_search_short
|
||||
memory.search_long_term = original_search_long
|
||||
memory.search_entity = original_search_entity
|
||||
memory.search_user_memory = original_search_user
|
||||
|
||||
# Test cache boundary constants are available
|
||||
from praisonaiagents.memory.memory import CACHE_BOUNDARY
|
||||
assert CACHE_BOUNDARY is not None, "CACHE_BOUNDARY should be defined"
|
||||
print("✓ Cache boundary constants are available")
|
||||
|
||||
def test_tool_schema_sorting():
|
||||
"""Test that tool schemas are sorted deterministically."""
|
||||
print("Testing tool schema sorting...")
|
||||
|
||||
from praisonaiagents.tools.base import get_sorted_tool_schemas
|
||||
from praisonaiagents.tools.decorator import tool
|
||||
|
||||
# Create test tools with different names
|
||||
@tool
|
||||
def zebra_tool():
|
||||
"""A tool starting with Z."""
|
||||
return "zebra"
|
||||
|
||||
@tool
|
||||
def apple_tool():
|
||||
"""A tool starting with A."""
|
||||
return "apple"
|
||||
|
||||
@tool
|
||||
def middle_tool():
|
||||
"""A tool in the middle."""
|
||||
return "middle"
|
||||
|
||||
tools = [zebra_tool, apple_tool, middle_tool]
|
||||
|
||||
# Get sorted schemas multiple times
|
||||
schemas1 = get_sorted_tool_schemas(tools)
|
||||
schemas2 = get_sorted_tool_schemas(tools)
|
||||
|
||||
# Results should be identical
|
||||
assert schemas1 == schemas2, "Tool schemas should be deterministic"
|
||||
|
||||
# Check they are sorted by name
|
||||
names = [schema["function"]["name"] for schema in schemas1]
|
||||
expected_names = ["apple_tool", "middle_tool", "zebra_tool"]
|
||||
assert names == expected_names, f"Tools should be sorted alphabetically, got {names}"
|
||||
print("✓ Tool schemas are sorted deterministically")
|
||||
|
||||
def test_cache_boundary_markers():
|
||||
"""Test that cache boundary markers work correctly."""
|
||||
print("Testing cache boundary markers...")
|
||||
|
||||
from praisonaiagents.memory.memory import Memory, CACHE_BOUNDARY
|
||||
|
||||
# Test that the boundary constant is properly defined
|
||||
assert "CACHE_BOUNDARY" in CACHE_BOUNDARY
|
||||
assert len(CACHE_BOUNDARY) > 0
|
||||
print("✓ Cache boundary constant is defined")
|
||||
|
||||
# Test the cache-optimized context method actually works
|
||||
memory = Memory(config={"provider": "none"})
|
||||
|
||||
# Mock search methods to return empty results for simpler test
|
||||
memory.search_short_term = lambda q, limit=3: []
|
||||
memory.search_long_term = lambda q, limit=3: []
|
||||
memory.search_entity = lambda q, limit=3: []
|
||||
memory.search_user_memory = lambda user_id, q, limit=3: []
|
||||
|
||||
# Test with cache boundary enabled
|
||||
result = memory.build_cache_optimized_context("test task", include_cache_boundary=True, include_in_output=True)
|
||||
assert "stable_prefix" in result, "Result should have stable_prefix"
|
||||
assert "cache_boundary" in result, "Result should have cache_boundary"
|
||||
assert result["cache_boundary"] == CACHE_BOUNDARY, "Cache boundary should match constant"
|
||||
|
||||
# Test with cache boundary disabled
|
||||
result2 = memory.build_cache_optimized_context("test task", include_cache_boundary=False, include_in_output=True)
|
||||
assert result2["cache_boundary"] == "", "Cache boundary should be empty when disabled"
|
||||
|
||||
print("✓ Cache optimization method works correctly")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
try:
|
||||
test_memory_stable_sorting()
|
||||
test_tool_schema_sorting()
|
||||
test_cache_boundary_markers()
|
||||
print("\n🎉 All tests passed! Context assembly cache optimization is working correctly.")
|
||||
except Exception as e:
|
||||
print(f"\n❌ Test failed: {e}")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Unit tests for Agent instantiation performance optimisations (lazy LLM, LoopGuard)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import ExecutionConfig
|
||||
from praisonaiagents.config.loader import clear_config_cache
|
||||
|
||||
|
||||
class TestLazyInstantiation:
|
||||
def test_agent_init_no_deprecation_warning(self):
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
Agent(name="T", output="silent")
|
||||
Agent(name="T", model="gpt-4o-mini", output="silent")
|
||||
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
|
||||
assert not deprecations
|
||||
|
||||
def test_litellm_model_deferred_at_init(self):
|
||||
agent = Agent(name="T", model="openai/gpt-4o-mini", output="silent")
|
||||
assert agent._llm_instance is None
|
||||
assert agent._llm_init_params["model"] == "openai/gpt-4o-mini"
|
||||
assert agent._using_custom_llm
|
||||
|
||||
def test_loop_guard_deferred_at_init(self):
|
||||
agent = Agent(name="T", output="silent")
|
||||
assert agent._loop_guard is None
|
||||
guard = agent._ensure_loop_guard()
|
||||
assert guard is not None
|
||||
assert agent._ensure_loop_guard() is guard
|
||||
|
||||
def test_llm_instance_materializes_once(self):
|
||||
with patch("praisonaiagents.llm.llm.LLM") as MockLLM:
|
||||
MockLLM.return_value = Mock()
|
||||
agent = Agent(name="T", model="openai/gpt-4o-mini", output="silent")
|
||||
first = agent.llm_instance
|
||||
second = agent.llm_instance
|
||||
assert first is second
|
||||
MockLLM.assert_called_once()
|
||||
|
||||
def test_dict_llm_stores_max_iter_in_deferred_params(self):
|
||||
agent = Agent(
|
||||
name="T",
|
||||
llm={"model": "gpt-4o-mini", "api_key": "x"},
|
||||
execution=ExecutionConfig(max_iter=12),
|
||||
output="silent",
|
||||
)
|
||||
assert agent._llm_init_params["max_iter"] == 12
|
||||
|
||||
def test_plain_model_string_no_custom_llm(self):
|
||||
agent = Agent(name="T", model="gpt-4o-mini", output="silent")
|
||||
assert not agent._using_custom_llm
|
||||
assert agent._llm_init_params is None
|
||||
|
||||
def test_config_defaults_fast_path_when_empty(self, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
clear_config_cache()
|
||||
agent = Agent(name="T", output="silent")
|
||||
assert agent.name == "T"
|
||||
|
||||
def test_execution_config_internal_from_tests_is_suppressed(self):
|
||||
"""Test/SDK paths suppress context_compaction spam during Agent init."""
|
||||
ExecutionConfig._context_compaction_warned = False # noqa: SLF001
|
||||
ExecutionConfig._context_compaction_internal = False # noqa: SLF001
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
ExecutionConfig()
|
||||
compaction = [w for w in caught if "context_compaction" in str(w.message)]
|
||||
assert not compaction
|
||||
assert ExecutionConfig._context_compaction_internal is True # noqa: SLF001
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Smoke tests for security audit hardening."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_workflow_include_skips_tools_without_opt_in(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("PRAISONAI_ALLOW_LOCAL_TOOLS", raising=False)
|
||||
monkeypatch.delenv("PRAISONAI_ALLOW_TEMPLATE_TOOLS", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
child = tmp_path / "child_recipe"
|
||||
child.mkdir()
|
||||
marker = tmp_path / "marker.txt"
|
||||
(child / "tools.py").write_text(f'from pathlib import Path\nPath({str(marker)!r}).write_text("x")\n')
|
||||
(child / "workflow.yaml").write_text("name: child\nsteps: []\n")
|
||||
|
||||
from praisonaiagents.workflows.workflows import Workflow, include
|
||||
|
||||
Workflow(steps=[include("child_recipe")]).run(input="", llm="dummy/local", stream=False)
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
def test_web_crawl_rejects_redirect_to_loopback():
|
||||
pytest.importorskip("httpx")
|
||||
from praisonaiagents.tools.web_crawl_tools import _is_safe_crawl_url
|
||||
|
||||
assert _is_safe_crawl_url("http://127.0.0.1/secret") is False
|
||||
assert _is_safe_crawl_url("http://example.com/") is True
|
||||
|
||||
|
||||
def test_spider_blocks_rebound_hostname():
|
||||
from praisonaiagents.tools.spider_tools import _host_is_blocked
|
||||
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("127.0.0.1", 0))]):
|
||||
assert _host_is_blocked("rebound.example") is True
|
||||
|
||||
|
||||
def test_file_memory_rejects_traversal_user_id():
|
||||
from praisonaiagents.memory.file_memory import FileMemory
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
FileMemory(user_id="../escape")
|
||||
|
||||
|
||||
def test_ast_grep_rewrite_requires_approval():
|
||||
from praisonaiagents.tools import ast_grep_tool # noqa: F401
|
||||
from praisonaiagents.approval import is_approval_required
|
||||
|
||||
assert is_approval_required("ast_grep_rewrite") is True
|
||||
|
||||
|
||||
def test_agent_server_enforces_auth_token():
|
||||
from praisonaiagents.server.server import AgentServer, ServerConfig
|
||||
|
||||
server = AgentServer(config=ServerConfig(auth_token="secret-token"))
|
||||
request = type("Req", (), {"headers": {"Authorization": "Bearer wrong"}})()
|
||||
assert server._authorise_request(request) is False
|
||||
request_ok = type("Req", (), {"headers": {"Authorization": "Bearer secret-token"}})()
|
||||
assert server._authorise_request(request_ok) is True
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Unit tests for security advisory hardening."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_webhook_fail_closed_without_secret(monkeypatch):
|
||||
from praisonai.bots.webhook_security import webhooks_require_verification
|
||||
|
||||
monkeypatch.delenv("PRAISONAI_INSECURE_WEBHOOKS", raising=False)
|
||||
assert webhooks_require_verification() is True
|
||||
|
||||
|
||||
def test_webhook_insecure_override(monkeypatch):
|
||||
from praisonai.bots.webhook_security import webhooks_require_verification
|
||||
|
||||
monkeypatch.setenv("PRAISONAI_INSECURE_WEBHOOKS", "true")
|
||||
assert webhooks_require_verification() is False
|
||||
|
||||
|
||||
def test_jobs_agent_root_rejects_escape(tmp_path, monkeypatch):
|
||||
from praisonai.jobs.path_validation import validate_agent_file_path
|
||||
|
||||
root = tmp_path / "agents"
|
||||
root.mkdir()
|
||||
outside = tmp_path / "outside.yaml"
|
||||
outside.write_text("agents: []")
|
||||
monkeypatch.setenv("PRAISONAI_JOBS_AGENT_ROOT", str(root))
|
||||
|
||||
with pytest.raises(ValueError, match="PRAISONAI_JOBS_AGENT_ROOT"):
|
||||
validate_agent_file_path(str(outside))
|
||||
|
||||
|
||||
def test_jobs_agent_root_allows_inside(tmp_path, monkeypatch):
|
||||
from praisonai.jobs.path_validation import validate_agent_file_path
|
||||
|
||||
root = tmp_path / "agents"
|
||||
root.mkdir()
|
||||
inside = root / "team.yaml"
|
||||
inside.write_text("agents: []")
|
||||
monkeypatch.setenv("PRAISONAI_JOBS_AGENT_ROOT", str(root))
|
||||
|
||||
assert validate_agent_file_path(str(inside)) == str(inside)
|
||||
|
||||
|
||||
def test_yaml_approve_blocks_critical_tools():
|
||||
from praisonaiagents.approval.registry import ApprovalRegistry
|
||||
|
||||
registry = ApprovalRegistry()
|
||||
token = registry.set_yaml_approved_tools(["execute_code", "search_web"])
|
||||
try:
|
||||
assert registry.is_yaml_approved("search_web") is True
|
||||
assert registry.is_yaml_approved("execute_code") is False
|
||||
finally:
|
||||
registry.reset_yaml_approved_tools(token)
|
||||
|
||||
|
||||
def test_sandbox_blocks_format():
|
||||
from praisonaiagents.tools.python_tools import _validate_code_ast
|
||||
|
||||
assert _validate_code_ast('"{0.__class__}".format(42)') is not None
|
||||
assert _validate_code_ast('"".format_map({})') is not None
|
||||
|
||||
|
||||
def test_sse_security_middleware_rejects_bad_origin(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.responses import PlainTextResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from praisonaiagents.mcp.mcp_security import SecurityConfig, build_sse_security_app
|
||||
|
||||
monkeypatch.delenv("MCP_SSE_ALLOWED_ORIGINS", raising=False)
|
||||
monkeypatch.delenv("MCP_SSE_AUTH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("MCP_AUTH_TOKEN", raising=False)
|
||||
|
||||
app = Starlette(routes=[Route("/", lambda r: PlainTextResponse("ok"))])
|
||||
config = SecurityConfig(validate_origin=True, allowed_origins=["localhost"])
|
||||
app = build_sse_security_app(app, config)
|
||||
|
||||
async def _run(origin: str):
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/",
|
||||
"headers": [(b"origin", origin.encode())],
|
||||
}
|
||||
status_code = 500
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
async def send(message):
|
||||
nonlocal status_code
|
||||
if message["type"] == "http.response.start":
|
||||
status_code = message["status"]
|
||||
|
||||
await app(scope, receive, send)
|
||||
return status_code
|
||||
|
||||
assert asyncio.run(_run("https://evil.com")) == 403
|
||||
assert asyncio.run(_run("http://localhost:8080")) == 200
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Tests for remaining security hardening (round 2)."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_launch_auth_optional_by_default():
|
||||
from praisonaiagents.agents.agents import _authorise_launch_request
|
||||
|
||||
class Req:
|
||||
headers = {}
|
||||
|
||||
assert _authorise_launch_request(Req()) is True
|
||||
|
||||
|
||||
def test_launch_auth_enforced_when_token_set(monkeypatch):
|
||||
from praisonaiagents.agents.agents import _authorise_launch_request
|
||||
|
||||
monkeypatch.setenv("PRAISONAI_LAUNCH_AUTH_TOKEN", "secret")
|
||||
|
||||
class Bad:
|
||||
headers = {}
|
||||
|
||||
class Good:
|
||||
headers = {"Authorization": "Bearer secret"}
|
||||
|
||||
assert _authorise_launch_request(Bad()) is False
|
||||
assert _authorise_launch_request(Good()) is True
|
||||
|
||||
monkeypatch.delenv("PRAISONAI_LAUNCH_AUTH_TOKEN", raising=False)
|
||||
|
||||
|
||||
def test_mentions_reject_outside_workspace(tmp_path):
|
||||
from praisonaiagents.tools.mentions import MentionsParser
|
||||
|
||||
outside = tmp_path / "secret.txt"
|
||||
outside.write_text("secret", encoding="utf-8")
|
||||
ws = tmp_path / "ws"
|
||||
ws.mkdir()
|
||||
parser = MentionsParser(workspace_path=str(ws))
|
||||
context, _ = parser.process(f"@file:{outside}")
|
||||
assert "outside workspace" in context.lower() or "not allowed" in context.lower()
|
||||
|
||||
|
||||
def test_imap_sanitize_rejects_injection():
|
||||
from praisonaiagents.tools.email_tools import _imap_sanitize
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
_imap_sanitize('test"\r\nBAD')
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Tests for outstanding GHSA security fixes in praisonaiagents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_searxng_url_blocks_private_ip():
|
||||
from praisonaiagents.tools.url_safety import validate_searxng_url
|
||||
|
||||
assert validate_searxng_url("http://10.0.0.1/search") is None
|
||||
|
||||
|
||||
def test_workflow_yaml_approve_gated_without_allow_dangerous():
|
||||
"""GHSA-7qw2: approve list must not auto-approve when allow_dangerous_tools=False."""
|
||||
from praisonaiagents import Agent, Workflow
|
||||
from praisonaiagents.approval import get_approval_registry
|
||||
|
||||
agent = Agent(name="a", instructions="test")
|
||||
wf = Workflow(steps=[agent])
|
||||
wf.approve_tools = ["execute_command"]
|
||||
wf.allow_dangerous_tools = False
|
||||
|
||||
registry = get_approval_registry()
|
||||
before = set(registry._yaml_approved_tools.get() or ())
|
||||
|
||||
try:
|
||||
wf.run("hello", verbose=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
after = set(registry._yaml_approved_tools.get() or ())
|
||||
assert after == before
|
||||
Generated
+1
-1
@@ -3125,7 +3125,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.55"
|
||||
version = "1.6.62"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -95,6 +95,25 @@ async def require_workspace_owner(
|
||||
)
|
||||
|
||||
|
||||
async def require_delete_permission(
|
||||
workspace_id: str,
|
||||
user: AuthIdentity,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
resource_owner_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Allow delete when user is admin/owner or owns the resource."""
|
||||
member_svc = MemberService(session)
|
||||
if await member_svc.has_role(workspace_id, user.id, "admin"):
|
||||
return
|
||||
if resource_owner_id and resource_owner_id == user.id:
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permission to delete this resource",
|
||||
)
|
||||
|
||||
|
||||
def ensure_resource_in_workspace(
|
||||
resource_workspace_id: str | None,
|
||||
workspace_id: str,
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..deps import get_db, require_workspace_member
|
||||
from ..deps import get_db, require_delete_permission, require_workspace_member
|
||||
from ..schemas import AgentCreate, AgentResponse, AgentUpdate
|
||||
from ...services.agent_service import AgentService
|
||||
|
||||
@@ -96,6 +96,12 @@ async def delete_agent(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = AgentService(session)
|
||||
agent = await svc.get(agent_id, workspace_id=workspace_id)
|
||||
if agent is None:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
await require_delete_permission(
|
||||
workspace_id, user, session, resource_owner_id=agent.owner_id
|
||||
)
|
||||
deleted = await svc.delete(agent_id, workspace_id=workspace_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..deps import get_db, require_issue_in_workspace, require_workspace_member
|
||||
from ..deps import get_db, require_delete_permission, require_issue_in_workspace, require_workspace_member
|
||||
from ...services.dependency_service import DependencyService
|
||||
from ..schemas import DependencyCreate, DependencyResponse
|
||||
|
||||
@@ -58,12 +58,21 @@ async def delete_dependency(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_issue_in_workspace(workspace_id, issue_id, session)
|
||||
from ...services.issue_service import IssueService
|
||||
|
||||
issue_svc = IssueService(session)
|
||||
issue = await issue_svc.get(issue_id, workspace_id=workspace_id)
|
||||
if issue is None:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
svc = DependencyService(session)
|
||||
dep = await svc.get(dep_id)
|
||||
if dep is None:
|
||||
raise HTTPException(status_code=404, detail="Dependency not found")
|
||||
if dep.issue_id != issue_id and dep.depends_on_issue_id != issue_id:
|
||||
raise HTTPException(status_code=404, detail="Dependency not found")
|
||||
await require_delete_permission(
|
||||
workspace_id, user, session, resource_owner_id=issue.creator_id
|
||||
)
|
||||
deleted = await svc.delete(dep_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Dependency not found")
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..deps import ensure_resource_in_workspace, get_db, require_workspace_member
|
||||
from ..deps import ensure_resource_in_workspace, get_db, require_delete_permission, require_workspace_member
|
||||
from ..schemas import (
|
||||
CommentCreate,
|
||||
CommentResponse,
|
||||
@@ -133,6 +133,12 @@ async def delete_issue(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = IssueService(session)
|
||||
issue = await svc.get(issue_id, workspace_id=workspace_id)
|
||||
if issue is None:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
await require_delete_permission(
|
||||
workspace_id, user, session, resource_owner_id=issue.creator_id
|
||||
)
|
||||
deleted = await svc.delete(issue_id, workspace_id=workspace_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
|
||||
@@ -12,6 +12,7 @@ from praisonaiagents.auth import AuthIdentity
|
||||
from ..deps import (
|
||||
ensure_resource_in_workspace,
|
||||
get_db,
|
||||
require_delete_permission,
|
||||
require_issue_in_workspace,
|
||||
require_workspace_member,
|
||||
)
|
||||
@@ -75,6 +76,7 @@ async def delete_label(
|
||||
if label is None:
|
||||
raise HTTPException(status_code=404, detail="Label not found")
|
||||
ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
|
||||
await require_delete_permission(workspace_id, user, session)
|
||||
deleted = await svc.delete(label_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Label not found")
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..deps import get_db, require_workspace_member
|
||||
from ..deps import get_db, require_delete_permission, require_workspace_member
|
||||
from ..schemas import ProjectCreate, ProjectResponse, ProjectUpdate
|
||||
from ...services.project_service import ProjectService
|
||||
|
||||
@@ -93,6 +93,12 @@ async def delete_project(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = ProjectService(session)
|
||||
project = await svc.get(project_id, workspace_id=workspace_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
await require_delete_permission(
|
||||
workspace_id, user, session, resource_owner_id=project.lead_id
|
||||
)
|
||||
deleted = await svc.delete(project_id, workspace_id=workspace_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
@@ -19,40 +19,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..db.models import Member, User
|
||||
from .jwt_secret import resolve_jwt_secret
|
||||
|
||||
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
_DEFAULT_SECRET = "dev-secret-change-me"
|
||||
JWT_SECRET = os.environ.get("PLATFORM_JWT_SECRET")
|
||||
JWT_SECRET = resolve_jwt_secret()
|
||||
JWT_ALGORITHM = "HS256"
|
||||
JWT_TTL_SECONDS = int(os.environ.get("PLATFORM_JWT_TTL", str(30 * 24 * 3600)))
|
||||
|
||||
if JWT_SECRET is None or JWT_SECRET == _DEFAULT_SECRET:
|
||||
# Determine if we are in an explicit dev environment.
|
||||
# Only trust PLATFORM_ENV when it is explicitly set — an unset value
|
||||
# must NOT silently fall back to "dev" because production deployments
|
||||
# that forget the variable would run with a well-known signing key.
|
||||
_explicit_env = os.environ.get("PLATFORM_ENV")
|
||||
if _explicit_env == "dev":
|
||||
# Developer explicitly opted in to insecure defaults — allow it.
|
||||
JWT_SECRET = _DEFAULT_SECRET
|
||||
else:
|
||||
# Either production or environment not specified.
|
||||
# Auto-generate an ephemeral secret so the service can start, but
|
||||
# warn loudly because tokens will not survive a restart.
|
||||
import secrets as _secrets
|
||||
import warnings as _warnings
|
||||
|
||||
JWT_SECRET = _secrets.token_urlsafe(32)
|
||||
_warnings.warn(
|
||||
"PLATFORM_JWT_SECRET is not set. Auto-generated an ephemeral "
|
||||
"signing key — JWT tokens will be invalidated on restart. "
|
||||
"Set PLATFORM_JWT_SECRET to a stable random value for production "
|
||||
"use, or set PLATFORM_ENV=dev to use the development default.",
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# Type-narrow: at this point JWT_SECRET is always a non-None str.
|
||||
assert JWT_SECRET is not None # noqa: S105
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""JWT secret resolution for praisonai-platform (testable, fail-closed in production)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import warnings
|
||||
from typing import Optional
|
||||
|
||||
_DEFAULT_SECRET = "dev-secret-change-me"
|
||||
|
||||
|
||||
def resolve_jwt_secret(
|
||||
*,
|
||||
env: Optional[dict[str, str]] = None,
|
||||
) -> str:
|
||||
"""Resolve PLATFORM_JWT_SECRET with production guards."""
|
||||
env = env if env is not None else os.environ
|
||||
explicit_env = env.get("PLATFORM_ENV")
|
||||
raw_secret = env.get("PLATFORM_JWT_SECRET")
|
||||
|
||||
if explicit_env == "production":
|
||||
if not raw_secret or raw_secret == _DEFAULT_SECRET:
|
||||
raise RuntimeError(
|
||||
"PLATFORM_JWT_SECRET must be set to a stable random value when "
|
||||
"PLATFORM_ENV=production"
|
||||
)
|
||||
return raw_secret
|
||||
|
||||
if raw_secret == _DEFAULT_SECRET:
|
||||
if explicit_env == "dev":
|
||||
return _DEFAULT_SECRET
|
||||
raise RuntimeError(
|
||||
"PLATFORM_JWT_SECRET cannot be the development default unless "
|
||||
"PLATFORM_ENV=dev"
|
||||
)
|
||||
|
||||
if raw_secret:
|
||||
return raw_secret
|
||||
|
||||
if explicit_env == "dev":
|
||||
return _DEFAULT_SECRET
|
||||
|
||||
ephemeral = secrets.token_urlsafe(32)
|
||||
warnings.warn(
|
||||
"PLATFORM_JWT_SECRET is not set. Auto-generated an ephemeral signing key — "
|
||||
"JWT tokens will be invalidated on restart. Set PLATFORM_JWT_SECRET for "
|
||||
"production, or PLATFORM_ENV=dev for development defaults.",
|
||||
stacklevel=2,
|
||||
)
|
||||
return ephemeral
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonai-platform"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
description = "Platform layer for PraisonAI — workspace, auth, issues, projects"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""DELETE ownership checks for platform resources."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonai_platform.services.auth_service import AuthService
|
||||
from praisonai_platform.services.issue_service import IssueService
|
||||
from praisonai_platform.services.member_service import MemberService
|
||||
from praisonai_platform.services.workspace_service import WorkspaceService
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from praisonai_platform.api.deps import require_delete_permission
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_member_cannot_delete_others_issue(session):
|
||||
auth = AuthService(session)
|
||||
owner, _ = await auth.register("[email protected]", "pass")
|
||||
other_user, _ = await auth.register("[email protected]", "pass")
|
||||
ws = await WorkspaceService(session).create("Team", owner.id)
|
||||
await MemberService(session).add(ws.id, other_user.id, "member")
|
||||
|
||||
issue = await IssueService(session).create(
|
||||
workspace_id=ws.id,
|
||||
title="private",
|
||||
creator_id=owner.id,
|
||||
)
|
||||
|
||||
other = AuthIdentity(id=other_user.id, email=other_user.email, name=other_user.name)
|
||||
with pytest.raises(Exception) as exc:
|
||||
await require_delete_permission(
|
||||
ws.id, other, session, resource_owner_id=issue.creator_id
|
||||
)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_can_delete_any_issue(session):
|
||||
auth = AuthService(session)
|
||||
owner, _ = await auth.register("[email protected]", "pass")
|
||||
admin_user, _ = await auth.register("[email protected]", "pass")
|
||||
ws = await WorkspaceService(session).create("AdminTeam", owner.id)
|
||||
await MemberService(session).add(ws.id, admin_user.id, "admin")
|
||||
|
||||
issue = await IssueService(session).create(
|
||||
workspace_id=ws.id,
|
||||
title="shared",
|
||||
creator_id=owner.id,
|
||||
)
|
||||
|
||||
admin = AuthIdentity(id=admin_user.id, email=admin_user.email, name=admin_user.name)
|
||||
await require_delete_permission(
|
||||
ws.id, admin, session, resource_owner_id=issue.creator_id
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Tests for platform JWT secret resolution (GHSA-cwj8, GHSA-f38v)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Direct import to avoid pulling auth_service deps at collection time
|
||||
_jwt_path = Path(__file__).resolve().parents[2] / "praisonai_platform" / "services" / "jwt_secret.py"
|
||||
_spec = importlib.util.spec_from_file_location("jwt_secret", _jwt_path)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
assert _spec and _spec.loader
|
||||
_spec.loader.exec_module(_mod)
|
||||
resolve_jwt_secret = _mod.resolve_jwt_secret
|
||||
|
||||
|
||||
def test_jwt_secret_rejects_dev_default_in_prod():
|
||||
with pytest.raises(RuntimeError):
|
||||
resolve_jwt_secret(env={
|
||||
"PLATFORM_ENV": "production",
|
||||
"PLATFORM_JWT_SECRET": "dev-secret-change-me",
|
||||
})
|
||||
|
||||
|
||||
def test_jwt_secret_allows_dev_default_when_dev_env():
|
||||
secret = resolve_jwt_secret(env={
|
||||
"PLATFORM_ENV": "dev",
|
||||
"PLATFORM_JWT_SECRET": "dev-secret-change-me",
|
||||
})
|
||||
assert secret == "dev-secret-change-me"
|
||||
|
||||
|
||||
def test_jwt_secret_rejects_explicit_dev_secret_without_dev_env():
|
||||
with pytest.raises(RuntimeError):
|
||||
resolve_jwt_secret(env={"PLATFORM_JWT_SECRET": "dev-secret-change-me"})
|
||||
|
||||
|
||||
def test_jwt_secret_ephemeral_when_unset():
|
||||
secret = resolve_jwt_secret(env={})
|
||||
assert secret != "dev-secret-change-me"
|
||||
assert len(secret) > 20
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-06-12",
|
||||
"lastUpdated": "2026-06-17",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"status": "PARITY_ACHIEVED",
|
||||
"summary": {
|
||||
"pythonCoreFeatures": 359,
|
||||
"pythonCoreFeatures": 360,
|
||||
"rustFeatures": 667,
|
||||
"gapCount": 80,
|
||||
"parityPercentage": 185.8
|
||||
"gapCount": 81,
|
||||
"parityPercentage": 185.3
|
||||
},
|
||||
"pythonCoreSDK": {
|
||||
"exports": [
|
||||
@@ -275,6 +275,7 @@
|
||||
"WebSearchProvider",
|
||||
"Workflow",
|
||||
"WorkflowContext",
|
||||
"__version__",
|
||||
"aembed",
|
||||
"aembedding",
|
||||
"aembeddings",
|
||||
@@ -4374,6 +4375,15 @@
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "__version__",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "aembed",
|
||||
"python": true,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# Rust Feature Parity Tracker
|
||||
|
||||
> **Python Features:** 359 | **Rust Features:** 667 | **Parity:** 79.4%
|
||||
> **Python Features:** 360 | **Rust Features:** 667 | **Parity:** 79.2%
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Python Core Features | 359 |
|
||||
| Python Core Features | 360 |
|
||||
| Rust Features | 667 |
|
||||
| **Actual Gap Count** | **74** |
|
||||
| **Actual Gap Count** | **75** |
|
||||
| Language Limitations (N/A) | 5 |
|
||||
| **Parity** | **79.4%** |
|
||||
| **Parity** | **79.2%** |
|
||||
|
||||
## Implemented Features
|
||||
|
||||
@@ -751,6 +751,7 @@ These Python features cannot be directly implemented in Rust due to reserved key
|
||||
- ❌ `ToolValidationError`
|
||||
- ❌ `ToolsetRegistry`
|
||||
- ❌ `ToolsetSpec`
|
||||
- ❌ `__version__`
|
||||
- ❌ `configure_structured_logging`
|
||||
- ❌ `get_default_policy`
|
||||
- ❌ `get_logger`
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-06-12",
|
||||
"lastUpdated": "2026-06-17",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"summary": {
|
||||
"pythonCoreFeatures": 359,
|
||||
"pythonCoreFeatures": 360,
|
||||
"pythonWrapperFeatures": 99,
|
||||
"typescriptFeatures": 1192,
|
||||
"gapCount": 67,
|
||||
"gapCount": 68,
|
||||
"priorityP0": 2,
|
||||
"priorityP1": 0,
|
||||
"priorityP2": 0,
|
||||
"priorityP3": 65
|
||||
"priorityP3": 66
|
||||
},
|
||||
"pythonCoreSDK": {
|
||||
"path": "/home/runner/work/PraisonAI/PraisonAI/src/praisonai-agents/praisonaiagents",
|
||||
@@ -279,6 +279,7 @@
|
||||
"WebSearchProvider",
|
||||
"Workflow",
|
||||
"WorkflowContext",
|
||||
"__version__",
|
||||
"aembed",
|
||||
"aembedding",
|
||||
"aembeddings",
|
||||
@@ -655,6 +656,7 @@
|
||||
"ToolsetRegistry",
|
||||
"ToolsetSpec",
|
||||
"ValidationError",
|
||||
"__version__",
|
||||
"config",
|
||||
"configure_structured_logging",
|
||||
"embedding",
|
||||
@@ -5491,6 +5493,15 @@
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "__version__",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "aembed",
|
||||
"python": true,
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# Feature Parity Tracker
|
||||
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-06-12
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-06-17
|
||||
> **Source of Truth:** Python SDK (praisonaiagents)
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Python Core Features | 359 |
|
||||
| Python Core Features | 360 |
|
||||
| Python Wrapper Features | 99 |
|
||||
| TypeScript Features | 1192 |
|
||||
| **Gap Count** | **67** |
|
||||
| **Gap Count** | **68** |
|
||||
| P0 (Critical) | 2 |
|
||||
| P1 (High) | 0 |
|
||||
| P2 (Medium) | 0 |
|
||||
| P3 (Low) | 65 |
|
||||
| P3 (Low) | 66 |
|
||||
|
||||
## Gap Matrix
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
| `enable\_telemetry` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_telemetry` | ✅ | ✅ | low | ✅ DONE |
|
||||
|
||||
### P3_Advanced (179 done, 65 todo)
|
||||
### P3_Advanced (179 done, 66 todo)
|
||||
|
||||
| Feature | Python | TypeScript | Effort | Status |
|
||||
|---------|--------|------------|--------|--------|
|
||||
@@ -205,6 +205,7 @@
|
||||
| `ToolsetRegistry` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ToolsetSpec` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ValidationError` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `\_\_version\_\_` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `configure\_structured\_logging` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `get\_default\_policy` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `get\_logger` | ✅ | ❌ | low | ⏳ TODO |
|
||||
@@ -555,7 +556,7 @@ from praisonaiagents import Memory
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>other</strong> (70 exports)</summary>
|
||||
<summary><strong>other</strong> (71 exports)</summary>
|
||||
|
||||
```python
|
||||
from praisonaiagents import AGGRESSIVE_POLICY, AgentMessageEvent, AgentRunOutcome, Agents, AsyncLearnProtocol, AutonomyConfig, BALANCED_POLICY, BudgetExceededError, CONSERVATIVE_POLICY, CliBackendConfig...
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "praisonai",
|
||||
"version": "1.7.1",
|
||||
"version": "1.7.2",
|
||||
"description": "PraisonAI TypeScript AI Agents Framework - Node.js, npm, and Javascript AI Agents Framework",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -135,6 +135,10 @@ export class AgentLoop {
|
||||
...config,
|
||||
};
|
||||
|
||||
if (this.config.onToolCall && this.config.tools) {
|
||||
this.config.tools = this.wrapToolsWithApproval(this.config.tools);
|
||||
}
|
||||
|
||||
// Add system message if provided
|
||||
if (config.system) {
|
||||
this.messages.push({ role: 'system', content: config.system });
|
||||
@@ -218,16 +222,9 @@ export class AgentLoop {
|
||||
|
||||
// Handle tool calls
|
||||
if (step.toolCalls.length > 0) {
|
||||
// Check for approval if callback provided
|
||||
if (this.config.onToolCall) {
|
||||
for (const toolCall of step.toolCalls) {
|
||||
const approved = await this.config.onToolCall(toolCall);
|
||||
if (!approved) {
|
||||
this.complete = true;
|
||||
step.finishReason = 'tool_rejected';
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Rejection is handled in wrapped tool execute before execution
|
||||
if (step.finishReason === 'tool_rejected') {
|
||||
this.complete = true;
|
||||
}
|
||||
|
||||
// Add tool call message
|
||||
@@ -306,6 +303,35 @@ export class AgentLoop {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap tools so onToolCall approval runs before execution.
|
||||
*/
|
||||
private wrapToolsWithApproval(tools: Record<string, AgentTool>): Record<string, AgentTool> {
|
||||
const onToolCall = this.config.onToolCall;
|
||||
if (!onToolCall) {
|
||||
return tools;
|
||||
}
|
||||
|
||||
const wrapped: Record<string, AgentTool> = {};
|
||||
for (const [name, tool] of Object.entries(tools)) {
|
||||
wrapped[name] = {
|
||||
...tool,
|
||||
execute: async (args: any) => {
|
||||
const approved = await onToolCall({
|
||||
toolCallId: `pending-${name}`,
|
||||
toolName: name,
|
||||
args,
|
||||
});
|
||||
if (!approved) {
|
||||
throw new Error(`Tool call rejected: ${name}`);
|
||||
}
|
||||
return tool.execute(args);
|
||||
},
|
||||
};
|
||||
}
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the loop should stop.
|
||||
*/
|
||||
|
||||
@@ -67,6 +67,33 @@ export const DEFAULT_BLOCKED_PATHS = [
|
||||
'/proc'
|
||||
];
|
||||
|
||||
/** Shell metacharacters that enable command chaining or substitution */
|
||||
export const SHELL_METACHAR_PATTERN = /[;|&`><]|\$\([^)]*\)|\$\{/;
|
||||
|
||||
/** Commands that typically require network access */
|
||||
export const NETWORK_COMMANDS = [
|
||||
'curl', 'wget', 'nc', 'netcat', 'ssh', 'scp', 'sftp', 'ftp', 'telnet',
|
||||
'ping', 'nslookup', 'dig', 'host', 'traceroute', 'tracepath', 'nmap',
|
||||
'socat', 'openssl', 'node', 'python', 'python3', 'ruby', 'perl',
|
||||
];
|
||||
|
||||
/** Environment variables commonly used for network/proxy configuration */
|
||||
const NETWORK_ENV_VARS = [
|
||||
'http_proxy', 'https_proxy', 'HTTP_PROXY', 'HTTPS_PROXY',
|
||||
'ALL_PROXY', 'all_proxy', 'NO_PROXY', 'no_proxy',
|
||||
'FTP_PROXY', 'ftp_proxy', 'SOCKS_PROXY', 'socks_proxy',
|
||||
];
|
||||
|
||||
export function containsShellMetacharacters(command: string): boolean {
|
||||
return SHELL_METACHAR_PATTERN.test(command);
|
||||
}
|
||||
|
||||
function parseCommandParts(command: string): { cmd: string; args: string[] } {
|
||||
const parts = command.trim().match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
|
||||
const unquoted = parts.map((part) => part.replace(/^["']|["']$/g, ''));
|
||||
return { cmd: unquoted[0] ?? '', args: unquoted.slice(1) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Command validator
|
||||
*/
|
||||
@@ -74,27 +101,43 @@ export class CommandValidator {
|
||||
private blockedCommands: string[];
|
||||
private blockedPaths: string[];
|
||||
private allowedCommands?: string[];
|
||||
private networkIsolated: boolean;
|
||||
|
||||
constructor(config: Partial<SandboxConfig> = {}) {
|
||||
this.blockedCommands = config.blockedCommands || DEFAULT_BLOCKED_COMMANDS;
|
||||
this.blockedPaths = config.blockedPaths || DEFAULT_BLOCKED_PATHS;
|
||||
this.allowedCommands = config.allowedCommands;
|
||||
this.networkIsolated = config.mode === 'network-isolated';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a command
|
||||
*/
|
||||
validate(command: string): { valid: boolean; reason?: string } {
|
||||
const normalized = command.toLowerCase().trim();
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed) {
|
||||
return { valid: false, reason: 'Empty command' };
|
||||
}
|
||||
|
||||
if (containsShellMetacharacters(trimmed)) {
|
||||
return { valid: false, reason: 'Shell metacharacters are not allowed' };
|
||||
}
|
||||
|
||||
const { cmd } = parseCommandParts(trimmed);
|
||||
const normalized = trimmed.toLowerCase();
|
||||
|
||||
// Check allowlist first if specified
|
||||
if (this.allowedCommands) {
|
||||
const baseCmd = normalized.split(/\s+/)[0];
|
||||
if (!this.allowedCommands.includes(baseCmd)) {
|
||||
return { valid: false, reason: `Command '${baseCmd}' not in allowlist` };
|
||||
if (!this.allowedCommands.includes(cmd.toLowerCase())) {
|
||||
return { valid: false, reason: `Command '${cmd}' not in allowlist` };
|
||||
}
|
||||
}
|
||||
|
||||
// network-isolated: reject network-oriented commands (proxy env alone is insufficient)
|
||||
if (this.networkIsolated && NETWORK_COMMANDS.includes(cmd.toLowerCase())) {
|
||||
return { valid: false, reason: `Network command '${cmd}' blocked in network-isolated mode` };
|
||||
}
|
||||
|
||||
// Check blocked commands
|
||||
for (const blocked of this.blockedCommands) {
|
||||
if (normalized.includes(blocked.toLowerCase())) {
|
||||
@@ -195,20 +238,29 @@ export class SandboxExecutor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the command
|
||||
* Spawn the command without shell when allowlisted or in strict mode
|
||||
*/
|
||||
private async spawn(command: string): Promise<Omit<ExecutionResult, 'duration'>> {
|
||||
const { spawn } = await import('child_process');
|
||||
const env = this.buildEnv();
|
||||
const useDirectSpawn = Boolean(this.config.allowedCommands) || this.config.mode === 'strict';
|
||||
const { cmd, args } = parseCommandParts(command);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const env = this.buildEnv();
|
||||
|
||||
const proc = spawn('sh', ['-c', command], {
|
||||
cwd: this.config.cwd,
|
||||
env,
|
||||
timeout: this.config.timeout,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
const proc = useDirectSpawn
|
||||
? spawn(cmd, args, {
|
||||
cwd: this.config.cwd,
|
||||
env,
|
||||
timeout: this.config.timeout,
|
||||
shell: false,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
: spawn('sh', ['-c', command], {
|
||||
cwd: this.config.cwd,
|
||||
env,
|
||||
timeout: this.config.timeout,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
@@ -275,7 +327,11 @@ export class SandboxExecutor {
|
||||
};
|
||||
|
||||
case 'network-isolated':
|
||||
// No network access (requires additional OS-level setup)
|
||||
// Proxy env vars alone do not block network; also reject network commands in validator.
|
||||
// Strip common proxy/network env vars as a defence-in-depth measure.
|
||||
for (const key of NETWORK_ENV_VARS) {
|
||||
delete baseEnv[key];
|
||||
}
|
||||
return {
|
||||
...baseEnv,
|
||||
http_proxy: 'http://localhost:0',
|
||||
@@ -283,7 +339,7 @@ export class SandboxExecutor {
|
||||
HTTP_PROXY: 'http://localhost:0',
|
||||
HTTPS_PROXY: 'http://localhost:0',
|
||||
no_proxy: '',
|
||||
NO_PROXY: ''
|
||||
NO_PROXY: '',
|
||||
};
|
||||
|
||||
case 'basic':
|
||||
@@ -315,6 +371,7 @@ export class SandboxExecutor {
|
||||
*/
|
||||
setMode(mode: SandboxMode): void {
|
||||
this.config.mode = mode;
|
||||
this.validator = new CommandValidator(this.config);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ export * from './tools/mcpSse';
|
||||
|
||||
// AI SDK Tools Registry
|
||||
export { tools, registerBuiltinTools } from './tools/tools';
|
||||
export { getToolsRegistry, createToolsRegistry, resetToolsRegistry, ToolsRegistry, get_registry, getRegistry, get_tool, getTool, register_tool, registerTool, validate_tool, validateTool } from './tools/registry';
|
||||
export { getToolsRegistry, createToolsRegistry, resetToolsRegistry, ToolsRegistry, get_registry, get_tool, register_tool, validate_tool } from './tools/registry';
|
||||
export type {
|
||||
ToolExecutionContext, ToolLimits, RedactionHooks, ToolLogger,
|
||||
ToolCapabilities, InstallHints, ToolMetadata, ToolExecutionResult,
|
||||
|
||||
@@ -205,6 +205,39 @@ export class MCPSecurity {
|
||||
return Math.max(0, config.requests - state.count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate credentials for a given auth method (fail closed by default)
|
||||
*/
|
||||
private async validateCredentials(
|
||||
auth: { method: AuthMethod; validate?: (token: string) => Promise<boolean> },
|
||||
token: string
|
||||
): Promise<boolean> {
|
||||
switch (auth.method) {
|
||||
case 'none':
|
||||
return true;
|
||||
|
||||
case 'api-key':
|
||||
case 'bearer':
|
||||
if (auth.validate) {
|
||||
return auth.validate(token);
|
||||
}
|
||||
if (this.apiKeys.size === 0) {
|
||||
return false;
|
||||
}
|
||||
return this.validateApiKey(token);
|
||||
|
||||
case 'basic':
|
||||
case 'oauth':
|
||||
if (!auth.validate) {
|
||||
return false;
|
||||
}
|
||||
return auth.validate(token);
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Match policy against request
|
||||
*/
|
||||
@@ -237,21 +270,18 @@ export class MCPSecurity {
|
||||
return { allowed: true };
|
||||
|
||||
case 'authenticate':
|
||||
if (!policy.auth) return { allowed: true };
|
||||
if (!policy.auth) {
|
||||
return { allowed: false, reason: 'Authentication policy misconfigured' };
|
||||
}
|
||||
|
||||
const token = request.headers ? this.extractToken(request.headers) : null;
|
||||
if (!token) {
|
||||
if (!token || !token.trim()) {
|
||||
return { allowed: false, reason: 'Authentication required' };
|
||||
}
|
||||
|
||||
if (policy.auth.method === 'api-key' || policy.auth.method === 'bearer') {
|
||||
const valid = policy.auth.validate
|
||||
? await policy.auth.validate(token)
|
||||
: this.validateApiKey(token);
|
||||
|
||||
if (!valid) {
|
||||
return { allowed: false, reason: 'Invalid credentials' };
|
||||
}
|
||||
const valid = await this.validateCredentials(policy.auth, token);
|
||||
if (!valid) {
|
||||
return { allowed: false, reason: 'Invalid credentials' };
|
||||
}
|
||||
|
||||
return { allowed: true, context: { authenticated: true } };
|
||||
|
||||
@@ -73,6 +73,8 @@ export interface MCPServerConfig {
|
||||
port?: number | null;
|
||||
/** Enable logging */
|
||||
logging?: boolean;
|
||||
/** Optional bearer token for HTTP transport (or set PRAISONAI_MCP_AUTH_TOKEN) */
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,6 +110,7 @@ export class MCPServer {
|
||||
private logging: boolean;
|
||||
private running: boolean = false;
|
||||
private httpServer: any = null;
|
||||
private authToken?: string;
|
||||
|
||||
constructor(config: MCPServerConfig = {}) {
|
||||
this.id = randomUUID();
|
||||
@@ -117,6 +120,7 @@ export class MCPServer {
|
||||
this.resources = new Map();
|
||||
this.prompts = new Map();
|
||||
this.logging = config.logging ?? false;
|
||||
this.authToken = config.authToken ?? process.env.PRAISONAI_MCP_AUTH_TOKEN ?? undefined;
|
||||
|
||||
// Register initial tools
|
||||
if (config.tools) {
|
||||
@@ -397,6 +401,29 @@ export class MCPServer {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify HTTP bearer token when authToken is configured
|
||||
*/
|
||||
private verifyHttpAuth(req: import('http').IncomingMessage): boolean {
|
||||
if (!this.authToken) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization ?? req.headers['Authorization'];
|
||||
if (typeof authHeader === 'string') {
|
||||
if (authHeader.startsWith('Bearer ') && authHeader.slice(7) === this.authToken) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const xAuth = req.headers['x-auth-token'] ?? req.headers['X-Auth-Token'];
|
||||
if (typeof xAuth === 'string' && xAuth === this.authToken) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start HTTP transport
|
||||
*/
|
||||
@@ -408,6 +435,12 @@ export class MCPServer {
|
||||
|
||||
this.httpServer = http.createServer(async (req, res) => {
|
||||
if (req.method === 'POST') {
|
||||
if (!this.verifyHttpAuth(req)) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
||||
return;
|
||||
}
|
||||
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
req.on('end', async () => {
|
||||
|
||||
@@ -201,6 +201,29 @@ export class AgentOS implements AgentOSProtocol {
|
||||
});
|
||||
}
|
||||
|
||||
// Optional API key middleware (except /health)
|
||||
if (this.config.apiKey) {
|
||||
app.use((req: any, res: any, next: any) => {
|
||||
if (req.path === '/health') {
|
||||
return next();
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
|
||||
if (authHeader.slice(7) === this.config.apiKey) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
const xAuth = req.headers['x-auth-token'] ?? req.headers['X-Auth-Token'];
|
||||
if (typeof xAuth === 'string' && xAuth === this.config.apiKey) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
});
|
||||
}
|
||||
|
||||
// Register routes
|
||||
this._registerRoutes(app);
|
||||
|
||||
|
||||
Loaded 100 of 186 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user